Commit Graph

420 Commits

Author SHA1 Message Date
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
Jiayuan Zhang
257e5e4363 fix(desktop): make dev diagnostics best effort (MUL-5148) (#5794)
* fix(desktop): make dev diagnostics best effort

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

* test(desktop): cover the destroyed dev-log sink guard

---------

Co-authored-by: Lambda <lambda@multica.ai>
Co-authored-by: multica-agent <github@multica.ai>
2026-07-23 01:51:45 +08:00
Jiayuan Zhang
e8746c5030 fix(onboarding): eliminate Desktop runtime-step false-negative flash + parallelize daemon version detection (MUL-5119) (#5756)
* perf(daemon): parallelize runtime version detection during registration (MUL-5119)

Registration probed each agent CLI's `--version` serially, so total latency
was the sum of every probe. On an onboarding host with several coding tools
installed that stacked into many seconds before runtimes registered — long
enough that the desktop runtime step timed out into its empty 'no runtime
found' state while the daemon was still working.

Fan the probes out with a bounded errgroup so total latency tracks the slowest
single probe instead of their sum. Each probe still self-heals a vanished
pinned path and re-detects the live version (no cross-registration caching, so
an in-place upgrade is still reported correctly); failures are logged and
skipped as before. Results are sorted by provider for a deterministic payload.

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

* fix(onboarding): stop the runtime step flashing 'no runtime found' while the daemon probes (MUL-5119)

The runtime step flipped from scanning to the empty 'no runtime found' state on
a fixed 5s wall-clock, so a machine that does have coding tools installed saw a
false-negative flash whenever registration outlasted the timeout (cold start,
slow/wedged CLI, many CLIs).

Gate the empty flip on a desktop-only `runtimesPending` signal derived from the
local daemon's live status (booting, or running with agent CLIs detected on the
host): while pending, keep the scanning skeleton past the soft timeout. An
absolute hard-timeout ceiling still guarantees a fallback so a wedged probe
can't pin the step on the skeleton forever. Web omits the signal and keeps the
plain wall-clock timeout.

Also drop the two dead/duplicated affordances on the step: the permanently
disabled 'Start exploring' button now renders only in the found phase, and the
empty state's duplicate footer 'Skip for now' is removed in favour of its own
Skip card.

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-23 01:29:47 +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
Naiyuan Qing
2f111037d2 feat(desktop): tab presentation by object identity (MUL-4370) (#5661)
* fix(nav): derive route icons from the URL across all nav surfaces (MUL-4370)

The same route rendered different icons in the sidebar and the desktop tab
bar because the mapping was maintained in three places. Projects had no tab
icon at all; autopilots/chat/squads/usage fell back to ListTodo.

Establish one contract instead: `@multica/core/paths` maps a route segment to
a stable icon *name* (React-free), and `@multica/views/layout` maps that name
to a Lucide component. Every nav surface — sidebar, desktop tab bar, and the
search palette — resolves through `routeIconForPath(path)`, so a route cannot
render two different icons.

Crucially the icon is now derived, not stored. `TabSession.icon` is removed,
so persisted tab state can no longer hold a stale icon name: a user who had
an /autopilots tab from an older build gets the correct icon after upgrade
rather than the one that was persisted. Legacy `icon` values in v4 payloads
are ignored on rehydration and dropped on the next write.

Builds on the design in #5204 by LiangliangSui.

Tests: stale/unknown/absent persisted icon on rehydration, derived icon
rendering per route in the tab bar, name→component registry totality, and
nav-route icon coverage.

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

* feat(desktop): tab presentation by object identity, not route segment (MUL-4370)

Replace the "route segment → icon" tab mapping with a semantic Tab
Presentation Contract: a tab's leading visual and title are derived live
from its URL + the query cache, so a tab shows *what it points at*, not the
module it lives under.

- core `parseTabSubject(url)` classifies a URL as page / resource / actor /
  container (inbox, chat) / flow / unknown, purely (no React, no Lucide).
- core `resolveTabPresentation(subject, data)` maps that + cached entity data
  to a leading visual (issue StatusIcon, ProjectIcon, ActorAvatar, or a type
  icon) and a title spec. Exhaustive: a new route forces an explicit choice.
- views `useTabPresentation` reads the cache (enabled:false, no fetch from the
  tab bar) and `ResourceLeadingVisual` renders it in a fixed 16×16 slot.
- Containers keep their icon; only the title tracks the selection
  (`/inbox?issue=`, `/chat?session=`), so an inbox-opened issue reads
  differently from a direct issue tab.
- Titles are plain text; the project 📁 and autopilot  glyphs are dropped
  from document.title.
- Pin no longer replaces the resource visual. Persisted `tab.icon` cleanup
  from the prior revision is kept; the active tab persists its resolved title
  as a first-frame fallback (the document.title→observer path is removed).

Supersedes the route-segment approach in #5204 per the agreed PRD. No schema,
API, or migration changes; reuses existing queries/caches.

Tests: table-driven parseTabSubject over every desktop route; the
URL/data → visual+title matrix incl. pending/loading, containers, unknown,
runtime custom-name; views cache-integration; tab-bar pin + active-tab
title persistence.

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

* fix(desktop): archived inbox title sync + attachment filename in tab (MUL-4370)

Addresses two PRD gaps from review:

1. Archived Inbox selection now syncs the tab title. `parseTabSubject` captures
   `?view=archived` on the inbox subject, and the presentation hook resolves the
   selection against `archivedInboxListOptions` (its own cache, the one the
   InboxPage populates) instead of only the active list. An
   `/inbox?view=archived&issue=<id>` tab now shows the archived item's title —
   issue (`identifier: title`) or non-issue (display title) — and, being purely
   URL+cache derived, restores correctly on refresh. Previously it fell back to
   "Inbox" and persisted that wrong title.

2. Attachment tabs use the filename. `parseTabSubject` captures the `?name=`
   the preview route already carries; the resolver shows the filename as the
   title and picks a file-type icon from its extension (image/video/audio/
   archive/code/text), falling back to the generic File glyph + "Attachment"
   label only when the name is missing.

Tests: parseTabSubject archived/name cases; the presentation matrix for
attachment filename/extension + missing fallback and iconForAttachment edge
cases; views cache-integration for archived issue/non-issue selection (must
resolve against the archived list, not the active one) and attachment filename.

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

---------

Co-authored-by: multica-agent <github@multica.ai>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-22 09:36:53 +08:00
Naiyuan Qing
181e21f2d0 fix(desktop): commit staging env with explicit VITE_APP_URL (MUL-5025) (#5680)
* fix(desktop): commit staging env with explicit VITE_APP_URL (MUL-5025)

Staging desktop dev relied on a hand-maintained local .env.staging, where
the web-origin var was misnamed VITE_WEB_URL. Desktop only reads
VITE_APP_URL, so appUrl silently fell back to the API-origin derivation
and copy-link URLs pointed at multica-api.copilothub.ai (404).

Track apps/desktop/.env.staging with the correct names (mirroring the
mobile precedent), and fill in mobile's EXPO_PUBLIC_WEB_URL now that the
staging web host is committed rather than living on a teammate's machine.

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

* chore(desktop): mark staging envs internal, clarify VITE_APP_URL semantics (MUL-5025)

Per review: state in both tracked .env.staging files that the staging
environment is internal (not a public support target), and spell out that
VITE_APP_URL is the web app origin for copy-link/open-in-browser URLs,
never the API host.

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

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>
2026-07-21 14:08:23 +08:00
Kyou
964b9269de fix(desktop): restore window state
Remember the main window size, position, and maximized or fullscreen state between launches. Keep the window visible when display settings change.
2026-07-20 15:04:55 +08:00
Multica Eve
612b2db3b9 fix(desktop): exclude dist/** from multi-arch package contents
Merging after independent reviews confirmed the fix and all CI checks passed.
2026-07-20 12:58:14 +08:00
Naiyuan Qing
1507997272 fix(agent): stop agents shipping local-path links, make Desktop 404 recoverable (MUL-4899) (#5557)
Agents were writing runtime-local paths into deliverables as clickable
links (`[screenshot](/Users/agent/work/shot.png)`). Two root causes, both
fixed here.

A. The brief never stated the delivery contract. Add an always-on delivery
invariant (outside writeOutput's kind switch, so no task kind can inherit
none) plus a per-surface file-delivery line for each of the five surfaces.
Chat splits into two: `attachment upload` works only on web/mobile chat,
never on an IM channel, so ChatChannelType is now threaded into
TaskContextForEnv.

The claim path only ever looked up Slack bindings, so a Feishu session
reported as a web chat and got upload guidance for a channel that cannot
carry attachments. Probe every channel type. The chat policy is two
independent layers and stays that way: delivery keys off "is there a
channel at all"; the `chat history` / `chat thread` commands stay
Slack-only because both endpoints are hardwired to h.SlackHistory and
there is no Feishu reader — ChatInThread only selects between those two
commands, so it stays Slack-only too.

Add a CLI hard-fail lint on `issue comment add` / `issue create` /
`issue update` as the enforcement backstop. Scoped narrowly, since a false
positive blocks a real deliverable: agent task context only (a human's PAT
run is untouched), real CommonMark link/image/autolink destinations only
via goldmark (a path in a code span or fence — how an agent quotes a path
it is discussing — is structurally invisible), and three high-confidence
signals only (`file://`, inside the workdir, or an existing local file).
A bare `/foo` is a valid origin-relative URI and is deliberately allowed.
`issue update` has no --attachment flag, so its hint redirects to
`comment add` rather than naming an argument it rejects.

B. Desktop presented the resulting router 404 as an unknown crash. 8 of 18
desktop_route_error reports were users clicking such a link and being told
the app broke and to file a bug. Split the 404 into a first-class Not Found
view: no crash framing, no Report error. Its recovery entry comes from the
tab store's active workspace, never from the failed pathname — deriving a
slug from `/Users/me/shot.png` yields "Users" and a button to `/Users/issues`,
a second 404.

Also add a will-navigate trusted-origin guard via the shared loadRenderer
(main + issue windows). This is origin hardening only, NOT the mechanism for
in-app links: client-side routing never fires will-navigate, so app paths
never reach it. Issue windows need no 404 work — their router only accepts
paths validated by parseIssueWindowPath and they do not listen for
multica:navigate, so a bad path cannot reach them.

Server-side completion observation is metric/log only and never blocks: it
is lexical (`file://` + task work_dir prefix) because the server cannot stat
the daemon's filesystem, and the metric label is a closed enum so no path or
reply text reaches Prometheus.

Verified: pnpm typecheck/lint/test (3582 tests), go vet, full Go suite
including new claim-path integration tests. cmd/multica was verified outside
the daemon workdir — inside one, 93 of its tests fail identically on
origin/main because the suite walks up and finds the runtime's own task marker.

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>
2026-07-17 13:40:23 +08:00
Naiyuan Qing
849df8bb3b fix(desktop): fade tab separators on hover, correct their weight (MUL-4811) (#5500)
The tab strip's hairlines used --border, which is authored for near-white
surfaces: its other uses sit on --surface-raised (L=1.0) where it gives a
reasonable dL 0.055, but on --app-shell (L=0.964) it collapses to dL 0.019 --
about a quarter of the 0.068-0.080 the rest of the chrome's 1px keylines run
at, so it read as almost invisible. Move both the tab separator and the
pinned-zone divider to --surface-border, which the surrounding chrome (the
active tab's border, the flare arcs, the content card's ring) already uses,
and which is what the system draws for separators on --sidebar -- a surface
whose lightness is identical to --app-shell.

Also fade a hairline out while either tab it divides is hovered, so it no
longer lingers 2px off the hover pill's rounded edge. The hairline sits on a
tab's own left edge, so the pair's other half belongs to the neighbouring
component instance and no ancestor-based variant can reach it; hence the
adjacent-sibling custom variant.

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>
2026-07-16 14:49:34 +08:00
Xzero
650f933367 fix(desktop): rename Linux executable to multica-desktop (#5483)
The Electron desktop app's Linux packages (deb/rpm/AppImage) installed
their launcher binary as `multica`, colliding with the Go CLI binary
of the same name. Whichever won PATH resolution silently shadowed the
other; hitting the Electron binary from a CLI invocation exits 0 with
empty stdout (its own Chromium flags eat args like `--output json`),
which reads as a healthy but empty CLI response instead of "wrong
binary".

Rename the packaged executable to `multica-desktop` so `multica` on
PATH is unambiguously the Go CLI. StartupWMClass is unaffected since
it derives from app.getName(), not executableName.

Fixes #5481

Co-authored-by: Product Engineering Specialist <support@weekome.com>
Co-authored-by: multica-agent <github@multica.ai>
2026-07-16 12:15:41 +08:00
Jiayuan Zhang
f8654b37c0 MUL-4817: open issue tabs in dedicated windows (#5462)
* feat(desktop): open issue tabs in dedicated windows

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

* fix(desktop): coordinate dedicated window lifecycle

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-16 03:26:30 +08:00
Multica Eve
3cde13768b test(desktop): de-flake UpdatesSettingsTab preference-load test (#5460)
Co-authored-by: Eve <eve@multica-ai.local>
Co-authored-by: multica-agent <github@multica.ai>
2026-07-15 17:54:38 +08:00
Naiyuan Qing
ea03912baf perf(desktop,issues): single-router tab sessions (MUL-4741 Phase 2) + trace-driven surface mount/render overhaul (MUL-4474/4750 reland) (#5403)
* Reapply "perf(issues): virtualize inbox/list/board/swimlane (MUL-4474, 方案2) (#…" (#5395)

This reverts commit c10bfa8f56.

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

* fix(issues): seed virtualized lists so route-return doesn't flash blank (MUL-4750)

The relanded MUL-4474 virtualization flashed an empty card area (group /
column headers present, rows blank) when returning to /issues or crossing
inbox<->issues. Two stacked blank windows caused it:

1. The scroll element reaches Virtuoso via a callback ref that lands in
   state, so the first render after a remount has customScrollParent === null
   and the code rendered nothing.
2. Even once mounted, Virtuoso renders 0 rows until its post-paint
   ResizeObserver measures the viewport.

Fix both, four surfaces (list / board / swimlane / inbox):

- New shared <VirtuosoSeed> renders a bounded slice of the real rows while the
  scroll parent is still null, reusing each caller's own itemContent /
  computeItemKey so a seeded row is identical to its virtualized counterpart.
- Pass initialItemCount={Math.min(len, SEED)} so the measurement frame keeps
  those rows instead of collapsing to empty.

SEED is capped at 30 and floored by Math.min, so small workspaces
(hasMore=false) and short columns never over-mount — the path that crashed on
real Desktop before. restoreStateFrom (tab-switch Activity restore) is
intentionally out of scope for this round.

Verified: @multica/views tsc --noEmit, eslint, and full vitest (1936 tests)
pass. Real-Desktop route-return / DnD / keyboard / scroll-position regression
pass still owed on device.

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

* perf(issues): defer per-card popup mounting, share one context menu per surface

Tab-switching to a board froze the main thread for seconds: every card
eagerly mounted ~6 popup roots (context menu, pickers, hover cards) plus
per-card query subscriptions, multiplied by seed x columns x remount.

- DeferredPopup: pickers render a pixel-identical static trigger and mount
  the real popover on first pointerenter/keydown (Base UI opens on click,
  so the warm mount always wins the race)
- AssigneePicker/PriorityPicker/DateOnlyPicker defer when uncontrolled;
  AssigneePicker's members/agents/squads/frequency subscriptions now only
  start on interaction
- IssueActionsContextMenu: one controlled ContextMenu per surface anchored
  at the cursor via a virtual anchor; items delegate (issue, position) up.
  Known debt: iOS Safari long-press no longer opens it
- ActorAvatar hover cards warm-mount on pointerenter with a manual
  first-dwell timer matching Base UI's OPEN_DELAY

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

* fix(issues): stabilize board column scrollbar across mounts

Column scrollbars redrew visibly on every surface mount (route switches,
first open): the seed frame's scroll height covered only the seeded cards,
then Virtuoso spaced out the full count.

- VirtuosoSeed: optional estimatedItemHeight renders a trailing spacer so
  the seed frame's scroll height already approximates the full list
- Board columns: seed capped at 10 (one column viewport of ~110px cards,
  not the 36px-row-sized generic 30) and the same estimate feeds Virtuoso's
  defaultItemHeight so both phases agree until real measurements land
- Columns at <=30 cards skip virtualization entirely and render plainly
  (same itemContent), making their scroll height browser-measured truth in
  every scenario -- the per-column split Linear ships
  (data-virtual-cluster=false for small clusters)

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

* perf(editor): reduce issue detail mount cost

Parse long Markdown in smaller chunks without the duplicate initial sync, defer title and empty composer editors until intent, and keep the description editor eager to avoid layout shifts.

* chore(desktop): add navigation boundary lint rule (MUL-4741 Phase 2 prereq)

The tab Coordinator protocol requires that application code never
navigates directly (invariant 1: a Router location change without a
Coordinator token is a protocol error). Enforce it statically:

- renderer app code may not import useNavigate/Navigate from
  react-router-dom nor call router.navigate; src/platform is exempt
- the five known legacy sites (the RFC §8.1 migration checklist,
  cross-validated: the rule fires on exactly those and nothing else)
  carry inline eslint-disable directives tagged MUL-4741 — the Phase 2
  migration removes them one by one, and this rule holding with zero
  disables is the machine check that the migration is complete

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

* fix(views): open deferred pickers on click, align triggerRender types

Pointerenter warm-mounting swapped the trigger element mid-gesture: real
browsers re-hit-test so the click lands on the new trigger, but synthetic
pointer sequences (tests, assistive tech) keep dispatching on the detached
node and the first click dies. Upgrade now happens on click/Enter/Space
only — the same timing as Base UI's own trigger — with the in-flight click
stopped so the popup's just-mounted outside-press dismissal doesn't close
it in the same breath.

Also widen triggerRender to ReactElement<Record<string, unknown>> (React
19 defaults ReactElement props to unknown) and mount the
IssueContextMenuProvider in the swimlane test harness like IssueSurface
does in production.

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

* refactor(desktop): single-router tab sessions with Coordinator protocol (MUL-4741 Phase 2)

Replace the per-tab-router + <Activity> keep-alive model with the approved
single-router session architecture:

- TabSession: tabs are pure serializable state (url, resourceKey, virtual
  history stack, scroll memento). Persist v4 does the one-time legacy
  view-state import from v3; mountGeneration is deliberately unpersisted.
- Coordinator (platform/tab-coordinator.ts) is the only router writer: it
  reconciles THE app router to the active session URL with navigation
  tokens; a location change without a token is a protocol error handled by
  bounded recovery (invariant 1). The router history is never used — every
  reconcile is a replace; back/forward are session-stack operations.
- ActiveTabHost mounts exactly one tab, keyed on tabId:mountGeneration.
  reload() = generation bump + active-scope query invalidation (never
  router.revalidate, never a global cache invalidation). Warm switches
  restore scroll pre-paint; cold restores pre-size containers from the
  memento's saved scrollHeight and settle when data lands.
- resourceKey dedup (pathname only) replaces exact-path dedup: opening
  /slug/issues?filter=b focuses the existing issues tab (RFC §8.2,
  deliberate semantic change).
- All five §8.1 legacy navigation sites migrated (index <Navigate>, error
  page recovery, workspace-layout login bounce, overlay parking, shell
  back/forward); their MUL-4741 ratchet eslint-disables are removed, so the
  navigation boundary rule now holds with zero exemptions outside
  src/platform.

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

* feat(issues): register board columns and list scroller for scroll mementos

Per-container scroll registration for the MUL-4741 tab session memento:
board columns key by group id (each column's offset restores
independently, the per-column split Linear ships), the list view keys as
"list". Chat and issue-detail already carry the marker.

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

* feat(tabs): pull-based scroll restoration fed into virtualized lists (MUL-4741)

Rebuild the restore side of the memento protocol on first principles (the
model Linear ships): a saved offset is an INPUT to the mounting view, not a
post-hoc DOM mutation from outside.

- ScrollRestorationProvider (views/platform): views pull their saved offset
  while mounting. Virtualized lists feed it into Virtuoso's initialScrollTop
  so the first render already materializes the rows around it — this
  replaces the pushed spacer+scrollTop hook, whose foreign spacer deadlocked
  against Virtuoso's own height model (restore landed the viewport in
  phantom space, Virtuoso rendered nothing, and the spacer's removal
  condition could never be met → blank issue detail). Plain containers
  assign the offset at ref-attach, pre-paint. Web has no provider and
  behaves as before.
- Memento keys gain a route dimension (`${pathname}::${containerKey}`) and
  capture now also fires before in-tab navigation, so back/forward restores
  each route's own offsets and same-named containers on different routes
  no longer collide.
- commitScrollMemento uses REPLACE-per-route semantics: a container
  scrolled back to 0 clears its stale offset instead of resurrecting the
  old position on the next visit.
- List view gets the same estimate alignment as the board (36px rows into
  the seed spacer and defaultItemHeight), which keeps the shared scroller's
  height truthful from the first frame so the restored offset sticks.

Known gap: chat's bottom-anchored list captures offsets but has no restore
consumer — intentional, it re-anchors to bottom on mount.

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

* perf(editor): make unlabelled code fences plaintext instead of auto-detected

Follow-up to the issue-detail mount work: lowlight's highlightAuto runs
every registered grammar over the full block for code fences without a
language, which dominated mount cost on code-heavy comments. Extract a
shared syntax-highlight module whose auto fallback deterministically
renders plaintext; explicitly labelled languages highlight as before.
Also ignore .gstack/.

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

* perf(issues): lazy-mount column-header popups, right-size swimlane seed

Trace analysis of tab/view switching (30s session): a swimlane mount spent
its largest slice on eagerly-mounted header machinery — ~170 tooltip roots
(one per lane x status cell add-button), 13 column dropdown-menus, and up
to 30 fully-materialized lanes from the generic seed count.

- DeferredTooltip (views/common): renders only the trigger until first
  hover, then mounts a controlled Tooltip anchored to the SAME element
  (no trigger swap, so mid-gesture events never land on a detached node);
  ui TooltipContent grows an `anchor` passthrough for it.
- Board/list/swimlane header add-buttons and hide-column dropdowns now
  defer via DeferredTooltip / DeferredPopup (which gains an ariaHasPopup
  option for menu triggers).
- Swimlane lane seed drops 30 -> 6 (a lane row is ~300px+; a viewport fits
  ~3) on both the pre-scroll seed and Virtuoso's initialItemCount.
- openTab gains an `activate` option so "open and focus" paths (pinned-tab
  redirect, explicit open-in-new-tab) are ONE store write instead of
  openTab + setActiveTab back-to-back — one full subscriber pass per user
  action instead of two.
- Dev-only breadcrumb logs IssueSurfaceContent's remount key: the trace
  showed the surface mounting twice inside one task, and the my-issues
  relation toggle is one confirmed key-flip source; the log ties the next
  trace's mounts to exact key transitions.

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

* perf(issues): stop double full-tree render passes on surface interactions

Trace forensics on view switching showed every interaction paying TWO full
surface render passes (React's own Cascading Update marker sits between
them): entering swimlane flips controller-level loading state (loadProjects
enables the projects query), and any such flag flip re-rendered the entire
unmemoized view tree (~600-1000ms dev per pass).

- Memoize BoardView / ListView / SwimLaneView: controller/data outputs are
  already useMemo/useCallback-stable, so a controller flag flip now
  re-renders the header, not the whole board. The one unstable prop —
  BoardView's inline assigneeGroups.flatMap — moves into a useMemo.
- Selection reset on mount swapped the initial empty Set for a NEW empty
  Set, buying a guaranteed extra full pass per surface mount; functional
  bail keeps the reference when nothing was selected.

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

* perf(ui): drive sidebar resize by direct DOM writes, drop motion/react

Trace analysis showed sidebar motion.div mounts costing ~1s across a
session. Width previews during drag now write straight to the two layout
shells (CSS disables their transitions while data-sidebar-resizing is set);
React only sees the single committed width on pointer-up, and framer-motion
leaves the sidebar entirely.

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

* fix(issues): wire swimlane's outer scroller into tab scroll restoration

Review blocker on #5403: board/list/issue-detail register their scroll
containers with the tab session memento protocol, but the swimlane outer
scroller did not — under the single-router architecture an inactive tab
unmounts, so a deep-scrolled swimlane returned at top after a tab switch
or reload.

Same wiring as the other surfaces: data-tab-scroll-root="swimlane" for
capture, useRestoredScrollRef in the scroller's attach callback for the
pre-paint assignment, and the saved offset into the lane Virtuoso's
initialScrollTop. Regression test asserts both the capture marker and the
restored offset.

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

---------

Co-authored-by: multica-agent <github@multica.ai>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-15 16:30:51 +08:00
Multica Eve
5b26b99722 MUL-4164: support Intel macOS Desktop packages (#5436)
* feat(desktop): support Intel macOS packages

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

* fix(desktop): scope Intel macOS rollout

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-15 12:58:47 +08:00
Jiayuan Zhang
7468ce06be fix(desktop): match canvas left margin to right when sidebar is collapsed (#5430)
The shell canvas kept its static 2px left margin after the left sidebar
collapsed offcanvas, while the right edge uses an 8px margin. Animate the
left margin to 8px (same spring as the rest of the shell) whenever the
sidebar leaves the main flow, so the floating canvas sits symmetrically.

Fixes MUL-4780

Co-authored-by: Lambda <lambda@multica.ai>
Co-authored-by: multica-agent <github@multica.ai>
2026-07-15 11:51:41 +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
Naiyuan Qing
de98b7cb83 test(desktop): stabilize updater preference test against slow-disk race (#5392)
The "skips startup and periodic checks when automatic updates are
disabled" case advanced fake timers without awaiting the async
preference load. On slow CI the in-flight readFile resolved after
afterEach() removed the temp dir, defaulted enabled back to true, and
fired a deferred background check into the next test's freshly-cleared
shared mock — making "persists the automatic update preference and stops
future background checks" flake with checkForUpdates called once.

Await updater:get-preferences (which awaits preferencesReady) before
advancing timers so the read settles against the existing file and no
background work outlives the test. Test-only change; production behavior
is unaffected.

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>
2026-07-14 18:08:43 +08:00
Jiayuan Zhang
2d13b26fcc feat(desktop): add automatic update preference (#5380) 2026-07-14 15:45:39 +08:00
Jiayuan Zhang
8e1bf6cc51 feat(desktop): merge active tab into content surface, Chrome-style (#5314)
The active tab now shares the content card's fill and keyline: rounded
top corners, concave bottom flares (radial-gradient corner pieces whose
1px arc hands the tab border over to the card's top ring), and a
borderless base that runs into the card so the two read as one surface.
Inactive tabs sit flat on the shell with an inset hover pill and
hairline separators that hide around the active tab, Chrome-style.

Closes MUL-4439

Co-authored-by: Lambda <lambda@multica.ai>
Co-authored-by: multica-agent <github@multica.ai>
2026-07-13 13:23:43 +08:00
Jiayuan Zhang
2a48ffa2aa fix(runtimes): simplify local machine list row (#5298) 2026-07-12 15:51:34 +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
b64ebd60b5 feat: add customizable keyboard shortcuts (#5294) 2026-07-12 14:50:36 +08:00
Jiayuan Zhang
12e3c393d7 Add auto-save confirmation toasts (#5261) 2026-07-11 17:26:45 +08:00
Jiayuan Zhang
d51a3cbbbd Unify settings layout and auto-save (#5257) 2026-07-11 15:28:56 +08:00
Jiayuan Zhang
4efcfb96e3 feat(ui): establish surface system (#5248) 2026-07-11 13:43:44 +08:00
Naiyuan Qing
595f785ac9 fix(desktop): make overflowing tab additions visible (#5215)
* fix(desktop): animate overflowing tab additions

* fix(desktop): recalculate tabs after pin layout changes

* docs: document daemon log rotation settings

Co-Authored-By: OpenAI Codex <noreply@openai.com>

* Revert "docs: document daemon log rotation settings"

This reverts commit a6cbaa99ec.

---------

Co-authored-by: OpenAI Codex <noreply@openai.com>
2026-07-10 17:16:35 +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
Bohan Jiang
3790ca78e7 fix(desktop): run git describe without a shell so version derivation works on Windows (#5097)
#5057 restricted version derivation to `git describe --tags --match 'v[0-9]*'`,
but the command was passed to `execSync` as a shell string. On Windows the
shell is cmd.exe, which does not strip the POSIX single quotes around
'v[0-9]*', so git received the quotes literally, matched no tag, fell through
to `--always`, and the version degraded to the `0.0.0-g<hash>` fallback.

That is what shipped a `0.0.0-gc05b67ae4` Windows Desktop build (electron-builder
`--publish always` then auto-created a bogus release) during the v0.3.41 release,
even though the tag was sitting exactly on HEAD. Linux/macOS were unaffected
because /bin/sh strips the quotes.

Fix: invoke git with an argv array via execFileSync in every version-derivation
path, so the match pattern reaches git as one literal argument regardless of
platform:

- apps/desktop/scripts/package.mjs      (Desktop version → electron-builder)
- apps/desktop/scripts/bundle-cli.mjs   (bundled CLI ldflags version)
- apps/desktop/src/main/app-version.ts  (dev-mode version fallback)

The Makefile is intentionally left as-is: make's `$(shell ...)` always runs via
/bin/sh (even on Windows) and the CLI release runs on Linux, so its single
quotes are stripped correctly.

Tests: export `deriveVersion` and `DESCRIBE_ARGS` and add coverage that runs the
real `git describe` against throwaway repos (clean semver tag, semver tag chosen
over a nearer non-semver tag, and the no-tag fallback), plus a structural check
that the match pattern is a bare argv token with no embedded quotes. The prior
suite only unit-tested the `normalizeGitVersion` string transform, which is why
this slipped through.

MUL-4256

Co-authored-by: J <j@multica.ai>
Co-authored-by: multica-agent <github@multica.ai>
2026-07-08 18:49:46 +08:00
YYClaw
c8cfd0a214 fix(desktop): restrict git describe to semver tags for version derivation (#5057)
Non-semver tags (e.g. release-train tags) could become the nearest match
for `git describe --tags`, producing a version string that is not a valid
semver prefix. Restrict describe to `v[0-9]*` tags across the CLI ldflags,
desktop bundling, and app-version paths so the resolved version always has
a `major.minor.patch` shape.
2026-07-08 16:04:54 +08:00
Bohan Jiang
3cb5dc3ad6 chore(analytics): retire redundant PostHog tracking (MUL-4127) (#4996)
* chore(analytics): retire redundant PostHog tracking (MUL-4127)

PostHog had become a chaotic, largely-unused second copy of data we already
query from the DB and Grafana. Remove the redundant instrumentation.

Server: every product event (signup, workspace_created, issue_created,
issue_executed, chat_message_sent, team_invite_*, onboarding_*, agent_created,
cloud_waitlist_joined, feedback_submitted, contact_sales_submitted,
squad_created, autopilot_created) is now in metricsOnlyEvents, so
metrics.RecordEvent still increments the Prometheus/Grafana counter but no longer
ships to PostHog. DB rows remain the source of truth. Runtime/autopilot/
agent_task lifecycle were already Prometheus-only.

Frontend: delete the PostHog-only funnel instrumentation — $pageview (+ web and
desktop trackers), download_intent_expressed/page_viewed/initiated, the
onboarding_started mirror, onboarding_runtime_path_selected/detected,
feedback_opened, and source_backfill_*. The source-backfill modal itself stays
(it PATCHes the questionnaire to the DB).

Kept on PostHog (frontend only): $exception autocapture and the
client_crash / client_unresponsive stability telemetry (no DB equivalent), plus
$identify/$set. captureSignupSource (attribution cookie) stays — it still feeds
the signup_source Prometheus label.

Verified: pnpm typecheck, pnpm lint (0 errors), vitest (core/views/web/desktop),
go test ./internal/analytics/... ./internal/metrics/...

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

* docs(analytics): fix stale PostHog references after MUL-4127 (review follow-up)

Addresses review of #4996 — three spots still described server events as active
PostHog signals after they became metrics-only:

- docs/analytics.md: issue_executed is no longer a PostHog success signal; it is
  Prometheus-only (multica_issue_executed_total) + issue.first_executed_at, in
  both the event contract and the Reconciliation section.
- docs/analytics.md: the signup $set_once person properties (email, signup_source)
  are no longer emitted — signup is Prometheus-only; only the bucketed
  signup_source survives as the multica_signup_total label.
- server/internal/metrics/business_events.go: RecordEvent doc comment no longer
  claims it ships product events to PostHog / "PostHog is reserved for
  user/product-behaviour events" — every server event is now metrics-only.

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

---------

Co-authored-by: J <j@multica.ai>
Co-authored-by: multica-agent <github@multica.ai>
2026-07-07 01:43:15 +08:00
YYClaw
12d901638e fix(desktop): resolve electron-vite bin via PATH in dev script (#4992)
Under the hoisted linker (node-linker=hoisted) electron-vite's bin only
lands in the repo-root node_modules/.bin, so the hardcoded
apps/desktop/node_modules/.bin path fails. Use envWithLocalBins to put
both .bin directories on PATH and invoke electron-vite by name.
2026-07-06 19:01:53 +08:00
Niklas
84a5853363 fix(desktop): restore correct filename in save dialog for attachment downloads (#4296)
Adds an Electron `will-download` handler that forwards the filename Electron parsed from the server's `Content-Disposition` header (`item.getFilename()`) into the native save dialog, so desktop attachment downloads no longer default to `download.txt`.

Registration is guarded with a module-level `WeakSet<Electron.Session>` so the handler is installed at most once per session, even when macOS re-invokes `createWindow()` via `app.on("activate")`.

Fixes #4153
2026-07-02 18:40:48 +08:00
Ryan Yu
03828015ca fix(feedback): validate response and pass error kind (#4633)
Wire structured feedback kind through the frontend/core feedback path so desktop route-renderer errors submit as bug feedback, and bring the feedback client onto parseWithFallback. MUL-3768
2026-07-02 16:26:42 +08:00
Jiayuan Zhang
6dcf82a58a feat(desktop): isolate pnpm dev:desktop per worktree (MUL-3724) (#4598)
* feat(desktop): isolate pnpm dev:desktop per worktree (MUL-3724)

Two worktrees could not run pnpm dev:desktop at once: both grabbed the
renderer port 5173 and the single-instance lock keyed by the app name
"Multica Canary". The env hooks to override each already existed
(DESKTOP_RENDERER_PORT in electron.vite.config.ts, DESKTOP_APP_SUFFIX in
src/main/index.ts) but nothing derived per-worktree values.

A new dev launcher (scripts/dev.mjs) derives both from the worktree path
for linked worktrees only — reusing the same cksum%1000 offset as
scripts/init-worktree-env.sh, so renderer port is 5173+offset and the app
becomes "Multica Canary <folder>" with its own userData/lock. The primary
checkout is untouched; explicit env vars still win. Backend targeting is
unchanged (apps/desktop/.env*). Also: brand-dev-electron honors the suffix,
turbo globalEnv passes it through, and CONTRIBUTING documents the flow.

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

* fix(desktop): make worktree dev port/suffix collision-safe (MUL-3724)

Addresses code review on #4598:

- Renderer port base 5173 → 5174 so a worktree whose offset is 0 (e.g.
  cksum("/tmp/multica-3494") % 1000 === 0) no longer collides with the
  primary checkout's default 5173.
- DESKTOP_APP_SUFFIX is now "<folder>-<offset>" instead of just the folder
  name, so worktrees that share a basename at different paths (or names that
  slug to the same fallback) get distinct single-instance locks. Without it
  the second Electron was still blocked by the shared lock.
- Tests: offset-0 port guard, and same-basename-different-path disambiguation.

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

---------

Co-authored-by: Lambda <agent@multica.ai>
Co-authored-by: multica-agent <github@multica.ai>
2026-06-26 15:11:28 +08:00
Naiyuan Qing
6d0e875dbb feat: add opt-in react-grab dev element inspector (web + desktop) (#4381)
* feat(web): add opt-in react-grab dev element inspector

Loads the react-grab overlay (hold ⌘C / Ctrl+C + click to copy an
element's source path + component stack) only when REACT_GRAB is set in
a local, gitignored apps/web/.env.local. Both the NODE_ENV and REACT_GRAB
guards are evaluated server-side in the root layout, so the <Script> tag
is omitted from the HTML for anyone who hasn't opted in — no effect on
other developers or production.

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

* feat(desktop): add opt-in react-grab dev element inspector

Mirrors the web wiring for the Electron renderer: injects the react-grab
overlay (hold ⌘C / Ctrl+C + click to copy an element's source path +
component stack) only when VITE_REACT_GRAB is set in a local, gitignored
apps/desktop/.env.development.local. Guarded by import.meta.env.DEV so the
branch is tree-shaken out of production builds; never activates for other
developers. No CSP/sandbox blocks the unpkg script (webSecurity is off).

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

* refactor(web): unify react-grab opt-in var to VITE_REACT_GRAB

Use the same env var name as the desktop renderer so one variable name
controls both apps. The desktop renderer is bundled by Vite, which only
exposes VITE_-prefixed vars to client code, so the shared name must carry
the VITE_ prefix; web reads it server-side where the name is unconstrained.

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

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-22 09:49:24 +08:00
Jiayuan Zhang
27fcbb015f Polish desktop sidebar motion
Polish desktop chrome/sidebar alignment and add motion-based transitions for left and right sidebars.
2026-06-19 06:26:14 +02:00
Bing2030
dd9e7bf19d fix(desktop): coerce commit-hash versions to valid semver (MUL-3314) (#4183)
* fix(desktop): coerce commit-hash versions to valid semver

normalizeGitVersion checked only the first character (/^\d/) to tell a real
version from a bare commit hash. A hash beginning with a digit (e.g.
'2f24057b') passed that check and was stamped as the app version, but bare
'2f24057b' is not valid semver, so electron-updater threw on launch.

Require a full major.minor.patch prefix; anything else (including a
digit-leading hash) falls back to 0.0.0-<hash> as before.

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

* fix(desktop): prefix bare-hash fallback with `g` for valid semver

normalizeGitVersion coerced an untagged build's bare commit hash into
`0.0.0-<hash>`, but that is not guaranteed valid semver: an all-digit
short hash with a leading zero (e.g. `0123456`) produced `0.0.0-0123456`,
and a numeric semver pre-release identifier must not have a leading zero.
electron-updater then threw on launch for exactly the untagged builds
this fallback exists to protect.

Prefix the hash with `g` (mirroring `git describe`'s own `g<hash>`
shorthand) so the pre-release is always a single alphanumeric identifier.
Add a regression test for the all-digit leading-zero case.

Addresses PR #4183 review.

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

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-18 14:17:50 +08:00
Naiyuan Qing
c222088262 feat: client failure telemetry (JS errors + freeze/crash) to PostHog (#4187)
* feat(analytics): capture JS exceptions to PostHog

Turn on posthog-js exception autocapture (window.onerror + unhandled
rejections, with stack) and add a buffered captureException() wrapper for
boundary-caught React errors those handlers can't see. Wire the web
route-level global-error boundary to report through it.

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

* feat(diagnostics): add shared freeze watchdog

Long-task observer (>=2s) emits client_unresponsive via captureEvent;
client_type super-property tags desktop vs web for free. Installed once in
CoreProvider so web and desktop share one in-thread, SSR-safe detector for
recoverable freezes.

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

* feat(desktop): report true hangs and crashes via breadcrumb

A real hang or crashed renderer can't report itself. The main process now
persists a breadcrumb on unresponsive / render-process-gone, and the next
renderer boot flushes it to PostHog (client_unresponsive / client_crash).
A recovered hang clears its breadcrumb so it isn't double-counted by the
in-thread watchdog.

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

* feat(analytics): scrub PII from $exception before send

Error messages can interpolate user input (typed values, URLs with tokens).
Add a before_send hook that redacts emails, URL query strings, and long
opaque tokens from the exception message and $exception_list values, keeping
type + stack frames (code locations, not user data). Addresses the privacy
gap from leaving capture_exceptions on with no sanitizer.

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

* test: cover breadcrumb state machine and freeze watchdog

The breadcrumb persist/clear orchestration is the correctness-critical part
and was untested. Cover: hang->write, recover->clear (no double-count),
recover-before-delay->no-op, force-quit->retained, crash->write-and-never-
clear, clean-exit->no-write. Add watchdog tests (threshold, idempotent,
SSR/PerformanceObserver no-op) via a fake observer.

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

* fix(desktop): breadcrumb field precedence + document limits

Spread the persisted context FIRST so explicit event fields (source,
recovered) always win over a future colliding context key. Document why
preload-error skips the breadcrumb and the single-slot last-write-wins
undercount limitation.

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

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 16:31:38 +08:00
Bohan Jiang
93541be975 MUL-3239: include route context in desktop recovery prompts
Co-authored-by: J <j@multica.ai>
Co-authored-by: multica-agent <github@multica.ai>
2026-06-15 16:50:54 +08:00
Bohan Jiang
7bd99c3c87 fix(desktop): mount Cmd+W handler at app root (#4137)
Co-authored-by: J <j@multica.ai>
Co-authored-by: multica-agent <github@multica.ai>
2026-06-15 15:10:33 +08:00
LeePepe
90fafab33a MUL-3240: fix(desktop): Cmd+W closes active tab first, then window
Closes #3987

MUL-3240
2026-06-15 14:52:52 +08:00
Bohan Jiang
9f720a401c fix(desktop): improve renderer recovery prompt (#4056)
Co-authored-by: J <j@multica.ai>
Co-authored-by: multica-agent <github@multica.ai>
2026-06-12 14:21:59 +08:00
Bohan Jiang
ac75c97797 fix(desktop): disable auto-start/stop toggles for a daemon the app can't control (WSL2) (#3940)
* feat(daemon): report OS in /health response

The desktop app reads daemon liveness over HTTP but starts/stops it via the
native CLI, which acts on the host process namespace. On Windows with the
daemon in WSL2, /health is reachable via localhost forwarding yet the daemon's
process is unreachable — so the app needs a signal to tell a daemon it manages
from one it merely sees. Expose runtime.GOOS as `os` so the desktop can
compare it against its own host OS. MUL-3154, #3916

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

* fix(desktop): disable auto-start/stop for an unmanageable daemon

When the daemon runs in an environment the app can't drive — e.g. Linux in
WSL2 behind a Windows desktop, reachable only via localhost forwarding — the
Auto-start/Auto-stop toggles silently did nothing: the lifecycle CLI acts on
the host process namespace and never reaches the daemon's PID.

Detect it by comparing the daemon's reported OS (new /health `os` field)
against the host OS, and only when a daemon is actually running. When they
differ: disable both toggles with an explanatory note, skip the version-match
restart on auto-start, and skip the no-op stop on quit. Fails safe — a missing
`os` (older daemon) or a matching OS keeps the toggles live, so native
Mac/Windows/Linux daemons are unaffected.

MUL-3154, #3916

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

* fix(desktop): centralize externally-managed guard at the lifecycle boundary

Review follow-up. The first cut only disabled the Settings toggles, but the
same unmanageable daemon (WSL2 etc.) could still be Stop/Restart-ed from the
Runtime card and from automatic lifecycle entries (logout, user switch,
reauth, first-workspace restart) — each of which would shell out to a native
CLI that can't reach the daemon's process.

Move the guard into the main-process lifecycle functions so every entry point
is covered by construction: stopDaemon() and restartDaemon() no-op for an
externally-managed daemon, and ensureRunningDaemonVersionMatches() treats it
as up-to-date (no misleading restart). The per-branch checks in the auto-start
handler and before-quit are removed — the boundary now covers them. The
Runtime card hides Stop/Restart and shows a 'Managed outside the app' hint,
mirroring the Settings tab. Adds a component test for the card's two states.

MUL-3154, #3916

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

* fix(desktop): preflight the lifecycle guard against live /health

Review follow-up. The guard read a cached lastExternallyManaged, which only
fetchHealth() updates — but not every lifecycle entry polls before calling
stop/restart. syncToken()'s user-switch branch calls restartDaemon() directly
after its own fetchHealthAtPort(), without refreshing the cache; on a fresh
launch / account switch (no poll yet) the cache is still the initial false, so
restartDaemon() would shell out to the native CLI and hit the very WSL/native
PID-namespace problem this PR avoids.

Make stopDaemon()/restartDaemon() preflight against a live /health read each
call instead of trusting the poll cache. The decision is extracted to a pure
daemonLifecycleUnreachable(readDaemonOS, hostOS) so a unit test can prove the
*live* value (not a cache) drives it. lastExternallyManaged is removed — the UI
already reads the per-status externallyManaged field, so it had no other
consumer.

MUL-3154, #3916

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

---------

Co-authored-by: J <j@multica.ai>
Co-authored-by: multica-agent <github@multica.ai>
2026-06-10 12:27:41 +08:00
Naiyuan Qing
3f98ada547 fix(desktop): guard updater events after window destroy (#3871)
* fix(desktop): guard updater events after window destroy

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

* test(desktop): cover updater send destroy race

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

---------

Co-authored-by: multica-agent <github@multica.ai>
2026-06-08 10:23:45 +08:00
Multica Eve
05e38e5d37 feat(lark): split bind CTA into Feishu and Lark entry points (MUL-3083) (#3832)
* feat(lark): split bind CTA into Feishu and Lark entry points (MUL-3083 follow-up)

The single "Bind to Lark" button began the device flow against
accounts.feishu.cn and relied on a mid-poll tenant_brand="lark" to
auto-switch international users over to accounts.larksuite.com. Lark
users had to scan a QR served from a Feishu domain first, which
surfaced as confusing in real use.

Replace with two explicit CTAs side by side — "Bind to Feishu" and
"Bind to Lark" — and route the device-flow begin straight to the
matching accounts host based on the user's choice. The mid-poll
auto-switch is preserved as a safety net for users who pick the wrong
entry.

Backend
- RegistrationClient.Begin(ctx, namePreset, region): POSTs to
  c.cfg.LarkDomain when region=lark, c.cfg.Domain otherwise. Empty /
  unknown region falls back to Feishu (matches RegionOrDefault).
- BeginInstallParams.Region threads through to the registration session
  and onto runPolling's initial region local. SwitchedDomain still
  flips it on tenant_brand=lark.
- POST /api/workspaces/{id}/lark/install/begin accepts ?region=feishu|lark
  with empty defaulting to feishu for back-compat.

Frontend
- api.beginLarkInstall(wsId, agentId, region) — region now required
  so every call site is forced to pick a cloud explicitly.
- LarkAgentBindButton renders two buttons; dialog state collapsed into
  a single dialogRegion useState so an "open but with no region picked"
  intermediate state can't exist.
- LarkInstallDialog takes region as a required prop and renders
  region-aware copy (title, description, scan hint, link fallback,
  success toast).

i18n
- Add bind_button_{feishu,lark}, install_dialog_{title,description}_*,
  install_scan_hint_*, install_open_link_fallback_*, and
  install_success_toast_* keys across en, zh-Hans, ja, ko. Legacy
  single-region keys are kept for now; nothing in the tree references
  them anymore but a follow-up cleanup can remove them once the dust
  settles.

Tests
- Two new lark.RegistrationClient tests pin region routing in both
  directions (region=lark hits LarkDomain; region=feishu hits Domain).
- Two new lark-tab.test.tsx cases pin that clicking each CTA calls
  beginLarkInstall with the matching region argument. Existing CTA
  tests updated to expect both buttons in place of one.

MUL-3083

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

* fix(lark): bidirectional tenant_brand swap + region-aware badge + link context menu

Addresses Elon's review on PR #3832 plus a separate report that the
"Or tap here to open in Lark" link in the install dialog had no
standard right-click affordances on the desktop app.

Backend (must-fix from review)

The PR's stated 'safety net for users who pick the wrong CTA' only
worked one direction: a Feishu-first begin already swapped to Lark on
tenant_brand=lark, but the new Lark-first begin (added by this same PR)
had no reverse path — a user who picked 'Bind to Lark' but actually
authorized with a Feishu account would carry RegionLark all the way
through finishSuccess and either fail at GetBotInfo or commit a
wrong-region row.

- PollResult now carries SwitchedDomain AND SwitchedRegion in
  lockstep, so the caller never has to re-derive region from the
  domain string.
- Poll() detects tenant_brand=feishu while polling against a non-Feishu
  host symmetrically with the existing tenant_brand=lark check, gated
  on the current host so we don't loop on a brand we already match.
- runPolling reads region from res.SwitchedRegion instead of the
  hardcoded RegionLark — the SwitchedDomain branch now flips both
  feishu→lark and lark→feishu cleanly.
- Tests: updated the existing TestRegistrationClient_Poll_DomainSwitchOnLarkTenant
  to assert SwitchedRegion, added TestRegistrationClient_Poll_DomainSwitchOnFeishuTenant
  for the reverse, and TestRegistrationClient_Poll_NoSwitchWhenAlreadyOnMatchingHost
  (table-driven, both directions) to pin that the gate doesn't loop.

Backend (nit from review)

Handler comment on /lark/install/begin claimed unknown region defaults
to Feishu downstream, but the handler already returns 400 on unknown
values. Updated the comment to match the actual behavior and document
why we 400 rather than silently normalize (so a frontend typo can't
land users on the wrong cloud without telling them).

Frontend (nit from review)

The Agent inspector's Connected badge was hardcoded 'Connected to
Lark' / 'Manage in Lark' (en) and 'Connected to Feishu' / 'Manage in
Feishu' (zh-Hans) — both wrong half the time now that the install
flow can land on either cloud per agent. Made the badge text and
Manage tooltip read from installation.region:

- agent_bot_connected_label_{feishu,lark}
- agent_bot_manage_link_{feishu,lark}
- agent_bot_manage_tooltip_{feishu,lark}

across en / zh-Hans / ja / ko. Legacy single-region keys retained for
safety. Existing badge tests updated: fixtures without 'region' now
expect the Feishu copy; the region: 'lark' test was promoted to also
assert the Lark badge text and link target. 21/21 lark-tab tests pass.

Desktop (separate report)

Right-clicking an <a> in the renderer surfaced only Copy / Cut /
Paste / Select All — no 'Open Link in Browser' or 'Copy Link Address'.
The renderer's <a target="_blank"> click path already routes through
setWindowOpenHandler → openExternalSafely, but discoverability via the
context menu was missing.

context-menu.ts now appends two link-specific items when params.linkURL
is an http(s) URL. Open Link routes through openExternalSafely (reuses
the existing scheme allowlist); Copy Link Address writes to Electron's
clipboard. Labels are localized to the OS preferred language for the
four locales the renderer ships (en / zh-Hans / ja / ko); zh-* variants
all route to zh-Hans, anything else falls back to English. New
context-menu.test.ts pins five cases: link items show for http(s),
not for javascript:/mailto:/etc., not when no link is under the cursor,
zh-CN gets Chinese, fr-FR falls back to English. 198/198 desktop tests
pass.

MUL-3083

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: Jiang Bohan <bhjiang@outlook.com>
2026-06-05 18:30:19 +08:00
Xinmin Zeng
270d177475 fix: broken "Add a computer" command on Multica Cloud + two CLI amplifiers (MUL-3087) (#3817)
* fix(server): recognize official cloud by frontend host in daemon setup config

The 'Add a computer' dialog builds its command from /api/config's
daemon_server_url/daemon_app_url, falling back to 'multica setup' when
both are empty. The official cloud is meant to omit them, but the
omission only fired when MULTICA_PUBLIC_URL=https://api.multica.ai. When
that env is unset the server URL defaults to the frontend origin and the
old guard (which required serverURL host == api.multica.ai) didn't match,
so the dialog emitted 'multica setup self-host --server-url
https://multica.ai' — pointing the daemon backend at the frontend (no
/health, no WebSocket proxy).

Identify the official cloud by its frontend host alone (multica.ai /
app.multica.ai) so a missing or misconfigured MULTICA_PUBLIC_URL can no
longer leak the broken self-host command. Regression from #3474.

* fix(cli): probe before persisting self-host config to preserve auth on failure

setup self-host wrote a fresh CLIConfig{ServerURL, AppURL} (a full
overwrite that drops the saved token) and only then probed the server,
returning early on failure. A failed probe therefore logged the user out
and left them unconnected, with no recovery in the same command.

Probe first via persistSelfHostConfigIfReachable: an unreachable server
leaves the existing config — and its token — untouched (failed setup =
no-op). The prober is injected so both branches are unit-tested.

* fix(daemon): serve health before preflight so daemon start readiness is accurate

The CLI's 'daemon start' polls the health endpoint for 15s expecting
status=running, but the daemon only began serving health after
preflightAuth, whose initial workspace sync detects every configured
agent's version by exec'ing it (~20s cold with 8 agents). Health served
too late, so a perfectly healthy daemon printed 'may not have started
successfully'.

Start the health server right after resolveAuth (which still fails fast
on a missing token) and before the slow preflight, so readiness reflects
the daemon core being up rather than agent-version detection finishing.

* fix(daemon): gate /health readiness so daemon start can't report a false start

Serving health before preflightAuth fixed the false-negative (a healthy
daemon printed "may not have started"), but health still returned
status:"running" unconditionally — before preflight (PAT renew + workspace
sync + runtime registration) had completed. `daemon start` and the desktop
treat "running" as ready, so a slow or *failing* preflight could be
misreported as a started daemon: setup prints "connected", then the process
exits or hangs in agent-version detection with no runtime registered. That
is harder to diagnose than the original false-negative.

Split liveness from readiness: bind/serve the health port early (so callers
see a live "starting" daemon instead of connection-refused), but report
status:"starting" until d.ready is set after preflight, then "running".

- daemon.go: add d.ready (atomic.Bool); set it true after the background
  loops launch, before pollLoop.
- health.go: healthHandler reports "starting" until ready, else "running".
- cmd_daemon.go: `daemon start` waits for "running" with a deadline raised
  to 45s (covers cold-start agent detection) and a clearer "still starting"
  message; new daemonAlive() helper treats both "running" and "starting" as
  a live daemon, so the already-running guard, restart, and stop act on a
  starting daemon and don't double-spawn or race its listener; `daemon
  status` shows "starting" distinctly.

Older CLIs/desktop that only know "running" safely treat "starting" as
not-ready (status != "running"), so no boundary break.

Tests: health reports starting-then-running; daemonAlive truth table.
Co-authored-by: multica-agent <github@multica.ai>

* fix(desktop): handle daemon "starting" health status in lifecycle

The daemon now reports /health status:"starting" until preflight completes
(liveness/readiness split). That made "starting" a new external contract of
/health, but the Desktop daemon-manager only knew "running", so the readiness
fix would have moved the CLI's false-negative into a Desktop start regression:

- `daemon start` now blocks up to 45s waiting for readiness, but the Desktop
  spawned it via execFile({ timeout: 20_000 }). On a cold start (the ~20s agent
  detection this PR targets) Electron killed the CLI supervisor at 20s and
  reported a start failure, even though the detached daemon child kept booting —
  the UI flashed "stopped" then "running". Raise the timeout to 60s (must exceed
  the CLI's 45s startupTimeout).
- The Desktop treated only raw status === "running" as a live daemon, so a
  daemon that was still "starting" (booting on its own or started via the CLI)
  showed as "stopped", and startDaemon() would spawn a second one — which the new
  CLI rejects as "already running", surfacing as a start error.

Add daemonStatusAlive() (shared, pure, unit-tested) mirroring the Go daemonAlive()
and use it for liveness: fetchHealth() surfaces a daemon-reported "starting" as
state "starting" regardless of our own currentState; startDaemon()'s
already-running guard and the restart-on-user-switch guard treat "starting" as an
existing daemon. version-decision stays gated on "running" (readiness, not
liveness) — unchanged.

Verified: desktop typecheck, eslint, full vitest suite (193 tests) all pass.
Co-authored-by: multica-agent <github@multica.ai>

---------

Co-authored-by: J <j@multica.ai>
Co-authored-by: multica-agent <github@multica.ai>
2026-06-05 17:01:23 +08:00
Nguyễn Phúc Lương
93b93f58b5 fix(desktop): route inbox notifications to the item's source workspace (#3797)
Resolves the desktop inbox notification slug from the item's own workspace_id, routes the click through the navigation adapter for a real workspace switch, and invalidates the source workspace's inbox cache. Follow-up: mute-preference fetch should also target the source workspace.

Closes #3766
2026-06-05 15:51:58 +08:00
Bohan Jiang
d6540a1869 fix(clipboard): support copy over http:// via execCommand fallback (#3810)
navigator.clipboard is only exposed in a secure context (https or
localhost). On self-hosted instances served over plain http:// it is
undefined, so every copy / "copy all" / export button silently failed and
left the clipboard empty (GitHub #3781).

Add a shared copyText(text): Promise<boolean> helper in
@multica/ui/lib/clipboard that prefers the async Clipboard API and falls
back to a hidden <textarea> + document.execCommand('copy') for non-secure
contexts. Migrate all direct navigator.clipboard.writeText call sites
(code blocks, agent transcript copy-all, token / webhook / issue-link
copy, etc.) to it, gating success side-effects on the returned boolean,
and remove the now-redundant copyMarkdown wrapper. Secure-context users
keep the native path unchanged.

MUL-3068

Co-authored-by: J <j@multica.ai>
Co-authored-by: multica-agent <github@multica.ai>
2026-06-05 14:55:23 +08:00