764 Commits

Author SHA1 Message Date
Jiayuan Zhang
e3bf9ccd43 feat(i18n): translate issue as the local word for "task" (MUL-5703) (#6362)
* feat(i18n): translate issue as the local word for "task" (MUL-5703)

zh-Hans 任务, ja タスク, ko 태스크 across the app locales, the landing
dictionaries, and the onboarding issue templates. "issue" has no meaning
in any of these languages outside dev jargon; the everyday task word is
what users already say for this.

The agent execution run is a separate user-visible entity, so it keeps a
distinct spelling per locale — zh `task` (unchanged), ja 作業, ko 작업 —
and never collapses into the same word as the issue.

Untouched: API/DB fields, `multica issue` CLI references, the /issue
Slack and Lark slash command, English feature names in the changelog
archive, and "issue" in the machine-health sense (异常 / 問題 / 문제).

conventions.mdx and conventions.zh.mdx carry the new rule so translation
PRs stop reverting to English.

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

* fix(i18n): finish the run/issue split in the landing copy (MUL-5703)

Renaming issue to 任务 / タスク / 태스크 left the agent execution run
sharing that word in the landing dictionaries — zh.ts had "任务表格"
(issue) and "智能体任务" (run) in one sentence. The run now reads
`task` / 作業 / 작업 there too, matching the app locales.

Each string was classified against its EN counterpart at the same
structural path, so "assign tasks" in the hero (which means issues)
stays 任务 while "agent tasks" in the changelog becomes the run word.
Strings EN cannot disambiguate were decided by hand.

Also: the onboarding starter prompts spelled the concept list
`workspace / issue / agent / runtime` while the next prompt in the same
file already used the new word, and the ja/ko VCS settings copy was
still untranslated English showing "issues" on the settings page.

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

* fix(i18n): keep Chinese fixed compounds, tidy ja/ko grammar (MUL-5703)

后台任务 and 定时任务 are settled Chinese compounds for "background job"
and "cron job" — neither is the agent execution entity, so splitting
them into `task` was wrong.

Japanese: the landing file spaces katakana-katakana compounds, so
replacing タスク with kanji 作業 left "エージェント 作業" and
"作業 トークン" reading oddly. Reworded to の / closed compounds.

Korean: 작업 ends in a consonant, so the subject marker after the
parenthetical becomes 이, not 가.

Also drops a __pycache__ artifact that git add -A swept in, and ignores
Python bytecode so agent tooling cannot commit it again.

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

---------

Co-authored-by: Lambda <lambda@multica.ai>
Co-authored-by: multica-agent <github@multica.ai>
2026-08-05 15:25:14 +08:00
Multica Eve
b98f760f1f MUL-5604: 支持 Reasonix runtime (#6370)
* feat(agent): add Reasonix runtime

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

* fix(agent): make Reasonix sandbox host-adaptive

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

---------

Co-authored-by: Eve <eve@multica-ai.local>
Co-authored-by: multica-agent <github@multica.ai>
2026-08-05 14:19:28 +08:00
Multica Eve
b312e1cbb4 docs(changelog): add v0.4.18 release entry (2026-08-04) (MUL-5716) (#6372)
* docs(changelog): add v0.4.18 release entry (2026-08-04) (MUL-5716)

Add the 2026-08-04 changelog entry across en/zh/ja/ko and bump apps/web to 0.4.18.

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

* docs(changelog): retitle v0.4.18 around the leaner agent briefing and tighten copy

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

* docs(changelog): drop the two entries reverted out of v0.4.18

Both changes they described are no longer in the release:

- blocked-Issue re-trigger (#6155) is reverted by #6377, on product grounds
- mobile chat realtime fallback (#6325) was already reverted on main by #6374

Removed from all four locales.

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

* docs(changelog): correct the new-channel attachment entry to match #6330

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

---------

Co-authored-by: Eve <eve@multica-ai.local>
Co-authored-by: multica-agent <github@multica.ai>
Co-authored-by: Bohan-J <bohan@devv.ai>
2026-08-04 17:56:22 +08:00
Naiyuan Qing
736fbc8a5f MUL-5685: fix agent builder composer clearing and conversation routing (#6339)
* fix(agents): clear the builder composer as soon as the send is accepted

Sending a message in the Agent Builder left the user's text in the box
while the message was already on screen.

The composer is on the shared MUL-5181 contract, which clears the draft
when the owner's send resolves. This owner resolved late: it awaited
three cache invalidations (messages, pendingTask, drafts list) after the
POST had already been accepted and the caches primed, so the clear was
gated behind that many extra round-trips. Typing during that window also
tripped ChatInput's stale-submit guard, which then declines to clear at
all and strands the sent text in the box.

`send` now takes ChatInput's `commitInput` and runs it the moment the
server accepts and the caches render the message; the invalidations
reconcile in the background. Same ordering as use-chat-controller's
handleSend. The empty-state prompt buttons call `send` with no composer
to clear, which is why the parameter is optional.

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

* refactor(agents): give a builder conversation its own route

The AI creation studio served two unrelated screens from one route,
switched on `?session=`: absent rendered the runtime picker, present
rendered the conversation. That inverted what each one is. The runtime
pick is a step — consumed the instant the session exists, since the
runtime is frozen onto the hidden carrier agent and cannot be revisited.
The conversation is a durable object: leaving the studio no longer
deletes it (#6307), so it is left and resumed, and it is the thing that
needs an address. It had the address of a modifier on the step.

Split into one route per screen:

    /{ws}/agents/new/ai              start one
    /{ws}/agents/new/ai/{sessionId}  one conversation

Behavior is deliberately unchanged: starting still REPLACES the picker
entry so a back cannot land on a consumed step, back still leaves to the
chooser, and a discarded conversation still replaces its own entry
rather than leaving a dead address on the stack.

One latent bug goes with it. The runtime a just-started conversation
runs on lived in React state on the shared page, because a conversation
joins the sessions list only once it holds a message or a saved
configuration — so for the first turn there was nothing to read it back
from. A refresh on that first turn dropped it and locked the runtime
picker. It now rides in `?runtime=` on the conversation URL, which is a
seed for exactly that gap and absent on every later visit.

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

* fix(core): register the builder conversation route for diagnostics

diagnostic-context.ts keeps a route-mask table that every path builder
must match, so a diagnostic report names the route instead of a
wildcard. The new conversation path fell through to `/:slug/agents/*`,
which its coverage test rejects.

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

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-04 10:34:27 +08:00
Naiyuan Qing
a5c1d44701 MUL-5642: fix(agents): stop the creation studio polling, and stop it losing work (#6307)
* feat(agents): make AI agent creation resumable (#6246)

Leaving the Agent Creation Studio destroyed the conversation. The unmount
cleanup called deleteChatSession, so a sidebar click, a tab close or a route
change deleted the builder session and every message in it — the bug external
users reported. Archiving instead (PR #6247) would have stopped the deletion
without giving anyone a way back in: builder sessions hang off a hidden
`kind = 'system'` carrier agent, which the `kind = 'user'` filter keeps out of
every chat list, so an archived one is unreachable rather than recoverable.

A creation conversation is now a durable object with its own address.

Server:
  - GET /api/agent-builder/sessions lists the caller's unfinished creations.
    Creator-scoped like every other chat read. It reports the CARRIER's
    runtime, not chat_session.runtime_id — the latter is the daemon's resume
    pointer and is deliberately left stale after a switch, so resuming from it
    would put the picker on a runtime that executes nothing (MUL-5163).
  - PUT /api/agent-builder/sessions/{id}/draft stores the configuration,
    including the edits the user typed but never sent. Migration 251 adds
    agent_builder_draft (no FK per repo rule; pruned explicitly by
    DeleteChatSession, the runtime teardown and the workspace teardown, and
    registered in the workspace-deletion manifest).
  - The payload is opaque to the server: its shape is the studio's AgentDraft,
    validated client-side. Teaching Postgres and the handler about it would
    create a second definition to keep in sync for no gain.

Client:
  - The session id lives in `?session=`, so a refresh, a back/forward and a
    reopened tab land back in the same conversation.
  - Leaving no longer deletes anything. The only destructive path is an
    explicit "discard", confirmed in a dialog, next to the create button.
  - Creating the agent archives the conversation instead of deleting it: it is
    the record of how that agent was designed, and an idle carrier costs
    nothing since usage is booked per task.
  - The configuration autosaves (debounced) and restores on arrival, with the
    applied-assistant-message marker stored alongside it so a restore cannot
    re-apply the last reply over edits made after it.
  - The 1.5s polling of messages and pending-task is gone. The global realtime
    sync already invalidates both per session id, exactly as it does for the
    main chat window, which has never polled.
  - The `<agent_draft>` block collapses to one "configuration updated" line.
    The regex now also swallows an unterminated block, which is what streaming
    produces — the raw payload used to scroll past on every turn.

The 2185-line agent-creation-studio.tsx is split into three routes
(`/agents/new`, `/agents/new/manual`, `/agents/new/ai`), its pure logic moves
to packages/core/agents/ with its tests, and the unreachable template flow —
`setMode("templates")` had no caller — is removed.

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

* fix(agents): let the builder panes resize

The conversation / configuration split was not draggable. Two structural
reasons, both fixed by giving the group the same shape the chat page uses:

  - The panels reached the group through BuilderWorkspace's fragment, so they
    were not children the group could measure.
  - The group's children alternated between one panel (runtime setup) and two
    (conversation), under one persisted layout id.

The group now lives inside BuilderWorkspace with its two panels as its only
children, and the setup screen renders no group at all — it has nothing to
split.

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

* fix(ui): give the resize handle a cursor on hover

The separator had no cursor of its own, so the only signal that a split was
draggable arrived after the drag started — the library writes a global
`cursor: ... !important` while dragging, and nothing before it.

Fixed on the shared handle rather than at one call site: every split surface
(chat, inbox, issue detail, project detail, the agent builder) was missing the
same affordance. The library's drag-time rule still outranks this one, so the
cursor keeps narrowing to `e-resize` / `w-resize` once a panel hits its bound.

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

* feat(agents): render the builder's draft block as an inspectable row

Every builder reply ends in an <agent_draft> block that rewrites the form on
the right. Flattening it to a line of prose said that something changed but not
what, and the payload — the only record of what the builder actually claimed —
was unreachable.

A settled reply now carries a full-width row saying the configuration was
updated, which opens the exact payload. A streaming one keeps a text line
instead: the block is still being written, so there is nothing complete to
open, and without the line the half-finished JSON scrolls past.

ChatMessageList gains an optional `renderAssistantAddon`. It is opt-in per
surface and undefined everywhere but this one, because no other chat speaks
this protocol — the alternative was to keep pushing an embedded protocol
through `transformContent`, which can only ever produce prose.

`extractBuilderDraftBlock` returns an unparseable payload verbatim rather than
withholding it: a malformed block is exactly when someone wants to read it.

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

* fix(agents): address review blockers on the resumable builder

1. Migration prefix collision. `251_agent_runtime_unbind` landed on main after
   this branch cut, so the backend's prefix-uniqueness guard failed. Renumbered
   to 252.

2. #6287 — the manual form still lost everything. The route split moved where
   you land, not what survives: the draft was `useState`, so a tab switch (the
   desktop shell mounts only the active tab) remounted it empty, and the
   beforeunload guard covered a hard reload and nothing else. It now persists
   through the repo's draft-store factory, scoped by what is being created — a
   blank agent and a copy of agent X are different work, and a copy of X is not
   a copy of Y — cleared once the agent is committed, and registered for logout
   / workspace-delete cleanup.

3. A saved draft with no messages was unreachable. The configuration form is
   editable from the moment a builder session exists and autosaves, so someone
   could open it, type a name and leave before the first turn; the list keyed
   "is this a draft" on messages alone, so that row existed and nothing could
   reach it. A session now qualifies on a message OR a stored draft, and sorts
   by whichever it has.

4. The debounce dropped the last edits. Its timer died with the component, so
   navigating away inside the 800ms window lost exactly the keystrokes the user
   had just made. The pending payload is now flushed on unmount.

`useUnsavedDraftWarning` is gone with its last caller: both routes persist, so
the browser prompt would have been warning about work that is already saved.

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

* fix(ui): stop the resize cursor flipping mid-drag

The library narrows its cursor the moment a panel hits a bound — col-resize
while both directions are open, a one-way arrow once only one is. Truthful, but
it reads as a glitch: the icon changes under your hand halfway through a drag
you never stopped making.

`disableCursor` turns that global rule off; the handle's own `cursor-col-resize`
is now the only source. A drag captures the pointer and walks it across the
panels, away from the 8px handle, so the group carries the same cursor for as
long as a separator is active — otherwise it would fall back to a text caret the
instant the pointer left the handle.

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

* fix(agents): key manual drafts by owner, drop the draft-block rendering

Two changes.

One slot destroyed the other flow's work. The manual draft was stored under a
single key: opening a blank form, or a copy of a different agent, refused to
adopt the stored draft and then immediately wrote its own empty form over it —
so a half-finished copy of agent A died the moment the user opened anything
else, before typing a character. Drafts are now keyed by what is being created,
the same shape the chat composer uses for its per-session drafts, and a slot is
dropped when its content is gone rather than parked blank (which also stops the
map growing a dead key per agent ever opened for duplication). Committing an
agent clears that flow's slot only.

The `<agent_draft>` block goes back to being hidden outright. Labelling it and
opening its payload dressed up machinery as content: the block drives the
configuration form, and the form is where its effect is already visible.
`renderAssistantAddon` goes with it — ChatMessageList is back to what it was,
since no surface needs the slot. The two-pattern strip stays: an unterminated
block is what streaming produces, and without matching it the raw JSON scrolled
past the reader on every turn.

Also removed six barrel exports nothing imported through, and unexported five
types only their own file used.

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

* fix(agents): keep a manual draft whose only edit is a picker

The "is this worth storing" predicate listed six fields by name, and the draft
serializes eleven. A form whose only change was the model, the thinking level,
the service tier, the access scope or a team grant read as untouched, so the
next save deleted its slot — picking a model before typing a name and switching
tabs lost the model.

Enumerating was the mistake, not the specific omissions: the predicate stops
covering every field added after it is written, and the failure is invisible
because each field saves correctly as long as some *other* field is also set.
It now compares the whole draft against a fresh one. The runtime stays outside
that comparison, on the entry rather than in the draft, because the form seeds
it on every visit and counting it would store a draft for a form nobody
touched.

Covered field by field, one edit at a time, so a future field cannot quietly
fall out.

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

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-03 19:00:00 +08:00
Multica Eve
6b56bc05a4 docs(changelog): add v0.4.17 release entry (2026-08-03) (MUL-5655) (#6311)
* docs(changelog): add v0.4.17 release entry (2026-08-03) (MUL-5655)

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

* docs(changelog): drop the MCP allowlist fix, reverted in #6314 (MUL-5655)

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

* docs(changelog): add the Kimi resume fix from #6308 (MUL-5655)

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

---------

Co-authored-by: Eve <eve@multica-ai.local>
Co-authored-by: multica-agent <github@multica.ai>
2026-08-03 18:12:07 +08:00
Multica Eve
bdd90dd9ca docs(changelog): add v0.4.16 release entry (2026-07-31) (MUL-5596) (#6242)
* docs(changelog): add v0.4.16 release entry (MUL-5596)

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

* docs(changelog): rewrite v0.4.16 entries for clarity (MUL-5596)

Each line now states the user-visible result instead of the mechanism:
name the agents whose replies were truncated, say what the Linux Codex
change actually gives you, drop WebSocket/migration internals, and use
the same word for link chips across all four languages.

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

---------

Co-authored-by: Eve <eve@multica-ai.local>
Co-authored-by: multica-agent <github@multica.ai>
Co-authored-by: Bohan-J <bohan@devv.ai>
2026-07-31 17:48:15 +08:00
Multica Eve
cac453fe01 MUL-5505: fix(selfhost): one source of truth for the backend host port (#6168)
* fix(selfhost): read the published host port back from Compose (MUL-5505)

`make selfhost` polled the wrong port when the backend host port was passed
through the environment. Make and Compose disagree on variable precedence: an
assignment in an included makefile (the Makefile `include`s .env) overrides a
real environment variable, while for Compose the environment overrides .env.
Because .env.example ships BACKEND_PORT uncommented, .env always pinned the
recipe's view of the port, so `BACKEND_PORT=9000 make selfhost` published the
stack on 9000 while the health check hammered 8080 for 60s and then reported
"Services are still starting" on a perfectly healthy install. The success
message printed the same wrong backend URL. FRONTEND_PORT had the same
inversion, affecting the printed frontend URL.

Stop re-deriving the host port in the recipe and ask Compose, which is the only
authority on what it published. The wait-and-report block (three port
references per target, duplicated across selfhost and selfhost-build) moves into
scripts/selfhost-wait.sh, which resolves both host ports via `docker compose
port` and falls back to the compose default chain when the stack is not up.

Also fix the input contract: `PORT` was the only port variable documented in
SELF_HOSTING_ADVANCED.md, yet compose read only BACKEND_PORT, so setting the
documented variable was silently ignored. The backend host port mapping now
falls back to `${PORT:-8080}`, matching Makefile and scripts/local-env.sh, and
the docs describe both variables as host ports.

Container-internal ports (8080/3000), the global PORT derivation used by local
dev, and the 127.0.0.1 bindings are unchanged; the default self-host experience
is byte-identical.

Fixes #6145

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

* fix(selfhost): make PORT the real backend port and test the whole chain

Addresses review feedback on #6168.

The first round's root-cause claim was wrong. It said `BACKEND_PORT=9100 make
selfhost` published on 9100 while the health check probed 8080. It does not:
when make drives Compose, Compose inherits make's exported values, so both
sides agree on 8080 and the override is silently dropped instead. Re-measured
through the real recipe, the genuine disagreements were:

  - `.env` setting PORT with no BACKEND_PORT: probe followed PORT, Compose
    published ${BACKEND_PORT:-8080} — 9100 vs 8080.
  - `make selfhost PORT=8080` over a .env with BACKEND_PORT=9100: probe 8080,
    Compose published 9100.

Both are the "wrong port variable" defect the GitHub issue names, and reading
the port back from Compose fixes both. The comments and PR text that blamed
make/Compose precedence are corrected.

The PORT fallback added to docker-compose.selfhost.yml was also unreachable:
.env.example shipped BACKEND_PORT=8080 uncommented, so the fallback never
engaged and `SELF_HOSTING_AI.md`'s "edit PORT and FRONTEND_PORT" instruction did
nothing. Pick one contract and apply it everywhere: PORT is the backend port to
edit, BACKEND_PORT/API_PORT/SERVER_PORT are optional aliases that override it.
That is already what Makefile, scripts/local-env.sh, scripts/install.sh and
install.ps1 do; compose and the web dev fallback were the outliers.

  - .env.example ships BACKEND_PORT commented out, like its sibling aliases, so
    editing PORT works out of the box.
  - apps/web/config/runtime-urls.ts walks the same alias chain instead of
    reading BACKEND_PORT alone, so `pnpm dev` follows an edited PORT.
  - SELF_HOSTING_AI.md and SELF_HOSTING_ADVANCED.md state the contract.

Configuration that cannot take effect is now reported instead of ignored.
scripts/selfhost-preflight.sh warns when an alias in the env file overrides an
edited PORT, and when a port from the shell environment is overridden by the env
file. The Makefile captures the pristine origins before its include, because
afterwards there is no way to tell that `PORT=9000 make selfhost` was asked for.

Tests now cross environment -> make -> compose -> report for real. The docker
stub is no longer told what to publish: on `up` it asks the real
`docker compose config` to interpolate the compose file with the environment the
recipe actually handed it, records that, and answers `port` from the recording.
Verified failing on the old code — with the old recipe and compose file the
suite reports published 8080 against probe 9100, the exact defect.

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

* ci: gate the self-host test on scripts/selfhost-preflight.sh too

The new preflight script was missing from the frontend path filter, so a
change to it alone would skip the test that covers it.

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

* fix(selfhost): honour the full backend port alias chain in Compose

Addresses the second review round on #6168.

The contract this PR documents is

  BACKEND_PORT -> API_PORT -> SERVER_PORT -> PORT -> 8080

and Makefile, scripts/local-env.sh, scripts/install.sh, scripts/install.ps1 and
apps/web/config/runtime-urls.ts all implement it. docker-compose.selfhost.yml
implemented only BACKEND_PORT -> PORT -> 8080, so `API_PORT=9100` or
`SERVER_PORT=9200` in .env still published 8080.

That is not only a direct-Compose concern. scripts/install.sh:407 derives its
health-check port from the full chain and then runs this compose file, so an
alias it honours but Compose ignores puts the probe on 9100 while the stack
listens on 8080 — the original #6145 defect, reached through the installer.

  - docker-compose.selfhost.yml publishes the full nested chain, verified to
    keep the same precedence and the same 8080 default.
  - scripts/selfhost-wait.sh's fallback matches, so the degraded path agrees
    with what Compose would have published.
  - The Makefile captures the pristine origins of API_PORT and SERVER_PORT too,
    and the preflight reports them, so no alias can be silently overridden by
    the env file while the others are reported.

Tests pin the chain on the boundaries a recipe-level test cannot see, because
Makefile normalises all four aliases into PORT before any recipe runs: a matrix
over the direct Compose path asserts each alias moves the published port with
the right precedence, and every case additionally asserts that
scripts/install.sh's resolver returns the same value Compose published. That
invariant is the one that actually protects the installer.

Verified failing without the fix: with the two-level chain restored the suite
reports `expected 9200 / compose published 8080 / installer resolved 9200`, and
with the alias warnings narrowed it reports the missing API_PORT notice.

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

* fix(selfhost): resolve one winner before reporting ignored port config

Addresses the third review round on #6168.

The preflight walked each alias independently, so it made a separate "wins"
claim per alias. With

  PORT=9000
  BACKEND_PORT=8000
  API_PORT=7000
  SERVER_PORT=6000

in .env it announced that BACKEND_PORT, API_PORT and SERVER_PORT each won, when
only BACKEND_PORT=8000 can. Two of the three notices were simply false.

It also compared only same-named shell and file variables, so shadowing across
aliases stayed silent: .env with PORT=8000 and BACKEND_PORT=8000 plus a shell
API_PORT=7000 produced no output at all, even though API_PORT was discarded.

Resolve the winner along the documented chain first — within a variable the env
file beats the environment, then BACKEND_PORT beats API_PORT beats SERVER_PORT
beats PORT — and report only the inputs that winner actually shadows. Output now
names one winning input and lists the rest, whichever variable or source they
came from. An input carrying the winning value is not reported: it is redundant
but the user still gets the port they asked for, so there is nothing to fix.

Tests cover both cases the old logic got wrong: every alias set at once must
yield exactly one winner claim and list the others as unused, and an env-file
alias beating a lower-priority shell alias must be reported. Also asserted that
a redundant same-value alias and a default configuration both stay silent.

Measured against the previous implementation, phrasing aside: with all four
values set it made 3 winner claims where there is now 1, and on the cross-alias
case it printed nothing where API_PORT is now reported.

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

* fix(selfhost): track env-file existence so empty port assignments resolve right

Addresses the fourth review round on #6168.

The Makefile passed only values to the preflight, so the script inferred "the
env file does not set this" from an empty string. An explicit empty assignment is
a different input from an absent one: make lets `BACKEND_PORT=` in the env file
override `BACKEND_PORT=9000` from the environment, and the variable then drops out
of the alias chain instead of setting a port.

With .env holding PORT=8080 and BACKEND_PORT=, invoked as
`BACKEND_PORT=9000 make selfhost`, the stack published 8080 and the health check
probed 8080 — correct — while the preflight announced

  the backend host port resolves to 9000 from BACKEND_PORT (environment)
  Set but unused: PORT=8080 (.env)

naming the ignored value as the winner and the winning value as unused. A report
that contradicts the startup is worse than no report. FRONTEND_PORT= had the
matching hole: it shadowed the environment, Compose fell back to 3000, and the
preflight said nothing.

The Makefile now captures ENV_FILE_<VAR>_IS_SET from $(origin) alongside the
value, for all five port variables via one $(foreach) instead of hand-written
lines. The preflight treats a variable the file defines as taking the file's
value even when empty, so it shadows the environment, and an empty value never
wins — resolution continues down the chain and falls back to 8080. Inputs the
winner shadows are still listed, including ones shadowed by an empty assignment.
The frontend check mirrors this and now names the port that actually resolved.

Recipe-level tests cover an empty alias over a shell value, every link of the
chain emptied at once, and the frontend equivalent — each asserting the reported
winner, the Compose published port and the probed port all agree. Against the
previous implementation the first case fails with `reported: 9000 / actual: 8080`.

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

* fix(selfhost): installers read the published port; drop the second parser

Addresses all four blockers from the consolidated review on #6168.

1. Both installers probed and printed the wrong port. scripts/install.sh:402 and
   scripts/install.ps1 start Compose inheriting the current environment, and
   Compose lets that environment outrank .env — but the installers then derived
   the probe port and the summary URL from .env alone. Measured on this branch
   before the fix, every port variable diverged:

     ambient PORT=9100        -> compose 9100, installer 8080
     ambient BACKEND_PORT=9200 -> compose 9200, installer 8080
     ambient API_PORT=9300     -> compose 9300, installer 8080
     ambient SERVER_PORT=9400  -> compose 9400, installer 8080
     ambient FRONTEND_PORT=3100 -> compose 3100, installer 3000

   Both now read the ports back once with `docker compose port` after `up -d`
   and reuse that single result for the health check and the summary. If the
   query fails they fail loudly instead of claiming success. The .env-derived
   helpers are deleted rather than left beside the new path.

2. The Make preflight is removed entirely, as the review recommended. It
   modelled `origin=environment` and `origin=file` but not `command line`, so
   `make selfhost BACKEND_PORT=9000` over a .env with API_PORT=7000 announced
   7000 while Compose published 9000. That was its fourth wrong report in four
   rounds, because it was a second parser of precedence rules — the very thing
   this PR removes elsewhere. `docker compose port` is the runtime truth, so the
   script, the Makefile captures and the macro are gone.

3. Tests now cover the installer paths that were unguarded. scripts/install.test.sh
   gains a `--with-server` matrix (defaults, .env PORT, all four backend aliases,
   FRONTEND_PORT, ambient overriding .env for all five, and explicit-empty
   fallback) plus a case proving an unresolvable port fails loudly. A new
   scripts/install.ps1.test.ps1 drives the same matrix through Start-LocalInstall.
   Every case asserts Compose's published port == the probed URL == the printed
   URL. Ambient variables are explicitly cleared per case so a runner's own PORT
   cannot leak. The Makefile matrix gains the command-line origin cases. CI runs
   the PowerShell suite on windows-latest in the always-on installer job, and the
   frontend filter now includes both installers.

4. Docs state the real contract: the alias order is shared, but which *source*
   wins is per entry point — Compose prefers the environment, make prefers the
   included env file, and a make command-line assignment outranks both. The
   removed preflight's promise is gone from SELF_HOSTING_AI.md, and the compose
   header no longer claims the installer derives its own health-check port.

Verified failing without the fixes: the Bash matrix reports
`[ambient PORT beats .env] compose published 9500 / probed 9100 / printed 9100`
against the old installer, and the PowerShell matrix reports
`printed backend=8080` against an injected summary divergence.

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

* fix(selfhost): keep web dev proxy off frontend port

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

---------

Co-authored-by: Eve <eve@multica-ai.local>
Co-authored-by: multica-agent <github@multica.ai>
2026-07-31 14:46:13 +08:00
Jiayuan Zhang
9c90327ce4 fix(ui): replace the text-transparency ladder with solid tones (MUL-5452) (#6152)
* fix(ui): replace the text-transparency ladder with solid tones (MUL-5452)

Hierarchy was being expressed with transparency: 152 call sites of
text-muted-foreground/30..80, 26 of text-foreground/60..90, plus a handful
on destructive and current, and a few written as a standalone opacity-*
utility instead of a slash alpha.

On light surfaces every muted variant failed WCAG AA - /80 reached only
3.78:1 and /40 sat at 1.80:1, below even the 3:1 floor for non-text -
because the palette had no step below --muted-foreground, so transparency
was the only tool for 'quieter than muted'.

The palette now has that step, and it is deliberately non-text:
--faint-foreground clears 3:1 (WCAG 1.4.11) on every surface for icons,
chevrons, separator glyphs and empty-cell em dashes. There is no room for
a third readable text tone - AA caps a lighter text tone 0.018 L away from
muted - so text keeps exactly one floor, --muted-foreground.

Also fixes text-destructive/70 on a cron error message, which was 3.61:1.

This branch changes zero font sizes. The sub-12px half of the issue is
MUL-5451's (#6136); keeping the two apart is what makes this one
reviewable on its own after #6108 was reverted.

apps/web/app/text-contrast.test.ts replaces muted-foreground-contrast.test.ts
rather than sitting beside it. It recomputes the floors from tokens.css
instead of hard-coding ratios, and fails the build on all four ways to spell
the defect: /70, /[0.5], /[50%], and a detached opacity-* in the same class
string. Transparency behind hover/focus/disabled stays allowed - the resting
state carries the contrast obligation and it is solid.

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

* fix(ui): correlate transparency across a whole class expression

Review found two ways past the guard, both real.

A per-literal check cannot see cn("… text-muted-foreground", suppressed &&
"opacity-60") - one element wearing a colour in one argument and a dim in
the next. That split shape is the common one, and it was hiding live
violations: the comment trigger chips dimmed aria-pressed label text to
2.55:1 while the sweep reported clean.

The second was my own exemption. Accepting any state word within 80
characters let "text-muted-foreground hover:text-foreground opacity-50"
through, because the hover: belongs to the colour, not to the opacity.

The detector now correlates across a whole cn() call or template literal,
splits it into segments that each carry their own condition, and exempts
only on the variant prefix the opacity utility itself carries or on the
condition governing its segment. Segment splitting is what keeps
${disabled ? "opacity-60" : ""} exempt while flagging its neighbours.

Fixed what that surfaced: three trigger-chip controls (the suppressed state
is already carried by the avatar's own grayscale, the sentence wording and a
solid muted step), a disabled-skill icon chip, and the diff gutter marker.
tab-bar's isDragging is exempt - a drag ghost is an in-flight gesture, the
same category as :active.

The detector now has its own table of thirteen cases. Every hole so far has
been silent, so the shapes it must and must not catch are pinned next to the
reason each one exists.

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

* test(ui): cover the faint tone in the cn() merge regression test

text-faint-foreground is a new text-<x> class, which is the exact shape
that silently broke the sidebar labels in #6108: tailwind-merge cannot
tell a size from a colour and drops one of them. The token does resolve
correctly today - verified both orders against a size step and against
another colour - but the test that exists to catch this was not covering
it, so the guarantee rested on nothing.

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

---------

Co-authored-by: Lambda <agent@multica.ai>
Co-authored-by: multica-agent <github@multica.ai>
2026-07-30 18:27:38 +08:00
Multica Eve
cbb23598fd docs(changelog): add v0.4.15 release entry (2026-07-30) (MUL-5545) (#6186)
Co-authored-by: Eve <eve@multica-ai.local>
Co-authored-by: multica-agent <github@multica.ai>
2026-07-30 17:44:02 +08:00
Bohan Jiang
577018649e feat(issues): support human-readable issue URLs using issue keys (MUL-5354) (#6117)
* feat(issues): support human-readable issue URLs using issue keys (MUL-5354)

Closes #5987. `/{ws}/issues/MUL-123` now opens the issue, the copy-link
action shares that form, and a UUID URL rewrites itself to it. Existing
UUID links keep working.

Backend already resolved identifiers on `GET /api/issues/{id}`, but it
compared the number only — every prefix with the right number opened the
same issue, so no identifier URL could be canonical. Resolution now
validates the prefix against the workspace's own (case-insensitively,
matching `lookupIssueByIdentifier`), and the number parser bails on
int32 overflow instead of truncating a digits-only UUID group into a
plausible issue number.

On the client the identifier stays a presentation concern: the route
resolves it to the UUID before rendering, because the realtime updaters
patch `issueKeys.detail(wsId, issue.id)` with the UUID from the
websocket payload. A view keyed on the identifier would sit on a cache
entry no realtime event can reach and silently stop updating. Resolution
reuses the request the detail view would have made anyway and seeds the
UUID-keyed entry, so an identifier URL costs no extra round trip. The
desktop tab title/status glyph hops through the same resolution for the
same reason.

The URL rewrite lives in the new route wrapper rather than IssueDetail:
the inbox renders IssueDetail in a side panel, where replacing the URL
would navigate the user out of the inbox.

No migration — `issue (workspace_id, number)` is already unique/indexed.

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

* refactor(issues): make the single-request guarantee for identifier URLs explicit

Review flagged that opening `/{ws}/issues/MUL-123` fires two detail
requests. It does not, under the app's own QueryClient — but the
guarantee was resting on something implicit, so make it structural.

The old shape seeded the UUID-keyed entry from a `useEffect` after
resolution, while the route enabled the UUID query in the same render.
That held only because the seed effect happened to be declared before
the UUID query's own effect, and because `createQueryClient` sets
`staleTime: Infinity` so a seeded entry is never refetched. Neither is
obvious from the code, and a diagnostic run under a bare `new
QueryClient()` (staleTime 0) does show two calls — the second being a
staleness refetch of an already-seeded entry, i.e. a harness artifact.

`useCanonicalIssueId` becomes `useCanonicalIssue`, which owns both the
resolution query and the canonical detail query and hands the resolution
response to the latter as `initialData`. That is applied while the
observer is created, so the canonical query never observes an empty
cache and never starts a fetch of its own — no dependency on effect
ordering, and no cache write that could race a realtime patch
(`initialData` is ignored once the entry holds data).

Callers collapse to one hook each: the route no longer runs its own
detail query, and the desktop page drops its duplicate.

Tests now build the client with `createQueryClient()` rather than a bare
`new QueryClient()`, so request-count assertions measure production
behavior instead of the harness, plus a direct assertion that an
identifier URL costs exactly one request.

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

* fix(issues): stop the request loop when an identifier names no issue

Opening `/{ws}/issues/ZZZ-134` never reached "not found". It spun an
unbounded request loop and left the UI on the loading skeleton forever.

The route treated a failed resolution as "nothing resolved" and handed
the raw identifier down to IssueDetail. IssueDetail mounted a second
observer on the query that had just failed; `retryOnMount` refetched it,
which flipped the resolve hook back to pending, which unmounted
IssueDetail, which remounted it when the refetch failed — and around
again. Measured with retry disabled to isolate it: 8,192 requests at
300ms, 32,768 at 600ms. Under the app's `retry: 1` the backoff only
paces the loop, it still never converges.

`useCanonicalIssue` now reports a terminal `notFound` read from the
resolution query's own error state, rather than leaving callers to infer
failure from "not resolving and no id" — an inference that cannot
distinguish failed from in-flight. `IssueDetailRoute` renders the
not-found UI itself and never hands an unresolved segment to a view that
would query it again, so no second observer exists to restart the cycle.
Same measurement after the fix: 1 request, settled, "not found" on
screen.

The not-found UI moves out of IssueDetail into a shared `IssueNotFound`
so both render the identical state.

Regression tests at both levels, with retry off so any count above 1 can
only be a remount refetch: the hook settles a failed resolution without
looping, and the real IssueDetailRoute holds at one request across
waits and rerenders. Both fail against the previous code.

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

---------

Co-authored-by: Bohan-J <bohan@devv.ai>
Co-authored-by: multica-agent <github@multica.ai>
2026-07-30 14:22:13 +08:00
Jiayuan Zhang
7803a5b9ea feat(ui): establish a role-named type scale and migrate ad-hoc font sizes (MUL-5451) (#6136)
tokens.css defined colours, radii and font families but not a single --text-*
step, so font sizes had no baseline to align to and grew wherever they were
needed: 51 distinct sizes across web + desktop, 370 written as arbitrary
values, six at half a pixel (10.5 / 11.5 / 12.5 / 13.5 / 14.5 / 15.5px).
text-xs and text-sm carried nearly all UI text while the range between them —
11, 13, 15px — could only be reached with arbitrary values. Hierarchy does not
come from having more sizes; past a handful, each extra size makes the
hierarchy blurrier.

Add ten role-named steps, each with its own line-height so leading cannot
fragment the way size did, and move every product-UI call site onto them.
Steps are named for what the text is for, not for a t-shirt size, because
that is what keeps the scale from drifting again.

Six steps deliberately keep the exact size/line-height pairs of the Tailwind
defaults they replace, so the ~1,900-call-site rename moves nothing on screen.
The visible changes are confined to former arbitrary values snapping to a step:
8/9/10px -> micro (11px) on badges and overlines; 17 -> 18; 22 -> 24; 30
(text-3xl) -> 36 on headings and stat numbers; 12.8px -> label (13px) on small
buttons and toggles. Half-pixel sizes are gone.

This supersedes #6108, which was reverted by #6116 because the sidebar group
labels rendered at the inherited 16px. The cause was not the scale but cn():
`text-<x>` is ambiguous in Tailwind, and tailwind-merge resolves it against a
table listing only the default sizes, so it filed every role step under
text-colour and dropped whichever of `text-caption` /
`text-sidebar-foreground/70` came first. Registering the steps as a font-size
class group restores the real conflict groups — size beats size, colour beats
colour, the two coexist — and a test pins the list against the scale, since
the failure is silent in source.

Hand-written CSS is covered too. The transcript kept a 12.5px body long after
every Tailwind call site was on the scale, so the "no half-pixel sizes" claim
was true of the classes and false of the product; the editor's prose, code and
mermaid ramps had the same blind spot, and seven of their eight values already
equalled a step exactly. All now reference var(--text-*). The guard test reads
raw `font-size:` declarations as well as class names, exempting only the 16px
iOS input-zoom workaround in base.css and the landing pages' marketing ramp.

apps/mobile (own NativeWind config) and apps/docs (fumadocs' own type system)
keep Tailwind's default scale and are untouched. Landing display type
(rem/clamp, 2.2-6.4rem) stays on its separate ramp, as do four decorative
emoji / serif-hero sizes.

Verified on a running local stack: pinned sidebar rows and group labels
measure 12px/16px, nav items 14px/20px — identical to pre-migration. An audit
of every rendered font size across the product surfaces finds nothing off the
scale; the only exceptions are avatar initials and emoji, which
actor-avatar.tsx sizes proportionally to the avatar diameter by design.

Co-authored-by: Lambda <lambda@multica.ai>
Co-authored-by: multica-agent <github@multica.ai>
2026-07-30 13:42:33 +08:00
Multica Eve
9dde8370a5 docs(changelog): add v0.4.14 release entry (#6120)
Co-authored-by: Eve <eve@multica-ai.local>
Co-authored-by: multica-agent <github@multica.ai>
2026-07-29 17:58:47 +08:00
Jiayuan Zhang
545afea827 Revert "feat(ui): establish a role-named type scale and migrate ad-hoc font s…" (#6116)
This reverts commit d68d636c91.
2026-07-29 17:10:33 +08:00
Jiayuan Zhang
d68d636c91 feat(ui): establish a role-named type scale and migrate ad-hoc font sizes (MUL-5451) (#6108)
tokens.css defined colours, radii and font families but not a single
--text-* step, so font sizes had no baseline to align to and grew wherever
they were needed: 51 distinct sizes across web + desktop, 370 of them written
as arbitrary text-[Npx] values, six at half a pixel (10.5 / 11.5 / 12.5 /
13.5 / 14.5 / 15.5px). text-xs and text-sm carried nearly all UI text while
the range between them — 11, 13, 15px — could only be reached with arbitrary
values. Hierarchy does not come from having more sizes; past a handful, each
extra size makes the hierarchy blurrier.

Add ten role-named steps, each with its own line-height so leading cannot
fragment the way size did, and move every product-UI call site onto them.
Steps are named for what the text is for, not for a t-shirt size, because
that is what keeps the scale from drifting again.

Six steps deliberately keep the exact size/line-height pairs of the Tailwind
defaults they replace, so the 1,900-call-site rename (text-sm -> text-body
and friends) moves nothing on screen. The visible changes are confined to
former arbitrary values snapping to a step:

- 8 / 9 / 10px -> micro (11px): 102 sites, mostly badges and overline
  labels. Deliberate — under 12px is a counter role, not a text role.
- 17px -> title (18px), 22px -> display-sm (24px), 30px (text-3xl) ->
  display (36px): 21 sites, all headings or stat numbers in flexible
  containers.
- Half-pixel steps are gone entirely.

Arbitrary sizes also inherited whatever line-height was above them; the
tokens now pin one, which removes latent overflow risk in the fixed-height
h-4/h-5 badges those sizes were used in.

apps/mobile (own NativeWind config) and apps/docs (fumadocs' own type system)
keep Tailwind's default scale and are untouched. Landing-page display type
(rem/clamp, 2.2-6.4rem) is marketing typography on a separate ramp and stays
out of the scale, as do four decorative emoji / serif-hero sizes.

A guard test fails the build on any font size written outside the scale,
reporting file:line — without it nothing in a Tailwind build makes an
off-scale value look wrong, which is how the drift happened.

Verified against a local stack: typecheck, lint and the full TS suite show no
new failures, and before/after screenshots of issues, issue detail, runtimes
(populated), usage, agents, inbox, my-issues and settings differ only in the
intended 10px -> 11px labels, with no row-height, truncation or layout shift.

Co-authored-by: Lambda <lambda@multica.ai>
Co-authored-by: multica-agent <github@multica.ai>
2026-07-29 16:30:13 +08:00
Jiayuan Zhang
4f70480039 fix(typography): real Inter italic + variable Geist Mono on desktop (MUL-5449) (#6105)
* fix(typography): load real Inter italic on web and desktop (MUL-5449)

Neither platform loaded an Inter italic face: next/font defaults to
`style: ["normal"]`, and desktop imported `@fontsource-variable/inter`
without `wght-italic.css`. Every `italic` in the product was therefore
browser-synthesized oblique — the ~20 semantic UI labels (chat empty
states, model-picker's "Managed by runtime", dashboard/squad
placeholders) plus all markdown <em> and blockquotes, which are user
content.

Load the real face on both, mirroring how Source Serif 4 is already
wired.

Also set `font-synthesis: style` on html, which forbids weight synthesis
while leaving style synthesis on. Weight synthesis is pure loss now that
every loaded face has real weights, and it is actively harmful on the CJK
tail of --font-sans: `font-bold` against PingFang SC (which stops at
Semibold) makes Chromium smear a fake 700 that closes up the counters of
dense Han glyphs.

Style synthesis stays on deliberately. `auto` only synthesizes when no
real italic matches, so the newly loaded Inter Italic already wins for
Latin. The only stacks still synthesizing are the two that ship no italic
at all — CJK fallbacks and Geist Mono — where `font-style: italic` has no
other visual carrier; a blanket `font-synthesis: none` would silently
flatten every Chinese <em>, every editor italic mark and every hljs
comment to upright.

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

* fix(typography): give desktop the variable Geist Mono (MUL-5449)

Web gets a variable Geist Mono from next/font (`font-weight: 100 900`,
verified in the emitted CSS), but desktop loaded only the discrete 400
and 700 cuts. Any weight in between silently snapped to the nearest one
desktop had, so the same shared component rendered at two different
weights on the two platforms:

- `font-mono font-medium` (500) fell back to 400 in chart.tsx,
  webhook-event-filter-section.tsx and keyboard-shortcuts-tab.tsx.
- Inline <code> in rendered markdown has no explicit weight, so inside a
  <strong>, heading or <th> — all `font-semibold` — it inherits 600 and
  resolved up to 700.

Loading `@fontsource/geist-mono/500.css` would have fixed only the first
of those. Switching to `@fontsource-variable/geist-mono` covers 100-900
in one file and closes the whole class, matching how Inter and Source
Serif 4 are already wired on desktop.

The latin subset is also smaller than the two static cuts it replaces:
22.6 KB vs 14.4 + 14.9 KB.

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

* docs(typography): correct stale Geist Mono note in font-synthesis comment

The comment was written when desktop still loaded discrete 400/700 cuts.
The following commit swapped desktop to @fontsource-variable/geist-mono,
so Geist Mono is now variable on both platforms and the "ships discrete
cuts" clause was false. Also name landing's 400-only Instrument Serif so
the "every face we load has real weights" claim is complete.

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

---------

Co-authored-by: Lambda <lambda@multica.ai>
Co-authored-by: multica-agent <github@multica.ai>
2026-07-29 16:29:05 +08:00
Jiayuan Zhang
e54296eabb fix(ui): raise light muted-foreground to clear WCAG AA (MUL-5447) (#6095)
In light mode `--muted-foreground` at oklch(0.552 0.016 285.938) missed the
4.5:1 AA floor on every light surface except pure white. The worst pair was
3.98:1 — nav labels on --sidebar-accent, i.e. the primary navigation in its
hover state. One token backs ~2k `text-muted-foreground` call sites, so the
blast radius is most of the product's secondary text.

Drop lightness to 0.505 and leave hue and chroma alone so the neutral ramp
keeps its cast. Worst case is now 4.88:1. Dark mode already passed at
5.2-7.2:1 and is untouched.

The `.landing-light` block in apps/web re-declares the light palette so
token-driven components stay light under next-themes' `.dark` class; it moves
with the source or it silently drifts.

The new test asserts on the token file rather than on components: the token is
the contract every consumer inherits, and a component test could only prove a
class name is present, not that the pixels are legible.

Co-authored-by: Lambda <lambda@multica.ai>
Co-authored-by: multica-agent <github@multica.ai>
2026-07-29 15:37:20 +08:00
Jiayuan Zhang
503b09c5de fix(web): give landing CJK headlines a real serif and CJK metrics (MUL-5446) (#6101)
The landing tree set `--font-serif` to Instrument Serif, which ships Latin
only, so zh/ja/ko headlines fell out of the stack onto the browser default
sans while the Latin next to them stayed in a high-contrast display serif.
A `Noto_Serif_SC` next/font entry was loaded as `--font-serif-zh` but no
rule ever referenced it, and it requested `subsets: ["latin"]`, so it
downloaded no CJK glyphs either way — remove it.

Compose `--font-serif` for the landing tree in static CSS instead, with a
per-`<html lang>` CJK tail, mirroring the `--font-sans` chain in globals.css:
Chinese before Korean by default, a Mincho-first chain promoted for `ja` so
Kanji do not get Chinese glyph shapes. Platform Songti/Mincho/Myeongjo faces
rather than a multi-megabyte CJK webfont.

The 16 landing surfaces that inlined `font-[family-name:var(--font-serif)]`
now share a `.landing-serif` hook, which gives the per-locale metric reset a
precise target: CJK headings drop the Latin display tracking (down to
-0.038em) and sub-1 leading (0.93) that crowded ideographs and collided the
two lines of a `<br />`-split headline. Korean additionally gets
`word-break: keep-all` so wrapped Hangul breaks at word boundaries.

Scoped to h1/h2 so the Latin-only footer wordmark keeps its tuned tracking.
The English landing hero renders pixel-identical to before.

Co-authored-by: Lambda <lambda@multica.ai>
Co-authored-by: multica-agent <github@multica.ai>
2026-07-29 15:36:42 +08:00
Bohan Jiang
0254090853 docs(changelog): file the Claude Code cache fix under fixes and lead with it (MUL-5400) (#6047)
* docs(changelog): lead v0.4.13 with the prompt-cache saving (MUL-5400)

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

* docs(changelog): scope the cache fix to Claude Code and move it to fixes (MUL-5400)

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

---------

Co-authored-by: Bohan-J <bohan@devv.ai>
Co-authored-by: multica-agent <github@multica.ai>
2026-07-28 18:48:09 +08:00
Multica Eve
79cc3abc8d docs(changelog): add v0.4.13 release entry (#6038)
Co-authored-by: Eve <eve@multica-ai.local>
Co-authored-by: multica-agent <github@multica.ai>
2026-07-28 18:33:42 +08:00
Bohan Jiang
ceba14a227 fix(issues): MUL-5362 return to the source list after deleting an issue (#5997)
* fix(issues): return to the source list after deleting an issue

Deleting an issue from its detail page always pushed the workspace
Issues list, so opening an issue from My Issues (or a project list,
search, a pin, an agent panel) and deleting it dropped the user's
navigation context — GH #5995.

Go back instead. `useBackOrReplace` steps back when the platform
reports in-app history and replaces with a fallback path when there
is none, so a shared link opened cold or a new tab never steps off
the app. Web answers via the Navigation API, falling back to counting
its own pushes; desktop reads the active tab's virtual history.

`replace`, not `push`: the deleted issue's URL must not stay in
history for the back button to land on a 404.

The not-found "Back to Issues" button loses the same context, so it
moves to the same helper and its label becomes a plain "Back".

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

* fix(web): track history position, not push count, for canGoBack

The Navigation API fallback only ever counted pushes, so `pushes > 0`
did not mean the current entry still had an in-app page behind it.
Cold-open an issue, click Issues, press the browser's Back button, then
delete: the tracker still claimed history and `back()` would step off
Multica — the exact case the fallback exists to prevent.

Count depth instead: a push adds one, any traversal takes one away
(clamped at zero). Reaching the document's first entry requires
traversing back at least as many times as we pushed, so a positive
depth can never be claimed while sitting on it. A browser Forward is
now conservative — it reports no history where a step back would have
been fine — which costs a fallback navigation rather than an exit.

Also corrects the useBackOrReplace contract comment: stepping back
leaves the dead URL in forward history, so the guarantee is that it
never lands on the back stack, not that it leaves history entirely.

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

* fix(web): answer canGoBack from the browser alone, never from push counts

Review found the counter still lied, one level deeper: it counted
`router.push` calls, and a call is not a committed history entry. Next
drops a push to `replaceState` when the canonical URL is unchanged
(app-router.js, the `pendingPush && href !== canonicalUrl` branch), so
clicking a self-link — the breadcrumb on an issue detail page, a pin to
the issue you are on — incremented depth with no entry behind it, and a
delete from there could still step off the app.

Fixing the count needs per-entry markers, which means depending on both
React effect ordering (our provider's effect runs before app-router's
history commit) and Next's own preserveCustomHistoryState behaviour.
Two rounds of review have now found holes in hand-rolled history
tracking; a third layer of it is not the way to buy this guarantee.

So stop deriving. `canGoBack` is the Navigation API's answer or `false`.
Browsers without it take the fallback path, which is exactly what they
did before any of this existed — nobody regresses, and the "wrong true
walks the user out of the app" failure is now unreachable by
construction.

Drops the tracker, the popstate wiring and the push wrapper: the
`multica:navigate` bridge returns to its original shape.

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

---------

Co-authored-by: Bohan-J <bohan@devv.ai>
Co-authored-by: multica-agent <github@multica.ai>
2026-07-27 18:39:25 +08:00
Multica Eve
e52b50658a docs(changelog): add v0.4.12 release entry (2026-07-27) (MUL-5364) (#5996)
* docs(changelog): add v0.4.12 release entry (2026-07-27) (MUL-5364)

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

* docs(changelog): move PR-card live status from v0.4.11 to v0.4.12 (never enabled in prod) (MUL-5364)

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

---------

Co-authored-by: Eve <eve@multica-ai.local>
Co-authored-by: multica-agent <github@multica.ai>
2026-07-27 17:36:27 +08:00
Multica Eve
b0bae3f95e docs(changelog): add v0.4.11 release entry (2026-07-25) (MUL-5284) (#5916)
Co-authored-by: Eve <eve@multica-ai.local>
Co-authored-by: multica-agent <github@multica.ai>
2026-07-25 03:38:12 +08:00
Multica Eve
afb3def1b7 docs(changelog): add v0.4.10 release entry (2026-07-24) (MUL-5273) (#5894)
* docs(changelog): add v0.5.0 release entry (2026-07-24) (MUL-5273)

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

* docs(changelog): include #5893 board/list load-more fix in v0.5.0 (MUL-5273)

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

* docs(changelog): use version 0.4.10 (patch bump) per review (MUL-5273)

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

---------

Co-authored-by: Eve <eve@multica-ai.local>
Co-authored-by: multica-agent <github@multica.ai>
2026-07-24 17:45:56 +08:00
苗大
a90aa92d0c fix(web): resolve API and docs upstreams at runtime (#4840)
* fix(web): resolve upstream URLs at runtime

* fix(web): keep unconfigured upstreams same-origin

* fix(web): restore dev-only localhost fallbacks for API and docs upstreams

`next dev` on a developer machine now falls back to the conventional
http://localhost:8080 backend (honoring BACKEND_PORT) and
http://localhost:4000 docs origin when nothing is configured, so a bare
`pnpm dev:web` keeps proxying out of the box. Builds and the runtime
proxy keep the strict resolvers, so prebuilt images still leave unset
upstreams unproxied.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Naiyuan Qing <145280634+NevilleQingNY@users.noreply.github.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-24 11:02:46 +08:00
Bohan Jiang
ffa8e16369 MUL-5228 fix(usage): bill Grok at xAI's reported cost, fix $0 resumed sessions (#5841)
* fix(agent): attribute Grok usage from the turn's own model id

A resumed Grok session with no configured model recorded its entire spend
under the model id "unknown", which matches no pricing row — so the task
reported $0 cost instead of its real spend.

grok.go only learned the model from the session handshake, and ACP's
`session/load` carries no model id (only `session/new` does). When neither
the agent nor MULTICA_GROK_MODEL pins a model, `daemon.go` legitimately
passes an empty model, leaving nothing to attribute the usage to.

Every Grok turn stamps `result._meta.modelId` with what it actually billed
against. Parse it in the shared ACP result parser and use it as the fallback
in grok.go. Other ACP backends are untouched — they keep whatever the
handshake gave them.

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

* fix(metrics): price the Grok catalog in server-side cost metrics

server/internal/metrics/pricing.go carried no Grok rows at all, so
RecordLLMUsage took the unpriced branch for every Grok turn: llm_cost_usd
reported zero Grok spend while the tokens accumulated in
llm_unpriced_tokens. Internal cost monitoring simply could not see Grok.

Add the six SKUs xAI publishes rates for, mirroring the frontend table in
packages/views/runtimes/utils.ts. Aliases are anchored exact matches like
the gpt-5.6 rows, so `grok-composer-*` (in the catalog, absent from the
price sheet) stays unmapped instead of inheriting a guessed rate.

Short-context tier on purpose: xAI bills a request at 2x once its prompt
reaches 200K tokens, but a usage record aggregates every model call in a
turn and cannot say which tier an individual request hit.

A regression test re-derives the cost of a real grok 0.2.106 turn from the
table and checks it against the costUsdTicks xAI returned for that turn.

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

* docs(changelog): scope the Grok cost claim to what was actually fixed

The v0.4.9 entry promised "accurate cost" in all four languages, but the
fix corrected catalog pricing and cached-input double-counting — it did not
implement xAI's 2x long-context tier, so a turn whose requests reach 200K
prompt tokens still under-reports by up to 50%. Say what was fixed instead.

Also correct two stale claims in the pricing comment: the daemon tags usage
rows with the runtime provider `grok`, not `xai` (the bare `grok-*` keys are
what make them resolve), and record why thresholding the long-context tier
on an aggregated row would be worse than not pricing it at all.

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

* feat(usage): carry the provider's own cost through to the usage record

Cost has always been derived client-side as tokens x a static rate, which
cannot express request-level pricing rules. xAI bills a Grok request at 2x
once its prompt reaches 200K tokens, and a task_usage row aggregates every
model call in a turn — so the stored token counts genuinely cannot say which
tier any individual request hit. Thresholding on the aggregate would be worse
than the status quo: it turns a bounded 50% under-estimate into an unbounded
over-estimate for turns made of many short requests.

Grok already reports what it charged, per turn, in `_meta.usage.costUsdTicks`.
Parse it, carry it through agent -> daemon -> API, and store it on task_usage
as a nullable BIGINT of 1e-10 USD ticks (integer, so sub-cent turns stay exact
end to end). NULL means the provider reported no cost — every pre-existing row
and every provider that doesn't return one. No backfill: there is no
authoritative figure to recover for those, and inventing one is the guess this
removes.

A single hourly bucket can mix rows that carry a cost with rows that don't, so
task_usage_hourly gains both halves: `cost_usd_ticks` sums the authoritative
side, and `uncosted_*_tokens` carry exactly the tokens that still need a
rate-table estimate. Consumers report authoritative + estimate(uncosted),
which degrades to today's behaviour when nothing in the bucket is
authoritative. The existing token columns keep covering every row, so token
displays are untouched. The new columns are additive with defaults, so the
unique key, the dirty-queue shape, and migration 102's triggers are unaffected.

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

* feat(usage): prefer the provider's own cost over the rate table

With the authoritative figure now stored, both cost consumers use it: the
usage dashboard (estimateCost / estimateCostBreakdown) and the server-side
llm_cost_usd metric. Each reports `authoritative + estimate(uncosted tokens)`,
so a row or bucket that mixes priced and unpriced sources stays whole.

The static rate tables remain, but for Grok they are now a fallback — they
still price usage recorded by a daemon too old to report cost, and every
provider that reports none. Custom pricing overrides likewise apply only to
the estimated half: they are a user's guess at a rate, and the authoritative
half is not a guess. A model with no rate-table row but a provider-reported
cost now also drops out of the "unmapped models" banner, since asking the user
to supply a rate for it would invite overriding a real bill.

llm_cost_usd is labelled by token_type and the provider reports one number per
turn, so the charge is distributed across the buckets in the rate table's own
proportions. Only the total is authoritative; the split stays an estimate,
which is why this scales the existing buckets rather than inventing a label.
estimateCostBreakdown does the same, keeping the stacked chart summing to the
headline figure instead of silently under-drawing every Grok row.

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

* docs(changelog): say Grok cost now follows xAI's actual charge

The earlier wording scoped the claim down to catalog pricing and cached input
because the long-context tier was still unhandled. It is handled now — the
cost comes from what xAI charged for the turn — so the entry can say so.

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

* fix(usage): keep the provider's cost when the model has no rate row

Both cost consumers bailed out before reading the authoritative figure when
the rate table had no row for the model. A `grok-composer-*` turn — in the
Grok Build catalog, absent from xAI's price sheet — was therefore reported as
$0 spend even though xAI told us exactly what it charged.

Worse on the client: estimateCost returned the real cost while
estimateCostBreakdown returned zeros, so the headline and the stacked chart
disagreed on precisely the rows whose cost is exact — and the unmapped-models
banner was (correctly) hidden, so nothing explained the discrepancy.

Handle the charge before the rate lookup in both places. Without rates there
is nothing to split a total by, so it lands whole in the `input` bucket, the
same fallback distributeAuthoritativeCost already uses when it has no shape to
scale. Tokens with no rate keep going to llm_unpriced_tokens: "unpriced"
describes the rate table, not the money.

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

* perf(usage): drop the historical rewrite from the cost-split migration

Migration 213 rewrote every existing task_usage_hourly row to seed the
uncosted counters. That is a full-table UPDATE inside a schema migration —
lock time, WAL and bloat all scaling with table size — for rows this issue
explicitly does not care about.

Deleting the UPDATE alone would have zeroed historical cost: with
`NOT NULL DEFAULT 0`, an untouched row asserts "nothing here needs
estimating", so every pre-split bucket would report $0 until the rollup
happened to touch it. Make the uncosted columns nullable with no default
instead. NULL means "never recomputed since the split existed", readers
COALESCE it to the row's own token total ("estimate all of it"), and the
pre-split behaviour is preserved exactly — with nothing to seed, so no
rewrite. A bare ADD COLUMN is metadata-only, so this is now fast DDL.

Rows heal into the split naturally as the rollup recomputes their buckets.

Verified on a fresh database: a legacy-shaped row reads back as its full
tokens to estimate, and a group mixing legacy and post-split buckets sums to
the authoritative cost plus both rows' estimable tokens.

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

---------

Co-authored-by: Bohan-J <bohan@devv.ai>
Co-authored-by: J <agent@multica.ai>
Co-authored-by: multica-agent <github@multica.ai>
2026-07-24 01:42:08 +08:00
Multica Eve
f51e019788 docs(changelog): add v0.4.9 release entry (2026-07-23) (MUL-5219) (#5832)
* docs(changelog): add v0.4.9 release entry (2026-07-23)

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

* chore(web): bump version to 0.4.9

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

---------

Co-authored-by: Eve <eve@multica-ai.local>
Co-authored-by: multica-agent <github@multica.ai>
2026-07-23 17:40:38 +08:00
Bohan Jiang
1fef98c24f fix(desktop): open in-app links in a tab instead of the browser (MUL-5208) (#5826)
* fix(desktop): open in-app links in a tab instead of the browser (MUL-5208)

A link written as an absolute URL on this deployment's own origin
(`https://<app-host>/acme/issues/1` — what "copy link" produces, and what
agents paste into chat) fell through openLink's external branch to
window.open, which Electron routes to shell.openExternal. Clicking an issue
link in Desktop chat therefore opened a browser window instead of a tab.

openLink now resolves such a URL back to its in-app path and takes the same
route a relative path does. Backend-served prefixes (/api/, /_next/) stay
external so attachment downloads keep working.

Two supporting fixes the change depends on:

- The `multica:navigate` event had no listener on web, so in-app paths in
  content were dead links there; normalizing app URLs would have extended
  that to every pasted app URL. The web platform layer now answers the event
  with a router push.
- The desktop handler opened every path inside the active workspace's tab
  group. A cross-workspace link now goes through switchWorkspace, matching
  what the navigation adapter already does for pushes.

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

* fix(desktop): scope in-app link conversion to workspace pages, answer it in issue windows

Review follow-ups on MUL-5208.

1. The non-page exclusion list only covered /api/ and /_next/, so a
   same-origin /uploads/* link — local-storage attachments, served by the
   backend and proxied by web — was routed as an app page, opening a dead tab
   instead of the file. Replaced the deny-list with the app's own routing
   model: an absolute URL converts to an in-app path only when its first
   segment is a slug a workspace could own, which the existing reserved-slug
   list (shared with the backend) already answers. /api, /uploads, /_next,
   /favicon.ico and the pre-workspace routes all stay external without a
   second list to keep in sync.

2. A dedicated issue window derives the same app origin from its adapter but
   had no multica:navigate listener, so a same-origin link there became a
   silent no-op (it used to reach the browser). The window now answers the
   event: another issue opens in place — matching what its adapter push and
   mention chips already do — and any other app page, which this single-route
   window cannot host, goes to the browser.

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

---------

Co-authored-by: J <agent@multica.ai>
Co-authored-by: multica-agent <github@multica.ai>
2026-07-23 16:50:23 +08:00
Multica Eve
67240cea46 docs(changelog): add v0.4.8 release entry (2026-07-22) (#5769)
Co-authored-by: Eve <eve@multica-ai.local>
Co-authored-by: multica-agent <github@multica.ai>
2026-07-22 17:55:08 +08:00
Multica Eve
216aee5629 [MUL-5125] Add daily Desktop/Web usage and runtime reporting (#5763)
* feat(analytics): add daily client usage reporting (MUL-5125)

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

* fix(analytics): clarify daily usage semantics (MUL-5125)

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

* fix(analytics): resolve usage review blockers (MUL-5125)

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

---------

Co-authored-by: Eve <eve@multica-ai.local>
Co-authored-by: multica-agent <github@multica.ai>
2026-07-22 16:52:34 +08:00
Multica Eve
b03e118aa7 docs(changelog): add 0.4.7 release entry (MUL-5111) (#5724)
Co-authored-by: Eve <eve@multica-ai.local>
Co-authored-by: multica-agent <github@multica.ai>
2026-07-21 17:32:24 +08:00
Multica Eve
5a176b9bca docs(changelog): add v0.4.5 (2026-07-20) release entry (#5681)
* docs(changelog): add v0.5.0 (2026-07-20) release entry

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

* docs(changelog): use v0.4.5 instead of v0.5.0 per Bohan

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

---------

Co-authored-by: Eve <eve@multica-ai.local>
Co-authored-by: multica-agent <github@multica.ai>
2026-07-20 17:33:38 +08:00
Multica Eve
8ca30e794e docs(changelog): v0.4.4 (2026-07-17) release entry (#5577)
* docs(changelog): add v0.4.4 (2026-07-17) release entry

Adds today's user-facing changelog entry across en/zh/ja/ko: a new autopilot schedule editor, an interactive diagram viewer, plus the day's improvements and fixes.

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

* docs(changelog): use 自动化 for autopilot in zh, matching the standard term

Per Bohan's review: the Chinese standard for autopilot is 自动化 (as used elsewhere in zh.ts, e.g. 定时自动化 / 触发式自动化), not 自动驾驶. Normalizes all 10 occurrences in the changelog, including the new v0.4.4 entry and earlier historical entries, for consistency.

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

---------

Co-authored-by: Eve <eve@multica-ai.local>
Co-authored-by: multica-agent <github@multica.ai>
2026-07-17 17:51:36 +08:00
Naiyuan Qing
ce15300493 MUL-4884: feat(issues): anchor the working chip on agents + real colour tiers (#5540)
* fix(issues): anchor the working chip on issues the filter actually shows (MUL-4884)

The header chip showed three units at once: the number counted distinct
issues, the avatar stack counted agents (with a rival "+N"), and the hover
card counted tasks — under a label with no noun at all. Each figure was
self-consistent; together they read as a miscount. c4209ec7c flipped the
number from agents to issues but left the other three surfaces on their old
units.

The chip now says one thing — "N issues in progress" — where N is the number
of rows clicking it leaves.

Data layer:

- The count is no longer re-derived from the task snapshot. The surface
  exposes `workingScopeIssues`: the render pipeline's own output with
  `workingOnly` forced on, so "chip count === row count" holds by
  construction instead of by convention. It previously counted running
  issue_ids against the PRE-filter set, so any active status/assignee/label
  filter — or a sub-issue hidden by the display toggle — made the chip
  disagree with the list it was filtering.
- Chat/autopilot tasks carry issue_id "" (not null). They used to collapse
  into one bucket and read as a phantom issue, inflating the count by exactly
  one whenever any were running. They are now bucketed out and disclosed.
- The list loads one page per status (50), so running work can exist past the
  window. The count stays list-anchored — counting rows a click cannot show
  would break the chip's whole promise — and the gap is stated in the hover
  card rather than dropped silently.

UI:

- Avatar stack is ambience, not a statistic: no "+N"; the tail fades and the
  exact roster lives in the hover card.
- Hover card groups rows by issue, names both counted units in its header
  ("3 issues in progress · 4 tasks"), and footnotes anything excluded — only
  when non-zero.
- Colour is two-step: idle activity is a brand tint, the filled state is
  reserved for "filter is ON".

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

* fix(issues): scope the working chip to the rows swimlane and gantt actually draw (MUL-4884)

Review found two modes where `workingScopeIssues` still came from a
different set than the one on screen — the exact drift this change set is
meant to remove.

Swimlane: scoped to the statusless `swimlaneIssues`, but SwimLaneView draws
its cards from `issues` (status filter applied) and only uses the statusless
set for LANE DISCOVERY. A status-filtered swimlane counted rows the canvas
never drew. Swimlane needs no branch at all — it renders the flat filtered
list like board and list do — so the special case is gone rather than
corrected.

Gantt: the canvas applies two rules of its own before drawing — a row needs a
date to be placed, and done/cancelled hide unless `ganttShowCompleted` is on
— so a done issue with a running agent counted toward the chip while the
canvas refused to draw it.

Those rules now live in the surface (`ganttCanvasRows`) instead of privately
inside GanttView, and both `filteredGanttIssues` and the working scope go
through them. Mirroring the rules in a second place would have reproduced the
original bug with extra steps; hoisting them means the chip narrows the same
set the canvas draws, by construction. GanttView goes back to being a
renderer: it orders rows, it does not decide which ones exist.

Regression tests cover both, and each was confirmed to fail against the code
it guards: swimlane asserts the statusless lane source still holds the wider
set, so a revert fails loudly rather than passing by luck; gantt covers the
hidden done row, the undated row, and the show-completed toggle widening both
the canvas and the scope together.

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

* feat(issues): anchor the working chip on agents, and give it real colour tiers (MUL-4884)

Follows the approved final mock. Two changes, one root cause each.

SEMANTICS: the chip now counts agents, not issues.

The original bug was "+1" (agents) sitting next to "3" (issues) — two units
in one control. Anchoring on issues fixed which number was authoritative but
left the units mismatched, so the avatar stack had to lose its +N and the
label had to grow a noun to suppress the contradiction. Counting agents makes
the number and the heads beside it the same list: the mismatch is gone rather
than managed. It also settles the subject — only agents emit a runtime
signal, so "N agents working" cannot be misread as "N issues someone is
working on", which "N issues in progress" invited.

Accepted trade-off: the number no longer predicts the row count of the click.
One agent on two issues reads "1 agent working" and opens two rows. The chip
answers WHO, the list answers WHERE — different questions, so they don't
compete, and the hover card's issue grouping shows the mapping. The
workingScopeIssues pipeline stays: it still decides WHICH agents count.

The hover card drops both "not counted" footnotes. They explained an absence
the user never perceived — chat/autopilot runs leave no row, no head, no
indicator on this page — so they invented a discrepancy instead of resolving
one. The empty-issue_id guard stays: that is data correctness (those agents
aren't on any issue), independent of whether we narrate it.

Avatar overflow returns to the component's standard +N badge; the fade
variant is deleted. With an agent-anchored number, "3 shown + 1 = 4"
corroborates the text instead of competing with it.

COLOUR: three tiers, each a self-contained Button variant.

Layering brand classes over `outline` could never work in dark mode:
`outline` ships dark:bg-input/30 / dark:border-input / dark:hover:bg-input/50,
tailwind-merge keeps them (different modifier group), and they win the
cascade — `dark` compiles to `&:is(.dark *)`, so they outrank a bare
.bg-brand on specificity, not just order. Both brand tiers were dead in dark;
--brand-foreground equals --foreground there, so filter-on and filter-off
rendered pixel-identical.

New `brand` / `brandSubtle` variants own every state instead: hover, pressed,
and aria-expanded pinned to the hover value so opening the popover doesn't
read as a colour change. `brand` needs no dark: at all — the token flips per
theme. `brandSubtle` runs one notch hotter in dark, where the same alpha
reads weaker.

Verified against the compiled stylesheet rather than by asserting class
strings — a string assertion is what let the dark bug through. A cascade
check resolves each tier x {default,hover,pressed,popover-open} x
{light,dark} by specificity and source order; all 16 brand cells land on
their intended notch, identical in both themes for `brand`.

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

* fix(issues): stop the empty-state muted text from overriding the brand tier (MUL-4884)

Review caught the colour rule breaking itself, and the test that was supposed
to guard it being unable to.

`filter ON + 0 agents` is a real state — the filter stays on after the last
agent finishes. There the variant is `brand`, and the chip appended
`text-muted-foreground` for the empty state regardless of the filter.
tailwind-merge keeps the last class in a group and `className` is merged
after the variant, so the muted grey WON: grey text on a brand-blue fill. The
irony is exact — this change set exists because colour classes in `className`
lose to a variant's `dark:` chain, and here one beat the variant instead.
Either way the lesson is the same: a tier's colours only ever come from its
variant. `chipAppearance` now makes that a rule with one gated exception
instead of an inline ternary, and says why.

The variant assertion could not catch this: the variant IS `brand` while the
text is overridden. That is the same shape of false confidence as the class
-string assertion it replaced, so this commit brings the cascade check into
the repo instead of leaving it as something a commit message claimed.

`apps/web/app/brand-variant-cascade.test.ts` compiles the real globals.css —
web's and desktop's, since each defines its own `dark` variant — and resolves
what a browser would paint for the merged class strings: filter declarations
matching the element's classes and state, then take the winner by
specificity, then source order. It asserts both tiers across default / hover
/ pressed / popover-open x light / dark, and pins the two ways the colour has
actually been lost as executable failures: layering brand over `outline`
(dark:bg-input/30 wins by specificity) and appending a colour class (wins by
merge order).

Both new guards were confirmed to fail against the code they guard: reverting
the gate fails the chipAppearance test; giving `brand` a dark: rule fails the
cascade test in both bundles.

Also refreshes the comments still describing the retired issue-anchored
invariant ("chip count === row count"). The scope pipeline they describe is
unchanged and still load-bearing — it decides WHICH agents count — but the
chip's number is agents, so it is no longer this list's length.

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

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>
2026-07-17 13:56:23 +08:00
Multica Eve
ad7b23896d docs(changelog): add v0.4.3 release entry (en/zh/ja/ko) (#5533)
Co-authored-by: Eve <eve@multica-ai.local>
Co-authored-by: multica-agent <github@multica.ai>
2026-07-16 17:37:34 +08:00
Jiayuan Zhang
47e9c8add5 fix(web): add macOS Intel downloads to landing page (#5517)
Co-authored-by: Lambda <lambda@multica.ai>
Co-authored-by: multica-agent <github@multica.ai>
2026-07-16 15:11:27 +08:00
Multica Eve
1062c13bdc docs(changelog): add v0.4.2 release entry (2026-07-15) (#5453)
Co-authored-by: Eve <eve@multica-ai.local>
Co-authored-by: multica-agent <github@multica.ai>
2026-07-15 17:55:44 +08:00
Naiyuan Qing
dddcff8744 fix(landing): point dashboard CTAs at the real workspace route (#5442)
The four landing "Dashboard" CTAs used href="/" and relied on the proxy
bouncing logged-in visitors from the root path to their workspace. Once #5363
kept "/" public on the official marketing host so logged-in users could browse
the site (#5326), that bounce stopped — and the CTAs began resolving to the
page the visitor was already on, so clicking them did nothing at all.

Resolve the destination in the CTA instead of leaning on a redirect side
effect elsewhere: useDashboardCtaHref() runs the same
resolvePostAuthDestination() that RedirectIfAuthenticated already uses, so
"where the dashboard lives" has one source of truth. This also collapses the
same `user ? "/" : "/login"` line that had been copy-pasted across four files.

proxy.ts and redirect-if-authenticated.tsx are untouched — #5363's behavior is
correct and proxy.test.ts still passes unchanged.

Closes MUL-4794

Co-authored-by: Walt <walt@multica.ai>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>
2026-07-15 14:44:49 +08:00
Multica Eve
ebca1c1914 docs(changelog): add 0.4.1 release notes (en/zh/ja/ko) (#5394)
* docs(changelog): add 0.4.1 release notes across en/zh/ja/ko

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

* fix(desktop): cancel scheduled update checks when auto-update is disabled

The startup and periodic update timers were left running when a user turned
automatic updates off; the timer callbacks only consulted the preference
asynchronously, so a tick that raced the preference flip could still fire a
check. Cancel the timers on disable (and re-arm them on re-enable) so disabling
truly stops future background checks, removing a CI-flaky race in updater.test.ts.

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

* docs(changelog): drop issue-view virtualization improvement from 0.4.1

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

---------

Co-authored-by: Eve <eve@multica-ai.local>
Co-authored-by: multica-agent <github@multica.ai>
Co-authored-by: J <j@multica.ai>
2026-07-14 18:15:06 +08:00
ZIce
e1d0d68c53 Fix public host root redirect (#5363)
Co-authored-by: multica-agent <github@multica.ai>
2026-07-14 09:11:21 +08:00
Multica Eve
014eb0496d docs(changelog): v0.4.0 release notes (#5339)
* docs(changelog): add v0.3.44 release notes across all locales

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

* docs(changelog): bump release version to 0.4.0

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

---------

Co-authored-by: Eve <eve@multica-ai.local>
Co-authored-by: multica-agent <github@multica.ai>
2026-07-13 18:11:48 +08:00
Multica Eve
220fa58264 fix: guide SSH installs to token login (#5318)
Co-authored-by: Eve <eve@multica-ai.local>
Co-authored-by: multica-agent <github@multica.ai>
2026-07-13 16:42:46 +08:00
Jiayuan Zhang
b47e835d7d refactor(runtimes): organize runtime management by machine (#5297) 2026-07-12 15:41:49 +08:00
Jiayuan Zhang
1427e8abd3 feat(agents): add conversational creation studio (#5296) 2026-07-12 15:40:10 +08:00
Jiayuan Zhang
ca46fdb483 fix(ui): lighten menu shadows with dedicated --menu-shadow token (#5256)
Dropdown, context menu, select, and popover surfaces used
--floating-shadow, which is sized for window-level overlays and reads
too heavy on trigger-anchored menus. Introduce a lighter --menu-shadow
tier (surface < menu < floating) and drop the shadow-lg override on
ContextMenuSubContent so submenus match their parent menu. Dialogs and
sheets keep --floating-shadow.

Co-authored-by: Lambda <lambda@multica.ai>
Co-authored-by: multica-agent <github@multica.ai>
2026-07-11 14:33:04 +08:00
Jiayuan Zhang
4efcfb96e3 feat(ui): establish surface system (#5248) 2026-07-11 13:43:44 +08:00
Multica Eve
37b0a6e72e docs(changelog): add v0.3.43 release entry (MUL-4376) (#5216)
Add today's user-facing release notes to /changelog across en/zh/ja/ko: new Codex gpt-5.6 models, issue-key autolinks, avatar cropping, per-thread agent replies, round avatars, direct batch status, bounded background log, and Lark/Cursor/Claude fixes.

Co-authored-by: multica-agent <github@multica.ai>
2026-07-10 17:35:34 +08:00
Multica Eve
01f28e8af6 docs(changelog): add v0.3.42 release entry across en/zh/ja/ko (#5159)
Adds the daily v0.3.42 changelog entry to all four localized landing sites: a dedicated Chat tab, LLM-generated chat titles, cancelled issues as a first-class column, agent model/effort on hover, plus reconnect/comment-delivery/agent-mention/link/version fixes.

Typecheck and the changelog unit test pass locally.

Co-authored-by: Eve <eve@multica-ai.local>
Co-authored-by: multica-agent <github@multica.ai>
2026-07-09 17:53:40 +08:00
Jiayuan Zhang
a51ab4d551 feat(chat): Chat V2 — first-class IM-style Chat tab (MUL-4171) (#5076)
* feat(chat): Chat V2 — first-class IM-style Chat tab (MUL-4171)

Replace the floating chat FAB/window with a first-class Chat tab under
Inbox, laid out as an IM-style two-pane surface (thread list + conversation).

Highlights:
- New Chat page (packages/views/chat/chat-page.tsx) with URL-addressable
  session selection; web + desktop routing wired up. Removes the old
  chat-fab / chat-window / resize-handles / context-items paths.
- IM thread list: agent avatar + last-message preview + IM timestamp, red
  unread *count* badge (read-cursor model), presence-gated typing vs waiting.
  Rename lives only in the conversation header ⋯ menu (not the list hover).
- Per-session conversation header (rename / view agent / delete), agent-aware
  empty state (avatar + name + description + starter prompts), and a
  deterministic clean-title derivation from the first message.
- Server: read-cursor unread model (migration 145) and per-user pinned agents
  (migration 146, dedicated chat_pinned_agent table + handler/queries).
  New-agent welcome chat auto-enqueues a real agent run (LLM intro, no
  static template).
- Design: fade the global --border token; borderless list headers on
  Chat/Inbox, kept (faded) on the conversation header.

Verified: pnpm typecheck (all packages), go build ./..., go vet, gofmt.
Co-authored-by: multica-agent <github@multica.ai>

* feat(chat): make new-agent welcome read as an agent-initiated intro (MUL-4230)

The "meet your new agent" chat used to insert a fake user message
("👋 Hi! Please introduce yourself …") and have the agent reply to it, so
the thread looked like the creator prompting the agent.

Drop the persisted user message. Flag the auto-created session
is_agent_intro (migration 147) and drive the intro run server-side: the
daemon builds a proactive self-introduction prompt for such sessions
(buildChatPrompt) instead of a "reply to their message" prompt. The intro
stays LLM-generated; the thread now opens with the agent's own message, as
if it reached out first.

- migration 147: chat_session.is_agent_intro
- CreateChatSession carries the flag; sendAgentWelcomeChat no longer
  persists/publishes a user message
- daemon: ChatIntro threaded from session flag → intro prompt

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

* feat(chat): Settings toggle for the floating chat window (MUL-4235) (#5080)

* feat(chat): Settings toggle for the floating chat window (MUL-4235)

Re-introduce the floating chat overlay on top of Chat V2 as an optional,
Settings-gated surface instead of deleting it outright.

- Settings → Preferences → Chat: a switch (floatingChatEnabled, persisted
  client preference, default ON) to show/hide the floating window.
- FloatingChat wrapper owns the two gates: the preference, and the /chat
  route (hidden on the tab so the same activeSessionId isn't shown twice).
- ChatFab + a compact ChatWindow that reuse the shared useChatController and
  conversation components, so activeSessionId stays in lockstep with the tab.
- Restore use-chat-context-items so the overlay's @ surfaces the current
  issue/project (the 'current context' affordance) — the tab stays manual.
- i18n (en/zh-Hans/ja/ko), store unit tests.

typecheck: core/views/web/desktop green. tests: chat store 9, settings 82,
chat 39 pass.

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

* feat(chat): dedicated Chat settings tab, floating window opt-in (MUL-4235)

Address review: give Chat its own Settings tab instead of a section inside
Preferences, and default the floating window OFF (opt-in).

- New Settings → Chat tab (chat-tab.tsx) under My Account; moves the
  floating-window toggle out of the Preferences tab.
- floatingChatEnabled now defaults OFF — only an explicit enable from the
  Chat tab mounts the FAB/overlay.
- i18n: page.tabs.chat + a top-level chat block (en/zh-Hans/ja/ko);
  revert the Preferences chat section and its test mock; store tests updated
  for the opt-in default.

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

---------

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

* refactor(chat): drop starter prompts from chat empty state (MUL-4237) (#5081)

The three starter prompts (List my open tasks by priority / Summarize what
I did today / Plan what to work on next) read as filler more than help, so
remove them along with the now-unused returning_subtitle ("Try asking").

The empty state keeps its agent-aware header — avatar + "Chat with {name}"
+ optional description — and the composer stays the entry point. Locale
keys dropped across en/zh-Hans/ja/ko (parity preserved).

Based on the Chat V2 branch (parent MUL-4171, #5076), not main.

Co-authored-by: Lambda <lambda@multica.ai>

* feat(chat): pin a chat to the top of the Chat list (MUL-4240) (#5082)

Builds on Chat V2 (#5076). Adds a per-conversation pin so a user can
keep important chats at the top of the IM-style thread list, above the
activity-sorted rest.

Backend:
- migration 148: chat_session.pinned_at (nullable) + partial index; the
  timestamp doubles as the pinned-group sort key and the boolean flag.
- list queries order pinned-first, then by most-recent activity.
- SetChatSessionPinned query + PATCH /api/chat/sessions/{id}/pin handler;
  pinning never bumps updated_at, so an unpinned chat won't jump the list.
- ChatSessionResponse.pinned + chat:session_updated carries the new state.

Frontend:
- ChatSession.pinned; setChatSessionPinned API + useSetChatSessionPinned
  with optimistic re-sort; shared sortChatSessions comparator.
- thread list: pin indicator on pinned rows + pin/unpin hover action;
  list sorted pinned-first so it stays ordered after cache patches.
- realtime patch re-sorts on pin change; en/ja/ko/zh-Hans strings.

Tests: SetChatSessionPinned handler test, sortChatSessions unit tests.

* feat(chat): round send button, move file upload into a + menu (MUL-4250) (#5088)

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

* fix(inbox): match chat list selected-item style (inset padding + rounded) (#5093)

Wrap the inbox list in p-1 and give each row rounded-md/px-3 so the
selected bg-accent reads as an inset rounded card — same treatment the
chat thread list already uses — instead of a full-bleed, sharp-cornered
highlight. Content stays 16px-inset (p-1 + px-3 == old px-4).

MUL-4253

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

* fix(chat): rename + menu upload item to "Image or files" (MUL-4250) (#5092)

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

* fix(chat): stop welcome intro session repeating the same introduction (MUL-4259)

The is_agent_intro flag on chat_session is persistent, so every follow-up turn on a welcome session re-selected the self-introduction prompt in buildChatPrompt and the agent kept replying with the same intro instead of answering the user.

Gate resp.ChatIntro at claim time on the session still having zero human (role='user') messages via a new ChatSessionHasUserMessage query: the first, message-less server-driven turn introduces the agent; once the creator replies, later turns fall back to the normal reply prompt.

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

* fix(chat): address review findings + unbreak CI (MUL-4171)

- task:failed now refreshes the sessions list (invalidateSessionLists), so
  the thread-list preview / unread / sort stays correct after an agent
  failure — FailTask persists a failure chat_message but only broadcasts
  task:failed, mirroring the chat:done success path.
- Self-heal stale chat deep links: once the sessions list has loaded and a
  ?session= id isn't in it (deleted / no access / never existed) with nothing
  in flight, clear the selection instead of rendering an editable empty chat
  that would POST into a nonexistent session. Freshly-created sessions are
  exempt (they carry optimistic messages + a pending task).
- CI: add the new parameterless `chat` route to link-handler's
  WORKSPACE_ROUTE_SEGMENTS and to paths/consistency.test.ts (route set +
  expectedSegments) — keeps the two in sync, fixes the failing @multica/core
  test.
- Fix a MUL-4235/MUL-4237 merge collision that broke @multica/views
  typecheck: chat-window.tsx still passed the removed `onPickPrompt` prop to
  EmptyState.

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

* fix(chat): self-heal dangling session in shared controller, not just ChatPage (MUL-4171)

Re-review follow-up: the stale-session self-heal only lived in ChatPage, so
the floating ChatWindow still entered from a persisted activeSessionId and
would render an editable empty chat (then POST into a nonexistent session)
when the selected session was deleted / lost access off the /chat route.

- Move the self-heal into the shared useChatController so every surface (tab
  and floating window) drops a dangling activeSessionId once the sessions list
  has loaded and doesn't contain it.
- Harden ensureSession: trust the current id only when it's in the loaded list
  or is a just-created session still awaiting the refetch; a dangling id falls
  through to create a fresh session instead of POSTing into a 404.
- Exempt just-created sessions via an OPTIMISTIC-write signal
  (hasOptimisticInFlight: pending task or optimistic- message), not hasMessages
  — a session deleted elsewhere with real cached history stays eligible for
  self-heal. Add a unit test for the discriminator.

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

* test(views): fix app-sidebar useWorkspacePaths mock for the new chat nav (MUL-4171)

The AppSidebar personal nav gained a `chat` item, so it calls
`useWorkspacePaths().chat()` at render. The app-sidebar.test.tsx mock hadn't
been updated, so `p.chat` was undefined and every render threw
`TypeError: p[item.key] is not a function`, failing @multica/views#test in CI.

- Add `chat: () => "/acme/chat"` to the mocked useWorkspacePaths.
- Route the chat-sessions query key through a mutable `chatSessions` fixture.
- Add coverage for the Chat nav: renders the link, badges the summed
  unread_count, and hides the badge when all sessions are read — so this drift
  is caught next time.

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

* fix(chat): use the original floating window, not the rewritten one (MUL-4235) (#5102)

Follow-up to the merged #5080, which shipped a hand-written, simplified
ChatWindow and lost the original's animations / drag-resize / expand-minimize.
The floating window is just a quick entry point — it should be the original
UI, not a rewrite.

- Restore chat-window.tsx, chat-fab.tsx, chat-resize-handles.tsx and
  use-chat-resize.ts verbatim from main (0-diff): motion animations, drag
  resize, expand/minimize and the session dropdown are back.
- Restore the empty_state.returning_subtitle + starter_prompts i18n keys the
  original window renders (V2 had dropped them); drop the now-unused
  window.open_full_tooltip key the rewrite added.
- Settings gating is unchanged: FloatingChat still wraps the original FAB +
  window, gated by floatingChatEnabled (default off) and hidden on /chat.

typecheck: core/views/web/desktop green. tests: chat + settings views 126 pass.

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

* feat(chat): archive chats from the list, delete only from Archived (MUL-4263) (#5098)

Restore an archive flow as the reversible sibling of delete:
- Chat list hover now offers Archive (not Delete); pin/stop unchanged.
- A footer entry ('Archived · N') opens an Archived view listing archived
  chats; hard delete lives only there (hover -> unarchive + delete, with
  the existing inline confirm).
- Conversation header ⋯ menu mirrors this: active chats archive, archived
  chats unarchive/delete.

Backend: PATCH /api/chat/sessions/{id}/archive flips status active<->archived
(SetChatSessionArchived), broadcasts status on chat:session_updated so other
tabs re-sort into the right list. SendChatMessage already refuses archived
sessions, so archived chats stay read-only until unarchived.

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

* feat(chat): handle archived agents in Chat V2 list & conversation (MUL-4265) (#5100)

* feat(chat): handle archived agents in Chat V2 list & conversation (MUL-4265)

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

* refactor(chat): drop chat-list archive marker, keep conversation read-only (MUL-4265)

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

* feat(chat): apply archived-agent read-only to the floating chat window (MUL-4265)

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

---------

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

* fix(chat): address floating-window + archived-agent review blockers (MUL-4171)

Re-review follow-up on the restored floating ChatWindow + archive flow:

1. Floating stale-session self-heal. The restored ChatWindow doesn't use the
   shared controller, so its ensureSession trusted any non-empty
   activeSessionId and there was no dangling-session cleanup — a deleted /
   no-access persisted session could send into a nonexistent session. Ported
   the same guard used for the tab: a self-heal effect that clears a dangling
   activeSessionId once the sessions list has loaded, and ensureSession only
   trusts an id that's in the list or has an in-flight optimistic write
   (hasOptimisticInFlight, reused from use-chat-controller). handleSend seeds
   the optimistic message + pending task before setActiveSession, so a
   freshly-created session is never mis-cleared.

2. Floating dropdown bypassed archive-first safety. Its active rows offered a
   hard-delete, letting the floating window destroy active chats and skip the
   "archive first, delete only from Archived" model. Active rows now ARCHIVE
   (reversible, one-click) like ChatThreadList; the floating window offers no
   hard-delete — unarchive/delete live only in the full Chat page's Archived
   view (reachable via expand). Removed the now-dead delete-confirm machinery.

3. Orphan user message on archived-agent send. SendChatMessage created the
   chat_message before EnqueueChatTask, which rejects an archived / runtime-less
   agent — a stale client would land a user message then get a 500, orphaning
   it. Added a preflight that checks the session agent's archived / runtime
   state and returns 409 before any mutation, plus a handler test asserting the
   send is rejected with no message persisted.

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

---------

Co-authored-by: Lambda <lambda@multica.ai>
Co-authored-by: multica-agent <github@multica.ai>
2026-07-08 21:58:16 +08:00
Multica Eve
c05b67ae4a docs(changelog): add v0.3.41 release notes (en/zh/ja/ko) (#5094)
Co-authored-by: multica-agent <github@multica.ai>
2026-07-08 17:37:33 +08:00