Compare commits

..

70 Commits

Author SHA1 Message Date
Jiang Bohan
2ac03176e9 fix(quick-create): branch Output section + deterministic origin lookup
Addresses GPT-Boy's second-pass review on #1786:

1. The runtime_config.go Output section forced "Final results MUST be
   delivered via multica issue comment add" for every non-autopilot
   task — quick-create still got this conflicting instruction even
   though there's no issue to comment on. Switched the Output block
   to a three-way switch so quick-create gets a tailored "stdout is
   captured automatically; do NOT call comment add" branch matching
   the autopilot variant.

2. Completion lookup was "most recent issue created by this agent
   since task.started_at", which races against concurrent issue
   creates by the same agent (assignment task running alongside
   quick-create when max_concurrent_tasks > 1). Replaced with a
   deterministic origin link:

   - Migration 060 extends issue.origin_type CHECK to allow
     'quick_create'.
   - Daemon sets MULTICA_QUICK_CREATE_TASK_ID env var when running a
     quick-create task.
   - multica issue create CLI reads the env var and stamps the new
     issue with origin_type=quick_create + origin_id=<task_id>.
   - Server CreateIssue handler accepts (origin_type, origin_id)
     from trusted callers (only "quick_create" is allowed; the pair
     is rejected unless both fields are provided together).
   - notifyQuickCreateCompleted now calls GetIssueByOrigin keyed on
     (workspace_id, "quick_create", task.ID) — no more time-window
     racing against parallel agent activity.

The old GetRecentIssueByCreatorSince query is removed.
2026-04-28 18:27:43 +08:00
Jiang Bohan
b3682eac4f fix(quick-create): execenv injection, claim race, private-agent permission
Addresses GPT-Boy review on #1786:

1. execenv was rendering the assignment-task issue_context.md / runtime
   workflow even for quick-create, telling the agent to call
   `multica issue get/status/comment add` against an empty IssueID.
   Adds QuickCreatePrompt to TaskContextForEnv, plus a quick-create
   branch in renderIssueContext + the runtime_config workflow that
   instructs the agent to run a single `multica issue create` and
   exit, with explicit "do NOT call issue get/status/comment add"
   guards.

2. ClaimAgentTask serialized only on issue_id / chat_session_id, so
   concurrent quick-creates on the same agent (both NULL on those
   columns) ran in parallel — making the success-inbox lookup race
   over "most recent issue by this agent". Adds a third OR clause
   that treats "all four FKs NULL" as a serialization key for the
   same agent, so quick-create tasks on a given agent run one at a
   time.

3. QuickCreateIssue handler bypassed the private-agent ownership rule
   that validateAssigneePair enforces elsewhere — a user could POST a
   private agent_id they didn't own and trigger it. Now routes the
   picked agent through validateAssigneePair before the runtime
   liveness check.

4. Clarifies the quick-create-store namespacing comment to match the
   actual workspace-aware StateStorage convention used by the other
   issue stores (per-user is browser-profile-local).
2026-04-28 17:29:54 +08:00
Jiang Bohan
bf9bd26dd9 feat(views): quick-create issue modal + inbox failure CTA
Adds a streamlined create-issue UI bound to the c shortcut: pick an
agent, type one line, submit. The modal closes immediately and the
agent translates the prompt into a multica issue create call in the
background. Shift+c keeps the legacy advanced form for users who want
every field. The "Advanced" button inside the new modal seeds the
shared issue-draft store with the prompt + picked agent so switching
mid-flow doesn't lose input.

Last-used agent persists per (user, workspace) via a workspace-aware
zustand store so frequent users skip the picker on every open.

Inbox renders quick_create_done items with a status pin to the new
issue and quick_create_failed items with an "Edit as advanced form"
CTA that re-seeds the legacy modal with the original prompt.

ApiError now carries the parsed JSON body so the modal can branch on
the structured agent_unavailable code without parsing the error
message.
2026-04-28 17:14:31 +08:00
Jiang Bohan
06e2ed5347 feat(server): add quick-create issue async task path
Adds POST /api/issues/quick-create which validates the picked agent's
reachability up front (not archived, has runtime, runtime online) then
queues an issue-less agent task whose context JSONB carries the user's
natural-language prompt + requester + workspace. Daemon claim resolves
the workspace from the context, and the prompt builder switches to a
quick-create template instructing the agent to translate the prompt
into a single multica issue create call.

