Compare commits

...

153 Commits

Author SHA1 Message Date
Lambda
117c7ba6ae fix(inbox): keep scope/availability caches fresh on issue reassign + relation changes
- issue:updated WS + useUpdateIssue / useBatchUpdateIssues now invalidate
  inboxKeys.list + scopeCounts so assignee_scope-derived chip filtering,
  badges, and bulk operations don't lag the actual scope.
- onInboxInvalidate / onInboxIssueDeleted also flush scopeCounts so
  single-row archived/read events and CASCADE-deletes refresh the chip
  badge alongside the list.
- agent / member / squad refresh handlers invalidate
  inboxKeys.resourceAvailability so chip enabled state reacts to the
  first owned-agent / squad-membership / squad creation event instead
  of waiting for reload.
- Inbox page header unread count derives from filtered items rather
  than the global useInboxUnreadCount so the badge matches the visible
  list; sidebar / desktop badge stay on the global count.

Co-authored-by: multica-agent <github@multica.ai>
2026-05-19 23:18:36 +08:00
Lambda
7ac797fcd8 refactor(inbox): rename batch-archived operation literal archive_all_read → archive_read
RFC v4 final naming (per Xeon directive on MUL-2426): the three places that
must agree on the operation literal are the server `inbox:batch-archived`
event payload, the bulk-endpoint handler switch, and the frontend
`InboxBatchArchiveOperation` discriminated union. UI menu / error i18n keys
are unrelated and stay as-is.

Co-authored-by: multica-agent <github@multica.ai>
2026-05-19 17:00:37 +08:00
Lambda
fd913a2596 feat(inbox): add assignment filter chips (assigned to me / my agent / my squad) (MUL-2426)
Implements RFC v3 + the two v4 deltas (operation field on inbox:batch-archived,
scoped.id/issue_id alias). Server-side first (SQL + handler + WS payload +
resource-availability), then frontend (chip UI + store + dynamic bulk labels).

Backend:
- Migration 095: SQL function squad_involves_user mirroring the
  ListIssues involves_user_id semantics so the inbox scope predicate
  can't drift from My Issues.
- ListInboxItems now tags each row with assignee_scope (me / my_agent /
  my_squad / other / none) and accepts an optional scopes filter.
- New endpoints: GET /api/inbox/scope-counts (post-dedup), GET
  /api/inbox/resource-availability (decoupled chip-disabled signal).
- mark-all-read + 3 archive endpoints accept ?scope=...; archive-* emit
  inbox:batch-archived with operation + scope so listeners can pick
  the right predicate when applying precise cache updates.

Frontend:
- New workspace-aware inbox-scope-store; default = all 3 chips selected.
- resolveInboxFilter implements the all / subset / empty algorithm.
- InboxFilterChips component with disabled-but-selected state machine
  (S1-S4) and tooltips, sourced from resource-availability rather than
  scope counts.
- Bulk actions disabled in empty mode, label swaps to "filtered" copy
  in subset mode.
- WS handlers for inbox:batch-read / inbox:batch-archived wired in to
  refresh other devices.

