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>
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>
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>
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>
* 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>
* 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>
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).
* 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>
* feat(daemon): inject project description into the agent brief
Issues bound to a project only surfaced the project title in the runtime
brief; the project description (durable, project-wide context the owner
sets) was loaded but dropped. Carry it end-to-end:
- claim handler reads proj.Description onto the response (issue-bound and
quick-create paths)
- new ProjectDescription field on AgentTaskResponse, daemon Task, and
TaskContextForEnv
- rendered in the brief's `## Project Context` section and written to
.multica/project/resources.json as project_description
Empty descriptions render nothing (no extra heading). Updated the
projects-and-resources built-in skill docs in the same change.
MUL-3465
Co-authored-by: multica-agent <github@multica.ai>
* feat(projects): clarify project description is injected as agent context
The project description is now durable context injected into every task's
brief, but the UI still presented it as a plain "Description" field, so
existing descriptions could silently become agent input. Add a hint under
the description editor on the project detail page and in the create-project
modal, in all four locales, stating it is shared with agents as context for
every task in the project. No data-semantics change.
Addresses review feedback on PR #4395. MUL-3465
Co-authored-by: multica-agent <github@multica.ai>
* test(handler): assert project description flows through task claim
The execenv tests cover brief rendering, but nothing pinned the claim
handler boundary where proj.Description is read onto the response. Add
two tests — issue-bound and quick-create paths — so a regression in that
assignment fails loudly instead of silently dropping the description.
Addresses review feedback on PR #4395. MUL-3465
Co-authored-by: multica-agent <github@multica.ai>
---------
Co-authored-by: J <j@multica.ai>
Co-authored-by: multica-agent <github@multica.ai>
* feat(agent): Qoder ACP runtime, chat reconnect recovery, and task linkage
- Add Qoder CLI backend (ACP transport, model discovery, blocked-args policy)
- Wire daemon/runtime config, docs, and UI provider assets
- Retry terminal task reports; add backoff unit tests
- Chat: SQL attach user message to task; handler + optimistic cache reconcile
- Invalidate chat/task-messages caches on WS reconnect; extract helper + tests
Co-authored-by: Orca <help@stably.ai>
Co-authored-by: Cursor <cursoragent@cursor.com>
* chore: drop non-Qoder changes (chat reconnect, task link, terminal report retries)
Keep only Qoder runtime, docs, daemon config/execenv, and UI provider assets.
Co-authored-by: Orca <help@stably.ai>
Co-authored-by: Cursor <cursoragent@cursor.com>
* fix(agent): harden Qoder ACP drain and wire project skills path
- Stop streaming to msgCh after reader wait so grace timeout cannot race close
- Resolve injected skills to .qoder/skills per Qoder CLI discovery
- Update AGENTS.md skill copy and add execenv tests
Co-authored-by: Orca <help@stably.ai>
Co-authored-by: Cursor <cursoragent@cursor.com>
* feat(qoder): add provider logo and wire MCP config into ACP sessions
- Add inline SVG QoderLogo component to provider-logo.tsx, replacing
the generic Monitor icon placeholder
- Add convertMcpConfigForACP helper to convert Claude-style MCP server
config (object map) into ACP array format for session/new and
session/resume
- Add unit tests for convertMcpConfigForACP covering stdio, SSE,
empty/nil, and multi-server cases
Co-authored-by: Orca <help@stably.ai>
* fix(test): capture both return values from InjectRuntimeConfig in Qoder test
Co-authored-by: Orca <help@stably.ai>
* fix(qoder): preserve remote MCP headers and promote provider errors
Addresses review feedback on #2461 (Bohan-J): two runtime-correctness
issues in the Qoder ACP backend.
1. Remote MCP headers were dropped. The bespoke convertMcpConfigForACP
only forwarded url/type, so an authenticated remote MCP server looked
configured in Multica but failed inside the Qoder session. Replace it
with the shared buildACPMcpServers helper (same path Hermes/Kimi/Kiro
use), which preserves headers as [{name, value}], sorts for
deterministic output, and handles remote transport aliases. Fail
closed on malformed mcp_config instead of silently dropping servers.
2. Provider failures could report as completed tasks. stderr was wired
via io.MultiWriter and the result was only promoted to failed when
output was empty, so a terminal upstream error (HTTP 429 / expired
token) racing a stopReason=end_turn with text still became
"completed". Switch to StderrPipe + an explicit copier, drain it
(bounded by the existing grace window, since qodercli can leave a
child holding the inherited fds) before the decision, and run the
shared promoteACPResultOnProviderError.
Tests: replace the convertMcpConfigForACP unit tests with two
end-to-end Qoder tests — one asserts the Authorization header reaches
the session/new payload as {name, value}, the other asserts a terminal
stderr error with non-empty output reports failed.
Co-authored-by: Orca <help@stably.ai>
* fix(qoder): align ACP session handling
Co-authored-by: Orca <help@stably.ai>
* fix(agent): guard qoder late output after drain
Co-authored-by: multica-agent <github@multica.ai>
---------
Co-authored-by: Orca <help@stably.ai>
Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: J <j@multica.ai>
Co-authored-by: multica-agent <github@multica.ai>
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>
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>
Add a Join Discord promo card pinned to the bottom of the left sidebar
(above the help launcher). Dismiss state persists per-user in
localStorage so it stays hidden once closed.
Extract the shared DiscordIcon + invite URL into layout/discord.tsx so
the help launcher and the card reuse one source. i18n copy added for
en / zh-Hans / ja / ko.
MUL-3505
Co-authored-by: multica-agent <github@multica.ai>
Add a Discord invite (https://discord.gg/W8gYBn226t) in three places:
- Website footer: social icon + link in the Resources group (en/zh/ja/ko)
- In-app help menu: Discord item in the help launcher (all 4 locales)
- GitHub repo README: badge + link (README.md and README.zh-CN.md)
MUL-3492
Co-authored-by: multica-agent <github@multica.ai>
* fix(auth): autofocus OTP input on verification step
The email-verification step renders the OTP input without focus, so
users must click the field before typing the code. This is friction on
every login, especially when switching accounts.
Add `autoFocus` to the InputOTP so the cursor lands in the field as
soon as the step mounts. Mirrors the existing email-step input and the
mobile OTP component, both of which already autofocus.
* test(web): polyfill document.elementFromPoint for input-otp in jsdom
Autofocusing the OTP input makes input-otp run its focus-time DOM
measurement, which calls document.elementFromPoint. jsdom doesn't
implement it, so the web login test threw an unhandled error.
packages/views/test/setup.ts already stubs this for the same reason;
mirror the stub in the web test setup (which already stubs
ResizeObserver for input-otp).
* test(auth): assert OTP input autofocuses on verification step
Guards the autofocus behavior: the test fails if the autoFocus prop is
removed from the verification-step InputOTP. Lives in packages/views
since it covers shared component behavior, mocking @multica/core.
* fix(projects): require admin for project deletion
* test(projects): clean up orphaned member rows in delete-permission helper
The schema uses no foreign keys or cascades, so deleting the test user
left its member row behind in the shared test workspace, polluting later
tests in the package. Delete the member row before the user in both the
pre-seed cleanup and t.Cleanup.
Co-authored-by: multica-agent <github@multica.ai>
---------
Co-authored-by: J <j@multica.ai>
Co-authored-by: multica-agent <github@multica.ai>
* fix(realtime): drop WS frames without a string type (MUL-3418)
The onmessage handler dispatched every parsed frame to onAny and the
ws.on subscribers without validating its shape. A frame whose parsed JSON
lacked a string `type` (an out-of-protocol frame injected by a proxy /
extension, or a bare JSON primitive) reached the realtime-sync onAny
dispatcher, where `msg.type.split(":")` threw an uncaught TypeError out of
onmessage. The browser reported it via window.onerror, flooding PostHog
`$exception` (~12k events). Non-fatal (no crash, connection stays up), but
pure noise.
Validate once at this trust boundary: a frame must be an object carrying a
string `type`, otherwise drop it as a no-op. The bad-frame log is
rate-limited to one entry per connection — a misbehaving source can repeat
the frame hundreds of times per session.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>
* feat(analytics): drop benign ResizeObserver exceptions from telemetry
ResizeObserver "loop ..." errors are the dominant `$exception` bucket
(~31k events / ~1.5k users). They are a benign, self-recovering browser
quirk with no actionable signal and otherwise drown real failures and burn
the event budget. Drop them entirely in before_send (ahead of redaction
and the dedupe fuse, which only caps repeats). The match is narrow — only
the benign "loop" phrasing — so a genuine ResizeObserver bug still reports.
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>
* feat(daemon): discover local skills from ~/.agents/skills (MUL-3333)
Upgrade local skill discovery and import from a single provider root to an
ordered multi-root scan: the runtime's own skill directory (e.g.
~/.claude/skills) first, then the cross-tool universal root ~/.agents/skills.
- Rename localSkillRootForProvider -> localSkillRootsForProvider, returning
ordered roots [provider, universal] with a kind classifier.
- listRuntimeLocalSkills iterates the roots, gives each root its OWN visited
set (so a cross-root symlink alias is not collapsed), dedupes strictly by
Key with the provider root winning, and sorts once after the merge.
- loadRuntimeLocalSkillBundle walks the same priority order and only falls
through to the next root on os.IsNotExist; any other stat error is returned
so import never silently resolves a different same-key skill.
- Add a Root ("provider" | "universal") field to the local skill summary
(daemon + handler structs and the TS RuntimeLocalSkillSummary type) so the
UI can label a skill's origin without a future schema break.
Backward compatible: every skill visible today keeps its Key, SourcePath and
FileCount; the universal root only surfaces additional, non-conflicting skills.
Out of scope (follow-up issues): execution-time injection of ~/.agents/skills
into runtimes (e.g. Codex seedUserCodexSkills) and workspace-relative
.agents/skills discovery.
Tests cover universal-root discovery + import, provider-wins conflict
priority, both-roots merge, missing/both-missing roots, nested layouts,
IsNotExist fallback, the no-fallthrough-on-read-error guarantee, and the
per-root visited cross-root symlink alias. Docs updated in en/zh/ja/ko.
Co-authored-by: multica-agent <github@multica.ai>
* fix(daemon): fall through to next root when a same-key dir has no SKILL.md
loadRuntimeLocalSkillBundle previously only fell through to the next root on
os.IsNotExist for the skill DIRECTORY. A provider-root directory that shares a
skill's key but contains no SKILL.md (so listRuntimeLocalSkills descends past
it and surfaces the universal-root skill instead) made load stop on the
invalid provider dir and error — list and load disagreed, and the import the
user picked from the list could not be fetched.
Make the validity predicate match list: a root "has" the skill at a key only
when it is a directory containing a SKILL.md. A missing entry, a non-directory,
or a directory without a SKILL.md all mean "this root doesn't have it" and we
continue to the next root. Only a genuine non-IsNotExist stat error or an
unreadable existing SKILL.md (permission/IO) is returned, so we still never
silently substitute a different-content same-key skill from a lower-priority
root (Eve review #1, preserved by the existing read-error guard test).
Adds regression tests for the provider-dir-without-SKILL.md and provider-non-dir
fall-through cases.
Co-authored-by: multica-agent <github@multica.ai>
---------
Co-authored-by: multica-agent <github@multica.ai>
In context mode (chat) a query merges context items (current page / recently
viewed) with search results into one popup. The old contextLayout made only the
"Recent" group scrollable (`min-h-0`) while every other group was `shrink-0`, and
the Recent `<section>` did not clip its own overflow. When the search groups
(Users/Issues) filled the height, the flex algorithm squeezed Recent toward zero
and its un-clipped rows painted on top of the groups below — the overlap users saw.
Collapse the two render branches into a single `overflow-y-auto` flex column so
every group just stacks and the whole popup scrolls; no group can collapse onto
another. The context variant only differs in width / max-height / chrome.
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
#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>
The create-workspace and onboarding UI hardcoded `multica.ai/` as the
workspace slug URL prefix, so self-hosted deployments showed the wrong
domain. Add a `workspaceUrlHost` helper that derives the host from the
deployment's app URL (`daemon_app_url` from `/api/config`, via the config
store) and falls back to the brand host when none is configured, then use
it in both views. Fixes#4263.
Co-authored-by: J <j@multica.ai>
Co-authored-by: multica-agent <github@multica.ai>
Disambiguate client-side model pricing by provider: generic ids (e.g. `auto`) resolve ${provider}/${model} first, so they only price under their real provider instead of borrowing Cursor's rate. Provider is LOWER()-normalized on read and write so mixed-case historical rows merge.
Closes#4199. MUL-3346
Extract three module-local helpers in content-editor.tsx and route the
duplicated call sites through them — no behavior change:
- normalizeMarkdown(md) / normalizeEditorMarkdown(editor): the single
definition of the "strip blob URLs + trimEnd" canonical form, replacing
the five editor-markdown sites and the one incoming-string site.
- hasUploadingNode(editor): replaces the two byte-identical document scans
(content-sync Guard 0 and hasActiveUploads).
The imperative getMarkdown() is deliberately left untrimmed; a safety-net
test pins its exact current return value.
Scope: WOR-59 upload-readiness review. file-upload.ts untouched; follow-up
items B1/F4/F5 are out of scope.
Co-authored-by: hzz <331380069@qq.com>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>
The `codebuddy` case in ProviderLogo was aliased to ClaudeLogo when the
backend was integrated (#3186), so CodeBuddy runtimes rendered the Claude
mark and were visually indistinguishable from Claude in the runtime list,
runtime detail, agent runtime picker, and onboarding.
Add a dedicated CodeBuddyLogo using the official mark shipped in Tencent's
own @tencent-ai/codebuddy-code CLI package (dist/web-ui/logo.svg) — the
same CLI this runtime spawns. The artwork overflows the 24×24 box and is
cropped by a clipPath whose id is per-instance (useId), mirroring KiroLogo,
so multiple logos on one page don't collide on a shared id.
Presentation-only: no provider string, backend/API, DB, daemon/runtime,
CLI, mobile, or copy changes. Shared views package, so web + desktop both
pick it up.
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>
* 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>
* feat(autopilot): default subscriber template (MUL-2533) — server
Add per-autopilot member subscriber template that fans out to every
issue the autopilot spawns. New autopilot_subscriber table; extend
issue_subscriber.reason with 'autopilot' so the dispatch-time fanout
is distinguishable from manual subscriptions.
API: POST/PATCH /api/autopilots accept a `subscribers` array (member
user_type only for the first version); PATCH semantics are full-replace.
GET returns subscribers on the detail endpoint; the list endpoint omits
them to avoid an N+1.
Dispatch: dispatchCreateIssue lists the template inside the same tx as
the issue insert and writes the rows with reason='autopilot' before
EventIssueCreated fires, so notification listeners see the full
subscriber set on the first event.
Co-authored-by: multica-agent <github@multica.ai>
* feat(autopilot): default subscriber template (MUL-2533) — frontend
New SubscriberMultiSelect picker (members-only search + chips) wired
into the create / edit AutopilotDialog. The detail page renders the
saved template as read-only chips; edits flow through the dialog.
TS types expose the new `subscribers` field on Autopilot, plus an
AutopilotSubscriberInput shape for the create/update wire payloads.
Co-authored-by: multica-agent <github@multica.ai>
* fix(autopilot): notify template subscribers on issue creation (MUL-2533)
The autopilot create-issue path fans out template subscribers into
issue_subscriber inside the same tx as the issue insert, but the
issue:created notification listener only matches handler.IssueResponse
payloads and only direct-notifies the assignee + @mentions. The autopilot
publishes a map[string]any payload, so the listener falls through and the
template subscribers never receive an inbox item for the creation event —
breaking OQ3 ("reason='autopilot' subscribers receive all subscription
events, consistent with reason='manual'").
Fix it where the divergence lives: in dispatchCreateIssue, right after
EventIssueCreated fires, write an inbox_item (type='issue_subscribed',
severity='info') for each member subscriber and publish EventInboxNew so
the recipient's inbox WS feed updates in real time. The write is after
the tx commit so an inbox hiccup can't roll back the issue; failures are
logged, not propagated. The manual path is unchanged — manual subscribers
don't exist at creation time, so there is nothing to notify there.
Adds a new InboxItemType 'issue_subscribed' (en/zh labels) and two
covering tests in autopilot_subscriber_test.go: one asserts the inbox
row lands for a template subscriber on dispatch, the other asserts the
no-subscriber autopilot stays silent.
Co-authored-by: multica-agent <github@multica.ai>
* fix(autopilot): align subscriber PR with current main
Co-authored-by: multica-agent <github@multica.ai>
* fix autopilot subscriber template transaction
Co-authored-by: multica-agent <github@multica.ai>
---------
Co-authored-by: Lambda <lambda@multica.ai>
Co-authored-by: multica-agent <github@multica.ai>
The runtimes page header has three outline action buttons — "Add runtime",
"Cloud Runtime", and "Add a computer" — that previously rendered with
inconsistent dimensions and responsive behavior:
- "Add a computer" used `h-8 w-8 ... md:w-auto md:px-2.5` (icon-only
below md, expands to icon+label on md+), with an `aria-label` for the
icon-only state and the label wrapped in `<span class="hidden md:inline">`.
- "Add runtime" had no responsive className, no aria-label, and the label
stayed visible at every width.
- "Cloud Runtime" had the same gaps as "Add runtime", plus the Cloud
icon was sized `h-3 w-3` instead of `h-3.5 w-3.5` like the others.
Visually they read as three different button styles crammed into one
header. Aligning all three to the "Add a computer" pattern gives the
header a single, consistent action group at all breakpoints, fixes the
mobile layout (header no longer wraps onto multiple lines for users with
both Cloud Runtime enabled and admin role), and makes every icon-only
state screen-reader accessible.
Changes:
- Apply `h-8 w-8 gap-1 px-0 md:w-auto md:px-2.5` to all three buttons.
- Add `aria-label` to "Add runtime" and "Cloud Runtime".
- Wrap the label of "Add runtime" and "Cloud Runtime" in
`<span className="hidden md:inline">` so they collapse to icon-only
below md, matching "Add a computer".
- Normalize the Cloud icon size from `h-3 w-3` to `h-3.5 w-3.5`.
Verification: typecheck + lint + 1395 unit tests across @multica/views
all pass.
Co-authored-by: Eve <eve@multica-ai.local>
Co-authored-by: multica-agent <github@multica.ai>
The Custom runtimes dialog repeated the same idea in three places: the
header tagline, the empty-state body, and the right-panel default state.
Per MUL-3367, tighten the prose so each surface adds new information:
- dialog_description: short tagline that points at the protocol families
list directly below.
- empty_description: drops the "such as Claude or Codex" example (already
enumerated in the protocols list right under it) and the "your daemon
should run" filler.
- detail.default_title / default_description: replace the
"Manage custom runtimes" + paragraph (which restated the header) with
a quiet "Select a runtime" placeholder.
- detail.default_builtin_hint: dropped entirely. The supported-protocols
section is already visible in the left column and each row carries a
Reference badge — pointing at it from the right panel is noise.
Updated en, zh-Hans, ja and ko locales in lockstep, plus the dialog test
assertions.
Co-authored-by: Eve <eve@multica-ai.local>
Co-authored-by: multica-agent <github@multica.ai>
* fix(markdown): don't auto-link bare filenames as external URLs
Agent comments that mention a project file like `plan.md` were turned into
clickable links to https://plan.md (dead external site). linkify-it fuzzy
detection matches `plan.md` as a domain because its extension is also a valid
TLD (md = Moldova; likewise io, sh, rs, py).
Suppress schemeless (fuzzy) linkify matches whose token is a bare filename
(single segment ending in a known source/config extension). Explicit schemes
(`https://plan.md`) and real domains (`example.com`) are unaffected. The file
extension list is now shared between the file-path and bare-filename detectors
so they can't drift.
Fixes#4222
Co-authored-by: multica-agent <github@multica.ai>
* docs(markdown): drop inaccurate .io example from bare-filename comment
io is not in the FILE_EXTENSIONS list, so .io domains are never suppressed.
Listing it as an example was misleading.
Co-authored-by: multica-agent <github@multica.ai>
---------
Co-authored-by: J <j@multica.ai>
Co-authored-by: multica-agent <github@multica.ai>
The runtime detail page disabled the Delete button — and the list-page
kebab dropped its only action — whenever the runtime was an online
local daemon (isSelfHealingRuntime). The intent was to spare users a
delete that the daemon would silently undo via re-register, but the
side effect was an Owner staring at an action they had every
permission for, with no obvious explanation.
The fix moves the warning into the confirmation dialog instead of
hiding the action:
- runtime-detail.tsx: drop the disabled+tooltip branch around the
Diagnostics card Delete button. canDelete (workspace owner/admin OR
runtime owner) now fully governs visibility, and the button is
always clickable when present.
- runtime-list.tsx: the row kebab no longer hides itself for
self-healing runtimes. showActions follows along.
- delete-runtime-dialog.tsx: drop the defensive self-healing guard in
handleConfirm (it returned early with a toast on the very click the
user just confirmed). Add a SelfHealNotice banner that renders in
both LightBody and CascadeBody when isSelfHealingRuntime is true,
so the user sees the trade-off before pressing the destructive
button rather than after.
- locales: drop delete_disabled_tooltip and the cascade
self_healing_blocked_toast across en/zh-Hans/ja/ko; add a single
detail.delete_dialog.self_heal_notice key that powers the banner.
- tests: flip the row-menu self-healing assertion (kebab is now
visible), add a banner-present / banner-absent / confirm-proceeds
triplet to delete-runtime-dialog.test.tsx, and pin the
enabled-Delete-for-owner case on runtime-detail-visibility.test.tsx.
Verified with @multica/views typecheck (clean) and a targeted vitest
run across the four affected suites — 73/73 tests pass. Lint stays at
0 errors on the package.
Co-authored-by: Eve <eve@multica-ai.local>
Co-authored-by: multica-agent <github@multica.ai>
* 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>
* feat(analytics): session-level $exception dedupe in before_send
A runaway client error (a render loop, a polling fetch that keeps
throwing) emits 100+ identical $exception events per session, which
showed up as a top PostHog cost/noise source after exception
autocapture landed (MUL-3331 / MUL-3330).
Add a per-tab-session fuse in before_send, after redaction: fingerprint
the already-redacted exception (type + redacted value + one deterministic
stack frame incl. colno), keep the first 3 per (session, fingerprint),
drop the rest. State lives in sessionStorage as a hash->count blob, so no
PII is persisted. Every storage failure fails open (keep the event);
before_send never throws.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>
* feat(diagnostics): global 60s cooldown for client_unresponsive
A single sustained freeze is delivered as several long-task entries, so
emitting per entry made client_unresponsive volume grow without bound
with the freeze length (MUL-3331). Cap it with a module-level (page-
lifetime) global cooldown: at most one event per 60s window. No route
bucketing — a global window is the most direct cap on volume.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>
* test(analytics): add exception-dedupe safety matrix
This file was written alongside the dedupe implementation but missed the
original commit, so the $exception fail-open / cap / fingerprint matrix
never landed on the branch. No implementation change — the tests pass as
written against the existing exception-dedupe.ts.
Covers: first-3-then-drop, fingerprint independence, colno discrimination,
hash-only storage (no PII), degraded/missing frames, undefined / throwing
/ corrupt-JSON sessionStorage fail-open, setItem-failure under-counts, and
the distinct-fingerprint cap (51st new fingerprint kept).
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>
* MUL-3284 PR3 (CLI): multica runtime profile subcommands + local path override
- cmd_runtime_profile.go: `multica runtime profile` group — list / create /
update / delete against /api/workspaces/{id}/runtime-profiles, plus set-path
/ unset-path for a per-machine command override. protocol-family validated
client-side via agent.IsSupportedType / agent.SupportedTypes; visibility
validated; update only sends changed flags (protocol_family immutable);
delete surfaces the server 409 body when agents are still bound.
- internal/cli/config.go: ProfileCommandOverrides map[string]string on
CLIConfig (omitempty), through the existing marshal/unmarshal so set/unset
round-trips without dropping other fields.
- internal/daemon: Config.ProfileCommandOverrides, loaded from CLIConfig;
appendProfileRuntimes now prefers an override path when set AND executable,
else falls back to exec.LookPath(command_name), else skips+logs as before.
- Tests: cmd_runtime_profile_test.go (registration, create/update/delete incl.
bad-family + missing-flag + 409 surfacing, set/unset path round-trip,
relative-path rejection, config preservation); cli/config round-trip;
daemon prefers-override / falls-back-when-not-executable.
Verified: go build ./..., go vet, go test ./cmd/multica/... ./internal/daemon/...
./internal/cli/... all pass.
Co-authored-by: multica-agent <github@multica.ai>
* MUL-3284 PR3 (Web): custom runtime profiles in the Runtime page
Single-list integration — no new page, no tabs/grouping. Built-in protocol
families and custom profiles render mixed in one catalog, each row badged
built-in vs custom (progressive disclosure).
- packages/core: RUNTIME_PROFILE_PROTOCOL_FAMILIES (single-source 13-family
whitelist, matches server agent.SupportedTypes + migration 120 CHECK) and
RuntimeProtocolFamily / RuntimeProfile types; api client
list/get/create/update/deleteRuntimeProfile against
/api/workspaces/{id}/runtime-profiles; runtimes/profiles.ts query +
mutation hooks and a 409 "agents still bound" conflict parser.
- packages/views/runtimes: runtime-profile-catalog (mixed built-in+custom
rows), runtime-profiles-dialog (header "+ Add runtime" → step 1 pick
protocol family → step 2 display_name/command_name/description; edit form
for custom; admin-gated), delete-runtime-profile-dialog (confirm + graceful
409), runtimes-page / runtime-list integration.
- i18n: new strings added to all four locales (en, zh-Hans, ja, ko).
- a11y: dialogs are focus-trapped, Esc-closable, labelled; full
create/edit/delete flow is keyboard + screen-reader operable.
Iron rule honored: no generic per-agent args UI here (those stay on Agent
config). fixed_args is not surfaced as a general args field.
Verified: turbo typecheck + lint + test pass for @multica/core, @multica/views,
@multica/web; the @multica/web production build succeeds.
Co-authored-by: multica-agent <github@multica.ai>
* MUL-3284 PR3: hide fixed_args from Web + CLI (not yet wired to launch)
Review fix. fixed_args was surfaced as a working feature, but the daemon does
not splice it into the agent launch command — exposing it promised admins a
no-op. Per the call, remove it from every user-facing surface while keeping the
underlying column/struct "carried but not exposed".
- Web (runtime-profiles-dialog.tsx + runtime-profile-catalog.ts): drop the
detail row, the create body field, the update patch field, and the form
textarea; remove the parseFixedArgs/fixedArgsToText helpers and the
fixedArgs form value. Left a NOTE pointing at the daemon TODO.
- i18n: removed the fixed_args strings from all four locales (en/zh-Hans/ja/ko).
- CLI (cmd_runtime_profile.go): removed the `--fixed-arg` flag from create and
update and stopped sending `fixed_args`; updated the "no fields" message.
Test now asserts the CLI never sends fixed_args.
Untouched (the carried-but-not-exposed layer): the runtime_profile.fixed_args
column, the server handler's accept/return, and the daemon's RuntimeProfile
field — all keep the existing TODO(MUL-3284) to wire it into the launch path
(with a test proving args reach the backend) before any UI/CLI re-exposes it.
Verified: turbo typecheck+lint+test pass for @multica/core and @multica/views;
go build/vet/test pass for ./cmd/multica/.
Co-authored-by: multica-agent <github@multica.ai>
* MUL-3284 PR3: stop exposing profile visibility=private (server forces workspace)
Double-review (Eve) caught a fixed_args-shaped hole: visibility=private was a
user-facing toggle (Web form + detail + CLI), but the three server read paths
(ListRuntimeProfiles, daemon ListEnabledRuntimeProfilesForWorkspace,
DaemonRegister) never enforce it — so a "private" profile's name/command would
leak to other members and could be registered by other machines' daemons
(lateral data leak). Same "don't paint a pie" fix as fixed_args: hide the
control everywhere and force the stored value.
- Server (runtime_profile.go): drop `visibility` from the create + update
request structs; CreateRuntimeProfile always stores 'workspace'
(runtimeProfileDefaultVisibility); UpdateRuntimeProfile no longer accepts it;
removed validRuntimeProfileVisibility. The column + response field stay
(always 'workspace') as the carried-but-not-exposed layer.
- Web (runtime-profiles-dialog.tsx): removed the visibility form fieldset,
the VisibilityOption component, the detail row, the visibility state, and the
create/update submit fields.
- i18n: removed the profile visibility strings from all four locales
(profiles.detail.visibility, profiles.visibility.*, profiles.form.visibility_*).
Top-level runtime/agent visibility strings are untouched.
- CLI (cmd_runtime_profile.go): removed `--visibility` from create/update and
the VISIBILITY list column; removed validateVisibility; stopped sending the
field.
- Tests: new TestCreateRuntimeProfile_ForcesWorkspaceVisibility (POST
visibility:"private" -> response and DB row are 'workspace'); CLI create test
now asserts visibility is never sent.
Follow-up MUL-3308 tracks implementing real creator-visibility (and wiring
fixed_args to the launch path); TODOs left in server/Web/CLI point to it.
Verified: turbo typecheck+lint+test pass (@multica/core, @multica/views);
go build/vet pass; go test ./cmd/multica/... and the full ./internal/handler/
suite pass against a migrated Postgres 17.
Co-authored-by: multica-agent <github@multica.ai>
---------
Co-authored-by: multica-agent <github@multica.ai>
* fix(editor): don't wipe in-flight uploads on external content sync
When a brand-new chat's first file upload triggers lazy session creation,
`setActiveSession(null → uuid)` flips ChatInput's draft key mid-upload, which
changes `defaultValue` to the new (empty) session draft. ContentEditor's
"sync external defaultValue" effect then ran `setContent` over a document that
still held the `uploading` image/fileCard node, wiping it — so the upload's
finalize could no longer find the node. The file vanished and the draft was
left with an empty `!file[name]()`.
The editor was never remounted (instance stays alive); the node was removed by
the content-sync effect. An uploading node is local state an external sync must
not overwrite, exactly like the existing dirty/focused guards. Add a guard that
bails the sync while any `uploading` node is present.
Pure frontend; affects only the first upload in a new chat (subsequent uploads
hit an existing session, so no draft-key flip).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* test(editor): cover the in-flight-upload content-sync guard
The content-sync effect now reads `editor.state.doc.descendants` on every run
to detect uploading nodes; the mocked editor didn't implement it, crashing all
ContentEditor tests. Add `descendants` (driven by `editorState.uploadingNodes`)
to the mock and a regression test asserting an external `defaultValue` change
does not setContent while an upload is in flight, and resumes once it settles.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix(chat): migrate new-chat draft onto the session id on lazy create
The first file upload in a brand-new chat lazily creates the session, flipping
ChatInput's draft key from `__new__:agent` to the session id mid-upload. The
in-progress (empty-href) file-card markdown the editor had already written into
the `__new__:agent` draft was neither migrated nor cleared, so it stayed
stranded under that key — and resurfaced as a stale `!file[name]()` the next
time a new chat opened for the same agent (the send only cleared the
session-keyed draft).
Migrate the `__new__:agent` draft onto the new session id the moment the
session is created (upload path only — text send already clears the pre-flip
key via `keyAtSend`). Add a shared `newSessionDraftKey` helper so ChatInput and
ensureSession agree on the slot name, and a `migrateInputDraft` store action.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix(web): preserve CLI callback params across Google OAuth redirect
When 'multica login' runs in a headless/WSL2 environment, the CLI generates
a login URL with cli_callback and cli_state query parameters. These params
were being lost during the Google OAuth redirect because:
1. The login page did not encode cli_callback/cli_state into the Google
OAuth state parameter (only platform and nextUrl were included).
2. The callback page had no code path to redirect the JWT back to the
CLI's local HTTP listener after Google OAuth completed.
Fix:
- Login page: encode cli_callback and cli_state into the Google OAuth
state parameter alongside existing platform/nextUrl values.
- Callback page: parse cli_callback/cli_state from the returned state,
validate the callback URL, and redirect the JWT token to the CLI's
local HTTP listener after successful Google login.
Closes#3049
Co-authored-by: multica-agent <github@multica.ai>
* refactor(auth): reuse redirectToCliCallback helper in OAuth callback
Export the existing redirectToCliCallback helper from @multica/views/auth
and reuse it in the Google OAuth callback page instead of duplicating the
token+state redirect string inline, so the CLI callback URL contract lives
in one place.
Co-authored-by: multica-agent <github@multica.ai>
---------
Co-authored-by: multica-agent <github@multica.ai>
Co-authored-by: J <j@multica.ai>
* feat(analytics): capture JS exceptions to PostHog
Turn on posthog-js exception autocapture (window.onerror + unhandled
rejections, with stack) and add a buffered captureException() wrapper for
boundary-caught React errors those handlers can't see. Wire the web
route-level global-error boundary to report through it.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* feat(diagnostics): add shared freeze watchdog
Long-task observer (>=2s) emits client_unresponsive via captureEvent;
client_type super-property tags desktop vs web for free. Installed once in
CoreProvider so web and desktop share one in-thread, SSR-safe detector for
recoverable freezes.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* feat(desktop): report true hangs and crashes via breadcrumb
A real hang or crashed renderer can't report itself. The main process now
persists a breadcrumb on unresponsive / render-process-gone, and the next
renderer boot flushes it to PostHog (client_unresponsive / client_crash).
A recovered hang clears its breadcrumb so it isn't double-counted by the
in-thread watchdog.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* feat(analytics): scrub PII from $exception before send
Error messages can interpolate user input (typed values, URLs with tokens).
Add a before_send hook that redacts emails, URL query strings, and long
opaque tokens from the exception message and $exception_list values, keeping
type + stack frames (code locations, not user data). Addresses the privacy
gap from leaving capture_exceptions on with no sanitizer.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* test: cover breadcrumb state machine and freeze watchdog
The breadcrumb persist/clear orchestration is the correctness-critical part
and was untested. Cover: hang->write, recover->clear (no double-count),
recover-before-delay->no-op, force-quit->retained, crash->write-and-never-
clear, clean-exit->no-write. Add watchdog tests (threshold, idempotent,
SSR/PerformanceObserver no-op) via a fake observer.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix(desktop): breadcrumb field precedence + document limits
Spread the persisted context FIRST so explicit event fields (source,
recovered) always win over a future colliding context key. Document why
preload-error skips the breadcrumb and the single-slot last-write-wins
undercount limitation.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>