Task completion writes a success inbox item to the requester pointing at
the newly-created issue (located by querying the agent's most recent
issue in the workspace since task start, so we don't depend on agent
stdout shape). Failures write an action_required inbox item carrying the
original prompt + agent id so the frontend can offer "Edit as advanced
form" without losing input.
2026-04-28 17:06:38 +08:00
Bohan Jiang
541aaa974d fix(server): clarify silent-exit prompt and pin handoff contract (#1775)
Follow-ups to #1765 review nits:

- Tighten the per-turn prompt and AGENTS.md workflow instructions so
  that "exit with no output" only applies when the trigger is from
  another agent AND no actual work was produced this turn. If the
  agent did real work, the standard "post results as a comment" rule
  still applies — a result reply is not a noise comment.

- Add TestAgentExplicitMentionStillTriggers as a positive control
  documenting the boundary the structural fix preserves: suppressing
  implicit parent-mention inheritance for agent authors does NOT
  block deliberate handoffs. An agent that explicitly @mentions
  another agent in its own content still enqueues a task for the
  mentioned agent and does not self-trigger.
2026-04-28 15:21:39 +08:00
Bright Zheng
81231e06f8 fix(server): prevent agent-to-agent mention inheritance loops (BRI-34) (#1765)
When an agent replied in a thread whose root mentioned another agent,
the reply inherited the parent mention and re-triggered the other agent.
This caused 'No reply needed' ping-pong loops between co-assigned agents.

Structural fix:
- In enqueueMentionedAgentTasks, suppress parent-mention inheritance
  when authorType == 'agent'. Explicit @mentions in the agent's own
  comment still work for deliberate handoffs.

Defense-in-depth (prompt):
- Strengthen per-turn prompt and AGENTS.md workflow instructions to
  explicitly forbid posting 'No reply needed' noise comments.

Regression test:
- TestAgentReplyDoesNotInheritParentMentions covers both the fix
  (agent reply does not re-trigger) and the positive control
  (member reply still inherits mentions).

Also updates TestBuildPromptCommentTriggeredByAgent to match the
new prompt wording.
2026-04-28 15:14:14 +08:00
devv-eve
6ef711cd35 fix: gate dev verification code behind explicit env (#1773)
* fix: gate dev verification code behind explicit env

* docs: fold dev verification code into env table

* docs: clarify fixed verification code opt-in

---------

Co-authored-by: Eve <eve@multica.ai>
2026-04-28 15:14:07 +08:00
Bohan Jiang
b8f661e006 feat(create-issue): default assignee to last-selected value (#1774)
The create-issue modal now remembers the assignee picked at submit
time and prefills the picker with that value when the modal next
opens. Implemented by tracking lastAssigneeType/Id alongside the
draft and seeding clearDraft's reset with those values.
2026-04-28 15:11:10 +08:00
Bohan Jiang
f628e48775 refactor(server): error-returning ParseUUID to prevent silent data loss
* refactor(server): make ParseUUID error-returning to prevent silent data loss (MUL-1410)

util.ParseUUID previously swallowed errors and returned a zero pgtype.UUID
on invalid input. When this zero UUID reached a write query (DELETE/UPDATE),
the SQL matched zero rows and the handler returned 2xx success — producing
silent data corruption. #1661 (DeleteIssue with identifier-style ID) was the
visible symptom; PR #1680 patched that one site, this commit closes the
class of bug.

Changes:

- util.ParseUUID now returns (pgtype.UUID, error). Add util.MustParseUUID
  for trusted round-trips that should panic on invalid input.
- handler/handler.go: parseUUID wrapper now calls MustParseUUID — any
  unguarded user-input string reaching it surfaces as a recovered panic
  (chi middleware.Recoverer → 500) instead of silently corrupting data.
  Add parseUUIDOrBadRequest(w, s, fieldName) for handler entry points.
- Convert every Queries.Delete*/Update* call site reachable from raw user
  input (autopilot, comment, project, skill, skill_file, label, pin,
  attachment, feedback, issue assignee, daemon runtime, workspace) to
  validate UUIDs explicitly with parseUUIDOrBadRequest, returning 400 on
  invalid input. Where a resolved entity.ID is already in scope, write
  queries now use it directly instead of re-parsing the URL string.
- Update getWorkspaceMember + loadIssueForUser to handle invalid UUIDs
  gracefully (404/400 instead of panic).
- Update util/middleware/cmd-level callers (subscriber_listeners,
  notification_listeners, activity_listeners, scope_authorizer,
  middleware/workspace) to use the error-returning API.
- Add server/internal/util/pgx_test.go covering valid/invalid input and
  the MustParseUUID panic contract.
- Add TestDeleteIssueByIdentifier + TestDeleteIssueRejectsInvalidUUID
  regression tests in handler_test.go (the original #1661 bug + the
  invalid-input case).
- Document the handler UUID parsing convention in CLAUDE.md so the rule
  is enforceable in future PR review.

* fix(server): address GPT-Boy review of #1748

P1 fixes from PR #1748 review:

1. Migrate remaining request-boundary UUIDs to parseUUIDOrBadRequest so
   malformed input returns 400 instead of panic/500. Was missing on:
   - issue.go: workspace_id in CreateIssue/ChildIssueProgress/ListIssues/
     SearchIssues/BatchUpdateIssues/BatchDeleteIssues; project_id /
     parent_issue_id / lead_id / assignee_id / assignee_ids / creator_id
     filters; batch issue_ids and assignee/parent/project fields in
     BatchUpdateIssues (skip on bad input via util.ParseUUID, matching
     the existing per-row continue semantics).
   - project.go: project id + workspace_id in GetProject/UpdateProject/
     DeleteProject; lead_id in CreateProject/UpdateProject;
     workspace_id in ListProjects + SearchProjects.
   - handler.go: resolveActor now uses util.ParseUUID for X-Agent-ID /
     X-Task-ID headers; invalid UUID falls back to "member" (matches
     pre-existing semantics) instead of panicking.
   - issue.go: validateAssigneePair returns 400 on invalid workspace_id
     instead of panicking.

2. Fix issue:deleted WS event payloads to emit uuidToString(issue.ID)
   instead of the raw URL string. After an identifier-path delete
   ("MUL-7"), the previous payload would have leaked the identifier to
   subscribers, leaving stale entries in frontend caches that key by
   UUID. Updated DeleteIssue (issue.go:1341) and BatchDeleteIssues
   (issue.go:1641). The slog "issue deleted" log line also now records
   the resolved UUID so logs match the WS payload.

3. Extend TestDeleteIssueByIdentifier to subscribe to the bus and
   assert issue:deleted.payload.issue_id is the resolved UUID, not
   the identifier.

* fix(server): validate remaining reviewed UUID inputs

* fix(server): validate remaining handler UUID inputs

* fix(server): finish request boundary UUID audit

* fix(server): validate remaining request body UUIDs

* fix(server): validate runtime path UUIDs

* fix(server): validate remaining audit UUID inputs

---------

Co-authored-by: Eve <eve@multica.ai>
2026-04-28 14:50:28 +08:00
devv-eve
f864a07bd5 feat: add server Prometheus metrics endpoint
Add Prometheus metrics endpoint with local-bind listener support and baseline metrics collectors.
2026-04-28 14:29:01 +08:00
devv-eve
c381d59c7a fix: preserve authored markdown links during linkify (#1761)
Co-authored-by: Eve <eve@multica.ai>
2026-04-28 08:57:15 +08:00
Bohan Jiang
1292ecf71b fix(labels): apply label attach optimistically (#1746)
* fix(labels): apply attach optimistically so chips render before round-trip

Attach went through onSuccess only, so users waited for the server
before seeing the new chip — out of step with detach (already optimistic)
and with status/assignee/priority via useUpdateIssue. Mirror the detach
pattern: snapshot the byIssue cache, look up the full label from the
workspace list cache, patch byIssue + the issue list/detail caches via
onIssueLabelsChanged in onMutate, and roll back on error. onSuccess and
onSettled keep the existing reconcile behavior.

* fix(labels): only patch attach when prev label set is known

GPT-Boy's review caught a corruption case: when byIssue cache was
unpopulated (user clicked before issueLabelsOptions resolved), the
optimistic patch fell back to an empty prev.labels, then mirrored
[label] into issue list/detail via onIssueLabelsChanged — wiping any
denormalized labels already on the issue. Worse, onError only restored
byIssue when ctx.prev existed, so the wipe persisted on failure.

Match useDetachLabel's invariant: skip the optimistic patch unless prev
is in cache. The chip will wait for the round-trip in the rare race
window, but caches stay consistent and rollback always works.
2026-04-27 18:24:40 +08:00
Bohan Jiang
b77acdf642 fix(comments): cancel triggered tasks when comment is deleted (#1747)
When a user deletes a comment that triggered an agent task, the agent
would still run with the now-deleted content baked into its prompt
(fetched at task claim time) — manifesting as "the agent still sees the
deleted comment". The FK ON DELETE SET NULL only nullified
trigger_comment_id; the queued task itself was never cancelled.

DeleteComment now cancels any queued/dispatched/running task whose
trigger is the deleted comment, before the comment row is removed.
2026-04-27 18:24:07 +08:00
dyjxg4xygary
6bd5bbad9c fix: timeout stalled Codex turns (#1730)
* fix: timeout stalled codex turns

* fix: count codex progress events as activity
2026-04-27 18:23:31 +08:00
songlei
4c81fbed2b fix(daemon/windows): break out of parent shell Job Object so daemon survives
Approved and merged via Multica after CI passed.
2026-04-27 17:47:30 +08:00
Alex Fishlock
d63e7c1c45 ci(release): skip homebrew-tap publish on forks (#1687)
The release job uses GoReleaser to bump the formula in
multica-ai/homebrew-tap. Forks don't have HOMEBREW_TAP_GITHUB_TOKEN
and should not publish to that tap, so the job currently fails on
every fork tag push (401 Bad credentials against the upstream tap).
This makes the workflow red on downstream forks even though the
actual artifact pipeline (verify → docker-backend-build →
docker-backend-merge) succeeds and produces a usable image.

Gate the release job on `github.repository_owner == 'multica-ai'`.
Upstream behaviour unchanged. Forks now see a clean green run for
docker artifacts only.
2026-04-27 17:47:11 +08:00
Bohan Jiang
dabebe0c12 docs(changelog): publish v0.2.18 release notes (#1745)
* docs(changelog): publish v0.2.18 release notes

Today's release covers 13 PRs since v0.2.17. Spotlight is the full Issue
Labels feature (backend + CLI + Web UI), plus the Labs settings tab,
sidebar invitation indicator, and the sharded Redis realtime relay.
Improvements and fixes round out comment rendering, project-icon usage
across the app, self-host env-var pass-through, and several
Windows-specific agent issues.

* docs(changelog): simplify v0.2.18 entries

Trim each line to a short, user-facing sentence; drop implementation
detail (sharded relay, build-id symlinks, --description-stdin, etc.) per
review feedback that the previous draft was too detailed.
2026-04-27 17:34:07 +08:00
Bohan Jiang
d14265de2a fix(comments): preserve newlines from agent CLI writes (#1744)
* fix(comments): preserve newlines from agent CLI writes

Agents (e.g. Codex) routinely emit `multica issue comment add --content
"para1\n\npara2"` because Python/JSON-style string literals are their
default. Bash does not expand `\n` inside double quotes, so the literal
4-char sequence flowed through the CLI into the database and rendered
as text in the issue panel — comments came out as one wall of prose.

Three coordinated fixes so the platform behavior no longer depends on
whether a given model has strong bash-quoting intuition:

- CLI: decode `\n / \r / \t / \\` in `--content` and `--description` for
  `issue create / update / comment add` (callers needing a literal
  backslash still have `--content-stdin`).
- Agent prompt: rewrite the comment-add example in the injected runtime
  config to require `--content-stdin` + HEREDOC for any multi-line body,
  and call out the same rule for `--description`. The previous wording
  flagged stdin only for "backticks, quotes", which models read as
  irrelevant to plain paragraphs.
- Renderer: add `remark-breaks` to the shared Markdown plugin chain so a
  bare `\n` becomes a visible line break instead of a CommonMark soft
  break — protects against models that emit single newlines for
  formatting.

Tests: pin the new CLI helper, and pin the runtime-config guidance so
the multi-line wording cannot decay back into a footnote.

* fix(comments): address review feedback on newline-rendering PR

- Cover the issue panel: ReadonlyContent (used by every comment card and
  the issue description) has its own react-markdown wiring; add
  remark-breaks there too so the renderer fix actually applies to the
  surface the bug was reported on, not just the chat panel. Pinned by
  ReadonlyContent line-break tests.
- Make the prompt's `--description` guidance executable: add
  `--description-stdin` to `issue create` / `issue update`, refactor
  comment-add to share a single `resolveTextFlag` helper, and have the
  injected runtime config name the real flag instead of an imaginary
  "stdin / a tempfile" path. Pinned by the runtime-config guidance test.
- Document the unescape contract on each affected flag's help text and
  pin the precise boundary in tests: `\n / \r / \t / \\` are decoded;
  `\d / \w / \s / \u / \0` and other unrecognised escapes pass through
  verbatim, so regex literals and Windows paths survive intact unless
  they embed a literal `\n` / `\r` / `\t`. Callers that need the literal
  sequence have `--content-stdin` / `--description-stdin` as the escape
  hatch.
2026-04-27 17:17:34 +08:00
Bohan Jiang
bf6509be96 fix(issues): show labels in my-issues view + place chips after title (#1743)
- my-issues page lost labels because myIssuesViewStore cherry-picked
  name/storage/partialize from viewStorePersistOptions and dropped the
  cardProperties-aware merge. Persisted snapshots predating the labels
  toggle had cardProperties.labels = undefined, falsy-shorting the chip
  render. Extracted mergeViewStatePersisted as a generic and wired it
  into both stores.
- list-row chips now render right after the title (with a small left
  margin for breathing room) instead of in the right-aligned cluster.
2026-04-27 16:50:13 +08:00
Bohan Jiang
6620997503 feat(issues): render labels on list/board with bulk server-side fetch (#1741)
* feat(issues): render labels on list/board with bulk server-side fetch

ListIssues / ListOpenIssues / GetIssue now bulk-fetch labels per response
via a new ListLabelsForIssues query so the client gets labels in a single
round-trip instead of N requests per visible issue. List-row and board-card
read issue.labels directly; an issue_labels:changed WS handler patches the
list and detail caches in place so chips stay live across tabs, and
attach/detach mutations mirror their result into the same caches for
immediate same-tab feedback.

Adds a "Labels" toggle to the card properties dropdown (defaults on).

* fix(issues): preserve cached labels and refresh on label edit/delete

Three fixes from gpt-boy's review of #1741:

1. IssueResponse.Labels was a non-omitempty slice, so paths that didn't
   load labels (UpdateIssue, batch updates, the issue:updated WS broadcast)
   serialized labels:null. onIssueUpdated then merged that null into the
   list/detail caches, wiping chips on every other tab whenever any non-
   label field changed. Switched to *[]LabelResponse + omitempty: nil =
   field absent (client merge keeps existing labels); non-nil (incl. empty
   slice) = authoritative.

2. issue.labels is a denormalized snapshot, but useUpdateLabel /
   useDeleteLabel and the WS label:* prefix only touched labelKeys, leaving
   stale chips in list/board after rename/recolor/delete. Mutations now
   also invalidate issueKeys.all(wsId), and the realtime refreshMap maps
   the label prefix to both labels and issues invalidation for cross-tab.

3. Persisted cardProperties from before this branch lacks the new `labels`
   key. Render fell back to `?? true` but the dropdown switch read it raw
   and showed unchecked. Added a custom Zustand merge that deep-merges
   cardProperties so newly added toggles inherit defaults for existing
   users; dropped the `?? true` fallbacks now that the store guarantees
   the key.
2026-04-27 16:33:34 +08:00
Naiyuan Qing
e268ee3e71 refactor(views): centralize project icon rendering and fix nav active state (#1738)
Extract <ProjectIcon> with sm/md/lg sizes and a single 📁 fallback,
replacing 9 inline render sites that had drifted into 6 different
sizes and a mixed FolderKanban/emoji fallback.

Two visible fixes fall out of the centralization:
- ProjectPicker trigger now shows the selected project's icon (most
  visibly in the issue detail right Properties panel, where it had
  always been a generic FolderKanban).
- Sidebar parent nav (Projects, Issues, Settings, ...) now stays
  highlighted on child detail routes via a small isNavActive helper.
  Pinned items keep strict equality.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-27 14:42:56 +08:00
Ayman Alkurdi
e9d04ecfc1 feat(labels): ship issue labels (closes #1191) (#1233)
* feat(labels): add issue label CRUD + attach/detach handlers (#1191)

The issue_label and issue_to_label tables were scaffolded in 001_init.up.sql
but never wired to any code path. This commit ships the backend for #1191:

- Migration 048: adds created_at/updated_at timestamps + workspace-scoped
  case-insensitive unique index on label names
- sqlc queries for label CRUD + issue<->label attach/detach + batch list
  (ListLabelsByIssueIDs for board/list views)
- HTTP handlers: /api/labels CRUD, /api/issues/{id}/labels attach/detach
- Protocol events: label:{created,updated,deleted} + issue_labels:changed
- Handler tests covering CRUD, duplicate-name conflict, invalid-color,
  attach/detach idempotency, and cross-workspace isolation

* feat(cli): add label and issue label subcommands (#1191)

- multica label {list,get,create,update,delete}
- multica issue label {list,add,remove}

Both follow existing CLI conventions (JSON/table output, flag shapes)
and exercise the /api/labels endpoints shipped in the previous commit.

* feat(web): add labels UI — picker with inline create + management dialog (#1191)

Exposes the backend label feature to users via the existing issue-detail
sidebar.

- `@multica/core/types/label` — Label, CreateLabelRequest, UpdateLabelRequest,
  plus response envelopes
- `@multica/core/api/client` — 8 methods for label CRUD and issue↔label
  attach/detach
- `@multica/core/labels` — labelKeys, queryOptions, and mutation hooks with
  optimistic updates (matches the project/ module layout)
- WS event type literals extended for label:{created,updated,deleted} and
  issue_labels:changed

- `views/labels/label-chip.tsx` — colored pill; uses relative luminance
  (ITU-R BT.601) to pick #111827 or #f9fafb text so chips stay readable on
  both pastel and saturated backgrounds
- `views/issues/components/pickers/label-picker.tsx`
  - Multi-select combobox in the issue sidebar
  - When 0 labels: "Add label" trigger
  - When 1+ labels: the chips themselves are the trigger; × on each chip
    detaches without opening the picker
  - Inline create: typing a new name + Enter creates with a hash-derived
    color and attaches in one motion (matches Linear/GitHub)
  - "Manage labels…" footer opens a dialog containing the full workspace
    panel — users never leave the issue context to rename/recolor/delete
- `views/issues/components/labels-panel.tsx` — workspace labels manager.
  Single-row create form (color swatch + name + Add button). Each label
  row supports inline rename + recolor + delete (with confirm dialog).
  Color input uses the browser's native picker for full-gamut access —
  no preset palette clutter.

- `PropRow label="Labels"` added to the issue-detail sidebar below Project

Labels are issue metadata everyone uses — not admin configuration.
Putting them in Settings next to destructive workspace actions misframed
them; adding a top-level nav entry or a sibling tab to the Issues page
added surface area that wasn't earning its keep for a feature users
touch occasionally. Keeping management in a dialog launched from the
picker itself keeps users in their issue context and matches how GitHub
handles label editing from the label selector.
2026-04-27 14:23:42 +08:00
Bohan Jiang
2e7da8c63f fix(desktop): disable RPM build-id symlinks to avoid Slack conflict (#1734)
Electron apps share an identical upstream Electron binary, so its GNU
build-id is the same across every Electron RPM (Slack, VS Code, Discord,
etc.). The default fpm/rpm behavior owns /usr/lib/.build-id/<hash>
symlinks, which collide between packages and make `dnf install` fail
when any other Electron app is already installed.

Pass `_build_id_links none` to rpmbuild via fpm so the multica-desktop
RPM no longer claims those paths.

Fixes multica-ai/multica#1723.
2026-04-27 14:11:16 +08:00
Jiayuan Zhang
04882c2201 feat(labs): Add labs settings tab (#1732) 2026-04-27 13:46:25 +08:00
devv-eve
ba2f19d631 fix: refresh agent status from active tasks (#1733)
Co-authored-by: Eve <eve@multica.ai>
2026-04-27 13:34:24 +08:00
devv-eve
7f6776b12f fix: harden Windows CLI architecture detection
* fix: harden windows cli architecture detection

* fix: avoid duplicate windows architecture signals

---------

Co-authored-by: Eve <eve@multica.ai>
2026-04-27 13:01:53 +08:00
Truffle
8b340fcf21 fix(agent/opencode): bypass npm .cmd shim on Windows to preserve multi-line prompts (#1718)
* fix(agent/opencode): bypass npm .cmd shim on Windows to preserve multi-line prompts

The npm-generated `opencode.cmd` shim forwards argv via Windows batch `%*`,
which silently truncates positional arguments at the first newline. The
daemon spawns OpenCode with a multi-line prompt (system prompt + user
message), so on Windows the agent only ever sees the first line and
responds generically as if it never received the user's message
(reported in #1717 with native-binary repro confirming the same prompt
arrives intact when cmd.exe is skipped).

When `runtime.GOOS == "windows"` and `exec.LookPath` returns a `.cmd`
shim, walk to the native binary that npm bundles next to the shim:

  <prefix>\opencode.cmd
  <prefix>\node_modules\opencode-ai\node_modules\opencode-windows-x64\bin\opencode.exe

If the native binary is missing (unusual install layout), keep the
original shim path so PATH lookup still wins. The resolver is a pure
function with an injectable `statFn`, so layout assertions are testable
on Linux:

- shim resolves to the bundled native binary
- missing native returns "" (caller keeps original path)
- non-cmd paths (Linux/Mac binary, opencode.exe direct, empty) skip resolution
- uppercase `.CMD` is accepted (PATHEXT entries can be either case)

Closes the user-facing failure mode without restructuring exec resolution
across the rest of the agent backends — the other shim-aware fixes can
follow the same shape if/when they land in similar repros.

* fix(agent/opencode): cover x64-baseline and arm64 npm package variants

`npm install -g opencode-ai` ships three Windows platform packages
(opencode-windows-x64, opencode-windows-x64-baseline for older CPUs
without AVX2, opencode-windows-arm64 for Surface / Copilot+ PC) and
installs whichever matches the host. The previous resolver only knew
about opencode-windows-x64, so baseline-x64 and arm64 hosts would fall
back to the .cmd shim and hit the multi-line prompt truncation again.

Iterate the three package candidates in GOARCH-preferred order. ARM64
hosts try arm64 first; everything else tries x64, then baseline, then
arm64 as a last resort. Cost is one extra statFn call per miss when
the GOARCH-preferred package isn't installed.

Surfaced by review on #1718.

* test(agent): add Windows counterpart to writeTestExecutable

writeTestExecutable in exec_fixture_unix_test.go is referenced by
claude_test.go / codex_test.go / kimi_test.go, but the //go:build unix
constraint meant `go test ./pkg/agent` failed to build on Windows.

ETXTBSY is a Linux/Unix fork-exec race; Windows doesn't have that
pathology, so a plain os.WriteFile is sufficient.

Lifted from #1719 (Codex) with attribution. Surfaced by review on #1718.
2026-04-27 12:16:56 +08:00
supercon99
1f770813dd fix(selfhost): pass ALLOW_SIGNUP / ALLOWED_EMAILS / ALLOWED_EMAIL_DOMAINS to backend (#1726)
docker-compose.selfhost.yml documents these as load-bearing in .env.example
but the backend service never received them, so allowlist / signup-gating
configs were silently ignored on self-hosted deployments. Wires the three
vars through with defaults matching .env.example.
2026-04-27 12:16:15 +08:00
Muhammadrizo
29122cc18b feat(sidebar): add dot to show the user about new invintation (#1711) 2026-04-27 11:41:03 +08:00
LinYushen
18524d80d0 Implement sharded Redis realtime relay (#1702)
* Implement sharded Redis realtime relay

* Isolate dual relay read pools

* Surface mirrored relay publish divergence
2026-04-26 12:03:06 +08:00
LinYushen
141c294cdb P0: isolate Redis relay pools (#1701)
* Isolate Redis relay pools

* Fix Redis relay shutdown order
2026-04-26 11:26:13 +08:00
Black
04f813a70f fix PR 1573 follow-up colors (#1699) 2026-04-26 11:14:40 +08:00
Bohan Jiang
c7a2d53f76 docs(changelog): publish v0.2.17 release notes (#1700)
* docs(changelog): publish v0.2.17 release notes

Covers commits between v0.2.16 (2026-04-24) and the v0.2.17 cut
(2026-04-26): --custom-env flag for agents, agent CLI stderr tail in
failure messages, configurable update download timeout, plus reliability
fixes around daemon cancellation, server heartbeat, Codex execenv, Pi
skills path, Windows console, CJK markdown URLs, attachment downloads
and autopilot run-only context.

Both en.ts and zh.ts updated.

* docs(changelog): trim small/internal items from v0.2.17 entry

Drops items that read as internal polish or were too narrow to belong in
release notes:
- Skills landing intro polish
- Codex execenv plugin-cache cleanup
- CLI exact-name/ShortID assignee resolution
- Settings invite role label rendering
- Skills SKILL.md fast-path
- CJK markdown URL-boundary fix
- Relative attachment download URLs

Keeps the user-facing wins: --custom-env, stderr-tail in failure
messages, configurable update timeout, cancelled-task classification,
heartbeat probe/claim split, plus the higher-impact fixes.
2026-04-26 11:10:20 +08:00
Bohan Jiang
aca74293dd fix(agent/claude): surface stderr tail on writeClaudeInput failure + lock with e2e test (#1698)
#1674 wired claude's post-handshake error path through withAgentStderr but
left the writeClaudeInput failure branch returning a bare "broken pipe"
error. That branch fires precisely when claude crashes during startup —
exactly when the stderr tail is most useful for root-causing V8 aborts,
Bun panics, or missing native modules. cmd.Wait() before sampling Tail()
flushes os/exec's internal stderr copy goroutine, matching the
Wait→Tail synchronization contract spelled out in stderr_tail.go.

Adds TestClaudeExecuteSurfacesStderrWhenChildExitsEarly mirroring the
codex test: a fake claude binary drains stdin, writes a V8-abort line to
stderr, and exits 3. Locks in the contract that Result.Error carries the
stderr tail in the post-handshake failure path on the claude backend too.
2026-04-26 11:09:38 +08:00
Bohan Jiang
12e6ca9906 refactor(execenv): collapse codex plugin cache stale-link branches (#1697)
Merge the two symlink removal branches in exposeSharedCodexPluginCache —
they shared the same os.Remove + recreate path with only the error label
differing. The branch is now keyed off Lstat's ModeSymlink bit, with
Readlink reused only to fast-path an already-correct link. Behaviour is
unchanged; just less duplicated code.
2026-04-26 11:05:08 +08:00
jmoney8896
3c3e3bd330 fix(task): reconcile agent status when cancelling tasks by issue (#1587) (#1648)
CancelTasksForIssue silently dropped the list of affected tasks, so
whenever an issue transitioned to "cancelled" or "done" while a task was
still active (6 call sites in issue.go), the underlying agent was left
stuck at status="working" indefinitely and required a manual
`multica agent update <id> --status idle` to self-correct. This matches
the symptom reported in #1587: task rows move to "cancelled" via a
non-user-initiated path, agent status never reconciles.

Change CancelAgentTasksByIssue from :exec to :many (also tack on
completed_at = now() for consistency with CancelAgentTasksByIssueAndAgent),
then update CancelTasksForIssue to iterate the returned rows and call
ReconcileAgentStatus + broadcast task:cancelled per affected task —
mirroring the pattern already used by CancelTask and RerunIssue.

No test added; the change is small and mirrors well-covered paths.
Happy to add a mock-backed test in a follow-up if reviewers prefer.

Refs #1587
Refs #1149
2026-04-26 10:58:42 +08:00
Y. L.
25b393df17 fix(execenv): hydrate Codex skill sources (#1668)
Expose the shared Codex plugin cache inside each per-task CODEX_HOME before launch so plugin-provided skills are available on the first session.

Refresh agent-assigned workspace skills for both newly prepared and reused Codex environments, and cover plugin cache plus reuse behavior with focused execenv tests.
2026-04-26 10:57:51 +08:00
songlei
6f04a6d26b feat(agent): surface agent CLI stderr tail in failure messages (#1674)
Hoist the existing stderrTail ring-buffer (previously codex-only) into
a shared pkg/agent helper so every Backend that supervises a child CLI
can include the last ~2 KB of that CLI's stderr in Result.Error. Wire
the claude backend through the same path.

Motivation: claude on Windows occasionally exits with a non-zero status
after ~5–8 minutes of a single long-running tool_use, and right now the
daemon only reports "claude exited with error: exit status 3" /
"exit status 0x80000003" — useless for root-causing V8 aborts, Bun
panics, native-module OOMs, or any other CLI-side crash. With the tail
attached, the failure message carries the real signal (panic line, V8
assertion, stderr-printed HTTP error) all the way into the task row's
error field that users see in the API.

Renames withCodexStderr to withAgentStderr(msg, label, tail) so the
helper is self-documenting across providers.
2026-04-26 10:55:21 +08:00
Bohan Jiang
58547faf31 fix(server): validate assignee_id existence on issue create/update (#1694)
* fix(server): validate assignee_id existence on issue create/update

POST /api/issues and PUT /api/issues/:id silently accepted any
well-formed UUID as assignee_id (#1662). The new validateAssigneePair
helper consolidates the existing canAssignAgent check and adds:

- existence lookup against workspace members for assignee_type=member
- existence lookup against workspace agents for assignee_type=agent
- pair consistency: type and id must be both set or both null
- whitelist for assignee_type values (member|agent)

UpdateIssue and BatchUpdateIssues now run the same validator on the
post-merge assignee pair whenever the caller touches either field,
closing the parallel gap on the update path.

* fix(server): reject malformed assignee_id at handler entry

parseUUID silently returns an invalid pgtype.UUID for unparseable input
and validateAssigneePair treats (type unset + id invalid) as "no
assignee". Together they let `POST /api/issues` and `PUT /api/issues/:id`
silently drop a malformed assignee_id and return a successful response.

Reject the parse failure inline at every entry point — Create, Update,
and BatchUpdateIssues — so the validator never sees an unparseable id.
Adds two regression tests covering the create and update paths.
2026-04-26 10:35:47 +08:00
Magnus Handeland
9b55b2a9ce feat(cli): add --custom-env flag to agent create/update (#1518)
* feat(cli): add --custom-env to agent create/update

Adds a JSON-object flag on `multica agent create` and `multica agent
update` that writes the agent's `custom_env` map via the existing
handler API. Needed so runtime bearer tokens (e.g. SECOND_BRAIN_TOKEN)
can be provisioned from the CLI without falling back to curl or
admin-only UI access.

- `--custom-env '{"KEY":"value"}'` → sets the map.
- `--custom-env '{}'` or `--custom-env ''` → clears the map on update
  (server treats a non-nil empty map as "clear all entries").
- Omitted flag → no change.
- Help text flags the value as secret material and never logged.
- Table-driven tests cover the parser (valid, clear, invalid JSON,
  wrong shape) plus flag discoverability on both commands.

* feat(cli): add --custom-env-{stdin,file}; sanitize parse errors

Security review of the --custom-env flag (PR #1518) surfaced two issues:

1. Secrets on the command line leak via shell history and /proc/<pid>/cmdline
   regardless of CLI logging. Add --custom-env-stdin and --custom-env-file
   as mutually-exclusive alternatives, and update the --custom-env help
   text to warn about shell history / 'ps' exposure so the "never logged"
   claim is no longer misleading.

2. parseCustomEnv wrapped json.Unmarshal errors with %w; SyntaxError /
   UnmarshalTypeError can surface fragments of the (secret) input. Return
   a fixed, content-free message instead.

Refactor the body-assembly blocks in both agentCreateCmd and
agentUpdateCmd to go through a single resolveCustomEnv helper so the
three input channels behave identically. Tests cover every channel,
mutual exclusion, error sanitization, and help-text wording.

* fix(cli): require explicit '{}' to clear custom_env; sanitize --custom-args errors

Address PR #1518 review feedback from @Bohan-J:

1. parseCustomEnv now errors on empty/whitespace input. The clear signal
   is the explicit '{}' object only. The previous behavior silently wiped
   the secret map when an upstream pipe was empty (cat missing.json |
   ... --custom-env-stdin without set -o pipefail) or when --custom-env-file
   pointed at an empty file. resolveCustomEnv emits channel-specific error
   messages (e.g. "--custom-env-stdin: empty input; pass '{}' to clear").

2. Drop the '&& filePath != ""' guard so an explicit --custom-env-file ""
   surfaces an error instead of being silently ignored.

3. Rewrite TestAgentUpdateNoFieldsMentionsCustomEnv into
   TestAgentUpdateNoFieldsErrorMentionsAllCustomEnvFlags — the body now
   actually runs runAgentUpdate with no flags and asserts the resulting
   "no fields" error names all three --custom-env channels.

4. Extract parseCustomArgs helper. Replace the '%w'-wrapped json error
   with a content-free message, mirroring parseCustomEnv. Although
   custom_args is not a dedicated secret channel, callers regularly stuff
   sensitive values like "--api-key=..." into it, so json.Unmarshal must
   never echo input fragments. Adds TestParseCustomArgsErrorSanitization.

Also adds resolveCustomEnv subtests for stdin/file empty-input, empty
file contents, empty file path, and explicit '{}' positive cases.

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

---------

Co-authored-by: Implementer (Multica Agent) <implementer@multica-agent.local>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-26 10:32:55 +08:00
Bohan Jiang
c7bac0aa6b docs(changelog): publish v0.2.16 release notes (#1695)
Covers everything between v0.2.15 (2026-04-22) and v0.2.16 (2026-04-24):
Chat V2, issue right-click context menu, in-app feedback + Help launcher,
Autopilot modal redesign, Skills page redesign, bilingual flat docs site
rewrite, plus the supporting agent / runtime / chat / desktop fixes.

Both en.ts and zh.ts updated.
2026-04-26 10:22:53 +08:00
Bohan Jiang
101601a4c3 fix(settings): render invite role label via roleConfig in members tab (#1693)
The invite-member role Select rendered the raw value ("member"/"admin")
in the trigger because Base UI's SelectValue defaults to the value, not
the item text. PR #1672 worked around it with `className="capitalize"`,
but this file already owns a roleConfig map with proper labels and the
codebase has an established render-prop pattern for SelectValue (see
trigger-config.tsx and runtime-local-skill-import-panel.tsx).

Use roleConfig[inviteRole].label inside SelectValue and reuse the same
labels for SelectItem children. Single source of truth for role display
names; future role additions or i18n won't depend on CSS capitalize.
2026-04-26 09:43:35 +08:00
Bohan Jiang
95912243bb test(daemon): cover cancelled classification in executeAndDrain (#1692)
Follow-up to #1686. Locks in two nits flagged during review:

1. agent.Result.Status doc comment now lists "cancelled" alongside the
   existing values, so the enum surface matches actual usage.
2. New TestExecuteAndDrain_ContextCancelled_ReportsCancelled exercises
   the path added in #1686: when the parent context is cancelled before
   the backend produces a Result, executeAndDrain must return
   Status="cancelled" (not "timeout"). A regression here would silently
   restore the misleading log line we just fixed.
2026-04-26 09:27:13 +08:00
Kagura
24e135541b fix(server): use resolved issue ID in DeleteIssue handler (#1680)
DeleteIssue passed the raw URL parameter through parseUUID(), which
returns a zero UUID for human-readable identifiers like "API-123".
This caused DELETE requests with identifier-style IDs to silently
succeed (204) without actually deleting the issue.

Use issue.ID from the already-resolved issue object instead, consistent
with BatchDeleteIssues and all other operations in the same handler.

Fixes #1661
2026-04-26 09:24:19 +08:00
Alex Fishlock
2df969cffc fix(daemon): report cancelled tasks as "cancelled", not "timeout" (#1686)
When the server cancels a task (e.g. assignee changes during execution,
explicit user cancel, or workspace_isolation check fail), the daemon's
cancellation poll fires runCancel() on the run context. The drainCtx
derived from runCtx then signals Done(), but executeAndDrain() was
returning Status: "timeout" regardless of *why* the context ended.

The "agent finished status=timeout" log line is then misleading — it
suggests an actual deadline timeout when really the task was cancelled
by upstream. We spent hours misdiagnosing a healthy handoff as a
broken timeout because of this.

Distinguish context.Canceled from context.DeadlineExceeded in
executeAndDrain, and add a "cancelled" case to runTask so the status
propagates through the existing log path.

No behaviour change for genuine timeouts; no behaviour change for
the cancelled-by-poll discard path in handleTask. Only the daemon
log line and TaskResult.Status get the more accurate label.
2026-04-26 09:23:32 +08:00
lmorgan-yozu
5eab1dbbe1 fix: handle relative attachment download URLs
Resolve server-relative attachment download URLs against the CLI server base URL while preserving signed absolute URL behavior.
2026-04-25 02:13:18 +08:00
Bohan Jiang
a89064d693 docs: clean up leftover .pi/agent/skills references (#1645)
PR #1632 updated the Pi project-level skill dir from
.pi/agent/skills/ to .pi/skills/, but missed two references:

- server/internal/daemon/execenv/runtime_config.go:20 — the comment
  block here lists project-level paths for every other provider, so
  using Pi's global path was inconsistent and misleading.
- docs/docs-rewrite-plan.md:88 — planning doc still listed the old
  path in the Skills row.

Follow-up to #1632.
2026-04-25 02:08:33 +08:00
etern
68a312c297 fix(runtimes): fix pi skills dir to: .pi/skills (#1632)
change .pi/agent/skills to .pi/skills

Pi loads skills from:

Global:
  ~/.pi/agent/skills/
  ~/.agents/skills/
Project:
  .pi/skills/
  .agents/skills/

- ref: https://github.com/badlogic/pi-mono/blob/main/packages/coding-agent/docs/skills.md#locations
2026-04-25 02:06:25 +08:00
Bohan Jiang
683ff132ca fix(server/heartbeat): probe/claim split + slow-log + model-list running timeout (#1644)
Mitigates #1637 and the related model-discovery failure in MUL-1397 by bounding the /api/daemon/heartbeat hot path with an ack-safe probe/claim split, adding structured slow-log attribution, and closing the ModelListStore running-state gap. See PR description for details.
2026-04-25 02:06:00 +08:00
Truffle
93fe324bb9 fix(skills): fast-path root-level SKILL.md with frontmatter guard (#1625)
Closes the functional gap the reporter hit on alchaincyf/huashu-design
(skills.sh/alchaincyf/huashu-design/huashu-design) without expanding
candidatePaths unconditionally, which would let an unrelated root
SKILL.md hijack a different skill URL in a multi-skill repo.

Try SKILL.md at the repo root before falling into the recursive tree
fallback added in #1432. Verify the frontmatter name matches the
requested skill so only genuine single-skill repos take the fast path.
For those repos this also shaves the recursive tree API call.

Also clarifies the candidate-path comment so the root case is
explicit.
2026-04-25 01:40:23 +08:00
Bohan Jiang
74593fdb88 fix(daemon): use CREATE_NEW_CONSOLE to stop grandchild console popups on Windows (#1521) (#1643)
* fix(daemon): use CREATE_NEW_CONSOLE to stop grandchild console popups on Windows (#1521)

CREATE_NO_WINDOW strips the console entirely. When the agent CLI then
spawns a console-subsystem grandchild (bash, cmd, netstat, findstr,
timeout) without itself passing CREATE_NO_WINDOW, Windows allocates a
brand-new visible console window per invocation — trading one popup per
agent run for N popups per tool call.

Switch to CREATE_NEW_CONSOLE + HideWindow=true so the agent gets a
hidden console that grandchildren inherit. Stdio pipes still work via
STARTF_USESTDHANDLES; no changes needed at the 17 hideAgentWindow call
sites.

Add a Windows-only regression test asserting CREATE_NEW_CONSOLE is set
and CREATE_NO_WINDOW is not, per the #1474 Windows-test follow-up.

Root-cause diagnosis by @matrenitski (verified against the shipped
multica.exe and the Claude Code CLI it spawns) in issue #1521.

* test(agent): use CREATE_NEW_CONSOLE-compatible flag in preservation test

CREATE_NEW_PROCESS_GROUP is silently ignored by Windows when combined
with CREATE_NEW_CONSOLE, so asserting it 'survives' was only bitwise-true,
not semantically meaningful. Switch the example to
CREATE_UNICODE_ENVIRONMENT (documented compatible) and also assert a
non-flag field (NoInheritHandles) survives to exercise full struct
preservation.
2026-04-25 01:40:15 +08:00
Bohan Jiang
60fdc82824 fix(cli): resolve assignee by exact name or ShortID to avoid substring collisions (#1642)
`multica issue assign --to <name>` matched agent/member names with a plain
`strings.Contains` check, so an exact match on `reviewer` became ambiguous
whenever a longer agent like `peer-reviewer` also existed. There was also
no way to disambiguate by ID.

Rework `resolveAssignee` to bucket candidates by priority:
1. Full UUID or 8-char ShortID (matches `truncateID` output) — case-insensitive.
2. Case-insensitive exact name (with surrounding whitespace trimmed).
3. Substring fallback — preserves the existing partial-name UX.

The first non-empty bucket wins. Ambiguity inside a higher-priority bucket
still errors and short-circuits lower-priority matching.

All six call sites (`issue assign/update/create/list`, `issue subscriber`,
`project`) are fixed by this single change.

Fixes #1620
2026-04-25 01:05:29 +08:00
Naiyuan Qing
c3ae212b40 fix(markdown): treat CJK full-width punctuation as URL boundary (#1630)
linkify-it only recognizes ASCII characters as URL boundaries. In Chinese
or Japanese text a URL followed by "。" (or any other full-width
punctuation) was greedily swallowed into the URL along with everything up
to the next whitespace, producing hrefs like
`https://.../pull/1623。merge` that 404 when clicked.

Truncate the detected URL at the first CJK full-width punctuation
character and re-scan the tail, so adjacent URLs separated only by
full-width punctuation are still each linked individually. The
terminator character set mirrors the fix applied in mattermost/marked#22.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 18:47:47 +08:00
Joey
d17b2bfb8c feat(cli): 添加更新下载超时配置选项 (#1622)
- 在 update 命令中添加 --download-timeout 标志用于设置下载超时时间
- 实现 UpdateViaDownloadWithTimeout 函数支持自定义下载超时
- 添加 updateDownloadTimeoutOrDefault 辅助函数处理超时值验证
- 设置默认下载超时时间为 120 秒
- 添加 updateDownloadTimeoutOrDefault 函数的单元测试
- 验证超时参数必须大于零的错误处理逻辑
2026-04-24 17:05:23 +08:00
devv-eve
13d9d7df1b fix: pass autopilot run-only context to agents
Fix run-only autopilot tasks so agents receive autopilot context instead of empty issue instructions. Add regression coverage for run-only terminal event sync.
2026-04-24 16:36:04 +08:00
Naiyuan Qing
71b2032174 feat(skills): restore page description, link to docs, polish intro layout (#1618)
* feat(skills): restore page description, link to docs, polish intro layout

The previous card-layout refactor (#1614) dropped the page-top
description entirely; without it the page jumps straight from the
PageHeader to a brand-colored banner that explains *how sharing works*,
with nothing answering "what IS a skill?". Bring the description back,
add a docs entry point, and tighten the visual hierarchy so the intro
block reads as one coherent unit above the table card.

- Restore a one-line description as the page's primary intro:
  "Instructions any agent in this workspace can use." — uses "any agent
  ... can use" (capability, not factual usage) since skills must be
  manually attached to take effect.
- Add an inline "Learn more about Skills →" link mirroring the
  onboarding docs-link pattern (muted underline, new tab) — opens
  https://multica.ai/docs/skills.
- Visual hierarchy: description is text-base + text-foreground (primary),
  link is text-xs + text-muted-foreground (auxiliary). Same line, eye
  follows weight order.
- Banner padding bumped from px-3 py-2 to px-4 py-3 so it breathes and
  its inner text lands at the same x as the table content.
- Wrap description + banner in a shared `pl-4 space-y-3` so they read as
  one intro block, indented to align with the table card's content.
- Loading skeleton updated to mirror the new structure.

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

* feat(skills): keep docs link underline subtle, only animate text color on hover

The underline was inheriting text-decoration-color from the link's text,
so when hover bumped the text from muted to foreground the underline
got darker too — making the link feel more prominent on hover than at
rest, the opposite of what we want for a tertiary docs link.

Pin decoration-color to muted-foreground/30 explicitly so it stays
faint regardless of hover state. Only the text color transitions; the
underline stays as a constant low-key marker that the element is a link.

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-04-24 15:35:33 +08:00
Naiyuan Qing
f7fe0829f2 refactor(skills): wrap list as card, use shared PageHeader, add scroll fade (#1614)
The skills page rolled its own HeroHeader instead of the shared PageHeader,
which meant no mobile sidebar trigger and visual drift from other list
pages. The table was also edge-to-edge inside the dashboard container, so
it felt "naked" compared to the rest of the product.

- Replace custom HeroHeader with shared PageHeader (gives mobile hamburger
  and h-12 chrome for free); move "New skill" into the PageHeader as the
  page-level action.
- Keep search + scope filters in a toolbar, but move that toolbar *inside*
  a bordered, rounded card together with the table, so the whole unit
  reads as a single scrollable surface with internal padding.
- Use the existing useScrollFade hook on the row list so the top/bottom
  edges fade while scrolling.
- Drop `divide-y` in favor of `border-b` per row — divide-y leaves the
  last row without a bottom rule, which looks unfinished when only a
  couple of skills exist and the scroll area is taller than the content.
- Drop the redundant description paragraph from the old hero; keep the
  "Shared with your workspace" banner above the card since it carries
  non-obvious UX context.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 15:04:09 +08:00
LinYushen
9e1e3981fb fix(workspace): defense-in-depth owner check in DeleteWorkspace handler
Adds an owner check inside DeleteWorkspace as defense-in-depth and covers both router-level and direct handler paths.
2026-04-24 14:29:39 +08:00
Naiyuan Qing
c7e725ef66 feat: surface docs from onboarding + landing, unify Autopilot naming (#1613)
* docs(autopilot): rename Routines → Autopilots to match product UI

Unify naming between docs and product. Sidebar label, URL route,
CLI command, and onboarding copy all call this feature "Autopilot";
the docs were the only surface that diverged. Aligning the docs to
the product (rather than the reverse) because the 830+ code-side
references would be a much larger rename to propagate.

- Rename routines.mdx / routines.zh.mdx → autopilots.mdx / autopilots.zh.mdx
- Update meta.json / meta.zh.json index entries (routines → autopilots)
- Drop the reconciliation note ("docs say Routines, CLI says autopilot")
  that shipped in the original routines.mdx and the cli.mdx section header
- Update cross-references in cli, how-multica-works, tasks,
  assigning-issues, chat, mentioning-agents, daemon-runtimes (EN + ZH)

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

* feat(onboarding): link to docs from key steps and starter tasks

Users who want to dig deeper now have a next hop from inside the flow
instead of having to dig through the help menu. Placed as secondary
links (muted, underline-offset-4) so they don't pull focus from the
primary CTA on each step.

Placement — one link per surface, placed in secondary regions:
- Welcome: "Learn how Multica works" below the subhead
- Questionnaire: "Learn how agents work" in the Why-we-ask aside
- Runtime aside (shared by desktop + web): "Learn about runtimes"
- Agent step: "Creating your first agent" in the About-agents aside
- StarterContentPrompt dialog: "Learn how Multica works"

Starter tasks (content/starter-content-templates.ts): added a single
"Learn about X" tail link per task, only on first occurrence of each
concept within a branch. 8 links on the agent-guided branch + 8 on
the self-serve branch + 1 on the welcome issue header (17 total).

URL scheme: absolute https://multica.ai/docs/{slug} throughout —
absolute so desktop (Electron) opens them in the system browser, and
the /en prefix is omitted because the docs middleware redirects it
away (English is the default, Chinese is /zh/).

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

* feat(landing): add docs link to footer and how-it-works section

Docs were previously reachable only from the in-app help menu. Landing
now surfaces them in two places, both locale-aware (/docs for English,
/docs/zh for Chinese):

- Footer Resources group: Documentation link was pointing at the
  GitHub repo; replaced with the real docs URL
- How-It-Works section CTA row: added "Read the docs" between the
  primary CTA and the GitHub link, same ghost styling

Locale resolution: href is picked per-render based on the landing's
current locale (cookie-driven via useLocale). The docs app itself
does not auto-detect language, so we must pick the right path
explicitly when emitting the link.

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

* fix(onboarding): clean up Autopilot rename leftovers and link formatting

- comments.mdx: "not routine updates" → "not day-to-day updates"
  (adjectival holdover now that the feature is renamed Autopilot;
  zeroes out remaining "routine" mentions in user-facing docs)
- starter-content-templates.ts: move the arrow inside the markdown
  link — "[text →](url)" instead of "→ [text](url)" — so the arrow
  is part of the clickable region. 17 occurrences.

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

* fix(onboarding): drop docs link from welcome screen and starter-content dialog

"Learn how Multica works" was showing up too often in the first two
screens users see. Keep the link in the post-import welcome issue
header (where users actually have time to explore); remove it from
the two earlier surfaces where it competes with the primary CTA.

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-04-24 14:27:53 +08:00
Naiyuan Qing
fe84e29b64 fix(ui): stop menu hover from overriding icon colors (#1612)
Menu primitives (context/dropdown/menubar/select/command) had rules like
`focus:**:text-accent-foreground` and `*:[svg]:text-destructive` that forced
descendant svg colors on focus, overriding icons that set their own color
(e.g. StatusIcon's `text-warning`). Remove them so icon color comes from
inheritance only: colored icons keep their color on hover, uncolored icons
still inherit the item's focus/destructive color as before.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 14:26:58 +08:00
Naiyuan Qing
4f40f70ea7 fix(skills): remove double-flicker on CreateSkillDialog close (#1610)
CreateSkillDialog used a controlled \`open\` prop while staying mounted,
so closing meant a data-open → data-closed flip on the already-mounted
Popup plus a tail re-render from \`useEffect([open])\` resetting \`method\`.
Visible as a double-blink: first the close animation, then a second
fade when the reset effect fired.

Align with the CreateIssue / CreateProject pattern: parent conditionally
renders the dialog and \`<Dialog open>\` is hard-coded. Close now unmounts
the component and Base UI's Portal owns the single exit animation. The
per-open method reset becomes unnecessary — fresh mount, fresh state.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 14:24:50 +08:00
LinYushen
99154d97b9 Restrict /health/realtime metrics exposure (MUL-1342) (#1608)
* Restrict /health/realtime metrics exposure (MUL-1342)

The realtime metrics endpoint was registered on the public router with
no authentication, exposing per-event/per-scope counters, redis.last_error,
and redis.node_id to anonymous callers. This enables information disclosure
and traffic profiling.

Move the handler behind a token + loopback policy:

- If REALTIME_METRICS_TOKEN is set, require Authorization: Bearer <token>
  using a constant-time compare. Reject other callers with 401 plus a
  WWW-Authenticate hint.
- If the env var is unset, only serve loopback callers and return 404 to
  remote clients so the endpoint is not enumerable. This keeps local dev
  workflows working without configuration.

The handler is extracted into health_realtime.go with focused unit tests
covering the token, loopback, and rejection paths. .env.example documents
the new variable.

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

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

* Fail closed for proxied /health/realtime requests (MUL-1342)

Addresses review on PR #1608: when the server runs behind a reverse
proxy (Caddy / Nginx -> localhost:8080), public callers reach the Go
handler with RemoteAddr=127.0.0.1, so the previous loopback shortcut
exposed the metrics surface in self-hosted deployments.

The no-token path now treats any forwarding header
(X-Forwarded-For / -Host / -Proto, X-Real-Ip, Forwarded) as a
'this request was proxied, can't attribute, fail closed' signal and
returns 404. Direct loopback callers without those headers still work
for local dev. Token-gated path is unchanged.

Tests cover all listed proxy headers (incl. multi-hop XFF chain and
RFC 7239 Forwarded) over both 127.0.0.1 and ::1, plus a regression
case ensuring an empty/whitespace forwarding header does not break
direct loopback access. .env.example updated to call out that proxied
deployments must configure REALTIME_METRICS_TOKEN.

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

---------

Co-authored-by: CC-Girl <cc-girl@multica.ai>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-04-24 14:04:10 +08:00
Naiyuan Qing
7067d8f125 refactor(skills): redesign list page and add skill detail page (#1607)
* feat(core): add skill detail path and query helpers

- paths.workspace(slug).skillDetail(id) → /:slug/skills/:id
- skillDetailOptions(wsId, skillId) for fetching a single skill
- selectSkillAssignments(agents) folds the cached agent list into
  Map<skillId, Agent[]>; returns a stable reference so consumers can
  memoize against agent-array identity without re-rendering on unrelated
  agent updates

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

* feat(views): add cross-platform openExternal helper

On Electron, route through window.desktopAPI.openExternal so the
http/https-only guard in the main process kicks in — direct window.open
inside Electron opens a new renderer window instead of handing the URL
to the OS shell. On web, fall back to window.open with noopener+noreferrer.
SSR-safe.

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

* refactor(skills): extract edit-permission hook and origin helper

- use-can-edit-skill: mirrors the server's rule (admin/owner ∨ creator)
  so the UI can hide/disable actions instead of waiting for a 403. Takes
  wsId explicitly per the repo rule for workspace-aware hooks.
- lib/origin: discriminated view over Skill.config.origin (manual /
  runtime_local / clawhub / skills_sh) so consumers don't spread JSONB
  parsing across the UI tree.

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

* refactor(skills): rewrite skills list page and collapse import UI

- SkillsPage rewritten: new hero header, single table layout with
  columns (Name / Used by / Source · Added by / Updated), agent avatar
  stack per skill, filter tabs aligned with Issues/MyIssues header
  (Button variant=outline + Tooltip + bg-accent active state).
- CreateSkillDialog: dedicated dialog for the manual/import entry
  points, replaces the inline row-triggered dialog.
- runtime-local import: dialog variant deleted; panel is now the single
  entry point, embeddable inside CreateSkillDialog. Panel covered by a
  new test.
- Deleted runtime-local-skill-row (no longer needed — row rendering
  lives in SkillsPage directly) and the old skills-page.test.tsx
  (structure diverged beyond salvaging; will be re-added alongside the
  detail-page tests).

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

* feat(skills): add skill detail page and wire routes on web and desktop

- SkillDetailPage: dedicated view for a single skill (name, description,
  origin, assignments, file listing). Uses skillDetailOptions and the
  new origin / use-can-edit-skill helpers.
- apps/web: /:workspaceSlug/skills/:id Next.js route.
- apps/desktop: /:slug/skills/:id added to the memory router under
  WorkspaceRouteLayout.

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

* test(skills): bump runtime-local-skill-import-panel timeouts for CI

The test chains a five-step async cascade (runtime list → setSelectedRuntimeId
effect → skills query → auto-select effect → row render). Comfortable on
local (~600ms) but tight against RTL's 1 s default on CI where jsdom +
Vitest import takes ~100s. Bump findByText and the two waitFor calls to
5 s each — no production behaviour change.

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-04-24 13:51:58 +08:00
devv-eve
9ed1fa95fc feat(server): add readiness health endpoints (#1605)
* feat(server): add readiness health endpoints

* fix(server): cache readiness checks

* fix(server): raise readiness cache ttl

---------

Co-authored-by: Eve <eve@multica.ai>
2026-04-24 13:50:24 +08:00
Naiyuan Qing
147fb2ee66 fix(autopilot): confirm before deleting autopilot or trigger (#1604)
Destructive actions in the autopilot detail page fired immediately on
click. Wrap "Delete autopilot" and per-trigger delete with AlertDialog
confirmation, matching the existing issue-delete pattern.

Also fix a latent bug in trigger deletion where the success toast was
shown synchronously after mutate(), so failures still reported success —
switch to mutateAsync + try/catch.
2026-04-24 13:11:52 +08:00
L.Amar
9c177562e2 fix(daemon/repocache): make bare repo cache keys collision-resistant 2026-04-24 13:04:08 +08:00
Naiyuan Qing
5bab95ad26 fix(issues): unify board card hover and active visual (#1603)
Hover and popup-open states now share the same bg-accent + border-accent
treatment. Drop the shadow-md hover (invisible in dark mode) and the
multi-property transition in favor of a single transition-colors.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 12:55:30 +08:00
Naiyuan Qing
0bd6ba9354 fix(issues): cleaner board card hover with shadow elevation (#1600)
Replace translucent tinted hover (border-accent/50 + bg-accent/20) with
a single-dimension shadow lift. The previous overlay was visually weak
because --accent is nearly identical to --card, so a 20% tint rendered
as almost no change. Active (popup-open) state now uses solid bg-accent
so hover and active are distinguished by different dimensions —
elevation vs color — instead of competing on the same axis.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 12:15:07 +08:00
Naiyuan Qing
40cea8454d feat(autopilot): redesign modal — simpler schema, consistent schedule UI (#1595)
Drop priority and project_id from autopilot. project_id was never exposed
in the UI and priority duplicated the agent's own task queue priority.

Redesign the create/edit modal as a Runbook (left) + Configuration (right)
layout. Rework the Schedule section around a single visual shell so every
picker aligns pixel-for-pixel on the same row:

- TimeInput (new): segmented HH:MM control adapted from openstatusHQ/time-picker,
  driven by keyboard (ArrowUp/Down to step, ArrowLeft/Right to jump segment,
  digit typing with a 2s two-digit window). Replaces <input type="time">,
  whose native UI broke the design system. Supports a minuteOnly variant
  for hourly schedules.
- TimezonePicker (new): searchable Popover with a fixed-width left check
  slot so rows stay aligned and GMT offsets never collide with the selected
  indicator.
- Runbook editor now lives in a bordered card, giving the placeholder an
  input surface instead of bare document flow.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 11:05:33 +08:00
Naiyuan Qing
d54daa62c5 feat(issues): right-click context menu + unified issue actions (#1594)
* feat(issues): add right-click context menu on list rows and board cards

Extract the detail page's ⋯ dropdown (~180 lines of inline JSX) into a
shared `useIssueActions` hook plus two thin wrappers so the same action
set (status / priority / assignee / due date / sub-issue ops / pin / copy
link / delete) can be mounted as both a DropdownMenu and a Base UI
ContextMenu. Right-click on any list row or board card now opens the
full action menu without entering the detail page.

Shell-level modals replace the detail-page-local state for set-parent /
add-child / delete-confirm / backlog-agent-hint, so any trigger (detail
page, list, board) can open them through `useModalStore`. Detail page
detects its own deletion via a query-transition effect, avoiding the
need to smuggle callbacks through the store.

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

* feat(issues): hover and active styling on list rows and board cards

Mirror the sidebar's same-color/different-intensity pattern for the new
right-click context menu states. Base UI adds `data-popup-open` to the
ContextMenuTrigger when the menu is open; `hover:not-data-[popup-open]`
suppresses hover feedback on the already-active item.

List rows apply the pattern directly to background color (`accent/60`
hover, `accent` active). Board cards additionally modulate the card's
border and a lighter background tint (`accent/20` hover, `accent/40`
active) so the card's own bg/border/shadow identity stays intact.

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

* feat(modals): show target issue banner in SetParent/AddChild pickers

When triggered from an issue's action menu, the IssuePickerModal now
displays a banner at the top showing "Setting parent of" / "Adding
sub-issue to" followed by the originating issue's status, identifier,
and title. Previously the operation target was only implied by the
modal's sr-only title.

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

* feat(modals): create-issue gains ⋯ overflow menu with parent issue linkage

Add a dropdown-menu with "Set parent issue..." / "Remove parent" at the
end of the property pill row. The ⋯ button is always the last DOM child
of the row so it stays at the tail even when the row wraps to multiple
lines. Menu state reflects current selection — unset shows a single
"Set parent…" entry, set shows the current identifier plus a separate
Remove option.

When a parent is set (either via the new menu or via `data.parent_issue_id`
from a "Create sub-issue" trigger), a chip appears in the pill row
showing "Sub-issue of {identifier}" with the same click-to-change /
click-×-to-clear semantics. This replaces the old header breadcrumb
disclosure that was neither editable nor visible in the form.

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

* refactor(issues): group relationship actions under "More" submenu

Nest Create sub-issue / Set parent issue / Add sub-issue inside a
`More >` submenu in the issue actions menu (both Dropdown and
Context variants). Top-level keeps Status/Priority/Assignee/Due date
category submenus plus Pin and Copy link; the relationship ops are
lower-frequency and will grow with future relation types (blocks,
duplicates, related) that fit the same category.

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

* feat(modals): create-issue adds Add sub-issue with deferred linking

The create modal's ⋯ menu gains an "Add sub-issue..." entry that queues
existing issues as children of the new one. Picked issues appear as
chips in the pill row (downward arrow, distinct from the upward parent
chip), each individually removable.

Linking is deferred because the new issue's ID doesn't exist at pick
time. Once createIssueMutation resolves, we run updateIssueMutation
for every queued child in parallel and surface any partial failures
via toast — the new issue itself is already committed and never rolls
back. Parent and child pickers exclude each other so a single issue
can't occupy both relations simultaneously.

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

* polish(issues): add MoreHorizontal icon to "More" submenu trigger

The "More" label was visually misaligned because every other top-level
entry has a leading icon. Use MoreHorizontal (same icon as the outer ⋯
trigger — semantically "more options, nested") and drop the `inset`
padding hack.

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

* revert(modals): drop target-issue banner from IssuePickerModal

The banner sat directly above the search input and rendered the target
issue with bolder styling than the "Setting parent of" / "Adding sub-issue
to" caption, which made it read like a pre-selected search result rather
than a context label. Users opening the modal from a menu item already
carry the context, so the extra chrome was redundant.

Remove the contextIssue / contextLabel API from IssuePickerModal and
drop the now-unused issueDetailOptions query in SetParentIssueModal.

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

* polish(modals): exclude current parent from create-issue parent picker

Re-opening the parent picker to change the already-set parent used to
show that parent in the results — picking it was a silent no-op. Mirror
the child picker's exclude-list construction so the current parent is
always filtered out.

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-04-24 10:48:46 +08:00
274 changed files with 16926 additions and 2504 deletions

View File

@@ -11,17 +11,21 @@ DATABASE_URL=postgres://multica:multica@localhost:5432/multica?sslmode=disable
# DATABASE_MIN_CONNS=5
# Server
# APP_ENV gates dev-only auth shortcuts (primarily the 888888 master code).
# - Docker self-host: docker-compose.selfhost.yml already pins APP_ENV to
# "production" by default, so 888888 is DISABLED — a public instance can't
# be logged into with any email + 888888.
# - Local dev (make dev): leave APP_ENV unset so 888888 works out of the box.
# - Docker self-host on a private network you fully control, or evaluation
# without Resend: set APP_ENV=development to re-enable 888888. Do NOT
# enable on a publicly reachable instance.
# APP_ENV gates production safety checks. Docker self-host pins APP_ENV to
# "production" by default. Local dev can leave it unset.
# See SELF_HOSTING.md for the full login setup.
APP_ENV=
# Optional local/testing shortcut. Empty by default, so there is no fixed
# verification code. Without RESEND_API_KEY, generated codes print to stdout.
# If you need deterministic local automation, set a 6-digit value such as
# 888888 and keep APP_ENV non-production. This is ignored when APP_ENV=production.
MULTICA_DEV_VERIFICATION_CODE=
PORT=8080
# Prometheus metrics are disabled by default. When enabled, bind to loopback
# unless you protect the listener with private networking, allowlists, or
# proxy auth. Do not expose this endpoint through the public app/API ingress.
# HTTP request metrics start accumulating only when this listener is enabled.
# METRICS_ADDR=127.0.0.1:9090
JWT_SECRET=change-me-in-production
MULTICA_SERVER_URL=ws://localhost:8080/ws
MULTICA_APP_URL=http://localhost:3000
@@ -45,8 +49,7 @@ 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 — codes print to stdout, and
# master code 888888 works (only when APP_ENV != "production"; see above).
# 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.
RESEND_API_KEY=
RESEND_FROM_EMAIL=noreply@multica.ai
@@ -85,6 +88,16 @@ LOCAL_UPLOAD_BASE_URL=http://localhost:8080
# Example: ALLOWED_ORIGINS=https://app.multica.ai,https://staging.multica.ai
ALLOWED_ORIGINS=
# Realtime metrics endpoint (/health/realtime) access control. See MUL-1342.
# When unset, the endpoint only serves direct loopback (127.0.0.1 / ::1)
# callers with no forwarding headers and returns 404 to everything else —
# safe for local dev. Any deployment behind a reverse proxy (Caddy / Nginx
# terminating TLS in front of localhost:8080) MUST set this token, since
# proxied requests look like loopback at the Go layer; with no token, those
# requests are refused with 404. Pass the token as
# `Authorization: Bearer <token>`.
# REALTIME_METRICS_TOKEN=
# Frontend
FRONTEND_PORT=3000
FRONTEND_ORIGIN=http://localhost:3000

View File

@@ -56,6 +56,12 @@ jobs:
release:
needs: verify
# Only run on the canonical upstream repo. Forks don't have the
# HOMEBREW_TAP_GITHUB_TOKEN secret and should not be publishing to
# `multica-ai/homebrew-tap` anyway. Without this guard, every fork's
# tag push fails this job (401 against the upstream tap), which makes
# downstream CI go red without affecting the actual artifact pipeline.
if: github.repository_owner == 'multica-ai'
runs-on: ubuntu-latest
steps:
- name: Checkout

View File

@@ -136,6 +136,17 @@ make start-worktree # Start using .env.worktree
- Avoid broad refactors unless required by the task.
- New global (pre-workspace) routes MUST use a single word (`/login`, `/inbox`) or a `/{noun}/{verb}` pair (`/workspaces/new`). NEVER add hyphenated word-group root routes (`/new-workspace`, `/create-team`) — they collide with common user workspace names and force endless reserved-slug audits. Reserving the noun (`workspaces`) automatically protects the entire `/workspaces/*` subtree.
### Backend Handler UUID Parsing Convention
Every Go handler in `server/internal/handler/` follows these rules. The convention exists because `util.ParseUUID` used to silently return a zero UUID on invalid input, which caused #1661 — a `DELETE` returning 204 success while the SQL `DELETE` matched zero rows.
- **Resource path params that accept either a UUID or a human-readable identifier** (e.g. `chi.URLParam(r, "id")` for an issue, which accepts both `MUL-123` and a UUID) MUST be resolved through the dedicated loader (`loadIssueForUser` / `loadSkillForUser` / `loadAgentForUser` / `requireDaemonRuntimeAccess`). After resolution, all subsequent DB calls — especially `Queries.Delete*` / `Queries.Update*` — MUST use `entity.ID` from the resolved object. Never round-trip the raw URL string through `parseUUID` for a write query.
- **Pure-UUID inputs from request boundaries** (URL params that are always UUIDs, request body fields, query params, headers) MUST be validated with `parseUUIDOrBadRequest(w, s, fieldName)`. On invalid input it writes a 400 and returns `ok=false` — return immediately.
- **Trusted UUID round-trips** (sqlc-returned UUIDs being passed back into queries, test fixtures) use `parseUUID(s)` which calls `util.MustParseUUID` and panics on invalid input. A panic here means an unguarded user-input string slipped in — that is a real bug. `chi`'s `middleware.Recoverer` translates the panic into a 500 so the process keeps running.
- **`util.ParseUUID(s) (pgtype.UUID, error)`** is the only safe variant outside the handler package. Always check the error.
When adding a `Queries.Delete*` or `Queries.Update*` call, ask: "Where did this UUID come from?" If the answer is "raw user input that hasn't been validated," route it through `parseUUIDOrBadRequest` or a loader first.
### Package Boundary Rules
These are hard constraints. Violating them breaks the cross-platform architecture:

View File

@@ -166,6 +166,7 @@ Daemon behavior is configured via flags or environment variables:
| Poll interval | `--poll-interval` | `MULTICA_DAEMON_POLL_INTERVAL` | `3s` |
| Heartbeat interval | `--heartbeat-interval` | `MULTICA_DAEMON_HEARTBEAT_INTERVAL` | `15s` |
| Agent timeout | `--agent-timeout` | `MULTICA_AGENT_TIMEOUT` | `2h` |
| Codex semantic inactivity timeout | `--codex-semantic-inactivity-timeout` | `MULTICA_CODEX_SEMANTIC_INACTIVITY_TIMEOUT` | `10m` |
| Max concurrent tasks | `--max-concurrent-tasks` | `MULTICA_DAEMON_MAX_CONCURRENT_TASKS` | `20` |
| Daemon ID | `--daemon-id` | `MULTICA_DAEMON_ID` | hostname |
| Device name | `--device-name` | `MULTICA_DAEMON_DEVICE_NAME` | hostname |

View File

@@ -373,7 +373,8 @@ done
#### 2. Create a test user and token (automated auth)
In non-production environments the verification code is fixed at `888888`:
For deterministic local automation, set `MULTICA_DEV_VERIFICATION_CODE=888888`
in your env file before starting the backend:
```bash
curl -s -X POST "$SERVER/auth/send-code" \
@@ -476,7 +477,9 @@ This automatically:
3. Starts and manages its own daemon instance
4. Connects to the local backend
Login in the Desktop UI with `dev@localhost` and code `888888`.
Login in the Desktop UI with `dev@localhost` and the generated code from the
backend logs. If you set `MULTICA_DEV_VERIFICATION_CODE=888888` before starting
the backend, you can use `888888` instead.
If the backend runs on a non-default port (worktree), create
`apps/desktop/.env.development.local`:

View File

@@ -15,7 +15,7 @@ COPY server/ ./server/
# Build binaries
ARG VERSION=dev
ARG COMMIT=unknown
RUN cd server && CGO_ENABLED=0 go build -ldflags "-s -w" -o bin/server ./cmd/server
RUN cd server && CGO_ENABLED=0 go build -ldflags "-s -w -X main.version=${VERSION} -X main.commit=${COMMIT}" -o bin/server ./cmd/server
RUN cd server && CGO_ENABLED=0 go build -ldflags "-s -w -X main.version=${VERSION} -X main.commit=${COMMIT}" -o bin/multica ./cmd/multica
RUN cd server && CGO_ENABLED=0 go build -ldflags "-s -w" -o bin/migrate ./cmd/migrate

View File

@@ -91,7 +91,7 @@ selfhost: ## Create .env if needed, then pull and start the official self-hosted
echo " $${MULTICA_WEB_IMAGE:-ghcr.io/multica-ai/multica-web}:$${MULTICA_IMAGE_TAG:-latest}"; \
echo ""; \
echo "Log in: configure RESEND_API_KEY in .env for email codes,"; \
echo " or set APP_ENV=development in .env (private networks only) to enable code 888888."; \
echo " or read the generated code from backend logs when Resend is unset."; \
echo ""; \
echo "Next — install the CLI and connect your machine:"; \
echo " brew install multica-ai/tap/multica"; \
@@ -130,7 +130,7 @@ selfhost-build: ## Build backend/web from the current checkout and start the sel
echo " Backend: http://localhost:$${PORT:-8080}"; \
echo ""; \
echo "Log in: configure RESEND_API_KEY in .env for email codes,"; \
echo " or set APP_ENV=development in .env (private networks only) to enable code 888888."; \
echo " or read the generated code from backend logs when Resend is unset."; \
echo ""; \
echo "Built images locally via docker-compose.selfhost.build.yml."; \
echo "Local tags: multica-backend:dev and multica-web:dev."; \
@@ -277,7 +277,7 @@ COMMIT ?= $(shell git rev-parse --short HEAD 2>/dev/null || echo unknown)
DATE ?= $(shell date -u '+%Y-%m-%dT%H:%M:%SZ')
build: ## Build the server, CLI, and migrate binaries into server/bin
cd server && go build -o bin/server ./cmd/server
cd server && go build -ldflags "-X main.version=$(VERSION) -X main.commit=$(COMMIT)" -o bin/server ./cmd/server
cd server && go build -ldflags "-X main.version=$(VERSION) -X main.commit=$(COMMIT) -X main.date=$(DATE)" -o bin/multica ./cmd/multica
cd server && go build -o bin/migrate ./cmd/migrate

View File

@@ -26,7 +26,7 @@ multica setup self-host
This installs the `multica` CLI, checks out the latest self-host assets, pulls the official Multica images from GHCR, and configures everything for localhost.
Open http://localhost:3000. To log in, configure `RESEND_API_KEY` in `.env` for email-based codes (recommended), or set `APP_ENV=development` in `.env` to enable the dev master code **`888888`**. See [Step 2 — Log In](#step-2--log-in) for details.
Open http://localhost:3000. To log in, configure `RESEND_API_KEY` in `.env` for email-based codes (recommended), or leave Resend unset and copy the generated code from the backend logs. See [Step 2 — Log In](#step-2--log-in) for details.
> **Prerequisites:** Docker and Docker Compose must be installed. The script checks for this and provides install links if missing.
>
@@ -67,15 +67,15 @@ Once ready:
### Step 2 — Log In
Open http://localhost:3000 in your browser. The Docker self-host stack defaults to `APP_ENV=production` (set in `docker-compose.selfhost.yml`), so the dev master code is **disabled by default** for safety on public deployments. Pick one of the following to log in:
Open http://localhost:3000 in your browser. The Docker self-host stack defaults to `APP_ENV=production` (set in `docker-compose.selfhost.yml`), and there is no fixed verification code by default. Pick one of the following to log in:
- **Recommended (production):** configure `RESEND_API_KEY` in `.env`, then restart the backend. Real verification codes will be sent to the email address you enter. See [Advanced Configuration → Email](SELF_HOSTING_ADVANCED.md#email-required-for-authentication).
- **Evaluation / private network:** set `APP_ENV=development` in `.env` and restart the backend. Verification code **`888888`** will then work for any email address.
- **Without configuring either:** the verification code is generated server-side and printed to the backend container logs (look for `[DEV] Verification code for ...:`). Useful for one-off testing on a single machine.
- **Without email configured:** the verification code is generated server-side and printed to the backend container logs (look for `[DEV] Verification code for ...:`). Useful for one-off testing on a single machine.
- **Deterministic local/private testing:** set `APP_ENV=development` and `MULTICA_DEV_VERIFICATION_CODE=888888` in `.env`, then restart the backend. This fixed code is ignored when `APP_ENV=production`.
Changes to `ALLOW_SIGNUP` and `GOOGLE_CLIENT_ID` also take effect after restarting the backend / compose stack. The web UI reads both from `/api/config` at runtime, so no web rebuild is needed.
> **Warning:** do **not** set `APP_ENV=development` on a publicly reachable instance — anyone who knows an email address can then log in with `888888`.
> **Warning:** do **not** set `MULTICA_DEV_VERIFICATION_CODE` on a publicly reachable instance — anyone who knows an email address can then log in with that fixed code.
### Step 3 — Install CLI & Start Daemon

View File

@@ -32,7 +32,7 @@ Multica uses email-based magic link authentication via [Resend](https://resend.c
| `RESEND_API_KEY` | Your Resend API key |
| `RESEND_FROM_EMAIL` | Sender email address (default: `noreply@multica.ai`) |
> **Note:** The dev master verification code `888888` is gated by `APP_ENV != "production"`. The Docker self-host stack defaults to `APP_ENV=production` (so `888888` is disabled), which protects publicly reachable instances. For local development without email configured, set `APP_ENV=development` in your `.env` to enable `888888` — never do this on a public instance.
> **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`.
### Google OAuth (Optional)
@@ -79,6 +79,7 @@ The `Secure` flag on session cookies is derived automatically from the scheme of
| Variable | Default | Description |
|----------|---------|-------------|
| `PORT` | `8080` | Backend server port |
| `METRICS_ADDR` | empty | Optional Prometheus metrics listener, for example `127.0.0.1:9090` |
| `FRONTEND_PORT` | `3000` | Frontend port |
| `CORS_ALLOWED_ORIGINS` | Value of `FRONTEND_ORIGIN` | Comma-separated list of allowed origins |
| `LOG_LEVEL` | `info` | Log level: `debug`, `info`, `warn`, `error` |
@@ -290,14 +291,45 @@ HTTP requests (issues, comments, uploads) work on LAN out of the box — Next.js
## Health Check
The backend exposes a health check endpoint:
The backend exposes public health endpoints:
```
```text
GET /health
→ {"status":"ok"}
GET /readyz
→ {"status":"ok","checks":{"db":"ok","migrations":"ok"}}
GET /healthz
→ same response as /readyz
```
Use this for load balancer health checks or monitoring.
Use `/health` for basic liveness / reachability checks. Use `/readyz` for
dependency-aware readiness probes and external monitoring that should fail when
the database is unavailable or migrations are not fully applied. `/healthz` is
kept as an alias for operator familiarity.
## Prometheus Metrics
The backend can expose Prometheus metrics on a separate management listener:
```bash
METRICS_ADDR=127.0.0.1:9090 ./server/bin/server
curl http://127.0.0.1:9090/metrics
```
`METRICS_ADDR` is empty by default, so no metrics listener is started. The
public API port does not serve `/metrics`; keep it that way for internet-facing
deployments. HTTP request metrics start accumulating only after the metrics
listener is enabled. Metrics can reveal internal routes, traffic volume,
dependency state, and runtime health.
For Docker or Kubernetes deployments, prefer a private scrape path: bind the
metrics listener to an internal interface and protect it with private
networking, allowlists, NetworkPolicy, or proxy authentication. If you bind
`METRICS_ADDR=0.0.0.0:9090` inside a container, only publish that port to a
trusted network, for example a host-local mapping such as
`127.0.0.1:9090:9090`.
## Upgrading

View File

@@ -37,7 +37,7 @@ multica setup self-host
The `multica setup self-host` command will:
1. Configure CLI to connect to localhost:8080 / localhost:3000
2. Open a browser for login — use verification code `888888` with any email
2. Open a browser for login — use the emailed code, or the generated code printed in backend logs when Resend is unset
3. Discover workspaces automatically
4. Start the daemon in the background
@@ -73,4 +73,4 @@ If the default ports (8080/3000) are in use:
- **Backend not ready:** `docker compose -f docker-compose.selfhost.yml logs backend`
- **Frontend not ready:** `docker compose -f docker-compose.selfhost.yml logs frontend`
- **Daemon issues:** `multica daemon logs`
- **Health check:** `curl http://localhost:8080/health`
- **Health checks:** `curl http://localhost:8080/health` for liveness, `curl http://localhost:8080/readyz` for dependency-aware readiness

View File

@@ -37,6 +37,14 @@ linux:
- deb
- rpm
artifactName: multica-desktop-${version}-linux-${arch}.${ext}
rpm:
# Disable RPM build-id symlinks. Electron apps embed the upstream Electron
# binary, whose GNU build-id is identical across every app shipping the same
# Electron version (Slack, VS Code, Discord, ...). Without this, our RPM
# would own /usr/lib/.build-id/<hash> paths and collide with any other
# Electron RPM already installed, breaking `dnf install` on Fedora/RHEL.
fpm:
- "--rpm-rpmbuild-define=_build_id_links none"
win:
target:
- nsis

View File

@@ -12,7 +12,7 @@ Assign an [issue](/issues) to an [agent](/agents) and it works as the **official
| **Assign** | Hand an agent ownership | Changes assignee | Issue + all comments | Inherits from issue | ✓ |
| [**@-mention**](/mentioning-agents) | Pull it in to take a look | No changes | Issue + trigger comment | Inherits from issue | ✓ |
| [**Chat**](/chat) | One-to-one conversation outside any issue | No issue involved | Current conversation history | Fixed medium | ✓ |
| [**Routines**](/routines) | Scheduled or manual automation | Depends on mode | Depends on mode | Set by routine | ✗ |
| [**Autopilots**](/autopilots) | Scheduled or manual automation | Depends on mode | Depends on mode | Set by autopilot | ✗ |
"Auto retry" refers to retries after infrastructure failures (runtime offline, timeout). Business errors on the agent side (for example, the model reporting an error) are not retried. See [**Tasks**](/tasks) for details.
@@ -78,4 +78,4 @@ But **different agents can work on the same issue in parallel** — for example,
- [**@-mention an agent in a comment**](/mentioning-agents) — a lighter trigger that leaves assignee and status untouched
- [**Chat**](/chat) — one-to-one conversation outside any issue
- [**Routines**](/routines) — let agents start work automatically on a schedule
- [**Autopilots**](/autopilots) — let agents start work automatically on a schedule

View File

@@ -12,7 +12,7 @@ import { Callout } from "fumadocs-ui/components/callout";
| **分配** | 让智能体正式负责 | 改 assignee | issue + 全部 comments | 继承 issue | ✓ |
| [**@ 提及**](/mentioning-agents) | 评论里让它看一眼 | 都不改 | issue + 触发评论 | 继承 issue | ✓ |
| [**对话**](/chat) | 独立于 issue 的一对一聊天 | 不涉及 issue | 当前对话历史 | 固定中 | ✓ |
| [**Routines**](/routines) | 定时 / 手动自动化 | 视模式 | 视模式 | routine 自定 | ✗ |
| [**Autopilots**](/autopilots) | 定时 / 手动自动化 | 视模式 | 视模式 | autopilot 自定 | ✗ |
"自动重试"指基础设施故障(运行时离线、超时)导致的重试;智能体侧业务错误(比如模型自己报错)不会自动重试。详见 [**执行任务**](/tasks)。
@@ -78,4 +78,4 @@ multica issue assign MUL-42 --unassign
- [**在评论里 @ 智能体**](/mentioning-agents) —— 更轻量的触发方式,不改 assignee / status
- [**对话**](/chat) —— 脱离 issue 和智能体一对一聊
- [**Routines**](/routines) —— 让智能体定时自动开工
- [**Autopilots**](/autopilots) —— 让智能体定时自动开工

View File

@@ -1,6 +1,6 @@
---
title: Sign-in and signup configuration
description: Configure email + verification code sign-in, Google OAuth, and signup allowlists. Avoid the 888888 trap.
description: Configure email + verification code sign-in, Google OAuth, signup allowlists, and local test codes.
---
import { Callout } from "fumadocs-ui/components/callout";
@@ -27,17 +27,24 @@ The user enters an email on the sign-in page → the server sends a 6-digit code
**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.
## The 888888 trap
## Fixed local testing codes
<Callout type="warning">
**If `APP_ENV` is not set to `production`, anyone can sign in to any account with the code `888888`.**
**Do not enable a fixed verification code on a publicly reachable instance.**
Multica has a development-only master code, `888888` — a backdoor so local development doesn't depend on Resend. The rule is straightforward: when `APP_ENV != "production"`, **any email** plus `888888` passes verification.
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.
**Production deployments must set `APP_ENV=production`**. If you deploy via `make selfhost` / `docker-compose.selfhost.yml`, this value is already set to `production` by default; but if you deploy from source yourself, write your own Docker config, or redefine environment variables in Kubernetes — you must add `APP_ENV=production` yourself.
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:
```bash
APP_ENV=development
MULTICA_DEV_VERIFICATION_CODE=888888
```
This shortcut is ignored when `APP_ENV=production`.
</Callout>
To check whether your deployment has this trap: open the sign-in page, enter **any email** to request a code, then enter `888888`. If you get in, your `APP_ENV` is not set to `production`, and **the entire instance is wide open**.
Production deployments should leave `MULTICA_DEV_VERIFICATION_CODE` empty and set `APP_ENV=production`. If you deploy via `make selfhost` / `docker-compose.selfhost.yml`, `APP_ENV` defaults to `production`.
## Google OAuth configuration

View File

@@ -1,12 +1,12 @@
---
title: 登录与注册配置
description: 配 Email 验证码登录 + Google OAuth + 注册白名单。避开最坑的 888888 陷阱
description: 配 Email 验证码登录Google OAuth注册白名单和本地测试验证码
---
import { Callout } from "fumadocs-ui/components/callout";
import { Mermaid } from "@/components/mermaid";
Multica 支持两种登录方式:**Email + 验证码**(默认)和 **Google OAuth**(可选)。登录成功后 server 签发一个 30 天有效期的 JWT cookie。这一页讲怎么配、怎么限制谁能注册、以及自部署最容易踩的一个陷阱
Multica 支持两种登录方式:**Email + 验证码**(默认)和 **Google OAuth**(可选)。登录成功后 server 签发一个 30 天有效期的 JWT cookie。这一页讲怎么配、怎么限制谁能注册、以及本地测试验证码怎么安全使用
上面用到的环境变量的清单见 [环境变量](/environment-variables)token 怎么用、生命周期细节见 [认证与令牌](/auth-tokens)。
@@ -27,17 +27,24 @@ Multica 支持两种登录方式:**Email + 验证码**(默认)和 **Google
**不配 `RESEND_API_KEY` 的后果**server 不报错,但**所有本该发出去的邮件只打到 server 的 stdout**。本地开发方便(你从日志抄验证码),生产环境等于黑洞。
## 888888 陷阱
## 固定本地测试验证码
<Callout type="warning">
**`APP_ENV` 不设为 `production`,任何人都能用验证码 `888888` 登录任何账号。**
**不要在公网可访问实例上启用固定验证码。**
Multica 有一个开发用的主验证码master code`888888`——为了本地开发不依赖 Resend 而设的后门。判定逻辑很简单:`APP_ENV != "production"` 时,**任何邮箱**输 `888888` 都能通过
旧版「非 production 默认接受 `888888`」的行为已经移除。除非你显式配置,否则输入 `888888` 会和普通错误验证码一样被拒绝
**生产部署必须设 `APP_ENV=production`**。如果你用 `make selfhost` / `docker-compose.selfhost.yml` 自部署,这个值已经默认设为 `production`;但如果你自己从源码部署、自己写 Docker 配置、或者在 Kubernetes 里重新定义环境变量——一定要自己把 `APP_ENV=production` 加上。
不配 Resend 的本地开发,应使用 server 日志里打印的随机验证码。如果你需要确定性的本地/私有自动化测试,可以把 `MULTICA_DEV_VERIFICATION_CODE` 设成一个 6 位数字,比如 `888888`,并保持 `APP_ENV` 为非 production
```bash
APP_ENV=development
MULTICA_DEV_VERIFICATION_CODE=888888
```
`APP_ENV=production` 时这个快捷码会被忽略。
</Callout>
检查你的部署是否有这个陷阱:打开登录页,输入**任意邮箱**请求验证码,再在验证码栏输 `888888`。如果能登进去 = 你的 `APP_ENV` 没设成 `production`**整个实例处于完全开放状态**
生产部署应保持 `MULTICA_DEV_VERIFICATION_CODE` 为空,并设置 `APP_ENV=production`。如果你用 `make selfhost` / `docker-compose.selfhost.yml` 自部署,`APP_ENV` 默认就是 `production`。
## 怎么配 Google OAuth

View File

@@ -38,7 +38,7 @@ In day-to-day use you'll only touch the first two directly. The **[daemon](/daem
2. Enter the code; the server issues a JWT cookie (browser) or exchanges it for a PAT (CLI).
<Callout type="warning">
**Self-hosting operators, take note**: if `APP_ENV` is not set to `production`, the verification code is always `888888` — anyone can sign in as anyone. See [Self-host auth configuration](/auth-setup).
**Self-hosting operators, take note**: keep `MULTICA_DEV_VERIFICATION_CODE` empty on public deployments. If you enable a fixed local test code, anyone who can request a code can sign in with that value while `APP_ENV` is non-production. See [Self-host auth configuration](/auth-setup).
</Callout>
### Google OAuth

View File

@@ -38,7 +38,7 @@ Multica 有三种令牌,对应三种使用场景:浏览器 Web UI、命令
2. 输入验证码server 签发 JWT cookie浏览器或交换出 PATCLI
<Callout type="warning">
**自部署运维注意**如果环境变量 `APP_ENV` 不是 `production`,验证码恒为 `888888`——任何人能登录任何账号。详见 [自部署的认证配置](/auth-setup)。
**自部署运维注意**公网部署时保持 `MULTICA_DEV_VERIFICATION_CODE` 为空。如果启用固定本地测试验证码,在 `APP_ENV` production 时,任何能请求验证码的人都能用该固定值登录。详见 [自部署的认证配置](/auth-setup)。
</Callout>
### Google OAuth

View File

@@ -0,0 +1,85 @@
---
title: Autopilots
description: Let agents start work on a cron schedule — or trigger once manually via the UI or CLI.
---
import { Callout } from "fumadocs-ui/components/callout";
Autopilots let [agents](/agents) **start work automatically on a schedule** — configure a cron expression and a timezone, and Multica dispatches a [`task`](/tasks) on its own, without you triggering anything. It fits periodic checks, recurring reports, and overnight cleanup jobs — the "standing order" shape of work. Compared to the other three trigger paths ([assigning](/assigning-issues), [@-mention](/mentioning-agents), and [chat](/chat), where you are the one kicking things off), the core difference with Autopilots is that they are **time-driven**.
## Configure an autopilot
Create a new autopilot on the workspace's **Autopilot** page. You set:
- **Name** — display name
- **Agent** — who the run is dispatched to
- **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)
## 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.
- **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.
<Callout type="warning">
**Run-only mode is currently unstable.** The CLI labels it "not yet supported end-to-end," and the dispatch path has known issues. New users should stick to create issue mode and wait for run-only mode to ship a stable release before switching.
</Callout>
## Run it on a schedule
Every autopilot needs at least one `schedule` trigger. Cron uses the **standard 5-field format** (minute hour day month weekday), with **1-minute** minimum granularity (no seconds). Timezone is IANA-formatted (for example, `Asia/Shanghai`) and determines which timezone the cron expression is interpreted in.
A few examples:
- `0 9 * * 1-5`, `Asia/Shanghai` — 9 AM Beijing time on weekdays
- `*/30 * * * *`, `UTC` — every 30 minutes
- `0 3 * * *`, `UTC` — every day at 3 AM UTC
The Multica server scans for due triggers every **30 seconds** — **the actual fire time can lag by up to 30 seconds**, not down to the second. If the server is restarted across a fire time, it catches up missed triggers on startup (nothing is lost, but they fire right away).
## Trigger once manually
To avoid waiting for cron while debugging an autopilot, trigger it manually:
- UI: click "Run now" on the autopilot detail page
- CLI:
```bash
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`.
## View run history
Every trigger produces a **run record**, visible on the "History" tab of the autopilot detail page:
- Trigger source (`schedule` / `manual`)
- Start time, completion time
- Status (`issue_created` / `running` / `completed` / `failed`)
- The linked issue (create issue mode) or `task` (run-only mode)
- Failure reason (if failed)
## What happens when an autopilot fails
<Callout type="warning">
**Autopilot failures are not auto-retried and do not send inbox notifications.** A failure leaves a `failed` entry in run history — no system-level re-enqueue like assign or @-mention, and no notification to anyone. If the autopilot is periodic, **the next cron fire will trigger a new run**, but the failed work is not automatically re-run.
If an autopilot is important, design your own monitoring — for example, have the agent post a comment on success, and catch failures by noticing missing comments.
</Callout>
Why no auto-retry: autopilots are already periodic, so adding system-level retries stacks on top of the next scheduled run and creates duplicate executions. Leaving the schedule entirely to cron keeps it clean.
## 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.**
## Next
- [**Assign issues to agents**](/assigning-issues) — a one-shot hand-off of an issue to an agent
- [**@-mention agents in comments**](/mentioning-agents) — pull an agent in to take a look from a comment
- [**Chat**](/chat) — one-to-one conversation outside any issue

View File

@@ -1,15 +1,15 @@
---
title: Routines
title: Autopilots
description: 让智能体按 cron 定时自己开工——或通过 UI / CLI 手动触发一次。
---
import { Callout } from "fumadocs-ui/components/callout";
Routines 让 [智能体](/agents) **按调度自动开工**——配好 cron 和时区,到点 Multica 自己派发 [`task`](/tasks),不需要你每次触发。适合定期巡检、周期性报告、凌晨跑的清理任务这类"standing order"场景。和前三种触发方式([分配](/assigning-issues) / [@ 提及](/mentioning-agents) / [对话](/chat) 都是你主动喊一声)相比,Routines 的核心差别是**时间驱动**。
Autopilots 让 [智能体](/agents) **按调度自动开工**——配好 cron 和时区,到点 Multica 自己派发 [`task`](/tasks),不需要你每次触发。适合定期巡检、周期性报告、凌晨跑的清理任务这类"standing order"场景。和前三种触发方式([分配](/assigning-issues) / [@ 提及](/mentioning-agents) / [对话](/chat) 都是你主动喊一声)相比,Autopilots 的核心差别是**时间驱动**。
## 配置一个 Routine
## 配置一个 Autopilot
在工作区的 **Routines** 页新建一条 routine,要定下:
在工作区的 **Autopilot** 页新建一条 autopilot,要定下:
- **名字** — 显示名
- **执行智能体** — 到点派给谁
@@ -20,10 +20,10 @@ Routines 让 [智能体](/agents) **按调度自动开工**——配好 cron 和
## 选择执行模式
Routine 有两种执行模式,**建议从"先建 issue 模式"开始**
Autopilot 有两种执行模式,**建议从"先建 issue 模式"开始**
- **先建 issue 模式**`create_issue`)—— 默认,**推荐**。每次触发先在工作区里建一个 issue标题支持 `{{date}}` 这样的插值),再按分配流程把 issue 派给智能体。所有工作都落在 issue 看板上,历史、评论、状态和手动分配的 issue 完全一致。
- **直跑模式**`run_only`)—— 不建 issue直接入队一个 `task`。看板上看不到这一次运行——只能在 Routine 的运行历史里看到。
- **直跑模式**`run_only`)—— 不建 issue直接入队一个 `task`。看板上看不到这一次运行——只能在 Autopilot 的运行历史里看到。
<Callout type="warning">
**直跑模式当前不稳定**——目前在 CLI 里被标注为"not yet supported end-to-end",派发路径有已知问题。新用户只使用先建 issue 模式,等直跑模式 ship 稳定版再切。
@@ -31,7 +31,7 @@ Routine 有两种执行模式,**建议从"先建 issue 模式"开始**
## 让它按时间跑
每个 Routine 至少要一个 `schedule` 触发器。Cron 是**标准 5 字段格式**(分 时 日 月 周),最小粒度 **1 分钟**(没有秒级)。时区用 IANA 格式(例如 `Asia/Shanghai`),决定 cron 表达式按哪个时区解读。
每个 Autopilot 至少要一个 `schedule` 触发器。Cron 是**标准 5 字段格式**(分 时 日 月 周),最小粒度 **1 分钟**(没有秒级)。时区用 IANA 格式(例如 `Asia/Shanghai`),决定 cron 表达式按哪个时区解读。
几个例子:
@@ -43,20 +43,20 @@ Multica 服务器每 **30 秒**扫一次到期的触发器——**触发时刻
## 手动触发一次
调试 Routine 时不想等 cron可以手动触发一次
调试 Autopilot 时不想等 cron可以手动触发一次
- UIRoutine 详情页点"手动运行"
- UIAutopilot 详情页点"手动运行"
- CLI
```bash
multica autopilot trigger <routine-id>
multica autopilot trigger <autopilot-id>
```
手动触发走和 `schedule` 触发完全相同的执行流程,只是运行记录里 `source` 字段标为 `manual`。
## 看运行历史
每次触发都会产生一条**运行记录**run可以在 Routine 详情页的"历史"tab 看到:
每次触发都会产生一条**运行记录**run可以在 Autopilot 详情页的"历史"tab 看到:
- 触发源(`schedule` / `manual`
- 开始时间、完成时间
@@ -64,29 +64,19 @@ multica autopilot trigger <routine-id>
- 关联的 issue先建 issue 模式)或 `task`(直跑模式)
- 失败原因(如果失败)
## Routine 失败会怎样
## Autopilot 失败会怎样
<Callout type="warning">
**Routine 失败不自动重试,也不发 inbox 通知。** 失败后只在运行历史里留一条 `failed` 记录——不会像分配 / @ 提及那样由系统重新排队,也不会给任何人发通知。如果这条 Routine 是周期任务,**下一次 cron 到点会重新触发一次**(新的 run但这一次失败的工作不会被自动补跑。
**Autopilot 失败不自动重试,也不发 inbox 通知。** 失败后只在运行历史里留一条 `failed` 记录——不会像分配 / @ 提及那样由系统重新排队,也不会给任何人发通知。如果这条 Autopilot 是周期任务,**下一次 cron 到点会重新触发一次**(新的 run但这一次失败的工作不会被自动补跑。
如果 Routine 很重要,要自己设计监控——例如让智能体在成功时给自己发个评论,通过缺失评论来发现失败。
如果 Autopilot 很重要,要自己设计监控——例如让智能体在成功时给自己发个评论,通过缺失评论来发现失败。
</Callout>
不自动重试的理由:Routine 本身是周期性的,系统层再加自动重试容易和下一次调度叠加,产生重复执行。调度权完全交给 cron 最干净。
不自动重试的理由:Autopilot 本身是周期性的,系统层再加自动重试容易和下一次调度叠加,产生重复执行。调度权完全交给 cron 最干净。
## 两个遗留的命名 / 能力
## 暂不可用的能力
**CLI 里它叫 `autopilot`**。当前 CLI 子命令是 `multica autopilot` 而不是 `multica routine`
```bash
multica autopilot list
multica autopilot create
multica autopilot trigger <id>
```
文档里一律用 Routines后续版本 CLI 会统一。现在遇到 `autopilot` 字样把它当 Routines 看就行。
**Webhook 和 API 触发暂不可用**。Routine 的触发器类型在 schema 里预留了 `webhook` 和 `api` 两种,但**还没接入站路由**——UI 可以创建这两类触发器,不会真的触发。目前**只有 `schedule` 和手动触发是端到端可用的**。
**Webhook 和 API 触发暂不可用**。Autopilot 的触发器类型在 schema 里预留了 `webhook` 和 `api` 两种,但**还没接入站路由**——UI 可以创建这两类触发器,不会真的触发。目前**只有 `schedule` 和手动触发是端到端可用的**。
## 下一步

View File

@@ -59,5 +59,5 @@ Conversations you no longer want to see can be archived — right-click in the c
## Next
- [**Routines**](/routines) — let agents start work automatically on a schedule
- [**Autopilots**](/autopilots) — let agents start work automatically on a schedule
- [**Assign issues to agents**](/assigning-issues) — bring the topic back onto the issue board

View File

@@ -59,5 +59,5 @@ import { Callout } from "fumadocs-ui/components/callout";
## 下一步
- [**Routines**](/routines) —— 让智能体定时自动开工
- [**Autopilots**](/autopilots) —— 让智能体定时自动开工
- [**分配 issue 给智能体**](/assigning-issues) —— 把话题放回 issue 看板上

View File

@@ -74,15 +74,13 @@ 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 |
## Routines (CLI command name: `autopilot`)
In the docs this feature is called **Routines**, but the CLI subcommand name is still `autopilot` — a future release will unify the two. If you're searching for "routines" and can't find it, try `multica autopilot --help`.
## Autopilots
| Command | Purpose |
|---|---|
| `multica autopilot list` | List every routine in the workspace |
| `multica autopilot get <id>` | Show a single routine |
| `multica autopilot create ...` | Create a routine |
| `multica autopilot list` | List every autopilot in the workspace |
| `multica autopilot get <id>` | Show a single autopilot |
| `multica autopilot create ...` | Create an autopilot |
| `multica autopilot update <id> ...` | Update |
| `multica autopilot delete <id>` | Delete |
| `multica autopilot runs <id>` | Show run history |

View File

@@ -74,15 +74,13 @@ Token 类型的详细区分见 [认证与令牌](/auth-tokens)。
| `multica skill import ...` | 从 GitHub / ClawHub / 本机导入 Skill |
| `multica skill files ...` | 嵌套:管理 Skill 的文件 |
## RoutinesCLI 命令名:`autopilot`
文档里叫 **Routines**,但 CLI 子命令名保留为 `autopilot`——后续版本会统一。如果你在搜索 "routines" 相关命令但找不到,用 `multica autopilot --help`。
## Autopilots
| 命令 | 用途 |
|---|---|
| `multica autopilot list` | 列出工作区所有 routine |
| `multica autopilot get <id>` | 查看单个 routine |
| `multica autopilot create ...` | 创建 routine |
| `multica autopilot list` | 列出工作区所有 autopilot |
| `multica autopilot get <id>` | 查看单个 autopilot |
| `multica autopilot create ...` | 创建 autopilot |
| `multica autopilot update <id> ...` | 修改 |
| `multica autopilot delete <id>` | 删除 |
| `multica autopilot runs <id>` | 查看运行历史 |

View File

@@ -34,7 +34,7 @@ Mentioning the same person multiple times in one comment still produces **only o
`@all` is a special target: it pushes a notification to every member of the workspace. Both people and agents can use `@all` — which means an agent reporting progress could also `@all`, so remind agents in their instructions to use it sparingly.
<Callout type="warning">
**Use `@all` carefully.** In a larger workspace, a single `@all` generates that many inbox notifications instantly. Reserve it for things everyone genuinely needs to know — not routine updates.
**Use `@all` carefully.** In a larger workspace, a single `@all` generates that many inbox notifications instantly. Reserve it for things everyone genuinely needs to know — not day-to-day updates.
</Callout>
## Editing and deleting a comment

View File

@@ -75,7 +75,7 @@ Multica uses heartbeats to decide whether a runtime is online. Three key numbers
Missing is not permanent — as soon as the daemon sends another heartbeat it returns to online, and the runtime record is preserved. Restarting the daemon does not lose runtimes.
<Callout type="warning">
**Tasks running on a missing runtime are marked as failed** (failure reason `runtime_offline`). For retryable sources (issues, chat), Multica automatically requeues them; Routines-triggered tasks are not retried automatically. See [Tasks → Which failures retry automatically](/tasks#which-failures-retry-automatically-which-dont).
**Tasks running on a missing runtime are marked as failed** (failure reason `runtime_offline`). For retryable sources (issues, chat), Multica automatically requeues them; Autopilot-triggered tasks are not retried automatically. See [Tasks → Which failures retry automatically](/tasks#which-failures-retry-automatically-which-dont).
</Callout>
## How many tasks can run in parallel

View File

@@ -75,7 +75,7 @@ Multica 用心跳判断运行时是否在线。三个关键数字:
失联不是永久的——守护进程只要再次发出心跳就立刻回到在线,运行时记录也会保留。重启守护进程不会丢运行时。
<Callout type="warning">
**失联的运行时上正在跑的执行任务会被标记为失败**(失败原因 `runtime_offline`。对可重试的来源issue、chatMultica 会自动重新排队;Routines 触发的任务不自动重试。详见 [执行任务 → 哪些失败会自动重试](/tasks#哪些失败会自动重试哪些不会)。
**失联的运行时上正在跑的执行任务会被标记为失败**(失败原因 `runtime_offline`。对可重试的来源issue、chatMultica 会自动重新排队;Autopilots 触发的任务不自动重试。详见 [执行任务 → 哪些失败会自动重试](/tasks#哪些失败会自动重试哪些不会)。
</Callout>
## 一次能并发跑多少任务

View File

@@ -7,20 +7,21 @@ import { Callout } from "fumadocs-ui/components/callout";
A self-hosted Multica [server](/self-host-quickstart) reads its configuration from environment variables at startup — database, sign-in, email, storage, signup allowlists all live here. This page groups every variable by purpose: each section spells out **what happens if you leave it unset** and **which ones you must set in production**. For how to actually configure the auth-related ones, see [Sign-in and signup configuration](/auth-setup).
## The five required at startup
## Core server variables
These are the five you must think about before deploying — some have defaults that let the server start, but in production you should set all of them explicitly.
These are the core variables you must think about before deploying — some have defaults that let the server start, but in production you should set the required ones explicitly.
| Variable | Default | Required in production? |
|---|---|---|
| `DATABASE_URL` | `postgres://multica:multica@localhost:5432/multica?sslmode=disable` | **Yes** |
| `PORT` | `8080` | No (unless you change the port) |
| `JWT_SECRET` | `multica-dev-secret-change-in-production` | **Yes** (the default is unsafe) |
| `APP_ENV` | empty | **Yes** (must be `production` — see the next section for the trap) |
| `APP_ENV` | empty | **Yes** (must be `production`) |
| `FRONTEND_ORIGIN` | empty | **Yes** (self-host must set its own domain) |
| `MULTICA_DEV_VERIFICATION_CODE` | empty | No (must stay empty in production) |
<Callout type="warning">
**If `APP_ENV` is not set to `production`, anyone can sign in to any account using the code `888888`.** Multica has a development-only master code, `888888` — when `APP_ENV != "production"`, **any email** plus `888888` passes verification. The behavior is intentional for local development (no Resend dependency); **in production, failing to set `production` is equivalent to disabling auth entirely**. See [Sign-in and signup configuration → The 888888 trap](/auth-setup#the-888888-trap).
**Keep `MULTICA_DEV_VERIFICATION_CODE` empty in production.** A fixed local test code is disabled by default, but if you opt in with `MULTICA_DEV_VERIFICATION_CODE=888888`, anyone who can request a code can sign in with that fixed value while `APP_ENV` is non-production. The shortcut is ignored when `APP_ENV=production`.
</Callout>
### Database connection pool

View File

@@ -7,20 +7,21 @@ import { Callout } from "fumadocs-ui/components/callout";
Multica 的 [自部署](/self-host-quickstart) 服务器启动时从环境变量读取配置——数据库、登录、邮件、存储、注册白名单都在这里配。这一页按用途分组给完整清单:每组说清楚**不设会怎样**、**生产必须设哪几个**。Auth 相关那几个怎么真正配见 [登录与注册配置](/auth-setup)。
## 启动必填的五个
## 核心 server 环境变量
五个是你部署前必须考虑的——有些有默认值能让 server 启动,但生产环境里你应该全部显式配。
是你部署前必须考虑的核心变量——有些有默认值能让 server 启动,但生产环境里你应该显式配置必填项
| 环境变量 | 默认值 | 生产必须设? |
|---|---|---|
| `DATABASE_URL` | `postgres://multica:multica@localhost:5432/multica?sslmode=disable` | **是** |
| `PORT` | `8080` | 否(除非换端口)|
| `JWT_SECRET` | `multica-dev-secret-change-in-production` | **是**(默认值不安全)|
| `APP_ENV` | 空 | **是**(必须 `production`——见下一节陷阱|
| `APP_ENV` | 空 | **是**(必须 `production`|
| `FRONTEND_ORIGIN` | 空 | **是**self-host 要填你自己的域名)|
| `MULTICA_DEV_VERIFICATION_CODE` | 空 | 否(生产必须保持为空)|
<Callout type="warning">
**`APP_ENV` 不设为 `production`,任何人都能用 `888888` 登录任何账号。** Multica 有一个开发用的主验证码master code`888888`——`APP_ENV != "production"` 时**任何邮箱**输 `888888` 都能通过。本地开发时故意留空方便调试;**生产环境一旦不设 `production`,等于 auth 完全失效**。详见 [登录与注册配置 → 888888 陷阱](/auth-setup#888888-陷阱)
**生产环境保持 `MULTICA_DEV_VERIFICATION_CODE` 为空。** 固定本地测试验证码默认关闭;如果你设置 `MULTICA_DEV_VERIFICATION_CODE=888888`,在 `APP_ENV` 非 production 时,任何能请求验证码的人都能用这个固定值登录。`APP_ENV=production` 时该快捷码会被忽略
</Callout>
### 数据库连接池

View File

@@ -31,7 +31,7 @@ curl -fsSL https://raw.githubusercontent.com/multica-ai/multica/main/scripts/ins
multica setup self-host
```
This installs the CLI, checks out the latest self-host assets, pulls the official Multica images from GHCR, and configures everything for localhost. Then open http://localhost:3000 and pick a login method: configure `RESEND_API_KEY` in `.env` for email-based codes (recommended), or set `APP_ENV=development` in `.env` to enable the dev master code **`888888`**. See [Step 2 — Log In](#step-2--log-in) for details.
This installs the CLI, checks out the latest self-host assets, pulls the official Multica images from GHCR, and configures everything for localhost. Then open http://localhost:3000 and pick a login method: configure `RESEND_API_KEY` in `.env` for email-based codes (recommended), or leave Resend unset and copy the generated code from backend logs. See [Step 2 — Log In](#step-2--log-in) for details.
<Callout>
If the self-host server is already running and you only need the CLI on a macOS/Linux machine, install it with Homebrew: `brew install multica-ai/tap/multica`.
@@ -68,16 +68,16 @@ If you prefer running the Docker Compose steps manually: `cp .env.example .env`,
### Step 2 — Log In
Open http://localhost:3000. The Docker self-host stack defaults to `APP_ENV=production` (set in `docker-compose.selfhost.yml`), so the dev master code is **disabled by default** for safety on public deployments. Pick one of the following to log in:
Open http://localhost:3000. The Docker self-host stack defaults to `APP_ENV=production` (set in `docker-compose.selfhost.yml`), and there is no fixed verification code by default. Pick one of the following to log in:
- **Recommended (production):** configure `RESEND_API_KEY` in `.env`, then restart the backend. Real verification codes will be sent to the email address you enter. See [Configuration](#configuration) below.
- **Evaluation / private network:** set `APP_ENV=development` in `.env` and restart the backend. Verification code **`888888`** will then work for any email address.
- **Without configuring either:** the verification code is generated server-side and printed to the backend container logs (look for `[DEV] Verification code for ...:`). Useful for one-off testing on a single machine.
- **Without email configured:** the verification code is generated server-side and printed to the backend container logs (look for `[DEV] Verification code for ...:`). Useful for one-off testing on a single machine.
- **Deterministic local/private testing:** set `APP_ENV=development` and `MULTICA_DEV_VERIFICATION_CODE=888888` in `.env`, then restart the backend. This fixed code is ignored when `APP_ENV=production`.
Changes to `ALLOW_SIGNUP` and `GOOGLE_CLIENT_ID` also take effect after restarting the backend / compose stack. The web UI reads both from `/api/config` at runtime, so no web rebuild is needed.
<Callout>
**Warning:** do **not** set `APP_ENV=development` on a publicly reachable instance — anyone who knows an email address can then log in with `888888`.
**Warning:** do **not** set `MULTICA_DEV_VERIFICATION_CODE` on a publicly reachable instance — anyone who knows an email address can then log in with that fixed code.
</Callout>
### Step 3 — Install CLI & Start Daemon
@@ -408,14 +408,23 @@ NEXT_PUBLIC_WS_URL=wss://api.example.com/ws
## Health Check
The backend exposes a health check endpoint:
The backend exposes public health endpoints:
```
```text
GET /health
→ {"status":"ok"}
GET /readyz
→ {"status":"ok","checks":{"db":"ok","migrations":"ok"}}
GET /healthz
→ same response as /readyz
```
Use this for load balancer health checks or monitoring.
Use `/health` for basic liveness / reachability checks. Use `/readyz` for
dependency-aware readiness probes and external monitoring that should fail when
the database is unavailable or migrations are not fully applied. `/healthz` is
kept as an alias for operator familiarity.
## Upgrading

View File

@@ -39,7 +39,7 @@ It's not only "assign an issue" — Multica has 4 triggers, one per collaboratio
| **Assign an issue** | The most common. Assign an issue to an agent and it starts on its own | [Assigning issues](/assigning-issues) |
| **@mention an agent in a comment** | "Take a look at this one for me" — don't change the assignee or status, just fire off a comment | [Mentioning agents](/mentioning-agents) |
| **Direct chat** | Standalone conversation, not tied to an issue — ask questions, have it draft an issue | [Chat](/chat) |
| **Routines (scheduled)** | Standing instructions — "do a standup summary every Monday morning" and the like | [Routines](/routines) |
| **Autopilots (scheduled)** | Standing instructions — "do a standup summary every Monday morning" and the like | [Autopilots](/autopilots) |
## Runtimes: where it runs, and how many tools

View File

@@ -39,7 +39,7 @@ Multica 是一个**分布式**平台。你看到的 Web 界面只是前台——
| **分配 issue** | 最常见。把一条 issue 指派给智能体,它自动开工 | [分配 issue](/assigning-issues) |
| **在评论里 @智能体** | "这条你帮我看一下"——不改 assignee、不改状态用一条评论触发 | [在评论里 @智能体](/mentioning-agents) |
| **直接聊天** | 独立对话,不绑 issue——问问题、让它帮起草任务 | [聊天](/chat) |
| **Routines定时** | 长期指令——每周一早上做 standup 总结之类 | [Routines](/routines) |
| **Autopilots定时** | 长期指令——每周一早上做 standup 总结之类 | [Autopilots](/autopilots) |
## 运行时:在哪里跑,跑几家工具

View File

@@ -54,5 +54,5 @@ This guard **only blocks direct self-references.** Agent A @-mentioning agent B
## Next
- [**Chat**](/chat) — one-to-one conversation outside any issue
- [**Routines**](/routines) — let agents start work automatically on a schedule
- [**Autopilots**](/autopilots) — let agents start work automatically on a schedule
- [**Comments**](/comments) — `@mention` syntax, the picker, and `@all` semantics

View File

@@ -54,5 +54,5 @@ import { Callout } from "fumadocs-ui/components/callout";
## 下一步
- [**对话**](/chat) —— 脱离 issue 和智能体一对一聊
- [**Routines**](/routines) —— 让智能体定时自动开工
- [**Autopilots**](/autopilots) —— 让智能体定时自动开工
- [**评论**](/comments) —— `@mention` 的语法、picker、`@all` 的语义

View File

@@ -22,7 +22,7 @@
"assigning-issues",
"mentioning-agents",
"chat",
"routines",
"autopilots",
"---Inbox---",
"inbox",
"---Self-hosting & ops---",

View File

@@ -22,7 +22,7 @@
"assigning-issues",
"mentioning-agents",
"chat",
"routines",
"autopilots",
"---收件箱---",
"inbox",
"---自部署运维---",

View File

@@ -22,7 +22,7 @@ For guidance on picking a tool when creating an agent, see [Creating and configu
| **Kimi** | Moonshot | ✅ | ❌ | `.kimi/skills/` | Dynamic discovery |
| **OpenCode** | SST | ✅ | ❌ | `.config/opencode/skills/` | Dynamic discovery |
| **OpenClaw** | Open source | ✅ | ❌ | `.agent_context/skills/` (fallback) | Bound to the agent, can't be switched per task |
| **Pi** | Inflection AI | ✅ (session is a file path) | ❌ | `.pi/agent/skills/` | Dynamic discovery |
| **Pi** | Inflection AI | ✅ (session is a file path) | ❌ | `.pi/skills/` | Dynamic discovery |
## What each tool is for
@@ -98,7 +98,7 @@ Each tool uses **its own** skill discovery path. Before a task runs, the Multica
| Cursor | `.cursor/skills/` | ✅ Native |
| Kimi | `.kimi/skills/` | ✅ Native |
| OpenCode | `.config/opencode/skills/` | ✅ Native |
| Pi | `.pi/agent/skills/` | ✅ Native |
| Pi | `.pi/skills/` | ✅ Native |
| Gemini | `.agent_context/skills/` | ⚠️ Generic fallback |
| Hermes | `.agent_context/skills/` | ⚠️ Generic fallback |
| OpenClaw | `.agent_context/skills/` | ⚠️ Generic fallback |

View File

@@ -22,7 +22,7 @@ Multica 内置支持 **10 款 AI 编程工具**。它们都实现了同一套接
| **Kimi** | Moonshot | ✅ | ❌ | `.kimi/skills/` | 动态发现 |
| **OpenCode** | SST | ✅ | ❌ | `.config/opencode/skills/` | 动态发现 |
| **OpenClaw** | 开源项目 | ✅ | ❌ | `.agent_context/skills/` fallback| 绑定在智能体上,不能在任务里切换 |
| **Pi** | Inflection AI | ✅session 为文件路径)| ❌ | `.pi/agent/skills/` | 动态发现 |
| **Pi** | Inflection AI | ✅session 为文件路径)| ❌ | `.pi/skills/` | 动态发现 |
## 每款工具的定位
@@ -98,7 +98,7 @@ Inflection AI 出品,极简主义。**会话恢复机制特殊**——session
| Cursor | `.cursor/skills/` | ✅ 原生 |
| Kimi | `.kimi/skills/` | ✅ 原生 |
| OpenCode | `.config/opencode/skills/` | ✅ 原生 |
| Pi | `.pi/agent/skills/` | ✅ 原生 |
| Pi | `.pi/skills/` | ✅ 原生 |
| Gemini | `.agent_context/skills/` | ⚠️ 通用 fallback |
| Hermes | `.agent_context/skills/` | ⚠️ 通用 fallback |
| OpenClaw | `.agent_context/skills/` | ⚠️ 通用 fallback |

View File

@@ -1,95 +0,0 @@
---
title: Routines
description: Let agents start work on a cron schedule — or trigger once manually via the UI or CLI.
---
import { Callout } from "fumadocs-ui/components/callout";
Routines let [agents](/agents) **start work automatically on a schedule** — configure a cron expression and a timezone, and Multica dispatches a [`task`](/tasks) on its own, without you triggering anything. It fits periodic checks, recurring reports, and overnight cleanup jobs — the "standing order" shape of work. Compared to the other three trigger paths ([assigning](/assigning-issues), [@-mention](/mentioning-agents), and [chat](/chat), where you are the one kicking things off), the core difference with Routines is that they are **time-driven**.
## Configure a routine
Create a new routine on the workspace's **Routines** page. You set:
- **Name** — display name
- **Agent** — who the run is dispatched to
- **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)
## Pick an execution mode
A routine 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.
- **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 routine's run history.
<Callout type="warning">
**Run-only mode is currently unstable.** The CLI labels it "not yet supported end-to-end," and the dispatch path has known issues. New users should stick to create issue mode and wait for run-only mode to ship a stable release before switching.
</Callout>
## Run it on a schedule
Every routine needs at least one `schedule` trigger. Cron uses the **standard 5-field format** (minute hour day month weekday), with **1-minute** minimum granularity (no seconds). Timezone is IANA-formatted (for example, `Asia/Shanghai`) and determines which timezone the cron expression is interpreted in.
A few examples:
- `0 9 * * 1-5`, `Asia/Shanghai` — 9 AM Beijing time on weekdays
- `*/30 * * * *`, `UTC` — every 30 minutes
- `0 3 * * *`, `UTC` — every day at 3 AM UTC
The Multica server scans for due triggers every **30 seconds** — **the actual fire time can lag by up to 30 seconds**, not down to the second. If the server is restarted across a fire time, it catches up missed triggers on startup (nothing is lost, but they fire right away).
## Trigger once manually
To avoid waiting for cron while debugging a routine, trigger it manually:
- UI: click "Run now" on the routine detail page
- CLI:
```bash
multica autopilot trigger <routine-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`.
## View run history
Every trigger produces a **run record**, visible on the "History" tab of the routine detail page:
- Trigger source (`schedule` / `manual`)
- Start time, completion time
- Status (`issue_created` / `running` / `completed` / `failed`)
- The linked issue (create issue mode) or `task` (run-only mode)
- Failure reason (if failed)
## What happens when a routine fails
<Callout type="warning">
**Routine failures are not auto-retried and do not send inbox notifications.** A failure leaves a `failed` entry in run history — no system-level re-enqueue like assign or @-mention, and no notification to anyone. If the routine is periodic, **the next cron fire will trigger a new run**, but the failed work is not automatically re-run.
If a routine is important, design your own monitoring — for example, have the agent post a comment on success, and catch failures by noticing missing comments.
</Callout>
Why no auto-retry: routines are already periodic, so adding system-level retries stacks on top of the next scheduled run and creates duplicate executions. Leaving the schedule entirely to cron keeps it clean.
## Two naming / capability carryovers
**In the CLI it is called `autopilot`.** The current CLI subcommand is `multica autopilot` rather than `multica routine`:
```bash
multica autopilot list
multica autopilot create
multica autopilot trigger <id>
```
The docs use Routines throughout; a future CLI release will unify the naming. For now, treat any `autopilot` wording as Routines.
**Webhook and API triggers are not available yet.** The routine 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.**
## Next
- [**Assign issues to agents**](/assigning-issues) — a one-shot hand-off of an issue to an agent
- [**@-mention agents in comments**](/mentioning-agents) — pull an agent in to take a look from a comment
- [**Chat**](/chat) — one-to-one conversation outside any issue

View File

@@ -31,6 +31,9 @@ make selfhost
3. Bring up every service using `docker-compose.selfhost.yml`
4. Wait until the backend's `/health` endpoint is ready
For ongoing production probes after startup, use `/readyz` when you want the
check to fail on database or migration problems.
The backend container **runs database migrations automatically** on startup (`docker/entrypoint.sh` runs `./migrate up` before the server starts) — you'll see the migration output in the backend logs. Version upgrades are handled the same way.
<Callout type="info">
@@ -42,19 +45,19 @@ Once it's up:
- **Frontend**: [http://localhost:3000](http://localhost:3000)
- **Backend**: [http://localhost:8080](http://localhost:8080)
## 2. Important: set `APP_ENV` to `production`
## 2. Important: keep production safety on
<Callout type="warning">
**`docker-compose.selfhost.yml` sets `APP_ENV` to `production` by default** — this prevents the development "master code `888888`" from being enabled on an instance you've exposed to the public internet.
**`docker-compose.selfhost.yml` sets `APP_ENV` to `production` by default** and leaves `MULTICA_DEV_VERIFICATION_CODE` empty, so there is no fixed code on public instances.
**But if your `.env` leaves `APP_ENV` empty or sets it to another value**, `888888` is enabled — **anyone can log in as any email by typing `888888` as the verification code**. See [Auth setup → The 888888 trap](/auth-setup#the-888888-trap).
Only set `MULTICA_DEV_VERIFICATION_CODE` for local or private test automation. If a fixed code is enabled while `APP_ENV` is non-production, anyone who can request a code can sign in with that fixed value. See [Auth setup → Fixed local testing codes](/auth-setup#fixed-local-testing-codes).
Before any public deployment, make sure `.env` has `APP_ENV=production`.
Before any public deployment, make sure `.env` has `APP_ENV=production` and `MULTICA_DEV_VERIFICATION_CODE` is empty.
</Callout>
## 3. Configure the email service (optional but recommended)
Without email configured, your users can't receive verification codes — **unless `APP_ENV != production`, in which case `888888` works** (see the warning above).
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:
@@ -77,6 +80,7 @@ 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)
- 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

View File

@@ -31,6 +31,8 @@ make selfhost
3. 用 `docker-compose.selfhost.yml` 启动全部服务
4. 等后端 `/health` 端点准备就绪
如果是启动完成后的生产探针,想让数据库或 migration 异常也体现为失败,请改用 `/readyz`。
后端容器启动时会**自动跑数据库 migration**`docker/entrypoint.sh` 在启动 server 前执行 `./migrate up`)——你会在 backend 日志里看到 migration 输出。升级版本时同样自动处理。
<Callout type="info">
@@ -42,19 +44,19 @@ make selfhost
- **前端**[http://localhost:3000](http://localhost:3000)
- **后端**[http://localhost:8080](http://localhost:8080)
## 2. 重要:改 `APP_ENV` 成 `production`
## 2. 重要:保持生产安全配置
<Callout type="warning">
**`docker-compose.selfhost.yml` 默认把 `APP_ENV` 设成 `production`**——这防止开发用的"万能验证码 `888888`"在你公网暴露的实例上启用
**`docker-compose.selfhost.yml` 默认把 `APP_ENV` 设成 `production`**,并让 `MULTICA_DEV_VERIFICATION_CODE` 为空,所以公网实例默认没有固定验证码
**但如果你的 `.env` 里把 `APP_ENV` 留空或改成其他值**`888888` 会被启用——**任何人输入任何邮箱 + `888888` 都能登录**。详见 [登录与注册配置 → 888888 陷阱](/auth-setup#888888-陷阱)。
只在本地或私有测试自动化里设置 `MULTICA_DEV_VERIFICATION_CODE`。如果在 `APP_ENV` 非 production 时启用了固定验证码,任何能请求验证码的人都能用这个固定值登录。详见 [登录与注册配置 → 固定本地测试验证码](/auth-setup#固定本地测试验证码)。
公网部署前一定检查 `.env` 里 `APP_ENV=production`。
公网部署前一定检查 `.env` 里 `APP_ENV=production`,且 `MULTICA_DEV_VERIFICATION_CODE` 为空
</Callout>
## 3. 配置邮件服务(可选但推荐)
如果不配邮件,你的用户无法收到验证码——**但如果 `APP_ENV != production` 你可以用 `888888` 登录**(见上方警告)
如果不配邮件,用户无法通过邮件收到验证码server 会把生成的验证码打印到 stdout
要真的发验证码邮件:
@@ -77,6 +79,7 @@ make selfhost
- 输入你的邮箱
- 从 Resend 邮件里拿验证码(或者前面没配 Resend 的话从 server 容器的 stdout 里抄 `[DEV] Verification code` 这行)
- 不要直接使用 `888888`;只有在非 production 私有实例上显式设置 `MULTICA_DEV_VERIFICATION_CODE=888888` 后它才会生效
- 登录后创建第一个工作区
## 5. 连接命令行工具到你自己的 server

View File

@@ -6,7 +6,7 @@ description: The unit of work for every agent run, with a clear state machine, t
import { Callout } from "fumadocs-ui/components/callout";
import { Mermaid } from "@/components/mermaid";
A **task** is the unit of every [agent](/agents) run — [assigning an issue to an agent](/assigning-issues), [@-mentioning an agent in a comment](/mentioning-agents), sending a message in [chat](/chat), or a [Routine](/routines) firing on schedule all produce a task. Multica puts it in a queue; a [daemon](/daemon-runtimes) picks it up and hands it off to the corresponding [AI coding tool](/providers), then writes the result back to the server when it finishes.
A **task** is the unit of every [agent](/agents) run — [assigning an issue to an agent](/assigning-issues), [@-mentioning an agent in a comment](/mentioning-agents), sending a message in [chat](/chat), or an [Autopilot](/autopilots) firing on schedule all produce a task. Multica puts it in a queue; a [daemon](/daemon-runtimes) picks it up and hands it off to the corresponding [AI coding tool](/providers), then writes the result back to the server when it finishes.
Tasks and [issues](/issues) are two different objects. A single issue can be assigned, @-mentioned, and manually rerun many times — each produces a **new** task.
@@ -59,10 +59,10 @@ Failures fall into two categories: **retryable** and **non-retryable**.
Automatic retry also has two extra conditions:
1. **At most 2 attempts** — 1 original + 1 retry. If the retry also fails, no further retries, even if the reason is retryable.
2. **Only for issue- and chat-triggered tasks** — Routine-triggered tasks do **not** retry automatically.
2. **Only for issue- and chat-triggered tasks** — Autopilot-triggered tasks do **not** retry automatically.
<Callout type="warning">
**Routine tasks don't retry automatically** by design. A Routine has its own firing cadence (e.g. daily); automatic retries on failure would overlap with the next scheduled run. If you need an immediate re-run after failure, use a manual rerun (next section).
**Autopilot tasks don't retry automatically** by design. An Autopilot has its own firing cadence (e.g. daily); automatic retries on failure would overlap with the next scheduled run. If you need an immediate re-run after failure, use a manual rerun (next section).
</Callout>
## Manual rerun vs. automatic retry
@@ -109,4 +109,4 @@ See [Providers Matrix → Session resumption](/providers#session-resumption-who-
## Next
- [Providers Matrix](/providers) — capability differences across the 10 AI coding tools (including the exact session-resumption status)
- [Assigning issues to agents](/assigning-issues) / [@-mentioning agents in comments](/mentioning-agents) / [Chat](/chat) / [Routines](/routines) — the four ways to trigger a task
- [Assigning issues to agents](/assigning-issues) / [@-mentioning agents in comments](/mentioning-agents) / [Chat](/chat) / [Autopilots](/autopilots) — the four ways to trigger a task

View File

@@ -6,7 +6,7 @@ description: 智能体每一次工作的单位,有明确的状态机、超时
import { Callout } from "fumadocs-ui/components/callout";
import { Mermaid } from "@/components/mermaid";
**执行任务**task是 [智能体](/agents) 每一次工作的单位——把一个 [issue 分给智能体](/assigning-issues)、[在评论里 @提及智能体](/mentioning-agents)、在 [聊天](/chat) 里发一条消息、或者 [Routine](/routines) 到点触发都会产生一个执行任务。Multica 把它放进队列,由 [守护进程](/daemon-runtimes) 领走后交给对应的 [AI 编程工具](/providers) 执行,结束时把结果写回服务器。
**执行任务**task是 [智能体](/agents) 每一次工作的单位——把一个 [issue 分给智能体](/assigning-issues)、[在评论里 @提及智能体](/mentioning-agents)、在 [聊天](/chat) 里发一条消息、或者 [Autopilot](/autopilots) 到点触发都会产生一个执行任务。Multica 把它放进队列,由 [守护进程](/daemon-runtimes) 领走后交给对应的 [AI 编程工具](/providers) 执行,结束时把结果写回服务器。
执行任务和 [issue](/issues) 是两层不同对象:一个 issue 可以反复分配、反复 @提及、手动重跑——每次都产生一个**新的**执行任务。
@@ -59,10 +59,10 @@ Multica 服务器每 30 秒扫描一次,有两种超时会触发失败:
自动重试有两个额外条件:
1. **最多 2 次**——1 次原任务 + 1 次重试。重试也失败就不再重试,即使原因可重试。
2. **只对 issue 和聊天触发的任务生效**——Routines 触发的任务**不自动重试**。
2. **只对 issue 和聊天触发的任务生效**——Autopilots 触发的任务**不自动重试**。
<Callout type="warning">
**Routines 任务不自动重试**是刻意设计。Routine 有自己的触发周期(例如每天一次);如果失败又自动重试,会和下一个周期的任务重叠。需要失败后立即重跑,用手动重跑(下一节)。
**Autopilots 任务不自动重试**是刻意设计。Autopilot 有自己的触发周期(例如每天一次);如果失败又自动重试,会和下一个周期的任务重叠。需要失败后立即重跑,用手动重跑(下一节)。
</Callout>
## 手动重跑和自动重试的区别
@@ -109,4 +109,4 @@ Multica 在任务过程中**两次**保存会话 ID——任务一开始AI
## 下一步
- [Providers Matrix](/providers) —— 10 款 AI 编程工具的能力差异对照(包括会话恢复的精确状态)
- [分配 issue 给智能体](/assigning-issues) / [在评论里 @智能体](/mentioning-agents) / [聊天](/chat) / [Routines](/routines) —— 触发执行任务的四种方式
- [分配 issue 给智能体](/assigning-issues) / [在评论里 @智能体](/mentioning-agents) / [聊天](/chat) / [Autopilots](/autopilots) —— 触发执行任务的四种方式

View File

@@ -25,6 +25,7 @@ Look up issues by symptom. Each entry gives you **symptom / likely causes / how
multica daemon logs --lines 100 # look for daemon-side errors
echo $MULTICA_SERVER_URL # confirm the address is set
curl -i http://<server-host>:8080/health # hit the server directly
curl -i http://<server-host>:8080/readyz # include DB + migration readiness
cat ~/.multica/config.json # verify api_token exists
multica workspace list # confirm you're a member of the target workspace
```
@@ -107,28 +108,29 @@ On the server side (self-host), grep for `"no_tasks"` / `"no_capacity"` to see t
- Domain not verified → run the DNS verification flow in the Resend console (add SPF / DKIM records)
- In an emergency (internal testing) → copy the code printed under `[DEV]` from the server logs
## Verification code `888888` doesn't work
## Fixed local test code doesn't work
**Symptom**: on a self-hosted instance, you try to sign in with the development-only master code `888888` and it's rejected with `invalid or expired code`.
**Symptom**: on a self-hosted instance, you try to sign in with a fixed local test code such as `888888` and it's rejected with `invalid or expired code`.
**Likely causes** (mutually exclusive):
1. **`APP_ENV=production`** — this is the **correct** production configuration; `888888` is **disabled** when `APP_ENV=production`. Intentional design, not a bug
2. **You received a real code via Resend** — if Resend is configured, the server sent an actual email; `888888` is only a dev fallback
1. **`MULTICA_DEV_VERIFICATION_CODE` is empty** — fixed codes are disabled by default
2. **`APP_ENV=production`** — this is the **correct** production configuration; fixed local test codes are ignored in production
3. **The configured code is not 6 digits** — the shortcut only accepts a 6-digit value
**How to diagnose**:
```bash
cat .env | grep APP_ENV # inspect current config
docker exec <container> env | grep APP_ENV # docker deployment
cat .env | grep -E 'APP_ENV|MULTICA_DEV_VERIFICATION_CODE'
docker exec <container> env | grep -E 'APP_ENV|MULTICA_DEV_VERIFICATION_CODE'
```
Check your inbox (including spam) for the real verification code.
**How to fix**:
- In production, you shouldn't be using `888888` at all — configure Resend and use real codes
- **For local development or internal testing**, if you need `888888`, ensure `APP_ENV` is unset or not `production` — but **never** run a public instance this way (see [Sign-in and signup configuration → The 888888 trap](/auth-setup#the-888888-trap))
- In production, leave `MULTICA_DEV_VERIFICATION_CODE` empty — configure Resend and use real codes
- For local development or internal testing, either copy the generated code from server logs or set `APP_ENV=development` plus `MULTICA_DEV_VERIFICATION_CODE=888888` — never enable a fixed code on a public instance (see [Sign-in and signup configuration → Fixed local testing codes](/auth-setup#fixed-local-testing-codes))
## Port conflicts

View File

@@ -25,6 +25,7 @@ import { Callout } from "fumadocs-ui/components/callout";
multica daemon logs --lines 100 # 看 daemon 侧错误
echo $MULTICA_SERVER_URL # 确认地址配对
curl -i http://<server-host>:8080/health # 直接戳 server
curl -i http://<server-host>:8080/readyz # 连同 DB + migration readiness 一起检查
cat ~/.multica/config.json # 看 api_token 是否存在
multica workspace list # 确认你是目标工作区成员
```
@@ -107,28 +108,29 @@ multica issue show <issue-id> # 看 task 历史
- 域名没验证 → Resend console 里走 DNS 验证流程(加 SPF / DKIM 记录)
- 紧急情况下(如内部测试)→ 从 server 日志里抄 `[DEV]` 打印出的验证码
## 验证码是 `888888` 但登不进去
## 固定本地测试验证码登不进去
**症状**:自部署实例,想用开发用的主验证码 `888888` 登录,但被拒 `invalid or expired code`。
**症状**:自部署实例,想用 `888888` 这类固定本地测试验证码登录,但被拒 `invalid or expired code`。
**可能原因**这俩互斥):
**可能原因**(互斥):
1. **`APP_ENV=production`** —— 这正是你**应该**的生产配置;`888888` 在 `APP_ENV=production` 时**被禁用**。这是刻意设计,不是 bug
2. **你在 Resend 收到了真实验证码** —— 如果 Resend 已配server 实际发了真邮件,`888888` 只作为 dev fallback
1. **`MULTICA_DEV_VERIFICATION_CODE` 为空** —— 固定验证码默认关闭
2. **`APP_ENV=production`** —— 这是正确的生产配置;固定本地测试验证码在 production 中会被忽略
3. **配置的验证码不是 6 位数字** —— 这个快捷码只接受 6 位数字
**怎么查**
```bash
cat .env | grep APP_ENV # 看当前配置
docker exec <container> env | grep APP_ENV # docker 部署
cat .env | grep -E 'APP_ENV|MULTICA_DEV_VERIFICATION_CODE'
docker exec <container> env | grep -E 'APP_ENV|MULTICA_DEV_VERIFICATION_CODE'
```
检查邮箱(含 spam看有没有收到真实验证码。
**怎么修**
- 生产环境你本来就不该用 `888888`—— 配好 Resend 用真实验证码
- **本地开发或内网测试**若需要 `888888`确保 `APP_ENV` 未设或不是 `production`——但**绝对不要**这样跑公网实例(详见 [登录与注册配置 → 888888 陷阱](/auth-setup#888888-陷阱)
- 生产环境保持 `MULTICA_DEV_VERIFICATION_CODE` 为空,配好 Resend 后使用真实验证码
- 本地开发或内网测试可以从 server 日志抄生成的验证码;如果需要 `888888`设置 `APP_ENV=development` 和 `MULTICA_DEV_VERIFICATION_CODE=888888`。不要在公网实例启用固定验证码(详见 [登录与注册配置 → 固定本地测试验证码](/auth-setup#固定本地测试验证码)
## 端口冲突

View File

@@ -6,7 +6,7 @@ import { useLocale } from "../i18n";
import { GitHubMark, githubUrl, heroButtonClassName } from "./shared";
export function HowItWorksSection() {
const { t } = useLocale();
const { t, locale } = useLocale();
const user = useAuthStore((s) => s.user);
return (
@@ -44,6 +44,12 @@ export function HowItWorksSection() {
<Link href={user ? "/" : "/login"} className={heroButtonClassName("solid")}>
{user ? t.header.dashboard : t.howItWorks.cta}
</Link>
<Link
href={locale === "zh" ? "/docs/zh" : "/docs"}
className={heroButtonClassName("ghost")}
>
{t.howItWorks.ctaDocs}
</Link>
<Link
href={githubUrl}
target="_blank"

View File

@@ -144,6 +144,7 @@ export function createEnDict(allowSignup: boolean): LandingDict {
],
cta: "Get started",
ctaGithub: "View on GitHub",
ctaDocs: "Read the docs",
},
openSource: {
@@ -232,7 +233,7 @@ export function createEnDict(allowSignup: boolean): LandingDict {
resources: {
label: "Resources",
links: [
{ label: "Documentation", href: githubUrl },
{ label: "Documentation", href: "/docs" },
{ label: "API", href: githubUrl },
{ label: "X (Twitter)", href: "https://x.com/MulticaAI" },
],
@@ -282,6 +283,80 @@ export function createEnDict(allowSignup: boolean): LandingDict {
fixes: "Bug Fixes",
},
entries: [
{
version: "0.2.18",
date: "2026-04-27",
title: "Issue Labels, Labs Tab & Sidebar Invite Dot",
changes: [],
features: [
"Issue labels — color-code and filter issues across list, board and detail views",
"Labs settings tab for experimental toggles",
"Sidebar shows a dot when you have an unread workspace invite",
],
improvements: [
"Project picker now shows the selected project's icon",
"Sidebar parent items stay highlighted on detail pages",
"Self-hosted deployments correctly honor signup gating env vars",
],
fixes: [
"Agent comments preserve line breaks again",
"Desktop RPM no longer conflicts with Slack / VS Code on Fedora",
"Windows agents handle multi-line prompts correctly",
],
},
{
version: "0.2.17",
date: "2026-04-26",
title: "Custom Agent Env, Better Failure Messages & Reliability Fixes",
changes: [],
features: [
"`multica agent create/update --custom-env KEY=VALUE` injects custom environment variables into agent runs",
"Agent failure messages now include a tail of the runtime CLI's stderr — much easier to debug runtime errors",
"CLI update download timeout is now configurable, so slow links no longer abort `multica update`",
],
improvements: [
"Daemon reports cancelled tasks as `cancelled` instead of `timeout`, and reconciles agent status when an issue's tasks are cancelled",
"Server heartbeat split into probe/claim with slow-log + a model-list running-timeout, so a lost heartbeat no longer wedges the UI",
],
fixes: [
"Server validates `assignee_id` on issue create/update so phantom IDs are rejected, and `DeleteIssue` uses the resolved issue ID",
"Pi runtime now reads/writes `.pi/skills` instead of the old `.pi/agent/skills` path",
"Windows daemon uses `CREATE_NEW_CONSOLE` so grandchild console popups no longer appear when launching agents",
"Autopilot run-only context is now properly forwarded to the agent",
],
},
{
version: "0.2.16",
date: "2026-04-24",
title: "Chat V2, Issue Right-Click Menu & In-App Feedback",
changes: [],
features: [
"Chat V2 — dedicated sidebar entry and full main-area page for AI conversations",
"Right-click context menu on issues with a unified action set across list, board, and detail",
"In-app feedback flow with a new Help launcher centralizing docs, support, and feedback",
"Autopilot modal redesigned — simpler schema and consistent schedule UI across creation and edit",
"Skills page redesigned — list + detail pages, scroll-fade card layout, shared PageHeader and mobile nav",
"Bilingual flat-content rewrite of the docs site — English and Chinese sections share one tree",
],
improvements: [
"Agent profile card appears on avatar hover for quick context",
"Native right-click menu on desktop with clipboard actions (copy / paste / cut / select all)",
"Daemon agent prompts hardened to break self-mention loops between agents",
"Server readiness health endpoints for proper rollout / ingress probes",
"Daemon GC defaults tightened and now accept flexible duration suffixes (e.g. `7d`, `12h`)",
"Test Connection / runtime ping removed — runtime reachability is detected automatically",
],
fixes: [
"Chat no longer flickers when a streamed response finalizes, and the input box no longer jumps when sending the first message",
"Desktop reopens the last-used workspace on app start instead of falling back to the first one",
"Editor preserves nested ordered lists through the readonly render path",
"CLI `browser-login` now works from a machine that isn't running the server",
"Daemon suppresses extra terminal windows when launching agents on Windows, and retries local-skill reports on transient server errors",
"`/api/config` is publicly reachable again so unauthenticated clients can bootstrap",
"Defense-in-depth owner check on workspace deletion, and `/health/realtime` metrics restricted to authorized callers (security)",
"Hermes ACP runtime now receives the configured model; OpenClaw agent discovery timeout raised to 30s",
],
},
{
version: "0.2.15",
date: "2026-04-22",

View File

@@ -43,6 +43,7 @@ export type LandingDict = {
steps: { title: string; description: string }[];
cta: string;
ctaGithub: string;
ctaDocs: string;
};
openSource: {
label: string;

View File

@@ -144,6 +144,7 @@ export function createZhDict(allowSignup: boolean): LandingDict {
],
cta: "\u5f00\u59cb\u4f7f\u7528",
ctaGithub: "\u5728 GitHub \u4e0a\u67e5\u770b",
ctaDocs: "\u9605\u8bfb\u6587\u6863",
},
openSource: {
@@ -232,7 +233,7 @@ export function createZhDict(allowSignup: boolean): LandingDict {
resources: {
label: "\u8d44\u6e90",
links: [
{ label: "\u6587\u6863", href: githubUrl },
{ label: "\u6587\u6863", href: "/docs/zh" },
{ label: "API", href: githubUrl },
{ label: "X (Twitter)", href: "https://x.com/MulticaAI" },
],
@@ -282,6 +283,80 @@ export function createZhDict(allowSignup: boolean): LandingDict {
fixes: "问题修复",
},
entries: [
{
version: "0.2.18",
date: "2026-04-27",
title: "Issue 标签、Labs 设置页与邀请红点",
changes: [],
features: [
"Issue 标签——给 Issue 上色、分类,列表、看板和详情页都能用",
"新增 Labs 设置页,集中放实验性开关",
"有未读工作区邀请时,侧边栏会出现红点提示",
],
improvements: [
"Project 选择器会显示当前所选 Project 的图标",
"进入详情页时,侧边栏父级菜单保持高亮",
"自托管部署正确读取注册放行相关的环境变量",
],
fixes: [
"Agent 评论的换行恢复正常显示",
"桌面端 RPM 不再与 Slack / VS Code 在 Fedora 上冲突",
"Windows 下 Agent 能正确处理多行 prompt",
],
},
{
version: "0.2.17",
date: "2026-04-26",
title: "Agent 自定义环境变量、更清晰的失败信息与一系列稳定性修复",
changes: [],
features: [
"`multica agent create/update --custom-env KEY=VALUE` 支持为 Agent 注入自定义环境变量",
"Agent 失败信息会带上 Runtime CLI 的 stderr 末尾片段,排查 Runtime 报错更直接",
"CLI 更新下载超时支持配置,弱网下 `multica update` 不再被默认超时切断",
],
improvements: [
"Daemon 把取消的任务上报为 `cancelled` 而非 `timeout`,并在按 Issue 取消任务时同步对齐 Agent 状态",
"Server 心跳拆成 probe/claim 两步,并补上慢日志和 model-list running-timeout丢心跳不再卡住 UI",
],
fixes: [
"Server 在 Issue 创建/更新时校验 `assignee_id` 真实存在DeleteIssue 改用解析后的 Issue ID",
"Pi Runtime 改为读写 `.pi/skills`,不再使用旧的 `.pi/agent/skills` 路径",
"Windows 下 Daemon 启动 Agent 改用 `CREATE_NEW_CONSOLE`,孙子进程不再弹出额外终端窗口",
"Autopilot 的 run-only 上下文正确传给被调起的 Agent",
],
},
{
version: "0.2.16",
date: "2026-04-24",
title: "Chat V2、Issue 右键菜单与应用内反馈",
changes: [],
features: [
"Chat V2——侧边栏新增 Chat 入口,主区域提供完整的 AI 对话页面",
"Issue 支持右键菜单,列表、看板和详情的操作入口统一收敛",
"应用内反馈流程及全新的 Help 启动器,集中托管文档、支持和反馈入口",
"Autopilot 弹窗重设计——更简的字段配置,创建与编辑共享一致的排期界面",
"Skills 页面重设计——列表+详情、卡片化布局、滚动渐隐和共享 PageHeader / 移动端导航",
"文档站重写为双语扁平内容树——中英文章节共用一棵目录",
],
improvements: [
"悬停 Agent 头像即可弹出资料卡,快速了解上下文",
"桌面应用新增原生右键菜单,支持复制 / 粘贴 / 剪切 / 全选等剪贴板操作",
"Daemon 强化 Agent 提示,避免 Agent 之间形成自互 @ 的循环",
"Server 新增就绪态健康检查端点,可对接灰度发布和 Ingress 探针",
"Daemon GC 默认参数收紧,并支持灵活的时长后缀(如 `7d`、`12h`",
"移除 Runtime 的 Test Connection / Ping 功能,可达性改为自动检测",
],
fixes: [
"Chat 流式回复结束时不再闪烁,发送第一条消息时输入框不再跳动",
"桌面应用启动时正确恢复上次的工作区,而不是默认回到第一个",
"编辑器只读渲染路径正确保留嵌套有序列表",
"CLI `browser-login` 现在可以从未运行 Server 的机器上发起",
"Windows 下 Daemon 启动 Agent 不再拉起额外终端窗口;本地 Skill 上报在服务端瞬时错误时会自动重试",
"`/api/config` 重新对未登录客户端可达,方便初次 bootstrap",
"DeleteWorkspace 增加防御性 owner 校验;`/health/realtime` 指标限定授权访问(安全)",
"Hermes ACP Runtime 正确传递配置的模型OpenClaw Agent 发现超时提高到 30s",
],
},
{
version: "0.2.15",
date: "2026-04-22",

View File

@@ -40,6 +40,7 @@ services:
environment:
DATABASE_URL: postgres://${POSTGRES_USER:-multica}:${POSTGRES_PASSWORD:-multica}@postgres:5432/${POSTGRES_DB:-multica}?sslmode=disable
PORT: "8080"
METRICS_ADDR: ${METRICS_ADDR:-}
JWT_SECRET: ${JWT_SECRET:-change-me-in-production}
FRONTEND_ORIGIN: ${FRONTEND_ORIGIN:-http://localhost:3000}
CORS_ALLOWED_ORIGINS: ${CORS_ALLOWED_ORIGINS:-}
@@ -55,7 +56,11 @@ services:
CLOUDFRONT_PRIVATE_KEY: ${CLOUDFRONT_PRIVATE_KEY:-}
COOKIE_DOMAIN: ${COOKIE_DOMAIN:-}
APP_ENV: ${APP_ENV:-production}
MULTICA_DEV_VERIFICATION_CODE: ${MULTICA_DEV_VERIFICATION_CODE:-}
MULTICA_APP_URL: ${MULTICA_APP_URL:-http://localhost:3000}
ALLOW_SIGNUP: ${ALLOW_SIGNUP:-true}
ALLOWED_EMAILS: ${ALLOWED_EMAILS:-}
ALLOWED_EMAIL_DOMAINS: ${ALLOWED_EMAIL_DOMAINS:-}
restart: unless-stopped
frontend:

View File

@@ -147,7 +147,7 @@ multica issue assign <issue-id> --agent <agent-slug>
**关键约定**
- **Callout**`<Callout type="info|warning|tip">...</Callout>`。warning 用于陷阱(如 888888info 用于补充说明tip 用于最佳实践
- **Callout**`<Callout type="info|warning|tip">...</Callout>`。warning 用于陷阱(如固定测试验证码info 用于补充说明tip 用于最佳实践
- **代码块**shell 命令用 \`\`\`bash配置用 \`\`\`yaml / \`\`\`envJSON 用 \`\`\`json
- **Cross-link**:用 markdown 链接 `[显示文字](/docs/page-slug)`,不要写成 "详见 Tasks 章节"
- **表格**:有 3 行以上对照才用表格,不要 1-2 行也用
@@ -723,11 +723,11 @@ multica issue assign <issue-id> --agent <agent-slug>
> **合并说明**:原 7.3 Auth Setup + 7.10 Signup Controls 合并。
- **Source files**: `server/internal/handler/auth.go`APP_ENV 判断 + checkSignupAllowed, `.env.example`auth 相关注释)
- **Source files**: `server/internal/handler/auth.go`固定测试验证码 + checkSignupAllowed, `.env.example`auth 相关注释)
- **目标读者**: self-host 运维
- **叙事位置**: self-host 的 auth 配置。
- **写什么**1500-2000 字):
- **🚨 超醒目 warning block**`APP_ENV=production` 必须设置,否则 verification code 恒为 `888888`(任何人登录任何账号)
- **🚨 超醒目 warning block**生产环境必须保持 `MULTICA_DEV_VERIFICATION_CODE` 为空;固定测试验证码只用于非 production 私有测试
- Email + verification code 登录流程(依赖 Resend
- Google OAuth 配置步骤(创建 OAuth client → redirect URI → 填 env
- **Signup 白名单三层优先级决策树**:
@@ -737,9 +737,9 @@ multica issue assign <issue-id> --agent <agent-slug>
- 典型场景:开放给公司域 / 限定几个邮箱 / 完全关闭 signup
- 和邀请的关系signup 关了也能通过邀请加人)
- **不写**: JWT 实现、token 类型§8.2 讲)
- **写前要验证**: APP_ENV 判断条件OAuth 流程最新Signup 优先级
- **写前要验证**: 固定测试验证码的 env 条件OAuth 流程最新Signup 优先级
- **⚠️ 动笔前必读**:
- ⚠️⚠️ **888888 陷阱必须最醒目**(红色 warning block这是 self-host 最大坑
- ⚠️⚠️ **固定测试验证码风险必须最醒目**(红色 warning block这是 self-host 最大坑
- OAuth 给外部步骤截图,别假设读者懂 GCP Console
- 决策树建议用 Mermaid 图
- **Owner**:
@@ -754,7 +754,7 @@ multica issue assign <issue-id> --agent <agent-slug>
- 任务一直 queuedruntime offline / max_concurrent 满 / agent 配错)
- WebSocket 连不上cookie / CORS / proxy
- Email 没收到Resend 未配置 → 看 stderr
- 验证码收到是 888888 但不工作APP_ENV 检查)
- 固定测试验证码不工作APP_ENV / MULTICA_DEV_VERIFICATION_CODE 检查)
- Port 冲突
- 日志位置daemon / server / browser console
- **不写**: 深度 bug report去 GitHub issue

View File

@@ -85,7 +85,7 @@ Multica = **人 + AI agent 在同一个看板上协作的任务管理平台**。
| 7 | **The Daemon** | 分布式执行的灵魂poll + heartbeat + concurrent execution | 每 30s heartbeat75s 无心跳 → 离线;启动时调 `recover-orphans` 回收孤儿任务max_concurrent_tasks 有双层daemon + agent |
| 8 | **Tasks** | 任务是什么;生命周期 queued→dispatched→running→completed/failed | **session_id mid-flight pinning**agent 首条 system message 一到就持久化,不等完成);失败自动重试只对 issue-sourced 任务max_attempts=3chat 和 autopilot 不自动重试 |
| 9 | **Triggers & Entry Points****独立页** | 5 种让 task 产生的入口Assignment / Comment @mention / Chat / Autopilot / Rerun每种的行为对比 | 每种的 FK 字段不同trigger_comment_id / chat_session_id / autopilot_run_id**对比表**:哪种有 session resume / 自动重试 / priority 来源 / dedup |
| 10 | **Skills** | 工作区 skill + 本地 skill按 provider 的注入路径 | 8 种 provider 有不同 skill 根路径Claude=`.claude/skills/`、Codex=`$CODEX_HOME/skills/`、Pi=`.pi/agent/skills/`、etcskill 不参与执行,只参与上下文注入 |
| 10 | **Skills** | 工作区 skill + 本地 skill按 provider 的注入路径 | 8 种 provider 有不同 skill 根路径Claude=`.claude/skills/`、Codex=`$CODEX_HOME/skills/`、Pi=`.pi/skills/`、etcskill 不参与执行,只参与上下文注入 |
| 11 | **MCP** | 独立协议;怎么给 agent 配 MCP server和 skill 的区别 | **目前只 Claude Code 真用**——其他 provider 收到 McpConfig 但 CLI 没对应 flagJSONB 明文存储,非 owner redact |
| 12 | **Autopilots** | 让 agent 自动开工的调度器;两种执行模式;三种触发;并发策略 | **Webhook trigger 字段有但没接路由**——第一版不文档化concurrency policy 只对 `run_only` 模式生效;`create_issue` 模式由 issue FSM 自然 gate |
| 13 | **Chat** | 和 issue comment 的区别session 复用 | **完全沙盒**——chat 里的 agent 不能发 comment 到 issuesession_id 用 COALESCE 持久化agent crash 不会抹掉 |
@@ -118,7 +118,7 @@ Multica = **人 + AI agent 在同一个看板上协作的任务管理平台**。
| Overview | 决策树(哪种部署模式适合你) |
| Docker Compose deployment | `make selfhost` vs `make selfhost-build` |
| Environment variables reference | 完整 env 表 |
| Authentication setup | **🚨 `APP_ENV != "production"` 会让 verification code 固定为 `888888`** —— 生产必须设置 `APP_ENV=production`Google OAuth 配置signup 白名单 |
| Authentication setup | **🚨 固定测试验证码必须显式设置 `MULTICA_DEV_VERIFICATION_CODE`,生产保持为空**Google OAuth 配置signup 白名单 |
| Storage | S3 / CloudFront / 本地磁盘 |
| Email | Resend 配置;**没配会落到 stderr** |
| Upgrading | 版本升级 + migration 策略 |
@@ -145,7 +145,7 @@ Installation / Authentication / Setup / Daemon / Workspace / Issue / Comment / A
| 5 | Webhook autopilot trigger 字段建了但没接路由——第一版不文档化 | Autopilots |
| 6 | custom_env merge 是覆盖而非合并——不能用 custom_env"取消设置"系统 env | Agents |
| 7 | 旧 assignee 取消分配后不会被取消订阅 | Subscriptions |
| 8 | `APP_ENV != "production"` 时 verification code 恒为 `888888` | Self-Hosting → Auth |
| 8 | 固定本地测试验证码默认关闭;`MULTICA_DEV_VERIFICATION_CODE` 仅用于非 production 私有测试 | Self-Hosting → Auth |
| 9 | Signup 白名单优先级ALLOWED_EMAILS > ALLOWED_EMAIL_DOMAINS > ALLOW_SIGNUP | Self-Hosting → Auth |
| 10 | One daemon ↔ many runtimesone runtime ↔ ONE provider同 daemon_id 重启复用旧 runtime 行 | Runtimes / Daemon |
| 11 | Inbox 10 种类型mention dedup 只在单 event 内生效 | Inbox |
@@ -159,7 +159,7 @@ Installation / Authentication / Setup / Daemon / Workspace / Issue / Comment / A
|---|---|
| Mermaid diagram | 架构图 / task 生命周期 / trigger 流向 / autopilot 调度链 |
| Tabs | Cloud / Self-Host / Desktop 并列CLI / UI 并列 |
| Callouts内置| Tip / Warning / Note — **警告类密集用在 Agents 的 custom_env 和 Self-Host 的 888888** |
| Callouts内置| Tip / Warning / Note — **警告类密集用在 Agents 的 custom_env 和 Self-Host 的固定测试验证码** |
| Code Tabs | API 调用多语言Shell / Node / Go |
| Video / GIF | "Create your first agent"、"Follow an agent working" |
| DeploymentPicker定制| 交互式决策树:回答 3 个问题 → 推荐部署路径 |

View File

@@ -373,7 +373,7 @@ skill
- Claude Code → `.claude/skills/{name}/SKILL.md`
- Codex → `CODEX_HOME/skills/{name}/`
- OpenCode → `.config/opencode/skills/{name}/SKILL.md`
- Pi → `.pi/agent/skills/{name}/SKILL.md`
- Pi → `.pi/skills/{name}/SKILL.md`
- Cursor → `.cursor/skills/{name}/SKILL.md`
- GitHub Copilot → `.github/skills/{name}/SKILL.md`
- 其他 → `.agent_context/skills/{name}/SKILL.md`

View File

@@ -51,6 +51,11 @@ import type {
CreateProjectRequest,
UpdateProjectRequest,
ListProjectsResponse,
Label,
CreateLabelRequest,
UpdateLabelRequest,
ListLabelsResponse,
IssueLabelsResponse,
PinnedItem,
CreatePinRequest,
PinnedItemType,
@@ -146,12 +151,17 @@ export interface ImportStarterContentResponse {
export class ApiError extends Error {
readonly status: number;
readonly statusText: string;
// Raw decoded JSON body (when the server returned one). Carries structured
// error fields like `code` so callers can branch on machine-readable
// identifiers instead of pattern-matching the human-readable message.
readonly body?: unknown;
constructor(message: string, status: number, statusText: string) {
constructor(message: string, status: number, statusText: string, body?: unknown) {
super(message);
this.name = "ApiError";
this.status = status;
this.statusText = statusText;
this.body = body;
}
}
@@ -216,6 +226,19 @@ export class ApiClient {
return fallback;
}
// Reads the response body once for both human-readable error message and
// structured fields. The Response stream can only be consumed once, so
// both pieces have to come from a single read.
private async parseErrorBody(res: Response, fallback: string): Promise<{ message: string; body: unknown }> {
try {
const data = await res.json() as { error?: string };
const message = typeof data.error === "string" && data.error ? data.error : fallback;
return { message, body: data };
} catch {
return { message: fallback, body: undefined };
}
}
private async fetch<T>(path: string, init?: RequestInit): Promise<T> {
const rid = createRequestId();
const start = Date.now();
@@ -238,10 +261,10 @@ export class ApiClient {
if (!res.ok) {
if (res.status === 401) this.handleUnauthorized();
const message = await this.parseErrorMessage(res, `API error: ${res.status} ${res.statusText}`);
const { message, body } = await this.parseErrorBody(res, `API error: ${res.status} ${res.statusText}`);
const logLevel = res.status === 404 ? "warn" : "error";
this.logger[logLevel](`${res.status} ${path}`, { rid, duration: `${Date.now() - start}ms`, error: message });
throw new ApiError(message, res.status, res.statusText);
throw new ApiError(message, res.status, res.statusText, body);
}
this.logger.info(`${res.status} ${path}`, { rid, duration: `${Date.now() - start}ms` });
@@ -394,6 +417,13 @@ export class ApiClient {
});
}
async quickCreateIssue(data: { agent_id: string; prompt: string }): Promise<{ task_id: string }> {
return this.fetch("/api/issues/quick-create", {
method: "POST",
body: JSON.stringify(data),
});
}
async createFeedback(data: {
message: string;
url?: string;
@@ -978,6 +1008,50 @@ export class ApiClient {
await this.fetch(`/api/projects/${id}`, { method: "DELETE" });
}
// Labels
async listLabels(): Promise<ListLabelsResponse> {
return this.fetch(`/api/labels`);
}
async getLabel(id: string): Promise<Label> {
return this.fetch(`/api/labels/${id}`);
}
async createLabel(data: CreateLabelRequest): Promise<Label> {
return this.fetch(`/api/labels`, {
method: "POST",
body: JSON.stringify(data),
});
}
async updateLabel(id: string, data: UpdateLabelRequest): Promise<Label> {
return this.fetch(`/api/labels/${id}`, {
method: "PUT",
body: JSON.stringify(data),
});
}
async deleteLabel(id: string): Promise<void> {
await this.fetch(`/api/labels/${id}`, { method: "DELETE" });
}
async listLabelsForIssue(issueId: string): Promise<IssueLabelsResponse> {
return this.fetch(`/api/issues/${issueId}/labels`);
}
async attachLabel(issueId: string, labelId: string): Promise<IssueLabelsResponse> {
return this.fetch(`/api/issues/${issueId}/labels`, {
method: "POST",
body: JSON.stringify({ label_id: labelId }),
});
}
async detachLabel(issueId: string, labelId: string): Promise<IssueLabelsResponse> {
return this.fetch(`/api/issues/${issueId}/labels/${labelId}`, {
method: "DELETE",
});
}
// Pins
async listPins(): Promise<PinnedItem[]> {
return this.fetch("/api/pins");

View File

@@ -0,0 +1,60 @@
import { beforeEach, describe, expect, it } from "vitest";
import { useIssueDraftStore } from "./draft-store";
const RESET_STATE = {
draft: {
title: "",
description: "",
status: "todo" as const,
priority: "none" as const,
assigneeType: undefined,
assigneeId: undefined,
dueDate: null,
},
lastAssigneeType: undefined,
lastAssigneeId: undefined,
};
describe("issue draft store — last assignee", () => {
beforeEach(() => {
useIssueDraftStore.setState(RESET_STATE);
});
it("clearDraft prefills the next draft with the remembered assignee", () => {
const { setDraft, setLastAssignee, clearDraft } =
useIssueDraftStore.getState();
setDraft({ title: "first", assigneeType: "member", assigneeId: "alice" });
setLastAssignee("member", "alice");
clearDraft();
const { draft } = useIssueDraftStore.getState();
expect(draft.title).toBe("");
expect(draft.assigneeType).toBe("member");
expect(draft.assigneeId).toBe("alice");
});
it("clearDraft yields an empty assignee when none has ever been remembered", () => {
const { setDraft, clearDraft } = useIssueDraftStore.getState();
setDraft({ title: "first" });
clearDraft();
const { draft } = useIssueDraftStore.getState();
expect(draft.assigneeType).toBeUndefined();
expect(draft.assigneeId).toBeUndefined();
});
it("setLastAssignee(undefined) lets the user opt back out of a default", () => {
const { setLastAssignee, clearDraft } = useIssueDraftStore.getState();
setLastAssignee("member", "alice");
clearDraft();
expect(useIssueDraftStore.getState().draft.assigneeId).toBe("alice");
setLastAssignee(undefined, undefined);
clearDraft();
expect(useIssueDraftStore.getState().draft.assigneeId).toBeUndefined();
expect(useIssueDraftStore.getState().draft.assigneeType).toBeUndefined();
});
});

View File

@@ -26,8 +26,14 @@ const EMPTY_DRAFT: IssueDraft = {
interface IssueDraftStore {
draft: IssueDraft;
// Last assignee picked at submit time. Persisted across drafts so the
// create-issue modal can prefill the picker with the user's most recent
// choice instead of always opening with no assignee.
lastAssigneeType?: IssueAssigneeType;
lastAssigneeId?: string;
setDraft: (patch: Partial<IssueDraft>) => void;
clearDraft: () => void;
setLastAssignee: (type?: IssueAssigneeType, id?: string) => void;
hasDraft: () => boolean;
}
@@ -35,9 +41,20 @@ export const useIssueDraftStore = create<IssueDraftStore>()(
persist(
(set, get) => ({
draft: { ...EMPTY_DRAFT },
lastAssigneeType: undefined,
lastAssigneeId: undefined,
setDraft: (patch) =>
set((s) => ({ draft: { ...s.draft, ...patch } })),
clearDraft: () => set({ draft: { ...EMPTY_DRAFT } }),
clearDraft: () =>
set((s) => ({
draft: {
...EMPTY_DRAFT,
assigneeType: s.lastAssigneeType,
assigneeId: s.lastAssigneeId,
},
})),
setLastAssignee: (type, id) =>
set({ lastAssigneeType: type, lastAssigneeId: id }),
hasDraft: () => {
const { draft } = get();
return !!(draft.title || draft.description);

View File

@@ -6,6 +6,7 @@ import {
type IssueViewState,
viewStoreSlice,
viewStorePersistOptions,
mergeViewStatePersisted,
} from "./view-store";
import { registerForWorkspaceRehydration } from "../../platform/workspace-storage";
@@ -32,6 +33,11 @@ const _myIssuesViewStore = createStore<MyIssuesViewState>()(
...basePersist.partialize(state),
scope: state.scope,
}),
// Reuse the same deep-merge as the base view store so newly added
// cardProperties toggles inherit defaults for existing users. Without
// this, the my-issues page renders no labels because the persisted
// snapshot predates the `labels` key and shallow-merge wins.
merge: mergeViewStatePersisted<MyIssuesViewState>,
},
),
);

View File

@@ -0,0 +1,33 @@
"use client";
import { create } from "zustand";
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 the user picked in the Quick Create
// modal. Defaulted to that agent on next open so frequent users skip the
// picker entirely. 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.
interface QuickCreateState {
lastAgentId: string | null;
setLastAgentId: (id: string | null) => void;
}
export const useQuickCreateStore = create<QuickCreateState>()(
persist(
(set) => ({
lastAgentId: null,
setLastAgentId: (id) => set({ lastAgentId: id }),
}),
{
name: "multica_quick_create",
storage: createJSONStorage(() => createWorkspaceAwareStorage(defaultStorage)),
},
),
);
registerForWorkspaceRehydration(() => useQuickCreateStore.persist.rehydrate());

View File

@@ -20,6 +20,7 @@ export interface CardProperties {
dueDate: boolean;
project: boolean;
childProgress: boolean;
labels: boolean;
}
export interface ActorFilterValue {
@@ -41,6 +42,7 @@ export const CARD_PROPERTY_OPTIONS: { key: keyof CardProperties; label: string }
{ key: "assignee", label: "Assignee" },
{ key: "dueDate", label: "Due date" },
{ key: "project", label: "Project" },
{ key: "labels", label: "Labels" },
{ key: "childProgress", label: "Sub-issue progress" },
];
@@ -92,6 +94,7 @@ export const viewStoreSlice = (set: StoreApi<IssueViewState>["setState"]): Issue
dueDate: true,
project: true,
childProgress: true,
labels: true,
},
listCollapsedStatuses: [],
@@ -204,8 +207,34 @@ export const viewStorePersistOptions = (name: string) => ({
cardProperties: state.cardProperties,
listCollapsedStatuses: state.listCollapsedStatuses,
}),
// Default Zustand merge is shallow, so a persisted `cardProperties` snapshot
// saved before a new toggle was introduced wins entirely and the new key is
// missing — the dropdown switch then reads `undefined` and renders unchecked
// even though defaults treat it as on. Deep-merge `cardProperties` so newly
// added toggles inherit their default value for existing users.
merge: mergeViewStatePersisted,
});
/**
* Reusable persist `merge` for view-state stores. Generic over T so the same
* deep-merge for `cardProperties` works for both the issues view store and
* the my-issues view store (which extends IssueViewState).
*/
export function mergeViewStatePersisted<T extends IssueViewState>(
persisted: unknown,
current: T,
): T {
const p = (persisted ?? {}) as Partial<T>;
return {
...current,
...p,
cardProperties: {
...current.cardProperties,
...(p.cardProperties ?? {}),
},
};
}
/** Factory: creates a vanilla StoreApi for use with React Context. */
export function createIssueViewStore(persistKey: string): StoreApi<IssueViewState> {
const store = createStore<IssueViewState>()(

View File

@@ -6,7 +6,7 @@ import {
patchIssueInBuckets,
removeIssueFromBuckets,
} from "./cache-helpers";
import type { Issue } from "../types";
import type { Issue, Label } from "../types";
import type { ListIssuesCache } from "../types";
export function onIssueCreated(
@@ -72,6 +72,26 @@ export function onIssueUpdated(
}
}
/**
* Patch an issue's `labels` field in-place across the list cache, my-issues
* caches, and the detail cache. Triggered by the `issue_labels:changed` WS
* event after attach/detach so list/board chips update without a refetch.
*/
export function onIssueLabelsChanged(
qc: QueryClient,
wsId: string,
issueId: string,
labels: Label[],
) {
qc.setQueryData<ListIssuesCache>(issueKeys.list(wsId), (old) =>
old ? patchIssueInBuckets(old, issueId, { labels }) : old,
);
qc.setQueryData<Issue>(issueKeys.detail(wsId, issueId), (old) =>
old ? { ...old, labels } : old,
);
qc.invalidateQueries({ queryKey: issueKeys.myAll(wsId) });
}
export function onIssueDeleted(
qc: QueryClient,
wsId: string,

View File

@@ -0,0 +1,8 @@
export { labelKeys, labelListOptions, issueLabelsOptions } from "./queries";
export {
useCreateLabel,
useUpdateLabel,
useDeleteLabel,
useAttachLabel,
useDetachLabel,
} from "./mutations";

View File

@@ -0,0 +1,171 @@
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { api } from "../api";
import { labelKeys } from "./queries";
import { useWorkspaceId } from "../hooks";
import { issueKeys } from "../issues/queries";
import { onIssueLabelsChanged } from "../issues/ws-updaters";
import type {
Label,
CreateLabelRequest,
UpdateLabelRequest,
ListLabelsResponse,
IssueLabelsResponse,
} from "../types";
export function useCreateLabel() {
const qc = useQueryClient();
const wsId = useWorkspaceId();
return useMutation({
mutationFn: (data: CreateLabelRequest) => api.createLabel(data),
onSuccess: (label) => {
qc.setQueryData<ListLabelsResponse>(labelKeys.list(wsId), (old) =>
old && !old.labels.some((l) => l.id === label.id)
? { ...old, labels: [...old.labels, label], total: old.total + 1 }
: old,
);
},
onSettled: () => {
qc.invalidateQueries({ queryKey: labelKeys.list(wsId) });
},
});
}
/**
* Optimistic rename/recolor. Matches the useUpdateProject pattern: apply the
* change locally, snapshot for rollback, invalidate on settle. Without this
* the UI freezes for the round-trip on every edit.
*/
export function useUpdateLabel() {
const qc = useQueryClient();
const wsId = useWorkspaceId();
return useMutation({
mutationFn: ({ id, ...data }: { id: string } & UpdateLabelRequest) =>
api.updateLabel(id, data),
onMutate: async ({ id, ...data }) => {
await qc.cancelQueries({ queryKey: labelKeys.list(wsId) });
const prevList = qc.getQueryData<ListLabelsResponse>(labelKeys.list(wsId));
qc.setQueryData<ListLabelsResponse>(labelKeys.list(wsId), (old) =>
old
? {
...old,
labels: old.labels.map((l) => (l.id === id ? { ...l, ...data } : l)),
}
: old,
);
return { prevList, id };
},
onError: (_err, _vars, ctx) => {
if (ctx?.prevList) qc.setQueryData(labelKeys.list(wsId), ctx.prevList);
},
onSettled: () => {
// Invalidate the entire labels scope so any byIssue cache holding a
// stale copy of this label is refetched. The list cache is the source
// of truth; byIssue views will re-render with the fresh data.
qc.invalidateQueries({ queryKey: labelKeys.all(wsId) });
// Issues now embed labels (denormalized snapshot), so a rename/recolor
// also has to refresh the issues caches that hold those snapshots.
qc.invalidateQueries({ queryKey: issueKeys.all(wsId) });
},
});
}
export function useDeleteLabel() {
const qc = useQueryClient();
const wsId = useWorkspaceId();
return useMutation({
mutationFn: (id: string) => api.deleteLabel(id),
onMutate: async (id) => {
await qc.cancelQueries({ queryKey: labelKeys.list(wsId) });
const prev = qc.getQueryData<ListLabelsResponse>(labelKeys.list(wsId));
qc.setQueryData<ListLabelsResponse>(labelKeys.list(wsId), (old) =>
old
? { ...old, labels: old.labels.filter((l) => l.id !== id), total: old.total - 1 }
: old,
);
return { prev };
},
onError: (_err, _id, ctx) => {
if (ctx?.prev) qc.setQueryData(labelKeys.list(wsId), ctx.prev);
},
onSettled: () => {
qc.invalidateQueries({ queryKey: labelKeys.all(wsId) });
// A deleted label still lives in cached issue.labels arrays until we
// refetch — invalidate so list/board chips drop the orphan.
qc.invalidateQueries({ queryKey: issueKeys.all(wsId) });
},
});
}
export function useAttachLabel(issueId: string) {
const qc = useQueryClient();
const wsId = useWorkspaceId();
return useMutation({
mutationFn: (labelId: string) => api.attachLabel(issueId, labelId),
onMutate: async (labelId) => {
await qc.cancelQueries({ queryKey: labelKeys.byIssue(wsId, issueId) });
const prev = qc.getQueryData<IssueLabelsResponse>(labelKeys.byIssue(wsId, issueId));
// Only patch when we already know the current label set — otherwise
// appending `[label]` to an empty array would wipe denormalized
// labels in issue list/detail caches and rollback couldn't restore
// them. If byIssue isn't cached yet (user clicked before the picker
// fetched), skip the optimistic patch and rely on onSettled refetch.
if (!prev) return { prev };
if (prev.labels.some((l) => l.id === labelId)) return { prev };
const list = qc.getQueryData<ListLabelsResponse>(labelKeys.list(wsId));
const label = list?.labels.find((l) => l.id === labelId);
if (!label) return { prev };
const next: IssueLabelsResponse = { ...prev, labels: [...prev.labels, label] };
qc.setQueryData<IssueLabelsResponse>(labelKeys.byIssue(wsId, issueId), next);
onIssueLabelsChanged(qc, wsId, issueId, next.labels);
return { prev };
},
onError: (_err, _id, ctx) => {
if (ctx?.prev) {
qc.setQueryData(labelKeys.byIssue(wsId, issueId), ctx.prev);
onIssueLabelsChanged(qc, wsId, issueId, ctx.prev.labels);
}
},
onSuccess: (data: IssueLabelsResponse) => {
// Backend may return an empty object when the post-mutation read fails
// (it logs a warning and skips the broadcast). Only apply the list
// when the backend gave us one — otherwise the optimistic patch from
// onMutate stands until onSettled's invalidation refetches.
if (data && Array.isArray(data.labels)) {
qc.setQueryData<IssueLabelsResponse>(labelKeys.byIssue(wsId, issueId), data);
onIssueLabelsChanged(qc, wsId, issueId, data.labels);
}
},
onSettled: () => {
qc.invalidateQueries({ queryKey: labelKeys.byIssue(wsId, issueId) });
},
});
}
export function useDetachLabel(issueId: string) {
const qc = useQueryClient();
const wsId = useWorkspaceId();
return useMutation({
mutationFn: (labelId: string) => api.detachLabel(issueId, labelId),
onMutate: async (labelId) => {
await qc.cancelQueries({ queryKey: labelKeys.byIssue(wsId, issueId) });
const prev = qc.getQueryData<IssueLabelsResponse>(labelKeys.byIssue(wsId, issueId));
const next = prev
? { ...prev, labels: prev.labels.filter((l: Label) => l.id !== labelId) }
: undefined;
if (next) {
qc.setQueryData<IssueLabelsResponse>(labelKeys.byIssue(wsId, issueId), next);
onIssueLabelsChanged(qc, wsId, issueId, next.labels);
}
return { prev };
},
onError: (_err, _id, ctx) => {
if (ctx?.prev) {
qc.setQueryData(labelKeys.byIssue(wsId, issueId), ctx.prev);
onIssueLabelsChanged(qc, wsId, issueId, ctx.prev.labels);
}
},
onSettled: () => {
qc.invalidateQueries({ queryKey: labelKeys.byIssue(wsId, issueId) });
},
});
}

View File

@@ -0,0 +1,28 @@
import { queryOptions } from "@tanstack/react-query";
import { api } from "../api";
export const labelKeys = {
all: (wsId: string) => ["labels", wsId] as const,
list: (wsId: string) => [...labelKeys.all(wsId), "list"] as const,
detail: (wsId: string, id: string) =>
[...labelKeys.all(wsId), "detail", id] as const,
byIssue: (wsId: string, issueId: string) =>
[...labelKeys.all(wsId), "issue", issueId] as const,
};
export function labelListOptions(wsId: string) {
return queryOptions({
queryKey: labelKeys.list(wsId),
queryFn: () => api.listLabels(),
select: (data) => data.labels,
});
}
export function issueLabelsOptions(wsId: string, issueId: string) {
return queryOptions({
queryKey: labelKeys.byIssue(wsId, issueId),
queryFn: () => api.listLabelsForIssue(issueId),
select: (data) => data.labels,
enabled: Boolean(issueId),
});
}

View File

@@ -2,7 +2,17 @@
import { create } from "zustand";
type ModalType = "create-workspace" | "create-issue" | "create-project" | "feedback" | null;
type ModalType =
| "create-workspace"
| "create-issue"
| "quick-create-issue"
| "create-project"
| "feedback"
| "issue-set-parent"
| "issue-add-child"
| "issue-delete-confirm"
| "issue-backlog-agent-hint"
| null;
interface ModalStore {
modal: ModalType;

View File

@@ -46,6 +46,9 @@
"./projects/queries": "./projects/queries.ts",
"./projects/mutations": "./projects/mutations.ts",
"./projects/config": "./projects/config.ts",
"./labels": "./labels/index.ts",
"./labels/queries": "./labels/queries.ts",
"./labels/mutations": "./labels/mutations.ts",
"./autopilots": "./autopilots/index.ts",
"./autopilots/queries": "./autopilots/queries.ts",
"./autopilots/mutations": "./autopilots/mutations.ts",

View File

@@ -85,10 +85,13 @@ export const RESERVED_SLUGS = new Set([
"tokens",
"cli",
// Backend ops / observability. `/health` and `/ws` exist on the backend
// Backend ops / observability. `/health`, `/readyz`, `/healthz`, and `/ws`
// exist on the backend
// host; reserving them on the workspace slug space prevents naming
// confusion if/when these paths are ever proxied through the web origin.
"health",
"readyz",
"healthz",
"ws",
"metrics",
"ping",

View File

@@ -18,6 +18,7 @@ import {
onIssueCreated,
onIssueUpdated,
onIssueDeleted,
onIssueLabelsChanged,
} from "../issues/ws-updaters";
import { onInboxNew, onInboxInvalidate, onInboxIssueStatusChanged, onInboxIssueDeleted } from "../inbox/ws-updaters";
import { inboxKeys } from "../inbox/queries";
@@ -31,6 +32,7 @@ import type {
IssueUpdatedPayload,
IssueCreatedPayload,
IssueDeletedPayload,
IssueLabelsChangedPayload,
InboxNewPayload,
CommentCreatedPayload,
CommentUpdatedPayload,
@@ -117,6 +119,17 @@ export function useRealtimeSync(
const wsId = getCurrentWsId();
if (wsId) qc.invalidateQueries({ queryKey: projectKeys.all(wsId) });
},
label: () => {
// label:created/updated/deleted — also refresh issues, since each
// issue carries a denormalized snapshot of its labels (rename/recolor
// /delete on a label needs to flush the chips on every issue showing
// it).
const wsId = getCurrentWsId();
if (wsId) {
qc.invalidateQueries({ queryKey: ["labels", wsId] });
qc.invalidateQueries({ queryKey: issueKeys.all(wsId) });
}
},
pin: () => {
const wsId = getCurrentWsId();
const userId = authStore.getState().user?.id;
@@ -147,7 +160,7 @@ export function useRealtimeSync(
// Event types handled by specific handlers below -- skip generic refresh
const specificEvents = new Set([
"issue:updated", "issue:created", "issue:deleted", "inbox:new",
"issue:updated", "issue:created", "issue:deleted", "issue_labels:changed", "inbox:new",
"comment:created", "comment:updated", "comment:deleted",
"activity:created",
"reaction:added", "reaction:removed",
@@ -200,6 +213,13 @@ export function useRealtimeSync(
}
});
const unsubIssueLabelsChanged = ws.on("issue_labels:changed", (p) => {
const { issue_id, labels } = p as IssueLabelsChangedPayload;
if (!issue_id) return;
const wsId = getCurrentWsId();
if (wsId) onIssueLabelsChanged(qc, wsId, issue_id, labels ?? []);
});
const unsubInboxNew = ws.on("inbox:new", (p) => {
const { item } = p as InboxNewPayload;
if (!item) return;
@@ -464,6 +484,7 @@ export function useRealtimeSync(
unsubIssueUpdated();
unsubIssueCreated();
unsubIssueDeleted();
unsubIssueLabelsChanged();
unsubInboxNew();
unsubCommentCreated();
unsubCommentUpdated();

View File

@@ -11,11 +11,9 @@ export type AutopilotRunSource = "schedule" | "manual" | "webhook" | "api";
export interface Autopilot {
id: string;
workspace_id: string;
project_id: string | null;
title: string;
description: string | null;
assignee_id: string;
priority: string;
status: AutopilotStatus;
execution_mode: AutopilotExecutionMode;
issue_title_template: string | null;
@@ -61,8 +59,6 @@ export interface CreateAutopilotRequest {
title: string;
description?: string;
assignee_id: string;
project_id?: string;
priority?: string;
execution_mode: AutopilotExecutionMode;
issue_title_template?: string;
}
@@ -71,8 +67,6 @@ export interface UpdateAutopilotRequest {
title?: string;
description?: string | null;
assignee_id?: string;
project_id?: string | null;
priority?: string;
status?: AutopilotStatus;
execution_mode?: AutopilotExecutionMode;
issue_title_template?: string | null;

View File

@@ -5,6 +5,7 @@ import type { Comment, Reaction } from "./comment";
import type { TimelineEntry } from "./activity";
import type { Workspace, MemberWithUser, Invitation } from "./workspace";
import type { Project } from "./project";
import type { Label } from "./label";
// WebSocket event types (matching Go server protocol/events.go)
export type WSEventType =
@@ -52,6 +53,10 @@ export type WSEventType =
| "project:created"
| "project:updated"
| "project:deleted"
| "label:created"
| "label:updated"
| "label:deleted"
| "issue_labels:changed"
| "pin:created"
| "pin:deleted"
| "pin:reordered"
@@ -78,6 +83,11 @@ export interface IssueDeletedPayload {
issue_id: string;
}
export interface IssueLabelsChangedPayload {
issue_id: string;
labels: Label[];
}
export interface AgentStatusPayload {
agent: Agent;
}

View File

@@ -16,7 +16,9 @@ export type InboxItemType =
| "task_failed"
| "agent_blocked"
| "agent_completed"
| "reaction_added";
| "reaction_added"
| "quick_create_done"
| "quick_create_failed";
export interface InboxItem {
id: string;

View File

@@ -34,6 +34,7 @@ export type {
export type { Workspace, WorkspaceRepo, Member, MemberRole, User, MemberWithUser, Invitation } from "./workspace";
export type { InboxItem, InboxSeverity, InboxItemType } from "./inbox";
export type { Comment, CommentType, CommentAuthorType, Reaction } from "./comment";
export type { Label, CreateLabelRequest, UpdateLabelRequest, ListLabelsResponse, IssueLabelsResponse } from "./label";
export type { TimelineEntry, AssigneeFrequencyEntry } from "./activity";
export type { IssueSubscriber } from "./subscriber";
export type * from "./events";

View File

@@ -1,3 +1,5 @@
import type { Label } from "./label";
export type IssueStatus =
| "backlog"
| "todo"
@@ -38,6 +40,7 @@ export interface Issue {
position: number;
due_date: string | null;
reactions?: IssueReaction[];
labels?: Label[];
created_at: string;
updated_at: string;
}

View File

@@ -0,0 +1,35 @@
/**
* Issue labels — workspace-scoped, applied as many-to-many to issues.
*
* Labels are lightweight metadata (name + color) distinct from projects:
* projects group related work, labels are cross-cutting tags (bug, feature,
* performance, …). Colors are normalized to lowercase `#RRGGBB`.
*/
export interface Label {
id: string;
workspace_id: string;
name: string;
/** Normalized lowercase hex color, e.g. `#3b82f6`. */
color: string;
created_at: string;
updated_at: string;
}
export interface CreateLabelRequest {
name: string;
color: string;
}
export interface UpdateLabelRequest {
name?: string;
color?: string;
}
export interface ListLabelsResponse {
labels: Label[];
total: number;
}
export interface IssueLabelsResponse {
labels: Label[];
}

View File

@@ -156,7 +156,7 @@ function CommandItem({
<CommandPrimitive.Item
data-slot="command-item"
className={cn(
"group/command-item relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none in-data-[slot=dialog-content]:rounded-lg! data-[disabled=true]:pointer-events-none data-[disabled=true]:opacity-50 data-selected:bg-muted data-selected:text-foreground [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 data-selected:*:[svg]:text-foreground",
"group/command-item relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none in-data-[slot=dialog-content]:rounded-lg! data-[disabled=true]:pointer-events-none data-[disabled=true]:opacity-50 data-selected:bg-muted data-selected:text-foreground [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}

View File

@@ -101,7 +101,7 @@ function ContextMenuItem({
data-inset={inset}
data-variant={variant}
className={cn(
"group/context-menu-item relative flex cursor-default items-center gap-1.5 rounded-md px-1.5 py-1 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground data-inset:pl-7 data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 data-[variant=destructive]:focus:text-destructive dark:data-[variant=destructive]:focus:bg-destructive/20 data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 focus:*:[svg]:text-accent-foreground data-[variant=destructive]:*:[svg]:text-destructive",
"group/context-menu-item relative flex cursor-default items-center gap-1.5 rounded-md px-1.5 py-1 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground data-inset:pl-7 data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 data-[variant=destructive]:focus:text-destructive dark:data-[variant=destructive]:focus:bg-destructive/20 data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}

View File

@@ -88,7 +88,7 @@ function DropdownMenuItem({
data-inset={inset}
data-variant={variant}
className={cn(
"group/dropdown-menu-item relative flex cursor-default items-center gap-1.5 rounded-md px-1.5 py-1 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground not-data-[variant=destructive]:focus:**:text-accent-foreground data-inset:pl-7 data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 data-[variant=destructive]:focus:text-destructive dark:data-[variant=destructive]:focus:bg-destructive/20 data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 data-[variant=destructive]:*:[svg]:text-destructive",
"group/dropdown-menu-item relative flex cursor-default items-center gap-1.5 rounded-md px-1.5 py-1 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground data-inset:pl-7 data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 data-[variant=destructive]:focus:text-destructive dark:data-[variant=destructive]:focus:bg-destructive/20 data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
@@ -113,7 +113,7 @@ function DropdownMenuSubTrigger({
data-slot="dropdown-menu-sub-trigger"
data-inset={inset}
className={cn(
"flex cursor-default items-center gap-1.5 rounded-md px-1.5 py-1 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground not-data-[variant=destructive]:focus:**:text-accent-foreground data-inset:pl-7 data-popup-open:bg-accent data-popup-open:text-accent-foreground data-open:bg-accent data-open:text-accent-foreground [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
"flex cursor-default items-center gap-1.5 rounded-md px-1.5 py-1 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground data-inset:pl-7 data-popup-open:bg-accent data-popup-open:text-accent-foreground data-open:bg-accent data-open:text-accent-foreground [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
@@ -201,7 +201,7 @@ function DropdownMenuRadioItem({
data-slot="dropdown-menu-radio-item"
data-inset={inset}
className={cn(
"relative flex cursor-default items-center gap-1.5 rounded-md py-1 pr-8 pl-1.5 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground focus:**:text-accent-foreground data-inset:pl-7 data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
"relative flex cursor-default items-center gap-1.5 rounded-md py-1 pr-8 pl-1.5 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground data-inset:pl-7 data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}

View File

@@ -98,7 +98,7 @@ function MenubarItem({
data-inset={inset}
data-variant={variant}
className={cn(
"group/menubar-item gap-1.5 rounded-md px-1.5 py-1 text-sm focus:bg-accent focus:text-accent-foreground not-data-[variant=destructive]:focus:**:text-accent-foreground data-inset:pl-7 data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 data-[variant=destructive]:focus:text-destructive dark:data-[variant=destructive]:focus:bg-destructive/20 data-disabled:opacity-50 [&_svg:not([class*='size-'])]:size-4 data-[variant=destructive]:*:[svg]:text-destructive!",
"group/menubar-item gap-1.5 rounded-md px-1.5 py-1 text-sm focus:bg-accent focus:text-accent-foreground data-inset:pl-7 data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 data-[variant=destructive]:focus:text-destructive dark:data-[variant=destructive]:focus:bg-destructive/20 data-disabled:opacity-50 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
@@ -120,7 +120,7 @@ function MenubarCheckboxItem({
data-slot="menubar-checkbox-item"
data-inset={inset}
className={cn(
"relative flex cursor-default items-center gap-1.5 rounded-md py-1 pr-1.5 pl-7 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground focus:**:text-accent-foreground data-inset:pl-7 data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0",
"relative flex cursor-default items-center gap-1.5 rounded-md py-1 pr-1.5 pl-7 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground data-inset:pl-7 data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0",
className
)}
checked={checked}
@@ -156,7 +156,7 @@ function MenubarRadioItem({
data-slot="menubar-radio-item"
data-inset={inset}
className={cn(
"relative flex cursor-default items-center gap-1.5 rounded-md py-1 pr-1.5 pl-7 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground focus:**:text-accent-foreground data-inset:pl-7 data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
"relative flex cursor-default items-center gap-1.5 rounded-md py-1 pr-1.5 pl-7 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground data-inset:pl-7 data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}

View File

@@ -117,7 +117,7 @@ function SelectItem({
<SelectPrimitive.Item
data-slot="select-item"
className={cn(
"relative flex w-full cursor-default items-center gap-1.5 rounded-md py-1 pr-8 pl-1.5 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground not-data-[variant=destructive]:focus:**:text-accent-foreground data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 *:[span]:last:flex *:[span]:last:items-center *:[span]:last:gap-2",
"relative flex w-full cursor-default items-center gap-1.5 rounded-md py-1 pr-8 pl-1.5 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 *:[span]:last:flex *:[span]:last:items-center *:[span]:last:gap-2",
className
)}
{...props}

View File

@@ -527,7 +527,7 @@ const sidebarMenuButtonVariants = cva(
variant: {
default: "hover:bg-sidebar-accent hover:text-sidebar-accent-foreground",
outline:
"bg-background shadow-[0_0_0_1px_hsl(var(--sidebar-border))] hover:bg-sidebar-accent hover:text-sidebar-accent-foreground hover:shadow-[0_0_0_1px_hsl(var(--sidebar-accent))]",
"bg-background shadow-[0_0_0_1px_var(--color-sidebar-border)] hover:bg-sidebar-accent hover:text-sidebar-accent-foreground hover:shadow-[0_0_0_1px_var(--color-sidebar-accent)]",
},
size: {
default: "h-8 text-sm",

View File

@@ -0,0 +1,219 @@
"use client";
import * as React from "react";
import { Clock } from "lucide-react";
import { cn } from "@multica/ui/lib/utils";
// Adapted from openstatusHQ/time-picker (MIT).
// Segmented HH:MM input with keyboard arrow increment / digit typing.
type Segment = "hours" | "minutes";
function getValidNumber(
raw: string,
{ max, min = 0, loop = false }: { max: number; min?: number; loop?: boolean },
): string {
let n = parseInt(raw, 10);
if (isNaN(n)) return "00";
if (!loop) {
if (n > max) n = max;
if (n < min) n = min;
} else {
if (n > max) n = min;
if (n < min) n = max;
}
return n.toString().padStart(2, "0");
}
function arrowValue(current: string, step: number, seg: Segment): string {
const max = seg === "hours" ? 23 : 59;
const n = parseInt(current, 10);
if (isNaN(n)) return "00";
return getValidNumber(String(n + step), { max, min: 0, loop: true });
}
function splitTime(value: string): { hh: string; mm: string } {
const [rawH, rawM] = (value || "").split(":");
const hh = getValidNumber(rawH ?? "0", { max: 23 });
const mm = getValidNumber(rawM ?? "0", { max: 59 });
return { hh, mm };
}
interface SegmentInputProps {
seg: Segment;
value: string;
onValueChange: (next: string) => void;
onLeftFocus?: () => void;
onRightFocus?: () => void;
disabled?: boolean;
ariaLabel: string;
}
const SegmentInput = React.forwardRef<HTMLInputElement, SegmentInputProps>(
function SegmentInput(
{ seg, value, onValueChange, onLeftFocus, onRightFocus, disabled, ariaLabel },
ref,
) {
// Two-digit typing window: first digit pads with leading 0; second digit within
// 2s replaces the leading 0, clamped to segment max. After 2s, reset.
const [pendingSecond, setPendingSecond] = React.useState(false);
React.useEffect(() => {
if (!pendingSecond) return;
const t = setTimeout(() => setPendingSecond(false), 2000);
return () => clearTimeout(t);
}, [pendingSecond]);
const handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
if (e.key === "Tab") return;
if (e.key === "ArrowRight") {
e.preventDefault();
onRightFocus?.();
return;
}
if (e.key === "ArrowLeft") {
e.preventDefault();
onLeftFocus?.();
return;
}
if (e.key === "ArrowUp" || e.key === "ArrowDown") {
e.preventDefault();
const step = e.key === "ArrowUp" ? 1 : -1;
onValueChange(arrowValue(value, step, seg));
setPendingSecond(false);
return;
}
if (e.key >= "0" && e.key <= "9") {
e.preventDefault();
const next = pendingSecond
? getValidNumber(value.slice(1) + e.key, {
max: seg === "hours" ? 23 : 59,
})
: "0" + e.key;
onValueChange(next);
if (pendingSecond) {
setPendingSecond(false);
onRightFocus?.();
} else {
setPendingSecond(true);
}
return;
}
if (e.key === "Backspace" || e.key === "Delete") {
e.preventDefault();
onValueChange("00");
setPendingSecond(false);
}
};
return (
<input
ref={ref}
type="text"
inputMode="numeric"
maxLength={2}
value={value}
disabled={disabled}
aria-label={ariaLabel}
onChange={() => {
// Fully controlled by keydown; ignore native onChange.
}}
onKeyDown={handleKeyDown}
onFocus={(e) => e.currentTarget.select()}
className="w-7 bg-transparent text-center text-sm tabular-nums outline-none caret-transparent focus:text-foreground"
/>
);
},
);
export interface TimeInputProps {
value: string;
onChange: (value: string) => void;
disabled?: boolean;
className?: string;
showIcon?: boolean;
/** Render only the minute segment with an "At :" prefix. Used for hourly schedules. */
minuteOnly?: boolean;
}
export function TimeInput({
value,
onChange,
disabled,
className,
showIcon = true,
minuteOnly = false,
}: TimeInputProps) {
const { hh, mm } = splitTime(value);
const hourRef = React.useRef<HTMLInputElement>(null);
const minuteRef = React.useRef<HTMLInputElement>(null);
const setHour = (next: string) => onChange(`${next}:${mm}`);
const setMinute = (next: string) =>
onChange(`${minuteOnly ? "00" : hh}:${next}`);
return (
<div
data-slot="time-input"
onClick={(e) => {
if (e.target === e.currentTarget) {
(minuteOnly ? minuteRef : hourRef).current?.focus();
}
}}
className={cn(
"flex h-8 items-center gap-1 rounded-lg border border-input bg-transparent px-2.5 text-sm transition-colors",
"focus-within:border-ring focus-within:ring-3 focus-within:ring-ring/50",
"dark:bg-input/30",
disabled && "pointer-events-none cursor-not-allowed opacity-50",
className,
)}
>
{minuteOnly ? (
<>
{showIcon && (
<Clock className="pointer-events-none size-3.5 shrink-0 text-muted-foreground" />
)}
<span className="pointer-events-none select-none text-muted-foreground">
at&nbsp;:
</span>
<SegmentInput
ref={minuteRef}
seg="minutes"
value={mm}
onValueChange={setMinute}
disabled={disabled}
ariaLabel="Minute"
/>
</>
) : (
<>
{showIcon && (
<Clock className="pointer-events-none size-3.5 shrink-0 text-muted-foreground" />
)}
<SegmentInput
ref={hourRef}
seg="hours"
value={hh}
onValueChange={setHour}
onRightFocus={() => minuteRef.current?.focus()}
disabled={disabled}
ariaLabel="Hour"
/>
<span className="pointer-events-none select-none text-muted-foreground">
:
</span>
<SegmentInput
ref={minuteRef}
seg="minutes"
value={mm}
onValueChange={setMinute}
onLeftFocus={() => hourRef.current?.focus()}
disabled={disabled}
ariaLabel="Minute"
/>
</>
)}
</div>
);
}

View File

@@ -3,6 +3,7 @@ import ReactMarkdown, { type Components, defaultUrlTransform } from 'react-markd
import rehypeKatex from 'rehype-katex'
import rehypeRaw from 'rehype-raw'
import rehypeSanitize, { defaultSchema } from 'rehype-sanitize'
import remarkBreaks from 'remark-breaks'
import remarkGfm from 'remark-gfm'
import remarkMath from 'remark-math'
import { FileText, Download } from 'lucide-react'
@@ -404,7 +405,7 @@ export function Markdown({
return (
<div className={cn('markdown-content break-words', className)}>
<ReactMarkdown
remarkPlugins={[remarkMath, [remarkGfm, { singleTilde: false }]]}
remarkPlugins={[remarkMath, remarkBreaks, [remarkGfm, { singleTilde: false }]]}
rehypePlugins={[rehypeRaw, [rehypeSanitize, sanitizeSchema], rehypeKatex]}
urlTransform={urlTransform}
components={components}

View File

@@ -15,6 +15,15 @@ const linkify = new LinkifyIt()
const FILE_PATH_REGEX =
/(?:^|[\s([{<])((\/|~\/|\.\/)[\w\-./@]+\.(?:ts|tsx|js|jsx|mjs|cjs|md|json|yaml|yml|py|go|rs|css|scss|less|html|htm|txt|log|sh|bash|zsh|swift|kt|java|c|cpp|h|hpp|rb|php|xml|toml|ini|cfg|conf|env|sql|graphql|vue|svelte|astro|prisma|dockerfile|makefile|gitignore))(?=[\s)\]}.,;:!?>]|$)/gi
// CJK full-width punctuation that should terminate a URL.
// linkify-it only treats ASCII punctuation as URL boundaries, so in Chinese /
// Japanese text a URL followed by e.g. "。" gets the punctuation and every
// character up to the next whitespace swallowed into the href. We truncate the
// detected URL at the first occurrence of any of these characters. Character
// set mirrors the fix applied in mattermost/marked#22.
const CJK_URL_TERMINATOR_REGEX =
/[----~、。「-】]/
interface DetectedLink {
type: 'url' | 'email' | 'file'
text: string
@@ -84,6 +93,88 @@ function isInsideCode(pos: number, ranges: CodeRange[]): boolean {
return ranges.some((r) => pos >= r.start && pos < r.end)
}
function isEscaped(text: string, index: number): boolean {
let slashCount = 0
for (let i = index - 1; i >= 0 && text[i] === '\\'; i--) {
slashCount++
}
return slashCount % 2 === 1
}
function findMatchingBracket(text: string, openIndex: number): number {
let depth = 0
for (let i = openIndex; i < text.length; i++) {
if (isEscaped(text, i)) continue
const char = text[i]
if (char === '[') {
depth++
} else if (char === ']') {
depth--
if (depth === 0) return i
}
}
return -1
}
function findInlineLinkEnd(text: string, openParenIndex: number): number {
let depth = 0
for (let i = openParenIndex; i < text.length; i++) {
if (isEscaped(text, i)) continue
const char = text[i]
if (char === '(') {
depth++
} else if (char === ')') {
depth--
if (depth === 0) return i + 1
}
}
return -1
}
/**
* Find existing markdown link/image spans so auto-linkification does not create
* nested links inside their labels or destinations.
*/
function findMarkdownLinkRanges(text: string): CodeRange[] {
const ranges: CodeRange[] = []
for (let i = 0; i < text.length; i++) {
if (text[i] !== '[' || isEscaped(text, i)) continue
if (ranges.some((r) => i >= r.start && i < r.end)) continue
const labelEnd = findMatchingBracket(text, i)
if (labelEnd === -1) continue
const start = i > 0 && text[i - 1] === '!' && !isEscaped(text, i - 1) ? i - 1 : i
const nextChar = text[labelEnd + 1]
if (nextChar === '(') {
const end = findInlineLinkEnd(text, labelEnd + 1)
if (end !== -1) {
ranges.push({ start, end })
i = end - 1
}
continue
}
if (nextChar === '[') {
const referenceEnd = findMatchingBracket(text, labelEnd + 1)
if (referenceEnd !== -1) {
ranges.push({ start, end: referenceEnd + 1 })
i = referenceEnd
}
}
}
return ranges
}
/**
* Check if a link at given position is already a markdown link
* Looks for patterns like [text](url) or [text][ref]
@@ -116,23 +207,54 @@ function rangesOverlap(
return a.start < b.end && b.start < a.end
}
/**
* Run linkify-it on `text` and push normalized link records into `out`,
* shifted by `offset`. When linkify-it merges multiple URLs into one match
* because they are separated only by CJK punctuation (which it doesn't treat
* as a URL boundary), we truncate at that punctuation and re-scan the tail.
*/
function collectLinkifyMatches(text: string, offset: number, out: DetectedLink[]): void {
const matches = linkify.match(text)
if (!matches) return
for (const match of matches) {
const cjkIdx = match.text.search(CJK_URL_TERMINATOR_REGEX)
if (cjkIdx === 0) continue // match starts with CJK punct — skip
const truncate = cjkIdx > 0
const matchText = truncate ? match.text.slice(0, cjkIdx) : match.text
// linkify-it may prepend a scheme (e.g. "http://" or "mailto:") to url
// while leaving text as the raw substring. Preserve that prefix.
const schemePrefix = match.url.slice(0, match.url.length - match.text.length)
const matchUrl = truncate ? schemePrefix + matchText : match.url
const matchEnd = truncate ? match.index + cjkIdx : match.lastIndex
out.push({
type: match.schema === 'mailto:' ? 'email' : 'url',
text: matchText,
url: matchUrl,
start: match.index + offset,
end: matchEnd + offset
})
if (truncate) {
// Rescan the tail after the CJK punct — linkify-it had greedily swallowed
// it, so any additional URLs after the punct were never emitted.
const tailStart = matchEnd + 1
collectLinkifyMatches(text.slice(tailStart), offset + tailStart, out)
return
}
}
}
/**
* Detect all links (URLs, emails, file paths) in text
*/
export function detectLinks(text: string): DetectedLink[] {
const links: DetectedLink[] = []
// 1. Detect URLs and emails with linkify-it
const urlMatches = linkify.match(text) || []
for (const match of urlMatches) {
links.push({
type: match.schema === 'mailto:' ? 'email' : 'url',
text: match.text,
url: match.url,
start: match.index,
end: match.lastIndex
})
}
// 1. Detect URLs and emails with linkify-it, applying CJK boundary handling.
collectLinkifyMatches(text, 0, links)
// 2. Detect file paths with custom regex
// Reset regex state
@@ -176,6 +298,7 @@ export function preprocessLinks(text: string): string {
}
const codeRanges = findCodeRanges(text)
const markdownLinkRanges = findMarkdownLinkRanges(text)
const links = detectLinks(text)
if (links.length === 0) return text
@@ -188,6 +311,9 @@ export function preprocessLinks(text: string): string {
// Skip if inside code block
if (isInsideCode(link.start, codeRanges)) continue
// Skip if this match is inside an existing markdown link or image.
if (markdownLinkRanges.some((range) => rangesOverlap(link, range))) continue
// Skip if already a markdown link
if (isAlreadyLinked(text, link.start, link.end)) continue

View File

@@ -39,6 +39,7 @@
"recharts": "3.8.0",
"rehype-katex": "catalog:",
"rehype-raw": "^7.0.0",
"remark-breaks": "^4.0.0",
"remark-gfm": "^4.0.1",
"remark-math": "catalog:",
"shiki": "^3.21.0",

View File

@@ -70,7 +70,7 @@
--border: oklch(0.92 0.004 286.32);
--input: oklch(0.92 0.004 286.32);
--ring: oklch(0.705 0.015 286.067);
--chart-1: oklch(0.871 0.006 286.286);
--chart-1: oklch(0.705 0.015 286.067);
--chart-2: oklch(0.552 0.016 285.938);
--chart-3: oklch(0.442 0.017 285.786);
--chart-4: oklch(0.37 0.013 285.805);

View File

@@ -27,6 +27,16 @@ import {
DialogContent,
DialogTitle,
} from "@multica/ui/components/ui/dialog";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@multica/ui/components/ui/alert-dialog";
import {
TriggerConfigSection,
getDefaultTriggerConfig,
@@ -91,6 +101,21 @@ function RunRow({ run }: { run: AutopilotRun }) {
function TriggerRow({ trigger, autopilotId }: { trigger: AutopilotTrigger; autopilotId: string }) {
const deleteTrigger = useDeleteAutopilotTrigger();
const [confirmOpen, setConfirmOpen] = useState(false);
const [deleting, setDeleting] = useState(false);
const handleDelete = async () => {
setDeleting(true);
try {
await deleteTrigger.mutateAsync({ autopilotId, triggerId: trigger.id });
toast.success("Trigger deleted");
setConfirmOpen(false);
} catch {
toast.error("Failed to delete trigger");
} finally {
setDeleting(false);
}
};
return (
<div className="flex items-center gap-3 rounded-md border px-3 py-2">
@@ -121,13 +146,30 @@ function TriggerRow({ trigger, autopilotId }: { trigger: AutopilotTrigger; autop
size="icon"
variant="ghost"
className="h-7 w-7 shrink-0"
onClick={() => {
deleteTrigger.mutate({ autopilotId, triggerId: trigger.id });
toast.success("Trigger deleted");
}}
onClick={() => setConfirmOpen(true)}
>
<Trash2 className="h-3.5 w-3.5 text-muted-foreground" />
</Button>
<AlertDialog open={confirmOpen} onOpenChange={(v) => { if (!v && !deleting) setConfirmOpen(false); }}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Delete trigger</AlertDialogTitle>
<AlertDialogDescription>
This trigger will be removed and the autopilot will stop firing on this schedule. This action cannot be undone.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel disabled={deleting}>Cancel</AlertDialogCancel>
<AlertDialogAction
onClick={handleDelete}
disabled={deleting}
className="bg-destructive text-white hover:bg-destructive/90"
>
{deleting ? "Deleting..." : "Delete"}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
);
}
@@ -211,6 +253,8 @@ export function AutopilotDetailPage({ autopilotId }: { autopilotId: string }) {
const [triggerDialogOpen, setTriggerDialogOpen] = useState(false);
const [editDialogOpen, setEditDialogOpen] = useState(false);
const [deleteConfirmOpen, setDeleteConfirmOpen] = useState(false);
const [deleting, setDeleting] = useState(false);
if (isLoading) {
return (
@@ -271,12 +315,14 @@ export function AutopilotDetailPage({ autopilotId }: { autopilotId: string }) {
};
const handleDelete = async () => {
setDeleting(true);
try {
await deleteAutopilot.mutateAsync(autopilotId);
toast.success("Autopilot deleted");
router.push(wsPaths.autopilots());
} catch {
toast.error("Failed to delete autopilot");
setDeleting(false);
}
};
@@ -338,11 +384,7 @@ export function AutopilotDetailPage({ autopilotId }: { autopilotId: string }) {
</div>
</div>
<div>
<label className="text-xs text-muted-foreground">Priority</label>
<div className="mt-1 capitalize">{autopilot.priority}</div>
</div>
<div>
<label className="text-xs text-muted-foreground">Execution Mode</label>
<label className="text-xs text-muted-foreground">Output Mode</label>
<div className="mt-1">
{autopilot.execution_mode === "create_issue" ? "Create Issue" : "Run Only"}
</div>
@@ -405,7 +447,7 @@ export function AutopilotDetailPage({ autopilotId }: { autopilotId: string }) {
{/* Danger zone */}
<section className="space-y-3 pt-4 border-t">
<h2 className="text-sm font-medium text-destructive uppercase tracking-wider">Danger Zone</h2>
<Button size="sm" variant="destructive" onClick={handleDelete}>
<Button size="sm" variant="destructive" onClick={() => setDeleteConfirmOpen(true)}>
<Trash2 className="h-3.5 w-3.5 mr-1" />
Delete autopilot
</Button>
@@ -428,12 +470,34 @@ export function AutopilotDetailPage({ autopilotId }: { autopilotId: string }) {
title: autopilot.title,
description: autopilot.description ?? "",
assignee_id: autopilot.assignee_id,
priority: autopilot.priority,
execution_mode: autopilot.execution_mode as AutopilotExecutionMode,
}}
triggers={triggers}
/>
)}
<AlertDialog
open={deleteConfirmOpen}
onOpenChange={(v) => { if (!v && !deleting) setDeleteConfirmOpen(false); }}
>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Delete autopilot</AlertDialogTitle>
<AlertDialogDescription>
This will permanently delete &ldquo;{autopilot.title}&rdquo;, along with its triggers and run history. This action cannot be undone.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel disabled={deleting}>Cancel</AlertDialogCancel>
<AlertDialogAction
onClick={handleDelete}
disabled={deleting}
className="bg-destructive text-white hover:bg-destructive/90"
>
{deleting ? "Deleting..." : "Delete"}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
);
}

View File

@@ -1,8 +1,21 @@
"use client";
import { useRef, useState } from "react";
import { useEffect, useMemo, useRef, useState } from "react";
import { useQuery } from "@tanstack/react-query";
import { toast } from "sonner";
import { Calendar, ChevronRight, Maximize2, Minimize2, Rocket, X as XIcon } from "lucide-react";
import {
Check,
ChevronDown,
ChevronRight,
Clock,
FilePlus2,
Maximize2,
Minimize2,
Play,
Rocket,
X as XIcon,
Zap,
} from "lucide-react";
import { cn } from "@multica/ui/lib/utils";
import {
Dialog,
@@ -11,7 +24,18 @@ import {
} from "@multica/ui/components/ui/dialog";
import { Tooltip, TooltipTrigger, TooltipContent } from "@multica/ui/components/ui/tooltip";
import { Button } from "@multica/ui/components/ui/button";
import {
Select,
SelectTrigger,
SelectValue,
SelectContent,
SelectItem,
} from "@multica/ui/components/ui/select";
import { TimeInput } from "@multica/ui/components/ui/time-input";
import { TimezonePicker } from "./pickers/timezone-picker";
import { useCurrentWorkspace } from "@multica/core/paths";
import { useWorkspaceId } from "@multica/core/hooks";
import { agentListOptions } from "@multica/core/workspace/queries";
import {
useCreateAutopilot,
useCreateAutopilotTrigger,
@@ -21,21 +45,18 @@ import {
import type {
AutopilotExecutionMode,
AutopilotTrigger,
IssuePriority,
} from "@multica/core/types";
import { TitleEditor, ContentEditor } from "../../editor";
import { PillButton } from "../../common/pill-button";
import { PriorityPicker } from "../../issues/components/pickers";
import { ActorAvatar } from "../../common/actor-avatar";
import { AgentPicker } from "./pickers/agent-picker";
import {
getDefaultTriggerConfig,
getLocalTimezone,
parseCronExpression,
summarizeTrigger,
toCronExpression,
type TriggerConfig,
type TriggerFrequency,
} from "./trigger-config";
import { AgentPicker } from "./pickers/agent-picker";
import { ExecutionModePicker } from "./pickers/execution-mode-picker";
import { SchedulePicker } from "./pickers/schedule-picker";
// ---------------------------------------------------------------------------
// Types
@@ -45,7 +66,6 @@ export interface AutopilotInitial {
title: string;
description: string;
assignee_id: string;
priority: string;
execution_mode: AutopilotExecutionMode;
}
@@ -67,20 +87,174 @@ export type AutopilotDialogProps =
};
// ---------------------------------------------------------------------------
// AutopilotDialog — shared Create/Edit dialog for autopilots
// Static data
// ---------------------------------------------------------------------------
const FREQUENCY_OPTIONS: { value: TriggerFrequency; label: string }[] = [
{ value: "hourly", label: "Every hour" },
{ value: "daily", label: "Every day" },
{ value: "weekdays", label: "Every weekday" },
{ value: "weekly", label: "Every week" },
{ value: "custom", label: "Custom cron" },
];
const DAY_OPTIONS: { value: number; label: string; short: string }[] = [
{ value: 0, label: "Sunday", short: "Sun" },
{ value: 1, label: "Monday", short: "Mon" },
{ value: 2, label: "Tuesday", short: "Tue" },
{ value: 3, label: "Wednesday", short: "Wed" },
{ value: 4, label: "Thursday", short: "Thu" },
{ value: 5, label: "Friday", short: "Fri" },
{ value: 6, label: "Saturday", short: "Sat" },
];
const TIMEZONE_OPTIONS = [
"UTC",
"America/New_York",
"America/Chicago",
"America/Denver",
"America/Los_Angeles",
"America/Sao_Paulo",
"Europe/London",
"Europe/Paris",
"Europe/Berlin",
"Europe/Moscow",
"Asia/Dubai",
"Asia/Kolkata",
"Asia/Singapore",
"Asia/Shanghai",
"Asia/Tokyo",
"Asia/Seoul",
"Australia/Sydney",
"Pacific/Auckland",
];
const OUTPUT_MODES: {
value: AutopilotExecutionMode;
label: string;
description: string;
Icon: typeof FilePlus2;
}[] = [
{
value: "create_issue",
label: "Create issue",
description: "Each run creates a tracked issue",
Icon: FilePlus2,
},
{
value: "run_only",
label: "Run only",
description: "Silent run, no issue created",
Icon: Play,
},
];
// ---------------------------------------------------------------------------
// Next-run computation (local approximation — server stores the authoritative value)
// ---------------------------------------------------------------------------
function computeNextRun(cfg: TriggerConfig, now: Date): Date | null {
const [hStr, mStr] = cfg.time.split(":");
const hour = parseInt(hStr ?? "9", 10);
const minute = parseInt(mStr ?? "0", 10);
const next = new Date(now);
switch (cfg.frequency) {
case "hourly": {
next.setMinutes(minute, 0, 0);
if (next <= now) next.setHours(next.getHours() + 1);
return next;
}
case "daily": {
next.setHours(hour, minute, 0, 0);
if (next <= now) next.setDate(next.getDate() + 1);
return next;
}
case "weekdays": {
next.setHours(hour, minute, 0, 0);
for (let i = 0; i < 8; i++) {
const dow = next.getDay();
if (next > now && dow >= 1 && dow <= 5) return next;
next.setDate(next.getDate() + 1);
next.setHours(hour, minute, 0, 0);
}
return null;
}
case "weekly": {
if (cfg.daysOfWeek.length === 0) return null;
next.setHours(hour, minute, 0, 0);
for (let i = 0; i < 8; i++) {
if (next > now && cfg.daysOfWeek.includes(next.getDay())) return next;
next.setDate(next.getDate() + 1);
next.setHours(hour, minute, 0, 0);
}
return null;
}
case "custom":
return null;
}
}
function formatCountdown(target: Date, now: Date): string {
const diff = Math.max(0, target.getTime() - now.getTime());
const seconds = Math.floor(diff / 1000);
const days = Math.floor(seconds / 86400);
const hours = Math.floor((seconds % 86400) / 3600);
const minutes = Math.floor((seconds % 3600) / 60);
if (days > 0) return `${days}d ${hours}h`;
if (hours > 0) return `${hours}h ${minutes}m`;
if (minutes > 0) return `${minutes}m`;
return "<1m";
}
function formatNextRunAbsolute(date: Date, timezone: string): string {
try {
return new Intl.DateTimeFormat("en-US", {
timeZone: timezone,
weekday: "short",
month: "short",
day: "numeric",
hour: "numeric",
minute: "2-digit",
hour12: true,
}).format(date);
} catch {
return date.toLocaleString();
}
}
// ---------------------------------------------------------------------------
// Live "now" ticker for countdown
// ---------------------------------------------------------------------------
function useNowTicker(intervalMs = 30_000): Date {
const [now, setNow] = useState(() => new Date());
useEffect(() => {
const t = setInterval(() => setNow(new Date()), intervalMs);
return () => clearInterval(t);
}, [intervalMs]);
return now;
}
// ---------------------------------------------------------------------------
// AutopilotDialog
// ---------------------------------------------------------------------------
export function AutopilotDialog(props: AutopilotDialogProps) {
const { open, onOpenChange } = props;
const workspaceName = useCurrentWorkspace()?.name;
const wsId = useWorkspaceId();
const { data: agents = [] } = useQuery(agentListOptions(wsId));
const [isExpanded, setIsExpanded] = useState(false);
const isCreate = props.mode === "create";
const initial: Partial<AutopilotInitial> = isCreate ? props.initial ?? {} : props.initial;
const initial: Partial<AutopilotInitial> = isCreate
? props.initial ?? {}
: props.initial;
const [title, setTitle] = useState(initial.title ?? "");
const [description, setDescription] = useState(initial.description ?? "");
const [assigneeId, setAssigneeId] = useState<string>(initial.assignee_id ?? "");
const [priority, setPriority] = useState<string>(initial.priority ?? "medium");
const [executionMode, setExecutionMode] = useState<AutopilotExecutionMode>(
initial.execution_mode ?? "create_issue",
);
@@ -97,17 +271,13 @@ export function AutopilotDialog(props: AutopilotDialogProps) {
return getDefaultTriggerConfig();
})();
const [triggerConfig, setTriggerConfig] = useState<TriggerConfig>(initialCfg);
// Snapshot initial cron payload at mount so `scheduleDirty` only flips when
// the payload actually changes (prevents phantom trigger creates when the
// user opens the popover, clicks the already-active option, then Saves).
const initialCronRef = useRef(toCronExpression(initialCfg));
const initialTimezoneRef = useRef(initialCfg.timezone);
const scheduleDirty =
toCronExpression(triggerConfig) !== initialCronRef.current ||
triggerConfig.timezone !== initialTimezoneRef.current;
// Snapshot the first-trigger id at mount. Parent `triggers` prop can update
// mid-dialog via WS refetch — we want Save to act on the trigger we showed.
const firstTriggerIdRef = useRef(
!isCreate && props.triggers[0] ? props.triggers[0].id : null,
);
@@ -115,12 +285,10 @@ export function AutopilotDialog(props: AutopilotDialogProps) {
const triggerCount = isCreate ? 0 : props.triggers.length;
const schedulePillDisabled = !isCreate && triggerCount >= 2;
const schedulePillLabel = (() => {
if (isCreate) return summarizeTrigger(triggerConfig);
if (triggerCount === 0) return "Add schedule";
if (triggerCount === 1) return summarizeTrigger(triggerConfig);
return `${triggerCount} schedules`;
})();
const selectedAgent = useMemo(
() => agents.find((a) => a.id === assigneeId) ?? null,
[agents, assigneeId],
);
const createAutopilot = useCreateAutopilot();
const createTrigger = useCreateAutopilotTrigger();
@@ -128,7 +296,8 @@ export function AutopilotDialog(props: AutopilotDialogProps) {
const updateTrigger = useUpdateAutopilotTrigger();
const [submitting, setSubmitting] = useState(false);
const canSubmit = title.trim().length > 0 && assigneeId.length > 0 && !submitting;
const canSubmit =
title.trim().length > 0 && assigneeId.length > 0 && !submitting;
const handleSubmit = async () => {
if (!canSubmit) return;
@@ -139,7 +308,6 @@ export function AutopilotDialog(props: AutopilotDialogProps) {
title: title.trim(),
description: description.trim() || undefined,
assignee_id: assigneeId,
priority,
execution_mode: executionMode,
});
let scheduleOk = true;
@@ -162,10 +330,8 @@ export function AutopilotDialog(props: AutopilotDialogProps) {
title: title.trim(),
description: description.trim() || null,
assignee_id: assigneeId,
priority,
execution_mode: executionMode,
});
// Schedule: patch the trigger we snapshotted at mount, else create one.
let scheduleOk = true;
if (scheduleDirty && !schedulePillDisabled) {
const snapshottedTriggerId = firstTriggerIdRef.current;
@@ -210,7 +376,9 @@ export function AutopilotDialog(props: AutopilotDialogProps) {
"p-0 gap-0 flex flex-col overflow-hidden",
"!transition-all !duration-300 !ease-out !-translate-y-1/2",
"!w-[calc(100vw-2rem)]",
isExpanded ? "!max-w-4xl !h-5/6" : "!max-w-2xl !h-96",
isExpanded
? "!max-w-6xl !h-[calc(100vh-4rem)]"
: "!max-w-5xl !h-[min(720px,calc(100vh-4rem))]",
)}
>
<DialogTitle className="sr-only">
@@ -218,14 +386,24 @@ export function AutopilotDialog(props: AutopilotDialogProps) {
</DialogTitle>
{/* Header */}
<div className="flex items-center justify-between px-5 pt-3 pb-2 shrink-0">
<div className="flex items-center gap-1.5 text-xs">
<span className="text-muted-foreground">{workspaceName}</span>
<ChevronRight className="size-3 text-muted-foreground/50" />
<Rocket className="size-3 text-muted-foreground" />
<span className="font-medium">
{isCreate ? "New autopilot" : "Edit autopilot"}
</span>
<div className="flex items-center justify-between px-5 pt-3 pb-2 shrink-0 border-b">
<div className="flex items-center gap-2 text-xs">
<div className="flex items-center gap-1.5">
<span className="inline-flex size-5 items-center justify-center rounded-md bg-primary/15 text-primary">
<Rocket className="size-3" />
</span>
<span className="font-medium text-foreground">
{isCreate ? "New autopilot" : "Edit autopilot"}
</span>
</div>
<span className="text-muted-foreground/60">·</span>
<span className="text-muted-foreground">A recurring AI task</span>
{workspaceName && (
<>
<ChevronRight className="size-3 text-muted-foreground/40" />
<span className="text-muted-foreground">{workspaceName}</span>
</>
)}
</div>
<div className="flex items-center gap-1">
<Tooltip>
@@ -257,94 +435,339 @@ export function AutopilotDialog(props: AutopilotDialogProps) {
</div>
</div>
{/* Body re-mounts when switching between autopilots (or create/edit) */}
<div key={contentKey} className="flex-1 flex flex-col min-h-0">
{/* Name */}
<div className="px-5 pb-2 shrink-0">
<TitleEditor
autoFocus={isCreate}
defaultValue={initial.title ?? ""}
placeholder="Autopilot name"
className="text-lg font-semibold"
onChange={setTitle}
onSubmit={handleSubmit}
/>
</div>
{/* Prompt — takes remaining space */}
<div className="relative flex-1 min-h-0 overflow-y-auto px-5">
<ContentEditor
defaultValue={initial.description ?? ""}
placeholder="Step-by-step instructions for the agent..."
onUpdate={setDescription}
debounceMs={300}
showBubbleMenu={false}
/>
</div>
{/* Pill toolbar */}
<div className="flex items-center gap-1.5 px-4 py-2 shrink-0 flex-wrap">
<AgentPicker
agentId={assigneeId || null}
onChange={setAssigneeId}
triggerRender={<PillButton />}
align="start"
/>
<PriorityPicker
priority={priority as IssuePriority}
onUpdate={(u) => { if (u.priority) setPriority(u.priority); }}
triggerRender={<PillButton />}
align="start"
/>
<ExecutionModePicker
mode={executionMode}
onChange={setExecutionMode}
triggerRender={<PillButton />}
align="start"
/>
{schedulePillDisabled ? (
<Tooltip>
<TooltipTrigger
render={
<PillButton
disabled
className="opacity-60 cursor-not-allowed"
>
<Calendar className="size-3" />
<span className="truncate">{schedulePillLabel}</span>
</PillButton>
}
/>
<TooltipContent side="top">Edit schedules in detail page</TooltipContent>
</Tooltip>
) : (
<SchedulePicker
config={triggerConfig}
onChange={setTriggerConfig}
triggerRender={
<PillButton>
<Calendar className="size-3" />
<span className="truncate">{schedulePillLabel}</span>
</PillButton>
}
{/* Body: two columns (stacks on narrow screens via flex-wrap at container level) */}
<div
key={contentKey}
className="flex-1 min-h-0 flex flex-col lg:flex-row overflow-hidden"
>
{/* Left: Runbook */}
<div className="flex-1 min-h-0 flex flex-col border-b lg:border-b-0 lg:border-r">
<div className="px-6 pt-5 pb-3 shrink-0">
<TitleEditor
autoFocus={isCreate}
defaultValue={initial.title ?? ""}
placeholder="Autopilot name"
className="text-2xl font-semibold tracking-tight"
onChange={setTitle}
onSubmit={handleSubmit}
/>
)}
</div>
<div className="px-6 pb-2 shrink-0 flex items-baseline gap-2">
<span className="text-[11px] font-semibold tracking-[0.08em] text-muted-foreground uppercase">
Runbook
</span>
<span className="text-xs text-muted-foreground/80">
Read by the agent on every run
</span>
</div>
<div className="flex-1 min-h-0 px-6 pb-6 flex flex-col">
<div className="h-full overflow-y-auto rounded-lg border border-border bg-background transition-colors focus-within:border-input px-4 py-3">
<ContentEditor
defaultValue={initial.description ?? ""}
placeholder={`# Goal\nWhat should the agent accomplish?\n\n# Context\nWho is this for? Any constraints?\n\n# Steps\n1. …\n2. …`}
onUpdate={setDescription}
debounceMs={300}
showBubbleMenu={false}
/>
</div>
</div>
</div>
{/* Footer */}
<div className="flex items-center justify-end gap-2 px-4 py-3 border-t shrink-0">
{/* Right: Configuration */}
<aside className="w-full lg:w-[340px] shrink-0 overflow-y-auto px-5 py-5 space-y-5 bg-muted/30">
<AgentSection
selectedId={assigneeId}
onChange={setAssigneeId}
selectedName={selectedAgent?.name}
selectedDescription={selectedAgent?.description}
/>
<OutputModeSection mode={executionMode} onChange={setExecutionMode} />
<ScheduleSection
config={triggerConfig}
onChange={setTriggerConfig}
disabled={schedulePillDisabled}
disabledReason={
schedulePillDisabled
? "This autopilot has multiple schedules — edit them in the detail page."
: undefined
}
/>
</aside>
</div>
{/* Footer */}
<div className="flex items-center justify-between gap-3 px-5 py-3 border-t shrink-0 bg-background">
<div className="flex items-center gap-1.5 text-xs text-muted-foreground min-w-0">
<Zap className="size-3.5 text-amber-500 shrink-0" />
<span className="truncate">
Once saved, runs automatically until paused.
</span>
</div>
<div className="flex items-center gap-2 shrink-0">
<Button size="sm" variant="outline" onClick={() => onOpenChange(false)}>
Cancel
</Button>
<Button size="sm" onClick={handleSubmit} disabled={!canSubmit}>
{submitting
? isCreate ? "Creating..." : "Saving..."
: isCreate ? "Create autopilot" : "Save"}
? isCreate
? "Creating..."
: "Saving..."
: isCreate
? "Create autopilot"
: "Save"}
</Button>
</div>
</div>
</DialogContent>
</Dialog>
);
}
// ---------------------------------------------------------------------------
// Right column sections
// ---------------------------------------------------------------------------
function SectionLabel({ children }: { children: React.ReactNode }) {
return (
<div className="text-[11px] font-semibold tracking-[0.08em] text-muted-foreground uppercase mb-2">
{children}
</div>
);
}
function AgentSection({
selectedId,
onChange,
selectedName,
selectedDescription,
}: {
selectedId: string;
onChange: (id: string) => void;
selectedName?: string;
selectedDescription?: string;
}) {
return (
<div>
<SectionLabel>Agent</SectionLabel>
<AgentPicker
agentId={selectedId || null}
onChange={onChange}
align="start"
triggerRender={
<button
type="button"
className={cn(
"w-full flex items-center gap-2.5 rounded-md border bg-background px-3 py-2 text-left",
"hover:bg-accent/40 transition-colors cursor-pointer",
)}
>
{selectedId ? (
<ActorAvatar
actorType="agent"
actorId={selectedId}
size={28}
disableHoverCard
/>
) : (
<span className="inline-flex size-7 items-center justify-center rounded-full bg-muted text-muted-foreground">
<Rocket className="size-3.5" />
</span>
)}
<span className="flex-1 min-w-0">
<span className="block text-sm font-medium truncate">
{selectedName ?? "Select agent"}
</span>
{selectedDescription && (
<span className="block text-xs text-muted-foreground truncate">
{selectedDescription}
</span>
)}
</span>
<ChevronDown className="size-3.5 text-muted-foreground shrink-0" />
</button>
}
/>
</div>
);
}
function OutputModeSection({
mode,
onChange,
}: {
mode: AutopilotExecutionMode;
onChange: (mode: AutopilotExecutionMode) => void;
}) {
return (
<div>
<SectionLabel>Output mode</SectionLabel>
<div className="space-y-1.5">
{OUTPUT_MODES.map((o) => {
const selected = o.value === mode;
return (
<button
key={o.value}
type="button"
onClick={() => onChange(o.value)}
className={cn(
"w-full flex items-start gap-2.5 rounded-md border px-3 py-2 text-left cursor-pointer transition-colors",
selected
? "border-primary bg-primary/5"
: "bg-background hover:bg-accent/40",
)}
>
<span
className={cn(
"mt-0.5 inline-flex size-4 shrink-0 items-center justify-center rounded-full border",
selected
? "border-primary bg-primary text-primary-foreground"
: "border-muted-foreground/40 bg-background",
)}
>
{selected && <Check className="size-2.5" strokeWidth={3} />}
</span>
<span className="flex-1 min-w-0">
<span className="block text-sm font-medium">{o.label}</span>
<span className="block text-xs text-muted-foreground">
{o.description}
</span>
</span>
</button>
);
})}
</div>
</div>
);
}
function ScheduleSection({
config,
onChange,
disabled,
disabledReason,
}: {
config: TriggerConfig;
onChange: (c: TriggerConfig) => void;
disabled?: boolean;
disabledReason?: string;
}) {
const now = useNowTicker();
const next = useMemo(() => computeNextRun(config, now), [config, now]);
const timezones = useMemo(() => {
const local = getLocalTimezone();
if (TIMEZONE_OPTIONS.includes(local)) return TIMEZONE_OPTIONS;
return [local, ...TIMEZONE_OPTIONS];
}, []);
const selectedDay = config.daysOfWeek[0] ?? 1;
return (
<div>
<SectionLabel>Schedule</SectionLabel>
<div
className={cn(
"space-y-2",
disabled && "opacity-60 pointer-events-none",
)}
>
{/* Row 1: Frequency + (Day when weekly) */}
<div className="grid grid-cols-2 gap-2">
<Select
value={config.frequency}
onValueChange={(v) =>
v && onChange({ ...config, frequency: v as TriggerFrequency })
}
>
<SelectTrigger className="w-full">
<SelectValue />
</SelectTrigger>
<SelectContent>
{FREQUENCY_OPTIONS.map((f) => (
<SelectItem key={f.value} value={f.value}>
{f.label}
</SelectItem>
))}
</SelectContent>
</Select>
{config.frequency === "weekly" ? (
<Select
value={String(selectedDay)}
onValueChange={(v) =>
v && onChange({ ...config, daysOfWeek: [parseInt(v, 10)] })
}
>
<SelectTrigger className="w-full">
<SelectValue />
</SelectTrigger>
<SelectContent>
{DAY_OPTIONS.map((d) => (
<SelectItem key={d.value} value={String(d.value)}>
{d.label}
</SelectItem>
))}
</SelectContent>
</Select>
) : (
<div />
)}
</div>
{/* Row 2: Time + Timezone (hidden for hourly / custom) */}
{config.frequency === "custom" ? (
<input
type="text"
value={config.cronExpression}
onChange={(e) =>
onChange({ ...config, cronExpression: e.target.value })
}
placeholder="0 9 * * 1-5"
className="w-full rounded-lg border border-input bg-transparent px-2.5 py-1 h-8 text-sm font-mono outline-none transition-colors focus:border-ring focus:ring-3 focus:ring-ring/50 dark:bg-input/30"
/>
) : config.frequency === "hourly" ? (
<TimeInput
minuteOnly
value={config.time}
onChange={(v) => onChange({ ...config, time: v })}
/>
) : (
<div className="grid grid-cols-2 gap-2">
<TimeInput
value={config.time}
onChange={(v) => onChange({ ...config, time: v })}
/>
<TimezonePicker
value={config.timezone}
onChange={(tz) => onChange({ ...config, timezone: tz })}
options={timezones}
/>
</div>
)}
{/* Next run preview */}
{next && (
<div className="flex items-center gap-1.5 text-xs text-muted-foreground pt-1">
<Clock className="size-3 shrink-0" />
<span className="truncate">
Next run:{" "}
<span className="text-foreground">
{formatNextRunAbsolute(next, config.timezone)}
</span>
</span>
<span className="ml-auto rounded-sm bg-muted px-1.5 py-0.5 text-[10px] font-medium text-foreground shrink-0">
{formatCountdown(next, now)}
</span>
</div>
)}
</div>
{disabled && disabledReason && (
<p className="mt-2 text-[11px] text-muted-foreground">
{disabledReason}
</p>
)}
</div>
);
}

View File

@@ -1,76 +0,0 @@
"use client";
import { useState } from "react";
import { FilePlus2, Play } from "lucide-react";
import type { AutopilotExecutionMode } from "@multica/core/types";
import { Tooltip, TooltipTrigger, TooltipContent } from "@multica/ui/components/ui/tooltip";
import {
PropertyPicker,
PickerItem,
} from "../../../issues/components/pickers/property-picker";
const OPTIONS: { value: AutopilotExecutionMode; label: string; description: string; Icon: typeof FilePlus2 }[] = [
{ value: "create_issue", label: "Create Issue", description: "File an issue with the agent assigned", Icon: FilePlus2 },
{ value: "run_only", label: "Run Only", description: "Run the agent without creating an issue", Icon: Play },
];
export function ExecutionModePicker({
mode,
onChange,
trigger: customTrigger,
triggerRender,
align = "start",
}: {
mode: AutopilotExecutionMode;
onChange: (mode: AutopilotExecutionMode) => void;
trigger?: React.ReactNode;
triggerRender?: React.ReactElement;
align?: "start" | "center" | "end";
}) {
const [open, setOpen] = useState(false);
const current = OPTIONS.find((o) => o.value === mode) ?? OPTIONS[0]!;
const CurrentIcon = current.Icon;
return (
<PropertyPicker
open={open}
onOpenChange={setOpen}
width="w-52"
align={align}
triggerRender={triggerRender}
trigger={
customTrigger ?? (
<>
<CurrentIcon className="size-3 shrink-0" />
<span className="truncate">{current.label}</span>
</>
)
}
>
{OPTIONS.map((o) => {
const Icon = o.Icon;
return (
<Tooltip key={o.value}>
<TooltipTrigger
render={
<PickerItem
selected={o.value === mode}
onClick={() => {
onChange(o.value);
setOpen(false);
}}
>
<Icon className="size-3.5 shrink-0 text-muted-foreground" />
<span>{o.label}</span>
</PickerItem>
}
/>
<TooltipContent side="right" sideOffset={8}>
{o.description}
</TooltipContent>
</Tooltip>
);
})}
</PropertyPicker>
);
}

View File

@@ -1,32 +0,0 @@
"use client";
import { useState } from "react";
import {
Popover,
PopoverTrigger,
PopoverContent,
} from "@multica/ui/components/ui/popover";
import {
TriggerConfigSection,
type TriggerConfig,
} from "../trigger-config";
export function SchedulePicker({
config,
onChange,
triggerRender,
}: {
config: TriggerConfig;
onChange: (cfg: TriggerConfig) => void;
triggerRender: React.ReactElement;
}) {
const [open, setOpen] = useState(false);
return (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger render={triggerRender} />
<PopoverContent align="start" className="w-96 p-3">
<TriggerConfigSection config={config} onChange={onChange} />
</PopoverContent>
</Popover>
);
}

View File

@@ -0,0 +1,131 @@
"use client";
import { useMemo, useState } from "react";
import { Check, ChevronDown, Globe } from "lucide-react";
import { cn } from "@multica/ui/lib/utils";
import {
PropertyPicker,
PickerEmpty,
} from "../../../issues/components/pickers/property-picker";
export interface TimezonePickerProps {
value: string;
onChange: (tz: string) => void;
options: string[];
disabled?: boolean;
className?: string;
}
function offsetFor(tz: string): string {
try {
const parts = new Intl.DateTimeFormat("en-US", {
timeZone: tz,
timeZoneName: "shortOffset",
}).formatToParts(new Date());
return parts.find((p) => p.type === "timeZoneName")?.value ?? "";
} catch {
return "";
}
}
function cityLabel(tz: string): string {
if (tz === "UTC") return "UTC";
return tz.split("/").pop()?.replace(/_/g, " ") ?? tz;
}
export function TimezonePicker({
value,
onChange,
options,
disabled,
className,
}: TimezonePickerProps) {
const [open, setOpen] = useState(false);
const [filter, setFilter] = useState("");
const selectedCity = cityLabel(value);
const selectedOffset = useMemo(() => offsetFor(value), [value]);
const query = filter.trim().toLowerCase();
const filteredOptions = useMemo(() => {
if (!query) return options;
return options.filter((tz) => {
const haystack = `${tz} ${cityLabel(tz)} ${offsetFor(tz)}`.toLowerCase();
return haystack.includes(query);
});
}, [options, query]);
return (
<PropertyPicker
open={open}
onOpenChange={(v) => {
setOpen(v);
if (!v) setFilter("");
}}
width="w-64"
align="start"
searchable
searchPlaceholder="Search timezone..."
onSearchChange={setFilter}
triggerRender={
<button
type="button"
disabled={disabled}
className={cn(
"flex h-8 w-full items-center gap-1.5 rounded-lg border border-input bg-transparent px-2.5 text-sm transition-colors outline-none",
"hover:bg-accent/30",
"focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50",
"disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50",
"dark:bg-input/30",
className,
)}
/>
}
trigger={
<>
<Globe className="size-3.5 shrink-0 text-muted-foreground" />
<span className="flex-1 truncate text-left">{selectedCity}</span>
{selectedOffset && (
<span className="shrink-0 text-xs text-muted-foreground tabular-nums">
{selectedOffset}
</span>
)}
<ChevronDown className="size-3.5 shrink-0 text-muted-foreground" />
</>
}
>
{filteredOptions.length === 0 ? (
<PickerEmpty />
) : (
filteredOptions.map((tz) => {
const off = offsetFor(tz);
const isSelected = tz === value;
return (
<button
key={tz}
type="button"
data-picker-item
onClick={() => {
onChange(tz);
setOpen(false);
}}
className="flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-sm transition-colors hover:bg-accent"
>
<span className="flex size-3.5 shrink-0 items-center justify-center">
{isSelected && (
<Check className="size-3.5 text-foreground" />
)}
</span>
<span className="flex-1 truncate text-left">{cityLabel(tz)}</span>
{off && (
<span className="shrink-0 text-xs text-muted-foreground tabular-nums">
{off}
</span>
)}
</button>
);
})
)}
</PropertyPicker>
);
}

View File

@@ -55,3 +55,20 @@ describe("ReadonlyContent math rendering", () => {
expect(text).toContain("\\int_0^1 x^2 \\, dx");
});
});
describe("ReadonlyContent line breaks", () => {
// Issue panel comments are the primary user-visible surface for agent
// output. CommonMark's default soft-break behavior collapses single
// newlines into spaces; agent text often relies on a single newline as a
// visible break. remark-breaks must remain wired into ReadonlyContent's
// remark plugin chain or comments lose their formatting again.
it("converts a single newline into a <br>", () => {
const { container } = render(<ReadonlyContent content={"line one\nline two"} />);
expect(container.querySelector("br")).not.toBeNull();
});
it("renders a blank-line gap as separate paragraphs", () => {
const { container } = render(<ReadonlyContent content={"para one\n\npara two"} />);
expect(container.querySelectorAll("p").length).toBeGreaterThanOrEqual(2);
});
});

View File

@@ -22,6 +22,7 @@ import ReactMarkdown, {
type Components,
} from "react-markdown";
import rehypeKatex from "rehype-katex";
import remarkBreaks from "remark-breaks";
import remarkGfm from "remark-gfm";
import remarkMath from "remark-math";
import rehypeRaw from "rehype-raw";
@@ -297,7 +298,7 @@ export function ReadonlyContent({ content, className }: ReadonlyContentProps) {
return (
<div ref={wrapperRef} className={cn("rich-text-editor readonly text-sm", className)}>
<ReactMarkdown
remarkPlugins={[remarkMath, [remarkGfm, { singleTilde: false }]]}
remarkPlugins={[remarkMath, remarkBreaks, [remarkGfm, { singleTilde: false }]]}
rehypePlugins={[rehypeRaw, [rehypeSanitize, sanitizeSchema], rehypeKatex]}
urlTransform={urlTransform}
components={components}

View File

@@ -0,0 +1,95 @@
import { describe, expect, it } from "vitest";
import { preprocessLinks } from "@multica/ui/markdown/linkify";
// The bug: linkify-it does not treat CJK full-width punctuation as a URL
// boundary, so the href can swallow trailing punctuation and the Chinese
// characters that follow it (up to the next space). The fix truncates the
// detected URL at the first CJK full-width punctuation character.
describe("preprocessLinks — CJK punctuation boundary", () => {
it("stops URL at ideographic full stop 。", () => {
const out = preprocessLinks("见 https://example.com/path。然后继续");
expect(out).toBe("见 [https://example.com/path](https://example.com/path)。然后继续");
});
it("stops URL at fullwidth comma ", () => {
const out = preprocessLinks("打开 https://example.com/a以及其他");
expect(out).toBe("打开 [https://example.com/a](https://example.com/a),以及其他");
});
it("stops URL at ideographic comma 、", () => {
const out = preprocessLinks("两个地址 https://a.com/x、https://b.com/y");
expect(out).toBe(
"两个地址 [https://a.com/x](https://a.com/x)、[https://b.com/y](https://b.com/y)",
);
});
it("stops URL at fullwidth right paren ", () => {
const out = preprocessLinks("(见 https://example.com/x后文");
expect(out).toBe("(见 [https://example.com/x](https://example.com/x))后文");
});
it("stops URL at corner bracket 」", () => {
const out = preprocessLinks("「https://example.com/a」后文");
expect(out).toBe("「[https://example.com/a](https://example.com/a)」后文");
});
it("stops URL at fullwidth exclamation ", () => {
const out = preprocessLinks("太好了 https://example.com/x继续");
expect(out).toBe("太好了 [https://example.com/x](https://example.com/x)!继续");
});
it("handles the original bug report (PR link then 。 then more text)", () => {
const out = preprocessLinks(
"已合并 PR #1623https://github.com/multica-ai/multica/pull/1623。merge commit",
);
expect(out).toBe(
"已合并 PR #1623[https://github.com/multica-ai/multica/pull/1623](https://github.com/multica-ai/multica/pull/1623)。merge commit",
);
});
it("does not swallow the entire remainder when there is no trailing space", () => {
const out = preprocessLinks("https://github.com/x/y/issues/1619。我接下来把这个");
expect(out).toBe(
"[https://github.com/x/y/issues/1619](https://github.com/x/y/issues/1619)。我接下来把这个",
);
});
it("preserves ASCII trailing period handling (no regression)", () => {
const out = preprocessLinks("visit https://example.com/path. next.");
expect(out).toBe("visit [https://example.com/path](https://example.com/path). next.");
});
it("preserves plain URL with no trailing punctuation (no regression)", () => {
const out = preprocessLinks("go https://example.com/path");
expect(out).toBe("go [https://example.com/path](https://example.com/path)");
});
it("preserves CJK letters inside URL path (only trims on punctuation)", () => {
const out = preprocessLinks("https://zh.wikipedia.org/wiki/中国 参考");
expect(out).toBe(
"[https://zh.wikipedia.org/wiki/中国](https://zh.wikipedia.org/wiki/中国) 参考",
);
});
it("does not re-link an already-linked URL that contains 。", () => {
// If a user or upstream already wrote [text](url。), we leave it alone.
const input = "见 [link](https://example.com/x。)后文";
expect(preprocessLinks(input)).toBe(input);
});
it("does not linkify fuzzy domains inside existing markdown link labels", () => {
const input =
"数据来源:[NBA.com Schedule](https://www.nba.com/schedule)、[NBC Insider](https://www.nbc.com/nbc-insider/every-nba-playoff-game-this-week-on-nbc-peacock-april-25-28)";
expect(preprocessLinks(input)).toBe(input);
});
it("still linkifies fuzzy domains outside existing markdown links", () => {
const input = "数据来源:[NBA.com Schedule](https://www.nba.com/schedule),官网 NBA.com";
expect(preprocessLinks(input)).toBe(
"数据来源:[NBA.com Schedule](https://www.nba.com/schedule),官网 [NBA.com](http://NBA.com)",
);
});
});

View File

@@ -20,6 +20,8 @@ const typeLabels: Record<InboxItemType, string> = {
agent_blocked: "Agent blocked",
agent_completed: "Agent completed",
reaction_added: "Reacted",
quick_create_done: "Quick create done",
quick_create_failed: "Quick create failed",
};
export { typeLabels };

View File

@@ -5,6 +5,8 @@ import { useDefaultLayout } from "react-resizable-panels";
import { useQuery } from "@tanstack/react-query";
import { useWorkspaceId } from "@multica/core/hooks";
import { useWorkspacePaths } from "@multica/core/paths";
import { useModalStore } from "@multica/core/modals";
import { useIssueDraftStore } from "@multica/core/issues/stores/draft-store";
import {
inboxListOptions,
deduplicateInboxItems,
@@ -257,7 +259,35 @@ export function InboxPage() {
{selected.body}
</div>
)}
<div className="mt-4">
{selected.type === "quick_create_failed" && selected.details?.original_prompt && (
<div className="mt-4 rounded-md border bg-muted/40 p-3">
<p className="text-xs font-medium text-muted-foreground">Original input</p>
<p className="mt-1 whitespace-pre-wrap text-sm">{selected.details.original_prompt}</p>
</div>
)}
<div className="mt-4 flex gap-2">
{selected.type === "quick_create_failed" && (
<Button
size="sm"
onClick={() => {
// Seed the legacy advanced form with the original prompt so the
// user can recover their input in the full editor instead of
// retyping. The agent picker hint becomes the assignee
// candidate (still editable).
const prompt = selected.details?.original_prompt ?? "";
const agentId = selected.details?.agent_id;
useIssueDraftStore.getState().setDraft({
description: prompt,
...(agentId
? { assigneeType: "agent" as const, assigneeId: agentId }
: {}),
});
useModalStore.getState().open("create-issue");
}}
>
Edit as advanced form
</Button>
)}
<Button
variant="outline"
size="sm"

View File

@@ -0,0 +1,190 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { render, screen, fireEvent } from "@testing-library/react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import type { Issue } from "@multica/core/types";
// ---------------------------------------------------------------------------
// Mocks — same pattern as the issue-detail test suite.
// ---------------------------------------------------------------------------
vi.mock("@multica/core/hooks", () => ({
useWorkspaceId: () => "ws-1",
}));
const mockOpenModal = vi.fn();
vi.mock("@multica/core/modals", () => ({
useModalStore: Object.assign(
(selector?: any) => {
const state = { open: mockOpenModal };
return selector ? selector(state) : state;
},
{ getState: () => ({ open: mockOpenModal }) },
),
}));
const mockAuthState = { user: { id: "user-1" }, isAuthenticated: true };
vi.mock("@multica/core/auth", () => ({
useAuthStore: Object.assign(
(selector?: any) => (selector ? selector(mockAuthState) : mockAuthState),
{ getState: () => mockAuthState },
),
registerAuthStore: vi.fn(),
}));
vi.mock("@multica/core/workspace/queries", () => ({
memberListOptions: () => ({
queryKey: ["workspaces", "ws-1", "members"],
queryFn: () =>
Promise.resolve([
{ user_id: "user-1", name: "Test User", email: "t@t.com", role: "admin" },
]),
}),
agentListOptions: () => ({
queryKey: ["workspaces", "ws-1", "agents"],
queryFn: () => Promise.resolve([]),
}),
}));
vi.mock("@multica/core/pins", () => ({
pinListOptions: () => ({
queryKey: ["pins", "ws-1", "user-1"],
queryFn: () => Promise.resolve([]),
}),
useCreatePin: () => ({ mutate: vi.fn() }),
useDeletePin: () => ({ mutate: vi.fn() }),
}));
vi.mock("@multica/core/issues/mutations", () => ({
useUpdateIssue: () => ({ mutate: vi.fn() }),
}));
vi.mock("@multica/core/paths", async () => {
const actual = await vi.importActual<typeof import("@multica/core/paths")>(
"@multica/core/paths",
);
return {
...actual,
useCurrentWorkspace: () => ({ id: "ws-1", name: "Test", slug: "test" }),
useWorkspacePaths: () => actual.paths.workspace("test"),
};
});
vi.mock("../../../navigation", () => ({
useNavigation: () => ({
push: vi.fn(),
pathname: "/test/issues/issue-1",
searchParams: new URLSearchParams(),
back: vi.fn(),
replace: vi.fn(),
}),
}));
vi.mock("sonner", () => ({
toast: { success: vi.fn(), error: vi.fn() },
}));
vi.mock("../../../common/actor-avatar", () => ({
ActorAvatar: ({ actorId }: any) => <span data-testid="actor">{actorId}</span>,
}));
// Import after mocks.
import { IssueActionsDropdown } from "../issue-actions-dropdown";
import { IssueActionsContextMenu } from "../issue-actions-context-menu";
const mockIssue: Issue = {
id: "issue-1",
workspace_id: "ws-1",
number: 1,
identifier: "TES-1",
title: "Example",
description: null,
status: "todo",
priority: "medium",
assignee_type: null,
assignee_id: null,
creator_type: "member",
creator_id: "user-1",
parent_issue_id: null,
due_date: null,
project_id: null,
created_at: "2026-01-01T00:00:00Z",
updated_at: "2026-01-01T00:00:00Z",
} as Issue;
function wrap(ui: React.ReactNode) {
const qc = new QueryClient({
defaultOptions: { queries: { retry: false } },
});
return <QueryClientProvider client={qc}>{ui}</QueryClientProvider>;
}
beforeEach(() => {
mockOpenModal.mockReset();
});
describe("IssueActionsDropdown", () => {
it("renders the top-level items when the trigger is clicked", async () => {
render(
wrap(
<IssueActionsDropdown
issue={mockIssue}
trigger={<button data-testid="trigger">Menu</button>}
/>,
),
);
fireEvent.click(screen.getByTestId("trigger"));
// Base UI portals the popup; role=menu lands on the popup wrapper.
expect(await screen.findByText("Status")).toBeInTheDocument();
expect(screen.getByText("Priority")).toBeInTheDocument();
expect(screen.getByText("Assignee")).toBeInTheDocument();
expect(screen.getByText("Due date")).toBeInTheDocument();
expect(screen.getByText("Copy link")).toBeInTheDocument();
expect(screen.getByText("More")).toBeInTheDocument();
expect(screen.getByText("Delete issue")).toBeInTheDocument();
// Relationship actions are hidden inside the "More" submenu by default.
expect(screen.queryByText("Create sub-issue")).not.toBeInTheDocument();
expect(screen.queryByText("Set parent issue...")).not.toBeInTheDocument();
expect(screen.queryByText("Add sub-issue...")).not.toBeInTheDocument();
});
it("clicking Delete issue opens the delete-confirm modal", async () => {
render(
wrap(
<IssueActionsDropdown
issue={mockIssue}
trigger={<button data-testid="trigger">Menu</button>}
onDeletedNavigateTo="/test/issues"
/>,
),
);
fireEvent.click(screen.getByTestId("trigger"));
const del = await screen.findByText("Delete issue");
fireEvent.click(del);
expect(mockOpenModal).toHaveBeenCalledWith("issue-delete-confirm", {
issueId: "issue-1",
identifier: "TES-1",
onDeletedNavigateTo: "/test/issues",
});
});
});
describe("IssueActionsContextMenu", () => {
it("renders the menu when the wrapped element receives a contextmenu event", async () => {
render(
wrap(
<IssueActionsContextMenu issue={mockIssue}>
<div data-testid="row">Row</div>
</IssueActionsContextMenu>,
),
);
fireEvent.contextMenu(screen.getByTestId("row"));
expect(await screen.findByText("Status")).toBeInTheDocument();
expect(screen.getByText("Delete issue")).toBeInTheDocument();
});
});

View File

@@ -0,0 +1,278 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { renderHook, act, waitFor } from "@testing-library/react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import type { Issue } from "@multica/core/types";
vi.mock("@multica/core/hooks", () => ({
useWorkspaceId: () => "ws-1",
}));
const mockOpenModal = vi.fn();
vi.mock("@multica/core/modals", () => ({
useModalStore: Object.assign(
(selector?: any) => {
const state = { open: mockOpenModal };
return selector ? selector(state) : state;
},
{ getState: () => ({ open: mockOpenModal }) },
),
}));
const mockAuthState = { user: { id: "user-1" }, isAuthenticated: true };
vi.mock("@multica/core/auth", () => ({
useAuthStore: Object.assign(
(selector?: any) => (selector ? selector(mockAuthState) : mockAuthState),
{ getState: () => mockAuthState },
),
registerAuthStore: vi.fn(),
}));
vi.mock("@multica/core/workspace/queries", () => ({
memberListOptions: () => ({
queryKey: ["workspaces", "ws-1", "members"],
queryFn: () =>
Promise.resolve([
{ user_id: "user-1", name: "Test User", email: "t@t.com", role: "admin" },
]),
}),
agentListOptions: () => ({
queryKey: ["workspaces", "ws-1", "agents"],
queryFn: () => Promise.resolve([]),
}),
}));
// Mutable so individual tests can seed the pin list.
const pinListRef: { value: Array<{ item_type: string; item_id: string }> } = {
value: [],
};
const mockCreatePinMutate = vi.fn();
const mockDeletePinMutate = vi.fn();
vi.mock("@multica/core/pins", () => ({
pinListOptions: () => ({
queryKey: ["pins", "ws-1", "user-1"],
queryFn: () => Promise.resolve(pinListRef.value),
}),
useCreatePin: () => ({ mutate: mockCreatePinMutate }),
useDeletePin: () => ({ mutate: mockDeletePinMutate }),
}));
const mockUpdateMutate = vi.fn();
vi.mock("@multica/core/issues/mutations", () => ({
useUpdateIssue: () => ({ mutate: mockUpdateMutate }),
}));
vi.mock("@multica/core/paths", async () => {
const actual = await vi.importActual<typeof import("@multica/core/paths")>(
"@multica/core/paths",
);
return {
...actual,
useCurrentWorkspace: () => ({ id: "ws-1", name: "Test", slug: "test" }),
useWorkspacePaths: () => actual.paths.workspace("test"),
};
});
vi.mock("../../../navigation", () => ({
useNavigation: () => ({
push: vi.fn(),
pathname: "/test/issues/issue-1",
searchParams: new URLSearchParams(),
back: vi.fn(),
replace: vi.fn(),
getShareableUrl: (p: string) => `https://app.multica.com${p}`,
}),
}));
vi.mock("sonner", () => ({
toast: { success: vi.fn(), error: vi.fn() },
}));
// Import AFTER mocks are registered.
import { useIssueActions } from "../use-issue-actions";
const mockIssue: Issue = {
id: "issue-1",
workspace_id: "ws-1",
number: 1,
identifier: "TES-1",
title: "Example",
description: null,
status: "todo",
priority: "medium",
assignee_type: null,
assignee_id: null,
creator_type: "member",
creator_id: "user-1",
parent_issue_id: null,
due_date: null,
project_id: null,
created_at: "2026-01-01T00:00:00Z",
updated_at: "2026-01-01T00:00:00Z",
} as Issue;
function wrapper({ children }: { children: React.ReactNode }) {
const qc = new QueryClient({
defaultOptions: { queries: { retry: false } },
});
return <QueryClientProvider client={qc}>{children}</QueryClientProvider>;
}
beforeEach(() => {
mockOpenModal.mockReset();
mockUpdateMutate.mockReset();
mockCreatePinMutate.mockReset();
mockDeletePinMutate.mockReset();
pinListRef.value = [];
localStorage.clear();
Object.defineProperty(navigator, "clipboard", {
configurable: true,
value: { writeText: vi.fn().mockResolvedValue(undefined) },
});
});
describe("useIssueActions", () => {
it("updateField dispatches useUpdateIssue.mutate with the correct payload", () => {
const { result } = renderHook(() => useIssueActions(mockIssue), { wrapper });
act(() => {
result.current.updateField({ status: "done" });
});
expect(mockUpdateMutate).toHaveBeenCalledWith(
{ id: "issue-1", status: "done" },
expect.any(Object),
);
});
it("assigning an agent to a backlog issue opens the backlog-hint modal", () => {
const backlogIssue = { ...mockIssue, status: "backlog" } as Issue;
const { result } = renderHook(() => useIssueActions(backlogIssue), { wrapper });
act(() => {
result.current.updateField({
assignee_type: "agent",
assignee_id: "agent-1",
});
});
expect(mockOpenModal).toHaveBeenCalledWith("issue-backlog-agent-hint", {
issueId: "issue-1",
});
});
it("does not re-open backlog-hint when the user has dismissed it", () => {
localStorage.setItem("multica:backlog-agent-hint-dismissed", "true");
const backlogIssue = { ...mockIssue, status: "backlog" } as Issue;
const { result } = renderHook(() => useIssueActions(backlogIssue), { wrapper });
act(() => {
result.current.updateField({
assignee_type: "agent",
assignee_id: "agent-1",
});
});
expect(mockOpenModal).not.toHaveBeenCalled();
});
it("copyLink writes the issue's shareable URL to the clipboard", async () => {
const { result } = renderHook(() => useIssueActions(mockIssue), { wrapper });
await act(async () => {
await result.current.copyLink();
});
expect(navigator.clipboard.writeText).toHaveBeenCalledWith(
"https://app.multica.com/test/issues/issue-1",
);
});
it("openSetParent / openAddChild / openDeleteConfirm / openCreateSubIssue open the correct modal with payload", () => {
const { result } = renderHook(() => useIssueActions(mockIssue), { wrapper });
act(() => {
result.current.openSetParent();
});
expect(mockOpenModal).toHaveBeenLastCalledWith("issue-set-parent", {
issueId: "issue-1",
});
act(() => {
result.current.openAddChild();
});
expect(mockOpenModal).toHaveBeenLastCalledWith("issue-add-child", {
issueId: "issue-1",
});
act(() => {
result.current.openCreateSubIssue();
});
expect(mockOpenModal).toHaveBeenLastCalledWith("create-issue", {
parent_issue_id: "issue-1",
parent_issue_identifier: "TES-1",
});
act(() => {
result.current.openDeleteConfirm({ onDeletedNavigateTo: "/test/issues" });
});
expect(mockOpenModal).toHaveBeenLastCalledWith("issue-delete-confirm", {
issueId: "issue-1",
identifier: "TES-1",
onDeletedNavigateTo: "/test/issues",
});
});
it("togglePin calls createPin when not pinned and deletePin when pinned", async () => {
pinListRef.value = [];
const { result: r1 } = renderHook(() => useIssueActions(mockIssue), { wrapper });
await waitFor(() => {
expect(r1.current.isPinned).toBe(false);
});
act(() => {
r1.current.togglePin();
});
expect(mockCreatePinMutate).toHaveBeenCalledWith({
item_type: "issue",
item_id: "issue-1",
});
expect(mockDeletePinMutate).not.toHaveBeenCalled();
mockCreatePinMutate.mockReset();
mockDeletePinMutate.mockReset();
pinListRef.value = [{ item_type: "issue", item_id: "issue-1" }];
const { result: r2 } = renderHook(() => useIssueActions(mockIssue), { wrapper });
await waitFor(() => {
expect(r2.current.isPinned).toBe(true);
});
act(() => {
r2.current.togglePin();
});
expect(mockDeletePinMutate).toHaveBeenCalledWith({
itemType: "issue",
itemId: "issue-1",
});
expect(mockCreatePinMutate).not.toHaveBeenCalled();
});
it("is a safe no-op when issue is null", () => {
const { result } = renderHook(() => useIssueActions(null), { wrapper });
act(() => {
result.current.updateField({ status: "done" });
result.current.togglePin();
result.current.openSetParent();
});
expect(mockUpdateMutate).not.toHaveBeenCalled();
expect(mockOpenModal).not.toHaveBeenCalled();
});
it("members and filtered agents are exposed on the result", async () => {
const { result } = renderHook(() => useIssueActions(mockIssue), { wrapper });
await waitFor(() => {
expect(result.current.members.length).toBe(1);
});
expect(result.current.members[0]!.user_id).toBe("user-1");
expect(result.current.agents).toEqual([]);
});
});

View File

@@ -0,0 +1,4 @@
export { useIssueActions } from "./use-issue-actions";
export type { UseIssueActionsResult } from "./use-issue-actions";
export { IssueActionsDropdown } from "./issue-actions-dropdown";
export { IssueActionsContextMenu } from "./issue-actions-context-menu";

View File

@@ -0,0 +1,39 @@
"use client";
import type { ReactElement } from "react";
import type { Issue } from "@multica/core/types";
import {
ContextMenu,
ContextMenuTrigger,
ContextMenuContent,
} from "@multica/ui/components/ui/context-menu";
import { useIssueActions } from "./use-issue-actions";
import {
IssueActionsMenuItems,
contextPrimitives,
} from "./issue-actions-menu-items";
interface IssueActionsContextMenuProps {
issue: Issue;
/** A single React element cloned by Base UI as the trigger (via `render` prop). */
children: ReactElement;
}
export function IssueActionsContextMenu({
issue,
children,
}: IssueActionsContextMenuProps) {
const actions = useIssueActions(issue);
return (
<ContextMenu>
<ContextMenuTrigger render={children} />
<ContextMenuContent>
<IssueActionsMenuItems
issue={issue}
actions={actions}
primitives={contextPrimitives}
/>
</ContextMenuContent>
</ContextMenu>
);
}

View File

@@ -0,0 +1,45 @@
"use client";
import type { ReactElement } from "react";
import type { Issue } from "@multica/core/types";
import {
DropdownMenu,
DropdownMenuTrigger,
DropdownMenuContent,
} from "@multica/ui/components/ui/dropdown-menu";
import { useIssueActions } from "./use-issue-actions";
import {
IssueActionsMenuItems,
dropdownPrimitives,
} from "./issue-actions-menu-items";
interface IssueActionsDropdownProps {
issue: Issue;
/** A single React element cloned by Base UI as the trigger (via `render` prop). */
trigger: ReactElement;
align?: "start" | "end" | "center";
/** If set, navigate here after the issue is deleted. */
onDeletedNavigateTo?: string;
}
export function IssueActionsDropdown({
issue,
trigger,
align = "end",
onDeletedNavigateTo,
}: IssueActionsDropdownProps) {
const actions = useIssueActions(issue);
return (
<DropdownMenu>
<DropdownMenuTrigger render={trigger} />
<DropdownMenuContent align={align} className="w-auto">
<IssueActionsMenuItems
issue={issue}
actions={actions}
primitives={dropdownPrimitives}
onDeletedNavigateTo={onDeletedNavigateTo}
/>
</DropdownMenuContent>
</DropdownMenu>
);
}

View File

@@ -0,0 +1,277 @@
"use client";
import {
ArrowDown,
ArrowUp,
Calendar,
Link2,
MoreHorizontal,
Pin,
PinOff,
Plus,
Trash2,
UserMinus,
} from "lucide-react";
import type { Issue } from "@multica/core/types";
import {
ALL_STATUSES,
STATUS_CONFIG,
PRIORITY_ORDER,
PRIORITY_CONFIG,
} from "@multica/core/issues/config";
import { StatusIcon } from "../components/status-icon";
import { PriorityIcon } from "../components/priority-icon";
import { ActorAvatar } from "../../common/actor-avatar";
import {
DropdownMenuItem,
DropdownMenuSub,
DropdownMenuSubTrigger,
DropdownMenuSubContent,
DropdownMenuSeparator,
} from "@multica/ui/components/ui/dropdown-menu";
import {
ContextMenuItem,
ContextMenuSub,
ContextMenuSubTrigger,
ContextMenuSubContent,
ContextMenuSeparator,
} from "@multica/ui/components/ui/context-menu";
import type { UseIssueActionsResult } from "./use-issue-actions";
// Both Dropdown and Context menu wrappers expose an API-compatible surface
// (variant, inset, onClick, etc.). We bundle the primitives we need into a
// single object so `IssueActionsMenuItems` can render the same JSX for both.
export interface MenuPrimitives {
Item: typeof DropdownMenuItem;
Sub: typeof DropdownMenuSub;
SubTrigger: typeof DropdownMenuSubTrigger;
SubContent: typeof DropdownMenuSubContent;
Separator: typeof DropdownMenuSeparator;
}
export const dropdownPrimitives: MenuPrimitives = {
Item: DropdownMenuItem,
Sub: DropdownMenuSub,
SubTrigger: DropdownMenuSubTrigger,
SubContent: DropdownMenuSubContent,
Separator: DropdownMenuSeparator,
};
// Context primitives are API-compatible with Dropdown primitives, but their
// TypeScript identities differ. Cast once here and call it a day — this is the
// single bridge between the two primitive sets.
export const contextPrimitives: MenuPrimitives = {
Item: ContextMenuItem as unknown as typeof DropdownMenuItem,
Sub: ContextMenuSub as unknown as typeof DropdownMenuSub,
SubTrigger: ContextMenuSubTrigger as unknown as typeof DropdownMenuSubTrigger,
SubContent: ContextMenuSubContent as unknown as typeof DropdownMenuSubContent,
Separator: ContextMenuSeparator as unknown as typeof DropdownMenuSeparator,
};
interface IssueActionsMenuItemsProps {
issue: Issue;
actions: UseIssueActionsResult;
primitives: MenuPrimitives;
/** If set, navigate here after the issue is deleted (used by the detail page). */
onDeletedNavigateTo?: string;
}
export function IssueActionsMenuItems({
issue,
actions,
primitives: P,
onDeletedNavigateTo,
}: IssueActionsMenuItemsProps) {
const {
members,
agents,
isPinned,
updateField,
togglePin,
copyLink,
openCreateSubIssue,
openSetParent,
openAddChild,
openDeleteConfirm,
} = actions;
const now = () => new Date();
const inDays = (days: number) => {
const d = new Date();
d.setDate(d.getDate() + days);
return d.toISOString();
};
return (
<>
{/* Status */}
<P.Sub>
<P.SubTrigger>
<StatusIcon status={issue.status} className="h-3.5 w-3.5" />
Status
</P.SubTrigger>
<P.SubContent>
{ALL_STATUSES.map((s) => (
<P.Item key={s} onClick={() => updateField({ status: s })}>
<StatusIcon status={s} className="h-3.5 w-3.5" />
{STATUS_CONFIG[s].label}
{issue.status === s && (
<span className="ml-auto text-xs text-muted-foreground"></span>
)}
</P.Item>
))}
</P.SubContent>
</P.Sub>
{/* Priority */}
<P.Sub>
<P.SubTrigger>
<PriorityIcon priority={issue.priority} />
Priority
</P.SubTrigger>
<P.SubContent>
{PRIORITY_ORDER.map((p) => (
<P.Item key={p} onClick={() => updateField({ priority: p })}>
<span
className={`inline-flex items-center gap-1 rounded px-1.5 py-0.5 text-xs font-medium ${PRIORITY_CONFIG[p].badgeBg} ${PRIORITY_CONFIG[p].badgeText}`}
>
<PriorityIcon priority={p} className="h-3 w-3" inheritColor />
{PRIORITY_CONFIG[p].label}
</span>
{issue.priority === p && (
<span className="ml-auto text-xs text-muted-foreground"></span>
)}
</P.Item>
))}
</P.SubContent>
</P.Sub>
{/* Assignee */}
<P.Sub>
<P.SubTrigger>
<UserMinus className="h-3.5 w-3.5" />
Assignee
</P.SubTrigger>
<P.SubContent>
<P.Item
onClick={() =>
updateField({ assignee_type: null, assignee_id: null })
}
>
<UserMinus className="h-3.5 w-3.5 text-muted-foreground" />
Unassigned
{!issue.assignee_type && (
<span className="ml-auto text-xs text-muted-foreground"></span>
)}
</P.Item>
{members.map((m) => (
<P.Item
key={m.user_id}
onClick={() =>
updateField({ assignee_type: "member", assignee_id: m.user_id })
}
>
<ActorAvatar actorType="member" actorId={m.user_id} size={16} />
{m.name}
{issue.assignee_type === "member" &&
issue.assignee_id === m.user_id && (
<span className="ml-auto text-xs text-muted-foreground"></span>
)}
</P.Item>
))}
{agents.map((a) => (
<P.Item
key={a.id}
onClick={() =>
updateField({ assignee_type: "agent", assignee_id: a.id })
}
>
<ActorAvatar actorType="agent" actorId={a.id} size={16} />
{a.name}
{issue.assignee_type === "agent" && issue.assignee_id === a.id && (
<span className="ml-auto text-xs text-muted-foreground"></span>
)}
</P.Item>
))}
</P.SubContent>
</P.Sub>
{/* Due date */}
<P.Sub>
<P.SubTrigger>
<Calendar className="h-3.5 w-3.5" />
Due date
</P.SubTrigger>
<P.SubContent>
<P.Item onClick={() => updateField({ due_date: now().toISOString() })}>
Today
</P.Item>
<P.Item onClick={() => updateField({ due_date: inDays(1) })}>
Tomorrow
</P.Item>
<P.Item onClick={() => updateField({ due_date: inDays(7) })}>
Next week
</P.Item>
{issue.due_date && (
<>
<P.Separator />
<P.Item onClick={() => updateField({ due_date: null })}>
Clear date
</P.Item>
</>
)}
</P.SubContent>
</P.Sub>
<P.Separator />
<P.Item onClick={togglePin}>
{isPinned ? (
<PinOff className="h-3.5 w-3.5" />
) : (
<Pin className="h-3.5 w-3.5" />
)}
{isPinned ? "Unpin from sidebar" : "Pin to sidebar"}
</P.Item>
<P.Item onClick={copyLink}>
<Link2 className="h-3.5 w-3.5" />
Copy link
</P.Item>
<P.Separator />
{/* Relationship actions live under "More" — they're lower-frequency and
will grow (blocks, duplicates, related) as we add more relation types. */}
<P.Sub>
<P.SubTrigger>
<MoreHorizontal className="h-3.5 w-3.5" />
More
</P.SubTrigger>
<P.SubContent>
<P.Item onClick={openCreateSubIssue}>
<Plus className="h-3.5 w-3.5" />
Create sub-issue
</P.Item>
<P.Item onClick={openSetParent}>
<ArrowUp className="h-3.5 w-3.5" />
Set parent issue...
</P.Item>
<P.Item onClick={openAddChild}>
<ArrowDown className="h-3.5 w-3.5" />
Add sub-issue...
</P.Item>
</P.SubContent>
</P.Sub>
<P.Separator />
<P.Item
variant="destructive"
onClick={() => openDeleteConfirm({ onDeletedNavigateTo })}
>
<Trash2 className="h-3.5 w-3.5" />
Delete issue
</P.Item>
</>
);
}

View File

@@ -0,0 +1,176 @@
"use client";
import { useCallback, useMemo } from "react";
import { useQuery } from "@tanstack/react-query";
import { toast } from "sonner";
import type {
Issue,
MemberWithUser,
Agent,
UpdateIssueRequest,
} from "@multica/core/types";
import { useAuthStore } from "@multica/core/auth";
import { useWorkspaceId } from "@multica/core/hooks";
import { useWorkspacePaths } from "@multica/core/paths";
import { useModalStore } from "@multica/core/modals";
import { useUpdateIssue } from "@multica/core/issues/mutations";
import {
memberListOptions,
agentListOptions,
} from "@multica/core/workspace/queries";
import { pinListOptions, useCreatePin, useDeletePin } from "@multica/core/pins";
import { canAssignAgent } from "../components/pickers";
import { useNavigation } from "../../navigation";
const BACKLOG_HINT_LS_KEY = "multica:backlog-agent-hint-dismissed";
export interface UseIssueActionsResult {
// Derived data for rendering menu rows
members: MemberWithUser[];
agents: Agent[];
isPinned: boolean;
// Handlers
updateField: (updates: Partial<UpdateIssueRequest>) => void;
togglePin: () => void;
copyLink: () => Promise<void>;
openCreateSubIssue: () => void;
openSetParent: () => void;
openAddChild: () => void;
openDeleteConfirm: (opts?: { onDeletedNavigateTo?: string }) => void;
}
/**
* Accepts a nullable issue so callers can invoke the hook before they've
* early-returned on a missing issue. Returned handlers are safe no-ops when
* `issue` is null.
*/
export function useIssueActions(issue: Issue | null): UseIssueActionsResult {
const wsId = useWorkspaceId();
const paths = useWorkspacePaths();
const navigation = useNavigation();
const user = useAuthStore((s) => s.user);
const userId = user?.id;
const { data: members = [] } = useQuery(memberListOptions(wsId));
const { data: agents = [] } = useQuery(agentListOptions(wsId));
const { data: pinnedItems = [] } = useQuery({
...pinListOptions(wsId, userId ?? ""),
enabled: !!userId,
});
const currentMemberRole = useMemo(
() => members.find((m) => m.user_id === userId)?.role,
[members, userId],
);
const filteredAgents = useMemo(
() =>
agents.filter(
(a) => !a.archived_at && canAssignAgent(a, userId, currentMemberRole),
),
[agents, userId, currentMemberRole],
);
const isPinned =
!!issue &&
pinnedItems.some(
(p) => p.item_type === "issue" && p.item_id === issue.id,
);
const updateIssue = useUpdateIssue();
const createPin = useCreatePin();
const deletePin = useDeletePin();
const openModal = useModalStore((s) => s.open);
const issueId = issue?.id ?? null;
const issueStatus = issue?.status ?? null;
const issueIdentifier = issue?.identifier ?? null;
const updateField = useCallback(
(updates: Partial<UpdateIssueRequest>) => {
if (!issueId) return;
updateIssue.mutate(
{ id: issueId, ...updates },
{ onError: () => toast.error("Failed to update issue") },
);
// Hint: assigning an agent to a backlog issue won't trigger execution
// until the issue is moved to an active status.
if (
updates.assignee_type === "agent" &&
updates.assignee_id &&
issueStatus === "backlog" &&
typeof window !== "undefined" &&
localStorage.getItem(BACKLOG_HINT_LS_KEY) !== "true"
) {
openModal("issue-backlog-agent-hint", { issueId });
}
},
[issueId, issueStatus, updateIssue, openModal],
);
const togglePin = useCallback(() => {
if (!issueId) return;
if (isPinned) {
deletePin.mutate({ itemType: "issue", itemId: issueId });
} else {
createPin.mutate({ item_type: "issue", item_id: issueId });
}
}, [isPinned, issueId, createPin, deletePin]);
const copyLink = useCallback(async () => {
if (!issueId) return;
const path = paths.issueDetail(issueId);
const url = navigation.getShareableUrl
? navigation.getShareableUrl(path)
: typeof window !== "undefined"
? window.location.origin + path
: path;
try {
await navigator.clipboard.writeText(url);
toast.success("Link copied");
} catch {
toast.error("Failed to copy link");
}
}, [paths, issueId, navigation]);
const openCreateSubIssue = useCallback(() => {
if (!issueId) return;
openModal("create-issue", {
parent_issue_id: issueId,
parent_issue_identifier: issueIdentifier,
});
}, [openModal, issueId, issueIdentifier]);
const openSetParent = useCallback(() => {
if (!issueId) return;
openModal("issue-set-parent", { issueId });
}, [openModal, issueId]);
const openAddChild = useCallback(() => {
if (!issueId) return;
openModal("issue-add-child", { issueId });
}, [openModal, issueId]);
const openDeleteConfirm = useCallback(
(opts?: { onDeletedNavigateTo?: string }) => {
if (!issueId) return;
openModal("issue-delete-confirm", {
issueId,
identifier: issueIdentifier,
onDeletedNavigateTo: opts?.onDeletedNavigateTo,
});
},
[openModal, issueId, issueIdentifier],
);
return {
members,
agents: filteredAgents,
isPinned,
updateField,
togglePin,
copyLink,
openCreateSubIssue,
openSetParent,
openAddChild,
openDeleteConfirm,
};
}

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