Compare commits

..

37 Commits

Author SHA1 Message Date
Jiang Bohan
948186a069 fix(autopilots): align trash icon with action buttons in webhook trigger row
The TriggerRow's outer flex uses `items-start`, which made sense back
when every trigger only had one row of content (label + maybe a cron
expression). Once #2774 added the URL action row to webhook triggers
(Copy + Rotate buttons sitting on a second line inside the inner column),
the trash button stayed pinned to the top-right of the outer flex — it
visibly floats above the URL action buttons instead of lining up with
them, which reads as a layout glitch.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

---------

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

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

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

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

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

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

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

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

---------

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

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

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

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

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

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

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

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

Addresses Reviewer REQUEST CHANGES on PR #2790:

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

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

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

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

---------

Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>
2026-05-18 16:23:40 +08:00
Ayman Alkurdi
d04b00b32e fix(security): bind all services to loopback in docker-compose files (#2759)
The base docker-compose.yml bound postgres to 0.0.0.0:5432 and
docker-compose.selfhost.yml bound postgres/backend/frontend without
a host_ip prefix — defaulting to 0.0.0.0 on all interfaces.

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

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

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

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

MUL-2337

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

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

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

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

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

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

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

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

---------

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

---------

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

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

The fix has three pieces:

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

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

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

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

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

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

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

Address reviewer feedback on PR #2772:

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

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

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

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

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

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

---------

Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>
2026-05-18 13:44:10 +08:00
Zohar Babin
15152c6ccd feat(auth): cache workspace membership for daemon heartbeat path (MUL-2247) (#2638)
* feat(auth): cache workspace membership for daemon heartbeat path

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

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

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

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

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

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

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

---------

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

Follow-up to #2636. MUL-2251.

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

Key design decisions per reviewer feedback:

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

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

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

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

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

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

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

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

* fix(runtime): tighten mention rule triggers

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

---------

Co-authored-by: Eve <eve@multica-ai.local>
Co-authored-by: multica-agent <github@multica.ai>
2026-05-18 12:52:54 +08:00
Naiyuan Qing
d42fbcb794 fix(editor): sync ContentEditor when defaultValue changes externally (#2765)
* fix(editor): sync ContentEditor when defaultValue changes externally

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

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

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

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

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

Closes #2409

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

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

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

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

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

---------

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

---------

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

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

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

No behavior change.

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

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

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

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

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

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

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

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

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

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

---------

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

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

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

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

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

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

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

Addresses code review on PR #2632.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

* fix(views): hide terminal PR status badges

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

---------

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

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

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

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

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

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

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

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

---------

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

Co-authored-by: multica-agent <github@multica.ai>
2026-05-16 12:45:27 +02:00
258 changed files with 17687 additions and 8087 deletions

View File

@@ -29,6 +29,22 @@ PORT=8080
JWT_SECRET=change-me-in-production
MULTICA_SERVER_URL=ws://localhost:8080/ws
MULTICA_APP_URL=http://localhost:3000
# Public URL the API is reachable at from the open internet (no trailing
# slash). Used to mint absolute webhook URLs for autopilot webhook
# triggers. Leave unset behind a same-origin reverse proxy or for plain
# localhost dev — the frontend will compose the URL from
# window.origin + webhook_path in that case. Headers are intentionally
# not used to derive this value, to avoid Host / X-Forwarded-Host
# spoofing when a self-hosted reverse proxy is not hardened.
MULTICA_PUBLIC_URL=
# Comma-separated CIDR list of reverse proxies whose X-Forwarded-For /
# X-Real-IP headers the per-IP webhook rate limiter is allowed to trust.
# Empty (the default) means "trust no headers" — the limiter uses
# r.RemoteAddr only, which is the safe shape when the backend is
# exposed directly. Set this when running behind nginx/Caddy/Cloudflare:
# e.g. "127.0.0.1/32" for a same-host reverse proxy, or the CDN's
# announced ranges for cloud deployments.
MULTICA_TRUSTED_PROXIES=
MULTICA_DAEMON_CONFIG=
MULTICA_WORKSPACE_ID=
MULTICA_DAEMON_ID=
@@ -103,8 +119,30 @@ LOCAL_UPLOAD_BASE_URL=http://localhost:8080
# Security
# Comma-separated list of allowed origins for CORS and WebSocket connections.
# Defaults to localhost dev origins when unset.
# Example: ALLOWED_ORIGINS=https://app.multica.ai,https://staging.multica.ai
ALLOWED_ORIGINS=
# Example: CORS_ALLOWED_ORIGINS=https://app.multica.ai,https://staging.multica.ai
CORS_ALLOWED_ORIGINS=
# ==================== Rate limiting (optional Redis) ====================
# Per-IP fixed-window rate limiter on the public auth endpoints
# (/auth/send-code, /auth/verify-code, /auth/google). Backed by Redis.
# When REDIS_URL is unset the limiter is a no-op (fail-open) and the
# backend logs "rate limiting disabled: REDIS_URL not configured" at
# startup. The same REDIS_URL is reused by the realtime fan-out hub,
# the PAT cache, and the daemon-token cache.
# REDIS_URL=redis://localhost:6379/0
# Max requests per IP per minute. Defaults are 5 for send-code/google
# and 20 for verify-code.
# RATE_LIMIT_AUTH=5
# RATE_LIMIT_AUTH_VERIFY=20
# Comma-separated CIDRs whose X-Forwarded-For the auth limiter is
# allowed to trust. Empty (default) = never trust XFF, only RemoteAddr.
# REQUIRED behind a reverse proxy — otherwise every real user shares
# the proxy IP and the whole deployment lands in one bucket, turning
# /auth/send-code into 5 req/min site-wide. Use e.g. "127.0.0.1/32,::1/128"
# for same-host Caddy/Nginx, or the CDN's published ranges for ALB/CF.
# This is a separate list from MULTICA_TRUSTED_PROXIES above (which
# governs the autopilot webhook limiter).
# RATE_LIMIT_TRUSTED_PROXIES=
# Realtime metrics endpoint (/health/realtime) access control. See MUL-1342.
# When unset, the endpoint only serves direct loopback (127.0.0.1 / ::1)

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -128,6 +128,25 @@ Three allowlist layers combine by priority. **If any layer is set to a non-empty
**Invite flows themselves do not check the signup allowlist** — but the invitee must still be able to **sign in** before accepting the invite. If they already have a Multica account (for example from another workspace), they can accept directly, unaffected by the allowlist; **if they have never signed up**, the first step of sign-in (requesting a verification code) still passes through the allowlist check, and an email rejected by `ALLOW_SIGNUP=false` or by `ALLOWED_EMAILS` / `ALLOWED_EMAIL_DOMAINS` **cannot finish signup, and therefore cannot accept the invite**.
## Rate limiting (optional Redis)
Public auth endpoints — `/auth/send-code`, `/auth/verify-code`, `/auth/google` — have per-IP fixed-window rate limiting in front of them. The limiter is backed by Redis. When `REDIS_URL` is unset the middleware is a **no-op** (fail-open) and the backend logs `rate limiting disabled: REDIS_URL not configured` at startup.
| Variable | Default | Description |
|---|---|---|
| `REDIS_URL` | empty | Redis connection URL (for example `redis://localhost:6379/0`). When unset, rate limiting on auth endpoints is disabled. The same Redis is also used by the realtime hub fan-out, the PAT cache, and the daemon-token cache — they all fall back to in-memory / direct-DB mode when unset |
| `RATE_LIMIT_AUTH` | `5` | Max requests per IP per minute against `/auth/send-code` and `/auth/google` |
| `RATE_LIMIT_AUTH_VERIFY` | `20` | Max requests per IP per minute against `/auth/verify-code` |
| `RATE_LIMIT_TRUSTED_PROXIES` | empty | Comma-separated CIDRs whose `X-Forwarded-For` header the limiter is allowed to trust. Empty (the default) means **never trust XFF** — the limiter only uses the direct connection's `RemoteAddr` |
When a request is over the limit, the server replies with `429 Too Many Requests`, `Retry-After: 60`, and body `{"error":"too many requests"}`.
<Callout type="warning">
**Behind a reverse proxy you must set `RATE_LIMIT_TRUSTED_PROXIES`.** Otherwise every real user shares the proxy's IP from the backend's point of view, the whole deployment ends up in one bucket, and `/auth/send-code` becomes 5 req/min for the entire site. Typical values: `127.0.0.1/32,::1/128` for a same-host Caddy / Nginx; the CDN's published ranges for Cloudflare / ALB / CloudFront. Only IPs whose `RemoteAddr` falls inside one of these CIDRs may use `X-Forwarded-For` to identify the client.
</Callout>
This separate `RATE_LIMIT_TRUSTED_PROXIES` is **not** the same as `MULTICA_TRUSTED_PROXIES`, which controls the autopilot-webhook limiter (`/api/webhooks/autopilots/{token}`). Each limiter parses its own list, so a deployment behind a proxy should set both.
## Daemon tuning parameters
The daemon runs on the user's local machine, and its config is read from local environment variables too. The common ones:

View File

@@ -128,6 +128,25 @@ Multica 存储用户上传的附件(评论里的图片、文件等)。**优
**邀请流程本身不检查 signup 白名单**——但被邀请人必须先能**登录**才能接受邀请。如果对方已经有 Multica 账号(比如在其他工作区注册过),可以直接接受,不受白名单影响;**如果对方还没注册过**,他们登录的第一步(发送验证码)仍然会过白名单检查,被 `ALLOW_SIGNUP=false` 或 `ALLOWED_EMAILS` / `ALLOWED_EMAIL_DOMAINS` 拒绝的邮箱**无法完成注册,也就没法接受邀请**。
## 速率限制(可选 Redis
公开认证端点——`/auth/send-code`、`/auth/verify-code`、`/auth/google`——前面挂了按 IP 的固定窗口限流。限流器后端是 Redis。`REDIS_URL` 不设时中间件**直通**fail-open后端启动会打日志 `rate limiting disabled: REDIS_URL not configured`。
| 环境变量 | 默认值 | 说明 |
|---|---|---|
| `REDIS_URL` | 空 | Redis 连接 URL例如 `redis://localhost:6379/0`)。不设时认证端点的限流功能直接关闭。同一个 Redis 也被实时事件 fan-out、PAT 缓存、守护进程 token 缓存复用;不设时这些组件分别回落到内存模式 / 直查 DB |
| `RATE_LIMIT_AUTH` | `5` | 单 IP 每分钟对 `/auth/send-code` 和 `/auth/google` 的最大请求数 |
| `RATE_LIMIT_AUTH_VERIFY` | `20` | 单 IP 每分钟对 `/auth/verify-code` 的最大请求数 |
| `RATE_LIMIT_TRUSTED_PROXIES` | 空 | 逗号分隔的 CIDR 列表,列在内的来源 IP 才允许通过 `X-Forwarded-For` 标识客户端。默认空 = **永不信任 XFF**,限流器只看直连的 `RemoteAddr` |
被限流的请求会返回 `429 Too Many Requests`,带 `Retry-After: 60` 头和 `{"error":"too many requests"}` 响应体。
<Callout type="warning">
**部署在反向代理后面时必须设 `RATE_LIMIT_TRUSTED_PROXIES`。** 否则在后端看来所有真实用户都共用代理那个 IP整个部署落到同一个桶里`/auth/send-code` 会变成全站每分钟只能发 5 次。常见值:本机 Caddy / Nginx 用 `127.0.0.1/32,::1/128`Cloudflare / ALB / CloudFront 用各家公开的 CDN IP 段。只有 `RemoteAddr` 落在这些 CIDR 内的请求才被允许通过 `X-Forwarded-For` 改写客户端 IP。
</Callout>
这里的 `RATE_LIMIT_TRUSTED_PROXIES` 和 `MULTICA_TRUSTED_PROXIES` **不是同一个**变量——后者控制的是 autopilot webhook 端点(`/api/webhooks/autopilots/{token}`)的限流器。两个限流器各自读各自的列表,部署在代理后面的实例需要两个都配上。
## 守护进程的调节参数
守护进程跑在用户本地机器上,配置也是读本地环境变量。常用的几个:

View File

@@ -45,6 +45,10 @@ Once it's up:
- **Frontend**: [http://localhost:3000](http://localhost:3000)
- **Backend**: [http://localhost:8080](http://localhost:8080)
<Callout type="info">
**Ports listen on `127.0.0.1` only.** `docker-compose.selfhost.yml` binds every published port to loopback — `ss -tlnp` will not show `0.0.0.0:8080`, and the services are unreachable from other machines by design. The default `JWT_SECRET` and Postgres credentials must never sit on the open internet. For cross-machine access, front the stack with a reverse proxy that terminates TLS — see [Step 5b — Cross-machine: front with a reverse proxy](#5b-cross-machine-front-with-a-reverse-proxy).
</Callout>
## 2. Important: keep production safety on
<Callout type="warning">
@@ -99,21 +103,53 @@ Open [http://localhost:3000](http://localhost:3000):
## 5. Point the CLI at your own server
The CLI install is the same as in [Cloud quickstart → 2. Install the CLI](/cloud-quickstart#2-install-the-multica-cli) — Homebrew / script / PowerShell, pick one. Once installed, **use the self-host variant of the setup command**:
The CLI install is the same as in [Cloud quickstart → 2. Install the CLI](/cloud-quickstart#2-install-the-multica-cli) — Homebrew / script / PowerShell, pick one.
```bash
multica setup self-host --server-url http://<your-server-address>:8080 --app-url http://<your-server-address>:3000
```
### 5a. Same machine
If you're running everything on one local machine:
If the CLI and the server run on the same host, the defaults already work:
```bash
multica setup self-host
```
That defaults to `http://localhost:8080` (backend) and `http://localhost:3000` (frontend).
That points the CLI at `http://localhost:8080` (backend) and `http://localhost:3000` (frontend), takes you through browser login, stores the PAT locally, and **starts the daemon automatically**.
`setup self-host` takes you through browser login, stores the PAT locally, and **starts the daemon automatically**.
### 5b. Cross-machine: front with a reverse proxy
Because the compose stack only listens on `127.0.0.1`, a daemon on a different machine cannot reach `http://<server-ip>:8080` directly — and you do not want it to, since the default `JWT_SECRET` would otherwise be reachable from the open internet. Put a reverse proxy on the server that terminates TLS and forwards to `127.0.0.1:8080` (backend) and `127.0.0.1:3000` (frontend), then point the CLI at the public HTTPS URL:
```bash
multica setup self-host \
--server-url https://<your-domain> \
--app-url https://<your-domain>
```
A minimal Caddyfile that fronts both the frontend and the backend (with WebSocket support, which the daemon and the web app both need) on a single hostname:
```nginx
multica.example.com {
# WebSocket route — must come before the catch-all
@ws path /ws /ws/*
handle @ws {
reverse_proxy 127.0.0.1:8080 {
flush_interval -1
}
}
# Backend API
handle /api/* {
reverse_proxy 127.0.0.1:8080
}
# Everything else → frontend
reverse_proxy 127.0.0.1:3000
}
```
After bringing the proxy up, set `FRONTEND_ORIGIN=https://multica.example.com` in the server's `.env` and restart the backend — otherwise the WebSocket origin check will reject the browser ([Troubleshooting → WebSocket can't connect](/troubleshooting#websocket-cant-connect)).
[Cloudflare Tunnel](https://developers.cloudflare.com/cloudflare-one/connections/connect-networks/) is another solid option — it gives you TLS and a public hostname without exposing any port on the host at all. An Nginx equivalent (separate `app.` / `api.` hostnames, `proxy_set_header Upgrade` for WebSockets) works just as well; the key requirements are TLS termination and forwarding the `Upgrade` header on `/ws`.
## 6. Create an agent + assign your first task

View File

@@ -44,6 +44,10 @@ make selfhost
- **前端**[http://localhost:3000](http://localhost:3000)
- **后端**[http://localhost:8080](http://localhost:8080)
<Callout type="info">
**所有端口只监听 `127.0.0.1`。** `docker-compose.selfhost.yml` 把每个 publish 出来的端口都绑到 loopback —— `ss -tlnp` 不会看到 `0.0.0.0:8080`,外网/其它机器默认根本连不上。这是为了避免默认 `JWT_SECRET` 和 Postgres 凭据被直接暴露到公网。要做跨机访问,请用反向代理在前面终结 TLS详见下方 [Step 5b —— 跨机访问:用反向代理把服务挡在前面](#5b-跨机访问用反向代理把服务挡在前面)。
</Callout>
## 2. 重要:保持生产安全配置
<Callout type="warning">
@@ -98,21 +102,53 @@ RESEND_FROM_EMAIL=noreply@yourdomain.com # 同时作为 SMTP From: 头
## 5. 连接命令行工具到你自己的 server
命令行装法和 [Cloud 快速上手 → 2. 装命令行工具](/cloud-quickstart#2-装-multica-命令行工具) 一样——Homebrew / 脚本 / PowerShell 任选。装好之后,**用 self-host 版本的 setup 命令**
命令行装法和 [Cloud 快速上手 → 2. 装命令行工具](/cloud-quickstart#2-装-multica-命令行工具) 一样——Homebrew / 脚本 / PowerShell 任选。
```bash
multica setup self-host --server-url http://<你的服务器地址>:8080 --app-url http://<你的服务器地址>:3000
```
### 5a. 同一台机器
本地就是一台电脑跑整套的话
CLI 和 server 在同一台机器上时,默认参数就够用
```bash
multica setup self-host
```
默认连 `http://localhost:8080`backend+ `http://localhost:3000`frontend
会自动连 `http://localhost:8080`backend+ `http://localhost:3000`frontend,引导你在浏览器里登录、把 PAT 存到本地、**自动启动守护进程**
`setup self-host` 会让你在浏览器里完成登录,把 PAT 存到本地,**自动启动守护进程**。
### 5b. 跨机访问:用反向代理把服务挡在前面
因为 compose 默认只监听 `127.0.0.1`,从别的机器跑的 daemon 是连不上 `http://<server-ip>:8080` 的——这也是有意为之,否则默认 `JWT_SECRET` 等于直接暴露在公网。正确做法是在 server 上跑一个反向代理Caddy / nginx / Cloudflare Tunnel由它终结 TLS再反代到 `127.0.0.1:8080`backend和 `127.0.0.1:3000`frontend。然后把 CLI 指到公开的 HTTPS 域名:
```bash
multica setup self-host \
--server-url https://<你的域名> \
--app-url https://<你的域名>
```
最小可用的 Caddyfile单域名同时挂前后端带 WebSocket 转发daemon 和网页端都依赖):
```nginx
multica.example.com {
# WebSocket 路由——必须在 catch-all 之前
@ws path /ws /ws/*
handle @ws {
reverse_proxy 127.0.0.1:8080 {
flush_interval -1
}
}
# Backend API
handle /api/* {
reverse_proxy 127.0.0.1:8080
}
# 其它请求 → 前端
reverse_proxy 127.0.0.1:3000
}
```
代理起好之后,记得在 server 的 `.env` 里把 `FRONTEND_ORIGIN` 设成 `https://multica.example.com` 并重启后端,否则 WebSocket 的 origin 校验会把浏览器拒掉(见 [故障排查 → WebSocket 连不上](/troubleshooting#websocket-连不上))。
[Cloudflare Tunnel](https://developers.cloudflare.com/cloudflare-one/connections/connect-networks/) 也是不错的选择——它直接给一个公开域名 + TLShost 上不用对外暴露任何端口。Nginx 也能做(分 `app.` / `api.` 两个域名 + `proxy_set_header Upgrade` 转 WebSocket关键就是终结 TLS、并在 `/ws` 上转发 `Upgrade` 头。
## 6. 创建智能体 + 分配第一个任务

View File

@@ -284,6 +284,34 @@ export function createEnDict(allowSignup: boolean): LandingDict {
fixes: "Bug Fixes",
},
entries: [
{
version: "0.3.2",
date: "2026-05-18",
title:
"Webhook Autopilots, Clearer Workboards & Better Runtime Control",
changes: [],
features: [
"Autopilots can now start from webhook events, show delivery history, and replay a delivery when a connected system needs another attempt",
"Issue boards can group work by assignee, show linked pull request status, and include start dates for clearer planning",
"Runtime pages now have a redesigned machine view plus time and task trends in usage charts",
"Skills can be copied from local runtimes in bulk, making workspace setup faster",
"HTML attachments and HTML code blocks can be previewed directly inside issue discussions",
],
improvements: [
"Failed issue actions now show clearer error messages so teams can understand what happened without digging through logs",
"Agent runs recover more reliably from stuck commands, idle sessions, and long-running work",
"GitHub-linked pull requests now surface CI and merge-conflict status inside Multica",
"Self-hosted deployments get safer defaults and clearer guidance for reverse proxies, auth limits, and local-only services",
"Search results are ranked more usefully and include better snippets",
],
fixes: [
"Autopilot-created issues can repeat reliably and are attributed to the right assignee agent",
"Runtime setup now prefers the local machine by default and uses cleaner labels in machine lists",
"Squad pages scroll correctly and show which members are already working",
"Desktop zoom shortcuts work again across the common keyboard combinations",
"Auth, dependency, and local-service updates improve the safety of hosted and self-hosted deployments",
],
},
{
version: "0.3.1",
date: "2026-05-15",

View File

@@ -284,6 +284,33 @@ export function createZhDict(allowSignup: boolean): LandingDict {
fixes: "问题修复",
},
entries: [
{
version: "0.3.2",
date: "2026-05-18",
title: "Webhook 自动任务、更清晰的工作看板与更稳的运行环境",
changes: [],
features: [
"Autopilot 现在可以由 webhook 事件触发,并能查看投递记录,在外部系统需要时重新投递一次",
"Issue 看板支持按负责人分组,展示关联 Pull Request 状态,并加入开始日期,排期更清楚",
"Runtime 页面升级了机器视图,并在用量图表中加入时间和任务趋势",
"Skills 支持从本地 runtime 批量复制到 workspace团队初始化更快",
"HTML 附件和 HTML 代码块可以直接在 Issue 讨论中预览",
],
improvements: [
"Issue 操作失败时会显示更明确的错误原因,团队不用翻日志也能理解发生了什么",
"Agent 运行在遇到卡住的命令、空闲会话和长时间任务时更容易恢复",
"关联 GitHub 的 Pull Request 会在 Multica 内展示 CI 和合并冲突状态",
"自托管部署获得更安全的默认配置,并补充反向代理、登录限制和本地服务的说明",
"搜索结果排序更准确,也会展示更有帮助的摘要片段",
],
fixes: [
"Autopilot 创建 Issue 时可以稳定重复触发,并正确归属到负责的 assignee agent",
"Runtime 设置默认优先选择本地机器,机器列表中的名称也更清晰",
"Squad 页面可以正常滚动,并能看到成员当前是否已经在处理工作",
"桌面端缩放快捷键在常见组合下恢复正常",
"登录、安全补丁和本地服务配置更新,让托管版和自托管部署都更安全",
],
},
{
version: "0.3.1",
date: "2026-05-15",

View File

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

View File

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

View File

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

View File

@@ -89,6 +89,8 @@ import type {
ListAutopilotsResponse,
GetAutopilotResponse,
ListAutopilotRunsResponse,
ListWebhookDeliveriesResponse,
WebhookDelivery,
NotificationPreferenceResponse,
NotificationPreferences,
GitHubPullRequest,
@@ -96,6 +98,7 @@ import type {
GitHubConnectResponse,
Squad,
SquadMember,
SquadMemberStatusListResponse,
} from "../types";
import type { OnboardingCompletionPath } from "../onboarding/types";
import { type Logger, noopLogger } from "../logger";
@@ -119,11 +122,17 @@ import {
EMPTY_CREATE_AGENT_FROM_TEMPLATE_RESPONSE,
EMPTY_GROUPED_ISSUES_RESPONSE,
EMPTY_LIST_ISSUES_RESPONSE,
EMPTY_SQUAD_MEMBER_STATUS_LIST,
EMPTY_TIMELINE_ENTRIES,
EMPTY_LIST_WEBHOOK_DELIVERIES_RESPONSE,
EMPTY_WEBHOOK_DELIVERY,
GroupedIssuesResponseSchema,
ListIssuesResponseSchema,
ListWebhookDeliveriesResponseSchema,
SquadMemberStatusListResponseSchema,
SubscribersListSchema,
TimelineEntriesSchema,
WebhookDeliveryResponseSchema,
} from "./schemas";
/** Identifies the calling client to the server.
@@ -1538,6 +1547,17 @@ export class ApiClient {
return this.fetch(`/api/squads/${squadId}/members/role`, { method: "PATCH", body: JSON.stringify(data) });
}
// Per-squad members status snapshot: one row per member with derived
// working/idle/offline/unstable plus the issues each agent is currently
// running. Parsed with a lenient schema so a new server-side status
// value or extra field can't white-screen the Squad page (#2143).
async getSquadMemberStatus(squadId: string): Promise<SquadMemberStatusListResponse> {
const raw = await this.fetch<unknown>(`/api/squads/${squadId}/members/status`);
return parseWithFallback(raw, SquadMemberStatusListResponseSchema, EMPTY_SQUAD_MEMBER_STATUS_LIST, {
endpoint: "GET /api/squads/:id/members/status",
}) as SquadMemberStatusListResponse;
}
// Autopilots
async listAutopilots(params?: { status?: string }): Promise<ListAutopilotsResponse> {
const search = new URLSearchParams();
@@ -1578,6 +1598,13 @@ export class ApiClient {
return this.fetch(`/api/autopilots/${id}/runs?${search}`);
}
// Returns a single run including its full trigger_payload. List responses
// omit trigger_payload to keep them small (a webhook envelope can be
// up to 256 KiB × limit rows), so the detail view fetches via this route.
async getAutopilotRun(autopilotId: string, runId: string): Promise<AutopilotRun> {
return this.fetch(`/api/autopilots/${autopilotId}/runs/${runId}`);
}
async createAutopilotTrigger(autopilotId: string, data: CreateAutopilotTriggerRequest): Promise<AutopilotTrigger> {
return this.fetch(`/api/autopilots/${autopilotId}/triggers`, {
method: "POST",
@@ -1596,6 +1623,74 @@ export class ApiClient {
await this.fetch(`/api/autopilots/${autopilotId}/triggers/${triggerId}`, { method: "DELETE" });
}
async rotateAutopilotTriggerWebhookToken(
autopilotId: string,
triggerId: string,
): Promise<AutopilotTrigger> {
return this.fetch(
`/api/autopilots/${autopilotId}/triggers/${triggerId}/rotate-webhook-token`,
{ method: "POST" },
);
}
// Webhook deliveries — list is slim (no raw_body / selected_headers /
// response_body); detail returns the full row. Both responses are parsed
// through a lenient schema so an unknown server-side `status` /
// `signature_status` value degrades to a generic row instead of dropping
// the whole list.
async listAutopilotDeliveries(
autopilotId: string,
params?: { limit?: number; offset?: number },
): Promise<ListWebhookDeliveriesResponse> {
const search = new URLSearchParams();
if (params?.limit) search.set("limit", params.limit.toString());
if (params?.offset) search.set("offset", params.offset.toString());
const raw = await this.fetch<unknown>(
`/api/autopilots/${autopilotId}/deliveries?${search}`,
);
return parseWithFallback(
raw,
ListWebhookDeliveriesResponseSchema,
EMPTY_LIST_WEBHOOK_DELIVERIES_RESPONSE,
{ endpoint: "GET /api/autopilots/:id/deliveries" },
);
}
async getAutopilotDelivery(
autopilotId: string,
deliveryId: string,
): Promise<WebhookDelivery> {
const raw = await this.fetch<unknown>(
`/api/autopilots/${autopilotId}/deliveries/${deliveryId}`,
);
return parseWithFallback(
raw,
WebhookDeliveryResponseSchema,
{ ...EMPTY_WEBHOOK_DELIVERY, id: deliveryId, autopilot_id: autopilotId },
{ endpoint: "GET /api/autopilots/:id/deliveries/:deliveryId" },
);
}
// Replay creates a NEW delivery row referencing the original via
// `replayed_from_delivery_id`. Server rejects replays of
// signature-invalid / rejected deliveries with 400 — the UI keeps the
// button disabled for those rows, but the server is the source of truth.
async replayAutopilotDelivery(
autopilotId: string,
deliveryId: string,
): Promise<WebhookDelivery> {
const raw = await this.fetch<unknown>(
`/api/autopilots/${autopilotId}/deliveries/${deliveryId}/replay`,
{ method: "POST" },
);
return parseWithFallback(
raw,
WebhookDeliveryResponseSchema,
{ ...EMPTY_WEBHOOK_DELIVERY, autopilot_id: autopilotId },
{ endpoint: "POST /api/autopilots/:id/deliveries/:deliveryId/replay" },
);
}
// GitHub integration
async getGitHubConnectURL(workspaceId: string): Promise<GitHubConnectResponse> {
return this.fetch(`/api/workspaces/${workspaceId}/github/connect`);

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -11,13 +11,14 @@ import { defaultStorage } from "../../platform/storage";
export type ViewMode = "board" | "list";
export type IssueGrouping = "status" | "assignee";
export type SortField = "position" | "priority" | "due_date" | "created_at" | "title";
export type SortField = "position" | "priority" | "start_date" | "due_date" | "created_at" | "title";
export type SortDirection = "asc" | "desc";
export interface CardProperties {
priority: boolean;
description: boolean;
assignee: boolean;
startDate: boolean;
dueDate: boolean;
project: boolean;
childProgress: boolean;
@@ -32,6 +33,7 @@ export interface ActorFilterValue {
export const SORT_OPTIONS: { value: SortField; label: string }[] = [
{ value: "position", label: "Manual" },
{ value: "priority", label: "Priority" },
{ value: "start_date", label: "Start date" },
{ value: "due_date", label: "Due date" },
{ value: "created_at", label: "Created date" },
{ value: "title", label: "Title" },
@@ -46,6 +48,7 @@ export const CARD_PROPERTY_OPTIONS: { key: keyof CardProperties; label: string }
{ key: "priority", label: "Priority" },
{ key: "description", label: "Description" },
{ key: "assignee", label: "Assignee" },
{ key: "startDate", label: "Start date" },
{ key: "dueDate", label: "Due date" },
{ key: "project", label: "Project" },
{ key: "labels", label: "Labels" },
@@ -103,6 +106,7 @@ export const viewStoreSlice = (set: StoreApi<IssueViewState>["setState"]): Issue
priority: true,
description: true,
assignee: true,
startDate: true,
dueDate: true,
project: true,
childProgress: true,

View File

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

View File

@@ -15,6 +15,7 @@
"./api": "./api/index.ts",
"./api/client": "./api/client.ts",
"./api/schema": "./api/schema.ts",
"./api/schemas": "./api/schemas.ts",
"./api/ws-client": "./api/ws-client.ts",
"./config": "./config/index.ts",
"./auth": "./auth/index.ts",

View File

@@ -102,8 +102,8 @@ describe("useRealtimeSync — ws instance change", () => {
rerender({ ws: ws2 });
// Should have called invalidateQueries for all workspace-scoped keys
// (11 workspace-scoped + 1 workspaceKeys.list() = 12 calls)
expect(invalidateSpy).toHaveBeenCalledTimes(12);
// (12 workspace-scoped + 1 workspaceKeys.list() = 13 calls)
expect(invalidateSpy).toHaveBeenCalledTimes(13);
});
it("does not re-invalidate when rerendered with the same ws instance", () => {

View File

@@ -119,6 +119,7 @@ function invalidateWorkspaceScopedQueries(qc: QueryClient): void {
qc.invalidateQueries({ queryKey: inboxKeys.all(wsId) });
qc.invalidateQueries({ queryKey: workspaceKeys.agents(wsId) });
qc.invalidateQueries({ queryKey: workspaceKeys.members(wsId) });
qc.invalidateQueries({ queryKey: workspaceKeys.squads(wsId) });
qc.invalidateQueries({ queryKey: workspaceKeys.skills(wsId) });
qc.invalidateQueries({ queryKey: projectKeys.all(wsId) });
qc.invalidateQueries({ queryKey: runtimeKeys.all(wsId) });
@@ -177,7 +178,14 @@ export function useRealtimeSync(
},
agent: () => {
const wsId = getCurrentWsId();
if (wsId) qc.invalidateQueries({ queryKey: workspaceKeys.agents(wsId) });
if (wsId) {
qc.invalidateQueries({ queryKey: workspaceKeys.agents(wsId) });
// Squad members status is derived per agent, so any agent
// change (status flip, archive, runtime swap) needs to refresh
// the per-squad members-status cache. Prefix-matches both the
// squad list and every squadMemberStatus query.
qc.invalidateQueries({ queryKey: workspaceKeys.squads(wsId) });
}
},
member: () => {
const wsId = getCurrentWsId();
@@ -220,7 +228,14 @@ export function useRealtimeSync(
},
daemon: () => {
const wsId = getCurrentWsId();
if (wsId) qc.invalidateQueries({ queryKey: runtimeKeys.all(wsId) });
if (wsId) {
qc.invalidateQueries({ queryKey: runtimeKeys.all(wsId) });
// Runtime online/offline transitions move the derived status
// for every agent that hosts on this runtime, which shifts the
// working/idle/offline pill on the squad page. Same prefix
// invalidation pattern as the agent handler above.
qc.invalidateQueries({ queryKey: workspaceKeys.squads(wsId) });
}
},
autopilot: () => {
const wsId = getCurrentWsId();
@@ -262,6 +277,14 @@ export function useRealtimeSync(
// every list-of-tasks query stale" so cache stays fresh even
// when the relevant component isn't currently mounted.
qc.invalidateQueries({ queryKey: ["issues", "tasks"] });
// Per-issue token usage card (issue-detail right rail). Same
// shape as the tasks invalidation above — any task lifecycle
// event shifts the aggregated usage numbers.
qc.invalidateQueries({ queryKey: ["issues", "usage"] });
// Squad members-status reads the same task lifecycle to flip
// working ↔ idle for each agent member. Prefix-matches every
// mounted squad-page's members-status query in O(1).
qc.invalidateQueries({ queryKey: workspaceKeys.squads(wsId) });
},
};

View File

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

View File

@@ -12,6 +12,7 @@ export interface CreateIssueRequest {
assignee_id?: string;
parent_issue_id?: string;
project_id?: string;
start_date?: string;
due_date?: string;
attachment_ids?: string[];
}
@@ -24,6 +25,7 @@ export interface UpdateIssueRequest {
assignee_type?: IssueAssigneeType | null;
assignee_id?: string | null;
position?: number;
start_date?: string | null;
due_date?: string | null;
parent_issue_id?: string | null;
project_id?: string | null;
@@ -110,6 +112,8 @@ export interface ListIssuesCache {
export interface SearchIssueResult extends Issue {
match_source: "title" | "description" | "comment";
matched_snippet?: string;
matched_description_snippet?: string;
matched_comment_snippet?: string;
}
export interface SearchIssuesResponse {

View File

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

View File

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

View File

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

View File

@@ -79,7 +79,9 @@ export type {
export type { PinnedItem, PinnedItemType, CreatePinRequest, ReorderPinsRequest } from "./pin";
export type {
GitHubInstallation,
GitHubMergeableState,
GitHubPullRequest,
GitHubPullRequestChecksConclusion,
GitHubPullRequestState,
ListGitHubInstallationsResponse,
GitHubConnectResponse,
@@ -100,6 +102,10 @@ export type {
ListAutopilotsResponse,
GetAutopilotResponse,
ListAutopilotRunsResponse,
WebhookDelivery,
WebhookDeliveryStatus,
WebhookSignatureStatus,
ListWebhookDeliveriesResponse,
} from "./autopilot";
export type {
Squad,
@@ -113,4 +119,8 @@ export type {
RemoveSquadMemberRequest,
UpdateSquadMemberRoleRequest,
CreateSquadActivityLogRequest,
SquadMemberStatusValue,
SquadActiveIssueBrief,
SquadMemberStatus,
SquadMemberStatusListResponse,
} from "./squad";

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -185,7 +185,14 @@ export function ActorIssuesPanel({
(issueId: string, updates: Pick<UpdateIssueRequest, "status" | "assignee_type" | "assignee_id" | "position">) => {
updateIssueMutation.mutate(
{ id: issueId, ...updates },
{ onError: () => toast.error(t(($) => $.page.move_failed)) },
{
onError: (err) =>
toast.error(
err instanceof Error && err.message
? err.message
: t(($) => $.page.move_failed),
),
},
);
},
[updateIssueMutation, t],

View File

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

View File

@@ -26,6 +26,11 @@ interface TranscriptButtonProps {
isLive?: boolean;
className?: string;
title?: string;
/**
* Optional content rendered above the transcript event list. Used to
* surface autopilot webhook payloads inline with the run history.
*/
headerSlot?: React.ReactNode;
}
/**
@@ -41,6 +46,7 @@ export function TranscriptButton({
isLive = false,
className,
title = "View transcript",
headerSlot,
}: TranscriptButtonProps) {
const [open, setOpen] = useState(false);
const [loading, setLoading] = useState(false);
@@ -105,6 +111,7 @@ export function TranscriptButton({
items={items}
agentName={agentName}
isLive={isLive}
headerSlot={headerSlot}
/>
)}
</>

View File

@@ -0,0 +1,209 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { fireEvent, render as rtlRender, screen, waitFor } from "@testing-library/react";
import type { ReactElement } from "react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
// vi.hoisted lets us reference the mock from inside the vi.mock factory
// even though the factory hoists above the file's top-level statements.
const { getAttachmentTextContentMock } = vi.hoisted(() => ({
getAttachmentTextContentMock: vi.fn(),
}));
vi.mock("@multica/core/api", () => ({
api: { getAttachmentTextContent: getAttachmentTextContentMock },
PreviewTooLargeError: class extends Error {},
PreviewUnsupportedError: class extends Error {},
}));
vi.mock("../i18n", () => ({
useT: () => ({
t: (sel: (s: Record<string, Record<string, string>>) => string) =>
sel({
image: { download: "Download" },
attachment: {
preview: "Preview",
preview_loading: "Loading preview…",
},
file_card: { uploading: "Uploading {{filename}}" },
}),
}),
}));
import { AttachmentCard } from "./attachment-card";
function render(ui: ReactElement) {
const qc = new QueryClient({
defaultOptions: { queries: { retry: false, gcTime: 0 } },
});
return rtlRender(<QueryClientProvider client={qc}>{ui}</QueryClientProvider>);
}
beforeEach(() => vi.clearAllMocks());
afterEach(() => vi.restoreAllMocks());
describe("AttachmentCard — kind dispatch", () => {
it("renders chrome only for non-html kinds (image, video, other)", () => {
render(
<AttachmentCard
filename="snapshot.png"
contentType="image/png"
attachmentId="att-1"
href="https://cdn.example/snapshot.png"
onPreview={() => {}}
onDownload={() => {}}
/>,
);
expect(screen.getByText("snapshot.png")).toBeTruthy();
// No inline iframe for an image-kind attachment.
expect(document.querySelector("iframe")).toBeNull();
});
it("renders chrome only for an html URL-only source (no attachmentId)", () => {
render(
<AttachmentCard
filename="report.html"
contentType="text/html"
href="https://cdn.example/report.html"
onPreview={() => {}}
onDownload={() => {}}
/>,
);
// Without an attachment id we cannot hit the ID-keyed /content proxy,
// so the card must fall back to chrome-only.
expect(document.querySelector("iframe")).toBeNull();
expect(screen.getByText("report.html")).toBeTruthy();
});
it("hides the Eye button for an html URL-only source (the modal's /content proxy is ID-keyed)", () => {
// Regression: a cross-comment / copy-pasted `!file[report.html](url)`
// used to surface a dead Eye button — the AttachmentCard allowed
// preview when `previewableFromUrl` was true even without an
// attachmentId, but the modal's tryOpen rejects URL-only text kinds
// and the click became a silent no-op.
render(
<AttachmentCard
filename="report.html"
contentType="text/html"
href="https://cdn.example/report.html"
onPreview={() => {}}
onDownload={() => {}}
/>,
);
expect(screen.queryByTitle("Preview")).toBeNull();
// Download stays available — the underlying URL is still reachable.
expect(screen.getByTitle("Download")).toBeTruthy();
});
it("still shows the Eye button for an html source when an attachmentId is available", () => {
getAttachmentTextContentMock.mockResolvedValueOnce({
text: "<p>ok</p>",
originalContentType: "text/html",
});
render(
<AttachmentCard
filename="report.html"
contentType="text/html"
attachmentId="att-1"
href="https://cdn.example/report.html"
onPreview={() => {}}
onDownload={() => {}}
/>,
);
expect(screen.getByTitle("Preview")).toBeTruthy();
});
it("shows the Eye button for a URL-only pdf source (modal renders pdfs directly from URL)", () => {
// Counterpart to the html regression: media kinds (pdf/video/audio)
// ARE URL-previewable because the modal renders them via
// <iframe src=url>/<video>/<audio>, not via the /content proxy.
render(
<AttachmentCard
filename="manual.pdf"
contentType="application/pdf"
href="https://cdn.example/manual.pdf"
onPreview={() => {}}
onDownload={() => {}}
/>,
);
expect(screen.getByTitle("Preview")).toBeTruthy();
});
it("renders an inline iframe with sandbox='allow-scripts' for an HTML attachment", async () => {
getAttachmentTextContentMock.mockResolvedValueOnce({
text: "<p>chart goes here</p>",
originalContentType: "text/html",
});
render(
<AttachmentCard
filename="report.html"
contentType="text/html"
attachmentId="att-1"
href="https://cdn.example/report.html"
onPreview={() => {}}
onDownload={() => {}}
/>,
);
await waitFor(() => {
const frame = document.querySelector("iframe") as HTMLIFrameElement | null;
expect(frame).toBeTruthy();
expect(frame?.getAttribute("sandbox")).toBe("allow-scripts");
expect(frame?.getAttribute("srcdoc")).toBe("<p>chart goes here</p>");
});
});
});
describe("AttachmentCard — Eye / Download buttons", () => {
it("invokes onPreview when Eye is clicked", () => {
const onPreview = vi.fn();
render(
<AttachmentCard
filename="manual.pdf"
contentType="application/pdf"
attachmentId="att-1"
href="https://cdn.example/manual.pdf"
onPreview={onPreview}
onDownload={() => {}}
/>,
);
fireEvent.mouseDown(screen.getByTitle("Preview"));
expect(onPreview).toHaveBeenCalled();
});
it("invokes onDownload when Download is clicked", () => {
const onDownload = vi.fn();
render(
<AttachmentCard
filename="manual.pdf"
contentType="application/pdf"
attachmentId="att-1"
href="https://cdn.example/manual.pdf"
onPreview={() => {}}
onDownload={onDownload}
/>,
);
fireEvent.mouseDown(screen.getByTitle("Download"));
expect(onDownload).toHaveBeenCalled();
});
it("hides the Eye button while uploading and skips the inline HTML preview", () => {
render(
<AttachmentCard
filename="report.html"
contentType="text/html"
attachmentId="att-1"
href="https://cdn.example/report.html"
uploading
onPreview={() => {}}
onDownload={() => {}}
/>,
);
expect(screen.queryByTitle("Preview")).toBeNull();
expect(screen.queryByTitle("Download")).toBeNull();
expect(document.querySelector("iframe")).toBeNull();
// The mock `t()` returns the i18n template as-is; the production t-fn
// interpolates {{filename}} → "report.html". Asserting the template
// proves the uploading branch was selected without depending on the
// interpolation behavior of the mock.
expect(screen.getByText("Uploading {{filename}}")).toBeTruthy();
});
});

View File

@@ -0,0 +1,228 @@
"use client";
/**
* AttachmentCard — shared attachment row UI used by every entry point that
* renders a non-image attachment in the editor surface.
*
* Three call sites:
* 1. `extensions/file-card.tsx` — Tiptap NodeView for `!file[name](url)`
* inline in markdown.
* 2. `readonly-content.tsx` — readonly file-card `<div data-type="fileCard">`
* branch, rendered through preprocessMarkdown.
* 3. `comment-card.tsx` `AttachmentList` — standalone attachments that were
* not referenced by URL inside the markdown body.
*
* Centralizing this avoids the third-instance trap: every previous attempt to
* add a feature here had to be added in three places, and dropping one
* silently re-introduced the bug — MUL-2330's HTML chart was a standalone
* attachment, so the inline HTML preview only works if THIS path is covered.
*
* HTML kind extension:
* - When the attachment is HTML and the caller can provide an
* `attachmentId` (i.e. the attachment record is known — required for the
* ID-keyed `/api/attachments/{id}/content` proxy), the card mounts an
* inline `CodeBlockIframe` underneath the row to render the HTML body
* directly. Loading errors and 413/415 cases collapse back to the bare
* row + Eye/Download buttons.
* - For non-HTML kinds (or HTML where we only have a URL), the card looks
* and behaves exactly like the previous handwritten rows.
*/
import { Download, Eye, FileText, Loader2 } from "lucide-react";
import { useT } from "../i18n";
import { getPreviewKind } from "./utils/preview";
import { CodeBlockIframe } from "./code-block-iframe";
import { useAttachmentHtmlText } from "./hooks/use-attachment-html-text";
// ---------------------------------------------------------------------------
// Inline HTML preview body
// ---------------------------------------------------------------------------
// Fixed height per the V2 plan; auto-resize via postMessage handshake is
// explicitly out of scope for V1.
const INLINE_HTML_HEIGHT = "h-[480px]";
function InlineHtmlIframe({
attachmentId,
filename,
}: {
attachmentId: string;
filename: string;
}) {
const { t } = useT("editor");
const query = useAttachmentHtmlText(attachmentId);
if (query.isLoading) {
return (
<div className="mt-1 flex h-[480px] items-center justify-center gap-2 rounded-md border border-border bg-muted/30 text-xs text-muted-foreground">
<Loader2 className="size-3.5 animate-spin" />
{t(($) => $.attachment.preview_loading)}
</div>
);
}
// Any error path (413 / 415 / transport) — fall back silently. The
// surrounding card still offers Eye → modal (which surfaces the typed
// error) and Download as escape hatches.
if (query.error || !query.data) return null;
return (
<div className="mt-1">
<CodeBlockIframe
html={query.data.text}
title={filename}
heightClassName={INLINE_HTML_HEIGHT}
/>
</div>
);
}
// ---------------------------------------------------------------------------
// Card chrome — icon + filename + optional Eye + Download
// ---------------------------------------------------------------------------
interface AttachmentCardChromeProps {
filename: string;
uploading?: boolean;
canPreview: boolean;
canDownload: boolean;
onPreview: () => void;
onDownload: () => void;
}
function AttachmentCardChrome({
filename,
uploading,
canPreview,
canDownload,
onPreview,
onDownload,
}: AttachmentCardChromeProps) {
const { t } = useT("editor");
return (
<div
className="flex items-center gap-2 rounded-md border border-border bg-muted/50 px-2.5 py-1 transition-colors hover:bg-muted"
onMouseDown={(e) => e.stopPropagation()}
>
{uploading ? (
<Loader2 className="size-4 shrink-0 animate-spin text-muted-foreground" />
) : (
<FileText className="size-4 shrink-0 text-muted-foreground" />
)}
<div className="min-w-0 flex-1">
<p className="truncate text-sm">
{uploading
? t(($) => $.file_card.uploading, { filename })
: filename}
</p>
</div>
{!uploading && canPreview && (
<button
type="button"
className="shrink-0 rounded-md p-1 text-muted-foreground transition-colors hover:bg-secondary hover:text-foreground"
title={t(($) => $.attachment.preview)}
aria-label={t(($) => $.attachment.preview)}
onMouseDown={(e) => {
e.preventDefault();
e.stopPropagation();
onPreview();
}}
>
<Eye className="size-3.5" />
</button>
)}
{!uploading && canDownload && (
<button
type="button"
className="shrink-0 rounded-md p-1 text-muted-foreground transition-colors hover:bg-secondary hover:text-foreground"
title={t(($) => $.image.download)}
aria-label={t(($) => $.image.download)}
onMouseDown={(e) => {
e.preventDefault();
e.stopPropagation();
onDownload();
}}
>
<Download className="size-3.5" />
</button>
)}
</div>
);
}
// ---------------------------------------------------------------------------
// AttachmentCard — public component
// ---------------------------------------------------------------------------
export interface AttachmentCardProps {
/** Filename used for icon label and previewable-kind detection. */
filename: string;
/** Content type used in addition to filename for previewable-kind detection. */
contentType?: string;
/**
* Attachment id — required for HTML inline rendering (the `/content`
* proxy is ID-keyed). Undefined means we only have a URL (e.g. a
* cross-comment `!file[]()` reference) — the card still renders, the
* HTML iframe just doesn't expand.
*/
attachmentId?: string;
/** Download URL — used purely as a non-null sentinel for the download button. */
href?: string;
/** True while a synchronous upload is in flight (file-card NodeView only). */
uploading?: boolean;
/** Pressed when the Eye button is clicked. */
onPreview: () => void;
/** Pressed when the Download button is clicked. */
onDownload: () => void;
/**
* Set to false to disable the HTML inline preview branch (and behave like
* the legacy chrome-only card). Useful for editor NodeViews while a draft
* upload is still in flight.
*/
inlineHtmlEnabled?: boolean;
}
export function AttachmentCard({
filename,
contentType = "",
attachmentId,
href,
uploading,
onPreview,
onDownload,
inlineHtmlEnabled = true,
}: AttachmentCardProps) {
const kind = filename ? getPreviewKind(contentType, filename) : null;
// Media kinds (pdf/video/audio) are previewable from a URL alone — the
// modal renders them as <video>/<audio>/<iframe src=url>. Text kinds
// (markdown/html/text) need the ID-keyed `/api/attachments/{id}/content`
// proxy, so they only preview when we have an attachmentId — otherwise
// the Eye button would call tryOpen, get rejected, and do nothing.
const isUrlPreviewableKind =
kind === "pdf" || kind === "video" || kind === "audio";
const canPreview =
!!href && kind !== null && (!!attachmentId || isUrlPreviewableKind);
// Mount the inline iframe only when we can hit the /content proxy
// (attachmentId present) AND kind is HTML AND no upload is in flight.
const showInlineHtml =
inlineHtmlEnabled &&
!uploading &&
kind === "html" &&
!!attachmentId;
return (
<div className="my-1">
<AttachmentCardChrome
filename={filename}
uploading={uploading}
canPreview={canPreview}
canDownload={!!href}
onPreview={onPreview}
onDownload={onDownload}
/>
{showInlineHtml && (
<InlineHtmlIframe attachmentId={attachmentId!} filename={filename} />
)}
</div>
);
}

View File

@@ -159,7 +159,7 @@ describe("AttachmentPreviewModal — dispatch", () => {
expect(screen.getByTestId("readonly-content").textContent).toContain("# heading");
});
it("renders an iframe with srcdoc + sandbox='' for HTML", async () => {
it("renders an iframe with srcdoc + sandbox='allow-scripts' for HTML", async () => {
getAttachmentTextContentMock.mockResolvedValueOnce({
text: "<p>hi</p>",
originalContentType: "text/html",
@@ -170,7 +170,10 @@ describe("AttachmentPreviewModal — dispatch", () => {
await waitFor(() => {
const frame = document.querySelector("iframe[sandbox]") as HTMLIFrameElement | null;
expect(frame).toBeTruthy();
expect(frame?.getAttribute("sandbox")).toBe("");
// `allow-scripts` is required so vanilla-JS chart libraries render
// (MUL-2330). The combination with `allow-same-origin` would defeat
// the sandbox, so this assertion must stay exact.
expect(frame?.getAttribute("sandbox")).toBe("allow-scripts");
expect(frame?.getAttribute("srcdoc")).toBe("<p>hi</p>");
});
});

View File

@@ -15,10 +15,12 @@
* - markdown : fetch text via api.getAttachmentTextContent, render via
* the existing ReadonlyContent (full mention/mermaid/katex
* pipeline included).
* - html : fetch text, hand to <iframe srcdoc={text} sandbox="">.
* Empty sandbox attribute = max restriction (no scripts,
* no forms, no top-nav, no popups, no same-origin) — the
* recommended pattern for previewing untrusted HTML.
* - html : fetch text, hand to <iframe srcdoc={text}
* sandbox="allow-scripts">. The iframe runs in an opaque
* origin: scripts execute (chart libraries / vanilla SVG
* JS work), but cookie / localStorage / parent access /
* top-navigation / popups / forms stay blocked because
* `allow-same-origin` is intentionally NOT included.
* - text : fetch text, highlight with lowlight if the extension
* maps to a known hljs language; otherwise plain <pre>.
*
@@ -35,17 +37,11 @@ import {
type ReactNode,
} from "react";
import { createPortal } from "react-dom";
import { useQuery } from "@tanstack/react-query";
import { Download, FileText, Loader2, X } from "lucide-react";
import { createLowlight, common } from "lowlight";
// @ts-expect-error -- hast-util-to-html has no bundled type declarations
import { toHtml } from "hast-util-to-html";
import { cn } from "@multica/ui/lib/utils";
import {
api,
PreviewTooLargeError,
PreviewUnsupportedError,
} from "@multica/core/api";
import { Download, FileText, Loader2, X } from "lucide-react";
import type { Attachment } from "@multica/core/types";
import { useT } from "../i18n";
import { openExternal } from "../platform";
@@ -56,6 +52,8 @@ import {
type PreviewKind,
} from "./utils/preview";
import { useDownloadAttachment } from "./use-download-attachment";
import { useAttachmentHtmlText } from "./hooks/use-attachment-html-text";
import { CodeBlockStatic } from "./code-block-static";
// ---------------------------------------------------------------------------
// Preview source — full attachment, or URL-only (media types only)
@@ -355,7 +353,12 @@ function PreviewContent({
render={(text) => (
<iframe
srcDoc={text}
sandbox=""
// `allow-scripts` without `allow-same-origin` — scripts run
// in an opaque origin and cannot read cookies / localStorage
// / parent state, nor escape via top-nav / popups / forms.
// Required so JS-driven charts (echarts / Plotly / vanilla
// SVG injection) render instead of showing a blank `<svg>`.
sandbox="allow-scripts"
className="h-full w-full bg-background"
title={state.filename}
/>
@@ -368,7 +371,11 @@ function PreviewContent({
attachmentId={state.attachmentId!}
onDownload={onDownload}
render={(text) => (
<CodeBlock language={extensionToLanguage(state.filename)} body={text} />
<CodeBlockStatic
language={extensionToLanguage(state.filename)}
body={text}
className="px-6 py-4"
/>
)}
/>
);
@@ -393,19 +400,7 @@ function TextBackedPreview({
render: (text: string) => ReactNode;
}) {
const { t } = useT("editor");
const query = useQuery({
queryKey: ["attachment-content", attachmentId] as const,
queryFn: () => api.getAttachmentTextContent(attachmentId),
// Errors are surfaced as typed fallbacks, not retried — 413 / 415 won't
// become 200 on a retry, and a transient failure is easier to recover
// from by closing and reopening the modal than waiting on background
// retries that have no UI affordance.
retry: false,
// 413 / 415 bodies are tiny; keep the result around for the session so
// the user can flip away and back without refetching.
staleTime: 5 * 60_000,
gcTime: 30 * 60_000,
});
const query = useAttachmentHtmlText(attachmentId);
if (query.isLoading) {
return (
@@ -443,44 +438,6 @@ function TextBackedPreview({
return <>{render(query.data.text)}</>;
}
// ---------------------------------------------------------------------------
// Code block — lowlight, matches readonly-content's hljs CSS
// ---------------------------------------------------------------------------
const lowlight = createLowlight(common);
function CodeBlock({ language, body }: { language: string | undefined; body: string }) {
const html = useMemo(() => {
const code = body.replace(/\n$/, "");
try {
const tree = language
? lowlight.highlight(language, code)
: lowlight.highlightAuto(code);
return toHtml(tree) as string;
} catch {
// Fallthrough to a plain escaped <pre> when lowlight rejects the
// language tag. Avoids crashing the preview on an unknown extension.
return escapeHtml(code);
}
}, [body, language]);
return (
<pre className="rich-text-editor m-0 overflow-auto px-6 py-4 text-sm">
<code
className={cn("hljs", language && `language-${language}`)}
dangerouslySetInnerHTML={{ __html: html }}
/>
</pre>
);
}
function escapeHtml(s: string): string {
return s
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;");
}
// ---------------------------------------------------------------------------
// Fallback — used for 413 / 415 / unknown kinds
// ---------------------------------------------------------------------------

View File

@@ -406,8 +406,12 @@ function CreateSubIssueButton({
)
.run();
toast.success(t(($) => $.bubble_menu.sub_issue.created, { identifier: newIssue.identifier }));
} catch {
toast.error(t(($) => $.bubble_menu.sub_issue.create_failed));
} catch (err) {
toast.error(
err instanceof Error && err.message
? err.message
: t(($) => $.bubble_menu.sub_issue.create_failed),
);
} finally {
setPending(false);
}

View File

@@ -0,0 +1,58 @@
"use client";
/**
* Shared HTML preview iframe.
*
* Used by:
* - InlineHtmlIframe inside AttachmentCard (HTML attachments inline preview)
* - CodeBlockView for fenced ```html blocks (editable Tiptap NodeView)
* - HtmlBlockPreview for fenced ```html blocks (ReadonlyContent)
* - AttachmentPreviewModal's full-screen HTML kind
*
* Sandbox semantics:
* sandbox="allow-scripts" (NOT "allow-same-origin")
* → iframe runs in an opaque origin: scripts execute (chart JS works),
* but cookie / localStorage / parent access / top-nav / popups / forms
* remain blocked. This is the standard "preview untrusted HTML" model
* (HTML spec §iframe sandbox, MDN, Claude artifacts, v0.dev preview).
*
* The server-side `text/plain` + `nosniff` defense at
* /api/attachments/{id}/content remains untouched — we only feed iframe.srcDoc
* the text body we fetched, never point iframe.src at the proxy URL.
*/
import { cn } from "@multica/ui/lib/utils";
interface CodeBlockIframeProps {
/** Document source for srcDoc. Empty string renders a blank frame. */
html: string;
/** Iframe title for accessibility. */
title: string;
className?: string;
/** Tailwind height token; defaults to h-[320px]. */
heightClassName?: string;
}
export function CodeBlockIframe({
html,
title,
className,
heightClassName = "h-[320px]",
}: CodeBlockIframeProps) {
return (
<iframe
// srcDoc keeps the body in the parent's process but isolated to an
// opaque origin via sandbox. Critical that we never combine
// `allow-scripts` with `allow-same-origin` — that pairing defeats the
// sandbox per the HTML spec (notes on the sandbox attribute).
srcDoc={html}
sandbox="allow-scripts"
title={title}
className={cn(
"w-full rounded-md border border-border bg-background",
heightClassName,
className,
)}
/>
);
}

View File

@@ -0,0 +1,57 @@
"use client";
/**
* CodeBlockStatic — read-only lowlight-highlighted code block.
*
* Used by:
* - AttachmentPreviewModal's text-kind fallback (extracted from there).
* - HtmlBlockPreview's "source" toggle in ReadonlyContent.
*
* NOT used by Tiptap's editable code-block NodeView: that path must keep
* `<NodeViewContent as="code" />` so the user can continue typing into the
* code block. Replacing it with a static lowlight component would freeze
* the content and desync ProseMirror state from the DOM.
*/
import { useMemo } from "react";
import { createLowlight, common } from "lowlight";
// @ts-expect-error -- hast-util-to-html has no bundled type declarations
import { toHtml } from "hast-util-to-html";
import { cn } from "@multica/ui/lib/utils";
const lowlight = createLowlight(common);
interface CodeBlockStaticProps {
language: string | undefined;
body: string;
className?: string;
}
export function CodeBlockStatic({ language, body, className }: CodeBlockStaticProps) {
const html = useMemo(() => {
const code = body.replace(/\n$/, "");
try {
const tree = language
? lowlight.highlight(language, code)
: lowlight.highlightAuto(code);
return toHtml(tree) as string;
} catch {
// Unknown language tag — fall back to escaped plain text so we don't
// crash on an esoteric extension.
return escapeHtml(code);
}
}, [body, language]);
return (
<pre className={cn("rich-text-editor m-0 overflow-auto text-sm", className)}>
<code
className={cn("hljs", language && `language-${language}`)}
dangerouslySetInnerHTML={{ __html: html }}
/>
</pre>
);
}
function escapeHtml(s: string): string {
return s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
}

View File

@@ -2,6 +2,13 @@ import { describe, it, expect, vi, beforeEach } from "vitest";
import { fireEvent, render, screen } from "@testing-library/react";
const mockFocus = vi.hoisted(() => vi.fn());
const mockSetContent = vi.hoisted(() => vi.fn());
const mockSetTextSelection = vi.hoisted(() => vi.fn());
const editorState = vi.hoisted(() => ({
isFocused: false,
isDestroyed: false,
markdown: "",
}));
vi.mock("@tanstack/react-query", () => ({
useQueryClient: () => ({}),
@@ -23,24 +30,38 @@ vi.mock("./bubble-menu", () => ({
EditorBubbleMenu: () => null,
}));
const editorRef = vi.hoisted<{ current: unknown }>(() => ({ current: null }));
const onCreateFired = vi.hoisted(() => ({ value: false }));
vi.mock("@tiptap/react", () => ({
useEditor: () => ({
commands: {
focus: mockFocus,
clearContent: vi.fn(),
},
getMarkdown: () => "",
state: {
doc: {
content: {
size: 0,
useEditor: (options: { onCreate?: (args: { editor: unknown }) => void }) => {
if (!editorRef.current) {
editorRef.current = {
get isFocused() {
return editorState.isFocused;
},
},
selection: {
empty: true,
},
},
}),
get isDestroyed() {
return editorState.isDestroyed;
},
commands: {
focus: mockFocus,
clearContent: vi.fn(),
setContent: mockSetContent,
setTextSelection: mockSetTextSelection,
},
getMarkdown: () => editorState.markdown,
state: {
doc: { content: { size: 0 } },
selection: { empty: true, from: 0, to: 0 },
},
};
}
if (!onCreateFired.value) {
onCreateFired.value = true;
options?.onCreate?.({ editor: editorRef.current });
}
return editorRef.current;
},
EditorContent: ({ className }: { className?: string }) => (
<div className={className} data-testid="editor-content">
<div className="ProseMirror rich-text-editor" data-testid="prosemirror" />
@@ -53,6 +74,11 @@ import { ContentEditor } from "./content-editor";
describe("ContentEditor", () => {
beforeEach(() => {
vi.clearAllMocks();
editorState.isFocused = false;
editorState.isDestroyed = false;
editorState.markdown = "";
editorRef.current = null;
onCreateFired.value = false;
});
it("focuses the editor when clicking the empty container area", () => {
@@ -73,4 +99,89 @@ describe("ContentEditor", () => {
expect(mockFocus).not.toHaveBeenCalled();
});
it("syncs editor content when defaultValue changes externally and editor is unfocused", () => {
editorState.markdown = "old content";
const { rerender } = render(<ContentEditor defaultValue="old content" />);
expect(mockSetContent).not.toHaveBeenCalled();
// Editor still holds the old, in-sync content; external value changes.
editorState.markdown = "old content";
rerender(<ContentEditor defaultValue="new content from server" />);
expect(mockSetContent).toHaveBeenCalledTimes(1);
expect(mockSetContent).toHaveBeenCalledWith(
"new content from server",
expect.objectContaining({ emitUpdate: false, contentType: "markdown" }),
);
});
it("does not sync when editor is focused and has unsaved local edits", () => {
editorState.markdown = "old content";
const { rerender } = render(<ContentEditor defaultValue="old content" />);
// User is typing — focused AND dirty (markdown diverges from
// lastEmittedRef, which was seeded with "old content" by onCreate).
editorState.isFocused = true;
editorState.markdown = "user-typed-content";
rerender(<ContentEditor defaultValue="incoming external change" />);
expect(mockSetContent).not.toHaveBeenCalled();
});
it("syncs even when editor is focused, as long as it is clean (focused-but-clean must not be permanently dropped)", () => {
// This case is the regression test for the focused-but-clean hole:
// user clicks into the editor (focused = true) but types nothing
// (markdown still equals lastEmittedRef). An external update arrives.
// With an unconditional `if (isFocused) return`, this sync would be lost
// forever because onBlur has no replay path.
editorState.markdown = "old content";
const { rerender } = render(<ContentEditor defaultValue="old content" />);
editorState.isFocused = true;
editorState.markdown = "old content"; // clean — no typing happened
rerender(<ContentEditor defaultValue="new content from server" />);
expect(mockSetContent).toHaveBeenCalledTimes(1);
expect(mockSetContent).toHaveBeenCalledWith(
"new content from server",
expect.objectContaining({ emitUpdate: false, contentType: "markdown" }),
);
});
it("does not sync when editor is unfocused but has unsaved local edits (blur-before-debounce window)", () => {
editorState.markdown = "old content";
const { rerender } = render(
<ContentEditor defaultValue="old content" onUpdate={() => {}} />,
);
// User typed locally, then blurred. Debounce hasn't flushed yet so
// lastEmittedRef inside the component still reflects "old content".
editorState.isFocused = false;
editorState.markdown = "user typed but unsaved";
rerender(
<ContentEditor
defaultValue="external update from another agent"
onUpdate={() => {}}
/>,
);
expect(mockSetContent).not.toHaveBeenCalled();
});
it("does not sync when defaultValue normalizes to the current editor markdown", () => {
editorState.markdown = "same content";
const { rerender } = render(<ContentEditor defaultValue="same content" />);
// Different `defaultValue` string forces the effect to re-run (the dep
// array sees a new value), but the trailing whitespace normalises away
// via `trimEnd()`, so `setContent` must still short-circuit.
rerender(<ContentEditor defaultValue={"same content\n"} />);
expect(mockSetContent).not.toHaveBeenCalled();
});
});

View File

@@ -225,6 +225,59 @@ const ContentEditor = forwardRef<ContentEditorRef, ContentEditorProps>(
};
}, []);
// Sync external `defaultValue` changes into the editor.
// Tiptap v3 `useEditor` reads `content` only at mount (ueberdosis/tiptap#5831);
// without this effect, a WS-driven description update keeps the editor
// showing stale content until the issue is closed and reopened.
useEffect(() => {
if (!editor || editor.isDestroyed) return;
const current = stripBlobUrls(editor.getMarkdown()).trimEnd();
// "Dirty" = user has local edits not yet flushed through the debounced
// `onUpdate`. `lastEmittedRef` is advanced only after a debounce fire,
// so a divergence means the editor holds unsaved bytes.
const isDirty =
lastEmittedRef.current !== null && current !== lastEmittedRef.current;
// Guard 1: focused AND dirty — protect bytes the user is actively
// typing. Focused-but-clean falls through: applying setContent is safe
// (no user input to lose) and necessary, because onBlur has no replay
// mechanism and a focused clean editor would otherwise drop this sync
// permanently.
if (editor.isFocused && isDirty) return;
// Guard 2: unfocused-but-dirty — blur happened but the debounce window
// (debounceMs, 1500ms for description) hasn't flushed yet. The pending
// onUpdate will reach the server and the cache will reconcile; skipping
// here avoids overwriting unsaved local edits.
if (isDirty) return;
const incoming = defaultValue ? preprocessMarkdown(defaultValue) : "";
const incomingNormalized = stripBlobUrls(incoming).trimEnd();
// Guard 3: normalized-equal short-circuit. Avoids a no-op transaction
// when the cache reflects a write this same editor just emitted.
if (incomingNormalized === current) return;
// Guard 4: `emitUpdate: false`. Tiptap v3's setContent defaults to
// `emitUpdate: true`; without this we would re-trigger onUpdate →
// server save → self-write loop.
const { from, to } = editor.state.selection;
editor.commands.setContent(incoming, {
emitUpdate: false,
contentType: "markdown",
});
// Clamp prior selection to the new doc size so the caret doesn't snap
// to position 0 after ProseMirror replaces the document.
const docSize = editor.state.doc.content.size;
editor.commands.setTextSelection({
from: Math.min(from, docSize),
to: Math.min(to, docSize),
});
lastEmittedRef.current = stripBlobUrls(editor.getMarkdown()).trimEnd();
}, [defaultValue, editor]);
useImperativeHandle(ref, () => ({
getMarkdown: () => stripBlobUrls(editor?.getMarkdown() ?? ""),
clearContent: () => {

View File

@@ -0,0 +1,98 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { act, fireEvent, render, screen } from "@testing-library/react";
// Tiptap's NodeView primitives are hard to instantiate in jsdom without a
// full editor. Stub them so the test can render <CodeBlockView /> as a plain
// React component and inspect the resulting DOM shape.
vi.mock("@tiptap/react", () => {
const NodeViewWrapper = ({ children, ...rest }: any) => (
<div data-testid="nvw" {...rest}>
{children}
</div>
);
// The real NodeViewContent renders an element managed by ProseMirror. For
// the test it's enough to surface a sentinel element so we can assert it
// remains mounted while CSS-hidden.
const NodeViewContent = ({ as = "div", ...rest }: any) => {
const Tag = as;
return <Tag data-testid="nvc" {...rest} />;
};
return { NodeViewWrapper, NodeViewContent };
});
vi.mock("../mermaid-diagram", () => ({
MermaidDiagram: () => null,
}));
vi.mock("../../i18n", () => ({
useT: () => ({
t: (sel: (s: Record<string, Record<string, string>>) => string) =>
sel({
code_block: {
copy_code: "Copy code",
show_preview: "Show preview",
show_source: "Show source",
},
}),
}),
}));
import { CodeBlockView } from "./code-block-view";
function makeProps(language: string, text: string) {
return {
node: {
attrs: { language },
textContent: text,
},
} as unknown as Parameters<typeof CodeBlockView>[0];
}
describe("CodeBlockView — html language toggle", () => {
// Inner async timers in useDebouncedValue make the iframe srcDoc lag by
// ~200ms; use fake timers so the test stays deterministic.
beforeEach(() => {
vi.useFakeTimers({ shouldAdvanceTime: true });
});
afterEach(() => {
vi.useRealTimers();
});
it("defaults to preview view: renders an iframe with sandbox='allow-scripts' and keeps the <pre> mounted (hidden)", () => {
render(<CodeBlockView {...makeProps("html", "<p>hello</p>")} />);
act(() => {
vi.advanceTimersByTime(250);
});
const frame = document.querySelector("iframe");
expect(frame).toBeTruthy();
expect(frame?.getAttribute("sandbox")).toBe("allow-scripts");
// NodeViewContent (and its enclosing <pre>) MUST remain mounted —
// unmounting would break Tiptap's bindings and prevent editing.
const nvc = screen.getByTestId("nvc");
expect(nvc).toBeTruthy();
const pre = nvc.closest("pre");
expect(pre).toBeTruthy();
expect(pre?.className).toContain("sr-only");
});
it("toggles to source view: iframe is removed and the <pre> is no longer hidden", () => {
render(<CodeBlockView {...makeProps("html", "<p>hello</p>")} />);
act(() => {
vi.advanceTimersByTime(250);
});
expect(document.querySelector("iframe")).toBeTruthy();
const toggle = screen.getByTitle("Show source");
fireEvent.click(toggle);
expect(document.querySelector("iframe")).toBeNull();
const nvc = screen.getByTestId("nvc");
const pre = nvc.closest("pre")!;
expect(pre.className).not.toContain("sr-only");
});
it("does not show the toggle or an iframe for a non-html language", () => {
render(<CodeBlockView {...makeProps("typescript", "const x = 1;")} />);
expect(screen.queryByTitle("Show source")).toBeNull();
expect(screen.queryByTitle("Show preview")).toBeNull();
expect(document.querySelector("iframe")).toBeNull();
});
});

View File

@@ -3,16 +3,22 @@
import { useEffect, useState } from "react";
import { NodeViewWrapper, NodeViewContent } from "@tiptap/react";
import type { NodeViewProps } from "@tiptap/react";
import { Copy, Check } from "lucide-react";
import { Code as CodeIcon, Copy, Check, Eye } from "lucide-react";
import { cn } from "@multica/ui/lib/utils";
import { useT } from "../../i18n";
import { MermaidDiagram } from "../mermaid-diagram";
import { CodeBlockIframe } from "../code-block-iframe";
// Coalesces fast keystrokes before re-rendering the Mermaid preview.
// Coalesces fast keystrokes before re-rendering live previews.
// `mermaid.initialize()` mutates a process-global config, so back-to-back
// renders during typing can race a concurrent ReadonlyContent render
// (e.g. a comment card) and clobber its theme variables. 200ms keeps the
// "live preview" feel while making concurrent inits unlikely in practice.
const MERMAID_PREVIEW_DEBOUNCE_MS = 200;
// HTML preview reuses the same debounce: re-keying iframe.srcDoc on every
// keystroke causes the iframe to re-load and flicker.
const PREVIEW_DEBOUNCE_MS = 200;
const HTML_PREVIEW_HEIGHT = "h-[320px]";
function useDebouncedValue<T>(value: T, delayMs: number): T {
const [debounced, setDebounced] = useState(value);
@@ -26,12 +32,22 @@ function useDebouncedValue<T>(value: T, delayMs: number): T {
function CodeBlockView({ node }: NodeViewProps) {
const { t } = useT("editor");
const [copied, setCopied] = useState(false);
// HTML blocks default to "preview"; the user can flip to "source" to
// edit the markup directly. Note: the source `<pre>` MUST stay mounted
// (just hidden) so ProseMirror keeps its NodeView bindings — unmounting
// it would break editing.
const [view, setView] = useState<"preview" | "source">("preview");
const language = node.attrs.language || "";
const isMermaid = language === "mermaid";
const isHtml = language === "html";
const chart = node.textContent;
const debouncedChart = useDebouncedValue(
isMermaid ? chart : "",
MERMAID_PREVIEW_DEBOUNCE_MS,
PREVIEW_DEBOUNCE_MS,
);
const debouncedHtml = useDebouncedValue(
isHtml ? chart : "",
PREVIEW_DEBOUNCE_MS,
);
const handleCopy = async () => {
@@ -42,6 +58,10 @@ function CodeBlockView({ node }: NodeViewProps) {
setTimeout(() => setCopied(false), 2000);
};
const showHtmlPreview = isHtml && view === "preview";
const toggleView = () =>
setView((v) => (v === "preview" ? "source" : "preview"));
return (
<NodeViewWrapper className="code-block-wrapper group/code relative my-2">
{isMermaid && debouncedChart.trim() && (
@@ -52,6 +72,18 @@ function CodeBlockView({ node }: NodeViewProps) {
<MermaidDiagram chart={debouncedChart} />
</div>
)}
{isHtml && showHtmlPreview && (
// CSS-hidden when toggled off so the `<pre>` below stays mounted —
// unmounting either side would either lose ProseMirror bindings
// (source) or thrash iframe.srcDoc (preview).
<div contentEditable={false} className="mb-1">
<CodeBlockIframe
html={debouncedHtml}
title="HTML preview"
heightClassName={HTML_PREVIEW_HEIGHT}
/>
</div>
)}
<div
contentEditable={false}
className="code-block-header absolute top-0 right-0 z-10 flex items-center gap-1.5 px-2 py-1.5 opacity-0 transition-opacity group-hover/code:opacity-100"
@@ -61,6 +93,29 @@ function CodeBlockView({ node }: NodeViewProps) {
{language}
</span>
)}
{isHtml && (
<button
type="button"
onClick={toggleView}
className="flex h-6 w-6 items-center justify-center rounded text-muted-foreground hover:bg-muted hover:text-foreground transition-colors"
title={
view === "preview"
? t(($) => $.code_block.show_source)
: t(($) => $.code_block.show_preview)
}
aria-label={
view === "preview"
? t(($) => $.code_block.show_source)
: t(($) => $.code_block.show_preview)
}
>
{view === "preview" ? (
<CodeIcon className="h-3.5 w-3.5" />
) : (
<Eye className="h-3.5 w-3.5" />
)}
</button>
)}
<button
type="button"
onClick={handleCopy}
@@ -74,7 +129,14 @@ function CodeBlockView({ node }: NodeViewProps) {
)}
</button>
</div>
<pre spellCheck={false}>
{/* `<pre>` + NodeViewContent must remain mounted so the user can keep
editing the code block contents. When the HTML preview is showing
we just visually hide it — ProseMirror still tracks it. */}
<pre
spellCheck={false}
className={cn(showHtmlPreview && "sr-only")}
aria-hidden={showHtmlPreview ? "true" : undefined}
>
{/* @ts-expect-error -- NodeViewContent supports as="code" at runtime */}
<NodeViewContent as="code" />
</pre>

View File

@@ -17,51 +17,33 @@
import { Node, mergeAttributes } from "@tiptap/core";
import { ReactNodeViewRenderer, NodeViewWrapper } from "@tiptap/react";
import type { NodeViewProps } from "@tiptap/react";
import { Eye, FileText, Loader2, Download } from "lucide-react";
import { FILE_CARD_URL_PATTERN } from "@multica/ui/markdown";
import { useT } from "../../i18n";
import { useAttachmentDownloadResolver } from "../attachment-download-context";
import { useAttachmentPreview } from "../attachment-preview-modal";
import { getPreviewKind } from "../utils/preview";
import { AttachmentCard } from "../attachment-card";
const FILE_CARD_MARKDOWN_RE = new RegExp(
`^!file\\[([^\\]]*)\\]\\((${FILE_CARD_URL_PATTERN.source})\\)`,
);
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
// ---------------------------------------------------------------------------
// React NodeView
// ---------------------------------------------------------------------------
function FileCardView({ node }: NodeViewProps) {
const { t } = useT("editor");
const href = (node.attrs.href as string) || "";
const filename = (node.attrs.filename as string) || "";
const uploading = node.attrs.uploading as boolean;
const { openByUrl, resolveAttachment } = useAttachmentDownloadResolver();
const preview = useAttachmentPreview();
const openFile = () => {
openByUrl(href);
};
// Preview gate mirrors the Download gate (href is enough). We attempt
// to resolve the full Attachment from the surrounding provider, but its
// absence is no longer fatal — media kinds (pdf/video/audio) only need
// the URL, so they remain previewable even when `resolveAttachment`
// misses (e.g. the URL was copy-pasted across comments and isn't in the
// current entity's attachments). Text kinds still require the id because
// the preview proxy is ID-keyed.
// Preview gate widens to "anything that can be downloaded AND whose
// filename is a previewable type". Media kinds remain previewable when the
// attachment record isn't reachable (e.g. URL was copy-pasted across
// comments). Text kinds (markdown / html / text) need the id because the
// preview proxy is ID-keyed.
const attachment = href ? resolveAttachment(href) : undefined;
const kind = filename
? getPreviewKind(attachment?.content_type ?? "", filename)
: null;
const isMediaKind = kind === "pdf" || kind === "video" || kind === "audio";
const canPreview = !!href && kind !== null && (!!attachment || isMediaKind);
const openPreview = () => {
if (attachment) {
@@ -73,49 +55,16 @@ function FileCardView({ node }: NodeViewProps) {
return (
<NodeViewWrapper as="div" className="file-card-node" data-type="fileCard">
<div
className="my-1 flex items-center gap-2 rounded-md border border-border bg-muted/50 px-2.5 py-1 transition-colors hover:bg-muted"
contentEditable={false}
onMouseDown={(e) => e.stopPropagation()}
>
{uploading ? (
<Loader2 className="size-4 shrink-0 animate-spin text-muted-foreground" />
) : (
<FileText className="size-4 shrink-0 text-muted-foreground" />
)}
<div className="min-w-0 flex-1">
<p className="truncate text-sm">{uploading ? t(($) => $.file_card.uploading, { filename }) : filename}</p>
</div>
{!uploading && canPreview && (
<button
type="button"
className="shrink-0 rounded-md p-1 text-muted-foreground transition-colors hover:bg-secondary hover:text-foreground"
title={t(($) => $.attachment.preview)}
aria-label={t(($) => $.attachment.preview)}
onMouseDown={(e) => {
e.preventDefault();
e.stopPropagation();
openPreview();
}}
>
<Eye className="size-3.5" />
</button>
)}
{!uploading && href && (
<button
type="button"
className="shrink-0 rounded-md p-1 text-muted-foreground transition-colors hover:bg-secondary hover:text-foreground"
title={t(($) => $.image.download)}
aria-label={t(($) => $.image.download)}
onMouseDown={(e) => {
e.preventDefault();
e.stopPropagation();
openFile();
}}
>
<Download className="size-3.5" />
</button>
)}
<div contentEditable={false}>
<AttachmentCard
filename={filename}
contentType={attachment?.content_type ?? ""}
attachmentId={attachment?.id}
href={href}
uploading={uploading}
onPreview={openPreview}
onDownload={() => openByUrl(href)}
/>
</div>
{preview.modal}
</NodeViewWrapper>

View File

@@ -0,0 +1,29 @@
"use client";
/**
* Shared React Query for fetching attachment text bodies via the
* `/api/attachments/{id}/content` proxy.
*
* Same retry / staleTime / gcTime policy as AttachmentPreviewModal's local
* TextBackedPreview, lifted out so the modal and the inline `AttachmentCard`
* (file-card NodeView / readonly file-card / standalone AttachmentList) hit
* the same cache key — opening the modal after the inline preview already
* loaded a body does not refetch.
*/
import { useQuery } from "@tanstack/react-query";
import { api } from "@multica/core/api";
export function useAttachmentHtmlText(attachmentId: string | null | undefined) {
return useQuery({
queryKey: ["attachment-content", attachmentId ?? ""] as const,
queryFn: () => api.getAttachmentTextContent(attachmentId as string),
enabled: !!attachmentId,
// 413 / 415 won't become 200 on retry; a transport error is easier to
// recover from by re-opening than waiting on background retries with
// no UI affordance.
retry: false,
staleTime: 5 * 60_000,
gcTime: 30 * 60_000,
});
}

View File

@@ -0,0 +1,108 @@
"use client";
/**
* HtmlBlockPreview — readonly rendering of fenced ```html code blocks.
*
* Default view is "preview" (iframe) per the V2 plan; user can flip to
* "source" to see the highlighted markup and Copy it.
*
* Mounted by ReadonlyContent's `code` renderer for `lang === "html"`. The
* `pre` renderer in ReadonlyContent recognizes this component by reference
* and unwraps it from the default `<pre>` envelope, matching the same
* two-layer trick already used for MermaidDiagram.
*
* NOT used in the editable Tiptap NodeView — that path must keep
* `<NodeViewContent as="code" />` so the user can continue typing.
*/
import { useState } from "react";
import { Check, Code as CodeIcon, Copy, Eye } from "lucide-react";
import { cn } from "@multica/ui/lib/utils";
import { useT } from "../i18n";
import { CodeBlockIframe } from "./code-block-iframe";
import { CodeBlockStatic } from "./code-block-static";
const CODE_BLOCK_IFRAME_HEIGHT = "h-[320px]";
// Label shown in the code-block header. Not a translatable string — it's a
// language identifier (matches the `lang === "html"` token below).
const HTML_LANGUAGE_LABEL = "html";
interface HtmlBlockPreviewProps {
html: string;
className?: string;
}
export function HtmlBlockPreview({ html, className }: HtmlBlockPreviewProps) {
const { t } = useT("editor");
const [view, setView] = useState<"preview" | "source">("preview");
const [copied, setCopied] = useState(false);
const handleCopy = async () => {
if (!html) return;
try {
await navigator.clipboard.writeText(html);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
} catch {
// Clipboard failures are user-recoverable (click again, or copy
// manually from the source view) — no need for a toast here.
}
};
const toggleView = () =>
setView((v) => (v === "preview" ? "source" : "preview"));
return (
<div className={cn("code-block-wrapper group/code relative my-2", className)}>
<div
className="absolute top-0 right-0 z-10 flex items-center gap-1.5 px-2 py-1.5 opacity-0 transition-opacity group-hover/code:opacity-100"
>
<span className="text-xs text-muted-foreground select-none">{HTML_LANGUAGE_LABEL}</span>
<button
type="button"
onClick={toggleView}
className="flex h-6 w-6 items-center justify-center rounded text-muted-foreground hover:bg-muted hover:text-foreground transition-colors"
title={
view === "preview"
? t(($) => $.code_block.show_source)
: t(($) => $.code_block.show_preview)
}
aria-label={
view === "preview"
? t(($) => $.code_block.show_source)
: t(($) => $.code_block.show_preview)
}
>
{view === "preview" ? (
<CodeIcon className="h-3.5 w-3.5" />
) : (
<Eye className="h-3.5 w-3.5" />
)}
</button>
<button
type="button"
onClick={handleCopy}
className="flex h-6 w-6 items-center justify-center rounded text-muted-foreground hover:bg-muted hover:text-foreground transition-colors"
title={t(($) => $.code_block.copy_code)}
aria-label={t(($) => $.code_block.copy_code)}
>
{copied ? (
<Check className="h-3.5 w-3.5" />
) : (
<Copy className="h-3.5 w-3.5" />
)}
</button>
</div>
{view === "preview" ? (
<CodeBlockIframe
html={html}
title="HTML preview"
heightClassName={CODE_BLOCK_IFRAME_HEIGHT}
/>
) : (
<CodeBlockStatic language="xml" body={html} />
)}
</div>
);
}

View File

@@ -20,3 +20,5 @@ export {
isPreviewable,
} from "./attachment-preview-modal";
export type { AttachmentPreviewHandle } from "./attachment-preview-modal";
export { AttachmentCard } from "./attachment-card";
export type { AttachmentCardProps } from "./attachment-card";

View File

@@ -153,6 +153,23 @@ describe("ReadonlyContent Mermaid rendering", () => {
);
});
it("does not regress Mermaid unwrap after the HtmlBlockPreview branch was added", async () => {
// Both Mermaid and HtmlBlockPreview rely on react-markdown's `code`
// renderer returning a non-<code> React element, and on the `pre`
// renderer recognizing the element by reference and unwrapping it. If
// someone tightens the `pre` check to a single component, the other
// one quietly regresses into a `<pre>`-wrapped DOM. This test pins the
// contract.
const { container } = render(
<ReadonlyContent
content={["```mermaid", "graph LR", " A --> B", "```"].join("\n")}
/>,
);
expect(container.querySelector(".mermaid-diagram")).not.toBeNull();
// No outer <pre> envelope.
expect(container.querySelector("pre")).toBeNull();
});
it("opens a fullscreen lightbox when the toolbar button is clicked", async () => {
const { container } = render(
<ReadonlyContent
@@ -186,3 +203,53 @@ describe("ReadonlyContent Mermaid rendering", () => {
});
});
});
describe("ReadonlyContent HTML block rendering", () => {
// `language=html` fenced blocks should default to a preview iframe with
// sandbox="allow-scripts" (chart JS executes in an opaque origin) and
// must NOT be wrapped by react-markdown's default <pre>, which would
// clamp the iframe with monospace / overflow styles. The two-layer
// code+pre unwrap mirror's Mermaid's pattern.
it("renders an iframe with sandbox='allow-scripts' for ```html and skips the outer <pre>", () => {
const { container } = render(
<ReadonlyContent
content={["```html", '<h1 id="x">hi</h1>', "```"].join("\n")}
/>,
);
const frame = container.querySelector<HTMLIFrameElement>("iframe");
expect(frame).not.toBeNull();
expect(frame?.getAttribute("sandbox")).toBe("allow-scripts");
expect(frame?.getAttribute("srcdoc")).toContain('<h1 id="x">hi</h1>');
expect(container.querySelector("pre")).toBeNull();
});
it("keeps the <pre><code> wrapper for adjacent languages like htmlbars / mermaidx", () => {
// Regression: the previous `className.includes("language-html")` check
// matched `language-htmlbars` too, so an htmlbars fence lost its outer
// <pre> envelope and rendered as bare lowlight-highlighted spans. The
// unwrap rule must match the exact class token, not a prefix.
const { container } = render(
<ReadonlyContent
content={[
"```htmlbars",
"<div>{{name}}</div>",
"```",
"",
"```mermaidx",
"not a real lang",
"```",
].join("\n")}
/>,
);
const pres = container.querySelectorAll("pre");
// Both fences keep their <pre> wrapper.
expect(pres.length).toBe(2);
// And the inner <code> still carries the original language class.
expect(
container.querySelector("pre code.language-htmlbars"),
).not.toBeNull();
expect(
container.querySelector("pre code.language-mermaidx"),
).not.toBeNull();
});
});

View File

@@ -30,7 +30,7 @@ import rehypeSanitize, { defaultSchema } from "rehype-sanitize";
import { createLowlight, common } from "lowlight";
// @ts-expect-error -- hast-util-to-html has no bundled type declarations
import { toHtml } from "hast-util-to-html";
import { Maximize2, Download, Eye, Link as LinkIcon, FileText } from "lucide-react";
import { Maximize2, Download, Link as LinkIcon } from "lucide-react";
import { toast } from "sonner";
import { cn } from "@multica/ui/lib/utils";
import { useWorkspacePaths, useWorkspaceSlug } from "@multica/core/paths";
@@ -45,9 +45,10 @@ import { openLink, isMentionHref } from "./utils/link-handler";
import { isAllowedFileCardHref } from "@multica/ui/markdown";
import { preprocessMarkdown } from "./utils/preprocess";
import { MermaidDiagram } from "./mermaid-diagram";
import { HtmlBlockPreview } from "./html-block-preview";
import { useDownloadAttachment } from "./use-download-attachment";
import { useAttachmentPreview, type PreviewSource } from "./attachment-preview-modal";
import { getPreviewKind } from "./utils/preview";
import { AttachmentCard } from "./attachment-card";
import "katex/dist/katex.min.css";
import "./content-editor.css";
@@ -57,6 +58,13 @@ import "./content-editor.css";
const lowlight = createLowlight(common);
// Code fences that the `code` renderer returns as a non-<code> React element
// (Mermaid diagram, HTML preview iframe). The `pre` renderer below unwraps
// these so the default <pre><code> envelope doesn't clamp their styles.
// Anchored to whole class tokens so `language-htmlbars` / `language-mermaidx`
// don't accidentally match and lose their <pre> wrapper.
const PRE_UNWRAP_RE = /(^|\s)language-(html|mermaid)(\s|$)/;
// ---------------------------------------------------------------------------
// Sanitization schema — extends GitHub defaults to allow file-card data attrs
// ---------------------------------------------------------------------------
@@ -235,10 +243,10 @@ function ReadonlyImage({
);
}
// Inline file card — same download semantics as the standalone attachment
// list: fresh-sign through `useDownloadAttachment` when the href matches a
// known attachment, otherwise hand the raw URL to the platform's external
// opener.
// Inline file card — wraps the shared AttachmentCard with the same
// download semantics as the standalone attachment list: fresh-sign through
// `useDownloadAttachment` when the href matches a known attachment,
// otherwise hand the raw URL to the platform's external opener.
function ReadonlyFileCard({
href,
filename,
@@ -252,16 +260,7 @@ function ReadonlyFileCard({
onDownload: (attachmentId: string) => void;
onPreview: (source: PreviewSource) => boolean;
}) {
const { t } = useT("editor");
const attachment = href ? resolveAttachment(href) : undefined;
// Mirror file-card.tsx (NodeView) — preview gate widens to "anything that
// can be downloaded AND whose filename is a previewable type". Media kinds
// fall through to URL-only when the attachment record isn't reachable.
const kind = filename
? getPreviewKind(attachment?.content_type ?? "", filename)
: null;
const isMediaKind = kind === "pdf" || kind === "video" || kind === "audio";
const canPreview = !!href && kind !== null && (!!attachment || isMediaKind);
const handleDownloadClick = () => {
if (attachment) {
onDownload(attachment.id);
@@ -277,34 +276,14 @@ function ReadonlyFileCard({
}
};
return (
<div className="my-1 flex items-center gap-2 rounded-md border border-border bg-muted/50 px-2.5 py-1 transition-colors hover:bg-muted">
<FileText className="size-4 shrink-0 text-muted-foreground" />
<div className="min-w-0 flex-1">
<p className="truncate text-sm">{filename}</p>
</div>
{canPreview && (
<button
type="button"
className="shrink-0 rounded-md p-1 text-muted-foreground transition-colors hover:bg-secondary hover:text-foreground"
title={t(($) => $.attachment.preview)}
aria-label={t(($) => $.attachment.preview)}
onClick={handlePreviewClick}
>
<Eye className="size-3.5" />
</button>
)}
{href && (
<button
type="button"
className="shrink-0 rounded-md p-1 text-muted-foreground transition-colors hover:bg-secondary hover:text-foreground"
title={t(($) => $.image.download)}
aria-label={t(($) => $.image.download)}
onClick={handleDownloadClick}
>
<Download className="size-3.5" />
</button>
)}
</div>
<AttachmentCard
filename={filename}
contentType={attachment?.content_type ?? ""}
attachmentId={attachment?.id}
href={href}
onPreview={handlePreviewClick}
onDownload={handleDownloadClick}
/>
);
}
@@ -365,6 +344,13 @@ function buildComponents(
if (isBlock && lang === "mermaid") {
return <MermaidDiagram chart={String(children).replace(/\n$/, "")} />;
}
if (isBlock && lang === "html") {
// Like Mermaid, return the React element directly here and rely on
// the `pre` renderer below to unwrap it — react-markdown otherwise
// wraps `code` children in a `<pre>` whose monospace + overflow
// styles would clamp the preview iframe.
return <HtmlBlockPreview html={String(children).replace(/\n$/, "")} />;
}
if (!isBlock && !lang) {
// Inline code — CSS handles styling via .rich-text-editor code
@@ -393,10 +379,26 @@ function buildComponents(
}
},
// Pre — pass through (CSS handles styling via .rich-text-editor pre)
// Pre — pass through (CSS handles styling via .rich-text-editor pre).
// Special-case Mermaid / HtmlBlockPreview returned from the `code`
// renderer above so the outer `<pre>` does not wrap them — this is the
// standard two-layer pattern used to escape react-markdown's default
// `<pre><code>` envelope.
pre: ({ children }) => {
if (isValidElement(children) && children.type === MermaidDiagram) {
return <>{children}</>;
// react-markdown calls `pre` BEFORE invoking the `code` renderer —
// `children` is the unrendered `<code>` element from the AST. So we
// identify "this block was meant to be unwrapped" by inspecting the
// child's className (`language-mermaid`, `language-html`), not by
// checking `children.type === MermaidDiagram`, which never matches.
//
// Match by exact class token: a substring `includes("language-html")`
// would also fire on neighboring languages like `language-htmlbars`
// and silently strip their <pre> wrapper.
if (isValidElement(children)) {
const childProps = children.props as { className?: string };
if (PRE_UNWRAP_RE.test(childProps.className ?? "")) {
return <>{children}</>;
}
}
return <pre>{children}</pre>;
},

View File

@@ -18,6 +18,7 @@ export function useTypeLabels(): Record<InboxItemType, string> {
assignee_changed: t(($) => $.types.assignee_changed),
status_changed: t(($) => $.types.status_changed),
priority_changed: t(($) => $.types.priority_changed),
start_date_changed: t(($) => $.types.start_date_changed),
due_date_changed: t(($) => $.types.due_date_changed),
new_comment: t(($) => $.types.new_comment),
mentioned: t(($) => $.types.mentioned),
@@ -83,6 +84,10 @@ export function InboxDetailLabel({ item }: { item: InboxItem }) {
}
return <span>{typeLabels[item.type]}</span>;
}
case "start_date_changed": {
if (details.to) return <span>{t(($) => $.labels.set_start_date_to, { date: shortDate(details.to) })}</span>;
return <span>{t(($) => $.labels.removed_start_date)}</span>;
}
case "due_date_changed": {
if (details.to) return <span>{t(($) => $.labels.set_due_date_to, { date: shortDate(details.to) })}</span>;
return <span>{t(($) => $.labels.removed_due_date)}</span>;

View File

@@ -136,7 +136,12 @@ export function InboxPage() {
useEffect(() => {
if (!selectedId || selectedRead) return;
markReadMutate(selectedId, {
onError: () => toast.error(t(($) => $.errors.mark_read_failed)),
onError: (err) =>
toast.error(
err instanceof Error && err.message
? err.message
: t(($) => $.errors.mark_read_failed),
),
});
}, [selectedId, selectedRead, markReadMutate, t]);
@@ -157,21 +162,36 @@ export function InboxPage() {
setSelectedKey(next ? (next.issue_id ?? next.id) : "");
}
archiveMutation.mutate(id, {
onError: () => toast.error(t(($) => $.errors.archive_failed)),
onError: (err) =>
toast.error(
err instanceof Error && err.message
? err.message
: t(($) => $.errors.archive_failed),
),
});
};
// Batch operations
const handleMarkAllRead = () => {
markAllReadMutation.mutate(undefined, {
onError: () => toast.error(t(($) => $.errors.mark_all_read_failed)),
onError: (err) =>
toast.error(
err instanceof Error && err.message
? err.message
: t(($) => $.errors.mark_all_read_failed),
),
});
};
const handleArchiveAll = () => {
setSelectedKey("");
archiveAllMutation.mutate(undefined, {
onError: () => toast.error(t(($) => $.errors.archive_all_failed)),
onError: (err) =>
toast.error(
err instanceof Error && err.message
? err.message
: t(($) => $.errors.archive_all_failed),
),
});
};
@@ -179,14 +199,24 @@ export function InboxPage() {
const readKeys = items.filter((i) => i.read).map((i) => i.issue_id ?? i.id);
if (readKeys.includes(selectedKey)) setSelectedKey("");
archiveAllReadMutation.mutate(undefined, {
onError: () => toast.error(t(($) => $.errors.archive_all_read_failed)),
onError: (err) =>
toast.error(
err instanceof Error && err.message
? err.message
: t(($) => $.errors.archive_all_read_failed),
),
});
};
const handleArchiveCompleted = () => {
setSelectedKey("");
archiveCompletedMutation.mutate(undefined, {
onError: () => toast.error(t(($) => $.errors.archive_completed_failed)),
onError: (err) =>
toast.error(
err instanceof Error && err.message
? err.message
: t(($) => $.errors.archive_completed_failed),
),
});
};

View File

@@ -122,6 +122,7 @@ const mockIssue: Issue = {
creator_type: "member",
creator_id: "user-1",
parent_issue_id: null,
start_date: null,
due_date: null,
project_id: null,
created_at: "2026-01-01T00:00:00Z",

View File

@@ -90,6 +90,7 @@ const mockIssue: Issue = {
creator_type: "member",
creator_id: "user-1",
parent_issue_id: null,
start_date: null,
due_date: null,
project_id: null,
created_at: "2026-01-01T00:00:00Z",

View File

@@ -7,6 +7,7 @@ import {
ArrowDown,
ArrowUp,
Calendar,
CalendarClock,
FolderOpen,
Link2,
MoreHorizontal,
@@ -197,6 +198,33 @@ export function IssueActionsMenuItems({
{t(($) => $.actions.assignee)}
</P.Item>
{/* Start date */}
<P.Sub>
<P.SubTrigger>
<CalendarClock className="h-3.5 w-3.5" />
{t(($) => $.actions.start_date)}
</P.SubTrigger>
<P.SubContent>
<P.Item onClick={() => updateField({ start_date: now().toISOString() })}>
{t(($) => $.actions.start_today)}
</P.Item>
<P.Item onClick={() => updateField({ start_date: inDays(1) })}>
{t(($) => $.actions.start_tomorrow)}
</P.Item>
<P.Item onClick={() => updateField({ start_date: inDays(7) })}>
{t(($) => $.actions.start_next_week)}
</P.Item>
{issue.start_date && (
<>
<P.Separator />
<P.Item onClick={() => updateField({ start_date: null })}>
{t(($) => $.actions.start_clear)}
</P.Item>
</>
)}
</P.SubContent>
</P.Sub>
{/* Due date */}
<P.Sub>
<P.SubTrigger>

View File

@@ -65,7 +65,14 @@ export function useIssueActions(issue: Issue | null): UseIssueActionsResult {
if (!issueId) return;
updateIssue.mutate(
{ id: issueId, ...updates },
{ onError: () => toast.error(t(($) => $.detail.update_failed)) },
{
onError: (err) =>
toast.error(
err instanceof Error && err.message
? err.message
: t(($) => $.detail.update_failed),
),
},
);
// Hint: assigning an agent to a backlog issue won't trigger execution
// until the issue is moved to an active status.

View File

@@ -53,8 +53,12 @@ export function BatchActionToolbar({
try {
await batchUpdate.mutateAsync({ ids, updates });
toast.success(t(($) => $.batch.update_success, { count }));
} catch {
toast.error(t(($) => $.batch.update_failed));
} catch (err) {
toast.error(
err instanceof Error && err.message
? err.message
: t(($) => $.batch.update_failed),
);
}
};
@@ -63,8 +67,12 @@ export function BatchActionToolbar({
await batchDelete.mutateAsync(ids);
clear();
toast.success(t(($) => $.batch.delete_success, { count }));
} catch {
toast.error(t(($) => $.batch.delete_failed));
} catch (err) {
toast.error(
err instanceof Error && err.message
? err.message
: t(($) => $.batch.delete_failed),
);
} finally {
setDeleteOpen(false);
}

View File

@@ -7,7 +7,7 @@ import type { AnimateLayoutChanges } from "@dnd-kit/sortable";
import { CSS } from "@dnd-kit/utilities";
import { toast } from "sonner";
import type { Issue, UpdateIssueRequest } from "@multica/core/types";
import { CalendarDays } from "lucide-react";
import { CalendarClock, CalendarDays } from "lucide-react";
import { useQuery } from "@tanstack/react-query";
import { ActorAvatar } from "../../common/actor-avatar";
import { useUpdateIssue } from "@multica/core/issues/mutations";
@@ -16,7 +16,7 @@ import { useWorkspaceId } from "@multica/core/hooks";
import { projectListOptions } from "@multica/core/projects/queries";
import { ProjectIcon } from "../../projects/components/project-icon";
import { PriorityIcon } from "./priority-icon";
import { PriorityPicker, AssigneePicker, DueDatePicker } from "./pickers";
import { PriorityPicker, AssigneePicker, StartDatePicker, DueDatePicker } from "./pickers";
import { PRIORITY_CONFIG } from "@multica/core/issues/config";
import { useViewStore } from "@multica/core/issues/stores/view-store-context";
import { ProgressRing } from "./progress-ring";
@@ -81,7 +81,14 @@ export const BoardCardContent = memo(function BoardCardContent({
(updates: Partial<UpdateIssueRequest>) => {
updateIssueMutation.mutate(
{ id: issue.id, ...updates },
{ onError: () => toast.error(t(($) => $.card.update_failed)) },
{
onError: (err) =>
toast.error(
err instanceof Error && err.message
? err.message
: t(($) => $.card.update_failed),
),
},
);
},
[issue.id, updateIssueMutation, t],
@@ -90,6 +97,7 @@ export const BoardCardContent = memo(function BoardCardContent({
const showPriority = storeProperties.priority;
const showDescription = storeProperties.description && issue.description;
const showAssignee = storeProperties.assignee && issue.assignee_type && issue.assignee_id;
const showStartDate = storeProperties.startDate && issue.start_date;
const showDueDate = storeProperties.dueDate && issue.due_date;
const showProject = storeProperties.project && project;
const showChildProgress = storeProperties.childProgress && childProgress;
@@ -138,8 +146,8 @@ export const BoardCardContent = memo(function BoardCardContent({
);
})()}
{/* Row 3: Assignee, priority badge, due date */}
{(showAssignee || showPriority || showDueDate) && (
{/* Row 3: Assignee, priority badge, start date, due date */}
{(showAssignee || showPriority || showStartDate || showDueDate) && (
<div className="mt-3 flex items-center gap-2">
{showAssignee &&
(editable ? (
@@ -186,6 +194,29 @@ export const BoardCardContent = memo(function BoardCardContent({
{priorityCfg.label}
</span>
))}
{showStartDate && (
<div className={showDueDate ? undefined : "ml-auto"}>
{editable ? (
<PickerWrapper>
<StartDatePicker
startDate={issue.start_date}
onUpdate={handleUpdate}
trigger={
<span className="flex items-center gap-1 text-xs text-muted-foreground">
<CalendarClock className="size-3" />
{formatDate(issue.start_date!)}
</span>
}
/>
</PickerWrapper>
) : (
<span className="flex items-center gap-1 text-xs text-muted-foreground">
<CalendarClock className="size-3" />
{formatDate(issue.start_date!)}
</span>
)}
</div>
)}
{showDueDate && (
<div className="ml-auto">
{editable ? (

View File

@@ -1,7 +1,7 @@
"use client";
import { memo, useCallback, useRef, useState } from "react";
import { CheckCircle2, ChevronRight, Copy, Download, Eye, FileText, MoreHorizontal, Pencil, RotateCcw, Trash2 } from "lucide-react";
import { CheckCircle2, ChevronRight, Copy, MoreHorizontal, Pencil, RotateCcw, Trash2 } from "lucide-react";
import { toast } from "sonner";
import { Card } from "@multica/ui/components/ui/card";
import { Button } from "@multica/ui/components/ui/button";
@@ -30,7 +30,7 @@ import { QuickEmojiPicker } from "@multica/ui/components/common/quick-emoji-pick
import { cn } from "@multica/ui/lib/utils";
import { useActorName } from "@multica/core/workspace/hooks";
import { timeAgo } from "@multica/core/utils";
import { ContentEditor, type ContentEditorRef, copyMarkdown, ReadonlyContent, useFileDropZone, FileDropOverlay, useDownloadAttachment, useAttachmentPreview, isPreviewable } from "../../editor";
import { ContentEditor, type ContentEditorRef, copyMarkdown, ReadonlyContent, useFileDropZone, FileDropOverlay, useDownloadAttachment, useAttachmentPreview, AttachmentCard } from "../../editor";
import { FileUploadButton } from "@multica/ui/components/common/file-upload-button";
import { useFileUpload } from "@multica/core/hooks/use-file-upload";
import { api } from "@multica/core/api";
@@ -122,7 +122,6 @@ function DeleteCommentDialog({
// ---------------------------------------------------------------------------
function AttachmentList({ attachments, content, className }: { attachments?: Attachment[]; content?: string; className?: string }) {
const { t } = useT("editor");
const download = useDownloadAttachment();
const preview = useAttachmentPreview();
if (!attachments?.length) return null;
@@ -150,35 +149,15 @@ function AttachmentList({ attachments, content, className }: { attachments?: Att
return (
<div className={cn("flex flex-col gap-1", className)}>
{standalone.map((a) => (
<div
<AttachmentCard
key={a.id}
className="flex items-center gap-2 rounded-md border border-border bg-muted/50 px-2.5 py-1 transition-colors hover:bg-muted"
>
<FileText className="size-4 shrink-0 text-muted-foreground" />
<div className="min-w-0 flex-1">
<p className="truncate text-sm">{a.filename}</p>
</div>
{isPreviewable(a.content_type, a.filename) && (
<button
type="button"
className="shrink-0 rounded-md p-1 text-muted-foreground transition-colors hover:bg-secondary hover:text-foreground"
title={t(($) => $.attachment.preview)}
aria-label={t(($) => $.attachment.preview)}
onClick={() => preview.tryOpen({ kind: "full", attachment: a })}
>
<Eye className="size-3.5" />
</button>
)}
<button
type="button"
className="shrink-0 rounded-md p-1 text-muted-foreground transition-colors hover:bg-secondary hover:text-foreground"
title={t(($) => $.image.download)}
aria-label={t(($) => $.image.download)}
onClick={() => download(a.id)}
>
<Download className="size-3.5" />
</button>
</div>
filename={a.filename}
contentType={a.content_type}
attachmentId={a.id}
href={a.url}
onPreview={() => preview.tryOpen({ kind: "full", attachment: a })}
onDownload={() => download(a.id)}
/>
))}
{preview.modal}
</div>
@@ -285,8 +264,12 @@ function CommentRow({
setEditing(false);
setPendingAttachments([]);
clearEditDraft(editDraftKey);
} catch {
toast.error(t(($) => $.comment.update_failed));
} catch (err) {
toast.error(
err instanceof Error && err.message
? err.message
: t(($) => $.comment.update_failed),
);
}
};
@@ -515,8 +498,12 @@ function CommentCardImpl({
setEditing(false);
setParentPendingAttachments([]);
clearParentEditDraft(parentEditDraftKey);
} catch {
toast.error(t(($) => $.comment.update_failed));
} catch (err) {
toast.error(
err instanceof Error && err.message
? err.message
: t(($) => $.comment.update_failed),
);
}
};

View File

@@ -1,7 +1,7 @@
export { StatusIcon } from "./status-icon";
export { StatusHeading } from "./status-heading";
export { PriorityIcon } from "./priority-icon";
export { StatusPicker, PriorityPicker, AssigneePicker, canAssignAgent, DueDatePicker, LabelPicker } from "./pickers";
export { StatusPicker, PriorityPicker, AssigneePicker, canAssignAgent, StartDatePicker, DueDatePicker, LabelPicker } from "./pickers";
export { IssueDetail } from "./issue-detail";
export { IssuesPage } from "./issues-page";
export { CommentCard } from "./comment-card";

View File

@@ -401,6 +401,7 @@ const mockIssue: Issue = {
parent_issue_id: null,
project_id: null,
position: 0,
start_date: null,
due_date: "2026-06-01T00:00:00Z",
created_at: "2026-01-15T00:00:00Z",
updated_at: "2026-01-20T00:00:00Z",
@@ -573,6 +574,7 @@ describe("IssueDetail (shared)", () => {
mockApiObj.getIssue.mockResolvedValue({
...mockIssue,
priority: "none",
start_date: null,
due_date: null,
});

View File

@@ -8,6 +8,7 @@ import { useNavigation } from "../../navigation";
import {
Archive,
Calendar,
CalendarClock,
CalendarDays,
ChevronDown,
ChevronLeft,
@@ -44,7 +45,7 @@ import type { Attachment, Issue, IssueStatus, IssuePriority, TimelineEntry, Upda
import { STATUS_CONFIG, PRIORITY_CONFIG } from "@multica/core/issues/config";
import { useUpdateIssue } from "@multica/core/issues/mutations";
import { toast } from "sonner";
import { StatusIcon, PriorityIcon, StatusPicker, PriorityPicker, DueDatePicker, AssigneePicker, LabelPicker } from ".";
import { StatusIcon, PriorityIcon, StatusPicker, PriorityPicker, StartDatePicker, DueDatePicker, AssigneePicker, LabelPicker } from ".";
import { IssueActionsDropdown, useIssueActions } from "../actions";
import { ProjectPicker } from "../../projects/components/project-picker";
import { CommentCard } from "./comment-card";
@@ -53,7 +54,6 @@ import { ResolvedThreadBar } from "./resolved-thread-bar";
import { collectThreadReplies } from "./thread-utils";
import { AgentLiveCard } from "./agent-live-card";
import { ExecutionLogSection } from "./execution-log-section";
import { TerminalPanel } from "./terminal-panel";
import { PullRequestList } from "./pull-request-list";
import { useQuery } from "@tanstack/react-query";
import { useAuthStore } from "@multica/core/auth";
@@ -214,6 +214,11 @@ function formatActivity(
if (details.from_id && !details.to_id) return t(($) => $.activity.removed_assignee);
return t(($) => $.activity.changed_assignee);
}
case "start_date_changed": {
if (!details.to) return t(($) => $.activity.start_date_removed);
const formatted = new Date(details.to).toLocaleDateString("en-US", { month: "short", day: "numeric" });
return t(($) => $.activity.start_date_set, { date: formatted });
}
case "due_date_changed": {
if (!details.to) return t(($) => $.activity.due_date_removed);
const formatted = new Date(details.to).toLocaleDateString("en-US", { month: "short", day: "numeric" });
@@ -283,10 +288,10 @@ const EMPTY_REPLIES: TimelineEntry[] = [];
// the Properties block, rendered only when the issue actually has a parent.
//
// `OPTIONAL_PROP_KEYS` is the open set — adding a new optional field
// (e.g. `start_date`) means appending here, wiring its row in the JSX
// switch below, and adding a locale key. The picker, visibility rules,
// and add-property menu all flow from this one list.
const OPTIONAL_PROP_KEYS = ["priority", "due_date", "labels"] as const;
// means appending here, wiring its row in the JSX switch below, and
// adding a locale key. The picker, visibility rules, and add-property
// menu all flow from this one list.
const OPTIONAL_PROP_KEYS = ["priority", "start_date", "due_date", "labels"] as const;
type OptionalPropKey = (typeof OPTIONAL_PROP_KEYS)[number];
function isOptionalPropSet(
@@ -297,6 +302,8 @@ function isOptionalPropSet(
switch (key) {
case "priority":
return issue.priority !== "none";
case "start_date":
return !!issue.start_date;
case "due_date":
return !!issue.due_date;
case "labels":
@@ -422,6 +429,7 @@ function ActivityBlock({
const details = (entry.details ?? {}) as Record<string, string>;
const isStatusChange = entry.action === "status_changed";
const isPriorityChange = entry.action === "priority_changed";
const isStartDateChange = entry.action === "start_date_changed";
const isDueDateChange = entry.action === "due_date_changed";
let leadIcon: React.ReactNode;
@@ -429,6 +437,8 @@ function ActivityBlock({
leadIcon = <StatusIcon status={details.to as IssueStatus} className="h-4 w-4 shrink-0" />;
} else if (isPriorityChange && details.to) {
leadIcon = <PriorityIcon priority={details.to as IssuePriority} className="h-4 w-4 shrink-0" />;
} else if (isStartDateChange) {
leadIcon = <CalendarClock className="h-4 w-4 shrink-0 text-muted-foreground" />;
} else if (isDueDateChange) {
leadIcon = <Calendar className="h-4 w-4 shrink-0 text-muted-foreground" />;
} else {
@@ -486,7 +496,14 @@ function SubIssueRow({ child }: { child: Issue }) {
(updates: Partial<UpdateIssueRequest>) => {
updateIssue.mutate(
{ id: child.id, ...updates },
{ onError: () => toast.error(t(($) => $.detail.update_failed)) },
{
onError: (err) =>
toast.error(
err instanceof Error && err.message
? err.message
: t(($) => $.detail.update_failed),
),
},
);
},
[child.id, updateIssue, t],
@@ -1234,6 +1251,15 @@ export function IssueDetail({ issueId, onDelete, onDone, defaultSidebarOpen = tr
/>
</PropRow>
)}
{visibleOptionalProps.has("start_date") && (
<PropRow label={t(($) => $.detail.prop_start_date)}>
<StartDatePicker
startDate={issue.start_date}
onUpdate={handleUpdateField}
defaultOpen={autoOpenProp === "start_date"}
/>
</PropRow>
)}
{visibleOptionalProps.has("due_date") && (
<PropRow label={t(($) => $.detail.prop_due_date)}>
<DueDatePicker
@@ -1281,6 +1307,9 @@ export function IssueDetail({ issueId, onDelete, onDone, defaultSidebarOpen = tr
{k === "priority" && (
<PriorityIcon priority="medium" inheritColor className="text-muted-foreground" />
)}
{k === "start_date" && (
<CalendarClock className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
)}
{k === "due_date" && (
<CalendarDays className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
)}
@@ -1289,6 +1318,7 @@ export function IssueDetail({ issueId, onDelete, onDone, defaultSidebarOpen = tr
)}
<span className="truncate">
{k === "priority" && t(($) => $.detail.prop_priority)}
{k === "start_date" && t(($) => $.detail.prop_start_date)}
{k === "due_date" && t(($) => $.detail.prop_due_date)}
{k === "labels" && t(($) => $.detail.prop_labels)}
</span>
@@ -1367,12 +1397,6 @@ export function IssueDetail({ issueId, onDelete, onDone, defaultSidebarOpen = tr
when there are no runs to show. */}
<ExecutionLogSection issueId={id} />
{/* Terminal panel — attaches to the PTY running in the daemon's
workdir for the latest agent task. Desktop-only (the panel
itself renders an explanatory placeholder on web).
See MUL-2295. */}
<TerminalPanelSection issueId={id} workspaceId={wsId} />
{/* Token usage */}
{usage && usage.task_count > 0 && (
<div>
@@ -1911,23 +1935,3 @@ export function IssueDetail({ issueId, onDelete, onDone, defaultSidebarOpen = tr
</ResizablePanelGroup>
);
}
// TerminalPanelSection wraps TerminalPanel in a collapsible header that
// matches the existing sidebar sections (Token usage, etc.). Collapsed
// by default — opening a PTY is an explicit action, and ResizeObserver +
// xterm bootstrap should not run for every issue view.
function TerminalPanelSection({ issueId, workspaceId }: { issueId: string; workspaceId: string }) {
const [open, setOpen] = useState(false);
return (
<div className="mt-6">
<button
className={`flex w-full items-center gap-1 rounded-md px-2 py-1 text-xs font-medium transition-colors mb-2 hover:bg-accent/70 ${open ? "" : "text-muted-foreground hover:text-foreground"}`}
onClick={() => setOpen((v) => !v)}
>
Terminal
<ChevronRight className={`!size-3 shrink-0 stroke-[2.5] text-muted-foreground transition-transform ${open ? "rotate-90" : ""}`} />
</button>
{open && <TerminalPanel issueId={issueId} workspaceId={workspaceId} />}
</div>
);
}

View File

@@ -568,9 +568,10 @@ export function IssueDisplayControls({ scopedIssues }: { scopedIssues: Issue[] }
labelFilters,
}) > 0;
const SORT_LABEL_KEY: Record<typeof SORT_OPTIONS[number]["value"], "sort_manual" | "sort_priority" | "sort_due_date" | "sort_created" | "sort_title"> = {
const SORT_LABEL_KEY: Record<typeof SORT_OPTIONS[number]["value"], "sort_manual" | "sort_priority" | "sort_start_date" | "sort_due_date" | "sort_created" | "sort_title"> = {
position: "sort_manual",
priority: "sort_priority",
start_date: "sort_start_date",
due_date: "sort_due_date",
created_at: "sort_created",
title: "sort_title",
@@ -579,10 +580,11 @@ export function IssueDisplayControls({ scopedIssues }: { scopedIssues: Issue[] }
status: "group_status",
assignee: "group_assignee",
};
const CARD_PROPERTY_LABEL_KEY: Record<typeof CARD_PROPERTY_OPTIONS[number]["key"], "card_priority" | "card_description" | "card_assignee" | "card_due_date" | "card_project" | "card_labels" | "card_child_progress"> = {
const CARD_PROPERTY_LABEL_KEY: Record<typeof CARD_PROPERTY_OPTIONS[number]["key"], "card_priority" | "card_description" | "card_assignee" | "card_start_date" | "card_due_date" | "card_project" | "card_labels" | "card_child_progress"> = {
priority: "card_priority",
description: "card_description",
assignee: "card_assignee",
startDate: "card_start_date",
dueDate: "card_due_date",
project: "card_project",
labels: "card_labels",

View File

@@ -354,6 +354,7 @@ const mockIssues: Issue[] = [
assignee_id: "user-1",
creator_type: "member",
creator_id: "user-1",
start_date: null,
due_date: null,
created_at: "2026-01-01T00:00:00Z",
updated_at: "2026-01-01T00:00:00Z",
@@ -372,6 +373,7 @@ const mockIssues: Issue[] = [
assignee_id: "agent-1",
creator_type: "member",
creator_id: "user-1",
start_date: null,
due_date: "2026-02-01T00:00:00Z",
created_at: "2026-01-01T00:00:00Z",
updated_at: "2026-01-01T00:00:00Z",
@@ -390,6 +392,7 @@ const mockIssues: Issue[] = [
assignee_id: null,
creator_type: "member",
creator_id: "user-1",
start_date: null,
due_date: null,
created_at: "2026-01-01T00:00:00Z",
updated_at: "2026-01-01T00:00:00Z",
@@ -408,6 +411,7 @@ const mockIssues: Issue[] = [
assignee_id: "squad-1",
creator_type: "member",
creator_id: "user-1",
start_date: null,
due_date: null,
created_at: "2026-01-01T00:00:00Z",
updated_at: "2026-01-01T00:00:00Z",

View File

@@ -121,7 +121,14 @@ export function IssuesPage() {
(issueId: string, updates: Pick<UpdateIssueRequest, "status" | "assignee_type" | "assignee_id" | "position">) => {
updateIssueMutation.mutate(
{ id: issueId, ...updates },
{ onError: () => toast.error(t(($) => $.page.move_failed)) },
{
onError: (err) =>
toast.error(
err instanceof Error && err.message
? err.message
: t(($) => $.page.move_failed),
),
},
);
},
[updateIssueMutation, t],

View File

@@ -50,6 +50,7 @@ export const ListRow = memo(function ListRow({
const showProject = storeProperties.project && project;
const showChildProgress = storeProperties.childProgress && childProgress;
const showAssignee = storeProperties.assignee && issue.assignee_type && issue.assignee_id;
const showStartDate = storeProperties.startDate && issue.start_date;
const showDueDate = storeProperties.dueDate && issue.due_date;
const showLabels = storeProperties.labels && labels.length > 0;
@@ -110,6 +111,11 @@ export const ListRow = memo(function ListRow({
<span className="truncate">{project!.title}</span>
</span>
)}
{showStartDate && (
<span className="shrink-0 text-xs text-muted-foreground">
{formatDate(issue.start_date!)}
</span>
)}
{showDueDate && (
<span className="shrink-0 text-xs text-muted-foreground">
{formatDate(issue.due_date!)}

View File

@@ -2,5 +2,6 @@ export { PropertyPicker, PickerItem, PickerSection, PickerEmpty } from "./proper
export { StatusPicker } from "./status-picker";
export { PriorityPicker } from "./priority-picker";
export { AssigneePicker, canAssignAgent } from "./assignee-picker";
export { StartDatePicker } from "./start-date-picker";
export { DueDatePicker } from "./due-date-picker";
export { LabelPicker } from "./label-picker";

View File

@@ -0,0 +1,82 @@
"use client";
import { useState } from "react";
import { CalendarClock } from "lucide-react";
import type { UpdateIssueRequest } from "@multica/core/types";
import { Calendar } from "@multica/ui/components/ui/calendar";
import {
Popover,
PopoverTrigger,
PopoverContent,
} from "@multica/ui/components/ui/popover";
import { Button } from "@multica/ui/components/ui/button";
import { useT } from "../../../i18n";
export function StartDatePicker({
startDate,
onUpdate,
trigger: customTrigger,
triggerRender,
align = "start",
defaultOpen = false,
}: {
startDate: string | null;
onUpdate: (updates: Partial<UpdateIssueRequest>) => void;
trigger?: React.ReactNode;
triggerRender?: React.ReactElement;
align?: "start" | "center" | "end";
/** Open the popover on first mount. Used by progressive-disclosure
* sidebars so a newly-added field immediately enters edit state. */
defaultOpen?: boolean;
}) {
const { t } = useT("issues");
const [open, setOpen] = useState(defaultOpen);
const date = startDate ? new Date(startDate) : undefined;
return (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger
className={triggerRender ? undefined : "flex items-center gap-1.5 cursor-pointer rounded px-1 -mx-1 hover:bg-accent/30 transition-colors"}
render={triggerRender}
>
{customTrigger ?? (
<>
<CalendarClock className="h-3.5 w-3.5 text-muted-foreground" />
{date ? (
<span>
{date.toLocaleDateString("en-US", { month: "short", day: "numeric" })}
</span>
) : (
<span className="text-muted-foreground">{t(($) => $.pickers.start_date.trigger_label)}</span>
)}
</>
)}
</PopoverTrigger>
<PopoverContent className="w-auto p-0" align={align}>
<Calendar
mode="single"
selected={date}
onSelect={(d: Date | undefined) => {
onUpdate({ start_date: d ? d.toISOString() : null });
setOpen(false);
}}
/>
{date && (
<div className="border-t px-3 py-2">
<Button
variant="ghost"
size="xs"
onClick={() => {
onUpdate({ start_date: null });
setOpen(false);
}}
className="text-muted-foreground hover:text-foreground"
>
{t(($) => $.pickers.start_date.clear_action)}
</Button>
</div>
)}
</PopoverContent>
</Popover>
);
}

View File

@@ -0,0 +1,211 @@
import { describe, expect, it, vi } from "vitest";
import { render, screen } from "@testing-library/react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { I18nProvider } from "@multica/core/i18n/react";
import type { GitHubPullRequest } from "@multica/core/types";
import enCommon from "../../locales/en/common.json";
import enIssues from "../../locales/en/issues.json";
const TEST_RESOURCES = { en: { common: enCommon, issues: enIssues } };
vi.mock("@multica/core/github/queries", async () => {
const actual = await vi.importActual<typeof import("@multica/core/github/queries")>(
"@multica/core/github/queries",
);
return {
...actual,
issuePullRequestsOptions: (issueId: string) => ({
queryKey: ["github", "pull-requests", issueId],
queryFn: async () => ({ pull_requests: mockPRs }),
enabled: !!issueId,
}),
};
});
import { PullRequestList } from "./pull-request-list";
let mockPRs: GitHubPullRequest[] = [];
function makePR(overrides: Partial<GitHubPullRequest> = {}): GitHubPullRequest {
return {
id: "pr-1",
workspace_id: "ws-1",
repo_owner: "acme",
repo_name: "widget",
number: 1,
title: "Test PR",
state: "open",
html_url: "https://example.test/pr/1",
branch: "feat/x",
author_login: "octocat",
author_avatar_url: null,
merged_at: null,
closed_at: null,
pr_created_at: "2026-01-01T00:00:00Z",
pr_updated_at: "2026-01-01T00:00:00Z",
mergeable_state: null,
checks_conclusion: null,
checks_passed: 0,
checks_failed: 0,
checks_pending: 0,
additions: 0,
deletions: 0,
changed_files: 0,
...overrides,
};
}
function renderList() {
const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } });
return render(
<QueryClientProvider client={qc}>
<I18nProvider resources={TEST_RESOURCES} locale="en">
<PullRequestList issueId="issue-1" />
</I18nProvider>
</QueryClientProvider>,
);
}
async function waitForRender() {
return screen.findAllByRole("link");
}
describe("PullRequestList sidebar rows", () => {
it("uses the sidebar list-row surface instead of a card surface", async () => {
mockPRs = [makePR({ title: "Visual row" })];
renderList();
await waitForRender();
const row = screen.getByTestId("pull-request-row");
expect(row).toHaveClass("rounded-md", "-mx-2", "hover:bg-accent/50");
expect(row).not.toHaveClass("rounded-lg", "border", "bg-card");
});
it("renders All-checks-passed status when only passed counts are non-zero", async () => {
mockPRs = [makePR({ checks_passed: 3 })];
renderList();
await waitForRender();
expect(screen.getByText("All checks passed")).toBeInTheDocument();
});
it("renders Some-checks-failed when any failed count is non-zero", async () => {
mockPRs = [makePR({ checks_failed: 1, checks_passed: 5 })];
renderList();
await waitForRender();
expect(screen.getByText("Some checks failed")).toBeInTheDocument();
});
it("renders pending status when only pending suites remain", async () => {
mockPRs = [makePR({ checks_pending: 2, checks_passed: 1 })];
renderList();
await waitForRender();
expect(screen.getByText("Some checks haven't completed yet")).toBeInTheDocument();
});
it("renders conflicts status when mergeable_state=dirty", async () => {
mockPRs = [makePR({ mergeable_state: "dirty" })];
renderList();
await waitForRender();
expect(screen.getByText("Has merge conflicts")).toBeInTheDocument();
});
it("renders Ready-to-merge when mergeable=clean and no suites observed", async () => {
mockPRs = [makePR({ mergeable_state: "clean" })];
renderList();
await waitForRender();
expect(screen.getByText("Ready to merge")).toBeInTheDocument();
});
it("renders Merged status for merged PRs, suppressing conflict/check text", async () => {
mockPRs = [
makePR({
state: "merged",
mergeable_state: "dirty",
checks_conclusion: "failed",
checks_failed: 5,
}),
];
renderList();
await waitForRender();
expect(screen.getByText("Merged")).toBeInTheDocument();
expect(screen.queryByText("Has merge conflicts")).not.toBeInTheDocument();
expect(screen.queryByText("Some checks failed")).not.toBeInTheDocument();
expect(screen.queryByText("Conflicts")).not.toBeInTheDocument();
expect(screen.queryByText("Checks failed")).not.toBeInTheDocument();
});
it("renders Closed-without-merging status for closed PRs, suppressing conflict/check badges", async () => {
mockPRs = [
makePR({
state: "closed",
mergeable_state: "clean",
checks_conclusion: "passed",
checks_passed: 3,
}),
];
renderList();
await waitForRender();
expect(screen.getByText("Closed without merging")).toBeInTheDocument();
expect(screen.queryByText("Ready to merge")).not.toBeInTheDocument();
expect(screen.queryByText("All checks passed")).not.toBeInTheDocument();
expect(screen.queryByText("No conflicts")).not.toBeInTheDocument();
expect(screen.queryByText("Checks passed")).not.toBeInTheDocument();
});
it("hides stats row when all stats are 0 (legacy backend)", async () => {
mockPRs = [makePR()];
renderList();
await waitForRender();
expect(screen.queryByText(/files?$/)).not.toBeInTheDocument();
expect(screen.queryByText(/^\+0/)).not.toBeInTheDocument();
});
it("shows stats row with additions / deletions / file count when present", async () => {
mockPRs = [makePR({ additions: 437, deletions: 6, changed_files: 6 })];
renderList();
await waitForRender();
expect(screen.getByText("+437")).toBeInTheDocument();
expect(screen.getByText("6")).toBeInTheDocument();
expect(screen.getByText("6 files")).toBeInTheDocument();
});
it("uses singular file copy when changed_files=1", async () => {
mockPRs = [makePR({ additions: 1, changed_files: 1 })];
renderList();
await waitForRender();
expect(screen.getByText("1 file")).toBeInTheDocument();
});
it("collapses extra PR rows past the visible limit behind Show more toggle", async () => {
mockPRs = [
makePR({ id: "a", number: 1, title: "PR-A" }),
makePR({ id: "b", number: 2, title: "PR-B" }),
makePR({ id: "c", number: 3, title: "PR-C" }),
makePR({ id: "d", number: 4, title: "PR-D" }),
makePR({ id: "e", number: 5, title: "PR-E" }),
];
renderList();
await waitForRender();
expect(screen.getByText("PR-A")).toBeInTheDocument();
expect(screen.getByText("PR-B")).toBeInTheDocument();
expect(screen.getByText("PR-C")).toBeInTheDocument();
expect(screen.queryByText("PR-D")).not.toBeInTheDocument();
expect(screen.queryByText("PR-E")).not.toBeInTheDocument();
expect(screen.getByText("Show 2 more")).toBeInTheDocument();
});
it("collapses to 3 rows + hidden tail when count == threshold", async () => {
mockPRs = [
makePR({ id: "a", number: 1, title: "PR-A" }),
makePR({ id: "b", number: 2, title: "PR-B" }),
makePR({ id: "c", number: 3, title: "PR-C" }),
makePR({ id: "d", number: 4, title: "PR-D" }),
];
renderList();
await waitForRender();
expect(screen.getByText("PR-A")).toBeInTheDocument();
expect(screen.getByText("PR-B")).toBeInTheDocument();
expect(screen.getByText("PR-C")).toBeInTheDocument();
expect(screen.queryByText("PR-D")).not.toBeInTheDocument();
expect(screen.getByText("Show 1 more")).toBeInTheDocument();
});
});

View File

@@ -1,18 +1,40 @@
"use client";
import { useState } from "react";
import { useQuery } from "@tanstack/react-query";
import {
CheckCircle2,
CircleDashed,
GitMerge,
GitPullRequest,
GitPullRequestArrow,
GitPullRequestClosed,
GitMerge,
GitPullRequestDraft,
TriangleAlert,
XCircle,
} from "lucide-react";
import { issuePullRequestsOptions } from "@multica/core/github/queries";
import type { GitHubPullRequest, GitHubPullRequestState } from "@multica/core/types";
import {
issuePullRequestsOptions,
derivePullRequestStatusKind,
derivePullRequestProgressSegments,
shouldShowPullRequestStats,
type PullRequestStatusKind,
type PullRequestProgressSegment,
} from "@multica/core/github";
import type {
GitHubPullRequest,
GitHubPullRequestChecksConclusion,
GitHubPullRequestState,
} from "@multica/core/types";
import { cn } from "@multica/ui/lib/utils";
import { useT } from "../../i18n";
type IssuesT = ReturnType<typeof useT<"issues">>["t"];
// Keep the existing sidebar density: show the first 3 PR rows inline, then
// collapse the rest once the section reaches 4 rows.
const PR_LIMIT_BEFORE_COLLAPSE = 4;
const STATE_ICON: Record<
GitHubPullRequestState,
{ icon: React.ComponentType<{ className?: string }>; className: string }
@@ -23,8 +45,18 @@ const STATE_ICON: Record<
closed: { icon: GitPullRequestClosed, className: "text-rose-600 dark:text-rose-400" },
};
const CHECKS_ICON: Record<
GitHubPullRequestChecksConclusion,
{ icon: React.ComponentType<{ className?: string }>; className: string }
> = {
passed: { icon: CheckCircle2, className: "text-emerald-600 dark:text-emerald-400" },
failed: { icon: XCircle, className: "text-rose-600 dark:text-rose-400" },
pending: { icon: CircleDashed, className: "text-amber-600 dark:text-amber-400" },
};
export function PullRequestList({ issueId }: { issueId: string }) {
const { t } = useT("issues");
const [expanded, setExpanded] = useState(false);
const { data, isLoading } = useQuery(issuePullRequestsOptions(issueId));
const prs = data?.pull_requests ?? [];
@@ -39,11 +71,35 @@ export function PullRequestList({ issueId }: { issueId: string }) {
);
}
// Render rule:
// - < PR_LIMIT_BEFORE_COLLAPSE: every PR row is visible.
// - >= PR_LIMIT_BEFORE_COLLAPSE: first (LIMIT - 1) rows are visible and
// the remainder sits behind a toggle.
const useCollapse = prs.length >= PR_LIMIT_BEFORE_COLLAPSE;
const expandedHead = useCollapse ? prs.slice(0, PR_LIMIT_BEFORE_COLLAPSE - 1) : prs;
const collapsedTail = useCollapse ? prs.slice(PR_LIMIT_BEFORE_COLLAPSE - 1) : [];
return (
<div className="space-y-1">
{prs.map((pr) => (
{expandedHead.map((pr) => (
<PullRequestRow key={pr.id} pr={pr} />
))}
{useCollapse ? (
<div className="space-y-1">
{expanded
? collapsedTail.map((pr) => <PullRequestRow key={pr.id} pr={pr} />)
: null}
<button
type="button"
onClick={() => setExpanded((v) => !v)}
className="block w-[calc(100%+1rem)] -mx-2 rounded-md px-2 py-1.5 text-left text-[11px] text-muted-foreground hover:bg-accent/50 hover:text-foreground transition-colors"
>
{expanded
? t(($) => $.detail.pull_request_card_show_less)
: t(($) => $.detail.pull_request_card_show_more, { count: collapsedTail.length })}
</button>
</div>
) : null}
</div>
);
}
@@ -51,32 +107,230 @@ export function PullRequestList({ issueId }: { issueId: string }) {
function PullRequestRow({ pr }: { pr: GitHubPullRequest }) {
const { t } = useT("issues");
const cfg = STATE_ICON[pr.state] ?? { icon: GitPullRequest, className: "" };
const Icon = cfg.icon;
const label =
pr.state === "open"
? t(($) => $.detail.pull_request_state_open)
: pr.state === "draft"
? t(($) => $.detail.pull_request_state_draft)
: pr.state === "merged"
? t(($) => $.detail.pull_request_state_merged)
: pr.state === "closed"
? t(($) => $.detail.pull_request_state_closed)
: pr.state;
const StateIcon = cfg.icon;
const kind = derivePullRequestStatusKind({
state: pr.state,
mergeable_state: pr.mergeable_state,
checks_failed: pr.checks_failed,
checks_pending: pr.checks_pending,
checks_passed: pr.checks_passed,
});
const segments = derivePullRequestProgressSegments({
state: pr.state,
checks_failed: pr.checks_failed,
checks_pending: pr.checks_pending,
checks_passed: pr.checks_passed,
});
const showStats = shouldShowPullRequestStats({
additions: pr.additions,
deletions: pr.deletions,
changed_files: pr.changed_files,
});
const statusText = useStatusText(kind);
const draftPrefix = pr.state === "draft";
const stateLabel = getStateLabel(pr.state, t);
return (
<a
data-testid="pull-request-row"
href={pr.html_url}
target="_blank"
rel="noreferrer noopener"
className="flex items-start gap-2 rounded-md px-2 py-1.5 -mx-2 hover:bg-accent/50 transition-colors group"
className={cn(
"flex items-start gap-2 rounded-md px-2 py-1.5 -mx-2 hover:bg-accent/50 transition-colors group",
draftPrefix ? "opacity-80" : null,
)}
>
<Icon className={cn("h-3.5 w-3.5 mt-0.5 shrink-0", cfg.className)} />
<StateIcon className={cn("h-3.5 w-3.5 mt-0.5 shrink-0", cfg.className)} />
<div className="min-w-0 flex-1">
<p className="text-xs font-medium truncate group-hover:text-foreground">{pr.title}</p>
<p className="text-xs font-medium leading-snug truncate group-hover:text-foreground">
{pr.title}
</p>
<p className="text-[11px] text-muted-foreground truncate">
{pr.repo_owner}/{pr.repo_name}#{pr.number} · {label}
{pr.repo_owner}/{pr.repo_name}#{pr.number} · {stateLabel}
{pr.author_login ? ` · @${pr.author_login}` : null}
</p>
<PullRequestRowDetails
pr={pr}
segments={segments}
showStats={showStats}
statusText={
draftPrefix
? t(($) => $.detail.pull_request_card_draft_prefix, { status: statusText })
: statusText
}
statusKind={kind}
/>
</div>
</a>
);
}
function PullRequestRowDetails({
pr,
segments,
showStats,
statusText,
statusKind,
}: {
pr: GitHubPullRequest;
segments: PullRequestProgressSegment[] | null;
showStats: boolean;
statusText: string;
statusKind: PullRequestStatusKind;
}) {
const { t } = useT("issues");
const checksBadge = getChecksBadge(pr, t);
const conflictsBadge = getConflictsBadge(pr, t);
const isTerminal = statusKind === "closed" || statusKind === "merged";
const showChecksBadge =
!isTerminal &&
!!checksBadge &&
statusKind !== "checks_failed" &&
statusKind !== "checks_pending" &&
statusKind !== "checks_passed";
const showConflictsBadge =
!isTerminal && !!conflictsBadge && statusKind !== "conflicts" && statusKind !== "ready";
return (
<div className="mt-1 flex flex-wrap items-center gap-x-2 gap-y-0.5 text-[11px] text-muted-foreground">
{showStats ? <PullRequestStats pr={pr} /> : null}
<PullRequestProgressStrip segments={segments} />
<span className="truncate">{statusText}</span>
{showChecksBadge ? <PullRequestBadge badge={checksBadge} /> : null}
{showConflictsBadge ? <PullRequestBadge badge={conflictsBadge} /> : null}
</div>
);
}
function PullRequestStats({ pr }: { pr: GitHubPullRequest }) {
const { t } = useT("issues");
return (
<span className="inline-flex items-center gap-1.5 tabular-nums">
<span className="text-emerald-600 dark:text-emerald-400">+{pr.additions ?? 0}</span>
<span className="text-rose-600 dark:text-rose-400">{pr.deletions ?? 0}</span>
<span aria-hidden="true">·</span>
<span>
{t(($) => $.detail.pull_request_card_files_count, {
count: pr.changed_files ?? 0,
})}
</span>
</span>
);
}
function PullRequestProgressStrip({
segments,
}: {
segments: PullRequestProgressSegment[] | null;
}) {
if (!segments) return null;
return (
<span className="flex h-1 w-12 shrink-0 overflow-hidden rounded-full bg-muted" aria-hidden="true">
{segments.map((seg) => (
<span
key={seg.kind}
className={cn(
"h-full block",
seg.kind === "failed" && "bg-rose-500 dark:bg-rose-400",
seg.kind === "pending" && "bg-amber-500 dark:bg-amber-400",
seg.kind === "passed" && "bg-emerald-500 dark:bg-emerald-400",
)}
style={{ width: `${seg.ratio * 100}%` }}
/>
))}
</span>
);
}
interface PullRequestBadgeConfig {
icon: React.ComponentType<{ className?: string }>;
label: string;
className: string;
}
function PullRequestBadge({ badge }: { badge: PullRequestBadgeConfig }) {
const Icon = badge.icon;
return (
<span className="inline-flex items-center gap-1">
<Icon className={cn("h-3 w-3", badge.className)} />
{badge.label}
</span>
);
}
function getConflictsBadge(
pr: GitHubPullRequest,
t: IssuesT,
): PullRequestBadgeConfig | null {
const mergeable = pr.mergeable_state ?? null;
return mergeable === "dirty"
? {
icon: TriangleAlert,
label: t(($) => $.detail.pull_request_conflicts_dirty),
className: "text-rose-600 dark:text-rose-400",
}
: mergeable === "clean"
? {
icon: CheckCircle2,
label: t(($) => $.detail.pull_request_conflicts_clean),
className: "text-emerald-600 dark:text-emerald-400",
}
: null;
}
function getChecksBadge(
pr: GitHubPullRequest,
t: IssuesT,
): PullRequestBadgeConfig | null {
const checks = pr.checks_conclusion ?? null;
return checks && CHECKS_ICON[checks]
? {
icon: CHECKS_ICON[checks].icon,
className: CHECKS_ICON[checks].className,
label:
checks === "passed"
? t(($) => $.detail.pull_request_checks_passed)
: checks === "failed"
? t(($) => $.detail.pull_request_checks_failed)
: t(($) => $.detail.pull_request_checks_pending),
}
: null;
}
function getStateLabel(
state: GitHubPullRequestState,
t: IssuesT,
): string {
return state === "open"
? t(($) => $.detail.pull_request_state_open)
: state === "draft"
? t(($) => $.detail.pull_request_state_draft)
: state === "merged"
? t(($) => $.detail.pull_request_state_merged)
: state === "closed"
? t(($) => $.detail.pull_request_state_closed)
: state;
}
function useStatusText(kind: PullRequestStatusKind): string {
const { t } = useT("issues");
switch (kind) {
case "closed":
return t(($) => $.detail.pull_request_card_status_closed);
case "merged":
return t(($) => $.detail.pull_request_card_status_merged);
case "conflicts":
return t(($) => $.detail.pull_request_card_status_conflicts);
case "checks_failed":
return t(($) => $.detail.pull_request_card_status_checks_failed);
case "checks_pending":
return t(($) => $.detail.pull_request_card_status_checks_pending);
case "checks_passed":
return t(($) => $.detail.pull_request_card_status_checks_passed);
case "ready":
return t(($) => $.detail.pull_request_card_status_ready);
case "unknown":
return t(($) => $.detail.pull_request_card_status_unknown);
}
}

View File

@@ -1,342 +0,0 @@
"use client";
import { useEffect, useMemo, useRef, useState } from "react";
import { Terminal as XTerminal } from "@xterm/xterm";
import { FitAddon } from "@xterm/addon-fit";
import { getApi } from "@multica/core/api";
import { Button } from "@multica/ui/components/ui/button";
import "@xterm/xterm/css/xterm.css";
// Protocol message types — kept in lockstep with
// server/pkg/protocol/messages.go. Strings are stable across daemon /
// server / browser, so duplicating them client-side is OK; if we ever
// regenerate types from Go we can swap these out.
const MSG_TERMINAL_DATA = "terminal.data";
const MSG_TERMINAL_RESIZE = "terminal.resize";
const MSG_TERMINAL_CLOSE = "terminal.close";
const MSG_TERMINAL_OPENED = "terminal.opened";
const MSG_TERMINAL_EXIT = "terminal.exit";
const MSG_TERMINAL_ERROR = "terminal.error";
interface Envelope {
type: string;
payload: unknown;
}
interface OpenedPayload {
request_id: string;
session_id: string;
work_dir: string;
shell: string;
}
interface DataPayload {
session_id: string;
data_b64: string;
}
interface ExitPayload {
session_id: string;
exit_code: number;
reason?: string;
}
interface ErrorPayload {
request_id?: string;
session_id?: string;
code: string;
message: string;
}
// Detect Electron — server-side render guard plus the desktop preload
// surface check. Mirrors the pattern used elsewhere in the desktop app;
// the Terminal panel is intentionally desktop-only because the daemon
// only runs on a developer machine.
function isDesktopRuntime(): boolean {
return typeof window !== "undefined" && "desktopAPI" in window;
}
interface TerminalPanelProps {
issueId: string;
workspaceId: string;
}
export function TerminalPanel({ issueId, workspaceId }: TerminalPanelProps) {
const containerRef = useRef<HTMLDivElement | null>(null);
const termRef = useRef<XTerminal | null>(null);
const fitRef = useRef<FitAddon | null>(null);
const wsRef = useRef<WebSocket | null>(null);
const sessionIdRef = useRef<string>("");
const [status, setStatus] = useState<
"idle" | "connecting" | "connected" | "closed" | "error"
>("idle");
const [errorMessage, setErrorMessage] = useState<string>("");
const [reconnectKey, setReconnectKey] = useState(0);
const wsUrl = useMemo(() => deriveTerminalWsUrl(issueId, workspaceId), [
issueId,
workspaceId,
]);
useEffect(() => {
if (!isDesktopRuntime()) return;
if (!containerRef.current) return;
const term = new XTerminal({
convertEol: true,
cursorBlink: true,
fontFamily:
"ui-monospace, SFMono-Regular, Menlo, Monaco, 'Cascadia Mono', 'Roboto Mono', 'Courier New', monospace",
fontSize: 13,
theme: { background: "#0b0b0b", foreground: "#e6e6e6" },
// Scrollback large enough to read a verbose `cargo build` or `git
// log` without auto-clipping the top.
scrollback: 5000,
});
const fit = new FitAddon();
term.loadAddon(fit);
term.open(containerRef.current);
fit.fit();
termRef.current = term;
fitRef.current = fit;
term.writeln("\x1b[90mconnecting to daemon…\x1b[0m");
setStatus("connecting");
const ws = new WebSocket(wsUrl);
wsRef.current = ws;
ws.onopen = () => {
// Cookie auth carries the session by default. If we ever flip to
// token-mode (no cookie), this is where we'd send an `auth` frame
// mirroring realtime/ws-client.ts. Server falls back gracefully.
setStatus("connected");
};
ws.onerror = () => {
// The browser only surfaces a generic Event; the server sends a
// structured terminal.error frame which we already render below.
// Keep this minimal so we don't double-up the error UI.
setStatus("error");
};
ws.onclose = (ev) => {
setStatus("closed");
term.writeln(
`\r\n\x1b[90mconnection closed (code=${ev.code})${
ev.reason ? ` reason=${ev.reason}` : ""
}\x1b[0m`,
);
};
ws.onmessage = (ev) => {
let env: Envelope;
try {
env = JSON.parse(typeof ev.data === "string" ? ev.data : "");
} catch {
return;
}
switch (env.type) {
case MSG_TERMINAL_OPENED: {
const p = env.payload as OpenedPayload;
sessionIdRef.current = p.session_id;
term.writeln(
`\x1b[90mattached to ${p.shell} (cwd: ${p.work_dir})\x1b[0m`,
);
// Send an initial resize matching the terminal's actual size,
// because the server-side open uses default 80x24 until we tell
// it otherwise.
const cols = term.cols;
const rows = term.rows;
ws.send(
JSON.stringify({
type: MSG_TERMINAL_RESIZE,
payload: {
session_id: p.session_id,
cols,
rows,
},
}),
);
break;
}
case MSG_TERMINAL_DATA: {
const p = env.payload as DataPayload;
if (typeof p.data_b64 !== "string") break;
const decoded = atobToUint8(p.data_b64);
// xterm.js accepts Uint8Array; we avoid the latin1 round-trip
// that would otherwise mangle UTF-8 PTY output.
term.write(decoded);
break;
}
case MSG_TERMINAL_EXIT: {
const p = env.payload as ExitPayload;
term.writeln(
`\r\n\x1b[90mprocess exited (code=${p.exit_code}${
p.reason ? `, reason=${p.reason}` : ""
})\x1b[0m`,
);
ws.close();
break;
}
case MSG_TERMINAL_ERROR: {
const p = env.payload as ErrorPayload;
setErrorMessage(`${p.code}: ${p.message}`);
term.writeln(`\r\n\x1b[31m${p.code}: ${p.message}\x1b[0m`);
break;
}
}
};
// Forward keystrokes as terminal.data with base64 of the UTF-8 bytes.
const dataSub = term.onData((data) => {
if (ws.readyState !== WebSocket.OPEN) return;
if (!sessionIdRef.current) return;
ws.send(
JSON.stringify({
type: MSG_TERMINAL_DATA,
payload: {
session_id: sessionIdRef.current,
data_b64: utf8ToBase64(data),
},
}),
);
});
const resizeSub = term.onResize(({ cols, rows }) => {
if (ws.readyState !== WebSocket.OPEN) return;
if (!sessionIdRef.current) return;
ws.send(
JSON.stringify({
type: MSG_TERMINAL_RESIZE,
payload: {
session_id: sessionIdRef.current,
cols,
rows,
},
}),
);
});
// Observe container size and re-fit so the PTY size tracks the panel
// (the right sidebar can be resized at runtime).
const ro = new ResizeObserver(() => {
try {
fit.fit();
} catch {
// fit() throws when the container has zero height during teardown;
// ignore — the next mount will rebind.
}
});
ro.observe(containerRef.current);
return () => {
dataSub.dispose();
resizeSub.dispose();
ro.disconnect();
try {
if (sessionIdRef.current && ws.readyState === WebSocket.OPEN) {
ws.send(
JSON.stringify({
type: MSG_TERMINAL_CLOSE,
payload: { session_id: sessionIdRef.current, reason: "panel_unmount" },
}),
);
}
} catch {
// ws may be already closing; nothing to do.
}
ws.close();
term.dispose();
termRef.current = null;
fitRef.current = null;
wsRef.current = null;
sessionIdRef.current = "";
};
}, [wsUrl, reconnectKey]);
if (!isDesktopRuntime()) {
return (
<div className="rounded-md border border-dashed p-4 text-sm text-muted-foreground">
The terminal is only available in the Multica Desktop app. It attaches
to the PTY hosted by the local daemon that ran the agent task.
</div>
);
}
return (
<div className="flex flex-col gap-2">
<div className="flex items-center justify-between text-xs text-muted-foreground">
<span>
Status: <span className="font-medium">{status}</span>
{errorMessage ? (
<span className="ml-2 text-destructive"> {errorMessage}</span>
) : null}
</span>
<Button
variant="ghost"
size="sm"
onClick={() => {
setErrorMessage("");
setReconnectKey((n) => n + 1);
}}
>
Reconnect
</Button>
</div>
<div
ref={containerRef}
className="h-[360px] w-full overflow-hidden rounded-md border bg-black"
/>
</div>
);
}
function deriveTerminalWsUrl(issueId: string, workspaceId: string): string {
// The API client knows the http(s) base URL; flip the scheme to ws(s)
// and target the proxy endpoint registered in router.go. Falls back to
// the page origin if for some reason the API base is empty (dev
// environments where the API lives on the same host).
let base = "";
try {
base = getApi().getBaseUrl();
} catch {
base = "";
}
if (!base && typeof window !== "undefined") {
base = window.location.origin;
}
const url = new URL(base);
if (url.protocol === "https:") {
url.protocol = "wss:";
} else if (url.protocol === "http:") {
url.protocol = "ws:";
}
url.pathname = url.pathname.replace(/\/$/, "") +
`/ws/issues/${encodeURIComponent(issueId)}/terminal`;
url.search = `?workspace_id=${encodeURIComponent(workspaceId)}&cols=120&rows=30`;
return url.toString();
}
function utf8ToBase64(s: string): string {
if (typeof TextEncoder !== "undefined") {
const bytes = new TextEncoder().encode(s);
let bin = "";
bytes.forEach((b) => {
bin += String.fromCharCode(b);
});
return btoa(bin);
}
// Fallback for old runtimes: assume latin1.
return btoa(s);
}
function atobToUint8(s: string): Uint8Array {
const bin = atob(s);
const out = new Uint8Array(bin.length);
for (let i = 0; i < bin.length; i++) {
out[i] = bin.charCodeAt(i);
}
return out;
}

View File

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

View File

@@ -265,8 +265,12 @@ export function useIssueTimeline(issueId: string, userId?: string) {
setSubmitting(true);
try {
await createComment({ content, attachmentIds });
} catch {
toast.error(t(($) => $.comment.send_failed));
} catch (err) {
toast.error(
err instanceof Error && err.message
? err.message
: t(($) => $.comment.send_failed),
);
} finally {
setSubmitting(false);
}
@@ -284,8 +288,12 @@ export function useIssueTimeline(issueId: string, userId?: string) {
parentId,
attachmentIds,
});
} catch {
toast.error(t(($) => $.comment.send_reply_failed));
} catch (err) {
toast.error(
err instanceof Error && err.message
? err.message
: t(($) => $.comment.send_reply_failed),
);
}
},
[userId, createComment, t],
@@ -295,8 +303,12 @@ export function useIssueTimeline(issueId: string, userId?: string) {
async (commentId: string, content: string, attachmentIds?: string[]) => {
try {
await updateComment({ commentId, content, attachmentIds });
} catch {
toast.error(t(($) => $.comment.update_failed));
} catch (err) {
toast.error(
err instanceof Error && err.message
? err.message
: t(($) => $.comment.update_failed),
);
}
},
[updateComment, t],
@@ -306,8 +318,12 @@ export function useIssueTimeline(issueId: string, userId?: string) {
async (commentId: string) => {
try {
await deleteCommentAsync(commentId);
} catch {
toast.error(t(($) => $.comment.delete_failed));
} catch (err) {
toast.error(
err instanceof Error && err.message
? err.message
: t(($) => $.comment.delete_failed),
);
}
},
[deleteCommentAsync, t],
@@ -317,11 +333,13 @@ export function useIssueTimeline(issueId: string, userId?: string) {
async (commentId: string, resolved: boolean) => {
try {
await resolveCommentAsync({ commentId, resolved });
} catch {
} catch (err) {
toast.error(
resolved
? t(($) => $.comment.resolve.resolve_failed)
: t(($) => $.comment.resolve.unresolve_failed),
err instanceof Error && err.message
? err.message
: resolved
? t(($) => $.comment.resolve.resolve_failed)
: t(($) => $.comment.resolve.unresolve_failed),
);
}
},

View File

@@ -30,6 +30,7 @@ function makeIssue(overrides: Partial<Issue> = {}): Issue {
parent_issue_id: null,
project_id: null,
position: 0,
start_date: null,
due_date: null,
created_at: "2025-01-01T00:00:00Z",
updated_at: "2025-01-01T00:00:00Z",

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