mirror of
https://github.com/multica-ai/multica.git
synced 2026-06-21 22:56:21 +02:00
Compare commits
70 Commits
refactor/s
...
feat/quick
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2ac03176e9 | ||
|
|
b3682eac4f | ||
|
|
bf9bd26dd9 | ||
|
|
06e2ed5347 | ||
|
|
541aaa974d | ||
|
|
81231e06f8 | ||
|
|
6ef711cd35 | ||
|
|
b8f661e006 | ||
|
|
f628e48775 | ||
|
|
f864a07bd5 | ||
|
|
c381d59c7a | ||
|
|
1292ecf71b | ||
|
|
b77acdf642 | ||
|
|
6bd5bbad9c | ||
|
|
4c81fbed2b | ||
|
|
d63e7c1c45 | ||
|
|
dabebe0c12 | ||
|
|
d14265de2a | ||
|
|
bf6509be96 | ||
|
|
6620997503 | ||
|
|
e268ee3e71 | ||
|
|
e9d04ecfc1 | ||
|
|
2e7da8c63f | ||
|
|
04882c2201 | ||
|
|
ba2f19d631 | ||
|
|
7f6776b12f | ||
|
|
8b340fcf21 | ||
|
|
1f770813dd | ||
|
|
29122cc18b | ||
|
|
18524d80d0 | ||
|
|
141c294cdb | ||
|
|
04f813a70f | ||
|
|
c7a2d53f76 | ||
|
|
aca74293dd | ||
|
|
12e6ca9906 | ||
|
|
3c3e3bd330 | ||
|
|
25b393df17 | ||
|
|
6f04a6d26b | ||
|
|
58547faf31 | ||
|
|
9b55b2a9ce | ||
|
|
c7bac0aa6b | ||
|
|
101601a4c3 | ||
|
|
95912243bb | ||
|
|
24e135541b | ||
|
|
2df969cffc | ||
|
|
5eab1dbbe1 | ||
|
|
a89064d693 | ||
|
|
68a312c297 | ||
|
|
683ff132ca | ||
|
|
93fe324bb9 | ||
|
|
74593fdb88 | ||
|
|
60fdc82824 | ||
|
|
c3ae212b40 | ||
|
|
d17b2bfb8c | ||
|
|
13d9d7df1b | ||
|
|
71b2032174 | ||
|
|
f7fe0829f2 | ||
|
|
9e1e3981fb | ||
|
|
c7e725ef66 | ||
|
|
fe84e29b64 | ||
|
|
4f40f70ea7 | ||
|
|
99154d97b9 | ||
|
|
7067d8f125 | ||
|
|
9ed1fa95fc | ||
|
|
147fb2ee66 | ||
|
|
9c177562e2 | ||
|
|
5bab95ad26 | ||
|
|
0bd6ba9354 | ||
|
|
40cea8454d | ||
|
|
d54daa62c5 |
33
.env.example
33
.env.example
@@ -11,17 +11,21 @@ DATABASE_URL=postgres://multica:multica@localhost:5432/multica?sslmode=disable
|
||||
# DATABASE_MIN_CONNS=5
|
||||
|
||||
# Server
|
||||
# APP_ENV gates dev-only auth shortcuts (primarily the 888888 master code).
|
||||
# - Docker self-host: docker-compose.selfhost.yml already pins APP_ENV to
|
||||
# "production" by default, so 888888 is DISABLED — a public instance can't
|
||||
# be logged into with any email + 888888.
|
||||
# - Local dev (make dev): leave APP_ENV unset so 888888 works out of the box.
|
||||
# - Docker self-host on a private network you fully control, or evaluation
|
||||
# without Resend: set APP_ENV=development to re-enable 888888. Do NOT
|
||||
# enable on a publicly reachable instance.
|
||||
# APP_ENV gates production safety checks. Docker self-host pins APP_ENV to
|
||||
# "production" by default. Local dev can leave it unset.
|
||||
# See SELF_HOSTING.md for the full login setup.
|
||||
APP_ENV=
|
||||
# Optional local/testing shortcut. Empty by default, so there is no fixed
|
||||
# verification code. Without RESEND_API_KEY, generated codes print to stdout.
|
||||
# If you need deterministic local automation, set a 6-digit value such as
|
||||
# 888888 and keep APP_ENV non-production. This is ignored when APP_ENV=production.
|
||||
MULTICA_DEV_VERIFICATION_CODE=
|
||||
PORT=8080
|
||||
# Prometheus metrics are disabled by default. When enabled, bind to loopback
|
||||
# unless you protect the listener with private networking, allowlists, or
|
||||
# proxy auth. Do not expose this endpoint through the public app/API ingress.
|
||||
# HTTP request metrics start accumulating only when this listener is enabled.
|
||||
# METRICS_ADDR=127.0.0.1:9090
|
||||
JWT_SECRET=change-me-in-production
|
||||
MULTICA_SERVER_URL=ws://localhost:8080/ws
|
||||
MULTICA_APP_URL=http://localhost:3000
|
||||
@@ -45,8 +49,7 @@ MULTICA_BACKEND_IMAGE=ghcr.io/multica-ai/multica-backend
|
||||
MULTICA_WEB_IMAGE=ghcr.io/multica-ai/multica-web
|
||||
|
||||
# Email (Resend)
|
||||
# For local/dev use, leave RESEND_API_KEY empty — codes print to stdout, and
|
||||
# master code 888888 works (only when APP_ENV != "production"; see above).
|
||||
# For local/dev use, leave RESEND_API_KEY empty — generated codes print to stdout.
|
||||
# For production, set your Resend API key and change RESEND_FROM_EMAIL to a domain verified in your Resend account.
|
||||
RESEND_API_KEY=
|
||||
RESEND_FROM_EMAIL=noreply@multica.ai
|
||||
@@ -85,6 +88,16 @@ LOCAL_UPLOAD_BASE_URL=http://localhost:8080
|
||||
# Example: ALLOWED_ORIGINS=https://app.multica.ai,https://staging.multica.ai
|
||||
ALLOWED_ORIGINS=
|
||||
|
||||
# Realtime metrics endpoint (/health/realtime) access control. See MUL-1342.
|
||||
# When unset, the endpoint only serves direct loopback (127.0.0.1 / ::1)
|
||||
# callers with no forwarding headers and returns 404 to everything else —
|
||||
# safe for local dev. Any deployment behind a reverse proxy (Caddy / Nginx
|
||||
# terminating TLS in front of localhost:8080) MUST set this token, since
|
||||
# proxied requests look like loopback at the Go layer; with no token, those
|
||||
# requests are refused with 404. Pass the token as
|
||||
# `Authorization: Bearer <token>`.
|
||||
# REALTIME_METRICS_TOKEN=
|
||||
|
||||
# Frontend
|
||||
FRONTEND_PORT=3000
|
||||
FRONTEND_ORIGIN=http://localhost:3000
|
||||
|
||||
6
.github/workflows/release.yml
vendored
6
.github/workflows/release.yml
vendored
@@ -56,6 +56,12 @@ jobs:
|
||||
|
||||
release:
|
||||
needs: verify
|
||||
# Only run on the canonical upstream repo. Forks don't have the
|
||||
# HOMEBREW_TAP_GITHUB_TOKEN secret and should not be publishing to
|
||||
# `multica-ai/homebrew-tap` anyway. Without this guard, every fork's
|
||||
# tag push fails this job (401 against the upstream tap), which makes
|
||||
# downstream CI go red without affecting the actual artifact pipeline.
|
||||
if: github.repository_owner == 'multica-ai'
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
|
||||
11
CLAUDE.md
11
CLAUDE.md
@@ -136,6 +136,17 @@ make start-worktree # Start using .env.worktree
|
||||
- Avoid broad refactors unless required by the task.
|
||||
- New global (pre-workspace) routes MUST use a single word (`/login`, `/inbox`) or a `/{noun}/{verb}` pair (`/workspaces/new`). NEVER add hyphenated word-group root routes (`/new-workspace`, `/create-team`) — they collide with common user workspace names and force endless reserved-slug audits. Reserving the noun (`workspaces`) automatically protects the entire `/workspaces/*` subtree.
|
||||
|
||||
### Backend Handler UUID Parsing Convention
|
||||
|
||||
Every Go handler in `server/internal/handler/` follows these rules. The convention exists because `util.ParseUUID` used to silently return a zero UUID on invalid input, which caused #1661 — a `DELETE` returning 204 success while the SQL `DELETE` matched zero rows.
|
||||
|
||||
- **Resource path params that accept either a UUID or a human-readable identifier** (e.g. `chi.URLParam(r, "id")` for an issue, which accepts both `MUL-123` and a UUID) MUST be resolved through the dedicated loader (`loadIssueForUser` / `loadSkillForUser` / `loadAgentForUser` / `requireDaemonRuntimeAccess`). After resolution, all subsequent DB calls — especially `Queries.Delete*` / `Queries.Update*` — MUST use `entity.ID` from the resolved object. Never round-trip the raw URL string through `parseUUID` for a write query.
|
||||
- **Pure-UUID inputs from request boundaries** (URL params that are always UUIDs, request body fields, query params, headers) MUST be validated with `parseUUIDOrBadRequest(w, s, fieldName)`. On invalid input it writes a 400 and returns `ok=false` — return immediately.
|
||||
- **Trusted UUID round-trips** (sqlc-returned UUIDs being passed back into queries, test fixtures) use `parseUUID(s)` which calls `util.MustParseUUID` and panics on invalid input. A panic here means an unguarded user-input string slipped in — that is a real bug. `chi`'s `middleware.Recoverer` translates the panic into a 500 so the process keeps running.
|
||||
- **`util.ParseUUID(s) (pgtype.UUID, error)`** is the only safe variant outside the handler package. Always check the error.
|
||||
|
||||
When adding a `Queries.Delete*` or `Queries.Update*` call, ask: "Where did this UUID come from?" If the answer is "raw user input that hasn't been validated," route it through `parseUUIDOrBadRequest` or a loader first.
|
||||
|
||||
### Package Boundary Rules
|
||||
|
||||
These are hard constraints. Violating them breaks the cross-platform architecture:
|
||||
|
||||
@@ -166,6 +166,7 @@ Daemon behavior is configured via flags or environment variables:
|
||||
| Poll interval | `--poll-interval` | `MULTICA_DAEMON_POLL_INTERVAL` | `3s` |
|
||||
| Heartbeat interval | `--heartbeat-interval` | `MULTICA_DAEMON_HEARTBEAT_INTERVAL` | `15s` |
|
||||
| Agent timeout | `--agent-timeout` | `MULTICA_AGENT_TIMEOUT` | `2h` |
|
||||
| Codex semantic inactivity timeout | `--codex-semantic-inactivity-timeout` | `MULTICA_CODEX_SEMANTIC_INACTIVITY_TIMEOUT` | `10m` |
|
||||
| Max concurrent tasks | `--max-concurrent-tasks` | `MULTICA_DAEMON_MAX_CONCURRENT_TASKS` | `20` |
|
||||
| Daemon ID | `--daemon-id` | `MULTICA_DAEMON_ID` | hostname |
|
||||
| Device name | `--device-name` | `MULTICA_DAEMON_DEVICE_NAME` | hostname |
|
||||
|
||||
@@ -373,7 +373,8 @@ done
|
||||
|
||||
#### 2. Create a test user and token (automated auth)
|
||||
|
||||
In non-production environments the verification code is fixed at `888888`:
|
||||
For deterministic local automation, set `MULTICA_DEV_VERIFICATION_CODE=888888`
|
||||
in your env file before starting the backend:
|
||||
|
||||
```bash
|
||||
curl -s -X POST "$SERVER/auth/send-code" \
|
||||
@@ -476,7 +477,9 @@ This automatically:
|
||||
3. Starts and manages its own daemon instance
|
||||
4. Connects to the local backend
|
||||
|
||||
Login in the Desktop UI with `dev@localhost` and code `888888`.
|
||||
Login in the Desktop UI with `dev@localhost` and the generated code from the
|
||||
backend logs. If you set `MULTICA_DEV_VERIFICATION_CODE=888888` before starting
|
||||
the backend, you can use `888888` instead.
|
||||
|
||||
If the backend runs on a non-default port (worktree), create
|
||||
`apps/desktop/.env.development.local`:
|
||||
|
||||
@@ -15,7 +15,7 @@ COPY server/ ./server/
|
||||
# Build binaries
|
||||
ARG VERSION=dev
|
||||
ARG COMMIT=unknown
|
||||
RUN cd server && CGO_ENABLED=0 go build -ldflags "-s -w" -o bin/server ./cmd/server
|
||||
RUN cd server && CGO_ENABLED=0 go build -ldflags "-s -w -X main.version=${VERSION} -X main.commit=${COMMIT}" -o bin/server ./cmd/server
|
||||
RUN cd server && CGO_ENABLED=0 go build -ldflags "-s -w -X main.version=${VERSION} -X main.commit=${COMMIT}" -o bin/multica ./cmd/multica
|
||||
RUN cd server && CGO_ENABLED=0 go build -ldflags "-s -w" -o bin/migrate ./cmd/migrate
|
||||
|
||||
|
||||
6
Makefile
6
Makefile
@@ -91,7 +91,7 @@ selfhost: ## Create .env if needed, then pull and start the official self-hosted
|
||||
echo " $${MULTICA_WEB_IMAGE:-ghcr.io/multica-ai/multica-web}:$${MULTICA_IMAGE_TAG:-latest}"; \
|
||||
echo ""; \
|
||||
echo "Log in: configure RESEND_API_KEY in .env for email codes,"; \
|
||||
echo " or set APP_ENV=development in .env (private networks only) to enable code 888888."; \
|
||||
echo " or read the generated code from backend logs when Resend is unset."; \
|
||||
echo ""; \
|
||||
echo "Next — install the CLI and connect your machine:"; \
|
||||
echo " brew install multica-ai/tap/multica"; \
|
||||
@@ -130,7 +130,7 @@ selfhost-build: ## Build backend/web from the current checkout and start the sel
|
||||
echo " Backend: http://localhost:$${PORT:-8080}"; \
|
||||
echo ""; \
|
||||
echo "Log in: configure RESEND_API_KEY in .env for email codes,"; \
|
||||
echo " or set APP_ENV=development in .env (private networks only) to enable code 888888."; \
|
||||
echo " or read the generated code from backend logs when Resend is unset."; \
|
||||
echo ""; \
|
||||
echo "Built images locally via docker-compose.selfhost.build.yml."; \
|
||||
echo "Local tags: multica-backend:dev and multica-web:dev."; \
|
||||
@@ -277,7 +277,7 @@ COMMIT ?= $(shell git rev-parse --short HEAD 2>/dev/null || echo unknown)
|
||||
DATE ?= $(shell date -u '+%Y-%m-%dT%H:%M:%SZ')
|
||||
|
||||
build: ## Build the server, CLI, and migrate binaries into server/bin
|
||||
cd server && go build -o bin/server ./cmd/server
|
||||
cd server && go build -ldflags "-X main.version=$(VERSION) -X main.commit=$(COMMIT)" -o bin/server ./cmd/server
|
||||
cd server && go build -ldflags "-X main.version=$(VERSION) -X main.commit=$(COMMIT) -X main.date=$(DATE)" -o bin/multica ./cmd/multica
|
||||
cd server && go build -o bin/migrate ./cmd/migrate
|
||||
|
||||
|
||||
@@ -26,7 +26,7 @@ multica setup self-host
|
||||
|
||||
This installs the `multica` CLI, checks out the latest self-host assets, pulls the official Multica images from GHCR, and configures everything for localhost.
|
||||
|
||||
Open http://localhost:3000. To log in, configure `RESEND_API_KEY` in `.env` for email-based codes (recommended), or set `APP_ENV=development` in `.env` to enable the dev master code **`888888`**. See [Step 2 — Log In](#step-2--log-in) for details.
|
||||
Open http://localhost:3000. To log in, configure `RESEND_API_KEY` in `.env` for email-based codes (recommended), or leave Resend unset and copy the generated code from the backend logs. See [Step 2 — Log In](#step-2--log-in) for details.
|
||||
|
||||
> **Prerequisites:** Docker and Docker Compose must be installed. The script checks for this and provides install links if missing.
|
||||
>
|
||||
@@ -67,15 +67,15 @@ Once ready:
|
||||
|
||||
### Step 2 — Log In
|
||||
|
||||
Open http://localhost:3000 in your browser. The Docker self-host stack defaults to `APP_ENV=production` (set in `docker-compose.selfhost.yml`), so the dev master code is **disabled by default** for safety on public deployments. Pick one of the following to log in:
|
||||
Open http://localhost:3000 in your browser. The Docker self-host stack defaults to `APP_ENV=production` (set in `docker-compose.selfhost.yml`), and there is no fixed verification code by default. Pick one of the following to log in:
|
||||
|
||||
- **Recommended (production):** configure `RESEND_API_KEY` in `.env`, then restart the backend. Real verification codes will be sent to the email address you enter. See [Advanced Configuration → Email](SELF_HOSTING_ADVANCED.md#email-required-for-authentication).
|
||||
- **Evaluation / private network:** set `APP_ENV=development` in `.env` and restart the backend. Verification code **`888888`** will then work for any email address.
|
||||
- **Without configuring either:** the verification code is generated server-side and printed to the backend container logs (look for `[DEV] Verification code for ...:`). Useful for one-off testing on a single machine.
|
||||
- **Without email configured:** the verification code is generated server-side and printed to the backend container logs (look for `[DEV] Verification code for ...:`). Useful for one-off testing on a single machine.
|
||||
- **Deterministic local/private testing:** set `APP_ENV=development` and `MULTICA_DEV_VERIFICATION_CODE=888888` in `.env`, then restart the backend. This fixed code is ignored when `APP_ENV=production`.
|
||||
|
||||
Changes to `ALLOW_SIGNUP` and `GOOGLE_CLIENT_ID` also take effect after restarting the backend / compose stack. The web UI reads both from `/api/config` at runtime, so no web rebuild is needed.
|
||||
|
||||
> **Warning:** do **not** set `APP_ENV=development` on a publicly reachable instance — anyone who knows an email address can then log in with `888888`.
|
||||
> **Warning:** do **not** set `MULTICA_DEV_VERIFICATION_CODE` on a publicly reachable instance — anyone who knows an email address can then log in with that fixed code.
|
||||
|
||||
### Step 3 — Install CLI & Start Daemon
|
||||
|
||||
|
||||
@@ -32,7 +32,7 @@ Multica uses email-based magic link authentication via [Resend](https://resend.c
|
||||
| `RESEND_API_KEY` | Your Resend API key |
|
||||
| `RESEND_FROM_EMAIL` | Sender email address (default: `noreply@multica.ai`) |
|
||||
|
||||
> **Note:** The dev master verification code `888888` is gated by `APP_ENV != "production"`. The Docker self-host stack defaults to `APP_ENV=production` (so `888888` is disabled), which protects publicly reachable instances. For local development without email configured, set `APP_ENV=development` in your `.env` to enable `888888` — never do this on a public instance.
|
||||
> **Note:** If Resend is not configured, generated verification codes are printed to backend logs. A fixed local testing code is disabled by default; to opt in on a private test instance, set `APP_ENV=development` and `MULTICA_DEV_VERIFICATION_CODE` to a 6-digit value. It is ignored when `APP_ENV=production`.
|
||||
|
||||
### Google OAuth (Optional)
|
||||
|
||||
@@ -79,6 +79,7 @@ The `Secure` flag on session cookies is derived automatically from the scheme of
|
||||
| Variable | Default | Description |
|
||||
|----------|---------|-------------|
|
||||
| `PORT` | `8080` | Backend server port |
|
||||
| `METRICS_ADDR` | empty | Optional Prometheus metrics listener, for example `127.0.0.1:9090` |
|
||||
| `FRONTEND_PORT` | `3000` | Frontend port |
|
||||
| `CORS_ALLOWED_ORIGINS` | Value of `FRONTEND_ORIGIN` | Comma-separated list of allowed origins |
|
||||
| `LOG_LEVEL` | `info` | Log level: `debug`, `info`, `warn`, `error` |
|
||||
@@ -290,14 +291,45 @@ HTTP requests (issues, comments, uploads) work on LAN out of the box — Next.js
|
||||
|
||||
## Health Check
|
||||
|
||||
The backend exposes a health check endpoint:
|
||||
The backend exposes public health endpoints:
|
||||
|
||||
```
|
||||
```text
|
||||
GET /health
|
||||
→ {"status":"ok"}
|
||||
|
||||
GET /readyz
|
||||
→ {"status":"ok","checks":{"db":"ok","migrations":"ok"}}
|
||||
|
||||
GET /healthz
|
||||
→ same response as /readyz
|
||||
```
|
||||
|
||||
Use this for load balancer health checks or monitoring.
|
||||
Use `/health` for basic liveness / reachability checks. Use `/readyz` for
|
||||
dependency-aware readiness probes and external monitoring that should fail when
|
||||
the database is unavailable or migrations are not fully applied. `/healthz` is
|
||||
kept as an alias for operator familiarity.
|
||||
|
||||
## Prometheus Metrics
|
||||
|
||||
The backend can expose Prometheus metrics on a separate management listener:
|
||||
|
||||
```bash
|
||||
METRICS_ADDR=127.0.0.1:9090 ./server/bin/server
|
||||
curl http://127.0.0.1:9090/metrics
|
||||
```
|
||||
|
||||
`METRICS_ADDR` is empty by default, so no metrics listener is started. The
|
||||
public API port does not serve `/metrics`; keep it that way for internet-facing
|
||||
deployments. HTTP request metrics start accumulating only after the metrics
|
||||
listener is enabled. Metrics can reveal internal routes, traffic volume,
|
||||
dependency state, and runtime health.
|
||||
|
||||
For Docker or Kubernetes deployments, prefer a private scrape path: bind the
|
||||
metrics listener to an internal interface and protect it with private
|
||||
networking, allowlists, NetworkPolicy, or proxy authentication. If you bind
|
||||
`METRICS_ADDR=0.0.0.0:9090` inside a container, only publish that port to a
|
||||
trusted network, for example a host-local mapping such as
|
||||
`127.0.0.1:9090:9090`.
|
||||
|
||||
## Upgrading
|
||||
|
||||
|
||||
@@ -37,7 +37,7 @@ multica setup self-host
|
||||
|
||||
The `multica setup self-host` command will:
|
||||
1. Configure CLI to connect to localhost:8080 / localhost:3000
|
||||
2. Open a browser for login — use verification code `888888` with any email
|
||||
2. Open a browser for login — use the emailed code, or the generated code printed in backend logs when Resend is unset
|
||||
3. Discover workspaces automatically
|
||||
4. Start the daemon in the background
|
||||
|
||||
@@ -73,4 +73,4 @@ If the default ports (8080/3000) are in use:
|
||||
- **Backend not ready:** `docker compose -f docker-compose.selfhost.yml logs backend`
|
||||
- **Frontend not ready:** `docker compose -f docker-compose.selfhost.yml logs frontend`
|
||||
- **Daemon issues:** `multica daemon logs`
|
||||
- **Health check:** `curl http://localhost:8080/health`
|
||||
- **Health checks:** `curl http://localhost:8080/health` for liveness, `curl http://localhost:8080/readyz` for dependency-aware readiness
|
||||
|
||||
@@ -37,6 +37,14 @@ linux:
|
||||
- deb
|
||||
- rpm
|
||||
artifactName: multica-desktop-${version}-linux-${arch}.${ext}
|
||||
rpm:
|
||||
# Disable RPM build-id symlinks. Electron apps embed the upstream Electron
|
||||
# binary, whose GNU build-id is identical across every app shipping the same
|
||||
# Electron version (Slack, VS Code, Discord, ...). Without this, our RPM
|
||||
# would own /usr/lib/.build-id/<hash> paths and collide with any other
|
||||
# Electron RPM already installed, breaking `dnf install` on Fedora/RHEL.
|
||||
fpm:
|
||||
- "--rpm-rpmbuild-define=_build_id_links none"
|
||||
win:
|
||||
target:
|
||||
- nsis
|
||||
|
||||
@@ -12,7 +12,7 @@ Assign an [issue](/issues) to an [agent](/agents) and it works as the **official
|
||||
| **Assign** | Hand an agent ownership | Changes assignee | Issue + all comments | Inherits from issue | ✓ |
|
||||
| [**@-mention**](/mentioning-agents) | Pull it in to take a look | No changes | Issue + trigger comment | Inherits from issue | ✓ |
|
||||
| [**Chat**](/chat) | One-to-one conversation outside any issue | No issue involved | Current conversation history | Fixed medium | ✓ |
|
||||
| [**Routines**](/routines) | Scheduled or manual automation | Depends on mode | Depends on mode | Set by routine | ✗ |
|
||||
| [**Autopilots**](/autopilots) | Scheduled or manual automation | Depends on mode | Depends on mode | Set by autopilot | ✗ |
|
||||
|
||||
"Auto retry" refers to retries after infrastructure failures (runtime offline, timeout). Business errors on the agent side (for example, the model reporting an error) are not retried. See [**Tasks**](/tasks) for details.
|
||||
|
||||
@@ -78,4 +78,4 @@ But **different agents can work on the same issue in parallel** — for example,
|
||||
|
||||
- [**@-mention an agent in a comment**](/mentioning-agents) — a lighter trigger that leaves assignee and status untouched
|
||||
- [**Chat**](/chat) — one-to-one conversation outside any issue
|
||||
- [**Routines**](/routines) — let agents start work automatically on a schedule
|
||||
- [**Autopilots**](/autopilots) — let agents start work automatically on a schedule
|
||||
|
||||
@@ -12,7 +12,7 @@ import { Callout } from "fumadocs-ui/components/callout";
|
||||
| **分配** | 让智能体正式负责 | 改 assignee | issue + 全部 comments | 继承 issue | ✓ |
|
||||
| [**@ 提及**](/mentioning-agents) | 评论里让它看一眼 | 都不改 | issue + 触发评论 | 继承 issue | ✓ |
|
||||
| [**对话**](/chat) | 独立于 issue 的一对一聊天 | 不涉及 issue | 当前对话历史 | 固定中 | ✓ |
|
||||
| [**Routines**](/routines) | 定时 / 手动自动化 | 视模式 | 视模式 | routine 自定 | ✗ |
|
||||
| [**Autopilots**](/autopilots) | 定时 / 手动自动化 | 视模式 | 视模式 | autopilot 自定 | ✗ |
|
||||
|
||||
"自动重试"指基础设施故障(运行时离线、超时)导致的重试;智能体侧业务错误(比如模型自己报错)不会自动重试。详见 [**执行任务**](/tasks)。
|
||||
|
||||
@@ -78,4 +78,4 @@ multica issue assign MUL-42 --unassign
|
||||
|
||||
- [**在评论里 @ 智能体**](/mentioning-agents) —— 更轻量的触发方式,不改 assignee / status
|
||||
- [**对话**](/chat) —— 脱离 issue 和智能体一对一聊
|
||||
- [**Routines**](/routines) —— 让智能体定时自动开工
|
||||
- [**Autopilots**](/autopilots) —— 让智能体定时自动开工
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
---
|
||||
title: Sign-in and signup configuration
|
||||
description: Configure email + verification code sign-in, Google OAuth, and signup allowlists. Avoid the 888888 trap.
|
||||
description: Configure email + verification code sign-in, Google OAuth, signup allowlists, and local test codes.
|
||||
---
|
||||
|
||||
import { Callout } from "fumadocs-ui/components/callout";
|
||||
@@ -27,17 +27,24 @@ The user enters an email on the sign-in page → the server sends a 6-digit code
|
||||
|
||||
**What happens if you don't set `RESEND_API_KEY`**: the server doesn't error, but **every email that should have been sent is written to the server's stdout only**. Handy for local development (copy the code from the logs); in production it's a black hole.
|
||||
|
||||
## The 888888 trap
|
||||
## Fixed local testing codes
|
||||
|
||||
<Callout type="warning">
|
||||
**If `APP_ENV` is not set to `production`, anyone can sign in to any account with the code `888888`.**
|
||||
**Do not enable a fixed verification code on a publicly reachable instance.**
|
||||
|
||||
Multica has a development-only master code, `888888` — a backdoor so local development doesn't depend on Resend. The rule is straightforward: when `APP_ENV != "production"`, **any email** plus `888888` passes verification.
|
||||
The old behavior where non-production instances accepted `888888` by default has been removed. Unless you explicitly configure it, typing `888888` is treated like any other wrong code.
|
||||
|
||||
**Production deployments must set `APP_ENV=production`**. If you deploy via `make selfhost` / `docker-compose.selfhost.yml`, this value is already set to `production` by default; but if you deploy from source yourself, write your own Docker config, or redefine environment variables in Kubernetes — you must add `APP_ENV=production` yourself.
|
||||
Local development without Resend should use the generated code printed in server logs. If you need deterministic local/private automation, set `MULTICA_DEV_VERIFICATION_CODE` to a 6-digit value such as `888888`, and keep `APP_ENV` non-production:
|
||||
|
||||
```bash
|
||||
APP_ENV=development
|
||||
MULTICA_DEV_VERIFICATION_CODE=888888
|
||||
```
|
||||
|
||||
This shortcut is ignored when `APP_ENV=production`.
|
||||
</Callout>
|
||||
|
||||
To check whether your deployment has this trap: open the sign-in page, enter **any email** to request a code, then enter `888888`. If you get in, your `APP_ENV` is not set to `production`, and **the entire instance is wide open**.
|
||||
Production deployments should leave `MULTICA_DEV_VERIFICATION_CODE` empty and set `APP_ENV=production`. If you deploy via `make selfhost` / `docker-compose.selfhost.yml`, `APP_ENV` defaults to `production`.
|
||||
|
||||
## Google OAuth configuration
|
||||
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
---
|
||||
title: 登录与注册配置
|
||||
description: 配 Email 验证码登录 + Google OAuth + 注册白名单。避开最坑的 888888 陷阱。
|
||||
description: 配 Email 验证码登录、Google OAuth、注册白名单和本地测试验证码。
|
||||
---
|
||||
|
||||
import { Callout } from "fumadocs-ui/components/callout";
|
||||
import { Mermaid } from "@/components/mermaid";
|
||||
|
||||
Multica 支持两种登录方式:**Email + 验证码**(默认)和 **Google OAuth**(可选)。登录成功后 server 签发一个 30 天有效期的 JWT cookie。这一页讲怎么配、怎么限制谁能注册、以及自部署最容易踩的一个陷阱。
|
||||
Multica 支持两种登录方式:**Email + 验证码**(默认)和 **Google OAuth**(可选)。登录成功后 server 签发一个 30 天有效期的 JWT cookie。这一页讲怎么配、怎么限制谁能注册、以及本地测试验证码怎么安全使用。
|
||||
|
||||
上面用到的环境变量的清单见 [环境变量](/environment-variables);token 怎么用、生命周期细节见 [认证与令牌](/auth-tokens)。
|
||||
|
||||
@@ -27,17 +27,24 @@ Multica 支持两种登录方式:**Email + 验证码**(默认)和 **Google
|
||||
|
||||
**不配 `RESEND_API_KEY` 的后果**:server 不报错,但**所有本该发出去的邮件只打到 server 的 stdout**。本地开发方便(你从日志抄验证码),生产环境等于黑洞。
|
||||
|
||||
## 888888 陷阱
|
||||
## 固定本地测试验证码
|
||||
|
||||
<Callout type="warning">
|
||||
**`APP_ENV` 不设为 `production`,任何人都能用验证码 `888888` 登录任何账号。**
|
||||
**不要在公网可访问实例上启用固定验证码。**
|
||||
|
||||
Multica 有一个开发用的主验证码(master code)`888888`——为了本地开发不依赖 Resend 而设的后门。判定逻辑很简单:`APP_ENV != "production"` 时,**任何邮箱**输 `888888` 都能通过。
|
||||
旧版「非 production 默认接受 `888888`」的行为已经移除。除非你显式配置,否则输入 `888888` 会和普通错误验证码一样被拒绝。
|
||||
|
||||
**生产部署必须设 `APP_ENV=production`**。如果你用 `make selfhost` / `docker-compose.selfhost.yml` 自部署,这个值已经默认设为 `production`;但如果你自己从源码部署、自己写 Docker 配置、或者在 Kubernetes 里重新定义环境变量——一定要自己把 `APP_ENV=production` 加上。
|
||||
不配 Resend 的本地开发,应使用 server 日志里打印的随机验证码。如果你需要确定性的本地/私有自动化测试,可以把 `MULTICA_DEV_VERIFICATION_CODE` 设成一个 6 位数字,比如 `888888`,并保持 `APP_ENV` 为非 production:
|
||||
|
||||
```bash
|
||||
APP_ENV=development
|
||||
MULTICA_DEV_VERIFICATION_CODE=888888
|
||||
```
|
||||
|
||||
`APP_ENV=production` 时这个快捷码会被忽略。
|
||||
</Callout>
|
||||
|
||||
检查你的部署是否有这个陷阱:打开登录页,输入**任意邮箱**请求验证码,再在验证码栏输 `888888`。如果能登进去 = 你的 `APP_ENV` 没设成 `production`,**整个实例处于完全开放状态**。
|
||||
生产部署应保持 `MULTICA_DEV_VERIFICATION_CODE` 为空,并设置 `APP_ENV=production`。如果你用 `make selfhost` / `docker-compose.selfhost.yml` 自部署,`APP_ENV` 默认就是 `production`。
|
||||
|
||||
## 怎么配 Google OAuth
|
||||
|
||||
|
||||
@@ -38,7 +38,7 @@ In day-to-day use you'll only touch the first two directly. The **[daemon](/daem
|
||||
2. Enter the code; the server issues a JWT cookie (browser) or exchanges it for a PAT (CLI).
|
||||
|
||||
<Callout type="warning">
|
||||
**Self-hosting operators, take note**: if `APP_ENV` is not set to `production`, the verification code is always `888888` — anyone can sign in as anyone. See [Self-host auth configuration](/auth-setup).
|
||||
**Self-hosting operators, take note**: keep `MULTICA_DEV_VERIFICATION_CODE` empty on public deployments. If you enable a fixed local test code, anyone who can request a code can sign in with that value while `APP_ENV` is non-production. See [Self-host auth configuration](/auth-setup).
|
||||
</Callout>
|
||||
|
||||
### Google OAuth
|
||||
|
||||
@@ -38,7 +38,7 @@ Multica 有三种令牌,对应三种使用场景:浏览器 Web UI、命令
|
||||
2. 输入验证码,server 签发 JWT cookie(浏览器)或交换出 PAT(CLI)
|
||||
|
||||
<Callout type="warning">
|
||||
**自部署运维注意**:如果环境变量 `APP_ENV` 不是 `production`,验证码恒为 `888888`——任何人能登录任何账号。详见 [自部署的认证配置](/auth-setup)。
|
||||
**自部署运维注意**:公网部署时保持 `MULTICA_DEV_VERIFICATION_CODE` 为空。如果启用固定本地测试验证码,在 `APP_ENV` 非 production 时,任何能请求验证码的人都能用该固定值登录。详见 [自部署的认证配置](/auth-setup)。
|
||||
</Callout>
|
||||
|
||||
### Google OAuth
|
||||
|
||||
85
apps/docs/content/docs/autopilots.mdx
Normal file
85
apps/docs/content/docs/autopilots.mdx
Normal file
@@ -0,0 +1,85 @@
|
||||
---
|
||||
title: Autopilots
|
||||
description: Let agents start work on a cron schedule — or trigger once manually via the UI or CLI.
|
||||
---
|
||||
|
||||
import { Callout } from "fumadocs-ui/components/callout";
|
||||
|
||||
Autopilots let [agents](/agents) **start work automatically on a schedule** — configure a cron expression and a timezone, and Multica dispatches a [`task`](/tasks) on its own, without you triggering anything. It fits periodic checks, recurring reports, and overnight cleanup jobs — the "standing order" shape of work. Compared to the other three trigger paths ([assigning](/assigning-issues), [@-mention](/mentioning-agents), and [chat](/chat), where you are the one kicking things off), the core difference with Autopilots is that they are **time-driven**.
|
||||
|
||||
## Configure an autopilot
|
||||
|
||||
Create a new autopilot on the workspace's **Autopilot** page. You set:
|
||||
|
||||
- **Name** — display name
|
||||
- **Agent** — who the run is dispatched to
|
||||
- **Priority** — inherited by the `task` it produces (same semantics as issue priority)
|
||||
- **Description / prompt** — the work description the agent receives each run
|
||||
- **Execution mode** — see below
|
||||
- **Triggers** — at least one `schedule` (cron + timezone)
|
||||
|
||||
## Pick an execution mode
|
||||
|
||||
An autopilot has two execution modes. **Start with "create issue" mode.**
|
||||
|
||||
- **Create issue mode** (`create_issue`) — default, **recommended**. Each trigger first creates an issue in the workspace (the title supports interpolation like `{{date}}`), then assigns the issue to the agent through the normal assignment flow. All work lands on the issue board with the same history, comments, and status as a manually assigned issue.
|
||||
- **Run-only mode** (`run_only`) — skips issue creation and enqueues a `task` directly. The run is invisible on the board — you can only see it in the autopilot's run history.
|
||||
|
||||
<Callout type="warning">
|
||||
**Run-only mode is currently unstable.** The CLI labels it "not yet supported end-to-end," and the dispatch path has known issues. New users should stick to create issue mode and wait for run-only mode to ship a stable release before switching.
|
||||
</Callout>
|
||||
|
||||
## Run it on a schedule
|
||||
|
||||
Every autopilot needs at least one `schedule` trigger. Cron uses the **standard 5-field format** (minute hour day month weekday), with **1-minute** minimum granularity (no seconds). Timezone is IANA-formatted (for example, `Asia/Shanghai`) and determines which timezone the cron expression is interpreted in.
|
||||
|
||||
A few examples:
|
||||
|
||||
- `0 9 * * 1-5`, `Asia/Shanghai` — 9 AM Beijing time on weekdays
|
||||
- `*/30 * * * *`, `UTC` — every 30 minutes
|
||||
- `0 3 * * *`, `UTC` — every day at 3 AM UTC
|
||||
|
||||
The Multica server scans for due triggers every **30 seconds** — **the actual fire time can lag by up to 30 seconds**, not down to the second. If the server is restarted across a fire time, it catches up missed triggers on startup (nothing is lost, but they fire right away).
|
||||
|
||||
## Trigger once manually
|
||||
|
||||
To avoid waiting for cron while debugging an autopilot, trigger it manually:
|
||||
|
||||
- UI: click "Run now" on the autopilot detail page
|
||||
- CLI:
|
||||
|
||||
```bash
|
||||
multica autopilot trigger <autopilot-id>
|
||||
```
|
||||
|
||||
A manual trigger goes through the exact same execution flow as a `schedule` trigger — only the `source` field on the run record is marked `manual`.
|
||||
|
||||
## View run history
|
||||
|
||||
Every trigger produces a **run record**, visible on the "History" tab of the autopilot detail page:
|
||||
|
||||
- Trigger source (`schedule` / `manual`)
|
||||
- Start time, completion time
|
||||
- Status (`issue_created` / `running` / `completed` / `failed`)
|
||||
- The linked issue (create issue mode) or `task` (run-only mode)
|
||||
- Failure reason (if failed)
|
||||
|
||||
## What happens when an autopilot fails
|
||||
|
||||
<Callout type="warning">
|
||||
**Autopilot failures are not auto-retried and do not send inbox notifications.** A failure leaves a `failed` entry in run history — no system-level re-enqueue like assign or @-mention, and no notification to anyone. If the autopilot is periodic, **the next cron fire will trigger a new run**, but the failed work is not automatically re-run.
|
||||
|
||||
If an autopilot is important, design your own monitoring — for example, have the agent post a comment on success, and catch failures by noticing missing comments.
|
||||
</Callout>
|
||||
|
||||
Why no auto-retry: autopilots are already periodic, so adding system-level retries stacks on top of the next scheduled run and creates duplicate executions. Leaving the schedule entirely to cron keeps it clean.
|
||||
|
||||
## What's not yet available
|
||||
|
||||
**Webhook and API triggers are not available yet.** The autopilot trigger schema reserves `webhook` and `api` types, but **they are not wired up to any ingress route** — the UI can create triggers of either type, but they will not actually fire. Today, **only `schedule` and manual triggers are end-to-end usable.**
|
||||
|
||||
## Next
|
||||
|
||||
- [**Assign issues to agents**](/assigning-issues) — a one-shot hand-off of an issue to an agent
|
||||
- [**@-mention agents in comments**](/mentioning-agents) — pull an agent in to take a look from a comment
|
||||
- [**Chat**](/chat) — one-to-one conversation outside any issue
|
||||
@@ -1,15 +1,15 @@
|
||||
---
|
||||
title: Routines
|
||||
title: Autopilots
|
||||
description: 让智能体按 cron 定时自己开工——或通过 UI / CLI 手动触发一次。
|
||||
---
|
||||
|
||||
import { Callout } from "fumadocs-ui/components/callout";
|
||||
|
||||
Routines 让 [智能体](/agents) **按调度自动开工**——配好 cron 和时区,到点 Multica 自己派发 [`task`](/tasks),不需要你每次触发。适合定期巡检、周期性报告、凌晨跑的清理任务这类"standing order"场景。和前三种触发方式([分配](/assigning-issues) / [@ 提及](/mentioning-agents) / [对话](/chat) 都是你主动喊一声)相比,Routines 的核心差别是**时间驱动**。
|
||||
Autopilots 让 [智能体](/agents) **按调度自动开工**——配好 cron 和时区,到点 Multica 自己派发 [`task`](/tasks),不需要你每次触发。适合定期巡检、周期性报告、凌晨跑的清理任务这类"standing order"场景。和前三种触发方式([分配](/assigning-issues) / [@ 提及](/mentioning-agents) / [对话](/chat) 都是你主动喊一声)相比,Autopilots 的核心差别是**时间驱动**。
|
||||
|
||||
## 配置一个 Routine
|
||||
## 配置一个 Autopilot
|
||||
|
||||
在工作区的 **Routines** 页新建一条 routine,要定下:
|
||||
在工作区的 **Autopilot** 页新建一条 autopilot,要定下:
|
||||
|
||||
- **名字** — 显示名
|
||||
- **执行智能体** — 到点派给谁
|
||||
@@ -20,10 +20,10 @@ Routines 让 [智能体](/agents) **按调度自动开工**——配好 cron 和
|
||||
|
||||
## 选择执行模式
|
||||
|
||||
Routine 有两种执行模式,**建议从"先建 issue 模式"开始**:
|
||||
Autopilot 有两种执行模式,**建议从"先建 issue 模式"开始**:
|
||||
|
||||
- **先建 issue 模式**(`create_issue`)—— 默认,**推荐**。每次触发先在工作区里建一个 issue(标题支持 `{{date}}` 这样的插值),再按分配流程把 issue 派给智能体。所有工作都落在 issue 看板上,历史、评论、状态和手动分配的 issue 完全一致。
|
||||
- **直跑模式**(`run_only`)—— 不建 issue,直接入队一个 `task`。看板上看不到这一次运行——只能在 Routine 的运行历史里看到。
|
||||
- **直跑模式**(`run_only`)—— 不建 issue,直接入队一个 `task`。看板上看不到这一次运行——只能在 Autopilot 的运行历史里看到。
|
||||
|
||||
<Callout type="warning">
|
||||
**直跑模式当前不稳定**——目前在 CLI 里被标注为"not yet supported end-to-end",派发路径有已知问题。新用户只使用先建 issue 模式,等直跑模式 ship 稳定版再切。
|
||||
@@ -31,7 +31,7 @@ Routine 有两种执行模式,**建议从"先建 issue 模式"开始**:
|
||||
|
||||
## 让它按时间跑
|
||||
|
||||
每个 Routine 至少要一个 `schedule` 触发器。Cron 是**标准 5 字段格式**(分 时 日 月 周),最小粒度 **1 分钟**(没有秒级)。时区用 IANA 格式(例如 `Asia/Shanghai`),决定 cron 表达式按哪个时区解读。
|
||||
每个 Autopilot 至少要一个 `schedule` 触发器。Cron 是**标准 5 字段格式**(分 时 日 月 周),最小粒度 **1 分钟**(没有秒级)。时区用 IANA 格式(例如 `Asia/Shanghai`),决定 cron 表达式按哪个时区解读。
|
||||
|
||||
几个例子:
|
||||
|
||||
@@ -43,20 +43,20 @@ Multica 服务器每 **30 秒**扫一次到期的触发器——**触发时刻
|
||||
|
||||
## 手动触发一次
|
||||
|
||||
调试 Routine 时不想等 cron,可以手动触发一次:
|
||||
调试 Autopilot 时不想等 cron,可以手动触发一次:
|
||||
|
||||
- UI:在 Routine 详情页点"手动运行"
|
||||
- UI:在 Autopilot 详情页点"手动运行"
|
||||
- CLI:
|
||||
|
||||
```bash
|
||||
multica autopilot trigger <routine-id>
|
||||
multica autopilot trigger <autopilot-id>
|
||||
```
|
||||
|
||||
手动触发走和 `schedule` 触发完全相同的执行流程,只是运行记录里 `source` 字段标为 `manual`。
|
||||
|
||||
## 看运行历史
|
||||
|
||||
每次触发都会产生一条**运行记录**(run),可以在 Routine 详情页的"历史"tab 看到:
|
||||
每次触发都会产生一条**运行记录**(run),可以在 Autopilot 详情页的"历史"tab 看到:
|
||||
|
||||
- 触发源(`schedule` / `manual`)
|
||||
- 开始时间、完成时间
|
||||
@@ -64,29 +64,19 @@ multica autopilot trigger <routine-id>
|
||||
- 关联的 issue(先建 issue 模式)或 `task`(直跑模式)
|
||||
- 失败原因(如果失败)
|
||||
|
||||
## Routine 失败会怎样
|
||||
## Autopilot 失败会怎样
|
||||
|
||||
<Callout type="warning">
|
||||
**Routine 失败不自动重试,也不发 inbox 通知。** 失败后只在运行历史里留一条 `failed` 记录——不会像分配 / @ 提及那样由系统重新排队,也不会给任何人发通知。如果这条 Routine 是周期任务,**下一次 cron 到点会重新触发一次**(新的 run),但这一次失败的工作不会被自动补跑。
|
||||
**Autopilot 失败不自动重试,也不发 inbox 通知。** 失败后只在运行历史里留一条 `failed` 记录——不会像分配 / @ 提及那样由系统重新排队,也不会给任何人发通知。如果这条 Autopilot 是周期任务,**下一次 cron 到点会重新触发一次**(新的 run),但这一次失败的工作不会被自动补跑。
|
||||
|
||||
如果 Routine 很重要,要自己设计监控——例如让智能体在成功时给自己发个评论,通过缺失评论来发现失败。
|
||||
如果 Autopilot 很重要,要自己设计监控——例如让智能体在成功时给自己发个评论,通过缺失评论来发现失败。
|
||||
</Callout>
|
||||
|
||||
不自动重试的理由:Routine 本身是周期性的,系统层再加自动重试容易和下一次调度叠加,产生重复执行。调度权完全交给 cron 最干净。
|
||||
不自动重试的理由:Autopilot 本身是周期性的,系统层再加自动重试容易和下一次调度叠加,产生重复执行。调度权完全交给 cron 最干净。
|
||||
|
||||
## 两个遗留的命名 / 能力点
|
||||
## 暂不可用的能力
|
||||
|
||||
**CLI 里它叫 `autopilot`**。当前 CLI 子命令是 `multica autopilot` 而不是 `multica routine`:
|
||||
|
||||
```bash
|
||||
multica autopilot list
|
||||
multica autopilot create
|
||||
multica autopilot trigger <id>
|
||||
```
|
||||
|
||||
文档里一律用 Routines,后续版本 CLI 会统一。现在遇到 `autopilot` 字样把它当 Routines 看就行。
|
||||
|
||||
**Webhook 和 API 触发暂不可用**。Routine 的触发器类型在 schema 里预留了 `webhook` 和 `api` 两种,但**还没接入站路由**——UI 可以创建这两类触发器,不会真的触发。目前**只有 `schedule` 和手动触发是端到端可用的**。
|
||||
**Webhook 和 API 触发暂不可用**。Autopilot 的触发器类型在 schema 里预留了 `webhook` 和 `api` 两种,但**还没接入站路由**——UI 可以创建这两类触发器,不会真的触发。目前**只有 `schedule` 和手动触发是端到端可用的**。
|
||||
|
||||
## 下一步
|
||||
|
||||
@@ -59,5 +59,5 @@ Conversations you no longer want to see can be archived — right-click in the c
|
||||
|
||||
## Next
|
||||
|
||||
- [**Routines**](/routines) — let agents start work automatically on a schedule
|
||||
- [**Autopilots**](/autopilots) — let agents start work automatically on a schedule
|
||||
- [**Assign issues to agents**](/assigning-issues) — bring the topic back onto the issue board
|
||||
|
||||
@@ -59,5 +59,5 @@ import { Callout } from "fumadocs-ui/components/callout";
|
||||
|
||||
## 下一步
|
||||
|
||||
- [**Routines**](/routines) —— 让智能体定时自动开工
|
||||
- [**Autopilots**](/autopilots) —— 让智能体定时自动开工
|
||||
- [**分配 issue 给智能体**](/assigning-issues) —— 把话题放回 issue 看板上
|
||||
|
||||
@@ -74,15 +74,13 @@ For the difference between token types, see [Authentication and tokens](/auth-to
|
||||
| `multica skill import ...` | Import a skill from GitHub, ClawHub, or the local machine |
|
||||
| `multica skill files ...` | Nested: manage a skill's files |
|
||||
|
||||
## Routines (CLI command name: `autopilot`)
|
||||
|
||||
In the docs this feature is called **Routines**, but the CLI subcommand name is still `autopilot` — a future release will unify the two. If you're searching for "routines" and can't find it, try `multica autopilot --help`.
|
||||
## Autopilots
|
||||
|
||||
| Command | Purpose |
|
||||
|---|---|
|
||||
| `multica autopilot list` | List every routine in the workspace |
|
||||
| `multica autopilot get <id>` | Show a single routine |
|
||||
| `multica autopilot create ...` | Create a routine |
|
||||
| `multica autopilot list` | List every autopilot in the workspace |
|
||||
| `multica autopilot get <id>` | Show a single autopilot |
|
||||
| `multica autopilot create ...` | Create an autopilot |
|
||||
| `multica autopilot update <id> ...` | Update |
|
||||
| `multica autopilot delete <id>` | Delete |
|
||||
| `multica autopilot runs <id>` | Show run history |
|
||||
|
||||
@@ -74,15 +74,13 @@ Token 类型的详细区分见 [认证与令牌](/auth-tokens)。
|
||||
| `multica skill import ...` | 从 GitHub / ClawHub / 本机导入 Skill |
|
||||
| `multica skill files ...` | 嵌套:管理 Skill 的文件 |
|
||||
|
||||
## Routines(CLI 命令名:`autopilot`)
|
||||
|
||||
文档里叫 **Routines**,但 CLI 子命令名保留为 `autopilot`——后续版本会统一。如果你在搜索 "routines" 相关命令但找不到,用 `multica autopilot --help`。
|
||||
## Autopilots
|
||||
|
||||
| 命令 | 用途 |
|
||||
|---|---|
|
||||
| `multica autopilot list` | 列出工作区所有 routine |
|
||||
| `multica autopilot get <id>` | 查看单个 routine |
|
||||
| `multica autopilot create ...` | 创建 routine |
|
||||
| `multica autopilot list` | 列出工作区所有 autopilot |
|
||||
| `multica autopilot get <id>` | 查看单个 autopilot |
|
||||
| `multica autopilot create ...` | 创建 autopilot |
|
||||
| `multica autopilot update <id> ...` | 修改 |
|
||||
| `multica autopilot delete <id>` | 删除 |
|
||||
| `multica autopilot runs <id>` | 查看运行历史 |
|
||||
|
||||
@@ -34,7 +34,7 @@ Mentioning the same person multiple times in one comment still produces **only o
|
||||
`@all` is a special target: it pushes a notification to every member of the workspace. Both people and agents can use `@all` — which means an agent reporting progress could also `@all`, so remind agents in their instructions to use it sparingly.
|
||||
|
||||
<Callout type="warning">
|
||||
**Use `@all` carefully.** In a larger workspace, a single `@all` generates that many inbox notifications instantly. Reserve it for things everyone genuinely needs to know — not routine updates.
|
||||
**Use `@all` carefully.** In a larger workspace, a single `@all` generates that many inbox notifications instantly. Reserve it for things everyone genuinely needs to know — not day-to-day updates.
|
||||
</Callout>
|
||||
|
||||
## Editing and deleting a comment
|
||||
|
||||
@@ -75,7 +75,7 @@ Multica uses heartbeats to decide whether a runtime is online. Three key numbers
|
||||
Missing is not permanent — as soon as the daemon sends another heartbeat it returns to online, and the runtime record is preserved. Restarting the daemon does not lose runtimes.
|
||||
|
||||
<Callout type="warning">
|
||||
**Tasks running on a missing runtime are marked as failed** (failure reason `runtime_offline`). For retryable sources (issues, chat), Multica automatically requeues them; Routines-triggered tasks are not retried automatically. See [Tasks → Which failures retry automatically](/tasks#which-failures-retry-automatically-which-dont).
|
||||
**Tasks running on a missing runtime are marked as failed** (failure reason `runtime_offline`). For retryable sources (issues, chat), Multica automatically requeues them; Autopilot-triggered tasks are not retried automatically. See [Tasks → Which failures retry automatically](/tasks#which-failures-retry-automatically-which-dont).
|
||||
</Callout>
|
||||
|
||||
## How many tasks can run in parallel
|
||||
|
||||
@@ -75,7 +75,7 @@ Multica 用心跳判断运行时是否在线。三个关键数字:
|
||||
失联不是永久的——守护进程只要再次发出心跳就立刻回到在线,运行时记录也会保留。重启守护进程不会丢运行时。
|
||||
|
||||
<Callout type="warning">
|
||||
**失联的运行时上正在跑的执行任务会被标记为失败**(失败原因 `runtime_offline`)。对可重试的来源(issue、chat),Multica 会自动重新排队;Routines 触发的任务不自动重试。详见 [执行任务 → 哪些失败会自动重试](/tasks#哪些失败会自动重试哪些不会)。
|
||||
**失联的运行时上正在跑的执行任务会被标记为失败**(失败原因 `runtime_offline`)。对可重试的来源(issue、chat),Multica 会自动重新排队;Autopilots 触发的任务不自动重试。详见 [执行任务 → 哪些失败会自动重试](/tasks#哪些失败会自动重试哪些不会)。
|
||||
</Callout>
|
||||
|
||||
## 一次能并发跑多少任务
|
||||
|
||||
@@ -7,20 +7,21 @@ import { Callout } from "fumadocs-ui/components/callout";
|
||||
|
||||
A self-hosted Multica [server](/self-host-quickstart) reads its configuration from environment variables at startup — database, sign-in, email, storage, signup allowlists all live here. This page groups every variable by purpose: each section spells out **what happens if you leave it unset** and **which ones you must set in production**. For how to actually configure the auth-related ones, see [Sign-in and signup configuration](/auth-setup).
|
||||
|
||||
## The five required at startup
|
||||
## Core server variables
|
||||
|
||||
These are the five you must think about before deploying — some have defaults that let the server start, but in production you should set all of them explicitly.
|
||||
These are the core variables you must think about before deploying — some have defaults that let the server start, but in production you should set the required ones explicitly.
|
||||
|
||||
| Variable | Default | Required in production? |
|
||||
|---|---|---|
|
||||
| `DATABASE_URL` | `postgres://multica:multica@localhost:5432/multica?sslmode=disable` | **Yes** |
|
||||
| `PORT` | `8080` | No (unless you change the port) |
|
||||
| `JWT_SECRET` | `multica-dev-secret-change-in-production` | **Yes** (the default is unsafe) |
|
||||
| `APP_ENV` | empty | **Yes** (must be `production` — see the next section for the trap) |
|
||||
| `APP_ENV` | empty | **Yes** (must be `production`) |
|
||||
| `FRONTEND_ORIGIN` | empty | **Yes** (self-host must set its own domain) |
|
||||
| `MULTICA_DEV_VERIFICATION_CODE` | empty | No (must stay empty in production) |
|
||||
|
||||
<Callout type="warning">
|
||||
**If `APP_ENV` is not set to `production`, anyone can sign in to any account using the code `888888`.** Multica has a development-only master code, `888888` — when `APP_ENV != "production"`, **any email** plus `888888` passes verification. The behavior is intentional for local development (no Resend dependency); **in production, failing to set `production` is equivalent to disabling auth entirely**. See [Sign-in and signup configuration → The 888888 trap](/auth-setup#the-888888-trap).
|
||||
**Keep `MULTICA_DEV_VERIFICATION_CODE` empty in production.** A fixed local test code is disabled by default, but if you opt in with `MULTICA_DEV_VERIFICATION_CODE=888888`, anyone who can request a code can sign in with that fixed value while `APP_ENV` is non-production. The shortcut is ignored when `APP_ENV=production`.
|
||||
</Callout>
|
||||
|
||||
### Database connection pool
|
||||
|
||||
@@ -7,20 +7,21 @@ import { Callout } from "fumadocs-ui/components/callout";
|
||||
|
||||
Multica 的 [自部署](/self-host-quickstart) 服务器启动时从环境变量读取配置——数据库、登录、邮件、存储、注册白名单都在这里配。这一页按用途分组给完整清单:每组说清楚**不设会怎样**、**生产必须设哪几个**。Auth 相关那几个怎么真正配见 [登录与注册配置](/auth-setup)。
|
||||
|
||||
## 启动必填的五个
|
||||
## 核心 server 环境变量
|
||||
|
||||
这五个是你部署前必须考虑的——有些有默认值能让 server 启动,但生产环境里你应该全部显式配。
|
||||
这些是你部署前必须考虑的核心变量——有些有默认值能让 server 启动,但生产环境里你应该显式配置必填项。
|
||||
|
||||
| 环境变量 | 默认值 | 生产必须设? |
|
||||
|---|---|---|
|
||||
| `DATABASE_URL` | `postgres://multica:multica@localhost:5432/multica?sslmode=disable` | **是** |
|
||||
| `PORT` | `8080` | 否(除非换端口)|
|
||||
| `JWT_SECRET` | `multica-dev-secret-change-in-production` | **是**(默认值不安全)|
|
||||
| `APP_ENV` | 空 | **是**(必须 `production`——见下一节陷阱)|
|
||||
| `APP_ENV` | 空 | **是**(必须 `production`)|
|
||||
| `FRONTEND_ORIGIN` | 空 | **是**(self-host 要填你自己的域名)|
|
||||
| `MULTICA_DEV_VERIFICATION_CODE` | 空 | 否(生产必须保持为空)|
|
||||
|
||||
<Callout type="warning">
|
||||
**`APP_ENV` 不设为 `production`,任何人都能用 `888888` 登录任何账号。** Multica 有一个开发用的主验证码(master code)`888888`——`APP_ENV != "production"` 时**任何邮箱**输 `888888` 都能通过。本地开发时故意留空方便调试;**生产环境一旦不设 `production`,等于 auth 完全失效**。详见 [登录与注册配置 → 888888 陷阱](/auth-setup#888888-陷阱)。
|
||||
**生产环境保持 `MULTICA_DEV_VERIFICATION_CODE` 为空。** 固定本地测试验证码默认关闭;如果你设置 `MULTICA_DEV_VERIFICATION_CODE=888888`,在 `APP_ENV` 非 production 时,任何能请求验证码的人都能用这个固定值登录。`APP_ENV=production` 时该快捷码会被忽略。
|
||||
</Callout>
|
||||
|
||||
### 数据库连接池
|
||||
|
||||
@@ -31,7 +31,7 @@ curl -fsSL https://raw.githubusercontent.com/multica-ai/multica/main/scripts/ins
|
||||
multica setup self-host
|
||||
```
|
||||
|
||||
This installs the CLI, checks out the latest self-host assets, pulls the official Multica images from GHCR, and configures everything for localhost. Then open http://localhost:3000 and pick a login method: configure `RESEND_API_KEY` in `.env` for email-based codes (recommended), or set `APP_ENV=development` in `.env` to enable the dev master code **`888888`**. See [Step 2 — Log In](#step-2--log-in) for details.
|
||||
This installs the CLI, checks out the latest self-host assets, pulls the official Multica images from GHCR, and configures everything for localhost. Then open http://localhost:3000 and pick a login method: configure `RESEND_API_KEY` in `.env` for email-based codes (recommended), or leave Resend unset and copy the generated code from backend logs. See [Step 2 — Log In](#step-2--log-in) for details.
|
||||
|
||||
<Callout>
|
||||
If the self-host server is already running and you only need the CLI on a macOS/Linux machine, install it with Homebrew: `brew install multica-ai/tap/multica`.
|
||||
@@ -68,16 +68,16 @@ If you prefer running the Docker Compose steps manually: `cp .env.example .env`,
|
||||
|
||||
### Step 2 — Log In
|
||||
|
||||
Open http://localhost:3000. The Docker self-host stack defaults to `APP_ENV=production` (set in `docker-compose.selfhost.yml`), so the dev master code is **disabled by default** for safety on public deployments. Pick one of the following to log in:
|
||||
Open http://localhost:3000. The Docker self-host stack defaults to `APP_ENV=production` (set in `docker-compose.selfhost.yml`), and there is no fixed verification code by default. Pick one of the following to log in:
|
||||
|
||||
- **Recommended (production):** configure `RESEND_API_KEY` in `.env`, then restart the backend. Real verification codes will be sent to the email address you enter. See [Configuration](#configuration) below.
|
||||
- **Evaluation / private network:** set `APP_ENV=development` in `.env` and restart the backend. Verification code **`888888`** will then work for any email address.
|
||||
- **Without configuring either:** the verification code is generated server-side and printed to the backend container logs (look for `[DEV] Verification code for ...:`). Useful for one-off testing on a single machine.
|
||||
- **Without email configured:** the verification code is generated server-side and printed to the backend container logs (look for `[DEV] Verification code for ...:`). Useful for one-off testing on a single machine.
|
||||
- **Deterministic local/private testing:** set `APP_ENV=development` and `MULTICA_DEV_VERIFICATION_CODE=888888` in `.env`, then restart the backend. This fixed code is ignored when `APP_ENV=production`.
|
||||
|
||||
Changes to `ALLOW_SIGNUP` and `GOOGLE_CLIENT_ID` also take effect after restarting the backend / compose stack. The web UI reads both from `/api/config` at runtime, so no web rebuild is needed.
|
||||
|
||||
<Callout>
|
||||
**Warning:** do **not** set `APP_ENV=development` on a publicly reachable instance — anyone who knows an email address can then log in with `888888`.
|
||||
**Warning:** do **not** set `MULTICA_DEV_VERIFICATION_CODE` on a publicly reachable instance — anyone who knows an email address can then log in with that fixed code.
|
||||
</Callout>
|
||||
|
||||
### Step 3 — Install CLI & Start Daemon
|
||||
@@ -408,14 +408,23 @@ NEXT_PUBLIC_WS_URL=wss://api.example.com/ws
|
||||
|
||||
## Health Check
|
||||
|
||||
The backend exposes a health check endpoint:
|
||||
The backend exposes public health endpoints:
|
||||
|
||||
```
|
||||
```text
|
||||
GET /health
|
||||
→ {"status":"ok"}
|
||||
|
||||
GET /readyz
|
||||
→ {"status":"ok","checks":{"db":"ok","migrations":"ok"}}
|
||||
|
||||
GET /healthz
|
||||
→ same response as /readyz
|
||||
```
|
||||
|
||||
Use this for load balancer health checks or monitoring.
|
||||
Use `/health` for basic liveness / reachability checks. Use `/readyz` for
|
||||
dependency-aware readiness probes and external monitoring that should fail when
|
||||
the database is unavailable or migrations are not fully applied. `/healthz` is
|
||||
kept as an alias for operator familiarity.
|
||||
|
||||
## Upgrading
|
||||
|
||||
|
||||
@@ -39,7 +39,7 @@ It's not only "assign an issue" — Multica has 4 triggers, one per collaboratio
|
||||
| **Assign an issue** | The most common. Assign an issue to an agent and it starts on its own | [Assigning issues](/assigning-issues) |
|
||||
| **@mention an agent in a comment** | "Take a look at this one for me" — don't change the assignee or status, just fire off a comment | [Mentioning agents](/mentioning-agents) |
|
||||
| **Direct chat** | Standalone conversation, not tied to an issue — ask questions, have it draft an issue | [Chat](/chat) |
|
||||
| **Routines (scheduled)** | Standing instructions — "do a standup summary every Monday morning" and the like | [Routines](/routines) |
|
||||
| **Autopilots (scheduled)** | Standing instructions — "do a standup summary every Monday morning" and the like | [Autopilots](/autopilots) |
|
||||
|
||||
## Runtimes: where it runs, and how many tools
|
||||
|
||||
|
||||
@@ -39,7 +39,7 @@ Multica 是一个**分布式**平台。你看到的 Web 界面只是前台——
|
||||
| **分配 issue** | 最常见。把一条 issue 指派给智能体,它自动开工 | [分配 issue](/assigning-issues) |
|
||||
| **在评论里 @智能体** | "这条你帮我看一下"——不改 assignee、不改状态,用一条评论触发 | [在评论里 @智能体](/mentioning-agents) |
|
||||
| **直接聊天** | 独立对话,不绑 issue——问问题、让它帮起草任务 | [聊天](/chat) |
|
||||
| **Routines(定时)** | 长期指令——每周一早上做 standup 总结之类 | [Routines](/routines) |
|
||||
| **Autopilots(定时)** | 长期指令——每周一早上做 standup 总结之类 | [Autopilots](/autopilots) |
|
||||
|
||||
## 运行时:在哪里跑,跑几家工具
|
||||
|
||||
|
||||
@@ -54,5 +54,5 @@ This guard **only blocks direct self-references.** Agent A @-mentioning agent B
|
||||
## Next
|
||||
|
||||
- [**Chat**](/chat) — one-to-one conversation outside any issue
|
||||
- [**Routines**](/routines) — let agents start work automatically on a schedule
|
||||
- [**Autopilots**](/autopilots) — let agents start work automatically on a schedule
|
||||
- [**Comments**](/comments) — `@mention` syntax, the picker, and `@all` semantics
|
||||
|
||||
@@ -54,5 +54,5 @@ import { Callout } from "fumadocs-ui/components/callout";
|
||||
## 下一步
|
||||
|
||||
- [**对话**](/chat) —— 脱离 issue 和智能体一对一聊
|
||||
- [**Routines**](/routines) —— 让智能体定时自动开工
|
||||
- [**Autopilots**](/autopilots) —— 让智能体定时自动开工
|
||||
- [**评论**](/comments) —— `@mention` 的语法、picker、`@all` 的语义
|
||||
|
||||
@@ -22,7 +22,7 @@
|
||||
"assigning-issues",
|
||||
"mentioning-agents",
|
||||
"chat",
|
||||
"routines",
|
||||
"autopilots",
|
||||
"---Inbox---",
|
||||
"inbox",
|
||||
"---Self-hosting & ops---",
|
||||
|
||||
@@ -22,7 +22,7 @@
|
||||
"assigning-issues",
|
||||
"mentioning-agents",
|
||||
"chat",
|
||||
"routines",
|
||||
"autopilots",
|
||||
"---收件箱---",
|
||||
"inbox",
|
||||
"---自部署运维---",
|
||||
|
||||
@@ -22,7 +22,7 @@ For guidance on picking a tool when creating an agent, see [Creating and configu
|
||||
| **Kimi** | Moonshot | ✅ | ❌ | `.kimi/skills/` | Dynamic discovery |
|
||||
| **OpenCode** | SST | ✅ | ❌ | `.config/opencode/skills/` | Dynamic discovery |
|
||||
| **OpenClaw** | Open source | ✅ | ❌ | `.agent_context/skills/` (fallback) | Bound to the agent, can't be switched per task |
|
||||
| **Pi** | Inflection AI | ✅ (session is a file path) | ❌ | `.pi/agent/skills/` | Dynamic discovery |
|
||||
| **Pi** | Inflection AI | ✅ (session is a file path) | ❌ | `.pi/skills/` | Dynamic discovery |
|
||||
|
||||
## What each tool is for
|
||||
|
||||
@@ -98,7 +98,7 @@ Each tool uses **its own** skill discovery path. Before a task runs, the Multica
|
||||
| Cursor | `.cursor/skills/` | ✅ Native |
|
||||
| Kimi | `.kimi/skills/` | ✅ Native |
|
||||
| OpenCode | `.config/opencode/skills/` | ✅ Native |
|
||||
| Pi | `.pi/agent/skills/` | ✅ Native |
|
||||
| Pi | `.pi/skills/` | ✅ Native |
|
||||
| Gemini | `.agent_context/skills/` | ⚠️ Generic fallback |
|
||||
| Hermes | `.agent_context/skills/` | ⚠️ Generic fallback |
|
||||
| OpenClaw | `.agent_context/skills/` | ⚠️ Generic fallback |
|
||||
|
||||
@@ -22,7 +22,7 @@ Multica 内置支持 **10 款 AI 编程工具**。它们都实现了同一套接
|
||||
| **Kimi** | Moonshot | ✅ | ❌ | `.kimi/skills/` | 动态发现 |
|
||||
| **OpenCode** | SST | ✅ | ❌ | `.config/opencode/skills/` | 动态发现 |
|
||||
| **OpenClaw** | 开源项目 | ✅ | ❌ | `.agent_context/skills/` (fallback)| 绑定在智能体上,不能在任务里切换 |
|
||||
| **Pi** | Inflection AI | ✅(session 为文件路径)| ❌ | `.pi/agent/skills/` | 动态发现 |
|
||||
| **Pi** | Inflection AI | ✅(session 为文件路径)| ❌ | `.pi/skills/` | 动态发现 |
|
||||
|
||||
## 每款工具的定位
|
||||
|
||||
@@ -98,7 +98,7 @@ Inflection AI 出品,极简主义。**会话恢复机制特殊**——session
|
||||
| Cursor | `.cursor/skills/` | ✅ 原生 |
|
||||
| Kimi | `.kimi/skills/` | ✅ 原生 |
|
||||
| OpenCode | `.config/opencode/skills/` | ✅ 原生 |
|
||||
| Pi | `.pi/agent/skills/` | ✅ 原生 |
|
||||
| Pi | `.pi/skills/` | ✅ 原生 |
|
||||
| Gemini | `.agent_context/skills/` | ⚠️ 通用 fallback |
|
||||
| Hermes | `.agent_context/skills/` | ⚠️ 通用 fallback |
|
||||
| OpenClaw | `.agent_context/skills/` | ⚠️ 通用 fallback |
|
||||
|
||||
@@ -1,95 +0,0 @@
|
||||
---
|
||||
title: Routines
|
||||
description: Let agents start work on a cron schedule — or trigger once manually via the UI or CLI.
|
||||
---
|
||||
|
||||
import { Callout } from "fumadocs-ui/components/callout";
|
||||
|
||||
Routines let [agents](/agents) **start work automatically on a schedule** — configure a cron expression and a timezone, and Multica dispatches a [`task`](/tasks) on its own, without you triggering anything. It fits periodic checks, recurring reports, and overnight cleanup jobs — the "standing order" shape of work. Compared to the other three trigger paths ([assigning](/assigning-issues), [@-mention](/mentioning-agents), and [chat](/chat), where you are the one kicking things off), the core difference with Routines is that they are **time-driven**.
|
||||
|
||||
## Configure a routine
|
||||
|
||||
Create a new routine on the workspace's **Routines** page. You set:
|
||||
|
||||
- **Name** — display name
|
||||
- **Agent** — who the run is dispatched to
|
||||
- **Priority** — inherited by the `task` it produces (same semantics as issue priority)
|
||||
- **Description / prompt** — the work description the agent receives each run
|
||||
- **Execution mode** — see below
|
||||
- **Triggers** — at least one `schedule` (cron + timezone)
|
||||
|
||||
## Pick an execution mode
|
||||
|
||||
A routine has two execution modes. **Start with "create issue" mode.**
|
||||
|
||||
- **Create issue mode** (`create_issue`) — default, **recommended**. Each trigger first creates an issue in the workspace (the title supports interpolation like `{{date}}`), then assigns the issue to the agent through the normal assignment flow. All work lands on the issue board with the same history, comments, and status as a manually assigned issue.
|
||||
- **Run-only mode** (`run_only`) — skips issue creation and enqueues a `task` directly. The run is invisible on the board — you can only see it in the routine's run history.
|
||||
|
||||
<Callout type="warning">
|
||||
**Run-only mode is currently unstable.** The CLI labels it "not yet supported end-to-end," and the dispatch path has known issues. New users should stick to create issue mode and wait for run-only mode to ship a stable release before switching.
|
||||
</Callout>
|
||||
|
||||
## Run it on a schedule
|
||||
|
||||
Every routine needs at least one `schedule` trigger. Cron uses the **standard 5-field format** (minute hour day month weekday), with **1-minute** minimum granularity (no seconds). Timezone is IANA-formatted (for example, `Asia/Shanghai`) and determines which timezone the cron expression is interpreted in.
|
||||
|
||||
A few examples:
|
||||
|
||||
- `0 9 * * 1-5`, `Asia/Shanghai` — 9 AM Beijing time on weekdays
|
||||
- `*/30 * * * *`, `UTC` — every 30 minutes
|
||||
- `0 3 * * *`, `UTC` — every day at 3 AM UTC
|
||||
|
||||
The Multica server scans for due triggers every **30 seconds** — **the actual fire time can lag by up to 30 seconds**, not down to the second. If the server is restarted across a fire time, it catches up missed triggers on startup (nothing is lost, but they fire right away).
|
||||
|
||||
## Trigger once manually
|
||||
|
||||
To avoid waiting for cron while debugging a routine, trigger it manually:
|
||||
|
||||
- UI: click "Run now" on the routine detail page
|
||||
- CLI:
|
||||
|
||||
```bash
|
||||
multica autopilot trigger <routine-id>
|
||||
```
|
||||
|
||||
A manual trigger goes through the exact same execution flow as a `schedule` trigger — only the `source` field on the run record is marked `manual`.
|
||||
|
||||
## View run history
|
||||
|
||||
Every trigger produces a **run record**, visible on the "History" tab of the routine detail page:
|
||||
|
||||
- Trigger source (`schedule` / `manual`)
|
||||
- Start time, completion time
|
||||
- Status (`issue_created` / `running` / `completed` / `failed`)
|
||||
- The linked issue (create issue mode) or `task` (run-only mode)
|
||||
- Failure reason (if failed)
|
||||
|
||||
## What happens when a routine fails
|
||||
|
||||
<Callout type="warning">
|
||||
**Routine failures are not auto-retried and do not send inbox notifications.** A failure leaves a `failed` entry in run history — no system-level re-enqueue like assign or @-mention, and no notification to anyone. If the routine is periodic, **the next cron fire will trigger a new run**, but the failed work is not automatically re-run.
|
||||
|
||||
If a routine is important, design your own monitoring — for example, have the agent post a comment on success, and catch failures by noticing missing comments.
|
||||
</Callout>
|
||||
|
||||
Why no auto-retry: routines are already periodic, so adding system-level retries stacks on top of the next scheduled run and creates duplicate executions. Leaving the schedule entirely to cron keeps it clean.
|
||||
|
||||
## Two naming / capability carryovers
|
||||
|
||||
**In the CLI it is called `autopilot`.** The current CLI subcommand is `multica autopilot` rather than `multica routine`:
|
||||
|
||||
```bash
|
||||
multica autopilot list
|
||||
multica autopilot create
|
||||
multica autopilot trigger <id>
|
||||
```
|
||||
|
||||
The docs use Routines throughout; a future CLI release will unify the naming. For now, treat any `autopilot` wording as Routines.
|
||||
|
||||
**Webhook and API triggers are not available yet.** The routine trigger schema reserves `webhook` and `api` types, but **they are not wired up to any ingress route** — the UI can create triggers of either type, but they will not actually fire. Today, **only `schedule` and manual triggers are end-to-end usable.**
|
||||
|
||||
## Next
|
||||
|
||||
- [**Assign issues to agents**](/assigning-issues) — a one-shot hand-off of an issue to an agent
|
||||
- [**@-mention agents in comments**](/mentioning-agents) — pull an agent in to take a look from a comment
|
||||
- [**Chat**](/chat) — one-to-one conversation outside any issue
|
||||
@@ -31,6 +31,9 @@ make selfhost
|
||||
3. Bring up every service using `docker-compose.selfhost.yml`
|
||||
4. Wait until the backend's `/health` endpoint is ready
|
||||
|
||||
For ongoing production probes after startup, use `/readyz` when you want the
|
||||
check to fail on database or migration problems.
|
||||
|
||||
The backend container **runs database migrations automatically** on startup (`docker/entrypoint.sh` runs `./migrate up` before the server starts) — you'll see the migration output in the backend logs. Version upgrades are handled the same way.
|
||||
|
||||
<Callout type="info">
|
||||
@@ -42,19 +45,19 @@ Once it's up:
|
||||
- **Frontend**: [http://localhost:3000](http://localhost:3000)
|
||||
- **Backend**: [http://localhost:8080](http://localhost:8080)
|
||||
|
||||
## 2. Important: set `APP_ENV` to `production`
|
||||
## 2. Important: keep production safety on
|
||||
|
||||
<Callout type="warning">
|
||||
**`docker-compose.selfhost.yml` sets `APP_ENV` to `production` by default** — this prevents the development "master code `888888`" from being enabled on an instance you've exposed to the public internet.
|
||||
**`docker-compose.selfhost.yml` sets `APP_ENV` to `production` by default** and leaves `MULTICA_DEV_VERIFICATION_CODE` empty, so there is no fixed code on public instances.
|
||||
|
||||
**But if your `.env` leaves `APP_ENV` empty or sets it to another value**, `888888` is enabled — **anyone can log in as any email by typing `888888` as the verification code**. See [Auth setup → The 888888 trap](/auth-setup#the-888888-trap).
|
||||
Only set `MULTICA_DEV_VERIFICATION_CODE` for local or private test automation. If a fixed code is enabled while `APP_ENV` is non-production, anyone who can request a code can sign in with that fixed value. See [Auth setup → Fixed local testing codes](/auth-setup#fixed-local-testing-codes).
|
||||
|
||||
Before any public deployment, make sure `.env` has `APP_ENV=production`.
|
||||
Before any public deployment, make sure `.env` has `APP_ENV=production` and `MULTICA_DEV_VERIFICATION_CODE` is empty.
|
||||
</Callout>
|
||||
|
||||
## 3. Configure the email service (optional but recommended)
|
||||
|
||||
Without email configured, your users can't receive verification codes — **unless `APP_ENV != production`, in which case `888888` works** (see the warning above).
|
||||
Without email configured, your users can't receive verification codes by email; the server prints generated codes to stdout instead.
|
||||
|
||||
To actually send verification emails:
|
||||
|
||||
@@ -77,6 +80,7 @@ Open [http://localhost:3000](http://localhost:3000):
|
||||
|
||||
- Enter your email
|
||||
- Grab the verification code from the Resend email (or, if you haven't configured Resend, from the server container stdout — look for the `[DEV] Verification code` line)
|
||||
- Do not use `888888` unless you explicitly set `MULTICA_DEV_VERIFICATION_CODE=888888` on a non-production private instance
|
||||
- Log in and create your first workspace
|
||||
|
||||
## 5. Point the CLI at your own server
|
||||
|
||||
@@ -31,6 +31,8 @@ make selfhost
|
||||
3. 用 `docker-compose.selfhost.yml` 启动全部服务
|
||||
4. 等后端 `/health` 端点准备就绪
|
||||
|
||||
如果是启动完成后的生产探针,想让数据库或 migration 异常也体现为失败,请改用 `/readyz`。
|
||||
|
||||
后端容器启动时会**自动跑数据库 migration**(`docker/entrypoint.sh` 在启动 server 前执行 `./migrate up`)——你会在 backend 日志里看到 migration 输出。升级版本时同样自动处理。
|
||||
|
||||
<Callout type="info">
|
||||
@@ -42,19 +44,19 @@ make selfhost
|
||||
- **前端**:[http://localhost:3000](http://localhost:3000)
|
||||
- **后端**:[http://localhost:8080](http://localhost:8080)
|
||||
|
||||
## 2. 重要:改 `APP_ENV` 成 `production`
|
||||
## 2. 重要:保持生产安全配置
|
||||
|
||||
<Callout type="warning">
|
||||
**`docker-compose.selfhost.yml` 默认把 `APP_ENV` 设成 `production`**——这防止开发用的"万能验证码 `888888`"在你公网暴露的实例上启用。
|
||||
**`docker-compose.selfhost.yml` 默认把 `APP_ENV` 设成 `production`**,并让 `MULTICA_DEV_VERIFICATION_CODE` 为空,所以公网实例默认没有固定验证码。
|
||||
|
||||
**但如果你的 `.env` 里把 `APP_ENV` 留空或改成其他值**,`888888` 会被启用——**任何人输入任何邮箱 + `888888` 都能登录**。详见 [登录与注册配置 → 888888 陷阱](/auth-setup#888888-陷阱)。
|
||||
只在本地或私有测试自动化里设置 `MULTICA_DEV_VERIFICATION_CODE`。如果在 `APP_ENV` 非 production 时启用了固定验证码,任何能请求验证码的人都能用这个固定值登录。详见 [登录与注册配置 → 固定本地测试验证码](/auth-setup#固定本地测试验证码)。
|
||||
|
||||
公网部署前一定检查 `.env` 里 `APP_ENV=production`。
|
||||
公网部署前一定检查 `.env` 里 `APP_ENV=production`,且 `MULTICA_DEV_VERIFICATION_CODE` 为空。
|
||||
</Callout>
|
||||
|
||||
## 3. 配置邮件服务(可选但推荐)
|
||||
|
||||
如果不配邮件,你的用户无法收到验证码——**但如果 `APP_ENV != production` 你可以用 `888888` 登录**(见上方警告)。
|
||||
如果不配邮件,用户无法通过邮件收到验证码;server 会把生成的验证码打印到 stdout。
|
||||
|
||||
要真的发验证码邮件:
|
||||
|
||||
@@ -77,6 +79,7 @@ make selfhost
|
||||
|
||||
- 输入你的邮箱
|
||||
- 从 Resend 邮件里拿验证码(或者前面没配 Resend 的话从 server 容器的 stdout 里抄 `[DEV] Verification code` 这行)
|
||||
- 不要直接使用 `888888`;只有在非 production 私有实例上显式设置 `MULTICA_DEV_VERIFICATION_CODE=888888` 后它才会生效
|
||||
- 登录后创建第一个工作区
|
||||
|
||||
## 5. 连接命令行工具到你自己的 server
|
||||
|
||||
@@ -6,7 +6,7 @@ description: The unit of work for every agent run, with a clear state machine, t
|
||||
import { Callout } from "fumadocs-ui/components/callout";
|
||||
import { Mermaid } from "@/components/mermaid";
|
||||
|
||||
A **task** is the unit of every [agent](/agents) run — [assigning an issue to an agent](/assigning-issues), [@-mentioning an agent in a comment](/mentioning-agents), sending a message in [chat](/chat), or a [Routine](/routines) firing on schedule all produce a task. Multica puts it in a queue; a [daemon](/daemon-runtimes) picks it up and hands it off to the corresponding [AI coding tool](/providers), then writes the result back to the server when it finishes.
|
||||
A **task** is the unit of every [agent](/agents) run — [assigning an issue to an agent](/assigning-issues), [@-mentioning an agent in a comment](/mentioning-agents), sending a message in [chat](/chat), or an [Autopilot](/autopilots) firing on schedule all produce a task. Multica puts it in a queue; a [daemon](/daemon-runtimes) picks it up and hands it off to the corresponding [AI coding tool](/providers), then writes the result back to the server when it finishes.
|
||||
|
||||
Tasks and [issues](/issues) are two different objects. A single issue can be assigned, @-mentioned, and manually rerun many times — each produces a **new** task.
|
||||
|
||||
@@ -59,10 +59,10 @@ Failures fall into two categories: **retryable** and **non-retryable**.
|
||||
Automatic retry also has two extra conditions:
|
||||
|
||||
1. **At most 2 attempts** — 1 original + 1 retry. If the retry also fails, no further retries, even if the reason is retryable.
|
||||
2. **Only for issue- and chat-triggered tasks** — Routine-triggered tasks do **not** retry automatically.
|
||||
2. **Only for issue- and chat-triggered tasks** — Autopilot-triggered tasks do **not** retry automatically.
|
||||
|
||||
<Callout type="warning">
|
||||
**Routine tasks don't retry automatically** by design. A Routine has its own firing cadence (e.g. daily); automatic retries on failure would overlap with the next scheduled run. If you need an immediate re-run after failure, use a manual rerun (next section).
|
||||
**Autopilot tasks don't retry automatically** by design. An Autopilot has its own firing cadence (e.g. daily); automatic retries on failure would overlap with the next scheduled run. If you need an immediate re-run after failure, use a manual rerun (next section).
|
||||
</Callout>
|
||||
|
||||
## Manual rerun vs. automatic retry
|
||||
@@ -109,4 +109,4 @@ See [Providers Matrix → Session resumption](/providers#session-resumption-who-
|
||||
## Next
|
||||
|
||||
- [Providers Matrix](/providers) — capability differences across the 10 AI coding tools (including the exact session-resumption status)
|
||||
- [Assigning issues to agents](/assigning-issues) / [@-mentioning agents in comments](/mentioning-agents) / [Chat](/chat) / [Routines](/routines) — the four ways to trigger a task
|
||||
- [Assigning issues to agents](/assigning-issues) / [@-mentioning agents in comments](/mentioning-agents) / [Chat](/chat) / [Autopilots](/autopilots) — the four ways to trigger a task
|
||||
|
||||
@@ -6,7 +6,7 @@ description: 智能体每一次工作的单位,有明确的状态机、超时
|
||||
import { Callout } from "fumadocs-ui/components/callout";
|
||||
import { Mermaid } from "@/components/mermaid";
|
||||
|
||||
**执行任务**(task)是 [智能体](/agents) 每一次工作的单位——把一个 [issue 分给智能体](/assigning-issues)、[在评论里 @提及智能体](/mentioning-agents)、在 [聊天](/chat) 里发一条消息、或者 [Routine](/routines) 到点触发,都会产生一个执行任务。Multica 把它放进队列,由 [守护进程](/daemon-runtimes) 领走后交给对应的 [AI 编程工具](/providers) 执行,结束时把结果写回服务器。
|
||||
**执行任务**(task)是 [智能体](/agents) 每一次工作的单位——把一个 [issue 分给智能体](/assigning-issues)、[在评论里 @提及智能体](/mentioning-agents)、在 [聊天](/chat) 里发一条消息、或者 [Autopilot](/autopilots) 到点触发,都会产生一个执行任务。Multica 把它放进队列,由 [守护进程](/daemon-runtimes) 领走后交给对应的 [AI 编程工具](/providers) 执行,结束时把结果写回服务器。
|
||||
|
||||
执行任务和 [issue](/issues) 是两层不同对象:一个 issue 可以反复分配、反复 @提及、手动重跑——每次都产生一个**新的**执行任务。
|
||||
|
||||
@@ -59,10 +59,10 @@ Multica 服务器每 30 秒扫描一次,有两种超时会触发失败:
|
||||
自动重试有两个额外条件:
|
||||
|
||||
1. **最多 2 次**——1 次原任务 + 1 次重试。重试也失败就不再重试,即使原因可重试。
|
||||
2. **只对 issue 和聊天触发的任务生效**——Routines 触发的任务**不自动重试**。
|
||||
2. **只对 issue 和聊天触发的任务生效**——Autopilots 触发的任务**不自动重试**。
|
||||
|
||||
<Callout type="warning">
|
||||
**Routines 任务不自动重试**是刻意设计。Routine 有自己的触发周期(例如每天一次);如果失败又自动重试,会和下一个周期的任务重叠。需要失败后立即重跑,用手动重跑(下一节)。
|
||||
**Autopilots 任务不自动重试**是刻意设计。Autopilot 有自己的触发周期(例如每天一次);如果失败又自动重试,会和下一个周期的任务重叠。需要失败后立即重跑,用手动重跑(下一节)。
|
||||
</Callout>
|
||||
|
||||
## 手动重跑和自动重试的区别
|
||||
@@ -109,4 +109,4 @@ Multica 在任务过程中**两次**保存会话 ID——任务一开始(AI
|
||||
## 下一步
|
||||
|
||||
- [Providers Matrix](/providers) —— 10 款 AI 编程工具的能力差异对照(包括会话恢复的精确状态)
|
||||
- [分配 issue 给智能体](/assigning-issues) / [在评论里 @智能体](/mentioning-agents) / [聊天](/chat) / [Routines](/routines) —— 触发执行任务的四种方式
|
||||
- [分配 issue 给智能体](/assigning-issues) / [在评论里 @智能体](/mentioning-agents) / [聊天](/chat) / [Autopilots](/autopilots) —— 触发执行任务的四种方式
|
||||
|
||||
@@ -25,6 +25,7 @@ Look up issues by symptom. Each entry gives you **symptom / likely causes / how
|
||||
multica daemon logs --lines 100 # look for daemon-side errors
|
||||
echo $MULTICA_SERVER_URL # confirm the address is set
|
||||
curl -i http://<server-host>:8080/health # hit the server directly
|
||||
curl -i http://<server-host>:8080/readyz # include DB + migration readiness
|
||||
cat ~/.multica/config.json # verify api_token exists
|
||||
multica workspace list # confirm you're a member of the target workspace
|
||||
```
|
||||
@@ -107,28 +108,29 @@ On the server side (self-host), grep for `"no_tasks"` / `"no_capacity"` to see t
|
||||
- Domain not verified → run the DNS verification flow in the Resend console (add SPF / DKIM records)
|
||||
- In an emergency (internal testing) → copy the code printed under `[DEV]` from the server logs
|
||||
|
||||
## Verification code `888888` doesn't work
|
||||
## Fixed local test code doesn't work
|
||||
|
||||
**Symptom**: on a self-hosted instance, you try to sign in with the development-only master code `888888` and it's rejected with `invalid or expired code`.
|
||||
**Symptom**: on a self-hosted instance, you try to sign in with a fixed local test code such as `888888` and it's rejected with `invalid or expired code`.
|
||||
|
||||
**Likely causes** (mutually exclusive):
|
||||
|
||||
1. **`APP_ENV=production`** — this is the **correct** production configuration; `888888` is **disabled** when `APP_ENV=production`. Intentional design, not a bug
|
||||
2. **You received a real code via Resend** — if Resend is configured, the server sent an actual email; `888888` is only a dev fallback
|
||||
1. **`MULTICA_DEV_VERIFICATION_CODE` is empty** — fixed codes are disabled by default
|
||||
2. **`APP_ENV=production`** — this is the **correct** production configuration; fixed local test codes are ignored in production
|
||||
3. **The configured code is not 6 digits** — the shortcut only accepts a 6-digit value
|
||||
|
||||
**How to diagnose**:
|
||||
|
||||
```bash
|
||||
cat .env | grep APP_ENV # inspect current config
|
||||
docker exec <container> env | grep APP_ENV # docker deployment
|
||||
cat .env | grep -E 'APP_ENV|MULTICA_DEV_VERIFICATION_CODE'
|
||||
docker exec <container> env | grep -E 'APP_ENV|MULTICA_DEV_VERIFICATION_CODE'
|
||||
```
|
||||
|
||||
Check your inbox (including spam) for the real verification code.
|
||||
|
||||
**How to fix**:
|
||||
|
||||
- In production, you shouldn't be using `888888` at all — configure Resend and use real codes
|
||||
- **For local development or internal testing**, if you need `888888`, ensure `APP_ENV` is unset or not `production` — but **never** run a public instance this way (see [Sign-in and signup configuration → The 888888 trap](/auth-setup#the-888888-trap))
|
||||
- In production, leave `MULTICA_DEV_VERIFICATION_CODE` empty — configure Resend and use real codes
|
||||
- For local development or internal testing, either copy the generated code from server logs or set `APP_ENV=development` plus `MULTICA_DEV_VERIFICATION_CODE=888888` — never enable a fixed code on a public instance (see [Sign-in and signup configuration → Fixed local testing codes](/auth-setup#fixed-local-testing-codes))
|
||||
|
||||
## Port conflicts
|
||||
|
||||
|
||||
@@ -25,6 +25,7 @@ import { Callout } from "fumadocs-ui/components/callout";
|
||||
multica daemon logs --lines 100 # 看 daemon 侧错误
|
||||
echo $MULTICA_SERVER_URL # 确认地址配对
|
||||
curl -i http://<server-host>:8080/health # 直接戳 server
|
||||
curl -i http://<server-host>:8080/readyz # 连同 DB + migration readiness 一起检查
|
||||
cat ~/.multica/config.json # 看 api_token 是否存在
|
||||
multica workspace list # 确认你是目标工作区成员
|
||||
```
|
||||
@@ -107,28 +108,29 @@ multica issue show <issue-id> # 看 task 历史
|
||||
- 域名没验证 → Resend console 里走 DNS 验证流程(加 SPF / DKIM 记录)
|
||||
- 紧急情况下(如内部测试)→ 从 server 日志里抄 `[DEV]` 打印出的验证码
|
||||
|
||||
## 验证码是 `888888` 但登不进去
|
||||
## 固定本地测试验证码登不进去
|
||||
|
||||
**症状**:自部署实例,想用开发用的主验证码 `888888` 登录,但被拒 `invalid or expired code`。
|
||||
**症状**:自部署实例,想用 `888888` 这类固定本地测试验证码登录,但被拒 `invalid or expired code`。
|
||||
|
||||
**可能原因**(这俩互斥):
|
||||
**可能原因**(互斥):
|
||||
|
||||
1. **`APP_ENV=production`** —— 这正是你**应该**的生产配置;`888888` 在 `APP_ENV=production` 时**被禁用**。这是刻意设计,不是 bug
|
||||
2. **你在 Resend 收到了真实验证码** —— 如果 Resend 已配,server 实际发了真邮件,`888888` 只作为 dev fallback
|
||||
1. **`MULTICA_DEV_VERIFICATION_CODE` 为空** —— 固定验证码默认关闭
|
||||
2. **`APP_ENV=production`** —— 这是正确的生产配置;固定本地测试验证码在 production 中会被忽略
|
||||
3. **配置的验证码不是 6 位数字** —— 这个快捷码只接受 6 位数字
|
||||
|
||||
**怎么查**:
|
||||
|
||||
```bash
|
||||
cat .env | grep APP_ENV # 看当前配置
|
||||
docker exec <container> env | grep APP_ENV # docker 部署
|
||||
cat .env | grep -E 'APP_ENV|MULTICA_DEV_VERIFICATION_CODE'
|
||||
docker exec <container> env | grep -E 'APP_ENV|MULTICA_DEV_VERIFICATION_CODE'
|
||||
```
|
||||
|
||||
检查邮箱(含 spam)看有没有收到真实验证码。
|
||||
|
||||
**怎么修**:
|
||||
|
||||
- 生产环境你本来就不该用 `888888`—— 配好 Resend 用真实验证码
|
||||
- **本地开发或内网测试**若需要 `888888`,确保 `APP_ENV` 未设或不是 `production`——但**绝对不要**这样跑公网实例(详见 [登录与注册配置 → 888888 陷阱](/auth-setup#888888-陷阱))
|
||||
- 生产环境保持 `MULTICA_DEV_VERIFICATION_CODE` 为空,配好 Resend 后使用真实验证码
|
||||
- 本地开发或内网测试可以从 server 日志抄生成的验证码;如果需要 `888888`,设置 `APP_ENV=development` 和 `MULTICA_DEV_VERIFICATION_CODE=888888`。不要在公网实例启用固定验证码(详见 [登录与注册配置 → 固定本地测试验证码](/auth-setup#固定本地测试验证码))
|
||||
|
||||
## 端口冲突
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@ import { useLocale } from "../i18n";
|
||||
import { GitHubMark, githubUrl, heroButtonClassName } from "./shared";
|
||||
|
||||
export function HowItWorksSection() {
|
||||
const { t } = useLocale();
|
||||
const { t, locale } = useLocale();
|
||||
const user = useAuthStore((s) => s.user);
|
||||
|
||||
return (
|
||||
@@ -44,6 +44,12 @@ export function HowItWorksSection() {
|
||||
<Link href={user ? "/" : "/login"} className={heroButtonClassName("solid")}>
|
||||
{user ? t.header.dashboard : t.howItWorks.cta}
|
||||
</Link>
|
||||
<Link
|
||||
href={locale === "zh" ? "/docs/zh" : "/docs"}
|
||||
className={heroButtonClassName("ghost")}
|
||||
>
|
||||
{t.howItWorks.ctaDocs}
|
||||
</Link>
|
||||
<Link
|
||||
href={githubUrl}
|
||||
target="_blank"
|
||||
|
||||
@@ -144,6 +144,7 @@ export function createEnDict(allowSignup: boolean): LandingDict {
|
||||
],
|
||||
cta: "Get started",
|
||||
ctaGithub: "View on GitHub",
|
||||
ctaDocs: "Read the docs",
|
||||
},
|
||||
|
||||
openSource: {
|
||||
@@ -232,7 +233,7 @@ export function createEnDict(allowSignup: boolean): LandingDict {
|
||||
resources: {
|
||||
label: "Resources",
|
||||
links: [
|
||||
{ label: "Documentation", href: githubUrl },
|
||||
{ label: "Documentation", href: "/docs" },
|
||||
{ label: "API", href: githubUrl },
|
||||
{ label: "X (Twitter)", href: "https://x.com/MulticaAI" },
|
||||
],
|
||||
@@ -282,6 +283,80 @@ export function createEnDict(allowSignup: boolean): LandingDict {
|
||||
fixes: "Bug Fixes",
|
||||
},
|
||||
entries: [
|
||||
{
|
||||
version: "0.2.18",
|
||||
date: "2026-04-27",
|
||||
title: "Issue Labels, Labs Tab & Sidebar Invite Dot",
|
||||
changes: [],
|
||||
features: [
|
||||
"Issue labels — color-code and filter issues across list, board and detail views",
|
||||
"Labs settings tab for experimental toggles",
|
||||
"Sidebar shows a dot when you have an unread workspace invite",
|
||||
],
|
||||
improvements: [
|
||||
"Project picker now shows the selected project's icon",
|
||||
"Sidebar parent items stay highlighted on detail pages",
|
||||
"Self-hosted deployments correctly honor signup gating env vars",
|
||||
],
|
||||
fixes: [
|
||||
"Agent comments preserve line breaks again",
|
||||
"Desktop RPM no longer conflicts with Slack / VS Code on Fedora",
|
||||
"Windows agents handle multi-line prompts correctly",
|
||||
],
|
||||
},
|
||||
{
|
||||
version: "0.2.17",
|
||||
date: "2026-04-26",
|
||||
title: "Custom Agent Env, Better Failure Messages & Reliability Fixes",
|
||||
changes: [],
|
||||
features: [
|
||||
"`multica agent create/update --custom-env KEY=VALUE` injects custom environment variables into agent runs",
|
||||
"Agent failure messages now include a tail of the runtime CLI's stderr — much easier to debug runtime errors",
|
||||
"CLI update download timeout is now configurable, so slow links no longer abort `multica update`",
|
||||
],
|
||||
improvements: [
|
||||
"Daemon reports cancelled tasks as `cancelled` instead of `timeout`, and reconciles agent status when an issue's tasks are cancelled",
|
||||
"Server heartbeat split into probe/claim with slow-log + a model-list running-timeout, so a lost heartbeat no longer wedges the UI",
|
||||
],
|
||||
fixes: [
|
||||
"Server validates `assignee_id` on issue create/update so phantom IDs are rejected, and `DeleteIssue` uses the resolved issue ID",
|
||||
"Pi runtime now reads/writes `.pi/skills` instead of the old `.pi/agent/skills` path",
|
||||
"Windows daemon uses `CREATE_NEW_CONSOLE` so grandchild console popups no longer appear when launching agents",
|
||||
"Autopilot run-only context is now properly forwarded to the agent",
|
||||
],
|
||||
},
|
||||
{
|
||||
version: "0.2.16",
|
||||
date: "2026-04-24",
|
||||
title: "Chat V2, Issue Right-Click Menu & In-App Feedback",
|
||||
changes: [],
|
||||
features: [
|
||||
"Chat V2 — dedicated sidebar entry and full main-area page for AI conversations",
|
||||
"Right-click context menu on issues with a unified action set across list, board, and detail",
|
||||
"In-app feedback flow with a new Help launcher centralizing docs, support, and feedback",
|
||||
"Autopilot modal redesigned — simpler schema and consistent schedule UI across creation and edit",
|
||||
"Skills page redesigned — list + detail pages, scroll-fade card layout, shared PageHeader and mobile nav",
|
||||
"Bilingual flat-content rewrite of the docs site — English and Chinese sections share one tree",
|
||||
],
|
||||
improvements: [
|
||||
"Agent profile card appears on avatar hover for quick context",
|
||||
"Native right-click menu on desktop with clipboard actions (copy / paste / cut / select all)",
|
||||
"Daemon agent prompts hardened to break self-mention loops between agents",
|
||||
"Server readiness health endpoints for proper rollout / ingress probes",
|
||||
"Daemon GC defaults tightened and now accept flexible duration suffixes (e.g. `7d`, `12h`)",
|
||||
"Test Connection / runtime ping removed — runtime reachability is detected automatically",
|
||||
],
|
||||
fixes: [
|
||||
"Chat no longer flickers when a streamed response finalizes, and the input box no longer jumps when sending the first message",
|
||||
"Desktop reopens the last-used workspace on app start instead of falling back to the first one",
|
||||
"Editor preserves nested ordered lists through the readonly render path",
|
||||
"CLI `browser-login` now works from a machine that isn't running the server",
|
||||
"Daemon suppresses extra terminal windows when launching agents on Windows, and retries local-skill reports on transient server errors",
|
||||
"`/api/config` is publicly reachable again so unauthenticated clients can bootstrap",
|
||||
"Defense-in-depth owner check on workspace deletion, and `/health/realtime` metrics restricted to authorized callers (security)",
|
||||
"Hermes ACP runtime now receives the configured model; OpenClaw agent discovery timeout raised to 30s",
|
||||
],
|
||||
},
|
||||
{
|
||||
version: "0.2.15",
|
||||
date: "2026-04-22",
|
||||
|
||||
@@ -43,6 +43,7 @@ export type LandingDict = {
|
||||
steps: { title: string; description: string }[];
|
||||
cta: string;
|
||||
ctaGithub: string;
|
||||
ctaDocs: string;
|
||||
};
|
||||
openSource: {
|
||||
label: string;
|
||||
|
||||
@@ -144,6 +144,7 @@ export function createZhDict(allowSignup: boolean): LandingDict {
|
||||
],
|
||||
cta: "\u5f00\u59cb\u4f7f\u7528",
|
||||
ctaGithub: "\u5728 GitHub \u4e0a\u67e5\u770b",
|
||||
ctaDocs: "\u9605\u8bfb\u6587\u6863",
|
||||
},
|
||||
|
||||
openSource: {
|
||||
@@ -232,7 +233,7 @@ export function createZhDict(allowSignup: boolean): LandingDict {
|
||||
resources: {
|
||||
label: "\u8d44\u6e90",
|
||||
links: [
|
||||
{ label: "\u6587\u6863", href: githubUrl },
|
||||
{ label: "\u6587\u6863", href: "/docs/zh" },
|
||||
{ label: "API", href: githubUrl },
|
||||
{ label: "X (Twitter)", href: "https://x.com/MulticaAI" },
|
||||
],
|
||||
@@ -282,6 +283,80 @@ export function createZhDict(allowSignup: boolean): LandingDict {
|
||||
fixes: "问题修复",
|
||||
},
|
||||
entries: [
|
||||
{
|
||||
version: "0.2.18",
|
||||
date: "2026-04-27",
|
||||
title: "Issue 标签、Labs 设置页与邀请红点",
|
||||
changes: [],
|
||||
features: [
|
||||
"Issue 标签——给 Issue 上色、分类,列表、看板和详情页都能用",
|
||||
"新增 Labs 设置页,集中放实验性开关",
|
||||
"有未读工作区邀请时,侧边栏会出现红点提示",
|
||||
],
|
||||
improvements: [
|
||||
"Project 选择器会显示当前所选 Project 的图标",
|
||||
"进入详情页时,侧边栏父级菜单保持高亮",
|
||||
"自托管部署正确读取注册放行相关的环境变量",
|
||||
],
|
||||
fixes: [
|
||||
"Agent 评论的换行恢复正常显示",
|
||||
"桌面端 RPM 不再与 Slack / VS Code 在 Fedora 上冲突",
|
||||
"Windows 下 Agent 能正确处理多行 prompt",
|
||||
],
|
||||
},
|
||||
{
|
||||
version: "0.2.17",
|
||||
date: "2026-04-26",
|
||||
title: "Agent 自定义环境变量、更清晰的失败信息与一系列稳定性修复",
|
||||
changes: [],
|
||||
features: [
|
||||
"`multica agent create/update --custom-env KEY=VALUE` 支持为 Agent 注入自定义环境变量",
|
||||
"Agent 失败信息会带上 Runtime CLI 的 stderr 末尾片段,排查 Runtime 报错更直接",
|
||||
"CLI 更新下载超时支持配置,弱网下 `multica update` 不再被默认超时切断",
|
||||
],
|
||||
improvements: [
|
||||
"Daemon 把取消的任务上报为 `cancelled` 而非 `timeout`,并在按 Issue 取消任务时同步对齐 Agent 状态",
|
||||
"Server 心跳拆成 probe/claim 两步,并补上慢日志和 model-list running-timeout,丢心跳不再卡住 UI",
|
||||
],
|
||||
fixes: [
|
||||
"Server 在 Issue 创建/更新时校验 `assignee_id` 真实存在;DeleteIssue 改用解析后的 Issue ID",
|
||||
"Pi Runtime 改为读写 `.pi/skills`,不再使用旧的 `.pi/agent/skills` 路径",
|
||||
"Windows 下 Daemon 启动 Agent 改用 `CREATE_NEW_CONSOLE`,孙子进程不再弹出额外终端窗口",
|
||||
"Autopilot 的 run-only 上下文正确传给被调起的 Agent",
|
||||
],
|
||||
},
|
||||
{
|
||||
version: "0.2.16",
|
||||
date: "2026-04-24",
|
||||
title: "Chat V2、Issue 右键菜单与应用内反馈",
|
||||
changes: [],
|
||||
features: [
|
||||
"Chat V2——侧边栏新增 Chat 入口,主区域提供完整的 AI 对话页面",
|
||||
"Issue 支持右键菜单,列表、看板和详情的操作入口统一收敛",
|
||||
"应用内反馈流程及全新的 Help 启动器,集中托管文档、支持和反馈入口",
|
||||
"Autopilot 弹窗重设计——更简的字段配置,创建与编辑共享一致的排期界面",
|
||||
"Skills 页面重设计——列表+详情、卡片化布局、滚动渐隐和共享 PageHeader / 移动端导航",
|
||||
"文档站重写为双语扁平内容树——中英文章节共用一棵目录",
|
||||
],
|
||||
improvements: [
|
||||
"悬停 Agent 头像即可弹出资料卡,快速了解上下文",
|
||||
"桌面应用新增原生右键菜单,支持复制 / 粘贴 / 剪切 / 全选等剪贴板操作",
|
||||
"Daemon 强化 Agent 提示,避免 Agent 之间形成自互 @ 的循环",
|
||||
"Server 新增就绪态健康检查端点,可对接灰度发布和 Ingress 探针",
|
||||
"Daemon GC 默认参数收紧,并支持灵活的时长后缀(如 `7d`、`12h`)",
|
||||
"移除 Runtime 的 Test Connection / Ping 功能,可达性改为自动检测",
|
||||
],
|
||||
fixes: [
|
||||
"Chat 流式回复结束时不再闪烁,发送第一条消息时输入框不再跳动",
|
||||
"桌面应用启动时正确恢复上次的工作区,而不是默认回到第一个",
|
||||
"编辑器只读渲染路径正确保留嵌套有序列表",
|
||||
"CLI `browser-login` 现在可以从未运行 Server 的机器上发起",
|
||||
"Windows 下 Daemon 启动 Agent 不再拉起额外终端窗口;本地 Skill 上报在服务端瞬时错误时会自动重试",
|
||||
"`/api/config` 重新对未登录客户端可达,方便初次 bootstrap",
|
||||
"DeleteWorkspace 增加防御性 owner 校验;`/health/realtime` 指标限定授权访问(安全)",
|
||||
"Hermes ACP Runtime 正确传递配置的模型;OpenClaw Agent 发现超时提高到 30s",
|
||||
],
|
||||
},
|
||||
{
|
||||
version: "0.2.15",
|
||||
date: "2026-04-22",
|
||||
|
||||
@@ -40,6 +40,7 @@ services:
|
||||
environment:
|
||||
DATABASE_URL: postgres://${POSTGRES_USER:-multica}:${POSTGRES_PASSWORD:-multica}@postgres:5432/${POSTGRES_DB:-multica}?sslmode=disable
|
||||
PORT: "8080"
|
||||
METRICS_ADDR: ${METRICS_ADDR:-}
|
||||
JWT_SECRET: ${JWT_SECRET:-change-me-in-production}
|
||||
FRONTEND_ORIGIN: ${FRONTEND_ORIGIN:-http://localhost:3000}
|
||||
CORS_ALLOWED_ORIGINS: ${CORS_ALLOWED_ORIGINS:-}
|
||||
@@ -55,7 +56,11 @@ services:
|
||||
CLOUDFRONT_PRIVATE_KEY: ${CLOUDFRONT_PRIVATE_KEY:-}
|
||||
COOKIE_DOMAIN: ${COOKIE_DOMAIN:-}
|
||||
APP_ENV: ${APP_ENV:-production}
|
||||
MULTICA_DEV_VERIFICATION_CODE: ${MULTICA_DEV_VERIFICATION_CODE:-}
|
||||
MULTICA_APP_URL: ${MULTICA_APP_URL:-http://localhost:3000}
|
||||
ALLOW_SIGNUP: ${ALLOW_SIGNUP:-true}
|
||||
ALLOWED_EMAILS: ${ALLOWED_EMAILS:-}
|
||||
ALLOWED_EMAIL_DOMAINS: ${ALLOWED_EMAIL_DOMAINS:-}
|
||||
restart: unless-stopped
|
||||
|
||||
frontend:
|
||||
|
||||
@@ -147,7 +147,7 @@ multica issue assign <issue-id> --agent <agent-slug>
|
||||
|
||||
**关键约定**:
|
||||
|
||||
- **Callout**:`<Callout type="info|warning|tip">...</Callout>`。warning 用于陷阱(如 888888),info 用于补充说明,tip 用于最佳实践
|
||||
- **Callout**:`<Callout type="info|warning|tip">...</Callout>`。warning 用于陷阱(如固定测试验证码),info 用于补充说明,tip 用于最佳实践
|
||||
- **代码块**:shell 命令用 \`\`\`bash;配置用 \`\`\`yaml / \`\`\`env;JSON 用 \`\`\`json
|
||||
- **Cross-link**:用 markdown 链接 `[显示文字](/docs/page-slug)`,不要写成 "详见 Tasks 章节"
|
||||
- **表格**:有 3 行以上对照才用表格,不要 1-2 行也用
|
||||
@@ -723,11 +723,11 @@ multica issue assign <issue-id> --agent <agent-slug>
|
||||
|
||||
> **合并说明**:原 7.3 Auth Setup + 7.10 Signup Controls 合并。
|
||||
|
||||
- **Source files**: `server/internal/handler/auth.go`(APP_ENV 判断 + checkSignupAllowed), `.env.example`(auth 相关注释)
|
||||
- **Source files**: `server/internal/handler/auth.go`(固定测试验证码 + checkSignupAllowed), `.env.example`(auth 相关注释)
|
||||
- **目标读者**: self-host 运维
|
||||
- **叙事位置**: self-host 的 auth 配置。
|
||||
- **写什么**(1500-2000 字):
|
||||
- **🚨 超醒目 warning block**:`APP_ENV=production` 必须设置,否则 verification code 恒为 `888888`(任何人登录任何账号)
|
||||
- **🚨 超醒目 warning block**:生产环境必须保持 `MULTICA_DEV_VERIFICATION_CODE` 为空;固定测试验证码只用于非 production 私有测试
|
||||
- Email + verification code 登录流程(依赖 Resend)
|
||||
- Google OAuth 配置步骤(创建 OAuth client → redirect URI → 填 env)
|
||||
- **Signup 白名单三层优先级决策树**:
|
||||
@@ -737,9 +737,9 @@ multica issue assign <issue-id> --agent <agent-slug>
|
||||
- 典型场景:开放给公司域 / 限定几个邮箱 / 完全关闭 signup
|
||||
- 和邀请的关系(signup 关了也能通过邀请加人)
|
||||
- **不写**: JWT 实现、token 类型(§8.2 讲)
|
||||
- **写前要验证**: APP_ENV 判断条件;OAuth 流程最新;Signup 优先级
|
||||
- **写前要验证**: 固定测试验证码的 env 条件;OAuth 流程最新;Signup 优先级
|
||||
- **⚠️ 动笔前必读**:
|
||||
- ⚠️⚠️ **888888 陷阱必须最醒目**(红色 warning block),这是 self-host 最大坑
|
||||
- ⚠️⚠️ **固定测试验证码风险必须最醒目**(红色 warning block),这是 self-host 最大坑
|
||||
- OAuth 给外部步骤截图,别假设读者懂 GCP Console
|
||||
- 决策树建议用 Mermaid 图
|
||||
- **Owner**: –
|
||||
@@ -754,7 +754,7 @@ multica issue assign <issue-id> --agent <agent-slug>
|
||||
- 任务一直 queued(runtime offline / max_concurrent 满 / agent 配错)
|
||||
- WebSocket 连不上(cookie / CORS / proxy)
|
||||
- Email 没收到(Resend 未配置 → 看 stderr)
|
||||
- 验证码收到是 888888 但不工作(APP_ENV 检查)
|
||||
- 固定测试验证码不工作(APP_ENV / MULTICA_DEV_VERIFICATION_CODE 检查)
|
||||
- Port 冲突
|
||||
- 日志位置:daemon / server / browser console
|
||||
- **不写**: 深度 bug report(去 GitHub issue)
|
||||
|
||||
@@ -85,7 +85,7 @@ Multica = **人 + AI agent 在同一个看板上协作的任务管理平台**。
|
||||
| 7 | **The Daemon** | 分布式执行的灵魂;poll + heartbeat + concurrent execution | 每 30s heartbeat;75s 无心跳 → 离线;启动时调 `recover-orphans` 回收孤儿任务;max_concurrent_tasks 有双层(daemon + agent) |
|
||||
| 8 | **Tasks** | 任务是什么;生命周期 queued→dispatched→running→completed/failed | **session_id mid-flight pinning**(agent 首条 system message 一到就持久化,不等完成);失败自动重试只对 issue-sourced 任务(max_attempts=3),chat 和 autopilot 不自动重试 |
|
||||
| 9 | **Triggers & Entry Points** ← **独立页** | 5 种让 task 产生的入口:Assignment / Comment @mention / Chat / Autopilot / Rerun;每种的行为对比 | 每种的 FK 字段不同(trigger_comment_id / chat_session_id / autopilot_run_id);**对比表**:哪种有 session resume / 自动重试 / priority 来源 / dedup |
|
||||
| 10 | **Skills** | 工作区 skill + 本地 skill;按 provider 的注入路径 | 8 种 provider 有不同 skill 根路径(Claude=`.claude/skills/`、Codex=`$CODEX_HOME/skills/`、Pi=`.pi/agent/skills/`、etc);skill 不参与执行,只参与上下文注入 |
|
||||
| 10 | **Skills** | 工作区 skill + 本地 skill;按 provider 的注入路径 | 8 种 provider 有不同 skill 根路径(Claude=`.claude/skills/`、Codex=`$CODEX_HOME/skills/`、Pi=`.pi/skills/`、etc);skill 不参与执行,只参与上下文注入 |
|
||||
| 11 | **MCP** | 独立协议;怎么给 agent 配 MCP server;和 skill 的区别 | **目前只 Claude Code 真用**——其他 provider 收到 McpConfig 但 CLI 没对应 flag;JSONB 明文存储,非 owner redact |
|
||||
| 12 | **Autopilots** | 让 agent 自动开工的调度器;两种执行模式;三种触发;并发策略 | **Webhook trigger 字段有但没接路由**——第一版不文档化;concurrency policy 只对 `run_only` 模式生效;`create_issue` 模式由 issue FSM 自然 gate |
|
||||
| 13 | **Chat** | 和 issue comment 的区别;session 复用 | **完全沙盒**——chat 里的 agent 不能发 comment 到 issue;session_id 用 COALESCE 持久化,agent crash 不会抹掉 |
|
||||
@@ -118,7 +118,7 @@ Multica = **人 + AI agent 在同一个看板上协作的任务管理平台**。
|
||||
| Overview | 决策树(哪种部署模式适合你) |
|
||||
| Docker Compose deployment | `make selfhost` vs `make selfhost-build` |
|
||||
| Environment variables reference | 完整 env 表 |
|
||||
| Authentication setup | **🚨 `APP_ENV != "production"` 会让 verification code 固定为 `888888`** —— 生产必须设置 `APP_ENV=production`;Google OAuth 配置;signup 白名单 |
|
||||
| Authentication setup | **🚨 固定测试验证码必须显式设置 `MULTICA_DEV_VERIFICATION_CODE`,生产保持为空**;Google OAuth 配置;signup 白名单 |
|
||||
| Storage | S3 / CloudFront / 本地磁盘 |
|
||||
| Email | Resend 配置;**没配会落到 stderr** |
|
||||
| Upgrading | 版本升级 + migration 策略 |
|
||||
@@ -145,7 +145,7 @@ Installation / Authentication / Setup / Daemon / Workspace / Issue / Comment / A
|
||||
| 5 | Webhook autopilot trigger 字段建了但没接路由——第一版不文档化 | Autopilots |
|
||||
| 6 | custom_env merge 是覆盖而非合并——不能用 custom_env"取消设置"系统 env | Agents |
|
||||
| 7 | 旧 assignee 取消分配后不会被取消订阅 | Subscriptions |
|
||||
| 8 | `APP_ENV != "production"` 时 verification code 恒为 `888888` | Self-Hosting → Auth |
|
||||
| 8 | 固定本地测试验证码默认关闭;`MULTICA_DEV_VERIFICATION_CODE` 仅用于非 production 私有测试 | Self-Hosting → Auth |
|
||||
| 9 | Signup 白名单优先级:ALLOWED_EMAILS > ALLOWED_EMAIL_DOMAINS > ALLOW_SIGNUP | Self-Hosting → Auth |
|
||||
| 10 | One daemon ↔ many runtimes;one runtime ↔ ONE provider;同 daemon_id 重启复用旧 runtime 行 | Runtimes / Daemon |
|
||||
| 11 | Inbox 10 种类型,mention dedup 只在单 event 内生效 | Inbox |
|
||||
@@ -159,7 +159,7 @@ Installation / Authentication / Setup / Daemon / Workspace / Issue / Comment / A
|
||||
|---|---|
|
||||
| Mermaid diagram | 架构图 / task 生命周期 / trigger 流向 / autopilot 调度链 |
|
||||
| Tabs | Cloud / Self-Host / Desktop 并列;CLI / UI 并列 |
|
||||
| Callouts(内置)| Tip / Warning / Note — **警告类密集用在 Agents 的 custom_env 和 Self-Host 的 888888** |
|
||||
| Callouts(内置)| Tip / Warning / Note — **警告类密集用在 Agents 的 custom_env 和 Self-Host 的固定测试验证码** |
|
||||
| Code Tabs | API 调用多语言(Shell / Node / Go) |
|
||||
| Video / GIF | "Create your first agent"、"Follow an agent working" |
|
||||
| DeploymentPicker(定制)| 交互式决策树:回答 3 个问题 → 推荐部署路径 |
|
||||
|
||||
@@ -373,7 +373,7 @@ skill
|
||||
- Claude Code → `.claude/skills/{name}/SKILL.md`
|
||||
- Codex → `CODEX_HOME/skills/{name}/`
|
||||
- OpenCode → `.config/opencode/skills/{name}/SKILL.md`
|
||||
- Pi → `.pi/agent/skills/{name}/SKILL.md`
|
||||
- Pi → `.pi/skills/{name}/SKILL.md`
|
||||
- Cursor → `.cursor/skills/{name}/SKILL.md`
|
||||
- GitHub Copilot → `.github/skills/{name}/SKILL.md`
|
||||
- 其他 → `.agent_context/skills/{name}/SKILL.md`
|
||||
|
||||
@@ -51,6 +51,11 @@ import type {
|
||||
CreateProjectRequest,
|
||||
UpdateProjectRequest,
|
||||
ListProjectsResponse,
|
||||
Label,
|
||||
CreateLabelRequest,
|
||||
UpdateLabelRequest,
|
||||
ListLabelsResponse,
|
||||
IssueLabelsResponse,
|
||||
PinnedItem,
|
||||
CreatePinRequest,
|
||||
PinnedItemType,
|
||||
@@ -146,12 +151,17 @@ export interface ImportStarterContentResponse {
|
||||
export class ApiError extends Error {
|
||||
readonly status: number;
|
||||
readonly statusText: string;
|
||||
// Raw decoded JSON body (when the server returned one). Carries structured
|
||||
// error fields like `code` so callers can branch on machine-readable
|
||||
// identifiers instead of pattern-matching the human-readable message.
|
||||
readonly body?: unknown;
|
||||
|
||||
constructor(message: string, status: number, statusText: string) {
|
||||
constructor(message: string, status: number, statusText: string, body?: unknown) {
|
||||
super(message);
|
||||
this.name = "ApiError";
|
||||
this.status = status;
|
||||
this.statusText = statusText;
|
||||
this.body = body;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -216,6 +226,19 @@ export class ApiClient {
|
||||
return fallback;
|
||||
}
|
||||
|
||||
// Reads the response body once for both human-readable error message and
|
||||
// structured fields. The Response stream can only be consumed once, so
|
||||
// both pieces have to come from a single read.
|
||||
private async parseErrorBody(res: Response, fallback: string): Promise<{ message: string; body: unknown }> {
|
||||
try {
|
||||
const data = await res.json() as { error?: string };
|
||||
const message = typeof data.error === "string" && data.error ? data.error : fallback;
|
||||
return { message, body: data };
|
||||
} catch {
|
||||
return { message: fallback, body: undefined };
|
||||
}
|
||||
}
|
||||
|
||||
private async fetch<T>(path: string, init?: RequestInit): Promise<T> {
|
||||
const rid = createRequestId();
|
||||
const start = Date.now();
|
||||
@@ -238,10 +261,10 @@ export class ApiClient {
|
||||
|
||||
if (!res.ok) {
|
||||
if (res.status === 401) this.handleUnauthorized();
|
||||
const message = await this.parseErrorMessage(res, `API error: ${res.status} ${res.statusText}`);
|
||||
const { message, body } = await this.parseErrorBody(res, `API error: ${res.status} ${res.statusText}`);
|
||||
const logLevel = res.status === 404 ? "warn" : "error";
|
||||
this.logger[logLevel](`← ${res.status} ${path}`, { rid, duration: `${Date.now() - start}ms`, error: message });
|
||||
throw new ApiError(message, res.status, res.statusText);
|
||||
throw new ApiError(message, res.status, res.statusText, body);
|
||||
}
|
||||
|
||||
this.logger.info(`← ${res.status} ${path}`, { rid, duration: `${Date.now() - start}ms` });
|
||||
@@ -394,6 +417,13 @@ export class ApiClient {
|
||||
});
|
||||
}
|
||||
|
||||
async quickCreateIssue(data: { agent_id: string; prompt: string }): Promise<{ task_id: string }> {
|
||||
return this.fetch("/api/issues/quick-create", {
|
||||
method: "POST",
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
}
|
||||
|
||||
async createFeedback(data: {
|
||||
message: string;
|
||||
url?: string;
|
||||
@@ -978,6 +1008,50 @@ export class ApiClient {
|
||||
await this.fetch(`/api/projects/${id}`, { method: "DELETE" });
|
||||
}
|
||||
|
||||
// Labels
|
||||
async listLabels(): Promise<ListLabelsResponse> {
|
||||
return this.fetch(`/api/labels`);
|
||||
}
|
||||
|
||||
async getLabel(id: string): Promise<Label> {
|
||||
return this.fetch(`/api/labels/${id}`);
|
||||
}
|
||||
|
||||
async createLabel(data: CreateLabelRequest): Promise<Label> {
|
||||
return this.fetch(`/api/labels`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
}
|
||||
|
||||
async updateLabel(id: string, data: UpdateLabelRequest): Promise<Label> {
|
||||
return this.fetch(`/api/labels/${id}`, {
|
||||
method: "PUT",
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
}
|
||||
|
||||
async deleteLabel(id: string): Promise<void> {
|
||||
await this.fetch(`/api/labels/${id}`, { method: "DELETE" });
|
||||
}
|
||||
|
||||
async listLabelsForIssue(issueId: string): Promise<IssueLabelsResponse> {
|
||||
return this.fetch(`/api/issues/${issueId}/labels`);
|
||||
}
|
||||
|
||||
async attachLabel(issueId: string, labelId: string): Promise<IssueLabelsResponse> {
|
||||
return this.fetch(`/api/issues/${issueId}/labels`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ label_id: labelId }),
|
||||
});
|
||||
}
|
||||
|
||||
async detachLabel(issueId: string, labelId: string): Promise<IssueLabelsResponse> {
|
||||
return this.fetch(`/api/issues/${issueId}/labels/${labelId}`, {
|
||||
method: "DELETE",
|
||||
});
|
||||
}
|
||||
|
||||
// Pins
|
||||
async listPins(): Promise<PinnedItem[]> {
|
||||
return this.fetch("/api/pins");
|
||||
|
||||
60
packages/core/issues/stores/draft-store.test.ts
Normal file
60
packages/core/issues/stores/draft-store.test.ts
Normal file
@@ -0,0 +1,60 @@
|
||||
import { beforeEach, describe, expect, it } from "vitest";
|
||||
import { useIssueDraftStore } from "./draft-store";
|
||||
|
||||
const RESET_STATE = {
|
||||
draft: {
|
||||
title: "",
|
||||
description: "",
|
||||
status: "todo" as const,
|
||||
priority: "none" as const,
|
||||
assigneeType: undefined,
|
||||
assigneeId: undefined,
|
||||
dueDate: null,
|
||||
},
|
||||
lastAssigneeType: undefined,
|
||||
lastAssigneeId: undefined,
|
||||
};
|
||||
|
||||
describe("issue draft store — last assignee", () => {
|
||||
beforeEach(() => {
|
||||
useIssueDraftStore.setState(RESET_STATE);
|
||||
});
|
||||
|
||||
it("clearDraft prefills the next draft with the remembered assignee", () => {
|
||||
const { setDraft, setLastAssignee, clearDraft } =
|
||||
useIssueDraftStore.getState();
|
||||
|
||||
setDraft({ title: "first", assigneeType: "member", assigneeId: "alice" });
|
||||
setLastAssignee("member", "alice");
|
||||
clearDraft();
|
||||
|
||||
const { draft } = useIssueDraftStore.getState();
|
||||
expect(draft.title).toBe("");
|
||||
expect(draft.assigneeType).toBe("member");
|
||||
expect(draft.assigneeId).toBe("alice");
|
||||
});
|
||||
|
||||
it("clearDraft yields an empty assignee when none has ever been remembered", () => {
|
||||
const { setDraft, clearDraft } = useIssueDraftStore.getState();
|
||||
|
||||
setDraft({ title: "first" });
|
||||
clearDraft();
|
||||
|
||||
const { draft } = useIssueDraftStore.getState();
|
||||
expect(draft.assigneeType).toBeUndefined();
|
||||
expect(draft.assigneeId).toBeUndefined();
|
||||
});
|
||||
|
||||
it("setLastAssignee(undefined) lets the user opt back out of a default", () => {
|
||||
const { setLastAssignee, clearDraft } = useIssueDraftStore.getState();
|
||||
|
||||
setLastAssignee("member", "alice");
|
||||
clearDraft();
|
||||
expect(useIssueDraftStore.getState().draft.assigneeId).toBe("alice");
|
||||
|
||||
setLastAssignee(undefined, undefined);
|
||||
clearDraft();
|
||||
expect(useIssueDraftStore.getState().draft.assigneeId).toBeUndefined();
|
||||
expect(useIssueDraftStore.getState().draft.assigneeType).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -26,8 +26,14 @@ const EMPTY_DRAFT: IssueDraft = {
|
||||
|
||||
interface IssueDraftStore {
|
||||
draft: IssueDraft;
|
||||
// Last assignee picked at submit time. Persisted across drafts so the
|
||||
// create-issue modal can prefill the picker with the user's most recent
|
||||
// choice instead of always opening with no assignee.
|
||||
lastAssigneeType?: IssueAssigneeType;
|
||||
lastAssigneeId?: string;
|
||||
setDraft: (patch: Partial<IssueDraft>) => void;
|
||||
clearDraft: () => void;
|
||||
setLastAssignee: (type?: IssueAssigneeType, id?: string) => void;
|
||||
hasDraft: () => boolean;
|
||||
}
|
||||
|
||||
@@ -35,9 +41,20 @@ export const useIssueDraftStore = create<IssueDraftStore>()(
|
||||
persist(
|
||||
(set, get) => ({
|
||||
draft: { ...EMPTY_DRAFT },
|
||||
lastAssigneeType: undefined,
|
||||
lastAssigneeId: undefined,
|
||||
setDraft: (patch) =>
|
||||
set((s) => ({ draft: { ...s.draft, ...patch } })),
|
||||
clearDraft: () => set({ draft: { ...EMPTY_DRAFT } }),
|
||||
clearDraft: () =>
|
||||
set((s) => ({
|
||||
draft: {
|
||||
...EMPTY_DRAFT,
|
||||
assigneeType: s.lastAssigneeType,
|
||||
assigneeId: s.lastAssigneeId,
|
||||
},
|
||||
})),
|
||||
setLastAssignee: (type, id) =>
|
||||
set({ lastAssigneeType: type, lastAssigneeId: id }),
|
||||
hasDraft: () => {
|
||||
const { draft } = get();
|
||||
return !!(draft.title || draft.description);
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
type IssueViewState,
|
||||
viewStoreSlice,
|
||||
viewStorePersistOptions,
|
||||
mergeViewStatePersisted,
|
||||
} from "./view-store";
|
||||
import { registerForWorkspaceRehydration } from "../../platform/workspace-storage";
|
||||
|
||||
@@ -32,6 +33,11 @@ const _myIssuesViewStore = createStore<MyIssuesViewState>()(
|
||||
...basePersist.partialize(state),
|
||||
scope: state.scope,
|
||||
}),
|
||||
// Reuse the same deep-merge as the base view store so newly added
|
||||
// cardProperties toggles inherit defaults for existing users. Without
|
||||
// this, the my-issues page renders no labels because the persisted
|
||||
// snapshot predates the `labels` key and shallow-merge wins.
|
||||
merge: mergeViewStatePersisted<MyIssuesViewState>,
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
33
packages/core/issues/stores/quick-create-store.ts
Normal file
33
packages/core/issues/stores/quick-create-store.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
"use client";
|
||||
|
||||
import { create } from "zustand";
|
||||
import { createJSONStorage, persist } from "zustand/middleware";
|
||||
import { createWorkspaceAwareStorage, registerForWorkspaceRehydration } from "../../platform/workspace-storage";
|
||||
import { defaultStorage } from "../../platform/storage";
|
||||
|
||||
// Per-workspace memory of the last agent the user picked in the Quick Create
|
||||
// modal. Defaulted to that agent on next open so frequent users skip the
|
||||
// picker entirely. Persisted with the workspace-aware StateStorage so
|
||||
// switching workspaces shows the right default automatically. Per-user
|
||||
// scoping comes for free from localStorage being browser-profile-local —
|
||||
// matches how draft-store / issues-scope-store / comment-collapse-store
|
||||
// already namespace themselves.
|
||||
interface QuickCreateState {
|
||||
lastAgentId: string | null;
|
||||
setLastAgentId: (id: string | null) => void;
|
||||
}
|
||||
|
||||
export const useQuickCreateStore = create<QuickCreateState>()(
|
||||
persist(
|
||||
(set) => ({
|
||||
lastAgentId: null,
|
||||
setLastAgentId: (id) => set({ lastAgentId: id }),
|
||||
}),
|
||||
{
|
||||
name: "multica_quick_create",
|
||||
storage: createJSONStorage(() => createWorkspaceAwareStorage(defaultStorage)),
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
registerForWorkspaceRehydration(() => useQuickCreateStore.persist.rehydrate());
|
||||
@@ -20,6 +20,7 @@ export interface CardProperties {
|
||||
dueDate: boolean;
|
||||
project: boolean;
|
||||
childProgress: boolean;
|
||||
labels: boolean;
|
||||
}
|
||||
|
||||
export interface ActorFilterValue {
|
||||
@@ -41,6 +42,7 @@ export const CARD_PROPERTY_OPTIONS: { key: keyof CardProperties; label: string }
|
||||
{ key: "assignee", label: "Assignee" },
|
||||
{ key: "dueDate", label: "Due date" },
|
||||
{ key: "project", label: "Project" },
|
||||
{ key: "labels", label: "Labels" },
|
||||
{ key: "childProgress", label: "Sub-issue progress" },
|
||||
];
|
||||
|
||||
@@ -92,6 +94,7 @@ export const viewStoreSlice = (set: StoreApi<IssueViewState>["setState"]): Issue
|
||||
dueDate: true,
|
||||
project: true,
|
||||
childProgress: true,
|
||||
labels: true,
|
||||
},
|
||||
listCollapsedStatuses: [],
|
||||
|
||||
@@ -204,8 +207,34 @@ export const viewStorePersistOptions = (name: string) => ({
|
||||
cardProperties: state.cardProperties,
|
||||
listCollapsedStatuses: state.listCollapsedStatuses,
|
||||
}),
|
||||
// Default Zustand merge is shallow, so a persisted `cardProperties` snapshot
|
||||
// saved before a new toggle was introduced wins entirely and the new key is
|
||||
// missing — the dropdown switch then reads `undefined` and renders unchecked
|
||||
// even though defaults treat it as on. Deep-merge `cardProperties` so newly
|
||||
// added toggles inherit their default value for existing users.
|
||||
merge: mergeViewStatePersisted,
|
||||
});
|
||||
|
||||
/**
|
||||
* Reusable persist `merge` for view-state stores. Generic over T so the same
|
||||
* deep-merge for `cardProperties` works for both the issues view store and
|
||||
* the my-issues view store (which extends IssueViewState).
|
||||
*/
|
||||
export function mergeViewStatePersisted<T extends IssueViewState>(
|
||||
persisted: unknown,
|
||||
current: T,
|
||||
): T {
|
||||
const p = (persisted ?? {}) as Partial<T>;
|
||||
return {
|
||||
...current,
|
||||
...p,
|
||||
cardProperties: {
|
||||
...current.cardProperties,
|
||||
...(p.cardProperties ?? {}),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/** Factory: creates a vanilla StoreApi for use with React Context. */
|
||||
export function createIssueViewStore(persistKey: string): StoreApi<IssueViewState> {
|
||||
const store = createStore<IssueViewState>()(
|
||||
|
||||
@@ -6,7 +6,7 @@ import {
|
||||
patchIssueInBuckets,
|
||||
removeIssueFromBuckets,
|
||||
} from "./cache-helpers";
|
||||
import type { Issue } from "../types";
|
||||
import type { Issue, Label } from "../types";
|
||||
import type { ListIssuesCache } from "../types";
|
||||
|
||||
export function onIssueCreated(
|
||||
@@ -72,6 +72,26 @@ export function onIssueUpdated(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Patch an issue's `labels` field in-place across the list cache, my-issues
|
||||
* caches, and the detail cache. Triggered by the `issue_labels:changed` WS
|
||||
* event after attach/detach so list/board chips update without a refetch.
|
||||
*/
|
||||
export function onIssueLabelsChanged(
|
||||
qc: QueryClient,
|
||||
wsId: string,
|
||||
issueId: string,
|
||||
labels: Label[],
|
||||
) {
|
||||
qc.setQueryData<ListIssuesCache>(issueKeys.list(wsId), (old) =>
|
||||
old ? patchIssueInBuckets(old, issueId, { labels }) : old,
|
||||
);
|
||||
qc.setQueryData<Issue>(issueKeys.detail(wsId, issueId), (old) =>
|
||||
old ? { ...old, labels } : old,
|
||||
);
|
||||
qc.invalidateQueries({ queryKey: issueKeys.myAll(wsId) });
|
||||
}
|
||||
|
||||
export function onIssueDeleted(
|
||||
qc: QueryClient,
|
||||
wsId: string,
|
||||
|
||||
8
packages/core/labels/index.ts
Normal file
8
packages/core/labels/index.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
export { labelKeys, labelListOptions, issueLabelsOptions } from "./queries";
|
||||
export {
|
||||
useCreateLabel,
|
||||
useUpdateLabel,
|
||||
useDeleteLabel,
|
||||
useAttachLabel,
|
||||
useDetachLabel,
|
||||
} from "./mutations";
|
||||
171
packages/core/labels/mutations.ts
Normal file
171
packages/core/labels/mutations.ts
Normal file
@@ -0,0 +1,171 @@
|
||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { api } from "../api";
|
||||
import { labelKeys } from "./queries";
|
||||
import { useWorkspaceId } from "../hooks";
|
||||
import { issueKeys } from "../issues/queries";
|
||||
import { onIssueLabelsChanged } from "../issues/ws-updaters";
|
||||
import type {
|
||||
Label,
|
||||
CreateLabelRequest,
|
||||
UpdateLabelRequest,
|
||||
ListLabelsResponse,
|
||||
IssueLabelsResponse,
|
||||
} from "../types";
|
||||
|
||||
export function useCreateLabel() {
|
||||
const qc = useQueryClient();
|
||||
const wsId = useWorkspaceId();
|
||||
return useMutation({
|
||||
mutationFn: (data: CreateLabelRequest) => api.createLabel(data),
|
||||
onSuccess: (label) => {
|
||||
qc.setQueryData<ListLabelsResponse>(labelKeys.list(wsId), (old) =>
|
||||
old && !old.labels.some((l) => l.id === label.id)
|
||||
? { ...old, labels: [...old.labels, label], total: old.total + 1 }
|
||||
: old,
|
||||
);
|
||||
},
|
||||
onSettled: () => {
|
||||
qc.invalidateQueries({ queryKey: labelKeys.list(wsId) });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Optimistic rename/recolor. Matches the useUpdateProject pattern: apply the
|
||||
* change locally, snapshot for rollback, invalidate on settle. Without this
|
||||
* the UI freezes for the round-trip on every edit.
|
||||
*/
|
||||
export function useUpdateLabel() {
|
||||
const qc = useQueryClient();
|
||||
const wsId = useWorkspaceId();
|
||||
return useMutation({
|
||||
mutationFn: ({ id, ...data }: { id: string } & UpdateLabelRequest) =>
|
||||
api.updateLabel(id, data),
|
||||
onMutate: async ({ id, ...data }) => {
|
||||
await qc.cancelQueries({ queryKey: labelKeys.list(wsId) });
|
||||
const prevList = qc.getQueryData<ListLabelsResponse>(labelKeys.list(wsId));
|
||||
qc.setQueryData<ListLabelsResponse>(labelKeys.list(wsId), (old) =>
|
||||
old
|
||||
? {
|
||||
...old,
|
||||
labels: old.labels.map((l) => (l.id === id ? { ...l, ...data } : l)),
|
||||
}
|
||||
: old,
|
||||
);
|
||||
return { prevList, id };
|
||||
},
|
||||
onError: (_err, _vars, ctx) => {
|
||||
if (ctx?.prevList) qc.setQueryData(labelKeys.list(wsId), ctx.prevList);
|
||||
},
|
||||
onSettled: () => {
|
||||
// Invalidate the entire labels scope so any byIssue cache holding a
|
||||
// stale copy of this label is refetched. The list cache is the source
|
||||
// of truth; byIssue views will re-render with the fresh data.
|
||||
qc.invalidateQueries({ queryKey: labelKeys.all(wsId) });
|
||||
// Issues now embed labels (denormalized snapshot), so a rename/recolor
|
||||
// also has to refresh the issues caches that hold those snapshots.
|
||||
qc.invalidateQueries({ queryKey: issueKeys.all(wsId) });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useDeleteLabel() {
|
||||
const qc = useQueryClient();
|
||||
const wsId = useWorkspaceId();
|
||||
return useMutation({
|
||||
mutationFn: (id: string) => api.deleteLabel(id),
|
||||
onMutate: async (id) => {
|
||||
await qc.cancelQueries({ queryKey: labelKeys.list(wsId) });
|
||||
const prev = qc.getQueryData<ListLabelsResponse>(labelKeys.list(wsId));
|
||||
qc.setQueryData<ListLabelsResponse>(labelKeys.list(wsId), (old) =>
|
||||
old
|
||||
? { ...old, labels: old.labels.filter((l) => l.id !== id), total: old.total - 1 }
|
||||
: old,
|
||||
);
|
||||
return { prev };
|
||||
},
|
||||
onError: (_err, _id, ctx) => {
|
||||
if (ctx?.prev) qc.setQueryData(labelKeys.list(wsId), ctx.prev);
|
||||
},
|
||||
onSettled: () => {
|
||||
qc.invalidateQueries({ queryKey: labelKeys.all(wsId) });
|
||||
// A deleted label still lives in cached issue.labels arrays until we
|
||||
// refetch — invalidate so list/board chips drop the orphan.
|
||||
qc.invalidateQueries({ queryKey: issueKeys.all(wsId) });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useAttachLabel(issueId: string) {
|
||||
const qc = useQueryClient();
|
||||
const wsId = useWorkspaceId();
|
||||
return useMutation({
|
||||
mutationFn: (labelId: string) => api.attachLabel(issueId, labelId),
|
||||
onMutate: async (labelId) => {
|
||||
await qc.cancelQueries({ queryKey: labelKeys.byIssue(wsId, issueId) });
|
||||
const prev = qc.getQueryData<IssueLabelsResponse>(labelKeys.byIssue(wsId, issueId));
|
||||
// Only patch when we already know the current label set — otherwise
|
||||
// appending `[label]` to an empty array would wipe denormalized
|
||||
// labels in issue list/detail caches and rollback couldn't restore
|
||||
// them. If byIssue isn't cached yet (user clicked before the picker
|
||||
// fetched), skip the optimistic patch and rely on onSettled refetch.
|
||||
if (!prev) return { prev };
|
||||
if (prev.labels.some((l) => l.id === labelId)) return { prev };
|
||||
const list = qc.getQueryData<ListLabelsResponse>(labelKeys.list(wsId));
|
||||
const label = list?.labels.find((l) => l.id === labelId);
|
||||
if (!label) return { prev };
|
||||
const next: IssueLabelsResponse = { ...prev, labels: [...prev.labels, label] };
|
||||
qc.setQueryData<IssueLabelsResponse>(labelKeys.byIssue(wsId, issueId), next);
|
||||
onIssueLabelsChanged(qc, wsId, issueId, next.labels);
|
||||
return { prev };
|
||||
},
|
||||
onError: (_err, _id, ctx) => {
|
||||
if (ctx?.prev) {
|
||||
qc.setQueryData(labelKeys.byIssue(wsId, issueId), ctx.prev);
|
||||
onIssueLabelsChanged(qc, wsId, issueId, ctx.prev.labels);
|
||||
}
|
||||
},
|
||||
onSuccess: (data: IssueLabelsResponse) => {
|
||||
// Backend may return an empty object when the post-mutation read fails
|
||||
// (it logs a warning and skips the broadcast). Only apply the list
|
||||
// when the backend gave us one — otherwise the optimistic patch from
|
||||
// onMutate stands until onSettled's invalidation refetches.
|
||||
if (data && Array.isArray(data.labels)) {
|
||||
qc.setQueryData<IssueLabelsResponse>(labelKeys.byIssue(wsId, issueId), data);
|
||||
onIssueLabelsChanged(qc, wsId, issueId, data.labels);
|
||||
}
|
||||
},
|
||||
onSettled: () => {
|
||||
qc.invalidateQueries({ queryKey: labelKeys.byIssue(wsId, issueId) });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useDetachLabel(issueId: string) {
|
||||
const qc = useQueryClient();
|
||||
const wsId = useWorkspaceId();
|
||||
return useMutation({
|
||||
mutationFn: (labelId: string) => api.detachLabel(issueId, labelId),
|
||||
onMutate: async (labelId) => {
|
||||
await qc.cancelQueries({ queryKey: labelKeys.byIssue(wsId, issueId) });
|
||||
const prev = qc.getQueryData<IssueLabelsResponse>(labelKeys.byIssue(wsId, issueId));
|
||||
const next = prev
|
||||
? { ...prev, labels: prev.labels.filter((l: Label) => l.id !== labelId) }
|
||||
: undefined;
|
||||
if (next) {
|
||||
qc.setQueryData<IssueLabelsResponse>(labelKeys.byIssue(wsId, issueId), next);
|
||||
onIssueLabelsChanged(qc, wsId, issueId, next.labels);
|
||||
}
|
||||
return { prev };
|
||||
},
|
||||
onError: (_err, _id, ctx) => {
|
||||
if (ctx?.prev) {
|
||||
qc.setQueryData(labelKeys.byIssue(wsId, issueId), ctx.prev);
|
||||
onIssueLabelsChanged(qc, wsId, issueId, ctx.prev.labels);
|
||||
}
|
||||
},
|
||||
onSettled: () => {
|
||||
qc.invalidateQueries({ queryKey: labelKeys.byIssue(wsId, issueId) });
|
||||
},
|
||||
});
|
||||
}
|
||||
28
packages/core/labels/queries.ts
Normal file
28
packages/core/labels/queries.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
import { queryOptions } from "@tanstack/react-query";
|
||||
import { api } from "../api";
|
||||
|
||||
export const labelKeys = {
|
||||
all: (wsId: string) => ["labels", wsId] as const,
|
||||
list: (wsId: string) => [...labelKeys.all(wsId), "list"] as const,
|
||||
detail: (wsId: string, id: string) =>
|
||||
[...labelKeys.all(wsId), "detail", id] as const,
|
||||
byIssue: (wsId: string, issueId: string) =>
|
||||
[...labelKeys.all(wsId), "issue", issueId] as const,
|
||||
};
|
||||
|
||||
export function labelListOptions(wsId: string) {
|
||||
return queryOptions({
|
||||
queryKey: labelKeys.list(wsId),
|
||||
queryFn: () => api.listLabels(),
|
||||
select: (data) => data.labels,
|
||||
});
|
||||
}
|
||||
|
||||
export function issueLabelsOptions(wsId: string, issueId: string) {
|
||||
return queryOptions({
|
||||
queryKey: labelKeys.byIssue(wsId, issueId),
|
||||
queryFn: () => api.listLabelsForIssue(issueId),
|
||||
select: (data) => data.labels,
|
||||
enabled: Boolean(issueId),
|
||||
});
|
||||
}
|
||||
@@ -2,7 +2,17 @@
|
||||
|
||||
import { create } from "zustand";
|
||||
|
||||
type ModalType = "create-workspace" | "create-issue" | "create-project" | "feedback" | null;
|
||||
type ModalType =
|
||||
| "create-workspace"
|
||||
| "create-issue"
|
||||
| "quick-create-issue"
|
||||
| "create-project"
|
||||
| "feedback"
|
||||
| "issue-set-parent"
|
||||
| "issue-add-child"
|
||||
| "issue-delete-confirm"
|
||||
| "issue-backlog-agent-hint"
|
||||
| null;
|
||||
|
||||
interface ModalStore {
|
||||
modal: ModalType;
|
||||
|
||||
@@ -46,6 +46,9 @@
|
||||
"./projects/queries": "./projects/queries.ts",
|
||||
"./projects/mutations": "./projects/mutations.ts",
|
||||
"./projects/config": "./projects/config.ts",
|
||||
"./labels": "./labels/index.ts",
|
||||
"./labels/queries": "./labels/queries.ts",
|
||||
"./labels/mutations": "./labels/mutations.ts",
|
||||
"./autopilots": "./autopilots/index.ts",
|
||||
"./autopilots/queries": "./autopilots/queries.ts",
|
||||
"./autopilots/mutations": "./autopilots/mutations.ts",
|
||||
|
||||
@@ -85,10 +85,13 @@ export const RESERVED_SLUGS = new Set([
|
||||
"tokens",
|
||||
"cli",
|
||||
|
||||
// Backend ops / observability. `/health` and `/ws` exist on the backend
|
||||
// Backend ops / observability. `/health`, `/readyz`, `/healthz`, and `/ws`
|
||||
// exist on the backend
|
||||
// host; reserving them on the workspace slug space prevents naming
|
||||
// confusion if/when these paths are ever proxied through the web origin.
|
||||
"health",
|
||||
"readyz",
|
||||
"healthz",
|
||||
"ws",
|
||||
"metrics",
|
||||
"ping",
|
||||
|
||||
@@ -18,6 +18,7 @@ import {
|
||||
onIssueCreated,
|
||||
onIssueUpdated,
|
||||
onIssueDeleted,
|
||||
onIssueLabelsChanged,
|
||||
} from "../issues/ws-updaters";
|
||||
import { onInboxNew, onInboxInvalidate, onInboxIssueStatusChanged, onInboxIssueDeleted } from "../inbox/ws-updaters";
|
||||
import { inboxKeys } from "../inbox/queries";
|
||||
@@ -31,6 +32,7 @@ import type {
|
||||
IssueUpdatedPayload,
|
||||
IssueCreatedPayload,
|
||||
IssueDeletedPayload,
|
||||
IssueLabelsChangedPayload,
|
||||
InboxNewPayload,
|
||||
CommentCreatedPayload,
|
||||
CommentUpdatedPayload,
|
||||
@@ -117,6 +119,17 @@ export function useRealtimeSync(
|
||||
const wsId = getCurrentWsId();
|
||||
if (wsId) qc.invalidateQueries({ queryKey: projectKeys.all(wsId) });
|
||||
},
|
||||
label: () => {
|
||||
// label:created/updated/deleted — also refresh issues, since each
|
||||
// issue carries a denormalized snapshot of its labels (rename/recolor
|
||||
// /delete on a label needs to flush the chips on every issue showing
|
||||
// it).
|
||||
const wsId = getCurrentWsId();
|
||||
if (wsId) {
|
||||
qc.invalidateQueries({ queryKey: ["labels", wsId] });
|
||||
qc.invalidateQueries({ queryKey: issueKeys.all(wsId) });
|
||||
}
|
||||
},
|
||||
pin: () => {
|
||||
const wsId = getCurrentWsId();
|
||||
const userId = authStore.getState().user?.id;
|
||||
@@ -147,7 +160,7 @@ export function useRealtimeSync(
|
||||
|
||||
// Event types handled by specific handlers below -- skip generic refresh
|
||||
const specificEvents = new Set([
|
||||
"issue:updated", "issue:created", "issue:deleted", "inbox:new",
|
||||
"issue:updated", "issue:created", "issue:deleted", "issue_labels:changed", "inbox:new",
|
||||
"comment:created", "comment:updated", "comment:deleted",
|
||||
"activity:created",
|
||||
"reaction:added", "reaction:removed",
|
||||
@@ -200,6 +213,13 @@ export function useRealtimeSync(
|
||||
}
|
||||
});
|
||||
|
||||
const unsubIssueLabelsChanged = ws.on("issue_labels:changed", (p) => {
|
||||
const { issue_id, labels } = p as IssueLabelsChangedPayload;
|
||||
if (!issue_id) return;
|
||||
const wsId = getCurrentWsId();
|
||||
if (wsId) onIssueLabelsChanged(qc, wsId, issue_id, labels ?? []);
|
||||
});
|
||||
|
||||
const unsubInboxNew = ws.on("inbox:new", (p) => {
|
||||
const { item } = p as InboxNewPayload;
|
||||
if (!item) return;
|
||||
@@ -464,6 +484,7 @@ export function useRealtimeSync(
|
||||
unsubIssueUpdated();
|
||||
unsubIssueCreated();
|
||||
unsubIssueDeleted();
|
||||
unsubIssueLabelsChanged();
|
||||
unsubInboxNew();
|
||||
unsubCommentCreated();
|
||||
unsubCommentUpdated();
|
||||
|
||||
@@ -11,11 +11,9 @@ export type AutopilotRunSource = "schedule" | "manual" | "webhook" | "api";
|
||||
export interface Autopilot {
|
||||
id: string;
|
||||
workspace_id: string;
|
||||
project_id: string | null;
|
||||
title: string;
|
||||
description: string | null;
|
||||
assignee_id: string;
|
||||
priority: string;
|
||||
status: AutopilotStatus;
|
||||
execution_mode: AutopilotExecutionMode;
|
||||
issue_title_template: string | null;
|
||||
@@ -61,8 +59,6 @@ export interface CreateAutopilotRequest {
|
||||
title: string;
|
||||
description?: string;
|
||||
assignee_id: string;
|
||||
project_id?: string;
|
||||
priority?: string;
|
||||
execution_mode: AutopilotExecutionMode;
|
||||
issue_title_template?: string;
|
||||
}
|
||||
@@ -71,8 +67,6 @@ export interface UpdateAutopilotRequest {
|
||||
title?: string;
|
||||
description?: string | null;
|
||||
assignee_id?: string;
|
||||
project_id?: string | null;
|
||||
priority?: string;
|
||||
status?: AutopilotStatus;
|
||||
execution_mode?: AutopilotExecutionMode;
|
||||
issue_title_template?: string | null;
|
||||
|
||||
@@ -5,6 +5,7 @@ import type { Comment, Reaction } from "./comment";
|
||||
import type { TimelineEntry } from "./activity";
|
||||
import type { Workspace, MemberWithUser, Invitation } from "./workspace";
|
||||
import type { Project } from "./project";
|
||||
import type { Label } from "./label";
|
||||
|
||||
// WebSocket event types (matching Go server protocol/events.go)
|
||||
export type WSEventType =
|
||||
@@ -52,6 +53,10 @@ export type WSEventType =
|
||||
| "project:created"
|
||||
| "project:updated"
|
||||
| "project:deleted"
|
||||
| "label:created"
|
||||
| "label:updated"
|
||||
| "label:deleted"
|
||||
| "issue_labels:changed"
|
||||
| "pin:created"
|
||||
| "pin:deleted"
|
||||
| "pin:reordered"
|
||||
@@ -78,6 +83,11 @@ export interface IssueDeletedPayload {
|
||||
issue_id: string;
|
||||
}
|
||||
|
||||
export interface IssueLabelsChangedPayload {
|
||||
issue_id: string;
|
||||
labels: Label[];
|
||||
}
|
||||
|
||||
export interface AgentStatusPayload {
|
||||
agent: Agent;
|
||||
}
|
||||
|
||||
@@ -16,7 +16,9 @@ export type InboxItemType =
|
||||
| "task_failed"
|
||||
| "agent_blocked"
|
||||
| "agent_completed"
|
||||
| "reaction_added";
|
||||
| "reaction_added"
|
||||
| "quick_create_done"
|
||||
| "quick_create_failed";
|
||||
|
||||
export interface InboxItem {
|
||||
id: string;
|
||||
|
||||
@@ -34,6 +34,7 @@ export type {
|
||||
export type { Workspace, WorkspaceRepo, Member, MemberRole, User, MemberWithUser, Invitation } from "./workspace";
|
||||
export type { InboxItem, InboxSeverity, InboxItemType } from "./inbox";
|
||||
export type { Comment, CommentType, CommentAuthorType, Reaction } from "./comment";
|
||||
export type { Label, CreateLabelRequest, UpdateLabelRequest, ListLabelsResponse, IssueLabelsResponse } from "./label";
|
||||
export type { TimelineEntry, AssigneeFrequencyEntry } from "./activity";
|
||||
export type { IssueSubscriber } from "./subscriber";
|
||||
export type * from "./events";
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import type { Label } from "./label";
|
||||
|
||||
export type IssueStatus =
|
||||
| "backlog"
|
||||
| "todo"
|
||||
@@ -38,6 +40,7 @@ export interface Issue {
|
||||
position: number;
|
||||
due_date: string | null;
|
||||
reactions?: IssueReaction[];
|
||||
labels?: Label[];
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
35
packages/core/types/label.ts
Normal file
35
packages/core/types/label.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
/**
|
||||
* Issue labels — workspace-scoped, applied as many-to-many to issues.
|
||||
*
|
||||
* Labels are lightweight metadata (name + color) distinct from projects:
|
||||
* projects group related work, labels are cross-cutting tags (bug, feature,
|
||||
* performance, …). Colors are normalized to lowercase `#RRGGBB`.
|
||||
*/
|
||||
export interface Label {
|
||||
id: string;
|
||||
workspace_id: string;
|
||||
name: string;
|
||||
/** Normalized lowercase hex color, e.g. `#3b82f6`. */
|
||||
color: string;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export interface CreateLabelRequest {
|
||||
name: string;
|
||||
color: string;
|
||||
}
|
||||
|
||||
export interface UpdateLabelRequest {
|
||||
name?: string;
|
||||
color?: string;
|
||||
}
|
||||
|
||||
export interface ListLabelsResponse {
|
||||
labels: Label[];
|
||||
total: number;
|
||||
}
|
||||
|
||||
export interface IssueLabelsResponse {
|
||||
labels: Label[];
|
||||
}
|
||||
@@ -156,7 +156,7 @@ function CommandItem({
|
||||
<CommandPrimitive.Item
|
||||
data-slot="command-item"
|
||||
className={cn(
|
||||
"group/command-item relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none in-data-[slot=dialog-content]:rounded-lg! data-[disabled=true]:pointer-events-none data-[disabled=true]:opacity-50 data-selected:bg-muted data-selected:text-foreground [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 data-selected:*:[svg]:text-foreground",
|
||||
"group/command-item relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none in-data-[slot=dialog-content]:rounded-lg! data-[disabled=true]:pointer-events-none data-[disabled=true]:opacity-50 data-selected:bg-muted data-selected:text-foreground [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
|
||||
@@ -101,7 +101,7 @@ function ContextMenuItem({
|
||||
data-inset={inset}
|
||||
data-variant={variant}
|
||||
className={cn(
|
||||
"group/context-menu-item relative flex cursor-default items-center gap-1.5 rounded-md px-1.5 py-1 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground data-inset:pl-7 data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 data-[variant=destructive]:focus:text-destructive dark:data-[variant=destructive]:focus:bg-destructive/20 data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 focus:*:[svg]:text-accent-foreground data-[variant=destructive]:*:[svg]:text-destructive",
|
||||
"group/context-menu-item relative flex cursor-default items-center gap-1.5 rounded-md px-1.5 py-1 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground data-inset:pl-7 data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 data-[variant=destructive]:focus:text-destructive dark:data-[variant=destructive]:focus:bg-destructive/20 data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
|
||||
@@ -88,7 +88,7 @@ function DropdownMenuItem({
|
||||
data-inset={inset}
|
||||
data-variant={variant}
|
||||
className={cn(
|
||||
"group/dropdown-menu-item relative flex cursor-default items-center gap-1.5 rounded-md px-1.5 py-1 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground not-data-[variant=destructive]:focus:**:text-accent-foreground data-inset:pl-7 data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 data-[variant=destructive]:focus:text-destructive dark:data-[variant=destructive]:focus:bg-destructive/20 data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 data-[variant=destructive]:*:[svg]:text-destructive",
|
||||
"group/dropdown-menu-item relative flex cursor-default items-center gap-1.5 rounded-md px-1.5 py-1 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground data-inset:pl-7 data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 data-[variant=destructive]:focus:text-destructive dark:data-[variant=destructive]:focus:bg-destructive/20 data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
@@ -113,7 +113,7 @@ function DropdownMenuSubTrigger({
|
||||
data-slot="dropdown-menu-sub-trigger"
|
||||
data-inset={inset}
|
||||
className={cn(
|
||||
"flex cursor-default items-center gap-1.5 rounded-md px-1.5 py-1 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground not-data-[variant=destructive]:focus:**:text-accent-foreground data-inset:pl-7 data-popup-open:bg-accent data-popup-open:text-accent-foreground data-open:bg-accent data-open:text-accent-foreground [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
"flex cursor-default items-center gap-1.5 rounded-md px-1.5 py-1 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground data-inset:pl-7 data-popup-open:bg-accent data-popup-open:text-accent-foreground data-open:bg-accent data-open:text-accent-foreground [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
@@ -201,7 +201,7 @@ function DropdownMenuRadioItem({
|
||||
data-slot="dropdown-menu-radio-item"
|
||||
data-inset={inset}
|
||||
className={cn(
|
||||
"relative flex cursor-default items-center gap-1.5 rounded-md py-1 pr-8 pl-1.5 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground focus:**:text-accent-foreground data-inset:pl-7 data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
"relative flex cursor-default items-center gap-1.5 rounded-md py-1 pr-8 pl-1.5 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground data-inset:pl-7 data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
|
||||
@@ -98,7 +98,7 @@ function MenubarItem({
|
||||
data-inset={inset}
|
||||
data-variant={variant}
|
||||
className={cn(
|
||||
"group/menubar-item gap-1.5 rounded-md px-1.5 py-1 text-sm focus:bg-accent focus:text-accent-foreground not-data-[variant=destructive]:focus:**:text-accent-foreground data-inset:pl-7 data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 data-[variant=destructive]:focus:text-destructive dark:data-[variant=destructive]:focus:bg-destructive/20 data-disabled:opacity-50 [&_svg:not([class*='size-'])]:size-4 data-[variant=destructive]:*:[svg]:text-destructive!",
|
||||
"group/menubar-item gap-1.5 rounded-md px-1.5 py-1 text-sm focus:bg-accent focus:text-accent-foreground data-inset:pl-7 data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 data-[variant=destructive]:focus:text-destructive dark:data-[variant=destructive]:focus:bg-destructive/20 data-disabled:opacity-50 [&_svg:not([class*='size-'])]:size-4",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
@@ -120,7 +120,7 @@ function MenubarCheckboxItem({
|
||||
data-slot="menubar-checkbox-item"
|
||||
data-inset={inset}
|
||||
className={cn(
|
||||
"relative flex cursor-default items-center gap-1.5 rounded-md py-1 pr-1.5 pl-7 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground focus:**:text-accent-foreground data-inset:pl-7 data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0",
|
||||
"relative flex cursor-default items-center gap-1.5 rounded-md py-1 pr-1.5 pl-7 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground data-inset:pl-7 data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0",
|
||||
className
|
||||
)}
|
||||
checked={checked}
|
||||
@@ -156,7 +156,7 @@ function MenubarRadioItem({
|
||||
data-slot="menubar-radio-item"
|
||||
data-inset={inset}
|
||||
className={cn(
|
||||
"relative flex cursor-default items-center gap-1.5 rounded-md py-1 pr-1.5 pl-7 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground focus:**:text-accent-foreground data-inset:pl-7 data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
"relative flex cursor-default items-center gap-1.5 rounded-md py-1 pr-1.5 pl-7 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground data-inset:pl-7 data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
|
||||
@@ -117,7 +117,7 @@ function SelectItem({
|
||||
<SelectPrimitive.Item
|
||||
data-slot="select-item"
|
||||
className={cn(
|
||||
"relative flex w-full cursor-default items-center gap-1.5 rounded-md py-1 pr-8 pl-1.5 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground not-data-[variant=destructive]:focus:**:text-accent-foreground data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 *:[span]:last:flex *:[span]:last:items-center *:[span]:last:gap-2",
|
||||
"relative flex w-full cursor-default items-center gap-1.5 rounded-md py-1 pr-8 pl-1.5 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 *:[span]:last:flex *:[span]:last:items-center *:[span]:last:gap-2",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
|
||||
@@ -527,7 +527,7 @@ const sidebarMenuButtonVariants = cva(
|
||||
variant: {
|
||||
default: "hover:bg-sidebar-accent hover:text-sidebar-accent-foreground",
|
||||
outline:
|
||||
"bg-background shadow-[0_0_0_1px_hsl(var(--sidebar-border))] hover:bg-sidebar-accent hover:text-sidebar-accent-foreground hover:shadow-[0_0_0_1px_hsl(var(--sidebar-accent))]",
|
||||
"bg-background shadow-[0_0_0_1px_var(--color-sidebar-border)] hover:bg-sidebar-accent hover:text-sidebar-accent-foreground hover:shadow-[0_0_0_1px_var(--color-sidebar-accent)]",
|
||||
},
|
||||
size: {
|
||||
default: "h-8 text-sm",
|
||||
|
||||
219
packages/ui/components/ui/time-input.tsx
Normal file
219
packages/ui/components/ui/time-input.tsx
Normal file
@@ -0,0 +1,219 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import { Clock } from "lucide-react";
|
||||
|
||||
import { cn } from "@multica/ui/lib/utils";
|
||||
|
||||
// Adapted from openstatusHQ/time-picker (MIT).
|
||||
// Segmented HH:MM input with keyboard arrow increment / digit typing.
|
||||
|
||||
type Segment = "hours" | "minutes";
|
||||
|
||||
function getValidNumber(
|
||||
raw: string,
|
||||
{ max, min = 0, loop = false }: { max: number; min?: number; loop?: boolean },
|
||||
): string {
|
||||
let n = parseInt(raw, 10);
|
||||
if (isNaN(n)) return "00";
|
||||
if (!loop) {
|
||||
if (n > max) n = max;
|
||||
if (n < min) n = min;
|
||||
} else {
|
||||
if (n > max) n = min;
|
||||
if (n < min) n = max;
|
||||
}
|
||||
return n.toString().padStart(2, "0");
|
||||
}
|
||||
|
||||
function arrowValue(current: string, step: number, seg: Segment): string {
|
||||
const max = seg === "hours" ? 23 : 59;
|
||||
const n = parseInt(current, 10);
|
||||
if (isNaN(n)) return "00";
|
||||
return getValidNumber(String(n + step), { max, min: 0, loop: true });
|
||||
}
|
||||
|
||||
function splitTime(value: string): { hh: string; mm: string } {
|
||||
const [rawH, rawM] = (value || "").split(":");
|
||||
const hh = getValidNumber(rawH ?? "0", { max: 23 });
|
||||
const mm = getValidNumber(rawM ?? "0", { max: 59 });
|
||||
return { hh, mm };
|
||||
}
|
||||
|
||||
interface SegmentInputProps {
|
||||
seg: Segment;
|
||||
value: string;
|
||||
onValueChange: (next: string) => void;
|
||||
onLeftFocus?: () => void;
|
||||
onRightFocus?: () => void;
|
||||
disabled?: boolean;
|
||||
ariaLabel: string;
|
||||
}
|
||||
|
||||
const SegmentInput = React.forwardRef<HTMLInputElement, SegmentInputProps>(
|
||||
function SegmentInput(
|
||||
{ seg, value, onValueChange, onLeftFocus, onRightFocus, disabled, ariaLabel },
|
||||
ref,
|
||||
) {
|
||||
// Two-digit typing window: first digit pads with leading 0; second digit within
|
||||
// 2s replaces the leading 0, clamped to segment max. After 2s, reset.
|
||||
const [pendingSecond, setPendingSecond] = React.useState(false);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!pendingSecond) return;
|
||||
const t = setTimeout(() => setPendingSecond(false), 2000);
|
||||
return () => clearTimeout(t);
|
||||
}, [pendingSecond]);
|
||||
|
||||
const handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
|
||||
if (e.key === "Tab") return;
|
||||
if (e.key === "ArrowRight") {
|
||||
e.preventDefault();
|
||||
onRightFocus?.();
|
||||
return;
|
||||
}
|
||||
if (e.key === "ArrowLeft") {
|
||||
e.preventDefault();
|
||||
onLeftFocus?.();
|
||||
return;
|
||||
}
|
||||
if (e.key === "ArrowUp" || e.key === "ArrowDown") {
|
||||
e.preventDefault();
|
||||
const step = e.key === "ArrowUp" ? 1 : -1;
|
||||
onValueChange(arrowValue(value, step, seg));
|
||||
setPendingSecond(false);
|
||||
return;
|
||||
}
|
||||
if (e.key >= "0" && e.key <= "9") {
|
||||
e.preventDefault();
|
||||
const next = pendingSecond
|
||||
? getValidNumber(value.slice(1) + e.key, {
|
||||
max: seg === "hours" ? 23 : 59,
|
||||
})
|
||||
: "0" + e.key;
|
||||
onValueChange(next);
|
||||
if (pendingSecond) {
|
||||
setPendingSecond(false);
|
||||
onRightFocus?.();
|
||||
} else {
|
||||
setPendingSecond(true);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (e.key === "Backspace" || e.key === "Delete") {
|
||||
e.preventDefault();
|
||||
onValueChange("00");
|
||||
setPendingSecond(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<input
|
||||
ref={ref}
|
||||
type="text"
|
||||
inputMode="numeric"
|
||||
maxLength={2}
|
||||
value={value}
|
||||
disabled={disabled}
|
||||
aria-label={ariaLabel}
|
||||
onChange={() => {
|
||||
// Fully controlled by keydown; ignore native onChange.
|
||||
}}
|
||||
onKeyDown={handleKeyDown}
|
||||
onFocus={(e) => e.currentTarget.select()}
|
||||
className="w-7 bg-transparent text-center text-sm tabular-nums outline-none caret-transparent focus:text-foreground"
|
||||
/>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
export interface TimeInputProps {
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
disabled?: boolean;
|
||||
className?: string;
|
||||
showIcon?: boolean;
|
||||
/** Render only the minute segment with an "At :" prefix. Used for hourly schedules. */
|
||||
minuteOnly?: boolean;
|
||||
}
|
||||
|
||||
export function TimeInput({
|
||||
value,
|
||||
onChange,
|
||||
disabled,
|
||||
className,
|
||||
showIcon = true,
|
||||
minuteOnly = false,
|
||||
}: TimeInputProps) {
|
||||
const { hh, mm } = splitTime(value);
|
||||
const hourRef = React.useRef<HTMLInputElement>(null);
|
||||
const minuteRef = React.useRef<HTMLInputElement>(null);
|
||||
|
||||
const setHour = (next: string) => onChange(`${next}:${mm}`);
|
||||
const setMinute = (next: string) =>
|
||||
onChange(`${minuteOnly ? "00" : hh}:${next}`);
|
||||
|
||||
return (
|
||||
<div
|
||||
data-slot="time-input"
|
||||
onClick={(e) => {
|
||||
if (e.target === e.currentTarget) {
|
||||
(minuteOnly ? minuteRef : hourRef).current?.focus();
|
||||
}
|
||||
}}
|
||||
className={cn(
|
||||
"flex h-8 items-center gap-1 rounded-lg border border-input bg-transparent px-2.5 text-sm transition-colors",
|
||||
"focus-within:border-ring focus-within:ring-3 focus-within:ring-ring/50",
|
||||
"dark:bg-input/30",
|
||||
disabled && "pointer-events-none cursor-not-allowed opacity-50",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{minuteOnly ? (
|
||||
<>
|
||||
{showIcon && (
|
||||
<Clock className="pointer-events-none size-3.5 shrink-0 text-muted-foreground" />
|
||||
)}
|
||||
<span className="pointer-events-none select-none text-muted-foreground">
|
||||
at :
|
||||
</span>
|
||||
<SegmentInput
|
||||
ref={minuteRef}
|
||||
seg="minutes"
|
||||
value={mm}
|
||||
onValueChange={setMinute}
|
||||
disabled={disabled}
|
||||
ariaLabel="Minute"
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
{showIcon && (
|
||||
<Clock className="pointer-events-none size-3.5 shrink-0 text-muted-foreground" />
|
||||
)}
|
||||
<SegmentInput
|
||||
ref={hourRef}
|
||||
seg="hours"
|
||||
value={hh}
|
||||
onValueChange={setHour}
|
||||
onRightFocus={() => minuteRef.current?.focus()}
|
||||
disabled={disabled}
|
||||
ariaLabel="Hour"
|
||||
/>
|
||||
<span className="pointer-events-none select-none text-muted-foreground">
|
||||
:
|
||||
</span>
|
||||
<SegmentInput
|
||||
ref={minuteRef}
|
||||
seg="minutes"
|
||||
value={mm}
|
||||
onValueChange={setMinute}
|
||||
onLeftFocus={() => hourRef.current?.focus()}
|
||||
disabled={disabled}
|
||||
ariaLabel="Minute"
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -3,6 +3,7 @@ import ReactMarkdown, { type Components, defaultUrlTransform } from 'react-markd
|
||||
import rehypeKatex from 'rehype-katex'
|
||||
import rehypeRaw from 'rehype-raw'
|
||||
import rehypeSanitize, { defaultSchema } from 'rehype-sanitize'
|
||||
import remarkBreaks from 'remark-breaks'
|
||||
import remarkGfm from 'remark-gfm'
|
||||
import remarkMath from 'remark-math'
|
||||
import { FileText, Download } from 'lucide-react'
|
||||
@@ -404,7 +405,7 @@ export function Markdown({
|
||||
return (
|
||||
<div className={cn('markdown-content break-words', className)}>
|
||||
<ReactMarkdown
|
||||
remarkPlugins={[remarkMath, [remarkGfm, { singleTilde: false }]]}
|
||||
remarkPlugins={[remarkMath, remarkBreaks, [remarkGfm, { singleTilde: false }]]}
|
||||
rehypePlugins={[rehypeRaw, [rehypeSanitize, sanitizeSchema], rehypeKatex]}
|
||||
urlTransform={urlTransform}
|
||||
components={components}
|
||||
|
||||
@@ -15,6 +15,15 @@ const linkify = new LinkifyIt()
|
||||
const FILE_PATH_REGEX =
|
||||
/(?:^|[\s([{<])((\/|~\/|\.\/)[\w\-./@]+\.(?:ts|tsx|js|jsx|mjs|cjs|md|json|yaml|yml|py|go|rs|css|scss|less|html|htm|txt|log|sh|bash|zsh|swift|kt|java|c|cpp|h|hpp|rb|php|xml|toml|ini|cfg|conf|env|sql|graphql|vue|svelte|astro|prisma|dockerfile|makefile|gitignore))(?=[\s)\]}.,;:!?>]|$)/gi
|
||||
|
||||
// CJK full-width punctuation that should terminate a URL.
|
||||
// linkify-it only treats ASCII punctuation as URL boundaries, so in Chinese /
|
||||
// Japanese text a URL followed by e.g. "。" gets the punctuation and every
|
||||
// character up to the next whitespace swallowed into the href. We truncate the
|
||||
// detected URL at the first occurrence of any of these characters. Character
|
||||
// set mirrors the fix applied in mattermost/marked#22.
|
||||
const CJK_URL_TERMINATOR_REGEX =
|
||||
/[!-/:-@[-`{-~、。「-】]/
|
||||
|
||||
interface DetectedLink {
|
||||
type: 'url' | 'email' | 'file'
|
||||
text: string
|
||||
@@ -84,6 +93,88 @@ function isInsideCode(pos: number, ranges: CodeRange[]): boolean {
|
||||
return ranges.some((r) => pos >= r.start && pos < r.end)
|
||||
}
|
||||
|
||||
function isEscaped(text: string, index: number): boolean {
|
||||
let slashCount = 0
|
||||
for (let i = index - 1; i >= 0 && text[i] === '\\'; i--) {
|
||||
slashCount++
|
||||
}
|
||||
return slashCount % 2 === 1
|
||||
}
|
||||
|
||||
function findMatchingBracket(text: string, openIndex: number): number {
|
||||
let depth = 0
|
||||
|
||||
for (let i = openIndex; i < text.length; i++) {
|
||||
if (isEscaped(text, i)) continue
|
||||
|
||||
const char = text[i]
|
||||
if (char === '[') {
|
||||
depth++
|
||||
} else if (char === ']') {
|
||||
depth--
|
||||
if (depth === 0) return i
|
||||
}
|
||||
}
|
||||
|
||||
return -1
|
||||
}
|
||||
|
||||
function findInlineLinkEnd(text: string, openParenIndex: number): number {
|
||||
let depth = 0
|
||||
|
||||
for (let i = openParenIndex; i < text.length; i++) {
|
||||
if (isEscaped(text, i)) continue
|
||||
|
||||
const char = text[i]
|
||||
if (char === '(') {
|
||||
depth++
|
||||
} else if (char === ')') {
|
||||
depth--
|
||||
if (depth === 0) return i + 1
|
||||
}
|
||||
}
|
||||
|
||||
return -1
|
||||
}
|
||||
|
||||
/**
|
||||
* Find existing markdown link/image spans so auto-linkification does not create
|
||||
* nested links inside their labels or destinations.
|
||||
*/
|
||||
function findMarkdownLinkRanges(text: string): CodeRange[] {
|
||||
const ranges: CodeRange[] = []
|
||||
|
||||
for (let i = 0; i < text.length; i++) {
|
||||
if (text[i] !== '[' || isEscaped(text, i)) continue
|
||||
if (ranges.some((r) => i >= r.start && i < r.end)) continue
|
||||
|
||||
const labelEnd = findMatchingBracket(text, i)
|
||||
if (labelEnd === -1) continue
|
||||
|
||||
const start = i > 0 && text[i - 1] === '!' && !isEscaped(text, i - 1) ? i - 1 : i
|
||||
const nextChar = text[labelEnd + 1]
|
||||
|
||||
if (nextChar === '(') {
|
||||
const end = findInlineLinkEnd(text, labelEnd + 1)
|
||||
if (end !== -1) {
|
||||
ranges.push({ start, end })
|
||||
i = end - 1
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
if (nextChar === '[') {
|
||||
const referenceEnd = findMatchingBracket(text, labelEnd + 1)
|
||||
if (referenceEnd !== -1) {
|
||||
ranges.push({ start, end: referenceEnd + 1 })
|
||||
i = referenceEnd
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return ranges
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a link at given position is already a markdown link
|
||||
* Looks for patterns like [text](url) or [text][ref]
|
||||
@@ -116,23 +207,54 @@ function rangesOverlap(
|
||||
return a.start < b.end && b.start < a.end
|
||||
}
|
||||
|
||||
/**
|
||||
* Run linkify-it on `text` and push normalized link records into `out`,
|
||||
* shifted by `offset`. When linkify-it merges multiple URLs into one match
|
||||
* because they are separated only by CJK punctuation (which it doesn't treat
|
||||
* as a URL boundary), we truncate at that punctuation and re-scan the tail.
|
||||
*/
|
||||
function collectLinkifyMatches(text: string, offset: number, out: DetectedLink[]): void {
|
||||
const matches = linkify.match(text)
|
||||
if (!matches) return
|
||||
|
||||
for (const match of matches) {
|
||||
const cjkIdx = match.text.search(CJK_URL_TERMINATOR_REGEX)
|
||||
if (cjkIdx === 0) continue // match starts with CJK punct — skip
|
||||
|
||||
const truncate = cjkIdx > 0
|
||||
const matchText = truncate ? match.text.slice(0, cjkIdx) : match.text
|
||||
// linkify-it may prepend a scheme (e.g. "http://" or "mailto:") to url
|
||||
// while leaving text as the raw substring. Preserve that prefix.
|
||||
const schemePrefix = match.url.slice(0, match.url.length - match.text.length)
|
||||
const matchUrl = truncate ? schemePrefix + matchText : match.url
|
||||
const matchEnd = truncate ? match.index + cjkIdx : match.lastIndex
|
||||
|
||||
out.push({
|
||||
type: match.schema === 'mailto:' ? 'email' : 'url',
|
||||
text: matchText,
|
||||
url: matchUrl,
|
||||
start: match.index + offset,
|
||||
end: matchEnd + offset
|
||||
})
|
||||
|
||||
if (truncate) {
|
||||
// Rescan the tail after the CJK punct — linkify-it had greedily swallowed
|
||||
// it, so any additional URLs after the punct were never emitted.
|
||||
const tailStart = matchEnd + 1
|
||||
collectLinkifyMatches(text.slice(tailStart), offset + tailStart, out)
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Detect all links (URLs, emails, file paths) in text
|
||||
*/
|
||||
export function detectLinks(text: string): DetectedLink[] {
|
||||
const links: DetectedLink[] = []
|
||||
|
||||
// 1. Detect URLs and emails with linkify-it
|
||||
const urlMatches = linkify.match(text) || []
|
||||
for (const match of urlMatches) {
|
||||
links.push({
|
||||
type: match.schema === 'mailto:' ? 'email' : 'url',
|
||||
text: match.text,
|
||||
url: match.url,
|
||||
start: match.index,
|
||||
end: match.lastIndex
|
||||
})
|
||||
}
|
||||
// 1. Detect URLs and emails with linkify-it, applying CJK boundary handling.
|
||||
collectLinkifyMatches(text, 0, links)
|
||||
|
||||
// 2. Detect file paths with custom regex
|
||||
// Reset regex state
|
||||
@@ -176,6 +298,7 @@ export function preprocessLinks(text: string): string {
|
||||
}
|
||||
|
||||
const codeRanges = findCodeRanges(text)
|
||||
const markdownLinkRanges = findMarkdownLinkRanges(text)
|
||||
const links = detectLinks(text)
|
||||
|
||||
if (links.length === 0) return text
|
||||
@@ -188,6 +311,9 @@ export function preprocessLinks(text: string): string {
|
||||
// Skip if inside code block
|
||||
if (isInsideCode(link.start, codeRanges)) continue
|
||||
|
||||
// Skip if this match is inside an existing markdown link or image.
|
||||
if (markdownLinkRanges.some((range) => rangesOverlap(link, range))) continue
|
||||
|
||||
// Skip if already a markdown link
|
||||
if (isAlreadyLinked(text, link.start, link.end)) continue
|
||||
|
||||
|
||||
@@ -39,6 +39,7 @@
|
||||
"recharts": "3.8.0",
|
||||
"rehype-katex": "catalog:",
|
||||
"rehype-raw": "^7.0.0",
|
||||
"remark-breaks": "^4.0.0",
|
||||
"remark-gfm": "^4.0.1",
|
||||
"remark-math": "catalog:",
|
||||
"shiki": "^3.21.0",
|
||||
|
||||
@@ -70,7 +70,7 @@
|
||||
--border: oklch(0.92 0.004 286.32);
|
||||
--input: oklch(0.92 0.004 286.32);
|
||||
--ring: oklch(0.705 0.015 286.067);
|
||||
--chart-1: oklch(0.871 0.006 286.286);
|
||||
--chart-1: oklch(0.705 0.015 286.067);
|
||||
--chart-2: oklch(0.552 0.016 285.938);
|
||||
--chart-3: oklch(0.442 0.017 285.786);
|
||||
--chart-4: oklch(0.37 0.013 285.805);
|
||||
|
||||
@@ -27,6 +27,16 @@ import {
|
||||
DialogContent,
|
||||
DialogTitle,
|
||||
} from "@multica/ui/components/ui/dialog";
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from "@multica/ui/components/ui/alert-dialog";
|
||||
import {
|
||||
TriggerConfigSection,
|
||||
getDefaultTriggerConfig,
|
||||
@@ -91,6 +101,21 @@ function RunRow({ run }: { run: AutopilotRun }) {
|
||||
|
||||
function TriggerRow({ trigger, autopilotId }: { trigger: AutopilotTrigger; autopilotId: string }) {
|
||||
const deleteTrigger = useDeleteAutopilotTrigger();
|
||||
const [confirmOpen, setConfirmOpen] = useState(false);
|
||||
const [deleting, setDeleting] = useState(false);
|
||||
|
||||
const handleDelete = async () => {
|
||||
setDeleting(true);
|
||||
try {
|
||||
await deleteTrigger.mutateAsync({ autopilotId, triggerId: trigger.id });
|
||||
toast.success("Trigger deleted");
|
||||
setConfirmOpen(false);
|
||||
} catch {
|
||||
toast.error("Failed to delete trigger");
|
||||
} finally {
|
||||
setDeleting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-3 rounded-md border px-3 py-2">
|
||||
@@ -121,13 +146,30 @@ function TriggerRow({ trigger, autopilotId }: { trigger: AutopilotTrigger; autop
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
className="h-7 w-7 shrink-0"
|
||||
onClick={() => {
|
||||
deleteTrigger.mutate({ autopilotId, triggerId: trigger.id });
|
||||
toast.success("Trigger deleted");
|
||||
}}
|
||||
onClick={() => setConfirmOpen(true)}
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5 text-muted-foreground" />
|
||||
</Button>
|
||||
<AlertDialog open={confirmOpen} onOpenChange={(v) => { if (!v && !deleting) setConfirmOpen(false); }}>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Delete trigger</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
This trigger will be removed and the autopilot will stop firing on this schedule. This action cannot be undone.
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel disabled={deleting}>Cancel</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
onClick={handleDelete}
|
||||
disabled={deleting}
|
||||
className="bg-destructive text-white hover:bg-destructive/90"
|
||||
>
|
||||
{deleting ? "Deleting..." : "Delete"}
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -211,6 +253,8 @@ export function AutopilotDetailPage({ autopilotId }: { autopilotId: string }) {
|
||||
|
||||
const [triggerDialogOpen, setTriggerDialogOpen] = useState(false);
|
||||
const [editDialogOpen, setEditDialogOpen] = useState(false);
|
||||
const [deleteConfirmOpen, setDeleteConfirmOpen] = useState(false);
|
||||
const [deleting, setDeleting] = useState(false);
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
@@ -271,12 +315,14 @@ export function AutopilotDetailPage({ autopilotId }: { autopilotId: string }) {
|
||||
};
|
||||
|
||||
const handleDelete = async () => {
|
||||
setDeleting(true);
|
||||
try {
|
||||
await deleteAutopilot.mutateAsync(autopilotId);
|
||||
toast.success("Autopilot deleted");
|
||||
router.push(wsPaths.autopilots());
|
||||
} catch {
|
||||
toast.error("Failed to delete autopilot");
|
||||
setDeleting(false);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -338,11 +384,7 @@ export function AutopilotDetailPage({ autopilotId }: { autopilotId: string }) {
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-xs text-muted-foreground">Priority</label>
|
||||
<div className="mt-1 capitalize">{autopilot.priority}</div>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-xs text-muted-foreground">Execution Mode</label>
|
||||
<label className="text-xs text-muted-foreground">Output Mode</label>
|
||||
<div className="mt-1">
|
||||
{autopilot.execution_mode === "create_issue" ? "Create Issue" : "Run Only"}
|
||||
</div>
|
||||
@@ -405,7 +447,7 @@ export function AutopilotDetailPage({ autopilotId }: { autopilotId: string }) {
|
||||
{/* Danger zone */}
|
||||
<section className="space-y-3 pt-4 border-t">
|
||||
<h2 className="text-sm font-medium text-destructive uppercase tracking-wider">Danger Zone</h2>
|
||||
<Button size="sm" variant="destructive" onClick={handleDelete}>
|
||||
<Button size="sm" variant="destructive" onClick={() => setDeleteConfirmOpen(true)}>
|
||||
<Trash2 className="h-3.5 w-3.5 mr-1" />
|
||||
Delete autopilot
|
||||
</Button>
|
||||
@@ -428,12 +470,34 @@ export function AutopilotDetailPage({ autopilotId }: { autopilotId: string }) {
|
||||
title: autopilot.title,
|
||||
description: autopilot.description ?? "",
|
||||
assignee_id: autopilot.assignee_id,
|
||||
priority: autopilot.priority,
|
||||
execution_mode: autopilot.execution_mode as AutopilotExecutionMode,
|
||||
}}
|
||||
triggers={triggers}
|
||||
/>
|
||||
)}
|
||||
<AlertDialog
|
||||
open={deleteConfirmOpen}
|
||||
onOpenChange={(v) => { if (!v && !deleting) setDeleteConfirmOpen(false); }}
|
||||
>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Delete autopilot</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
This will permanently delete “{autopilot.title}”, along with its triggers and run history. This action cannot be undone.
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel disabled={deleting}>Cancel</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
onClick={handleDelete}
|
||||
disabled={deleting}
|
||||
className="bg-destructive text-white hover:bg-destructive/90"
|
||||
>
|
||||
{deleting ? "Deleting..." : "Delete"}
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,8 +1,21 @@
|
||||
"use client";
|
||||
|
||||
import { useRef, useState } from "react";
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { toast } from "sonner";
|
||||
import { Calendar, ChevronRight, Maximize2, Minimize2, Rocket, X as XIcon } from "lucide-react";
|
||||
import {
|
||||
Check,
|
||||
ChevronDown,
|
||||
ChevronRight,
|
||||
Clock,
|
||||
FilePlus2,
|
||||
Maximize2,
|
||||
Minimize2,
|
||||
Play,
|
||||
Rocket,
|
||||
X as XIcon,
|
||||
Zap,
|
||||
} from "lucide-react";
|
||||
import { cn } from "@multica/ui/lib/utils";
|
||||
import {
|
||||
Dialog,
|
||||
@@ -11,7 +24,18 @@ import {
|
||||
} from "@multica/ui/components/ui/dialog";
|
||||
import { Tooltip, TooltipTrigger, TooltipContent } from "@multica/ui/components/ui/tooltip";
|
||||
import { Button } from "@multica/ui/components/ui/button";
|
||||
import {
|
||||
Select,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
} from "@multica/ui/components/ui/select";
|
||||
import { TimeInput } from "@multica/ui/components/ui/time-input";
|
||||
import { TimezonePicker } from "./pickers/timezone-picker";
|
||||
import { useCurrentWorkspace } from "@multica/core/paths";
|
||||
import { useWorkspaceId } from "@multica/core/hooks";
|
||||
import { agentListOptions } from "@multica/core/workspace/queries";
|
||||
import {
|
||||
useCreateAutopilot,
|
||||
useCreateAutopilotTrigger,
|
||||
@@ -21,21 +45,18 @@ import {
|
||||
import type {
|
||||
AutopilotExecutionMode,
|
||||
AutopilotTrigger,
|
||||
IssuePriority,
|
||||
} from "@multica/core/types";
|
||||
import { TitleEditor, ContentEditor } from "../../editor";
|
||||
import { PillButton } from "../../common/pill-button";
|
||||
import { PriorityPicker } from "../../issues/components/pickers";
|
||||
import { ActorAvatar } from "../../common/actor-avatar";
|
||||
import { AgentPicker } from "./pickers/agent-picker";
|
||||
import {
|
||||
getDefaultTriggerConfig,
|
||||
getLocalTimezone,
|
||||
parseCronExpression,
|
||||
summarizeTrigger,
|
||||
toCronExpression,
|
||||
type TriggerConfig,
|
||||
type TriggerFrequency,
|
||||
} from "./trigger-config";
|
||||
import { AgentPicker } from "./pickers/agent-picker";
|
||||
import { ExecutionModePicker } from "./pickers/execution-mode-picker";
|
||||
import { SchedulePicker } from "./pickers/schedule-picker";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Types
|
||||
@@ -45,7 +66,6 @@ export interface AutopilotInitial {
|
||||
title: string;
|
||||
description: string;
|
||||
assignee_id: string;
|
||||
priority: string;
|
||||
execution_mode: AutopilotExecutionMode;
|
||||
}
|
||||
|
||||
@@ -67,20 +87,174 @@ export type AutopilotDialogProps =
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// AutopilotDialog — shared Create/Edit dialog for autopilots
|
||||
// Static data
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const FREQUENCY_OPTIONS: { value: TriggerFrequency; label: string }[] = [
|
||||
{ value: "hourly", label: "Every hour" },
|
||||
{ value: "daily", label: "Every day" },
|
||||
{ value: "weekdays", label: "Every weekday" },
|
||||
{ value: "weekly", label: "Every week" },
|
||||
{ value: "custom", label: "Custom cron" },
|
||||
];
|
||||
|
||||
const DAY_OPTIONS: { value: number; label: string; short: string }[] = [
|
||||
{ value: 0, label: "Sunday", short: "Sun" },
|
||||
{ value: 1, label: "Monday", short: "Mon" },
|
||||
{ value: 2, label: "Tuesday", short: "Tue" },
|
||||
{ value: 3, label: "Wednesday", short: "Wed" },
|
||||
{ value: 4, label: "Thursday", short: "Thu" },
|
||||
{ value: 5, label: "Friday", short: "Fri" },
|
||||
{ value: 6, label: "Saturday", short: "Sat" },
|
||||
];
|
||||
|
||||
const TIMEZONE_OPTIONS = [
|
||||
"UTC",
|
||||
"America/New_York",
|
||||
"America/Chicago",
|
||||
"America/Denver",
|
||||
"America/Los_Angeles",
|
||||
"America/Sao_Paulo",
|
||||
"Europe/London",
|
||||
"Europe/Paris",
|
||||
"Europe/Berlin",
|
||||
"Europe/Moscow",
|
||||
"Asia/Dubai",
|
||||
"Asia/Kolkata",
|
||||
"Asia/Singapore",
|
||||
"Asia/Shanghai",
|
||||
"Asia/Tokyo",
|
||||
"Asia/Seoul",
|
||||
"Australia/Sydney",
|
||||
"Pacific/Auckland",
|
||||
];
|
||||
|
||||
const OUTPUT_MODES: {
|
||||
value: AutopilotExecutionMode;
|
||||
label: string;
|
||||
description: string;
|
||||
Icon: typeof FilePlus2;
|
||||
}[] = [
|
||||
{
|
||||
value: "create_issue",
|
||||
label: "Create issue",
|
||||
description: "Each run creates a tracked issue",
|
||||
Icon: FilePlus2,
|
||||
},
|
||||
{
|
||||
value: "run_only",
|
||||
label: "Run only",
|
||||
description: "Silent run, no issue created",
|
||||
Icon: Play,
|
||||
},
|
||||
];
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Next-run computation (local approximation — server stores the authoritative value)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function computeNextRun(cfg: TriggerConfig, now: Date): Date | null {
|
||||
const [hStr, mStr] = cfg.time.split(":");
|
||||
const hour = parseInt(hStr ?? "9", 10);
|
||||
const minute = parseInt(mStr ?? "0", 10);
|
||||
const next = new Date(now);
|
||||
|
||||
switch (cfg.frequency) {
|
||||
case "hourly": {
|
||||
next.setMinutes(minute, 0, 0);
|
||||
if (next <= now) next.setHours(next.getHours() + 1);
|
||||
return next;
|
||||
}
|
||||
case "daily": {
|
||||
next.setHours(hour, minute, 0, 0);
|
||||
if (next <= now) next.setDate(next.getDate() + 1);
|
||||
return next;
|
||||
}
|
||||
case "weekdays": {
|
||||
next.setHours(hour, minute, 0, 0);
|
||||
for (let i = 0; i < 8; i++) {
|
||||
const dow = next.getDay();
|
||||
if (next > now && dow >= 1 && dow <= 5) return next;
|
||||
next.setDate(next.getDate() + 1);
|
||||
next.setHours(hour, minute, 0, 0);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
case "weekly": {
|
||||
if (cfg.daysOfWeek.length === 0) return null;
|
||||
next.setHours(hour, minute, 0, 0);
|
||||
for (let i = 0; i < 8; i++) {
|
||||
if (next > now && cfg.daysOfWeek.includes(next.getDay())) return next;
|
||||
next.setDate(next.getDate() + 1);
|
||||
next.setHours(hour, minute, 0, 0);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
case "custom":
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function formatCountdown(target: Date, now: Date): string {
|
||||
const diff = Math.max(0, target.getTime() - now.getTime());
|
||||
const seconds = Math.floor(diff / 1000);
|
||||
const days = Math.floor(seconds / 86400);
|
||||
const hours = Math.floor((seconds % 86400) / 3600);
|
||||
const minutes = Math.floor((seconds % 3600) / 60);
|
||||
if (days > 0) return `${days}d ${hours}h`;
|
||||
if (hours > 0) return `${hours}h ${minutes}m`;
|
||||
if (minutes > 0) return `${minutes}m`;
|
||||
return "<1m";
|
||||
}
|
||||
|
||||
function formatNextRunAbsolute(date: Date, timezone: string): string {
|
||||
try {
|
||||
return new Intl.DateTimeFormat("en-US", {
|
||||
timeZone: timezone,
|
||||
weekday: "short",
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
hour: "numeric",
|
||||
minute: "2-digit",
|
||||
hour12: true,
|
||||
}).format(date);
|
||||
} catch {
|
||||
return date.toLocaleString();
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Live "now" ticker for countdown
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function useNowTicker(intervalMs = 30_000): Date {
|
||||
const [now, setNow] = useState(() => new Date());
|
||||
useEffect(() => {
|
||||
const t = setInterval(() => setNow(new Date()), intervalMs);
|
||||
return () => clearInterval(t);
|
||||
}, [intervalMs]);
|
||||
return now;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// AutopilotDialog
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function AutopilotDialog(props: AutopilotDialogProps) {
|
||||
const { open, onOpenChange } = props;
|
||||
const workspaceName = useCurrentWorkspace()?.name;
|
||||
const wsId = useWorkspaceId();
|
||||
const { data: agents = [] } = useQuery(agentListOptions(wsId));
|
||||
const [isExpanded, setIsExpanded] = useState(false);
|
||||
|
||||
const isCreate = props.mode === "create";
|
||||
const initial: Partial<AutopilotInitial> = isCreate ? props.initial ?? {} : props.initial;
|
||||
const initial: Partial<AutopilotInitial> = isCreate
|
||||
? props.initial ?? {}
|
||||
: props.initial;
|
||||
|
||||
const [title, setTitle] = useState(initial.title ?? "");
|
||||
const [description, setDescription] = useState(initial.description ?? "");
|
||||
const [assigneeId, setAssigneeId] = useState<string>(initial.assignee_id ?? "");
|
||||
const [priority, setPriority] = useState<string>(initial.priority ?? "medium");
|
||||
const [executionMode, setExecutionMode] = useState<AutopilotExecutionMode>(
|
||||
initial.execution_mode ?? "create_issue",
|
||||
);
|
||||
@@ -97,17 +271,13 @@ export function AutopilotDialog(props: AutopilotDialogProps) {
|
||||
return getDefaultTriggerConfig();
|
||||
})();
|
||||
const [triggerConfig, setTriggerConfig] = useState<TriggerConfig>(initialCfg);
|
||||
// Snapshot initial cron payload at mount so `scheduleDirty` only flips when
|
||||
// the payload actually changes (prevents phantom trigger creates when the
|
||||
// user opens the popover, clicks the already-active option, then Saves).
|
||||
|
||||
const initialCronRef = useRef(toCronExpression(initialCfg));
|
||||
const initialTimezoneRef = useRef(initialCfg.timezone);
|
||||
const scheduleDirty =
|
||||
toCronExpression(triggerConfig) !== initialCronRef.current ||
|
||||
triggerConfig.timezone !== initialTimezoneRef.current;
|
||||
|
||||
// Snapshot the first-trigger id at mount. Parent `triggers` prop can update
|
||||
// mid-dialog via WS refetch — we want Save to act on the trigger we showed.
|
||||
const firstTriggerIdRef = useRef(
|
||||
!isCreate && props.triggers[0] ? props.triggers[0].id : null,
|
||||
);
|
||||
@@ -115,12 +285,10 @@ export function AutopilotDialog(props: AutopilotDialogProps) {
|
||||
const triggerCount = isCreate ? 0 : props.triggers.length;
|
||||
const schedulePillDisabled = !isCreate && triggerCount >= 2;
|
||||
|
||||
const schedulePillLabel = (() => {
|
||||
if (isCreate) return summarizeTrigger(triggerConfig);
|
||||
if (triggerCount === 0) return "Add schedule";
|
||||
if (triggerCount === 1) return summarizeTrigger(triggerConfig);
|
||||
return `${triggerCount} schedules`;
|
||||
})();
|
||||
const selectedAgent = useMemo(
|
||||
() => agents.find((a) => a.id === assigneeId) ?? null,
|
||||
[agents, assigneeId],
|
||||
);
|
||||
|
||||
const createAutopilot = useCreateAutopilot();
|
||||
const createTrigger = useCreateAutopilotTrigger();
|
||||
@@ -128,7 +296,8 @@ export function AutopilotDialog(props: AutopilotDialogProps) {
|
||||
const updateTrigger = useUpdateAutopilotTrigger();
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
|
||||
const canSubmit = title.trim().length > 0 && assigneeId.length > 0 && !submitting;
|
||||
const canSubmit =
|
||||
title.trim().length > 0 && assigneeId.length > 0 && !submitting;
|
||||
|
||||
const handleSubmit = async () => {
|
||||
if (!canSubmit) return;
|
||||
@@ -139,7 +308,6 @@ export function AutopilotDialog(props: AutopilotDialogProps) {
|
||||
title: title.trim(),
|
||||
description: description.trim() || undefined,
|
||||
assignee_id: assigneeId,
|
||||
priority,
|
||||
execution_mode: executionMode,
|
||||
});
|
||||
let scheduleOk = true;
|
||||
@@ -162,10 +330,8 @@ export function AutopilotDialog(props: AutopilotDialogProps) {
|
||||
title: title.trim(),
|
||||
description: description.trim() || null,
|
||||
assignee_id: assigneeId,
|
||||
priority,
|
||||
execution_mode: executionMode,
|
||||
});
|
||||
// Schedule: patch the trigger we snapshotted at mount, else create one.
|
||||
let scheduleOk = true;
|
||||
if (scheduleDirty && !schedulePillDisabled) {
|
||||
const snapshottedTriggerId = firstTriggerIdRef.current;
|
||||
@@ -210,7 +376,9 @@ export function AutopilotDialog(props: AutopilotDialogProps) {
|
||||
"p-0 gap-0 flex flex-col overflow-hidden",
|
||||
"!transition-all !duration-300 !ease-out !-translate-y-1/2",
|
||||
"!w-[calc(100vw-2rem)]",
|
||||
isExpanded ? "!max-w-4xl !h-5/6" : "!max-w-2xl !h-96",
|
||||
isExpanded
|
||||
? "!max-w-6xl !h-[calc(100vh-4rem)]"
|
||||
: "!max-w-5xl !h-[min(720px,calc(100vh-4rem))]",
|
||||
)}
|
||||
>
|
||||
<DialogTitle className="sr-only">
|
||||
@@ -218,14 +386,24 @@ export function AutopilotDialog(props: AutopilotDialogProps) {
|
||||
</DialogTitle>
|
||||
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between px-5 pt-3 pb-2 shrink-0">
|
||||
<div className="flex items-center gap-1.5 text-xs">
|
||||
<span className="text-muted-foreground">{workspaceName}</span>
|
||||
<ChevronRight className="size-3 text-muted-foreground/50" />
|
||||
<Rocket className="size-3 text-muted-foreground" />
|
||||
<span className="font-medium">
|
||||
{isCreate ? "New autopilot" : "Edit autopilot"}
|
||||
</span>
|
||||
<div className="flex items-center justify-between px-5 pt-3 pb-2 shrink-0 border-b">
|
||||
<div className="flex items-center gap-2 text-xs">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className="inline-flex size-5 items-center justify-center rounded-md bg-primary/15 text-primary">
|
||||
<Rocket className="size-3" />
|
||||
</span>
|
||||
<span className="font-medium text-foreground">
|
||||
{isCreate ? "New autopilot" : "Edit autopilot"}
|
||||
</span>
|
||||
</div>
|
||||
<span className="text-muted-foreground/60">·</span>
|
||||
<span className="text-muted-foreground">A recurring AI task</span>
|
||||
{workspaceName && (
|
||||
<>
|
||||
<ChevronRight className="size-3 text-muted-foreground/40" />
|
||||
<span className="text-muted-foreground">{workspaceName}</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<Tooltip>
|
||||
@@ -257,94 +435,339 @@ export function AutopilotDialog(props: AutopilotDialogProps) {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Body re-mounts when switching between autopilots (or create/edit) */}
|
||||
<div key={contentKey} className="flex-1 flex flex-col min-h-0">
|
||||
{/* Name */}
|
||||
<div className="px-5 pb-2 shrink-0">
|
||||
<TitleEditor
|
||||
autoFocus={isCreate}
|
||||
defaultValue={initial.title ?? ""}
|
||||
placeholder="Autopilot name"
|
||||
className="text-lg font-semibold"
|
||||
onChange={setTitle}
|
||||
onSubmit={handleSubmit}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Prompt — takes remaining space */}
|
||||
<div className="relative flex-1 min-h-0 overflow-y-auto px-5">
|
||||
<ContentEditor
|
||||
defaultValue={initial.description ?? ""}
|
||||
placeholder="Step-by-step instructions for the agent..."
|
||||
onUpdate={setDescription}
|
||||
debounceMs={300}
|
||||
showBubbleMenu={false}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Pill toolbar */}
|
||||
<div className="flex items-center gap-1.5 px-4 py-2 shrink-0 flex-wrap">
|
||||
<AgentPicker
|
||||
agentId={assigneeId || null}
|
||||
onChange={setAssigneeId}
|
||||
triggerRender={<PillButton />}
|
||||
align="start"
|
||||
/>
|
||||
<PriorityPicker
|
||||
priority={priority as IssuePriority}
|
||||
onUpdate={(u) => { if (u.priority) setPriority(u.priority); }}
|
||||
triggerRender={<PillButton />}
|
||||
align="start"
|
||||
/>
|
||||
<ExecutionModePicker
|
||||
mode={executionMode}
|
||||
onChange={setExecutionMode}
|
||||
triggerRender={<PillButton />}
|
||||
align="start"
|
||||
/>
|
||||
{schedulePillDisabled ? (
|
||||
<Tooltip>
|
||||
<TooltipTrigger
|
||||
render={
|
||||
<PillButton
|
||||
disabled
|
||||
className="opacity-60 cursor-not-allowed"
|
||||
>
|
||||
<Calendar className="size-3" />
|
||||
<span className="truncate">{schedulePillLabel}</span>
|
||||
</PillButton>
|
||||
}
|
||||
/>
|
||||
<TooltipContent side="top">Edit schedules in detail page</TooltipContent>
|
||||
</Tooltip>
|
||||
) : (
|
||||
<SchedulePicker
|
||||
config={triggerConfig}
|
||||
onChange={setTriggerConfig}
|
||||
triggerRender={
|
||||
<PillButton>
|
||||
<Calendar className="size-3" />
|
||||
<span className="truncate">{schedulePillLabel}</span>
|
||||
</PillButton>
|
||||
}
|
||||
{/* Body: two columns (stacks on narrow screens via flex-wrap at container level) */}
|
||||
<div
|
||||
key={contentKey}
|
||||
className="flex-1 min-h-0 flex flex-col lg:flex-row overflow-hidden"
|
||||
>
|
||||
{/* Left: Runbook */}
|
||||
<div className="flex-1 min-h-0 flex flex-col border-b lg:border-b-0 lg:border-r">
|
||||
<div className="px-6 pt-5 pb-3 shrink-0">
|
||||
<TitleEditor
|
||||
autoFocus={isCreate}
|
||||
defaultValue={initial.title ?? ""}
|
||||
placeholder="Autopilot name"
|
||||
className="text-2xl font-semibold tracking-tight"
|
||||
onChange={setTitle}
|
||||
onSubmit={handleSubmit}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="px-6 pb-2 shrink-0 flex items-baseline gap-2">
|
||||
<span className="text-[11px] font-semibold tracking-[0.08em] text-muted-foreground uppercase">
|
||||
Runbook
|
||||
</span>
|
||||
<span className="text-xs text-muted-foreground/80">
|
||||
Read by the agent on every run
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 min-h-0 px-6 pb-6 flex flex-col">
|
||||
<div className="h-full overflow-y-auto rounded-lg border border-border bg-background transition-colors focus-within:border-input px-4 py-3">
|
||||
<ContentEditor
|
||||
defaultValue={initial.description ?? ""}
|
||||
placeholder={`# Goal\nWhat should the agent accomplish?\n\n# Context\nWho is this for? Any constraints?\n\n# Steps\n1. …\n2. …`}
|
||||
onUpdate={setDescription}
|
||||
debounceMs={300}
|
||||
showBubbleMenu={false}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="flex items-center justify-end gap-2 px-4 py-3 border-t shrink-0">
|
||||
{/* Right: Configuration */}
|
||||
<aside className="w-full lg:w-[340px] shrink-0 overflow-y-auto px-5 py-5 space-y-5 bg-muted/30">
|
||||
<AgentSection
|
||||
selectedId={assigneeId}
|
||||
onChange={setAssigneeId}
|
||||
selectedName={selectedAgent?.name}
|
||||
selectedDescription={selectedAgent?.description}
|
||||
/>
|
||||
|
||||
<OutputModeSection mode={executionMode} onChange={setExecutionMode} />
|
||||
|
||||
<ScheduleSection
|
||||
config={triggerConfig}
|
||||
onChange={setTriggerConfig}
|
||||
disabled={schedulePillDisabled}
|
||||
disabledReason={
|
||||
schedulePillDisabled
|
||||
? "This autopilot has multiple schedules — edit them in the detail page."
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
</aside>
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="flex items-center justify-between gap-3 px-5 py-3 border-t shrink-0 bg-background">
|
||||
<div className="flex items-center gap-1.5 text-xs text-muted-foreground min-w-0">
|
||||
<Zap className="size-3.5 text-amber-500 shrink-0" />
|
||||
<span className="truncate">
|
||||
Once saved, runs automatically until paused.
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 shrink-0">
|
||||
<Button size="sm" variant="outline" onClick={() => onOpenChange(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button size="sm" onClick={handleSubmit} disabled={!canSubmit}>
|
||||
{submitting
|
||||
? isCreate ? "Creating..." : "Saving..."
|
||||
: isCreate ? "Create autopilot" : "Save"}
|
||||
? isCreate
|
||||
? "Creating..."
|
||||
: "Saving..."
|
||||
: isCreate
|
||||
? "Create autopilot"
|
||||
: "Save"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Right column sections
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function SectionLabel({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<div className="text-[11px] font-semibold tracking-[0.08em] text-muted-foreground uppercase mb-2">
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function AgentSection({
|
||||
selectedId,
|
||||
onChange,
|
||||
selectedName,
|
||||
selectedDescription,
|
||||
}: {
|
||||
selectedId: string;
|
||||
onChange: (id: string) => void;
|
||||
selectedName?: string;
|
||||
selectedDescription?: string;
|
||||
}) {
|
||||
return (
|
||||
<div>
|
||||
<SectionLabel>Agent</SectionLabel>
|
||||
<AgentPicker
|
||||
agentId={selectedId || null}
|
||||
onChange={onChange}
|
||||
align="start"
|
||||
triggerRender={
|
||||
<button
|
||||
type="button"
|
||||
className={cn(
|
||||
"w-full flex items-center gap-2.5 rounded-md border bg-background px-3 py-2 text-left",
|
||||
"hover:bg-accent/40 transition-colors cursor-pointer",
|
||||
)}
|
||||
>
|
||||
{selectedId ? (
|
||||
<ActorAvatar
|
||||
actorType="agent"
|
||||
actorId={selectedId}
|
||||
size={28}
|
||||
disableHoverCard
|
||||
/>
|
||||
) : (
|
||||
<span className="inline-flex size-7 items-center justify-center rounded-full bg-muted text-muted-foreground">
|
||||
<Rocket className="size-3.5" />
|
||||
</span>
|
||||
)}
|
||||
<span className="flex-1 min-w-0">
|
||||
<span className="block text-sm font-medium truncate">
|
||||
{selectedName ?? "Select agent"}
|
||||
</span>
|
||||
{selectedDescription && (
|
||||
<span className="block text-xs text-muted-foreground truncate">
|
||||
{selectedDescription}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
<ChevronDown className="size-3.5 text-muted-foreground shrink-0" />
|
||||
</button>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function OutputModeSection({
|
||||
mode,
|
||||
onChange,
|
||||
}: {
|
||||
mode: AutopilotExecutionMode;
|
||||
onChange: (mode: AutopilotExecutionMode) => void;
|
||||
}) {
|
||||
return (
|
||||
<div>
|
||||
<SectionLabel>Output mode</SectionLabel>
|
||||
<div className="space-y-1.5">
|
||||
{OUTPUT_MODES.map((o) => {
|
||||
const selected = o.value === mode;
|
||||
return (
|
||||
<button
|
||||
key={o.value}
|
||||
type="button"
|
||||
onClick={() => onChange(o.value)}
|
||||
className={cn(
|
||||
"w-full flex items-start gap-2.5 rounded-md border px-3 py-2 text-left cursor-pointer transition-colors",
|
||||
selected
|
||||
? "border-primary bg-primary/5"
|
||||
: "bg-background hover:bg-accent/40",
|
||||
)}
|
||||
>
|
||||
<span
|
||||
className={cn(
|
||||
"mt-0.5 inline-flex size-4 shrink-0 items-center justify-center rounded-full border",
|
||||
selected
|
||||
? "border-primary bg-primary text-primary-foreground"
|
||||
: "border-muted-foreground/40 bg-background",
|
||||
)}
|
||||
>
|
||||
{selected && <Check className="size-2.5" strokeWidth={3} />}
|
||||
</span>
|
||||
<span className="flex-1 min-w-0">
|
||||
<span className="block text-sm font-medium">{o.label}</span>
|
||||
<span className="block text-xs text-muted-foreground">
|
||||
{o.description}
|
||||
</span>
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ScheduleSection({
|
||||
config,
|
||||
onChange,
|
||||
disabled,
|
||||
disabledReason,
|
||||
}: {
|
||||
config: TriggerConfig;
|
||||
onChange: (c: TriggerConfig) => void;
|
||||
disabled?: boolean;
|
||||
disabledReason?: string;
|
||||
}) {
|
||||
const now = useNowTicker();
|
||||
const next = useMemo(() => computeNextRun(config, now), [config, now]);
|
||||
const timezones = useMemo(() => {
|
||||
const local = getLocalTimezone();
|
||||
if (TIMEZONE_OPTIONS.includes(local)) return TIMEZONE_OPTIONS;
|
||||
return [local, ...TIMEZONE_OPTIONS];
|
||||
}, []);
|
||||
|
||||
const selectedDay = config.daysOfWeek[0] ?? 1;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<SectionLabel>Schedule</SectionLabel>
|
||||
<div
|
||||
className={cn(
|
||||
"space-y-2",
|
||||
disabled && "opacity-60 pointer-events-none",
|
||||
)}
|
||||
>
|
||||
{/* Row 1: Frequency + (Day when weekly) */}
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<Select
|
||||
value={config.frequency}
|
||||
onValueChange={(v) =>
|
||||
v && onChange({ ...config, frequency: v as TriggerFrequency })
|
||||
}
|
||||
>
|
||||
<SelectTrigger className="w-full">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{FREQUENCY_OPTIONS.map((f) => (
|
||||
<SelectItem key={f.value} value={f.value}>
|
||||
{f.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{config.frequency === "weekly" ? (
|
||||
<Select
|
||||
value={String(selectedDay)}
|
||||
onValueChange={(v) =>
|
||||
v && onChange({ ...config, daysOfWeek: [parseInt(v, 10)] })
|
||||
}
|
||||
>
|
||||
<SelectTrigger className="w-full">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{DAY_OPTIONS.map((d) => (
|
||||
<SelectItem key={d.value} value={String(d.value)}>
|
||||
{d.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
) : (
|
||||
<div />
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Row 2: Time + Timezone (hidden for hourly / custom) */}
|
||||
{config.frequency === "custom" ? (
|
||||
<input
|
||||
type="text"
|
||||
value={config.cronExpression}
|
||||
onChange={(e) =>
|
||||
onChange({ ...config, cronExpression: e.target.value })
|
||||
}
|
||||
placeholder="0 9 * * 1-5"
|
||||
className="w-full rounded-lg border border-input bg-transparent px-2.5 py-1 h-8 text-sm font-mono outline-none transition-colors focus:border-ring focus:ring-3 focus:ring-ring/50 dark:bg-input/30"
|
||||
/>
|
||||
) : config.frequency === "hourly" ? (
|
||||
<TimeInput
|
||||
minuteOnly
|
||||
value={config.time}
|
||||
onChange={(v) => onChange({ ...config, time: v })}
|
||||
/>
|
||||
) : (
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<TimeInput
|
||||
value={config.time}
|
||||
onChange={(v) => onChange({ ...config, time: v })}
|
||||
/>
|
||||
<TimezonePicker
|
||||
value={config.timezone}
|
||||
onChange={(tz) => onChange({ ...config, timezone: tz })}
|
||||
options={timezones}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Next run preview */}
|
||||
{next && (
|
||||
<div className="flex items-center gap-1.5 text-xs text-muted-foreground pt-1">
|
||||
<Clock className="size-3 shrink-0" />
|
||||
<span className="truncate">
|
||||
Next run:{" "}
|
||||
<span className="text-foreground">
|
||||
{formatNextRunAbsolute(next, config.timezone)}
|
||||
</span>
|
||||
</span>
|
||||
<span className="ml-auto rounded-sm bg-muted px-1.5 py-0.5 text-[10px] font-medium text-foreground shrink-0">
|
||||
{formatCountdown(next, now)}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{disabled && disabledReason && (
|
||||
<p className="mt-2 text-[11px] text-muted-foreground">
|
||||
{disabledReason}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,76 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { FilePlus2, Play } from "lucide-react";
|
||||
import type { AutopilotExecutionMode } from "@multica/core/types";
|
||||
import { Tooltip, TooltipTrigger, TooltipContent } from "@multica/ui/components/ui/tooltip";
|
||||
import {
|
||||
PropertyPicker,
|
||||
PickerItem,
|
||||
} from "../../../issues/components/pickers/property-picker";
|
||||
|
||||
const OPTIONS: { value: AutopilotExecutionMode; label: string; description: string; Icon: typeof FilePlus2 }[] = [
|
||||
{ value: "create_issue", label: "Create Issue", description: "File an issue with the agent assigned", Icon: FilePlus2 },
|
||||
{ value: "run_only", label: "Run Only", description: "Run the agent without creating an issue", Icon: Play },
|
||||
];
|
||||
|
||||
export function ExecutionModePicker({
|
||||
mode,
|
||||
onChange,
|
||||
trigger: customTrigger,
|
||||
triggerRender,
|
||||
align = "start",
|
||||
}: {
|
||||
mode: AutopilotExecutionMode;
|
||||
onChange: (mode: AutopilotExecutionMode) => void;
|
||||
trigger?: React.ReactNode;
|
||||
triggerRender?: React.ReactElement;
|
||||
align?: "start" | "center" | "end";
|
||||
}) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const current = OPTIONS.find((o) => o.value === mode) ?? OPTIONS[0]!;
|
||||
const CurrentIcon = current.Icon;
|
||||
|
||||
return (
|
||||
<PropertyPicker
|
||||
open={open}
|
||||
onOpenChange={setOpen}
|
||||
width="w-52"
|
||||
align={align}
|
||||
triggerRender={triggerRender}
|
||||
trigger={
|
||||
customTrigger ?? (
|
||||
<>
|
||||
<CurrentIcon className="size-3 shrink-0" />
|
||||
<span className="truncate">{current.label}</span>
|
||||
</>
|
||||
)
|
||||
}
|
||||
>
|
||||
{OPTIONS.map((o) => {
|
||||
const Icon = o.Icon;
|
||||
return (
|
||||
<Tooltip key={o.value}>
|
||||
<TooltipTrigger
|
||||
render={
|
||||
<PickerItem
|
||||
selected={o.value === mode}
|
||||
onClick={() => {
|
||||
onChange(o.value);
|
||||
setOpen(false);
|
||||
}}
|
||||
>
|
||||
<Icon className="size-3.5 shrink-0 text-muted-foreground" />
|
||||
<span>{o.label}</span>
|
||||
</PickerItem>
|
||||
}
|
||||
/>
|
||||
<TooltipContent side="right" sideOffset={8}>
|
||||
{o.description}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
);
|
||||
})}
|
||||
</PropertyPicker>
|
||||
);
|
||||
}
|
||||
@@ -1,32 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import {
|
||||
Popover,
|
||||
PopoverTrigger,
|
||||
PopoverContent,
|
||||
} from "@multica/ui/components/ui/popover";
|
||||
import {
|
||||
TriggerConfigSection,
|
||||
type TriggerConfig,
|
||||
} from "../trigger-config";
|
||||
|
||||
export function SchedulePicker({
|
||||
config,
|
||||
onChange,
|
||||
triggerRender,
|
||||
}: {
|
||||
config: TriggerConfig;
|
||||
onChange: (cfg: TriggerConfig) => void;
|
||||
triggerRender: React.ReactElement;
|
||||
}) {
|
||||
const [open, setOpen] = useState(false);
|
||||
return (
|
||||
<Popover open={open} onOpenChange={setOpen}>
|
||||
<PopoverTrigger render={triggerRender} />
|
||||
<PopoverContent align="start" className="w-96 p-3">
|
||||
<TriggerConfigSection config={config} onChange={onChange} />
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
}
|
||||
131
packages/views/autopilots/components/pickers/timezone-picker.tsx
Normal file
131
packages/views/autopilots/components/pickers/timezone-picker.tsx
Normal file
@@ -0,0 +1,131 @@
|
||||
"use client";
|
||||
|
||||
import { useMemo, useState } from "react";
|
||||
import { Check, ChevronDown, Globe } from "lucide-react";
|
||||
import { cn } from "@multica/ui/lib/utils";
|
||||
import {
|
||||
PropertyPicker,
|
||||
PickerEmpty,
|
||||
} from "../../../issues/components/pickers/property-picker";
|
||||
|
||||
export interface TimezonePickerProps {
|
||||
value: string;
|
||||
onChange: (tz: string) => void;
|
||||
options: string[];
|
||||
disabled?: boolean;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
function offsetFor(tz: string): string {
|
||||
try {
|
||||
const parts = new Intl.DateTimeFormat("en-US", {
|
||||
timeZone: tz,
|
||||
timeZoneName: "shortOffset",
|
||||
}).formatToParts(new Date());
|
||||
return parts.find((p) => p.type === "timeZoneName")?.value ?? "";
|
||||
} catch {
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
function cityLabel(tz: string): string {
|
||||
if (tz === "UTC") return "UTC";
|
||||
return tz.split("/").pop()?.replace(/_/g, " ") ?? tz;
|
||||
}
|
||||
|
||||
export function TimezonePicker({
|
||||
value,
|
||||
onChange,
|
||||
options,
|
||||
disabled,
|
||||
className,
|
||||
}: TimezonePickerProps) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [filter, setFilter] = useState("");
|
||||
|
||||
const selectedCity = cityLabel(value);
|
||||
const selectedOffset = useMemo(() => offsetFor(value), [value]);
|
||||
|
||||
const query = filter.trim().toLowerCase();
|
||||
const filteredOptions = useMemo(() => {
|
||||
if (!query) return options;
|
||||
return options.filter((tz) => {
|
||||
const haystack = `${tz} ${cityLabel(tz)} ${offsetFor(tz)}`.toLowerCase();
|
||||
return haystack.includes(query);
|
||||
});
|
||||
}, [options, query]);
|
||||
|
||||
return (
|
||||
<PropertyPicker
|
||||
open={open}
|
||||
onOpenChange={(v) => {
|
||||
setOpen(v);
|
||||
if (!v) setFilter("");
|
||||
}}
|
||||
width="w-64"
|
||||
align="start"
|
||||
searchable
|
||||
searchPlaceholder="Search timezone..."
|
||||
onSearchChange={setFilter}
|
||||
triggerRender={
|
||||
<button
|
||||
type="button"
|
||||
disabled={disabled}
|
||||
className={cn(
|
||||
"flex h-8 w-full items-center gap-1.5 rounded-lg border border-input bg-transparent px-2.5 text-sm transition-colors outline-none",
|
||||
"hover:bg-accent/30",
|
||||
"focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50",
|
||||
"disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50",
|
||||
"dark:bg-input/30",
|
||||
className,
|
||||
)}
|
||||
/>
|
||||
}
|
||||
trigger={
|
||||
<>
|
||||
<Globe className="size-3.5 shrink-0 text-muted-foreground" />
|
||||
<span className="flex-1 truncate text-left">{selectedCity}</span>
|
||||
{selectedOffset && (
|
||||
<span className="shrink-0 text-xs text-muted-foreground tabular-nums">
|
||||
{selectedOffset}
|
||||
</span>
|
||||
)}
|
||||
<ChevronDown className="size-3.5 shrink-0 text-muted-foreground" />
|
||||
</>
|
||||
}
|
||||
>
|
||||
{filteredOptions.length === 0 ? (
|
||||
<PickerEmpty />
|
||||
) : (
|
||||
filteredOptions.map((tz) => {
|
||||
const off = offsetFor(tz);
|
||||
const isSelected = tz === value;
|
||||
return (
|
||||
<button
|
||||
key={tz}
|
||||
type="button"
|
||||
data-picker-item
|
||||
onClick={() => {
|
||||
onChange(tz);
|
||||
setOpen(false);
|
||||
}}
|
||||
className="flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-sm transition-colors hover:bg-accent"
|
||||
>
|
||||
<span className="flex size-3.5 shrink-0 items-center justify-center">
|
||||
{isSelected && (
|
||||
<Check className="size-3.5 text-foreground" />
|
||||
)}
|
||||
</span>
|
||||
<span className="flex-1 truncate text-left">{cityLabel(tz)}</span>
|
||||
{off && (
|
||||
<span className="shrink-0 text-xs text-muted-foreground tabular-nums">
|
||||
{off}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</PropertyPicker>
|
||||
);
|
||||
}
|
||||
@@ -55,3 +55,20 @@ describe("ReadonlyContent math rendering", () => {
|
||||
expect(text).toContain("\\int_0^1 x^2 \\, dx");
|
||||
});
|
||||
});
|
||||
|
||||
describe("ReadonlyContent line breaks", () => {
|
||||
// Issue panel comments are the primary user-visible surface for agent
|
||||
// output. CommonMark's default soft-break behavior collapses single
|
||||
// newlines into spaces; agent text often relies on a single newline as a
|
||||
// visible break. remark-breaks must remain wired into ReadonlyContent's
|
||||
// remark plugin chain or comments lose their formatting again.
|
||||
it("converts a single newline into a <br>", () => {
|
||||
const { container } = render(<ReadonlyContent content={"line one\nline two"} />);
|
||||
expect(container.querySelector("br")).not.toBeNull();
|
||||
});
|
||||
|
||||
it("renders a blank-line gap as separate paragraphs", () => {
|
||||
const { container } = render(<ReadonlyContent content={"para one\n\npara two"} />);
|
||||
expect(container.querySelectorAll("p").length).toBeGreaterThanOrEqual(2);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -22,6 +22,7 @@ import ReactMarkdown, {
|
||||
type Components,
|
||||
} from "react-markdown";
|
||||
import rehypeKatex from "rehype-katex";
|
||||
import remarkBreaks from "remark-breaks";
|
||||
import remarkGfm from "remark-gfm";
|
||||
import remarkMath from "remark-math";
|
||||
import rehypeRaw from "rehype-raw";
|
||||
@@ -297,7 +298,7 @@ export function ReadonlyContent({ content, className }: ReadonlyContentProps) {
|
||||
return (
|
||||
<div ref={wrapperRef} className={cn("rich-text-editor readonly text-sm", className)}>
|
||||
<ReactMarkdown
|
||||
remarkPlugins={[remarkMath, [remarkGfm, { singleTilde: false }]]}
|
||||
remarkPlugins={[remarkMath, remarkBreaks, [remarkGfm, { singleTilde: false }]]}
|
||||
rehypePlugins={[rehypeRaw, [rehypeSanitize, sanitizeSchema], rehypeKatex]}
|
||||
urlTransform={urlTransform}
|
||||
components={components}
|
||||
|
||||
95
packages/views/editor/utils/preprocess-links.test.ts
Normal file
95
packages/views/editor/utils/preprocess-links.test.ts
Normal file
@@ -0,0 +1,95 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { preprocessLinks } from "@multica/ui/markdown/linkify";
|
||||
|
||||
// The bug: linkify-it does not treat CJK full-width punctuation as a URL
|
||||
// boundary, so the href can swallow trailing punctuation and the Chinese
|
||||
// characters that follow it (up to the next space). The fix truncates the
|
||||
// detected URL at the first CJK full-width punctuation character.
|
||||
|
||||
describe("preprocessLinks — CJK punctuation boundary", () => {
|
||||
it("stops URL at ideographic full stop 。", () => {
|
||||
const out = preprocessLinks("见 https://example.com/path。然后继续");
|
||||
expect(out).toBe("见 [https://example.com/path](https://example.com/path)。然后继续");
|
||||
});
|
||||
|
||||
it("stops URL at fullwidth comma ,", () => {
|
||||
const out = preprocessLinks("打开 https://example.com/a,以及其他");
|
||||
expect(out).toBe("打开 [https://example.com/a](https://example.com/a),以及其他");
|
||||
});
|
||||
|
||||
it("stops URL at ideographic comma 、", () => {
|
||||
const out = preprocessLinks("两个地址 https://a.com/x、https://b.com/y");
|
||||
expect(out).toBe(
|
||||
"两个地址 [https://a.com/x](https://a.com/x)、[https://b.com/y](https://b.com/y)",
|
||||
);
|
||||
});
|
||||
|
||||
it("stops URL at fullwidth right paren )", () => {
|
||||
const out = preprocessLinks("(见 https://example.com/x)后文");
|
||||
expect(out).toBe("(见 [https://example.com/x](https://example.com/x))后文");
|
||||
});
|
||||
|
||||
it("stops URL at corner bracket 」", () => {
|
||||
const out = preprocessLinks("「https://example.com/a」后文");
|
||||
expect(out).toBe("「[https://example.com/a](https://example.com/a)」后文");
|
||||
});
|
||||
|
||||
it("stops URL at fullwidth exclamation !", () => {
|
||||
const out = preprocessLinks("太好了 https://example.com/x!继续");
|
||||
expect(out).toBe("太好了 [https://example.com/x](https://example.com/x)!继续");
|
||||
});
|
||||
|
||||
it("handles the original bug report (PR link then 。 then more text)", () => {
|
||||
const out = preprocessLinks(
|
||||
"已合并 PR #1623:https://github.com/multica-ai/multica/pull/1623。merge commit",
|
||||
);
|
||||
expect(out).toBe(
|
||||
"已合并 PR #1623:[https://github.com/multica-ai/multica/pull/1623](https://github.com/multica-ai/multica/pull/1623)。merge commit",
|
||||
);
|
||||
});
|
||||
|
||||
it("does not swallow the entire remainder when there is no trailing space", () => {
|
||||
const out = preprocessLinks("https://github.com/x/y/issues/1619。我接下来把这个");
|
||||
expect(out).toBe(
|
||||
"[https://github.com/x/y/issues/1619](https://github.com/x/y/issues/1619)。我接下来把这个",
|
||||
);
|
||||
});
|
||||
|
||||
it("preserves ASCII trailing period handling (no regression)", () => {
|
||||
const out = preprocessLinks("visit https://example.com/path. next.");
|
||||
expect(out).toBe("visit [https://example.com/path](https://example.com/path). next.");
|
||||
});
|
||||
|
||||
it("preserves plain URL with no trailing punctuation (no regression)", () => {
|
||||
const out = preprocessLinks("go https://example.com/path");
|
||||
expect(out).toBe("go [https://example.com/path](https://example.com/path)");
|
||||
});
|
||||
|
||||
it("preserves CJK letters inside URL path (only trims on punctuation)", () => {
|
||||
const out = preprocessLinks("https://zh.wikipedia.org/wiki/中国 参考");
|
||||
expect(out).toBe(
|
||||
"[https://zh.wikipedia.org/wiki/中国](https://zh.wikipedia.org/wiki/中国) 参考",
|
||||
);
|
||||
});
|
||||
|
||||
it("does not re-link an already-linked URL that contains 。", () => {
|
||||
// If a user or upstream already wrote [text](url。), we leave it alone.
|
||||
const input = "见 [link](https://example.com/x。)后文";
|
||||
expect(preprocessLinks(input)).toBe(input);
|
||||
});
|
||||
|
||||
it("does not linkify fuzzy domains inside existing markdown link labels", () => {
|
||||
const input =
|
||||
"数据来源:[NBA.com Schedule](https://www.nba.com/schedule)、[NBC Insider](https://www.nbc.com/nbc-insider/every-nba-playoff-game-this-week-on-nbc-peacock-april-25-28)";
|
||||
|
||||
expect(preprocessLinks(input)).toBe(input);
|
||||
});
|
||||
|
||||
it("still linkifies fuzzy domains outside existing markdown links", () => {
|
||||
const input = "数据来源:[NBA.com Schedule](https://www.nba.com/schedule),官网 NBA.com";
|
||||
|
||||
expect(preprocessLinks(input)).toBe(
|
||||
"数据来源:[NBA.com Schedule](https://www.nba.com/schedule),官网 [NBA.com](http://NBA.com)",
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -20,6 +20,8 @@ const typeLabels: Record<InboxItemType, string> = {
|
||||
agent_blocked: "Agent blocked",
|
||||
agent_completed: "Agent completed",
|
||||
reaction_added: "Reacted",
|
||||
quick_create_done: "Quick create done",
|
||||
quick_create_failed: "Quick create failed",
|
||||
};
|
||||
|
||||
export { typeLabels };
|
||||
|
||||
@@ -5,6 +5,8 @@ import { useDefaultLayout } from "react-resizable-panels";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useWorkspaceId } from "@multica/core/hooks";
|
||||
import { useWorkspacePaths } from "@multica/core/paths";
|
||||
import { useModalStore } from "@multica/core/modals";
|
||||
import { useIssueDraftStore } from "@multica/core/issues/stores/draft-store";
|
||||
import {
|
||||
inboxListOptions,
|
||||
deduplicateInboxItems,
|
||||
@@ -257,7 +259,35 @@ export function InboxPage() {
|
||||
{selected.body}
|
||||
</div>
|
||||
)}
|
||||
<div className="mt-4">
|
||||
{selected.type === "quick_create_failed" && selected.details?.original_prompt && (
|
||||
<div className="mt-4 rounded-md border bg-muted/40 p-3">
|
||||
<p className="text-xs font-medium text-muted-foreground">Original input</p>
|
||||
<p className="mt-1 whitespace-pre-wrap text-sm">{selected.details.original_prompt}</p>
|
||||
</div>
|
||||
)}
|
||||
<div className="mt-4 flex gap-2">
|
||||
{selected.type === "quick_create_failed" && (
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
// Seed the legacy advanced form with the original prompt so the
|
||||
// user can recover their input in the full editor instead of
|
||||
// retyping. The agent picker hint becomes the assignee
|
||||
// candidate (still editable).
|
||||
const prompt = selected.details?.original_prompt ?? "";
|
||||
const agentId = selected.details?.agent_id;
|
||||
useIssueDraftStore.getState().setDraft({
|
||||
description: prompt,
|
||||
...(agentId
|
||||
? { assigneeType: "agent" as const, assigneeId: agentId }
|
||||
: {}),
|
||||
});
|
||||
useModalStore.getState().open("create-issue");
|
||||
}}
|
||||
>
|
||||
Edit as advanced form
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
|
||||
@@ -0,0 +1,190 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { render, screen, fireEvent } from "@testing-library/react";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import type { Issue } from "@multica/core/types";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Mocks — same pattern as the issue-detail test suite.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
vi.mock("@multica/core/hooks", () => ({
|
||||
useWorkspaceId: () => "ws-1",
|
||||
}));
|
||||
|
||||
const mockOpenModal = vi.fn();
|
||||
vi.mock("@multica/core/modals", () => ({
|
||||
useModalStore: Object.assign(
|
||||
(selector?: any) => {
|
||||
const state = { open: mockOpenModal };
|
||||
return selector ? selector(state) : state;
|
||||
},
|
||||
{ getState: () => ({ open: mockOpenModal }) },
|
||||
),
|
||||
}));
|
||||
|
||||
const mockAuthState = { user: { id: "user-1" }, isAuthenticated: true };
|
||||
vi.mock("@multica/core/auth", () => ({
|
||||
useAuthStore: Object.assign(
|
||||
(selector?: any) => (selector ? selector(mockAuthState) : mockAuthState),
|
||||
{ getState: () => mockAuthState },
|
||||
),
|
||||
registerAuthStore: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@multica/core/workspace/queries", () => ({
|
||||
memberListOptions: () => ({
|
||||
queryKey: ["workspaces", "ws-1", "members"],
|
||||
queryFn: () =>
|
||||
Promise.resolve([
|
||||
{ user_id: "user-1", name: "Test User", email: "t@t.com", role: "admin" },
|
||||
]),
|
||||
}),
|
||||
agentListOptions: () => ({
|
||||
queryKey: ["workspaces", "ws-1", "agents"],
|
||||
queryFn: () => Promise.resolve([]),
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("@multica/core/pins", () => ({
|
||||
pinListOptions: () => ({
|
||||
queryKey: ["pins", "ws-1", "user-1"],
|
||||
queryFn: () => Promise.resolve([]),
|
||||
}),
|
||||
useCreatePin: () => ({ mutate: vi.fn() }),
|
||||
useDeletePin: () => ({ mutate: vi.fn() }),
|
||||
}));
|
||||
|
||||
vi.mock("@multica/core/issues/mutations", () => ({
|
||||
useUpdateIssue: () => ({ mutate: vi.fn() }),
|
||||
}));
|
||||
|
||||
vi.mock("@multica/core/paths", async () => {
|
||||
const actual = await vi.importActual<typeof import("@multica/core/paths")>(
|
||||
"@multica/core/paths",
|
||||
);
|
||||
return {
|
||||
...actual,
|
||||
useCurrentWorkspace: () => ({ id: "ws-1", name: "Test", slug: "test" }),
|
||||
useWorkspacePaths: () => actual.paths.workspace("test"),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("../../../navigation", () => ({
|
||||
useNavigation: () => ({
|
||||
push: vi.fn(),
|
||||
pathname: "/test/issues/issue-1",
|
||||
searchParams: new URLSearchParams(),
|
||||
back: vi.fn(),
|
||||
replace: vi.fn(),
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("sonner", () => ({
|
||||
toast: { success: vi.fn(), error: vi.fn() },
|
||||
}));
|
||||
|
||||
vi.mock("../../../common/actor-avatar", () => ({
|
||||
ActorAvatar: ({ actorId }: any) => <span data-testid="actor">{actorId}</span>,
|
||||
}));
|
||||
|
||||
// Import after mocks.
|
||||
import { IssueActionsDropdown } from "../issue-actions-dropdown";
|
||||
import { IssueActionsContextMenu } from "../issue-actions-context-menu";
|
||||
|
||||
const mockIssue: Issue = {
|
||||
id: "issue-1",
|
||||
workspace_id: "ws-1",
|
||||
number: 1,
|
||||
identifier: "TES-1",
|
||||
title: "Example",
|
||||
description: null,
|
||||
status: "todo",
|
||||
priority: "medium",
|
||||
assignee_type: null,
|
||||
assignee_id: null,
|
||||
creator_type: "member",
|
||||
creator_id: "user-1",
|
||||
parent_issue_id: null,
|
||||
due_date: null,
|
||||
project_id: null,
|
||||
created_at: "2026-01-01T00:00:00Z",
|
||||
updated_at: "2026-01-01T00:00:00Z",
|
||||
} as Issue;
|
||||
|
||||
function wrap(ui: React.ReactNode) {
|
||||
const qc = new QueryClient({
|
||||
defaultOptions: { queries: { retry: false } },
|
||||
});
|
||||
return <QueryClientProvider client={qc}>{ui}</QueryClientProvider>;
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
mockOpenModal.mockReset();
|
||||
});
|
||||
|
||||
describe("IssueActionsDropdown", () => {
|
||||
it("renders the top-level items when the trigger is clicked", async () => {
|
||||
render(
|
||||
wrap(
|
||||
<IssueActionsDropdown
|
||||
issue={mockIssue}
|
||||
trigger={<button data-testid="trigger">Menu</button>}
|
||||
/>,
|
||||
),
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByTestId("trigger"));
|
||||
|
||||
// Base UI portals the popup; role=menu lands on the popup wrapper.
|
||||
expect(await screen.findByText("Status")).toBeInTheDocument();
|
||||
expect(screen.getByText("Priority")).toBeInTheDocument();
|
||||
expect(screen.getByText("Assignee")).toBeInTheDocument();
|
||||
expect(screen.getByText("Due date")).toBeInTheDocument();
|
||||
expect(screen.getByText("Copy link")).toBeInTheDocument();
|
||||
expect(screen.getByText("More")).toBeInTheDocument();
|
||||
expect(screen.getByText("Delete issue")).toBeInTheDocument();
|
||||
// Relationship actions are hidden inside the "More" submenu by default.
|
||||
expect(screen.queryByText("Create sub-issue")).not.toBeInTheDocument();
|
||||
expect(screen.queryByText("Set parent issue...")).not.toBeInTheDocument();
|
||||
expect(screen.queryByText("Add sub-issue...")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("clicking Delete issue opens the delete-confirm modal", async () => {
|
||||
render(
|
||||
wrap(
|
||||
<IssueActionsDropdown
|
||||
issue={mockIssue}
|
||||
trigger={<button data-testid="trigger">Menu</button>}
|
||||
onDeletedNavigateTo="/test/issues"
|
||||
/>,
|
||||
),
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByTestId("trigger"));
|
||||
const del = await screen.findByText("Delete issue");
|
||||
fireEvent.click(del);
|
||||
|
||||
expect(mockOpenModal).toHaveBeenCalledWith("issue-delete-confirm", {
|
||||
issueId: "issue-1",
|
||||
identifier: "TES-1",
|
||||
onDeletedNavigateTo: "/test/issues",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("IssueActionsContextMenu", () => {
|
||||
it("renders the menu when the wrapped element receives a contextmenu event", async () => {
|
||||
render(
|
||||
wrap(
|
||||
<IssueActionsContextMenu issue={mockIssue}>
|
||||
<div data-testid="row">Row</div>
|
||||
</IssueActionsContextMenu>,
|
||||
),
|
||||
);
|
||||
|
||||
fireEvent.contextMenu(screen.getByTestId("row"));
|
||||
|
||||
expect(await screen.findByText("Status")).toBeInTheDocument();
|
||||
expect(screen.getByText("Delete issue")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,278 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { renderHook, act, waitFor } from "@testing-library/react";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import type { Issue } from "@multica/core/types";
|
||||
|
||||
vi.mock("@multica/core/hooks", () => ({
|
||||
useWorkspaceId: () => "ws-1",
|
||||
}));
|
||||
|
||||
const mockOpenModal = vi.fn();
|
||||
vi.mock("@multica/core/modals", () => ({
|
||||
useModalStore: Object.assign(
|
||||
(selector?: any) => {
|
||||
const state = { open: mockOpenModal };
|
||||
return selector ? selector(state) : state;
|
||||
},
|
||||
{ getState: () => ({ open: mockOpenModal }) },
|
||||
),
|
||||
}));
|
||||
|
||||
const mockAuthState = { user: { id: "user-1" }, isAuthenticated: true };
|
||||
vi.mock("@multica/core/auth", () => ({
|
||||
useAuthStore: Object.assign(
|
||||
(selector?: any) => (selector ? selector(mockAuthState) : mockAuthState),
|
||||
{ getState: () => mockAuthState },
|
||||
),
|
||||
registerAuthStore: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@multica/core/workspace/queries", () => ({
|
||||
memberListOptions: () => ({
|
||||
queryKey: ["workspaces", "ws-1", "members"],
|
||||
queryFn: () =>
|
||||
Promise.resolve([
|
||||
{ user_id: "user-1", name: "Test User", email: "t@t.com", role: "admin" },
|
||||
]),
|
||||
}),
|
||||
agentListOptions: () => ({
|
||||
queryKey: ["workspaces", "ws-1", "agents"],
|
||||
queryFn: () => Promise.resolve([]),
|
||||
}),
|
||||
}));
|
||||
|
||||
// Mutable so individual tests can seed the pin list.
|
||||
const pinListRef: { value: Array<{ item_type: string; item_id: string }> } = {
|
||||
value: [],
|
||||
};
|
||||
const mockCreatePinMutate = vi.fn();
|
||||
const mockDeletePinMutate = vi.fn();
|
||||
vi.mock("@multica/core/pins", () => ({
|
||||
pinListOptions: () => ({
|
||||
queryKey: ["pins", "ws-1", "user-1"],
|
||||
queryFn: () => Promise.resolve(pinListRef.value),
|
||||
}),
|
||||
useCreatePin: () => ({ mutate: mockCreatePinMutate }),
|
||||
useDeletePin: () => ({ mutate: mockDeletePinMutate }),
|
||||
}));
|
||||
|
||||
const mockUpdateMutate = vi.fn();
|
||||
vi.mock("@multica/core/issues/mutations", () => ({
|
||||
useUpdateIssue: () => ({ mutate: mockUpdateMutate }),
|
||||
}));
|
||||
|
||||
vi.mock("@multica/core/paths", async () => {
|
||||
const actual = await vi.importActual<typeof import("@multica/core/paths")>(
|
||||
"@multica/core/paths",
|
||||
);
|
||||
return {
|
||||
...actual,
|
||||
useCurrentWorkspace: () => ({ id: "ws-1", name: "Test", slug: "test" }),
|
||||
useWorkspacePaths: () => actual.paths.workspace("test"),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("../../../navigation", () => ({
|
||||
useNavigation: () => ({
|
||||
push: vi.fn(),
|
||||
pathname: "/test/issues/issue-1",
|
||||
searchParams: new URLSearchParams(),
|
||||
back: vi.fn(),
|
||||
replace: vi.fn(),
|
||||
getShareableUrl: (p: string) => `https://app.multica.com${p}`,
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("sonner", () => ({
|
||||
toast: { success: vi.fn(), error: vi.fn() },
|
||||
}));
|
||||
|
||||
// Import AFTER mocks are registered.
|
||||
import { useIssueActions } from "../use-issue-actions";
|
||||
|
||||
const mockIssue: Issue = {
|
||||
id: "issue-1",
|
||||
workspace_id: "ws-1",
|
||||
number: 1,
|
||||
identifier: "TES-1",
|
||||
title: "Example",
|
||||
description: null,
|
||||
status: "todo",
|
||||
priority: "medium",
|
||||
assignee_type: null,
|
||||
assignee_id: null,
|
||||
creator_type: "member",
|
||||
creator_id: "user-1",
|
||||
parent_issue_id: null,
|
||||
due_date: null,
|
||||
project_id: null,
|
||||
created_at: "2026-01-01T00:00:00Z",
|
||||
updated_at: "2026-01-01T00:00:00Z",
|
||||
} as Issue;
|
||||
|
||||
function wrapper({ children }: { children: React.ReactNode }) {
|
||||
const qc = new QueryClient({
|
||||
defaultOptions: { queries: { retry: false } },
|
||||
});
|
||||
return <QueryClientProvider client={qc}>{children}</QueryClientProvider>;
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
mockOpenModal.mockReset();
|
||||
mockUpdateMutate.mockReset();
|
||||
mockCreatePinMutate.mockReset();
|
||||
mockDeletePinMutate.mockReset();
|
||||
pinListRef.value = [];
|
||||
localStorage.clear();
|
||||
Object.defineProperty(navigator, "clipboard", {
|
||||
configurable: true,
|
||||
value: { writeText: vi.fn().mockResolvedValue(undefined) },
|
||||
});
|
||||
});
|
||||
|
||||
describe("useIssueActions", () => {
|
||||
it("updateField dispatches useUpdateIssue.mutate with the correct payload", () => {
|
||||
const { result } = renderHook(() => useIssueActions(mockIssue), { wrapper });
|
||||
|
||||
act(() => {
|
||||
result.current.updateField({ status: "done" });
|
||||
});
|
||||
|
||||
expect(mockUpdateMutate).toHaveBeenCalledWith(
|
||||
{ id: "issue-1", status: "done" },
|
||||
expect.any(Object),
|
||||
);
|
||||
});
|
||||
|
||||
it("assigning an agent to a backlog issue opens the backlog-hint modal", () => {
|
||||
const backlogIssue = { ...mockIssue, status: "backlog" } as Issue;
|
||||
const { result } = renderHook(() => useIssueActions(backlogIssue), { wrapper });
|
||||
|
||||
act(() => {
|
||||
result.current.updateField({
|
||||
assignee_type: "agent",
|
||||
assignee_id: "agent-1",
|
||||
});
|
||||
});
|
||||
|
||||
expect(mockOpenModal).toHaveBeenCalledWith("issue-backlog-agent-hint", {
|
||||
issueId: "issue-1",
|
||||
});
|
||||
});
|
||||
|
||||
it("does not re-open backlog-hint when the user has dismissed it", () => {
|
||||
localStorage.setItem("multica:backlog-agent-hint-dismissed", "true");
|
||||
const backlogIssue = { ...mockIssue, status: "backlog" } as Issue;
|
||||
const { result } = renderHook(() => useIssueActions(backlogIssue), { wrapper });
|
||||
|
||||
act(() => {
|
||||
result.current.updateField({
|
||||
assignee_type: "agent",
|
||||
assignee_id: "agent-1",
|
||||
});
|
||||
});
|
||||
|
||||
expect(mockOpenModal).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("copyLink writes the issue's shareable URL to the clipboard", async () => {
|
||||
const { result } = renderHook(() => useIssueActions(mockIssue), { wrapper });
|
||||
|
||||
await act(async () => {
|
||||
await result.current.copyLink();
|
||||
});
|
||||
|
||||
expect(navigator.clipboard.writeText).toHaveBeenCalledWith(
|
||||
"https://app.multica.com/test/issues/issue-1",
|
||||
);
|
||||
});
|
||||
|
||||
it("openSetParent / openAddChild / openDeleteConfirm / openCreateSubIssue open the correct modal with payload", () => {
|
||||
const { result } = renderHook(() => useIssueActions(mockIssue), { wrapper });
|
||||
|
||||
act(() => {
|
||||
result.current.openSetParent();
|
||||
});
|
||||
expect(mockOpenModal).toHaveBeenLastCalledWith("issue-set-parent", {
|
||||
issueId: "issue-1",
|
||||
});
|
||||
|
||||
act(() => {
|
||||
result.current.openAddChild();
|
||||
});
|
||||
expect(mockOpenModal).toHaveBeenLastCalledWith("issue-add-child", {
|
||||
issueId: "issue-1",
|
||||
});
|
||||
|
||||
act(() => {
|
||||
result.current.openCreateSubIssue();
|
||||
});
|
||||
expect(mockOpenModal).toHaveBeenLastCalledWith("create-issue", {
|
||||
parent_issue_id: "issue-1",
|
||||
parent_issue_identifier: "TES-1",
|
||||
});
|
||||
|
||||
act(() => {
|
||||
result.current.openDeleteConfirm({ onDeletedNavigateTo: "/test/issues" });
|
||||
});
|
||||
expect(mockOpenModal).toHaveBeenLastCalledWith("issue-delete-confirm", {
|
||||
issueId: "issue-1",
|
||||
identifier: "TES-1",
|
||||
onDeletedNavigateTo: "/test/issues",
|
||||
});
|
||||
});
|
||||
|
||||
it("togglePin calls createPin when not pinned and deletePin when pinned", async () => {
|
||||
pinListRef.value = [];
|
||||
const { result: r1 } = renderHook(() => useIssueActions(mockIssue), { wrapper });
|
||||
await waitFor(() => {
|
||||
expect(r1.current.isPinned).toBe(false);
|
||||
});
|
||||
act(() => {
|
||||
r1.current.togglePin();
|
||||
});
|
||||
expect(mockCreatePinMutate).toHaveBeenCalledWith({
|
||||
item_type: "issue",
|
||||
item_id: "issue-1",
|
||||
});
|
||||
expect(mockDeletePinMutate).not.toHaveBeenCalled();
|
||||
|
||||
mockCreatePinMutate.mockReset();
|
||||
mockDeletePinMutate.mockReset();
|
||||
pinListRef.value = [{ item_type: "issue", item_id: "issue-1" }];
|
||||
const { result: r2 } = renderHook(() => useIssueActions(mockIssue), { wrapper });
|
||||
await waitFor(() => {
|
||||
expect(r2.current.isPinned).toBe(true);
|
||||
});
|
||||
act(() => {
|
||||
r2.current.togglePin();
|
||||
});
|
||||
expect(mockDeletePinMutate).toHaveBeenCalledWith({
|
||||
itemType: "issue",
|
||||
itemId: "issue-1",
|
||||
});
|
||||
expect(mockCreatePinMutate).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("is a safe no-op when issue is null", () => {
|
||||
const { result } = renderHook(() => useIssueActions(null), { wrapper });
|
||||
|
||||
act(() => {
|
||||
result.current.updateField({ status: "done" });
|
||||
result.current.togglePin();
|
||||
result.current.openSetParent();
|
||||
});
|
||||
|
||||
expect(mockUpdateMutate).not.toHaveBeenCalled();
|
||||
expect(mockOpenModal).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("members and filtered agents are exposed on the result", async () => {
|
||||
const { result } = renderHook(() => useIssueActions(mockIssue), { wrapper });
|
||||
await waitFor(() => {
|
||||
expect(result.current.members.length).toBe(1);
|
||||
});
|
||||
expect(result.current.members[0]!.user_id).toBe("user-1");
|
||||
expect(result.current.agents).toEqual([]);
|
||||
});
|
||||
});
|
||||
4
packages/views/issues/actions/index.ts
Normal file
4
packages/views/issues/actions/index.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
export { useIssueActions } from "./use-issue-actions";
|
||||
export type { UseIssueActionsResult } from "./use-issue-actions";
|
||||
export { IssueActionsDropdown } from "./issue-actions-dropdown";
|
||||
export { IssueActionsContextMenu } from "./issue-actions-context-menu";
|
||||
39
packages/views/issues/actions/issue-actions-context-menu.tsx
Normal file
39
packages/views/issues/actions/issue-actions-context-menu.tsx
Normal file
@@ -0,0 +1,39 @@
|
||||
"use client";
|
||||
|
||||
import type { ReactElement } from "react";
|
||||
import type { Issue } from "@multica/core/types";
|
||||
import {
|
||||
ContextMenu,
|
||||
ContextMenuTrigger,
|
||||
ContextMenuContent,
|
||||
} from "@multica/ui/components/ui/context-menu";
|
||||
import { useIssueActions } from "./use-issue-actions";
|
||||
import {
|
||||
IssueActionsMenuItems,
|
||||
contextPrimitives,
|
||||
} from "./issue-actions-menu-items";
|
||||
|
||||
interface IssueActionsContextMenuProps {
|
||||
issue: Issue;
|
||||
/** A single React element cloned by Base UI as the trigger (via `render` prop). */
|
||||
children: ReactElement;
|
||||
}
|
||||
|
||||
export function IssueActionsContextMenu({
|
||||
issue,
|
||||
children,
|
||||
}: IssueActionsContextMenuProps) {
|
||||
const actions = useIssueActions(issue);
|
||||
return (
|
||||
<ContextMenu>
|
||||
<ContextMenuTrigger render={children} />
|
||||
<ContextMenuContent>
|
||||
<IssueActionsMenuItems
|
||||
issue={issue}
|
||||
actions={actions}
|
||||
primitives={contextPrimitives}
|
||||
/>
|
||||
</ContextMenuContent>
|
||||
</ContextMenu>
|
||||
);
|
||||
}
|
||||
45
packages/views/issues/actions/issue-actions-dropdown.tsx
Normal file
45
packages/views/issues/actions/issue-actions-dropdown.tsx
Normal file
@@ -0,0 +1,45 @@
|
||||
"use client";
|
||||
|
||||
import type { ReactElement } from "react";
|
||||
import type { Issue } from "@multica/core/types";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuTrigger,
|
||||
DropdownMenuContent,
|
||||
} from "@multica/ui/components/ui/dropdown-menu";
|
||||
import { useIssueActions } from "./use-issue-actions";
|
||||
import {
|
||||
IssueActionsMenuItems,
|
||||
dropdownPrimitives,
|
||||
} from "./issue-actions-menu-items";
|
||||
|
||||
interface IssueActionsDropdownProps {
|
||||
issue: Issue;
|
||||
/** A single React element cloned by Base UI as the trigger (via `render` prop). */
|
||||
trigger: ReactElement;
|
||||
align?: "start" | "end" | "center";
|
||||
/** If set, navigate here after the issue is deleted. */
|
||||
onDeletedNavigateTo?: string;
|
||||
}
|
||||
|
||||
export function IssueActionsDropdown({
|
||||
issue,
|
||||
trigger,
|
||||
align = "end",
|
||||
onDeletedNavigateTo,
|
||||
}: IssueActionsDropdownProps) {
|
||||
const actions = useIssueActions(issue);
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger render={trigger} />
|
||||
<DropdownMenuContent align={align} className="w-auto">
|
||||
<IssueActionsMenuItems
|
||||
issue={issue}
|
||||
actions={actions}
|
||||
primitives={dropdownPrimitives}
|
||||
onDeletedNavigateTo={onDeletedNavigateTo}
|
||||
/>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
);
|
||||
}
|
||||
277
packages/views/issues/actions/issue-actions-menu-items.tsx
Normal file
277
packages/views/issues/actions/issue-actions-menu-items.tsx
Normal file
@@ -0,0 +1,277 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
ArrowDown,
|
||||
ArrowUp,
|
||||
Calendar,
|
||||
Link2,
|
||||
MoreHorizontal,
|
||||
Pin,
|
||||
PinOff,
|
||||
Plus,
|
||||
Trash2,
|
||||
UserMinus,
|
||||
} from "lucide-react";
|
||||
import type { Issue } from "@multica/core/types";
|
||||
import {
|
||||
ALL_STATUSES,
|
||||
STATUS_CONFIG,
|
||||
PRIORITY_ORDER,
|
||||
PRIORITY_CONFIG,
|
||||
} from "@multica/core/issues/config";
|
||||
import { StatusIcon } from "../components/status-icon";
|
||||
import { PriorityIcon } from "../components/priority-icon";
|
||||
import { ActorAvatar } from "../../common/actor-avatar";
|
||||
import {
|
||||
DropdownMenuItem,
|
||||
DropdownMenuSub,
|
||||
DropdownMenuSubTrigger,
|
||||
DropdownMenuSubContent,
|
||||
DropdownMenuSeparator,
|
||||
} from "@multica/ui/components/ui/dropdown-menu";
|
||||
import {
|
||||
ContextMenuItem,
|
||||
ContextMenuSub,
|
||||
ContextMenuSubTrigger,
|
||||
ContextMenuSubContent,
|
||||
ContextMenuSeparator,
|
||||
} from "@multica/ui/components/ui/context-menu";
|
||||
import type { UseIssueActionsResult } from "./use-issue-actions";
|
||||
|
||||
// Both Dropdown and Context menu wrappers expose an API-compatible surface
|
||||
// (variant, inset, onClick, etc.). We bundle the primitives we need into a
|
||||
// single object so `IssueActionsMenuItems` can render the same JSX for both.
|
||||
export interface MenuPrimitives {
|
||||
Item: typeof DropdownMenuItem;
|
||||
Sub: typeof DropdownMenuSub;
|
||||
SubTrigger: typeof DropdownMenuSubTrigger;
|
||||
SubContent: typeof DropdownMenuSubContent;
|
||||
Separator: typeof DropdownMenuSeparator;
|
||||
}
|
||||
|
||||
export const dropdownPrimitives: MenuPrimitives = {
|
||||
Item: DropdownMenuItem,
|
||||
Sub: DropdownMenuSub,
|
||||
SubTrigger: DropdownMenuSubTrigger,
|
||||
SubContent: DropdownMenuSubContent,
|
||||
Separator: DropdownMenuSeparator,
|
||||
};
|
||||
|
||||
// Context primitives are API-compatible with Dropdown primitives, but their
|
||||
// TypeScript identities differ. Cast once here and call it a day — this is the
|
||||
// single bridge between the two primitive sets.
|
||||
export const contextPrimitives: MenuPrimitives = {
|
||||
Item: ContextMenuItem as unknown as typeof DropdownMenuItem,
|
||||
Sub: ContextMenuSub as unknown as typeof DropdownMenuSub,
|
||||
SubTrigger: ContextMenuSubTrigger as unknown as typeof DropdownMenuSubTrigger,
|
||||
SubContent: ContextMenuSubContent as unknown as typeof DropdownMenuSubContent,
|
||||
Separator: ContextMenuSeparator as unknown as typeof DropdownMenuSeparator,
|
||||
};
|
||||
|
||||
interface IssueActionsMenuItemsProps {
|
||||
issue: Issue;
|
||||
actions: UseIssueActionsResult;
|
||||
primitives: MenuPrimitives;
|
||||
/** If set, navigate here after the issue is deleted (used by the detail page). */
|
||||
onDeletedNavigateTo?: string;
|
||||
}
|
||||
|
||||
export function IssueActionsMenuItems({
|
||||
issue,
|
||||
actions,
|
||||
primitives: P,
|
||||
onDeletedNavigateTo,
|
||||
}: IssueActionsMenuItemsProps) {
|
||||
const {
|
||||
members,
|
||||
agents,
|
||||
isPinned,
|
||||
updateField,
|
||||
togglePin,
|
||||
copyLink,
|
||||
openCreateSubIssue,
|
||||
openSetParent,
|
||||
openAddChild,
|
||||
openDeleteConfirm,
|
||||
} = actions;
|
||||
|
||||
const now = () => new Date();
|
||||
const inDays = (days: number) => {
|
||||
const d = new Date();
|
||||
d.setDate(d.getDate() + days);
|
||||
return d.toISOString();
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Status */}
|
||||
<P.Sub>
|
||||
<P.SubTrigger>
|
||||
<StatusIcon status={issue.status} className="h-3.5 w-3.5" />
|
||||
Status
|
||||
</P.SubTrigger>
|
||||
<P.SubContent>
|
||||
{ALL_STATUSES.map((s) => (
|
||||
<P.Item key={s} onClick={() => updateField({ status: s })}>
|
||||
<StatusIcon status={s} className="h-3.5 w-3.5" />
|
||||
{STATUS_CONFIG[s].label}
|
||||
{issue.status === s && (
|
||||
<span className="ml-auto text-xs text-muted-foreground">✓</span>
|
||||
)}
|
||||
</P.Item>
|
||||
))}
|
||||
</P.SubContent>
|
||||
</P.Sub>
|
||||
|
||||
{/* Priority */}
|
||||
<P.Sub>
|
||||
<P.SubTrigger>
|
||||
<PriorityIcon priority={issue.priority} />
|
||||
Priority
|
||||
</P.SubTrigger>
|
||||
<P.SubContent>
|
||||
{PRIORITY_ORDER.map((p) => (
|
||||
<P.Item key={p} onClick={() => updateField({ priority: p })}>
|
||||
<span
|
||||
className={`inline-flex items-center gap-1 rounded px-1.5 py-0.5 text-xs font-medium ${PRIORITY_CONFIG[p].badgeBg} ${PRIORITY_CONFIG[p].badgeText}`}
|
||||
>
|
||||
<PriorityIcon priority={p} className="h-3 w-3" inheritColor />
|
||||
{PRIORITY_CONFIG[p].label}
|
||||
</span>
|
||||
{issue.priority === p && (
|
||||
<span className="ml-auto text-xs text-muted-foreground">✓</span>
|
||||
)}
|
||||
</P.Item>
|
||||
))}
|
||||
</P.SubContent>
|
||||
</P.Sub>
|
||||
|
||||
{/* Assignee */}
|
||||
<P.Sub>
|
||||
<P.SubTrigger>
|
||||
<UserMinus className="h-3.5 w-3.5" />
|
||||
Assignee
|
||||
</P.SubTrigger>
|
||||
<P.SubContent>
|
||||
<P.Item
|
||||
onClick={() =>
|
||||
updateField({ assignee_type: null, assignee_id: null })
|
||||
}
|
||||
>
|
||||
<UserMinus className="h-3.5 w-3.5 text-muted-foreground" />
|
||||
Unassigned
|
||||
{!issue.assignee_type && (
|
||||
<span className="ml-auto text-xs text-muted-foreground">✓</span>
|
||||
)}
|
||||
</P.Item>
|
||||
{members.map((m) => (
|
||||
<P.Item
|
||||
key={m.user_id}
|
||||
onClick={() =>
|
||||
updateField({ assignee_type: "member", assignee_id: m.user_id })
|
||||
}
|
||||
>
|
||||
<ActorAvatar actorType="member" actorId={m.user_id} size={16} />
|
||||
{m.name}
|
||||
{issue.assignee_type === "member" &&
|
||||
issue.assignee_id === m.user_id && (
|
||||
<span className="ml-auto text-xs text-muted-foreground">✓</span>
|
||||
)}
|
||||
</P.Item>
|
||||
))}
|
||||
{agents.map((a) => (
|
||||
<P.Item
|
||||
key={a.id}
|
||||
onClick={() =>
|
||||
updateField({ assignee_type: "agent", assignee_id: a.id })
|
||||
}
|
||||
>
|
||||
<ActorAvatar actorType="agent" actorId={a.id} size={16} />
|
||||
{a.name}
|
||||
{issue.assignee_type === "agent" && issue.assignee_id === a.id && (
|
||||
<span className="ml-auto text-xs text-muted-foreground">✓</span>
|
||||
)}
|
||||
</P.Item>
|
||||
))}
|
||||
</P.SubContent>
|
||||
</P.Sub>
|
||||
|
||||
{/* Due date */}
|
||||
<P.Sub>
|
||||
<P.SubTrigger>
|
||||
<Calendar className="h-3.5 w-3.5" />
|
||||
Due date
|
||||
</P.SubTrigger>
|
||||
<P.SubContent>
|
||||
<P.Item onClick={() => updateField({ due_date: now().toISOString() })}>
|
||||
Today
|
||||
</P.Item>
|
||||
<P.Item onClick={() => updateField({ due_date: inDays(1) })}>
|
||||
Tomorrow
|
||||
</P.Item>
|
||||
<P.Item onClick={() => updateField({ due_date: inDays(7) })}>
|
||||
Next week
|
||||
</P.Item>
|
||||
{issue.due_date && (
|
||||
<>
|
||||
<P.Separator />
|
||||
<P.Item onClick={() => updateField({ due_date: null })}>
|
||||
Clear date
|
||||
</P.Item>
|
||||
</>
|
||||
)}
|
||||
</P.SubContent>
|
||||
</P.Sub>
|
||||
|
||||
<P.Separator />
|
||||
|
||||
<P.Item onClick={togglePin}>
|
||||
{isPinned ? (
|
||||
<PinOff className="h-3.5 w-3.5" />
|
||||
) : (
|
||||
<Pin className="h-3.5 w-3.5" />
|
||||
)}
|
||||
{isPinned ? "Unpin from sidebar" : "Pin to sidebar"}
|
||||
</P.Item>
|
||||
<P.Item onClick={copyLink}>
|
||||
<Link2 className="h-3.5 w-3.5" />
|
||||
Copy link
|
||||
</P.Item>
|
||||
|
||||
<P.Separator />
|
||||
|
||||
{/* Relationship actions live under "More" — they're lower-frequency and
|
||||
will grow (blocks, duplicates, related) as we add more relation types. */}
|
||||
<P.Sub>
|
||||
<P.SubTrigger>
|
||||
<MoreHorizontal className="h-3.5 w-3.5" />
|
||||
More
|
||||
</P.SubTrigger>
|
||||
<P.SubContent>
|
||||
<P.Item onClick={openCreateSubIssue}>
|
||||
<Plus className="h-3.5 w-3.5" />
|
||||
Create sub-issue
|
||||
</P.Item>
|
||||
<P.Item onClick={openSetParent}>
|
||||
<ArrowUp className="h-3.5 w-3.5" />
|
||||
Set parent issue...
|
||||
</P.Item>
|
||||
<P.Item onClick={openAddChild}>
|
||||
<ArrowDown className="h-3.5 w-3.5" />
|
||||
Add sub-issue...
|
||||
</P.Item>
|
||||
</P.SubContent>
|
||||
</P.Sub>
|
||||
|
||||
<P.Separator />
|
||||
|
||||
<P.Item
|
||||
variant="destructive"
|
||||
onClick={() => openDeleteConfirm({ onDeletedNavigateTo })}
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
Delete issue
|
||||
</P.Item>
|
||||
</>
|
||||
);
|
||||
}
|
||||
176
packages/views/issues/actions/use-issue-actions.ts
Normal file
176
packages/views/issues/actions/use-issue-actions.ts
Normal file
@@ -0,0 +1,176 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useMemo } from "react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { toast } from "sonner";
|
||||
import type {
|
||||
Issue,
|
||||
MemberWithUser,
|
||||
Agent,
|
||||
UpdateIssueRequest,
|
||||
} from "@multica/core/types";
|
||||
import { useAuthStore } from "@multica/core/auth";
|
||||
import { useWorkspaceId } from "@multica/core/hooks";
|
||||
import { useWorkspacePaths } from "@multica/core/paths";
|
||||
import { useModalStore } from "@multica/core/modals";
|
||||
import { useUpdateIssue } from "@multica/core/issues/mutations";
|
||||
import {
|
||||
memberListOptions,
|
||||
agentListOptions,
|
||||
} from "@multica/core/workspace/queries";
|
||||
import { pinListOptions, useCreatePin, useDeletePin } from "@multica/core/pins";
|
||||
import { canAssignAgent } from "../components/pickers";
|
||||
import { useNavigation } from "../../navigation";
|
||||
|
||||
const BACKLOG_HINT_LS_KEY = "multica:backlog-agent-hint-dismissed";
|
||||
|
||||
export interface UseIssueActionsResult {
|
||||
// Derived data for rendering menu rows
|
||||
members: MemberWithUser[];
|
||||
agents: Agent[];
|
||||
isPinned: boolean;
|
||||
// Handlers
|
||||
updateField: (updates: Partial<UpdateIssueRequest>) => void;
|
||||
togglePin: () => void;
|
||||
copyLink: () => Promise<void>;
|
||||
openCreateSubIssue: () => void;
|
||||
openSetParent: () => void;
|
||||
openAddChild: () => void;
|
||||
openDeleteConfirm: (opts?: { onDeletedNavigateTo?: string }) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Accepts a nullable issue so callers can invoke the hook before they've
|
||||
* early-returned on a missing issue. Returned handlers are safe no-ops when
|
||||
* `issue` is null.
|
||||
*/
|
||||
export function useIssueActions(issue: Issue | null): UseIssueActionsResult {
|
||||
const wsId = useWorkspaceId();
|
||||
const paths = useWorkspacePaths();
|
||||
const navigation = useNavigation();
|
||||
const user = useAuthStore((s) => s.user);
|
||||
const userId = user?.id;
|
||||
|
||||
const { data: members = [] } = useQuery(memberListOptions(wsId));
|
||||
const { data: agents = [] } = useQuery(agentListOptions(wsId));
|
||||
const { data: pinnedItems = [] } = useQuery({
|
||||
...pinListOptions(wsId, userId ?? ""),
|
||||
enabled: !!userId,
|
||||
});
|
||||
|
||||
const currentMemberRole = useMemo(
|
||||
() => members.find((m) => m.user_id === userId)?.role,
|
||||
[members, userId],
|
||||
);
|
||||
const filteredAgents = useMemo(
|
||||
() =>
|
||||
agents.filter(
|
||||
(a) => !a.archived_at && canAssignAgent(a, userId, currentMemberRole),
|
||||
),
|
||||
[agents, userId, currentMemberRole],
|
||||
);
|
||||
const isPinned =
|
||||
!!issue &&
|
||||
pinnedItems.some(
|
||||
(p) => p.item_type === "issue" && p.item_id === issue.id,
|
||||
);
|
||||
|
||||
const updateIssue = useUpdateIssue();
|
||||
const createPin = useCreatePin();
|
||||
const deletePin = useDeletePin();
|
||||
const openModal = useModalStore((s) => s.open);
|
||||
|
||||
const issueId = issue?.id ?? null;
|
||||
const issueStatus = issue?.status ?? null;
|
||||
const issueIdentifier = issue?.identifier ?? null;
|
||||
|
||||
const updateField = useCallback(
|
||||
(updates: Partial<UpdateIssueRequest>) => {
|
||||
if (!issueId) return;
|
||||
updateIssue.mutate(
|
||||
{ id: issueId, ...updates },
|
||||
{ onError: () => toast.error("Failed to update issue") },
|
||||
);
|
||||
// Hint: assigning an agent to a backlog issue won't trigger execution
|
||||
// until the issue is moved to an active status.
|
||||
if (
|
||||
updates.assignee_type === "agent" &&
|
||||
updates.assignee_id &&
|
||||
issueStatus === "backlog" &&
|
||||
typeof window !== "undefined" &&
|
||||
localStorage.getItem(BACKLOG_HINT_LS_KEY) !== "true"
|
||||
) {
|
||||
openModal("issue-backlog-agent-hint", { issueId });
|
||||
}
|
||||
},
|
||||
[issueId, issueStatus, updateIssue, openModal],
|
||||
);
|
||||
|
||||
const togglePin = useCallback(() => {
|
||||
if (!issueId) return;
|
||||
if (isPinned) {
|
||||
deletePin.mutate({ itemType: "issue", itemId: issueId });
|
||||
} else {
|
||||
createPin.mutate({ item_type: "issue", item_id: issueId });
|
||||
}
|
||||
}, [isPinned, issueId, createPin, deletePin]);
|
||||
|
||||
const copyLink = useCallback(async () => {
|
||||
if (!issueId) return;
|
||||
const path = paths.issueDetail(issueId);
|
||||
const url = navigation.getShareableUrl
|
||||
? navigation.getShareableUrl(path)
|
||||
: typeof window !== "undefined"
|
||||
? window.location.origin + path
|
||||
: path;
|
||||
try {
|
||||
await navigator.clipboard.writeText(url);
|
||||
toast.success("Link copied");
|
||||
} catch {
|
||||
toast.error("Failed to copy link");
|
||||
}
|
||||
}, [paths, issueId, navigation]);
|
||||
|
||||
const openCreateSubIssue = useCallback(() => {
|
||||
if (!issueId) return;
|
||||
openModal("create-issue", {
|
||||
parent_issue_id: issueId,
|
||||
parent_issue_identifier: issueIdentifier,
|
||||
});
|
||||
}, [openModal, issueId, issueIdentifier]);
|
||||
|
||||
const openSetParent = useCallback(() => {
|
||||
if (!issueId) return;
|
||||
openModal("issue-set-parent", { issueId });
|
||||
}, [openModal, issueId]);
|
||||
|
||||
const openAddChild = useCallback(() => {
|
||||
if (!issueId) return;
|
||||
openModal("issue-add-child", { issueId });
|
||||
}, [openModal, issueId]);
|
||||
|
||||
const openDeleteConfirm = useCallback(
|
||||
(opts?: { onDeletedNavigateTo?: string }) => {
|
||||
if (!issueId) return;
|
||||
openModal("issue-delete-confirm", {
|
||||
issueId,
|
||||
identifier: issueIdentifier,
|
||||
onDeletedNavigateTo: opts?.onDeletedNavigateTo,
|
||||
});
|
||||
},
|
||||
[openModal, issueId, issueIdentifier],
|
||||
);
|
||||
|
||||
return {
|
||||
members,
|
||||
agents: filteredAgents,
|
||||
isPinned,
|
||||
updateField,
|
||||
togglePin,
|
||||
copyLink,
|
||||
openCreateSubIssue,
|
||||
openSetParent,
|
||||
openAddChild,
|
||||
openDeleteConfirm,
|
||||
};
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user