Compare commits

...

88 Commits

Author SHA1 Message Date
Jiayuan Zhang
c2d8ef97f2 docs(runtimes): add install-agent-runtime page and link from onboarding empty state
New docs page covering install pointers, binary names the daemon scans
for, and basic auth notes for all 11 supported AI coding tools. EN +
zh-Hans, registered under "How agents run" in the docs sidebar.

The onboarding "no agent runtime found" empty state now shows an
"Install an agent runtime →" link that opens the new doc, so users have
a discoverable path beyond "skip" and "join waitlist".

Co-authored-by: multica-agent <github@multica.ai>
2026-05-19 01:57:49 +08:00
Jiayuan Zhang
e0a6a39a47 feat(agents): list-only tasks panel with issue search (MUL-2391) (#2820)
* feat(agents): list-only tasks panel with issue search (MUL-2391)

Replace the agent detail tasks view-mode toggle with a fixed list view and
add a search bar that filters by issue title, identifier, or pinyin.

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

* fix(actor-issues): only show search empty state when searching

Previously the panel rendered the search empty state whenever the
filtered issue list was empty, which masked ListView's own status-based
empty states when status/priority/assignee/project/label filters
narrowed the list to 0. Now search_empty only renders when
`search.trim()` is non-empty and results are 0; otherwise ListView
takes over and shows its native empty states.

Refs MUL-2391

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

---------

Co-authored-by: multica-agent <github@multica.ai>
2026-05-18 18:44:21 +02:00
Bohan Jiang
6f5fbb7813 feat(comments): thread-aware list with composite cursor (MUL-2340) (#2787)
* feat(comments): thread-aware list with composite cursor (MUL-2340)

Adds three optional query params to GET /api/issues/{id}/comments and the
matching `multica issue comment list` flags:

- `thread=<comment-uuid>` resolves the anchor to the thread root via a
  recursive CTE (defends against any future nested replies) and returns
  root + all descendants chronologically. Anchor can be any comment in
  the thread, root or reply.
- `recent=<N>` returns the newest N comments for the issue, ordered
  chronologically in the response.
- `before=<RFC3339>` + `before-id=<uuid>` form a composite cursor for
  stable pagination of `recent`. Both must be set together; a
  timestamp-only cursor is rejected because ties on `created_at` would
  let the existing `(created_at ASC, id ASC)` total order skip or
  duplicate rows across pages.

Flag combination rules: `thread` is exclusive with `recent` and the
cursor; both may combine with `since`. Server and CLI enforce the same
matrix; the CLI fails fast locally so callers don't pay for a 400
round-trip.

Default behaviour (no params) is unchanged — full chronological dump
capped at commentHardCap — so the desktop UI and existing `--since`
polling are untouched. Agent prompt updates land in a follow-up PR so
the new CLI capabilities ship and bake first.

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

* fix(comments): reject cursor without recent and align CLI/server on invalid --recent (MUL-2340)

Elon's PR #2787 second review flagged two gaps in the flag combination
matrix:

- server: GET /comments?before=...&before_id=... without `recent` was
  silently dropped by fetchCommentsForList (RecentN=0 fell through to
  the default / since path), so callers got the full timeline instead
  of the documented "before X" semantics. Now returns 400.
- CLI: --recent 0 / --recent -3 were collapsed with "flag not passed"
  by `recent > 0`, so an explicit invalid value silently fell back to
  the default list. Switched to Flags().Changed("recent") so explicit
  non-positive values fail loudly. Also enforces that --before /
  --before-id only appear with explicit --recent (mirrors the new
  server-side rule).

Tests:
- server flag matrix gains `before + before_id without recent → 400`.
- CLI gains TestRunIssueCommentListFlagGuards covering `--recent 0`,
  `--recent -3`, cursor-without-recent, and the thread/recent
  exclusivity path under the new Changed()-based check. The mock
  server fatals if a request reaches /comments, proving the guards
  fire before any HTTP round-trip.

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

* feat(comments): make `recent` thread-grouped with a thread cursor (MUL-2340)

Bohan pushed back on the row-based `recent=N` shape: comments form a tree,
not a list, and the newest N rows can come from N unrelated threads, giving
the agent N disjoint conversational tails. Replace the row-based query with
a thread-grouped one before #2787 merges so we never ship the wrong shape:

- `recent=N` now returns the N most recently active threads (root + every
  descendant per thread). A thread's recency is MAX(created_at) across its
  whole subtree, so a stale-but-recently-replied thread outranks an old
  quiet one — exactly the property row-recent loses.
- The cursor is now a *thread* cursor: `before` = a thread's
  last_activity_at, `before_id` = its root comment id. The pair walks
  threads strictly less recent than the page's oldest-active thread. The
  cursor surfaces via `X-Multica-Next-Before` / `X-Multica-Next-Before-Id`
  response headers (empty when there are no older threads); the CLI
  forwards the same pair to stderr after listing.
- Row-based `recent` is gone — there is no internal caller and the prompt
  update has not shipped yet, so there is no compat surface to preserve.
- Response body shape unchanged (flat JSON array, chronological). Default
  and `--since` paths untouched. Desktop UI keeps working.

Tests:
- recent=1 returns the freshest-active thread fully; recent=2 returns both
  with the older-active thread first (oldest-active → freshest tail).
- Stale-but-fresh: a thread whose root is older but has a fresh reply
  outranks a thread whose root is newer but quiet.
- Cursor headers emitted only on full pages; empty on the final page.
- Pagination walks threads root2 → root1 → empty, no skips/duplicates.
- Tie-break: three threads sharing last_activity_at paginate one-at-a-time
  using (last_activity_at, root_id) ordering — verifies the timestamp-only
  cursor failure mode is fixed for the thread case too.

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

---------

Co-authored-by: multica-agent <github@multica.ai>
2026-05-18 19:28:26 +08:00
Naiyuan Qing
baedc48f59 fix(editor): source-view highlight + HTML attachment open-in-new-tab (#2812)
* fix(editor): bump hast-util-to-html to v9 so lowlight output actually serializes

Source view of fenced ```html (and any other code block falling through to
the lowlight branch in ReadonlyContent) silently rendered as un-highlighted
escaped text. Root cause was a stale dep pin: `hast-util-to-html: ^4.0.1`
predates the package's ESM/named-export rewrite — v4 only exports a CJS
default function, so the `import { toHtml } from "hast-util-to-html"` in
code-block-static.tsx:19 and readonly-content.tsx:32 resolved to
`undefined` at runtime. The try/catch in both call sites caught the
"toHtml is not a function" throw and fell through to escapeHtml plain
text, so no `.hljs-*` spans ever made it to the DOM and the syntax-color
CSS added in #2808 had nothing to attach to.

Bumping to ^9.0.5 (matches the v9 line that lowlight@3 / remark / rehype
ship in the rest of the tree) makes the named `toHtml` export available
and source-view highlighting works.

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

* feat(editor): open HTML attachment in new tab + full-page preview route

Adds a third toolbar button to HtmlAttachmentPreview between Maximize and
Download: open the attachment in a new app tab (desktop) or browser tab
(web). The full-screen modal stays — they serve different scenarios:
modal for a quick "see it bigger" without leaving the issue context,
new-tab when the user wants to keep the rendered HTML around while
working on something else.

Components:
- New workspace path: `/{slug}/attachments/{id}/preview?name={filename}`.
  Lives outside the (dashboard) group on web so the iframe gets the full
  viewport — sidebar would defeat the point. Desktop registers the route
  inside `WorkspaceRouteLayout` so workspace context resolution still
  runs (no slug → no path is built).
- `packages/views/attachments/attachment-preview-page.tsx`: shared full-
  page view that reuses `useAttachmentHtmlText` for the iframe srcDoc.
  Sandbox stays `allow-scripts` (no allow-same-origin) — same security
  posture as the inline preview.
- `HtmlAttachmentPreview`: adds Open-in-new-tab button. Routes through
  `useNavigation().openInNewTab` when available (desktop), falls back to
  `window.open(getShareableUrl(path))` on web. Button is hidden when no
  workspace slug is in scope (shouldn't happen in practice, but the
  shared component must not throw outside a workspace route).

Tests cover: desktop openInNewTab call args, web window.open fallback,
and that the failure-mode toolbar still surfaces all three actions.

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

* fix(editor): drop now-stale @ts-expect-error on hast-util-to-html imports

v9 ships bundled type declarations, so the directives added for v4 trigger
TS2578 ("Unused '@ts-expect-error' directive") on CI typecheck.

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-18 19:09:45 +08:00
Boynn
933f417dac fix(views): clear manual draft when packing into agent prompt (#2370)
When alternately switching between manual and agent modes in the create-issue
dialog, the title and description were being duplicated and accumulated on
every round-trip. Root cause: manual→agent packed title+description into the
agent prompt but left them in the shared useIssueDraftStore; the subsequent
agent→manual wrote the agent markdown into draft.description while the stale
draft.title persisted, so the remounted manual panel surfaced both.

Clear title/description from the shared draft at the moment they move into
the agent representation, so round-trips can't layer stale manual state on
top of prompt-as-description.

Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-18 18:50:08 +08:00
Naiyuan Qing
e6cf5a6eca fix(editor): highlight HTML source view + drop misplaced Copy on attachments (#2808)
Two issues from #2790's HTML inline preview work:

1. HTML source view rendered as default-colored text. lowlight emits
   `.hljs-tag` / `.hljs-name` for `<...>` brackets and element names, but
   content-editor.css only styled the keyword / string / attr / etc.
   classes — so toggling an inline ```html``` block to "source" showed
   attributes colored and everything else plain. Adds the two missing
   classes in light + dark.

2. HtmlAttachmentPreview carried a "Copy code" button. An HTML attachment
   is a file (view + download), not an inline source snippet. The inline
   ```html``` fenced block (HtmlBlockPreview) is where reading / copying
   source belongs. Drops the button, its state, and the useAttachmentHtmlText
   `canCopy` branch — the hook is still needed for the iframe srcDoc.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-18 18:27:28 +08:00
Qi Yijiazhen
d9ae891064 fix(avatar): stop bg-muted bleeding through transparent images (#2670)
ActorAvatar applies bg-muted on its container regardless of whether
an image is loaded, so transparent regions of PNG/SVG avatars reveal
the grey placeholder. agent-detail-inspector also wraps ActorAvatar
in an outer bg-muted div, layering a second grey square.

Make bg-muted conditional on the fallback state in ActorAvatar, and
drop the redundant bg-muted from avatar-picker's image-loaded branch
and the two inspector wrappers. Empty-state placeholders unchanged.
2026-05-18 18:23:46 +08:00
Bohan Jiang
ffba2607aa fix(daemon): default auto-update off for self-host instances (MUL-2381) (#2807)
A self-host operator running a fork of Multica with their own patches would
have their daemon silently upgraded to the upstream GitHub release, clobbering
the fork. Self-host setups also routinely pin to an older server, so a fresh
CLI may no longer talk to it.

Flip the default: auto-update remains opt-in on api.multica.ai and defaults to
off on any other server URL. Either side can override via
MULTICA_DAEMON_AUTO_UPDATE.

Co-authored-by: multica-agent <github@multica.ai>
2026-05-18 18:19:07 +08:00
Bohan Jiang
b97cc3cb6e fix(autopilots): align trash icon with action buttons in webhook trigger row (#2805)
The TriggerRow's outer flex uses `items-start`, which made sense back
when every trigger only had one row of content (label + maybe a cron
expression). Once #2774 added the URL action row to webhook triggers
(Copy + Rotate buttons sitting on a second line inside the inner column),
the trash button stayed pinned to the top-right of the outer flex — it
visibly floats above the URL action buttons instead of lining up with
them, which reads as a layout glitch.

Move the trash button into the URL action row for webhook triggers so
all three action buttons (Copy, Rotate, Delete) share one flex container
and align by construction. Schedule and API triggers — which have no
URL row — keep the trash button pinned top-right (their bodies are
short enough that the top corner reads as "the row's right end").

Extract a `deleteButton` const so the JSX isn't duplicated, and add the
existing `delete_dialog.confirm` i18n string as the title attribute for
consistency with the other action buttons (Copy / Rotate already have
hover titles).

No behavioural change — same click handler, same confirm dialog.
2026-05-18 18:16:45 +08:00
Multica Eve
b58ab2cc48 docs: remove reverted runtime changelog note (#2806)
Co-authored-by: Eve <eve@multica-ai.local>
Co-authored-by: multica-agent <github@multica.ai>
2026-05-18 18:12:44 +08:00
Bohan Jiang
eabfb8f3d1 fix(autopilots): reject unknown {{...}} tokens in issue title template (MUL-2370) (#2799)
* fix(autopilots): reject unknown {{...}} tokens in issue title template (MUL-2370)

`--issue-title-template` (and the matching `issue_title_template` API
field) silently kept any placeholder other than `{{date}}` as a literal
string in the rendered issue title — `{{.TriggeredAt}}`, `{{trigger_id}}`,
`${date}`, etc. would all slip through `strings.ReplaceAll` unchanged
because the renderer only knew one token. The flag name and help text
("Template for issue titles (create_issue mode)") and the docs phrasing
("the title supports interpolation like `{{date}}`") both implied a
richer placeholder set existed.

Tightens the contract on three fronts:
- Reject any `{{...}}` token other than `{{date}}` at create/update time
  with `unknown template variable %q; supported: {{date}}` — turns the
  silent-on-trigger surprise into an explicit 400 the moment the user
  sets the template.
- Update CLI flag help on `autopilot create --issue-title-template` and
  `autopilot update --issue-title-template` to spell out that only
  `{{date}}` (UTC, YYYY-MM-DD) is interpolated.
- Update `apps/docs/content/docs/autopilots{,.zh}.mdx` to drop the
  "like `{{date}}`" phrasing for the single supported placeholder.

Adds service-layer tests covering `interpolateTemplate` (substitution,
empty-template fallback, no-placeholder verbatim) and
`ValidateIssueTitleTemplate` (accepts empty / plain / `{{date}}` /
`{{ date }}`; rejects Go-template, Mustache-style, future placeholders
like `{{datetime}}`, and templates that mix one valid and one invalid
token).

Expanding the placeholder set (`{{datetime}}`, `{{trigger_id}}`,
`{{trigger_source}}`) is tracked as a separate enhancement — those
need run/trigger context plumbed into the renderer, which is out of
scope for this bug fix.

Closes #2732

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

* fix(autopilots): render {{ date }} whitespace form too (MUL-2370)

Validator permitted {{ date }} but interpolateTemplate only matched the
exact string {{date}}, so a template that passed create/update could
still emit a literal {{ date }} at trigger time — re-introducing the
silent-literal behaviour the validator was meant to remove.

Route rendering through the same regex as validation so every accepted
form is also a substituted form. Cover {{ date }} substitution in
TestInterpolateTemplate.

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

---------

Co-authored-by: multica-agent <github@multica.ai>
2026-05-18 18:12:14 +08:00
Bohan Jiang
e8d4b9a0a2 revert: drop exec_command watchdog (#2779, #2786) (MUL-2337) (#2803)
* Revert "fix(codex): bump default exec_command stuck timeout to 3 minutes (#2786)"

This reverts commit 433cd1aaf5.

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

* Revert "feat(codex): add per-exec_command watchdog to escape dropped function_call_output (MUL-2337) (#2779)"

This reverts commit 60bae62622.

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

---------

Co-authored-by: multica-agent <github@multica.ai>
2026-05-18 18:08:07 +08:00
Bohan Jiang
fe1ccb19c9 Revert "MUL-2324 conditionally inject non-core rule blocks (#2771)" (#2802)
This reverts commit e8fb0efe3d.
2026-05-18 17:48:44 +08:00
Naiyuan Qing
5f1ced867c feat(editor): HTML attachments render like images (MUL-2345 v4) (#2798)
* feat(editor): HTML attachments render like images (MUL-2345 v4)

HTML attachments no longer wear the file-card chrome (icon + filename
row). They now render as a sandboxed iframe with a hover-revealed
right-top toolbar (Open / Download / Copy code), mirroring the image
attachment visual model.

- New HtmlAttachmentPreview owns the iframe + hover toolbar plus three
  states (loading / success / error). Failure mode keeps the toolbar
  pinned open and Open/Download enabled so the user is never stranded
  without an escape hatch — Copy code disables when the text body is
  unavailable.
- New AttachmentBlock thin dispatcher picks the renderer per kind:
  html + attachmentId + !uploading -> HtmlAttachmentPreview, else
  AttachmentCard. All three entry points (file-card NodeView, readonly
  file-card, standalone AttachmentList) call AttachmentBlock, so feature
  work on a new kind only touches one place.
- AttachmentCard collapses back to a pure file-card row UI: the inline
  HTML iframe branch (InlineHtmlIframe + inlineHtmlEnabled +
  showInlineHtml) is removed.
- AttachmentBlock added to the editor barrel export.

Sandbox/server-side defenses unchanged: sandbox="allow-scripts" (no
allow-same-origin), srcDoc, server still returns text/plain + nosniff
on the /content proxy.

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

* test(editor): pin three entry points to AttachmentBlock HTML route (MUL-2345)

Reviewer flagged that the v4 dispatcher refactor only had tests on the
shared AttachmentBlock + HtmlAttachmentPreview; the three real call
sites at file-card.tsx:59, readonly-content.tsx:279, and
comment-card.tsx:152 had no regression coverage. Reverting any one
would silently lose the inline HTML iframe path — the exact MUL-2330
regression we're meant to be locking down.

Each new test renders the real entry point with an HTML+attachmentId
fixture and asserts the dispatched iframe (sandbox=allow-scripts,
srcdoc) shows up while the AttachmentCard chrome (filename row) does
not. FileCardView and AttachmentList are exported from their files for
direct rendering, mirroring the existing CodeBlockView test pattern.

Mutation-tested locally: temporarily flipping each site back to
<AttachmentCard> turns its corresponding test red.

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-18 17:44:32 +08:00
Multica Eve
4d8b6ddb84 docs: add May 18 changelog entry (#2800)
Co-authored-by: Eve <eve@multica-ai.local>
Co-authored-by: multica-agent <github@multica.ai>
2026-05-18 17:28:52 +08:00
Bohan Jiang
692570f41a fix(autopilots): contain Delivery dialog within viewport (#2788)
Two related overflow bugs in the Delivery detail dialog (the popover you
open from a webhook deliveries row, shipped in #2784) became obvious as
soon as a real webhook payload was exercised:

1. **Horizontal overflow: minified JSON pushed dialog off-screen.**
   `CodeBlock`'s `<pre>` uses `white-space: pre` (default for the tag),
   which means a single-line minified JSON body had intrinsic
   min-content equal to the whole line's width. The parent grid cell
   inherits the default `min-width: auto` (= min-content), so a long
   body propagated all the way up and blew DialogContent past its
   `max-w-2xl` cap. Headers rendered fine because they're
   pretty-printed JSON with real newlines.

   Fix: `min-w-0` on the CodeBlock wrapper so it can shrink below
   min-content, plus `whitespace-pre-wrap break-all` on the `<pre>` so
   long lines wrap (`break-all` is the only modifier that breaks
   mid-token, which a minified JSON body needs because it has no
   whitespace to break at).

2. **Vertical overflow: dialog grew past viewport.**
   `DialogContent` had no height cap. With Raw body + Headers +
   Response body + Replay button stacked vertically, anything beyond
   the screen edge (notably the Replay button) became unreachable.

   Fix: `max-h-[85vh] overflow-y-auto` on `DialogContent`.

Both fixes are CSS-only in one file; HMR verified.
2026-05-18 17:07:14 +08:00
Bohan Jiang
84d75cdd1e docs(self-host): reverse-proxy guidance for loopback-only ports (MUL-2360) (#2794)
* docs(self-host): explain loopback-only bindings + reverse proxy guidance (MUL-2360)

Follow-up to #2759, which bound all docker-compose published ports to
127.0.0.1. The self-host quickstart still told cross-machine users to
point their CLI at `http://<server-ip>:8080`, which no longer works
(and shouldn't — the default JWT_SECRET/Postgres creds must not be
reachable from the open internet).

- Add a Callout to step 1 explaining the loopback-only bindings and
  linking to the new reverse-proxy step.
- Split step 5 into 5a (same machine, defaults) and 5b (cross-machine),
  with a minimal Caddyfile that fronts both frontend and backend on a
  single hostname (including the `/ws` route with `flush_interval -1`).
  Switch the cross-machine `--server-url` example to `https://<domain>`.
- Mirror the changes in the Chinese quickstart.
- Add a header comment block to docker-compose.selfhost.yml so anyone
  reading the file directly understands why services don't show up on
  `0.0.0.0` and what to do about it.

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

* docs(self-host): use nginx highlighter for Caddyfile snippet

Shiki's default bundle does not include `caddy` / `caddyfile`, so
Vercel's `pnpm build` failed with:

  ShikiError: Language `caddy` is not included in this bundle.

Switch the code fence to `nginx`, which is in the default bundle and
gives near-identical visual highlighting for this snippet. No content
changes — the Caddyfile inside the block is untouched.

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

---------

Co-authored-by: multica-agent <github@multica.ai>
2026-05-18 17:00:31 +08:00
AdamQQQ
fab0671332 feat(skills): support multi-select bulk import in Copy from runtime (#2686)
- Multi-select UI for batch importing skills from a local runtime
- Server batch-dispatches up to 10 import requests per heartbeat cycle
- WS heartbeat now reads supports_batch_import from daemon payload
  instead of hardcoding true, so old daemons correctly fall back to
  one-at-a-time dispatch
- Raised server pending timeout to 3min and client poll timeout to 4min
  to accommodate daemons that pop only one import per 15s heartbeat

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-05-18 16:56:27 +08:00
Jiayuan Zhang
46c1e2c889 feat(squads): show member working status on squad detail page (#2768)
* feat(squads): show member working status on squad detail page

Add a new GET /api/squads/{id}/members/status endpoint that returns each
member's derived working/idle/offline/unstable status, the issues each
agent is currently running, and the last observed activity timestamp.
The Squad detail page's Members tab consumes this snapshot to render a
status pill and an active-issue link next to each agent, with live
refresh wired through the existing task/agent/daemon WS events.

Human members are returned with status=null so the UI can keep them in
the same list without implying a presence signal. Archived agents stay
in the response and surface as offline rather than being filtered out.

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

* fix(squads): address review feedback on member status endpoint

- i18n the "blocked" issue-status pill in squad members tab (was a
  bare literal that failed `i18next/no-literal-string` lint).
- Treat any dispatched/running task as working, even when its
  `agent_task_queue.issue_id` is NULL (chat / quick-create tasks).
  The agent slot is occupied regardless of whether we can render an
  issue link.
- Force `offline` for archived agents so they appear in the list
  but never look like they're still on duty, matching the RFC
  decision in MUL-2319.
- Include `workspaceKeys.squads` in the post-reconnect /
  workspace-switch bulk invalidation so members-status recovers
  after a disconnect during which task/runtime events were missed.

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

---------

Co-authored-by: multica-agent <github@multica.ai>
2026-05-18 10:35:18 +02:00
Zheng Li
c78bfbcf17 fix(skills): keep skill title input transparent in dark mode (#2710)
The skill name Input on the detail editor uses `bg-transparent px-0`
to render as flush, chrome-less text. The base Input component also
applies `dark:bg-input/30`, which Tailwind keeps because it lives in
the `dark:` variant. In dark mode this exposes a 30% white fill that
appears flush against the text — looking like missing left padding.

Add `dark:bg-transparent` to the className so the override wins in
both color modes.
2026-05-18 16:32:28 +08:00
Bohan Jiang
1796ef6dff fix(runtimes): prefer Local machine as default selection (MUL-2359) (#2792)
On desktop, localDaemonId is fetched async, so on first paint the only
machines available are remotes — the existing auto-select picks the
first remote, then sticks because subsequent renders see selectedMachineId
still in the list. Result: the local Mac never gets the default focus
even though it sorts first.

Re-evaluate the default on every machines change, preferring the local
section. Honor a user pick once it's been made.

Co-authored-by: multica-agent <github@multica.ai>
2026-05-18 16:29:02 +08:00
Naiyuan Qing
ceb967aefa feat(editor): inline HTML attachment preview + ```html block render (MUL-2345) (#2790)
* feat(editor): inline HTML attachment preview + ```html block render (MUL-2345)

* attachment-preview-modal: switch HTML iframe sandbox from "" to
  "allow-scripts" so JS-driven chart libraries render. The opaque-origin
  iframe still cannot touch cookies, localStorage, parent state, or
  top-nav — only scripts run.
* New shared AttachmentCard wired into the three attachment surfaces
  (file-card NodeView, ReadonlyContent file-card branch, comment-card
  standalone AttachmentList). HTML attachments now render inline via a
  sandboxed iframe pulled through the existing /content proxy; other
  kinds keep the original chrome behavior.
* New HtmlBlockPreview for fenced ```html blocks in ReadonlyContent —
  default preview iframe, source/Copy toggle. Two-layer code+pre unwrap
  mirrors the Mermaid pattern; unwrap now matches on language-* class
  because react-markdown invokes pre before the code renderer runs.
* CodeBlockView (Tiptap NodeView) renders an iframe preview for
  language=html with a CSS-hidden toggle to the editable source — the
  <NodeViewContent as="code"/> mount must remain in the tree.
* Shared use-attachment-html-text hook keeps inline and modal HTML
  rendering on the same React Query cache.
* Vitest coverage: allow-scripts assertion, attachment-card kind
  branches, readonly HTML iframe + Mermaid unwrap regression, NodeView
  editable + preview/source toggle.

No backend changes; server-side text/plain + nosniff defense kept.

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

* fix(editor): tighten attachment preview and pre unwrap gates (MUL-2345)

Addresses Reviewer REQUEST CHANGES on PR #2790:

1. URL-only text/html attachment cards no longer surface a dead Eye
   button. `AttachmentCard` previously allowed preview when
   `previewableFromUrl=true` regardless of kind, but the modal's
   `tryOpen` rejects URL-only text kinds because the `/content` proxy
   is ID-keyed. Drop the `previewableFromUrl` prop and gate the
   no-attachmentId path strictly to URL-previewable media kinds
   (pdf/video/audio).

2. Readonly `pre` unwrap now uses exact class-token matching. The
   previous `className.includes("language-html")` check also fired
   on `language-htmlbars`, silently stripping its `<pre>` wrapper.
   Use `/(^|\s)language-(html|mermaid)(\s|$)/` so only the exact
   tokens unwrap.

Regression tests:
- `report.html + no attachmentId` asserts no Preview button.
- `pdf URL-only` asserts Preview button still appears.
- `htmlbars` / `mermaidx` fences keep their `<pre><code>` wrapper.

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-18 16:23:40 +08:00
Ayman Alkurdi
d04b00b32e fix(security): bind all services to loopback in docker-compose files (#2759)
The base docker-compose.yml bound postgres to 0.0.0.0:5432 and
docker-compose.selfhost.yml bound postgres/backend/frontend without
a host_ip prefix — defaulting to 0.0.0.0 on all interfaces.

On any VPS with a public IP, these services were reachable from the
internet. Docker bypasses UFW iptables chains by default, so host-
level firewall rules on these ports had no effect.

Fix: prefix every port binding with 127.0.0.1 so services are only
reachable from the host itself. This matches the documented
DATABASE_URL (which uses localhost) and does not break any legitimate
local dev or self-host workflow — connections from the host shell,
migration scripts, and the backend container (via Docker internal
network) all continue to work unchanged.
2026-05-18 16:14:41 +08:00
Bohan Jiang
a4a18605eb fix(desktop): handle Cmd/Ctrl +/-/0 zoom in main process (MUL-2354) (#2791)
The default Electron application menu's zoomIn/zoomOut roles do not fire
reliably on macOS — Cmd+= would zoom in but Cmd+- could not undo it, so
users got stuck at the zoomed-in level with no way back.

Move the shortcut into before-input-event so the same handler covers
every platform and every keyboard layout. preventDefault here blocks
both the renderer keydown and the menu accelerator, so there's no
double-zoom risk on macOS.

Co-authored-by: multica-agent <github@multica.ai>
2026-05-18 16:12:03 +08:00
Multica Eve
dfe2a57361 fix(autopilots): allow duplicate create_issue runs (#2789)
Co-authored-by: Eve <eve@multica-ai.local>
Co-authored-by: multica-agent <github@multica.ai>
2026-05-18 16:05:54 +08:00
LinYushen
6621231237 fix: improve search ranking and snippet support (MUL-2329)
Fixes MUL-2329
2026-05-18 15:45:06 +08:00
Bohan Jiang
433cd1aaf5 fix(codex): bump default exec_command stuck timeout to 3 minutes (#2786)
The watchdog fires on a "no progress" window, so the default mainly
matters for commands that go fully silent (no outputDelta). Bumping
from 2m → 3m leaves more headroom for legitimately slow silent
commands before treating them as a dropped function_call_output, at
a modest cost to recovery latency.

MUL-2337

Co-authored-by: multica-agent <github@multica.ai>
2026-05-18 15:30:05 +08:00
YYClaw
8cc48b1176 fix(ui): vertically center SelectItem content (#2782) 2026-05-18 15:28:00 +08:00
Anderson Shindy Oki
2d501322e9 fix: Squads page unable to scroll (#2764) 2026-05-18 15:19:16 +08:00
Bohan Jiang
60bae62622 feat(codex): add per-exec_command watchdog to escape dropped function_call_output (MUL-2337) (#2779)
* feat(codex): add per-exec_command watchdog to escape dropped function_call_output (MUL-2337)

Codex app-server can drop the second function_call_output when two
exec_command calls fan out in the same turn and both async-yield through
the yield_time_ms boundary (observed 2026-05-18, MUL-2334 — Trump Agent
wedged for 6+ min with no semantic activity events to drive any existing
timer). The model then waits forever for the missing output; only the
10-minute semantic inactivity timeout would eventually rescue the run.

Add a per-call watchdog in the codex client that tracks open
exec_command / commandExecution items by call_id and fails the turn
quickly (default 2 min, configurable via ExecOptions.ExecCommandStuckTimeout)
when one stays open without progress. outputDelta events reset the
per-call progress timestamp so long-running streaming commands aren't
flagged.

This is a daemon-side mitigation only — codex itself still has the
upstream race, but the daemon no longer burns the full inactivity budget
before the run is marked failed and a new run can recover.

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

* feat(codex): track legacy exec_command_output_delta in watchdog (MUL-2337)

Mirrors the raw v2 item/commandExecution/outputDelta refresh on the legacy
codex/event protocol so a long-running streaming exec doesn't get falsely
flagged as stuck after begin + 2 min.

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

---------

Co-authored-by: multica-agent <github@multica.ai>
2026-05-18 15:14:45 +08:00
Bohan Jiang
c328c402d8 feat(autopilots): webhook deliveries tab + replay button (MUL-2334) (#2784)
Wires the frontend onto the PR1 webhook delivery layer. Adds a Deliveries
section to the autopilot detail page that lists recent deliveries
(queued / dispatched / rejected / ignored / failed) with provider, event,
attempt count, and timestamp. Clicking a row opens a detail dialog with
raw body, headers subset, response body, signature status, and a Replay
button. Replay is disabled client-side for signature-invalid / rejected /
still-queued deliveries to mirror the server's 400.

Backend contract is locked behind a lenient zod schema via
parseWithFallback — unknown future status / signature_status values
degrade to a generic row instead of dropping the whole list.

Co-authored-by: multica-agent <github@multica.ai>
2026-05-18 15:13:07 +08:00
Bohan Jiang
2323b72710 feat(autopilots): webhook delivery layer + idempotency/signature/replay (MUL-2334) [PR1] (#2774)
* feat(autopilots): webhook delivery layer + idempotency / signature / replay (MUL-2334)

Splits "inbound webhook receipt" from "autopilot run creation" so we can
record duplicate attempts, signature outcomes, and ignored/skipped
deliveries — and replay a delivery on demand. v1 ingress wrote straight
into autopilot_run.trigger_payload, which collapsed the two concerns and
left run_only autopilots vulnerable to provider retry storms.

Backend only (PR1). UI Deliveries tab follows in PR2.

Schema (migration 093):
  - autopilot_trigger.provider: 'generic' | 'github' (default 'generic').
  - autopilot_trigger.signing_secret: nullable plaintext (HMAC needs it
    cleartext; mirrors how webhook_token is stored).
  - webhook_delivery: one row per inbound POST. Carries raw_body,
    selected_headers, dedupe_key/source, signature_status,
    autopilot_run_id, replayed_from_delivery_id, response_status / body.
  - Partial unique index on (trigger_id, dedupe_key) excludes NULL and
    'rejected' rows, so a wrong-secret 401 does NOT permanently block a
    future retry with the same X-GitHub-Delivery once the operator fixes
    the secret.

Ingress flow (autopilot_webhook.go), persist-first + sync dispatch:
  1. IP rate limit -> 2. token lookup -> 3. token rate limit ->
  4. read raw body -> 5. autopilot/workspace cross-check ->
  6. normalize JSON (400 without persistence on parse failure) ->
  7. compute dedupe key + signature status ->
  8. INSERT delivery (status=queued). On (trigger_id, dedupe_key)
     unique-violation: bump attempt_count on existing row and return
     the original delivery_id + autopilot_run_id with 200 ->
  9. invalid/missing signature: UPDATE -> rejected, return 401 with
     delivery_id (no dispatch, not replayable) ->
 10. trigger disabled / autopilot paused/archived: UPDATE -> ignored,
     return 200 ->
 11. DispatchAutopilot synchronously, UPDATE -> dispatched/skipped/failed
     with autopilot_run_id and the response body we returned ->
 12. TouchAutopilotTriggerFiredAt and return 200.

No new long-running worker. A stale 'queued' row only happens if the
process dies between INSERT and UPDATE; that's a follow-up sweeper, not
this PR.

Authenticated API:
  - GET    /api/autopilots/{id}/deliveries (slim list)
  - GET    /api/autopilots/{id}/deliveries/{deliveryId} (with raw_body)
  - POST   /api/autopilots/{id}/deliveries/{deliveryId}/replay -> creates
    a new delivery row (replayed_from_delivery_id set), dispatches a
    new run, never collapses onto the original via dedupe.
  - PUT    /api/autopilots/{id}/triggers/{triggerId}/signing-secret
    Write-only; trigger response surfaces has_signing_secret +
    signing_secret_hint (last 4 chars), never the secret itself.

Signature verification reuses the GitHub-compatible
X-Hub-Signature-256: sha256=<hex(hmac(body, secret))> scheme; the
HMAC helper is constant-time. Invalid/missing signatures still count
against per-IP and per-token rate limits.

autopilot_run.trigger_payload is intentionally preserved — delivery
records the HTTP receipt; run records the normalized envelope handed
to the agent. They are two different views.

Tests (Postgres-backed):
  - delivery persistence on accept
  - dedupe via Idempotency-Key and X-GitHub-Delivery; run_only retry
    storm pin (3 retries -> 1 run)
  - invalid signature: 401 + rejected row + no run linkage
  - missing signature when secret configured: 401 + 'missing' state
  - valid signature dispatches
  - signing secret never echoed in trigger responses; hint shows last 4
  - min-length and clear-by-empty for signing secret PUT
  - replay creates a NEW delivery + new run; rejected deliveries cannot
    be replayed
  - list omits raw_body; detail includes it; cross-autopilot ID returns
    404 (workspace isolation defense in depth)
  - provider validation: unknown -> 400, github -> 201 round-trips
  - bad-signature stream still counts against per-token rate limit

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

* fix(autopilots): address PR review on webhook delivery layer (MUL-2334)

- Exclude `failed` from the (trigger_id, dedupe_key) partial unique index
  alongside `rejected`, so a transient ingress failure does not strand the
  provider's stable X-GitHub-Delivery / Idempotency-Key retry. Update the
  dedupe lookup to prefer non-terminal rows under the same predicate.
- Tighten delivery status enum: drop `skipped` from the CHECK constraint
  and from the handler. A run that was admission-skipped (e.g. runtime
  offline) is now recorded as delivery=`dispatched` linked to the
  skipped run, with the response payload carrying status=`skipped`.
  Source of truth for skipped-ness is autopilot_run.status, not the
  delivery row — keeps the Deliveries UI enum unambiguous.
- On dispatch error, link the (possibly non-nil) autopilot_run returned
  by DispatchAutopilot to the failed delivery so Deliveries UI can
  navigate to the run row for debugging.
- Slim list projection: ListWebhookDeliveriesByAutopilot no longer pulls
  raw_body / selected_headers / response_body — a 100-row page × 256 KiB
  would otherwise round-trip ~25 MiB from Postgres per Deliveries reload.
  Detail endpoint continues to return the full row.
- Fix backend CI: TestGetDelivery_ReturnsFullPayload now decodes the
  response and asserts on the parsed raw_body instead of substring-
  matching against an escaped JSON string; raise the test-suite default
  webhook rate limits in TestMain so the shared 192.0.2.1 IP bucket
  doesn't fill across the suite and leak 429s into unrelated tests.
- Add regression coverage for the dedupe-after-failure path.

cd server && go test ./... is green locally.

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

---------

Co-authored-by: multica-agent <github@multica.ai>
2026-05-18 14:59:40 +08:00
Naiyuan Qing
20c2f45b4a fix(views): surface backend error messages on mutation failures (MUL-2317) (#2772)
* fix(views): surface backend error messages on mutation failures (MUL-2317)

Mutation toasts across the views package were swallowing the backend
`error` string and showing only a generic i18n fallback. This made it
impossible for users to see why an operation failed (most visibly:
creating an issue with a duplicate title produced a vague "Failed to
create issue" toast).

The fix has three pieces:

1. Create-issue duplicate branch (A段)
   - New schema `DuplicateIssueErrorBodySchema` in core/api/schemas.ts.
   - `create-issue.tsx` parses `ApiError.body` via `parseWithFallback`
     and renders a dedicated amber-toned toast with a "view existing"
     link when the server returns `{ code: "active_duplicate_issue",
     issue: {...} }`. Schema drift downgrades to the normal error toast.
   - Schema intentionally omits `issue.status` so the toast does not
     depend on `StatusIcon`, which has no fallback for unknown enums.

2. User-facing mutation failure toasts (B段)
   - 47 sites converted to `err instanceof Error && err.message ?
     err.message : <existing fallback>` — preserves all existing
     code-specific branches (slug conflict, agent_unavailable,
     daemon_version_unsupported) and i18n keys.
   - Covers Type 1 (onError) and Type 2 (catch block) patterns across
     issues, projects, autopilots, inbox, runtimes, squads, comments,
     batch actions, workspace create, and agent config tabs.

3. Autopilot partial-success (Type 3)
   - New i18n keys `toast_create_partial_with_reason` /
     `toast_update_partial_with_reason` (double-brace `{{reason}}`).
   - `autopilot-dialog.tsx` captures `err.message` in the schedule
     `catch` and routes to the `_with_reason` variant when present,
     preserving the partial-success semantic (autopilot saved, schedule
     failed) while exposing the actual reason.

Explicitly out of scope:
- `packages/core/` mutation hooks (no global onError, no UI dependency)
- No `toastApiError` helper (matches existing 14+ correct sites)
- Sub-issue link aggregate `Promise.allSettled` keeps count-based toast
  (N independent requests cannot collapse to one err.message); only
  added a dev-side `console.error` per rejection.
- Clipboard catches and `useUpdateChatSession` (not API mutation toasts)

Tests:
- `packages/core/api/schemas.test.ts` — schema contract (valid body,
  forward-compat fields, rename rejection, missing issue, wrong types).
- `packages/views/modals/create-issue.test.tsx` — duplicate toast +
  view link, schema-drift fallback, err.message surfacing, non-Error
  fallback (4 new cases).
- `packages/views/autopilots/components/autopilot-dialog-i18n.test.ts`
  — real i18next, asserts rendered text contains the reason verbatim
  (guards against `{reason}` vs `{{reason}}` regression).

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

* fix(autopilots): unify rotate-token catch + cover dialog partial-success render

Address reviewer feedback on PR #2772:

1. webhook-token rotate (`autopilot-detail-page.tsx`) now follows the
   `err.message ?? fallback` ternary used by the sibling trigger
   delete/add paths, instead of swallowing the error.

2. Extract `formatSchedulePartialFailureToast` so the dialog's
   partial-success branches and the i18n test exercise the same
   helper. The test now drives the actual format function, so a
   variable-name typo at the call site (e.g. `{ msg }` instead of
   `{ reason }`) fails the substring assertion.

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

* test(modals): drop user.type for title in success path to dodge CI 5s timeout

The success-path test typed the 42-character title via userEvent which
triggers a controlled re-render per keystroke. On the slower CI runner
the whole test crept up to ~5s and intermittently tripped the default
vitest timeout. Setting the value in one shot via fireEvent.change cuts
the cost while leaving the submit + toast interactions on userEvent.

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-18 13:44:10 +08:00
Zohar Babin
15152c6ccd feat(auth): cache workspace membership for daemon heartbeat path (MUL-2247) (#2638)
* feat(auth): cache workspace membership for daemon heartbeat path

Cache workspace membership existence (not role) in Redis to eliminate a
DB round-trip on every PAT-authenticated daemon heartbeat. Follows the
existing PATCache nil-safe pattern.

Key design decisions per reviewer feedback:
- Cache existence only (sentinel "1"), not role string. Authorization
  decisions that depend on role always hit the DB directly. This
  eliminates the cache-aside race where a stale elevated role could
  persist after a downgrade.
- Proactive invalidation on UpdateMember, DeleteMember, LeaveWorkspace,
  and DeleteWorkspace (iterates members before cascade delete).
- 5 min TTL. Combined with PATCache (10 min), worst-case revocation
  delay is max(10m, 5m) = 10 min — consistent with original PATCache
  design decision.

Limitations:
- Non-members still hit DB on every request (negative caching not
  implemented — the scenario is rare for daemon endpoints which require
  valid workspace-scoped tokens).

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

* test(auth): drive membership cache invalidation through real handlers

- TestRequireDaemonWorkspaceAccess_CacheHit now uses a ghost user with no
  member row, so the only path to a granted access is the cache short-circuit.
  Without priming the cache the access check must fail; with priming it must
  succeed. A future change that bypasses the cache would fail the second
  assertion.
- Replaces the cache-only InvalidatedOnMemberRemoval test (which only
  re-exercised the auth-package primitive) with four handler-driven tests
  that exercise DeleteMember, UpdateMember, LeaveWorkspace and
  DeleteWorkspace via their real HTTP handlers. Each test prepares a real
  member, primes the cache, calls the handler, and asserts the cache entry
  is gone — so a refactor that drops one of the Invalidate(...) calls in
  workspace.go will fail CI.

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

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>
Co-authored-by: Jiang Bohan <bhjiang@outlook.com>
2026-05-18 13:30:35 +08:00
Bohan Jiang
eb5c6d7547 docs(self-host): document auth rate-limit env keys (#2773)
Adds REDIS_URL, RATE_LIMIT_AUTH, RATE_LIMIT_AUTH_VERIFY, and
RATE_LIMIT_TRUSTED_PROXIES to the environment-variables page (EN +
ZH) and to .env.example, with the reverse-proxy caveat that without
RATE_LIMIT_TRUSTED_PROXIES every user shares the proxy IP and the
whole deployment ends up in one bucket.

Follow-up to #2636. MUL-2251.

Co-authored-by: multica-agent <github@multica.ai>
2026-05-18 13:11:17 +08:00
Zohar Babin
e50bfc88da fix(auth): add per-IP rate limiting on public auth endpoints (#2636)
Adds a Redis-backed fixed-window rate limiter middleware on /auth/send-code,
/auth/verify-code, and /auth/google. Prevents brute-force enumeration,
verification_code table flooding, and connection pool exhaustion from
rapid-fire unauthenticated requests.

Key design decisions per reviewer feedback:

- X-Forwarded-For trust model: XFF is NEVER trusted by default. Only
  honored when RemoteAddr is from a CIDR in RATE_LIMIT_TRUSTED_PROXIES.
  Uses rightmost-untrusted algorithm (walks XFF right-to-left, returns
  first non-trusted IP). Matches the project's conservative model in
  health_realtime.go.

- Atomic INCR+EXPIRE via Lua script: prevents a stuck key (permanent
  ban) if EXPIRE fails independently. Follows existing Lua script
  pattern in runtime_local_skills_redis_store.go.

- Fixed-window counter (not sliding-window): simple, adequate for auth
  rate limiting where precision at window boundaries is acceptable.

- Fail-open with startup warning: nil Redis disables rate limiting
  (same as PATCache), but logs a warning at startup so ops can see.

- IPv6 normalization: net.ParseIP().String() produces canonical form.

- Configurable via env vars: RATE_LIMIT_AUTH (default 5/min),
  RATE_LIMIT_AUTH_VERIFY (default 20/min), RATE_LIMIT_TRUSTED_PROXIES.

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-05-18 12:59:28 +08:00
Multica Eve
e8fb0efe3d MUL-2324 conditionally inject non-core rule blocks (#2771)
* feat(runtime): conditionally inject non-core rule blocks

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

* fix(runtime): tighten mention rule triggers

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-18 12:52:54 +08:00
Naiyuan Qing
d42fbcb794 fix(editor): sync ContentEditor when defaultValue changes externally (#2765)
* fix(editor): sync ContentEditor when defaultValue changes externally

Tiptap v3 `useEditor` reads `content` only at mount (ueberdosis/tiptap#5831
— by design), so when an issue description is updated remotely (WS event,
another agent, another client), the editor kept showing stale content
until the issue was closed and reopened. `key={id}` in issue-detail only
force-remounts on issue switch, not on same-issue updates.

Add a useEffect in ContentEditor that watches `defaultValue` and applies
it via `editor.commands.setContent()` with four guards:

  1. Focused AND dirty — protect bytes the user is actively typing.
     Focused-but-clean intentionally falls through: onBlur has no replay
     path, so an unconditional `if (isFocused) return` would drop the
     sync forever for users who click into the editor without typing.
  2. Unfocused AND dirty — covers the blur → debounce (1500ms) window
     where the editor holds unsaved content but isFocused is already
     false. The pending onUpdate flush reconciles via the cache;
     overwriting here would be silent data loss.
  3. Normalized-equal short-circuit — avoids a no-op transaction when
     the cache reflects a write this editor just emitted.
  4. `emitUpdate: false` — Tiptap v3 flipped setContent's emitUpdate
     default to true; without this the sync would re-trigger onUpdate
     → server save → self-write loop.

After setContent, clamp the prior selection to the new doc size so the
caret doesn't snap to position 0.

Tests cover five cases: unfocused+dirty-content (sync fires),
focused+dirty (skip), focused+clean (must sync — regression guard for
the focused-but-clean hole), unfocused+dirty (blur-before-debounce
window, skip), and normalized-equal short-circuit (skip).

Closes #2409

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

* test(editor): cover normalized-equal sync path with a distinct defaultValue

The previous rerender passed the same `defaultValue` string, so React's
dep-array equality short-circuited the sync effect entirely — the test
only exercised the first-mount equality check, not the actual
normalized-equal guard.

Pass a different-but-trimEnd-equivalent value so the effect re-runs and
the normalized-equal short-circuit is what keeps setContent uncalled.

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-18 12:39:14 +08:00
johnhu-1237
79dd066363 fix env example websocket origin (#2599) 2026-05-18 12:38:52 +08:00
Multica Eve
58a76f6d96 fix(execenv): trim default runtime brief command list (MUL-2322) (#2769)
Trim the default runtime brief Available Commands to the agreed core set, including issue create/update, while keeping non-core commands discoverable through help. CI passed for backend and frontend.
2026-05-18 12:25:37 +08:00
Kerim Incedayi
9418d2a2c1 feat(autopilots): webhook triggers (server + CLI + UI + docs) MUL-2049 (#2348)
* feat(server): add webhook trigger DB migration + sqlc queries

Lays the foundation for webhook autopilot triggers:
- partial unique index on autopilot_trigger.webhook_token (kind=webhook only)
  so the public ingress route can resolve a trigger in O(1)
- GetWebhookTriggerByToken / TouchAutopilotTriggerFiredAt /
  RotateAutopilotTriggerWebhookToken / SetAutopilotTriggerWebhookToken
  queries, regenerated with sqlc

* feat(server): webhook token generator + payload normalizer

Two pure helpers for the webhook autopilot work:
- generateWebhookToken: 32 random bytes -> base64-url, "awt_" prefix.
  256 bits of entropy keeps brute-force off the table; the prefix makes
  leaked tokens recognisable in logs.
- normalizeWebhookPayload: turns arbitrary JSON into the WebhookEnvelope
  shape (event/eventPayload/request) used by trigger_payload. Header- and
  body-based event inference covers GitHub, GitLab, X-Event-Type, and
  caller-provided envelopes; scalar/empty/invalid bodies are rejected so
  the handler can answer 400.

* feat(server): generate webhook tokens and expose rotate endpoint

- New handler.Config.PublicURL fed by MULTICA_PUBLIC_URL env so
  /api/autopilots/.../triggers responses can include an absolute
  webhook_url alongside the always-present webhook_path.
- CreateAutopilotTrigger now mints a webhook_token via crypto/rand
  for kind=webhook and ignores cron/timezone for non-schedule kinds.
  api triggers stay accepted-but-inert per PLAN.md.
- New POST /api/autopilots/{id}/triggers/{triggerId}/rotate-webhook-token
  protected by the existing workspace auth group; old tokens stop
  working immediately because the unique-index lookup keys on the
  current row value.

* feat(server): public webhook ingress route + per-token rate limiter

- New POST /api/webhooks/autopilots/{token} route, mounted outside the
  authenticated group: the path token is the credential. Workspace
  context is derived from the joined autopilot row, never headers.
- Body capped at 256 KiB via http.MaxBytesReader; oversized payloads
  return 413 mid-read instead of being fully buffered.
- Disabled triggers / paused / archived autopilots return
  200 {"status":"ignored"} so providers stop retrying.
- Skipped-runtime dispatches surface 200 {"status":"skipped"} with the
  reason from the autopilot service's pre-flight admission check.
- WebhookRateLimiter interface with sliding-window in-memory + Redis
  Lua-script implementations. Default 60 req/min per token. Test
  coverage on the in-memory path; Redis variant fails open on cache
  errors so a Redis hiccup never blocks ingress.
- Integration tests exercise token generation, dispatch, payload
  envelope persistence, GitHub-header inference, paused/disabled
  short-circuits, oversized rejection, and rotate-then-old-token-404.

* feat(server): include webhook payload in create_issue description

When an autopilot run is triggered by a webhook and execution_mode is
create_issue, the agent only sees the issue body — never the run's
trigger_payload. Append a 'Webhook event:' line and a fenced JSON block
with the normalized eventPayload so the agent has the inbound context
inline. Schedule / manual runs are unchanged.

Tests cover:
  - schedule path keeps existing italic note, no webhook block
  - webhook path emits event line + payload block, italic before block
  - non-envelope JSON falls back to raw body (defensive)
  - non-webhook source with payload still gets no webhook block

* feat(core): types, API client and mutations for webhook triggers

- AutopilotRunStatus gains 'skipped' so the run-list UI handles the
  admission-skipped state explicitly instead of falling through to a
  generic case (the backend already emits it via MUL-1899).
- AutopilotTrigger picks up optional webhook_path / webhook_url. Both
  are optional so older self-hosted servers that pre-date this change
  still parse cleanly.
- buildAutopilotWebhookUrl helper composes a usable absolute URL with
  the priority webhook_url > apiBaseUrl + path > origin + path > path.
  Tested with seven cases covering each branch.
- ApiClient.rotateAutopilotTriggerWebhookToken posts to
  /api/autopilots/{id}/triggers/{triggerId}/rotate-webhook-token; the
  HTTP-contract test pins URL + method.
- useRotateAutopilotTriggerWebhookToken mutation invalidates
  autopilotKeys.detail on settle, mirroring the existing trigger-mutation
  pattern.

* feat(views): webhook trigger UI in Add Trigger dialog and trigger row

Add Trigger dialog gains a Schedule/Webhook segmented toggle:
  - Schedule reuses TriggerConfigSection unchanged.
  - Webhook hides the cron config and shows a help line; the trigger is
    created with kind=webhook and the URL is generated server-side.
  - Toast text differentiates schedule vs webhook on success.

TriggerRow grows a webhook branch:
  - Webhook icon, kind translated via trigger_kind.
  - URL shown in a truncating monospace pill, with copy + rotate
    buttons. Copy uses navigator.clipboard with toast feedback; rotate
    uses an AlertDialog confirm because the old URL stops working
    immediately.
  - api triggers render a Deprecated badge and skip URL/copy/rotate
    affordances.

RunRow gains a 'skipped' RUN_VISUAL entry (muted dash) so admission-
skipped runs don't fall through to a generic case. Source label uses the
new run_source i18n key instead of capitalize.

Locales: en + zh-Hans gain run_status.skipped, run_source.*,
trigger_kind.*, trigger_row.{copy_url,rotate_url,*_confirm_*,toast_*},
add_trigger_dialog.{type_*,webhook_help,toast_added_{schedule,webhook}}.

* feat(cli): support webhook trigger creation and URL rotation

- multica autopilot trigger-add now takes --kind schedule|webhook
  (default schedule for backward compatibility). For webhook it skips
  --cron / --timezone validation and prints the resulting webhook URL,
  preferring the server-provided webhook_url and falling back to
  client.BaseURL + webhook_path.
- New multica autopilot trigger-rotate-url <autopilot-id> <trigger-id>
  command for rotating the bearer URL of a webhook trigger.

* docs(autopilots): add webhook trigger guide (en + zh)

Replaces the 'Webhook and API triggers are not available yet' section
with end-to-end webhook documentation: how the URL is generated, what
payload shapes are accepted, the inferred-event rules, the bearer-secret
warning + rotate flow, status-code semantics for accepted/skipped/
ignored/4xx/5xx outcomes, and the MULTICA_PUBLIC_URL self-host
configuration.

Run history list now mentions skipped status. The 'unavailable
features' section narrows to api-kind triggers, HMAC signing, IP
allowlists, and provider presets.

* feat(views): add Schedule/Webhook toggle to the create autopilot dialog

Closes the gap where a brand-new autopilot could only be created with a
schedule trigger. The right-column config now has a Trigger section
with a segmented Schedule/Webhook control:
  - Schedule keeps the existing cron/timezone UI.
  - Webhook hides the cron UI and shows a help line; on submit, a
    kind=webhook trigger is created right after the autopilot.

In edit mode the toggle is intentionally hidden (PLAN.md treats trigger-
type changes as delete-old + create-new, not in-place updates), but the
panel still picks the right kind based on props.triggers[0].kind so a
webhook autopilot doesn't render an irrelevant cron form.

Locales: section_trigger_kind, trigger_kind_{schedule,webhook},
section_webhook, webhook_help_{create,edit} added in en + zh-Hans.

* feat(views): show webhook URL inline after creating a webhook autopilot

After a successful create with kind=webhook, the dialog stays open and
swaps to a confirmation panel showing the freshly minted URL with a
copy button + 'Treat this URL like a password' warning + Done button.
Avoids the friction of "create the autopilot, then go find it in the
list, click in, scroll to triggers, copy URL."

Locales: dialog.webhook_created_{title,description,warning,done} added
in en + zh-Hans.

Schedule create flow is unchanged (toast + close). The success panel is
gated on the trigger returned from the create mutation, so a partial
failure (autopilot created, trigger creation errored) still falls
through to the toast_create_partial path.

* feat(views): show webhook payload in run detail dialog

The agent transcript dialog now accepts an optional headerSlot that
sits above the event list. The autopilot RunRow drops a
WebhookPayloadPreview into that slot when the run came from a webhook
and trigger_payload is non-empty.

The preview is collapsed by default (the transcript itself is the main
event), shows the inferred event name + receivedAt in the header, and
reveals the eventPayload as pretty-printed JSON with a copy button on
expand. Falls back gracefully if the row's trigger_payload doesn't
match the WebhookEnvelope shape — the whole value is shown instead so
nothing is hidden.

Closes the "agent didn't echo the payload, now I can't see what
triggered the run" gap. PLAN.md tracked this as
"Payload preview in run history" under follow-ups.

Locales: webhook_payload.{label, unknown_event, payload, content_type,
copy, copied, copied_short, copy_failed} added in en + zh-Hans.

* chore(server): wire MULTICA_PUBLIC_URL through self-host compose

Two small follow-ups split out of the webhook trigger PR:

- docker-compose.selfhost.yml passes MULTICA_PUBLIC_URL into the
  backend container so a self-hosted deployment behind a real domain
  gets absolute webhook URLs in the trigger response. Documented in
  .env.example with the rationale for not deriving the public host
  from request headers.
- Drop a duplicated 'invalid json:' prefix in the webhook ingress
  400 error path. normalizeWebhookPayload already prefixes its
  errors, so the handler doesn't need to re-prefix.

* fix(migrations): renumber webhook trigger migration 081 → 089 to avoid collision

The branch's 081_autopilot_webhook_triggers.{up,down}.sql collided
numerically with 081_runtime_timezone.{up,down}.sql that landed on
main, making migration apply order undefined. Renumber to 089 so the
file slots after the latest main migration (088_squad_instructions).

The SQL itself doesn't conflict — it only creates a partial unique
index on autopilot_trigger.webhook_token — but the duplicate prefix
is what the migration runner sees, so the filename must move.

* fix(autopilot-webhook): address PR review blocking issues

- Redact bearer tokens from request logs: paths matching
  /api/webhooks/autopilots/<token> now log "[redacted]" instead of the
  token. The resolved trigger ID is plumbed via context so audit lines
  stay useful for debugging. (Review item Blocking #1.)
- Distinguish pgx.ErrNoRows from transient DB errors in token lookup:
  no-row stays 404 (so providers don't retry on a deleted webhook),
  other errors return 500 (which providers DO retry, avoiding silent
  drops on DB blips). (Review item Blocking #2.)
- Add per-IP sliding-window rate limiter that runs BEFORE the token
  lookup, so spraying random tokens can no longer probe the
  autopilot_trigger index unboundedly. Reuses the existing Lua script
  with a separate Redis key namespace; falls open on Redis errors.
  Default budget 30 req/min/IP. (Review item Blocking #3.)

The webhook handler now applies the gates in the order: per-IP rate
limit → token lookup → per-token rate limit → handler logic.

* fix(autopilot): atomic webhook trigger creation + strict kind/timezone validation

- Mint the webhook bearer token BEFORE the INSERT and pass it via
  CreateAutopilotTriggerParams so the row never exists in a half-written
  kind=webhook + webhook_token=NULL state. On the (vanishingly rare)
  unique-index collision the whole INSERT is retried with a fresh token
  — no UPDATE second step. Removes the now-dead attachFreshWebhookToken
  helper. (Review item Recommended #4.)
- Add new GET /api/autopilots/{id}/runs/{runId} endpoint that returns a
  single run including the full trigger_payload. The list response is
  now slim (omits trigger_payload) so worst-case payload size drops
  from ~5 MB to ~5 KB. (Review item Recommended #5, server side.)
- Reject kind=api with 400 ("kind=api is deprecated; use schedule or
  webhook") and reject kind=webhook with --timezone with 400 — both
  surfaces stragglers loudly instead of silently dropping fields.
  CLI mirrors the check so --timezone with --kind webhook errors
  client-side. (Review nits.)
- Add --yes (-y) flag and an interactive y/N confirmation prompt to
  `multica autopilot trigger-rotate-url` so the destructive rotate
  matches the UI's AlertDialog safety. (Review item Recommended #6.)

* fix(views): fetch webhook payload on-demand and truncate at 4 KiB

- Add useAutopilotRun query hook + getAutopilotRun API client method
  paired with the new server endpoint. The run-detail dialog now mounts
  a WebhookPayloadSlot that fetches the full run (incl. trigger_payload)
  lazily — list responses no longer carry up to 256 KiB × N runs of
  envelope data.
- WebhookPayloadPreview truncates its in-DOM <pre> at 4 KiB with a
  localized marker so jank-y machines aren't asked to render a 256 KiB
  JSON blob. The Copy button still yields the full string.
- Adds the truncated_marker i18n string to en + zh-Hans.

Review items Recommended #5 (frontend) and a nit on the preview's
unbounded <pre>.

* test(autopilot-webhook): close coverage gaps flagged in PR review

- request_logger: redactWebhookPath unit tests + integration test
  proving the bearer token never lands in slog output, plus the
  webhook_trigger_id context plumbing.
- autopilot_webhook_handler: empty body → 400, archived autopilot →
  200 ignored, per-IP rate limiter trips before DB lookup, kind=api
  and webhook+timezone are rejected at 400, slim list + full detail
  endpoint round-trip.
- webhook_rate_limiter: Lua script structure guard (catches reordering
  even without a live Redis), plus live-Redis tests for both per-token
  and per-IP limiters (REDIS_TEST_URL gated, matching the existing
  Redis test pattern in the package).
- WebhookPayloadPreview: envelope rendering, fallback shape, and the
  >4 KiB truncation path with full-payload-on-Copy guarantee.

Two branches are documented as code-review-protected rather than
covered by tests: the 500-on-DB-error path requires injecting a stub
Queries (no interface here), and the cross-workspace defense-in-depth
check is unreachable from valid SQL state.

* fix(middleware): SetWebhookTriggerID must mutate request in place

The round-1 helper returned a fresh *http.Request from WithContext, and
the webhook handler did `r = SetWebhookTriggerID(r, ...)`. That swaps
the handler's local pointer but doesn't propagate the new context back
to RequestLogger, which is still holding the original *http.Request —
so the audit line never actually included webhook_trigger_id in
production. The round-1 test happened to pass because it pre-stashed
the value on the request before calling ServeHTTP, bypassing the bug
it was meant to verify.

Switch to in-place mutation via `*r = *r.WithContext(...)` so the
wrapping middleware sees the new context after next.ServeHTTP returns,
and update the test to exercise the real call pattern (set the context
from inside the handler, assert the surrounding logger reads it).

Verified live: an accepted webhook now logs
  path=/api/webhooks/autopilots/[redacted] webhook_trigger_id=<uuid>

* fix(autopilot-webhook): symmetric ErrNoRows split + trusted-proxy gate

Round-2 review (Bohan-J, PR #2348 follow-up):

- Must-fix #1: the second lookup at autopilot_webhook.go:258
  (GetAutopilot after the token resolves) was folding every error into
  404. A transient DB blip would tell a webhook sender "not found" and
  it would never retry. Apply the same errors.Is(err, pgx.ErrNoRows)
  → 404 / else → 500 split as the first lookup got in round 1.

- Must-fix #2: clientIPForRateLimit was honoring X-Forwarded-For /
  X-Real-IP from any caller. An attacker spraying random tokens could
  just rotate the XFF header and the per-IP bucket became per-request,
  so the limiter that's specifically supposed to gate spraying before
  it hits the DB unique index was bypassed.

  New shape — matches Bohan's suggestion exactly:
  * Default: r.RemoteAddr only, headers ignored.
  * Operator opt-in via MULTICA_TRUSTED_PROXIES (comma-separated
    CIDRs). XFF/X-Real-IP are honored only when r.RemoteAddr is
    inside one of the listed prefixes; otherwise they're dropped.

  Wired through .env.example and docker-compose.selfhost.yml so
  self-host operators can configure their reverse-proxy's CIDR.
  Invalid CIDRs in the env var are dropped with a single slog.Warn at
  startup rather than crashing the server. Uses net/netip (stdlib,
  value-typed) for parsing and containment checks.

Verified live on the rebuilt self-host backend: a 35-request spray
from one source with rotating XFF gets the expected 30× 404 + 5× 429,
proving the per-IP bucket is keyed on the real connection IP.

* fix(autopilot): reject cron/timezone PATCH on non-schedule triggers

Round-2 review should-fix. CreateAutopilotTrigger already 400s on
kind=webhook + timezone/cron_expression, but UpdateAutopilotTrigger
silently wrote those fields regardless of prev.Kind. The values then
sat in the DB visible to nobody and read by nothing — a back door that
left the API contract fuzzy across create vs update.

Mirror the create-path discipline: after loading prev, if prev.Kind
!= "schedule" and the PATCH body sets cron_expression or timezone,
return 400 with a clear message. enabled and label remain accepted on
every kind.

The existing prev.Kind == "schedule" guard on next_run_at recompute
stays as belt-and-braces, but with this gate in place the recompute
branch is now reachable only for the kind it was meant for.

* test(autopilot-webhook): close round-2 coverage gaps

- IPRateLimitNotBypassedByXFFSpoof: drives the must-fix #2 invariant
  by rotating XFF across three calls from the same RemoteAddr and
  asserting the third gets 429. Pre-round-2 this test would have
  passed for the wrong reason (limiter trusted XFF, so per-bucket
  collision was incidental); now it pins the bypass-closed property.
- IPRateLimitReturns429BeforeDBLookup: updated to set RemoteAddr
  explicitly and drop the XFF header it was leaning on. With
  TrustedProxies empty (test default) the limiter keys on the real
  connection IP, which is what the test wants to assert anyway.
- UpdateAutopilotTrigger_RejectsCronExpressionOnWebhookKind +
  UpdateAutopilotTrigger_RejectsTimezoneOnWebhookKind: drive the
  round-2 should-fix from the handler boundary.
- UpdateAutopilotTrigger_AcceptsEnabledAndLabelOnWebhookKind: counter
  test so a regression to a blanket reject is caught.

* fix(migrations): bump webhook trigger migration 089 → 091

origin/main added 089_squad_no_action_activity_index (and 090_task_is_leader)
since our last rebase, re-colliding with our 089_autopilot_webhook_triggers.
Bump to 091 so the filename ordering is unambiguous again. The SQL is
unchanged — same partial unique index on autopilot_trigger.webhook_token —
only the filename moves.

* fix(views): dedupe skipped icon in autopilot RUN_VISUAL after rebase

The rebase against origin/main merged main's add of `Ban` for the
skipped status next to our round-1 `MinusCircle` entry, leaving the
RUN_VISUAL map with two `skipped` keys (only the last would have been
read at runtime, and MinusCircle had been dropped from the imports
during conflict resolution — so the file would not compile).

Keep main's `Ban` icon (latest design) and a single `skipped` entry.
Carry over the round-1 comment about why the muted styling matters
for failure-ratio readability.

---------

Co-authored-by: Kerim Incedayi <kerim.incedayi@digitalchargingsolutions.com>
2026-05-18 12:17:39 +08:00
Jiayuan Zhang
7c3dab695f fix(runtimes): stop surfacing agent CLI version branding in machine subtitle (#2752)
compactDeviceInfo was flipping the parenthetical of an agent CLI version
string (e.g. "2.1.5 (Claude Code)" -> "Claude Code 2.1.5") and using that
as the per-machine subtitle. Each daemon's runtimes are sorted alphabetically
and `claude` always sorts first, so every claude-equipped machine's row
ended up showing "Claude Code …" — drowning out actual per-machine differences.

The reshape was meant for OS+arch shapes ("macOS (x86_64)" -> "x86_64 macOS"),
not version strings. Filter agent-version-like parts out before picking a
primary so the subtitle either reflects real machine info or falls back to
the daemon-id descriptor.

Co-authored-by: multica-agent <github@multica.ai>
2026-05-18 11:06:19 +08:00
Jiayuan Zhang
f1c9617b5e feat(runtimes): Redesign runtimes machine layout (#2747) 2026-05-17 23:14:22 +08:00
Bohan Jiang
113c4f4e90 docs(agent): clarify openclaw agent id vs name semantics (#2744)
Follow-up to #2716. Updates two stale comments that still described
openclaw's `name` and `id` as interchangeable. The actual contract:
`id` is the routing key passed to `openclaw agent --agent <id>`;
`name` is a human display label and is not safe to pass to the CLI.

No behavior change.

Co-authored-by: multica-agent <github@multica.ai>
2026-05-17 17:20:41 +08:00
Kagura
44d2fc1946 fix(agent): use openclaw agent id instead of name for --agent flag (#2716)
openclawEntriesToModels() used the agent Name (which may contain
spaces, e.g. "Sub2API OPS") as Model.ID. This ID is passed to
openclaw via --agent, where normalizeAgentId mangles spaces into
hyphens ("sub2api-ops"), causing a lookup miss against the
registered id ("sub2api") and a "no parseable output" error.

Fix: prefer agent ID for Model.ID; use Name only for display Label.
When ID is empty, fall back to Name for backward compatibility.

Fixes #2714
2026-05-17 17:08:00 +08:00
Bohan Jiang
3645bdb5b6 feat(issues): add start_date field with progressive disclosure (MUL-2274) (#2696)
* feat(issues): add start_date field with progressive disclosure (MUL-2274)

Mirrors the existing due_date implementation end-to-end so an issue can
express a planned start in addition to a deadline. Surfaces start_date as
an optional sidebar property alongside priority / due_date / labels (added
in MUL-2275), with consistent picker, board/list/sort, activity, and inbox
plumbing.

Backs the Project Gantt work (parent MUL-1881) and keeps the
progressive-disclosure attribute experience consistent.

- DB: migration 091 adds issue.start_date TIMESTAMPTZ.
- sqlc: ListIssues / CreateIssue / UpdateIssue / CreateIssueWithOrigin /
  ListOpenIssues read & write start_date.
- Backend: IssueResponse + create/update/batch-update handlers parse and
  emit start_date with RFC3339 validation; new start_date_changed activity
  event + subscriber notification (with prev_start_date in event payload).
- CLI: --start-date flag on `multica issue create` / `issue update`.
- Frontend: StartDatePicker component, start_date wired into Issue type,
  Zod schema, draft / view stores, sort util, header sort + card-property
  options, list-row / board-card display, create-issue modal, and the
  issue-detail progressive-disclosure "+ Add property" surface (visibility
  rule, picker row, add-property menu icon + label).
- i18n: en + zh-Hans for sort_start_date / card_start_date /
  prop_start_date / activity start_date_set / start_date_removed /
  picker start_date.trigger_label / clear_action / inbox labels.
- Tests: new TestNotification_StartDateChanged; existing Issue / draft /
  modal fixtures extended with start_date.

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

* feat(issues): align start_date with due_date in actions menu and CLI table

- Add Start Date submenu (today / tomorrow / next week / clear) in
  actions menu, mirroring Due Date — parity with the Due Date quick
  setters in list/board context and 3-dot menus.
- Add corresponding en / zh-Hans i18n keys
  (actions.start_date / start_today / start_tomorrow / start_next_week
  / start_clear).
- CLI human table for `multica issue list` and `multica issue get`
  now shows a START DATE column next to DUE DATE; --full-id variant
  too.

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

---------

Co-authored-by: multica-agent <github@multica.ai>
2026-05-17 15:01:38 +08:00
Jiayuan Zhang
668cab6022 feat(github): mirror PR CI checks and merge conflict status (MUL-2228) (#2632)
* feat(github): mirror PR CI checks and merge conflict status (MUL-2228)

Surface "checks passed/failed" and "conflicts/no conflicts" badges under
each linked PR on the issue page so users can judge readiness without
flipping over to GitHub. CI state is fed by check_suite webhooks
(GitHub Actions + apps using the Checks API; legacy status events are
out of scope for MVP); conflicts are read from pull_request.mergeable_state.

Data model:
  * github_pull_request: add head_sha + mergeable_state
  * github_pull_request_check_suite: per-suite rows keyed by (pr_id, suite_id)
  * Aggregation done at query time, filtering by current head_sha so
    late-arriving suites for a stale head can't contaminate the new head's
    pending view; per-app latest suite chosen first so a single app firing
    multiple suites isn't counted N times.

Webhook hardening:
  * synchronize/opened/reopened/edited(base) explicitly clear mergeable_state
  * single-row ordering protection on the check_suite upsert prevents a
    late-delivered older event from overwriting a newer one
  * check_suite.pull_requests is iterated; unknown PRs are logged and dropped

UI:
  * PR row shows Checks + Conflicts badges; opaque mergeable values
    (blocked/behind/unstable/...) render as no badge, not as conflicts.
  * Terminal PR states (merged/closed) suppress the status row entirely.

Tests: * Pure unit coverage for derivePRMergeableState + aggregateChecksConclusion
  * Webhook integration tests: multi-app aggregation, old-head ignore,
    late-older-event ignore, synchronize clears mergeable_state
  * Vitest coverage for pull-request-list badge rendering across CI/conflict
    combinations and the legacy (null) fallback.
Co-authored-by: multica-agent <github@multica.ai>

* fix(github): scope check_suite PR lookup; preserve mergeable on metadata

Addresses code review on PR #2632.

1. check_suite handler now resolves the PR through the workspace-scoped
   GetGitHubPullRequest query instead of GetGitHubPullRequestByRepoNumber.
   The (workspace_id, repo_owner, repo_name, pr_number) tuple is the real
   uniqueness key, so a bare (owner, repo, number) lookup could return a
   stale row from another workspace and either land the suite on the wrong
   PR or skip the right one when the installation ids drifted. The old
   unscoped query is removed.

2. derivePRMergeableState now returns (value, clear) and the upsert SQL
   distinguishes three cases: state-changing actions clear the column to
   NULL, non-empty payloads write the value, and metadata events with an
   empty payload preserve the existing column. Previously every empty
   payload became NULL, so a labeled/assigned event silently wiped a
   known clean/dirty verdict in violation of the RFC's "metadata empty
   payload preserves" rule.

3. ListPullRequestsByIssue narrows to the issue's PR ids before running
   the per-app check_suite aggregation, avoiding a full-table scan over
   github_pull_request_check_suite when only a handful of rows belong to
   the requested issue.

New helper test covers labeled+empty preserves; new integration test
verifies a metadata event after a known mergeable_state keeps the value.

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

* feat(github): PR card layout v3 increment — stats + segmented progress bar

Replaces the row + badge layout under "Pull requests" on the issue
detail sidebar with a card that mirrors the GitHub PR summary look:
title, author/avatar, +N −M · K files diff stats, segmented progress
bar (failed → pending → passed, failure leftmost), and a one-line
status caption following an explicit priority pass-through.

Backend
- Migration 092: github_pull_request adds additions / deletions /
  changed_files (INT NOT NULL DEFAULT 0). Zero defaults are what the
  new frontend treats as "legacy backend — hide the stats row" so old
  PR rows that pre-date this migration don't render "+0 −0 · 0 files".
- pull_request webhook handler reads stats off the top-level payload.
- ListPullRequestsByIssue now surfaces per-suite counts
  (checks_passed / failed / pending) alongside the existing aggregate
  conclusion, so the segmented bar reuses the already-computed counts
  with no new aggregation.

Frontend (packages)
- core/github/pull-request-status.{ts,test.ts}: pure-function module
  for the status-kind priority table and the segment derivation; 15
  cases covered, includes the "all-zero → hide stats" guard.
- views/issues/components/pull-request-list.tsx: PullRequestCard plus
  a compact-row fallback used when count > 4 (first 3 as cards, the
  remainder collapsed behind a Show more toggle).
- i18n: new `pull_request_card_*` keys in en + zh-Hans.

Tests
- 12 component tests covering each rule of the priority table, the
  legacy-zero stats fallback, and the collapse threshold.
- Reuse of the v3 webhook handler tests confirmed.

Verification
- pnpm typecheck + pnpm test green (60 test files, 536 tests).
- go build ./... + go vet ./... clean.
- 6 demo issues (DEV-2..DEV-7) screenshotted via Playwright; see the
  PR comments for the visual check matrix.

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

* fix(views): collapse PR cards at N>=4, not N>4

The card-vs-collapse threshold used `>` so 4 PRs slipped past it and
all rendered as full cards, contrary to RFC v3 (N >= 4 collapses to
3 cards + compact tail). Switch to `>=` and update the threshold-
boundary test to expect "Show 1 more".

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

* fix(views): align PR sidebar rows with existing list style

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

* fix(views): hide terminal PR status badges

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

---------

Co-authored-by: multica-agent <github@multica.ai>
2026-05-16 21:26:30 +02:00
Jiayuan Zhang
431006e7d6 feat(daemon): add debug-level logs at key debug-path nodes (MUL-2304) (#2733)
Local daemon previously logged mostly at Info, leaving startup/exit,
config resolution, registration, heartbeat ticks, agent invocation, and
result classification undiagnosable without code-reading. Add Debug
logs at those checkpoints so LOG_LEVEL=debug (the default) produces
enough detail to follow a run end-to-end without changing normal Info
output.

Co-authored-by: multica-agent <github@multica.ai>
2026-05-16 18:02:12 +02:00
Jiayuan Zhang
9bd17058f8 fix(daemon): bump idle watchdog default 5m → 30m (MUL-2300) (#2728)
* fix(daemon): bump idle watchdog default 5m → 30m (MUL-2300)

The previous 5 min default killed legitimate long assistant outputs (e.g.
RFC-length writeups) where the model streams a single message for many
minutes without any daemon-visible activity. 30 min keeps the safety net
for truly stuck runs (dockerd hang) while leaving headroom for long
writes.

runIdleWatchdog tick interval is window/2, with a 30 s floor that only
applies when interval < 30 s — at window=30 min the natural tick is 15
min, so no sync needed.

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

* docs(daemon): drop stale 5-minute mention from idle watchdog comment

Refers to DefaultAgentIdleWatchdog so the comment stays in sync if the
default shifts again. Follow-up to Emacs review on PR #2728.

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

---------

Co-authored-by: multica-agent <github@multica.ai>
2026-05-16 17:20:10 +02:00
Jiayuan Zhang
e00b94b0f9 fix(realtime): invalidate per-issue token usage on task events (MUL-2298) (#2723)
The issue-detail right-rail Token usage card is fed by useQuery(issueUsageOptions(id)),
but the realtime task: handler only invalidated ["issues","tasks"]. As a result the
card only refreshed on remount, so consecutive runs on the same issue left the
numbers stuck until the user navigated away and back. Mirror the existing tasks
invalidation with a prefix invalidation of ["issues","usage"] so any task
lifecycle event refreshes the aggregated usage numbers.

Co-authored-by: multica-agent <github@multica.ai>
2026-05-16 12:45:27 +02:00
Jiayuan Zhang
4c7a990a25 fix(autopilot): attribute autopilot-created issue to assignee agent (MUL-2293) (#2719)
Before: dispatchCreateIssue copied autopilot.created_by_type/id onto the
new issue's creator_type/creator_id, and the same fields were used as the
ActorType/ActorID of the issue:created event. Result: any issue spawned by
an autopilot was reported as created by the human who first configured
the autopilot, not by the agent that actually owns the work. Downstream
subscriber/activity/notification listeners inherited the same wrong actor.

After: creator and actor are both the autopilot's assignee agent
(creator_type=agent, creator_id=ap.assignee_id). The human owner is still
recoverable via origin_type=autopilot + origin_id.

Audited the other ap.created_by_* usages: analytics attribution
(autopilotActorID, task.go user-id), and the private-agent visibility
gate in shouldSkipDispatch — all correctly read the autopilot's owner,
not the executor, so they stay as-is.

Co-authored-by: multica-agent <github@multica.ai>
2026-05-16 09:32:15 +02:00
Jiayuan Zhang
380c6b5122 feat(usage): add Time and Tasks to daily-trend toggle (MUL-2283) (#2709)
Extends the workspace /usage page Daily tokens chart toggle from
Tokens | Cost to Tokens | Cost | Time | Tasks, so users see daily
run-time and task-count trends alongside spend without leaving the page.

- New SQL `ListDashboardRunTimeDaily`: per-date totals from
  agent_task_queue (terminal tasks only), scoped to workspace and
  optionally project. Same time anchor as ListDashboardAgentRunTime
  so day boundaries line up.
- New handler GET /api/dashboard/runtime/daily + TanStack Query option.
- New DailyTimeChart (single-series, smart h/m/s unit) and
  DailyTasksChart (completed + failed stacked).
- Empty-state is per-metric so a workspace with tokens but no terminal
  runs (or vice-versa) doesn't get a false "no data".
- i18n: en + zh-Hans daily.metric_time / metric_tasks + titles.

Co-authored-by: multica-agent <github@multica.ai>
2026-05-15 18:51:02 +02:00
Naiyuan Qing
0079a73430 fix(views): narrow agent/squad create dialogs to max-w-2xl (#2706)
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>
2026-05-15 23:09:15 +08:00
Jiayuan Zhang
3698fd85d5 feat(views): show Total in daily token/cost chart tooltips (MUL-2282) (#2704)
* feat(views): show Total in daily token/cost chart tooltips (MUL-2282)

Add a Total row at the bottom of the daily-tokens-chart and daily-cost-chart
tooltips so users can see the precise stack sum on hover, in addition to the
per-stack breakdown.

Implemented by extending shared ChartTooltipContent with an optional `footer`
prop (ReactNode | (payload) => ReactNode) that renders below the items with a
top divider; backwards-compatible (no behavior change when footer is omitted).

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

* fix(views): i18n Total label in chart tooltips (MUL-2282)

Lint rule i18next/no-literal-string flagged the hardcoded "Total" string
in daily-cost-chart and daily-tokens-chart tooltips. Move it to
runtimes.charts.tooltip_total and read via useT.

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

---------

Co-authored-by: multica-agent <github@multica.ai>
2026-05-15 15:50:20 +02:00
Kagura
d43961ed7a MUL-2284 fix(deps): bump Next.js to patch CVE-2026-44578 (#2690)
* fix(deps): bump Next.js to patch CVE-2026-44578

Bump minimum Next.js versions to the first patched releases:
- apps/docs: ^15.3.3 → ^15.5.16
- apps/web: ^16.2.3 → ^16.2.5

Advisory: https://github.com/advisories/GHSA-c4j6-fc7j-m34r

Closes #2676

* chore: regenerate lockfile for Next.js bump
2026-05-15 19:59:25 +08:00
Bohan Jiang
bfe9bf3eea feat(daemon): force-stop hung agent runs via idle watchdog (MUL-2281) (#2691)
* feat(daemon): force-stop hung agent runs via idle watchdog (MUL-2281)

A backend whose subprocess hangs on a stuck child process (e.g. claude
blocked on `docker ps` against a frozen dockerd) keeps the daemon's run
record at status="running" until the full DefaultAgentTimeout (2 h)
expires, because cmd.Wait() never returns and Session.Result is never
written. MUL-2225 spent 17+ minutes in this state in the wild.

Add a per-task idle watchdog around executeAndDrain:

- Wrap the caller's ctx so a single cancel propagates to the agent
  subprocess (via the ctx passed to backend.Execute) AND the drain loop.
- Stamp lastActivityAt every time the drain loop receives a message.
- Tick at window/2; when idle_for >= window AND session.Messages buffer
  is empty, set a fired flag and call cancel.
- Tag the resulting Result.Status as "idle_watchdog" so runTask routes
  it through a dedicated failure_reason instead of "agent_error".

Default window is 5 min, configurable via MULTICA_AGENT_IDLE_WATCHDOG;
set to 0 to disable. Tests cover the activity-then-silence case, the
zero-message case, the disabled case, and the happy path.

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

* fix(daemon): skip idle watchdog while a tool call is in flight

A legitimate long-running tool call (npm install, docker build, test
suite) can sit silent between tool_use and tool_result for many minutes.
Without this gate, the watchdog would yank the agent mid-build.

Track unmatched tool_use messages in an atomic counter; only let the
watchdog fire when the counter is zero. tool_result clamps non-negative
so a stray result with no matching use can't re-arm the watchdog one
call too early.

Adds two regression tests:
  - DoesNotFireDuringInFlightToolCall: tool_use -> silence past
    window -> tool_result -> completed (must NOT fire)
  - FiresAfterToolResultIfBackendStaysSilent: tool_use -> tool_result
    -> silence past window (MUST fire — backend really is stuck)

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

---------

Co-authored-by: multica-agent <github@multica.ai>
2026-05-15 19:48:39 +08:00
Jiayuan Zhang
8e88156356 Add assignee grouping for issue boards (#2693) 2026-05-15 18:44:08 +08:00
iYuan
d8635ad580 fix(issues): prevent duplicate active issue creation (MUL-2225) (#2602)
* fix: prevent duplicate active issue creation

* fix(issues): address duplicate guard review

* fix(autopilot): skip duplicate issue admissions

* fix(issueguard): tighten duplicate lookup edge cases

* test(issues): cover duplicate guard autopilot skips

* feat(autopilots): group skipped runs in history
2026-05-15 18:27:56 +08:00
Bohan Jiang
fcd13aece9 feat(daemon): auto-update CLI when idle (MUL-2100) (#2679)
* feat(daemon): auto-update CLI when idle (MUL-2100)

Add a periodic poller that checks GitHub for a newer multica release
every hour and self-updates when the daemon is idle, reusing the same
brew-or-download upgrade path the Runtimes-page "Update" button already
runs.

- Refactor handleUpdate to call a shared runUpdate(target) helper so
  both server-triggered and auto-triggered upgrades go through the same
  brew detection + atomic replace + restart.
- New autoUpdateLoop gates each tick on: opt-out flag, Desktop launch
  source, dev-build version, an in-flight update, and active tasks. The
  idle gate guarantees we never interrupt a running agent — busy ticks
  silently retry at the next interval.
- Config: MULTICA_DAEMON_AUTO_UPDATE=false to disable (also via
  --no-auto-update), MULTICA_DAEMON_AUTO_UPDATE_INTERVAL to retune the
  poll period.
- IsNewerVersion / IsReleaseVersion helpers in the cli package, with
  tests covering patch/minor/major bumps, dev-describe strings, and
  malformed input.
- Daemon-side tests cover every skip path (updating, active tasks,
  fetch failure, no-newer) plus the success path that fires
  triggerRestart while keeping the updating flag held to the end.

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

* fix(daemon): close idle race + verify checksum in auto-update (MUL-2100)

Two issues raised in PR #2679 review:

1. The first idle check in tryAutoUpdate only ran before the release-metadata
   fetch, so a poller that won the claim race during the fetch could end up
   handing handleTask a task that triggerRestart was about to cancel via root-
   ctx cancellation. Add a strict claim barrier: runRuntimePoller now
   tryEnterClaim()s before ClaimTask, and tryAutoUpdate flips pauseClaims
   under claimMu only after observing claimsInFlight + activeTasks == 0.
   Pollers that were already mid-claim hold claimsInFlight > 0, so the barrier
   refuses to engage and the update defers to the next tick.

2. The direct-download path replaced the running binary with whatever bytes
   GitHub returned, without checking checksums.txt. Pull the manifest first,
   buffer the archive, and reject on SHA-256 mismatch before extraction. The
   GoReleaser config already publishes checksums.txt; we just consume it.

Also tighten parseReleaseVersion so it stops accepting dev-describe shapes
like "v0.1.13-5-gabcdef0" through the patch trim, matching its docstring.
The auto-update loop already guards on IsReleaseVersion, but the lenient
parser was a footgun and the existing test name even said "not newer" while
asserting the opposite.

Tests:
- TestTryAutoUpdate_DefersWhenClaimInFlightAtBarrier (new race coverage)
- TestTryAutoUpdate_HoldsBarrierAcrossRestart / ReleasesBarrierOnUpgradeFailure
- TestTryEnterClaim_RespectsBarrier
- TestFindChecksumManifestAsset / TestParseChecksumManifest / TestVerifyAssetSHA256
- TestIsNewerVersion: dev-describe cases now expect false (matches docstring)

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

* chore(daemon): default auto-update poll interval to 6h (MUL-2100)

1h was overly chatty for a release that lands at most a few times a week.
Operators who want a different cadence can still set
MULTICA_DAEMON_AUTO_UPDATE_INTERVAL or --auto-update-interval.

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

---------

Co-authored-by: multica-agent <github@multica.ai>
2026-05-15 18:10:22 +08:00
Bohan Jiang
57be69517f feat(views): progressive disclosure for issue sidebar properties (MUL-2275) (#2675)
* feat(views): progressive disclosure for issue sidebar properties (MUL-2275)

Split sidebar Properties into a core group that always renders
(status / priority / assignee / labels) and an optional group
(due_date / project / parent) that only appears when the issue has
the value set or the user explicitly added it via a new
"+ Add property" picker. A field cleared in-session stays visible
to avoid row flicker; navigating to a different issue reseeds
visibility from that issue's set fields. The standalone "Parent
issue" card is folded into Properties as one of those optional
rows. Adds `defaultOpen` to DueDatePicker / ProjectPicker so a
newly-added row drops the user straight into edit state.

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

* refactor(views): swap sidebar optional set to due_date + labels

Per design feedback: status / priority / assignee / project / parent
are all required and should always render in the sidebar; only
due_date and labels are progressive-disclosure optionals. Move project
and parent rows out of the optional block (drop their +Add property
menu entries and the parent special-case in addOptionalProp). Move
labels into the optional block, gated on the issue's actual attached-
label count (queried via issueLabelsOptions), with defaultOpen wired
through LabelPicker so picking "Labels" from +Add property drops the
user straight into the picker. Tests updated for the new split.

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

* refactor(views): restore standalone parent card, move priority to optional

Parent goes back to its own collapsible section, rendered only when the
issue actually has a parent — matching the pre-MUL-2275 behavior. It is
no longer interleaved with Properties rows.

Priority joins the progressive-disclosure set (priority / due_date /
labels). New issues default to priority "none", so the row is hidden
until set or added via "+ Add property", and PriorityPicker gains
defaultOpen so the field drops straight into edit state when chosen
from the add-property menu.

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

* refactor(issue-detail): tighten Add-property popover visual rhythm

Picked up a small visual inconsistency while reviewing the PR's UI:
the "Add property" dropdown floated above the inspector at a noticeably
larger type scale than the property rows, and each item was bare text
while the rows it sat above all rendered with an icon + value pair.

Tweaks:
- Items: `text-sm py-1.5` → `text-xs py-1`, matching the inspector
  row typography and trimming row-to-row gap from 12px to 8px.
- Each option leads with the icon the resulting picker uses
  (`PriorityIcon` bars / `CalendarDays` / `Tag`) so the dropdown reads
  as a preview of what will appear in the new PropRow.
- Focus indicator: replace the default thick focus ring with
  `focus-visible:bg-accent + outline-none`, matching the hover state
  language — keyboard focus and mouse hover now look the same.
- Popover width: `w-48` → `w-44` since the labels are short and the
  visual is now denser; still leaves room for translated strings.

* fix(issue-detail): dismiss Add-property popover when an option is picked

Base UI's `Popover` doesn't auto-dismiss when a child is clicked (it's
not a Menu primitive), so picking an option left the "+ Add property"
popover sitting behind the picker that auto-opens for the newly added
row — two popovers visibly stacked.

Make the Popover controlled with a local `addPropPopoverOpen` state and
close it inside `addOptionalProp` right after enqueuing the row's
auto-open. The picker still pops on mount via `defaultOpen={autoOpenProp
=== key}`, so the user flow is unchanged from their perspective:

  Click "+ Add property" → menu opens
  Click an option         → menu closes AND target picker opens

(Was the same flow on paper before; just had the orphan popover behind
the picker.)

---------

Co-authored-by: multica-agent <github@multica.ai>
2026-05-15 18:04:33 +08:00
Naiyuan Qing
f64d182fd1 fix(views): narrow agent/squad create dialogs from max-w-5xl to max-w-4xl (#2688)
Both create dialogs were too wide at 5xl (1024px). Align with the
codebase convention for full create dialogs (create-project,
create-issue expanded) which use max-w-4xl (896px). Keeps both
modals consistent.

Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>
2026-05-15 17:59:45 +08:00
Multica Eve
2d21f5258d docs: add May 15 changelog entry (#2682)
Co-authored-by: Eve <eve@multica-ai.local>
Co-authored-by: multica-agent <github@multica.ai>
2026-05-15 17:50:11 +08:00
Naiyuan Qing
5ad1641b72 Revert "Squad archive dialog + role editor + transactional DeleteSquad (#2680)" (#2687)
This reverts commit 2980ead4c7.
2026-05-15 17:44:59 +08:00
Naiyuan Qing
1cb926d52d feat(views): refine navigation progress bar with brand color and glow (MUL-2269) (#2681)
* feat(views): refine navigation progress bar with brand color and glow (MUL-2269)

The previous 1px bg-primary bar read as near-black on light theme and
snapped on/off in a single frame, which felt abrupt despite being a small
visual element. Switch to a 2px brand-colored sweep with right-edge glow,
slower 1.4s cubic-bezier easing, and a 200ms fade-out so completion
doesn't pop.

- Container: h-px → h-0.5 (2px); always mounted with opacity-driven fade
- Bar: bg-primary → bg-brand + two-layer box-shadow glow via color-mix
- Keyframe: 1.1s ease-in-out → 1.4s cubic-bezier(0.4, 0, 0.2, 1)

Zero new design tokens (reuses existing --brand) and zero tailwind config
changes. Desktop unaffected — same component, same prefetch=no-op path.

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

* fix(views): unmount nav progress sweep when hidden (MUL-2269)

Hiding the bar with opacity-0 left the inner element's `infinite` keyframe
animation running on every dashboard page, defeating the perceived-perf goal.
Mount the sweep only while navigating, plus the 200ms fade tail (unmount on
opacity transitionend), so nothing animates while hidden.

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-15 17:39:28 +08:00
Naiyuan Qing
2980ead4c7 Squad archive dialog + role editor + transactional DeleteSquad (#2680)
* docs(squad): address plan-review feedback for archive + role plan

Resolve the 4 items the reviewer raised on MUL-2265:

1. TS schema: declare `active_issue_count` as optional (`number | null | undefined`)
   so list/create/update Squad responses don't lie about their shape; only
   `getSquad` parses through SquadSchema.
2. Archive semantics: restrict TransferSquadAssignees to active issues
   (status NOT IN done, cancelled) so dialog count and SQL operate on one set
   and terminal-state issues keep their historical assignee.
3. Index assumption: corrected — `idx_issue_assignee (assignee_type,
   assignee_id)` exists and is sufficient at realistic squad cardinality;
   no new index needed.
4. Fixed `*int64` test comparison and added `.loose()` to SquadSchema per
   the local schemas.ts convention.

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

* docs(squad): plan v3 — revert to count-all/transfer-all on archive

Reviewer round 2 surfaced two structural problems with plan v2's
active-only carve-out:

1. useActorName resolves squad names via ListSquads, which filters
   archived_at IS NULL. A closed issue with an archived-squad assignee
   would render as "Unknown Squad".

2. The status-only update path in UpdateIssue skips validateAssigneePair,
   so a done/cancelled issue with an archived-squad assignee could be
   reopened to in_progress, violating the "no active issue on an archived
   squad" invariant enforced elsewhere.

Both problems disappear by reverting to count-all + transfer-all: after
ArchiveSquad runs, no issue points at the archived squad, so neither
case can occur. The product trade-off is that closed historical issues
now show the leader agent instead of the archived squad in their
"Assigned to" badge — consistent with existing agent-level reassignment
behavior elsewhere in the product.

Field rename: active_issue_count -> issue_count.
TransferSquadAssignees SQL is unchanged (already transfers all).

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

* docs(squad): add Task 2b — wrap DeleteSquad transfer + archive in one tx

Reviewer round-3 flagged that the v3 invariant ("after archive no
issue points to the squad") was asserted on the happy path only.
DeleteSquad's current best-effort impl breaks it two ways:
- transfer failure → slog.Warn but archive proceeds (Unknown Squad,
  reopen-into-archived-squad bugs reappear)
- archive failure after a committed transfer → 500 with squad still
  active but emptied

Task 2b rewrites DeleteSquad to run TransferSquadAssignees +
ArchiveSquad inside one pgx tx, mirroring the project.go:266-314
pattern. Publish moves below Commit. Adds two regression tests that
lock both partial-write failure modes.

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

* feat(squad): replace native confirm() with AlertDialog and rewrite role editor as combobox

Backend:
- Add CountIssuesForSquad sqlc query (counts every issue assigned to a squad,
  no status filter — matches the existing transfer-all archive semantics).
- Extend SquadResponse with optional `issue_count` (`*int64` + omitempty,
  populated only by GetSquad to avoid an N+1 in the list endpoint).
- Wrap DeleteSquad's transfer + archive in a single pgx transaction so the
  v3 invariant ("after archive, no issue points to the squad") is durable
  rather than best-effort. Promote slog.Warn to slog.Error and check the
  parseUUIDOrBadRequest ok flag (silent zero-UUID was a #1661-class latent
  bug). Publish only after Commit so realtime never sees rolled-back state.
- Tests cover happy path (count, transfer-all including terminal statuses)
  and both rollback directions (transfer fail / archive fail) via a
  fault-injecting tx wrapper.

Frontend:
- Extend Squad TS type with `issue_count?: number | null` (optional —
  list/create/update legitimately omit it). Add SquadSchema with `.loose()`
  and wrap getSquad with parseWithFallback so older servers and count-error
  responses degrade to the dialog's "no count" copy variant.
- Replace `window.confirm()` with shadcn `ArchiveSquadConfirmDialog`
  (destructive variant, leader name + count + closed-issue caveat in the
  copy, Loader2 while pending). i18n keys added under squads.archive_dialog.
- Rewrite RoleEditor as a Popover + Command combobox: Pencil affordance is
  always visible, suggestions aggregate other members' roles, commit only
  on Enter or selecting a suggestion (blur discards), per-member savingId
  drives Loader2 so the spinner only renders on the row being saved.

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

* fix(squad): discard RoleEditor draft on close and no-op blank Enter

Two reviewer findings on e0d754bf:

1. Closing the Popover (outside click, Esc, trigger re-click) left `query`
   in state, so reopening + Enter would commit the stale draft. Clear
   `query` on every non-saving close path.
2. With an existing role, opening the editor and pressing Enter on an
   empty input committed "" — `commit` only no-op'd when trimmed matched
   value. Treat blank Enter as a no-op; clearing a role would need an
   explicit clear action that doesn't exist yet.

Add two regression tests:
- close (via outside click) → reopen surfaces a clean input; Enter does
  not commit the stale draft
- blank Enter on an existing role does not call onSave

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

* fix(squad): add explicit Clear button to RoleEditor

Role is optional, but the previous fix turned blank Enter into a no-op
without exposing any other way to clear an existing role — that broke a
valid terminal state. Keep blank Enter as no-op; add a "Clear role"
button at the bottom of the popover that only renders when value is
non-empty and routes through onSave("").

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

---------

Co-authored-by: multica-agent <github@multica.ai>
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-15 17:29:37 +08:00
Naiyuan Qing
e8d6c912c4 feat(views): prefetch + transition + skeleton for snappy web navigation (MUL-2269) (#2677)
Internal navigation on web feels laggy because clicking a sidebar link blocks
0.2–0.6s with zero visual feedback — no prefetch, no Suspense fallback in the
dashboard segment, and no React transition to mark the route commit as pending.

This change adds the three pieces App Router needs to make the click→commit
window feel instant, scoped to the (dashboard) segment so auth/landing keep
their existing chrome:

- NavigationAdapter gains an optional prefetch(path). The web adapter wires
  it to router.prefetch; desktop leaves it undefined (react-router has no
  equivalent and doesn't need one). AppLink prefetches on hover/focus and
  preserves caller-supplied onMouseEnter/onFocus/onClick.
- NavigationProvider wraps push/replace in useTransition and exposes the
  pending flag via useIsNavigating(). Every useNavigation().push caller —
  sidebar AppLink, command palette, post-create modal jumps — picks this up
  automatically.
- New apps/web/app/[workspaceSlug]/(dashboard)/loading.tsx renders a minimal
  skeleton during cold transitions inside the dashboard segment only.
- DashboardLayout renders a 1px top progress bar driven by useIsNavigating.

packages/views remains free of next/* imports; desktop is unaffected by
construction (no prefetch, transition flips quickly, no loading.tsx).

Co-authored-by: multica-agent <github@multica.ai>
2026-05-15 17:01:42 +08:00
LinYushen
319b23eb39 Revert "feat(task): add claim lease mechanism (Phase 2, MUL-2246) (#2660)" (#2674)
This reverts commit 3137feecdf.
2026-05-15 16:07:23 +08:00
LinYushen
b7a58c06ac Revert "feat(task): wire claim lease into TaskService and sweeper (MUL-2246) …" (#2673)
This reverts commit bb32be0e50.
2026-05-15 16:06:58 +08:00
LinYushen
bb32be0e50 feat(task): wire claim lease into TaskService and sweeper (MUL-2246) (#2662)
* feat(task): wire claim lease queries into TaskService and sweeper (MUL-2246)

- ClaimTask now uses ClaimAgentTaskWithLease (generates claim_token + lease)
- StartTask accepts optional claim_token for token-verified start
- AgentTaskResponse includes claim_token for daemon to use
- Daemon client sends claim_token in StartTask body
- Sweeper calls RequeueExpiredClaimLeases each tick
- Legacy daemons without claim_token still work (graceful fallback)

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

* fix(task): address PR #2662 review blockers (MUL-2246)

1. ClaimAgentTaskForRuntime: push runtime_id into atomic SQL WHERE clause
   so runtime A cannot claim tasks queued for runtime B under the same agent.

2. Legacy StartAgentTask: add claim_token IS NULL guard so leased rows
   cannot be started without token verification. Handler rejects malformed
   tokens with 400 instead of silently degrading to legacy path.

3. StartAgentTaskWithClaimToken: validate claim_expires_at >= now(),
   preserve claim_token until terminal state (only clear claim_expires_at),
   use CTE + UNION ALL for idempotent retry when daemon resends after a
   lost StartTask response. Return 409 Conflict on token mismatch/expiry.

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

* fix(daemon): StartTask 409 handling, transport retry, claim_token on FailTask (MUL-2246)

- StartTask 409 (claim superseded): release slot, don't call FailTask
- StartTask transport timeout/5xx: retry once with same token, then
  check task status before failing
- FailTask now sends claim_token; server-side FailAgentTask SQL adds
  AND (claim_token IS NULL OR claim_token = @claim_token) guard so
  stale daemons cannot fail tasks that have been re-claimed

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

* fix(task): close FailTask token bypass and RequeueExpiredClaimLeases liveness gap (MUL-2246)

Blocker 1 - FailTask token validation:
- SQL: change (param IS NULL OR claim_token = param) to
  (param IS NULL AND claim_token IS NULL) OR claim_token = param
  so tokenless requests can only fail legacy (tokenless) rows.
- task.go: malformed claim_token now returns ErrInvalidClaimToken (400)
  instead of being silently dropped to NULL.
- Handler: maps ErrInvalidClaimToken→400, ErrClaimTokenInvalid→409.
- Service: when UPDATE returns no rows but task is still active,
  return ErrClaimTokenInvalid (token mismatch) instead of silent success.

Blocker 2 - RequeueExpiredClaimLeases runtime liveness:
- SQL: JOIN agent_runtime, only requeue tasks where runtime is 'online'.
  Dead/offline runtime tasks stay dispatched for FailTasksForOfflineRuntimes.
- FOR UPDATE → FOR UPDATE OF atq (required with JOIN).

Regression tests:
- task_claim_token_test.go: malformed, tokenless-on-tokened, wrong-token
- requeue_lease_test.go: SQL must JOIN agent_runtime with online filter

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

* fix(task): move expired lease requeue to ClaimTaskForRuntime preflight, add heartbeat freshness backstop (MUL-2246)

- Add RequeueExpiredClaimLeasesForRuntime: per-runtime preflight self-requeue
  in ClaimTaskForRuntime. Runtime proves liveness by actively claiming, so no
  heartbeat check needed.
- Update global RequeueExpiredClaimLeases to require ar.last_seen_at freshness
  (stale_threshold_secs param). Prevents requeuing to a dead runtime in the
  90s gap between lease expiry (60s) and offline detection (150s).
- Add regression tests verifying the heartbeat freshness check and that the
  preflight query does not join agent_runtime.

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

* fix(task): use LivenessStore for global requeue, move preflight before empty-cache (MUL-2246)

Blocker 1: Global RequeueExpiredClaimLeases now uses LivenessStore.IsAliveBatch
to verify runtimes are truly alive before requeuing expired leases. When
LivenessStore is unavailable (no Redis), global requeue is skipped entirely —
the preflight self-requeue in ClaimTaskForRuntime handles live runtimes. This
closes the 60-150s gap where a dead runtime still appears online in DB.

Blocker 2: Moved RequeueExpiredClaimLeasesForRuntime BEFORE EmptyClaim.IsEmpty
fast-path in ClaimTaskForRuntime. Expired leases are now requeued (which bumps
the empty cache via notifyTaskAvailable) before the empty check can
short-circuit the claim path.

Also adds ListRuntimesWithExpiredClaimLeases SQL query and LivenessChecker
interface on TaskService.

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

* fix(task): wire EmptyClaimCache into backend taskSvc for backstop requeue (MUL-2246)

The backend taskSvc used by the sweeper only had Liveness wired but not
EmptyClaim. When global backstop requeue called notifyTaskAvailable,
s.EmptyClaim.Bump() was a nil no-op — the handler's empty-cache was never
invalidated, so the daemon's next claim hit a stale empty verdict.

Fix: wire the same Redis-backed EmptyClaimCache into the backend taskSvc
in main.go (same Redis keys as router.go:139 handler instance).

Add regression test verifying backstop requeue invalidates the handler's
empty-cache.

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

* fix(task): global backstop must not requeue — alive runtimes use preflight, dead stay dispatched (MUL-2246)

- RequeueExpiredClaimLeases is now a no-op (returns 0 always)
- Alive runtimes self-requeue via ClaimTaskForRuntime preflight
- Dead runtimes stay dispatched for FailTasksForOfflineRuntimes
- Rewriting to queued on dead runtime creates 2h blackhole (offline
  sweeper only handles dispatched/running)
- Test actually calls RequeueExpiredClaimLeases and asserts 0 in all cases

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

* fix(daemon): remove duplicate usage reporting block after merge conflict (MUL-2246)

The merge resolution introduced a second ReportTaskUsage call after the
status check, duplicating the usage-before-early-return block that already
runs right after runner.run. Remove the duplicate and add a regression test
asserting /usage is called exactly once on the normal completion path.

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

---------

Co-authored-by: multica-agent <github@multica.ai>
2026-05-15 15:15:31 +08:00
LinYushen
3137feecdf feat(task): add claim lease mechanism (Phase 2, MUL-2246) (#2660)
Add claim_token + claim_expires_at columns to agent_task_queue and three
new SQL queries for the claim lease protocol:

- ClaimAgentTaskWithLease: generates a UUID token and sets a lease expiry
  when claiming a task, so the daemon must prove it received the response
- StartAgentTaskWithClaimToken: validates the token on StartTask, preventing
  stale daemons from starting requeued tasks
- RequeueExpiredClaimLeases: moves dispatched tasks with expired leases back
  to queued for re-claim

This closes the reliability gap where a claim response lost in transit
leaves a task stuck in dispatched until the 60s dispatch timeout fires.

Co-authored-by: multica-agent <github@multica.ai>
2026-05-15 15:14:05 +08:00
Bohan Jiang
461be83970 feat(views): collapse activity blocks in issue timeline (#2585)
Each consecutive run of activities renders as a single "N activities"
summary by default. Clicking expands the block in place. Comments are
unaffected; the most recent activity block stays expanded so users see
"what just happened" without a click.

Refs MUL-2188

Co-authored-by: multica-agent <github@multica.ai>
Co-authored-by: J <j@multica.ai>
2026-05-15 14:39:59 +08:00
Bohan Jiang
a23856bae3 MUL-1624 docs(email): clarify 888888 is opt-in; document SMTP option (#2666)
* docs(email): clarify 888888 is opt-in via MULTICA_DEV_VERIFICATION_CODE; document SMTP option in self-host docs

The startup log line, .env.example, and SELF_HOSTING_ADVANCED.md still
implied that the dev master code 888888 is auto-active whenever
APP_ENV != "production". That has not been true since the master code
was gated behind MULTICA_DEV_VERIFICATION_CODE — the fixed code is
disabled by default and must be opted in explicitly.

Also extend the docs site with the SMTP relay backend added in #1877:
auth-setup, environment-variables, and self-host-quickstart now cover
both Resend and SMTP options in EN and ZH.

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

* docs(email): treat SMTP as an email backend in self-host docs and startup warning

Address review feedback on #2666:

- server: startup warning now fires only when both RESEND_API_KEY and SMTP_HOST
  are empty, since either one is a valid email backend. Otherwise the log
  mis-tells SMTP-only operators that verification codes go to stdout.
- self-host-quickstart (EN/ZH): tell readers to fetch the verification code
  from whichever backend they configured (Resend or SMTP); fall back to
  stdout only when neither is configured.
- auth-setup (EN/ZH): \"without Resend\" → \"without any email backend
  configured\" so the wording stays correct now that SMTP is a first-class
  option.

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

---------

Co-authored-by: multica-agent <github@multica.ai>
2026-05-15 14:18:46 +08:00
LinYushen
75dc70686b fix(realtime): include actor_type in WS broadcast messages (#2668)
* fix(realtime): include actor_type in WebSocket broadcast messages

The WS broadcast message format was {type, payload, actor_id} but missing
actor_type. This meant the web UI could not distinguish agent from human
operations in real-time events at the top level.

While payload data for comments (author_type) and activities (entry.actor_type)
already included the type, the top-level message did not — causing the web UI
to display agent CLI operations as human operations when relying on the
broadcast actor identity.

Changes:
- server/cmd/server/listeners.go: add actor_type to all broadcast messages
- packages/core/types/events.ts: add actor_type to WSMessage interface
- packages/core/api/ws-client.ts: pass actor_type to event handlers
- packages/core/realtime/hooks.ts: update EventHandler type signature
- packages/core/realtime/provider.tsx: update EventHandler type signature

Fixes MUL-2260

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

* test: add frame-shape unit test asserting actor_type in WS frames

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

---------

Co-authored-by: multica-agent <github@multica.ai>
2026-05-15 14:10:24 +08:00
Bohan Jiang
9b6b8f5877 fix(ci): refresh pnpm-lock.yaml + name test wrapper after #2665 (#2667)
* fix(deps): refresh pnpm-lock.yaml after #2665 added test deps to core

#2665 (MUL-2256, fix(realtime)) added `@testing-library/react` and
`react-dom` to `packages/core/package.json` devDependencies, plus moved
`react` from dependencies → devDependencies, but didn't commit the
regenerated lockfile. CI runs `pnpm install` with --frozen-lockfile
(implicit in CI envs), which bails immediately:

  ERR_PNPM_OUTDATED_LOCKFILE: pnpm-lock.yaml is not up to date with
  packages/core/package.json
  * 2 dependencies were added: @testing-library/react@catalog:,
    react-dom@catalog:

Frontend CI has been red on main since 7c8cf929. Backend is fine
because Go doesn't share the lockfile.

Lockfile delta is small (+9 / -3): the only changes are the three
specifier blocks for the deps already declared in package.json. No
version upgrades, no transitive churn — `pnpm install` produced an
identical resolved tree minus the missing entries.

* fix(core): name the test wrapper component to satisfy react/display-name

Same source of CI red as the lockfile bump in this PR — #2665 also
introduced packages/core/realtime/use-realtime-sync-ws-instance.test.tsx
where `createWrapper` returned an anonymous arrow component. The
`react/display-name` lint rule (enforced as error in core) flagged it,
and once `pnpm install` was unblocked the next CI step fell through to
this lint failure.

Convert the inline arrow into a named `function Wrapper(...)` —
identical render output, satisfies the rule.

Verified: `pnpm --filter @multica/core lint` → 0 errors (was 1).
The 4 tests in this file still pass.
2026-05-15 13:51:35 +08:00
LinYushen
7c8cf929d1 MUL-2256 fix(realtime): invalidate workspace queries on WSClient instance change (#2665)
* fix(realtime): invalidate workspace queries on WSClient instance change

When switching workspaces, the old WSClient is torn down and a new one
is created. Events emitted during the transition are lost because
onReconnect only fires for reconnections within the same instance.

Add an effect that tracks the WSClient instance via useRef and, on
detecting a non-initial new instance, invalidates all workspace-scoped
queries (same set as onReconnect). The first assignment is skipped to
avoid redundant refetches on initial mount.

Closes multica-ai/multica#2562

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

* refactor(realtime): extract shared invalidation helper + add ws instance test

- Extract invalidateWorkspaceScopedQueries() to deduplicate the
  invalidation key list shared by onReconnect and ws-instance-change effects
- Add hook test covering: first ws skip, null gap no-op, new instance
  invalidates exactly once, same instance no re-invalidation

Addresses review nits from PR #2665.

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

---------

Co-authored-by: multica-agent <github@multica.ai>
2026-05-15 13:37:48 +08:00
apollion69
35e9a7f0f6 feat(email): add SMTP relay as alternative to Resend for self-hosted deployments (#1877)
* feat(email): add SMTP relay as alternative to Resend

Self-hosted deployments often run behind a corporate firewall with an
existing SMTP relay (Exchange, Postfix, sendmail) and no access to
external SaaS APIs. Resend requires a public domain, an API key, and
outbound HTTPS to api.resend.com — all unavailable in air-gapped or
private-network setups.

This adds a second email delivery path using Go's stdlib net/smtp,
activated when SMTP_HOST is set. Priority order:
  1. SMTP relay  (SMTP_HOST set)
  2. Resend API  (RESEND_API_KEY set)
  3. DEV stdout  (neither set)

New env vars (all optional, no breaking change):
  SMTP_HOST            — SMTP server hostname
  SMTP_PORT            — port, default 25
  SMTP_USERNAME        — for authenticated SMTP; empty = unauthenticated relay
  SMTP_PASSWORD        — used only when SMTP_USERNAME is set
  SMTP_TLS_INSECURE    — set to "true" to skip TLS cert verification
                         (for private CA / self-signed certs)

The implementation:
- Dials TCP, creates smtp.Client manually (avoids smtp.SendMail which
  does not expose TLS config)
- Tries STARTTLS if advertised; uses InsecureSkipVerify only when
  SMTP_TLS_INSECURE=true (opt-in, nolint:gosec annotated)
- Applies PlainAuth only when SMTP_USERNAME is non-empty
- Wraps all errors with context for easier debugging
- Reuses existing HTML templates from buildInvitationParams for
  invitation emails (no template duplication)

Also updates .env.example and docker-compose.selfhost.yml with the
new variables and inline documentation.

* fix(email): add dial timeout, session deadline, RFC headers for SMTP path

Address review blockers from multica-eve and Bohan-J (PR #1877):

- net.Dial → net.DialTimeout(10s) + conn.SetDeadline(30s) so a blackholed
  SMTP relay cannot hang SendVerificationCode (called synchronously from the
  auth handler) or leak goroutines in the invitation path.
- Add Date, Message-ID, and proper Content-Transfer-Encoding headers.
  Date is required by RFC 5322; many strict relays reject messages without it.
  Message-ID aids deliverability and threading.
- MIME-encode Subject via mime.QEncoding so non-ASCII workspace/inviter names
  (CJK, emoji) survive without corruption across any RFC 2047-conformant relay.
- Probe 8BITMIME after (possible) STARTTLS: use Content-Transfer-Encoding 8bit
  when the relay advertises 8BITMIME, quoted-printable otherwise — safe for
  all relay configurations without forcing base64 overhead.
- Update SELF_HOSTING_ADVANCED.md to document Option B (SMTP relay) alongside
  the existing Resend section, including all five env vars and a note that
  port 465/SMTPS is not yet supported.

* fix(email): correct has8Bit assignment order (bool is first return of Extension)
2026-05-15 13:35:01 +08:00
joyanup
4c1fd60215 fix(daemon): report task usage before cancel check (#1180)
handleTask had two early-return paths that ran before ReportTaskUsage:
the cancelledByPoll select and the post-run GetTaskStatus check. Both
silently discarded any usage accumulated by the agent — and both
claude.go and codex.go populate Result.Usage even when runCtx is
cancelled mid-run, so cancelled tasks consistently under-reported tokens.

Hoist ReportTaskUsage to run immediately after the runner returns,
before any early-return path. Add a taskRunner interface seam and a
cancelPollInterval field so tests can inject a fake runner and trigger
the poll-cancellation path on a 10ms ticker without spawning real agents.

Two regression tests cover both leak windows:
- TestHandleTask_ReportsUsageBeforeCancel: post-run /status returns
  "cancelled"; usage must be reported before the status check.
- TestHandleTask_ReportsUsageWhenCancelledByPoll: poll goroutine fires
  first and cancels runCtx; runner returns usage on Done; assert
  poll-status precedes usage (proving the cancelledByPoll branch was
  the one exercised, not the post-run path).

Sanity-checked: reverting only the ReportTaskUsage hoist fails both
tests with the original "tokens lost" message.

MUL-2258

Co-authored-by: Jiang Bohan <bhjiang@outlook.com>
Co-authored-by: multica-agent <github@multica.ai>
2026-05-15 13:33:17 +08:00
Jiayuan Zhang
2f0e5b589e [codex] Add member and agent task views 2026-05-15 07:23:00 +02:00
LinYushen
e6e9a9f77d squad_briefing: add hard rule requiring mention link for every delegation (#2663)
Without the full [@Name](mention://<type>/<UUID>) syntax, the platform
does not trigger the target agent. Add an explicit, strongly-worded
hard rule at the top of the list so the leader model never forgets.

Co-authored-by: multica-agent <github@multica.ai>
2026-05-15 13:17:08 +08:00
Naiyuan Qing
f29bd93444 feat(squads): rework Create Squad modal (MUL-2233) (#2645)
* feat(squad): accept avatar_url on CreateSquad

Threads avatar_url through the SQL query, sqlc-generated code, and the Go
handler so the create-squad flow can persist an avatar at creation time
instead of forcing a follow-up PATCH.

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

* feat(squad): add avatar_url to CreateSquadRequest

Extends the TS contract for the new backend field so the frontend can pass
an uploaded avatar URL through api.createSquad.

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

* feat(squads): rework Create Squad modal to match CreateAgentDialog (MUL-2233)

Replaces the cramped small-dialog flow with the same large-dialog shape used
by Create Agent: identity row (AvatarPicker + name + description with char
counter), grouped Leader picker (My Agents first, then Workspace Agents),
and a new multi-select Additional Members picker covering agents and
workspace members. The members trigger collapses to "+N" once more than
three are selected; promoting an agent to leader auto-drops it from the
additional-members list.

After createSquad, additional members are attached via Promise.allSettled
so a single failure surfaces a warning toast without blocking navigation —
the squad still exists and the user can retry from the Members tab.

Adds packages/views/modals/create-squad.test.tsx covering identity binding,
leader-group ordering, leader/member conflict sanitization, the empty- and
partial-failure success paths, and the create-failure recovery path.

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

* fix(squads): valid trigger HTML + drop conflicted leader from members

Two issues from PR #2645 review:

1. AdditionalMembersPicker's PopoverTrigger was a <button> containing
   MemberChip's remove <button>, which React/HTML flags as nested
   interactive content (hydration + a11y warning). Render the trigger as
   a <div role="combobox"> via Base UI's render prop so the chip's
   remove button is valid.

2. sanitizedMembers only hid the leader from rendered/submitted output,
   so promoting an additional member to leader then switching leader
   away resurrected the hidden pick. Drop it from selectedMembers at
   the moment of promotion via handleLeaderChange; sanitizedMembers is
   no longer needed.

Adds a test that promotes → switches leader and asserts the member is
not resubmitted.

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-15 13:11:08 +08:00
Bohan Jiang
2acc454ea5 fix(repos): accept scp shorthand in repo URL inputs (MUL-2250) (#2661)
Backend now validates http/https/ssh/git scheme plus scp-like
`git@host:owner/repo.git` shorthand, but three repo URL inputs were
still `type="url"`. The browser's native URL validation rejected scp
shorthand with "Please enter a URL" before the value could reach the
backend.

- Switch the three inputs to `type="text"` so submission isn't blocked
  client-side (project resources picker, workspace repositories tab,
  create-project repo picker).
- Extend the en/zh placeholders to show a scp shorthand example
  alongside the existing https one.
- Add a repositories-tab test that types `git@github.com:...` and
  asserts the input is text-type, passes native validity, and reaches
  the update mutation.

Co-authored-by: multica-agent <github@multica.ai>
2026-05-15 13:09:06 +08:00
Jiayuan Zhang
25182995c6 fix(projects): accept SSH repo URLs for github_repo resources MUL-2112 (#2492)
* fix(projects): accept SSH repo URLs for github_repo resources (#2484)

The project resource validator rejected anything that wasn't http(s), so
workspace repos configured with an SSH remote (ssh:// or the scp-like
`git@host:owner/repo.git` shorthand) could not be attached to a project.
Both forms are valid git remotes and the daemon hands the URL straight to
`git clone`, so the API has no reason to require https specifically.

Relax the validator to accept http/https/ssh/git schemes and the scp-like
shorthand, while still rejecting pasted garbage (no scheme, missing host,
missing path, ftp://, file://, etc.).

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

* fix(projects): reject scp-like URLs with '@' after ':' to avoid panic

isValidGitRepoURL indexed '@' and ':' independently, then sliced
s[at+1 : colon]. For inputs without '://' where '@' appears after the
first ':' (e.g. `host:org/repo@branch`), `at+1 > colon` triggered a
slice-bounds panic instead of a 400. Guard the slice and treat such
inputs as malformed.

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

---------

Co-authored-by: multica-agent <github@multica.ai>
2026-05-15 12:47:38 +08:00
Bohan Jiang
8d872b7521 fix(daemon): disable Claude AskUserQuestion in non-interactive mode (MUL-2244) (#2656)
* fix(daemon): disable Claude AskUserQuestion in non-interactive mode (MUL-2244)

GitHub #2588: when Claude Code calls its built-in AskUserQuestion tool
inside the daemon's stream-json runtime, the question never reaches the
user — there's no UI to render it — so the SDK returns an empty answer
and the agent silently "infers" and continues. From the issue's
perspective, execution looks stuck while the agent is actually charging
ahead on its own guess.

Two-part fix:

- `buildClaudeArgs` now passes `--disallowedTools AskUserQuestion` so
  the tool is not exposed to the model at all.
- The Claude-specific runtime brief tells the agent to use a `blocked`
  issue comment for genuine clarification, or to state an explicit
  assumption and proceed.

Adds a regression test that pins both: AskUserQuestion is forbidden in
CLAUDE.md and is NOT mentioned in the AGENTS.md emitted for non-Claude
providers (the tool is Claude-specific).

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

* refactor(daemon): drop CLAUDE.md AskUserQuestion guidance, rely on --disallowedTools

The --disallowedTools flag already prevents Claude from invoking
AskUserQuestion, so duplicating the rule in the runtime brief just bloats
the prompt without changing behavior. Removes the section and its
regression test; the argv-level test in pkg/agent already pins the flag.

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

---------

Co-authored-by: multica-agent <github@multica.ai>
2026-05-15 12:42:23 +08:00
Bohan Jiang
968ef1ca84 test(runtimes): pin combined provider+dotted+dated Claude normalization (#2657)
Adds a regression test for `anthropic/claude-opus-4.7-20251001` that
exercises all three resolvePricing tolerances at once (provider strip,
Claude dot→dash, date trim). Each step was already covered pairwise;
this nails down their composition so a future change to candidate
ordering can't silently drop a step.

Follow-up to #2654 (MUL-2243); raised in second review.

Co-authored-by: multica-agent <github@multica.ai>
2026-05-15 12:35:31 +08:00
Bohan Jiang
833032ed9c fix(runtimes): price Claude IDs reported as dotted / provider-prefixed (MUL-2243) (#2654)
Copilot's `meta.agentMeta.model` reports Claude SKUs with dots
(`claude-opus-4.7`, `claude-sonnet-4.6`, ...), and openclaw / opencode
emit the `<provider>/<model>` form (`anthropic/claude-opus-4.7`). The
maintained MODEL_PRICING table only keys on Anthropic's canonical
dashed form (`claude-opus-4-7`), so every Copilot-routed turn was
falling through to the "Custom model pricing" dialog and silently
contributing $0 to cost totals.

Teach `resolvePricing` two new tolerances, in order before date stripping:

  1. Strip a leading `<provider>/` segment — that's routing metadata,
     not part of the SKU.
  2. For `claude-*` IDs only, normalize dots to dashes. Scoped to
     Anthropic because for OpenAI the separator is semantic (`gpt-5.4`
     is a distinct SKU from a hypothetical `gpt-5-4`).

Custom pricing still wins over nothing, but the maintained catalog
still wins over a stale custom override (existing invariant preserved
by the test suite).

Co-authored-by: multica-agent <github@multica.ai>
2026-05-15 12:29:25 +08:00
Jiayuan Zhang
e7db644563 fix(chat): make session dropdown width track its trigger (MUL-2223) (#2630)
The chat header dropdown was capped at max-w-80 while the trigger
could grow unbounded with the current chat title, so the popup
appeared narrower than the trigger and titles inside were truncated
early. Cap the trigger at max-w-96 and let the popup inherit the
trigger width via --anchor-width with the same upper bound, so the
two stay visually consistent and only truncate at extreme lengths.

Co-authored-by: multica-agent <github@multica.ai>
2026-05-15 06:25:24 +02:00
Valentin Mihov
da7b33561e fix: make quick-create output prefix agnostic (#2604)
* fix: make quick-create output prefix agnostic

* fix: remove quick-create prefix assumption from runtime config
2026-05-15 12:20:53 +08:00
Naiyuan Qing
cc3a510952 fix(issues): respect create-mode preference at generic entry points (#2640)
Sidebar "新建 issue" button, command palette "New Issue", and the `c`
shortcut all hard-coded which create modal to open, ignoring the
persisted lastMode in useCreateModeStore. Pressing `c` after switching
from agent → manual reverted to agent on the next open.

Add `openCreateIssueWithPreference(data?)` helper next to the store.
Generic entries call it; entries that pre-seed manual-only fields
(status, project_id, parent_issue_id from board / list / project /
sub-issue actions) keep opening "create-issue" directly because agent
mode does not honour those seeds.

Co-authored-by: multica-agent <github@multica.ai>
2026-05-15 10:00:53 +08:00
347 changed files with 28130 additions and 2575 deletions

View File

@@ -29,6 +29,22 @@ PORT=8080
JWT_SECRET=change-me-in-production
MULTICA_SERVER_URL=ws://localhost:8080/ws
MULTICA_APP_URL=http://localhost:3000
# Public URL the API is reachable at from the open internet (no trailing
# slash). Used to mint absolute webhook URLs for autopilot webhook
# triggers. Leave unset behind a same-origin reverse proxy or for plain
# localhost dev — the frontend will compose the URL from
# window.origin + webhook_path in that case. Headers are intentionally
# not used to derive this value, to avoid Host / X-Forwarded-Host
# spoofing when a self-hosted reverse proxy is not hardened.
MULTICA_PUBLIC_URL=
# Comma-separated CIDR list of reverse proxies whose X-Forwarded-For /
# X-Real-IP headers the per-IP webhook rate limiter is allowed to trust.
# Empty (the default) means "trust no headers" — the limiter uses
# r.RemoteAddr only, which is the safe shape when the backend is
# exposed directly. Set this when running behind nginx/Caddy/Cloudflare:
# e.g. "127.0.0.1/32" for a same-host reverse proxy, or the CDN's
# announced ranges for cloud deployments.
MULTICA_TRUSTED_PROXIES=
MULTICA_DAEMON_CONFIG=
MULTICA_WORKSPACE_ID=
MULTICA_DAEMON_ID=
@@ -48,11 +64,26 @@ MULTICA_IMAGE_TAG=latest
MULTICA_BACKEND_IMAGE=ghcr.io/multica-ai/multica-backend
MULTICA_WEB_IMAGE=ghcr.io/multica-ai/multica-web
# Email (Resend)
# For local/dev use, leave RESEND_API_KEY empty — generated codes print to stdout.
# For production, set your Resend API key and change RESEND_FROM_EMAIL to a domain verified in your Resend account.
# Email
# Two delivery options - only one needs to be configured:
#
# Option A: Resend (SaaS, recommended for cloud deployments)
# Set RESEND_API_KEY to a key from resend.com and verify your sending domain there.
# For local/dev use, leave RESEND_API_KEY empty - codes print to stdout. To
# accept a fixed local code, also set MULTICA_DEV_VERIFICATION_CODE above
# (ignored when APP_ENV=production).
RESEND_API_KEY=
RESEND_FROM_EMAIL=noreply@multica.ai
#
# Option B: SMTP relay (for self-hosted / on-premise deployments)
# Takes priority over Resend when SMTP_HOST is set.
# Supports unauthenticated relay (leave SMTP_USERNAME empty) and authenticated SMTP.
# Set SMTP_TLS_INSECURE=true only for private CA or self-signed certificates.
SMTP_HOST=
SMTP_PORT=25
SMTP_USERNAME=
SMTP_PASSWORD=
SMTP_TLS_INSECURE=false
# Google OAuth
# The web login page reads GOOGLE_CLIENT_ID from /api/config at runtime, so
@@ -88,8 +119,30 @@ LOCAL_UPLOAD_BASE_URL=http://localhost:8080
# Security
# Comma-separated list of allowed origins for CORS and WebSocket connections.
# Defaults to localhost dev origins when unset.
# Example: ALLOWED_ORIGINS=https://app.multica.ai,https://staging.multica.ai
ALLOWED_ORIGINS=
# Example: CORS_ALLOWED_ORIGINS=https://app.multica.ai,https://staging.multica.ai
CORS_ALLOWED_ORIGINS=
# ==================== Rate limiting (optional Redis) ====================
# Per-IP fixed-window rate limiter on the public auth endpoints
# (/auth/send-code, /auth/verify-code, /auth/google). Backed by Redis.
# When REDIS_URL is unset the limiter is a no-op (fail-open) and the
# backend logs "rate limiting disabled: REDIS_URL not configured" at
# startup. The same REDIS_URL is reused by the realtime fan-out hub,
# the PAT cache, and the daemon-token cache.
# REDIS_URL=redis://localhost:6379/0
# Max requests per IP per minute. Defaults are 5 for send-code/google
# and 20 for verify-code.
# RATE_LIMIT_AUTH=5
# RATE_LIMIT_AUTH_VERIFY=20
# Comma-separated CIDRs whose X-Forwarded-For the auth limiter is
# allowed to trust. Empty (default) = never trust XFF, only RemoteAddr.
# REQUIRED behind a reverse proxy — otherwise every real user shares
# the proxy IP and the whole deployment lands in one bucket, turning
# /auth/send-code into 5 req/min site-wide. Use e.g. "127.0.0.1/32,::1/128"
# for same-host Caddy/Nginx, or the CDN's published ranges for ALB/CF.
# This is a separate list from MULTICA_TRUSTED_PROXIES above (which
# governs the autopilot webhook limiter).
# RATE_LIMIT_TRUSTED_PROXIES=
# Realtime metrics endpoint (/health/realtime) access control. See MUL-1342.
# When unset, the endpoint only serves direct loopback (127.0.0.1 / ::1)

View File

@@ -25,14 +25,30 @@ These have sensible defaults and only need to be set when tuning a large or cons
### Email (Required for Authentication)
Multica uses email-based magic link authentication via [Resend](https://resend.com).
Multica supports two email backends. `SMTP_HOST` takes priority when set; otherwise `RESEND_API_KEY` is used. With neither configured, verification codes are printed to the server log — copy them from there to log in.
#### Option A: Resend (recommended for cloud deployments)
| Variable | Description |
|----------|-------------|
| `RESEND_API_KEY` | Your Resend API key |
| `RESEND_FROM_EMAIL` | Sender email address (default: `noreply@multica.ai`) |
> **Note:** If Resend is not configured, generated verification codes are printed to backend logs. A fixed local testing code is disabled by default; to opt in on a private test instance, set `APP_ENV=development` and `MULTICA_DEV_VERIFICATION_CODE` to a 6-digit value. It is ignored when `APP_ENV=production`.
#### Option B: SMTP relay (for self-hosted / on-premise deployments)
Use this option when your deployment cannot reach the public internet or you already have an internal mail relay (e.g. Exchange, Postfix, SendGrid on-prem).
| Variable | Description | Default |
|----------|-------------|----------|
| `SMTP_HOST` | SMTP relay hostname (setting this activates SMTP mode) | - |
| `SMTP_PORT` | SMTP port | `25` |
| `SMTP_USERNAME` | SMTP username (leave empty for unauthenticated relay) | - |
| `SMTP_PASSWORD` | SMTP password | - |
| `SMTP_TLS_INSECURE` | Set `true` to skip TLS certificate verification (self-signed / private CA certs) | `false` |
STARTTLS is used automatically when advertised by the server. Port 465 (SMTPS / implicit TLS) is not currently supported - use ports 25 or 587 with STARTTLS.
> **Note:** If neither Resend nor SMTP is configured, generated verification codes are printed to backend logs — copy them from there to log in. A fixed local testing code (e.g. `888888`) is **opt-in only**: set `MULTICA_DEV_VERIFICATION_CODE=888888` in `.env` and keep `APP_ENV` non-production. The Docker self-host stack pins `APP_ENV=production`, so the shortcut is ignored there. **Never enable a fixed code on a publicly reachable instance.**
### Google OAuth (Optional)

View File

@@ -7,6 +7,7 @@ import { setupAutoUpdater } from "./updater";
import { setupDaemonManager } from "./daemon-manager";
import { openExternalSafely, downloadURLSafely } from "./external-url";
import { installContextMenu } from "./context-menu";
import { handleAppShortcut } from "./keyboard-shortcuts";
import { getAppVersion } from "./app-version";
import { loadRuntimeConfig } from "./runtime-config-loader";
import type { RuntimeConfigResult } from "../shared/runtime-config";
@@ -189,19 +190,13 @@ function createWindow(): void {
return { action: "deny" };
});
// Prevent Cmd+R / Ctrl+R / Shift+Cmd+R / Shift+Ctrl+R / F5 from
// reloading the page. In a desktop app an accidental reload destroys
// in-memory state (tabs, drafts, WS connections) with no URL bar to
// navigate back. DevTools refresh (via the DevTools UI) still works.
mainWindow.webContents.on("before-input-event", (_event, input) => {
if (input.type !== "keyDown") return;
const cmdOrCtrl =
process.platform === "darwin" ? input.meta : input.control;
if (
(cmdOrCtrl && input.key.toLowerCase() === "r") ||
input.key === "F5"
) {
_event.preventDefault();
// Window-level keyboard shortcuts. Calling preventDefault here prevents
// both the renderer keydown AND the application menu accelerator, so
// anything we own here (reload-block, zoom) is the sole handler for
// that combination — no double-fire with the macOS default View menu.
mainWindow.webContents.on("before-input-event", (event, input) => {
if (handleAppShortcut(input, mainWindow!.webContents)) {
event.preventDefault();
}
});

View File

@@ -0,0 +1,152 @@
import { describe, expect, it, vi } from "vitest";
import { handleAppShortcut, type ShortcutInput } from "./keyboard-shortcuts";
function makeWc(initialLevel = 0) {
let level = initialLevel;
return {
getZoomLevel: vi.fn(() => level),
setZoomLevel: vi.fn((next: number) => {
level = next;
}),
currentLevel: () => level,
};
}
function key(
k: string,
mods: Partial<Pick<ShortcutInput, "control" | "meta">> = {},
): ShortcutInput {
return {
type: "keyDown",
key: k,
control: false,
meta: false,
...mods,
};
}
describe("handleAppShortcut — reload blocking", () => {
it("swallows Cmd+R on macOS", () => {
const wc = makeWc();
expect(handleAppShortcut(key("r", { meta: true }), wc, "darwin")).toBe(true);
expect(wc.setZoomLevel).not.toHaveBeenCalled();
});
it("swallows Ctrl+R on Linux/Windows", () => {
const wc = makeWc();
expect(handleAppShortcut(key("r", { control: true }), wc, "linux")).toBe(true);
expect(handleAppShortcut(key("R", { control: true }), wc, "win32")).toBe(true);
});
it("swallows F5 regardless of modifier", () => {
const wc = makeWc();
expect(handleAppShortcut(key("F5"), wc, "darwin")).toBe(true);
});
it("ignores non-keyDown events", () => {
const wc = makeWc();
expect(
handleAppShortcut({ ...key("r", { meta: true }), type: "keyUp" }, wc, "darwin"),
).toBe(false);
});
});
describe("handleAppShortcut — zoom in", () => {
it("zooms in on Cmd+= (unshifted)", () => {
const wc = makeWc(0);
expect(handleAppShortcut(key("=", { meta: true }), wc, "darwin")).toBe(true);
expect(wc.currentLevel()).toBe(0.5);
});
it("zooms in on Cmd++ (Shift+=)", () => {
const wc = makeWc(0);
expect(handleAppShortcut(key("+", { meta: true }), wc, "darwin")).toBe(true);
expect(wc.currentLevel()).toBe(0.5);
});
it("zooms in on Ctrl+= on non-mac", () => {
const wc = makeWc(0);
expect(handleAppShortcut(key("=", { control: true }), wc, "linux")).toBe(true);
expect(wc.currentLevel()).toBe(0.5);
});
it("does nothing without Cmd/Ctrl", () => {
const wc = makeWc(0);
expect(handleAppShortcut(key("="), wc, "darwin")).toBe(false);
expect(wc.setZoomLevel).not.toHaveBeenCalled();
});
it("clamps zoom-in at the upper bound", () => {
const wc = makeWc(4.5);
expect(handleAppShortcut(key("=", { meta: true }), wc, "darwin")).toBe(true);
expect(wc.currentLevel()).toBe(4.5);
});
});
describe("handleAppShortcut — zoom out (regression: MUL-2354)", () => {
it("zooms out on Cmd+- (unshifted)", () => {
const wc = makeWc(1);
expect(handleAppShortcut(key("-", { meta: true }), wc, "darwin")).toBe(true);
expect(wc.currentLevel()).toBe(0.5);
});
it("zooms out on Cmd+_ (Shift+-)", () => {
const wc = makeWc(1);
expect(handleAppShortcut(key("_", { meta: true }), wc, "darwin")).toBe(true);
expect(wc.currentLevel()).toBe(0.5);
});
it("zooms out on Ctrl+- on non-mac", () => {
const wc = makeWc(1);
expect(handleAppShortcut(key("-", { control: true }), wc, "win32")).toBe(true);
expect(wc.currentLevel()).toBe(0.5);
});
it("undoes a prior Cmd+= so the user can return to 100%", () => {
const wc = makeWc(0);
handleAppShortcut(key("=", { meta: true }), wc, "darwin");
expect(wc.currentLevel()).toBe(0.5);
handleAppShortcut(key("-", { meta: true }), wc, "darwin");
expect(wc.currentLevel()).toBe(0);
});
it("clamps zoom-out at the lower bound", () => {
const wc = makeWc(-3);
expect(handleAppShortcut(key("-", { meta: true }), wc, "darwin")).toBe(true);
expect(wc.currentLevel()).toBe(-3);
});
it("does nothing without Cmd/Ctrl", () => {
const wc = makeWc(1);
expect(handleAppShortcut(key("-"), wc, "darwin")).toBe(false);
expect(wc.setZoomLevel).not.toHaveBeenCalled();
});
});
describe("handleAppShortcut — reset zoom", () => {
it("resets to 0 on Cmd+0", () => {
const wc = makeWc(2);
expect(handleAppShortcut(key("0", { meta: true }), wc, "darwin")).toBe(true);
expect(wc.currentLevel()).toBe(0);
});
it("resets to 0 on Ctrl+0", () => {
const wc = makeWc(-1.5);
expect(handleAppShortcut(key("0", { control: true }), wc, "linux")).toBe(true);
expect(wc.currentLevel()).toBe(0);
});
it("ignores plain 0 without modifier", () => {
const wc = makeWc(2);
expect(handleAppShortcut(key("0"), wc, "darwin")).toBe(false);
expect(wc.setZoomLevel).not.toHaveBeenCalled();
});
});
describe("handleAppShortcut — unrelated keys pass through", () => {
it("does not capture plain letters", () => {
const wc = makeWc();
expect(handleAppShortcut(key("a", { meta: true }), wc, "darwin")).toBe(false);
expect(handleAppShortcut(key("k", { meta: true }), wc, "darwin")).toBe(false);
});
});

View File

@@ -0,0 +1,74 @@
import type { WebContents } from "electron";
// Shape of the input subset we read from Electron's `before-input-event`.
// Modeled as a structural type so the handler is unit-testable without a
// real Electron Input instance.
export type ShortcutInput = {
type: string;
key: string;
control: boolean;
meta: boolean;
};
// Subset of WebContents the zoom handler needs. Keeps the test mock tiny.
export type ZoomTarget = Pick<WebContents, "getZoomLevel" | "setZoomLevel">;
// Match Electron's built-in zoomIn/zoomOut roles (Chromium default of 0.5
// per step). Clamp to a range that keeps the UI legible — values outside
// this band turn the workspace into either confetti or a microfiche.
const ZOOM_STEP = 0.5;
const ZOOM_MIN = -3;
const ZOOM_MAX = 4.5;
/**
* Inspect a `before-input-event` key and apply (or block) the matching
* window-level shortcut. Returns `true` when the caller should call
* `event.preventDefault()` — that both swallows the renderer keydown and
* prevents the application menu accelerator from firing, so we don't
* double-trigger zoom on macOS where the default menu also binds these
* keys.
*
* Why we don't rely on the menu's `zoomIn` / `zoomOut` roles: on macOS the
* default `Cmd+-` accelerator does not fire reliably across keyboard
* layouts (issue MUL-2354 — Cmd+= zooms in but Cmd+- doesn't undo it).
* Handling the shortcuts here gives identical behavior on every platform
* and every layout.
*/
export function handleAppShortcut(
input: ShortcutInput,
webContents: ZoomTarget,
platform: NodeJS.Platform = process.platform,
): boolean {
if (input.type !== "keyDown") return false;
const cmdOrCtrl = platform === "darwin" ? input.meta : input.control;
// Block reload — accidental Cmd+R / Ctrl+R / F5 destroys in-memory state
// (tabs, drafts, WS connections) with no URL bar to recover from.
if ((cmdOrCtrl && input.key.toLowerCase() === "r") || input.key === "F5") {
return true;
}
if (!cmdOrCtrl) return false;
// Cmd/Ctrl + "=" (unshifted) or "+" (Shift+=) → zoom in.
if (input.key === "=" || input.key === "+") {
const next = Math.min(webContents.getZoomLevel() + ZOOM_STEP, ZOOM_MAX);
webContents.setZoomLevel(next);
return true;
}
// Cmd/Ctrl + "-" (unshifted) or "_" (Shift+-) → zoom out.
if (input.key === "-" || input.key === "_") {
const next = Math.max(webContents.getZoomLevel() - ZOOM_STEP, ZOOM_MIN);
webContents.setZoomLevel(next);
return true;
}
// Cmd/Ctrl + 0 → reset zoom to 100%.
if (input.key === "0") {
webContents.setZoomLevel(0);
return true;
}
return false;
}

View File

@@ -4,7 +4,6 @@ import {
Play,
Square,
RotateCw,
Server,
Activity,
ScrollText,
} from "lucide-react";
@@ -12,15 +11,7 @@ import { useQuery } from "@tanstack/react-query";
import { useWorkspaceId } from "@multica/core/hooks";
import { runtimeListOptions } from "@multica/core/runtimes";
import { agentTaskSnapshotOptions } from "@multica/core/agents";
import { cn } from "@multica/ui/lib/utils";
import { Button } from "@multica/ui/components/ui/button";
import {
Card,
CardAction,
CardDescription,
CardHeader,
CardTitle,
} from "@multica/ui/components/ui/card";
import {
Dialog,
DialogContent,
@@ -32,24 +23,13 @@ import {
import { toast } from "sonner";
import { DaemonPanel } from "./daemon-panel";
import type { DaemonStatus } from "../../../shared/daemon-types";
import {
DAEMON_STATE_COLORS,
DAEMON_STATE_LABELS,
daemonStateDescription,
formatUptime,
} from "../../../shared/daemon-types";
import { DAEMON_STATE_LABELS } from "../../../shared/daemon-types";
/**
* Header card on the desktop Runtimes page that surfaces the daemon embedded
* in this Electron app. The same daemon process registers N runtimes with the
* server (one per detected CLI), which appear in the runtime list below — so
* this card is the parent control surface for "what's running on this Mac".
*
* Why this lives only on desktop: web users don't have an embedded daemon;
* they bring their own (CLI-launched or remote VM) and just see runtimes in
* the list. The `desktop-runtimes-page` wrapper is the only mount point.
* Desktop-only controls for the daemon embedded in this Electron app. The
* shared runtimes page renders this inside the selected local machine header.
*/
export function DaemonRuntimeCard() {
export function DaemonRuntimeActions() {
const [status, setStatus] = useState<DaemonStatus>({ state: "stopped" });
const [panelOpen, setPanelOpen] = useState(false);
const [actionLoading, setActionLoading] = useState(false);
@@ -57,14 +37,8 @@ export function DaemonRuntimeCard() {
const wsId = useWorkspaceId();
const { data: runtimes = [] } = useQuery(runtimeListOptions(wsId));
// Snapshot also includes each agent's latest terminal; the filter below
// drops anything that isn't running/dispatched, so terminal rows pass
// through harmlessly.
const { data: snapshot = [] } = useQuery(agentTaskSnapshotOptions(wsId));
// Set of runtime IDs registered by THIS daemon (one per detected CLI).
// Used both to count "how many CLIs am I contributing" and to figure
// out which active tasks would be impacted by a Stop.
const localRuntimeIds = useMemo(() => {
if (!status.daemonId) return new Set<string>();
return new Set(
@@ -76,10 +50,6 @@ export function DaemonRuntimeCard() {
const runtimeCount = localRuntimeIds.size;
// Tasks that are actually doing work on this daemon right now —
// running or dispatched. Queued tasks haven't claimed a runtime yet,
// so stopping the daemon won't break them (they'll wait for any
// available daemon). The number drives the Stop-confirmation dialog.
const affectedTasks = useMemo(
() =>
snapshot.filter(
@@ -108,9 +78,6 @@ export function DaemonRuntimeCard() {
}
}, []);
// The actual stop call, separated from the click handler so we can call
// it both from the direct path (no active tasks) and from the confirm
// dialog's confirm button.
const performStop = useCallback(async () => {
setActionLoading(true);
const result = await window.daemonAPI.stop();
@@ -119,8 +86,6 @@ export function DaemonRuntimeCard() {
}
}, []);
// Click on the Stop button. If there's nothing running, just stop;
// otherwise pop a confirm dialog explaining the blast radius.
const handleStopClick = useCallback(() => {
if (affectedTasks.length === 0) {
void performStop();
@@ -136,9 +101,6 @@ export function DaemonRuntimeCard() {
toast.error("Failed to restart daemon", { description: result.error });
return;
}
// Success feedback — the daemon takes a few seconds to come back online,
// and the only other UI signal is the state badge flipping briefly. A
// toast confirms the click was received and tells the user what to expect.
toast.success("Restarting daemon", {
description: "Runtimes will be back online in a few seconds.",
});
@@ -162,106 +124,64 @@ export function DaemonRuntimeCard() {
return (
<>
<Card size="sm">
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Server className="size-4 text-muted-foreground" />
Local daemon
<span className="inline-flex items-center gap-1.5 rounded-md border bg-background px-1.5 py-0.5 text-xs font-normal">
<span
className={cn(
"size-1.5 rounded-full",
DAEMON_STATE_COLORS[status.state],
)}
/>
<span
className={cn(
"tabular-nums",
isRunning ? "text-foreground" : "text-muted-foreground",
)}
>
{DAEMON_STATE_LABELS[status.state]}
</span>
{isRunning && status.uptime && (
<span className="text-muted-foreground">
· {formatUptime(status.uptime)}
</span>
)}
</span>
</CardTitle>
<CardDescription>
{daemonStateDescription(status.state, runtimeCount)}
</CardDescription>
<CardAction className="self-center">
<div className="flex items-center gap-1.5">
{isRunning && (
<>
<Button
size="sm"
variant="ghost"
onClick={() => setPanelOpen(true)}
>
<ScrollText className="size-3.5 mr-1.5" />
View logs
</Button>
<Button
size="sm"
variant="outline"
onClick={handleRestart}
disabled={actionLoading}
>
<RotateCw className="size-3.5 mr-1.5" />
Restart
</Button>
<Button
size="sm"
variant="destructive"
onClick={handleStopClick}
disabled={actionLoading}
>
<Square className="size-3.5 mr-1.5" />
Stop
</Button>
</>
)}
<div className="flex flex-wrap items-center justify-end gap-1.5">
{isRunning && (
<>
<Button size="sm" variant="ghost" onClick={() => setPanelOpen(true)}>
<ScrollText className="size-3.5 mr-1.5" />
View logs
</Button>
<Button
size="sm"
variant="outline"
onClick={handleRestart}
disabled={actionLoading}
>
<RotateCw className="size-3.5 mr-1.5" />
Restart
</Button>
<Button
size="sm"
variant="destructive"
onClick={handleStopClick}
disabled={actionLoading}
>
<Square className="size-3.5 mr-1.5" />
Stop
</Button>
</>
)}
{isStopped && (
<Button
size="sm"
onClick={handleStart}
disabled={actionLoading}
>
{actionLoading ? (
<Activity className="size-3.5 mr-1.5 animate-pulse" />
) : (
<Play className="size-3.5 mr-1.5" />
)}
Start
</Button>
)}
{isStopped && (
<Button size="sm" onClick={handleStart} disabled={actionLoading}>
{actionLoading ? (
<Activity className="size-3.5 mr-1.5 animate-pulse" />
) : (
<Play className="size-3.5 mr-1.5" />
)}
Start
</Button>
)}
{isCliMissing && (
<Button
size="sm"
variant="outline"
onClick={handleRetryInstall}
disabled={actionLoading}
>
<RotateCw className="size-3.5 mr-1.5" />
Retry setup
</Button>
)}
{isCliMissing && (
<Button
size="sm"
variant="outline"
onClick={handleRetryInstall}
disabled={actionLoading}
>
<RotateCw className="size-3.5 mr-1.5" />
Retry setup
</Button>
)}
{(isTransitioning || isInstalling) && (
<Button size="sm" variant="outline" disabled>
<Activity className="size-3.5 mr-1.5 animate-pulse" />
{DAEMON_STATE_LABELS[status.state]}
</Button>
)}
</div>
</CardAction>
</CardHeader>
</Card>
{(isTransitioning || isInstalling) && (
<Button size="sm" variant="outline" disabled>
<Activity className="size-3.5 mr-1.5 animate-pulse" />
{DAEMON_STATE_LABELS[status.state]}
</Button>
)}
</div>
<DaemonPanel
open={panelOpen}

View File

@@ -1,6 +1,6 @@
import { useEffect, useState } from "react";
import { RuntimesPage } from "@multica/views/runtimes";
import { DaemonRuntimeCard } from "./daemon-runtime-card";
import { DaemonRuntimeActions } from "./daemon-runtime-card";
import type { DaemonStatus } from "../../../shared/daemon-types";
/**
@@ -32,7 +32,9 @@ export function DesktopRuntimesPage() {
return (
<RuntimesPage
topSlot={<DaemonRuntimeCard />}
localDaemonId={status.daemonId ?? null}
localMachineName={status.deviceName ?? null}
localMachineActions={<DaemonRuntimeActions />}
bootstrapping={bootstrapping}
/>
);

View File

@@ -0,0 +1,16 @@
import { useParams, useSearchParams } from "react-router-dom";
import { AttachmentPreviewPage } from "@multica/views/attachments";
import { ErrorBoundary } from "@multica/ui/components/common/error-boundary";
export function AttachmentPreviewRoute() {
const { id } = useParams<{ id: string }>();
const [searchParams] = useSearchParams();
const filename = searchParams.get("name") ?? undefined;
if (!id) return null;
return (
<ErrorBoundary resetKeys={[id]}>
<AttachmentPreviewPage attachmentId={id} filename={filename} />
</ErrorBoundary>
);
}

View File

@@ -0,0 +1,18 @@
import { useParams } from "react-router-dom";
import { useQuery } from "@tanstack/react-query";
import { MemberDetailPage as SharedMemberDetailPage } from "@multica/views/members";
import { useWorkspaceId } from "@multica/core/hooks";
import { memberListOptions } from "@multica/core/workspace/queries";
import { useDocumentTitle } from "@/hooks/use-document-title";
export function MemberDetailPage() {
const { id } = useParams<{ id: string }>();
const wsId = useWorkspaceId();
const { data: members = [] } = useQuery(memberListOptions(wsId));
const member = members.find((m) => m.user_id === id) ?? null;
useDocumentTitle(member?.name ?? "Member");
if (!id) return null;
return <SharedMemberDetailPage userId={id} />;
}

View File

@@ -11,7 +11,9 @@ import { ProjectDetailPage } from "./pages/project-detail-page";
import { AutopilotDetailPage } from "./pages/autopilot-detail-page";
import { SkillDetailPage } from "./pages/skill-detail-page";
import { AgentDetailPage } from "./pages/agent-detail-page";
import { MemberDetailPage } from "./pages/member-detail-page";
import { RuntimeDetailPage } from "./pages/runtime-detail-page";
import { AttachmentPreviewRoute } from "./pages/attachment-preview-page";
import { IssuesPage } from "@multica/views/issues/components";
import { ProjectsPage } from "@multica/views/projects/components";
import { DashboardPage } from "@multica/views/dashboard";
@@ -147,6 +149,11 @@ export const appRoutes: RouteObject[] = [
element: <AgentDetailPage />,
handle: { title: "Agent" },
},
{
path: "members/:id",
element: <MemberDetailPage />,
handle: { title: "Member" },
},
{ path: "squads", element: <SquadsPage />, handle: { title: "Squads" } },
{
path: "squads/:id",
@@ -154,6 +161,11 @@ export const appRoutes: RouteObject[] = [
handle: { title: "Squad" },
},
{ path: "inbox", element: <InboxPage />, handle: { title: "Inbox" } },
{
path: "attachments/:id/preview",
element: <AttachmentPreviewRoute />,
handle: { title: "Attachment" },
},
{
path: "usage",
element: <DashboardPage />,

View File

@@ -12,9 +12,11 @@ For the list of environment variables referenced below, see [Environment variabl
## How email + verification code sign-in works
The user enters an email on the sign-in page → the server sends a 6-digit code → the user enters it → the server verifies it → a JWT cookie is issued. Standard flow. It requires [Resend](https://resend.com/) as the email provider:
The user enters an email on the sign-in page → the server sends a 6-digit code → the user enters it → the server verifies it → a JWT cookie is issued. Standard flow. Two delivery backends are supported — pick whichever fits your deployment:
1. Create a Resend account and verify your domain
### Option A: Resend (recommended for cloud / public-internet deployments)
1. Create a [Resend](https://resend.com/) account and verify your domain
2. Create an API key
3. Set the environment variables:
@@ -25,7 +27,22 @@ The user enters an email on the sign-in page → the server sends a 6-digit code
4. Restart the server
**What happens if you don't set `RESEND_API_KEY`**: the server doesn't error, but **every email that should have been sent is written to the server's stdout only**. Handy for local development (copy the code from the logs); in production it's a black hole.
### Option B: SMTP relay (for self-hosted / on-premise deployments)
Use this when the deployment can't reach `api.resend.com` or you already have an internal mail relay (Exchange, Postfix, on-prem SendGrid, etc.). `SMTP_HOST` takes priority over `RESEND_API_KEY` when both are set.
```bash
SMTP_HOST=smtp.internal.example.com
SMTP_PORT=587 # default 25; use 587 for STARTTLS submission
SMTP_USERNAME=multica # leave empty for unauthenticated relay
SMTP_PASSWORD=...
SMTP_TLS_INSECURE=false # set true only for self-signed / private CA
RESEND_FROM_EMAIL=noreply@yourdomain.com # reused as the From: header
```
STARTTLS is upgraded automatically when the server advertises it. Port 465 (SMTPS / implicit TLS) is **not** currently supported — use port 25 or 587.
**What happens if you set neither**: the server doesn't error, but **every email that should have been sent is written to the server's stdout only**. Handy for local development (copy the code from the logs); in production it's a black hole.
## Fixed local testing codes
@@ -34,7 +51,7 @@ The user enters an email on the sign-in page → the server sends a 6-digit code
The old behavior where non-production instances accepted `888888` by default has been removed. Unless you explicitly configure it, typing `888888` is treated like any other wrong code.
Local development without Resend should use the generated code printed in server logs. If you need deterministic local/private automation, set `MULTICA_DEV_VERIFICATION_CODE` to a 6-digit value such as `888888`, and keep `APP_ENV` non-production:
Local development without any email backend configured (no Resend, no SMTP) should use the generated code printed in server logs. If you need deterministic local/private automation, set `MULTICA_DEV_VERIFICATION_CODE` to a 6-digit value such as `888888`, and keep `APP_ENV` non-production:
```bash
APP_ENV=development

View File

@@ -12,9 +12,11 @@ Multica 支持两种登录方式:**Email + 验证码**(默认)和 **Google
## Email + 验证码登录怎么工作
用户在登录页输邮箱 → server 发 6 位验证码 → 用户填回 → server 验证 → 签发 JWT cookie。是标准流程。需要 [Resend](https://resend.com/) 作为邮件发送服务
用户在登录页输邮箱 → server 发 6 位验证码 → 用户填回 → server 验证 → 签发 JWT cookie。是标准流程。支持两种邮件发送通道,按部署环境二选一
1. 在 Resend 建账号、验证你的域名
### Option AResend公网/云端部署推荐)
1. 在 [Resend](https://resend.com/) 建账号、验证你的域名
2. 创建 API key
3. 设环境变量:
@@ -25,7 +27,22 @@ Multica 支持两种登录方式:**Email + 验证码**(默认)和 **Google
4. 重启 server
**不配 `RESEND_API_KEY` 的后果**server 不报错,但**所有本该发出去的邮件只打到 server 的 stdout**。本地开发方便(你从日志抄验证码),生产环境等于黑洞。
### Option BSMTP relay内网/自部署)
适合内网无法访问 `api.resend.com`或者已经有内部邮件中继Exchange、Postfix、自部署 SendGrid 等)的场景。同时设置时 `SMTP_HOST` 优先级高于 `RESEND_API_KEY`。
```bash
SMTP_HOST=smtp.internal.example.com
SMTP_PORT=587 # 默认 25STARTTLS 提交端口用 587
SMTP_USERNAME=multica # 留空则使用未认证 relay
SMTP_PASSWORD=...
SMTP_TLS_INSECURE=false # 仅在私有 CA / 自签证书时改成 true
RESEND_FROM_EMAIL=noreply@yourdomain.com # 同时作为 SMTP From: 头
```
服务端 advertise STARTTLS 时会自动升级。**暂不支持** 465SMTPS / 隐式 TLS请使用 25 或 587。
**两种都不配**server 不报错,但所有本该发出去的邮件**只打到 server 的 stdout**。本地开发方便(你从日志抄验证码),生产环境等于黑洞。
## 固定本地测试验证码
@@ -34,7 +51,7 @@ Multica 支持两种登录方式:**Email + 验证码**(默认)和 **Google
旧版「非 production 默认接受 `888888`」的行为已经移除。除非你显式配置,否则输入 `888888` 会和普通错误验证码一样被拒绝。
不配 Resend 的本地开发,应使用 server 日志里打印的随机验证码。如果你需要确定性的本地/私有自动化测试,可以把 `MULTICA_DEV_VERIFICATION_CODE` 设成一个 6 位数字,比如 `888888`,并保持 `APP_ENV` 为非 production
没配任何邮件后端Resend 和 SMTP 都没设)的本地开发,应使用 server 日志里打印的随机验证码。如果你需要确定性的本地/私有自动化测试,可以把 `MULTICA_DEV_VERIFICATION_CODE` 设成一个 6 位数字,比如 `888888`,并保持 `APP_ENV` 为非 production
```bash
APP_ENV=development

View File

@@ -1,6 +1,6 @@
---
title: Autopilots
description: Let agents start work on a cron schedule or trigger once manually via the UI or CLI.
description: Let agents start work on a cron schedule, an inbound webhook, or trigger once manually via the UI or CLI.
---
import { Callout } from "fumadocs-ui/components/callout";
@@ -16,13 +16,13 @@ Create a new autopilot on the workspace's **Autopilot** page. You set:
- **Priority** — inherited by the `task` it produces (same semantics as issue priority)
- **Description / prompt** — the work description the agent receives each run
- **Execution mode** — see below
- **Triggers** — at least one `schedule` (cron + timezone)
- **Triggers** — at least one `schedule` (cron + timezone) or `webhook`
## Pick an execution mode
An autopilot has two execution modes. **Start with "create issue" mode.**
- **Create issue mode** (`create_issue`) — default, **recommended**. Each trigger first creates an issue in the workspace (the title supports interpolation like `{{date}}`), then assigns the issue to the agent through the normal assignment flow. All work lands on the issue board with the same history, comments, and status as a manually assigned issue.
- **Create issue mode** (`create_issue`) — default, **recommended**. Each trigger first creates an issue in the workspace (the title currently supports a single placeholder, `{{date}}`, which interpolates to the UTC date in `YYYY-MM-DD` format; any other `{{...}}` token is rejected at create-time so a typo cannot silently land as the literal string in your issue titles), then assigns the issue to the agent through the normal assignment flow. All work lands on the issue board with the same history, comments, and status as a manually assigned issue.
- **Run-only mode** (`run_only`) — skips issue creation and enqueues a `task` directly. The run is invisible on the board — you can only see it in the autopilot's run history.
## Run it on a schedule
@@ -50,15 +50,109 @@ multica autopilot trigger <autopilot-id>
A manual trigger goes through the exact same execution flow as a `schedule` trigger — only the `source` field on the run record is marked `manual`.
## Trigger from a webhook
Autopilots can also fire on inbound HTTP webhooks. Add a **Webhook** trigger
on the autopilot detail page; Multica generates a unique URL of the shape:
```
https://<your-multica-host>/api/webhooks/autopilots/awt_…
```
POST any JSON to that URL — Multica records a run with `source = webhook`,
stores the body as the run's `trigger_payload`, and dispatches the agent
exactly the way a schedule trigger would.
```bash
curl -X POST "$MULTICA_WEBHOOK_URL" \
-H "Content-Type: application/json" \
-d '{"event":"demo.received","eventPayload":{"message":"hello"}}'
```
In **create issue mode**, the inbound payload is appended to the new issue's
description so the agent can read it inline. In **run-only mode**, the
payload is part of the run context the daemon hands the agent.
### Payload shape
You can send your own envelope:
```json
{ "event": "github.pull_request.opened", "eventPayload": { } }
```
…or any JSON object/array. Multica normalizes it into an internal envelope:
```json
{
"event": "<inferred>",
"eventPayload": <your body>,
"request": { "receivedAt": "<rfc3339>", "contentType": "application/json" }
}
```
When you don't provide an `event` field, Multica infers it from common
headers and body fields (`X-GitHub-Event` + body `action`,
`X-Gitlab-Event`, `X-Event-Type`, body `event`/`type`/`action`). When
nothing matches, the event is `webhook.received`.
When configuring GitHub or similar sources, set the content type to
`application/json` — form-encoded webhook payloads are not accepted.
### URL is a bearer secret
The generated URL **is** the credential. Anyone with it can fire the
autopilot. Treat it like a token:
- **Don't paste it into public issue threads, screenshots, or chat history.**
- **Rotate it if it leaks** — click "Rotate URL" on the trigger row, or run
`multica autopilot trigger-rotate-url <autopilot-id> <trigger-id>`. The
old URL stops working immediately.
- For sources that require strong source authentication, wait for
per-trigger HMAC signature verification; this v1 URL is bearer-only.
- Workspace members who can view the autopilot can read its webhook URLs
for now — tighter per-role secret visibility is a follow-up.
### Status-code semantics
Multica returns `200 OK` with a `status` field for normal no-op outcomes so
your provider's webhook-retry machinery doesn't keep hammering the URL:
- `{"status":"accepted","run_id":"…","autopilot_id":"…","trigger_id":"…"}`
— a run was dispatched.
- `{"status":"skipped","run_id":"…","reason":"agent runtime is offline at dispatch time"}`
— the assignee's runtime is offline; recorded as a `skipped` run.
- `{"status":"ignored","reason":"trigger_disabled"}` — the trigger is disabled.
- `{"status":"ignored","reason":"autopilot_paused"}` — the autopilot is paused.
- `{"status":"ignored","reason":"autopilot_archived"}` — the autopilot is archived.
Non-2xx responses cover real failures:
- `400` — invalid JSON, scalar body, or empty body.
- `404` — unknown token (`{"error":"webhook not found"}`).
- `413` — payload exceeded 256 KiB.
- `429` — per-token rate limit exceeded (defaults to 60 req/min).
### Self-hosted: configure your public URL
When `MULTICA_PUBLIC_URL` is set on the server (e.g. `https://multica.example.com`),
the trigger response includes an absolute `webhook_url` and the UI shows a
ready-to-copy URL. Without it, the UI composes the URL from the client's
API origin — which is fine for desktop and same-origin web, but not for
custom self-hosted reverse proxies. Multica deliberately does not derive
the public host from `Host` / `X-Forwarded-Host` headers so a misconfigured
reverse proxy cannot trick the server into minting webhook URLs pointing at
an attacker-controlled host.
## View run history
Every trigger produces a **run record**, visible on the "History" tab of the autopilot detail page:
- Trigger source (`schedule` / `manual`)
- Trigger source (`schedule` / `manual` / `webhook`)
- Start time, completion time
- Status (`issue_created` / `running` / `completed` / `failed`)
- Status (`issue_created` / `running` / `completed` / `failed` / `skipped`)
- The linked issue (create issue mode) or `task` (run-only mode)
- Failure reason (if failed)
- Failure reason (if failed or skipped)
## What happens when an autopilot fails
@@ -72,7 +166,11 @@ Why no auto-retry: autopilots are already periodic, so adding system-level retri
## What's not yet available
**Webhook and API triggers are not available yet.** The autopilot trigger schema reserves `webhook` and `api` types, but **they are not wired up to any ingress route** — the UI can create triggers of either type, but they will not actually fire. Today, **only `schedule` and manual triggers are end-to-end usable.**
**API-kind triggers are not wired up.** The trigger schema reserves an `api`
kind, but no ingress route fires it; the UI shows a Deprecated badge for
existing rows and offers no copy/rotate affordances. Per-trigger HMAC
signature verification, IP allowlists, and provider-specific event presets
are tracked as follow-ups; v1 URLs are bearer-only.
## Next

View File

@@ -1,6 +1,6 @@
---
title: Autopilots
description: 让智能体按 cron 定时自己开工——或通过 UI / CLI 手动触发一次。
description: 让智能体按 cron 定时自己开工,或在 webhook 到来时被触发——也可以通过 UI / CLI 手动触发一次。
---
import { Callout } from "fumadocs-ui/components/callout";
@@ -16,13 +16,13 @@ Autopilots 让 [智能体](/agents) **按调度自动开工**——配好 cron
- **优先级** — 继承给它产生的 `task`(语义同 issue 优先级)
- **描述 / Prompt** — 智能体每次执行拿到的工作说明
- **执行模式** — 见下节
- **触发器** — 至少加一条 `schedule`cron + 时区)
- **触发器** — 至少加一条 `schedule`cron + 时区)或 `webhook`
## 选择执行模式
Autopilot 有两种执行模式,**建议从"先建 issue 模式"开始**
- **先建 issue 模式**`create_issue`)—— 默认,**推荐**。每次触发先在工作区里建一个 issue标题支持 `{{date}}` 这样的插值),再按分配流程把 issue 派给智能体。所有工作都落在 issue 看板上,历史、评论、状态和手动分配的 issue 完全一致。
- **先建 issue 模式**`create_issue`)—— 默认,**推荐**。每次触发先在工作区里建一个 issue标题目前只支持一个占位符 `{{date}}`,会插值成 UTC 日期 `YYYY-MM-DD`;其他 `{{...}}` 形式的占位符会在创建时被拒绝,避免拼错以后悄无声息地把原文当成 issue 标题),再按分配流程把 issue 派给智能体。所有工作都落在 issue 看板上,历史、评论、状态和手动分配的 issue 完全一致。
- **直跑模式**`run_only`)—— 不建 issue直接入队一个 `task`。看板上看不到这一次运行——只能在 Autopilot 的运行历史里看到。
## 让它按时间跑
@@ -50,15 +50,105 @@ multica autopilot trigger <autopilot-id>
手动触发走和 `schedule` 触发完全相同的执行流程,只是运行记录里 `source` 字段标为 `manual`。
## 通过 Webhook 触发
Autopilot 也可以由入站 HTTP webhook 触发。在详情页添加一个 **Webhook**
触发器Multica 会生成一个唯一的 URL
```
https://<你的 Multica host>/api/webhooks/autopilots/awt_…
```
向这个 URL POST 任意 JSON——Multica 会记录一条 `source = webhook` 的
run把请求体保存为 run 的 `trigger_payload`,然后按和 schedule 触发器
完全一致的方式派发给智能体。
```bash
curl -X POST "$MULTICA_WEBHOOK_URL" \
-H "Content-Type: application/json" \
-d '{"event":"demo.received","eventPayload":{"message":"hello"}}'
```
在**先建 issue 模式**下,入站 payload 会附加在新 issue 的描述里供智能体
直接读到;**直跑模式**下payload 也会随 run 一并交给 daemon。
### Payload 形态
可以发自己的封装:
```json
{ "event": "github.pull_request.opened", "eventPayload": { } }
```
也可以直接发任意 JSON 对象 / 数组。Multica 会规范化为内部封装:
```json
{
"event": "<推断>",
"eventPayload": <你的 body>,
"request": { "receivedAt": "<rfc3339>", "contentType": "application/json" }
}
```
不带 `event` 字段时Multica 会按以下顺序从常见 header 和 body 字段
推断:`X-GitHub-Event` + body `action``X-Gitlab-Event`、
`X-Event-Type`、body 里的 `event` / `type` / `action`。都不命中时事件
名退化为 `webhook.received`。
配置 GitHub 之类的来源时,请把 content type 设为 `application/json`——
表单编码的 webhook payload 在 v1 里不接受。
### URL 即 bearer secret
生成的 URL **就是凭证**,谁拿到都能触发这个 Autopilot。请按 token 对待:
- **不要贴到公开 issue 评论、截图、聊天记录里。**
- **泄漏后立即重新生成**——在触发器上点"重新生成 URL",或运行
`multica autopilot trigger-rotate-url <autopilot-id> <trigger-id>`。
旧 URL 立即失效。
- 对需要强来源认证的源,等 per-trigger HMAC 签名校验上线v1 URL 仅
bearer。
- 当前能查看 Autopilot 的工作区成员都能看到它的 webhook URL——更细的
权限可见性是后续工作。
### 状态码语义
正常的 no-op 路径都返回 `200 OK` 加 `status` 字段,避免外部 webhook 重试
机制反复打:
- `{"status":"accepted","run_id":"…","autopilot_id":"…","trigger_id":"…"}`
—— 已派发一次 run。
- `{"status":"skipped","run_id":"…","reason":"agent runtime is offline at dispatch time"}`
—— 受派智能体的 runtime 离线,记为 `skipped` run。
- `{"status":"ignored","reason":"trigger_disabled"}` —— 触发器已禁用。
- `{"status":"ignored","reason":"autopilot_paused"}` —— Autopilot 已暂停。
- `{"status":"ignored","reason":"autopilot_archived"}` —— Autopilot 已归档。
非 2xx 是真正的失败:
- `400` —— 无效 JSON、scalar body、空 body。
- `404` —— 未知 token`{"error":"webhook not found"}`)。
- `413` —— 请求体超过 256 KiB。
- `429` —— 单 token 速率限制(默认 60 次 / 分钟)。
### 自托管:配置公开 URL
服务端设置 `MULTICA_PUBLIC_URL`(例如 `https://multica.example.com`)后,
触发器响应里会带绝对的 `webhook_url`UI 直接显示可复制的 URL。没设
时 UI 会用客户端的 API origin 拼出 URL——desktop 和同源 web 没问题,
但自定义反向代理就不行了。Multica **故意不**从 `Host` /
`X-Forwarded-Host` header 推断公开主机,避免反代配置失误时被诱导生成
指向攻击者域名的 webhook URL。
## 看运行历史
每次触发都会产生一条**运行记录**run可以在 Autopilot 详情页的"历史"tab 看到:
- 触发源(`schedule` / `manual`
- 触发源(`schedule` / `manual` / `webhook`
- 开始时间、完成时间
- 状态(`issue_created` / `running` / `completed` / `failed`
- 状态(`issue_created` / `running` / `completed` / `failed` / `skipped`
- 关联的 issue先建 issue 模式)或 `task`(直跑模式)
- 失败原因(如果失败)
- 失败原因(失败或跳过时
## Autopilot 失败会怎样
@@ -72,7 +162,10 @@ multica autopilot trigger <autopilot-id>
## 暂不可用的能力
**Webhook 和 API 触发暂不可用**。Autopilot 的触发器类型在 schema 里留了 `webhook` 和 `api` 两种,但**还没接入站路由**——UI 可以创建这两类触发器,不会真的触发。目前**只有 `schedule` 和手动触发是端到端可用的**。
**API 类型触发器尚未接入。** 触发器 schema 里留了 `api` 类型但没有
入站路由会触发它UI 会给已有的此类记录打 Deprecated 标签,也不显示
copy / rotate 操作。Per-trigger HMAC 签名校验、IP allowlist、按提供方
的事件预设是后续工作v1 URL 仅 bearer。
## 下一步

View File

@@ -35,14 +35,28 @@ These are the core variables you must think about before deploying — some have
## Email configuration
Multica uses [Resend](https://resend.com/) to send verification codes and invite emails.
Multica supports two delivery backends — [Resend](https://resend.com/) for cloud deployments, or an SMTP relay for internal / on-premise networks. `SMTP_HOST` takes priority over `RESEND_API_KEY` when both are set.
### Resend
| Variable | Default | Description |
|---|---|---|
| `RESEND_API_KEY` | empty | Resend API key |
| `RESEND_FROM_EMAIL` | `noreply@multica.ai` | Sender address (must be a domain verified in your Resend account) |
| `RESEND_FROM_EMAIL` | `noreply@multica.ai` | Sender address (must be a domain verified in your Resend account; also reused as the `From:` header when SMTP is in use) |
**Behavior when `RESEND_API_KEY` is unset**: the server does not error, but every email that should have been sent (verification codes, invite links) **is written to the server's stdout only**. Convenient for local development — copy the code out of the server logs; **in production, forgetting to set this creates a silent black hole**, with users never receiving email and no error surfaced.
### SMTP relay
| Variable | Default | Description |
|---|---|---|
| `SMTP_HOST` | empty | SMTP relay hostname. Setting this activates SMTP mode and overrides Resend |
| `SMTP_PORT` | `25` | SMTP port. Use `587` for STARTTLS submission; **port 465 (SMTPS / implicit TLS) is not supported** |
| `SMTP_USERNAME` | empty | SMTP username. Leave empty for unauthenticated relay |
| `SMTP_PASSWORD` | empty | SMTP password |
| `SMTP_TLS_INSECURE` | `false` | Set `true` to skip TLS certificate verification (private CA / self-signed only) |
STARTTLS is upgraded automatically when the server advertises it. The dial timeout is 10s and the whole SMTP session has a 30s deadline, so a black-holed relay can't hang the auth handler.
**Behavior when neither is set**: the server does not error, but every email that should have been sent (verification codes, invite links) **is written to the server's stdout only**. Convenient for local development — copy the code out of the server logs; **in production, forgetting to set this creates a silent black hole**, with users never receiving email and no error surfaced.
## Google OAuth configuration
@@ -114,6 +128,25 @@ Three allowlist layers combine by priority. **If any layer is set to a non-empty
**Invite flows themselves do not check the signup allowlist** — but the invitee must still be able to **sign in** before accepting the invite. If they already have a Multica account (for example from another workspace), they can accept directly, unaffected by the allowlist; **if they have never signed up**, the first step of sign-in (requesting a verification code) still passes through the allowlist check, and an email rejected by `ALLOW_SIGNUP=false` or by `ALLOWED_EMAILS` / `ALLOWED_EMAIL_DOMAINS` **cannot finish signup, and therefore cannot accept the invite**.
## Rate limiting (optional Redis)
Public auth endpoints — `/auth/send-code`, `/auth/verify-code`, `/auth/google` — have per-IP fixed-window rate limiting in front of them. The limiter is backed by Redis. When `REDIS_URL` is unset the middleware is a **no-op** (fail-open) and the backend logs `rate limiting disabled: REDIS_URL not configured` at startup.
| Variable | Default | Description |
|---|---|---|
| `REDIS_URL` | empty | Redis connection URL (for example `redis://localhost:6379/0`). When unset, rate limiting on auth endpoints is disabled. The same Redis is also used by the realtime hub fan-out, the PAT cache, and the daemon-token cache — they all fall back to in-memory / direct-DB mode when unset |
| `RATE_LIMIT_AUTH` | `5` | Max requests per IP per minute against `/auth/send-code` and `/auth/google` |
| `RATE_LIMIT_AUTH_VERIFY` | `20` | Max requests per IP per minute against `/auth/verify-code` |
| `RATE_LIMIT_TRUSTED_PROXIES` | empty | Comma-separated CIDRs whose `X-Forwarded-For` header the limiter is allowed to trust. Empty (the default) means **never trust XFF** — the limiter only uses the direct connection's `RemoteAddr` |
When a request is over the limit, the server replies with `429 Too Many Requests`, `Retry-After: 60`, and body `{"error":"too many requests"}`.
<Callout type="warning">
**Behind a reverse proxy you must set `RATE_LIMIT_TRUSTED_PROXIES`.** Otherwise every real user shares the proxy's IP from the backend's point of view, the whole deployment ends up in one bucket, and `/auth/send-code` becomes 5 req/min for the entire site. Typical values: `127.0.0.1/32,::1/128` for a same-host Caddy / Nginx; the CDN's published ranges for Cloudflare / ALB / CloudFront. Only IPs whose `RemoteAddr` falls inside one of these CIDRs may use `X-Forwarded-For` to identify the client.
</Callout>
This separate `RATE_LIMIT_TRUSTED_PROXIES` is **not** the same as `MULTICA_TRUSTED_PROXIES`, which controls the autopilot-webhook limiter (`/api/webhooks/autopilots/{token}`). Each limiter parses its own list, so a deployment behind a proxy should set both.
## Daemon tuning parameters
The daemon runs on the user's local machine, and its config is read from local environment variables too. The common ones:

View File

@@ -35,14 +35,28 @@ Multica 的 [自部署](/self-host-quickstart) 服务器启动时从环境变量
## 怎么配邮件
Multica [Resend](https://resend.com/) 发验证码和邀请邮件
Multica 支持两种邮件发送通道——[Resend](https://resend.com/) 适合公网部署SMTP relay 适合内网/自部署。同时设置时 `SMTP_HOST` 优先级高于 `RESEND_API_KEY`
### Resend
| 环境变量 | 默认值 | 说明 |
|---|---|---|
| `RESEND_API_KEY` | 空 | Resend API key |
| `RESEND_FROM_EMAIL` | `noreply@multica.ai` | 发件地址(必须是 Resend 账号已验证的域名)|
| `RESEND_FROM_EMAIL` | `noreply@multica.ai` | 发件地址(必须是 Resend 账号已验证的域名;走 SMTP 时同时作为 `From:` 头|
**不设 `RESEND_API_KEY` 时的行为**server 不会报错,但所有本该发出去的邮件(验证码、邀请链接)**只打到 server 的 stdout**。本地开发时方便——你从 server 日志里抄验证码;**生产环境忘记设就是黑洞**,用户收不到邮件也没任何错误提示。
### SMTP relay
| 环境变量 | 默认值 | 说明 |
|---|---|---|
| `SMTP_HOST` | 空 | SMTP relay 主机名。设置后即启用 SMTP 模式并覆盖 Resend |
| `SMTP_PORT` | `25` | SMTP 端口。STARTTLS 提交端口用 `587`**暂不支持 465SMTPS / 隐式 TLS** |
| `SMTP_USERNAME` | 空 | SMTP 用户名。留空表示未认证 relay |
| `SMTP_PASSWORD` | 空 | SMTP 密码 |
| `SMTP_TLS_INSECURE` | `false` | 设为 `true` 跳过 TLS 证书校验(仅限私有 CA / 自签证书)|
服务端 advertise STARTTLS 时会自动升级。dial 超时 10s整个 SMTP 会话有 30s deadline避免 relay 黑洞把 auth handler 挂死。
**两种都不设的行为**server 不会报错,但所有本该发出去的邮件(验证码、邀请链接)**只打到 server 的 stdout**。本地开发方便(你从 server 日志里抄验证码);**生产环境忘记设就是黑洞**,用户收不到邮件也没任何错误提示。
## 怎么配 Google OAuth
@@ -114,6 +128,25 @@ Multica 存储用户上传的附件(评论里的图片、文件等)。**优
**邀请流程本身不检查 signup 白名单**——但被邀请人必须先能**登录**才能接受邀请。如果对方已经有 Multica 账号(比如在其他工作区注册过),可以直接接受,不受白名单影响;**如果对方还没注册过**,他们登录的第一步(发送验证码)仍然会过白名单检查,被 `ALLOW_SIGNUP=false` 或 `ALLOWED_EMAILS` / `ALLOWED_EMAIL_DOMAINS` 拒绝的邮箱**无法完成注册,也就没法接受邀请**。
## 速率限制(可选 Redis
公开认证端点——`/auth/send-code`、`/auth/verify-code`、`/auth/google`——前面挂了按 IP 的固定窗口限流。限流器后端是 Redis。`REDIS_URL` 不设时中间件**直通**fail-open后端启动会打日志 `rate limiting disabled: REDIS_URL not configured`。
| 环境变量 | 默认值 | 说明 |
|---|---|---|
| `REDIS_URL` | 空 | Redis 连接 URL例如 `redis://localhost:6379/0`)。不设时认证端点的限流功能直接关闭。同一个 Redis 也被实时事件 fan-out、PAT 缓存、守护进程 token 缓存复用;不设时这些组件分别回落到内存模式 / 直查 DB |
| `RATE_LIMIT_AUTH` | `5` | 单 IP 每分钟对 `/auth/send-code` 和 `/auth/google` 的最大请求数 |
| `RATE_LIMIT_AUTH_VERIFY` | `20` | 单 IP 每分钟对 `/auth/verify-code` 的最大请求数 |
| `RATE_LIMIT_TRUSTED_PROXIES` | 空 | 逗号分隔的 CIDR 列表,列在内的来源 IP 才允许通过 `X-Forwarded-For` 标识客户端。默认空 = **永不信任 XFF**,限流器只看直连的 `RemoteAddr` |
被限流的请求会返回 `429 Too Many Requests`,带 `Retry-After: 60` 头和 `{"error":"too many requests"}` 响应体。
<Callout type="warning">
**部署在反向代理后面时必须设 `RATE_LIMIT_TRUSTED_PROXIES`。** 否则在后端看来所有真实用户都共用代理那个 IP整个部署落到同一个桶里`/auth/send-code` 会变成全站每分钟只能发 5 次。常见值:本机 Caddy / Nginx 用 `127.0.0.1/32,::1/128`Cloudflare / ALB / CloudFront 用各家公开的 CDN IP 段。只有 `RemoteAddr` 落在这些 CIDR 内的请求才被允许通过 `X-Forwarded-For` 改写客户端 IP。
</Callout>
这里的 `RATE_LIMIT_TRUSTED_PROXIES` 和 `MULTICA_TRUSTED_PROXIES` **不是同一个**变量——后者控制的是 autopilot webhook 端点(`/api/webhooks/autopilots/{token}`)的限流器。两个限流器各自读各自的列表,部署在代理后面的实例需要两个都配上。
## 守护进程的调节参数
守护进程跑在用户本地机器上,配置也是读本地环境变量。常用的几个:

View File

@@ -0,0 +1,169 @@
---
title: Install an agent runtime
description: Multica drives whichever AI coding tools you have on your machine. This page shows you how to install each of the 11 supported tools so the daemon can detect them.
---
import { Callout } from "fumadocs-ui/components/callout";
A **runtime** in Multica is the daemon on your machine paired with one AI coding tool the daemon found on your `PATH`. If the onboarding "Connect a runtime" step shows **No supported tools detected**, it means the daemon scanned `PATH` and didn't find any of the 11 tools it knows how to drive. Install one (or several) of the tools below, then come back to the step and re-scan — the runtime will show up within a few seconds.
This page is the install-side companion to:
- [Daemon and runtimes](/daemon-runtimes) — how detection works
- [AI coding tools matrix](/providers) — what each tool can and can't do (session resumption, MCP, model selection)
<Callout type="info">
The Multica server never sees your API keys or the tools themselves. Everything below — installation, authentication, model access — lives on your local machine. If something fails, it's almost always a local problem.
</Callout>
## Before you start
Two prerequisites apply to **every** tool below:
1. **The Multica daemon must be running.** Either run `multica daemon start` after installing the [Multica CLI](/cli), or use the [Multica desktop app](/desktop-app), which launches the daemon automatically. Without a running daemon there is nothing to detect tools.
2. **The tool's binary must be reachable on `PATH`.** The daemon shells out to each tool by name (see the **Daemon looks for** column in each section). If `which <name>` doesn't find it in your terminal, the daemon won't find it either. After installing, open a fresh terminal (or restart the daemon) so the new `PATH` entry is picked up.
After installing a tool, restart the daemon:
```bash
multica daemon restart
```
Or, in the desktop app, just relaunch the app. The daemon re-scans `PATH` on every start.
## The 11 supported tools
Listed roughly from most to least common. Pick whichever ones you already have credentials for — you don't need all 11.
### Claude Code (Anthropic)
The most complete integration. Session resumption works, MCP works, and it's the **only one of the 11 that actually consumes the `mcp_config` field** on agents (see the [matrix](/providers#mcp-configuration-only-claude-code-actually-reads-it)).
| | |
|---|---|
| Daemon looks for | `claude` |
| Install | Follow the official guide at [claude.com/claude-code](https://www.claude.com/claude-code). The standard route is the npm package `@anthropic-ai/claude-code` (Node.js 18+ required). |
| Authentication | Run `claude` once and follow the in-CLI login flow, or set `ANTHROPIC_API_KEY`. |
| Notes | First-choice recommendation for new users. |
### Codex (OpenAI)
JSON-RPC 2.0 transport with finer-grained approval gates. **Session resumption code exists but is currently unreachable** — pick Claude Code or one of the ACP family if you need resume.
| | |
|---|---|
| Daemon looks for | `codex` |
| Install | Follow the official guide at [github.com/openai/codex](https://github.com/openai/codex). The standard route is the npm package `@openai/codex`. |
| Authentication | `codex login` (browser-based) or `OPENAI_API_KEY`. |
### Cursor (Anysphere)
The CLI counterpart to the Cursor editor. **Session resumption is broken** — Cursor's CLI doesn't return a session id, so the value you pass on resume is always invalid.
| | |
|---|---|
| Daemon looks for | `cursor-agent` |
| Install | Install the [Cursor editor](https://cursor.com/) and then the CLI per their docs at [docs.cursor.com](https://docs.cursor.com/). The binary name is `cursor-agent`, not `cursor`. |
| Authentication | Sign in through the Cursor editor; the CLI reuses that session. |
### GitHub Copilot
Model routing goes through your GitHub account entitlement — the tool doesn't pick a model itself; GitHub decides which model you get.
| | |
|---|---|
| Daemon looks for | `copilot` |
| Install | See GitHub's CLI docs at [github.com/github/copilot-cli](https://github.com/github/copilot-cli). |
| Authentication | Browser-based GitHub login through the CLI. |
| Notes | Requires an active GitHub Copilot subscription on the signed-in account. |
### Gemini (Google)
Supports the Gemini 2.5 and 3 series. No session resumption, no MCP — suitable for one-shot tasks.
| | |
|---|---|
| Daemon looks for | `gemini` |
| Install | Follow the official guide at [github.com/google-gemini/gemini-cli](https://github.com/google-gemini/gemini-cli). The standard route is the npm package `@google/gemini-cli`. |
| Authentication | `gemini` will prompt for a Google account login, or set `GEMINI_API_KEY`. |
### OpenCode (SST)
Open-source CLI agent. Dynamically discovers available models from its own configuration file — good fit for users who want to bring their own model catalog.
| | |
|---|---|
| Daemon looks for | `opencode` |
| Install | Follow the official guide at [opencode.ai](https://opencode.ai/) or the GitHub repo at [github.com/sst/opencode](https://github.com/sst/opencode). The typical route is the install script or the npm package. |
| Authentication | Configure your model provider(s) per OpenCode's docs (Anthropic, OpenAI, etc.). |
### Kiro CLI (Amazon)
ACP-over-stdio transport. Session resumption works through ACP `session/load`; skills are copied into `.kiro/skills/`.
| | |
|---|---|
| Daemon looks for | `kiro-cli` |
| Install | See the Kiro docs at [kiro.dev](https://kiro.dev/). The binary name is `kiro-cli`, not `kiro`. |
| Authentication | AWS-account-based; follow Kiro's own onboarding. |
### Kimi (Moonshot)
ACP-protocol agent, primarily aimed at the Chinese market. Skills live under `.kimi/skills/` (native discovery).
| | |
|---|---|
| Daemon looks for | `kimi` |
| Install | Follow the official guide at [github.com/MoonshotAI/kimi-cli](https://github.com/MoonshotAI/kimi-cli). |
| Authentication | Moonshot API key, configured per the vendor's docs. |
### Hermes (Nous Research)
ACP-protocol agent (shares the transport with Kimi). Session resumption works. The skill injection path falls back to the generic `.agent_context/skills/` — verify your skills are loading before relying on them.
| | |
|---|---|
| Daemon looks for | `hermes` |
| Install | See Nous Research's repository at [github.com/NousResearch](https://github.com/NousResearch) for the latest CLI distribution. |
| Authentication | Per the vendor's docs. |
### OpenClaw
Open-source CLI agent orchestrator. **Model is bound at the agent layer** (`openclaw agents add --model`) — it can't be overridden per task, and you can't pass `--model` or `--system-prompt` from Multica.
| | |
|---|---|
| Daemon looks for | `openclaw` |
| Install | See the project at [github.com/openclaw-org/openclaw](https://github.com/openclaw-org/openclaw) (community-maintained). |
| Authentication | Configure the underlying model provider per OpenClaw's docs. |
### Pi (Inflection AI)
Minimalist. **Session resumption is unusual** — the resume id is the path to a session file on disk, not a string id.
| | |
|---|---|
| Daemon looks for | `pi` |
| Install | See Inflection's CLI docs at [pi.ai](https://pi.ai/). |
| Authentication | Per the vendor's docs. |
## After installing
1. **Confirm the binary is on `PATH`.** Open a fresh terminal and run `which <name>` (for example `which claude`, `which cursor-agent`, `which kiro-cli`). If it prints a path, the daemon will find it. If it prints nothing, fix your shell `PATH` first (the typical cause is a per-shell rc file that wasn't reloaded).
2. **Restart the daemon.** `multica daemon restart`, or relaunch the desktop app. The daemon only scans `PATH` at startup.
3. **Check the Runtimes page.** In the Multica UI, the **Runtimes** page should now list one row per `(workspace × tool)` combination. If the row says "offline", see [Daemon and runtimes → When a runtime is marked offline](/daemon-runtimes#when-a-runtime-is-marked-offline).
4. **Go back to onboarding.** The "Connect a runtime" step polls and will pick up the new runtime within a few seconds — no need to refresh.
## Troubleshooting
- **`which` finds the binary but the daemon doesn't.** The daemon was started with an older `PATH`. Restart it.
- **The binary exists but launching fails.** Run the tool's own `--version` or `--help` once from the terminal — most failures here are missing auth, expired tokens, or a Node.js / runtime mismatch.
- **The Runtimes page shows the row, but tasks fail immediately.** Check `multica daemon logs -f` while triggering a task. The daemon surfaces the tool's own error output.
For broader symptoms, see the [Troubleshooting guide](/troubleshooting).
## Next
- [Daemon and runtimes](/daemon-runtimes) — how detection, heartbeats, and offline handling work
- [AI coding tools matrix](/providers) — capability differences once a tool is connected
- [Creating and configuring agents](/agents-create) — pick a tool for your agent and start running tasks

View File

@@ -0,0 +1,169 @@
---
title: 安装一个 Agent 运行时
description: Multica 驱动本机上已安装的 AI 编程工具。这一页讲清楚怎么安装目前支持的 11 款工具,让守护进程能扫到。
---
import { Callout } from "fumadocs-ui/components/callout";
在 Multica 里,一个**运行时**runtime就是你机器上的守护进程配上守护进程在 `PATH` 里扫到的某一款 AI 编程工具。如果 onboarding 的 "连接运行时" 这一步显示 **未检测到支持的工具**,说明守护进程扫了 `PATH`,但 11 款它认得的工具一个都没找到。装下面任意一款(或几款),回到这一步重新扫描,几秒内运行时就会出现。
这一页是装机的入口,和它配套的是:
- [守护进程与运行时](/zh/daemon-runtimes) — 检测是怎么工作的
- [AI 编程工具矩阵](/zh/providers) — 每款工具的能力差异会话续接、MCP、模型选择
<Callout type="info">
Multica 服务器从不接触你的 API key也不接触工具本身。下面这些操作 —— 安装、登录、模型访问 —— 全部发生在你本机。出问题几乎都是本地问题。
</Callout>
## 开始前
下面每一款工具都有两个共同前提:
1. **Multica 守护进程在运行。** 装完 [Multica CLI](/zh/cli) 后跑 `multica daemon start`;或者用 [Multica 桌面端](/zh/desktop-app),它启动时自动拉起守护进程。守护进程没起来,就没人去扫工具。
2. **工具的可执行文件在 `PATH` 上。** 守护进程通过名字 shell out 调起工具(见每一节里 **守护进程扫描** 那行的命令名)。终端里 `which <名字>` 找不到,守护进程也找不到。装完后打开新终端(或者重启守护进程),让新的 `PATH` 生效。
装完一款工具后,重启守护进程:
```bash
multica daemon restart
```
桌面端的话,重启 app 即可。守护进程只在启动时扫一次 `PATH`。
## 11 款支持的工具
大致按常见程度排序。挑你已经有账号 / API key 的那几款就行 —— 不需要 11 个全装。
### Claude CodeAnthropic
集成最完整的一款。会话续接好用MCP 好用,而且 **11 款里只有它真正会读 agent 配置里的 `mcp_config` 字段**(见[矩阵](/zh/providers))。
| | |
|---|---|
| 守护进程扫描 | `claude` |
| 安装 | 看官方指引 [claude.com/claude-code](https://www.claude.com/claude-code)。常见装法是 npm 包 `@anthropic-ai/claude-code`(需要 Node.js 18+)。 |
| 认证 | 跑一次 `claude`,跟着 CLI 里的登录流程走;或者设置 `ANTHROPIC_API_KEY`。 |
| 备注 | 新用户首选。 |
### CodexOpenAI
JSON-RPC 2.0 传输,审批粒度更细。**会话续接的代码在,但调不到** —— 要续接的话选 Claude Code 或 ACP 系列。
| | |
|---|---|
| 守护进程扫描 | `codex` |
| 安装 | 看官方指引 [github.com/openai/codex](https://github.com/openai/codex)。常见装法是 npm 包 `@openai/codex`。 |
| 认证 | `codex login`(浏览器登录),或 `OPENAI_API_KEY`。 |
### CursorAnysphere
Cursor 编辑器的 CLI 对应物。**会话续接是坏的** —— Cursor CLI 不返回 session id你传过去的续接 id 永远无效。
| | |
|---|---|
| 守护进程扫描 | `cursor-agent` |
| 安装 | 先装 [Cursor 编辑器](https://cursor.com/),再按 [docs.cursor.com](https://docs.cursor.com/) 的说明装 CLI。可执行文件叫 `cursor-agent`,不是 `cursor`。 |
| 认证 | 在 Cursor 编辑器里登录CLI 复用同一份会话。 |
### GitHub Copilot
模型走的是你 GitHub 账号的 entitlement —— 工具自己不挑模型GitHub 决定你拿到哪个模型。
| | |
|---|---|
| 守护进程扫描 | `copilot` |
| 安装 | 看 GitHub 的 CLI 文档 [github.com/github/copilot-cli](https://github.com/github/copilot-cli)。 |
| 认证 | CLI 里走 GitHub 浏览器登录。 |
| 备注 | 登录账号必须有有效的 GitHub Copilot 订阅。 |
### GeminiGoogle
支持 Gemini 2.5 和 3 系列。没有会话续接,没有 MCP —— 适合一次性、无需上下文记忆的任务。
| | |
|---|---|
| 守护进程扫描 | `gemini` |
| 安装 | 看官方指引 [github.com/google-gemini/gemini-cli](https://github.com/google-gemini/gemini-cli)。常见装法是 npm 包 `@google/gemini-cli`。 |
| 认证 | 跑 `gemini` 会提示 Google 账号登录,或设置 `GEMINI_API_KEY`。 |
### OpenCodeSST
开源 CLI agent。会从自己的配置文件里动态发现可用模型 —— 适合想自己掌控模型清单的用户。
| | |
|---|---|
| 守护进程扫描 | `opencode` |
| 安装 | 看官方指引 [opencode.ai](https://opencode.ai/) 或仓库 [github.com/sst/opencode](https://github.com/sst/opencode)。一般是装脚本或 npm 包。 |
| 认证 | 按 OpenCode 的文档配你自己的模型供应商Anthropic、OpenAI 等)。 |
### Kiro CLIAmazon
ACP-over-stdio 传输。会话续接通过 ACP `session/load` 工作skills 拷到 `.kiro/skills/`。
| | |
|---|---|
| 守护进程扫描 | `kiro-cli` |
| 安装 | 看 Kiro 的文档 [kiro.dev](https://kiro.dev/)。可执行文件叫 `kiro-cli`,不是 `kiro`。 |
| 认证 | 基于 AWS 账号,按 Kiro 自己的引导走。 |
### KimiMoonshot
ACP 协议 agent主要面向中国市场。Skills 放在 `.kimi/skills/`(原生发现路径)。
| | |
|---|---|
| 守护进程扫描 | `kimi` |
| 安装 | 看官方指引 [github.com/MoonshotAI/kimi-cli](https://github.com/MoonshotAI/kimi-cli)。 |
| 认证 | Moonshot API key按厂商文档配置。 |
### HermesNous Research
ACP 协议 agent和 Kimi 共享传输层。会话续接可用。Skill 注入用的是通用回退路径 `.agent_context/skills/` —— 用之前先验证 skills 真的被加载了。
| | |
|---|---|
| 守护进程扫描 | `hermes` |
| 安装 | 看 Nous Research 的仓库 [github.com/NousResearch](https://github.com/NousResearch) 获取最新 CLI。 |
| 认证 | 按厂商文档。 |
### OpenClaw
开源 CLI agent 编排器。**模型绑在 agent 层**`openclaw agents add --model`)—— 不能按任务覆盖,从 Multica 也传不了 `--model` / `--system-prompt`。
| | |
|---|---|
| 守护进程扫描 | `openclaw` |
| 安装 | 看项目 [github.com/openclaw-org/openclaw](https://github.com/openclaw-org/openclaw)(社区维护)。 |
| 认证 | 按 OpenClaw 的文档配底层模型供应商。 |
### PiInflection AI
极简风格。**会话续接的方式不太一样** —— resume id 是磁盘上的会话文件路径,不是字符串 id。
| | |
|---|---|
| 守护进程扫描 | `pi` |
| 安装 | 看 Inflection 的 CLI 文档 [pi.ai](https://pi.ai/)。 |
| 认证 | 按厂商文档。 |
## 装完之后
1. **确认可执行文件在 `PATH` 上。** 开一个新终端,跑 `which <名字>`(比如 `which claude`、`which cursor-agent`、`which kiro-cli`)。打印出路径,守护进程就找得到;什么都不打印,先修 shell 的 `PATH`(最常见原因是 rc 文件没重新加载)。
2. **重启守护进程。** `multica daemon restart`,或者重启桌面端。守护进程只在启动时扫一次 `PATH`。
3. **看 Runtimes 页面。** Multica UI 的 **Runtimes** 页应该会出现一行 `(工作区 × 工具)`。如果显示 "offline",看[守护进程与运行时 → 运行时何时被标记为离线](/zh/daemon-runtimes#运行时何时被标记为离线)。
4. **回到 onboarding。** "连接运行时" 这一步会一直轮询,几秒内就能扫到新运行时,不需要手动刷新。
## 排错
- **`which` 找得到,但守护进程找不到。** 守护进程是用旧 `PATH` 启的,重启它。
- **可执行文件在,但启动就失败。** 在终端单独跑一次工具的 `--version` 或 `--help`绝大多数失败都是登录没做、token 过期、Node.js / 运行时版本不对。
- **Runtimes 页面看到行,但任务一跑就失败。** 一边触发任务一边跑 `multica daemon logs -f`。守护进程会把工具自己的报错原样吐出来。
更宽的症状看[排错指南](/zh/troubleshooting)。
## 接下来
- [守护进程与运行时](/zh/daemon-runtimes) — 检测、心跳、离线处理
- [AI 编程工具矩阵](/zh/providers) — 工具连上之后的能力差异
- [创建并配置智能体](/zh/agents-create) — 给你的 agent 挑一款工具,开始跑任务

View File

@@ -19,6 +19,7 @@
"squads",
"---How agents run---",
"daemon-runtimes",
"install-agent-runtime",
"tasks",
"providers",
"---Collaborating with agents---",

View File

@@ -45,6 +45,10 @@ Once it's up:
- **Frontend**: [http://localhost:3000](http://localhost:3000)
- **Backend**: [http://localhost:8080](http://localhost:8080)
<Callout type="info">
**Ports listen on `127.0.0.1` only.** `docker-compose.selfhost.yml` binds every published port to loopback — `ss -tlnp` will not show `0.0.0.0:8080`, and the services are unreachable from other machines by design. The default `JWT_SECRET` and Postgres credentials must never sit on the open internet. For cross-machine access, front the stack with a reverse proxy that terminates TLS — see [Step 5b — Cross-machine: front with a reverse proxy](#5b-cross-machine-front-with-a-reverse-proxy).
</Callout>
## 2. Important: keep production safety on
<Callout type="warning">
@@ -59,7 +63,9 @@ Before any public deployment, make sure `.env` has `APP_ENV=production` and `MUL
Without email configured, your users can't receive verification codes by email; the server prints generated codes to stdout instead.
To actually send verification emails:
Two delivery backends are supported — pick whichever fits your network:
**Option A — Resend (cloud / public-internet deployments):**
1. Sign up at [Resend](https://resend.com/) and get an API key
2. Verify a sending domain you control
@@ -70,36 +76,80 @@ To actually send verification emails:
RESEND_FROM_EMAIL=noreply@yourdomain.com
```
4. Restart: `docker compose -f docker-compose.selfhost.yml restart backend`
**Option B — SMTP relay (internal networks / on-premise):**
For more auth configuration (OAuth, signup allowlist), see [Auth setup](/auth-setup).
Use this when the deployment can't reach `api.resend.com`, or you already have an internal mail relay (Exchange, Postfix, on-prem SendGrid, etc.). `SMTP_HOST` takes priority over Resend when both are set.
```bash
SMTP_HOST=smtp.internal.example.com
SMTP_PORT=587 # default 25; use 587 for STARTTLS submission
SMTP_USERNAME=multica # leave empty for unauthenticated relay
SMTP_PASSWORD=...
RESEND_FROM_EMAIL=noreply@yourdomain.com # reused as the From: header
```
Then restart: `docker compose -f docker-compose.selfhost.yml restart backend`.
For more auth configuration (OAuth, signup allowlist) and the full SMTP variable reference, see [Auth setup](/auth-setup) and [Environment variables → Email](/environment-variables#email-configuration).
## 4. First login + create a workspace
Open [http://localhost:3000](http://localhost:3000):
- Enter your email
- Grab the verification code from the Resend email (or, if you haven't configured Resend, from the server container stdout — look for the `[DEV] Verification code` line)
- Grab the verification code from your configured email backend (Resend or SMTP relay); if neither is configured, copy it from the server container stdout — look for the `[DEV] Verification code` line
- Do not use `888888` unless you explicitly set `MULTICA_DEV_VERIFICATION_CODE=888888` on a non-production private instance
- Log in and create your first workspace
## 5. Point the CLI at your own server
The CLI install is the same as in [Cloud quickstart → 2. Install the CLI](/cloud-quickstart#2-install-the-multica-cli) — Homebrew / script / PowerShell, pick one. Once installed, **use the self-host variant of the setup command**:
The CLI install is the same as in [Cloud quickstart → 2. Install the CLI](/cloud-quickstart#2-install-the-multica-cli) — Homebrew / script / PowerShell, pick one.
```bash
multica setup self-host --server-url http://<your-server-address>:8080 --app-url http://<your-server-address>:3000
```
### 5a. Same machine
If you're running everything on one local machine:
If the CLI and the server run on the same host, the defaults already work:
```bash
multica setup self-host
```
That defaults to `http://localhost:8080` (backend) and `http://localhost:3000` (frontend).
That points the CLI at `http://localhost:8080` (backend) and `http://localhost:3000` (frontend), takes you through browser login, stores the PAT locally, and **starts the daemon automatically**.
`setup self-host` takes you through browser login, stores the PAT locally, and **starts the daemon automatically**.
### 5b. Cross-machine: front with a reverse proxy
Because the compose stack only listens on `127.0.0.1`, a daemon on a different machine cannot reach `http://<server-ip>:8080` directly — and you do not want it to, since the default `JWT_SECRET` would otherwise be reachable from the open internet. Put a reverse proxy on the server that terminates TLS and forwards to `127.0.0.1:8080` (backend) and `127.0.0.1:3000` (frontend), then point the CLI at the public HTTPS URL:
```bash
multica setup self-host \
--server-url https://<your-domain> \
--app-url https://<your-domain>
```
A minimal Caddyfile that fronts both the frontend and the backend (with WebSocket support, which the daemon and the web app both need) on a single hostname:
```nginx
multica.example.com {
# WebSocket route — must come before the catch-all
@ws path /ws /ws/*
handle @ws {
reverse_proxy 127.0.0.1:8080 {
flush_interval -1
}
}
# Backend API
handle /api/* {
reverse_proxy 127.0.0.1:8080
}
# Everything else → frontend
reverse_proxy 127.0.0.1:3000
}
```
After bringing the proxy up, set `FRONTEND_ORIGIN=https://multica.example.com` in the server's `.env` and restart the backend — otherwise the WebSocket origin check will reject the browser ([Troubleshooting → WebSocket can't connect](/troubleshooting#websocket-cant-connect)).
[Cloudflare Tunnel](https://developers.cloudflare.com/cloudflare-one/connections/connect-networks/) is another solid option — it gives you TLS and a public hostname without exposing any port on the host at all. An Nginx equivalent (separate `app.` / `api.` hostnames, `proxy_set_header Upgrade` for WebSockets) works just as well; the key requirements are TLS termination and forwarding the `Upgrade` header on `/ws`.
## 6. Create an agent + assign your first task
@@ -108,7 +158,7 @@ Same flow as Cloud — see [Cloud quickstart → Steps 5-6](/cloud-quickstart#5-
## Common issues
- **Backend won't start**: check container logs with `docker compose -f docker-compose.selfhost.yml logs backend`; usually it's a bad `DATABASE_URL` or `JWT_SECRET` in `.env`
- **Verification code not received**: Resend isn't configured → look for `[DEV] Verification code` in `docker compose logs backend`
- **Verification code not received**: no email backend is configured (neither Resend nor SMTP) → look for `[DEV] Verification code` in `docker compose logs backend`
- **WebSocket won't connect**: for public deployments you must set `FRONTEND_ORIGIN` to your real frontend domain; see [Troubleshooting → WebSocket won't connect](/troubleshooting#websocket-wont-connect)
## Next steps

View File

@@ -44,6 +44,10 @@ make selfhost
- **前端**[http://localhost:3000](http://localhost:3000)
- **后端**[http://localhost:8080](http://localhost:8080)
<Callout type="info">
**所有端口只监听 `127.0.0.1`。** `docker-compose.selfhost.yml` 把每个 publish 出来的端口都绑到 loopback —— `ss -tlnp` 不会看到 `0.0.0.0:8080`,外网/其它机器默认根本连不上。这是为了避免默认 `JWT_SECRET` 和 Postgres 凭据被直接暴露到公网。要做跨机访问,请用反向代理在前面终结 TLS详见下方 [Step 5b —— 跨机访问:用反向代理把服务挡在前面](#5b-跨机访问用反向代理把服务挡在前面)。
</Callout>
## 2. 重要:保持生产安全配置
<Callout type="warning">
@@ -58,7 +62,9 @@ make selfhost
如果不配邮件用户无法通过邮件收到验证码server 会把生成的验证码打印到 stdout。
要真的发验证码邮件
支持两种发送通道,按部署环境二选一
**Option A — Resend公网/云端部署):**
1. 在 [Resend](https://resend.com/) 注册并拿一个 API key
2. 验证一个你控制的发件域名
@@ -69,36 +75,80 @@ make selfhost
RESEND_FROM_EMAIL=noreply@yourdomain.com
```
4. 重启:`docker compose -f docker-compose.selfhost.yml restart backend`
**Option B — SMTP relay内网/自部署):**
更多 auth 配置OAuth、注册白名单见 [登录与注册配置](/auth-setup)
适合内网无法访问 `api.resend.com`或已经有内部邮件中继Exchange、Postfix、自部署 SendGrid 等)的场景。同时设置时 `SMTP_HOST` 优先级高于 Resend
```bash
SMTP_HOST=smtp.internal.example.com
SMTP_PORT=587 # 默认 25STARTTLS 提交端口用 587
SMTP_USERNAME=multica # 留空则使用未认证 relay
SMTP_PASSWORD=...
RESEND_FROM_EMAIL=noreply@yourdomain.com # 同时作为 SMTP From: 头
```
之后重启:`docker compose -f docker-compose.selfhost.yml restart backend`。
更多 auth 配置OAuth、注册白名单以及完整的 SMTP 变量说明见 [登录与注册配置](/auth-setup) 和 [环境变量](/environment-variables)。
## 4. 首次登录 + 创建工作区
打开 [http://localhost:3000](http://localhost:3000)
- 输入你的邮箱
- 从 Resend 邮件里拿验证码(或者前面没配 Resend 的话从 server 容器的 stdout 里抄 `[DEV] Verification code` 这行
- 从你配置的邮件后端Resend 或 SMTP relay收到的邮件里拿验证码两者都没配的话从 server 容器的 stdout 里抄 `[DEV] Verification code` 这行
- 不要直接使用 `888888`;只有在非 production 私有实例上显式设置 `MULTICA_DEV_VERIFICATION_CODE=888888` 后它才会生效
- 登录后创建第一个工作区
## 5. 连接命令行工具到你自己的 server
命令行装法和 [Cloud 快速上手 → 2. 装命令行工具](/cloud-quickstart#2-装-multica-命令行工具) 一样——Homebrew / 脚本 / PowerShell 任选。装好之后,**用 self-host 版本的 setup 命令**
命令行装法和 [Cloud 快速上手 → 2. 装命令行工具](/cloud-quickstart#2-装-multica-命令行工具) 一样——Homebrew / 脚本 / PowerShell 任选。
```bash
multica setup self-host --server-url http://<你的服务器地址>:8080 --app-url http://<你的服务器地址>:3000
```
### 5a. 同一台机器
本地就是一台电脑跑整套的话
CLI 和 server 在同一台机器上时,默认参数就够用
```bash
multica setup self-host
```
默认连 `http://localhost:8080`backend+ `http://localhost:3000`frontend
会自动连 `http://localhost:8080`backend+ `http://localhost:3000`frontend,引导你在浏览器里登录、把 PAT 存到本地、**自动启动守护进程**
`setup self-host` 会让你在浏览器里完成登录,把 PAT 存到本地,**自动启动守护进程**。
### 5b. 跨机访问:用反向代理把服务挡在前面
因为 compose 默认只监听 `127.0.0.1`,从别的机器跑的 daemon 是连不上 `http://<server-ip>:8080` 的——这也是有意为之,否则默认 `JWT_SECRET` 等于直接暴露在公网。正确做法是在 server 上跑一个反向代理Caddy / nginx / Cloudflare Tunnel由它终结 TLS再反代到 `127.0.0.1:8080`backend和 `127.0.0.1:3000`frontend。然后把 CLI 指到公开的 HTTPS 域名:
```bash
multica setup self-host \
--server-url https://<你的域名> \
--app-url https://<你的域名>
```
最小可用的 Caddyfile单域名同时挂前后端带 WebSocket 转发daemon 和网页端都依赖):
```nginx
multica.example.com {
# WebSocket 路由——必须在 catch-all 之前
@ws path /ws /ws/*
handle @ws {
reverse_proxy 127.0.0.1:8080 {
flush_interval -1
}
}
# Backend API
handle /api/* {
reverse_proxy 127.0.0.1:8080
}
# 其它请求 → 前端
reverse_proxy 127.0.0.1:3000
}
```
代理起好之后,记得在 server 的 `.env` 里把 `FRONTEND_ORIGIN` 设成 `https://multica.example.com` 并重启后端,否则 WebSocket 的 origin 校验会把浏览器拒掉(见 [故障排查 → WebSocket 连不上](/troubleshooting#websocket-连不上))。
[Cloudflare Tunnel](https://developers.cloudflare.com/cloudflare-one/connections/connect-networks/) 也是不错的选择——它直接给一个公开域名 + TLShost 上不用对外暴露任何端口。Nginx 也能做(分 `app.` / `api.` 两个域名 + `proxy_set_header Upgrade` 转 WebSocket关键就是终结 TLS、并在 `/ws` 上转发 `Upgrade` 头。
## 6. 创建智能体 + 分配第一个任务
@@ -107,7 +157,7 @@ multica setup self-host
## 常见问题
- **后端起不来**:看容器日志 `docker compose -f docker-compose.selfhost.yml logs backend`;常见是 `.env` 里 `DATABASE_URL` 或 `JWT_SECRET` 有问题
- **验证码收不到**:没配 Resend → 从 `docker compose logs backend` 里找 `[DEV] Verification code`
- **验证码收不到**:没配任何邮件后端Resend 和 SMTP 都没设) → 从 `docker compose logs backend` 里找 `[DEV] Verification code`
- **WebSocket 连不上**:公网部署必须设 `FRONTEND_ORIGIN` 成你真实的前端域名;见 [故障排查 → WebSocket 连不上](/troubleshooting#websocket-连不上)
## 下一步

View File

@@ -18,7 +18,7 @@
"fumadocs-ui": "^15.5.2",
"lucide-react": "catalog:",
"mermaid": "^11.14.0",
"next": "^15.3.3",
"next": "^15.5.16",
"next-themes": "^0.4.6",
"react": "catalog:",
"react-dom": "catalog:"

View File

@@ -0,0 +1,20 @@
import { Skeleton } from "@multica/ui/components/ui/skeleton";
// Rendered by Next.js as the Suspense fallback during route transitions
// inside the (dashboard) segment. Scoped to this segment only — auth /
// landing keep their own full-screen fallbacks.
export default function DashboardLoading() {
return (
<div className="flex h-svh w-full flex-col">
<div className="flex h-12 shrink-0 items-center gap-3 border-b px-4">
<Skeleton className="h-5 w-5 rounded-md" />
<Skeleton className="h-4 w-32" />
</div>
<div className="flex-1 space-y-2 p-4">
{Array.from({ length: 8 }).map((_, i) => (
<Skeleton key={i} className="h-9 w-full" />
))}
</div>
</div>
);
}

View File

@@ -0,0 +1,13 @@
"use client";
import { use } from "react";
import { MemberDetailPage } from "@multica/views/members";
export default function MemberDetailRoute({
params,
}: {
params: Promise<{ id: string }>;
}) {
const { id } = use(params);
return <MemberDetailPage userId={id} />;
}

View File

@@ -0,0 +1,26 @@
"use client";
import { use } from "react";
import { useSearchParams } from "next/navigation";
import { AttachmentPreviewPage } from "@multica/views/attachments";
import { ErrorBoundary } from "@multica/ui/components/common/error-boundary";
// Lives at /:slug/attachments/:id/preview — OUTSIDE the (dashboard) group on
// purpose. The dashboard layout adds a left sidebar + top chrome; this page
// wants the full viewport for the HTML iframe. Workspace resolution still
// happens in the parent [workspaceSlug] layout so useWorkspaceId() works.
export default function AttachmentPreviewWebPage({
params,
}: {
params: Promise<{ id: string }>;
}) {
const { id } = use(params);
const search = useSearchParams();
const filename = search.get("name") ?? undefined;
return (
<ErrorBoundary resetKeys={[id]}>
<AttachmentPreviewPage attachmentId={id} filename={filename} />
</ErrorBoundary>
);
}

View File

@@ -284,6 +284,59 @@ export function createEnDict(allowSignup: boolean): LandingDict {
fixes: "Bug Fixes",
},
entries: [
{
version: "0.3.2",
date: "2026-05-18",
title:
"Webhook Autopilots, Clearer Workboards & Better Runtime Control",
changes: [],
features: [
"Autopilots can now start from webhook events, show delivery history, and replay a delivery when a connected system needs another attempt",
"Issue boards can group work by assignee, show linked pull request status, and include start dates for clearer planning",
"Runtime pages now have a redesigned machine view plus time and task trends in usage charts",
"Skills can be copied from local runtimes in bulk, making workspace setup faster",
"HTML attachments and HTML code blocks can be previewed directly inside issue discussions",
],
improvements: [
"Failed issue actions now show clearer error messages so teams can understand what happened without digging through logs",
"GitHub-linked pull requests now surface CI and merge-conflict status inside Multica",
"Self-hosted deployments get safer defaults and clearer guidance for reverse proxies, auth limits, and local-only services",
"Search results are ranked more usefully and include better snippets",
],
fixes: [
"Autopilot-created issues can repeat reliably and are attributed to the right assignee agent",
"Runtime setup now prefers the local machine by default and uses cleaner labels in machine lists",
"Squad pages scroll correctly and show which members are already working",
"Desktop zoom shortcuts work again across the common keyboard combinations",
"Auth, dependency, and local-service updates improve the safety of hosted and self-hosted deployments",
],
},
{
version: "0.3.1",
date: "2026-05-15",
title: "Faster Navigation, Background Updates & More Reliable Squads",
changes: [],
features: [
"Member and agent detail pages now show related tasks so teams can review who is working on what",
"The desktop app downloads updates in the background so a new version is ready when you are",
"Self-hosted deployments can send email through SMTP as an alternative to Resend",
"Create Squad has a clearer setup flow with member selection that works better for team coordination",
],
improvements: [
"Page transitions are faster, with issue pages prepared ahead of time and smoother loading states",
"Long issue activity blocks collapse so comments and conclusions are easier to scan",
"Agents and Squads remember the Mine/All view when you return to the list",
"Repository setup accepts more SSH URL formats across settings, projects, and quick create",
"Squad handoffs are more dependable when agents have multiple roles or delegate to a specific member",
],
fixes: [
"Self-hosted local file cards render and preview correctly",
"Agent-run tasks are more dependable when local tools or skills need to be found automatically",
"Claude usage totals match more of the model names reported by connected tools",
"After switching workspaces, live updates come from the correct workspace and show the right source",
"Chat session menus and runtime names hold their shape in narrower spaces",
],
},
{
version: "0.3.0",
date: "2026-05-14",

View File

@@ -284,6 +284,58 @@ export function createZhDict(allowSignup: boolean): LandingDict {
fixes: "问题修复",
},
entries: [
{
version: "0.3.2",
date: "2026-05-18",
title: "Webhook 自动任务、更清晰的工作看板与更稳的运行环境",
changes: [],
features: [
"Autopilot 现在可以由 webhook 事件触发,并能查看投递记录,在外部系统需要时重新投递一次",
"Issue 看板支持按负责人分组,展示关联 Pull Request 状态,并加入开始日期,排期更清楚",
"Runtime 页面升级了机器视图,并在用量图表中加入时间和任务趋势",
"Skills 支持从本地 runtime 批量复制到 workspace团队初始化更快",
"HTML 附件和 HTML 代码块可以直接在 Issue 讨论中预览",
],
improvements: [
"Issue 操作失败时会显示更明确的错误原因,团队不用翻日志也能理解发生了什么",
"关联 GitHub 的 Pull Request 会在 Multica 内展示 CI 和合并冲突状态",
"自托管部署获得更安全的默认配置,并补充反向代理、登录限制和本地服务的说明",
"搜索结果排序更准确,也会展示更有帮助的摘要片段",
],
fixes: [
"Autopilot 创建 Issue 时可以稳定重复触发,并正确归属到负责的 assignee agent",
"Runtime 设置默认优先选择本地机器,机器列表中的名称也更清晰",
"Squad 页面可以正常滚动,并能看到成员当前是否已经在处理工作",
"桌面端缩放快捷键在常见组合下恢复正常",
"登录、安全补丁和本地服务配置更新,让托管版和自托管部署都更安全",
],
},
{
version: "0.3.1",
date: "2026-05-15",
title: "更快的导航、后台更新与更可靠的小队协作",
changes: [],
features: [
"成员和 agent 详情页现在可以看到关联任务,方便回看每个人和每个 agent 正在推进的工作",
"桌面端会在后台提前下载新版本,等你准备好时再安装更新",
"自托管部署可以使用 SMTP 发送邮件,不再只依赖 Resend",
"创建 Squad 的流程更清晰,成员选择和初始设置更适合团队协作",
],
improvements: [
"页面切换更快Issue 页面会提前准备内容,并在加载时展示更自然的过渡状态",
"Issue 时间线会把较长的活动记录收起,重点评论和结论更容易扫读",
"Agents 和 Squads 页会记住你上次选择的 Mine/All 视图,返回列表时不再重置",
"仓库设置、项目资源和快速创建流程更好地支持 SSH 形式的仓库地址",
"小队分工更稳定leader 能正确接续双角色 agent 的回复,也会更明确地把任务交给指定成员",
],
fixes: [
"自托管本地文件卡片可以正常展示和预览",
"Agent 在自动寻找本地工具、加载技能以及无人值守运行时更可靠",
"Claude 用量统计能识别更多接入工具上报的模型名称",
"切换 workspace 后,实时更新会来自正确的 workspace消息来源也更准确",
"聊天会话下拉菜单和 runtime 名称展示在窄空间里更稳定",
],
},
{
version: "0.3.0",
date: "2026-05-14",

View File

@@ -50,7 +50,7 @@
"linkify-it": "^5.0.0",
"lowlight": "^3.3.0",
"lucide-react": "catalog:",
"next": "^16.2.3",
"next": "^16.2.5",
"next-themes": "^0.4.6",
"react": "catalog:",
"react-day-picker": "^9.14.0",

View File

@@ -24,6 +24,12 @@ function NavigationProviderInner({
searchParams: new URLSearchParams(searchParams.toString()),
getShareableUrl: (path: string) =>
typeof window === "undefined" ? path : window.location.origin + path,
// router.prefetch is a no-op in dev mode by Next.js design; in production
// it warms the RSC payload + route chunk so the next push() commits with
// no network round-trip. Safe to call repeatedly — Next dedupes internally.
prefetch: (path: string) => {
router.prefetch(path);
},
};
return <NavigationProvider value={adapter}>{children}</NavigationProvider>;

View File

@@ -1,5 +1,13 @@
# Self-hosting Docker Compose — starts PostgreSQL, backend, and frontend.
#
# Services bind to 127.0.0.1 only. For cross-machine or public access, front
# them with a reverse proxy (Caddy / nginx / Cloudflare Tunnel) that terminates
# TLS and forwards to 127.0.0.1:8080 (backend) and 127.0.0.1:3000 (frontend).
# Do NOT change these bindings to 0.0.0.0 — Docker bypasses host firewalls
# (UFW/iptables) by default, so the raw ports would be exposed to the internet
# with the default JWT_SECRET and Postgres credentials. See:
# apps/docs/content/docs/self-host-quickstart.mdx
#
# Usage:
# cp .env.example .env
# # Edit .env — change JWT_SECRET at minimum
@@ -18,7 +26,7 @@ services:
POSTGRES_USER: ${POSTGRES_USER:-multica}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-multica}
ports:
- "${POSTGRES_PORT:-5432}:5432"
- "127.0.0.1:${POSTGRES_PORT:-5432}:5432"
volumes:
- pgdata:/var/lib/postgresql/data
restart: unless-stopped
@@ -34,7 +42,7 @@ services:
postgres:
condition: service_healthy
ports:
- "${PORT:-8080}:8080"
- "127.0.0.1:${PORT:-8080}:8080"
volumes:
- backend_uploads:/app/data/uploads
environment:
@@ -46,6 +54,11 @@ services:
CORS_ALLOWED_ORIGINS: ${CORS_ALLOWED_ORIGINS:-}
RESEND_API_KEY: ${RESEND_API_KEY:-}
RESEND_FROM_EMAIL: ${RESEND_FROM_EMAIL:-noreply@multica.ai}
SMTP_HOST: ${SMTP_HOST:-}
SMTP_PORT: ${SMTP_PORT:-25}
SMTP_USERNAME: ${SMTP_USERNAME:-}
SMTP_PASSWORD: ${SMTP_PASSWORD:-}
SMTP_TLS_INSECURE: ${SMTP_TLS_INSECURE:-false}
GOOGLE_CLIENT_ID: ${GOOGLE_CLIENT_ID:-}
GOOGLE_CLIENT_SECRET: ${GOOGLE_CLIENT_SECRET:-}
GOOGLE_REDIRECT_URI: ${GOOGLE_REDIRECT_URI:-http://localhost:3000/auth/callback}
@@ -63,6 +76,19 @@ services:
ALLOWED_EMAIL_DOMAINS: ${ALLOWED_EMAIL_DOMAINS:-}
GITHUB_APP_SLUG: ${GITHUB_APP_SLUG:-}
GITHUB_WEBHOOK_SECRET: ${GITHUB_WEBHOOK_SECRET:-}
# Public URL the API is reachable at from the open internet, no
# trailing slash. Used to mint absolute webhook URLs for autopilot
# webhook triggers. Leave unset behind a same-origin reverse proxy
# (e.g. plain localhost dev); the frontend will compose the URL
# from window.origin + webhook_path in that case. Headers are
# intentionally NOT used to derive this value, to avoid Host /
# X-Forwarded-Host spoofing on misconfigured proxies.
MULTICA_PUBLIC_URL: ${MULTICA_PUBLIC_URL:-}
# Comma-separated CIDRs whose source IP is allowed to set
# X-Forwarded-For / X-Real-IP for the webhook per-IP rate limiter.
# Empty default = headers ignored, RemoteAddr used. Set e.g.
# "127.0.0.1/32" when running behind a same-host reverse proxy.
MULTICA_TRUSTED_PROXIES: ${MULTICA_TRUSTED_PROXIES:-}
restart: unless-stopped
frontend:
@@ -70,7 +96,7 @@ services:
depends_on:
- backend
ports:
- "${FRONTEND_PORT:-3000}:3000"
- "127.0.0.1:${FRONTEND_PORT:-3000}:3000"
environment:
HOSTNAME: "0.0.0.0"
restart: unless-stopped

View File

@@ -8,7 +8,7 @@ services:
POSTGRES_USER: ${POSTGRES_USER:-multica}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-multica}
ports:
- "5432:5432"
- "127.0.0.1:5432:5432"
volumes:
- pgdata:/var/lib/postgresql/data

View File

@@ -62,6 +62,7 @@ describe("ApiClient", () => {
});
await client.updateAutopilotTrigger("ap-1", "tr-1", { enabled: false });
await client.deleteAutopilotTrigger("ap-1", "tr-1");
await client.rotateAutopilotTriggerWebhookToken("ap-1", "tr-1");
const calls = fetchMock.mock.calls.map(([url, init]) => ({
url,
@@ -104,6 +105,10 @@ describe("ApiClient", () => {
body: JSON.stringify({ enabled: false }),
},
{ url: "https://api.example.test/api/autopilots/ap-1/triggers/tr-1", method: "DELETE" },
{
url: "https://api.example.test/api/autopilots/ap-1/triggers/tr-1/rotate-webhook-token",
method: "POST",
},
]);
});

View File

@@ -2,6 +2,7 @@ import type {
Issue,
CreateIssueRequest,
UpdateIssueRequest,
GroupedIssuesResponse,
ListIssuesResponse,
SearchIssuesResponse,
SearchProjectsResponse,
@@ -9,6 +10,7 @@ import type {
CreateMemberRequest,
UpdateMemberRequest,
ListIssuesParams,
ListGroupedIssuesParams,
Agent,
CreateAgentRequest,
AgentTemplate,
@@ -45,6 +47,7 @@ import type {
DashboardUsageDaily,
DashboardUsageByAgent,
DashboardAgentRunTime,
DashboardRunTimeDaily,
RuntimeUpdate,
RuntimeModelListRequest,
RuntimeLocalSkillListRequest,
@@ -86,6 +89,8 @@ import type {
ListAutopilotsResponse,
GetAutopilotResponse,
ListAutopilotRunsResponse,
ListWebhookDeliveriesResponse,
WebhookDelivery,
NotificationPreferenceResponse,
NotificationPreferences,
GitHubPullRequest,
@@ -93,6 +98,7 @@ import type {
GitHubConnectResponse,
Squad,
SquadMember,
SquadMemberStatusListResponse,
} from "../types";
import type { OnboardingCompletionPath } from "../onboarding/types";
import { type Logger, noopLogger } from "../logger";
@@ -107,17 +113,26 @@ import {
CommentsListSchema,
CreateAgentFromTemplateResponseSchema,
DashboardAgentRunTimeListSchema,
DashboardRunTimeDailyListSchema,
DashboardUsageByAgentListSchema,
DashboardUsageDailyListSchema,
EMPTY_AGENT_TEMPLATE_DETAIL,
EMPTY_AGENT_TEMPLATE_SUMMARY_LIST,
EMPTY_ATTACHMENT,
EMPTY_CREATE_AGENT_FROM_TEMPLATE_RESPONSE,
EMPTY_GROUPED_ISSUES_RESPONSE,
EMPTY_LIST_ISSUES_RESPONSE,
EMPTY_SQUAD_MEMBER_STATUS_LIST,
EMPTY_TIMELINE_ENTRIES,
EMPTY_LIST_WEBHOOK_DELIVERIES_RESPONSE,
EMPTY_WEBHOOK_DELIVERY,
GroupedIssuesResponseSchema,
ListIssuesResponseSchema,
ListWebhookDeliveriesResponseSchema,
SquadMemberStatusListResponseSchema,
SubscribersListSchema,
TimelineEntriesSchema,
WebhookDeliveryResponseSchema,
} from "./schemas";
/** Identifies the calling client to the server.
@@ -473,6 +488,36 @@ export class ApiClient {
});
}
async listGroupedIssues(params: ListGroupedIssuesParams): Promise<GroupedIssuesResponse> {
const search = new URLSearchParams({ group_by: params.group_by });
if (params.limit) search.set("limit", String(params.limit));
if (params.offset) search.set("offset", String(params.offset));
if (params.workspace_id) search.set("workspace_id", params.workspace_id);
if (params.statuses?.length) search.set("statuses", params.statuses.join(","));
if (params.priorities?.length) search.set("priorities", params.priorities.join(","));
if (params.assignee_types?.length) search.set("assignee_types", params.assignee_types.join(","));
if (params.assignee_id) search.set("assignee_id", params.assignee_id);
if (params.assignee_ids?.length) search.set("assignee_ids", params.assignee_ids.join(","));
if (params.creator_id) search.set("creator_id", params.creator_id);
if (params.project_id) search.set("project_id", params.project_id);
if (params.assignee_filters?.length) {
search.set("assignee_filters", params.assignee_filters.map((f) => `${f.type}:${f.id}`).join(","));
}
if (params.include_no_assignee) search.set("include_no_assignee", "true");
if (params.creator_filters?.length) {
search.set("creator_filters", params.creator_filters.map((f) => `${f.type}:${f.id}`).join(","));
}
if (params.project_ids?.length) search.set("project_ids", params.project_ids.join(","));
if (params.include_no_project) search.set("include_no_project", "true");
if (params.label_ids?.length) search.set("label_ids", params.label_ids.join(","));
if (params.group_assignee_type) search.set("group_assignee_type", params.group_assignee_type);
if (params.group_assignee_id) search.set("group_assignee_id", params.group_assignee_id);
const raw = await this.fetch<unknown>(`/api/issues/grouped?${search}`);
return parseWithFallback(raw, GroupedIssuesResponseSchema, EMPTY_GROUPED_ISSUES_RESPONSE, {
endpoint: "GET /api/issues/grouped",
});
}
async searchIssues(params: { q: string; limit?: number; offset?: number; include_closed?: boolean; signal?: AbortSignal }): Promise<SearchIssuesResponse> {
const search = new URLSearchParams({ q: params.q });
if (params.limit !== undefined) search.set("limit", String(params.limit));
@@ -855,6 +900,21 @@ export class ApiClient {
);
}
async getDashboardRunTimeDaily(
params: { days?: number; project_id?: string | null },
): Promise<DashboardRunTimeDaily[]> {
const search = new URLSearchParams();
if (params.days) search.set("days", String(params.days));
if (params.project_id) search.set("project_id", params.project_id);
const raw = await this.fetch<unknown>(`/api/dashboard/runtime/daily?${search}`);
return parseWithFallback<DashboardRunTimeDaily[]>(
raw,
DashboardRunTimeDailyListSchema,
[],
{ endpoint: "GET /api/dashboard/runtime/daily" },
);
}
async initiateUpdate(
runtimeId: string,
targetVersion: string,
@@ -1459,7 +1519,7 @@ export class ApiClient {
return this.fetch(`/api/squads/${id}`);
}
async createSquad(data: { name: string; description?: string; leader_id: string }): Promise<Squad> {
async createSquad(data: { name: string; description?: string; leader_id: string; avatar_url?: string }): Promise<Squad> {
return this.fetch("/api/squads", { method: "POST", body: JSON.stringify(data) });
}
@@ -1487,6 +1547,17 @@ export class ApiClient {
return this.fetch(`/api/squads/${squadId}/members/role`, { method: "PATCH", body: JSON.stringify(data) });
}
// Per-squad members status snapshot: one row per member with derived
// working/idle/offline/unstable plus the issues each agent is currently
// running. Parsed with a lenient schema so a new server-side status
// value or extra field can't white-screen the Squad page (#2143).
async getSquadMemberStatus(squadId: string): Promise<SquadMemberStatusListResponse> {
const raw = await this.fetch<unknown>(`/api/squads/${squadId}/members/status`);
return parseWithFallback(raw, SquadMemberStatusListResponseSchema, EMPTY_SQUAD_MEMBER_STATUS_LIST, {
endpoint: "GET /api/squads/:id/members/status",
}) as SquadMemberStatusListResponse;
}
// Autopilots
async listAutopilots(params?: { status?: string }): Promise<ListAutopilotsResponse> {
const search = new URLSearchParams();
@@ -1527,6 +1598,13 @@ export class ApiClient {
return this.fetch(`/api/autopilots/${id}/runs?${search}`);
}
// Returns a single run including its full trigger_payload. List responses
// omit trigger_payload to keep them small (a webhook envelope can be
// up to 256 KiB × limit rows), so the detail view fetches via this route.
async getAutopilotRun(autopilotId: string, runId: string): Promise<AutopilotRun> {
return this.fetch(`/api/autopilots/${autopilotId}/runs/${runId}`);
}
async createAutopilotTrigger(autopilotId: string, data: CreateAutopilotTriggerRequest): Promise<AutopilotTrigger> {
return this.fetch(`/api/autopilots/${autopilotId}/triggers`, {
method: "POST",
@@ -1545,6 +1623,74 @@ export class ApiClient {
await this.fetch(`/api/autopilots/${autopilotId}/triggers/${triggerId}`, { method: "DELETE" });
}
async rotateAutopilotTriggerWebhookToken(
autopilotId: string,
triggerId: string,
): Promise<AutopilotTrigger> {
return this.fetch(
`/api/autopilots/${autopilotId}/triggers/${triggerId}/rotate-webhook-token`,
{ method: "POST" },
);
}
// Webhook deliveries — list is slim (no raw_body / selected_headers /
// response_body); detail returns the full row. Both responses are parsed
// through a lenient schema so an unknown server-side `status` /
// `signature_status` value degrades to a generic row instead of dropping
// the whole list.
async listAutopilotDeliveries(
autopilotId: string,
params?: { limit?: number; offset?: number },
): Promise<ListWebhookDeliveriesResponse> {
const search = new URLSearchParams();
if (params?.limit) search.set("limit", params.limit.toString());
if (params?.offset) search.set("offset", params.offset.toString());
const raw = await this.fetch<unknown>(
`/api/autopilots/${autopilotId}/deliveries?${search}`,
);
return parseWithFallback(
raw,
ListWebhookDeliveriesResponseSchema,
EMPTY_LIST_WEBHOOK_DELIVERIES_RESPONSE,
{ endpoint: "GET /api/autopilots/:id/deliveries" },
);
}
async getAutopilotDelivery(
autopilotId: string,
deliveryId: string,
): Promise<WebhookDelivery> {
const raw = await this.fetch<unknown>(
`/api/autopilots/${autopilotId}/deliveries/${deliveryId}`,
);
return parseWithFallback(
raw,
WebhookDeliveryResponseSchema,
{ ...EMPTY_WEBHOOK_DELIVERY, id: deliveryId, autopilot_id: autopilotId },
{ endpoint: "GET /api/autopilots/:id/deliveries/:deliveryId" },
);
}
// Replay creates a NEW delivery row referencing the original via
// `replayed_from_delivery_id`. Server rejects replays of
// signature-invalid / rejected deliveries with 400 — the UI keeps the
// button disabled for those rows, but the server is the source of truth.
async replayAutopilotDelivery(
autopilotId: string,
deliveryId: string,
): Promise<WebhookDelivery> {
const raw = await this.fetch<unknown>(
`/api/autopilots/${autopilotId}/deliveries/${deliveryId}/replay`,
{ method: "POST" },
);
return parseWithFallback(
raw,
WebhookDeliveryResponseSchema,
{ ...EMPTY_WEBHOOK_DELIVERY, autopilot_id: autopilotId },
{ endpoint: "POST /api/autopilots/:id/deliveries/:deliveryId/replay" },
);
}
// GitHub integration
async getGitHubConnectURL(workspaceId: string): Promise<GitHubConnectResponse> {
return this.fetch(`/api/workspaces/${workspaceId}/github/connect`);

View File

@@ -13,6 +13,8 @@ export type {
} from "./client";
export { parseWithFallback, setSchemaLogger } from "./schema";
export type { ParseOptions } from "./schema";
export { DuplicateIssueErrorBodySchema } from "./schemas";
export type { DuplicateIssueErrorBody } from "./schemas";
export { WSClient } from "./ws-client";
import type { ApiClient as ApiClientType } from "./client";

View File

@@ -91,6 +91,15 @@ describe("ApiClient schema fallback", () => {
});
});
describe("listGroupedIssues", () => {
it("falls back to empty groups when the response is malformed", async () => {
stubFetchJson({ groups: "not-an-array" });
const client = new ApiClient("https://api.example.test");
const res = await client.listGroupedIssues({ group_by: "assignee" });
expect(res).toEqual({ groups: [] });
});
});
describe("listComments", () => {
it("returns [] when the response is not an array", async () => {
stubFetchJson({ wrong: "shape" });
@@ -189,6 +198,68 @@ describe("ApiClient schema fallback", () => {
});
});
describe("listAutopilotDeliveries", () => {
it("falls back to an empty list when the body is null", async () => {
stubFetchJson(null);
const client = new ApiClient("https://api.example.test");
const res = await client.listAutopilotDeliveries("ap-1");
expect(res).toEqual({ deliveries: [], total: 0 });
});
it("falls back to an empty list when `deliveries` is not an array", async () => {
stubFetchJson({ deliveries: "not-an-array", total: 0 });
const client = new ApiClient("https://api.example.test");
const res = await client.listAutopilotDeliveries("ap-1");
expect(res).toEqual({ deliveries: [], total: 0 });
});
it("accepts an unknown future status value rather than dropping the row", async () => {
// Server-side enum drift (e.g. new `quarantined` state). The list
// must still surface the row; downstream UI code's `default` arm
// handles unknown values with a generic visual.
stubFetchJson({
deliveries: [
{
id: "d-1",
workspace_id: "ws-1",
autopilot_id: "ap-1",
trigger_id: "t-1",
provider: "github",
event: "pull_request.opened",
dedupe_key: "abc",
dedupe_source: "x-github-delivery",
signature_status: "valid",
status: "quarantined",
attempt_count: 1,
content_type: "application/json",
response_status: 200,
autopilot_run_id: null,
replayed_from_delivery_id: null,
error: null,
received_at: "2026-01-01T00:00:00Z",
last_attempt_at: "2026-01-01T00:00:00Z",
created_at: "2026-01-01T00:00:00Z",
},
],
total: 1,
});
const client = new ApiClient("https://api.example.test");
const res = await client.listAutopilotDeliveries("ap-1");
expect(res.deliveries).toHaveLength(1);
expect(res.deliveries[0]?.status).toBe("quarantined");
});
});
describe("getAutopilotDelivery", () => {
it("falls back to a placeholder carrying the requested id", async () => {
stubFetchJson({ wrong: "shape" });
const client = new ApiClient("https://api.example.test");
const detail = await client.getAutopilotDelivery("ap-1", "d-1");
expect(detail.id).toBe("d-1");
expect(detail.autopilot_id).toBe("ap-1");
});
});
describe("createAgentFromTemplate", () => {
it("falls back to an empty agent when the response is malformed", async () => {
// The agent was created server-side even though the client can't

View File

@@ -0,0 +1,51 @@
import { describe, expect, it } from "vitest";
import { DuplicateIssueErrorBodySchema } from "./schemas";
// The duplicate-issue branch in create-issue.tsx feeds ApiError.body
// (typed as `unknown`) through this schema. Any future server drift that
// loses the contract MUST fail the parse so the UI falls back to a normal
// error toast instead of rendering an empty / partial duplicate card.
describe("DuplicateIssueErrorBodySchema", () => {
const valid = {
code: "active_duplicate_issue",
error: "An active issue with this title already exists: MUL-12 Login bug",
issue: {
id: "11111111-1111-1111-1111-111111111111",
identifier: "MUL-12",
title: "Login bug",
},
};
it("accepts a well-formed body", () => {
expect(DuplicateIssueErrorBodySchema.safeParse(valid).success).toBe(true);
});
it("accepts unknown extra fields via .loose()", () => {
const forwardCompat = {
...valid,
hint: "Try a different title",
issue: { ...valid.issue, workspace_id: "ws-1", status: "todo" },
};
expect(DuplicateIssueErrorBodySchema.safeParse(forwardCompat).success).toBe(true);
});
it("rejects a renamed code (so renames degrade to the generic toast)", () => {
const renamed = { ...valid, code: "duplicate_issue" };
expect(DuplicateIssueErrorBodySchema.safeParse(renamed).success).toBe(false);
});
it("rejects a missing issue object", () => {
const { issue: _omit, ...without } = valid;
expect(DuplicateIssueErrorBodySchema.safeParse(without).success).toBe(false);
});
it("rejects a non-string issue.id", () => {
const broken = { ...valid, issue: { ...valid.issue, id: 42 } };
expect(DuplicateIssueErrorBodySchema.safeParse(broken).success).toBe(false);
});
it("accepts a missing error field (it is optional)", () => {
const { error: _omit, ...without } = valid;
expect(DuplicateIssueErrorBodySchema.safeParse(without).success).toBe(true);
});
});

View File

@@ -5,8 +5,11 @@ import type {
AgentTemplateSummary,
Attachment,
CreateAgentFromTemplateResponse,
GroupedIssuesResponse,
ListIssuesResponse,
ListWebhookDeliveriesResponse,
TimelineEntry,
WebhookDelivery,
} from "../types";
// ---------------------------------------------------------------------------
@@ -147,6 +150,7 @@ const IssueSchema = z.object({
parent_issue_id: z.string().nullable(),
project_id: z.string().nullable(),
position: z.number(),
start_date: z.string().nullable(),
due_date: z.string().nullable(),
reactions: z.array(z.unknown()).optional(),
labels: z.array(z.unknown()).optional(),
@@ -164,6 +168,22 @@ export const EMPTY_LIST_ISSUES_RESPONSE: ListIssuesResponse = {
total: 0,
};
const IssueAssigneeGroupSchema = z.object({
id: z.string(),
assignee_type: z.string().nullable(),
assignee_id: z.string().nullable(),
issues: z.array(IssueSchema).default([]),
total: z.number().default(0),
}).loose();
export const GroupedIssuesResponseSchema = z.object({
groups: z.array(IssueAssigneeGroupSchema).default([]),
}).loose();
export const EMPTY_GROUPED_ISSUES_RESPONSE: GroupedIssuesResponse = {
groups: [],
};
const SubscriberSchema = z.object({
issue_id: z.string(),
user_type: z.string(),
@@ -221,6 +241,15 @@ const DashboardAgentRunTimeSchema = z.object({
export const DashboardAgentRunTimeListSchema = z.array(DashboardAgentRunTimeSchema);
const DashboardRunTimeDailySchema = z.object({
date: z.string(),
total_seconds: z.number().default(0),
task_count: z.number().default(0),
failed_count: z.number().default(0),
}).loose();
export const DashboardRunTimeDailyListSchema = z.array(DashboardRunTimeDailySchema);
// ---------------------------------------------------------------------------
// Agent template catalog — `/api/agent-templates*` and the
// create-from-template response. The desktop app's create-agent picker
@@ -306,3 +335,140 @@ export const EMPTY_CREATE_AGENT_FROM_TEMPLATE_RESPONSE: CreateAgentFromTemplateR
imported_skill_ids: [],
reused_skill_ids: [],
};
// Squad member status — backs the Squad detail page's Members tab. status
// is `string | null` (not the narrow `SquadMemberStatusValue` union) so a
// new server-side status doesn't fail the parse; the UI defaults to a
// neutral pill for unknown values.
const SquadActiveIssueBriefSchema = z.object({
issue_id: z.string(),
identifier: z.string(),
title: z.string(),
issue_status: z.string(),
}).loose();
const SquadMemberStatusSchema = z.object({
member_type: z.string(),
member_id: z.string(),
status: z.string().nullable().optional().transform((v) => v ?? null),
active_issues: z.array(SquadActiveIssueBriefSchema).default([]),
last_active_at: z.string().nullable().optional().transform((v) => v ?? null),
}).loose();
export const SquadMemberStatusListResponseSchema = z.object({
members: z.array(SquadMemberStatusSchema).default([]),
}).loose();
export const EMPTY_SQUAD_MEMBER_STATUS_LIST = { members: [] };
// ---------------------------------------------------------------------------
// Structured error body — POST /api/workspaces/:wsId/issues 409 conflict.
//
// When the server detects an active issue with the same title in the same
// workspace, it returns `{ code: "active_duplicate_issue", error, issue }`
// instead of letting the create through. The UI uses the embedded issue ref
// to offer "view existing" rather than dropping the user into a generic
// "create failed" toast.
//
// Strict guarantees:
// - `code` is a literal so a future server rename (e.g. `duplicate_issue`)
// fails the parse and falls back to a normal error toast — drift never
// ships as a broken duplicate UI.
// - `issue` is required; without an id/identifier/title the "view existing"
// button has nothing to point at, so we'd rather fall back than guess.
// - `issue.status` is intentionally OMITTED: the duplicate toast doesn't
// render a StatusIcon (which has no fallback for unknown enum values),
// so a future server-side rename of `status` must not knock this branch
// out. `.loose()` lets the field pass through unchanged for any other
// consumer.
// ---------------------------------------------------------------------------
export const DuplicateIssueErrorBodySchema = z.object({
code: z.literal("active_duplicate_issue"),
error: z.string().optional(),
issue: z.object({
id: z.string(),
identifier: z.string(),
title: z.string(),
}).loose(),
}).loose();
export interface DuplicateIssueErrorBody {
code: "active_duplicate_issue";
error?: string;
issue: {
id: string;
identifier: string;
title: string;
};
}
// ---------------------------------------------------------------------------
// Webhook delivery schemas — backing the Autopilot Deliveries section. Enums
// (`status`, `signature_status`, `provider`) are kept as `z.string()` so a
// future server-side value (e.g. a Stripe provider, a new dedupe state)
// degrades to a generic UI fallback rather than collapsing the list into
// the empty array. `.loose()` lets unknown fields pass through, matching
// the rule used by every other endpoint here.
// ---------------------------------------------------------------------------
const WebhookDeliverySchema = z.object({
id: z.string(),
workspace_id: z.string(),
autopilot_id: z.string(),
trigger_id: z.string(),
provider: z.string(),
event: z.string(),
dedupe_key: z.string().nullable(),
dedupe_source: z.string().nullable(),
signature_status: z.string(),
status: z.string(),
attempt_count: z.number().default(0),
content_type: z.string().nullable(),
response_status: z.number().nullable(),
autopilot_run_id: z.string().nullable(),
replayed_from_delivery_id: z.string().nullable(),
error: z.string().nullable(),
received_at: z.string(),
last_attempt_at: z.string(),
created_at: z.string(),
// Detail-only fields. The list endpoint omits them; the detail endpoint
// populates raw_body / selected_headers / response_body.
selected_headers: z.record(z.string(), z.unknown()).nullable().optional(),
raw_body: z.string().nullable().optional(),
response_body: z.string().nullable().optional(),
}).loose();
export const ListWebhookDeliveriesResponseSchema = z.object({
deliveries: z.array(WebhookDeliverySchema).default([]),
total: z.number().default(0),
}).loose();
export const WebhookDeliveryResponseSchema = WebhookDeliverySchema;
export const EMPTY_LIST_WEBHOOK_DELIVERIES_RESPONSE: ListWebhookDeliveriesResponse = {
deliveries: [],
total: 0,
};
export const EMPTY_WEBHOOK_DELIVERY: WebhookDelivery = {
id: "",
workspace_id: "",
autopilot_id: "",
trigger_id: "",
provider: "",
event: "",
dedupe_key: null,
dedupe_source: null,
signature_status: "not_required",
status: "queued",
attempt_count: 0,
content_type: null,
response_status: null,
autopilot_run_id: null,
replayed_from_delivery_id: null,
error: null,
received_at: "",
last_attempt_at: "",
created_at: "",
};

View File

@@ -1,7 +1,7 @@
import type { WSMessage, WSEventType } from "../types/events";
import { type Logger, noopLogger } from "../logger";
type EventHandler = (payload: unknown, actorId?: string) => void;
type EventHandler = (payload: unknown, actorId?: string, actorType?: string) => void;
/** Identifies the WS client to the server. Sent as `client_platform`,
* `client_version`, and `client_os` query parameters on the upgrade URL —
@@ -84,7 +84,7 @@ export class WSClient {
const eventHandlers = this.handlers.get(msg.type);
if (eventHandlers) {
for (const handler of eventHandlers) {
handler(msg.payload, msg.actor_id);
handler(msg.payload, msg.actor_id, msg.actor_type);
}
}
for (const handler of this.anyHandlers) {

View File

@@ -1,4 +1,11 @@
export { autopilotKeys, autopilotListOptions, autopilotDetailOptions, autopilotRunsOptions } from "./queries";
export {
autopilotKeys,
autopilotListOptions,
autopilotDetailOptions,
autopilotRunsOptions,
autopilotDeliveriesOptions,
autopilotDeliveryOptions,
} from "./queries";
export {
useCreateAutopilot,
useUpdateAutopilot,
@@ -7,4 +14,7 @@ export {
useCreateAutopilotTrigger,
useUpdateAutopilotTrigger,
useDeleteAutopilotTrigger,
useRotateAutopilotTriggerWebhookToken,
useReplayAutopilotDelivery,
} from "./mutations";
export { buildAutopilotWebhookUrl } from "./webhook";

View File

@@ -128,3 +128,32 @@ export function useDeleteAutopilotTrigger() {
},
});
}
export function useRotateAutopilotTriggerWebhookToken() {
const qc = useQueryClient();
const wsId = useWorkspaceId();
return useMutation({
mutationFn: ({ autopilotId, triggerId }: { autopilotId: string; triggerId: string }) =>
api.rotateAutopilotTriggerWebhookToken(autopilotId, triggerId),
onSettled: (_data, _err, vars) => {
qc.invalidateQueries({ queryKey: autopilotKeys.detail(wsId, vars.autopilotId) });
},
});
}
// Replay re-dispatches a previously-recorded delivery. The server creates
// a new delivery row (with `replayed_from_delivery_id`) and synchronously
// kicks off a new autopilot run. We invalidate both deliveries and runs so
// the new delivery and any resulting run show up immediately.
export function useReplayAutopilotDelivery() {
const qc = useQueryClient();
const wsId = useWorkspaceId();
return useMutation({
mutationFn: ({ autopilotId, deliveryId }: { autopilotId: string; deliveryId: string }) =>
api.replayAutopilotDelivery(autopilotId, deliveryId),
onSettled: (_data, _err, vars) => {
qc.invalidateQueries({ queryKey: autopilotKeys.deliveries(wsId, vars.autopilotId) });
qc.invalidateQueries({ queryKey: autopilotKeys.runs(wsId, vars.autopilotId) });
},
});
}

View File

@@ -8,6 +8,12 @@ export const autopilotKeys = {
[...autopilotKeys.all(wsId), "detail", id] as const,
runs: (wsId: string, id: string) =>
[...autopilotKeys.all(wsId), "runs", id] as const,
run: (wsId: string, autopilotId: string, runId: string) =>
[...autopilotKeys.all(wsId), "runs", autopilotId, runId] as const,
deliveries: (wsId: string, id: string) =>
[...autopilotKeys.all(wsId), "deliveries", id] as const,
delivery: (wsId: string, autopilotId: string, deliveryId: string) =>
[...autopilotKeys.all(wsId), "deliveries", autopilotId, deliveryId] as const,
};
export function autopilotListOptions(wsId: string) {
@@ -32,3 +38,52 @@ export function autopilotRunsOptions(wsId: string, id: string) {
select: (data) => data.runs,
});
}
// autopilotRunOptions fetches a single run with its full trigger_payload.
// The list endpoint (autopilotRunsOptions) omits trigger_payload to keep
// list responses small; callers (e.g. the run-detail dialog) use this
// query on demand when the user opens a run.
export function autopilotRunOptions(
wsId: string,
autopilotId: string,
runId: string,
options?: { enabled?: boolean },
) {
return queryOptions({
queryKey: autopilotKeys.run(wsId, autopilotId, runId),
queryFn: () => api.getAutopilotRun(autopilotId, runId),
enabled: options?.enabled ?? true,
});
}
// autopilotDeliveriesOptions powers the Deliveries section in the autopilot
// detail page. The list is slim — raw_body / selected_headers / response_body
// are omitted server-side. Detail rows are fetched on-demand when the user
// expands a row (see autopilotDeliveryOptions).
export function autopilotDeliveriesOptions(
wsId: string,
autopilotId: string,
options?: { enabled?: boolean },
) {
return queryOptions({
queryKey: autopilotKeys.deliveries(wsId, autopilotId),
queryFn: () => api.listAutopilotDeliveries(autopilotId),
select: (data) => data.deliveries,
enabled: options?.enabled ?? true,
});
}
// autopilotDeliveryOptions fetches the full delivery row including raw_body
// and headers subset. Used by the detail dialog opened from a list row.
export function autopilotDeliveryOptions(
wsId: string,
autopilotId: string,
deliveryId: string,
options?: { enabled?: boolean },
) {
return queryOptions({
queryKey: autopilotKeys.delivery(wsId, autopilotId, deliveryId),
queryFn: () => api.getAutopilotDelivery(autopilotId, deliveryId),
enabled: options?.enabled ?? true,
});
}

View File

@@ -0,0 +1,73 @@
import { describe, expect, it } from "vitest";
import { buildAutopilotWebhookUrl } from "./webhook";
import type { AutopilotTrigger } from "../types";
const baseTrigger: AutopilotTrigger = {
id: "t1",
autopilot_id: "a1",
kind: "webhook",
enabled: true,
cron_expression: null,
timezone: null,
next_run_at: null,
webhook_token: "awt_abc",
webhook_path: "/api/webhooks/autopilots/awt_abc",
webhook_url: null,
label: null,
last_fired_at: null,
created_at: "",
updated_at: "",
};
describe("buildAutopilotWebhookUrl", () => {
it("returns the server-provided webhook_url verbatim when present", () => {
expect(
buildAutopilotWebhookUrl({
trigger: { ...baseTrigger, webhook_url: "https://custom.example/api/webhooks/autopilots/awt_abc" },
}),
).toBe("https://custom.example/api/webhooks/autopilots/awt_abc");
});
it("composes from apiBaseUrl + webhook_path", () => {
expect(
buildAutopilotWebhookUrl({ trigger: baseTrigger, apiBaseUrl: "https://api.example" }),
).toBe("https://api.example/api/webhooks/autopilots/awt_abc");
});
it("strips trailing slash on apiBaseUrl", () => {
expect(
buildAutopilotWebhookUrl({ trigger: baseTrigger, apiBaseUrl: "https://api.example/" }),
).toBe("https://api.example/api/webhooks/autopilots/awt_abc");
});
it("falls back to currentOrigin when apiBaseUrl is empty", () => {
expect(
buildAutopilotWebhookUrl({
trigger: baseTrigger,
apiBaseUrl: "",
currentOrigin: "https://app.example",
}),
).toBe("https://app.example/api/webhooks/autopilots/awt_abc");
});
it("composes from token when webhook_path is missing", () => {
expect(
buildAutopilotWebhookUrl({
trigger: { ...baseTrigger, webhook_path: null },
apiBaseUrl: "https://api.example",
}),
).toBe("https://api.example/api/webhooks/autopilots/awt_abc");
});
it("returns null for non-webhook trigger", () => {
expect(
buildAutopilotWebhookUrl({
trigger: { ...baseTrigger, kind: "schedule", webhook_token: null, webhook_path: null },
}),
).toBeNull();
});
it("returns relative path when no base or origin available", () => {
expect(buildAutopilotWebhookUrl({ trigger: baseTrigger })).toBe("/api/webhooks/autopilots/awt_abc");
});
});

View File

@@ -0,0 +1,43 @@
import type { AutopilotTrigger } from "../types";
/**
* Compose a usable absolute webhook URL for a webhook trigger.
*
* Resolution order:
* 1. trigger.webhook_url — present only when MULTICA_PUBLIC_URL is set on the
* server. This is the authoritative form when available.
* 2. apiBaseUrl + webhook_path — desktop apps and self-host setups where the
* server didn't mint an absolute URL but the client knows its API origin.
* 3. currentOrigin + webhook_path — browser fallback when getBaseUrl() is
* empty (e.g. same-origin Next.js dev).
*
* Returns null when the trigger has no token / path yet (a new trigger that
* hasn't been written back to the cache, or a non-webhook trigger).
*/
export function buildAutopilotWebhookUrl(params: {
trigger: Pick<AutopilotTrigger, "kind" | "webhook_token" | "webhook_path" | "webhook_url">;
apiBaseUrl?: string;
currentOrigin?: string;
}): string | null {
const { trigger, apiBaseUrl, currentOrigin } = params;
if (trigger.kind !== "webhook") return null;
if (typeof trigger.webhook_url === "string" && trigger.webhook_url) {
return trigger.webhook_url;
}
const path =
(typeof trigger.webhook_path === "string" && trigger.webhook_path) ||
(trigger.webhook_token ? `/api/webhooks/autopilots/${trigger.webhook_token}` : null);
if (!path) return null;
const base = stripTrailingSlash(apiBaseUrl) || stripTrailingSlash(currentOrigin);
if (!base) return path; // last resort — relative path will still work in-browser
return base + path;
}
function stripTrailingSlash(s: string | undefined): string {
if (!s) return "";
return s.endsWith("/") ? s.slice(0, -1) : s;
}

View File

@@ -22,6 +22,8 @@ export const dashboardKeys = {
[...dashboardKeys.all(wsId), "by-agent", days, projectId] as const,
agentRuntime: (wsId: string, days: number, projectId: string | null) =>
[...dashboardKeys.all(wsId), "agent-runtime", days, projectId] as const,
runTimeDaily: (wsId: string, days: number, projectId: string | null) =>
[...dashboardKeys.all(wsId), "runtime-daily", days, projectId] as const,
};
// 60s staleTime matches the per-runtime usage queries — the data is rollup-
@@ -70,3 +72,17 @@ export function dashboardAgentRunTimeOptions(
staleTime: STALE_TIME,
});
}
export function dashboardRunTimeDailyOptions(
wsId: string,
days: number,
projectId: string | null,
) {
return queryOptions({
queryKey: dashboardKeys.runTimeDaily(wsId, days, projectId),
queryFn: () =>
api.getDashboardRunTimeDaily({ days, project_id: projectId ?? undefined }),
enabled: !!wsId,
staleTime: STALE_TIME,
});
}

View File

@@ -1 +1,2 @@
export * from "./queries";
export * from "./pull-request-status";

View File

@@ -0,0 +1,146 @@
import { describe, expect, it } from "vitest";
import {
derivePullRequestStatusKind,
derivePullRequestProgressSegments,
shouldShowPullRequestStats,
type PullRequestStatusInput,
} from "./pull-request-status";
const base: PullRequestStatusInput = { state: "open" };
describe("derivePullRequestStatusKind", () => {
it("closed beats every other signal", () => {
expect(
derivePullRequestStatusKind({
state: "closed",
mergeable_state: "dirty",
checks_failed: 99,
checks_pending: 99,
checks_passed: 99,
}),
).toBe("closed");
});
it("merged beats every other signal except closed", () => {
expect(
derivePullRequestStatusKind({
state: "merged",
mergeable_state: "dirty",
checks_failed: 5,
}),
).toBe("merged");
});
it("dirty conflicts wins over check signals", () => {
expect(
derivePullRequestStatusKind({
...base,
mergeable_state: "dirty",
checks_passed: 3,
}),
).toBe("conflicts");
});
it("any failed check beats pending and passed", () => {
expect(
derivePullRequestStatusKind({
...base,
checks_failed: 1,
checks_pending: 3,
checks_passed: 5,
}),
).toBe("checks_failed");
});
it("pending beats passed when no failure", () => {
expect(
derivePullRequestStatusKind({
...base,
checks_pending: 1,
checks_passed: 5,
}),
).toBe("checks_pending");
});
it("all-passed is checks_passed regardless of mergeable=clean", () => {
expect(
derivePullRequestStatusKind({
...base,
mergeable_state: "clean",
checks_passed: 5,
}),
).toBe("checks_passed");
});
it("clean + no suites is ready-to-merge", () => {
expect(
derivePullRequestStatusKind({ ...base, mergeable_state: "clean" }),
).toBe("ready");
});
it("opaque mergeable values render as unknown", () => {
for (const m of ["blocked", "behind", "unstable", "has_hooks", "unknown", null, undefined]) {
expect(derivePullRequestStatusKind({ ...base, mergeable_state: m })).toBe("unknown");
}
});
});
describe("derivePullRequestProgressSegments", () => {
it("returns null for terminal PRs (merged / closed)", () => {
expect(derivePullRequestProgressSegments({ state: "merged", checks_passed: 5 })).toBeNull();
expect(derivePullRequestProgressSegments({ state: "closed", checks_failed: 3 })).toBeNull();
});
it("returns null when no suite has been observed", () => {
expect(derivePullRequestProgressSegments({ ...base })).toBeNull();
expect(
derivePullRequestProgressSegments({ ...base, checks_failed: 0, checks_pending: 0, checks_passed: 0 }),
).toBeNull();
});
it("orders segments failed → pending → passed (failure leftmost)", () => {
const segs = derivePullRequestProgressSegments({
...base,
checks_failed: 1,
checks_pending: 2,
checks_passed: 3,
});
expect(segs).not.toBeNull();
expect(segs!.map((s) => s.kind)).toEqual(["failed", "pending", "passed"]);
});
it("emits a zero-width segment-free output (no entry with ratio 0)", () => {
const segs = derivePullRequestProgressSegments({
...base,
checks_failed: 0,
checks_pending: 0,
checks_passed: 4,
});
expect(segs).toEqual([{ kind: "passed", ratio: 1 }]);
});
it("ratios sum to ~1 across segments", () => {
const segs = derivePullRequestProgressSegments({
...base,
checks_failed: 1,
checks_pending: 1,
checks_passed: 2,
})!;
const total = segs.reduce((acc, s) => acc + s.ratio, 0);
expect(total).toBeCloseTo(1, 6);
});
});
describe("shouldShowPullRequestStats", () => {
it("hides when every field is 0 or missing (legacy backend)", () => {
expect(shouldShowPullRequestStats({})).toBe(false);
expect(shouldShowPullRequestStats({ additions: 0, deletions: 0, changed_files: 0 })).toBe(false);
});
it("shows when at least one number is non-zero", () => {
expect(shouldShowPullRequestStats({ additions: 1 })).toBe(true);
expect(shouldShowPullRequestStats({ deletions: 1 })).toBe(true);
expect(shouldShowPullRequestStats({ changed_files: 1 })).toBe(true);
expect(shouldShowPullRequestStats({ additions: 437, deletions: 6, changed_files: 6 })).toBe(true);
});
});

View File

@@ -0,0 +1,101 @@
import type { GitHubPullRequest } from "../types";
// Status kinds rendered in the PR sidebar row's detail line. Order in the
// pass-through table matters — the first matching rule wins. The order is
// chosen so terminal PR states (closed / merged) short-circuit before any
// transient CI/conflict signal, since those signals are no longer actionable
// on a terminal PR.
//
// Priority (high → low):
// 1. closed (not merged) → status_closed
// 2. merged → status_merged
// 3. mergeable_state = "dirty" → status_conflicts
// 4. any failed suite → status_checks_failed
// 5. any pending suite → status_checks_pending
// 6. any passed suite → status_checks_passed
// 7. no suite + mergeable=clean → status_ready
// 8. otherwise → status_unknown
//
// Note: this table is the single source of truth for the sidebar PR row. The
// older row-with-badges implementation used a separate "hide status row for
// terminal PRs" branch — the current row renders
// with status_closed / status_merged text, never falling through to a
// conflicts / checks line on a terminal PR. Keep this priority order in sync
// with the i18n keys `pull_request_card_status_*` and with the progress-strip
// derivation in `derivePullRequestProgressSegments` (terminal kinds get a
// solid bar; the rest map onto the per-suite counts).
export type PullRequestStatusKind =
| "closed"
| "merged"
| "conflicts"
| "checks_failed"
| "checks_pending"
| "checks_passed"
| "ready"
| "unknown";
export interface PullRequestStatusInput {
state: GitHubPullRequest["state"];
mergeable_state?: string | null;
checks_failed?: number;
checks_pending?: number;
checks_passed?: number;
}
export function derivePullRequestStatusKind(input: PullRequestStatusInput): PullRequestStatusKind {
if (input.state === "closed") return "closed";
if (input.state === "merged") return "merged";
if (input.mergeable_state === "dirty") return "conflicts";
if ((input.checks_failed ?? 0) > 0) return "checks_failed";
if ((input.checks_pending ?? 0) > 0) return "checks_pending";
if ((input.checks_passed ?? 0) > 0) return "checks_passed";
if (input.mergeable_state === "clean") return "ready";
return "unknown";
}
export interface PullRequestProgressSegment {
kind: "failed" | "pending" | "passed";
ratio: number;
}
// Segmented progress bar input. Returns null when:
// - the PR is terminal (closed/merged) — the card paints a solid bar
// in a state-specific color, no segmentation needed;
// - no check_suite has been observed (total === 0) — the card hides
// the bar entirely.
// Otherwise emits the segments left-to-right: failed → pending → passed.
// "Failure first" is intentional: problems should be visible before signal
// that everything is fine.
export function derivePullRequestProgressSegments(
input: PullRequestStatusInput,
): PullRequestProgressSegment[] | null {
if (input.state === "closed" || input.state === "merged") return null;
const failed = input.checks_failed ?? 0;
const pending = input.checks_pending ?? 0;
const passed = input.checks_passed ?? 0;
const total = failed + pending + passed;
if (total === 0) return null;
const segments: PullRequestProgressSegment[] = [];
if (failed > 0) segments.push({ kind: "failed", ratio: failed / total });
if (pending > 0) segments.push({ kind: "pending", ratio: pending / total });
if (passed > 0) segments.push({ kind: "passed", ratio: passed / total });
return segments;
}
export interface PullRequestStatsInput {
additions?: number;
deletions?: number;
changed_files?: number;
}
// shouldShowPullRequestStats encodes the "old backend → new frontend" guard:
// when the backend that served this PR row doesn't know about the stats
// columns yet, every numeric field defaults to 0. Rendering "+0 0 · 0 files"
// in that case would be a lie (the PR almost certainly has real changes),
// so we hide the entire stats row until at least one signal is non-zero.
export function shouldShowPullRequestStats(input: PullRequestStatsInput): boolean {
const a = input.additions ?? 0;
const d = input.deletions ?? 0;
const f = input.changed_files ?? 0;
return a + d + f > 0;
}

View File

@@ -1,9 +1,10 @@
import { useState, useCallback } from "react";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { useMutation, useQueryClient, type QueryKey } from "@tanstack/react-query";
import { api } from "../api";
import {
issueKeys,
ISSUE_PAGE_SIZE,
type AssigneeGroupedIssuesFilter,
type MyIssuesFilter,
} from "./queries";
import {
@@ -24,7 +25,7 @@ import {
} from "./delete-cache";
import { useWorkspaceId } from "../hooks";
import { useRecentIssuesStore } from "./stores";
import type { Issue, IssueReaction, IssueStatus } from "../types";
import type { GroupedIssuesResponse, Issue, IssueAssigneeGroup, IssueReaction, IssueStatus } from "../types";
import type {
CreateIssueRequest,
UpdateIssueRequest,
@@ -102,6 +103,58 @@ export function useLoadMoreByStatus(
return { loadMore, hasMore, isLoading, total };
}
export function useLoadMoreByAssigneeGroup(
group: Pick<IssueAssigneeGroup, "id" | "assignee_type" | "assignee_id">,
queryKey: QueryKey,
filter: AssigneeGroupedIssuesFilter,
) {
const qc = useQueryClient();
const [isLoading, setIsLoading] = useState(false);
const cache = qc.getQueryData<GroupedIssuesResponse>(queryKey);
const cachedGroup = cache?.groups.find((g) => g.id === group.id);
const loaded = cachedGroup?.issues.length ?? 0;
const total = cachedGroup?.total ?? 0;
const hasMore = loaded < total;
const loadMore = useCallback(async () => {
if (isLoading || !hasMore) return;
setIsLoading(true);
try {
const res = await api.listGroupedIssues({
group_by: "assignee",
limit: ISSUE_PAGE_SIZE,
offset: loaded,
...filter,
group_assignee_type: group.assignee_type ?? "none",
group_assignee_id: group.assignee_id ?? undefined,
});
const nextGroup = res.groups[0];
if (!nextGroup) return;
qc.setQueryData<GroupedIssuesResponse>(queryKey, (old) => {
if (!old) return old;
return {
groups: old.groups.map((existing) => {
if (existing.id !== nextGroup.id) return existing;
const existingIds = new Set(existing.issues.map((issue) => issue.id));
const appended = nextGroup.issues.filter((issue) => !existingIds.has(issue.id));
return {
...existing,
issues: [...existing.issues, ...appended],
total: nextGroup.total,
};
}),
};
});
} finally {
setIsLoading(false);
}
}, [filter, group.assignee_id, group.assignee_type, hasMore, isLoading, loaded, qc, queryKey]);
return { loadMore, hasMore, isLoading, total };
}
// ---------------------------------------------------------------------------
// Issue CRUD
// ---------------------------------------------------------------------------
@@ -126,6 +179,8 @@ export function useCreateIssue() {
},
onSettled: () => {
qc.invalidateQueries({ queryKey: issueKeys.list(wsId) });
qc.invalidateQueries({ queryKey: issueKeys.assigneeGroupsAll(wsId) });
qc.invalidateQueries({ queryKey: issueKeys.myAssigneeGroupsAll(wsId) });
},
});
}
@@ -200,6 +255,8 @@ export function useUpdateIssue() {
onSettled: (_data, _err, vars, ctx) => {
qc.invalidateQueries({ queryKey: issueKeys.detail(wsId, vars.id) });
qc.invalidateQueries({ queryKey: issueKeys.list(wsId) });
qc.invalidateQueries({ queryKey: issueKeys.assigneeGroupsAll(wsId) });
qc.invalidateQueries({ queryKey: issueKeys.myAssigneeGroupsAll(wsId) });
// Refresh the issue's attachments cache when the description editor
// bound new uploads — the description editor reads `issueAttachments`
// to resolve text-preview Eye gates, and unlike other mutations this
@@ -281,6 +338,8 @@ export function useDeleteIssue() {
},
onSettled: (_data, _err, _id, ctx) => {
qc.invalidateQueries({ queryKey: issueKeys.list(wsId) });
qc.invalidateQueries({ queryKey: issueKeys.assigneeGroupsAll(wsId) });
qc.invalidateQueries({ queryKey: issueKeys.myAssigneeGroupsAll(wsId) });
if (ctx?.metadata) invalidateDeletedIssueParentCaches(qc, wsId, ctx.metadata);
},
});
@@ -338,6 +397,8 @@ export function useBatchUpdateIssues() {
},
onSettled: (_data, _err, _vars, ctx) => {
qc.invalidateQueries({ queryKey: issueKeys.list(wsId) });
qc.invalidateQueries({ queryKey: issueKeys.assigneeGroupsAll(wsId) });
qc.invalidateQueries({ queryKey: issueKeys.myAssigneeGroupsAll(wsId) });
if (ctx?.affectedParentIds && ctx.affectedParentIds.size > 0) {
for (const parentId of ctx.affectedParentIds) {
qc.invalidateQueries({
@@ -438,6 +499,8 @@ export function useBatchDeleteIssues() {
},
onSettled: (_data, _err, _ids, ctx) => {
qc.invalidateQueries({ queryKey: issueKeys.list(wsId) });
qc.invalidateQueries({ queryKey: issueKeys.assigneeGroupsAll(wsId) });
qc.invalidateQueries({ queryKey: issueKeys.myAssigneeGroupsAll(wsId) });
if (ctx?.parentIssueIds && ctx.parentIssueIds.size > 0) {
invalidateDeletedIssueParentCaches(qc, wsId, {
parentIssueIds: Array.from(ctx.parentIssueIds),

View File

@@ -1,7 +1,9 @@
import { queryOptions } from "@tanstack/react-query";
import { api } from "../api";
import type {
GroupedIssuesResponse,
IssueStatus,
ListGroupedIssuesParams,
ListIssuesParams,
ListIssuesCache,
} from "../types";
@@ -10,11 +12,22 @@ import { BOARD_STATUSES } from "./config";
export const issueKeys = {
all: (wsId: string) => ["issues", wsId] as const,
list: (wsId: string) => [...issueKeys.all(wsId), "list"] as const,
assigneeGroupsAll: (wsId: string) =>
[...issueKeys.all(wsId), "assignee-groups"] as const,
assigneeGroups: (wsId: string, filter: AssigneeGroupedIssuesFilter) =>
[...issueKeys.assigneeGroupsAll(wsId), filter] as const,
/** All "my issues" queries — use for bulk invalidation. */
myAll: (wsId: string) => [...issueKeys.all(wsId), "my"] as const,
/** Per-scope "my issues" list with filter identity baked into the key. */
myList: (wsId: string, scope: string, filter: MyIssuesFilter) =>
[...issueKeys.myAll(wsId), scope, filter] as const,
myAssigneeGroupsAll: (wsId: string) =>
[...issueKeys.myAll(wsId), "assignee-groups"] as const,
myAssigneeGroups: (
wsId: string,
scope: string,
filter: AssigneeGroupedIssuesFilter,
) => [...issueKeys.myAssigneeGroupsAll(wsId), scope, filter] as const,
detail: (wsId: string, id: string) =>
[...issueKeys.all(wsId), "detail", id] as const,
children: (wsId: string, id: string) =>
@@ -45,6 +58,11 @@ export type MyIssuesFilter = Pick<
"assignee_id" | "assignee_ids" | "creator_id" | "project_id"
>;
export type AssigneeGroupedIssuesFilter = Omit<
ListGroupedIssuesParams,
"group_by" | "limit" | "offset" | "group_assignee_type" | "group_assignee_id"
>;
/** Page size per status column. */
export const ISSUE_PAGE_SIZE = 50;
@@ -92,6 +110,22 @@ export function issueListOptions(wsId: string) {
});
}
export function issueAssigneeGroupsOptions(
wsId: string,
filter: AssigneeGroupedIssuesFilter,
) {
return queryOptions<GroupedIssuesResponse>({
queryKey: issueKeys.assigneeGroups(wsId, filter),
queryFn: () =>
api.listGroupedIssues({
group_by: "assignee",
limit: ISSUE_PAGE_SIZE,
offset: 0,
...filter,
}),
});
}
/**
* Server-filtered issue list for the My Issues page.
* Each scope gets its own cache entry so switching tabs is instant after first load.
@@ -108,6 +142,23 @@ export function myIssueListOptions(
});
}
export function myIssueAssigneeGroupsOptions(
wsId: string,
scope: string,
filter: AssigneeGroupedIssuesFilter,
) {
return queryOptions<GroupedIssuesResponse>({
queryKey: issueKeys.myAssigneeGroups(wsId, scope, filter),
queryFn: () =>
api.listGroupedIssues({
group_by: "assignee",
limit: ISSUE_PAGE_SIZE,
offset: 0,
...filter,
}),
});
}
export function issueDetailOptions(wsId: string, id: string) {
return queryOptions({
queryKey: issueKeys.detail(wsId, id),

View File

@@ -0,0 +1,48 @@
"use client";
import { createStore, type StoreApi } from "zustand/vanilla";
import { persist } from "zustand/middleware";
import {
type IssueViewState,
viewStoreSlice,
viewStorePersistOptions,
mergeViewStatePersisted,
} from "./view-store";
import { registerForWorkspaceRehydration } from "../../platform/workspace-storage";
export type ActorIssuesScope = "assigned" | "created";
export interface ActorIssuesViewState extends IssueViewState {
scope: ActorIssuesScope;
setScope: (scope: ActorIssuesScope) => void;
}
const basePersist = viewStorePersistOptions("multica_actor_issues_view");
const _actorIssuesViewStore = createStore<ActorIssuesViewState>()(
persist(
(set) => ({
...viewStoreSlice(set as unknown as StoreApi<IssueViewState>["setState"]),
// Actor tasks panel is list-only; override the slice's "board" default.
viewMode: "list",
scope: "assigned" as ActorIssuesScope,
setScope: (scope: ActorIssuesScope) => set({ scope }),
}),
{
name: basePersist.name,
storage: basePersist.storage,
partialize: (state: ActorIssuesViewState) => ({
...basePersist.partialize(state),
scope: state.scope,
}),
merge: mergeViewStatePersisted<ActorIssuesViewState>,
},
),
);
export const actorIssuesViewStore: StoreApi<ActorIssuesViewState> =
_actorIssuesViewStore;
registerForWorkspaceRehydration(() =>
_actorIssuesViewStore.persist.rehydrate(),
);

View File

@@ -0,0 +1,44 @@
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import {
openCreateIssueWithPreference,
useCreateModeStore,
} from "./create-mode-store";
import { useModalStore } from "../../modals";
describe("openCreateIssueWithPreference", () => {
const initialMode = useCreateModeStore.getState().lastMode;
beforeEach(() => {
useModalStore.getState().close();
});
afterEach(() => {
useCreateModeStore.getState().setLastMode(initialMode);
useModalStore.getState().close();
});
it("opens quick-create-issue when last mode is agent", () => {
useCreateModeStore.getState().setLastMode("agent");
openCreateIssueWithPreference();
expect(useModalStore.getState().modal).toBe("quick-create-issue");
expect(useModalStore.getState().data).toBeNull();
});
it("opens create-issue when last mode is manual", () => {
useCreateModeStore.getState().setLastMode("manual");
openCreateIssueWithPreference();
expect(useModalStore.getState().modal).toBe("create-issue");
});
it("forwards seed data to whichever modal is opened", () => {
useCreateModeStore.getState().setLastMode("manual");
openCreateIssueWithPreference({ project_id: "p1" });
expect(useModalStore.getState().modal).toBe("create-issue");
expect(useModalStore.getState().data).toEqual({ project_id: "p1" });
useCreateModeStore.getState().setLastMode("agent");
openCreateIssueWithPreference({ project_id: "p2" });
expect(useModalStore.getState().modal).toBe("quick-create-issue");
expect(useModalStore.getState().data).toEqual({ project_id: "p2" });
});
});

View File

@@ -3,6 +3,7 @@
import { create } from "zustand";
import { createJSONStorage, persist } from "zustand/middleware";
import { defaultStorage } from "../../platform/storage";
import { useModalStore } from "../../modals";
/**
* Last create-issue mode the user landed on. Drives the global `c` shortcut
@@ -34,3 +35,18 @@ export const useCreateModeStore = create<CreateModeState>()(
},
),
);
/**
* Open the create-issue flow in whichever mode the user landed on last.
* Generic entry points (sidebar button, command palette, `c` shortcut) call
* this so the persisted preference actually takes effect; entry points that
* pre-seed manual-only fields (status, parent_issue_id) keep opening
* "create-issue" directly because agent mode can't honour those seeds.
*/
export function openCreateIssueWithPreference(
data?: Record<string, unknown> | null,
) {
const lastMode = useCreateModeStore.getState().lastMode;
const modal = lastMode === "manual" ? "create-issue" : "quick-create-issue";
useModalStore.getState().open(modal, data ?? null);
}

View File

@@ -9,6 +9,7 @@ const RESET_STATE = {
priority: "none" as const,
assigneeType: undefined,
assigneeId: undefined,
startDate: null,
dueDate: null,
},
lastAssigneeType: undefined,

View File

@@ -11,6 +11,7 @@ interface IssueDraft {
priority: IssuePriority;
assigneeType?: IssueAssigneeType;
assigneeId?: string;
startDate: string | null;
dueDate: string | null;
}
@@ -21,6 +22,7 @@ const EMPTY_DRAFT: IssueDraft = {
priority: "none",
assigneeType: undefined,
assigneeId: undefined,
startDate: null,
dueDate: null,
};

View File

@@ -1,5 +1,9 @@
export { useIssueSelectionStore } from "./selection-store";
export { useCreateModeStore, type CreateMode } from "./create-mode-store";
export {
useCreateModeStore,
openCreateIssueWithPreference,
type CreateMode,
} from "./create-mode-store";
export { useIssueDraftStore } from "./draft-store";
export {
useRecentIssuesStore,
@@ -19,6 +23,11 @@ export {
type MyIssuesViewState,
type MyIssuesScope,
} from "./my-issues-view-store";
export {
actorIssuesViewStore,
type ActorIssuesViewState,
type ActorIssuesScope,
} from "./actor-issues-view-store";
export {
useIssueViewStore,
createIssueViewStore,

View File

@@ -10,13 +10,15 @@ import { createWorkspaceAwareStorage, registerForWorkspaceRehydration } from "..
import { defaultStorage } from "../../platform/storage";
export type ViewMode = "board" | "list";
export type SortField = "position" | "priority" | "due_date" | "created_at" | "title";
export type IssueGrouping = "status" | "assignee";
export type SortField = "position" | "priority" | "start_date" | "due_date" | "created_at" | "title";
export type SortDirection = "asc" | "desc";
export interface CardProperties {
priority: boolean;
description: boolean;
assignee: boolean;
startDate: boolean;
dueDate: boolean;
project: boolean;
childProgress: boolean;
@@ -31,15 +33,22 @@ export interface ActorFilterValue {
export const SORT_OPTIONS: { value: SortField; label: string }[] = [
{ value: "position", label: "Manual" },
{ value: "priority", label: "Priority" },
{ value: "start_date", label: "Start date" },
{ value: "due_date", label: "Due date" },
{ value: "created_at", label: "Created date" },
{ value: "title", label: "Title" },
];
export const GROUPING_OPTIONS: { value: IssueGrouping; label: string }[] = [
{ value: "status", label: "Status" },
{ value: "assignee", label: "Assignee" },
];
export const CARD_PROPERTY_OPTIONS: { key: keyof CardProperties; label: string }[] = [
{ key: "priority", label: "Priority" },
{ key: "description", label: "Description" },
{ key: "assignee", label: "Assignee" },
{ key: "startDate", label: "Start date" },
{ key: "dueDate", label: "Due date" },
{ key: "project", label: "Project" },
{ key: "labels", label: "Labels" },
@@ -48,6 +57,7 @@ export const CARD_PROPERTY_OPTIONS: { key: keyof CardProperties; label: string }
export interface IssueViewState {
viewMode: ViewMode;
grouping: IssueGrouping;
statusFilters: IssueStatus[];
priorityFilters: IssuePriority[];
assigneeFilters: ActorFilterValue[];
@@ -61,6 +71,7 @@ export interface IssueViewState {
cardProperties: CardProperties;
listCollapsedStatuses: IssueStatus[];
setViewMode: (mode: ViewMode) => void;
setGrouping: (grouping: IssueGrouping) => void;
toggleStatusFilter: (status: IssueStatus) => void;
togglePriorityFilter: (priority: IssuePriority) => void;
toggleAssigneeFilter: (value: ActorFilterValue) => void;
@@ -80,6 +91,7 @@ export interface IssueViewState {
export const viewStoreSlice = (set: StoreApi<IssueViewState>["setState"]): IssueViewState => ({
viewMode: "board",
grouping: "status",
statusFilters: [],
priorityFilters: [],
assigneeFilters: [],
@@ -94,6 +106,7 @@ export const viewStoreSlice = (set: StoreApi<IssueViewState>["setState"]): Issue
priority: true,
description: true,
assignee: true,
startDate: true,
dueDate: true,
project: true,
childProgress: true,
@@ -102,6 +115,7 @@ export const viewStoreSlice = (set: StoreApi<IssueViewState>["setState"]): Issue
listCollapsedStatuses: [],
setViewMode: (mode) => set({ viewMode: mode }),
setGrouping: (grouping) => set({ grouping }),
toggleStatusFilter: (status) =>
set((state) => ({
statusFilters: state.statusFilters.includes(status)
@@ -205,6 +219,7 @@ export const viewStorePersistOptions = (name: string) => ({
storage: createJSONStorage(() => createWorkspaceAwareStorage(defaultStorage)),
partialize: (state: IssueViewState) => ({
viewMode: state.viewMode,
grouping: state.grouping,
statusFilters: state.statusFilters,
priorityFilters: state.priorityFilters,
assigneeFilters: state.assigneeFilters,

View File

@@ -64,6 +64,7 @@ const baseIssue: Issue = {
parent_issue_id: null,
project_id: null,
position: 0,
start_date: null,
due_date: null,
labels: [labelA],
created_at: "2025-01-01T00:00:00Z",

View File

@@ -19,6 +19,8 @@ export function onIssueCreated(
old ? addIssueToBuckets(old, issue) : old,
);
qc.invalidateQueries({ queryKey: issueKeys.myAll(wsId) });
qc.invalidateQueries({ queryKey: issueKeys.assigneeGroupsAll(wsId) });
qc.invalidateQueries({ queryKey: issueKeys.myAssigneeGroupsAll(wsId) });
if (issue.parent_issue_id) {
qc.invalidateQueries({ queryKey: issueKeys.children(wsId, issue.parent_issue_id) });
qc.invalidateQueries({ queryKey: issueKeys.childProgress(wsId) });
@@ -48,6 +50,8 @@ export function onIssueUpdated(
old ? patchIssueInBuckets(old, issue.id, issue) : old,
);
qc.invalidateQueries({ queryKey: issueKeys.myAll(wsId) });
qc.invalidateQueries({ queryKey: issueKeys.assigneeGroupsAll(wsId) });
qc.invalidateQueries({ queryKey: issueKeys.myAssigneeGroupsAll(wsId) });
qc.setQueryData<Issue>(issueKeys.detail(wsId, issue.id), (old) =>
old ? { ...old, ...issue } : old,
);
@@ -100,6 +104,8 @@ export function onIssueLabelsChanged(
old ? { ...old, labels } : old,
);
qc.invalidateQueries({ queryKey: issueKeys.myAll(wsId) });
qc.invalidateQueries({ queryKey: issueKeys.assigneeGroupsAll(wsId) });
qc.invalidateQueries({ queryKey: issueKeys.myAssigneeGroupsAll(wsId) });
}
export function onIssueDeleted(
@@ -108,4 +114,6 @@ export function onIssueDeleted(
issueId: string,
) {
cleanupDeletedIssueCaches(qc, wsId, issueId);
qc.invalidateQueries({ queryKey: issueKeys.assigneeGroupsAll(wsId) });
qc.invalidateQueries({ queryKey: issueKeys.myAssigneeGroupsAll(wsId) });
}

View File

@@ -15,6 +15,7 @@
"./api": "./api/index.ts",
"./api/client": "./api/client.ts",
"./api/schema": "./api/schema.ts",
"./api/schemas": "./api/schemas.ts",
"./api/ws-client": "./api/ws-client.ts",
"./config": "./config/index.ts",
"./auth": "./auth/index.ts",
@@ -108,8 +109,11 @@
},
"devDependencies": {
"@multica/tsconfig": "workspace:*",
"@testing-library/react": "catalog:",
"@types/react": "catalog:",
"jsdom": "catalog:",
"react": "catalog:",
"react-dom": "catalog:",
"typescript": "catalog:",
"vitest": "catalog:"
}

View File

@@ -13,6 +13,7 @@ describe("paths.workspace(slug)", () => {
expect(ws.autopilots()).toBe("/acme/autopilots");
expect(ws.autopilotDetail("a1")).toBe("/acme/autopilots/a1");
expect(ws.agents()).toBe("/acme/agents");
expect(ws.memberDetail("u1")).toBe("/acme/members/u1");
expect(ws.inbox()).toBe("/acme/inbox");
expect(ws.myIssues()).toBe("/acme/my-issues");
expect(ws.runtimes()).toBe("/acme/runtimes");
@@ -21,6 +22,7 @@ describe("paths.workspace(slug)", () => {
expect(ws.squads()).toBe("/acme/squads");
expect(ws.squadDetail("sq_1")).toBe("/acme/squads/sq_1");
expect(ws.settings()).toBe("/acme/settings");
expect(ws.attachmentPreview("att_42")).toBe("/acme/attachments/att_42/preview");
});
it("URL-encodes special characters in ids", () => {

View File

@@ -27,6 +27,7 @@ function workspaceScoped(slug: string) {
autopilotDetail: (id: string) => `${ws}/autopilots/${encode(id)}`,
agents: () => `${ws}/agents`,
agentDetail: (id: string) => `${ws}/agents/${encode(id)}`,
memberDetail: (id: string) => `${ws}/members/${encode(id)}`,
squads: () => `${ws}/squads`,
squadDetail: (id: string) => `${ws}/squads/${encode(id)}`,
inbox: () => `${ws}/inbox`,
@@ -36,6 +37,7 @@ function workspaceScoped(slug: string) {
skills: () => `${ws}/skills`,
skillDetail: (id: string) => `${ws}/skills/${encode(id)}`,
settings: () => `${ws}/settings`,
attachmentPreview: (id: string) => `${ws}/attachments/${encode(id)}/preview`,
};
}

View File

@@ -4,7 +4,7 @@ import { useEffect } from "react";
import type { WSEventType } from "../types";
import { useWS } from "./provider";
type EventHandler = (payload: unknown, actorId?: string) => void;
type EventHandler = (payload: unknown, actorId?: string, actorType?: string) => void;
/**
* Hook that subscribes to a WebSocket event and calls the handler.

View File

@@ -21,7 +21,7 @@ import {
import { createLogger } from "../logger";
import { useRealtimeSync, type RealtimeSyncStores } from "./use-realtime-sync";
type EventHandler = (payload: unknown, actorId?: string) => void;
type EventHandler = (payload: unknown, actorId?: string, actorType?: string) => void;
interface WSContextValue {
subscribe: (event: WSEventType, handler: EventHandler) => () => void;

View File

@@ -0,0 +1,122 @@
/**
* @vitest-environment jsdom
*/
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { renderHook } from "@testing-library/react";
import type { ReactNode } from "react";
import { describe, expect, it, vi, beforeEach } from "vitest";
import type { WSClient } from "../api/ws-client";
import { useRealtimeSync, type RealtimeSyncStores } from "./use-realtime-sync";
vi.mock("../platform/workspace-storage", () => ({
getCurrentWsId: () => "ws-1",
getCurrentSlug: () => "test-ws",
}));
vi.mock("../paths", () => ({
useHasOnboarded: () => true,
resolvePostAuthDestination: () => "/",
}));
function createMockWs(): WSClient {
return {
on: vi.fn(() => () => {}),
onAny: vi.fn(() => () => {}),
onReconnect: vi.fn(() => () => {}),
} as unknown as WSClient;
}
function createStores(): RealtimeSyncStores {
return {
authStore: Object.assign(() => ({}), {
getState: () => ({ user: { id: "u1" } }),
subscribe: () => () => {},
setState: () => {},
destroy: () => {},
}),
} as unknown as RealtimeSyncStores;
}
function createWrapper(qc: QueryClient) {
// Named function (not arrow) so react/display-name lint rule passes —
// anonymous render-fn components break that rule even in test files.
return function Wrapper({ children }: { children: ReactNode }) {
return <QueryClientProvider client={qc}>{children}</QueryClientProvider>;
};
}
describe("useRealtimeSync — ws instance change", () => {
let qc: QueryClient;
let stores: RealtimeSyncStores;
let invalidateSpy: ReturnType<typeof vi.spyOn>;
beforeEach(() => {
qc = new QueryClient({ defaultOptions: { queries: { retry: false } } });
stores = createStores();
invalidateSpy = vi.spyOn(qc, "invalidateQueries");
});
it("skips invalidation on first non-null ws instance", () => {
const ws = createMockWs();
renderHook(() => useRealtimeSync(ws, stores), {
wrapper: createWrapper(qc),
});
// The main effect calls invalidateQueries for its own setup, but the
// ws-instance-change effect should NOT have fired invalidation.
// The only invalidateQueries calls should come from the main effect's
// event handlers, not from the instance-change effect.
// We verify by checking that no call was made with workspaceKeys.list()
// pattern from the instance-change path (it logs a specific message).
// Simpler: count calls — first mount with a ws should not trigger the
// workspace-scoped bulk invalidation.
expect(invalidateSpy).not.toHaveBeenCalled();
});
it("does not invalidate when ws goes from instance to null", () => {
const ws1 = createMockWs();
const { rerender } = renderHook(
({ ws }) => useRealtimeSync(ws, stores),
{ initialProps: { ws: ws1 as WSClient | null }, wrapper: createWrapper(qc) },
);
invalidateSpy.mockClear();
rerender({ ws: null });
expect(invalidateSpy).not.toHaveBeenCalled();
});
it("invalidates exactly once when a new ws instance appears after null gap", () => {
const ws1 = createMockWs();
const { rerender } = renderHook(
({ ws }) => useRealtimeSync(ws, stores),
{ initialProps: { ws: ws1 as WSClient | null }, wrapper: createWrapper(qc) },
);
// Simulate workspace switch: ws -> null -> new ws
invalidateSpy.mockClear();
rerender({ ws: null });
expect(invalidateSpy).not.toHaveBeenCalled();
const ws2 = createMockWs();
rerender({ ws: ws2 });
// Should have called invalidateQueries for all workspace-scoped keys
// (12 workspace-scoped + 1 workspaceKeys.list() = 13 calls)
expect(invalidateSpy).toHaveBeenCalledTimes(13);
});
it("does not re-invalidate when rerendered with the same ws instance", () => {
const ws1 = createMockWs();
const { rerender } = renderHook(
({ ws }) => useRealtimeSync(ws, stores),
{ initialProps: { ws: ws1 as WSClient | null }, wrapper: createWrapper(qc) },
);
invalidateSpy.mockClear();
// Rerender with same instance
rerender({ ws: ws1 });
expect(invalidateSpy).not.toHaveBeenCalled();
});
});

View File

@@ -107,6 +107,30 @@ export function applyChatDoneToCache(
qc.invalidateQueries({ queryKey: chatKeys.pendingTask(sessionId) });
}
/**
* Invalidates all workspace-scoped queries. Used after reconnect and when a
* new WSClient instance is detected (workspace switch) to recover events
* missed while disconnected.
*/
function invalidateWorkspaceScopedQueries(qc: QueryClient): void {
const wsId = getCurrentWsId();
if (wsId) {
qc.invalidateQueries({ queryKey: issueKeys.all(wsId) });
qc.invalidateQueries({ queryKey: inboxKeys.all(wsId) });
qc.invalidateQueries({ queryKey: workspaceKeys.agents(wsId) });
qc.invalidateQueries({ queryKey: workspaceKeys.members(wsId) });
qc.invalidateQueries({ queryKey: workspaceKeys.squads(wsId) });
qc.invalidateQueries({ queryKey: workspaceKeys.skills(wsId) });
qc.invalidateQueries({ queryKey: projectKeys.all(wsId) });
qc.invalidateQueries({ queryKey: runtimeKeys.all(wsId) });
qc.invalidateQueries({ queryKey: autopilotKeys.all(wsId) });
qc.invalidateQueries({ queryKey: agentTaskSnapshotKeys.all(wsId) });
qc.invalidateQueries({ queryKey: agentActivityKeys.all(wsId) });
qc.invalidateQueries({ queryKey: agentRunCountsKeys.all(wsId) });
}
qc.invalidateQueries({ queryKey: workspaceKeys.list() });
}
export interface RealtimeSyncStores {
authStore: UseBoundStore<StoreApi<AuthState>>;
}
@@ -154,7 +178,14 @@ export function useRealtimeSync(
},
agent: () => {
const wsId = getCurrentWsId();
if (wsId) qc.invalidateQueries({ queryKey: workspaceKeys.agents(wsId) });
if (wsId) {
qc.invalidateQueries({ queryKey: workspaceKeys.agents(wsId) });
// Squad members status is derived per agent, so any agent
// change (status flip, archive, runtime swap) needs to refresh
// the per-squad members-status cache. Prefix-matches both the
// squad list and every squadMemberStatus query.
qc.invalidateQueries({ queryKey: workspaceKeys.squads(wsId) });
}
},
member: () => {
const wsId = getCurrentWsId();
@@ -197,7 +228,14 @@ export function useRealtimeSync(
},
daemon: () => {
const wsId = getCurrentWsId();
if (wsId) qc.invalidateQueries({ queryKey: runtimeKeys.all(wsId) });
if (wsId) {
qc.invalidateQueries({ queryKey: runtimeKeys.all(wsId) });
// Runtime online/offline transitions move the derived status
// for every agent that hosts on this runtime, which shifts the
// working/idle/offline pill on the squad page. Same prefix
// invalidation pattern as the agent handler above.
qc.invalidateQueries({ queryKey: workspaceKeys.squads(wsId) });
}
},
autopilot: () => {
const wsId = getCurrentWsId();
@@ -239,6 +277,14 @@ export function useRealtimeSync(
// every list-of-tasks query stale" so cache stays fresh even
// when the relevant component isn't currently mounted.
qc.invalidateQueries({ queryKey: ["issues", "tasks"] });
// Per-issue token usage card (issue-detail right rail). Same
// shape as the tasks invalidation above — any task lifecycle
// event shifts the aggregated usage numbers.
qc.invalidateQueries({ queryKey: ["issues", "usage"] });
// Squad members-status reads the same task lifecycle to flip
// working ↔ idle for each agent member. Prefix-matches every
// mounted squad-page's members-status query in O(1).
qc.invalidateQueries({ queryKey: workspaceKeys.squads(wsId) });
},
};
@@ -833,21 +879,7 @@ export function useRealtimeSync(
const unsub = ws.onReconnect(async () => {
logger.info("reconnected, refetching all data");
try {
const wsId = getCurrentWsId();
if (wsId) {
qc.invalidateQueries({ queryKey: issueKeys.all(wsId) });
qc.invalidateQueries({ queryKey: inboxKeys.all(wsId) });
qc.invalidateQueries({ queryKey: workspaceKeys.agents(wsId) });
qc.invalidateQueries({ queryKey: workspaceKeys.members(wsId) });
qc.invalidateQueries({ queryKey: workspaceKeys.skills(wsId) });
qc.invalidateQueries({ queryKey: projectKeys.all(wsId) });
qc.invalidateQueries({ queryKey: runtimeKeys.all(wsId) });
qc.invalidateQueries({ queryKey: autopilotKeys.all(wsId) });
qc.invalidateQueries({ queryKey: agentTaskSnapshotKeys.all(wsId) });
qc.invalidateQueries({ queryKey: agentActivityKeys.all(wsId) });
qc.invalidateQueries({ queryKey: agentRunCountsKeys.all(wsId) });
}
qc.invalidateQueries({ queryKey: workspaceKeys.list() });
invalidateWorkspaceScopedQueries(qc);
} catch (e) {
logger.error("reconnect refetch failed", e);
}
@@ -855,4 +887,22 @@ export function useRealtimeSync(
return unsub;
}, [ws, qc]);
// New WSClient instance (workspace switch) -> invalidate workspace-scoped
// queries to recover events missed while the previous instance was torn down.
// Skips the initial assignment to avoid a redundant refetch on first mount.
const wsInstanceRef = useRef<WSClient | null>(null);
useEffect(() => {
if (!ws) return;
if (wsInstanceRef.current === null) {
// First non-null instance — store and skip invalidation.
wsInstanceRef.current = ws;
return;
}
if (wsInstanceRef.current === ws) return;
wsInstanceRef.current = ws;
logger.info("new WSClient instance detected, invalidating workspace queries");
invalidateWorkspaceScopedQueries(qc);
}, [ws, qc]);
}

View File

@@ -14,6 +14,17 @@ export const runtimeLocalSkillsKeys = {
const POLL_INTERVAL_MS = 500;
const POLL_TIMEOUT_MS = 30_000;
// Import timeout is longer than discovery because old daemons (pre-batch) pop
// only one import per heartbeat cycle (~15s). With 10 queued imports the 10th
// can wait up to 150s in pending before being claimed, plus up to 60s for
// the daemon to actually run the import.
//
// Timeout invariant: IMPORT_POLL_TIMEOUT_MS must exceed
// runtimeLocalSkillPendingTimeout + runtimeLocalSkillRunningTimeout
// (server/internal/handler/runtime_local_skills.go).
// See also IMPORT_CONCURRENCY in packages/views/.../runtime-local-skill-import-panel.tsx
// and maxLocalSkillImportBatch in server/internal/handler/daemon.go.
const IMPORT_POLL_TIMEOUT_MS = 4 * 60_000; // 4 minutes
export async function resolveRuntimeLocalSkills(
runtimeId: string,
@@ -49,7 +60,7 @@ export async function resolveRuntimeLocalSkillImport(
let current = initial;
while (current.status === "pending" || current.status === "running") {
if (Date.now() - start > POLL_TIMEOUT_MS) {
if (Date.now() - start > IMPORT_POLL_TIMEOUT_MS) {
throw new Error("runtime local skill import timed out");
}
await new Promise((resolve) => setTimeout(resolve, POLL_INTERVAL_MS));

View File

@@ -397,6 +397,17 @@ export interface DashboardAgentRunTime {
failed_count: number;
}
// One (date) bucket of terminal-task run-time + counts for the workspace
// dashboard. Powers the Time and Tasks metrics on the daily-trend toggle
// — same toggle as Tokens / Cost, anchored on completed_at so day buckets
// line up with the per-agent run-time card.
export interface DashboardRunTimeDaily {
date: string;
total_seconds: number;
task_count: number;
failed_count: number;
}
export type RuntimeUpdateStatus =
| "pending"
| "running"

View File

@@ -12,6 +12,7 @@ export interface CreateIssueRequest {
assignee_id?: string;
parent_issue_id?: string;
project_id?: string;
start_date?: string;
due_date?: string;
attachment_ids?: string[];
}
@@ -24,6 +25,7 @@ export interface UpdateIssueRequest {
assignee_type?: IssueAssigneeType | null;
assignee_id?: string | null;
position?: number;
start_date?: string | null;
due_date?: string | null;
parent_issue_id?: string | null;
project_id?: string | null;
@@ -46,12 +48,52 @@ export interface ListIssuesParams {
open_only?: boolean;
}
export interface IssueActorRef {
type: IssueAssigneeType;
id: string;
}
export interface ListGroupedIssuesParams {
group_by: "assignee";
limit?: number;
offset?: number;
workspace_id?: string;
statuses?: IssueStatus[];
priorities?: IssuePriority[];
assignee_types?: IssueAssigneeType[];
assignee_id?: string;
assignee_ids?: string[];
creator_id?: string;
project_id?: string;
assignee_filters?: IssueActorRef[];
include_no_assignee?: boolean;
creator_filters?: IssueActorRef[];
project_ids?: string[];
include_no_project?: boolean;
label_ids?: string[];
group_assignee_type?: IssueAssigneeType | "none";
group_assignee_id?: string;
}
/** Raw backend response shape for `GET /api/issues`. */
export interface ListIssuesResponse {
issues: Issue[];
total: number;
}
export interface IssueAssigneeGroup {
id: string;
assignee_type: IssueAssigneeType | null;
assignee_id: string | null;
issues: Issue[];
total: number;
}
/** Raw backend response shape for `GET /api/issues/grouped?group_by=assignee`. */
export interface GroupedIssuesResponse {
groups: IssueAssigneeGroup[];
}
/** Per-status bucket in the paginated issue cache. `total` is the server count (all pages), not the length of `issues`. */
export interface IssueStatusBucket {
issues: Issue[];
@@ -70,6 +112,8 @@ export interface ListIssuesCache {
export interface SearchIssueResult extends Issue {
match_source: "title" | "description" | "comment";
matched_snippet?: string;
matched_description_snippet?: string;
matched_comment_snippet?: string;
}
export interface SearchIssuesResponse {

View File

@@ -4,7 +4,16 @@ export type AutopilotExecutionMode = "create_issue" | "run_only";
export type AutopilotTriggerKind = "schedule" | "webhook" | "api";
export type AutopilotRunStatus = "issue_created" | "running" | "completed" | "failed";
// `skipped` is emitted by the backend pre-flight admission check
// (assignee runtime offline at dispatch time, MUL-1899). The frontend MUST
// handle it explicitly — falling through to a generic case used to show
// the run as still-pending which masked the no-op.
export type AutopilotRunStatus =
| "issue_created"
| "running"
| "completed"
| "failed"
| "skipped";
export type AutopilotRunSource = "schedule" | "manual" | "webhook" | "api";
@@ -33,6 +42,14 @@ export interface AutopilotTrigger {
timezone: string | null;
next_run_at: string | null;
webhook_token: string | null;
// webhook_path is computed server-side from webhook_token (always
// "/api/webhooks/autopilots/{token}"). Optional so older servers can be
// talked to gracefully.
webhook_path?: string | null;
// webhook_url is only present when MULTICA_PUBLIC_URL is configured
// server-side. Clients fall back to composing from getBaseUrl/origin +
// webhook_path when this is missing.
webhook_url?: string | null;
label: string | null;
last_fired_at: string | null;
created_at: string;
@@ -100,3 +117,52 @@ export interface ListAutopilotRunsResponse {
runs: AutopilotRun[];
total: number;
}
// Webhook delivery enum is server-canonical. The frontend MUST `default`
// any switch on it to a generic fallback — see API Response Compatibility
// rules in CLAUDE.md. PR1 collapsed `skipped` into `dispatched` (the run
// itself carries the skip state); a future server may add new values.
export type WebhookDeliveryStatus =
| "queued"
| "dispatched"
| "rejected"
| "ignored"
| "failed";
export type WebhookSignatureStatus =
| "not_required"
| "valid"
| "invalid"
| "missing";
export interface WebhookDelivery {
id: string;
workspace_id: string;
autopilot_id: string;
trigger_id: string;
provider: string;
event: string;
dedupe_key: string | null;
dedupe_source: string | null;
signature_status: WebhookSignatureStatus;
status: WebhookDeliveryStatus;
attempt_count: number;
content_type: string | null;
response_status: number | null;
autopilot_run_id: string | null;
replayed_from_delivery_id: string | null;
error: string | null;
received_at: string;
last_attempt_at: string;
created_at: string;
// Detail-only fields. The list endpoint omits these to keep the wire
// size bounded (raw_body alone can be up to 256 KiB per delivery).
selected_headers?: Record<string, unknown> | null;
raw_body?: string | null;
response_body?: string | null;
}
export interface ListWebhookDeliveriesResponse {
deliveries: WebhookDelivery[];
total: number;
}

View File

@@ -82,6 +82,7 @@ export interface WSMessage<T = unknown> {
type: WSEventType;
payload: T;
actor_id?: string;
actor_type?: string;
}
export interface IssueCreatedPayload {

View File

@@ -1,5 +1,16 @@
export type GitHubPullRequestState = "open" | "closed" | "merged" | "draft";
/** Aggregated CI status for a PR's current head SHA, computed server-side from
* the latest check_suite per app. `null` when no completed suite has been seen
* yet (e.g. PR just opened, or repository has no CI configured). */
export type GitHubPullRequestChecksConclusion = "passed" | "failed" | "pending";
/** Raw mirror of GitHub's `mergeable_state`. The UI only surfaces `clean` and
* `dirty`; the other values (`blocked`, `behind`, `unstable`, `unknown`,
* `has_hooks`, `draft`) round-trip but render as unknown to avoid asserting
* "conflicts" for blocking reasons that aren't actual conflicts. */
export type GitHubMergeableState = string;
export interface GitHubInstallation {
id: string;
workspace_id: string;
@@ -26,6 +37,20 @@ export interface GitHubPullRequest {
closed_at: string | null;
pr_created_at: string;
pr_updated_at: string;
/** Optional; older backends omit this field. */
mergeable_state?: GitHubMergeableState | null;
/** Optional; older backends omit this field. */
checks_conclusion?: GitHubPullRequestChecksConclusion | null;
/** Per-suite counts that feed the segmented progress bar. Older backends
* omit these; treat absence as 0 (the card renders only when sum > 0). */
checks_passed?: number;
checks_failed?: number;
checks_pending?: number;
/** Diff stats from GitHub's `pull_request` payload. Older backends omit
* these fields; we treat 0/0/0 as "unknown" and hide the stats row. */
additions?: number;
deletions?: number;
changed_files?: number;
}
export interface ListGitHubInstallationsResponse {

View File

@@ -8,6 +8,7 @@ export type InboxItemType =
| "assignee_changed"
| "status_changed"
| "priority_changed"
| "start_date_changed"
| "due_date_changed"
| "new_comment"
| "mentioned"

View File

@@ -32,6 +32,7 @@ export type {
DashboardUsageDaily,
DashboardUsageByAgent,
DashboardAgentRunTime,
DashboardRunTimeDaily,
RuntimeUpdate,
RuntimeUpdateStatus,
RuntimeModel,
@@ -78,7 +79,9 @@ export type {
export type { PinnedItem, PinnedItemType, CreatePinRequest, ReorderPinsRequest } from "./pin";
export type {
GitHubInstallation,
GitHubMergeableState,
GitHubPullRequest,
GitHubPullRequestChecksConclusion,
GitHubPullRequestState,
ListGitHubInstallationsResponse,
GitHubConnectResponse,
@@ -99,6 +102,10 @@ export type {
ListAutopilotsResponse,
GetAutopilotResponse,
ListAutopilotRunsResponse,
WebhookDelivery,
WebhookDeliveryStatus,
WebhookSignatureStatus,
ListWebhookDeliveriesResponse,
} from "./autopilot";
export type {
Squad,
@@ -112,4 +119,8 @@ export type {
RemoveSquadMemberRequest,
UpdateSquadMemberRoleRequest,
CreateSquadActivityLogRequest,
SquadMemberStatusValue,
SquadActiveIssueBrief,
SquadMemberStatus,
SquadMemberStatusListResponse,
} from "./squad";

View File

@@ -38,6 +38,7 @@ export interface Issue {
parent_issue_id: string | null;
project_id: string | null;
position: number;
start_date: string | null;
due_date: string | null;
reactions?: IssueReaction[];
labels?: Label[];

View File

@@ -41,6 +41,7 @@ export interface CreateSquadRequest {
name: string;
description?: string;
leader_id: string;
avatar_url?: string;
}
export interface UpdateSquadRequest {
@@ -75,3 +76,32 @@ export interface CreateSquadActivityLogRequest {
outcome: SquadActivityOutcome;
details?: unknown;
}
// SquadMemberStatus mirrors the four-way bucket the back-end derives in
// handler/squad.go::deriveSquadMemberStatus. Kept as a string union here
// (rather than re-derived from snapshot data) so the squad page can render
// the freshest server-side judgement without re-fetching the agent
// snapshot / runtime list.
export type SquadMemberStatusValue = "working" | "idle" | "offline" | "unstable";
export interface SquadActiveIssueBrief {
issue_id: string;
identifier: string;
title: string;
issue_status: string;
}
export interface SquadMemberStatus {
member_type: SquadMemberType;
member_id: string;
// Human members are returned with status === null so the UI can render
// them in the same list without showing a status pill (v1 has no
// presence signal for humans).
status: SquadMemberStatusValue | null;
active_issues: SquadActiveIssueBrief[];
last_active_at: string | null;
}
export interface SquadMemberStatusListResponse {
members: SquadMemberStatus[];
}

View File

@@ -1,5 +1,6 @@
"use client";
import { useCallback, useMemo } from "react";
import { useQuery } from "@tanstack/react-query";
import { useWorkspaceId } from "../hooks";
import { memberListOptions, agentListOptions, squadListOptions } from "./queries";
@@ -10,30 +11,30 @@ export function useActorName() {
const { data: agents = [] } = useQuery(agentListOptions(wsId));
const { data: squads = [] } = useQuery(squadListOptions(wsId));
const getMemberName = (userId: string) => {
const getMemberName = useCallback((userId: string) => {
const m = members.find((m) => m.user_id === userId);
return m?.name ?? "Unknown";
};
}, [members]);
const getAgentName = (agentId: string) => {
const getAgentName = useCallback((agentId: string) => {
const a = agents.find((a) => a.id === agentId);
return a?.name ?? "Unknown Agent";
};
}, [agents]);
const getSquadName = (squadId: string) => {
const getSquadName = useCallback((squadId: string) => {
const s = squads.find((s) => s.id === squadId);
return s?.name ?? "Unknown Squad";
};
}, [squads]);
const getActorName = (type: string, id: string) => {
const getActorName = useCallback((type: string, id: string) => {
if (type === "member") return getMemberName(id);
if (type === "agent") return getAgentName(id);
if (type === "squad") return getSquadName(id);
if (type === "system") return "Multica";
return "System";
};
}, [getAgentName, getMemberName, getSquadName]);
const getActorInitials = (type: string, id: string) => {
const getActorInitials = useCallback((type: string, id: string) => {
const name = getActorName(type, id);
return name
.split(" ")
@@ -41,14 +42,31 @@ export function useActorName() {
.join("")
.toUpperCase()
.slice(0, 2);
};
}, [getActorName]);
const getActorAvatarUrl = (type: string, id: string): string | null => {
const getActorAvatarUrl = useCallback((type: string, id: string): string | null => {
if (type === "member") return members.find((m) => m.user_id === id)?.avatar_url ?? null;
if (type === "agent") return agents.find((a) => a.id === id)?.avatar_url ?? null;
if (type === "squad") return squads.find((s) => s.id === id)?.avatar_url ?? null;
return null;
};
}, [agents, members, squads]);
return { getMemberName, getAgentName, getSquadName, getActorName, getActorInitials, getActorAvatarUrl };
return useMemo(
() => ({
getMemberName,
getAgentName,
getSquadName,
getActorName,
getActorInitials,
getActorAvatarUrl,
}),
[
getActorAvatarUrl,
getActorInitials,
getActorName,
getAgentName,
getMemberName,
getSquadName,
],
);
}

View File

@@ -10,6 +10,11 @@ export const workspaceKeys = {
myInvitations: () => ["invitations", "mine"] as const,
agents: (wsId: string) => ["workspaces", wsId, "agents"] as const,
squads: (wsId: string) => ["workspaces", wsId, "squads"] as const,
// Per-squad member status. Lives under the workspace key tree so
// workspace switches naturally drop the cache, and so a broad
// `["workspaces", wsId, "squads"]` invalidation covers it.
squadMemberStatus: (wsId: string, squadId: string) =>
["workspaces", wsId, "squads", squadId, "members-status"] as const,
skills: (wsId: string) => ["workspaces", wsId, "skills"] as const,
assigneeFrequency: (wsId: string) => ["workspaces", wsId, "assignee-frequency"] as const,
};
@@ -52,6 +57,20 @@ export function squadListOptions(wsId: string) {
});
}
// Per-squad members status snapshot. The freshness signal is the WS task /
// agent / runtime invalidation wired in use-realtime-sync (which broadly
// invalidates `["workspaces", wsId, "squads"]`); the staleTime is a
// tab-focus safety net.
export function squadMemberStatusOptions(wsId: string, squadId: string) {
return queryOptions({
queryKey: workspaceKeys.squadMemberStatus(wsId, squadId),
queryFn: () => api.getSquadMemberStatus(squadId),
enabled: !!wsId && !!squadId,
staleTime: 30 * 1000,
refetchOnWindowFocus: true,
});
}
export function skillListOptions(wsId: string) {
return queryOptions({
queryKey: workspaceKeys.skills(wsId),

View File

@@ -40,7 +40,7 @@ function ActorAvatar({
// Squads (a group, non-human) get a square tile so they don't read as
// a single person; everyone else stays round.
isSquad ? "rounded-md" : "rounded-full",
"bg-muted text-muted-foreground",
(!avatarUrl || imgError) && "bg-muted text-muted-foreground",
className
)}
style={{ width: size, height: size, fontSize: size * 0.45 }}

View File

@@ -127,6 +127,7 @@ function ChartTooltipContent({
labelFormatter,
labelClassName,
formatter,
footer,
color,
nameKey,
labelKey,
@@ -137,6 +138,16 @@ function ChartTooltipContent({
indicator?: "line" | "dot" | "dashed"
nameKey?: string
labelKey?: string
footer?:
| React.ReactNode
| ((
payload: NonNullable<
RechartsPrimitive.DefaultTooltipContentProps<
TooltipValueType,
TooltipNameType
>["payload"]
>,
) => React.ReactNode)
} & Omit<
RechartsPrimitive.DefaultTooltipContentProps<
TooltipValueType,
@@ -266,6 +277,11 @@ function ChartTooltipContent({
)
})}
</div>
{footer != null && (
<div className="mt-0.5 border-t border-border/50 pt-1.5">
{typeof footer === "function" ? footer(payload) : footer}
</div>
)}
</div>
)
}

View File

@@ -122,7 +122,7 @@ function SelectItem({
)}
{...props}
>
<SelectPrimitive.ItemText className="flex flex-1 shrink-0 gap-2 whitespace-nowrap">
<SelectPrimitive.ItemText className="flex flex-1 shrink-0 items-center gap-2 whitespace-nowrap">
{children}
</SelectPrimitive.ItemText>
<SelectPrimitive.ItemIndicator

View File

@@ -114,6 +114,20 @@
animation: chat-text-shimmer 2.5s linear infinite;
}
/* Navigation progress bar: 2px brand-colored indeterminate sweep with a
* right-edge glow that shows across the top of the dashboard while a
* transition-wrapped push/replace is committing. Driven by useIsNavigating();
* independent of the actual network, so it disappears the moment React commits
* the new route. */
@keyframes nav-progress-sweep {
0% { transform: translateX(-100%); }
100% { transform: translateX(100%); }
}
.animate-nav-progress-sweep {
animation: nav-progress-sweep 1.4s cubic-bezier(0.4, 0, 0.2, 1) infinite;
}
/* Border beam: a brand-tinted highlight sweeps continuously around the
* element's rounded border, drawing the eye to a CTA that would otherwise
* blend into the chrome (e.g. the "switch to agent" affordance in manual

View File

@@ -240,7 +240,7 @@ function AvatarEditor({
if (!canEdit) {
return (
<div className="h-14 w-14 shrink-0 overflow-hidden rounded-lg bg-muted">
<div className="h-14 w-14 shrink-0 overflow-hidden rounded-lg">
<ActorAvatar
actorType="agent"
actorId={agent.id}
@@ -271,7 +271,7 @@ function AvatarEditor({
type="button"
// rounded-lg matches the standard agent avatar treatment used in
// list rows. Avoid rounded-full — circles are reserved for humans.
className="group relative h-14 w-14 shrink-0 overflow-hidden rounded-lg bg-muted focus:outline-none focus-visible:ring-2 focus-visible:ring-ring"
className="group relative h-14 w-14 shrink-0 overflow-hidden rounded-lg focus:outline-none focus-visible:ring-2 focus-visible:ring-ring"
onClick={() => fileInputRef.current?.click()}
disabled={uploading}
aria-label={t(($) => $.inspector.change_avatar_aria)}

View File

@@ -6,6 +6,7 @@ import {
BookOpenText,
FileText,
KeyRound,
ListTodo,
Terminal,
} from "lucide-react";
import type { Agent, AgentRuntime } from "@multica/core/types";
@@ -24,17 +25,20 @@ import { InstructionsTab } from "./tabs/instructions-tab";
import { SkillsTab } from "./tabs/skills-tab";
import { EnvTab } from "./tabs/env-tab";
import { CustomArgsTab } from "./tabs/custom-args-tab";
import { ActorIssuesPanel } from "../../common/actor-issues-panel";
import { useT } from "../../i18n";
type DetailTab =
| "activity"
| "tasks"
| "instructions"
| "skills"
| "env"
| "custom_args";
const TAB_LABEL_KEY: Record<DetailTab, "activity" | "instructions" | "skills" | "environment" | "custom_args"> = {
const TAB_LABEL_KEY: Record<DetailTab, "activity" | "tasks" | "instructions" | "skills" | "environment" | "custom_args"> = {
activity: "activity",
tasks: "tasks",
instructions: "instructions",
skills: "skills",
env: "environment",
@@ -46,6 +50,7 @@ const detailTabs: {
icon: typeof FileText;
}[] = [
{ id: "activity", icon: Activity },
{ id: "tasks", icon: ListTodo },
{ id: "instructions", icon: FileText },
{ id: "skills", icon: BookOpenText },
{ id: "env", icon: KeyRound },
@@ -59,10 +64,11 @@ interface AgentOverviewPaneProps {
}
/**
* Right-pane on the agent detail page. Five tabs of equal weight:
* Right-pane on the agent detail page:
*
* - Activity (default) — what the agent is doing now / how it's been doing /
* what it just finished. The "watch state" surface.
* - Tasks — assigned/created issues using the shared issue board/list.
* - Instructions / Skills / Env / Custom Args — four editing surfaces.
*
* The previous Settings tab was deleted because every field on it is now
@@ -142,6 +148,11 @@ export function AgentOverviewPane({
<div className="flex-1 min-h-0 overflow-y-auto">
{activeTab === "activity" && <ActivityTab agent={agent} />}
{activeTab === "tasks" && (
<div className="flex h-full min-h-[520px] flex-col">
<ActorIssuesPanel actorType="agent" actorId={agent.id} />
</div>
)}
{activeTab === "instructions" && (
<TabContent>
<InstructionsTab

View File

@@ -73,7 +73,7 @@ export function AvatarPicker({ value, onChange, size = 56 }: AvatarPickerProps)
"group relative h-full w-full overflow-hidden rounded-lg outline-none transition-colors",
"focus-visible:ring-2 focus-visible:ring-ring",
hasValue
? "border bg-muted"
? "border"
: "border border-dashed bg-muted/40 hover:bg-muted",
)}
aria-label={

View File

@@ -225,7 +225,7 @@ export function CreateAgentDialog({
return (
<Dialog open onOpenChange={(v) => { if (!v) onClose(); }}>
<DialogContent className="p-0 gap-0 flex flex-col overflow-hidden !top-1/2 !left-1/2 !-translate-x-1/2 !-translate-y-1/2 !w-full !max-w-5xl !h-[85vh]">
<DialogContent className="p-0 gap-0 flex flex-col overflow-hidden !top-1/2 !left-1/2 !-translate-x-1/2 !-translate-y-1/2 !w-full !max-w-2xl !h-[85vh]">
<DialogHeader className="border-b px-5 py-3 space-y-0">
<DialogTitle className="text-base font-semibold">{headerTitle}</DialogTitle>
{isDuplicate && template && (

View File

@@ -71,8 +71,12 @@ export function CustomArgsTab({
try {
await onSave({ custom_args: currentArgs });
toast.success(t(($) => $.tab_body.custom_args.saved_toast));
} catch {
toast.error(t(($) => $.tab_body.custom_args.save_failed_toast));
} catch (err) {
toast.error(
err instanceof Error && err.message
? err.message
: t(($) => $.tab_body.custom_args.save_failed_toast),
);
} finally {
setSaving(false);
}

View File

@@ -114,8 +114,12 @@ export function EnvTab({
try {
await onSave({ custom_env: currentEnvMap });
toast.success(t(($) => $.tab_body.env.saved_toast));
} catch {
toast.error(t(($) => $.tab_body.env.save_failed_toast));
} catch (err) {
toast.error(
err instanceof Error && err.message
? err.message
: t(($) => $.tab_body.env.save_failed_toast),
);
} finally {
setSaving(false);
}

View File

@@ -0,0 +1,72 @@
"use client";
/**
* AttachmentPreviewPage — full-page HTML attachment viewer.
*
* Destination for `openInNewTab` from HtmlAttachmentPreview's toolbar. The
* inline preview (HtmlAttachmentPreview) renders the same content in a 480px
* card with a hover toolbar; this is the same content edge-to-edge so the
* user can resize / interact with the document at full size.
*
* Same security posture as the inline preview: iframe sandbox is
* "allow-scripts" only — no allow-same-origin, no allow-top-navigation. The
* iframe runs in an opaque origin and cannot reach cookies, localStorage,
* parent, or top-level navigation.
*
* The route is workspace-scoped (`/{slug}/attachments/{id}/preview`) for
* tenancy isolation; the `/api/attachments/{id}/content` proxy itself is
* already auth-checked, so the slug is purely a URL contract.
*/
import { useEffect } from "react";
import { useT } from "../i18n";
import { useAttachmentHtmlText } from "../editor/hooks/use-attachment-html-text";
interface AttachmentPreviewPageProps {
attachmentId: string;
/** Optional display name. Falls back to a generic label and is only used
* for the document title — never echoed into the iframe sandbox. */
filename?: string;
}
export function AttachmentPreviewPage({
attachmentId,
filename,
}: AttachmentPreviewPageProps) {
const { t } = useT("editor");
const query = useAttachmentHtmlText(attachmentId);
// Set document.title so desktop's MutationObserver-based tab title picks
// up the filename. Web shows the same string in the browser tab.
useEffect(() => {
if (filename) document.title = filename;
}, [filename]);
const text = query.data?.text;
const isLoading = query.isLoading;
const isError = !isLoading && (!!query.error || !text);
return (
<div className="flex h-full w-full flex-col bg-background">
{isLoading ? (
<div className="flex flex-1 items-center justify-center text-sm text-muted-foreground">
{t(($) => $.attachment.preview_loading)}
</div>
) : isError ? (
<div
className="flex flex-1 items-center justify-center px-4 text-sm text-muted-foreground"
data-testid="attachment-preview-page-error"
>
{t(($) => $.attachment.preview_failed)}
</div>
) : (
<iframe
srcDoc={text}
sandbox="allow-scripts"
title={filename ?? "HTML attachment"}
className="flex-1 w-full border-0 bg-background"
/>
)}
</div>
);
}

View File

@@ -0,0 +1 @@
export { AttachmentPreviewPage } from "./attachment-preview-page";

View File

@@ -1,16 +1,23 @@
"use client";
import { useState } from "react";
import { Zap, Play, Clock, Plus, Trash2, CheckCircle2, XCircle, Loader2, Pencil } from "lucide-react";
import {
Zap, Play, Clock, Plus, Trash2, CheckCircle2, XCircle, Loader2, Pencil,
Ban, ChevronDown, ChevronRight,
Webhook, Copy, Check, RotateCw,
} from "lucide-react";
import { useQuery } from "@tanstack/react-query";
import { autopilotDetailOptions, autopilotRunsOptions } from "@multica/core/autopilots/queries";
import { autopilotDetailOptions, autopilotRunsOptions, autopilotRunOptions } from "@multica/core/autopilots/queries";
import {
useUpdateAutopilot,
useDeleteAutopilot,
useTriggerAutopilot,
useCreateAutopilotTrigger,
useDeleteAutopilotTrigger,
useRotateAutopilotTriggerWebhookToken,
} from "@multica/core/autopilots/mutations";
import { buildAutopilotWebhookUrl } from "@multica/core/autopilots";
import { api } from "@multica/core/api";
import { useWorkspaceId } from "@multica/core/hooks";
import { useWorkspacePaths } from "@multica/core/paths";
import { useActorName } from "@multica/core/workspace/hooks";
@@ -48,6 +55,8 @@ import type { AgentTask } from "@multica/core/types/agent";
import { ReadonlyContent } from "../../editor";
import { TranscriptButton } from "../../common/task-transcript";
import { AutopilotDialog } from "./autopilot-dialog";
import { WebhookPayloadPreview } from "./webhook-payload-preview";
import { WebhookDeliveriesSection } from "./webhook-deliveries-section";
import { useT } from "../../i18n";
function formatDate(date: string): string {
@@ -59,15 +68,37 @@ function formatDate(date: string): string {
});
}
type RunStatus = "issue_created" | "running" | "completed" | "failed";
type RunStatus = "issue_created" | "running" | "skipped" | "completed" | "failed";
const RUN_VISUAL: Record<RunStatus, { color: string; icon: typeof CheckCircle2; spin?: boolean }> = {
issue_created: { color: "text-blue-500", icon: Clock },
running: { color: "text-blue-500", icon: Loader2, spin: true },
// `skipped` (admission check found the assignee runtime offline,
// MUL-1899) is muted so it doesn't read as a failure-ratio inflator.
// The row still shows failure_reason which carries the skip context.
skipped: { color: "text-muted-foreground", icon: Ban },
completed: { color: "text-emerald-500", icon: CheckCircle2 },
failed: { color: "text-destructive", icon: XCircle },
};
// WebhookPayloadSlot lazy-fetches the full run (incl. trigger_payload) once
// the parent dialog actually mounts this slot. The list endpoint omits
// trigger_payload to keep responses small (worst case 256 KiB × N runs),
// so the detail-on-demand fetch lives here.
function WebhookPayloadSlot({ autopilotId, runId }: { autopilotId: string; runId: string }) {
const wsId = useWorkspaceId();
const { data, isLoading } = useQuery(
autopilotRunOptions(wsId, autopilotId, runId),
);
if (isLoading) {
return <Skeleton className="h-9 w-full" />;
}
if (!data || data.trigger_payload == null) {
return null;
}
return <WebhookPayloadPreview payload={data.trigger_payload} />;
}
function RunRow({ run, agentId, agentName }: { run: AutopilotRun; agentId: string; agentName: string }) {
const { t } = useT("autopilots");
const wsPaths = useWorkspacePaths();
@@ -104,7 +135,9 @@ function RunRow({ run, agentId, agentName }: { run: AutopilotRun; agentId: strin
<span className={cn("w-24 shrink-0 text-xs font-medium", visual.color)}>
{t(($) => $.run_status[status])}
</span>
<span className="w-16 shrink-0 text-xs text-muted-foreground capitalize">{run.source}</span>
<span className="w-20 shrink-0 text-xs text-muted-foreground">
{t(($) => $.run_source[run.source as "schedule" | "manual" | "webhook" | "api"]) ?? run.source}
</span>
<span className="flex-1 min-w-0 text-xs text-muted-foreground truncate">
{run.issue_id ? (
t(($) => $.run.issue_linked)
@@ -121,6 +154,11 @@ function RunRow({ run, agentId, agentName }: { run: AutopilotRun; agentId: strin
agentName={agentName}
isLive={run.status === "running"}
title={t(($) => $.run.view_log)}
headerSlot={
run.source === "webhook" ? (
<WebhookPayloadSlot autopilotId={run.autopilot_id} runId={run.id} />
) : undefined
}
/>
)}
</>
@@ -139,11 +177,85 @@ function RunRow({ run, agentId, agentName }: { run: AutopilotRun; agentId: strin
return <div className={rowClass}>{content}</div>;
}
function RunHistoryList({
runs,
agentId,
agentName,
}: {
runs: AutopilotRun[];
agentId: string;
agentName: string;
}) {
const visibleRuns = runs.filter((run) => run.status !== "skipped");
const skippedRuns = runs.filter((run) => run.status === "skipped");
return (
<div className="rounded-md border overflow-hidden">
{visibleRuns.map((run) => (
<RunRow key={run.id} run={run} agentId={agentId} agentName={agentName} />
))}
{skippedRuns.length > 0 && (
<SkippedRunsGroup runs={skippedRuns} agentId={agentId} agentName={agentName} />
)}
</div>
);
}
function SkippedRunsGroup({
runs,
agentId,
agentName,
}: {
runs: AutopilotRun[];
agentId: string;
agentName: string;
}) {
const { t } = useT("autopilots");
const [open, setOpen] = useState(false);
const latestRun = runs[0];
const ToggleIcon = open ? ChevronDown : ChevronRight;
return (
<div className="border-t bg-muted/20">
<button
type="button"
className="flex w-full items-center gap-3 px-4 py-2.5 text-left text-sm hover:bg-accent/30 transition-colors"
onClick={() => setOpen((value) => !value)}
aria-expanded={open}
>
<ToggleIcon className="h-4 w-4 shrink-0 text-muted-foreground" />
<Ban className="h-4 w-4 shrink-0 text-muted-foreground" />
<span className="w-24 shrink-0 text-xs font-medium text-muted-foreground">
{t(($) => $.run.skipped_group.label)}
</span>
<span className="flex-1 min-w-0 text-xs text-muted-foreground truncate">
{t(($) => $.run.skipped_group.summary, { count: runs.length })}
</span>
{latestRun && (
<span className="w-32 shrink-0 text-right text-xs text-muted-foreground tabular-nums">
{formatDate(latestRun.triggered_at || latestRun.created_at)}
</span>
)}
</button>
{open && (
<div className="border-t bg-background">
{runs.map((run) => (
<RunRow key={run.id} run={run} agentId={agentId} agentName={agentName} />
))}
</div>
)}
</div>
);
}
function TriggerRow({ trigger, autopilotId }: { trigger: AutopilotTrigger; autopilotId: string }) {
const { t } = useT("autopilots");
const deleteTrigger = useDeleteAutopilotTrigger();
const rotateToken = useRotateAutopilotTriggerWebhookToken();
const [confirmOpen, setConfirmOpen] = useState(false);
const [rotateOpen, setRotateOpen] = useState(false);
const [deleting, setDeleting] = useState(false);
const [copied, setCopied] = useState(false);
const handleDelete = async () => {
setDeleting(true);
@@ -151,19 +263,83 @@ function TriggerRow({ trigger, autopilotId }: { trigger: AutopilotTrigger; autop
await deleteTrigger.mutateAsync({ autopilotId, triggerId: trigger.id });
toast.success(t(($) => $.trigger_row.toast_deleted));
setConfirmOpen(false);
} catch {
toast.error(t(($) => $.trigger_row.toast_delete_failed));
} catch (err) {
toast.error(
err instanceof Error && err.message
? err.message
: t(($) => $.trigger_row.toast_delete_failed),
);
} finally {
setDeleting(false);
}
};
const isWebhook = trigger.kind === "webhook";
const isApi = trigger.kind === "api";
// Resolve the URL from the server's webhook_url first, then compose
// from the API base URL (desktop) or window.origin (web). Falls back
// to the relative path if neither is available.
const webhookUrl = isWebhook
? buildAutopilotWebhookUrl({
trigger,
apiBaseUrl: api.getBaseUrl(),
currentOrigin: typeof window !== "undefined" ? window.location.origin : undefined,
})
: null;
const handleCopy = async () => {
if (!webhookUrl) return;
try {
await navigator.clipboard.writeText(webhookUrl);
setCopied(true);
toast.success(t(($) => $.trigger_row.url_copied));
setTimeout(() => setCopied(false), 1500);
} catch {
toast.error(t(($) => $.trigger_row.url_copy_failed));
}
};
const handleRotate = async () => {
try {
await rotateToken.mutateAsync({ autopilotId, triggerId: trigger.id });
toast.success(t(($) => $.trigger_row.toast_rotated));
setRotateOpen(false);
} catch (err) {
toast.error(
err instanceof Error && err.message
? err.message
: t(($) => $.trigger_row.toast_rotate_failed),
);
}
};
const Icon = isWebhook ? Webhook : isApi ? Zap : Clock;
const showWebhookUrlRow = isWebhook && webhookUrl;
// Delete control extracted so a webhook trigger can render it inline
// with Copy / Rotate on the URL action row (where the other action
// buttons live), while schedule / api triggers — which have no URL row
// — keep it pinned to the row's top-right corner. Without this the
// trash icon visually floats above the URL action buttons because the
// outer flex uses `items-start`.
const deleteButton = (
<Button
size="icon"
variant="ghost"
className="h-7 w-7 shrink-0"
onClick={() => setConfirmOpen(true)}
title={t(($) => $.trigger_row.delete_dialog.confirm)}
>
<Trash2 className="h-3.5 w-3.5 text-muted-foreground" />
</Button>
);
return (
<div className="flex items-center gap-3 rounded-md border px-3 py-2">
<Clock className="h-4 w-4 shrink-0 text-muted-foreground" />
<div className="flex items-start gap-3 rounded-md border px-3 py-2">
<Icon className="h-4 w-4 shrink-0 text-muted-foreground mt-0.5" />
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<span className="text-sm font-medium capitalize">{trigger.kind}</span>
<div className="flex items-center gap-2 flex-wrap">
<span className="text-sm font-medium">{t(($) => $.trigger_kind[trigger.kind])}</span>
{trigger.label && (
<span className="text-xs text-muted-foreground">({trigger.label})</span>
)}
@@ -172,6 +348,11 @@ function TriggerRow({ trigger, autopilotId }: { trigger: AutopilotTrigger; autop
{t(($) => $.trigger_row.disabled_badge)}
</span>
)}
{isApi && (
<span className="text-xs bg-muted px-1.5 py-0.5 rounded">
{t(($) => $.trigger_row.deprecated_badge)}
</span>
)}
</div>
{trigger.cron_expression && (
<div className="text-xs text-muted-foreground mt-0.5">
@@ -184,15 +365,35 @@ function TriggerRow({ trigger, autopilotId }: { trigger: AutopilotTrigger; autop
{t(($) => $.trigger_row.next_label, { date: formatDate(trigger.next_run_at) })}
</div>
)}
{showWebhookUrlRow && (
<div className="mt-1.5 flex items-center gap-1.5">
<code className="flex-1 min-w-0 truncate rounded bg-muted px-2 py-1 text-xs font-mono text-foreground">
{webhookUrl}
</code>
<Button
size="icon"
variant="ghost"
className="h-7 w-7 shrink-0"
onClick={handleCopy}
title={t(($) => $.trigger_row.copy_url)}
>
{copied ? <Check className="h-3.5 w-3.5 text-emerald-500" /> : <Copy className="h-3.5 w-3.5 text-muted-foreground" />}
</Button>
<Button
size="icon"
variant="ghost"
className="h-7 w-7 shrink-0"
onClick={() => setRotateOpen(true)}
title={t(($) => $.trigger_row.rotate_url)}
disabled={rotateToken.isPending}
>
<RotateCw className={cn("h-3.5 w-3.5 text-muted-foreground", rotateToken.isPending && "animate-spin")} />
</Button>
{deleteButton}
</div>
)}
</div>
<Button
size="icon"
variant="ghost"
className="h-7 w-7 shrink-0"
onClick={() => setConfirmOpen(true)}
>
<Trash2 className="h-3.5 w-3.5 text-muted-foreground" />
</Button>
{!showWebhookUrlRow && deleteButton}
<AlertDialog open={confirmOpen} onOpenChange={(v) => { if (!v && !deleting) setConfirmOpen(false); }}>
<AlertDialogContent>
<AlertDialogHeader>
@@ -217,6 +418,26 @@ function TriggerRow({ trigger, autopilotId }: { trigger: AutopilotTrigger; autop
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
<AlertDialog open={rotateOpen} onOpenChange={(v) => { if (!v && !rotateToken.isPending) setRotateOpen(false); }}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>{t(($) => $.trigger_row.rotate_confirm_title)}</AlertDialogTitle>
<AlertDialogDescription>
{t(($) => $.trigger_row.rotate_confirm_description)}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel disabled={rotateToken.isPending}>
{t(($) => $.trigger_row.rotate_confirm_cancel)}
</AlertDialogCancel>
<AlertDialogAction onClick={handleRotate} disabled={rotateToken.isPending}>
{rotateToken.isPending
? t(($) => $.trigger_row.rotate_in_progress)
: t(($) => $.trigger_row.rotate_confirm_action)}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
);
}
@@ -232,29 +453,47 @@ function AddTriggerDialog({
}) {
const { t } = useT("autopilots");
const createTrigger = useCreateAutopilotTrigger();
const [kind, setKind] = useState<"schedule" | "webhook">("schedule");
const [config, setConfig] = useState<TriggerConfig>(getDefaultTriggerConfig);
const [label, setLabel] = useState("");
const [submitting, setSubmitting] = useState(false);
const handleSubmit = async () => {
if (submitting) return;
const cronExpr = toCronExpression(config);
if (!cronExpr.trim()) return;
setSubmitting(true);
try {
await createTrigger.mutateAsync({
autopilotId,
kind: "schedule",
cron_expression: cronExpr,
timezone: config.timezone || undefined,
label: label.trim() || undefined,
});
if (kind === "schedule") {
const cronExpr = toCronExpression(config);
if (!cronExpr.trim()) {
setSubmitting(false);
return;
}
await createTrigger.mutateAsync({
autopilotId,
kind: "schedule",
cron_expression: cronExpr,
timezone: config.timezone || undefined,
label: label.trim() || undefined,
});
toast.success(t(($) => $.add_trigger_dialog.toast_added_schedule));
} else {
await createTrigger.mutateAsync({
autopilotId,
kind: "webhook",
label: label.trim() || undefined,
});
toast.success(t(($) => $.add_trigger_dialog.toast_added_webhook));
}
onOpenChange(false);
setKind("schedule");
setConfig(getDefaultTriggerConfig());
setLabel("");
toast.success(t(($) => $.add_trigger_dialog.toast_added));
} catch {
toast.error(t(($) => $.add_trigger_dialog.toast_add_failed));
} catch (err) {
toast.error(
err instanceof Error && err.message
? err.message
: t(($) => $.add_trigger_dialog.toast_add_failed),
);
} finally {
setSubmitting(false);
}
@@ -265,7 +504,48 @@ function AddTriggerDialog({
<DialogContent className="max-w-sm">
<DialogTitle>{t(($) => $.add_trigger_dialog.title)}</DialogTitle>
<div className="space-y-4 pt-2">
<TriggerConfigSection config={config} onChange={setConfig} />
<div>
<label className="text-xs font-medium text-muted-foreground">
{t(($) => $.add_trigger_dialog.type_label)}
</label>
<div className="mt-1 grid grid-cols-2 gap-1 rounded-md bg-muted p-1">
<button
type="button"
onClick={() => setKind("schedule")}
className={cn(
"flex items-center justify-center gap-1.5 rounded px-3 py-1.5 text-sm transition-colors",
kind === "schedule"
? "bg-background text-foreground shadow-sm"
: "text-muted-foreground hover:text-foreground",
)}
>
<Clock className="h-3.5 w-3.5" />
{t(($) => $.add_trigger_dialog.type_schedule)}
</button>
<button
type="button"
onClick={() => setKind("webhook")}
className={cn(
"flex items-center justify-center gap-1.5 rounded px-3 py-1.5 text-sm transition-colors",
kind === "webhook"
? "bg-background text-foreground shadow-sm"
: "text-muted-foreground hover:text-foreground",
)}
>
<Webhook className="h-3.5 w-3.5" />
{t(($) => $.add_trigger_dialog.type_webhook)}
</button>
</div>
</div>
{kind === "schedule" ? (
<TriggerConfigSection config={config} onChange={setConfig} />
) : (
<p className="rounded-md bg-muted/50 px-3 py-2 text-xs text-muted-foreground">
{t(($) => $.add_trigger_dialog.webhook_help)}
</p>
)}
<div>
<label className="text-xs font-medium text-muted-foreground">
{t(($) => $.add_trigger_dialog.label_field)}
@@ -373,8 +653,12 @@ export function AutopilotDetailPage({ autopilotId }: { autopilotId: string }) {
await deleteAutopilot.mutateAsync(autopilotId);
toast.success(t(($) => $.detail.toast_deleted));
router.push(wsPaths.autopilots());
} catch {
toast.error(t(($) => $.detail.toast_delete_failed));
} catch (err) {
toast.error(
err instanceof Error && err.message
? err.message
: t(($) => $.detail.toast_delete_failed),
);
setDeleting(false);
}
};
@@ -485,6 +769,14 @@ export function AutopilotDetailPage({ autopilotId }: { autopilotId: string }) {
)}
</section>
{/* Webhook deliveries — only renders when at least one webhook
trigger is configured. The component does its own fetch so
schedule-only autopilots don't pay for an empty list query. */}
<WebhookDeliveriesSection
autopilotId={autopilotId}
hasWebhookTrigger={triggers.some((trig) => trig.kind === "webhook")}
/>
{/* Run History */}
<section className="space-y-3">
<h2 className="text-sm font-medium text-muted-foreground uppercase tracking-wider">
@@ -501,11 +793,11 @@ export function AutopilotDetailPage({ autopilotId }: { autopilotId: string }) {
{t(($) => $.detail.no_runs)}
</div>
) : (
<div className="rounded-md border overflow-hidden">
{runs.map((run) => (
<RunRow key={run.id} run={run} agentId={autopilot.assignee_id} agentName={getActorName("agent", autopilot.assignee_id)} />
))}
</div>
<RunHistoryList
runs={runs}
agentId={autopilot.assignee_id}
agentName={getActorName("agent", autopilot.assignee_id)}
/>
)}
</section>

View File

@@ -0,0 +1,72 @@
import { describe, expect, it } from "vitest";
import type { TFunction } from "i18next";
import { createI18n } from "@multica/core/i18n/react";
import enAutopilots from "../../locales/en/autopilots.json";
import zhAutopilots from "../../locales/zh-Hans/autopilots.json";
import { formatSchedulePartialFailureToast } from "./autopilot-dialog-toast";
// Contract test for the autopilot-dialog partial-success toast formatting.
//
// The dialog routes its partial-success branches through
// `formatSchedulePartialFailureToast`, so this test drives that exact
// helper rather than calling `t(...)` independently. That means a regression
// in either side — the JSON template (e.g. `{reason}` instead of `{{reason}}`)
// or the call-site variable name (e.g. `{ msg: ... }` instead of
// `{ reason: ... }`) — fails this test with the substring assertion.
describe("autopilot dialog partial-success toast", () => {
const reason = "schedule conflict: 09:00 overlaps existing trigger";
describe("en", () => {
const i18n = createI18n("en", { en: { autopilots: enAutopilots } });
const t = i18n.getFixedT("en", "autopilots") as TFunction<"autopilots">;
it("renders create partial-success with the server reason verbatim", () => {
const rendered = formatSchedulePartialFailureToast(t, "create", reason);
expect(rendered).toContain(reason);
expect(rendered).not.toContain("{{");
expect(rendered).not.toContain("{reason}");
});
it("renders update partial-success with the server reason verbatim", () => {
const rendered = formatSchedulePartialFailureToast(t, "update", reason);
expect(rendered).toContain(reason);
expect(rendered).not.toContain("{{");
expect(rendered).not.toContain("{reason}");
});
it("falls back to the no-reason create string when reason is null", () => {
expect(formatSchedulePartialFailureToast(t, "create", null)).toBe(
"Autopilot created, but schedule failed to save",
);
});
it("falls back to the no-reason update string when reason is null", () => {
expect(formatSchedulePartialFailureToast(t, "update", null)).toBe(
"Autopilot updated, but schedule failed to save",
);
});
});
describe("zh-Hans", () => {
const i18n = createI18n("zh-Hans", {
"zh-Hans": { autopilots: zhAutopilots },
en: { autopilots: enAutopilots },
});
const t = i18n.getFixedT("zh-Hans", "autopilots") as TFunction<"autopilots">;
it("renders create partial-success with the server reason verbatim", () => {
const rendered = formatSchedulePartialFailureToast(t, "create", reason);
expect(rendered).toContain(reason);
expect(rendered).not.toContain("{{");
expect(rendered).not.toContain("{reason}");
});
it("renders update partial-success with the server reason verbatim", () => {
const rendered = formatSchedulePartialFailureToast(t, "update", reason);
expect(rendered).toContain(reason);
expect(rendered).not.toContain("{{");
expect(rendered).not.toContain("{reason}");
});
});
});

View File

@@ -0,0 +1,21 @@
import type { TFunction } from "i18next";
// Centralizes the partial-success toast formatting so the i18n keys and the
// `{ reason }` placeholder live in one tested place. Without this, the
// translation contract in `autopilot-dialog-i18n.test.ts` could pass while
// the dialog's call-site silently passes the wrong variable name and ships
// a literal `{{reason}}` to users.
export function formatSchedulePartialFailureToast(
t: TFunction<"autopilots">,
kind: "create" | "update",
reason: string | null,
): string {
if (reason) {
return kind === "create"
? t(($) => $.dialog.toast_create_partial_with_reason, { reason })
: t(($) => $.dialog.toast_update_partial_with_reason, { reason });
}
return kind === "create"
? t(($) => $.dialog.toast_create_partial)
: t(($) => $.dialog.toast_update_partial);
}

View File

@@ -8,11 +8,13 @@ import {
ChevronDown,
ChevronRight,
Clock,
Copy,
FilePlus2,
Maximize2,
Minimize2,
Play,
Rocket,
Webhook,
X as XIcon,
Zap,
} from "lucide-react";
@@ -42,6 +44,8 @@ import {
useUpdateAutopilot,
useUpdateAutopilotTrigger,
} from "@multica/core/autopilots/mutations";
import { buildAutopilotWebhookUrl } from "@multica/core/autopilots";
import { api } from "@multica/core/api";
import type {
AutopilotExecutionMode,
AutopilotTrigger,
@@ -58,6 +62,7 @@ import {
type TriggerFrequency,
} from "./trigger-config";
import { useT } from "../../i18n";
import { formatSchedulePartialFailureToast } from "./autopilot-dialog-toast";
// ---------------------------------------------------------------------------
// Types
@@ -264,6 +269,20 @@ export function AutopilotDialog(props: AutopilotDialogProps) {
})();
const [triggerConfig, setTriggerConfig] = useState<TriggerConfig>(initialCfg);
// Trigger kind selector. Only meaningful in create mode — edit mode does
// not support converting between kinds inline (PLAN.md calls that
// out as "delete old, create new" rather than ambiguous in-place
// updates), so the toggle is hidden when editing. The kind is
// initialized from the first existing trigger so we render the right
// panel without surprising the user.
const initialKind: "schedule" | "webhook" = (() => {
if (isCreate) return "schedule";
const first = props.triggers[0];
if (first?.kind === "webhook") return "webhook";
return "schedule";
})();
const [triggerKind, setTriggerKind] = useState<"schedule" | "webhook">(initialKind);
const initialCronRef = useRef(toCronExpression(initialCfg));
const initialTimezoneRef = useRef(initialCfg.timezone);
const scheduleDirty =
@@ -288,6 +307,12 @@ export function AutopilotDialog(props: AutopilotDialogProps) {
const updateTrigger = useUpdateAutopilotTrigger();
const [submitting, setSubmitting] = useState(false);
// After a successful webhook-kind create, we don't close the dialog —
// we swap to a confirmation state showing the freshly minted URL with
// copy / done affordances. This avoids the "now go find your autopilot
// and click into it to grab the URL" friction.
const [createdWebhookTrigger, setCreatedWebhookTrigger] = useState<AutopilotTrigger | null>(null);
const canSubmit =
title.trim().length > 0 && assigneeId.length > 0 && !submitting;
@@ -302,20 +327,44 @@ export function AutopilotDialog(props: AutopilotDialogProps) {
assignee_id: assigneeId,
execution_mode: executionMode,
});
let scheduleOk = true;
let triggerOk = true;
let triggerErrMessage: string | null = null;
let webhookTrigger: AutopilotTrigger | null = null;
try {
await createTrigger.mutateAsync({
autopilotId: autopilot.id,
kind: "schedule",
cron_expression: toCronExpression(triggerConfig),
timezone: triggerConfig.timezone,
});
} catch {
scheduleOk = false;
if (triggerKind === "webhook") {
webhookTrigger = await createTrigger.mutateAsync({
autopilotId: autopilot.id,
kind: "webhook",
});
} else {
await createTrigger.mutateAsync({
autopilotId: autopilot.id,
kind: "schedule",
cron_expression: toCronExpression(triggerConfig),
timezone: triggerConfig.timezone,
});
}
} catch (err) {
triggerOk = false;
triggerErrMessage =
err instanceof Error && err.message ? err.message : null;
}
if (triggerKind === "webhook" && webhookTrigger) {
// Stay in the dialog and surface the URL inline so the user
// can copy it without first navigating to the detail page.
setCreatedWebhookTrigger(webhookTrigger);
toast.success(t(($) => $.dialog.toast_created));
return;
}
onOpenChange(false);
if (scheduleOk) toast.success(t(($) => $.dialog.toast_created));
else toast.error(t(($) => $.dialog.toast_create_partial));
if (triggerOk) {
toast.success(t(($) => $.dialog.toast_created));
} else {
// Partial success: autopilot saved, schedule failed. Show the
// server-provided reason so the user can act on it (cron syntax
// error, conflict, etc.) instead of seeing a generic message.
toast.error(formatSchedulePartialFailureToast(t, "create", triggerErrMessage));
}
} else {
await updateAutopilot.mutateAsync({
id: props.autopilotId,
@@ -325,7 +374,11 @@ export function AutopilotDialog(props: AutopilotDialogProps) {
execution_mode: executionMode,
});
let scheduleOk = true;
if (scheduleDirty && !schedulePillDisabled) {
let scheduleErrMessage: string | null = null;
// Skip the schedule sync when the autopilot's first trigger is a
// webhook — there's no cron to update there, and the schedule
// panel isn't even rendered for webhook autopilots.
if (triggerKind === "schedule" && scheduleDirty && !schedulePillDisabled) {
const snapshottedTriggerId = firstTriggerIdRef.current;
try {
if (snapshottedTriggerId) {
@@ -343,19 +396,26 @@ export function AutopilotDialog(props: AutopilotDialogProps) {
timezone: triggerConfig.timezone,
});
}
} catch {
} catch (err) {
scheduleOk = false;
scheduleErrMessage =
err instanceof Error && err.message ? err.message : null;
}
}
onOpenChange(false);
if (scheduleOk) toast.success(t(($) => $.dialog.toast_updated));
else toast.error(t(($) => $.dialog.toast_update_partial));
if (scheduleOk) {
toast.success(t(($) => $.dialog.toast_updated));
} else {
toast.error(formatSchedulePartialFailureToast(t, "update", scheduleErrMessage));
}
}
} catch {
} catch (err) {
toast.error(
isCreate
? t(($) => $.dialog.toast_create_failed)
: t(($) => $.dialog.toast_update_failed),
err instanceof Error && err.message
? err.message
: isCreate
? t(($) => $.dialog.toast_create_failed)
: t(($) => $.dialog.toast_update_failed),
);
} finally {
setSubmitting(false);
@@ -435,6 +495,16 @@ export function AutopilotDialog(props: AutopilotDialogProps) {
</div>
</div>
{createdWebhookTrigger ? (
<WebhookCreatedPanel
trigger={createdWebhookTrigger}
onClose={() => {
setCreatedWebhookTrigger(null);
onOpenChange(false);
}}
/>
) : (
<>
{/* Body: two columns (stacks on narrow screens via flex-wrap at container level) */}
<div
key={contentKey}
@@ -486,16 +556,24 @@ export function AutopilotDialog(props: AutopilotDialogProps) {
<OutputModeSection mode={executionMode} onChange={setExecutionMode} />
<ScheduleSection
config={triggerConfig}
onChange={setTriggerConfig}
disabled={schedulePillDisabled}
disabledReason={
schedulePillDisabled
? t(($) => $.dialog.schedule_disabled_reason)
: undefined
}
/>
{isCreate && (
<TriggerKindSection kind={triggerKind} onChange={setTriggerKind} />
)}
{triggerKind === "schedule" ? (
<ScheduleSection
config={triggerConfig}
onChange={setTriggerConfig}
disabled={schedulePillDisabled}
disabledReason={
schedulePillDisabled
? t(($) => $.dialog.schedule_disabled_reason)
: undefined
}
/>
) : (
<WebhookHelpSection isCreate={isCreate} />
)}
</aside>
</div>
@@ -520,6 +598,8 @@ export function AutopilotDialog(props: AutopilotDialogProps) {
</Button>
</div>
</div>
</>
)}
</DialogContent>
</Dialog>
);
@@ -780,3 +860,169 @@ function ScheduleSection({
</div>
);
}
// ---------------------------------------------------------------------------
// Trigger kind segmented control + webhook help section
// ---------------------------------------------------------------------------
function TriggerKindSection({
kind,
onChange,
}: {
kind: "schedule" | "webhook";
onChange: (kind: "schedule" | "webhook") => void;
}) {
const { t } = useT("autopilots");
return (
<div>
<SectionLabel>{t(($) => $.dialog.section_trigger_kind)}</SectionLabel>
<div className="grid grid-cols-2 gap-1 rounded-md bg-muted p-1">
<TriggerKindButton
active={kind === "schedule"}
onClick={() => onChange("schedule")}
icon={<Clock className="h-3.5 w-3.5" />}
label={t(($) => $.dialog.trigger_kind_schedule)}
/>
<TriggerKindButton
active={kind === "webhook"}
onClick={() => onChange("webhook")}
icon={<Webhook className="h-3.5 w-3.5" />}
label={t(($) => $.dialog.trigger_kind_webhook)}
/>
</div>
</div>
);
}
function TriggerKindButton({
active,
onClick,
icon,
label,
}: {
active: boolean;
onClick: () => void;
icon: React.ReactNode;
label: string;
}) {
return (
<button
type="button"
onClick={onClick}
className={cn(
"flex items-center justify-center gap-1.5 rounded px-3 py-1.5 text-sm transition-colors",
active
? "bg-background text-foreground shadow-sm"
: "text-muted-foreground hover:text-foreground",
)}
>
{icon}
<span>{label}</span>
</button>
);
}
function WebhookHelpSection({ isCreate }: { isCreate: boolean }) {
const { t } = useT("autopilots");
return (
<div>
<SectionLabel>{t(($) => $.dialog.section_webhook)}</SectionLabel>
<p className="rounded-md border bg-background px-3 py-2 text-xs text-muted-foreground leading-relaxed">
{isCreate
? t(($) => $.dialog.webhook_help_create)
: t(($) => $.dialog.webhook_help_edit)}
</p>
</div>
);
}
// ---------------------------------------------------------------------------
// Post-create state for webhook autopilots: shows the freshly minted URL
// inline so the user can copy it without leaving the dialog.
// ---------------------------------------------------------------------------
function WebhookCreatedPanel({
trigger,
onClose,
}: {
trigger: AutopilotTrigger;
onClose: () => void;
}) {
const { t } = useT("autopilots");
const [copied, setCopied] = useState(false);
// Same URL composition the trigger row uses: prefer the server-provided
// webhook_url, fall back to apiBaseUrl + webhook_path, then origin + path.
const url =
buildAutopilotWebhookUrl({
trigger,
apiBaseUrl: api.getBaseUrl(),
currentOrigin: typeof window !== "undefined" ? window.location.origin : undefined,
}) ?? "";
const handleCopy = async () => {
if (!url) return;
try {
await navigator.clipboard.writeText(url);
setCopied(true);
toast.success(t(($) => $.trigger_row.url_copied));
setTimeout(() => setCopied(false), 1500);
} catch {
toast.error(t(($) => $.trigger_row.url_copy_failed));
}
};
return (
<>
<div className="flex-1 min-h-0 overflow-y-auto px-8 py-10">
<div className="mx-auto max-w-xl space-y-5">
<div className="flex items-center gap-3">
<span className="inline-flex size-9 items-center justify-center rounded-full bg-primary/15 text-primary">
<Webhook className="size-4" />
</span>
<h2 className="text-lg font-semibold tracking-tight">
{t(($) => $.dialog.webhook_created_title)}
</h2>
</div>
<p className="text-sm text-muted-foreground leading-relaxed">
{t(($) => $.dialog.webhook_created_description)}
</p>
<div>
<div className="text-[11px] font-semibold tracking-[0.08em] text-muted-foreground uppercase mb-2">
{t(($) => $.trigger_row.webhook_url_label)}
</div>
<div className="flex items-stretch gap-1.5">
<code className="flex-1 min-w-0 truncate rounded-md border bg-muted px-3 py-2 text-xs font-mono text-foreground">
{url}
</code>
<Button
size="icon"
variant="outline"
className="h-9 w-9 shrink-0"
onClick={handleCopy}
title={t(($) => $.trigger_row.copy_url)}
>
{copied ? (
<Check className="size-4 text-emerald-500" />
) : (
<Copy className="size-4 text-muted-foreground" />
)}
</Button>
</div>
</div>
<div className="rounded-md border border-amber-500/30 bg-amber-500/10 px-3 py-2 text-xs text-amber-700 dark:text-amber-400 leading-relaxed">
{t(($) => $.dialog.webhook_created_warning)}
</div>
</div>
</div>
<div className="flex items-center justify-end gap-3 px-5 py-3 border-t shrink-0 bg-background">
<Button size="sm" onClick={onClose}>
{t(($) => $.dialog.webhook_created_done)}
</Button>
</div>
</>
);
}

View File

@@ -0,0 +1,560 @@
"use client";
import { useState } from "react";
import {
CheckCircle2,
XCircle,
Loader2,
Ban,
AlertTriangle,
ShieldOff,
RotateCw,
Copy,
Check,
Webhook,
} from "lucide-react";
import { useQuery } from "@tanstack/react-query";
import {
autopilotDeliveriesOptions,
autopilotDeliveryOptions,
useReplayAutopilotDelivery,
} from "@multica/core/autopilots";
import { useWorkspaceId } from "@multica/core/hooks";
import { Skeleton } from "@multica/ui/components/ui/skeleton";
import { Button } from "@multica/ui/components/ui/button";
import { Badge } from "@multica/ui/components/ui/badge";
import {
Dialog,
DialogContent,
DialogTitle,
} from "@multica/ui/components/ui/dialog";
import { cn } from "@multica/ui/lib/utils";
import { toast } from "sonner";
import { useT } from "../../i18n";
import type {
WebhookDelivery,
WebhookDeliveryStatus,
WebhookSignatureStatus,
} from "@multica/core/types";
// --- Status visuals -------------------------------------------------------
// Mapping is exhaustive over the current backend enum but every consumer
// site falls back to a generic "unknown" visual when the server adds a new
// value — see the API Response Compatibility rules in CLAUDE.md.
type StatusVisual = {
color: string;
icon: typeof CheckCircle2;
spin?: boolean;
};
const STATUS_VISUAL: Record<WebhookDeliveryStatus, StatusVisual> = {
queued: { color: "text-blue-500", icon: Loader2, spin: true },
dispatched: { color: "text-emerald-500", icon: CheckCircle2 },
// Signature failures and pre-flight bouncebacks land here. Read as a
// failure visually, the dialog footer explains the reason.
rejected: { color: "text-destructive", icon: ShieldOff },
// Ignored covers paused/disabled/archived autopilots — same payload was
// received but no run was created. Muted so it doesn't look like a bug.
ignored: { color: "text-muted-foreground", icon: Ban },
failed: { color: "text-destructive", icon: XCircle },
};
const UNKNOWN_VISUAL: StatusVisual = {
color: "text-muted-foreground",
icon: AlertTriangle,
};
function visualForStatus(status: string): StatusVisual {
return (STATUS_VISUAL as Record<string, StatusVisual>)[status] ?? UNKNOWN_VISUAL;
}
// --- Helpers --------------------------------------------------------------
function formatDate(value: string): string {
if (!value) return "—";
return new Date(value).toLocaleString(undefined, {
month: "short",
day: "numeric",
hour: "2-digit",
minute: "2-digit",
});
}
// A delivery is replayable when (a) the server allows it (signature is not
// invalid AND the delivery itself wasn't rejected) and (b) we have something
// to replay (raw_body / received). We mirror the server's rule rather than
// rely on the response — keeping the button disabled saves a 400 round-trip.
function canReplay(delivery: WebhookDelivery): boolean {
if (delivery.signature_status === "invalid") return false;
if (delivery.status === "rejected") return false;
// `queued` deliveries are mid-flight on the server; replay would race the
// synchronous dispatch path. Once they settle, the user can replay.
if (delivery.status === "queued") return false;
return true;
}
// --- Section --------------------------------------------------------------
export function WebhookDeliveriesSection({
autopilotId,
hasWebhookTrigger,
}: {
autopilotId: string;
hasWebhookTrigger: boolean;
}) {
const { t } = useT("autopilots");
const wsId = useWorkspaceId();
const { data: deliveries = [], isLoading } = useQuery(
autopilotDeliveriesOptions(wsId, autopilotId, {
enabled: hasWebhookTrigger,
}),
);
// No webhook trigger configured → the entire section is irrelevant. We hide
// it rather than render an empty card to keep the detail page short for
// schedule-only autopilots.
if (!hasWebhookTrigger) return null;
return (
<section className="space-y-3">
<h2 className="text-sm font-medium text-muted-foreground uppercase tracking-wider">
{t(($) => $.deliveries.section_title)}
</h2>
{isLoading ? (
<div className="space-y-1">
{Array.from({ length: 3 }).map((_, i) => (
<Skeleton key={i} className="h-10 w-full" />
))}
</div>
) : deliveries.length === 0 ? (
<div className="rounded-md border border-dashed p-4 text-center text-sm text-muted-foreground">
{t(($) => $.deliveries.empty)}
</div>
) : (
<div className="rounded-md border overflow-hidden">
{deliveries.map((delivery) => (
<DeliveryRow
key={delivery.id}
delivery={delivery}
autopilotId={autopilotId}
/>
))}
</div>
)}
</section>
);
}
// --- Row ------------------------------------------------------------------
function DeliveryRow({
delivery,
autopilotId,
}: {
delivery: WebhookDelivery;
autopilotId: string;
}) {
const { t } = useT("autopilots");
const [open, setOpen] = useState(false);
const visual = visualForStatus(delivery.status);
const StatusIcon = visual.icon;
const statusLabel =
t(($) => $.deliveries.status[delivery.status as WebhookDeliveryStatus]) ??
delivery.status;
const providerLabel = delivery.provider || "—";
return (
<>
<button
type="button"
onClick={() => setOpen(true)}
className="flex w-full items-center gap-3 px-4 py-2.5 text-left text-sm hover:bg-accent/30 transition-colors"
>
<StatusIcon
className={cn(
"h-4 w-4 shrink-0",
visual.color,
visual.spin && "animate-spin",
)}
/>
<span className={cn("w-24 shrink-0 text-xs font-medium", visual.color)}>
{statusLabel}
</span>
<span className="w-20 shrink-0 text-xs text-muted-foreground truncate">
{providerLabel}
</span>
<span className="flex-1 min-w-0 text-xs text-muted-foreground truncate font-mono">
{delivery.event || t(($) => $.webhook_payload.unknown_event)}
</span>
{delivery.replayed_from_delivery_id && (
<Badge variant="secondary" className="shrink-0">
<RotateCw className="h-3 w-3" />
{t(($) => $.deliveries.row.replay_badge)}
</Badge>
)}
{delivery.attempt_count > 1 && (
<Badge variant="outline" className="shrink-0">
{t(($) => $.deliveries.row.attempts, {
count: delivery.attempt_count,
})}
</Badge>
)}
<span className="w-32 shrink-0 text-right text-xs text-muted-foreground tabular-nums">
{formatDate(delivery.received_at || delivery.created_at)}
</span>
</button>
{open && (
<DeliveryDetailDialog
open={open}
onOpenChange={setOpen}
autopilotId={autopilotId}
delivery={delivery}
/>
)}
</>
);
}
// --- Detail dialog --------------------------------------------------------
function DeliveryDetailDialog({
open,
onOpenChange,
autopilotId,
delivery,
}: {
open: boolean;
onOpenChange: (open: boolean) => void;
autopilotId: string;
delivery: WebhookDelivery;
}) {
const { t } = useT("autopilots");
const wsId = useWorkspaceId();
const { data: detail, isLoading } = useQuery(
autopilotDeliveryOptions(wsId, autopilotId, delivery.id, { enabled: open }),
);
// Use the detail row when loaded, otherwise the slim row from the list.
// The slim row is missing raw_body / response_body / selected_headers; the
// dialog renders skeleton placeholders for those sections while detail is
// still loading.
const full = detail ?? delivery;
const visual = visualForStatus(full.status);
const StatusIcon = visual.icon;
return (
<Dialog open={open} onOpenChange={onOpenChange}>
{/* max-h + overflow-y-auto: webhook bodies + headers + response can
easily exceed viewport height. Without a cap the dialog grows past
the screen edge and the bottom (e.g. Replay button) becomes
unreachable. 85vh leaves breathing room around the dialog. */}
<DialogContent className="max-w-2xl max-h-[85vh] overflow-y-auto">
<DialogTitle className="flex items-center gap-2">
<Webhook className="h-4 w-4 text-muted-foreground" />
{t(($) => $.deliveries.detail.title)}
</DialogTitle>
<div className="space-y-4 pt-1">
{/* Header row — status / provider / event */}
<div className="flex flex-wrap items-center gap-3">
<div className="flex items-center gap-2">
<StatusIcon
className={cn(
"h-4 w-4 shrink-0",
visual.color,
visual.spin && "animate-spin",
)}
/>
<span className={cn("text-sm font-medium", visual.color)}>
{t(($) => $.deliveries.status[full.status as WebhookDeliveryStatus]) ??
full.status}
</span>
</div>
<Badge variant="outline">{full.provider || "—"}</Badge>
<code className="rounded bg-muted px-2 py-0.5 text-xs font-mono">
{full.event || t(($) => $.webhook_payload.unknown_event)}
</code>
<SignatureBadge status={full.signature_status as WebhookSignatureStatus} />
</div>
{/* Meta grid */}
<dl className="grid grid-cols-2 gap-x-4 gap-y-2 text-xs">
<MetaRow
label={t(($) => $.deliveries.detail.received_at)}
value={formatDate(full.received_at)}
/>
<MetaRow
label={t(($) => $.deliveries.detail.last_attempt_at)}
value={formatDate(full.last_attempt_at)}
/>
<MetaRow
label={t(($) => $.deliveries.detail.attempt_count)}
value={String(full.attempt_count)}
/>
<MetaRow
label={t(($) => $.deliveries.detail.response_status)}
value={full.response_status != null ? String(full.response_status) : "—"}
/>
<MetaRow
label={t(($) => $.deliveries.detail.dedupe_key)}
value={full.dedupe_key ?? "—"}
mono
/>
<MetaRow
label={t(($) => $.deliveries.detail.dedupe_source)}
value={full.dedupe_source ?? "—"}
/>
{full.content_type && (
<MetaRow
label={t(($) => $.deliveries.detail.content_type)}
value={full.content_type}
mono
/>
)}
{full.replayed_from_delivery_id && (
<MetaRow
label={t(($) => $.deliveries.detail.replayed_from)}
value={full.replayed_from_delivery_id}
mono
/>
)}
</dl>
{full.error && (
<div className="rounded-md border border-destructive/30 bg-destructive/5 px-3 py-2 text-xs text-destructive">
<div className="font-medium">
{t(($) => $.deliveries.detail.error_label)}
</div>
<div className="mt-0.5 font-mono break-all">{full.error}</div>
</div>
)}
{/* Raw body + response body + headers, all loaded lazily */}
<DetailSections detail={detail} isLoading={isLoading} />
{/* Replay button */}
<div className="flex items-center justify-between pt-2">
<ReplayHint delivery={full} />
<ReplayButton
autopilotId={autopilotId}
delivery={full}
onSuccess={() => onOpenChange(false)}
/>
</div>
</div>
</DialogContent>
</Dialog>
);
}
function MetaRow({
label,
value,
mono = false,
}: {
label: string;
value: string;
mono?: boolean;
}) {
return (
<div className="flex flex-col">
<dt className="text-muted-foreground">{label}</dt>
<dd
className={cn(
"truncate text-foreground",
mono && "font-mono",
)}
title={value}
>
{value}
</dd>
</div>
);
}
function SignatureBadge({ status }: { status: WebhookSignatureStatus | string }) {
const { t } = useT("autopilots");
let variant: "default" | "secondary" | "destructive" | "outline" = "outline";
if (status === "valid") variant = "default";
else if (status === "invalid") variant = "destructive";
else if (status === "missing") variant = "secondary";
return (
<Badge variant={variant}>
{t(($) => $.deliveries.signature[status as WebhookSignatureStatus]) ?? status}
</Badge>
);
}
function DetailSections({
detail,
isLoading,
}: {
detail: WebhookDelivery | undefined;
isLoading: boolean;
}) {
const { t } = useT("autopilots");
if (isLoading && !detail) {
return (
<div className="space-y-2">
<Skeleton className="h-24 w-full" />
<Skeleton className="h-16 w-full" />
</div>
);
}
if (!detail) return null;
return (
<div className="space-y-3">
{detail.raw_body && (
<CodeBlock
label={t(($) => $.deliveries.detail.raw_body)}
value={detail.raw_body}
/>
)}
{detail.selected_headers && Object.keys(detail.selected_headers).length > 0 && (
<CodeBlock
label={t(($) => $.deliveries.detail.selected_headers)}
value={JSON.stringify(detail.selected_headers, null, 2)}
/>
)}
{detail.response_body && (
<CodeBlock
label={t(($) => $.deliveries.detail.response_body)}
value={detail.response_body}
/>
)}
</div>
);
}
function CodeBlock({ label, value }: { label: string; value: string }) {
const { t } = useT("autopilots");
const [copied, setCopied] = useState(false);
// Truncate in-DOM display for very large bodies; the Copy button still
// yields the full string. 4 KiB is large enough for typical webhook
// payloads while keeping the dialog responsive.
const TRUNCATE_AT = 4096;
const isTruncated = value.length > TRUNCATE_AT;
const display = isTruncated ? value.slice(0, TRUNCATE_AT) : value;
const handleCopy = async () => {
try {
await navigator.clipboard.writeText(value);
setCopied(true);
toast.success(t(($) => $.webhook_payload.copied));
setTimeout(() => setCopied(false), 1500);
} catch {
toast.error(t(($) => $.webhook_payload.copy_failed));
}
};
return (
// min-w-0 lets this card shrink below the <pre>'s intrinsic min-content
// width — without it, a minified single-line JSON body would push the
// surrounding grid/flex cell (and the whole DialogContent) past the
// viewport edge.
<div className="min-w-0 rounded-md border bg-background">
<div className="flex items-center justify-between border-b px-3 py-1.5 text-[11px]">
<span className="font-medium text-muted-foreground">{label}</span>
<button
type="button"
onClick={handleCopy}
className="flex items-center gap-1 rounded px-2 py-0.5 hover:bg-accent transition-colors"
>
{copied ? (
<Check className="h-3 w-3 text-emerald-500" />
) : (
<Copy className="h-3 w-3" />
)}
{copied
? t(($) => $.webhook_payload.copied_short)
: t(($) => $.webhook_payload.copy)}
</button>
</div>
{/* whitespace-pre-wrap keeps pretty-printed indentation but lets
long lines wrap; break-all is the only thing that breaks mid-token
(necessary for minified JSON, which has no whitespace to break at). */}
<pre className="max-h-48 overflow-auto bg-muted/40 px-3 py-2 text-xs font-mono leading-relaxed whitespace-pre-wrap break-all">
{display}
{isTruncated && (
<span className="block pt-2 text-muted-foreground/70">
{t(($) => $.webhook_payload.truncated_marker)}
</span>
)}
</pre>
</div>
);
}
function ReplayHint({ delivery }: { delivery: WebhookDelivery }) {
const { t } = useT("autopilots");
if (delivery.signature_status === "invalid") {
return (
<span className="text-xs text-muted-foreground">
{t(($) => $.deliveries.replay.disabled_invalid_signature)}
</span>
);
}
if (delivery.status === "rejected") {
return (
<span className="text-xs text-muted-foreground">
{t(($) => $.deliveries.replay.disabled_rejected)}
</span>
);
}
if (delivery.status === "queued") {
return (
<span className="text-xs text-muted-foreground">
{t(($) => $.deliveries.replay.disabled_queued)}
</span>
);
}
return null;
}
function ReplayButton({
autopilotId,
delivery,
onSuccess,
}: {
autopilotId: string;
delivery: WebhookDelivery;
onSuccess: () => void;
}) {
const { t } = useT("autopilots");
const replay = useReplayAutopilotDelivery();
const enabled = canReplay(delivery) && !replay.isPending;
const handleClick = async () => {
try {
await replay.mutateAsync({ autopilotId, deliveryId: delivery.id });
toast.success(t(($) => $.deliveries.replay.toast_success));
onSuccess();
} catch (e: unknown) {
const message =
e instanceof Error
? e.message
: t(($) => $.deliveries.replay.toast_failed);
toast.error(message);
}
};
return (
<Button
size="sm"
variant="outline"
onClick={handleClick}
disabled={!enabled}
>
<RotateCw
className={cn(
"h-3.5 w-3.5 mr-1",
replay.isPending && "animate-spin",
)}
/>
{replay.isPending
? t(($) => $.deliveries.replay.in_progress)
: t(($) => $.deliveries.replay.action)}
</Button>
);
}

View File

@@ -0,0 +1,72 @@
import { describe, it, expect, beforeAll, vi } from "vitest";
import { screen, fireEvent } from "@testing-library/react";
import { renderWithI18n } from "../../test/i18n";
import { WebhookPayloadPreview } from "./webhook-payload-preview";
// sonner.toast is a fire-and-forget side-effect we don't want to assert on
// in these tests; stub it so the Copy button doesn't blow up on toast
// invocation.
vi.mock("sonner", () => ({
toast: { success: vi.fn(), error: vi.fn() },
}));
// jsdom doesn't provide navigator.clipboard by default. Stub it once.
beforeAll(() => {
Object.assign(navigator, {
clipboard: { writeText: vi.fn().mockResolvedValue(undefined) },
});
});
const envelope = (event: string, eventPayload: unknown, extras: Record<string, unknown> = {}) => ({
event,
eventPayload,
request: { receivedAt: "2026-05-13T12:34:56Z", contentType: "application/json", ...extras },
});
describe("WebhookPayloadPreview", () => {
it("renders the envelope event in the header", () => {
renderWithI18n(
<WebhookPayloadPreview
payload={envelope("github.pull_request.opened", { number: 1 })}
defaultOpen
/>,
);
expect(screen.getByText("github.pull_request.opened")).toBeInTheDocument();
});
it("falls back gracefully when payload is not an envelope", () => {
renderWithI18n(
<WebhookPayloadPreview payload={{ hello: "world" }} defaultOpen />,
);
// The unknown-event placeholder is the i18n key; the body should still
// include the raw JSON so nothing is hidden.
expect(screen.getByText(/hello/)).toBeInTheDocument();
});
it("truncates display when the payload exceeds 4 KiB but copies full text", async () => {
// 5 KiB string field → stringified envelope > 4 KiB.
const bigPayload = envelope("demo.big", { blob: "x".repeat(5 * 1024) });
renderWithI18n(
<WebhookPayloadPreview payload={bigPayload} defaultOpen />,
);
// Truncation marker (i18n) appears as a tail span — we assert by
// partial text rather than coupling to the exact phrasing.
expect(screen.getByText(/truncated/i)).toBeInTheDocument();
// The visible <pre> body must NOT contain the full 5 KiB blob — it is
// sliced to the truncate threshold.
const pre = document.querySelector("pre");
expect(pre).not.toBeNull();
expect((pre!.textContent ?? "").length).toBeLessThan(5 * 1024 + 200);
// Clicking Copy must still hand the FULL payload to the clipboard.
fireEvent.click(screen.getByRole("button", { name: /copy/i }));
const writeText = navigator.clipboard.writeText as ReturnType<typeof vi.fn>;
expect(writeText).toHaveBeenCalled();
const lastCall = writeText.mock.calls[writeText.mock.calls.length - 1];
if (!lastCall) throw new Error("clipboard.writeText was not called");
const written = lastCall[0] as string;
expect(written.length).toBeGreaterThan(5 * 1024);
expect(written).toContain("xxxxxxxx");
});
});

View File

@@ -0,0 +1,141 @@
"use client";
import { useState, useMemo } from "react";
import { Webhook, ChevronDown, ChevronRight, Copy, Check } from "lucide-react";
import { toast } from "sonner";
import { cn } from "@multica/ui/lib/utils";
import { useT } from "../../i18n";
interface WebhookPayloadPreviewProps {
payload: unknown;
/** Default open vs collapsed. The dialog has limited vertical space, so
* we collapse by default and let the user expand. */
defaultOpen?: boolean;
}
/**
* Renders a webhook trigger payload (the WebhookEnvelope shape produced
* server-side by normalizeWebhookPayload) inline with the autopilot run
* detail. Falls back gracefully when the payload isn't an envelope —
* showing whatever JSON is there with a generic header.
*
* This is intentionally read-only and decoupled from any specific dialog
* — it gets dropped into AgentTranscriptDialog's headerSlot.
*/
export function WebhookPayloadPreview({
payload,
defaultOpen = false,
}: WebhookPayloadPreviewProps) {
const { t } = useT("autopilots");
const [open, setOpen] = useState(defaultOpen);
const [copied, setCopied] = useState(false);
const { event, receivedAt, contentType, fullJSON, displayJSON, isTruncated } = useMemo(() => {
let event: string | null = null;
let eventPayload: unknown = null;
let receivedAt: string | null = null;
let contentType: string | null = null;
if (payload && typeof payload === "object" && !Array.isArray(payload)) {
const obj = payload as Record<string, unknown>;
if (typeof obj.event === "string") event = obj.event;
if ("eventPayload" in obj) eventPayload = obj.eventPayload;
const req = obj.request;
if (req && typeof req === "object") {
const r = req as Record<string, unknown>;
if (typeof r.receivedAt === "string") receivedAt = r.receivedAt;
if (typeof r.contentType === "string") contentType = r.contentType;
}
}
// If the payload didn't match the envelope shape (caller wrote
// directly to trigger_payload, malformed history row, etc.), show
// the whole thing as the eventPayload so nothing is hidden.
if (eventPayload === null && payload !== null && payload !== undefined) {
eventPayload = payload;
}
const fullJSON = JSON.stringify(eventPayload, null, 2);
// Truncate the in-DOM string so the dialog stays responsive even when a
// provider sent a 256 KiB envelope. The Copy button still yields the
// full string, so the user never loses the data. 4 KiB is large enough
// to show the envelope header + first object-level fields of a typical
// webhook payload.
const TRUNCATE_AT = 4096;
const isTruncated = fullJSON.length > TRUNCATE_AT;
const displayJSON = isTruncated ? fullJSON.slice(0, TRUNCATE_AT) : fullJSON;
return { event, receivedAt, contentType, fullJSON, displayJSON, isTruncated };
}, [payload]);
const handleCopy = async (e: React.MouseEvent) => {
e.stopPropagation();
try {
await navigator.clipboard.writeText(fullJSON);
setCopied(true);
toast.success(t(($) => $.webhook_payload.copied));
setTimeout(() => setCopied(false), 1500);
} catch {
toast.error(t(($) => $.webhook_payload.copy_failed));
}
};
return (
<div className="rounded-md border bg-background">
<button
type="button"
onClick={() => setOpen((v) => !v)}
className="flex w-full items-center gap-2 px-3 py-2 text-left text-xs hover:bg-accent/30 transition-colors"
>
<Webhook className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
<span className="font-medium">
{t(($) => $.webhook_payload.label)}
</span>
<code className="truncate font-mono text-muted-foreground">
{event ?? t(($) => $.webhook_payload.unknown_event)}
</code>
{receivedAt && (
<span className="ml-auto shrink-0 text-muted-foreground/70">
{receivedAt}
</span>
)}
{open ? (
<ChevronDown className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
) : (
<ChevronRight className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
)}
</button>
{open && (
<div className="border-t">
<div className="flex items-center justify-between px-3 py-1.5 text-[11px] text-muted-foreground">
<span>
{contentType
? t(($) => $.webhook_payload.content_type, { type: contentType })
: t(($) => $.webhook_payload.payload)}
</span>
<button
type="button"
onClick={handleCopy}
className={cn(
"flex items-center gap-1 rounded px-2 py-0.5 hover:bg-accent transition-colors",
)}
>
{copied ? (
<Check className="h-3 w-3 text-emerald-500" />
) : (
<Copy className="h-3 w-3" />
)}
{copied
? t(($) => $.webhook_payload.copied_short)
: t(($) => $.webhook_payload.copy)}
</button>
</div>
<pre className="max-h-64 overflow-auto bg-muted/40 px-3 py-2 text-xs font-mono leading-relaxed">
{displayJSON}
{isTruncated && (
<span className="block pt-2 text-muted-foreground/70">
{t(($) => $.webhook_payload.truncated_marker)}
</span>
)}
</pre>
</div>
)}
</div>
);
}

View File

@@ -907,7 +907,7 @@ function SessionDropdown({
return (
<>
<DropdownMenu>
<DropdownMenuTrigger className="flex items-center gap-1.5 min-w-0 rounded-md px-1.5 py-1 transition-colors hover:bg-accent aria-expanded:bg-accent">
<DropdownMenuTrigger className="flex max-w-96 min-w-0 items-center gap-1.5 rounded-md px-1.5 py-1 transition-colors hover:bg-accent aria-expanded:bg-accent">
{triggerAgent && (
<ActorAvatar
actorType="agent"
@@ -917,7 +917,7 @@ function SessionDropdown({
showStatusDot
/>
)}
<span className="truncate text-sm font-medium">{title}</span>
<span className="min-w-0 truncate text-sm font-medium">{title}</span>
{otherSessionRunning ? (
<span
aria-label={t(($) => $.window.another_running)}
@@ -933,7 +933,10 @@ function SessionDropdown({
) : null}
<ChevronDown className="size-3 text-muted-foreground shrink-0" />
</DropdownMenuTrigger>
<DropdownMenuContent align="start" className="max-h-96 w-auto min-w-64 max-w-80 overflow-y-auto">
<DropdownMenuContent
align="start"
className="max-h-96 w-auto min-w-[max(16rem,var(--anchor-width,16rem))] max-w-96 overflow-y-auto"
>
{sessions.length === 0 ? (
<div className="px-2 py-1.5 text-xs text-muted-foreground">
{t(($) => $.window.no_previous)}

View File

@@ -9,11 +9,12 @@ import {
} from "@multica/ui/components/ui/hover-card";
import { useActorName } from "@multica/core/workspace/hooks";
import { useAgentPresenceDetail } from "@multica/core/agents";
import { useCurrentWorkspace } from "@multica/core/paths";
import { useCurrentWorkspace, useWorkspacePaths } from "@multica/core/paths";
import { AgentProfileCard } from "../agents/components/agent-profile-card";
import { AgentLivePeekCard } from "../agents/components/agent-live-peek-card";
import { MemberProfileCard } from "../members/member-profile-card";
import { availabilityConfig } from "../agents/presence";
import { useNavigation } from "../navigation";
/**
* Selects which agent hover-card payload to render when `enableHoverCard` is
@@ -54,10 +55,17 @@ interface ActorAvatarProps {
* existing call sites keep their identity-card behaviour.
*/
hoverCardVariant?: AgentHoverCardVariant;
/**
* Make the avatar click through to the actor page. Defaults on for members
* and agents, while picker/menu controls keep their own click behavior.
*/
profileLink?: boolean;
}
const FOCUSABLE_ANCESTOR_SELECTOR =
'a[href], button:not([disabled]), [role="button"]:not([aria-disabled="true"]), [tabindex]:not([tabindex="-1"])';
const PROFILE_LINK_CONTROL_SELECTOR =
'button, [role^="menuitem"], [role="option"], [data-slot="dropdown-menu-item"], [data-slot="dropdown-menu-checkbox-item"], [data-slot="popover-trigger"]';
export function ActorAvatar({
actorType,
@@ -67,8 +75,10 @@ export function ActorAvatar({
enableHoverCard,
showStatusDot,
hoverCardVariant = "profile",
profileLink,
}: ActorAvatarProps) {
const { getActorName, getActorInitials, getActorAvatarUrl } = useActorName();
const paths = useWorkspacePaths();
const avatar = (
<ActorAvatarBase
name={getActorName(actorType, actorId)}
@@ -95,21 +105,79 @@ export function ActorAvatar({
) : (
avatar
);
const shouldLinkToProfile =
profileLink ?? (actorType === "member" || actorType === "agent");
const profileHref =
shouldLinkToProfile && actorType === "member"
? paths.memberDetail(actorId)
: shouldLinkToProfile && actorType === "agent"
? paths.agentDetail(actorId)
: null;
const content = profileHref ? (
<ActorAvatarProfileLink href={profileHref}>{dotted}</ActorAvatarProfileLink>
) : (
dotted
);
if (!enableHoverCard) {
return dotted;
return content;
}
if (actorType === "agent") {
return (
<AgentAvatarHoverCard agentId={actorId} variant={hoverCardVariant}>
{dotted}
{content}
</AgentAvatarHoverCard>
);
}
if (actorType === "member") {
return <MemberAvatarHoverCard userId={actorId}>{dotted}</MemberAvatarHoverCard>;
return <MemberAvatarHoverCard userId={actorId}>{content}</MemberAvatarHoverCard>;
}
return dotted;
return content;
}
function ActorAvatarProfileLink({
href,
children,
}: {
href: string;
children: React.ReactNode;
}) {
const { push, openInNewTab } = useNavigation();
const navigate = (event: React.MouseEvent | React.KeyboardEvent) => {
const controlAncestor = event.currentTarget.parentElement?.closest(
PROFILE_LINK_CONTROL_SELECTOR,
);
if (controlAncestor) return;
event.preventDefault();
event.stopPropagation();
if (
"metaKey" in event &&
(event.metaKey || event.ctrlKey || event.shiftKey) &&
openInNewTab
) {
openInNewTab(href);
return;
}
push(href);
};
return (
<span
role="link"
tabIndex={-1}
className="inline-flex cursor-pointer rounded-full"
onClick={navigate}
onKeyDown={(event) => {
if (event.key === "Enter" || event.key === " ") {
navigate(event);
}
}}
>
{children}
</span>
);
}
// Small presence indicator overlaid on the bottom-right of an agent avatar.

View File

@@ -0,0 +1,246 @@
"use client";
import { useEffect, useMemo, useState } from "react";
import { useStore } from "zustand";
import { ListTodo, Search } from "lucide-react";
import { useQuery } from "@tanstack/react-query";
import { useWorkspaceId } from "@multica/core/hooks";
import { BOARD_STATUSES } from "@multica/core/issues/config";
import {
childIssueProgressOptions,
myIssueListOptions,
type MyIssuesFilter,
} from "@multica/core/issues/queries";
import {
actorIssuesViewStore,
type ActorIssuesScope,
} from "@multica/core/issues/stores/actor-issues-view-store";
import { useIssueSelectionStore } from "@multica/core/issues/stores/selection-store";
import { useClearFiltersOnWorkspaceChange } from "@multica/core/issues/stores/view-store";
import { ViewStoreProvider } from "@multica/core/issues/stores/view-store-context";
import { Button } from "@multica/ui/components/ui/button";
import { Input } from "@multica/ui/components/ui/input";
import { Skeleton } from "@multica/ui/components/ui/skeleton";
import { Tooltip, TooltipContent, TooltipTrigger } from "@multica/ui/components/ui/tooltip";
import { ListView } from "../issues/components/list-view";
import { BatchActionToolbar } from "../issues/components/batch-action-toolbar";
import { IssueDisplayControls } from "../issues/components/issues-header";
import { filterIssues } from "../issues/utils/filter";
import { matchesPinyin } from "../editor/extensions/pinyin-match";
import { useT } from "../i18n";
export type TaskActorType = "member" | "agent";
const SCOPE_VALUES: ActorIssuesScope[] = ["assigned", "created"];
export function ActorIssuesPanel({
actorType,
actorId,
}: {
actorType: TaskActorType;
actorId: string;
}) {
const { t } = useT("issues");
const wsId = useWorkspaceId();
const scope = useStore(actorIssuesViewStore, (s) => s.scope);
const setScope = useStore(actorIssuesViewStore, (s) => s.setScope);
const setViewMode = useStore(actorIssuesViewStore, (s) => s.setViewMode);
const statusFilters = useStore(actorIssuesViewStore, (s) => s.statusFilters);
const priorityFilters = useStore(actorIssuesViewStore, (s) => s.priorityFilters);
const assigneeFilters = useStore(actorIssuesViewStore, (s) => s.assigneeFilters);
const includeNoAssignee = useStore(actorIssuesViewStore, (s) => s.includeNoAssignee);
const creatorFilters = useStore(actorIssuesViewStore, (s) => s.creatorFilters);
const projectFilters = useStore(actorIssuesViewStore, (s) => s.projectFilters);
const includeNoProject = useStore(actorIssuesViewStore, (s) => s.includeNoProject);
const labelFilters = useStore(actorIssuesViewStore, (s) => s.labelFilters);
const [search, setSearch] = useState("");
useClearFiltersOnWorkspaceChange(actorIssuesViewStore, wsId);
// The actor tasks panel is list-only; clear any persisted "board" state
// so list-only affordances (e.g. BatchActionToolbar) render correctly.
useEffect(() => {
setViewMode("list");
}, [setViewMode]);
useEffect(() => {
useIssueSelectionStore.getState().clear();
}, [scope, actorType, actorId]);
const queryFilter: MyIssuesFilter = useMemo(
() =>
scope === "assigned"
? { assignee_id: actorId }
: { creator_id: actorId },
[scope, actorId],
);
const queryScope = `${actorType}:${actorId}:${scope}`;
const rawIssuesQuery = useQuery(myIssueListOptions(wsId, queryScope, queryFilter));
const rawIssues = useMemo(
() => rawIssuesQuery.data ?? [],
[rawIssuesQuery.data],
);
const isLoading = rawIssuesQuery.isLoading;
const actorIssues = useMemo(
() =>
rawIssues.filter((issue) =>
scope === "assigned"
? issue.assignee_type === actorType && issue.assignee_id === actorId
: issue.creator_type === actorType && issue.creator_id === actorId,
),
[actorId, actorType, rawIssues, scope],
);
const filteredIssues = useMemo(
() =>
filterIssues(actorIssues, {
statusFilters,
priorityFilters,
assigneeFilters,
includeNoAssignee,
creatorFilters,
projectFilters,
includeNoProject,
labelFilters,
}),
[
actorIssues,
statusFilters,
priorityFilters,
assigneeFilters,
includeNoAssignee,
creatorFilters,
projectFilters,
includeNoProject,
labelFilters,
],
);
const issues = useMemo(() => {
const query = search.trim().toLowerCase();
if (!query) return filteredIssues;
return filteredIssues.filter((issue) => {
const title = issue.title ?? "";
return (
title.toLowerCase().includes(query) ||
issue.identifier.toLowerCase().includes(query) ||
matchesPinyin(title, query)
);
});
}, [filteredIssues, search]);
const { data: childProgressMap = new Map() } = useQuery(
childIssueProgressOptions(wsId),
);
const visibleStatuses = useMemo(() => {
if (statusFilters.length > 0) {
return BOARD_STATUSES.filter((s) => statusFilters.includes(s));
}
return BOARD_STATUSES;
}, [statusFilters]);
if (isLoading) {
return <ActorIssuesSkeleton />;
}
return (
<ViewStoreProvider store={actorIssuesViewStore}>
<div className="flex flex-1 min-h-0 flex-col">
<div className="flex h-12 shrink-0 items-center justify-between gap-3 border-b px-4">
<div className="flex items-center gap-3">
<div className="relative">
<Search className="pointer-events-none absolute left-2.5 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-muted-foreground" />
<Input
value={search}
onChange={(e) => setSearch(e.target.value)}
placeholder={t(($) => $.actor_issues.search_placeholder)}
className="h-8 w-64 pl-8 text-sm"
/>
</div>
<div className="flex items-center gap-1">
{SCOPE_VALUES.map((value) => (
<Tooltip key={value}>
<TooltipTrigger
render={
<Button
variant="outline"
size="sm"
className={
scope === value
? "bg-accent text-accent-foreground hover:bg-accent/80"
: "text-muted-foreground"
}
onClick={() => setScope(value)}
>
{t(($) => $.actor_issues.scope[value].label)}
</Button>
}
/>
<TooltipContent side="bottom">
{t(($) => $.actor_issues.scope[value].description)}
</TooltipContent>
</Tooltip>
))}
</div>
</div>
<IssueDisplayControls scopedIssues={actorIssues} hideViewToggle />
</div>
{actorIssues.length === 0 ? (
<div className="flex flex-1 min-h-0 flex-col items-center justify-center gap-2 text-muted-foreground">
<ListTodo className="h-10 w-10 text-muted-foreground/40" />
<p className="text-sm">
{t(($) => $.actor_issues.empty[scope].title)}
</p>
<p className="text-xs">
{t(($) => $.actor_issues.empty[scope].description)}
</p>
</div>
) : search.trim() !== "" && issues.length === 0 ? (
<div className="flex flex-1 min-h-0 flex-col items-center justify-center gap-2 text-muted-foreground">
<Search className="h-10 w-10 text-muted-foreground/40" />
<p className="text-sm">{t(($) => $.actor_issues.search_empty)}</p>
</div>
) : (
<div className="flex flex-1 min-h-0 flex-col">
<ListView
issues={issues}
visibleStatuses={visibleStatuses}
childProgressMap={childProgressMap}
myIssuesScope={queryScope}
myIssuesFilter={queryFilter}
/>
</div>
)}
<BatchActionToolbar />
</div>
</ViewStoreProvider>
);
}
function ActorIssuesSkeleton() {
return (
<div className="flex flex-1 min-h-0 flex-col">
<div className="flex h-12 shrink-0 items-center justify-between border-b px-4">
<div className="flex items-center gap-3">
<Skeleton className="h-8 w-64 rounded-md" />
<Skeleton className="h-8 w-20 rounded-md" />
<Skeleton className="h-8 w-20 rounded-md" />
</div>
<div className="flex items-center gap-1">
<Skeleton className="h-8 w-8 rounded-md" />
<Skeleton className="h-8 w-8 rounded-md" />
</div>
</div>
<div className="flex flex-1 min-h-0 flex-col gap-2 p-4">
{Array.from({ length: 6 }).map((_, i) => (
<Skeleton key={i} className="h-10 w-full rounded-md" />
))}
</div>
</div>
);
}

View File

@@ -43,6 +43,13 @@ interface AgentTranscriptDialogProps {
items: TimelineItem[];
agentName: string;
isLive?: boolean;
/**
* Optional content rendered between the header chips and the event list.
* Used by autopilot run rows to surface the inbound webhook trigger
* payload so it's visible regardless of whether the agent echoes it.
* The dialog stays generic — slot content is the caller's concern.
*/
headerSlot?: React.ReactNode;
}
// ─── Color mapping for timeline segments ────────────────────────────────────
@@ -162,6 +169,7 @@ export function AgentTranscriptDialog({
items,
agentName,
isLive = false,
headerSlot,
}: AgentTranscriptDialogProps) {
const { t } = useT("agents");
const [selectedSeq, setSelectedSeq] = useState<number | null>(null);
@@ -451,6 +459,13 @@ export function AgentTranscriptDialog({
</div>
)}
{/* ── Optional header slot (e.g. webhook payload preview) ── */}
{headerSlot && (
<div className="border-b px-4 py-3 shrink-0 bg-muted/20">
{headerSlot}
</div>
)}
{/* ── Event list ─────────────────────────────────────────── */}
<div
ref={scrollContainerRef}

Some files were not shown because too many files have changed in this diff Show More