1335 Commits

Author SHA1 Message Date
Naiyuan Qing
595f785ac9 fix(desktop): make overflowing tab additions visible (#5215)
* fix(desktop): animate overflowing tab additions

* fix(desktop): recalculate tabs after pin layout changes

* docs: document daemon log rotation settings

Co-Authored-By: OpenAI Codex <noreply@openai.com>

* Revert "docs: document daemon log rotation settings"

This reverts commit a6cbaa99ec.

---------

Co-authored-by: OpenAI Codex <noreply@openai.com>
2026-07-10 17:16:35 +08:00
Bohan Jiang
69132f9fa6 fix(chat): remove unread-count badge from chat FAB (MUL-4374) (#5213)
The floating chat bubble's unread-count badge duplicated the chat tab's
unread indicator in the sidebar and visually overlapped it. Drop the badge;
the hover tooltip still communicates unread/running state.

Co-authored-by: J <j@multica.ai>
Co-authored-by: multica-agent <github@multica.ai>
2026-07-10 17:15:25 +08:00
Bohan Jiang
cb87dd106b feat(chat): task-owned direct-chat input batches + explicit no_response outcome (MUL-4351) (#5195)
* feat(chat): task-owned direct-chat input batches + explicit no_response outcome (MUL-4351)

Direct (web/mobile) chat no longer uses the last-assistant-row as an implicit
input cursor. Each direct send now owns an immutable input batch:

- agent_task_queue.chat_input_task_id makes a task the owner of the user
  messages it must consume; the send path creates the task + user message +
  attachment bindings + session touch in one transaction, and the daemon is
  notified only after commit. A claim reads exactly that batch, so a message
  that arrives mid-run belongs to the next task and is never absorbed.
- Auto-retry inherits the root input owner and is queued at a bumped priority,
  created inside FailTask's transaction so no newer chat task can jump ahead.
- CompleteTask writes exactly one assistant outcome inside the completion
  transaction: a normal message, or a visible no_response outcome (with a
  non-empty English fallback) when the final output is empty. The write failing
  rolls the completion back and the handler returns 5xx so the daemon retries;
  the status CAS keeps it idempotent. chat:done carries message_kind.
- Web/desktop/mobile render no_response as a localized 'no text reply' state
  (keeping the tool timeline), suppress Copy, keep it unread, and keep the
  session-list preview non-blank.
- Legacy/channel tasks (chat_input_task_id NULL) keep the trailing-message
  selector, so a rolling deploy never replays Slack/Lark history.

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

* fix(chat): scope no_response to direct tasks; don't cancel task on input read error (MUL-4351)

Addresses PR review (Niko):
- writeChatCompletionOutcome only writes a no_response row for task-owned
  direct tasks (chat_input_task_id set). Legacy/channel (Slack/Lark) tasks keep
  the prior behavior: empty output writes no assistant row, so chat:done carries
  empty content and the channel outbound silently drops it — the no_response
  fallback body never reaches an external channel.
- The daemon claim distinguishes a genuine zero-input batch from a failed
  input read: on ListChatInputMessages / ListChatMessages error it returns 5xx
  and preserves the dispatched task for redelivery instead of cancelling a valid
  task on a transient DB error.

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

---------

Co-authored-by: J <j@multica.ai>
Co-authored-by: multica-agent <github@multica.ai>
2026-07-10 16:22:42 +08:00
Wood
f7ca045fb1 feat(daemon): discover Codex model and reasoning catalog dynamically (#5198)
Discover the Codex model list and per-model reasoning efforts from the installed CLI (codex debug models --bundled), with a verified static fallback for old/offline installs. Server gates token syntax; the daemon validates the exact (model, effort) pair.

Closes #5197
MUL-4354
2026-07-10 14:32:05 +08:00
Multica Eve
bf161f2f9c fix(tasks): preserve merged comment delivery (#5192)
Track actual claim-time delivery, support legacy daemons, and repair comment
batches across claim, retry, edit, and delete races.

MUL-4348

Co-authored-by: Eve <eve@multica-ai.local>
Co-authored-by: multica-agent <github@multica.ai>
2026-07-10 14:10:10 +08:00
Bohan Jiang
8e0fbecab5 fix(models): codex empty-model effort validation + exact 5.6 aliases (MUL-4347) (#5196)
Follow-up to #5188 addressing the second-round review.

- ValidateThinkingLevel now fails an empty codex model closed instead of
  borrowing the flagged Default (gpt-5.6-sol). An empty model follows
  config.toml, which can resolve to any installed model; Sol alone advertises
  `ultra`, so the old borrow green-lit levels Luna / gpt-5.5 don't support and
  Codex doesn't reject. Checked before ListModels so a discovery error can't
  fail it open. Frontend pickModelEntry mirrors this (no per-model effort
  preview for an empty codex model); the persisted-orphan clear path stays.
- parseCodexDebugModels drops efforts without a known label so the picker
  never advertises a level the Create/Update enum gate would 400 on save; the
  contract test now drives the real parser with an unknown effort instead of
  comparing two hand-written maps.
- gpt-5.6 price aliases anchor to a literal dot (not the [.-] class), so
  dashed variants like gpt-5-6-luna surface as unmapped on both backend and
  frontend rather than silently borrowing a tier.

Co-authored-by: J <j@multica.ai>
Co-authored-by: multica-agent <github@multica.ai>
2026-07-10 14:00:38 +08:00
Bohan Jiang
6b980a8e71 feat(models): add Codex gpt-5.6 series (sol/terra/luna) to model list & pricing (MUL-4347) (#5188)
* feat(models): add Codex gpt-5.6 series (sol/terra/luna) to model list & pricing (MUL-4347)

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

* fix(models): official gpt-5.6 pricing, exact aliases, max/ultra effort levels (MUL-4347)

- Replace provisional gpt-5.6 rates with OpenAI's official announcement
  values (sol 5/30, terra 2.5/15, luna 1/6); cache read 0.1x input, cache
  write 1.25x input (frontend + backend, kept in sync).
- Anchor gpt-5.6 price aliases to exact match so unknown suffixed variants
  surface as unmapped instead of borrowing a tier.
- Add Codex 0.144.1 max/ultra effort levels to the label map and server
  enum so the daemon-advertised catalog matches what the API can persist;
  add a catalog->API contract test.
- Clarify that the codex Default flag is the effort-validation anchor, not a
  user-facing badge.
- Note the cache-write measurement limitation (codex usage stream doesn't
  report cache-write tokens yet).

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

---------

Co-authored-by: J <j@multica.ai>
Co-authored-by: multica-agent <github@multica.ai>
2026-07-10 13:00:34 +08:00
Naiyuan Qing
302662aee3 fix(issues): batch status applies directly; coalesce staged parent notifications (MUL-4155) (#5151)
Batch sub-issue status changes triggered two wrong behaviours from one user
action:

- Frontend popped the pre-trigger "现在开始处理?" confirm modal for every
  non-backlog target, but done/cancelled can never start a run, so it degenerated
  into a misleading "won't start → OK" step. handleBatchStatus now applies
  directly (product decision: batch status, including backlog → active promotion,
  applies like a single-issue/CLI change). Assign agent/squad and delete still
  confirm. The now-unreachable status mode is removed from RunConfirmModal and
  its locale keys.

- Backend evaluated the stage barrier per-child inside the batch loop, using a
  mid-batch sibling snapshot. A batch closing several stages at once emitted one
  comment per intermediate stage, pinned the parent assignee's wake to a stale
  "advance Stage N+1" instruction (the accurate wake was swallowed by the
  pending-task dedup), and the outcome depended on issue_ids order.
  BatchUpdateIssues now collects terminal transitions and evaluates each parent
  once against the batch's final state (notifyParentsOfBatchChildDone): at most
  one accurate comment + one wake per parent, order-independent. Single-issue
  UpdateIssue is unchanged; WillEnqueueRun is untouched.

Tests: cross-stage batch done/cancelled (forward + reverse) and lower-stage-only
on the backend; status-direct / assign-confirm / delete-confirm routing on the
frontend.

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>
2026-07-10 09:11:29 +08:00
Naiyuan Qing
f4de0948a2 refactor(ui): unify ActorAvatar size tiers + round all avatars & cropper (MUL-4277, MUL-4184) (#5133)
* refactor(ui): converge ActorAvatar size to semantic tiers (MUL-4277)

Replace the free-form numeric `size` on ActorAvatar with a constrained
`AvatarSize` union (xs/sm/md/lg/xl/2xl) so avatar dimensions are chosen by
role instead of ad-hoc pixels. This eliminates the magic-number drift where
the same role rendered at different sizes across pages.

- Add `@multica/ui/lib/avatar-size` (AvatarSize union + AVATAR_SIZE_PX map +
  default tier).
- Base `ActorAvatar` (packages/ui) and business `ActorAvatar`/`AgentStatusDot`
  (packages/views) now take `AvatarSize`; internal font/icon math and the
  presence-dot threshold read px from the map.
- Migrate all web/desktop call sites (packages/ui + packages/views) from
  numeric sizes to tiers using the role table
  (12,14->xs 16,18,20->sm 22,24,28->md 30,32,34->lg 40,44->xl 56,64->2xl).
- Token-ise the derived consumers `AgentAvatarStack` and
  `IssueAgentActivityIndicator` (px looked up internally for overlap/+N math).

Out of scope (per plan): ui/avatar.tsx primitive, account-tab/AvatarPicker,
mobile, and component-name disambiguation.

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

* fix(ui): unify all avatars and the upload cropper to round (MUL-4277, MUL-4184)

main's avatar-shape decision rendered non-human actors (agent, squad,
system) and the workspace logo as rounded squares, and the upload cropper
mirrored that with a square crop window. Per the updated decision
(avatars_and_cropper_round_required), every avatar and the crop UI are now
circular; the square path is removed rather than left as dead config.

- Base ActorAvatar: always rounded-full (drop the isHuman/rounded-md split).
- avatar-crop-dialog: remove the AvatarCropShape/square path; crop window is
  always cropShape="round".
- avatar-upload-control: drop VARIANT_SHAPE; the control is always round and
  no longer threads a shape to the dialog (variant still drives the fallback).
- Strip rounded-md/rounded-none square overrides from agent/squad/member
  ActorAvatar call sites; round the read-only agent/squad static wrappers.
- WorkspaceAvatar: round the org logo so it matches the (now round) workspace
  upload/crop and the shared avatar shape.

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

* fix(ui): make round avatar shape a hard invariant (MUL-4277)

Close the two remaining square squad-avatar paths flagged in review and
prevent call sites from re-squaring the avatar:

- base ActorAvatar: keep `rounded-full` as the last class in cn() so a
  call-site `className` can no longer override the circle.
- SquadHeaderAvatar: drop `className="rounded"` (was overriding the base
  circle into a small rounded square).
- SquadsPage no-avatar fallback: route through the shared ActorAvatarBase
  (`isSquad size="lg"`) instead of a hand-written rounded-md tile, so the
  fallback matches the image path — one shape source of truth.

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

* fix(views): round the agent/squad avatar loading skeletons (MUL-4277)

The avatar placeholder skeletons on the agent/squad list, detail, and
profile-card loading states were still rounded squares (rounded-md/lg) from
the pre-round era, so the avatar visibly popped from square to circle on
load — inconsistent with the round avatars and with the member/inbox/issue
skeletons that already use rounded-full.

Round all five: agents-page, agent-detail-page, squads-page,
squad-detail-page, squad-profile-card.

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-07-10 08:31:40 +08:00
Naiyuan Qing
3f02083fec feat(editor): Linear-style issue identifier autolink (MUL-4241) (#5090)
* feat(editor): Linear-style issue identifier autolink (MUL-4241)

Bare issue identifiers (e.g. MUL-123, TES-1) now render as navigable issue
chips and can be typed/pasted into a real mention, instead of staying inert
text. Covers Phase 1 (readonly render) and Phase 2 (editable editor); the
Phase 3 batch resolve API is intentionally deferred.

Phase 1 — readonly render autolink
- Pure, markdown-aware detector `preprocessIssueIdentifiers` in
  @multica/ui/markdown rewrites bare identifiers to
  `[MUL-123](mention://issue/MUL-123)`, skipping code, existing links,
  URLs, and file/path tokens. Runs before linkify/file-card.
- `isIssueIdentifier` distinguishes a bare identifier from a real mention
  UUID at render time (a UUID never matches the identifier pattern).
- Chat markdown and comment/description readonly both resolve identifiers
  to a real issue via a workspace-scoped, exact-match TanStack Query
  (`issueIdentifierOptions`), rendering a chip on a hit and plain text on a
  miss / cross-workspace / while loading. The exact `identifier ===` filter
  enforces the workspace prefix, since the backend search matches by number.
- Autolink is opt-in per surface; the shared editable preprocess pipeline is
  untouched so editable content is never rewritten with fake mentions.

Phase 2 — editable editor input/paste
- Async ProseMirror plugin resolves a completed identifier (boundary typed
  after it, or found in pasted text) and swaps it for an issue mention node,
  serialising to canonical `[MUL-123](mention://issue/<uuid>)`. Only genuine
  user edits seed candidates (programmatic setContent is gated), so opening
  existing content never rewrites it. Resolver injected from the setup layer;
  no React hooks inside the extension.

Tests: detector (code/link/url/path skips, dedupe), core resolver (exact
match, wrong-prefix miss, empty response, key shape), chat + readonly render
(hit/miss/code/canonical), and the editable extension (type/paste/miss/
inline-code/mount-safe/incomplete-token).

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

* fix(editor): scope Phase 2 autolink to the captured candidate range (MUL-4241)

Howard final-review blocker: the async resolve rescanned the whole document
for every occurrence of the resolved identifier, so completing a new `MUL-1`
also rewrote a pre-existing `MUL-1` the user never touched (persisted-content
rewrite; violated "opening existing content is not rewritten").

Fix: capture the specific candidate range(s) a user transaction introduces —
the token before the caret when typing, the tokens inside the pasted slice on
paste — into plugin state, mapping each range forward on every subsequent
transaction. After async resolve, replace ONLY those mapped ranges, verifying
each still holds exactly that identifier with intact boundaries and no
code/link mark. No document-wide scan by identifier. Also skip link-marked
text at capture so an existing link label is never converted.

Regression tests: (1) typing a new MUL-1 converts only the new occurrence,
not a pre-existing identical one; (2) paste converts identifiers inside the
paste range but leaves an identical one outside it untouched; (3) an
identifier already carrying an explicit link mark is not replaced.

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-07-09 21:46:33 +08:00
Naiyuan Qing
c6783efd88 feat(views): unify avatar upload with crop editing (#5074)
* feat(views): unify avatar upload with crop editing across web/desktop

Add a shared AvatarUploadControl + AvatarCropDialog used by the user,
workspace, agent, and squad avatar entry points. Cropping (pan/zoom, fixed
1:1) and compression run client-side on canvas; the existing /api/upload-file
+ avatar_url chain is reused unchanged (no backend/API/DB changes). This
collapses four hand-rolled upload buttons into one control and removes
AvatarPicker.

Also make the shared display avatar treat all non-human actors (agent, squad,
system) as rounded squares — completing the "circles are for humans"
convention the editors already assumed, so display and editors agree.

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

* feat(views): rebuild avatar cropper to the "Edit avatar" reference form

Replace the hand-rolled canvas cropper with react-easy-crop to match the
requested design: full-bleed image with a dimmed overlay outside a bright
crop window, a rotate control, a zoom slider flanked by −/+, and a
Reset / Cancel / Save footer. Round window for people, rounded-square for
non-human actors; output stays a 512px square (webp, jpeg fallback) through
the same upload/avatar_url chain.

avatar-crop.ts keeps the encode pipeline and gains rotation-aware
getCroppedAvatarBlob; the interactive geometry now lives in react-easy-crop.

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

* fix(views): round square avatar crop frame

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>
2026-07-09 20:27:33 +08:00
Jiayuan Zhang
d3e51a7658 fix(i18n): translate Chat sidebar nav + page title to zh-Hans (MUL-4322) (#5163)
Co-authored-by: Lambda <agent@multica.ai>
Co-authored-by: multica-agent <github@multica.ai>
2026-07-09 17:54:18 +08:00
Jiayuan Zhang
4763772c4f feat(chat): auto-focus compose box on new chat (#5146)
Starting a new chat (⊕ new chat on the Chat tab, or ⊕ / switching agent
in the floating window) now pulls keyboard focus into the compose box so
the user can type immediately, instead of having to click into it first.

Focus is driven by a monotonic `focusRequest` nonce bumped only by the
new-chat handlers, so selecting an existing chat or opening one via a
`?session=` deep link never steals focus. ContentEditor.focus() latches
through to onCreate when the editor is not yet mounted (immediatelyRender:
false), so the freshly-mounted compose box focuses on its first frame.

Co-authored-by: Lambda <lambda@multica.ai>
Co-authored-by: multica-agent <github@multica.ai>
2026-07-09 14:26:59 +08:00
Naiyuan Qing
0ffb5f6863 fix(markdown): drop trailing markdown delimiters from linkified URLs (MUL-4242) (#5139)
Supersedes the read-only gfm-autolink approach (#5091), which split URL
linkification across two engines: the editor kept the string preprocessor
(urls:true) while the read-only renderer let remark-gfm autolink (urls:false)
plus a remark-cjk-autolink plugin. gfm autolink still swallowed the closing
`**` into the href whenever a CJK punctuation immediately followed
(`**url**(MUL)`), so bold-wrapped URLs stayed broken in Chinese prose.

Fix it once, at the shared string layer: collectLinkifyMatches now drops a
trailing run of markdown delimiters (`*`, `~`) from each URL match, so
`**url**` yields a clean `**[url](url)**` and the emphasis closes. Editor and
read-only share preprocessMarkdown / preprocessLinks again — one linkify logic,
no renderer-specific machinery.

- linkify.ts: trailing-delimiter strip in collectLinkifyMatches; CJK rescan is
  keyed off the terminator index, independent of the trim.
- Remove the urls:false split (detectLinks / preprocessLinks / preprocessMarkdown)
  and delete the remark-cjk-autolink plugin.
- Tests: **url**, **url**(CJK, CJK multi-URL, explicit link untouched, and the
  trailing-* tradeoff.

Known tradeoff: a bare URL that genuinely ends in `*` (e.g. a glob) has the `*`
dropped from the link — identical to GitHub's autolink, locked by test.

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>
2026-07-09 13:33:19 +08:00
Naiyuan Qing
0582db356e feat(issues): make cancelled a default status, not filter-gated (MUL-4290) (#5135)
MUL-4261 surfaced cancelled issues only when the status filter explicitly
selected "cancelled": a separate BOARD_STATUSES (six statuses, cancelled
excluded) plus a runtime showCancelled gate hid cancelled from the default
list/board/swimlane. That is the wrong product model — cancelled is a
lifecycle state in the same category as todo/in_progress/done/blocked and
should be a first-class default column.

- Remove BOARD_STATUSES. Its only purpose was to exclude cancelled, which
  this change reverses. PAGINATED_STATUSES is now ALL_STATUSES; the surface's
  default visible/hidden status derivation, the assignee-grouped board's
  default status set, and the swimlane column fallback all use ALL_STATUSES.
- Remove the `bucketedIssues.filter(status !== "cancelled")` gate in the
  surface data layer. Cancelled flows through to list/board/swimlane columns,
  header facet counts, batch selection, and isEmpty like every other status.
- hiddenStatuses derives from ALL_STATUSES, so cancelled participates in the
  board show/hide controls consistently (hideStatus already used ALL_STATUSES).

The status filter now narrows the visible set instead of unlocking an
otherwise-hidden bucket. Cancelled renders last (its canonical ALL_STATUSES
position). Mobile keeps its own status mirror and is out of scope.

Regression tests updated: controller now asserts cancelled is a default
visible status, the filter narrows (and can hide cancelled), swimlane renders
the Cancelled column by default and drops it only when the filter narrows past
it, and the assignee board fetches cancelled by default.

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>
2026-07-09 11:10:24 +08:00
Naiyuan Qing
6077f1a9b0 feat(issues): surface cancelled issues via status filter (MUL-4261) (#5099)
* feat(issues): surface cancelled issues via status filter (MUL-4261)

Cancelled issues were never visible in the web/desktop issue surface:
`PAGINATED_STATUSES`/`BOARD_STATUSES` excluded `cancelled`, so the list/
board/swimlane never fetched or rendered it, and the status filter offered
a "Cancelled" checkbox that resolved to an empty list.

Implement plan A (fetch-always, hide-by-default):

- `PAGINATED_STATUSES` now includes `cancelled`, so it is always fetched
  into the byStatus cache and rebuckets correctly when an issue is
  cancelled (previously the card was dropped). `BOARD_STATUSES` stays the
  default *visible* column set.
- The surface gates the flattened list on the status filter: cancelled
  issues are excluded from `surfaceIssues` (and therefore list/board/
  swimlane columns, header facet counts, batch selection, and isEmpty)
  unless the filter explicitly selects "cancelled". Then a Cancelled
  section appears, sorted last.
- `hiddenStatuses` stays board-only, so cancelled is never offered as a
  hideable/persistent board column.

Dragging a card into the Cancelled column (visible only when filtered)
sets status=cancelled through the existing generic column DnD — no new
entry point or copy added.

Non-goals (unchanged): mobile, member/agent archive surfaces, an
always-on cancelled column.

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

* fix(issues): swimlane must keep the cancelled column when filtered (MUL-4261)

The swimlane derived its status columns as
`BOARD_STATUSES.filter(s => visibleStatuses.includes(s))`, re-imposing
canonical order by intersecting with BOARD_STATUSES. Since BOARD_STATUSES
omits `cancelled`, a filter-selected Cancelled column was silently dropped
even though the controller's `visibleStatuses` included it — the surface
fetched and gated cancelled correctly, but swimlane never rendered it.

Filter against ALL_STATUSES instead: same canonical ordering, but a
selected `cancelled` column now survives. `hiddenStatuses` stays
board-only, so cancelled is still never a hideable/persistent column.

Regression tests:
- swimlane renders a Cancelled column + its cards when cancelled is in
  visibleStatuses, and omits it otherwise (verified failing pre-fix);
- controller asserts hiddenStatuses never contains cancelled.

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-07-09 10:07:34 +08:00
Naiyuan Qing
cac2965ddb fix(markdown): autolink read-only URLs in the parse tree, not raw text (MUL-4242) (#5091)
* fix(markdown): autolink read-only URLs in the parse tree, not raw text

Read-only markdown surfaces (comments, descriptions, chat) pre-linkified
bare URLs by rewriting the raw source to [url](url) before parsing. Because
linkify-it treats `*` as a valid URL character, a bare URL followed by a
bold close — `**PR:https://…/5081**` — had the trailing `**` swallowed into
the match and rewritten as [url**](url**). That consumed the emphasis closer
(the bold never closed; the leading `**` rendered as literal asterisks) and
corrupted the href with a trailing `**` (MUL-4242).

Let remark-gfm autolink URLs in the parse tree instead, where emphasis is
already resolved so an adjacent delimiter can never be absorbed. The custom
string pass now runs in a `urls: false` mode on read-only surfaces and only
linkifies file paths (which gfm never does). A small remark plugin
(remark-cjk-autolink) re-applies the existing CJK URL boundary to gfm's
autolink literals so `https://x/a。后面` still stops at 。.

The Tiptap editor path is unchanged (`urls: true`): @tiptap/markdown does not
autolink bare URLs, so it still needs the string pass.

Note: read-only URL autolinking now follows GFM semantics (scheme, www., or
email required); bare fuzzy domains like `NBA.com` render as plain text on
read-only surfaces, matching CommonMark/GFM.

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

* fix(markdown): keep every URL in a CJK-separated run linked in readonly

Follow-up to the read-only autolink fix. remark-gfm glues `url1、url2` into a
single autolink literal because it treats CJK punctuation as a URL character;
remark-cjk-autolink trimmed only at the first terminator and dropped the tail
to plain text, so the second URL stopped being a link — a same-class regression
of MUL-4242 for CJK-punctuation-separated URLs (flagged in review).

Re-derive the segments with detectLinks (which reuses collectLinkifyMatches'
truncate-and-rescan) and rebuild the [link, text, link, …] sequence, so every
URL in the run stays linked. Adds a read-only test for
`两个地址 https://a.com/x、https://b.com/y`.

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-07-09 09:49:11 +08:00
MeloMei
67cd1e645a fix(core): add exponential backoff with jitter to WSClient reconnect (#5036)
* fix(core): add exponential backoff with jitter to WSClient reconnect

The WebSocket client used a flat 3-second reconnect delay with no
backoff, jitter, or attempt limit. When the server restarts, every
connected client (web + desktop) reconnects at exactly T+3s, creating
a thundering-herd connection spike.

Replace the fixed delay with exponential backoff:
- Base delay 1 s, doubling each attempt (1 → 2 → 4 → 8 → …)
- Cap at 30 s to keep recovery time reasonable
- ±20 % jitter to decorrelate clients that disconnect simultaneously
- Give up after 20 consecutive failures (log error, allow manual retry)
- Reset the counter on successful authentication

Add 7 unit tests covering the backoff curve, cap, jitter range,
counter reset, max-attempt cutoff, and disconnect cancellation.

Closes #5035

* fix(core): clamp jittered delay to max and make jitter test deterministic

Address Copilot review feedback:

1. Clamp the final delay to RECONNECT_MAX_DELAY_MS after jitter is
   applied. Previously, when base was already at the 30s cap, +20%
   jitter could push the delay to 36s, violating the configured max.

2. Replace the nondeterministic jitter test (which relied on real
   Math.random() producing ≥2 distinct values in 20 samples) with a
   deterministic stub that alternates between 0 and 1, asserting
   exact min/max delays (800ms and 1200ms).

* fix(core): remove reconnect attempt limit, retry indefinitely with capped backoff

Address maintainer feedback (NevilleQingNY):

The web/desktop UI does not currently expose a visible disconnected
state or manual retry action, so the 20-attempt give-up limit would
leave an open tab silently stale after a long outage. Remove the
limit and let the client retry indefinitely with the 30s capped
jittered delay.

- Drop RECONNECT_MAX_ATTEMPTS constant and the give-up early-return
- Update JSDoc to document the indefinite-retry contract
- Replace "stops after max attempts" test with "keeps retrying
  indefinitely with capped delay" that verifies 25+ attempts still
  schedule reconnects at 30s
2026-07-09 08:55:19 +08:00
Jiayuan Zhang
3a3159a14a fix(ui): unify chat list & inbox list avatar size to 32px [MUL-4283] (#5121)
Chat list avatars read too large at 36px; inbox list was 28px, so the two
surfaces were inconsistent. Standardize both to 32px (the common list-row
avatar size) — chat list 36->32 (fallback placeholder size-9->size-8),
inbox list 28->32.

Co-authored-by: Lambda <lambda@multica.ai>
Co-authored-by: multica-agent <github@multica.ai>
2026-07-09 02:51:39 +08:00
Jiayuan Zhang
3efe5bac17 fix(views): restore chat and inbox list gutters (#5120)
Co-authored-by: Lambda <lambda@multica.ai>
Co-authored-by: multica-agent <github@multica.ai>
2026-07-09 02:29:21 +08:00
Jiayuan Zhang
f8c4c88129 feat(agents): show runtime model + effort on agent hover card [MUL-4280] (#5117)
The agent profile hover card now surfaces the runtime-native model id
(mono, e.g. `claude-opus-4-8`) with the reasoning/effort token as a badge,
so a quick hover answers "which model is this agent running?" without
opening the detail page. Empty model renders a "Runtime default" placeholder.

Also fixes the Skills row: chips wrap to multiple lines, so the vertically
centered label drifted to the middle chip — it now pins to the first chip
row (items-start + pt), and each chip truncates so a long skill name can't
blow out the card width.

The effort badge is gated on `effort` alone (not `hasModel`): an agent with
no pinned model can still persist a thinking_level override that applies at
run time, and hiding it would misreport the agent's real config. Adds
agent-profile-card.test.tsx covering the model/effort render states,
including the `model:"" / thinking_level:"high"` regression.

New i18n: profile_card.model_label / model_unset (en/zh-Hans/ja/ko).

Co-authored-by: Lambda <lambda@multica.ai>
Co-authored-by: multica-agent <github@multica.ai>
2026-07-09 02:08:36 +08:00
Jiayuan Zhang
23e4f125b2 feat(chat): default floating chat window to on (#5113)
Co-authored-by: Lambda <lambda@multica.ai>
Co-authored-by: multica-agent <github@multica.ai>
2026-07-09 01:41:35 +08:00
Jiayuan Zhang
4a93f4ce41 fix(chat): advance selection to next chat when archiving the open session (#5110)
* fix(chat): advance selection to next chat when archiving the open session

Archiving the chat currently open in the two-pane Chat tab left the
conversation pane showing a now read-only, dangling session. Mirror the
Inbox list's handleArchive: move selection to the next chat in the
sorted, non-archived history list, fall back to the previous one, and
clear only when nothing is left. Archiving a non-open row is unchanged.

Closes MUL-4278

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

* fix(chat): route archive-advance through the shared controller

Address review of #5110:

- Advance now routes through handleSelectSession so selectedAgentId stays
  in sync when the next chat belongs to a different agent (a follow-up
  "new chat" no longer defaults to the archived chat's agent).
- Move the advance/next-prev/clear logic into use-chat-controller
  (advanceSelectionAfterArchive + archiveSession) and drive both Chat-tab
  entry points from a single ChatPage.handleArchive: the thread-list row
  AND the conversation header ⋯ menu (the header previously only flipped
  status, stranding the user on the archived read-only conversation).
- Mobile: archiving the open fullscreen conversation returns to the list.
- Floating window: archiving the open chat now advances to the next chat
  (with cross-agent sync) instead of clearing, matching the Chat tab.

Tests: controller test covers advance/fallback/clear/no-op + cross-agent
sync; thread-list test now asserts it delegates to onArchive.

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

---------

Co-authored-by: Lambda <lambda@multica.ai>
Co-authored-by: multica-agent <github@multica.ai>
2026-07-08 23:13:46 +08:00
Jiayuan Zhang
956f74e1d2 fix(chat): refresh Chat V2 input placeholder without remount (MUL-4276) (#5111)
Tiptap's Placeholder only reads its text at mount, and ContentEditor had a
defaultValue-sync effect but no placeholder-sync effect. Switching between
sessions of the same agent doesn't remount the editor, so the placeholder
froze on the previous value — e.g. stuck on "This session is archived" after
visiting an archived session, even on an active, usable input.

Mutating the extension's string option at runtime does not repaint (Tiptap
snapshots a string placeholder at mount). A function placeholder, however, is
re-invoked on every decoration pass, so:

- extensions: `placeholder` option now accepts `string | (() => string)`.
- content-editor: pass a getter over a live `placeholderRef`; a new sync effect
  updates the ref and dispatches an empty transaction (docChanged=false, no
  onUpdate loop) to force a decoration recompute — no remount required. This
  also fixes placeholder staleness when the session/agent archive state changes.
- chat-input: update the editorKey comment (placeholder no longer relies on the
  agent-switch remount).
- tests: getter reads the live value + one repaint on change; no repaint when
  the placeholder prop is unchanged.

Co-authored-by: Lambda <lambda@multica.ai>
Co-authored-by: multica-agent <github@multica.ai>
2026-07-08 23:05:53 +08:00
Jiayuan Zhang
a51ab4d551 feat(chat): Chat V2 — first-class IM-style Chat tab (MUL-4171) (#5076)
* feat(chat): Chat V2 — first-class IM-style Chat tab (MUL-4171)

Replace the floating chat FAB/window with a first-class Chat tab under
Inbox, laid out as an IM-style two-pane surface (thread list + conversation).

Highlights:
- New Chat page (packages/views/chat/chat-page.tsx) with URL-addressable
  session selection; web + desktop routing wired up. Removes the old
  chat-fab / chat-window / resize-handles / context-items paths.
- IM thread list: agent avatar + last-message preview + IM timestamp, red
  unread *count* badge (read-cursor model), presence-gated typing vs waiting.
  Rename lives only in the conversation header ⋯ menu (not the list hover).
- Per-session conversation header (rename / view agent / delete), agent-aware
  empty state (avatar + name + description + starter prompts), and a
  deterministic clean-title derivation from the first message.
- Server: read-cursor unread model (migration 145) and per-user pinned agents
  (migration 146, dedicated chat_pinned_agent table + handler/queries).
  New-agent welcome chat auto-enqueues a real agent run (LLM intro, no
  static template).
- Design: fade the global --border token; borderless list headers on
  Chat/Inbox, kept (faded) on the conversation header.

Verified: pnpm typecheck (all packages), go build ./..., go vet, gofmt.
Co-authored-by: multica-agent <github@multica.ai>

* feat(chat): make new-agent welcome read as an agent-initiated intro (MUL-4230)

The "meet your new agent" chat used to insert a fake user message
("👋 Hi! Please introduce yourself …") and have the agent reply to it, so
the thread looked like the creator prompting the agent.

Drop the persisted user message. Flag the auto-created session
is_agent_intro (migration 147) and drive the intro run server-side: the
daemon builds a proactive self-introduction prompt for such sessions
(buildChatPrompt) instead of a "reply to their message" prompt. The intro
stays LLM-generated; the thread now opens with the agent's own message, as
if it reached out first.

- migration 147: chat_session.is_agent_intro
- CreateChatSession carries the flag; sendAgentWelcomeChat no longer
  persists/publishes a user message
- daemon: ChatIntro threaded from session flag → intro prompt

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

* feat(chat): Settings toggle for the floating chat window (MUL-4235) (#5080)

* feat(chat): Settings toggle for the floating chat window (MUL-4235)

Re-introduce the floating chat overlay on top of Chat V2 as an optional,
Settings-gated surface instead of deleting it outright.

- Settings → Preferences → Chat: a switch (floatingChatEnabled, persisted
  client preference, default ON) to show/hide the floating window.
- FloatingChat wrapper owns the two gates: the preference, and the /chat
  route (hidden on the tab so the same activeSessionId isn't shown twice).
- ChatFab + a compact ChatWindow that reuse the shared useChatController and
  conversation components, so activeSessionId stays in lockstep with the tab.
- Restore use-chat-context-items so the overlay's @ surfaces the current
  issue/project (the 'current context' affordance) — the tab stays manual.
- i18n (en/zh-Hans/ja/ko), store unit tests.

typecheck: core/views/web/desktop green. tests: chat store 9, settings 82,
chat 39 pass.

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

* feat(chat): dedicated Chat settings tab, floating window opt-in (MUL-4235)

Address review: give Chat its own Settings tab instead of a section inside
Preferences, and default the floating window OFF (opt-in).

- New Settings → Chat tab (chat-tab.tsx) under My Account; moves the
  floating-window toggle out of the Preferences tab.
- floatingChatEnabled now defaults OFF — only an explicit enable from the
  Chat tab mounts the FAB/overlay.
- i18n: page.tabs.chat + a top-level chat block (en/zh-Hans/ja/ko);
  revert the Preferences chat section and its test mock; store tests updated
  for the opt-in default.

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

---------

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

* refactor(chat): drop starter prompts from chat empty state (MUL-4237) (#5081)

The three starter prompts (List my open tasks by priority / Summarize what
I did today / Plan what to work on next) read as filler more than help, so
remove them along with the now-unused returning_subtitle ("Try asking").

The empty state keeps its agent-aware header — avatar + "Chat with {name}"
+ optional description — and the composer stays the entry point. Locale
keys dropped across en/zh-Hans/ja/ko (parity preserved).

Based on the Chat V2 branch (parent MUL-4171, #5076), not main.

Co-authored-by: Lambda <lambda@multica.ai>

* feat(chat): pin a chat to the top of the Chat list (MUL-4240) (#5082)

Builds on Chat V2 (#5076). Adds a per-conversation pin so a user can
keep important chats at the top of the IM-style thread list, above the
activity-sorted rest.

Backend:
- migration 148: chat_session.pinned_at (nullable) + partial index; the
  timestamp doubles as the pinned-group sort key and the boolean flag.
- list queries order pinned-first, then by most-recent activity.
- SetChatSessionPinned query + PATCH /api/chat/sessions/{id}/pin handler;
  pinning never bumps updated_at, so an unpinned chat won't jump the list.
- ChatSessionResponse.pinned + chat:session_updated carries the new state.

Frontend:
- ChatSession.pinned; setChatSessionPinned API + useSetChatSessionPinned
  with optimistic re-sort; shared sortChatSessions comparator.
- thread list: pin indicator on pinned rows + pin/unpin hover action;
  list sorted pinned-first so it stays ordered after cache patches.
- realtime patch re-sorts on pin change; en/ja/ko/zh-Hans strings.

Tests: SetChatSessionPinned handler test, sortChatSessions unit tests.

* feat(chat): round send button, move file upload into a + menu (MUL-4250) (#5088)

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

* fix(inbox): match chat list selected-item style (inset padding + rounded) (#5093)

Wrap the inbox list in p-1 and give each row rounded-md/px-3 so the
selected bg-accent reads as an inset rounded card — same treatment the
chat thread list already uses — instead of a full-bleed, sharp-cornered
highlight. Content stays 16px-inset (p-1 + px-3 == old px-4).

MUL-4253

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

* fix(chat): rename + menu upload item to "Image or files" (MUL-4250) (#5092)

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

* fix(chat): stop welcome intro session repeating the same introduction (MUL-4259)

The is_agent_intro flag on chat_session is persistent, so every follow-up turn on a welcome session re-selected the self-introduction prompt in buildChatPrompt and the agent kept replying with the same intro instead of answering the user.

Gate resp.ChatIntro at claim time on the session still having zero human (role='user') messages via a new ChatSessionHasUserMessage query: the first, message-less server-driven turn introduces the agent; once the creator replies, later turns fall back to the normal reply prompt.

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

* fix(chat): address review findings + unbreak CI (MUL-4171)

- task:failed now refreshes the sessions list (invalidateSessionLists), so
  the thread-list preview / unread / sort stays correct after an agent
  failure — FailTask persists a failure chat_message but only broadcasts
  task:failed, mirroring the chat:done success path.
- Self-heal stale chat deep links: once the sessions list has loaded and a
  ?session= id isn't in it (deleted / no access / never existed) with nothing
  in flight, clear the selection instead of rendering an editable empty chat
  that would POST into a nonexistent session. Freshly-created sessions are
  exempt (they carry optimistic messages + a pending task).
- CI: add the new parameterless `chat` route to link-handler's
  WORKSPACE_ROUTE_SEGMENTS and to paths/consistency.test.ts (route set +
  expectedSegments) — keeps the two in sync, fixes the failing @multica/core
  test.
- Fix a MUL-4235/MUL-4237 merge collision that broke @multica/views
  typecheck: chat-window.tsx still passed the removed `onPickPrompt` prop to
  EmptyState.

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

* fix(chat): self-heal dangling session in shared controller, not just ChatPage (MUL-4171)

Re-review follow-up: the stale-session self-heal only lived in ChatPage, so
the floating ChatWindow still entered from a persisted activeSessionId and
would render an editable empty chat (then POST into a nonexistent session)
when the selected session was deleted / lost access off the /chat route.

- Move the self-heal into the shared useChatController so every surface (tab
  and floating window) drops a dangling activeSessionId once the sessions list
  has loaded and doesn't contain it.
- Harden ensureSession: trust the current id only when it's in the loaded list
  or is a just-created session still awaiting the refetch; a dangling id falls
  through to create a fresh session instead of POSTing into a 404.
- Exempt just-created sessions via an OPTIMISTIC-write signal
  (hasOptimisticInFlight: pending task or optimistic- message), not hasMessages
  — a session deleted elsewhere with real cached history stays eligible for
  self-heal. Add a unit test for the discriminator.

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

* test(views): fix app-sidebar useWorkspacePaths mock for the new chat nav (MUL-4171)

The AppSidebar personal nav gained a `chat` item, so it calls
`useWorkspacePaths().chat()` at render. The app-sidebar.test.tsx mock hadn't
been updated, so `p.chat` was undefined and every render threw
`TypeError: p[item.key] is not a function`, failing @multica/views#test in CI.

- Add `chat: () => "/acme/chat"` to the mocked useWorkspacePaths.
- Route the chat-sessions query key through a mutable `chatSessions` fixture.
- Add coverage for the Chat nav: renders the link, badges the summed
  unread_count, and hides the badge when all sessions are read — so this drift
  is caught next time.

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

* fix(chat): use the original floating window, not the rewritten one (MUL-4235) (#5102)

Follow-up to the merged #5080, which shipped a hand-written, simplified
ChatWindow and lost the original's animations / drag-resize / expand-minimize.
The floating window is just a quick entry point — it should be the original
UI, not a rewrite.

- Restore chat-window.tsx, chat-fab.tsx, chat-resize-handles.tsx and
  use-chat-resize.ts verbatim from main (0-diff): motion animations, drag
  resize, expand/minimize and the session dropdown are back.
- Restore the empty_state.returning_subtitle + starter_prompts i18n keys the
  original window renders (V2 had dropped them); drop the now-unused
  window.open_full_tooltip key the rewrite added.
- Settings gating is unchanged: FloatingChat still wraps the original FAB +
  window, gated by floatingChatEnabled (default off) and hidden on /chat.

typecheck: core/views/web/desktop green. tests: chat + settings views 126 pass.

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

* feat(chat): archive chats from the list, delete only from Archived (MUL-4263) (#5098)

Restore an archive flow as the reversible sibling of delete:
- Chat list hover now offers Archive (not Delete); pin/stop unchanged.
- A footer entry ('Archived · N') opens an Archived view listing archived
  chats; hard delete lives only there (hover -> unarchive + delete, with
  the existing inline confirm).
- Conversation header ⋯ menu mirrors this: active chats archive, archived
  chats unarchive/delete.

Backend: PATCH /api/chat/sessions/{id}/archive flips status active<->archived
(SetChatSessionArchived), broadcasts status on chat:session_updated so other
tabs re-sort into the right list. SendChatMessage already refuses archived
sessions, so archived chats stay read-only until unarchived.

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

* feat(chat): handle archived agents in Chat V2 list & conversation (MUL-4265) (#5100)

* feat(chat): handle archived agents in Chat V2 list & conversation (MUL-4265)

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

* refactor(chat): drop chat-list archive marker, keep conversation read-only (MUL-4265)

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

* feat(chat): apply archived-agent read-only to the floating chat window (MUL-4265)

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

---------

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

* fix(chat): address floating-window + archived-agent review blockers (MUL-4171)

Re-review follow-up on the restored floating ChatWindow + archive flow:

1. Floating stale-session self-heal. The restored ChatWindow doesn't use the
   shared controller, so its ensureSession trusted any non-empty
   activeSessionId and there was no dangling-session cleanup — a deleted /
   no-access persisted session could send into a nonexistent session. Ported
   the same guard used for the tab: a self-heal effect that clears a dangling
   activeSessionId once the sessions list has loaded, and ensureSession only
   trusts an id that's in the list or has an in-flight optimistic write
   (hasOptimisticInFlight, reused from use-chat-controller). handleSend seeds
   the optimistic message + pending task before setActiveSession, so a
   freshly-created session is never mis-cleared.

2. Floating dropdown bypassed archive-first safety. Its active rows offered a
   hard-delete, letting the floating window destroy active chats and skip the
   "archive first, delete only from Archived" model. Active rows now ARCHIVE
   (reversible, one-click) like ChatThreadList; the floating window offers no
   hard-delete — unarchive/delete live only in the full Chat page's Archived
   view (reachable via expand). Removed the now-dead delete-confirm machinery.

3. Orphan user message on archived-agent send. SendChatMessage created the
   chat_message before EnqueueChatTask, which rejects an archived / runtime-less
   agent — a stale client would land a user message then get a 500, orphaning
   it. Added a preflight that checks the session agent's archived / runtime
   state and returns 409 before any mutation, plus a handler test asserting the
   send is rejected with no message persisted.

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

---------

Co-authored-by: Lambda <lambda@multica.ai>
Co-authored-by: multica-agent <github@multica.ai>
2026-07-08 21:58:16 +08:00
Bohan Jiang
bcb111dbd9 fix(runtimes): machine-level rename + fix picker search copy (MUL-4217) (#5087)
* fix(runtimes): make rename a machine action + fix picker search copy (MUL-4217)

Follow-up to the runtime-naming feature based on testing feedback:

1. The create-agent runtime picker filters by MACHINE (its search matches the
   machine title / host / provider names), but the placeholder said "Search
   runtimes", which misled users into typing a runtime name. Change the
   placeholder and the empty-state to "Search machines" / "No matching
   machines" (all 4 locales).

2. Rename was framed as "rename this runtime" with an opt-in "apply to whole
   machine" checkbox, but the intent is naming the machine (the computer), not
   an individual runtime. Rework it into a machine-level action:
   - New RenameMachineDialog always names the whole machine (apply_to_machine);
     the per-runtime dialog + checkbox are gone.
   - The entry now lives on the Runtimes page, on the selected machine's header
     (a pencil next to the machine title), owner/admin gated — that's where the
     user looked for it. Removed the per-runtime pencil from the runtime detail
     page.

No backend change — apply_to_machine and machine-name inheritance already exist.

Verified: pnpm typecheck (all apps), full views suite (1650) + locale parity,
lint (0 errors).

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

* fix(runtimes): always show machine header in picker; pre-fill rename with shared name only

Testing + review follow-ups on #5087:

1. The create-agent picker hid the machine group header when there was only
   one machine (e.g. after a search narrowed to one), collapsing to a flat
   list. Always render the machine header so grouping stays consistent.

2. (Elon) RenameMachineDialog pre-filled from the first non-empty custom_name
   on the machine, but the machine title only uses a name when ALL runtimes
   share one (sharedCustomName). A lone per-runtime name would thus pre-fill as
   if it were the machine name — the same runtime-vs-machine confusion this PR
   set out to remove. Export sharedCustomName and use it for the pre-fill;
   otherwise pre-fill empty. Added direct unit tests.

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

---------

Co-authored-by: J <j@multica.ai>
Co-authored-by: multica-agent <github@multica.ai>
2026-07-08 17:01:43 +08:00
Jiayuan Zhang
875eb55109 fix(chat): friendlier failure message when agent runtime errors (MUL-4249) (#5085)
Co-authored-by: Lambda <lambda@multica.ai>
Co-authored-by: multica-agent <github@multica.ai>
2026-07-08 16:37:58 +08:00
Bohan Jiang
fd3216fd6b feat(lark): let an agent's owner bind/manage its Lark bot (MUL-4213) (#5079)
* feat(lark): let an agent's owner bind/manage its Lark bot (MUL-4213)

Scan-to-bind was authorized by workspace role only, so a non-admin
member could not bind a Lark bot even to an agent they own. Authorize
the device-flow install, status poll, and revoke by the same rule that
governs every other agent-management op — canManageAgent: the agent's
owner OR a workspace owner/admin.

Backend:
- router: begin/status/revoke drop to workspace-member level; the
  per-agent check moves into the handlers (agent_id is a query param /
  installation id, which the role middleware can't see).
- BeginLarkInstall + RevokeLarkInstallation load the target agent and
  run canManageAgent.
- GetLarkInstallStatus scopes the read to the session initiator or a
  workspace owner/admin; others get 404 (no existence leak). Session
  state now carries InitiatorID for this.

Frontend:
- LarkAgentBindButton takes agentOwnerId and lets the agent owner
  through (mirrors canEditAgent).
- Agent Integrations tab gates Lark per-agent (owner or admin) while
  Slack stays workspace-admin-only, since its routes are unchanged.

Tests: begin/status/revoke authorization (owner, agent owner, unrelated
member) on the backend; agent-owner bind visibility on the frontend.

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

* fix(lark): keep orphan installation revoke available to workspace admins (MUL-4213)

RevokeLarkInstallation loaded the bound agent and ran canManageAgent
unconditionally, so once the agent was hard-deleted the load 404'd and
a workspace owner/admin could no longer disconnect the orphan Lark
installation — a documented cleanup path (ListByWorkspace lists orphans;
the active-connection query filters them; Settings surfaces "Unknown
Agent" Disconnect).

Fall back to workspace owner/admin-only revoke when GetAgentInWorkspace
finds no agent; agents that still exist keep the owner-OR-admin
canManageAgent check. A plain member gains no orphan-row cleanup rights.
No FK/cascade — resolved in the application layer.

Adds a backend regression test: orphan installation is revocable by a
workspace owner but not a plain member.

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

---------

Co-authored-by: J <j@multica.ai>
Co-authored-by: multica-agent <github@multica.ai>
2026-07-08 16:19:41 +08:00
Bohan Jiang
fd58e13bec feat(runtimes): custom runtime names + searchable machine-grouped picker (MUL-4217) (#5070)
* feat(runtimes): custom runtime names + searchable machine-grouped picker

MUL-4217. Runtime names were daemon-generated ("Claude (host)") and
uneditable, so picking one at agent-create time was painful once a
workspace had many machines.

Phase 1 — create-agent RuntimePicker: add a search box (>6 runtimes) and
group options by machine (Local/Remote/Cloud, online-first, current
machine first) reusing buildRuntimeMachines/filterRuntimeMachines. Rows
show the provider under a machine header instead of a flat repeated list.

Phase 2 — custom names: new nullable agent_runtime.custom_name column,
never written by the registration/heartbeat upsert so the daemon can't
clobber it; display is coalesce(custom_name, name) via runtimeDisplayName.
PATCH /api/runtimes/:id gains custom_name (+ apply_to_machine to name every
runtime sharing a daemon_id in one action, owner-scoped for non-admins).
Rename UI on the runtime detail page; `multica runtime rename` CLI command.

Verified: go build/vet, sqlc, handler tests (incl. new custom-name single
+ machine-fanout), 1650 views + 764 core TS tests, typecheck, locale parity.

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

* fix(runtimes): address review — persist machine name on new registrations, keep custom_name in register response

Elon's review on #5070 (MUL-4217):

1. Machine name looked "lost" when a new provider registered on an
   already-named machine — the new row landed with custom_name=null and
   broke sharedCustomName. Now a fresh runtime inherits the machine's shared
   custom name at register time (ListDaemonCustomNames + sharedDaemonCustomName),
   so the machine title stays stable as providers come and go.

2. DaemonRegister rebuilt the response row by hand and dropped custom_name,
   so register returned custom_name:null — inconsistent with list/get/update.
   Both branches now carry CustomName.

Also: tighten the updateRuntime patch type to custom_name?: string (drop the
misleading `| null`, since the server treats null as "unchanged", not "clear").

Tests: register response preserves custom_name; new runtime inherits machine name.
Co-authored-by: multica-agent <github@multica.ai>

* fix(runtimes): inherit machine name for failed-profile registrations too

Elon's re-review of #5070 (MUL-4217): the machine-name inheritance added
last round only covered the normal req.Runtimes path. The req.FailedProfiles
branch also upserts a daemon_id-scoped agent_runtime row (offline, profile
registration error), which shows up in the runtime list / machine grouping —
so on a named machine a failed custom-profile row landed with custom_name=NULL
and dragged the machine title back to the hostname.

Extract the inheritance into h.inheritMachineCustomName and call it from both
the normal runtime path and the failed-profile path. Add a test: named daemon
+ failed profile upsert -> the failed row's persisted custom_name is inherited.

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

---------

Co-authored-by: J <j@multica.ai>
Co-authored-by: multica-agent <github@multica.ai>
2026-07-08 16:00:17 +08:00
Bohan Jiang
405d88c1dc feat(squad): allow members to create and manage their own squads (MUL-4223) (#5071)
Squad create/manage was gated behind workspace owner/admin, inconsistent with
agents and projects which any member can create. Move squads to a creator-scoped
model: any member can create a squad and becomes its creator, and manages only
the squads they created; owner/admin continue to manage every squad.

Backend (server/internal/handler/squad.go):
- Add canManageSquad (admin/owner OR creator) and gate UpdateSquad, DeleteSquad,
  AddSquadMember, RemoveSquadMember, UpdateSquadMemberRole on it (member load +
  squad load + per-squad check, replacing requireWorkspaceRole).
- CreateSquad is now member-creatable.
- Add memberCanWireAgent: a non-admin may only wire agents they can @-trigger
  (canInvokeAgent as themselves) as squad leader (create/update) or worker
  (add member); admins may wire any workspace agent. Prevents a creator from
  smuggling an agent they cannot invoke into a squad.

Frontend:
- squad-detail-page: compute per-squad canManage (admin || creator) and render
  the inspector, members tab, instructions and archive read-only otherwise,
  mirroring the agent detail canEdit pattern.
- squads-page: per-row actions and the actions column now key off per-squad
  canManage instead of workspace-admin.

Squads stay visible workspace-wide (ListSquads unfiltered); creator transfer is
out of scope for this iteration.

Co-authored-by: J <j@multica.ai>
Co-authored-by: multica-agent <github@multica.ai>
2026-07-08 14:51:54 +08:00
Naiyuan Qing
324ebf6560 fix(core): mark list stale after off-window status count move (#5038)
The coordinator's absent-card status-move branch shifted one unit of
server total between the two status buckets and stopped there — no
stale key. Under the app's staleTime: Infinity + no focus-refetch
setup, explicit invalidation is the only channel that reconciles a
loaded list, so an open board would show the moved count with the row
permanently missing from the destination bucket's visible window
(e.g. "done 61" with 60 visible rows) until an unrelated event
happened to invalidate the list.

Push the list key onto staleKeys after a successful moveBucketTotal.
The count still moves instantly (optimistic UX unchanged); the flush
timing follows the existing contract — mutations invalidate on
onSettled, the WS path immediately. No mutation/WS fork, no new
coordinator parameters (MUL-4182).

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>
2026-07-08 14:13:00 +08:00
Multica Eve
4f371c5c9c fix: expose mcp config for supported providers (#5024)
Co-authored-by: Eve <eve@multica-ai.local>
Co-authored-by: multica-agent <github@multica.ai>
2026-07-07 15:32:38 +08:00
Naiyuan Qing
93aa959239 fix(workspace): await delete before navigating; suppress self-initiated realtime relocate (MUL-4129) (#4983)
* fix(workspace): drop workspace from list cache while delete is pending (MUL-4129)

The delete-workspace flow navigates away before awaiting the DELETE
(required ordering — see navigateAwayFromCurrentWorkspace's
CancelledError notes), but useDeleteWorkspace left the workspace in the
list cache until onSettled. During the pending window any list refetch
re-presented the deleting workspace as a selectable/current option.

Optimistically remove it in onMutate (after cancelling in-flight list
fetches), roll the snapshot back in onError so a failed delete restores
the workspace alongside the existing error toast, and keep the
onSettled invalidate as the server-truth reconcile.

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

* fix(workspace): own storage cleanup on delete success and tombstone pending deletes (MUL-4129)

Address final-review blockers on #4980:

1. The realtime workspace:deleted handler reverse-looks-up the slug from
   the list cache to clear the ${key}:${slug} persisted namespace; the
   optimistic removal empties that row on the initiating client, so the
   lookup misses and cleanup was silently skipped. Capture the slug in
   onMutate before removal and clear storage in onSuccess only — a
   failed DELETE rolls back and must not touch persisted state.

2. cancelQueries only covered fetches already in flight at onMutate.
   Add a pending-delete tombstone (marked onMutate, lifted onSettled
   before the reconcile invalidate) filtered inside workspaceListOptions'
   queryFn, so invalidation/reconnect/fetchQuery refetches that land
   mid-pending cannot write the not-yet-committed row back into cache.

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

* docs(claude-md): scope optimistic updates to same-screen field patches

Replace the blanket "mutations optimistic by default" state rule with
three scoped rules: optimistic only for predictable same-screen field
patches; navigating/confirming flows (create/delete/leave) await the
server first; chat send uses the pending-message pattern.

Aligned with TanStack Query maintainer guidance and React Router's
pending-UI criteria; the old blanket rule is what steered the original
MUL-4129 fix toward optimistic entity removal.

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

* fix(workspace): await delete before navigating; drop optimistic removal (MUL-4129)

Rework of the previous approach on this branch. The optimistic removal
emptied the workspace list cache at click time, while the settings page
was still mounted on the old slug — useWorkspaceId (URL slug + list
lookup) then threw 'no workspace selected'. Root cause of the original
navigate-first ordering was dual ownership of delete handling between
the initiating flow and the realtime workspace:deleted handler.

- useDeleteWorkspace: no optimistic removal, no rollback; onMutate only
  marks the delete self-initiated and captures the slug; onSuccess owns
  storage cleanup; onSettled invalidates.
- pending-delete.ts: repurposed from tombstone filter to self-initiated
  marker; kept on success (suppresses the WS echo), lifted on failure.
- use-realtime-sync: workspace:deleted no-ops for self-initiated
  deletes; it now only serves deletes initiated elsewhere.
- workspace-tab: confirm dialog stays open in loading state, navigate
  only after the DELETE succeeds; failure leaves the user in place with
  nothing to roll back. Replace throwing useWorkspaceId with
  workspace?.id + enabled gating (independent crash on external
  deletes of the current workspace).

Known debt: useLeaveWorkspace still navigates before awaiting
(member:removed has no self-initiated marker yet).

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

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>
2026-07-07 13:45:49 +08:00
LinYushen
566d51f1c0 perf(chat): fix pending-task index mismatch + cut aggregate request storm (MUL-4159) (#5018)
* perf(chat): fix pending-task index mismatch + cut aggregate request storm (MUL-4159)

ListPendingChatTasksByCreator was a top DB hotspot. Root cause: the partial
index idx_agent_task_queue_chat_pending (migration 040) only covers
status IN (queued, dispatched, running), but migration 109 added a fourth
in-flight status (waiting_local_directory) that both pending chat queries now
filter on. Postgres can only use a partial index when the query predicate is a
subset of the index predicate, so the 4-status query stopped using it and
degraded to a Seq Scan over the whole agent_task_queue.

Implements the reviewed P0-P3 plan in one PR:

P0 index fix (split single-statement CONCURRENTLY migrations per repo convention)
- 143: CREATE INDEX CONCURRENTLY idx_agent_task_queue_chat_pending_v2 covering
  all four in-flight statuses (same column list, so GetPendingChatTask still
  benefits).
- 144: DROP the superseded 3-status index, in its own migration.

P1 SQL + handler hot path
- ListPendingChatTasksByCreator now returns cs.agent_id and states
  chat_session_id IS NOT NULL so the planner can prove the partial-index subset.
- ListPendingChatTasks filters private-agent access against the already-loaded
  accessible-agent set using the returned agent_id, dropping the extra
  ListAllChatSessionsByCreator scan on the hot path.
- Regenerated sqlc.

P2 frontend request amplification
- FAB uses the new boolean has-any query gated on enabled:!isOpen, so the
  minimised button never holds the full aggregate.
- use-realtime-sync maintains the pending aggregate (list + has-any) in place
  from task lifecycle events (queued/dispatch/running/waiting_local_directory
  -> upsert; completed/failed/cancelled -> remove) instead of invalidating on
  every chat:message/chat:done, with a debounced fallback invalidate for
  reconnect / unknown payloads.

P3 boolean endpoint
- GET /api/chat/pending-tasks/has-any backed by HasPendingChatTasksByCreator
  (EXISTS). Permission filtering is baked in via agent_id = ANY($3); an empty
  accessible-agent set short-circuits to false. The detailed list stays for the
  ChatWindow history / stop-task flows.

Tests: new handler tests cover the private-agent gate on both endpoints
(hidden from a creator who lost access, visible to the agent owner) plus the
boolean status/terminal semantics.

EXPLAIN (ANALYZE, BUFFERS) on a 300k-row reproduction:
- before (3-status index): Parallel Seq Scan, ~300k rows filtered,
  shared hit=3012, 12.1 ms.
- after (v2 index): Index Scan on idx_agent_task_queue_chat_pending_v2,
  shared hit=131, 0.07 ms.

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

* fix(chat): stop optimistic cross-session pending aggregate writes from workspace-fanout task events (MUL-4159)

Review on PR #5018 flagged a real privilege-escalation bug in the P2 change:
use-realtime-sync optimistically upserted the cross-session pending aggregate
(pendingTasks / pendingTasksHasAny) from chat task:* events. Those events are a
workspace fanout delivered to every member (server still BroadcastToWorkspace,
see cmd/server/listeners.go), and the payload carries no creator / agent
visibility. So member B starting a chat task could flip member A's FAB to
has_pending=true, bypassing the server-side permission filter on
/api/chat/pending-tasks[/has-any].

Fix (option 1 from the review — the self-contained one): never optimistically
write the aggregate from task:* events. On every task lifecycle transition,
debounced-invalidate the aggregate so it is refetched through the
permission-filtering endpoint, which only returns the caller's own
creator-owned, accessible-agent tasks. The per-session pendingTask cache is
still written directly — it is keyed by chat_session_id and only rendered for
sessions the user can open (server-gated), so it is not a cross-user leak.
chat:message is still excluded from aggregate refresh, so the MUL-4159 request
storm stays fixed; task transitions are per-task and coalesced by the debounce.

- Removed upsertPendingAggregate / removePendingAggregate.
- Added exported refetchPendingChatAggregate(qc, wsId) — an invalidate, never a
  setQueryData — used by the debounced handler.
- Regression tests: refetchPendingChatAggregate leaves the cached
  has_pending/list untouched (no optimistic write) and only invalidates for an
  authoritative server-filtered refetch; no-ops without a workspace id.

Verified: @multica/core + @multica/views typecheck; full core vitest suite
(752 tests) green including the 2 new guard tests.

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

* chore(chat): address review nits on pending-tasks endpoints (MUL-4159)

- Restore the GetPendingChatTask godoc first line that was clipped when the
  has-any handler was inserted (nit#1).
- ListPendingChatTasks short-circuits to an empty list when the caller has no
  accessible agents, mirroring HasPendingChatTasks — skips the DB round-trip
  (nit#2).
- Add a cross-creator negative test: user A's in-flight task on a
  workspace-visible agent returns has_pending=false / empty list for user B,
  locking the cs.creator_id tenant gate that the agent-visibility filter does
  not cover (nit#3).

Verified: go build ./... and go test ./internal/handler -run PendingChatTasks
(7 tests) green against live Postgres.

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

---------

Co-authored-by: multica-agent <github@multica.ai>
2026-07-07 13:20:30 +08:00
Bohan Jiang
bcad2edc9e feat(issues): add Cmd+F in-page find to issue detail (MUL-4126) (#4989)
* feat(issues): add Cmd+F in-page find to issue detail

Replace the stopgap "find-in-page is virtualized" toast with a real find
bar (MUL-4126). Cmd/Ctrl+F opens a floating bar with keyword input, live
match count, and prev/next navigation that scrolls to and highlights each
match.

- Opening find force-renders the comment timeline flat (reusing the
  existing highlightCommentId escape hatch) so off-screen comments become
  searchable — the root cause of the original complaint.
- Matches are painted with the CSS Custom Highlight API (ranges only, no
  DOM mutation), so highlighting layers cleanly over React-rendered
  markdown and the contenteditable title/description editors.
- Scroll-to-match drives container.scrollTop directly (never native
  scrollIntoView; #3929).

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

* fix(issues): keep in-page find usable without CSS Custom Highlight API

On browsers lacking the CSS Custom Highlight API, `!supported` was folded
into the match-collection path, so Cmd/Ctrl+F opened the bar and swallowed
native find but reported 0 matches and could not navigate — strictly worse
than the native find it replaced (MUL-4126 review).

Feature-guard only the paint calls now: match collection, count, active
index, and scroll-to-match run regardless of support, while
`CSS.highlights.set/delete` / `new Highlight` stay behind the guard. The
MutationObserver re-derives ranges even when unsupported so fallback
counting/navigation track live DOM churn.

Adds a hook test that drives the degraded path (jsdom has no highlight API)
and asserts counting + prev/next still work.

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

---------

Co-authored-by: J <j@multica.ai>
Co-authored-by: multica-agent <github@multica.ai>
2026-07-07 11:54:08 +08:00
Bohan Jiang
3cb5dc3ad6 chore(analytics): retire redundant PostHog tracking (MUL-4127) (#4996)
* chore(analytics): retire redundant PostHog tracking (MUL-4127)

PostHog had become a chaotic, largely-unused second copy of data we already
query from the DB and Grafana. Remove the redundant instrumentation.

Server: every product event (signup, workspace_created, issue_created,
issue_executed, chat_message_sent, team_invite_*, onboarding_*, agent_created,
cloud_waitlist_joined, feedback_submitted, contact_sales_submitted,
squad_created, autopilot_created) is now in metricsOnlyEvents, so
metrics.RecordEvent still increments the Prometheus/Grafana counter but no longer
ships to PostHog. DB rows remain the source of truth. Runtime/autopilot/
agent_task lifecycle were already Prometheus-only.

Frontend: delete the PostHog-only funnel instrumentation — $pageview (+ web and
desktop trackers), download_intent_expressed/page_viewed/initiated, the
onboarding_started mirror, onboarding_runtime_path_selected/detected,
feedback_opened, and source_backfill_*. The source-backfill modal itself stays
(it PATCHes the questionnaire to the DB).

Kept on PostHog (frontend only): $exception autocapture and the
client_crash / client_unresponsive stability telemetry (no DB equivalent), plus
$identify/$set. captureSignupSource (attribution cookie) stays — it still feeds
the signup_source Prometheus label.

Verified: pnpm typecheck, pnpm lint (0 errors), vitest (core/views/web/desktop),
go test ./internal/analytics/... ./internal/metrics/...

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

* docs(analytics): fix stale PostHog references after MUL-4127 (review follow-up)

Addresses review of #4996 — three spots still described server events as active
PostHog signals after they became metrics-only:

- docs/analytics.md: issue_executed is no longer a PostHog success signal; it is
  Prometheus-only (multica_issue_executed_total) + issue.first_executed_at, in
  both the event contract and the Reconciliation section.
- docs/analytics.md: the signup $set_once person properties (email, signup_source)
  are no longer emitted — signup is Prometheus-only; only the bucketed
  signup_source survives as the multica_signup_total label.
- server/internal/metrics/business_events.go: RecordEvent doc comment no longer
  claims it ships product events to PostHog / "PostHog is reserved for
  user/product-behaviour events" — every server event is now metrics-only.

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

---------

Co-authored-by: J <j@multica.ai>
Co-authored-by: multica-agent <github@multica.ai>
2026-07-07 01:43:15 +08:00
Jiayuan Zhang
380a2b8cb8 fix(chat): default chat window to closed on workspace open (#5003)
Opening a workspace no longer pops the chat window for users who have
never toggled it — the FAB keeps chat discoverable. An explicit stored
open/closed preference is still honoured on reload.

Closes MUL-4149

Co-authored-by: Lambda <lambda@multica.ai>
Co-authored-by: multica-agent <github@multica.ai>
2026-07-06 23:25:27 +08:00
Bohan Jiang
7183ec9b6d feat: add agent list search (#4988)
Co-authored-by: J <j@multica.ai>
Co-authored-by: multica-agent <github@multica.ai>
2026-07-06 18:31:46 +08:00
Naiyuan Qing
152edab8d0 Revert "fix(workspace): drop workspace from list cache while delete is pendin…" (#4982)
This reverts commit f9b7e5035a.
2026-07-06 17:02:01 +08:00
Naiyuan Qing
f9b7e5035a fix(workspace): drop workspace from list cache while delete is pending (MUL-4129) (#4980)
The delete-workspace flow navigates away before awaiting the DELETE
(required ordering — see navigateAwayFromCurrentWorkspace's
CancelledError notes), but useDeleteWorkspace left the workspace in the
list cache until onSettled. During the pending window any list refetch
re-presented the deleting workspace as a selectable/current option.

Optimistically remove it in onMutate (after cancelling in-flight list
fetches), roll the snapshot back in onError so a failed delete restores
the workspace alongside the existing error toast, and keep the
onSettled invalidate as the server-truth reconcile.

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>
2026-07-06 17:00:57 +08:00
Bohan Jiang
a48b6f70ef fix(issues): replace nested "More" action submenu with "Relations" (MUL-3972) (#4847)
The issue action menu (3-dot / right-click) nested a "More" submenu inside the
already-open menu, so opening the menu surfaced yet another "More" — the first
level told you nothing about what was inside.

Rename that submenu to the semantically explicit "Relations" (关系 / 関係 / 관계)
with a Network icon, matching the noun-labelled pattern of the sibling submenus
(Status, Priority, Start date, Due date). Its contents are unchanged — create/add
sub-issue and set/remove parent — and stay grouped so future relation types
(blocks, duplicates, related) have a home.

- Rename i18n key actions.more -> actions.relations across en/zh-Hans/ja/ko
- Swap MoreHorizontal icon for Network
- Update the shared menu test

Co-authored-by: J <j@multica.ai>
Co-authored-by: multica-agent <github@multica.ai>
2026-07-06 15:11:41 +08:00
Bohan Jiang
346f818206 fix(runtime): add traecli to custom runtime profile whitelist (#4972)
Trae (traecli) already has a New() backend, launch header (traecli acp
serve) and provider branding, but was missing from every protocol_family
whitelist, so custom runtime profiles based on Trae were rejected and it
never appeared in the family picker.

Add traecli to SupportedTypes (Go), RUNTIME_PROFILE_PROTOCOL_FAMILIES
(TS), the lockstep test's want map, and a new migration 136 widening the
runtime_profile_protocol_family_check constraint.

MUL-4094, #4945

Co-authored-by: J <j@multica.ai>
Co-authored-by: multica-agent <github@multica.ai>
2026-07-06 15:03:17 +08:00
Naiyuan Qing
c84a939c83 fix(views): enable multi-file selection on all attachment upload buttons (#4962)
The FileUploadButton component already fans out per-file onSelect
callbacks and every editor surface already handles N concurrent
uploads (drag-drop and paste were multi-file all along), but five
call sites never passed the `multiple` prop, so the OS file dialog
capped picks at one file: chat composer, create-issue modal,
quick-create modal, issue description, and feedback modal.

Fixes MUL-4074.

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>
2026-07-06 09:43:03 +08:00
Naiyuan Qing
ebd0248be2 MUL-4016: fix mention tokenizer stacktrace backtracking (#4889)
* MUL-4016: fix mention tokenizer stacktrace backtracking

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

* fix(editor): de-ambiguate escaped-label regexes to kill ReDoS (MUL-4016)

The mention/slash/file-card label regexes used `(?:\\.|[^\]])` where both
alternatives can consume a backslash. On an unterminated match, each `\x`
run is enumerated 2^n ways — pasting a Java stacktrace (`\~\[...\]`) or a
crafted ~50-char string freezes the main thread for seconds (GitHub #4881).

Exclude backslash from the char class (`[^\]\\]`) so a backslash can only be
consumed by `\\.`. The alternatives become disjoint and matching is linear;
legal escaped-bracket labels like `David\[TF\]` still parse unchanged.

Fixed in all four sites that shared the pattern:
- mention-extension.ts tokenize()
- slash-command-extension.ts start() + tokenize()
- file-card.tsx FILE_CARD_MARKDOWN_RE
- packages/ui/markdown/file-cards.ts NEW_FILE_CARD_RE (runs on every
  read-only comment/description render, not just the editor)

Adds adversarial regression tests (repeated `\a` + missing closing bracket)
that fail in ~10-40s against the old regexes and pass in <1ms after the fix.
Builds on #4889's marker-first mention start().

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

* fix(editor): escape backslash in mention/slash labels for round-trip (MUL-4016)

Follow-up to the de-ambiguation fix, addressing Howard's PR review.

The linear tokenizer now treats "\" as an escape lead (\\.), so a label whose
serialized form contains a bare "\" adjacent to the closing "]" no longer parses
back — the "\]" is consumed as an escaped bracket and swallows the boundary. The
old ambiguous regex tolerated this by chance; the de-ambiguation exposes it.

mention/slash renderMarkdown escaped only [ and ], not \. Switch both to the
shared escapeMarkdownLabel() (escapes [ ] \ ( )) and mirror it on parse with
replace(/\\([[\]\\()])/g, "$1"), matching what file-card already does. This also
converges the three tokenizers on one escape contract. file-card was already
correct and is unchanged.

Adds parameterized round-trip tests for labels containing "\" / "\]" / parens
(e.g. "A\\", "ends\\", "a\\]b"); these fail on the old serializer and pass now.

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

---------

Co-authored-by: multica-agent <github@multica.ai>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-06 08:43:51 +08:00
Bohan Jiang
129efb7688 feat(runtime): allow qoder as a custom runtime profile base (#4883) (#4912)
Qoder CN (`qoderclicn`) users could only reach a working custom runtime by
misrouting through the Kiro backend, which launches `<cmd> acp
--trust-all-tools`. That is incompatible with Qoder's global `--acp` / `--yolo`
argv, so the task failed immediately with `kiro initialize failed` and no run
messages.

Expose `qoder` in the custom-profile protocol_family whitelist across every
lockstep layer:
- server/pkg/agent SupportedTypes (+ whitelist pin test)
- migration 134 runtime_profile protocol_family CHECK (NOT VALID, mirroring 126)
- packages/core RUNTIME_PROFILE_PROTOCOL_FAMILIES

The existing qoderBackend already honors an ExecutablePath override and launches
`<cmd> --yolo --acp`, so a profile with protocol_family=qoder and
command_name=qoderclicn now launches with the correct argv instead of the Kiro
shape. Provider branding/logo for qoder already exists.

MUL-4018

Co-authored-by: J <j@multica.ai>
Co-authored-by: multica-agent <github@multica.ai>
2026-07-04 23:16:01 +08:00
Multica Eve
1ae0a1f5bf fix(agents): defend AccessPicker against undefined invocation_targets (GH #4915) (#4924)
Opening the agent detail page in v0.3.37 crashed the whole route with
"Cannot read properties of undefined (reading 'some')" whenever the
cached agent record's `invocation_targets` was missing at runtime — even
though the TypeScript type declares it a required `AgentInvocationTarget[]`.

Root cause: `AccessPicker`'s `hasWorkspaceTarget` / `selectedMemberIds` /
`selectedTeamIds` helpers called `.some()` / `.filter()` directly on the
prop, and the same unguarded pattern was mirrored in `AgentMcpTab` and
the `canAssignAgentToIssue` permission gate. The field can legitimately
be undefined at runtime because:

- `packages/core/api/schemas.ts` declares `invocation_targets` as
  `.optional()`, and `MinimalAgentSchema` (used for the create-from-
  template response) also marks it optional.
- `api.listAgents` / `api.getAgent` return raw JSON without running
  through the schema, so an older self-host backend that predates
  MUL-3963 (permission_mode + invocation_targets) — the exact scenario
  in GH #4915 — yields an Agent with the field missing.

Fix (`?? []`) at every site that indexes into the list, matching the
already-established pattern in `create-agent-dialog.tsx`:

- `access-picker.tsx`: coerce inside the three helpers and widen the
  prop type to `AgentInvocationTarget[] | undefined` so the accepted
  runtime shape is documented.
- `agent-mcp-tab.tsx`: `(agent.invocation_targets ?? []).some(...)`.
- `permissions/rules.ts`: `agent.invocation_targets ?? []` before the
  workspace / member membership checks.

Adds three regression tests (AccessPicker owner + read-only paths,
AgentMcpTab shared-warning path, canAssignAgentToIssue) that pass
`undefined` for invocation_targets and assert the UI degrades to the
private / empty-allowlist state instead of throwing.

Co-authored-by: Eve <eve@multica-ai.local>
Co-authored-by: multica-agent <github@multica.ai>
2026-07-04 18:07:03 +08:00
Naiyuan Qing
dd9996d0aa MUL-4014: persist transcript filters and expansion (#4884)
* feat(transcript): persist log view preferences

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

* fix(transcript): wrap modal header controls

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

---------

Co-authored-by: multica-agent <github@multica.ai>
2026-07-03 16:45:53 +08:00
Multica Eve
4d968ba875 fix(agents): gate create-agent access picker on composio_mcp_apps flag (MUL-4010) (#4888)
Reuse the existing `composio_mcp_apps` feature flag instead of the
separate `agent_access_picker` flag introduced in #4879. The MUL-3963
permission_mode + invocation_targets model exists to gate Composio
sharing, so the create-flow access picker ships on the same switch as
the rest of the Composio rollout — environments that already enable
Composio (`FF_COMPOSIO_MCP_APPS=true`) now also see the aligned Private
/ Public-to picker in Create / Duplicate.

- Drop `AGENT_ACCESS_PICKER_FLAG` (frontend keys.ts + index re-export).
- Drop `AgentAccessPicker` (server featureflags list).
- `CreateAgentDialog` reads `COMPOSIO_MCP_APPS_FLAG` instead.
- Tests updated to set the composio flag.

All 10 create-agent-dialog tests + Composio-related tabs tests pass.

Co-authored-by: Eve <eve@multica-ai.local>
Co-authored-by: multica-agent <github@multica.ai>
2026-07-03 16:41:28 +08:00
LinYushen
77ba0fdddb MUL-4009: hide not-configured Composio toolkits at Service layer (#4880)
* MUL-4009: hide not-configured Composio toolkits at Service layer

Filter out toolkits with no enabled auth config in Service.ListToolkits so
the Settings UI only shows connectable apps. A dead 'Not configured' card is
noise for end users, so drop the entry entirely instead of showing a greyed
label (which existed only to avoid a dead Connect button, MUL-3720).

- service.go: only append connectable toolkits; drop the connectable-first
  sort (all entries are connectable now); a resolver error now returns an
  error (502) instead of masking to an empty catalog, so the UI shows its
  honest load-failed state rather than a misleading 'no apps configured'.
- Keep the wire 'connectable' field (always true) for backward compat with
  older desktop clients that branch on it.
- composio-tab.tsx: remove the 'Not configured' branch; keep the
  toolkit.connectable guard as a client-side backstop.
- i18n: drop composio.not_connectable / not_connectable_hint from en/ja/ko/zh-Hans.
- Update service + handler tests to assert filtering and the resolver-error path.

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

* MUL-4009: address review — empty-state copy, 502 handler test, stale comments

Review follow-up on #4880:
- i18n: rewrite composio.page_description / empty_title / empty_description in
  all 4 locales. The empty state now means 'no toolkit with an enabled auth
  config in the project', not 'Composio returned no catalog' — the old copy
  misled users after filtering landed.
- Add TestComposio_ListToolkits_ResolverErrorIs502: fakes an auth-config
  resolver error and asserts ListComposioToolkits returns 502, pinning the
  no-silent-empty-catalog behavior (composioFakeSDK gains listAuthErr).
- Refresh stale 'full catalog / false connectable' docs in
  packages/core/types/composio.ts, composio/queries.ts, api/client.ts to the
  connectable-only model.

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

---------

Co-authored-by: multica-agent <github@multica.ai>
2026-07-03 16:10:19 +08:00
Multica Eve
b24b63c513 feat(agents): align create-agent visibility with MUL-3963 access model (MUL-4010) (#4879)
Adopt the private / public_to invocation-permission model in the Create
Agent (and Duplicate) dialog to match the AccessPicker on the agent
detail page. When the new `agent_access_picker` feature flag is ON, the
visibility section is replaced with an inline access editor that:

- Toggles between Private (owner-only) and Public
- Under Public, offers "Everyone in workspace" plus a member allow-list
  (multi-select, current user excluded, mirroring AccessPicker semantics)
- Preserves team targets on the source agent when duplicating
- Collapses an empty public_to (no workspace, no members) back to private
  on submit — same normalisation the AccessPicker emits

The dialog submits `permission_mode` + `invocation_targets` in that mode,
matching the authoritative gate; when the flag is OFF (default) it keeps
sending the legacy `visibility` field so production is unaffected until
the rollout is greenlit.

Backend: register the flag in the frontend-public list so it flows to
the web config bootstrap.

Tests: legacy toggle still submits `visibility`; flag-on submits
`permission_mode`/`invocation_targets`, collapses empty public_to to
private, and preserves ticked member grants.

Co-authored-by: Eve <eve@multica-ai.local>
Co-authored-by: multica-agent <github@multica.ai>
2026-07-03 16:03:36 +08:00