2947 Commits

Author SHA1 Message Date
Bohan Jiang
e3e61c161c fix(inbox): show Multica logo for system-actor notifications (#2479)
Notifications from system actors (e.g. GitHub PR closed) were rendering
with an "S" initials fallback. The avatar now shows the Multica icon
when actor_type === "system", matching the platform's brand.

Co-authored-by: multica-agent <github@multica.ai>
v0.2.31
2026-05-12 18:06:42 +08:00
Multica Eve
a0c64aaf65 docs: add 0.2.31 changelog (#2476)
* docs: add 0.2.31 changelog

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

* docs: refine 0.2.31 changelog copy

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

* docs: rename github integration changelog title

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

---------

Co-authored-by: Eve <eve@multica-ai.local>
Co-authored-by: multica-agent <github@multica.ai>
2026-05-12 17:40:18 +08:00
Bohan Jiang
2e4d6aa3a9 docs(integrations): add GitHub PR ↔ issue integration feature page and self-host setup (MUL-2090) (#2474)
- New /github-integration page (EN + zh) covering identifier matching, merge → Done rule, limitations, and full self-host walkthrough (GitHub App fields, env vars, migration, curl probe)
- Adds Integrations nav section in meta.json + meta.zh.json
- Adds GITHUB_APP_SLUG / GITHUB_WEBHOOK_SECRET to environment-variables (EN + zh) with cross-link
- Cross-links from self-host quickstart Next steps

Co-authored-by: multica-agent <github@multica.ai>
2026-05-12 15:47:47 +08:00
Bohan Jiang
a02e58b488 fix(github): only auto-close issue after all linked PRs resolve (#2470)
* fix(github): only auto-close issue when all linked PRs have resolved

Previously, the webhook handler unconditionally moved an issue to `done`
as soon as a single linked PR was merged. If a second PR was also linked
to the same issue and still open / draft, the issue would close before
the work was actually finished.

Add `CountOpenSiblingPullRequestsForIssue` and gate the auto-status
transition on it: a merged PR advances its linked issues only when no
sibling PR linked to the same issue is still in flight. Issues stay put
while siblings are open or draft, and the merge that resolves the last
in-flight PR is the one that closes the issue.

Adds an integration test that opens two PRs against the same issue,
merges the first, asserts the issue stays in_progress, then merges the
second and asserts the issue advances to done.

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

* fix(github): re-evaluate auto-close on closed-without-merge events too

GPT-Boy review on #2470: gating only the `state == "merged"` branch left
one ordering hole. PR-A merges first → issue stays in_progress because
PR-B is open; PR-B later closes WITHOUT merging → no event ever re-runs
the auto-close check, so the issue is stuck in_progress.

Generalise the trigger to every terminal PR event (`merged` or `closed`)
and advance the issue only when:
- the issue is not already terminal (done / cancelled);
- no sibling PR is still in flight (open / draft);
- at least one linked PR — current or sibling — actually merged.

Rule (3) preserves "user closed every PR without merging → leave the
issue alone": if no work was delivered, the user decides what to do.

Replace `CountOpenSiblingPullRequestsForIssue` with
`GetSiblingPullRequestStateCountsForIssue`, which returns both the
in-flight count and the merged count in a single roundtrip.

Adds `TestWebhook_ClosedSiblingAfterMerge` (the regression GPT-Boy
flagged) and `TestWebhook_AllClosedWithoutMerge` (the negative case
guarding rule 3). Refactors the multi-PR webhook helper out of the
existing two-merge test so all three multi-PR scenarios share it.

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

---------

Co-authored-by: multica-agent <github@multica.ai>
2026-05-12 15:39:55 +08:00
Naiyuan Qing
61ca43835a fix(issue-detail): drop virtualization when deep-linking, restore reliable landing (#2472)
Virtualization and precise deep-link landing have fundamentally opposed
contracts: virtualization uses estimated heights for off-screen items,
deep-link needs real heights for everything above the target. Three
prior fix attempts (initial scrollToIndex race, settle-by-silence
observer, 3-pass cooperative scroll) all tried to satisfy both in one
path and none fully stabilized — code/image/mermaid-heavy comments
kept drifting the target after first landing.

Split by user intent instead:
- highlightCommentId set (user came from inbox to read a specific
  comment) -> render flat. Every comment mounts, every height is real,
  the target id is in the DOM the instant the effect runs. Native
  document.getElementById + el.scrollIntoView({block:'center'}) is
  semantically identical to a native <a href="#comment-X"> anchor.
- otherwise -> Virtuoso. Browsing mode keeps the first-paint perf win
  from #2413 on long timelines.

Deep-link effect collapses to ~22 lines, matching the pre-virtualization
implementation. A shared renderItem function keeps both render modes
consistent. Removes: bootstrapRef, three-pass scrollToIndex effect,
overflow-anchor:none, scrollPaddingTop on container, scroll-margin-top
on every comment wrapper, virtuosoRef + VirtuosoHandle, initialItemCount
prop, useLayoutEffect.

Mermaid gets a 280px skeleton (web.dev CLS guidance) plus a
sessionStorage layout cache keyed by chart-text hash, so the 0px ->
real-height shift no longer drifts the surrounding layout — useful for
both render modes, deep-link or browsing. Pattern matches ant-design/x
#1497 which fixes the same Mermaid drift in their own stack.

Auto-expand a folded resolved thread when the deep-link target is a
reply inside it; without this the target reply stays collapsed and the
user sees only the resolved-bar.

Net: +131 / -245 in issue-detail.tsx. Tests added for the
resolved-thread-reply auto-expand path.

Known follow-ups:
- <ReadonlyImage> aspect-ratio for image CLS (same class as Mermaid).
- Layout heisenbug (page width "abnormal" without devtools open) is
  orthogonal to deep-link and survives this PR; needs separate triage.
- 500+ comment cold mount in deep-link mode pays full markdown+lowlight
  cost; GitHub takes the same hit and we accept it.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 15:35:14 +08:00
Bohan Jiang
f17acc21de refactor(integrations): drop installation list from Settings tab (#2468)
The card displayed a per-installation row (avatar + account_login +
"User|Organization · connected <date>") plus a disconnect button. In
practice the title regularly fell back to "unknown" because the server's
fetchInstallationAccount call doesn't sign App JWT, and the
account-level framing also leaked GitHub's data model into the UX —
users care about which repos are wired up, not which GitHub account the
App is installed on.

Collapse the card to: GitHub mark + description + Connect button (plus
the "not configured" hint and role gate). Existing installations stay
fully manageable from GitHub's own settings page, reachable via Connect.

Removes:
- installation list + disconnect button + handleDisconnect
- useQueryClient / Trash2 / githubKeys imports
- five now-dead i18n keys (loading / empty / connected_at /
  toast_disconnected / toast_disconnect_failed) in en + zh-Hans
2026-05-12 15:04:41 +08:00
Naiyuan Qing
01bcede2ad feat(issues): confirm before terminating a single task (#2466)
The two issue-detail surfaces that stop a single agent task — the
sticky AgentLiveCard banner and the active rows inside
ExecutionLogSection — cancelled on the first click. Task
cancellation is irreversible, and a misclick on a long-running run
was costly with no way to recover.

Both entry points now route through a shared
TerminateTaskConfirmDialog (AlertDialog with destructive confirm),
mirroring the pattern the Agents list row actions already use for
the "cancel all tasks" flow. The running-state note about a few
seconds to fully halt is only shown when the task is actually
running or dispatched.

Chat window pending-pill Stop is intentionally not affected — it
is fire-and-forget with the UI clearing optimistically, and a
confirm step there would interrupt chat flow.

Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>
2026-05-12 15:02:07 +08:00
YYClaw
0e7fa21832 fix(runtimes): correct broken docs link to /docs/daemon-runtimes (#2465)
The 'Learn more' link on the Runtimes page pointed to
https://multica.ai/docs/runtimes which returns 404. The docs page is
published at /docs/daemon-runtimes.
2026-05-12 15:00:45 +08:00
Bohan Jiang
caeb146bac feat(github): GitHub App integration for PR ↔ issue linking (#1817)
* feat(github): GitHub App backend for PR ↔ issue linking

- New tables: github_installation (workspace ↔ App install), github_pull_request (mirrored PR state), issue_pull_request (M:N link).
- Webhook handler verifies HMAC-SHA256, upserts PR rows, parses issue identifiers from PR title/body/branch and auto-links them. Merging a linked PR moves the issue to done.
- Connect/setup endpoints power the zero-config "Connect GitHub" install flow; state token is HMAC-signed so the setup callback can recover the workspace.
- Workspace-scoped admin routes for listing/disconnecting installations, plus a per-issue `pull-requests` list endpoint.

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

* feat(github): UI for connecting GitHub and viewing linked PRs

- Settings → Integrations: new tab with Connect GitHub / installations list / disconnect, gated on the deployment having the App configured.
- Issue detail sidebar: Pull requests section showing linked PR title, repo, state (open/draft/merged/closed), and author, with deep link to GitHub.
- Real-time refresh: github_installation:* and pull_request:* events invalidate the matching TanStack Query caches.

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

* fix(github): address review — null actor, role gating, configured guard, scoped uninstall broadcast

- listeners: use optionalUUID(e.ActorID) so the system actor on the github-driven issue:updated event no longer panics activity / notification listeners; merged-PR → issue done now produces a status_changed activity and inbox entry.
- IntegrationsTab: gate the admin-only installations query on canManage so members no longer hit /github/installations 403; the configured/not-configured copy is also scoped to admins.
- backend: introduce isGitHubConfigured() requiring both GITHUB_APP_SLUG and GITHUB_WEBHOOK_SECRET, and surface that single flag from list-installations + connect endpoints so the frontend Connect button stays disabled until both are set.
- DeleteGitHubInstallationByInstallationID now RETURNs workspace_id; webhook handler publishes github_installation:deleted scoped to the right workspace so already-open Settings tabs invalidate in real time. ErrNoRows on a re-fired delete short-circuits cleanly.
- tests: focused webhook integration coverage (auto-link + merge → done, cancelled preservation, uninstall returns workspace).

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

* fix(github): i18n the new GitHub UI strings to satisfy lint

CI flagged every literal string in the Integrations tab, the Pull requests
sidebar section, and the per-PR row label. Move them through useT() and
add the matching `integrations.*` block to settings.json (en / zh-Hans)
plus `detail.section_pull_requests` / `detail.pull_request_state_*` /
loading + empty copy under `issues.json`.

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

---------

Co-authored-by: multica-agent <github@multica.ai>
2026-05-12 13:49:03 +08:00
Bohan Jiang
f08b2b4f50 fix(attachments): harden local sidecar serving and tighten Upload gate (#2459)
Follow-ups to #2444:

- ServeFile refuses keys ending in .meta.json so the sidecar JSON isn't
  a stable read API. Sits before any disk work so a crafted
  .meta.json sibling can't trigger an out-of-tree read.
- ServeFile rejects paths that resolve outside uploadDir (via
  filepath.Rel) before readLocalMeta runs. http.ServeFile's own ..
  guard fires later on r.URL.Path, but readLocalMeta would otherwise
  do a stray disk read on <some-path>.meta.json before the 400 lands.
- Upload only writes a sidecar when filename is non-empty. ServeFile
  only reads the filename anyway, so a content-type-only sidecar was
  dead disk weight.
- Drop the dead json.Marshal error branch — marshaling two strings
  cannot fail.

Three new tests cover sidecar suffix rejection, the traversal guard,
and the no-filename Upload short-circuit.

Co-authored-by: multica-agent <github@multica.ai>
2026-05-12 12:49:22 +08:00
Truffle
91bdec9a54 fix(attachments): preserve original filename on /uploads/* downloads (#2444)
LocalStorage.ServeFile delegated straight to http.ServeFile without
setting Content-Disposition, so downloads of local-storage attachments
landed on disk under the UUID-based storage key instead of the human
filename the uploader had chosen. The S3 backend already sets
Content-Disposition on PutObject (s3.go:186-187), so the local backend
was the only one losing the original filename — a sibling asymmetry
that's been there since multi-backend support landed.

Upload now writes a sidecar <key>.meta.json beside the data file
capturing the original filename and sniffed content type. ServeFile
reads the sidecar when present and sets Content-Disposition using the
existing sanitizeFilename + isInlineContentType helpers, mirroring the
S3 inline/attachment decision exactly. Uploads from before this lands
have no sidecar and fall through to the previous behavior. Delete now
removes the sidecar alongside the data file so the upload directory
doesn't grow orphans.

Closes #2442
2026-05-12 12:37:07 +08:00
Naiyuan Qing
a1c2d53939 fix(chat): keep editor mounted across lazy session creation (#2457)
The first file upload in a brand-new chat showed the blob preview for
a moment and then disappeared — the upload looked like it had failed
even though the attachment was actually saved.

Root cause: `<ContentEditor key={draftKey}>`. `draftKey` includes
`activeSessionId`, and `handleUploadFile` (chat-window.tsx) awaits
`ensureSession("")` before forwarding the file to the upload handler.
Lazy-create flips `activeSessionId` from null to a uuid mid-upload,
which changes `draftKey`, which forces React to remount the editor.
The blob image node inserted by `uploadAndInsertFile` was on the old
editor instance; by the time the upload settled, the swap-to-CDN-URL
walk in file-upload.ts couldn't find the blob src in the new editor
and finally `URL.revokeObjectURL` released the blob — broken image.

The create-issue modal has the same draft-store pattern but does not
hit this bug because it never sets a `key` on its ContentEditor; the
editor lives for the lifetime of the modal regardless of draft churn.

Split the two concerns the previous `draftKey` was conflating:

- `draftKey` (zustand storage key) keeps `activeSessionId` so each
  session gets its own draft slot — unchanged behaviour.
- `editorKey` (React identity key) drops `activeSessionId` and only
  varies on `selectedAgentId`, which is the actual signal Tiptap's
  Placeholder needs to refresh on agent switch.

Now the editor stays mounted across the lazy session creation. The
blob preview survives long enough for the swap to find it, and the
user sees the image render normally on the very first upload of a new
chat.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 12:20:20 +08:00
ayakabot
da03c83251 fix(modals): correct text input height in issue creation dialog (#2434)
* fix(modals): correct text input height in issue creation dialog

Fixed text input height for both agent and manual create issue dialogs:
- Agent dialog: added flex to outer div and flex-1 to inner div
- Manual dialog: added flex to description container and flex-1 to editor

Fixed: #2433

* fix(editor): make EditorContent a proper flex container

- EditorContent: flex flex-1 flex-col
- Remove min-height: 100% from .ProseMirror CSS
- Let flex-grow handle height consistently across the chain

Fixed: #2433

---------

Co-authored-by: ayakabot <ayakabot@seepine.com>
2026-05-12 12:13:45 +08:00
Naiyuan Qing
23c05f13c4 refactor(feedback): replace generic description with brand-colored GitHub CTA (#2455)
* refactor(feedback): replace generic description with brand-colored GitHub CTA

The Feedback modal previously rendered three lines of grey copy before the
editor — title, description, and the GitHub hint from #2451. The hint blended
into the description, defeating its purpose of nudging users toward a tracked
channel.

Drop the generic description (placeholder already explains what to type) and
restyle the hint so GitHub itself is the only brand-coloured anchor. The
shorter sentence ("Want faster traction? Head to GitHub") puts the link at
the natural end-of-line fixation point, where the colour shift actually
registers.

i18n splits into prefix + link (suffix would be empty), avoiding the
sentence-order brittleness that 3-key splits usually introduce.

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

* copy(feedback): expand GitHub hint to highlight discussion as well

Reviewer feedback: "faster traction" only signals speed; users also care about
having an open back-and-forth on a tracked thread. Update the hint to surface
both benefits without lengthening the line meaningfully.

- EN: "Want faster handling and open discussion? Head to GitHub"
- ZH: "想被更快处理、参与讨论?请去 GitHub"

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

---------

Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>
2026-05-12 11:36:53 +08:00
Naiyuan Qing
b21f69f31a fix(views): land deep-link via cooperative scroll passes (#2452)
Replaces #2452's first attempt (placeholder-freeze, 800ms blank
window) and the multi-observer settle pipeline from #2449. Both
were trying to land the target with a single perfectly-timed scroll,
which doesn't compose with how virtualization actually works.

The non-virtualized version of this code, pre-#2413, was 12 lines:
one el.scrollIntoView once timeline.length > 0 && !loading. That
worked because every comment was in the DOM, so the target's
absolute position was real, not estimated. Virtualization breaks
that invariant — Virtuoso renders a window, fills the rest with
spacer heights derived from estimates, and the target's offset is
spacer-sum until each above-target item is mounted and measured for
the first time. Those measurements arrive in waves: viewport mount,
ResizeObserver pass, markdown render, lowlight code highlight,
image load. Each wave updates spacers and shifts the target's
offset by tens to hundreds of pixels.

The previous two attempts both tried to detect "settle" and land
once. ResizeObserver on the target watches the symptom, not the
cause (#2449). Rendering placeholders to freeze the cause shows
800ms of blank where comments should be (#2452 v1).

This rewrite cooperates with Virtuoso's own measure→correct loop
instead of trying to outrun it. Three scrollToIndex calls — t=0,
t=120 (after the first measurement wave), t=500 (after markdown /
lowlight settle) — let the convergence narrow on each pass. Each
call uses whatever spacer heights are current; differences across
passes are typically a few pixels (cold viewport) to a few dozen
(big code blocks), not the full-spacer drift that motivated
placeholders. Visually it reads as a single instant scroll with at
most a couple of subtle re-centerings, not a re-jump.

initialTopMostItemIndex stays — it's the only API that anchors
position *before* first paint, and it's the reason cold-start
deep-links from inbox land at the target without a visible "scroll
from top". Captured exactly once via a useRef one-shot following
React's documented "avoid recreating ref contents" idiom, so #458's
persistent-anchor reset behavior can't trip. Crucially we now
spread-on-defined rather than passing `={undefined}` — react-virtuoso
crashes with "Cannot read properties of undefined (reading 'index')"
on the latter because the library accesses .index on the prop without
a null guard.

Net delta vs main: −86 lines. Deletes ~150 lines of the #2449
MutationObserver/ResizeObserver settle pipeline plus this PR's
prior placeholder/deepLinking/flushSync machinery, replaces with
~30 lines of straightforward effect + bootstrap ref. The whole
deep-link path is now smaller than the original pre-virtualization
version was, because the convergence loop is explicit and the
correctness story doesn't require auxiliary state.

Refs: react-virtuoso #458 (initialTopMostItemIndex anchor reset),
#883 (initial scroll race), #1083 (scrollTop model divergence vs
native scrollIntoView).

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 11:23:02 +08:00
Naiyuan Qing
723489d2a9 feat(feedback): nudge users toward GitHub for discussion and faster traction (#2451)
Add a small CTA below the Feedback modal description that links to
github.com/multica-ai/multica/issues for users who want a tracked, public
channel. The in-app feedback form still serves vague impressions and
weekly-aggregated input; GitHub is for concrete bugs, feature requests, and
discussion that benefits from community visibility.

i18n covers en + zh-Hans following the conventions.zh.mdx voice guide
(full-width punctuation, ASCII ellipsis, spaces around Latin terms).

Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>
2026-05-12 11:00:39 +08:00
Naiyuan Qing
86aa5199fc feat(chat): support attachments & images in chat input (#2445)
* docs(plans): chat attachment & image support implementation plan

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

* feat(db): add chat_session_id/chat_message_id to attachment

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

* feat(db): sqlc — chat_session_id on CreateAttachment + LinkAttachmentsToChatMessage

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

* feat(file): upload-file accepts chat_session_id form field

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

* feat(chat): SendChatMessage links uploaded attachments to the new message

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

* feat(api): uploadFile accepts chatSessionId; sendChatMessage accepts attachmentIds

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

* feat(core): useFileUpload supports chatSessionId context

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

* feat(chat): support paste/drag/upload attachments in chat input

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

* test(e2e): chat input attachment upload + send round-trip

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

* chore(chat): keep lazy-created session title empty so untitled fallback localizes

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

* fix(chat): address review — dedupe ensureSession + parse upload response

- chat-window: cache in-flight createSession promise in a ref so a file drop
  followed by a quick send no longer spawns two sessions (and orphans the
  attachment on the losing one).
- Attachment type + EMPTY_ATTACHMENT + AttachmentResponseSchema: include the
  new chat_session_id / chat_message_id fields the server now returns.
- uploadFile: route the response through parseWithFallback so a malformed
  body returns EMPTY_ATTACHMENT instead of an undefined-keyed Attachment,
  matching the API boundary rule.

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

* fix(chat): address PR #2445 review — test ctx, send gating, attachment surface

1. Backend test was 400ing because the handler reads workspace from
   middleware-injected ctx, and `newRequest` only sets the header. Helper
   `withChatTestWorkspaceCtx` mirrors the agent-access-test pattern and
   loads the member row + SetMemberContext before invoking the handler.

2. Attachment metadata now flows end-to-end:
   - new sqlc `ListAttachmentsByChatMessageIDs` (batch lookup, mirrors the
     comment-side query)
   - `chatMessageToResponse` takes `attachments` and `ChatMessageResponse`
     surfaces them — same shape as CommentResponse
   - `ListChatMessages` loads them via a new `groupChatMessageAttachments`
     helper so the chat bubble can render file cards
   - daemon claim path pulls `ListAttachmentsByChatMessage` for the latest
     user message and ships `ChatMessageAttachments` to the daemon
   - `buildChatPrompt` lists id+filename+content_type and instructs the
     agent to `multica attachment download <id>` — fixes the private-CDN
     expiring-URL problem where the markdown URL would have expired by
     the time the agent acts
   - TS `ChatMessage` gains an optional `attachments` field

3. Chat composer now blocks send while uploads are in flight:
   - `pendingUploads` counter increments in handleUpload, SubmitButton
     uses it to disable
   - handleSend also gates on `editorRef.current.hasActiveUploads()` to
     catch the Mod+Enter path that bypasses the button
   - new vitest covers the "drop large file → immediate send" scenario
     where attachment id would otherwise be silently dropped

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

* chore: drop implementation plan doc

Process artefact, not something the repo needs to keep.

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

---------

Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>
2026-05-12 10:57:54 +08:00
Naiyuan Qing
208f1ddb29 fix(views): land virtualized deep-link via settle-by-silence (#2449)
The earlier deep-link fix (0d0d100e) used a fixed 20-frame rAF poll to
wait for Virtuoso to mount the target before handing off to the
browser's native scrollIntoView. That approach failed under three
conditions all reproduced on the 500-comment perf fixture:

 1. Items near the bottom of long lists: Virtuoso's estimate→mount→
    ResizeObserver→correction sequence stretches past 320ms; the
    poll gave up and set highlight without scroll.
 2. Tall markdown/code-block comments: the target mounted within the
    poll window but its measured height was not yet final (lowlight
    was still highlighting). scrollIntoView landed on the not-yet-
    reflowed card; the card grew a moment later and dragged the
    target out of view.
 3. Late image loads or any post-mount layout shift inside the
    timeline: the browser's built-in CSS scroll-anchoring silently
    nudged scrollTop after we had already finished, putting the
    target back off-center.

The root cause is the same race that every variable-height
virtualizer has — official react-virtuoso #1263 calls it out as
intentional, and #1296 shows even Virtuoso's own `scrollIntoView({done})`
callback is unreliable across the same scenarios. The fix is
virtualizer-agnostic: don't trust *any* "we landed" signal the
virtualizer gives you. Wait for the real DOM node to stop reflowing
before handing off to the browser.

Four phases now:

  Phase 1 (coarse): virtuosoRef.scrollToIndex only to *mount* the
    target. The scroll position it produces is discarded.
  Phase 2 (adopt):  MutationObserver on the scroll container picks
    up the target node as soon as it enters the DOM.
  Phase 3 (settle): a ResizeObserver on the target with a
    "settle-by-silence" timer — every RO tick re-arms a 120ms idle
    window; when the window elapses with no further ticks the card
    is treated as stable. Baseline 150ms timer so a fully-static
    card (or test env with stubbed RO) still proceeds.
  Phase 4 (land):   native el.scrollIntoView({block:'center'}), then
    light the highlight on `scrollend` (or a 200ms fallback for
    Safari < 17.4 and jsdom, both of which never fire scrollend).

Hard 2.5s cap on the whole pipeline so a comment whose images load
indefinitely doesn't leak observers; in that case we still attempt a
final scroll with whatever's measured and flash the highlight so a
manual scroll lands on a marked card.

CSS partner: `overflow-anchor: none` on the scroll container disables
the browser's automatic re-anchoring on layout shifts above the
viewport. Without this even a perfectly-landed scrollIntoView can be
silently nudged off-target by a late ResizeObserver pass on a
comment above the viewport.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 10:15:05 +08:00
Bohan Jiang
b58567ed6c fix(desktop): restore Multica app icon on Linux (#2437)
Fixes three gaps in the Linux desktop build that combined to render the
Multica window with the system Settings (gear) icon on Ubuntu:

1. Force `linux.executableName: multica` so the scoped npm name
   `@multica/desktop` stops leaking into `executableName`, the `.desktop`
   filename, the `Icon=` field, and `/usr/share/icons/hicolor/*/apps/*.png`.
   The leading `@` in the previously-generated `@multicadesktop` violates
   freedesktop desktop-entry naming, breaking GNOME's window↔.desktop
   association and forcing the theme-default icon. (The artifact-filename
   side of the same scoped-name leak was already patched in 10618b1f;
   this commit closes the desktop/icon-identity side.)

2. Always set `BrowserWindow({ icon })` on Linux — previously gated on
   `is.dev`. AppImage direct-launches never install the `.desktop` entry,
   so without an explicit window icon the WM has no other path to the
   bundled image. The resolved path now points into `app.asar.unpacked/`
   (matching the existing `bundledCliPath()` convention in
   `daemon-manager.ts`) since the Linux native icon code path requires a
   real filesystem path, not an asar-internal one.

3. Pin `linux.desktop.entry.StartupWMClass: Multica` explicitly. The
   value already matches the productName-derived default, so this is a
   build-time no-op today, but it makes the WM_CLASS↔StartupWMClass
   matching contract auditable in config — future changes to
   `productName` or `app.setName()` now show up as a diff against this
   file instead of silently re-breaking the icon association.

Fixes https://github.com/multica-ai/multica/issues/2424.
2026-05-11 23:51:44 +08:00
Bohan Jiang
bb312002d1 docs(self-hosting): document Caddy WebSocket essentials (#2436)
* docs(self-hosting): document Caddy WebSocket essentials

Add a single-domain Caddy example and harden the separate-domain one
with the WebSocket route a self-hoster actually needs:

- handle /ws* (prefix match, not exact `/ws`) so future path variants
  don't fall through to the frontend block
- flush_interval -1 inside the WS reverse_proxy, otherwise frames sit
  behind Caddy's default flush window and surface as "comments only
  appear after a page refresh"

Both gaps were hit by a self-hosted user on a single-domain Caddy
deployment, and neither was documented.

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

* docs(self-hosting): tighten Caddy /ws matcher to avoid catching `/ws-*` slugs

Use a named matcher `path /ws /ws/*` instead of the over-broad `handle /ws*`.
Caddy's `*` is a path-glob without segment boundary, so `/ws*` would also
match unrelated paths like `/ws-foo` — which is a legitimate workspace URL
under the current reserved-slug rules (only the exact `ws` slug is reserved).

Per GPT-Boy review on PR #2436.

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

---------

Co-authored-by: multica-agent <github@multica.ai>
2026-05-11 23:36:58 +08:00
Bohan Jiang
2eefa3b90b refactor(runtime): move visibility description to hover tooltip (#2435)
The Diagnostics card's Visibility section had a two-line layout — icon +
label on top, descriptive hint underneath — which made it look noisy next
to the compact Timezone / CLI sections. Move the hint into a tooltip on
hover and collapse the buttons into a tight segmented-toggle pair
matching the runtimes-page Mine/All filter pattern. Readout side mirrors
the change: chip-only, full description on hover.

Co-authored-by: multica-agent <github@multica.ai>
2026-05-11 23:07:38 +08:00
Bohan Jiang
63d215e1c3 feat(runtime): visibility (public/private) gate on CreateAgent / UpdateAgent (#2419)
* feat(runtime): visibility (public/private) gate on CreateAgent / UpdateAgent

Closes the hole where a plain workspace member could pick another member's
runtime in the Create Agent dialog and bind an agent to it — the backend
wasn't checking runtime ownership, so the agent ran on someone else's
hardware / tokens. Reported on GH #1804.

Schema
- Migration 083 adds agent_runtime.visibility ('private' default, 'public')
  with a CHECK constraint. Existing rows default to private — same
  ownership semantics as before, no behavior change for legacy data.

Backend
- canUseRuntimeForAgent predicate: allow when caller is workspace
  owner/admin, the runtime owner, or the runtime is public.
- CreateAgent and UpdateAgent both gate on it: UpdateAgent matters because
  a plain member could otherwise create on their own runtime, then re-bind
  to a private one.
- PATCH /api/runtimes/:id accepts { visibility } — owner/admin only,
  validated against the same private/public allow-list.

Frontend
- Create-agent dialog renders other-owned private runtimes disabled with a
  Lock badge + tooltip explaining who to ask.
- Inspector runtime-picker disables the same set so re-binding fails
  the same way at the UI layer.
- Runtime detail diagnostics gains a Visibility editor (owner/admin) or
  read-only chip (everyone else).
- Runtime list shows a private/public chip next to the name.

Tests
- Go: canUseRuntimeForAgent truth table; CreateAgent / UpdateAgent
  end-to-end gate tests (admin / runtime owner / plain member);
  PATCH visibility owner / admin / member / invalid-value coverage.
- Vitest: create-agent dialog disabled state on private/public runtimes,
  default-runtime selection skips locked rows; runtime detail visibility
  editor → mutation, read-only fallback.

Migrating runtimes: existing rows default to private to preserve the
"owner only" status quo. Owners switch to public via the detail page
diagnostics card.

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

* fix(runtime): apply timezone+visibility atomically; don't seed locked template runtime

Two issues surfaced in review of MUL-2062:

1. PATCH /api/runtimes/:id ran the timezone branch first, which:
   - returned early on a tz no-op, silently dropping a concurrent
     `visibility` patch in the same body;
   - committed the timezone mutation (+ usage rollup rebuild) before
     validating visibility, so an invalid visibility left the row
     half-updated.

   Validate every field first, then run the mutations in order. The
   no-op short-circuit now only triggers when nothing else is requested.

2. The Create Agent dialog in duplicate mode unconditionally seeded
   `template.runtime_id` as the selected runtime, even when that runtime
   is now private and owned by someone else — the user saw a selected
   row they couldn't submit (Create → backend 403). Fall back to the
   first usable runtime when the template's runtime is locked, and gate
   the Create button on `selectedRuntimeLocked` as defense in depth.

Tests:
- Go: TestUpdateAgentRuntime_CombinedPatchAppliesBoth (tz no-op +
  visibility flip), TestUpdateAgentRuntime_InvalidVisibilityDoesNotMutateTimezone
  (atomic-fail invariant).
- Vitest: duplicate template pointing at a locked runtime now seeds
  the first usable one; Create button stays disabled when no usable
  alternative exists.

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

---------

Co-authored-by: multica-agent <github@multica.ai>
2026-05-11 22:53:07 +08:00
Naiyuan Qing
fb8ad8cc5e perf: virtualize issue detail timeline + seed test scaffolding (#2413)
* perf(views): virtualize issue detail timeline with react-virtuoso

The unvirtualized timeline at issue-detail.tsx full-mounted every
entry, freezing first paint for several seconds at 500+ comments
(markdown parse + lowlight per CommentCard on mount). Production p99
is ~30 comments but the all-time max is ~1.1k and the server hard-caps
at 2000 — long-tail issues were unusable.

Swap the inline `.map` for `<Virtuoso customScrollParent>` driven by a
flattened TimelineItem discriminated union. TanStack Query stays the
source of truth; existing memo machinery (`prevThreadRepliesRef`,
`EMPTY_REPLIES`) and WS handlers are untouched. `followOutput="auto"`
matches Slack/Discord — users at the bottom auto-follow new comments,
users mid-scroll are not yanked back down.

Comment drafts move to a new persisted Zustand store
(`comment-draft-store`) so virtualization-driven unmount can no longer
drop in-progress edits or new comments. Hydrates via ContentEditor
`defaultValue`, flushes on update / blur / visibilitychange.

Deep-link from inbox is rewritten from `getElementById` +
`scrollIntoView` to `virtuosoRef.scrollToIndex` with a double-rAF
mitigation for the Virtuoso #883 initial-scroll race. Highlight flash
bumped 2s→3s to outlast mount latency on cold cards.

Cmd-F shows a once-per-session toast on long timelines since browser
find-in-page can't reach off-screen virtualized items. Real in-app
search lands in a follow-up.

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

* fix(views): repair deep-link scroll and isolate comment drafts

The first virtualization landing had three latent issues that runtime
testing on perf fixtures (10 → 5000 comments) exposed:

1. Deep-link landing position was wrong by ~380px on every issue.
   In customScrollParent mode Virtuoso computes scrollTop from the
   list's internal coordinate space only — it doesn't account for
   sibling content (title editor, description, sub-issues, agent
   card) sitting above the list inside the same scroll parent. The
   useEffect now uses Virtuoso scrollToIndex only to MOUNT the
   target into the DOM, then polls a `data-comment-id` anchor and
   delegates positioning to the browser's scrollIntoView, which
   honors getBoundingClientRect and lands accurately every time.

2. Scroll-up was being yanked back to the deep-link anchor on every
   ResizeObserver tick. Root cause was `followOutput="auto"`, which
   stays "stuck to bottom" once the deep-link lands there and resets
   scrollTop to maxScrollTop on each height change. Issue detail is
   document-shaped, not chat-shaped, so removing followOutput
   altogether is the right tradeoff. Likewise `initialTopMostItemIndex`
   acts as a persistent anchor in customScrollParent mode (Virtuoso
   #458) — dropped entirely and replaced with imperative scroll.
   `defaultItemHeight` is also dropped so Virtuoso probes real
   heights instead of estimating + correcting visually.

3. Reply-comment deep-links from the inbox would short-circuit
   because the reply id isn't in the flat items[] array. Added a
   replyToRoot map so deep-link falls back to the enclosing thread's
   root index, scrolls there, and lets the reply's own ring fire
   once the thread is in view.

Also fixes a latent cross-issue draft leak in `<CommentInput>`:
web's /issues/[id] route doesn't remount IssueDetail on issueId
change, so without an explicit `key={id}` the editor kept the
previous issue's in-memory content and the next keystroke would
flush it under the new issue's draft key. The same fix incidentally
repairs the pre-existing "submit composer from issue A while viewing
issue B" submit-target bug.

Highlight UX polish: bg-brand/5 was too faint to notice; ring upgraded
to ring-brand/60 as the sole signal. transition-colors didn't actually
animate ring/box-shadow — switched to transition-shadow duration-500
ease-out so highlight has visible fade in / fade out. Flash duration
3s → 4s. Polling failure now still sets highlight + warns so a manual
scroll to the target still flashes.

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

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 18:56:27 +08:00
Bohan Jiang
b7cd7e9adf docs(changelog): add 0.2.30 release notes for 2026-05-11 (#2416)
Summarizes the 24 PRs landed since v0.2.29 in EN and ZH changelog
data, organized into features, improvements, and fixes.

Co-authored-by: multica-agent <github@multica.ai>
2026-05-11 18:51:12 +08:00
Bohan Jiang
046e4b1efa fix(execenv): switch every provider's Windows reply template to --content-file (#2411)
Three user reports converge on the same Windows-shell encoding bug:

- #2198 / #2236 — Chinese, Codex on Win11. Comments / descriptions
  generated by the agent arrive as `?`.
- #2376 — Cyrillic, non-Codex agent ("Ops Lead") on Win11 Desktop.
  Title preserved (argv → CreateProcessW UTF-16), description / agent
  reply garbled (stdin → shell-codepage re-encoding).

woodcoal's independent diagnosis on #2198 confirms the root cause:
Windows PowerShell 5.1's `$OutputEncoding` defaults to ASCIIEncoding
when piping to a native command, so non-ASCII bytes are silently
replaced with `?` before they reach `multica.exe`. The CLI's stdin
parsing is fine; the bytes are corrupted upstream, in the agent's
shell layer.

This PR ships the fix that supersedes the codex-only attempt in
PR #2265 (which is closed in favour of this one):

## CLI

Add `--content-file <path>` to `multica issue comment add` and
`--description-file <path>` to `multica issue {create,update}`. The
CLI reads bytes off disk via `os.ReadFile` and skips the shell
entirely; UTF-8 survives end-to-end regardless of `$OutputEncoding`
or `chcp`. The three input modes (`--content`, `--content-stdin`,
`--content-file`) are mutually exclusive.

## Runtime config

`buildMetaSkillContent`'s Available Commands section is rewritten as a
neutral three-mode menu. The previous unconditional "MUST pipe via
stdin" / `--description-stdin` mandate (over-spread from #1795 /
#1851's Codex-multi-line fix) is gone for non-Codex providers; the
strong directive now lives only in the Codex-Specific section, which
branches on host:

- Codex / Linux+macOS: `--content-stdin` + HEREDOC (preserves MUL-1467
  fix against codex's literal `\n` habit).
- Codex / Windows: `--content-file` (PowerShell ASCII pipe is the
  exact bug we're patching).

## Per-turn reply template

`BuildCommentReplyInstructions` now takes a provider arg and branches
provider × OS:

- Windows + any provider → `--content-file` (the bug is shell-layer,
  not provider-layer; #2376 shows non-Codex agents on Windows also
  hit it). All providers write a UTF-8 file with their file-write tool
  and post via `--content-file ./reply.md`.
- Linux/macOS + Codex → stdin/HEREDOC (MUL-1467 protection).
- Linux/macOS + non-Codex → lightweight pre-#1795 inline
  `--content "..."`. The CLI server-side decodes `\n`, so escaped
  multi-line works; the agent retains stdin / file as escape hatches
  for richer formatting.

`BuildPrompt` and `buildCommentPrompt` gain a `provider` arg;
`daemon.runTask` already has it in scope.

## Tests

- `TestResolveTextFlag` — file-source verbatim with non-ASCII
  (`标题 / Заголовок / 中文段落`), missing-file error, empty-file
  rejection, three-way mutual exclusion.
- `TestInjectRuntimeConfigAvailableCommandsIsNeutral` — every
  non-Codex provider × {linux, darwin, windows} pins the three-mode
  menu present + over-spread "MUST stdin" substrings absent.
- `TestInjectRuntimeConfigCodexLinuxEmphasizesStdin` +
  `TestInjectRuntimeConfigCodexWindowsUsesContentFile` — Codex
  section's per-OS branch.
- `TestBuildCommentReplyInstructionsCodexLinux` +
  `TestBuildCommentReplyInstructionsNonCodexLinux` +
  `TestBuildCommentReplyInstructionsWindowsUsesContentFile` — the
  reply-template provider × OS matrix.
- `TestInjectRuntimeConfigWindowsCommentTriggerHasNoStdin` — end-to-end
  AGENTS.md / CLAUDE.md on Windows has no prescriptive stdin
  directive, for claude / codex / opencode.

`go test ./...` and `go vet ./...` clean.

Closes #2198, #2236, #2376.

Co-authored-by: multica-agent <github@multica.ai>
v0.2.30
2026-05-11 17:05:45 +08:00
YYClaw
2e5e3a7189 fix(core): stop leaking recent issues across workspaces (#2403)
* fix(core): namespace recent-issues by workspace id in state

The recent-issues store was using createWorkspaceAwareStorage, which
namespaces the storage key by the current slug. That broke whenever a
setter ran before WorkspaceRouteLayout's mount-effect set the slug —
child effects fire before parent effects in React, so recordVisit from
issue-detail wrote to the un-namespaced bare key, leaking visits across
workspaces. The /<slug>/issues page then fanned out a per-id GET for
each leaked id, mostly 404s.

Move the namespacing into the store state itself (byWorkspace keyed by
wsId), so reads/writes pick the right bucket at call time and don't
depend on a singleton being set before module hydration. Drop the
storage-level namespacing and the rehydration registration for this
store.

Add pruneWorkspaces to evict buckets for workspaces the user is no
longer a member of, wired into useDashboardGuard so it runs whenever
the workspace list resolves. As a defense against the prune never
firing, cap the total tracked workspaces at 50 (LRU on oldest visit).

Bump persist version to 1; the v0 entries don't know which workspace
they belonged to, so migrate drops them and the cache repopulates as
the user visits issues.

* fix(core): fail closed on null slug in workspace-aware storage

createWorkspaceAwareStorage used to fall back to the un-namespaced bare
key when no workspace was active. That fallback let any setter firing
before WorkspaceRouteLayout's mount-effect (e.g. a child component's
own mount-effect) leak workspace-scoped data into a global slot
visible to every workspace. Initial zustand persist hydration also ran
in this null-slug window, so every store would read the polluted bare
key on first load.

Drop the fallback: null slug → getItem returns null, setItem/removeItem
are no-ops. Stores still get a correct read via their registered
rehydrate fn once setCurrentWorkspace fires. The remaining nine stores
using this storage no longer rely on the bare-key path either; their
data has always been intended to be workspace-scoped.

---------

Co-authored-by: YYClaw <yyclaw0@gmail.com>
2026-05-11 16:56:54 +08:00
Bohan Jiang
352e838b01 fix(attachments): re-sign CloudFront download URLs at click time (#2407)
* fix(attachments): re-sign CloudFront download URLs at click time

The attachment download buttons opened `download_url` directly from cached
timeline/comment payloads. The signed URL is valid for 30 minutes, so a page
left open past that window would 403 with `AccessDenied` (MUL-2038 /
GitHub #2397).

- Add `GET /api/attachments/{id}` client method that re-signs on every call,
  validated by a stricter `AttachmentResponseSchema` (enforces `url`,
  `download_url`, `filename` so a malformed response degrades to the
  EMPTY_ATTACHMENT record instead of opening `undefined`).
- Introduce `useDownloadAttachment` hook with two execution shapes:
  - Web: synchronously open `about:blank` inside the click gesture to keep
    popup activation, then hydrate `location.href` after the fetch. Cannot
    pass `noopener` here — HTML spec dom-open step 17 makes that return
    null.
  - Desktop: skip the placeholder (Electron's setWindowOpenHandler rejects
    about:blank) and hand the fresh URL to `openExternal`.
- Wire the hook into the standalone attachment buttons (comment-card) and
  the inline `<img>` / file-card buttons inside `ReadonlyContent`. Inline
  buttons resolve the attachment id by URL match; external URLs fall back
  to `openExternal`.

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

* fix(editor): re-sign downloads from ContentEditor file/image NodeViews

The previous commit only wired the click-time fresh-sign through
ReadonlyContent + the standalone attachment list. The Tiptap NodeViews
inside ContentEditor still opened the raw URL with
`window.open(href, "_blank", "noopener,noreferrer")`, leaving two
download surfaces on stale signatures:

- Issue description (always renders via ContentEditor)
- Comment edit mode (transient ContentEditor instance)

- Add AttachmentDownloadContext + AttachmentDownloadProvider so NodeViews
  can resolve markdown URLs to an attachment id and call the existing
  `useDownloadAttachment` hook. The default fallback (no provider mounted)
  hands the raw URL to `openExternal`, keeping non-editor mounts unaffected.
- ContentEditor accepts `attachments?: Attachment[]` and wraps EditorContent
  with the provider.
- file-card.tsx and image-view.tsx NodeViews swap their `window.open(...)`
  calls for `openByUrl(href|src)` from the provider.
- issue-detail.tsx threads `useQuery(issueAttachmentsOptions(id))` into
  ContentEditor for the description.
- comment-card.tsx passes `entry.attachments` to both edit-mode editors.

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

---------

Co-authored-by: multica-agent <github@multica.ai>
2026-05-11 16:33:41 +08:00
Kagura
702c48209b fix(agent): stop filtering Pi extension tools via hardcoded --tools allowlist (#2379) (#2381)
The Pi backend hardcoded `--tools read,bash,edit,write,grep,find,ls` in
buildPiArgs. Pi's SDK treats --tools as a restrictive allowlist: only the
listed tools pass through `_refreshToolRegistry()`, silently filtering
out any user-installed extension tools registered via `pi.registerTool()`.

Omitting --tools makes Pi's `allowedToolNames` undefined, so the
`isAllowedTool()` filter becomes a no-op and all tools — built-in and
extension — are available. This matches Pi's standalone behavior.

Users who want to restrict tools can still pass --tools via custom_args
(it is not in piBlockedArgs).

Closes #2379
2026-05-11 16:11:32 +08:00
Bohan Jiang
fae8558263 fix(daemon): self-heal when a runtime is deleted server-side (#2404)
Closes #2391.
2026-05-11 16:09:40 +08:00
Bohan Jiang
f5c2994aed feat(workspace): revoke a member's runtimes when they leave or are removed (#2401)
* feat(workspace): revoke a member's runtimes when they leave or are removed

Previously, leaving or being removed from a workspace only deleted the
member row — every runtime the departed user owned in that workspace
remained in the DB, kept its daemon_token valid, and stayed reachable to
the workspace's other members. The departed user lost access but their
machine kept doing work.

This change converges the runtime state in the same transaction as the
member-row deletion: agents pinned to those runtimes are archived,
in-flight tasks are cancelled (so the daemon's per-task status poller
interrupts the running agent gracefully), the runtimes are forced
offline, and the daemon_token rows are deleted. After commit the
DaemonTokenCache is invalidated and agent:archived / daemon:register
events fire so connected clients reconcile immediately.

Server-side state convergence is the production safety net; the
daemon_token revoke takes effect once the mdt_ flow is live (today most
daemons fall back to PAT/JWT, and the member-row deletion is what stops
those requests via requireWorkspaceMember).

Daemon-side handling (recognising the resulting 401/404 and tearing down
the local pairing for that workspace) lands in a follow-up.

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

* fix(workspace): also cancel tasks for archived agents on member revoke

CancelAgentTasksByRuntime only matched tasks whose runtime_id was in the
revoked set, missing a real path: agent.runtime_id can be reassigned via
UpdateAgent, but agent_task_queue.runtime_id keeps the value from when
the task was queued. So an agent currently bound to the leaving member's
runtime gets archived correctly, but its older tasks still pinned to a
prior runtime stay 'queued' — and ClaimAgentTask does not gate on
agent.archived_at, so those orphaned tasks remain claimable by the
prior runtime.

Replace CancelAgentTasksByRuntime with CancelAgentTasksByRuntimeOrAgent,
which OR-matches runtime_ids and the archived agent IDs in one UPDATE.
Pass the archived agent IDs through from revokeAndRemoveMember.

Adds TestDeleteMember_CancelsTasksFromAgentReassignment as a regression
guard: same agent, two runtimes, the older task on the surviving runtime
must end up cancelled while the surviving runtime stays online.

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

---------

Co-authored-by: multica-agent <github@multica.ai>
2026-05-11 15:06:50 +08:00
Bohan Jiang
02310d083e docs(util): clarify EnsureHiddenConsole call-order contract (#2399)
Co-authored-by: multica-agent <github@multica.ai>
2026-05-11 14:45:54 +08:00
Kagura
fb026f2607 fix(daemon): suppress git console windows on Windows (#2358)
* fix(daemon): suppress git console windows on Windows

Apply the same HideConsoleWindow pattern used for agent processes
(PR #1474) to all git commands spawned by the daemon's repo-cache,
execenv, and GC packages. Each exec.Command now calls
util.HideConsoleWindow(cmd) which sets CREATE_NEW_CONSOLE + HideWindow
so grandchildren inherit a hidden console instead of flashing visible
console windows.

Closes #2357

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

* refactor: use EnsureHiddenConsole at daemon startup

Replace per-site HideConsoleWindow(cmd) calls with a single
EnsureHiddenConsole() invoked once at daemon startup. The daemon
now owns a hidden console that every child process (git, cmd /c
mklink, etc.) inherits automatically, eliminating the need for
per-call SysProcAttr configuration.

This also covers the previously missed exec.Command in
codex_home_link_windows.go (cmd /c mklink) which never had a
HideConsoleWindow call.

Signed-off-by: kagura-agent <kagura.agent.ai@gmail.com>

---------

Signed-off-by: kagura-agent <kagura.agent.ai@gmail.com>
Co-authored-by: Claude Opus 4 (1M context) <noreply@anthropic.com>
2026-05-11 14:41:07 +08:00
Naiyuan Qing
34a7ba9865 fix(chat): unify chat and comment send shortcut to Mod+Enter (#2398)
Chat input had `submitOnEnter` enabled while the comment editor used
`Mod+Enter`. Two consequences:

- Inconsistent muscle memory between the two inputs.
- In chat, bare Enter sending stole the only key that continues a
  TipTap bullet/ordered list. Shift+Enter falls through to HardBreak
  (a <br> inside the same list item), so bullet lists were stuck at
  one item.

Drop `submitOnEnter` from the chat input so it follows the editor
default. Mod+Enter (⌘↵ / Ctrl+Enter) sends in both places; bare Enter
now continues lists and inserts paragraphs as users expect.

Surface the shortcut on the SubmitButton via a new optional `tooltip`
prop, and route the comment input through SubmitButton instead of an
ad-hoc Button — same affordance, deduped.

Add unit coverage for the submit-shortcut extension that pins
Mod-Enter, the submitOnEnter=false case, IME, and code-block guards.

Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>
2026-05-11 14:39:37 +08:00
Multica Eve
d6349c16ec feat(runtime): per-runtime timezone for token-usage aggregation (MUL-1950) (#2394)
* feat: per-runtime timezone for token usage aggregation

The runtime token-usage charts (daily and hourly tabs on the
runtime-detail page) bucketed every event by the Postgres session
timezone, which is UTC in production. For an operator in UTC+8 that
meant a Tuesday afternoon's tasks landed in Tuesday early-morning's
bar — the chart was always one off.

Fix: store an IANA timezone on agent_runtime and aggregate under it.

* migrations 081 / 082 add agent_runtime.timezone (TEXT NOT NULL
  DEFAULT 'UTC') and rebuild the rollup pipeline (window function
  and both trigger functions) to compute bucket_date with
  AT TIME ZONE rt.timezone instead of bare DATE().
* No historical backfill — task_usage_daily rows already on disk
  keep their UTC bucket_date; only future writes / re-touches
  recompute under the new tz. (Product call from MUL-1950: 'guarantee
  future correctness'.)
* runtime_usage.sql gains a @tz parameter on ListRuntimeUsage and
  GetRuntimeUsageByHour and threads tz through GetRuntimeTaskHourly  Activity. ListRuntimeUsageDaily reads bucket_date as-is since the
  rollup already wrote it in tz.
* parseSinceParamInTZ replaces the raw N×24h cutoff with start-of-
  day-N in the runtime's tz so 'last 7 days' lines up with bucket
  boundaries.
* Daemon registration sends the host's IANA tz (TZ env, then
  time.Local), and UpsertAgentRuntime preserves any user override
  via a CASE-on-existing-value pattern so a daemon reconnect can't
  silently revert the operator's setting.
* New PATCH /api/runtimes/:id endpoint (UpdateAgentRuntime) lets
  the runtime detail page edit the tz; the editor seeds with the
  browser tz on first interaction.

Refs: MUL-1950

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Co-authored-by: multica-agent <github@multica.ai>

* fix: harden runtime timezone rollups

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

* fix: address runtime timezone review nits

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

---------

Co-authored-by: Eve <eve@multica.ai>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Co-authored-by: multica-agent <github@multica.ai>
Co-authored-by: Eve <eve@multica-ai.local>
2026-05-11 14:39:35 +08:00
Multica Eve
e79ffc0f01 fix(agent): expand Copilot CLI model catalog with correct dotted IDs (#2336)
* fix(agent): expand Copilot CLI model catalog with correct dotted IDs

The Copilot CLI provider only exposed two models in the runtime
dropdown, and one of them used the dashed legacy form
`claude-sonnet-4-6` which `copilot --model` rejects with
"Model ... is not available". The CLI accepts dotted IDs
(e.g. `claude-sonnet-4.6`, `gpt-5.4`).

Sync `copilotStaticModels()` with the official supported-models
catalog so the dropdown surfaces the full set the user's account
can route to (8 OpenAI + 4 Anthropic), and add a regression test
that pins the expected IDs and bans the dashed form.

Closes MUL-1948.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Co-authored-by: multica-agent <github@multica.ai>

* feat(agent): dynamic Copilot model discovery via ACP session/new

The previous static catalog could only ever lag behind the user's
real entitlements and what GitHub ships. Copilot CLI exposes the
live catalog through its ACP server (`copilot --acp`): the
`session/new` response includes `models.availableModels` plus
`currentModelId`, scoped to the authenticated account.

Wire copilot through the existing discoverACPModels helper —
already used by hermes/kimi/kiro — so the dropdown reflects the
account's real catalog, including the `auto` entry and per-tier
model availability (Pro / Pro+ / Enterprise / evaluation models).

The Copilot CLI puts itself into ACP server mode via the `--acp`
flag instead of an `acp` subcommand, so acpDiscoveryProvider now
takes an optional acpArgs override.

Copilot's ACP payload omits the vendor name, so a small
prefix-based inferCopilotProvider keeps the UI's openai /
anthropic / google grouping working.

When the binary is missing or auth fails, fall back to
copilotStaticModels() so self-hosted runtimes without a copilot
install still see a populated dropdown.

Verified against `copilot 1.0.44`: live discovery returns 13
models with gpt-5.5 marked Default. Closes MUL-1948.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Co-authored-by: multica-agent <github@multica.ai>

* fix(agent): drop no-op COPILOT_ALLOW_ALL env and generalize OpenAI o-series prefix check

- discoverCopilotModels: remove COPILOT_ALLOW_ALL=1 (not a real
  Copilot CLI env var; copy-pasta from HERMES_YOLO_MODE=1).
  Discovery only drives initialize + session/new which never
  trigger tool-permission prompts, so no extra env is needed.
- inferCopilotProvider: replace the o1/o3/o4 prefix chain with a
  generic o<digit>+ check via isOpenAIReasoningSeriesID, so future
  o5/o6/… reasoning models are tagged as openai automatically.
  Guards against false positives like 'opus-…' or bare 'o'.
- Extend TestInferCopilotProvider with o5/o6 forward-compat cases
  and negative cases (opus-fake, omni, o).

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Co-authored-by: multica-agent <github@multica.ai>

---------

Co-authored-by: Eve <eve@multica-ai.local>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Co-authored-by: multica-agent <github@multica.ai>
2026-05-11 14:36:43 +08:00
Bohan Jiang
6e3e6f714c feat(runtimes): let users set custom prices for unmaintained models (#2386)
* feat(runtimes): let users set custom prices for unmaintained models

The Runtime > Usage pricing diagnostic previously told users to "edit
packages/views/runtimes/utils.ts" when a model wasn't priced. That's
fine for us, useless for everyone else. We can't track every model
release, so let users supply their own per-million-token rates for
anything we don't ship a maintained rate for (e.g. gpt-5.5-mini today).

- Add a persisted Zustand store (custom-pricing-store) keyed by model
  name; rates live in localStorage so they survive reloads.
- resolvePricing consults the maintained MODEL_PRICING catalog first,
  then falls back to the store. Catalog still wins on overlap so a
  stale local override can't shadow a known rate.
- EmptyChartState gains a "Set custom prices" button when unmapped
  models exist; the dialog lists every unmapped model plus everything
  already overridden so users can edit / clear prior entries.

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

* fix(runtimes): show pricing-gap notice for partial unmapping; invalidate cost memos on price save

Two bugs surfaced in review:

1. The "Set custom prices" CTA only showed inside EmptyChartState, which
   only fires when Daily / Hourly total cost is exactly 0. Mixed windows
   (some priced + some unpriced models) rendered the chart normally and
   left no entry point — the unpriced tokens silently contributed \$0
   to totals.

   Add a permanent UnmappedPricingNotice above the KPI grid that appears
   whenever collectUnmappedModels(filtered) is non-empty, regardless of
   chart state. EmptyChartState keeps the diagnostic text but the CTA
   button moves to the notice so the two surfaces don't duplicate.

2. The aggregate useMemo blocks (WhenChart's dailyCostStack / hourlyCost,
   CostByBlock's byAgent / byModel, ActivityHeatmap's cells) keyed only
   on their query data. After a price save the parent re-rendered, but
   the memos returned cached pre-save totals because their deps were
   identical. The KPI cards updated; the charts did not.

   Subscribe to the pricing store in each aggregating component and
   list `pricings` as a memo dependency. The store returns a stable
   reference until setCustomPricing fires, so memos only invalidate
   on real changes.

New unit tests cover both: a mixed priced/unpriced aggregate produces
mixed costs (and surfaces the unpriced names), and aggregateCostByModel
called twice on the same input array reflects a freshly-saved override.

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

---------

Co-authored-by: multica-agent <github@multica.ai>
2026-05-11 14:36:14 +08:00
Multica Eve
72e89a74f3 fix: surface copilot failure details (#2396)
Co-authored-by: Eve <eve@multica-ai.local>
Co-authored-by: multica-agent <github@multica.ai>
2026-05-11 14:08:33 +08:00
Naiyuan Qing
a49222f37b fix(realtime): allow same-origin WebSocket (mobile/CLI) (#2395)
* fix(realtime): allow same-origin WebSocket clients (mobile/CLI)

The previous CheckOrigin implementation (PR #2318) bypassed the Origin
check whenever the request URL carried `client_platform=mobile` and no
browser session cookie. That contract requires every native client to
remember to add a query parameter — and in practice mobile clients hit
ws://localhost:8080/ws with no extra params, so the Origin filled by
the WebSocket library (the server's own host) gets rejected.

Replace the platform-specific bypass with same-origin acceptance: if
Origin's host equals the request Host, allow the upgrade. This is
gorilla/websocket's default CheckOrigin behavior, restored alongside
the existing cross-origin allowlist (for browser web/desktop clients).

Native clients are now zero-config. CSRF defense is unaffected:
SameSite=Strict cookies, the multica_csrf token, workspace membership
check, and the allowlist itself remain in place. Browser CSWSH attacks
fail both same-origin (browser forces Origin = page origin, not the
server's Host) and allowlist checks.

Refs: https://pkg.go.dev/github.com/gorilla/websocket
      https://cheatsheetseries.owasp.org/cheatsheets/WebSocket_Security_Cheat_Sheet.html

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

* fix(realtime): use case-insensitive Host comparison for same-origin

HTTP host is case-insensitive (RFC 7230 §2.7.3), and gorilla/websocket's
default checkSameOrigin uses equalASCIIFold(u.Host, r.Host). The plain
== comparison would reject legitimate same-origin requests with a
case-mismatched Host header (e.g. Host: LOCALHOST:8080 vs
Origin: http://localhost:8080).

Switch to strings.EqualFold and cover the case with a regression test.

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

---------

Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>
2026-05-11 13:42:42 +08:00
Bohan Jiang
b26f850d4e feat(agents): gate private-agent surfaces with allowed_principals predicate (#2359)
* feat(agents): gate private-agent surfaces with allowed_principals predicate

Tighten chat/@-mention, history, edit, and delete entry points so private
agents are only reachable by their owner or workspace owner/admin. Agent-to-
agent traffic still bypasses the gate so A2A collaboration keeps working.

- New canAccessPrivateAgent predicate in handler/agent_access.go; used by
  comment.enqueueMentionedAgentTasks (replacing the inline check), GetAgent,
  ListAgents (filter), ListAgentTasks, GetWorkspaceAgentRunCounts /
  Activity30d / TaskSnapshot (workspace-wide aggregations no longer leak
  private-agent existence + counts), chat.CreateChatSession,
  chat.SendChatMessage (re-checks on every send so role changes can't leave
  a stale session as a back-door), and autopilot.shouldSkipDispatch
  (caller = autopilot creator).
- allowed_principals is computed inline as {agent.owner_id} ∪ workspace
  owner/admin members. No new table — manual config is intentionally not
  exposed in v1; the predicate is the extension seam.
- Front-end agent detail page distinguishes 403 (private agent the caller
  can't access) from 404 (deleted/missing) and renders a "no access"
  placeholder with a back-to-agents button.
- Go tests cover the pure predicate matrix + the four protected surfaces;
  vitest passes for the affected views.

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

* feat(agents): gate issue assignment with the private-agent predicate

Refactor validateAssigneePair to call the shared canAccessPrivateAgent
helper. This closes the back door where a plain member could assign a
private agent to an issue and let normal task dispatch run it, side-
stepping the chat / @-mention gate. Agent callers (X-Agent-ID) bypass
so A2A delegation onto a private assignee still works.

Add an integration test covering all three callers (workspace owner,
agent owner, plain member).

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

* fix(agents): close three private-agent gate bypasses found in PR review

1. X-Agent-ID forgery (resolveActor): require X-Task-ID alongside
   X-Agent-ID before trusting the agent identity. Without this a plain
   workspace member could set X-Agent-ID to any visible agent UUID and
   short-circuit the gate to "actor=agent, allow". Daemons already
   pair the two headers, so legitimate A2A traffic is unaffected.

2. Chat history read path (chat.go): GetChatSession / ListChatMessages /
   GetPendingChatTask / MarkChatSessionRead now go through a new
   gateChatSessionForUser helper that re-applies canAccessPrivateAgent
   after the ownership check, so a session creator whose role was later
   downgraded loses transcript access. ListChatSessions and
   ListPendingChatTasks filter their result sets by the same predicate.

3. Cross-workspace @mention (comment.enqueueMentionedAgentTasks):
   resolve the mentioned agent via GetAgentInWorkspace scoped to the
   issue's workspace so a UUID belonging to a different workspace's
   private agent can't slip past the gate (the gate was being applied
   against the current workspace's role table, which is the wrong
   one).

Regression tests cover each bypass, plus an update to the resolveActor
unit test to reflect the new "X-Agent-ID without X-Task-ID falls back
to member" contract.

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

* test(handler): seed X-Task-ID alongside X-Agent-ID in existing agent-caller tests

After tightening resolveActor to require both headers (X-Agent-ID +
X-Task-ID) for the "agent" actor identity, three existing tests that
set only X-Agent-ID started failing because their requests now resolve
to "member" instead of "agent". Add createHandlerTestTaskForAgent
helper and seed a task per agent-caller assertion. Also patch
TestAgentExplicitMentionStillTriggers — it still passed only because
the @mention path doesn't care about author type for member callers,
but the test claims to exercise the agent path, so make it faithful.

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

* test(handler): finish X-Task-ID seeding + fix cross-workspace mention test schema

The previous CI run still failed in two places:

1. server/cmd/server integration tests — postCommentAsAgent → authRequestWithAgent
   only set X-Agent-ID, so resolveActor downgraded the request to "member"
   and the on_comment chain produced the wrong task counts. Fix:
   authRequestWithAgent now also sets X-Task-ID, fetched or seeded by a new
   ensureAgentTask(agentID) helper.

2. TestMentionAgent_RejectsCrossWorkspaceAgentUUID's hand-crafted comment
   INSERT was missing comment.workspace_id, which migration 025 made
   NOT NULL. Pass testWorkspaceID into the seed row.

Build + vet clean locally; both packages compile.

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

---------

Co-authored-by: multica-agent <github@multica.ai>
2026-05-11 12:39:45 +08:00
Bohan Jiang
b2b20b291b fix(inbox): re-fire scroll-to-comment effect once issue finishes loading (#2332)
When clicking an inbox notification for a different issue, the IssueDetail
remounts and both the issue detail and timeline queries fetch in parallel.
If the timeline query resolves first, `timeline.length` flips to >0 while
`loading` is still true — at that moment the component is rendering the
skeleton, so `getElementById('comment-<id>')` returns null and the scroll
silently fails. Without `loading` in the effect's deps, the effect never
re-runs when the issue finally loads, leaving the user at the top of the
issue instead of jumping to the highlighted comment.

Add `loading` to the early-return guard and to the dep list so the scroll
fires once both the issue and its comments are mounted. The dropped
`return () => clearTimeout(timer)` was inside requestAnimationFrame and
never functioned as cleanup — removed for clarity.

Test seeds the timeline cache and holds back the issue fetch to reproduce
the race deterministically; without the fix the regression test times out
waiting for scrollIntoView.

Co-authored-by: multica-agent <github@multica.ai>
2026-05-11 11:45:12 +08:00
Bohan Jiang
4d11023680 fix(web): match Changelog header link to GitHub ghost button (#2365)
The Changelog link rendered as plain text next to two pill-shaped
buttons, breaking the header's visual rhythm. Reuse the shared ghost
button helper so all secondary actions share one shape language.
2026-05-10 14:41:18 +08:00
Bohan Jiang
ce32a99a5c feat(web): add Changelog link to landing header (#2364)
Surfaces the changelog page from the marketing site's top navigation,
sitting alongside GitHub and the auth CTA. Hidden below the `sm`
breakpoint so the mobile header stays compact.

Co-authored-by: multica-agent <github@multica.ai>
2026-05-10 14:20:16 +08:00
Bohan Jiang
39e57b870f fix(cli): allow --mode run_only on autopilot create/update (#2360)
* fix(cli): allow --mode run_only on autopilot create/update

The autopilot run_only dispatch path is wired end-to-end (handler accepts
the mode, AutopilotService.dispatchRunOnly enqueues a task with
AutopilotRunID, daemon resolves workspace via autopilot_run -> autopilot
in ClaimTaskByRuntime and TaskService.ResolveTaskWorkspaceID). The CLI
guard was added before those fixes landed and never removed.

Drop the CLI rejection on both create and update so callers can pick the
same modes the API and UI already support, and remove the stale "unstable"
callout from the autopilots docs.

Closes multica-ai/multica#2347

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

* fix(daemon): advertise autopilot run_only in agent runtime instructions

The runtime config injected into AGENTS.md / CLAUDE.md only listed
`--mode create_issue` for autopilot create and didn't expose `--mode` on
update at all. So even after the CLI guard was lifted, agents reading
their harness instructions would still believe create_issue was the only
choice — undermining the "agents operate the same surface as humans"
intent.

Update both lines to advertise create_issue|run_only on create and on
update, and add an InjectRuntimeConfig assertion so the runtime prompt
can't drift away from the CLI surface again.

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

---------

Co-authored-by: multica-agent <github@multica.ai>
2026-05-10 14:12:34 +08:00
Bohan Jiang
15c3886302 docs(daemon): refresh stale comment for inline system prompt path (#2362)
The inline path now carries the full runtime brief (CLI catalog,
workflow steps, persona, skills, project context) rather than just
identity/persona instructions, after #2353 / #2355. The pre-existing
comment still described it as "identity/persona instructions inline",
which would mislead future maintainers about why the inline payload is
load-bearing.

Also call out kiro/kimi alongside openclaw/hermes since they were added
to providerNeedsInlineSystemPrompt in #2328, and document the concrete
failure mode (issues stuck in todo) so the rationale is searchable.

Co-authored-by: multica-agent <github@multica.ai>
2026-05-10 14:00:08 +08:00
Kagura
a6968c7485 fix(daemon): inline runtime brief for providers that need system prompt (#2355)
InjectRuntimeConfig writes the full meta skill content (CLI catalog,
workflow instructions, project context, skills) to workdir/AGENTS.md,
but providers like OpenClaw, Hermes, Kiro, and Kimi read bootstrap
files from their own agent workspace — not the task workdir. The
inline system prompt path (providerNeedsInlineSystemPrompt) only
passed the agent persona instructions, so these providers never
received the runtime brief.

Have InjectRuntimeConfig return the rendered content so the daemon can
both write it to disk (for file-reading providers) and pass it inline
(for workspace-isolated providers). This avoids double-rendering and
keeps the file and inline payloads identical.

Fixes #2353
2026-05-10 13:57:05 +08:00
jiawen134
00415de463 feat(editor): render mermaid diagrams inside issue descriptions (#2297)
* feat(editor): render mermaid diagrams inside issue descriptions

Issue descriptions are rendered through the Tiptap-based ContentEditor
(not ReadonlyContent), so the mermaid handler that PR #1888 added to
ReadonlyContent never reached them. Comments worked because comment-card
toggles between ContentEditor (edit mode) and ReadonlyContent (display
mode); issue descriptions stay in ContentEditor permanently.

This patch teaches the Tiptap CodeBlock NodeView to render a Mermaid
preview when the language is `mermaid`, giving issue descriptions a
split view: live diagram on top, editable source below. Theme variables
(light/dark), the sandboxed iframe, the lightbox and error fallback all
come from the existing implementation — only the location moved.

Changes:
- Extract MermaidDiagram + helpers (theme detection, sandbox iframe,
  lightbox, useThemeVersion) from `readonly-content.tsx` into a new
  `editor/mermaid-diagram.tsx`. ReadonlyContent (~200 lines lighter)
  imports the same component, so comment-card / inbox rendering is
  unchanged byte-for-byte.
- Update `code-block-view.tsx` (the Tiptap CodeBlock NodeView) to render
  `<MermaidDiagram>` above the editable source whenever the block's
  language is `mermaid` and the source is non-empty.

Tested:
- pnpm --filter @multica/views typecheck — clean
- pnpm --filter @multica/views test — 327 tests pass (43 files)
- Manually verified a mermaid block in an issue description renders as
  an SVG flowchart while staying editable underneath.

Closes #2079

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

* perf(editor): debounce mermaid preview re-renders during edits

Addresses review feedback on #2297. Previously every keystroke in a
Mermaid code block triggered `mermaid.initialize() + render()` on the
CodeBlockView preview. Because `mermaid.initialize()` mutates a
process-global config, those bursts could race a concurrent
ReadonlyContent render (e.g. a comment card) and clobber its theme
variables.

200ms is short enough that the preview still feels live during typing
but long enough to make concurrent inits unlikely in practice. The
ReadonlyContent path is unchanged: chart there is the saved markdown
and never changes after mount, so the race only existed on the new
edit-time path this PR introduced.

A small `useDebouncedValue` hook local to the file gates `chart` so
that it only flows into MermaidDiagram after 200ms of stable input.
When the language is non-Mermaid the hook short-circuits to "", so
non-Mermaid blocks pay no extra cost.

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

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 13:11:20 +08:00
Jiayuan Zhang
448e75ce53 feat(issues): inline status & assignee pickers + batch select on sub-issue rows
- Sub-issue rows on the parent issue's detail page now expose inline StatusPicker and AssigneePicker, optimistically syncing the children cache via a useUpdateIssue parent-id fallback that scans loaded children caches.
- Hover-revealed checkbox + indeterminate select-all in the section header drive batch selection through the existing useIssueSelectionStore; the BatchActionToolbar gains a "placement" prop and renders inline directly under the sub-issues header so the action is right next to the rows.
- useBatchUpdateIssues / useBatchDeleteIssues now mirror their optimistic patches into every loaded children cache (with rollback) and invalidate children + childProgress on settle.
- SubIssueRow restructure: AppLink wraps only the identifier + title, so the checkbox / picker areas no longer accidentally fire navigation.

Refs MUL-2005.
2026-05-09 17:52:22 +02:00
Bohan Jiang
e076bbafcc fix(runtimes): price OpenAI Codex / GPT models so cost stops showing $0 (#2334)
* fix(runtimes): price OpenAI Codex / GPT models so cost stops showing $0

The runtime detail / usage charts compute cost client-side from
MODEL_PRICING, but the table only had Claude entries. Codex CLI
sessions report models like gpt-5-codex / gpt-5, so estimateCost()
returned 0 for every Codex runtime — the dashboard read $0 even on
runtimes with billions of tokens consumed.

Add pricing rows for the GPT-5 family (incl. -codex/-mini/-nano), the
o-series reasoning models, and GPT-4o, ordered so the startsWith()
fallback resolves the more-specific variants first. Cover the new
entries with a small unit test for utils.ts.

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

* fix(runtimes): require explicit price rows for catalog SKUs (no startsWith fallback)

Per review: the previous startsWith() fallback let `gpt-5.5*` / `gpt-5.4*`
inherit the lower-tier `gpt-5` price. Address by:

- Add explicit rows for every dotted Codex catalog SKU listed in
  server/pkg/agent/models.go: gpt-5.5, gpt-5.4, gpt-5.4-mini, gpt-5.3-codex.
- Drop the startsWith fallback in resolvePricing entirely. Anything not
  exactly matching a row (after date-snapshot stripping) is now reported
  as unmapped — the diagnostic surfaces it rather than silently absorbing
  it into a near-named relative.
- Extend the date-strip regex to also handle `2025-08-07`-style dashes
  (OpenAI snapshot format) in addition to the `20250929` Anthropic format.
- Tests cover dotted SKUs at their own tier, gpt-5-2025-08-07 stripping,
  and explicitly assert that gpt-5.5-mini (catalog SKU without a published
  OpenAI price) is unmapped instead of borrowing gpt-5.5's row.

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

---------

Co-authored-by: multica-agent <github@multica.ai>
2026-05-09 19:21:55 +08:00
Bohan Jiang
1d4595ff8f docs(changelog): add 0.2.29 release notes for 2026-05-09 (#2335)
* docs(changelog): add 0.2.29 release notes for 2026-05-09

Summarizes the 31 PRs landed since v0.2.28 in EN and ZH changelog
data, organized into features, improvements, and fixes.

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

* docs(changelog): remove PostHog feature note

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

---------

Co-authored-by: multica-agent <github@multica.ai>
Co-authored-by: Eve <eve@multica-ai.local>
v0.2.29
2026-05-09 17:51:28 +08:00
Bohan Jiang
b73a301bf9 fix(agent): drain stderr before deciding ACP failure promotion (#2333)
`hermes`, `kimi`, and `kiro` all wired stderr through
`cmd.Stderr = io.MultiWriter(logWriter, providerErrSniffer)`.
The OS-pipe → MultiWriter copy goroutine that exec spawns for
that form is only joined by `cmd.Wait()`, which the lifecycle
goroutine fires in deferred cleanup — *after*
`promoteACPResultOnProviderError` already consulted the sniffer.
When stopReason=end_turn (success) raced ahead of the stderr
drain, the sniffer's `lines` slice was empty, the helper fell
through to the synthetic agent-text fallback ("hermes provider
error: API call failed after 3 retries"), and the actionable
upstream signal (HTTP 429 / usage limit) was lost.

This was visible as a flaky
`TestHermesBackendPromotesProviderErrorWithNonEmptyOutput` in CI
under high parallelism — a real prod bug, not a test issue: live
runs hit the same race when an upstream LLM returns 429 and
hermes' synthetic agent turn beats the stderr drain to the
parent.

Replace the MultiWriter wiring with `cmd.StderrPipe()` + an
explicit copier goroutine that signals on `stderrDone`. The
lifecycle goroutine already awaits `<-readerDone` for stdout;
add `<-stderrDone` next to it before `promoteACPResultOnProviderError`
runs. The deferred `cmd.Wait()` ordering is unchanged — it just
becomes a cheap reap by the time it fires.

Verified: `go test ./pkg/agent/ -run "TestHermes|TestKimi|TestKiro"
-count=10 -race`, then full package `-count=3 -race`, all green.

Co-authored-by: multica-agent <github@multica.ai>
2026-05-09 17:34:25 +08:00