Compare commits

...

84 Commits

Author SHA1 Message Date
Jiayuan Zhang
c6ee718117 refactor(daemon/execenv): lazy-load full multica CLI manual via skill
Slim the `## Available Commands` section in CLAUDE.md / AGENTS.md /
GEMINI.md down to the high-frequency subset and ship the full flag-level
reference as a built-in `multica-cli` skill installed into every task's
provider-native skills directory. Saves ~750 prompt tokens per turn
while keeping low-frequency commands (autopilots, labels, subscribers,
project resources, run-messages) one skill-load away.

The MUL-1467 multi-line / HEREDOC guidance and example stay inlined in
the runtime config — they're an unmissable footgun, not an edge case.

Co-authored-by: multica-agent <github@multica.ai>
2026-05-07 12:11:47 +08:00
Mark Gaze
f9cdd487e0 fix(projects): pre-fill the status and project to match the parent issue when creating sub-issue (#2177) 2026-05-07 08:10:25 +08:00
Jiayuan Zhang
5d51a0c9df feat(cli): add multica workspace update (#2191)
* feat(cli): add `multica workspace update` to edit workspace metadata

Closes the CLI-side gap for #2178: the `PATCH /api/workspaces/{id}`
endpoint and TS client method already exist, only the CLI subcommand
was missing. Supports partial updates of name, description, context,
and issue_prefix; long fields accept stdin via `--description-stdin` /
`--context-stdin`. `slug` stays immutable, `settings`/`repos` are out
of scope (deferred). Empty PATCH is rejected locally so we don't fire
a no-op `EventWorkspaceUpdated` broadcast. Permission gate is
unchanged (server-side admin/owner middleware).

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

* fix(cli): address review on workspace update command

- Reject `--issue-prefix ""` (and whitespace-only) explicitly. The
  server handler silently skips empty prefixes, so the previous
  behavior was a 200 OK with no actual change — exactly the kind of
  invisible no-op Emacs flagged in review.
- Restore the `## Issues` H2 in the zh CLI reference. The earlier
  edit dropped it, leaving issue commands nested under the Workspaces
  section.

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

* docs(cli): list `workspace update` in the en + zh top-level reference

Mirrors the existing zh-only entry under apps/docs/content/docs/cli/
into the English overview so the new command is discoverable from
both locales.

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

---------

Co-authored-by: multica-agent <github@multica.ai>
2026-05-07 00:49:36 +02:00
Jiayuan Zhang
d07c7c2a15 feat(inbox): auto-select next item after archiving the selected one (#2190)
Archiving the currently selected inbox item used to clear the selection
and leave the detail panel empty, forcing the user to click the next
item to keep going. Pick the next (older) item from the deduplicated
list, falling back to the previous (newer) one when archiving at the
bottom, and only clear when nothing is left.

Route the detail panel's onDone path through the same handleArchive so
the auto-select behavior is shared.

Co-authored-by: multica-agent <github@multica.ai>
2026-05-07 06:19:46 +08:00
Bohan Jiang
0af67c8159 fix(agent/openclaw): block tasks if openclaw < 2026.5.5 with upgrade hint (#2181)
PR #2101 swapped the openclaw runtime adapter from reading --json on
stderr to stdout. That fixed openclaw 2026.5+ but inverted the breakage
for pre-2026.5 builds — those still write JSON to stderr, so the
adapter now sees an empty stdout and falls through to the same
"openclaw returned no parseable output" failure that 2026.5+ users
saw before #2101.

Add a per-task version gate inside openclawBackend.Execute that runs
`openclaw --version`, parses the dotted version, and rejects anything
below 2026.5.5 with a hardcoded upgrade hint:

    openclaw <detected> is below the minimum supported version 2026.5.5.
    Run `openclaw update` to upgrade and try again.

The check is intentionally per-task and uncached so users who upgrade
do not need to restart the daemon — the next task automatically
re-checks. ~20ms per task is negligible vs. the typical run.

Co-authored-by: multica-agent <github@multica.ai>
2026-05-07 02:11:47 +08:00
Thanh Minh
9c00ecfdb4 fix(issues): blur sticky agent live card (#2170)
* fix(issues): blur sticky agent live card

* fix(issues): drop inner live-card blur

* fix(issues): match sticky live-card radius
2026-05-07 02:01:11 +08:00
Joey Frasier (Boothe)
af971e1e5c fix(agent/openclaw): read --json from stdout, not stderr (#2101)
Multica's openclaw runtime adapter has been reading agent output from
stderr since the early openclaw integration days. Current openclaw
(2026.5.5, c37871e) writes its --json blob exclusively to stdout:

    $ openclaw agent --local --json --agent main --message 'say hi' >stdout 2>stderr
    STDOUT bytes: 27401
    STDERR bytes:     0

Result: every successful turn was followed by a daemon-generated system
comment 'openclaw returned no parseable output', visible to users,
looked like the agent broke when it didn't. Reproduced live on WOR-2,
turn at 2026-05-05 16:35 UTC; daemon log confirmed the full result JSON
arrived on the [openclaw:stdout] debug channel and was discarded while
the empty stderr pipe hit the no-events fallback.

Changes
- server/pkg/agent/openclaw.go: swap pipes, StdoutPipe() for the JSON
  stream, cmd.Stderr = newLogWriter(...) for log overflow. Cleanup
  goroutine now closes stdout on cancel. Comments and the read-error
  errMsg updated to reflect the new pipe.
- server/pkg/agent/openclaw_test.go: TestOpenclawProcessOutputReadError
  asserts on 'read stdout' (was 'read stderr'), string-only fix,
  no behavior change. New TestOpenclawProcessOutputStdoutFixture feeds
  a recorded openclaw 2026.5.5 --json blob through processOutput and
  asserts result + messages parse cleanly.
- server/pkg/agent/testdata/openclaw-2026.5.5-stdout.json: 27401-byte
  fixture captured fresh from the openclaw CLI for the regression test.

Side effects (net positive)
- Log lines openclaw writes to stderr (security warnings, tool errors)
  now show up under [openclaw:stderr] instead of being silently consumed
  by the JSON parser.
- Daemon's success_pattern heuristic (empty-output -> 'blocked')
  becomes meaningful again because result.Output actually populates.

Closes WOR-10.
2026-05-07 01:50:16 +08:00
Bohan Jiang
d0ac67dea2 fix(skills): drop SKILL.md content from list endpoints (#2180)
* fix(skills): drop SKILL.md content from list endpoints (#2174)

`GET /api/skills` and `GET /api/agents/{id}/skills` were SELECT *'ing the
skill row and shipping the full SKILL.md `content` blob to every caller.
SKILL.md bodies routinely run 50–200KB each, so a workspace with 30–40
skills returned multi-megabyte JSON arrays — past the CLI's 15s timeout
on high-latency links and locking out non-US users entirely.

Add `ListSkillSummariesByWorkspace` / `ListAgentSkillSummaries` sqlc
queries that omit `content`, plus a dedicated `SkillSummaryResponse`
wire shape so the contract is explicit (versus stuffing
`Content: ""` back into the existing struct). Detail endpoints
(`GET /api/skills/{id}`, agent CRUD return values) keep returning the
full body.

`AgentResponse.skills` and the matching TS `Agent.skills` now use
`SkillSummary[]` — frontend list/columns code already only read
id/name/description/config.origin, so the type narrowing matches actual
usage and prevents new code from accidentally depending on a content
field that won't be there.

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

* fix(agents): narrow embedded skills to AgentSkillSummary; gofmt agent.go

GPT-Boy review of #2180: the previous commit typed AgentResponse.Skills as
[]SkillSummaryResponse, but the agent list batch query
(ListAgentSkillsByWorkspace) only joins agent_id/id/name/description, so
the wider type left workspace_id/config/created_at/updated_at as zero
values. Define a dedicated AgentSkillSummary {id,name,description} that
matches what the batch query actually returns and what the frontend
actually reads (`agent.skills.map(s => s.name|s.id)`); the standalone
GET /api/agents/{id}/skills endpoint keeps SkillSummaryResponse for
callers that need the source/origin info.

Switch GetAgent's per-agent skills load from ListAgentSkills (full Skill
rows including content) back to ListAgentSkillSummaries to avoid reading
SKILL.md bodies just to discard them.

Re-run gofmt on agent.go to fix the field-tag alignment that drifted when
Skills changed type.

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

* docs(types): correct SkillSummary JSDoc — Agent.skills is AgentSkillSummary[]

GPT-Boy spotted on review: comment said SkillSummary was "embedded in
Agent.skills", but that field is now AgentSkillSummary[]. Re-point the
reader at the right type to avoid future confusion.

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

---------

Co-authored-by: multica-agent <github@multica.ai>
2026-05-07 01:36:29 +08:00
Bohan Jiang
53a3b33c50 fix(docs): keep zh internal links inside the zh locale (#2179)
Markdown links like `[xx](/workspaces)` written in `*.zh.mdx` rendered
as bare `<a href="/workspaces">`, which Next's basePath rewrote to
`/docs/workspaces` and the docs middleware then routed to English —
silently kicking Chinese readers out of their locale on every internal
click.

Add a `LocaleLink` MDX `a` override that runs every internal href
through `prefixLocale(href, lang)` before passing it to `next/link`, and
wire a `DocsLocaleProvider` around the MDX body in both page entry
points so the override and `NumberedCard` know the active locale.
External links, in-page anchors, relative paths, already-prefixed
paths, and default-language pages are deliberately left untouched.

Closes the bug reported in https://github.com/multica-ai/multica/issues/2173.

Co-authored-by: multica-agent <github@multica.ai>
2026-05-07 01:21:57 +08:00
Jiayuan Zhang
c3ddb57b82 feat(create-issue): add border beam to switch-to-agent button (#2157)
* feat(create-issue): add border beam to "switch to agent" button

Draws the eye to the manual→agent affordance so users discover quick
capture mode. Adds a reusable .border-beam utility (conic-gradient ring
on ::before, driven by an @property-animated angle) and applies it to
the switch-to-agent button alongside a brand-tinted background tint and
a hover icon flip. Honors prefers-reduced-motion.

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

* style(border-beam): switch to magic-ui colorful palette

Replaces the single brand-color sweep with a rainbow trail
(#ffbe7b → #ff777f → #ff8ab4 → #a07cfe → #5b9dff), matching the
`colorVariant="colorful"` look from magic-ui's border-beam reference.
Static fallback under prefers-reduced-motion uses the same palette as a
linear gradient.

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

---------

Co-authored-by: multica-agent <github@multica.ai>
2026-05-06 16:01:31 +02:00
Jiayuan Zhang
d16c48172a fix(projects): pre-fill project on per-status "+" create-issue (#2155)
The "+" button in each status column/section opens the create-issue
modal. On the project detail page it was passing only `{ status }`,
so the new issue's project field came up empty even though the user
was clearly in a project context. Thread `projectId` through
BoardView/ListView down to BoardColumn/StatusAccordionItem and
include `project_id` in the modal payload when set.

Co-authored-by: multica-agent <github@multica.ai>
2026-05-06 18:48:31 +08:00
Naiyuan Qing
11a6288cbd fix(timeline): legacy array shape for pre-#2128 clients (#2143, #2147) (#2156)
#2128 changed GET /api/issues/:id/timeline from a bare TimelineEntry[] to
a wrapped { entries, next_cursor, ... } object. Multica.app ≤ v0.2.25 still
in the wild reads the response body as TimelineEntry[] directly, so the
moment v0.2.26 backend rolled out, every old desktop hit
"timeline.filter is not a function" on any issue open — bug reports landed
within ten minutes of the v0.2.26 release (#2143, #2147).

The new client always sends ?limit=..., so absence of every pagination
param uniquely identifies a legacy caller. Detect that at the top of
ListTimeline and serve the old shape (ASC, []TimelineEntry, capped at 200)
through a dedicated listTimelineLegacy helper. New clients fall through
unchanged.

A new TestListTimeline_LegacyShapeForPreCursorClients pins the contract
(array shape, ASC order, "[]" not "null" on empty issues). Two existing
tests that used the empty query string have been updated to send
?limit=50, since the empty form is now reserved for the compat path.

The legacy branch can be deleted once desktop auto-update has rolled the
user base past v0.2.26.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-06 18:46:45 +08:00
Naiyuan Qing
32740d0ee3 docs+i18n: fix terminology/runtime drift across landing, onboarding, docs (#2146)
* fix(landing): align ZH copy with conventions and update tool list to 11

- Replace "Agent" with "智能体" in ZH marketing copy (lines 1-275) per
  conventions.zh.mdx — landing was the only surface still using "Agent"
  while UI, docs, and locales already use "智能体". Changelog-section
  technical names (Agent SDK / Agent runtime / Cursor Agent) preserved.
- Replace the 4-tool list (Claude Code / Codex / OpenClaw / OpenCode)
  with the actual 11 supported tools across hero card, how-it-works
  step, and FAQ — this matches daemon-runtimes.mdx and the file's own
  changelog entries that already record the rollout of Cursor, Copilot,
  Gemini, Hermes, Kimi, Kiro CLI, and Pi.
- Drop the "plug in and go" line; replace with an honest sentence about
  multica setup walking through OAuth + daemon start.

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

* fix(i18n): correct daemon/runtime drift across modals, onboarding, docs

- modals/zh-Hans: 4 places used "daemon" untranslated; conventions.zh.mdx
  rules Daemon -> 守护进程. Aligned.
- onboarding/zh-Hans: line "把任务交给它们" was the only spot using "任务"
  for the task entity; rest of the file already uses lowercase "task"
  per conventions. Aligned.
- onboarding (en + zh-Hans) runtime_aside.what_suffix: said runtime IS
  a background process. daemon-runtimes.mdx defines runtime = daemon ×
  one AI coding tool (one machine + N tools = N runtimes). Replaced with
  the correct definition so new users form the right mental model on
  first contact.
- onboarding (en + zh-Hans) step_platform headline+lede: said "Connect a
  runtime" but the next options are "install desktop / CLI / cloud
  waitlist" — those install a runtime source, not connect to one.
  Reworded.
- onboarding/zh-Hans: 4 places used "AI 编码工具"; docs use "AI 编程工具"
  consistently. Unified on the docs term.
- daemon-runtimes (en + zh): added cross-link to /desktop-app for users
  deciding between desktop daemon and CLI daemon.

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

* feat(onboarding): localize starter-content (Getting Started project)

The Getting Started project + welcome issue + 10 sub-issues that land in
the workspace at the end of onboarding were hardcoded English. Chinese
users finished a Chinese onboarding flow and arrived to an all-English
workspace; the welcome issue's prompt to the agent was also English, so
the agent's first reply tended to be English regardless of what
templates the user picked.

This commit adds Chinese parity, fixes the runtime definition error
that was the source of similar drift in onboarding.json, and removes a
few hardcoded UI specifics that would silently rot.

Architecture:

- Long-form markdown (~600 lines per language) lives in TS sibling
  files: starter-content-content-en.ts and starter-content-content-zh.ts.
  JSON locales were considered, but multi-paragraph markdown becomes
  unreadable single-line escape soup in JSON; keeping it in TS lets
  reviewers see the rendered shape and catch markdown regressions in
  code review.
- starter-content-templates.ts is now a thin orchestrator: imports both
  content files, exports buildImportPayload({ ..., locale }), picks the
  right one at runtime.
- StarterContentPrompt resolves locale from i18n.language (with a small
  startsWith("zh") helper so "zh-Hans-CN" or future variants still hit
  the ZH content).

Content fixes (apply to both EN and ZH):

- "A runtime is a small background process" was wrong (runtime = daemon
  × one AI coding tool, per docs). Replaced with the correct definition
  so the welcome agent doesn't seed an incorrect mental model.
- Removed hardcoded "tabs at the top: 6 tabs" / "(third row)" /
  "6 templates" lists — those rot the moment product UI changes. Replaced
  with descriptions that don't depend on exact counts/positions.

Conventions adherence (ZH):

- agent → 智能体, daemon → 守护进程, runtime → 运行时, workspace → 工作区
- task / issue / skill stay lowercase English (per conventions.zh.mdx)
- Product UI labels (Properties, Assignee, Status, Activity, Live card,
  Inbox, Members, Settings, Runtimes, Configure, Repositories,
  Instructions, Tasks, Skills, Autopilot, etc.) stay English so the
  doc text matches what the user sees on screen.

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

* docs(conventions): formalize mixed-rule for task / issue / skill in CN

The prior rule said issue/skill/task always render as lowercase English
in Chinese text. That worked for UI strings but never matched what the
sister docs actually do — tasks.zh.mdx is built around "执行任务",
issues.zh.mdx titles "Issue 与 project", skills.zh.mdx titles "Skills".
Three docs, three patterns, all sensible in their own context, none
matching the old rule. Conventions also explicitly cited the docs as
the voice standard, so the rule was internally inconsistent.

This commit promotes the de facto pattern to a written rule:

- UI strings, state names, code references → lowercase English
  ("排队中的 task", "创建子 issue", "为智能体注入 skill")
- Doc titles / section headings → Title-case English OR Chinese term
  ("Issue 与 project", "Skills", "执行任务")
- Doc prose where the entity is the running subject → Chinese term,
  with English in parentheses on first mention
  ("**执行任务**(task)是智能体每一次工作的单位")
- API / DB fields → always task / issue / skill (`task_id`, etc.)

Provides the term mapping (task ↔ 执行任务) explicitly so future
translation PRs don't have to rediscover it.

No code or other doc changes — tasks.zh.mdx already follows this
pattern; this commit just formalizes it. Other ZH locale strings
remain lowercase per the UI rule (which the locale audit + PR #2139
verified).

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

* docs: add Projects page (en + zh) and Autopilot failure visibility note

The audit found that 'projects' was the most prominently missing docs
page — it appears as a sidebar nav item in onboarding's workspace
preview, but users clicking through to docs found nothing on the topic.
The other locale-but-no-doc pages (my-issues, labels, settings) are
listed as follow-ups; this PR ships the highest-impact one.

Also adds a missing piece in tasks.{mdx,zh.mdx}: the Autopilot
no-auto-retry callout explained the *why* but never the *how do I
notice* — added a sentence pointing users at Inbox + the issue
status revert + the Autopilot page's run history.

projects.mdx covers:

- What a project is (container for related issues)
- Fields: name, icon, description, lead, status, priority, progress
- Project-issue many-to-one relationship + how progress is computed
- Pinning to sidebar (personal preference)
- Resources section (GitHub repos passed to daemon)
- Delete behavior (issues unlinked, not deleted)
- Lead can be a member or an agent

Both pages registered in meta.json / meta.zh.json under "Workspace &
team" group, between issues and comments.

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

* chore(pr-template): add drift-prevention checkboxes for runtime/CN copy

Two failure modes the docs+onboarding audit found, both caused by
adding-a-thing without remembering all the places that thing surfaces:

1. New runtime / coding tool / UI tab gets recorded in changelog but not
   in landing FAQ ("Multica supports 4 tools" while changelog shows the
   11th was added) or starter-content tutorial ("6 tabs at the top:
   Instructions / Skills / Tasks / Environment / Custom Args / Settings"
   stays frozen the moment a tab is added or renamed).

2. Chinese copy added without checking the canonical glossary —
   "Agent" survived in landing/zh.ts long after product UI standardized
   on "智能体" because nobody routed landing through the conventions
   review.

Adding two checklist items to the PR template so authors see the
specific paths to update at PR-creation time, before the drift ships.

This is the final batch (5 / 5) from the audit.

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-06 18:44:39 +08:00
Naiyuan Qing
c784a6a9ee feat(chat): copy assistant reply + collapse process into a single outer fold (#2151)
Restructures the assistant timeline into a Conductor-style "X steps"
outer fold that wraps every thinking/tool/intermediate-text item between
the first and last non-text item; the final answer renders below the
fold at full prose size. The inner per-row Collapsibles
(ThinkingRow / ToolCallRow / ToolResultRow) are unchanged.

Adds an inline footer "Replied in 38s · [Copy]" beneath each persisted
assistant reply. Copy puts the markdown source of the visible text
(preface + final, never middle) on the clipboard via the existing
`copyMarkdown` helper. Suppressed during streaming.

Pure carving + extraction lives in `chat/lib/copy-text.ts` with 11 unit
tests covering all timeline shapes (all-text, all-non-text, standard,
preface, multi-final, legacy fallback).

Also cleans up 7 pre-existing `text-[11px]` arbitrary values in this
file to `text-xs`, and uses standard `size="icon-xs"` Button variant
for the Copy button (no manual size overrides).

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-06 18:19:34 +08:00
Bohan Jiang
9306d60451 fix(agent-live-card): self-heal stale 'is working' banner via reconcile (#2142)
* fix(agent-live-card): self-heal stale "is working" banner via reconcile

The banner relied on receiving task:completed/failed/cancelled to clear
itself. When a WS reconnect dropped one of those events the banner stayed
forever and the elapsed timer kept ticking.

Replace the additive update paths (mount + queued/dispatch) with a single
reconcile() that refetches /active-task and replaces the local task set
with the server's truth, preserving accumulated TimelineItems for tasks
still active. Wire it to:

- mount / issueId change
- WS reconnect (useWSReconnect)
- task:queued / task:dispatch
- task:completed / task:failed / task:cancelled (after the optimistic
  delete, so a missed sibling end-event also clears)

Per-task hydration guard (hydratedTaskIds) keeps the messages backfill
one-shot when reconcile fires repeatedly within a tick.

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

* fix(agent-live-card): guard reconcile against out-of-order responses

reconcile() previously had no request-ordering protection, so a slow
getActiveTasksForIssue response could land after a newer one and clobber
the fresher state. Race scenario: task:queued fires reconcile A (response
includes T but is delayed); task:completed fires next, optimistically
removes T, and triggers reconcile B; B resolves empty and clears the
banner; A finally resolves with the stale snapshot and re-adds T —
permanent stale "is working" banner with no further events to clear it.

Add a monotonic reconcileSeq ref. Each call captures its issued seq;
the response only applies if mySeq === reconcileSeq.current (i.e. no
newer call was issued after this one). Drop the response otherwise.

Add a regression test covering the deferred-promise case plus a
companion test for the WS reconnect self-heal path.

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

---------

Co-authored-by: multica-agent <github@multica.ai>
2026-05-06 18:16:51 +08:00
Bohan Jiang
4a749f103b docs(views): explain min-h-[60vh] mobile fallback in agent overview pane (#2061)
The 60vh value is the magic number that keeps the tab content area
usably tall when the parent stacks inspector + overview on mobile and
delegates scroll to the page. Add a short note next to the className
so future maintainers know what the constraint is for and why `md:`
overrides it.
2026-05-06 18:06:31 +08:00
Bohan Jiang
38f777d0ba feat(autopilot): auto-pause autopilots with sustained high failure rate (#2136)
* feat(autopilot): auto-pause autopilots with sustained high failure rate

Adds a background monitor that pauses any active autopilot whose recent
runs are dominated by failures (defaults: ≥100 terminal runs in 7d, ≥90%
failed). The monitor leaves a severity=attention inbox notification for
the autopilot's creator (or the agent's owner if the autopilot was
agent-created) so a human learns about the auto-pause and can fix the
root cause before re-enabling.

Motivated by MUL-1336 §6 #2: a single broken cron autopilot
(`Registro de ls cada 5 min`, 1,475/1,476 failed in 7d) was burning
~1.5k tasks/tokens per week with no human in the loop.

Tunable via AUTOPILOT_FAIL_MONITOR_{INTERVAL,LOOKBACK,MIN_RUNS,FAIL_RATIO,STARTUP_DELAY};
INTERVAL=0 disables the monitor entirely.

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

* chore(autopilot): relax failure monitor defaults to daily / 50 runs

Per review feedback in MUL-1339: 30-min scan was overkill — the 50-run
threshold already provides multi-hour lag, and operational simplicity
matters. Lowering MinRuns from 100 → 50 keeps low-frequency autopilots
in scope (~7 runs/day reaches threshold within 7d window).

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

---------

Co-authored-by: multica-agent <github@multica.ai>
2026-05-06 17:59:15 +08:00
Bohan Jiang
2f979ac6f0 fix(daemon): tighten quick-create prompt to drop meta-instructions and apologetic Context (#2137)
* fix(daemon): tighten quick-create prompt to drop meta-instructions and apologetic Context

The quick-create prompt was producing descriptions that:
1. Echoed routing meta-instructions ("create an issue for me", "cc @X") into
   the User request body, even though those phrases are handled by separate
   CLI flags and are not spec content.
2. Emitted a Context section to apologize for resources it could not fetch
   (e.g. an image attachment not piped through to the run), instead of
   staying silent and letting the executing agent ask the user.
3. Preserved pure conversational fillers ("对吧?", "嗯", "那个…") because the
   model treated removing them as forbidden paraphrasing.

Updates the prompt to call out each of these as explicit non-spec material
to strip before writing the description, while keeping the "high fidelity /
no paraphrasing of substantive content" invariant. Adds a regression test
that locks in the new rules at the substring level.

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

* fix(daemon): preserve cc mention links in quick-create description

Stripping "cc @Y" wholesale would have lost the mentioned member's only
routing channel: `multica issue create` has no --subscriber/--cc flag, and
the platform auto-subscribes members by parsing `[@Name](mention://member/<uuid>)`
links from the description body. Without the mention link in the body, a
cc'd member would never get subscribed or notified.

Updates the prompt to:
- Strip only the verbal "cc" wrapper from the User request body.
- Append a trailing `CC: <mention links>` line to the description so the
  platform's auto-subscribe logic still picks the mentions up.
- Spell out the contrast for assignee mentions, where --assignee-id is
  the routing channel and the body should not double-encode the mention.

Also adds a substring assertion for the "Pure conversational fillers" rule
that was missing from the original regression test.

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

* refactor(daemon): trim quick-create prompt rules to general principles

Reviewers pointed out the previous rewrite traded one prompt smell (over-
permissive verbatim quoting) for another (too many specific rules and
exhaustive bilingual example tables). Rewrites the description block as
general principles with a single representative example each, trusting the
model to generalize:

- "Strip non-spec material before writing" replaces the multi-bullet list
  of routing-meta-instruction and conversational-filler enumerations.
- "Include Context only when references were fetched and produced facts;
  never use it as an apology log" replaces the three "Do NOT emit a
  Context section to" sub-bullets.
- The CC exception (the only operationally non-obvious rule, since
  `multica issue create` has no --subscriber flag) is kept inline as a
  single sentence and is still locked in by the regression test.

Net: ~16 fewer lines of prompt text without losing any of the rules the
test asserts.

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

---------

Co-authored-by: multica-agent <github@multica.ai>
2026-05-06 17:57:11 +08:00
Bohan Jiang
8d20a2f7bd docs(changelog): add v0.2.26 entry for 2026-05-06 release (#2138)
* docs(changelog): add v0.2.26 entry for 2026-05-06 release

Summarizes the 32 PRs landed on main since v0.2.25:
i18n (en + zh-Hans) full rollout, system notifications toggle,
chat session deletion, Redis-backed runtime liveness, long-issue
Timeline keyset pagination, and a batch of daemon/runtime
stability fixes. Mirrored across en.ts and zh.ts.

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

* docs(changelog): tighten v0.2.26 feature copy

Per review feedback — drop "so you can" / "across the entire app"
clauses, match the terse one-clause cadence used by the 0.2.24 entry.
Improvements/fixes copy is unchanged.

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

---------

Co-authored-by: multica-agent <github@multica.ai>
2026-05-06 17:43:49 +08:00
Jiayuan Zhang
e3dd31cbe5 feat(notifications): add system notifications toggle in settings (#2132)
* feat(notifications): add system notifications toggle in settings

Add a per-user, per-workspace toggle to enable/disable native OS
notification banners. Reuses the existing notification-preferences
endpoint by introducing a `system_notifications` key alongside the
inbox event groups; the realtime handler reads the cached preference
and skips desktopAPI.showNotification when muted.

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

* fix(notifications): fetch system_notifications pref lazily

Settings is the only mounted reader of notificationPreferenceOptions,
so a fresh app start (or any session that never visits Settings) left
the cache empty and the muted preference silently fell back to default
"all". Switch the inbox:new handler to ensureQueryData so the value is
fetched on first use and cached for subsequent events.

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

---------

Co-authored-by: multica-agent <github@multica.ai>
2026-05-06 11:01:22 +02:00
Naiyuan Qing
5cf1d01076 feat(settings): rename Appearance tab to Preferences and persist active tab in URL (#2131)
- Rename appearance-tab → preferences-tab; AppearanceTab → PreferencesTab
- i18n top-level key appearance → preferences; tab label "Appearance" → "Preferences" / "偏好设置"
- Swap icon Palette → SlidersHorizontal (preferences semantic)
- SettingsPage: read active tab from ?tab= via NavigationAdapter, write back with replace() on change; whitelist valid tabs (incl. desktop extras daemon/updates), unknown values fall back to profile
- Update conventions.mdx (en + zh) references to renamed file and i18n key

Why preferences over appearance: the tab held both theme and language; "Appearance" semantically excludes localization. "Preferences" follows Linear/Slack/Discord and leaves room to add timezone/date format later.

Why query param over path: settings tabs are UI modifier state, not resources; query persistence keeps the existing single Next.js route file and desktop memory router unchanged, gives a natural fallback for unknown values, and avoids 404 risk.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-06 16:53:32 +08:00
Jiayuan Zhang
6d59505575 fix(quick-create): remove duplicate keyboard shortcut on agent submit button (#2130)
The agent submit button rendered the shortcut hint twice — the i18n
string already contained '(⌘↵)' and the JSX appended another
formatShortcut() suffix. Drop the hardcoded shortcut from the
translations and rely on the platform-aware formatShortcut() in JSX.

Co-authored-by: multica-agent <github@multica.ai>
2026-05-06 10:43:47 +02:00
Naiyuan Qing
58db751089 ci(lint): enable lint in CI + fix existing lint debt (#2129)
CI was running build + typecheck + test, but never lint. The i18n
guardrail (eslint-plugin-i18next on packages/views/**/*.tsx) was
configured but not enforced, so PRs kept landing user-facing English
strings (chat session delete, project resources, mermaid fallback,
invitations batch page).

Changes:

- .github/workflows/ci.yml: add `lint` to the turbo command
- packages/eslint-config/react.js: split React rules (JSX-only) from
  react-hooks rules (apply to .ts too) — hooks live in .ts modules
  like use-agent-presence.ts, and inline-disable comments need the
  rule registered to resolve
- Translate the 10 lint errors that surfaced:
  - editor/readonly-content.tsx mermaid render-error + rendering
  - issues/issue-detail.tsx Archive tooltip
  - invitations/invitations-page.tsx full page (new invite.batch.*)
- invitations-page.test.tsx wrap with I18nProvider so getByRole queries
  match translated button labels
- core/auth/utils.ts intentional control-char regex: add eslint-disable

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-06 16:40:21 +08:00
Naiyuan Qing
ba147708a6 fix(timeline): cursor-paginated timeline to stop long-issue freeze (#1968) (#2128)
* fix(timeline): cursor-paginated timeline to stop long-issue freeze (#1968)

Opening an issue from Inbox with thousands of timeline entries used to
hard-freeze the browser tab on a synchronous render of every comment +
activity. The whole pipeline was unbounded: the API returned every row,
TanStack Query cached the full array, and IssueDetail mounted N
CommentCards (each running a full react-markdown + lowlight pipeline)
in one frame.

This swaps the timeline endpoint to keyset cursor pagination and rewires
the frontend to useInfiniteQuery so a long issue costs the same as a
short one on first paint.

API:
- GET /issues/:id/timeline now accepts ?before / ?after / ?around (mutex)
  + ?limit (default 50, max 100); response wraps entries with next/prev
  cursors and has_more flags. Cursors are opaque base64 (created_at, id).
- ?around=<entry_id> anchors a window on the target so Inbox notifications
  pointing at an old comment never trigger the freeze.
- New composite indexes on (issue_id, created_at DESC, id DESC) replace
  the redundant single-column ones so keyset queries are index-only scans.
- /issues/:id/comments default branch now caps at 50 instead of returning
  every row unbounded; the unbounded ListComments / ListActivities sqlc
  queries are deleted.

Frontend:
- useIssueTimeline switches to useInfiniteQuery, exposes
  fetchOlder/fetchNewer/jumpToLatest + isAtLatest + newEntriesBelowCount.
- WS handlers respect the at-latest invariant: comment/activity:created
  prepends to pages[0] only when the user is reading the live tail;
  otherwise it just bumps a counter so the UI offers a "Jump to latest"
  affordance without yanking scroll.
- Optimistic mutations adapted to the InfiniteData shape via shared
  helpers (mapAllEntries / filterAllEntries / prependToLatestPage in
  core/issues/timeline-cache.ts) and use setQueriesData so all open
  windows of the same issue stay in sync.
- IssueDetail Activity section gets a TimelineSkeleton placeholder
  during the brief load window plus subtle text-link load-more buttons
  matching the existing Subscribe affordance (no Button chrome). Top
  uses a divider for boundary clarity; bottom shows
  "Jump to latest · N new" weighted slightly heavier when there's
  unread state.
- highlightCommentId now flows into the hook's around parameter so
  Inbox jumps fetch the surrounding 50 entries directly.

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

* chore(agent): default comment list to 50 + prompt hint about long issues

The CLI's "multica issue comment list" used to default to --limit 0
(meaning "fetch every comment"), which lets an agent on a long issue
fill its context window with thousands of rows. The default is now 50;
agents that need older history can pass --limit or --since explicitly.

The local-coding-agent prompt also gains a single-line note about this
in both the comment-triggered and on-assign flows so the agent knows to
scope its fetches when issue size is unknown. Autopilot run-only mode
is intentionally unchanged — it has no issue context to query.

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-06 16:27:06 +08:00
Naiyuan Qing
3447764b03 feat(i18n): full rollout — 21 namespaces translated (en + zh-Hans) (#1853)
* feat(i18n): rollout phase — translate 9 namespaces (WIP)

Phase 1 complete (基建 + login + Settings language switcher),
phase 2 partial (Wave 4 done, search done). Pending namespaces
documented inline; another developer can pick up from here.

Infrastructure
--------------
- server: add users.language column + extend PATCH /api/me
  (TestUpdateMeAcceptsLanguage / TestUpdateMePreservesLanguage)
- packages/core/i18n: types / pickLocale (intl-localematcher) /
  browser-cookie-adapter / createI18n (initAsync false +
  useSuspense false) / I18nProvider / LocaleAdapterProvider
- Split server-safe vs React entries:
    @multica/core/i18n        — for proxy/RSC/middleware (no React)
    @multica/core/i18n/react  — for client trees (createContext)
  (RSC vendored React lacks createContext; mixed import would crash
  proxy.ts at module load.)
- packages/views/i18n: useT hook + selector API augmentation
  (i18next v26 default; auto-propagates to apps via the side-effect
  import in use-t.ts).
- apps/web: proxy.ts (Next 16 renamed middleware) merges existing
  legacy/root redirects with x-multica-locale header forwarding;
  layout.tsx reads locale via headers() and pre-loads RSC resources.
- apps/desktop: webPreferences.additionalArguments injects
  systemLocale (no sendSync — avoids main-thread blocking IPC);
  renderer adapter reads via process.argv.
- ESLint: i18next/no-literal-string at file-scope for translated
  files via packages/views/eslint.config.mjs TRANSLATED_FILES.
- glossary.md (packages/views/locales/) freezes term policy:
  Issue / Workspace / Agent / Skill / Autopilot / Daemon / Runtime
  stay English; Inbox / Project / Comment / Member translate.

Translated namespaces (9 / 19)
------------------------------
- auth: login page (web wrapper含 desktop-handoff 文案) + Settings
  Appearance language switcher
- editor: 9 .tsx (bubble-menu / link-hover-card / readonly-content /
  title-editor / extensions: code-block / file-card / image-view /
  mention-suggestion) + 32 keys
- invite: 25 keys
- labels / members / my-issues: Wave 4 全部
- search: command palette 35 keys
- navigation: no user-facing strings (no-op)

Pending (10 / 19)
-----------------
issues (46 files / ~210 keys)
agents (29 files / ~155 keys; presence.ts + config.ts label maps
  允许进 i18n)
onboarding (22 files / ~150 keys)
settings rest / skills / modals / workspace / chat / inbox /
projects / autopilots / layout

Workflow for picking up
-----------------------
- Glossary: packages/views/locales/glossary.md (mandatory read)
- Reference impls: auth/login-page.tsx + editor/* (selector API +
  i18n-provider test wrapper pattern)
- Per namespace:
    1. create locales/{en,zh-Hans}/{ns}.json
    2. add to packages/views/i18n/resources-types.ts
    3. useT('{ns}') + t($ => $.foo) in components
    4. add files to TRANSLATED_FILES in eslint.config.mjs
    5. typecheck + test + lint must pass
- Subagents currently CANNOT write files (sandbox deny). Run as
  hybrid: subagent researches + outputs full JSON + tsx diff,
  controller writes.

Other
-----
- scripts/init-worktree-env.sh: default
  MULTICA_DEV_VERIFICATION_CODE=888888 in dev for deterministic
  login (gated by isProductionEnv).

Verified: pnpm typecheck (6 pkgs ok), pnpm test (232 pass),
make test (Go).

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

* docs(i18n): rewrite glossary aligned with docs zh voice

Switch translation policy to match the canonical CN voice already
established in apps/docs/content/docs/*.zh.mdx (20+ files). The new
rule splits product nouns into two classes:

- Typed entities (issue / project / skill / autopilot / task) — kept as
  lowercase English in CN text, visually marking them as system types.
- Concepts (workspace / agent / daemon / runtime / inbox) — fully
  translated (工作区 / 智能体 / 守护进程 / 运行时 / 收件箱).

Previous glossary kept Workspace / Agent / Daemon / Runtime as English
on "工程惯例" grounds, but docs zh and CN AI ecosystem (Coze / 腾讯元器
/ 百度) consistently translate these. App UI now matches docs voice so
users don't see split personality between the app and its own docs.

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

* fix(i18n): register 6 namespaces and retrofit zh strings to new glossary

Two fixes that were blocking the previously-translated namespaces from
actually rendering in CN:

1. RESOURCES gap — locales/index.ts only loaded common/auth/settings,
   but resources-types.ts declared 12 namespaces and 6 of them had real
   translation content. At runtime i18next would fall back to raw keys
   for editor / invite / labels / members / my-issues / search.
   Register all 9 currently-translated namespaces.

2. Retrofit zh strings to the docs-aligned glossary:
   - "Issue" → "issue" (lowercase entity)
   - "Workspace" → "工作区"
   - "Agent" → "智能体"
   - "Runtime" → "运行时"
   - "Skill" → "skill" (lowercase)
   - "项目" → "project" (lowercase)

Touched: editor.json (sub_issue + mention.group_issues), invite.json
(3 Workspace occurrences), members.json (agents_section / more_agents),
my-issues.json (8 retrofits across page/header/errors), search.json
(13 retrofits across groups/pages/commands/empty).

Verified: pnpm typecheck (6/6) + pnpm test (238/238) all green.

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

* feat(i18n): translate inbox namespace

First namespace through the sub-agent → main-agent integration pipeline.

JSON: en/inbox.json + zh-Hans/inbox.json — 60 keys across page / menu /
list / detail / types / labels / errors. Time-formatter labels are kept
compact in EN ("5m" / "3h" / "2d") and use full units in zh ("5 分钟" /
"5 小时" / "5 天") since raw "5 分" reads as "5 marks/points" in CN.

Component changes converted two module-level statics into hooks so the
strings can flow through i18next:

- inbox-list-item.tsx: `timeAgo` (pure fn) → `useTimeAgo` (hook
  returning a fn). The local copy is a duplicate of @multica/core/utils
  `timeAgo` that is only used by inbox-page; other consumers across
  chat/agents/skills/issues stay on the core util for now and will be
  translated when their namespaces land.

- inbox-detail-label.tsx: `typeLabels` (static const Record) →
  `useTypeLabels` (hook returning the same Record shape). Call sites
  keep the existing `typeLabels[type]` access pattern.

inbox-page.tsx now uses both hooks and `useT('inbox')` selector calls
for all hardcoded strings (~24 sites: header / dropdown menu / list
empty state / detail panel / mobile back / quick-create-failed flow /
all error toasts).

Wired up: resources-types.ts, locales/index.ts RESOURCES, ESLint
TRANSLATED_FILES (3 inbox tsx files now lint-protected).

Verified: pnpm typecheck (6/6) + pnpm --filter @multica/views test
(238/238) + ESLint clean on inbox/.

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

* feat(i18n): translate workspace namespace

Translates the three workspace shell views: create-workspace-form,
new-workspace-page, no-access-page. Also fixes the prior-art
no-unescaped-entities lint errors in no-access-page.tsx — the
apostrophes in "doesn't" / "don't" were JSX text literals that move
into JSON values after translation, so the lint rule no longer fires.

Tests wrapped: workspace/create-workspace-form.test.tsx,
workspace/no-access-page.test.tsx, modals/create-workspace.test.tsx
all now wrap render() with <I18nProvider locale="en"> so the en values
in workspace.json drive the rendered text and the existing assertions
continue to match.

Slug constants kept: WORKSPACE_SLUG_FORMAT_ERROR /
WORKSPACE_SLUG_CONFLICT_ERROR exports in workspace/slug.ts are still
imported by onboarding/steps/step-workspace.tsx (out of scope here).
The workspace shell now reads its strings from workspace.json directly.

Multica.ai brand prefix in the slug input affordance is wrapped with
an inline `// eslint-disable-next-line i18next/no-literal-string` per
glossary policy on brand names.

Renamed sign_in_other → sign_in_different to avoid colliding with
i18next's `_other` plural-suffix convention which the selector-API
typings treated as a plural form of `sign_in`.

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

* feat(i18n): translate projects namespace

Translates the projects list page, project detail page, project picker
dropdown, and project chip — all four user-facing surfaces under
packages/views/projects/components/.

New file: projects/components/labels.ts exposes three hooks that
replace the static `.label` field on PROJECT_STATUS_CONFIG /
PROJECT_PRIORITY_CONFIG and the previous module-level
`formatRelativeDate` helper. Core's `.label` stays untouched (it's
still consumed by search and the create-project modal, both
out-of-scope for this namespace) — those will flip when their
respective namespaces translate.

In zh, the "project" entity stays lowercase English per glossary
(`新建 project`, `还没有 project`, `从 project 移除`). Status / priority /
table column labels translate fully.

The cancelled / done / paused etc. status labels duplicate per-
namespace as `projects.status.*` rather than reading from a future
shared status namespace. This matches the auth/inbox/workspace
pattern of self-contained namespaces. If a generic "issue/project
status" pool emerges later, these can collapse.

Verified: pnpm --filter @multica/views typecheck (clean) +
test (238/238) + ESLint clean on projects/ (1 pre-existing warning
about useEffect/sidebarRef dep, unrelated to i18n).

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

* feat(i18n): translate autopilots namespace

Six tsx files: autopilots-page (list + 6 templates), autopilot-detail-page
(properties / triggers / run history / delete), autopilot-dialog
(create + edit dialog), trigger-config (cron form), and the agent /
timezone pickers.

Hook conversions for module-level helpers that need t():
- summarizeTrigger / describeTrigger → useSummarizeTrigger /
  useDescribeTrigger (no external callers, removed the plain exports)
- formatRelativeDate → useFormatRelativeDate (per-component hook)
- formatCountdown → useFormatCountdown (per-component hook)
- TEMPLATES array now keyed by id; titles + summaries pull from
  templates/{id}/{title,summary} JSON. Prompts stay raw EN since
  they're injected directly into the agent task — translating them
  would translate the agent's instructions, not the user's UI.

Status / execution-mode / run-status enums render via t($ => $.status[k])
with k typed against the core type (no separate hook needed).

Verified: pnpm --filter @multica/views typecheck (clean) +
test (238/238) + ESLint clean on autopilots/.

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

* feat(i18n): translate skills namespace

Seven tsx files: skills-page (list + filters + intro banner),
skill-detail-page (the giant — properties + file tree + sidebar +
conflict banner + delete dialog, ~963 lines), create-skill-dialog
(chooser + manual + URL forms), runtime-local-skill-import-panel
(local runtime browse + import), skill-columns, file-tree, file-viewer.

Notable patterns:
- `createSkillColumns` factory → `useSkillColumns` hook so column
  headers flow through useT. Column identity changes per render is
  fine — DataTable handles it.
- `validateNewFilePath` (pure helper) → `useValidateNewFilePath` hook
  so the 5 validation error messages can be translated.
- skill_files / used_by / description_with_agents use i18next plural
  keys (`_one` / `_other`) — the type system collapses these into a
  single PluralValue access, so call sites use
  `t($ => $.foo, { count })` and i18next picks the form.
- Per glossary, "skill" stays lowercase EN in zh ("新建 skill",
  "已删除 skill", "未找到该 skill").

Test wrapper: runtime-local-skill-import-panel.test.tsx now wraps
render() with <I18nProvider> so the assertion on /Import to Workspace/i
matches the EN translation.

Verified: pnpm --filter @multica/views typecheck (clean) +
test (238/238) + ESLint clean on skills/.

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

* feat(i18n): translate chat namespace

Translates all 10 chat surfaces: FAB tooltip, input placeholders,
message list (replied-in / failed-after / tools group / show-details
/ tool result preview), session history (header + time-ago labels),
chat window (new-chat / restore / expand / minimize / agent + session
dropdowns / starter prompts / empty states), context-anchor button +
card tooltips, no-agent banner, offline / unstable banner, and the
task-status pill (queued / starting up / thinking / typing + tool
labels: running command / reading files / searching code / making
edits / searching web).

Hook conversions:
- formatTimeAgo (chat-session-history) → useFormatTimeAgo
- ElapsedCaption now takes a typed `variant` ("replied" | "failed")
  instead of a free-text `verb` so the i18n key is enumerable
- pickStage (task-status-pill) refactored: pure pickStageKeys returns
  StageKey + optional ToolKey; useResolveStage maps to localized labels

Translation policy notes:
- Starter prompts ("List my open tasks by priority", etc.) are user
  UI when displayed AND the user's input when clicked — translating
  them sends the agent the user's locale-native phrasing, which is
  the right UX for a CN user using a CN agent.
- buildAnchorMarkdown (chat-window) stays in English: it's an
  agent-bound markdown prefix injected into the outgoing message,
  not user-facing UI.

Verified: pnpm --filter @multica/views typecheck (clean) +
test (238/238).

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

* feat(i18n): translate modals namespace

Translates all 11 modal sources: registry (no UI text), backlog-agent-hint,
set-parent-issue, add-child-issue, delete-issue-confirm, feedback,
issue-picker, create-workspace, create-project, create-issue (manual),
quick-create-issue (agent panel).

Notable patterns:
- create-project re-uses useProjectStatusLabels / useProjectPriorityLabels
  hooks from views/projects/components/labels — same translation source
  as the projects list / detail, no duplication.
- create-issue.tsx: renamed `toast.custom((t) => ...)` callback param to
  `toastId` to avoid shadowing the closure-captured useT() `t` function.
- Test wrapper added to modals/create-issue.test.tsx so the two assertions
  on rendered modal text (success toast + Create another) match the EN
  bundle. modals/create-workspace.test.tsx was already wrapped (workspace
  ns commit).

Verified: pnpm --filter @multica/views typecheck (clean) +
test (238/238).

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

* feat(i18n): translate settings namespace (rest of tabs)

Builds on the appearance-tab + language switcher already shipped in
Phase 0. Translates the remaining 8 settings surfaces: settings-page
shell (left nav + tab keys), account / profile, notifications-tab
(5 group labels + descriptions), tokens-tab (create / list /
revoke / created dialog), workspace-tab (general fields + danger
zone + leave/delete confirmations), members-tab (invite + role
config + revoke / remove flows), repositories-tab, labs-tab,
delete-workspace-dialog.

Hook conversion: members-tab `roleConfig` static const → `useRoleLabels`
hook returning a Record<MemberRole, {label, description, icon}>. The
icon stays as a typed React component (Crown / Shield / User), so
rendering pattern is unchanged at call sites.

Test wrapper: settings/components/delete-workspace-dialog.test.tsx
now wraps render() with <I18nProvider> (custom render() helper)
because the test asserts on rendered button labels ("Delete workspace",
"Cancel", "Deleting...").

Verified: pnpm --filter @multica/views typecheck (clean) +
test (238/238).

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

* feat(i18n): translate runtimes namespace (entry surfaces)

Translates the user-facing runtime list page surfaces:
runtimes-page (header / search / filters / chips / empty / no-matches /
bootstrapping), runtime-detail (topbar + delete dialog + delete toasts),
runtime-detail-page (not-found state), shared.tsx (4-state HealthBadge
labels).

Hook conversion: shared `healthLabel(health)` was a pure module-level
function. Added `useHealthLabel` hook for translated call sites; kept
`healthLabel` as an EN-only fallback for non-component callers (column
factory in runtime-columns).

Deferred:
- runtime-list / runtime-columns (data table column headers + cell
  bodies) — large surface, not in the page-load critical path.
- connect-remote-dialog / update-section / usage-section — secondary
  flows, English remains acceptable until a focused pass.
- charts/* — primarily numeric tooltips and axes; minimal user-visible
  text.

Verified: pnpm --filter @multica/views typecheck (clean) +
test (238/238).

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

* feat(i18n): translate layout namespace (sidebar nav, help, loader)

Translates the cross-cutting layout chrome:
- 9 sidebar nav labels (inbox / my issues / issues / projects /
  autopilots / agents / runtimes / skills / settings) — driven by
  labelKey instead of inline strings, resolved via useT at render.
- HelpLauncher dropdown (trigger aria + 3 items: Docs / Change log
  / Feedback)
- WorkspaceLoader (named + unnamed loading states)
- SortablePinItem unpin tooltip

Pattern shift in app-sidebar.tsx: nav arrays carry `labelKey: NavLabelKey`
(typed against the layout JSON) instead of `label: string`. The string
comparison checks (`item.label === "Inbox"`) became cleaner ID-based
checks (`item.key === "inbox"`).

Deferred: deeper sidebar surfaces — workspace switcher dropdown,
"New Issue" CTA, "Pinned" / "Workspace" / "Configure" group labels —
remain English. The 9 nav labels are the ones that read in every
session.

Verified: pnpm --filter @multica/views typecheck (clean) +
test (238/238).

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

* feat(i18n): translate onboarding namespace (welcome + step header)

Translates the user-first-impression surfaces of the onboarding flow:

- step-welcome.tsx (the wordmark, headline, lede paragraphs, all CTAs:
  Download Desktop / Continue on web / Start exploring / I've done
  this before, illustration caption)
- step-header.tsx ("Step N of M" counter + matching aria-label)
- onboarding-flow.tsx (skip-onboarding error toast)

Test wrapper added to onboarding/components/step-header.test.tsx —
custom render() helper wraps with <I18nProvider> so the "Step 2 of 5"
assertions match the EN bundle.

Deferred (acceptable English fallback for now): step-questionnaire,
step-workspace, step-runtime-connect, step-platform-fork, step-agent,
step-first-issue, cli-install-instructions, option-card, runtime
aside panels, starter-content-prompt, cloud-waitlist-expand. These
are deeper steps with significant copy that would benefit from a
focused dedicated pass — voice on each is more nuanced (questionnaire
options, runtime install instructions, agent template recommendations).

Verified: pnpm --filter @multica/views typecheck (clean) +
test (238/238).

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

* test(i18n): add EN/zh-Hans key parity guard

Schema-level vitest that walks RESOURCES.en and RESOURCES["zh-Hans"]
namespace by namespace and asserts both bundles cover the same key
set. i18next plural rule is normalized before compare (`_one` /
`_other` collapse to a single logical key) so EN's plural pair
matches zh's `_other`-only form.

Catches retrofit drift where a new EN key lands without zh —
previously this would silently fall back to the English string in
production. Cheap to keep green: 39 tests across 21 namespaces in
under a second.

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

* feat(i18n): translate issues namespace

Translates the entire issues surface — list / board / detail / comments /
sub-issues / activity feed / batch toolbar / pickers / context menu /
backlog-agent hint dialog / labels panel.

Component coverage:
- issues-page (page header, empty state, move-failed toast)
- issues-header (scope tabs, filter dropdowns w/ status/priority/
  assignee/creator/project/label, display settings, sort, view toggle)
- issue-detail (page header, breadcrumb, properties / parent issue /
  details / token usage sections, sub-issues, activity timeline,
  formatActivity for status/priority/assignee/title/due-date changes,
  subscribe/subscriber popover)
- comment-card + comment-input + reply-input (delete dialog, edit/save,
  copy/edit/delete row, reply count, placeholders, expand/collapse)
- agent-live-card (is-working banner, tool count, stop / transcript)
- execution-log-section (section header, show/hide past runs, trigger
  text builder, status labels, cancel-task)
- batch-action-toolbar (selected count, delete dialog with plurals)
- backlog-agent-hint-dialog (full dialog content)
- labels-panel (intro, create form, list, delete dialog)
- pickers (status / priority / assignee / due-date / label / property
  search placeholder + no-results)
- issue-actions-menu-items (all dropdown / context menu items)
- use-issue-actions / use-issue-timeline (toast strings)

STATUS_CONFIG / PRIORITY_CONFIG label rendering routed through
$.status[enum] / $.priority[enum] at every call site; the core config
keeps its English fallback for non-i18n consumers but UI never reads
.label directly anymore.

Tests retrofitted: issues-page, issue-detail, and issue-actions-menu
RTL specs now wrap renders in <I18nProvider> with the EN bundle, so
their string assertions match the bundle (not hardcoded literals).

ESLint i18next allow-list extended to 24 issues files. Verified:
pnpm --filter @multica/views typecheck + test (277/277) all green.

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

* feat(i18n): translate agents namespace

Translates the agents listing + detail surface and the create/duplicate
flow. Covers the high-frequency surfaces; deeper sub-tab editors
(activity / instructions / skills / env / custom-args bodies, and the
hooks-buggy runtime/model/concurrency pickers) are deferred — they
have their own pre-existing react-hooks rule violations and benefit
from a focused dedicated pass.

Component coverage:
- agents-page (page header w/ tagline + new button, scope segment,
  search, sort dropdown, availability chips, archived toolbar, empty
  state, no-matches messaging w/ search interpolation, list-load
  error)
- agent-detail-page (back link, archived banner, archive dialog,
  not-found state, all 4 toast strings)
- agent-detail-inspector (avatar editor, name + description popover,
  description dialog, every PropRow label, validation message,
  presence badge label sourced from $.availability[enum])
- agent-overview-pane (tab labels, discard-unsaved-changes dialog)
- create-agent-dialog (title / description / labels / placeholders /
  duplicate-suffix / runtime filter buttons / runtime status copy)
- agent-row-actions (full dropdown items + cancel-tasks dialog with
  pluralized "N running + M queued" summary + archive dialog + 6 toasts)
- agent-columns (every header cell, You / Archived chips, runtime
  fallback labels, availability + workload labels via $.availability /
  $.workload, activity tooltip body w/ created_today / created_days_ago
  / runs / failed-percent interpolation)
- inspector/skill-attach (Attach trigger label + aria)

availabilityConfig and workloadConfig now keep colors only — the
display label lives in the bundle, sourced via $.availability[enum]
and $.workload[enum] at every call site. Same pattern as
STATUS_CONFIG/PRIORITY_CONFIG in the issues namespace.

ESLint i18next allow-list extended to 8 agents files.
Verified: pnpm --filter @multica/views typecheck + test (277/277)
all green.

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

* fix(i18n): clear 30 stray EN strings in translated files

Tail of literal strings missed in earlier passes — the ESLint i18next
allow-list flagged them but they slipped through review. Files touched:

- layout/app-sidebar.tsx (10 keys: Workspaces / Pending invitations /
  Create workspace / Join / Decline / Log out / New Issue + shortcut /
  Pinned / Workspace / Configure)
- runtimes/components/runtime-detail.tsx (Serving header + serving_count
  pluralization, no_agents copy, running/queued chips with count
  interpolation, Diagnostics header, CLI label, Delete runtime button,
  Technical details toggle, last seen interpolation)
- onboarding/steps/step-welcome.tsx (entire WelcomeIllustration mock —
  5 cards × actor names + body copy + 3 mention chips + 2 timestamps;
  zh translation reads naturally instead of leaving the demo English)
- settings/components/labs-tab.tsx (`Co-authored-by: ...` git trailer
  wrapped in {} so linter sees a JS string, not JSX text — magic
  identifier git relies on, must not translate)
- settings/components/members-tab.tsx (✓ glyph wrapped in {})
- modals/feedback.tsx (⌘↵ shortcut wrapped in {})

ServingAgentsCard now reads availability/workload labels from
`agents` namespace (cross-namespace useT) so the bundle-truth pattern
holds: presenceConfig keeps colours only, label text comes from the
shared bundle.

Verified: typecheck + 277/277 tests + lint (only the pre-existing
react-hooks rule-of-hooks errors remain, which task #6 addresses).

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

* fix(agents): rules-of-hooks + translate 4 model/runtime pickers

Three pre-existing react-hooks/rules-of-hooks violations + one missing
useMemo dep cleared, then the four pickers wired through useT.

Hook order fixes:
- concurrency-picker: useEffect now runs before the !canEdit early
  return. Stale-draft reset still works the same way.
- runtime-picker: useMemo for the filtered list moved above the
  !canEdit branch.
- model-dropdown: `models = data?.models ?? []` was minting a fresh
  array each render and tripping the deps lint of the downstream
  useMemo. Wrap in useMemo so the reference is stable.

Translation coverage:
- concurrency-picker: tooltip ("Concurrency · N max..."), range
  helper text, Save button.
- runtime-picker: trigger label fallback ("No runtime"), tooltip
  text composed from {{name}} + status, Mine/All filter buttons,
  empty-list copy, "owned by {{name}}" + status fragments in row
  tooltip, Cloud badge, online/offline aria.
- model-picker: trigger label, tooltip, "Managed by runtime"
  fallback, search placeholder, "Discovering models…", default
  badge, "No models available", "Use \"X\"" custom-id flow, Clear
  button + its title.
- model-dropdown: every label string including the "Select a runtime
  first" / "Default (provider)" / "Runtime offline — enter manually"
  trigger fallbacks, the supported=false explanation block, discovery
  failed badge, all popover items.

ESLint allow-list extended to 4 picker files. Verified: typecheck +
277/277 tests + lint (0 errors, only pre-existing react-hooks warnings
in unrelated files).

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

* feat(i18n): translate runtimes list + connect dialog + CLI updater

Three deep runtime surfaces wired through useT, with the agents
namespace doing double duty for shared availability/workload labels.

runtime-columns:
- 7 column headers via t-augmented createRuntimeColumns({ t }).
- HealthCell now reads from useHealthLabel() (already translation-aware)
  instead of the EN-only healthLabel() helper.
- WorkloadCell sources the label from $.workload[enum] (cross-namespace
  to agents) — colour stays via workloadConfig.
- CostCell delta "flat" copy + CLI cell "Desktop" badge + update-
  available aria/tooltip + RowMenu's full delete dialog (title /
  description with {{name}} interpolation / cancel / confirm /
  deleting state) plus its admin-permission hint.

connect-remote-dialog:
- Three steps fully translated: instructions (header + 4 numbered
  steps + security warning + troubleshooting list with mono code
  snippets escaped as JS strings), waiting (loader + hint), success
  (CTA pair).
- Mono CLI commands wrapped in {} so linter sees JS strings — those
  are literal commands that must stay untranslated for the user to
  paste into a terminal.

update-section:
- statusConfig collapsed to icon+colour only; labels move to
  $.update.status[enum] for proper translation per-state.
- "CLI Version:" / "Latest" / "available" / "Update" / "Retry"
  copy + the "Managed by Desktop" tooltip and disabled hint.

Layout helpers tagged: runtime-list passes `t` through to the column
factory the same way agent-columns does.

ESLint allow-list extended with the 4 wired files. Verified:
typecheck + 277/277 tests + 0 i18n lint errors. usage-section.tsx
(KPI cards / WhenChart / TopUsageBreakdown / receipt table) is the
remaining runtimes surface — chart-heavy and benefits from a focused
pass next.

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

* feat(i18n): translate 5 agent detail tabs + skill-add dialog

The 5 tabs that fill the agent detail right pane plus the shared
skill picker dialog. Agents bundle gains a `tab_body` block with
sub-namespaces per tab + a `common` slot for save/add/unsaved.

Tab coverage:
- instructions-tab: intro paragraph, multi-line example placeholder
  (full 18-line zh translation), Save / Unsaved.
- env-tab: read-only intro / empty state, editable intro with two
  inline `<code>` env-var examples kept English (mono terminal
  payloads), KEY / value placeholders, Show/Hide value aria, Add /
  Remove aria, all 3 toasts (duplicate keys / saved / save failed).
- custom-args-tab: intro about whitespace splitting, launch-mode
  prefix line + `<your args>` placeholder, --flag value placeholder,
  Add, Remove aria, both toasts.
- skills-tab: intro, Add skill button, import-hint callout, empty
  state title + hint + add-CTA, remove-failed toast.
- activity-tab: 3 section titles (Now / Last 30 days / Recent work),
  active-task pluralization, performance subtitle, all 3 empty
  states, runs/success%/avg-duration/failed pluralization with
  interpolation, source labels (Issue / Chat / Autopilot / Untracked),
  source fallbacks (Quick create / Creating issue / Chat session /
  Autopilot run), issue-short fallback, "Triggered by" tooltip
  header, open-issue / transcript / cancel-task tooltips and ARIAs,
  cancelling state, started/dispatched/queued time prefixes, show
  more.
- skill-add-dialog: dialog title + description, empty list copy,
  Cancel button, add-failed toast.

skills-tab.test.tsx wrapped in <I18nProvider> with the EN bundle so
its `Local runtime skills are always available` assertion still
matches the resolved translation instead of the raw key path.

ESLint allow-list extended with the 6 wired files. Verified:
typecheck + 277/277 tests + 0 i18n lint errors. Only the per-test
mock for skills-tab needed wrapping; the other 4 tabs ship without
test files of their own and inherit the I18nProvider chain via
agent-overview-pane / agent-detail-page test renders (when those
exist later).

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

* feat(i18n): translate onboarding step-questionnaire + option-card

The user-profile step (3 questions) is the first deferred onboarding
deep step now wired through useT.

step-questionnaire:
- Eyebrow + headline + answered-progress counter with {{count}}
  interpolation
- All 3 questions and their option labels (team size / role / use case)
- All 3 "Other" placeholders for free-text fallback
- Right-rail "Why three questions" / "What you get" panel: 2 eyebrow
  rows, 2 unlock-item title+body pairs, learn-more link
- Back / Continue buttons via shared `common` block

option-card: shared "Other" radio label and aria.

Test wrapped in <I18nProvider>. EN value of `other_label` kept as
"Other" so the existing /^other$/i regex in step-questionnaire.test
keeps matching after the rendering pipeline switched from a hardcoded
literal to a bundle lookup.

ESLint allow-list extended with these 2 files. The remaining 4 deep
steps (workspace / runtime-connect / platform-fork / agent), the
2 ancillary surfaces (cli-install-instructions / starter-content-
prompt), and the 3 side panels (runtime-aside-panel / cloud-waitlist-
expand / compact-runtime-row) will be surfaced + swept by the global
ESLint switch (next commit).

Verified: typecheck + 277/277 tests + 0 i18n lint errors.

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

* feat(i18n): flip ESLint to glob + drain remaining hardcoded EN

ESLint i18next/no-literal-string now applies to **/*.tsx by default
instead of an explicit allow-list. Files that genuinely still need
hardcoded EN are listed in STILL_HARDCODED — concrete, finite, and
the goal is to drain that list to zero.

Tail strings translated in this commit (surfaced by the global flip):

- common/task-transcript/agent-transcript-dialog.tsx — full dialog:
  status badge (Running / Completed / Failed), sr-only DialogTitle,
  Filter dropdown trigger + Clear filters, Copy all / Copy filtered /
  Copied, tool-calls + events metadata chips with pluralization,
  events-filtered "{{shown}} of {{total}}" interpolation, "Waiting
  for events..." live state, "No execution data recorded." past
  state. New `transcript` block in agents namespace.
- runtimes/components/charts/activity-heatmap.tsx — Less / More
  legend labels around the contribution-style heat squares.
- search/search-trigger.tsx — sidebar Search... button label.
  ⌘ glyph wrapped in {} to satisfy the linter (mono shortcut symbol,
  not translatable).

Holdouts (STILL_HARDCODED, ~14 files): the deep onboarding steps
(workspace / runtime-connect / platform-fork / agent / first-issue /
cli-install-instructions, plus 4 ancillary panels), the runtimes
usage-section + KPI cards, and 5 minor agent visual primitives
(sparkline / agent-presence-indicator / agent-profile-card /
visibility-badge / char-counter). Each one gets a dedicated future
pass; the global rule prevents new hardcoded strings from landing
elsewhere.

Verified: typecheck + 277/277 tests + 0 i18n lint errors.

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

* feat(i18n): drain agent visual primitives + onboarding small components

8 files removed from STILL_HARDCODED:

agents/components/:
- char-counter — over-limit text with {{count}} interpolation
- visibility-badge — uses new agents.visibility.{private,workspace}.
  {label,tooltip} block; drops VISIBILITY_LABEL/TOOLTIP imports from
  core in favour of bundle-driven copy
- agent-presence-indicator — availability + workload labels via
  $.availability[enum] / $.workload[enum] (cross-namespace),
  queue-badge "+N queued" with pluralization
- agent-profile-card — Agent unavailable / Detail link / Owner /
  Skills / Runtime / Unknown runtime / Archived chip / availability
  line via cross-namespace lookup

agents.json: new presence + visibility + profile_card + char_counter
blocks.

onboarding/components/:
- compact-runtime-row — online/offline aria via agents.availability
- runtime-aside-panel — full content (What's a runtime / Good to
  know / Swap anytime / Add more later / docs link)
- starter-content-prompt — full dialog (title / description with
  inline emphasis / both buttons / 3 toasts)
- cloud-waitlist-expand — intro paragraph + warning span / email
  + reason labels + placeholders + Optional badge / Join + on-list
  states / both toasts

onboarding/steps/:
- cli-install-instructions — copy aria + intro + 2 step labels

onboarding.json: new runtime_aside / cli_install / starter_content /
cloud_waitlist blocks.

Tests for step-platform-fork + step-runtime-connect wrapped in
<I18nProvider> with EN bundle so /you're on the list/i etc. still
matches the resolved translations.

Verified: typecheck + 277/277 tests + 0 i18n lint errors.

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

* feat(i18n): translate onboarding deep steps

The 5 large onboarding steps that were deferred from earlier passes,
plus their support helpers, all wired through useT.

step-first-issue (final beat — flips onboarded_at):
- error_title / Retry / retry_failed toast / finishing / opening
  states.

step-agent (creates the user's first agent):
- Templates moved from a module-level const to a useT-driven
  useAgentTemplates() hook. Names + emoji stay constant (visual
  identity), labels + blurbs + instructions resolve from the
  bundle. coding / planning / writing / assistant — all four
  templates ship a full zh translation that reads naturally.
- Recommended badge, eyebrow + headline + lede, footer hint,
  Create {{name}} CTA, create_failed toast.
- Right-rail "About agents" panel (4 way-items + headline +
  add-more hint + docs link).

step-workspace (create or pick existing):
- 5 footer states (open / creating / creating-pending / name-first
  / pick), all hint + CTA strings via interpolation.
- Name + URL + slug placeholders, issue-prefix preview spans,
  Create-new card title + subtitle.
- 8-row WorkspacePreviewCard sidebar (Inbox / Issues / Agents /
  Projects / Autopilot / Runtimes / Skills / And more) — every
  label + meta strapped to bundle keys.
- 4 perks (assign / chat / invite / switch) + 3 next-steps
  (runtime / agent / starter), 2 toasts (slug-conflict / failed).
- `multica.ai/${slug}` mono URL escaped via template-literal
  expression so the linter sees a JS string.

step-runtime-connect (desktop scan flow):
- 3 phase headlines + ledes (scanning / found / empty), trust-strip
  status (all online / N online / none online) with pluralization,
  online/offline labels, Skip / Continue / Selected hint.
- Empty-view 2 cards (skip + waitlist) and the cloud waitlist
  dialog wrapper.

step-platform-fork (web fan-out):
- Eyebrow + headline + lede, footer hint with 3 phase variants.
- Primary download card (before/after click) + 2 alt cards (CLI /
  cloud) + CLI dialog with 4 elapsed-time stages (normal / midway /
  slow / stalled), live-listening header, runtime-connected
  pluralization, cloud waitlist dialog.

ESLint: STILL_HARDCODED list shrunk from 14 entries to 1 — only
runtimes/components/usage-section.tsx (chart-heavy KPI panel)
remains.

Verified: typecheck + 277/277 tests + 0 i18n lint errors.

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

* feat(i18n): translate runtimes usage panel + drop STILL_HARDCODED

Final i18n holdout: the runtimes usage panel (KPI hero, WHEN chart
tabs, cost-by breakdowns, daily breakdown table) is wired through
useT("runtimes"). With this drained, the eslint scaffolding for
explicit holdouts is removed — every JSX text node in @multica/views
now flows through i18n.

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

* fix(i18n): drain rollout gaps + add cross-device sync

Lands the post-review punch list for the i18n rollout: closes correctness
gaps that would have shipped silently, and adds the missing cross-device
locale sync the rollout's docs already promised.

Coverage:
- Register issues + agents namespaces in RESOURCES (90 useT call sites
  were rendering keys-as-text in production)
- Harden parity test to compare RESOURCES keys against on-disk JSON
  files, so a future missing namespace registration fails loudly
- Server-side language whitelist in UpdateMe + reject-unsupported test
- Safe SupportedLocale resolution in appearance-tab (no more `as` cast
  on a region-tagged BCP-47 string)
- HTML lang attribute uses zh-CN (not zh-Hans) for screen reader / CJK
  font-stack compatibility
- Cookie Secure flag on https
- Pulled createBrowserCookieLocaleAdapter out of the server-safe entry
  into a new @multica/core/i18n/browser subpath; document.cookie access
  can no longer leak into Edge middleware imports

Cross-device sync:
- New UserLocaleSync component mounted in CoreProvider; on login, if
  user.language differs from the active i18n.language, persist via the
  adapter and reload. Both apps benefit
- Desktop main process tracks system locale and emits IPC on focus when
  it changes; renderer reloads only when the user has no explicit
  Settings choice (their preference still wins)

Tests:
- pickLocale / matchLocale (11 cases incl. region-tagged BCP-47, malformed
  tags, zh-Hant collapse-to-zh-Hans semantics)
- browser-cookie-adapter (6 cases under jsdom)
- Shared renderWithI18n helper at packages/views/test/i18n.tsx that wraps
  the real RESOURCES map; future tests opt in instead of inlining a
  per-file TEST_RESOURCES slice that goes stale silently

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

* docs(conventions): consolidate naming + i18n glossary into docs site

Single source of truth for code naming, i18n translation glossary, and
Chinese voice rules. Previously split between packages/views/locales/glossary.md
and scattered comments — now lives at apps/docs/content/docs/developers/conventions.{mdx,zh.mdx}
with both English and Chinese versions kept in sync.

Three sections per page:
1. Code naming — routes, packages, files, DB, Go, TS, commits
2. i18n translation glossary — entity vs concept rule, what to translate,
   word combination, plurals, interpolation, key naming
3. Chinese voice + style — punctuation, principles, where to look in doubt

Side effects:
- packages/views/locales/glossary.md collapses to a stub redirecting to
  the docs page; do not edit it
- CLAUDE.md gets a new top-level "Conventions reference" section so any
  Claude session sees the pointer before any other rule
- apps/docs/content/docs/developers/ gets a stub English meta.json so the
  conventions page is reachable on the EN side (contributing.zh.mdx /
  architecture.zh.mdx remain ZH-only — separate work)
- Both root sidebars get a new "Developers" group

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

* fix(i18n): apply zh voice rules + translate project/autopilot

Two-part cleanup driven by the conventions doc landed last commit:

Voice violations (mechanical sweep across 10 zh-Hans namespaces):
- 「」 (Japanese-style brackets) → \" to match the EN source's straight
  double quotes (~13 sites)
- … (single-char ellipsis) → ... three dots (~43 sites)
- Drop translation-ese pronoun "我们" where it's a pure narrator
  ("我们已发送" → "已发送", "我们替你托管" → "由 Multica 托管"); keep
  "告诉我们" where "we" is the legitimate brand recipient
- "作为父级 / 作为子级" → "设为父级 / 设为子级"
- "任务" mistranslated as the task entity → `task` (lowercase EN entity)
- Dialog title "Autopilot" → "autopilot"

Translate project / autopilot per industry consensus:
- `project` → 「项目」 (~42 value sites). Feishu / Tower / Teambition /
  PingCode / GitHub Projects all translate; no Chinese product keeps
  `project`.
- `autopilot` → 「自动化」 (~34 value sites). Avoids the Tesla-style
  「自动驾驶」 association; matches Notion / Feishu's industry term.
- Issue / skill / task remain lowercase EN per dev-team familiarity.
- Sidebar nav-label entities get Title Case ("Issue" / "Skill" / "我的
  Issue") so the entry-point label reads as a proper UI signal; body
  prose stays lowercase.

Conventions doc (EN + ZH) reflects the decision and adds a "why these
translate but issue/skill/task don't" rationale block.

Verification: parity test 45/45, full monorepo typecheck green.

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

* feat(i18n): translate chat session delete + project resources section

Two features main shipped while this branch was idle never went through
the i18n pass:

- Chat session delete confirmation dialog (#2115) and history toggle
  tooltip (#2117): adds session_history.delete_dialog.* and
  session_history.row_delete_*, plus window.history_show_tooltip /
  history_back_tooltip.
- Project resources sidebar (#1926/#2080/#2111): entire component
  including toasts, popover form, attach/remove tooltips. New
  projects.resources subtree.

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-06 16:16:12 +08:00
Bohan Jiang
ae985ae2a3 fix(daemon): tighten 404 task-not-found semantics — server + final guard (#2127)
* fix(server): return 500 for transient DB errors in daemon task lookup

requireDaemonTaskAccess used to turn any GetAgentTask error into
404 "task not found", including transient DB connection / pool errors.
Combined with PR #2107 — which added 404+"task not found" as a daemon
cancellation trigger — that means a single DB hiccup could kill an
in-flight agent run.

Distinguish pgx.ErrNoRows (real "task gone", 404) from other errors
(transient, 500 + warn log) using the existing isNotFound helper.

Tests cover both paths via the mockDB pattern already used by
TestFindOrCreateUserGating.

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

* fix(daemon): honor task-deleted signal in post-runTask completion guard

The final pre-completion check in handleTask only looked for
status == "cancelled" and ignored errors. After PR #2107 added a 404
task-deleted cancellation path to the in-flight watcher, this trailing
guard fell out of sync — if the task was deleted between the watcher's
last poll and runTask returning, handleTask would still try to call
CompleteTask and only learn about the deletion via the 404 from that
callback.

Reuse shouldInterruptAgent so the same truth table (cancelled OR
404 task-not-found, but NOT transient errors) drives both polling and
the final guard.

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

---------

Co-authored-by: multica-agent <github@multica.ai>
2026-05-06 16:02:59 +08:00
DimaS
b1be9ed27f fix(daemon): cancel running agent when task is deleted server-side (#2107)
When the server deletes a task while the daemon's agent is still running
(issue removed, agent reassigned, workspace cleanup), GetTaskStatus
starts returning 404 "task not found". The previous polling loop only
checked for status == "cancelled" and silently swallowed the error, so
the local agent kept emitting tool calls against a dead task until its
own timeout fired — minutes of wasted model spend and patch_apply
operations against a workdir nobody would consume.

Changes:

- Add isTaskNotFoundError next to isWorkspaceNotFoundError so the daemon
  can distinguish "task gone" 404 from "workspace gone" 404 (already
  handled separately) and from generic network errors.
- Extract the cancellation polling goroutine in handleTask into
  watchTaskCancellation, plus a pure shouldInterruptAgent decision
  helper. The pure helper makes both signals (cancelled status and 404
  task) easy to unit-test without spinning up a real backend.
- Trigger interruption on the new 404 path. Transient errors (5xx,
  network) intentionally still don't cancel — the next poll will retry
  and a flaky link should not kill an in-flight agent.

Tests cover the helper truth table, the existing "status cancelled"
path, the new "task deleted (404)" path, and a negative case ensuring a
running task is not interrupted.

Co-authored-by: “646826” <“646826@gmail.com”>
2026-05-06 15:45:03 +08:00
Bohan Jiang
144661e68f fix(daemon/execenv): refresh stale Codex auth.json across env reuse (#2126)
`ensureSymlink` previously short-circuited whenever `dst` already existed
as a regular file ("Regular file exists — don't overwrite"). On Windows
that branch is reachable via the createFileLink copy fallback that fires
when `os.Symlink` is unavailable, so once a per-task `codex-home/auth.json`
was written as a copy it would never be refreshed by subsequent
Prepare/Reuse calls. If the shared `~/.codex/auth.json` rotated (e.g.
Codex Desktop refreshed the token in the background), the daemon kept
handing Codex a now-revoked refresh_token, which the OAuth server
rejected with `refresh_token_reused` / `token_expired`. Renaming the
workspace directory was the only recovery path.

Treat any non-matching dst — wrong-target symlink, broken symlink, or
stale regular file — as something to delete and re-create via
createFileLink, so each Prepare/Reuse mirrors the current shared source.
Add a `logCodexAuthState` info log (file kind, link target, size, mtime —
never contents) so operators chasing the same symptom can see at a glance
whether the per-task home is tracking the shared auth or has drifted.

Tests cover: stale regular-file dst is replaced, copy-fallback dst is
refreshed when the shared source rotates, and a high-level
prepareCodexHome regression simulating the Windows + token-rotation
scenario from issue #2081.

Co-authored-by: multica-agent <github@multica.ai>
2026-05-06 15:18:04 +08:00
Matt Van Horn
0dbfbfed2e fix(daemon/execenv): refuse to write .gc_meta.json when issue_id is empty (#2077)
A non-trivial fraction of completed task workdirs (~28% in field reports)
end up with .gc_meta.json files containing issue_id: "". Empty issue_id
defeats the daemon's own GC loop (gc.go:139 calls
GetIssueGCCheck(meta.IssueID)) and external retention scripts that
cross-reference issue status before deleting orphaned workdirs.

Refuse to write the file when issueID is empty, logging a Warn so
operators have a starting point for debugging the upstream race
condition. Skip is preferred over a sentinel-marker file: it keeps the
data invariant clean (a .gc_meta.json file always carries a valid
issue_id) and matches the repo CLAUDE.md preference for not preserving
dual-state behavior.

WriteGCMeta now takes a *slog.Logger so it can emit the warning. The
package already uses log/slog (Prepare/reuseEnv), and daemon.go:884 has
taskLog in scope at the only call site.

Closes #1913

Co-authored-by: Matt Van Horn <455140+mvanhorn@users.noreply.github.com>
2026-05-06 15:02:16 +08:00
furtherref
1b3c78e4b5 fix(pins): unpin missing sidebar rows (#2062)
* fix(pins): unpin missing sidebar rows

* fix(pins): guard missing pin auto-unpin
2026-05-06 14:43:47 +08:00
Bohan Jiang
09f04847d3 feat(server): redis-backed runtime liveness with DB fallback (#2121) 2026-05-06 14:31:33 +08:00
prellr
ee10c508fb fix(daemon): trust the agent's session id from session/resume across ACP backends (#2070)
When the local state.db of an ACP backend (hermes, kimi, kiro) is wiped
— crash, config change, manual kill, container reset — the backend's
session/resume (or session/load, in kiro's case) silently creates a
brand-new session rather than failing, and returns the new id in the
response. Today the daemon ignores the response and stamps
sessionID = opts.ResumeSessionID across all three backends, so every
subsequent session/prompt is addressed to a session id the backend has
no record of. The task fails with JSON-RPC -32603 (Internal error) on
the very first turn, with no operator-visible signal that the problem
is a session-id mismatch one layer down.

The behavior is invisible: agent shows "started", then "failed" with a
generic Internal error. Reproducing in production took repeated runs
because nothing in the logs pointed at the silent reset.

Fix: route all three ACP backends through a small `resolveResumedSessionID`
helper that:

- prefers the id the backend returned in its response (the canonical
  id; the one the backend will accept on the next call)
- falls back to the requested id when the response is malformed,
  empty, or omits sessionId — defensive fallback so older / non-
  conforming backends (notably kiro's current session/load shape)
  behave identically to today
- signals (via a bool) when the id changed, so the caller logs a Warn
  with `backend=<hermes|kimi|kiro>` and operators can grep for silent
  state resets to correlate them with task failures

Why this is at the backend layer rather than the daemon's existing
session-resume fallback: server/internal/daemon/daemon.go:1554-1566
already retries with a fresh session when resume fails, but it gates
on `result.Status == "failed" && result.SessionID == ""`. The backend
WILL hand back a result.SessionID — just the new one it silently
committed to — so the daemon-level fallback never fires for this
failure mode.

The helper is also what session/new already uses (extractACPSessionID,
documented in code as "Shared by all ACP backends"). session/new
extracts the canonical id from the response; session/resume just
didn't, until now.

Coverage:
- hermes.go: confirmed bug, root cause of -32603 in production
- kimi.go: same code shape, same protocol method, same response
  schema as hermes (per extractACPSessionID's comment) — same bug
- kiro.go: same code shape, different method (session/load). Current
  observed response doesn't include sessionId, so the defensive
  fallback means today's behavior is preserved. Routing through the
  same helper means a future kiro release that DOES return a sessionId
  on silent reset works the same way as hermes/kimi without another
  diff.

Tests (server/pkg/agent/hermes_test.go — helper covers all three
backends, no per-backend duplication):
- TestResolveResumedSessionIDMatching — backend confirms requested id
- TestResolveResumedSessionIDDifferent — backend returned a new id;
  caller is told to switch
- TestResolveResumedSessionIDEmptyResponse — older / malformed body;
  defensive fallback to requested id (covers kiro's current shape)

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-06 14:15:40 +08:00
Naiyuan Qing
140678c4b3 fix(web): redesign 404 + break NoAccessPage redirect loop (#2122)
* refactor(web): rewrite 404 page using design tokens

Replace editorial-style 404 (hardcoded cream/ink/terracotta colors,
Instrument Serif font, fluid clamp() typography) with a minimal version
using semantic tokens and the project's buttonVariants helper.

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

* fix(workspace): break NoAccessPage redirect loop by clearing stale cookie

The web proxy redirects / to /<lastSlug>/issues based on the
last_workspace_slug cookie alone, with no access check. When a user
gets evicted from a workspace, the cookie still points at it; clicking
"Go to my workspaces" then loops: NoAccessPage -> / -> proxy ->
same bad slug -> NoAccessPage.

Clear the cookie on mount so the proxy falls through to the landing
page, which resolves the correct destination via the workspace list.

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

* fix(web): mark not-found as client to allow buttonVariants import

buttonVariants is exported from a "use client" module, so calling it
from a server component is rejected by Next 16's directive checks.
Production build of /workspaces/new prerender failed because of this.

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-06 14:15:13 +08:00
Bohan Jiang
b08594f2f6 fix(daemon): isolate runtime poll & heartbeat schedules per runtime (#2116)
* fix(daemon): isolate runtime poll & heartbeat schedules per runtime

A daemon serving multiple workspaces ran a single round-robin poll loop
and a single HTTP heartbeat loop across every registered runtime. A 30s
HTTP timeout for any one runtime serialized that delay across all the
others — observed in production as one workspace's runtimes wedging
every other workspace's runtimes on the same daemon.

This change:

- Replaces the shared runtime-set channel with a multi-subscriber
  watcher so taskWakeupLoop, heartbeatLoop, and pollLoop can each
  react to runtime-set changes independently.
- Splits heartbeatLoop and pollLoop into supervisor + per-runtime
  worker goroutines. Each runtime owns its claim cadence and its
  heartbeat ticker, so a slow request on one runtime no longer blocks
  any other.
- Stagers the per-runtime heartbeat first tick by a jittered delay up
  to one full interval to avoid a thundering herd at startup.
- Sizes the WS writer channel to scale with the runtime count
  (max(16, 2*N)) so a full per-runtime heartbeat batch always fits;
  the previous fixed 8-slot buffer dropped heartbeats whenever a
  daemon watched more than ~8 runtimes.

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

* fix(daemon): acquire execution slot only after ClaimTask, drain pollers before taskWG

Two issues from review on the previous commit:

1. Acquiring the shared task slot before ClaimTask reintroduced the very
   head-of-line blocking the refactor was meant to remove. With
   MaxConcurrentTasks=1, a slow claim on one runtime parked the only slot
   for the duration of the HTTP timeout (up to 30s), starving every other
   runtime's claim attempts. Slots are now acquired after the claim
   returns a task; other runtimes' pollers stay free to claim. The
   already-dispatched task waits for a slot under MaxConcurrentTasks
   bounds, which is the same backpressure shape we had before.

2. pollLoop's shutdown path called taskWG.Wait immediately after
   cancelling pollers, but a poller could still be between ClaimTask
   returning a task and taskWG.Add(1). When taskWG's counter is zero
   that races with Wait — undefined sync.WaitGroup misuse, sometimes
   panic. Added a pollerWG so the supervisor blocks until every poller
   goroutine has actually returned before reaching taskWG.Wait.

Tests:
- TestRunRuntimePollerIsolatesSlowRuntime now uses MaxConcurrentTasks=1
  (was 4) so it would have failed under the old slot-before-claim path.
- New TestPollLoopShutdownWaitsForPollersBeforeTaskWG drives the exact
  race window — claim returns a task at the same moment shutdown fires —
  under -race.

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

* fix(daemon): acquire slot before ClaimTask so capacity-waiters never enter dispatched

The previous commit moved slot acquisition AFTER ClaimTask to address a
review concern about head-of-line blocking with MaxConcurrentTasks=1.
That introduced a strictly worse failure mode: server-side ClaimTask
flips the task to `dispatched` immediately (agent.sql:174-176), and the
runtime sweeper fails any task in `dispatched` for >300s with
`failed/timeout` (runtime_sweeper.go:25-28). When local execution
capacity is full and the next claimed task can't acquire a slot within
5 minutes, the user sees the exact failure this issue is fixing —
`dispatched_at` set, `started_at` NULL, `failure_reason=timeout`.

Reverted to slot-before-claim. The trade-off is the original review
concern: with MaxConcurrentTasks=1 and a slow ClaimTask, other
runtimes' claims are delayed by up to client.Timeout=30s. That's a
30s polling delay, not a failure — server-side those tasks remain
`queued` (no timeout in that state) until a slot frees. 30s ≪ 300s,
so other runtimes' tasks cannot get sweeper-failed because of this.

The pollerWG fix from the previous commit (avoiding sync.WaitGroup
misuse on shutdown) is preserved.

Tests:
- TestRunRuntimePollerIsolatesSlowRuntime: MaxConcurrentTasks back to
  4 (the pre-issue baseline) — the headroom case where slot-before-
  claim still gives full per-runtime isolation.
- New TestRunRuntimePollerSkipsClaimWhenAtCapacity: holds the only
  slot and verifies the poller never calls ClaimTask while sem is
  empty. The previous "claim first" path would have failed this.

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

---------

Co-authored-by: multica-agent <github@multica.ai>
2026-05-06 14:13:27 +08:00
Bohan Jiang
a4fac51cf5 fix(projects): add resource_count breadcrumb instead of inlining resources (#2118)
* fix(projects): add resource_count breadcrumb to project responses

Closes #2087

`multica project get` previously returned project metadata with no signal
that resources existed. Agents that fetched a project this way had no way
to discover its attached resources without already knowing about
`/api/projects/{id}/resources` or the on-disk `.multica/project/resources.json`.

Rather than inline the full resource list into the parent payload (which
conflates parent metadata with a child sub-collection and locks the
resource_ref shape into the project endpoint's contract), this adds a
scalar `resource_count` breadcrumb to ProjectResponse. The actual list
stays at the dedicated sub-collection endpoint.

Changes:
- GetProjectResourceCounts :many — new batched sqlc query
- ProjectResponse.ResourceCount populated in GetProject, ListProjects,
  SearchProjects, and the with-resources CreateProject echo
- multica project get prints a stderr hint pointing at
  multica project resource list <id> when count > 0; the JSON on stdout
  stays parseable
- Meta-skill (runtime_config.go) lists multica project get and
  multica project resource list in Available Commands so agents that
  read CLAUDE.md / AGENTS.md know about both paths

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

* fix(projects): wire ResourceCount through Update + Create event payload

Review feedback on #2118.

- UpdateProject now reloads ResourceCount before responding/publishing.
  Previously a title- or status-only PUT served (and broadcast over WS)
  resource_count: 0 even when resources existed.
- The with-resources CreateProject path sets resp.ResourceCount before
  the project:created publish, so the WS event payload matches the HTTP
  echo. The hand-rolled response map collapses to an embedded
  ProjectResponse + resources array — one source of truth for the
  serialized shape.
- packages/core/types/project.ts: Project gains resource_count: number
  to keep the TS contract aligned with the server response.

Tests:
- TestProjectResourceCountBreadcrumb extends to assert UpdateProject
  preserves the breadcrumb.
- TestCreateProjectWithResourcesEchoesCount asserts the create echo
  carries resource_count matching the attached resources.

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

---------

Co-authored-by: multica-agent <github@multica.ai>
2026-05-06 14:09:35 +08:00
Multica Eve
2b967338a8 fix(runtimes): narrow CostCell usage window from 180d to 14d (#2119)
The runtimes list page renders a CostCell per row that only displays a
7d cost total plus a 7d-vs-prior-7d delta. Until now each cell still
fetched a 180d usage window so the cache key matched the runtime-detail
page (clicking a row would pre-warm detail). The side effect was N
parallel 180d in-line aggregations against task_usage on every list
visit, one per runtime, which dominated DB load for this view.

Switch the cell to a 14d window — exactly the data it actually needs
for cost7d + costPrev7d. Detail still owns its own 180d query; the
worst case after this change is one extra request on first navigation
into detail, in exchange for a large steady-state reduction on the
list page (down to 14d × N instead of 180d × N, ~13× fewer rows
scanned per request).

This is the frontend half of the runtime-usage perf work tracked in
MUL-1748. The backend index + daily rollup changes will land
separately.

Co-authored-by: Eve <eve@multica.ai>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Co-authored-by: multica-agent <github@multica.ai>
2026-05-06 13:56:58 +08:00
Bohan Jiang
6ef9be10d6 fix(chat): expose History panel + delete affordance from chat header (#2117)
ChatSessionHistory was already implemented but unreachable: nothing in the
app rendered it and there was no UI to toggle showHistory. The trash icon
on each session row was therefore invisible.

Adds a History icon button to the chat-window header that toggles the
panel; when on, it renders ChatSessionHistory in place of the message
list and input. Per-row delete (hover trash + AlertDialog) works as
designed.

Co-authored-by: multica-agent <github@multica.ai>
2026-05-06 13:36:06 +08:00
Bohan Jiang
60b215f44f feat(chat): support deleting chat sessions (#2115)
* feat(chat): support deleting chat sessions

Replaces the unreachable archive endpoint with a real hard delete and
exposes it from the chat history panel.

- DELETE /api/chat/sessions/{id} now hard-deletes the session and its
  messages (CASCADE), cancels any in-flight tasks before removal so the
  daemon doesn't keep running work whose result has nowhere to land,
  and broadcasts chat:session_deleted.
- Frontend adds a per-row delete button with a confirmation dialog,
  optimistically drops the session from both list caches, and clears the
  active session pointer locally + on other tabs via the WS handler.

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

* fix(chat): make session delete atomic and keep archived sessions read-only

Address review feedback on #2115.

- DeleteChatSession now runs lock + cancel + delete in a single tx and
  only broadcasts events post-commit. The new LockChatSessionForDelete
  query takes FOR UPDATE on chat_session, which blocks the FK validation
  of any concurrent SendChatMessage trying to enqueue a task for this
  session — that insert fails after we commit, so it can no longer
  produce an orphaned task whose chat_session_id is nulled by
  ON DELETE SET NULL. Cancel failure now aborts the delete instead of
  warn-and-continue.
- SendChatMessage refuses non-active sessions again. The archive code
  path is gone, but legacy rows with status='archived' may still exist
  in the DB; keep the guard until we explicitly migrate them.
- Frontend re-reads allChatSessionsOptions to disable ChatInput on
  legacy archived sessions so the UX matches the server-side guard.

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

---------

Co-authored-by: multica-agent <github@multica.ai>
2026-05-06 13:22:53 +08:00
Bohan Jiang
f1082b10a4 feat(cli): add --assignee-id / --to-id / --user-id for unambiguous targeting (#2114)
* feat(cli): add --assignee-id / --to-id / --user-id for unambiguous targeting

`multica issue {create,update,list}`, `issue assign`, and `issue subscriber
{add,remove}` accepted only fuzzy name matching, which fails in workspaces
where one user's name is a substring of another (e.g. agent "J" vs
"Cursor - J" / member "Jiayuan"). #1642 added UUID acceptance through the
existing flags, but there was still no explicit path that signals "this is a
UUID, not a name" — important for scripts that read IDs from
`multica workspace members --output json`.

Adds an `-id`-suffixed counterpart for every assignee-taking flag:

- `issue list`     : --assignee-id
- `issue create`   : --assignee-id
- `issue update`   : --assignee-id
- `issue assign`   : --to-id
- `issue subscriber {add,remove}` : --user-id

The new flags route through `resolveAssigneeByID`, a strict resolver that
requires a canonical UUID and fails with a clear error when the entity is
not in the workspace (no name fallback). A shared `pickAssigneeFromFlags`
helper enforces mutual exclusion between the name and id flags so a script
that accidentally sets both never silently applies one over the other.

Refs MUL-1254.

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

* fix(cli): detect assignee flag presence via Changed, not value-emptiness

`pickAssigneeFromFlags` previously branched on `flag value != ""`, so
explicitly passing an empty UUID silently routed through the "no flag set"
path:

  multica issue list --assignee-id ""        # listed every issue
  multica issue create --assignee-id ""      # created an unassigned issue
  multica issue subscriber add --user-id ""  # subscribed the caller

This is exactly the failure mode the strict-UUID flag was added to prevent —
a script interpolating `--assignee-id "$MAYBE_UUID"` against a missing env
var should fail loudly, not silently degrade to a different operation.

Switch the picker (and the assign-command top-level guard) to use
`Flags().Changed`, so an explicit empty value reaches `resolveAssigneeByID`
/ `resolveAssignee` and surfaces a clear "expected a canonical UUID" /
"no member or agent found matching" error.

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

* docs(cli): cover --assignee-id / --to-id in user docs and quick-create prompt

Follow-up to the --*-id flag rollout: surface the new flags everywhere the
old ones are documented so users (and agents) can discover them.

- assigning-issues.{mdx,zh.mdx}: the page explicitly calls out the
  duplicate-name footgun ("first one listed wins, so rename before
  assigning") — replace that workaround with a --to-id <uuid> example
- cloud-quickstart.{mdx,zh.mdx}: add a --to-id hint after the substring-
  match callout so first-time users learn about the strict path
- internal/daemon/prompt.go (quick-create injected prompt):
  - default-to-self: pass --assignee-id <task.Agent.ID> instead of
    --assignee <name>; the picker agent's UUID is already in scope and
    UUID matching is unambiguous in workspaces with overlapping agent
    names (J / Cursor - J / Pi - J etc.)
  - user-named: tell the agent to prefer --assignee-id <uuid> using the
    user_id/id from the JSON it already fetched; --assignee <name> stays
    a fallback for unambiguous workspaces

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

---------

Co-authored-by: multica-agent <github@multica.ai>
2026-05-06 13:13:36 +08:00
LinYushen
44a0ced558 fix(runtime): persist CLI update requests in Redis (#2113)
Co-authored-by: multica-agent <github@multica.ai>
2026-05-06 13:00:11 +08:00
Bohan Jiang
89b939b07c fix(storage): build region-qualified S3 public URLs (#2051) (#2065)
* fix(storage): build region-qualified S3 public URLs (#2051)

The uploadedURL fallback (no CloudFront, no custom endpoint) wrote
"https://<bucket>/<key>" — missing the ".s3.<region>.amazonaws.com"
suffix — so any deployment that pointed S3_BUCKET at a real AWS bucket
without a CDN got broken image URLs back to the client. Avatar URLs
were persisted in this broken form on the user/agent rows, so profile
pictures uploaded via the SDK never rendered.

- Track S3_REGION on S3Storage and emit
  https://<bucket>.s3.<region>.amazonaws.com/<key> by default;
  fall back to path-style https://s3.<region>.amazonaws.com/<bucket>/<key>
  when the bucket name contains dots, since the AWS wildcard cert
  can't validate dotted virtual-hosted hosts.
- Teach KeyFromURL to recognise the new region-qualified hosts (both
  styles) and keep recognising the legacy bucket-only host so historical
  records can still be deleted/migrated.
- Document that S3_BUCKET is the bucket name only, not a hostname,
  in env-vars docs (en+zh), self-hosting guides, and .env.example.

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

* feat(storage): warn at startup when S3_BUCKET looks like a hostname

Catches the most common misconfiguration shape (S3_BUCKET set to
"<bucket>.s3.<region>.amazonaws.com") with a startup log line so
operators don't silently end up with a config that signs uploads
against an invalid bucket name.

A real bucket name can never legitimately contain "amazonaws.com",
so the check is a single substring match — no false positives
worth carving out.

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

---------

Co-authored-by: multica-agent <github@multica.ai>
2026-05-06 12:45:55 +08:00
Bohan Jiang
8b0eeb0615 fix(projects): show URL tooltip on already-attached repos in Add Resource list (#2111)
The repo button in the Add Resource popover used the native `disabled`
attribute when a repo was already attached. Browsers suppress pointer
events on disabled form controls, so the tooltip on the URL text never
fired for attached rows — the issue spec calls out "hovering over any
URL should also show the complete URL in a tooltip".

Switch to `aria-disabled` plus a click guard so the row still announces
as disabled to assistive tech, looks the same visually, and is no
longer click-able, but hover still reaches the tooltip trigger.

Co-authored-by: multica-agent <github@multica.ai>
2026-05-06 12:00:27 +08:00
Ákos Seres
64c605e227 fix(execenv): write OpenCode skills to .opencode/skills/ for native discovery (#2016)
* fix(execenv): write OpenCode skills to .opencode/skills/ for native discovery

* fix(repocache): exclude OpenCode skill directory
2026-05-06 11:48:06 +08:00
Cong Vu Chi
820d57535e feat(desktop): load runtime self-host config (#2012)
* feat(desktop): load runtime self-host config

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

* docs: document desktop runtime self-host config

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

* fix(desktop): address runtime config review feedback

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

---------

Co-authored-by: Cheese <congvc@congvc-c00.taila6fa8a.ts.net>
Co-authored-by: multica-agent <github@multica.ai>
Co-authored-by: congvc <congvc-dev@gmail.com>
2026-05-06 11:39:36 +08:00
Bohan Jiang
a7299bf857 refactor(projects): pass projectId prop to ProjectIssuesContent (#2110)
Replace `scope.replace("project:", "")` with the `projectId` already
held by `ProjectDetail`, so the create-issue handler in the empty
state no longer depends on the `project:<id>` scope-string format.

Co-authored-by: multica-agent <github@multica.ai>
2026-05-06 11:36:45 +08:00
Yash Soni
baac4080e9 fix(installer): correct Windows version parsing and checksum decode (#2093)
Closes #2092
2026-05-06 11:36:25 +08:00
Kagura
99f6cb8130 fix(projects): add New Issue button to empty project state and URL tooltips to resources (#2080)
When a project has no issues, show a [+ New Issue] button that opens
the create-issue dialog with the project pre-selected. Previously
users had to navigate to the issues page and manually assign the
project.

Also add tooltips to repository URLs in the Resources section so
truncated URLs can be read in full on hover.

Fixes #2078
2026-05-06 11:33:26 +08:00
ASDFGHoney
b5f1e506e5 fix(views): split desktop/mobile sidebar state in project-detail (#2067)
Mobile project-detail mounted its <Sheet> with open=true for one render —
useIsMobile() reports false on first render and flips to true on the next,
so the mobile branch briefly mounted Base UI Dialog open, painted its
fixed inset-0 z-50 backdrop and locked scroll. The follow-up useEffect
toggled it closed within the same animation cycle, leaving Dialog's
pointer-events/inert/scroll-lock state stuck on mobile.

Mirror packages/views/issues/components/issue-detail.tsx by keeping
desktopSidebarOpen (default true) and mobileSidebarOpen (default false)
as separate states, binding the mobile <Sheet> to mobileSidebarOpen only.
The single-state pattern dates back to #1087, where issue-detail and
project-detail received mobile-Sheet support together but only
issue-detail used split state.
2026-05-06 11:27:45 +08:00
Thanh Minh
00cde21724 fix(views): hide archived agents from runtime detail (#2097) 2026-05-06 11:23:56 +08:00
Jiayuan Zhang
1476c268dd refactor(quick-create): exempt git-describe daemons from CLI gate (#2108)
* refactor(quick-create): remove daemon CLI version gate

Local-source daemons report dev-suffixed versions (e.g.
v0.2.15-235-gdaf0e935) that the picker pre-check and server gate both
treat as too old, blocking quick-create during local testing.

Drops the gate end-to-end: removes MinQuickCreateCLIVersion +
CheckMinCLIVersion in pkg/agent, the checkQuickCreateDaemonVersion
handler and readRuntimeCLIVersion helper in handler/issue.go, and the
mirrored cli-version.ts plus the modal's pre-check, blocked-state UI,
and daemon_version_unsupported error branch.

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

* refactor(quick-create): skip daemon CLI version gate in dev

Restores the gate (reverts the full-removal commit) and bypasses it in
non-production environments instead. The motivation for the original
removal — local source-built daemons report a `git describe` version
like v0.2.15-N-gHASH that parses below 0.2.20 and blocks dev testing —
is now handled by checking APP_ENV on the server and NODE_ENV on the
client. Production keeps the original "needs upgrade" UX.

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

* refactor(quick-create): exempt git-describe daemons instead of env bypass

Replaces the per-environment bypass added in the previous commit with a
shared daemon-version signal. CheckMinCLIVersion / checkQuickCreateCliVersion
now treat any daemon whose CLI version matches the
`vX.Y.Z-N-gHASH[-dirty]` git-describe shape as OK; tagged releases keep
going through the normal min-version comparison.

Why: Emacs flagged that (a) NODE_ENV !== "production" also disables the
gate on staging and other non-prod deployments, undoing the protection
for the case the gate was originally written for, and (b) NODE_ENV (web
client) and APP_ENV (server) are not equivalent, so the modal pre-check
and server gate could disagree on the same request. Both go away when
the signal is intrinsic to the daemon's version string.

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

---------

Co-authored-by: multica-agent <github@multica.ai>
2026-05-06 09:00:11 +08:00
Jiayuan Zhang
9a5f5ca498 fix(views): coalesce repeated task_completed/task_failed activity entries (#2044)
Consecutive "completed the task" entries from the same agent now merge
into a single line showing the count (e.g. "completed the task (7 times)")
regardless of time gap. Other activity types keep the existing 2-minute
coalescing window.

Closes MUL-1709
2026-05-06 02:11:43 +02:00
Bohan Jiang
daf0e935f6 fix(views): show Ctrl+K / Ctrl+Enter on non-Mac platforms (#2060)
The sidebar search trigger, quick-create-issue modal, and feedback modal
hardcoded the Mac glyphs (⌘, ↵) for their keyboard hints, so Windows and
Linux users always saw Mac shortcuts even though the underlying handlers
already accept metaKey || ctrlKey.

Extract a small platform helper (isMac, modKey, enterKey, formatShortcut)
in packages/core/platform/keyboard.ts and route all four affected sites
(plus the editor bubble menu, which had the same logic inlined) through
it, so non-Mac users see Ctrl+K, Ctrl+Enter, etc.

Closes multica-ai/multica#2056
2026-05-04 21:26:00 +08:00
Bohan Jiang
5c42ed1649 fix(server): allow re-inviting after invitation expires (#2059)
The uniqueness check on workspace invitations only filtered by
status='pending', not by expires_at. Combined with the partial unique
index idx_invitation_unique_pending (also keyed only on status), a
past-due pending row permanently blocked re-inviting the same email.

Now, before creating a new invitation, the handler flips any past-due
pending row for the same (workspace_id, invitee_email) to 'expired',
freeing the unique slot. Also tightens GetPendingInvitationByEmail to
require expires_at > now(), matching the existing list queries.

Closes multica-ai/multica#2055.
2026-05-04 21:24:56 +08:00
Dingyj3178
a57dd76faf fix(views): improve mobile responsiveness for agents and settings (#2036)
* feat(agents): make agent detail page mobile responsive (#1)

Stack the inspector + overview pane vertically below md, switch the
shell to page-level scroll so the inspector flows naturally, give the
overview pane a min-h-[60vh] floor so tabs stay usable, and let the
5-tab nav scroll horizontally on narrow viewports.

* fix(settings): make Repositories tab and Settings shell mobile-responsive (#2)

The Settings shell used a fixed w-52 sidebar with no responsive behavior,
leaving almost no room for tab content on phone-width viewports. Stack the
nav above the content on mobile, scale inner padding, and let the
Repositories tab's input/button rows wrap rather than overflow.
2026-05-04 21:24:07 +08:00
Bohan Jiang
c24191a884 fix(editor): keep blank-line paste inside the code block (#2058)
Pasting `line1\n\nline2` while the caret was inside a code block ran the
text through the Markdown parser, which split on the blank line and tore
the code block open, dropping the trailing content into a sibling
paragraph.

Detect the codeBlock parent on `handlePaste` and insert the clipboard
text verbatim instead. Code blocks have `code: true`, so newlines stay
literal — exactly what users expect when pasting code or logs.

Closes #1982
2026-05-04 21:12:14 +08:00
Kagura
629f4136ac fix(codex): handle MCP elicitation server requests correctly (#1944)
* fix(codex): handle MCP elicitation server requests correctly

Fixes #1942.

handleServerRequest responded with {} to unrecognized Codex server
requests including mcpServer/elicitation/request. Codex 0.125+ expects
{action, content, _meta} for elicitation — the empty object causes a
deserialization error and the MCP tool call is reported as user-rejected.

Changes:
- Add mcpServer/elicitation/request case with correct response schema
- Add respondError helper for JSON-RPC error responses
- Return proper JSON-RPC method-not-found error for unknown server
  requests instead of silent empty object
- Add tests for MCP elicitation and unknown method handling

* fix: use cfg.Logger instead of global slog in codex handleServerRequest

Switch the unhandled-server-request warning from global slog.Warn to
c.cfg.Logger.Warn for consistency with all other log calls in codex.go.
This ensures the warning appears in daemon run-logs and per-task
pipelines where operators look during triage.
2026-05-04 21:05:37 +08:00
ASDFGHoney
cb078c0f36 fix(core): patch byIssue label cache on WS label change (#2048)
`onIssueLabelsChanged` patched the embedded `labels` field in the
issue list and detail caches but never touched `labelKeys.byIssue`,
the cache backing the issue-detail Properties LabelPicker. Mutations
already covered all three caches; WS-driven changes (agents, other
tabs) left the picker stale until remount, since `staleTime: Infinity`
plus `refetchOnWindowFocus: false` prevent recovery on focus.
2026-05-04 20:51:02 +08:00
ayakabot
e13e5edc8e fix(issues): trimEnd comparison on blur to avoid unnecessary updates (#2054)
Fixed: #2053
2026-05-04 20:50:39 +08:00
Manu
fee393df1f fix(views): show full repo URLs in project creation (#2045) 2026-05-04 20:50:17 +08:00
ayakabot
1ff4e27e77 feat(quick-create): cache agent prompt draft across navigation (#2039)
When creating an issue with agent, the input content was lost when
navigating away (e.g., to view a ticket) and returning. Manual create
already persisted its draft - now agent create does too.

Changes:
- Add prompt field to useQuickCreateStore (persisted with workspace)
- AgentCreatePanel reads initial prompt from draft store if no transient
  data.prompt is provided
- onUpdate now saves prompt to draft store (not just hasContent)
- clearPrompt() called after successful submit

Fixes: #1957
2026-05-04 00:03:27 +02:00
Jiayuan Zhang
fbf9460d5e feat(chat): support fullscreen expand mode (#2043)
* feat(chat): support fullscreen mode similar to Linear

When the expand button is clicked, the chat window now fills the entire
content area (inset-0) instead of scaling to 90% of parent. Resize
handles are hidden in fullscreen mode.

* fix(chat): use stacked card layout for fullscreen mode

Fullscreen chat now uses inset-3 with rounded corners, ring, and shadow
to create a stacked card effect on top of the content area — matching
the Linear design — instead of a flush inset-0 fill.

* feat(chat): add motion.dev spring animations for expand/collapse

- Install `motion` in @multica/views
- Replace CSS transitions with motion.div layout animation for
  expand/collapse (spring-based FLIP), giving a natural bouncy feel
- Open/close uses spring scale + smooth opacity fade
- Layout animations are disabled during drag-to-resize (instant updates)

* fix(chat): remove spring bounce from expand/collapse animation

Use critically damped springs (bounce: 0) so the animation settles
directly at its target without overshooting.

* fix(chat): fix text distortion during expand/collapse animation

Use layout="position" instead of layout (full FLIP). Full FLIP uses
scale transforms to animate size changes, which distorts text and
child content. Position-only layout animates translate only — size
changes are instant, text stays crisp.

* fix: regenerate lockfile with pnpm@10.28.2

The lockfile was previously generated with pnpm 10.12.4, causing
unrelated churn (lost libc constraints, deprecated metadata). Reset
to main and regenerated with the repo's pinned pnpm@10.28.2 so
the diff is scoped to the new motion dependency only.
2026-05-03 22:56:22 +02:00
Jiayuan Zhang
d492b9d7a6 Revert "feat(quick-create): add preset issue fields (#2002)" (#2042)
This reverts commit a039c4d803.
2026-05-03 20:02:40 +02:00
Bohan Jiang
3dc3e49a47 fix(daemon): remove Co-authored-by hook when workspace setting is off (#2035)
* fix(daemon): remove Co-authored-by hook when workspace setting is off

The prepare-commit-msg hook is installed in the bare repo's shared
hooks dir, so once installed it persists across worktrees. CreateWorktree
only installed the hook when the setting was enabled, but never removed
it — so disabling the workspace toggle had no effect on subsequent
commits.

Add removeCoAuthoredByHook and call it in both CreateWorktree branches
when the setting is disabled. Use a marker comment in the hook script so
removal only deletes hooks the daemon owns; user-installed hooks at the
same path are left alone.

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

* fix(daemon): recognize legacy Multica prepare-commit-msg hook on removal

The first cut of removeCoAuthoredByHook only recognized hooks installed
by the new code (containing the multicaHookMarker sentinel). Bare clones
already on disk from previous daemon releases carry the older script
without that line, so toggling the workspace setting off would have
treated them as user hooks and left the trailer in place — exactly the
state reported in MUL-1704.

Match against a list of known daemon signatures (current marker + the
legacy "Installed by the Multica daemon." comment), and add a test that
seeds the verbatim legacy hook before CreateWorktree(... disabled) to
keep recognition aligned with what production hosts actually have on
disk.

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

---------

Co-authored-by: multica-agent <github@multica.ai>
2026-05-03 21:09:16 +08:00
Bohan Jiang
ae9098637d feat(analytics): suppress PostHog $pageview on desktop tab/workspace switches (#2033)
* feat(analytics): suppress PostHog $pageview on desktop tab/workspace switches

Desktop tab switches were emitting a $pageview every time the user clicked
between already-open tabs (or workspaces), since the tracker fired on any
change to the resolved active path. Real-data audit showed this was the
single largest source of PostHog quota burn — desktop accounted for 51% of
all $pageviews at ~34 pv/user/30d vs web's ~10 — and the re-emitted paths
add no signal because the original navigation already fired.

Detect "tab switch" as `(workspace, tabId)` identity changing while the
surface stays `tab`, and skip the capture in that case while still updating
the ref so the next in-tab navigation compares against the right baseline.
Login transitions, overlay open/close, and intra-tab navigation continue
to fire as before.

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

* fix(analytics): only suppress $pageview for re-activations of known tabs

Prior commit suppressed every (workspace, tabId) change while the surface
stayed `tab`, which also swallowed the first $pageview for newly opened
tabs (`openInNewTab` / `addTab`) and for cross-workspace `switchWorkspace`
into a not-yet-seen tab.

Track an observed `(workspace, tabId) → path` map seeded from the
persisted tab store on mount. Suppress only when the active key is
already in the map AND its recorded path matches the current path —
i.e. genuine re-activation of an already-known tab. New tabs and
cross-workspace navigation to a fresh tab now correctly emit one
pageview.

Adds a vitest covering the three behaviors GPT-Boy flagged plus the
intra-tab navigation, overlay/login transitions, and persistence-restored
mount paths. Wires the `@/` alias into `vitest.config.ts` so component
tests can resolve renderer-relative imports.

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

* refactor(analytics): reuse tab-store helpers and inline observed-tabs seed

Replace the two ad-hoc tab selectors with the existing
`useActiveTabIdentity()` + `getActiveTab()` helpers from tab-store, which
already provide the (slug, tabId) primitive pair and the active tab
lookup with the same stability guarantees.

Move the observed-tabs Map seeding from a useEffect into a synchronous
first-render initializer. The seed runs once per mount before any
state-driven effect, so the previous useEffect-then-defensive-fallback
pattern in the second effect was unreachable.

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

---------

Co-authored-by: multica-agent <github@multica.ai>
2026-05-03 20:54:29 +08:00
Kagura
cc94fbd305 fix: handle square brackets in agent names for mention parsing (#1992)
* fix: handle square brackets in agent names for mention parsing (#1991)

The mention regex used [^\]]* to match labels, which broke when agent
names contained square brackets (e.g. David[TF]). The ] inside the name
caused the regex to stop matching prematurely, silently dropping the
mention.

Changes:
- Backend (mention.go): Switch to .+? (non-greedy) anchored on
  ](mention:// to correctly match labels with brackets
- Frontend (mention-extension.ts): Same regex fix in tokenizer, plus
  escape [ and ] in renderMarkdown to prevent creating ambiguous
  markdown syntax
- Add comprehensive tests for ParseMentions covering bracket names

Fixes #1991

* fix: add optional chaining for match group access

Fixes TS2532: Object is possibly 'undefined' on match[1] when calling
.replace() in the mention tokenizer.

* fix: tighten mention tokenizer to reject ordinary Markdown links

- Replace .+? with (?:\\.|[^\]])+  in start() and tokenize() regexes
  so the label cannot cross a ]( Markdown link boundary
- Escaped brackets (\[ \]) from renderMarkdown() are still accepted
- Add frontend tokenizer/serializer round-trip tests:
  - Plain mention
  - Escaped brackets (David[TF]) round-trip
  - Normal Markdown link + mention on same line (regression)
  - Multiple links before mention
  - Nested brackets (Bot[v2][beta])
  - Issue mentions without @ prefix

Addresses review feedback on #1992.

* fix: add type assertions for tiptap MarkdownTokenizer interface in tests

The tiptap MarkdownTokenizer type allows start to be string | function
and tokenize to accept 3 arguments. Our extension always provides
single-arg functions, so cast them for TypeScript satisfaction.

Fixes CI typecheck failure in @multica/views package.

* fix: cast renderMarkdown to single-arg shape and reset file modes to 0644
2026-05-03 19:39:26 +08:00
ayakabot
a039c4d803 feat(quick-create): add preset issue fields (#2002)
Fixed: #2001
2026-05-03 19:37:12 +08:00
Bohan Jiang
cf0d58ab50 docs(changelog): add 0.2.24 entry covering 0.2.22 → 0.2.23 → today (#2028)
Folds together everything that landed since the last public changelog
entry (0.2.21) into one 0.2.24 release note: repo checkout --ref,
agent avatar CLI, Hermes per-turn gate, multi-replica model picker
on Redis, Inbox long-timeline perf, and the rest of the smaller fixes
queued for tonight's release.

en.ts and zh.ts both updated.

Co-authored-by: multica-agent <github@multica.ai>
2026-05-03 12:39:00 +08:00
furtherref
3fe3b84981 fix: hydrate agent cache after create (#2027)
(cherry picked from commit 0ea425c6e4)
2026-05-03 12:25:05 +08:00
Bohan Jiang
c4352da126 fix(daemon): drain background repo syncs before test teardown (#2026)
TestRegisterTaskReposSurvivesWorkspaceRefresh started flaking on CI
after #1988 (`feat: support repo checkout ref selection`) extended the
bare-clone path to run an extra `git fetch` to backfill
refs/remotes/origin/* under the new refspec layout. The race was
already latent: registerTaskRepos kicks off `go syncWorkspaceRepos(...)`
to clone a repo into the cache root, which in tests is `t.TempDir()`.
Once the test waited on `repoCache.Lookup` to return a path it would
proceed and return — but the bg goroutine was still inside
`ensureRemoteTrackingLayout` running git operations on the clone dir.
`t.TempDir`'s cleanup then races with those git commands and surfaces
either as "directory not empty" or "fatal: cannot change to ... No such
file or directory", with no hint that the failure is unrelated to the
test's actual assertion.

Track the background goroutine on the Daemon via a sync.WaitGroup and
expose `waitBackgroundSyncs()` for tests. `newRepoReadyTestDaemon`
registers a t.Cleanup that calls it, so every test that uses the
helper now drains in-flight syncs before t.TempDir cleanup runs. No
production-behavior change — registerTaskRepos still fires-and-forgets
from the caller's perspective.

Verified with `go test ./internal/daemon -run
TestRegisterTaskReposSurvivesWorkspaceRefresh -count=30` (was failing
within ~10 iterations before, 30 green after) and the full
`go test ./internal/daemon/...` suite.

Co-authored-by: multica-agent <github@multica.ai>
2026-05-03 12:24:56 +08:00
Bohan Jiang
d0c66f3173 perf(issue-detail): memoize timeline render to mitigate Inbox long-timeline freeze (#2025)
* perf(issue-detail): memoize timeline render to fix Inbox long-timeline freeze

On long-timeline issues (thousands of comments), opening from Inbox hard-freezes
the browser tab because every WS-driven parent re-render re-runs the full
react-markdown + rehype-* + lowlight pipeline for every comment. This is the
S3 mitigation for multica#1968:

- Wrap ReadonlyContent in React.memo so equal-content re-renders skip the
  markdown pipeline entirely (the dominant cost per comment).
- Wrap CommentCard in React.memo so unrelated parent state updates don't
  re-render every card.
- useMemo the timeline grouping in IssueDetail so the allReplies Map and
  groups array references are stable across re-renders that don't change
  timeline.
- Stabilize toggleReaction via a timelineRef so its identity doesn't change
  on every WS event, which previously defeated CommentCard memoization.

Virtualization (S2) is the root fix for first-paint cost and lands separately.

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

* fix(issue-detail): destructure mutate/mutateAsync so CommentCard memo holds

Per review on PR #2025: TanStack Query v5 returns a fresh result wrapper
from useMutation on every render, with only the inner mutate / mutateAsync
functions guaranteed stable. The previous useCallback dependencies listed
the whole mutation object, so on every parent re-render the callbacks
flipped identity — defeating React.memo on CommentCard and leaving the
long-timeline mitigation only half-effective.

Pull just the stable handles into deps. Add a renderHook-based regression
test that re-renders useIssueTimeline twice and asserts the four callbacks
passed to CommentCard keep the same identity.

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

---------

Co-authored-by: multica-agent <github@multica.ai>
2026-05-03 11:57:30 +08:00
Bohan Jiang
170fa2102b fix(agent/hermes): wire streamingCurrentTurn gate to drop history replay (#2024)
Hermes ACP can flush queued session updates from the previous turn
before the current turn actually starts — both as session/resume
history replay and as chunks queued before our session/prompt response
streams. Without a gate those updates were appended to output and
re-emitted to the UI, so the previous answer appeared duplicated next
to the new one. Closes #1997.

PR #1789 added the acceptNotification hook field to hermesClient and
the call site in handleNotification, but never assigned it for Hermes,
so the guard short-circuited and every notification was processed.
This change mirrors the working Kiro pattern (kiro.go:87/97/240):

  - declare a streamingCurrentTurn atomic.Bool in the backend.
  - assign acceptNotification, onMessage, onPromptDone gates that all
    return early when the flag is false.
  - flip the flag to true immediately before c.request("session/prompt").

Adds TestHermesClientAcceptNotificationGate as a regression test that
exercises the gate directly on hermesClient.

Verified with `go test ./pkg/agent`.

Co-authored-by: multica-agent <github@multica.ai>
2026-05-03 11:43:36 +08:00
Bohan Jiang
a414a00b4a refactor(repocache): clarify resolveBaseRef comment and cover tag refs (#2023)
Follow-up nits from PR #1988 review:

- Move the comment that documents getRemoteDefaultBranch's resolution
  walk into the resolveBaseRef call site description, and rephrase the
  "" branch so it's clear that path only fires for the default-branch
  case (the requested-ref path returns an explicit error before
  reaching it).
- Add TestCreateWorktreeWithRequestedTagRef to lock in the
  refs/tags/<ref> candidate. The test tags the initial commit, advances
  the default branch past it, then asserts the worktree HEAD matches
  the tagged commit (so the tag must have been resolved, not the
  default branch).

Co-authored-by: multica-agent <github@multica.ai>
2026-05-03 11:30:25 +08:00
Prince Pal
862b0509df feat: support repo checkout ref selection (#1988) 2026-05-03 11:27:16 +08:00
Bohan Jiang
ba5b7db78e fix(server): persist ModelListStore across replicas via Redis (#2022)
* fix(server): persist ModelListStore across replicas via Redis

The model picker uses a pending-request pattern: the frontend POSTs to
create a request, the daemon pops it on its next heartbeat, runs
agent.ListModels locally, and reports back. Until now the store was a
plain in-memory map per Handler instance.

That works for self-hosted single-instance deploys but fails in any
multi-replica environment (Multica Cloud). Each replica has its own
map, so:

  POST /runtimes/:id/models               → request stored in replica A
  GET  /runtimes/:id/models/<requestId>   → polls land on B/C → 404
  daemon heartbeat                        → only A sees PendingModelList
  POST .../<requestId>/result             → daemon's report has to land on A

Success probability ~1/N². The visible symptom is "No models available"
in the picker for every provider, even those (Claude/Codex) whose
catalog is statically populated end-to-end.

Same shape of bug, same Redis-backed fix as multica-ai/multica#1557 did
for LocalSkillListStore / LocalSkillImportStore. Reuse the operational
playbook (namespaced keys, ZSET-backed pending queue, atomic
ZREM+SET-running via the shared Lua script) so we don't introduce a
second concurrency model for the same primitive.

Changes:
- Convert ModelListStore from struct to interface with context-aware
  methods. Add HasPending for cheap heartbeat-side probing.
- InMemoryModelListStore — single-node fallback, used when REDIS_URL
  is unset (self-hosted dev / tests).
- RedisModelListStore — multi-node implementation using the same key
  layout and Lua atomic claim as RedisLocalSkillListStore.
- Use RunStartedAt (not UpdatedAt) as the running-timeout reference
  point, matching the local-skill stores so subsequent UpdatedAt
  bumps don't reset the running clock.
- Heartbeat now uses the probe-then-pop pattern for the model queue
  (matching local-skills) so a slow Redis can't stall every connected
  daemon. Extends heartbeatMetrics + slow-log with probe_model_ms /
  pop_model_ms / probe_model_timed_out for parity.
- Wire the Redis backend in NewRouterWithOptions when rdb != nil.
- Tests for both backends. Redis tests gate on REDIS_TEST_URL so
  laptop runs without Redis still pass; CI provides it.

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

* fix(server): persist RunStartedAt + retry model report on transient failures

Two follow-ups from PR #2022 review:

1. RedisModelListStore was dropping ModelListRequest.RunStartedAt on
   persistence — the field is tagged json:"-" so it doesn't leak into
   the HTTP response, which made plain json.Marshal(req) silently
   discard it. Across-node readers saw RunStartedAt=nil and
   applyModelListTimeout's running branch became a no-op, so the 60s
   running-timeout escape hatch never fired. CI's
   TestRedisModelListStore_RunningTimeout was failing on this exact
   case. Fix mirrors RedisLocalSkillImportStore's envelope pattern —
   wrap in an internal struct that re-promotes the field. HTTP shape
   stays clean. Adds a no-Redis unit test that pins the round trip.

2. Daemon's handleModelList called d.client.ReportModelListResult
   directly and swallowed any 5xx, leaving the pending request
   stranded in "running" until its 60s server-side timeout — exactly
   the failure mode the multi-node store fix was meant to eliminate.
   Generalize the existing local-skill retry helper into
   reportRuntimeResultWithRetry (kind: model_list / local_skill_list /
   local_skill_import) and wire handleModelList through a new
   reportModelListResult helper. Renames the test-overridable
   var localSkillReportBackoffs → runtimeReportBackoffs to match.

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

---------

Co-authored-by: multica-agent <github@multica.ai>
2026-05-03 11:13:34 +08:00
Bohan Jiang
3f046d03f7 fix(agent): expose GPT-5.5 family in Codex runtime model picker (#2020)
Latest Codex CLI ships with GPT-5.5 / GPT-5.5 mini, but the static
catalog still topped out at GPT-5.4 so users couldn't pick the new
model from the agent picker.

Add gpt-5.5 + gpt-5.5-mini to codexStaticModels and promote 5.5 as
the default badge. Keep the older 5.4 / 5.3-codex / gpt-5 / o3
entries for users on older Codex CLI builds. Add a regression test
mirroring TestGeminiStaticModelsExposesAliasesAndGemini3 so the
next OpenAI release isn't a silent miss.

Co-authored-by: multica-agent <github@multica.ai>
2026-05-03 11:12:51 +08:00
Bohan Jiang
e665b597b3 refactor(server): polish runtime-guard nits from PR #1905 review (#2021)
- Expand chat-resume comment in ClaimTaskByRuntime to spell out *why* the
  task-row fallback exists (single failed turn must not drop chat memory)
  and that it covers more than just legacy NULL rows.
- Replace the sessionRuntimeID := t.RuntimeID; sessionRuntimeID.Valid = ...
  pattern in CompleteTask/FailTask with a clearer var-then-assign that makes
  the "no session_id, leave runtime_id alone" coupling obvious.
- Add TestClaimTask_ChatLegacyNullRuntimeFallsBackToTaskRow covering the
  case the prior PR's tests didn't reach: chat_session.runtime_id IS NULL
  (legacy / unbackfilled) plus a matching-runtime task row, fallback
  should resume. This is the dominant post-migration shape and was
  previously only covered transitively.

No behavior change beyond the new test; runtime-guard semantics stay
identical to PR #1905.

Co-authored-by: multica-agent <github@multica.ai>
2026-05-03 11:00:32 +08:00
matthewcorven
075a845d9a docs: include GitHub Copilot CLI in root agent listings (#1983)
Copilot's backend (server/pkg/agent/copilot.go) and the public docs
site (apps/docs/) already treat it as one of the 11 supported agents,
but the root README, CLI guide, and self-host docs still listed only
10. Bring those to parity. Also brings README.zh-CN.md up to current
English content (was missing Copilot, Kimi, and Kiro CLI).
2026-05-03 10:59:09 +08:00
Bohan Jiang
972c65dbc1 fix(cli): make multica login --token accept the PAT as a value (#2017)
* fix(cli): make `multica login --token` accept the PAT as a value

The flag was registered as a Bool, so `multica login --token <PAT>` parsed
`--token` as `true` and dropped the supplied value as an unused positional
argument, then unconditionally prompted "Enter your personal access token:".
This contradicted the user-facing docs (`cli.mdx`, `CLI_AND_DAEMON.md`,
the in-app `connect-remote-dialog`) which show `--token <mul_...>`.

Switch `--token` to a String flag. Both `--token mul_...` and
`--token=mul_...` now bind the value and skip the prompt. Passing
`--token=` with an empty value (or `multica login --token=""`) still
falls through to the interactive prompt for users who don't want the
token in shell history. Updates the few internal docs that showed the
no-value form.

Fixes #1994

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

* fix(cli): preserve `multica login --token` (no value) prompt path and tighten regression test

Addresses review feedback on #2017:

1. Restore the legacy no-value form. After the prior commit, `multica
   login --token` (no value) errored with `flag needs an argument:
   --token`, which broke the CLI_INSTALL.md / CLI_AND_DAEMON.md flow for
   headless users. Set `NoOptDefVal` on the `--token` flag to a sentinel
   that runAuthLoginToken treats as "prompt me," so:
     - `--token mul_xxx` and `--token=mul_xxx` consume the value (the
       #1994 fix is preserved),
     - `--token` alone falls through to the interactive prompt,
     - `--token=""` (explicit empty) also prompts.
   pflag with `NoOptDefVal` won't bind the next positional as the flag's
   value, so runAuthLogin recovers `--token mul_xxx` (the form from
   #1994) by promoting a single positional arg into the token. loginCmd
   gains `Args: cobra.MaximumNArgs(1)` so multi-positional typos still
   error fast.

2. Tighten regression coverage. Split into TestLoginTokenFlagWiring
   (asserts the production loginCmd.Flags().Lookup("token") is a String
   flag with the prompt-mode NoOptDefVal — would fail if anyone reverts
   the flag to Bool) and TestLoginTokenFlagParsing (drives all five
   documented invocation forms through the same flag wiring + the
   runAuthLogin space-form recovery). The synthetic-only test that the
   reviewer flagged is gone.

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

---------

Co-authored-by: multica-agent <github@multica.ai>
2026-05-03 10:53:06 +08:00
Multica Eve
f85b7cce91 fix: make CLI update completion status reliable (#2018)
Co-authored-by: Devv <devv@Devvs-Mac-mini.local>
Co-authored-by: multica-agent <github@multica.ai>
2026-05-03 10:52:14 +08:00
Bright Zheng
cf47d9b702 fix: guard session resume by runtime (#1905) 2026-05-03 10:51:31 +08:00
Bright Zheng
c2f199650a feat(cli): add agent avatar upload command (#1760)
* feat(cli): add UploadFileWithURL and AttachmentResponse to APIClient

* feat(cli): add agent avatar command and show avatar_url in agent get output

* fix(server): include id and url in no-workspace file upload response

* fix(cli): remove dead HTTPClient timeout swap, extend ctx to 60s for avatar upload

The 30s context deadline was tighter than the 60s HTTPClient timeout
swap, so the swap was dead code and did nothing for slow connections.
Both Neo and Omni Mentor flagged this in review.

Fix: extend the command context to 60s and remove the HTTPClient
mutation. This is simpler, thread-safe, and actually works for slow
uploads.

* fix: align fallback upload response shape and honor context deadline

- file.go: fallback returns {id, url, filename} instead of {filename, link},
  matching the no-workspace path response shape.
- client.go UploadFileWithURL: tolerate empty attachment ID (S3 succeeded
  but DB record failed — the file is still usable via its URL).
- client.go UploadFileWithURL: use a context-deadline-aware HTTP client so
  that the 60s upload timeout set by the avatar command actually takes
  effect instead of being shadowed by the default 15s client timeout.
- client_test.go: update 'missing id' test to verify empty-id success
  (fallback tolerance).

* fix(cli): shallow-copy HTTP client to preserve Transport on upload timeout

When the context deadline exceeds the default 15s HTTP client timeout,
UploadFileWithURL was creating a bare &http.Client{Timeout: remaining},
silently dropping any custom Transport, Jar, or CheckRedirect configured
on the original client. This causes obscure connection failures when the
CLI uses an authenticated proxy, custom TLS, or mock transport in tests.

Fix: perform a shallow copy of the original client struct and only
mutate the Timeout field on the copy.
2026-05-03 10:49:02 +08:00
Jiayuan Zhang
3df95c84b8 fix(daemon): add safe.directory=* to gitEnv to fix CI dubious ownership errors (#1980)
* fix(daemon): add safe.directory=* to gitEnv to fix CI dubious ownership errors

TestRegisterTaskReposAllowsProjectOnlyURL and
TestRegisterTaskReposSurvivesWorkspaceRefresh fail on GitHub Actions CI
because git clone --bare from local temp directories triggers git's
safe.directory ownership check when the runner UID differs from the
directory owner.

Set safe.directory=* via GIT_CONFIG env vars in gitEnv() so all daemon
git subprocesses trust any directory. The daemon manages its own bare
caches and worktrees, so the ownership check provides no security value.

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

* fix(daemon): preserve existing GIT_CONFIG_* entries in gitEnv

Instead of resetting GIT_CONFIG_COUNT to 1, read the existing count
from the environment and append safe.directory at the next available
index. This preserves any env-scoped git config (auth, URL rewrites,
extra headers) injected into the daemon process.

Adds TestGitEnvPreservesExistingConfig to verify the append behavior.

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

---------

Co-authored-by: multica-agent <github@multica.ai>
2026-05-01 16:18:58 +02:00
Jiayuan Zhang
050a2f0a5b fix(views): preserve kanban display settings when dragging issues (#1971)
Dragging an issue between kanban columns was forcefully switching the
sort mode to "position" (manual), resetting any user-chosen display
settings like sorting by title. Remove the auto-switch so the sort
preference is preserved across drag operations.

Fixes multica-ai/multica#1960

Co-authored-by: multica-agent <github@multica.ai>
2026-05-01 15:55:01 +02:00
Jiayuan Zhang
374f62be13 feat(inbox): remove redundant mark-as-done hover button, add archive button for done tasks (#1970)
Remove the "mark as done" hover button from inbox list items since it
duplicates the one in the issue detail header. For done tasks, show an
archive button in the issue detail header instead.

Co-authored-by: multica-agent <github@multica.ai>
2026-05-01 09:19:15 +02:00
474 changed files with 29421 additions and 5713 deletions

View File

@@ -63,6 +63,9 @@ GOOGLE_CLIENT_SECRET=
GOOGLE_REDIRECT_URI=http://localhost:3000/auth/callback
# S3 / CloudFront
# S3_BUCKET — bucket NAME only (e.g. "my-bucket"). Do NOT include the
# ".s3.<region>.amazonaws.com" suffix; the server builds the public URL
# from S3_BUCKET + S3_REGION. S3_REGION must match the bucket's real region.
S3_BUCKET=
S3_REGION=us-west-2
CLOUDFRONT_KEY_PAIR_ID=

View File

@@ -40,6 +40,8 @@ Closes #
- [ ] I have added or updated tests where applicable
- [ ] If this change affects the UI, I have included before/after screenshots
- [ ] I have updated relevant documentation to reflect my changes
- [ ] If I added a new runtime / coding tool / UI tab, I synced the change to **landing copy** (`apps/web/features/landing/i18n/`), **starter-content** (`packages/views/onboarding/utils/starter-content-content-*.ts`), and **relevant docs** (`apps/docs/content/docs/`)
- [ ] If this PR touches Chinese product copy, I checked it against `apps/docs/content/docs/developers/conventions.zh.mdx` (terminology, mixed-rule for `task` / `issue` / `skill`)
- [ ] I have considered and documented any risks above
- [ ] I will address all reviewer comments before requesting merge

View File

@@ -29,8 +29,8 @@ jobs:
- name: Install dependencies
run: pnpm install
- name: Build, type check, and test
run: pnpm exec turbo build typecheck test --filter='!@multica/docs'
- name: Build, type check, lint, and test
run: pnpm exec turbo build typecheck lint test --filter='!@multica/docs'
backend:
runs-on: ubuntu-latest

View File

@@ -2,6 +2,21 @@
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
## Conventions reference
The single source of truth for **code naming, the i18n translation glossary, and the Chinese voice guide** is the docs site:
- **`apps/docs/content/docs/developers/conventions.mdx`** (English)
- **`apps/docs/content/docs/developers/conventions.zh.mdx`** (Chinese)
Read that page before:
- Writing or editing translations (`packages/views/locales/`)
- Naming a new route, package, file, DB column, or TS type
- Writing Chinese product copy (UI strings, error messages, docs)
The legacy `packages/views/locales/glossary.md` is now a stub redirecting to the docs page; do not rely on it.
## Project Context
Multica is an AI-native task management platform — like Linear, but with AI agents as first-class citizens.

View File

@@ -70,10 +70,10 @@ Opens your browser for OAuth authentication, creates a 90-day personal access to
### Token Login
```bash
multica login --token
multica login --token <mul_...>
```
Authenticate by pasting a personal access token directly. Useful for headless environments.
Authenticate using a personal access token directly. Useful for headless environments. Pass `--token=` with an empty value to be prompted interactively (so the token never lands in shell history).
### Check Status
@@ -140,6 +140,7 @@ The daemon auto-detects these AI CLIs on your PATH:
|-----|---------|-------------|
| [Claude Code](https://docs.anthropic.com/en/docs/claude-code) | `claude` | Anthropic's coding agent |
| [Codex](https://github.com/openai/codex) | `codex` | OpenAI's coding agent |
| [GitHub Copilot CLI](https://docs.github.com/en/copilot) | `copilot` | GitHub's coding agent (model routed by your GitHub entitlement) |
| OpenCode | `opencode` | Open-source coding agent |
| OpenClaw | `openclaw` | Open-source coding agent |
| Hermes | `hermes` | Nous Research coding agent |
@@ -201,6 +202,8 @@ Agent-specific overrides:
| `MULTICA_CODEX_PATH` | Custom path to the `codex` binary |
| `MULTICA_CODEX_MODEL` | Override the Codex model used |
| `MULTICA_CODEX_ARGS` | Default extra arguments for Codex runs |
| `MULTICA_COPILOT_PATH` | Custom path to the `copilot` binary |
| `MULTICA_COPILOT_MODEL` | Override the Copilot model used (note: GitHub Copilot routes models through your account entitlement, so this may not be honoured) |
| `MULTICA_OPENCODE_PATH` | Custom path to the `opencode` binary |
| `MULTICA_OPENCODE_MODEL` | Override the OpenCode model used |
| `MULTICA_OPENCLAW_PATH` | Custom path to the `openclaw` binary |
@@ -302,10 +305,11 @@ multica workspace members <workspace-id>
multica issue list
multica issue list --status in_progress
multica issue list --priority urgent --assignee "Agent Name"
multica issue list --assignee-id 5fb87ac7-23b5-4a7a-81fa-ed295a54545d
multica issue list --limit 20 --output json
```
Available filters: `--status`, `--priority`, `--assignee`, `--project`, `--limit`.
Available filters: `--status`, `--priority`, `--assignee` / `--assignee-id`, `--project`, `--limit`. Use `--assignee-id <uuid>` for unambiguous filtering when names overlap.
### Get Issue
@@ -318,9 +322,10 @@ multica issue get <id> --output json
```bash
multica issue create --title "Fix login bug" --description "..." --priority high --assignee "Lambda"
multica issue create --title "Fix login bug" --assignee-id 5fb87ac7-23b5-4a7a-81fa-ed295a54545d
```
Flags: `--title` (required), `--description`, `--status`, `--priority`, `--assignee`, `--parent`, `--project`, `--due-date`.
Flags: `--title` (required), `--description`, `--status`, `--priority`, `--assignee` / `--assignee-id`, `--parent`, `--project`, `--due-date`. Pass `--assignee-id <uuid>` (mutually exclusive with `--assignee`) when scripting against the IDs returned by `multica workspace members --output json` / `multica agent list --output json`.
### Update Issue
@@ -332,9 +337,12 @@ multica issue update <id> --title "New title" --priority urgent
```bash
multica issue assign <id> --to "Lambda"
multica issue assign <id> --to-id 5fb87ac7-23b5-4a7a-81fa-ed295a54545d
multica issue assign <id> --unassign
```
Pass `--to-id <uuid>` to assign by canonical UUID (mutually exclusive with `--to`); useful when names overlap across members and agents.
### Change Status
```bash

View File

@@ -140,7 +140,7 @@ multica auth status
Expected output should show the authenticated user and server URL.
**If login fails:**
- If no browser is available (headless environment), the user can generate a Personal Access Token at `https://app.multica.ai/settings` and run: `multica login --token`
- If no browser is available (headless environment), the user can generate a Personal Access Token at `https://app.multica.ai/settings` and run: `multica login --token <mul_...>` (use `--token=` with an empty value to be prompted interactively).
- If the server URL needs to be customized: `multica config set server_url <url>` before logging in.
---
@@ -166,12 +166,12 @@ Wait 3 seconds, then verify:
multica daemon status
```
Expected output should show `running` status with detected agents (e.g. `claude`, `codex`, `opencode`, `openclaw`, `hermes`, `gemini`, `pi`, `cursor-agent`).
Expected output should show `running` status with detected agents (e.g. `claude`, `codex`, `copilot`, `opencode`, `openclaw`, `hermes`, `gemini`, `pi`, `cursor-agent`).
**If daemon fails to start:**
- Check logs: `multica daemon logs`
- If a port conflict occurs, the daemon may already be running under a different profile.
- If no agents are detected, ensure at least one AI CLI (`claude`, `codex`, `opencode`, `openclaw`, `hermes`, `gemini`, `pi`, or `cursor-agent`) is installed and on the `$PATH`.
- If no agents are detected, ensure at least one AI CLI (`claude`, `codex`, `copilot`, `opencode`, `openclaw`, `hermes`, `gemini`, `pi`, or `cursor-agent`) is installed and on the `$PATH`.
---
@@ -185,12 +185,12 @@ multica daemon status
Confirm:
1. Status is `running`
2. At least one agent is listed (e.g. `claude`, `codex`, `opencode`, `openclaw`, `hermes`, `gemini`, `pi`, or `cursor-agent`)
2. At least one agent is listed (e.g. `claude`, `codex`, `copilot`, `opencode`, `openclaw`, `hermes`, `gemini`, `pi`, or `cursor-agent`)
3. At least one workspace is being watched
If the agents list is empty, tell the user:
> "The Multica daemon is running but no AI agent CLIs were detected. Please install at least one supported CLI (`claude`, `codex`, `opencode`, `openclaw`, `hermes`, `gemini`, `pi`, or `cursor-agent`), then restart the daemon with `multica daemon stop && multica daemon start`."
> "The Multica daemon is running but no AI agent CLIs were detected. Please install at least one supported CLI (`claude`, `codex`, `copilot`, `opencode`, `openclaw`, `hermes`, `gemini`, `pi`, or `cursor-agent`), then restart the daemon with `multica daemon stop && multica daemon start`."
---

View File

@@ -30,7 +30,7 @@ Turn coding agents into real teammates — assign tasks, track progress, compoun
Multica turns coding agents into real teammates. Assign issues to an agent like you'd assign to a colleague — they'll pick up the work, write code, report blockers, and update statuses autonomously.
No more copy-pasting prompts. No more babysitting runs. Your agents show up on the board, participate in conversations, and compound reusable skills over time. Think of it as open-source infrastructure for managed agents — vendor-neutral, self-hosted, and designed for human + AI teams. Works with **Claude Code**, **Codex**, **OpenClaw**, **OpenCode**, **Hermes**, **Gemini**, **Pi**, **Cursor Agent**, **Kimi**, and **Kiro CLI**.
No more copy-pasting prompts. No more babysitting runs. Your agents show up on the board, participate in conversations, and compound reusable skills over time. Think of it as open-source infrastructure for managed agents — vendor-neutral, self-hosted, and designed for human + AI teams. Works with **Claude Code**, **Codex**, **GitHub Copilot CLI**, **OpenClaw**, **OpenCode**, **Hermes**, **Gemini**, **Pi**, **Cursor Agent**, **Kimi**, and **Kiro CLI**.
<p align="center">
<img src="docs/assets/hero-screenshot.png" alt="Multica board view" width="800">
@@ -110,7 +110,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`, `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`) on your PATH.
### 2. Verify your runtime
@@ -120,7 +120,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, 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, or Kiro CLI). Give your agent a name — this is how it will appear on the board, in comments, and in assignments.
### 4. Assign your first task
@@ -172,10 +172,9 @@ See the [CLI and Daemon Guide](CLI_AND_DAEMON.md) for the full command reference
┌──────┴───────┐
│ Agent Daemon │ runs on your machine
└──────────────┘ (Claude Code, Codex, OpenCode,
OpenClaw, Hermes, Gemini,
Pi, Cursor Agent, Kimi,
Kiro CLI)
└──────────────┘ (Claude Code, Codex, GitHub Copilot CLI,
OpenCode, OpenClaw, Hermes, Gemini,
Pi, Cursor Agent, Kimi, Kiro CLI)
```
| Layer | Stack |
@@ -183,7 +182,7 @@ See the [CLI and Daemon Guide](CLI_AND_DAEMON.md) for the full command reference
| Frontend | Next.js 16 (App Router) |
| Backend | Go (Chi router, sqlc, gorilla/websocket) |
| Database | PostgreSQL 17 with pgvector |
| Agent Runtime | Local daemon executing Claude Code, Codex, OpenClaw, OpenCode, Hermes, Gemini, Pi, Cursor Agent, Kimi, or Kiro CLI |
| Agent Runtime | Local daemon executing Claude Code, Codex, GitHub Copilot CLI, OpenClaw, OpenCode, Hermes, Gemini, Pi, Cursor Agent, Kimi, or Kiro CLI |
## Development

View File

@@ -30,7 +30,7 @@
Multica 将编码 Agent 变成真正的队友。像分配给同事一样分配给 Agent——它们会自主接手工作、编写代码、报告阻塞问题、更新状态。
不再需要复制粘贴 prompt不再需要盯着运行过程。你的 Agent 出现在看板上、参与对话、随着时间积累可复用的技能。可以理解为开源的 Managed Agents 基础设施——厂商中立、可自部署、专为人类 + AI 团队设计。支持 **Claude Code**、**Codex**、**OpenClaw**、**OpenCode**、**Hermes**、**Gemini**、**Pi****Cursor Agent**
不再需要复制粘贴 prompt不再需要盯着运行过程。你的 Agent 出现在看板上、参与对话、随着时间积累可复用的技能。可以理解为开源的 Managed Agents 基础设施——厂商中立、可自部署、专为人类 + AI 团队设计。支持 **Claude Code**、**Codex**、**GitHub Copilot CLI**、**OpenClaw**、**OpenCode**、**Hermes**、**Gemini**、**Pi****Cursor Agent**、**Kimi** 和 **Kiro CLI**
<p align="center">
<img src="docs/assets/hero-screenshot.png" alt="Multica 看板视图" width="800">
@@ -111,7 +111,7 @@ multica setup # 连接 Multica Cloud登录启动 daemon
multica setup # 配置、认证、启动 daemon一条命令搞定
```
daemon 在后台运行,保持你的机器与 Multica 的连接。它会自动检测 PATH 中可用的 Agent CLI`claude``codex``openclaw``opencode``hermes``gemini``pi``cursor-agent`)。
daemon 在后台运行,保持你的机器与 Multica 的连接。它会自动检测 PATH 中可用的 Agent CLI`claude``codex``copilot``openclaw``opencode``hermes``gemini``pi``cursor-agent``kimi``kiro-cli`)。
### 2. 确认运行时已连接
@@ -121,7 +121,7 @@ daemon 在后台运行,保持你的机器与 Multica 的连接。它会自动
### 3. 创建 Agent
进入 **设置 → Agents**,点击 **新建 Agent**。选择你刚连接的 Runtime选择 ProviderClaude Code、Codex、OpenClaw、OpenCode、Hermes、Gemini、PiCursor Agent并为 Agent 起个名字——它将以这个名字出现在看板、评论和任务分配中。
进入 **设置 → Agents**,点击 **新建 Agent**。选择你刚连接的 Runtime选择 ProviderClaude Code、Codex、GitHub Copilot CLI、OpenClaw、OpenCode、Hermes、Gemini、PiCursor Agent、Kimi 或 Kiro CLI),并为 Agent 起个名字——它将以这个名字出现在看板、评论和任务分配中。
### 4. 分配你的第一个任务
@@ -154,9 +154,9 @@ daemon 在后台运行,保持你的机器与 Multica 的连接。它会自动
┌──────┴───────┐
│ Agent Daemon │ 运行在你的机器上
└──────────────┘ Claude Code、Codex、OpenCode
OpenClaw、Hermes、Gemini、
Pi、Cursor Agent
└──────────────┘ Claude Code、Codex、GitHub Copilot CLI
OpenCode、OpenClaw、Hermes、Gemini、
Pi、Cursor Agent、Kimi、Kiro CLI
```
| 层级 | 技术栈 |
@@ -164,7 +164,7 @@ daemon 在后台运行,保持你的机器与 Multica 的连接。它会自动
| 前端 | Next.js 16 (App Router) |
| 后端 | Go (Chi router, sqlc, gorilla/websocket) |
| 数据库 | PostgreSQL 17 with pgvector |
| Agent 运行时 | 本地 daemon 执行 Claude Code、Codex、OpenClaw、OpenCode、Hermes、Gemini、PiCursor Agent |
| Agent 运行时 | 本地 daemon 执行 Claude Code、Codex、GitHub Copilot CLI、OpenClaw、OpenCode、Hermes、Gemini、PiCursor Agent、Kimi 或 Kiro CLI |
## 开发

View File

@@ -92,6 +92,7 @@ brew install multica-ai/tap/multica
You also need at least one AI agent CLI installed:
- [Claude Code](https://docs.anthropic.com/en/docs/claude-code) (`claude` on PATH)
- [Codex](https://github.com/openai/codex) (`codex` on PATH)
- [GitHub Copilot CLI](https://docs.github.com/en/copilot) (`copilot` on PATH)
- [OpenClaw](https://github.com/openclaw/openclaw) (`openclaw` on PATH)
- [OpenCode](https://github.com/anomalyco/opencode) (`opencode` on PATH)
- [Hermes](https://github.com/NousResearch/hermes) (`hermes` on PATH)

View File

@@ -56,13 +56,15 @@ Changes take effect after restarting the backend / compose stack. The web UI rea
### File Storage (Optional)
For file uploads and attachments, configure S3 and CloudFront:
For file uploads and attachments, configure S3 and (optionally) CloudFront:
| Variable | Description |
|----------|-------------|
| `S3_BUCKET` | S3 bucket name |
| `S3_REGION` | AWS region (default: `us-west-2`) |
| `CLOUDFRONT_DOMAIN` | CloudFront distribution domain |
| `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 |
| `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) |
@@ -103,6 +105,8 @@ Agent-specific overrides:
| `MULTICA_CLAUDE_MODEL` | Override the Claude model used |
| `MULTICA_CODEX_PATH` | Custom path to the `codex` binary |
| `MULTICA_CODEX_MODEL` | Override the Codex model used |
| `MULTICA_COPILOT_PATH` | Custom path to the `copilot` (GitHub Copilot CLI) binary |
| `MULTICA_COPILOT_MODEL` | Override the Copilot model used (note: GitHub Copilot routes models through your account entitlement, so this may not be honoured) |
| `MULTICA_OPENCODE_PATH` | Custom path to the `opencode` binary |
| `MULTICA_OPENCODE_MODEL` | Override the OpenCode model used |
| `MULTICA_OPENCLAW_PATH` | Custom path to the `openclaw` binary |

View File

@@ -1,12 +0,0 @@
# Production environment for `pnpm package` / `pnpm build`.
# electron-vite (Vite under the hood) reads this automatically in
# production mode and inlines the values into the renderer bundle via
# import.meta.env.VITE_*. These are public URLs, not secrets.
# Backend API + websocket the desktop app talks to.
VITE_API_URL=https://api.multica.ai
VITE_WS_URL=wss://api.multica.ai/ws
# Public web app URL — used to build shareable links like "Copy link to
# issue" that users paste into Slack / messages. See platform/navigation.tsx.
VITE_APP_URL=https://multica.ai

View File

@@ -8,6 +8,8 @@ import { setupDaemonManager } from "./daemon-manager";
import { openExternalSafely } from "./external-url";
import { installContextMenu } from "./context-menu";
import { getAppVersion } from "./app-version";
import { loadRuntimeConfig } from "./runtime-config-loader";
import type { RuntimeConfigResult } from "../shared/runtime-config";
// Bundled icon used for dev-mode dock/taskbar branding. In production the
// app bundle icon (from electron-builder) wins; this path is only consumed
@@ -37,6 +39,10 @@ if (process.platform !== "win32") {
const PROTOCOL = "multica";
let mainWindow: BrowserWindow | null = null;
let runtimeConfigResult: RuntimeConfigResult = {
ok: false,
error: { message: "Runtime config has not loaded yet" },
};
// --- Deep link helpers ---------------------------------------------------
@@ -72,7 +78,25 @@ function handleDeepLink(url: string): void {
// --- Window creation -----------------------------------------------------
// Tracks the OS-preferred language as last seen by the running process.
// Updated on each window-focus check so we can emit a `locale:system-changed`
// event to the renderer when the user changes their OS language without
// quitting the app — without restart, app.getPreferredSystemLanguages()
// would still report the boot value forever.
let lastKnownSystemLocale = "en";
function getSystemLocale(): string {
return app.getPreferredSystemLanguages()[0] ?? "en";
}
function createWindow(): void {
// Pass the OS-preferred language to the renderer via additionalArguments
// instead of a sync IPC call. process.argv is available to the preload
// script before the first network request, so the renderer's i18next
// instance can initialize with the right locale on the very first paint.
const systemLocale = getSystemLocale();
lastKnownSystemLocale = systemLocale;
mainWindow = new BrowserWindow({
width: 1280,
height: 800,
@@ -89,6 +113,7 @@ function createWindow(): void {
preload: join(__dirname, "../preload/index.js"),
sandbox: false,
webSecurity: false,
additionalArguments: [`--multica-locale=${systemLocale}`],
},
});
@@ -106,6 +131,18 @@ function createWindow(): void {
mainWindow?.show();
});
// Detect OS language changes while the app is running. Electron has no
// dedicated event for this on any platform, so we poll on focus regain —
// catches the common case where users switch System Settings → Language
// and bring the app back. The renderer decides whether to act (it ignores
// the signal when the user has an explicit Settings choice).
mainWindow.on("focus", () => {
const current = getSystemLocale();
if (current === lastKnownSystemLocale) return;
lastKnownSystemLocale = current;
mainWindow?.webContents.send("locale:system-changed", current);
});
mainWindow.webContents.setWindowOpenHandler((details) => {
openExternalSafely(details.url);
return { action: "deny" };
@@ -187,7 +224,25 @@ if (!gotTheLock) {
if (deepLinkUrl) handleDeepLink(deepLinkUrl);
});
app.whenReady().then(() => {
app.whenReady().then(async () => {
const viteEnv = import.meta.env as ImportMetaEnv & {
readonly VITE_API_URL?: string;
readonly VITE_WS_URL?: string;
readonly VITE_APP_URL?: string;
};
runtimeConfigResult = await loadRuntimeConfig({
isDev: is.dev,
// electron-vite exposes VITE_* on import.meta.env for the main process;
// keep dev URL overrides on the same source the renderer used before
// runtime config moved endpoint resolution into main/preload.
env: {
apiUrl: viteEnv.VITE_API_URL,
wsUrl: viteEnv.VITE_WS_URL,
appUrl: viteEnv.VITE_APP_URL,
},
});
electronApp.setAppUserModelId(
is.dev ? "ai.multica.desktop.dev" : "ai.multica.desktop",
);
@@ -223,6 +278,13 @@ if (!gotTheLock) {
event.returnValue = { version: getAppVersion(), os };
});
// Sync IPC: preload exposes the validated runtime config before renderer
// boot. If desktop.json exists but is invalid, renderer receives the
// blocking error and must not silently fall back to the cloud defaults.
ipcMain.on("runtime-config:get", (event) => {
event.returnValue = runtimeConfigResult;
});
// IPC: toggle immersive mode — hides the macOS traffic lights so full-screen
// modals (e.g. create-workspace) can place UI in the top-left corner
// without fighting the native window controls' hit-test.

View File

@@ -0,0 +1,90 @@
import { mkdtemp, writeFile } from "fs/promises";
import { join } from "path";
import { tmpdir } from "os";
import { describe, expect, it } from "vitest";
import { loadRuntimeConfig } from "./runtime-config-loader";
describe("loadRuntimeConfig", () => {
it("uses dev env and ignores desktop.json during electron-vite dev", async () => {
const dir = await mkdtemp(join(tmpdir(), "multica-desktop-config-"));
const configPath = join(dir, "desktop.json");
await writeFile(
configPath,
JSON.stringify({ schemaVersion: 1, apiUrl: "https://prod.example.com" }),
);
await expect(
loadRuntimeConfig({
isDev: true,
configPath,
env: {
apiUrl: "http://localhost:8080",
wsUrl: "ws://localhost:8080/ws",
appUrl: "http://localhost:3000",
},
}),
).resolves.toEqual({
ok: true,
config: {
schemaVersion: 1,
apiUrl: "http://localhost:8080",
wsUrl: "ws://localhost:8080/ws",
appUrl: "http://localhost:3000",
},
});
});
it("uses cloud defaults when packaged config is absent", async () => {
const dir = await mkdtemp(join(tmpdir(), "multica-desktop-config-"));
await expect(
loadRuntimeConfig({
isDev: false,
configPath: join(dir, "missing.json"),
env: {},
}),
).resolves.toEqual({
ok: true,
config: {
schemaVersion: 1,
apiUrl: "https://api.multica.ai",
wsUrl: "wss://api.multica.ai/ws",
appUrl: "https://multica.ai",
},
});
});
it("parses a valid packaged desktop.json", async () => {
const dir = await mkdtemp(join(tmpdir(), "multica-desktop-config-"));
const configPath = join(dir, "desktop.json");
await writeFile(
configPath,
JSON.stringify({ schemaVersion: 1, apiUrl: "https://api.example.com" }),
);
await expect(
loadRuntimeConfig({ isDev: false, configPath, env: {} }),
).resolves.toEqual({
ok: true,
config: {
schemaVersion: 1,
apiUrl: "https://api.example.com",
wsUrl: "wss://api.example.com/ws",
appUrl: "https://api.example.com",
},
});
});
it("fails closed when packaged desktop.json is invalid", async () => {
const dir = await mkdtemp(join(tmpdir(), "multica-desktop-config-"));
const configPath = join(dir, "desktop.json");
await writeFile(configPath, "{");
const result = await loadRuntimeConfig({ isDev: false, configPath, env: {} });
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.error.message).toContain(configPath);
expect(result.error.message).toContain("Invalid desktop runtime config JSON");
}
});
});

View File

@@ -0,0 +1,60 @@
import { app } from "electron";
import { readFile } from "fs/promises";
import { join } from "path";
import {
DEFAULT_RUNTIME_CONFIG,
parseRuntimeConfig,
runtimeConfigFromDevEnv,
type RuntimeConfig,
type RuntimeConfigEnv,
type RuntimeConfigResult,
} from "../shared/runtime-config";
export async function loadRuntimeConfig(options: {
isDev: boolean;
env: RuntimeConfigEnv;
configPath?: string;
}): Promise<RuntimeConfigResult> {
if (options.isDev) {
try {
return { ok: true, config: runtimeConfigFromDevEnv(options.env) };
} catch (err) {
return { ok: false, error: { message: errorMessage(err) } };
}
}
const configPath = options.configPath ?? desktopConfigPath();
try {
const raw = await readFile(configPath, "utf-8");
return { ok: true, config: parseRuntimeConfig(raw) };
} catch (err) {
if (isMissingFileError(err)) {
return { ok: true, config: { ...DEFAULT_RUNTIME_CONFIG } };
}
return {
ok: false,
error: {
message: `Invalid ${configPath}: ${errorMessage(err)}`,
},
};
}
}
export function desktopConfigPath(): string {
return join(app.getPath("home"), ".multica", "desktop.json");
}
function isMissingFileError(err: unknown): boolean {
return Boolean(
err &&
typeof err === "object" &&
"code" in err &&
(err as NodeJS.ErrnoException).code === "ENOENT",
);
}
function errorMessage(err: unknown): string {
return err instanceof Error ? err.message : String(err);
}
export type { RuntimeConfig, RuntimeConfigResult };

View File

@@ -1,4 +1,5 @@
import { ElectronAPI } from "@electron-toolkit/preload";
import type { RuntimeConfigResult } from "../shared/runtime-config";
interface DesktopAPI {
/** App version + normalized OS, captured synchronously at preload time. */
@@ -6,6 +7,12 @@ interface DesktopAPI {
version: string;
os: "macos" | "windows" | "linux" | "unknown";
};
/** OS-preferred locale (BCP 47) injected by main via additionalArguments. */
systemLocale: string;
/** Subscribe to OS language changes detected after boot. Returns an unsubscribe function. */
onSystemLocaleChanged: (callback: (locale: string) => void) => () => void;
/** Validated runtime endpoint config, or a blocking config error. */
runtimeConfig: RuntimeConfigResult;
/** Listen for auth token delivered via deep link. Returns an unsubscribe function. */
onAuthToken: (callback: (token: string) => void) => () => void;
/** Listen for invitation IDs delivered via deep link. Returns an unsubscribe function. */

View File

@@ -1,5 +1,6 @@
import { contextBridge, ipcRenderer } from "electron";
import { electronAPI } from "@electron-toolkit/preload";
import type { RuntimeConfigResult } from "../shared/runtime-config";
// Synchronously fetch app metadata from main at preload time so the renderer
// can pass it into CoreProvider during the initial render — the alternative
@@ -21,12 +22,53 @@ function fetchAppInfo(): { version: string; os: "macos" | "windows" | "linux" |
return { version: "unknown", os };
}
function fetchRuntimeConfig(): RuntimeConfigResult {
try {
const result = ipcRenderer.sendSync("runtime-config:get") as RuntimeConfigResult | undefined;
if (result && typeof result === "object" && "ok" in result) return result;
} catch (err) {
return {
ok: false,
error: {
message: err instanceof Error ? err.message : String(err),
},
};
}
return { ok: false, error: { message: "Runtime config unavailable" } };
}
const appInfo = fetchAppInfo();
const runtimeConfig = fetchRuntimeConfig();
// Read the OS-preferred locale that main injected via additionalArguments.
// Zero IPC, zero blocking — process.argv is populated before preload runs.
function fetchSystemLocale(): string {
const arg = process.argv.find((a) => a.startsWith("--multica-locale="));
return arg?.split("=")[1] ?? "en";
}
const systemLocale = fetchSystemLocale();
const desktopAPI = {
/** App version + normalized OS. Read once at preload time so the renderer
* can use it synchronously when initializing the API client. */
appInfo,
/** OS-preferred locale (BCP 47), passed from main via additionalArguments.
* Used by the renderer's LocaleAdapter as the system-preference signal. */
systemLocale,
/** Subscribe to OS language changes detected after boot. The renderer
* decides whether to act (no-op when the user has an explicit Settings
* choice). Returns an unsubscribe function. */
onSystemLocaleChanged: (callback: (locale: string) => void) => {
const handler = (_event: Electron.IpcRendererEvent, locale: string) =>
callback(locale);
ipcRenderer.on("locale:system-changed", handler);
return () => {
ipcRenderer.removeListener("locale:system-changed", handler);
};
},
/** Validated runtime endpoint config, or a blocking config error. */
runtimeConfig,
/** Listen for auth token delivered via deep link */
onAuthToken: (callback: (token: string) => void) => {
const handler = (_event: Electron.IpcRendererEvent, token: string) =>

View File

@@ -1,6 +1,7 @@
import { useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
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 { workspaceKeys, workspaceListOptions } from "@multica/core/workspace/queries";
import { api } from "@multica/core/api";
@@ -15,6 +16,8 @@ import { UpdateNotification } from "./components/update-notification";
import { useTabStore } from "./stores/tab-store";
import { useWindowOverlayStore } from "./stores/window-overlay-store";
import { useDaemonIPCBridge } from "./platform/daemon-ipc-bridge";
import { createDesktopLocaleAdapter } from "./platform/i18n-adapter";
import { RESOURCES } from "@multica/views/locales";
function AppContent() {
@@ -30,11 +33,16 @@ function AppContent() {
// first render.
const [bootstrapping, setBootstrapping] = useState(false);
const runtimeConfig = window.desktopAPI.runtimeConfig.ok
? window.desktopAPI.runtimeConfig.config
: null;
// Tell the main process which backend URL we talk to, so daemon-manager
// can pick the matching CLI profile (server_url from ~/.multica config).
useEffect(() => {
window.daemonAPI.setTargetApiUrl(DAEMON_TARGET_API_URL);
}, []);
if (!runtimeConfig) return;
window.daemonAPI.setTargetApiUrl(runtimeConfig.apiUrl);
}, [runtimeConfig]);
// Listen for invite IDs delivered via deep link (multica://invite/<id>).
// We open the overlay regardless of login state — if the user isn't logged
@@ -226,9 +234,21 @@ function AppContent() {
);
}
// Backend the daemon should connect to — same URL the renderer talks to.
const DAEMON_TARGET_API_URL =
import.meta.env.VITE_API_URL || "http://localhost:8080";
function BlockingRuntimeConfigError({ message }: { message: string }) {
return (
<div className="flex h-screen items-center justify-center bg-background p-8 text-foreground">
<div className="max-w-xl rounded-lg border bg-card p-6 shadow-sm">
<h1 className="text-lg font-semibold">Desktop configuration error</h1>
<p className="mt-3 text-sm text-muted-foreground">
Multica Desktop could not load <code>~/.multica/desktop.json</code>. Fix or remove the file and restart the app.
</p>
<pre className="mt-4 whitespace-pre-wrap rounded-md bg-muted p-3 text-xs text-muted-foreground">
{message}
</pre>
</div>
</div>
);
}
// On logout, wipe desktop-only in-memory state and stop the daemon so that
// a subsequent login as a different user never inherits the previous user's
@@ -252,22 +272,61 @@ async function handleDaemonLogout() {
export default function App() {
const { version, os } = window.desktopAPI.appInfo;
const systemLocale = window.desktopAPI.systemLocale;
const runtimeConfigResult = window.desktopAPI.runtimeConfig;
// Stable identity reference so downstream effects (WS reconnect) don't
// tear down on every parent render.
const identity = useMemo(
() => ({ platform: "desktop", version, os }),
[version, os],
);
// Locale resolution happens once at app boot. Switching language goes
// through window.location.reload() to avoid hydration mismatch.
const localeAdapter = useMemo(
() => createDesktopLocaleAdapter(systemLocale),
[systemLocale],
);
const locale = useMemo(() => pickLocale(localeAdapter), [localeAdapter]);
const resources = useMemo(
() => ({ [locale]: RESOURCES[locale] }),
[locale],
);
// React to OS-level language changes detected by main on focus regain.
// Only act when the user is following the system signal (no explicit
// Settings choice) — otherwise their preference wins. Cross-device sync
// for the explicit-choice case is handled inside CoreProvider.
useEffect(() => {
return window.desktopAPI.onSystemLocaleChanged((nextSystemLocale) => {
if (localeAdapter.getUserChoice()) return;
const next = pickLocale({
...localeAdapter,
getSystemPreferences: () =>
nextSystemLocale ? [nextSystemLocale] : [],
});
if (next === locale) return;
localeAdapter.persist(next);
window.location.reload();
});
}, [localeAdapter, locale]);
return (
<ThemeProvider>
<CoreProvider
apiBaseUrl={import.meta.env.VITE_API_URL || "http://localhost:8080"}
wsUrl={import.meta.env.VITE_WS_URL || "ws://localhost:8080/ws"}
onLogout={handleDaemonLogout}
identity={identity}
>
<AppContent />
</CoreProvider>
{runtimeConfigResult.ok ? (
<CoreProvider
apiBaseUrl={runtimeConfigResult.config.apiUrl}
wsUrl={runtimeConfigResult.config.wsUrl}
onLogout={handleDaemonLogout}
identity={identity}
locale={locale}
resources={resources}
localeAdapter={localeAdapter}
>
<AppContent />
</CoreProvider>
) : (
<BlockingRuntimeConfigError message={runtimeConfigResult.error.message} />
)}
<Toaster />
<UpdateNotification />
</ThemeProvider>

View File

@@ -0,0 +1,243 @@
import { describe, expect, it, vi, beforeEach } from "vitest";
import { render } from "@testing-library/react";
// vi.hoisted shared state — every store mock reads the same object so each
// test can mutate it then re-render to drive the tracker.
const state = vi.hoisted(() => ({
user: null as { id: string } | null,
overlay: null as { type: string; invitationId?: string } | null,
activeWorkspaceSlug: null as string | null,
byWorkspace: {} as Record<
string,
{ activeTabId: string; tabs: { id: string; path: string }[] }
>,
capturePageview: vi.fn<(path?: string) => void>(),
}));
vi.mock("@multica/core/analytics", () => ({
capturePageview: state.capturePageview,
}));
// Auth store — single selector pattern (`s => s.user`).
vi.mock("@multica/core/auth", () => {
const useAuthStore = (selector: (s: typeof state) => unknown) =>
selector(state);
return { useAuthStore };
});
// Window overlay store — same shape.
vi.mock("@/stores/window-overlay-store", () => {
const useWindowOverlayStore = (selector: (s: typeof state) => unknown) =>
selector(state);
return { useWindowOverlayStore };
});
// Tab store — selectors read activeWorkspaceSlug + byWorkspace. Also expose
// getState() for the seed pass and the helpers the tracker imports
// (useActiveTabIdentity, getActiveTab) so we don't have to re-import them
// from the real store inside a mocked module.
vi.mock("@/stores/tab-store", () => {
const useTabStore = Object.assign(
(selector: (s: typeof state) => unknown) => selector(state),
{ getState: () => state },
);
const getActiveTab = (s: typeof state) => {
const slug = s.activeWorkspaceSlug;
if (!slug) return null;
const group = s.byWorkspace[slug];
if (!group) return null;
return group.tabs.find((t) => t.id === group.activeTabId) ?? null;
};
const useActiveTabIdentity = () => ({
slug: state.activeWorkspaceSlug,
tabId: state.activeWorkspaceSlug
? (state.byWorkspace[state.activeWorkspaceSlug]?.activeTabId ?? null)
: null,
});
return { useTabStore, getActiveTab, useActiveTabIdentity };
});
import { PageviewTracker } from "./pageview-tracker";
function reset() {
state.user = { id: "u1" };
state.overlay = null;
state.activeWorkspaceSlug = null;
state.byWorkspace = {};
state.capturePageview.mockClear();
}
beforeEach(() => {
reset();
});
describe("PageviewTracker", () => {
it("suppresses pageview when switching to a previously-visible tab on its existing path", () => {
state.byWorkspace = {
acme: {
activeTabId: "tA",
tabs: [
{ id: "tA", path: "/acme/issues" },
{ id: "tB", path: "/acme/inbox" },
],
},
};
state.activeWorkspaceSlug = "acme";
const { rerender } = render(<PageviewTracker />);
// Initial mount on tA — seeded as observed, no pageview because both
// tabs were already in the persisted store before the tracker mounted.
expect(state.capturePageview).not.toHaveBeenCalled();
// Switch to tB (already-known tab on its already-known path).
state.byWorkspace = {
acme: {
activeTabId: "tB",
tabs: [
{ id: "tA", path: "/acme/issues" },
{ id: "tB", path: "/acme/inbox" },
],
},
};
rerender(<PageviewTracker />);
expect(state.capturePageview).not.toHaveBeenCalled();
// Switch back to tA — still no pageview.
state.byWorkspace = {
acme: {
activeTabId: "tA",
tabs: [
{ id: "tA", path: "/acme/issues" },
{ id: "tB", path: "/acme/inbox" },
],
},
};
rerender(<PageviewTracker />);
expect(state.capturePageview).not.toHaveBeenCalled();
});
it("fires pageview when a new tab is opened (openInNewTab / addTab)", () => {
state.byWorkspace = {
acme: {
activeTabId: "tA",
tabs: [{ id: "tA", path: "/acme/issues" }],
},
};
state.activeWorkspaceSlug = "acme";
const { rerender } = render(<PageviewTracker />);
state.capturePageview.mockClear();
// Simulate openInNewTab("/acme/agents") → new tab tC added and activated.
state.byWorkspace = {
acme: {
activeTabId: "tC",
tabs: [
{ id: "tA", path: "/acme/issues" },
{ id: "tC", path: "/acme/agents" },
],
},
};
rerender(<PageviewTracker />);
expect(state.capturePageview).toHaveBeenCalledTimes(1);
expect(state.capturePageview).toHaveBeenCalledWith("/acme/agents");
});
it("fires pageview when switchWorkspace opens a new path in another workspace", () => {
state.byWorkspace = {
acme: {
activeTabId: "tA",
tabs: [{ id: "tA", path: "/acme/issues" }],
},
};
state.activeWorkspaceSlug = "acme";
const { rerender } = render(<PageviewTracker />);
state.capturePageview.mockClear();
// Cross-workspace navigation: switchWorkspace("butter", "/butter/inbox")
// creates a fresh tab in the destination workspace and makes it active.
state.byWorkspace = {
acme: { activeTabId: "tA", tabs: [{ id: "tA", path: "/acme/issues" }] },
butter: {
activeTabId: "tD",
tabs: [{ id: "tD", path: "/butter/inbox" }],
},
};
state.activeWorkspaceSlug = "butter";
rerender(<PageviewTracker />);
expect(state.capturePageview).toHaveBeenCalledTimes(1);
expect(state.capturePageview).toHaveBeenCalledWith("/butter/inbox");
});
it("fires pageview on intra-tab navigation (path changes for the same tabId)", () => {
state.byWorkspace = {
acme: {
activeTabId: "tA",
tabs: [{ id: "tA", path: "/acme/issues" }],
},
};
state.activeWorkspaceSlug = "acme";
const { rerender } = render(<PageviewTracker />);
state.capturePageview.mockClear();
state.byWorkspace = {
acme: {
activeTabId: "tA",
tabs: [{ id: "tA", path: "/acme/issues/123" }],
},
};
rerender(<PageviewTracker />);
expect(state.capturePageview).toHaveBeenCalledTimes(1);
expect(state.capturePageview).toHaveBeenCalledWith("/acme/issues/123");
});
it("fires overlay and login pageviews and suppresses re-entry into the same tab afterward", () => {
state.byWorkspace = {
acme: {
activeTabId: "tA",
tabs: [{ id: "tA", path: "/acme/issues" }],
},
};
state.activeWorkspaceSlug = "acme";
const { rerender } = render(<PageviewTracker />);
state.capturePageview.mockClear();
// Open onboarding overlay.
state.overlay = { type: "onboarding" };
rerender(<PageviewTracker />);
expect(state.capturePageview).toHaveBeenLastCalledWith("/onboarding");
// Close overlay back to the tab — the tab is already observed on
// /acme/issues so this is a re-activation, no pageview.
state.capturePageview.mockClear();
state.overlay = null;
rerender(<PageviewTracker />);
expect(state.capturePageview).not.toHaveBeenCalled();
// Logout fires /login.
state.user = null;
rerender(<PageviewTracker />);
expect(state.capturePageview).toHaveBeenLastCalledWith("/login");
});
it("suppresses on initial mount when the active tab was restored from persistence", () => {
state.byWorkspace = {
acme: {
activeTabId: "tA",
tabs: [{ id: "tA", path: "/acme/issues" }],
},
};
state.activeWorkspaceSlug = "acme";
render(<PageviewTracker />);
// Restored tab — seeded, treated as a re-activation.
expect(state.capturePageview).not.toHaveBeenCalled();
});
});

View File

@@ -1,11 +1,17 @@
import { useEffect } from "react";
import { useEffect, useRef } from "react";
import { capturePageview } from "@multica/core/analytics";
import { useAuthStore } from "@multica/core/auth";
import { useTabStore } from "@/stores/tab-store";
import {
getActiveTab,
useActiveTabIdentity,
useTabStore,
} from "@/stores/tab-store";
import { useWindowOverlayStore, type WindowOverlay } from "@/stores/window-overlay-store";
/**
* Fires a PostHog $pageview whenever the user's visible surface changes.
* Fires a PostHog $pageview whenever the user's visible surface changes,
* EXCEPT for re-activations of an already-known tab on its already-known
* path.
*
* Desktop has three layers that can own the visible page:
*
@@ -17,10 +23,18 @@ import { useWindowOverlayStore, type WindowOverlay } from "@/stores/window-overl
* 3. Otherwise → the active tab's path (workspace-scoped, e.g.
* `/acme/issues/123`). Kept in sync by `useTabRouterSync`.
*
* The overlay takes precedence over the tab path because it is visually in
* front of the tab system; the logged-out state shadows both because the
* shell doesn't render at all yet. This keeps the `$pageview` stream aligned
* with what the user actually sees.
* Tab-switch suppression: re-activating an already-open tab surfaces a
* previously-visited path under a `(workspace, tabId)` we have already
* seen — the pageview was emitted when the user originally navigated
* there, so re-emitting on every switch just inflates PostHog billing
* without adding signal (real-data audit: desktop tab switches were
* ~50% of all `$pageview` events).
*
* Newly opened tabs (`openInNewTab`, `addTab`) and cross-workspace
* `switchWorkspace(slug, path)` to a previously-unseen tab still fire,
* because their key is not in the observed map yet. The map is seeded
* from the persisted tab store on first render so tabs restored from a
* previous session don't all re-emit on first activation.
*
* PostHog's `capture_pageview: true` auto-capture is intentionally off (see
* `initAnalytics`) so this component owns the event shape, matching the web
@@ -29,34 +43,75 @@ import { useWindowOverlayStore, type WindowOverlay } from "@/stores/window-overl
export function PageviewTracker() {
const user = useAuthStore((s) => s.user);
const overlay = useWindowOverlayStore((s) => s.overlay);
const activeTabPath = useTabStore((s) => {
const slug = s.activeWorkspaceSlug;
if (!slug) return null;
const group = s.byWorkspace[slug];
if (!group) return null;
return group.tabs.find((t) => t.id === group.activeTabId)?.path ?? null;
});
const { slug: activeWorkspaceSlug, tabId: activeTabId } = useActiveTabIdentity();
const activeTabPath = useTabStore((s) => getActiveTab(s)?.path ?? null);
const path = resolvePath(user, overlay, activeTabPath);
// (slug:tabId) → last path observed while that tab was visible. Lets us
// tell "re-activating a tab on a path we already saw" (suppress) apart
// from "newly opened tab" or "intra-tab navigation" (fire). Seeded
// synchronously on first render from the persisted tab store so
// session-restored tabs don't re-emit on first click.
const observedTabsRef = useRef<Map<string, string> | null>(null);
if (observedTabsRef.current === null) {
const seed = new Map<string, string>();
for (const [slug, group] of Object.entries(useTabStore.getState().byWorkspace)) {
for (const tab of group.tabs) {
seed.set(`${slug}:${tab.id}`, tab.path);
}
}
observedTabsRef.current = seed;
}
const lastSurfaceRef = useRef<{
kind: "login" | "overlay" | "tab" | null;
key: string | null;
path: string | null;
}>({ kind: null, key: null, path: null });
useEffect(() => {
if (!path) return;
let kind: "login" | "overlay" | "tab";
let path: string;
let key: string | null = null;
if (!user) {
kind = "login";
path = "/login";
} else if (overlay) {
kind = "overlay";
path = overlayPath(overlay);
} else if (activeTabPath && activeTabId && activeWorkspaceSlug) {
kind = "tab";
key = `${activeWorkspaceSlug}:${activeTabId}`;
path = activeTabPath;
} else {
return;
}
const observed = observedTabsRef.current!;
const last = lastSurfaceRef.current;
const next = { kind, key, path };
if (kind === "tab" && key !== null) {
const knownPath = observed.get(key);
const isReactivation =
last.key !== key && knownPath !== undefined && knownPath === path;
observed.set(key, path);
if (isReactivation) {
lastSurfaceRef.current = next;
return;
}
}
const unchanged =
last.kind === kind && last.key === key && last.path === path;
if (unchanged) return;
capturePageview(path);
}, [path]);
lastSurfaceRef.current = next;
}, [user, overlay, activeWorkspaceSlug, activeTabId, activeTabPath]);
return null;
}
function resolvePath(
user: unknown,
overlay: WindowOverlay | null,
activeTabPath: string | null,
): string | null {
if (!user) return "/login";
if (overlay) return overlayPath(overlay);
return activeTabPath;
}
function overlayPath(overlay: WindowOverlay): string {
switch (overlay.type) {
case "new-workspace":

View File

@@ -2,14 +2,23 @@ import { LoginPage } from "@multica/views/auth";
import { DragStrip } from "@multica/views/platform";
import { MulticaIcon } from "@multica/ui/components/common/multica-icon";
const WEB_URL = import.meta.env.VITE_APP_URL || "http://localhost:3000";
function requireRuntimeAppUrl(): string {
const runtimeConfig = window.desktopAPI.runtimeConfig;
if (!runtimeConfig.ok) {
throw new Error(
"Invariant violated: DesktopLoginPage rendered before App accepted runtime config",
);
}
return runtimeConfig.config.appUrl;
}
export function DesktopLoginPage() {
const webUrl = requireRuntimeAppUrl();
const handleGoogleLogin = () => {
// Open web login page in the default browser with platform=desktop flag.
// The web callback will redirect back via multica:// deep link with the token.
window.desktopAPI.openExternal(
`${WEB_URL}/login?platform=desktop`,
`${webUrl}/login?platform=desktop`,
);
};

View File

@@ -0,0 +1,31 @@
import type { LocaleAdapter, SupportedLocale } from "@multica/core/i18n";
const STORAGE_KEY = "multica-locale";
// Desktop adapter:
// - User choice: localStorage (set by Settings switcher).
// - System preference: locale main injected via additionalArguments
// (read from preload, exposed on window.desktopAPI.systemLocale).
// - Persist: localStorage. The Settings switcher additionally PATCHes
// /api/me when logged in so user.language follows the user across devices.
export function createDesktopLocaleAdapter(systemLocale: string): LocaleAdapter {
return {
getUserChoice() {
try {
return window.localStorage.getItem(STORAGE_KEY);
} catch {
return null;
}
},
getSystemPreferences() {
return systemLocale ? [systemLocale] : [];
},
persist(locale: SupportedLocale) {
try {
window.localStorage.setItem(STORAGE_KEY, locale);
} catch {
// Best-effort
}
},
};
}

View File

@@ -15,11 +15,15 @@ import {
} from "@/stores/tab-store";
import { useWindowOverlayStore } from "@/stores/window-overlay-store";
// Public web app URL — injected at build time via .env.production. In dev
// (no VITE_APP_URL set) falls back to the local web dev server so "Copy
// link" in a dev build yields a URL that points at the running dev
// frontend, not the prod host. Matches the fallback used in pages/login.tsx.
const APP_URL = import.meta.env.VITE_APP_URL || "http://localhost:3000";
function requireRuntimeAppUrl(scope: string): string {
const runtimeConfig = window.desktopAPI.runtimeConfig;
if (!runtimeConfig.ok) {
throw new Error(
`Invariant violated: ${scope} rendered before App accepted runtime config`,
);
}
return runtimeConfig.config.appUrl;
}
/**
* Extract the leading workspace slug from a path, or null if the path isn't
@@ -116,6 +120,7 @@ export function DesktopNavigationProvider({
}: {
children: React.ReactNode;
}) {
const appUrl = requireRuntimeAppUrl("DesktopNavigationProvider");
// Primitive-only subscriptions so this component doesn't re-render on
// unrelated store updates (e.g. an inactive tab's router tick). We
// resolve the active router here only to subscribe once per tab switch.
@@ -186,9 +191,9 @@ export function DesktopNavigationProvider({
const tabId = store.openTab(path, title ?? path, icon);
if (tabId) store.setActiveTab(tabId);
},
getShareableUrl: (path: string) => `${APP_URL}${path}`,
getShareableUrl: (path: string) => `${appUrl}${path}`,
}),
[location],
[appUrl, location],
);
return <NavigationProvider value={adapter}>{children}</NavigationProvider>;
@@ -211,6 +216,7 @@ export function TabNavigationProvider({
router: DataRouter;
children: React.ReactNode;
}) {
const appUrl = requireRuntimeAppUrl("TabNavigationProvider");
const [location, setLocation] = useState(router.state.location);
useEffect(() => {
@@ -246,9 +252,9 @@ export function TabNavigationProvider({
const tabId = store.openTab(path, title ?? path, icon);
if (tabId) store.setActiveTab(tabId);
},
getShareableUrl: (path: string) => `${APP_URL}${path}`,
getShareableUrl: (path: string) => `${appUrl}${path}`,
}),
[router, location],
[appUrl, router, location],
);
return <NavigationProvider value={adapter}>{children}</NavigationProvider>;

View File

@@ -0,0 +1,99 @@
import { describe, expect, it } from "vitest";
import {
DEFAULT_RUNTIME_CONFIG,
deriveWsUrl,
parseRuntimeConfig,
runtimeConfigFromDevEnv,
} from "./runtime-config";
describe("runtime config", () => {
it("uses cloud defaults without a desktop.json file", () => {
expect(DEFAULT_RUNTIME_CONFIG).toEqual({
schemaVersion: 1,
apiUrl: "https://api.multica.ai",
wsUrl: "wss://api.multica.ai/ws",
appUrl: "https://multica.ai",
});
});
it("derives https/wss compatible URLs from apiUrl", () => {
expect(
parseRuntimeConfig(
JSON.stringify({
schemaVersion: 1,
apiUrl: "https://congvc-x99.taila6fa8a.ts.net:18443",
}),
),
).toEqual({
schemaVersion: 1,
apiUrl: "https://congvc-x99.taila6fa8a.ts.net:18443",
wsUrl: "wss://congvc-x99.taila6fa8a.ts.net:18443/ws",
appUrl: "https://congvc-x99.taila6fa8a.ts.net:18443",
});
});
it("derives ws for http api URLs", () => {
expect(deriveWsUrl("http://localhost:8080")).toBe("ws://localhost:8080/ws");
});
it("accepts explicit appUrl and wsUrl", () => {
expect(
parseRuntimeConfig(
JSON.stringify({
schemaVersion: 1,
apiUrl: "https://api.example.com/",
wsUrl: "wss://ws.example.com/socket/",
appUrl: "https://app.example.com/",
}),
),
).toEqual({
schemaVersion: 1,
apiUrl: "https://api.example.com",
wsUrl: "wss://ws.example.com/socket",
appUrl: "https://app.example.com",
});
});
it("rejects invalid JSON", () => {
expect(() => parseRuntimeConfig("{")).toThrow(/Invalid desktop runtime config JSON/);
});
it("rejects unsupported schema versions", () => {
expect(() =>
parseRuntimeConfig(JSON.stringify({ schemaVersion: 2, apiUrl: "https://api.example.com" })),
).toThrow(/schemaVersion/);
});
it("rejects non-http api schemes", () => {
expect(() =>
parseRuntimeConfig(JSON.stringify({ schemaVersion: 1, apiUrl: "file:///tmp/multica" })),
).toThrow(/apiUrl must use http or https/);
});
it("rejects non-ws websocket schemes", () => {
expect(() =>
parseRuntimeConfig(
JSON.stringify({
schemaVersion: 1,
apiUrl: "https://api.example.com",
wsUrl: "https://api.example.com/ws",
}),
),
).toThrow(/wsUrl must use ws or wss/);
});
it("preserves electron-vite dev env precedence", () => {
expect(
runtimeConfigFromDevEnv({
apiUrl: "http://dev-api.example.test:8080/",
wsUrl: "ws://dev-api.example.test:8080/ws/",
appUrl: "http://dev-app.example.test:3000/",
}),
).toEqual({
schemaVersion: 1,
apiUrl: "http://dev-api.example.test:8080",
wsUrl: "ws://dev-api.example.test:8080/ws",
appUrl: "http://dev-app.example.test:3000",
});
});
});

View File

@@ -0,0 +1,157 @@
export interface RuntimeConfig {
schemaVersion: 1;
apiUrl: string;
wsUrl: string;
appUrl: string;
}
export interface RuntimeConfigError {
message: string;
}
export type RuntimeConfigResult =
| { ok: true; config: RuntimeConfig }
| { ok: false; error: RuntimeConfigError };
export const DEFAULT_RUNTIME_CONFIG: RuntimeConfig = Object.freeze({
schemaVersion: 1,
apiUrl: "https://api.multica.ai",
wsUrl: "wss://api.multica.ai/ws",
appUrl: "https://multica.ai",
});
const LOCAL_DEV_RUNTIME_CONFIG: RuntimeConfig = Object.freeze({
schemaVersion: 1,
apiUrl: "http://localhost:8080",
wsUrl: "ws://localhost:8080/ws",
appUrl: "http://localhost:3000",
});
export interface RuntimeConfigEnv {
apiUrl?: string;
wsUrl?: string;
appUrl?: string;
}
export function runtimeConfigFromDevEnv(env: RuntimeConfigEnv): RuntimeConfig {
const apiUrl = normalizeHttpUrl(
env.apiUrl || LOCAL_DEV_RUNTIME_CONFIG.apiUrl,
"VITE_API_URL",
);
return {
schemaVersion: 1,
apiUrl,
wsUrl: env.wsUrl
? normalizeWsUrl(env.wsUrl, "VITE_WS_URL")
: deriveWsUrl(apiUrl),
appUrl: normalizeHttpUrl(
env.appUrl || LOCAL_DEV_RUNTIME_CONFIG.appUrl,
"VITE_APP_URL",
),
};
}
export function parseRuntimeConfig(raw: string): RuntimeConfig {
let parsed: unknown;
try {
parsed = JSON.parse(raw);
} catch (err) {
throw new Error(
`Invalid desktop runtime config JSON: ${err instanceof Error ? err.message : "parse failed"}`,
);
}
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
throw new Error("Invalid desktop runtime config: expected a JSON object");
}
const obj = parsed as Record<string, unknown>;
if (obj.schemaVersion !== 1) {
throw new Error("Unsupported desktop runtime config schemaVersion: expected 1");
}
const apiUrl = requiredString(obj.apiUrl, "apiUrl");
const appUrl = optionalString(obj.appUrl, "appUrl");
const wsUrl = optionalString(obj.wsUrl, "wsUrl");
const normalizedApiUrl = normalizeHttpUrl(apiUrl, "apiUrl");
return {
schemaVersion: 1,
apiUrl: normalizedApiUrl,
wsUrl: wsUrl ? normalizeWsUrl(wsUrl, "wsUrl") : deriveWsUrl(normalizedApiUrl),
appUrl: appUrl ? normalizeHttpUrl(appUrl, "appUrl") : deriveAppUrl(normalizedApiUrl),
};
}
export function deriveWsUrl(apiUrl: string): string {
const url = new URL(apiUrl);
if (url.protocol === "https:") url.protocol = "wss:";
else if (url.protocol === "http:") url.protocol = "ws:";
else throw new Error("apiUrl must use http or https");
url.pathname = joinPath(url.pathname, "/ws");
url.search = "";
url.hash = "";
return trimTrailingSlash(url.toString());
}
export function deriveAppUrl(apiUrl: string): string {
const url = new URL(apiUrl);
url.pathname = "";
url.search = "";
url.hash = "";
return trimTrailingSlash(url.toString());
}
function requiredString(value: unknown, field: string): string {
if (typeof value !== "string" || value.trim().length === 0) {
throw new Error(`Invalid desktop runtime config: ${field} must be a non-empty string`);
}
return value;
}
function optionalString(value: unknown, field: string): string | undefined {
if (value === undefined) return undefined;
if (typeof value !== "string" || value.trim().length === 0) {
throw new Error(`Invalid desktop runtime config: ${field} must be a non-empty string when set`);
}
return value;
}
function normalizeHttpUrl(value: string, field: string): string {
let url: URL;
try {
url = new URL(value.trim());
} catch {
throw new Error(`Invalid desktop runtime config: ${field} must be a valid URL`);
}
if (url.protocol !== "http:" && url.protocol !== "https:") {
throw new Error(`Invalid desktop runtime config: ${field} must use http or https`);
}
url.search = "";
url.hash = "";
return trimTrailingSlash(url.toString());
}
function normalizeWsUrl(value: string, field: string): string {
let url: URL;
try {
url = new URL(value.trim());
} catch {
throw new Error(`Invalid desktop runtime config: ${field} must be a valid URL`);
}
if (url.protocol !== "ws:" && url.protocol !== "wss:") {
throw new Error(`Invalid desktop runtime config: ${field} must use ws or wss`);
}
url.search = "";
url.hash = "";
return trimTrailingSlash(url.toString());
}
function joinPath(base: string, suffix: string): string {
const normalizedBase = base.endsWith("/") ? base.slice(0, -1) : base;
return `${normalizedBase}${suffix}`;
}
function trimTrailingSlash(value: string): string {
return value.replace(/\/+$/, "");
}

View File

@@ -1,8 +1,14 @@
import { resolve } from "path";
import { defineConfig } from "vitest/config";
import react from "@vitejs/plugin-react";
export default defineConfig({
plugins: [react()],
resolve: {
alias: {
"@": resolve(__dirname, "src/renderer/src"),
},
},
test: {
globals: true,
include: ["src/**/*.test.{ts,tsx}", "scripts/**/*.test.mjs"],

View File

@@ -9,6 +9,14 @@ import { notFound } from "next/navigation";
import defaultMdxComponents from "fumadocs-ui/mdx";
import type { Metadata } from "next";
import { docsAlternates } from "@/lib/site";
import { i18n, type Lang } from "@/lib/i18n";
import { DocsLocaleProvider, LocaleLink } from "@/components/locale-link";
function asLang(lang: string): Lang {
return (i18n.languages as readonly string[]).includes(lang)
? (lang as Lang)
: (i18n.defaultLanguage as Lang);
}
export default async function Page(props: {
params: Promise<{ lang: string; slug: string[] }>;
@@ -18,13 +26,16 @@ export default async function Page(props: {
if (!page) notFound();
const MDX = page.data.body;
const lang = asLang(params.lang);
return (
<DocsPage toc={page.data.toc}>
<DocsTitle>{page.data.title}</DocsTitle>
<DocsDescription>{page.data.description}</DocsDescription>
<DocsBody>
<MDX components={{ ...defaultMdxComponents }} />
<DocsLocaleProvider lang={lang}>
<MDX components={{ ...defaultMdxComponents, a: LocaleLink }} />
</DocsLocaleProvider>
</DocsBody>
</DocsPage>
);

View File

@@ -8,6 +8,7 @@ import { Byline, NumberedCards, NumberedCard, NumberedSteps, Step } from "@/comp
import { i18n, type Lang } from "@/lib/i18n";
import { homeCopy } from "@/lib/translations";
import { docsAlternates } from "@/lib/site";
import { DocsLocaleProvider, LocaleLink } from "@/components/locale-link";
function asLang(lang: string): Lang {
return (i18n.languages as readonly string[]).includes(lang)
@@ -52,15 +53,18 @@ export default async function Page({
/>
<Byline items={[...copy.byline]} />
<DocsBody>
<MDX
components={{
...defaultMdxComponents,
NumberedCards,
NumberedCard,
NumberedSteps,
Step,
}}
/>
<DocsLocaleProvider lang={lang}>
<MDX
components={{
...defaultMdxComponents,
a: LocaleLink,
NumberedCards,
NumberedCard,
NumberedSteps,
Step,
}}
/>
</DocsLocaleProvider>
</DocsBody>
</DocsPage>
);

View File

@@ -1,5 +1,9 @@
"use client";
import Link from "next/link";
import type { ReactNode } from "react";
import { useDocsLocale } from "@/components/locale-link";
import { prefixLocale } from "@/lib/locale-link";
/**
* Byline — editorial metadata strip with ruled top + bottom borders.
@@ -55,9 +59,10 @@ export function NumberedCard({
tag?: string;
children: ReactNode;
}) {
const lang = useDocsLocale();
return (
<Link
href={href}
href={prefixLocale(href, lang)}
className="group flex flex-col gap-2.5 border-r border-border px-0 py-5 pr-4 no-underline last:border-r-0 md:px-4 md:first:pl-0 md:last:pr-0"
>
<div className="font-mono text-[0.6875rem] uppercase tracking-[0.08em] text-muted-foreground">

View File

@@ -0,0 +1,48 @@
"use client";
import Link from "next/link";
import {
createContext,
useContext,
type AnchorHTMLAttributes,
type ReactNode,
} from "react";
import { i18n, type Lang } from "@/lib/i18n";
import { prefixLocale } from "@/lib/locale-link";
const DocsLocaleContext = createContext<Lang>(i18n.defaultLanguage as Lang);
// Wraps the rendered MDX subtree so descendant <LocaleLink>s and any
// editorial component using `useDocsLocale()` know which language the page
// was rendered in. Mounted at each docs page entry; never elsewhere.
export function DocsLocaleProvider({
lang,
children,
}: {
lang: Lang;
children: ReactNode;
}) {
return (
<DocsLocaleContext.Provider value={lang}>
{children}
</DocsLocaleContext.Provider>
);
}
export function useDocsLocale(): Lang {
return useContext(DocsLocaleContext);
}
// Drop-in replacement for the MDX-rendered `<a>` element. Keeps the same
// surface shape as the default `a` from `defaultMdxComponents` but routes
// internal links through the locale prefixer + next/link so client-side
// navigation stays inside the active locale.
export function LocaleLink({
href,
...rest
}: AnchorHTMLAttributes<HTMLAnchorElement> & { href?: string }) {
const lang = useDocsLocale();
if (!href) return <a {...rest} />;
const final = prefixLocale(href, lang);
return <Link href={final} {...rest} />;
}

View File

@@ -32,9 +32,10 @@ The command-line equivalent:
```bash
multica issue assign MUL-42 --to alice
multica issue assign MUL-42 --to-id 5fb87ac7-23b5-4a7a-81fa-ed295a54545d
```
`--to` takes a member username or an agent name. Giving agents memorable names makes this step smoother — if multiple agents share a name in the workspace, the first one listed wins, so rename before assigning.
`--to` takes a member username or an agent name (fuzzy match). When names overlap — e.g. an agent `J` alongside `Cursor - J` — pass `--to-id <uuid>` instead, using the `user_id` (member) or `id` (agent) from `multica workspace members --output json` / `multica agent list --output json`. UUID matching is strict and unambiguous, which is what you want from scripts and from agents driving the CLI. `--to` and `--to-id` are mutually exclusive.
Unassign:

View File

@@ -32,9 +32,10 @@ import { Callout } from "fumadocs-ui/components/callout";
```bash
multica issue assign MUL-42 --to alice
multica issue assign MUL-42 --to-id 5fb87ac7-23b5-4a7a-81fa-ed295a54545d
```
`--to` 后跟成员用户名或智能体名字。给智能体起个好记的名字会让这一步顺很多——工作区里重名的会按列出顺序选第一个,建议先改名再分配
`--to` 后跟成员用户名或智能体名字(模糊匹配)。如果工作区里有同名 / 互相含子串的成员或智能体(例如 agent `J` 旁边还有 `Cursor - J`),改用 `--to-id <uuid>`UUID 来自 `multica workspace members --output json` 的 `user_id` 或 `multica agent list --output json` 的 `id`,是唯一精确的方式,特别适合脚本和驱动 CLI 的智能体。`--to` 和 `--to-id` 互斥
取消分配:

View File

@@ -40,6 +40,7 @@ For the difference between token types, see [Authentication and tokens](/auth-to
| `multica workspace list` | List every workspace you can access |
| `multica workspace get <slug>` | Show details for one workspace |
| `multica workspace members` | List members of the current workspace |
| `multica workspace update <id> --name "..." [--description "..."] [--context "..."] [--issue-prefix "..."]` | Update workspace metadata (admin/owner). Long fields accept `--description-stdin` / `--context-stdin`. |
## Issues and projects

View File

@@ -40,6 +40,7 @@ Token 类型的详细区分见 [认证与令牌](/auth-tokens)。
| `multica workspace list` | 列出你有权访问的所有工作区 |
| `multica workspace get <slug>` | 查看一个工作区的详情 |
| `multica workspace members` | 列出当前工作区的成员 |
| `multica workspace update <id> --name "..." [--description "..."] [--context "..."] [--issue-prefix "..."]` | 修改 workspace 元数据admin/owner 权限)。长文本可用 `--description-stdin` / `--context-stdin`。 |
## Issue 和 Project

View File

@@ -18,10 +18,10 @@ Opens your browser for OAuth authentication, creates a 90-day personal access to
### Token Login
```bash
multica login --token
multica login --token <mul_...>
```
Authenticate by pasting a personal access token directly. Useful for headless environments.
Authenticate using a personal access token directly. Useful for headless environments. Pass `--token=` with an empty value to be prompted interactively (so the token never lands in shell history).
### Check Status
@@ -213,6 +213,28 @@ multica workspace get <workspace-id> --output json
multica workspace members <workspace-id>
```
### Update Workspace
需要 admin 或 owner 权限。所有字段都是部分更新PATCH 语义):未传的字段保持不变。
```bash
multica workspace update <workspace-id> --name "Acme Eng"
multica workspace update <workspace-id> \
--description "Engineering team workspace" \
--issue-prefix ENG
```
长文本走 stdin保留换行/反斜杠):
```bash
cat <<'CTX' | multica workspace update <workspace-id> --context-stdin
我们是一支 5 人 AI-native 团队。
工作语言:中文 + 英文混合。
CTX
```
可编辑字段:`--name`、`--description` / `--description-stdin`、`--context` / `--context-stdin`、`--issue-prefix`。`slug` 创建后只读,不暴露在 CLI。`--description` 与 `--description-stdin`(以及 `context` 同名对)互斥。未传任何字段 flag 时命令拒绝执行,避免空 PATCH 触发无意义的 workspace 更新事件。`--issue-prefix ""` 也会被拒绝:当前后端在 prefix 为空时静默跳过该字段CLI 在本地拦下避免“看似成功的 no-op”。
## Issues
### List Issues
@@ -221,10 +243,11 @@ multica workspace members <workspace-id>
multica issue list
multica issue list --status in_progress
multica issue list --priority urgent --assignee "Agent Name"
multica issue list --assignee-id 5fb87ac7-23b5-4a7a-81fa-ed295a54545d
multica issue list --limit 20 --output json
```
Available filters: `--status`, `--priority`, `--assignee`, `--project`, `--limit`.
Available filters: `--status`, `--priority`, `--assignee` / `--assignee-id`, `--project`, `--limit`. 在重名 workspace 下用 `--assignee-id <uuid>` 可以精确锁定一个成员或 agent。
### Get Issue
@@ -237,9 +260,10 @@ multica issue get <id> --output json
```bash
multica issue create --title "Fix login bug" --description "..." --priority high --assignee "Lambda"
multica issue create --title "Fix login bug" --assignee-id 5fb87ac7-23b5-4a7a-81fa-ed295a54545d
```
Flags: `--title` (required), `--description`, `--status`, `--priority`, `--assignee`, `--parent`, `--project`, `--due-date`.
Flags: `--title` (required), `--description`, `--status`, `--priority`, `--assignee` / `--assignee-id`, `--parent`, `--project`, `--due-date`. 脚本里如果已经拿到了 UUID例如来自 `multica workspace members --output json`),传 `--assignee-id <uuid>`(与 `--assignee` 互斥)以精确锁定。
### Update Issue
@@ -251,9 +275,12 @@ multica issue update <id> --title "New title" --priority urgent
```bash
multica issue assign <id> --to "Lambda"
multica issue assign <id> --to-id 5fb87ac7-23b5-4a7a-81fa-ed295a54545d
multica issue assign <id> --unassign
```
`--to-id <uuid>`(与 `--to` 互斥)按 UUID 精确分配;适合重名 workspace 下脚本化场景。
### Change Status
```bash

View File

@@ -99,7 +99,7 @@ Assign the issue to the agent you just created — click its avatar in the web U
multica issue assign MUL-1 --to my-agent-name
```
`--to` takes the **name** of an agent or member. A substring match works — if the agent is called `my-code-reviewer`, `reviewer` resolves to it.
`--to` takes the **name** of an agent or member. A substring match works — if the agent is called `my-code-reviewer`, `reviewer` resolves to it. If your workspace has overlapping names, pass `--to-id <uuid>` instead (mutually exclusive with `--to`); look up the UUID via `multica agent list --output json` or `multica workspace members --output json`.
**What happens next from the daemon**:

View File

@@ -99,7 +99,7 @@ multica issue create --title "给 README 加一段 ASCII 架构图"
multica issue assign MUL-1 --to my-agent-name
```
`--to` 后面填智能体或成员的**名字**,子串就行——如果智能体叫 `my-code-reviewer`,填 `reviewer` 也能命中。
`--to` 后面填智能体或成员的**名字**,子串就行——如果智能体叫 `my-code-reviewer`,填 `reviewer` 也能命中。如果工作区里名字相互重叠或冲突,改用 `--to-id <uuid>`(与 `--to` 互斥UUID 来自 `multica agent list --output json` 或 `multica workspace members --output json`。
**接下来守护进程会**

View File

@@ -37,7 +37,7 @@ Common commands:
Full CLI reference in [CLI commands](/cli).
**The desktop app ships with a daemon.** If you use the [desktop app](/desktop-app), you don't need to run `multica daemon start` manually — it launches the daemon automatically on startup.
**The desktop app ships with a daemon.** If you use the [desktop app](/desktop-app), you don't need to run `multica daemon start` manually — it launches the daemon automatically on startup. See the [Desktop app](/desktop-app) page for which option fits your workflow.
## Why one machine has multiple runtimes

View File

@@ -37,7 +37,7 @@ multica daemon start
完整 CLI 参考见 [CLI 命令速查](/cli)。
**桌面应用自带守护进程。**用 [桌面应用](/desktop-app) 就不必手动 `multica daemon start`——它启动时会自动拉起守护进程。
**桌面应用自带守护进程。**用 [桌面应用](/desktop-app) 就不必手动 `multica daemon start`——它启动时会自动拉起守护进程。哪种方式更适合你的工作流,详见 [桌面应用](/desktop-app) 页面。
## 为什么一台机器会有多个运行时

View File

@@ -5,7 +5,7 @@ description: What Multica Desktop is, how it differs from the web app, and when
import { Callout } from "fumadocs-ui/components/callout";
Multica Desktop is a native desktop app for macOS, Windows, and Linux. It talks to the same backend as the web app and shows the same data, but it adds a few things the browser can't: **independent tab groups per [workspace](/workspaces)**, **automatic [daemon](/daemon-runtimes) startup**, and **one-click upgrades**.
Multica Desktop is a native desktop app for macOS, Windows, and Linux. For the environment it is configured for, it talks to the same backend as the web app and shows the same data. By default Desktop uses Multica Cloud; self-hosted instances can be configured with a local runtime config file. Desktop also adds a few things the browser can't: **independent tab groups per [workspace](/workspaces)**, **automatic [daemon](/daemon-runtimes) startup**, and **one-click upgrades**.
## Desktop or web — which to pick
@@ -66,25 +66,34 @@ Grab the installer for your platform from the [Multica downloads page](https://m
On first launch you'll need to sign in — the same email + verification code flow as the web app. Once you're in, Desktop syncs your workspace list automatically.
<Callout type="warning">
**Released Desktop builds are pinned to Multica Cloud.** The backend, websocket, and web URLs are baked in at build time (`VITE_API_URL` / `VITE_WS_URL` / `VITE_APP_URL`) — there is no in-app option to point Desktop at a self-hosted instance. To use Desktop against a self-hosted backend you need to build it yourself:
<Callout type="info">
**Desktop defaults to Multica Cloud, but can be pointed at a self-hosted instance with a local config file.** There is still no in-app "connect to self-host" picker. Desktop reads `~/.multica/desktop.json` before the renderer starts; if the file is missing, it uses the Cloud defaults.
```bash
git clone https://github.com/multica-ai/multica.git
cd multica
# Edit apps/desktop/.env.production:
# VITE_API_URL=https://api.your-domain
# VITE_WS_URL=wss://api.your-domain/ws
# VITE_APP_URL=https://your-domain
pnpm install
pnpm --filter @multica/desktop package
Minimal self-host config:
```json
{
"schemaVersion": 1,
"apiUrl": "https://api.your-domain"
}
```
If you'd rather not build from source, the supported self-hosted path is **web frontend + CLI** — see [Self-host quickstart](/self-host-quickstart). Runtime backend configuration in Desktop is tracked in [issue #1371](https://github.com/multica-ai/multica/issues/1371).
`apiUrl` is required and must use `http` or `https`. Desktop derives `wsUrl` as `/ws` on the same origin (`wss` for `https`, `ws` for `http`) and derives `appUrl` from the API origin. If your deployment uses different origins, set them explicitly:
```json
{
"schemaVersion": 1,
"apiUrl": "https://api.your-domain",
"wsUrl": "wss://api.your-domain/ws",
"appUrl": "https://your-domain"
}
```
If `desktop.json` exists but is invalid, Desktop fails closed and shows a blocking config error instead of silently falling back to Cloud. For development builds, `VITE_API_URL` / `VITE_WS_URL` / `VITE_APP_URL` still take precedence during `electron-vite dev`. Runtime Desktop self-host configuration was implemented for [issue #1371](https://github.com/multica-ai/multica/issues/1371).
</Callout>
## Next steps
- [Cloud Quickstart](/cloud-quickstart) — the Cloud onboarding flow for Desktop
- [Self-Host Quickstart](/self-host-quickstart) — running your own backend (Desktop against self-host requires a custom build, see the callout above)
- [Self-Host Quickstart](/self-host-quickstart) — running your own backend and connecting with the CLI or Desktop runtime config
- [Daemon and runtimes](/daemon-runtimes) — how the daemon works (Desktop starts it for you, but the behavior is the same)

View File

@@ -5,7 +5,7 @@ description: Multica Desktop 是什么、和 Web 有什么区别、什么时候
import { Callout } from "fumadocs-ui/components/callout";
Multica Desktop 是原生桌面应用——macOS / Windows / Linux 三个平台。它和 Web 版连同一个后端看到的数据完全一样,但给了几个 Web 做不到的能力:**[工作区](/workspaces) 独立的多标签页**、**自动启动 [守护进程](/daemon-runtimes)**、**一键升级**。
Multica Desktop 是原生桌面应用——macOS / Windows / Linux 三个平台。对它当前配置的环境来说,它和 Web 版连同一个后端看到的数据完全一样。Desktop 默认使用 Multica Cloud自部署实例可以通过本地运行时配置文件接入。它还给了几个 Web 做不到的能力:**[工作区](/workspaces) 独立的多标签页**、**自动启动 [守护进程](/daemon-runtimes)**、**一键升级**。
## Desktop 和 Web 该用哪个
@@ -66,25 +66,34 @@ macOS 版本已经签名 + 公证,第一次打开不会有"未知开发者"的
安装后第一次打开需要登录——和 Web 版一样的 email + 验证码流程。登录成功后 Desktop 自动把工作区列表同步下来。
<Callout type="warning">
**发布版的 Desktop 是锁死连 Multica Cloud 的**。后端 / WebSocket / Web 前端 URL`VITE_API_URL` / `VITE_WS_URL` / `VITE_APP_URL`)在构建时就写死了,应用内**没有切换后端的入口**。要让 Desktop 连自部署后端,需要你自己从源码 build
<Callout type="info">
**Desktop 默认连接 Multica Cloud,但可以通过本地配置文件指向自部署实例。** 应用内仍然没有“连接自部署”的切换入口。Desktop 会在 renderer 启动前读取 `~/.multica/desktop.json`;如果这个文件不存在,就使用 Cloud 默认值。
```bash
git clone https://github.com/multica-ai/multica.git
cd multica
# 编辑 apps/desktop/.env.production
# VITE_API_URL=https://api.your-domain
# VITE_WS_URL=wss://api.your-domain/ws
# VITE_APP_URL=https://your-domain
pnpm install
pnpm --filter @multica/desktop package
最小自部署配置:
```json
{
"schemaVersion": 1,
"apiUrl": "https://api.your-domain"
}
```
不想自己 build 的话,自部署的官方路径是 **Web 前端 + CLI**——见 [自部署快速上手](/self-host-quickstart)。Desktop 运行时切换后端的能力跟踪在 [issue #1371](https://github.com/multica-ai/multica/issues/1371)。
`apiUrl` 是必填项,必须使用 `http` 或 `https`。Desktop 会自动从它推导 `wsUrl`(同源 `/ws``https` 对应 `wss``http` 对应 `ws`)和 `appUrl`API 的同源地址)。如果你的部署使用不同域名,可以显式设置:
```json
{
"schemaVersion": 1,
"apiUrl": "https://api.your-domain",
"wsUrl": "wss://api.your-domain/ws",
"appUrl": "https://your-domain"
}
```
如果 `desktop.json` 存在但内容无效Desktop 会 fail closed显示阻塞式配置错误而不是悄悄回退到 Cloud。开发构建里`electron-vite dev` 仍然优先使用 `VITE_API_URL` / `VITE_WS_URL` / `VITE_APP_URL`。Desktop 运行时自部署配置能力对应 [issue #1371](https://github.com/multica-ai/multica/issues/1371)。
</Callout>
## 下一步
- [Cloud Quickstart](/cloud-quickstart) —— Desktop 版的 Cloud 接入流程
- [Self-Host Quickstart](/self-host-quickstart) —— 自部署后端Desktop 连自部署需要自行构建,见上方提示)
- [Self-Host Quickstart](/self-host-quickstart) —— 自部署后端,并通过 CLI 或 Desktop 运行时配置连接
- [守护进程与运行时](/daemon-runtimes) —— 守护进程机制Desktop 自动起它,但行为一样)

View File

@@ -0,0 +1,301 @@
---
title: Conventions
description: Single source of truth for code naming, i18n translation glossary, and Chinese voice guide.
---
This page is the single source of truth for code naming, the i18n translation glossary, and the Chinese voice guide. Anything that used to live in `packages/views/locales/glossary.md` or in scattered comments now lives here.
If you write Multica code, change a translation, or write Chinese product copy, this is the page to reference.
---
## 1. Code naming
### Routes
Pre-workspace routes (the routes that exist before the user is in a workspace) MUST use either a single word or the `/{noun}/{verb}` pattern.
- ✅ `/login`, `/inbox`, `/workspaces/new`
- ❌ `/new-workspace`, `/create-team`, `/accept-invite`
Hyphenated word groups at the root collide with user-chosen workspace slugs and force endless reserved-slug audits. Reserving the noun (`workspaces`) automatically protects the entire `/workspaces/*` subtree.
### Workspace-scoped routes
Always live under `/{slug}/{section}` — `/{slug}/issues`, `/{slug}/agents`, `/{slug}/settings`. Never duplicate workspace routing logic; use `useNavigation().push()` from shared code, never framework-specific link APIs.
### Packages and modules
The monorepo enforces strict package boundaries:
| Package | May depend on | Must NOT depend on |
| --- | --- | --- |
| `packages/core` | nothing app-specific | `react-dom`, `localStorage`, `process.env`, `next/*`, UI libraries |
| `packages/ui` | nothing | `@multica/core`, business logic |
| `packages/views` | `core/`, `ui/` | `next/*`, `react-router-dom`, stores |
| `apps/web/platform/` | `next/*` | other apps |
| `apps/desktop/.../platform/` | `react-router-dom`, electron | other apps |
If logic appears in both apps, it MUST be extracted to a shared package. There are no exceptions for "small" duplication.
### Files and components
- Files: `kebab-case.tsx` / `kebab-case.ts` (e.g. `agent-row-actions.tsx`)
- Components: `PascalCase` (e.g. `AgentRowActions`)
- Hooks: `useCamelCase` (e.g. `useWorkspaceId`)
- Tests: colocated as `<file>.test.ts(x)`
- Stores (Zustand): `<feature>-store.ts`, exported as `use<Feature>Store`
### Database (Go + sqlc)
- Tables: `snake_case` singular (`user`, `workspace`, `agent_runtime`)
- Columns: `snake_case` (`workspace_id`, `created_at`, `last_seen_at`)
- Foreign keys: `<table>_id`
- Booleans: `is_<state>` or `<state>_at` (timestamp form preferred for state changes)
- Migration files: `NNN_descriptive_name.up.sql` + `.down.sql` — always provide both directions
### Go
- Standard `gofmt` + `go vet`. No exceptions.
- Handler files mirror domain: `agent.go`, `auth.go`, `runtime.go`
- Tests: `<file>_test.go` colocated
- For UUID parsing in handlers, follow the rule in the root `CLAUDE.md` — `parseUUIDOrBadRequest` for boundary input, `parseUUID` (panicking) for trusted round-trips, never `util.ParseUUID` directly without checking the error.
### TypeScript
- API responses on the wire are `snake_case`; the api client converts to `camelCase` at the boundary. Inside TS code, **always camelCase**.
- Types: `PascalCase` (`Issue`, `AgentRuntime`); never `IPrefix`, never `_t` suffix.
- Enums: prefer string literal unions; reserve `enum` for runtime-iterable cases.
- TanStack Query keys: factory functions in `<feature>/queries.ts`, e.g. `issueKeys.detail(id)`.
### Issue keys
Every issue has a human-readable key like `MUL-123`: workspace `issue_prefix` (3 letters, uppercase) + sequence number. The prefix is set at workspace creation and is never changed afterward.
### Comments in code
English only. The repo enforces this for both Go and TypeScript. If you find a Chinese comment in code, it's a bug — replace it.
### Commit messages
Conventional format: `feat(scope)`, `fix(scope)`, `refactor(scope)`, `docs`, `test(scope)`, `chore(scope)`. Atomic commits grouped by intent.
---
## 2. i18n translation glossary
This is the **mandatory** glossary for every translation PR. It used to live at `packages/views/locales/glossary.md`; that file is now a stub pointing here.
### The core distinction: entity vs concept
Multica's product nouns split into two categories:
- **Entity** — has a URL, a database row, an API type. In Chinese text, render as **lowercase English** so it visually reads like a type name and signals "this is a Multica system entity".
- **Concept** — generic noun, not a database entity. **Translate fully** so Chinese users don't see jagged English embedded in flowing text.
This rule is aligned with `apps/docs/content/docs/*.zh.mdx` — the docs are the de facto Chinese voice standard and have been battle-tested across 20+ pages.
### Entities — mixed rule (`issue` / `skill` / `task`)
`issue` / `skill` / `task` are Multica's core entities. They have schema columns, API fields, and product UI labels that are all English. In Chinese text, they follow a **mixed rule** — what to use depends on where the word appears:
| Context | Render | Example |
| --- | --- | --- |
| **UI strings, state names, code references** | lowercase English | "排队中的 task"、"创建子 issue"、"为智能体注入 skill" |
| **Doc titles / section headings** | Title-case English **or** the Chinese term | "Issue 与 project"、"Skills"、"执行任务" |
| **Long-form doc prose, when the entity is the running subject** | Chinese term, with English in parentheses on first mention | "**执行任务**task是智能体每一次工作的单位" |
| **API / DB fields** | always `task` / `issue` / `skill` | `task_id`, `issue_status`, `skill_uuid` |
Chinese term reference:
- `task` ↔ `执行任务` (or shortened to `任务` once context is clear)
- `issue` has no settled Chinese translation — leave English; titles may capitalize as `Issue`
- `skill` has no settled Chinese translation — leave English; titles may capitalize as `Skills`
**Why `issue` / `skill` / `task` aren't forced into Chinese the way `project` / `autopilot` are**:
- **`issue` / `task`**: dev teams talk in English. The Chinese candidates ("任务" — too vague, almost synonymous with "工作"; "工单" — IT ticket connotation; "议题" — GitHub-style but doesn't match the product feel) all read worse than `issue`. **But** in long-form doc prose, repeating lowercase `task` 50× breaks the rhythm — so prose is allowed to use `执行任务`, while UI strings and state names stay lowercase English.
- **`skill`**: Multica-specific concept with no established Chinese term.
- **`project` → "项目"**: settled mainstream Chinese word. Feishu / Tower / Teambition / PingCode / GitHub Projects — every Chinese product translates it. No product keeps `project` in Chinese context.
- **`autopilot` → "自动化"**: in Chinese, "autopilot" associates with Tesla's "自动驾驶" and doesn't match what the feature does (run tasks on a schedule). Notion and Feishu both use "自动化"; that's the industry consensus.
### Don't translate — brands and acronyms
| Category | Terms |
| --- | --- |
| Brands | **Multica**, GitHub, Slack, Google, Anthropic, OpenAI, Claude, Codex, Cursor, Linear, Jira |
| Acronyms | API, CLI, URL, SDK, OAuth, JWT, SSO, WebSocket, HTTP, JSON, YAML, SQL |
### Translate fully — concepts
| English | Chinese |
| --- | --- |
| Workspace | **工作区** |
| Agent | **智能体** |
| Project | **项目** |
| Autopilot | **自动化** |
| Daemon | **守护进程** |
| Runtime | **运行时** |
| Inbox | **收件箱** |
| Comment | **评论** |
| Reply | **回复** |
| Notifications | **通知** |
| Member | **成员** |
| Label | **标签** |
| Settings | **设置** |
| Onboarding | **上手引导** |
### Translate fully — generic UI words
| English | Chinese |
| --- | --- |
| Invite / Invitation | 邀请 |
| Search | 搜索 |
| Email | 邮箱 (label) / 邮件 (action) |
| Password | 密码 |
| Sign in / Log in | 登录 |
| Sign up | 注册 |
| Sign out / Log out | 退出登录 |
| Save / Cancel / Delete | 保存 / 取消 / 删除 |
| Confirm / Continue / Back | 确认 / 继续 / 返回 |
| Edit / New / Create / Add | 编辑 / 新建 / 创建 / 添加 |
| Remove / Send / Open / Close | 移除 / 发送 / 打开 / 关闭 |
| Done / Loading... | 完成 / 加载中... |
| Profile / Account / Appearance | 个人资料 / 账号 / 外观 |
| Theme / Language | 主题 / 语言 |
| Light / Dark / System | 浅色 / 深色 / 跟随系统 |
| Active / Archived | 活跃 (or 启用) / 已归档 |
| Status / Priority | 状态 / 优先级 |
| Assignee / Reporter | 负责人 / 报告人 |
| Description / Title | 描述 / 标题 |
| Date / Time | 日期 / 时间 |
| Today / Yesterday / Tomorrow | 今天 / 昨天 / 明天 |
| Empty / Failed / Success | 空 / 失败 / 成功 |
| Error / Warning | 错误 / 警告 |
### Roles and status enums (lowercase English, not translated)
These are schema-level identifiers; render as lowercase English even in Chinese context.
- Roles: `owner` / `admin` / `member`
- Issue status: `backlog` / `todo` / `in_progress` / `in_review` / `done` / `blocked` / `cancelled`
In UI, surface them in English (optionally `code-style` wrapped):
- "你需要 owner 权限"
- "已切换到 in_progress"
### Word combination rules
Always put **a single space** between an English word (entity / brand / acronym) and surrounding Chinese:
- "Create new issue" → "新建 issue"
- "Assign to agent" → "分配给智能体"
- "Configure runtime" → "配置运行时"
- "Stop daemon" → "停止守护进程"
### Plurals and counts
i18next uses `_one` / `_other`; Chinese has no grammatical number, only fill `_other`.
```json
// en/issues.json
{
"issue_count_one": "{{count}} issue",
"issue_count_other": "{{count}} issues"
}
// zh-Hans/issues.json
{
"issue_count_other": "{{count}} 个 issue"
}
```
Common count formats:
- `{{count}} issues` → `{{count}} 个 issue`
- `{{count}} agents` → `{{count}} 个智能体`
- `{{count}} workspaces` → `{{count}} 个工作区`
- `{{count}} comments` → `{{count}} 条评论`
- `{{count}} members` → `{{count}} 位成员`
- `{{count}} skills` → `{{count}} 个 skill`
### Interpolation
Use `{{var}}`. Chinese translations may reorder for natural sentence flow.
```json
// en
{ "welcome_message": "Welcome back, {{name}}!" }
// zh-Hans
{ "welcome_message": "欢迎回来,{{name}}" }
```
### Translation key naming
Three-level nesting: `feature.component.action`.
```json
{
"feature_or_component": {
"subcomponent_or_section": {
"action_or_label": "..."
}
}
}
```
Examples:
- `issues.toolbar.batch_update_success`
- `issues.detail.comment_form.placeholder`
- `inbox.empty.title`
- `settings.preferences.language.title`
### Web-only / desktop-only copy
- Shared copy: top level of the namespace JSON
- Web-only: `web` section
- Desktop-only: `desktop` section
See `auth.json` for the canonical example (the `web` section contains `prefer_desktop` / `desktop_handoff.*`).
---
## 3. Chinese voice and style
### Punctuation
- Full-width punctuation in Chinese: `,。:;!?`
- Quotes: straight double quotes `"..."` to match the English source. Do not use `「」` or curly quotes.
- Ellipsis: three dots `...` not the single character `…`. Match the English source.
- Mixed Chinese-English: a single space on each side of the English word (see Word combination rules).
### Style principles
- **Concise and direct.** Avoid translation-ese: "对于 X 来说"、"作为 X"、"我们的"。
- **Error messages**: gentle but clear. "无法保存修改" beats "保存修改失败了!".
- **Buttons**: verb first, 24 characters. "取消"、"保存修改"、"立即同步".
- **Tooltips**: full short sentence. "复制链接到剪贴板".
- **Placeholders**: example-style. "输入 issue 标题...".
### Where to look when in doubt
When the glossary doesn't cover a term, look at:
1. `apps/docs/content/docs/*.zh.mdx` — the de facto Chinese voice standard, 20+ pages of consistent translation
2. `packages/views/locales/zh-Hans/auth.json` and `editor.json` — JSON structure + selector API patterns
3. `packages/views/auth/login-page.tsx` — component-level selector API call site
4. `packages/views/settings/components/preferences-tab.tsx` — language switcher reference
---
## Updating this page
If you change a rule here, also:
1. Apply it in the relevant locale JSONs / CLAUDE.md / docs page
2. Note the change in the PR description so reviewers know to look for downstream sweep
This page is the contract; nothing else overrides it.

View File

@@ -0,0 +1,301 @@
---
title: 规范
description: 代码命名规范、i18n 翻译术语表、中文风格指南的唯一权威来源。
---
本页是代码命名规范、i18n 翻译术语表、中文风格指南的唯一权威来源。原本散落在 `packages/views/locales/glossary.md` 和各处注释里的规则现在都收拢到这里。
写 Multica 代码、改翻译、写中文产品文案,都从这一页查。
---
## 1. 代码命名
### 路由
工作区前置路由(用户进入工作区之前能访问的路由)必须用单个单词,或者 `/{noun}/{verb}` 格式。
- ✅ `/login`、`/inbox`、`/workspaces/new`
- ❌ `/new-workspace`、`/create-team`、`/accept-invite`
根目录的连字符词组会跟用户自选 workspace slug 冲突,逼着团队不停审保留字列表。把名词(`workspaces`)保留下来,整个 `/workspaces/*` 子树自动受保护。
### 工作区路由
永远用 `/{slug}/{section}` —— `/{slug}/issues`、`/{slug}/agents`、`/{slug}/settings`。共享代码不要复制路由逻辑,统一走 `useNavigation().push()`,不要直接用框架的 link API。
### 包与模块
monorepo 的包边界是硬约束:
| 包 | 可依赖 | 不能依赖 |
| --- | --- | --- |
| `packages/core` | 仅平台无关基础库 | `react-dom`、`localStorage`、`process.env`、`next/*`、UI 库 |
| `packages/ui` | 无业务依赖 | `@multica/core`、业务逻辑 |
| `packages/views` | `core/`、`ui/` | `next/*`、`react-router-dom`、stores |
| `apps/web/platform/` | `next/*` | 其他 app |
| `apps/desktop/.../platform/` | `react-router-dom`、electron | 其他 app |
两个 app 都有的逻辑,**必须**抽到共享包。"小段重复"也不算例外。
### 文件与组件
- 文件名:`kebab-case.tsx` / `kebab-case.ts`(如 `agent-row-actions.tsx`
- 组件:`PascalCase`(如 `AgentRowActions`
- Hook`useCamelCase`(如 `useWorkspaceId`
- 测试:与源文件同目录,命名 `<file>.test.ts(x)`
- Zustand store`<feature>-store.ts`,导出名 `use<Feature>Store`
### 数据库Go + sqlc
- 表名:`snake_case` 单数(`user`、`workspace`、`agent_runtime`
- 字段:`snake_case``workspace_id`、`created_at`、`last_seen_at`
- 外键:`<table>_id`
- 布尔:`is_<state>` 或者 `<state>_at`(状态变化优先用时间戳形式)
- 迁移文件:`NNN_descriptive_name.up.sql` + `.down.sql`**永远写双向**
### Go
- 标准 `gofmt` + `go vet`,无例外
- Handler 文件按域命名:`agent.go`、`auth.go`、`runtime.go`
- 测试:`<file>_test.go` 同目录
- handler 里 UUID 解析遵守根 `CLAUDE.md` 的规则:边界输入用 `parseUUIDOrBadRequest`,可信回环用 `parseUUID`panic 版),永远不要直接用 `util.ParseUUID` 不查 error
### TypeScript
- 网络上 API 响应是 `snake_case`api client 在边界处转成 `camelCase`。**TS 代码内部一律 camelCase**
- 类型:`PascalCase``Issue`、`AgentRuntime`),不加 `IPrefix`,不加 `_t` 后缀
- 枚举:优先用 string literal union需要 runtime 迭代时才用 `enum`
- TanStack Query key用 `<feature>/queries.ts` 里的工厂函数,例如 `issueKeys.detail(id)`
### Issue 编号
每个 issue 有人类可读的编号,比如 `MUL-123`:工作区 `issue_prefix`3 个大写字母)+ 流水号。前缀在工作区创建时定,之后不可改。
### 代码注释
**只允许英文**。Go 和 TypeScript 都强制。如果在代码里看到中文注释,那就是 bug替换掉。
### Commit message
Conventional 格式:`feat(scope)`、`fix(scope)`、`refactor(scope)`、`docs`、`test(scope)`、`chore(scope)`。按意图原子化分组。
---
## 2. i18n 翻译术语表
这是每个翻译 PR 都必须遵守的术语表。原本在 `packages/views/locales/glossary.md`,那个文件现在是个 stub指向这一页。
### 核心区分:实体 vs 概念
Multica 的产品名词分两类:
- **实体typed entity** —— 有 URL、有数据库 row、是 API 响应里某种 type 的东西。中文里**用小写英文**呈现,视觉上像类型名,告诉读者"这是 Multica 系统里的特定实体"。
- **概念concept** —— 不是数据库实体的普通名词。**完整翻译成中文**CN 用户看不到生硬的英文。
这套规则与 `apps/docs/content/docs/*.zh.mdx` 完全对齐 —— docs 是已经实战 20+ 篇的 CN voice 标准。
### 实体词的混合规则(`issue` / `skill` / `task`
`issue` / `skill` / `task` 是 Multica 的核心实体。schema 字段、API 字段、产品 UI 标签都用英文。中文里采用**混合规则** —— 词出现在哪里决定怎么写:
| 场景 | 写法 | 例 |
| --- | --- | --- |
| **UI 短句 / 状态名 / 代码上下文** | 小写英文 | "排队中的 task"、"创建子 issue"、"为智能体注入 skill" |
| **doc 标题 / 章节标题** | 首字母大写英文,**或**对应中文术语 | "Issue 与 project"、"Skills"、"执行任务" |
| **doc 正文长篇讨论中作为主语** | 中文术语,首次出现配括号英文 | "**执行任务**task是智能体每一次工作的单位" |
| **API / DB 字段** | 永远 `task` / `issue` / `skill` | `task_id`、`issue_status`、`skill_uuid` |
中文术语对照:
- `task` ↔ `执行任务`(上下文清楚后可简写为「任务」)
- `issue` 没有公认中文译法 —— 保留英文;标题可大写为 `Issue`
- `skill` 没有公认中文译法 —— 保留英文;标题可大写为 `Skills`
**为什么 `issue` / `skill` / `task` 不强制译,而 `project` / `autopilot` 必译**
- **`issue` / `task`**dev 团队习惯说英文,"任务"在中文里和"工作"几乎同义太空泛,"工单"是 IT 工单语义,"议题"是 GitHub 风格但用户场景不匹配 —— 三个候选都不如 `issue` 准确。**但**在长篇 doc 正文里,重复 50 次 `task` 节奏不顺,所以正文允许用 `执行任务`UI 短句、状态名仍保持小写英文。
- **`skill`**Multica 特有概念,没有公认中文译法。
- **`project` 翻成「项目」**:中文里早就稳定的日常词。飞书 / Tower / Teambition / PingCode / GitHub Projects 中文版 0 例外都翻译成「项目」,没有产品保留 `project`。
- **`autopilot` 翻成「自动化」**autopilot 在中文里联想到特斯拉的「自动驾驶」,跟产品功能(按周期跑 task对应不上。Notion / 飞书都用「自动化」,是行业共识。
### 完整翻译 —— 概念词
| 英 | 中 |
| --- | --- |
| Workspace | **工作区** |
| Agent | **智能体** |
| Project | **项目** |
| Autopilot | **自动化** |
| Daemon | **守护进程** |
| Runtime | **运行时** |
| Inbox | **收件箱** |
| Comment | **评论** |
| Reply | **回复** |
| Notifications | **通知** |
| Member | **成员** |
| Label | **标签** |
| Settings | **设置** |
| Onboarding | **上手引导** |
### 不翻 —— 品牌名 + 通用缩写
| 类别 | 词 |
| --- | --- |
| 品牌 | **Multica**、GitHub、Slack、Google、Anthropic、OpenAI、Claude、Codex、Cursor、Linear、Jira |
| 缩写 | API、CLI、URL、SDK、OAuth、JWT、SSO、WebSocket、HTTP、JSON、YAML、SQL |
### 完整翻译 —— 通用 UI 词
| 英 | 中 |
| --- | --- |
| Invite / Invitation | 邀请 |
| Search | 搜索 |
| Email | 邮箱label/ 邮件action |
| Password | 密码 |
| Sign in / Log in | 登录 |
| Sign up | 注册 |
| Sign out / Log out | 退出登录 |
| Save / Cancel / Delete | 保存 / 取消 / 删除 |
| Confirm / Continue / Back | 确认 / 继续 / 返回 |
| Edit / New / Create / Add | 编辑 / 新建 / 创建 / 添加 |
| Remove / Send / Open / Close | 移除 / 发送 / 打开 / 关闭 |
| Done / Loading... | 完成 / 加载中... |
| Profile / Account / Appearance | 个人资料 / 账号 / 外观 |
| Theme / Language | 主题 / 语言 |
| Light / Dark / System | 浅色 / 深色 / 跟随系统 |
| Active / Archived | 活跃(或 启用)/ 已归档 |
| Status / Priority | 状态 / 优先级 |
| Assignee / Reporter | 负责人 / 报告人 |
| Description / Title | 描述 / 标题 |
| Date / Time | 日期 / 时间 |
| Today / Yesterday / Tomorrow | 今天 / 昨天 / 明天 |
| Empty / Failed / Success | 空 / 失败 / 成功 |
| Error / Warning | 错误 / 警告 |
### 角色名 + 状态名(小写英文,不翻)
这些是 schema-level 标识符,中文环境也保持小写英文:
- 角色:`owner` / `admin` / `member`
- Issue 状态:`backlog` / `todo` / `in_progress` / `in_review` / `done` / `blocked` / `cancelled`
UI 里展示这些值时保持英文(必要时用 code-style 包起来):
- "你需要 owner 权限"
- "已切换到 in_progress"
### 词组组合规则
英文词(实体名 + 品牌名 + 缩写)与中文之间**加单空格**
- "Create new issue" → "新建 issue"
- "Assign to agent" → "分配给智能体"
- "Configure runtime" → "配置运行时"
- "Stop daemon" → "停止守护进程"
### 复数与计数
i18next 用 `_one` / `_other`;中文不区分语法单复数,只填 `_other`。
```json
// en/issues.json
{
"issue_count_one": "{{count}} issue",
"issue_count_other": "{{count}} issues"
}
// zh-Hans/issues.json
{
"issue_count_other": "{{count}} 个 issue"
}
```
常见计数格式:
- `{{count}} issues` → `{{count}} 个 issue`
- `{{count}} agents` → `{{count}} 个智能体`
- `{{count}} workspaces` → `{{count}} 个工作区`
- `{{count}} comments` → `{{count}} 条评论`
- `{{count}} members` → `{{count}} 位成员`
- `{{count}} skills` → `{{count}} 个 skill`
### 插值
用 `{{var}}` 形式。中文翻译可以调整位置以符合中文语序。
```json
// en
{ "welcome_message": "Welcome back, {{name}}!" }
// zh-Hans
{ "welcome_message": "欢迎回来,{{name}}" }
```
### Key 命名约定
3 层嵌套:`feature.component.action`。
```json
{
"feature_or_component": {
"subcomponent_or_section": {
"action_or_label": "..."
}
}
}
```
实例:
- `issues.toolbar.batch_update_success`
- `issues.detail.comment_form.placeholder`
- `inbox.empty.title`
- `settings.preferences.language.title`
### Web-only / Desktop-only 文案位置
- 共享文案:放 namespace JSON 顶层
- Web-only放 `web` 段
- Desktop-only放 `desktop` 段
参考 `auth.json``web` 段含 `prefer_desktop` / `desktop_handoff.*`)。
---
## 3. 中文风格
### 标点
- 中文用全角标点:`,。:;!?`
- 引号:用 `"..."`(直引号),与英文 source 保持一致。**不要**用 `「」` 或弯引号
- 省略号:用 `...`(三点)而非 `…`(单字符),与英文 source 保持一致
- 中英混排:英文词左右各加 1 个空格(详见词组组合规则)
### 风格原则
- **简洁直白**:避免翻译腔,"对于 X 来说"、"作为 X"、"我们的"
- **错误信息**:温和但明确,"无法保存修改" 优于 "保存修改失败了!"
- **按钮**动词开头2-4 字最佳。"取消"、"保存修改"、"立即同步"
- **Tooltip**:完整短句。"复制链接到剪贴板"
- **placeholder**:示例性提示。"输入 issue 标题..."
### 拿不准的时候去哪查
术语表没覆盖的词,按这个顺序查:
1. `apps/docs/content/docs/*.zh.mdx` —— CN voice 事实标准20+ 篇高度一致
2. `packages/views/locales/zh-Hans/auth.json` 和 `editor.json` —— JSON 结构 + selector API 用法参考
3. `packages/views/auth/login-page.tsx` —— 组件层 selector API 调用参考
4. `packages/views/settings/components/preferences-tab.tsx` —— 语言切换器参考
---
## 修改这一页时
改本页规则的同时还要:
1. 把规则在相关 locale JSON / CLAUDE.md / docs 页面里同步落地
2. PR 描述里写明改了什么,方便 reviewer 检查下游是否跟着改了
本页是契约,其他文档不能 override。

View File

@@ -0,0 +1,4 @@
{
"title": "Developers",
"pages": ["conventions"]
}

View File

@@ -1,4 +1,4 @@
{
"title": "Developers",
"pages": ["contributing", "architecture"]
"pages": ["contributing", "architecture", "conventions"]
}

View File

@@ -66,13 +66,19 @@ Multica stores user-uploaded attachments (images and files in comments). **S3 is
| Variable | Default | Description |
|---|---|---|
| `S3_BUCKET` | empty | Setting this enables S3 storage |
| `S3_REGION` | `us-west-2` | AWS region |
| `S3_BUCKET` | empty | **Bucket name only** (for example `my-bucket`). Do **not** include the `.s3.<region>.amazonaws.com` suffix — the server constructs the public host from `S3_BUCKET` + `S3_REGION`. Setting this enables S3 storage |
| `S3_REGION` | `us-west-2` | AWS region. Must match the bucket's actual region — it is used both for SDK signing and for building the public URL |
| `AWS_ACCESS_KEY_ID` / `AWS_SECRET_ACCESS_KEY` | empty | Static credentials. When both are unset, the AWS SDK default credential chain is used (IAM role / environment credentials) |
| `AWS_ENDPOINT_URL` | empty | Custom S3-compatible endpoint (for example [MinIO](https://min.io/)). Setting this switches to path-style URLs |
**When `S3_BUCKET` is unset**: the server logs `"S3_BUCKET not set, cloud upload disabled"` at startup, and all uploads fall back to local disk.
**Public URLs** are constructed in this order of priority:
1. `https://<CLOUDFRONT_DOMAIN>/<key>` if `CLOUDFRONT_DOMAIN` is set.
2. `<AWS_ENDPOINT_URL>/<S3_BUCKET>/<key>` (path-style) if `AWS_ENDPOINT_URL` is set.
3. `https://<S3_BUCKET>.s3.<S3_REGION>.amazonaws.com/<key>` (virtual-hosted-style). When `S3_BUCKET` contains dots, the server falls back to `https://s3.<S3_REGION>.amazonaws.com/<S3_BUCKET>/<key>` (path-style) because the AWS-issued wildcard TLS certificate does not validate dotted bucket hosts.
### Local disk (when S3 is not configured)
| Variable | Default | Description |

View File

@@ -66,13 +66,19 @@ Multica 存储用户上传的附件(评论里的图片、文件等)。**优
| 环境变量 | 默认值 | 说明 |
|---|---|---|
| `S3_BUCKET` | 空 | 设了就启用 S3 存储 |
| `S3_REGION` | `us-west-2` | AWS 区域 |
| `S3_BUCKET` | 空 | **只填 bucket 名**(例如 `my-bucket`**不要**带 `.s3.<region>.amazonaws.com` 后缀——server 会用 `S3_BUCKET` + `S3_REGION` 自己拼公开 host。设了就启用 S3 存储 |
| `S3_REGION` | `us-west-2` | AWS 区域。必须和 bucket 所在区域一致——SDK 签名和公开 URL 都用它 |
| `AWS_ACCESS_KEY_ID` / `AWS_SECRET_ACCESS_KEY` | 空 | 静态凭证。全未设时用 AWS SDK 默认凭证链IAM role / 环境凭证)|
| `AWS_ENDPOINT_URL` | 空 | 自定义 S3 兼容端点(例如 [MinIO](https://min.io/))。设了会切到 path-style URL |
**`S3_BUCKET` 未设时**server 启动时打 info 日志 `"S3_BUCKET not set, cloud upload disabled"`,所有上传回落到本地磁盘。
**公开 URL** 按优先级拼装:
1. 设了 `CLOUDFRONT_DOMAIN` → `https://<CLOUDFRONT_DOMAIN>/<key>`
2. 设了 `AWS_ENDPOINT_URL` → `<AWS_ENDPOINT_URL>/<S3_BUCKET>/<key>`path-style
3. 默认走 AWS S3 → `https://<S3_BUCKET>.s3.<S3_REGION>.amazonaws.com/<key>`virtual-hosted-style。bucket 名含点时会回落到 `https://s3.<S3_REGION>.amazonaws.com/<S3_BUCKET>/<key>`path-style因为 AWS 通配证书无法覆盖含点 host。
### 本地磁盘S3 未配时)
| 环境变量 | 默认值 | 说明 |

View File

@@ -212,13 +212,15 @@ Changes take effect after restarting the backend / compose stack. The web UI rea
### File Storage (Optional)
For file uploads and attachments, configure S3 and CloudFront:
For file uploads and attachments, configure S3 and (optionally) CloudFront:
| Variable | Description |
|----------|-------------|
| `S3_BUCKET` | S3 bucket name |
| `S3_REGION` | AWS region (default: `us-west-2`) |
| `CLOUDFRONT_DOMAIN` | CloudFront distribution domain |
| `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 |
| `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

@@ -40,7 +40,7 @@ The daemon auto-detects which CLIs are available on your PATH and registers them
Multica supports two layers of skills:
- **Local skills** — Skills already installed in your local runtime (e.g., `.claude/skills/`, `.config/opencode/skills/`) are automatically discovered and used by agents. You do **not** need to upload them to Multica.
- **Local skills** — Skills already installed in your local runtime (e.g., `.claude/skills/`, `.opencode/skills/`) are automatically discovered and used by agents. You do **not** need to upload them to Multica.
- **Workspace skills** — Skills created or imported in the Multica Skills page are shared across the workspace. They are automatically injected into agent runs as supplementary context, so every team member's agents benefit from them.
Workspace skills are designed for team-wide sharing and collaboration — codify your team's best practices once, and every agent can leverage them:

View File

@@ -9,6 +9,7 @@
"workspaces",
"members-roles",
"issues",
"projects",
"comments",
"project-resources",
"---Agents---",
@@ -33,6 +34,8 @@
"---Reference---",
"cli",
"auth-tokens",
"desktop-app"
"desktop-app",
"---Developers---",
"developers"
]
}

View File

@@ -9,6 +9,7 @@
"workspaces",
"members-roles",
"issues",
"projects",
"comments",
"---智能体---",
"agents",
@@ -32,6 +33,8 @@
"---参考---",
"cli",
"auth-tokens",
"desktop-app"
"desktop-app",
"---开发者---",
"developers"
]
}

View File

@@ -0,0 +1,49 @@
---
title: Projects
description: Group related issues and track them as one unit — with priority, status, progress, and an owner.
---
import { Callout } from "fumadocs-ui/components/callout";
A **project** in Multica is a container for related [issues](/issues). Use it when a body of work is bigger than one issue but smaller than a full workspace — a launch, a migration, a feature with multiple parts, an investigation that branches into several threads.
Each project has a name, an icon, a description, a **lead** (a member or an [agent](/agents)), a **status** (`planned` / `in_progress` / `paused` / `completed` / `cancelled`), a **priority** (`urgent` / `high` / `medium` / `low` / `none`), and a **progress** percentage that's auto-derived from the status of its linked issues.
## How projects relate to issues
Projects and issues are independent objects with a many-to-one relationship: an issue can belong to **at most one** project; a project holds **any number of** issues. Linking and unlinking is reversible at any time — drag in the board view, or use the project picker on the issue's right-side properties panel.
The progress bar on a project is computed from its linked issues — the more issues hit `done`, the further it fills. Issues that are `cancelled` are excluded from the count; issues in `backlog` count toward the denominator but not the numerator.
## Pinning to the sidebar
Click the pin icon in a project's top-right corner to add it to your sidebar's pinned list. Pinned projects stay one click away no matter where you are in the workspace; everyone on the team can pin independently — pins are personal.
The sidebar **Workspace → Projects** link always shows every project in the workspace; pinning is a personal shortcut on top of that.
## Attaching resources
Each project has a **Resources** section where you attach GitHub repositories. Once attached, any [agent](/agents) assigned to issues in this project can read and write to those repos when executing tasks — Multica passes the repo URLs as context to the [daemon](/daemon-runtimes).
Resources are per-project; if multiple projects share a repo, attach it to each one.
## Deleting a project
Deleting a project **does not delete its issues**. The linked issues are simply unlinked and revert to the workspace's flat issue list. This is intentional — work that was scoped to a project is rarely throwaway, even when the framing of the project changes.
<Callout type="info">
If you want to delete the work too, archive or delete the issues first, then delete the project.
</Callout>
## Project lead
The lead is the person — or agent — accountable for the project. It's a soft signal, not an access control: any workspace member can edit a project regardless of who's lead. A project's lead can be:
- A workspace member (human teammate)
- An [agent](/agents) — useful when the project's work is mostly delegated to an agent (e.g., "Weekly bug triage" led by a triage agent)
## Next
- [Issues](/issues) — the unit of work that lives inside projects
- [Agents as project lead](/agents) — when an agent is the right owner
- [How Multica works](/how-multica-works) — the broader picture

View File

@@ -0,0 +1,49 @@
---
title: 项目
description: 把相关的 issue 归为一组当成一个单元来跟进 —— 有优先级、状态、进度和负责人。
---
import { Callout } from "fumadocs-ui/components/callout";
Multica 里的**项目**project是相关 [issue](/issues) 的容器。当一摊工作比单个 issue 大、又比整个工作区小的时候用它 —— 一次发布、一次迁移、一个分多块做的功能、一个会拆出多个线索的调研。
每个项目有名字、图标、描述、**负责人**lead可以是成员也可以是 [智能体](/agents))、**状态**`planned` / `in_progress` / `paused` / `completed` / `cancelled`)、**优先级**`urgent` / `high` / `medium` / `low` / `none`),以及一个根据关联 issue 状态自动算出来的**进度**百分比。
## 项目和 issue 的关系
项目和 issue 是独立对象,多对一关系:一个 issue **最多属于一个**项目;一个项目可以容纳**任意多个** issue。关联和解除关联随时可逆 —— 在看板视图里拖动,或者在 issue 右侧 properties 面板用项目选择器。
项目的进度条是按关联 issue 状态自动算出来的 —— 越多 issue 到 `done`,进度条越满。`cancelled` 的 issue 不计入分母;`backlog` 的 issue 计入分母但不计入分子。
## pin 到侧边栏
点项目右上角的 pin 图标可以把这个项目加到侧边栏的固定区。pin 过的项目无论你在工作区哪里都一键可达;每个人独立 pin —— pin 是个人偏好。
侧边栏 **Workspace → Projects** 链接始终展示工作区里所有项目pin 只是在这之上的个人快捷方式。
## 关联 resources
每个项目有一个 **Resources** 区,可以挂 GitHub 仓库。挂上之后,被分配到这个项目里 issue 的 [智能体](/agents) 在执行 task 时可以读写这些仓库 —— Multica 会把仓库 URL 作为上下文传给 [守护进程](/daemon-runtimes)。
Resources 是项目级别的;多个项目要共享同一个仓库,要分别挂上。
## 删除项目
删除项目**不会**删除它的 issue。关联的 issue 只是解除关联,回到工作区的扁平 issue 列表。这是刻意的 —— 即使项目本身的框架变了,里面的工作通常也不会是一次性的。
<Callout type="info">
如果你确实想把工作也删掉,先归档或删除 issue再删除项目。
</Callout>
## 项目负责人
负责人是为这个项目负总责的人 —— 或者智能体。这是一个软信号,不是权限控制:工作区任何成员都可以编辑项目,不管谁是负责人。项目负责人可以是:
- 工作区里的成员(人)
- [智能体](/agents) —— 当项目里的工作大部分要交给智能体时合适(例如"每周 bug 巡检"由一个巡检智能体担任 lead
## 下一步
- [Issues](/issues) —— 项目里装的工作单元
- [智能体担任项目负责人](/agents) —— 什么时候由智能体当 lead 合适
- [Multica 怎么运转](/how-multica-works) —— 整体视图

View File

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

View File

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

View File

@@ -116,4 +116,4 @@ Same flow as Cloud — see [Cloud quickstart → Steps 5-6](/cloud-quickstart#5-
- [Environment variables](/environment-variables) — full env reference
- [Auth setup](/auth-setup) — Resend / OAuth / signup allowlist in detail
- [Troubleshooting](/troubleshooting) — start here when things go wrong
- [Desktop app](/desktop-app) — released Desktop builds connect to Multica Cloud only; using Desktop with self-host requires a custom build (see the callout in the desktop-app page)
- [Desktop app](/desktop-app) — optional Desktop setup via `~/.multica/desktop.json`; the web frontend + CLI remains the quickest self-host path

View File

@@ -115,4 +115,4 @@ multica setup self-host
- [环境变量](/environment-variables) —— 完整 env 清单
- [登录与注册配置](/auth-setup) —— Resend / OAuth / 注册白名单详细配置
- [故障排查](/troubleshooting) —— 遇到问题先来这里
- [桌面应用](/desktop-app) —— 发布版 Desktop 只连 Multica Cloud要让 Desktop 连自部署后端需要自行构建(详见 desktop-app 页的提示)
- [桌面应用](/desktop-app) —— 可以通过 `~/.multica/desktop.json` 连接 DesktopWeb 前端 + CLI 仍然是最快的自部署路径

View File

@@ -63,6 +63,8 @@ Automatic retry also has two extra conditions:
<Callout type="warning">
**Autopilot tasks don't retry automatically** by design. An Autopilot has its own firing cadence (e.g. daily); automatic retries on failure would overlap with the next scheduled run. If you need an immediate re-run after failure, use a manual rerun (next section).
**How you'll know an Autopilot task failed**: a notification lands in your [Inbox](/inbox), and the associated issue's status reverts from `in_progress` back to `todo`. The [Autopilots](/autopilots) page also shows the latest run result per autopilot.
</Callout>
## Manual rerun vs. automatic retry

View File

@@ -63,6 +63,8 @@ Multica 服务器每 30 秒扫描一次,有两种超时会触发失败:
<Callout type="warning">
**Autopilots 任务不自动重试**是刻意设计。Autopilot 有自己的触发周期(例如每天一次);如果失败又自动重试,会和下一个周期的任务重叠。需要失败后立即重跑,用手动重跑(下一节)。
**怎么知道 Autopilot 失败了**:失败的 Autopilot 任务会在你的 [收件箱](/inbox) 里出现一条通知,关联的 issue 状态也会从 `in_progress` 退回 `todo`。直接打开 [Autopilots](/autopilots) 页面也能看到每条 autopilot 的最近运行结果。
</Callout>
## 手动重跑和自动重试的区别

View File

@@ -0,0 +1,52 @@
import { describe, expect, it } from "vitest";
import { prefixLocale } from "./locale-link";
describe("prefixLocale", () => {
it("prefixes root-relative paths with the active non-default locale", () => {
expect(prefixLocale("/workspaces", "zh")).toBe("/zh/workspaces");
expect(prefixLocale("/agents-create", "zh")).toBe("/zh/agents-create");
});
it("preserves anchors and query strings on prefixed paths", () => {
expect(prefixLocale("/providers#claude-code", "zh")).toBe(
"/zh/providers#claude-code",
);
expect(prefixLocale("/agents?from=docs", "zh")).toBe(
"/zh/agents?from=docs",
);
});
it("rewrites the bare root path to the locale root", () => {
expect(prefixLocale("/", "zh")).toBe("/zh");
});
it("leaves the default language untouched (URLs are prefix-less)", () => {
expect(prefixLocale("/workspaces", "en")).toBe("/workspaces");
expect(prefixLocale("/", "en")).toBe("/");
});
it("does not double-prefix paths that already carry a known locale", () => {
expect(prefixLocale("/zh/workspaces", "zh")).toBe("/zh/workspaces");
expect(prefixLocale("/en/workspaces", "zh")).toBe("/en/workspaces");
});
it("leaves external URLs alone", () => {
expect(prefixLocale("https://multica.ai/download", "zh")).toBe(
"https://multica.ai/download",
);
expect(prefixLocale("mailto:hello@multica.ai", "zh")).toBe(
"mailto:hello@multica.ai",
);
expect(prefixLocale("tel:+1234567890", "zh")).toBe("tel:+1234567890");
});
it("leaves in-page anchors and relative paths alone", () => {
expect(prefixLocale("#section", "zh")).toBe("#section");
expect(prefixLocale("./sibling", "zh")).toBe("./sibling");
expect(prefixLocale("../sibling", "zh")).toBe("../sibling");
});
it("returns empty/undefined hrefs unchanged", () => {
expect(prefixLocale("", "zh")).toBe("");
});
});

View File

@@ -0,0 +1,31 @@
import { i18n } from "./i18n";
// Add the active locale prefix to root-relative MDX links so internal
// navigation inside Chinese (or any non-default-language) docs stays in
// that language. Without this, `[xx](/workspaces)` written in a `*.zh.mdx`
// renders as `<a href="/workspaces">`, which Next's basePath rewrites to
// `/docs/workspaces` and the docs middleware then routes to English —
// leaking the reader out of their chosen locale.
//
// We deliberately do NOT touch:
// - external links (`https:`, `mailto:`, `tel:`, etc.)
// - in-page anchors (`#section`)
// - relative paths (`./foo`, `../bar`)
// - paths already prefixed with a known locale
// - the default language (URLs are intentionally prefix-less under
// `hideLocale: 'default-locale'`)
export function prefixLocale(href: string, lang: string): string {
if (!href) return href;
if (lang === i18n.defaultLanguage) return href;
if (/^[a-z][a-z0-9+.-]*:/i.test(href)) return href;
if (href.startsWith("#")) return href;
if (!href.startsWith("/")) return href;
const segments = href.split("/").filter(Boolean);
const first = segments[0];
if (first && (i18n.languages as readonly string[]).includes(first)) {
return href;
}
return href === "/" ? `/${lang}` : `/${lang}${href}`;
}

View File

@@ -8,6 +8,7 @@
"build": "fumadocs-mdx && next build",
"start": "next start",
"typecheck": "fumadocs-mdx && tsc --noEmit",
"test": "vitest run",
"postinstall": "fumadocs-mdx"
},
"dependencies": {
@@ -27,6 +28,7 @@
"@types/react": "catalog:",
"@types/react-dom": "catalog:",
"tailwindcss": "catalog:",
"typescript": "catalog:"
"typescript": "catalog:",
"vitest": "catalog:"
}
}

View File

@@ -0,0 +1,16 @@
import { defineConfig } from "vitest/config";
import path from "path";
export default defineConfig({
test: {
environment: "node",
globals: true,
include: ["**/*.test.{ts,tsx}"],
exclude: ["node_modules/**", ".next/**", ".source/**"],
},
resolve: {
alias: {
"@": path.resolve(__dirname, "."),
},
},
});

View File

@@ -2,12 +2,22 @@ import { describe, it, expect, vi, beforeEach } from "vitest";
import { render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { I18nProvider } from "@multica/core/i18n/react";
import enCommon from "@multica/views/locales/en/common.json";
import enAuth from "@multica/views/locales/en/auth.json";
import enSettings from "@multica/views/locales/en/settings.json";
import type { ReactNode } from "react";
const TEST_RESOURCES = {
en: { common: enCommon, auth: enAuth, settings: enSettings },
};
function createWrapper() {
const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } });
return ({ children }: { children: ReactNode }) => (
<QueryClientProvider client={qc}>{children}</QueryClientProvider>
<I18nProvider locale="en" resources={TEST_RESOURCES}>
<QueryClientProvider client={qc}>{children}</QueryClientProvider>
</I18nProvider>
);
}

View File

@@ -26,6 +26,7 @@ import { captureDownloadIntent } from "@multica/core/analytics";
import { setLoggedInCookie } from "@/features/auth/auth-cookie";
import Link from "next/link";
import { LoginPage, validateCliCallback } from "@multica/views/auth";
import { useT } from "@multica/views/i18n";
/**
* Pick where a logged-in user with no explicit `?next=` should land.
@@ -56,6 +57,7 @@ async function resolveLoggedInDestination(
function LoginPageContent() {
const router = useRouter();
const qc = useQueryClient();
const { t } = useT("auth");
const googleClientId = useConfigStore((state) => state.googleClientId);
const user = useAuthStore((s) => s.user);
const isLoading = useAuthStore((s) => s.isLoading);
@@ -93,7 +95,9 @@ function LoginPageContent() {
})
.catch((err) => {
setDesktopError(
err instanceof Error ? err.message : "Failed to prepare Desktop sign-in",
err instanceof Error
? err.message
: t(($) => $.web.desktop_handoff.prepare_failed),
);
});
return;
@@ -140,7 +144,9 @@ function LoginPageContent() {
<div className="flex min-h-screen items-center justify-center">
<Card className="w-full max-w-sm">
<CardHeader className="text-center">
<CardTitle className="text-2xl">Sign-in Failed</CardTitle>
<CardTitle className="text-2xl">
{t(($) => $.web.desktop_handoff.failed_title)}
</CardTitle>
<CardDescription>{desktopError}</CardDescription>
</CardHeader>
</Card>
@@ -151,11 +157,13 @@ function LoginPageContent() {
<div className="flex min-h-screen items-center justify-center">
<Card className="w-full max-w-sm">
<CardHeader className="text-center">
<CardTitle className="text-2xl">Opening Multica</CardTitle>
<CardTitle className="text-2xl">
{t(($) => $.web.desktop_handoff.opening_title)}
</CardTitle>
<CardDescription>
{desktopToken
? "You should see a prompt to open the Multica desktop app. If nothing happens, click the button below."
: "Preparing Desktop sign-in..."}
? t(($) => $.web.desktop_handoff.opening_description)
: t(($) => $.web.desktop_handoff.preparing)}
</CardDescription>
</CardHeader>
<CardContent className="flex justify-center">
@@ -166,7 +174,7 @@ function LoginPageContent() {
window.location.href = `multica://auth/callback?token=${encodeURIComponent(desktopToken)}`;
}}
>
Open Multica Desktop
{t(($) => $.web.desktop_handoff.open_button)}
</Button>
) : (
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
@@ -196,18 +204,14 @@ function LoginPageContent() {
}
onTokenObtained={setLoggedInCookie}
extra={
// Web-only nudge toward the desktop app. Copy is hardcoded EN
// for now because the login route sits outside the landing
// group's LocaleProvider — if this page ever becomes
// locale-aware, the strings live in positioning doc §3.3.
<span className="text-xs text-muted-foreground">
Prefer the desktop app?{" "}
{t(($) => $.web.prefer_desktop)}{" "}
<Link
href="/download"
onClick={() => captureDownloadIntent("login")}
className="font-medium text-foreground underline decoration-foreground/30 underline-offset-4 hover:decoration-foreground/70"
>
Download
{t(($) => $.web.download)}
</Link>
</span>
}

View File

@@ -1,5 +1,6 @@
import { cookies, headers } from "next/headers";
import { Instrument_Serif, Noto_Serif_SC } from "next/font/google";
import { LOCALE_COOKIE } from "@multica/core/i18n";
import { LocaleProvider } from "@/features/landing/i18n";
import type { Locale } from "@/features/landing/i18n";
@@ -43,7 +44,7 @@ const jsonLd = {
async function getInitialLocale(): Promise<Locale> {
// 1. User's explicit preference (cookie set when they switch language)
const cookieStore = await cookies();
const stored = cookieStore.get("multica-locale")?.value;
const stored = cookieStore.get(LOCALE_COOKIE)?.value;
if (stored === "en" || stored === "zh") return stored;
// 2. Detect from Accept-Language header

View File

@@ -1,10 +1,16 @@
import type { Metadata, Viewport } from "next";
import { headers } from "next/headers";
import { Inter, Geist_Mono, Source_Serif_4 } from "next/font/google";
import { ThemeProvider } from "@/components/theme-provider";
import { Toaster } from "@multica/ui/components/ui/sonner";
import { cn } from "@multica/ui/lib/utils";
import { WebProviders } from "@/components/web-providers";
import { LocaleSync } from "@/components/locale-sync";
import {
DEFAULT_LOCALE,
SUPPORTED_LOCALES,
type SupportedLocale,
} from "@multica/core/i18n";
import { RESOURCES } from "@multica/views/locales";
import "./globals.css";
// Font stack: Inter for Latin UI text + system Chinese fonts for zh content.
@@ -97,21 +103,40 @@ export const metadata: Metadata = {
},
};
export default function RootLayout({
function isSupportedLocale(value: string | null): value is SupportedLocale {
return value !== null && (SUPPORTED_LOCALES as readonly string[]).includes(value);
}
// HTML lang attribute uses BCP-47 region tags that screen readers and font
// stacks recognize widely. i18next keeps `zh-Hans` as its internal locale
// (script subtag is what we actually translate against), but the html element
// expects a region-flavoured tag for accessibility tooling and CJK fallback.
const HTML_LANG: Record<SupportedLocale, string> = {
en: "en",
"zh-Hans": "zh-CN",
};
export default async function RootLayout({
children,
}: {
children: React.ReactNode;
}) {
const h = await headers();
const headerLocale = h.get("x-multica-locale");
const locale: SupportedLocale = isSupportedLocale(headerLocale)
? headerLocale
: DEFAULT_LOCALE;
const resources = { [locale]: RESOURCES[locale] };
return (
<html
lang="en"
lang={HTML_LANG[locale]}
suppressHydrationWarning
className={cn("antialiased font-sans h-full", inter.variable, geistMono.variable, sourceSerif.variable)}
>
<body className="h-full overflow-hidden">
<LocaleSync />
<ThemeProvider>
<WebProviders>
<WebProviders locale={locale} resources={resources}>
{children}
</WebProviders>
<Toaster />

View File

@@ -1,61 +1,19 @@
import { Instrument_Serif } from "next/font/google";
"use client";
// Editorial-style 404. Cream + ink + terracotta palette is intentionally
// inline — these brand experiments have not been promoted to design tokens.
// The route lives outside the (landing) group's font scope, so we attach
// Instrument Serif locally to match the editorial direction.
const CREAM = "#faf9f6";
const INK = "#1b1812";
const TERRACOTTA = "#a64a2c";
const editorialSerif = Instrument_Serif({
subsets: ["latin"],
weight: "400",
variable: "--font-serif",
});
import Link from "next/link";
import { buttonVariants } from "@multica/ui/components/ui/button";
export default function NotFound() {
return (
<section
className={`${editorialSerif.variable} relative flex min-h-screen flex-col items-center justify-center px-6 py-16`}
style={{ backgroundColor: CREAM, color: INK }}
>
{/* tracking is wider than Tailwind's tracking-widest (0.1em) — editorial eyebrow detail, deliberate. */}
<div
className="flex items-center gap-3 text-xs uppercase tracking-[0.25em]"
style={{ color: TERRACOTTA }}
>
<span aria-hidden="true" className="inline-block h-px w-10" style={{ background: TERRACOTTA }} />
<span>error · not found</span>
<span aria-hidden="true" className="inline-block h-px w-10" style={{ background: TERRACOTTA }} />
</div>
{/* Fluid hero size + ultra-tight leading; outside the Tailwind type scale by design. */}
<h1 className="mt-12 font-serif text-[clamp(7rem,16vw,15rem)] leading-[0.85] tracking-tight">
404
</h1>
<p className="mt-10 max-w-xl text-center font-serif text-3xl leading-tight">
This page{" "}
<em className="not-italic" style={{ color: TERRACOTTA }}>
doesn&rsquo;t exist
</em>
.
<main className="flex min-h-screen flex-col items-center justify-center gap-6 px-6 py-24 text-center">
<p className="text-sm font-medium text-muted-foreground">404</p>
<h1 className="text-2xl font-semibold tracking-tight">Page not found</h1>
<p className="max-w-md text-sm text-muted-foreground">
The page you are looking for doesn&rsquo;t exist or has been moved.
</p>
<p
className="mt-5 max-w-md text-center text-sm leading-relaxed"
style={{ color: INK, opacity: 0.6 }}
>
The URL may have changed, the resource may be deleted, or you arrived from a stale link.
</p>
<a
href="/"
className="mt-12 inline-flex h-10 items-center rounded-full px-6 text-sm font-medium transition hover:opacity-90"
style={{ background: INK, color: CREAM }}
>
<Link href="/" className={buttonVariants({ className: "mt-2" })}>
Back to Multica
</a>
</section>
</Link>
</main>
);
}

View File

@@ -1,20 +0,0 @@
"use client";
import { useEffect } from "react";
/**
* Reads the locale cookie on the client and updates <html lang>.
* This avoids calling cookies() in the root Server Component layout,
* which would mark the entire app as dynamic and disable the Router Cache.
*/
export function LocaleSync() {
useEffect(() => {
const match = document.cookie.match(/(?:^|;\s*)multica-locale=(\w+)/);
const locale = match?.[1];
if (locale === "zh") {
document.documentElement.lang = "zh";
}
}, []);
return null;
}

View File

@@ -2,6 +2,8 @@
import { Suspense, useMemo } from "react";
import { CoreProvider } from "@multica/core/platform";
import { createBrowserCookieLocaleAdapter } from "@multica/core/i18n/browser";
import type { LocaleResources, SupportedLocale } from "@multica/core/i18n";
import packageJson from "../package.json";
import { WebNavigationProvider } from "@/platform/navigation";
import {
@@ -41,7 +43,15 @@ function deriveWsUrl(): string | undefined {
const WEB_VERSION =
process.env.NEXT_PUBLIC_APP_VERSION || packageJson.version || "dev";
export function WebProviders({ children }: { children: React.ReactNode }) {
export function WebProviders({
children,
locale,
resources,
}: {
children: React.ReactNode;
locale: SupportedLocale;
resources: Record<string, LocaleResources>;
}) {
const cookieAuth = !hasLegacyToken();
// Stable identity reference so downstream effects keyed on it don't see a
// new object on every parent render.
@@ -49,6 +59,7 @@ export function WebProviders({ children }: { children: React.ReactNode }) {
() => ({ platform: "web", version: WEB_VERSION }),
[],
);
const localeAdapter = useMemo(() => createBrowserCookieLocaleAdapter(), []);
return (
<CoreProvider
apiBaseUrl={process.env.NEXT_PUBLIC_API_URL}
@@ -57,6 +68,9 @@ export function WebProviders({ children }: { children: React.ReactNode }) {
onLogin={setLoggedInCookie}
onLogout={clearLoggedInCookie}
identity={identity}
locale={locale}
resources={resources}
localeAdapter={localeAdapter}
>
{/* Suspense boundary is required by Next.js for useSearchParams in
a client component mounted this high in the tree. */}

View File

@@ -2,6 +2,7 @@
import { createContext, useContext, useState, useCallback, useMemo } from "react";
import { useConfigStore } from "@multica/core/config";
import { LOCALE_COOKIE } from "@multica/core/i18n";
import { createEnDict } from "./en";
import { createZhDict } from "./zh";
import type { LandingDict, Locale } from "./types";
@@ -11,7 +12,6 @@ const dictionaryFactories: Record<Locale, (allowSignup: boolean) => LandingDict>
zh: createZhDict,
};
const COOKIE_NAME = "multica-locale";
const COOKIE_MAX_AGE = 60 * 60 * 24 * 365; // 1 year
type LocaleContextValue = {
@@ -38,7 +38,11 @@ export function LocaleProvider({
const setLocale = useCallback((l: Locale) => {
setLocaleState(l);
document.cookie = `${COOKIE_NAME}=${l}; path=/; max-age=${COOKIE_MAX_AGE}; SameSite=Lax`;
const secure =
typeof location !== "undefined" && location.protocol === "https:"
? "; Secure"
: "";
document.cookie = `${LOCALE_COOKIE}=${l}; path=/; max-age=${COOKIE_MAX_AGE}; SameSite=Lax${secure}`;
}, []);
return (

View File

@@ -94,7 +94,7 @@ export function createEnDict(allowSignup: boolean): LandingDict {
label: "RUNTIMES",
title: "One dashboard for all your compute",
description:
"Local daemons and cloud runtimes, managed from a single panel. Real-time monitoring of online/offline status, usage charts, and activity heatmaps. Auto-detects local CLIs \u2014 plug in and go.",
"Local daemons and cloud runtimes, managed from a single panel. Real-time monitoring of online/offline status, usage charts, and activity heatmaps. Auto-detects 11 supported coding tools on your machine.",
cards: [
{
title: "Unified runtime panel",
@@ -107,9 +107,9 @@ export function createEnDict(allowSignup: boolean): LandingDict {
"Online/offline status, usage charts, and activity heatmaps. Know exactly what your compute is doing at any moment.",
},
{
title: "Auto-detection & plug-and-play",
title: "Auto-detection on first run",
description:
"Multica detects available CLIs like Claude Code, Codex, OpenClaw, and OpenCode automatically. Connect a machine, and it\u2019s ready to work.",
"Multica scans for 11 supported coding tools \u2014 Claude Code, Codex, Cursor, Copilot, Gemini, Hermes, Kimi, Kiro CLI, OpenCode, OpenClaw, and Pi \u2014 and registers a runtime for each one it finds.",
},
],
},
@@ -129,7 +129,7 @@ export function createEnDict(allowSignup: boolean): LandingDict {
{
title: "Install the CLI & connect your machine",
description:
"Run multica setup to configure, authenticate, and start the daemon. It auto-detects Claude Code, Codex, OpenClaw, and OpenCode on your machine \u2014 plug in and go.",
"Run multica setup \u2014 it walks you through OAuth, starts the daemon, and scans for the 11 supported coding tools (Claude Code, Codex, Cursor, Copilot, Gemini, Hermes, Kimi, Kiro CLI, OpenCode, OpenClaw, Pi). Whichever ones you already have installed get registered as runtimes automatically.",
},
{
title: "Create your first agent",
@@ -185,7 +185,7 @@ export function createEnDict(allowSignup: boolean): LandingDict {
{
question: "What coding agents does Multica support?",
answer:
"Multica currently supports Claude Code, Codex, OpenClaw, and OpenCode out of the box. The daemon auto-detects whichever CLIs you have installed. Since it\u2019s open source, you can also add your own backends.",
"Multica supports 11 coding tools out of the box: Claude Code, Codex, Cursor, Copilot, Gemini, Hermes, Kimi, Kiro CLI, OpenCode, OpenClaw, and Pi. The daemon auto-detects whichever CLIs you already have installed and registers a runtime for each one. Since it's open source, you can also add your own backends.",
},
{
question: "Do I need to self-host, or is there a cloud version?",
@@ -283,6 +283,75 @@ export function createEnDict(allowSignup: boolean): LandingDict {
fixes: "Bug Fixes",
},
entries: [
{
version: "0.2.26",
date: "2026-05-06",
title: "Full i18n Rollout, Long-Issue Timeline & System Notifications Toggle",
changes: [],
features: [
"Web app fully translated to Simplified Chinese (21 namespaces), with per-user locale",
"System Notifications toggle in Settings",
"Delete chat sessions; History panel surfaced on the chat header",
"Runtime liveness backed by Redis, with DB fallback",
"Desktop loads runtime self-host config",
"CLI adds `--assignee-id` / `--to-id` / `--user-id` for unambiguous targeting",
],
improvements: [
"Settings 'Appearance' tab is renamed to 'Preferences', and the active tab is reflected in the URL so deep links work",
"Long issues open instantly — Timeline switched to cursor-based keyset pagination, and repeated `task_completed` / `task_failed` activity entries are coalesced",
"Runtime poll and heartbeat schedules are isolated per-runtime, so one busy runtime can no longer starve others",
"CLI update requests persist in Redis, so a server restart no longer drops them",
"Runtime cost usage window narrowed from 180 days to 14 days, dropping query load",
"Project list returns a `resource_count` instead of inlining all resources, keeping responses lean",
"404 page redesigned, with the No-Access redirect loop fixed",
"Quick Create exempts git-describe daemons from the CLI version gate",
"CI now enforces lint on every PR, and the existing lint debt has been cleared",
],
fixes: [
"Daemon cancels the running agent when the task is deleted server-side, eliminating orphan processes",
"Daemon refreshes a stale Codex `auth.json` when reusing an exec env, fixing intermittent auth errors",
"Daemon refuses to write `.gc_meta.json` when `issue_id` is empty",
"Session / resume across ACP backends now trusts the agent-reported session id, fixing cross-session bleed",
"OpenCode skills are written under `.opencode/skills/` so they are discovered natively",
"404 task-not-found semantics tightened on both server and the final guard",
"Pinned sidebar rows are auto-unpinned when the underlying entity disappears",
"Project detail page splits desktop and mobile sidebar state",
"Runtime detail page hides archived agents",
"Already-attached repos in Add Resource show a URL tooltip; empty project state has a New Issue button",
"S3 public URLs are region-qualified, fixing cross-region access",
"Windows installer parses version numbers and decodes checksums correctly",
"Quick Create submit button no longer shows a duplicate keyboard shortcut",
],
},
{
version: "0.2.24",
date: "2026-05-03",
title: "Repo Checkout `--ref`, Hermes Replay Fix & Multi-Replica Model Picker",
changes: [],
features: [
"`multica repo checkout --ref` targets a branch, tag, or specific commit when pulling a repo into the workspace",
"`multica agent avatar` uploads an agent avatar straight from the CLI",
"Inbox shows an archive button on done tasks; the redundant mark-as-done hover button is gone",
],
improvements: [
"Long-timeline issues open instantly from Inbox — the markdown render pipeline is memoized so unrelated WS events no longer re-render thousands of comments",
"Model picker works on multi-replica deployments — pending requests persist via Redis, with daemon retries on transient report failures",
"Daemon empty-claim cache TTL bumped, further reducing idle DB load",
],
fixes: [
"Newly created agents show up everywhere immediately — the agent cache is hydrated on create",
"Hermes no longer replays the previous answer when a new turn starts — historical chunks are gated behind a per-turn flag",
"Codex runtime model picker exposes the GPT-5.5 family",
"`multica login --token <PAT>` accepts the PAT as a flag value instead of rejecting it",
"CLI update completion status is now reliable",
"Session resume is guarded by runtime, preventing cross-runtime resume",
"Kanban display settings survive when dragging issues across columns",
"Autopilot list is responsive on mobile viewports",
"Quick Create prompts produce higher-fidelity descriptions from the user's input",
"Skill upsert sanitizes null bytes, fixing a PostgreSQL UTF8 error",
"Connect Remote dialog points to the correct install script URL",
],
},
{
version: "0.2.21",
date: "2026-04-30",

View File

@@ -13,42 +13,42 @@ export function createZhDict(allowSignup: boolean): LandingDict {
headlineLine1: "\u4f60\u7684\u4e0b\u4e00\u6279\u5458\u5de5",
headlineLine2: "\u4e0d\u662f\u4eba\u7c7b\u3002",
subheading:
"Multica \u662f\u4e00\u4e2a\u5f00\u6e90\u5e73\u53f0\uff0c\u5c06\u7f16\u7801 Agent \u53d8\u6210\u771f\u6b63\u7684\u961f\u53cb\u3002\u5206\u914d\u4efb\u52a1\u3001\u8ddf\u8e2a\u8fdb\u5ea6\u3001\u79ef\u7d2f\u6280\u80fd\u2014\u2014\u5728\u4e00\u4e2a\u5730\u65b9\u7ba1\u7406\u4f60\u7684\u4eba\u7c7b + Agent \u56e2\u961f\u3002",
"Multica \u662f\u4e00\u4e2a\u5f00\u6e90\u5e73\u53f0\uff0c\u5c06\u7f16\u7801 智能体 \u53d8\u6210\u771f\u6b63\u7684\u961f\u53cb\u3002\u5206\u914d\u4efb\u52a1\u3001\u8ddf\u8e2a\u8fdb\u5ea6\u3001\u79ef\u7d2f\u6280\u80fd\u2014\u2014\u5728\u4e00\u4e2a\u5730\u65b9\u7ba1\u7406\u4f60\u7684\u4eba\u7c7b + 智能体 \u56e2\u961f\u3002",
cta: "免费开始",
downloadDesktop: "下载桌面端",
worksWith: "支持",
imageAlt: "Multica \u770b\u677f\u89c6\u56fe\u2014\u2014\u4eba\u7c7b\u548c Agent \u534f\u540c\u7ba1\u7406\u4efb\u52a1",
imageAlt: "Multica \u770b\u677f\u89c6\u56fe\u2014\u2014\u4eba\u7c7b\u548c 智能体 \u534f\u540c\u7ba1\u7406\u4efb\u52a1",
},
features: {
teammates: {
label: "\u56e2\u961f\u534f\u4f5c",
title: "\u50cf\u5206\u914d\u7ed9\u540c\u4e8b\u4e00\u6837\u5206\u914d\u7ed9 Agent",
title: "\u50cf\u5206\u914d\u7ed9\u540c\u4e8b\u4e00\u6837\u5206\u914d\u7ed9 智能体",
description:
"Agent \u4e0d\u662f\u88ab\u52a8\u5de5\u5177\u2014\u2014\u5b83\u4eec\u662f\u4e3b\u52a8\u53c2\u4e0e\u8005\u3002\u5b83\u4eec\u62e5\u6709\u4e2a\u4eba\u8d44\u6599\u3001\u62a5\u544a\u72b6\u6001\u3001\u521b\u5efa Issue\u3001\u53d1\u8868\u8bc4\u8bba\u3001\u66f4\u65b0\u72b6\u6001\u3002\u4f60\u7684\u6d3b\u52a8\u6d41\u5c55\u793a\u4eba\u7c7b\u548c Agent \u5e76\u80a9\u5de5\u4f5c\u3002",
"智能体 \u4e0d\u662f\u88ab\u52a8\u5de5\u5177\u2014\u2014\u5b83\u4eec\u662f\u4e3b\u52a8\u53c2\u4e0e\u8005\u3002\u5b83\u4eec\u62e5\u6709\u4e2a\u4eba\u8d44\u6599\u3001\u62a5\u544a\u72b6\u6001\u3001\u521b\u5efa Issue\u3001\u53d1\u8868\u8bc4\u8bba\u3001\u66f4\u65b0\u72b6\u6001\u3002\u4f60\u7684\u6d3b\u52a8\u6d41\u5c55\u793a\u4eba\u7c7b\u548c 智能体 \u5e76\u80a9\u5de5\u4f5c\u3002",
cards: [
{
title: "Agent \u51fa\u73b0\u5728\u6307\u6d3e\u4eba\u9009\u62e9\u5668\u4e2d",
title: "智能体 \u51fa\u73b0\u5728\u6307\u6d3e\u4eba\u9009\u62e9\u5668\u4e2d",
description:
"\u4eba\u7c7b\u548c Agent \u51fa\u73b0\u5728\u540c\u4e00\u4e2a\u4e0b\u62c9\u83dc\u5355\u91cc\u3002\u628a\u4efb\u52a1\u5206\u914d\u7ed9 Agent \u548c\u5206\u914d\u7ed9\u540c\u4e8b\u6ca1\u6709\u4efb\u4f55\u533a\u522b\u3002",
"\u4eba\u7c7b\u548c 智能体 \u51fa\u73b0\u5728\u540c\u4e00\u4e2a\u4e0b\u62c9\u83dc\u5355\u91cc\u3002\u628a\u4efb\u52a1\u5206\u914d\u7ed9 智能体 \u548c\u5206\u914d\u7ed9\u540c\u4e8b\u6ca1\u6709\u4efb\u4f55\u533a\u522b\u3002",
},
{
title: "\u81ea\u4e3b\u53c2\u4e0e",
description:
"Agent \u4e3b\u52a8\u521b\u5efa Issue\u3001\u53d1\u8868\u8bc4\u8bba\u3001\u66f4\u65b0\u72b6\u6001\u2014\u2014\u800c\u4e0d\u662f\u53ea\u5728\u88ab\u63d0\u793a\u65f6\u624d\u884c\u52a8\u3002",
"智能体 \u4e3b\u52a8\u521b\u5efa Issue\u3001\u53d1\u8868\u8bc4\u8bba\u3001\u66f4\u65b0\u72b6\u6001\u2014\u2014\u800c\u4e0d\u662f\u53ea\u5728\u88ab\u63d0\u793a\u65f6\u624d\u884c\u52a8\u3002",
},
{
title: "\u7edf\u4e00\u7684\u6d3b\u52a8\u65f6\u95f4\u7ebf",
description:
"\u6574\u4e2a\u56e2\u961f\u5171\u7528\u4e00\u4e2a\u6d3b\u52a8\u6d41\u3002\u4eba\u7c7b\u548c Agent \u7684\u64cd\u4f5c\u4ea4\u66ff\u5c55\u793a\uff0c\u4f60\u59cb\u7ec8\u77e5\u9053\u53d1\u751f\u4e86\u4ec0\u4e48\u3001\u662f\u8c01\u505a\u7684\u3002",
"\u6574\u4e2a\u56e2\u961f\u5171\u7528\u4e00\u4e2a\u6d3b\u52a8\u6d41\u3002\u4eba\u7c7b\u548c 智能体 \u7684\u64cd\u4f5c\u4ea4\u66ff\u5c55\u793a\uff0c\u4f60\u59cb\u7ec8\u77e5\u9053\u53d1\u751f\u4e86\u4ec0\u4e48\u3001\u662f\u8c01\u505a\u7684\u3002",
},
],
},
autonomous: {
label: "\u81ea\u4e3b\u6267\u884c",
title: "\u8bbe\u7f6e\u540e\u65e0\u9700\u7ba1\u7406\u2014\u2014Agent \u5728\u4f60\u7761\u89c9\u65f6\u5de5\u4f5c",
title: "\u8bbe\u7f6e\u540e\u65e0\u9700\u7ba1\u7406\u2014\u2014智能体 \u5728\u4f60\u7761\u89c9\u65f6\u5de5\u4f5c",
description:
"\u4e0d\u53ea\u662f\u63d0\u793a-\u54cd\u5e94\u3002\u5b8c\u6574\u7684\u4efb\u52a1\u751f\u547d\u5468\u671f\u7ba1\u7406\uff1a\u5165\u961f\u3001\u9886\u53d6\u3001\u542f\u52a8\u3001\u5b8c\u6210\u6216\u5931\u8d25\u3002Agent \u4e3b\u52a8\u62a5\u544a\u963b\u585e\uff0c\u4f60\u901a\u8fc7 WebSocket \u83b7\u53d6\u5b9e\u65f6\u8fdb\u5ea6\u3002",
"\u4e0d\u53ea\u662f\u63d0\u793a-\u54cd\u5e94\u3002\u5b8c\u6574\u7684\u4efb\u52a1\u751f\u547d\u5468\u671f\u7ba1\u7406\uff1a\u5165\u961f\u3001\u9886\u53d6\u3001\u542f\u52a8\u3001\u5b8c\u6210\u6216\u5931\u8d25\u3002智能体 \u4e3b\u52a8\u62a5\u544a\u963b\u585e\uff0c\u4f60\u901a\u8fc7 WebSocket \u83b7\u53d6\u5b9e\u65f6\u8fdb\u5ea6\u3002",
cards: [
{
title: "\u5b8c\u6574\u7684\u4efb\u52a1\u751f\u547d\u5468\u671f",
@@ -58,12 +58,12 @@ export function createZhDict(allowSignup: boolean): LandingDict {
{
title: "\u4e3b\u52a8\u62a5\u544a\u963b\u585e",
description:
"\u5f53 Agent \u9047\u5230\u56f0\u96be\u65f6\uff0c\u4f1a\u7acb\u5373\u53d1\u51fa\u8b66\u62a5\u3002\u4e0d\u7528\u7b49\u51e0\u4e2a\u5c0f\u65f6\u540e\u624d\u53d1\u73b0\u4ec0\u4e48\u90fd\u6ca1\u53d1\u751f\u3002",
"\u5f53 智能体 \u9047\u5230\u56f0\u96be\u65f6\uff0c\u4f1a\u7acb\u5373\u53d1\u51fa\u8b66\u62a5\u3002\u4e0d\u7528\u7b49\u51e0\u4e2a\u5c0f\u65f6\u540e\u624d\u53d1\u73b0\u4ec0\u4e48\u90fd\u6ca1\u53d1\u751f\u3002",
},
{
title: "\u5b9e\u65f6\u8fdb\u5ea6\u63a8\u9001",
description:
"\u57fa\u4e8e WebSocket \u7684\u5b9e\u65f6\u66f4\u65b0\u3002\u5b9e\u65f6\u89c2\u770b Agent \u5de5\u4f5c\uff0c\u6216\u968f\u65f6\u67e5\u770b\u2014\u2014\u65f6\u95f4\u7ebf\u59cb\u7ec8\u662f\u6700\u65b0\u7684\u3002",
"\u57fa\u4e8e WebSocket \u7684\u5b9e\u65f6\u66f4\u65b0\u3002\u5b9e\u65f6\u89c2\u770b 智能体 \u5de5\u4f5c\uff0c\u6216\u968f\u65f6\u67e5\u770b\u2014\u2014\u65f6\u95f4\u7ebf\u59cb\u7ec8\u662f\u6700\u65b0\u7684\u3002",
},
],
},
@@ -71,22 +71,22 @@ export function createZhDict(allowSignup: boolean): LandingDict {
label: "\u6280\u80fd\u5e93",
title: "\u6bcf\u4e2a\u89e3\u51b3\u65b9\u6848\u90fd\u6210\u4e3a\u5168\u56e2\u961f\u53ef\u590d\u7528\u7684\u6280\u80fd",
description:
"\u6280\u80fd\u662f\u53ef\u590d\u7528\u7684\u80fd\u529b\u5b9a\u4e49\u2014\u2014\u4ee3\u7801\u3001\u914d\u7f6e\u548c\u4e0a\u4e0b\u6587\u6253\u5305\u5728\u4e00\u8d77\u3002\u53ea\u9700\u7f16\u5199\u4e00\u6b21\uff0c\u56e2\u961f\u4e2d\u6bcf\u4e2a Agent \u90fd\u80fd\u4f7f\u7528\u3002\u4f60\u7684\u6280\u80fd\u5e93\u968f\u65f6\u95f4\u4e0d\u65ad\u79ef\u7d2f\u3002",
"\u6280\u80fd\u662f\u53ef\u590d\u7528\u7684\u80fd\u529b\u5b9a\u4e49\u2014\u2014\u4ee3\u7801\u3001\u914d\u7f6e\u548c\u4e0a\u4e0b\u6587\u6253\u5305\u5728\u4e00\u8d77\u3002\u53ea\u9700\u7f16\u5199\u4e00\u6b21\uff0c\u56e2\u961f\u4e2d\u6bcf\u4e2a 智能体 \u90fd\u80fd\u4f7f\u7528\u3002\u4f60\u7684\u6280\u80fd\u5e93\u968f\u65f6\u95f4\u4e0d\u65ad\u79ef\u7d2f\u3002",
cards: [
{
title: "\u53ef\u590d\u7528\u7684\u6280\u80fd\u5b9a\u4e49",
description:
"\u5c06\u77e5\u8bc6\u5c01\u88c5\u6210\u4efb\u4f55 Agent \u90fd\u80fd\u6267\u884c\u7684\u6280\u80fd\u3002\u90e8\u7f72\u5230\u6d4b\u8bd5\u73af\u5883\u3001\u7f16\u5199\u8fc1\u79fb\u3001\u5ba1\u67e5 PR\u2014\u2014\u5168\u90e8\u4ee3\u7801\u5316\u3002",
"\u5c06\u77e5\u8bc6\u5c01\u88c5\u6210\u4efb\u4f55 智能体 \u90fd\u80fd\u6267\u884c\u7684\u6280\u80fd\u3002\u90e8\u7f72\u5230\u6d4b\u8bd5\u73af\u5883\u3001\u7f16\u5199\u8fc1\u79fb\u3001\u5ba1\u67e5 PR\u2014\u2014\u5168\u90e8\u4ee3\u7801\u5316\u3002",
},
{
title: "\u5168\u56e2\u961f\u5171\u4eab",
description:
"\u4e00\u4e2a\u4eba\u7684\u6280\u80fd\u5c31\u662f\u6bcf\u4e2a Agent \u7684\u6280\u80fd\u3002\u7f16\u5199\u4e00\u6b21\uff0c\u5168\u56e2\u961f\u53d7\u76ca\u3002",
"\u4e00\u4e2a\u4eba\u7684\u6280\u80fd\u5c31\u662f\u6bcf\u4e2a 智能体 \u7684\u6280\u80fd\u3002\u7f16\u5199\u4e00\u6b21\uff0c\u5168\u56e2\u961f\u53d7\u76ca\u3002",
},
{
title: "\u590d\u5408\u589e\u957f",
description:
"\u7b2c 1 \u5929\uff1a\u4f60\u6559 Agent \u90e8\u7f72\u3002\u7b2c 30 \u5929\uff1a\u6bcf\u4e2a Agent \u90fd\u80fd\u90e8\u7f72\u3001\u5199\u6d4b\u8bd5\u3001\u505a\u4ee3\u7801\u5ba1\u67e5\u3002\u56e2\u961f\u80fd\u529b\u6307\u6570\u7ea7\u589e\u957f\u3002",
"\u7b2c 1 \u5929\uff1a\u4f60\u6559 智能体 \u90e8\u7f72\u3002\u7b2c 30 \u5929\uff1a\u6bcf\u4e2a 智能体 \u90fd\u80fd\u90e8\u7f72\u3001\u5199\u6d4b\u8bd5\u3001\u505a\u4ee3\u7801\u5ba1\u67e5\u3002\u56e2\u961f\u80fd\u529b\u6307\u6570\u7ea7\u589e\u957f\u3002",
},
],
},
@@ -94,7 +94,7 @@ export function createZhDict(allowSignup: boolean): LandingDict {
label: "\u8fd0\u884c\u65f6",
title: "\u4e00\u4e2a\u63a7\u5236\u53f0\u7ba1\u7406\u6240\u6709\u7b97\u529b",
description:
"\u672c\u5730\u5b88\u62a4\u8fdb\u7a0b\u548c\u4e91\u7aef\u8fd0\u884c\u65f6\uff0c\u5728\u540c\u4e00\u4e2a\u9762\u677f\u4e2d\u7ba1\u7406\u3002\u5b9e\u65f6\u76d1\u63a7\u5728\u7ebf/\u79bb\u7ebf\u72b6\u6001\u3001\u4f7f\u7528\u91cf\u56fe\u8868\u548c\u6d3b\u52a8\u70ed\u529b\u56fe\u3002\u81ea\u52a8\u68c0\u6d4b\u672c\u5730 CLI\u2014\u2014\u63d2\u4e0a\u5c31\u7528\u3002",
"\u672c\u5730\u5b88\u62a4\u8fdb\u7a0b\u548c\u4e91\u7aef\u8fd0\u884c\u65f6\uff0c\u5728\u540c\u4e00\u4e2a\u9762\u677f\u4e2d\u7ba1\u7406\u3002\u5b9e\u65f6\u76d1\u63a7\u5728\u7ebf/\u79bb\u7ebf\u72b6\u6001\u3001\u4f7f\u7528\u91cf\u56fe\u8868\u548c\u6d3b\u52a8\u70ed\u529b\u56fe\u3002\u81ea\u52a8\u68c0\u6d4b\u672c\u673a\u5df2\u5b89\u88c5\u7684 11 \u6b3e\u652f\u6301\u7684 AI \u7f16\u7a0b\u5de5\u5177\u3002",
cards: [
{
title: "\u7edf\u4e00\u8fd0\u884c\u65f6\u9762\u677f",
@@ -107,9 +107,9 @@ export function createZhDict(allowSignup: boolean): LandingDict {
"\u5728\u7ebf/\u79bb\u7ebf\u72b6\u6001\u3001\u4f7f\u7528\u91cf\u56fe\u8868\u548c\u6d3b\u52a8\u70ed\u529b\u56fe\u3002\u968f\u65f6\u4e86\u89e3\u4f60\u7684\u7b97\u529b\u5728\u505a\u4ec0\u4e48\u3002",
},
{
title: "\u81ea\u52a8\u68c0\u6d4b\u4e0e\u5373\u63d2\u5373\u7528",
title: "\u9996\u6b21\u542f\u52a8\u81ea\u52a8\u6ce8\u518c",
description:
"Multica \u81ea\u52a8\u68c0\u6d4b Claude Code\u3001Codex\u3001OpenClaw \u548c OpenCode \u7b49\u53ef\u7528 CLI\u3002\u8fde\u63a5\u4e00\u53f0\u673a\u5668\uff0c\u5373\u53ef\u5f00\u59cb\u5de5\u4f5c\u3002",
"Multica \u626b\u63cf\u672c\u673a\u7684 11 \u6b3e\u652f\u6301\u7684 AI \u7f16\u7a0b\u5de5\u5177\u2014\u2014Claude Code\u3001Codex\u3001Cursor\u3001Copilot\u3001Gemini\u3001Hermes\u3001Kimi\u3001Kiro CLI\u3001OpenCode\u3001OpenClaw\u3001Pi\u2014\u2014\u5e76\u4e3a\u6bcf\u6b3e\u5df2\u5b89\u88c5\u7684\u5de5\u5177\u6ce8\u518c\u4e00\u4e2a\u8fd0\u884c\u65f6\u3002",
},
],
},
@@ -129,17 +129,17 @@ export function createZhDict(allowSignup: boolean): LandingDict {
{
title: "\u5b89\u88c5 CLI \u5e76\u8fde\u63a5\u4f60\u7684\u673a\u5668",
description:
"运行 multica setup 一键完成配置、认证和启动守护进程自动检测你机器上的 Claude Code、Codex、OpenClaw 和 OpenCode——插上就用。",
"运行 multica setup——它会引导你完成 OAuth 登录、启动守护进程、并扫描 11 款支持的 AI 编程工具Claude Code、Codex、Cursor、Copilot、Gemini、Hermes、Kimi、Kiro CLI、OpenCode、OpenClaw、Pi。本机已安装的工具会被自动注册成运行时。",
},
{
title: "\u521b\u5efa\u4f60\u7684\u7b2c\u4e00\u4e2a Agent",
title: "\u521b\u5efa\u4f60\u7684\u7b2c\u4e00\u4e2a 智能体",
description:
"\u7ed9\u5b83\u8d77\u4e2a\u540d\u5b57\uff0c\u5199\u597d\u6307\u4ee4\uff0c\u9644\u52a0\u6280\u80fd\uff0c\u8bbe\u7f6e\u89e6\u53d1\u5668\u3002\u9009\u62e9\u5b83\u4f55\u65f6\u6fc0\u6d3b\uff1a\u88ab\u6307\u6d3e\u65f6\u3001\u6709\u8bc4\u8bba\u65f6\u3001\u88ab @\u63d0\u53ca\u65f6\u3002",
},
{
title: "\u6307\u6d3e\u4e00\u4e2a Issue \u5e76\u89c2\u5bdf\u5b83\u5de5\u4f5c",
description:
"\u4ece\u6307\u6d3e\u4eba\u4e0b\u62c9\u83dc\u5355\u4e2d\u9009\u62e9\u4f60\u7684 Agent\u2014\u2014\u5c31\u50cf\u6307\u6d3e\u7ed9\u540c\u4e8b\u4e00\u6837\u3002\u4efb\u52a1\u81ea\u52a8\u5165\u961f\u3001\u9886\u53d6\u3001\u6267\u884c\u3002\u5b9e\u65f6\u89c2\u770b\u8fdb\u5ea6\u3002",
"\u4ece\u6307\u6d3e\u4eba\u4e0b\u62c9\u83dc\u5355\u4e2d\u9009\u62e9\u4f60\u7684 智能体\u2014\u2014\u5c31\u50cf\u6307\u6d3e\u7ed9\u540c\u4e8b\u4e00\u6837\u3002\u4efb\u52a1\u81ea\u52a8\u5165\u961f\u3001\u9886\u53d6\u3001\u6267\u884c\u3002\u5b9e\u65f6\u89c2\u770b\u8fdb\u5ea6\u3002",
},
],
cta: "\u5f00\u59cb\u4f7f\u7528",
@@ -152,7 +152,7 @@ export function createZhDict(allowSignup: boolean): LandingDict {
headlineLine1: "\u5f00\u6e90",
headlineLine2: "\u4e3a\u6240\u6709\u4eba\u3002",
description:
"Multica \u5b8c\u5168\u5f00\u6e90\u3002\u5ba1\u67e5\u6bcf\u4e00\u884c\u4ee3\u7801\uff0c\u6309\u4f60\u7684\u65b9\u5f0f\u81ea\u6258\u7ba1\uff0c\u5851\u9020\u4eba\u7c7b + Agent \u534f\u4f5c\u7684\u672a\u6765\u3002",
"Multica \u5b8c\u5168\u5f00\u6e90\u3002\u5ba1\u67e5\u6bcf\u4e00\u884c\u4ee3\u7801\uff0c\u6309\u4f60\u7684\u65b9\u5f0f\u81ea\u6258\u7ba1\uff0c\u5851\u9020\u4eba\u7c7b + 智能体 \u534f\u4f5c\u7684\u672a\u6765\u3002",
cta: "\u5728 GitHub \u4e0a Star",
highlights: [
{
@@ -163,17 +163,17 @@ export function createZhDict(allowSignup: boolean): LandingDict {
{
title: "\u65e0\u4f9b\u5e94\u5546\u9501\u5b9a",
description:
"\u81ea\u5e26 LLM \u63d0\u4f9b\u5546\u3001\u66f4\u6362 Agent \u540e\u7aef\u3001\u6269\u5c55 API\u3002\u4f60\u62e5\u6709\u6574\u4e2a\u6280\u672f\u6808\u7684\u63a7\u5236\u6743\u3002",
"\u81ea\u5e26 LLM \u63d0\u4f9b\u5546\u3001\u66f4\u6362 智能体 \u540e\u7aef\u3001\u6269\u5c55 API\u3002\u4f60\u62e5\u6709\u6574\u4e2a\u6280\u672f\u6808\u7684\u63a7\u5236\u6743\u3002",
},
{
title: "\u9ed8\u8ba4\u900f\u660e",
description:
"\u6bcf\u4e00\u884c\u4ee3\u7801\u90fd\u53ef\u5ba1\u8ba1\u3002\u786e\u5207\u4e86\u89e3\u4f60\u7684 Agent \u5982\u4f55\u505a\u51b3\u7b56\u3001\u4efb\u52a1\u5982\u4f55\u8def\u7531\u3001\u6570\u636e\u6d41\u5411\u4f55\u65b9\u3002",
"\u6bcf\u4e00\u884c\u4ee3\u7801\u90fd\u53ef\u5ba1\u8ba1\u3002\u786e\u5207\u4e86\u89e3\u4f60\u7684 智能体 \u5982\u4f55\u505a\u51b3\u7b56\u3001\u4efb\u52a1\u5982\u4f55\u8def\u7531\u3001\u6570\u636e\u6d41\u5411\u4f55\u65b9\u3002",
},
{
title: "\u793e\u533a\u9a71\u52a8",
description:
"\u4e0e\u793e\u533a\u4e00\u8d77\u5efa\u8bbe\uff0c\u800c\u4e0d\u4ec5\u4ec5\u662f\u4e3a\u793e\u533a\u5efa\u8bbe\u3002\u8d21\u732e\u6280\u80fd\u3001\u96c6\u6210\u548c Agent \u540e\u7aef\uff0c\u8ba9\u6bcf\u4e2a\u4eba\u53d7\u76ca\u3002",
"\u4e0e\u793e\u533a\u4e00\u8d77\u5efa\u8bbe\uff0c\u800c\u4e0d\u4ec5\u4ec5\u662f\u4e3a\u793e\u533a\u5efa\u8bbe\u3002\u8d21\u732e\u6280\u80fd\u3001\u96c6\u6210\u548c 智能体 \u540e\u7aef\uff0c\u8ba9\u6bcf\u4e2a\u4eba\u53d7\u76ca\u3002",
},
],
},
@@ -183,9 +183,9 @@ export function createZhDict(allowSignup: boolean): LandingDict {
headline: "\u95ee\u4e0e\u7b54\u3002",
items: [
{
question: "Multica \u652f\u6301\u54ea\u4e9b\u7f16\u7801 Agent\uff1f",
question: "Multica \u652f\u6301\u54ea\u4e9b\u7f16\u7801 智能体\uff1f",
answer:
"Multica \u76ee\u524d\u5f00\u7bb1\u5373\u7528\u652f\u6301 Claude Code\u3001Codex\u3001OpenClaw \u548c OpenCode\u3002\u5b88\u62a4\u8fdb\u7a0b\u81ea\u52a8\u68c0\u6d4b\u4f60\u5b89\u88c5\u7684 CLI\u3002\u56e0\u4e3a\u5f00\u6e90\uff0c\u4f60\u4e5f\u53ef\u4ee5\u81ea\u5df1\u6dfb\u52a0\u540e\u7aef\u3002",
"Multica \u5f00\u7bb1\u5373\u7528\u652f\u6301 11 \u6b3e AI \u7f16\u7a0b\u5de5\u5177\uff1aClaude Code\u3001Codex\u3001Cursor\u3001Copilot\u3001Gemini\u3001Hermes\u3001Kimi\u3001Kiro CLI\u3001OpenCode\u3001OpenClaw\u3001Pi\u3002\u5b88\u62a4\u8fdb\u7a0b\u4f1a\u81ea\u52a8\u68c0\u6d4b\u672c\u673a\u5df2\u5b89\u88c5\u7684 CLI \u5e76\u4e3a\u6bcf\u6b3e\u6ce8\u518c\u4e00\u4e2a\u8fd0\u884c\u65f6\u3002\u56e0\u4e3a\u5f00\u6e90\uff0c\u4f60\u4e5f\u53ef\u4ee5\u81ea\u5df1\u6dfb\u52a0\u540e\u7aef\u3002",
},
{
question: "\u9700\u8981\u81ea\u6258\u7ba1\u5417\uff0c\u8fd8\u662f\u6709\u4e91\u7248\u672c\uff1f",
@@ -194,31 +194,31 @@ export function createZhDict(allowSignup: boolean): LandingDict {
},
{
question:
"\u8fd9\u548c\u76f4\u63a5\u7528\u7f16\u7801 Agent \u6709\u4ec0\u4e48\u533a\u522b\uff1f",
"\u8fd9\u548c\u76f4\u63a5\u7528\u7f16\u7801 智能体 \u6709\u4ec0\u4e48\u533a\u522b\uff1f",
answer:
"\u7f16\u7801 Agent \u64c5\u957f\u6267\u884c\u3002Multica \u6dfb\u52a0\u7684\u662f\u7ba1\u7406\u5c42\uff1a\u4efb\u52a1\u961f\u5217\u3001\u56e2\u961f\u534f\u4f5c\u3001\u6280\u80fd\u590d\u7528\u3001\u8fd0\u884c\u65f6\u76d1\u63a7\uff0c\u4ee5\u53ca\u6bcf\u4e2a Agent \u5728\u505a\u4ec0\u4e48\u7684\u7edf\u4e00\u89c6\u56fe\u3002\u628a\u5b83\u60f3\u8c61\u6210\u4f60\u7684 Agent \u7684\u9879\u76ee\u7ecf\u7406\u3002",
"\u7f16\u7801 智能体 \u64c5\u957f\u6267\u884c\u3002Multica \u6dfb\u52a0\u7684\u662f\u7ba1\u7406\u5c42\uff1a\u4efb\u52a1\u961f\u5217\u3001\u56e2\u961f\u534f\u4f5c\u3001\u6280\u80fd\u590d\u7528\u3001\u8fd0\u884c\u65f6\u76d1\u63a7\uff0c\u4ee5\u53ca\u6bcf\u4e2a 智能体 \u5728\u505a\u4ec0\u4e48\u7684\u7edf\u4e00\u89c6\u56fe\u3002\u628a\u5b83\u60f3\u8c61\u6210\u4f60\u7684 智能体 \u7684\u9879\u76ee\u7ecf\u7406\u3002",
},
{
question: "Agent \u80fd\u81ea\u4e3b\u5904\u7406\u957f\u65f6\u95f4\u4efb\u52a1\u5417\uff1f",
question: "智能体 \u80fd\u81ea\u4e3b\u5904\u7406\u957f\u65f6\u95f4\u4efb\u52a1\u5417\uff1f",
answer:
"\u53ef\u4ee5\u3002Multica \u7ba1\u7406\u5b8c\u6574\u7684\u4efb\u52a1\u751f\u547d\u5468\u671f\u2014\u2014\u5165\u961f\u3001\u9886\u53d6\u3001\u6267\u884c\u3001\u5b8c\u6210\u6216\u5931\u8d25\u3002Agent \u4e3b\u52a8\u62a5\u544a\u963b\u585e\u5e76\u5b9e\u65f6\u63a8\u9001\u8fdb\u5ea6\u3002\u4f60\u53ef\u4ee5\u968f\u65f6\u67e5\u770b\uff0c\u4e5f\u53ef\u4ee5\u8ba9\u5b83\u4eec\u8fd0\u884c\u6574\u665a\u3002",
"\u53ef\u4ee5\u3002Multica \u7ba1\u7406\u5b8c\u6574\u7684\u4efb\u52a1\u751f\u547d\u5468\u671f\u2014\u2014\u5165\u961f\u3001\u9886\u53d6\u3001\u6267\u884c\u3001\u5b8c\u6210\u6216\u5931\u8d25\u3002智能体 \u4e3b\u52a8\u62a5\u544a\u963b\u585e\u5e76\u5b9e\u65f6\u63a8\u9001\u8fdb\u5ea6\u3002\u4f60\u53ef\u4ee5\u968f\u65f6\u67e5\u770b\uff0c\u4e5f\u53ef\u4ee5\u8ba9\u5b83\u4eec\u8fd0\u884c\u6574\u665a\u3002",
},
{
question: "\u6211\u7684\u4ee3\u7801\u5b89\u5168\u5417\uff1fAgent \u5728\u54ea\u91cc\u6267\u884c\uff1f",
question: "\u6211\u7684\u4ee3\u7801\u5b89\u5168\u5417\uff1f智能体 \u5728\u54ea\u91cc\u6267\u884c\uff1f",
answer:
"Agent \u5728\u4f60\u7684\u673a\u5668\uff08\u672c\u5730\u5b88\u62a4\u8fdb\u7a0b\uff09\u6216\u4f60\u81ea\u5df1\u7684\u4e91\u57fa\u7840\u8bbe\u65bd\u4e0a\u6267\u884c\u3002\u4ee3\u7801\u6c38\u8fdc\u4e0d\u4f1a\u7ecf\u8fc7 Multica \u670d\u52a1\u5668\u3002\u5e73\u53f0\u53ea\u534f\u8c03\u4efb\u52a1\u72b6\u6001\u548c\u5e7f\u64ad\u4e8b\u4ef6\u3002",
"智能体 \u5728\u4f60\u7684\u673a\u5668\uff08\u672c\u5730\u5b88\u62a4\u8fdb\u7a0b\uff09\u6216\u4f60\u81ea\u5df1\u7684\u4e91\u57fa\u7840\u8bbe\u65bd\u4e0a\u6267\u884c\u3002\u4ee3\u7801\u6c38\u8fdc\u4e0d\u4f1a\u7ecf\u8fc7 Multica \u670d\u52a1\u5668\u3002\u5e73\u53f0\u53ea\u534f\u8c03\u4efb\u52a1\u72b6\u6001\u548c\u5e7f\u64ad\u4e8b\u4ef6\u3002",
},
{
question: "\u6211\u53ef\u4ee5\u8fd0\u884c\u591a\u5c11\u4e2a Agent\uff1f",
question: "\u6211\u53ef\u4ee5\u8fd0\u884c\u591a\u5c11\u4e2a 智能体\uff1f",
answer:
"\u53d6\u51b3\u4e8e\u4f60\u7684\u786c\u4ef6\u3002\u6bcf\u4e2a Agent \u6709\u53ef\u914d\u7f6e\u7684\u5e76\u53d1\u9650\u5236\uff0c\u4f60\u53ef\u4ee5\u8fde\u63a5\u591a\u53f0\u673a\u5668\u4f5c\u4e3a\u8fd0\u884c\u65f6\u3002\u5f00\u6e90\u7248\u672c\u6ca1\u6709\u4efb\u4f55\u4eba\u4e3a\u9650\u5236\u3002",
"\u53d6\u51b3\u4e8e\u4f60\u7684\u786c\u4ef6\u3002\u6bcf\u4e2a 智能体 \u6709\u53ef\u914d\u7f6e\u7684\u5e76\u53d1\u9650\u5236\uff0c\u4f60\u53ef\u4ee5\u8fde\u63a5\u591a\u53f0\u673a\u5668\u4f5c\u4e3a\u8fd0\u884c\u65f6\u3002\u5f00\u6e90\u7248\u672c\u6ca1\u6709\u4efb\u4f55\u4eba\u4e3a\u9650\u5236\u3002",
},
],
},
footer: {
tagline:
"\u4eba\u7c7b + Agent \u56e2\u961f\u7684\u9879\u76ee\u7ba1\u7406\u3002\u5f00\u6e90\u3001\u53ef\u81ea\u6258\u7ba1\u3001\u4e3a\u672a\u6765\u7684\u5de5\u4f5c\u65b9\u5f0f\u800c\u5efa\u3002",
"\u4eba\u7c7b + 智能体 \u56e2\u961f\u7684\u9879\u76ee\u7ba1\u7406\u3002\u5f00\u6e90\u3001\u53ef\u81ea\u6258\u7ba1\u3001\u4e3a\u672a\u6765\u7684\u5de5\u4f5c\u65b9\u5f0f\u800c\u5efa\u3002",
cta: "\u5f00\u59cb\u4f7f\u7528",
groups: {
product: {
@@ -283,6 +283,75 @@ export function createZhDict(allowSignup: boolean): LandingDict {
fixes: "问题修复",
},
entries: [
{
version: "0.2.26",
date: "2026-05-06",
title: "i18n 全量铺开、长 Issue Timeline 提速与系统通知开关",
changes: [],
features: [
"Web 端完成简中翻译21 个命名空间齐全,语言偏好按账号同步",
"Settings 新增 System Notifications 开关",
"支持删除 Chat 会话History 面板移至 chat header",
"Runtime 在线判断改走 RedisDB 兜底)",
"Desktop 支持加载 runtime 自托管配置",
"CLI 新增 `--assignee-id` / `--to-id` / `--user-id`,重名时定位更准",
],
improvements: [
"Settings 的 Appearance Tab 改名为 Preferences并把当前激活的 Tab 反映到 URL深链可分享",
"长 Issue 打开秒开 —— Timeline 改为基于游标的 keyset 分页,重复的 `task_completed` / `task_failed` 活动条目合并展示",
"Runtime poll 与 heartbeat 调度按 runtime 隔离,单个忙碌 runtime 不再拖慢其他",
"CLI 更新请求落 Redisserver 重启也不丢",
"Runtime 用量统计窗口由 180 天收窄到 14 天,降低查询压力",
"项目列表返回 `resource_count` 摘要,不再内联全部 resource响应体更小",
"404 页面重新设计,并修复 No-Access 重定向死循环",
"Quick Create 对 git-describe 类 daemon 跳过 CLI 版本闸",
"CI 启用 lint 强制门禁,历史 lint 债同步清理完毕",
],
fixes: [
"Task 在服务端被删后daemon 主动取消正在运行的 agent避免孤儿进程",
"复用 execenv 时刷新陈旧的 Codex `auth.json`,修复偶发鉴权失败",
"`issue_id` 为空时拒绝写入 `.gc_meta.json`",
"跨 ACP 后端的 session/resume 信任 agent 自报的 session id修复串号问题",
"OpenCode 的 skills 写到 `.opencode/skills/` 让其原生发现",
"Daemon 对 task-not-found 的 404 语义在 server 和最终 guard 双重收紧",
"侧边栏中失效的 Pin 自动取消挂载",
"项目详情页桌面端与移动端侧边栏状态独立保存",
"Runtime 详情页隐藏已归档的 agent",
"Add Resource 列表中已挂载的 repo 显示 URL tooltip空项目页加上 New Issue 入口",
"S3 公开 URL 携带 region修复跨区访问失败",
"Windows 安装器修正版本号解析与 checksum 解码",
"Quick Create 提交按钮去掉重复的快捷键提示",
],
},
{
version: "0.2.24",
date: "2026-05-03",
title: "Repo Checkout `--ref`、Hermes 历史回放修复与多副本 Model Picker",
changes: [],
features: [
"`multica repo checkout --ref` 支持按分支、tag 或指定 commit 拉取仓库",
"`multica agent avatar` 命令支持直接通过 CLI 上传 Agent 头像",
"Inbox 中已完成任务新增 archive 按钮,移除冗余的 mark-as-done 悬浮按钮",
],
improvements: [
"长 timeline 的 Issue 从 Inbox 打开不再卡顿 —— Markdown 渲染管线已 memoize无关的 WS 事件不会再重渲染数千条评论",
"Model Picker 在多副本部署下可用 —— pending 请求改走 Redis 持久化Daemon 上报失败也会自动重试",
"Daemon 空认领缓存 TTL 调高,空闲态 DB 压力进一步下降",
],
fixes: [
"新创建的 Agent 立刻在各处可见 —— 创建时即 hydrate Agent 缓存",
"Hermes 在新一轮对话开始时不再重放上一轮答案 —— 历史 chunk 受单轮门禁限制",
"Codex runtime 模型选择器开放 GPT-5.5 系列",
"`multica login --token <PAT>` 正确接收 PAT 作为参数值",
"CLI update 完成状态上报更可靠",
"Session resume 按 runtime 正确守卫,避免跨 runtime 复用 session",
"看板拖拽 Issue 时显示设置不再丢失",
"Autopilot 列表在移动端 viewport 下响应式排版",
"Quick Create 生成的描述更贴合用户输入",
"Skill upsert 清理 null bytes修复 PostgreSQL UTF8 错误",
"Connect Remote 弹窗的安装脚本 URL 修正",
],
},
{
version: "0.2.21",
date: "2026-04-30",

View File

@@ -1,6 +1,6 @@
/// <reference types="next" />
/// <reference types="next/image-types/global" />
import "./.next/types/routes.d.ts";
import "./.next/dev/types/routes.d.ts";
// NOTE: This file should not be edited
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.

View File

@@ -1,4 +1,5 @@
import { NextResponse, type NextRequest } from "next/server";
import { matchLocale, LOCALE_COOKIE } from "@multica/core/i18n";
// Old workspace-scoped route segments that existed before the URL refactor
// (pre-#1131). Any URL with these as the FIRST segment is a legacy URL that
@@ -16,7 +17,34 @@ const LEGACY_ROUTE_SEGMENTS = new Set([
"settings",
]);
// Next.js 16 renamed `middleware` → `proxy`. The runtime API is identical.
// Resolve the active locale per request. Cookie wins over Accept-Language;
// matchLocale() falls back to DEFAULT_LOCALE when neither yields a match.
function resolveLocale(req: NextRequest): string {
const cookieLocale = req.cookies.get(LOCALE_COOKIE)?.value;
const acceptLanguage = req.headers.get("accept-language") ?? "";
const candidates: string[] = [];
if (cookieLocale) candidates.push(cookieLocale);
for (const part of acceptLanguage.split(",")) {
const tag = part.split(";")[0]?.trim();
if (tag) candidates.push(tag);
}
return matchLocale(candidates);
}
// Forward the resolved locale to RSC layouts via the `x-multica-locale`
// request header. layout.tsx reads it through `await headers()`. The
// `request: { headers }` form is what makes the header land on the upstream
// request — without it the value would only sit on the response.
function nextWithLocale(req: NextRequest): NextResponse {
const headers = new Headers(req.headers);
headers.set("x-multica-locale", resolveLocale(req));
return NextResponse.next({ request: { headers } });
}
// Next.js 16 renamed `middleware` → `proxy`. API surface (NextRequest /
// NextResponse / cookies / matcher) is identical; the only behavioral
// change is the runtime — proxy is forced to nodejs and cannot opt into
// edge.
export function proxy(req: NextRequest) {
const { pathname } = req.nextUrl;
const hasSession = req.cookies.has("multica_logged_in");
@@ -48,34 +76,21 @@ export function proxy(req: NextRequest) {
}
// --- Root path: redirect logged-in users to their last workspace ---
if (pathname === "/") {
if (!hasSession) return NextResponse.next();
if (lastSlug) {
const url = req.nextUrl.clone();
url.pathname = `/${lastSlug}/issues`;
return NextResponse.redirect(url);
}
// No last_workspace_slug cookie → let landing page pick the first workspace
// client-side (features/landing/components/redirect-if-authenticated.tsx).
return NextResponse.next();
if (pathname === "/" && hasSession && lastSlug) {
const url = req.nextUrl.clone();
url.pathname = `/${lastSlug}/issues`;
return NextResponse.redirect(url);
}
return NextResponse.next();
// --- Default: forward locale header to RSC, no redirect/rewrite ---
// Covers logged-out root path, /login, /:slug/*, and everything else.
return nextWithLocale(req);
}
export const config = {
matcher: [
"/",
"/issues/:path*",
"/projects/:path*",
"/agents/:path*",
"/inbox/:path*",
"/my-issues/:path*",
"/autopilots/:path*",
"/runtimes/:path*",
"/skills/:path*",
"/settings/:path*",
],
// i18n header must land on every page request, so we use the standard
// negative-lookahead pattern from Next's i18n guide: skip API routes
// (Go backend), Next internals, and any path with a file extension
// (favicons, sw.js, public/* assets).
matcher: ["/((?!api|_next/static|_next/image|favicon.ico|.*\\.).*)"],
};

View File

@@ -14,6 +14,7 @@ export const mockUser: User = {
// Matches real server behavior for anyone who onboarded before this
// field shipped — migration 054 backfills 'skipped_legacy'.
starter_content_state: "skipped_legacy",
language: null,
created_at: "2026-01-01T00:00:00Z",
updated_at: "2026-01-01T00:00:00Z",
};

View File

@@ -372,7 +372,7 @@ skill
3. **注入**:当 agent 认领任务时daemon 把挂载的 skill 内容写到任务工作目录的 **provider 原生位置**
- Claude Code → `.claude/skills/{name}/SKILL.md`
- Codex → `CODEX_HOME/skills/{name}/`
- OpenCode → `.config/opencode/skills/{name}/SKILL.md`
- OpenCode → `.opencode/skills/{name}/SKILL.md`
- Pi → `.pi/skills/{name}/SKILL.md`
- Cursor → `.cursor/skills/{name}/SKILL.md`
- GitHub Copilot → `.github/skills/{name}/SKILL.md`

View File

@@ -26,6 +26,7 @@ import type {
MemberWithUser,
User,
Skill,
SkillSummary,
CreateSkillRequest,
UpdateSkillRequest,
SetAgentSkillsRequest,
@@ -42,7 +43,8 @@ import type {
RuntimeLocalSkillListRequest,
CreateRuntimeLocalSkillImportRequest,
RuntimeLocalSkillImportRequest,
TimelineEntry,
TimelinePage,
TimelinePageParam,
AssigneeFrequencyEntry,
TaskMessagePayload,
Attachment,
@@ -494,8 +496,17 @@ export class ApiClient {
});
}
async listTimeline(issueId: string): Promise<TimelineEntry[]> {
return this.fetch(`/api/issues/${issueId}/timeline`);
async listTimeline(
issueId: string,
pageParam: TimelinePageParam = { mode: "latest" },
limit = 50,
): Promise<TimelinePage> {
const params = new URLSearchParams();
params.set("limit", String(limit));
if (pageParam.mode === "before") params.set("before", pageParam.cursor);
else if (pageParam.mode === "after") params.set("after", pageParam.cursor);
else if (pageParam.mode === "around") params.set("around", pageParam.id);
return this.fetch(`/api/issues/${issueId}/timeline?${params.toString()}`);
}
async getAssigneeFrequency(): Promise<AssigneeFrequencyEntry[]> {
@@ -903,7 +914,7 @@ export class ApiClient {
}
// Skills
async listSkills(): Promise<Skill[]> {
async listSkills(): Promise<SkillSummary[]> {
return this.fetch("/api/skills");
}
@@ -936,7 +947,7 @@ export class ApiClient {
});
}
async listAgentSkills(agentId: string): Promise<Skill[]> {
async listAgentSkills(agentId: string): Promise<SkillSummary[]> {
return this.fetch(`/api/agents/${agentId}/skills`);
}
@@ -1009,7 +1020,7 @@ export class ApiClient {
});
}
async archiveChatSession(id: string): Promise<void> {
async deleteChatSession(id: string): Promise<void> {
await this.fetch(`/api/chat/sessions/${id}`, { method: "DELETE" });
}

View File

@@ -15,6 +15,7 @@
export function sanitizeNextUrl(raw: string | null): string | null {
if (!raw) return null;
if (!raw.startsWith("/") || raw.startsWith("//")) return null;
// eslint-disable-next-line no-control-regex -- intentional: rejecting control chars is the whole point
if (/[\x00-\x1f\\]/.test(raw)) return null;
return raw;
}

View File

@@ -70,14 +70,20 @@ export function useMarkChatSessionRead() {
});
}
export function useArchiveChatSession() {
/**
* Hard-deletes a chat session. Optimistically removes the row from both
* the active and all-sessions lists so the history panel updates instantly;
* rolls back on error. The matching `chat:session_deleted` WS event keeps
* other tabs/devices in sync — see use-realtime-sync.ts.
*/
export function useDeleteChatSession() {
const qc = useQueryClient();
const wsId = useWorkspaceId();
return useMutation({
mutationFn: (sessionId: string) => {
logger.info("archiveChatSession.start", { sessionId });
return api.archiveChatSession(sessionId);
logger.info("deleteChatSession.start", { sessionId });
return api.deleteChatSession(sessionId);
},
onMutate: async (sessionId) => {
await qc.cancelQueries({ queryKey: chatKeys.sessions(wsId) });
@@ -86,26 +92,20 @@ export function useArchiveChatSession() {
const prevSessions = qc.getQueryData<ChatSession[]>(chatKeys.sessions(wsId));
const prevAll = qc.getQueryData<ChatSession[]>(chatKeys.allSessions(wsId));
// Optimistic: remove from active, mark as archived in allSessions
qc.setQueryData<ChatSession[]>(chatKeys.sessions(wsId), (old) =>
old ? old.filter((s) => s.id !== sessionId) : old,
);
qc.setQueryData<ChatSession[]>(chatKeys.allSessions(wsId), (old) =>
old?.map((s) =>
s.id === sessionId ? { ...s, status: "archived" as const } : s,
),
);
const drop = (old?: ChatSession[]) => old?.filter((s) => s.id !== sessionId);
qc.setQueryData<ChatSession[]>(chatKeys.sessions(wsId), drop);
qc.setQueryData<ChatSession[]>(chatKeys.allSessions(wsId), drop);
logger.debug("archiveChatSession.optimistic", { sessionId });
logger.debug("deleteChatSession.optimistic", { sessionId });
return { prevSessions, prevAll };
},
onError: (err, sessionId, ctx) => {
logger.error("archiveChatSession.error.rollback", { sessionId, err });
logger.error("deleteChatSession.error.rollback", { sessionId, err });
if (ctx?.prevSessions) qc.setQueryData(chatKeys.sessions(wsId), ctx.prevSessions);
if (ctx?.prevAll) qc.setQueryData(chatKeys.allSessions(wsId), ctx.prevAll);
},
onSettled: (_data, _err, sessionId) => {
logger.debug("archiveChatSession.settled", { sessionId });
logger.debug("deleteChatSession.settled", { sessionId });
qc.invalidateQueries({ queryKey: chatKeys.sessions(wsId) });
qc.invalidateQueries({ queryKey: chatKeys.allSessions(wsId) });
},

View File

@@ -0,0 +1,30 @@
"use client";
import { createContext, useContext, type ReactNode } from "react";
import type { LocaleAdapter } from "./types";
const LocaleAdapterContext = createContext<LocaleAdapter | null>(null);
export function LocaleAdapterProvider({
adapter,
children,
}: {
adapter: LocaleAdapter;
children: ReactNode;
}) {
return (
<LocaleAdapterContext.Provider value={adapter}>
{children}
</LocaleAdapterContext.Provider>
);
}
export function useLocaleAdapter(): LocaleAdapter {
const ctx = useContext(LocaleAdapterContext);
if (!ctx) {
throw new Error(
"useLocaleAdapter must be used within <LocaleAdapterProvider>",
);
}
return ctx;
}

View File

@@ -0,0 +1,59 @@
// @vitest-environment jsdom
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import {
LOCALE_COOKIE,
createBrowserCookieLocaleAdapter,
} from "./browser-cookie-adapter";
function clearCookies() {
document.cookie
.split(";")
.map((c) => c.trim().split("=")[0])
.filter(Boolean)
.forEach((name) => {
document.cookie = `${name}=; path=/; expires=Thu, 01 Jan 1970 00:00:00 GMT`;
});
}
describe("createBrowserCookieLocaleAdapter", () => {
beforeEach(clearCookies);
afterEach(clearCookies);
it("getUserChoice returns null when no cookie is set", () => {
const adapter = createBrowserCookieLocaleAdapter();
expect(adapter.getUserChoice()).toBe(null);
});
it("getUserChoice round-trips a persisted value", () => {
const adapter = createBrowserCookieLocaleAdapter();
adapter.persist("zh-Hans");
expect(adapter.getUserChoice()).toBe("zh-Hans");
});
it("getUserChoice decodes URI-encoded cookie values", () => {
document.cookie = `${LOCALE_COOKIE}=${encodeURIComponent("zh-Hans")}; path=/`;
expect(createBrowserCookieLocaleAdapter().getUserChoice()).toBe("zh-Hans");
});
it("getUserChoice ignores unrelated cookies that share a prefix", () => {
document.cookie = `${LOCALE_COOKIE}-other=should-not-match; path=/`;
expect(createBrowserCookieLocaleAdapter().getUserChoice()).toBe(null);
});
it("persist writes a cookie with SameSite=Lax", () => {
const adapter = createBrowserCookieLocaleAdapter();
adapter.persist("en");
expect(document.cookie).toContain(`${LOCALE_COOKIE}=en`);
});
it("getSystemPreferences mirrors navigator.languages", () => {
Object.defineProperty(navigator, "languages", {
value: ["zh-Hans-CN", "en-US"],
configurable: true,
});
expect(createBrowserCookieLocaleAdapter().getSystemPreferences()).toEqual([
"zh-Hans-CN",
"en-US",
]);
});
});

View File

@@ -0,0 +1,35 @@
import type { LocaleAdapter, SupportedLocale } from "./types";
export const LOCALE_COOKIE = "multica-locale";
const COOKIE_MAX_AGE = 60 * 60 * 24 * 365;
// Web-only adapter: persists via document.cookie so the Next.js proxy can
// read the active locale on the next request. Desktop has no server-side
// proxy and must use createDesktopLocaleAdapter (apps/desktop/.../i18n-adapter)
// which persists via window.localStorage instead.
export function createBrowserCookieLocaleAdapter(): LocaleAdapter {
return {
getUserChoice() {
if (typeof document === "undefined") return null;
const m = document.cookie.match(
new RegExp(`(?:^|;\\s*)${LOCALE_COOKIE}=([^;]+)`),
);
const value = m?.[1];
return value ? decodeURIComponent(value) : null;
},
getSystemPreferences() {
if (typeof navigator === "undefined") return [];
return [...navigator.languages];
},
persist(locale: SupportedLocale) {
if (typeof document === "undefined") return;
const secure =
typeof location !== "undefined" && location.protocol === "https:"
? ";Secure"
: "";
document.cookie =
`${LOCALE_COOKIE}=${encodeURIComponent(locale)};` +
`path=/;max-age=${COOKIE_MAX_AGE};SameSite=Lax${secure}`;
},
};
}

View File

@@ -0,0 +1,8 @@
// Browser-only entry: anything that touches `document` / `navigator` /
// `window` lives here, so accidental imports from RSC, Edge middleware, or
// nodejs proxy.ts crash at the import site instead of at first call.
//
// `LOCALE_COOKIE` (a plain string constant) is also exported from the
// server-safe `@multica/core/i18n` entry — proxy.ts needs it to read the
// cookie from a NextRequest. Only the adapter factory is browser-restricted.
export { createBrowserCookieLocaleAdapter } from "./browser-cookie-adapter";

View File

@@ -0,0 +1,23 @@
import i18next, { type i18n as I18n } from "i18next";
import { initReactI18next } from "react-i18next";
import type { LocaleResources, SupportedLocale } from "./types";
// Both server (RSC) and client must call this with the SAME locale + resources
// to avoid hydration mismatch. `initAsync: false` forces synchronous init
// (renamed from `initImmediate` in i18next v25+); `useSuspense: false`
// prevents fallback rendering during hydration.
export function createI18n(
locale: SupportedLocale,
resources: Record<string, LocaleResources>,
): I18n {
const instance = i18next.createInstance();
instance.use(initReactI18next).init({
lng: locale,
fallbackLng: "en",
resources,
interpolation: { escapeValue: false },
initAsync: false,
react: { useSuspense: false },
});
return instance;
}

View File

@@ -0,0 +1,20 @@
// Server-safe i18n entry: zero React imports + zero DOM/document/navigator
// access anywhere in this transitive graph. Safe to import from proxy.ts /
// RSC / Edge / nodejs middleware.
//
// React-side helpers (I18nProvider, useLocaleAdapter, createI18n) live in
// "@multica/core/i18n/react" — split because Next.js gives RSC a vendored
// React build that lacks createContext, and react-i18next's top-level
// React.createContext() call would crash any non-client load of this file.
//
// Browser-only helpers (createBrowserCookieLocaleAdapter) live in
// "@multica/core/i18n/browser" — they read document.cookie / navigator.languages
// at construction time and would crash in any non-DOM context.
export type {
LocaleAdapter,
LocaleResources,
SupportedLocale,
} from "./types";
export { DEFAULT_LOCALE, SUPPORTED_LOCALES } from "./types";
export { matchLocale, pickLocale } from "./pick-locale";
export { LOCALE_COOKIE } from "./browser-cookie-adapter";

View File

@@ -0,0 +1,80 @@
import { describe, expect, it } from "vitest";
import { matchLocale, pickLocale } from "./pick-locale";
import type { LocaleAdapter } from "./types";
function makeAdapter(
overrides: Partial<LocaleAdapter> = {},
): LocaleAdapter {
return {
getUserChoice: () => null,
getSystemPreferences: () => [],
persist: () => {},
...overrides,
};
}
describe("matchLocale", () => {
it("returns DEFAULT_LOCALE when given an empty list", () => {
expect(matchLocale([])).toBe("en");
});
it("matches a clean supported tag", () => {
expect(matchLocale(["zh-Hans"])).toBe("zh-Hans");
expect(matchLocale(["en"])).toBe("en");
});
it("collapses region-tagged BCP-47 to the supported base", () => {
expect(matchLocale(["en-US"])).toBe("en");
expect(matchLocale(["zh-Hans-CN"])).toBe("zh-Hans");
});
it("falls back to DEFAULT_LOCALE when no candidate matches", () => {
expect(matchLocale(["fr", "ja", "ko"])).toBe("en");
});
it("zh-Hant (traditional) collapses to zh-Hans — same base subtag, better UX than English fallback", () => {
expect(matchLocale(["zh-Hant"])).toBe("zh-Hans");
});
it("uses the first supported candidate when multiple appear", () => {
expect(matchLocale(["fr", "zh-Hans", "en"])).toBe("zh-Hans");
});
it("returns DEFAULT_LOCALE for malformed BCP-47 tags rather than throwing", () => {
expect(matchLocale(["----"])).toBe("en");
expect(matchLocale(["x-private-only"])).toBe("en");
});
});
describe("pickLocale", () => {
it("prefers explicit user choice over system signal", () => {
const adapter = makeAdapter({
getUserChoice: () => "zh-Hans",
getSystemPreferences: () => ["en-US"],
});
expect(pickLocale(adapter)).toBe("zh-Hans");
});
it("falls back to system preferences when no user choice", () => {
const adapter = makeAdapter({
getSystemPreferences: () => ["zh-Hans-CN", "en-US"],
});
expect(pickLocale(adapter)).toBe("zh-Hans");
});
it("returns DEFAULT_LOCALE when neither choice nor preference yields a match", () => {
const adapter = makeAdapter({
getUserChoice: () => null,
getSystemPreferences: () => ["fr", "ja"],
});
expect(pickLocale(adapter)).toBe("en");
});
it("ignores empty-string user choice and falls through to system", () => {
const adapter = makeAdapter({
getUserChoice: () => "",
getSystemPreferences: () => ["zh-Hans"],
});
expect(pickLocale(adapter)).toBe("zh-Hans");
});
});

View File

@@ -0,0 +1,26 @@
import { match } from "@formatjs/intl-localematcher";
import {
DEFAULT_LOCALE,
SUPPORTED_LOCALES,
type LocaleAdapter,
type SupportedLocale,
} from "./types";
export function matchLocale(candidates: string[]): SupportedLocale {
if (candidates.length === 0) return DEFAULT_LOCALE;
try {
return match(
candidates,
SUPPORTED_LOCALES,
DEFAULT_LOCALE,
) as SupportedLocale;
} catch {
return DEFAULT_LOCALE;
}
}
export function pickLocale(adapter: LocaleAdapter): SupportedLocale {
const choice = adapter.getUserChoice();
if (choice) return matchLocale([choice]);
return matchLocale(adapter.getSystemPreferences());
}

View File

@@ -0,0 +1,24 @@
"use client";
import { useState, type ReactNode } from "react";
import { I18nextProvider } from "react-i18next";
import { createI18n } from "./create-i18n";
import type { LocaleResources, SupportedLocale } from "./types";
export interface I18nProviderProps {
locale: SupportedLocale;
resources: Record<string, LocaleResources>;
children: ReactNode;
}
export function I18nProvider({
locale,
resources,
children,
}: I18nProviderProps) {
// Lazy init via useState so the instance survives re-renders.
// Locale + resources are determined at boot and never change at runtime —
// language switching goes through window.location.reload().
const [instance] = useState(() => createI18n(locale, resources));
return <I18nextProvider i18n={instance}>{children}</I18nextProvider>;
}

View File

@@ -0,0 +1,9 @@
// React-only i18n entry: depends on react-i18next, which calls
// React.createContext() at module load. Importing this from a non-client
// context (RSC / proxy.ts) will crash with "createContext is not a function"
// because Next.js vendors a stripped React build for those contexts.
// Always pair with "use client" or import only inside client trees.
export { createI18n } from "./create-i18n";
export { I18nProvider, type I18nProviderProps } from "./provider";
export { LocaleAdapterProvider, useLocaleAdapter } from "./adapter-context";
export { UserLocaleSync } from "./user-locale-sync";

View File

@@ -0,0 +1,12 @@
export type SupportedLocale = "en" | "zh-Hans";
export const SUPPORTED_LOCALES: SupportedLocale[] = ["en", "zh-Hans"];
export const DEFAULT_LOCALE: SupportedLocale = "en";
export type LocaleResources = Record<string, Record<string, unknown>>;
export interface LocaleAdapter {
getUserChoice(): string | null;
getSystemPreferences(): string[];
persist(locale: SupportedLocale): void;
}

View File

@@ -0,0 +1,36 @@
"use client";
import { useEffect } from "react";
import { useTranslation } from "react-i18next";
import { useAuthStore } from "../auth";
import { useLocaleAdapter } from "./adapter-context";
import { SUPPORTED_LOCALES, type SupportedLocale } from "./types";
// Pulls the server-stored `user.language` into the local locale adapter on
// login. Without this, switching device (macOS → Windows, browser → desktop)
// loses the user's language preference: pickLocale only consults the local
// adapter (cookie / localStorage), never user.language.
//
// Mounts inside CoreProvider so it has access to the auth store + locale
// adapter + i18n instance. Renders nothing.
//
// Loop safety: reload only fires when user.language is a supported locale AND
// differs from the active i18n.language. After reload, pickLocale reads the
// freshly-persisted value from the adapter, locales match, effect no-ops.
export function UserLocaleSync() {
const userLanguage = useAuthStore((s) => s.user?.language ?? null);
const adapter = useLocaleAdapter();
const { i18n } = useTranslation();
useEffect(() => {
if (!userLanguage) return;
if (!(SUPPORTED_LOCALES as readonly string[]).includes(userLanguage)) {
return;
}
if (userLanguage === i18n.language) return;
adapter.persist(userLanguage as SupportedLocale);
if (typeof window !== "undefined") window.location.reload();
}, [userLanguage, i18n.language, adapter]);
return null;
}

View File

@@ -23,6 +23,12 @@ import type {
ListIssuesCache,
} from "../types";
import type { TimelineEntry, IssueSubscriber, Reaction } from "../types";
import {
mapAllEntries,
filterAllEntries,
prependToLatestPage,
type TimelineCacheData,
} from "./timeline-cache";
// ---------------------------------------------------------------------------
// Shared mutation variable types — used by both mutation hooks and
@@ -312,26 +318,27 @@ export function useCreateComment(issueId: string) {
attachmentIds?: string[];
}) => api.createComment(issueId, content, type, parentId, attachmentIds),
onSuccess: (comment) => {
qc.setQueryData<TimelineEntry[]>(
issueKeys.timeline(issueId),
(old) => {
if (!old) return old;
const entry: TimelineEntry = {
type: "comment",
id: comment.id,
actor_type: comment.author_type,
actor_id: comment.author_id,
content: comment.content,
parent_id: comment.parent_id,
comment_type: comment.type,
reactions: comment.reactions ?? [],
attachments: comment.attachments ?? [],
created_at: comment.created_at,
updated_at: comment.updated_at,
};
if (old.some((e) => e.id === comment.id)) return old;
return [...old, entry];
},
// Write into every paginated timeline cache that's currently at-latest
// (around-mode caches viewing older windows skip silently inside
// prependToLatestPage). Both the latest cache and any open around-mode
// window that has been scrolled all the way to the live tail get the
// optimistic entry; everything else falls back to invalidation.
const entry: TimelineEntry = {
type: "comment",
id: comment.id,
actor_type: comment.author_type,
actor_id: comment.author_id,
content: comment.content,
parent_id: comment.parent_id,
comment_type: comment.type,
reactions: comment.reactions ?? [],
attachments: comment.attachments ?? [],
created_at: comment.created_at,
updated_at: comment.updated_at,
};
qc.setQueriesData<TimelineCacheData>(
{ queryKey: ["issues", "timeline", issueId] },
(old) => prependToLatestPage(old, entry),
);
},
onSettled: () => {
@@ -346,18 +353,27 @@ export function useUpdateComment(issueId: string) {
mutationFn: ({ commentId, content }: { commentId: string; content: string }) =>
api.updateComment(commentId, content),
onMutate: async ({ commentId, content }) => {
await qc.cancelQueries({ queryKey: issueKeys.timeline(issueId) });
const prev = qc.getQueryData<TimelineEntry[]>(issueKeys.timeline(issueId));
qc.setQueryData<TimelineEntry[]>(
issueKeys.timeline(issueId),
await qc.cancelQueries({ queryKey: ["issues", "timeline", issueId] });
// Snapshot every open timeline cache (latest + any around windows) so
// an error rollback restores them all atomically.
const prevSnapshots = qc.getQueriesData<TimelineCacheData>({
queryKey: ["issues", "timeline", issueId],
});
qc.setQueriesData<TimelineCacheData>(
{ queryKey: ["issues", "timeline", issueId] },
(old) =>
old?.map((e) => (e.id === commentId ? { ...e, content } : e)),
mapAllEntries(old, (e) =>
e.id === commentId ? { ...e, content } : e,
),
);
return { prev };
return { prevSnapshots };
},
onError: (_err, _vars, ctx) => {
if (ctx?.prev)
qc.setQueryData(issueKeys.timeline(issueId), ctx.prev);
if (ctx?.prevSnapshots) {
for (const [key, prev] of ctx.prevSnapshots) {
qc.setQueryData(key, prev);
}
}
},
onSettled: () => {
qc.invalidateQueries({ queryKey: issueKeys.timeline(issueId) });
@@ -370,33 +386,45 @@ export function useDeleteComment(issueId: string) {
return useMutation({
mutationFn: (commentId: string) => api.deleteComment(commentId),
onMutate: async (commentId) => {
await qc.cancelQueries({ queryKey: issueKeys.timeline(issueId) });
const prev = qc.getQueryData<TimelineEntry[]>(issueKeys.timeline(issueId));
await qc.cancelQueries({ queryKey: ["issues", "timeline", issueId] });
const prevSnapshots = qc.getQueriesData<TimelineCacheData>({
queryKey: ["issues", "timeline", issueId],
});
// Cascade: collect all child comment IDs
// Cascade: collect all child comment IDs across every loaded page.
const toRemove = new Set<string>([commentId]);
if (prev) {
for (const [, data] of prevSnapshots) {
if (!data) continue;
let changed = true;
while (changed) {
changed = false;
for (const e of prev) {
if (e.parent_id && toRemove.has(e.parent_id) && !toRemove.has(e.id)) {
toRemove.add(e.id);
changed = true;
for (const page of data.pages) {
for (const e of page.entries) {
if (
e.parent_id &&
toRemove.has(e.parent_id) &&
!toRemove.has(e.id)
) {
toRemove.add(e.id);
changed = true;
}
}
}
}
}
qc.setQueryData<TimelineEntry[]>(
issueKeys.timeline(issueId),
(old) => old?.filter((e) => !toRemove.has(e.id)),
qc.setQueriesData<TimelineCacheData>(
{ queryKey: ["issues", "timeline", issueId] },
(old) => filterAllEntries(old, (e) => toRemove.has(e.id)),
);
return { prev };
return { prevSnapshots };
},
onError: (_err, _id, ctx) => {
if (ctx?.prev)
qc.setQueryData(issueKeys.timeline(issueId), ctx.prev);
if (ctx?.prevSnapshots) {
for (const [key, prev] of ctx.prevSnapshots) {
qc.setQueryData(key, prev);
}
}
},
onSettled: () => {
qc.invalidateQueries({ queryKey: issueKeys.timeline(issueId) });

View File

@@ -1,6 +1,12 @@
import { queryOptions } from "@tanstack/react-query";
import { infiniteQueryOptions, queryOptions } from "@tanstack/react-query";
import { api } from "../api";
import type { IssueStatus, ListIssuesParams, ListIssuesCache } from "../types";
import type {
IssueStatus,
ListIssuesParams,
ListIssuesCache,
TimelinePage,
TimelinePageParam,
} from "../types";
import { BOARD_STATUSES } from "./config";
export const issueKeys = {
@@ -17,7 +23,15 @@ export const issueKeys = {
[...issueKeys.all(wsId), "children", id] as const,
childProgress: (wsId: string) =>
[...issueKeys.all(wsId), "child-progress"] as const,
timeline: (issueId: string) => ["issues", "timeline", issueId] as const,
/**
* Cursor-paginated timeline cache. Around-mode lookups use a separate cache
* (keyed by the anchor id) so an Inbox-jump fetch does not pollute the
* default latest-page cache that the regular issue list path consumes.
*/
timeline: (issueId: string, around?: string | null) =>
around
? (["issues", "timeline", issueId, "around", around] as const)
: (["issues", "timeline", issueId] as const),
reactions: (issueId: string) => ["issues", "reactions", issueId] as const,
subscribers: (issueId: string) =>
["issues", "subscribers", issueId] as const,
@@ -126,10 +140,40 @@ export function childIssuesOptions(wsId: string, id: string) {
});
}
export function issueTimelineOptions(issueId: string) {
return queryOptions({
queryKey: issueKeys.timeline(issueId),
queryFn: () => api.listTimeline(issueId),
/**
* Infinite-query options for the cursor-paginated timeline. The first page is
* either the latest 50 entries (no `around`) or a 50-wide window centered on
* the given comment/activity id (Inbox jump path). `getNextPageParam` walks
* older; `getPreviousPageParam` walks newer.
*/
export function issueTimelineInfiniteOptions(
issueId: string,
around?: string | null,
) {
return infiniteQueryOptions<
TimelinePage,
Error,
{ pages: TimelinePage[]; pageParams: TimelinePageParam[] },
readonly unknown[],
TimelinePageParam
>({
queryKey: issueKeys.timeline(issueId, around ?? null),
initialPageParam: around
? ({ mode: "around", id: around } as TimelinePageParam)
: ({ mode: "latest" } as TimelinePageParam),
queryFn: ({ pageParam }) => api.listTimeline(issueId, pageParam),
// Walk older: append a page below the current oldest (last entry of the
// last loaded page). undefined = no more older entries.
getNextPageParam: (lastPage) =>
lastPage.has_more_before && lastPage.next_cursor
? ({ mode: "before", cursor: lastPage.next_cursor } as TimelinePageParam)
: undefined,
// Walk newer: prepend a page above the current newest (first entry of the
// first loaded page). undefined = at the latest, no newer to fetch.
getPreviousPageParam: (firstPage) =>
firstPage.has_more_after && firstPage.prev_cursor
? ({ mode: "after", cursor: firstPage.prev_cursor } as TimelinePageParam)
: undefined,
});
}

View File

@@ -0,0 +1,26 @@
import { beforeEach, describe, expect, it } from "vitest";
import { useQuickCreateStore } from "./quick-create-store";
const RESET_STATE = {
lastAgentId: null,
prompt: "",
keepOpen: false,
};
describe("quick create store", () => {
beforeEach(() => {
useQuickCreateStore.setState(RESET_STATE);
});
it("persists the agent prompt draft until explicitly cleared", () => {
const { setPrompt, clearPrompt } = useQuickCreateStore.getState();
setPrompt("Investigate the inbox loading regression");
expect(useQuickCreateStore.getState().prompt).toBe(
"Investigate the inbox loading regression",
);
clearPrompt();
expect(useQuickCreateStore.getState().prompt).toBe("");
});
});

View File

@@ -15,6 +15,9 @@ import { defaultStorage } from "../../platform/storage";
interface QuickCreateState {
lastAgentId: string | null;
setLastAgentId: (id: string | null) => void;
prompt: string;
setPrompt: (prompt: string) => void;
clearPrompt: () => void;
keepOpen: boolean;
setKeepOpen: (v: boolean) => void;
}
@@ -24,6 +27,9 @@ export const useQuickCreateStore = create<QuickCreateState>()(
(set) => ({
lastAgentId: null,
setLastAgentId: (id) => set({ lastAgentId: id }),
prompt: "",
setPrompt: (prompt) => set({ prompt }),
clearPrompt: () => set({ prompt: "" }),
keepOpen: false,
setKeepOpen: (v) => set({ keepOpen: v }),
}),

View File

@@ -0,0 +1,73 @@
import type { InfiniteData } from "@tanstack/react-query";
import type {
TimelineEntry,
TimelinePage,
TimelinePageParam,
} from "../types";
/** Shape of the cursor-paginated timeline cache. Exported so consumers (the
* hook, mutations, tests) all reference the same type. */
export type TimelineCacheData = InfiniteData<TimelinePage, TimelinePageParam>;
/** Map fn over every entry across every page, preserving page identity for
* any page whose entries don't change so React.memo on CommentCard isn't
* defeated by gratuitous reference churn. */
export function mapAllEntries(
data: TimelineCacheData | undefined,
fn: (e: TimelineEntry) => TimelineEntry,
): TimelineCacheData | undefined {
if (!data) return data;
let pagesChanged = false;
const pages = data.pages.map((page) => {
let entriesChanged = false;
const entries = page.entries.map((e) => {
const next = fn(e);
if (next !== e) entriesChanged = true;
return next;
});
if (!entriesChanged) return page;
pagesChanged = true;
return { ...page, entries };
});
if (!pagesChanged) return data;
return { ...data, pages };
}
/** Filter out entries matching the predicate from every page. */
export function filterAllEntries(
data: TimelineCacheData | undefined,
predicate: (e: TimelineEntry) => boolean,
): TimelineCacheData | undefined {
if (!data) return data;
let pagesChanged = false;
const pages = data.pages.map((page) => {
const entries = page.entries.filter((e) => !predicate(e));
if (entries.length === page.entries.length) return page;
pagesChanged = true;
return { ...page, entries };
});
if (!pagesChanged) return data;
return { ...data, pages };
}
/** Prepend a new entry to the latest page (pages[0]). Caller must verify
* the cache is at-latest before calling — otherwise the entry is hidden
* behind a "show newer" gap and shouldn't be injected. Returns the data
* unchanged if the cache is not at-latest or the entry already exists. */
export function prependToLatestPage(
data: TimelineCacheData | undefined,
entry: TimelineEntry,
): TimelineCacheData | undefined {
if (!data || data.pages.length === 0) return data;
const first = data.pages[0];
if (!first) return data;
if (first.has_more_after) return data; // not at latest; skip silently
if (first.entries.some((e) => e.id === entry.id)) return data;
return {
...data,
pages: [
{ ...first, entries: [entry, ...first.entries] },
...data.pages.slice(1),
],
};
}

View File

@@ -0,0 +1,95 @@
import { beforeEach, describe, expect, it } from "vitest";
import { QueryClient } from "@tanstack/react-query";
import { onIssueLabelsChanged } from "./ws-updaters";
import { issueKeys } from "./queries";
import { labelKeys } from "../labels/queries";
import type {
Issue,
IssueLabelsResponse,
Label,
ListIssuesCache,
} from "../types";
const WS_ID = "ws-1";
const ISSUE_ID = "issue-1";
const labelA: Label = {
id: "label-a",
workspace_id: WS_ID,
name: "bug",
color: "#ef4444",
created_at: "2025-01-01T00:00:00Z",
updated_at: "2025-01-01T00:00:00Z",
};
const labelB: Label = {
id: "label-b",
workspace_id: WS_ID,
name: "feature",
color: "#22c55e",
created_at: "2025-01-01T00:00:00Z",
updated_at: "2025-01-01T00:00:00Z",
};
const baseIssue: Issue = {
id: ISSUE_ID,
workspace_id: WS_ID,
number: 1,
identifier: "MUL-1",
title: "Test",
description: null,
status: "todo",
priority: "none",
assignee_type: null,
assignee_id: null,
creator_type: "member",
creator_id: "user-1",
parent_issue_id: null,
project_id: null,
position: 0,
due_date: null,
labels: [labelA],
created_at: "2025-01-01T00:00:00Z",
updated_at: "2025-01-01T00:00:00Z",
};
describe("onIssueLabelsChanged", () => {
let qc: QueryClient;
beforeEach(() => {
qc = new QueryClient();
});
it("patches the per-issue label cache when present (LabelPicker source)", () => {
qc.setQueryData<IssueLabelsResponse>(labelKeys.byIssue(WS_ID, ISSUE_ID), {
labels: [labelA],
});
onIssueLabelsChanged(qc, WS_ID, ISSUE_ID, [labelB]);
expect(
qc.getQueryData<IssueLabelsResponse>(labelKeys.byIssue(WS_ID, ISSUE_ID)),
).toEqual({ labels: [labelB] });
});
it("leaves the per-issue label cache untouched when the picker has not fetched", () => {
onIssueLabelsChanged(qc, WS_ID, ISSUE_ID, [labelB]);
expect(qc.getQueryData(labelKeys.byIssue(WS_ID, ISSUE_ID))).toBeUndefined();
});
it("still patches the list and detail caches", () => {
qc.setQueryData<ListIssuesCache>(issueKeys.list(WS_ID), {
byStatus: { todo: { issues: [baseIssue], total: 1 } },
});
qc.setQueryData<Issue>(issueKeys.detail(WS_ID, ISSUE_ID), baseIssue);
onIssueLabelsChanged(qc, WS_ID, ISSUE_ID, [labelB]);
const list = qc.getQueryData<ListIssuesCache>(issueKeys.list(WS_ID));
expect(list?.byStatus.todo?.issues[0]?.labels).toEqual([labelB]);
const detail = qc.getQueryData<Issue>(issueKeys.detail(WS_ID, ISSUE_ID));
expect(detail?.labels).toEqual([labelB]);
});
});

View File

@@ -1,12 +1,13 @@
import type { QueryClient } from "@tanstack/react-query";
import { issueKeys } from "./queries";
import { labelKeys } from "../labels/queries";
import {
addIssueToBuckets,
findIssueLocation,
patchIssueInBuckets,
removeIssueFromBuckets,
} from "./cache-helpers";
import type { Issue, Label } from "../types";
import type { Issue, IssueLabelsResponse, Label } from "../types";
import type { ListIssuesCache } from "../types";
export function onIssueCreated(
@@ -73,9 +74,15 @@ export function onIssueUpdated(
}
/**
* Patch an issue's `labels` field in-place across the list cache, my-issues
* caches, and the detail cache. Triggered by the `issue_labels:changed` WS
* event after attach/detach so list/board chips update without a refetch.
* Patch an issue's labels in-place across the list cache, my-issues caches,
* the detail cache, and the per-issue label cache. Triggered by the
* `issue_labels:changed` WS event after attach/detach so list/board chips
* and the issue-detail Properties LabelPicker update without a refetch.
*
* The byIssue cache backs `LabelPicker`; without patching it, externally
* driven label changes (agents, other tabs) leave the picker stale until it
* remounts — `staleTime: Infinity` + `refetchOnWindowFocus: false` (see
* `query-client.ts`) means focus changes won't recover it.
*/
export function onIssueLabelsChanged(
qc: QueryClient,
@@ -89,6 +96,9 @@ export function onIssueLabelsChanged(
qc.setQueryData<Issue>(issueKeys.detail(wsId, issueId), (old) =>
old ? { ...old, labels } : old,
);
qc.setQueryData<IssueLabelsResponse>(labelKeys.byIssue(wsId, issueId), (old) =>
old ? { ...old, labels } : old,
);
qc.invalidateQueries({ queryKey: issueKeys.myAll(wsId) });
}

View File

@@ -24,6 +24,7 @@
"./issues": "./issues/index.ts",
"./issues/queries": "./issues/queries.ts",
"./issues/mutations": "./issues/mutations.ts",
"./issues/timeline-cache": "./issues/timeline-cache.ts",
"./issues/ws-updaters": "./issues/ws-updaters.ts",
"./issues/config": "./issues/config/index.ts",
"./issues/config/status": "./issues/config/status.ts",
@@ -79,12 +80,18 @@
"./utils": "./utils.ts",
"./constants/*": "./constants/*.ts",
"./platform": "./platform/index.ts",
"./analytics": "./analytics/index.ts"
"./analytics": "./analytics/index.ts",
"./i18n": "./i18n/index.ts",
"./i18n/react": "./i18n/react.ts",
"./i18n/browser": "./i18n/browser.ts"
},
"dependencies": {
"@formatjs/intl-localematcher": "catalog:",
"@tanstack/react-query": "catalog:",
"@tanstack/react-query-devtools": "^5.96.2",
"i18next": "catalog:",
"posthog-js": "catalog:",
"react-i18next": "catalog:",
"zustand": "catalog:"
},
"peerDependencies": {
@@ -93,6 +100,7 @@
"devDependencies": {
"@multica/tsconfig": "workspace:*",
"@types/react": "catalog:",
"jsdom": "catalog:",
"typescript": "catalog:",
"vitest": "catalog:"
}

View File

@@ -5,6 +5,11 @@ import { ApiClient } from "../api/client";
import { setApiInstance } from "../api";
import { createAuthStore, registerAuthStore } from "../auth";
import { createChatStore, registerChatStore } from "../chat";
import {
I18nProvider,
LocaleAdapterProvider,
UserLocaleSync,
} from "../i18n/react";
import { WSProvider } from "../realtime";
import { QueryProvider } from "../provider";
import { createLogger } from "../logger";
@@ -65,13 +70,19 @@ export function CoreProvider({
onLogin,
onLogout,
identity,
locale,
resources,
localeAdapter,
}: CoreProviderProps) {
// Initialize singletons on first render only. Dependencies are read-once:
// apiBaseUrl, storage, and callbacks are set at app boot and never change at runtime.
// eslint-disable-next-line react-hooks/exhaustive-deps
useMemo(() => initCore(apiBaseUrl, storage, onLogin, onLogout, cookieAuth, identity), []);
return (
// I18nProvider wraps everything else: server and client must use the same
// (locale, resources) to avoid hydration mismatch. Language switching goes
// through window.location.reload(), never client-side changeLanguage.
const tree = (
<QueryProvider>
<AuthInitializer
onLogin={onLogin}
@@ -92,4 +103,21 @@ export function CoreProvider({
</AuthInitializer>
</QueryProvider>
);
// UserLocaleSync requires a LocaleAdapter to persist; only mount it when
// the host app provides one (web layout + desktop App both do).
const withAdapter = localeAdapter ? (
<LocaleAdapterProvider adapter={localeAdapter}>
<UserLocaleSync />
{tree}
</LocaleAdapterProvider>
) : (
tree
);
return (
<I18nProvider locale={locale} resources={resources}>
{withAdapter}
</I18nProvider>
);
}

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