Compare commits

...

196 Commits

Author SHA1 Message Date
yushen
fdf44a453b Merge remote-tracking branch 'origin/main' into feat/cloud-billing-proxy 2026-05-28 15:58:36 +08:00
Jiang Bohan
f26bdffaf2 fix(provider-logo): handle Next.js vs vite PNG import shape divergence
Next.js types PNG imports as StaticImageData ({ src, width, height });
vite/electron-vite types them as plain string. Component is consumed by
both apps/web (Next.js) and apps/desktop (electron-vite), so a single
type can't satisfy both — last CI failed apps/web typecheck.

Normalise via unknown at the import site so neither side's narrower
type causes the other side's branch to collapse to never. assets.d.ts
declares the union; the component derives a plain-string src once at
module load.
2026-05-28 15:58:28 +08:00
Bohan Jiang
1195255e43 MUL-2771: feat(transcript): server-derived relative work_dir chip (#3428)
* MUL-2771: feat(transcript): server-derived relative work_dir chip

Adds a privacy-safe `relative_work_dir` field to the agent task wire
shape so the transcript dialog can show where a task ran without
leaking the user's home directory. Standard tasks strip the daemon's
workspaces root to `<wsUUID>/<taskShort>/workdir`; local_directory
tasks fall back to the trailing two path segments (`repos/foo`),
which keeps enough context for the user to recognise the directory
without exposing $HOME or the username.

The derivation lives in `taskToResponse` so every endpoint that
serves a task — list, snapshot, claim, rerun, cancel, complete,
fail — fills the field consistently. taskToResponse now also
populates `workspace_id`, which the prior shape declared but never
set. shortTaskID mirrors execenv.shortID; a colocated test pins the
two helpers together so future daemon-side layout changes don't
silently degrade the chip into the local_directory fallback.

Replaces the front-end stripping attempt in PR #3379, which passed
issue_id where workspace_id was required and therefore rendered the
full absolute path on every standard task.

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

* MUL-2771: harden privacy guards on transcript work_dir chip

Address second-round review feedback from PR #3428:

1. Drop the `title={task.work_dir}` tooltip in the transcript dialog.
   The visible chip was safe but native browser tooltips re-rendered the
   absolute `/Users/<name>/...` on hover, leaking into screen shares,
   screenshots, and recordings — defeating the stated goal of the chip.
   The absolute path now never reaches the DOM (no title, aria, or data
   attribute).

2. Replace the "tail two segments" fallback for local_directory paths
   with explicit home-prefix stripping plus a basename-only final
   fallback. The old behaviour leaked the username on shallow paths like
   `/Users/alice/foo`, `/home/alice/project`, and `C:\Users\alice\foo`.
   The new behaviour recognises common per-user home layouts on macOS,
   Linux, and Windows (case-insensitive), strips them down to the
   remainder, and falls back to the basename for any path under an
   unrecognised root — a single segment can never carry the home prefix.

3. Align the Go and TypeScript field comments with the real fallback
   policy so future readers see "strip home / basename" instead of the
   outdated "tail two segments" description.

Tests: expanded `TestRelativeWorkDir` to cover shallow `/Users/...`,
`/home/...`, and `C:\Users\...` paths, the exact-home edge cases,
case-insensitive matching, and the non-home basename-only fallback.

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

---------

Co-authored-by: J <j@multica.ai>
Co-authored-by: multica-agent <github@multica.ai>
2026-05-28 15:53:16 +08:00
yushen
afb199d7bf fix(billing): block mcn_ cloud-node PAT actors too — same kind as mat_
Same-shaped vulnerability the mat_ commit (86d309dbb) just fixed,
caught in re-review: mcn_ cloud-node PATs are also a machine
credential, and they were sailing through RequireHumanActor because
the gate only knew about 'task_token'.

The mcn_ branch in middleware/auth.go was setting X-User-ID from the
verifier's owner_id but not stamping any X-Actor-Source. The
RequireHumanActor gate added in 86d309dbb did:

    if r.Header.Get("X-Actor-Source") == "task_token" { 403 }

so an mcn_ caller looked indistinguishable from a JWT/mul_ caller —
which is exactly the lateral-movement risk the previous commit was
supposed to close. A compromised cloud-runtime EC2 node could read
its owner's billing balance / transactions / batches / topups, and
create checkout / portal sessions, despite us thinking we'd shut
agents/nodes out.

Three changes:

1. middleware/auth.go mcn_ branch now stamps
   X-Actor-Source=cloud_pat alongside X-User-ID. Symmetric with the
   existing 'task_token' stamp in the mat_ branch — server-set,
   stripped from any client-supplied value at the top of the
   middleware, no way for the cloud node to forge or omit.

2. handler/actor_guards.go RequireHumanActor switched from a single-
   value '== task_token' check to an explicit switch covering both
   'task_token' and 'cloud_pat'. Comments were rewritten to call
   out the two-machine-credentials picture explicitly, plus a
   maintenance note that any new machine-credential auth branch
   added later MUST stamp a distinct value AND get reviewed against
   this gate at the same time.

3. middleware/daemon_auth.go was updated for symmetry — strips
   client-supplied X-Actor-Source at the top (Auth has done this
   since MUL-2600; DaemonAuth was missing it) and stamps
   X-Actor-Source=cloud_pat in the mcn_ branch. This doesn't
   directly affect billing (billing routes do not go through
   DaemonAuth), but keeps the two middlewares' contracts uniform
   so an endpoint that ever sits behind both, or moves between
   them, can't be tricked by which auth chain happened to handle
   the request.

Tests:

  * RequireHumanActor — old TestRequireHumanActor_BlocksTaskToken
    folded into a table-driven TestRequireHumanActor_BlocksMachineCredentials
    that walks both 'task_token' and 'cloud_pat'. The table comment
    explicitly says: 'a new machine credential added without a
    corresponding case here would silently grant agents/nodes
    account-level access' — keeps future review honest.
  * Auth mcn_ happy-path test now also asserts X-Actor-Source=cloud_pat
    is stamped after a successful Fleet verify.
  * DaemonAuth mcn_ happy-path test mirrors the same assertion.
  * New TestDaemonAuth_StripsClientSuppliedActorSource pins the new
    top-of-middleware strip so a future regression on it is caught.

go build / go vet / go test ./... clean.
2026-05-28 15:51:32 +08:00
yushen
86d309dbb8 fix(billing): block mat_ task-token actors from /api/cloud-billing/*
Adds handler.RequireHumanActor — a chi middleware that 403s any
request carrying X-Actor-Source=task_token — and applies it to the
cloud-billing route group.

Why this matters (the bug it fixes):

The general Auth middleware (server/internal/middleware/auth.go)
turns every recognized bearer format into the same shape: a stamped
X-User-ID header. JWT cookie, mul_ PAT, AND mat_ task token all
produce a non-empty X-User-ID. That uniformity is correct for issue
/ comment / chat scopes — agents acting as their owner inside a
bounded (agent, task) job is exactly what mat_ is designed for.

It is NOT correct for account-level scopes:

  * Reading balance / transactions / batches / topups list lets an
    agent see its owner's wallet state without the owner approving
    the query.

  * Creating checkout / portal sessions can move money or leak
    payment-method state. A compromised agent (prompt-injected, bad
    MCP tool, escaped quote in scratch data) could spin up a checkout
    bound to an attacker-controlled email or open a Billing Portal
    session.

Before this commit, /api/cloud-billing/* sat outside the workspace
group with only the generic Auth chain, so an mat_ task token's
stamped X-User-ID was indistinguishable from an mul_ PAT's. The
proxy happily forwarded everything to multica-cloud, where the
upstream's owner-only contract was bypassed entirely.

The fix:

  * RequireHumanActor middleware checks the SERVER-SET (not client-
    settable) X-Actor-Source header. The Auth middleware deletes any
    client-supplied value before stamping its own, so a non-empty
    'task_token' value here is authoritative — a member-token caller
    cannot forge it, and an mat_-token caller cannot strip it.

  * Wired in via r.Use(handler.RequireHumanActor) on the
    /api/cloud-billing route group. Applying it as middleware (not
    inline in each handler) means a developer adding a new billing
    endpoint cannot accidentally skip the gate.

  * Deliberately denylist-shaped, not allowlist. Future actor kinds
    added to auth.go (e.g. service-account tokens) get a conscious
    decision-point at the gate rather than being pre-emptively shut
    out by an over-broad rule today.

The Stripe webhook is untouched — it sits outside the entire Auth
group, so X-Actor-Source is never stamped on it (the auth middleware
doesn't run). The guard is irrelevant on that path.

Tests:

  * TestRequireHumanActor_AllowsHumanRequest — empty actor source
    passes through (JWT / mul_ PAT path).
  * TestRequireHumanActor_BlocksTaskToken — 'task_token' value
    triggers 403 and inner handler is not called.
  * TestRequireHumanActor_IgnoresUnknownActorSource — pins the
    denylist shape with explicit comment so the next person adding
    an actor kind knows where to revisit.
  * TestRequireHumanActor_AppliedViaChiRouterUse — small but
    important: builds a real chi router with r.Use(...) and proves
    the guard fires on every endpoint in the group, which is the
    contract router.go relies on.

go build / go vet / go test ./... clean.
2026-05-28 15:40:28 +08:00
Bohan Jiang
bae8a84abd MUL-2767 feat(agent): add Antigravity runtime backend (#3427)
* feat(agent): add Antigravity runtime backend

Adds Google's Antigravity CLI (`agy`) as the 12th supported coding-tool
runtime, alongside Claude / Codex / Cursor / Copilot / Gemini / Hermes /
Kimi / Kiro / OpenCode / OpenClaw / Pi.

The CLI emits plain assistant text on stdout (no structured event
stream), so the backend streams stdout line-by-line as `MessageText`
events and accumulates the same text as the final `Result.Output`.
Session resumption uses `--conversation <id>`; because the conversation
UUID is not echoed on stdout, the daemon routes `--log-file` to a temp
file and recovers the id from the glog-formatted log lines.

MUL-2767

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

* fix(agent): correct Antigravity capability contract from Elon review

- ModelSelectionSupported now returns false for antigravity. `agy` has no
  --model flag and antigravityBackend deliberately drops opts.Model, so
  the UI must render a disabled "Managed by runtime" picker instead of
  an empty dropdown plus a silently-ignored manual-entry field. Also
  stop seeding AgentEntry.Model from MULTICA_ANTIGRAVITY_MODEL — the
  backend would silently ignore it.

- Antigravity skills now write to {workDir}/.agents/skills/, the CLI's
  native workspace path (inherits Gemini CLI's layout per
  https://antigravity.google/docs/gcli-migration). Previously they went
  to the .agent_context/skills/ fallback that the CLI doesn't scan.
  Runtime brief moves antigravity into the native-discovery branch and
  local_skills.go points the user-level skill root at
  ~/.gemini/antigravity-cli/skills for Runtime → local skill import.

- Doc + UI comment sync: providers matrix / install-agent-runtime /
  cloud-quickstart / agents-create / tasks (session-resume support) /
  skills / README all now list Antigravity in the right buckets, and
  the model-picker / model-dropdown comments cite antigravity (not the
  stale hermes reference) as the supported=false example.

New tests: TestAntigravityModelSelectionUnsupported,
TestInjectRuntimeConfigAntigravity (native discovery wording),
TestWriteContextFilesAntigravityNativeSkills (.agents/skills/ landing,
.agent_context/skills/ NOT written).

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

* feat(provider-logo): swap inline placeholder for real Antigravity PNG

Replaces the hand-drawn planet+arc placeholder with the official asset
shipped from Downloads. Stored next to the component; bundlers
(Next.js / electron-vite) resolve the PNG import to a URL string at
build time. Added a small assets.d.ts so packages/views' tsc accepts
PNG / SVG module imports — there was no prior asset usage in this
package to register the declaration.

---------

Co-authored-by: J <j@multica.ai>
Co-authored-by: multica-agent <github@multica.ai>
2026-05-28 15:40:05 +08:00
yushen
b604c6d9b0 fix(billing): self-review fixes — workspace scope, rate limit, signature gate
Addresses review feedback on the initial /api/cloud-billing proxy.

(P2) Move /api/cloud-billing/* out of the workspace-scoped group.
Billing is account-level — the upstream owner model is single-user
per X-User-ID and has no workspace concept. Routes were nested under
RequireWorkspaceMember, so any caller without an active workspace
slug got a 400/404 from the membership middleware before the proxy
could run. Now sits next to /api/me / /api/tokens in the user-scoped
section, still inside the Auth group but free of the workspace
membership check.

Stripe webhook hardening:

  * Per-IP rate limit (reuses h.WebhookIPRateLimiter, same limiter
    HandleAutopilotWebhook uses). The endpoint is public; without
    rate limiting an attacker can flood it and force one upstream
    round-trip per request, burning the cloud's webhook handling
    budget. Fast-path 429 stops the bleed before we touch the body.

  * Early 401 on missing Stripe-Signature. Real Stripe deliveries
    always include the header; absence is a confident not-from-Stripe
    signal. Local 401 saves the upstream RTT on the most common
    illegitimate poke (curl from a probe). It is a strict superset of
    what the upstream would do (cloud also rejects missing-signature
    with 401), so Stripe's delivery dashboard sees the same outcome
    that would have come from the cloud.

  * Forward Stripe's original Content-Type. cloudruntime's default
    overrode it to plain application/json, dropping the
    '; charset=utf-8' suffix Stripe always sends. Signature is
    over the body so it didn't break verification, but preserving
    the exact header is cheap and removes a debug-time surprise.

Code-quality cleanups:

  * Allow-list (not deny-list) on Stripe session_id path param. Old
    check rejected /, ?, # only; allow-list rejects anything outside
    [A-Za-z0-9_]. A future Stripe ID format bump cannot quietly slip
    a new harmful character past us — it gets rejected until we
    consciously widen the rule.

  * Cache http.CanonicalHeaderKey result in cloudruntime.Client.Do
    (was called twice per iteration).

  * gofmt the test file (struct field alignment + stray semicolon).

Tests updated:

  * Existing happy-path test now also asserts upstream Content-Type
    matches the request's '; charset=utf-8' value.
  * TestStripeWebhookMissingSignatureStillForwards →
    TestStripeWebhookMissingSignatureRejectedLocally (asserts 401
    AND that upstream is NOT called).
  * Empty-body test now sets Stripe-Signature header (otherwise the
    new pre-check 401s before the empty-body branch can run).
  * New TestStripeWebhookRateLimited drives the 429 fast-path with
    a denying limiter stub.

go build / go vet / go test ./... clean.
2026-05-28 15:23:32 +08:00
Bohan Jiang
d39da9f7f0 MUL-2764: feat(agents): add MCP config tab to agent detail page (#3419)
* MUL-2764: feat(agents): add MCP config tab to agent detail page

Backend already stores `mcp_config` and the daemon forwards it to the
runtime CLI via `--mcp-config`; this only adds the UI entry point.

The new tab presents a JSON editor that pretty-prints the existing
config, validates the buffer on every keystroke, and saves through the
existing `PUT /api/agents/{id}` path. Clearing the editor sends
`mcp_config: null`, which the handler reads as "wipe the column" and
the daemon falls back to the CLI's own default.

When the caller can't see secrets (agent actor, or a non-owner
non-admin member), the server already returns `mcp_config: null` with
`mcp_config_redacted: true`; the tab renders a read-only "configured
but hidden" state in that case so a non-privileged member cannot
silently overwrite an admin-owned config by saving an empty editor.

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

* fix(agents): MCP tab — preserve in-flight edits + warn non-Claude runtimes

- Fix stale-editor sync: compare the local draft against the *previous*
  original via a ref, so a background agent refetch updates an untouched
  editor instead of being silently ignored. Without this, a draft equal to
  the OLD original was treated as user-edited after the prop changed, and
  the next Save would write the old config back over a concurrent admin
  edit.
- Surface a notice inside the tab when the agent's runtime provider is not
  Claude — today's daemon only forwards mcp_config via Claude's
  --mcp-config, so saving on e.g. a Codex agent was silent but ineffective.
- Tests for both: rerender resyncs an untouched editor, rerender preserves
  an in-flight edit, warning renders on non-Claude / hides on Claude.

MUL-2764

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

* MUL-2764: feat(agents): codex MCP support + hide MCP tab on unsupported runtimes

- Backend: codex.go now translates agent.mcp_config (Claude-style
  `{"mcpServers": {...}}`) into `-c mcp_servers.<name>=<inline-toml>`
  flags for `codex app-server`, so MCP servers configured in the UI
  reach Codex's per-task config layer. Bad mcp_config JSON downgrades
  to a warn-and-skip so it can't break the agent launch.
- Frontend: AgentOverviewPane hides the MCP tab when the agent's
  runtime provider doesn't read mcp_config — only `claude` and `codex`
  are supported today, every other provider sees no MCP tab. The
  previous in-tab warning is removed (no longer reachable).
- New shared helper `providerSupportsMcpConfig` lives in
  `@multica/core/agents` so views and any future caller share one list
  of MCP-aware providers.
- Tests: new go-side coverage for stdio + url + multi-server inputs,
  TOML string escaping, malformed-input fallback, and arg ordering vs
  custom_args; new views-side coverage for which providers surface the
  MCP tab. En + zh-Hans copy and parity test refreshed.

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

* MUL-2764: fix(agents): keep codex mcp_config secrets out of argv/logs

Move the agent's mcp_config from a `-c mcp_servers.<id>=<inline-toml>`
argv flag into a daemon-managed `[mcp_servers.*]` block inside the
per-task `$CODEX_HOME/config.toml`. mcp_servers.<id>.env is a documented
Codex config field and the UI already treats mcp_config as redacted for
non-admins; argv would have leaked those values into `ps aux` and the
`agent command` log line. The file is forced to 0600 to keep secrets in
the daemon owner's lane regardless of the seed file's mode.

Also drop user-supplied `-c/--config mcp_servers.*` entries from
custom_args. Codex `-c` is last-wins (verified against codex-cli 0.132.0),
so without filtering, a custom_args entry could silently shadow whatever
the MCP Tab saved.

Strip inherited `[mcp_servers.*]` tables from the per-task config.toml
when the agent has its own mcp_config, mirroring Claude's
`--strict-mcp-config`: avoids TOML "table already exists" errors on
name collisions and matches admin expectations that the MCP Tab is the
authoritative source for that task.

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

* MUL-2764: fix(agents): codex mcp_config three-state semantics + custom_args compat

Address the third review pass:

1. Distinguish nil vs present-but-empty mcp_config. `{}` and
   `{"mcpServers":{}}` now count as "admin saved an explicit (empty)
   managed set" — strip inherited user `[mcp_servers.*]` and pin an
   empty managed marker block. Only SQL NULL / JSON `null` map to
   "absent" and fall back to the user's global `~/.codex/config.toml`.
   This aligns Codex with the API's three-state contract (omit / null
   / object) and with Claude's `--strict-mcp-config` semantics.

2. Fail closed on `ensureCodexMcpConfig` errors and on managed
   mcp_config without CODEX_HOME. Previous warn-and-launch would
   silently inherit the user's global MCP servers and look identical
   to a successful apply — exactly the surprise the MCP Tab is meant
   to remove.

3. Only filter `-c mcp_servers.*` from `custom_args`/`extra_args`
   when the agent has a managed mcp_config. Pre-MUL-2764 agents that
   configured MCP via custom_args keep working; once an admin opts
   in via the MCP Tab the daemon owns the `mcp_servers` namespace
   and overrides are dropped (last-wins safety).

4. Update mcp_config locale intro to mention $CODEX_HOME/config.toml
   instead of the now-removed `-c mcp_servers.*` argv path.

Tests:
- Split `TestEnsureCodexMcpConfigEmptyInputsAreNoop` into
  `TestEnsureCodexMcpConfigAbsentLeavesUserTablesAlone` (nil/null)
  and `TestEnsureCodexMcpConfigEmptyManagedSetStripsUserMcp` (`{}`,
  `{"mcpServers":{}}`).
- Add `TestEnsureCodexMcpConfigEmptyManagedSetIdempotent` to pin
  byte-identical reruns on the empty managed marker block.
- Add `TestHasManagedCodexMcpConfig` covering the eight relevant
  inputs.
- Add `TestBuildCodexArgsPreservesCustomMcpOverridesWhenUnmanaged`
  and `TestBuildCodexArgsDropsCustomMcpOverridesWhenManaged` to
  pin the new gating.
- Add `TestCodexExecuteFailsClosedWhenMcpConfigInvalid` and
  `TestCodexExecuteFailsClosedWhenManagedMcpButNoCodexHome` for the
  Execute paths.

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

---------

Co-authored-by: J <j@multica.ai>
Co-authored-by: multica-agent <github@multica.ai>
2026-05-28 15:11:28 +08:00
Multica Eve
202200bc62 feat: publish helm chart to ghcr (#3415)
Co-authored-by: Eve <eve@multica-ai.local>
Co-authored-by: multica-agent <github@multica.ai>
2026-05-28 15:04:05 +08:00
yushen
6cd03f24a0 feat(billing): proxy /api/cloud-billing/* + Stripe webhook to multica-cloud
Mirrors the existing /api/cloud-runtime/* Fleet proxy pattern. Eight
user-authenticated endpoints under /api/cloud-billing/ stamp X-User-ID
from the auth context and forward to /api/v1/billing/* on multica-cloud
(same upstream service / port the cloud-runtime proxy already targets,
per the upstream API doc):

  GET    /api/cloud-billing/balance
  GET    /api/cloud-billing/transactions
  GET    /api/cloud-billing/batches
  GET    /api/cloud-billing/topups
  GET    /api/cloud-billing/price-tiers
  POST   /api/cloud-billing/checkout-sessions
  GET    /api/cloud-billing/checkout-sessions/{sessionId}
  POST   /api/cloud-billing/portal-sessions

Stripe webhook is the outlier and lives outside the Auth group at
POST /api/webhooks/stripe (public ingress, alongside the autopilot
and GitHub webhooks). Three things matter for it and are NOT shared
with the JSON proxy:

  1. No application-layer auth — Stripe signs the raw body with a
     shared secret, the cloud upstream verifies. Adding any
     pre-check here would only weaken the contract.
  2. Body forwarded byte-for-byte. The signature is computed over
     the exact bytes; a json.Unmarshal/Marshal round-trip silently
     breaks verification.
  3. Stripe-Signature header is forwarded verbatim. To support that,
     cloudruntime.Request gains an optional Headers http.Header
     field, applied AFTER the client's defaults (Accept,
     Content-Type) but BEFORE the authoritative X-User-ID /
     X-Request-ID stamps so a caller can't smuggle either of those.

A 1 MiB MaxBytesReader caps webhook bodies so a flood can't make us
buffer arbitrary memory before the upstream rejects on signature.

Frontend integration is out of scope for this PR — the routes are
just plumbing. Per-deployment behavior:

  - SaaS API node with MULTICA_CLOUD_FLEET_URL set: routes work
    end-to-end against the cloud Billing module.
  - Self-hosted (no fleet URL): all proxy routes return 503 'cloud
    runtime is not configured', which is the same fail-soft already
    used for /api/cloud-runtime/*.

Tests in cloud_billing_test.go cover:
  * table-driven happy path for every standard endpoint (method,
    path, user_id stamp, query forwarding for GET-with-query, body
    forwarding for POST)
  * the dynamic-path checkout-session GET (param appended verbatim)
  * path-traversal rejection on session_id (/, ?, # are unsafe)
  * missing-param 400
  * disabled-cloud 503
  * webhook raw-body byte-perfect forwarding (body deliberately has
    leading whitespace + trailing newline + non-canonical key order
    to catch any unmarshal/marshal round-trip)
  * webhook missing Stripe-Signature still forwards (cloud is the
    one that rejects)
  * empty webhook body forwards
  * oversize webhook body returns 413 without calling upstream
  * webhook on a self-hosted deployment returns 503

go build / go vet / go test ./... clean.
2026-05-28 15:01:39 +08:00
QJ Yu
56bddc5e06 fix(issues): place new issues at top of column in manual sort mode
Fixes PER-145.
2026-05-28 14:20:20 +08:00
Bohan Jiang
fe2c990296 docs(self-host): document Microsoft Exchange / SMTP relay modes and failure diagnostics (#3426)
GH#3405 / MUL-2768. Self-host docs already point at the SMTP path, but on-prem operators ran into two gaps:

- The Option B env block in auth-setup and self-host-quickstart only showed a 587 authenticated example, with no copy-pasteable block for the most common Exchange "anonymous internal relay on port 25" pattern, and no explicit mapping between port / auth / TLS / supported-or-not.
- troubleshooting "Emails not received" only covered Resend; SMTP failures (smtp dial / starttls / auth / MAIL FROM / RCPT TO / DATA) surface as wrapped errors in the backend logs, but operators had no doc telling them which Exchange-side fix maps to each.

Adds:

- A relay-mode table (anonymous 25 / authenticated 587 / 465 still unsupported) and two copy-pasteable env blocks in both auth-setup.mdx and self-host-quickstart.mdx (EN + ZH).
- Explicit note on the EmailService startup log line so operators can confirm SMTP is the active provider after restart, without leaking credentials.
- An SMTP failure-mode table in troubleshooting.mdx (EN + ZH) keyed on the exact wrapped error string, with the Exchange-side fix for each.

No code changes; env variable surface unchanged (still SMTP_HOST / SMTP_PORT / SMTP_USERNAME / SMTP_PASSWORD / SMTP_TLS_INSECURE). Port 465 stays "not supported" pending #3340.

Co-authored-by: J <j@multica.ai>
Co-authored-by: multica-agent <github@multica.ai>
2026-05-28 13:59:10 +08:00
Bohan Jiang
5732b0dae8 fix(issues): clear deleted ids from recent issues store (#3420)
cleanupDeletedIssueCaches now also calls a new
useRecentIssuesStore.forgetIssue(wsId, issueId) action so the persisted
Recent Issues bucket no longer keeps deleted ids around. Both the delete
mutation and the WS delete event flow through the same cleanup, so this
covers self-delete and cross-client delete. Without this, Cmd+K fires a
detail query for every recent id on open and returns a steady stream of
404s for issues the user has deleted (#3413).

MUL-2765

Co-authored-by: J <j@multica.ai>
Co-authored-by: multica-agent <github@multica.ai>
2026-05-28 13:06:13 +08:00
yushen
1947830d4b chore(mobile): re-ignore .DS_Store under apps/mobile/data/
The `!data/**` negation that rescues apps/mobile/data/ from the
root .gitignore's `data/` rule was inadvertently pulling .DS_Store
back in too — Finder metadata kept showing up in git status. Restate
.DS_Store after the negation so last-match-wins re-ignores it.
2026-05-28 12:50:50 +08:00
Bohan Jiang
2bda4065d0 MUL-2708: fix(agent): preserve multi-line Pi prompt on Windows by bypassing the .cmd shim (#3417)
Pi is installed on Windows via npm, which lays down `pi.cmd` → `pi.ps1`
→ `node_modules/@mariozechner/pi-coding-agent/dist/cli.js`. The daemon
spawns Pi with `exec.Command("pi", ...)`; PATHEXT resolves that to
`pi.cmd`, and cmd.exe expands `%*` in the shim by re-tokenising the
original command line, which truncates any argv containing newlines.

buildPiArgs passes the full prompt as the last positional argv, so the
multi-line system+user prompt is silently cut at the first newline
before it reaches the JS entrypoint. The session JSONL then records
only the first line ("You are running as a chat assistant for a Multica
workspace.") and Pi replies as if the user message were missing
(GitHub multica-ai/multica#3306).

Mirror the existing cursor-agent fix: when LookPath resolves Pi to a
.cmd/.bat launcher and a sibling pi.ps1 exists, invoke PowerShell with
`-File <ps1>` directly and forward each arg as a discrete token. This
keeps us on the official launch path while skipping the cmd.exe %*
re-expansion. Falls back to the original launcher when pi.ps1 or
PowerShell can't be located.

The Windows test asserts the rewrite produces the expected argv and
that the multi-line positional prompt survives unchanged.

Co-authored-by: J <j@multica.ai>
Co-authored-by: multica-agent <github@multica.ai>
2026-05-28 12:36:16 +08:00
Dmitry Rybalka
7722a98a6a fix(ui): coalesce split task transcript messages (#3097) 2026-05-28 12:34:51 +08:00
DimaS
ccbd62c7ad fix(daemon): ignore gc meta with empty parent ids (#3407)
Co-authored-by: “646826” <“646826@gmail.com”>
2026-05-28 12:33:34 +08:00
DENG
ec5874db96 fix(runtimes): consolidate local machine by device name
Fixes #3333
2026-05-28 11:49:44 +08:00
Bohan Jiang
4864831721 MUL-2744: feat(auth): auto-renew daemon PAT in-place within 7-day window (#3360)
* MUL-2744: feat(auth): auto-renew daemon PAT in-place within 7-day window

Daemons currently hold a 90-day PAT and have no renewal path: once the
token's expires_at passes, every request 401s and the user has to find
the silent failure in the daemon log and re-run `multica login`.

This adds an in-place renewal:

- New `POST /api/tokens/current/renew` (Auth-protected, mul_ only). The
  server checks remaining lifetime: ≥ 7 days is a no-op; < 7 days bumps
  expires_at to now + 90 days via a guarded UPDATE that makes concurrent
  renews idempotent (the WHERE expires_at < $2 clause means only one
  writer wins; the loser sees pgx.ErrNoRows and reports the already-
  extended value). No raw token rotation — the same secret stays in
  every CLI/daemon process sharing the config.

- Daemon-side `tokenRenewalLoop`: fires once on startup (covers
  machine-was-off cases) and then every 3 days. With a 7-day server
  threshold this gives at least two renewal attempts before the window
  closes, so a single network blip can't push the token out.

- 401 fallback: when the renew call comes back 401 (token already
  revoked/expired), the daemon logs a user-actionable WARN telling the
  operator to run `multica login` — instead of the current silent
  failure mode. Loop keeps running so the warning repeats until fixed.

PAT cache (auth.AuthCacheTTL = 10m) doesn't need invalidation: the next
miss after the UPDATE re-reads the row and re-caches with the bumped
TTL automatically.

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

* MUL-2744: fix(auth): renew PAT before first sync; CAS against renewal threshold

Addresses the two issues Elon raised on #3360.

Must-fix: if the PAT is already revoked/expired when the daemon starts,
syncWorkspacesFromAPI 401s and Run returns before the background
tokenRenewalLoop ever fires its initial renewal. The operator only sees
a generic auth failure in the workspace-sync log with no hint that
'multica login' is the fix. Now the startup path runs an inline
tryRenewToken first, surfacing the existing 401 WARN before anything
else gets a chance to fail. Pulled the renew + first-sync pair into
preflightAuth so the ordering invariant is enforced at one site and
tests can exercise the failure modes without spinning up the full Run
setup. Removed the redundant initial tryRenewToken from
tokenRenewalLoop — startup now owns the first call.

Nit: the previous WHERE clause on ExtendPersonalAccessTokenExpiry
(expires_at < $2) did not actually make concurrent renews idempotent
the way the comment claimed. Two callers race-computing
$2 = now + 90d produce strictly-different values, and the second
writer's $2 always exceeds the row the first writer just wrote, so the
UPDATE re-matches and bumps again. Switched to a CAS against the
renewal threshold (expires_at <= $renew_threshold_at, i.e. now + 7d):
once writer A pushes expires_at past the threshold, writer B's UPDATE
matches zero rows and the loser falls back to reporting the
already-extended value as a no-op.

Tests:
- TestPreflightAuth_RenewsBeforeWorkspaceSyncOnExpiredToken locks in
  the call ordering — renew endpoint is hit before workspaces, and the
  re-login WARN appears even though both endpoints 401.
- TestPreflightAuth_SyncProceedsWhenRenewIsNoOp covers steady-state
  startup: a renew=false no-op must still progress to workspace sync.
- TestPreflightAuth_TransientRenewFailureDoesNotBlockStartup covers a
  500 from the renew endpoint — startup must continue, no WARN.
- TestRenewPAT_ParallelRenewExtendsExactlyOnce fires N=8 concurrent
  renews at one row and asserts exactly one returns renewed=true with
  the others reporting the same already-extended expires_at, plus the
  DB carries only that single bumped value.

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

---------

Co-authored-by: J <j@multica.ai>
Co-authored-by: multica-agent <github@multica.ai>
2026-05-27 22:22:26 +08:00
Multica Eve
be32e5af00 docs(changelog): add v0.3.10 release notes (#3362)
* docs(changelog): add 2026-05-27 release notes

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

* docs: refine v0.3.10 changelog copy

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

---------

Co-authored-by: Eve <eve@multica-ai.local>
Co-authored-by: multica-agent <github@multica.ai>
2026-05-27 17:51:37 +08:00
Bohan Jiang
bed032f937 fix(issues): move local-directory hint out of the comment composer (#3363)
The "Agent will work in-place at …" banner used to render directly above
the comment input, which made it read like an input adornment. Move it
to the top of the Activity section (below the "Activity" heading, above
the live agent card) so it reads as section context instead of composer
chrome. Update the component's JSDoc and tweak the margin (mb-2 → mt-3)
to match the new placement.

MUL-2752
2026-05-27 17:21:46 +08:00
Naiyuan Qing
171ee842d4 fix(editor): preserve raw html-like text on paste (#3355)
* fix(editor): fall back to literal paste when markdown parser drops all content

When pasting text like `<T>` or `<MyComponent>`, the CommonMark-compliant
markdown parser treats them as inline HTML tags. ProseMirror's schema doesn't
recognize unknown HTML elements, so they are silently dropped — producing an
empty document from non-empty input.

Detect this case (non-empty input → empty parse result) and fall back to
literal text insertion so the user sees their text instead of nothing.

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

* fix(editor): escape non-standard HTML tags in paste to prevent content loss

When pasting mixed content containing multiple <tag> patterns (e.g.
"<t>\n裸 `<tag>` 做转\n<tag>\n<t>"), CommonMark treats bare <word>
as inline HTML. ProseMirror silently drops unknown HTML elements,
causing partial content loss. The previous empty-result fallback only
caught the single-tag case where the entire parse result was empty.

Pre-process paste text before markdown parsing: escape <tag> patterns
whose tag name is not a standard HTML element, while respecting inline
code spans and fenced code blocks. Standard HTML (div, br, img, etc.)
passes through normally.

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

* fix(editor): preserve raw html-like text on paste

* fix(editor): prefer rich html paste when semantic

* fix(editor): avoid native paste when html drops raw tags

---------

Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>
2026-05-27 17:14:10 +08:00
Naiyuan Qing
85daf7818a fix(editor): render code blocks when lowlight highlightAuto returns empty tree (#3358)
lowlight.highlightAuto() returns a Root with zero children (relevance 0)
for content it cannot classify into any language. toHtml() of that tree
produces an empty string, so dangerouslySetInnerHTML rendered a blank
<code> element — the <pre> background was visible but the text was gone.

Fall through to plain text render when toHtml produces nothing.

Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>
2026-05-27 16:57:38 +08:00
Kagura
f02bc56e70 fix(agent/cursor): remove obsolete 'chat' subcommand from argv (#3077) (#3092)
The current cursor-agent CLI no longer has a 'chat' subcommand. The
positional 'chat' argument was silently treated as prompt text, leaking
into the user message (e.g. 'chat <actual prompt>').

Remove 'chat' from buildCursorArgs so the generated argv matches the
current cursor-agent CLI interface.

Fixes #3077
2026-05-27 16:40:29 +08:00
Anderson Shindy Oki
bdb60acae9 fix: swimlane empty lanes in due to pagination (MUL-2724) (#3326)
* fix: Swimlane lazy load issues

* wip

* refactor

* fix: Rebase issues

* fix: rerender

* refactor bactch and chunking
2026-05-27 16:28:15 +08:00
Bohan Jiang
d16ed2806a MUL-2748 docs(autopilots): document webhook event filters + link from UI (#3359)
* MUL-2748 docs(autopilots): document webhook event filters and link from UI

Follow-up to PR #3231. The webhook event filters feature shipped
without user-facing docs and the UI section gave no hint about how
event/action are derived from inbound requests.

- Add an "Event filters" subsection to autopilots.mdx and .zh.mdx
  under the existing "Trigger from a webhook" section: what the
  filter does (with the `event_filtered` outcome), where the event
  name and action come from (body envelope / headers / body
  fallbacks), examples, a non-string-action gotcha, and a curl
  recipe verifying both the allowed and filtered paths.
- Add a small ExternalLink icon next to the "Event filters" label
  in WebhookEventFilterSection that opens the docs section in a new
  tab. Locale-aware: zh users land on the Chinese page anchor, en
  users on the English one.

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

* docs(autopilots): expand "where event name and action come from" with curl examples

Add concrete curl request/inference pairs under each derivation step
(body envelope, headers, body fallback, default) and a "common gotcha"
explaining why filter `event=trigger, action=true` does not match
`{"trigger": true}`. Mirrors the explanation that resolved the
on-call confusion about Event filter semantics.

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

---------

Co-authored-by: J <j@multica.ai>
Co-authored-by: multica-agent <github@multica.ai>
2026-05-27 16:07:20 +08:00
Alex
746c0c4456 MUL-2746 fix(avatar): normalize relative avatar urls in desktop/web (#3100)
* fix(avatar): normalize relative avatar urls in desktop/web

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

* fix: test

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

* fix(avatar): normalize avatar url in AvatarPicker preview

MUL-2746. The picker is used by create-agent and create-squad, and also
prefills from a template's `avatar_url` when duplicating an agent. The
upload result / template URL is root-relative in local-storage setups,
so on Desktop (file:// runtime) the preview <img> resolves against the
local filesystem and the avatar fails to render. Route the value through
`resolvePublicFileUrl` for rendering only; the stored URL stays raw so
the parent's create call still posts what the backend expects.

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

---------

Co-authored-by: multica-agent <github@multica.ai>
Co-authored-by: J (Multica agent) <agents@multica.ai>
2026-05-27 16:02:08 +08:00
Raúl Anatol
2b5696703f MUL-2703: feat(autopilots): webhook event filters per trigger (MUL-2334 follow-up) (#3231)
* feat(autopilots): webhook event filters per trigger (MUL-2334 follow-up)

Adds schema-backed event/action filtering to webhook triggers so operators
can declare exactly which GitHub (or generic) events should spawn autopilot
runs. Events outside the declared scope are recorded as ignored with reason
'event_filtered' — visible in the delivery log but without expensive run/task
creation.

Closes #3093 (supersedes the description-parsing approach from that PR).

Backend:
- Migration 108 adds event_filters JSONB to autopilot_trigger
- sqlc queries updated for CREATE / UPDATE / LIST / GET
- HandleAutopilotWebhook filters against trigger.event_filters before dispatch
- Create/Update trigger handlers accept event_filters in the request body
- Response shape includes event_filters so the UI can render it

Frontend:
- New WebhookEventFilterSection component in the autopilot dialog
- Inputs for event name + comma-separated actions
- i18n strings added (en + zh-Hans)

Tests:
- Unit tests for splitWebhookEvent and webhookEventAllowedByTriggerScope
- Handler-level integration tests for filtered / allowed / no-filter paths

co-authored-by: ZephaniaCN <agent/autopilot-webhook-filter>

* fix: recognize gitlab/bitbucket/gitea as providers in splitWebhookEvent

TestSplitWebhookEvent failed because only 'github' was recognized as a
provider prefix. Extract isKnownProvider() to handle gitlab, bitbucket,
and gitea as well.

* fix(autopilots): address PR #3231 review for webhook event filters

Must-fix from PR #3231 review:

1. event_filters now uses typed []WebhookEventFilter at the HTTP boundary
   instead of []byte. encoding/json was base64-encoding the field on the
   way out, so the UI could not .map() the response, and a real JSON
   array on the way in failed to decode. Response field also decodes the
   stored JSONB into a typed slice before serialising back.

2. UpdateAutopilotTriggerRequest.EventFilters is *[]WebhookEventFilter
   with tri-state PATCH semantics: nil pointer = leave alone, [] =
   clear, [...] = replace. The handler marshals an explicit empty slice
   to the JSONB literal `[]` so COALESCE overwrites instead of preserves.
   AutopilotDialog now PATCHes the webhook trigger when event_filters
   change in edit mode (previously the toast said "updated" while the
   backend was unchanged).

3. webhookEventAllowedByTriggerScope no longer short-circuits to false
   on the first event-name match whose actions don't line up. Earlier
   code silently shadowed any later filter that shared the same event
   name with disjoint actions.

Robustness: validateWebhookEventFilters rejects empty event names /
actions at write time, and the matcher fails closed on malformed stored
bytes instead of widening the allowlist.

Tests: handler tests now post real JSON arrays (the prior []byte path
masked the contract bug). Adds round-trip / clear-with-[] / preserve-
when-omitted / replace / invalid-filter / filters-on-schedule coverage,
plus matcher tests for same-event multi-filter and malformed-deny.

Migration renamed 108 → 110 to avoid colliding with main's
108_task_token (came in via the merge from main).
2026-05-27 15:47:36 +08:00
Bohan Jiang
e3723dbb22 refactor(autopilot): centralize timezone default and cover invalid-timezone fallback (MUL-2742) (#3356)
Follow-up nits from PR #3324 review:

- Export DefaultAutopilotTriggerTimezone so the autopilot scheduler reuses
  the same source-of-truth as the service layer instead of hardcoding "UTC"
  in two places.
- Add tests that lock down the invalid-timezone fallback (e.g. "Foo/Bar")
  for both buildIssueDescription and interpolateTemplate, so a future change
  to the resolve/format helpers can't silently emit a half-formatted
  timestamp or date.

Co-authored-by: J <j@multica.ai>
Co-authored-by: multica-agent <github@multica.ai>
2026-05-27 15:40:05 +08:00
YOMXXX
607e64d722 fix(autopilot): render trigger output in trigger timezone (#3324) 2026-05-27 15:26:49 +08:00
Naiyuan Qing
bd1fb10afa chore: react-doctor cleanup — button types, useContext→use(), toSorted, error fixes (#3350)
- Add explicit type="button" to 61 <button> elements missing the attribute
- Replace useContext() with React 19 use() across 16 context consumers
- Replace [...arr].sort() with arr.toSorted() in 12 web/desktop files
  (mobile excluded — Hermes lacks toSorted support)
- Fix rules-of-hooks violation: useSidebar try/catch → useSidebarSafe null check
- Fix nested component definition: useMemo wrapping HeaderRight → useCallback
- Fix missing ARIA: add aria-expanded + aria-controls to combobox in create-squad

React Doctor score: 23 → 30. No behavioral changes, no business logic modified.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-27 14:57:07 +08:00
LinYushen
c968c13c87 feat(auth): support mcn_ Cloud Node PATs verified via Fleet (#3349)
* feat(auth): support mcn_ Cloud Node PATs verified via Fleet

Adds a new token kind, mcn_ (multica cloud node), recognized in both
the regular Auth and DaemonAuth middlewares. mcn_ tokens are minted
and owned by Multica Cloud (not the local personal_access_tokens
table); the server validates them by POSTing to the Fleet's
/api/v1/pat/verify endpoint and uses the returned owner_id as
X-User-ID for downstream handlers.

Cloud is the authoritative owner of token status, so this is a
verifier-only path with no DB fallback:

  * Fleet says valid:false -> 401 (token genuinely bad)
  * Fleet unreachable / 5xx -> 503 (transient, retry)
  * No MULTICA_CLOUD_FLEET_URL configured -> 401 (fail closed)

Verification results are cached in Redis for 60s under
mul:auth:mcn:<sha256> to bound the per-request load on Fleet without
extending the revocation window beyond what the Cloud doc allows.
Negative results are NOT cached, so a freshly minted token doesn't
get locked out by a stale 'token_not_found'.

Reuses MULTICA_CLOUD_FLEET_URL (the same env the cloud-runtime proxy
already uses) so deployments don't need a second config knob.

Tests cover the happy path, every documented invalid reason, 4xx/5xx
mapping, network error, decode error, ctx cancellation, the
fail-closed valid:true-without-owner_id case, trailing-slash URL
normalization, and the Redis cache short-circuit + negative
no-cache contract. Middleware tests pin the four 401/503/200 outcomes
in both Auth and DaemonAuth.

* auth(mcn): require owner_id to map to a real local user; drop X-User-PAT plumbing

Two related changes:

1. Cloud-verified owner_id is now checked against our local users table.
   The Cloud owner_id and our users.id share the same UUID space by
   contract; a missing local user means either the row was deleted
   under an active node or something is forging owner_ids — either
   way, fail closed.

   CloudPATVerifier.Verify takes a new OwnerLookupFunc:
     - returns (true, nil)   -> success, cache + return
     - returns (false, nil)  -> ErrCloudPATInvalid (reason='owner_unknown'),
                                NOT cached (so a freshly-created user
                                doesn't get locked out for a TTL window)
     - returns (_, error)    -> ErrCloudPATUnavailable (transient,
                                middleware emits 503)

   Both Auth and DaemonAuth wire ownerLookupFor(queries), a new shared
   helper that wraps queries.GetUser, mapping pgx.ErrNoRows / unparseable
   UUIDs to (false, nil) and other errors to a real Go error.

2. Removed all X-User-PAT plumbing. Cloud now mints node-scoped mcn_
   PATs itself during /api/v1/nodes (see multica-cloud
   docs/api/node-pat.md) and ships them into the EC2 instance via SSM,
   so multica-api no longer needs to forward the caller's mul_ PAT.
   Propagating a long-lived user PAT into a remote machine widened
   the blast radius of any node compromise; that's gone now.

   Removed:
     - cloud_runtime.go: withUserPAT option, cloudRuntimeUserPAT,
       generateCloudRuntimePAT, revokeGeneratedPAT
     - cloudruntime/Request.UserPAT field + X-User-PAT header
     - X-User-PAT from CORS allowed headers
     - obsolete handler tests:
         TestCreateCloudRuntimeNodeForwardsValidatedPAT
         TestCreateCloudRuntimeNodeRejectsUnownedPAT
         TestCreateCloudRuntimeNodeRejectsExpiredPAT
         TestCreateCloudRuntimeNodeAutoGeneratesPAT
       replaced with TestCreateCloudRuntimeNodeForwardsBody
     - X-User-PAT references in packages/core/api/client.test.ts

Tests:
  * 3 new verifier-level tests (owner_unknown not cached, lookup error
    -> Unavailable, success path is cached for both fleet AND lookup)
  * 5 new owner_lookup_test.go tests (nil queries, existing user,
    missing user, malformed UUID, DB error)
  * 1 new end-to-end DaemonAuth test (cloud says valid, no local user
    -> 401)
  * Existing X-User-PAT TS assertions removed; full vitest run passes.
  * go test ./... and go vet ./... clean on the server module.
2026-05-27 14:52:03 +08:00
Bohan Jiang
963c33f030 MUL-2618 docs(project-resources): document local_directory resource type (#3347)
* docs(project-resources): document local_directory resource type

Add user-facing guidance for the local_directory project resource introduced
in #3283 — when to pick it over github_repo, the Desktop / CLI attach flow,
path validation rules, the daemon-scoped one-per-(project, daemon) limit,
serial task execution + waiting_local_directory status, what the daemon will
and won't touch in the user's folder, and the v1 limits to call out
(no auto branch switch / commit / PR; dirty tree carried through).

Also ship the missing Chinese counterpart of project-resources and wire it
into meta.zh.json.

MUL-2618

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

* docs(project-resources): cover write-conflict trade-off + mixed-resource behavior

Expand the local_directory docs in response to review feedback:

- Restate "when to pick local_directory" as two distinct use cases (clone
  cost; fine-grained changes needing frequent local review) instead of a
  one-liner, and make the trade-off explicit: v1 ships no file-level write
  lock, so the per-directory serial gate is the only protection against
  cross-issue agents touching the same files.
- Add a new "Mixing resource types, and multiple local_directory resources"
  section that answers: github_repo + local_directory on the same project
  (local takes precedence on the bound daemon, github_repo falls back
  everywhere else), and two local_directory resources (only possible
  across two daemons, routed by the agent's runtime assignment, no
  load-balancing).

Mirrored into the Chinese translation. typecheck + tests still pass.

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

* docs(project-resources): tighten local_directory wording per review

- Soften "only the bound daemon can take tasks" to "only the bound
  daemon uses this local directory" (zh) — aligns with the
  mixed-resource fallback section where other daemons still run.
- Clarify that local_directory does not create/use a github_repo
  worktree for that task (en + zh); the per-workspace repo cache may
  still sync as a background behaviour.
- Match implementation for the Desktop "Add local directory" button:
  it stays visible but is disabled with a hint when daemon is offline
  or the per-daemon limit is reached; only the web app hides it
  outright (en + zh).

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

---------

Co-authored-by: J <j@multica.ai>
Co-authored-by: multica-agent <github@multica.ai>
2026-05-27 14:33:11 +08:00
Naiyuan Qing
31b58494cf feat(comments): align UpdateComment post-processing with CreateComment (#3337)
* feat(comments): align UpdateComment post-processing with CreateComment (#2965 follow-up)

Part 1 — PR #2965 code review follow-ups:
- Fix sqlc Column3 naming → AttachmentIds via sqlc.arg(attachment_ids)
- Return 500 on ReplaceCommentAttachments failure instead of logging + 200
- Remove optional marker from onEdit attachmentIds (always passed)
- Add optimistic update for attachments in useUpdateComment
- Extract useEditAttachmentState hook from CommentRow/CommentCardImpl
- Add integration tests for attachment replacement scenarios

Part 2 — Edit-comment logic alignment:
- Add ExpandIssueIdentifiers to UpdateComment (bare identifiers now expand)
- Add handleEditMentionDiff: diff old vs new agent/squad mentions on edit,
  cancel tasks for removed mentions, enqueue tasks for added mentions,
  cancel + re-trigger when content changes but mentions are unchanged

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

* fix(sqlc): regenerate with v1.31.1 + add mention diff integration tests

Fixes sqlc version downgrade (v1.31.1 → v1.30.0) that was introduced
when the original PR was authored with a local v1.30.0 binary.
Regenerated all sqlc output with v1.31.1 to match main.

Adds integration tests for handleEditMentionDiff covering: edit adds
mention → task enqueued, edit removes mention → task cancelled, edit
changes content with same mentions → cancel + re-trigger.

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

* refactor(comments): simplify edit post-processing to cancel-all + re-trigger

Replace handleEditMentionDiff (120-line mention diff) with a simpler
model: when content changes, cancel all tasks triggered by this comment,
then re-run the same three trigger paths as CreateComment (assignee,
squad leader, mentions). Fixes gap where assignee/squad-leader tasks
were not cancelled or re-triggered on edit.

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

* refactor(comments): extract triggerTasksForComment to unify Create/Edit trigger paths

Create and Edit duplicated the same three trigger paths (assignee,
squad leader, mentioned agents). A fourth path would need changes
in two places. Extract into a shared function so the composition is:
  Create: trigger() + unresolve()
  Edit:   cancel()  + trigger()
  Delete: cancel()

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

---------

Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>
2026-05-27 14:30:41 +08:00
Bohan Jiang
0dcc35edc8 ci: split mobile lint/typecheck out of frontend job (#3346)
* ci: split mobile lint/typecheck out of frontend job

Mobile lint (~38s) + typecheck (~13s) ran on every web/desktop PR even
though mobile has no vitest suite and main CLAUDE.md already promises a
parallel mobile-verify workflow. Excluding @multica/mobile from the
frontend turbo filter pulls those 50s off the critical path, and the new
mobile-verify.yml runs them in parallel only when apps/mobile/** or
packages/core/types/** changes.

MUL-2729

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

* ci(mobile-verify): broaden path filter to cover real mobile deps

The initial filter only watched `apps/mobile/**` and
`packages/core/types/**`, but mobile imports runtime modules from many
more `@multica/core/*` paths (agents, markdown, permissions,
api/schemas, etc.). PRs that touched only those subtrees would skip
main CI (via `--filter='!@multica/mobile'`) AND skip Mobile Verify — a
coverage regression vs. the pre-split CI.

Expand paths to:
- `packages/core/**` (covers every importable subpath)
- root install/turbo configs that affect mobile build:
  `package.json`, `pnpm-lock.yaml`, `pnpm-workspace.yaml`, `turbo.json`

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

---------

Co-authored-by: J <j@multica.ai>
Co-authored-by: multica-agent <github@multica.ai>
2026-05-27 14:22:26 +08:00
Bohan Jiang
17714c3ad1 fix(create-issue): preserve parent_issue_id through Create with agent flow (MUL-2534) (#3083)
* fix(create-issue): preserve parent_issue_id through Create with agent flow (MUL-2534)

When the create-issue modal was opened from the "Add sub issue" entry on
an existing issue and the user switched to "Create with agent", the
parent_issue_id was silently dropped: switchToAgent only forwarded
prompt + actor + project_id, the AgentCreatePanel had no notion of
parent context, and the daemon prompt never instructed the agent to
pass --parent <uuid>. The sub-issue intent was lost and the new issue
landed as a standalone.

This fix threads parent_issue_id through the whole pipeline silently —
no new editable form field, the existing carry channel handles it:

- Frontend: ManualCreatePanel.switchToAgent + AgentCreatePanel.switchToManual
  now carry parent_issue_id (and identifier, for display) so the sub-issue
  intent survives mode flips in either direction. AgentCreatePanel reads
  parent from `data`, forwards to api.quickCreateIssue, and renders a
  read-only "Sub-issue of MUL-XX" chip so the user can see the relationship.
- API: quickCreateIssue accepts optional parent_issue_id.
- Backend: QuickCreateIssueRequest validates parent_issue_id belongs to the
  same workspace (same path as CreateIssue), persists it in
  QuickCreateContext, and the daemon claim handler resolves the parent's
  identifier for prompt context.
- Daemon prompt: when ParentIssueID is set, buildQuickCreatePrompt instructs
  the agent to pass `--parent <uuid>` and treat the modal entry point as
  authoritative.

Tests cover all three hops: switchToAgent carry payload, AgentCreatePanel →
api.quickCreateIssue, and the daemon prompt's --parent injection (with both
identifier-present and UUID-only fallback branches).

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

* test(create-issue): cover quick-create parent trust boundary + identifier fallback (MUL-2534)

Address review on PR #3083:

- Add server-side test for POST /api/issues/quick-create parent_issue_id:
  same-workspace parent threads through QuickCreateContext.ParentIssueID,
  foreign-workspace and bogus UUIDs return 400 and never enqueue a task.
- Fall back to `data.parent_issue_identifier` in ManualCreatePanel's
  switchToAgent when the parent detail query hasn't hydrated yet, so the
  agent chip never renders "Sub-issue of " with an empty tail.

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

---------

Co-authored-by: multica-agent <github@multica.ai>
2026-05-27 14:18:48 +08:00
Jiang Bohan
d15da372a9 chore: gitignore agent runtime caches (.agent_context/, .multica/) 2026-05-27 14:02:38 +08:00
Jiang Bohan
329fc0139d fix(create-project): anchor Source popover to top so it doesn't get pushed below the trigger when the modal is expanded 2026-05-27 13:51:20 +08:00
Bohan Jiang
341ce7bfa5 feat: support local working directory for projects (MUL-2618 v1) (#3283)
* feat(project): add local_directory project_resource type (MUL-2662)

Adds a second project_resource type alongside github_repo so a project
can be pinned to an existing directory on a specific daemon (the v1 of
the local-working-directory flow tracked in MUL-2618). The ref schema is
{ local_path, daemon_id, label? }; local_path must be absolute and
daemon_id is required. The same (daemon_id, local_path) pair is allowed
on multiple projects by design — no UNIQUE constraint is added.

Implementation reuses the existing project_resource API surface: the new
type is wired through the validator switch with no migration, no new
events, and no daemon-handler changes (daemon already passes through
arbitrary resource types via ProjectResources). The CLI gains
--local-path / --daemon-id / --ref-label shortcuts so
`multica project resource add --type local_directory` mirrors the
existing `--type github_repo --url ...` ergonomics; the generic --ref
flag still works for both types.

Tests cover the full CRUD lifecycle, the same-path-across-projects
allowance, the same-path-same-project conflict, the validator rejections
(missing/blank/relative path, missing daemon_id, wrong payload type),
and the cross-platform isAbsoluteLocalPath helper.

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

* feat(project): add update endpoint + label-shadow guard for project_resource (MUL-2662)

Addresses the Elon review on PR #3263:

- Add PUT /api/projects/{id}/resources/{resourceId} with sqlc query,
  matching handler, CLI `project resource update`, and a new
  EventProjectResourceUpdated WS event. resource_type stays immutable;
  ref/label/position are all individually optional.
- Catch same-project (daemon_id, local_path) collisions where only the
  embedded label differs — the row-level UNIQUE only matches the full
  ref JSON, so a label typo would otherwise let the same working
  directory bind twice.
- Tests cover the update lifecycle (label-only / ref / clear / 404 /
  invalid path) and the label-shadow conflict on both create and
  update; the in-place rename still succeeds because the conflict
  scan ignores the row being edited.

Incidental: regenerating sqlc picked up a missing skills_local scan in
UpdateAgentCustomEnv that drifted in from #3200.

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

* fix(project): close bundled-create label-shadow gap + merge resource_ref on CLI update (MUL-2662)

Two follow-ups from MUL-2662 review round 2:

- CreateProject inline resources path now dedupes local_directory entries on
  (daemon_id, local_path) before opening the transaction. The DB-level
  UNIQUE(project_id, resource_type, resource_ref) constraint only fires on a
  full JSON match, so two rows with the same target but different `label`
  would otherwise slip past. Standalone POST/PUT already cover this via
  findLocalDirectoryConflict; bundled create was the missing surface.
- `multica project resource update` now seeds resource_ref from the existing
  row before applying per-type shortcut flags, so `--default-branch-hint x`
  on its own no longer constructs a payload missing `url` (which the server
  400s on). Local_directory partial edits get the same merge behavior.

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

* feat(desktop): local_directory project_resource UI (MUL-2665) (#3273)

* feat(desktop): local_directory project_resource UI (MUL-2665)

First UI surface for the local-working-directory flow tracked in MUL-2618.
Lets users on the desktop pin a project to an existing folder on this
machine; web stays read-only since the per-daemon check can't be done in
the browser.

What's new for the renderer:

- ProjectResourcesSection grows a desktop-only "Add local directory"
  button next to the existing GitHub-repo popover. Clicking it opens
  Electron's native folder picker, validates the path through a new
  IPC pair (existence + r/w), and submits a project_resource of
  resource_type=local_directory with daemon_id pulled live from
  daemonAPI.getStatus.
- LocalDirectoryRow renders the rename pencil + path tooltip, and
  greys out when ref.daemon_id != this machine's daemon_id (with a
  "only available on the machine that registered this directory"
  tooltip). Delete stays enabled so users can drop stale registrations
  from any device.
- LocalDirectoryHint sits above the issue-detail comment composer and
  shows "Agent will work in-place at {label} ({path})" when the issue's
  project has a local_directory matching this daemon. Hidden on web.
- TaskStatusPill picks up a new "waiting_for_directory_release" stage
  that the daemon will publish when it dequeues a task but can't
  acquire the path lock. The render is in place now so the daemon
  sibling subtask can wire the status string without an additional UI
  PR.

Plumbing:

- @multica/core/types gains LocalDirectoryResourceRef +
  UpdateProjectResourceRequest, and the api client gets the matching
  PUT method backed by the server endpoint that landed in
  2ac3faebb (MUL-2662). A useUpdateProjectResource hook drives the
  in-place label edit.
- New Electron handlers under apps/desktop/src/main/local-directory.ts:
    local-directory:pick     -> dialog.showOpenDialog (openDirectory)
    local-directory:validate -> stat + access(R_OK + W_OK)
  exposed through the preload as desktopAPI.pickDirectory /
  validateLocalDirectory. View code talks to them via a thin
  packages/views/platform helper that returns reason=unsupported on
  web instead of crashing.
- useLocalDaemonStatus exposes the local daemon's id, device name, and
  running flag from daemonAPI.onStatusChange so the renderer can do the
  cross-device match without coupling to the desktop preload typings.

Tests:

- pickStageKeys gets a unit test covering the new stage and proving
  the directory-release status outranks availability hints.
- LocalDirectoryHint tests cover the four render branches (no project,
  no daemon, foreign daemon, matching daemon).
- i18n parity stays green; new keys added under projects.resources.*
  and chat.status_pill.stages.waiting_for_directory_release in both
  locales.

Out of scope (will land separately):
- The daemon-side waiting/lock signal that flips the pill into the
  new state.
- Adding local_directory to the create-project modal's bulk
  attach flow.
- Docs page refresh for project-resources.mdx — left for the
  MUL-2618 umbrella sweep.

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

* fix(desktop): hide rename for foreign daemon local_directory rows (MUL-2618)

Address review nit on #3273: the rename pencil was gated only by
`canEdit`, so a foreign / unknown-daemon row still showed it even
though the spec says cross-device rows are disabled. Gate rename on
`!mismatch` so it disappears on those rows; delete stays available
so a stale registration can still be dropped from any device.

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

---------

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

* feat(daemon): local_directory execution + path mutex + GC exception (MUL-2663) (#3274)

* feat(daemon): local_directory execution + path mutex + GC exception (MUL-2663)

Wires up the daemon side of the local_directory project_resource introduced
in MUL-2662. When a task is dispatched against a project whose resources
include a local_directory pinned to this daemon's UUID, the daemon now:

  - Validates the path (absolute, exists, daemon process can read+write,
    not in the system-root / $HOME blacklist) and fails the task fast on
    any precondition violation, with a user-readable reason.
  - Serialises concurrent tasks on the same on-disk path via a
    daemon-local LocalPathLocker keyed by symlink-resolved realpath. The
    lock is held for the entire task lifetime (claim → context write →
    agent → result report).
  - When the lock is contended, the daemon flips the row to a new
    waiting_local_directory status on the server (carrying a wait_reason
    like "<path> (held by task <short id>)") so the UI can render
    "等待本地目录释放" instead of leaving the row silently in dispatched
    past the sweeper timeout. The status accepts being woken into running
    once the lock is acquired.
  - Sets execenv.WorkDir to the user's path (no copy, no mount). envRoot
    still lives under workspacesRoot/<wsID>/ and hosts output/, logs/, and
    .gc_meta.json — the daemon's logbook for the run.
  - Stamps GCMeta.LocalDirectory=true so the GC loop never RemoveAlls
    envRoot for these tasks (gcActionClean → gcActionCleanArtifacts,
    gcActionOrphan → gcActionSkip). The user's directory was never under
    envRoot to begin with, so this is defense in depth.
  - Skips execenv.Reuse for local_directory tasks because the prior
    WorkDir is the user's path and reusing it through that code path
    loses the envRoot association the GC loop needs. Prepare is cheap
    here (no clone, no copy), so always running it is fine.

Server-side protocol changes:

  - New CHECK value 'waiting_local_directory' on agent_task_queue.status
    plus a wait_reason TEXT column (migration 109).
  - All cancel / active / counted-as-running / orphan-recovery queries
    expanded to include the new status; FailStaleTasks intentionally
    excludes it (the daemon owns the wait).
  - New SQL MarkAgentTaskWaitingLocalDirectory(id, reason) and a relaxed
    StartAgentTask that accepts both dispatched and
    waiting_local_directory as preconditions (and clears wait_reason on
    the way through).
  - New POST /api/daemon/tasks/{taskId}/wait-local-directory endpoint,
    TaskService.MarkTaskWaitingLocalDirectory broadcaster, and matching
    daemon Client.MarkTaskWaitingLocalDirectory.

Tests cover: path blacklist + R/W enforcement, mutex serialisation +
ctx-cancelled wait, lock handover between two tasks, GC never returns
gcActionClean / gcActionOrphan for local_directory rows (with negative
control for the standard path), and Prepare/Cleanup correctly substitute
+ protect the user's WorkDir.

The desktop UI side (UI for adding a local_directory resource, surfacing
the "等待本地目录" badge) is MUL-2665; the agent-task lifecycle changes
(no branch switch, dirty-tree tolerant, auto-commit) are MUL-2664.

This PR targets the shared MUL-2618 v1 feature branch agent/j/912b8cb1,
not main; the whole v1 will be merged to main together when complete.

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

* fix(daemon): tighten local_directory status, symlink, cancel handling (MUL-2618)

Address the 3 must-fix items from Elon's review of PR #3274.

1. Status string unified. The server / daemon publish
   `waiting_local_directory`; align views, locales, and the
   pickStageKeys test (PR #3273 had used `waiting_for_directory_release`
   on a placeholder string). Without this, the daemon's wait state
   never reached the pill once the two siblings merged.

2. validateLocalPath now also runs the blacklist against the
   symlink-resolved realpath, with macOS's `/etc` -> `/private/etc`
   redirect handled via `isBlacklistedRealPath` which compares
   canonical forms. Without this, a symlink such as
   `/Users/me/proj/home -> /Users/me` slipped the literal $HOME check
   while every daemon write still landed in the user's home. Tests
   cover symlink-to-home, symlink-to-system-root, and the negative
   case (symlink to a regular subdirectory).

3. acquireLocalDirectoryLockIfNeeded now spins up a cancellation
   watcher inside `onWait` (lazy — the fast path stays free) so the
   gap between dispatch and StartTask responds to server-side cancel
   or row deletion. If the watcher fires while the daemon is parked
   on the path mutex, the lock-wait context is cancelled, Acquire
   returns promptly, and the helper exits silently the same way the
   run-phase poller does. New TestAcquireLocalDirectoryLock_CancelDuringWait
   exercises the path end-to-end with a fake server.

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

* fix(daemon): unconditional canonical blacklist + Windows drive-root generalisation (MUL-2618)

- validateLocalPath now always runs isBlacklistedRealPath on the
  symlink-resolved path, not only when it differs from absPath. The old
  guard let users type the canonical form of an OS-symlinked banned root
  (e.g. /private/tmp, /private/etc, /private/var on macOS) straight
  through, since EvalSymlinks is a no-op on already-canonical input.
- Windows drive-root rejection moved off the static C/D/E/F enumeration
  onto filepath.VolumeName via a new isDriveRoot helper, so removable /
  network drives mounted at G:..Z: and UNC \\server\share roots are also
  blocked. systemRootBlacklist keeps the well-known C:\ trees only.
- Tests: macOS-only case exercises direct /private/{tmp,etc,var}; a
  new TestIsDriveRoot covers the Windows generalisation (skipped on
  POSIX runners by runtime guard).

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

---------

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

* feat(views): wire waiting_local_directory end-to-end in issue UI + presence (MUL-2618)

Connect the daemon-emitted `task:waiting_local_directory` and `task:running`
events through to issue execution log, sticky agent banner, activity indicator,
and agent presence so a parked task is no longer invisible on the issue page.

- Add `waiting_local_directory` to `AgentTask.status` and the typed
  `task:running` / `task:waiting_local_directory` WS event payloads.
- Chat realtime sync writes both new statuses into the pending-task cache so
  the chat StatusPill flips out of a stale `dispatched` frame.
- ExecutionLogSection: count `waiting_local_directory` as active, add tone +
  status label, treat parked tasks the same as dispatched for time anchor /
  transcript visibility / terminate-confirm note.
- AgentLiveCard: subscribe to both new events, rank the parked state between
  dispatched and queued, and surface a "is waiting for the local directory"
  banner with the muted "Clock" treatment used for queued.
- IssueAgentActivityIndicator: route parked tasks into the queued bucket so
  the hover stack and chip stay visible.
- derive-presence: parked tasks count toward `queuedCount` so the agent
  workload chip stays out of `idle` while the daemon waits on the path lock.
- Locales: add `agent_live.is_waiting_local_directory` and
  `execution_log.status_waiting_local_directory` (en + zh-Hans).

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

* feat(project): enforce one local_directory per (project, daemon) (MUL-2618)

The daemon-side resolver picks the first matching local_directory by
daemon_id, so allowing two rows on the same daemon — even at different
paths — let the agent silently write into whichever sorted first. Tighten
the invariant top to bottom:

- server: `findLocalDirectoryConflict` rejects any second row sharing a
  daemon_id, regardless of `local_path` or label. Bundled-create surface in
  `CreateProject` runs the same daemon-scoped dedupe up front.
- daemon: `findLocalDirectoryAssignment` fails fast when it finds more than
  one row pinned to the current daemon (older API client / direct DB
  writes can still produce that state — refuse to guess).
- desktop UI: hide the "Add local directory" action once the current
  daemon owns a row on this project, with a hint and a defensive toast on
  the call path; foreign-daemon rows stay visible read-only as before.
- Tests:
  * daemon: new `two local_directory rows on this daemon fail fast` /
    `local_directory rows on different daemons coexist` cases.
  * handler: rewrite the legacy `LabelShadow` cases as
    `DaemonScopedConflict` / `BundledLocalDirectoryDaemonConflict` —
    asserts 409 on same-daemon different-path, 201 on per-daemon bundles.
- Locales: en + zh-Hans copy for the new hint + toast.

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

* chore(sqlc): drop stale skills_local in UpdateAgentCustomEnv (MUL-2618)

Follow-up to the main-merge in 0f8e8ca7: the auto-merge preserved most
of main's skills_local revert but kept the column reference inside the
UpdateAgentCustomEnv scanner because that block hadn't been touched by
either side. Re-running `sqlc generate` regenerates the file without
skills_local in this query, matching the rest of the file and the
post-revert schema.

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

* feat(create-project): binary source picker — repos OR local directory

Turn the create-project dialog's "Repos" pill into a binary Source
picker. A project's source is mutually exclusive: either a set of
GitHub repos (worktree mode, default) or a single local working
directory (local mode, desktop-only). Mirrors the constraint the
backend will enforce next.

Behavior:
- Pill shows the active mode's selection (GitHub icon + repo count, or
  folder icon + local label/path).
- Popover has a 2-tab segmented control at the top; the Local tab is
  hidden entirely on web (local_directory needs a daemon_id).
- Local tab requires the daemon online — amber notice + disabled picker
  when offline, re-renders automatically via useLocalDaemonStatus.
- Switching tabs preserves the other side's stash, but handleSubmit
  only emits the resource matching the active sourceMode, so abandoned
  picks never leak into the created project.

Backend mutual-exclusion validation + the resources-section
conditional-add-button still to come — this PR just unblocks the
dialog so it can be demoed.

* fix(mobile): cover waiting_local_directory in run row status maps (MUL-2618)

---------

Co-authored-by: multica-agent <github@multica.ai>
Co-authored-by: Multica J <j@multica.ai>
2026-05-27 13:44:31 +08:00
Naiyuan Qing
2af2f7b3e4 fix(comments): defer input clearing until submit succeeds (#3344)
clearContent() and setIsEmpty() were called before await onSubmit(),
causing permanent content and draft loss on network failure. Move both
to the success path, consistent with attachment and draft cleanup.

Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>
2026-05-27 13:42:22 +08:00
DENG
7bc1aa7563 fix(daemon): detect Codex Desktop bundle CLI (#3332)
Co-authored-by: codex <codex@multica.local>
2026-05-27 13:39:54 +08:00
Bohan Jiang
b343e13ec4 fix(settings): remove orphan repo count from GitHub tab shortcut card (#3342)
The bare "N" under the Repositories shortcut had no label and was
adjacent to a sentence ("Repository URLs live in the Repositories
tab") that has no semantic link to a number, so users read it as a
typo. The card is a navigation shortcut, not a status panel — the
actual count is visible after clicking through.

MUL-2725

Co-authored-by: J <j@multica.ai>
Co-authored-by: multica-agent <github@multica.ai>
2026-05-27 13:32:21 +08:00
Bohan Jiang
668fe99cce fix(cli): drop "Showing N comments." stderr preamble on issue comment list (#3341)
This was the only `list` subcommand that printed a human-readable count
to stderr. Consumers that merge stdout/stderr (agent harnesses, CI
`2>&1`) saw it interleaved with the JSON array on `--output json`, and
in table mode it carried no information the table itself didn't.

The `Next thread cursor` / `Next reply cursor` lines stay — they're
real paging signals the agent runtime reads from stderr.

Closes #3303
MUL-2709

Co-authored-by: J <j@multica.ai>
Co-authored-by: multica-agent <github@multica.ai>
2026-05-27 13:08:03 +08:00
Naiyuan Qing
6261e2b6b2 fix(comments): clear editor immediately on submit to eliminate WS race glitch (#3331)
* fix(comments): clear editor immediately on submit to eliminate WS race visual glitch

The comment editor stayed populated while WebSocket delivered the new
comment faster than the HTTP response, causing a "duplicate comment"
flash. Move clearContent/setIsEmpty before the await so the editor clears
at click time. Also remove dead `submitting` state in useIssueTimeline
(redundant with the input components' own guards) and dead `isTemp` logic
in comment-card (no code path ever creates temp- prefixed entries).

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

* fix(comments): preserve attachments on submit failure and fix CommentRow indentation

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

---------

Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>
2026-05-27 10:39:21 +08:00
李冠辰
963ed5cd0e feat(comments): allow selecting multiple attachments 2026-05-27 10:13:27 +08:00
YOMXXX
7d24a8594a fix(comments): support edit-time attachment removal (#2965) 2026-05-27 09:48:59 +08:00
Naiyuan Qing
730fb61f4a fix(views): keep sort label centered in viewport during board scroll (#3325)
The "Board ordered by" overlay used absolute positioning inside a
scrollable container, causing it to drift with scroll content. Move
the overlay outside the scroll area into a non-scrolling wrapper so
it stays centered in the visible viewport.

Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>
2026-05-27 09:00:08 +08:00
Jiayuan Zhang
e55f050b84 fix(i18n): clean up zh-Hans translation inconsistencies (#3308)
- Normalize nav/page/section titles to plural English (Issues/Skills/Tasks) per conventions.zh.mdx rules for section titles
- Lowercase 'Issue' inside UI short phrase '我的 Issue' (UI short-phrase rule)
- Translate concept words in GitHub settings (Connection/Features/Repositories/Done)
- Translate 'Cloud Runtime' to '云端运行时' to match runtime→运行时 glossary

Co-authored-by: Lambda <lambda@multica.ai>
Co-authored-by: multica-agent <github@multica.ai>
2026-05-27 00:11:48 +08:00
Bohan Jiang
fa2a0e57ec feat(views): swimlane supports parent / project / assignee grouping (MUL-2711) (#3311)
* feat(views): swimlane supports parent / project / assignee grouping (MUL-2711)

The swimlane view was hard-coded to group by parent issue. This adds a
display dropdown so users can pick parent (default), project, or
assignee — analogous to how the board view exposes its grouping option.

- Generalise the lane builder in swimlane-view.tsx behind a `LaneGroup`
  abstraction (matcher + per-grouping `moveUpdates` payload) so the
  drag-end handler no longer branches on grouping. Cell ids gain a
  `<grouping>:<rawId>` prefix and lane sortable ids include the
  grouping so dnd-kit cannot collide entries from different groupings.
- Extend the view store with `swimlaneGrouping`, `swimlaneOrders` (one
  saved order per grouping), and a grouping-keyed `collapsedSwimlanes`.
  The persist `merge` defends against the old `string[]` shape so a
  pre-upgrade snapshot doesn't crash on first read.
- Wire `setSwimlaneGrouping` into the issues display popover next to
  the existing board grouping control. Add en / zh-Hans copy for the
  three swimlane buckets (Parent issue / Project / Assignee) and the
  two new pinned lanes (No project / Unassigned).
- Expand swimlane tests with parent / project / assignee smoke cases
  and update existing mocks to the new lane-id format. Add stable
  `useActorName` / `projectListOptions` mocks to avoid the
  set-state-in-effect loop that an unstable `getActorName` would
  trigger via the cells-rebuild memo.

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

* feat(views): default swimlane grouping to assignee

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

---------

Co-authored-by: J <j@multica.ai>
Co-authored-by: multica-agent <github@multica.ai>
2026-05-26 22:23:14 +08:00
Bohan Jiang
df02fcf175 fix(cli): show real MEMBERS count in multica squad list (#3307)
The MEMBERS column was hardcoded to "-" in the table output, so every
squad looked empty even though the backend already returns
`member_count` (and `member_preview`) on each row. `squad get --output
json` exposed the correct data, which is why the bug was cosmetic but
confusing.

Read `member_count` from the response and render it; fall back to "-"
when missing or zero so empty squads stay visually distinct.

Fixes #3304 (MUL-2706).

Co-authored-by: J <j@multica.ai>
Co-authored-by: multica-agent <github@multica.ai>
2026-05-26 20:11:22 +08:00
Bohan Jiang
298f54c819 fix(agents): gate on_comment trigger with private-agent visibility (MUL-2702) (#3302)
Closes #3300.

After #2359 added canAccessPrivateAgent to chat, @mention, ListAgents,
GetAgent, history, edit, delete and issue assignment, one trigger path
was missed: shouldEnqueueOnComment. Once an owner/admin assigned a
private agent to an issue, the agent's UUID was "welded" onto that
issue and any workspace member who could view the issue could dispatch
a new task to it by posting a plain (non-@mention) comment — bypassing
the visibility gate the #2359 work was supposed to enforce.

Mirror the @mention path: plumb (authorType, authorID) from
CreateComment into shouldEnqueueOnComment, load the assigned agent, and
gate it with canAccessPrivateAgent before enqueueing. Add a Go
regression test on the existing privateAgentTestFixture covering the
plain-member, agent-owner, workspace-owner and agent-to-agent cases.

Co-authored-by: multica-agent <github@multica.ai>
2026-05-26 18:58:06 +08:00
Multica Eve
311cf4d998 fix(agent): surface Codex app-server no-progress diagnostics (MUL-2688)
Refs #3262.
2026-05-26 18:42:47 +08:00
Bohan Jiang
735f18a4ef fix(agents): drop the import-hint callout on agent Skills tab (again) (#3301)
#3265 already removed this blue "Importing creates a workspace copy..."
banner, but #3286 (the skills_local toggle revert) brought it back as
collateral. Re-remove it — this tab isn't where skill imports happen
(that lives behind Skills page → Add Skill → From Runtime), so the
callout is pure noise here.

Also flip the header row back to items-center now that the intro is
once again the only thing in it.
2026-05-26 18:41:25 +08:00
Multica Eve
26ff52385b fix: attribute Hermes usage to current model (MUL-2696)
Fix Hermes ACP usage attribution to current model when agent.model is unset.

Also preserves cache-read token accounting and makes ACP model-list parsing more tolerant of snake_case payloads and Unknown display names.
2026-05-26 18:13:28 +08:00
Multica Eve
7e37461075 docs: add 2026-05-26 changelog entry (#3293)
* docs: add 2026-05-26 changelog entry

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

* docs: refine 2026-05-26 changelog entry

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

---------

Co-authored-by: Eve <eve@multica-ai.local>
Co-authored-by: multica-agent <github@multica.ai>
2026-05-26 18:04:01 +08:00
LinYushen
83ecad4789 MUL-2690 fix(deps): force @xmldom/xmldom to ^0.8.13 via pnpm overrides (#3290)
Pin @xmldom/xmldom to ^0.8.13 in `pnpm.overrides` so every transitive
resolution (currently @expo/plist@0.5.3 and plist@3.1.0, both pulled
through expo) ships a patched build. All four lockfile entries move
from 0.8.12 to 0.8.13.

Closes the four high-severity advisories pnpm audit reports against
the prior 0.8.12 resolution:
- GHSA-2v35-w6hq-6mfw — uncontrolled recursion in serialization (DoS)
- GHSA-f6ww-3ggp-fr8h — XML injection via DocumentType serialization
- GHSA-x6wf-f3px-wcqx — node injection via processing-instruction
- GHSA-j759-j44w-7fr8 — node injection via comment serialization

Using `pnpm.overrides` (not a root direct dep) keeps the transitive
fix scoped to the dependency graph and avoids implying that the
multica codebase consumes xmldom directly.

Verification: `pnpm audit --prod --audit-level high` no longer lists
any @xmldom/xmldom advisories on this branch.

Co-authored-by: multica-agent <github@multica.ai>
2026-05-26 17:15:36 +08:00
Naiyuan Qing
30a7da6d10 feat(list): drag-and-drop reordering + loadMore sort fix (#3284)
* feat(list): drag-and-drop reordering + shared drag utils

- List view drag-and-drop: reorder within/across status groups, drop into
  collapsed groups, DraggableListRow with useSortable + checkbox protection
- Extract shared drag utilities (computePosition, findColumn, buildColumns,
  makeKanbanCollision, issueMatchesGroup, getMoveUpdates) to drag-utils.ts
- Reuse IssueDisplayControls in MyIssues header (dedup ~380 lines)
- Radio check marks for grouping/sort dropdowns
- Remove outer p-1 wrapper, unify board view to p-2

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

* fix(my-issues): move MyIssuesHeader inside ViewStoreProvider

IssueDisplayControls calls useViewStore which requires the provider context.

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

* fix(header): restore swimlane option in view toggle dropdown

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

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-26 17:09:42 +08:00
Multica Eve
744b474199 revert(agent): remove per-agent local skill toggle (MUL-2603) (#3286)
* Revert "feat(agents): hide skills_local toggle for runtimes that don't honour it (MUL-2603) (#3276)"

This reverts commit 0b50c5a209.

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

* Revert "fix(agent): surface host OAuth token via env var on macOS isolation (MUL-2603) (#3267)"

This reverts commit a67bf81225.

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

* Revert "fix(agents): tighten skills-tab intro and drop redundant import hint (#3265)"

This reverts commit d8075a5775.

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

* Revert "fix(agent): mirror $HOME/.claude.json into isolated config dir (MUL-2661) (#3261)"

This reverts commit 40da88fc16.

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

* Revert "feat(agent): per-agent toggle to isolate host-machine skills (MUL-2603) (#3200)"

This reverts commit 960befa56f.

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

* Add migration cleanup for reverted agent skills toggle

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

---------

Co-authored-by: Eve <eve@multica-ai.local>
Co-authored-by: multica-agent <github@multica.ai>
2026-05-26 17:00:01 +08:00
Bohan Jiang
ae11f290b4 fix(server): gate GitHub auto-close on closing keywords (MUL-2680) (#3281)
* fix(server): gate GitHub auto-close on closing keywords (MUL-2680)

Closes multica-ai/multica#3264. The PR webhook previously treated any
mention of an issue identifier in a PR title/body/branch as a close
intent, so a body of "Closes MUL-1. Follow up in MUL-2. Unblocks MUL-3."
would advance all three issues to done on merge. The auto-link layer
stays generous (mentions still link the PR), but advancing to done now
requires an explicit "Closes/Fixes/Resolves MUL-X" keyword adjacent to
the identifier in the title or body — bare title prefixes (`MUL-1: ...`)
and branch-name references no longer auto-complete.

MUL-2680

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

* fix(server): persist close_intent on issue↔PR link rows (MUL-2680)

The first take of MUL-2680 gated auto-advance on `closingIdents[id]` from
the current webhook event. That broke the multi-PR sibling case: a PR
declaring `Closes MUL-X` could merge first while a link-only sibling
stayed open, leaving the issue in_progress; when the sibling closed
later, its webhook carried no closing keyword and the handler skipped
re-evaluation, so the issue stayed stuck forever.

Move close intent from per-event state to per-link state:

- New `close_intent` column on `issue_pull_request` (migration 109),
  set monotonically — `LinkIssueToPullRequest` ORs the existing flag with
  the incoming one so a subsequent webhook re-fire without the keyword
  cannot clear it.
- New `GetIssuePullRequestCloseAggregate` query returns open-count and
  merged-with-close-intent-count for an issue. The auto-advance gate
  now reads from this persisted aggregate, which is event-agnostic: any
  terminal linked-PR event re-evaluates and the verdict only depends on
  accumulated DB state.
- Webhook handler links all mentioned identifiers first (writing
  close_intent for the ones declared with a keyword), then iterates the
  affected issues in a separate pass to re-evaluate. The 'only fires for
  keyword-declared identifiers in this event' gate is gone — replaced by
  `merged_with_close_intent_count > 0` against the link rows.

Regression test `TestWebhook_LinkOnlySiblingMergeAfterCloseKeywordPR`
walks the full open→merge→open→merge sequence Elon described and asserts
the issue advances on the link-only sibling's merge.

MUL-2680

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

* Fix GitHub close intent updates

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

---------

Co-authored-by: multica-agent <github@multica.ai>
Co-authored-by: Eve <eve@multica-ai.local>
2026-05-26 16:45:46 +08:00
Bohan Jiang
057cb2ff34 fix(issues): thread sort through load-more so appended pages render (MUL-2678) (#3279)
The recent server-side-sort change (#3228) keyed the issue-list cache by
sort but did not update the load-more hooks: useLoadMoreByStatus used a
prefix-match that could pick a stale cache variant, and neither hook
forwarded sort to the API request. As a result, scroll-to-load-more
fired its request, but the response was either appended to a cache no
useQuery was subscribed to, or it appended rows in an unsorted order
into a sorted bucket.

Pass `sort` explicitly through Board/List/Swimlane and into the hooks.
The hook now targets the full sorted key via setQueryData and forwards
sort to the listIssues / listGroupedIssues calls so the appended page
lines up with the existing items.

Also adds focused tests for both load-more hooks: stale-sort cache is
untouched, sort is forwarded to the API, and sort-less callers still hit
the {} key path used by actor-issues-panel.

Co-authored-by: multica-agent <github@multica.ai>
2026-05-26 16:27:33 +08:00
Bohan Jiang
0b50c5a209 feat(agents): hide skills_local toggle for runtimes that don't honour it (MUL-2603) (#3276)
* feat(agents): hide skills_local toggle for runtimes that don't honour it (MUL-2603)

Only Claude Code and Codex runtimes actually enforce `skills_local` at exec
time today — Claude isolates `~/.claude/skills/` via `CLAUDE_CONFIG_DIR`,
Codex isolates `~/.codex/skills/` via per-task `CODEX_HOME`. Every other
runtime currently stores the field but treats it as a no-op, which made
the toggle in the Create Agent dialog and Skills tab misleading for those
runtimes.

Gate the toggle on `runtime.provider` so it only renders for the providers
the daemon currently isolates. Centralise the supported-provider list as
`isSkillsLocalSupportedProvider()` in `packages/core/agents` and reuse it
from the create dialog and the Skills tab. The create dialog also drops
`skills_local` from the payload when the selected runtime is unsupported,
so a runtime swap can't leave a stale `ignore` opt-in pinned where it
would never take effect.

Docs (EN + ZH) updated to say the toggle is hidden — not just "a no-op" —
for the unsupported runtimes.

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

* docs(agents): align skills_local hint and type comment with claude+codex boundary

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

---------

Co-authored-by: multica-agent <github@multica.ai>
2026-05-26 16:09:51 +08:00
Bohan Jiang
46f7ac6bac docs(self-host): document task_usage_hourly rollup requirement (MUL-2682) (#3280)
The Usage / Runtime dashboards read from `task_usage_hourly`, but the
default self-host stack does not schedule `rollup_task_usage_hourly()`
anywhere — the bundled pgvector/pgvector:pg17 image ships without
pg_cron, and the backend does not run the rollup in-process. Fresh
installs see the dashboard stay at zero forever (#3244), and upgrades
from v0.3.4 → v0.3.5+ are blocked by migration 103's fail-closed guard
(#3015).

Document the three supported paths (external cron / systemd-timer /
CronJob, Postgres with pg_cron, or backfill_task_usage_hourly for
upgrades) across SELF_HOSTING.md, SELF_HOSTING_ADVANCED.md, the
quickstart pages on the docs site, and add troubleshooting entries
for both the silent-zero and the migration-guard failure modes.

Co-authored-by: multica-agent <github@multica.ai>
2026-05-26 16:03:14 +08:00
Bohan Jiang
91506e7f7b refactor(cli): rename daemon status helper and align value column (MUL-2676) (#3275)
- Rename printDaemonStatusTable -> printDaemonStatusReport. The helper
  emits a key/value list, not a table; the old name implied a tabular
  layout that never existed and made the call site read wrong.

- Align the value column dynamically off the widest key. Previously the
  spacing was hard-coded so the static rows (Version/Agents/Workspaces)
  all landed at column 14, but the dynamic "Daemon [profile]" label
  could outgrow that and push only its own value rightward, breaking
  vertical alignment as soon as a profile was active.

- Add negative coverage for cli_version absent / empty (the real
  back-compat contract for older daemons paired with a newer CLI) and a
  test that asserts the value column lines up under a long profile
  label.

Co-authored-by: multica-agent <github@multica.ai>
2026-05-26 15:34:33 +08:00
Bohan Jiang
a67bf81225 fix(agent): surface host OAuth token via env var on macOS isolation (MUL-2603) (#3267)
* fix(agent): surface host OAuth token via env var on macOS isolation (MUL-2603)

Claude Code 2.x scopes the macOS keychain credentials entry by
sha256(CLAUDE_CONFIG_DIR)[:8], so the MUL-2603 isolation path strands
the child at "Not logged in" even after #3261 mirrored .claude.json:
the child looks up `Claude Code-credentials-<scratch-hash>`, the host
token is sitting in the no-suffix `Claude Code-credentials` entry.

Read the host OAuth token from the keychain via /usr/bin/security and
inject it as CLAUDE_CODE_OAUTH_TOKEN, which bypasses keychain lookup
entirely. Linux/Windows continue to use the .credentials.json mirror
(no-op there). Operator-pinned tokens and ANTHROPIC_API_KEY both take
precedence over the keychain reader.

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

* fix(agent): tighten empty-value auth gate, pin Claude CLI env-scrub assumption (MUL-2603)

Empty-value gate
  - `ANTHROPIC_API_KEY=` inherited from a login shell that conditionally
    exports auth previously posed as an "operator pinned API-key auth"
    choice and disabled the keychain reader, stranding the isolated child
    at "Not logged in" even though no auth was actually selected.
  - Custom_env `CLAUDE_CODE_OAUTH_TOKEN=""` (stale agent config) had the
    same effect, plus would have shadowed a keychain-injected token in
    libc env lookups that pick the first match.
  - Both are now treated as noise: the empty entry is dropped from the
    child env and the keychain reader runs unchanged. Two new unit tests
    cover the os.Environ side (`...TreatsEmptyAnthropicAPIKeyAsUnpinned`,
    `...HonorsNonEmptyAnthropicAPIKey`) and the custom_env side
    (`...EmptyOAuthTokenInCustomEnvAsUnpinned`).

Env-scrub boundary
  - Surfacing `CLAUDE_CODE_OAUTH_TOKEN` to the isolated child is only
    safe because Claude Code itself drops that variable from the env it
    hands to Bash / hook subprocesses, so a model-driven `printenv` can
    never echo the secret into the agent transcript.
  - Empirically verified against `claude` 2.1.121:
        printf '...test -n "$CLAUDE_CODE_OAUTH_TOKEN" && echo SET || echo UNSET...' \
            | CLAUDE_CODE_OAUTH_TOKEN=sk-canary-XYZ \
              MUL2603_CONTROL=control-value \
              claude --print --output-format text \
                     --allow-dangerously-skip-permissions --allowedTools Bash
    returned `UNSET` for the OAuth token while the non-sensitive
    `MUL2603_CONTROL` control returned `CONTROL-SET`, proving the CLI
    scrubs only the auth env, not the env in general.
  - Pinned this assumption in a new skip-gated regression test
    (`TestClaudeCLIScrubsOAuthTokenFromBashSubprocess`) that boots the
    real CLI with a canary token; failing the test means upstream
    Claude Code stopped scrubbing and the passthrough must move off env
    vars before MUL-2603 can ship.

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

* fix(agent): gate keychain passthrough on default host dir, harden scrub test (MUL-2603)

Two follow-ups from the round-2 review on #3267:

1. Custom CLAUDE_CONFIG_DIR no longer pulls the default OAuth token.
   Claude Code 2.x maps each config dir to its own suffixed
   `Claude Code-credentials-<hash>` keychain entry, so an operator that
   pins a managed/custom CLAUDE_CONFIG_DIR via custom_env or the
   daemon-host env was getting the *daemon user's* default unsuffixed
   entry injected into the isolated child — silently crossing accounts,
   exactly the boundary mirrorHostClaudeJSONIfMissing already protects
   for `.claude.json`. buildClaudeEnvWith now threads the effective
   hostConfigDir through and only calls the reader when that dir is the
   default `$HOME/.claude`. The new gate has a unit-level truth table
   (TestIsDefaultHostClaudeConfigDir) plus a regression
   (TestBuildClaudeEnvIsolatedSkipsKeychainForCustomHostConfigDir) that
   makes a t.Fatal-armed reader prove the gate keeps the read off for
   custom dirs.

2. Scrub e2e now asserts the control prong and the proof-of-execution
   marker, not just "canary absent". The previous assertion would
   false-pass on a model refusal, paraphrase, or "Bash gets no env at
   all" upstream change. The strengthened version sets a non-secret
   MUL2603_CONTROL alongside the canary OAuth token and asserts (a)
   canary is NOT in the transcript, (b) CONTROL-SET IS in the
   transcript (env propagation works for non-secrets — proves a
   targeted scrub), (c) UNSET IS in the transcript (the Bash tool
   actually ran AND saw the OAuth var as empty/unset). Code comment in
   buildClaudeEnvWith and the test docstring now narrow the
   security contract to the Bash tool subprocess only; hook subprocess
   env-scrub is no longer claimed because it has not been verified.

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

* test(agent): use per-run nonces in Claude scrub e2e to kill false-pass (MUL-2603)

Elon's round-3 review flagged that TestClaudeCLIScrubsOAuthTokenFromBashSubprocess
still false-passed: the proof markers "UNSET" / "CONTROL-SET" were literal
strings in the prompt, so strings.Contains matched them even when the model
only paraphrased the prompt without spawning Bash.

Replace the hard-coded markers with two per-run random hex nonces passed *only*
via env vars (MUL2603_UNSET_NONCE, MUL2603_CONTROL_NONCE). The prompt now
references the variable names, not the values, so the nonces can land in the
transcript only if a real Bash subprocess inherits the env vars and echoes
them. A paraphrasing or refusing model cannot fake nonces it never saw.

Also update the security-boundary comment in buildClaudeEnvWith to describe
the nonce-based proof.

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

---------

Co-authored-by: multica-agent <github@multica.ai>
2026-05-26 15:29:58 +08:00
Nerilo
382e294e8c Show CLI version in daemon status (#3212)
Co-authored-by: Coresen <158120130+iCoresen@users.noreply.github.com>
Co-authored-by: multica-agent <github@multica.ai>
2026-05-26 15:27:58 +08:00
Anderson Shindy Oki
65ed5744a6 feat: add issue view swimlane (MUL-2607) (#3149)
* feat: Add swimlane view

* refactor

* test: add tests

* wip

* wip

* wip

* refactor

* refactor and fixes

* refactor

* fix: parent empty cells

* chore: Remove meanless comments

* remove meanless comments
2026-05-26 15:22:16 +08:00
Bohan Jiang
77c7267105 fix(server): trigger assignee on agent-driven backlog→active (MUL-2670) (#3270)
* fix(server): trigger assignee on agent-driven backlog→active (MUL-2670)

The backlog→active transition was gated on `actorType == "member"`, which
silently dropped agent-driven promotions and broke the documented serial
sub-task workflow — a parent agent finishing Step 1 and promoting Step 2
from backlog→todo would never fire Step 2's assignee.

Replace the member-only gate with a self-promotion guard. Agent actors
now fire the same enqueue path as members; the only excluded case is an
agent promoting an issue assigned to itself (which would self-loop on
every run). Applied to both UpdateIssue and BatchUpdateIssues.

Adds two integration tests covering the documented serial-chain case and
the self-loop guard.

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

* fix(server): scope backlog→active self-loop guard to the calling task's issue

The previous agent-id-only guard over-blocked same-agent serial chains:
if Agent A finished a task on issue I1 and promoted issue I2 from
backlog→todo, the promotion was silently dropped whenever I2 was also
assigned to A. Only the cross-agent handoff worked.

Replace the actor-vs-assignee check with a task-vs-issue check:
isAgentRunningOnIssue looks up the calling X-Task-ID and only blocks
when that task's issue_id matches the issue being promoted (the true
self-loop). Member actors and same-agent cross-issue promotions now
fire, including via BatchUpdateIssues.

Tests:
- TestBacklogToTodoByAgentSameIssueDoesNotSelfTrigger (true self-loop)
- TestBacklogToTodoByAgentSameAgentDifferentIssue (serial chain works)
- TestBatchBacklogToTodoByAgentTriggersAssignee (batch path)
- TestBacklogToTodoByAgentTriggersSquadLeader (squad branch)

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

* test(server): seed running task in handler test helper to avoid collisions

createHandlerTestTaskForAgentOnIssue inserted with status='queued',
which broke two tests added by the same-issue self-loop guard:

- TestBacklogToTodoByAgentSameIssueDoesNotSelfTrigger asserted
  `count(*) WHERE status='queued'` was 0, but the seeded task itself
  showed up in the count → got 1.
- TestBacklogToTodoByAgentSameAgentDifferentIssue seeded a task for
  the same (issue_id, agent_id) as step1's auto-enqueued queued task,
  tripping idx_one_pending_task_per_issue_agent.

X-Task-ID semantically belongs to a currently-running task. Inserting
the seed with status='running' (and started_at=now()) keeps it outside
both the unique index and the queued-count assertions, so the tests
verify only what the handler does in response to the agent-driven
backlog→active promotion.

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

---------

Co-authored-by: multica-agent <github@multica.ai>
2026-05-26 15:20:08 +08:00
Wington Brito
88fe6d754f docs(cli): list valid statuses in issue status --help (#3239)
multica issue status --help only documents <status> as a required
positional. Users have to discover the valid set via trial-and-error
(triggering 'Error: invalid status "X"; valid values: ...').

Add a Long description that lists the 7 valid statuses inline:
backlog, todo, in_progress, in_review, done, blocked, cancelled.

Pure docs change; no behavior changes.

Co-authored-by: Wington Brito <4412238+wingtonrbrito@users.noreply.github.com>
2026-05-26 15:07:27 +08:00
Bohan Jiang
e6324aad6c fix(server/child-done): require sibling dependency check before promoting backlog (MUL-2618) (#3272)
The previous system-comment wording ("promote any waiting `backlog`
sub-issues") let a planner agent flip every backlog sibling to `todo` on
the first child-done signal, ignoring per-sibling stated dependencies.
Tighten the prompt so the agent must read each sibling's description,
only promote items whose dependencies are satisfied, and leave the
status alone (and comment to confirm) when the parent's higher-level
breakdown conflicts with what a sibling lists as a prerequisite.

This is the short-term mitigation; a structured `blocked_by` edge is
out of scope here and will be designed separately.

Co-authored-by: multica-agent <github@multica.ai>
2026-05-26 15:04:24 +08:00
LinYushen
bf8a346cf0 feat(runtimes): cascade-archive agents on runtime delete (MUL-2667) (#3266)
* feat(runtimes): cascade-archive agents on runtime delete (MUL-2667)

Replace the bare 409 "cannot delete runtime: it has active agents" with a structured response carrying the blocking agent list, and wire a cascade endpoint that archives those agents, cancels their tasks, pauses dangling autopilots and deletes the runtime in a single transaction. The unified DeleteRuntimeDialog opens directly in cascade mode when the runtime has bound agents, pivots from light to cascade if the strict DELETE refuses with runtime_has_active_agents, and re-prompts when the cascade refuses with runtime_delete_plan_changed (live agent set drifted while the dialog was open). The online-local self-healing rule is preserved at the affordance level (kebab hidden, Diagnostics button disabled with tooltip) and re-checked at confirm time as defence in depth.

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

* fix(runtimes): close cascade race + i18n delete dialog (PR #3266 review)

- Acquire FOR UPDATE on the runtime row at the top of the cascade tx so
  FK-validated agent INSERTs/UPDATEs that would point at this runtime
  block until commit, and lock each currently-active agent row via
  ListActiveAgentsByRuntimeForUpdate so a concurrent archive/move of
  an existing active row also blocks.
- Switch the bulk archive from runtime-keyed (ArchiveAgentsByRuntime)
  to ID-keyed (ArchiveAgentsByIDs), narrowed to the user-confirmed
  expected_active_agent_ids set. Combined with the runtime row lock,
  this guarantees no agent outside the confirmed plan can be silently
  archived between plan-compare and archive even at read-committed.
- Wire delete-runtime-dialog.tsx to runtimes locale via useT(); add
  detail.delete_dialog.{light,cascade} keys (EN with _one/_other
  plurals, zh-Hans _other) covering titles, descriptions, warning,
  notices, checkbox, buttons, table headers, presence labels, and
  toasts. Resolves the i18next/no-literal-string CI failure.
- Locale parity test passes (51 tests). All 4 dialog test cases pass
  unmodified (EN copy preserves original wording). Full views vitest:
  91 files / 792 tests green; full server go test: green.

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

---------

Co-authored-by: multica-agent <github@multica.ai>
2026-05-26 14:59:38 +08:00
Bohan Jiang
d8075a5775 fix(agents): tighten skills-tab intro and drop redundant import hint (#3265)
Three small UI cleanups on the agent Skills tab:

- The blue "Importing creates a workspace copy that your team can edit
  and reuse" callout was visual clutter — drop it (and the Info icon
  import that it relied on).
- The intro paragraph conflated two things: the workspace-skills concept
  (applies to every runtime) and the Allow-locally-installed-skills
  toggle (only honoured by Claude Code and Codex; verified — none of
  copilot/cursor/gemini/opencode/openclaw/hermes/pi/kimi/kiro read
  agent.SkillsLocal). Rewrite the intro to only describe the main
  concept; the toggle's own local_hint_on/off strings still carry the
  Claude/Codex caveat where it belongs.
- The trimmed intro now fits one line, so flip the header row from
  items-start to items-center so the text sits on the same baseline as
  the "Add skill" button instead of clinging to its top edge.
2026-05-26 14:58:12 +08:00
Bohan Jiang
6bc3d14eb3 fix(daemon/execenv): refresh stale Codex config copies across env reuse (MUL-2646) (#3268)
* fix(daemon/execenv): refresh stale Codex config copies across env reuse (MUL-2646)

`copyFileIfExists` previously short-circuited whenever the per-task
`codex-home/{config.toml,config.json,instructions.md}` already existed,
so once the files were seeded at first Prepare they were never refreshed
again — even though `Reuse()` calls `prepareCodexHomeWithOpts` on every
resume. A user who rotated their Codex `~/.codex/config.toml` between
runs (e.g. switching the active `[model_providers.X]` `base_url`, or
pointing `env_key` at a freshly rotated API key) kept reading the stale
per-task copy on session resume. Codex then issued requests to the new
URL using the old key and the API rejected the token.

Treat any existing `dst` as something to drop and re-copy from the
current shared source, mirroring the symlink path that already refreshes
`auth.json` (#2126). The daemon-managed sandbox / multi-agent / memory
blocks are applied via marker-bracketed idempotent passes after the
copy, so a re-copy + re-ensure cycle preserves them.

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

* fix(daemon/execenv): drop per-task Codex copy when shared source removed (MUL-2646)

Extend the MUL-2646 fix to the deletion arm of "sync the shared source":
`syncCopiedFile` (renamed from `copyFileIfExists`) now also removes the
per-task `dst` when the shared `src` is absent. The prior version
short-circuited on missing src and left `config.toml` / `config.json` /
`instructions.md` from the previous Prepare lingering in the per-task
home — so a user who removed a provider by deleting `~/.codex/config.toml`,
or pulled `config.json` / `instructions.md` out of the shared home, would
keep replaying the stale copy on session resume.

For `config.toml` the subsequent `ensureCodex{Sandbox,MultiAgent,Memory}Config`
passes recreate the file with only the daemon-managed default blocks, so
removing the shared file cleanly drops every user-managed
`[model_providers.X]` / `model_provider` line. For `config.json` and
`instructions.md` there is no daemon default, so they disappear in
lockstep with the shared source.

Adds `TestPrepareCodexHome_DropsCopiedConfigWhenSharedSourceRemoved`
covering the new path, and extends the refresh-arm test to assert the
multi-agent / memory marker blocks are still present after the copy is
refreshed.

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

---------

Co-authored-by: multica-agent <github@multica.ai>
2026-05-26 14:52:53 +08:00
Naiyuan Qing
612ac8f28e feat(issues): server-side sort + fix drag position corruption (#3228)
* refactor(editor): split rich text styles

* feat(issues): server-side sort + fix drag position corruption in non-manual sort

Backend: ListIssues and ListGroupedIssues now accept `sort` and `direction`
query params (position/priority/title/created_at/start_date/due_date).
ListIssues converted from sqlc to hand-written SQL for dynamic ORDER BY.
Priority sort uses CASE expression for semantic ordering.

Frontend: query keys include sort so changing sort triggers server refetch.
Client-side sortIssues() removed from board-view and list-view.

Drag-and-drop: non-manual sort disables within-column reorder (prevents
silent position corruption). Cross-column drag only updates status/assignee,
preserves original position. Column overlay shows current sort during drag.

Cache: query key split into prefix (list) for invalidation and full key
(listSorted) for queryOptions. All optimistic update paths use prefix
matching via getQueriesData to work with any active sort.

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

* fix(board): prevent drag flicker by settling columns until mutation refetch

After drag-and-drop, the optimistic cache patch updates position values
without reordering the bucket array. The useEffect that rebuilds columns
from TQ data would overwrite the correct local drag order, causing cards
to snap back then forward. Fix: isSettlingRef blocks column rebuilds
between drag end and mutation onSettled.

Also invalidate issueKeys.list on WS position changes so other windows
refetch correctly sorted data instead of showing stale bucket order.

Includes debug logs (to be removed after verification).

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

* fix(board): stabilize drag-and-drop for non-manual sort modes

Three behavioral fixes for board drag when sort != position:

1. Settling: isSettlingRef + settleVersion blocks column rebuilds
   between drag-end and mutation settle, preventing the optimistic
   cache patch (which updates position values without reordering the
   bucket array) from overwriting the correct local column state.

2. Non-manual cross-column: handleDragOver returns prev (no visual
   card movement — column highlight + sort label is sufficient).
   handleDragEnd uses overCol directly instead of findColumn on the
   card's current position (which would be the source column).
   Cards use useSortable({ disabled: { droppable: true } }) to
   suppress within-column insertion indicators.

3. Collision detection: when no card droppables exist (disabled in
   non-manual sort), return column droppables from pointerWithin
   instead of falling through to closestCenter, so isOver reflects
   the column the pointer is actually inside.

Also: WS position changes now invalidate issueKeys.list so other
windows refetch correctly sorted data.

Insertion-position prediction intentionally omitted — PostgreSQL's
en_US.utf8 collation (glibc) cannot be faithfully replicated in
JavaScript (ICU/V8), and an inaccurate indicator is worse than none.

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

* fix(sort): manual sort ignores direction param on both ends

Manual sort (position) is user-defined order via drag-and-drop —
reversing it has no product meaning.

Backend: sort=position now skips the direction query param and
always uses ASC. Both ListIssues and ListGroupedIssues handlers.

Frontend: sort object omits sort_direction when sortBy is position.
Direction toggle hidden in the display popover for manual mode.

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

* perf(board): memo columns + stabilize references to reduce re-renders

- BoardColumn, PaginatedBoardColumn, PaginatedAssigneeBoardColumn
  wrapped in memo() — only columns with changed props re-render
- IssueAgentActivityIndicator wrapped in memo() — 111 snapshot
  subscribers no longer trigger full re-render on every WS task event
- buildColumns rewritten from O(groups × issues) to single-pass O(n)
- EMPTY_IDS constant replaces ?? [] fallbacks (stable reference)
- EMPTY_CHILD_PROGRESS constant replaces new Map() default
- BOARD_COL_WIDTH / BOARD_CARD_WIDTH constants shared between
  column and DragOverlay for consistent card dimensions
- issueListOptions + issueAssigneeGroupsOptions use
  placeholderData: keepPreviousData so sort/filter changes don't
  flash a full-page skeleton
- Loading skeleton scoped to content area only — header stays
  rendered during data transitions

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

* chore: remove outdated server-side sort implementation plan

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

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-26 14:50:25 +08:00
Bohan Jiang
40da88fc16 fix(agent): mirror $HOME/.claude.json into isolated config dir (MUL-2661) (#3261)
PR #3200 introduced per-agent `skills_local=ignore` isolation that
mirrors the host's Claude config dir into a per-task scratch dir,
omitting `skills/` to keep broken local skills out of the CLI's
discovery path. The mirror walks entries inside `hostConfigDir`
(default: `$HOME/.claude/`), but Claude Code's default layout stores
its main config — login state, project history — at
`$HOME/.claude.json`, a *sibling* of `~/.claude/` rather than inside
it. Once `CLAUDE_CONFIG_DIR=$ISOLATED` is set, the CLI looks for
`$ISOLATED/.claude.json`, finds only `backups/.claude.json.backup.*`
(those live inside `~/.claude/` and DO get mirrored), and exits with:

  Claude configuration file not found at: …/.claude.json
  Not logged in · Please run /login

— so every agent with `skills_local=ignore` on a host using the
default Claude layout dies on the first turn. Flipping the toggle back
to "merge" restores the host CLAUDE_CONFIG_DIR and recovers the agent;
that's the workaround Bohan flagged in MUL-2661.

Fix: after the existing `mirrorHostClaudeExceptSkills`, run a new
`mirrorHostClaudeJSONIfMissing` that pulls `$HOME/.claude.json` into
the scratch dir as `.claude.json` when (a) the dest doesn't already
have one and (b) the host source dir is the default `$HOME/.claude/`.
The custom-CLAUDE_CONFIG_DIR path is left alone because a pinned
custom dir is expected to be self-contained — silently borrowing
`$HOME/.claude.json` from a different account would mask credential
drift.

The helper goes through `createFileLink`, so it inherits the same
symlink → junction → hardlink → copy fallback chain the rest of the
mirror uses on Windows-without-Developer-Mode hosts.

Tests:
- `TestMirrorHostClaudeJSONIfMissing_DefaultLayoutMirrorsParentFile`
  covers the happy path with an injected `homeDir`/`fileLink`.
- `TestMirrorHostClaudeJSONIfMissing_AlreadyPresentNoop` asserts a
  pre-existing dest `.claude.json` (from a custom CLAUDE_CONFIG_DIR
  mirror) is not overwritten.
- `TestMirrorHostClaudeJSONIfMissing_CustomHostDirSkipped` locks in
  the custom-host-dir gate.
- `TestMirrorHostClaudeJSONIfMissing_MissingSourceNoop` documents the
  env-var-auth-only / fresh-install case.
- `TestClaudeExecuteIsolatesProvidesClaudeJSONFromHome` is the
  end-to-end MUL-2661 regression: a fake `\$HOME` with the default
  split layout, `skills_local=ignore`, fake claude binary that prints
  whatever `.claude.json` reaches the scratch dir. Asserts the file
  rides through. Verified the test fails (with the documented
  MUL-2661 error message) when the new mirror call is removed.

Verification:
- `go test ./pkg/agent/...` green (full agent suite).
- `GOOS=windows GOARCH=amd64 go vet ./pkg/agent/...` clean.

Co-authored-by: multica-agent <github@multica.ai>
2026-05-26 13:50:35 +08:00
Bohan Jiang
960befa56f feat(agent): per-agent toggle to isolate host-machine skills (MUL-2603) (#3200)
* feat(agent): per-agent toggle to isolate host-machine skills (MUL-2603)

Adds an agent-scoped `skills_local` switch ("ignore" default / "merge") so
shared agents stop inheriting the operator's user-global Claude skill
directory. A single broken local skill on one operator's machine was
crashing the Claude CLI before it ever read stdin — the daemon saw a
"broken pipe" with no recoverable signal (GitHub #3052).

- DB: migration 108 adds `agent.skills_local` (NOT NULL DEFAULT 'ignore'),
  with sqlc CreateAgent/UpdateAgent updates and handler validation.
- Claude runtime: when the agent is in "ignore" mode the backend points
  CLAUDE_CONFIG_DIR at an empty per-task scratch dir under the task cwd
  (fallback: OS temp), strips any inherited override, and cleans up after
  the run. Workspace skills under `{cwd}/.claude/skills/` still load.
  "merge" preserves the legacy inherit-from-machine behavior; Codex and
  other isolated backends are no-ops.
- UI: new Skills toggle in the Create Agent dialog and the Agent → Skills
  tab, with EN/zh-Hans copy and SkillsLocalToggle shared between the two.
- Tests: unit coverage for the new env helper, isolation dir lifecycle,
  full Claude execute paths (ignore + merge), and the handler tristate
  contract. Existing skills-tab test updated for the new copy.
- Docs: updated `/skills` docs (EN + ZH) and added a 0.3.7 changelog entry
  in the landing-page i18n.

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

* fix(agent): preserve claude login + validate skills_local input (MUL-2603)

Address Elon's review on PR #3200:

1. Skill isolation no longer drops the operator's Claude login. The
   per-task scratch dir now mirrors every entry under `~/.claude/`
   as symlinks except `skills/`, so `.credentials.json`, settings,
   plugins, etc. reach the CLI exactly as on the host while the
   user-global skills directory stays hidden. Without this, default
   `ignore` would have broken every Claude agent on a non-API-key
   host the moment migration 108 landed.

2. Internal CreateAgent callers (agent_template, onboarding_shim)
   now set `SkillsLocal: "ignore"`. The Go zero value was about to
   trip the migration-108 CHECK constraint and 500 template /
   onboarding agent creation.

3. Create / update handler validation no longer normalizes garbage
   to "ignore". The strict 400 path is now reachable on bad client
   input; the drift-safe `normalizeSkillsLocal` stays on the read
   side only.

UI copy + docs clarified that the toggle is Claude-only; other
runtimes ignore the setting.

Verification:
- `go test ./...` green (full suite locally).
- `pnpm --filter @multica/views exec vitest run agents/components/tabs/skills-tab.test.tsx` green.
- Handler DB-backed tests still skip locally without docker (same
  as Elon's run) — CI will validate the create / update paths
  against migration 108.

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

* fix(agent): mirror effective claude config dir with windows fallback (MUL-2603)

Address Elon's second-round review on PR #3200:

1. The per-task scratch dir now mirrors the *effective* host Claude
   config dir, not unconditionally `~/.claude/`. Precedence: agent
   `custom_env` CLAUDE_CONFIG_DIR > parent process env > `~/.claude/`.
   Without this, an operator who pinned Claude at a managed install
   (custom env CLAUDE_CONFIG_DIR) would get the wrong credentials in
   the scratch dir, because `buildClaudeEnv` strips that env before
   handing it to the child. We resolve the source up front and feed
   it to the mirror, so the override env still points at the right
   bytes.

2. Mirror entries now go through platform-aware linkers. On Windows
   without Developer Mode / admin, `os.Symlink` is denied, which
   previously left the scratch dir empty and broke Claude Code auth
   on default `ignore`. The new helpers try symlink first, then fall
   back to a directory junction (`mklink /J`) for dirs or a hardlink
   (same-volume content share) / copy for files. Mirrors the
   execenv/codex_home_link_windows.go pattern.

3. Tests:
   - `TestResolveHostClaudeConfigDir` locks in the custom_env >
     parent_env > `~/.claude` precedence.
   - `TestNewIsolatedClaudeConfigDirMirrorsCustomHostDir` confirms
     the scratch dir picks up `.credentials.json` from a synthetic
     custom host dir, proving the source resolution actually
     propagates into the mirror.
   - `TestNewIsolatedClaudeConfigDirEmptyHostIsNoop` documents the
     env-var-auth-only case (no host source ⇒ empty scratch dir).
   - `TestMirrorHostClaudeExceptSkillsWith_FallbackWhenSymlinkFails`
     exercises the Windows-no-Developer-Mode path via the new
     `mirrorHostClaudeExceptSkillsWith` seam, asserting credentials
     and sub-dir children still reach the scratch dir after the
     symlink stand-in fails.
   - `TestMirrorHostClaudeExceptSkillsWith_PropagatesFirstLinkError`
     confirms callers see the per-entry error when even fallback
     fails (so the warn-log fires on broken Windows installs).
   - `TestCopyFileRoundTrip` covers the last-resort copy fallback
     and its EXCL no-overwrite contract.
   - `TestClaudeExecuteIsolatesUsesCustomEnvSource` is the
     end-to-end check: an agent with custom_env CLAUDE_CONFIG_DIR
     reads its credentials from the pinned dir, not `~/.claude/`.

4. Docs: `apps/docs/content/docs/skills.{mdx,zh.mdx}` updated to
   describe the effective-source resolution and the Windows
   fallback chain so the docs match the runtime behaviour.

Verification:
- `go test ./...` green (full server suite locally, including
  `pkg/agent` 23 cases covering the new + existing isolation
  paths).
- `GOOS=windows GOARCH=amd64 go vet ./pkg/agent/...` and
  `go test -c -o /dev/null` both compile clean, confirming the
  Windows-tagged linker file builds.

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

* fix(agent): default skills_local to merge to preserve legacy behavior (MUL-2603)

Per Bohan's product decision on PR #3200, the per-agent host-skill toggle
defaults to "merge" — the pre-MUL-2603 inherit-from-machine behavior —
so existing personal workflows that rely on locally installed Claude
Skills keep working unchanged. Agent owners explicitly opt into "ignore"
when they need to harden a shared agent against a broken local skill on
one operator's machine (GitHub #3052).

Also audited all 11 runtimes for user-global skill discovery paths and
documented the scope of the toggle. Only Claude reads a user-global
`~/.claude/skills/`; Codex isolates via `CODEX_HOME`, the ACP backends
(Hermes / Kimi / Kiro) and the JSON-stream backends (Copilot / Cursor /
Gemini / Pi / OpenCode / OpenClaw) anchor discovery to the task workdir
and never read a user-global skill directory. UI copy and docs now say
"for runtimes that support it (currently Claude Code)" everywhere so
the scope is explicit.

Changes:

- Migration 108: column default flipped to 'merge'.
- Handler CreateAgent: missing field → "merge"; explicit "ignore" /
  "merge" still validated, garbage still 400.
- normalizeSkillsLocal: drift-safe coercion now lands on "merge" for
  anything that isn't the exact literal "ignore".
- agent_template.go / onboarding_shim.go: internal CreateAgent callers
  send "merge" instead of "ignore" to match the new default.
- Claude runtime (`claude.go`): isolate-mode gate flipped from
  `SkillsLocal != "merge"` to `SkillsLocal == "ignore"`, so "" (legacy
  daemons / older clients) and "merge" both walk `~/.claude/` directly.
- Create Agent dialog + Skills tab: toggle defaults to on (merge); only
  duplicate of an explicit "ignore" agent carries through. The
  isolation opt-in is now `skills_local: "ignore"` when the user flips
  off; "merge" is omitted from the request body.
- i18n (EN + zh-Hans): copy reframed — "On (default) — merged"; "Off —
  ignored. Recommended for shared agents".
- Docs (`/skills`, `/guides/agents.zh`): describe new default and
  enumerate which runtimes act on the toggle.
- Landing changelog 0.3.7: retitled "Per-Agent Local-Skill Toggle"; note
  the on-by-default behavior + off-to-isolate framing.
- Tests:
  - `TestClaudeExecuteIsolatesHostSkillsWhenIgnoreOptedIn` replaces the
    old by-default isolation case (now requires explicit "ignore").
  - New `TestClaudeExecuteDefaultModeKeepsHostConfigDir` locks in that
    default ExecOptions preserve the host CLAUDE_CONFIG_DIR.
  - `TestClaudeExecuteIsolatesUsesCustomEnvSource` now explicitly opts
    into "ignore" mode.
  - Handler tests: omitted → "merge"; explicit "ignore" round-trips;
    preserve-existing test seeds "ignore" and asserts "merge" flip-back.
  - `TestNormalizeSkillsLocal_DriftStaysSafe`: only literal "ignore"
    maps to ignore; everything else → "merge".
  - `skills-tab.test.tsx`: toggle ON by default; flip OFF when agent
    opted into "ignore". Intro-text matcher anchored to a more specific
    phrase so it no longer collides with the toggle hint copy.

Verification:
- `go test ./...` green (full server suite locally).
- `GOOS=windows GOARCH=amd64 go vet ./pkg/agent/...` and
  `go test -c -o /dev/null` both compile clean (windows-tagged linker
  file still builds).
- `pnpm typecheck` green across all packages and apps.
- `pnpm --filter @multica/views test` 88 files / 771 tests green.
- `pnpm --filter @multica/core test` 43 files / 390 tests green.
- Handler DB-backed tests still skip locally without docker; CI will
  validate the create / update paths against migration 108.

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

* chore(landing): drop 0.3.7 changelog entry from this PR (MUL-2603)

The landing-page release notes belong in a separate release-prep PR, not in the feature PR.

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

* fix(agent): propagate skills_local=ignore to codex user-skill seed (MUL-2603)

Make the per-agent skills_local toggle real for Codex too, not just Claude.
Previously the toggle was only consumed by the Claude backend, while the
daemon's execenv layer always seeded Codex's per-task CODEX_HOME with the
host machine's user-installed skills from ~/.codex/skills/. A shared Codex
agent with skills_local=ignore could still inherit a broken local skill
from one operator's machine.

Now: PrepareParams/ReuseParams carry SkillsLocal; hydrateCodexSkills
skips seedUserCodexSkills when SkillsLocal == "ignore" so the per-task
CODEX_HOME exposes only workspace skills to the codex CLI. Default
("merge", or empty from older servers/clients) preserves existing
inherit-from-machine behavior. UI / docs are updated to reflect the
contract honestly: Claude Code and Codex honor the toggle; other
runtimes (Hermes / Kimi / Kiro / Copilot / Cursor / Gemini / Pi /
OpenCode / OpenClaw) leave $HOME untouched and discover user-level
skills natively, so the toggle is a no-op for them today.

New tests: TestPrepareCodexSkillsLocalIgnoreSkipsUserSeed,
TestPrepareCodexSkillsLocalMergeSeedsUserSkills, and
TestReuseCodexSkillsLocalIgnoreSkipsUserSeed cover Prepare(ignore),
Prepare(merge), and the toggle-flip-on-reuse path.

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

* docs(skills): scope skills_local toggle copy to Claude Code + Codex (MUL-2603)

Off-state hint and Skills tab intro now explicitly call out Claude Code +
Codex as the only runtimes that honor the toggle, with "other runtimes
ignore this setting" wired into both states (en + zh-Hans), so users on
non-Claude/Codex agents don't read "Off" as runtime-wide isolation.

Docs (skills.mdx, skills.zh.mdx, guides/agents.zh.mdx) stop describing
Hermes / Kimi / Gemini / Copilot / Cursor / Pi / OpenCode / OpenClaw / Kiro
as having native user-level skill discovery; the daemon simply does not
manage user-level skill discovery for those runtimes today, and the toggle
is a no-op regardless of where it is set.

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

---------

Co-authored-by: multica-agent <github@multica.ai>
2026-05-26 13:26:33 +08:00
Naiyuan Qing
312ee29cb6 fix(views): always show assignee meta row on board cards when assignee property is enabled (#3250)
When no assignee was set, the entire meta row (assignee + dates + child progress) could disappear because showAssignee required both storeProperties.assignee AND issue.assignee_id. Now the row visibility depends only on storeProperties.assignee, and unassigned cards show "Unassigned" text with a clickable picker to assign.

Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>
2026-05-26 10:09:00 +08:00
Naiyuan Qing
ec1589f7b6 fix(deps): add eslint phantom dep detection + fix existing violations (MUL-2654) (#3249)
* fix(deps): add eslint phantom dep detection + fix existing violations (MUL-2654)

Introduce eslint-plugin-import-x/no-extraneous-dependencies rule to
prevent phantom deps from causing production build splits when pnpm
creates peer-dep variants. Fix all existing phantom deps across the
monorepo, unify catalog references, and enable desktop smoke CI on PRs.

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

* revert(ci): remove desktop smoke PR trigger per user feedback

The existing smoke workflow only verifies packaging completes — it does
not actually start the app or check rendering. This means it wouldn't
have caught the white-screen bug (which was a runtime issue, not a build
failure). Adding it to PRs would slow CI without providing meaningful
protection. The ESLint no-extraneous-dependencies rule is the actual
prevention mechanism.

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

* fix(deps): sync pnpm-lock.yaml for rehype-sanitize dep classification

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

* fix(ui): move rehype-sanitize to deps + declare eslint-config (MUL-2654)

- Move rehype-sanitize from devDependencies to dependencies (used in
  production Markdown.tsx)
- Add @multica/eslint-config to devDependencies (imported by
  eslint.config.mjs but previously undeclared)

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

---------

Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>
2026-05-26 09:42:29 +08:00
Naiyuan Qing
e33b893c3f feat(views): sticky group headers in list view (#3221)
* feat(views): add sticky positioning to list-view group headers

Group headers now stay pinned at the top of the scroll viewport so users
always know which status group they are looking at. Background changed
from semi-transparent to opaque to prevent content bleeding through.

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

* fix(views): remove top padding from list-view scroll container for sticky headers

The `p-2` padding on the scroll container caused an 8px gap above sticky
group headers. Replace with `px-2 pb-2` to keep horizontal and bottom
padding while allowing headers to stick flush to the top. Sync skeleton
containers in issues-page and my-issues-page to match.

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

* fix(views): use p-2 pt-0 instead of px-2 pb-2 for list-view scroll container

Reporter preferred adding pt-0 to override the top padding from p-2,
keeping the original p-2 shorthand intact.

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

* fix(views): opaque sticky header hover + cursor-pointer on trigger

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

---------

Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>
2026-05-26 08:27:57 +08:00
Naiyuan Qing
c5b4c45e41 fix(chat): unify expand and drag-to-max rendering (MUL-2653) (#3235)
* fix(chat): unify expand and drag-to-max rendering so both produce same dimensions (MUL-2653)

Expand button used CSS `inset-3` (parent minus 24px each side) while
drag-to-max used explicit 90%-of-parent pixel dimensions — different
sizes for the same conceptual state. Expand also hid resize handles,
preventing drag-back. Now both paths render with explicit width/height
at bottom-right and resize handles stay visible in all states.

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

* fix(chat): animate width/height via framer-motion for smooth expand toggle (MUL-2653)

Move width/height from style prop into animate prop so framer-motion
interpolates size changes. Remove layout="position" which only tracked
position. Drag uses duration:0 for instant feedback.

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

---------

Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>
2026-05-26 08:27:31 +08:00
Bohan Jiang
e351f89843 fix(desktop): declare phantom deps + dedupe react-query in renderer (MUL-2651) (#3232)
Packaged renderer was bundling two copies of @tanstack/react-query because
apps/desktop imported it without declaring the dep, so Node resolution fell
through to the hoisted root variant (react@19.2.0, pulled in by apps/mobile),
while packages/core resolved to the catalog variant (react@19.2.3). Two physical
paths → two QueryClientContexts → "No QueryClient set" white screen on launch.

- Declare @tanstack/react-query, lucide-react, zustand as direct deps via catalog:
  so apps/desktop resolves to the same peer variant as packages/core/views.
- Add @tanstack/react-query to renderer dedupe as a defense-in-depth bound
  against future peer drift.

Verified: realpaths under apps/desktop, packages/core, packages/views all point
to @tanstack+react-query@5.96.2_react@19.2.3; production renderer bundle now
contains exactly one "use QueryClientProvider to set one" string (was 2) and
no useQueryClient\$1 suffix.

Co-authored-by: multica-agent <github@multica.ai>
2026-05-25 20:14:50 +08:00
Bohan Jiang
bf1e375015 fix(docker): copy source.config.ts into web deps stage (MUL-2649) (#3227)
apps/web postinstall runs fumadocs-mdx, which reads
apps/web/source.config.ts. The deps stage only copied
package.json files, so `pnpm install --frozen-lockfile`
failed with "Could not resolve /app/apps/web/source.config.ts"
and blocked the GHCR multica-web image build in the v0.3.7 release.

Co-authored-by: multica-agent <github@multica.ai>
2026-05-25 19:28:48 +08:00
Bohan Jiang
636fa1adb4 fix(issues): hide activity-block header while only recent entries are shown (#3226)
In the trailing activity block's default truncated state ("last 8 shown,
N older hidden"), we were rendering two stacked chevron rows: a "v N
activities" collapse header and a "> Show N more activities" reveal link.
Visually that looked like nested folds even though they're parallel
controls, and the header is redundant when the user just wants a glance
at recent activity.

Drop the header in the truncated default state. It reappears the moment
the user clicks "Show N more" — at that point they're seeing the full
block and a fold-back affordance becomes useful again. Blocks that fit
within the 8-entry limit (and non-trailing blocks, which never truncate)
keep their header as before.
2026-05-25 19:04:15 +08:00
Bohan Jiang
441fa18db4 feat(issues): truncate trailing activity block to most recent 8 (MUL-2628) (#3219)
* feat(issues): truncate trailing activity block to most recent 6 (MUL-2628)

The trailing activity block defaults to expanded, but a block with dozens
of entries still drowns the comment area. Show only the most recent 6 by
default; older entries fold behind an in-place "Show N more activities"
toggle. Non-trailing blocks are unchanged — they still collapse whole.

The "show older" choice is tracked per block id in a separate Set so it
survives the block losing its trailing position (when a new comment
lands after it) and survives a collapse/re-expand cycle.

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

* refactor(issues): bump trailing activity block visible limit from 6 to 8 (MUL-2628)

User feedback on the original PR: 6 felt slightly too tight. Bumped the
trailing-block truncation threshold to 8 entries to give the "most recent
activity" view a bit more headroom before older entries fold behind the
"Show N more activities" toggle.

Test count is unchanged; the existing trailing-block / non-trailing-block
truncation cases were adjusted to exercise the new 8-entry boundary
(10-entry trailing block → 2 hidden; 8-entry trailing block → none
hidden; 10-entry non-trailing block → all visible after expand).

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

---------

Co-authored-by: multica-agent <github@multica.ai>
2026-05-25 18:43:31 +08:00
Bohan Jiang
13f74e651a feat(agents): remove custom_env from agent resources, add audited env endpoint (MUL-2600) (#3209)
* feat(agents): remove custom_env from agent resources, add audited env endpoint (MUL-2600)

The agent resource shape (list / get / create / update / archive /
restore responses + WebSocket events) no longer carries `custom_env`
values. Reads/writes of env now flow exclusively through a dedicated
`/api/agents/{id}/env` endpoint that is owner/admin-only, rejects
agent-actor sessions, applies a "****" sentinel preserve guard on
PUT, and writes a persistent audit row per reveal/update.

Why
- `multica agent list --output json` historically returned plaintext
  `custom_env` for owner/admin callers (the redaction gate gave only
  members the masked map). Any agent token running on the workspace
  inherits its owner's role and could read every other agent's
  secrets just by listing.
- Patching list/get redaction alone (PR #3175 direction) left
  symmetric leaks via mutation responses, WS events, the "reveal"
  path itself (no actor-aware auth), and a `****` overwrite footgun
  on UpdateAgent.

What changed
- Backend: drop `custom_env` from AgentResponse; add coarse
  `has_custom_env` + `custom_env_key_count`. Strip env handling from
  UpdateAgent (silently ignored if sent). Keep CreateAgent's
  custom_env acceptance.
- Backend: new GET/PUT `/api/agents/{id}/env` handlers in
  `internal/handler/agent_env.go`:
  - resolveActor → 403 for agent actors (closes the lateral-movement
    path).
  - Owner/admin role gate via existing helper.
  - PUT honours value == "****" as "preserve existing value".
  - Both write to `activity_log` with `agent_env_revealed` /
    `agent_env_updated` actions. Audit details record key names only,
    never values.
- Daemon claim path (`ClaimAgentTask`) unchanged — `TaskAgentData`
  still carries plaintext env for runtime injection.
- SQL: new `UpdateAgentCustomEnv` query; sqlc regenerated (v1.31.1).
- CLI: new `multica agent env get|set` subcommands. `--custom-env*`
  flags removed from `multica agent update`; the no-fields error
  now points to the new path.
- Frontend: drop env fields from `Agent` + `UpdateAgentRequest`; add
  `getAgentEnv` / `updateAgentEnv` client methods; rewrite env-tab
  to show "N variables configured" + explicit "Reveal & edit"
  button, fetching values only on intentional reveal.
- Locales: parity-safe additions to en + zh-Hans.
- Docs: agents-create.{mdx,zh.mdx} reflect the new threat model and
  endpoint.
- Mobile: schema drops `custom_env` / `custom_env_redacted`, adds
  metadata fields.

Tests
- Handler tests pinned the new invariants: no env in list/get
  responses, owner reveal happy-path + audit row, agent-actor 403,
  `****` sentinel preserves real values, UpdateAgent silently
  ignores `custom_env`, pure `mergeAgentEnv` cases.
- CLI tests pivot to the new flag surface: `agent update` MUST NOT
  expose the env flags; `agent env set` MUST expose
  --custom-env-stdin/--custom-env-file.
- Frontend test fixtures updated; pnpm typecheck / test / lint
  pass cleanly.

This is a breaking API change. Scripts that read `custom_env` from
`/api/agents` must migrate to `GET /api/agents/{id}/env`.

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

* fix(agents): close actor-spoofing + audit fail-closed in env endpoints (MUL-2600)

Addresses Elon's review of #3209:

* Mint a task-scoped `mat_` token per claim, bound to (agent, task,
  workspace, owner). Daemon injects it into the agent process in place
  of its own credential. Auth middleware authoritatively rebuilds
  X-User-ID / X-Agent-ID / X-Task-ID from the token row and sets
  X-Actor-Source=task_token; that header is server-set only — incoming
  values are stripped before any auth branch runs. resolveActor honors
  the header so an agent that strips X-Agent-ID / X-Task-ID still
  resolves as actor=agent.
* GetAgentEnv / UpdateAgentEnv are now fail-closed on audit-log
  failures: GET refuses to return plaintext, PUT persists inside the
  same tx as the audit row so they commit/roll back together.
* PUT /api/agents/{id} returns 400 when the body carries custom_env
  instead of silently dropping it — directs callers to the audited env
  endpoint.
* Agent actors never see mcp_config, even when the underlying member
  is owner/admin; mutation broadcasts go through a redaction shim so
  WS subscribers don't pick it up either.
* Fix backend test that asserted dense JSON (jsonb::text renders
  whitespace) and frontend test that assumed a unique "Test User"
  match.

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

* fix(agents): close residual MUL-2600 gaps from review (MUL-2600)

Migration 108 FK now correctly references agent_task_queue(id) instead
of the non-existent agent_task table; the previous name blocked CI
backend migrations.

Task-token-authenticated requests can no longer be re-routed at a
different workspace by passing workspace_slug / workspace_id /
?workspace_id / a URL workspace param. ResolveWorkspaceIDFromRequest
and resolveWorkspaceUUID both short-circuit on X-Actor-Source=task_token
and return only the token-bound X-Workspace-ID; buildMiddleware adds a
defence-in-depth 403 if any URL-resolved workspace disagrees with the
token binding.

mcp_config no longer leaks back to agent actors through UpdateAgent /
CreateAgent / ArchiveAgent / RestoreAgent HTTP responses — the same
redactAgentResponseForActor helper that GetAgent/ListAgents use is now
applied to mutation responses too. WS broadcasts were already redacted
via broadcastAgentResponse.

FailTask and every TaskService cancel path (CancelTask /
CancelTasksForIssue / CancelTasksForAgent / CancelTasksByTriggerComment
/ BroadcastCancelledTasks) now eagerly DeleteTaskTokensByTask so the
mat_ token's 24h window doesn't outlive a terminated task. Failure is
non-fatal — the FK cascade and expiry remain durable guards.

Doc-only: clarify that PUT /api/agents/{id} now hard-rejects bodies
that carry custom_env (was previously "silently ignores").

Tests:
- middleware: TestResolveWorkspaceIDFromRequest gains a task_token
  case asserting client-supplied slug/id/query cannot override the
  bound workspace.
- handler: TestUpdateAgent_RedactsMcpConfigForAgentActor and
  TestUpdateAgent_KeepsMcpConfigForMemberActor pin the mutation-
  response redaction contract per actor type.

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

* fix(agents): match redacted mcp_config as JSON null, not Go nil (MUL-2600)

`AgentResponse.McpConfig` is `json.RawMessage` without `omitempty`, so
the redacted response serialises as `"mcp_config": null`. On decode,
`json.RawMessage` keeps the literal bytes `null` rather than collapsing
to Go nil, which made the assertion fire on a non-leak.

The product contract (field always present, distinguished from "no
config" via `mcp_config_redacted`) is intentional, so adjust the test
to check for "no secret-bearing content" instead of weakening the
contract via `omitempty`.

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

---------

Co-authored-by: multica-agent <github@multica.ai>
2026-05-25 18:42:48 +08:00
Multica Eve
5877cff9de docs: add 2026-05-25 changelog entry (#3218)
* docs: add 2026-05-25 changelog entry

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

* docs: clarify iOS changelog availability

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

* docs: remove reverted squad fix from changelog

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

---------

Co-authored-by: Eve <eve@multica-ai.local>
Co-authored-by: multica-agent <github@multica.ai>
2026-05-25 18:09:56 +08:00
Bohan Jiang
2b3e408db1 Revert "fix(squad): skip leader on agent reply to explicit member @-mention (MUL-2624) (#3217)" (#3222)
This reverts commit ce98b1c9ef.

Co-authored-by: multica-agent <github@multica.ai>
2026-05-25 18:06:33 +08:00
Qi Yijiazhen
f9dfb3b9fc Fix duplicate desktop back navigation (#3210) 2026-05-25 17:13:10 +08:00
Bohan Jiang
ce98b1c9ef fix(squad): skip leader on agent reply to explicit member @-mention (MUL-2624) (#3217)
When a user explicitly @-mentions an agent on an issue assigned to a squad,
the existing rule already suppresses the squad leader on the mention
comment itself — the user is routing deliberately, the mentioned agent
owns the next step. The leader was still woken on the agent's reply,
though, so it would re-@ the user every time the agent answered.

Extend the suppression to the second leg of that explicit exchange:
when an agent reply lands as a child of a member comment that carried a
routing @mention (agent/member/squad/all — issue cross-refs still
ignored), the leader stays out. The CreateComment handler already pins
agent parent_id == task.TriggerCommentID, so this fires exactly when
the agent's reply is provably tied to the upstream routing comment.
Top-level agent comments and agent-to-agent threads continue to wake
the leader so coordination keeps working everywhere else.

Co-authored-by: multica-agent <github@multica.ai>
2026-05-25 17:12:33 +08:00
Bohan Jiang
b9bf2653be fix(desktop): retry tab scroll restore until virtualized content rehydrates (#3214)
Follow-up to #3196. Switching tabs and back on a long issue still landed at
scrollTop=0 because issue-detail uses Virtuoso with customScrollParent —
Virtuoso wires its scroll/resize observers in a passive useEffect, which
fires *after* useLayoutEffect. So at the moment the restore hook ran, the
spacer that gives the scroll container its tall scrollHeight hadn't been
re-established yet (scrollHeight === clientHeight), and the browser
silently clamped `scrollTop = saved` down to 0.

Diagnostic console output confirmed this:
  marker key=true saved=10356.5 currentScrollTop=0 scrollHeight=750 clientHeight=750
  → set scrollTop to 10356.5 actually now 0

Fix: keep the synchronous set as the fast path, then if the assignment was
clamped, retry across rAF frames for up to ~500ms (30 frames at 60fps).
That gives Virtuoso's passive effect time to re-establish the spacer, after
which the next tick succeeds. Cancel any in-flight retry when the effect
tears down (Activity hidden again or component unmount).

Existing 4 tests in use-tab-scroll-restore.test.tsx still pass — the
synchronous fast path covers the simple-content case they exercise. A
jsdom regression for the Virtuoso scenario didn't reproduce reliably (the
clamp + rAF interplay needs a real browser), so this relies on manual
verification: open issue-detail, scroll deep into comments, switch tabs,
switch back — scroll position now holds.
2026-05-25 16:59:06 +08:00
Naiyuan Qing
5c1fad4508 refactor(editor): split rich text styles (#3211) 2026-05-25 16:02:10 +08:00
Bohan Jiang
90455abd8d fix(desktop): preserve tab scroll position across Activity visibility cycles (MUL-2602) (#3196)
Closes #3183.

Tabs render under `<Activity mode="visible|hidden">`, which keeps React
state but drops DOM scrollTop when the subtree leaves layout. Switching
to another tab and back sent users to the top of long discussions.

`useTabScrollRestore` records the scrollTop of every element marked with
`data-tab-scroll-root` while the tab is visible (capture-phase scroll
listener) and restores them in a useLayoutEffect on the next visible
transition, before paint. Saved offsets are dropped when the tab's path
changes so intra-tab navigation lands at scroll=0 instead of inheriting
the previous route's position.

Mark scroll containers in views with `data-tab-scroll-root` (issue
detail + chat message list ship with the marker; other views can adopt
the convention as needed).

`useAutoScroll` previously called `scrollToBottom()` on every effect
mount, which would have overwritten the restored offset every time a
chat tab cycled back to visible. Guard it with a once-per-instance ref.

Co-authored-by: multica-agent <github@multica.ai>
2026-05-25 15:31:01 +08:00
Xiaohan Li
9d5c023145 fix(markdown): disable code ligatures (#3038) 2026-05-25 15:29:26 +08:00
Wes
cfc652aa5f fix(daemon): close stdin pipe in Pi adapter to deliver EOF (#2188) (#3118)
Pi reads its prompt from argv (positional, see buildPiArgs) and never
expects interactive input, so the Pi backend previously left cmd.Stdin
nil. Under systemd, the resulting /dev/null character device has been
observed not to satisfy Pi's readable-side wait, leaving runs stuck in
"working" forever (#2188).

Attach an explicit StdinPipe and close it immediately after Start so the
child sees an EOF on a FIFO, matching the pattern already used by the
Claude, Codex, Hermes, Kiro, and Kimi backends. The fix is defensive on
the daemon side because Pi is mid-refactor and is not accepting issues
upstream; once Pi itself stops blocking on stdin, this close is still
correct (a closed pipe is a no-op for a process that does not read it).

Test asserts the structural invariant: a shell-stub `pi` inspects
/proc/self/fd/0 and only emits a valid event stream when stdin is a
FIFO. If a future change drops the StdinPipe and stdin reverts to
/dev/null (char device), the stub exits non-zero and the test fails.
2026-05-25 15:29:09 +08:00
Bohan Jiang
1c5e483b1c feat(pricing): add DeepSeek, Kimi K2.6, and Zhipu GLM cost tracking (MUL-2606) (#3204)
Adds rows to MODEL_PRICING for the Chinese-model SKUs listed on each
provider's official pricing page, so opencode / OpenRouter-routed
runtimes stop showing $0.00 in the dashboard for these models.

Sources (now cited inline above the table):

- DeepSeek: https://api-docs.deepseek.com/quick_start/pricing
- Moonshot: https://www.kimi.com/resources/kimi-k2-6-pricing
- Zhipu z.ai: https://docs.z.ai/guides/overview/pricing

Notes vs the closed PR #3170:

- Only SKUs that exist on the official pages are added. glm-z1*,
  deepseek-v4-pro at $0.55/$2.19, kimi-k2.6 at K2's tier were all
  hallucinated and are NOT included.
- deepseek-chat / deepseek-reasoner are routed by DeepSeek to
  deepseek-v4-flash, so they share the v4-flash rate.
- deepseek-v4-pro is priced at the post-promo standard rate
  ($1.74 / $3.48), not the 75%-off promo that ends 2026-05-31. Brief
  over-estimate beats a sudden 4x jump on June 1.
- glm-*-flash are priced at $0 because z.ai's free tiers are the
  literal published price.

Co-authored-by: multica-agent <github@multica.ai>
2026-05-25 15:19:14 +08:00
Bohan Jiang
cd71b0fe05 fix(daemon): disable Codex native auto-memory in per-task config.toml (#3202)
Codex CLI's auto-memory subsystem writes summaries to
`$CODEX_HOME/memories/raw_memories.md` and `state_*.sqlite`, then reads
them back on the next turn. The daemon never cleared these files across
Reuse(), and Codex CLI may also pull from user-level `~/.codex/memories/`
entirely outside the per-task isolation. Either path leaks unrelated
context into new Multica tasks — multica#3130 saw `D:\Project\MoHaYu\
WowChat` Raw Memories injected into a brand-new issue's first turn.

Write a daemon-managed block into the per-task `config.toml` that sets
`features.memories = false`, `memories.generate_memories = false`, and
`memories.use_memories = false`. Codex then neither writes nor reads
its memory subsystem regardless of where the residual files live. The
user's global `~/.codex/config.toml` is never touched.

Pattern mirrors `ensureCodexMultiAgentConfig`: idempotent managed-block
upsert, two TOML layout variants (root dotted-key vs. inside a `[features]`
/ `[memories]` table) to satisfy strict toml-rs parsing, and a
`MULTICA_CODEX_MEMORY` env-var escape hatch.

MUL-2598

Co-authored-by: multica-agent <github@multica.ai>
2026-05-25 15:17:38 +08:00
LinYushen
8e9df90d32 feat: include repo description in agent brief (#3203)
Add Description field to RepoData structs so that workspace repo
descriptions (set via the settings UI) are preserved through
normalization and rendered in the agent brief as:
  - <url> — <description>

When no description is set, the existing format is unchanged.

Closes MUL-2610

Co-authored-by: multica-agent <github@multica.ai>
2026-05-25 15:16:22 +08:00
Naiyuan Qing
6703072241 Improve landing header CTA hierarchy (#3197)
* Improve landing header CTA hierarchy

* Remove duplicate landing login action
2026-05-25 14:53:20 +08:00
LinYushen
a8cda1bd96 feat: add description field to workspace repository settings (#3198)
- Add optional description field to WorkspaceRepo type
- Show description input below URL in edit mode
- Display description text in view mode
- Update isDirty to compare descriptions
- Update tests for new field

Co-authored-by: multica-agent <github@multica.ai>
2026-05-25 14:51:24 +08:00
Bohan Jiang
be54e79f38 fix: upgrade github.com/jackc/pgx/v5 to 5.9.2 (#3192)
Remediates two pgx security advisories in a single bump:
- CVE-2026-33816 (fixed in 5.9.0) — pgproto3 memory-safety DoS from
  malformed messages sent by a malicious server.
- GHSA-j88v-2chj-qfwx / CVE-2026-41889 (fixed in 5.9.2) — SQL injection
  via placeholder confusion with dollar-quoted literals under
  QueryExecModeSimpleProtocol. Not reachable in this codebase (no
  simple-protocol callers), but pinned to clear future scanner runs.

No source changes needed: pgx 5.9.x adds no breaking APIs over 5.8.x
(adds PG protocol 3.2 support, SCRAM-SHA-256-PLUS, OAuth, plus
pgtype/pgconn bug fixes). Minimum Go bumped to 1.25 in 5.9.0; repo
already on 1.26.1.

MUL-2597

Co-authored-by: multica-agent <github@multica.ai>
2026-05-25 14:06:56 +08:00
Naiyuan Qing
6261ea45fd Improve board and squad hover cards (#3188) 2026-05-25 12:58:39 +08:00
lucccf
077bc055f7 fix(views): sort timeline entries by created_at on WebSocket append (MUL-2582) (#3139)
* fix: sort timeline entries by created_at on WebSocket append

When multiple agents post comments concurrently, WebSocket events may
arrive out of chronological order. The handlers blindly appended new
entries to the end of the cached timeline array, causing display
misordering. This fix sorts the array by created_at (with id as
tie-breaker) after each insert.

Changes:
- use-issue-timeline.ts: sort after comment:created and activity:created
- issue-ws-updaters.ts: sort in appendTimelineEntry

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* fix(views): extract sortTimelineEntriesAsc helper, cover mutation onSuccess

Review feedback from @Bohan-J: useCreateComment.onSuccess also appends
unsorted (mutations.ts:558). When the local user posts a comment whose
HTTP response returns after a concurrent WS event, the unsorted append
leaves the cache misordered and the subsequent WS dedup skips re-sort.

Extract sortTimelineEntriesAsc helper and reuse it in all three web
cache writers:
- comment:created WS handler
- activity:created WS handler
- useCreateComment.onSuccess

Mobile keeps its own inline sort (apps/mobile/CLAUDE.md boundary).

Add regression tests for sort position (mid-insert and oldest-insert).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-25 12:54:09 +08:00
Juan Carranza
3df26ddd28 feat(self-host): add Helm chart for Kubernetes deployment (#2377)
* Include k8s deployment instructions

* Use helm for deployment

* docs(self-host): add Helm / Kubernetes deployment to quickstart (en + zh)

* fix(helm): gate backend ExternalName alias behind a value

The unprefixed Service/backend in the chart is load-bearing, but as
written it limits the chart to one release per namespace and fails
helm install whenever a Service/backend already exists in the
namespace (without --take-ownership).

Gate the alias behind frontend.compatibility.backendAlias (default
true, so existing installs are unchanged). Operators running a web
image with a patched REMOTE_API_URL can set it to false to drop the
Service entirely. Document the one-release-per-namespace constraint
and the opt-out in values.yaml and the SELF_HOSTING.md Kubernetes
section.

Addresses review item #1 on PR #2377.

* fix(helm): add backend startupProbe so cold installs survive migrations

The entrypoint runs `./migrate up` before serving traffic. On a cold
cluster (Postgres still coming up) this can take minutes, during which
the livenessProbe (initialDelaySeconds 30 / periodSeconds 30) trips and
restarts the pod 1-2 times.

Add a startupProbe on /healthz (failureThreshold 30, periodSeconds 10,
~5 min budget). Kubernetes disables liveness/readiness until it passes,
so migrations finish without the pod being killed, and the aggressive
livenessProbe is untouched for steady-state. Update the SELF_HOSTING.md
install step, which no longer expects 1-2 restarts.

Addresses review item #2 on PR #2377.

* fix(helm): roll backend pods on config/secret change via checksum annotations

envFrom does not watch the referenced ConfigMap/Secret, and helm
upgrade alone does not change the pod template hash, so editing
values.yaml + `helm upgrade` left the old backend pods running stale
config.

Add checksum/config (hash of the rendered configmap.yaml) and
checksum/secret (hash of the live existingSecret via lookup, since it
is created out-of-band and has no chart template) to the backend pod
template. Config edits now actually re-roll the backend on upgrade,
and Secret rotations do too. lookup is empty under
`helm template`/`--dry-run`; that placeholder is harmless and
documented inline.

Addresses review item #3 on PR #2377.

* docs(self-host): sync quickstart with new startupProbe behavior

SELF_HOSTING.md was updated to reflect that the backend now stays
Running but not Ready while Postgres comes up (startupProbe absorbs
it, so no restart), but the EN/ZH quickstart docs still described the
pre-startupProbe behavior of "may restart 1-2 times". Bring them in
line.

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

---------

Co-authored-by: Bohan Jiang <52446949+Bohan-J@users.noreply.github.com>
Co-authored-by: multica-agent <github@multica.ai>
2026-05-25 12:53:01 +08:00
Bohan Jiang
f59c34eea8 docs: clarify Chinese README license label (#3189)
The LICENSE file adds commercial restrictions on top of Apache 2.0, so the
README should not advertise the project as plain "Apache 2.0". Match the
actual terms.

Closes #3144

Co-authored-by: multica-agent <github@multica.ai>
2026-05-25 12:47:33 +08:00
Naiyuan Qing
5f1f08e466 feat(web): add use-cases content pipeline with welcome page (MUL-2349) (#2795)
* feat(web): add use-cases content pipeline with welcome page (MUL-2349)

Wire fumadocs-mdx into apps/web with an independent collection rooted at
content/use-cases/. Add the first page at /use-cases/welcome (header + H1 +
prose + screenshot + footer) using the about-page visual shell.

- source.config.ts + lib/use-cases-source.ts (separate from apps/docs)
- features/landing/components/mdx/screenshot.tsx wraps next/image
- public/use-cases/welcome/screenshot-1.png placeholder (55KB)
- next.config.ts wraps NextConfig with createMDX()
- .gitignore + eslint ignore .source/

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

* feat(web): bilingual db-boy use case with cookie locale (MUL-2349)

Extends the use-cases pipeline into the first real article.

- ZH + EN MDX (auto-data-analysis.{zh,en}.mdx) sharing three real
  screenshots; sensitive fields on db-boy-profile.png (RDS host, DB
  name, password) are blurred in-place.
- Cookie-based locale: /use-cases/<slug> reads multica-locale
  server-side via lib/use-cases-i18n.ts (mirrors LandingLayout's
  cookie + Accept-Language fallback). Same URL serves either language;
  no [lang] segment so all other landing routes stay unchanged.
- Frontmatter schema (source.config.ts): z.looseObject with declared
  hero_image / updated_at (required) / category (optional); a
  preprocess converts YAML-auto-parsed Date back to a YYYY-MM-DD string.
- MDX components factory createMdxComponents(locale) routes the
  secondary CTA to /docs/zh (ZH) or /docs (EN); internal MDX links
  use <Link> for SPA nav; full-width and half-width colons both
  trigger [CTA: ...] / [占位图: ...] markers; 副 and Secondary
  both work as the secondary CTA prefix.
- Index page localizes hero / subtitle / card CTA / metadata; sort
  fallback uses an epoch placeholder so undefined-order disappears.
- Landing header + footer surface use-cases entry in both locales.
- Detail route: sticky header, right-rail TOC with anchor jumps,
  scroll-mt-[100px] on H2/H3 so anchor jumps don't slip under the
  sticky header.
- Drop welcome demo page.

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

* fix(web): resolve code review blockers on use-cases PR

- Add `use-cases` to reserved_slugs.json + regenerate TS (P1: prevent
  future workspace slug collision)
- Fix dead links in both MDX files: /features/* → /docs/* (P2)
- Remove duplicate brand suffix in page title metadata (nit)

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

* fix(web): align usecases locale routing

* chore: refresh web mdx lockfile

* fix(web): type mdx next config adapter

* fix(web): wrap settings route page

---------

Co-authored-by: multica-agent <github@multica.ai>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-25 10:05:17 +08:00
Naiyuan Qing
f0c32d5728 fix(views): move member count from header badge to section label in squad popover (MUL-2586) (#3178)
Remove the standalone member-count badge from the squad profile card
header and display the count inline with the Members section label
(Members · N). Add max-height + scroll guard on the member list to
prevent card overflow with many members.

Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>
2026-05-25 09:32:16 +08:00
Naiyuan Qing
44ee74eb25 feat(views): add squad popover hover card to ActorAvatar (#3176)
Squad avatars now show a hover card on dwell, matching the existing
agent and member cards. The card displays the squad name, member count
badge, description (line-clamp 2), and a members list (top 3, leader
first) with agent status dots. Clicking an avatar navigates to the
squad detail page. Closes MUL-2586.

Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>
2026-05-25 08:59:10 +08:00
Jiayuan Zhang
993cf550ad docs(readme): add Autopilots to features list (#3155)
Autopilots are a shipped product feature with full UI and backend support,
but were missing from the README features list. Add a bullet in both EN
and zh-CN versions, placed next to Autonomous Execution since both cover
how work gets triggered and run.
2026-05-24 16:53:53 +08:00
Jiayuan Zhang
ba945c1141 fix(runtimes): price claude-opus-4-7[1m] at standard Opus tier (MUL-2584) (#3152)
Claude Code reports the 1M-context Opus beta as `claude-opus-4-7[1m]`.
The pricing resolver had no tolerance for the bracketed context tag, so
the row missed the maintained catalog and its tokens were silently
excluded from cost totals.

Add a `[...]` context-tag strip alongside the existing provider / dot↔dash
/ date-snapshot normalizations. The 1M variant is priced at the standard
$5/$25 Opus rate; aggregated daily totals don't carry per-request prompt
sizes, so the >200K 2× surcharge can't be applied precisely. Mild
under-estimate beats the previous $0.

Co-authored-by: Lambda <lambda@multica.ai>
Co-authored-by: multica-agent <github@multica.ai>
2026-05-24 09:20:07 +02:00
Michael Wong
3e1066a638 feat(ui): add repository search to resource pickers (#3126)
* feat(ui): make project resource list scrollable

* feat(ui): add repository search to resource pickers
2026-05-24 13:47:50 +08:00
YOMXXX
bfb7c85491 fix(selfhost): derive local port URLs from env (MUL-2506) (#2939)
* fix(selfhost): derive local port URLs from env

* fix(selfhost): derive local script URLs
2026-05-24 13:05:53 +08:00
Bohan Jiang
660e27b981 fix(runtimes): extend self-healing delete guard to list row menu (MUL-2569) (#3081)
Follow-up to #3076. The detail-page guard left a bypass via the runtimes
list row menu — owners could still walk Runtimes → kebab → Delete → toast
→ runtime reappears. Extract isSelfHealingRuntime into the shared utils
module so detail and list agree on the predicate, and drop the kebab
entirely for self-healing rows (the menu's only item was Delete). Also
swap the lingering English "daemon" in the zh-Hans delete_disabled_tooltip
for 守护进程 to match the rest of the file.

Co-authored-by: multica-agent <github@multica.ai>
2026-05-24 12:41:56 +08:00
Naiyuan Qing
fd0fe1d08a feat(mobile): Multica for iOS — first version (#2337)
* docs(mobile): establish independence rules and tech-stack baseline

- Refactor root CLAUDE.md sharing rules into a single Sharing Principles
  section, replacing scattered mentions across 10 places with one source
  of truth + minimal "(web + desktop)" qualifiers on existing sections
- Add apps/mobile/CLAUDE.md with locked tech-stack baseline: Expo SDK 54,
  React Native 0.81, NativeWind 4 + Tailwind 3.4, react-native-reusables,
  TanStack Query 5, Zustand, expo-secure-store
- Mobile pins React directly (does NOT track root catalog:) so the Expo
  SDK / RN release schedule isn't blocked by web/desktop upgrades
- Visual tokens are mobile-owned (transcribed from packages/ui/styles/
  tokens.css by hand, not imported); Tailwind v3.4 vs v4 mismatch makes
  file sharing impractical anyway
- Document mobile build/release pipeline (main CI excludes mobile,
  separate mobile-verify and mobile-release workflows, EAS Update for OTA)

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

* feat(mobile): v1 shell — auth, workspace switching, inbox + my-issues

- Auth: email OTP login mirroring packages/core/auth/store.ts behavior
  (401 clears token, non-401 preserves; token written only on verify
  success); expo-secure-store with key "multica_token" matching desktop
- Workspace context: /[workspace]/ URL slug as source of truth (deep-
  link friendly), ApiClient auto-injects X-Workspace-Slug, SecureStore
  persists last-selected slug for cold-start restore
- Bottom tabs (Ionicons): Inbox / My Issues / Settings
- Inbox: actor avatar, unread brand-dot, status icon, time-ago + body
  subtitle. getInboxDisplayTitle mirrored from packages/views/inbox/
  components/inbox-display.ts
- My Issues: priority bars (matching IssuePriority bar counts from
  packages/core/issues/config/priority.ts), status dot, identifier,
  title, assignee avatar
- Settings: account info + workspace switcher; switching replaces nav
  to /[newSlug]/inbox so back stack doesn't trail to old workspace
- Multi-env: .env.staging / .env.production / .env.development.local
  with EXPO_PUBLIC_API_URL; APP_ENV in app.config.ts swaps
  bundleIdentifier so dev/staging/prod coexist on a device
- Build: dev:mobile + dev:mobile:staging scripts; main turbo
  build/typecheck/lint/test filter excludes @multica/mobile

Tech-stack (locked in apps/mobile/CLAUDE.md):
- Expo SDK 55, RN 0.83.6, React 19.2.0 (pinned, NOT catalog)
- NativeWind 4 + Tailwind 3.4 (intentional mismatch w/ web's Tailwind 4;
  visual tokens transcribed by hand from packages/ui/styles/tokens.css)
- TanStack Query 5 with AppState focus listener; Zustand 5

Not in this commit (intentional): issue detail page, mark-read mutation,
pull-to-refresh polish — next iteration.

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

* fix(mobile): unignore data/ + dedup, layout, mark-read, SVG icons, issue page

Critical: previous commit (def9c08d) was missing apps/mobile/data/ entirely
because root .gitignore has a generic `data/` rule (for backend runtime
dirs) that swallowed mobile's source tree. Added !data/ override to
apps/mobile/.gitignore. The branch was running locally only because
untracked files still load at runtime.

Functional changes on top:

- Status icon: react-native-svg, 7 variants (backlog 16-dot ring / todo /
  in_progress 0.5 / in_review 0.75 / done + check / blocked + slash /
  cancelled + x). Geometry mirrors packages/views/issues/components/
  status-icon.tsx (14x14 viewBox, OUTER_R=6, FILL_R=3.5)
- Priority icon: 4 ascending bars + "none" horizontal dash; mirrors web
  priority-icon.tsx. Urgent pulse animation deferred.
- Inbox row click: optimistic mark-read (mirrors packages/core/inbox/
  mutations.ts useMarkInboxRead) + router.push to /[ws]/issue/[id]
- My Issues row click: router.push to /[ws]/issue/[id]
- /[ws]/issue/[id] placeholder with native iOS Stack header + back
  button + edge-swipe-to-dismiss
- Inbox layout: title-row right edge = StatusIcon, body-row right edge
  = timeAgo, vertically aligned (matches web inbox-list-item.tsx)
- InboxDetailLabel mobile mirror at components/inbox/detail-label.tsx —
  type-aware second-line ("Set status to (icon) Done" / "Mentioned" /
  "Assigned to <name>" etc.). Was rendering raw markdown body which
  leaked ## heading prefixes.
- Inbox dedup: deduplicateInboxItems mirrored into apps/mobile/lib/
  inbox-display.ts (filter archived -> group by issue_id -> keep newest
  -> sort desc). Without it mobile rendered 3 unread dots while web
  sidebar showed "Inbox 1". Documented in apps/mobile/CLAUDE.md
  "Behavioral parity" with the lesson: before rendering ANY list-shaped
  API response, mirror every preprocessing step web/desktop runs
  between useQuery and JSX (dedupe / coalesce / filter / display
  helpers). Backend returns raw cache shape; client shapes it.

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

* feat(mobile): ApiClient capability set + issue detail v1 + lessons in CLAUDE.md

ApiClient hardening (data/api.ts):
- onUnauthorized callback wired in _layout.tsx — 401 clears token,
  workspace store, TanStack Query cache, replaces nav to /login.
  Idempotent via signingOutRef. Mirrors packages/core/api/client.ts
  handleUnauthorized.
- X-Request-ID per request (lib/request-id.ts)
- Structured logger: `[api] -> METHOD path (rid)` on start, `[api] <-
  STATUS path (rid, duration)` on end. console.error for 5xx,
  console.warn for 404, console.log for success.
- Zod parseWithFallback for listIssues + listTimeline (the only two
  endpoints with schemas in packages/core/api/schemas.ts today —
  matches web's current coverage; new schemas should land on the web
  side first and both clients pick them up).

Core export (packages/core/package.json):
- Add `./api/schemas` to exports map so mobile can import the shared
  Zod schemas + EMPTY_* fallbacks (pure data, on the mobile sharing
  whitelist per CLAUDE.md).

Issue detail v1 (app/(app)/[workspace]/issue/[id].tsx):
- Read issue + infinite-scroll timeline + comment composer
- Stack header shows MUL-XXX once detail loads
- Supporting files: data/queries/issues.ts, data/mutations/issues.ts,
  components/issue/{timeline-list,comment-composer,...},
  lib/{format-activity,timeline-coalesce,timeline-thread}.ts
- Property edits, reactions, mentions, image lightbox deferred to V2+

apps/mobile/CLAUDE.md — Lessons learned (encode into reflexes):
1. Install/upgrade deps: `pnpm view <pkg> dist-tags` first; `expo
   install` for Expo packages, never `pnpm add` blindly
2. New source subdirectory: `git check-ignore -v` to verify against
   root .gitignore generic rules (data/, build/, bin/); add !data/
   override if matched. Cost a 14-file missing commit before.
3. ApiClient capability list (Zod parse / 401 callback / X-Request-ID
   / structured logger) — all baseline, not polish
4. Visual alignment is baseline, not polish — tab icons, screen titles,
   right-column vertical alignment of trailing elements, type-aware
   secondary lines (mirror InboxDetailLabel, not raw item.body)

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

* feat(mobile): activity row parity with web — lead icon, coalesce badge, single-line

Activity rows previously showed a two-line `[verb] / [absolute time]` block
with no icons, mismatching web (issue-detail.tsx:1046-1100). This redesign
brings mobile in line:

- Single-line layout: [lead icon] [name] [verb...truncate] [×N] [time→]
- Contextual lead icon: StatusIcon(details.to) for status_changed,
  PriorityIcon(details.to) for priority_changed, inline Calendar SVG for
  due_date_changed, ActorAvatar(size=16) otherwise
- Relative time right-aligned (drops the made-up "Linear-style" absolute
  timestamp; web uses relative + hover tooltip, mobile keeps relative only
  for v1)
- Coalesce ×N badge for non-task actions; task_completed/failed already
  bake the count into their copy
- Whole row text-xs muted-foreground — activity is supposed to feel quiet
  next to comment bubbles
- FlatList contentContainer gap-3 owns row spacing; rows themselves drop
  their own py so spacing doesn't double up

Calendar icon is an inline 16-line react-native-svg primitive — avoids
adding lucide-react-native to the mobile baseline.

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

* feat(mobile): standalone markdown renderer with mentions, files, images, lightbox

Replaces `<Text>{content}</Text>` placeholders in issue description and
comment body with a full markdown pipeline at apps/mobile/lib/markdown/.

Pipeline: preprocess → marked.lexer → AST transforms → RN component tree.
Uses `marked` (~30KB JS parser) for CommonMark+GFM tokens; renderer is
hand-written (~600 LoC) for full control over RN's text-in-text rules,
mention chips, file cards, and inline-image-to-block promotion.

Supported in this drop:
- Headings, paragraphs, lists (ordered/unordered/task), block quotes,
  hr, fenced code (no syntax highlight), strong/em/del/codespan, autolinks
- Mention chips: mention://member/<id>, mention://agent/<id>,
  mention://issue/<id> — name resolution via existing useActorLookup;
  issue tap navigates to /:slug/issue/:id
- File cards: !file[name](url) preprocessed to [📎 name](url) link;
  Linking.openURL hands off to system viewers (PDF, doc, share sheet)
- Inline images promoted to block siblings (AST pass) — marked always
  wraps `![]()` in paragraph and RN can't put Image inside Text
- Real aspect ratio via Image.getSize, expo-image for caching/transition,
  global LightboxProvider with react-native-image-viewing for tap-to-zoom
- Tables degrade to card-per-row with header:value pairs (mobile-friendly
  responsive pattern; horizontal scroll tables get lost on touch)
- Embedded HTML stripped before lexing: <br> → newline, comments removed,
  other tags peeled to inner text. Residual html tokens render muted

Cross-package: lifted preprocessMentionShortcodes to @multica/core/markdown
so mobile can import it (mobile may import pure functions from core; cannot
import from packages/ui per Sharing Principles). packages/ui/markdown
keeps its own synced copy with a cross-reference comment — packages/ui
cannot import from core (Package Boundary Rules), so two synced copies
is the cleanest path.

Drops the comment-card "📎 N attachments" placeholder; markdown rendering
covers inline images and !file[] cards. attachments[] is backend cleanup
metadata, not display content (matches web).

New deps: marked@18, expo-image@55, react-native-image-viewing@0.2.
All Expo Go compatible — no native modules added.

Plan: ~/.claude/plans/plan-dynamic-narwhal.md
Research: apps/mobile/docs/markdown-renderer-research.md

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

* wip(mobile): markdown engine swap to enriched-markdown + sprint progress

Bundles the markdown rendering overhaul plus in-flight mobile feature
work as a single WIP for review.

Markdown work (the new direction):
- Swap internal Markdown component from hand-rolled marked walker to
  react-native-enriched-markdown (Software Mansion, native md4c).
  Public API <Markdown content={...} /> unchanged; consumers untouched.
  Mention links degrade to colored links + onLinkPress routing.
- Pre-swap fixes that landed first: 3-layer inline code (later corrected),
  Shiki via react-native-shiki-engine wired (now bypassed; code retained
  for selective re-enable on code blocks), code block copy button with
  expo-clipboard + expo-haptics, inline SVG copy/check icons, header
  scale calibrated to Apple HIG, paragraph leading-6 for CJK, list
  bullet column 24->16, lineBreakStrategyIOS="hangul-word" on outer
  paragraph Text.
- Preprocess: <br> -> "  \n" (CommonMark HardBreak) so md4c respects
  intentional breaks without misreading bare \n.
- Drop the Expo Go compatibility constraint from CLAUDE.md and
  markdown-renderer-research.md (project runs on dev client).
- New apps/mobile/docs/markdown-renderer-research.md captures the
  RN nested-Text rendering constraints (#10775 / #45925 / #6728), the
  CJK amplification mechanism, the typography scale calibration, and
  every decision-log entry from the engine evolution.

Other in-flight mobile features included:
- Issue detail timeline polish, comment composer + action sheet,
  mention suggestion bar, emoji picker sheet, reaction bar.
- Status / priority / assignee / label / due date picker sheets.
- My Issues filter sheet + view store.
- Realtime layer (ws-client, realtime-provider, use-inbox-realtime).
- Data layer additions (queries, mutations, schemas, attribute chips).

Cross-package:
- packages/core/api/schemas.ts: export IssueSchema for mobile use.

Build: native rebuild required after pulling (enriched-markdown is
a native Fabric module).

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

* feat(mobile): 4-tab shell — Chat tab, More tab, single-row header, filter chips, modal stubs

Scaffolds the next phase of mobile so per-feature work has a clean shell
to fill into. No new business logic, no data fetching beyond what already
existed; this is layout + navigation only.

Tab restructure (3 → 4 tabs):
- Add Chat tab placeholder (will port web bottom-right chat widget logic).
- Rename Settings → More; convert to grouped iOS-style list with sections
  Workspace / Personal / Account / Workspaces, all SectionGroup + NavRow.
- Workspace switcher list inside More uses the same NavRow visual pattern
  (active row marks with checkmark, inactive shows chevron).

Header (single-row):
- ScreenHeader simplified to one row: large title left, right actions
  slot. Removed the second-row WS switcher idea — switcher only lives in
  More now (the global header would mix scope levels with global actions).
- New HeaderActions component holds the two global actions: search and
  create-issue. Wired into all 4 tabs.

My Issues filter relocation:
- Filter button moved out of the header right slot (was a scope-mismatch
  hazard — global header should not host tab-local controls). Now sits
  inline at the right end of the ScopeTabs row.
- New ActiveFilterChips row renders below ScopeTabs when filters are
  active; each chip is tap-to-clear. Mirrors iOS Mail/Things UX.

Stubs for next phase:
- [workspace]/new-issue.tsx and [workspace]/search.tsx as modal screens
  presented from HeaderActions. Both have a Cancel button (new
  ModalCloseButton) in headerLeft.
- More tab sub-pages: more/{projects,agents,pins,notifications}.tsx
  registered in [workspace]/_layout.tsx with native Stack headers.

Cross-cutting:
- lib/issue-status.ts exports PRIORITY_LABEL alongside STATUS_LABEL
  (used by the new filter chip row).
- All new code uses Ionicons from @expo/vector-icons; not adding
  lucide-react-native — see comment-composer.tsx for the reasoning.

Verified: pnpm --filter @multica/mobile typecheck passes; lint shows
only pre-existing issues unrelated to this change; more/ subdirectory
checked against .gitignore per CLAUDE.md mobile rule 2.

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

* feat(mobile): hybrid markdown — Shiki code + lightbox images, prose via enriched

react-native-enriched-markdown does not expose JS-level custom renderers
(issues #54, #232, #246), so syntax highlighting, tap-to-lightbox, and
copy buttons cannot live inside enriched. Maintainer-endorsed workaround
(#246): split markdown at those boundaries and render the leaves in
React.

splitMarkdown walks marked.lexer tokens and emits prose / code / image
segments. Each prose island gets its own EnrichedMarkdownText; code
blocks reuse the in-house CodeBlock (Shiki + copy + horizontal scroll);
images reuse MarkdownImage (expo-image + lightbox). Paragraph-embedded
images are promoted to block siblings, matching GitHub mobile and
Linear iOS.

Drops ~600 LOC of dead walker code (render-block, render-inline, ast,
link, mention-chip, key) that the previous engine swap left behind.

Visual polish for the hybrid output:
- inline code alpha 20% → 12%; enriched paints over the full line
  height and RN can't apply the padding/radius/0.85em that keep
  GitHub web's chip compact, so the web alpha reads too heavy here.
- new `code-surface` token (#e8e8eb), one step darker than `secondary`,
  plus a 1px `border-border` hairline. Code block now elevates inside
  both white issue bodies and grey comment cards.
- code block margin my-3 — breathing room both sides.

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

* feat(mobile): new issue creation — Manual mode fully wired with @ mention

Mobile can now actually create issues. Phase 1 left submit as a
console.log stub; this iteration wires Manual mode end-to-end so an
issue typed on a phone lands in the backend and appears in the user's
my-issues list on next refresh.

Wire-up:
- api.createIssue(body) — POST /api/issues, mirroring server route at
  server/cmd/server/router.go:320. Matches the CreateIssueRequest type
  exported from @multica/core/types so payload shape agrees across
  clients.
- useCreateIssue() mutation in data/mutations/issues.ts — no optimistic
  insert (the my-issues list is status-bucketed + scope-filtered, so
  optimism needs bucket+scope decisions; invalidation is simpler and
  hosted-backend latency is sub-300ms). onSuccess invalidates myAll
  and inbox query keys.
- new-issue.tsx Manual panel: submit ↑ calls mutateAsync, dismisses on
  success, surfaces errors via Alert.alert with the form state preserved
  so the user can retry. Button shows a spinner during the in-flight
  request and all inputs are disabled.

@ mention in description (members + agents):
- Mirrors comment-composer.tsx pattern exactly — selection tracking,
  tokenAtCursor on every change/selection event, MentionSuggestionBar
  rendered above the chip row, insertMention on pick, markers list
  appended.
- Title input stays plain (web doesn't allow mentions in title; we
  mirror that).
- Wire format on submit: serializeMentions(description, markers) →
  `[@name](mention://type/id)` markdown. Recognised by:
    * server/internal/util/mention.go ParseMentions
    * packages/views/editor/extensions/mention-extension.ts (web Tiptap)
    * apps/mobile/components/issue/mention-chip.tsx (mobile timeline)
- Backend does NOT trigger inbox notifications for mentions in issue
  descriptions (only on comments — see server/internal/handler/comment.go
  ParseMentions call). Mobile doesn't need to send a separate mentioned_*
  field; the markdown alone is sufficient.

Header polish:
- SubmitIssueButton accepts a `loading` prop; renders ActivityIndicator
  in place of the ↑ glyph while pending. Defends against double-tap.
- ModalCloseButton's earlier "Cancel" text is now a ✕ icon in a circle
  to match the new-issue / search modal visual reference (Linear-style).

Agent mode unchanged — still a placeholder that console.logs and
dismisses. Phase 3 will wire the real agent picker, apiClient
.quickCreateIssue, and the daemon version gate.

Explicitly NOT in this commit (later phases):
- Markdown formatting toolbar (Phase 2C)
- Project / Labels / Due date / Parent chips (Phase 2D)
- Image / file attachments (Phase 2E)
- #MUL-42 issue references, @all mention
- Draft persistence, "Create Another" toggle
- Pre-fill from sub-issue entry, optimistic list insert
- Success toast (success path = silent dismiss; mobile has no toast
  component yet)

Verified: pnpm --filter @multica/mobile typecheck passes; lint shows
only pre-existing issues unrelated to this change.

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

* feat(mobile): WS realtime coverage — issue detail / my issues / agent tasks

Previous iteration shipped issue creation but mobile only ran WS for
inbox. Anything else (issue detail, my-issues list, agent task progress)
was pull-refresh only. Cross-client edits, agents working in the
background, and concurrent user changes all required the user to
manually refresh.

This commit closes that gap so all four user-facing surfaces stay
live without input. Mobile now matches web/desktop in product
freshness, while keeping mobile-specific patterns (patch over
invalidate, per-screen mount, event-always-wins) that reflect cellular
and AppState constraints.

New (3 files):

- data/realtime/issue-ws-updaters.ts — mobile-owned cache patchers.
  Pure functions over QueryClient: patchIssueDetail, prependTimelineEntry,
  patchTimelineEntry, removeTimelineEntry, patchMyIssuesList,
  removeFromMyIssuesList, addCommentReaction, removeCommentReaction,
  addIssueReaction, removeIssueReaction, patchIssueLabels,
  commentToTimelineEntry. NOT imported from packages/core because web's
  updaters bind to web's issueKeys instance and target bucketed caches
  mobile doesn't have — see CLAUDE.md "Mobile-owned updaters" rule.

- data/realtime/use-issue-realtime.ts — per-issue subscriptions mounted
  by the detail screen. Subscribes to 11 issue/comment/activity/reaction
  events plus 6 task:* events for live agent progress. Every handler
  filters by issue_id so we ignore noise from other issues. Reconnect
  invalidates only this issue's detail + timeline (not a global sweep).
  On issue:deleted for the active id, runs onDeleted callback so the
  screen can router.back() rather than strand the user on a 404.

- data/realtime/use-my-issues-realtime.ts — listing-level subscriptions
  mounted globally. issue:created → invalidate myAll (we don't know
  scope/filter membership for a fresh issue). issue:updated → patch via
  setQueriesData across every cached scope/filter combination.
  issue:deleted → strip from every cached list. Reconnect → invalidate
  myAll.

Modified (2 files):

- app/(app)/[workspace]/_layout.tsx — RealtimeSubscriptions adds
  useMyIssuesRealtime alongside useInboxRealtime. Both are workspace-
  session lifetime.

- app/(app)/[workspace]/issue/[id].tsx — mounts useIssueRealtime(id)
  with router.back as the onDeleted callback.

Docs (apps/mobile/CLAUDE.md):

New top-level section "## Realtime / WebSocket strategy" before the
Lessons section. Documents:
- Three-layer stack (ws-client → realtime-provider → per-feature hooks)
- Mount strategy: list-level global vs per-record per-screen, and why
  mobile doesn't use a single centralized useRealtimeSync like web
- Patch over invalidate (cellular-data rule)
- Mobile-owned updaters (don't import packages/core/issues/ws-updaters)
- Event-always-wins conflict policy
- Per-hook reconnect scoping (no global invalidate sweep)
- Recipe for adding new event coverage

Out of scope (deferred):
- Workspace member events (Phase 3D) — wait until More tab adds a real
  members list
- "N new comments" floating banner — patch-only for now
- Push notifications (APNs) — requires server config + entitlement

Verified: pnpm --filter @multica/mobile typecheck passes; lint shows
only pre-existing issues unrelated to this change.

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

* fix(mobile): markdown segment spacing uses Yoga gap, not per-child margin

Two consecutive fenced code blocks (and code-image / image-image
combos) rendered with effectively zero gap on iOS — NativeWind 4
compiles `my-3` to `marginVertical: 12`, but Yoga's sibling margin
behaviour doesn't accumulate the way web CSS does. Result: a `my-3`
sibling pair landed at ~12px on the screen instead of 24px, and the
border-on-border made it look like the two blocks were glued.

Move the spacing from per-child `marginVertical` to a `gap-3` on the
markdown root `<View>`. Gap is layout-level (Yoga implements it
directly), independent of margin behaviour, and uniformly applies
between every segment pair — prose ↔ code, code ↔ code, image ↔ code,
etc. CodeBlock and MarkdownImage drop their `my-3` / `mb-3` since the
parent now owns the spacing.

Prose ↔ code reads as ~24px (prose's enriched-markdown
`paragraph.marginBottom` 12 + root gap 12), which is the comfortable
"new block" feel; code ↔ code reads as exactly 12px, which is the
"these are related" feel. Both improve on the previous 0–8px crunch.

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

* feat(mobile): unified input UX — mention hook, markdown toolbar, file upload

new-issue Description and Comment composer used to each carry their
own copy of mention state (mentioning / recomputeMentioning /
onChangeText / onSelectionChange / onAtButton / onSelectMention /
serialize), ~50 LOC of identical boilerplate per surface. The
description had no toolbar at all; the comment had a lone left-side
`@` button. Visually the two body inputs looked like different
products — description was bare text, comment was rounded-2xl
bg-secondary with a focus tint.

Three changes consolidate the body-input experience:

1. Shared mention pipeline. `useMentionInput()` in lib/use-mention-input.ts
   owns text / selection / markers / mentioning, plus handlers
   (onChangeText, onSelectionChange, onAtButtonPress), suggestion-bar
   props, `insertAtCursor`, `insertAtLineStart`, serialize, snapshot,
   restore, reset. Comment-composer and new-issue both consume it,
   killing the duplication.

2. Shared keyboard-bar markdown toolbar. Linear-iOS range: `@`, bullet
   list, checklist, code block, quote, image, file. All buttons are
   literal-character inserts via hook helpers — no WYSIWYG. Toggles
   like bold/italic are deliberately out of scope because RN TextInput
   can't render styled ranges inside the input; a real WYSIWYG would
   mean swapping to react-native-enriched and crossing an HTML <->
   markdown boundary, which is a separate decision.

3. File upload. `api.uploadFile(asset, { issueId?, commentId? })`
   mirrors web's `/api/upload-file` contract but takes the RN-shaped
   `{ uri, name, type }` payload and validates the response against
   a strict `AttachmentSchema` (no silent fallback — an empty `url`
   would put a broken link into the editor). `useFileAttach()` glues
   expo-image-picker / expo-document-picker into the toolbar's image
   and file buttons. Context follows web: comments pass issueId,
   not-yet-created issues pass nothing. MAX_FILE_SIZE is mirrored, not
   imported, per mobile CLAUDE.md.

Cleanup:
- `MOBILE_PLACEHOLDER_COLOR` + `MIN_BODY_INPUT_HEIGHT_PX` in
  components/ui/input-tokens.ts; six hardcoded `#a1a1aa` callers now
  reference the const.
- Description now sits in a rounded-2xl bg-secondary/40 container
  with a focus-tint border, visually matching the comment composer.
- app.config.ts gets `expo-image-picker` plugin with
  `photosPermission` set and `cameraPermission` / `microphonePermission`
  disabled — without this Info.plist string, calling the image picker
  hard-crashes on iOS 14+.

A dev-client rebuild is required (new native modules); existing
behaviour and read-only rendering are unchanged.

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

* fix(mobile): hard 30s fetch timeout + TanStack Query signal pass-through

Triggered by a real user-visible bug: the Inbox tab's pull-to-refresh
spinner sometimes stuck on indefinitely after returning the app to the
foreground. List items rendered normally underneath, but `isRefetching`
never flipped back to `false`.

Root cause: api.ts fetch() had no timeout, no AbortController, and
ignored caller-supplied signals. iOS suspends background apps and can
silently kill in-flight network tasks (facebook/react-native#35384,
#38711). When the app foregrounded, the suspended Promise neither
resolved nor rejected. TanStack Query saw a fetch already in flight
and would not start a replacement on invalidate — it just waited
forever on the dead Promise.

Fix is three layers (all three required — partial fix leaves a footgun):

1. api.ts fetch() — hard 30s timeout via manual AbortController +
   setTimeout. Hermes does not implement AbortSignal.timeout() /
   AbortSignal.any() (facebook/react-native#42042, livekit#4014), so
   composition is via addEventListener("abort", ...) forwarding. On
   timeout we throw an ApiError(message, status=0) so callers see a
   real error instead of a Promise-that-never-settles.

2. All read-side api methods now accept opts?: { signal?: AbortSignal }
   and forward to fetch(): listInbox, listWorkspaces, getMe, listMembers,
   listAgents, listIssues, getIssue, listTimeline, listLabels,
   listProjects. Mutations are unchanged — TanStack Query doesn't pass
   a signal to mutationFn.

3. All queryFn definitions in data/queries/* now destructure { signal }
   and forward it. The TanStack official cancellation guide states that
   the signal is aborted when a query becomes out-of-date or inactive,
   so this is the primary mechanism that unwedges stuck queries (the
   30s timeout is the safety net for cases where nothing else fires).

Already in place (untouched, but documented):
- query-client.ts wires focusManager ← AppState and onlineManager ←
  NetInfo per TanStack's React Native official guide. focusManager
  alone wasn't enough — when a fetch hangs, "focused = true" can't
  unstick the query without signal cancellation or timeout. The three
  pieces work together.

Docs (apps/mobile/CLAUDE.md):

New Lesson #5 captures all of the above with:
- The original symptom + root cause
- The three-part rule (timeout / api opts / queryFn destructure)
- Hermes-specific caveats with citations to the upstream issues
- A grep verification command future readers can run to enforce part 3

Verified:
- pnpm --filter @multica/mobile typecheck passes
- pnpm --filter @multica/mobile lint shows only pre-existing issues
  unrelated to this change
- grep -n "queryFn: () =>" apps/mobile/data/queries/*.ts returns zero
  matches (every queryFn destructures signal)

Sources cited in CLAUDE.md:
- TanStack Query Cancellation guide (tanstack.com/query/v5)
- TanStack Query React Native official guide (tanstack.com/query/v5)
- facebook/react-native#42042 (AbortSignal.timeout unavailable in Hermes)
- facebook/react-native#35384 (iOS background fetch failure)
- facebook/react-native#38711 (iOS background JS Timers don't fire)
- livekit/livekit#4014 (AbortSignal.any unavailable in React Native)

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

* feat(mobile): chat v1 — single-tab IA, optimistic send, two-tier WS

Fill the Chat tab placeholder. UX is mobile-native (top bar with tap-title
sheet, message list, bottom composer — no two-layer nav); logic is at
parity with web (API/events/has_unread/optimistic sequence/permissions/
enums all mirrored).

Includes:
- data layer: 8 chat API methods + zod schemas with .catch() enum drift
  fallback; queries / mutations (optimistic delete + markRead); per-
  session drafts store
- two-tier realtime: listing-level hook mounted in workspace _layout
  (chat:session_* + chat:done for has_unread), per-record hook mounted in
  the chat screen (chat:message/done + 5 task:* events, all filtered by
  chat_session_id, scoped reconnect invalidates); ws-updaters carry an
  invalidate fallback for pre-#2123 servers that omit chat:done payload
- rule mirrors: canAssignAgent, failureReasonLabel, agent availability
  three-state hook (mirror-not-import per apps/mobile/CLAUDE.md)
- UI: ChatHeader (tap title → SessionSheet) + ChatMessageList (FlatList,
  destructive bubble on failure_reason) + ChatComposer (mention +
  markdown toolbar minus file/image) + StatusPill (Thinking · Ns) +
  SessionSheet (with agent avatars + long-press delete) +
  AgentPickerSheet + NoAgentBanner

v1 cuts (deferred to v2): file upload, rename, Chat tab unread badge,
agent presence dot, task tool_use detail expansion, focus mode route
anchor, starter prompts, history pagination, mobile test infra.

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

* feat(mobile): add due_date / project to create-issue, drop agent toggle

Wire the last two CreateIssueRequest fields that have a meaningful UX on
mobile (due_date, project_id) to the new-issue form via two new chips
sharing the existing CreateFormAttributeRow + picker-sheet pattern.

Fixes a silent 400 on the existing detail-page due_date update: the
picker was emitting YYYY-MM-DD but server/internal/handler/issue.go
parses with time.Parse(time.RFC3339, ...) which rejects date-only. Now
sends full ISO, matching web's due-date-picker.tsx.

Removes the placeholder agent-mode toggle from new-issue — it was a
dead UI surface (logged to console on submit, never wired). Mobile's
create-issue is now manual-only, aligned with web's form semantics.

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

* feat(mobile): redesign chat composer as floating card

Move chat input to a rounded card with inline @ and Send/Stop buttons
(Linear / iMessage idiom), dropping the markdown toolbar that comment-
composer needs but chat doesn't. Send stays visible-but-disabled when
there's no draft so the button row no longer jitters as the user types.
Adds SF Symbols, expo-haptics, and reanimated crossfade for send↔stop.

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

* feat(mobile): add issue MentionType + viewed-issues store

Extend MentionType with "issue" and serialize issue mentions without
the leading `@` in the link label, matching web's
mention-extension.ts:67-74. New in-memory LRU tracks recently viewed
issues per workspace so the chat composer can surface them next.

Issue detail screen pushes its id into the store on mount. Suggestion
bar UI lands in a follow-up commit.

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

* feat(mobile): @ in chat picks an issue (Recent + My issues)

In 1:1 user↔agent chat sessions, @member and @agent are noise (no
notification channel; the session is already bound to one agent).
Switch the mention bar to surface issues instead — Recent (most recent
5 from the in-memory viewed-issues store) followed by My issues
(assigned-to-me, max 10, deduped). The serialized token matches web
byte-for-byte ([MUL-XXX](mention://issue/<uuid>)) so the agent can read
the reference directly even though chat.go SendChatMessage doesn't yet
run ParseMentions — that's a follow-up.

MentionSuggestionBar gains a mode="comment"|"chat" prop; comment mode
is the default and preserves existing behaviour for the issue comment
composer and new-issue body.

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

* fix(mobile): stable empty reference in viewed-issues selector

selectViewedIssueIds was returning a fresh `[]` when the workspace had
no entry yet, which made useSyncExternalStore see a different snapshot
on every read and trigger "getSnapshot should be cached" + infinite
re-render. Share a single frozen empty array for all no-entry paths,
matching the Zustand footgun rule in CLAUDE.md.

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

* feat(mobile): iMessage-style keyboard dismiss in chat message list

Drag the list to interactively pull the keyboard down with the finger,
or tap empty space between bubbles to dismiss. `handled` keeps long-
press action sheets and other in-bubble Pressables firing normally.

Sending a message intentionally keeps the input focused so the user
can immediately type the next one — RN's default and the chat-app
standard.

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

* fix(mobile): tap message area dismisses keyboard in chat

keyboardShouldPersistTaps="handled" on FlatList has a long-standing
RN bug (facebook/react-native#31448) that prevents the tap-to-dismiss
path from firing in many setups. Wrap ChatMessageList with a Pressable
that calls Keyboard.dismiss() — the canonical workaround documented
in the RN Keyboard guide and the Expo keyboard-handling guide.

Interactive drag-dismiss on the FlatList itself (the previous commit)
is an independent code path and continues to work.

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

* fix(mobile): drop double home-indicator padding under chat composer

chat.tsx wrote SafeAreaView edges={["top","bottom"]} while the parent
<Tabs> container already absorbs the home-indicator inset on behalf of
all tab screens. The result was ~34pt of empty space below the
composer. Sibling tabs (inbox / my-issues / more) all use
edges={["top"]} — chat was the outlier.

The gap only became visible after the floating-card composer landed;
the previous sticky-bar layout disguised it as bg-coloured padding.

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

* fix(mobile): simplify create-issue layout, fix render loop

Reshape the new-issue modal into one vertical scrolling form
(title → description → property chips), matching the Apple
Reminders / Linear iOS pattern. Previously the chips sat sticky-
pinned above the keyboard, which made them invisible when the
keyboard was up and stranded at the bottom of an empty screen
when it was down — neither state served the user.

Drop the markdown toolbar and upload buttons from the modal:
mobile users almost never format markdown when creating an issue,
and attachment upload is deferred for this release. Removing them
also lets the form breathe vertically.

Fix the "Maximum update depth exceeded" loop that surfaced once
real data started flowing. Root cause was duplicate
useQuery(projectListOptions) subscribers in CreateFormAttributeRow
and ProjectPickerSheet on the same key, under React 19 strict
mode. Form now holds the full Project object lifted from the
picker, so only the picker queries the list.

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

* feat(mobile): More tab opens global nav popover

Replaces the full-screen More tab with a bottom-bar trigger that opens a
popover containing the workspace switcher and 9 nav destinations
(Inbox, My Issues, Favorites, Projects, Initiatives, Views, Teams,
Settings, Search). Uses expo-router Tabs.Screen listeners.tabPress +
preventDefault — the more.tsx route is a stub that redirects to inbox
if hit directly. Custom Modal popover (no @gorhom/bottom-sheet) since
that lib still requires Reanimated v3 and mobile is on v4. Account info
+ workspace list + sign out moved into a dedicated Settings page.

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

* feat(mobile): add projects feature with realtime cache sync

Mobile parity for the projects domain — browse, detail, create, edit,
delete, plus GitHub resource attach. UX adapted to iOS (Stack push +
modal sheets, picker sheets per property, ActionSheet for Edit/Delete,
collapsible Open/Done buckets in related issues) while preserving web's
semantics: 5 status enums (incl. cancelled), 5 priorities, lead supports
both members and agents, counts come from server fields.

Data layer follows mobile CLAUDE.md rules: parseWithFallback + signal
on every read, optimistic patch + WS event-always-wins on mutations,
mobile-owned ws-updaters (not imported from packages/core) that patch
over invalidate to honour the cellular-data rule. Per-record realtime
hook subscribes to issue:* events filtered by project_id so the
related-issues list stays fresh without pull-to-refresh.

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

* feat(mobile): redesign More popover — user card + lean nav

- Add user identity card at top of GlobalNavMenu, mirroring web sidebar
  dropdown (packages/views/layout/app-sidebar.tsx:496). Tap pushes into
  the existing settings page where account / workspaces / sign-out
  already live.
- Trim NAV_ITEMS to Projects only. Inbox / My Issues / Chat are bottom
  tabs; Settings is reached via the user card.
- Delete six orphaned stub routes (favorites, initiatives, views, teams,
  notifications, pins) — no remaining external references.

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

* refactor(mobile): extract shared IssueRow + props-driven filter sheet

- Add components/issue/issue-row.tsx as the single source for list-style
  issue rendering. `<IssueRow issue showStatus? />` — showStatus opt-in
  for ungrouped lists (project related-issues), default off where the
  SectionList header already shows status (my-issues).
- Replace the two inline IssueRow copies in (tabs)/my-issues.tsx and
  components/project/project-related-issues.tsx.
- Rename MyIssuesFilterSheet → IssueFilterSheet and replace store-coupled
  state with props so the same sheet can serve any view-store. My Issues
  call site passes useMyIssuesViewStore selectors as props.
- Rename filterMyIssues → filterIssues (function was already generic;
  the misnomer just reflected the original single call site).

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

* feat(mobile): workspace Issues page in More popover

New surface for the workspace-wide issue list. Mirrors web's IssuesPage
(packages/views/issues/components/issues-page.tsx) at mobile fidelity:
SectionList grouped by status, status + priority filter (reuses the
shared IssueFilterSheet), pull-to-refresh, empty/error states, IssueRow
identical to other surfaces.

Differs from My Issues by dropping the Assigned/Created/Agents scope tabs
(workspace-wide list has no per-user scope) and using an independent
view-store so filters don't bleed between the two pages.

Plumbing:
- data/queries/issues.ts → issueListOptions(wsId) using existing
  issueKeys.list(wsId) prefix (already wired into invalidations from
  mutations and project realtime).
- data/stores/issues-view-store.ts → status/priority filter state.
- data/realtime/use-issues-realtime.ts → list-level WS subscription;
  patches list(wsId) on issue:created (prepend) / updated / deleted,
  invalidates on reconnect. Mounted in <RealtimeSubscriptions />.
- data/realtime/issue-ws-updaters.ts → patchIssuesList /
  prependToIssuesList / removeFromIssuesList, plus extending
  patchIssueLabels to also patch list(wsId).
- workspace _layout: register more/issues Stack.Screen, drop Stack.Screen
  entries for the routes deleted in 5cc7f01 (favorites/initiatives/
  views/teams/notifications/pins).

Filters beyond status/priority (assignee/project/label/creator) are a
v1.1 follow-up; v1 ships at My Issues parity for code reuse.

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

* chore(mobile): add Issues entry to More popover

Wires the new workspace Issues page (more/issues.tsx) into GlobalNavMenu,
ordered above Projects (higher-frequency surface).

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

* chore(mobile): rename ios run scripts to ios:device, add .env.example, document commands

`expo run:ios` always meant device install in this project, but the
unqualified `ios` / `ios:mobile` script names invited confusion with the
simulator default. Rename to `ios:device` / `ios:device:staging` so the
intent is explicit, and pair with a checked-in `.env.example` so a fresh
clone knows which keys mobile needs. CLAUDE.md picks up the new command
list under the existing Commands section.

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

* refactor(mobile): drop paginated timeline, fetch as single ASC list

Server-side timeline pagination was retired (#2322) because p99 issues
have ~30 entries — cursors were pure overhead and split reply threads
across page boundaries. Mobile mirrors the new shape:

- `api.listTimeline` returns `TimelineEntry[]` directly (was
  `TimelinePage` with `next_cursor` + `has_more_before`).
- `issueTimelineOptions` is a flat `queryOptions` (was
  `infiniteQueryOptions`); query consumers drop the page-walking dance.
- WS handlers `comment:created` / `activity:created` now `append`
  (oldest-first ASC list) instead of `prepend`. Mirror updater renamed.
- Timeline list view collapses to a single `FlatList data={entries}`,
  no more `pages.flat()` + `fetchNextPage` plumbing.

Mirrors web's post-#2322 `issueTimelineOptions` shape (per
apps/mobile/CLAUDE.md "mirror, don't import").

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

* fix(mobile): restore Chat list scrolling + align bubble UI with web

The Chat tab message list was unscrollable. Two distinct root causes
under the same surface symptom:

1. Wrapper hijacking the touch responder. chat.tsx mounted a
   Pressable around ChatMessageList to implement "tap empty area =
   dismiss keyboard". Any Touchable* (Pressable / TouchableWithoutFeedback /
   TouchableOpacity) claims the responder via the shared Touchable mixin
   and does NOT reliably hand it back to the child FlatList for pan
   gestures, killing scroll. Removed entirely — `keyboardShouldPersistTaps
   ="handled"` on the FlatList already provides the same behaviour per
   RN docs (a tap not handled by a child bubble dismisses the keyboard),
   and `keyboardDismissMode="interactive"` covers drag-to-dismiss. Mirrors
   web's bare `<div className="flex-1 overflow-y-auto">` mount.

2. `onContentSizeChange` re-sticking to bottom on every async layout.
   Markdown async rendering (Shiki highlight, image natural-size
   resolution, lightbox provider injection) fires content-size changes
   for seconds after first paint. The previous handler called
   `scrollToEnd` unconditionally, snapping the user back to the bottom
   the instant they tried to drag up. Replaced with a sticky-bottom
   state machine — `isAtBottomRef` / `userHasScrolledRef` /
   `firstMsgIdRef` — that only re-sticks while the user is anchored
   at the bottom; reading history is left alone. Same semantic as
   iMessage and web ChatWindow.

Bonus alignment with web's bubble styling:
- User bubble: bg-muted (was bg-primary dark), max-w-[80%] (was 88%),
  text-foreground.
- Assistant: w-full (was self-start max-w-[88%]) so Markdown / code
  blocks / tables get the full content width.
- Outer content padding: px-4 pt-3 pb-4 gap-3 (was px-3 py-3 gap-2),
  matching web's `max-w-4xl px-5 py-4 space-y-4` rhythm at mobile scale
  and giving the last bubble breathing room above the composer.
- FlatList itself gets `className="flex-1"` so its height is the
  remaining viewport in the KeyboardAvoidingView column, matching web's
  `flex-1 overflow-y-auto` host.

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

* feat(mobile): default Chat tab to most recent session on first entry

Web's chat-window opens to an empty state when no activeSessionId is
persisted, because the sidebar SessionDropdown makes one-click switching
cheap. On a phone, picking a session is 4 taps (header → sheet open →
row → close), so an always-empty default is friction — users complained
they had to re-pick the session every cold start.

Mobile-only deviation: on the first Chat tab entry for a given
workspace, jump straight to the most recent session (`sessions[0]`,
server-sorted by `updated_at desc`). A per-workspace `useRef` flag
makes the hydration a one-shot — subsequent user intent (point + New,
delete-active) sets activeSessionId to null and is respected forever
after. When the user switches workspaces, the ref resets so the new
workspace gets its own first-entry hydration.

Behavioural parity is preserved: counts / visibility / permissions /
enums match web exactly. UX is allowed to diverge on UI mechanics per
apps/mobile/CLAUDE.md.

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

* fix(mobile): inbox row flips to read state before navigation push

Tapping an unread inbox row produced no visible "now read" feedback —
the row disappeared into the issue detail push transition still wearing
its unread bullet and bold-foreground style. Users came back via the
back button to find it had become read (correct cache state, just no
real-time feedback).

Root cause: `useMarkInboxRead.onMutate` does `await qc.cancelQueries`
before the optimistic `setQueryData`, so the optimistic write lands one
microtask after the synchronous `router.push`. iOS native stack
captures the source view screenshot at push time — the screenshot freezes
the row in its unread state, and the transition animates that frozen
frame regardless of any later cache write.

Fix: in `onPressItem`, do the optimistic `setQueryData` synchronously
right before calling `markRead.mutate(...)`. The mutation still runs
end-to-end (so the server PATCH fires and `onSettled` invalidate
reconciles), but the row already shows the read style on the frame
that gets screenshotted for the push transition. The tab-bar inbox
badge also drops one count at the same instant for the same reason.

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

* feat(mobile): unread badges on Inbox and Chat tabs

Surface the same unread signals web puts on the sidebar (inbox) and
the ChatFab (chat). On a phone the user lives on the tab bar, so
mounting badges directly on the Inbox and Chat tabs is the closest
equivalent.

Display semantics mirror web exactly (apps/mobile/CLAUDE.md "counts
must agree"):

- Inbox badge = `deduplicateInboxItems(items).filter(i => !i.read).length`,
  same as web's `useInboxUnreadCount` (packages/core/inbox/queries.ts:22).
  99+ truncation matches the sidebar.
- Chat badge = `sessions.filter(s => s.has_unread).length`, same as web's
  ChatFab (packages/views/chat/components/chat-fab.tsx:29). 9+ truncation
  matches the fab.

Implementation:
- New `apps/mobile/lib/unread-counts.ts` with two `useQuery + select`
  hooks; mirror-don't-import the web design.
- Wired into `(tabs)/_layout.tsx` as React Navigation's native
  `tabBarBadge` + `tabBarBadgeStyle`. Style is JUST `backgroundColor`
  (brand blue `#4571e0`); @react-navigation/elements `Badge` internally
  uses `borderRadius = size / 2` and `minWidth = size`, so the
  single-character badge renders as a true circle. Overriding minWidth /
  fontSize / fontWeight breaks that geometry — keep the override minimal.
- Brand blue chosen over the iOS default red: matches web's
  ChatFab `bg-brand` pip and avoids the "error / critical" connotation
  red carries for an everyday new-comment notification.

Both queries (`inboxListOptions`, `chatSessionsOptions`) are already
kept fresh by listing-level realtime hooks mounted in
`app/(app)/[workspace]/_layout.tsx` (`useInboxRealtime` /
`useChatSessionsRealtime`), so badges update via WS events without a
poll or focus refetch.

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

* feat(mobile): workspace search modal

Wires the header search icon to a working modal — debounced search
across issues + projects, Recent as empty state, modal-to-detail via
router.replace. Behavioral parity with packages/views/search but stays
search-only (no command-palette section) so it doesn't dual-list
targets already in the More popover.

- data/schemas.ts: SearchIssuesResponseSchema / SearchProjectsResponseSchema
  with enum-drift defense (match_source falls back to "title")
- data/api.ts: searchIssues / searchProjects with AbortSignal forwarding
  and parseWithFallback
- (app)/[workspace]/search.tsx: TextInput + 300ms debounce + abort,
  single FlatList driving Recent / Projects / Issues rows, snippet
  line for comment-matches mirrors web search-command.tsx:632

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

* fix(mobile): stop emoji clipping in ProjectIcon

Previous impl rendered the emoji as <Text leading-none>. On iOS, emoji
glyphs render ~10-15% larger than fontSize because they ignore latin
baseline metrics, and <Text> clips content to lineHeight — so the top
and bottom of every project emoji were being cut off. project-row.tsx
had a pt-0.5 compensation that only nudged the top, leaving the bottom
clipped and producing the "row height feels off" visual.

Wrap the Text in a fixed square View (sm=18 / md=22 / lg=28 px), set
explicit lineHeight = round(fontSize * 1.2) so the glyph has the room
it needs. Drop the pt-0.5 hack — the icon now self-centers cleanly and
flex parents using items-start / items-center align siblings against a
stable square footprint.

Affects every ProjectIcon call site: search rows, Projects list,
project header card, issue attribute / create-form rows, project
picker sheet.

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

* feat(mobile): inbox → comment deep-link with flash highlight

When a user taps a new_comment / mentioned / reaction_added inbox row,
the issue detail screen now auto-scrolls to the target comment and
flashes it (matching web's behavior at packages/views/issues/components/
issue-detail.tsx:686-709). Replies are folded into their parent's
CommentCard, so a reply deep-link scrolls to the parent row and lights
up the matching child View only — mirroring web's replyToRoot fallback.

- Inbox tap now uses object-form router.push with highlight + h (nonce)
  params so re-tapping the same row re-fires the effect.
- TimelineList owns scrollToIndex (data-relative, viewPosition 0.3) with
  the standard onScrollToIndexFailed estimate-then-retry dance for
  variable-height rows.
- CommentCard renders an absolute-positioned Reanimated overlay
  (borderWidth + bg wash for root, bg-only for reply) driven by a single
  sharedValue with withSequence(700ms in, 1800ms hold, 700ms out) —
  matching web's transition-colors duration-700 + setTimeout(2500) timing.

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

* feat(mobile): TextField + AutosizeTextArea primitives

Mobile had 16 bare <TextInput> sites and a shared <Input> component
that nothing used. Every screen author repeated the four RN cross-
platform workarounds independently — paddingVertical:0, includeFont
Padding:false, textAlignVertical, and (for multiline) the onContentSize
Change + height-state dance — and most missed at least one.

This commit introduces two primitives that bake those in:

- <TextField> — single-line baseline with variant="filled" (default).
  Locks multiline={false} + numberOfLines={1} so callers can't mix
  iOS UITextField / UITextView modes by accident.

- <AutosizeTextArea> — multiline that actually grows with content,
  via onContentSizeChange → useState(height) clamp to [minHeight,
  maxHeight]. RN's Yoga doesn't read native intrinsicContentSize
  (facebook/react-native#54570, open), so this is the only way the
  bounding box keeps up with text. scrollEnabled flips on at the
  ceiling so a tall draft becomes internally scrollable instead of
  pushing the layout open.

Migrated 8 of 16 sites — chat composer, 3 description fields (new
issue, project new, project edit), and 4 picker sheets (label,
project, assignee, add-resource). Comment composer migration ships
in the follow-up commit since it's bundled with the redesign.

login / verify / search / hero titles + variant="outlined" / size="hero"
intentionally deferred (Out of Scope per plan) — no user-reported bug,
add them when the migration earns its weight.

<Input> is repurposed as a re-export of <TextField> so any future
import-by-name resolves to a sensible primitive.

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

* feat(mobile): comment composer tap-to-expand two-state UX

CommentComposer's previous "stacked horizontal bars" layout (replying-
to chip + 7-button MarkdownToolbar + TextInput row + floating Send)
looked nothing like the chat composer beside it and dominated ~120pt
of vertical space on the issue detail screen even when no one was
composing.

Rewritten as a compact pill that taps open into a chat-composer-shaped
floating card. State machine is blur-driven:

- compact + tap pill → expanded, focus TextInput via useRef + rAF
  (autoFocus on conditional render is unreliable across iOS/Android)
- expanded + onBlur + text empty + no replyingTo → collapse to compact
- expanded + onBlur + has text or replyingTo → stay expanded; draft
  visible, user can scroll the timeline without losing context
- send success resets text but does not collapse — next blur drives it,
  so back-to-back sends don't make the card jump

In-card action row mirrors chat: @ · 📷 · 📎 left, Send right.
File / image upload reuses useFileAttach and inserts the existing
markdown formats (![](url), [📎 name](url)) — no backend changes.

Drops MarkdownToolbar entirely (list/checkbox/code/quote) — users can
still type those by hand and the timeline renderer is unchanged. The
replyingTo chip moves to a rounded pill above the card (border-b would
have clashed visually with the rounded-3xl card geometry).

Also fixes a pre-existing race: canSend now gates on !fileAttach.
uploading so a deferred insertAtCursor can't land in an already-cleared
input. Hardens canCancelReply: blur the input when reply is cleared
with empty text, so the existing collapse rule fires uniformly without
forcing manual keyboard dismiss.

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

* refactor(mobile): standardize sheets on iOS pageSheet via SheetShell

The 16 Modal-based sheets in apps/mobile/ all copy-pasted the same
transparent-fade + hand-drawn backdrop + maxHeight pattern from the
project's first sheet. That shape is right for short action menus but
wrong for content viewing / search / forms — each subsequent sheet hit
its own bug (keyboard squash, FlatList clipping, useSafeAreaInsets
returning 0 inside Modal, "floating" feel from transparent backdrop).

Introduce SheetShell — a shared primitive wrapping Modal
presentationStyle="pageSheet" + nested SafeAreaProvider + header
(title + X) + safe-area-aware body. Migrate 7 misclassified sheets:
session, issue-filter, assignee/label/project/project-lead pickers,
add-resource. Codify the container-selection rule as CLAUDE.md Lesson
#6 so the next sheet doesn't inherit the wrong shape.

A-class sheets (comment-action, emoji-picker, fixed-option pickers)
intentionally left alone — their content matches the original pattern.

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

* feat(mobile): show agent runs on issue detail

New double-state row inside IssueHeaderCard (between title and
attributes): "[👤👤👤] Working" + pulse dot when ≥1 active task,
"Runs · N" when only past runs exist, hidden otherwise. Tap opens a
pageSheet listing Active + Past runs with status badges and an inline
Cancel button on active rows.

Data layer:
- api.ts: listActiveTasksForIssue (GET /api/issues/:id/active-task)
  and listTasksByIssue (GET /api/issues/:id/task-runs), both run
  through parseWithFallback + a new AgentTaskSchema (lenient enums
  with .catch() for forward-compat)
- queries/issue-keys.ts + queries/issues.ts: activeTasks + tasks
  options, workspace-scoped, signal forwarded
- mutations/issues.ts: useCancelTask with optimistic remove + rollback
- realtime/use-issue-realtime.ts: task:* WS events now invalidate the
  two new task queries (in addition to detail+timeline), so the row
  and sheet update without polling

New components: AgentActivityRow (the row), RunsSheet (built on
SheetShell), RunRow (single task row, cancel action), AvatarStack
(mobile-native overlapping avatars).

Transcript drilldown deferred to a follow-up — past row tap is no-op
in v1.

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

* feat(mobile): inbox swipe-to-archive + batch menu

Closes the inbox archive gap on mobile — desktop made archive a
first-class action (hover icon + batch dropdown) but mobile had no
archive entry point at all. Adds the canonical iOS pattern: left-swipe
on a row reveals a destructive Archive button, full swipe auto-fires.
Header gains a three-action menu for "archive all read / completed /
all" mirroring the desktop dropdown.

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

* feat(mobile): issue detail delete via three-dot header menu

Issue detail had no headerRight menu, leaving users unable to delete
issues from the phone. Adds the same ActionSheetIOS pattern the project
detail screen already uses: Copy link / Open on web / Delete (red,
Alert-confirmed). Property edits stay on IssueHeaderCard chips — one
entry per action.

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

* fix(mobile): close API schema + polymorphic-actor parity gaps

Three real bugs uncovered by the apps/mobile/ code review, all unprotected
by parseWithFallback or by the actor/assignee polymorphism:

- ActorAvatar + useActorLookup did not accept "system" actors. Inbox items
  with actor_type="system" (platform-triggered notifications) rendered a
  blank circle. Add a system glyph branch + widen the lookup signature.

- AssigneeValue was narrowed to "member" | "agent", silently dropping
  squad assignments coming from web/desktop and preventing the user from
  clearing them on mobile. Widen to IssueAssigneeType and render squad
  assignees with a generic group glyph (no squad list query yet — picker
  still lists members + agents only, but Unassigned now clears squads).

- Six read endpoints (getMe, listWorkspaces, listInbox, listMembers,
  listAgents, getIssue) returned bare fetch<T>() casts with no schema
  validation, violating the "API Response Compatibility" rule that
  installed-app architectures depend on. Add zod schemas with .loose()
  and enum-drift .catch() defenses, plus EMPTY_* sentinels so drift
  downgrades to "stale defaults render" instead of crashing the boot
  sequence.

Also fixes the AttachmentSchema typecheck failure by adding the missing
chat_session_id and chat_message_id fields (mobile schema had drifted
from packages/core/types/attachment.ts).

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

* refactor(mobile): simplify TextField primitive

Strip the four cross-platform RN TextInput workaround comments down to
the two notes that still apply. Anchor height with `h-10` instead of
`paddingVertical: 0`, and inline `fontSize` to avoid NativeWind mapping
to fontSize+lineHeight (RN clips descenders when lineHeight is set on
iOS TextInput).

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

* feat(mobile): swap tab bar icons to SF Symbols

Use expo-image's `sf:` source URLs for the four tab icons (tray /
checklist / bubble.left / ellipsis) instead of Ionicons. Native SF
Symbols render at the iOS standard tab-bar weight and stroke, so the
bar matches first-party iOS apps visually.

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

* refactor(mobile): always-on issue comment composer

Drop the tap-to-expand pill state machine. The composer now mounts in
its full form (input + @ / 📷 / 📎 / Send action row) immediately, with
no compact-pill intermediate state. Tap focuses the input and opens the
keyboard directly.

The pill→expand pattern was added to mirror chat composer's two-state
UX, but on a primary input surface like comments it is pure friction:
the user always has to tap once to get the affordance they came to use.

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

* feat(mobile): OTP code input + resend cooldown on verify screen

Replace the generic Input on the email-verify screen with a 6-slot
SF-styled OTP component (`input-otp-native`). Auto-submits on the
final keystroke instead of requiring a tap on the Verify button, and
exposes a `clear()` ref so the input resets after a server-side
rejection.

Add a 60-second resend cooldown with a live countdown beneath the
input, calling `auth.sendCode` on tap. Clears the previous code +
error when a new code is requested.

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

* feat(mobile): agent presence dots + offline banner

Mirrors web's agent presence semantics (packages/core/agents/derive-presence.ts)
on iOS: 3-state availability (online / unstable / offline) derived from
runtime.status + last_seen_at + task snapshot, with a 30s wall-clock tick so
the 5-min unstable window decays without new server data.

Pure derivation imported from @multica/core/agents (whitelisted). React glue
(hook + WS + UI) is mobile-owned per the Sharing Principles in
apps/mobile/CLAUDE.md.

Wired into 12 avatar call sites via an opt-in showPresence prop:
chat-header / agent-picker / session-sheet / inbox-row / issue-row /
attribute-row / create-form-attribute-row / comment-card / run-row /
project lead + picker. Chat composer gets an OfflineBanner above it that
stays silent during loading.

Two mobile-specific tweaks vs web:
- 30s tick is AppState-gated and forces a recompute on foreground resume
  (iOS freezes JS timers in background).
- daemon:heartbeat / task:progress / task:message are explicitly skipped
  from the WS invalidation list — high-frequency events would burn cellular
  data; web already documented this footgun in use-realtime-sync.ts.

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

* feat(mobile): ambient agent-working badge in issue header

Adds an always-visible "agent is working" indicator next to the issue
detail Stack header — a small AvatarStack + green PulseDot that opens the
Runs sheet on tap. Pairs with the existing in-card AgentActivityRow, which
is the first-time discovery surface; the header badge is the ambient
surface that stays put while the user scrolls the timeline (agent tasks
run minutes to tens of minutes).

Refactors AgentActivityRow + RunsSheet to dispatch through a shared
useRunsSheetStore (Zustand), since the Stack-header tree and the page-body
tree can't share local React state across that boundary on Expo Router.

Rationale: Apple HIG "Progress Indicators" + agent-UX ambient status
pattern. See plan /Users/qingnaiyuan/.claude/plans/ok-plan-linked-taco.md.

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

* feat(mobile): squad @-mention support in issue composer

Adds squad rows to the @-mention suggestion bar — picker / serializer /
actor name lookup. Selecting a squad emits a `mention://squad/<uuid>`
token; backend wakes the squad's leader. Mirrors web's mention extension
(packages/views/editor/extensions/mention-suggestion.tsx): alphabetical
sort, archived hidden, distinct "Squad" badge.

Also adds a presence dot to the agent suggestion row in the same bar
(opt-in showPresence prop on ActorAvatar, mirroring 12 other call sites
on this branch).

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

* docs: add iOS mobile client section + apps/mobile/README

Adds a pointer from the root README (EN + zh) to apps/mobile/, plus a
mobile-specific README covering scripts, env files, and the build-onto-
your-own-iPhone path for self-hosters.

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

* fix(mobile): escape apostrophes in login + select-workspace copy

CI lint failed on react/no-unescaped-entities. Two pre-existing JSX
literals contained raw apostrophes; replace with &apos;.

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

* chore(mobile): add iOS app icon (shared 1024x1024 with desktop)

Adds apps/mobile/assets/icon.png (copy of apps/desktop/build/icon.png,
1024x1024 RGBA) and points the Expo config at it. Resolves the
\"No icon is defined in the Expo config\" warning on prebuild / EAS build.

Single-source: any brand refresh updates desktop's icon, then mirrors
into apps/mobile/assets/. Expo prebuild generates every required iOS
icon size from this one PNG.

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

* fix(mobile): remove alpha channel from app icon

iOS app icons must not have an alpha channel — transparent backgrounds
can render as a blank/default icon on the device home screen.

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

* docs(mobile): env example documents all six build/dev scripts

Previous template only mentioned the two dev:mobile* (Metro) scripts.
Now lists all six commands that read .env.development.local / .env.staging,
and flags the compile-time-baked gotcha: changing a value requires a
re-run of an ios:* build before an installed app sees the new value.

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

* fix(mobile): chat tab badge stuck or self-clearing in background

Two paired bugs in the auto-markRead effect:

1. A `lastMarkedRef` short-circuited every re-fire of the effect, so once
   a session was marked read, a subsequent chat:done arriving on the same
   session left the badge stuck at 1 forever.

2. With (1) gone, the effect re-fired even while the Chat tab was
   backgrounded (React Navigation keeps sibling tabs mounted), silently
   clearing unread state the user never had a chance to see.

Mirror web's chat-window.tsx logic: gate on `useIsFocused()` (mobile's
analogue of web's `isOpen`), and rely on has_unread itself as the dedup
signal — the mutation's optimistic patch flips it false immediately, so
the effect won't re-fire until the next chat:done flips it true again.

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

* feat(mobile): add ios:device:staging:release build script

Adds a Release-configuration build path for the staging variant:

  pnpm ios:mobile:device:staging:release
  → cd apps/mobile && expo run:ios --device --configuration Release

Release builds strip `expo-dev-launcher` from the binary (it's only
linked in the Debug Pod configuration), so the installed app loads the
embedded JS bundle directly — no "Downloading…" screen, no Metro
probe, no Recently-opened launcher menu. Standalone use feels like an
App Store install.

The existing `ios:device:staging` (Debug) path is unchanged — it stays
the daily-driver for hot-reload development.

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

* docs(mobile): correct Debug-vs-Release standalone claim and env reload semantics

Two corrections to docs landed earlier this branch:

- The README told self-host users that ios:device:staging "runs without
  the Mac after the build completes." That is wrong for the Debug build
  it produces: every launch the embedded expo-dev-launcher probes Metro,
  showing a "Downloading…" / Recently-opened screen and stalling when the
  Mac is asleep or unreachable. Split the section into two paths and
  recommend the new :release variant for standalone use.

- The .env.example said changing a value "requires re-running an ios:*
  build" and that "dev:* (Metro) alone will not refresh baked-in values."
  That is only true for an installed Release build. For Debug, restarting
  Metro is sufficient — it re-reads .env on startup and inlines the new
  values into the next JS bundle it serves. Rewrite the comment to
  distinguish the two cases.

Also drop stale references to the removed ios:mobile:sim* scripts from
the env example.

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

* feat(mobile): adopt react-native-reusables + class-mode dark mode

First wave of the RNR migration documented in apps/mobile/docs/
rnr-migration.md. The hand-written components/ui/ shell was producing a
steady stream of dark-mode and sheet-handling bugs; this commit
establishes the foundation that lets every subsequent screen pick up
RNR-shipped components and a real theme system instead.

Foundation (Phase 1):
- global.css + tailwind.config.js switch to shadcn neutral CSS variables
  (light + dark) under :root and .dark:root, with Multica custom tokens
  appended. tailwind utilities resolve to hsl(var(--...)).
- New lib/theme.ts mirrors the variables in TypeScript and exports
  NAV_THEME for React Navigation chrome.
- New lib/use-color-scheme.ts wraps NativeWind's useColorScheme with
  expo-secure-store persistence (preference key: theme-preference,
  values: light/dark/system).
- components.json registers shadcn CLI paths so `npx @rnr/cli add` writes
  to the expected aliases. metro.config.js gains inlineRem: 16.
- app/_layout.tsx wraps the tree in ThemeProvider(NAV_THEME[scheme]) and
  mounts <PortalHost /> for RNR dialogs.
- Settings → Appearance picker (three rows: Light / Dark / System,
  persisted) — the only product addition in this commit.

Component canary (Phase 2):
- button.tsx + text.tsx replaced by RNR's defaults via the CLI (uses
  TextClassContext to flow text variants from Button into nested Text).
- 11 button call sites updated to wrap children in <Text> (the RNR
  convention). The old `brand` variant had zero call sites and was
  dropped without follow-up.

Bottom navigation:
- (tabs)/_layout.tsx tried NativeTabs first but rolled back to JS Tabs:
  NativeTabs hard-codes canPreventDefault: false on tabPress events, so
  the "More tap opens a sheet without navigating" pattern was
  unreachable. The rolled-back layout uses useColorScheme + THEME to
  derive active/inactive tint, fixing the dark-mode "dim selected tab"
  bug.
- More tab intercepts tabPress and pushes /[workspace]/menu — a stack
  route registered with presentation: "formSheet" +
  sheetAllowedDetents: "fitToContents" so iOS sizes the sheet to the
  menu's intrinsic height (UIKit handles drag handle, swipe dismiss,
  blur backdrop).
- The formSheet route is named `menu.tsx` rather than `more.tsx` to
  avoid the URL collision with (tabs)/more.tsx — both files would
  otherwise resolve to /[workspace]/more because (tabs) is a transparent
  route group.
- components/nav/global-nav-menu.tsx refactored from a self-managed
  Modal into a plain ScrollView (no flex-1, so fitToContents can
  measure). Closes via router.dismiss() instead of an onClose prop.

Docs / rules:
- apps/mobile/CLAUDE.md adds two hard rules: "defaults first" and "iOS
  native > RNR > discuss" (the three-tier waterfall).
- apps/mobile/docs/rnr-migration.md captures the alternatives evaluated,
  the three-tier component classification, the phased rollout, and the
  pitfalls hit during this commit.

Out of scope for this wave (planned but not started):
- Tier A remaining primitives (input / card / text-field / textarea)
- Tier B sheets (the 18 hand-rolled Modal sheets — to be replaced one
  PR at a time with ActionSheetIOS / native pickers / RNR Dialog)
- Tier C domain UI internal-token upgrades

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

* wip(mobile): markdown rendering tweaks — incomplete

Checkpoint commit. Markdown rendering refactor is in progress and not
yet producing the full expected output; committing so it isn't lost
alongside the RNR migration in the same tree. Will be finished in a
follow-up before push.

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

* refactor(mobile): simple Header + IconButton, drop ScreenHeader / ChatHeader

Tab and stack screens were carrying two hand-rolled header components
(ScreenHeader, ChatHeader) that reimplemented enough of UINavigationBar
to ship the obvious bugs: hardcoded hex colors that didn't follow the
NativeWind dark scheme, no shared dark/light token wiring, no consistent
touch feedback for action buttons (Pressable + custom className per
call site).

This commit collapses both into one shared component family:

  - `components/ui/header.tsx` — slot-based (`title` / `center` / `left`
    / `right`) rendered in the screen's JSX. Self-handles the top safe
    area, uses semantic RNR tokens (`bg-background`, `text-foreground`,
    `border-border`) so dark mode flips via NativeWind class mode with
    no per-screen logic.
  - `components/ui/icon-button.tsx` — `<RNR Button variant="ghost"
    size="icon">` wrapping an Ionicon whose color falls back to
    `useTheme().colors.text` (the active navigation theme), so the
    glyph follows dark/light automatically without callers passing
    a color prop.
  - `components/chat/chat-title-button.tsx` + `chat-session-actions.tsx`
    — chat-specific slots that plug into the same Header (center +
    right) instead of the chat tab having its own complete header.

Call sites:
  - Inbox / My Issues / Chat / more/issues — drop `<ScreenHeader>` and
    `<ChatHeader>`, render `<Header ...>` at the top of the screen body
    with the appropriate slot contents.
  - HeaderActions — Search / New-Issue buttons swap raw Pressable for
    IconButton. The previously-added Menu button is removed (redundant
    with the "More" tab in the bottom bar).
  - more/issues — was rendering both the workspace stack's native
    header AND its own ScreenHeader inside the screen body, so the
    filter button now goes onto the stack header via
    `navigation.setOptions({ headerRight })` and the in-body header
    is gone.

Why the per-tab Stack approach (briefly explored) was abandoned:
react-navigation's native large title is the only thing that needed a
Stack per tab, and the product doesn't want collapse-on-scroll. With
that gone, every dynamic header content piece (Inbox's archive menu,
Chat's agent picker title) was forced through `navigation.setOptions`
in a useLayoutEffect — strictly more complexity than just rendering
the Header in JSX with state passed as props.

Net: 349 lines removed, 208 added. Two header components deleted; two
small primitives added.

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

* fix(mobile): resolve mc:// image URIs against attachment list before render

Markdown content authored in Multica stores image references as
`mc://file/<id>` rather than baking signed HTTPS URLs into the text
(signed URLs expire). iOS image loader doesn't understand the `mc:`
scheme, so any attachment-image in a description, comment, or chat
message was raising a redbox: "No suitable image URL loader found for
mc://file/...".

Web already resolves this via `packages/views/editor/
attachment-download-context.tsx`: components look up the markdown URL
in the issue's attachment list and use the matching `download_url`.
This commit mirrors that pattern for mobile.

The wiring:

  - `data/schemas.ts` — AttachmentListSchema + EMPTY_ATTACHMENT_LIST
  - `data/api.ts` — listAttachments(issueId) → GET /api/issues/:id/attachments
  - `data/queries/issue-keys.ts` — `attachments(wsId, id)` key
  - `data/queries/issues.ts` — issueAttachmentsOptions
  - `lib/markdown/markdown.tsx` — Markdown accepts `attachments?` and
    forwards to MarkdownImage
  - `lib/markdown/markdown-image.tsx` — looks up uri in attachments,
    swaps for `download_url`; unresolved URIs fall through and fail
    the getSize callback gracefully (16:9 muted placeholder, no
    redbox)
  - `IssueDescription` and `CommentCard` — fetch via
    issueAttachmentsOptions; TanStack Query dedupes so the same
    issue's attachment list only fires one request regardless of how
    many components need it
  - `chat-message-list` — passes `message.attachments` directly (chat
    messages carry their attachment list on the message record itself,
    distinct from the issue-scoped model)

Unmatched URIs (e.g. test placeholders like `file_abc123`) now render
the same muted 16:9 fallback as a 404 — never a redbox.

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

* refactor(mobile): typed ws.on<E>() + useWSSubscriptions to cut realtime boilerplate

Adds WSEventPayloadMap in @multica/core/types so callers get the precise
payload type per event — no more `const p = msg as IssueUpdatedPayload`
boilerplate at every handler. Mobile ws-client adopts the generic
signature; web's untyped on() is untouched but can opt in later.

useWSSubscriptions wraps the if-ws-and-wsId-then-useEffect-cleanup
template every Layer-3 realtime hook used to repeat. Each of the 8 hooks
sheds ~7 lines of lifecycle scaffolding and ~30 total `as Payload` casts
go away; only 1 deliberate cast stays for the cross-event onTaskEvent
(task:progress has no formal payload interface yet).

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

* feat(mobile): settings — profile + notifications subscreens, RNR primitives, API helpers

Settings page rewritten to use RNR primitives (RadioGroup, Switch,
Avatar, Separator) instead of self-drawn equivalents, removes 3
hardcoded #71717a hex colors in favor of THEME tokens, and adds
Alert.alert confirmation on sign-out with destructive Button variant.

Two new push subscreens under more/settings/:
  - profile.tsx     edits name + avatar. Avatar tap opens iOS native
                    ActionSheetIOS (Take Photo / Library / Remove) via
                    expo-image-picker, then PATCH /api/me.
  - notifications.tsx  5 inbox groups + system_notifications toggle,
                       backed by optimistic PUT /api/notification-preferences.

New mobile-owned query + mutation for notification preferences mirror
the web design (no runtime import — per CLAUDE.md "Mobile-owned
updaters"). auth-store gets setUser action for in-memory user update
after profile PATCH.

ApiClient gains fetchValidated + fetchValidatedWith private helpers
that collapse the fetch+parseWithFallback envelope. 4 settings-related
methods migrated as canary (getMe, updateMe, getNotificationPreferences,
updateNotificationPreferences); remaining 30+ read methods migrate
progressively in later PRs.

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

* feat(mobile): inbox refactor — Mark all read, swipe UX, parity fixes

Swipe-to-archive no longer auto-fires on full drag (felt aggressive, no
peek, easy mistrigger on fast scroll). Now matches iOS Mail / Linear: drag
reveals the red Archive button + medium haptic at threshold, user taps to
commit. Auto-fire path removed; useAnimatedReaction + runOnJS bridges the
UI-thread shared value to Haptics.impactAsync.

Behavioral parity fixes the previous mobile inbox was missing vs web:
  - Mark all read action — endpoint POST /api/inbox/mark-all-read already
    existed server-side; mobile just never wired it. Added api.markAllInbox
    Read + useMarkAllInboxRead (optimistic flip read=true on non-archived)
    + ActionSheet menu entry as the first option.
  - issue:updated → patch inbox row's StatusIcon inline. Previously mobile
    ignored the event and showed stale status until the next inbox event
    refetched the list.
  - issue:deleted → strip orphaned inbox rows so tapping doesn't 404 on
    the issue detail page.
  - Both via a new mobile-owned inbox-ws-updaters.ts mirroring web's
    packages/core/inbox/ws-updaters.ts.

Internal cleanup:
  - inboxKeys factory in data/queries/inbox.ts ({all,list}, 3-segment
    shape matching web). 6 inline ["inbox", wsId] strings retired across
    queries / mutations / realtime / useCreateIssue inbox invalidate.
  - Synchronous setQueryData hack (workaround for iOS push transition
    snapshot capturing pre-flip state) moved from inbox.tsx caller into
    useMarkInboxRead.onMutate. Every caller benefits, none can forget it.

UX polish:
  - Loading state: 6 Skeleton rows (RNR, installed this PR) replacing
    centered ActivityIndicator.
  - Empty state: mail-open icon + helper text replacing bare "No inbox
    items." copy.
  - ItemSeparatorComponent ml-[60px] → ml-16 (token, aligns with avatar
    36 + px-4 + gap-3).

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

* docs(mobile): encode helper-layer conventions + swipe & Tier C lessons

CLAUDE.md grew with rules surfaced by the inbox PR + the earlier WS / API
helper work, so future agents can find the helpers instead of recreating
them.

New section "Data layer helpers" — three rails (logic mirrors web; use
existing components, don't invent primitives; use the wrapped request
layer) + helper-by-helper reference (fetchValidated, fetchValidatedWith,
xKeys factory shape, ws.on<E>() + WSEventPayloadMap, useWSSubscriptions,
synchronous-setQueryData-before-await ordering) + a 7-step checklist for
new features.

Realtime strategy extended with "Cross-cutting cache patches across
features" — the rule that issue:* → inbox-cache patches live in
inbox-ws-updaters.ts (owned by the feature being patched), not in issues'
own hook. Reconnect table updated to use inboxKeys.list(wsId).

Two new Lessons:
  - Lesson 7: destructive swipe is reveal-only, never auto-fire; haptic
    via useAnimatedReaction + runOnJS at the threshold. Encoded from the
    inbox PR's swipe UX fix.
  - Lesson 8: Tier C domain components (ActorAvatar, StatusIcon, etc.)
    upgrade opportunistically — don't silently rewrite when you're just
    rendering them in a new feature.

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

* feat(mobile): issue detail — comment-as-modal route, hex/Pressable cleanup, API helpers

Comment composer redesign (user feedback: inline always-on was clunky,
keyboard avoidance bad, no room for @mention suggestion bar). The bottom
of issue/[id].tsx is now a single <Button>Comment</Button>; tap pushes
the new issue/[id]/new-comment modal — full screen for typing,
AutosizeTextArea + MentionSuggestionBar + toolbar. Reply path goes
through the same modal with parent / parentName route params, so
"Reply" on a comment long-press just pushes the modal in reply mode.

Comment-card long-press no longer competes with iOS native text
selection: wrapped <Markdown> in a View with userSelect:'none' so the
press only triggers the action sheet. Users can still copy the full
comment body via the existing "Copy text" entry.

issue/[id].tsx headerRight 3-dot menu switches from a hand-drawn
Pressable + Ionicons (hardcoded #0a84ff/#71717a) to <IconButton>. Same
hex cleanup applied to:
  - agent-activity-row.tsx (2× #a1a1aa → THEME.mutedForeground)
  - activity-row.tsx (MUTED constant deleted; SVG glyph takes stroke prop)
  - comment-card.tsx BRAND_RING/BRAND_WASH rgba constants gone — animated
    overlays now use NativeWind border-brand/50 + bg-brand/5 classes,
    opacity stays the only animated channel.

API layer: 5 issue GET methods migrated to fetchValidated (getIssue,
listTimeline, listAttachments, listActiveTasksForIssue, listTasksByIssue).
Write endpoints stay on raw this.fetch per the existing mobile convention
— migrating writes needs new zod schemas, defer to a follow-up PR.

comment-composer.tsx deleted: orphan after the modal swap. CommentActionSheet
is kept as-is — it has the quick-react emoji row (the only "add reaction"
entry for comments) and already follows the correct Lesson 6 short-action
card pattern.

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

* refactor(mobile): close button uses <IconButton variant=secondary>

Both the SheetShell (pageSheet header) and the standalone ModalCloseButton
(modal Stack header) were drawing the circular grey close ✕ by hand:
<Pressable> + <View bg-secondary> + <Ionicons color="#3f3f46">. Two
problems with that pattern:

1. The #3f3f46 zinc-700 hex is invisible in dark mode — the icon and
   background both go dark, contrast collapses.
2. It bypasses RNR Button (which is exactly what an icon button is),
   re-implements active state, and lives outside the design system.

Swap both to <IconButton name="close" variant="secondary"
className="size-7 rounded-full"> — RNR Button under the hood, secondary
variant carries the bg-secondary token (so dark mode flips), icon color
comes from useTheme(). className locks the 28pt circular shape that
Linear iOS / Things 3 use for this slot (RNR's default size="icon" is a
40pt rounded-md square box, which is a different look).

One-line fix per file, no new primitive. Affects every pageSheet
close button (RunsSheet, picker sheets via sheet-shell) and every modal
close button (new-issue, search, new-comment).

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

* fix(mobile): PulseDot uses brand colour, not success — running ≠ completed

The agent "is working" pulse dot (shown both in the issue Stack header
ambient badge and in the in-card AgentActivityRow "Working" row) was
backgroundColor #22c55e — that's the success/completed token. Reading
green here meant "task complete", which is the opposite of what the
animation represents.

Switch to THEME[scheme].brand (hsl(225 71% 58%)), matching:
  - mobile RunRow status text: STATUS_CLASS.running = "text-brand"
  - web agent-live-card.tsx:327: <Loader2 text-info animate-spin />
  - Apple HIG / shadcn semantic colour convention:
      green = success, blue/brand = in-progress, red = destructive

One-line fix in pulse-dot.tsx; both call sites (AgentHeaderBadge top-right,
AgentActivityRow under the title) flip from green to brand blue
together. Docstring updated to spell out the rule for future readers:
DO NOT use success here.

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

* fix(mobile): activity ↔ web parity — start_date / squad_leader / wording

Five small fixes that close the remaining gaps between mobile's activity
rendering and the web equivalent in packages/views/issues/components/
issue-detail.tsx. All logic-layer; no component or container changes.

  - timeline-coalesce.ts: add NEVER_COALESCE_ACTIONS = {squad_leader_
    evaluated}. Without it, two consecutive squad-leader evaluations from
    the same actor within 2 min merged into one row, dropping the second's
    `outcome` + `reason` audit fields. Web does this since the rule was
    added; mobile was missing it.

  - format-activity.ts: add cases for `start_date_changed` (set / remove
    branches) and `squad_leader_evaluated` (outcome × reason 4 branches).
    Before, both fell through to the default that returns the raw enum
    name — users saw literal `start_date_changed` / `squad_leader_
    evaluated` strings in the timeline.

  - format-activity.ts: tighten assignee wording from "assigned NAME" to
    "assigned to NAME" — matches web's en/issues.json copy.

  - activity-row.tsx: `LeadIcon` now reuses CalendarGlyph for
    `start_date_changed` (same affordance as `due_date_changed`).

  - components/inbox/detail-label.tsx: TYPE_LABEL Record was missing
    `start_date_changed` — fixes a pre-existing TS error.

  - data/schemas.ts: EMPTY_ISSUE_FALLBACK was missing `start_date: null`
    — fixes the other pre-existing TS error. Both gaps had the same root
    cause (backend added the field, mobile didn't follow).

Typecheck is now clean — no pre-existing errors remaining.

Copy strings mirror packages/views/locales/en/issues.json verbatim
(activity.start_date_set / squad_leader_action / etc.).

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

* feat(mobile): attribute row — project picker wired + all pickers go pageSheet

Issue-detail AttributeRow chip row (status / priority / assignee / label /
project / due-date) had three nagging gaps. Fix them together so the
whole row behaves consistently.

  - ProjectPickerSheet was never wired: the file existed (155 lines, ready
    to use) but the chip was read-only with a stale `// picker deferred
    until web ships one` comment. Web has had a project picker forever.
    Add the projectOpen state, an `onProject` handler that calls
    `useUpdateIssue.mutate({ project_id })`, a placeholder dimmed chip
    when no project is set, and mount the sheet. Mobile users can now
    change an issue's project.

  - PRIORITY_LABEL was duplicated in two places — re-declared inside
    priority-picker-sheet.tsx (full form `none: "No priority"`) and as a
    near-identical chip placeholder in attribute-row.tsx (short form
    `none: "Priority"`). Both now import from the single source in
    `lib/issue-status.ts`; attribute-row keeps a 1-key override
    (`PRIORITY_CHIP_LABEL = { ...PRIORITY_FULL_LABEL, none: "Priority" }`)
    so the chip placeholder still reads as a placeholder, not as an
    assigned value.

  - Sheet container split was inconsistent: assignee / label / project
    pickers used SheetShell pageSheet (slide-up from bottom), while
    status / priority / due-date used a centered transparent Modal card
    (different gesture, different position). For a chip row where users
    tap several pickers in succession, the inconsistency broke iOS
    muscle memory. Status / priority / due-date all switch to pageSheet
    so the whole row reads as "tap chip → slide-up sheet" uniformly.
    Linear iOS / Things 3 / Apple Reminders use this pattern even for
    short fixed lists.

CLAUDE.md Lesson #6 modal container table grew a "picker-row consistency
wins over per-container optimisation" carve-out so future row-of-pickers
work follows the same rule.

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

* refactor(mobile): 5-tier surface elevation scale — fixes comment-bubble nested contrast + inline-code link confusion

Two related fixes that share root cause: shadcn's neutral palette
collapses `secondary` / `muted` / `accent` to the SAME L 96.1% value
intentionally — it's a single tonal slot whose semantic name varies by
use case, not three different colors. Stacking a bg-muted child on a
bg-secondary parent (which is what we were doing for code/table headers
inside the comment bubble) made the inner element visually disappear.

Introduce a proper 5-tier elevation scale calibrated to Refactoring UI
and Material 3 guidance:

  L 100   page bg / card / popover            (page floor)
  L 98    surface-1   NEW                     (subtle elevated — comment
                                               bubbles, iOS settings-cell
                                               feel: visible boundary
                                               via radius + border, fill
                                               is almost-page)
  L 96.1  secondary / muted / accent          (shadcn default, untouched —
                                               button hover, chips, skeleton)
  L 90    surface-2   NEW                     (nested inside surface-1 —
                                               table headers + code blocks
                                               inside comment bubbles, 8% L
                                               step over surface-1)
  L 84    border      (was 89.8% → 84%)       (visible across every tier,
                                               6-16% darker than adjacent
                                               surface, within Refactoring
                                               UI's 5-10% guideline)

Dark mirror flips the lightness direction (higher elevation = lighter):
page 3.9 → surface-1 8 → secondary 14.9 → surface-2 19 → border 25.

Applied across three files:

  - global.css + tailwind.config.js + lib/theme.ts mirror the new tokens
    (CSS variables, Tailwind class map, TypeScript export — they must
    stay in sync per CLAUDE.md §5).

  - components/issue/comment-card.tsx switches the bubble bg from
    `bg-secondary` (too prominent, same color as inner muted elements)
    to `bg-surface-1` (subtle, 8% lighter than inner surface-2).

  - lib/markdown/markdown-style.ts:
      - table.headerBackgroundColor + codeBlock.backgroundColor:
        `t.muted` → `t.surface2`, so they're framed against the bubble.
      - inline `code:`: REVERT 2026-05-19's `color: t.brand` workaround
        for upstream enriched-markdown #255. The brand-tint avoided the
        chip's top-heavy padding artifact but broke Refactoring UI's #1
        rule (color carries semantic meaning — brand IS the link color,
        users reported tapping inline code thinking it was a link).
        Re-enable bg-chip + foreground text, matching GitHub mobile /
        Slack / Notion / Apple Notes. The padding artifact is the lesser
        evil; in surface-2 (L 90%) on surface-1 (L 98%) the chip is
        subtle enough that the few pixels of asymmetry are unobtrusive.

The shadcn `secondary` / `muted` / `accent` tokens stay at L 96.1%
unchanged — other call sites (button hover, skeleton, avatar fallback,
chips) all work fine on their own and were never the problem.

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

* docs(mobile): hoist "existing pattern first" to Principle 1 in UI rules

So AI agents grep the codebase for an analogous component before reaching
for RNR add or hand-rolling — structural fix for the pre-migration legacy
(21 hand-written components, 18 sheets) that accumulated by treating each
new screen as a blank slate.

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

* feat(mobile): align my-issues + Issues with web/desktop — squad parity, scope tabs, RNR UI

- my-issues "agents" scope now uses server-side involves_user_id (MUL-2397)
  covering squads the user is involved in; tab label "Agents and Squads"
  matches web my-issues.json:14
- workspace Issues gains all / members / agents scope tabs with per-scope
  counts (client-side assignee_type filter mirroring issues-page.tsx:90-94),
  scope persists across workspace switches
- both screens migrate to iOS-native SegmentedControl, IconButton + dot,
  Ionicons chip X, and a shared IssuesLoading skeleton — drops hardcoded
  #71717a and react-native-svg usage on these surfaces
- new useClearFiltersOnWorkspaceChange hook + IssuesLoading component
  shared across both surfaces (three-occurrence threshold respected)

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

* feat(mobile): migrate sheet modals to route-level pageSheet (Tier B rollout)

Replaces the legacy "Modal transparent fade + hand-drawn backdrop" sheet
shell with expo-router route-level pageSheet modals — the canonical
container for content sheets per mobile/CLAUDE.md Lesson 6 and the Tier B
section of docs/rnr-migration.md.

Sheets deleted (9): chat session-sheet, comment-action-sheet, issue-filter-sheet,
six issue pickers (assignee, due-date, label, priority, project, status),
runs-sheet, project add-resource-sheet, project-lead-picker-sheet, plus the
shared sheet-shell and runs-sheet-store that supported them.

Route-level modals added: /[workspace]/{chat-sessions, issues-filter,
new-issue-picker/*, issue/[id]/{runs, picker/*, comment/[commentId]/actions},
project/[id]/{add-resource, picker/lead}}. Each picker is split into a thin
route file + reusable *-picker-body.tsx so the same body composes inside
the new-issue draft form and the issue-detail attribute row.

Comment CRUD endpoints (update / delete / resolve / unresolve) + matching
optimistic mutations + CommentSchema added to support the new comment
actions route. Two new draft/picker stores carry session-scoped state for
the chat-session picker and the new-issue form.

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

* docs(mobile): markdown rendering ADR + selectable carve-out

Formalises the rendering decision (Path B — react-native-markdown-display +
Shiki + custom renderers) into a one-page ADR with A-tier source citations,
keeping the longer research log alongside it.

Adds a `selectable` opt-out to `CodeBlock` and `Markdown` so timeline
comments can disable RN's UIKit selection magnifier when an outer Pressable
already owns the long-press gesture, while issue descriptions and chat
messages keep the default selectable behaviour for copy-to-clipboard.

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

* fix(mobile): add inline titles to 5 issue picker bodies

SHEET_OPTIONS sets headerShown: false so every formSheet body must draw
its own title. Five issue pickers (status / priority / assignee / label /
project) were shipping headerless; only due-date had a title. Inline a
single header row in each body — five callers, no shared primitive (3x
rule not triggered).

* feat(mobile): full emoji picker for comment reactions via formSheet route

Mobile now offers the full emoji set behind a 'More reactions' overflow
in the per-comment actions sheet, matching web's emoji-mart parity.

- Adopt rn-emoji-keyboard 1.7.0 (zero runtime deps, React 19 / RN 0.83
  compatible, installed via expo install).
- New formSheet route at issue/[id]/comment/[commentId]/emoji-picker.tsx
  embeds EmojiKeyboard inline so UISheetPresentationController retains
  grabber, detents, and drag-to-dismiss.
- Quick-row overflow '+' button in comment actions pushes the new route.
- Delete the dead emoji-picker-sheet.tsx and the unused
  emojiPickerOpen state in comment-card.tsx (never opened from
  anywhere after the actions-route migration).
- Move QUICK_EMOJIS to lib/quick-emojis.ts since its old host file is
  gone.
- Update rnr-migration.md B.4 to record the resolution.

* feat(mobile): project status + priority pickers via formSheet routes

Project detail's Status and Priority chips were the last two picker
chips still using the legacy centered-Modal pattern. The mixed gesture
(Status/Priority popped a centered card; Lead / Add Resource slid up a
formSheet) violated the picker-row consistency rule in CLAUDE.md
Lesson 6 — the four chips on the same row now all open the same way.

- New picker bodies under components/project/pickers/.
- New formSheet routes under app/(app)/[workspace]/project/[id]/picker/.
- Register both screens in workspace _layout.tsx using SHEET_OPTIONS.
- project/[id].tsx: drop the local state, swap chip onPress to
  router.push, and remove the trailing 'still uses transparent-Modal'
  apology comment.
- project/new.tsx is a draft modal so it can't push to a route (no
  project exists yet to read from cache). Inline a tiny DraftPickerModal
  shell that hosts the same picker bodies — documented in the file.
- Delete the obsolete ProjectStatusPickerSheet / ProjectPriorityPickerSheet
  files and update rnr-migration.md to reflect that B.2 is closed.

* refactor(mobile): menu sheet uses shared SHEET_OPTIONS

Drop the bespoke 'fitToContents' branch for menu.tsx. Every other
formSheet uses [0.6, 0.95] explicit detents to dodge the iOS 26 +
Expo 55 fitToContents bugs (expo/expo#42904, #42965). Keeping menu on
the unsafe API solely because it 'shipped first' was a divergence
without a current reason — the bugs apply to it too. SHEET_OPTIONS is
now the single source of truth for every sheet.

CLAUDE.md Lesson 6 rationale updated to match.

* fix(mobile): reset cross-route draft stores on workspace change

Both useNewIssueDraftStore and useChatSessionPickerStore hold
workspace-scoped state (assignee ids, draft session ids) that points at
records in the workspace that seeded them. Switching workspaces left
that state in place — a draft assignee from workspace A would survive
into workspace B's new-issue modal, where the id resolves to nothing.

Add a reset() to chat-session-picker-store (new-issue-draft-store
already had one) and expose a use…ResetOnWorkspaceChange(wsId) hook from
each store file. Wire both hooks once from workspace _layout.tsx so the
reset fires on every transition between matched workspace ids.

Docblocks updated to record where the reset is wired (single source of
truth: workspace _layout.tsx).

* fix(mobile): typed picker pathname maps replace 'as never' router.push

attribute-row.tsx and create-form-attribute-row.tsx built the formSheet
route pathname via template strings cast 'as never', which silently
accepted any field name. Typos would compile and only blow up at runtime
with a 'no matching route' that's easy to miss in dev.

Introduce per-row IssuePickerField / NewIssuePickerField union types
mapped to literal-typed pathname records (with 'satisfies' to keep the
record exhaustive). Any new picker field is now a compile error until
both the union and the map are updated together.

Verified: changing 'priority' to 'pirority' at a call site now produces
TS2345 instead of compiling silently.

* fix(mobile): cold-start anchor for formSheet deep links

Without unstable_settings.anchor, a deep link or notification that
targets a formSheet route (issue/[id]/picker/status, etc.) cold-starts
the app onto the sheet alone — no parent screen, swipe-down lands the
user on a blank canvas. Anchor: '(tabs)' tells Expo Router to mount the
tab UI as the implicit base, so dismissing the sheet always returns to
a sensible workspace home.

Set on the workspace _layout.tsx that owns every formSheet route
registration. The root (app)/_layout has no formSheet declarations so
no anchor is needed there.

* refactor(mobile): new-project draft store + formSheet pickers

Replaces the one-off DraftPickerModal (RN <Modal transparent fade> +
centered card) in project/new.tsx with the same cross-route draft-store +
formSheet picker route pattern as new-issue. Status / priority chips now
push /new-project-picker/<field> like the new-issue chips do, and the
picker bodies are reused as-is.

Removes the last hand-rolled modal sheet introduced after the Lesson 6
formSheet migration — keeping the rule "every sheet is a formSheet route"
intact across the codebase.

* fix(mobile): make first mount a true no-op in draft-store reset hooks

The two cross-route draft store reset hooks (new-issue, chat-session)
documented their first mount as "effectively a no-op" but the
implementations stomped the store on every workspace-id transition
including the initial null → uuid resolve. That's harmless when the
store is already INITIAL but contradicts the docblock and would corrupt
any future code that pre-seeds the store before navigation lands.

Gate the reset() call on a useRef-tracked previous id so it only fires
on genuine transitions. Matches the new-project-draft-store hook added
in the prior commit so all three stores follow one shape.

* fix(mobile): menu sheet keeps fitToContents detent

The Tier B sheet migration swept menu.tsx into shared SHEET_OPTIONS,
which set sheetAllowedDetents=[0.6, 0.95]. That's right for picker-row
sheets where consistency across neighbour chips matters, but the menu
is an isolated sheet (≤ 5 fixed actions, opened from the tab bar) —
the two-snap default leaves ~60% of the sheet blank.

Override sheetAllowedDetents to "fitToContents" for menu only, and
amend the SHEET_OPTIONS rationale in apps/mobile/CLAUDE.md so the rule
is spelled out: picker-row sheets share the explicit detents for
muscle-memory carry-over; isolated sheets shrink-wrap.

* fix(mobile): align picker search box to title (px-4)

The three search-bearing picker bodies (assignee / label / project) had
title rows at px-4 and search boxes at px-3 — a 4px misalignment where
the search field's leading edge sat outside the title's leading edge.
Bring the search container to px-4 so the title text, the search
placeholder, and the search input all share one vertical baseline.

Status / priority / due-date pickers have no search box (and so no
misalignment); project-detail lead picker has no title row (search box
defines its own px-3 baseline), both intentionally unchanged.

* feat(mobile): mirror web project progress section in header card

Adds a horizontal progress bar driven by `done_count / issue_count`
plus a "X / Y · NN%" label, hidden when issue_count is zero (no info
to show + divide-by-zero hazard). Mirrors web's project-detail.tsx
596-620 to satisfy behavioral parity — web users see project progress
in the project header, mobile users should too.

Note: this change was added autonomously by the code-review follow-up
agent outside the original 6-item review scope. Code quality is sound
(token-based colors, zero-count guard, web source referenced inline)
so kept rather than dropped, but flagged here for traceability.

* feat(mobile): project surface v1 — Board view, hex/SVG sweep, planning docs

Closes the remaining items from project-v1-plan.md:

- View mode switcher (List / Board) on project detail's related-issues:
  - List mode regrouped into full BOARD_STATUSES (backlog / todo /
    in_progress / in_review / done / blocked), replacing the mobile-only
    "Open / Done" two-bucket rollup that silently diverged from web's
    six-bucket grouping (parity violation, gap audit §3)
  - Board mode: horizontal scroll, one status column per group, each
    column is a FlatList of IssueRow (reuses existing primitive)
  - View mode is local useState — no Zustand store (single component
    scope, mobile/CLAUDE.md "no state unless required")

- Hex sweep → THEME tokens / NativeWind semantic classes (gap audit §5):
  project-properties-section, project-resources-section, project/[id],
  more/projects. Eliminates the last project-domain dark-mode breakage.

- Hand-drawn SVG icons → existing primitives (gap audit §6):
  more/projects PlusButton → <IconButton name="add">
  project-properties-section chevron → <Ionicons name="chevron-forward">
  project-related-issues chevron → <Ionicons name="chevron-forward">
  Drops react-native-svg where no longer used.

Items 1 / 2 / 4 (Tier B picker migration, progress section, new-project
draft persistence) landed in preceding commits c644e2a3, 7337206f,
2ff95c34. With this PR the full project-v1-plan is implemented and the
two planning docs (gap audit + implementation plan) are committed for
future reference.

* refactor(mobile): drop project board (kanban) view, keep list-only

Mobile intentionally diverges from web's Board / List view selector and
ships only the status-grouped list. Reasons (now documented in the file
docblock):

- Phone screens are too narrow to show ≥3 status columns at once,
  defeating kanban's core "see pipeline at a glance" value — users
  end up swiping between near-empty columns.
- Major mobile task apps (Linear iOS, Things, Apple Reminders) don't
  ship kanban; list with status grouping is the established
  small-screen pattern.
- mobile/CLAUDE.md "Behavioral parity" permits UI divergence when
  semantics agree. Same issues, same status enum, same 6
  BOARD_STATUSES grouping — only the layout differs.

What stays from the prior plan:
- Full BOARD_STATUSES grouping (backlog / todo / in_progress /
  in_review / done / blocked) — the real parity fix replacing the
  earlier mobile-only "Open / Done" two-bucket rollup. Cancelled
  remains hidden on both clients.

What's removed:
- BoardView component + horizontal ScrollView
- View mode SegmentedControl + ViewMode local state
- BoardView's column-empty placeholders

The `@react-native-segmented-control/segmented-control` dependency is
kept — my-issues and more/issues still use it for scope tabs (Mine /
All / Agents) where semantics also vary on web.

* feat(mobile): More tab opens dropdown popover anchored above the tab

Tapping the More tab now opens a small DropdownMenu popover containing
the user card, workspace switcher, and secondary nav (Issues/Projects)
— anchored directly above the tab button. Replaces the previous
listeners.tabPress that pushed /menu as an iOS formSheet, which felt
heavy for a quick switch.

Implementation:

- Add @rn-primitives/dropdown-menu and a shadcn-style wrapper at
  components/ui/dropdown-menu.tsx (Root/Trigger/Portal/Overlay/Content/
  Item/Label/Separator using semantic tokens — bg-popover, accent,
  border — matching the existing button.tsx pattern).
- New MoreTabDropdownAnchor (components/nav/more-tab-dropdown.tsx)
  mounts as a sibling to <Tabs> at the workspace tabs layout. It is
  absolute-positioned over the More tab's screen rect (right 25%,
  bottom = safe-area inset, height = 49) with pointerEvents="box-none"
  so taps pass straight through to the real tab button. The Trigger
  inside is an invisible Pressable; opened imperatively via
  TriggerRef.open() from listeners.tabPress on the More tab. The
  @rn-primitives Trigger measures its own rect inside open(), so the
  popover anchors correctly without manual screen-width math.
- The /menu formSheet route stays registered in [workspace]/_layout.tsx
  as a dead path for now (reversibility); to be removed once the
  popover bakes in.

Rejected alternative: replacing the More tab's tabBarButton with a
custom DropdownMenuTrigger wrapper. RN's BottomTabItem wraps the
returned button in <View style={{flex:1}}> and expects a single
Pressable; introducing the DropdownMenu Root as an extra wrapping View
broke the flex layout and stripped the "More" label. The Option B
pattern here leaves the real tab button entirely untouched.

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

* refactor(mobile): swap SegmentedControl for RNR Tabs; drop bg-popover from sheet contents

- Add components/ui/tabs.tsx (RNR Tabs primitive wrapper on
  @rn-primitives/tabs, shadcn-style API).
- My Issues and the More > Issues page swap iOS SegmentedControl for
  the new RNR Tabs — consistent visual with the rest of the RNR
  components and gives count-suffix labels room to breathe.
- Switch the shared SHEET_OPTIONS contentStyle from height: "100%" to
  flex: 1 — works for both fixed-detent and fitToContents sheets,
  whereas the explicit 100% height pre-empted flex behaviour in the
  fitToContents case.
- Drop the explicit `bg-popover` background from sheet root Views
  (chat-sessions, issues-filter, runs, comment actions/emoji-picker,
  add-resource). The iOS formSheet container already paints the
  popover surface; an inner bg-popover stacked on top showed as a
  subtle double-layer when detents animated.

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

* feat(mobile): native iOS assignee picker — search bar + pin selected + checkmark accessory

- Switch assignee picker (issue + new-issue) from body-rendered header to
  native Stack header + UISearchController via headerSearchBarOptions.
- Body becomes pure FlatList — fixes react-native-screens#3634 overlap
  (FlatList now route's direct child, no intermediate wrapper view).
- Pin currently-selected actor + Unassigned to the top when no query;
  search results stay in member → agent → squad order.
- Inline right-aligned "Agent" / "Squad" tag mirrors Apple's Value-1 cell
  style (UIListContentConfiguration.valueCell) used throughout Settings.
- Selection indicator: Ionicons checkmark in primary tint only, no row
  bg highlight (Apple HIG: never use selection to indicate state).
- Avatar 28pt → 36pt.
- autoFocus on search bar for search-first pickers — keyboard appears on
  mount, opt-in via hook option.
- Extract useNativeSearchBar + useScrollToTopOnChange hooks under
  apps/mobile/lib/ for phase-2 rollout to label / project / lead pickers.

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

* wip(mobile): in-flight comment-select / chat / markdown work

Batch commit of pre-existing uncommitted work carried forward alongside
the assignee picker refactor. Topics mixed — split into proper atomic
commits when each lands.

- apps/mobile/data/comment-select-store.ts: new comment-selection store
- components/issue/comment-card.tsx + issue/[id].tsx + comment actions:
  comment-select wiring
- components/chat/chat-message-list.tsx: chat list rework (~170 lines)
- lib/markdown/markdown.tsx: markdown adjustments
- package.json + pnpm-lock.yaml: dependency drift

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

* chore(mobile): EXPO_BUNDLE_IDENTIFIER override + brand logo + CLAUDE.md preflight rules

- .env.example + app.config.ts: optional EXPO_BUNDLE_IDENTIFIER for devs whose Apple ID isn't on the Multica team
- components/brand/multica-logo.tsx: new brand logo asset
- CLAUDE.md: restructured with mandatory pre-flight (read web impl → show plan → wait for go) before any new mobile feature; consolidated behavioral parity rules

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

* fix(mobile): friendlier auth error messages on login + verify

Adds lib/auth-error.ts that maps backend raw English errors (invalid / expired / rate-limited / network) to user-facing copy. login.tsx and verify.tsx route their catch blocks through it with a per-screen fallback string.

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

* chore(mobile): markdown rendering + UI primitive polish

- lib/markdown/{code-block,markdown-style,preprocess}: refined code block rendering, restructured style map, preprocess tweaks
- components/ui/{actor-avatar,text-field}: visual polish
- components/issue/mention-suggestion-bar: tweaks alongside inline composer mention pipeline
- components/editor/use-file-attach: small adjustments

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

* feat(mobile): picker polish + inline label create with deterministic color

- New labels mutation (data/mutations/labels.ts) + createLabel API method (data/api.ts) so the label picker can create-and-attach in one flow without leaving the sheet
- lib/inline-color.ts: deterministic palette hash ported from packages/views label-picker for behavioral parity (same name → same color across web/mobile)
- All issue + project picker bodies (label/priority/status/project on issues; lead/priority/status on projects) reworked for visual + interaction consistency
- Picker route shells (issue/[id]/picker/{label,project}, new-issue-picker/project, project/[id]/picker/lead) updated to match

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

* refactor(mobile): drop menu route + global-nav-menu, dropdown only

The More-tab dropdown popover (introduced earlier) now covers everything the dedicated /menu route and global-nav-menu component used to render. Drop both.

The Stack.Screen registration for the menu route in (app)/[workspace]/_layout.tsx is removed in the follow-up comment-surface commit alongside other dead route registrations.

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

* feat(mobile): comment surface — inline composer + UIKit context menu + failed-retry + last-viewed divider

Replaces the old route-based comment composition + actions sheet with surface-level UI that matches iMessage / Slack iOS / Telegram conventions.

Long-press on a comment bubble now hands the gesture to UIKit's UIContextMenuInteraction (via react-native-ios-context-menu) — system blur, snapshot scale, grouped menu (Reply / Edit / Copy / Select Text / Copy Link / Resolve / New Issue / Delete), and a Tapback-style auxiliary preview emoji row above the snapshot. Eliminates the race between Pressable.onLongPress and UITextView's selection magnifier that the old formSheet route suffered from.

New inline composer (components/issue/inline-comment-composer.tsx) sits at the bottom of the issue detail screen, pinned just above the keyboard via KeyboardStickyView (react-native-keyboard-controller). Replaces the new-comment.tsx modal route — phone keyboard already gives the composer dedicated real estate, the route + draft store were overhead.

Timeline gains:
- "New since last view" divider driven by data/stores/last-viewed-store.ts
- Failed-comment retry/discard inline affordance backed by data/stores/failed-comments-store.ts (mutation onError keeps the optimistic entry; this store carries retry metadata + error string)

Data layer:
- mutations/issues: useCreateComment accepts attachmentIds, mirrors web's activeIds derivation
- realtime/issue-ws-updaters + use-issue-realtime: WS coverage tweaks for new comment lifecycle
- comment-select-store: extended for the Select Text path triggered from the new context menu

Cleanup of dead route registrations (workspace _layout.tsx) for the removed new-comment, comment/actions, and (already-removed) menu routes.

Adds deps: react-native-ios-context-menu, react-native-ios-utilities, react-native-keyboard-controller.

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

* feat(mobile): More popover — pins + workspace switcher

- Pins: pin issues/projects from the header three-dot menu; Pinned list
  in the More popover; mirrors web's pin endpoints + cache shapes.
  Adds data/queries/pins.ts, data/mutations/pins.ts, realtime updater,
  PinListSchema + EMPTY_PIN_LIST fallback.
- Workspace switcher: collapse the per-workspace list in the More
  popover down to a single WorkspaceCard row + pushes a dedicated
  switch-workspace formSheet with an iOS Alert.alert confirm before
  actually switching. Adds friction against accidental taps and keeps
  the popover short.

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

* refactor(mobile): comment + chat long-press → ActionSheetIOS, composer pill↔expanded

- Comment long-press: drop react-native-ios-context-menu UIContextMenu
  wrapper in favour of native ActionSheetIOS via a useCommentLongPress
  hook. Removes two native deps (react-native-ios-context-menu +
  react-native-ios-utilities). The "Select text" path still works —
  toggling useCommentSelectStore swaps the bubble's long-press handler
  for selectable text.
- Comment composer: two visual states. Collapsed = pill placeholder
  ("Add a comment, @ to mention…"). Expanded = TextInput + toolbar
  (📎 attach · ➤ send). Adds reply-target-store driven by the long-press
  "Reply" action and an attachment row (composer-attachment-row +
  comment-attachment-list mirror web's data contract).
- Chat: matching ActionSheetIOS long-press (Copy / Select Text / Cancel)
  via message-long-press + chat-select-store; cleared on tab blur via
  useFocusEffect.
- useMentionInput.setText now accepts the React functional updater so
  post-await replacements (upload placeholder → final markdown) don't
  lose the user's intermediate typing.

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

* refactor(mobile): list parity polish + drop new-issue seed params

- my-issues / more issues: drop the RNR Tabs primitive in favour of
  plain Pressable pills (Tabs adds vertical padding + a divider that
  break under the cramped 375pt SE3 layout). "Agents and Squads" pill
  label trimmed to "Agents" — backend predicate unchanged
  (involves_user_id), empty-state copy still mentions "agents or
  squads". Scope counts dropped from pill labels (web's IssuesHeader
  doesn't show them either, and "(123)" suffix overflowed on SE3).
- issue-row: render assignee whenever assignee_type + assignee_id are
  both truthy. Earlier whitelist (member/agent only) silently dropped
  squad assignees; ActorAvatar already handles all four enum values.
- new-issue: remove unused seed_content / seed_actor route params —
  the comment-action-sheet path that fed them no longer exists.

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

* style(mobile): tighter markdown code sizing + auth layout

- Markdown: inline code 15→14 (match body) and block code 14→13 +
  leading-5. SF Mono is denser than PingFang at the same point size, so
  the +1 inline bump made mono glyphs visibly larger than surrounding
  Latin text; the new sizing matches GitHub Mobile / Linear iOS /
  Notion iOS. The two paths (CodeBlock vs enriched list-nested code)
  now agree on 13px.
- Login + verify: logo 56→32, title text-3xl bold → text-2xl semibold,
  description text-base → text-sm, outer gap-8 → gap-6, brand cluster
  gap-4/2 → gap-3/1. Brings the auth screens in line with iOS native
  Settings / Things 3 / Linear iOS layouts.

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

* chore(mobile): fresh-checkout build path — simulator scripts, env consistency

- Track apps/mobile/.env.staging (root .gitignore was swallowing it despite mobile gitignore claiming it was committed). Fresh checkouts can now run *:staging without copying the template first.
- Rename EXPO_BUNDLE_IDENTIFIER → EXPO_BUNDLE_IDENTIFIER_DEV and apply only in the dev variant of app.config.ts. Expo CLI auto-loads .env.development.local on every run regardless of APP_ENV, so a generic name silently leaked a dev's personal bundle id into staging / production builds and collapsed the three variants onto one id. The _DEV suffix + isDev-only branch keeps each variant on its canonical id.
- Add ios:mobile / ios:mobile:staging scripts (root + apps/mobile package.json) so the iOS Simulator path exists end-to-end. Previously the only documented build commands targeted USB devices.
- Rewrite apps/mobile/README.md: 6-row command table, first-time setup section (.env.development.local copy step, EXPO_BUNDLE_IDENTIFIER_DEV note), explicit simulator section, clarify 7-day signing limit applies to device builds only.
- Update root CLAUDE.md mobile commands block to list both simulator and device commands.

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

* feat(mobile): prod build path + composer/mention/edit polish

Prod build path — lets external users self-build a personal copy against
api.multica.ai's production backend:
- New `prod` variant alongside `dev` / `staging`: `.env.production`,
  `dev:prod` / `ios:device:prod` / `ios:device:prod:release` scripts
- `EXPO_BUNDLE_IDENTIFIER_PROD` shell override in `app.config.ts` for
  contributors not on the Multica Apple Developer team (parallel to
  existing `_DEV` pattern)
- Public docs page `mobile-app.{mdx,zh.mdx}` + Reference entry; README
  gains a top-of-file "Just want to use it" section

Composer refactor:
- Shared `components/composer/message-composer.tsx` shell removes ~400
  lines of duplication between chat-composer and inline-comment-composer
- Mention picker pulled out of inline modal into a Router formSheet route
  (`mention-picker.tsx` + `pickers/mention-picker-body.tsx`), backed by a
  Zustand `mention-draft-store`

Other:
- Issue edit screen (`issue/[id]/edit.tsx`) + reusable description-field
- Chat empty-state and timeline split into dedicated components;
  status-pill / message-list / attachment-row rewrites
- Markdown render tweaks, `lib/format-elapsed.ts`, `ui/collapsible.tsx`
- Realtime / schemas additions for chat session updates; new mention-picker
  stack screen registered in workspace `_layout.tsx`

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

* docs(mobile): rewrite self-build framing + fix latent CI errors

Docs: drop the "Multica Apple Developer team" framing (no such team) —
every contributor signs the default bundle id with Xcode's free Personal
Team; the EXPO_BUNDLE_IDENTIFIER_PROD override is just a fallback for the
rare case where the prefix gets squatted in Apple's developer portal.
Touched:
- apps/mobile/README.md (top "Just want to use it" section)
- apps/docs/content/docs/mobile-app.{mdx,zh.mdx}

CI: latent type / lint errors that the prior install-step failure had been
masking — surfaced once dependencies installed cleanly:
- failure-reason-label.ts / run-row.tsx — add the new
  codex_semantic_inactivity enum key from packages/core/types/agent.ts
- schemas.ts UserSchema + EMPTY_USER — add profile_description, timezone
- schemas.ts EMPTY_ISSUE_FALLBACK — add metadata
- profile.tsx — escape apostrophe in JSX text

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

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 19:14:55 +08:00
Naiyuan Qing
c280fc0879 fix(editor): sync TitleEditor when defaultValue changes externally (MUL-2565) (#3080)
* fix(editor): sync TitleEditor when defaultValue changes externally (MUL-2565)

Tiptap's useEditor consumes `content` only at mount, so a WS-driven title
update left the editor showing the old text. Worse, the next blur ran
onBlur's value-vs-issue.title compare with stale editor bytes and silently
mutated the title back, rolling the external change.

Add a useEffect that calls editor.commands.setContent when defaultValue
diverges and the editor is unfocused (preserve in-flight user typing).
Pass emitUpdate:false to avoid an onUpdate echo loop.

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

* fix(editor): refine TitleEditor focus guard to focused+dirty only (MUL-2565)

Reviewer flagged that the previous "focused → skip" guard was too coarse:
a user who clicked into the title field but had not yet typed would leave
the editor doc stale when an external title update arrived, and the next
blur would compare the stale text to the new server value and silently
roll the external update back.

Track the previous defaultValue in a ref and only skip when the editor is
both focused AND its current text diverges from that previous value
(meaning the user has actually typed). Focused-but-clean updates fall
through and accept the new external value.

Adds a regression test covering the focused-but-clean external update
case.

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

---------

Co-authored-by: multica-agent <github@multica.ai>
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-22 18:50:14 +08:00
Multica Eve
0339599ff6 docs(changelog): add 2026-05-22 release notes (#3082)
* docs(changelog): add 2026-05-22 release notes

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

* docs: tighten zh changelog copy

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

---------

Co-authored-by: Eve <eve@multica-ai.local>
Co-authored-by: multica-agent <github@multica.ai>
2026-05-22 17:58:05 +08:00
Bohan Jiang
a55c03a0b3 fix(agent): inject Workspace Context into agent brief (MUL-2542) (#3078)
* fix(agent): inject Workspace Context into agent brief (MUL-2542)

The per-workspace `workspace.context` field (Settings → General) was
stored in the DB but never reached the agent prompt. Plumb it from the
workspace row through the claim response, the daemon's Task struct and
TaskContextForEnv, and render it as `## Workspace Context` in the meta
brief above `## Available Commands`. Heading is skipped when the field
is empty so workspaces that haven't set a context don't see a bare
header. Applies to every task kind — issue, comment, chat, autopilot,
quick-create — so the shared system prompt is consistent regardless of
trigger source.

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

* chore(server): gofmt files touched by workspace-context injection

Run gofmt on the files that buildWorkspaceContext injection touched.
Cleans up composite-literal alignment in execenv task context and
struct-tag alignment in Task / AgentTaskResponse / RegisterRequest.
No behavior change.

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

---------

Co-authored-by: multica-agent <github@multica.ai>
Co-authored-by: J <agent-j@multica.ai>
2026-05-22 17:23:27 +08:00
YYClaw
ed8f43867c fix(runtimes): guard delete for self-healing local runtimes (#3076)
Deleting an online local runtime has no lasting effect — a live daemon
re-registers itself within seconds (#2404). Disable the delete button
for online local runtimes and explain why in a hover tooltip.

Also drop the redundant topbar delete button (the Diagnostics card
already owns the delete action), and navigate back to the runtimes
list after a successful delete instead of leaving a stale detail page.
2026-05-22 17:14:32 +08:00
Naiyuan Qing
d6fdd8d74e feat(onboarding): upgrade welcome_page card to slides + add Helper Stay-current rule (#3073)
- Card 3 (welcome_page): swap "HTML welcome page" for a single-file HTML
  slide deck. Prompt inlines frontend-slides constraints (viewport 100vh,
  clamp typography, density caps, anti-AI-slop aesthetic, CSS-only
  staggered load-in). Cards 1 (intro) and 2 (tour) unchanged.
- Helper instruction: add a "Stay current" section telling the agent to
  surface contradictions between this instruction and CLI/docs/repo,
  propose an updated instruction, and wait for user confirmation before
  applying via CLI — never self-update silently.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 16:39:25 +08:00
Bohan Jiang
f2e6dc75bd feat(create-issue): collapse start date into ⋯ overflow menu (#3063)
Start date is a low-frequency field for most issues, so the always-on
inline pill was crowding the property toolbar. Move it behind the ⋯
overflow menu by default: the pill only appears once a value is set,
or transiently while the calendar popover is open after the user picks
"Set start date..." from the menu. Closing the popover without a value
returns the pill to the menu-only state.

To make the menu item open the popover programmatically, lift the
picker's open state via new controlled `open` / `onOpenChange` props
(matching the priority-picker pattern).

MUL-2557

Co-authored-by: multica-agent <github@multica.ai>
2026-05-22 16:13:23 +08:00
Bohan Jiang
0bb51ccd0e feat(issues): mention parent assignee in child-done system comment (MUL-2538) (#3065)
* feat(issues): mention parent assignee in child-done system comment (MUL-2538)

Per Bohan's product call on MUL-2538 ("方案 C"), the platform's child-done
system comment now @mentions the parent assignee — member, squad, or
agent — and the platform fires the matching side effect explicitly:

- agent  → mention task via TaskService.EnqueueTaskForMention
- squad  → leader task via TaskService.EnqueueTaskForSquadLeader
- member → 'mentioned' inbox row + EventInboxNew broadcast

The generic comment listener still short-circuits on author_type='system'
(see notification_listeners.go) so smuggled mention links in the child
title can never light up unrelated members; the parent assignee mention
is the only side effect, and it is fired from the handler with explicit
guards rather than the listener path.

Guards retained / added:
- Comment-fire gates from prior PR unchanged (status transition, parent
  state, no parent).
- Loop guard: skip trigger when child and parent share the same assignee
  (same agent / same squad / same member). The comment + mention still
  render so the timeline tells the full story; the second task does not
  fire.
- Idempotency: HasPendingTaskForIssueAndAgent dedupes rapid-fire enqueues
  for the same parent (back-to-back child completions).
- Readiness: archived agents / missing runtimes are silently skipped.

Tests:
- TestChildDoneMentionsParentAssignee_{Agent,Member,Squad} verify the
  mention link + the matching trigger / inbox row.
- TestChildDoneSelfTriggerGuard_SameAgent asserts that an agent assigned
  to both the child and the parent gets the comment + mention but no
  second task — the documented loop break.
- TestChildDoneNotifiesParent updated: when the parent has no assignee
  (its existing fixture), no routing mention should appear; the assigned
  branches are exercised by the new cases above.

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

* feat(issues): skip child-done parent notification for human assignees (MUL-2538)

Humans read their own timeline manually — an automated system comment
is pure noise for member-assigned parents, and there is no agent task
to trigger. Skipping the notification entirely also removes the mention
question (no comment → no mention → no inbox row).

The agent / squad / unassigned branches stay unchanged.

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

* fix(issues): close cross-squad shared-leader loop in child-done dispatch (MUL-2538)

Elon's review of PR #3065 flagged that triggerChildDoneAgent and
triggerChildDoneSquad only compared the child's direct assignee, so a
child-done event could still wake the same agent when:

  - parent assigned to agent A, child assigned to a squad whose leader is A;
  - parent and child assigned to two different squads sharing the same
    leader agent.

Replace the per-side checks with a single effectiveChildAgentOwner helper
that reduces the child to "the agent that would actually act on it" (the
agent assignee, or the squad's leader) and lets both trigger paths compare
apples to apples. Add coverage for both newly-blocked cases, and tighten
the documented side-effect semantics (squad triggers leader only — no
member fan-out; notification_preference is not consulted, downstream
agent_task / inbox pipeline still respects mutes).

Also fix the member-skip test fixture to write user_id, matching the
production invariant that issue.assignee_id for assignee_type='member'
references user_id (validateAssigneePair, server/internal/handler/issue.go).

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

---------

Co-authored-by: multica-agent <github@multica.ai>
2026-05-22 16:05:26 +08:00
Dmitry
5bc77f2953 fix(pi): strip leaked tool markup safely (#2956) 2026-05-22 15:46:10 +08:00
Naiyuan Qing
e0b756f515 feat(issues): redesign board card layout + extract useTimeAgo i18n hook (#3064)
* refactor(views): replace static timeAgo with shared useTimeAgo hook

The previous timeAgo helper in packages/core/utils.ts hardcoded English
output ("2d ago"), producing "更新于 2d ago" mixed-language strings in
zh locale. Replaced with a localized useTimeAgo() hook in
packages/views/i18n, backed by common.time.{just_now,minutes_ago,
hours_ago,days_ago} translation keys. Migrated all 10 view-side
call sites and removed the static function.

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

* feat(issues): redesign board card layout

Properties were piling onto the bottom row (assignee + priority badge
+ start date + due date) until it overflowed. Restructured into four
semantic rows:

- Top: priority icon (left, icon-only — color already conveys urgency)
  + identifier; agent activity indicator (right)
- Title
- Chip row: project + labels
- Meta row: assignee (left, avatar + name when only property present;
  bare avatar otherwise) + start/due dates + child progress

Long agent/team names truncate cleanly (min-w-0 + max-w-[160px]) and
dates/progress are shrink-0 so they never compress. When the meta row
contains only an assignee, the right side fills with "Updated 2d ago"
to avoid a half-empty row.

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

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 15:21:10 +08:00
Bohan Jiang
a6f19380b2 test(agent): use ForkLock helper to fix ETXTBSY flake in thinking tests (#3062)
Two thinking tests wrote fake CLI scripts via os.WriteFile and immediately
execed them. Under t.Parallel() with the rest of pkg/agent, a sibling
test's concurrent fork can inherit our still-open write fd, so Linux
returns ETXTBSY at exec time (Go #22315). CI hit this on main as
"TestRunCodexDebugModels_ArgvSeenByBinary: fork/exec ...: text file busy".

Switch both call sites to the existing writeTestExecutable helper, which
holds syscall.ForkLock across OpenFile→Write→Close so no concurrent fork
can inherit the write fd. Same pattern the rest of the package already
uses (kimi, kiro, codex, claude tests).
2026-05-22 14:53:56 +08:00
Bohan Jiang
c967ae0e0e feat(issues): platform-owned parent notify on child done (MUL-2538) (#3055)
* feat(issues): platform-owned parent notify on child done (MUL-2538)

When a child issue transitions from a non-done status into `done` and has
an open parent, the server now posts a top-level platform-generated
comment on the parent itself. Replaces the agent-prompt rule shipped in
PR #2918, which produced self-mention loops, planner ping-pong, and
accidental `MUL-` prefix hardcoding because the agent did not always know
the workspace prefix.

- Migration 107 widens `comment.author_type` to allow `system`; the
  zero UUID is used as the sentinel `author_id` (the column stays NOT
  NULL, callers branch on `author_type === 'system'`).
- `Handler.notifyParentOfChildDone` fires from both `UpdateIssue` and
  `BatchUpdateIssues`. Guards: prev status != done, new status == done,
  parent set, parent not in `done`/`cancelled`. Bypasses the
  CreateComment HTTP path so the assignee on_comment trigger and the
  mention-trigger paths do not fire — the comment content carries only
  the safe issue mention for the child, no `mention://agent/...` /
  `mention://member/...` / `mention://squad/...` links.
- `runtime_config.go` downgrades the Parent/Sub-issue Protocol rule 1
  to an explicit "do NOT post one yourself" guardrail; rule 2 (sub-issue
  creation `--status todo` vs `backlog`) is unchanged.
- New handler test exercises the happy path, idempotency, reopen+done,
  parent done/cancelled guards, and the no-parent case. Runtime-config
  tests reassert the new wording and the banned strings from the prior
  revision.

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

* fix(issues): isolate system comments + wire GH merge path (MUL-2538)

Addresses the two must-fix items from the PR #3055 second review:

1. The platform-generated `comment:created` event (author_type='system')
   was running through the generic comment listeners, which (a) tried to
   subscribe the zero-UUID author and (b) parsed @mentions from the body
   for inbox notifications. Both subscriber_listeners and
   notification_listeners now early-return on author_type='system' so the
   event becomes a pure WS broadcast for the timeline — no inbox rows,
   no transcluded-mention attack surface.

2. advanceIssueToDone (the GitHub merge auto-done path) only published
   issue:updated and skipped notifyParentOfChildDone, so a child closed
   via merged PR — the dominant completion path — left the parent
   silent. The helper is now invoked on the same prev/updated pair, with
   the existing guards (transition + parent state) protecting double-fire.

Tests:
- New cmd/server/notification_listeners_test:
  TestNotification_SystemCommentSkipsInboxAndMentions (parent subscribers
  and smuggled @mention targets stay quiet),
  TestSubscriberSystemCommentDoesNotSubscribe (zero-UUID never reaches
  AddIssueSubscriber).
- New internal/handler/github_test:
  TestWebhook_MergedPR_ChildWithParent_NotifiesParent fires a real
  pull_request closed-merged webhook against a child and asserts the
  parent receives exactly one safe system comment with the workspace's
  real identifier (no `mention://agent|member|squad` links).

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

* fix(runtime): drop parent-notification guidance from agent brief (MUL-2538)

Per Bohan's product call on PR #3055: the platform now owns the
child-done parent notification, so the runtime brief should not mention
the parent-comment path at all — not as an instruction, not as a "do
not do it" guardrail. The previous revision kept rule 1 of the Parent /
Sub-issue Protocol as a "Do NOT post your own parent-notification
comment." sentence; that still puts the concept in front of the agent
every run, which is exactly what we are trying to avoid.

What changes:
- Delete the "Parent / Sub-issue Protocol" preamble and rule 1 from
  buildMetaSkillContent. The remaining content — the `--status todo`
  vs `--status backlog` rule for creating sub-issues — now lives in a
  dedicated `## Sub-issue Creation` section, since the parent/child
  framing it previously sat under is gone.
- The system comment on the parent stays exactly as in 366f6e2: the
  agent simply does not need to know about it.

Tests:
- runtime_config_test.go is rewritten around the new section name and
  the wider "no parent-notification guidance" canary; the banned list
  now covers both the original PR #2918 wording and the intermediate
  "do NOT post one" wording.

System comment UI: the frontend already renders `author_type === "system"`
with author name "Multica" (`useActorName`) and the MulticaIcon avatar
(`ActorAvatar` via `isSystem`), matching Bohan's "looks like a normal
comment, author is multica + multica logo" requirement — no frontend
changes needed.

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

---------

Co-authored-by: multica-agent <github@multica.ai>
2026-05-22 14:51:43 +08:00
Tom Qiao
1c91c2a3b2 security(db): scope DELETE/UpdateIssueStatus by workspace_id (defense-in-depth) (#3027)
* fix(security): scope DELETE/UpdateIssueStatus by workspace_id

Add workspace_id to the WHERE clause of DeleteIssue, DeleteComment,
DeleteProject, DeleteSkill, DeleteChatSession, and UpdateIssueStatus
as SQL-layer defense-in-depth.

Handler loaders (loadIssueForUser / loadSkillForUser / etc.) already
enforce workspace membership today, so this is not patching a known
live vuln. But the tenant invariant is currently a handler-layer
guarantee — a future loader bypass or a new caller skipping the
loader would be silently catastrophic. Making workspace_id part of
the SQL identity collapses the trust surface to the schema itself:
forging a sibling-workspace UUID becomes ErrNoRows instead of a
cross-tenant write.

Reference: incident #1661 (util.ParseUUID silent zero UUID returning
204 on a DELETE that matched zero rows) — same class of failure,
prevented at a different layer.

Scope:
- 5 DELETE queries: issue, comment, project, skill, chat_session
- 1 simple UPDATE: UpdateIssueStatus (2 narg, no SET ordering risk)
- All callers updated (handlers, service, runtime sweeper fallback)

Multi-narg UPDATE queries (UpdateIssue, UpdateProject, UpdateSkill,
UpdateComment, UpdateChatSession*) are deferred to a follow-up to
keep this change reviewable: each needs its narg pinning shifted
and per-caller verification.

sqlc was regenerated by hand (no local sqlc toolchain); CI's
backend job is the authoritative compile check.

* test(security): add workspace_scope_guard regression test

Locks in the SQL-layer tenant guard added in this PR. For each of the 6
scoped queries (DeleteIssue, DeleteComment, DeleteProject, DeleteSkill,
DeleteChatSession, UpdateIssueStatus), creates the resource in workspace
A, invokes the query with a foreign workspace UUID, and asserts the row
is untouched (0 rows affected with no error for :exec; pgx.ErrNoRows for
:one). A future refactor that drops the workspace_id arg from any of
these queries will now fail loudly instead of silently regressing.

Includes a sanity sub-test that the in-workspace path still mutates, so
a buggy guard that returns no-op for every call would not pass.

Co-Authored-By: Claude Opus 4 <noreply@anthropic.com>

---------

Co-authored-by: Tom Qiao <tomqiaozc@users.noreply.github.com>
Co-authored-by: Claude Opus 4 <noreply@anthropic.com>
2026-05-22 14:39:47 +08:00
Naiyuan Qing
fedd0f1694 feat(issues): live agent activity chip + per-issue indicator + filter (#3058)
* feat(server): broadcast task:running event

The dispatched → running transition was silent: only task:queued,
task:dispatch, task:cancelled, task:completed and task:failed
broadcast over WS. Any UI that distinguishes "queued" from "running"
(e.g. the new issue-card agent activity indicator) would lag by up to
the 30s agentTaskSnapshot staleTime on the most user-visible
transition. StartTask now broadcasts task:running so the workspace
snapshot invalidates immediately, keeping the agent activity UI live.

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

* feat(issues): live agent activity chip + per-issue indicator + filter

Surfaces "which agents are working on what, right now" in the Issues
and My Issues views, with a one-click filter to narrow the list to
issues that have a running agent task.

Two visual surfaces:

- **Workspace chip** in the header (left of Filter). Shows the
  brand-tinted avatar stack of agents currently running on visible
  issues. Click toggles a page-scoped filter; idle state renders a
  static "0 working" button with a hover-card placeholder. When the
  filter is active the chip pins to brand fill across hover and popover
  states (the Button outline variant otherwise repaints back to
  neutral). A muted "Viewing only working agents" hint sits to the
  left of the chip whenever the filter is on, so users notice the
  active state without having to hover.

- **Per-issue indicator** on every board card and list row (top-right
  of the identifier line). Renders the avatar stack of agents in
  running or queued state on that issue, full-opacity ring at brand/70
  when ≥1 is running, half-opacity stack when only queued. Returns
  null when nothing is in flight.

Both surfaces open the same hover-card body that lists each active
task with the agent avatar, status dot (composed via the existing
availability + workload tokens), and a live-ticking duration.

Adds a new "All" scope to /my-issues that unions assignee, creator,
and involves_user_id via three parallel fetches deduped on the
client — no backend changes for this part. The chip's count and the
quick-filter both use the page's currently visible issue ids so they
stay in sync with the active scope.

State is per-user (Zustand + localStorage) and the agentRunningFilter
is intentionally omitted from partialize — running state changes
second-to-second and a stored toggle would land users in an
unexplained empty list. WS task:running, already added in the
preceding commit, drives real-time updates without polling.

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

* refactor(issues): swap indicator ring pulse for shimmer text label

Earlier iterations layered a brand ring with various opacity-pulse
cadences around the per-issue avatar stack. Every tuning attempt was
either invisible (transparent ring + faded pulse) or oppressive (a
visible ring that flashed on a dense board). Moves the "alive" signal
onto a small text label and reuses chat's existing
`animate-chat-text-shimmer` utility — a soft light sweep across the
glyphs that already powers the ChatGPT-style "thinking" cue in
task-status-pill.

Indicator now reads as a 12 px avatar stack + 10 px label:

- Running → full-opacity avatars + shimmering localized "Working"
- Queued  → half-opacity avatars + muted static "Queued"
- Idle    → render nothing (unchanged)

Avatars and the surrounding card stay completely still; only the few
glyphs animate. The label is i18n-driven via the existing
`status_running` / `status_queued` keys, so no locale changes are
required.

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

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 14:20:42 +08:00
George
5d9293b8d0 fix(selfhost): remove unused db exposed port (#3040) 2026-05-22 14:19:46 +08:00
Bohan Jiang
f0a6738ed9 fix(landing): scroll to success card and simplify CTA on contact sales (#3057)
After submit, the tall form collapses into the much shorter success card;
the browser keeps the scroll offset so the user lands on the footer and
has to scroll up to see the confirmation. Scroll the page back to the
success card on success.

Also shorten the awkward "Back to multica.ai" / "返回 multica.ai" CTA to
"Back to home" / "返回首页".

MUL-2493

Co-authored-by: multica-agent <github@multica.ai>
2026-05-22 14:11:37 +08:00
Kagura
eefc6cebaa feat(server): add workspace-level always_redact_env setting (MUL-2495) (#2367)
* feat(server): add workspace-level always_redact_env setting

When a workspace opts into always_redact_env (via workspace settings JSON),
all agent GET/LIST responses will have custom_env values masked and
mcp_config nulled regardless of the caller's role. This provides a stricter
security posture for single-tenant self-hosts or environments where
screen-sharing or pairing makes plaintext secrets a risk.

The setting is opt-in and defaults to false (preserving existing behavior).
Owners can still write secrets via the update path; they just cannot read
them back through the API when this setting is enabled.

Closes #2352

* fix(server): fail-closed on GetWorkspace, add HTTP tests, distinguish redaction reason

Address review feedback on #2367:

1. GetWorkspace failure now returns 500 instead of silently defaulting
   to alwaysRedact=false (fail-open → fail-closed).

2. Add HTTP-level regression tests for always_redact_env:
   - GetAgent with flag on → owner sees redacted env
   - ListAgents with flag on → owner sees redacted env
   - GetAgent with default settings → owner sees plaintext env

3. Add custom_env_redacted_reason field ('policy' | 'role') to
   distinguish workspace-policy redaction from role-based redaction.
   UI now only sets readOnly when reason is 'role', allowing owners
   to edit env even when always_redact_env is enabled.

4. Write-back footgun tracked in #2999.

Signed-off-by: kagura-agent <kagura.agent.ai@gmail.com>

* fix(test): clear workspace settings before DefaultNoRedactForOwner

Guard against test-order leakage: if a preceding test enabled
always_redact_env on the shared workspace and its cleanup didn't
run (e.g. due to -shuffle or parallel execution), this test would
incorrectly see policy-level redaction. Explicitly reset settings
to NULL before assertions.

Signed-off-by: kagura-agent <kagura.agent.ai@gmail.com>

* fix(ui): make EnvTab read-only when env is redacted by any policy

Previously the readOnly guard only checked for 'role' redaction,
leaving the tab editable under 'policy' redaction. This meant
a user could save the form with '****' placeholder values,
permanently overwriting the actual secrets.

Use the boolean custom_env_redacted flag instead so the tab is
locked regardless of the redaction reason.

Fixes the regression flagged in the third-pass review.

Signed-off-by: kagura-agent <kagura.agent.ai@gmail.com>

* fix: reset workspace settings to empty JSON instead of NULL

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* style: gofmt AgentResponse struct alignment

Signed-off-by: kagura-agent <kagura.agent.ai@gmail.com>

---------

Signed-off-by: kagura-agent <kagura.agent.ai@gmail.com>
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-05-22 14:10:09 +08:00
Bohan Jiang
38ea02e60c feat(landing): move Contact Sales to hero as text-only link (#3056)
Per design feedback, the Contact Sales entry now sits next to "Start
free trial" / "Download Desktop" in the hero as a text-only "Talk to
sales →" link (no background, no border) and is removed from the
landing header.

MUL-2493

Co-authored-by: multica-agent <github@multica.ai>
2026-05-22 13:59:20 +08:00
Bohan Jiang
bc056cf0ea fix(landing): call API origin directly from Contact Sales form (#3054)
The form posted to a relative `/api/contact-sales`, which on the
Vercel-hosted web app gets handled by the `/api/*` rewrite using
the server-only `REMOTE_API_URL`. On `multica-app.copilothub.ai`
that env points at a privately-resolvable host, so the rewrite
returns 404 (`DNS_HOSTNAME_RESOLVED_PRIVATE`) even though every
other API call works — the rest of the app uses
`NEXT_PUBLIC_API_URL` and hits the API origin directly.

Switch the form to do the same: `${NEXT_PUBLIC_API_URL}/api/contact-sales`,
falling back to a relative URL for local dev / self-hosted setups
where same-origin still works.

MUL-2493

Co-authored-by: multica-agent <github@multica.ai>
2026-05-22 13:50:47 +08:00
Chener
ba9714a364 feat(desktop): support macOS swipe navigation (#2997)
Co-authored-by: chener <chener@M5Air.local>
2026-05-22 13:49:29 +08:00
Bohan Jiang
46a29b1ebb fix(squads): warn leader against double-triggering an agent (#3053)
Squad coordinators were both @mentioning an agent in the parent issue and
creating a todo child issue assigned to the same agent, causing the agent
to be triggered twice in parallel (mention dispatch + assignment dispatch).
The server has no cross-issue dedupe for this case — and adding one would
make @mention semantics context-dependent and unpredictable.

Fix is at the prompt level: tell the squad leader that a `todo` child
issue with an agent assignee already fires that agent, so they must pick
exactly one delegation path for any given piece of work — comment-based
@mention or todo child-issue assignment, never both.

Adds a focused regression test that locks in the new rule via narrow
substring checks (so harmless rewording stays free).

Fixes #3033

Co-authored-by: multica-agent <github@multica.ai>
2026-05-22 13:48:21 +08:00
DorothyWoolf
a5582198ab fix(views): widen assignee picker and add text truncation (#2947) (#3044)
Popover was too narrow (w-52) to display long names. Widened to w-64 and
added truncate class to member/agent/squad name spans to prevent overflow.

Co-authored-by: dengjie5 <dengjie5@xiaomi.com>
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-05-22 13:36:46 +08:00
Bohan Jiang
7984606eed feat(landing): add Contact Sales page and inquiry endpoint (MUL-2493) (#2988)
* feat(landing): add Contact Sales page and inquiry endpoint (MUL-2493)

Adds a public `/contact-sales` marketing page with a needs-discovery form
modelled on the design reference attached to MUL-2493 — first/last name,
business email (with free-provider rejection), company name + size,
country/region, intended use case, and a free-text goals field, plus the
two consent checkboxes from the reference.

Submissions hit a new public `POST /api/contact-sales` endpoint with
per-IP rate limiting (Redis-backed via the existing RateLimit middleware,
configurable through `RATE_LIMIT_CONTACT_SALES`) and a per-email hourly
cap so a single business address can't be used as a flood channel after
one valid pass. The inquiry is stored in a new `contact_sales_inquiry`
table; analytics fires a `contact_sales_submitted` PostHog event with
only the closed-enum dimensions (size, country, use case) — the free-text
goals stay in the DB and are never broadcast.

The page is linked from the landing header (md+) and the footer's Company
column, in both English and Simplified Chinese. The reserved-slug list is
updated so a workspace named `contact-sales` can't shadow the route.

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

* fix(landing): canonicalize business email and tighten contact-sales form (MUL-2493)

- Parse the submitted email with net/mail and run the free-email
  block-list against the canonical addr.Address, so a display-name
  form like `Ada <ada@gmail.com>` can no longer slip past the gate
  (the raw string had domain `gmail.com>`, which wasn't blocked).
  Adds regression tests covering the display-name bypass and the
  canonicalization helper.
- Drop noValidate from the contact-sales form so the browser's
  native required / email / select checks fire before submit;
  the JS-side free-email warning still runs as a UX guard.
- Update success copy ("respond within three business days") in
  EN and ZH plus the page metadata.

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

---------

Co-authored-by: multica-agent <github@multica.ai>
2026-05-22 13:22:36 +08:00
Bohan Jiang
424f67f7cb fix(security): normalize MIME type in isInlineContentType (#3050)
isInlineContentType is the security boundary that decides whether an
uploaded file is served with Content-Disposition: inline (renderable
in the document origin) or attachment. The SVG carve-out added in
#3023 to block stored-XSS via uploaded .svg only matched the exact
literal "image/svg+xml", so callers that supply "IMAGE/SVG+XML",
"image/svg+xml; charset=utf-8", or whitespace-padded variants would
still see disposition=inline. MIME type matching is case-insensitive
per RFC 2045 §5.1 and may carry parameters, so the safe thing is to
normalize at the boundary instead of trusting every caller.

Today both call sites (S3.Upload and LocalStorage.Serve) happen to
feed in the exact literal because the upload handler overrides .svg
to "image/svg+xml" before storage sees it, so this is defense-in-depth
rather than a live regression. Hardens the helper so any future caller
(including one that ever trusts a client-supplied Content-Type) stays
behind the same guard.

Co-authored-by: multica-agent <github@multica.ai>
2026-05-22 13:01:52 +08:00
Tom Qiao
295df8d928 fix(security): force attachment disposition for SVG uploads (#3023)
SVG files are XML and can carry <script>, <foreignObject>, or onload=
attributes that execute in the document's origin when rendered inline.
The upload handler maps .svg to image/svg+xml, and storage backends
(local + S3) previously set Content-Disposition: inline based on the
image/ prefix in isInlineContentType. A workspace member could upload
a crafted SVG, share its attachment URL in an issue or comment, and any
teammate who clicks the link would execute attacker-controlled JS in
the application's first-party origin (reading auth cookies, posting to
authenticated endpoints).

Exclude image/svg+xml from isInlineContentType so both storage paths
serve SVG with Content-Disposition: attachment.

Test coverage:
- New util_test.go covers the inline/attachment matrix including SVG.
- Existing local_test.go ContentDisposition table gains an SVG case.

Co-authored-by: Tom Qiao <tomqiaozc@users.noreply.github.com>
2026-05-22 12:51:43 +08:00
LinYushen
5bacfd9742 MUL-2526 feat: add member(user_id, workspace_id) index + upgrade sqlc to v1.31.1 (#3046)
- Add migration 106: CREATE INDEX CONCURRENTLY on member(user_id, workspace_id)
- Rewrite ListWorkspaces to drive from member table with explicit fields
- Regenerate all sqlc code with v1.31.1 (intentional version upgrade)

Co-authored-by: multica-agent <github@multica.ai>
2026-05-22 12:26:56 +08:00
Tom Qiao
b9602adabe fix(handler): validate skill id UUID at request boundary (#3025)
loadSkillForUser was passing chi.URLParam(r, "id") directly into
parseUUID, the panic-on-invalid helper reserved for trusted UUID
round-trips. A malformed `/api/skills/{notuuid}` request panicked
in util.MustParseUUID; chi's middleware.Recoverer turned it into a
500 instead of a 400.

This violates the documented convention (CLAUDE.md → "Backend Handler
UUID Parsing Convention"): pure-UUID request inputs must use
parseUUIDOrBadRequest, which writes a 400 and short-circuits.

Switch loadSkillForUser to parseUUIDOrBadRequest. Behaviour for valid
UUIDs is unchanged; malformed input now returns 400 with a clear
"invalid skill id" message.

Test:
- TestGetSkill_MalformedUUIDReturns400 asserts GET /api/skills/not-a-uuid
  returns 400.

Co-authored-by: Tom Qiao <tomqiaozc@users.noreply.github.com>
2026-05-22 12:22:07 +08:00
LinYushen
74f4d5a8fc MUL-2510 fix(api): use instance_id in deleteCloudRuntimeNode body (#3009)
* fix(api): use instance_id in deleteCloudRuntimeNode body

Fleet API requires instance_id, not id. Fixes 'instance_id is required' error.

MUL-2510

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

* fix(ui): pass node.instance_id instead of node.id to deleteNode mutation

Fleet expects the actual AWS instance_id (e.g. i-0123456789abcdef0),
not the internal DB id. Updated the mutate call in cloud-runtime-dialog
to pass node.instance_id so the correct value reaches Fleet's
DELETE /api/v1/nodes endpoint.

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

* fix: pass node.instance_id and rename param to instanceId

- cloud-runtime-dialog.tsx: deleteNode.mutate(node.instance_id)
- client.ts: rename nodeId param to instanceId
- cloud-runtime.ts: rename nodeId param to instanceId
- client.test.ts: use i-0123456789abcdef0 test value

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

* fix: update test description from 'node id' to 'instance id'

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

---------

Co-authored-by: multica-agent <github@multica.ai>
2026-05-22 10:59:51 +08:00
Qi Yijiazhen
4ee5d5acdd fix(skills): improve mobile skill readability (#2973) 2026-05-22 09:19:41 +08:00
Bohan Jiang
41788d2728 fix(settings): i18n the desktop Updates tab (MUL-2515) (#3014)
* fix(settings): i18n the desktop Updates tab (MUL-2515)

The Updates tab in Settings was hardcoded English, so Chinese users
saw a jagged untranslated panel. Wrap the desktop settings route in a
component so the tab label can pull from i18n, move the panel copy to
a new desktop.updates namespace under settings, and translate it for
zh-Hans.

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

* fix(settings): polish zh-Hans Updates tab copy (MUL-2515)

Address review feedback on PR #3014:
- "桌面 app" → "桌面端" to match runtime voice
- "检查中…" → "检查中..." per zh conventions (ASCII ellipsis)

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

---------

Co-authored-by: multica-agent <github@multica.ai>
2026-05-21 22:27:40 +08:00
Naiyuan Qing
fbd965e5bf feat(onboarding): v3 — thin server, frontend-orchestrated welcome (#3008)
* feat(onboarding): Multica Helper as general workspace assistant + blocking modal

Reshape Multica Helper from an onboarding-only guide into the workspace's
general-purpose AI assistant. The agent's permanent identity (injected as
`## Agent Identity` into every task's CLAUDE.md / AGENTS.md / GEMINI.md
via execenv.InjectRuntimeConfig) is rewritten to three sections that don't
overlap with what the brief already provides:

  - Who I am (built-in workspace assistant, not onboarding-only)
  - What Multica is + docs/source/issues URLs as knowledge sources
  - What I can do (CLI = manifest, `multica --help` is the source of truth)
  - Tone (concise, like a colleague, match user's language)

Bootstrap moves out of the in-flow Step 4. Runtime step now exits the
onboarding shell with no bootstrap call; a blocking OnboardingHelperModal
mounts inside the workspace layout (web + desktop) and gates purely on
`me.onboarded_at == null`. The user picks one of three starter prompts
(intro / assign / second_agent) and the modal calls
BootstrapOnboardingRuntime with a new optional `starter_prompt` field that
becomes the seeded onboarding issue's description.

Side effects required to make `onboarded_at == null` an honest signal:

  - CreateWorkspace no longer marks onboarded (was atomic with CreateMember).
    The "member exists ⟹ onboarded_at != null" invariant is intentionally
    broken; guards (useDashboardGuard / desktop App.tsx) already tolerate
    this — comments updated to reflect the new contract.
  - AcceptInvitation still marks (invitee skips the modal in someone
    else's workspace). Code comment added warning future removers.
  - resolvePostAuthDestination flips to workspace-presence-first: a user
    with a workspace lands in it regardless of `onboarded_at`, so the
    modal can pick up an interrupted setup on relogin.

Other backend changes:
  - `onboardingAssistantDescription` rewritten ("Built-in workspace assistant…")
  - `onboardingAssistantInstructions` rewritten to the 3-section identity
  - `bootstrapOnboardingRuntimeRequest.StarterPrompt` (optional, 2 KiB rune
    cap, empty-falls-back-to onboardingIssueDescription)

Frontend changes:
  - Delete `packages/views/onboarding/steps/step-teammate.tsx` (no longer a
    persisted step)
  - `ONBOARDING_STEP_ORDER` and `OnboardingStep` type drop `"teammate"`
  - `handleRuntimeNext` exits via `onComplete(workspace, undefined)` — no
    bootstrap, `onboarded_at` stays NULL so the modal fires
  - Runtime step next-button copy → "Start exploring" / "开始探索"
  - New `packages/views/workspace/onboarding-helper-modal.tsx`:
    Base UI Dialog, dismissible=false, three localized cards, mutation
    invalidates agents + issues queries then navigates to the seeded issue
  - Mounted in both `apps/web/app/[workspaceSlug]/layout.tsx` and
    `apps/desktop/src/renderer/src/components/workspace-route-layout.tsx`

Tests:
  - Backend: TestBootstrapOnboardingRuntime_{With,No}StarterPrompt and
    TestCreateWorkspace_DoesNotMarkOnboarded
  - Frontend: onboarding-helper-modal.test.tsx covers all four gating
    conditions, three-card behavior, mutation pending state, and the
    "no close button" invariant

Compatibility:
  - Already-onboarded users: zero impact (modal can't fire)
  - Invitees: AcceptInvitation still marks → modal can't fire
  - Skip-runtime path: BootstrapOnboardingNoRuntime still marks → modal can't fire
  - Old desktop / web clients: legacy teammate-step path keeps working
    (bootstrap accepts missing starter_prompt) — the new modal only fires
    on the new frontend bundle
  - Avatar SVG kept (asterisk variant) — no migration of existing Helper
    agents, only newly-created Helpers pick up the new instructions/description

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

* fix(desktop): suppress OnboardingHelperModal while a WindowOverlay is open

On desktop, App.tsx auto-creates a tab pointing at the user's first
workspace as soon as workspaces.length flips from 0 → 1 (during onboarding
Step 2). The new tab mounts WorkspaceRouteLayout under the overlay,
which mounts OnboardingHelperModal. The modal's Portal renders to
document.body — appearing AFTER the WindowOverlay in DOM order, so its
z-50 wins and the modal floats in front of the still-active onboarding
Step 3 (runtime).

Suppress the modal whenever any WindowOverlay is active. When the overlay
closes (onComplete fires after the user finishes onboarding), the modal
re-evaluates `me.onboarded_at == null` and pops on its own.

Web is unaffected (onboarding flow lives at /onboarding, not under
/[workspaceSlug]/, so WorkspaceRouteLayout never mounts during the
onboarding flow).

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

* docs(onboarding): add v2 refactor plan

Captures the design + 8-step implementation order for collapsing the
onboarding state machine: single mark-onboarded entry point, persisted
Step 3 user choice, dumb Modal, single install-runtime seed call site.
Includes old-user compatibility analysis (4 existing gates) and per-PR
risk/rollback.

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

* feat(db): persist Step 3 runtime choice on user record (MUL-onboarding-v2)

Adds onboarding_runtime_id UUID NULL + onboarding_runtime_skipped BOOLEAN
columns to "user" and the CHECK constraint enforcing the 3-state machine
(unset / picked-runtime / explicit-skip; the fourth combination is
forbidden). ON DELETE SET NULL on the FK so a deleted runtime degrades
to "unset" rather than dangling.

PatchUserOnboarding gains the two narg fields plus CASE expressions that
collapse the runtime/skipped pair atomically — a follow-up PATCH that
flips one side now clears the other in the same statement, instead of
preserving it via per-field COALESCE and tripping the CHECK constraint.

Backwards compatible for existing users: both new fields default to
(NULL, false), which is the "unset" leaf of the state machine, and four
upstream gates on me.onboarded_at != null already short-circuit the
new fields' readers for everyone who's already onboarded.

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

* refactor(server): collapse onboarding side effects to service layer

Introduces OnboardingService.MarkComplete and
WorkspaceContentService.{Ensure,Seed}InstallRuntimeIssue as the single
authorities for the two onboarding side effects that used to be
duplicated across four handlers:

  - MarkUserOnboarded + claim starter_content_state +
    optional install-runtime fallback seed: was inline in
    BootstrapOnboardingRuntime, BootstrapOnboardingNoRuntime,
    AcceptInvitation, and CompleteOnboarding.
  - install-runtime issue seeding: was inline in CreateWorkspace and
    AcceptInvitation as a "no runtime yet" fallback.

After this refactor:
  - MarkUserOnboarded is called from exactly one place (the service).
  - install-runtime issue is seeded from exactly one place (the service).
  - CreateWorkspace deliberately does not seed — the new
    /ensure-onboarding-content endpoint (also added here) lets the
    workspace-entry init component request the seed on first mount, so
    workspaces created but never opened don't accumulate stale issues.
  - The PatchOnboarding handler now accepts the new runtime_id /
    runtime_skipped fields and rejects (uuid, skipped=true) up front.
  - UserResponse exposes the two new persisted fields so the frontend
    can read them off `me` without an extra round-trip.

Handler-side tests added: TestPatchOnboarding_RuntimeChoiceSwitch (the
explicit cross-request switch path that the original COALESCE design
would have 500'd on) + TestPatchOnboarding_PreserveUntouched.

Old handler-local file no_runtime_issue.go is deleted; its content
moved to service/workspace_content.go with the helpers exported.

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

* feat(core): API + types for persisted onboarding runtime choice

User type / Zod schema gain onboarding_runtime_id (string | null) and
onboarding_runtime_skipped (boolean); EMPTY_USER + test fixture updated
to match. api.patchOnboarding accepts the new optional fields and the
new api.ensureOnboardingContent endpoint is wired so the workspace
shell can request the fallback seed.

Two new store helpers — recordOnboardingRuntimeChoice(runtimeId) and
recordOnboardingRuntimeSkipped() — replace the prior pattern of
Step 3 calling bootstrap directly. They PATCH the user's choice, sync
the auth store, and return. Mutually exclusive on the server side via
the CHECK constraint; the client just ships one intent at a time.

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

* feat(workspace): WorkspaceOnboardingInit single decision point + dumb Modal

Replaces OnboardingHelperModal's self-gating render path with a 4-branch
dispatcher that runs once on workspace-shell mount:

  branch 0  me.onboarded_at != null         → ensure install-runtime issue
                                              fallback, render nothing
  branch 1  me.onboarding_runtime_skipped   → SkipBootstrapping component:
                                              loading veil → bootstrap →
                                              navigate. On failure shows
                                              a Retry UI instead of
                                              silently freezing the veil
  branch 2  me.onboarding_runtime_id        → render Modal with the
                                              runtime id from `me` (no
                                              internal list query)
  branch 3  (none of the above)             → useEffect navigate back to
                                              /onboarding so the user
                                              walks Step 3 again

The Modal itself is now a dumb component — receives `workspace` and
`runtimeId` as props, no internal gates, no runtimeListOptions query.
Tests rewritten to cover the props-driven render + pick-card paths;
the prior gating tests move into the new
workspace-onboarding-init.test.tsx alongside the M2 retry-on-failure
behaviour.

Mounted in both apps/web/app/[workspaceSlug]/layout.tsx and the desktop
workspace-route-layout. Desktop keeps its `!overlayActive` suppression
guard so the init doesn't portal-jump in front of an active
WindowOverlay.

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

* feat(onboarding): Step 3 records user choice instead of calling bootstrap

handleRuntimeNext now PATCHes the user's pick (recordOnboardingRuntime
{Choice,Skipped}) and navigates straight into the workspace shell. The
workspace-entry WorkspaceOnboardingInit reads the persisted choice off
`me` and runs the appropriate branch — Step 3 is pure intent capture
with zero side effects on its own.

PATCH must succeed before navigation: if it fails the user stays on
Step 3 with a toast, because navigating with no persisted intent would
land them in WorkspaceOnboardingInit's branch 3 "no decision yet" rescue
and trigger a redirect loop back to /onboarding.

The prior asymmetry (Connect deferred bootstrap to the workspace, Skip
ran bootstrap inline) is gone — both paths defer to the workspace
shell now.

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

* feat(onboarding): v3 — thin server, frontend-orchestrated welcome

Collapse v2's persisted runtime-choice fields + 4-branch dispatcher +
OnboardingService/WorkspaceContentService stack down to a single rule:
`onboarded_at` is the only state field, layout hard-gates on it, and the
welcome experience after Step 3 is owned entirely by the frontend.

V3 flow
- Step 3 button: await POST /api/me/onboarding/complete (mark only) +
  park a transient signal in `useWelcomeStore` + navigate
- Workspace layout: hard gate `onboarded_at == null` -> /onboarding
- `<WelcomeAfterOnboarding />` reads the welcome-store signal:
  - runtime path: find-or-create Multica Helper via generic createAgent
    with bilingual instructions from `templates/helper-instructions.ts`,
    blocking modal with 3 starter cards, pick -> createIssue + navigate
  - skip path: provision install-runtime (in_progress) -> agent-guide
    (todo, body embeds install-runtime mention chip) -> follow-up comment
    on install-runtime mentioning agent-guide; then pop celebration
    modal with 🎉 emoji pop animation, 2 read-only preview cards, single
    [Got it] CTA that navigates to install-runtime

Server cleanup
- Drop OnboardingService, WorkspaceContentService, v2 runtime-choice
  columns/CHECK on user, EnsureOnboardingContent endpoint
- CompleteOnboarding/AcceptInvitation call qtx.MarkUserOnboarded
  directly (no service indirection)
- BootstrapOnboardingRuntime / BootstrapOnboardingNoRuntime kept as a
  deprecation shim in onboarding_shim.go for desktop < v3 during the
  rollout window — handlers inlined to qtx.* calls, no service layer

Localization
- Persisted strings (issue titles/bodies, Helper instructions/
  description, comment prefix) live as TS const `{en, zh}` maps in
  `packages/views/onboarding/templates/` — i18n bundle staleness can no
  longer write raw key paths into DB
- UI-rendered strings (modal copy, status chips, buttons) stay in
  `packages/views/locales/{en,zh-Hans}/onboarding.json`
- Language picked from live `i18n.language` (not `me.language`, which is
  null for new users until they pick a preference)

Race protection
- Module-level promise dedupe (`findOrCreateHelper`, `seedIssueDeduped`,
  `postCommentDeduped`) so React StrictMode double-mount can't fire two
  parallel API calls that the server would then 409

Cross-references between the two skip-path issues render via Multica's
mention-chip protocol `[<identifier>](mention://issue/<uuid>)` so they
match the styled IssueChip pills used elsewhere.

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

* feat(onboarding): welcome-after-onboarding modal redesign + cross-user safety

Welcome modal polish (the post-Step-3 surface this branch already
introduced):

Runtime path
- Helper avatar replaces the bouncy 🎉 hero; tone-down animation to
  fade. New copy: "Hi, welcome to Multica / I'm your first Agent
  assistant" + capability hint sentence so users discover assignment +
  chat from the first screen.
- Cards changed from "click = submit" to multi-select with the existing
  border-primary + ring selection pattern used by compact-runtime-row;
  bottom CTA "Assign N tasks to me →" appears only with N>0.
- New starter cards: intro / tour / welcome_page (the last one tells
  Helper to paste an HTML welcome page into the issue comment — works
  on any runtime regardless of fs access).
- Success state added between createIssue and navigation: 🎉 +
  "All set!" + "Sit tight  — your {agentName} is on it" + inbox/chat
  hints, single [Got it] button.
- Title/prompt for starter cards now live in TS const
  HELPER_STARTER_PROMPTS (persisted to DB — must not depend on i18n
  bundle being loaded); subtitle stays in onboarding.json.

Skip path
- Body restructured into three independent ```md blocks (Name /
  Description / Instructions) so each picks up the markdown renderer's
  per-block copy button — no manual extraction.
- ZH body now embeds the ZH Helper Description + Instructions (was
  Chinese-around-English-block).
- Follow-up comment uses Multica's mention-chip protocol
  [identifier](mention://issue/uuid) so it renders as the styled
  IssueChip pill.
- Issue titles bilingual with "Step 1 / Step 2" prefix.

Cross-user / cross-workspace safety (code review feedback)
- web onLogout + desktop handleDaemonLogout now call
  useWelcomeStore.reset() so user B logging into the same browser
  doesn't inherit user A's signal.
- WelcomeAfterOnboarding gates on
  currentWorkspace.id === signal.workspaceId — prevents firing the
  modal in workspace B when the signal was parked for workspace A
  (desktop multi-tab, back/forward, deep-link).
- Module-level promise dedupes (pendingHelperSetup,
  pendingIssueSeed, pendingCommentSeed) for the three API calls so
  React 18+ StrictMode dev double-mount can't race-create duplicates.

Other small fixes carried in this commit
- Helper instructions / agent description / starter card titles all
  read i18n.language (not me.language, which is null for new users
  who haven't picked a UI language preference yet).
- Reverted welcome-emoji-pop animation to a small fade for the runtime
  avatar (kept the bouncy variant for the skip 🎉 hero where the
  celebration is the whole point).
- Removed the duplicate 🎉 from the skip modal title (kept the hero
  one only).

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

* fix(views): i18n hardcoded "Close" in welcome FullScreenError

CI lint (i18next/no-literal-string) blocked on a literal "Close" string
inside `FullScreenError` — surfaced as a nit in the original code
review but missed in the merge. Add `error_close` to onboarding.json
(EN: "Close" / ZH: "关闭") and thread it through as a `closeLabel`
prop, matching the existing `retryLabel` plumbing.

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

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 19:00:26 +08:00
Bohan Jiang
cb90249eac test(issues): match new metadata trigger label with count suffix (#3011)
The sidebar metadata trigger now reads "Metadata · N" (#3010), so the
exact-name button query stopped matching and 2 tests went red on main.
Relax the assertion to `/^Metadata\b/` — still anchors on the label but
tolerates the count suffix.
2026-05-21 18:25:36 +08:00
Multica Eve
af13d7ad3a docs: add v0.3.5 changelog (#3006)
* docs: add v0.3.5 changelog

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

* docs: revise v0.3.5 changelog

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

* docs: refine v0.3.5 changelog title

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

---------

Co-authored-by: Eve <eve@multica-ai.local>
Co-authored-by: multica-agent <github@multica.ai>
2026-05-21 18:09:29 +08:00
Bohan Jiang
cbd42dfcc4 feat(issues): align metadata trigger with sibling sections (#3010)
Reshape the sidebar metadata trigger so it visually matches the Pull
requests / Details / Parent issue headers (muted "Metadata · N" row
instead of an icon+label button). Clicking still opens the existing
JSON dialog — folding the bag inline pushed the rest of the sidebar
down too much when the payload was large.
2026-05-21 17:57:27 +08:00
LinYushen
adec90c621 MUL-2510 feat: add delete button to fleet nodes list (#3007)
* feat: add delete button to fleet nodes list

- Add deleteCloudRuntimeNode method to API client (DELETE /api/cloud-runtime/nodes/:nodeId)
- Add useDeleteCloudRuntimeNode mutation hook in cloud-runtime.ts
- Add delete button with Trash2 icon to CloudRuntimeNodeRow component
- Include confirmation dialog, loading state, and toast notifications
- Add i18n keys for en and zh-Hans locales

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

* fix(api): correct deleteCloudRuntimeNode contract to match server

- Change from DELETE /api/cloud-runtime/nodes/:nodeId (no body) to
  DELETE /api/cloud-runtime/nodes with JSON body { id: nodeId }
- Use fetchRaw + Content-Type header to match server's withBody proxy
- Add contract test verifying URL, method, body, and Content-Type

Fixes review feedback on MUL-2510

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

---------

Co-authored-by: multica-agent <github@multica.ai>
2026-05-21 17:46:26 +08:00
Bohan Jiang
ae530ef057 docs(runtime): tighten issue-metadata write bar (MUL-2507) (#3004)
The previous wording invited agents to pin too much: any opened PR,
external link, or "fact future agents will want one-glance access to"
was framed as worth writing, with no explicit upper bound. In practice
this caused metadata bags to accumulate single-run details and
description-summary noise instead of the small set of repeatedly-read
values the feature was designed for.

Rework the agent runtime brief and the CLI docs to lead with the bar:
write a key only when it is materially important AND likely to be
re-read by future runs on the same issue. "Most runs write zero new
keys" is now stated as the expected case, and the workflow exit step
is rewritten to mirror the same gate. Recommended-key list, safety
boundaries, and stale-key cleanup are preserved so the locked-in test
anchors still pass.

Co-authored-by: multica-agent <github@multica.ai>
2026-05-21 17:20:43 +08:00
Bohan Jiang
ab0228c2a1 feat(issues): collapse long metadata bags in sidebar MUL-2503 (#3003)
* feat(issues): collapse long metadata bags in sidebar (MUL-2503)

The metadata KV strip rendered every key inline, so issues with many
pinned keys pushed the rest of the sidebar far down. Keep the first
four rows visible and tuck the remainder behind a Show N more / Show
less toggle once the bag reaches five keys, mirroring the PR list
collapse rule.

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

* refactor(issues): hide metadata behind a JSON dialog (MUL-2503)

Metadata is an agent-facing free-form KV bag — the values almost never
mean anything to a human reader, and every property humans actually care
about already has a dedicated sidebar field (status, priority, assignee,
etc.). Rendering the first four keys inline still pushed real signal
down and added visual noise for no benefit, so drop the inline strip
entirely.

Replace the section with a small `{ }` Metadata button at the bottom of
the sidebar that opens a Dialog showing the formatted JSON. The button
hides itself when the bag is empty, so the common case stays completely
quiet. Removes the prior collapse threshold (and its `Show N more` /
`Show less` strings) since there is nothing to collapse anymore.

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

---------

Co-authored-by: multica-agent <github@multica.ai>
2026-05-21 17:18:57 +08:00
LinYushen
e288eff2c5 feat: server auto-generates PAT for cloud runtime bootstrap (#3002)
When bootstrap is enabled and no PAT is available from the request
header or Authorization bearer token, the server now generates a new
PAT automatically and forwards it to the cloud service.

This removes the need for the frontend to pass X-User-PAT — the
server handles it entirely.
2026-05-21 17:07:44 +08:00
YOMXXX
29c2a5d18f fix(daemon): reclaim stale dispatched claims (MUL-2485) (#2872)
* fix(daemon): reclaim stale dispatched claims

* fix(daemon): widen stale claim reclaim window
2026-05-21 17:06:55 +08:00
Tom Qiao
81e8aa5812 test(core): add unit tests for reserved-slugs (#2985)
Co-authored-by: Tom Qiao <tomqiaozc@users.noreply.github.com>
Co-authored-by: Claude Opus 4 <noreply@anthropic.com>
2026-05-21 16:54:45 +08:00
Bohan Jiang
0c767c0052 feat(issues): per-issue metadata KV (MUL-2017) (#2845)
* feat(issues): per-issue metadata KV (MUL-2017)

Adds a small JSONB KV map to every issue for agent pipeline state (attempts,
PR number, pipeline status, ...). Keys match a narrow regex, values are
primitives (string / number / bool), capped at 50 keys per issue and 8KB
per blob. Defense-in-depth via two CHECK constraints (object shape + size).

All mutations are single-key atomic (jsonb_set / `- key`). `UpdateIssue`
intentionally does NOT touch metadata: a whole-blob overwrite would race
with concurrent agent writes.

  GET    /api/issues/:id/metadata
  PUT    /api/issues/:id/metadata/:key   body: { "value": <primitive> }
  DELETE /api/issues/:id/metadata/:key

Containment filter on list: GET /api/issues?metadata=<json-object> uses
PG `@>` against a `jsonb_path_ops` GIN index. Mirrored across ListIssues,
CountIssues, ListOpenIssues, and the hand-rolled ListGroupedIssues SQL so
CLI/API and UI grouped views stay consistent.

CLI: multica issue metadata {list,get,set,delete}
  multica issue list --metadata key=value (repeatable, AND)
  set has --type to override the default value-sniffing
Co-authored-by: multica-agent <github@multica.ai>

* fix(issues): metadata test bugs + wire realtime + read-only display (MUL-2017)

- Fix two failing handler tests blocking backend CI:
  - reset decode target after delete so map merge does not mask removal
  - url.PathEscape the key segment so spaces no longer panic NewRequest
- Wire issue_metadata:changed end to end so the detail / list / my-issues
  caches stay in sync with set/delete events (other tabs, CLI writes).
- Add a read-only Metadata strip to the issue detail sidebar; hidden when
  the issue has no keys so it stays quiet in the common case.

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

* feat(runtime): teach agents to read/write issue metadata (MUL-2017)

Add an `## Issue Metadata` section to the runtime brief plus a
`metadata list` step on entry and a `metadata set`/`delete` step on
exit. Section only emits when the task carries an issue id (comment- or
assignment-triggered); chat / quick-create / run-only autopilot stay
clean so they don't fire failing CLI calls.

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

* fix(issues): bump metadata migration to 105 and drop attempts as example (MUL-2017)

main is now at 104_drop_runtime_timezone; the migrator picks
LatestVersion() by sorted filename, so a slot before the tail would
let DBs that have already run 099–104 think they're up-to-date while
the issue.metadata column is missing — runtime would then fail with
column does not exist. Renumbering to 105 puts the migration at the
tail and forces it to run.

Also drop attempts as a positive example across docs/code comments and
test fixtures — the runtime instruction prompt already lists it under
"What NOT to pin" (runtime bookkeeping). Replace with pr_number, which
is in the recommended-keys set, so docs/tests speak the same language
as the prompt.

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

---------

Co-authored-by: multica-agent <github@multica.ai>
2026-05-21 16:35:45 +08:00
Multica Eve
66c0464140 fix: simplify cloud runtime create form (#3000)
Co-authored-by: Eve <eve@multica-ai.local>
Co-authored-by: multica-agent <github@multica.ai>
2026-05-21 16:34:11 +08:00
Bohan Jiang
9a5d8a52f3 fix(timezone): harden hourly-rollup rollout against straight-through migrate MUL-2488 (#2998)
* fix(timezone): harden hourly-rollup rollout against straight-through migrate

MUL-2488

PR #2968 introduced the new task_usage_hourly rollup but assumed operators
would stop migrate between 102 and 103 to run the one-shot
cmd/backfill_task_usage_hourly. Two pieces made that unsafe in practice:

1. The Dockerfile only shipped server / multica / migrate, so a deployed
   container has no backfill binary to run between phases.
2. cmd/migrate has no per-version stop, and entrypoint.sh runs `migrate up`
   to the latest version, so 103 silently drops the legacy daily rollups
   even when nobody ran the backfill — leaving usage dashboards at zero
   despite source data being intact in task_usage.

Changes:

- Build cmd/backfill_task_usage_hourly into the runtime image alongside
  the other binaries so operators can `docker exec` the backfill instead
  of needing a source checkout.
- Add a fail-closed plpgsql guard at the top of migration 103 that
  aborts the migration when task_usage has rows but task_usage_hourly is
  empty. Fresh databases (no task_usage rows) are exempt because the new
  triggers from 102 will populate the hourly table on the first event.

Already-applied databases are unaffected — schema_migrations tracks by
version only, so 103 is not re-run.

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

* fix(timezone): use watermark coverage for hourly-rollup guard

The previous check only required `task_usage_hourly` to be non-empty,
which an interrupted backfill or a manual `rollup_task_usage_hourly_window`
call both satisfy. The completion signal we actually trust is
`task_usage_hourly_rollup_state.watermark_at` — backfill only stamps it
to `now() - 5 min` after every monthly slice succeeded, and the cron
worker only advances it on a real tick. Default after migration 101 is
`1970-01-01`, so an unrun or partial backfill is trivially detected.

Also corrects the comment about fresh-install behavior: the triggers in
102 only enqueue dirty keys for agent_task_queue / issue / task_usage
DELETE — they do not write hourly rows. INSERT/UPDATE flows through the
`updated_at` watermark window of `rollup_task_usage_hourly()`, which
only runs once the operator registers it as a pg_cron job.

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

---------

Co-authored-by: multica-agent <github@multica.ai>
2026-05-21 16:26:42 +08:00
Multica Eve
51b3c5291f feat: add env-gated cloud runtime launcher (MUL-2453) (#2995)
* feat: add env-gated cloud runtime launcher

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

* fix: address cloud runtime frontend nits

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

---------

Co-authored-by: Eve <eve@multica-ai.local>
Co-authored-by: multica-agent <github@multica.ai>
2026-05-21 15:41:31 +08:00
Bohan Jiang
51c6e90363 docs: finish /projects link fix + tidy AWS_ENDPOINT_URL description (#2996)
Followup to #2979. One missed /issues → /projects link in agents.mdx
plus two AWS_ENDPOINT_URL row nits (URL/URLs repetition and trailing
period) in SELF_HOSTING_ADVANCED.md and the Chinese self-hosting page.

MUL-2498

Co-authored-by: multica-agent <github@multica.ai>
2026-05-21 15:35:39 +08:00
YYClaw
614dfae884 MUL-2488 feat(timezone): Scheduling / Viewing two-layer timezone architecture (#2968)
* docs(timezone): add scheduling/viewing timezone architecture RFC

* feat(db): replace daily rollups with task_usage_hourly, add user.timezone

Migrations 100-104: add "user".timezone (Viewing tz), build the UTC
hourly task_usage_hourly rollup with its pipeline, drop the legacy
task_usage_daily / task_usage_dashboard_daily pipelines, and drop the
agent_runtime.timezone column. Report queries now slice day boundaries
at read time by the caller-supplied @tz instead of materialising in a
fixed tz. Regenerate sqlc.

* feat(server): add task_usage_hourly backfill command

Replace the two legacy backfill commands (daily / dashboard_daily) with
a single backfill_task_usage_hourly that loads historical task_usage
into the new UTC hourly rollup, sliced per workspace.

* refactor(server): resolve viewing timezone in report handlers

Report handlers resolve the Viewing tz per request (?tz query param,
then user.timezone, then UTC) and pass it to the hourly-rollup queries.
Drop the UseDailyRollup feature flags and the old raw-scan/daily-rollup
dual paths, remove the /api/usage endpoints, and stop the daemon from
reporting and the runtime handler from accepting host timezone.

* refactor(core): switch report queries to viewing timezone

API client and dashboard/runtime queries send ?tz with each report
request, the user schema/types carry the new timezone field, and the
runtime timezone field/mutation is removed.

* feat(views): add viewing timezone preference and UI

Add the useViewingTimezone hook and a Timezone setting in Preferences;
report charts and the dashboard week boundary follow the viewer tz.
Remove the runtime detail timezone editor and its locale strings.

* fix(test): update fixtures and stabilize tests for timezone refactor

The timezone architecture refactor changed several types without
updating dependent test code:

- RuntimeDevice no longer has a timezone field — drop it from the
  create-agent-dialog runtime fixture.
- User now requires a timezone field — add it to the apps/web mockUser
  fixture.
- The PreferencesTab timezone tests asserted on the async save handler
  (PATCH then store update) with a bare expect, racing the mutation's
  settle callback, and timed out querying the Select's ~600-option IANA
  list on a loaded CI runner. Wrap the assertions in waitFor and extend
  the timeout for those three tests.

* docs(timezone): document self-host migration order and trigger invariant

Add a SELF-HOST UPGRADE ORDER runbook to the backfill command's package
comment: applying migrations 100-104 in a single migrate-up drops the
legacy daily rollups before the hourly backfill runs, leaving dashboards
empty until cron catches up.

Add an INVARIANT comment on trg_atq_dirty_hourly noting that agent_id
must be added to the trigger's OF list if it ever becomes mutable,
otherwise dirty buckets for the old agent_id are silently missed.

* style(runtimes): drop trailing blank line in runtime-detail
2026-05-21 15:33:47 +08:00
Tom Qiao
d0666138ec docs: fix broken anchor links and truncated env-var description (#2979)
Three docs issues spotted while reading:
- agents.mdx and agents.zh.mdx: [project](/issues) -> [project](/projects)
- cloud-quickstart.mdx: troubleshooting anchor #daemon-cant-reach-the-server
  did not exist; the heading is "Daemon can't connect to the server"
- SELF_HOSTING_ADVANCED.md and getting-started/self-hosting.zh.mdx:
  AWS_ENDPOINT_URL row description was truncated; append " URLs."

Co-authored-by: Tom Qiao <tomqiaozc@users.noreply.github.com>
2026-05-21 15:32:58 +08:00
Multica Eve
41cb91abd9 feat: add cloud runtime fleet proxy API (MUL-2453) (#2986)
* feat: add cloud runtime fleet proxy API

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

* test: cover cloud runtime handler nits

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

---------

Co-authored-by: Eve <eve@multica-ai.local>
Co-authored-by: multica-agent <github@multica.ai>
2026-05-21 15:06:10 +08:00
Bohan Jiang
1c892aa3f9 fix(projects): default project view to compact (#2975)
The compact view was the original list layout and is what users expect
on this page; the post-#2840 default of comfortable changed long-standing
behavior. Reset the unpersisted default (and the cross-workspace fallback
in `merge`) back to compact. Updates the view-store tests accordingly.

MUL-2464

Co-authored-by: multica-agent <github@multica.ai>
2026-05-21 14:07:40 +08:00
Anderson Shindy Oki
65feb890b8 feat: Add project list responsive compact and comfortable views (MUL-2464) (#2840)
* feat: Add project screen compact and comfortable views

* wip

* i18n

* refactor and add search

* refactor
2026-05-21 13:56:11 +08:00
兰之
7e55813460 fix(ui): show tooltip when create-issue button is disabled due to empty title (#2943)
Co-Authored-By: Xiaomi MiMo V2.5 Pro
2026-05-21 13:43:31 +08:00
Bohan Jiang
7f9e4e829d feat(comments): thread-internal --tail pagination + reply cursor (MUL-2421) (#2846)
* feat(comments): thread-internal pagination via --tail + reply cursor (MUL-2421)

Long threads inside a single issue still forced agents to read every reply
once they used --thread, even after MUL-2387 fixed cross-thread noise. This
adds reply-level paging so a 200-reply thread can be navigated tail-first
without dragging the whole conversation into prompt context.

- New SQL query ListThreadCommentsForIssuePaged: same recursive root walk
  as the legacy thread query, but caps reply count and supports an
  (created_at, id) composite cursor. Root is unconditional — even tail=0
  emits it so the reader keeps the "what is this thread about" context.
- Handler ListComments: parses `tail` (non-negative, ThreadTailSet flag
  preserves the tail=0 intent), threads it through to the paged query,
  and re-uses X-Multica-Next-Before / X-Multica-Next-Before-Id for the
  reply cursor. Cursor's meaning is now context-dependent: thread cursor
  under --recent, reply cursor under --thread + --tail.
- CLI: new --tail flag (only valid with --thread; mutually exclusive
  with --recent), reply-cursor semantics for --before / --before-id when
  paired with --thread + --tail, stderr label flips to "Next reply cursor"
  so an operator copy-pasting the cursor knows which scope it scrolls.
- Tests cover the new contract: tail=N keeps newest N + root, tail=0 is
  root-only, anchor on a nested reply still walks up, reply cursor
  scrolls older replies page-by-page, since combined with tail filters
  after the cut, and the negative-flag-combination matrix.

Out of scope: prompt template update to hint at `--thread <id> --tail 30`
on long threads — separate follow-up per the issue.

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

* fix(comments): only emit reply cursor when older reply exists (MUL-2421)

The thread-tail path emitted `X-Multica-Next-Before` whenever the page
filled to exactly the requested reply count, even when there was nothing
older to scroll to. So `--thread <root> --tail 3` on a thread with
exactly 3 replies sent a cursor that, when followed, returned just the
root — a wasted round-trip that surfaced as a phantom "older replies"
affordance in the agent prompt.

Switch to a `reply_limit + 1` probe: ask the SQL for one extra row, trim
the oldest overflow before responding, and only emit the cursor when an
older reply actually existed. The exact-boundary case (replyCount ==
tail with no overflow) now returns no cursor.

Also documents `--thread/--tail/--recent/--before` and the cursor
semantics in CLI_AND_DAEMON.md, which was the second must-fix in the
MUL-2421 review.

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

* fix(comments): suppress reply cursor when --since covers older replies (MUL-2421)

In the thread + tail + since path the server still emitted a reply cursor
whenever there was an older reply on disk, regardless of `since`. If the
oldest retained reply on the page was already `<= since`, every older
reply was guaranteed to be filtered out too, so the next page only ever
returned the root — wasting round-trips until the agent walked the whole
pre-`since` history. Mirror the recent + since suppression: when
`replies[0].CreatedAt <= since`, drop the cursor.

Test covers the exact case from Elon's review: tail=2 overflow, body
keeps a fresher reply, but the cursor target (oldest retained reply) is
already past `since` — header must be empty.

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

* feat(prompt): default comment-trigger reads to --thread --tail 30 (MUL-2421)

Comment-triggered agents previously defaulted the trigger-thread read to
the unbounded `--thread <id> --output json`, which dumps the full thread
into the prompt — exactly the kind of context bloat MUL-2387 fixed at the
cross-thread layer but never bounded inside a single thread.

Use the new `--tail` flag landed earlier in this PR (server + CLI) as the
default for both the per-turn prompt and the runtime-config Workflow:

- `--thread <trigger-id> --tail 30 --output json` is the new default.
  Root is always included so "what is this about" context survives.
- If 30 replies aren't enough, the prompt now spells out the reply
  cursor: re-feed the stderr `Next reply cursor: --before <ts>
  --before-id <reply-id>` pair back to walk older replies.
- `--recent 20` stays as the cross-thread background fallback, with an
  explicit callout that the same `--before` / `--before-id` flags walk
  *threads* (not replies) in that mode.
- Available Commands core line now surfaces `--tail N` and both stderr
  cursor labels so non-workflow callers also discover the flag.
- `--since` callouts reflect the post-MUL-2421 combinable mode names
  (`--thread --tail` / `--recent`).

Tests (`prompt_test.go`, `execenv_test.go`) pin the new defaults and add
a regression guard against the unbounded `--thread` recipe sneaking back
in.

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

---------

Co-authored-by: multica-agent <github@multica.ai>
2026-05-21 13:43:15 +08:00
Bohan Jiang
8a135d2982 fix(ws): truncate unparseable frame payload in client warn log (#2974)
The post-#2946 onmessage guard logs the raw event.data alongside the
warning. A malformed or rogue server can stream arbitrarily large
garbage and bloat the renderer / desktop main-process log buffers, so
cap the logged payload to the first 200 chars and append a
"(truncated, N chars total)" suffix when truncation occurs.

MUL-2490

Co-authored-by: multica-agent <github@multica.ai>
2026-05-21 13:37:42 +08:00
YOMXXX
83e90c9530 fix(ws): log auth frame write failures (#2946) 2026-05-21 13:33:12 +08:00
Bohan Jiang
ef6a944063 fix(cli): accept slug + short UUID prefix in workspace get/update/member (#2972)
* fix(cli): accept slug + short UUID prefix in workspace get/update/member (MUL-2385)

`workspace list` shows the 8-char short UUID prefix, name, and slug by
default; `workspace get`/`update`/`member list` only accepted full UUIDs.
That broke the natural list -> get flow: every value the user could copy
from list output was rejected. They had to either rerun list with
`--full-id` or parse the JSON output -- both implementation-detail level
operations.

Extend `resolveWorkspaceByIDOrSlug` with a short UUID prefix fallback
(>=4 hex chars, ambiguous matches return all candidates), introduce
`resolveWorkspaceRef`/`resolveWorkspaceArg` helpers that fetch the
caller's accessible workspaces and resolve UUID/slug/prefix in one call,
and wire them into get/update/member list (switch already used the same
list-then-resolve pattern). Full UUIDs short-circuit the extra
`/api/workspaces` round trip; access control remains on the downstream
endpoint.

Also add a one-line tip after `workspace list` table output pointing
users at get/update/switch with the same identifier columns, and
broaden the command Use strings to `<id|slug|prefix>` so help reflects
the new behavior.

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

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

* chore(cli): include prefix hint in workspace list footer

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

---------

Co-authored-by: multica-agent <github@multica.ai>
2026-05-21 13:08:44 +08:00
YOMXXX
ed2957ddf8 fix(claude): record result model usage (#2899) 2026-05-21 13:00:12 +08:00
iYuan
2f1f90c11a fix(agent): retry codex semantic inactivity fresh (#2593) 2026-05-20 20:03:39 +08:00
Bohan Jiang
688dcb017c fix(agents): drop confusing "default" badge from model picker (MUL-2477) (#2938)
The model dropdown already exposes a "Default (provider)" option meaning
"follow the CLI's current selection". Tagging the runtime's preferred
model with a small "default" chip created two competing notions of
"default" in the same UI and confused users. Remove the chip from both
the create-agent ModelDropdown and the inspector ModelPicker; keep the
underlying RuntimeModel.default flag intact since thinking-prop-row
still uses it as a fallback heuristic.

Co-authored-by: multica-agent <github@multica.ai>
2026-05-20 18:07:57 +08:00
Multica Eve
cf000d1e93 docs(changelog): add 2026-05-20 release notes (#2932)
Co-authored-by: Eve <eve@multica-ai.local>
Co-authored-by: multica-agent <github@multica.ai>
2026-05-20 17:28:08 +08:00
Naiyuan Qing
317bca40c1 feat(squads): show skeleton on squad detail initial load (#2930)
Replaces the plain "Loading..." text fallback in SquadDetailPage with a
skeleton that mirrors the loaded page's two-column layout (left inspector
+ right tabs panel), matching the SquadsListSkeleton work shipped in #2890.

Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>
2026-05-20 17:21:52 +08:00
Bohan Jiang
8d4f4caf4a MUL-2338 fix(comments): allow agent self-mention to enqueue cross-issue handoff (#2928)
* fix(comments): allow agent self-mention to enqueue cross-issue handoff

The @mention path in CreateComment unconditionally skipped any
self-mention. That dropped the child→parent handoff between issues
assigned to the same agent: the child run posted `@J` on the parent
issue, the guard tripped, and the parent's J was never woken — the chain
silently broke.

Drop the self-trigger `continue` in the agent mention branch. Runtime
ready / private-agent gate / HasPendingTaskForIssueAndAgent dedup all
remain, so a same-issue self-mention while a queued or dispatched task
exists is still deduped; a running task no longer pre-empts a new
follow-up (the existing queue coalescing handles that).

Three regression tests:
  - cross-issue self-mention enqueues a task on the target issue
  - same-issue self-mention while running queues a follow-up
  - same-issue self-mention with a pre-existing queued/dispatched task
    is deduped

MUL-2338

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

* test(handler): assign per-workspace issue number in self-mention fixture

The fixture inserts two issues in the same test workspace; without an
explicit number both default to 0 and the second insert violates
uq_issue_workspace_number, taking the backend CI job down on PR #2928.

Mirror the workspace-counter advancement pattern from
issue_scheduled_test.go so each fixture issue gets a unique number.

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

---------

Co-authored-by: multica-agent <github@multica.ai>
2026-05-20 17:18:41 +08:00
YOMXXX
34f16e2c7a fix(opencode): deny interactive questions in daemon mode (#2878)
* fix(opencode): deny interactive questions in daemon mode

* fix(opencode): avoid permission env ordering bypass
2026-05-20 17:17:31 +08:00
Naiyuan Qing
85e363370e Revert "feat(issues): Working filter + agent-working badge on board (MUL-2452…" (#2927)
This reverts commit dee5c7cf50.
2026-05-20 16:47:41 +08:00
Naiyuan Qing
b040165f4e feat(squads): skeleton loader + AlertDialog archive confirm (MUL-2437) (#2890)
* feat(squads): skeleton loader + AlertDialog archive confirm (MUL-2437)

- Replace `Loading...` text on the squads list with a Skeleton placeholder
  matching the SquadCard shape (avatar + title + subtitle), aligning with
  the Agents / Dashboard pattern.
- Replace the native `confirm()` on the squad detail Archive button with
  the project's AlertDialog (destructive variant, pending-disabled, i18n
  copy interpolating the squad name).

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

* fix(squads): drop misleading restore copy from archive confirm (MUL-2437)

Archive is irreversible — there is no unarchive command (see
apps/docs/content/docs/squads.mdx:113). Aligns dialog copy with
docs: tells the user the action can't be undone and to create a
new squad if they need the routing back.

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

---------

Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>
2026-05-20 16:43:58 +08:00
Naiyuan Qing
dee5c7cf50 feat(issues): Working filter + agent-working badge on board (MUL-2452) (#2924)
* feat(issues): surface "agent working" on board + add Working filter (MUL-2452)

Adds a brand-color "agent working" badge to board cards / list rows so
users can see at a glance which issues have an active agent task, plus a
new "Working" toggle on the `/issues` and `/my-issues` headers (next to
the existing scope segmented control) that filters to those issues. The
toggle shows an avatar stack of the agents currently active on the
current surface + scope. Pure frontend: re-shapes the existing
workspace-wide `agentTaskSnapshot` cache via two new selectors
(`activeTasksByIssueOptions` / `workingIssueIdsOptions`), no new SQL,
endpoint, or DB field; WS `task:*` events already invalidate the
snapshot so the badge / filter update in realtime.

Project detail page keeps the per-card badge but intentionally omits the
header toggle (`showWorkingToggle={false}`) to leave the project
surface's filter dimensions unchanged.

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

* fix(issues): working filter column header reflects filtered count (MUL-2452)

Assignee-grouped board column headers kept showing the unfiltered cache
total when Working was on, because `PaginatedAssigneeBoardColumn` passed
`useLoadMoreByAssigneeGroup`'s cache-derived `total` straight to
`BoardColumn`. The hook still needs the cache total for hasMore, but the
displayed count must follow the visible-after-filter set.

Split the two: when Working is active the column header now uses
`group.totalCount` (set by applyWorkingFilterToGroups) for the assignee
path, and `issueIds.length` for the status path. Load-more keeps reading
from cache so paginated columns still see the full server total.

Regression tests cover applyWorkingFilterToGroups (total rewrite +
empty-group preservation), filterIssues workingOnly combinations, and an
end-to-end assertion via IssuesPage that proves the column header equals
the filtered count, not the cached value.

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

---------

Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>
2026-05-20 16:35:58 +08:00
Bohan Jiang
aeb284cbeb feat(runtime): teach agents the parent/sub-issue protocol (MUL-2338) (#2918)
* feat(runtime): teach agents the parent/sub-issue protocol (MUL-2338)

Adds a Parent / Sub-issue Protocol section to the runtime brief built by
`buildMetaSkillContent`, emitted whenever the agent is running on a real
Multica issue (assignment- or comment-triggered). Two behaviors are now
documented for every issue-bound agent:

- A. When wrapping up a child issue, post the final result and switch to
  `in_review` on this issue first, then post a single top-level comment
  on the parent. Mention the parent assignee only when it is another
  agent on a still-open parent — never self-mention, never @ member /
  squad, never re-trigger a `done` / `cancelled` parent.
- B. When creating sub-issues, choose `--status backlog` for sub-issues
  that must wait and `--status todo` for the one to start immediately;
  promote with `multica issue status <id> todo` when its turn comes.

The signal is explicitly framed as best-effort — no server-side state
sync, no claim of a guaranteed handshake. The section is skipped for
chat, quick-create, and run-only autopilot runs, which have no
parent/child semantics.

Tests in runtime_config_test.go assert that the section is present in
both issue workflows, absent in the three non-issue modes, and that the
wording does not introduce a non-existent `multica issue list --parent`
command or promise a reliable handshake.

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

* fix(runtime): split Step A of parent/sub-issue protocol by trigger type (MUL-2338)

Comment-triggered runs were inheriting an unconditional
`multica issue status <this-issue-id> in_review` from Step A, which
conflicts with the comment-triggered workflow rule "Do NOT change the
issue status unless the comment explicitly asks for it" (Elon's blocking
review on PR #2918). Step A now branches on trigger type:

- Assignment-triggered: keep "post final results + flip in_review".
- Comment-triggered: complete the reply per the existing workflow rule,
  only flip status when the triggering comment asked for it, and gate
  the parent-notification steps on actually closing out child work.

Tests lock the boundary: comment-triggered briefs must not contain the
unconditional in_review command, must echo the existing status
guardrail inside Step A, and must spell out the "closing out" gate.
Assignment-triggered briefs still carry the unconditional flip.

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

* fix(runtime): simplify parent/sub-issue mention rule to always @ parent assignee (MUL-2338)

Per Bohan's directive on PR #2918: the per-case mention table (same agent /
member / squad / closed parent) is overkill prompt complexity. Replace it
with a single rule: always @mention the parent's assignee using the URL
that matches assignee_type. The platform's existing run dedup handles
re-triggers, and a single rule is easier for agents to follow predictably.

Preserves the existing comment-triggered boundary (Step A still does NOT
add an unconditional in_review flip on comment-triggered runs).

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

* refactor(runtime): compress parent/sub-issue protocol to 3-rule convention (MUL-2338)

Drop the spec-flavored A/B sub-headings and per-case mention table; keep
three numbered rules (close out child, notify parent, pick backlog vs
todo) plus a one-line best-effort preamble. The comment-triggered
branch still re-asserts the "do not change status unless asked"
guardrail and gates parent notification on actually closing out child
work; the assignment-triggered branch still flips to `in_review`.

Section is now 7 lines instead of 29. A new TestParentSubIssueProtocolIsCompact
guards the ≤10-line ceiling so this stays a convention, not a spec.

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

* fix(runtime): make sub-issue creation rule unconditional in parent/sub-issue protocol (MUL-2338)

Elon's review on PR #2918: the preamble previously gated all three
rules on the current issue having `parent_issue_id`, but rule 3
(creating sub-issues) needs to reach top-level parents that have no
parent themselves — that is exactly where the `todo` vs `backlog`
decision matters most. Move the gate from the preamble onto rules 1
and 2 per-rule; rule 3 now applies to any issue-bound run. Section
stays at 7 newlines (≤10).

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

* refactor(runtime): unify parent/sub-issue protocol as mechanism description (MUL-2338)

Drop the if/else split between assignment- and comment-triggered runs in
the Parent / Sub-issue Protocol section: both runs now read the same
two-rule description of how the parent/child mechanism works. The
comment-triggered workflow rule "Do NOT change the issue status unless
the comment explicitly asks for it" naturally short-circuits the parent
notification (no status flip → not closing out the child → skip), so the
protocol no longer needs to branch on TriggerCommentID.

Tests collapse the two trigger-specific cases into one parameterized
test, and the assignment vs comment status-flip invariants are now
anchored on the real workflow command (with substituted issue id)
instead of the protocol's removed `<this-issue-id>` placeholder.

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

---------

Co-authored-by: multica-agent <github@multica.ai>
2026-05-20 16:20:33 +08:00
Angular
1f978bf1ec feat(autopilot): link created issues to projects (#2908)
* feat(autopilot): link created issues to projects

* test(autopilot): cover project flag
2026-05-20 15:37:23 +08:00
Bohan Jiang
ffc0c5ab2e docs(agent-inspector): sync thinking_level comments with no-override semantics (MUL-2339) (#2923)
Follow-up to #2919 review nits — comments still described the empty
thinking_level as "use runtime default" and claimed ThinkingPicker callers
guaranteed non-empty levels. Both were stale after the semantics changed:

- packages/core/types/agent.ts: clarify that "" clears the override and
  the local CLI config / built-in default decides at runtime.
- thinking-picker.tsx: document that the stale-orphan clear path in
  ThinkingPropRow mounts the picker with an empty levels list plus a
  persisted value, so callers do not guarantee non-empty levels.

Co-authored-by: multica-agent <github@multica.ai>
2026-05-20 15:34:27 +08:00
Bohan Jiang
b7082a01f1 fix(issues): retry button targets the row's agent (MUL-2457) (#2921)
* fix(issues): retry button targets the row's agent, not the assignee (MUL-2457)

The execution log retry button used to re-fire the issue's current
assignee instead of the agent that actually ran the clicked row. After
a reassignment, or for squad workers / @-mention agents, the rerun
landed on the wrong agent.

POST /api/issues/{id}/rerun now accepts an optional task_id: when set,
the rerun targets that task's agent (and reuses its leader/worker
role). An empty body keeps the assignee-driven CLI/API contract.

The execution-log retry button passes task.id, so per-row retry always
fires the correct agent. enqueueMentionTask gained a forceFreshSession
parameter so the new mention-path rerun keeps the same fresh-session
contract as the assignee path.

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

* fix(issues): inherit trigger provenance + fix cross-issue test (MUL-2457)

Address review feedback on PR #2921:

1. RerunIssue now inherits TriggerCommentID from the source task when
   sourceTaskID is valid. Without this, a per-row rerun of a comment-
   or mention-triggered task degrades into a generic issue run because
   the daemon's buildCommentPrompt path keys on TriggerCommentID. The
   inherited summary is rebuilt naturally inside the enqueue helpers
   (buildCommentTriggerSummary derives it from the comment ID).
2. The new cross-issue rejection test inserted a second issue without
   `number`, hitting uq_issue_workspace_number on a same-workspace
   collision with the fixture's issue. Both inserts now claim the next
   available per-workspace number (MAX(number)+1) — matching the
   pattern used by notification_listeners_test.

Added TestRerunIssueInheritsTriggerCommentFromSourceTask to lock the
trigger provenance contract.

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

---------

Co-authored-by: multica-agent <github@multica.ai>
2026-05-20 15:30:03 +08:00
Angular
314e91fa6d fix(chat): guard optimistic task message ids (#2901) 2026-05-20 15:18:42 +08:00
Bohan Jiang
68270e238e MUL-2339: polish(agent-inspector): optimistic updates + picker layout + thinking-default semantics (#2919)
* polish(agent-inspector): optimistic updates + picker layout + thinking-default semantics

Round of cleanup on the agent inspector pickers after using them end-to-end:

1. **Optimistic updates** (`agent-detail-page.tsx`)
   The `handleUpdate` callback that backs every inspector picker
   (thinking / model / visibility / concurrency / runtime / name /
   description / avatar) was strictly sequential:
   `await api.updateAgent → invalidateQueries → toast.success`. Each pick
   waited 0.5-2s for the network round trip before the trigger chip
   updated, which read as visible UI lag.
   Snapshot the cached agent list, patch the matching agent
   synchronously via `setQueryData`, then run the network request in
   the background. On error roll back to the snapshot before the toast
   surfaces the cause. All inspector pickers now respond instantly.

2. **Block-in-inline fix in Model + Thinking pickers**
   `PickerItem` wraps its children in a flex `<span>`. The picker
   bodies had `<div>` children, which is block-in-inline (invalid
   HTML5) and triggers a browser layout quirk that off-aligns
   descendants — model IDs floated to the center under their labels
   in ModelPicker, descriptions indented unevenly under levels in
   ThinkingPicker. Replace the inner `<div>`s with `<span block
   text-left>` so the layout is deterministic across rows.

3. **Visual polish in Thinking picker**
   Label was `font-medium` at the parent's default `text-sm` (14px),
   chunky next to the 10px description. Drop to `text-[13px]`, bump
   description to `text-[11px] leading-snug` with `mt-0.5` so the
   contrast between rows feels less jarring.

4. **Match Model picker's row typography to Thinking's**
   Same `text-[13px]` for label + `text-[10px] mt-0.5` for the model
   ID. Both pickers now read as the same component family.

5. **"Default" semantics: follow CLI config, not model factory default**
   The chip displayed "Default" / "default" badge when no
   `thinking_level` was set, alongside a `[default]` chip on the
   model's factory-advertised default option in the menu. That was
   misleading: when Multica omits `--effort` (because picker is
   unset), it's the user's *local CLI config* (claude/codex) that
   decides the reasoning level — not the model's factory default.
   Showing "medium [default]" while the user has xhigh in their CLI
   config lies about what actually fires at the API.
   - Trigger label: "Default" → "Follow CLI config" (zh: "跟随 CLI 配置")
   - Footer clear button: "Use model default" → "Follow CLI config"
   - Footer tooltip: explicitly mentions claude/codex CLI config
   - Inline `[default]` badge on the factory-default option: removed
   - `defaultLevel` prop chain (picker + prop-row + test): cleaned up
     as now-dead code

6. **Stop hiding the Thinking row while discovery loads**
   `if (levels.length === 0 && !value) return null` hid the row
   while the runtime-models query was still in flight, which
   subscribed-then-unsubscribed from useQuery in such a way that
   the discovery only fired when the user manually opened the Model
   picker. Gate the early return on `!isLoading && !isFetching` so
   ThinkingPropRow stays mounted (and thus its useQuery keeps
   subscribed) until discovery returns; row appears as soon as
   data arrives, no Model-picker tap required.

7. **Drop the inline tooltip on Thinking picker items**
   The same description was rendered both inline under the label
   (always visible) and as a hover tooltip (overlapping the next
   row). The hover bubble was redundant — removed.

Tests
- `pnpm --filter @multica/views test thinking-picker` → 7/7 pass after
  renaming the "Default" assertion + clearing the unused defaultLevel
  test prop.
- `pnpm --filter @multica/views typecheck` clean.

* fix(test): align thinking-prop-row tests with renamed copy + loading-aware row gate

CI surfaced 3 broken assertions in `thinking-prop-row.test.tsx` —
all consequences of the polish PR's behaviour changes that the test
file hadn't tracked:

- "hides the row when ... no thinking levels and nothing is persisted"
  The row now stays mounted while runtime-models discovery is in
  flight (so the useQuery subscription actually survives long enough
  to issue the request — fixes the bug where Thinking only appeared
  after manually opening the Model picker). The assertion asserted
  absence only after `initiate` was called, but loading is still in
  progress at that point. Wrap the absence assertion in `waitFor`
  so it waits for the row to disappear after the query settles.

- "clears the orphan value via the picker footer"
  Tooltip copy changed from "Clear and fall back to this model's
  default reasoning level" → "Clear the override and let the local
  CLI config decide the reasoning level". Update the regex.

- "renders the row with \"Default\" when value is empty"
  Trigger label changed from "Default" → "Follow CLI config" to
  reflect that Multica omits --effort and the local CLI config
  decides. Update the assertion + test name.

`pnpm --filter @multica/views test` → 701/701 pass.

* fix(agent-inspector): drop loading-row gate + per-field optimistic rollback (MUL-2339)

Addressing review feedback on #2919:

- ThinkingPropRow no longer keeps the row visible during discovery.
  The previous explanation ("early return null aborts the useQuery
  subscription") was wrong — React doesn't unmount a component that
  returns null, so hooks (and their subscriptions) stay live. The
  loading-aware gate only succeeded in showing an empty "Follow CLI
  config" row that opened to an empty menu before discovery settled.
  Restore the simple `levels empty && !value -> null` behavior; the
  sibling ModelPicker mounts unconditionally and keeps the shared
  runtime-models query active regardless.

- AgentDetailPage.handleUpdate now rolls back only the fields the
  failing PATCH wrote, instead of restoring a whole-list snapshot.
  A whole-list snapshot rollback discards any concurrent successful
  inspector mutation that landed between snapshot and rollback. Per-
  field rollback + a final invalidate converges the cache on server
  truth without clobbering unrelated optimistic writes.

- Sync the now-stale "use model/runtime default" wording in the
  thinking-related JSDoc and type comments: empty thinking_level is a
  "no override" sentinel — the backend omits --effort and the upstream
  CLI config decides — not a Multica-known default level.

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

---------

Co-authored-by: multica-agent <github@multica.ai>
2026-05-20 15:18:34 +08:00
Bohan Jiang
eaf8b14866 fix(installer): post-merge nits from #2881 (MUL-2458) (#2922)
- Capture `brew tap` output and print the same diagnostic tail on
  failure that `brew install` already prints, so #2867-style "no
  signal" reports are gone from both Homebrew failure paths.
- Add a `brew tap` failure regression case to `scripts/install.test.sh`
  and refactor the test runner to share sandbox/curl-stub setup; both
  cases now also assert the diagnostic tail is emitted.
- Move the shell installer test out of the heavy backend job into a
  dedicated `installer` matrix job that runs on `ubuntu-latest` and
  `macos-latest`, since the installer targets macOS/Homebrew and BSD vs
  GNU `tar` / `sed` / `mktemp` differences are the next likely break.
- Surface `MULTICA_INSTALL_DIR`, `MULTICA_BIN_DIR`, and
  `MULTICA_SELFHOST_REF` in `install.sh --help` so `MULTICA_BIN_DIR`
  stops looking like a test-only knob.

Co-authored-by: multica-agent <github@multica.ai>
2026-05-20 15:18:17 +08:00
Jiayuan Zhang
41753d17a2 feat(desktop): pin tab (MUL-2449) (#2914)
* feat(desktop): pin tab — keep parked tabs anchored across navigations (MUL-2449)

Adds tab pinning to the desktop tab bar. Pinned tabs render as icon-only at
the left, suppress the X close button, and intercept any `navigation.push()`
that would change their pathname — those are redirected into a new tab so
the pinned tab stays parked on its original route. Search/hash/back/forward
stay in-tab so pinned filter and drawer state still work.

Implements the FINAL combo from the MUL-2449 RFC §4: right-click menu +
⌘⇧P shortcut (D1 a+c), icon-only visual (D1v i), pathname-change → new tab
with same-path-allowed (D2a/b A), back / refresh allowed (D2c/d A), pinned
auto-cluster left and persist (D3a/b A), pinned can't be X-closed (D3c A),
dedupe respected (D4a A), default Issues tab pinnable (D4b A), drag clamped
to its zone (D4c A), deep link prefers pinned (D4e A).

Store changes:
  - Tab.pinned added; togglePin maintains the "pinned first" invariant by
    inserting at the zone boundary.
  - moveTab clamps cross-zone drags so dnd-kit can't violate the ordering.
  - Persistence bumped v2 → v3 with a defaulting migration (pinned=false).
    Rehydrate sorts pinned-first as a defensive net.

Navigation:
  - tryRouteToPinnedNewTab compares the active tab router's live pathname
    to the target. Same-pathname push (query / hash / sub-router) falls
    through to the router; different pathname → openTab + setActiveTab
    (foreground; respects dedupe).

UI:
  - Tab bar wraps each tab in a shadcn ContextMenu with Pin/Unpin + Close
    (Close disabled for pinned or last-remaining tab).
  - Pinned tabs use a narrower icon-only layout with an accent left border
    and a divider between the pinned and unpinned groups.
  - Global keydown listener registers ⌘⇧P / Ctrl+Shift+P to toggle pin on
    the active tab.

Tests: - tab-store: togglePin ordering, moveTab boundary clamping, v2→v3
    migration.
  - navigation: pinned push → new foreground tab; same-pathname push stays
    in tab; cross-workspace still wins over pin.
Co-authored-by: multica-agent <github@multica.ai>

* test(desktop): cover TabNavigationProvider.push pin interception (MUL-2449)

Add pathname-diff / same-pathname cases for the per-tab navigation
adapter. Existing tests only exercised the root-level
DesktopNavigationProvider, but in-tab AppLink / page clicks flow
through TabNavigationProvider — so a future refactor that drops the
pin check from that provider would silently regress.

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

* refactor(desktop): pin tab — hover button, full title, drop ⌘⇧P (MUL-2449)

Jiayuan's interactive review of PR #2914 surfaced three changes to the
RFC's D1 (entry / visual) decisions:

  1. Drop the ⌘⇧P global shortcut — it added a keybinding for a
     low-frequency action and crowded the shortcut namespace.
  2. Reveal a Pin / Unpin button on tab hover instead of relying on the
     right-click menu as the primary entry; right-click remains as a
     fallback (and for Close).
  3. Pinned tabs keep their full title and width. The only weak visual
     differences vs. unpinned tabs are the accent left border and the
     suppressed X close button.

Removes the global keydown listener (no other doc / handler referenced
it). Adds a hover-only Pin / Unpin span next to the existing close
affordance, both gated by group-hover. Drops the icon-only width /
hidden-title styling for pinned tabs.

Tests: new tab-bar.test.tsx covers Pin / Unpin button rendering, click
handlers (togglePin), the hidden-X invariant on pinned tabs, and the
full-title rendering. 146 passed, typecheck clean.

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

* refactor(desktop): pin tab — drop accent left border, swap leading icon to Pin (MUL-2449)

Jiayuan reported that the accent left border on pinned tabs reads as a
heavy black edge in light mode and looks unrefined. Replace it with a
quieter identifier: pinned tabs swap their route icon for a Pin glyph
in the leading slot (same size, no extra horizontal space). The hidden
X close button stays as the secondary cue. RFC §3 D1v moves from
iii FINAL to iv FINAL; iii is demoted to v2 FINAL → v3 REMOVED.

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

---------

Co-authored-by: Lambda <lambda@multica.ai>
Co-authored-by: multica-agent <github@multica.ai>
2026-05-20 09:14:43 +02:00
Angular
edded77691 fix(installer): fall back when brew install fails (#2881) 2026-05-20 15:14:18 +08:00
Bohan Jiang
9d3b6e2241 feat(agent): inspector picker for thinking_level (MUL-2339) (#2912)
* feat(agent): inspector picker for thinking_level (MUL-2339)

PR1 (#2865) shipped the backend — column, daemon-side discovery,
Claude/Codex injection, API validation — but the agent detail inspector
had no UI to set the value. Users could only configure thinking_level
via custom_env / API. This wires up the picker so it lives next to
Runtime and Model where everything else editable already lives.

Picker is per-(runtime, model): it reuses the same `runtimeModelsOptions`
query the Model picker already runs (60s cache, no extra round-trip)
and reads the active model's `thinking.supported_levels`. When the list
is empty — every provider except Claude/Codex today, or a Claude model
that doesn't expose `--effort` — the entire PropRow is hidden, not just
rendered inert. The picker never gets to invent value/label pairs
itself; they come verbatim from each CLI's own catalog (`Low`,
`Extra high`, …) so the user sees exactly what `claude --effort` /
`/effort` and Codex's TUI show.

The `default_level` from the catalog is badged inside the popover so
the user knows which value `""` (the persisted "use model default"
sentinel) maps to. The clear footer sends `""` explicitly, which the
backend already understands as the tri-state "explicit clear" branch
of UpdateAgent. Invalid combinations (e.g. picking a value not in the
target provider's enum after a runtime swap in the same PATCH) hit
the existing 400 path on the server and surface as a toast via the
inspector's standard `onUpdate` error handler — no extra client-side
guard needed.

Exports `RuntimeModelThinking` and `RuntimeModelThinkingLevel` from
`@multica/core/types` so views consumers can refer to them by name.
i18n keys added in EN and zh-Hans (parity test green).

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

* fix(agent): preserve unknown thinking_level in picker label

Stale persisted values (model swap, CLI catalog shrink) used to render
as 'Default' even though the backend would still ship the orphaned
token. Fall back to the raw value when no entry matches so the user
sees what's actually saved and can clear it.

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

* test(agent): unit tests for thinking-picker label + clear flow

Covers the default-vs-set trigger label, the unknown-token preservation
path added in 3452fae3f, the read-only display, picking and re-picking
into onChange, and the clear footer's empty-string emission.

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

* fix(agent): keep Thinking row visible when value is stale (MUL-2339)

Inspector was hiding the row whenever the active model had no
supported_levels, which also hid persisted orphan tokens (model swap
into a non-thinking runtime, or a CLI catalog that shrank). PR1's
per-model invalid behavior is daemon-side warn/drop, not a synchronous
DB clear, so the frontend has to surface the raw value and let the
user explicit-clear it via the picker footer.

Render the row when levels are empty AND value is empty; otherwise
keep it. Extract ThinkingPropRow into its own file so the row-level
logic is unit-testable.

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

---------

Co-authored-by: multica-agent <github@multica.ai>
2026-05-20 13:47:19 +08:00
Bohan Jiang
2bec2221d2 feat(agent): per-agent thinking_level for claude + codex (MUL-2339) (#2865)
* feat(agent): persist thinking_level per agent (MUL-2339)

Adds a nullable `thinking_level` column to the `agent` table so the
backend can route a runtime-native reasoning/effort token (e.g. Claude's
`xhigh`, Codex's `minimal`) through to the agent CLI on every dispatch.

The column is intentionally TEXT rather than an enum — Claude and Codex
publish overlapping but distinct vocabularies and we want the persisted
value to round-trip exactly through whichever CLI receives it. NULL is
the "use runtime default" sentinel that every downstream consumer reads
as "do not inject --effort / reasoning_effort".

This commit is just the storage layer (migration + sqlc); subsequent
commits wire it through the API, daemon, and agent backends.

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

* feat(agent-backend): inject reasoning effort for claude + codex (MUL-2339)

Extends ExecOptions with a runtime-native ThinkingLevel string and wires
it into the Claude and Codex backends. Discovery is driven by the local
CLI so the daemon advertises whatever the host install supports rather
than a hand-maintained list that goes stale.

Per Elon's PR1 review:
- Claude: parses `claude --help` to learn the `--effort` superset and
  projects through a per-model allow-list (xhigh is Opus-only; max is
  session-only on the smaller models). Falls back to a conservative
  static list when the binary is missing or help drift hides the line.
- Codex: drives `codex debug models --output json` so per-model
  reasoning subsets and the documented default come directly from the
  CLI. The older config-error probe trick is gone — the JSON path is
  stable and doesn't pollute stderr with an intentional misconfig.
- Cache key includes (provider, executablePath, cliVersion) so a CLI
  upgrade invalidates entries that referenced the older help / catalog.

Per Trump's PR1 constraint, all three Codex injection points
(thread/start.config, thread/resume.config, turn/start.effort) flow
through one helper (`applyCodexReasoningEffort`) so they cannot drift
independently. The shared `codexReasoningCases` fixture in
`thinking_test.go` asserts the same value→{shape, key} contract at
each site for every level the runtimes know about.

Claude's `--effort` is also added to `claudeBlockedArgs` so a user
custom_args entry can't silently outvote the daemon-injected value.

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

* feat(api): wire thinking_level through API + daemon contract (MUL-2339)

End-to-end plumbing for the per-agent reasoning/effort setting:

- AgentResponse / TaskAgentData now carry `thinking_level`; the daemon's
  claim response includes it and the daemon's executor passes it through
  to agent.ExecOptions, where the Claude and Codex backends already know
  what to do with it.
- ModelEntry on the runtime-models wire format gains a `thinking` block
  carrying `supported_levels` + `default_level` per model so the UI can
  render a runtime-aware picker without the server having to know about
  the local CLI install. `handleModelList` projects the agent-package
  catalog (including the new Thinking field) into the wire shape.
- CreateAgent / UpdateAgent gate the field with a synchronous provider
  enum check (claude / codex only today). UpdateAgent is tri-state:
  field omitted = no change, "" = explicit clear (new
  `ClearAgentThinkingLevel` query, mirrors the existing mcp_config null
  pattern), non-empty = validate then set.

Per Trump's PR1 review, the API NEVER auto-clears on a runtime/model
swap and ALWAYS returns 400 on an unknown literal value — same shape
across CreateAgent, UpdateAgent, and combined patches that move
runtime + level in one request. Per-model combination failures (e.g.
`xhigh` against a model that only supports up to `high`) surface as a
daemon-side task error, not a silent server-side rewrite.

TS types follow the same shape: `Agent.thinking_level`,
`CreateAgentRequest`/`UpdateAgentRequest` add the field, `RuntimeModel`
grows a `thinking` block. Older backends omit the field, which the
front-end treats as "no picker for this model" — installed desktop
builds keep working.

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

* fix(agent): correct codex debug models argv + pin via runner test (MUL-2339)

`codex debug models --output json` is rejected by codex-cli 0.131.0 —
the subcommand emits JSON on stdout by default and has no `--output`
flag. Drop the flag and add `--bundled` to skip the network refresh
discovery doesn't need. Move the argv to a package-level var and add
a test that runs a fake `codex` to assert the binary actually
receives exactly `debug models --bundled`, so the contract can't
silently drift on the next refactor.

Also teach ValidateThinkingLevel to resolve an empty model to the
provider's default model entry. Without this, every default-model
task with a persisted thinking_level would be misjudged "unknown
model" by the daemon guard.

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

* fix(api): reject runtime switch that would leave invalid thinking_level (MUL-2339)

A PATCH that changed `runtime_id` without touching `thinking_level`
used to silently keep the existing value, so a Claude agent storing
`max` could land on a Codex runtime where `max` is not a recognised
token at all, and the daemon would receive a literal-invalid level.

Hold the same "always 400 on literal-invalid, never silent coerce"
rule on this implicit path. When runtime_id changes and the existing
value is not in the new provider's enum, return 400 with the
recovery options (clear via `thinking_level=""` or re-set in the
same PATCH).

Add coverage for both the kept-when-still-valid and the rejected
cases, plus the two recovery paths (clear and replace).

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

* fix(daemon): guard runTask with per-model thinking_level validator (MUL-2339)

ValidateThinkingLevel existed but had no call site — `task.Agent.
ThinkingLevel` flowed straight into ExecOptions, so `xhigh` configured
on a non-Opus Claude model, or API-side stale values that escaped the
provider enum gate, would be injected anyway.

Run the validator before building ExecOptions. Invalid combinations
log a warning and drop the level instead of failing the task: the
agent still runs, just at the runtime's default reasoning effort.
Discovery errors fail open (keep the level, let the CLI surface any
objection) so a transient `claude --help` failure can't strand work.

Empty model is forwarded as-is; the validator resolves it to the
provider's default model internally per the cross-package contract.

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

* chore(agent): drop stale `--output json` comments + unused scanner (MUL-2339)

Codex CLI's `debug models` subcommand emits JSON without an `--output`
flag, and `parseCodexDebugModels` never read from the bufio.Scanner.
Sync the comments with the actual invocation and remove the dead init.

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

---------

Co-authored-by: multica-agent <github@multica.ai>
2026-05-20 12:30:10 +08:00
Jiayuan Zhang
292226f632 fix(runtimes): use official Gemini spark icon (MUL-2447) (#2904)
* fix(runtimes): use official Gemini spark icon (MUL-2447)

Gemini provider was falling through to the default Monitor icon in the
runtime list. Add the official 4-point spark mark with Google's
blue → purple → pink gradient, matching the SVG style/sizing of the
other provider icons.

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

* fix(runtimes): use current Gemini multicolor spark gradient (MUL-2447)

Per review on PR #2904: the previous 3-stop blue/purple/pink gradient
was the legacy Bard-era Gemini spark. Update to the 5-stop cyan → blue
→ purple → pink → orange gradient used by the current Gemini app/web
multicolor mark.

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

* fix(runtimes): switch Gemini icon to aurora multicolor treatment (MUL-2447)

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

* fix(runtimes): align Gemini aurora color positions and smooth spark path

Swap yellow/green radial gradient anchors so colors land at the official
positions: top red / right blue / left yellow / bottom green, matching
gemini.google.com's current aurora spark. Replace the arc-based 4-point
spark outline with a cubic-bezier version normalized to the 24-viewBox
so the inset between tips is smoother and closer to the gstatic source.

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

* fix(runtimes): use Simple Icons Google Gemini mark (MUL-2447)

Drop the hand-crafted aurora gradient approximation and inline the
canonical "Google Gemini" path from Simple Icons (CC0 1.0), rendered
in the Simple Icons brand color (#8E75B2). This matches the pattern
used by the other provider marks in this file (Claude/Codex from
Bootstrap Icons, etc.) instead of trying to manually approximate the
official multicolor wash from gemini.google.com (which paints via a
clipPath over an embedded raster).

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

---------

Co-authored-by: Lambda <lambda@multica.ai>
Co-authored-by: multica-agent <github@multica.ai>
2026-05-20 12:27:53 +08:00
Jiayuan Zhang
72339f347b fix(desktop): keep local machine row visible after stopping daemon (#2906)
The Start button lives in `DaemonRuntimeActions`, which is rendered in
the per-machine detail pane and only when the selected machine is
flagged `isCurrent`. After the user manually stopped the daemon,
`status.daemonId` went back to undefined, so no machine could be
matched as `isCurrent` — the local row either disappeared (when the
server-side runtime had been GC'd) or moved into the "remote" section
(when it was still present but unmatched). Either way the Start button
was unreachable until the app was restarted.

Two-part fix:

- `DesktopRuntimesPage` now caches the last-known daemonId/deviceName
  so the local match keeps working while the runtime is still on the
  server (recently_lost / offline window).
- `buildRuntimeMachines` accepts an `ensureLocalMachine` flag; when no
  real runtime matches, a placeholder local row is synthesized so the
  Start button still has a home. Desktop opts in via a new
  `hasLocalMachine` prop on `RuntimesPage`. The empty state is also
  suppressed when this prop is set so the placeholder row isn't hidden
  behind the "register a runtime" hint on first launch.

Co-authored-by: Lambda <lambda@multica.ai>
Co-authored-by: multica-agent <github@multica.ai>
2026-05-20 06:16:20 +02:00
Jiayuan Zhang
fc8528d64d feat(autopilot): support assigning to a squad (MUL-2429) (#2888)
* feat(autopilot): support assigning autopilot to a squad (MUL-2429)

Path A (Squad-as-Leader) from the RFC: when an autopilot's assignee is a
squad, dispatch resolves to squad.leader_id and executes against the
leader's runtime — semantics match a human manually assigning the issue
to that squad, no fan-out.

Backend scope only; frontend picker change is a follow-up PR.

Changes:
- 096_autopilot_squad_assignee migration: drop agent FK on
  autopilot.assignee_id, add assignee_type column (default 'agent'),
  add autopilot_run.squad_id attribution column.
- service.AgentReadiness: single source of truth for archived /
  runtime-bound / runtime-online checks. Shared by autopilot
  admission gate, run_only dispatch, and isSquadLeaderReady.
- service.resolveAutopilotLeader: translates assignee_type/id to the
  agent that actually runs the work.
- dispatchCreateIssue: stamps issue with assignee_type='squad' for
  squad autopilots and enqueues via EnqueueTaskForSquadLeader.
- dispatchRunOnly: belt-and-braces readiness re-check after resolving
  squad → leader so a leader that went offline between admission and
  dispatch produces a clean failure instead of a doomed task.
- handler.CreateAutopilot / UpdateAutopilot: accept assignee_type with
  squad/agent existence + leader-archived validation. Backward-compatible
  default of "agent" preserves the contract for older clients.
- Analytics: AutopilotRunStarted/Completed/Failed events carry
  assignee_type and squad_id; PostHog can now group autopilot runs by
  squad without joining back to the autopilot row.

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

* fix(autopilot): reject archived squads, route post-admission skips, cleanup dangling-agent autopilots (MUL-2429)

Addresses three review findings on PR #2888:

1. Archived squad handling: validateAutopilotAssignee now rejects squads
   with archived_at set; resolveAutopilotLeader returns errSquadArchived
   so the admission gate fails closed; DeleteSquad now mirrors the issue
   transfer for autopilot rows (TransferSquadAutopilotsToLeader) so
   surviving autopilots flip to assignee_type='agent' (leader) instead
   of dangling at the archived squad.

2. dispatchRunOnly post-admission readiness: introduces errDispatchSkipped
   sentinel, recognised by DispatchAutopilot via handleDispatchSkip so
   the run is recorded as `skipped` (not `failed`). Manual triggers no
   longer 500 when the leader's runtime goes offline between admission
   and task creation. New TestManualTriggerDoesNotErrorOnPostAdmissionSkip
   locks the behaviour in.

3. Dangling agent assignee after migration 096 dropped the FK:
   shouldSkipDispatch now distinguishes pgx.ErrNoRows / errSquadArchived
   (hard skip — retrying won't help) from transient DB errors
   (fail-open). DeleteAgentRuntime pauses autopilots that target agents
   about to be hard-deleted (ListArchivedAgentIDsByRuntime +
   PauseAutopilotsByAgentAssignees) so the breakage surfaces as a paused
   row in the UI instead of a quiet skip-burning loop.

Unit tests cover the sentinel unwrap contract and errSquadArchived
errors.Is behaviour. Integration test
TestAutopilotDispatchSkipsWhenRuntimeOffline re-verified against a fresh
DB with migration 096 applied.

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

* fix(autopilot): bump last_run_at on post-admission skip (MUL-2429)

Match recordSkippedRun (pre-flight skip) and the success path so the
scheduler / "last seen" UI both reflect that this tick evaluated the
trigger, even when the post-admission readiness gate caught a late
regression.

Addresses Emacs review caveat #1 on PR #2888.

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

* feat(autopilot): mixed agent/squad assignee picker in dialog (MUL-2429)

End-to-end UI for assigning an autopilot to a squad. Closes the PR #2888
backend gap: the squad-as-assignee feature was already wired in Go (Path A,
RFC §4) but the desktop dialog never offered the choice.

- core/types/autopilot: add `AutopilotAssigneeType`, surface
  `assignee_type` on `Autopilot` + Create/Update request payloads.
- views/autopilots/pickers/agent-picker: switch to a polymorphic
  AssigneeSelection (`{type, id}`); render agents and squads as two
  grouped sections with shared pinyin search.
- views/autopilots/autopilot-dialog: maintain `assigneeType` state, send
  it on create/update, render the trigger avatar / hover dot with
  `assignee.type`.
- views/autopilots/autopilots-page + autopilot-detail-page: render the
  assignee row using `autopilot.assignee_type` so squad-typed autopilots
  show the squad avatar + name, not a broken agent lookup.
- locales: add `agents_group` / `squads_group` / `select_assignee` keys
  (en + zh-Hans), keep legacy `select_agent` for callers that still
  reference it.

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

---------

Co-authored-by: Lambda <lambda@multica.ai>
Co-authored-by: multica-agent <github@multica.ai>
2026-05-20 05:30:13 +02:00
Jiayuan Zhang
4a487adfeb feat(github): split canView / canManage in settings tab for read-only members (MUL-2413) (#2898)
Wires the frontend half of the read-only RFC. The Settings → GitHub tab
now always issues the installation list query for any workspace member
(the backend gates it via `RequireWorkspaceMember` after PR #2886) and
gets `can_manage` straight from the API response. The render matrix
covers the six cases the RFC calls out:

- configured + connected + admin   → Disconnect + (optional) Connected by
- configured + connected + member  → read-only "Connected to" + read_only_hint
- configured + not connected + admin   → Connect button + dev description
- configured + not connected + member  → contact_admin_to_connect hint
- not configured + admin               → operator banner + disabled Connect
- not configured + member              → contact_admin_to_connect hint

New i18n keys (en + zh-Hans): read_only_hint, connected_by, contact_admin_to_connect.
The unused github.manage_hint string is removed (its non-admin branch
now resolves to one of the two new hints depending on connection state).

GitHubInstallation gains an optional `connected_by` display name so the
UI can render the "Connected by {name}" line without further changes
once the backend exposes the field.

Co-authored-by: Lambda <lambda@multica.ai>
Co-authored-by: multica-agent <github@multica.ai>
2026-05-20 04:19:28 +02:00
Jiayuan Zhang
e48f6a84d6 feat(github): expose read-only installation list to workspace members (MUL-2413) (#2886)
* feat(github): expose read-only installation list to workspace members (MUL-2413)

Relax `GET /api/workspaces/{id}/github/installations` from owner/admin-only
to any workspace member so the Settings → Integrations tab no longer renders
blank for non-admins (the original symptom of MUL-2413).

The handler now reads the caller's role from the workspace middleware:
- owner / admin keep the full row including the numeric `installation_id`
  (the connect / disconnect handle) and receive `can_manage: true`.
- every other role (member / guest) receives rows with `installation_id`
  omitted and `can_manage: false`, giving them visibility into "is GitHub
  wired up?" without the management handle.

`GET /github/connect` and `DELETE /github/installations/{id}` stay under
the admin/owner middleware group — this PR only relaxes the read path.

Tests: `TestListGitHubInstallations_RoleGating` exercises admin, owner,
member, and guest paths against the real DB-backed handler fixture and
asserts the field stripping + `can_manage` contract.

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

* fix(github): redact installation_id from realtime broadcasts (MUL-2413)

GET /github/installations strips the numeric installation_id for non-admin
members, but the github_installation:created / uninstall / suspend WS
events were still publishing it, so the same handle was reachable from
any workspace client subscribed to the workspace scope. Broadcast both
payload variants without it — the frontend uses these events only to
invalidate the installations query, so admins re-query the list endpoint
to recover the management handle.

Also adds a router-level test that mounts the production middleware split
(member-visible list vs. owner/admin connect+delete) so a future routing
change can't silently widen the write surface.

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

---------

Co-authored-by: Lambda <lambda@multica.ai>
Co-authored-by: multica-agent <github@multica.ai>
2026-05-20 04:17:45 +02:00
Naiyuan Qing
5b8303b83c fix(editor): fill modal viewport in attachment preview (MUL-2431) (#2891)
In the attachment preview modal, image and video previews used
`max-h-full max-w-full`, which let small assets render at their
natural size and leave the modal mostly empty. Switch to
`h-full w-full` so the preview always occupies the modal viewport,
relying on `object-contain` to preserve aspect ratio without
upscaling beyond the intrinsic bounds.

Only touches `packages/views/editor/attachment-preview-modal.tsx`
for the image (line 355) and video (line 373) branches; pdf, audio,
markdown, html, and text branches keep their existing layout.

Co-authored-by: multica-agent <github@multica.ai>
2026-05-20 09:16:08 +08:00
Naiyuan Qing
071ffca034 fix(editor): exit list when Enter pressed on empty top-level item (MUL-2430) (#2861)
Tiptap's stock ListItem keymap binds Enter only to splitListItem. When the
cursor sits in an empty top-level list item, splitListItem returns false
(without dispatching) with a code comment saying "let next command handle
lifting" — but no next command is chained. Enter then falls through to
ProseMirror's baseKeymap which inserts another empty paragraph inside the
list item, trapping the user.

Replace StarterKit's ListItem with PatchedListItem whose Enter binding
chains splitListItem → liftListItem via commands.first. The lift fallback
only runs when splitListItem returns false (top-level empty case),
restoring the standard "double-Enter exits the list" behaviour seen in
every other rich-text editor. Non-empty and nested-empty items are
unaffected because splitListItem already handles them correctly.

Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>
2026-05-20 08:20:50 +08:00
Jiayuan Zhang
2ad1cd8ff8 feat(profile): user profile description injected into agent brief (MUL-2406)
## Summary

Adds per-user `profile_description` so coding agents have cheap, durable context about who is asking. v1 per the brief Xeon locked in on [MUL-2406](mention://issue/63a7247c-4f6a-42cf-90d1-7c746e77158a):

- **DB** — `user.profile_description TEXT NOT NULL DEFAULT ''` (migration 096). 2000-rune cap enforced server-side. No nullable / privacy state to manage.
- **API** — `PATCH /api/me` accepts the field; `UserResponse` always emits it. Client wraps `updateMe` in a lenient `UserSchema` + `EMPTY_USER` fallback per CLAUDE.md API Response Compatibility.
- **UI** — Settings → Account gains an "About you" textarea with live `n/2000` counter, `maxLength` guard, and a localized too-long error (EN + zh-Hans).
- **CLI** — `multica user profile get` / `multica user profile update` with `--description / --description-stdin / --description-file / --clear`, mirroring the existing `issue comment add` input-mode menu.
- **Daemon injection** — claim handler resolves the runtime owner and stamps `requesting_user_name` + `requesting_user_profile_description` on the task. `buildMetaSkillContent` emits `## Requesting User` between `## Agent Identity` and `## Available Commands`, blockquoted and framed as background context. The block is omitted entirely when the description is empty (no token cost when unused).

Brief is written **once per task** via `CLAUDE.md` / `AGENTS.md`, not the per-turn prompt — same path the agent already reads for identity, so no extra per-turn cost.

## Test plan

- [x] `go build ./...`, `go vet ./...`, `go test ./internal/cli/ ./internal/daemon/ ./internal/daemon/execenv/ ./cmd/multica/`
- [x] New brief tests: `TestBuildMetaSkillContentEmitsRequestingUser`, `TestBuildMetaSkillContentOmitsRequestingUserWhenEmpty`
- [x] `pnpm typecheck`, `pnpm lint`, `pnpm test` (74 files, 644 tests pass)
- [ ] Handler DB tests (`TestUpdateMe*`) require a migrated test DB — not runnable in this sandbox
- [ ] Manual: open Settings → Account, set a description, confirm the next daemon-run agent's `CLAUDE.md` shows `## Requesting User`
2026-05-19 19:51:28 +02:00
Jiayuan Zhang
34988216ed feat(issues): show project segment in issue breadcrumb (MUL-2422)
* feat(issues): show project segment in issue breadcrumb (MUL-2422)

Render the issue's project (when present) between the workspace and any
parent-issue segment. Segment reflects the issue's own `project_id` so
the same URL produces the same breadcrumb from every entry point.

Failed/missing project queries fall back to an "Unknown project"
placeholder; loading shows a skeleton to avoid layout shift.

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

* fix(issues): cap project breadcrumb width to preserve title precedence

Constrain Project crumb to max-w-72 (matching ProjectChip) and add
min-w-0 to the title span so the flex compression order matches RFC
§5/§9: Project/Parent shrink before the current Issue title.

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

---------

Co-authored-by: Lambda <lambda@multica.ai>
Co-authored-by: multica-agent <github@multica.ai>
2026-05-19 19:46:20 +02:00
988 changed files with 100309 additions and 9105 deletions

View File

@@ -21,14 +21,23 @@ APP_ENV=
# 888888 and keep APP_ENV non-production. This is ignored when APP_ENV=production.
MULTICA_DEV_VERIFICATION_CODE=
PORT=8080
# Optional aliases for the local/self-host backend port. If one is set, it
# takes precedence over PORT in compose, Makefile, and installer helpers.
# BACKEND_PORT=8080
# API_PORT=8080
# SERVER_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
# Derived by Makefile / local scripts from the backend port.
# Set explicitly only when the daemon reaches the API through a different URL.
# MULTICA_SERVER_URL=ws://localhost:8080/ws
# Derived by docker-compose.selfhost.yml / local scripts from FRONTEND_PORT.
# Set explicitly only when the app's public URL differs from local frontend.
# MULTICA_APP_URL=http://localhost:3000
# Public URL the API is reachable at from the open internet (no trailing
# slash). Used to mint absolute webhook URLs for autopilot webhook
# triggers. Leave unset behind a same-origin reverse proxy or for plain
@@ -91,7 +100,9 @@ SMTP_TLS_INSECURE=false
# rebuild is needed.
GOOGLE_CLIENT_ID=
GOOGLE_CLIENT_SECRET=
GOOGLE_REDIRECT_URI=http://localhost:3000/auth/callback
# Derived by docker-compose.selfhost.yml / local scripts from FRONTEND_PORT.
# Set explicitly only when your OAuth callback URL differs from local frontend.
# GOOGLE_REDIRECT_URI=http://localhost:3000/auth/callback
# S3 / CloudFront
# S3_BUCKET — bucket NAME only (e.g. "my-bucket"). Do NOT include the
@@ -121,7 +132,9 @@ COOKIE_DOMAIN=
# Local file storage (fallback when S3_BUCKET is not set)
LOCAL_UPLOAD_DIR=./data/uploads
LOCAL_UPLOAD_BASE_URL=http://localhost:8080
# Derived by Makefile / local scripts from the backend port.
# Set explicitly only when uploads are served through a different public URL.
# LOCAL_UPLOAD_BASE_URL=http://localhost:8080
# Security
# Comma-separated list of allowed origins for CORS and WebSocket connections.
@@ -170,9 +183,11 @@ GITHUB_WEBHOOK_SECRET=
# Frontend
FRONTEND_PORT=3000
FRONTEND_ORIGIN=http://localhost:3000
# Derived by docker-compose.selfhost.yml / local scripts from FRONTEND_PORT.
# Set explicitly only when serving frontend on a different origin/domain.
# FRONTEND_ORIGIN=http://localhost:3000
# Leave empty — auto-derived from page origin in browser, set by Makefile for local dev.
# Only set explicitly if frontend and backend are on different domains.
# NEXT_PUBLIC_API_URL also feeds the Next.js SSR proxy when explicitly set.
NEXT_PUBLIC_API_URL=
NEXT_PUBLIC_WS_URL=

View File

@@ -29,6 +29,9 @@ jobs:
- name: Install dependencies
run: pnpm install
- name: Test self-host env derivation
run: bash scripts/selfhost-config.test.sh
- name: Verify reserved-slugs.ts is up to date
# Re-runs the generator and fails on any drift from the
# checked-in TypeScript output. The Go side embeds the JSON
@@ -39,7 +42,12 @@ jobs:
git diff --exit-code -- packages/core/paths/reserved-slugs.ts
- name: Build, type check, lint, and test
run: pnpm exec turbo build typecheck lint test --filter='!@multica/docs'
# Mobile lives in a parallel mobile-verify workflow (path-filtered
# to apps/mobile/** + packages/core/types/**) so it doesn't add
# ~50s of expo-lint + tsc to every web/desktop PR. Keep this
# filter in sync with the root package.json scripts, which also
# exclude @multica/mobile.
run: pnpm exec turbo build typecheck lint test --filter='!@multica/docs' --filter='!@multica/mobile'
backend:
runs-on: ubuntu-latest
@@ -91,3 +99,20 @@ jobs:
- name: Test
run: cd server && go test ./...
installer:
# Stub-driven shell tests for scripts/install.sh. Kept off the heavy
# backend job so installer regressions surface independently, and
# exercised on macOS too because the installer targets macOS/Homebrew
# and `tar` / `sed` / `mktemp` differ between BSD and GNU userlands.
strategy:
fail-fast: false
matrix:
os: [ubuntu-latest, macos-latest]
runs-on: ${{ matrix.os }}
steps:
- name: Checkout
uses: actions/checkout@v6
- name: Test shell installers
run: bash scripts/install.test.sh

65
.github/workflows/mobile-verify.yml vendored Normal file
View File

@@ -0,0 +1,65 @@
name: Mobile Verify
# Runs lint + typecheck for apps/mobile only, parallel to the main CI
# workflow. Path-filtered so PRs that don't touch mobile (or anything
# mobile transitively depends on) pay zero cost.
#
# Path scope rationale — mobile transitively depends on:
# - apps/mobile/** — the app itself
# - packages/core/** — mobile imports types AND pure functions
# (agents, markdown, permissions, api/schemas, …),
# not only @multica/core/types
# - package.json / pnpm-lock.yaml — install graph
# - pnpm-workspace.yaml — catalog versions
# - turbo.json — turbo task pipeline
#
# Mobile has no vitest suite today; if one lands, add `test` to the turbo
# task list below.
on:
push:
branches: [main]
paths:
- "apps/mobile/**"
- "packages/core/**"
- "package.json"
- "pnpm-lock.yaml"
- "pnpm-workspace.yaml"
- "turbo.json"
- ".github/workflows/mobile-verify.yml"
pull_request:
branches: [main]
paths:
- "apps/mobile/**"
- "packages/core/**"
- "package.json"
- "pnpm-lock.yaml"
- "pnpm-workspace.yaml"
- "turbo.json"
- ".github/workflows/mobile-verify.yml"
concurrency:
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: true
jobs:
mobile:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v6
- name: Setup pnpm
uses: pnpm/action-setup@v4
- name: Setup Node.js
uses: actions/setup-node@v6
with:
node-version: 22
cache: pnpm
- name: Install dependencies
run: pnpm install
- name: Type check and lint
run: pnpm exec turbo typecheck lint --filter=@multica/mobile

View File

@@ -322,6 +322,47 @@ jobs:
docker buildx imagetools inspect \
ghcr.io/${{ github.repository_owner }}/multica-web:${{ steps.meta.outputs.version }}
helm-chart:
needs: [verify, docker-backend-merge, docker-web-merge]
if: github.repository_owner == 'multica-ai'
runs-on: ubuntu-latest
concurrency:
group: release-helm-chart-${{ github.ref }}
cancel-in-progress: true
env:
CHART_DIR: deploy/helm/multica
OCI_REGISTRY: oci://ghcr.io/${{ github.repository_owner }}/charts
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup Helm
uses: azure/setup-helm@v4
- name: Sync chart metadata with release tag
env:
TAG_NAME: ${{ needs.verify.outputs.tag_name }}
run: |
chart_version="${TAG_NAME#v}"
sed -i -E "s/^version:.*/version: ${chart_version}/" "$CHART_DIR/Chart.yaml"
sed -i -E "s/^appVersion:.*/appVersion: \"${TAG_NAME}\"/" "$CHART_DIR/Chart.yaml"
echo "CHART_VERSION=${chart_version}" >> "$GITHUB_ENV"
- name: Lint chart
run: helm lint "$CHART_DIR"
- name: Package chart
run: helm package "$CHART_DIR" --destination .chart-packages
- name: Login to GHCR
run: echo "${{ secrets.GITHUB_TOKEN }}" | helm registry login ghcr.io --username "${{ github.actor }}" --password-stdin
- name: Push chart to GHCR
run: helm push ".chart-packages/multica-${CHART_VERSION}.tgz" "$OCI_REGISTRY"
- name: Verify published chart
run: helm show chart "$OCI_REGISTRY/multica" --version "$CHART_VERSION"
# Build the Desktop installers for Linux and Windows and upload them to
# the GitHub Release that the `release` job above just published. macOS
# Desktop continues to ship via the manual `release-desktop` skill so it

10
.gitignore vendored
View File

@@ -23,6 +23,14 @@ dist-electron
# Desktop production config is public (backend URL, etc.) — track it so
# `pnpm package` produces a release-ready build without extra setup.
!apps/desktop/.env.production
# Mobile staging config is public (staging API URL) — track it so a fresh
# checkout can run `pnpm dev:mobile:staging` / `ios:mobile*:staging` without
# the user having to copy `.env.example` first.
!apps/mobile/.env.staging
# Mobile production config is public (production API URL) — track it so
# external users can run `pnpm ios:mobile:device:prod:release` against
# multica.ai's production backend without copying templates first.
!apps/mobile/.env.production
# test coverage
coverage
@@ -40,6 +48,8 @@ apps/web/test-results/
# context (agent workspace)
.context
.agent_context/
.multica/
# local settings
.claude/

View File

@@ -32,11 +32,14 @@ Multica is an AI-native task management platform — like Linear, but with AI ag
- `server/` — Go backend (Chi router, sqlc for DB, gorilla/websocket for real-time)
- `apps/web/` — Next.js frontend (App Router)
- `apps/desktop/` — Electron desktop app (electron-vite)
- `packages/core/` — Headless business logic (zero react-dom, all-platform reuse)
- `apps/mobile/` — Expo / React Native iOS app. See `apps/mobile/CLAUDE.md`.
- `packages/core/` — Headless business logic (zero react-dom)
- `packages/ui/` — Atomic UI components (zero business logic)
- `packages/views/` — Shared business pages/components (zero next/* imports, zero react-router imports)
- `packages/tsconfig/` — Shared TypeScript configuration
What lives where for sharing purposes is documented in *Sharing Principles* below — read it once.
### Key Architectural Decisions
**Internal Packages pattern** — all shared packages export raw `.ts`/`.tsx` files (no pre-compilation). The consuming app's bundler compiles them directly. This gives zero-config HMR and instant go-to-definition.
@@ -52,7 +55,7 @@ Multica is an AI-native task management platform — like Linear, but with AI ag
The architecture relies on a strict split between server state and client state. Mixing them is the most common way to break it.
- **TanStack Query owns all server state.** Issues, users, workspaces, inbox — anything fetched from the API lives in the Query cache. WS events keep it fresh via invalidation; no polling, no `staleTime` workarounds.
- **Zustand owns all client state.** UI selections, filters, drafts, modal state, navigation history. Stores live in `packages/core/` (never in `packages/views/`) so both apps share them.
- **Zustand owns all client state.** UI selections, filters, drafts, modal state, navigation history. Stores live in `packages/core/` (never in `packages/views/`) so they're shared.
- **React Context** is reserved for cross-cutting platform plumbing — `WorkspaceIdProvider`, `NavigationProvider`. Don't reach for it for general state.
- **Auth and workspace stores are the only stores allowed to call `api.*` directly**, because they manage critical state that must exist before queries can run. They're created via factory + injected dependencies, registered by the platform layer.
@@ -69,6 +72,17 @@ The architecture relies on a strict split between server state and client state.
- Selectors must return stable references. Returning a freshly built object or array on every call (e.g. `s => ({ a: s.a, b: s.b })` or `s => s.items.map(...)`) triggers infinite re-renders. Either select primitives separately or use shallow comparison.
- Hooks that need workspace context should accept `wsId` as a parameter, not call `useWorkspaceId()` internally — this lets them work outside the `WorkspaceIdProvider` (e.g. in a sidebar that renders before workspace is loaded).
## Sharing Principles
The monorepo splits into two share zones:
- **Web and desktop** share business logic, components, hooks, stores, and views through `packages/core/`, `packages/ui/`, and `packages/views/`. Existing model — keep using it.
- **Mobile (`apps/mobile/`) is independent.** It shares only **types and pure functions** from `@multica/core/`, with `import type` for types (zero runtime coupling). UI, state, hooks, providers, i18n, React version, build pipeline, release cadence — all mobile-owned.
Mobile is locked to the React version that Expo SDK / React Native ships (which lags React main by 6-12 months). Coupling mobile to the root `catalog:` React would block mobile from upgrading on its own schedule.
See `apps/mobile/CLAUDE.md` for the mobile rules and tech-stack baseline.
## Commands
```bash
@@ -111,6 +125,16 @@ cd server && go test ./internal/handler/ -run TestName
# Run a single E2E test (requires backend + frontend running)
pnpm exec playwright test e2e/tests/specific-test.spec.ts
# Mobile (Expo) — two environments only: dev and staging
pnpm dev:mobile # Metro, dev env (reads apps/mobile/.env.development.local)
pnpm dev:mobile:staging # Metro, staging env (reads apps/mobile/.env.staging)
pnpm ios:mobile # Native build + install dev-client to iOS Simulator, dev env
pnpm ios:mobile:staging # Native build + install dev-client to iOS Simulator, staging env
pnpm ios:mobile:device # Native build + install dev-client to USB iPhone, dev env
pnpm ios:mobile:device:staging # Native build + install dev-client to USB iPhone, staging env
# Daily flow: run `pnpm dev:mobile:staging` (or :dev). Only re-run `ios:mobile*` when
# native code or any expo-*/react-native-* dependency changes (lockfile drift counts).
# Desktop build & package
pnpm --filter @multica/desktop build # Compile TS → JS (reads .env.production)
pnpm --filter @multica/desktop package # Package into .app/.dmg/.exe (current platform only)
@@ -179,21 +203,29 @@ Every Go handler in `server/internal/handler/` follows these rules. The conventi
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.
### Dependency Declaration Rule
Every workspace (`apps/` and `packages/` directories) must explicitly declare all directly imported external packages in its own `package.json`. Relying on pnpm hoist to resolve undeclared imports (phantom deps) is prohibited — it causes production build failures when pnpm creates peer-dep variants.
- Use `"pkg": "catalog:"` to reference the shared version from `pnpm-workspace.yaml`.
- CI enforces this via `eslint-plugin-import-x/no-extraneous-dependencies`.
- Exception: `apps/mobile/` uses pinned versions (not `catalog:`) for packages tied to its own React/Expo version.
### Package Boundary Rules
These are hard constraints. Violating them breaks the cross-platform architecture:
- `packages/core/` — zero react-dom, zero localStorage (use StorageAdapter), zero process.env, zero UI libraries. **All shared Zustand stores live here**, even view-related ones (filters, view modes) — stores are pure state, not UI.
- `packages/core/` — zero react-dom, zero localStorage (use StorageAdapter), zero process.env, zero UI libraries. **Shared Zustand stores live here**, even view-related ones (filters, view modes) — stores are pure state, not UI.
- `packages/ui/` — zero `@multica/core` imports (pure UI, no business logic).
- `packages/views/` — zero `next/*` imports, zero `react-router-dom` imports, zero stores. Use `NavigationAdapter` for all routing.
- `apps/web/platform/` — the only place for Next.js APIs (`next/navigation`).
- `apps/desktop/src/renderer/src/platform/` — the only place for react-router-dom navigation wiring.
### The No-Duplication Rule
### The No-Duplication Rule (web + desktop)
**If the same logic exists in both apps, it must be extracted to a shared package.**
**If the same logic exists in both web and desktop, it must be extracted to a shared package.**
This applies to everything: components, hooks, guards, providers, utility functions. The decision process:
This applies to everything between web and desktop: components, hooks, guards, providers, utility functions. The decision process:
1. Does this code depend on Next.js or Electron APIs? → Keep in the respective app.
2. Does it depend on `react-router-dom` or `next/navigation`? → Keep in app's `platform/` layer.
@@ -201,9 +233,9 @@ This applies to everything: components, hooks, guards, providers, utility functi
When the two apps need different behavior for the same concept (e.g., different loading UI), extract the shared logic into a component with props/slots for the differences. Don't duplicate the logic.
### Cross-Platform Development Rules
### Cross-Platform Development Rules (web + desktop)
When adding a new page or feature:
When adding a new page or feature for web/desktop:
1. **New page component** → add to `packages/views/<domain>/`. Never import from `next/*` or `react-router-dom`.
2. **Wire it in both apps** → add a route in `apps/web/app/` (Next.js page file) AND in the desktop router. **Exception**: pre-workspace transition flows (create workspace, accept invite) are NOT routes on desktop — they're `WindowOverlay` state. See *Desktop-specific Rules → Route categories*.
@@ -212,14 +244,18 @@ When adding a new page or feature:
5. **Platform-specific UI** → if a feature is web-only or desktop-only, keep it in the respective app. Use props slots (`extra`, `topSlot`) on shared layout components to inject platform-specific UI.
6. **New hooks that need workspace context** → accept `wsId` as parameter instead of reading from `useWorkspaceId()` Context, so they work both inside and outside `WorkspaceIdProvider`.
### CSS Architecture
### CSS Architecture (web + desktop)
Both apps share the same CSS foundation from `packages/ui/styles/`.
Web and desktop share the same CSS foundation from `packages/ui/styles/`.
- **Design tokens** → use semantic tokens (`bg-background`, `text-muted-foreground`). Never use hardcoded Tailwind colors (`text-red-500`, `bg-gray-100`).
- **Shared styles** → `packages/ui/styles/`. Never duplicate scrollbar styling, keyframes, or base layer rules in app CSS.
- **`@source` directives** → both apps scan shared packages so Tailwind sees all class names.
## Mobile-specific Rules
Rules for `apps/mobile/` live in `apps/mobile/CLAUDE.md`. Read it before touching anything in `apps/mobile/` — it covers what may be imported from `@multica/core/`, the React version policy, the build/release pipeline, and the locked tech-stack baseline.
## Desktop-specific Rules
These rules apply to `apps/desktop/` only. Web has different constraints (URL bar, SSR, no tabs) and doesn't share these concerns. Every rule in this section was added after a concrete bug — treat them as enforced, not suggestions.

View File

@@ -328,7 +328,14 @@ multica issue list --full-id
multica issue list --limit 20 --output json
```
Table output shows a routable issue `KEY` such as `MUL-123`; copy that key into follow-up commands like `issue get`, `issue comment list`, `issue status`, or `--parent`. Add `--full-id` when you need canonical UUIDs. Available filters: `--status`, `--priority`, `--assignee` / `--assignee-id`, `--project`, `--limit`. Use `--assignee-id <uuid>` for unambiguous filtering when names overlap.
Table output shows a routable issue `KEY` such as `MUL-123`; copy that key into follow-up commands like `issue get`, `issue comment list`, `issue status`, or `--parent`. Add `--full-id` when you need canonical UUIDs. Available filters: `--status`, `--priority`, `--assignee` / `--assignee-id`, `--project`, `--metadata`, `--limit`. Use `--assignee-id <uuid>` for unambiguous filtering when names overlap.
Use `--metadata key=value` (repeatable; combined with AND) to filter by per-issue metadata. The value is JSON-parsed: `true`/`false` become bool, numbers become numbers, anything else is a string. Wrap as `'"42"'` to force a string when the value would otherwise sniff as a number:
```bash
multica issue list --metadata pipeline_status=waiting_review
multica issue list --metadata pr_number=482 --metadata is_blocked=true
```
### Get Issue
@@ -373,9 +380,44 @@ Valid statuses: `backlog`, `todo`, `in_progress`, `in_review`, `done`, `blocked`
### Comments
```bash
# List comments
# List comments — flat timeline, chronological. Hard cap of 2000 rows; on
# long-running issues prefer one of the thread-aware reads below to keep
# context windows tight.
multica issue comment list <issue-id>
# Single thread (root + every descendant). Anchor may be the root itself
# or any reply inside the thread — the server walks up to the root.
multica issue comment list <issue-id> --thread <comment-id>
# Single thread, capped to the N most recent replies. The thread root is
# always included (even with --tail 0), so an agent landing on a long
# thread keeps the "what is this about" context without dragging hundreds
# of replies into its prompt.
multica issue comment list <issue-id> --thread <comment-id> --tail 30
# Scroll older replies inside the same thread. --before / --before-id are
# the reply cursor that the previous response emitted on stderr as
# `Next reply cursor: --before <ts> --before-id <reply-id>`.
multica issue comment list <issue-id> --thread <comment-id> --tail 30 \
--before <ts> --before-id <reply-id>
# Most recently active threads (root + every descendant), grouped by
# thread. Returns N complete conversational arcs, oldest-active first so
# the freshest thread sits closest to "now" in an agent prompt.
multica issue comment list <issue-id> --recent 20
# Scroll older threads. Under --recent, --before / --before-id are a
# THREAD cursor (thread last_activity_at + root id), emitted on stderr as
# `Next thread cursor: --before <ts> --before-id <root-id>`.
multica issue comment list <issue-id> --recent 20 \
--before <ts> --before-id <root-id>
# Incremental polling. Combines with --thread or --recent; filters out
# replies created on or before <ts> from the page (the thread root is
# exempt so the agent always gets context).
multica issue comment list <issue-id> --thread <comment-id> --tail 30 \
--since <RFC3339-timestamp>
# Add a comment
multica issue comment add <issue-id> --content "Looks good, merging now"
@@ -386,6 +428,56 @@ multica issue comment add <issue-id> --parent <comment-id> --content "Thanks!"
multica issue comment delete <comment-id>
```
**`--before` / `--before-id` semantics depend on the paging mode**, by
design — same flag, different scope:
| Mode | What the cursor walks | stderr label |
| --- | --- | --- |
| `--recent N` | Older *threads* (last_activity_at, root_id) | `Next thread cursor` |
| `--thread <id> --tail N` | Older *replies* inside that thread (created_at, id) | `Next reply cursor` |
Outside those two modes (`--thread` without `--tail`, or no `--thread`
and no `--recent`) the cursor flags are rejected so they cannot silently
no-op. The server emits the cursor headers (`X-Multica-Next-Before` /
`X-Multica-Next-Before-Id`) only when an older page actually exists —
exact-boundary pages (e.g. `--tail 3` on a thread with exactly 3
replies) intentionally return no cursor so callers stop paginating.
When `--since` is combined with `--recent` or `--thread --tail`, the
server additionally suppresses the cursor once the cursor target itself
is older than `since`. Older pages walk strictly older rows, so they
cannot satisfy `> since` either — emitting a cursor there would just
hand back root-only pages until the caller reaches the start of the
thread / issue. Incremental polling stops at the first page whose
cursor target falls before the watermark.
### Metadata
Per-issue metadata is a small KV map agents use to track pipeline state (PR number, pipeline status, waiting_on, ...). Keys match `^[a-zA-Z_][a-zA-Z0-9_.-]{0,63}$`, values are primitives (string / number / bool), max 50 keys per issue, blob capped at 8KB.
The bar for writing is high: pin a value only when it is materially important to the issue AND likely to be re-read by future runs on this same issue (the PR URL, the deploy URL, what we're blocked on). Most runs write zero new keys — that's the expected case. Don't pin runtime bookkeeping like `attempts`, single-run investigation notes, large logs, secrets/tokens, or description/comment copies — see the agent runtime prompt for the full anti-pattern list.
```bash
# List every key on an issue
multica issue metadata list <issue-id>
# Read a single key
multica issue metadata get <issue-id> --key pipeline_status
# Write a single key — value auto-typed (true/false → bool, numbers → number, else string)
multica issue metadata set <issue-id> --key pipeline_status --value waiting_review
multica issue metadata set <issue-id> --key pr_number --value 482
multica issue metadata set <issue-id> --key is_blocked --value true
# Force a specific type when sniffing would pick the wrong one
multica issue metadata set <issue-id> --key code --value 42 --type string
# Remove a key
multica issue metadata delete <issue-id> --key pipeline_status
```
All writes are single-key atomic — concurrent agents writing different keys do not lose each other's updates. To query, use `multica issue list --metadata key=value` (see *List Issues* above).
### Subscribers
```bash

View File

@@ -18,6 +18,7 @@ ARG COMMIT=unknown
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
RUN cd server && CGO_ENABLED=0 go build -ldflags "-s -w" -o bin/backfill_task_usage_hourly ./cmd/backfill_task_usage_hourly
# --- Runtime stage ---
FROM alpine:3.21
@@ -29,6 +30,7 @@ WORKDIR /app
COPY --from=builder /src/server/bin/server .
COPY --from=builder /src/server/bin/multica .
COPY --from=builder /src/server/bin/migrate .
COPY --from=builder /src/server/bin/backfill_task_usage_hourly .
COPY server/migrations/ ./migrations/
COPY docker/entrypoint.sh .
RUN sed -i 's/\r$//' entrypoint.sh && chmod +x entrypoint.sh

View File

@@ -8,6 +8,8 @@ WORKDIR /app
# Copy workspace config and all package.json files for dependency resolution
COPY pnpm-lock.yaml pnpm-workspace.yaml package.json turbo.json .npmrc ./
COPY apps/web/package.json apps/web/
# postinstall runs fumadocs-mdx which reads apps/web/source.config.ts
COPY apps/web/source.config.ts apps/web/source.config.ts
COPY packages/core/package.json packages/core/
COPY packages/ui/package.json packages/ui/
COPY packages/views/package.json packages/views/

View File

@@ -12,7 +12,7 @@ POSTGRES_DB ?= multica
POSTGRES_USER ?= multica
POSTGRES_PASSWORD ?= multica
POSTGRES_PORT ?= 5432
PORT ?= 8080
PORT := $(or $(BACKEND_PORT),$(API_PORT),$(SERVER_PORT),$(PORT),8080)
FRONTEND_PORT ?= 3000
FRONTEND_ORIGIN ?= http://localhost:$(FRONTEND_PORT)
MULTICA_APP_URL ?= $(FRONTEND_ORIGIN)
@@ -21,6 +21,7 @@ NEXT_PUBLIC_API_URL ?= http://localhost:$(PORT)
NEXT_PUBLIC_WS_URL ?= ws://localhost:$(PORT)/ws
GOOGLE_REDIRECT_URI ?= $(FRONTEND_ORIGIN)/auth/callback
MULTICA_SERVER_URL ?= ws://localhost:$(PORT)/ws
LOCAL_UPLOAD_BASE_URL ?= http://localhost:$(PORT)
export

View File

@@ -57,6 +57,7 @@ Multica manages the full agent lifecycle: from task assignment to execution moni
- **Agents as Teammates** — assign to an agent like you'd assign to a colleague. They have profiles, show up on the board, post comments, create issues, and report blockers proactively.
- **Squads** — group agents (and humans) under a leader agent and assign work to the *squad*. The leader decides who should pick it up, so routing stays stable as the team grows. `@FrontendTeam` instead of `@alice-or-bob-or-carol`.
- **Autonomous Execution** — set it and forget it. Full task lifecycle management (enqueue, claim, start, complete/fail) with real-time progress streaming via WebSocket.
- **Autopilots** — schedule recurring work for agents. Cron triggers, webhooks, or manual runs — each autopilot creates the issue and routes it to an agent automatically, so daily standups, weekly reports, and periodic audits run themselves.
- **Reusable Skills** — every solution becomes a reusable skill for the whole team. Deployments, migrations, code reviews — skills compound your team's capabilities over time.
- **Unified Runtimes** — one dashboard for all your compute. Local daemons and cloud runtimes, auto-detection of available CLIs, real-time monitoring.
- **Multi-Workspace** — organize work across teams with workspace-level isolation. Each workspace has its own agents, issues, and settings.
@@ -113,7 +114,7 @@ multica setup # Connect to Multica Cloud, log in, start daemon
multica setup # Configure, authenticate, and start the daemon
```
The daemon runs in the background and auto-detects agent CLIs (`claude`, `codex`, `copilot`, `openclaw`, `opencode`, `hermes`, `gemini`, `pi`, `cursor-agent`, `kimi`, `kiro-cli`) on your PATH.
The daemon runs in the background and auto-detects agent CLIs (`claude`, `codex`, `copilot`, `openclaw`, `opencode`, `hermes`, `gemini`, `pi`, `cursor-agent`, `kimi`, `kiro-cli`, `agy`) on your PATH.
### 2. Verify your runtime
@@ -123,7 +124,7 @@ Open your workspace in the Multica web app. Navigate to **Settings → Runtimes*
### 3. Create an agent
Go to **Settings → Agents** and click **New Agent**. Pick the runtime you just connected and choose a provider (Claude Code, Codex, GitHub Copilot CLI, OpenClaw, OpenCode, Hermes, Gemini, Pi, Cursor Agent, Kimi, or Kiro CLI). Give your agent a name — this is how it will appear on the board, in comments, and in assignments.
Go to **Settings → Agents** and click **New Agent**. Pick the runtime you just connected and choose a provider (Claude Code, Codex, GitHub Copilot CLI, OpenClaw, OpenCode, Hermes, Gemini, Pi, Cursor Agent, Kimi, Kiro CLI, or Antigravity). Give your agent a name — this is how it will appear on the board, in comments, and in assignments.
### 4. Assign your first task
@@ -187,3 +188,5 @@ make dev
`make dev` auto-detects your environment (main checkout or worktree), creates the env file, installs dependencies, sets up the database, runs migrations, and starts all services.
See [CONTRIBUTING.md](CONTRIBUTING.md) for the full development workflow, worktree support, testing, and troubleshooting.
An iOS mobile client lives in [`apps/mobile/`](apps/mobile/) — see its [README](apps/mobile/README.md) for how to build it onto your own iPhone.

View File

@@ -57,6 +57,7 @@ Multica 管理完整的 Agent 生命周期:从任务分配到执行监控再
- **Agent 即队友** — 像分配给同事一样分配给 Agent。它们有个人档案、出现在看板上、发表评论、创建 Issue、主动报告阻塞问题。
- **Squads小队** — 把多个 Agent以及人类成员组合成由 leader agent 带队的小队直接把任务分配给小队本身。Leader 会判断谁最适合接手,团队扩容时路由方式保持不变。用 `@前端组` 代替 `@小张或小李或小王`
- **自主执行** — 设置后无需管理。完整的任务生命周期管理(排队、认领、执行、完成/失败),通过 WebSocket 实时推送进度。
- **自动化Autopilots** — 为 Agent 安排周期性工作。定时Cron、Webhook 或手动触发,自动化会自动创建 Issue 并分配给 Agent——日报、周报、定期巡检都能让它自己跑起来。
- **可复用技能** — 每个解决方案都成为全团队可复用的技能。部署、数据库迁移、代码审查——技能让团队能力随时间持续增长。
- **统一运行时** — 一个控制台管理所有算力。本地 daemon 和云端运行时,自动检测可用 CLI实时监控。
- **多工作区** — 按团队组织工作,工作区级别隔离。每个工作区有独立的 Agent、Issue 和设置。
@@ -171,6 +172,8 @@ make start
完整的开发流程、worktree 支持、测试和问题排查请参阅 [CONTRIBUTING.md](CONTRIBUTING.md)。
iOS 移动端代码位于 [`apps/mobile/`](apps/mobile/),自己编译装到手机的方法见 [README](apps/mobile/README.md)。
## 开源协议
[Apache 2.0](LICENSE)
[Modified Apache 2.0 (with commercial restrictions)](LICENSE)

View File

@@ -135,6 +135,262 @@ multica daemon status
3. Go to **Settings → Agents** and create a new agent
4. Create an issue and assign it to your agent — it will pick up the task automatically
---
## Kubernetes Deployment (Alternative)
If you already run a Kubernetes cluster, you can deploy Multica there instead of Docker Compose using the released OCI Helm chart at `oci://ghcr.io/multica-ai/charts/multica` or the source chart at [`deploy/helm/multica/`](deploy/helm/multica/). It targets a typical k3s / k8s setup with an Ingress controller and a default `ReadWriteOnce` StorageClass — authored against k3s + Traefik + `local-path`, and should work on any cluster with minor tweaks.
The chart creates the following resources in the target namespace:
- `multica-postgres``pgvector/pgvector:pg17` backed by a 10Gi PVC
- `multica-backend` — Go API/WS server backed by a 5Gi uploads PVC
- `multica-frontend` — Next.js standalone server
- Two `Ingress` resources: one for the web host, one for the backend host
- `multica-config` ConfigMap (rendered from `values.yaml`)
The `multica-secrets` Secret is **not** managed by the chart — you create it once with `kubectl` so real values never need to land in git.
> **One release per namespace:** the prebuilt `multica-web` image bakes `REMOTE_API_URL=http://backend:8080` at build time, so the chart ships an ExternalName Service literally named `backend`. Because that name is unprefixed, you can run only one Multica release per namespace, and `helm install` will fail if a `Service/backend` already exists there (pass `--take-ownership`, or use a dedicated namespace). If you build a web image with a patched `REMOTE_API_URL`, set `frontend.compatibility.backendAlias: false` to drop the alias.
> **Prerequisites:** `kubectl` and `helm` (v3.13+ for `--take-ownership`, or v4+) configured for the target cluster, an Ingress controller (Traefik / NGINX), and a default StorageClass.
### Step 1 — Point hostnames at the cluster
The chart defaults to `multica.dev.lan` (web) and `api.multica.dev.lan` (backend). Pick one of:
- **`/etc/hosts`** on every machine that needs access (developer laptops + the machine running the daemon):
```text
192.168.1.206 multica.dev.lan api.multica.dev.lan
```
Replace `192.168.1.206` with any node IP where your Ingress controller's Service is reachable.
- **Local DNS** (Pi-hole, Unbound, etc.): add A records for both hostnames pointing at the cluster Ingress IP.
To use different hostnames, override the matching values at install time (see [Step 4](#step-4--install-the-chart)) — `ingress.frontend.host`, `ingress.backend.host`, plus `backend.config.appUrl`, `backend.config.frontendOrigin`, `backend.config.localUploadBaseUrl`, and `backend.config.googleRedirectUri`.
### Step 2 — Create the namespace
```bash
kubectl create namespace multica
```
### Step 3 — Create the `multica-secrets` Secret
The chart references this Secret by name. Create it once with random values:
```bash
kubectl -n multica create secret generic multica-secrets \
--from-literal=JWT_SECRET="$(openssl rand -hex 32)" \
--from-literal=POSTGRES_PASSWORD="$(openssl rand -hex 16)" \
--from-literal=RESEND_API_KEY="" \
--from-literal=GOOGLE_CLIENT_SECRET="" \
--from-literal=CLOUDFRONT_PRIVATE_KEY="" \
--from-literal=MULTICA_DEV_VERIFICATION_CODE=""
```
Leave optional values empty for now — you can fill them in later (see [Step 5 — Log In](#step-5--log-in)).
### Step 4 — Install the chart
```bash
helm install multica oci://ghcr.io/multica-ai/charts/multica \
--version <chart-version> \
-n multica
```
Released chart versions strip the leading `v` from the Git tag. For example, release tag `v0.3.5` publishes chart version `0.3.5`; the chart defaults the backend and frontend image tags to `v0.3.5`.
To override defaults, export the chart values, edit them, and pass them with `-f`:
```bash
helm show values oci://ghcr.io/multica-ai/charts/multica \
--version <chart-version> > my-values.yaml
# edit my-values.yaml — e.g. change ingress hosts, image tags, resource limits
helm install multica oci://ghcr.io/multica-ai/charts/multica \
--version <chart-version> \
-n multica \
-f my-values.yaml
```
When developing from a checkout, use the local chart path instead:
```bash
helm install multica deploy/helm/multica -n multica
```
Watch the pods come up:
```bash
kubectl -n multica get pods -w
```
On a cold cluster the backend can sit `Running` but not `Ready` for a few minutes while it waits on PostgreSQL and runs migrations — a startupProbe absorbs this, so the pod should not restart. Once the backend reports `Ready`, migrations have completed and `/healthz` returns OK:
```bash
curl -H "Host: api.multica.dev.lan" http://<ingress-ip>/healthz
# {"status":"ok","checks":{"db":"ok","migrations":"ok"}}
```
Then open http://multica.dev.lan in your browser.
### Step 5 — Log In
The chart defaults to `APP_ENV=production` (set in `values.yaml` under `backend.config.appEnv`), and there is no fixed verification code by default. Pick one of the following to log in — the same three options as the Docker setup:
- **Recommended (production):** patch the Secret with a real Resend key, then restart the backend:
```bash
kubectl -n multica patch secret multica-secrets --type=merge \
-p '{"stringData":{"RESEND_API_KEY":"re_xxx"}}'
kubectl -n multica rollout restart deploy/multica-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).
- **Without email configured:** the verification code is generated server-side and printed to the backend pod logs (look for `[DEV] Verification code for ...:`). Useful for one-off testing.
```bash
kubectl -n multica logs -f deploy/multica-backend | grep "Verification code"
```
- **Deterministic local/private testing:** set `backend.config.appEnv: development` in your values file and `MULTICA_DEV_VERIFICATION_CODE=888888` in the Secret, then `helm upgrade` and restart. This fixed code is ignored when `APP_ENV=production`.
```bash
helm upgrade multica oci://ghcr.io/multica-ai/charts/multica \
--version <chart-version> \
-n multica \
-f my-values.yaml --set backend.config.appEnv=development
kubectl -n multica patch secret multica-secrets --type=merge \
-p '{"stringData":{"MULTICA_DEV_VERIFICATION_CODE":"888888"}}'
kubectl -n multica rollout restart deploy/multica-backend
```
`ALLOW_SIGNUP` and `GOOGLE_CLIENT_ID` likewise live under `backend.config.*` in `values.yaml`. After `helm upgrade`, the backend pod will roll automatically because the ConfigMap hash changes; the web UI reads both from `/api/config` at runtime, so no web rebuild is needed.
> **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 6 — Install CLI & Start Daemon
The daemon runs on your local machine, not in the cluster. Install the CLI and an AI agent as in [Step 3](#step-3--install-cli--start-daemon) above, then point the CLI at your Ingress hostnames:
```bash
multica setup self-host \
--server-url http://api.multica.dev.lan \
--app-url http://multica.dev.lan
```
Make sure the machine running the daemon has the same `/etc/hosts` (or DNS) entries from [Step 1](#step-1--point-hostnames-at-the-cluster).
### Updating
To pull the latest images without changing the chart version when your values still use the mutable `latest` image tag:
```bash
kubectl -n multica rollout restart deploy/multica-backend deploy/multica-frontend
```
To upgrade to a specific Multica release, upgrade to the matching chart version. The released chart defaults its app images to the matching Git tag:
```bash
helm upgrade multica oci://ghcr.io/multica-ai/charts/multica \
--version <chart-version> \
-n multica \
-f my-values.yaml
```
If you need to override the app images independently from the chart version, set the image tags in your values file:
```yaml
images:
backend:
tag: v0.2.4
frontend:
tag: v0.2.4
```
Then run the same upgrade command with `-f my-values.yaml`:
```bash
helm upgrade multica oci://ghcr.io/multica-ai/charts/multica \
--version <chart-version> \
-n multica \
-f my-values.yaml
```
To roll back if an upgrade goes sideways:
```bash
helm -n multica rollback multica
```
> **Upgrading from `v0.3.4` to `v0.3.5+` fails with `refusing to drop legacy daily rollups: ...`?** Same migration guard as the Docker path — see [Usage Dashboard Rollup → Option C](#option-c--backfill-history-first-then-schedule). Run the backfill against the same database the chart is using (`kubectl -n multica exec deploy/multica-backend -- ./backfill_task_usage_hourly --sleep-between-slices=2s`), then restart the backend deployment to re-apply migrations.
### Tearing down
```bash
# Remove the workloads but keep the PVCs and the Secret
helm -n multica uninstall multica
# Wipe everything, including PostgreSQL data and uploads
kubectl delete namespace multica
```
---
## Usage Dashboard Rollup (Required)
Starting with `v0.3.5`, the Usage / Runtime dashboards read from a derived `task_usage_hourly` table rather than directly from `task_usage`. Raw `task_usage` rows are written by the backend on every task, but the dashboard only sees data after `rollup_task_usage_hourly()` runs and aggregates them into `task_usage_hourly`.
**The bundled `pgvector/pgvector:pg17` image does NOT include `pg_cron`.** If nothing schedules the rollup, the dashboard will stay at zero forever even though `task_usage` is populated. You have three supported options — pick one before relying on the dashboard.
> **Upgrading from `v0.3.4` to `v0.3.5+`** with existing `task_usage` history: migration `103` is fail-closed and will abort `migrate up` with `refusing to drop legacy daily rollups: …`. Run `backfill_task_usage_hourly` first (Option C below), then re-run the upgrade. **Fresh installs** are exempted by that guard and migrate cleanly — but the dashboard will still stay at zero until you pick Option A or Option B.
### Option A — External cron / systemd-timer (simplest)
Schedule a 5-minute job that calls `rollup_task_usage_hourly()`. It is idempotent and watermark-driven, so a missed tick catches up on the next run.
```bash
# /etc/cron.d/multica-rollup — every 5 minutes
*/5 * * * * root docker compose -f /path/to/multica/docker-compose.selfhost.yml \
exec -T postgres psql -U multica -d multica \
-c "SELECT rollup_task_usage_hourly();" >/dev/null
```
Or as a systemd timer + service if you prefer that surface. The function returns the number of (upserted + deleted-empty) rows; it's safe to call concurrently with itself (an advisory lock makes overlapping runs no-op) and safe to call alongside `backfill_task_usage_hourly`.
### Option B — Swap Postgres for an image that ships `pg_cron`
If you'd rather have Postgres schedule itself, replace `pgvector/pgvector:pg17` in `docker-compose.selfhost.yml` with an image that bundles both `pgvector` and `pg_cron` (e.g. `supabase/postgres`, or your own build of `pgvector/pgvector` with `pg_cron` added and `shared_preload_libraries=pg_cron` set on the server). Then, once:
```sql
CREATE EXTENSION IF NOT EXISTS pg_cron;
SELECT cron.schedule(
'rollup_task_usage_hourly',
'*/5 * * * *',
$$SELECT rollup_task_usage_hourly()$$
);
```
`shared_preload_libraries` requires a Postgres restart to take effect — set it in `postgresql.conf` (or via the image's documented mechanism) before bringing the container up.
### Option C — Backfill history first, then schedule
If you're upgrading from `v0.3.4 → v0.3.5+` and already have `task_usage` rows (or you just want the dashboard to show historical data on a fresh install that you've been running for a while), run the bundled backfill command once before scheduling the rollup:
```bash
# Backfills task_usage_hourly from all historical task_usage rows and stamps
# the rollup watermark. Idempotent — safe to re-run.
docker compose -f docker-compose.selfhost.yml exec backend \
./backfill_task_usage_hourly --sleep-between-slices=2s
```
On a database with years of data this can scan tens of millions of rows; `--sleep-between-slices=2s` throttles the read pressure. Use `--months-back N` (plus `--force-partial`) if you only want the last N months. Once it finishes, set up Option A or Option B so new buckets keep flowing.
After upgrading, re-run `migrate up` (or restart the backend container — migrations run automatically on startup) to apply migration `103` cleanly.
## Stopping Services
If you installed via the install script:
@@ -175,6 +431,8 @@ docker compose -f docker-compose.selfhost.yml up -d
Pin `MULTICA_IMAGE_TAG` in `.env` to an exact version like `v0.2.4` if you want to stay on a specific release. Migrations run automatically on backend startup.
If the selected GHCR tag has not been published yet, fall back to `make selfhost-build` or `docker compose -f docker-compose.selfhost.yml -f docker-compose.selfhost.build.yml up -d --build`.
> **Upgrading from `v0.3.4` to `v0.3.5+` fails with `refusing to drop legacy daily rollups: ...`?** That's migration `103`'s fail-closed guard: it requires `task_usage_hourly` to be seeded before the legacy daily rollups are dropped. Run `backfill_task_usage_hourly` first, then re-run the upgrade. Full instructions in [Usage Dashboard Rollup → Option C](#option-c--backfill-history-first-then-schedule).
---
## Manual Docker Compose Setup

View File

@@ -79,7 +79,7 @@ For file uploads and attachments, configure S3 and (optionally) CloudFront:
| `S3_BUCKET` | Bucket name only (e.g. `my-bucket`). Do **not** include the `.s3.<region>.amazonaws.com` suffix — the server constructs the public URL from `S3_BUCKET` + `S3_REGION` |
| `S3_REGION` | AWS region (default: `us-west-2`). Must match the bucket's actual region — used for both SDK signing and public URLs |
| `AWS_ACCESS_KEY_ID` / `AWS_SECRET_ACCESS_KEY` | Static credentials. When both are unset, the AWS SDK default credential chain is used |
| `AWS_ENDPOINT_URL` | Custom S3-compatible endpoint (e.g. MinIO, R2, B2). Setting this switches the public URL to path-style |
| `AWS_ENDPOINT_URL` | Custom S3-compatible endpoint (e.g. MinIO, R2, B2). Setting this switches to path-style URLs |
| `CLOUDFRONT_DOMAIN` | CloudFront distribution domain — when set, public URLs use this host instead of the S3 host |
| `CLOUDFRONT_KEY_PAIR_ID` | CloudFront key pair ID for signed URLs |
| `CLOUDFRONT_PRIVATE_KEY` | CloudFront private key (PEM format) |
@@ -166,6 +166,111 @@ The Docker Compose setup runs migrations automatically. If you need to run them
cd server && go run ./cmd/migrate up
```
## Usage Dashboard Rollup
The Usage and Runtime dashboards read from `task_usage_hourly`, a derived table populated by `rollup_task_usage_hourly()`. The function is **not** scheduled out of the box on the default self-host stack: the bundled `pgvector/pgvector:pg17` image ships without `pg_cron`, and the backend does not run the rollup in-process either. Until something calls it on a schedule, raw `task_usage` rows will keep arriving while the dashboard stays at zero.
Pick one of the supported paths:
### Option A — External cron / systemd-timer
The simplest path. Schedule `SELECT rollup_task_usage_hourly()` every five minutes from any out-of-band timer (host crontab, systemd timer, sidecar container, Kubernetes CronJob). It is idempotent and watermark-driven — overlapping runs are no-ops on an internal advisory lock, and a missed tick catches up on the next run.
Docker Compose:
```bash
# /etc/cron.d/multica-rollup
*/5 * * * * root docker compose -f /path/to/multica/docker-compose.selfhost.yml \
exec -T postgres psql -U multica -d multica \
-c "SELECT rollup_task_usage_hourly();" >/dev/null
```
Kubernetes (one-off `CronJob`):
```yaml
apiVersion: batch/v1
kind: CronJob
metadata:
name: multica-usage-rollup
spec:
schedule: "*/5 * * * *"
concurrencyPolicy: Forbid
jobTemplate:
spec:
template:
spec:
restartPolicy: OnFailure
containers:
- name: psql
image: postgres:17-alpine
command:
- psql
- "$(DATABASE_URL)"
- -c
- "SELECT rollup_task_usage_hourly();"
env:
- name: DATABASE_URL
valueFrom:
secretKeyRef:
name: multica-secrets
key: DATABASE_URL
```
### Option B — Postgres with `pg_cron`
If you'd rather have Postgres schedule itself, swap the bundled image for one that ships both `pgvector` and `pg_cron` (e.g. `supabase/postgres`, or a custom build of `pgvector/pgvector` with `pg_cron` added). `pg_cron` requires `shared_preload_libraries=pg_cron` in `postgresql.conf`, which only takes effect on Postgres restart — set it before bringing the container up.
Then register the job once:
```sql
CREATE EXTENSION IF NOT EXISTS pg_cron;
SELECT cron.schedule(
'rollup_task_usage_hourly',
'*/5 * * * *',
$$SELECT rollup_task_usage_hourly()$$
);
```
`pg_cron.database_name` defaults to `postgres`; if your Multica database has a different name, point `pg_cron` at it via that GUC or run `cron.schedule_in_database(...)` instead.
### Option C — Backfill historical data first
`rollup_task_usage_hourly()` only processes new buckets after it starts running. If you already have `task_usage` rows from before the rollup was scheduled — most commonly when upgrading from `v0.3.4` to `v0.3.5+`, or on a fresh install that has been collecting usage for a while — run `backfill_task_usage_hourly` once to seed historical buckets, then set up Option A or Option B for ongoing rollups.
```bash
# Docker Compose
docker compose -f docker-compose.selfhost.yml exec backend \
./backfill_task_usage_hourly --sleep-between-slices=2s
# Kubernetes
kubectl -n multica exec deploy/multica-backend -- \
./backfill_task_usage_hourly --sleep-between-slices=2s
```
The command walks `task_usage`'s full time range in monthly slices and calls the same idempotent primitive the cron path uses, so it's safe to re-run, to interrupt with Ctrl-C, and to run concurrently with an already-scheduled rollup. Flags:
| Flag | Description |
|---|---|
| `--sleep-between-slices` | Pause between monthly slices to throttle read pressure on busy databases (e.g. `2s`). Recommended on production DBs with years of history. |
| `--months-back N` | Only backfill the last N months. **Requires `--force-partial`** because the watermark still advances past the skipped older buckets — those are permanently abandoned. |
| `--dry-run` | Log slices that would be processed without writing anything. |
After backfill completes, the rollup-state watermark is stamped to `now() - 5 minutes`, so the first scheduled tick after backfill does not redo history.
### `v0.3.4 → v0.3.5+` upgrade order
Migration `103` adds a fail-closed guard that refuses to drop the legacy daily rollups until `task_usage_hourly` has caught up. If you run `migrate up` straight through on a database with existing `task_usage` rows, it aborts with:
```text
ERROR: refusing to drop legacy daily rollups:
task_usage_hourly_rollup_state.watermark_at (1970-01-01 ...) trails
task_usage latest event (...) by more than 01:00:00 — backfill is
incomplete or pg_cron is not running. Run cmd/backfill_task_usage_hourly
(and let pg_cron catch up) before re-running migrate
```
Recovery is straightforward: run `backfill_task_usage_hourly` (Option C above), then re-run `migrate up` (or restart the backend container — migrations run automatically on startup). **Fresh installs are exempt** — the guard short-circuits when `task_usage` is empty, and migrations succeed, but the dashboard will still stay at zero until you set up Option A or Option B.
## Manual Setup (Without Docker Compose)
If you prefer to build and run services manually:

View File

@@ -23,7 +23,7 @@ export default defineConfig({
alias: {
"@": resolve("src/renderer/src"),
},
dedupe: ["react", "react-dom"],
dedupe: ["react", "react-dom", "@tanstack/react-query"],
},
},
});

View File

@@ -45,15 +45,21 @@
"@multica/core": "workspace:*",
"@multica/ui": "workspace:*",
"@multica/views": "workspace:*",
"@tanstack/react-query": "catalog:",
"electron-updater": "^6.8.3",
"fix-path": "^5.0.0",
"lucide-react": "catalog:",
"react": "catalog:",
"react-dom": "catalog:",
"react-router-dom": "^7.6.0",
"shadcn": "^4.1.0",
"sonner": "^2.0.7",
"tw-animate-css": "^1.4.0"
"tw-animate-css": "^1.4.0",
"zustand": "catalog:"
},
"devDependencies": {
"@electron-toolkit/tsconfig": "^2.0.0",
"@multica/eslint-config": "workspace:*",
"@multica/tsconfig": "workspace:*",
"@tailwindcss/vite": "^4",
"@testing-library/jest-dom": "catalog:",
@@ -65,9 +71,8 @@
"electron": "^39.2.6",
"electron-builder": "^26.0.12",
"electron-vite": "^5.0.0",
"globals": "^16.0.0",
"jsdom": "catalog:",
"react": "catalog:",
"react-dom": "catalog:",
"tailwindcss": "^4",
"typescript": "catalog:",
"vitest": "catalog:"

View File

@@ -5,9 +5,11 @@ import { electronApp, optimizer, is } from "@electron-toolkit/utils";
import fixPath from "fix-path";
import { setupAutoUpdater } from "./updater";
import { setupDaemonManager } from "./daemon-manager";
import { setupLocalDirectory } from "./local-directory";
import { openExternalSafely, downloadURLSafely } from "./external-url";
import { installContextMenu } from "./context-menu";
import { handleAppShortcut } from "./keyboard-shortcuts";
import { installNavigationGestures } from "./navigation-gestures";
import { getAppVersion } from "./app-version";
import { loadRuntimeConfig } from "./runtime-config-loader";
import type { RuntimeConfigResult } from "../shared/runtime-config";
@@ -252,6 +254,7 @@ function createWindow(): void {
}
installContextMenu(mainWindow.webContents);
installNavigationGestures(mainWindow);
if (is.dev && process.env["ELECTRON_RENDERER_URL"]) {
mainWindow.loadURL(process.env["ELECTRON_RENDERER_URL"]);
@@ -458,6 +461,7 @@ if (!gotTheLock) {
setupAutoUpdater(() => mainWindow);
setupDaemonManager(() => mainWindow);
setupLocalDirectory(() => mainWindow);
// macOS: deep link arrives via open-url event
app.on("open-url", (_event, url) => {

View File

@@ -0,0 +1,93 @@
import { ipcMain, dialog, BrowserWindow } from "electron";
import { access, stat } from "fs/promises";
import { constants as fsConstants } from "fs";
import { basename, isAbsolute } from "path";
export interface PickDirectoryResult {
ok: boolean;
path?: string;
basename?: string;
/** Set when ok=false. "cancelled" = user dismissed; otherwise an error blurb. */
reason?: "cancelled" | "no_window" | "error";
error?: string;
}
export interface ValidateLocalDirectoryResult {
ok: boolean;
/** When ok=false, identifies which check failed so the renderer can render a
* specific message without parsing free-form text. */
reason?:
| "not_absolute"
| "not_found"
| "not_a_directory"
| "not_readable"
| "not_writable"
| "error";
error?: string;
}
async function validateLocalDirectory(
path: string,
): Promise<ValidateLocalDirectoryResult> {
if (!path || !isAbsolute(path)) {
return { ok: false, reason: "not_absolute" };
}
try {
const st = await stat(path);
if (!st.isDirectory()) return { ok: false, reason: "not_a_directory" };
} catch (err) {
const code = (err as NodeJS.ErrnoException).code;
if (code === "ENOENT") return { ok: false, reason: "not_found" };
return { ok: false, reason: "error", error: errorMessage(err) };
}
try {
await access(path, fsConstants.R_OK);
} catch {
return { ok: false, reason: "not_readable" };
}
try {
await access(path, fsConstants.W_OK);
} catch {
return { ok: false, reason: "not_writable" };
}
return { ok: true };
}
function errorMessage(err: unknown): string {
return err instanceof Error ? err.message : String(err);
}
export function setupLocalDirectory(
windowGetter: () => BrowserWindow | null,
): void {
ipcMain.handle(
"local-directory:pick",
async (_event, defaultPath?: string): Promise<PickDirectoryResult> => {
const win = windowGetter();
if (!win) return { ok: false, reason: "no_window" };
try {
const result = await dialog.showOpenDialog(win, {
// Multiple-selection is intentionally disabled — a project_resource
// points at a single directory, and the create flow expects one
// path per click. Multi-add would have to be a separate UX.
properties: ["openDirectory", "createDirectory"],
...(defaultPath ? { defaultPath } : {}),
});
if (result.canceled || result.filePaths.length === 0) {
return { ok: false, reason: "cancelled" };
}
const picked = result.filePaths[0];
if (!picked) return { ok: false, reason: "cancelled" };
return { ok: true, path: picked, basename: basename(picked) };
} catch (err) {
return { ok: false, reason: "error", error: errorMessage(err) };
}
},
);
ipcMain.handle(
"local-directory:validate",
(_event, path: string): Promise<ValidateLocalDirectoryResult> =>
validateLocalDirectory(path),
);
}

View File

@@ -0,0 +1,60 @@
import type { BrowserWindow } from "electron";
import { describe, expect, it, vi } from "vitest";
import { NAVIGATION_GESTURE_CHANNEL } from "../shared/navigation-gestures";
import { installNavigationGestures } from "./navigation-gestures";
function makeWindow() {
let swipeHandler:
| ((event: unknown, direction: string) => void)
| undefined;
const win = {
on: vi.fn(
(event: string, handler: (event: unknown, direction: string) => void) => {
if (event === "swipe") swipeHandler = handler;
return win;
},
),
webContents: {
send: vi.fn(),
},
};
return {
win: win as unknown as BrowserWindow,
send: win.webContents.send,
emitSwipe: (direction: string) => swipeHandler?.({}, direction),
};
}
describe("installNavigationGestures", () => {
it("registers macOS swipe navigation", () => {
const { win, send, emitSwipe } = makeWindow();
installNavigationGestures(win, "darwin");
emitSwipe("right");
expect(send).toHaveBeenCalledWith(NAVIGATION_GESTURE_CHANNEL, "back");
emitSwipe("left");
expect(send).toHaveBeenCalledWith(NAVIGATION_GESTURE_CHANNEL, "forward");
});
it("ignores non-horizontal swipe directions", () => {
const { win, send, emitSwipe } = makeWindow();
installNavigationGestures(win, "darwin");
emitSwipe("up");
expect(send).not.toHaveBeenCalled();
});
it("does not register on non-mac platforms", () => {
const { win, send, emitSwipe } = makeWindow();
installNavigationGestures(win, "linux");
emitSwipe("right");
expect(send).not.toHaveBeenCalled();
});
});

View File

@@ -0,0 +1,18 @@
import type { BrowserWindow } from "electron";
import {
NAVIGATION_GESTURE_CHANNEL,
navigationGestureFromSwipe,
} from "../shared/navigation-gestures";
export function installNavigationGestures(
win: BrowserWindow,
platform: NodeJS.Platform = process.platform,
): void {
if (platform !== "darwin") return;
win.on("swipe", (_event, direction) => {
const gesture = navigationGestureFromSwipe(direction);
if (!gesture) return;
win.webContents.send(NAVIGATION_GESTURE_CHANNEL, gesture);
});
}

View File

@@ -1,5 +1,6 @@
import { ElectronAPI } from "@electron-toolkit/preload";
import type { RuntimeConfigResult } from "../shared/runtime-config";
import type { NavigationGesture } from "../shared/navigation-gestures";
interface DesktopAPI {
/** App version + normalized OS, captured synchronously at preload time. */
@@ -42,6 +43,34 @@ interface DesktopAPI {
issueKey: string;
}) => void,
) => () => void;
/** Listen for native macOS back/forward swipe gestures. Returns an unsubscribe function. */
onNavigationGesture: (callback: (gesture: NavigationGesture) => void) => () => void;
/** Open the OS folder picker and return the chosen absolute path.
* Used by the Project settings "Add local directory" flow. */
pickDirectory: (
defaultPath?: string,
) => Promise<{
ok: boolean;
path?: string;
basename?: string;
reason?: "cancelled" | "no_window" | "error";
error?: string;
}>;
/** Validate that a path is an existing readable+writable directory.
* Mirrors the daemon's runtime check so the user sees errors before submit. */
validateLocalDirectory: (
path: string,
) => Promise<{
ok: boolean;
reason?:
| "not_absolute"
| "not_found"
| "not_a_directory"
| "not_readable"
| "not_writable"
| "error";
error?: string;
}>;
}
interface DaemonStatus {

View File

@@ -1,6 +1,11 @@
import { contextBridge, ipcRenderer } from "electron";
import { electronAPI } from "@electron-toolkit/preload";
import type { RuntimeConfigResult } from "../shared/runtime-config";
import {
isNavigationGesture,
NAVIGATION_GESTURE_CHANNEL,
type NavigationGesture,
} from "../shared/navigation-gestures";
// Synchronously fetch app metadata from main at preload time so the renderer
// can pass it into CoreProvider during the initial render — the alternative
@@ -141,6 +146,22 @@ const desktopAPI = {
ipcRenderer.removeListener("inbox:open", handler);
};
},
/** Listen for native macOS back/forward swipe gestures. */
onNavigationGesture: (callback: (gesture: NavigationGesture) => void) => {
const handler = (_event: Electron.IpcRendererEvent, gesture: unknown) => {
if (isNavigationGesture(gesture)) callback(gesture);
};
ipcRenderer.on(NAVIGATION_GESTURE_CHANNEL, handler);
return () => {
ipcRenderer.removeListener(NAVIGATION_GESTURE_CHANNEL, handler);
};
},
/** Open the OS folder picker and return the chosen absolute path. */
pickDirectory: (defaultPath?: string) =>
ipcRenderer.invoke("local-directory:pick", defaultPath),
/** Validate that a path is an existing readable+writable directory. */
validateLocalDirectory: (path: string) =>
ipcRenderer.invoke("local-directory:validate", path),
};
interface DaemonStatus {

View File

@@ -3,9 +3,11 @@ import { useQuery, useQueryClient } from "@tanstack/react-query";
import { CoreProvider } from "@multica/core/platform";
import { pickLocale } from "@multica/core/i18n";
import { useAuthStore } from "@multica/core/auth";
import { useWelcomeStore } from "@multica/core/onboarding";
import { workspaceKeys, workspaceListOptions } from "@multica/core/workspace/queries";
import { api } from "@multica/core/api";
import { useHasOnboarded } from "@multica/core/paths";
import { setCurrentWorkspace } from "@multica/core/platform";
import { ThemeProvider } from "@multica/ui/components/common/theme-provider";
import { MulticaIcon } from "@multica/ui/components/common/multica-icon";
import { Toaster } from "@multica/ui/components/ui/sonner";
@@ -118,25 +120,31 @@ function AppContent() {
: undefined;
useDaemonIPCBridge(activeWsId);
// Pre-workspace overlay routing for desktop. Mirrors the web entry-point
// judgment in callback / login:
// un-onboarded:
// pending invites on email → /invitations overlay
// no invites → /onboarding overlay
// already onboarded:
// zero workspaces → /workspaces/new overlay
// ≥1 workspaces → no overlay, fall through to dashboard
// Pre-workspace overlay routing for desktop. Mirrors the web layout
// hard gate via overlays (desktop has no URL bar, so we open the
// onboarding overlay instead of router.replace):
// onboarded + has workspace → no overlay, dashboard
// un-onboarded (any wsCount):
// pending invites on email → /invitations overlay
// no invites → /onboarding overlay
// onboarded + no workspace → /workspaces/new overlay
//
// The "un-onboarded but in workspace" state is now physically impossible
// because backend transactions atomically set onboarded_at when a user
// joins the `member` table. Anyone with workspaces is by definition
// onboarded.
// V3 invariant: `onboarded_at != null` is the only path into the
// dashboard. CreateWorkspace does not mark onboarded; only Step 3's
// CompleteOnboarding (and AcceptInvitation) flip the flag. A user who
// somehow has a workspace but no onboarded mark must be sent back to
// /onboarding — we also clear the active workspace so the dashboard
// doesn't render under the overlay with stale workspace context.
useEffect(() => {
if (!user || !workspaceListFetched) return undefined;
const { overlay, open } = useWindowOverlayStore.getState();
if (overlay) return undefined;
if (wsCount > 0) return undefined;
if (hasOnboarded && wsCount > 0) return undefined;
if (!hasOnboarded) {
// Stale workspace context (if any) would leak X-Workspace-Slug
// headers into onboarding-time API calls. Clear it before opening
// the overlay.
setCurrentWorkspace(null, null);
// Look up pending invitations by email. Network blip is non-fatal —
// fall through to onboarding so the user isn't stuck on a blank
// window. The sidebar's pending-invitations dropdown will surface
@@ -258,6 +266,9 @@ function BlockingRuntimeConfigError({ message }: { message: string }) {
async function handleDaemonLogout() {
useTabStore.getState().reset();
useWindowOverlayStore.getState().close();
// Drop any post-onboarding welcome signal so user B logging in next
// doesn't inherit user A's pending modal state.
useWelcomeStore.getState().reset();
try {
await window.daemonAPI.clearToken();
} catch {

View File

@@ -34,6 +34,7 @@ function SidebarTopBar() {
style={{ WebkitAppRegion: "no-drag" } as React.CSSProperties}
>
<button
type="button"
onClick={goBack}
disabled={!canGoBack}
aria-label="Go back"
@@ -42,6 +43,7 @@ function SidebarTopBar() {
<ChevronLeft className="size-4" />
</button>
<button
type="button"
onClick={goForward}
disabled={!canGoForward}
aria-label="Go forward"
@@ -54,6 +56,20 @@ function SidebarTopBar() {
);
}
function useNativeNavigationGestures() {
const { goBack, goForward } = useTabHistory();
useEffect(() => {
return window.desktopAPI.onNavigationGesture((gesture) => {
if (gesture === "back") {
goBack();
} else {
goForward();
}
});
}, [goBack, goForward]);
}
// The main area's top bar doubles as a window drag region. When the sidebar
// is not occupying main-flow width — either user-collapsed (offcanvas) or
// auto-hidden in mobile mode (<768px, becomes a sheet drawer) — we pad the
@@ -132,6 +148,7 @@ function DesktopInboxBridge() {
export function DesktopShell() {
useInternalLinkHandler();
useActiveTitleSync();
useNativeNavigationGestures();
// Reactive read of current workspace slug from the platform singleton.
// On first mount, slug is null until WorkspaceRouteLayout (inside the tab

View File

@@ -19,10 +19,28 @@ import type { DaemonStatus } from "../../../shared/daemon-types";
*/
export function DesktopRuntimesPage() {
const [status, setStatus] = useState<DaemonStatus>({ state: "stopped" });
// Remember the last known daemonId/deviceName. After the daemon is
// stopped, `status.daemonId` goes back to undefined — without this
// sticky cache the local row would either disappear or get reclassified
// as a remote machine (since `isCurrent` requires a daemonId match),
// taking the Start button with it.
const [lastIdentity, setLastIdentity] = useState<{
daemonId: string | null;
deviceName: string | null;
}>({ daemonId: null, deviceName: null });
useEffect(() => {
window.daemonAPI.getStatus().then(setStatus);
return window.daemonAPI.onStatusChange(setStatus);
const apply = (s: DaemonStatus) => {
setStatus(s);
if (s.daemonId) {
setLastIdentity({
daemonId: s.daemonId,
deviceName: s.deviceName ?? null,
});
}
};
window.daemonAPI.getStatus().then(apply);
return window.daemonAPI.onStatusChange(apply);
}, []);
const bootstrapping =
@@ -32,9 +50,14 @@ export function DesktopRuntimesPage() {
return (
<RuntimesPage
localDaemonId={status.daemonId ?? null}
localMachineName={status.deviceName ?? null}
localDaemonId={status.daemonId ?? lastIdentity.daemonId}
localMachineName={status.deviceName ?? lastIdentity.deviceName}
localMachineActions={<DaemonRuntimeActions />}
// Desktop owns a local machine for the lifetime of the app, even
// while the daemon is stopped or hasn't registered yet. The shared
// page synthesizes a placeholder local row when no real runtime
// matches, so the Start button is always reachable.
hasLocalMachine
bootstrapping={bootstrapping}
/>
);

View File

@@ -0,0 +1,151 @@
import { describe, expect, it, vi, beforeEach } from "vitest";
import { render, fireEvent, within } from "@testing-library/react";
type MockTab = {
id: string;
path: string;
title: string;
icon: string;
pinned: boolean;
};
const state = vi.hoisted(() => ({
activeWorkspaceSlug: "acme" as string | null,
byWorkspace: {
acme: {
activeTabId: "tA",
tabs: [
{ id: "tA", path: "/acme/issues", title: "Issues", icon: "ListTodo", pinned: false },
{ id: "tB", path: "/acme/projects", title: "Projects", icon: "ListTodo", pinned: false },
] as MockTab[],
},
} as Record<string, { activeTabId: string; tabs: MockTab[] }>,
togglePin: vi.fn<(tabId: string) => void>(),
closeTab: vi.fn<(tabId: string) => void>(),
setActiveTab: vi.fn<(tabId: string) => void>(),
moveTab: vi.fn<(from: number, to: number) => void>(),
addTab: vi.fn<(path: string, title: string, icon: string) => string>(),
}));
vi.mock("@/stores/tab-store", () => {
const store = {
get activeWorkspaceSlug() {
return state.activeWorkspaceSlug;
},
get byWorkspace() {
return state.byWorkspace;
},
togglePin: state.togglePin,
closeTab: state.closeTab,
setActiveTab: state.setActiveTab,
moveTab: state.moveTab,
addTab: state.addTab,
};
const useTabStore = Object.assign(
(selector?: (s: typeof store) => unknown) =>
selector ? selector(store) : store,
{ getState: () => store },
);
const useActiveGroup = () =>
state.activeWorkspaceSlug
? (state.byWorkspace[state.activeWorkspaceSlug] ?? null)
: null;
const resolveRouteIcon = () => "ListTodo";
return { useTabStore, useActiveGroup, resolveRouteIcon };
});
vi.mock("@multica/core/paths", () => ({
paths: {
workspace: (slug: string) => ({
issues: () => `/${slug}/issues`,
}),
},
}));
import { TabBar } from "./tab-bar";
function reset() {
state.activeWorkspaceSlug = "acme";
state.byWorkspace = {
acme: {
activeTabId: "tA",
tabs: [
{ id: "tA", path: "/acme/issues", title: "Issues", icon: "ListTodo", pinned: false },
{ id: "tB", path: "/acme/projects", title: "Projects", icon: "ListTodo", pinned: false },
],
},
};
state.togglePin.mockReset();
state.closeTab.mockReset();
state.setActiveTab.mockReset();
state.moveTab.mockReset();
state.addTab.mockReset();
}
beforeEach(reset);
describe("TabBar hover action buttons", () => {
it("renders a Pin button on every unpinned tab and an Unpin button on every pinned tab", () => {
state.byWorkspace.acme.tabs = [
{ id: "tA", path: "/acme/issues", title: "Issues", icon: "ListTodo", pinned: true },
{ id: "tB", path: "/acme/projects", title: "Projects", icon: "ListTodo", pinned: false },
];
const { getAllByLabelText } = render(<TabBar />);
expect(getAllByLabelText("Unpin tab")).toHaveLength(1);
expect(getAllByLabelText("Pin tab")).toHaveLength(1);
});
it("clicking the Pin button calls togglePin for the tab", () => {
const { getAllByLabelText } = render(<TabBar />);
const pinButtons = getAllByLabelText("Pin tab");
fireEvent.click(pinButtons[1]); // click Pin on tB (Projects)
expect(state.togglePin).toHaveBeenCalledWith("tB");
});
it("clicking the Unpin button on a pinned tab calls togglePin", () => {
state.byWorkspace.acme.tabs = [
{ id: "tA", path: "/acme/issues", title: "Issues", icon: "ListTodo", pinned: true },
{ id: "tB", path: "/acme/projects", title: "Projects", icon: "ListTodo", pinned: false },
];
const { getByLabelText } = render(<TabBar />);
fireEvent.click(getByLabelText("Unpin tab"));
expect(state.togglePin).toHaveBeenCalledWith("tA");
});
it("hides the X close button on a pinned tab but keeps it on an unpinned tab", () => {
state.byWorkspace.acme.tabs = [
{ id: "tA", path: "/acme/issues", title: "Issues", icon: "ListTodo", pinned: true },
{ id: "tB", path: "/acme/projects", title: "Projects", icon: "ListTodo", pinned: false },
];
const { queryAllByLabelText } = render(<TabBar />);
// Only the unpinned tab exposes a Close affordance — pinned tab requires
// explicit Unpin first (RFC §3 D3c FINAL).
expect(queryAllByLabelText("Close tab")).toHaveLength(1);
});
it("keeps the full title visible on a pinned tab (no icon-only collapse)", () => {
state.byWorkspace.acme.tabs = [
{ id: "tA", path: "/acme/issues", title: "Issues", icon: "ListTodo", pinned: true },
];
const { getByLabelText } = render(<TabBar />);
const pinnedTab = getByLabelText("Issues (pinned)");
expect(within(pinnedTab).getByText("Issues")).toBeTruthy();
});
it("renders the Pin glyph as the leading icon on a pinned tab and the route icon on an unpinned tab", () => {
state.byWorkspace.acme.tabs = [
{ id: "tA", path: "/acme/issues", title: "Issues", icon: "ListTodo", pinned: true },
{ id: "tB", path: "/acme/projects", title: "Projects", icon: "ListTodo", pinned: false },
];
const { getByLabelText } = render(<TabBar />);
const pinnedTab = getByLabelText("Issues (pinned)");
const unpinnedTab = getByLabelText("Projects");
// lucide-react renders the icon name into the class list. The leading
// slot icon is size-3.5; the hover Pin/Unpin action button is size-2.5,
// so we qualify on size to avoid matching the action glyph.
expect(pinnedTab.querySelector(".lucide-pin.size-3\\.5")).toBeTruthy();
expect(pinnedTab.querySelector(".lucide-list-todo")).toBeNull();
expect(unpinnedTab.querySelector(".lucide-list-todo.size-3\\.5")).toBeTruthy();
expect(unpinnedTab.querySelector(".lucide-pin.size-3\\.5")).toBeNull();
});
});

View File

@@ -1,3 +1,4 @@
import { Fragment } from "react";
import {
Inbox,
CircleUser,
@@ -8,6 +9,8 @@ import {
Settings,
X,
Plus,
Pin,
PinOff,
type LucideIcon,
} from "lucide-react";
import {
@@ -28,8 +31,20 @@ import {
restrictToParentElement,
} from "@dnd-kit/modifiers";
import { CSS } from "@dnd-kit/utilities";
import {
ContextMenu,
ContextMenuContent,
ContextMenuItem,
ContextMenuSeparator,
ContextMenuTrigger,
} from "@multica/ui/components/ui/context-menu";
import { cn } from "@multica/ui/lib/utils";
import { useTabStore, useActiveGroup, resolveRouteIcon, type Tab } from "@/stores/tab-store";
import {
useTabStore,
useActiveGroup,
resolveRouteIcon,
type Tab,
} from "@/stores/tab-store";
import { paths } from "@multica/core/paths";
const TAB_ICONS: Record<string, LucideIcon> = {
@@ -42,9 +57,23 @@ const TAB_ICONS: Record<string, LucideIcon> = {
Settings,
};
function SortableTabItem({ tab, isActive, isOnly }: { tab: Tab; isActive: boolean; isOnly: boolean }) {
function SortableTabItem({
tab,
isActive,
isOnly,
}: {
tab: Tab;
isActive: boolean;
/**
* True iff this is the only tab in the workspace. Hiding X on the last
* tab matches existing behavior and avoids the surprise of the store's
* last-tab reseed kicking in. Pinned tabs always hide X (RFC §3 D3c).
*/
isOnly: boolean;
}) {
const setActiveTab = useTabStore((s) => s.setActiveTab);
const closeTab = useTabStore((s) => s.closeTab);
const togglePin = useTabStore((s) => s.togglePin);
const {
attributes,
@@ -55,7 +84,11 @@ function SortableTabItem({ tab, isActive, isOnly }: { tab: Tab; isActive: boolea
isDragging,
} = useSortable({ id: tab.id });
const Icon = TAB_ICONS[tab.icon];
// Pinned tabs swap the route icon for a Pin glyph as the static "I am
// pinned" indicator (RFC §3 D1v-iv FINAL). The route information is still
// present in the title, and this avoids a hard left accent border that read
// as visually heavy in light mode.
const LeadingIcon = tab.pinned ? Pin : TAB_ICONS[tab.icon];
const style = {
transform: CSS.Transform.toString(transform),
@@ -74,17 +107,31 @@ function SortableTabItem({ tab, isActive, isOnly }: { tab: Tab; isActive: boolea
closeTab(tab.id);
};
const stopDragOnClose = (e: React.PointerEvent) => {
const handleTogglePin = (e: React.MouseEvent) => {
e.stopPropagation();
togglePin(tab.id);
};
const stopDragOnAction = (e: React.PointerEvent) => {
e.stopPropagation();
};
return (
// Pinned tabs keep their full title (RFC §3 D1v-ii FINAL). The only visual
// differences vs. unpinned tabs are the leading Pin icon (swapped in above)
// and the suppressed X (closing requires explicit Unpin). Pin/Unpin is
// reachable via the hover action button below and the right-click menu.
const showCloseButton = !tab.pinned && !isOnly;
const tabButton = (
<button
type="button"
ref={setNodeRef}
style={style}
{...attributes}
{...listeners}
onClick={handleClick}
aria-label={tab.pinned ? `${tab.title} (pinned)` : tab.title}
title={tab.pinned ? `${tab.title} (pinned)` : undefined}
className={cn(
"group flex h-7 w-40 items-center gap-1.5 rounded-md px-2 text-xs transition-colors",
"select-none cursor-default",
@@ -94,7 +141,7 @@ function SortableTabItem({ tab, isActive, isOnly }: { tab: Tab; isActive: boolea
isDragging && "opacity-60",
)}
>
{Icon && <Icon className="size-3.5 shrink-0" />}
{LeadingIcon && <LeadingIcon className="size-3.5 shrink-0" />}
<span
className="min-w-0 flex-1 overflow-hidden whitespace-nowrap text-left"
style={{
@@ -104,10 +151,22 @@ function SortableTabItem({ tab, isActive, isOnly }: { tab: Tab; isActive: boolea
>
{tab.title}
</span>
{!isOnly && (
<span
onClick={handleTogglePin}
onPointerDown={stopDragOnAction}
role="button"
aria-label={tab.pinned ? "Unpin tab" : "Pin tab"}
title={tab.pinned ? "Unpin tab" : "Pin tab"}
className="hidden size-3.5 shrink-0 items-center justify-center rounded-sm text-muted-foreground transition-colors group-hover:flex hover:bg-muted-foreground/20 hover:text-foreground"
>
{tab.pinned ? <PinOff className="size-2.5" /> : <Pin className="size-2.5" />}
</span>
{showCloseButton && (
<span
onClick={handleClose}
onPointerDown={stopDragOnClose}
onPointerDown={stopDragOnAction}
role="button"
aria-label="Close tab"
className="hidden size-3.5 shrink-0 items-center justify-center rounded-sm text-muted-foreground transition-colors group-hover:flex hover:bg-muted-foreground/20 hover:text-foreground"
>
<X className="size-2.5" />
@@ -115,6 +174,36 @@ function SortableTabItem({ tab, isActive, isOnly }: { tab: Tab; isActive: boolea
)}
</button>
);
return (
<ContextMenu>
<ContextMenuTrigger render={tabButton} />
<ContextMenuContent>
<ContextMenuItem onClick={() => togglePin(tab.id)}>
{tab.pinned ? (
<>
<PinOff />
Unpin tab
</>
) : (
<>
<Pin />
Pin tab
</>
)}
</ContextMenuItem>
<ContextMenuSeparator />
<ContextMenuItem
variant="destructive"
disabled={tab.pinned || isOnly}
onClick={() => closeTab(tab.id)}
>
<X />
Close tab
</ContextMenuItem>
</ContextMenuContent>
</ContextMenu>
);
}
function NewTabButton() {
@@ -133,6 +222,7 @@ function NewTabButton() {
return (
<button
type="button"
onClick={handleClick}
style={{ WebkitAppRegion: "no-drag" } as React.CSSProperties}
className="flex size-7 shrink-0 items-center justify-center rounded-md text-muted-foreground/70 transition-colors hover:bg-muted/50 hover:text-muted-foreground"
@@ -155,12 +245,17 @@ export function TabBar() {
const tabs = group?.tabs ?? [];
const activeTabId = group?.activeTabId ?? "";
const tabIds = tabs.map((t) => t.id);
const pinnedCount = tabs.filter((t) => t.pinned).length;
const unpinnedCount = tabs.length - pinnedCount;
const handleDragEnd = (event: DragEndEvent) => {
const { active, over } = event;
if (!over || active.id === over.id) return;
const from = tabs.findIndex((t) => t.id === active.id);
const to = tabs.findIndex((t) => t.id === over.id);
// The store clamps the destination to within the source tab's zone
// (pinned vs unpinned), so this call is safe even when the user tries
// to drag across the boundary — the tab will land at the boundary.
if (from !== -1 && to !== -1) moveTab(from, to);
};
@@ -173,13 +268,22 @@ export function TabBar() {
onDragEnd={handleDragEnd}
>
<SortableContext items={tabIds} strategy={horizontalListSortingStrategy}>
{tabs.map((tab) => (
<SortableTabItem
key={tab.id}
tab={tab}
isActive={tab.id === activeTabId}
isOnly={tabs.length === 1}
/>
{tabs.map((tab, index) => (
<Fragment key={tab.id}>
<SortableTabItem
tab={tab}
isActive={tab.id === activeTabId}
isOnly={tabs.length === 1}
/>
{tab.pinned &&
index === pinnedCount - 1 &&
unpinnedCount > 0 && (
<div
aria-hidden
className="mx-1 h-4 w-px bg-border"
/>
)}
</Fragment>
))}
</SortableContext>
</DndContext>

View File

@@ -3,6 +3,7 @@ import { RouterProvider } from "react-router-dom";
import { useActiveGroup } from "@/stores/tab-store";
import { TabNavigationProvider } from "@/platform/navigation";
import { useTabRouterSync } from "@/hooks/use-tab-router-sync";
import { useTabScrollRestore } from "@/hooks/use-tab-scroll-restore";
import type { Tab } from "@/stores/tab-store";
/**
@@ -15,6 +16,28 @@ function TabRouterInner({ tab }: { tab: Tab }) {
return null;
}
/**
* Wraps a tab's subtree so its scroll position survives the round trip
* through `<Activity mode="hidden">`. Lives inside Activity so the hook's
* effects cycle with the tab's visibility — see `useTabScrollRestore` for
* the mechanism. `display: contents` keeps the wrapper transparent to
* the surrounding flex layout.
*/
function TabScrollRestoreWrapper({
tabPath,
children,
}: {
tabPath: string;
children: React.ReactNode;
}) {
const ref = useTabScrollRestore(tabPath);
return (
<div ref={ref} style={{ display: "contents" }}>
{children}
</div>
);
}
/**
* Renders the active workspace's tabs using Activity for state preservation.
* Only the active tab is visible; hidden tabs keep their DOM and React state.
@@ -44,10 +67,12 @@ export function TabContent() {
key={tab.id}
mode={tab.id === group.activeTabId ? "visible" : "hidden"}
>
<TabNavigationProvider router={tab.router}>
<RouterProvider router={tab.router} />
<TabRouterInner tab={tab} />
</TabNavigationProvider>
<TabScrollRestoreWrapper tabPath={tab.path}>
<TabNavigationProvider router={tab.router}>
<RouterProvider router={tab.router} />
<TabRouterInner tab={tab} />
</TabNavigationProvider>
</TabScrollRestoreWrapper>
</Activity>
))}
</>

View File

@@ -26,6 +26,7 @@ export function UpdateNotification() {
return (
<div className="fixed bottom-4 right-4 z-50 w-80 rounded-lg border border-border bg-background p-4 shadow-lg animate-in slide-in-from-bottom-2 fade-in duration-300">
<button
type="button"
onClick={() => setDismissed(true)}
className="absolute top-2 right-2 rounded-md p-1 text-muted-foreground hover:text-foreground transition-colors"
>
@@ -43,12 +44,14 @@ export function UpdateNotification() {
</p>
<div className="mt-2 flex items-center gap-1.5">
<button
type="button"
onClick={() => setDismissed(true)}
className="inline-flex items-center rounded-md border border-border bg-background px-3 py-1.5 text-xs font-medium text-foreground hover:bg-accent transition-colors"
>
Later
</button>
<button
type="button"
onClick={() => window.updater.installUpdate()}
className="inline-flex items-center rounded-md bg-primary px-3 py-1.5 text-xs font-medium text-primary-foreground hover:bg-primary/90 transition-colors"
>

View File

@@ -1,6 +1,7 @@
import { useCallback, useState } from "react";
import { AlertCircle, ArrowDownToLine, Check, Loader2 } from "lucide-react";
import { Button } from "@multica/ui/components/ui/button";
import { useT } from "@multica/views/i18n";
type CheckState =
| { status: "idle" }
@@ -10,6 +11,7 @@ type CheckState =
| { status: "error"; message: string };
export function UpdatesSettingsTab() {
const { t } = useT("settings");
const [state, setState] = useState<CheckState>({ status: "idle" });
const currentVersion = window.desktopAPI.appInfo.version;
@@ -29,17 +31,15 @@ export function UpdatesSettingsTab() {
return (
<div>
<h2 className="text-lg font-semibold">Updates</h2>
<h2 className="text-lg font-semibold">{t(($) => $.desktop.updates.title)}</h2>
<p className="text-sm text-muted-foreground mt-1">
The desktop app checks for new versions automatically once an hour and
shortly after launch, downloading them in the background. You&apos;ll
be prompted to restart once an update is ready.
{t(($) => $.desktop.updates.description)}
</p>
<div className="mt-6 divide-y">
<div className="flex items-center justify-between gap-6 py-4">
<div className="min-w-0">
<p className="text-sm font-medium">Current version</p>
<p className="text-sm font-medium">{t(($) => $.desktop.updates.current_version)}</p>
<p className="text-sm text-muted-foreground mt-0.5 font-mono">
v{currentVersion}
</p>
@@ -48,23 +48,20 @@ export function UpdatesSettingsTab() {
<div className="flex items-start justify-between gap-6 py-4">
<div className="min-w-0">
<p className="text-sm font-medium">Check for updates</p>
<p className="text-sm font-medium">{t(($) => $.desktop.updates.check_section_title)}</p>
<p className="text-sm text-muted-foreground mt-0.5">
Trigger a check now instead of waiting for the next automatic
poll. Available updates download in the background and show a
restart prompt when ready.
{t(($) => $.desktop.updates.check_section_description)}
</p>
{state.status === "up-to-date" && (
<p className="text-sm text-muted-foreground mt-2 inline-flex items-center gap-1.5">
<Check className="size-3.5 text-success" />
You&apos;re on the latest version.
{t(($) => $.desktop.updates.up_to_date)}
</p>
)}
{state.status === "available" && (
<p className="text-sm text-muted-foreground mt-2 inline-flex items-center gap-1.5">
<ArrowDownToLine className="size-3.5 text-primary" />
v{state.latestVersion} is downloading in the background
you&apos;ll be notified when it&apos;s ready to install.
{t(($) => $.desktop.updates.downloading, { version: state.latestVersion })}
</p>
)}
{state.status === "error" && (
@@ -84,10 +81,10 @@ export function UpdatesSettingsTab() {
{state.status === "checking" ? (
<>
<Loader2 className="size-3.5 animate-spin" />
Checking
{t(($) => $.desktop.updates.checking)}
</>
) : (
"Check now"
t(($) => $.desktop.updates.check_now)
)}
</Button>
</div>

View File

@@ -9,8 +9,10 @@ import {
import { setCurrentWorkspace } from "@multica/core/platform";
import { useAuthStore } from "@multica/core/auth";
import { useWorkspaceSeen } from "@multica/views/workspace/use-workspace-seen";
import { WelcomeAfterOnboarding } from "@multica/views/workspace/welcome-after-onboarding";
import { WorkspacePresencePrefetch } from "@multica/views/layout";
import { useTabStore } from "@/stores/tab-store";
import { useWindowOverlayStore } from "@/stores/window-overlay-store";
/**
* Desktop equivalent of apps/web/app/[workspaceSlug]/layout.tsx.
@@ -34,6 +36,15 @@ export function WorkspaceRouteLayout() {
const navigate = useNavigate();
const user = useAuthStore((s) => s.user);
const isAuthLoading = useAuthStore((s) => s.isLoading);
// While a WindowOverlay is open (onboarding, accept-invite, new-workspace),
// the underlying tab is still mounted in the React tree — so this layout
// and its WelcomeAfterOnboarding Modal would render UNDER the overlay.
// Because the modal uses a Portal that targets document.body, it ends up
// rendered LATER in the DOM and visually outranks the overlay's z-50.
// Suppress the modal whenever any overlay is active; the moment the
// overlay closes the welcome hook re-evaluates and pops if its store
// signal is still set.
const overlayActive = useWindowOverlayStore((s) => s.overlay !== null);
// Workspace routes require auth. If user is unauthenticated, bounce to /login.
useEffect(() => {
@@ -85,6 +96,14 @@ export function WorkspaceRouteLayout() {
<WorkspaceSlugProvider slug={workspaceSlug}>
<WorkspacePresencePrefetch />
<Outlet />
{/* Reads the welcome-store transient signal parked by
* OnboardingFlow.handleRuntimeNext. Suppressed while a WindowOverlay
* (onboarding / accept-invite / new-workspace) is open so the modal
* doesn't portal-jump in front of an active pre-workspace flow.
* Once the overlay closes the hook re-evaluates and pops the
* Modal — unless the store signal has already been consumed, in
* which case the hook renders null. */}
{!overlayActive && <WelcomeAfterOnboarding />}
</WorkspaceSlugProvider>
);
}

View File

@@ -0,0 +1,116 @@
import { Activity } from "react";
import { describe, expect, it } from "vitest";
import { fireEvent, render } from "@testing-library/react";
import { useTabScrollRestore } from "./use-tab-scroll-restore";
function Harness({ path }: { path: string }) {
const ref = useTabScrollRestore(path);
return (
<div ref={ref} style={{ display: "contents" }}>
<div
data-tab-scroll-root
data-testid="scroller"
style={{ height: 100, overflow: "auto" }}
>
<div style={{ height: 1000 }} />
</div>
<div
data-tab-scroll-root="aside"
data-testid="aside"
style={{ height: 100, overflow: "auto" }}
>
<div style={{ height: 1000 }} />
</div>
<div
data-testid="unmarked"
style={{ height: 100, overflow: "auto" }}
>
<div style={{ height: 1000 }} />
</div>
</div>
);
}
function App({ visible, path }: { visible: boolean; path: string }) {
return (
<Activity mode={visible ? "visible" : "hidden"}>
<Harness path={path} />
</Activity>
);
}
function setScroll(el: HTMLElement, top: number) {
el.scrollTop = top;
fireEvent.scroll(el);
}
describe("useTabScrollRestore", () => {
it("restores scroll position when a tab cycles through hidden -> visible", () => {
const { rerender, getByTestId } = render(
<App visible={true} path="/acme/issues/1" />,
);
const scroller = getByTestId("scroller") as HTMLElement;
setScroll(scroller, 500);
expect(scroller.scrollTop).toBe(500);
// Simulate Activity hiding the subtree: layout would drop the offset.
rerender(<App visible={false} path="/acme/issues/1" />);
scroller.scrollTop = 0;
rerender(<App visible={true} path="/acme/issues/1" />);
expect(scroller.scrollTop).toBe(500);
});
it("restores multiple named scroll roots independently", () => {
const { rerender, getByTestId } = render(
<App visible={true} path="/acme/issues/1" />,
);
const main = getByTestId("scroller") as HTMLElement;
const aside = getByTestId("aside") as HTMLElement;
setScroll(main, 300);
setScroll(aside, 150);
rerender(<App visible={false} path="/acme/issues/1" />);
main.scrollTop = 0;
aside.scrollTop = 0;
rerender(<App visible={true} path="/acme/issues/1" />);
expect(main.scrollTop).toBe(300);
expect(aside.scrollTop).toBe(150);
});
it("ignores scroll on elements without the data-tab-scroll-root marker", () => {
const { rerender, getByTestId } = render(
<App visible={true} path="/acme/issues/1" />,
);
const unmarked = getByTestId("unmarked") as HTMLElement;
setScroll(unmarked, 250);
rerender(<App visible={false} path="/acme/issues/1" />);
unmarked.scrollTop = 0;
rerender(<App visible={true} path="/acme/issues/1" />);
expect(unmarked.scrollTop).toBe(0);
});
it("drops saved offsets when the tab path changes (intra-tab navigation)", () => {
const { rerender, getByTestId } = render(
<App visible={true} path="/acme/issues/1" />,
);
const scroller = getByTestId("scroller") as HTMLElement;
setScroll(scroller, 500);
// Navigating within the tab swaps the active route — same marker key,
// different page. We should NOT restore the prior page's offset.
rerender(<App visible={true} path="/acme/issues/2" />);
scroller.scrollTop = 0;
rerender(<App visible={false} path="/acme/issues/2" />);
rerender(<App visible={true} path="/acme/issues/2" />);
expect(scroller.scrollTop).toBe(0);
});
});

View File

@@ -0,0 +1,106 @@
import { useEffect, useLayoutEffect, useRef } from "react";
/**
* Persist a tab's scroll positions across <Activity> visibility transitions.
*
* Tabs render under `<Activity mode="visible|hidden">`, which keeps React
* state but loses DOM scrollTop — the subtree is taken out of layout while
* hidden and rejoins with scrollTop=0. This hook records every marked
* container's `scrollTop` while the tab is visible (continuously, via a
* capture-phase scroll listener) and restores them in a `useLayoutEffect`
* the next time the tab becomes visible, before the browser paints.
*
* Mark scroll containers in views with `data-tab-scroll-root`. The
* attribute value is the cache key — defaults to `"main"` for unnamed
* roots. Most pages have a single scroll container, so a bare attribute
* is enough; named keys are only needed when a page has multiple
* independently scrollable regions whose positions must all be restored.
*
* When the tab's path changes (intra-tab navigation), the saved offsets
* are dropped — the new route's container shares the same marker key but
* is a different page, and restoring the old offset would land the user
* somewhere arbitrary on the new page.
*
* For virtualized children (Virtuoso, react-virtual, etc.) the single
* synchronous `scrollTop = saved` inside useLayoutEffect isn't enough:
* the child registers its observers in a passive useEffect that fires
* later, so at restore time the container's scrollHeight has collapsed
* to clientHeight and the browser clamps our assignment to 0. The
* restore loops across rAF frames until the assignment sticks, which
* lets virtualization rehydrate before we give up.
*/
export function useTabScrollRestore(tabPath: string) {
const containerRef = useRef<HTMLDivElement>(null);
const savedRef = useRef<Map<string, number>>(new Map());
const prevPathRef = useRef(tabPath);
if (prevPathRef.current !== tabPath) {
savedRef.current.clear();
prevPathRef.current = tabPath;
}
// <Activity> cleans up effects on hidden and re-mounts them on visible,
// so an empty-deps useLayoutEffect runs exactly on every hidden → visible
// transition. Restoring here (before the browser paints) handles the
// common case without a flash.
//
// The synchronous set isn't enough for virtualized lists though
// (issue-detail uses Virtuoso with customScrollParent). Virtuoso wires
// its scroll/resize observers in a passive useEffect, which fires AFTER
// useLayoutEffect — so at the moment we try to restore, the spacer that
// gives the container its tall scrollHeight hasn't been re-established
// yet. The browser silently clamps `scrollTop = saved` down to 0 because
// `scrollHeight === clientHeight` in that window. Retry across rAF
// frames until the set sticks (or we time out around the time any sane
// child should have laid out, ~500ms).
useLayoutEffect(() => {
const root = containerRef.current;
if (!root) return;
const els = root.querySelectorAll<HTMLElement>("[data-tab-scroll-root]");
const cancellers: Array<() => void> = [];
els.forEach((el) => {
const key = scrollKey(el);
const saved = savedRef.current.get(key);
if (saved === undefined) return;
el.scrollTop = saved;
if (el.scrollTop === saved) return;
let cancelled = false;
let attempts = 0;
const maxAttempts = 30; // ~500ms at 60fps
const tick = () => {
if (cancelled) return;
el.scrollTop = saved;
attempts++;
if (el.scrollTop === saved) return;
if (attempts >= maxAttempts) return;
requestAnimationFrame(tick);
};
requestAnimationFrame(tick);
cancellers.push(() => {
cancelled = true;
});
});
return () => cancellers.forEach((c) => c());
}, []);
useEffect(() => {
const root = containerRef.current;
if (!root) return;
const onScroll = (e: Event) => {
const target = e.target;
if (!(target instanceof HTMLElement)) return;
if (!target.hasAttribute("data-tab-scroll-root")) return;
savedRef.current.set(scrollKey(target), target.scrollTop);
};
// Scroll events don't bubble, but capture catches them anyway.
root.addEventListener("scroll", onScroll, { capture: true, passive: true });
return () => root.removeEventListener("scroll", onScroll, true);
}, []);
return containerRef;
}
function scrollKey(el: HTMLElement): string {
return el.getAttribute("data-tab-scroll-root") || "main";
}

View File

@@ -5,17 +5,40 @@ import { useEffect } from "react";
// Shared in-memory state that the mocked tab store reads / mutates. The test
// records every method call so we can assert openInNewTab does NOT activate
// the new tab (i.e. setActiveTab is never invoked on the same-workspace path).
type MockRouter = {
state: { location: { pathname: string; search: string; hash: string } };
navigate: ReturnType<typeof vi.fn>;
};
type MockTab = {
id: string;
path: string;
pinned: boolean;
router: MockRouter;
};
function makeMockRouter(pathname: string, search = "", hash = ""): MockRouter {
return {
state: { location: { pathname, search, hash } },
navigate: vi.fn(),
};
}
const state = vi.hoisted(() => ({
activeWorkspaceSlug: "acme" as string | null,
byWorkspace: {
acme: {
activeTabId: "tA",
tabs: [{ id: "tA", path: "/acme/issues" }],
tabs: [
{
id: "tA",
path: "/acme/issues",
pinned: false,
router: makeMockRouter("/acme/issues"),
},
] as MockTab[],
},
} as Record<
string,
{ activeTabId: string; tabs: { id: string; path: string }[] }
>,
} as Record<string, { activeTabId: string; tabs: MockTab[] }>,
openTab: vi.fn<(path: string, title?: string, icon?: string) => string>(),
setActiveTab: vi.fn<(tabId: string) => void>(),
switchWorkspace: vi.fn<(slug: string, openPath?: string) => void>(),
@@ -91,7 +114,14 @@ beforeEach(() => {
state.byWorkspace = {
acme: {
activeTabId: "tA",
tabs: [{ id: "tA", path: "/acme/issues" }],
tabs: [
{
id: "tA",
path: "/acme/issues",
pinned: false,
router: makeMockRouter("/acme/issues"),
},
],
},
};
Object.defineProperty(window, "desktopAPI", {
@@ -170,6 +200,95 @@ describe("DesktopNavigationProvider.openInNewTab", () => {
});
});
describe("DesktopNavigationProvider.push with pinned active tab", () => {
function pinActive(pathname: string) {
state.byWorkspace.acme.tabs[0] = {
id: "tA",
path: pathname,
pinned: true,
router: makeMockRouter(pathname),
};
}
it("redirects push to a new foreground tab when pathname differs", () => {
pinActive("/acme/issues");
let adapter: ReturnType<typeof useNavigation> | null = null;
const Probe = captureAdapter((a) => {
adapter = a;
});
render(
<DesktopNavigationProvider>
<Probe />
</DesktopNavigationProvider>,
);
adapter!.push("/acme/projects");
expect(state.openTab).toHaveBeenCalledWith("/acme/projects", "/acme/projects", "File");
expect(state.setActiveTab).toHaveBeenCalledWith("tNew");
});
it("allows in-tab navigation when only search/hash changes", () => {
pinActive("/acme/issues");
let adapter: ReturnType<typeof useNavigation> | null = null;
const Probe = captureAdapter((a) => {
adapter = a;
});
render(
<DesktopNavigationProvider>
<Probe />
</DesktopNavigationProvider>,
);
adapter!.push("/acme/issues?filter=open");
// Pathname unchanged → pinned interception declines and falls through to
// the router's own navigate — openTab / setActiveTab must not fire.
expect(state.openTab).not.toHaveBeenCalled();
expect(state.setActiveTab).not.toHaveBeenCalled();
});
it("leaves cross-workspace push to the workspace switcher (not pin)", () => {
pinActive("/acme/issues");
let adapter: ReturnType<typeof useNavigation> | null = null;
const Probe = captureAdapter((a) => {
adapter = a;
});
render(
<DesktopNavigationProvider>
<Probe />
</DesktopNavigationProvider>,
);
adapter!.push("/butter/inbox");
// Cross-workspace push runs through tryRouteToOtherWorkspace before
// tryRouteToPinnedNewTab, so switchWorkspace wins.
expect(state.switchWorkspace).toHaveBeenCalledWith("butter", "/butter/inbox");
expect(state.openTab).not.toHaveBeenCalled();
});
});
describe("DesktopNavigationProvider.push duplicate path guard", () => {
it("does not navigate when the target exactly matches the active tab location", () => {
const activeRouter = makeMockRouter("/acme/issues/child");
state.byWorkspace.acme.tabs[0] = {
id: "tA",
path: "/acme/issues/child",
pinned: false,
router: activeRouter,
};
let adapter: ReturnType<typeof useNavigation> | null = null;
const Probe = captureAdapter((a) => {
adapter = a;
});
render(
<DesktopNavigationProvider>
<Probe />
</DesktopNavigationProvider>,
);
adapter!.push("/acme/issues/child");
expect(activeRouter.navigate).not.toHaveBeenCalled();
});
});
describe("TabNavigationProvider.openInNewTab", () => {
function renderTabProvider() {
let adapter: ReturnType<typeof useNavigation> | null = null;
@@ -205,3 +324,98 @@ describe("TabNavigationProvider.openInNewTab", () => {
expect(state.switchWorkspace).not.toHaveBeenCalled();
});
});
describe("TabNavigationProvider.push duplicate path guard", () => {
function renderTabProviderAt(
pathname: string,
search = "",
hash = "",
) {
let adapter: ReturnType<typeof useNavigation> | null = null;
const Probe = captureAdapter((a) => {
adapter = a;
});
const fakeRouter = {
state: { location: { pathname, search, hash } },
subscribe: () => () => {},
navigate: vi.fn(),
} as unknown as Parameters<typeof TabNavigationProvider>[0]["router"];
render(
<TabNavigationProvider router={fakeRouter}>
<Probe />
</TabNavigationProvider>,
);
return { getAdapter: () => adapter!, fakeRouter };
}
it("does not navigate when the target exactly matches the current full location", () => {
const { getAdapter, fakeRouter } = renderTabProviderAt("/acme/issues/child");
getAdapter().push("/acme/issues/child");
expect(fakeRouter.navigate).not.toHaveBeenCalled();
});
it("still navigates when only search or hash differs", () => {
const { getAdapter, fakeRouter } = renderTabProviderAt("/acme/issues");
getAdapter().push("/acme/issues?filter=open#top");
expect(fakeRouter.navigate).toHaveBeenCalledWith("/acme/issues?filter=open#top");
});
});
describe("TabNavigationProvider.push with pinned active tab", () => {
type ProviderRouter = Parameters<typeof TabNavigationProvider>[0]["router"];
function renderPinnedTabProvider(pathname: string) {
// The active tab and the per-tab router must share the same pathname:
// tryRouteToPinnedNewTab reads the *active tab's* router for the current
// pathname (so query-only pushes routed via React Router still compare
// correctly), while the TabNavigationProvider falls back to *its own*
// router.navigate when no interception fires. In real desktop usage they
// are the same router instance; this helper mirrors that invariant.
const fakeRouter = {
state: { location: { pathname, search: "", hash: "" } },
subscribe: () => () => {},
navigate: vi.fn(),
} as unknown as ProviderRouter;
state.byWorkspace.acme.tabs[0] = {
id: "tA",
path: pathname,
pinned: true,
router: fakeRouter as unknown as MockRouter,
};
let adapter: ReturnType<typeof useNavigation> | null = null;
const Probe = captureAdapter((a) => {
adapter = a;
});
render(
<TabNavigationProvider router={fakeRouter}>
<Probe />
</TabNavigationProvider>,
);
return { getAdapter: () => adapter!, fakeRouter };
}
it("redirects push to a new foreground tab when pathname differs", () => {
const { getAdapter, fakeRouter } = renderPinnedTabProvider("/acme/issues");
getAdapter().push("/acme/projects");
expect(state.openTab).toHaveBeenCalledWith("/acme/projects", "/acme/projects", "File");
expect(state.setActiveTab).toHaveBeenCalledWith("tNew");
// Pinned interception short-circuits — the per-tab router must NOT
// navigate, otherwise the pinned tab itself would move off its path.
expect(fakeRouter.navigate).not.toHaveBeenCalled();
});
it("allows in-tab navigation when only search/hash changes", () => {
const { getAdapter, fakeRouter } = renderPinnedTabProvider("/acme/issues");
getAdapter().push("/acme/issues?filter=open");
// Same pathname → pinned interception declines, push falls through to
// the tab's own router.navigate, and no new tab is opened.
expect(state.openTab).not.toHaveBeenCalled();
expect(state.setActiveTab).not.toHaveBeenCalled();
expect(fakeRouter.navigate).toHaveBeenCalledWith("/acme/issues?filter=open");
});
});

View File

@@ -89,6 +89,11 @@ function tryRouteToOverlay(path: string, router?: DataRouter): boolean {
return false;
}
function routerLocationPath(router: DataRouter): string {
const { pathname, search, hash } = router.state.location;
return `${pathname}${search ?? ""}${hash ?? ""}`;
}
/**
* Intercept pushes that change workspace. Returns `true` if the navigation
* was delegated to the tab store (caller should NOT proceed).
@@ -108,6 +113,37 @@ function tryRouteToOtherWorkspace(path: string): boolean {
return true;
}
/**
* Intercept pushes originating in a pinned tab and force them into a new
* tab. Returns `true` if the navigation was redirected (caller should NOT
* proceed). Pathname-only changes (search / hash / same-page state) are
* allowed through so pinned filter / drawer / form-state interactions
* still work — see RFC §3 D2a (FINAL: any pathname change → new tab) and
* D2b (FINAL: same pathname → allowed in pinned tab).
*
* Dedupe is preserved (D4a): `openTab` activates an existing same-path tab
* if one exists, otherwise creates a new one. The newly-focused tab is
* activated foreground — a pinned-tab push is an explicit user action, not
* a background cmd+click, so the focus follows.
*/
function tryRouteToPinnedNewTab(path: string): boolean {
const store = useTabStore.getState();
const active = getActiveTab(store);
if (!active?.pinned) return false;
// Use the live router pathname rather than `active.path` so query-only
// navigations performed via React Router (which only sync pathname back
// to the store) still compare correctly.
const currentPathname = active.router.state.location.pathname;
const newPathname = path.split("?")[0].split("#")[0];
if (currentPathname === newPathname) return false;
const icon = resolveRouteIcon(path);
const newId = store.openTab(path, path, icon);
if (newId) store.setActiveTab(newId);
return true;
}
/**
* Root-level navigation provider for components outside the per-tab
* RouterProviders (sidebar, search dialog, modals, WindowOverlay contents).
@@ -164,7 +200,9 @@ export function DesktopNavigationProvider({
}
const active = currentActiveTab();
if (tryRouteToOverlay(path, active?.router)) return;
if (active && routerLocationPath(active.router) === path) return;
if (tryRouteToOtherWorkspace(path)) return;
if (tryRouteToPinnedNewTab(path)) return;
active?.router.navigate(path);
},
replace: (path: string) => {
@@ -239,7 +277,9 @@ export function TabNavigationProvider({
() => ({
push: (path: string) => {
if (tryRouteToOverlay(path, router)) return;
if (routerLocationPath(router) === path) return;
if (tryRouteToOtherWorkspace(path)) return;
if (tryRouteToPinnedNewTab(path)) return;
router.navigate(path);
},
replace: (path: string) => {

View File

@@ -25,12 +25,40 @@ import { AgentsPage } from "@multica/views/agents";
import { SquadsPage, SquadDetailPage as SquadDetailPageView } from "@multica/views/squads/components";
import { InboxPage } from "@multica/views/inbox";
import { SettingsPage } from "@multica/views/settings";
import { useT } from "@multica/views/i18n";
import { ErrorBoundary } from "@multica/ui/components/common/error-boundary";
import { Download, Server } from "lucide-react";
import { DaemonSettingsTab } from "./components/daemon-settings-tab";
import { UpdatesSettingsTab } from "./components/updates-settings-tab";
import { WorkspaceRouteLayout } from "./components/workspace-route-layout";
/**
* Wraps `SettingsPage` so the desktop-only extra tabs can pull their labels
* from i18n. The route element has to be a component (not a literal JSX
* value) for `useT` to run.
*/
function DesktopSettingsRoute() {
const { t } = useT("settings");
return (
<SettingsPage
extraAccountTabs={[
{
value: "daemon",
label: "Daemon",
icon: Server,
content: <DaemonSettingsTab />,
},
{
value: "updates",
label: t(($) => $.desktop.tabs.updates),
icon: Download,
content: <UpdatesSettingsTab />,
},
]}
/>
);
}
/**
* Sets document.title from the deepest matched route's handle.title.
* The tab system observes document.title via MutationObserver.
@@ -173,24 +201,7 @@ export const appRoutes: RouteObject[] = [
},
{
path: "settings",
element: (
<SettingsPage
extraAccountTabs={[
{
value: "daemon",
label: "Daemon",
icon: Server,
content: <DaemonSettingsTab />,
},
{
value: "updates",
label: "Updates",
icon: Download,
content: <UpdatesSettingsTab />,
},
]}
/>
),
element: <DesktopSettingsRoute />,
handle: { title: "Settings" },
},
],

View File

@@ -17,6 +17,7 @@ vi.mock("../routes", () => ({
import {
sanitizeTabPath,
migrateV1ToV2,
migrateV2ToV3,
useTabStore,
} from "./tab-store";
@@ -277,3 +278,155 @@ describe("useTabStore actions", () => {
expect(useTabStore.getState().activeWorkspaceSlug).toBe("acme");
});
});
describe("togglePin", () => {
it("flips a tab's pinned state", () => {
const store = useTabStore.getState();
store.switchWorkspace("acme");
const tabId = useTabStore.getState().byWorkspace.acme.tabs[0].id;
expect(useTabStore.getState().byWorkspace.acme.tabs[0].pinned).toBe(false);
store.togglePin(tabId);
expect(useTabStore.getState().byWorkspace.acme.tabs[0].pinned).toBe(true);
store.togglePin(tabId);
expect(useTabStore.getState().byWorkspace.acme.tabs[0].pinned).toBe(false);
});
it("moves a newly-pinned tab to the start of the pinned zone", () => {
const store = useTabStore.getState();
store.switchWorkspace("acme"); // creates default unpinned tab at index 0
store.addTab("/acme/projects", "Projects", "FolderKanban");
store.addTab("/acme/agents", "Agents", "Bot");
const agentsId = useTabStore.getState().byWorkspace.acme.tabs[2].id;
store.togglePin(agentsId);
const tabs = useTabStore.getState().byWorkspace.acme.tabs;
expect(tabs[0].id).toBe(agentsId);
expect(tabs[0].pinned).toBe(true);
expect(tabs[1].pinned).toBe(false);
expect(tabs[2].pinned).toBe(false);
});
it("appends a second pinned tab after the first pinned tab", () => {
const store = useTabStore.getState();
store.switchWorkspace("acme");
store.addTab("/acme/projects", "Projects", "FolderKanban");
store.addTab("/acme/agents", "Agents", "Bot");
const projectsId = useTabStore.getState().byWorkspace.acme.tabs[1].id;
const agentsId = useTabStore.getState().byWorkspace.acme.tabs[2].id;
store.togglePin(agentsId);
store.togglePin(projectsId);
// Both pinned, in the order they were pinned (agents first, projects
// second), then the unpinned default tab.
const tabs = useTabStore.getState().byWorkspace.acme.tabs;
expect(tabs.map((t) => t.id)).toEqual([
agentsId,
projectsId,
tabs[2].id,
]);
expect(tabs.map((t) => t.pinned)).toEqual([true, true, false]);
});
it("returns an unpinned tab to the start of the unpinned zone", () => {
const store = useTabStore.getState();
store.switchWorkspace("acme");
store.addTab("/acme/projects", "Projects", "FolderKanban");
const issuesId = useTabStore.getState().byWorkspace.acme.tabs[0].id;
const projectsId = useTabStore.getState().byWorkspace.acme.tabs[1].id;
// Pin both, then unpin one.
store.togglePin(issuesId);
store.togglePin(projectsId);
store.togglePin(issuesId);
const tabs = useTabStore.getState().byWorkspace.acme.tabs;
expect(tabs.map((t) => t.id)).toEqual([projectsId, issuesId]);
expect(tabs.map((t) => t.pinned)).toEqual([true, false]);
});
});
describe("moveTab boundary clamp", () => {
it("clamps a pinned-tab move so it never crosses into the unpinned zone", () => {
const store = useTabStore.getState();
store.switchWorkspace("acme");
store.addTab("/acme/projects", "Projects", "FolderKanban");
store.addTab("/acme/agents", "Agents", "Bot");
const issuesId = useTabStore.getState().byWorkspace.acme.tabs[0].id;
store.togglePin(issuesId); // [issues(pinned), projects, agents]
// User tries to drag the pinned tab to index 2 (unpinned zone end).
store.moveTab(0, 2);
const tabs = useTabStore.getState().byWorkspace.acme.tabs;
// It should be clamped to index 0 — the only pinned slot — i.e. unchanged.
expect(tabs[0].id).toBe(issuesId);
expect(tabs.map((t) => t.pinned)).toEqual([true, false, false]);
});
it("clamps an unpinned-tab move so it never crosses into the pinned zone", () => {
const store = useTabStore.getState();
store.switchWorkspace("acme");
store.addTab("/acme/projects", "Projects", "FolderKanban");
store.addTab("/acme/agents", "Agents", "Bot");
const issuesId = useTabStore.getState().byWorkspace.acme.tabs[0].id;
const agentsId = useTabStore.getState().byWorkspace.acme.tabs[2].id;
store.togglePin(issuesId); // [issues(pinned), projects, agents]
// User tries to drag agents (index 2) to index 0 (pinned zone).
store.moveTab(2, 0);
const tabs = useTabStore.getState().byWorkspace.acme.tabs;
// Clamped to index 1 — start of the unpinned zone.
expect(tabs[0].id).toBe(issuesId);
expect(tabs[1].id).toBe(agentsId);
expect(tabs.map((t) => t.pinned)).toEqual([true, false, false]);
});
it("reorders freely within the same zone", () => {
const store = useTabStore.getState();
store.switchWorkspace("acme");
store.addTab("/acme/projects", "Projects", "FolderKanban");
store.addTab("/acme/agents", "Agents", "Bot");
// All unpinned; move agents (2) to position 0.
store.moveTab(2, 0);
const tabs = useTabStore.getState().byWorkspace.acme.tabs;
expect(tabs.map((t) => t.path)).toEqual([
"/acme/agents",
"/acme/issues",
"/acme/projects",
]);
});
});
describe("migrateV2ToV3", () => {
it("adds pinned=false to every persisted tab", () => {
const v2 = {
activeWorkspaceSlug: "acme",
byWorkspace: {
acme: {
activeTabId: "t1",
tabs: [
{ id: "t1", path: "/acme/issues", title: "Issues", icon: "ListTodo" },
{ id: "t2", path: "/acme/projects", title: "Projects", icon: "FolderKanban" },
],
},
},
};
const v3 = migrateV2ToV3(v2);
expect(v3.activeWorkspaceSlug).toBe("acme");
expect(v3.byWorkspace.acme.tabs).toEqual([
{ id: "t1", path: "/acme/issues", title: "Issues", icon: "ListTodo", pinned: false },
{ id: "t2", path: "/acme/projects", title: "Projects", icon: "FolderKanban", pinned: false },
]);
});
it("handles missing byWorkspace gracefully", () => {
const v3 = migrateV2ToV3({ activeWorkspaceSlug: null } as Parameters<typeof migrateV2ToV3>[0]);
expect(v3.byWorkspace).toEqual({});
expect(v3.activeWorkspaceSlug).toBeNull();
});
});

View File

@@ -20,6 +20,14 @@ export interface Tab {
router: DataRouter;
historyIndex: number;
historyLength: number;
/**
* Pinned tabs render at the left of the tab bar as icon-only, suppress the
* X close button, and turn any `navigation.push()` originating in them into
* an `openInNewTab()` so they stay parked on their original path. Pinning
* is invariant-preserving: pinned tabs always come before unpinned tabs in
* a workspace's `tabs` array; `togglePin` / `moveTab` enforce this.
*/
pinned: boolean;
}
export interface WorkspaceTabGroup {
@@ -78,8 +86,20 @@ interface TabStore {
updateTab: (tabId: string, patch: Partial<Pick<Tab, "path" | "title" | "icon">>) => void;
/** Patch history tracking of a tab. Finds across groups. */
updateTabHistory: (tabId: string, historyIndex: number, historyLength: number) => void;
/** Reorder within the active workspace's group only. */
/**
* Reorder within the active workspace's group only. Clamped so a tab can
* never cross the pinned / unpinned boundary — a drag that would move a
* pinned tab into the unpinned zone (or vice versa) is dropped at the
* boundary instead. This keeps the "pinned tabs first" invariant without
* requiring callers to know about it.
*/
moveTab: (fromIndex: number, toIndex: number) => void;
/**
* Flip a tab's pinned state. Pinning moves it to the end of the pinned
* zone; unpinning moves it to the start of the unpinned zone. Both
* preserve the "pinned tabs before unpinned tabs" invariant.
*/
togglePin: (tabId: string) => void;
/**
* After the workspace list arrives/changes (login, realtime delete), drop
* any tab group whose slug is no longer in `validSlugs`, and repoint
@@ -190,9 +210,17 @@ function makeTab(path: string, title: string, icon: string): Tab {
router: createTabRouter(path),
historyIndex: 0,
historyLength: 1,
pinned: false,
};
}
/** Index of the first unpinned tab in a group (== pinned count). */
function pinnedBoundary(tabs: Tab[]): number {
let i = 0;
while (i < tabs.length && tabs[i].pinned) i++;
return i;
}
/** Default entry point for a workspace — its issues list. */
function defaultPathFor(slug: string): string {
return `/${slug}/issues`;
@@ -453,17 +481,63 @@ export const useTabStore = create<TabStore>()(
if (!activeWorkspaceSlug) return;
const group = byWorkspace[activeWorkspaceSlug];
if (!group) return;
if (fromIndex < 0 || fromIndex >= group.tabs.length) return;
// Clamp the drop position to within the source tab's group (pinned vs
// unpinned) so the "pinned tabs first" invariant survives drag-reorder.
// Pinned zone is [0, boundary); unpinned zone is [boundary, length).
const boundary = pinnedBoundary(group.tabs);
const source = group.tabs[fromIndex];
let clampedTo: number;
if (source.pinned) {
// boundary is exclusive upper bound for pinned-zone indices.
clampedTo = Math.max(0, Math.min(toIndex, boundary - 1));
} else {
clampedTo = Math.max(boundary, Math.min(toIndex, group.tabs.length - 1));
}
if (clampedTo === fromIndex) return;
set({
byWorkspace: {
...byWorkspace,
[activeWorkspaceSlug]: {
...group,
tabs: arrayMove(group.tabs, fromIndex, toIndex),
tabs: arrayMove(group.tabs, fromIndex, clampedTo),
},
},
});
},
togglePin(tabId) {
const { byWorkspace } = get();
const hit = findTabLocation(byWorkspace, tabId);
if (!hit) return;
const { slug, group, index } = hit;
const current = group.tabs[index];
const nextTab: Tab = { ...current, pinned: !current.pinned };
// Remove from current position, then insert at the new zone boundary:
// pinning → end of pinned zone (just before first unpinned tab)
// unpinning → start of unpinned zone (right after last pinned tab)
const withoutCurrent = [
...group.tabs.slice(0, index),
...group.tabs.slice(index + 1),
];
const newBoundary = pinnedBoundary(withoutCurrent);
const insertAt = newBoundary;
const nextTabs = [
...withoutCurrent.slice(0, insertAt),
nextTab,
...withoutCurrent.slice(insertAt),
];
set({
byWorkspace: {
...byWorkspace,
[slug]: { ...group, tabs: nextTabs },
},
});
},
validateWorkspaceSlugs(validSlugs) {
const { activeWorkspaceSlug, byWorkspace } = get();
let changed = false;
@@ -497,17 +571,23 @@ export const useTabStore = create<TabStore>()(
}),
{
name: "multica_tabs",
version: 2,
version: 3,
storage: createJSONStorage(() => createPersistStorage(defaultStorage)),
migrate: (persistedState, version) => {
// v1 → v2: flat `tabs` array → per-workspace grouping.
// Tabs whose path isn't workspace-scoped (root `/`, login, etc.)
// are dropped — they have no workspace to belong to, and the new
// model's invariant is "every tab lives in a workspace group".
if (version < 2 && persistedState && typeof persistedState === "object") {
return migrateV1ToV2(persistedState as Partial<V1Persisted>);
let state = persistedState;
if (version < 2 && state && typeof state === "object") {
state = migrateV1ToV2(state as Partial<V1Persisted>);
}
return persistedState as V2Persisted;
// v2 → v3: introduce `Tab.pinned`. Existing tabs default to
// unpinned; pin ordering invariant trivially holds (no pinned tabs).
if (version < 3 && state && typeof state === "object") {
state = migrateV2ToV3(state as V2Persisted);
}
return state as V3Persisted;
},
partialize: (state) => ({
activeWorkspaceSlug: state.activeWorkspaceSlug,
@@ -517,15 +597,19 @@ export const useTabStore = create<TabStore>()(
{
activeTabId: group.activeTabId,
tabs: group.tabs.map(
({ router: _router, historyIndex: _hi, historyLength: _hl, ...rest }) =>
rest,
({
router: _router,
historyIndex: _hi,
historyLength: _hl,
...rest
}) => rest,
),
},
]),
),
}),
merge: (persistedState, currentState) => {
const persisted = persistedState as Partial<V2Persisted> | undefined;
const persisted = persistedState as Partial<V3Persisted> | undefined;
if (!persisted?.byWorkspace) return currentState;
const byWorkspace: Record<string, WorkspaceTabGroup> = {};
@@ -552,9 +636,14 @@ export const useTabStore = create<TabStore>()(
router: createTabRouter(clean),
historyIndex: 0,
historyLength: 1,
pinned: pTab.pinned === true,
});
}
if (tabs.length === 0) continue;
// Enforce the "pinned first" invariant on rehydration in case a
// user (or a buggy older write) persisted the pinned tabs out of
// order. Stable sort preserves intra-group order.
tabs.sort((a, b) => (a.pinned === b.pinned ? 0 : a.pinned ? -1 : 1));
const activeTabId = tabs.some((t) => t.id === pGroup.activeTabId)
? pGroup.activeTabId
: tabs[0].id;
@@ -605,6 +694,38 @@ interface V2Persisted {
byWorkspace: Record<string, V2PersistedGroup>;
}
interface V3PersistedTab {
id: string;
path: string;
title: string;
icon: string;
pinned: boolean;
}
interface V3PersistedGroup {
tabs: V3PersistedTab[];
activeTabId: string;
}
interface V3Persisted {
activeWorkspaceSlug: string | null;
byWorkspace: Record<string, V3PersistedGroup>;
}
export function migrateV2ToV3(v2: V2Persisted): V3Persisted {
const byWorkspace: Record<string, V3PersistedGroup> = {};
for (const [slug, group] of Object.entries(v2.byWorkspace ?? {})) {
byWorkspace[slug] = {
activeTabId: group.activeTabId,
tabs: group.tabs.map((t) => ({ ...t, pinned: false })),
};
}
return {
activeWorkspaceSlug: v2.activeWorkspaceSlug ?? null,
byWorkspace,
};
}
export function migrateV1ToV2(v1: Partial<V1Persisted>): V2Persisted {
const byWorkspace: Record<string, V2PersistedGroup> = {};
const oldTabs = v1.tabs ?? [];

View File

@@ -0,0 +1,27 @@
import { describe, expect, it } from "vitest";
import {
isNavigationGesture,
navigationGestureFromSwipe,
} from "./navigation-gestures";
describe("navigationGestureFromSwipe", () => {
it("maps horizontal macOS swipe directions to browser-style history", () => {
expect(navigationGestureFromSwipe("right")).toBe("back");
expect(navigationGestureFromSwipe("left")).toBe("forward");
});
it("ignores vertical and unknown directions", () => {
expect(navigationGestureFromSwipe("up")).toBeNull();
expect(navigationGestureFromSwipe("down")).toBeNull();
expect(navigationGestureFromSwipe("sideways")).toBeNull();
});
});
describe("isNavigationGesture", () => {
it("accepts only the renderer navigation gestures", () => {
expect(isNavigationGesture("back")).toBe(true);
expect(isNavigationGesture("forward")).toBe(true);
expect(isNavigationGesture("right")).toBe(false);
expect(isNavigationGesture(null)).toBe(false);
});
});

View File

@@ -0,0 +1,15 @@
export const NAVIGATION_GESTURE_CHANNEL = "navigation:gesture";
export type NavigationGesture = "back" | "forward";
export function isNavigationGesture(value: unknown): value is NavigationGesture {
return value === "back" || value === "forward";
}
export function navigationGestureFromSwipe(
direction: string,
): NavigationGesture | null {
if (direction === "right") return "back";
if (direction === "left") return "forward";
return null;
}

View File

@@ -3,7 +3,7 @@
import Link from "next/link";
import {
createContext,
useContext,
use,
type AnchorHTMLAttributes,
type ReactNode,
} from "react";
@@ -30,7 +30,7 @@ export function DocsLocaleProvider({
}
export function useDocsLocale(): Lang {
return useContext(DocsLocaleContext);
return use(DocsLocaleContext);
}
// Drop-in replacement for the MDX-rendered `<a>` element. Keeps the same

View File

@@ -21,7 +21,7 @@ The form has only two required fields: **name** (unique within the workspace) an
## Pick an AI coding tool
Each runtime is backed by a specific AI coding tool. Multica supports 11 of them. The most common choices:
Each runtime is backed by a specific AI coding tool. Multica supports 12 of them. The most common choices:
| Tool | Good for |
|---|---|
@@ -31,7 +31,7 @@ Each runtime is backed by a specific AI coding tool. Multica supports 11 of them
| **Copilot** | Teams leveraging their GitHub account entitlements |
| **Gemini** | Users in the Google ecosystem |
The other six (Hermes, Kimi, Kiro CLI, OpenCode, Pi, OpenClaw), along with each tool's full capability matrix (session resume, MCP, skill injection path, model selection), are covered in [AI coding tools comparison](/providers).
The other seven (Antigravity, Hermes, Kimi, Kiro CLI, OpenCode, Pi, OpenClaw), along with each tool's full capability matrix (session resume, MCP, skill injection path, model selection), are covered in [AI coding tools comparison](/providers).
## Writing system instructions
@@ -64,9 +64,9 @@ ANTHROPIC_BASE_URL = https://my-proxy.example.com
System-critical variables cannot be overridden: `PATH`, `HOME`, `USER`, `SHELL`, `TERM`, `CODEX_HOME`, and any key starting with `MULTICA_*` are silently ignored by the daemon (with a warn log — no error).
<Callout type="warning">
**Values in `custom_env` are stored in plaintext in Multica's server database.** Non-creators and non-workspace-admins can't see the values (the API returns `****`), but they're still visible in database backups and DB audits.
**Values in `custom_env` are stored in plaintext in Multica's server database.** Agent list/get responses no longer carry env values at all — only an opaque count. Reading values requires a workspace owner or admin to hit the dedicated, audited `GET /api/agents/{id}/env` endpoint (CLI: `multica agent env get <id>`). Agents running tasks can NOT use their host's owner credentials to reveal env on other agents — the endpoint denies agent-actor sessions.
**Don't put high-value secrets in `custom_env`** (production database passwords, root-level tokens, etc.). Use **dedicated, limited-scope credentials** for agents (read-only API keys, single-scope PATs), and rotate them regularly.
**Don't put high-value secrets in `custom_env`** (production database passwords, root-level tokens, etc.). Use **dedicated, limited-scope credentials** for agents (read-only API keys, single-scope PATs), and rotate them regularly. Database backups and DB audits remain a meaningful exposure surface.
</Callout>
## Custom CLI arguments (custom_args)
@@ -96,7 +96,7 @@ Arguments are passed as-is, not through a shell (no injection risk), but whether
New agents default to `private`.
**Private does not mean hidden** — every member sees a private agent's name and description in the list, they just can't see sensitive config fields (the values in `custom_env` and MCP config are masked). Full meaning in [Agents → Who can assign an agent](/agents#who-can-assign-an-agent).
**Private does not mean hidden** — every member sees a private agent's name and description in the list, they just can't read sensitive config (env values never appear in agent list/get responses; MCP config is masked for non-owners). Full meaning in [Agents → Who can assign an agent](/agents#who-can-assign-an-agent).
## Concurrency limit
@@ -123,5 +123,5 @@ Archived agents can't be assigned new tasks.
## Next steps
- [Skills](/skills) — attach knowledge packs to an agent
- [AI coding tools comparison](/providers) — full capability matrix across all 11 tools
- [AI coding tools comparison](/providers) — full capability matrix across all 12 tools
- [Assigning issues to agents](/assigning-issues) — put your new agent to work

View File

@@ -21,7 +21,7 @@ multica agent create
## 选一款 AI 编程工具
运行时背后是一款具体的 AI 编程工具。Multica 支持 11 款,最常用的几款:
运行时背后是一款具体的 AI 编程工具。Multica 支持 12 款,最常用的几款:
| 工具 | 适合 |
|---|---|
@@ -31,7 +31,7 @@ multica agent create
| **Copilot** | 用 GitHub 账号权益的团队 |
| **Gemini** | Google 生态用户 |
另外 6Hermes、Kimi、Kiro CLI、OpenCode、Pi、OpenClaw以及每款工具的完整能力差别会话恢复、MCP、skill 注入路径、模型选择)见 [AI 编程工具对照](/providers)。
另外 7 款(Antigravity、Hermes、Kimi、Kiro CLI、OpenCode、Pi、OpenClaw以及每款工具的完整能力差别会话恢复、MCP、skill 注入路径、模型选择)见 [AI 编程工具对照](/providers)。
## 写系统指令
@@ -64,7 +64,7 @@ ANTHROPIC_BASE_URL = https://my-proxy.example.com
系统关键变量不能被覆盖:`PATH`、`HOME`、`USER`、`SHELL`、`TERM`、`CODEX_HOME`,以及任何 `MULTICA_*` 开头的 key 都会被守护进程静默忽略(日志里有 warn不会报错
<Callout type="warning">
**`custom_env` 的值在 Multica 服务器的数据库里是明文存储的。** 非智能体创建者 / 非 workspace admin 看不到值API 返回 `****`),但数据库备份、DB 审计里仍然能看到。
**`custom_env` 的值在 Multica 服务器的数据库里是明文存储的。** agent list/get 接口不再返回任何 env 值——只返回键的数量。读取真实值需要 workspace owner / admin 调用专用且会审计的 `GET /api/agents/{id}/env`CLI: `multica agent env get <id>`)。正在执行任务的智能体即便宿主是 owner也无法借此读取其它智能体的 env——这个接口拒绝 agent-actor 会话。数据库备份、DB 审计里仍然能看到真实值
**不要把高价值 secret 放进 `custom_env`**生产数据库密码、root 级 token 等)。给智能体用**独立的、有限权限的凭证**(只读 API key、单 scope 的 PAT定期轮换。
</Callout>
@@ -96,7 +96,7 @@ claude --model <model> --max-turns 100 --append-system-prompt "always respond in
新建默认 `private`。
**私有不等于隐藏**——列表里所有成员都能看到私有智能体的名字和描述,只是看不到敏感配置字段(`custom_env`、MCP 配置的值被打码)。完整含义见 [智能体 → 谁能把智能体分配出去](/agents#谁能把智能体分配出去)。
**私有不等于隐藏**——列表里所有成员都能看到私有智能体的名字和描述,只是看不到敏感配置env 值从不会出现在 agent list/get 响应里MCP 配置对非 owner 会被打码)。完整含义见 [智能体 → 谁能把智能体分配出去](/agents#谁能把智能体分配出去)。
## 并发上限
@@ -123,5 +123,5 @@ claude --model <model> --max-turns 100 --append-system-prompt "always respond in
## 下一步
- [Skills](/skills) —— 给智能体挂专业知识包
- [AI 编程工具对照](/providers) —— 11 款工具的完整能力差别
- [AI 编程工具对照](/providers) —— 12 款工具的完整能力差别
- [把 issue 分配给智能体](/assigning-issues) —— 创建完之后怎么用起来

View File

@@ -5,7 +5,7 @@ description: "An agent is a first-class member of a Multica workspace — it can
import { Callout } from "fumadocs-ui/components/callout";
An agent is a **first-class member** of a Multica [workspace](/workspaces) — like a human, it can be [assigned issues](/assigning-issues), speak up in [comments](/comments), be [`@`-mentioned](/mentioning-agents), and lead a [project](/issues). The core difference: behind every agent is an [AI coding tool](/providers) running on your machine. Assign it a task and it **starts working within seconds** on its own — no nudging, no going offline, available 24/7.
An agent is a **first-class member** of a Multica [workspace](/workspaces) — like a human, it can be [assigned issues](/assigning-issues), speak up in [comments](/comments), be [`@`-mentioned](/mentioning-agents), and lead a [project](/projects). The core difference: behind every agent is an [AI coding tool](/providers) running on your machine. Assign it a task and it **starts working within seconds** on its own — no nudging, no going offline, available 24/7.
## What an agent can do
@@ -14,7 +14,7 @@ Agents use the same "member" surface as humans, and the UI barely distinguishes
- **[Be assigned issues](/assigning-issues)** — once set as the assignee, it starts working automatically
- **[Be `@`-mentioned](/mentioning-agents)** — write `@agent-name` in a comment and it wakes up to read that comment
- **Post [comments](/comments)** — it reports progress and replies to people under the issue
- **Lead a [project](/issues)** — it can be set as project lead, same as a human
- **Lead a [project](/projects)** — it can be set as project lead, same as a human
- **Open [issues](/issues) itself** — while running a task, if it spots a related problem, it can create a new issue directly
From the collaboration view, an agent is just a member of the workspace — its name sits in the same member list as humans, usually with a small robot icon in front.

View File

@@ -5,7 +5,7 @@ description: 智能体agent是 Multica 工作区里的一等公民成员
import { Callout } from "fumadocs-ui/components/callout";
智能体agent是 Multica [工作区](/workspaces) 里的**一等公民成员**——和人一样能被 [分配 issue](/assigning-issues)、在 [评论](/comments) 里发言、被 [`@` 点名](/mentioning-agents)、作为 [project](/issues) 的负责人。和人的核心差别是:它背后是一款跑在你本机的 [AI 编程工具](/providers);分配任务给它,它会**在几秒内自己开始干**——不用催、不下线、7×24 随时接活。
智能体agent是 Multica [工作区](/workspaces) 里的**一等公民成员**——和人一样能被 [分配 issue](/assigning-issues)、在 [评论](/comments) 里发言、被 [`@` 点名](/mentioning-agents)、作为 [project](/projects) 的负责人。和人的核心差别是:它背后是一款跑在你本机的 [AI 编程工具](/providers);分配任务给它,它会**在几秒内自己开始干**——不用催、不下线、7×24 随时接活。
## 智能体能做什么
@@ -14,7 +14,7 @@ import { Callout } from "fumadocs-ui/components/callout";
- **[被分配 issue](/assigning-issues)** —— 作为 assignee分配后它会自动开工
- **[被 `@` 点名](/mentioning-agents)** —— 在评论里写 `@agent-name`,它会被立刻唤醒去看这条评论
- **发 [评论](/comments)** —— 它会在 issue 底下汇报进展、回复别人
- **作为 [project](/issues) 的负责人** —— 和人一样能被设为 project lead
- **作为 [project](/projects) 的负责人** —— 和人一样能被设为 project lead
- **自己开 [issue](/issues)** —— 跑任务时如果发现了关联问题,它能直接创建新的 issue
从协作视图上看,智能体就是工作区里的一个成员;它和人的名字排在同一张成员列表里,只是前面通常有一个机器人图标。

View File

@@ -29,18 +29,39 @@ The user enters an email on the sign-in page → the server sends a 6-digit code
### Option B: SMTP relay (for self-hosted / on-premise deployments)
Use this when the deployment can't reach `api.resend.com` or you already have an internal mail relay (Exchange, Postfix, on-prem SendGrid, etc.). `SMTP_HOST` takes priority over `RESEND_API_KEY` when both are set.
Use this when the deployment can't reach `api.resend.com` or you already have an internal mail relay (Microsoft Exchange, Postfix, on-prem SendGrid, etc.). `SMTP_HOST` takes priority over `RESEND_API_KEY` when both are set — if `SMTP_HOST` is non-empty the server always goes through SMTP, even if `RESEND_API_KEY` is also configured, so verification and invite mail never leaves the internal network.
The SMTP path supports the three relay modes most on-premise mail servers (notably Microsoft Exchange's receive connectors) expose:
| Mode | Port | Auth | TLS |
|---|---|---|---|
| Anonymous internal relay | `25` | none — submission is trusted by IP / subnet | none on the wire (internal segment only) |
| Authenticated submission | `587` | `SMTP_USERNAME` + `SMTP_PASSWORD` | STARTTLS, upgraded automatically |
| Implicit TLS (SMTPS) | `465` | — | **not supported yet** — use port 25 or 587 |
**Anonymous Exchange relay on port 25** — the typical "internal SMTP relay" / Exchange anonymous receive connector that accepts mail from a trusted subnet without credentials:
```bash
SMTP_HOST=smtp.internal.example.com
SMTP_PORT=587 # default 25; use 587 for STARTTLS submission
SMTP_USERNAME=multica # leave empty for unauthenticated relay
SMTP_PASSWORD=...
SMTP_TLS_INSECURE=false # set true only for self-signed / private CA
SMTP_HOST=exchange.internal.example.com
SMTP_PORT=25
SMTP_USERNAME=
SMTP_PASSWORD=
SMTP_TLS_INSECURE=false
RESEND_FROM_EMAIL=noreply@yourdomain.com # reused as the From: header
```
STARTTLS is upgraded automatically when the server advertises it. Port 465 (SMTPS / implicit TLS) is **not** currently supported — use port 25 or 587.
**Authenticated submission on port 587** — for relays that require a service account; STARTTLS is upgraded automatically when the server advertises it:
```bash
SMTP_HOST=smtp.internal.example.com
SMTP_PORT=587
SMTP_USERNAME=multica
SMTP_PASSWORD=...
SMTP_TLS_INSECURE=false # set true only for self-signed / private CA
RESEND_FROM_EMAIL=noreply@yourdomain.com
```
At startup the server prints which provider it picked — for example `EmailService: SMTP relay exchange.internal.example.com:25 from=noreply@example.com` (or `Resend API` / `DEV mode`). The password is never logged. If you don't see the SMTP line after restart, `SMTP_HOST` didn't reach the process — check the container env (`docker compose -f docker-compose.selfhost.yml exec backend env | grep SMTP`).
**What happens if you set neither**: the server doesn't error, but **every email that should have been sent is written to the server's stdout only**. Handy for local development (copy the code from the logs); in production it's a black hole.

View File

@@ -29,18 +29,39 @@ Multica 支持两种登录方式:**Email + 验证码**(默认)和 **Google
### Option BSMTP relay内网/自部署)
适合内网无法访问 `api.resend.com`或者已经有内部邮件中继Exchange、Postfix、自部署 SendGrid 等)的场景。同时设置时 `SMTP_HOST` 优先级高于 `RESEND_API_KEY`。
适合内网无法访问 `api.resend.com`,或者已经有内部邮件中继(Microsoft Exchange、Postfix、自部署 SendGrid 等)的场景。同时设置时 `SMTP_HOST` 优先级高于 `RESEND_API_KEY`:只要 `SMTP_HOST` 非空server 一律走 SMTP 路径,即便 `RESEND_API_KEY` 也配着,验证码和邀请邮件也不会经过 Resend 外发出内网
SMTP 路径覆盖大多数本地邮件服务器(特别是 Microsoft Exchange 的 receive connector暴露的三种 relay 模式:
| 模式 | 端口 | 认证 | TLS |
|---|---|---|---|
| 匿名内部 relay | `25` | 无 —— 按 IP / 子网信任 | 链路上无 TLS仅限内网段 |
| 认证提交submission | `587` | `SMTP_USERNAME` + `SMTP_PASSWORD` | STARTTLS自动升级 |
| 隐式 TLSSMTPS | `465` | —— | **暂不支持** —— 请用 25 或 587 |
**匿名 Exchange relay端口 25** —— 经典的 "internal SMTP relay" / Exchange 匿名 receive connector按可信子网放行不要求凭据
```bash
SMTP_HOST=smtp.internal.example.com
SMTP_PORT=587 # 默认 25STARTTLS 提交端口用 587
SMTP_USERNAME=multica # 留空则使用未认证 relay
SMTP_PASSWORD=...
SMTP_TLS_INSECURE=false # 仅在私有 CA / 自签证书时改成 true
SMTP_HOST=exchange.internal.example.com
SMTP_PORT=25
SMTP_USERNAME=
SMTP_PASSWORD=
SMTP_TLS_INSECURE=false
RESEND_FROM_EMAIL=noreply@yourdomain.com # 同时作为 SMTP From: 头
```
服务端 advertise STARTTLS 时会自动升级。**暂不支持** 465SMTPS / 隐式 TLS请使用 25 或 587。
**认证提交,端口 587** —— 需要 service account 的 relay服务端 advertise STARTTLS 时会自动升级:
```bash
SMTP_HOST=smtp.internal.example.com
SMTP_PORT=587
SMTP_USERNAME=multica
SMTP_PASSWORD=...
SMTP_TLS_INSECURE=false # 仅在私有 CA / 自签证书时改成 true
RESEND_FROM_EMAIL=noreply@yourdomain.com
```
启动时 server 会打印当前选择的 provider比如 `EmailService: SMTP relay exchange.internal.example.com:25 from=noreply@example.com`(或 `Resend API` / `DEV mode`),密码不会出现在日志里。重启后没看到 SMTP 这行,说明 `SMTP_HOST` 没进到进程,确认下容器环境(`docker compose -f docker-compose.selfhost.yml exec backend env | grep SMTP`)。
**两种都不配**server 不报错,但所有本该发出去的邮件**只打到 server 的 stdout**。本地开发方便(你从日志抄验证码),生产环境等于黑洞。

View File

@@ -99,6 +99,126 @@ nothing matches, the event is `webhook.received`.
When configuring GitHub or similar sources, set the content type to
`application/json` — form-encoded webhook payloads are not accepted.
### Event filters
A new webhook trigger fires on every inbound POST, which is fine for a
single-purpose URL but noisy for sources that fan out many event types
(GitHub being the obvious one — a single repo webhook can deliver
`push`, `pull_request`, `workflow_run`, `check_suite`, and more). The
**Event filters** section on a webhook trigger lets you restrict which
events actually dispatch a run; everything else is recorded in delivery
history with `status = ignored` and `reason = event_filtered`, and no
run or issue is created.
Each row is one rule: an **event name** plus an optional
comma-separated **actions** list. Multica allows a webhook if **any**
row matches; leave the section empty to accept everything (the
pre-filter behavior).
Examples:
| Event name | Actions | Matches |
| -------------- | ------------------- | ------------------------------------------------------------------------ |
| `workflow_run` | `completed, failed` | `workflow_run` events with `action: completed` or `action: failed` only |
| `workflow_run` | _(empty)_ | every `workflow_run` event, regardless of action |
| `push` | _(empty)_ | every `push` event |
#### Where the event name and action come from
Multica derives the `event` name and `action` from the inbound request
in this order — **the first match wins**.
**1. Body envelope.** If the body is a JSON object with a string
`event` field, that value is the event name directly. An optional
`eventPayload` object then supplies action candidates from its
`action` / `state` / `conclusion` / `status` fields.
```bash
curl -X POST "$MULTICA_WEBHOOK_URL" \
-H 'Content-Type: application/json' \
-d '{"event":"trigger","eventPayload":{"action":"true"}}'
# inferred: event = trigger, action candidate = true
```
**2. Headers.** When no body envelope is present, Multica reads the
following well-known provider headers:
- `X-GitHub-Event: <event>` — combined with the top-level body
`action` field (when present) to form `github.<event>.<action>`.
- `X-Gitlab-Event: <event>` — becomes `gitlab.<event>`.
- `X-Event-Type: <event>` — passed through verbatim.
```bash
# GitHub-style: header gives the event name, body gives the action.
curl -X POST "$MULTICA_WEBHOOK_URL" \
-H 'X-GitHub-Event: workflow_run' \
-H 'Content-Type: application/json' \
-d '{"action":"completed"}'
# inferred: event = github.workflow_run.completed
# → matches a filter row of workflow_run / completed
# Generic event-type header — no body fields needed.
curl -X POST "$MULTICA_WEBHOOK_URL" \
-H 'X-Event-Type: trigger.true' \
-H 'Content-Type: application/json' \
-d '{}'
# inferred: event = trigger.true → matches trigger / true
```
**3. Body fallback.** If neither a body envelope nor a known header is
present, Multica falls back to top-level body string fields in this
order: `event` → `type` → `action`.
```bash
curl -X POST "$MULTICA_WEBHOOK_URL" \
-H 'Content-Type: application/json' \
-d '{"type":"trigger","action":"true"}'
# inferred: event = trigger (from `type`), action candidate = true
```
**4. Default.** If nothing above matches, the event is
`webhook.received` and there are no action candidates.
**Action candidates, in full.** Once the event is determined, Multica
considers every value below as a possible action match:
- The event-name suffix, when the event has the form
`provider.event.<action>` (e.g. `github.workflow_run.completed` →
`completed`).
- The body fields `action`, `state`, `conclusion`, and `status` —
**only when they are JSON strings**. A boolean (`{"action": true}`)
or a number does not qualify, so a filter expecting
`event=trigger, action=true` will never match a body of
`{"trigger": true}` because `true` is a bool, not a string.
**Common gotcha.** A filter row like `Event name: trigger` /
`Actions: true` does **not** mean "fire when the body has
`trigger: true`" — Event filters match the *inferred event and
action*, not arbitrary body fields. Send `trigger.true` via
`X-Event-Type` (or use the body envelope shown above) to hit it.
Surrounding whitespace in saved filter rows (`" workflow_run "`) is
stored verbatim and will never match — trim before saving.
#### Quick test
Once a filter is configured, you can confirm both branches with `curl`:
```bash
# Allowed — header drives event=workflow_run, body drives action=completed
curl -X POST "$MULTICA_WEBHOOK_URL" \
-H 'X-GitHub-Event: workflow_run' \
-H 'Content-Type: application/json' \
-d '{"action":"completed"}'
# → 200 {"status":"accepted", ...}
# Filtered — same event, action not in allowlist
curl -X POST "$MULTICA_WEBHOOK_URL" \
-H 'X-GitHub-Event: workflow_run' \
-H 'Content-Type: application/json' \
-d '{"action":"in_progress"}'
# → 200 {"status":"ignored","reason":"event_filtered"}
```
### URL is a bearer secret
The generated URL **is** the credential. Anyone with it can fire the

View File

@@ -98,6 +98,116 @@ curl -X POST "$MULTICA_WEBHOOK_URL" \
配置 GitHub 之类的来源时,请把 content type 设为 `application/json`——
表单编码的 webhook payload 在 v1 里不接受。
### 事件过滤
新建的 webhook 触发器对每一条入站 POST 都会触发,这在单一用途的 URL
上没问题,但对那种会扇出很多事件类型的来源(典型就是 GitHub——
一个仓库 webhook 就会同时下发 `push`、`pull_request`、`workflow_run`、
`check_suite` 等等就会很吵。webhook 触发器上的**事件过滤**区块用来
限制哪些事件真正派发一次 run其它的只记录到投递历史里
`status = ignored`、`reason = event_filtered`,不会建任何 issue 或 run。
每一行是一条规则:一个**事件名**加可选的、逗号分隔的 **action** 列表。
**任意一行命中**即放行;区块留空则接受所有事件(即过滤前的行为)。
例子:
| 事件名 | Actions | 命中范围 |
| ---------------- | ------------------- | ------------------------------------------------------------------------ |
| `workflow_run` | `completed, failed` | 只有 `action` 为 `completed` 或 `failed` 的 `workflow_run` 事件 |
| `workflow_run` | _(留空)_ | 所有 `workflow_run` 事件,不限 action |
| `push` | _(留空)_ | 所有 `push` 事件 |
#### 事件名和 action 从哪来
Multica 按下面的顺序从入站请求里推断 `event` 和 `action`**先命中先用**。
**1. Body envelope。** 如果 body 是一个 JSON 对象、且带字符串字段
`event`,就直接用它作为事件名。可选的 `eventPayload` 对象再
从自己的 `action` / `state` / `conclusion` / `status` 字段里提供
action 候选。
```bash
curl -X POST "$MULTICA_WEBHOOK_URL" \
-H 'Content-Type: application/json' \
-d '{"event":"trigger","eventPayload":{"action":"true"}}'
# 推断结果event = triggeraction 候选 = true
```
**2. 请求头。** 没有 body envelope 时按以下头部识别:
- `X-GitHub-Event: <event>` —— 结合 body 顶层的 `action` 字段
(如果有),拼成 `github.<event>.<action>`。
- `X-Gitlab-Event: <event>` —— 拼成 `gitlab.<event>`。
- `X-Event-Type: <event>` —— 原样使用。
```bash
# GitHub 风格:事件名来自 headeraction 来自 body。
curl -X POST "$MULTICA_WEBHOOK_URL" \
-H 'X-GitHub-Event: workflow_run' \
-H 'Content-Type: application/json' \
-d '{"action":"completed"}'
# 推断结果event = github.workflow_run.completed
# → 命中 workflow_run / completed 的过滤规则
# 通用 event-type 头部 —— 不需要任何 body 字段。
curl -X POST "$MULTICA_WEBHOOK_URL" \
-H 'X-Event-Type: trigger.true' \
-H 'Content-Type: application/json' \
-d '{}'
# 推断结果event = trigger.true → 命中 trigger / true
```
**3. Body 兜底。** Body envelope 和上面的 header 都没有时,
从 body 顶层字符串字段里依次找:`event` → `type` → `action`。
```bash
curl -X POST "$MULTICA_WEBHOOK_URL" \
-H 'Content-Type: application/json' \
-d '{"type":"trigger","action":"true"}'
# 推断结果event = trigger取自 `type`action 候选 = true
```
**4. 默认值。** 以上都没命中时,事件名取 `webhook.received`
没有 action 候选。
**action 候选的完整清单。** 事件名确定后,下面这些值都会被列为
可能的 action
- 事件名后缀,当事件形如 `provider.event.<action>` 时
(例如 `github.workflow_run.completed` → `completed`)。
- body 里 `action`、`state`、`conclusion`、`status` 这四个字段——
**必须是 JSON 字符串**。布尔(`{"action": true}`)或数字都不算
候选,所以 `event=trigger, action=true` 的过滤规则**永远命中不了**
`{"trigger": true}` 这种 body因为 `true` 是 bool 不是字符串。
**常见误区。** 一条 `Event name: trigger` / `Actions: true` 的规则
**不是**「body 里出现 `trigger: true` 就放行」的意思——事件过滤匹配的是
**推断出来的事件和 action**,不是任意 body 字段。要命中它,请用
`X-Event-Type` 头发送 `trigger.true`,或者用上面的 body envelope。
保存时带空格的值(例如 `" workflow_run "`)会被原样保存,但永远命中
不了——保存前请先 trim。
#### 快速验证
配好过滤后,可以用 `curl` 同时验证「命中」和「被过滤」两条路径:
```bash
# 命中 —— 请求头给出 event=workflow_runbody 给出 action=completed
curl -X POST "$MULTICA_WEBHOOK_URL" \
-H 'X-GitHub-Event: workflow_run' \
-H 'Content-Type: application/json' \
-d '{"action":"completed"}'
# → 200 {"status":"accepted", ...}
# 被过滤 —— 同样的事件,但 action 不在白名单
curl -X POST "$MULTICA_WEBHOOK_URL" \
-H 'X-GitHub-Event: workflow_run' \
-H 'Content-Type: application/json' \
-d '{"action":"in_progress"}'
# → 200 {"status":"ignored","reason":"event_filtered"}
```
### URL 即 bearer secret
生成的 URL **就是凭证**,谁拿到都能触发这个 Autopilot。请按 token 对待:

View File

@@ -7,7 +7,7 @@ import { Callout } from "fumadocs-ui/components/callout";
This page walks you end-to-end through Multica Cloud — **sign up → install the [CLI](/cli) → start the [daemon](/daemon-runtimes) → create an [agent](/agents) → assign your first [task](/tasks)**. Takes about 5 minutes.
One prerequisite: you already have at least one [AI coding tool](/providers) installed locally ([Claude Code](/providers#claude-code), [Codex](/providers#codex), [Cursor](/providers#cursor), [Copilot](/providers#copilot), [Gemini](/providers#gemini), [Hermes](/providers#hermes), [Kimi](/providers#kimi), [Kiro CLI](/providers#kiro-cli), [OpenCode](/providers#opencode), [OpenClaw](/providers#openclaw), or [Pi](/providers#pi)). The daemon auto-detects them on startup and refuses to start if none are present.
One prerequisite: you already have at least one [AI coding tool](/providers) installed locally ([Antigravity](/providers#antigravity), [Claude Code](/providers#claude-code), [Codex](/providers#codex), [Cursor](/providers#cursor), [Copilot](/providers#copilot), [Gemini](/providers#gemini), [Hermes](/providers#hermes), [Kimi](/providers#kimi), [Kiro CLI](/providers#kiro-cli), [OpenCode](/providers#opencode), [OpenClaw](/providers#openclaw), or [Pi](/providers#pi)). The daemon auto-detects them on startup and refuses to start if none are present.
## 1. Create an account
@@ -72,7 +72,7 @@ multica daemon status
In the web UI, go to **Settings → Runtimes**. The daemon you just started should appear as one or more active runtimes — one per AI coding tool installed locally.
If it shows as offline, don't panic — see [Troubleshooting → Daemon can't reach the server](/troubleshooting#daemon-cant-reach-the-server).
If it shows as offline, don't panic — see [Troubleshooting → Daemon can't connect to the server](/troubleshooting#daemon-cant-connect-to-the-server).
## 5. Create an agent
@@ -114,6 +114,6 @@ The web UI updates in **real time** (via WebSocket) — no refresh needed.
- [Daemon and runtimes](/daemon-runtimes) — how the daemon operates and what runtimes mean
- [Tasks](/tasks) — task lifecycle and retry rules
- [AI coding tools compared](/providers) — capability differences across the 11 tools
- [AI coding tools compared](/providers) — capability differences across the 12 tools
- [Desktop app](/desktop-app) — if you'd rather not run the daemon yourself
- [Self-host quickstart](/self-host-quickstart) — run your own backend

View File

@@ -7,7 +7,7 @@ import { Callout } from "fumadocs-ui/components/callout";
这一页带你走一遍 Multica Cloud 的端到端流程——**注册 → 装 [命令行工具](/cli) → 启动 [守护进程](/daemon-runtimes) → 创建 [智能体](/agents) → 分配第一个 [任务](/tasks)**,约 5 分钟完成。
前置只有一个:你本地已经装了至少一款 [AI 编程工具](/providers)[Claude Code](/providers#claude-code)、[Codex](/providers#codex)、[Cursor](/providers#cursor)、[Copilot](/providers#copilot)、[Gemini](/providers#gemini)、[Hermes](/providers#hermes)、[Kimi](/providers#kimi)、[Kiro CLI](/providers#kiro-cli)、[OpenCode](/providers#opencode)、[OpenClaw](/providers#openclaw)、[Pi](/providers#pi))中的一款。守护进程启动时会自动探测它们,没装任何一个的话守护进程会直接拒绝启动。
前置只有一个:你本地已经装了至少一款 [AI 编程工具](/providers)[Antigravity](/providers#antigravity)、[Claude Code](/providers#claude-code)、[Codex](/providers#codex)、[Cursor](/providers#cursor)、[Copilot](/providers#copilot)、[Gemini](/providers#gemini)、[Hermes](/providers#hermes)、[Kimi](/providers#kimi)、[Kiro CLI](/providers#kiro-cli)、[OpenCode](/providers#opencode)、[OpenClaw](/providers#openclaw)、[Pi](/providers#pi))中的一款。守护进程启动时会自动探测它们,没装任何一个的话守护进程会直接拒绝启动。
## 1. 注册账号
@@ -114,6 +114,6 @@ Web 界面会**实时**(通过 WebSocket显示进度——不需要刷新
- [守护进程与运行时](/daemon-runtimes) —— 守护进程怎么运作、运行时概念
- [执行任务](/tasks) —— 任务生命周期、重试规则
- [AI 编程工具对照](/providers) —— 11 款工具的能力差异
- [AI 编程工具对照](/providers) —— 12 款工具的能力差异
- [桌面应用](/desktop-app) —— 不想自己跑守护进程的话
- [Self-Host 快速上手](/self-host-quickstart) —— 在自己服务器上跑一套

View File

@@ -21,7 +21,7 @@ multica daemon start
On startup it does four things:
1. Reads the credentials saved when you logged in
2. Detects AI coding tools installed on your `PATH` (11 built-in: [Claude Code](/providers#claude-code), [Codex](/providers#codex), [Cursor](/providers#cursor), [Copilot](/providers#copilot), [Gemini](/providers#gemini), [Hermes](/providers#hermes), [Kimi](/providers#kimi), [Kiro CLI](/providers#kiro-cli), [OpenCode](/providers#opencode), [OpenClaw](/providers#openclaw), [Pi](/providers#pi))
2. Detects AI coding tools installed on your `PATH` (12 built-in: [Antigravity](/providers#antigravity), [Claude Code](/providers#claude-code), [Codex](/providers#codex), [Cursor](/providers#cursor), [Copilot](/providers#copilot), [Gemini](/providers#gemini), [Hermes](/providers#hermes), [Kimi](/providers#kimi), [Kiro CLI](/providers#kiro-cli), [OpenCode](/providers#opencode), [OpenClaw](/providers#openclaw), [Pi](/providers#pi))
3. Registers itself with the server, along with a runtime for each detected tool
4. Keeps **polling every 3 seconds** for tasks to pick up, and **sends a heartbeat every 15 seconds**
@@ -108,4 +108,4 @@ More scenarios in [Troubleshooting](/troubleshooting).
## Next
- [Tasks](/tasks) — the full lifecycle of a task once the daemon picks it up
- [Providers Matrix](/providers) — capability differences across the 11 AI coding tools
- [Providers Matrix](/providers) — capability differences across the 12 AI coding tools

View File

@@ -21,7 +21,7 @@ multica daemon start
启动后它会做四件事:
1. 读取你登录时保存的凭证
2. 探测本机 `PATH` 上已安装的 AI 编程工具(内置支持 11 款:[Claude Code](/providers#claude-code)、[Codex](/providers#codex)、[Cursor](/providers#cursor)、[Copilot](/providers#copilot)、[Gemini](/providers#gemini)、[Hermes](/providers#hermes)、[Kimi](/providers#kimi)、[Kiro CLI](/providers#kiro-cli)、[OpenCode](/providers#opencode)、[OpenClaw](/providers#openclaw)、[Pi](/providers#pi)
2. 探测本机 `PATH` 上已安装的 AI 编程工具(内置支持 12 款:[Antigravity](/providers#antigravity)、[Claude Code](/providers#claude-code)、[Codex](/providers#codex)、[Cursor](/providers#cursor)、[Copilot](/providers#copilot)、[Gemini](/providers#gemini)、[Hermes](/providers#hermes)、[Kimi](/providers#kimi)、[Kiro CLI](/providers#kiro-cli)、[OpenCode](/providers#opencode)、[OpenClaw](/providers#openclaw)、[Pi](/providers#pi)
3. 向服务器注册自己,以及每款检测到的工具对应的运行时
4. 持续**每 3 秒轮询一次**是否有任务要领,**每 15 秒发一次心跳**
@@ -108,4 +108,4 @@ Multica 对并发有两层限额:
## 下一步
- [执行任务](/tasks) —— 守护进程领到任务后,它的完整生命周期
- [Providers Matrix](/providers) —— 11 款 AI 编程工具的能力差异对照
- [Providers Matrix](/providers) —— 12 款 AI 编程工具的能力差异对照

View File

@@ -133,6 +133,18 @@ Alternatively, configure step by step: `multica config set server_url http://loc
3. Go to **Settings → Agents** and create a new agent
4. Create an issue and assign it to your agent
## Usage Dashboard Rollup (Required)
Starting with `v0.3.5`, the Usage / Runtime dashboards read from a derived `task_usage_hourly` table populated by `rollup_task_usage_hourly()`. The bundled `pgvector/pgvector:pg17` image does **not** include `pg_cron`, and the backend doesn't run the rollup in-process either — until you schedule it yourself, the dashboard will stay at zero even though `task_usage` is populated.
Pick one supported path before relying on the Usage / Runtime dashboard:
- **External cron / systemd-timer / Kubernetes `CronJob`**: schedule `SELECT rollup_task_usage_hourly()` every 5 minutes. Idempotent, watermark-driven — overlapping or skipped ticks are safe.
- **Postgres with `pg_cron`**: swap the bundled Postgres image for one that ships `pg_cron`, set `shared_preload_libraries=pg_cron`, then `SELECT cron.schedule('rollup_task_usage_hourly', '*/5 * * * *', 'SELECT rollup_task_usage_hourly()')` once.
- **Backfill historical data**: required on the `v0.3.4 → v0.3.5+` upgrade path when the database already has `task_usage` rows — migration `103` is fail-closed and will abort `migrate up` with `refusing to drop legacy daily rollups: ...` until the hourly table is seeded. Run `./backfill_task_usage_hourly --sleep-between-slices=2s` inside the backend container, then re-run the upgrade and configure one of the schedules above.
Full reference (Compose + Kubernetes templates, flag descriptions, upgrade order) lives in [`SELF_HOSTING_ADVANCED.md → Usage Dashboard Rollup`](https://github.com/multica-ai/multica/blob/main/SELF_HOSTING_ADVANCED.md#usage-dashboard-rollup).
## Stopping Services
```bash
@@ -219,7 +231,7 @@ For file uploads and attachments, configure S3 and (optionally) CloudFront:
| `S3_BUCKET` | Bucket name only (e.g. `my-bucket`). Do **not** include the `.s3.<region>.amazonaws.com` suffix — the server constructs the public URL from `S3_BUCKET` + `S3_REGION` |
| `S3_REGION` | AWS region (default: `us-west-2`). Must match the bucket's actual region — used for both SDK signing and public URLs |
| `AWS_ACCESS_KEY_ID` / `AWS_SECRET_ACCESS_KEY` | Static credentials. When both are unset, the AWS SDK default credential chain is used |
| `AWS_ENDPOINT_URL` | Custom S3-compatible endpoint (e.g. MinIO, R2, B2). Setting this switches the public URL to path-style |
| `AWS_ENDPOINT_URL` | Custom S3-compatible endpoint (e.g. MinIO, R2, B2). Setting this switches to path-style URLs |
| `CLOUDFRONT_DOMAIN` | CloudFront distribution domain — when set, public URLs use this host instead of the S3 host |
| `CLOUDFRONT_KEY_PAIR_ID` | CloudFront key pair ID for signed URLs |
| `CLOUDFRONT_PRIVATE_KEY` | CloudFront private key (PEM format) |

View File

@@ -13,7 +13,7 @@ Multica is a **distributed** platform. The web interface you see is just the fro
- **Multica server** — the workspaces, issue lists, and comment threads you see all live in its database. It's also a WebSocket hub that pushes real-time updates between you and your teammates. It does **not** execute any agent tasks.
- **Daemon** — part of the Multica CLI, running on your own machine. On start it detects which AI coding tools are installed locally, registers with the server, and begins polling for tasks every 3 seconds and sending heartbeats every 15 seconds.
- **AI coding tools** — one of the eleven (or several in parallel): [Claude Code](/providers#claude-code), [Codex](/providers#codex), [Cursor](/providers#cursor), [Copilot](/providers#copilot), [Gemini](/providers#gemini), [Hermes](/providers#hermes), [Kimi](/providers#kimi), [Kiro CLI](/providers#kiro-cli), [OpenCode](/providers#opencode), [OpenClaw](/providers#openclaw), [Pi](/providers#pi). Once the daemon has picked up a task, it uses these tools to actually do the work.
- **AI coding tools** — one of the twelve (or several in parallel): [Antigravity](/providers#antigravity), [Claude Code](/providers#claude-code), [Codex](/providers#codex), [Cursor](/providers#cursor), [Copilot](/providers#copilot), [Gemini](/providers#gemini), [Hermes](/providers#hermes), [Kimi](/providers#kimi), [Kiro CLI](/providers#kiro-cli), [OpenCode](/providers#opencode), [OpenClaw](/providers#openclaw), [Pi](/providers#pi). Once the daemon has picked up a task, it uses these tools to actually do the work.
Because the toolchain stays local, **your API keys, code directories, and authorized tools** are only ever used on your machine — the Multica server never sees any of them. This holds whether you self-host or use Cloud.

View File

@@ -13,7 +13,7 @@ Multica 是一个**分布式**平台。你看到的 Web 界面只是前台——
- **Multica 服务器**——你看到的工作区、issue 列表、评论线都存在它的数据库里。它同时是 WebSocket hub把你和同事之间的实时更新推送过去。它**不**执行任何智能体任务。
- **守护进程**daemon——Multica CLI 的一部分,跑在你自己的机器上。启动后它探测本地装了哪些 AI 编程工具,注册到 server开始每 3 秒领一次任务、每 15 秒发一次心跳。
- **AI 编程工具**——[Claude Code](/providers#claude-code)、[Codex](/providers#codex)、[Cursor](/providers#cursor)、[Copilot](/providers#copilot)、[Gemini](/providers#gemini)、[Hermes](/providers#hermes)、[Kimi](/providers#kimi)、[Kiro CLI](/providers#kiro-cli)、[OpenCode](/providers#opencode)、[OpenClaw](/providers#openclaw)、[Pi](/providers#pi) 11 款之一(或多款并存)。守护进程领到任务后,用这些工具真正去写代码。
- **AI 编程工具**——[Antigravity](/providers#antigravity)、[Claude Code](/providers#claude-code)、[Codex](/providers#codex)、[Cursor](/providers#cursor)、[Copilot](/providers#copilot)、[Gemini](/providers#gemini)、[Hermes](/providers#hermes)、[Kimi](/providers#kimi)、[Kiro CLI](/providers#kiro-cli)、[OpenCode](/providers#opencode)、[OpenClaw](/providers#openclaw)、[Pi](/providers#pi) 12 款之一(或多款并存)。守护进程领到任务后,用这些工具真正去写代码。
工具链在本地的结果:**你的 API 密钥、代码目录、已授权的工具**都只在本地使用Multica 服务器一个都看不到。自部署还是用 Cloud 都不改变这一点。

View File

@@ -13,7 +13,7 @@ This page explains where agents run and the ways you can start using Multica.
Agents do **not** execute tasks on Multica's servers. Multica currently supports one runtime model:
- **Local [daemon](/daemon-runtimes)** — you run `multica daemon` on your own machine, and it drives the [AI coding tools](/providers) installed locally. Eleven are built in today: [Claude Code](/providers#claude-code), [Codex](/providers#codex), [Cursor](/providers#cursor), [Copilot](/providers#copilot), [Gemini](/providers#gemini), [Hermes](/providers#hermes), [Kimi](/providers#kimi), [Kiro CLI](/providers#kiro-cli), [OpenCode](/providers#opencode), [OpenClaw](/providers#openclaw), [Pi](/providers#pi). Your API keys, toolchain, and code directories stay on your machine.
- **Local [daemon](/daemon-runtimes)** — you run `multica daemon` on your own machine, and it drives the [AI coding tools](/providers) installed locally. Twelve are built in today: [Antigravity](/providers#antigravity), [Claude Code](/providers#claude-code), [Codex](/providers#codex), [Cursor](/providers#cursor), [Copilot](/providers#copilot), [Gemini](/providers#gemini), [Hermes](/providers#hermes), [Kimi](/providers#kimi), [Kiro CLI](/providers#kiro-cli), [OpenCode](/providers#opencode), [OpenClaw](/providers#openclaw), [Pi](/providers#pi). Your API keys, toolchain, and code directories stay on your machine.
<Callout type="info">
**Cloud runtimes are coming**, currently waitlist-only. Once live, you won't need a local daemon — agent tasks will execute on Multica Cloud directly. Sign up on the [Downloads](https://multica.ai/download) page to get notified.

View File

@@ -13,7 +13,7 @@ Multica 是一个任务协作平台,让人类和 AI [智能体](/agents) 在
智能体执行任务**不**发生在 Multica 服务器上。目前 Multica 支持一种运行方式:
- **本地 [守护进程](/daemon-runtimes)** — 你在自己的机器上运行 `multica daemon`,由它调用本地安装的 [AI 编程工具](/providers)。目前内置 11 款:[Claude Code](/providers#claude-code)、[Codex](/providers#codex)、[Cursor](/providers#cursor)、[Copilot](/providers#copilot)、[Gemini](/providers#gemini)、[Hermes](/providers#hermes)、[Kimi](/providers#kimi)、[Kiro CLI](/providers#kiro-cli)、[OpenCode](/providers#opencode)、[OpenClaw](/providers#openclaw)、[Pi](/providers#pi)。你的 API 密钥、工具链、代码目录都保留在本地。
- **本地 [守护进程](/daemon-runtimes)** — 你在自己的机器上运行 `multica daemon`,由它调用本地安装的 [AI 编程工具](/providers)。目前内置 12 款:[Antigravity](/providers#antigravity)、[Claude Code](/providers#claude-code)、[Codex](/providers#codex)、[Cursor](/providers#cursor)、[Copilot](/providers#copilot)、[Gemini](/providers#gemini)、[Hermes](/providers#hermes)、[Kimi](/providers#kimi)、[Kiro CLI](/providers#kiro-cli)、[OpenCode](/providers#opencode)、[OpenClaw](/providers#openclaw)、[Pi](/providers#pi)。你的 API 密钥、工具链、代码目录都保留在本地。
<Callout type="info">
**云端运行时即将开放**,目前处于等待名单阶段。上线后,你无需在本地运行守护进程,即可在 Multica Cloud 上直接执行智能体任务。在 [下载页面](https://multica.ai/download) 登记邮箱以获取通知。

View File

@@ -1,11 +1,11 @@
---
title: Install an agent runtime
description: Multica drives whichever AI coding tools you have on your machine. This page shows you how to install each of the 11 supported tools so the daemon can detect them.
description: Multica drives whichever AI coding tools you have on your machine. This page shows you how to install each of the 12 supported tools so the daemon can detect them.
---
import { Callout } from "fumadocs-ui/components/callout";
A **runtime** in Multica is the daemon on your machine paired with one AI coding tool the daemon found on your `PATH`. If the onboarding "Connect a runtime" step shows **No supported tools detected**, it means the daemon scanned `PATH` and didn't find any of the 11 tools it knows how to drive. Install one (or several) of the tools below, then come back to the step and re-scan — the runtime will show up within a few seconds.
A **runtime** in Multica is the daemon on your machine paired with one AI coding tool the daemon found on your `PATH`. If the onboarding "Connect a runtime" step shows **No supported tools detected**, it means the daemon scanned `PATH` and didn't find any of the 12 tools it knows how to drive. Install one (or several) of the tools below, then come back to the step and re-scan — the runtime will show up within a few seconds.
This page is the install-side companion to:
@@ -31,9 +31,9 @@ multica daemon restart
Or, in the desktop app, just relaunch the app. The daemon re-scans `PATH` on every start.
## The 11 supported tools
## The 12 supported tools
Listed roughly from most to least common. Pick whichever ones you already have credentials for — you don't need all 11.
Listed roughly from most to least common. Pick whichever ones you already have credentials for — you don't need all 12.
### Claude Code (Anthropic)
@@ -147,9 +147,20 @@ Minimalist. **Session resumption is unusual** — the resume id is the path to a
| Install | See Inflection's CLI docs at [pi.ai](https://pi.ai/). |
| Authentication | Per the vendor's docs. |
### Antigravity (Google)
Google's Antigravity CLI (`agy`). Pairs with Google's Antigravity service and runs Gemini-backed models. Session resumption works through `--conversation <id>`, captured by the daemon from the CLI log file. Model selection is managed inside the Antigravity CLI itself — Multica disables the per-agent model picker for this provider. Skills are written to `.agents/skills/` (the CLI inherits Gemini CLI's workspace skill layout — see [Antigravity docs](https://antigravity.google/docs/gcli-migration)).
| | |
|---|---|
| Daemon looks for | `agy` |
| Install | Follow the official guide at [antigravity.google/docs/cli-overview](https://antigravity.google/docs/cli-overview). The CLI ships pre-built — run `agy install` once to wire up PATH and shell aliases. |
| Authentication | Run `agy` once interactively and complete the Google account login, or sign in via the Antigravity desktop app — the CLI reuses the keyring entry the GUI writes. |
| Notes | The CLI emits plain assistant text on stdout, not a structured event stream; intermediate "I will run X" lines and the final reply are both relayed to Multica as text. |
## After installing
1. **Confirm the binary is on `PATH`.** Open a fresh terminal and run `which <name>` (for example `which claude`, `which cursor-agent`, `which kiro-cli`). If it prints a path, the daemon will find it. If it prints nothing, fix your shell `PATH` first (the typical cause is a per-shell rc file that wasn't reloaded).
1. **Confirm the binary is on `PATH`.** Open a fresh terminal and run `which <name>` (for example `which claude`, `which cursor-agent`, `which kiro-cli`, `which agy`). If it prints a path, the daemon will find it. If it prints nothing, fix your shell `PATH` first (the typical cause is a per-shell rc file that wasn't reloaded).
2. **Restart the daemon.** `multica daemon restart`, or relaunch the desktop app. The daemon only scans `PATH` at startup.
3. **Check the Runtimes page.** In the Multica UI, the **Runtimes** page should now list one row per `(workspace × tool)` combination. If the row says "offline", see [Daemon and runtimes → When a runtime is marked offline](/daemon-runtimes#when-a-runtime-is-marked-offline).
4. **Go back to onboarding.** The "Connect a runtime" step polls and will pick up the new runtime within a few seconds — no need to refresh.

View File

@@ -1,11 +1,11 @@
---
title: 安装一个 Agent 运行时
description: Multica 驱动本机上已安装的 AI 编程工具。这一页讲清楚怎么安装目前支持的 11 款工具,让守护进程能扫到。
description: Multica 驱动本机上已安装的 AI 编程工具。这一页讲清楚怎么安装目前支持的 12 款工具,让守护进程能扫到。
---
import { Callout } from "fumadocs-ui/components/callout";
在 Multica 里,一个**运行时**runtime就是你机器上的守护进程配上守护进程在 `PATH` 里扫到的某一款 AI 编程工具。如果 onboarding 的 "连接运行时" 这一步显示 **未检测到支持的工具**,说明守护进程扫了 `PATH`,但 11 款它认得的工具一个都没找到。装下面任意一款(或几款),回到这一步重新扫描,几秒内运行时就会出现。
在 Multica 里,一个**运行时**runtime就是你机器上的守护进程配上守护进程在 `PATH` 里扫到的某一款 AI 编程工具。如果 onboarding 的 "连接运行时" 这一步显示 **未检测到支持的工具**,说明守护进程扫了 `PATH`,但 12 款它认得的工具一个都没找到。装下面任意一款(或几款),回到这一步重新扫描,几秒内运行时就会出现。
这一页是装机的入口,和它配套的是:
@@ -31,13 +31,13 @@ multica daemon restart
桌面端的话,重启 app 即可。守护进程只在启动时扫一次 `PATH`。
## 11 款支持的工具
## 12 款支持的工具
大致按常见程度排序。挑你已经有账号 / API key 的那几款就行 —— 不需要 11 个全装。
大致按常见程度排序。挑你已经有账号 / API key 的那几款就行 —— 不需要 12 个全装。
### Claude CodeAnthropic
集成最完整的一款。会话续接好用MCP 好用,而且 **11 款里只有它真正会读 agent 配置里的 `mcp_config` 字段**(见[矩阵](/zh/providers))。
集成最完整的一款。会话续接好用MCP 好用,而且 **12 款里只有它真正会读 agent 配置里的 `mcp_config` 字段**(见[矩阵](/zh/providers))。
| | |
|---|---|
@@ -147,9 +147,20 @@ ACP 协议 agent和 Kimi 共享传输层。会话续接可用。Skill 注
| 安装 | 看 Inflection 的 CLI 文档 [pi.ai](https://pi.ai/)。 |
| 认证 | 按厂商文档。 |
### AntigravityGoogle
Google 的 Antigravity CLI`agy`)。搭配 Google Antigravity 服务,默认走 Gemini 系列模型。会话续接通过 `--conversation <id>` 工作——守护进程从 CLI 的日志文件里抓取 conversation UUID。模型选择保存在 Antigravity CLI 自己的设置里——Multica 里这款工具的「模型」选择项被禁用。Skill 文件写入 `.agents/skills/`CLI 沿用 Gemini CLI 的 workspace 布局——见 [Antigravity 文档](https://antigravity.google/docs/gcli-migration))。
| | |
|---|---|
| 守护进程扫描 | `agy` |
| 安装 | 看官方指引 [antigravity.google/docs/cli-overview](https://antigravity.google/docs/cli-overview)。CLI 是预编译的,跑一次 `agy install` 配好 PATH 和 shell 别名即可。 |
| 认证 | 交互式跑一次 `agy` 走 Google 账号登录流程;或者通过 Antigravity 桌面端登录——CLI 会复用 GUI 写入 keyring 的凭据。 |
| 备注 | CLI 的 stdout 是纯文本,不是结构化事件流;中间的 "I will run X" 思考过程和最终回复都会作为 text 消息送回 Multica。 |
## 装完之后
1. **确认可执行文件在 `PATH` 上。** 开一个新终端,跑 `which <名字>`(比如 `which claude`、`which cursor-agent`、`which kiro-cli`)。打印出路径,守护进程就找得到;什么都不打印,先修 shell 的 `PATH`(最常见原因是 rc 文件没重新加载)。
1. **确认可执行文件在 `PATH` 上。** 开一个新终端,跑 `which <名字>`(比如 `which claude`、`which cursor-agent`、`which kiro-cli`、`which agy`)。打印出路径,守护进程就找得到;什么都不打印,先修 shell 的 `PATH`(最常见原因是 rc 文件没重新加载)。
2. **重启守护进程。** `multica daemon restart`,或者重启桌面端。守护进程只在启动时扫一次 `PATH`。
3. **看 Runtimes 页面。** Multica UI 的 **Runtimes** 页应该会出现一行 `(工作区 × 工具)`。如果显示 "offline",看[守护进程与运行时 → 运行时何时被标记为离线](/zh/daemon-runtimes#运行时何时被标记为离线)。
4. **回到 onboarding。** "连接运行时" 这一步会一直轮询,几秒内就能扫到新运行时,不需要手动刷新。

View File

@@ -39,6 +39,7 @@
"cli",
"auth-tokens",
"desktop-app",
"mobile-app",
"---Developers---",
"developers"
]

View File

@@ -11,6 +11,7 @@
"issues",
"projects",
"comments",
"project-resources",
"---智能体---",
"agents",
"agents-create",
@@ -37,6 +38,7 @@
"cli",
"auth-tokens",
"desktop-app",
"mobile-app",
"---开发者---",
"developers"
]

View File

@@ -0,0 +1,82 @@
---
title: Mobile app (iOS)
description: How to build the open-source Multica iOS app on your own iPhone — no App Store yet.
---
import { Callout } from "fumadocs-ui/components/callout";
Multica's iOS client is open-source and lives in the [main repo](https://github.com/multica-ai/multica) alongside web, desktop, and backend. It isn't on the App Store yet — until that changes, anyone who wants it on their iPhone builds from source. The build takes about 1020 minutes the first time and ~2 minutes after that, and it talks to the same backend as [multica.ai](https://multica.ai) so your existing account just works.
<Callout type="info">
This page is for **personal use**. App developers should read [`apps/mobile/README.md`](https://github.com/multica-ai/multica/blob/main/apps/mobile/README.md) in the repo — it covers the dev / staging variants and the full script matrix.
</Callout>
## What you need
- A **Mac** with Xcode installed (free from the App Store).
- A free **Apple ID** added under Xcode → Settings → Accounts. A paid Apple Developer Program account is optional and only extends the 7-day signing window to 1 year — see [7-day limit](#7-day-signing-limit) below.
- An **iPhone** connected via USB cable, with [Developer Mode enabled](https://docs.expo.dev/guides/ios-developer-mode/) (Settings → Privacy & Security → Developer Mode).
- The Multica source code checked out:
```bash
git clone https://github.com/multica-ai/multica.git
cd multica
pnpm install
```
If anything in that list is missing, walk through Expo's [Set up your environment](https://docs.expo.dev/get-started/set-up-your-environment/) (pick **Development build → iOS Device**) — it's the canonical setup guide for everything except the repo checkout.
## Build it
One command:
```bash
pnpm ios:mobile:device:prod:release
```
Xcode signs the build with the "Personal Team" your Apple ID automatically owns — this team is created silently the first time you sign into Xcode with any Apple ID, so it's there even if you don't remember setting anything up. This is a **Release build**: no Metro dependency, splash → app, exactly like an App Store install.
The first build downloads CocoaPods + compiles React Native from source — expect 1020 minutes. Subsequent builds reuse Xcode's cache.
That's it for the typical path. If signing fails, jump to [Troubleshooting](#troubleshooting).
## 7-day signing limit
A free Apple ID signs builds for **7 days**. After that, the app refuses to launch on your iPhone and shows an "untrusted developer" error. Plug back into your Mac and re-run the same command to re-sign — your data stays put because it lives on the backend, not in the app.
The only way to extend this is an **Apple Developer Program account** ($99/yr from [developer.apple.com](https://developer.apple.com)). Signing is then valid for 1 year between renewals, and you can also distribute to other devices via TestFlight.
## Updating
There is no auto-update yet. When the Multica codebase moves forward, pull and rebuild:
```bash
git pull
pnpm install
pnpm ios:mobile:device:prod:release
```
Subsequent builds are fast because Xcode caches the native compile.
## Why no App Store yet
The iOS app is still moving fast — the team prefers ship-and-iterate over App Store review cycles right now. A TestFlight beta is the most likely next step before a full App Store release. Until then, the self-build path above is the only way to use Multica on iOS.
If you'd like to be notified when TestFlight opens, watch the [GitHub repo](https://github.com/multica-ai/multica).
## Troubleshooting
**"No matching provisioning profiles found"** — Xcode refuses to sign the default bundle id `ai.multica.mobile` with your Apple ID. Rare, but happens if someone has registered that prefix on Apple's developer portal. Pick any reverse-domain you control (`com.yourname.multica` is fine), export it, and re-run:
```bash
export EXPO_BUNDLE_IDENTIFIER_PROD=com.yourname.multica
pnpm ios:mobile:device:prod:release
```
The id doesn't have to mean anything — Apple just needs it to be unclaimed by other teams.
**"Could not launch &lt;app&gt;" / "Untrusted Developer"** — either you've hit the 7-day limit (re-run the build) or you need to manually trust the developer profile on your iPhone: Settings → General → VPN & Device Management → tap your Apple ID → Trust.
**Build hangs on `Pod install` or compiles forever** — first build is genuinely 1020 minutes because CocoaPods downloads dependencies and Xcode compiles React Native from source. Subsequent builds are much faster.
**App can't reach the backend** — confirm `apps/mobile/.env.production` hasn't been modified (it ships with `EXPO_PUBLIC_API_URL=https://api.multica.ai`). If you changed it, restore with `git checkout apps/mobile/.env.production`.

View File

@@ -0,0 +1,82 @@
---
title: 移动 AppiOS
description: 在自己的 iPhone 上自助 build 开源版 Multica iOS app —— 暂未上 App Store。
---
import { Callout } from "fumadocs-ui/components/callout";
Multica iOS 客户端开源,跟 web、desktop、后端一起放在[主仓库](https://github.com/multica-ai/multica)里。目前没上 App Store —— 在那之前,想用的人自己从源码 build 一份。首次 build 约 1020 分钟,之后每次约 2 分钟,连接的是 [multica.ai](https://multica.ai) 同一个后端,所以你现有账号直接能登。
<Callout type="info">
本页是给**个人使用者**看的。如果你是要开发这个 app,请看仓库里的 [`apps/mobile/README.md`](https://github.com/multica-ai/multica/blob/main/apps/mobile/README.md) —— 那里覆盖 dev / staging 变体和完整脚本表。
</Callout>
## 你需要
- 一台装了 Xcode 的 **Mac**(Xcode 在 App Store 免费下载)。
- 一个免费的 **Apple ID**,在 Xcode → Settings → Accounts 里加进去。付费的 Apple Developer Program 账号是可选的 —— 只把 7 天签名期延到 1 年,见下方[7 天签名限制](#7-天签名限制)。
- 一台通过 USB 线连接的 **iPhone**,并打开 [Developer Mode](https://docs.expo.dev/guides/ios-developer-mode/)(设置 → 隐私与安全性 → 开发者模式)。
- Multica 源码已 clone:
```bash
git clone https://github.com/multica-ai/multica.git
cd multica
pnpm install
```
上面任何一项缺失,先走 Expo 的 [Set up your environment](https://docs.expo.dev/get-started/set-up-your-environment/)(选 **Development build → iOS Device**)—— 它是除仓库拉取外所有环境准备的官方指引。
## Build
一条命令:
```bash
pnpm ios:mobile:device:prod:release
```
Xcode 会用你 Apple ID 自动持有的"Personal Team"来签名 —— 这个 team 是你第一次用任何 Apple ID 登 Xcode 时静默建的,所以即使你不记得"什么时候弄过",它都已经在那里了。这是个 **Release build**:不依赖 Metro,启动屏 → app,跟从 App Store 装的体验一样。
首次 build 会下载 CocoaPods + 从源码编译 React Native —— 大约 1020 分钟。之后 build 会快很多,Xcode 缓存了原生编译产物。
典型路径就这样。签名失败的话见下方[排错](#排错)。
## 7 天签名限制
免费 Apple ID 签的 build 只有 **7 天**有效期。过期后 app 在 iPhone 上拒绝启动,提示 "untrusted developer"。插回 Mac 重跑同一条命令重签即可 —— 数据不会丢,因为数据在后端,不在 app 里。
唯一的延期方式是 **Apple Developer Program 账号**($99/年,在 [developer.apple.com](https://developer.apple.com) 注册)。有了它签名一次有效 1 年(直到续费),还能通过 TestFlight 分发给其他设备。
## 更新
暂时没有自动更新。Multica 代码库前进时,你 pull 然后重 build:
```bash
git pull
pnpm install
pnpm ios:mobile:device:prod:release
```
后续 build 很快,因为 Xcode 缓存了原生编译产物。
## 为什么还没上 App Store
iOS app 还在快速迭代 —— 团队目前更倾向于"先发再改",而不是 App Store 审核周期。下一步比较可能是 TestFlight 内测,然后才是正式上架。在那之前,上面的自助 build 是 iOS 上用 Multica 的唯一方式。
想第一时间知道 TestFlight 开放的话,watch 一下 [GitHub 仓库](https://github.com/multica-ai/multica)。
## 排错
**"No matching provisioning profiles found"** —— Xcode 拒绝用你的 Apple ID 签默认的 `ai.multica.mobile`。比较罕见,如果有人在 Apple Developer Portal 抢注了这个前缀就会出现。换一个你控制的反向域名(`com.yourname.multica` 就够),export 后重跑:
```bash
export EXPO_BUNDLE_IDENTIFIER_PROD=com.yourname.multica
pnpm ios:mobile:device:prod:release
```
id 本身没意义,Apple 只要求它没被别的 team 抢注就行。
**"无法启动 &lt;app&gt;" / "未受信任的开发者"** —— 要么过了 7 天有效期(重跑 build),要么需要在 iPhone 上手动信任开发者证书:设置 → 通用 → VPN 与设备管理 → 点你的 Apple ID → 信任。
**Build 卡在 `Pod install` 或者编译很久不动** —— 首次 build 就是 1020 分钟,CocoaPods 要下载依赖、Xcode 要从源码编译 React Native。后续会快很多。
**App 连不上后端** —— 确认 `apps/mobile/.env.production` 没动过(默认值 `EXPO_PUBLIC_API_URL=https://api.multica.ai`)。如果你改过,用 `git checkout apps/mobile/.env.production` 还原。

View File

@@ -1,25 +1,27 @@
---
title: Project Resources
description: Attach typed pointers (Git repos today, more later) to a project so agents can pick them up as scoped context.
description: Attach typed pointers (Git repos, local directories, more later) to a project so agents can pick them up as scoped context.
---
A **Project Resource** is a typed pointer — a Git repo URL today, a Notion page or document link tomorrow — attached to a [project](/workspaces). When an [agent](/agents) runs against an issue inside that project, the daemon automatically writes the project's resource list into the agent's working directory and into its [meta-skill](/skills) prompt.
A **Project Resource** is a typed pointer — a Git repo URL, a path on your own machine, a Notion page tomorrow — attached to a [project](/workspaces). When an [agent](/agents) runs against an issue inside that project, the daemon automatically writes the project's resource list into the agent's working directory and into its [meta-skill](/skills) prompt.
The result: the agent knows which repo to check out, which docs are the "primary references" for this project, without anyone copy-pasting context into the issue body.
The result: the agent knows which repo to check out (or which local directory to work in), and which docs are the "primary references" for this project, without anyone copy-pasting context into the issue body.
## Mental model
A project is no longer just a label. It is a small **resource container**:
- A project has 0..N **resources**.
- A resource has a `resource_type` (e.g. `github_repo`) and a `resource_ref` (a JSON payload typed by `resource_type`).
- A resource has a `resource_type` (e.g. `github_repo`, `local_directory`) and a `resource_ref` (a JSON payload typed by `resource_type`).
- New resource types add a string + a handler. **No schema migration. No frontend rewrite.**
This shape is intentional — it's the same pattern Multica already uses for agent providers: a `type` discriminator and a typed payload. It keeps the schema stable so adding "Notion page", "Google Doc", "uploaded file", or "external URL" later is a small, additive change.
## Today: `github_repo`
Today two resource types ship: [`github_repo`](#resource-type-github_repo) (clone-per-task into an isolated worktree) and [`local_directory`](#resource-type-local_directory) (run directly inside a folder on a specific daemon's machine).
The first resource type ships ready to use:
## Resource type: `github_repo`
The default resource type — checked out per task into an isolated worktree:
```json
{
@@ -33,6 +35,122 @@ The first resource type ships ready to use:
`default_branch_hint` is optional — if present, the daemon surfaces it in the meta-skill so the agent knows which branch to base its work on.
## Resource type: `local_directory`
For repos that can't reasonably be re-cloned per task — multi-gigabyte game checkouts, large monorepos, or any project where the worktree-per-task model is painful — a project can instead point at an **existing directory on a specific [daemon](/daemon-runtimes)'s machine**. The agent runs **directly inside that folder**, with no clone, no copy, and no worktree.
```json
{
"resource_type": "local_directory",
"resource_ref": {
"local_path": "/Users/me/code/big-game",
"daemon_id": "0001234e-…",
"label": "main checkout"
}
}
```
The trade-off vs. `github_repo` is intentional: only the bound daemon can pick up tasks against the directory, and tasks on the same directory run **serially** instead of in parallel. In exchange you keep your existing checkout, your existing branch, your existing dirty state — Multica never re-clones it.
### When to pick `local_directory` over `github_repo`
| Concern | `github_repo` (worktree) | `local_directory` |
| --- | --- | --- |
| Checkout cost per task | Fresh clone + worktree | None — agent runs in place |
| Concurrency on the same repo | Many tasks in parallel | One at a time per directory |
| Branch / dirty state | Each task gets a fresh branch from the default | Whatever the directory currently has |
| Where it can run | Any daemon | Exactly one daemon (the one bound) |
| Disk footprint | One worktree per task | Zero overhead — your existing folder |
Pick `local_directory` when **either** of these matches:
1. **Re-cloning is prohibitively expensive** — a multi-gigabyte game checkout, a monorepo with heavy LFS assets, or anything where the per-task `git clone` would dominate the actual work. You trade concurrency for a clone-free run.
2. **Your changes are fine-grained and you want to review them locally as they happen** — you're iterating on a single component, you want to flip between the agent's edits and your editor every few minutes, and you'd rather have your existing checkout be the source of truth than a per-task worktree you have to dig out of `~/multica_workspaces/`.
The trade-off you accept in both cases is the same: **this version ships no file-level write lock.** The per-directory serial gate (one task at a time on the same folder) is the only protection against agents in two different issues touching the same files at the same time. If you point two issues' agents at the same `local_directory`, their tasks queue rather than parallelise — that's by design. If you need real parallelism on the same codebase, stay on `github_repo`.
### Attaching a local directory
The folder picker lives in the **Desktop app** only — the web app has no way to read OS paths, so the "Add local directory" button is hidden there. On Desktop:
1. Open the project → **Resources** panel.
2. Click **Add local directory**. A native folder picker opens.
3. Pick the folder. The path is bound to **the daemon currently registered by this Desktop install** — the resource record stores both the path and that daemon's ID.
On Desktop the button stays visible but is **disabled with a hint** when the daemon on this machine is offline, or when the project already has a `local_directory` bound to this daemon — so you can see *why* it's unavailable. (On the web app the button is hidden outright, since there's no folder picker available there at all.) To bind another machine's directory, install Desktop on that machine and add the resource from there.
From the CLI (works on web-only environments too, as long as you supply the daemon ID yourself):
```bash
multica project resource add <project-id> \
--type local_directory \
--local-path /Users/me/code/big-game \
--daemon-id <daemon-uuid> \
--ref-label "main checkout" # optional
multica project resource update <project-id> <resource-id> \
--local-path /Users/me/code/big-game-new
```
`--daemon-id` comes from `multica daemon list`. The CLI also accepts the generic `--ref '<json>'` escape hatch if you'd rather pass the payload directly.
### Path rules
The path you attach must clear both an attach-time and a per-task validation. Both are enforced by the daemon that owns the resource — the server only stores the JSON. A path that breaks any rule fails the task with a typed error and leaves your directory untouched:
- Must be **absolute**.
- Must **exist** and be a **directory** (not a file, symlink to a file, or device node).
- Must be **readable and writable** by the daemon process.
- Cannot be a system root or an entire user profile — `/`, `/Users`, `/home`, `/root`, `/etc`, `/tmp`, `/var`, `/usr`, `/opt`, `/Users/Shared`, your own `$HOME`, any Windows drive root (`C:\`, `D:\`, …), or `C:\Users` / `C:\ProgramData` / `C:\Program Files` / `C:\Program Files (x86)` / `C:\Windows`.
- A symlink that resolves to any of the above is rejected, and so is the canonical form of an OS-aliased path (e.g. on macOS, typing `/private/tmp` is rejected the same way as `/tmp`).
The blacklist is intentionally aggressive — picking your home directory would put Multica's runtime files at the root of your account, which is never what you want. Pick a sub-folder (typically your actual project checkout) instead.
### One per (project, daemon)
A project may hold **at most one `local_directory` per daemon**. Attempting to add a second one on the same daemon returns a `409` from the API; the Desktop button hides itself when the limit is already reached and surfaces a tooltip explaining why.
Different daemons are independent — a shared project can have one `local_directory` per teammate's machine, each binding the same project to a different folder on a different host. When the daemon claims a task, it picks the row that matches its own ID and ignores the rest.
### Mixing resource types, and multiple `local_directory` resources
Two cross-resource configurations show up in practice:
- **`github_repo` + `local_directory` on the same project.** On the daemon that has a matching `local_directory` binding, the local directory **takes precedence**: the agent runs in your folder, and the daemon does not create or use a `github_repo` worktree for that task. (The per-workspace repo cache may still sync as usual — that's a background behaviour unrelated to this task's working tree.) The `github_repo` URL still appears in `.multica/project/resources.json` and in the agent's `## Repositories` section for reference — but the working tree the agent edits is your local one, not a worktree. On a daemon that has **no** `local_directory` row for this project (different machine, or before that teammate attached one), the task falls back to the usual `github_repo` worktree flow. Effectively the local directory is a per-daemon override of the worktree path.
- **Two `local_directory` resources on the same project.** Because each `local_directory` is bound to exactly one daemon, this only happens across two different machines (the API rejects two on the same daemon at attach time, see above). Tasks are routed by the agent's runtime assignment, not by which daemon has a local directory: a task lands on the daemon that owns the receiving agent's runtime, that daemon picks the `local_directory` row matching its own ID, and ignores the rest. There is no load-balancing — if you want a specific machine to run a task, dispatch the agent that's bound to that machine's runtime.
A daemon that has no `local_directory` row for a project that has one bound elsewhere is **not** blocked — its tasks simply proceed via the project's other resources (typically the `github_repo` fallback). The `local_directory` only matters for the daemon it's bound to.
### Running tasks against a local directory
When a task is dispatched on an issue whose project has a `local_directory` bound to the receiving daemon, the daemon:
1. Re-validates the path (rules above).
2. Acquires a per-directory lock keyed on the symlink-resolved real path — so two routes to the same folder (one via a symlink, one direct) still serialise.
3. Writes the agent's `CLAUDE.md` / `AGENTS.md` (and `.multica/project/resources.json`) **into the user's directory**. The agent works there, just as if you'd opened the folder yourself.
4. Keeps Multica's runtime artefacts (`output/`, `logs/`, `.gc_meta.json`) in a separate envRoot **outside** the user's directory.
If a second task for the same directory arrives while the first is running, it parks with status **Waiting for local directory** (等待本地目录释放). The status is visible everywhere the task is — the chat task pill, the agent banner, the execution log, and the activity indicator — and the parked task counts toward the agent's "queued" presence. Cancelling the parked task releases its slot immediately; cancelling the running task lets the next one promote.
The wait is not a timeout — a parked task stays parked until either the lock releases or the user / agent cancels it.
### What Multica will and won't touch in your directory
- **Will write** `CLAUDE.md` / `AGENTS.md` (or the equivalent for your agent's provider) and `.multica/project/resources.json` at the directory root, so the agent has its meta-skill and resource list. Add these to your `.gitignore` if you don't want them committed.
- **Will write** whatever code edits the agent decides to make — exactly the same way as if you'd run the agent locally yourself.
- **Will never physically delete** the directory or anything inside it. Garbage collection is path-aware: for `local_directory` envRoots it cleans only its own `output/` and `logs/` under `workspacesRoot`, and treats the user's directory as off-limits.
### v1 limits (will tighten in follow-ups)
The first release deliberately ships with sharper edges than `github_repo`. Expect this list to shrink over time — what's documented here is what's true today:
- **No automatic branch switching.** The agent runs in whatever branch you have checked out. Switch branches before dispatching if it matters.
- **No dirty-tree protection or auto-commit.** Uncommitted changes are visible to the agent, may be modified in place, and won't be stashed. Treat the directory as a real working tree and commit before risky runs.
- **No automatic PR.** When the task ends, the changes sit on whatever branch they were made on — nothing is pushed and no PR is opened. Push and open the PR yourself when you're ready.
- **`waiting_local_directory` shows status, not the holder.** The badge tells you the task is parked; it doesn't surface which task or which file path is currently holding the directory.
These are tracked as the agent-task-lifecycle follow-up to the local-directory work; until that ships, treat `local_directory` as "the agent runs in your folder, the same way you would."
## Attaching repos at project creation
In the **Web** or **Desktop** app, opening *New project* now shows a **Repos** pill alongside Status / Priority / Lead. Selecting workspace-bound repos (or pasting an ad-hoc URL) attaches them as `github_repo` resources the moment the project is created.

View File

@@ -0,0 +1,262 @@
---
title: 项目资源
description: 给项目挂上有类型的指针GitHub 仓库、本地目录,未来更多),让智能体执行任务时自动拿到对应上下文。
---
**项目资源Project Resource** 是挂在 [项目](/workspaces) 上的有类型的指针 —— 今天可以挂 GitHub 仓库 URL 或者本机的一个目录,未来还会有 Notion 页面、文档链接等等。当 [智能体](/agents) 在这个项目的 issue 上跑任务时,守护进程会自动把项目的资源列表写进智能体的工作目录,并塞进它的 [元 skill](/skills) prompt 里。
带来的结果:智能体知道应该 checkout 哪个仓库(或者在哪个本地目录里工作),知道项目"主要参考资料"是哪些 —— 没人需要把上下文复制粘贴进 issue 描述里。
## 心智模型
项目不再只是一个标签,而是一个小的 **资源容器**
- 一个项目可以挂 0..N 个 **资源**。
- 每个资源有一个 `resource_type`(比如 `github_repo`、`local_directory`),以及一个 `resource_ref`(按 `resource_type` 定型的 JSON 负载)。
- 加新资源类型只需要加一个字符串 + 一个 handler。**不需要 schema 迁移,不需要重写前端。**
这个形状是有意的 —— 跟 Multica 里 agent provider 的设计完全一致:一个 `type` 判别字段 + 一个有类型的 payload。schema 保持稳定,未来加"Notion 页面"、"Google Doc"、"上传文件"、"外部 URL"都是小型的、增量的改动。
目前内置两种资源类型:[`github_repo`](#resource-type-github_repo)(每次任务克隆出独立 worktree和 [`local_directory`](#resource-type-local_directory)(直接在某台守护进程所在机器的目录里运行)。
## 资源类型:`github_repo`
默认的资源类型 —— 每次任务都在隔离的 worktree 里 checkout
```json
{
"resource_type": "github_repo",
"resource_ref": {
"url": "https://github.com/owner/repo",
"default_branch_hint": "main"
}
}
```
`default_branch_hint` 可选 —— 填了之后,守护进程会把它写进元 skill告诉智能体该基于哪个分支干活。
## 资源类型:`local_directory`
对于那些不适合每次任务重新 clone 的仓库 —— 几十 GB 的游戏项目、超大 monorepo、或者本身就不想被多次复制的目录 —— 项目可以改为指向 **某台 [守护进程](/daemon-runtimes) 机器上的一个已有目录**。智能体会 **直接在这个目录里运行**,不 clone、不复制、不创建 worktree。
```json
{
"resource_type": "local_directory",
"resource_ref": {
"local_path": "/Users/me/code/big-game",
"daemon_id": "0001234e-…",
"label": "主开发目录"
}
}
```
跟 `github_repo` 相比的取舍是有意的:只有绑定的那台守护进程会使用这个本地目录执行任务,而且 **同一个目录上的任务串行执行**,不再并行。换来的是:你的现有 checkout、当前分支、未提交的脏改动一切照旧 —— Multica 不会重新 clone。
### 什么时候选 `local_directory`、什么时候继续用 `github_repo`
| 关注点 | `github_repo`worktree 模式) | `local_directory` |
| --- | --- | --- |
| 每次任务的 checkout 成本 | 重新 clone + worktree | 0 —— 智能体原地干活 |
| 同一仓库的并发 | 任意条任务并行 | 同一目录每次一条 |
| 分支 / 脏改动 | 每条任务从默认分支拉一个干净分支 | 用目录当前的状态 |
| 可在哪台机器跑 | 任意守护进程 | 仅绑定的那一台 |
| 磁盘占用 | 每条任务一个 worktree | 0 —— 用你已有的目录 |
下面两种场景下推荐选 `local_directory`
1. **重新 clone 的成本过高** —— 几十 GB 的游戏 checkout、带大量 LFS 资源的 monorepo、或者任何场景下 `git clone` 的耗时会盖过实际工作量。你拿并发能力换"零 clone"的运行。
2. **改动很细碎,需要频繁在本地 review** —— 你在反复打磨某个组件,几分钟就要切回编辑器看一眼智能体改了什么;与其每次去 `~/multica_workspaces/` 翻一个新 worktree不如让它就在你已有的 checkout 里干活。
两种情况下你接受的取舍是同一个:**当前版本没有任何文件级的写入锁**。"同一目录上的任务串行执行"这一道闸是唯一防止两个 issue 的智能体同时改同一份文件的保护。如果你把两个 issue 的智能体都指向同一个 `local_directory`,它们的任务会排队、而不是并行 —— 这是有意的。如果需要在同一份代码上真正的并行,请继续用 `github_repo`。
### 添加本地目录
文件夹选择器 **只在桌面端** 提供 —— 浏览器没法读 OS 路径,所以网页端不显示"添加本地目录"按钮。桌面端的流程是:
1. 打开项目 → **Resources资源** 面板。
2. 点击 **Add local directory添加本地目录**,系统会弹出原生文件夹选择器。
3. 选一个文件夹。这个路径会被绑定到 **当前这台桌面端注册的守护进程** —— 资源记录里同时保存路径和该守护进程的 ID。
在桌面端,当本机的守护进程离线、或者这个项目已经在本机绑定了一个 `local_directory` 时,按钮 **会保留显示但置灰**,并在下方给出一行说明 —— 让你看得到为什么暂时不能用。(网页端则是直接隐藏整个按钮,因为网页端本来就没有文件夹选择器。)要把另一台机器上的目录绑过来,需要在那台机器装上桌面端、并在那边添加资源。
也可以用 CLI即便环境是纯网页端也行只要你自己提供守护进程 ID
```bash
multica project resource add <project-id> \
--type local_directory \
--local-path /Users/me/code/big-game \
--daemon-id <daemon-uuid> \
--ref-label "主开发目录" # 可选
multica project resource update <project-id> <resource-id> \
--local-path /Users/me/code/big-game-new
```
`--daemon-id` 可以通过 `multica daemon list` 拿到。CLI 也接受 `--ref '<json>'` 这种通用形式,直接传 payload。
### 路径规则
挂资源时和每次任务启动时,守护进程都会校验一次路径(服务器只负责存 JSON不做校验。任何一条不满足的任务会以一个有类型的错误失败**不会动你的目录**
- 必须是 **绝对路径**。
- 必须 **存在**,并且是 **目录**(不能是普通文件、设备节点、或者指向文件的 symlink
- 守护进程进程必须能 **读 + 写**。
- 不能是系统根目录或整个用户配置区 —— `/`、`/Users`、`/home`、`/root`、`/etc`、`/tmp`、`/var`、`/usr`、`/opt`、`/Users/Shared`、你自己的 `$HOME`、任意 Windows 盘根(`C:\`、`D:\`…),以及 `C:\Users` / `C:\ProgramData` / `C:\Program Files` / `C:\Program Files (x86)` / `C:\Windows`。
- 指向上述任何路径的 symlink 也会被拒macOS 的 canonical 别名(比如手动输入 `/private/tmp`)和 `/tmp` 受同样的限制。
黑名单故意做得激进 —— 选你自己的 home 目录就意味着 Multica 的运行时文件会出现在你账号的根下,这不会是任何人想要的结果。请选一个子目录(一般就是你具体的项目 checkout
### 每台守护进程每个项目最多一条
同一项目上 **每台守护进程最多只能绑一个 `local_directory`**。在同一台机器上想再加一条会被 API 用 `409` 拒绝;桌面端的按钮在达到上限时会自动隐藏,并在 tooltip 里说明原因。
不同的守护进程互不影响 —— 一个共享的项目可以为每个队友的机器各绑一个 `local_directory`,把同一个项目挂到不同主机上的不同目录。守护进程领任务时会挑跟自己 ID 匹配的那一行,其它的忽略。
### 混用资源类型,以及多个 `local_directory` 资源
实际会碰到两种跨资源的组合:
- **同一个项目同时挂 `github_repo` 和 `local_directory`。** 在拥有匹配 `local_directory` 绑定的那台守护进程上,本地目录 **优先**:智能体跑在你的目录里,这次任务不会为项目的 `github_repo` 创建或使用 worktree。每工作区的仓库缓存可能仍会照常 sync这层是和具体任务无关的后台行为。`github_repo` 的 URL 仍然会出现在 `.multica/project/resources.json` 和智能体的 `## Repositories` 段里供参考,但智能体真正在编辑的工作树是你的本地目录,不是 worktree。在 **没有** 匹配 `local_directory` 行的其他守护进程上(不同机器、或者那位队友还没挂本地目录),任务按原本的 `github_repo` worktree 流程走。本质上 `local_directory` 是某台守护进程对 worktree 路径的一个覆盖。
- **同一个项目挂两个 `local_directory`。** 每个 `local_directory` 只能绑一台守护进程,所以这只会发生在两台不同机器之间(同一台机器上想加第二条会被 API 当场拒掉,见上一节)。任务的去向由智能体 runtime 绑定决定,不是由"哪台守护进程有本地目录"决定 —— 任务会落到承载这条智能体 runtime 的那台守护进程上,由它挑出匹配自己 ID 的那一行、忽略其他行。这里没有负载均衡:如果你想让某台机器跑某条任务,请把任务派给绑在那台机器 runtime 上的智能体。
如果某台守护进程没有匹配这个项目的 `local_directory` 行,**不会**因为别处有人绑了就被拦下 —— 它的任务会照常走项目里的其他资源(一般就是 `github_repo` 那条 fallback 路径)。`local_directory` 只对它绑定的那台守护进程生效。
### 任务在本地目录里如何运行
当一条任务被分发到某个 issue、并且该项目在领任务的守护进程上绑了 `local_directory` 时,守护进程会:
1. 再次校验路径(按上面那套规则)。
2. 以 symlink 解析后的真实路径为 key 取一把"目录锁" —— 即便两条路径走不同路由(一条经过 symlink一条直接指向同一个文件夹也会被串行化。
3. 把智能体的 `CLAUDE.md` / `AGENTS.md`(以及 `.multica/project/resources.json`**写进你的目录**。智能体就在那里工作,跟你自己打开这个文件夹后启动它是一样的体验。
4. Multica 自己的运行时产物(`output/`、`logs/`、`.gc_meta.json`)放在 **你目录之外** 的一个独立 envRoot 里。
如果第一条任务还在跑、第二条针对同一目录的任务又来了,第二条会停在 **Waiting for local directory等待本地目录释放** 状态。这个状态在所有相关 UI 都看得见 —— 聊天里的任务状态条、智能体横幅、执行记录、活动指示器;等待中的任务会被计入该智能体的"排队"占用。取消等待中的任务会立即释放它的槽位;取消正在跑的那条会让下一条立即顶上。
等待没有超时 —— 一条等待中的任务会一直等到锁释放、或者被用户/智能体取消为止。
### Multica 会写、不会动什么
- **会写入** `CLAUDE.md` / `AGENTS.md`(或对应 provider 的等价文件)以及 `.multica/project/resources.json` 到目录根 —— 这样智能体才有自己的元 skill 和资源清单。如果不想把这些提交到 git请加进 `.gitignore`。
- **会写入** 智能体决定做出的所有代码改动 —— 跟你自己在本地跑这个智能体没有区别。
- **永远不会物理删除** 你的目录或里面任何内容。垃圾回收对路径是有判断的:对 `local_directory` 的 envRoot它只会清自己挂在 `workspacesRoot` 下的 `output/` 与 `logs/`**完全不碰** 你的目录。
### v1 阶段的限制(后续会逐步收敛)
第一版有意比 `github_repo` 留了更多锋利的边。下面这份清单会随着后续工作逐步缩短;这里列出的是 **今天**的真实行为:
- **不会自动切分支。** 智能体跑在你当前 checkout 的分支上。如果分支选择重要,请在分发任务前自己切换好。
- **不会保护脏工作区,也不会自动 commit。** 未提交的改动智能体看得到、可能就地修改,不会被 stash。请把这个目录当作真实的工作区来对待重要的运行前自己先 commit。
- **不会自动开 PR。** 任务结束后,改动就停在它实际做出来的分支上 —— Multica 不会 push、不会开 PR。需要 push、需要 PR 的话请自己来。
- **`waiting_local_directory` 只显示状态,不显示是谁占着。** 这个徽标只告诉你"任务在等",不会告诉你具体是哪条任务、哪个路径正在持有目录。
这些都列在 local-directory 工作的"智能体任务生命周期"后续项里;在那之前,请把 `local_directory` 当作"智能体在你的目录里跑,跟你自己跑没区别"来用。
## 在创建项目时挂仓库
在 **网页端** 或 **桌面端**,打开 *新建项目* 时Status / Priority / Lead 旁边多了一个 **Repos** 标签。选已绑到工作区的仓库(或者粘贴一个临时 URL项目创建的瞬间它们就以 `github_repo` 资源的形式挂上去。
通过 **CLI**
```bash
# 创建 + 挂资源一步到位。资源是在同一个事务里挂上的 ——
# 任何一个资源不合法都会让整个 create 回滚,绝不会出现
# "项目建好了但资源只挂了一半"的状态。
multica project create \
--title "Agent UX 2026" \
--repo https://github.com/multica-ai/multica
# 后续管理资源
multica project resource list <project-id>
multica project resource add <project-id> --type github_repo --url <url>
multica project resource remove <project-id> <resource-id>
# 任何服务器认识的 resource_type 都可以用这个通用入口 ——
# 加新类型时不需要改 CLI
multica project resource add <project-id> \
--type notion_page \
--ref '{"page_id":"…","title":"…"}'
```
`--repo` 可以重复指定;每个值会被当作一条单独的 `github_repo` 资源挂上去。
## 运行时智能体看到什么
当守护进程为一个项目内的 issue 启动智能体时,会发生两件事:
### 1. `.multica/project/resources.json`
API 响应的结构化透传,写进智能体的工作目录里:
```json
{
"project_id": "…",
"project_title": "Agent UX 2026",
"resources": [
{
"id": "…",
"resource_type": "github_repo",
"resource_ref": {
"url": "https://github.com/multica-ai/multica",
"default_branch_hint": "main"
}
}
]
}
```
Skill、辅助脚本、或者智能体自己想拿到本次运行**精确的**资源列表时,解析这个文件即可。
### 2. 元 skill prompt 里的 "Project Context" 段
智能体的 `CLAUDE.md` / `AGENTS.md`(按 provider 不同)里多出一段人类可读的摘要:
```
## Project Context
This issue belongs to **Agent UX 2026**.
Project resources (also written to `.multica/project/resources.json`):
- **GitHub repo**: https://github.com/multica-ai/multica (default branch: `main`)
Resources are pointers — open them only when relevant to the task. For
`github_repo` resources, use `multica repo checkout <url>` to fetch the code.
```
这段文字故意做得很精简:完整 payload 已经写到磁盘上了prompt 只是给智能体一个定位 —— 让它知道项目存在、知道挂了什么。
### 失败时
资源拉取是 **best-effort**。如果 API 调失败了prompt 里的项目段会省掉、文件也不写,但任务照常启动 —— 智能体永远不会因为缺少项目上下文而卡住。
## 加一种新资源类型
抽象的全部目的就是让加新类型变便宜。完整流程:
1. **服务端校验器**`server/internal/handler/project_resource.go`)—— 在 `validateAndNormalizeResourceRef` 里加一个 case解析并标准化新的 payload。
2. **守护进程元 skill 格式化器**`server/internal/daemon/execenv/runtime_config.go`)—— 在 `formatProjectResource` 里加一个 case让智能体 prompt 把新类型渲染成一条可读的列表项。
3. **TypeScript 类型**`packages/core/types/project.ts`)—— 给 `ProjectResourceType` 加值,并加上对应的 payload 接口。
4. **UI 渲染**`packages/views/projects/components/project-resources-section.tsx`)—— 在 `ResourceRow` 里加一个 case 渲染新类型。
**不需要 schema 迁移、不需要新的 sqlc query、不需要新的 endpoint、也不需要改 CLI** —— CLI 的通用 `--ref '<json>'` 选项接受校验器认识的任何 payload第 0 天就能用。(之后如果想加按类型的 CLI 快捷参数,可以加,但不是必须。)
同一张 `project_resource` 表、同一套 CRUD 调用,处理所有类型。
## 工作区仓库 vs. 项目仓库
智能体看到的仓库列表(`CLAUDE.md` / `AGENTS.md` 里的 `## Repositories` 段)由守护进程的领任务逻辑按以下优先级决定:
- **项目挂了至少一条 `github_repo` 资源** → 仅显示项目挂的仓库。工作区绑定的仓库会被隐藏,避免智能体猜哪一个属于这个 issue。
- **项目没挂 `github_repo` 资源(或 issue 根本不在项目里)** → 回落到工作区的仓库列表,跟以前一样。
这样可以把智能体的工作集压紧:项目把仓库说清楚了,那就是权威答案。而 `.multica/project/resources.json` 里始终带着完整列表,需要查看全部资源的 skill 仍然能拿到。
守护进程在 checkout 这一侧也是配套的:当任务带着项目级别的 `github_repo` URL 进来时,这些 URL 会被合并进每工作区的白名单,并在智能体启动前同步进本地仓库缓存。所以即便某个项目仓库 URL 没被绑到工作区,`multica repo checkout` 也能正常处理它,不会以"未配置"为由拒绝。白名单的划分只是内部实现:工作区绑定的 URL 和任务级别的 URL 分开记录,工作区仓库列表的刷新不会顺手吊销某条项目 URL。
## 当前**不**做的事
- **跨项目共享**。每条资源现在只能挂在一个项目上。
- **按 skill 划定资源可见性**。所有资源对智能体本次运行里的每个 skill 都可见;按类型过滤是后续工作。
- **缓存 / 同步**。`github_repo` 目前只是元数据 —— checkout 仍然按需走 `multica repo checkout`。Notion / Google Docs 之类的文档文本缓存会随对应类型一起到位。
这些是有意的留白 —— 第一版的目标是用最小的活动部件去验证这个抽象。

View File

@@ -1,11 +1,11 @@
---
title: AI coding tools matrix
description: Multica supports 11 AI coding tools; they implement the same interface, but the capability details diverge significantly.
description: Multica supports 12 AI coding tools; they implement the same interface, but the capability details diverge significantly.
---
import { Callout } from "fumadocs-ui/components/callout";
Multica ships with built-in support for **11 AI coding tools**. They all implement the same interface — queue, dispatch, execute, return results — so you can drive any of them from the same Multica board. **But the capability details diverge significantly**: whether session resumption actually works, whether MCP is supported, where skill files live, how models are selected. This page is the full matrix.
Multica ships with built-in support for **12 AI coding tools**. They all implement the same interface — queue, dispatch, execute, return results — so you can drive any of them from the same Multica board. **But the capability details diverge significantly**: whether session resumption actually works, whether MCP is supported, where skill files live, how models are selected. This page is the full matrix.
For guidance on picking a tool when creating an agent, see [Creating and configuring agents](/agents-create).
@@ -13,6 +13,7 @@ For guidance on picking a tool when creating an agent, see [Creating and configu
| Tool | Vendor | Session resumption | MCP | Skill injection path | Model selection |
|---|---|---|---|---|---|
| **Antigravity** | Google | ✅ (`--conversation <id>`) | ❌ | `.agents/skills/` | Managed inside the Antigravity CLI itself |
| **Claude Code** | Anthropic | ✅ | **✅ (the only one that actually uses it)** | `.claude/skills/` | Static + flag |
| **Codex** | OpenAI | ⚠️ Code exists but unreachable | ❌ | `$CODEX_HOME/skills/` | Static |
| **Copilot** | GitHub | ✅ | ❌ | `.github/skills/` | Static (determined by account entitlement) |
@@ -27,6 +28,10 @@ For guidance on picking a tool when creating an agent, see [Creating and configu
## What each tool is for
### Antigravity
From Google. CLI binary name is `agy`. Pairs with Google's Antigravity service and ships with a Gemini-backed default model. **Session resumption works** via `--conversation <id>`; the daemon captures the conversation UUID from the CLI's log file because stdout is plain text rather than a structured event stream. There is no `--model` flag — model selection lives inside the Antigravity CLI settings, so Multica disables the per-agent model picker for this provider. Skills land in `.agents/skills/` (the CLI inherits Gemini CLI's workspace skill layout — see [Antigravity migration docs](https://antigravity.google/docs/gcli-migration)).
### Claude Code
From Anthropic. **First choice for new users** — the most complete feature set: session resumption actually works, it's the **only one of the 11 that truly reads MCP configuration**, and it supports fine-tuning flags like `--max-turns` and `--append-system-prompt`. Requires an Anthropic API key.
@@ -77,7 +82,7 @@ The session resumption mechanism is covered in [Tasks](/tasks#can-a-task-continu
| Status | Tools | Meaning |
|---|---|---|
| ✅ Really works | Claude Code, Copilot, Hermes, Kimi, Kiro CLI, OpenCode, OpenClaw, Pi | Pass the resume id and it continues from the previous context |
| ✅ Really works | Antigravity, Claude Code, Copilot, Hermes, Kimi, Kiro CLI, OpenCode, OpenClaw, Pi | Pass the resume id and it continues from the previous context |
| ⚠️ Code exists but unreachable | Codex, Cursor | Resume paths exist in the code but aren't actually reached (Codex silently falls back; Cursor doesn't return session id) — **treat as unsupported** |
| ❌ None | Gemini | The CLI has no resume mechanism |
@@ -85,7 +90,7 @@ The session resumption mechanism is covered in [Tasks](/tasks#can-a-task-continu
## MCP configuration: only Claude Code actually reads it
**Of the 11 tools, only Claude Code actually consumes `mcp_config`**. The other 10 accept the field but **completely ignore it** — no error, no warning, the config just has no effect.
**Of the 12 tools, only Claude Code actually consumes `mcp_config`**. The other 11 accept the field but **completely ignore it** — no error, no warning, the config just has no effect.
<Callout type="warning">
If you set `mcp_config` in an agent configuration but pick a tool other than Claude Code, your MCP servers have **no effect** on that agent. MCP integration currently covers Claude Code only.
@@ -105,6 +110,7 @@ Each tool uses **its own** skill discovery path. Before a task runs, the Multica
| Kiro CLI | `.kiro/skills/` | ✅ Native |
| OpenCode | `.opencode/skills/` | ✅ Native |
| Pi | `.pi/skills/` | ✅ Native |
| Antigravity | `.agents/skills/` | ✅ Native (inherits Gemini CLI's workspace layout — see [Antigravity docs](https://antigravity.google/docs/gcli-migration)) |
| Gemini | `.agent_context/skills/` | ⚠️ Generic fallback |
| Hermes | `.agent_context/skills/` | ⚠️ Generic fallback |
| OpenClaw | `.agent_context/skills/` | ⚠️ Generic fallback |
@@ -118,3 +124,4 @@ For creating and using skills, see [Skills](/skills).
- [Creating and configuring agents](/agents-create) — pick a tool for your agent
- [Tasks](/tasks) — task lifecycle and session-resumption mechanics
- [Daemon and runtimes](/daemon-runtimes) — where the tools run and how they connect to Multica
- [Install an agent runtime](/install-agent-runtime) — installation and authentication for each of the 12 supported tools

View File

@@ -1,11 +1,11 @@
---
title: AI 编程工具对照
description: Multica 支持 11 款 AI 编程工具;它们实现同一套接口,但能力细节差异很大。
description: Multica 支持 12 款 AI 编程工具;它们实现同一套接口,但能力细节差异很大。
---
import { Callout } from "fumadocs-ui/components/callout";
Multica 内置支持 **11 款 AI 编程工具**。它们都实现了同一套接口——排队、派发、执行、结果回传,所以你可以从 Multica 的同一个看板上指挥任意一款。**但它们在能力细节上差异很大**:会话恢复是否真用、是否支持 MCP、skill 文件该放在哪里、模型怎么选。这一页是完整对照。
Multica 内置支持 **12 款 AI 编程工具**。它们都实现了同一套接口——排队、派发、执行、结果回传,所以你可以从 Multica 的同一个看板上指挥任意一款。**但它们在能力细节上差异很大**:会话恢复是否真用、是否支持 MCP、skill 文件该放在哪里、模型怎么选。这一页是完整对照。
创建智能体时挑选工具的指引见 [创建和配置智能体](/agents-create)。
@@ -13,6 +13,7 @@ Multica 内置支持 **11 款 AI 编程工具**。它们都实现了同一套接
| 工具 | 厂商 | 会话恢复 | MCP | Skill 注入路径 | 模型选择 |
|---|---|---|---|---|---|
| **Antigravity** | Google | ✅(`--conversation <id>`| ❌ | `.agents/skills/` | 由 Antigravity CLI 自己管理 |
| **Claude Code** | Anthropic | ✅ | **✅(唯一真用)** | `.claude/skills/` | 静态 + flag |
| **Codex** | OpenAI | ⚠️ 代码存在但不可达 | ❌ | `$CODEX_HOME/skills/` | 静态 |
| **Copilot** | GitHub | ✅ | ❌ | `.github/skills/` | 静态(账号权益决定)|
@@ -27,9 +28,13 @@ Multica 内置支持 **11 款 AI 编程工具**。它们都实现了同一套接
## 每款工具的定位
### Antigravity
Google 出品。CLI 二进制名为 `agy`,搭配 Google Antigravity 服务,默认走 Gemini 系列模型。**会话恢复真用**——通过 `--conversation <id>`;因为 stdout 是纯文本而非结构化事件流,守护进程从 CLI 的日志文件里抓取 conversation UUID。CLI 没有 `--model` flag——模型选择保存在 Antigravity 自己的设置里,因此 Multica 禁用了这款工具的模型选择控件。Skill 文件写入 `.agents/skills/`CLI 沿用 Gemini CLI 的 workspace 布局——见 [Antigravity 迁移文档](https://antigravity.google/docs/gcli-migration))。
### Claude Code
Anthropic 出品。**新用户首选**——功能最完整:会话恢复真用,是 **11 款里唯一真读 MCP 配置**的工具,支持 `--max-turns`、`--append-system-prompt` 等细调参数。需要一个 Anthropic API 密钥。
Anthropic 出品。**新用户首选**——功能最完整:会话恢复真用,是 **12 款里唯一真读 MCP 配置**的工具,支持 `--max-turns`、`--append-system-prompt` 等细调参数。需要一个 Anthropic API 密钥。
### Codex
@@ -77,7 +82,7 @@ Inflection AI 出品,极简主义。**会话恢复机制特殊**——session
| 状态 | 工具 | 含义 |
|---|---|---|
| ✅ 真用 | Claude Code、Copilot、Hermes、Kimi、Kiro CLI、OpenCode、OpenClaw、Pi | 传 resume id会从上次上下文接着继续 |
| ✅ 真用 | Antigravity、Claude Code、Copilot、Hermes、Kimi、Kiro CLI、OpenCode、OpenClaw、Pi | 传 resume id会从上次上下文接着继续 |
| ⚠️ 代码存在但不可达 | Codex、Cursor | 代码里有 resume 路径但实际走不到Codex 静默回落、Cursor session id 不回传)—— **当作不支持** |
| ❌ 无 | Gemini | CLI 无 resume 机制 |
@@ -85,7 +90,7 @@ Inflection AI 出品,极简主义。**会话恢复机制特殊**——session
## MCP 配置:只有 Claude Code 真的读
**11 款工具里只有 Claude Code 实际消费 `mcp_config`**。其他 10 款会接收这个字段但**完全忽略**——不报错、不警告,只是配置不生效。
**12 款工具里只有 Claude Code 实际消费 `mcp_config`**。其他 11 款会接收这个字段但**完全忽略**——不报错、不警告,只是配置不生效。
<Callout type="warning">
如果你在智能体配置里设置了 `mcp_config`,但选了 Claude Code 之外的工具,你的 MCP server 对这个智能体**没有效果**。目前的 MCP 集成只覆盖 Claude Code。
@@ -105,6 +110,7 @@ Inflection AI 出品,极简主义。**会话恢复机制特殊**——session
| Kiro CLI | `.kiro/skills/` | ✅ 原生 |
| OpenCode | `.opencode/skills/` | ✅ 原生 |
| Pi | `.pi/skills/` | ✅ 原生 |
| Antigravity | `.agents/skills/` | ✅ 原生(沿用 Gemini CLI 的 workspace 布局——见 [Antigravity 文档](https://antigravity.google/docs/gcli-migration)|
| Gemini | `.agent_context/skills/` | ⚠️ 通用 fallback |
| Hermes | `.agent_context/skills/` | ⚠️ 通用 fallback |
| OpenClaw | `.agent_context/skills/` | ⚠️ 通用 fallback |

View File

@@ -1,6 +1,6 @@
---
title: Self-host quickstart
description: Run Multica on your own server or machine with Docker. Takes about 10 minutes.
description: Run Multica on your own server or machine with Docker (or Helm on Kubernetes). Takes about 10 minutes.
---
import { Callout } from "fumadocs-ui/components/callout";
@@ -18,6 +18,10 @@ Agent **execution** still relies on the [daemon](/daemon-runtimes) you run local
## 1. Pull the project and start the backend
<Callout type="info">
**Already on Kubernetes?** Skip Docker and use the Helm chart instead — jump to [Kubernetes deployment](#kubernetes-deployment-alternative) below, then come back to [Step 4](#4-first-login--create-a-workspace) for first login.
</Callout>
```bash
git clone https://github.com/multica-ai/multica.git
cd multica
@@ -78,17 +82,31 @@ Two delivery backends are supported — pick whichever fits your network:
**Option B — SMTP relay (internal networks / on-premise):**
Use this when the deployment can't reach `api.resend.com`, or you already have an internal mail relay (Exchange, Postfix, on-prem SendGrid, etc.). `SMTP_HOST` takes priority over Resend when both are set.
Use this when the deployment can't reach `api.resend.com`, or you already have an internal mail relay (Microsoft Exchange, Postfix, on-prem SendGrid, etc.). `SMTP_HOST` takes priority over Resend when both are set, so verification and invite mail stays on the internal relay. Port 465 (SMTPS / implicit TLS) is not currently supported — use 25 or 587.
For **anonymous Exchange internal relay (port 25)** — the host is trusted by IP and submits without credentials:
```bash
SMTP_HOST=smtp.internal.example.com
SMTP_PORT=587 # default 25; use 587 for STARTTLS submission
SMTP_USERNAME=multica # leave empty for unauthenticated relay
SMTP_PASSWORD=...
SMTP_HOST=exchange.internal.example.com
SMTP_PORT=25
SMTP_USERNAME=
SMTP_PASSWORD=
SMTP_TLS_INSECURE=false
RESEND_FROM_EMAIL=noreply@yourdomain.com # reused as the From: header
```
Then restart: `docker compose -f docker-compose.selfhost.yml restart backend`.
For **authenticated submission (port 587, STARTTLS)** — the relay requires a service account; STARTTLS is upgraded automatically when advertised:
```bash
SMTP_HOST=smtp.internal.example.com
SMTP_PORT=587
SMTP_USERNAME=multica
SMTP_PASSWORD=...
SMTP_TLS_INSECURE=false # set true only for private CA / self-signed
RESEND_FROM_EMAIL=noreply@yourdomain.com
```
Then restart: `docker compose -f docker-compose.selfhost.yml restart backend`. On restart, the backend prints which provider it picked (`EmailService: SMTP relay …` / `Resend API` / `DEV mode`) — credentials are never logged, so this line is safe to share when asking for help.
For more auth configuration (OAuth, signup allowlist) and the full SMTP variable reference, see [Auth setup](/auth-setup) and [Environment variables → Email](/environment-variables#email-configuration).
@@ -155,11 +173,99 @@ After bringing the proxy up, set `FRONTEND_ORIGIN=https://multica.example.com` i
Same flow as Cloud — see [Cloud quickstart → Steps 5-6](/cloud-quickstart#5-create-an-agent).
## 7. Schedule the usage rollup (required for the Usage dashboard)
<Callout type="warning">
The Usage / Runtime dashboards read from a derived `task_usage_hourly` table populated by `rollup_task_usage_hourly()`. The bundled `pgvector/pgvector:pg17` Postgres image **does not include `pg_cron`**, and the backend does not run the rollup in-process either. If nothing schedules `rollup_task_usage_hourly()`, raw `task_usage` rows keep arriving while the dashboard stays at zero forever.
</Callout>
Pick one of the supported options — only one is needed.
**Option A — External cron / systemd-timer (simplest).** Run the rollup every 5 minutes from any out-of-band scheduler. It's idempotent and watermark-driven, so missed ticks catch up:
```bash
# /etc/cron.d/multica-rollup — every 5 minutes
*/5 * * * * root docker compose -f /path/to/multica/docker-compose.selfhost.yml \
exec -T postgres psql -U multica -d multica \
-c "SELECT rollup_task_usage_hourly();" >/dev/null
```
**Option B — Swap Postgres for an image that ships `pg_cron`.** Replace `pgvector/pgvector:pg17` in `docker-compose.selfhost.yml` with an image that has both `pgvector` and `pg_cron` (`supabase/postgres`, or a custom build), set `shared_preload_libraries=pg_cron`, restart, then register the job once:
```sql
CREATE EXTENSION IF NOT EXISTS pg_cron;
SELECT cron.schedule(
'rollup_task_usage_hourly',
'*/5 * * * *',
$$SELECT rollup_task_usage_hourly()$$
);
```
**Option C — Backfill history first (upgrade path).** If you're upgrading from `v0.3.4 → v0.3.5+` and have existing `task_usage` rows, migration `103` will abort `migrate up` with `refusing to drop legacy daily rollups: ...` until the hourly table is seeded. Run the bundled backfill once, then set up Option A or B:
```bash
docker compose -f docker-compose.selfhost.yml exec backend \
./backfill_task_usage_hourly --sleep-between-slices=2s
```
`--sleep-between-slices=2s` throttles read pressure on a busy DB. After it finishes, restart the backend container (migrations run on startup) and the upgrade completes.
Full reference — including the Kubernetes `CronJob` template and the upgrade order — lives in the repo's [`SELF_HOSTING_ADVANCED.md → Usage Dashboard Rollup`](https://github.com/multica-ai/multica/blob/main/SELF_HOSTING_ADVANCED.md#usage-dashboard-rollup).
## Kubernetes deployment (alternative)
If you already run a Kubernetes cluster, the repo also ships a Helm chart at `deploy/helm/multica/`. It's the equivalent of `make selfhost` for k8s — same backend image, frontend image, and `pgvector/pgvector:pg17` Postgres, packaged as Deployments / Services / Ingresses with one `ConfigMap` rendered from `values.yaml`. Authored against k3s + Traefik + `local-path` and should work on any cluster with an Ingress controller and a default `ReadWriteOnce` StorageClass.
The chart **does not template secret values**. It references a Secret named `multica-secrets` by name, so real JWT / DB / Resend / Google keys never need to live in git or in `values.yaml`. Create the namespace + Secret once with kubectl:
```bash
kubectl create namespace multica
kubectl -n multica create secret generic multica-secrets \
--from-literal=JWT_SECRET="$(openssl rand -hex 32)" \
--from-literal=POSTGRES_PASSWORD="$(openssl rand -hex 16)" \
--from-literal=RESEND_API_KEY="" \
--from-literal=GOOGLE_CLIENT_SECRET="" \
--from-literal=CLOUDFRONT_PRIVATE_KEY="" \
--from-literal=MULTICA_DEV_VERIFICATION_CODE=""
```
Then install the chart:
```bash
git clone https://github.com/multica-ai/multica.git
cd multica
helm install multica deploy/helm/multica -n multica
```
Defaults assume the hostnames `multica.dev.lan` (web) and `api.multica.dev.lan` (backend). Add them to `/etc/hosts` (or local DNS) pointing at any node IP where your Ingress is reachable. To use different hostnames, copy `deploy/helm/multica/values.yaml`, edit `ingress.frontend.host` / `ingress.backend.host` and the matching `backend.config.appUrl` / `frontendOrigin` / `localUploadBaseUrl` / `googleRedirectUri`, then install with `-f my-values.yaml`.
On a cold cluster the backend can stay `Running` but not `Ready` for a few minutes while it waits on Postgres and runs migrations — a startupProbe absorbs this, so the pod should not restart. Once it's `Ready`:
```bash
curl -H "Host: api.multica.dev.lan" http://<ingress-ip>/healthz
# {"status":"ok","checks":{"db":"ok","migrations":"ok"}}
```
Then open `http://multica.dev.lan` and continue at [Step 4 — First login](#4-first-login--create-a-workspace) above. Point the CLI at your Ingress hostnames:
```bash
multica setup self-host \
--server-url http://api.multica.dev.lan \
--app-url http://multica.dev.lan
```
To pull the latest images without changing the chart, `kubectl -n multica rollout restart deploy/multica-backend deploy/multica-frontend`. To pin a specific Multica release, set `images.backend.tag` / `images.frontend.tag` in your values file and `helm upgrade`. `helm -n multica uninstall multica` removes the workloads but keeps the PVCs and Secret; `kubectl delete namespace multica` wipes everything.
The full reference — three login modes, the `backend` ExternalName workaround for the build-time-baked `REMOTE_API_URL` in the web image, resource limits, and TLS — lives in the repo's [`SELF_HOSTING.md`](https://github.com/multica-ai/multica/blob/main/SELF_HOSTING.md#kubernetes-deployment-alternative).
## Common issues
- **Backend won't start**: check container logs with `docker compose -f docker-compose.selfhost.yml logs backend`; usually it's a bad `DATABASE_URL` or `JWT_SECRET` in `.env`
- **Verification code not received**: no email backend is configured (neither Resend nor SMTP) → look for `[DEV] Verification code` in `docker compose logs backend`
- **WebSocket won't connect**: for public deployments you must set `FRONTEND_ORIGIN` to your real frontend domain; see [Troubleshooting → WebSocket won't connect](/troubleshooting#websocket-wont-connect)
- **Usage / Runtime dashboard stays at zero**: `rollup_task_usage_hourly()` isn't being scheduled — see [Step 7](#7-schedule-the-usage-rollup-required-for-the-usage-dashboard) above and [Troubleshooting → Usage dashboard shows zero](/troubleshooting#usage-dashboard-stays-at-zero)
- **`migrate up` fails with `refusing to drop legacy daily rollups`**: upgrade-path guard from `v0.3.4 → v0.3.5+`. Run `backfill_task_usage_hourly` first — see [Step 7 → Option C](#7-schedule-the-usage-rollup-required-for-the-usage-dashboard)
## Next steps

View File

@@ -1,6 +1,6 @@
---
title: Self-Host 快速上手
description: 在自己的服务器或本机用 Docker 把 Multica 跑起来。约 10 分钟。
description: 在自己的服务器或本机用 Docker 把 Multica 跑起来(也可以在 Kubernetes 上用 Helm。约 10 分钟。
---
import { Callout } from "fumadocs-ui/components/callout";
@@ -18,6 +18,10 @@ import { Callout } from "fumadocs-ui/components/callout";
## 1. 拉取项目 + 一键启动后端
<Callout type="info">
**已经有 Kubernetes 集群?** 不用走 Docker直接用 Helm chart——跳到下面的 [Kubernetes 部署(替代方案)](#kubernetes-部署替代方案),装完再回到 [第 4 步](#4-首次登录--创建工作区) 完成登录。
</Callout>
```bash
git clone https://github.com/multica-ai/multica.git
cd multica
@@ -77,17 +81,31 @@ make selfhost
**Option B — SMTP relay内网/自部署):**
适合内网无法访问 `api.resend.com`或已经有内部邮件中继Exchange、Postfix、自部署 SendGrid 等)的场景。同时设置时 `SMTP_HOST` 优先级高于 Resend。
适合内网无法访问 `api.resend.com`,或已经有内部邮件中继(Microsoft Exchange、Postfix、自部署 SendGrid 等)的场景。同时设置时 `SMTP_HOST` 优先级高于 Resend,验证码和邀请邮件不会走外部 provider。**暂不支持** 465SMTPS / 隐式 TLS请使用 25 或 587
**匿名 Exchange 内部 relay端口 25** —— 主机按 IP 被信任,不需要凭据:
```bash
SMTP_HOST=smtp.internal.example.com
SMTP_PORT=587 # 默认 25STARTTLS 提交端口用 587
SMTP_USERNAME=multica # 留空则使用未认证 relay
SMTP_PASSWORD=...
SMTP_HOST=exchange.internal.example.com
SMTP_PORT=25
SMTP_USERNAME=
SMTP_PASSWORD=
SMTP_TLS_INSECURE=false
RESEND_FROM_EMAIL=noreply@yourdomain.com # 同时作为 SMTP From: 头
```
之后重启:`docker compose -f docker-compose.selfhost.yml restart backend`。
**认证提交(端口 587STARTTLS** —— relay 需要 service account服务端 advertise STARTTLS 时自动升级:
```bash
SMTP_HOST=smtp.internal.example.com
SMTP_PORT=587
SMTP_USERNAME=multica
SMTP_PASSWORD=...
SMTP_TLS_INSECURE=false # 仅在私有 CA / 自签证书时改成 true
RESEND_FROM_EMAIL=noreply@yourdomain.com
```
之后重启:`docker compose -f docker-compose.selfhost.yml restart backend`。重启时 backend 会打印当前选择的 provider`EmailService: SMTP relay …` / `Resend API` / `DEV mode`),密码不会被记录,所以这行截图给同事是安全的。
更多 auth 配置OAuth、注册白名单以及完整的 SMTP 变量说明见 [登录与注册配置](/auth-setup) 和 [环境变量](/environment-variables)。
@@ -154,11 +172,99 @@ multica.example.com {
流程和 Cloud 一样——见 [Cloud 快速上手 → 5-6 步](/cloud-quickstart#5-创建智能体)。
## 7. 调度用量汇总任务Usage Dashboard 必需)
<Callout type="warning">
Usage / Runtime 看板读的是派生表 `task_usage_hourly`,需要 `rollup_task_usage_hourly()` 周期性运行才能填充。**默认的 `pgvector/pgvector:pg17` 镜像不带 `pg_cron`**,后端进程内部也不会跑这个 rollup——什么都没调度的话原始 `task_usage` 行会继续写入,但 dashboard 会一直停在 0不会报错。
</Callout>
三种支持路径,三选一即可。
**Option A —— 外部 cron / systemd-timer最简单。** 在任意外部调度器上每 5 分钟跑一次 rollup。函数是幂等的、按 watermark 推进,丢一两个 tick 下次能补上:
```bash
# /etc/cron.d/multica-rollup —— 每 5 分钟跑一次
*/5 * * * * root docker compose -f /path/to/multica/docker-compose.selfhost.yml \
exec -T postgres psql -U multica -d multica \
-c "SELECT rollup_task_usage_hourly();" >/dev/null
```
**Option B —— 换成自带 `pg_cron` 的 Postgres 镜像。** 把 `docker-compose.selfhost.yml` 里的 `pgvector/pgvector:pg17` 换成同时带 `pgvector` 和 `pg_cron` 的镜像(比如 `supabase/postgres`,或自己 build 一份),把 `shared_preload_libraries=pg_cron` 配上、重启 Postgres然后注册一次任务
```sql
CREATE EXTENSION IF NOT EXISTS pg_cron;
SELECT cron.schedule(
'rollup_task_usage_hourly',
'*/5 * * * *',
$$SELECT rollup_task_usage_hourly()$$
);
```
**Option C —— 先回填历史(升级路径)。** 如果你是从 `v0.3.4` 升级到 `v0.3.5+` 且数据库里已经有 `task_usage` 行migration `103` 会以 `refusing to drop legacy daily rollups: ...` 报错并中止 `migrate up`,直到 hourly 表被 seed 过。先跑一次内置的 backfill 命令,然后再配 Option A 或 Option B 让新数据持续流进来:
```bash
docker compose -f docker-compose.selfhost.yml exec backend \
./backfill_task_usage_hourly --sleep-between-slices=2s
```
`--sleep-between-slices=2s` 用来在繁忙的数据库上限制读压力。回填跑完后重启后端容器migration 在启动时自动跑),升级就能继续。
完整参考(含 Kubernetes `CronJob` 模板和升级顺序)见仓库的 [`SELF_HOSTING_ADVANCED.md → Usage Dashboard Rollup`](https://github.com/multica-ai/multica/blob/main/SELF_HOSTING_ADVANCED.md#usage-dashboard-rollup)。
## Kubernetes 部署(替代方案)
如果你已经在跑 Kubernetes 集群,仓库里也带了一个 Helm chart路径 `deploy/helm/multica/`。它就是 k8s 版的 `make selfhost`——一样的 backend 镜像、frontend 镜像、`pgvector/pgvector:pg17` Postgres封装成 Deployment / Service / Ingress再加上一个由 `values.yaml` 渲染出来的 `ConfigMap`。这套 chart 是按照 k3s + Traefik + `local-path` 写的,集群里只要有 Ingress controller 和默认的 `ReadWriteOnce` StorageClass 就能跑,其他类型的集群稍微改一改也能用。
这个 chart **不会模板化任何敏感值**。它通过 name 引用一个叫 `multica-secrets` 的 Secret所以真实的 JWT / DB / Resend / Google 密钥永远不用进 git也不用进 `values.yaml`。先用 kubectl 一次性把命名空间和 Secret 建好:
```bash
kubectl create namespace multica
kubectl -n multica create secret generic multica-secrets \
--from-literal=JWT_SECRET="$(openssl rand -hex 32)" \
--from-literal=POSTGRES_PASSWORD="$(openssl rand -hex 16)" \
--from-literal=RESEND_API_KEY="" \
--from-literal=GOOGLE_CLIENT_SECRET="" \
--from-literal=CLOUDFRONT_PRIVATE_KEY="" \
--from-literal=MULTICA_DEV_VERIFICATION_CODE=""
```
再装 chart
```bash
git clone https://github.com/multica-ai/multica.git
cd multica
helm install multica deploy/helm/multica -n multica
```
默认主机名是 `multica.dev.lan`web和 `api.multica.dev.lan`backend。把它们加进 `/etc/hosts`(或者本地 DNS指向任意一个 Ingress 可达的节点 IP 就行。要换主机名,就把 `deploy/helm/multica/values.yaml` 复制一份,改掉 `ingress.frontend.host` / `ingress.backend.host`,再把 `backend.config.appUrl` / `frontendOrigin` / `localUploadBaseUrl` / `googleRedirectUri` 改成相应的地址,然后 `helm install ... -f my-values.yaml`。
冷集群上 backend 可能会 `Running` 但 `Not Ready` 持续几分钟,等 Postgres 起来并跑完 migration——startupProbe 会兜住这一段pod 不会被 liveness 重启。等它 `Ready` 之后:
```bash
curl -H "Host: api.multica.dev.lan" http://<ingress-ip>/healthz
# {"status":"ok","checks":{"db":"ok","migrations":"ok"}}
```
然后浏览器打开 `http://multica.dev.lan`,回到上面的 [第 4 步——首次登录](#4-首次登录--创建工作区) 继续。命令行连到你的 Ingress 主机:
```bash
multica setup self-host \
--server-url http://api.multica.dev.lan \
--app-url http://multica.dev.lan
```
只想拉最新镜像、不动 chart`kubectl -n multica rollout restart deploy/multica-backend deploy/multica-frontend`。要锁到某个 Multica 版本,就在 values 文件里设 `images.backend.tag` / `images.frontend.tag`,再 `helm upgrade`。`helm -n multica uninstall multica` 只删工作负载PVC 和 Secret 都保留;`kubectl delete namespace multica` 才会全清。
完整参考——三种登录方式、为了绕过 web 镜像 build-time 写死的 `REMOTE_API_URL` 而加的 `backend` ExternalName 别名、资源限制、TLS——都在仓库的 [`SELF_HOSTING.md`](https://github.com/multica-ai/multica/blob/main/SELF_HOSTING.md#kubernetes-deployment-alternative)。
## 常见问题
- **后端起不来**:看容器日志 `docker compose -f docker-compose.selfhost.yml logs backend`;常见是 `.env` 里 `DATABASE_URL` 或 `JWT_SECRET` 有问题
- **验证码收不到**没配任何邮件后端Resend 和 SMTP 都没设) → 从 `docker compose logs backend` 里找 `[DEV] Verification code`
- **WebSocket 连不上**:公网部署必须设 `FRONTEND_ORIGIN` 成你真实的前端域名;见 [故障排查 → WebSocket 连不上](/troubleshooting#websocket-连不上)
- **Usage / Runtime 看板一直是 0**:没人调度 `rollup_task_usage_hourly()` —— 见上面的 [第 7 步](#7-调度用量汇总任务usage-dashboard-必需) 和 [故障排查 → Usage 看板一直是 0](/troubleshooting#usage-看板一直是-0)
- **`migrate up` 报 `refusing to drop legacy daily rollups`**`v0.3.4 → v0.3.5+` 升级路径的 fail-closed guard。先跑 `backfill_task_usage_hourly` —— 见 [第 7 步 → Option C](#7-调度用量汇总任务usage-dashboard-必需)
## 下一步

View File

@@ -31,7 +31,7 @@ Both individual files and whole skill packs have size caps (single-file cap arou
Once imported, a skill has to be **attached to a specific agent** to take effect. One agent can have multiple skills attached, and one skill can be attached to multiple agents.
After attaching, the agent picks up its skills the next time it starts a task — each AI coding tool has its own skill discovery path (Claude Code uses `.claude/skills/`, Cursor uses `.cursor/skills/`, etc.), and Multica drops files in the right place automatically. **However, three tools (Gemini, Hermes, OpenClaw) currently use the generic fallback path `.agent_context/skills/` — whether these tools actually read skills from that path depends on the tool itself.** Full path mapping and the native-discovery vs. fallback distinction is in [AI coding tools comparison → Where skill files go](/providers#where-skill-files-go).
After attaching, the agent picks up its skills the next time it starts a task — each AI coding tool has its own skill discovery path (Claude Code uses `.claude/skills/`, Cursor uses `.cursor/skills/`, Antigravity uses `.agents/skills/`, etc.), and Multica drops files in the right place automatically. **However, three tools (Gemini, Hermes, OpenClaw) currently use the generic fallback path `.agent_context/skills/` — whether these tools actually read skills from that path depends on the tool itself.** Full path mapping and the native-discovery vs. fallback distinction is in [AI coding tools comparison → Where skill files go](/providers#where-skill-files-go).
After you edit a skill's contents, **only newly created tasks pick up the new version** — tasks already running continue with the old skill.
@@ -64,4 +64,4 @@ By now you know what an agent is, how to create one, and how to attach skills. T
- [Daemon and runtimes](/daemon-runtimes) — where agents actually run, and how to tell online from offline
- [Executing tasks](/tasks) — the full lifecycle of one "agent work session"
- [AI coding tools comparison](/providers) — full comparison of all 11 tools (including each one's skill injection path)
- [AI coding tools comparison](/providers) — full comparison of all 12 tools (including each one's skill injection path)

View File

@@ -31,7 +31,7 @@ Multica 支持两种 Skill 来源:
Skill 导入后需要**挂载到具体的智能体**才会生效。一个智能体能挂多个 Skill一个 Skill 也能挂到多个智能体。
挂上之后,智能体下次开工时会自动拿到挂着的 Skill——不同 AI 编程工具有各自的 Skill 发现路径Claude Code 是 `.claude/skills/`、Cursor 是 `.cursor/skills/` 等Multica 会自动放到对的位置。**但有 3 款工具Gemini / Hermes / OpenClaw当前走的是通用 fallback 路径 `.agent_context/skills/`——这些工具能否真的从这里读到 skill取决于工具本身是否支持**。完整路径对照和原生发现 vs fallback 的区分见 [AI 编程工具对照 → skill 文件该放哪儿](/providers#skill-文件该放哪儿)。
挂上之后,智能体下次开工时会自动拿到挂着的 Skill——不同 AI 编程工具有各自的 Skill 发现路径Claude Code 是 `.claude/skills/`、Cursor 是 `.cursor/skills/`、Antigravity 是 `.agents/skills/`Multica 会自动放到对的位置。**但有 3 款工具Gemini / Hermes / OpenClaw当前走的是通用 fallback 路径 `.agent_context/skills/`——这些工具能否真的从这里读到 skill取决于工具本身是否支持**。完整路径对照和原生发现 vs fallback 的区分见 [AI 编程工具对照 → skill 文件该放哪儿](/providers#skill-文件该放哪儿)。
修改 Skill 的内容后,**只有之后新创建的任务会拿到新版本**——正在跑的任务继续用旧版 Skill。
@@ -64,4 +64,4 @@ Skill 导入后需要**挂载到具体的智能体**才会生效。一个智能
- [守护进程与运行时](/daemon-runtimes) —— 智能体到底跑在哪、怎么判断在线 / 离线
- [执行任务](/tasks) —— 一次"智能体工作"的完整生命周期
- [AI 编程工具对照](/providers) —— 11 款工具的完整对比(含每款的 Skill 注入路径)
- [AI 编程工具对照](/providers) —— 12 款工具的完整对比(含每款的 Skill 注入路径)

View File

@@ -77,8 +77,9 @@ multica issue rerun <issue-id>
Behavior:
- Targets the issue's **current agent assignee** — not whoever ran the most recent task. If the assignee changed since the last run, rerun follows the current assignment. To rerun a specific agent that is no longer the assignee, reassign the issue first, then rerun.
- **Cancels** the assignee's queued or running task on this issue (if any). Tasks owned by other agents on the same issue (e.g. parallel @-mention runs) are left alone.
- By default, targets the issue's **current agent assignee** — useful when you want the rerun to follow the current assignment regardless of who ran the prior task.
- The execution-log retry button on a specific row sends that row's task ID alongside, so the rerun targets **the agent that ran that exact task** — not the current assignee. This makes per-row retry meaningful for squad workers, parallel @-mention agents, or rows whose agent has since been displaced by a reassignment.
- **Cancels** the target agent's queued or running task on this issue (if any). Tasks owned by other agents on the same issue (e.g. parallel @-mention runs) are left alone.
- Creates a **brand-new** task — attempt count resets to 1, even if the original task hit the attempt ceiling.
- Starts a **fresh agent session** — the prior session ID is **not** inherited. A manual rerun means you've judged the previous output bad, so resuming the same conversation would replay the same poisoned state. (Automatic retry, by contrast, does inherit the session — that path is for infrastructure failures, not bad output.)
@@ -89,7 +90,7 @@ Comparison:
| Trigger | System, based on failure reason | You, manually |
| Ceiling | 2 attempts | No limit |
| Applicable sources | Issues, chat | Issues with an agent assignee |
| Agent picked | Same agent as the failed task | Issue's current assignee |
| Agent picked | Same agent as the failed task | Source task's agent (UI per-row retry) or issue's current assignee (CLI / no task_id) |
| Session inheritance | Yes (resumes prior session) | No (fresh session) |
## How a failed task affects issue status
@@ -104,7 +105,7 @@ Multica pins the session ID **twice** during a task: once at the start (when the
But **which AI coding tools actually support this** varies a lot:
- ✅ **Real support** — Claude Code, Copilot, Hermes, Kimi, Kiro CLI, OpenCode, OpenClaw, Pi
- ✅ **Real support** — Antigravity, Claude Code, Copilot, Hermes, Kimi, Kiro CLI, OpenCode, OpenClaw, Pi
- ⚠️ **Code exists but unusable** — Codex, Cursor
- ❌ **No support** — Gemini
@@ -112,5 +113,5 @@ See [Providers Matrix → Session resumption](/providers#session-resumption-who-
## Next
- [Providers Matrix](/providers) — capability differences across the 11 AI coding tools (including the exact session-resumption status)
- [Providers Matrix](/providers) — capability differences across the 12 AI coding tools (including the exact session-resumption status)
- [Assigning issues to agents](/assigning-issues) / [@-mentioning agents in comments](/mentioning-agents) / [Chat](/chat) / [Autopilots](/autopilots) — the four ways to trigger a task

View File

@@ -77,8 +77,9 @@ multica issue rerun <issue-id>
行为:
- 跑的是 issue **当前的智能体分配人**——不是上一次跑过的 agent。如果分配人在上次运行后改了rerun 会跟着新的分配人走。要重跑一个已经不再是分配人的智能体,先把 issue 改派回它,再 rerun
- **取消**该分配人在这条 issue 上 queued / running 的任务(如果有)。同 issue 上其它 agent 的任务(例如 @-mention 触发的并行任务)不会被一起取消
- 默认跑的是 issue **当前的智能体分配人**——适用于希望 rerun 跟随当前分配人的场景
- 执行日志里某一行的 retry 按钮会把这一行的 task ID 一并发出rerun 会**针对那一行原本的 agent**,而不是当前分配人。这让 squad worker、并行的 @-mention agent、或者已经被新分配人替代的旧任务行的 retry 按钮都能符合直觉地工作
- **取消**目标 agent 在这条 issue 上 queued / running 的任务(如果有)。同 issue 上其它 agent 的任务(例如 @-mention 触发的并行任务)不会被一起取消。
- 创建一个**全新**的执行任务——尝试次数重置为 1即使原任务已达最大尝试。
- 启动**全新的智能体会话**——**不**继承之前的会话 ID。手动重跑意味着你已经判定上一次的产出不行再继续之前的对话只会重放被污染的上下文。自动重试则相反会继承会话——那条路径处理的是基础设施层面的失败不是产出不好。
@@ -89,7 +90,7 @@ multica issue rerun <issue-id>
| 触发 | 系统基于失败原因自动执行 | 你主动发起 |
| 上限 | 2 次 | 无上限 |
| 适用来源 | issue、聊天 | 有智能体分配人的 issue |
| 跑哪个 agent | 失败任务原本的 agent | issue 当前的分配人 |
| 跑哪个 agent | 失败任务原本的 agent | UI 单行 retry那一行任务的 agentCLI / 不带 task_idissue 当前的分配人 |
| 会话继承 | 是(接着上次会话) | 否(全新会话) |
## 失败的任务对 issue 状态有什么影响
@@ -104,7 +105,7 @@ Multica 在任务过程中**两次**保存会话 ID——任务一开始AI
但**哪些 AI 编程工具真的支持**差别很大:
- ✅ **真支持**——Claude Code、Copilot、Hermes、Kimi、Kiro CLI、OpenCode、OpenClaw、Pi
- ✅ **真支持**——Antigravity、Claude Code、Copilot、Hermes、Kimi、Kiro CLI、OpenCode、OpenClaw、Pi
- ⚠️ **代码看起来支持但实际不可用**——Codex、Cursor
- ❌ **不支持**——Gemini
@@ -112,5 +113,5 @@ Multica 在任务过程中**两次**保存会话 ID——任务一开始AI
## 下一步
- [Providers Matrix](/providers) —— 11 款 AI 编程工具的能力差异对照(包括会话恢复的精确状态)
- [Providers Matrix](/providers) —— 12 款 AI 编程工具的能力差异对照(包括会话恢复的精确状态)
- [分配 issue 给智能体](/assigning-issues) / [在评论里 @智能体](/mentioning-agents) / [聊天](/chat) / [Autopilots](/autopilots) —— 触发执行任务的四种方式

View File

@@ -1,6 +1,6 @@
---
title: Troubleshooting
description: The top 7 common issues when self-hosting Multica — symptoms, causes, how to diagnose, how to fix.
description: Common issues when self-hosting Multica — symptoms, causes, how to diagnose, how to fix.
---
import { Callout } from "fumadocs-ui/components/callout";
@@ -89,6 +89,20 @@ On the server side (self-host), grep for `"no_tasks"` / `"no_capacity"` to see t
**Symptom**: after submitting an email during sign-in or invite acceptance, neither the inbox nor the spam folder has the verification code.
**First, confirm which provider the server thinks is active.** At startup the backend prints one of:
- `EmailService: SMTP relay <host>:<port> from=<addr>` — using SMTP (`SMTP_HOST` non-empty wins over Resend)
- `EmailService: Resend API from=<addr>` — using Resend
- `EmailService: DEV mode — codes printed to stdout …` — no provider configured
```bash
docker compose -f docker-compose.selfhost.yml logs backend | grep "EmailService:"
```
If the line you expected isn't there, the environment didn't reach the process — check `.env` and `docker compose -f docker-compose.selfhost.yml exec backend env | grep -E 'RESEND_|SMTP_'`. Credentials are never logged on this startup line.
### When Resend is the active provider
**Likely causes**:
1. **`RESEND_API_KEY` not set** — the server silently falls back and **writes the code to its own stdout** without error. Easy to trip over in production
@@ -108,6 +122,34 @@ 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
### When SMTP is the active provider
The SMTP path wraps every failure with the stage it failed at, so the server logs already tell you where the relay rejected the session. Grep for `"failed to send verification email"` / `"failed to send invitation email"` and check the wrapped error:
| Logged error | What it means | How to fix |
|---|---|---|
| `smtp dial <host>:<port>: dial tcp …: connect: connection refused` / `i/o timeout` | The backend container can't reach the relay — wrong host, wrong port, firewall, or the relay isn't listening | Verify `SMTP_HOST` / `SMTP_PORT` resolve from inside the container (`docker compose -f docker-compose.selfhost.yml exec backend nslookup <host>` and `nc -vz <host> <port>`); open the firewall from the host running Multica to the relay |
| `smtp starttls: x509: certificate signed by unknown authority` (or `certificate is not valid for any names`) | The relay uses a private CA / self-signed cert and the container's trust store rejects it | Either install the CA into the container, or set `SMTP_TLS_INSECURE=true` only after confirming the relay is reachable on a trusted segment |
| `smtp auth: 535 5.7.8 Authentication credentials invalid` (or `534`/`530`) | `SMTP_USERNAME` / `SMTP_PASSWORD` are wrong, or the relay requires a different auth mechanism than `PLAIN` | Re-confirm the service-account credentials with your mail admin; for Exchange anonymous internal relay leave both empty (`SMTP_USERNAME=`, `SMTP_PASSWORD=`) |
| `smtp MAIL FROM: 550 5.7.1 Client does not have permissions to send as this sender` | The relay won't accept `RESEND_FROM_EMAIL` as the envelope sender — typical Exchange "anonymous users not allowed" or DMARC alignment issue | Set `RESEND_FROM_EMAIL` to a domain the relay accepts; on Exchange, grant the source IP `ms-Exch-SMTP-Accept-Any-Sender` on the receive connector |
| `smtp RCPT TO <addr>: 550 5.7.1 Unable to relay` | The relay's receive connector doesn't allow your subnet to relay to external recipients (most common for anonymous internal relays talking to outside domains) | Either restrict invites to internal recipients, or add the Multica host's subnet to the Exchange "Anonymous Users → Relay" permission list |
| `smtp DATA` / `smtp write body` / `smtp end data` | Session was accepted but the relay dropped the body — usually message-size limits, content filtering, or a connection reset mid-stream | Check the relay's logs for the same `Message-ID` (logged as `<unixnano>@<host>`); raise the message size limit if needed |
`MAIL FROM`, `RCPT TO`, and `DATA` errors are always logged with the relay's response code so you can match them against Exchange / Postfix logs on the other side. Verification codes and invite tokens are **never** included in the wrapped error.
**How to diagnose**:
- Grep `"EmailService: SMTP relay"` once at startup, then `"failed to send"` for runtime failures
- From inside the backend container, sanity-check connectivity: `docker compose -f docker-compose.selfhost.yml exec backend sh -c 'nc -vz $SMTP_HOST $SMTP_PORT'`
- Confirm the env reached the process: `docker compose -f docker-compose.selfhost.yml exec backend env | grep SMTP_` (password will be in the output — only run on a trusted shell)
**How to fix**:
- Wrong host / port → adjust `SMTP_HOST` / `SMTP_PORT` and restart the backend; for the supported relay modes see [Auth setup → Option B: SMTP relay](/auth-setup)
- Cert mismatch → install the relay's CA into the container, or temporarily `SMTP_TLS_INSECURE=true` on a trusted segment
- Auth failure → re-check credentials; for anonymous internal relay leave `SMTP_USERNAME` and `SMTP_PASSWORD` empty
- `Unable to relay` → either restrict to internal recipients or grant the Multica host's IP relay permission on the Exchange receive connector
## Fixed local test code doesn't work
**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`.
@@ -132,6 +174,75 @@ Check your inbox (including spam) for the real verification code.
- 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))
## Usage dashboard stays at zero
**Symptom**: agents complete tasks, raw token usage is written to the database, but **Settings → Usage** and **Settings → Runtime** show 0 input / output / cost across the board. This is silent — there is no error in the backend logs.
**Likely causes**:
1. **`rollup_task_usage_hourly()` is never scheduled** — the Usage / Runtime dashboards read from the derived `task_usage_hourly` table, which is populated by that function. The bundled `pgvector/pgvector:pg17` image does not include `pg_cron`, and the backend does not run the rollup in-process either. On a fresh self-host install with no external scheduler, this is the default state.
2. **`pg_cron` is installed but pointing at the wrong database** — `pg_cron.database_name` defaults to `postgres`; if your Multica database has a different name, the scheduled job never sees `rollup_task_usage_hourly()`.
3. **The scheduler is running but the rollup is silently erroring** — e.g. wrong DB role / search_path inside the cron entry.
**How to diagnose**:
```sql
-- Confirm raw events exist but the hourly table is empty.
SELECT count(*) AS raw_rows FROM task_usage;
SELECT count(*) AS hourly_rows FROM task_usage_hourly;
-- Confirm pg_cron is (or isn't) available.
SELECT * FROM pg_available_extensions WHERE name = 'pg_cron';
SHOW shared_preload_libraries;
-- If pg_cron is installed, check the schedule + last run.
SELECT jobname, schedule, database, active FROM cron.job;
SELECT jobname, status, return_message, start_time, end_time
FROM cron.job_run_details ORDER BY start_time DESC LIMIT 10;
-- Watermark — if this is 1970-01-01, the rollup has never run.
SELECT watermark_at FROM task_usage_hourly_rollup_state;
```
**How to fix**:
- Call the rollup once by hand to confirm it works: `SELECT rollup_task_usage_hourly();` — refresh the dashboard; if numbers appear, the only missing piece is a scheduler.
- Pick one of the supported paths from [Self-host quickstart → Schedule the usage rollup](/self-host-quickstart#7-schedule-the-usage-rollup-required-for-the-usage-dashboard): external cron / systemd-timer / Kubernetes CronJob, or swap Postgres for an image with `pg_cron`.
- If you already have history that pre-dates the schedule, run `backfill_task_usage_hourly` inside the backend container to seed buckets before the watermark.
## Migration `103` fails with `refusing to drop legacy daily rollups`
**Symptom**: upgrading from `v0.3.4` to `v0.3.5+`, the backend container fails to start (or `migrate up` aborts) with:
```text
ERROR: refusing to drop legacy daily rollups:
task_usage_hourly_rollup_state.watermark_at (1970-01-01 ...) trails
task_usage latest event (...) by more than 01:00:00 — backfill is
incomplete or pg_cron is not running. Run cmd/backfill_task_usage_hourly
(and let pg_cron catch up) before re-running migrate
```
**Likely cause**: this is migration `103`'s fail-closed guard. It refuses to drop the legacy daily rollups until `task_usage_hourly` has caught up with raw `task_usage`. The guard fires whenever existing rows are present and the rollup watermark still sits at the epoch — i.e. nothing has rolled history into the hourly table yet.
**How to fix**:
1. Run the backfill against the same database (idempotent, safe to interrupt, safe to re-run):
```bash
# Docker Compose
docker compose -f docker-compose.selfhost.yml exec backend \
./backfill_task_usage_hourly --sleep-between-slices=2s
# Kubernetes
kubectl -n multica exec deploy/multica-backend -- \
./backfill_task_usage_hourly --sleep-between-slices=2s
```
2. Re-run the upgrade — restarting the backend container is enough, migrations run on startup. The guard now sees a current watermark and lets `103` apply.
3. Set up an ongoing rollup schedule (cron / `pg_cron`) so the watermark keeps advancing — see [Self-host quickstart → Schedule the usage rollup](/self-host-quickstart#7-schedule-the-usage-rollup-required-for-the-usage-dashboard).
`--sleep-between-slices=2s` is a polite default on production databases with years of history. Use `--months-back N --force-partial` if you only want to keep the last N months and are willing to permanently abandon older buckets.
## Port conflicts
**Symptom**: `multica server` or `multica daemon start` fails with `address already in use`.

View File

@@ -1,6 +1,6 @@
---
title: 故障排查
description: self-host Multica 遇到的 Top 7 常见问题——症状、原因、怎么查、怎么修。
description: self-host Multica 常见问题——症状、原因、怎么查、怎么修。
---
import { Callout } from "fumadocs-ui/components/callout";
@@ -89,6 +89,20 @@ multica issue show <issue-id> # 看 task 历史
**症状**:登录或邀请时提交邮箱后,收件箱(和垃圾邮件)里都没有验证码邮件。
**先确认 server 自己认为在用哪个 provider。** 启动时 backend 会打印这三种之一:
- `EmailService: SMTP relay <host>:<port> from=<addr>` —— 走 SMTP`SMTP_HOST` 非空时优先级高于 Resend
- `EmailService: Resend API from=<addr>` —— 走 Resend
- `EmailService: DEV mode — codes printed to stdout …` —— 没配任何 provider
```bash
docker compose -f docker-compose.selfhost.yml logs backend | grep "EmailService:"
```
如果应该出现的那行没出现,说明环境变量没进到进程 —— 检查 `.env` 和 `docker compose -f docker-compose.selfhost.yml exec backend env | grep -E 'RESEND_|SMTP_'`。这行启动日志里**不会**打印任何密码。
### Resend 是当前 provider
**可能原因**
1. **`RESEND_API_KEY` 没配** —— server 会静默回落,**把验证码打到自己的 stdout 里**,不报错。生产部署很容易踩
@@ -108,6 +122,34 @@ multica issue show <issue-id> # 看 task 历史
- 域名没验证 → Resend console 里走 DNS 验证流程(加 SPF / DKIM 记录)
- 紧急情况下(如内部测试)→ 从 server 日志里抄 `[DEV]` 打印出的验证码
### SMTP 是当前 provider
SMTP 路径把每个失败都按阶段包装好,所以 server 日志已经告诉你 relay 在哪一步拒绝了会话。搜 `"failed to send verification email"` / `"failed to send invitation email"`,看里面包的具体错误:
| 错误日志 | 含义 | 怎么修 |
|---|---|---|
| `smtp dial <host>:<port>: dial tcp …: connect: connection refused` / `i/o timeout` | backend 容器连不上 relay —— host / port 错、防火墙挡了、或者 relay 没开 | 在容器里确认能解析和连通:`docker compose -f docker-compose.selfhost.yml exec backend nslookup <host>` 以及 `nc -vz <host> <port>`;放行从 Multica 主机到 relay 的网络 |
| `smtp starttls: x509: certificate signed by unknown authority`(或 `certificate is not valid for any names` | relay 用了私有 CA / 自签证书,容器的信任库不接受 | 要么把 CA 装进容器,要么在确认 relay 走的是可信网段后设 `SMTP_TLS_INSECURE=true` |
| `smtp auth: 535 5.7.8 Authentication credentials invalid`(或 `534`/`530` | `SMTP_USERNAME` / `SMTP_PASSWORD` 不对,或 relay 不接受 `PLAIN` 认证 | 找邮件管理员复核 service account 凭据Exchange 匿名内部 relay 应当把两者都留空 |
| `smtp MAIL FROM: 550 5.7.1 Client does not have permissions to send as this sender` | relay 不接受 `RESEND_FROM_EMAIL` 作为信封发件人 —— Exchange 常见 "anonymous users not allowed" 或 DMARC 对齐问题 | 把 `RESEND_FROM_EMAIL` 改成 relay 接受的域名Exchange 上给来源 IP 授 `ms-Exch-SMTP-Accept-Any-Sender` |
| `smtp RCPT TO <addr>: 550 5.7.1 Unable to relay` | relay 的 receive connector 不允许这个子网把邮件中继到外部收件人(匿名内部 relay 发给外部域时常见) | 邀请仅限内部域,或者把 Multica 主机的子网加进 Exchange "Anonymous Users → Relay" 权限列表 |
| `smtp DATA` / `smtp write body` / `smtp end data` | 会话被接受但 body 被丢 —— 通常是消息大小限制、内容过滤、或中途断连 | 在 relay 端按同一个 `Message-ID`(日志里是 `<unixnano>@<host>`)找上下文;必要时调大消息大小限制 |
`MAIL FROM` / `RCPT TO` / `DATA` 的错误日志里都带着 relay 返回的状态码,可以和 Exchange / Postfix 那边的日志对齐。验证码和邀请 token **不会**出现在这些包装的错误里。
**怎么查**
- 启动时搜 `"EmailService: SMTP relay"` 一次,运行时搜 `"failed to send"` 看具体阶段
- 在 backend 容器里测连通:`docker compose -f docker-compose.selfhost.yml exec backend sh -c 'nc -vz $SMTP_HOST $SMTP_PORT'`
- 确认环境变量真进到了进程:`docker compose -f docker-compose.selfhost.yml exec backend env | grep SMTP_`(这条会带出密码,仅在可信终端运行)
**怎么修**
- host / port 不对 → 改 `SMTP_HOST` / `SMTP_PORT` 后重启 backend支持的 relay 模式见 [登录与注册配置 → Option BSMTP relay](/auth-setup)
- 证书校验失败 → 把 relay 的 CA 装进容器,或在可信网段上临时 `SMTP_TLS_INSECURE=true`
- 认证失败 → 复核凭据;匿名内部 relay 应把 `SMTP_USERNAME` 和 `SMTP_PASSWORD` 都留空
- `Unable to relay` → 邀请仅限内部域,或在 Exchange receive connector 上给 Multica 主机授中继权限
## 固定本地测试验证码登不进去
**症状**:自部署实例,想用 `888888` 这类固定本地测试验证码登录,但被拒 `invalid or expired code`。
@@ -132,6 +174,75 @@ docker exec <container> env | grep -E 'APP_ENV|MULTICA_DEV_VERIFICATION_CODE'
- 生产环境保持 `MULTICA_DEV_VERIFICATION_CODE` 为空,配好 Resend 后使用真实验证码
- 本地开发或内网测试可以从 server 日志抄生成的验证码;如果需要 `888888`,设置 `APP_ENV=development` 和 `MULTICA_DEV_VERIFICATION_CODE=888888`。不要在公网实例启用固定验证码(详见 [登录与注册配置 → 固定本地测试验证码](/auth-setup#固定本地测试验证码)
## Usage 看板一直是 0
**症状**agent 执行完任务、原始的 token 用量已经写入数据库,但 **Settings → Usage** 和 **Settings → Runtime** 上输入 / 输出 / 成本全部显示 0。**没有任何报错**——这是静默故障。
**可能原因**
1. **`rollup_task_usage_hourly()` 没人调度** —— Usage / Runtime 看板读的是派生表 `task_usage_hourly`,这张表必须靠 `rollup_task_usage_hourly()` 周期性填充。默认的 `pgvector/pgvector:pg17` 镜像不带 `pg_cron`,后端进程内部也不会跑 rollup。如果你是新装的自部署、没配过外部调度器默认就是这种状态。
2. **`pg_cron` 装了但指向了错的库** —— `pg_cron.database_name` 默认是 `postgres`;如果你的 Multica 数据库名不是 `postgres`,调度任务根本看不到 `rollup_task_usage_hourly()`。
3. **调度跑了,但 rollup 静默报错** —— 比如 cron entry 里 DB role / search_path 不对。
**怎么查**
```sql
-- 确认原始数据有、hourly 表是空的
SELECT count(*) AS raw_rows FROM task_usage;
SELECT count(*) AS hourly_rows FROM task_usage_hourly;
-- 看 pg_cron 装没装、有没有加载
SELECT * FROM pg_available_extensions WHERE name = 'pg_cron';
SHOW shared_preload_libraries;
-- 如果 pg_cron 装了,看调度和最近一次运行
SELECT jobname, schedule, database, active FROM cron.job;
SELECT jobname, status, return_message, start_time, end_time
FROM cron.job_run_details ORDER BY start_time DESC LIMIT 10;
-- watermark —— 如果还是 1970-01-01说明 rollup 从来没跑过
SELECT watermark_at FROM task_usage_hourly_rollup_state;
```
**怎么修**
- 手动跑一次确认函数本身没问题:`SELECT rollup_task_usage_hourly();` —— 刷新看板;如果数字出来了,缺的就只是调度器。
- 从 [Self-host 快速上手 → 调度用量汇总任务](/self-host-quickstart#7-调度用量汇总任务usage-dashboard-必需) 里挑一种调度方式:外部 cron / systemd-timer / Kubernetes CronJob或者换成带 `pg_cron` 的 Postgres 镜像。
- 如果调度配好之前数据库已经有一段历史,先在后端容器里跑 `backfill_task_usage_hourly` 把 watermark 之前的桶补出来。
## migration `103` 报 `refusing to drop legacy daily rollups`
**症状**:从 `v0.3.4` 升级到 `v0.3.5+` 时,后端容器起不来(或 `migrate up` 中止),错误信息:
```text
ERROR: refusing to drop legacy daily rollups:
task_usage_hourly_rollup_state.watermark_at (1970-01-01 ...) trails
task_usage latest event (...) by more than 01:00:00 — backfill is
incomplete or pg_cron is not running. Run cmd/backfill_task_usage_hourly
(and let pg_cron catch up) before re-running migrate
```
**可能原因**:这是 migration `103` 的 fail-closed guard。它要求 `task_usage_hourly` 已经追平了原始的 `task_usage` 之后,才允许丢掉旧的 daily rollup。只要数据库里有历史数据、且 rollup watermark 还停在 epoch说明还没把历史回填进 hourly 表),这条 guard 就会拦住。
**怎么修**
1. 对同一个数据库跑一次 backfill幂等可以打断可以重试
```bash
# Docker Compose
docker compose -f docker-compose.selfhost.yml exec backend \
./backfill_task_usage_hourly --sleep-between-slices=2s
# Kubernetes
kubectl -n multica exec deploy/multica-backend -- \
./backfill_task_usage_hourly --sleep-between-slices=2s
```
2. 重新跑升级 —— 重启 backend 容器即可,启动时会自动跑 migration。Guard 看到新的 watermark`103` 就会通过。
3. 同时配上持续的 rollup 调度,保证 watermark 持续推进 —— 见 [Self-host 快速上手 → 调度用量汇总任务](/self-host-quickstart#7-调度用量汇总任务usage-dashboard-必需)。
`--sleep-between-slices=2s` 在有多年历史的生产库上是个比较克制的默认值。如果你只想保留最近 N 个月、可以接受永久丢掉更老的桶,用 `--months-back N --force-partial`。
## 端口冲突
**症状**`multica server` 或 `multica daemon start` 启动失败,报 `address already in use`。

34
apps/mobile/.env.example Normal file
View File

@@ -0,0 +1,34 @@
# Mobile env template — copy this to one of:
# .env.development.local (used by `*:mobile` — local backend)
# .env.staging (used by `*:mobile:staging` — remote staging)
#
# All five mobile scripts read one of these two files, depending on suffix:
# dev:mobile / dev:mobile:staging — Metro only
# ios:mobile:device / ios:mobile:device:staging — Debug build to iPhone
# ios:mobile:device:staging:release — Release build to iPhone
#
# How EXPO_PUBLIC_* values reach the installed app:
# - Metro reads this file once at startup and inlines the values into every
# JS bundle it serves. Editing the file mid-session does NOT auto-refresh
# — restart Metro (Ctrl+C, then re-run `dev:mobile*`) to pick up changes.
# - For an installed Release build the value is baked into the embedded
# bundle at `ios:*:release` time; the only way to change it is to re-run
# the release build.
#
# Phone must be able to reach this URL. For local dev use your Mac's LAN IP
# (run `ipconfig getifaddr en0`), not `localhost` / `127.0.0.1`.
#
# Staging URL: see apps/desktop/.env.staging (`VITE_API_URL`) for the canonical
# value, or ask a teammate. Same backend across mobile / web / desktop.
EXPO_PUBLIC_API_URL=https://<api-host>
# Optional. Overrides the iOS bundleIdentifier for the DEV variant only so a
# dev whose Apple ID isn't on the Multica Apple Developer team yet can still
# sign local builds. Use a reverse-domain you own (e.g. com.<yourname>.multica).
# Leave unset to use the default ai.multica.mobile.dev.
#
# Only read in `.env.development.local` — staging / production bundle ids are
# never overridable (variants must stay on their canonical ids so the same
# device can hold all three side-by-side).
# EXPO_BUNDLE_IDENTIFIER_DEV=com.yourname.multica.dev

View File

@@ -0,0 +1,5 @@
# Mobile production env — committed so external users can build a personal
# iPhone copy of Multica against the same backend as multica.ai on web.
# Loaded by the `*:prod` scripts via dotenv-cli (see package.json).
EXPO_PUBLIC_API_URL=https://api.multica.ai
EXPO_PUBLIC_WEB_URL=https://multica.ai

10
apps/mobile/.env.staging Normal file
View File

@@ -0,0 +1,10 @@
# Used by `pnpm dev:mobile:staging` and the `ios:device:staging[:release]`
# scripts. Loaded via `dotenv-cli` (see package.json), NOT by Expo's auto-
# loader — Expo only auto-loads .env.<NODE_ENV>.local files.
EXPO_PUBLIC_API_URL=https://multica-api.copilothub.ai
# Optional. Enables "Copy link" / "Open on web" actions in issue / project /
# comment menus. Without it those menu items just don't appear. Fill in the
# staging web host when you have it (canonical value lives in
# apps/desktop/.env.staging on a teammate's machine).
# EXPO_PUBLIC_WEB_URL=https://<staging-web-host>

34
apps/mobile/.gitignore vendored Normal file
View File

@@ -0,0 +1,34 @@
node_modules/
.expo/
dist/
web-build/
# macOS
.DS_Store
# Local env files only. `.env.staging` is committed — the override that
# rescues it from the repo-root `.env*` ignore rule lives in the root
# .gitignore (`!apps/mobile/.env.staging`).
.env*.local
# Native (Expo prebuild output)
ios/
android/
# Override the root .gitignore "data/" rule (intended for backend runtime
# dirs). apps/mobile/data/ is source — TanStack Query queries, mutations,
# stores, ApiClient — and MUST be tracked.
!data/
!data/**
# Re-ignore macOS metadata that the broader `!data/**` rule above would
# otherwise pull back in. The `.DS_Store` line at the top of this file is
# overridden by the negation, so we restate it here in last-match-wins
# position.
data/**/.DS_Store
# @generated expo-cli sync-2b81b286409207a5da26e14c78851eb30d8ccbdb
# The following patterns were generated by expo-cli
expo-env.d.ts
# @end expo-cli

575
apps/mobile/CLAUDE.md Normal file
View File

@@ -0,0 +1,575 @@
# Mobile App Rules (apps/mobile/)
For cross-app sharing rules, see the root `CLAUDE.md` *Sharing Principles* section. This file documents the locked tech-stack baseline and the few mobile-specific rules — so AI doesn't suggest outdated alternatives.
## What mobile may import from `packages/`
- `import type` from `@multica/core/types/*` (zero runtime coupling)
- Pure functions from `@multica/core/`
Everything else, mobile writes its own.
## Pre-flight — before you write any code
For any new mobile feature / screen / interaction, complete the three steps below in order. **Skipping any step = no code yet** (read-only investigation and answering questions are exempt). This section overrides every other rule in this file.
### 1. Read the real web/desktop implementation
Until you can name the relevant code, don't reason from "general experience":
- `packages/views/<feature>/` — UI shape, information density
- `packages/core/<feature>/{queries,mutations,ws-updaters}.ts` — endpoints, cache key shapes, optimistic patches, WS event coverage
- Anything matching `*-display.ts` / `dedupe*` / `coalesce*` / `useMemo(() => transform(raw))` — preprocessing between backend and JSX
List the **must-agree points**: counts, enums, permissions, cross-cache side effects (e.g. a status change must also refresh inbox), navigation flow. Missing one of these is how the 2026-05-09 inbox duplicate-dot incident happened.
### 2. Show the user the interaction plan + parity points (≤30s to read)
Include:
- What you're about to build (one sentence)
- The container / interaction you propose (after walking the iOS-native > RNR > ask waterfall in §UI components)
- Mental-model parity points pulled from step 1 (example: "counts mirror `deduplicateInboxItems`")
- What UI **must differ** and why (example: "web has a sidebar workspace switcher; mobile puts it in Settings — same switching semantics")
- **Visual baseline check** (this is baseline, not polish): tab bar has icons, every screen has a title, multiple right-side row elements stack vertically, secondary text routes through a type-aware label; place a web screenshot next to a simulator screenshot
### 3. Wait for an explicit "do it / go / start" before writing code
"Yes / right / sounds good" ≠ permission to act. "How should we do X?" ≠ permission to act. Only an explicit imperative ("build X / change X / start") triggers code.
> Detailed rules live downstream: must-agree details in §Behavioral parity; component waterfall in §UI components; data / mirroring rules in §Data layer helpers and §Realtime. Pre-flight is the gate; those are the references.
## Behavioral parity with web/desktop
Mobile is allowed to differ in **UI and interaction** — it's a phone, not a port. It is NOT allowed to differ in **product semantics**. Users should not get a different mental model of "what's there" depending on which client they open.
**The four things that must agree:**
- **Counts / visibility** — same N for the same filter, under identical pagination / coalescing rules.
- **Permissions / access** — mirror the same logic web uses (from `packages/core`); don't re-derive from feel.
- **State enums / transitions** — render every status / priority / inbox type / comment type, with a sensible fallback for unknown values (per "API Response Compatibility" in the root CLAUDE.md). Never silently drop a category.
- **Data identity** — same `id`, same `slug`, same canonical fields. Don't invent ids or normalize differently.
**When UI must diverge**, write at the divergence point what rule it's mirroring (point at the source function in `packages/core` or `packages/views`) and why mobile renders it differently. A future reader should be able to tell in 30 seconds that the divergence is intentional and find the web-side source of truth.
### ⚠️ Incident (2026-05-09): inbox dedup missing — counts disagreed
**Symptom**: Web sidebar showed "Inbox 1" while mobile rendered 3+ unread dots on the same workspace, same user, same moment.
**Root cause**: Backend `GET /api/inbox` returns raw rows that include:
1. archived items, and
2. multiple inbox notifications per issue (a comment, a status change, and an assignment on the same issue each create one row).
Web/desktop run those raw rows through `deduplicateInboxItems` (`packages/core/inbox/queries.ts`) before rendering and before counting unread:
1. filter `archived = true` out
2. group by `issue_id`, keep the newest in each group
3. sort by `created_at` desc
Mobile's first cut rendered the raw list directly. So a single issue with 3 notifications showed as 3 rows with 3 unread dots, while web showed 1.
**Fix**: mirror `deduplicateInboxItems` into `apps/mobile/lib/inbox-display.ts`, run mobile's inbox tab through it before rendering and before any counting.
**Lesson — encode this into your reflexes when adding any new mobile screen that consumes a list endpoint**:
> Before rendering an API list response, grep `packages/core/<domain>/queries.ts` and `packages/views/<domain>/components/*.tsx` for any preprocessing — `dedupe*`, `coalesce*`, `filter*`, `*-display.ts`, `useMemo(() => transform(raw))`. Mirror everything that runs between `useQuery` and the JSX in web/desktop. **Do not assume the backend returns "what should be displayed"** — it usually returns the raw cache shape, and the client is responsible for shaping it.
This pattern repeats: timeline coalescing (`buildTimelineGroups`), inbox dedup, comment thread flattening, etc. Each one is a behavioral parity hazard if mobile skips it.
## Tech-stack baseline
Start minimal. Add to this list when actually adopted — do NOT pre-list libraries.
- **Expo SDK 55**
- **React Native 0.82**
- **React 19.1** — whatever Expo SDK 55 ships. Pinned in `apps/mobile/package.json` directly, NOT via root `catalog:`.
- **TypeScript** strict
- **Expo Router 55** (file-based routing — version aligns with Expo SDK)
- **NativeWind 4** + **Tailwind 3.4** — NativeWind 5 is unstable; stay on v4. (Note: web/desktop use Tailwind v4 — versions intentionally differ.)
- **react-native-reusables (RNR)** — the shadcn equivalent for React Native. Uses NativeWind + RN-Primitives + CVA. Component API mirrors shadcn. **Phased adoption in progress — see `apps/mobile/docs/rnr-migration.md` for the canonical plan, three-tier classification, and Phase 0/1/2/3 status.**
- **TanStack Query 5** — mobile owns its `QueryClient` with `AppState` focus listener + `NetInfo` online listener.
- **Zustand** — mobile-local state only.
- **expo-secure-store** — auth token persistence + theme preference (`light` / `dark` / `system`).
When upgrading any of these, update this list.
## UI components & theming
The full plan, file inventory, and migration phases live in `apps/mobile/docs/rnr-migration.md`. The rules below are the durable ones that must survive after the migration completes — read this section first when working on any UI.
### Hard rule — existing pattern first, defaults first, native waterfall
Three principles govern every UI decision on mobile. They exist to fight the temptation to recreate things that already exist — which is exactly the trap that produced the current 21 hand-written components and 18 hand-rolled sheets.
**Principle 1 — existing pattern first.** Before reaching for ANY new component (RNR add, hand-written primitive, new sheet container), grep the mobile codebase for an already-shipped pattern that does the same thing.
- Building a row → grep `components/inbox/`, `components/issue/`, `components/project/` for an analogous list-row first.
- Building a picker / sheet → check `components/issue/pickers/`, `components/project/pickers/` — there are 8+ pickers; one of them is probably the shape you need.
- Building a status / priority / actor visual → `components/ui/status-icon.tsx`, `priority-icon.tsx`, `actor-avatar.tsx` already exist. Re-use, don't re-skin.
- Composer / form / detail screen layout → `app/(app)/[workspace]/issue/[id]/`, `chat/`, `new-issue.tsx` — copy the structure, don't reinvent.
If a working pattern exists, **import or copy-adapt it**. If it almost-fits but needs a small extension, extend the existing one (one PR) rather than fork a second variant. Only when no existing pattern fits, proceed to Principle 2.
Why: every "I'll just write a fresh one" produced one of the 21 legacy components. The codebase already paid the cost of figuring out the iOS-correct shape for inbox rows, picker sheets, status icons — don't re-pay it.
**Principle 2 — defaults first.** When you use any RNR component, accept its default variant, default size, default spacing, default palette. Do NOT add wrapper layers, "improved" defaults, or `variant="multicaCustom"` styles unless a concrete product need demands it. Reaching for shadcn defaults is correct; reaching for a hand-tuned version of them is the failure mode.
**Principle 3 — iOS native > RNR > discuss.** When you need a new interaction, walk this waterfall in order, stop at the first hit:
1. **iOS / RN ships a native API?** Use it directly. Don't wrap a `Modal` to mimic it.
- Text input prompt → `Alert.prompt`
- Confirm / destructive prompt → `Alert.alert`
- Action sheet (one-of-N) → `ActionSheetIOS.showActionSheetWithOptions`
- Date / time → `@react-native-community/datetimepicker` (already installed)
- Image / camera → `expo-image-picker` (already installed)
- Documents → `expo-document-picker` (already installed)
- Share → `Share.share` from `react-native`
- Haptics → `expo-haptics` (already installed)
2. **RNR ships a matching component?** `npx @react-native-reusables/cli@latest add <name>`. Use the default variant/size/palette.
3. **Neither.** **Stop and ask the user.** Don't silently hand-roll a replacement — that's exactly how the pre-migration legacy accumulated.
### Component placement
After deciding via the waterfall:
- **Generic UI primitives** → `components/ui/`. Either RNR `add` output or hand-written with `cva` + `cn()` + semantic tokens + `@rn-primitives/*` building blocks.
- **Domain UI** (anything mentioning issues, priorities, statuses, actors, agents, presence, projects, runs) → `components/<domain>/`. Composes primitives but isn't generic.
Never copy the visual shape of an existing hand-written `components/ui/` component as a template if its RNR equivalent exists — most of them are pre-migration legacy. The migration doc tracks which files are legacy and which have been replaced.
### Theming model — CSS variables + class-based dark mode
- Source of truth for colors is `global.css` — CSS variables defined under `:root` (light) and `.dark:root` (dark). `tailwind.config.js` maps utilities like `bg-background` to `hsl(var(--background))`, so the same class name resolves to the right color in either mode automatically.
- `darkMode: 'class'` (NOT media-query). We control the mode explicitly so the in-app Settings → Appearance picker (`light` / `dark` / `system`) can override the OS preference.
- The mode is switched by NativeWind's `useColorScheme().setColorScheme(mode)`. Calling it sets the root class; every `bg-foo` / `text-foo` reactively rebinds to the new variable values. No manual className toggling, no re-render dance.
- React Navigation (`expo-router`'s `Stack` headers, modal chrome, drawer) is themed separately by passing `NAV_THEME[isDarkColorScheme ? 'dark' : 'light']` into `ThemeProvider`. Source of `NAV_THEME` is `lib/theme.ts`, which mirrors `global.css` in TypeScript.
- Persistence: the user's choice goes into `expo-secure-store` under the key `theme-preference` (values: `light` / `dark` / `system`). Loaded synchronously at app startup in `app/_layout.tsx` before the first paint; missing key defaults to `system`.
- **When you change a CSS variable in `global.css`, also update `lib/theme.ts`.** They mirror each other. The RNR docs include a prompt template for this sync.
### What this replaces (and what stays)
- The old "Visual tokens" approach — hand-transcribed hex values in `tailwind.config.js` — is being **replaced** by the CSS-variable system above. Web tokens are still inspiration only; we do NOT import `packages/ui/styles/tokens.css` (Tailwind v3.4 vs v4 mismatch makes file sharing impractical; isolation is intentional).
- The `cn()` helper at `lib/utils.ts` stays — RNR uses the same one.
- The sheet rule from Lesson 6 below still applies. RNR ships `Dialog` and other modal primitives; use them for **new** sheets. The legacy `sheet-shell.tsx` (RN `<Modal presentationStyle="pageSheet">`) has been deleted — every long-list / search / form sheet now uses an Expo Router `presentation: "formSheet"` route, which instantiates iOS' `UISheetPresentationController` for native grabber, detents, and spring drag physics.
## Build & release
- **Main CI** (`.github/workflows/ci.yml`) excludes mobile via `--filter='!@multica/mobile'`. Mobile failures do NOT block web/desktop PRs.
- **Mobile verify** (`.github/workflows/mobile-verify.yml`): triggered on `apps/mobile/**` or `packages/core/types/**` changes — runs typecheck/lint/test only, no IPA build.
- **Mobile release** (`.github/workflows/mobile-release.yml`): triggered by `mobile-v*.*.*` tag → `eas build` + `eas submit`.
- **OTA** — EAS Update for JS-only fixes that don't change the runtime version. Manual / on-demand push to preview/production channels.
Mobile release cadence is decoupled from main `v*.*.*` tags (server / CLI / desktop).
## Realtime / WebSocket strategy
Mobile uses the same WS server protocol as web/desktop, but mounts subscriptions differently. The rules below exist because mobile-specific constraints (cellular data cost, AppState lifecycle, per-screen unmount cleanup, smaller cache surface) make a direct port of web's pattern wrong.
### Three-layer stack
```
Layer 1 ws-client.ts — single socket, no React. Exponential
backoff with full jitter. Three-state
lifecycle (idle / active / paused) so
the provider can pause on background
and resume on foreground without
racing the auto-reconnect timer.
Layer 2 realtime-provider.tsx — owns the WSClient. Mounts/unmounts on
auth + workspace + AppState + NetInfo
changes. Exposes useWSClient().
Layer 3 use-<feature>-realtime.ts — per-feature subscriptions. Translate
events → cache mutations.
```
Layer 3 is what changes per feature; layers 1 and 2 are infrastructure and shouldn't be edited when adding event coverage.
### Mount strategy: list-level global, per-record per-screen
Mobile **does NOT use a single centralized `useRealtimeSync` hook** like `packages/core/realtime/use-realtime-sync.ts`. That pattern is fine on web (one tab = one mount, lives forever) but on mobile it gets in the way: most events care about a single record (one issue's comments, one chat session's messages), and the hook needs to know which record without prop-drilling.
Two mount tiers:
- **Listing-level (always-on for the workspace session)** — mount inside the `<RealtimeSubscriptions />` component in `app/(app)/[workspace]/_layout.tsx`. These don't take parameters; they patch caches keyed only on `wsId`. Examples: `useInboxRealtime`, `useMyIssuesRealtime`. Both run from the moment the user enters a workspace until they leave it, regardless of which tab is foregrounded.
- **Per-record (mounted with id, cleans up on unmount)** — mount inside the screen that owns the record, parameterized by the id from the route. Example: `useIssueRealtime(id, () => router.back())` in `issue/[id].tsx`. The hook filters every event by `payload.issue_id === id` and only patches the current issue's caches. When the user navigates away the `useEffect` cleanup unsubscribes all listeners, so a backgrounded screen doesn't keep mutating caches it no longer owns.
Don't mount a per-record hook globally to "just be safe" — every filter call on every event then runs N times where N is the number of issues a user has ever opened in this session.
### Patch over invalidate (cellular-data rule)
When a WS payload contains the full updated object, **patch** the cache (`setQueryData` / `setQueriesData`). Only fall back to **invalidate** when:
1. The payload is just an id (we don't know the full new shape — e.g., `issue:created` with no scope context).
2. The cache shape doesn't match what we can patch (e.g., multi-key scope-filtered lists where we'd have to predict membership).
3. The event is rare enough that the extra refetch isn't a real cost (e.g., `issue:deleted` on a list that was about to invalidate anyway).
4. After a reconnect, where we may have missed events while disconnected.
Web is fine to invalidate generously because most users are on broadband; mobile users on cellular pay for each refetch. A `setQueryData` is free; an `invalidateQueries` is a network roundtrip per affected query key.
### Mobile-owned updaters (don't import `packages/core/issues/ws-updaters.ts`)
Mobile has its own `apps/mobile/data/realtime/issue-ws-updaters.ts` even though web has a near-identical file. **Do not import web's updaters into mobile.** Two reasons:
1. **Key-factory binding.** Web's updaters reference `issueKeys` from `packages/core/issues/queries.ts` — a different runtime instance from mobile's `apps/mobile/data/queries/issue-keys.ts`. TanStack Query compares keys structurally so it *appears* to work, but binding cache mutation to a foreign key factory invites silent drift the moment either side adjusts its key shape (renames a segment, adds a discriminator).
2. **Cache-shape divergence.** Mobile has simpler caches: flat `Issue[]` for my-issues (web has status-bucketed); no children subtree (web does); no label-byIssue cache (web does). Web's updaters carry conditional dead-code for paths mobile doesn't have, and mobile would silently no-op on web shapes that don't exist locally.
When the same logic needs to exist on both sides, copy the design — not the import. Document the mirror at the top of the mobile file (see `issue-ws-updaters.ts` for the pattern).
### Event-always-wins (optimistic conflict policy)
Mutations like `useUpdateIssue` apply an optimistic patch to the detail cache, then the server processes the request and broadcasts `issue:updated`. If a separate WS event (from another client / another user / an agent) arrives between the optimistic patch and the mutation response, the WS handler overwrites the optimistic state with the server's authoritative state. Brief UI flicker is acceptable; correctness wins.
**Do not** add timestamp-comparison logic to "protect" the optimistic state — the server is the truth and the user benefits from seeing real changes immediately. If a specific event proves problematic in practice, add the gate at that point, not by default.
### Reconnect handling
Each hook registers a single `ws.onReconnect(cb)` that invalidates **only the queries it owns**:
| Hook | Invalidates on reconnect |
|---|---|
| `useInboxRealtime` | `inboxKeys.list(wsId)` |
| `useMyIssuesRealtime` | `issueKeys.myAll(wsId)` |
| `useIssueRealtime(id)` | `issueKeys.detail(wsId, id)` + `issueKeys.timeline(wsId, id)` |
No global "invalidate everything on reconnect" sweep. The fanout would be every screen the user has ever visited in this session refetching simultaneously — wasteful on cellular and prone to rate-limiting the server in low-signal areas where reconnects happen frequently.
### Cross-cutting cache patches across features
Some events legitimately need to mutate a foreign feature's cache. The
canonical example: `issue:updated` changing an issue's status must also
update the StatusIcon shown on the matching inbox row, and `issue:deleted`
must strip every inbox row pointing at the dead issue.
The pattern:
1. **The feature whose cache is being patched owns the updater.** Example:
`apps/mobile/data/realtime/inbox-ws-updaters.ts` exports
`patchInboxIssueStatus` and `dropInboxItemsByIssue` — they live with
inbox, not with issues, because they read `inboxKeys.list(wsId)`.
2. **That feature's realtime hook subscribes to the foreign event.**
`use-inbox-realtime.ts` subscribes to `issue:updated` and `issue:deleted`
alongside the `inbox:*` events. The issue-realtime hook does NOT know
that inbox cares.
3. **Mirror web's wiring.** Web's `packages/core/inbox/ws-updaters.ts` has
the same handlers; mobile copies the design. Behavioral parity hazard:
without these the mobile inbox row keeps showing the prior status (or
404s on tap if the issue is gone) while web users see the change live.
If you find yourself reaching across features in `use-issues-realtime` to
patch something else, you have the inversion: move the updater to the
patched feature and subscribe there.
### Adding new event coverage — recipe
1. **Read the payload.** Find the event in `@multica/core/types/events.ts`. Note the fields; decide if patch is possible (full object) or invalidate is required (just an id).
2. **Mirror, don't import.** If web has an updater for this event in `packages/core/<feature>/ws-updaters.ts`, copy the design into `apps/mobile/data/realtime/<feature>-ws-updaters.ts`. Adapt to mobile's actual cache shapes — don't carry web's bucket/children/childProgress dead-code if mobile doesn't have those caches.
3. **Subscribe in a hook.** Either extend an existing `use-<feature>-realtime.ts` or create a new one. Filter by id at the top of each handler so per-record hooks ignore unrelated events.
4. **Mount it.** Listing-level → add to `<RealtimeSubscriptions />` in workspace `_layout.tsx`. Per-record → add to the owning screen's body, parameterized by the route id.
5. **Add reconnect invalidate.** Single `ws.onReconnect()` call scoped to the hook's own keys.
6. **Verify cross-client.** Open the affected screen on mobile, change the same record from a second client (web or another device), confirm mobile updates within ~500ms without pull-to-refresh.
If a new event has no consumer on mobile (e.g., `subscriber:added` when mobile doesn't render subscriber lists yet), **don't subscribe**. Mounting a listener with no UI consumer adds CPU on every fire for zero user benefit.
## Data layer helpers (use these — don't recreate them)
Common boilerplate is wrapped. New code that reinvents these helpers is a
review-block, both because it makes the codebase inconsistent AND because
the helpers encode subtle correctness rules (signal forwarding, schema
fallback, sync-before-await ordering, type-safe payloads).
### Three rails that every feature must follow
1. **Logic mirrors web/desktop.** See §Pre-flight step 1 at the top of
this file. Restating the data-contract half here: endpoints, request
bodies, response schemas, optimistic patches, and cache key prefixes
all match web verbatim. UI / interaction can diverge freely per
§Behavioral parity.
2. **Use the existing components — no new primitives.** Walk the
`iOS native > RNR > discuss` waterfall in §UI components. If RNR ships
it, `npx @react-native-reusables/cli@latest add <name>`. If iOS ships
it (Alert / ActionSheetIOS / Haptics / share / picker), use it directly.
If neither has it AND it's a single-screen need, inline compose with
`<Pressable>` + `<Text>` + tokens. **Do NOT create a new generic
primitive in `components/ui/` for one or two callers** — the migration
doc lists "21 hand-written components" as exactly the trap we're
escaping. Threshold for a new primitive is three callers AND no
RNR/iOS-native alternative.
3. **Use the wrapped request / WS layer.** See the helper map below.
### API client: `fetchValidated` + `fetchValidatedWith`
`apps/mobile/data/api.ts` exposes two private helpers on `ApiClient` that
collapse the fetch + parseWithFallback envelope. **Every new read-side
method that returns a typed body must use them.**
| Helper | When to use | Shape |
|---|---|---|
| `this.fetchValidated(path, schema, fallback, opts?)` | GET endpoints | One-liner method body — see `getMe`, `listInbox`, `getNotificationPreferences` |
| `this.fetchValidatedWith(path, schema, fallback, init, opts?)` | Any HTTP method (PATCH / PUT / POST) whose response is consumed | Carries the body via `init.body` + method; signal forwarding handled |
| `this.fetch<T>(path, init?)` directly | Writes whose response is `{ count }` / `void` / not consumed by UI logic | Only here is a raw `as T` acceptable, because the value never reaches a render path |
Rules:
- The fallback object MUST match the success type exactly so downstream
code never has a partial value (see `EMPTY_USER` / `EMPTY_INBOX_LIST`
pattern in `apps/mobile/data/schemas.ts`).
- The `endpoint` label is for telemetry — defaults to the path; override
only when the path has dynamic segments and you want stable groupings
(`GET /api/issues/:id` not `GET /api/issues/abc-123`).
- Migration is progressive: not every legacy method is converted yet.
Adding a new method? Use the helpers. Touching an old method that
isn't using them? Convert it as part of the same PR.
### Query / mutation factory pattern
Every workspace-scoped feature exposes a key factory in
`apps/mobile/data/queries/<feature>.ts`:
```ts
export const inboxKeys = {
all: (wsId: string | null) => ["inbox", wsId] as const,
list: (wsId: string | null) => [...inboxKeys.all(wsId), "list"] as const,
};
```
Three-segment shape matches web (`packages/core/inbox/queries.ts`).
Reasons:
- TQ does prefix matching by default — `invalidateQueries({ queryKey:
inboxKeys.all(wsId) })` invalidates the list AND any future sub-keys
(e.g. a `detail(id)`) under the same prefix. Use `.all` to clear a
workspace cleanly, `.list` to target the list specifically.
- Cross-platform mental-model parity: a reader switching between mobile
and web finds the same key shape.
- Stops bare `["inbox", wsId]` strings from spreading. Grep
`\["inbox"` in this codebase should only hit the factory file.
Mutations import the factory and use `inboxKeys.list(wsId)` everywhere —
never inline strings.
### WS layer: `ws.on<E>()` + `useWSSubscriptions`
Two helpers replace ~20 lines of boilerplate per realtime hook:
1. **`ws.on<E extends WSEventType>(event, handler)`** — the handler's
`payload` parameter is auto-typed to `WSEventPayload<E>`. **Do not
add `as XxxPayload` casts at handler bodies** — they're redundant
and (worse) silently hide drift if `WSEventPayloadMap` shifts.
The cast is only acceptable when one handler covers multiple events
that don't share a typed common ancestor (see `onTaskEvent` in
`use-issue-realtime.ts` — `task:progress` has no formal payload).
2. **`useWSSubscriptions(setup, deps)`** in
`apps/mobile/lib/use-ws-subscriptions.ts` — wraps the
`if (!ws || !wsId) return; useEffect + cleanup` template. Setup
callback receives `(ws, wsId)`, returns the unsub array (or
`undefined` to short-circuit, e.g. when a per-record id is missing).
Adding a new event type? Extend `packages/core/types/events.ts`:
1. Add the event to the `WSEventType` union.
2. Add the payload interface.
3. Add the `WSEventType → payload` entry in `WSEventPayloadMap`.
Forgetting step 3 means callers get `unknown` (loud — they have to
narrow), not `any` (silent unsafe access). That's the safety net.
### Synchronous setQueryData before `await cancelQueries`
Optimistic mutations that flip state read by a UI element that's about
to be in a navigation snapshot (the classic case: marking an inbox row
read, then `router.push` to the issue) MUST call `setQueryData` in
`onMutate` **before** `await qc.cancelQueries(...)`. The await yields
one microtask; iOS captures the source-view snapshot during that gap and
freezes the row in its unread style inside the slide-in transition.
Lives inside the mutation, not the caller. See `useMarkInboxRead.onMutate`
in `apps/mobile/data/mutations/inbox.ts` for the canonical example.
### Checklist for a new feature
Before opening a PR for a new screen / mutation / realtime hook:
1. Grep `packages/core/<feature>/` for the web equivalent — endpoints,
key shape, optimistic patch shape. Mirror, don't invent.
2. API methods → `fetchValidated` / `fetchValidatedWith` (or raw
`this.fetch` only for writes with no consumed response).
3. Query key → factory in `data/queries/<feature>.ts`, 3-segment shape.
4. Mutations → optimistic three-step (snapshot → patch → rollback) +
settle invalidate, all keys via factory.
5. Realtime → `useWSSubscriptions(setup, deps)`, typed `ws.on<E>()`,
per-event patching (no global invalidate) when payload carries the
full object.
6. UI → waterfall (iOS native > RNR > inline compose). No new
`components/ui/` primitive unless three callers + RNR doesn't ship.
7. Verify cross-client: change the same record from web and confirm
mobile updates within ~500ms without pull-to-refresh.
## Lessons learned (encode into reflexes)
These are real mistakes that have been made building the mobile shell. Each one cost time to find. Treat as enforceable rules, not suggestions.
### 1. Install/upgrade any dependency: check `dist-tags` first
Do NOT hardcode version numbers from memory. Run `pnpm view <pkg> dist-tags` to see `latest / sdk-XX / canary` and decide which tag to lock. For Expo packages (`expo-*` / `react-native-*` that Expo aligns), use `pnpm exec expo install <pkg>` — it queries Expo's dependency manifest and picks the SDK-compatible version. `pnpm add <pkg>` will silently install the npm `latest`, which often outpaces the SDK and breaks at runtime. Past mistakes: hardcoded `expo@~54.0.0` (latest was already `55.x`); installed `lucide-react-native@0.468` without checking React 19 peer compatibility.
### 2. New source subdirectory: verify git tracking
Every time you create a new source subdirectory under `apps/mobile/` (e.g. `data/`, `lib/foo/`, `components/inbox/`):
1. Run `git check-ignore -v <dir>/<file>` immediately. The repo-root `.gitignore` has generic rules (`data/`, `build/`, `bin/`, `*.app`, `*.dmg`) that are intended for backend runtime/output dirs but will silently swallow mobile source.
2. If a rule matches, add `!<dir>/` and `!<dir>/**` to `apps/mobile/.gitignore` (subtree override beats parent rule).
3. After the commit lands, run `git ls-files <dir>` to confirm every file is tracked.
This rule exists because `apps/mobile/data/` was once committed-but-not-tracked — 14 source files (ApiClient, all queries, all stores) were missing from the git tree even though `git status` was clean. Local builds worked because Metro reads the filesystem; CI / clones would have died.
### 3. ApiClient capability list (4 must-haves)
Mobile's fetch wrapper (`apps/mobile/data/api.ts`) MUST implement all four. Missing any of them is a bug, not a deferred polish item.
1. **Zod `parseWithFallback` for response validation.** Strictly enforced by the root CLAUDE.md "API Response Compatibility" section and the "Type drift defense" section above. **Any new endpoint method that does `as T` on the response body is a bug.** Reuse schemas from `packages/core/api/schemas.ts` (pure Zod exports, on the mobile sharing whitelist); define mobile-side fallbacks for new endpoints in `apps/mobile/data/`.
2. **`onUnauthorized` 401 callback.** The `ApiClientOptions.onUnauthorized` hook fires on every 401 and must be wired in `app/_layout.tsx` to: clear auth token, clear workspace store, clear TanStack Query cache, navigate to `/login`. Without it a session that expired server-side puts every subsequent request into a 401 loop and the user sees opaque "API error: 401" toasts on every screen. Use a `signingOutRef` to make the callback idempotent — multiple in-flight requests will all 401 simultaneously when a session expires.
3. **`X-Request-ID` per request.** Generate a short random ID (`createRequestId()` in `apps/mobile/lib/request-id.ts`), send as `X-Request-ID` header. The same ID goes into client-side log lines so backend telemetry can be cross-referenced (server picks it up via the same header).
4. **Structured request logger.** Two log lines per request: `[api] → METHOD path` (start, with `rid`) and `[api] ← STATUS path` (end, with `rid` + `duration`). Use `console.error` for 5xx, `console.warn` for 404s, `console.log` for success. Without this, debugging mobile API issues means staring at the React Native Network panel; with it, the dev console is self-explanatory and prod telemetry already comes structured.
**What mobile correctly does NOT need (don't add these):** CSRF token (`X-CSRF-Token`), `credentials: "include"`, cookie reading. Mobile is Bearer-token auth, not cookie auth — the cookie attack surface that requires CSRF protection on web doesn't exist on mobile.
### 4. Every read query must pass `signal` to fetch; api.ts always has a hard timeout
**Symptom that triggered the rule (2026-05-11)**: Inbox screen sometimes returned to the foreground showing the FlatList pull-to-refresh spinner stuck indefinitely. List items were rendered underneath, but `isRefetching` never flipped back to `false`. Pull-to-refresh, navigating away, and re-opening the tab did not clear it.
**Root cause**: `apps/mobile/data/api.ts`'s `fetch()` had no timeout, no `AbortController`, and no caller-`signal` plumbing. iOS suspends backgrounded apps within ~30 seconds and can silently kill in-flight network tasks (facebook/react-native#35384 — "iOS fetch() POST fails if called too soon, with app running in background"; facebook/react-native#38711 — "JS Timers don't fire when app is launched in background"). When the app foregrounded, the suspended fetch's Promise neither resolved nor rejected. TanStack Query saw an existing query still in `fetching` state and did NOT start a new fetch on invalidate — it just waited on the dead Promise forever. `isRefetching` stayed `true`, the FlatList spinner stayed spinning.
**Rule, three parts (every one is required — partial fixes leave a footgun)**:
**1. `api.ts` `fetch()` MUST have a hard timeout** (currently 30s; the `FETCH_TIMEOUT_MS` constant). Without this, a single suspended request can wedge a query indefinitely. Use a manual `AbortController` + `setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS)` — **DO NOT** use `AbortSignal.timeout()`: Hermes throws `TypeError: AbortSignal.timeout is not a function` (facebook/react-native#42042). Same for `AbortSignal.any()` — Hermes does not implement it (livekit/livekit#4014). To combine the timeout signal with a caller-supplied signal, attach an `"abort"` event listener manually and forward to the inner controller.
**2. Every read-side `api.ts` method MUST accept `opts?: { signal?: AbortSignal }` and pass it to `fetch()`**. Mutations don't need this (TanStack Query doesn't pass a signal to `mutationFn`). The pattern:
```ts
async listInbox(opts?: { signal?: AbortSignal }): Promise<InboxItem[]> {
return this.fetch<InboxItem[]>("/api/inbox", { signal: opts?.signal });
}
```
Adding a new query-bound method without `opts` is a bug — the next person who writes a `queryFn` will silently drop the signal.
**3. Every `queryFn` MUST forward the signal it receives from TanStack Query**. The official TanStack guide (tanstack.com/query/v5/docs/framework/react/guides/query-cancellation) states: "When a query becomes out-of-date or inactive, this `signal` will become aborted." The pattern:
```ts
queryOptions({
queryKey: [...],
queryFn: ({ signal }) => api.listInbox({ signal }),
});
```
Forgetting the destructure (writing `() => api.listInbox()`) defeats every benefit of (1) and (2): TQ can't cancel hung requests when the user navigates away, and on workspace switch every stale request lives until its 30s timeout.
**Verification**: After any change to `api.ts` or a new query addition, `grep -n "queryFn: () =>" apps/mobile/data/queries/` should return zero matches. Every `queryFn` should destructure `{ signal }`.
**Why the wiring already in `data/query-client.ts` (focusManager + AppState, onlineManager + NetInfo) is not enough on its own**: focusManager triggers a *refetch attempt* when the app comes back to the foreground, but if the prior fetch promise is hanging, TQ won't start a new request — it'll keep waiting on the dead one. Only timeout + signal cancellation actually unwedges the query. The three pieces work together: signal lets TQ proactively cancel on staleness, timeout is the safety net when nothing else fires, focusManager is the "user came back, let's recheck" trigger.
### 5. Modal container selection: match container to content, don't copy the first sheet
The mobile codebase started with ~15 Modal sheets. They almost all copied the same shape (`Modal transparent fade` + hand-drawn `bg-black/40` backdrop + centered/bottom card with `maxHeight`). That shape is correct for **short action menus** (the earliest sheets), wrong for **everything else**. Once the pattern was established as "the mobile sheet style," subsequent sheets inherited it regardless of content — and inherited a different bug each time: keyboard squashing the card, `maxHeight: 380` clipping FlatLists on tall phones, `useSafeAreaInsets` returning 0 inside Modal so bottom content collides with the Home Indicator, etc.
**Choose the container by content type, not by "what the last sheet did":**
| Content shape | Container | Why |
|---|---|---|
| < 5 fixed actions, 1-2s stay, no keyboard | `Modal transparent` + bottom action card | Short, light, dim-backdrop tap-to-dismiss is correct here |
| Yes/No or one-tap confirm | `Alert.alert` | Native, accessible, no custom UI |
| One-of-N from a server-driven short list | `ActionSheetIOS.showActionSheetWithOptions` | Native iOS action sheet, no custom UI |
| < 7 fixed picker options, no search | `Modal transparent` + small centered card | Same as action card, just centered |
| Long list / search box / content view / form / anything with a keyboard | **Expo Router `presentation: "formSheet"` route** | Instantiates iOS `UISheetPresentationController`: native grabber, drag-dismiss with spring physics, stacked-card backdrop, detents — all UIKit-managed |
| Multi-screen flow / route-level full modal | Expo Router `presentation: "modal"` | Full-page slide-up, has back-stack, swipe-dismiss, deep-linkable |
**`SheetShell` is deleted.** It was a wrapper around RN core `<Modal presentationStyle="pageSheet">` which does NOT instantiate `UISheetPresentationController` — so it never had native grabber, stacked-card backdrop, or real spring physics. Every former SheetShell call site is now an Expo Router formSheet route.
**Rules for adding a new formSheet route:**
1. **File goes under the parent context** so the URL reads sensibly — issue-detail pickers under `app/(app)/[workspace]/issue/[id]/picker/<field>.tsx`; project pickers under `project/[id]/picker/<field>.tsx`; transient action sheets under `<context>/<noun>/actions.tsx`. The new-issue draft flow has its own `new-issue-picker/<field>.tsx` directory because routes can't share state with the modal that opened them — see the draft-store discussion below.
2. **Register the Stack.Screen in `app/(app)/[workspace]/_layout.tsx`** using the shared `SHEET_OPTIONS` constant. Do NOT inline the config per screen — every picker-row sheet must look and feel identical (grabber, detents, corner radius). Isolated sheets that have no neighbour to be consistent with may override `sheetAllowedDetents` only (e.g. the `menu` sheet uses `"fitToContents"` because it's ≤ 5 fixed actions and the two-snap default would leave 60% blank).
3. **Self-contained route bodies.** A picker route reads the record it needs from the TanStack Query cache (issue / project / timeline are already cached when the user gets there), calls its own mutation on submit, and `router.back()`s. No callbacks back up to a parent. The only legitimate exception is the new-issue draft flow, which uses `useNewIssueDraftStore` because the issue doesn't exist yet — there's nothing in cache to read.
4. **Header is drawn inside the body**, not by the Stack. SHEET_OPTIONS sets `headerShown: false`; the body renders its own `<View>` with title + optional right action. The native Stack header on a formSheet creates a layout dance with the grabber that doesn't match iOS sheets.
**SHEET_OPTIONS rationale (every value exists for a known bug or platform behavior):**
- `presentation: "formSheet"` — the magic that hands the screen to `UISheetPresentationController`.
- `sheetGrabberVisible: true` — the iOS native drag handle. Users don't discover the gesture without it.
- `sheetAllowedDetents: [0.6, 0.95]` — explicit numeric detents. The ergonomic `"fitToContents"` is broken on iOS 26 + Expo 55 (expo/expo#42904 padding inconsistency, #42965 zero-size). Predictable two-snap presentation across every picker-row sheet is more important than shrink-wrapping; every formSheet that lives in a chip row (issue-detail / project-detail AttributeRow) uses these explicit detents so muscle memory carries across the row. Isolated sheets (no chip-row neighbour) override with `"fitToContents"` — see the workspace `menu` sheet for the canonical example.
- `sheetCornerRadius: 20` — matches RNR card radius. Without this iOS uses a larger system default that's slightly out of sync with the rest of the app.
- `contentStyle: { height: "100%" }` — safety net against the zero-size class of bugs above. Ensures the sheet body fills the allotted detent height.
**Caveats that still apply:**
- **Android falls back to a regular modal** — no rounded corners, no native drag. mobile/CLAUDE.md treats iOS as the primary target so this is acceptable, but document inline at the call site if a particular feature must work identically on both.
- **A formSheet pushed from inside a `presentation: "modal"` route is supported** by Expo Router 55 / RN Screens 4, but the back gesture from the formSheet returns to the modal, not the underlying tab. This is the right UX for the new-issue draft flow (sheet dismisses back to the form), but check the navigation graph if you're adding a sheet under a non-obvious parent.
**Carve-out — picker-row consistency wins over per-container optimisation:**
The table above says "< 7 fixed picker options → centered card". That rule
applies in isolation, but **breaks down when multiple pickers coexist in
the same chip row** (issue-detail AttributeRow is the canonical case:
status / priority / assignee / label / project / due-date all sit next
to each other). Mixing centered cards (for status/priority, short
fixed lists) with formSheet routes (for assignee/label/project, long
lists) means the user gets two different gestures depending on which
chip they tap — there's no muscle-memory carry-over.
When you find yourself building a row like this, **use the formSheet
route for every picker in the row**, even the ones a standalone
centered card would handle fine. The cost is some empty space below
57 short rows; the gain is uniform tap → slide-up-sheet +
drag-down-to-dismiss behaviour across the whole row. Linear iOS /
Things 3 / Apple Reminders all do this for the same reason.
The centered-card pattern stays correct for **isolated short menus**
(e.g. the chat-composer's "More" popover, the timeline's coalesce-
expand) where there's no neighbour to be consistent with.
### 6. Destructive swipe: reveal only, no auto-fire — always pair with haptic
iOS Mail / Linear iOS / Things: leftward swipe reveals a red Archive
button; the user **must tap it** to commit. The earlier mobile inbox
swipe auto-fired on full drag past the threshold and "felt wrong" — no
peek, easy to trigger by accident on a fast vertical scroll that
catches some horizontal motion. There is no native UX that auto-commits
a destructive action on swipe — match the platform standard.
The rule:
- `ReanimatedSwipeable` with `renderRightActions={<Pressable onPress={fireArchive} />}`.
- **No `onSwipeableOpen` auto-fire.** Drag → reveals the action; release
past threshold → action stays revealed; tap action → commit; tap
outside or drag back → cancel.
- One-shot `Haptics.impactAsync('medium')` when the drag crosses the
action width. Wire via `useAnimatedReaction(() => drag.value <= -ACTION_WIDTH, ...)`
+ `runOnJS(Haptics.impactAsync)`. The shared-value reaction runs on
the UI thread; `runOnJS` bridges to the JS-only Haptics call.
See `apps/mobile/components/inbox/swipeable-inbox-row.tsx` for the
reference implementation. When adding a new swipe-to-action row
elsewhere, copy that pattern; do not reinvent.
### 7. Tier C domain components: opportunistic upgrade only — no silent rewrites
Tier C in `apps/mobile/docs/rnr-migration.md` §4 names the domain UI
files that stay where they are but need foundation upgrades
(`ActorAvatar`, `StatusIcon`, `PriorityIcon`, `PresenceDot`, etc.).
**You don't rewrite a Tier C file just because you're rendering it in
your new feature.** That spreads scope and stalls feature PRs.
Two rules:
1. **Touch only what your PR needs to touch.** If `ActorAvatar` has
hardcoded `#71717a` and you're building an inbox feature that
*uses* `<ActorAvatar>`, leave the hex alone. Note it for a future
doc / cleanup PR.
2. **Upgrade Tier C only when you're modifying that file for a
different real reason.** E.g. adding presence to chat header → you
were going to touch `<ActorAvatar>` anyway → fold the RNR-Avatar
migration + hex → token cleanup into the same PR.
The pre-migration legacy persists because someone "while I'm in
here…"-style touched 21 files in one PR; we don't do that anymore.
Document any Tier C smells you spotted in the PR description as
follow-ups; surface for a future grouped Tier C cleanup PR.

104
apps/mobile/README.md Normal file
View File

@@ -0,0 +1,104 @@
# Multica Mobile (iOS)
Expo + React Native iOS client for Multica. Independent from web/desktop — shares only types from `@multica/core/`. See [`CLAUDE.md`](./CLAUDE.md) for the locked tech-stack baseline and import rules.
## Just want to use it on your phone? (no development)
Multica isn't on the App Store yet — until that changes, anyone who wants it on their iPhone builds from source. One command:
```bash
pnpm ios:mobile:device:prod:release
```
This connects to the same backend as `multica.ai`, so your existing account just works.
**Prerequisites**: Mac with Xcode, a free Apple ID added under Xcode → Settings → Accounts, iPhone connected via USB with [Developer Mode enabled](https://docs.expo.dev/guides/ios-developer-mode/). Walk through Expo's [Set up your environment](https://docs.expo.dev/get-started/set-up-your-environment/) (pick **Development build → iOS Device**) if any of that is missing.
Xcode signs the build with the "Personal Team" your Apple ID automatically owns — created silently the first time you signed into Xcode, no setup needed. The first build downloads CocoaPods + compiles React Native from source — expect 1020 minutes. Subsequent builds reuse Xcode's cache.
**If Xcode rejects signing with "No matching provisioning profiles found"** — rare, happens if someone has claimed the default bundle id `ai.multica.mobile` on Apple's developer portal. Pick any reverse-domain you own and re-run:
```bash
export EXPO_BUNDLE_IDENTIFIER_PROD=com.yourname.multica
pnpm ios:mobile:device:prod:release
```
**7-day signing limit**: a free Apple ID signs builds for 7 days. After that, plug back into the Mac and re-run the command to re-sign. An Apple Developer Program account ($99/yr) extends this to 1 year.
Everything below is for app developers — you can ignore the rest if you only wanted a personal install.
## Scripts
| Command | What it does | Backend |
|---|---|---|
| `pnpm dev:mobile` | Metro only (reuse existing install) | local (`.env.development.local`) |
| `pnpm dev:mobile:staging` | Metro only (reuse existing install) | staging (`.env.staging`) |
| `pnpm dev:mobile:prod` | Metro only (reuse existing install) | production (`.env.production`) |
| `pnpm ios:mobile` | Full rebuild + install on **iOS Simulator**, Debug | local |
| `pnpm ios:mobile:staging` | Full rebuild + install on **iOS Simulator**, Debug | staging |
| `pnpm ios:mobile:prod` | Full rebuild + install on **iOS Simulator**, Debug | production |
| `pnpm ios:mobile:device` | Full rebuild + install on **USB iPhone**, Debug | local |
| `pnpm ios:mobile:device:staging` | Full rebuild + install on **USB iPhone**, Debug | staging |
| `pnpm ios:mobile:device:staging:release` | Full rebuild + install on **USB iPhone**, Release (standalone) | staging |
| `pnpm ios:mobile:device:prod` | Full rebuild + install on **USB iPhone**, Debug | production |
| `pnpm ios:mobile:device:prod:release` | Full rebuild + install on **USB iPhone**, Release (standalone) | production |
`dev:*` runs Metro only — assumes the matching variant is already installed. `ios:mobile*` does a full native rebuild + install.
Bundle id and display name switch on `APP_ENV` (see `app.config.ts`), so Dev / Staging / Production variants can coexist on the same device or simulator.
## First-time setup
`.env.staging` is committed (public staging URL). `.env.development.local` is gitignored — copy the template once:
```bash
cp apps/mobile/.env.example apps/mobile/.env.development.local
# then edit EXPO_PUBLIC_API_URL inside it to your Mac's LAN IP, e.g. http://192.168.1.42:8080
```
If your Apple ID isn't on the Multica Apple Developer team yet, also uncomment and set `EXPO_BUNDLE_IDENTIFIER_DEV` to a reverse-domain you own (e.g. `com.yourname.multica.dev`). This **only** overrides the dev variant — staging / production bundle ids are intentionally not overridable so variants can coexist.
## Build it onto your iPhone
Two paths, depending on what you want to do:
### Day-to-day development (Mac in front of you)
```bash
pnpm ios:mobile:device:staging
```
Produces a **Debug build** with `expo-dev-launcher` embedded. Every launch the app probes Metro on your Mac and pulls fresh JS — perfect for hot-reload, painful when the Mac is asleep or you're on a different WiFi.
### Standalone / "just use it" (walk away from the Mac)
```bash
pnpm ios:mobile:device:staging:release
```
Produces a **Release build**. No `expo-dev-launcher`, no Metro probe, no "Downloading…" screen. Splash → app, exactly like an App Store install. Trade-off: every JS change requires re-running this command.
Both paths share the same prerequisites: Mac with Xcode, free Apple ID added under Xcode → Settings → Accounts, iPhone connected via USB with Developer Mode enabled. Follow Expo's [Set up your environment](https://docs.expo.dev/get-started/set-up-your-environment/) — pick **Development build → iOS Device** — if any of that is missing.
First build of either variant downloads CocoaPods + compiles React Native from source — expect 10-20 minutes. Subsequent builds reuse Xcode's DerivedData cache.
## Try it in the iOS Simulator (no iPhone needed)
```bash
pnpm ios:mobile:staging
```
Boots the simulator, builds, installs the dev-client. Faster to iterate than a device build because no signing / provisioning step. Same `dev:mobile:staging` Metro flow afterward.
## 7-day signing limit (device only)
A free Apple ID signs builds for **7 days only**, Debug and Release both. After that the app refuses to launch on the iPhone. Plug back into the Mac and re-run the corresponding `ios:mobile:device*` script to re-sign. Simulator builds are unaffected. The only workaround for the device limit is an Apple Developer Program account ($99/yr), which extends to 1 year.
## Pointing at a different backend
Edit `EXPO_PUBLIC_API_URL` in `.env.staging`, `.env.production`, or `.env.development.local` (whichever variant you're running). Then:
- For an installed **Debug build**: restart Metro (`pnpm dev:mobile:staging`) so the next JS bundle picks up the new value.
- For an installed **Release build**: re-run the `ios:mobile:device:staging:release` command — the value is baked into the embedded bundle at build time.
For local backend testing, use your Mac's LAN IP (`ipconfig getifaddr en0`), not `localhost`.

79
apps/mobile/app.config.ts Normal file
View File

@@ -0,0 +1,79 @@
import type { ExpoConfig, ConfigContext } from "expo/config";
/**
* Dynamic Expo config — replaces app.json so we can read APP_ENV at runtime
* and switch bundleIdentifier / display name for dev / staging / production.
*
* APP_ENV is set by package.json scripts:
* - dev → APP_ENV unset (treated as "development")
* - dev:staging → APP_ENV=staging
* - dev:prod → APP_ENV=production (rare; usually only for EAS build)
*/
export default ({ config }: ConfigContext): ExpoConfig => {
const env = process.env.APP_ENV ?? "development";
const isProd = env === "production";
const isStaging = env === "staging";
return {
...config,
name: isProd
? "Multica"
: isStaging
? "Multica (Staging)"
: "Multica (Dev)",
slug: "multica-mobile",
version: "0.1.0",
orientation: "portrait",
userInterfaceStyle: "automatic",
scheme: "multica",
// 1024x1024 source shared with the desktop client
// (apps/desktop/build/icon.png). Expo prebuild generates every required
// iOS icon size from this single PNG.
icon: "./assets/icon.png",
ios: {
supportsTablet: false,
// Per-variant bundle id overrides exist for one reason: an Apple ID
// can only sign bundle prefixes it owns, so contributors not on the
// Multica Apple Developer team (and external users self-building a
// personal copy against production) need to swap to a reverse-domain
// they control. Each variant has its own `_<VARIANT>` suffix and is
// only read inside that variant's branch — a generic
// `EXPO_BUNDLE_IDENTIFIER` would leak across variants (Expo CLI
// auto-loads `.env.<mode>.local` regardless of APP_ENV) and collapse
// dev / staging / prod onto a single id.
bundleIdentifier: isProd
? (process.env.EXPO_BUNDLE_IDENTIFIER_PROD ?? "ai.multica.mobile")
: isStaging
? "ai.multica.mobile.staging"
: (process.env.EXPO_BUNDLE_IDENTIFIER_DEV ?? "ai.multica.mobile.dev"),
},
plugins: [
"expo-router",
"expo-secure-store",
"@react-native-community/datetimepicker",
"react-native-enriched-markdown",
[
"expo-image-picker",
{
// iOS NSPhotoLibraryUsageDescription. Without this string in
// Info.plist, calling launchImageLibraryAsync hard-crashes on
// iOS 14+. Camera + microphone are disabled — we only ever read
// from the existing photo library.
photosPermission:
"Allow Multica to access your photos to attach images to issues and comments.",
cameraPermission: false,
microphonePermission: false,
},
],
[
"expo-build-properties",
{
ios: {
buildReactNativeFromSource: true,
},
},
],
],
extra: { APP_ENV: env },
};
};

View File

@@ -0,0 +1,149 @@
/**
* Bottom tab bar — JS `<Tabs>` from expo-router (react-navigation under the
* hood). We tried NativeTabs first but its `canPreventDefault: false`
* constraint makes "tap More → open something" impossible. JS Tabs
* supports `listeners.tabPress + e.preventDefault()`, the canonical RN
* pattern for tab-as-action.
*
* The "More" tab is **not a navigation target** — its press opens a
* DropdownMenu popover anchored above the tab. The popover is rendered
* by `<MoreTabDropdownAnchor />` as a sibling of `<Tabs>`, NOT as a
* `tabBarButton` replacement: keeping the real tab button intact means
* the icon + "More" label render identically to the other three tabs.
* We just open the dropdown imperatively from `listeners.tabPress` via
* the exposed `TriggerRef.open()`.
*
* The stub (tabs)/more.tsx file still exists only because expo-router
* requires every Tabs.Screen to have a backing route file — the press
* is preventDefault'd so we never actually navigate to it.
*
* Active / inactive tint colors are derived from the current colour
* scheme via THEME so dark mode picks contrasting values automatically.
*/
import { useRef } from "react";
import { Tabs } from "expo-router";
import { Image } from "expo-image";
import { View } from "react-native";
import type { TriggerRef } from "@rn-primitives/dropdown-menu";
import { useWorkspaceStore } from "@/data/workspace-store";
import { useColorScheme } from "@/lib/use-color-scheme";
import { THEME } from "@/lib/theme";
import {
useInboxUnreadCount,
useChatUnreadSessionCount,
} from "@/lib/unread-counts";
import { MoreTabDropdownAnchor } from "@/components/nav/more-tab-dropdown";
// Only override backgroundColor — @react-navigation/elements Badge internally
// sets borderRadius = size/2, height = size, minWidth = size, so a single
// character renders as a perfect circle. Overriding minWidth/fontSize here
// breaks that geometry. Text color is auto-derived from backgroundColor
// luminance by Badge itself (white on brand blue).
const BADGE_STYLE = {
backgroundColor: THEME.light.brand,
};
export default function TabsLayout() {
const { colorScheme } = useColorScheme();
const t = THEME[colorScheme];
const wsId = useWorkspaceStore((s) => s.currentWorkspaceId);
const inboxUnread = useInboxUnreadCount(wsId);
const chatUnread = useChatUnreadSessionCount(wsId);
// Truncation aligned with web: inbox 99+, chat 9+ (matches sidebar +
// ChatFab respectively). `undefined` makes React Navigation hide the
// badge, so zero-count is a free no-op.
const inboxBadge =
inboxUnread > 0 ? (inboxUnread > 99 ? "99+" : String(inboxUnread)) : undefined;
const chatBadge =
chatUnread > 0 ? (chatUnread > 9 ? "9+" : String(chatUnread)) : undefined;
// Imperative handle into the More tab's dropdown — listeners.tabPress
// calls .open(); the @rn-primitives Trigger measures itself inside
// open() so the popover anchors to MoreTabDropdownAnchor's rect.
const moreTriggerRef = useRef<TriggerRef>(null);
return (
<View style={{ flex: 1 }}>
<Tabs
screenOptions={{
headerShown: false,
tabBarActiveTintColor: t.foreground,
tabBarInactiveTintColor: t.mutedForeground,
tabBarStyle: { backgroundColor: t.background },
tabBarLabelStyle: { fontSize: 11 },
}}
>
<Tabs.Screen
name="inbox"
options={{
title: "Inbox",
tabBarBadge: inboxBadge,
tabBarBadgeStyle: BADGE_STYLE,
tabBarIcon: ({ color, size, focused }) => (
<Image
source={focused ? "sf:tray.fill" : "sf:tray"}
tintColor={color}
style={{ width: size, height: size }}
/>
),
}}
/>
<Tabs.Screen
name="my-issues"
options={{
title: "My Issues",
tabBarIcon: ({ color, size, focused }) => (
<Image
source={focused ? "sf:checklist" : "sf:checklist.unchecked"}
tintColor={color}
style={{ width: size, height: size }}
/>
),
}}
/>
<Tabs.Screen
name="chat"
options={{
title: "Chat",
tabBarBadge: chatBadge,
tabBarBadgeStyle: BADGE_STYLE,
tabBarIcon: ({ color, size, focused }) => (
<Image
source={focused ? "sf:bubble.left.fill" : "sf:bubble.left"}
tintColor={color}
style={{ width: size, height: size }}
/>
),
}}
/>
<Tabs.Screen
name="more"
options={{
title: "More",
tabBarIcon: ({ color, size }) => (
<Image
source="sf:ellipsis"
tintColor={color}
style={{ width: size, height: size }}
/>
),
}}
listeners={() => ({
tabPress: (e) => {
// Don't navigate to the (stub) /more screen — open the
// dropdown popover instead. The trigger is invisible and
// mounted in MoreTabDropdownAnchor below; ref.open() also
// measures its rect so the popover anchors correctly.
e.preventDefault();
moreTriggerRef.current?.open();
},
})}
/>
</Tabs>
<MoreTabDropdownAnchor triggerRef={moreTriggerRef} />
</View>
);
}

View File

@@ -0,0 +1,428 @@
/**
* Chat tab — single-screen IA.
*
* Layout:
* View ─ Header(center: ChatTitleButton, right: ChatSessionActions)
* ─ (NoAgentBanner?)
* ─ KeyboardAvoidingView ─ ChatMessageList (includes live status
* + timeline in its
* ListFooterComponent)
* ─ OfflineBanner
* ─ ChatComposer
*
* Session switching, agent selection, and session deletion all happen
* inside this screen via Modal sheets — there is no `/chat/[id]` sub-route.
*
* State (all local, none in Zustand):
* - activeSessionId — which session is being viewed (null = new chat blank)
* - selectedAgentId — overrides currentSession.agent_id when set (used
* when starting a new chat with a freshly-picked agent)
* - sessionSheetOpen — bottom modal visibility
* - agentPickerOpen — bottom modal visibility
*
* Side effects:
* - useChatSessionRealtime(activeSessionId) for per-record WS events
* - auto markRead when entering a session with has_unread
* - ensureSession dedupe ref for concurrent first-message sends
*
* Optimistic send burst mirrors web's chat-window.tsx send sequence
* (packages/views/chat/components/chat-window.tsx ~262-345):
* seed messages → seed pendingTask → flip activeSessionId → POST →
* patch pendingTask with server task_id + created_at.
*/
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import {
Alert,
KeyboardAvoidingView,
Platform,
View,
} from "react-native";
import { router } from "expo-router";
import { useFocusEffect, useIsFocused } from "@react-navigation/native";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import type {
Agent,
ChatMessage,
ChatPendingTask,
} from "@multica/core/types";
import { api } from "@/data/api";
import { useAuthStore } from "@/data/auth-store";
import { useWorkspaceStore } from "@/data/workspace-store";
import { agentListOptions } from "@/data/queries/agents";
import { memberListOptions } from "@/data/queries/members";
import {
chatKeys,
chatMessagesOptions,
chatSessionsOptions,
pendingChatTaskOptions,
taskMessagesOptions,
} from "@/data/queries/chat";
import {
useCreateChatSession,
useDeleteChatSession,
useMarkChatSessionRead,
} from "@/data/mutations/chat";
import {
DRAFT_NEW_SESSION,
useChatDraftsStore,
} from "@/data/stores/chat-drafts-store";
import { useChatSessionPickerStore } from "@/data/stores/chat-session-picker-store";
import { useChatSessionRealtime } from "@/data/realtime/use-chat-session-realtime";
import { canAssignAgent } from "@/lib/can-assign-agent";
import { useWorkspaceAgentAvailability } from "@/lib/workspace-agent-availability";
import { useAgentPresence } from "@/lib/use-agent-presence";
import { Header } from "@/components/ui/header";
import { ChatTitleButton } from "@/components/chat/chat-title-button";
import { ChatSessionActions } from "@/components/chat/chat-session-actions";
import { ChatMessageList } from "@/components/chat/chat-message-list";
import { ChatComposer } from "@/components/chat/chat-composer";
import { AgentPickerSheet } from "@/components/chat/agent-picker-sheet";
import { NoAgentBanner } from "@/components/chat/no-agent-banner";
import { OfflineBanner } from "@/components/chat/offline-banner";
import { useChatSelectStore } from "@/data/chat-select-store";
export default function ChatTab() {
const qc = useQueryClient();
const wsId = useWorkspaceStore((s) => s.currentWorkspaceId);
const wsSlug = useWorkspaceStore((s) => s.currentWorkspaceSlug);
const userId = useAuthStore((s) => s.user?.id);
const [activeSessionId, setActiveSessionId] = useState<string | null>(null);
const [selectedAgentId, setSelectedAgentId] = useState<string | null>(null);
const [agentPickerOpen, setAgentPickerOpen] = useState(false);
// Bridge to the chat-sessions formSheet route. Mirror local
// activeSessionId into the store so the picker can render the current
// selection's check mark; consume the picker's one-shot select request
// via useEffect.
const setStoreActiveSessionId = useChatSessionPickerStore(
(s) => s.setActiveSessionId,
);
const selectRequest = useChatSessionPickerStore((s) => s.selectRequest);
const consumeSelect = useChatSessionPickerStore((s) => s.consumeSelect);
useEffect(() => {
setStoreActiveSessionId(activeSessionId);
}, [activeSessionId, setStoreActiveSessionId]);
// ── Server state ───────────────────────────────────────────────────────
const { data: sessions = [] } = useQuery(chatSessionsOptions(wsId));
const { data: agents = [] } = useQuery(agentListOptions(wsId));
const { data: members = [] } = useQuery(memberListOptions(wsId));
// ── Auto-hydrate active session on first Chat tab entry ────────────────
// Mobile-only deviation from web: web's chat-window opens to an empty
// state when no `activeSessionId` is persisted; on a phone, picking
// a session is 4 taps, so jump straight to the most recent session.
// Hydration is one-shot per workspace.
const hydratedWsRef = useRef<string | null>(null);
useEffect(() => {
if (!wsId) return;
if (hydratedWsRef.current === wsId) return;
if (sessions.length === 0) {
hydratedWsRef.current = wsId;
return;
}
hydratedWsRef.current = wsId;
setActiveSessionId(sessions[0].id);
}, [wsId, sessions]);
const { data: messages = [], isLoading: messagesLoading } = useQuery(
chatMessagesOptions(activeSessionId),
);
const { data: pendingTask } = useQuery(
pendingChatTaskOptions(activeSessionId),
);
// Live execution trace for the in-flight task. `task:message` WS events
// append rows to this same cache key via `appendTaskMessage`, so the
// list/pill stay in sync without a polling fetch. `enabled` is gated by
// `isTaskMessageTaskId` inside taskMessagesOptions — optimistic ids
// never hit the network.
const { data: liveTaskMessages = [] } = useQuery(
taskMessagesOptions(pendingTask?.task_id),
);
// ── Derived ────────────────────────────────────────────────────────────
const memberRole = useMemo(
() => members.find((m) => m.user_id === userId)?.role,
[members, userId],
);
const availableAgents = useMemo(
() =>
agents.filter(
(a) => !a.archived_at && canAssignAgent(a, userId, memberRole),
),
[agents, userId, memberRole],
);
const activeSession = useMemo(
() => sessions.find((s) => s.id === activeSessionId) ?? null,
[sessions, activeSessionId],
);
// Active agent: explicit selection wins; otherwise inherit from the
// active session; otherwise pick the first available agent.
const currentAgent: Agent | null = useMemo(() => {
if (selectedAgentId) {
return availableAgents.find((a) => a.id === selectedAgentId) ?? null;
}
if (activeSession) {
return agents.find((a) => a.id === activeSession.agent_id) ?? null;
}
return availableAgents[0] ?? null;
}, [selectedAgentId, availableAgents, activeSession, agents]);
const availability = useWorkspaceAgentAvailability();
const presenceDetail = useAgentPresence(wsId, currentAgent?.id);
const presenceAvailability =
presenceDetail === "loading" ? undefined : presenceDetail.availability;
const isArchived = activeSession?.status === "archived";
const sending = !!pendingTask?.task_id;
// ── Drafts ─────────────────────────────────────────────────────────────
const draftKey = activeSessionId ?? DRAFT_NEW_SESSION;
const draft = useChatDraftsStore((s) => s.drafts[draftKey] ?? "");
const setDraft = useChatDraftsStore((s) => s.setDraft);
const clearDraft = useChatDraftsStore((s) => s.clearDraft);
const promoteNewDraft = useChatDraftsStore((s) => s.promoteNewDraft);
// ── Realtime ───────────────────────────────────────────────────────────
useChatSessionRealtime(activeSessionId, () => {
setActiveSessionId(null);
});
// Exit text-selection mode whenever the chat tab loses focus. Expo
// Router bottom tabs stay mounted across tab switches, so a plain
// useEffect cleanup wouldn't fire — useFocusEffect is the navigation-
// aware equivalent.
useFocusEffect(
useCallback(() => () => useChatSelectStore.getState().clear(), []),
);
// ── Auto markRead while viewing a session with unread state ──────────
const isFocused = useIsFocused();
const markRead = useMarkChatSessionRead();
useEffect(() => {
if (!isFocused) return;
if (!activeSessionId) return;
if (!activeSession?.has_unread) return;
markRead.mutate(activeSessionId);
}, [isFocused, activeSessionId, activeSession?.has_unread, markRead]);
// ── Mutations ──────────────────────────────────────────────────────────
const createSession = useCreateChatSession();
const deleteSession = useDeleteChatSession();
// ── Send burst ─────────────────────────────────────────────────────────
const sessionPromiseRef = useRef<Promise<string | null> | null>(null);
const ensureSession = useCallback(
async (titleSeed: string): Promise<string | null> => {
if (activeSessionId) return activeSessionId;
if (!currentAgent) return null;
if (sessionPromiseRef.current) return sessionPromiseRef.current;
const promise = (async () => {
try {
const session = await createSession.mutateAsync({
agent_id: currentAgent.id,
title: titleSeed.slice(0, 50),
});
return session.id;
} finally {
sessionPromiseRef.current = null;
}
})();
sessionPromiseRef.current = promise;
return promise;
},
[activeSessionId, currentAgent, createSession],
);
const handleSend = useCallback(
async (content: string, attachmentIds: string[] = []) => {
if (!currentAgent) return;
const isNewSession = !activeSessionId;
const sessionId = await ensureSession(content);
if (!sessionId) return;
const sentAt = new Date().toISOString();
const optimistic: ChatMessage = {
id: `optimistic-${Date.now()}`,
chat_session_id: sessionId,
role: "user",
content,
task_id: null,
created_at: sentAt,
};
qc.setQueryData<ChatMessage[]>(chatKeys.messages(sessionId), (old) =>
old ? [...old, optimistic] : [optimistic],
);
qc.setQueryData<ChatPendingTask>(chatKeys.pendingTask(sessionId), {
task_id: `optimistic-${optimistic.id}`,
status: "queued",
created_at: sentAt,
});
if (isNewSession) {
promoteNewDraft(sessionId);
setActiveSessionId(sessionId);
}
try {
const result = await api.sendChatMessage(sessionId, content, {
attachmentIds: attachmentIds.length > 0 ? attachmentIds : undefined,
});
qc.setQueryData<ChatPendingTask>(chatKeys.pendingTask(sessionId), {
task_id: result.task_id,
status: "queued",
created_at: result.created_at,
});
qc.invalidateQueries({ queryKey: chatKeys.messages(sessionId) });
clearDraft(sessionId);
} catch (err) {
qc.setQueryData<ChatMessage[]>(chatKeys.messages(sessionId), (old) =>
old ? old.filter((m) => m.id !== optimistic.id) : old,
);
qc.setQueryData(chatKeys.pendingTask(sessionId), {});
throw err;
}
},
[
activeSessionId,
currentAgent,
ensureSession,
qc,
promoteNewDraft,
clearDraft,
],
);
// ── Cancel in-flight ───────────────────────────────────────────────────
const handleStop = useCallback(() => {
if (!pendingTask?.task_id || !activeSessionId) return;
qc.setQueryData(chatKeys.pendingTask(activeSessionId), {});
void api.cancelTaskById(pendingTask.task_id).catch(() => {
// Silent — task may have already terminated server-side.
});
}, [pendingTask?.task_id, activeSessionId, qc]);
// ── Header / sheet actions ─────────────────────────────────────────────
const handleNewChat = useCallback(() => {
if (availableAgents.length > 1) {
setAgentPickerOpen(true);
return;
}
setSelectedAgentId(null);
setActiveSessionId(null);
}, [availableAgents.length]);
const handlePickAgent = useCallback((agent: Agent) => {
setSelectedAgentId(agent.id);
setActiveSessionId(null);
}, []);
// Apply the user's pick from the chat-sessions route (or "no session"
// when they delete the active one in the sheet).
useEffect(() => {
if (!selectRequest) return;
setSelectedAgentId(null);
setActiveSessionId(selectRequest.id);
consumeSelect();
}, [selectRequest, consumeSelect]);
const handleDeleteActive = useCallback(() => {
if (!activeSession) return;
Alert.alert(
"Delete this chat?",
activeSession.title || "Untitled chat",
[
{ text: "Cancel", style: "cancel" },
{
text: "Delete",
style: "destructive",
onPress: () => {
const id = activeSession.id;
setActiveSessionId(null);
deleteSession.mutate(id);
},
},
],
{ cancelable: true },
);
}, [activeSession, deleteSession]);
// ── Composer disabled-state ────────────────────────────────────────────
const disabled =
!currentAgent || availability === "none" || isArchived === true;
const disabledReason = !currentAgent
? "No agent selected"
: availability === "none"
? "No agents in this workspace"
: isArchived
? "This chat is archived"
: undefined;
return (
<View className="flex-1 bg-background">
<Header
center={
<ChatTitleButton
currentSession={activeSession}
currentAgent={currentAgent}
onPress={() => {
if (!wsSlug) return;
router.push({
pathname: "/[workspace]/chat-sessions",
params: { workspace: wsSlug },
});
}}
/>
}
right={
<ChatSessionActions
showMore={!!activeSession}
onMorePress={handleDeleteActive}
onNewPress={handleNewChat}
/>
}
/>
{availability === "none" ? <NoAgentBanner /> : null}
<KeyboardAvoidingView
behavior={Platform.OS === "ios" ? "padding" : undefined}
className="flex-1"
>
<ChatMessageList
messages={messages}
loading={messagesLoading}
hasSessions={sessions.length > 0}
agentName={currentAgent?.name}
onPickPrompt={(text) => setDraft(draftKey, text)}
pendingTask={pendingTask}
liveTaskMessages={liveTaskMessages}
availability={presenceAvailability}
/>
<OfflineBanner
agentName={currentAgent?.name}
availability={presenceAvailability}
/>
<ChatComposer
value={draft}
onChangeText={(next) => setDraft(draftKey, next)}
onSend={handleSend}
onStop={handleStop}
sending={sending}
disabled={disabled}
disabledReason={disabledReason}
/>
</KeyboardAvoidingView>
<AgentPickerSheet
visible={agentPickerOpen}
agents={availableAgents}
currentAgentId={currentAgent?.id ?? null}
onPick={handlePickAgent}
onClose={() => setAgentPickerOpen(false)}
/>
</View>
);
}

View File

@@ -0,0 +1,199 @@
import { useMemo } from "react";
import {
ActionSheetIOS,
Alert,
FlatList,
View,
} from "react-native";
import { useQuery } from "@tanstack/react-query";
import { router } from "expo-router";
import { Ionicons } from "@expo/vector-icons";
import type { InboxItem } from "@multica/core/types";
import { Text } from "@/components/ui/text";
import { Button } from "@/components/ui/button";
import { Skeleton } from "@/components/ui/skeleton";
import { Header } from "@/components/ui/header";
import { IconButton } from "@/components/ui/icon-button";
import { HeaderActions } from "@/components/ui/app-header-actions";
import { SwipeableInboxRow } from "@/components/inbox/swipeable-inbox-row";
import { inboxListOptions } from "@/data/queries/inbox";
import {
useArchiveAllInbox,
useArchiveAllReadInbox,
useArchiveCompletedInbox,
useArchiveInbox,
useMarkAllInboxRead,
useMarkInboxRead,
} from "@/data/mutations/inbox";
import { useWorkspaceStore } from "@/data/workspace-store";
import { useColorScheme } from "@/lib/use-color-scheme";
import { THEME } from "@/lib/theme";
import { deduplicateInboxItems } from "@/lib/inbox-display";
export default function Inbox() {
const wsId = useWorkspaceStore((s) => s.currentWorkspaceId);
const wsSlug = useWorkspaceStore((s) => s.currentWorkspaceSlug);
const { colorScheme } = useColorScheme();
const { data: rawItems, isLoading, error, refetch, isRefetching } = useQuery(
inboxListOptions(wsId),
);
// Dedup + drop archived to match web/desktop. See CLAUDE.md
// "Behavioral parity" → inbox dedup incident.
const data = useMemo(
() => deduplicateInboxItems(rawItems ?? []),
[rawItems],
);
const markRead = useMarkInboxRead();
const markAllRead = useMarkAllInboxRead();
const archive = useArchiveInbox();
const archiveAll = useArchiveAllInbox();
const archiveAllRead = useArchiveAllReadInbox();
const archiveCompleted = useArchiveCompletedInbox();
const onPressItem = (item: InboxItem) => {
if (!item.read) {
// Optimistic read flip lives in useMarkInboxRead.onMutate — fires
// setQueryData synchronously before the cancelQueries await, so the
// row is already styled "read" by the time iOS captures the source
// snapshot for the native stack push transition.
markRead.mutate(item.id);
}
if (item.issue_id && wsSlug) {
router.push({
pathname: "/[workspace]/issue/[id]",
params: {
workspace: wsSlug,
id: item.issue_id,
highlight: item.details?.comment_id,
h: String(Date.now()),
},
});
}
};
// Trailing batch menu — mirrors web's dropdown
// (packages/views/inbox/components/inbox-page.tsx). "Mark all read" is
// first (most common batch op); "Archive all" is destructive so it gets
// the iOS red treatment + Alert confirm.
const onPressMenu = () => {
const options = [
"Cancel",
"Mark all read",
"Archive all read",
"Archive completed",
"Archive all",
];
ActionSheetIOS.showActionSheetWithOptions(
{
options,
cancelButtonIndex: 0,
destructiveButtonIndex: 4,
title: "Inbox",
},
(i) => {
if (i === 1) markAllRead.mutate();
else if (i === 2) archiveAllRead.mutate();
else if (i === 3) archiveCompleted.mutate();
else if (i === 4) {
Alert.alert(
"Archive all?",
"This archives every inbox item, read or unread. You can still find them via the issue pages.",
[
{ text: "Cancel", style: "cancel" },
{
text: "Archive all",
style: "destructive",
onPress: () => archiveAll.mutate(),
},
],
);
}
},
);
};
return (
<View className="flex-1 bg-background">
<Header
title="Inbox"
right={
<>
<IconButton
name="ellipsis-horizontal"
onPress={onPressMenu}
accessibilityLabel="Inbox actions"
/>
<HeaderActions />
</>
}
/>
{isLoading ? (
<InboxLoading />
) : error ? (
<View className="px-4 gap-3 pt-4">
<Text className="text-sm text-destructive">
Failed to load inbox:{" "}
{error instanceof Error ? error.message : "unknown error"}
</Text>
<Button variant="outline" onPress={() => refetch()}>
<Text>Retry</Text>
</Button>
</View>
) : !data || data.length === 0 ? (
<InboxEmpty iconColor={THEME[colorScheme].mutedForeground} />
) : (
<FlatList
data={data}
keyExtractor={(item) => item.id}
ItemSeparatorComponent={() => (
<View className="h-px bg-border ml-16" />
)}
contentContainerClassName="pb-6"
renderItem={({ item }) => (
<SwipeableInboxRow
item={item}
onPress={() => onPressItem(item)}
onArchive={() => archive.mutate(item.id)}
/>
)}
refreshing={isRefetching}
onRefresh={refetch}
/>
)}
</View>
);
}
// Loading state — 6 row-shaped Skeletons matching InboxRow's layout
// (avatar circle + two text lines). Perceived perf wins over a centered
// spinner because the eye immediately sees the list-like structure.
function InboxLoading() {
return (
<View className="px-4 pt-4 gap-4">
{Array.from({ length: 6 }).map((_, i) => (
<View key={i} className="flex-row gap-3">
<Skeleton className="size-9 rounded-full" />
<View className="flex-1 gap-2 pt-1">
<Skeleton className="h-3.5 w-3/4" />
<Skeleton className="h-3 w-1/2" />
</View>
</View>
))}
</View>
);
}
function InboxEmpty({ iconColor }: { iconColor: string }) {
return (
<View className="flex-1 items-center justify-center px-8 gap-3">
<Ionicons name="mail-open-outline" size={42} color={iconColor} />
<Text className="text-base font-medium text-foreground text-center">
Inbox zero
</Text>
<Text className="text-sm text-muted-foreground text-center">
When someone @mentions you, assigns an issue, or an agent finishes a
task, it shows up here.
</Text>
</View>
);
}

View File

@@ -0,0 +1,16 @@
/**
* Stub route. The "More" tab in (tabs)/_layout.tsx intercepts tabPress and
* pushes /[workspace]/menu (formSheet route) instead of navigating here,
* so this screen is never rendered through normal use. expo-router still
* requires a file to exist at this path to register the Tabs.Screen entry.
*
* If a deep link or stale tab state somehow lands the user here, bounce
* to inbox so they don't see a blank screen.
*/
import { Redirect } from "expo-router";
import { useWorkspaceStore } from "@/data/workspace-store";
export default function MoreStub() {
const slug = useWorkspaceStore((s) => s.currentWorkspaceSlug);
return <Redirect href={slug ? `/${slug}/inbox` : "/select-workspace"} />;
}

View File

@@ -0,0 +1,373 @@
/**
* "My Issues" tab. Three scopes — assigned / created / agents — mirroring
* web's `packages/views/my-issues/components/my-issues-page.tsx:48-65`. The
* `agents` scope label is "Agents and Squads" because the backend predicate
* (`involves_user_id`, MUL-2397) surfaces both the user's owned agents and
* squads they're involved in (member / leader / has an owned agent inside).
*
* Issues are grouped by status using SectionList in `BOARD_STATUSES` order;
* empty status sections are filtered out so the screen doesn't fill with
* "(0)" headers. Section grouping uses `BOARD_STATUSES` (cancelled excluded)
* to match web — same source `packages/views/my-issues/components/my-issues-page.tsx:117-125`.
*
* Status + Priority filters mirror web's MyIssuesHeader filter sub-menus.
* Filter state lives in `useMyIssuesViewStore` and is cleared on workspace
* change via the shared `useClearFiltersOnWorkspaceChange` hook.
*/
import { useMemo } from "react";
import { Pressable, SectionList, View } from "react-native";
import { useQuery } from "@tanstack/react-query";
import { router } from "expo-router";
import { Ionicons } from "@expo/vector-icons";
import type { Issue, IssuePriority, IssueStatus } from "@multica/core/types";
import { Text } from "@/components/ui/text";
import { Button } from "@/components/ui/button";
import { Header } from "@/components/ui/header";
import { HeaderActions } from "@/components/ui/app-header-actions";
import { StatusIcon } from "@/components/ui/status-icon";
import { IssueRow } from "@/components/issue/issue-row";
import { IssuesLoading } from "@/components/issue/issues-loading";
import {
buildMyIssuesFilter,
myIssueListOptions,
} from "@/data/queries/my-issues";
import type { MyIssuesScope } from "@/data/queries/issue-keys";
import { useAuthStore } from "@/data/auth-store";
import { useWorkspaceStore } from "@/data/workspace-store";
import { useMyIssuesViewStore } from "@/data/stores/my-issues-view-store";
import { useClearFiltersOnWorkspaceChange } from "@/lib/use-clear-filters-on-workspace-change";
import {
BOARD_STATUSES,
PRIORITY_LABEL,
STATUS_LABEL,
} from "@/lib/issue-status";
import { filterIssues } from "@/lib/filter-issues";
import { useColorScheme } from "@/lib/use-color-scheme";
import { THEME } from "@/lib/theme";
// Mobile pill row has tight width on SE3 (375pt). Three pills + Filter icon
// must fit in 343pt usable space, so the agents scope renders "Agents" — the
// full "Agents and Squads" label (~135pt) blows past safe limits and breaks
// under Dynamic Type. Semantics unchanged: same backend predicate
// (`involves_user_id`, MUL-2397) covers owned agents + related squads; the
// empty state copy still says "agents or squads".
const SCOPES: { value: MyIssuesScope; label: string }[] = [
{ value: "assigned", label: "Assigned" },
{ value: "created", label: "Created" },
{ value: "agents", label: "Agents" },
];
type IssueSection = { status: IssueStatus; data: Issue[] };
export default function MyIssues() {
const userId = useAuthStore((s) => s.user?.id ?? null);
const wsId = useWorkspaceStore((s) => s.currentWorkspaceId);
const wsSlug = useWorkspaceStore((s) => s.currentWorkspaceSlug);
const scope = useMyIssuesViewStore((s) => s.scope);
const setScope = useMyIssuesViewStore((s) => s.setScope);
const statusFilters = useMyIssuesViewStore((s) => s.statusFilters);
const priorityFilters = useMyIssuesViewStore((s) => s.priorityFilters);
const openFilter = () => {
if (!wsSlug) return;
router.push({
pathname: "/[workspace]/issues-filter",
params: { workspace: wsSlug, scope: "my" },
});
};
useClearFiltersOnWorkspaceChange(
useMyIssuesViewStore.getState().clearFilters,
wsId,
);
const filter = useMemo(
() => (userId ? buildMyIssuesFilter(scope, userId) : { assignee_id: "" }),
[scope, userId],
);
const { data, isLoading, error, refetch, isRefetching } = useQuery({
...myIssueListOptions(wsId, scope, filter),
enabled: !!wsId && !!userId,
});
// Apply client-side status + priority filter. Mirrors the predicate at
// packages/views/issues/utils/filter.ts:30-34 via filterIssues().
const filtered = useMemo(
() => filterIssues(data ?? [], statusFilters, priorityFilters),
[data, statusFilters, priorityFilters],
);
// When statusFilters is non-empty, intersect visible status order with it
// so hidden statuses don't render an empty section header. Uses
// BOARD_STATUSES (cancelled excluded) to match web.
const sections = useMemo<IssueSection[]>(() => {
if (filtered.length === 0) return [];
const byStatus = new Map<IssueStatus, Issue[]>();
for (const issue of filtered) {
const list = byStatus.get(issue.status);
if (list) list.push(issue);
else byStatus.set(issue.status, [issue]);
}
const visibleStatuses = statusFilters.length > 0
? BOARD_STATUSES.filter((s) => statusFilters.includes(s))
: BOARD_STATUSES;
return visibleStatuses
.map((status) => ({ status, data: byStatus.get(status) ?? [] }))
.filter((s) => s.data.length > 0);
}, [filtered, statusFilters]);
const hasActiveFilters =
statusFilters.length > 0 || priorityFilters.length > 0;
const showEmptyState =
!isLoading && !error && filtered.length === 0;
return (
<View className="flex-1 bg-background">
<Header title="My Issues" right={<HeaderActions />} />
<ScopeToolbar
scopes={SCOPES}
scope={scope}
onChange={(v) => setScope(v)}
onOpenFilter={openFilter}
hasActiveFilters={hasActiveFilters}
/>
{hasActiveFilters ? (
<ActiveFilterChips
statusFilters={statusFilters}
priorityFilters={priorityFilters}
onClearStatus={(s) =>
useMyIssuesViewStore.getState().toggleStatusFilter(s)
}
onClearPriority={(p) =>
useMyIssuesViewStore.getState().togglePriorityFilter(p)
}
/>
) : null}
{isLoading ? (
<IssuesLoading />
) : error ? (
<View className="px-4 gap-3 pt-4">
<Text className="text-sm text-destructive">
Failed to load issues:{" "}
{error instanceof Error ? error.message : "unknown error"}
</Text>
<Button variant="outline" onPress={() => refetch()}>
<Text>Retry</Text>
</Button>
</View>
) : showEmptyState ? (
<EmptyState
message={
hasActiveFilters
? "No issues match the current filters."
: emptyMessageForScope(scope)
}
/>
) : (
<SectionList
sections={sections}
keyExtractor={(item) => item.id}
stickySectionHeadersEnabled={false}
ItemSeparatorComponent={() => (
<View className="h-px bg-border ml-4" />
)}
renderSectionHeader={({ section }) => (
<SectionHeader
status={section.status}
count={section.data.length}
/>
)}
contentContainerClassName="pb-6"
renderItem={({ item }) => (
<IssueRow
issue={item}
onPress={() => {
if (wsSlug) router.push(`/${wsSlug}/issue/${item.id}`);
}}
/>
)}
refreshing={isRefetching}
onRefresh={refetch}
/>
)}
</View>
);
}
/**
* Outline icon button matching the pill height so the toolbar row reads as
* one visual group. Mirrors web `IssuesHeader` / `MyIssuesHeader` filter
* trigger (`packages/views/my-issues/components/my-issues-header.tsx:174`),
* which is also `variant="outline"` + icon-sized — NOT the ghost-style we'd
* get from <IconButton>. Square (`w-9`) with `px-0` to suppress the sm
* default `px-3`.
*/
function FilterButton({
onPress,
hasActiveFilters,
}: {
onPress: () => void;
hasActiveFilters: boolean;
}) {
const { colorScheme } = useColorScheme();
return (
<View style={{ position: "relative" }} className="ml-2">
<Button
variant="outline"
size="sm"
onPress={onPress}
accessibilityLabel="Filter"
className="w-9 px-0"
>
<Ionicons
name="options-outline"
size={16}
color={THEME[colorScheme].mutedForeground}
/>
</Button>
{hasActiveFilters ? (
<View
pointerEvents="none"
className="absolute top-1 right-1 size-1.5 rounded-full bg-brand"
/>
) : null}
</View>
);
}
/**
* Toolbar row mirroring web `MyIssuesHeader` / `IssuesHeader`
* (`packages/views/my-issues/components/my-issues-header.tsx:138-163`):
* left-aligned scope pill group + right-side Filter icon (red dot when
* filters are active). Replaces the previous full-width segmented tabs +
* Filter-in-title-bar split — keeps scope and the filter affordance in the
* same row, because they both control the list directly below.
*/
function ScopeToolbar<S extends string>({
scopes,
scope,
onChange,
onOpenFilter,
hasActiveFilters,
}: {
scopes: { value: S; label: string }[];
scope: S;
onChange: (value: S) => void;
onOpenFilter: () => void;
hasActiveFilters: boolean;
}) {
return (
<View className="flex-row items-center justify-between px-4 pt-2 pb-2">
<View className="flex-row items-center gap-1 flex-shrink min-w-0">
{scopes.map((s) => {
const active = scope === s.value;
return (
<Button
key={s.value}
variant="outline"
size="sm"
onPress={() => onChange(s.value)}
className={active ? "bg-accent" : ""}
accessibilityState={{ selected: active }}
>
<Text
numberOfLines={1}
className={active ? "text-accent-foreground" : "text-muted-foreground"}
>
{s.label}
</Text>
</Button>
);
})}
</View>
<FilterButton
onPress={onOpenFilter}
hasActiveFilters={hasActiveFilters}
/>
</View>
);
}
function ActiveFilterChips({
statusFilters,
priorityFilters,
onClearStatus,
onClearPriority,
}: {
statusFilters: IssueStatus[];
priorityFilters: IssuePriority[];
onClearStatus: (s: IssueStatus) => void;
onClearPriority: (p: IssuePriority) => void;
}) {
return (
<View className="flex-row flex-wrap gap-1.5 px-4 pb-2">
{statusFilters.map((s) => (
<Chip key={`s-${s}`} label={STATUS_LABEL[s]} onClear={() => onClearStatus(s)} />
))}
{priorityFilters.map((p) => (
<Chip key={`p-${p}`} label={PRIORITY_LABEL[p]} onClear={() => onClearPriority(p)} />
))}
</View>
);
}
function Chip({ label, onClear }: { label: string; onClear: () => void }) {
const { colorScheme } = useColorScheme();
return (
<Pressable
onPress={onClear}
className="flex-row items-center gap-1 pl-2.5 pr-2 py-1 rounded-full border border-border bg-secondary/40 active:bg-secondary"
>
<Text className="text-xs text-foreground">{label}</Text>
<Ionicons
name="close"
size={12}
color={THEME[colorScheme].mutedForeground}
/>
</Pressable>
);
}
function SectionHeader({
status,
count,
}: {
status: IssueStatus;
count: number;
}) {
return (
<View className="flex-row items-center gap-2 px-4 py-2 bg-background">
<StatusIcon status={status} size={14} />
<Text className="text-xs uppercase tracking-wider text-muted-foreground font-medium">
{STATUS_LABEL[status]}
</Text>
<Text className="text-xs text-muted-foreground/60">{count}</Text>
</View>
);
}
function EmptyState({ message }: { message: string }) {
return (
<View className="flex-1 items-center justify-center px-6">
<Text className="text-sm text-muted-foreground text-center">
{message}
</Text>
</View>
);
}
function emptyMessageForScope(scope: MyIssuesScope): string {
switch (scope) {
case "assigned":
return "No issues assigned to you.";
case "created":
return "You haven't created any issues.";
case "agents":
return "No issues assigned to your agents or squads yet.";
}
}

View File

@@ -0,0 +1,339 @@
import { useEffect } from "react";
import type { ComponentProps } from "react";
import { Redirect, Stack, useLocalSearchParams } from "expo-router";
import { useQuery } from "@tanstack/react-query";
import { workspaceListOptions } from "@/data/queries/workspaces";
import { useWorkspaceStore } from "@/data/workspace-store";
import { RealtimeProvider } from "@/data/realtime/realtime-provider";
import { useInboxRealtime } from "@/data/realtime/use-inbox-realtime";
import { useIssuesRealtime } from "@/data/realtime/use-issues-realtime";
import { useMyIssuesRealtime } from "@/data/realtime/use-my-issues-realtime";
import { useChatSessionsRealtime } from "@/data/realtime/use-chat-sessions-realtime";
import { useProjectsRealtime } from "@/data/realtime/use-projects-realtime";
import { usePinsRealtime } from "@/data/realtime/use-pins-realtime";
import { usePresenceRealtime } from "@/data/realtime/use-presence-realtime";
import { useWorkspacePresencePrefetch } from "@/lib/use-workspace-presence-prefetch";
import { ModalCloseButton } from "@/components/ui/modal-close-button";
import { useNewIssueDraftResetOnWorkspaceChange } from "@/data/stores/new-issue-draft-store";
import { useNewProjectDraftResetOnWorkspaceChange } from "@/data/stores/new-project-draft-store";
import { useChatSessionPickerResetOnWorkspaceChange } from "@/data/stores/chat-session-picker-store";
/**
* Shared Stack.Screen options for every iOS formSheet-presented sheet route.
*
* Why these specific values:
* - `presentation: "formSheet"` instantiates iOS
* UISheetPresentationController — native grabber, stacked-card backdrop,
* drag-to-dismiss spring physics, detents.
* - `sheetAllowedDetents: [0.6, 0.95]` — explicit numeric detents. The
* ergonomic `"fitToContents"` is broken on iOS 26 + Expo 55
* (expo/expo#42904 padding inconsistency, expo/expo#42965 zero-size).
* Predictable two-snap presentation across every picker-row sheet >
* shrink-wrap; this is the right default for sheets that sit next to
* other sheets in the same chip row (issue / project AttributeRow) so
* the user gets the same gesture regardless of which chip they tap.
* Isolated sheets that have no neighbour to be consistent with (e.g.
* the workspace `menu` sheet) override this with `"fitToContents"`
* to avoid the large blank area below their content.
* - `sheetGrabberVisible: true` — surfaces the iOS native drag handle
* so users discover the gesture.
* - `contentStyle.height: "100%"` — safety net against the same
* zero-size class of bugs above; ensures the sheet body fills the
* allotted detent.
* - `headerShown: false` — every sheet body draws its own header (title
* + optional right action). The native Stack header would double up.
*/
const SHEET_OPTIONS: ComponentProps<typeof Stack.Screen>["options"] = {
presentation: "formSheet",
sheetGrabberVisible: true,
sheetAllowedDetents: [0.6, 0.95],
sheetCornerRadius: 20,
contentStyle: { flex: 1 },
headerShown: false,
};
/**
* Cold-start deep-link anchor. Expo Router otherwise treats whatever
* route resolves the URL as the root of the stack — if the user opens a
* notification that targets `issue/[id]/picker/status` directly, they
* land on the formSheet with NO parent under it, no way to go back to
* the tabs. `anchor: "(tabs)"` tells the router to mount the tab UI as
* the implicit underlying screen so back/swipe-dismiss returns the user
* to a sensible base state.
*/
export const unstable_settings = { anchor: "(tabs)" } as const;
/**
* Mounts every per-feature realtime subscription. Lives inside
* RealtimeProvider so the WSClient context is available, and stays alive
* for the whole workspace session — the inbox unread count must keep
* refreshing even while the user is on an issue page or settings, not
* just when the inbox tab is foregrounded.
*
* Add new realtime feature hooks here as they land (issue, chat, etc).
*/
function RealtimeSubscriptions() {
useInboxRealtime();
useIssuesRealtime();
useMyIssuesRealtime();
useChatSessionsRealtime();
useProjectsRealtime();
usePinsRealtime();
// Presence: warm the three queries up front so avatars don't flash a
// dotless first render, and listen for daemon/agent/task events to keep
// the runtime + snapshot caches fresh. See use-presence-realtime.ts for
// the deliberately-skipped high-frequency events.
useWorkspacePresencePrefetch();
usePresenceRealtime();
return null;
}
/**
* Workspace context layout. Reads the slug from the URL (the route is the
* source of truth — see apps/mobile/CLAUDE.md "Behavioral parity"), validates
* membership against the workspaces list, then syncs id+slug into the
* Zustand store so ApiClient.fetch can read the slug synchronously when
* injecting the X-Workspace-Slug header.
*
* If the slug doesn't match any workspace the user belongs to, redirect to
* /select-workspace (covers stale persisted slugs after the user lost
* membership, deep links to wrong slugs, etc.).
*/
export default function WorkspaceLayout() {
const { workspace: slug } = useLocalSearchParams<{ workspace: string }>();
const { data: workspaces, isLoading } = useQuery(workspaceListOptions());
const setCurrentWorkspace = useWorkspaceStore((s) => s.setCurrentWorkspace);
const matched = workspaces?.find((w) => w.slug === slug);
useEffect(() => {
if (matched) {
setCurrentWorkspace(matched.id, matched.slug);
}
}, [matched, setCurrentWorkspace]);
// Wipe cross-route Zustand draft stores whenever the active workspace
// changes — a draft picked under workspace A (assignee id, draft
// session id, etc.) is invalid in workspace B and must not leak.
useNewIssueDraftResetOnWorkspaceChange(matched?.id ?? null);
useNewProjectDraftResetOnWorkspaceChange(matched?.id ?? null);
useChatSessionPickerResetOnWorkspaceChange(matched?.id ?? null);
// Wait for the workspaces list before deciding membership — otherwise a
// valid deep link would briefly redirect away on cold start.
if (isLoading) return null;
if (!matched) return <Redirect href="/select-workspace" />;
// Tabs hide their own header; pushed screens (issue/[id]) get a native
// iOS Stack header with the standard back button + swipe-to-dismiss.
return (
<RealtimeProvider>
<RealtimeSubscriptions />
<Stack>
<Stack.Screen name="(tabs)" options={{ headerShown: false }} />
<Stack.Screen
name="issue/[id]"
options={{
title: "Issue",
headerBackTitle: "Back",
}}
/>
<Stack.Screen
name="project/[id]"
options={{
title: "Project",
headerBackTitle: "Back",
}}
/>
<Stack.Screen
name="project/[id]/edit"
options={{
title: "Edit Project",
presentation: "modal",
headerLeft: () => <ModalCloseButton />,
}}
/>
<Stack.Screen
name="issue/[id]/edit"
options={{
title: "Edit Issue",
presentation: "modal",
headerLeft: () => <ModalCloseButton />,
}}
/>
<Stack.Screen
name="project/new"
options={{
title: "New Project",
presentation: "modal",
headerLeft: () => <ModalCloseButton />,
}}
/>
{/* Issue-detail formSheet pickers. All share the same sheet config:
explicit numeric detents to dodge expo/expo#42904+#42965 (the
`fitToContents` zero-size / padding bugs on iOS 26 + Expo 55),
iOS native grabber, and contentStyle.height=100% as a safety
net against the same zero-size class of bugs. */}
<Stack.Screen
name="issue/[id]/picker/status"
options={SHEET_OPTIONS}
/>
<Stack.Screen
name="issue/[id]/picker/priority"
options={SHEET_OPTIONS}
/>
{/* Experiment: assignee uses iOS-native nav header + UISearchController
instead of the body-rendered header pattern in SHEET_OPTIONS.
Eliminates the #3634 overlap class of bugs and the focus-loss
footgun of a custom TextInput inside ListHeaderComponent. The
route file wires `headerSearchBarOptions` via setOptions. If this
proves out, propagate to label / project / other search pickers
and update CLAUDE.md Lesson 6 with a carve-out. */}
<Stack.Screen
name="issue/[id]/picker/assignee"
options={{
...SHEET_OPTIONS,
headerShown: true,
title: "Assignee",
}}
/>
<Stack.Screen
name="issue/[id]/picker/label"
options={SHEET_OPTIONS}
/>
<Stack.Screen
name="mention-picker"
options={{
...SHEET_OPTIONS,
headerShown: true,
title: "Mention",
}}
/>
<Stack.Screen
name="issue/[id]/picker/project"
options={SHEET_OPTIONS}
/>
<Stack.Screen
name="issue/[id]/picker/due-date"
options={SHEET_OPTIONS}
/>
<Stack.Screen name="issue/[id]/runs" options={SHEET_OPTIONS} />
{/* Full emoji picker for a comment reaction. Pushed from the "+"
button inside the comment long-press tapback row — see
components/issue/comment-context-menu.tsx. */}
<Stack.Screen
name="issue/[id]/comment/[commentId]/emoji-picker"
options={SHEET_OPTIONS}
/>
{/* Project-detail formSheet pickers. */}
<Stack.Screen
name="project/[id]/picker/status"
options={SHEET_OPTIONS}
/>
<Stack.Screen
name="project/[id]/picker/priority"
options={SHEET_OPTIONS}
/>
<Stack.Screen
name="project/[id]/picker/lead"
options={SHEET_OPTIONS}
/>
<Stack.Screen
name="project/[id]/add-resource"
options={SHEET_OPTIONS}
/>
{/* New-issue draft formSheet pickers — stacked on top of the
new-issue.tsx Stack.Screen (which is itself a `modal`).
Expo Router 55 / RN Screens 4 support a formSheet pushed on top
of a modal in the same Stack. */}
<Stack.Screen
name="new-issue-picker/status"
options={SHEET_OPTIONS}
/>
<Stack.Screen
name="new-issue-picker/priority"
options={SHEET_OPTIONS}
/>
<Stack.Screen
name="new-issue-picker/assignee"
options={{
...SHEET_OPTIONS,
headerShown: true,
title: "Assignee",
}}
/>
<Stack.Screen
name="new-issue-picker/project"
options={SHEET_OPTIONS}
/>
<Stack.Screen
name="new-issue-picker/due-date"
options={SHEET_OPTIONS}
/>
{/* New-project draft formSheet pickers — same pattern as
new-issue-picker/*. Stacked on top of `project/new` (a modal). */}
<Stack.Screen
name="new-project-picker/status"
options={SHEET_OPTIONS}
/>
<Stack.Screen
name="new-project-picker/priority"
options={SHEET_OPTIONS}
/>
{/* Shared filter sheet for My Issues and the workspace Issues page —
chooses the right view-store via `?scope=my|all` URL param. */}
<Stack.Screen name="issues-filter" options={SHEET_OPTIONS} />
{/* Chat session-switch sheet. */}
<Stack.Screen name="chat-sessions" options={SHEET_OPTIONS} />
{/* Workspace switcher — reached from the More popover's collapsed
WorkspaceCard. Two-step (pick → iOS Alert confirm → switch). */}
<Stack.Screen name="switch-workspace" options={SHEET_OPTIONS} />
<Stack.Screen
name="more/issues"
options={{ title: "Issues", headerBackTitle: "Back" }}
/>
<Stack.Screen
name="more/projects"
options={{ title: "Projects", headerBackTitle: "Back" }}
/>
<Stack.Screen
name="more/agents"
options={{ title: "Agents", headerBackTitle: "Back" }}
/>
<Stack.Screen
name="more/pins"
options={{ title: "Pinned", headerBackTitle: "Back" }}
/>
<Stack.Screen
name="more/settings"
options={{ title: "Settings", headerBackTitle: "Back" }}
/>
<Stack.Screen
name="more/settings/profile"
options={{ title: "Profile", headerBackTitle: "Settings" }}
/>
<Stack.Screen
name="more/settings/notifications"
options={{ title: "Notifications", headerBackTitle: "Settings" }}
/>
<Stack.Screen
name="new-issue"
options={{
title: "New Issue",
presentation: "modal",
headerLeft: () => <ModalCloseButton />,
}}
/>
<Stack.Screen
name="search"
options={{
title: "Search",
presentation: "modal",
headerLeft: () => <ModalCloseButton />,
}}
/>
</Stack>
</RealtimeProvider>
);
}

View File

@@ -0,0 +1,121 @@
/**
* Chat session-switch sheet — presented as a formSheet by the parent Stack.
* Reads the session list from the chat cache and writes the user's pick
* through a shared "active session" store so the chat tab picks it up on
* dismiss.
*
* Why a tiny dedicated store: the chat tab's `activeSessionId` used to live
* as a `useState` inside `chat.tsx`, but now that session picking happens
* on a separate route screen, we need a cross-screen channel. Same minimum
* pattern as `useNewIssueDraftStore` for the new-issue form.
*/
import { Alert, Pressable, ScrollView, View } from "react-native";
import { router } from "expo-router";
import { useQuery } from "@tanstack/react-query";
import type { ChatSession } from "@multica/core/types";
import { Text } from "@/components/ui/text";
import { ActorAvatar } from "@/components/ui/actor-avatar";
import { chatSessionsOptions } from "@/data/queries/chat";
import { useDeleteChatSession } from "@/data/mutations/chat";
import { useChatSessionPickerStore } from "@/data/stores/chat-session-picker-store";
import { useWorkspaceStore } from "@/data/workspace-store";
import { cn } from "@/lib/utils";
export default function ChatSessionsRoute() {
const wsId = useWorkspaceStore((s) => s.currentWorkspaceId);
const { data: sessions = [] } = useQuery(chatSessionsOptions(wsId));
const activeSessionId = useChatSessionPickerStore((s) => s.activeSessionId);
const requestSelect = useChatSessionPickerStore((s) => s.requestSelect);
const deleteSession = useDeleteChatSession();
const confirmDelete = (session: ChatSession) => {
Alert.alert(
"Delete this chat?",
session.title || "Untitled chat",
[
{ text: "Cancel", style: "cancel" },
{
text: "Delete",
style: "destructive",
onPress: () => {
deleteSession.mutate(session.id);
// If we just deleted the active one, the chat tab clears its
// local activeSessionId via the picker-store request.
if (session.id === activeSessionId) {
requestSelect(null);
}
},
},
],
{ cancelable: true },
);
};
return (
<View className="flex-1">
<View className="px-4 pt-4 pb-3">
<Text className="text-base font-semibold text-foreground">Chats</Text>
</View>
<ScrollView className="flex-1" showsVerticalScrollIndicator={false}>
{sessions.length === 0 ? (
<View className="px-4 py-8">
<Text className="text-sm text-muted-foreground text-center">
No chats yet.
</Text>
</View>
) : (
sessions.map((session) => {
const selected = session.id === activeSessionId;
const archived = session.status === "archived";
return (
<Pressable
key={session.id}
onPress={() => {
requestSelect(session.id);
router.back();
}}
onLongPress={() => confirmDelete(session)}
className={cn(
"flex-row items-center gap-3 px-4 py-3 active:bg-secondary",
selected && "bg-secondary/60",
)}
>
<View
className={cn(
"h-2 w-2 rounded-full",
session.has_unread ? "bg-primary" : "bg-transparent",
)}
/>
<ActorAvatar
type="agent"
id={session.agent_id}
size={32}
showPresence
/>
<View className="flex-1">
<Text
className={cn(
"text-sm text-foreground",
session.has_unread && "font-semibold",
)}
numberOfLines={1}
>
{session.title || "Untitled chat"}
</Text>
{archived ? (
<Text className="text-xs text-muted-foreground mt-0.5">
archived
</Text>
) : null}
</View>
{selected ? (
<Text className="text-sm text-primary font-semibold"></Text>
) : null}
</Pressable>
);
})
)}
</ScrollView>
</View>
);
}

View File

@@ -0,0 +1,222 @@
/**
* Issue detail screen.
*
* Read-mostly timeline with an inline comment composer pinned to the
* bottom (`<InlineCommentComposer>`). The composer is a single
* `<TextInput>` + mention suggestion bar — no modal route, no toolbar,
* no draft persistence. Sticks to the keyboard via `KeyboardStickyView`.
*
* Header note: the parent _layout.tsx already declares the `issue/[id]`
* Stack.Screen with title "Issue". We override that here once the data
* lands so the navigation bar shows `MUL-123` (Linear-style).
*/
import { useCallback, useEffect } from "react";
import {
ActionSheetIOS,
ActivityIndicator,
Alert,
Linking,
View,
} from "react-native";
import { Stack, router, useLocalSearchParams } from "expo-router";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import * as Clipboard from "expo-clipboard";
import type { Issue } from "@multica/core/types";
import { Text } from "@/components/ui/text";
import { Button } from "@/components/ui/button";
import { IconButton } from "@/components/ui/icon-button";
import { TimelineList } from "@/components/issue/timeline-list";
import { AgentHeaderBadge } from "@/components/issue/agent-header-badge";
import { InlineCommentComposer } from "@/components/issue/inline-comment-composer";
import {
issueDetailOptions,
issueKeys,
issueTimelineOptions,
} from "@/data/queries/issues";
import { useDeleteIssue } from "@/data/mutations/issues";
import { pinListOptions } from "@/data/queries/pins";
import { useCreatePin, useDeletePin } from "@/data/mutations/pins";
import { useAuthStore } from "@/data/auth-store";
import { useIssueRealtime } from "@/data/realtime/use-issue-realtime";
import { useWorkspaceStore } from "@/data/workspace-store";
import { useViewedIssuesStore } from "@/data/viewed-issues-store";
import { useCommentSelectStore } from "@/data/comment-select-store";
import { useReplyTargetStore } from "@/data/stores/reply-target-store";
export default function IssueDetail() {
// `highlight` + `h` come from inbox deep-link (apps/mobile/app/(app)/
// [workspace]/(tabs)/inbox.tsx). `highlight` is the target comment id;
// `h` is a per-tap nonce so re-tapping the same row re-fires the
// scroll-and-flash effect.
const { id, workspace: wsSlug, highlight, h } = useLocalSearchParams<{
id: string;
workspace: string;
highlight?: string;
h?: string;
}>();
const wsId = useWorkspaceStore((s) => s.currentWorkspaceId);
const qc = useQueryClient();
const detail = useQuery(issueDetailOptions(wsId, id));
const timeline = useQuery(issueTimelineOptions(wsId, id));
// Subscribe to per-issue WS events: status/priority/assignee/label
// changes, comments, activity, reactions, agent task progress.
// Mounted with `id` — cleans up automatically on navigate-away.
// If another client deletes the issue we're viewing, pop back so the
// user isn't stranded on a 404 detail page.
useIssueRealtime(id, () => router.back());
// Track viewed issues so the chat composer's `@` suggestion bar can
// surface "Recent" — the user just looked at MUL-123, likely wants to
// ask the agent about it next. Workspace-scoped + in-memory; see
// data/viewed-issues-store.ts.
useEffect(() => {
if (wsId && id) {
useViewedIssuesStore.getState().push(wsId, id);
}
}, [wsId, id]);
// Screen-scoped composer state — clear on unmount so re-entering the
// issue starts from a clean slate (no stale text-selection comment id,
// no stale "Replying to X" target). Both stores are singletons used by
// the long-press action sheet.
useEffect(() => {
return () => {
useCommentSelectStore.getState().clear();
useReplyTargetStore.getState().clear();
};
}, []);
const onRefresh = useCallback(async () => {
await Promise.all([
detail.refetch(),
qc.invalidateQueries({ queryKey: issueKeys.timeline(wsId, id) }),
]);
}, [detail, qc, wsId, id]);
const issue = detail.data;
const deleteIssue = useDeleteIssue();
const userId = useAuthStore((s) => s.user?.id ?? null);
const { data: pins } = useQuery(pinListOptions(wsId, userId));
const isPinned =
!!issue &&
!!pins?.some((p) => p.item_type === "issue" && p.item_id === issue.id);
const createPin = useCreatePin();
const deletePin = useDeletePin();
// Three-dot menu: Pin/Unpin / Copy link / Open on web (if web URL set) /
// Delete. Mirrors apps/mobile/app/(app)/[workspace]/project/[id].tsx — same
// ActionSheetIOS + Alert.alert confirm pattern. Property edits (status,
// priority, assignee, due_date) live on the IssueHeaderCard chips inside
// the timeline list, not in this menu — one entry per action.
const onPressMore = useCallback(() => {
if (!issue || !wsSlug) return;
const webUrl = process.env.EXPO_PUBLIC_WEB_URL;
const issueLink = webUrl
? `${webUrl}/${wsSlug}/issue/${issue.identifier}`
: null;
const options: string[] = ["Cancel"];
options.push(isPinned ? "Unpin" : "Pin");
options.push("Edit details");
if (issueLink) options.push("Copy link");
if (issueLink) options.push("Open on web");
options.push("Delete issue");
const destructiveIndex = options.length - 1;
ActionSheetIOS.showActionSheetWithOptions(
{
options,
cancelButtonIndex: 0,
destructiveButtonIndex: destructiveIndex,
title: issue.identifier,
},
(i) => {
const label = options[i];
if (label === "Pin") {
createPin.mutate({ item_type: "issue", item_id: issue.id });
} else if (label === "Unpin") {
deletePin.mutate({ itemType: "issue", itemId: issue.id });
} else if (label === "Edit details") {
if (wsSlug) router.push(`/${wsSlug}/issue/${issue.id}/edit`);
} else if (label === "Copy link" && issueLink) {
Clipboard.setStringAsync(issueLink);
} else if (label === "Open on web" && issueLink) {
Linking.openURL(issueLink);
} else if (label === "Delete issue") {
confirmDelete(issue, () =>
deleteIssue.mutate(issue.id, {
onSuccess: () => router.back(),
}),
);
}
},
);
}, [issue, wsSlug, deleteIssue, isPinned, createPin, deletePin]);
return (
<View className="flex-1 bg-background">
<Stack.Screen
options={{
title: issue?.identifier ?? "Issue",
headerBackTitle: "Back",
headerRight: issue
? () => (
<View className="flex-row items-center gap-2">
{/* Ambient agent-working badge — renders null when no
* active tasks, so it doesn't crowd the header in the
* common case. See agent-header-badge.tsx. */}
<AgentHeaderBadge issueId={id} />
<IconButton
name="ellipsis-horizontal"
onPress={onPressMore}
accessibilityLabel="Issue actions"
/>
</View>
)
: undefined,
}}
/>
{detail.isLoading ? (
<View className="flex-1 items-center justify-center">
<ActivityIndicator />
</View>
) : detail.error || !issue ? (
<View className="flex-1 items-center justify-center px-6 gap-3">
<Text className="text-sm text-destructive text-center">
Failed to load issue:{" "}
{detail.error instanceof Error
? detail.error.message
: "not found"}
</Text>
<Button variant="outline" onPress={() => detail.refetch()}>
<Text>Retry</Text>
</Button>
</View>
) : (
<View className="flex-1">
<TimelineList
issue={issue}
entries={timeline.data}
timelineLoading={timeline.isLoading}
refreshing={detail.isRefetching || timeline.isRefetching}
onRefresh={onRefresh}
highlightCommentId={highlight}
highlightNonce={h}
/>
<InlineCommentComposer issueId={id} />
</View>
)}
</View>
);
}
function confirmDelete(issue: Issue, onConfirm: () => void) {
Alert.alert(
"Delete issue?",
`${issue.identifier} and its comments, reactions, and attachments will be permanently deleted. This cannot be undone.`,
[
{ text: "Cancel", style: "cancel" },
{ text: "Delete", style: "destructive", onPress: onConfirm },
],
);
}

View File

@@ -0,0 +1,114 @@
/**
* Full emoji picker for a comment reaction — opened from the per-comment
* long-press menu's "+" tapback button. Mirrors web's emoji-mart picker
* that sits behind QuickEmojiPicker's overflow button: same product
* semantics (mobile must offer the full emoji set, not only the 8 quick
* picks).
*
* Reads the comment from the timeline cache to detect an already-applied
* reaction by the current user, then fires `useToggleCommentReaction` with
* the right `existing` value so re-tapping an active emoji removes it
* (matches web behaviour and the inline ReactionBar toggle semantics).
*
* Library: `rn-emoji-keyboard` (TheWidlarzGroup/rn-emoji-keyboard). We
* embed the `EmojiKeyboard` component (no built-in modal) inside the
* Expo Router formSheet route body, so the iOS UISheetPresentationController
* still owns the chrome (grabber, detents, drag-to-dismiss).
*/
import { useCallback, useMemo } from "react";
import { View } from "react-native";
import { useLocalSearchParams, router } from "expo-router";
import { useQuery } from "@tanstack/react-query";
import { EmojiKeyboard, type EmojiType } from "rn-emoji-keyboard";
import type { Reaction } from "@multica/core/types";
import { Text } from "@/components/ui/text";
import { issueTimelineOptions } from "@/data/queries/issues";
import { useToggleCommentReaction } from "@/data/mutations/issues";
import { useAuthStore } from "@/data/auth-store";
import { useWorkspaceStore } from "@/data/workspace-store";
import { useColorScheme } from "@/lib/use-color-scheme";
import { THEME } from "@/lib/theme";
export default function CommentEmojiPickerRoute() {
const { id, commentId } = useLocalSearchParams<{
id: string;
commentId: string;
}>();
const wsId = useWorkspaceStore((s) => s.currentWorkspaceId);
const userId = useAuthStore((s) => s.user?.id);
const toggle = useToggleCommentReaction(id);
const { colorScheme } = useColorScheme();
const { data: timeline = [] } = useQuery(issueTimelineOptions(wsId, id));
const entry = useMemo(
() => timeline.find((e) => e.id === commentId) ?? null,
[timeline, commentId],
);
const reactions = useMemo<Reaction[]>(
() => (entry?.reactions ?? []) as Reaction[],
[entry?.reactions],
);
const onSelect = useCallback(
(picked: EmojiType) => {
const existing = reactions.find(
(r) =>
r.emoji === picked.emoji &&
r.actor_type === "member" &&
r.actor_id === userId,
);
toggle.mutate({ commentId, emoji: picked.emoji, existing });
router.back();
},
[reactions, userId, toggle, commentId],
);
const theme = THEME[colorScheme];
return (
<View className="flex-1">
<View className="px-4 pt-3 pb-2">
<Text className="text-lg font-semibold text-foreground">
Add Reaction
</Text>
</View>
<View className="flex-1">
<EmojiKeyboard
onEmojiSelected={onSelect}
enableSearchBar
enableRecentlyUsed
categoryPosition="top"
theme={{
backdrop: theme.background,
knob: theme.mutedForeground,
container: theme.popover,
header: theme.foreground,
skinTonesContainer: theme.secondary,
category: {
icon: theme.mutedForeground,
iconActive: theme.foreground,
container: theme.popover,
containerActive: theme.secondary,
},
search: {
background: theme.secondary,
text: theme.foreground,
placeholder: theme.mutedForeground,
icon: theme.mutedForeground,
},
customButton: {
icon: theme.mutedForeground,
iconPressed: theme.foreground,
background: theme.secondary,
backgroundPressed: theme.muted,
},
emoji: {
selected: theme.secondary,
},
}}
/>
</View>
</View>
);
}

View File

@@ -0,0 +1,203 @@
/**
* Edit issue title / description. Modal presentation, configured in
* `[workspace]/_layout.tsx`. Save runs the optimistic `useUpdateIssue`
* mutation; modal dismisses on success.
*
* Mirrors `project/[id]/edit.tsx` so users get the same gesture on both
* record types (cancel/save in header, dirty Alert on dismiss-while-dirty).
*
* Description uses `useMentionInput` + `<DescriptionField>` so the @-mention
* pipeline matches `new-issue.tsx`. v1 note: existing mentions in the
* server-side description render as raw markdown text while editing because
* there's no markdown-to-marker deserializer yet — `serialize()` still
* produces a valid round-trip since unparsed `[@name](mention://...)` literals
* pass through unchanged. New @-mentions added during the edit get serialized
* normally via the marker pipeline.
*
* Properties (status / priority / assignee / labels / project / due_date)
* are NOT edited here — they have dedicated chip pickers on the detail page.
* This screen only owns the two free-text fields.
*/
import { useCallback, useEffect, useMemo, useState } from "react";
import {
Alert,
KeyboardAvoidingView,
Platform,
Pressable,
ScrollView,
TextInput,
View,
} from "react-native";
import { Stack, router, useLocalSearchParams } from "expo-router";
import { useQuery } from "@tanstack/react-query";
import { Text } from "@/components/ui/text";
import { DescriptionField } from "@/components/issue/description-field";
import { MentionSuggestionBar } from "@/components/issue/mention-suggestion-bar";
import { MOBILE_PLACEHOLDER_COLOR } from "@/components/ui/input-tokens";
import { issueDetailOptions } from "@/data/queries/issues";
import { useUpdateIssue } from "@/data/mutations/issues";
import { useWorkspaceStore } from "@/data/workspace-store";
import { useMentionInput } from "@/lib/use-mention-input";
export default function EditIssue() {
const { id } = useLocalSearchParams<{ id: string }>();
const wsId = useWorkspaceStore((s) => s.currentWorkspaceId);
const detail = useQuery(issueDetailOptions(wsId, id));
const update = useUpdateIssue(id);
const [title, setTitle] = useState("");
const description = useMentionInput();
const [seeded, setSeeded] = useState(false);
// `useMentionInput` returns `setText` from `useState`, which is a stable
// identity across renders. Pulling it out of the hook return lets us list
// it explicitly in the seeding effect's dep array without the whole
// `description` object (which changes every render) re-triggering the
// seed and overwriting in-progress edits.
const setDescriptionText = description.setText;
useEffect(() => {
if (!detail.data || seeded) return;
setTitle(detail.data.title);
setDescriptionText(detail.data.description ?? "");
setSeeded(true);
}, [detail.data, seeded, setDescriptionText]);
const initialDescription = detail.data?.description ?? "";
const currentDescription = description.serialize();
const dirty = useMemo(() => {
if (!detail.data || !seeded) return false;
return (
title.trim() !== detail.data.title ||
currentDescription.trim() !== initialDescription
);
}, [detail.data, seeded, title, currentDescription, initialDescription]);
const canSave =
seeded && title.trim().length > 0 && dirty && !update.isPending;
const onCancel = useCallback(() => {
if (!dirty) {
router.back();
return;
}
Alert.alert(
"Discard changes?",
"Your edits to this issue will be lost.",
[
{ text: "Keep editing", style: "cancel" },
{
text: "Discard",
style: "destructive",
onPress: () => router.back(),
},
],
);
}, [dirty]);
const onSave = useCallback(() => {
if (!canSave) return;
// `UpdateIssueRequest.description` is `string | undefined` — server
// treats empty string as "clear the description", which is what we
// want when the user wipes the field.
const patch = {
title: title.trim(),
description: currentDescription.trim(),
};
update.mutate(patch, {
onSuccess: () => router.back(),
onError: (err) => {
Alert.alert(
"Failed to save",
err instanceof Error ? err.message : "Unknown error",
);
},
});
}, [canSave, title, currentDescription, update]);
const headerLeft = useCallback(
() => (
<Pressable onPress={onCancel} className="px-1 py-1">
<Text className="text-base text-brand">Cancel</Text>
</Pressable>
),
[onCancel],
);
const headerRight = useCallback(
() => (
<Pressable
onPress={onSave}
disabled={!canSave}
className={canSave ? "px-1 py-1" : "px-1 py-1 opacity-40"}
>
<Text className="text-base text-brand font-semibold">
{update.isPending ? "Saving…" : "Save"}
</Text>
</Pressable>
),
[canSave, onSave, update.isPending],
);
return (
<>
<Stack.Screen options={{ headerLeft, headerRight }} />
<KeyboardAvoidingView
className="flex-1 bg-background"
behavior={Platform.OS === "ios" ? "padding" : undefined}
>
<ScrollView
className="flex-1"
contentContainerClassName="px-4 pt-4 pb-6 gap-4"
keyboardShouldPersistTaps="handled"
>
{!detail.data ? (
<Text className="text-sm text-muted-foreground">Loading</Text>
) : (
<>
<Field label="Title">
<TextInput
value={title}
onChangeText={setTitle}
placeholder="Issue title"
placeholderTextColor={MOBILE_PLACEHOLDER_COLOR}
className="text-base text-foreground bg-secondary/50 rounded-md px-3 py-2"
returnKeyType="next"
editable={!update.isPending}
/>
</Field>
<Field label="Description">
<DescriptionField
description={description}
disabled={update.isPending}
/>
</Field>
</>
)}
</ScrollView>
{/* Mention suggestion bar floats above the keyboard while the user
is mid-@. Outside the ScrollView so it doesn't scroll with the
form body. */}
<MentionSuggestionBar {...description.suggestionBar} />
</KeyboardAvoidingView>
</>
);
}
function Field({
label,
children,
}: {
label: string;
children: React.ReactNode;
}) {
return (
<View className="gap-1.5">
<Text className="text-xs uppercase tracking-wider text-muted-foreground">
{label}
</Text>
{children}
</View>
);
}

View File

@@ -0,0 +1,44 @@
/**
* Assignee picker route for an existing issue. Uses the native iOS Stack
* header + UISearchController (registered in ../_layout.tsx with
* `headerShown: true` + title); the search bar wiring is encapsulated in
* `useNativeSearchBar`.
*/
import { useLocalSearchParams, router } from "expo-router";
import { useQuery } from "@tanstack/react-query";
import { AssigneePickerBody } from "@/components/issue/pickers/assignee-picker-body";
import { issueDetailOptions } from "@/data/queries/issues";
import { useUpdateIssue } from "@/data/mutations/issues";
import { useWorkspaceStore } from "@/data/workspace-store";
import { useNativeSearchBar } from "@/lib/use-native-search-bar";
export default function IssueAssigneePickerRoute() {
const { id } = useLocalSearchParams<{ id: string }>();
const wsId = useWorkspaceStore((s) => s.currentWorkspaceId);
const { data: issue } = useQuery(issueDetailOptions(wsId, id));
const updateIssue = useUpdateIssue(id);
const query = useNativeSearchBar("Search people", { autoFocus: true });
const value =
issue?.assignee_type && issue?.assignee_id
? { type: issue.assignee_type, id: issue.assignee_id }
: null;
return (
<AssigneePickerBody
value={value}
query={query}
onChange={(next) => {
if (next === null) {
updateIssue.mutate({ assignee_type: null, assignee_id: null });
} else {
updateIssue.mutate({
assignee_type: next.type,
assignee_id: next.id,
});
}
router.back();
}}
/>
);
}

View File

@@ -0,0 +1,84 @@
/**
* Due-date picker route for an existing issue.
*
* Diverges from the other single-select pickers because the native
* UIDatePicker needs a confirmation step — the user spins to a date but
* doesn't auto-commit on every onChange. Done / Clear buttons live in a
* mini header row inside the route body (the parent Stack hides its own
* header per the formSheet config), and on submit we fire the mutation +
* router.back().
*/
import { useRef } from "react";
import { Pressable, View } from "react-native";
import { useLocalSearchParams, router } from "expo-router";
import { useQuery } from "@tanstack/react-query";
import { Text } from "@/components/ui/text";
import {
DueDatePickerBody,
type DueDatePickerBodyHandle,
} from "@/components/issue/pickers/due-date-picker-body";
import { issueDetailOptions } from "@/data/queries/issues";
import { useUpdateIssue } from "@/data/mutations/issues";
import { useWorkspaceStore } from "@/data/workspace-store";
export default function IssueDueDatePickerRoute() {
const { id } = useLocalSearchParams<{ id: string }>();
const wsId = useWorkspaceStore((s) => s.currentWorkspaceId);
const { data: issue } = useQuery(issueDetailOptions(wsId, id));
const updateIssue = useUpdateIssue(id);
const ref = useRef<DueDatePickerBodyHandle>(null);
const value = issue?.due_date ?? null;
return (
<View className="flex-1">
<DueDateHeader
hasValue={!!value}
onDone={() => {
const iso = ref.current?.getIso();
if (iso) updateIssue.mutate({ due_date: iso });
router.back();
}}
onClear={() => {
updateIssue.mutate({ due_date: null });
router.back();
}}
/>
<DueDatePickerBody ref={ref} value={value} />
</View>
);
}
function DueDateHeader({
hasValue,
onDone,
onClear,
}: {
hasValue: boolean;
onDone: () => void;
onClear: () => void;
}) {
return (
<View className="flex-row items-center justify-between px-4 pt-4 pb-2">
<Text className="text-base font-semibold text-foreground">Due date</Text>
<View className="flex-row items-center gap-1">
{hasValue ? (
<Pressable
onPress={onClear}
hitSlop={6}
className="px-2 py-1 rounded-md active:bg-secondary"
>
<Text className="text-sm text-destructive">Clear</Text>
</Pressable>
) : null}
<Pressable
onPress={onDone}
hitSlop={6}
className="px-2 py-1 rounded-md active:bg-secondary"
>
<Text className="text-sm font-medium text-primary">Done</Text>
</Pressable>
</View>
</View>
);
}

View File

@@ -0,0 +1,59 @@
/**
* Label picker route for an existing issue — multi-select with inline
* create. Uses native iOS Stack header + UISearchController via
* `useNativeSearchBar` (sheet stays open across toggles; the user
* dismisses via the sheet grabber or the Back button).
*/
import { useRef } from "react";
import { useLocalSearchParams } from "expo-router";
import { useQuery } from "@tanstack/react-query";
import { LabelPickerBody } from "@/components/issue/pickers/label-picker-body";
import { issueDetailOptions } from "@/data/queries/issues";
import {
useAttachLabel,
useDetachLabel,
} from "@/data/mutations/issues";
import { useCreateLabel } from "@/data/mutations/labels";
import { useWorkspaceStore } from "@/data/workspace-store";
import { useNativeSearchBar } from "@/lib/use-native-search-bar";
export default function IssueLabelPickerRoute() {
const { id } = useLocalSearchParams<{ id: string }>();
const wsId = useWorkspaceStore((s) => s.currentWorkspaceId);
const { data: issue } = useQuery(issueDetailOptions(wsId, id));
const attachLabel = useAttachLabel(id);
const detachLabel = useDetachLabel(id);
const createLabel = useCreateLabel();
const query = useNativeSearchBar("Search labels", { autoFocus: true });
// Synchronous lock to prevent double-submit on rapid taps on the Create
// row before React state updates — mirrors web's `creatingRef` pattern in
// `packages/views/issues/components/pickers/label-picker.tsx`.
const creatingRef = useRef(false);
const attached = issue?.labels ?? [];
return (
<LabelPickerBody
attached={attached}
query={query}
onAttach={(label) => attachLabel.mutate({ label })}
onDetach={(labelId) => detachLabel.mutate({ labelId })}
onCreate={(name, color) => {
if (creatingRef.current) return;
creatingRef.current = true;
createLabel.mutate(
{ name, color },
{
onSuccess: (label) => {
attachLabel.mutate({ label });
},
onSettled: () => {
creatingRef.current = false;
},
},
);
}}
/>
);
}

View File

@@ -0,0 +1,27 @@
/**
* Priority picker route for an existing issue. See ./status.tsx for the
* self-contained-route rationale.
*/
import { useLocalSearchParams, router } from "expo-router";
import { useQuery } from "@tanstack/react-query";
import { PriorityPickerBody } from "@/components/issue/pickers/priority-picker-body";
import { issueDetailOptions } from "@/data/queries/issues";
import { useUpdateIssue } from "@/data/mutations/issues";
import { useWorkspaceStore } from "@/data/workspace-store";
export default function IssuePriorityPickerRoute() {
const { id } = useLocalSearchParams<{ id: string }>();
const wsId = useWorkspaceStore((s) => s.currentWorkspaceId);
const { data: issue } = useQuery(issueDetailOptions(wsId, id));
const updateIssue = useUpdateIssue(id);
return (
<PriorityPickerBody
value={issue?.priority ?? "none"}
onChange={(next) => {
updateIssue.mutate({ priority: next });
router.back();
}}
/>
);
}

View File

@@ -0,0 +1,39 @@
/**
* Project picker route for an existing issue. Uses native iOS Stack header
* + UISearchController via `useNativeSearchBar` (search bar registered in
* ../_layout.tsx).
*/
import { useMemo } from "react";
import { useLocalSearchParams, router } from "expo-router";
import { useQuery } from "@tanstack/react-query";
import { ProjectPickerBody } from "@/components/issue/pickers/project-picker-body";
import { issueDetailOptions } from "@/data/queries/issues";
import { findProject, projectListOptions } from "@/data/queries/projects";
import { useUpdateIssue } from "@/data/mutations/issues";
import { useWorkspaceStore } from "@/data/workspace-store";
import { useNativeSearchBar } from "@/lib/use-native-search-bar";
export default function IssueProjectPickerRoute() {
const { id } = useLocalSearchParams<{ id: string }>();
const wsId = useWorkspaceStore((s) => s.currentWorkspaceId);
const { data: issue } = useQuery(issueDetailOptions(wsId, id));
const { data: projects = [] } = useQuery(projectListOptions(wsId));
const updateIssue = useUpdateIssue(id);
const query = useNativeSearchBar("Search projects", { autoFocus: true });
const project = useMemo(
() => findProject(projects, issue?.project_id ?? null),
[projects, issue?.project_id],
);
return (
<ProjectPickerBody
value={project ?? null}
query={query}
onChange={(next) => {
updateIssue.mutate({ project_id: next?.id ?? null });
router.back();
}}
/>
);
}

View File

@@ -0,0 +1,36 @@
/**
* Status picker route for an existing issue — presented as a formSheet
* (UISheetPresentationController) by the parent Stack.
*
* Self-contained: reads the issue from the TanStack Query detail cache,
* calls `useUpdateIssue` directly on selection, then `router.back()`s. No
* onChange callback to a parent.
*
* If the cache is cold (rare — the user reaches this screen by tapping
* a chip on the issue-detail page that already populated it), the picker
* still renders against the current value of `todo` and the optimistic
* mutation patches the cache when the user picks.
*/
import { useLocalSearchParams, router } from "expo-router";
import { useQuery } from "@tanstack/react-query";
import { StatusPickerBody } from "@/components/issue/pickers/status-picker-body";
import { issueDetailOptions } from "@/data/queries/issues";
import { useUpdateIssue } from "@/data/mutations/issues";
import { useWorkspaceStore } from "@/data/workspace-store";
export default function IssueStatusPickerRoute() {
const { id } = useLocalSearchParams<{ id: string }>();
const wsId = useWorkspaceStore((s) => s.currentWorkspaceId);
const { data: issue } = useQuery(issueDetailOptions(wsId, id));
const updateIssue = useUpdateIssue(id);
return (
<StatusPickerBody
value={issue?.status ?? "todo"}
onChange={(next) => {
updateIssue.mutate({ status: next });
router.back();
}}
/>
);
}

View File

@@ -0,0 +1,111 @@
/**
* Agent Runs sheet — presented as a formSheet by the parent Stack. Two
* sections: Active (queued/dispatched/running, created_at desc) and Past
* (failed → cancelled → completed, completed_at desc within each). Empty
* sections hide entirely.
*
* Both entry points (the in-card AgentActivityRow and the Stack-header
* AgentHeaderBadge) now `router.push("/[workspace]/issue/[id]/runs")` —
* the legacy `useRunsSheetStore` is gone since the route system is the
* single source of truth for what's open.
*
* Past-row tap is a no-op in v1 — transcript drilldown is deferred.
*/
import { useMemo } from "react";
import { ScrollView, View } from "react-native";
import { useLocalSearchParams } from "expo-router";
import { useQuery } from "@tanstack/react-query";
import type { AgentTask } from "@multica/core/types";
import { Text } from "@/components/ui/text";
import { RunRow } from "@/components/issue/run-row";
import {
issueActiveTasksOptions,
issueTasksOptions,
} from "@/data/queries/issues";
import { useWorkspaceStore } from "@/data/workspace-store";
const PAST_STATUS_ORDER: Record<AgentTask["status"], number> = {
failed: 0,
cancelled: 1,
completed: 2,
queued: 99,
dispatched: 99,
waiting_local_directory: 99,
running: 99,
};
export default function IssueRunsRoute() {
const { id } = useLocalSearchParams<{ id: string }>();
const wsId = useWorkspaceStore((s) => s.currentWorkspaceId);
const { data: activeTasks = [] } = useQuery(
issueActiveTasksOptions(wsId, id),
);
const { data: allTasks = [] } = useQuery(issueTasksOptions(wsId, id));
const active = useMemo(
() =>
[...activeTasks].sort((a, b) =>
(b.created_at ?? "").localeCompare(a.created_at ?? ""),
),
[activeTasks],
);
const past = useMemo(() => {
const filtered = allTasks.filter(
(t) =>
t.status === "completed" ||
t.status === "failed" ||
t.status === "cancelled",
);
return filtered.sort((a, b) => {
const ord = PAST_STATUS_ORDER[a.status] - PAST_STATUS_ORDER[b.status];
if (ord !== 0) return ord;
return (b.completed_at ?? "").localeCompare(a.completed_at ?? "");
});
}, [allTasks]);
return (
<View className="flex-1">
<View className="px-4 pt-4 pb-3">
<Text className="text-base font-semibold text-foreground">
Agent Runs
</Text>
</View>
<ScrollView showsVerticalScrollIndicator={false}>
<View className="px-4 gap-3 pb-4">
{active.length > 0 ? (
<Section title="Active">
{active.map((task) => (
<RunRow key={task.id} task={task} issueId={id} />
))}
</Section>
) : null}
{past.length > 0 ? (
<Section title="Past">
{past.map((task) => (
<RunRow key={task.id} task={task} issueId={id} />
))}
</Section>
) : null}
</View>
</ScrollView>
</View>
);
}
function Section({
title,
children,
}: {
title: string;
children: React.ReactNode;
}) {
return (
<View className="gap-1">
<Text className="text-[11px] font-medium text-muted-foreground uppercase tracking-wide">
{title}
</Text>
<View>{children}</View>
</View>
);
}

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