Co-authored-by: multica-agent <github@multica.ai>
2026-05-19 16:55:33 +08:00
Naiyuan Qing
39f43a9a98 refactor(editor): unify attachment rendering into a single <Attachment> component (#2850)
Collapse the five separate attachment render paths (file-card NodeView,
image NodeView, readonly markdown img/fileCard renderers, AttachmentList
standalone fallback, and the parallel packages/ui/markdown renderer) into
one <Attachment attachment={a} /> dispatcher.

Fixes a P0 visual regression: a PNG attached to a message but not inlined
in the markdown body used to render as a gray "file card" because
getPreviewKind() lacked an "image" branch and image rendering bypassed
the dispatcher entirely. Now every surface routes through <Attachment>,
so the same PNG renders as a real <img> with hover toolbar and
preview-modal everywhere.

Key changes:
- PreviewKind gains "image"; getPreviewKind() detects image/* + common
  extensions before the html/text branches (so svg stays image, not text).
- AttachmentPreviewModal gains case "image" (replaces the standalone
  ImageLightbox, which is deleted).
- New packages/views/editor/attachment.tsx owns all kind-aware routing
  (image | html | file) and dispatches preview modal + download via the
  existing useAttachmentPreview / useDownloadAttachment hooks. Subsumes
  the deleted AttachmentBlock.
- AttachmentInput.url accepts a forceKind hint so callers that *know*
  the structural kind (markdown ![](url), Tiptap image node) skip the
  filename-based autodetect — fixes a regression where empty or
  descriptive alt text would route an image to the file-card chrome.
- Tiptap NodeViews (file-card.tsx, image-view.tsx) shrink to thin
  wrappers that forward editor hints (selected, deleteNode, uploading)
  to <Attachment>.
- ReadonlyContent and AttachmentList each mount their own
  AttachmentDownloadProvider so url → record resolution works outside
  ContentEditor's provider.
- packages/ui/markdown gains optional renderImage / renderFileCard slot
  props; packages/views/common/markdown.tsx injects <Attachment> into
  those slots and threads message attachments through to chat /
  skill-file viewers.
- chat-message-list passes message.attachments to every <Markdown> call
  site and renders a standalone AttachmentList under each bubble for
  attachments not referenced in the body.

Tests: attachment.test.tsx covers 9 scenarios (record image / pdf / html;
url-only image with resolver hit and miss; uploading state; editable
delete; forceKind regression). attachment-preview-modal.test.tsx gains
image-dispatch cases. 652/652 unit tests pass.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 16:23:19 +08:00
Kagura
59617f376e feat(auth): make auth token TTL configurable via AUTH_TOKEN_TTL env var (MUL-2371) (#2713)
* feat(auth): make auth token TTL configurable via AUTH_TOKEN_TTL env var

Add AUTH_TOKEN_TTL environment variable (in seconds) to override the
hardcoded 30-day auth token lifetime. Self-hosted deployments on trusted
networks can set a longer value to avoid frequent magic-link
re-authentication.

The value is read once at startup and cached. Invalid or missing values
fall back to the 30-day default with a warning log.

Closes #2685

* refactor(auth): extract parseAuthTokenTTL for testability

Address review feedback: extract pure parse function from sync.Once
wrapper so the parsing logic can be unit-tested independently.
Add TestParseAuthTokenTTL with table-driven cases.

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

* refactor(auth): accept Go duration strings + hoist shared TTL in SetAuthCookies

Address nice-to-have review feedback from Bohan-J:
- parseAuthTokenTTL now tries time.ParseDuration first (e.g. '8760h'),
  falling back to ParseInt for integer seconds
- Warn on unreasonable values (>10 years) but still accept them
- Hoist AuthTokenTTL() and time.Now() in SetAuthCookies so both
  cookies share the exact same expiry
- Add security trade-off note in .env.example
- Add 5 new test cases for duration strings

Co-Authored-By: Claude Opus 4 (1M context) <noreply@anthropic.com>
Signed-off-by: kagura-agent <kagura.agent.ai@gmail.com>

* fix: use AuthTokenTTL() in CloudFront middleware, guard ParseInt overflow

Address review feedback from Bohan-J (round 2):

1. CloudFront refresh middleware (cloudfront.go:21) was hardcoding
   30*24*time.Hour instead of using auth.AuthTokenTTL(). Now calls
   AuthTokenTTL() so the middleware respects AUTH_TOKEN_TTL env var.

2. parseAuthTokenTTL integer-seconds branch: very large values like
   9999999999 would silently overflow int64 when multiplied by
   time.Second. Added overflow guard comparing against
   math.MaxInt64/int64(time.Second) before the multiplication.

3. Updated AuthTokenTTL() doc comment to reflect that it accepts
   Go duration strings or integer seconds (not just seconds).

4. Added middleware test (cloudfront_test.go) verifying short
   AUTH_TOKEN_TTL produces short cookie expiry, not 30-day hardcode.
   Also covers nil signer and existing-cookie-skip cases.

5. Added integer overflow test case to cookie_test.go.

* style: run gofmt on cookie.go and cookie_test.go

---------

Signed-off-by: kagura-agent <kagura.agent.ai@gmail.com>
Co-authored-by: Claude Opus 4 (1M context) <noreply@anthropic.com>
2026-05-19 16:22:07 +08:00
Bohan Jiang
9a577f3e11 fix(runtimes): anchor OpenCode skill + AGENTS.md discovery to task workdir (MUL-2416) (#2849)
* fix(runtimes): anchor OpenCode skill + AGENTS.md discovery to task workdir

OpenCode resolves its project discovery root from `--dir` and `PWD`
before falling back to `process.cwd()`. The daemon set `cmd.Dir =
workDir` but never overrode the inherited `PWD`, so OpenCode walked
from the daemon's shell directory and silently bypassed the per-task
workdir — agents lost visibility into `.opencode/skills/` and
`AGENTS.md`, falling back to whatever global skills the host had
installed (MUL-2416).

- Pass `opencode run --dir <workDir>` and override `PWD=<workDir>` in
  the child env so AGENTS.md walk-up + `.opencode/skills` project
  config scan both anchor on the task workdir.
- Block `--dir` from custom args so user overrides cannot re-introduce
  the regression.
- Plumb skill `description` from DB through service / daemon /
  execenv. `writeSkillFiles` synthesizes a YAML frontmatter block
  (`name`, optional `description`) when the stored content lacks one,
  since runtimes like OpenCode silently drop SKILL.md files without a
  parseable `name`. Existing frontmatter is preserved unchanged so
  upstream-imported skills (GitHub / ClawHub / Skills.sh) keep their
  hand-shaped metadata.

Tests:
- New fake-CLI test confirms argv carries `--dir <workDir>` and the
  child sees `PWD=<workDir>`.
- New test confirms a user-supplied `--dir` in custom_args is dropped.
- New execenv tests cover synthesized frontmatter and preservation of
  pre-existing frontmatter.

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

* fix(runtimes): inject SKILL.md `name` when upstream frontmatter omits it

Skills imported with frontmatter that sets `description` but leaves `name`
implicit (relying on the directory slug, as common in GitHub/Skills.sh
imports) still hit OpenCode's "no parseable name → drop" path because the
DB Name fallback never made it into the SKILL.md body. ensureSkillFrontmatter
now scans the existing block and, when name is missing or empty, prepends
`name: <slug>` while preserving description, body, and any runtime-specific
keys verbatim.

Also tighten yamlEscapeInline to always double-quote so descriptions that
look like YAML keywords (`null`, `true`, `[foo]`, `{x: y}`, `2024-01-01`)
parse as strings rather than getting reinterpreted and rejected.

Adds regression test for the nameless-frontmatter case and updates the
existing OpenCode skill test for the always-quoted description format.

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

---------

Co-authored-by: multica-agent <github@multica.ai>
2026-05-19 16:21:02 +08:00
Bohan Jiang
7be3838ada feat(transcript): add sort direction toggle to agent transcript dialog (MUL-2368) (#2848)
Adds a header toggle that lets users flip the agent transcript between
chronological (oldest first, current behavior) and newest-first. The
preference is persisted via a small Zustand store. Default stays
chronological so existing readers see no behavior change.

Sort is a pure presentation concern — the underlying timeline (seq
numbers, filter keys, segment navigation) is untouched. Toggling resets
the scroll container to the top so the user lands on the newest end of
the chosen direction. Copy-all respects the displayed order so the
exported text matches what's on screen.

Scope is limited to the task transcript dialog per the MVP plan; the
issue execution log and agent activity tab are out of scope and may be
revisited once this interaction validates.

Closes GH #2736.

Co-authored-by: multica-agent <github@multica.ai>
2026-05-19 16:03:31 +08:00
Bohan Jiang
98ef021d1d feat(projects): add Project Gantt view (MUL-1881) (#2843)
* feat(projects): add Project Gantt view (MUL-1881)

Adds Gantt as a third option in the Project page's view toggle (Board /
List / Gantt). Bars span start_date → due_date; issues with only one
date render as markers, issues with neither are collapsed into an
Unscheduled section. Toolbar exposes day/week/month zoom and a
show-completed toggle. The Gantt view shares the existing IssuesHeader
filters/sort.

Implementation is self-rendered SVG/HTML — no new dependencies. UTC
day-aligned date math keeps bars on the right columns regardless of
viewer timezone.

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

* fix(projects): scope Gantt to project surface + warn on hidden pages

- IssuesHeader / IssueDisplayControls now take `allowGantt` (default false);
  only Project Detail opts in. /issues, /my-issues and the actor panel no
  longer expose a Gantt option that silently fell through to List, and the
  toggle icon falls back to List when a stored `viewMode === "gantt"` lands
  on a surface that doesn't render it.
- Project Gantt now surfaces a banner with hidden-issue count plus a
  Load-all action that drains every remaining paginated page into the
  cache via the new `useLoadAllRemaining` helper. Pagination summary comes
  from `myIssueListPaginationOptions`, which shares the existing cache key
  with `myIssueListOptions` so totals stay in sync with Board/List.
- ScheduledRow normalizes a `start_date > due_date` anomaly to min/max and
  outlines the bar with a destructive ring + tooltip note, instead of
  silently dropping the row.

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

---------

Co-authored-by: multica-agent <github@multica.ai>
2026-05-19 15:55:57 +08:00
Jiayuan Zhang
6f21cb8f3e [codex] Simplify onboarding runtime bootstrap (#2836)
* feat(onboarding): simplify runtime bootstrap

* fix(onboarding): close private-helper reuse hole and guide-issue nav race

- server: when bootstrap looks for an existing Multica Helper, require
  Visibility="workspace" so a private helper owned by another member
  can't be auto-assigned to the onboarding issue (and trigger a task as
  that private agent), which would have bypassed canAccessPrivateAgent.
- web onboarding page: refreshMe() inside bootstrap flips hasOnboarded
  before onComplete fires, letting the guard's router.replace overtake
  onComplete's router.push to the new guide issue. Mark the page as
  "completing" right before navigating so the guard stays silent during
  the in-flight transition.

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

* fix(runtimes): escape daemon command literals to satisfy i18next/no-literal-string

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

---------

Co-authored-by: multica-agent <github@multica.ai>
Co-authored-by: Lambda <lambda@multica.ai>
2026-05-19 09:52:35 +02:00
Bohan Jiang
d7e58760f3 fix(runtimes): exempt CLI command literals in Connect Remote dialog from i18n rule (#2841)
The two `<code>` blocks in the "having trouble?" disclosure of the
Connect Remote dialog render literal shell commands ("multica daemon
status" and "multica daemon logs -f"). The `i18next/no-literal-string`
rule (enforced as error across packages/views) flagged them, turning
@multica/views#lint red on main since the dialog landed.

These strings are inherently locale-agnostic — they are the actual
commands users type into a shell, identical in every language. Wrapping
them in t() would be wrong (translators would have no source-of-truth
about whether the binary name `multica` or the subcommand `daemon` could
be translated; the answer is "never").

Mark them as exempt with `eslint-disable-next-line i18next/no-literal-string`
+ a one-line comment explaining why. Mirrors how shell-command snippets
are treated elsewhere in the repo.

Verification:
- `pnpm --filter @multica/views lint` → 0 errors (was 2). 13 remaining
  warnings are pre-existing in other files and don't fail CI.
- Cascaded failures (@multica/views#typecheck, web/desktop builds) on CI
  were strictly downstream of the lint failure; they'll go green once
  lint passes.
2026-05-19 15:01:40 +08:00
Bohan Jiang
6e0f7b0f36 feat(settings): allow editing workspace issue prefix (MUL-2369) (#2809)
* feat(settings): allow editing workspace issue prefix (MUL-2369)

Workspace admins can now change the issue prefix from Settings → General.
The change is gated by a confirmation dialog that warns about external
references (PR titles, branch names, links) breaking, because issue
identifiers are rendered as `prefix-N` on the fly — changing the prefix
effectively renames every existing issue.

Refs https://github.com/multica-ai/multica/issues/2797

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

* fix(settings): invalidate issue cache when workspace prefix changes (MUL-2369)

Issue identifiers (`MUL-123`) are recomputed from `workspace.issue_prefix`
at read time, so cached issues kept showing the old `OLD-N` keys after a
prefix change. Without invalidation the confirm dialog's "all issues will
be renumbered" promise was broken until a hard refresh — and other tabs
receiving the `workspace:updated` WS event saw the same drift.

- WorkspaceTab: after a prefix-changing save, invalidate `issueKeys.all`
  in addition to the workspace list. Non-prefix saves stay cheap.
- Realtime: split `workspace:updated` out of the generic `workspace`
  refresh into a specific handler that compares cached vs incoming
  `issue_prefix` and invalidates issues only when it actually changed.
- Docs: align the "uppercase" language with the actual UI/backend rule
  (uppercase letters and digits, up to 10 chars).

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

---------

Co-authored-by: multica-agent <github@multica.ai>
2026-05-19 14:47:34 +08:00
Bohan Jiang
b5102eb3d2 feat(cli): add workspace switch + current commands (MUL-2386) (#2838)
`multica workspace switch <id|slug>` is the product-semantic entry point for
changing the default workspace on the current profile. It looks the target up
in the user's accessible workspace list (an access check by construction —
the server only returns workspaces the user is a member of), persists the
chosen UUID via the existing CLI config layer, and prints the resolved name.
`config set workspace_id` stays as the low-level escape hatch.

`multica workspace switch` resolves the workspace before saving, so an
unknown id or slug fails fast and leaves the previous default intact.

`multica workspace current` and a `*` marker in `multica workspace list`
expose which workspace commands without --workspace-id/MULTICA_WORKSPACE_ID
will target. `multica login` reuses the same marker when listing discovered
workspaces and points multi-workspace users at switch.

Docs gain a "Working with multiple workspaces" section spelling out the
resolution priority (--workspace-id flag > env > profile default) and
calling out config set workspace_id as low-level.

Addresses GitHub#2750.

Co-authored-by: multica-agent <github@multica.ai>
2026-05-19 14:43:20 +08:00
Bohan Jiang
e19f7967b9 feat(prompt): thread-first comment reads for agent runs (MUL-2387) (#2816)
* feat(prompt): thread-first comment reads for agent runs (MUL-2387)

PR #2787 added --thread / --recent / --before / --before-id to the
ListComments API and CLI but kept the agent prompt steering at the
legacy "dump everything" recipe. On a long-running issue the flat dump
burns context on chatter unrelated to the trigger; agents acting on the
trigger want the trigger's thread first.

Prompt updates:

- Comment-triggered Workflow (runtime_config.go) now anchors step 2 on
  `multica issue comment list <issue-id> --thread <trigger-comment-id>
  --output json`. Fallback offers `--recent 20 --output json` with the
  stderr `Next thread cursor: --before <ts> --before-id <root-id>` line
  feeding the next-page cursor. `--since` is preserved and explicitly
  marked combinable with --thread / --recent.
- Per-turn buildCommentPrompt (prompt.go) carries the same thread-first
  guidance so a Codex-style runtime that re-reads the per-turn message
  every iteration gets the same steering, even if it ignores the
  injected runtime config.
- Assignment-triggered Workflow keeps the mandatory full-history rule
  (MUL-1124) but now also points at `--recent 20` as the long-issue
  alternative — this is the place that previously had no thread-aware
  guidance at all.
- Default fallback prompt (no trigger comment, no chat, no autopilot,
  no quick-create) gains the same --recent hint without --thread (no
  comment to anchor on).
- Available Commands core line surfaces the new flags so the discovery
  path matches the workflow guidance.

Default CLI/API semantics are unchanged: the unparameterized list still
returns the full chronological dump capped at 2000, --since still works
on its own, and the desktop UI is untouched.

Tests:

- prompt_test.go: TestBuildPromptCommentTriggerPromotesThreadReads pins
  --thread <triggerID>, --recent 20, the stderr cursor phrasing, and
  the absence of the legacy "returns all comments" prose.
- prompt_test.go: TestBuildPromptDefaultMentionsRecent guards the
  no-trigger fallback (mentions --recent, must NOT mention --thread).
- execenv_test.go: TestInjectRuntimeConfigCommentTriggerThreadFirstReads
  asserts the comment-triggered Workflow steers at --thread/--recent,
  the Available Commands line surfaces the new flags, and the legacy
  "read the conversation (returns all comments...)" string is gone.
- execenv_test.go: TestInjectRuntimeConfigAssignmentTriggerMentionsRecent
  keeps the mandatory full-history rule pinned AND asserts --recent is
  offered as the long-issue alternative.

Also fixes the recent+since cursor nit Elon flagged in #2787's second
review: when `since` empties the page, the `len(seenRoot) >= recentN`
check used to emit a cursor anyway. Pagination walks threads in
strictly decreasing last_activity_at — if every comment in this page is
<= since, every older thread's last_activity is also <= since by
transitivity, so the cursor would only invite the caller into a
guaranteed-empty walk. Now suppressed; new tests pin both branches
(suppressed when empty, retained when at least one row passes since).

MUL-2387

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

* fix(comments): suppress recent+since cursor when head thread past since (MUL-2387)

Previous suppression only tripped when the `since` filter emptied the
page. That missed the mixed case Elon flagged in #2787's second review:
the page keeps rows from fresher threads but the head (oldest-active)
thread already sits at or before `since`, so every older page is
guaranteed empty too. Predicating on `headLast <= since` covers both
cases.

Add a recent=2 + since fixture that pins the mixed scenario: root1
(last_activity = base+3m) is filtered out, root2 stays, and the cursor
is suppressed even though the body is non-empty.

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

* fix(prompt): clarify --recent is paging, not a replacement (MUL-2387)

Address Elon's second-pass nit on #2816: the assignment-trigger workflow
in runtime_config.go used "you may switch to --recent 20", which reads as
a replacement for the mandatory full-history rule. Rephrase --recent as a
paging strategy ("read the full history page-by-page, not a shortcut that
replaces it") so it cannot conflict with the rule it lives next to.

The default per-turn prompt in prompt.go opened with "If you need comment
history" — that soft conditional contradicts the runtime workflow's
mandatory read. Move it to a neutral "For comment history, follow the
rule in your runtime workflow file" framing that defers to whatever the
workflow says (mandatory for assignment, optional elsewhere) instead of
encoding its own policy.

Keep the runtime/prompt dual-layer fallback intact — different runtimes
propagate the config file vs. the per-turn user prompt with varying
fidelity, so both surfaces need the guidance.

Tests pin the new phrasing against regression:

- TestBuildPromptDefaultMentionsRecent now also forbids "If you need
  comment history" from sneaking back in.
- TestInjectRuntimeConfigAssignmentTriggerMentionsRecent now also forbids
  "you may switch to" / "switch to `--recent" replacement phrasing.

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

---------

Co-authored-by: multica-agent <github@multica.ai>
2026-05-19 14:42:35 +08:00
Jiayuan Zhang
ccd9e6cdfb feat(runtimes): simplify "Add a computer" dialog (MUL-2408) (#2839)
- Align Runtimes connect flow with Onboarding CLI install: install.sh + multica setup
- Drop manual "I've started the daemon" step; subscribe to daemon:register WS and auto-advance
- Rename Connect remote machine -> Add a computer, remove EC2-specific copy
- Rework UI per web design guidelines (focus rings, aria labels, live status, footer alignment)
- Fix DialogFooter negative-margin overflow with p-0 content; use outline Cancel button

Co-authored-by: multica-agent <github@multica.ai>
2026-05-19 08:33:32 +02:00
Jiayuan Zhang
8d30d76300 feat(dashboard): add 1d range to workspace Usage tab (#2837)
* feat(dashboard): add 1d time range to workspace Usage tab

1d means "today" — the natural calendar day from 00:00 UTC, matching the
rollup's bucket_date axis — not the trailing 24 hours. The client-side
dailyCutoffIso filter is now applied in daily dim too so 1d collapses
strictly to today even at the midnight UTC edge where the server's
wall-clock since cutoff would otherwise include yesterday.

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

* fix(dashboard): scope `1d` to today only on aggregate endpoints

The pre-aggregated `byAgent` / `runTime` dashboard endpoints leaked
yesterday into the agent leaderboard and KPI cards for the `1d` time
range because `parseSinceParam(days=1)` returned `now-24h` (wall clock)
and the downstream SQL then applied `DATE_TRUNC('day', @since)`, which
landed on yesterday 00:00 UTC. The PR's client-side `dailyCutoffIso`
filter could only fix the date-bearing daily endpoints; aggregate
responses are already collapsed across dates.

Anchor `parseSinceParam` at UTC start-of-today instead, so `days=N`
covers N natural calendar days (today + N-1 prior). This matches the
frontend `dailyCutoffIso = today - (days-1)` semantic that the
workspace dashboard already assumes, and removes the off-by-one that
previously made `30d` return 31 buckets.

The runtime-detail page uses `parseSinceParamInTZ` (timezone-aware),
which is unchanged — it has no `1d` option.

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

---------

Co-authored-by: multica-agent <github@multica.ai>
2026-05-19 08:28:04 +02:00
Jiayuan Zhang
0339de54e7 add web design guidelines skill (#2832) 2026-05-19 12:09:41 +08:00
Jiayuan Zhang
c577a29c10 feat(onboarding): v2 per-question questionnaire (source/role/use_case) (#2814)
* feat(onboarding): per-question v2 questionnaire (source/role/use_case)

Replaces the 3-questions-on-one-screen gate with three lightweight,
individually-skippable steps. New step order:

  welcome → source → role → use_case → workspace → runtime → agent → first_issue

- New v2 questionnaire schema: source/role/use_case + per-slot
  `*_skipped` markers. `team_size` removed.
- Click-to-advance card grid with lucide + emoji icons (RFC Option B).
- Skip is a footer text button; Other expands a free-text input.
- Recommendation table updated for new role × use_case vocabulary,
  with use_case-only fallback when role is skipped.
- DB migration v1 → v2 maps existing role/use_case answers and drops
  team_size; historical nulls stay null (not retroactively skipped).
- Re-entry treats skipped slots as fresh; analytics record kept in DB.
- onboarding_questionnaire_submitted event payload updated:
  source replaces team_size, per-slot skip booleans added.

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

* fix(onboarding): tighten question UX (Continue, layout, brand icons)

Address review feedback on Source/Role/Use-case:

- Replace auto-advance with an explicit Continue button so selections
  are reviewable. Continue is disabled until something is picked (and,
  for Other, until the free-text input is non-empty).
- Move Back/Skip/Continue inline under the option grid; drop the
  duplicate Back from the top header — the page now has a single,
  anchored action row.
- Swap the placeholder lucide marks for real brand SVGs on Source:
  Google, X, LinkedIn, YouTube, and an OpenAI mark for the AI-assistant
  option. Generic options stay on lucide.
- Replace the awkward expanded underline input on the Other card with
  an inline borderless input that swaps in for the label slot, so the
  Other state has the same height and weight as the other cards.

E2E smoke test updated to click Continue between question steps.

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

* fix(onboarding): unify step nav, rename Runtime step around "where agents run"

- Refactor the Source/Role/Use case questionnaire steps to use the same
  3-region chrome (header with Back + step indicator, scrolling main,
  sticky footer with Skip + Continue) that Workspace/Runtime/Agent
  already use, so the Back/Skip/Continue affordances stay in the same
  on-screen position across the whole flow.
- Reframe the Runtime step around the user-visible question — "Where
  will your agents run?" — instead of the internal "runtime" concept.
  The aside panel keeps the educational "What's a runtime?" copy for
  users who want to learn.
- Drop the hard-coded "Step 3 · Runtime" eyebrow on the web fork step:
  Runtime is now step 5 of 7 after the per-question split, and the
  step indicator already shows the correct count.

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

* fix(onboarding): tighten Skip/Continue spacing in step footer

Group Skip and Continue inside a sub-flex with gap-2 so they read as a
single action cluster on the right, while the status hint still anchors
left via mr-auto. Applied to both the questionnaire steps and the
runtime step so the footer layout stays consistent across onboarding.

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

* fix(onboarding): move Skip/Continue inline below form, drop sticky footer

The sticky bottom footer left a large dead zone between the form
content and the action buttons — most onboarding steps only fill the
top third of the viewport. Move the hint + Skip + Continue inline,
directly below the form/options grid, so the buttons sit where the eye
already is after picking an option.

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

* fix(onboarding): match Skip button size to Continue (size="lg")

Skip used the default button size (h-8) while Continue used size="lg"
(h-9), so the two adjacent action buttons rendered visibly different
heights. Promote Skip to size="lg" in step-question and
step-runtime-connect so they line up.

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

* fix(onboarding): reframe step 3 as 'connect a computer' / 'pick an agent runtime'

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

* fix(onboarding): replace cloud waitlist with "Coming soon", reword CLI intro

- Web Step 3 cloud card: remove "Join waitlist" CTA + dialog and render a
  static "Coming soon" badge instead. Drops CloudWaitlistDialog, the
  cloud DialogState, waitlistSubmitted local state, and the
  onWaitlistSubmitted prop on StepPlatformFork (desktop's
  StepRuntimeConnect still owns its own waitlist path).
- Tighten cloud_subtitle to drop the "join the waitlist" half now that
  the action is gone.
- cli_install.intro: "AI coding tool" → "agent runtime", EN + zh-Hans.

Tests updated to match: asserts the Coming soon badge is non-actionable
and drops the four cloud-dialog scenarios (now unreachable).

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

* fix(onboarding): refresh button, "agent runtime" wording, coming-soon card

Three fixes on the desktop Step 3 empty state per review:

1. Empty headline + hints now say "agent runtime", matching the
   picker-context terminology established earlier in this PR.
2. Add a Refresh button (header pill in Found, inline with the
   headline in Empty). Desktop wires it to restart the bundled
   daemon so a freshly-installed Claude/Codex/Cursor CLI is picked
   up — the daemon's PATH probe runs once at boot, so without a
   restart the install would only take effect on next launch.
3. "Use a cloud computer" loses the waitlist dialog and renders as
   a disabled "Coming soon" badge, aligning with the web fork.

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

* fix(onboarding): address review follow-ups (i18n, step-order, version, tests)

- runtime-aside-panel: point "Learn more" to /docs/install-agent-runtime,
  branching by language so zh users land on /docs/zh/...
- zh-Hans: unify Cloud "Coming soon" wording to "即将推出"; translate
  step_workspace.preview.more_meta ("and more" -> "等等")
- onboarding-flow: derive forward navigation from ONBOARDING_STEP_ORDER
  via advanceFrom(curr) so inserting/reordering a step only requires
  editing the canonical array; runtime → agent/first_issue branch keeps
  its bespoke routing with a comment explaining why
- onboarding handler: gate questionnaireAnswers.complete() on
  Version == 2 so a future schema bump can't be silently mis-counted
  against v2 funnel semantics
- add unit tests for step-source / step-role / step-use-case (option
  click, Skip patch, Other free-text) and step-question shell
  (canContinue + pendingOther state machine)

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

* fix(onboarding): rename useCaseFallback to fallbackFromUseCase

ESLint's react-hooks/rules-of-hooks treats any function starting with
"use" as a React hook. The helper is a pure switch — give it a name
that doesn't trip the rule.

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

---------

Co-authored-by: multica-agent <github@multica.ai>
2026-05-19 05:35:18 +02:00
Naiyuan Qing
434003d129 fix(my-issues): rename tab 3 label to include squads (MUL-2397) (#2830)
Tab 3's semantics were widened in #2829 to surface issues assigned to
either an owned agent OR a squad the user belongs to / leads. The label
still said "我的智能体" / "My Agents", which under-described the new
scope. Rename to "我的智能体和小队" / "My Agents and Squads" so the tab
title matches what it filters.

Locale-only change. Filter logic, SQL, and other tabs untouched.

Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>
2026-05-19 11:07:34 +08:00
Naiyuan Qing
93153d08b7 feat(my-issues): cover squad assignees via involves_user_id (MUL-2397) (#2829)
Re-introduces the `involves_user_id` filter on the issues list / open-list /
count / grouped paths, but with the semantics nailed down for the second time
around: tab 3 surfaces issues whose assignee is an *indirect* extension of the
user (owned agent, or a squad they're a human member of / lead via owned agent
/ have an owned agent inside) — and explicitly NOT direct member assignment,
which is tab 1's meaning.

- server/pkg/db/queries/issue.sql: 4-branch filter on ListIssues /
  ListOpenIssues / CountIssues. Each subquery clamps workspace_id because
  issue.assignee_id is polymorphic with no FK. Leader resolution reads
  squad.leader_id directly, not the squad_member copy row (squad.go ignores
  errors when seeding that copy, so it can be missing). FindActiveDuplicateIssue
  switched from positional $2/$3/$4 to named sqlc.arg() — pure hygiene so the
  generated struct field names don't drift when new nargs are added.
- server/internal/handler/issue.go: parse involves_user_id and plumb it into
  the three sqlc params; ListGroupedIssues (hand-written dynamic SQL) gets a
  mirrored 4-branch fragment, no shortcut.
- packages/core: ListIssuesParams / ListGroupedIssuesParams / MyIssuesFilter /
  api.listIssues / api.listGroupedIssues all carry the new param through.
- packages/views/my-issues: tab 3 switches from client-side agent-fanout to
  involves_user_id=user.id. agentListOptions import and the myAgentIds memo
  go away.
- server/internal/handler/issue_involves_test.go: 13 integration tests cover
  every branch (positive + cross-workspace negatives) plus the critical
  ExcludesDirectMemberAssignee negative on BOTH the sqlc and the grouped paths,
  locking tab 3 ∩ tab 1 = ∅.

Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>
2026-05-19 10:37:38 +08:00
Jiayuan Zhang
35fc318d68 feat(runtimes): weekly usage dimension + tz-aware aggregation (MUL-2382) (#2822)
* feat(runtimes): weekly usage dimension + tz-aware aggregation (MUL-2382)

Adds a Weekly view to the runtime Usage chart alongside Daily and Hourly,
backed by `aggregateByWeek` on the existing 180-day daily cache (no new
endpoint). Weeks are ISO 8601 Mon–Sun; the in-progress week is rendered at
half opacity and tooltip-labelled "partial · N / 7 days".

Side effects called out in the RFC:

- `sliceWindow` now reads "today" in the runtime's IANA timezone, fixing a
  one-day drift at the window edge when the browser and runtime sit in
  different time zones.
- ActivityHeatmap rows are reordered Mon → Sun to match the rest of the
  Weekly aggregation; "today" is computed in runtime tz so the grid's
  trailing column lines up with the daily rows the backend buckets.

Dimension / period coupling: switching dimension resets the period to that
dimension's default when the active value isn't in its allowed set
(Hourly 7/30, Daily 7/30/90, Weekly 30/90/180).

Unit tests cover weekStart / addDays / tz-aware today, the sliceWindow
boundary, and aggregateByWeek's partial-week math.

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

* fix(runtimes): weekly chart shows trailing calendar weeks (MUL-2382)

aggregateByWeek built one bucket per week-with-data, and the caller
took the last N buckets. With sparse data — old populated weeks plus
empty stretches near today — the slice surfaced the old weeks instead
of the trailing in-window calendar weeks the user selected.

Now aggregateByWeek takes weekCount and emits exactly that many
trailing calendar weeks anchored at today's week in the runtime tz.
Buckets are pre-zeroed so empty in-range weeks render as empty bars;
rows outside the window are dropped.

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

* feat(usage): drop Hourly dim + add Daily/Weekly to workspace dashboard (MUL-2382)

- Remove Hourly from the runtime usage WHEN-chart: segmented control is
  now Daily / Weekly. Drop the HourlyActivityChart component,
  aggregateCostByHour helper, byHour query subscription, and the
  when_tab_hourly i18n key.
- Add the same Daily / Weekly dimension toggle to the workspace-level
  Usage page (dashboard-page.tsx). Time-range linkage matches the runtime
  page: Daily allows 7/30/90 (default 30), Weekly allows 30/90/180
  (default 90); switching dimensions resets `days` when the current value
  isn't in the new dimension's set.
- Reuse `aggregateByWeek` from runtimes/utils for cost / tokens
  (signature relaxed to accept the wider DashboardUsageDaily shape).
  Add `aggregateWeeklyTime` / `aggregateWeeklyTasks` in dashboard/utils
  with identical pre-zeroed trailing-week semantics. Workspace dashboard
  uses the user-chosen timezone (existing TimezoneSelect) as the
  week-boundary tz; runtime page continues to use the runtime's IANA tz.
- New `WeeklyTimeChart` / `WeeklyTasksChart` mirror their daily
  counterparts plus partial-week half-opacity bars and rangeLabel
  tooltips, matching the existing Weekly cost / tokens charts.
- Tests: drop hourly-related setup; add weekly run-time / tasks coverage
  asserting pre-zeroed trailing buckets and the same MUL-2382 sparse
  window-scoping regression we caught on the runtime side.

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

* fix(usage): correct workspace Weekly window + lock tz to UTC (MUL-2382)

Two blocking correctness bugs from Emacs's PR #2822 review:

1. The Weekly chart paints `ceil(days/7)` trailing calendar weeks but the
   API was still asked for exactly `days`. Worst case (today = Sunday on a
   30D request) the leftmost Monday sits 34 days back, so the first week's
   bucket was silently truncated. Over-fetch the per-date queries to
   `weekCount * 7` days when Weekly is active; per-agent rollups stay at
   `days` so the KPI / leaderboard labels keep their advertised window.
   Daily-aggregation surfaces (cost/tokens/time/tasks KPIs and the Daily
   chart) re-scope the over-fetched rows back to `days` so the labels
   stay consistent.

2. The backend dashboard rollup buckets data by UTC `bucket_date` (and the
   raw fallback queries by `DATE(tu.created_at)`, also UTC), but the
   frontend was driving Weekly boundaries from the user-chosen
   `TimezoneSelect`. Near midnight UTC that put cross-boundary rows into
   the wrong calendar week. Lock workspace Weekly to UTC and remove the
   timezone picker from this page; the runtime detail page keeps its own
   `runtime.timezone`-anchored aggregation, which is consistent because
   its rollup is materialized in that runtime's tz.

Verification: pnpm --filter @multica/views test (636 passed),
typecheck clean, lint 0 errors / 13 pre-existing warnings.

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

---------

Co-authored-by: multica-agent <github@multica.ai>
2026-05-19 04:24:46 +02:00
Naiyuan Qing
5476e7678d Revert "feat(my-issues): cover squad assignees via involves_user_id (MUL-2364…" (#2828)
This reverts commit 3c510c31ed.
2026-05-19 09:31:43 +08:00
Anderson Shindy Oki
e65c0889b9 feat: Add squad page responsive layout (#2826) 2026-05-19 09:18:30 +08:00
Naiyuan Qing
8db354f721 feat(editor): add open-in-new-tab to HTML attachment full-screen modal (#2827)
The inline HtmlAttachmentPreview toolbar carries an "Open in new tab"
button that routes to /{slug}/attachments/{id}/preview. The full-screen
AttachmentPreviewModal was missing the same affordance, so users who
maximized an HTML preview lost the ability to pop it into its own tab.

Mirror the gating exactly: show when kind === 'html' && slug &&
attachmentId. Other PreviewKinds keep the existing header (Download +
Close) — they don't have a corresponding full-page route.

Co-authored-by: multica-agent <github@multica.ai>
2026-05-19 09:06:35 +08:00
Naiyuan Qing
3c510c31ed feat(my-issues): cover squad assignees via involves_user_id (MUL-2364) (#2801)
* feat(my-issues): cover squad assignees via involves_user_id (MUL-2364)

The "My Agents" tab on /my-issues only resolved agents owned by the
caller, so issues assigned to squads (member, leader, or agent-member of
mine) never surfaced. This added a UNION-based involves_user_id filter
that the backend expands to "me + agents I own + squads I relate to" in
a single query.

- SQL: ListIssues / ListOpenIssues / CountIssues accept narg
  involves_user_id and OR a workspace-scoped 3-branch UNION on the
  squad assignee subquery. Leader is sourced from canonical
  squad.leader_id (not the best-effort squad_member copy row whose
  AddSquadMember error is dropped in squad.go:177-188 and :259-263).
- Handler: parses involves_user_id via parseUUIDOrBadRequest, plumbs
  into all three list params, and mirrors the same UNION fragment into
  the grouped dynamic SQL path.
- Frontend: ListIssuesParams / ListGroupedIssuesParams / MyIssuesFilter
  gain involves_user_id; api client forwards it to the querystring.
- My Issues page: "agents" scope now passes involves_user_id instead of
  fanning out owned-agent IDs client-side. Tab label widens to
  "我的智能体 / 小队" / "My Agents / Squads".
- Tests: Go suite covers all three squad relations including the
  canonical-leader-without-squad_member-copy variant, cross-workspace
  isolation for agent / leader / squad_member branches, combination
  with creator_id, and the malformed-UUID 400 path. Client test pins
  the involves_user_id querystring wiring for both list endpoints.

The FindActiveDuplicateIssue query gets explicit sqlc.arg() names so
sqlc regeneration keeps the existing struct field names regardless of
the local sqlc version (no behavior change).

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

* test(my-issues): tighten cross-workspace negatives for involves_user_id UNION

Cross-workspace negative tests previously put both the foreign actor and the
foreign issue in the foreign workspace, so the outer i.workspace_id = $1
already excluded the row before the UNION branches were exercised. Stripping
a.workspace_id = $1 / s.workspace_id = $1 from any of the UNION subqueries
would not have failed the tests.

Rewrite the three existing negative cases to seed the issue in
testWorkspaceID with a polymorphic assignee_id pointing at a foreign-workspace
agent or squad (issue.assignee_id has no FK per migrations/001_init.up.sql:61).
Now each UNION branch must enforce its own workspace scoping for the issue to
stay out of the result.

Also add ExcludesOtherWorkspaceSquadAgentMember: the squad_member.agent UNION
branch had only positive coverage; this test pins that s.workspace_id = $1
and a.workspace_id = $1 must both hold there too.

Verified by mutation: stripping the workspace clause from each branch makes
the corresponding test fail.

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-19 09:01:51 +08:00
Jiayuan Zhang
54f884ebc8 docs(runtimes): add install-agent-runtime page and link from onboarding empty state (#2825)
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 02:00:31 +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
Jiayuan Zhang
ee48e58b8f feat(desktop): silent background auto-download for updates (MUL-2224) (#2631)
* feat(desktop): silent background auto-download for updates (MUL-2224)

Flip electron-updater to autoDownload=true so new releases are pulled in
the background without user action; the UI now only surfaces a
"ready to install" prompt once the package is fully downloaded.

- updater.ts: autoDownload=true; update-downloaded forwards version +
  releaseNotes; single-flight guard around checkForUpdates() so startup,
  periodic, and manual triggers don't pile up overlapping downloads.
- preload: update-downloaded payload now carries { version, releaseNotes? }.
- update-notification.tsx: drop available/downloading UI; ready state has
  Later / Restart now and renders the version from the download event.
- updates-settings-tab.tsx: settings copy now describes background download
  + restart prompt instead of a download prompt.

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

* fix(desktop): swallow unhandled downloadPromise rejection in updater (MUL-2224)

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

---------

Co-authored-by: multica-agent <github@multica.ai>
2026-05-14 17:06:07 +02:00
Bohan Jiang
464201ba0d feat(execenv): native OpenClaw skill discovery via per-task config (MUL-2219) (#2628)
* feat(execenv): native OpenClaw skill discovery via per-task config

MUL-2213 stopped lying about native discovery and routed openclaw skills
to .agent_context/skills/ — a path openclaw's scanner never reads.
Multica skills attached to openclaw-backed agents were still invisible to
the runtime; the AGENTS.md fallback was only a documentation patch.

OpenClaw's skill scanner walks <workspaceDir>/skills/ (plus a few other
roots), and workspaceDir is resolved from the openclaw config file —
specifically agents.list[id].workspace → agents.defaults.workspace →
~/.openclaw/workspace. There is no CLI flag or env var override on the
agent runtime; the only knob is the config file.

This change wires a per-task synthesized config:

  1. execenv.prepareOpenclawConfig deep-copies the user's existing
     openclaw.json (priority: $OPENCLAW_CONFIG_PATH, else
     ~/.openclaw/openclaw.json), rewrites agents.defaults.workspace AND
     every agents.list[].workspace to the task workdir, and writes the
     result to {envRoot}/openclaw-config.json. Provider sections,
     registered agents, model providers, gateway settings — everything
     openclaw needs to actually start — are preserved as-is.
  2. resolveSkillsDir for "openclaw" now points at {workDir}/skills/,
     which is the first path openclaw scans under workspaceDir. Skills
     written here are picked up natively.
  3. daemon.go exports OPENCLAW_CONFIG_PATH={env.OpenclawConfigPath} on
     the openclaw subprocess and adds OPENCLAW_CONFIG_PATH to the
     custom_env blocklist so users cannot accidentally override it.
  4. buildMetaSkillContent now lists openclaw alongside the
     "discovered automatically" providers; the .agent_context/skills/
     fallback line stays for gemini/hermes.

The new regression test TestPrepareOpenclawSkillWriteMatchesScanPath is
the one MUL-2219's DoD calls out: it resolves the workspaceDir the way
openclaw does (reading agents.defaults.workspace out of the synthesized
config) and proves {workspaceDir}/skills/<name>/SKILL.md is what Multica
actually wrote. The pre-MUL-2219 fix asserted "we wrote a file" without
checking the scanner would ever see it — which is how the dead drop into
.openclaw/skills/ landed in #2621's first commit.

Verified locally: minimum-viable synthesized config validates via
`openclaw config validate`, and `OPENCLAW_CONFIG_PATH=<path> openclaw
config get agents.defaults.workspace` returns the task workdir as
expected. MUL-2219

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

* fix(execenv): delegate openclaw config parsing to CLI and fail closed

Address Elon's must-fix on PR #2628: the previous implementation parsed
~/.openclaw/openclaw.json with encoding/json, which cannot read JSON5
or follow $include — the OpenClaw spec's actual format. When parsing
failed, prepareOpenclawConfig silently emitted a minimal config, which
could boot OpenClaw without the user's registered agents, model
providers, or API keys.

Two changes:

1. Delegate active-config-path resolution and config reading to the
   openclaw CLI itself. `openclaw config file` locates the active
   config (covering OPENCLAW_CONFIG_PATH / OPENCLAW_STATE_DIR /
   OPENCLAW_HOME / default and the legacy chain), and the wrapper we
   write uses $include to point at it so OpenClaw's own loader handles
   JSON5, $include nesting, env-substitution, and secret refs. We read
   only agents.list via `openclaw config get --json` to rewrite each
   entry's workspace — secrets, comments, and includes in the user
   config are never touched.

2. Remove the silent minimal-config fallback. Any CLI failure,
   malformed output, or write error now surfaces as a hard error from
   Prepare / Reuse. The only "synthesize minimal" path left is a fresh
   install (CLI reports a path but the file doesn't exist), where
   there is no user data to lose.

The per-task override still rewrites every agents.list[].workspace,
not just agents.defaults.workspace — this is intentional task
isolation, documented in prepareOpenclawConfig and the PR body. A
host-scope per-agent workspace would otherwise silently route the
scanner back to the user's shared workspace.

Cleanups Elon flagged in the same review:
- daemon.go inline-system-prompt comment no longer claims openclaw
  ignores the task workdir; it does load it now, and the inline brief
  is a belt-and-suspenders carryover for older releases.
- execenv.go openclaw block no longer references "skill file paths in
  the inline brief" — the brief uses "discovered automatically".

Reuse() switches to a ReuseParams struct so the openclaw binary path
threads through alongside CodexVersion without a 6th positional arg.

MUL-2219

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

* fix(execenv): grant OpenClaw $include cross-dir confinement for per-task wrapper

The per-task wrapper at envRoot/openclaw-config.json $includes the user's
active config (typically ~/.openclaw/openclaw.json), but OpenClaw confines
$include resolution to the wrapper file's directory unless the target's
parent is granted via OPENCLAW_INCLUDE_ROOTS. Without this, OpenClaw refuses
to follow the link at runtime and the wrapper boots with no user-registered
agents.

prepareOpenclawConfig now returns dirname(activePath) as IncludeRoot, and
the daemon prepends it to whatever the user already has in
OPENCLAW_INCLUDE_ROOTS via the new composeOpenclawIncludeRoots helper
(dedupes, drops empty segments, preserves user-configured roots). Fresh
install emits no $include and leaves the env var untouched.

Adds OPENCLAW_INCLUDE_ROOTS to the custom_env blocklist so a per-agent
override cannot strip the granted root.

Regression tests:
- TestPrepareOpenclawConfigWrapperLoadableUnderIncludeConfinement asserts
  every $include target's dirname is covered by the IncludeRoot we surface.
- TestPrepareEnvironmentOpenclawWiresIncludeRoot covers the non-fresh-install
  Environment wiring.
- TestComposeOpenclawIncludeRoots covers the daemon-side env composition
  (preserve, dedupe, drop empties).

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

---------

Co-authored-by: multica-agent <github@multica.ai>
2026-05-14 22:35:31 +08:00
Jiayuan Zhang
9517536d49 fix(runtimes): keep base name visible, truncate hostname first (#2629)
The RUNTIME cell rendered base name + (hostname) with both spans using
flex: 0 1 auto, so the longer hostname dominated and squashed the name
to a single letter. Give the base name shrink priority and let the
hostname own the flex slot with basis-0, so hostname truncates first
while the name stays readable.

Co-authored-by: multica-agent <github@multica.ai>
2026-05-14 15:47:28 +02:00
Jiayuan Zhang
4d6b5ad06f fix(squad): wake leader when dual-role agent posts as worker (MUL-2218) (#2626)
* fix(squad): wake leader when dual-role agent posts as worker (MUL-2218)

The squad-leader self-trigger guard skipped a comment whenever the
author equalled the squad's leader id, regardless of the role the agent
was acting in. For an agent that holds both leader and worker roles in
the same squad, this meant the leader role never reacted to its own
worker output and the issue stalled.

Tag each enqueued task with is_leader_task and consult the agent's
most recent task on the issue from both self-trigger guards (comment
path + @squad mention path) — skip only when that task was itself a
leader task.

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

* fix(squad): inherit is_leader_task on retry task clone (MUL-2218)

CreateRetryTask cloned a parent task into a fresh queued attempt but
omitted is_leader_task from the column list, so the child silently fell
back to the column default (false). For a leader task that hit auto-retry
through MaybeRetryFailedTask, the retried task posed as a worker task —
the self-trigger guard then no longer recognised the leader's own
comments, re-opening the very loop MUL-2218 closes.

Inherit p.is_leader_task in the clone and add a query-level test that
covers both leader and worker retries.

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

---------

Co-authored-by: multica-agent <github@multica.ai>
2026-05-14 15:23:36 +02:00
Bohan Jiang
8572a79950 MUL-2215: fix(daemon): close handleRuntimeGone success/straggler race (#2623)
* MUL-2215: fix(daemon): close handleRuntimeGone success/straggler race

handleRuntimeGone coalesced concurrent recoveries with a per-workspace
`reregisterNextAttempt` slot that was deleted immediately on success. A
late-arriving goroutine whose `removeStaleRuntime` was delayed by mutex
contention could reach the coalesce gate after the winner cleared the
slot, observe no slot, re-claim, and double-register — the source of the
intermittent `register endpoint called 2 times under stampede, want 1`
failure on PR #2348.

The slot delete on success is intentional (a genuinely later distinct
deletion in the same workspace must register again, validated by
TestHandleRuntimeGone_DistinctDeletionsWithinCoalesceWindowBothRecover),
so we can't just extend the slot's lifetime.

Add a second per-workspace gate: `reregisterLastCompletedAt`. Every call
captures `entryAt` at the top of handleRuntimeGone; at the coalesce gate
a caller bails if `lastCompletedAt >= entryAt`, i.e. a peer's register
completed AFTER we entered the function. Same-wave stragglers bail
deterministically; distinct later events have `entryAt > lastCompletedAt`
and proceed.

Extracted the gate into `tryClaimRegisterSlot` / `recordRegisterCompletion`
so the race can be exercised deterministically with synthetic timestamps
instead of relying on `-count=N` to win the scheduling lottery.

- TestHandleRuntimeGone_CoalescesConcurrentCallers: -count=500 -race
  clean (previously intermittent).
- New unit tests cover the straggler bail, the distinct-later-event
  claim, failure backoff suppression, and peer-holds-slot coalescing.

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

* MUL-2215: narrow completion stamp to success path

Second review caught that recordRegisterCompletion stamped
lastCompletedAt on both success and failure. A failed register has not
covered any workspace state, so a same-wave straggler whose entryAt
predates the failure must be allowed to retry once the failure backoff
expires — the previous behavior would let the failure-time stamp also
hide that straggler. workspaceSyncLoop only retries when a workspace's
runtimeIDs fully drain, so partial-deletion recovery has to come from
the straggler path.

Failure path now only updates reregisterNextAttempt; success path keeps
its existing stamp + slot clear. Add a regression test covering the
entryAt-before-failed-completion / arrival-past-backoff edge.

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

---------

Co-authored-by: multica-agent <github@multica.ai>
2026-05-14 21:01:55 +08:00
Bohan Jiang
f82a6adde9 fix(execenv): fall back OpenClaw skills to .agent_context/skills/ and stop claiming native auto-discovery (#2621)
* fix(execenv): write OpenClaw skills to .openclaw/skills/ for native discovery

The OpenClaw provider was missing a case in resolveSkillsDir, so workspace
skills attached to OpenClaw-backed agents fell through to .agent_context/
skills/ — a path the openclaw CLI never inspects. The result: agents
created against the OpenClaw runtime saw zero of their loaded Skills in
chat or task runs, even though the meta AGENTS.md content advertised
them as auto-discovered.

Mirrors the same per-provider mapping already in place for OpenCode,
Copilot, Pi, Cursor, Kimi, Kiro. Also adds .openclaw to the repocache
git-exclude list so the per-task skills directory does not pollute
checked-out repos. MUL-2213

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

* fix(execenv): drop .openclaw/skills dead-drop write; flag openclaw as non-auto-discovery

Reviewer (Elon) pointed out that {workDir}/.openclaw/skills/ is not in any
OpenClaw skill discovery path. Confirmed by reading openclaw upstream
(src/agents/skills/refresh.ts, src/agents/agent-scope-config.ts,
src/cli/program/register.agent.ts):

- OpenClaw scans <workspaceDir>/skills, <workspaceDir>/.agents/skills,
  ~/.openclaw/skills, ~/.agents/skills, bundled, and config
  skills.load.extraDirs.
- workspaceDir is resolved from the openclaw config (per-agent
  workspace -> agents.defaults.workspace -> ~/.openclaw/workspace).
  It is NOT the cwd of the openclaw process.
- There is no --workspace CLI flag on 'openclaw agent', and no
  OPENCLAW_WORKSPACE env var consumed at runtime. The only knob is the
  config file.

So {workDir}/.openclaw/skills/ written by Multica is never seen by the
openclaw runtime, and the meta AGENTS.md was lying to the agent by
claiming auto-discovery. Reverts:

- resolveSkillsDir: drop the openclaw case; falls back to
  .agent_context/skills/ (same path as hermes).
- agentGitExcludePatterns: drop .openclaw; nothing is written there now.

Also updates the openclaw branch in buildMetaSkillContent to point the
agent at .agent_context/skills/ explicitly (alongside gemini/hermes), so
loaded skills are at least referenced by path in the AGENTS.md context.
The openclaw native loader still won't see them as installed skills.

Native auto-discovery for openclaw needs per-task workspace integration
(e.g. synthesized per-task config via OPENCLAW_CONFIG_PATH that overrides
agents.defaults.workspace, or resolving the agent's actual configured
workspace at exec time) — tracked as follow-up. MUL-2213

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

---------

Co-authored-by: multica-agent <github@multica.ai>
2026-05-14 20:24:45 +08:00
Jiayuan Zhang
675ed02aa6 MUL-2216: persist Mine/All tab selection on Agents and Squads pages (#2624)
* MUL-2216: feat(agents,squads): persist Mine/All tab selection per workspace

Tab selection on the Agents and Squads list pages was held in
component-local state, so navigating into a detail page and back
remounted the list and reset the tab to the default "Mine". Move
`scope` into Zustand stores backed by `persist` +
`createWorkspaceAwareStorage`, matching the pattern used by the
Issues view store. Selection now survives list → detail → back
navigation and page reloads, scoped per workspace.

Only `scope` is persisted; `search`, `sort`, and other ephemeral
filters intentionally still reset on remount.

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

* fix(views): reset scope to mine when switching to a workspace with no persisted value

zustand persist.rehydrate() is a no-op when storage returns null, so
workspaces with no entry kept the previous workspace's in-memory scope
("all" leaked from one workspace into the next). Provide a custom merge
that resets to the default "mine" when no persisted state is present.

Add coverage for the missing-storage workspace-switch case for both
Agents and Squads.

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

---------

Co-authored-by: multica-agent <github@multica.ai>
2026-05-14 14:11:22 +02:00
Jiayuan Zhang
9da52add15 feat(settings): view/edit toggle for repositories tab (MUL-2217) (#2625)
* feat(settings): view/edit toggle for repositories tab

Saved repos render as static rows (truncated, monospace) with hover/focus-revealed
Edit + Delete affordances. Clicking Edit flips to the existing Input; on
successful Save the row returns to display mode. Save button is gated on a
dirty check (URL arrays in order) so a clean state reads as "All changes
saved". Resolves user feedback that the always-visible input made saved
state ambiguous (MUL-2217).

- Track editingIndices with a Set; new rows auto-enter edit mode; deleting
  a row remaps indices so the wrong row never opens.
- Touch devices and focus-within keep the action buttons reachable.
- New i18n keys in en + zh-Hans (saved_hint, empty, edit/delete_aria, url_empty).

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

* fix(settings): add Cancel affordance to exit clean edit mode

Clicking Edit on a clean saved row opened the row in edit mode with
no way back to display mode unless the user changed the URL and saved,
re-introducing the original saved-state ambiguity after an accidental
click. Add a per-row Cancel (X) button visible only in edit mode that:

- reverts the URL to the saved value for existing rows
- removes the row entirely for never-saved (newly added) rows
- exits edit mode without dirtying Save

Action group is always visible (no hover gate) while editing so the
exit is discoverable. Adds en/zh-Hans cancel_aria string and three
regression tests covering clean-cancel, dirty-cancel, and new-row-cancel.

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

---------

Co-authored-by: multica-agent <github@multica.ai>
2026-05-14 13:59:26 +02:00
Jiayuan Zhang
7bd25fd390 docs(readme): add Squads feature and remove Paperclip comparison (#2622)
- Add Squads to Features list (EN/zh) highlighting team-level agent routing
- Add a short Squads callout to the 'What is Multica?' section
- Remove the outdated 'Multica vs Paperclip' section from both READMEs

Co-authored-by: multica-agent <github@multica.ai>
2026-05-14 13:28:04 +02:00
Bohan Jiang
08e355be0b MUL-2167: fix(daemon): resolve agent CLIs via login shell when daemon PATH misses them (#2620)
* fix(daemon): resolve agent CLIs via login shell when daemon PATH misses them

GUI-launched daemons on macOS/Linux do not inherit the user's interactive
shell PATH, so fnm/nvm/volta multishells and the Anthropic native installer
silently disappear during onboarding even though `claude --version` works
in Terminal. Fall back to `$SHELL -ilc` to ask the login shell for the
canonical absolute path, then verify it with exec.LookPath before trusting
it. Symlinks (fnm/nvm prefix dirs) are resolved while the helper shell is
still alive so per-session paths get canonicalised before they vanish.

Refs MUL-2167, multica-ai/multica#2512.

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

* fix(daemon): strip alias shadowing, harden timeout, lazy-resolve via login shell

Three follow-ups from the PR #2620 review (Elon):

1. Alias shadowing — `command -v claude` in zsh/bash returns the alias
   definition, not the binary, and the absolute-path filter then rejects it.
   The script now `unalias`/`unset -f` the name before lookup so `command -v`
   falls through to the real PATH binary. This is the exact case behind
   #2512.

2. Hard timeout — `CommandContext` kills only the shell process. Rc files
   that background processes inheriting stdout (`direnv hook`, `nvm` shims,
   plain `&`) keep the pipe open and `cmd.Output()` would block for as long
   as the survivors live. `Cmd.WaitDelay` forcibly closes the pipes once
   the cap elapses, so total startup penalty is bounded by
   `timeout + waitDelay` regardless of rc-file content.

3. Lazy fallback — the resolver no longer runs on every daemon start.
   `getShellResolved` is `sync.Once`-guarded and only fires when a bare
   command name actually misses `exec.LookPath`. Users whose PATH already
   contains every agent never pay the rc-file load cost.

Tests: - `TestResolveAgentsViaLoginShell_StripsAliasShadowing` — rc declares
    `alias fakeclaude=...`, real binary lives on PATH, resolver must
    return the binary, not the alias text.
  - `TestResolveAgentsViaLoginShell_HardTimeoutOnBackgroundedStdout` —
    rc backgrounds a 60s sleeper holding stdout; resolver must return
    inside `timeout + waitDelay + slack`, not 60s.
  - `TestLoadConfig_SkipsLoginShellWhenLookPathSucceeds` — when
    exec.LookPath finds every agent, SHELL (a marker-writing sentinel)
    must not be invoked.
Co-authored-by: multica-agent <github@multica.ai>

---------

Co-authored-by: multica-agent <github@multica.ai>
2026-05-14 19:27:57 +08:00
Prateek Bhatnagar
681d720671 fix(issues): file-card render for self-host with local storage (#2349)
* fix(issues): file-card render for self-host with local storage

Fixes #1520. When self-hosting without S3, the upload handler returns
site-relative URLs like /uploads/workspaces/<wsId>/<file>. Four
frontend regexes only matched https?://, so persisted
!file[name](/uploads/...) markdown failed to parse and leaked through
as raw text in the issue view, chat, skill file viewer, and board
card preview.

Narrow allow-list: the relative branch only accepts /uploads/ — not
any /-prefixed href — so protocol-relative //evil.com/x, path-traversal
/../api/x, and other internal /api/... paths are rejected. Without
this, a stored file-card with an attacker-chosen filename and a
//host/x href would turn into a one-click external-site jump via
window.open from inside an issue (per review feedback on #2349).

Single source of truth: packages/ui/markdown/file-cards.ts now exports
isAllowedFileCardHref + FILE_CARD_URL_PATTERN. The four sites use one
of them, so the next regression is cheaper than restoring four parallel
regexes.

- packages/ui/markdown/file-cards.ts: helper + URL pattern.
- packages/views/editor/extensions/file-card.tsx: Tiptap tokenizer
  composes from FILE_CARD_URL_PATTERN.
- packages/views/editor/readonly-content.tsx: sanitiser uses helper.
- packages/ui/markdown/Markdown.tsx: sanitiser uses helper.
- packages/views/issues/components/board-card.tsx: strip markdown
  tokens from the line-clamped board preview so raw !file[...] no
  longer leaks there either.
- packages/ui/markdown/file-cards.test.ts: covers accept (/uploads/ok,
  https://cdn/x) and reject (javascript:, data:, //evil.com/x,
  /../api/x, /api/x, empty, ftp:, bare 'uploads/x') for both the
  helper and the parser composed from the pattern.

javascript:, data:, and other dangerous schemes remain rejected.

* test(markdown): move file-card href allow-list test into @multica/views

Per review feedback on #2349: keep the test where vitest is already
running instead of bootstrapping a new test runner inside @multica/ui.
The test now lives at packages/views/editor/file-card-href.test.ts and
imports isAllowedFileCardHref / FILE_CARD_URL_PATTERN /
preprocessFileCards from the @multica/ui/markdown public surface,
exercising the same 30 cases.

Reverts the @multica/ui package.json test script + vitest devDep + the
local vitest.config.ts that the previous commit added; the package
goes back to typecheck + lint only, matching every other ui-only
package in the monorepo.

---------

Co-authored-by: Lalbadshah <11599756+Lalbadshah@users.noreply.github.com>
2026-05-14 18:32:40 +08:00
Bohan Jiang
21386e8f97 docs(issue-template): clarify deployment type options (#2618)
Rename the Deployment type dropdown options to Official App and
self-host so reporters pick the right one without guessing.

MUL-2212

Co-authored-by: multica-agent <github@multica.ai>
2026-05-14 18:27:01 +08:00
Multica Eve
a732c3d775 docs(changelog): add May 14 release notes (#2610)
* docs(changelog): add 2026-05-14 release notes

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

* docs(changelog): update May 14 release notes

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-14 18:14:08 +08:00
Naiyuan Qing
43b9a1173c refactor(agents): drop template chooser from create-agent dialog (#2615)
* refactor(agents): drop template chooser from create-agent dialog

Removes the blank-vs-template chooser, the template picker, and the
template detail step. The "Create agent" entry point now opens directly
on the form. The createAgentFromTemplate API and types remain
untouched — this only removes the UI entry.

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

* docs(squads): fix stale comment about createAgentFromTemplate

Squad-scoped create flow no longer goes through the template path;
the dialog now only calls api.createAgent then api.addSquadMember.

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-14 18:05:37 +08:00
Bohan Jiang
c98161b039 docs(squads): add Squads page and cross-link from related docs (#2612)
Adds a dedicated bilingual /docs/squads page covering the squad model
(leader + members), assignment, comment trigger rules, archive
semantics, and the squad CLI surface. Wires the new page into
meta.json and meta.zh.json under the Agents section, and adds
short cross-references from agents, assigning-issues,
mentioning-agents, and the CLI reference so users can discover
squads from the pages they're already on.

MUL-2206

Co-authored-by: multica-agent <github@multica.ai>
2026-05-14 17:53:45 +08:00
Bohan Jiang
fdf19cac8f fix(quick-create): default squad-picked issues to the squad, not the leader (#2611)
When the user opens quick-create with a squad selected, the task is
enqueued against the squad's leader agent — but the squad, not the
leader, is the expected owner. The prompt previously instructed the
leader to "default to YOURSELF" using its own agent UUID, hiding new
issues from the squad's delegation flow.

Surface the squad's id + name on the claim response and branch the
default-assignee instruction in buildQuickCreatePrompt: when SquadID is
present, point --assignee-id at the squad UUID and explicitly forbid
self-assignment.

MUL-2203

Co-authored-by: multica-agent <github@multica.ai>
2026-05-14 17:48:02 +08:00
Naiyuan Qing
77b929fd3e feat(squads): add agent live peek hover card on member avatars (#2608)
* feat(squads): add agent live peek hover card on member avatars

Squad members tab now opens a live-state peek card on agent avatar
hover/focus — workload, current issue (clickable), and last activity.
Identity (description / runtime / skills / owner) stays on the existing
AgentProfileCard; new AgentLivePeekCard is the second `hoverCardVariant`
on ActorAvatar so the 23+ existing profile-card call sites keep their
behaviour. Reuses the workspace agent-task snapshot already fetched by
the presence dot, so this adds zero new requests per row. Failed
terminal tasks surface as a small ⚠ on the last-activity line without
polluting workload (workload stays current-state only, matching the
deliberate split documented in core/agents/types.ts).

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

* fix(squads): only enable hover card for agent avatars

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

---------

Co-authored-by: multica-agent <github@multica.ai>
2026-05-14 17:30:08 +08:00
yujiawei
a8ce0a8998 feat(cli): add 'multica issue cancel-task <task-id>' command (#2560)
Exposes the existing /api/tasks/{id}/cancel backend endpoint as a CLI
command. Combined with upstream #2107 (cancel running agent on
server-side task delete), this gives operators a way to interrupt a
runaway agent push-storm without resorting to admin-bypass on the
downstream PR.

Use cases:
- Titan / DevBot iterating beyond its boundary (e.g. push-skip loops)
- Codex turn that locked in tool-call spam
- Manual recovery when a long-running task needs to stop NOW

Symmetric with 'issue rerun': accepts the short ID prefix shown by
'issue runs', supports --issue scoping, and reuses resolveTaskRunID
for ambiguity handling.

Refs: PR#19 octo-server post-mortem (2026-05-13)

Co-authored-by: yujiawei <yujiawei@mininglamp.com>
2026-05-14 17:02:58 +08:00
Naiyuan Qing
5eb04f73e3 feat(squads): add tooltips and agent detail link to squad member row (#2603)
* feat(squads): add tooltips and agent detail link to squad member row

Replace native title attributes on the make-leader and remove buttons
with proper Tooltip components, and add a new icon button on agent
rows that navigates to the agent detail page. All three tooltips are
localised.

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

* fix(squads): keyboard focus visibility + AppLink for agent detail

- Add group-focus-within:opacity-100 so Tab to the row's hover-only
  action buttons makes the container visible (previously opacity-0
  kept buttons focusable but invisible).
- Replace the agent-detail jump button's onClick+push() with AppLink
  href, restoring middle/Cmd+Click new-tab behavior. Removes the
  now-unused onViewAgent callback chain.

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-14 16:35:15 +08:00
Naiyuan Qing
bc613c08b3 fix(squad): align squad detail tab width with agent detail (#2600)
Drop mx-auto + max-w-2xl wrappers around the Members and Instructions
tab content so the right pane fills the available width like the agent
detail page (TabContent uses flex h-full flex-col p-4 md:p-6).

Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>
2026-05-14 16:09:45 +08:00
Naiyuan Qing
2c7738b03a feat(issues): close composer attachment preview loop end-to-end (#2594)
Text/code attachments (markdown, JSON, .ts, .log, …) need an attachment id
to render through `/api/attachments/{id}/content`. The composer pipeline
was dropping that id at the upload-hook boundary, so the Eye preview gate
only fired for media (PDF / video / audio via filename fallback).

- `useFileUpload` now returns the full `Attachment` (with `link` kept as a
  `url` alias) so editor providers can resolve content-type and id.
- New-comment and reply composers hold a `pendingAttachments` state and
  feed it to `ContentEditor`; the active subset (those still referenced in
  the markdown) is sent on submit as before.
- Comment edit modes (CommentRow + CommentCardImpl) merge pending uploads
  with `entry.attachments` for the editor and pipe `attachment_ids` into
  `onEdit` so newly uploaded files actually bind to the comment.
- Issue description editor pushes pending `attachment_ids` on every
  debounced save and invalidates `issueKeys.attachments` so the preview
  Eye survives a refresh.
- `UpdateComment` and `UpdateIssue` handlers accept `attachment_ids` and
  call the existing `linkAttachmentsByIDs` / `linkAttachmentsByIssueIDs`
  helpers; the bind is idempotent so re-sending an existing id is safe.

Closes MUL-2153.

Co-authored-by: multica-agent <github@multica.ai>
2026-05-14 15:06:21 +08:00
LinYushen
e492d989d1 fix: trigger squad leader agent run on squad @mention in comment (#2592)
* fix: trigger squad leader agent run when squad is @mentioned in comment

Previously, enqueueMentionedAgentTasks only processed m.Type == "agent"
mentions, skipping squad mentions entirely. The shouldEnqueueSquadLeaderOnComment
path only fires when the issue is already assigned to a squad.

This adds handling for m.Type == "squad" in enqueueMentionedAgentTasks:
when a squad is @mentioned, look up the squad's leader agent and enqueue
a task for them (with the same dedup/self-trigger/archived guards as
direct agent mentions).

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

* fix: add canAccessPrivateAgent gate to squad mention branch

Closes the P1 permission vulnerability where a plain workspace member
could trigger a private squad leader by @mentioning the squad, bypassing
the private-agent access check that the direct @agent mention path
enforces.

Adds regression test TestCreateComment_SquadMentionPrivateLeaderBlocksPlainMember.

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

---------

Co-authored-by: multica-agent <github@multica.ai>
2026-05-14 14:33:27 +08:00
Naiyuan Qing
0c4133ef5b feat(agents): rewrite template catalog as 25 lightweight starters (#2587)
* feat(agents): rewrite template catalog as 25 lightweight starters

Replaces every Phase-1 template with a curated set built around the
"persona + intake + scaffold + hard negatives" instruction shape. Cross-
platform survey (Cursor / Cline / Roo / Continue / Custom GPTs) showed
the industry baseline for starter agents is "few but sharp" — single
intent, no methodology buy-in, mostly prompt-only. The original catalog
went the opposite direction (avg 2.5 skills, six-skill Full-stack
methodology stack) and felt heavy for first-time use.

Catalog shape:

- 25 templates across 7 categories: Engineering (8), Product (4),
  Writing (5), Design (3), Communication (2), Team (1), Productivity (2).
  New Product / Design / Communication / Team domains fill gaps the old
  Eng-heavy catalog ignored.
- 16 / 25 are prompt-only (no skill fan-out). Avg 0.56 skill per template
  vs. 2.5 prior. Heaviest is 2 skills, only for templates whose intent
  cannot be expressed in instructions alone (Playwright runner, single-
  file HTML bundlers, design + UX-guidelines pair).
- Universal top-frequency intents that the old catalog missed are now
  covered: Code Explainer (intent #1 across every platform surveyed),
  Translator (中英), Summarizer, Writing Critic, PRD Drafter/Critic,
  RCA Writer, ADR Writer, PR Description Writer, Commit Message Writer.

Loader allows 0-skill templates:

- server/internal/agenttmpl/loader.go drops the "must declare at least
  one skill" validation; comment explains the picker's "Prompt only"
  rendering path.
- loader_test.go: removed the corresponding negative case, added
  TestLoadFromFS_PromptOnlyTemplate as a regression guard.
- agent_template.go handler is unchanged — every len(tmpl.Skills) call
  site was already 0-safe (empty fan-out short-circuits the fetch phase
  and the in-tx loop both skip cleanly).

Frontend:

- template-picker.tsx: 18 new lucide icons (BookOpen, Bug, GitPullRequest,
  GitCommit, AlertTriangle, Scale, ClipboardList, Microscope, UserRound,
  Target, Highlighter, Languages, AlignLeft, GraduationCap, Lightbulb,
  Type, MessageSquare, Briefcase). Card renders a "Prompt only" badge
  when skills.length === 0 instead of "0 skills".
- template-detail.tsx: skill list section is hidden entirely for prompt-
  only templates — a header reading "Includes 0 skills" above an empty
  list was just visual noise. Instructions section below carries the
  agent's identity for these.
- locales/en + zh-Hans agents.json: new create_dialog.template_card.
  prompt_only key ("Prompt only" / "纯指令").

Verification:

- go test ./internal/agenttmpl/ — 9/9 pass, including
  TestLoad_RealTemplates which fails closed if any new JSON is malformed.
- pnpm typecheck — all 6 packages clean.
- pnpm --filter @multica/views test — 482/482 pass.
- pnpm lint — 0 errors.

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

* feat(agents): add category filter pills to template picker

25 templates across 7 categories made the picker scroll-heavy on first
open. Add a single-select category filter row above the grid so a PM
can isolate Product templates in one click, an engineer can jump
straight to Engineering, etc.

Visual reuses the IssuesHeader scope-toggle pattern verbatim — Button
variant="outline" + active class swap (bg-accent / text-muted-foreground)
— so the affordance reads the same as the existing filter pills in
issues / squads / runtimes / my-issues. flex-wrap keeps the 8 pills
(All + 7 categories) honest on narrow widths.

Counts are inlined into the label ("Engineering (8)") rather than
shown as a separate badge — single-line-tall pills look right next to
the picker grid, and surfacing the per-category density up front
doubles as a hint at the catalog's "less but sharper" intent.

When a specific category is active, the grid renders flat (no
section headers) — the active pill already names what's on screen,
and a header reading "Engineering" above an only-Engineering grid is
visual duplication. "All" falls back to the prior grouped layout.

State is component-local (no URL sync, no persistence) since the
picker is dialog-internal transient state — closing the dialog
naturally resets the filter, which is the expected behaviour for a
"choose from a catalog" surface.

i18n: new `create_dialog.template_picker.filter_all` key in en + zh.

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-14 14:12:18 +08:00
LinYushen
0cb759b446 fix(squad): suppress no-action leader comments (#2583) 2026-05-14 14:07:26 +08:00
Multica Eve
58cc189dcd fix: honor quick-create squad mentions (#2586)
Co-authored-by: Eve <eve@multica-ai.local>
Co-authored-by: multica-agent <github@multica.ai>
2026-05-14 14:01:37 +08:00
LinYushen
053a37d19c feat: add pinyin search to subscriber popover in issue-detail (#2584)
Co-authored-by: multica-agent <github@multica.ai>
2026-05-14 13:57:46 +08:00
LinYushen
d1c8c213e4 feat: extend pinyin search to all Agent/Member/Squad selectors (#2582)
Integrate matchesPinyin into:
- AssigneePicker (issue assignee selector)
- IssuesHeader (assignee filter bar)
- AgentPicker (autopilot agent selector)
- SquadDetailPage (add member/agent picker)
- QuickCreateIssue (agent/squad picker)
- CreateProject (lead picker)
- ProjectDetail (lead picker)
- ProjectsPage (lead filter)
- AgentsPage (agent search)
- SquadsPage (squad search)

Closes MUL-2179 extended scope.

Co-authored-by: multica-agent <github@multica.ai>
2026-05-14 13:57:38 +08:00
Bohan Jiang
f15a745182 feat(squads): add Create Agent entry on Squad detail (MUL-2178) (#2579)
Adds a Create Agent button on the Squad detail Members tab, visible
only to workspace owner/admin (matching the AddSquadMember backend
gate). The dialog reuses the existing CreateAgentDialog — both the
manual and template paths now accept an optional squadId; when set,
the dialog runs addSquadMember after createAgent / createAgentFromTemplate
and skips the navigation to the agent detail page so the user lands
back on the Members tab.

Atomicity is best-effort frontend-serial (no new backend transaction):
on partial failure the dialog surfaces a warning toast and the agent
remains addable from the existing Add Member flow.

Co-authored-by: multica-agent <github@multica.ai>
2026-05-14 13:32:28 +08:00
LinYushen
ca10535bb6 fix: execution log name rendering and squad assignee support (#2575)
* fix: execution log name rendering and squad assignee support

- Strip mention markdown in trigger_summary ([@Name](mention://...) → @Name)
  so execution log rows show clean text instead of raw markdown
- Add squad to ActorFilterValue type so squad assignees are filterable
- Add squad section to assignee filter dropdown in issues-header
- Add i18n keys for squads_group (en/zh-Hans)

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

* fix: address PR #2575 review feedback

1. Extract stripMentionMarkdown as reusable helper with proper regex
   - Handles escaped brackets in names (e.g. David\[TF\])
   - Skips backslash-escaped mentions (\[@...])
   - Handles issue mentions (no @ prefix)
   - Does not touch regular markdown links
   - 10 unit tests added

2. Squad only appears in Assignee filter, not Creator
   - Added showSquads prop to ActorSubContent (default true)
   - Creator filter passes showSquads={false}

3. Squad included in Agents scope
   - issues-page scope filter now includes squad in agents scope
   - 2 regression tests added for scope coverage

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

---------

Co-authored-by: multica-agent <github@multica.ai>
2026-05-14 13:08:05 +08:00
LinYushen
376cc8372a fix: inject squad leader no_action rule for member-triggered comments (#2576)
The per-turn prompt in buildCommentPrompt() only injected the squad
leader no_action prohibition inside the 'if TriggerAuthorType == agent'
block. When a member (human) posted a comment like 'LGTM', the squad
leader was triggered but the per-turn prompt did NOT include the
prohibition, causing the model to post noise comments like 'LGTM is a
pure acknowledgment — no reply needed. Exiting silently.'

Fix: move the squad leader no_action rule outside the agent-only block
so it fires for ALL trigger types (agent and member).

Fixes: MUL-2168

Co-authored-by: multica-agent <github@multica.ai>
2026-05-14 13:01:07 +08:00
LinYushen
add3135a42 feat(cli): add squad create/update/delete and member add/remove (#2574)
* feat(cli): add squad create/update/delete and member add/remove commands

Implement missing squad management commands in the CLI:
- squad create --name --leader [--description]
- squad update <id> [--name] [--description] [--instructions] [--leader] [--avatar-url]
- squad delete <id>
- squad member add <squad-id> --member-id --type [--role]
- squad member remove <squad-id> --member-id --type

Also adds DeleteJSONWithBody to the API client for the member remove
endpoint which uses DELETE with a JSON body.

All commands support --output json for structured output.

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

* fix(squad): add --output json to delete/member remove, return 404 on 0-row delete

- squad delete: add --output json flag, emit {id, deleted} on success
- squad member remove: add --output json flag, emit {squad_id, member_id, removed}
- Backend RemoveSquadMember: change query to :execrows, check RowsAffected
  and return 404 'squad member not found' when 0 rows deleted

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

---------

Co-authored-by: multica-agent <github@multica.ai>
2026-05-14 12:51:44 +08:00
LinYushen
c628958fdd feat: support pinyin search in @mention suggestions (#2572)
* feat: support pinyin search in @mention suggestions

Add pinyin matching for Chinese names in the mention suggestion popup.
Users can now search by:
- Full pinyin: 'liyunlong' matches '李云龙'
- Initial letters: 'lyl' matches '李云龙'
- Partial/hybrid: 'liyu' or 'liyunl' matches '李云龙'

Implementation:
- New pinyin-match.ts utility using pinyin-pro library
- Integrated into member, agent, and squad filters in mention-suggestion.tsx
- 21 tests passing (9 unit + 12 integration)

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

* fix: normalize ü→v in pinyin matching for names like 吕布

Enable pinyin-pro's v:true option so 吕→lv instead of lü.
Add test case for 吕布/lvbu matching.

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

---------

Co-authored-by: multica-agent <github@multica.ai>
2026-05-14 12:44:43 +08:00
LinYushen
f6ac53a967 fix: squad leader no_action must not post comment on comment-triggered path (#2573)
PR #2564 only added IsSquadLeader handling to the assignment-triggered
workflow path and the Output section. When a squad leader is triggered by
a comment (the common case for re-evaluation), the comment-triggered
workflow path had NO squad leader special handling, so the model still
posted comments announcing no_action/silence.

Changes:
- runtime_config.go: Add IsSquadLeader check to comment-triggered step 4
  with explicit prohibition against posting no_action announcement comments
- runtime_config.go: Strengthen Output section from 'may exit silently' to
  'MUST exit without posting any comment' with explicit DO NOT examples
- runtime_config.go: Strengthen assignment-triggered step 5 similarly
- prompt.go: Add squad leader no_action rule to per-turn comment prompt
  when trigger author is an agent and agent instructions contain the
  Squad Operating Protocol marker
- Add tests for both the per-turn prompt and CLAUDE.md generation

Fixes MUL-2168

Co-authored-by: multica-agent <github@multica.ai>
2026-05-14 12:36:06 +08:00
Bohan Jiang
334d9cdd02 fix(squad): skip leader when a member @mentions anyone (MUL-2170) (#2569)
* fix(squad): skip leader on comment when a member @mentions any agent (MUL-2170)

When a human commenter routes an issue directly at a specific agent via
[@Name](mention://agent/<id>), the squad leader was still being woken up
to evaluate the same comment. The leader's only real options were to
re-delegate to the agent the member already named or to record
no_action — both of which produce queue noise without changing the
outcome.

This skips the leader-enqueue path entirely when:
  - the assignee is a squad,
  - the comment author is a member, AND
  - the comment body contains at least one agent mention.

Agent-authored comments are intentionally exempt: when an agent posts
an update that @mentions another agent, the leader still needs to
coordinate the thread. The existing leader-self-trigger guard is
preserved. Only the current comment's body is inspected — parent
(thread root) mentions are not inherited here.

Tests cover the helper (mentions parsing) plus the integration matrix:
member plain / member @member / member @non-leader-agent /
member @leader / agent @agent / leader-self.

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

* test(squad): exercise full CreateComment path for leader-skip rule (MUL-2170)

Adds an integration test that drives the HTTP-layer CreateComment handler
(not just the helper) to lock the call-site wiring: a member top-level
comment with an @agent skips the squad leader, and a subsequent plain
reply in the same thread DOES wake the leader — the parent's @agent
mention must not be inherited into the leader-skip decision.

Picks up a non-blocking review note on PR #2569.

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

* fix(squad): skip leader on any explicit member mention, not only @agent (MUL-2170)

Broaden the leader-skip rule for squad-assigned issues: a member comment
that explicitly @mentions anyone — @agent, @member, @squad, or @all —
counts as deliberate routing and the squad leader stays out. Issue
cross-references (mention://issue/...) are not routing and still trigger
the leader as before.

Per Bohan's follow-up on MUL-2170 — @member should suppress the leader
for the same reason @agent does: the human has already pointed at a
specific recipient, so a leader turn would just be observation noise.

Helper renamed commentMentionsAnyAgent → commentMentionsAnyone with
explicit handling of all four routing mention types. Existing call-site
wiring (current-comment-only, agent-author exemption, leader self-trigger
guard) is unchanged.

Tests updated and extended to cover the full routing matrix:
@member / @squad / @all / @issue (cross-ref) plus the @agent variants
already covered.

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

---------

Co-authored-by: multica-agent <github@multica.ai>
2026-05-14 12:22:10 +08:00
fr00st
cc9fbd3db0 Fix stale Done replies on comment follow-ups (#2495)
* fix: avoid stale done replies on comment follow-ups

* fix: avoid inlining runtime brief for Hermes ACP

* fix: address comment follow-up review feedback
2026-05-14 12:00:04 +08:00
LinYushen
9256743549 fix(mention): prefetch squads so @mention list shows all squads
Closes MUL-2176
2026-05-14 11:52:13 +08:00
Naiyuan Qing
c49c778613 fix(editor): align Preview gate with Download — survive URL-only sources (#2566)
The Eye button required a fully resolved Attachment record (URL-lookup
via `resolveAttachment(href)`) before showing. Download only required
the URL, falling back to `openExternal(href)` when the lookup missed.
Result: any case where the URL in markdown couldn't be reverse-matched
to the entity's `attachments` prop (cross-comment copy-paste, stale
caches) silently hid the Preview button while Download kept working —
edit and readonly surfaces diverged for the same content.

Widen the Preview gate to mirror Download: show the Eye whenever the
filename indicates a previewable type. Introduce a `PreviewSource`
tagged union — `{ kind: "full", attachment }` for the existing path,
`{ kind: "url", url, filename }` for the fallback. Media kinds
(pdf/video/audio) render directly from the URL; text kinds still
require an attachment id because the /content proxy is ID-keyed, so
`tryOpen` rejects URL+text combinations and PreviewContent has a
defensive fallback for direct mounts.

Side effects:
- `getPreviewKind` gains filename-extension fallbacks for video/audio
  (was PDF-only); without these the URL-only path can't infer kind
  when content_type is empty.
- AttachmentList in comment-card.tsx unchanged behaviorally — only the
  tryOpen call site is updated to the new signature.

Pre-existing architectural issues (AttachmentList readonly-only,
URL-based attachment lookup, per-entity ownership) are intentionally
out of scope.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-14 11:33:48 +08:00
Naiyuan Qing
52d032335a feat(agents): expose runtime + model on create-from-template (#2565)
Template create used to silently default the runtime to "first usable"
and never collected a model — users had no idea where the new agent
would run or which model it would use until they opened the detail
page. Add a Runtime + Model picker pair above the skill list on the
template-detail step so the choice is visible (and overridable) before
the one-click Use action.

- Extract RuntimePicker out of create-agent-dialog so the form and the
  template-detail step share one popover; selection seeding moves into
  the picker too, since it's the only place that knows the active
  filter (mine/all). Parent keeps just the duplicate-mode pre-fill.
- Mirror RuntimePicker's label-row + trigger DOM in ModelDropdown so
  the two pickers render at identical heights when sat side-by-side
  (fixes a 6-8px misalignment caused by inconsistent label-row sizing).
- Send model in createAgentFromTemplate; server side already accepts
  the field (CreateAgentFromTemplateRequest.Model, omitempty), empty
  string still falls through to the runtime's default model.
- Drop the runtime_register_first fallback hint that made the Runtime
  trigger two-line in the empty state, breaking alignment with Model's
  one-line trigger.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-14 11:33:39 +08:00
LinYushen
7a1284128d fix: allow squad leader to exit silently on no_action without posting a comment (#2564)
The runtime prompt's Output section unconditionally required all tasks to
post a comment via 'multica issue comment add', which conflicted with the
squad leader protocol that says to 'exit silently' on no_action.

Changes:
- Add IsSquadLeader bool to TaskContextForEnv (detected via Squad Operating
  Protocol marker in agent instructions)
- Relax the Output section and assignment-triggered workflow step 5 to
  allow squad leaders to exit with only a 'multica squad activity' call
  when the outcome is no_action

Fixes MUL-2168

Co-authored-by: multica-agent <github@multica.ai>
2026-05-14 11:33:15 +08:00
Bohan Jiang
21b49eb59b fix(cli): resolve squad assignees in issue create/update/assign (MUL-2165) (#2551)
* fix(cli): resolve squad assignees in issue create/update/assign (MUL-2165)

The CLI assignee resolver only searched workspace members and agents, so a
quick-create input like "assign to <SquadName>" silently fell through to
"Unrecognized assignee: <SquadName>" in the issue description — even though
squads are first-class assignees server-side and the prompt's whole point was
to route the work for the user.

Extend resolveAssignee / resolveAssigneeByID to also fetch /api/squads, teach
the actor display lookup to render squad names in table output, update the
quick-create prompt and runtime-config command listing to mention
`multica squad list` alongside members and agents, and lock in the new
behavior with tests.

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

* fix(cli): gate squad assignee resolution behind an allowed-kinds set (MUL-2165)

The earlier MUL-2165 fix taught resolveAssignee / resolveAssigneeByID to also
return (squad, ...), but those helpers are shared. Project lead and issue
subscriber callers were still using them, and their target schemas reject
squads — project.lead_type has a DB CHECK constraint
(server/migrations/034_projects.up.sql:10) and the subscriber handler's
isWorkspaceEntity switch only knows member/agent
(server/internal/handler/handler.go:414). So
`multica project create --lead "<SquadName>"` and
`multica issue subscriber add --user "<SquadName>"` would resolve to
(squad, ...) and surface as a 500/403 server-side instead of a clean
CLI-side resolution error.

Thread an assigneeKinds set through the resolver and the pickAssigneeFromFlags
helper. Issue create/update/assign/list pass `issueAssigneeKinds` (all three);
project lead and subscriber pass `memberOrAgentKinds`. The squads fetch is
skipped entirely when not allowed, and the not-found / no-match error wording
adapts to the allowed kinds so it never mentions a type the caller cannot use.

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

---------

Co-authored-by: multica-agent <github@multica.ai>
2026-05-13 22:31:50 +08:00
Bohan Jiang
0345285b86 feat(quick-create): searchable actor picker + squad support (#2552)
* feat(quick-create): searchable actor picker + squad support (MUL-2163)

- Replaces the flat agent dropdown in the "Create with agent" modal with a
  searchable PropertyPicker that lists Agents and Squads in separate
  sections, so users can filter by name and pick a squad as the creator.
- Persists the selection as (lastActorType, lastActorId), removing the
  agent-only lastAgentId field on the quick-create store.
- Adds squad_id to the quick-create API request and stamps it onto the
  task's QuickCreateContext. The handler resolves the squad to its leader
  agent (re-using validateAssigneePair) and the daemon claim path injects
  the squad-leader briefing when the task carries a squad hint, matching
  the behavior of issue-bound squad tasks.

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

* fix(create-issue): forward squad picks across manual→agent switch

Manual mode → agent mode previously only carried `agent_id`, so picking
a squad and then flipping to agent silently fell back to the persisted
actor / first visible agent and lost the user's choice. Carry `squad_id`
on the same branch so the agent panel honors the squad pick.

Adds a sibling test alongside the existing project-carry case.

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

---------

Co-authored-by: multica-agent <github@multica.ai>
2026-05-13 22:31:17 +08:00
553 changed files with 48333 additions and 7541 deletions

View File

@@ -0,0 +1,39 @@
---
name: web-design-guidelines
description: Review UI code for Web Interface Guidelines compliance. Use when asked to "review my UI", "check accessibility", "audit design", "review UX", or "check my site against best practices".
metadata:
author: vercel
version: "1.0.0"
argument-hint: <file-or-pattern>
---
# Web Interface Guidelines
Review files for compliance with Web Interface Guidelines.
## How It Works
1. Fetch the latest guidelines from the source URL below
2. Read the specified files (or prompt user for files/pattern)
3. Check against all rules in the fetched guidelines
4. Output findings in the terse `file:line` format
## Guidelines Source
Fetch fresh guidelines before each review:
```
https://raw.githubusercontent.com/vercel-labs/web-interface-guidelines/main/command.md
```
Use WebFetch to retrieve the latest rules. The fetched content contains all the rules and output format instructions.
## Usage
When a user provides a file or pattern argument:
1. Fetch guidelines from the source URL above
2. Read the specified files
3. Apply all rules from the fetched guidelines
4. Output findings using the format specified in the guidelines
If no files specified, ask the user which files to review.

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
@@ -81,6 +112,13 @@ CLOUDFRONT_DOMAIN=
# attribute and browsers silently drop such cookies.
COOKIE_DOMAIN=
# AUTH_TOKEN_TTL — auth token lifetime. Accepts Go duration strings (e.g.
# "8760h", "720h30m") or plain integer seconds.
# Default: 2592000 (30 days). Self-hosted deployments on trusted networks can
# set a longer value to reduce re-authentication frequency.
# Note: longer TTL = longer exposure window if a cookie is leaked.
# AUTH_TOKEN_TTL=2592000
# Local file storage (fallback when S3_BUCKET is not set)
LOCAL_UPLOAD_DIR=./data/uploads
LOCAL_UPLOAD_BASE_URL=http://localhost:8080
@@ -88,8 +126,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

@@ -7,10 +7,10 @@ body:
id: deployment
attributes:
label: Deployment type
description: Are you using the hosted version or a self-hosted instance?
description: Are you using the Official App (multica.ai) or a self-hosted instance?
options:
- multica.ai (hosted)
- Self-hosted
- Official App
- self-host
validations:
required: true

View File

@@ -7,10 +7,10 @@ body:
id: deployment
attributes:
label: Deployment type
description: Are you using the hosted version or a self-hosted instance?
description: Are you using the Official App (multica.ai) or a self-hosted instance?
options:
- multica.ai (hosted)
- Self-hosted
- Official App
- self-host
validations:
required: true

View File

@@ -269,21 +269,45 @@ Each profile gets its own config directory (`~/.multica/profiles/<name>/`), daem
## Workspaces
### Working with multiple workspaces
Every command runs against a single workspace. The CLI resolves which one in this order (highest priority first):
1. `--workspace-id <id>` flag on the command
2. `MULTICA_WORKSPACE_ID` environment variable
3. The default workspace stored in your current profile (set by `multica workspace switch` or `multica login`)
`multica workspace switch <id|slug>` is the day-to-day way to change the default workspace. For scripting and headless setups where you don't want any stored state, prefer the `--workspace-id` flag or the env variable. `multica config set workspace_id <id>` is the low-level equivalent of `switch` (it writes the same setting but skips the access check).
If you need full isolation between organizations or accounts — separate tokens, separate daemons, separate config dirs — use `--profile <name>` instead. Each profile keeps its own default workspace.
### List Workspaces
```bash
multica workspace list
multica workspace list --output json
```
Watched workspaces are marked with `*`. The daemon only processes tasks for watched workspaces.
The current default workspace is marked with `*`.
### Watch / Unwatch
### Show Current Workspace
```bash
multica workspace watch <workspace-id>
multica workspace unwatch <workspace-id>
multica workspace current
multica workspace current --output json
```
Prints the workspace that commands without `--workspace-id` and `MULTICA_WORKSPACE_ID` would target.
### Switch Default Workspace
```bash
multica workspace switch <workspace-id>
multica workspace switch <slug>
```
Verifies you have access to the workspace, then sets it as the default for the current profile. Subsequent commands without `--workspace-id` and `MULTICA_WORKSPACE_ID` target this workspace. Pair `--profile` if you want to change a non-default profile's workspace.
### Get Details
```bash
@@ -508,6 +532,8 @@ multica config set app_url https://app.example.com
multica config set workspace_id <workspace-id>
```
`config set workspace_id <id>` is the low-level interface — it writes the value verbatim without checking that the workspace exists or that you have access. Prefer `multica workspace switch <id|slug>` for day-to-day workspace changes; it does both checks before saving.
## Autopilot Commands
Autopilots are scheduled/triggered automations that dispatch agent tasks (either by creating an issue or by running an agent directly).

View File

@@ -32,6 +32,8 @@ Multica turns coding agents into real teammates. Assign issues to an agent like
No more copy-pasting prompts. No more babysitting runs. Your agents show up on the board, participate in conversations, and compound reusable skills over time. Think of it as open-source infrastructure for managed agents — vendor-neutral, self-hosted, and designed for human + AI teams. Works with **Claude Code**, **Codex**, **GitHub Copilot CLI**, **OpenClaw**, **OpenCode**, **Hermes**, **Gemini**, **Pi**, **Cursor Agent**, **Kimi**, and **Kiro CLI**.
For larger teams, Squads add a stable routing layer: assign work to a group led by an agent, and the leader delegates to the right member.
<p align="center">
<img src="docs/assets/hero-screenshot.png" alt="Multica board view" width="800">
</p>
@@ -53,6 +55,7 @@ Like Multics before it, the bet is on multiplexing: a small team shouldn't feel
Multica manages the full agent lifecycle: from task assignment to execution monitoring to skill reuse.
- **Agents as Teammates** — assign to an agent like you'd assign to a colleague. They have profiles, show up on the board, post comments, create issues, and report blockers proactively.
- **Squads** — group agents (and humans) under a leader agent and assign work to the *squad*. The leader decides who should pick it up, so routing stays stable as the team grows. `@FrontendTeam` instead of `@alice-or-bob-or-carol`.
- **Autonomous Execution** — set it and forget it. Full task lifecycle management (enqueue, claim, start, complete/fail) with real-time progress streaming via WebSocket.
- **Reusable Skills** — every solution becomes a reusable skill for the whole team. Deployments, migrations, code reviews — skills compound your team's capabilities over time.
- **Unified Runtimes** — one dashboard for all your compute. Local daemons and cloud runtimes, auto-detection of available CLIs, real-time monitoring.
@@ -128,21 +131,6 @@ Create an issue from the board (or via `multica issue create`), then assign it t
---
## Multica vs Paperclip
| | Multica | Paperclip |
|---|---------|-----------|
| **Focus** | Team AI agent collaboration platform | Solo AI agent company simulator |
| **User model** | Multi-user teams with roles & permissions | Single board operator |
| **Agent interaction** | Issues + Chat conversations | Issues + Heartbeat |
| **Deployment** | Cloud-first | Local-first |
| **Management depth** | Lightweight (Issues / Projects / Labels) | Heavy governance (Org chart / Approvals / Budgets) |
| **Extensibility** | Skills system | Skills + Plugin system |
**TL;DR — Multica is built for teams that want to collaborate with AI agents on real projects together.**
---
## CLI
The `multica` CLI connects your local machine to Multica — authenticate, manage workspaces, and run the agent daemon.
@@ -154,6 +142,8 @@ The `multica` CLI connects your local machine to Multica — authenticate, manag
| `multica daemon status` | Check daemon status |
| `multica setup` | One-command setup for Multica Cloud (configure + login + start daemon) |
| `multica setup self-host` | Same, but for self-hosted deployments |
| `multica workspace list` | List your workspaces (current is marked with `*`) |
| `multica workspace switch <id\|slug>` | Switch the default workspace for this profile |
| `multica issue list` | List issues in your workspace |
| `multica issue create` | Create a new issue |
| `multica update` | Update to the latest version |

View File

@@ -32,6 +32,8 @@ Multica 将编码 Agent 变成真正的队友。像分配给同事一样分配
不再需要复制粘贴 prompt不再需要盯着运行过程。你的 Agent 出现在看板上、参与对话、随着时间积累可复用的技能。可以理解为开源的 Managed Agents 基础设施——厂商中立、可自部署、专为人类 + AI 团队设计。支持 **Claude Code**、**Codex**、**GitHub Copilot CLI**、**OpenClaw**、**OpenCode**、**Hermes**、**Gemini**、**Pi**、**Cursor Agent**、**Kimi** 和 **Kiro CLI**
面向更大的团队Squads小队提供稳定的路由层把任务分给由 Agent 带队的小队,由队长判断谁最适合接手。
<p align="center">
<img src="docs/assets/hero-screenshot.png" alt="Multica 看板视图" width="800">
</p>
@@ -53,6 +55,7 @@ Multica——**Mul**tiplexed **I**nformation and **C**omputing **A**gent。
Multica 管理完整的 Agent 生命周期:从任务分配到执行监控再到技能复用。
- **Agent 即队友** — 像分配给同事一样分配给 Agent。它们有个人档案、出现在看板上、发表评论、创建 Issue、主动报告阻塞问题。
- **Squads小队** — 把多个 Agent以及人类成员组合成由 leader agent 带队的小队直接把任务分配给小队本身。Leader 会判断谁最适合接手,团队扩容时路由方式保持不变。用 `@前端组` 代替 `@小张或小李或小王`
- **自主执行** — 设置后无需管理。完整的任务生命周期管理(排队、认领、执行、完成/失败),通过 WebSocket 实时推送进度。
- **可复用技能** — 每个解决方案都成为全团队可复用的技能。部署、数据库迁移、代码审查——技能让团队能力随时间持续增长。
- **统一运行时** — 一个控制台管理所有算力。本地 daemon 和云端运行时,自动检测可用 CLI实时监控。
@@ -131,19 +134,6 @@ daemon 在后台运行,保持你的机器与 Multica 的连接。它会自动
---
## Multica vs Paperclip
| | Multica | Paperclip |
|---|---------|-----------|
| **定位** | 团队 AI Agent 协作平台 | 个人 AI Agent 公司模拟器 |
| **用户模型** | 多人团队,角色权限 | 单人 Board Operator |
| **Agent 交互** | Issue + Chat 对话 | Issue + Heartbeat |
| **部署** | 云端优先 | 本地优先 |
| **管理深度** | 轻量Issue / Project / Labels | 重度(组织架构 / 审批 / 预算) |
| **扩展** | Skills 系统 | Skills + 插件系统 |
**简单来说Multica 专为团队协作打造,让团队和 AI Agent 一起高效完成项目。**
## 架构
```

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

@@ -1,7 +1,10 @@
import { autoUpdater } from "electron-updater";
import { autoUpdater, UpdateDownloadedEvent } from "electron-updater";
import { app, BrowserWindow, ipcMain } from "electron";
autoUpdater.autoDownload = false;
// Silent background updates: electron-updater downloads on its own as soon
// as `update-available` fires; we only surface UI when the package is fully
// downloaded and ready to install on next quit.
autoUpdater.autoDownload = true;
autoUpdater.autoInstallOnAppQuit = true;
// Windows arm64 ships its own update metadata channel because
@@ -26,8 +29,39 @@ export type ManualUpdateCheckResult =
}
| { ok: false; error: string };
// Single-flight guard around checkForUpdates(). With autoDownload=true the
// startup, periodic, and manual triggers can all kick off downloads, and
// overlapping calls have caused duplicate download warnings in the past
// (see electronjs.org/docs/latest/api/auto-updater). Coalesce concurrent
// callers onto the same in-flight promise.
let inFlightCheck: Promise<unknown> | null = null;
function checkForUpdatesOnce(): Promise<unknown> {
if (inFlightCheck) return inFlightCheck;
const p = autoUpdater
.checkForUpdates()
.then((result) => {
// checkForUpdates resolves as soon as metadata is fetched; the actual
// download (when autoDownload=true) is exposed on result.downloadPromise.
// Without a handler a download failure becomes an unhandled rejection
// in the main process — Node may terminate it on future versions.
void (result as { downloadPromise?: Promise<unknown> } | null)?.downloadPromise?.catch(
(err) => {
console.error("Failed to download update:", err);
},
);
return result;
})
.finally(() => {
if (inFlightCheck === p) inFlightCheck = null;
});
inFlightCheck = p;
return p;
}
export function setupAutoUpdater(getMainWindow: () => BrowserWindow | null): void {
autoUpdater.on("update-available", (info) => {
// Forwarded for renderer-side state tracking only; the notification UI
// does not render an "available" affordance with autoDownload=true.
const win = getMainWindow();
win?.webContents.send("updater:update-available", {
version: info.version,
@@ -42,15 +76,20 @@ export function setupAutoUpdater(getMainWindow: () => BrowserWindow | null): voi
});
});
autoUpdater.on("update-downloaded", () => {
autoUpdater.on("update-downloaded", (info: UpdateDownloadedEvent) => {
const win = getMainWindow();
win?.webContents.send("updater:update-downloaded");
win?.webContents.send("updater:update-downloaded", {
version: info.version,
releaseNotes: info.releaseNotes,
});
});
autoUpdater.on("error", (err) => {
console.error("Auto-updater error:", err);
});
// Retained for IPC back-compat with older renderer bundles. With
// autoDownload=true the renderer no longer triggers this path.
ipcMain.handle("updater:download", () => {
return autoUpdater.downloadUpdate();
});
@@ -61,7 +100,9 @@ export function setupAutoUpdater(getMainWindow: () => BrowserWindow | null): voi
ipcMain.handle("updater:check", async (): Promise<ManualUpdateCheckResult> => {
try {
const result = await autoUpdater.checkForUpdates();
const result = (await checkForUpdatesOnce()) as
| { updateInfo: { version: string }; isUpdateAvailable?: boolean }
| null;
const currentVersion = app.getVersion();
// Trust electron-updater's own decision rather than re-deriving it from
// a version-string compare. The two diverge for pre-release channels,
@@ -85,7 +126,7 @@ export function setupAutoUpdater(getMainWindow: () => BrowserWindow | null): voi
// Initial check shortly after startup so we don't block boot.
setTimeout(() => {
autoUpdater.checkForUpdates().catch((err) => {
checkForUpdatesOnce().catch((err) => {
console.error("Failed to check for updates:", err);
});
}, STARTUP_CHECK_DELAY_MS);
@@ -93,7 +134,7 @@ export function setupAutoUpdater(getMainWindow: () => BrowserWindow | null): voi
// Background poll so long-running sessions still pick up new releases
// without requiring the user to restart the app.
setInterval(() => {
autoUpdater.checkForUpdates().catch((err) => {
checkForUpdatesOnce().catch((err) => {
console.error("Periodic update check failed:", err);
});
}, PERIODIC_CHECK_INTERVAL_MS);

View File

@@ -84,7 +84,9 @@ interface DaemonAPI {
interface UpdaterAPI {
onUpdateAvailable: (callback: (info: { version: string; releaseNotes?: string }) => void) => () => void;
onDownloadProgress: (callback: (progress: { percent: number }) => void) => () => void;
onUpdateDownloaded: (callback: () => void) => () => void;
onUpdateDownloaded: (
callback: (info: { version: string; releaseNotes?: string }) => void,
) => () => void;
downloadUpdate: () => Promise<void>;
installUpdate: () => Promise<void>;
checkForUpdates: () => Promise<

View File

@@ -207,8 +207,11 @@ const updaterAPI = {
ipcRenderer.on("updater:download-progress", handler);
return () => ipcRenderer.removeListener("updater:download-progress", handler);
},
onUpdateDownloaded: (callback: () => void) => {
const handler = () => callback();
onUpdateDownloaded: (
callback: (info: { version: string; releaseNotes?: string }) => void,
) => {
const handler = (_: unknown, info: { version: string; releaseNotes?: string }) =>
callback(info);
ipcRenderer.on("updater:update-downloaded", handler);
return () => ipcRenderer.removeListener("updater:update-downloaded", handler);
},

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

@@ -1,55 +1,27 @@
import { useCallback, useEffect, useState } from "react";
import { ArrowDownToLine, RefreshCw, X } from "lucide-react";
import { useEffect, useState } from "react";
import { RefreshCw, X } from "lucide-react";
// Downloads run silently in the background (main process has
// autoDownload=true). The renderer only renders UI once the package is fully
// downloaded and waiting for a restart.
type UpdateState =
| { status: "idle" }
| { status: "available"; version: string }
| { status: "downloading"; percent: number }
| { status: "ready" };
| { status: "ready"; version: string };
export function UpdateNotification() {
const [state, setState] = useState<UpdateState>({ status: "idle" });
const [dismissed, setDismissed] = useState(false);
useEffect(() => {
const cleanups: (() => void)[] = [];
cleanups.push(
window.updater.onUpdateAvailable((info) => {
setState({ status: "available", version: info.version });
setDismissed(false);
}),
);
cleanups.push(
window.updater.onDownloadProgress((progress) => {
setState({ status: "downloading", percent: progress.percent });
}),
);
cleanups.push(
window.updater.onUpdateDownloaded(() => {
setState({ status: "ready" });
}),
);
return () => cleanups.forEach((fn) => fn());
const cleanup = window.updater.onUpdateDownloaded((info) => {
setState({ status: "ready", version: info.version });
setDismissed(false);
});
return cleanup;
}, []);
const handleDownload = useCallback(() => {
// Prevent double-click: immediately transition to downloading state
if (state.status !== "available") return;
setState({ status: "downloading", percent: 0 });
window.updater.downloadUpdate();
}, [state.status]);
const handleInstall = useCallback(() => {
window.updater.installUpdate();
}, []);
// Only allow dismiss when update is available (not during download or ready)
if (state.status === "idle") return null;
if (dismissed && state.status === "available") return null;
if (dismissed) return null;
return (
<div className="fixed bottom-4 right-4 z-50 w-80 rounded-lg border border-border bg-background p-4 shadow-lg animate-in slide-in-from-bottom-2 fade-in duration-300">
@@ -60,78 +32,31 @@ export function UpdateNotification() {
<X className="size-3.5" />
</button>
{state.status === "available" && (
<div className="flex items-start gap-3">
<div className="mt-0.5 rounded-md bg-primary/10 p-1.5">
<ArrowDownToLine className="size-4 text-primary" />
</div>
<div className="flex-1 min-w-0">
<p className="text-sm font-medium">New version available</p>
<p className="text-xs text-muted-foreground mt-0.5">
v{state.version} is ready to download
</p>
<div className="flex items-start gap-3">
<div className="mt-0.5 rounded-md bg-success/10 p-1.5">
<RefreshCw className="size-4 text-success" />
</div>
<div className="flex-1 min-w-0">
<p className="text-sm font-medium">Update ready</p>
<p className="text-xs text-muted-foreground mt-0.5">
v{state.version} will be applied on next launch.
</p>
<div className="mt-2 flex items-center gap-1.5">
<button
onClick={handleDownload}
className="mt-2 inline-flex items-center rounded-md bg-primary px-3 py-1.5 text-xs font-medium text-primary-foreground hover:bg-primary/90 transition-colors"
onClick={() => setDismissed(true)}
className="inline-flex items-center rounded-md border border-border bg-background px-3 py-1.5 text-xs font-medium text-foreground hover:bg-accent transition-colors"
>
Download update
Later
</button>
<button
onClick={() => window.updater.installUpdate()}
className="inline-flex items-center rounded-md bg-primary px-3 py-1.5 text-xs font-medium text-primary-foreground hover:bg-primary/90 transition-colors"
>
Restart now
</button>
</div>
</div>
)}
{state.status === "downloading" && (
<div className="flex items-start gap-3">
<div className="mt-0.5 rounded-md bg-primary/10 p-1.5">
<ArrowDownToLine className="size-4 text-primary animate-pulse" />
</div>
<div className="flex-1 min-w-0">
<p className="text-sm font-medium">Downloading update...</p>
<div className="mt-2 h-1.5 w-full rounded-full bg-muted overflow-hidden">
<div
className="h-full rounded-full bg-primary transition-all duration-300"
style={{ width: `${Math.round(state.percent)}%` }}
/>
</div>
<p className="text-xs text-muted-foreground mt-1">
{Math.round(state.percent)}%
</p>
</div>
</div>
)}
{state.status === "ready" && (
<div className="flex items-start gap-3">
<div className="mt-0.5 rounded-md bg-success/10 p-1.5">
<RefreshCw className="size-4 text-success" />
</div>
<div className="flex-1 min-w-0">
<p className="text-sm font-medium">Update ready</p>
<p className="text-xs text-muted-foreground mt-0.5">
Restart to apply the update
</p>
<div className="mt-2 flex items-center gap-1.5">
{/* Secondary "See changes" — gives the user a reason to
restart by surfacing what they're about to get. Opens
in the default browser via the shared openExternal
bridge so the URL hits the same allow-list as every
other outbound link. */}
<button
onClick={() => window.desktopAPI.openExternal("https://multica.ai/changelog")}
className="inline-flex items-center rounded-md border border-border bg-background px-3 py-1.5 text-xs font-medium text-foreground hover:bg-accent transition-colors"
>
See changes
</button>
<button
onClick={handleInstall}
className="inline-flex items-center rounded-md bg-primary px-3 py-1.5 text-xs font-medium text-primary-foreground hover:bg-primary/90 transition-colors"
>
Restart now
</button>
</div>
</div>
</div>
)}
</div>
</div>
);
}

