Commit Graph

260 Commits

Author SHA1 Message Date
Bohan Jiang
37d9fafda6 feat(issues): add Remove parent issue action (MUL-3764) (#4630)
* feat(issues): add Remove parent issue action to promote a sub-issue to standalone (MUL-3764)

Surfaces a discoverable UI affordance for clearing an issue's parent — the
backend and CLI (multica issue update --parent "") already support it, but
the Official App only exposed Set parent. Adds:

- A 'Remove parent issue' item in the issue actions menu (dropdown +
  right-click), shown only when the issue has a parent.
- A hover unlink button on the parent card in the issue detail sidebar.
- A removeParent handler that clears parent_issue_id and stage in one
  write (stage only orders sub-issues under a parent) with a success toast.

Closes #4629

Co-authored-by: multica-agent <github@multica.ai>

* fix(issues): toast remove-parent on success only, prune old parent's children cache (MUL-3764)

Addresses review feedback on #4630:

- use-issue-actions.ts: the remove-parent success toast fired eagerly after
  mutate(), so a request that failed on permission/network/validation would
  flash "removed" before the error toast and optimistic rollback. Move it to
  onSuccess so only a server-confirmed detach is announced.

- mutations.ts: when a write re-parents an issue away from its current parent,
  prune it from the old parent's children cache instead of patching it to
  parent_issue_id: null in place. The parent's sub-issues list renders that
  array directly, so the orphaned row used to linger until the settle refetch.
  onError still restores prevChildren, so the prune rolls back on failure.

Adds cache-prune coverage (optimistic remove / rollback / non-reparenting
no-op) and onSuccess-vs-onError toast coverage.

Co-authored-by: multica-agent <github@multica.ai>

---------

Co-authored-by: J <j@multica.ai>
Co-authored-by: multica-agent <github@multica.ai>
2026-06-26 23:13:44 +08:00
Naiyuan Qing
f1e6c18e3e fix(issues): add loading state to edit comment save button (MUL-3709) (#4588) 2026-06-26 10:11:21 +08:00
Naiyuan Qing
a7908e6967 fix(issues): sync header agent chip with execution log via shared query (#4498)
The header live chip derived its active-task state from the workspace-wide
agent-task-snapshot, while the right-panel Execution log read the per-issue
task list. Two queries, two endpoints, two independent refetches: the heavier
workspace snapshot lands later than the per-issue list, so the log could show
a running task while the header chip had not started yet.

Point the chip at the same `issueKeys.tasks(issueId)` cache the Execution log
uses (identical query options). Both surfaces now observe one cache entry and
update atomically. Drop the now-redundant workspace-id lookup and client-side
issue_id filter, since the endpoint is already issue-scoped.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-24 13:26:50 +08:00
Naiyuan Qing
3ce97453b3 fix(issues): pre-trigger preview + run-confirm + handoff UX polish (MUL-3375) (#4454)
* fix(issues): stop issue-trigger preview flicker

The pre-trigger preview re-rendered/refetched on every workspace task
event: WS task lifecycle invalidated issueTriggerPreviewAll (staleTime 0),
forcing a background refetch whose isFetching was surfaced as isLoading,
collapsing and reopening CreateRunHint's reveal band.

The assign source (create / assignee change) cancels existing tasks before
enqueuing, so its verdict can't shift from a task event at all; the status
source's pending dedup could, but the preview is advisory and the write
path re-evaluates authoritatively, so a rare stale label is harmless. Drop
the WS invalidation so the preview refetches only on input (signature)
change. Keep the comment-trigger invalidation — its verdict genuinely
changes mid-compose and its chips drive an immediate, unconfirmed send.

Align the hook's data handling with the comment-trigger preview:
keepPreviousData so an input switch swaps in place instead of collapsing,
and treat only the first load (no prior data) as loading.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(issues): skip run-confirm modal for backlog assign

Assigning a Backlog issue to an agent/squad never starts a run (the
parking lot — server/internal/service/issue_trigger.go), so the
pre-trigger confirm modal only rendered an empty "won't start" box with
a single Apply button. Apply directly instead: the single path checks
issue.status, the batch path skips only when every selected issue is
Backlog (mixed selections still confirm — the non-backlog ones trigger).
Mirrors the existing backlog short-circuit in handleBatchStatus.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(modals): run-confirm loading state + submit spinner

The dialog grew in height after open: it rendered the short "won't
start" variant while POST /api/issues/preview-trigger was in flight, then
the note box appeared when the predicate landed. Keep the note box
mounted (disabled) during loading so assign mode opens at its resolved
height, and show a Spinner + 'checking' headline while loading.

Submit had no feedback — buttons only disabled, which read as frozen for
note assigns (the request starts an agent server-side). Track which
footer action is in flight and show a Spinner on the clicked button.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(issues): show handoff note in execution-log trigger text

An assignment-triggered run that carried a handoff note showed the
generic "Initial run" label. Surface the note inline (truncated, like
comment triggers show their text) so the row reads as the handoff.

taskToResponse now populates handoff_note for all callers (dropping the
now-redundant explicit set in ClaimTaskByRuntime); the field is added to
the AgentTask type + zod schema (optional, additive — old clients ignore
it via the loose schema, new clients fall back to "Initial run").

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-23 16:15:44 +08:00
Naiyuan Qing
4ab335b8a5 MUL-3416: Issue pre-trigger preview + Handoff Note (#4383)
* feat(issues): unify run-enqueue decision behind WillEnqueueRun + preview endpoint

Collapse the issue update/batch enqueue copies into one service predicate
service.IssueService.WillEnqueueRun, shared verbatim with a new dry-run
endpoint POST /api/issues/preview-trigger so the four entry points stop
drifting (squad/self-loop/batch omissions, MUL-3375). The private-agent gate
stays at the HTTP boundary: write paths inject allow-all, preview injects the
real gate so it never leaks a private agent's readiness.

Add suppress_run to issue update/batch: the change applies but no run starts.
Remove the now-dead handler mirrors shouldEnqueueSquadLeaderOnAssign /
isSquadLeaderReady. service.Create and the comment trigger chain are untouched.

Tests: preview behavior, preview<->write-path match, batch aggregation,
member no-trigger, suppress_run skip, malformed-body 400.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>

* feat(issues): inject handoff note into assigned runs via first-class task field

Add an optional handoff_note carried by issue assign/promote into the run's
opening prompt and issue_context.md, via a dedicated agent_task_queue column
(migration 122) and a daemon assignment-handoff render branch — never a
fabricated comment, never trigger_comment_id (MUL-3375 §6.1).

Thread the note through enqueueIssueTask/enqueueMentionTask + WithHandoff
public variants and dispatchIssueRun; suppress_run or a parked write drops it
(no run = nothing to inject). Soft version gate: MinHandoffCLIVersion +
HandoffSupported, surfaced per-trigger as handoff_supported in the preview so
the UI can gray the note box on old daemons; the assignment never hard-fails.

Tests: daemon prompt + issue_context render via the assignment branch (not
quick-create/comment), version helper matrix, note persists on the task,
suppressed assign enqueues nothing.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>

* feat(issues): leave a display-only handoff record on the timeline

When an assign/promote with a handoff note starts a run, write one
type='handoff' timeline record via TaskService.RecordHandoff — a direct
Queries.CreateComment + timeline event that bypasses Handler.CreateComment, so
it never reaches triggerTasksForComment and cannot start a second run
(MUL-3375 §6.2, the must-not-retrigger invariant). Author is the actor who
handed off; body is the note. Migration 123 admits the 'handoff' comment type.
Recorded only on a real run start: suppress_run or a parked write writes
nothing. enqueueSquadLeaderTask now reports whether it enqueued so the trace
is gated on an actual dispatch.

Test: exactly one handoff record on assign-with-note, exactly one task (no
re-trigger), and no record when suppressed.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>

* feat(issues): frontend plumbing for issue-trigger preview + handoff (core)

Add api.previewIssueTrigger + IssueTriggerPreviewSchema (zod parseWithFallback),
the use-issue-trigger-preview hook, issueKeys.issueTriggerPreview(+All) with WS
queue-state invalidation, suppress_run/handoff_note on UpdateIssueRequest, the
'handoff' CommentType, and stripping of the control fields from optimistic
update/batch cache patches (MUL-3375 §9).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>

* fix(issues): exclude handoff records from new-comment counting

type='handoff' is a display-only timeline record, not conversation. Exclude it
from CountNewCommentsSince so a handoff note never inflates the count of
"new comments to catch up on" fed to a claiming agent (MUL-3375 §12). Analytics
already excludes it (RecordHandoff is a direct write that emits no analytics
event), and the comment-trigger path is already bypassed.

Test: a handoff record does not bump the new-comment count; a real comment does.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>

* feat(issues): pre-trigger preview UI, handoff note, timeline card (web/desktop)

Wire the §9 frontend onto the preview endpoint + handoff fields:
- Delete the backlog blocking dialog (backlog-agent-hint*) and its modal type;
  the over-eager nag is gone. Backlog awareness is now a passive label.
- RunConfirmModal: single assign + batch assign/status route here. Shows the
  backend predicate's verdict ("将启动 @X" / "将启动 N 个" / parked), an optional
  handoff note (assign only, soft-gated by handoff_supported), and 暂不启动 —
  then applies via update/batch. No frontend guessing.
- create modal: passive CreateRunHint ("将启动 @X" / backlog parked).
- single status change stays a direct apply (unchanged).
- timeline: render type='handoff' as a distinct, non-interactive handoff card.
- i18n run_confirm + handoff_card across en/ja/ko/zh-Hans; drop backlog action
  keys; locale parity green.

Tests: use-issue-actions (assign → run-confirm modal, member → direct),
create-issue + comment-card suites updated/green; views typecheck + lint clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>

* test(issues): use a valid anchor in the handoff count-exclusion test

CountNewCommentsSince filters id <> @anchor_id; SQL id <> NULL is NULL and
excludes every row, so an empty anchor made the control assertion read 0. The
production caller always passes a real anchor — mirror that with a non-matching
sentinel uuid.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>

* test(issues): RunConfirmModal apply logic (start/suppress/note-gate/batch)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>

* test(core): preview schema malformed/missing/null fallback coverage

Cover IssueTriggerPreviewSchema via parseWithFallback (MUL-3375): well-formed
parse, top-level + item default fills (empty/older backend), and fallback to
{ triggers: [], total_count: 0 } for malformed shapes, a dropped required
issue_id, a wrong-typed total_count, and null/non-object bodies — so the four
entry points degrade to "nothing will start" instead of throwing.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>

* refactor(issues): remove display-only handoff timeline record (留痕)

The handoff "留痕" timeline record (type='handoff' comment written on run
start) was judged superfluous and dropped per product call. This removes
only the display-only trace; the handoff NOTE injection into the run's
opening prompt + issue_context.md is untouched.

- backend: drop RecordHandoff + its call in dispatchIssueRun
- db: drop the `type <> 'handoff'` exclusion in CountNewCommentsSince and
  migration 123 (comment_type_check reverts to the 4-type set from 001);
  no production data exists for this unreleased feature
- frontend: drop the "handoff" CommentType, HandoffCard, and handoff_card
  i18n (all locales)
- tests: drop handoff_count_test.go and the record-write assertions in
  issue_trigger_preview_test.go (note-injection tests retained)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>

* feat(issues): dismissable run-confirm modal + team-handoff copy

Two fixes to the pre-trigger confirm modal (MUL-3375).

1. Dismissable: switch RunConfirmModal from AlertDialog to the standard
   shadcn Dialog so it has the close (X) button + Esc + click-outside.
   Previously the only choices were "start" / "don't start now" with no
   way to abort the action entirely; dismissing now cancels with no write.

2. Copy: rework the action-surface wording away from the backend term
   "run" toward team-handoff voice — 指派 / 开始 / 交接 (run stays only on
   record surfaces). Unifies the note's three names to "交接说明", and
   parallels the rewrite across en/ja/ko.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>

* chore(agent): bump handoff note min CLI version to 0.3.28

The daemon release that renders handoff notes ships in 0.3.28 (0.3.27
was the prior tag), so move the soft-gate threshold up. Below this the
note is silently dropped and the frontend grays the note box — assignment
is never blocked.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(issues): skip run-confirm when batch-moving issues to backlog

A move into backlog never starts a run (service/issue_trigger.go), so the
pre-trigger confirm modal degenerated to an empty "won't start" box with a
single Apply button — pure friction. Apply directly instead, matching the
single-issue status path. Other target statuses still route through the
modal.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(issues): refine pre-trigger preview hint and copy

- Move the create-issue run hint to a reveal band (grid 0fr→1fr) above the
  property toolbar. It was sharing the footer button row and, lacking a
  width constraint, reflowed the submit buttons whenever it appeared.
  Restyle to a borderless, comment-style avatar+caption that is purely a
  caption (non-interactive avatar).
- Distinguish squad from agent in the pre-trigger copy: a squad's leader
  evaluates and delegates rather than "starting work" itself. Add
  will_start_named_squad / will_start_squad / create_will_start_squad across
  en/zh/ja/ko (reusing the squad_leader_* evaluate→arrange vocabulary) and
  branch run-confirm + the create hint on squad assignees.
- Bold the assignee name in the run-confirm headline via a language-safe
  sentinel split (no per-language prefix/suffix keys).
- Align zh "开始处理" → "开始工作" on the single-assign copy.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* test(issues): stub ActorAvatar in create-issue suite

CreateRunHint now renders an ActorAvatar for agent/squad assignees, which
pulls in getActorInitials/getActorAvatarUrl + the workspace/presence/navigation
hook tree. This form-focused suite only stubbed getActorName, so the
squad-forwarding test crashed with "getActorInitials is not a function". Stub
the avatar inert — its own behavior is covered elsewhere.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Walt <walt@multica.ai>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>
2026-06-23 13:17:13 +08:00
honki12345
4679217586 feat(cli): STR-208 오토파일럿 구독자 플래그 추가 (#4438)
* feat(cli): STR-208 오토파일럿 구독자 플래그 추가

* test(core): Issue fixture stage 기본값 추가

* test(views): Issue fixture stage 기본값 추가
2026-06-23 11:56:09 +08:00
Naiyuan Qing
45dae3185f fix(issues): eliminate optimistic-update drag flicker (board, list, batch, WS) (#4415)
* fix(issues): stop kanban card snapping back on drag

A cross-column drag on a non-position-sorted board left the card in its
origin column for the whole request, then jumped to the target only when
the mutation settled — the "snaps back, then moves" glitch. Root cause was
three coupled choices in the optimistic path:

- board-view never updated local columns on drop for sortBy != "position"
  (onDragOver is a no-op there), so the card relied on the settle refetch
  to move across.
- useUpdateIssue invalidated the whole list on settle, replacing the column
  and re-landing the card even on success.
- patchIssueInBuckets appended a moved card to the column tail instead of
  its position slot, so any later cache refresh teleported it to the end.

Fixes:
- board-view: optimistically move the card into the target column on drop
  for the non-position path (insertIdByPosition), and reconcile local
  columns from the cache on settle for both paths (revert on error now that
  the list is no longer refetched).
- mutations: reconcile via onSuccess surgical patch of the returned entity;
  drop the list/detail invalidation from onSettled (aggregates still flush).
- cache-helpers: patchIssueInBuckets inserts the moved/reordered card at its
  position slot; a plain field update still keeps its slot.

Adds cache-helpers and drag-utils unit tests.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>

* fix(issues): patch My-Issues / Project board caches on move too

The drag fix made the board reconcile local columns from its feeding cache
on settle. The workspace board rides issueKeys.list (patched by onMutate),
but the My-Issues and Project boards ride the myList cache, which the
mutation did not patch — so a successful move snapped back on those boards.

useUpdateIssue now patches/snapshots/rolls back every bucketed list cache
(workspace list + myList), selected by the ListIssuesCache `byStatus` shape
so grouped (assignee) and flat (gantt) caches are skipped. Adds renderHook
regression tests covering both-cache optimistic move, both-cache rollback,
and no-list-invalidation-on-settle.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>

* fix(issues): drop redundant WS position->list invalidate

onIssueUpdated already surgically patches the non-filtered workspace board
via patchIssueInBuckets (cross-status move + same-column reorder). The extra
`if (position) invalidateQueries(list)` re-pulled the whole board on top of
that, re-introducing drag flicker through the echoed-back WS event. Removed.
Filtered myAll lists still invalidate (membership can change there) — the
client-side membership reconciliation for those is a separate follow-up.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>

* fix(issues): batch update patches myList + stops list refetch on settle

- onMutate now patches both issueKeys.list and the filtered issueKeys.myAll
  bucketed caches, so a batch edit on a My-Issues / Project board is
  optimistic too. Previously only the workspace board was patched, so batch
  edits on those boards relied entirely on the settle refetch.
- onSettled no longer invalidates issueKeys.list: the optimistic patch is a
  complete reconcile for these bucketed boards (batch changes status /
  priority / project, never a server-computed value), so a full-board
  refetch only re-introduced the flicker the single-issue path removed.
  Aggregate / grouped caches still refresh.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>

* fix(issues): list view optimistically moves row on non-position drag

The sortBy != "position" branch called onMoveIssue without moving the row in
local columns, so the row sat in its origin group for the whole request and
only jumped across on settle -- the same snap-back the board view had before
its fix. Now mirrors board-view: setColumns(insertIdByPosition) on drop so
the settle rebuild is a visual no-op.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>

* fix(issues): keep My-Issues/Project boards in place on non-membership WS change

onIssueUpdated now surgically patches the filtered myList (myAll) caches and
only invalidates them when the change can actually move an issue in/out of the
filter: an assignee change (covers My-Issues direct-assignee + the involves leg
+ actor panels) or a project change (Project board). A pure status / position /
priority / label change reconciles in place -- no refetch -- removing the last
drag flicker on filtered boards.

Uses the assignee_changed flag the server already sends on issue:updated
(surfaced on IssueUpdatedPayload + forwarded by the realtime dispatch); project
change is diffed client-side against the cached value. No predicate replication,
no backend change.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>

* fix(issues): add settle-lock to swimlane drag (no clobber mid-flight)

The swimlane drag had no settle window: the resync useEffect (and the issueMap
freeze) guarded only isDraggingRef, so a cache change landing after drop but
before the move settled could rebuild localCells out from under the optimistic
move. Adds isSettlingRef + settleVersion (mirroring board-view / list-view): the
lock is held from drop until onMoveIssue settles, then released, forcing a
single resync from the reconciled cache.

onMoveIssue now accepts the same optional onSettled callback board/list already
use; the parent handleMoveIssue supplies it.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>

* refactor(issues): extract shared useDragSettle hook for board + list

board-view and list-view carried byte-identical drag/settle scaffolding (the
local columns mirror, the dragging/settling locks, the post-move animation-frame
throttle, and the settle callback). That duplication is exactly what let
list-view silently drift earlier (it had lost the optimistic-move half of the
fix, and its position-branch settle callback omitted the settleVersion bump).
Extract the primitive into useDragSettle so both surfaces share one
implementation and can't drift again.

Behavior-preserving for board-view. For list-view the one intended alignment:
its position-branch failed move now reverts, gaining the settleVersion bump
board-view already had.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>
2026-06-23 09:20:01 +08:00
Bohan Jiang
3a814bd74a fix(issues): refine Stage field icon and dropdown font (#4425)
Replace the # (Hash) icon for the Stage property with the Milestone icon
across the picker trigger, dropdown option rows, and the Add-property menu.

Shrink the Stage dropdown option font to text-xs (scoped to the Stage
picker only; the shared PickerItem keeps text-sm so other property
dropdowns are unaffected).
2026-06-23 00:41:36 +08:00
Bohan Jiang
a123dfc2df MUL-3508: stage sub-issues so the parent wakes per stage, not per child (#4410)
* feat(issues): stage sub-issues so the parent wakes per stage, not per child

Sub-issues under a parent can be grouped into ordered stages (issue.stage).
The child-done -> parent notification + assignee wake now fire only when a
stage barrier closes: every sub-issue in the lowest unfinished stage has
reached a terminal status (done/cancelled). An unstaged sibling set is one
implicit stage, so the parent is woken once when the last sub-issue finishes
instead of on every child — the default fix for the fire-on-every-child
cascade reported in discussion #4320 / MUL-3508.

Stage advancement stays agent-driven: the server only detects the closed
barrier and wakes the parent assignee, who decides whether to promote the
next stage.

- DB: nullable issue.stage (CHECK >= 1) + sqlc regen
- API: stage on issue create/update/response and batch update
- CLI: `issue create`/`issue update` --stage; new `issue children` command
  that lists sub-issues grouped by stage (table + json)
- stageBarrierClosed / stageProgressSummary in issue_child_done.go, with the
  wake comment now stage-aware, plus unit tests
- skill docs (multica-working-on-issues SKILL.md + source map)

Web UI (create-form stage picker, sidebar edit, group-by-stage display) is a
follow-up; the API already returns stage for it to consume.

MUL-3508

Co-authored-by: multica-agent <github@multica.ai>

* fix(issues): address review on stage barrier (cancel, batch, unstaged)

Resolves the three blockers from the PR review:

1. Cancel can close a stage. The child-done barrier now fires on any
   non-terminal -> terminal transition (done OR cancelled), not just done.
   isTerminalChildStatus already treats cancelled as terminal (a cancelled
   sibling never finishes, so it must not hold a stage open), so a cancelled
   last-open child now closes its stage and wakes the parent. Keying on the
   transition also makes a later cancelled -> done edit a no-op, avoiding a
   lagging duplicate wake.

2. Batch update of stage no longer no-ops. `hasMutation` now includes
   "stage", so `{"updates":{"stage":N}}` persists instead of returning
   {"updated": 0}.

3. Unstaged children no longer participate in the staged frontier. In a
   staged sibling set, NULL-stage children neither hold a stage open nor fire
   on their own completion, and the wake comment no longer renders "Stage 0".
   This matches migration 123 ("NULL does not participate in staged
   grouping") and the CLI's separate unstaged group, removing the footgun
   where an unstaged backlog child silently blocked Stage 1.

Tests: cancellation closes a stage (staged + unstaged), unstaged ignored in a
staged set, stage summary skips unstaged, and a stage-only batch update
persists.

MUL-3508

Co-authored-by: multica-agent <github@multica.ai>

* feat(web): stage UI — create picker, sidebar edit, group sub-issues by stage

Frontend for the sub-issue stage feature (web + desktop, shared via packages):

- core: `stage` on the Issue type + create/update request types; zod
  IssueSchema parses it (defaults to null for older backends) with schema
  tests for the numeric and omitted cases.
- StagePicker component (mirrors the other property pickers): "No stage" +
  Stage 1..N, offering one beyond the current/sibling max.
- Create-issue modal: a Stage pill, shown only when a parent is selected,
  threaded into the create payload.
- Issue detail sidebar: an editable Stage row + "add property" entry, gated to
  sub-issues (issues with a parent).
- Sub-issue list grouped by stage with per-stage headers (flat when unstaged).
- i18n: stage keys across en / zh-Hans / ja / ko (parity test passes).

Verified: full typecheck (6/6), core (591) + views (1433) vitest suites,
lint clean (no new findings). Backend/CLI shipped earlier in this PR.

MUL-3508

Co-authored-by: multica-agent <github@multica.ai>

* test(issues): add stage to Issue fixtures merged from main

The merge brought in new Issue fixtures that predate the required `stage`
field: core issues/batch.test.ts, views batch-action-toolbar.test.tsx, and
the mobile EMPTY_ISSUE_FALLBACK sentinel. Add `stage: null` so they satisfy
the Issue type (mobile reuses core's IssueSchema for parsing, so only the
sentinel needs it).

MUL-3508

Co-authored-by: multica-agent <github@multica.ai>

* fix(web): feed StagePicker the sibling max stage so higher stages stay selectable

The StagePicker accepts maxStage to extend its option list beyond the
floored Stage 1-3, but neither call site passed it, so a parent with an
existing Stage 4/5 child could not pick that stage when creating a new
sub-issue or editing one in the sidebar.

- Compute the sibling max stage at both call sites: the create modal now
  loads the parent's children (childIssuesOptions) and the detail sidebar
  reuses the already-loaded parentChildIssues.
- Extract maxSiblingStage + stageOptions as pure helpers on stage-picker
  and unit-test them (the regression: a Stage 5 sibling keeps Stage 5
  selectable and offers Stage 6).

MUL-3508

Co-authored-by: multica-agent <github@multica.ai>

---------

Co-authored-by: J <j@multica.ai>
Co-authored-by: multica-agent <github@multica.ai>
2026-06-23 00:14:42 +08:00
Naiyuan Qing
149cc9bd0a fix(issues): reflect real common value in batch toolbar pickers (#4403)
The batch action toolbar hardcoded status="todo", priority="none", and a
null assignee, so the status/priority/assignee pickers always checked a
fixed row regardless of the selected issues. The batch write itself worked,
but the picker mis-reported the current value, surfacing as "status always
defaults to todo" (MUL-3510). The same defect applied to priority and
assignee, across all five toolbar mount points.

Derive the shared status/priority/assignee of the selected issues via a new
commonIssueFields helper and feed it to the pickers; when the selection is
mixed, pass an empty value so no row is checked. Pickers now accept a
nullable current value, and AssigneePicker gains a `mixed` flag to
distinguish an all-unassigned selection (check "No assignee") from a mixed
one (check nothing). Each call site passes its issue universe, mirroring the
skill list's selected-rows approach.

Adds unit tests for commonIssueFields and a toolbar picker-wiring test.

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>
2026-06-22 18:23:05 +08:00
Jiayuan Zhang
916cee5c5d feat(issues): open agent activity chip on hover (#4405)
The header 'agent is working' chip previously required a click to reveal the
activity card. Open it on hover instead so the live signal reads as a
glanceable status surface. The hover config lives on Base UI's Popover.Trigger
(openOnHover + delay/closeDelay), and the trigger stays a real button so
click/keyboard access is retained for touch and a11y.

Add a regression test asserting openOnHover is wired on the trigger so a
click-only implementation can no longer pass.

MUL-3507

Co-authored-by: Lambda <lambda@multica.ai>
Co-authored-by: multica-agent <github@multica.ai>
2026-06-22 17:06:15 +08:00
Jiayuan Zhang
c0c41fa0b4 fix(views): gate right sidebar motion to toggles (#4335) 2026-06-19 16:40:49 +08:00
Jiayuan Zhang
27fcbb015f Polish desktop sidebar motion
Polish desktop chrome/sidebar alignment and add motion-based transitions for left and right sidebars.
2026-06-19 06:26:14 +02:00
Naiyuan Qing
4c7fefd143 fix(editor): cap issue mention chip at container width, no clickable gap (#4295)
#4288 swapped the chip cap from a fixed `max-w-72` to `max-w-[min(18rem,100%)]`.
A percentage max-width on a flex item is dropped while its flex-container wrapper
(`<a class="inline-flex">`) computes its own max-content size, so the wrapper
ballooned to the untruncated title width while the chip truncated to the cap —
leaving an empty, clickable strip after the visible chip.

Fix it as standard atomic-inline behavior instead of a fixed magic cap:
- IssueChip caps at `max-w-full` with the title truncating to an ellipsis, so it
  wraps to the next line as a unit and only truncates once a whole line can't
  hold it.
- Drop `inline-flex` from the editor NodeView `<a>` (issue + project) and the
  markdown AppLink so the chip's only wrapper is a plain inline box with a
  definite (line-based) percentage basis — no second flex container to balloon.

ProjectChip uses a definite `max-w-72`, so it never hit the gap; left as-is.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-18 14:11:07 +08:00
Bohan Jiang
02c0298c18 MUL-3405: constrain issue mention chips in chat (#4288)
* fix: constrain issue mention chips

Co-authored-by: multica-agent <github@multica.ai>

* docs: update issue chip width comment

Co-authored-by: multica-agent <github@multica.ai>

---------

Co-authored-by: J <j@multica.ai>
Co-authored-by: multica-agent <github@multica.ai>
2026-06-18 11:26:05 +08:00
Naiyuan Qing
b7857a6aa3 feat(chat): workspace-scoped attachment binding + fire-and-forget send (#4249)
* feat(chat): workspace-scoped attachment binding + fire-and-forget send

Uploads are now workspace-scoped: the chat session is created and
attachments are bound to the message at send time, so a paste/drop no
longer creates an empty session the user never sends.

- LinkAttachmentsToChatMessage returns the ids it actually bound; the
  client diffs requested-vs-bound and warns on partial bind, replacing
  an extra listChatMessagesPage fetch.
- Cancelling an empty chat task detaches attachments before deleting the
  user message (attachment FK is ON DELETE CASCADE) and returns them via
  cancelled_chat_message.attachments, so a restored draft can re-bind.
- SendChatMessageResponse.attachment_ids has no omitempty: "requested but
  bound zero" serializes [] so the client can tell it apart from an older
  server and still warn.
- Send is fire-and-forget: it no longer steals focus when the user has
  navigated to another session (guarded on the live store + new-chat agent
  id); the reply surfaces via the unread dot. commitInput gets clearEditor
  so a navigated-away commit doesn't wipe the editor now showing another
  session, while still clearing the sent draft's data.
- Draft restore is session-aware so a failed fire-and-forget send restores
  into the session it was sent from, never the one the user moved to.
- Removed the now-unreferenced migrateInputDraft store action.

Verified: core/views typecheck, chat-input (15) / store (3) / api client
(24) unit tests, go build + vet, handler SendChatMessage + CancelTaskByUser
DB tests. Full make check / E2E left to CI.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* test(chat): guard attachment survival on empty-chat cancel

Cancelling an empty chat task deletes the user message, and
attachment.chat_message_id is ON DELETE CASCADE (migration 083), so the
detach-before-delete in finalizeCancelledChatMessage is the only thing
keeping the user's attachment from being silently destroyed. Nothing
covered it.

Add a DB regression test that binds an attachment to the cancelled user
message and asserts: the row survives the cascade (chat_message_id NULL,
chat_session_id retained), the cancel response returns it via
cancelled_chat_message.attachments, and a resend re-binds it to the new
message. Verified red when the detach step is removed.

Related issue: MUL-3364

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>

* fix(comment): pessimistic submit for comment/reply composers

The comment and reply composers cleared the editor after `await onSubmit`
returned, with no in-flight lock. On a slow send the WS `comment:created`
event already dropped the real comment into the timeline while the box
still held the same text + spinner, so it read as two comments. And
because `submitComment`/`submitReply` swallow errors (toast, no rethrow),
a failed send still reached `clearContent` and silently discarded the
user's draft.

Recover the comment/reply portion of the closed #4236: make the submit
callback resolve a success boolean (true on success, false on the caught
failure), lock the editor while in flight (pointer-events-none + dimmed
wrapper + aria-busy, since ContentEditor can't toggle Tiptap `editable`
post-mount), keep the button spinning, and clear only on success — a
failed send keeps the draft. Chat composer is out of scope (already
reworked on this branch); attachment binding is untouched.

Adds two view tests (in-flight lock then clear-on-success; failed send
keeps the draft); both verified red against the un-fixed code.

Related issue: MUL-3364

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>
2026-06-18 09:40:38 +08:00
Jiayuan Zhang
63b9b10df5 MUL-3328: add retry button for failed agent comments (#4217)
* MUL-3328: add retry button for failed agent comments

Co-authored-by: multica-agent <github@multica.ai>

* MUL-3328: cover child-done system comments

Co-authored-by: multica-agent <github@multica.ai>

* MUL-3328: restrict retry affordance to failures

Co-authored-by: multica-agent <github@multica.ai>

* MUL-3328: clean migration whitespace

Co-authored-by: multica-agent <github@multica.ai>

---------

Co-authored-by: Lambda <lambda@multica.ai>
Co-authored-by: multica-agent <github@multica.ai>
2026-06-17 15:18:44 +02:00
Jiayuan Zhang
59263df748 feat(issues): unify trigger chip copy to will-start phrasing (#4215)
* feat(issues): unify trigger chip copy to will-start phrasing

Make the comment trigger chip's on/off states symmetric around the verb
'start' instead of mixing natural language with the 'trigger' jargon:

- on:           Will start when sent
- skipped:      Won't start this time
- all skipped:  No agents will start
- multi on:     N agents will start when sent

Updates all four locales (en/zh-Hans/ja/ko); CJK on-state copy already
reads as future-conditional so only the skip states are realigned to the
'start' verb. Updates the component test expectations to match.

Refs MUL-3211

Co-authored-by: multica-agent <github@multica.ai>

* feat(issues): align restore hint to will-start phrasing

Carry the trigger-chip copy unification into the suppressed-agent restore
hint (trigger_click_to_restore), the last surface still mixing 'trigger'
with the chip's 'start' wording:

- en: Won't start this time. Click to restore.
- CJK: skip-state term realigned to the 'start' verb, rest unchanged.

Refs MUL-3211

Co-authored-by: multica-agent <github@multica.ai>

* feat(issues): trim trigger preview popover copy and drop redundant reason lines

The active hover popover stacked header + reason + presence, repeating the
same fact across lines and again against the chip. Tighten it:

- Drop the reason line for assignee / @mention: the header (name · source)
  already conveys why they fire. Keep reason only for squad-leader (the link
  is non-obvious) and the unknown fallback, both trimmed of the duplicated
  name.
- Shorten presence (Starts right away. / Offline now — starts once online.)
  and de-jargon the skip/manage hints (no more 'trigger').
- Align the popover title to the chip wording (Will start when sent).

All four locales updated; removes the two now-unused reason keys.

Refs MUL-3211

Co-authored-by: multica-agent <github@multica.ai>

---------

Co-authored-by: Lambda <lambda@multica.ai>
Co-authored-by: multica-agent <github@multica.ai>
2026-06-17 09:12:45 +02:00
Wes
d26cac0008 Guard issue icons against unknown values (#4206) 2026-06-17 14:56:10 +08:00
Wes
3aaca155e7 Fix transcript actions on touch devices (#4161) 2026-06-16 17:06:14 +08:00
Naiyuan Qing
7c71007e6e refactor(comments): trim trigger preview copy and unify composer buttons (#4174)
Two related cleanups to the issue comment/reply/edit composer:

- Drop the trigger-preview "context" copy added in #4147 (chip prefix
  `trigger_context_*` and per-context popover titles `trigger_preview_title_*`).
  The actual "align context" fix in #4147 was the backend/hook work; the copy
  was redundant decoration. Removes the `context` prop, the dead i18n keys
  across en/zh/ja/ko, and the corresponding test assertions; the popover title
  falls back to the original single `trigger_preview_title`.

- Edit-comment footer: lay the trigger chip on a single row with the action
  cluster (📎 Cancel Save) on the right, attachments on their own full-width
  row above. The 📎 now sits with the action buttons, matching the new-comment
  and reply composers.

- Unify composer buttons on shadcn `Button`: `FileUploadButton` renders a
  ghost icon button instead of a hand-rolled circle, and the reply submit
  button uses `Button` (icon-xs, ghost-when-empty / primary-when-typed) instead
  of a hand-rolled element. Sizes: 📎 and reply submit are both icon-xs (24px).

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 09:44:05 +08:00
DimaS
2f24057bc2 feat(issues): add date filter (#4129)
Co-authored-by: “646826” <“646826@gmail.com”>
2026-06-16 08:38:53 +08:00
Naiyuan Qing
1afa493165 fix(comments): align trigger preview context (#4147)
Co-authored-by: multica-agent <github@multica.ai>
2026-06-16 08:20:15 +08:00
Naiyuan Qing
76c687d39a fix(markdown): allow attachment download file-card hrefs (#4145)
Co-authored-by: multica-agent <github@multica.ai>
2026-06-15 16:47:11 +08:00
Naiyuan Qing
ea4f816ce2 fix(comments): support edit trigger suppression (#4136)
Co-authored-by: multica-agent <github@multica.ai>
2026-06-15 15:12:45 +08:00
Fangfei
40b318e3e0 fix(issues): restore issue detail scroll on back (MUL-2841) (#3539)
* Fix issue detail scroll restoration

* Fix highlight scroll restore regression

* Fix saved highlight scroll restoration
2026-06-15 15:00:53 +08:00
Naiyuan Qing
63cf0ed308 feat(lists): rebuild all six list surfaces on a shared Linear-style list grid (#4038)
* fix(issues): render thread replies in chronological order (#3691)

collectThreadReplies walked the parent_id tree depth-first, so an agent
reply forced to nest under its trigger comment rendered before earlier
sibling replies (A-D-B-C instead of A-B-C-D) whenever the agent returned
late. Sort the collected subtree by created_at (id tie-break) so the
thread reads in arrival order — the same order the server already feeds
agents via `comment list --thread` (ListThreadCommentsForIssue).

All other consumers of the array (resolution derivation, fold bars,
counts, deep-link) are order-independent.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(skills): rebuild skills list on shared Linear-style list grid

- new ListGrid primitives (subgrid: single source of truth for column tracks)
- skills list: sortable columns, used-by avatar stack, source/creator columns,
  row kebab + batch toolbar with add-to-agent and delete
- skill view store in core; addAgentSkills client method; HoverCheck extracted
  to views/common (issues header now imports the shared copy)
- locale keys for list actions/filters and the reworked detail page

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(skills): rework detail page into overview/files tabs

- tabs directly under the breadcrumb header: overview (default) and files
- overview: identity block + rendered SKILL.md as the main column, right
  rail with metadata card (source/creator/updated, inline name+description
  edit toggle) and used-by panel with bind/unbind
- files: file tree + viewer/editor unchanged; SKILL.md "edit" jumps here
- header kebab menu (copy skill ID, delete); page-level save bar shared by
  both tabs; tab state persisted in ?tab=
- file tree: ARIA tree roles + roving-tabindex keyboard navigation
- drop the old right sidebar (metadata dl, permissions paragraph)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* revert(skills): restore detail page to main, keep branch list-only

Drop the overview/files tabs rework from this branch so the PR scope is
the list rebuild only. skill-detail-page.tsx and file-tree.tsx are back
to the main versions; the locale detail/file_tree sections are restored
to match. The detail rework is preserved on stash/skills-detail-tabs
for a follow-up PR.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(skills): drop description column from skills list

Description is agent-facing routing metadata, not a scannable list
property — Linear's display options expose no description column for
the same reason. Removes the cell, column key, display toggle, lg grid
track, skeleton cells, and the now-dead table.description /
table.no_description locale keys.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(skills): drive list column hiding by container width, drop by priority

Replace viewport sm:/lg: breakpoints with Tailwind v4 container query
variants (@2xl/@4xl) on the list wrapper, so an open sidebar or split
pane narrows the column set instead of squashing tracks. Remove the
min-w-fit + overflow-x-auto horizontal-scroll fallback: when space runs
out, low-priority columns (created/source/creator, then updated) drop
and return as the container widens; name and usedBy never drop. ListGrid
conventions comment updated — this is the template for all list pages.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(skills): virtualize list rows with @tanstack/react-virtual

Linear-style headless virtualization: the virtualizer computes the
visible index range and offsets; offsets land as padding on the
scrolling ListGridBody so mounted rows stay direct subgrid children and
column alignment is untouched. Fixed 48px rows skip per-row measurement.

Hideable column tracks move from max-content to deterministic widths
(CSS vars) — with only the visible slice mounted, content-driven tracks
would resize during scroll. A user-hidden column zeroes its var so the
track still collapses; per-cell max-w caps move into the tracks.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(skills): list tiers must fit their container trigger width

The @4xl tier's track sum (~1080px with gaps) exceeded its 896px
trigger; with the horizontal-scroll fallback gone, the right-side
columns were clipped unreachably between 896-1080px. Move tier 3 to
@5xl (1024px), trim usedBy/source/creator tracks, and document the
fit invariant with its arithmetic next to the template and in the
ListGrid conventions.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(skills): show description as subtext under the skill name

Lives in the name track as a second truncated line (max-w 36rem,
title attr for the full text) — no track, no header, no slot in the
responsive arithmetic. Both lines fit the fixed 48px row, so the
virtualizer contract is untouched; rows without a description center
the name.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Revert "feat(skills): show description as subtext under the skill name"

This reverts commit f39721301b.

* fix(skills): anchor batch toolbar to the page, not the viewport

fixed bottom-6 left-1/2 centered the bar on the window; with the
sidebar open the list's visual center sits ~120px right of the window
center, so the bar looked off-center (worse with desktop split panes).
Page root becomes the positioning context (relative) and the bar uses
absolute — same rule applies to future list pages.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(skills): show matching count next to search while list is narrowed

"n / total" appears right of the search box only when search or
filters are active — idle state would duplicate the total already in
the page header.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(autopilots): derive trigger kinds, next run, last run status in list

The list endpoint only selected the autopilot table, so the list UI
could not answer "is this automation working" without N+1 detail
calls. Each list row now carries trigger_kinds + next_run_at (enabled
triggers only — the columns describe how it fires today) and
last_run_status (most recent run). Fields are omitempty and absent
from detail/create/update responses; clients must treat them as
optional per the API compatibility rules.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(autopilots): list schema, parsed client, and view store in core

- listAutopilots now runs through parseWithFallback with a zod schema
  (this endpoint was a bare fetch — overdue per the API compatibility
  rules); malformed bodies degrade to an empty list, old-server rows
  without assignee_type or the new derived fields parse cleanly, and
  enum drift passes through as plain strings
- Autopilot type gains the three optional list-only derived fields
- New autopilots view store (scope/sort/columns/filters, persisted per
  workspace): status is the promoted scope dimension so it does NOT
  appear in filters — one dimension lives in exactly one place

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(autopilots): rebuild list on shared ListGrid with scope buttons

Same skeleton as the skills list (container-query tiers, deterministic
var-width tracks with documented fit arithmetic, virtualized 48px rows,
sortable headers, filter + display toolbar, page-anchored batch
toolbar), plus the autopilots-specific pieces:

- Status is the promoted SCOPE dimension: 全部/运行中/已暂停/已归档
  segmented buttons with full-set counts; "all" = active+paused
  (archived gets its own visible home, Linear archive semantics);
  status is therefore absent from the filter dropdown
- Columns: name (paused marker inline), assignee (agent/squad),
  trigger kind badges, last run (outcome dot + time, enum-drift safe
  default), next run; mode/creator/created opt-in hidden
- Filters: assignee, trigger kind, mode, creator (composite type:id
  values for polymorphic actors); sort name/lastRun/nextRun/created
  with lastRun desc default
- Row kebab (pause/resume/archive/unarchive/delete) and batch toolbar
  share one delete dialog; status changes ride useUpdateAutopilot's
  optimistic cache
- Fix noUncheckedIndexedAccess errors the branch had never typechecked
  (skills virtual rows, UsedByCell, added_toast)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(autopilots): scope buttons follow the issues header pattern

Replace the bespoke segmented-pill control with the existing scope
button convention from the issues page: outline buttons with bg-accent
active state on md+, collapsing to a radio dropdown below md. Counts
stay (stage inventories from the full set).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(skills,autopilots): toolbar small-screen treatment follows issues header

Below md: the search box (and its result count) disappear entirely,
and the filter/display controls collapse to square icon-only buttons
(labels and the clear-X are md+), matching the issues header's
responsive pattern.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(skills,autopilots): two-zone columns — WYSIWYG with scroll escape valve

Static width tiers silently hid user-enabled columns (toggle on,
nothing appears — autopilots' mode/creator/created sat behind a 1280px
container gate no laptop reaches; skills' source/created behind
1024px). Tiers can't know how many columns are enabled, so the
mechanism is replaced, not retuned:

- ≥@2xl container: every enabled column renders; the grid carries
  min-width = Σ(enabled tracks + gaps) (pure constants, no
  measurement) and the wrapper scrolls horizontally only when the
  enabled set outgrows the container
- <@2xl: static core set (skills: name+usedBy; autopilots:
  name+assignee), no scroll, toggles don't apply

Per-tier templates and the hand-maintained fit arithmetic retire;
ListGrid conventions updated accordingly.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(skills,autopilots): widen name column minimums (120px base, 200px wide)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(autopilots): drop the archived scope and the list search box

Archiving never existed as a UI flow (the DB status value is only
reachable via direct API; the detail page disables its switch when
archived), so the list stops inventing it: no archived scope, no
archive/unarchive row or batch actions. API-archived rows are excluded
everywhere; a persisted retired scope value falls back to "all".
The search box goes too — scope buttons already partition the small
set, search is redundant (product call). Skills keeps its search (no
scope there).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(skills,autopilots): quiet outline create buttons in page headers

Page-header chrome shouldn't carry the loudest element on the page:
the create button becomes outline with text on md+ and collapses to a
square plus icon below md (same responsive treatment as the toolbar
controls). Primary stays reserved for empty-state CTAs. Agents follows
when its list migrates to ListGrid.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(agents): rebuild list on shared ListGrid with identity rows

Same skeleton as the skills/autopilots lists (two-zone container
responsiveness, deterministic var tracks + min-width scroll escape
valve, virtualized fixed-height rows, issues-style scope buttons,
page-anchored batch toolbar, quiet outline create button), plus the
agents-specific decisions:

- Identity rows: the documented exception to the single-line rule —
  avatar + name + description two-line cells, 64px rows (agents are
  few, identity-rich entities); the italic "no description"
  placeholder is gone, empty descriptions just center the name
- Scope: Mine (historical default) | All | Archived with full-set
  counts; archived ignores the ownership lens; no search box
- The 7d sparkline column is replaced by a sortable "Last active"
  column derived from the same 30-day activity buckets (zero API
  change) — per-row-normalized mini bars can't be compared across
  rows, and the default sort finally has a visible anchor; the
  detailed histogram stays on the hover card / detail page
- Workload folds into the status cell ("Online · 2 tasks") — a 0-2
  integer doesn't earn a column
- Columns: status, runtime, last active, runs (30d); model/created
  opt-in hidden; filters: availability, runtime
- Operations unchanged: row kebab reuses AgentRowActions
  (cancel-tasks/duplicate/archive/restore with permissions); batch
  archive (confirmed) + restore; no delete — the API has none
- View store extended (scope incl. archived, sort, columns, filters);
  agent-columns.tsx (DataTable columns) deleted

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(agents): trim status track to its real worst case (160 -> 144px)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(runtimes): machine detail's runtime table on the shared ListGrid

The master-detail console keeps its shape (machines are few and
strongly categorized; left list, charts, update section untouched) —
only the right pane's runtimes table moves from TanStack DataTable to
the ListGrid family, taking the paradigm pieces that earn their keep
at 1-5 rows: subgrid template + var tracks, two-zone container
responsiveness (the pane is squeezed by the machine list, so the
core-set collapse below @2xl matters more here than on full-width
pages), min-width scroll escape valve, shared header/row/hover visual
language. Deliberately NOT taken: virtualization, sorting, filters,
column toggles, and batch selection — dead weight at this row count,
and batch-deleting runtimes (a cascade-confirm operation) is unsafe
by design.

Workload folds into the health cell ("Online · Working 2") like the
agents status cell; the owner column keeps its only-when-multiple-
owners rule via a zeroed track var. runtime-columns.tsx is deleted;
the row-menu/CLI tests render the exported cells directly.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(runtimes): collapse the kebab track when no row has actions

On a healthy local machine every row's only action (delete) is hidden
by the self-healing rule, leaving a permanent ~64px dead zone after
the CLI column. The action track now follows the owner column's
conditional-var mechanism: zeroed unless at least one row will show
the menu.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(runtimes): drop doubled header border, align create button with convention

PageHeader already carries border-b; the content wrappers' border-t
stacked a second line right under it (the only list page doing this).
"Add a computer" follows the chrome-button convention: outline with
text on md+, square plus icon below md — primary stays reserved for
the empty state CTA.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(runtimes): health cell load suffix matches the agents status cell

"Healthy · 2 tasks" instead of the old workload vocabulary
("Working 2 +1q") — the count is unit-bearing and both surfaces now
speak one language. The queued-anomaly distinction the old words
hinted at belongs to the health layer if it ever earns surfacing.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(lists): pin overflow-y-hidden on the horizontal-scroll wrappers

CSS coerces overflow-x:auto into overflow:auto on both axes, which
silently armed the list wrappers with a vertical scrollbar they were
never meant to have. Combined with the h-full grid's percentage
resolution across scrollbar-induced reflows, the wrapper's vertical
bar and horizontal bar fed each other in a non-converging layout loop
(visible as two stacked, flickering scrollbars on the agents list —
the same latent loop exists in all four wrappers; agents' wider
min-width and 64px rows just hit the trigger zone first). Vertical
scrolling belongs solely to ListGridBody; declare overflow-y-hidden
explicitly to break the loop.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(agents): single scroll container for the list (trial before rollout)

Both scroll axes move to the outer wrapper; the grid drops h-full and
the rows wrapper drops its own overflow. Kills the percentage-height
bridge between the two scroll elements that fed the flickering double
scrollbars and clipped the last row under the horizontal scrollbar.
Sticky header pins inside the scroller; vertical scrollbar now spans
the full pane (Linear's structure). Skills/autopilots follow after
visual confirmation.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(lists): roll single scroll container out to skills/autopilots, add bottom clearance

ListGridBody retires its own scrolling entirely (the agents trial
confirmed the structure): both axes live on the single outer wrapper,
grids drop the h-full percentage bridge, virtualizers point at the
wrapper. The rows wrapper gains LIST_GRID_BOTTOM_CLEARANCE (64px)
appended to the virtualization padding so the last row scrolls clear
of the chat FAB (~48px at bottom-right) and the batch toolbar (~62px).
Runtimes' machine table is untouched: content-height at the top of a
tall pane, no bridge and no practical FAB overlap.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(squads): rebuild list on shared ListGrid (identity rows, minimal)

The last list joins the family. Squads are the fewest entity (1-5 rows),
so this is the agents identity-row shell on the runtime-list minimal
skeleton: ListGrid subgrid + var tracks + two-zone responsiveness +
single scroll container, but NO virtualization, checkbox, or batch.

- Identity two-line rows (squad avatar + name + description, 64px) like
  agents; columns: name / leader / members (polymorphic ActorAvatar
  stack from member_preview), creator + created opt-in hidden
- Scope Mine/All (creator-based, issues-header styling, <md dropdown);
  no archived scope (list API hard-filters archived + no restore
  endpoint), no search (scope-bearing), no filters (set too small)
- Sort name (default) / members / created
- Row kebab = Archive (= the delete endpoint, which archives + transfers
  issues/autopilots to the leader); workspace owner/admin only, so the
  kebab track collapses for non-admins. Reuses the existing
  archive_dialog copy. No batch.
- View store extended (scope + sort + columns); zero API change — pure
  frontend (member_preview/count already in the list payload)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(agents,squads): owner/created-by columns + owner filter

Surface ownership as a real column on both lists, named by what the
field actually means in each permission model:
- Agents: "Owner" — owner_id is the creator (set at creation, never
  transferred) and carries management rights. Promoted to a default-
  visible column (avatar + name); the half-baked inline owner avatar in
  the name cell is removed ("You" badge stays).
- Squads: "Created by" (NOT Owner) — creator_id holds no rights
  (archiving is workspace-admin only), so Owner would mislead. Now a
  default-visible column with avatar + name.

Agents also gains an Owner filter, kept orthogonal to the Mine scope by
the single-axis rule: "Mine" is the clean no-filter personal view, so
applying any filter (owner or otherwise) leaves Mine for All, and
clicking Mine clears all filters. Owner and Mine therefore never
coexist — no "mine + owner=someone-else = empty" contradiction. Squads
keep the plain Mine/All toggle (too few rows for a creator filter).

Both lists keep a Created (date) column, opt-in hidden.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(agents): backfill new filter dimensions on rehydrate (owners crash)

A view payload persisted before the owners filter existed overwrote the
default filters wholesale on rehydrate, dropping filters.owners to
undefined and crashing the list's filter predicate (.length on
undefined). The store merge now deep-merges filters over
EMPTY_AGENT_FILTERS so newly-added dimensions always get their default.
Regression test added.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(skills,autopilots): deep-merge filters on rehydrate too

Same latent crash the agents store just hit: the copied view-store
merge spread persisted.filters wholesale, so adding a new filter
dimension later would drop it to undefined for users with older
persisted state. Harden skills and autopilots the same way (merge over
their EMPTY_*_FILTERS) before that bug can ship.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(projects): rebuild table view on ListGrid + filters + pin/delete kebab

Projects is the dual-view list: the compact table moves onto the shared
ListGrid (subgrid tracks, two-zone responsiveness, single scroll
container, FAB bottom clearance) while the comfortable card grid stays
as the alternate view, toggled by a restyled view switch (Table/Cards
outline buttons, active = bg-accent). Inline editing is preserved —
rows are NOT whole-row links; the name navigates and status/priority/
lead stay click-to-edit (matching prior behaviour, no navigate-vs-edit
conflict).

- View store extended: viewMode + sort (name/priority/status/progress/
  created) + hidden columns + filters (status/priority/lead); merge
  deep-merges filters (migration-safe). No scope (lead optional/often
  an agent; status is a 5-value lifecycle → filter, not scope).
- Toolbar: search (kept — scopeless list) + result count + Filter
  (status/priority/lead) + Display (sort+columns, table view only).
- Row kebab: Pin/Unpin (any member, reuses the existing project pin
  API — zero new endpoints) + Delete (workspace admin). Pin is the
  flexible per-user favourite the list previously lacked.
- Zero API change; status/priority filtering is client-side like the
  other lists.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(projects): GRID_COLS must be a literal string (Tailwind can't see interpolation)

The table view's grid-cols template interpolated ${STATUS_WIDTH}px, so
Tailwind never generated the arbitrary-value class — the grid collapsed
to one column and every cell stacked vertically. Inline the literal
116px. This is the documented ListGrid rule (keep the class literal so
Tailwind scans it).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(projects): single view-toggle button, decouple Display from view mode

Two fixes from the same principle — view mode is pure presentation and
must not couple to anything:
- The view switch is now ONE button that flips table ⇄ cards (shows the
  current view's icon+label, tooltip names the target), instead of two
  side-by-side buttons.
- The Display (sort/columns) control no longer disappears when you
  switch to cards — it was gated on isCompact, so flipping the view
  made it vanish (the "filter gone after switching" weirdness). It's
  always present now; only the columns *section* inside the popover is
  table-only (cards have no columns). Sort applies to both views.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(projects,squads): projects multi-select + squads FAB clearance/toast

Cross-list consistency audit fixes:
- projects: add multi-select (checkbox column + select-all header +
  page-anchored batch toolbar) — it's a dozens-scale full-page list
  like skills/autopilots/agents but was the only one missing it. Batch
  ops: Pin all (any member) + Delete (workspace admin). Table view
  only (cards have no checkboxes). GRID template + min-width updated
  for the checkbox track.
- squads: add the FAB bottom clearance the other full-page lists have
  (last row/kebab was sliding under the chat FAB).
- squads: archive success toast was showing the dialog's question
  title ("Archive this squad?"); use a proper "Squad archived" key.

Intentional and left as-is (documented): squads/runtimes have no
multi-select/virtualization (1-5 rows); projects table isn't
virtualized yet (dual-view + card grid; tracked as low-risk debt).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(agents,squads): close the filter/column consistency gaps

Apply the principle "every categorical column is filterable" where it
was missing:
- agents: add a Model filter (model was a categorical column with no
  filter). Distinct non-empty models from the in-scope rows.
- squads: add filters entirely (it had leader/creator columns + a
  column-toggle panel but no Filter button — the only such outlier).
  Leader (agent) + Creator (member) filters, with the result count and
  the same Filter dropdown shape as the other lists. Store gains
  SquadListFilters + toggleFilter/clearFilters + migration-safe
  filters deep-merge.

autopilots creator stays default-hidden per product call (not every
"who made it" must be visible). Filter stores' partialize tests
updated.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(autopilots): match list-page root to flex-1 convention

skills/agents/projects roots use `relative flex flex-1 min-h-0 flex-col`;
autopilots used `h-full`. Both anchor the batch toolbar correctly, but
align the flex sizing for consistency across the six list surfaces.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-06-15 14:12:24 +08:00
Bohan Jiang
ef08d8584c MUL-3254: flush issue description edits on close (#4082)
* fix: flush issue description editor on close

Co-authored-by: multica-agent <github@multica.ai>

* fix: make unmount flush opt-in via flushPendingOnUnmount

The unconditional unmount flush re-emitted discarded content into
composers that clear their draft and then unmount (comment edit cancel,
create-issue / feedback submit), resurrecting the cleared draft.

- Add flushPendingOnUnmount prop (default false); only the issue-detail
  description editor opts in.
- Cache the pending markdown in a ref at onUpdate time and emit that
  cached copy on unmount, instead of reading the editor instance during
  teardown.
- Regression tests: default drops the pending update on unmount, opt-in
  flush emits the cached value even when the editor is already
  destroyed, no double-emit after the debounce fired, and issue-detail
  pins the opt-in wiring.

Co-authored-by: multica-agent <github@multica.ai>

---------

Co-authored-by: J <j@multica.ai>
Co-authored-by: multica-agent <github@multica.ai>
2026-06-13 02:03:13 +08:00
Jiayuan Zhang
7d28b5a040 fix(issues): remove duplicate emoji reaction entry from comment header (#4068)
The comment card exposed two identical add-reaction affordances: a
QuickEmojiPicker in the header's top-right actions and the add button
inside the bottom ReactionBar. Keep only the bottom one.

- Drop QuickEmojiPicker from the root header and reply-row headers
- Always show the ReactionBar add button (it is the only entry point
  now), removing the isLongContent gating
- Remove the now-unused hideAddButton prop from ReactionBar

MUL-3262

Co-authored-by: Lambda <lambda@multica.ai>
Co-authored-by: multica-agent <github@multica.ai>
2026-06-12 12:39:40 +02:00
Naiyuan Qing
0985bad9fd fix(issues): render thread replies in chronological order (#3691) (#4033)
collectThreadReplies walked the parent_id tree depth-first, so an agent
reply forced to nest under its trigger comment rendered before earlier
sibling replies (A-D-B-C instead of A-B-C-D) whenever the agent returned
late. Sort the collected subtree by created_at (id tie-break) so the
thread reads in arrival order — the same order the server already feeds
agents via `comment list --thread` (ListThreadCommentsForIssue).

All other consumers of the array (resolution derivation, fold bars,
counts, deep-link) are order-independent.

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 15:45:53 +08:00
Antoine GIRARD
5480c69c9e fix: sort execution log past runs by timestamp (newest first) (#4018)
MUL-3217
2026-06-11 12:18:04 +08:00
Naiyuan Qing
a0b63462d0 fix(issues): keep comment trigger preview fresh against live queue state (#4007)
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>
2026-06-10 17:45:29 +08:00
Naiyuan Qing
d66730ecdb fix(issues): state-specific trigger chip copy (#4006)
Five chip states get distinct copy instead of sharing one sentence and a
vague "not this time":

- single, will trigger:  Starts working when sent (unchanged)
- single, skipped:       Won't be triggered
- several, k will fire:  {{count}} agents start working when sent —
  the count covers only non-suppressed agents; skipped ones read as the
  dimmed heads in the stack next to the number
- several, all skipped:  No agents will be triggered
- popover row state:     Skipped

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-06-10 17:29:24 +08:00
Naiyuan Qing
dc129b1178 fix(issues): polish comment trigger chip presentation (#4002)
- Copy: one fixed sentence for single and stacked chips — the avatar(s)
  carry who and how many, the text carries condition + outcome
  ("发送后开始工作" / "Starts working when sent"), killing the
  "is it already running?" misread. Drops the per-name and count keys.
- Color: sidebar-style resting state — muted-foreground until hover so
  the strip reads as metadata, not content.
- Motion: pure fade-in (no slide offset).
- Spacing: reply composer reserves pb-9 so the chip strip reads as a
  footer instead of a second content line glued to the text.

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-06-10 17:09:03 +08:00
LinYushen
619c4c4953 fix(attachments): bind description uploads via contentReferencesAttachment (#4001)
The issue description editor bound pending uploads with
`md.includes(a.url)`, but the editor persists the durable markdownLink
(`/api/attachments/<id>/download` / markdown_url), never the raw storage
`a.url`. The filter therefore never matched, so description uploads were
never linked via `attachment_ids`.

After reload the attachment was absent from `issueAttachments`, so the
renderer could not resolve it to a freshly-signed CDN `download_url` and
fell back to the persisted auth-gated download endpoint. That endpoint
loads on web (same-site cookie / proxy) but fails as a native <img> on
Desktop/Electron (cross-origin file:// renderer carries no auth), leaving
the image broken — while comments rendered fine because they already bind
via contentReferencesAttachment.

Switch the description binding to contentReferencesAttachment, matching
the comment/reply/chat composers, so description images resolve to the
signed CDN URL on every client. Add a regression test pinning the
absolute-host markdown_url shape.
2026-06-10 17:04:06 +08:00
Naiyuan Qing
906f70a3e2 Add comment trigger preview suppression (#3792)
* 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>
2026-06-10 16:27:07 +08:00
Multica Eve
abf99eb700 fix(attachments): server-driven markdown_url + legacy compat (MUL-3192) (#3991)
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>
2026-06-10 16:00:40 +08:00
Naiyuan Qing
6d646db577 fix(issues): soften comment sticky mask (#3956) 2026-06-09 18:27:49 +08:00
Naiyuan Qing
b542c40936 fix(issues): keep sticky comment highlight consistent (#3955)
* fix(issues): keep sticky comment highlight consistent

* fix(issues): remove reply resolve-thread action
2026-06-09 18:02:11 +08:00
Naiyuan Qing
2bdc8344dd fix(issues): align sticky comment header padding (#3952) 2026-06-09 17:17:14 +08:00
Naiyuan Qing
c983905d5c feat(issues): per-comment thread resolution with sticky collapse (#3910)
* feat(issues): per-comment thread resolution with sticky collapse

Allow resolving any comment, not just roots. Resolving a root folds the
whole thread into one bar (existing); resolving a reply marks it as the
thread's resolution ("Resolve thread with comment") and folds the other
replies behind a "N comments" bar, with the resolution kept visible and
badged. Which comment is the resolution is a pure frontend derivation
(root wins, else latest resolved reply), so no write-side bookkeeping is
needed and any resolved_at combination renders one resolution.

- backend: drop the "only root comments can be resolved" guard
- views: deriveThreadResolution + reply-resolution rendering, sticky
  collapse/fold bars (overflow-clip on the card so sticky resolves to the
  timeline scroll parent), scroll the folded thread back into view on
  collapse, ListChevronsDownUp icon, locales (en/ja/ko/zh-Hans)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(issues): sticky comment headers for long comments

Pin each comment's header (root + replies) to the timeline's scroll
parent while reading, so a long comment keeps its author + actions
visible instead of scrolling out of reach. Exactly one header is pinned
at a time:

- Reply headers stick within their own CommentRow box (release at the
  reply's end).
- The root header is wrapped in a root-section container so its sticky
  containing block spans only the header + root body — without it the
  containing block is the whole thread and the root header stays stuck
  behind every reply. Replies render outside the wrapper, gated on open.
- Skip the root header sticky whenever a resolution collapse bar already
  owns the top-0 slot (root resolved+expanded, or reply-resolution
  expanded) to avoid two bars stacking at the same offset.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-09 16:32:22 +08:00
Naiyuan Qing
90b639888d fix(issues): confine inbox deep-link scroll to the timeline container (#3929) (#3942)
Opening an inbox comment notification on an issue with a running agent
shoved the whole desktop page — header included — off the top, and no
amount of scrolling brought it back; only toggling the right sidebar
(which reflows the panel group) restored it.

Root cause: the deep-link landing uses native
scrollIntoView({block:"center"}) on the target comment. Native
scrollIntoView is spec'd to scroll EVERY scrollable ancestor. On a cold
mount where the timeline is still streaming (is-working) and the sidebar
panel starts collapsed, the inner timeline scroller can't center the
target on its own, so the scroll propagates up and scrolls the desktop
shell's overflow:hidden wrapper (desktop-layout.tsx). That wrapper has no
scrollbar and doesn't auto-clamp, so the page stays shoved up until a
resize reflows it. Desktop-only: web sits in a document-scroll context
with a real scrollbar that self-corrects.

Fix: drive the timeline container's scrollTop directly and re-center
across rAF frames until async heights settle, instead of leaning on the
ancestor scroll. The scroll never touches an ancestor, so the header can
no longer be pushed off-screen.

Tests assert the user-facing contract (lands on + highlights the target
comment) rather than the scroll mechanism, which jsdom can't lay out.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-09 14:46:46 +08:00
Multica Eve
13e9485a3b MUL-3130: persist stable /api/attachments/<id>/download URL in comment markdown (#3937)
* MUL-3130: persist a stable attachment download URL in comment markdown

Comment image attachments rendered as broken placeholders ~30 minutes
after upload because the editor was persisting a short-lived
HMAC-signed URL into the comment body. After PR #3903 (MUL-3132)
hardened /uploads/* with auth, `attachmentToResponse` started signing
`attachment.url` as `/uploads/<key>?exp=<unix>&sig=<HMAC>` for
LocalStorage so token-auth clients could keep loading inline images.
The signature has a 30-min TTL by design — but `useFileUpload` was
returning that signed value as `link` and the editor was writing
`![file](signed-url)` straight into the markdown, so the comment
permanently captured a URL that stopped working as soon as the
signature expired.

The fix is to persist a stable per-attachment URL that the server can
re-sign on every request:

* `useFileUpload` now returns `link = /api/attachments/<id>/download`
  (avatar uploads without an id still fall back to `att.url` so the
  pre-attachment-row code paths keep working).
* `DownloadAttachment` self-resolves the workspace from the attachment
  row instead of reading X-Workspace-Slug / X-Workspace-ID headers,
  and the route is registered under the auth-only group so a native
  browser <img>/<video> resource load (which cannot attach those
  headers) succeeds. Membership is checked inside the handler with
  a 404 deny shape so the route does not act as an IDOR oracle.
* A new `GetAttachmentByIDOnly` SQL query supports the workspace-
  derivation step.
* `AttachmentDownloadProvider` now extracts the attachment id from
  the stable URL when matching markdown refs to attachment records,
  with a fallback to the existing url-equality check for legacy
  comments (and S3/CloudFront markdown that points straight at the
  CDN).
* `contentReferencesAttachment` covers both URL shapes for the
  composer / standalone-list dedup paths so an attachment uploaded
  before the fix and one uploaded after both deduplicate cleanly.

Tests:
- New unit tests for the URL helpers (16 tests, packages/core).
- Backend regression test: bare `<img src>`-style request without
  workspace headers now succeeds for a member (200) and 404s for a
  non-member, replacing the previous "400 without workspace context"
  contract.
- Existing TestDownload*, TestServeLocalUpload*, TestAttachmentTo
  Response* and the 1220 frontend views tests all pass.

Refs: MUL-3130, GitHub issue #3891
Co-authored-by: multica-agent <github@multica.ai>

* MUL-3130: address PR review — split markdown link from upload link, swap render src

Two follow-ups from GPT-Boy's review on PR #3937.

(1) Don't reroute every upload consumer through the workspace-gated
    download endpoint.

    The previous change made `useFileUpload`'s `link` field unconditionally
    return `/api/attachments/<id>/download` whenever the upload had an id.
    But `useFileUpload` is also used by avatar / logo pickers
    (account-tab, workspace-tab, agents/avatar-picker, squads/squad-detail-page)
    that persist `result.link` directly into `avatar_url`. Avatars are
    referenced cross-workspace (mention chips, member lists, inbox
    items), so binding their URL to a workspace-membership-gated
    endpoint would silently break cross-workspace avatar visibility.

    The fix splits the URL into two semantically distinct fields:

      - `link`         — same as `att.url` (legacy contract). Avatar /
                          logo callers continue to use this and remain
                          on whatever URL semantics the storage backend
                          dictates.
      - `markdownLink` — the stable per-attachment URL
                          `/api/attachments/<id>/download`. Only the
                          editor's markdown-persisting flow consumes
                          this. Falls back to `link` for the
                          no-workspace upload branch (where there is
                          no attachment-row id to address).

    `editor/extensions/file-upload.ts` switches `image.src` and
    `fileCard.href` to `markdownLink ?? link` so comment markdown gets
    the stable shape while avatar callers stay on `link` unchanged.

(2) Make the render-time img src loadable for token-mode clients.

    Persisting the stable `/api/attachments/<id>/download` URL fixes the
    expiry problem but the path itself sits behind `middleware.Auth`,
    which expects either a `multica_auth` cookie or a Bearer token in
    `Authorization`. Native `<img>`/`<video>` resource loads from
    token-mode clients (Electron's default mode, the mobile app,
    legacy-token web sessions) cannot attach the Authorization header,
    so the bare URL would 401 immediately rather than 30 minutes later.

    `Attachment.normalize` now runs the resolved record through a new
    `pickInlineMediaURL` helper that returns:

      - `record.download_url` when it's an absolute URL with a
         recognised CDN signature query (CloudFront-signed
         `Signature` / `Expires` / `Key-Pair-Id`, or
         `X-Amz-Signature` for raw S3 presigns) — these load as
         native resource src in any client.
      - else `record.url`, which on the LocalStorage backend carries
         a freshly-minted `/uploads/<key>?exp&sig` query whose
         signature IS the auth (token-mode-loadable). On non-CF S3
         backends this is the raw stored URL — same behaviour as
         today.
      - else the original input URL (legacy / unresolved markdown
         keeps its existing path).

    This gives the same effect for both `kind: "record"` and
    `kind: "url"` attachment inputs: once a record is in hand, the
    rendered media src is whichever URL the current backend exposes
    a working signature on.

Tests:

  - New `file-upload.test.ts` regression pinning that `markdownLink`
    is what lands in the markdown body when the upload result returns
    both a short-lived storage URL and a stable download path.
  - Updated `attachment.test.tsx` to reflect the new render-time
    swap (the rendered img src now follows the freshly signed URL,
    not the raw storage URL) and added a record-mode regression
    pinning the LocalStorage default — when `download_url` is the
    bare /api/attachments/<id>/download path, the renderer must fall
    through to the signed `record.url`.
  - Updated `chat-input.test.tsx` makeUpload helper for the new
    `markdownLink` UploadResult field.
  - 1222 frontend views tests + 507 core tests + typecheck across
    @multica/{core,ui,views} all pass.

Refs: MUL-3130, GitHub issue #3891. Builds on a740f7a35.
Co-authored-by: multica-agent <github@multica.ai>

* MUL-3130: chat upload map keys on persisted markdownLink, not the short-lived link

GPT-Boy's second-round review on PR #3937 caught a chat-only blocker
left over from the previous fix.

After the previous commit split `UploadResult.link` into `link`
(legacy avatar/logo URL) and `markdownLink` (stable per-attachment
URL persisted into markdown), the comment editor's image src + file
card href correctly switched to `markdownLink ?? link`. But chat
input still kept the upload-map key on the old `link`:

  uploadMapRef.current.set(result.link, result.id)
  …
  if (content.includes(url)) activeIds.push(id)

In the LocalStorage backend `link` is the short-lived
`/uploads/<key>?exp=&sig=` URL. The editor persists the stable
`/api/attachments/<id>/download` URL into the message body, so
`content.includes(url)` never matches and the send call drops
`attachment_ids`. The attachment ends up bound only to the chat
session, not to the message — agents reading message-level metadata
see no attachments.

Fix: key the upload map on the same value the editor actually wrote
into the markdown body (`markdownLink || link`). The
`content.includes(url)` check then matches and the attachment id is
correctly forwarded on send.

Tests:

- Updated the chat-input mock editor to insert `markdownLink || link`
  into its value, mirroring the real editor's persisted-URL choice
  (uploadAndInsertFile in editor/extensions/file-upload.ts). Without
  this the mock would silently paper over the bug.
- Added a regression test where the upload result returns a
  short-lived `link = /uploads/...?exp&sig` and a stable
  `markdownLink = /api/attachments/<id>/download`. Asserts (a) the
  message body carries the stable URL and never the signed query,
  and (b) the bound `attachment_ids` includes the attachment id.

All 1223 frontend views tests pass (was 1222, +1 new regression).
Typecheck and 507 core tests still green.

Refs: MUL-3130, PR #3937 review by GPT-Boy. Builds on f66a522d0.
Co-authored-by: multica-agent <github@multica.ai>

---------

Co-authored-by: Eve <eve@multica-ai.local>
Co-authored-by: multica-agent <github@multica.ai>
2026-06-09 14:26:36 +08:00
Jiayuan Zhang
072404d912 fix(issues): header chip shows 'is queued' when no agent is running (#3923)
Co-authored-by: Lambda <lambda@multica.ai>
Co-authored-by: multica-agent <github@multica.ai>
2026-06-09 03:11:10 +08:00
Jiayuan Zhang
0c80c33c62 feat(issues): add brand border beam to active agent header chip (#3921)
Co-authored-by: Lambda <lambda@multica.ai>
Co-authored-by: multica-agent <github@multica.ai>
2026-06-09 02:34:22 +08:00
Naiyuan Qing
139cd755e2 Revert "MUL-3127: reuse submit button in reply input (#3901)" (#3906)
This reverts commit d434f038c9.
2026-06-08 17:54:47 +08:00
Naiyuan Qing
51a214b4c0 MUL-3134: restore header agent popover (#3905)
Co-authored-by: multica-agent <github@multica.ai>
2026-06-08 17:47:56 +08:00
Naiyuan Qing
f6999a9dcb MUL-3134: simplify issue agent header chip (#3902)
* MUL-3134: simplify issue agent header chip

Co-authored-by: multica-agent <github@multica.ai>

* MUL-3134: remove execution log event count

Co-authored-by: multica-agent <github@multica.ai>

---------

Co-authored-by: multica-agent <github@multica.ai>
2026-06-08 17:29:34 +08:00
Naiyuan Qing
d434f038c9 MUL-3127: reuse submit button in reply input (#3901)
Co-authored-by: multica-agent <github@multica.ai>
2026-06-08 16:40:33 +08:00
Naiyuan Qing
a02b3dfb4a feat(issues): move agent live signal into the issue-detail header (#3879)
* feat(issues): move agent live signal into the issue-detail header

Replace the in-body sticky "agent is working" card (AgentLiveCard) with a
compact chip in the issue-detail header, so the live signal sits in one
fixed place and never competes with sticky banners in the content column.

- New IssueAgentHeaderChip: avatar(s) + live-ticking blue elapsed time;
  click opens a popover listing every active task.
- Popover reuses ExecutionLogSection's ActiveTaskRow (now exported) so the
  popover and the right panel are literally the same row — no duplication.
- PopoverContent gains an optional keepMounted so the row's confirm dialog
  survives the popover closing on Stop.
- Running rows in ExecutionLogSection drop the blue spinner for a
  live-ticking blue elapsed timer (panel + popover share this).
- Source the chip from the workspace agent-task snapshot filtered by issue
  (same source as board/list indicators, zero extra network); delete the
  old AgentLiveCard + its test and its heavy per-issue WS machinery.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(issues): live event count on the agent chip + execution-log rows

Show a live "N events (elapsed)" on running agents, consistent across the
header chip, its popover rows, and the right-panel execution log.

- Read the shared per-task message cache (taskMessagesOptions, kept live by
  useRealtimeSync's global task:message handler) instead of a bespoke
  subscription — one source of truth, deduped across chip / popover / panel /
  transcript, no extra WS wiring.
- Extract <RunningStat> (event count in info-blue + elapsed in muted parens)
  so all surfaces render the running stat identically.
- ExecutionLogSection running rows now show the same "N events (elapsed)";
  the transcript opened from them streams live from the shared cache.
- Chip: single running shows events (elapsed); multiple shows "N working".
- i18n: add agent_live.event_count (4 locales).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-08 14:51:20 +08:00