View File

@@ -32,7 +32,8 @@ export function UpdatesSettingsTab() {
<h2 className="text-lg font-semibold">Updates</h2>
<p className="text-sm text-muted-foreground mt-1">
The desktop app checks for new versions automatically once an hour and
shortly after launch.
shortly after launch, downloading them in the background. You&apos;ll
be prompted to restart once an update is ready.
</p>
<div className="mt-6 divide-y">
@@ -50,7 +51,8 @@ export function UpdatesSettingsTab() {
<p className="text-sm font-medium">Check for updates</p>
<p className="text-sm text-muted-foreground mt-0.5">
Trigger a check now instead of waiting for the next automatic
poll. Available updates appear as a notification in the corner.
poll. Available updates download in the background and show a
restart prompt when ready.
</p>
{state.status === "up-to-date" && (
<p className="text-sm text-muted-foreground mt-2 inline-flex items-center gap-1.5">
@@ -61,8 +63,8 @@ export function UpdatesSettingsTab() {
{state.status === "available" && (
<p className="text-sm text-muted-foreground mt-2 inline-flex items-center gap-1.5">
<ArrowDownToLine className="size-3.5 text-primary" />
v{state.latestVersion} is available see the download prompt
in the corner.
v{state.latestVersion} is downloading in the background
you&apos;ll be notified when it&apos;s ready to install.
</p>
)}
{state.status === "error" && (

View File

@@ -62,18 +62,25 @@ function WindowOverlayInner() {
{overlay.type === "invitations" && <InvitationsPage />}
{overlay.type === "onboarding" && (
<OnboardingFlow
onComplete={(ws) => {
onComplete={(ws, issueId) => {
close();
// Post-onboarding landing is always the workspace issues
// list. The welcome-issue flow moved into a dialog that
// renders on that page (StarterContentPrompt), so the
// flow doesn't need to thread a target issue id back here.
if (ws) {
// Runtime-connected onboarding lands on its single guide
// issue. Runtime-less exits still land on the issues list.
if (ws && issueId) {
push(paths.workspace(ws.slug).issueDetail(issueId));
} else if (ws) {
push(paths.workspace(ws.slug).issues());
} else {
push(paths.root());
}
}}
// Restart the bundled daemon when the user hits Refresh on
// Step 3. The daemon's PATH probe runs once at boot, so a
// newly-installed CLI (Claude / Codex / Cursor) doesn't show
// up until the daemon is bounced.
onRuntimeRefresh={async () => {
await window.daemonAPI?.restart?.();
}}
/>
)}
</div>

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

@@ -45,4 +45,5 @@ New agents default to **private**. To make one available to the whole workspace,
- [Create and configure an agent](/agents-create) — how to build one
- [Skills](/skills) — attach knowledge packs to an agent
- [Squads](/squads) — group agents under a leader so the right one picks up the right issue
- [Daemon and runtimes](/daemon-runtimes) — what an agent needs to actually run

View File

@@ -45,4 +45,5 @@ import { Callout } from "fumadocs-ui/components/callout";
- [创建和配置智能体](/agents-create) —— 怎么把一个智能体捏出来
- [Skills](/skills) —— 给智能体挂上专业知识包
- [小队](/squads) —— 把智能体编成一组,由队长决定谁接手哪条 issue
- [守护进程与运行时](/daemon-runtimes) —— 智能体真正跑起来需要什么

View File

@@ -5,7 +5,7 @@ description: Hand an issue to an agent and it takes over as the official assigne
import { Callout } from "fumadocs-ui/components/callout";
Assign an [issue](/issues) to an [agent](/agents) and it works as the **official assignee** until the work is done — it can read the full issue context (description + all [comments](/comments)) and change status, post comments, and edit fields. This is the **most common and heaviest** of Multica's four trigger paths.
Assign an [issue](/issues) to an [agent](/agents) and it works as the **official assignee** until the work is done — it can read the full issue context (description + all [comments](/comments)) and change status, post comments, and edit fields. This is the **most common and heaviest** of Multica's four trigger paths. The same flow also accepts a [squad](/squads) as the assignee — Multica then triggers the squad's **leader agent** instead.
| Path | When to use | Changes the issue | Context | Priority | Auto retry |
|---|---|---|---|---|---|
@@ -18,7 +18,7 @@ Assign an [issue](/issues) to an [agent](/agents) and it works as the **official
## Assign from the UI
On the issue detail page, click the **Assignee** picker. It lists every member in the workspace plus all non-archived agents. Pick an agent and the issue is assigned right away.
On the issue detail page, click the **Assignee** picker. It lists every member in the workspace, all non-archived agents, and every non-archived [squad](/squads). Pick an agent (or squad) and the issue is assigned right away.
A few rules:
@@ -78,5 +78,6 @@ But **different agents can work on the same issue in parallel** — for example,
## Next
- [**@-mention an agent in a comment**](/mentioning-agents) — a lighter trigger that leaves assignee and status untouched
- [**Squads**](/squads) — assign to a group of agents and let the leader decide who picks it up
- [**Chat**](/chat) — one-to-one conversation outside any issue
- [**Autopilots**](/autopilots) — let agents start work automatically on a schedule

View File

@@ -5,7 +5,7 @@ description: 把 issue 交给智能体,它作为正式负责人一直工作到
import { Callout } from "fumadocs-ui/components/callout";
把 [issue](/issues) 分配给 [智能体](/agents),它会作为**正式负责人**一直工作到结束——能读到 issue 的完整上下文(描述 + 所有 [评论](/comments)),也能改状态、发评论、改字段。这是 Multica 四种触发方式里**最常见也最"重"**的一种。
把 [issue](/issues) 分配给 [智能体](/agents),它会作为**正式负责人**一直工作到结束——能读到 issue 的完整上下文(描述 + 所有 [评论](/comments)),也能改状态、发评论、改字段。这是 Multica 四种触发方式里**最常见也最"重"**的一种。同样的流程也接受 [小队squad](/squads) 作为 assignee——这种情况下 Multica 会触发小队的**队长智能体**。
| 方式 | 何时用 | 改 issue | 上下文 | 优先级 | 自动重试 |
|---|---|---|---|---|---|
@@ -18,7 +18,7 @@ import { Callout } from "fumadocs-ui/components/callout";
## 在界面里分配
在 issue 详情页点 **Assignee** 选择器,会列出工作区里所有成员未归档的智能体。选一个智能体issue 立刻分给它
在 issue 详情页点 **Assignee** 选择器,会列出工作区里所有成员未归档的智能体、以及未归档的 [小队](/squads)。选一个智能体(或小队)issue 立刻分
几条规则:
@@ -78,5 +78,6 @@ multica issue assign MUL-42 --unassign
## 下一步
- [**在评论里 @ 智能体**](/mentioning-agents) —— 更轻量的触发方式,不改 assignee / status
- [**小队**](/squads) —— 把 issue 分给一组智能体,由队长决定谁接手
- [**对话**](/chat) —— 脱离 issue 和智能体一对一聊
- [**Autopilots**](/autopilots) —— 让智能体定时自动开工

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

@@ -79,6 +79,20 @@ For the difference between token types, see [Authentication and tokens](/auth-to
| `multica skill import ...` | Import a skill from GitHub, ClawHub, or the local machine |
| `multica skill files ...` | Nested: manage a skill's files |
## Squads
| Command | Purpose |
|---|---|
| `multica squad list` | List squads in the workspace |
| `multica squad get <id>` | Show a single squad |
| `multica squad create --name "..." --leader <agent>` | Create a squad (owner / admin) |
| `multica squad update <id> ...` | Update name, description, instructions, leader, or avatar |
| `multica squad delete <id>` | Archive (soft-delete) — transfers assigned issues to the leader |
| `multica squad member list/add/remove <squad-id>` | Manage squad members |
| `multica squad activity <issue-id> <action\|no_action\|failed> --reason "..."` | Used by squad leader agents to record an evaluation per turn |
See [Squads](/squads) for the full model.
## Autopilots
| Command | Purpose |

View File

@@ -79,6 +79,20 @@ Token 类型的详细区分见 [认证与令牌](/auth-tokens)。
| `multica skill import ...` | 从 GitHub / ClawHub / 本机导入 Skill |
| `multica skill files ...` | 嵌套:管理 Skill 的文件 |
## 小队
| 命令 | 用途 |
|---|---|
| `multica squad list` | 列出工作区里的小队 |
| `multica squad get <id>` | 查看一个小队 |
| `multica squad create --name "..." --leader <agent>` | 创建小队owner / admin|
| `multica squad update <id> ...` | 修改名字、描述、instructions、队长、头像 |
| `multica squad delete <id>` | 归档(软删除)—— 同时把分配给小队的 issue 转给队长 |
| `multica squad member list/add/remove <squad-id>` | 管理小队成员 |
| `multica squad activity <issue-id> <action\|no_action\|failed> --reason "..."` | 队长智能体每轮结束时调用,记录 evaluation |
完整模型见 [小队](/squads)。
## Autopilots
| 命令 | 用途 |

View File

@@ -70,7 +70,7 @@ If logic appears in both apps, it MUST be extracted to a shared package. There a
### Issue keys
Every issue has a human-readable key like `MUL-123`: workspace `issue_prefix` (3 letters, uppercase) + sequence number. The prefix is set at workspace creation and is never changed afterward.
Every issue has a human-readable key like `MUL-123`: workspace `issue_prefix` (uppercase letters and digits, typically 3 chars, max 10) + sequence number. Workspace admins can change the prefix in Settings → General; changing it renumbers every existing issue, so external references that embed the old prefix (PR titles, branch names, links in docs and chat) stop resolving.
### Comments in code

View File

@@ -70,7 +70,7 @@ monorepo 的包边界是硬约束:
### Issue 编号
每个 issue 有人类可读的编号,比如 `MUL-123`:工作区 `issue_prefix`3 个大写字母)+ 流水号。前缀在工作区创建时定,之后不可改
每个 issue 有人类可读的编号,比如 `MUL-123`:工作区 `issue_prefix`大写字母和数字,通常 3 个字符,最长 10 个)+ 流水号。工作区管理员可以在 Settings → General 中修改前缀;修改会让所有现有 issue 重新编号外部引用——PR 标题、分支名、文档与聊天里的链接——里的旧前缀会失效
### 代码注释

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

@@ -16,6 +16,10 @@ Same as mentioning a member — type `@` to open the picker and select an agent.
The `@mention` Markdown syntax, the picker, and `@all` semantics are covered in [**Comments**](/comments).
<Callout type="info">
**You can also `@`-mention a [squad](/squads) in a comment.** The same picker surfaces squads alongside members and agents; selecting one inserts `[@SquadName](mention://squad/<uuid>)` and triggers the squad's **leader agent** to coordinate a response — assignee and status stay untouched.
</Callout>
## How it differs from assignment
Both put the agent to work, but the mechanics are entirely different:
@@ -53,6 +57,7 @@ This guard **only blocks direct self-references.** Agent A @-mentioning agent B
## Next
- [**Squads**](/squads) — `@`-mention a squad to have the leader route the question to the right member
- [**Chat**](/chat) — one-to-one conversation outside any issue
- [**Autopilots**](/autopilots) — let agents start work automatically on a schedule
- [**Comments**](/comments) — `@mention` syntax, the picker, and `@all` semantics

View File

@@ -16,6 +16,10 @@ import { Callout } from "fumadocs-ui/components/callout";
`@mention` 的 Markdown 语法、picker 的用法、`@all` 的语义见 [**评论**](/comments)。
<Callout type="info">
**`@` 也可以指向 [小队squad](/squads)。** picker 里小队和成员、智能体并列;选中后会插入 `[@SquadName](mention://squad/<uuid>)`,触发小队的**队长智能体**来协调响应——assignee 和 status 都不会变。
</Callout>
## 和分配的差别
同样是让智能体工作,但机制完全不同:
@@ -53,6 +57,7 @@ import { Callout } from "fumadocs-ui/components/callout";
## 下一步
- [**小队**](/squads) —— `@` 一个小队,由队长把问题派给合适的成员
- [**对话**](/chat) —— 脱离 issue 和智能体一对一聊
- [**Autopilots**](/autopilots) —— 让智能体定时自动开工
- [**评论**](/comments) —— `@mention` 的语法、picker、`@all` 的语义

View File

@@ -16,8 +16,10 @@
"agents",
"agents-create",
"skills",
"squads",
"---How agents run---",
"daemon-runtimes",
"install-agent-runtime",
"tasks",
"providers",
"---Collaborating with agents---",

View File

@@ -15,6 +15,7 @@
"agents",
"agents-create",
"skills",
"squads",
"---智能体怎么运行---",
"daemon-runtimes",
"tasks",

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

@@ -0,0 +1,136 @@
---
title: Squads
description: "A squad is a group of agents (and optionally human members) led by one designated leader agent. Assign an issue to a squad and the leader decides who picks it up."
---
import { Callout } from "fumadocs-ui/components/callout";
A squad is a **named group of [agents](/agents) and human [members](/members-roles)**, with one designated **leader agent**. The squad is itself a first-class assignee: pick it from any **Assignee** picker and the leader takes the trigger, reads the issue, then `@`-mentions the squad member best suited to do the work. Squads let you assemble specialists once and dispatch them **by topic instead of by name** — the team grows, the routing stays the same.
## What a squad is, in mechanics
- **One leader, many members.** The leader must be an agent; members can be agents or human members. A squad with only the leader is allowed (the leader briefing notes "no other members"), and the same agent can sit in multiple squads.
- **Assignable everywhere a person is.** Squads appear in the Assignee picker, the @-mention picker, and the quick-create modal — anywhere you'd pick an agent or member, you can pick a squad.
- **Soft-deleted via archive.** Archive a squad and it disappears from pickers and lists; any issue currently assigned to it is **transferred to the leader agent** so the work doesn't go silent. Archived squads can't be assigned to new issues.
## When to use a squad versus a single agent
| Pick a squad when… | Pick a single agent when… |
|---|---|
| You have several specialists and don't know which one fits this issue in advance | The work is well-scoped to one specialty and you know who should do it |
| You want one stable assignee (the squad) while the actual responder changes per issue | You want the agent's name on the issue and clear individual accountability |
| You want a `@FrontendTeam` style routing target in comments | One-on-one `@agent-name` is enough |
The squad doesn't add capability — it adds **routing**. The members are still ordinary agents; the leader's only job is to pick the right one.
## Permissions
| Action | Who can do it |
|---|---|
| Create / update / archive a squad | Workspace **owner** or **admin** |
| Add or remove members, change roles | Workspace **owner** or **admin** |
| Assign an issue to a squad | Any workspace member (same as assigning to an agent) |
| `@`-mention a squad in a comment | Any workspace member |
| Record a squad-leader evaluation | The squad leader agent only (via CLI) |
The full role matrix lives in [Members and roles](/members-roles).
## Create a squad
In the sidebar, open **Squads → New squad** and fill in:
- **Name** — e.g. `Frontend Team`, `Bug Triage`. Doesn't need to be unique within the workspace.
- **Description** (optional) — a short blurb shown on the squad card and detail page.
- **Leader** — pick an existing agent. The leader is added to the squad automatically with role `leader`.
After creation, open the squad's detail page to:
- **Add members** — pick agents or human members, optionally give each a short role description (e.g. "owns the migrations", "reviewer of last resort"). The leader uses these roles when deciding who to delegate to.
- **Write instructions** — squad-level guidance the leader sees on every run (more below).
- **Set an avatar** — picked from the same picker used for agents.
CLI equivalent:
```bash
multica squad create --name "Frontend Team" --leader frontend-lead-agent
multica squad member add <squad-id> --member-id <agent-or-user-uuid> --type agent --role "Owns Tailwind / shadcn surface"
```
## How a squad-assigned issue runs
When a non-Backlog issue is assigned to a squad, Multica immediately enqueues a `task` for the **leader agent** (not for every member). The flow then looks like this:
1. **Leader claims the task.** The agent runtime picks up the task on its next poll, same as any other agent assignment.
2. **Leader is briefed.** On claim, Multica appends three sections to the leader's system prompt — see [What the leader sees on every turn](#what-the-leader-sees-on-every-turn) below.
3. **Leader posts one delegation comment.** The comment `@`-mentions the chosen member(s) using the exact mention markdown from the roster — that mention triggers a new `task` for each mentioned agent.
4. **Leader records its evaluation** via `multica squad activity <issue-id> action --reason "..."`. This writes an entry to the issue's activity timeline so humans can see the leader actually evaluated the trigger.
5. **Leader stops.** The leader does not do the implementation itself. When the delegated member posts back, the leader is re-triggered to read the update and either delegate the next step, escalate, or stay silent.
If the issue is in **Backlog**, the leader is not triggered — Backlog is a parking lot, same rule as for direct agent assignment.
### What the leader sees on every turn
On each squad-leader run, three blocks are appended to the leader's instructions:
- **Squad Operating Protocol** — a hard-coded rule set: read the issue, delegate by `@`-mention, be terse (don't restate the issue body — the assignee can read it), record an evaluation every turn, and **stop after dispatching**. This protocol is system-managed and not editable.
- **Squad Roster** — the leader's self-row plus one row per non-archived member. Each row carries the exact mention markdown (`[@Name](mention://agent/<uuid>)` or `[@Name](mention://member/<uuid>)`) the leader should paste — typing a plain `@name` won't trigger anyone.
- **Squad Instructions** — your custom guidance for this squad (set on the squad detail page or via `multica squad update --instructions`). Use this for routing rules ("send DB work to Alice, frontend to Bob"), escalation policies, or anything else the leader needs to know that isn't already in the issue.
## When the leader is re-triggered
After the first dispatch, the leader is woken up automatically by **most subsequent comments** on the issue. The exact rules:
| Event | Leader triggered? |
|---|---|
| A non-member (human reporter, external agent) posts a comment | **Yes** |
| A squad member posts a progress update with no `@mention` | **Yes** — the leader re-evaluates whether the next step is needed |
| Anyone posts a comment that explicitly `@`-mentions another agent / member / squad / `@all` | **No** — the explicit `@` is the routing signal; the leader gets out of the way |
| The leader's own comment (self-trigger) | **No** — guarded to prevent a loop |
| A comment containing only an issue cross-reference (`[MUL-123](mention://issue/...)`) | **Yes** — issue references aren't routing |
Dedup applies on top of these rules: if the leader already has a `queued` or `dispatched` task on this issue, a new trigger won't enqueue a duplicate.
<Callout type="info">
**Why the leader doesn't trigger when a member posts an `@`-mention.** Once a squad member directly `@`s someone, that comment is a deliberate hand-off — having the leader wake up to "observe" the routing would just produce a no-op turn and clutter the timeline. Agent-authored comments are the exception: when an agent posts a result that `@`s another agent, the leader still wakes up so it can coordinate the thread.
</Callout>
## `@`-mention a squad in a comment
Squads appear in the `@` picker alongside members and agents. Mentioning a squad inserts `[@SquadName](mention://squad/<uuid>)` and triggers the **squad leader** as if you had assigned the issue to the squad — without changing the assignee or the status. Use this when you want the squad to pick someone for a question or sub-task while keeping the current owner.
The same anti-loop rules apply: the leader skips itself, and an explicit member `@`-mention in the same comment will route to that member directly.
## Reassign or archive a squad
**Reassigning an issue away from a squad** behaves like any other assignee change: all of the issue's active tasks (including the leader's) are cancelled, and the new assignee — agent, member, or another squad — is enqueued. There is no separate "remove squad without changing assignee" action; pick a different assignee.
**Archiving a squad** (`multica squad delete <id>`, or the Archive button on the detail page):
1. **Transfers issues currently assigned to the squad to the leader agent**, so the work continues against a concrete agent instead of going silent.
2. Marks the squad with `archived_at` / `archived_by` — the row is preserved so historical activity entries still resolve, but the squad disappears from lists, pickers, and the @-mention dropdown.
3. **Rejects future assignments** to this squad with `cannot assign to an archived squad`.
There is currently no unarchive command; create a new squad if you need the routing back.
## Squad operations from the CLI
| Command | Purpose |
|---|---|
| `multica squad list` | List squads in the workspace |
| `multica squad get <id>` | Show one squad's name, leader, description, instructions |
| `multica squad create --name "..." --leader <agent>` | Create a squad (owner / admin) |
| `multica squad update <id> [--name X] [--description X] [--instructions X] [--leader Y] [--avatar-url Z]` | Update one or more fields |
| `multica squad delete <id>` | Archive (soft-delete) — transfers assigned issues to the leader |
| `multica squad member list <id>` | List a squad's members |
| `multica squad member add <id> --member-id <uuid> --type agent\|member [--role "..."]` | Add a member (owner / admin) |
| `multica squad member remove <id> --member-id <uuid> --type agent\|member` | Remove a member (the leader cannot be removed — change leader first) |
| `multica squad activity <issue-id> <action\|no_action\|failed> --reason "..."` | Recorded by the leader agent at the end of every turn |
`--leader` accepts an agent name or UUID; for everything else, IDs come from `multica agent list --output json`, `multica workspace members --output json`, and `multica squad list --output json`.
## Next
- [Assign issues to agents](/assigning-issues) — same flow, applies to squad assignees too
- [`@`-mention agents in comments](/mentioning-agents) — the `@` picker also surfaces squads
- [Agents](/agents) — what an agent is, the building block of every squad
- [Members and roles](/members-roles) — the full owner / admin / member permission matrix

View File

@@ -0,0 +1,136 @@
---
title: 小队
description: 小队squad是一组智能体可选附带成员由一名指定的"队长"智能体leader领导。把 issue 分配给小队,队长来决定谁接手。
---
import { Callout } from "fumadocs-ui/components/callout";
小队squad是一组 [智能体](/agents) 和 [人类成员](/members-roles) 的**命名集合**,其中有一名指定的**队长leader必须是智能体**。小队本身是一等可分配对象——在任意 **Assignee** 选择器里直接挑它,触发会落到队长身上:队长读 issue、判断谁最合适然后用 `@` 提及把活派给那个成员。小队让你把一组专家**一次性编好队**,之后**按主题派活,而不是按名字派活**——队伍扩展,路由不变。
## 小队的运转机制
- **一个队长,多名成员。** 队长必须是智能体;成员可以是智能体或人类成员。只有队长一个人的小队也是允许的(队长 briefing 会注明"没有其他成员"),同一个智能体也能加入多个小队。
- **任何能选人的地方都能选小队。** Assignee picker、@ 提及 picker、快速创建 modal——只要能选智能体或成员的位置小队都会出现。
- **删除走"归档"软删除。** 归档一个小队后,它会从 picker 和列表里消失;当前分配给它的 issue 会被**自动转给队长智能体**,让工作不至于卡住。归档的小队不能再被分配新 issue。
## 什么时候用小队,什么时候用单个智能体
| 用小队的场景 | 用单个智能体的场景 |
|---|---|
| 有几个专家,但事先不知道这条 issue 该归谁 | 工作范围很明确,明确知道该谁干 |
| 想让 assignee小队稳定实际响应人按 issue 变 | 希望 issue 上挂的是这个智能体的名字,责任清晰 |
| 想要一个 `@FrontendTeam` 那样的路由目标 | 一对一 `@agent-name` 就够用 |
小队不增加能力——它增加**路由**。成员还是那些智能体,队长唯一的工作是**挑对人**。
## 权限
| 操作 | 谁能做 |
|---|---|
| 创建 / 更新 / 归档小队 | 工作区 **owner** 或 **admin** |
| 增删成员、改成员角色 | 工作区 **owner** 或 **admin** |
| 把 issue 分配给小队 | 任何工作区成员(和分配给智能体一样)|
| 在评论里 `@` 小队 | 任何工作区成员 |
| 记录小队队长的 evaluation | 只有队长智能体本人(通过 CLI|
完整角色权限对照见 [成员与权限](/members-roles)。
## 创建小队
在侧边栏打开 **Squads → New squad**,填几个字段:
- **名字Name** —— 例如 `Frontend Team`、`Bug Triage`。在工作区里**不要求唯一**。
- **描述Description可选** —— 一句话简介,展示在小队卡片和详情页上。
- **队长Leader** —— 选一个已有的智能体。创建后队长会自动以 `leader` 角色加入小队。
创建完打开小队详情页可以:
- **加成员** —— 选智能体或人类成员;可以给每个成员加一句"角色描述"(例如 "owns the migrations"、"reviewer of last resort")。队长派活时会参考这些角色。
- **写 instructions** —— 小队级别的指令,队长每次执行都能看到(见下文)。
- **设头像** —— 用和智能体一样的头像选择器。
CLI 等价命令:
```bash
multica squad create --name "Frontend Team" --leader frontend-lead-agent
multica squad member add <squad-id> --member-id <agent-or-user-uuid> --type agent --role "Owns Tailwind / shadcn surface"
```
## 分配给小队的 issue 是怎么跑的
非 Backlog 状态的 issue 一旦分配给小队Multica 会立刻给**队长智能体**入队一个 `task`(不是给每个成员都入一个)。整个流程是这样的:
1. **队长领走 task。** 队长所在的 daemon 在下次轮询时把 task 领走,和普通智能体的分配流程一样。
2. **队长拿到 briefing。** 领走的瞬间Multica 会在队长的系统提示后面追加三段内容——详见下文 [队长每次执行看到的内容](#队长每次执行看到的内容)。
3. **队长发一条"派活"评论。** 评论里用 roster 里给好的 mention markdown `@` 选中的成员——这个 `@` 会触发被派的成员入队新 `task`。
4. **队长记录 evaluation** `multica squad activity <issue-id> action --reason "..."`。这一行会写进 issue 的 activity 时间线,方便人类回溯队长确实评估过这一次触发。
5. **队长停下。** 派完活,队长**不动手干活**。当被派的成员有回复时,队长会被自动唤醒,决定下一步:继续派活、上抛给人类、还是保持沉默。
如果 issue 是 **Backlog** 状态队长不会被触发——Backlog 是停泊场,规则和直接分配给智能体一样。
### 队长每次执行看到的内容
每次队长被触发,三段内容会被附加到它的 instructions 上:
- **Squad Operating Protocol小队工作规范** —— 一段硬编码的规则集:读 issue → 用 `@` 派活 → 简洁(**不要**复述 issue 内容,被派的成员自己能读)→ 每次都记 evaluation → **派完就停**。这段是系统管理的,不可编辑。
- **Squad Roster小队花名册** —— 队长自己一行 + 每个未归档成员一行。每一行带上**确切可用**的 mention markdown`[@Name](mention://agent/<uuid>)` 或 `[@Name](mention://member/<uuid>)`)让队长直接复制——纯文本 `@name` 是**不会**触发任何人的。
- **Squad Instructions小队自定义指令** —— 你为这个小队写的私货(在详情页里编辑,或用 `multica squad update --instructions`)。用来写路由规则("DB 相关派给 Alice前端派给 Bob")、上报策略,或者任何 issue 本身不会有的背景。
## 队长什么时候会被再次触发
第一次派活完之后,**大多数后续评论**都会自动唤醒队长。具体规则:
| 事件 | 触发队长?|
|---|---|
| 非小队成员(人类 reporter、外部智能体发评论 | **会** |
| 小队成员发"进展更新"**不带任何** `@mention` | **会**——队长重新评估是否需要下一步 |
| 任何人发的评论里**显式 `@`** 智能体 / 成员 / 小队 / `@all` | **不会**——显式 `@` 就是路由信号,队长让位 |
| 队长自己发的评论 | **不会**——硬编码防自触发 |
| 评论里只有 issue 互链 `[MUL-123](mention://issue/...)` | **会**——issue 引用不算路由 |
以上规则之上还有去重:如果队长在这个 issue 上已经有 `queued` 或 `dispatched` 的 task新一次触发不会重复入队。
<Callout type="info">
**为什么成员发的 `@` 评论不会唤醒队长。** 小队成员一旦直接 `@` 谁,那条评论就是**有意识的交接**——再让队长唤醒一次"观察"路由,只会产出一次空回合、把时间线搞乱。智能体作者的评论是个例外:当某个智能体发出一条结果还顺手 `@` 了另一个智能体时,队长仍然会被唤醒,以便协调整条线程。
</Callout>
## 在评论里 `@` 一个小队
小队会出现在 `@` picker 里,和成员、智能体并列。点选小队会插入 `[@SquadName](mention://squad/<uuid>)`,效果等同于把这个 issue 分配给小队触发的**队长**——但**不改 assignee、不改 status**。适合"我想让小队挑个人回答一下/做一小步,但 issue 还归原来的人"这种场景。
防循环规则同样适用:队长跳过自己;同一条评论里如果还显式 `@` 了某个成员,路由会直接落到那个成员。
## 重新分配或归档一个小队
**把分配人从小队改成别的**,行为和换 assignee 完全一致:当前 issue 上所有活跃 task包括队长的会被取消新的 assignee智能体、成员、或另一个小队被入队。没有"不改 assignee 只移除小队"的单独操作;要换就选新的 assignee。
**归档小队**`multica squad delete <id>`,或详情页的 Archive 按钮):
1. **当前分配给这个小队的 issue 会被自动转给队长智能体**,让工作落到一个具体智能体上,避免无人接手。
2. 在 squad 表上写入 `archived_at` / `archived_by`——记录被保留下来,历史的 activity 还能解析但从列表、picker、`@` 下拉里它都消失。
3. **拒绝后续分配**——`cannot assign to an archived squad`。
目前没有"反归档"命令;要恢复路由,重新建一个小队即可。
## CLI 命令
| 命令 | 用途 |
|---|---|
| `multica squad list` | 列出工作区里的小队 |
| `multica squad get <id>` | 查看小队的名字、队长、描述、instructions |
| `multica squad create --name "..." --leader <agent>` | 创建小队owner / admin|
| `multica squad update <id> [--name X] [--description X] [--instructions X] [--leader Y] [--avatar-url Z]` | 修改一个或多个字段 |
| `multica squad delete <id>` | 归档(软删除)——同时把当前分配给小队的 issue 转给队长 |
| `multica squad member list <id>` | 列出小队成员 |
| `multica squad member add <id> --member-id <uuid> --type agent\|member [--role "..."]` | 加成员owner / admin|
| `multica squad member remove <id> --member-id <uuid> --type agent\|member` | 移除成员(**不能移除队长**——先换队长)|
| `multica squad activity <issue-id> <action\|no_action\|failed> --reason "..."` | 队长每次结束前由它自己调用 |
`--leader` 接受智能体名字或 UUID其它 ID 从 `multica agent list --output json`、`multica workspace members --output json`、`multica squad list --output json` 拿。
## 下一步
- [分配 issue 给智能体](/assigning-issues) —— 流程相同,对小队 assignee 也适用
- [在评论里 `@` 智能体](/mentioning-agents) —— `@` picker 同样能选到小队
- [智能体](/agents) —— 小队的"零件"
- [成员与权限](/members-roles) —— owner / admin / member 的完整权限对照

View File

@@ -13,7 +13,7 @@ Three things get decided when you create a workspace:
- **Workspace name** — the display name members see. Spaces and non-ASCII characters are allowed. You can change it later.
- **Slug** — the string used in the workspace URL. Lowercase letters and digits only (joined with `-`). **It cannot be changed after creation**, so pick carefully. If the slug is taken or hits a system-reserved word, the create screen will ask you to choose another.
- **Issue prefix** — the prefix for every issue number in the workspace (the `MUL` in `MUL-123`). Use uppercase letters.
- **Issue prefix** — the prefix for every issue number in the workspace (the `MUL` in `MUL-123`). Uppercase letters and digits, up to 10 characters.
<Callout type="warning">
**Avoid changing the issue prefix.** Issue numbers are rendered with the current prefix — change it and `MUL-5` instantly becomes `NEW-5`. Every external link, Slack mention, and historical reference in comments breaks against the old number. Treat the issue prefix as "set at creation, never touched."

View File

@@ -13,7 +13,7 @@ import { Callout } from "fumadocs-ui/components/callout";
- **工作区名字** — 给成员看的显示名称,可以包含空格和中文。后续随时能改。
- **Slug短链标识符** — 工作区 URL 中使用的字符串,只能是小写字母和数字(用 `-` 连接)。**创建后不能改**,提前想好。如果 slug 已被占用或命中系统保留词,创建界面会让你换一个。
- **Issue 前缀** — 工作区里所有 issue 编号的前缀(比如 `MUL-123` 里的 `MUL`)。使用大写字母。
- **Issue 前缀** — 工作区里所有 issue 编号的前缀(比如 `MUL-123` 里的 `MUL`)。只能是大写字母和数字,最长 10 个字符
<Callout type="warning">
**尽量不要修改 issue 前缀。** 系统在展示 issue 编号时会用当前的前缀——改了之后,`MUL-5` 会立刻变成 `NEW-5`。所有外部链接、Slack 提及、评论里的历史引用都会对不上旧编号。把 issue 前缀当成"创建后不改"的设计来对待。

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

@@ -1,6 +1,6 @@
"use client";
import { useEffect } from "react";
import { useEffect, useRef } from "react";
import { useRouter } from "next/navigation";
import { useQuery } from "@tanstack/react-query";
import { useAuthStore } from "@multica/core/auth";
@@ -17,9 +17,9 @@ import { CliInstallInstructions, OnboardingFlow } from "@multica/views/onboardin
* web (matching `WindowOverlay` on desktop); content is the shared
* `<OnboardingFlow />`. Kept minimal — guard on auth, render, exit.
*
* On complete: if a workspace was just created, navigate into it;
* otherwise fall back to root (proxy / landing picks the user's first ws
* or bounces to onboarding if still zero).
* On complete: runtime-connected onboarding may provide a guide issue id;
* navigate there. Otherwise land on the workspace issues list, or root if
* the flow never produced a workspace.
*
* `CliInstallInstructions` is passed in as the `runtimeInstructions`
* slot so the flow can render it inside the CLI dialog. The commands it
@@ -34,6 +34,14 @@ export default function OnboardingPage() {
...workspaceListOptions(),
enabled: !!user,
});
// The bootstrap path calls refreshMe() before returning, which flips
// hasOnboarded to true while the page is still mounted. Without this
// flag the guard below races onComplete: the guard's router.replace
// (issues list) can overtake onComplete's router.push (guide issue),
// dropping the user on the wrong destination. Marking the page as
// "completing" right before onComplete navigates keeps the guard
// silent for the in-flight transition.
const completingRef = useRef(false);
useEffect(() => {
if (isLoading || !user) {
@@ -41,6 +49,7 @@ export default function OnboardingPage() {
return;
}
if (!workspacesFetched) return;
if (completingRef.current) return;
// Bounce out only when onboarding genuinely doesn't apply: the user is
// already onboarded. We deliberately don't bounce on `workspaces.length`
// here — Step 3 of the flow creates a workspace mid-onboarding, and a
@@ -62,12 +71,14 @@ export default function OnboardingPage() {
return (
<div className="h-full overflow-y-auto bg-background">
<OnboardingFlow
onComplete={(ws) => {
// No more firstIssueId handoff — the welcome issue is created
// inside the workspace via StarterContentPrompt, not during
// onboarding. Always land on the workspace issues list (or
// root if the flow never produced a workspace).
if (ws) {
onComplete={(ws, issueId) => {
// Runtime-connected onboarding now creates one focused
// onboarding issue. Skip/runtime-less exits still land on the
// workspace issues list.
completingRef.current = true;
if (ws && issueId) {
router.push(paths.workspace(ws.slug).issueDetail(issueId));
} else if (ws) {
router.push(paths.workspace(ws.slug).issues());
} else {
router.push(paths.root());

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,83 @@ 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",
title: "Squads & Attachment Previews",
changes: [],
features: [
"Squads let teams assign work to a group, with a leader agent coordinating the next step",
"Attachments can be previewed in place for PDFs, audio, video, markdown, code, logs, and plain text",
"Chinese names can be found by pinyin across mentions, assignees, subscribers, agents, projects, and squads",
],
improvements: [
"Squad pages now include member management, faster agent creation from a squad, clearer row actions, and a wider detail layout",
"Quick-create and picker flows are easier to search and now include squad-aware routing",
"Usage charts can switch between cost and token views, with the same timezone controls used by runtimes",
"Workspace operators get command-line controls for managing squads and stopping a runaway issue run",
"Shared interface labels are translated more consistently in English and Chinese",
],
fixes: [
"Squad leaders stay quiet when a human already routed the conversation to someone specific",
"Mentioning a squad now wakes the right leader while preserving private-agent access rules",
"Issue lists stay fresher after deletes and follow-up comments no longer trigger stale Done replies",
"Attachment previews keep working for files added while writing or editing issues and comments",
],
},
{
version: "0.2.32",
date: "2026-05-13",

View File

@@ -284,6 +284,82 @@ 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",
title: "Squads 与附件预览",
changes: [],
features: [
"Squads 支持把任务交给一个小组,由 leader agent 负责协调下一步",
"附件可以直接预览,支持 PDF、音频、视频、Markdown、代码、日志和纯文本",
"中文姓名支持用拼音搜索,适用于 mention、负责人、订阅人、agents、projects 和 squads",
],
improvements: [
"Squad 页面补齐成员管理、从 squad 内快速创建 agent、清晰的成员操作按钮以及更宽的详情布局",
"快速创建和各类选择器更容易搜索,并能识别 squad 相关的指派和提及",
"Usage 图表可以在费用和 token 视图之间切换,并复用 runtime 的时区控制",
"工作区管理员可以通过命令行管理 squads并在必要时停止失控的 issue 执行",
"共享界面文案的中英文翻译更完整",
],
fixes: [
"当成员已经明确把讨论指向某个人或小组时Squad leader 不再重复发言",
"提及 squad 时会正确唤起对应 leader同时保留私有 agent 的访问限制",
"删除 Issue 后列表刷新更准确,后续评论也不再触发过期的 Done 回复",
"在撰写或编辑 issue 和评论时新增的附件,也可以稳定使用预览",
],
},
{
version: "0.2.32",
date: "2026-05-13",

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

@@ -0,0 +1,111 @@
import { test, expect } from "@playwright/test";
import { TestApiClient } from "./fixtures";
// Smoke test for Onboarding V2: verifies the new per-question flow
// renders and captures screenshots for review. Uses a unique email
// per run so the user is always a fresh, un-onboarded user landing
// on /onboarding.
const EMAIL = `onboarding-v2-${Date.now()}@localhost`;
const SHOTS_DIR = "/tmp/onboarding-v2-shots";
test.use({ viewport: { width: 1440, height: 900 } });
test("onboarding v2 — welcome → source → role → use_case (skip path)", async ({ page }) => {
const api = new TestApiClient();
await api.login(EMAIL, "OBv2 Tester");
const token = api.getToken();
await page.goto("/login");
await page.evaluate((t) => {
localStorage.setItem("multica_token", t);
}, token);
await page.goto("/onboarding");
await page.waitForLoadState("networkidle");
// 1. Welcome screen
await expect(page.getByRole("button", { name: "Continue on web" })).toBeVisible({ timeout: 15000 });
await page.screenshot({ path: `${SHOTS_DIR}/01-welcome.png`, fullPage: false });
// Click Start exploring to advance to Source
await page.getByRole("button", { name: "Continue on web" }).click();
// 2. Source step
await expect(page.getByText("How did you hear about Multica?")).toBeVisible({ timeout: 10000 });
await expect(page.getByText(`Step 1 of 6`)).toBeVisible();
await page.waitForTimeout(500);
await page.screenshot({ path: `${SHOTS_DIR}/02-source.png` });
// Pick Friends/colleagues then click Continue to advance.
await page.getByRole("radio", { name: /Friends or colleagues/i }).click();
await page.getByRole("button", { name: "Continue" }).click();
// 3. Role step
await expect(page.getByText("Which best describes you?")).toBeVisible({ timeout: 10000 });
await expect(page.getByText(`Step 2 of 6`)).toBeVisible();
await page.waitForTimeout(500);
await page.screenshot({ path: `${SHOTS_DIR}/03-role.png` });
// Skip role
await page.getByRole("button", { name: "Skip" }).click();
// 4. Use case step
await expect(page.getByText("What do you want to use Multica for?")).toBeVisible({ timeout: 10000 });
await expect(page.getByText(`Step 3 of 6`)).toBeVisible();
await page.waitForTimeout(500);
await page.screenshot({ path: `${SHOTS_DIR}/04-use-case.png` });
// Pick ship_code then Continue → workspace step.
await page.getByRole("radio", { name: /Ship code with AI agents/i }).click();
await page.getByRole("button", { name: "Continue" }).click();
// 5. Workspace step (legacy)
await expect(page.getByRole("heading", { name: /Name your workspace/i })).toBeVisible({ timeout: 10000 });
await page.screenshot({ path: `${SHOTS_DIR}/05-workspace.png` });
});
test("onboarding v2 — rage-skip all 3 questions", async ({ page }) => {
const api = new TestApiClient();
await api.login(`rage-skip-${Date.now()}@localhost`, "Rage Skipper");
const token = api.getToken();
await page.goto("/login");
await page.evaluate((t) => localStorage.setItem("multica_token", t), token);
await page.goto("/onboarding");
await page.waitForLoadState("networkidle");
await page.getByRole("button", { name: "Continue on web" }).click();
await expect(page.getByText("How did you hear about Multica?")).toBeVisible({ timeout: 10000 });
// Skip × 3
await page.getByRole("button", { name: "Skip" }).click();
await expect(page.getByText("Which best describes you?")).toBeVisible({ timeout: 10000 });
await page.getByRole("button", { name: "Skip" }).click();
await expect(page.getByText("What do you want to use Multica for?")).toBeVisible({ timeout: 10000 });
await page.getByRole("button", { name: "Skip" }).click();
// Lands on workspace step
await expect(page.getByRole("heading", { name: /Name your workspace/i })).toBeVisible({ timeout: 10000 });
await page.screenshot({ path: `${SHOTS_DIR}/06-after-rage-skip.png` });
});
test("onboarding v2 — zh-Hans renders Chinese labels", async ({ page, context }) => {
await context.addCookies([
{ name: "multica-locale", value: "zh-Hans", url: "http://localhost:13442" },
]);
const api = new TestApiClient();
await api.login(`zh-${Date.now()}@localhost`, "中文用户");
const token = api.getToken();
await page.goto("/login");
await page.evaluate((t) => localStorage.setItem("multica_token", t), token);
await page.goto("/onboarding");
await page.waitForLoadState("networkidle");
await page.getByRole("button").first().click().catch(() => {});
// Source screen — Chinese question
await expect(page.getByText("你是从哪里了解到 Multica 的?")).toBeVisible({ timeout: 10000 });
await page.waitForTimeout(500);
await page.screenshot({ path: `${SHOTS_DIR}/07-source-zh.png` });
});

View File

@@ -0,0 +1,9 @@
export {
useAgentsViewStore,
type AgentsScope,
type AgentsViewState,
} from "./view-store";
export {
useTranscriptViewStore,
type TranscriptSortDirection,
} from "./transcript-view-store";

View File

@@ -0,0 +1,22 @@
import { beforeEach, describe, expect, it } from "vitest";
import { useTranscriptViewStore } from "./transcript-view-store";
beforeEach(() => {
useTranscriptViewStore.setState({ sortDirection: "chronological" });
});
describe("useTranscriptViewStore", () => {
it("defaults to chronological so existing readers see no behavior change", () => {
expect(useTranscriptViewStore.getState().sortDirection).toBe("chronological");
});
it("setSortDirection switches between the two known directions", () => {
const { setSortDirection } = useTranscriptViewStore.getState();
setSortDirection("newest_first");
expect(useTranscriptViewStore.getState().sortDirection).toBe("newest_first");
setSortDirection("chronological");
expect(useTranscriptViewStore.getState().sortDirection).toBe("chronological");
});
});

View File

@@ -0,0 +1,26 @@
"use client";
import { create } from "zustand";
import { createJSONStorage, persist } from "zustand/middleware";
import { defaultStorage } from "../../platform/storage";
export type TranscriptSortDirection = "chronological" | "newest_first";
interface TranscriptViewState {
sortDirection: TranscriptSortDirection;
setSortDirection: (dir: TranscriptSortDirection) => void;
}
export const useTranscriptViewStore = create<TranscriptViewState>()(
persist(
(set) => ({
sortDirection: "chronological",
setSortDirection: (sortDirection) => set({ sortDirection }),
}),
{
name: "multica_transcript_view",
storage: createJSONStorage(() => defaultStorage),
partialize: (state) => ({ sortDirection: state.sortDirection }),
},
),
);

View File

@@ -0,0 +1,96 @@
// @vitest-environment jsdom
import { afterEach, beforeAll, beforeEach, describe, expect, it } from "vitest";
import { useAgentsViewStore } from "./view-store";
import { setCurrentWorkspace } from "../../platform/workspace-storage";
const flush = () => new Promise((resolve) => queueMicrotask(() => resolve(null)));
// Node 25 ships a partial `localStorage` shim under jsdom that's missing
// `clear`/`removeItem`; replace it with a real in-memory Storage so persist
// can round-trip values.
beforeAll(() => {
if (typeof globalThis.localStorage?.clear !== "function") {
const values = new Map<string, string>();
const storage: Storage = {
get length() { return values.size; },
clear: () => values.clear(),
getItem: (k) => values.get(k) ?? null,
key: (i) => Array.from(values.keys())[i] ?? null,
removeItem: (k) => { values.delete(k); },
setItem: (k, v) => { values.set(k, v); },
};
Object.defineProperty(globalThis, "localStorage", { configurable: true, value: storage });
Object.defineProperty(window, "localStorage", { configurable: true, value: storage });
}
});
beforeEach(() => {
localStorage.clear();
useAgentsViewStore.setState({ scope: "mine" });
setCurrentWorkspace(null, null);
});
afterEach(() => {
setCurrentWorkspace(null, null);
});
describe("useAgentsViewStore", () => {
it("defaults to 'mine'", () => {
expect(useAgentsViewStore.getState().scope).toBe("mine");
});
it("setScope mutates the store", () => {
useAgentsViewStore.getState().setScope("all");
expect(useAgentsViewStore.getState().scope).toBe("all");
});
it("partialize persists only scope under the workspace-namespaced key", async () => {
setCurrentWorkspace("acme", "ws_a");
await flush();
useAgentsViewStore.getState().setScope("all");
const raw = localStorage.getItem("multica_agents_view:acme");
expect(raw).not.toBeNull();
const parsed = JSON.parse(raw as string);
expect(parsed.state).toEqual({ scope: "all" });
});
it("rehydrates a different saved scope on workspace switch", async () => {
localStorage.setItem(
"multica_agents_view:acme",
JSON.stringify({ state: { scope: "all" }, version: 0 }),
);
localStorage.setItem(
"multica_agents_view:beta",
JSON.stringify({ state: { scope: "mine" }, version: 0 }),
);
setCurrentWorkspace("acme", "ws_a");
await flush();
await flush();
expect(useAgentsViewStore.getState().scope).toBe("all");
setCurrentWorkspace("beta", "ws_b");
await flush();
await flush();
expect(useAgentsViewStore.getState().scope).toBe("mine");
});
it("resets to 'mine' when switching to a workspace with no persisted value", async () => {
localStorage.setItem(
"multica_agents_view:acme",
JSON.stringify({ state: { scope: "all" }, version: 0 }),
);
setCurrentWorkspace("acme", "ws_a");
await flush();
await flush();
expect(useAgentsViewStore.getState().scope).toBe("all");
setCurrentWorkspace("beta", "ws_b");
await flush();
await flush();
expect(useAgentsViewStore.getState().scope).toBe("mine");
expect(localStorage.getItem("multica_agents_view:acme")).not.toBeNull();
});
});

View File

@@ -0,0 +1,40 @@
"use client";
import { create } from "zustand";
import { createJSONStorage, persist } from "zustand/middleware";
import {
createWorkspaceAwareStorage,
registerForWorkspaceRehydration,
} from "../../platform/workspace-storage";
import { defaultStorage } from "../../platform/storage";
export type AgentsScope = "mine" | "all";
export interface AgentsViewState {
scope: AgentsScope;
setScope: (scope: AgentsScope) => void;
}
export const useAgentsViewStore = create<AgentsViewState>()(
persist(
(set) => ({
scope: "mine",
setScope: (scope) => set({ scope }),
}),
{
name: "multica_agents_view",
storage: createJSONStorage(() => createWorkspaceAwareStorage(defaultStorage)),
partialize: (state) => ({ scope: state.scope }),
// On rehydrate, if the new workspace has no persisted value, reset to
// the default "mine" instead of leaving the previous workspace's in-
// memory scope in place. Default merge keeps current state when
// persisted is undefined, which would leak "all" across workspaces.
merge: (persisted, current) => {
if (!persisted) return { ...current, scope: "mine" };
return { ...current, ...(persisted as Partial<AgentsViewState>) };
},
},
),
);
registerForWorkspaceRehydration(() => useAgentsViewStore.persist.rehydrate());

View File

@@ -1,20 +1,22 @@
"use client";
import { useQuery } from "@tanstack/react-query";
import { agentListOptions } from "../workspace/queries";
import { agentListOptions, squadListOptions } from "../workspace/queries";
import { runtimeListOptions } from "../runtimes/queries";
import { agentTaskSnapshotOptions } from "./queries";
// Subscribe to the three queries that power agent presence so they're warm
// by the time any hover card / inline indicator first renders. Without this
// warm-up, surfaces that don't otherwise touch the snapshot (inbox, issues,
// chat) flash a skeleton on first hover while the fetch is in flight.
// Subscribe to the queries that power agent presence and the @mention
// suggestion list so they're warm by the time any hover card / inline
// indicator / mention popup first renders. Without this warm-up, surfaces
// that don't otherwise touch the snapshot (inbox, issues, chat) flash a
// skeleton on first hover while the fetch is in flight, and the @mention
// list may show incomplete results (e.g. missing squads).
//
// useRealtimeSync (WS task / agent / daemon invalidations) and the 30s
// presence tick keep these caches fresh after the initial fetch — this hook
// only collapses the cold-start window.
// useRealtimeSync (WS task / agent / daemon / squad invalidations) and the
// 30s presence tick keep these caches fresh after the initial fetch — this
// hook only collapses the cold-start window.
//
// All three are workspace-scoped; the queryKeys include wsId so workspace
// All queries are workspace-scoped; the queryKeys include wsId so workspace
// switch automatically refetches the new workspace's data with no extra
// wiring here. The workspace-scoped layouts on both apps gate rendering on
// "workspace resolved", so callers can safely pass useWorkspaceId() — by the
@@ -23,4 +25,5 @@ export function useWorkspacePresencePrefetch(wsId: string | undefined): void {
useQuery({ ...agentListOptions(wsId ?? ""), enabled: !!wsId });
useQuery({ ...runtimeListOptions(wsId ?? ""), enabled: !!wsId });
useQuery({ ...agentTaskSnapshotOptions(wsId ?? ""), enabled: !!wsId });
useQuery({ ...squadListOptions(wsId ?? ""), enabled: !!wsId });
}

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,
@@ -21,6 +23,9 @@ import type {
AgentRunCount,
AgentRuntime,
InboxItem,
InboxFilterScope,
InboxScopeCounts,
InboxResourceAvailability,
IssueSubscriber,
Comment,
Reaction,
@@ -45,6 +50,7 @@ import type {
DashboardUsageDaily,
DashboardUsageByAgent,
DashboardAgentRunTime,
DashboardRunTimeDaily,
RuntimeUpdate,
RuntimeModelListRequest,
RuntimeLocalSkillListRequest,
@@ -86,6 +92,8 @@ import type {
ListAutopilotsResponse,
GetAutopilotResponse,
ListAutopilotRunsResponse,
ListWebhookDeliveriesResponse,
WebhookDelivery,
NotificationPreferenceResponse,
NotificationPreferences,
GitHubPullRequest,
@@ -93,6 +101,7 @@ import type {
GitHubConnectResponse,
Squad,
SquadMember,
SquadMemberStatusListResponse,
} from "../types";
import type { OnboardingCompletionPath } from "../onboarding/types";
import { type Logger, noopLogger } from "../logger";
@@ -107,17 +116,28 @@ 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,
OnboardingNoRuntimeBootstrapResponseSchema,
OnboardingRuntimeBootstrapResponseSchema,
SquadMemberStatusListResponseSchema,
SubscribersListSchema,
TimelineEntriesSchema,
WebhookDeliveryResponseSchema,
} from "./schemas";
/** Identifies the calling client to the server.
@@ -145,6 +165,38 @@ export interface LoginResponse {
user: User;
}
export interface OnboardingRuntimeBootstrapResponse {
workspace_id: string;
agent_id: string;
issue_id: string;
}
const EMPTY_ONBOARDING_RUNTIME_BOOTSTRAP_RESPONSE:
OnboardingRuntimeBootstrapResponse = {
workspace_id: "",
agent_id: "",
issue_id: "",
};
export interface OnboardingNoRuntimeBootstrapResponse {
workspace_id: string;
issue_id: string;
}
const EMPTY_ONBOARDING_NO_RUNTIME_BOOTSTRAP_RESPONSE:
OnboardingNoRuntimeBootstrapResponse = {
workspace_id: "",
issue_id: "",
};
// Serialize the inbox `scope` filter into a `?scope=me,my_agent` query
// fragment. The server rejects empty arrays, so callers must skip the bulk
// request entirely when no chip is selected (RFC v3 §E.1, mode=empty).
function inboxScopeQuery(scope?: InboxFilterScope[] | null): string {
if (!scope || scope.length === 0) return "";
return `?scope=${encodeURIComponent(scope.join(","))}`;
}
// --- Starter content (post-onboarding import) -----------------------------
// Shape mirrors the Go request/response in handler/onboarding.go.
//
@@ -399,6 +451,43 @@ export class ApiClient {
});
}
async bootstrapOnboardingRuntime(payload: {
workspace_id: string;
runtime_id: string;
}): Promise<OnboardingRuntimeBootstrapResponse> {
const raw = await this.fetch<unknown>(
"/api/me/onboarding/runtime-bootstrap",
{
method: "POST",
body: JSON.stringify(payload),
},
);
return parseWithFallback(
raw,
OnboardingRuntimeBootstrapResponseSchema,
EMPTY_ONBOARDING_RUNTIME_BOOTSTRAP_RESPONSE,
{ endpoint: "POST /api/me/onboarding/runtime-bootstrap" },
);
}
async bootstrapOnboardingNoRuntime(payload: {
workspace_id: string;
}): Promise<OnboardingNoRuntimeBootstrapResponse> {
const raw = await this.fetch<unknown>(
"/api/me/onboarding/no-runtime-bootstrap",
{
method: "POST",
body: JSON.stringify(payload),
},
);
return parseWithFallback(
raw,
OnboardingNoRuntimeBootstrapResponseSchema,
EMPTY_ONBOARDING_NO_RUNTIME_BOOTSTRAP_RESPONSE,
{ endpoint: "POST /api/me/onboarding/no-runtime-bootstrap" },
);
}
async joinCloudWaitlist(payload: {
email: string;
reason?: string;
@@ -465,6 +554,7 @@ export class ApiClient {
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?.involves_user_id) search.set("involves_user_id", params.involves_user_id);
if (params?.open_only) search.set("open_only", "true");
const path = `/api/issues?${search}`;
const raw = await this.fetch<unknown>(path);
@@ -473,6 +563,37 @@ 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.involves_user_id) search.set("involves_user_id", params.involves_user_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));
@@ -500,7 +621,12 @@ export class ApiClient {
});
}
async quickCreateIssue(data: { agent_id: string; prompt: string; project_id?: string | null }): Promise<{ task_id: string }> {
async quickCreateIssue(data: {
agent_id?: string;
squad_id?: string;
prompt: string;
project_id?: string | null;
}): Promise<{ task_id: string }> {
return this.fetch("/api/issues/quick-create", {
method: "POST",
body: JSON.stringify(data),
@@ -587,10 +713,10 @@ export class ApiClient {
return this.fetch("/api/assignee-frequency");
}
async updateComment(commentId: string, content: string): Promise<Comment> {
async updateComment(commentId: string, content: string, attachmentIds?: string[]): Promise<Comment> {
return this.fetch(`/api/comments/${commentId}`, {
method: "PUT",
body: JSON.stringify({ content }),
body: JSON.stringify({ content, attachment_ids: attachmentIds }),
});
}
@@ -850,6 +976,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,
@@ -965,8 +1106,8 @@ export class ApiClient {
}
// Inbox
async listInbox(): Promise<InboxItem[]> {
return this.fetch("/api/inbox");
async listInbox(scope?: InboxFilterScope[]): Promise<InboxItem[]> {
return this.fetch(`/api/inbox${inboxScopeQuery(scope)}`);
}
async markInboxRead(id: string): Promise<InboxItem> {
@@ -981,20 +1122,28 @@ export class ApiClient {
return this.fetch("/api/inbox/unread-count");
}
async markAllInboxRead(): Promise<{ count: number }> {
return this.fetch("/api/inbox/mark-all-read", { method: "POST" });
async getInboxScopeCounts(): Promise<InboxScopeCounts> {
return this.fetch("/api/inbox/scope-counts");
}
async archiveAllInbox(): Promise<{ count: number }> {
return this.fetch("/api/inbox/archive-all", { method: "POST" });
async getInboxResourceAvailability(): Promise<InboxResourceAvailability> {
return this.fetch("/api/inbox/resource-availability");
}
async archiveAllReadInbox(): Promise<{ count: number }> {
return this.fetch("/api/inbox/archive-all-read", { method: "POST" });
async markAllInboxRead(scope?: InboxFilterScope[]): Promise<{ count: number }> {
return this.fetch(`/api/inbox/mark-all-read${inboxScopeQuery(scope)}`, { method: "POST" });
}
async archiveCompletedInbox(): Promise<{ count: number }> {
return this.fetch("/api/inbox/archive-completed", { method: "POST" });
async archiveAllInbox(scope?: InboxFilterScope[]): Promise<{ count: number }> {
return this.fetch(`/api/inbox/archive-all${inboxScopeQuery(scope)}`, { method: "POST" });
}
async archiveAllReadInbox(scope?: InboxFilterScope[]): Promise<{ count: number }> {
return this.fetch(`/api/inbox/archive-all-read${inboxScopeQuery(scope)}`, { method: "POST" });
}
async archiveCompletedInbox(scope?: InboxFilterScope[]): Promise<{ count: number }> {
return this.fetch(`/api/inbox/archive-completed${inboxScopeQuery(scope)}`, { method: "POST" });
}
// Notification preferences
@@ -1037,7 +1186,7 @@ export class ApiClient {
});
}
async updateWorkspace(id: string, data: { name?: string; description?: string; context?: string; settings?: Record<string, unknown>; repos?: WorkspaceRepo[] }): Promise<Workspace> {
async updateWorkspace(id: string, data: { name?: string; description?: string; context?: string; settings?: Record<string, unknown>; repos?: WorkspaceRepo[]; issue_prefix?: string }): Promise<Workspace> {
return this.fetch(`/api/workspaces/${id}`, {
method: "PATCH",
body: JSON.stringify(data),
@@ -1454,7 +1603,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) });
}
@@ -1482,6 +1631,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();
@@ -1522,6 +1682,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",
@@ -1540,6 +1707,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(),
@@ -178,6 +198,17 @@ export const ChildIssuesResponseSchema = z.object({
issues: z.array(IssueSchema).default([]),
}).loose();
export const OnboardingRuntimeBootstrapResponseSchema = z.object({
workspace_id: z.string(),
agent_id: z.string(),
issue_id: z.string(),
}).loose();
export const OnboardingNoRuntimeBootstrapResponseSchema = z.object({
workspace_id: z.string(),
issue_id: z.string(),
}).loose();
// ---------------------------------------------------------------------------
// Workspace dashboard schemas
//
@@ -221,6 +252,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 +346,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

@@ -5,11 +5,11 @@ import type { ApiClient } from "../api/client";
import type { Attachment } from "../types";
import { MAX_FILE_SIZE } from "../constants/upload";
export interface UploadResult {
id: string;
filename: string;
link: string;
}
// Carries the full Attachment so editors that need preview metadata
// (`content_type`, `download_url`) get it directly; `link` is kept as an
// alias for `url` because many callers persist it into Markdown / avatar
// fields by that name.
export type UploadResult = Attachment & { link: string };
export interface UploadContext {
issueId?: string;
@@ -36,7 +36,7 @@ export function useFileUpload(
commentId: ctx?.commentId,
chatSessionId: ctx?.chatSessionId,
});
return { id: att.id, filename: att.filename, link: att.url };
return { ...att, link: att.url };
} finally {
setUploading(false);
}

View File

@@ -1,3 +1,4 @@
export * from "./queries";
export * from "./mutations";
export * from "./ws-updaters";
export * from "./stores";

View File

@@ -2,7 +2,7 @@ import { useMutation, useQueryClient } from "@tanstack/react-query";
import { api } from "../api";
import { inboxKeys } from "./queries";
import { useWorkspaceId } from "../hooks";
import type { InboxItem } from "../types";
import type { InboxItem, InboxFilterScope } from "../types";
export function useMarkInboxRead() {
const qc = useQueryClient();
@@ -22,6 +22,7 @@ export function useMarkInboxRead() {
},
onSettled: () => {
qc.invalidateQueries({ queryKey: inboxKeys.list(wsId) });
qc.invalidateQueries({ queryKey: inboxKeys.scopeCounts(wsId) });
},
});
}
@@ -51,21 +52,27 @@ export function useArchiveInbox() {
},
onSettled: () => {
qc.invalidateQueries({ queryKey: inboxKeys.list(wsId) });
qc.invalidateQueries({ queryKey: inboxKeys.scopeCounts(wsId) });
},
});
}
// All bulk mutations accept an optional `scope` parameter. When the caller
// is in mode=all (RFC v3 §E.1) it should pass undefined; when in mode=subset
// it should pass the resolved chip subset; in mode=empty the button is
// disabled and these mutations should not fire.
export function useMarkAllInboxRead() {
const qc = useQueryClient();
const wsId = useWorkspaceId();
return useMutation({
mutationFn: () => api.markAllInboxRead(),
onMutate: async () => {
mutationFn: (scope?: InboxFilterScope[]) => api.markAllInboxRead(scope),
onMutate: async (scope) => {
await qc.cancelQueries({ queryKey: inboxKeys.list(wsId) });
const prev = qc.getQueryData<InboxItem[]>(inboxKeys.list(wsId));
const inScope = scopeMatcher(scope);
qc.setQueryData<InboxItem[]>(inboxKeys.list(wsId), (old) =>
old?.map((item) =>
!item.archived ? { ...item, read: true } : item,
!item.archived && inScope(item) ? { ...item, read: true } : item,
),
);
return { prev };
@@ -75,6 +82,7 @@ export function useMarkAllInboxRead() {
},
onSettled: () => {
qc.invalidateQueries({ queryKey: inboxKeys.list(wsId) });
qc.invalidateQueries({ queryKey: inboxKeys.scopeCounts(wsId) });
},
});
}
@@ -83,9 +91,10 @@ export function useArchiveAllInbox() {
const qc = useQueryClient();
const wsId = useWorkspaceId();
return useMutation({
mutationFn: () => api.archiveAllInbox(),
mutationFn: (scope?: InboxFilterScope[]) => api.archiveAllInbox(scope),
onSettled: () => {
qc.invalidateQueries({ queryKey: inboxKeys.list(wsId) });
qc.invalidateQueries({ queryKey: inboxKeys.scopeCounts(wsId) });
},
});
}
@@ -94,9 +103,10 @@ export function useArchiveAllReadInbox() {
const qc = useQueryClient();
const wsId = useWorkspaceId();
return useMutation({
mutationFn: () => api.archiveAllReadInbox(),
mutationFn: (scope?: InboxFilterScope[]) => api.archiveAllReadInbox(scope),
onSettled: () => {
qc.invalidateQueries({ queryKey: inboxKeys.list(wsId) });
qc.invalidateQueries({ queryKey: inboxKeys.scopeCounts(wsId) });
},
});
}
@@ -105,9 +115,21 @@ export function useArchiveCompletedInbox() {
const qc = useQueryClient();
const wsId = useWorkspaceId();
return useMutation({
mutationFn: () => api.archiveCompletedInbox(),
mutationFn: (scope?: InboxFilterScope[]) => api.archiveCompletedInbox(scope),
onSettled: () => {
qc.invalidateQueries({ queryKey: inboxKeys.list(wsId) });
qc.invalidateQueries({ queryKey: inboxKeys.scopeCounts(wsId) });
},
});
}
// True when the inbox item belongs to the user-selected scope subset, or
// when no scope was passed (= mark/archive everything).
function scopeMatcher(scope?: InboxFilterScope[]) {
if (!scope || scope.length === 0) return (_item: InboxItem) => true;
const set = new Set(scope);
return (item: InboxItem) => {
const s = item.assignee_scope;
return s != null && (set as Set<string>).has(s);
};
}

View File

@@ -1,19 +1,49 @@
import { queryOptions, useQuery } from "@tanstack/react-query";
import { api } from "../api";
import type { InboxItem } from "../types";
import type {
InboxItem,
InboxFilterScope,
InboxScopeCounts,
InboxResourceAvailability,
} from "../types";
export const inboxKeys = {
all: (wsId: string) => ["inbox", wsId] as const,
// The list key is intentionally a single key per workspace — the scope
// filter is applied client-side on top of the full cached list (RFC v3
// §E selector), so we don't fragment the cache by scope. When the user
// changes chips we just re-derive from the same query.
list: (wsId: string) => [...inboxKeys.all(wsId), "list"] as const,
scopeCounts: (wsId: string) =>
[...inboxKeys.all(wsId), "scope-counts"] as const,
resourceAvailability: (wsId: string) =>
[...inboxKeys.all(wsId), "resource-availability"] as const,
};
export function inboxListOptions(wsId: string) {
return queryOptions({
queryKey: inboxKeys.list(wsId),
// Always fetch the full list (no scope param). The chip filter runs in
// the selector — that way the badge counts and the dedupe logic always
// operate on the complete picture, and toggling a chip is instant.
queryFn: () => api.listInbox(),
});
}
export function inboxScopeCountsOptions(wsId: string) {
return queryOptions({
queryKey: inboxKeys.scopeCounts(wsId),
queryFn: () => api.getInboxScopeCounts(),
});
}
export function inboxResourceAvailabilityOptions(wsId: string) {
return queryOptions({
queryKey: inboxKeys.resourceAvailability(wsId),
queryFn: () => api.getInboxResourceAvailability(),
});
}
/**
* Unread inbox count for the given workspace, aligned with what the inbox
* list UI renders: archived items excluded, then deduplicated by issue so a
@@ -57,3 +87,29 @@ export function deduplicateInboxItems(items: InboxItem[]): InboxItem[] {
new Date(b.created_at).getTime() - new Date(a.created_at).getTime(),
);
}
/**
* Narrow a deduplicated inbox list to the user-selected chips. Applies the
* RFC v3 §E selector rules: a strict subset of {me, my_agent, my_squad}
* keeps only items tagged with one of those scopes (other/none are dropped);
* a null filter (= "all" mode) passes everything through unchanged.
*
* `null` is the no-op signal. Pass `null` whenever you don't want to filter,
* including the empty-mode case where the caller is also expected to render
* an empty state instead of calling this.
*/
export function filterInboxByScope(
items: InboxItem[],
scopes: InboxFilterScope[] | null,
): InboxItem[] {
if (!scopes) return items;
const set = new Set(scopes);
return items.filter((i) => {
const s = i.assignee_scope;
return s != null && (set as Set<string>).has(s);
});
}
// Re-exports — kept for backwards compatibility with code importing the
// inbox scope-count / availability response shapes from this module.
export type { InboxScopeCounts, InboxResourceAvailability };

View File

@@ -0,0 +1,83 @@
"use client";
import { create } from "zustand";
import { createJSONStorage, persist } from "zustand/middleware";
import { createWorkspaceAwareStorage, registerForWorkspaceRehydration } from "../../platform/workspace-storage";
import { defaultStorage } from "../../platform/storage";
import type { InboxFilterScope } from "../../types";
// All three assignment chips, in stable display order. Used both for the
// "default = all selected" initial state and for callers that need to render
// chips deterministically.
export const INBOX_FILTER_SCOPES: readonly InboxFilterScope[] = [
"me",
"my_agent",
"my_squad",
] as const;
interface InboxScopeState {
// Persisted selection. The default is the full set so a freshly installed
// app shows every notification — see RFC v3 §E.1 mode=all.
selected: InboxFilterScope[];
toggle: (scope: InboxFilterScope) => void;
set: (scopes: InboxFilterScope[]) => void;
selectAll: () => void;
clear: () => void;
}
export const useInboxScopeStore = create<InboxScopeState>()(
persist(
(set) => ({
selected: [...INBOX_FILTER_SCOPES],
toggle: (scope) =>
set((state) => ({
selected: state.selected.includes(scope)
? state.selected.filter((s) => s !== scope)
: [...state.selected, scope],
})),
set: (scopes) => set({ selected: scopes }),
selectAll: () => set({ selected: [...INBOX_FILTER_SCOPES] }),
clear: () => set({ selected: [] }),
}),
{
name: "multica_inbox_scope",
storage: createJSONStorage(() => createWorkspaceAwareStorage(defaultStorage)),
},
),
);
registerForWorkspaceRehydration(() => useInboxScopeStore.persist.rehydrate());
// Resolved filter mode. Matches the three-state algorithm in RFC v3 §E.1:
// - all: 3 selected → no `scope` is sent; selector keeps me/my_agent/my_squad/other/none
// - subset: 1-2 selected → `scope=...` is sent; selector filters to the subset
// - empty: 0 selected → don't request; show empty state, bulk disabled
export type InboxFilterMode = "all" | "subset" | "empty";
export interface InboxFilterResolution {
mode: InboxFilterMode;
// Scopes to send on the wire. `null` for mode="all" (omit param entirely),
// a string[] for mode="subset", `[]` for mode="empty".
scopes: InboxFilterScope[] | null;
}
export function resolveInboxFilter(
selected: InboxFilterScope[],
): InboxFilterResolution {
// Dedupe + restrict to the three valid chip values. "other" / "none" are
// server-internal buckets and must never appear on the wire.
const unique = new Set<InboxFilterScope>();
for (const s of selected) {
if (s === "me" || s === "my_agent" || s === "my_squad") unique.add(s);
}
if (unique.size === INBOX_FILTER_SCOPES.length) {
return { mode: "all", scopes: null };
}
if (unique.size === 0) {
return { mode: "empty", scopes: [] };
}
return {
mode: "subset",
scopes: INBOX_FILTER_SCOPES.filter((s) => unique.has(s)),
};
}

View File

@@ -0,0 +1,7 @@
export {
useInboxScopeStore,
resolveInboxFilter,
INBOX_FILTER_SCOPES,
type InboxFilterMode,
type InboxFilterResolution,
} from "./inbox-scope-store";

View File

@@ -10,6 +10,19 @@ export function onInboxNew(
// Use invalidateQueries instead of setQueryData — triggers a refetch that
// reliably notifies all observers. The inbox list is small so this is cheap.
qc.invalidateQueries({ queryKey: inboxKeys.list(wsId) });
qc.invalidateQueries({ queryKey: inboxKeys.scopeCounts(wsId) });
}
// `inbox:batch-read` and `inbox:batch-archived` are emitted when the user
// runs a bulk endpoint (mark-all-read / archive-*). They can carry a `scope`
// filter (RFC v3 §C.5) and `inbox:batch-archived` additionally carries an
// `operation` (RFC v4 §1). We currently fall back to a generic invalidate
// for both — precise cache updates per operation+scope are a documented
// follow-up: the payload contract is already in place, so the optimization
// is a frontend-only change later.
export function onInboxBatch(qc: QueryClient, wsId: string) {
qc.invalidateQueries({ queryKey: inboxKeys.list(wsId) });
qc.invalidateQueries({ queryKey: inboxKeys.scopeCounts(wsId) });
}
export function onInboxIssueStatusChanged(
@@ -27,7 +40,9 @@ export function onInboxIssueStatusChanged(
// Mirrors the DB-level ON DELETE CASCADE on inbox_item.issue_id: when an issue
// is deleted, all inbox items that referenced it are gone server-side, so drop
// them from the cache too.
// them from the cache too. Scope counts shift in lockstep with the pruned
// rows, so invalidate them here as well — otherwise the chip badge keeps
// counting an issue that no longer exists.
export function onInboxIssueDeleted(
qc: QueryClient,
wsId: string,
@@ -36,8 +51,14 @@ export function onInboxIssueDeleted(
qc.setQueryData<InboxItem[]>(inboxKeys.list(wsId), (old) =>
old?.filter((i) => i.issue_id !== issueId),
);
qc.invalidateQueries({ queryKey: inboxKeys.scopeCounts(wsId) });
}
// Generic single-item inbox invalidation (e.g. `inbox:archived`,
// `inbox:read`). The chip badge is derived from the same rows that just
// changed, so it has to be re-fetched alongside the list — otherwise the
// badge stays at the pre-change value until a hard refresh.
export function onInboxInvalidate(qc: QueryClient, wsId: string) {
qc.invalidateQueries({ queryKey: inboxKeys.list(wsId) });
qc.invalidateQueries({ queryKey: inboxKeys.scopeCounts(wsId) });
}

View File

@@ -1,9 +1,11 @@
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,
PAGINATED_STATUSES,
type AssigneeGroupedIssuesFilter,
type MyIssuesFilter,
} from "./queries";
import {
@@ -23,8 +25,9 @@ import {
pruneDeletedIssueFromParentChildrenCaches,
} from "./delete-cache";
import { useWorkspaceId } from "../hooks";
import { inboxKeys } from "../inbox/queries";
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 +105,127 @@ export function useLoadMoreByStatus(
return { loadMore, hasMore, isLoading, total };
}
/**
* Drain every remaining paginated page across all statuses into the cache.
* Used by surfaces that can't paginate per-column (e.g. the Project Gantt
* view) and need the full project issue set up-front. Each iteration appends
* one ISSUE_PAGE_SIZE page per status that still has unfetched rows; loops
* until the cache totals match the server.
*/
export function useLoadAllRemaining(
myIssues?: { scope: string; filter: MyIssuesFilter },
) {
const qc = useQueryClient();
const wsId = useWorkspaceId();
const [isLoading, setIsLoading] = useState(false);
const queryKey = myIssues
? issueKeys.myList(wsId, myIssues.scope, myIssues.filter)
: issueKeys.list(wsId);
const loadAll = useCallback(async () => {
if (isLoading) return;
setIsLoading(true);
try {
// Round-trip the cache rather than caching `loaded` locally so a
// concurrent WS-driven update or another loadMore can't make us
// re-fetch an already-loaded page.
for (;;) {
const cache = qc.getQueryData<ListIssuesCache>(queryKey);
if (!cache) return;
const pending = PAGINATED_STATUSES.filter((status) => {
const bucket = cache.byStatus[status];
if (!bucket) return false;
return bucket.issues.length < bucket.total;
});
if (pending.length === 0) return;
const results = await Promise.all(
pending.map((status) =>
api
.listIssues({
status,
limit: ISSUE_PAGE_SIZE,
offset: cache.byStatus[status]!.issues.length,
...myIssues?.filter,
})
.then((res) => ({ status, res })),
),
);
qc.setQueryData<ListIssuesCache>(queryKey, (old) => {
if (!old) return old;
let next = old;
for (const { status, res } of results) {
const prev = getBucket(next, status);
const existingIds = new Set(prev.issues.map((i) => i.id));
const appended = res.issues.filter((i) => !existingIds.has(i.id));
next = setBucket(next, status, {
issues: [...prev.issues, ...appended],
total: res.total,
});
}
return next;
});
}
} finally {
setIsLoading(false);
}
}, [isLoading, qc, queryKey, myIssues?.filter]);
return { loadAll, isLoading };
}
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 +250,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 +326,29 @@ 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) });
// Inbox rows carry a server-computed `assignee_scope` derived from
// the issue's assignee. Re-assigning the issue (member ↔ agent ↔
// squad ↔ none) shifts the row's chip bucket and the scope-count
// badge, so flush both whenever this mutation touched assignment.
// The WS handler also invalidates on the broadcast issue:updated;
// doing it here too lets the originating tab refresh without
// round-tripping through the server.
if (
Object.prototype.hasOwnProperty.call(vars, "assignee_id") ||
Object.prototype.hasOwnProperty.call(vars, "assignee_type")
) {
qc.invalidateQueries({ queryKey: inboxKeys.list(wsId) });
qc.invalidateQueries({ queryKey: inboxKeys.scopeCounts(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
// payload mutates the attachment join table.
if (vars.attachment_ids?.length) {
qc.invalidateQueries({ queryKey: issueKeys.attachments(vars.id) });
}
// Invalidate old parent's children cache
if (ctx?.parentId) {
qc.invalidateQueries({
@@ -274,6 +423,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);
},
});
@@ -329,8 +480,19 @@ export function useBatchUpdateIssues() {
}
}
},
onSettled: (_data, _err, _vars, ctx) => {
onSettled: (_data, _err, vars, ctx) => {
qc.invalidateQueries({ queryKey: issueKeys.list(wsId) });
qc.invalidateQueries({ queryKey: issueKeys.assigneeGroupsAll(wsId) });
qc.invalidateQueries({ queryKey: issueKeys.myAssigneeGroupsAll(wsId) });
// Bulk reassignments shift `assignee_scope` across N rows — same
// reasoning as useUpdateIssue.
if (
Object.prototype.hasOwnProperty.call(vars.updates, "assignee_id") ||
Object.prototype.hasOwnProperty.call(vars.updates, "assignee_type")
) {
qc.invalidateQueries({ queryKey: inboxKeys.list(wsId) });
qc.invalidateQueries({ queryKey: inboxKeys.scopeCounts(wsId) });
}
if (ctx?.affectedParentIds && ctx.affectedParentIds.size > 0) {
for (const parentId of ctx.affectedParentIds) {
qc.invalidateQueries({
@@ -431,6 +593,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),
@@ -496,8 +660,8 @@ export function useCreateComment(issueId: string) {
export function useUpdateComment(issueId: string) {
const qc = useQueryClient();
return useMutation({
mutationFn: ({ commentId, content }: { commentId: string; content: string }) =>
api.updateComment(commentId, content),
mutationFn: ({ commentId, content, attachmentIds }: { commentId: string; content: string; attachmentIds?: string[] }) =>
api.updateComment(commentId, content, attachmentIds),
onMutate: async ({ commentId, content }) => {
await qc.cancelQueries({ queryKey: issueKeys.timeline(issueId) });
const prev = qc.getQueryData<TimelineCache>(issueKeys.timeline(issueId));

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) =>
@@ -42,7 +55,12 @@ export const issueKeys = {
export type MyIssuesFilter = Pick<
ListIssuesParams,
"assignee_id" | "assignee_ids" | "creator_id" | "project_id"
"assignee_id" | "assignee_ids" | "creator_id" | "project_id" | "involves_user_id"
>;
export type AssigneeGroupedIssuesFilter = Omit<
ListGroupedIssuesParams,
"group_by" | "limit" | "offset" | "group_assignee_type" | "group_assignee_id"
>;
/** Page size per status column. */
@@ -61,6 +79,34 @@ export function flattenIssueBuckets(data: ListIssuesCache) {
return out;
}
export interface IssueListPagination {
loaded: number;
total: number;
hasMore: boolean;
}
/**
* Aggregate the bucketed cache totals so non-paginated consumers (e.g. the
* Gantt view, which doesn't have a per-status load-more affordance) can tell
* whether the cache is missing pages and warn the user instead of silently
* rendering an incomplete schedule.
*/
export function summarizeIssueListPagination(
data: ListIssuesCache | undefined,
): IssueListPagination {
if (!data) return { loaded: 0, total: 0, hasMore: false };
let loaded = 0;
let total = 0;
for (const status of PAGINATED_STATUSES) {
const bucket = data.byStatus[status];
if (bucket) {
loaded += bucket.issues.length;
total += bucket.total;
}
}
return { loaded, total, hasMore: loaded < total };
}
async function fetchFirstPages(filter: MyIssuesFilter = {}): Promise<ListIssuesCache> {
const responses = await Promise.all(
PAGINATED_STATUSES.map((status) =>
@@ -92,6 +138,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 +170,41 @@ export function myIssueListOptions(
});
}
/**
* Same cache entry as {@link myIssueListOptions} (shared queryKey + queryFn —
* TanStack Query dedupes), but `select` derives a pagination summary instead
* of the flat issue list. Use this alongside the list query when a consumer
* needs to know how many issues live behind unfetched pages.
*/
export function myIssueListPaginationOptions(
wsId: string,
scope: string,
filter: MyIssuesFilter,
) {
return queryOptions({
queryKey: issueKeys.myList(wsId, scope, filter),
queryFn: () => fetchFirstPages(filter),
select: summarizeIssueListPagination,
});
}
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

@@ -2,7 +2,8 @@ import { beforeEach, describe, expect, it } from "vitest";
import { useQuickCreateStore } from "./quick-create-store";
const RESET_STATE = {
lastAgentId: null,
lastActorType: null,
lastActorId: null,
lastProjectId: null,
prompt: "",
keepOpen: false,
@@ -34,4 +35,20 @@ describe("quick create store", () => {
setLastProjectId(null);
expect(useQuickCreateStore.getState().lastProjectId).toBeNull();
});
it("remembers the last actor (agent or squad) and clears both fields together", () => {
const { setLastActor } = useQuickCreateStore.getState();
setLastActor("agent", "agent-1");
expect(useQuickCreateStore.getState().lastActorType).toBe("agent");
expect(useQuickCreateStore.getState().lastActorId).toBe("agent-1");
setLastActor("squad", "squad-1");
expect(useQuickCreateStore.getState().lastActorType).toBe("squad");
expect(useQuickCreateStore.getState().lastActorId).toBe("squad-1");
setLastActor(null, null);
expect(useQuickCreateStore.getState().lastActorType).toBeNull();
expect(useQuickCreateStore.getState().lastActorId).toBeNull();
});
});

View File

@@ -5,17 +5,26 @@ import { createJSONStorage, persist } from "zustand/middleware";
import { createWorkspaceAwareStorage, registerForWorkspaceRehydration } from "../../platform/workspace-storage";
import { defaultStorage } from "../../platform/storage";
// Per-workspace memory of the last agent and project the user picked in the
// Quick Create modal. Defaulted to those values on next open so frequent
// users skip the pickers entirely — without this, anyone targeting a single
// project ends up retyping "in project A" on every prompt. Persisted with
// the workspace-aware StateStorage so switching workspaces shows the right
// default automatically. Per-user scoping comes for free from localStorage
// being browser-profile-local — matches how draft-store /
// issues-scope-store / comment-collapse-store already namespace themselves.
export type QuickCreateActorType = "agent" | "squad";
// Per-workspace memory of the last actor (agent or squad) and project the
// user picked in the Quick Create modal. Defaulted to those values on next
// open so frequent users skip the pickers entirely — without this, anyone
// targeting a single project ends up retyping "in project A" on every
// prompt. Persisted with the workspace-aware StateStorage so switching
// workspaces shows the right default automatically. Per-user scoping comes
// for free from localStorage being browser-profile-local — matches how
// draft-store / issues-scope-store / comment-collapse-store already
// namespace themselves.
//
// lastActorType + lastActorId replace the prior `lastAgentId` field once
// squads became selectable. Users who had a persisted agent preference
// land back on whatever the picker shows first; a one-time re-pick is
// preferable to the type-tag ambiguity of overloading a single UUID.
interface QuickCreateState {
lastAgentId: string | null;
setLastAgentId: (id: string | null) => void;
lastActorType: QuickCreateActorType | null;
lastActorId: string | null;
setLastActor: (type: QuickCreateActorType | null, id: string | null) => void;
lastProjectId: string | null;
setLastProjectId: (id: string | null) => void;
prompt: string;
@@ -28,8 +37,9 @@ interface QuickCreateState {
export const useQuickCreateStore = create<QuickCreateState>()(
persist(
(set) => ({
lastAgentId: null,
setLastAgentId: (id) => set({ lastAgentId: id }),
lastActorType: null,
lastActorId: null,
setLastActor: (type, id) => set({ lastActorType: type, lastActorId: id }),
lastProjectId: null,
setLastProjectId: (id) => set({ lastProjectId: id }),
prompt: "",

View File

@@ -9,14 +9,17 @@ import { ALL_STATUSES } from "../config";
import { createWorkspaceAwareStorage, registerForWorkspaceRehydration } from "../../platform/workspace-storage";
import { defaultStorage } from "../../platform/storage";
export type ViewMode = "board" | "list";
export type SortField = "position" | "priority" | "due_date" | "created_at" | "title";
export type ViewMode = "board" | "list" | "gantt";
export type GanttZoom = "day" | "week" | "month";
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;
@@ -24,22 +27,29 @@ export interface CardProperties {
}
export interface ActorFilterValue {
type: "member" | "agent";
type: "member" | "agent" | "squad";
id: string;
}
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 +58,7 @@ export const CARD_PROPERTY_OPTIONS: { key: keyof CardProperties; label: string }
export interface IssueViewState {
viewMode: ViewMode;
grouping: IssueGrouping;
statusFilters: IssueStatus[];
priorityFilters: IssuePriority[];
assigneeFilters: ActorFilterValue[];
@@ -60,7 +71,12 @@ export interface IssueViewState {
sortDirection: SortDirection;
cardProperties: CardProperties;
listCollapsedStatuses: IssueStatus[];
ganttZoom: GanttZoom;
ganttShowCompleted: boolean;
setViewMode: (mode: ViewMode) => void;
setGanttZoom: (zoom: GanttZoom) => void;
toggleGanttShowCompleted: () => void;
setGrouping: (grouping: IssueGrouping) => void;
toggleStatusFilter: (status: IssueStatus) => void;
togglePriorityFilter: (priority: IssuePriority) => void;
toggleAssigneeFilter: (value: ActorFilterValue) => void;
@@ -80,6 +96,7 @@ export interface IssueViewState {
export const viewStoreSlice = (set: StoreApi<IssueViewState>["setState"]): IssueViewState => ({
viewMode: "board",
grouping: "status",
statusFilters: [],
priorityFilters: [],
assigneeFilters: [],
@@ -94,14 +111,21 @@ export const viewStoreSlice = (set: StoreApi<IssueViewState>["setState"]): Issue
priority: true,
description: true,
assignee: true,
startDate: true,
dueDate: true,
project: true,
childProgress: true,
labels: true,
},
listCollapsedStatuses: [],
ganttZoom: "week",
ganttShowCompleted: false,
setViewMode: (mode) => set({ viewMode: mode }),
setGanttZoom: (zoom) => set({ ganttZoom: zoom }),
toggleGanttShowCompleted: () =>
set((state) => ({ ganttShowCompleted: !state.ganttShowCompleted })),
setGrouping: (grouping) => set({ grouping }),
toggleStatusFilter: (status) =>
set((state) => ({
statusFilters: state.statusFilters.includes(status)
@@ -205,6 +229,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,
@@ -217,6 +242,8 @@ export const viewStorePersistOptions = (name: string) => ({
sortDirection: state.sortDirection,
cardProperties: state.cardProperties,
listCollapsedStatuses: state.listCollapsedStatuses,
ganttZoom: state.ganttZoom,
ganttShowCompleted: state.ganttShowCompleted,
}),
// Default Zustand merge is shallow, so a persisted `cardProperties` snapshot
// saved before a new toggle was introduced wins entirely and the new key is

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