Compare commits

...

247 Commits

Author SHA1 Message Date
Jiayuan Zhang
be8a2040f5 fix(cli): always use localhost for auth callback in browser login
When the app URL pointed to a remote private IP (e.g. 192.168.11.200),
the CLI incorrectly used that IP as the callback host. The callback
HTTP server runs on the CLI's local machine, but the browser redirect
would target the remote server's IP on a random port that isn't open
there — breaking the entire auth flow for remote self-hosted setups.

Since openBrowser() always opens the browser on the same machine as the
CLI, the callback must always target localhost.

Closes #1056
2026-04-15 14:25:28 +08:00
LinYushen
6bbe059055 feat(desktop): sync package version with CLI via git tag at build time (#1050)
* fix(desktop): ship entitlements.mac.plist so electron-builder can codesign

electron-builder.yml already references build/entitlements.mac.plist
via entitlementsInherit, but the file was missing from the tree, so
`pnpm package` failed at the codesign step with:

  build/entitlements.mac.plist: cannot read entitlement data

Ship the file. It grants the hardened-runtime capabilities the app
actually needs: JIT + unsigned executable memory for V8, disabled
library validation so the Electron process can spawn the bundled
`multica` Go binary as a child process, and network client/server for
the daemon's API and /health endpoints.

Also tweak the root .gitignore: the top-level `build` rule was
shadowing apps/desktop/build/, hiding this config file from git.
Add a scoped exception so apps/desktop/build/ (which holds
electron-builder source resources, not output) is tracked.

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

* feat(desktop): derive package version from git tag at build time

The Desktop app version was hardcoded to "0.1.0" in package.json and
never bumped, while the bundled CLI reports whatever `git describe`
gives at build time. Result: packaging on main produced
desktop-0.1.0.dmg containing multica v0.1.35-14-gf1415e96 — completely
disconnected. Users see two unrelated version numbers for the same
release.

Sync them by using the same source GoReleaser uses for the CLI: the
nearest git tag. A new scripts/package.mjs wrapper runs bundle-cli.mjs,
derives the version via `git describe --tags --always --dirty` (strips
the `v` prefix, falls back to `0.0.0-<hash>` when no tags are
reachable), and invokes electron-builder with
`-c.extraMetadata.version=<derived>` — which overrides package.json at
build time without mutating the tracked file.

On a clean tag commit → "0.1.36"; between tags → "0.1.35-14-gf1415e96"
(valid semver prerelease); dirty tree → same with "-dirty" suffix.

The `package` script in package.json now points to the wrapper.
Passthrough args (--mac, --arm64, etc.) after `pnpm package --` are
forwarded to electron-builder unchanged. Dev and build scripts are
untouched — they continue to use bundle-cli.mjs directly.

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

* feat(desktop): enable macOS notarization and clean artifact names

Two electron-builder.yml tweaks that unblock a proper release:

- `mac.notarize: false` → `true`. Notarization runs in-build via
  notarytool, reading APPLE_ID/APPLE_APP_SPECIFIC_PASSWORD/APPLE_TEAM_ID
  from env. electron-builder then staples the ticket before zipping, so
  `latest-mac.yml`'s SHA512s match the published artifacts (critical
  for electron-updater — post-hoc re-stapling would invalidate them).
  Non-mac/CI contributors are unaffected: `pnpm package` already
  requires the Developer ID signing cert, and notarization is a strict
  superset of signing.

- `mac.artifactName` and `dmg.artifactName` now hardcode
  `multica-desktop-${version}-${arch}.${ext}` instead of using
  `${name}`, which expands to `@multica/desktop` for scoped package
  names and literally produced files at `dist/@multica/desktop-*.dmg`.
  The nested `@multica/` path is useless and makes the GitHub Release
  asset URL ugly. New layout is flat: `dist/multica-desktop-<ver>-arm64.dmg`.

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

* fix(desktop): keep local package builds working after notarize: true

Three polish items from review of this PR.

- Local dev regression: `mac.notarize: true` in electron-builder.yml
  made `pnpm package` hard-fail on macs without APPLE_* env vars, even
  for non-publishing local smoke tests. Detect the missing env in
  scripts/package.mjs and pass `-c.mac.notarize=false` for that run
  only. Real release builds (which source apps/desktop/macOS/.env via
  the release-desktop skill) are unaffected. Also logs a clear warning
  so the developer knows notarization was skipped.
- spawnSync previously used `shell: true`, which reassembled argv into
  a shell command string. Zero real-world injection risk given our
  controlled inputs, but dropping it closes the vector at no cost —
  pnpm already puts node_modules/.bin on PATH for script runs so the
  binary is found without a shell wrapper.
- On spawn failure (e.g. electron-builder not found), result.error was
  silently swallowed and the exit was just `1`. Log the underlying
  reason before exiting.

Also refactor so normalizeGitVersion is exportable and guard the main
entry behind an import.meta.url check, enabling unit coverage. New
package.test.mjs covers the six branches: null/empty input, clean tag,
between-tags prerelease, dirty suffix, v-prefixed prerelease tags
(vX.Y.Z-alpha and vX.Y.Z-rc.2), and the 0.0.0-<hash> fallback for
hash-only describe output. vitest.config.ts picks up scripts/**/*.test.mjs.

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

* feat(desktop): commit .env.production for release builds

Bake production backend + app URLs into release packages so `pnpm
package` produces a build that points at multica.ai out of the box.
electron-vite (Vite) reads .env.production automatically in production
mode — no script changes needed.

Values:

  VITE_API_URL   = https://api.multica.ai
  VITE_WS_URL    = wss://api.multica.ai/ws
  VITE_APP_URL   = https://multica.ai

Also parameterize the two hardcoded `https://www.multica.ai` strings
in platform/navigation.tsx's `getShareableUrl` on VITE_APP_URL. The
previous hardcoded host pointed to `www.multica.ai`, which disagrees
with the canonical `multica.ai` we're standardizing on. Shareable
links from the desktop ("Copy link to issue") now match.

The env file is public config, not a secret, so add a scoped exception
to the root .gitignore's `.env*` rule.

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

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-15 14:12:53 +08:00
Naiyuan Qing
cf70860a0b Merge pull request #1052 from multica-ai/NevilleQingNY/fix-bubble-menu-pos
fix(editor): fix bubble menu positioning on first selection
2026-04-15 13:57:15 +08:00
Naiyuan Qing
9f350e312d Merge pull request #1053 from multica-ai/agent/agent/bbde5dd5
fix(cli): add pagination metadata to issue list and update agent prompt
2026-04-15 13:53:03 +08:00
Naiyuan Qing
08c3513eef fix(cli): add pagination metadata to issue list JSON output and update agent prompt
Issue list JSON now includes total, limit, offset, has_more fields so agents
can detect truncated results and paginate. Also documents --limit/--offset in
the agent prompt and emphasizes mention format in Output section.

Closes MUL-837

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-15 13:51:08 +08:00
Naiyuan Qing
817e69a9eb fix(editor): fix bubble menu positioning on first selection
Tiptap's React wrapper initialises the menu element with
position:absolute, but computePosition needs position:fixed so
getOffsetParent returns the viewport instead of a positioned ancestor.
On the first show, coordinates were computed relative to the wrong
containing block, causing the menu to fly off-screen (negative coords).

Fix: set position:fixed in the onShow callback, which fires right
before updatePosition(), ensuring computePosition sees the correct
offset parent.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-15 13:49:33 +08:00
Jiayuan Zhang
f94b0100cd refactor(autopilot): remove broken concurrency policies and fix multiple bugs (#1048)
Remove the concurrency_policy system (skip/queue/replace) — skip had an
orphan bug that permanently blocked triggers, queue didn't actually queue,
and replace didn't cancel running tasks. Every trigger now simply executes.

Bug fixes:
- Listener now handles in_review status (was silently ignored)
- Issue deletion fails linked autopilot runs before DELETE (prevents orphans)
- ComputeNextRun rejects invalid timezones instead of silent UTC fallback
- dispatchCreateIssue post-commit failures now properly fail the run

Reliability:
- Scheduler recovers lost triggers on startup (crash recovery)
- New index on autopilot_run(issue_id) for deletion lookups
- Migration 043 cleans up historical orphaned/skipped/pending runs
2026-04-15 13:48:21 +08:00
marcel
287a9eb546 fix(repocache): pass explicit env to remote-facing git subprocesses (#1029)
fix(repocache): pass explicit env to remote-facing git subprocesses
2026-04-15 13:15:36 +08:00
Bohan Jiang
45dad23074 fix(views): sort timeline entries after WebSocket append (#1047)
WebSocket event handlers for comment:created and activity:created
appended new entries to the end of the timeline array without sorting.
When events arrived out of order (e.g. agent replying rapidly), comments
displayed out of chronological order.

Sort the timeline by created_at after each append to maintain correct
chronological ordering.

Closes #1032
2026-04-15 13:07:45 +08:00
Bohan Jiang
762e64d469 fix(agent): restrict custom_env visibility to owner/admin (#1046)
* fix(agent): restrict custom_env visibility to agent owner and workspace admin

Agent environment variables (custom_env) were visible to all workspace
members, exposing sensitive tokens. Now only the agent owner and
workspace owner/admin can view them — regular members receive the field
omitted (null) from API responses, and the frontend hides the
Environment tab accordingly.

Closes #1018

* fix(agent): show masked env keys to non-authorized users instead of hiding tab

Instead of completely hiding the Environment tab for non-owner/non-admin
users, show the variable keys with masked values (****) in a read-only
view. This lets members see which variables are configured without
exposing the actual values.

- Backend: mask values with "****" instead of nullifying custom_env
- Added custom_env_redacted boolean to API response
- Frontend: EnvTab supports readOnly mode with lock icon and muted styling
2026-04-15 13:06:49 +08:00
devv-eve
f1415e9622 fix(sidebar): narrow user popover width (#1045)
* feat(sidebar): replace user menu ellipsis with full-row popover

Remove the three-dot menu from the sidebar footer user profile.
The entire row is now clickable and opens an upward popover showing
the user's full name, email, and a logout button.

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

* fix(sidebar): narrow user popover width

Reduce popover from w-64 to w-48 and tighten internal spacing
to better fit the sidebar proportions.

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

---------

Co-authored-by: Devv <devv@Devvs-Mac-mini.local>
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-authored-by: yushen <ldnvnbl@gmail.com>
2026-04-15 12:24:42 +08:00
LinYushen
8030f1adbc feat(desktop): restart local daemon when bundled CLI version differs (#1041)
* feat(desktop): restart local daemon when bundled CLI version differs

Desktop bundles a multica CLI binary at build time via bundle-cli.mjs.
If a local daemon is already running from a previous session with an
older CLI, the newly bundled version never takes effect until the user
manually restarts. Fix that on the login/auto-start path.

- Expose the daemon's CLI version on GET /health as cli_version (sourced
  from cfg.CLIVersion, which is already set from the ldflag at daemon
  startup in cmd_daemon.go).
- In the desktop main process, query the resolved CLI binary's version
  once via `multica version --output json` and cache it for the process
  lifetime.
- On daemon:auto-start, if the daemon is already running, compare the
  two versions. Restart only when BOTH sides are known and the strings
  differ — a restart kills in-flight agent tasks, so any uncertainty
  (bundled CLI unknown, older daemon without cli_version field, read
  failure) fails safe and leaves the daemon alone.

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

* feat(daemon): defer version-mismatch restart until active tasks drain

Previous iteration restarted the daemon immediately on a confirmed CLI
version mismatch, which would kill any agent tasks mid-execution. Gate
the restart on an active-task counter so in-flight work always finishes.

- Daemon: add `activeTasks atomic.Int64` on the Daemon struct,
  increment/decrement it around handleTask, and expose it as
  `active_task_count` on GET /health.
- Desktop: when a version mismatch is confirmed but active_task_count >
  0, set a pendingVersionRestart flag instead of restarting. The 5s
  pollOnce loop retries ensureRunningDaemonVersionMatches on each tick
  and fires the restart the moment the count drops to 0.
- Eventual consistency: if the user keeps the daemon permanently busy,
  the version stays out of date — that's a strictly better failure mode
  than silently killing hour-long agent runs.

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

* test(daemon): cover version-check decision + /health counter exposure

Addresses the test-coverage gap from the second review.

- Go: extract the /health handler into a named method `(d *Daemon)
  healthHandler(startedAt time.Time)` so it can be exercised via
  httptest without spinning up a listener. Add health_test.go covering
  cli_version + active_task_count field exposure and the increment /
  decrement protocol used by pollLoop.
- Desktop: extract the pure version-check decision logic into
  version-decision.ts (no electron, no I/O, no module state). The
  ensureRunningDaemonVersionMatches wrapper now delegates the "what
  should we do" decision to decideVersionAction and owns only the side
  effects (logging, flag mutation, restartDaemon call).
- Desktop: bolt vitest onto apps/desktop (vitest.config.ts + catalog
  devDep + test script) so main-process unit tests have a home. Add
  version-decision.test.ts covering all four action branches and the
  busy→idle drain transition.

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

* fix(daemon): bust CLI version cache on retry-install, lock wire-level JSON keys

Two polish items from review.

- daemon:retry-install now also clears cachedCliBinaryVersion. Previously
  a retry that landed a newly-downloaded CLI at a different version
  would false-negative on the next version check because the cached
  version string was sticky for the process lifetime.
- TestHealthHandlerReportsCLIVersionAndActiveTaskCount now decodes into
  a raw map[string]any and asserts the exact snake_case keys
  (cli_version, active_task_count, status). The desktop TS client keys
  on these literal strings, so a silent struct-tag rename must fail the
  test. Typed struct round-trip kept as a separate value check.

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

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-15 12:19:01 +08:00
devv-eve
eacf33299a feat(sidebar): replace user menu ellipsis with full-row popover (#1044)
Remove the three-dot menu from the sidebar footer user profile.
The entire row is now clickable and opens an upward popover showing
the user's full name, email, and a logout button.

Co-authored-by: Devv <devv@Devvs-Mac-mini.local>
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-15 12:15:10 +08:00
devv-eve
cf012b2706 feat(agents): show runtime owner and Mine/All filter in Create Agent dialog (#1042)
* feat(agents): show runtime owner and add Mine/All filter in Create Agent dialog

Display the runtime owner (with avatar) in the runtime selector dropdown,
matching the pattern used in the Runtime list page. Add a Mine/All toggle
to filter runtimes by ownership, defaulting to "Mine" so the current user's
runtimes appear first.

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

* feat(agents): show runtime owner and Mine/All filter in agent Settings tab

Apply the same owner display and Mine/All filter pattern to the Settings
tab's runtime selector, matching the Create Agent dialog. Uses ProviderLogo
and ActorAvatar for consistent runtime item rendering across both selectors.

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

* fix(agents): address PR review — use unfiltered runtimes for lookup, simplify IIFE

- Look up selectedRuntime from full `runtimes` array instead of
  `filteredRuntimes` to avoid null flash when switching filters
- Replace IIFE with inline optional chaining for owner name display
- Fix indentation on the trigger subtitle div

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

---------

Co-authored-by: Devv <devv@Devvs-Mac-mini.local>
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-14 20:57:53 -07:00
devv-eve
2cbebfc568 refactor(daemon): remove watch/unwatch workspace logic, default to all workspaces (#1003)
The daemon now automatically watches all workspaces the user belongs to,
fetched directly from the API. This removes the manual watch/unwatch
workflow, the config-based watched/unwatched lists, the /watch HTTP
endpoints, the CLI watch/unwatch commands, and the desktop app's watched
workspace UI and reconciliation logic.

Co-authored-by: Devv <devv@Devvs-Mac-mini.local>
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-15 11:24:15 +08:00
KimSeongJun
100146c49e feat(selfhost): add single-domain Caddy setup (#899)
* selfhost: add single-domain caddy setup

* fix(selfhost): address Caddy review feedback
2026-04-14 20:20:26 -07:00
Naiyuan Qing
de982f3a4e Merge pull request #1037 from multica-ai/NevilleQingNY/editor-arch-review
refactor(editor): remove hardcoded CDN domain, unify file card rendering
2026-04-15 10:47:19 +08:00
Naiyuan Qing
53cb01cc91 refactor(editor): remove hardcoded CDN domain, unify file card rendering
- Add GET /api/config endpoint exposing cdn_domain from CLOUDFRONT_DOMAIN
- Create packages/core/config/ zustand store, fetched at app startup
- Extract file card preprocessing to packages/ui/markdown/file-cards.ts
  with isCdnUrl(url, cdnDomain) using exact hostname match
- Add file card support to packages/ui/markdown/Markdown.tsx (was missing)
- Remove hardcoded .copilothub.ai hostname check from file-card.tsx
- Fix LocalStorage.CdnDomain() to return hostname not full URL
- Always run preprocessFileCards regardless of cdnDomain availability
  (!file syntax works without CDN domain, only legacy matching needs it)
- Use useConfigStore hook in common/markdown.tsx for reactive updates

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-15 10:43:36 +08:00
Naiyuan Qing
afa711b442 Merge pull request #1031 from multica-ai/NevilleQingNY/editor-arch-review
fix(editor): hover card bug, view crash, perf, and link handler cleanup
2026-04-15 09:30:41 +08:00
Naiyuan Qing
8d6e5f2bcc fix(editor): hover card bug, view crash, perf, and link handler cleanup
- Fix issue mention cards incorrectly triggering Link Hover Card
- Guard editor.view access in BubbleMenu against unmounted/destroyed
  view Proxy (fixes desktop Inbox fast-switching crash)
- Use useEditorState for precise formatting state subscriptions in
  BubbleMenu instead of relying on parent re-renders
- Add markdownTokenizer to FileCard for unambiguous !file[name](url)
  roundtrip syntax (legacy CDN hostname matching kept for compat)
- Extract shared openLink/isMentionHref into utils/link-handler.ts

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-15 09:27:01 +08:00
Naiyuan Qing
c460206846 Merge pull request #1030 from multica-ai/feat/inter-font-cjk-fallback
fix(fonts): Inter + CJK fallback to fix full-width punctuation rendering
2026-04-15 08:49:20 +08:00
Naiyuan Qing
70e4f44860 style(fonts): add text-autospace for CJK+Latin auto-spacing and sync design doc
- packages/ui/styles/base.css: add `text-autospace: ideograph-alpha
  ideograph-numeric` to html. Native CSS feature (Chrome 119+,
  Electron recent) that auto-inserts 1/4em space between CJK ideographs
  and Latin letters/numerals. Progressive enhancement — older browsers
  ignore the rule silently.
- docs/design.md: update font family table to reflect Inter + CJK system
  fallback. Reword font-bold ban rationale to be font-agnostic
  (information density / layout rhythm), not Geist-specific.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-15 08:45:23 +08:00
Naiyuan Qing
4b10c9354a fix(fonts): swap Geist Sans → Inter with explicit CJK fallback
Full-width Chinese punctuation (e.g. ,) was rendering at Latin-font
metrics, making it look half-width in the editor. Root cause: Geist is
Latin-only, and neither web (next/font) nor desktop (@fontsource) declared
any CJK fallback, so CJK chars inherited Geist's em-box width through
Chromium's per-character fallback.

- Web (apps/web/app/layout.tsx): Geist → Inter via next/font/google,
  with explicit fallback array: system fonts → PingFang SC (macOS) →
  Microsoft YaHei (Windows) → Noto Sans CJK SC (Linux) → sans-serif.
- Desktop: removed @fontsource/geist-sans, added @fontsource-variable/inter
  (single variable-weight file replaces 4 static weights). Updated
  --font-sans in globals.css to match web's fallback chain.
- Geist Mono kept for code blocks; mono chain has no CJK fallback by
  design (CJK is non-aligned in mono grids, listing CJK fonts would
  falsely signal alignment guarantees). Added Consolas to web mono for
  Windows symmetry with desktop.
- Cross-reference sync comments in both layout.tsx and globals.css:
  CJK tail must stay in sync; Inter primary differs by design (next/font
  injects `__Inter_xxx` with adjustFontFallback metric override;
  fontsource uses raw "Inter Variable").

Currently covers English + Simplified Chinese. When ja/ko i18n lands,
extend fallback tails with Hiragino Kaku Gothic ProN / Yu Gothic /
Apple SD Gothic Neo / Malgun Gothic.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-15 08:45:14 +08:00
Jiayuan Zhang
d88fe2608e feat(autopilot): scheduled/triggered automations for AI agents (#1028)
* feat(autopilot): add scheduled/triggered automation for AI agents

Introduce the Autopilot feature — recurring automations that assign work
to AI agents on a schedule or manual trigger. Supports two execution
modes: create_issue (creates an issue for the agent to work on) and
run_only (directly enqueues an agent task without issue pollution).

Backend: migration (3 tables + 2 columns), sqlc queries, AutopilotService
with concurrency policies (skip/queue/replace), HTTP CRUD + trigger
endpoints, background cron scheduler (30s tick), event listeners for
issue→run and task→run status sync.

Frontend: types, API client methods, TanStack Query hooks with optimistic
mutations, realtime cache invalidation, list page with create dialog,
detail page with trigger management and run history, sidebar nav + routes
for both web and desktop apps.

* feat(autopilot): improve UX — trigger config, edit dialog, template gallery

- Replace raw cron input with friendly frequency tabs (Hourly/Daily/Weekdays/Weekly/Custom), time picker, and timezone dropdown defaulting to user's local timezone
- Fix Select components showing UUIDs instead of names (Base UI render function pattern)
- Add Edit button on detail page opening a unified edit dialog
- Remove project/concurrency/issue-title-template from create/edit (simplify for users)
- Add trigger configuration inline during autopilot creation
- Add template gallery on empty state (6 step-by-step workflow templates)
- Rename "Description" to "Prompt" throughout UI
- Inject autopilot run timestamp into issue description for agent date awareness
- Treat issue status "in_review" as run completion (fixes skip on next trigger)
- Make migration idempotent with IF NOT EXISTS clauses
2026-04-15 04:54:37 +08:00
Bohan Jiang
c79cfaf330 fix(auth): honor ?next= redirect through Google OAuth flow (#1024)
The login page now encodes the ?next= param into the Google OAuth state
so the auth callback can redirect to the right destination (e.g.
/invite/{id}) after login, instead of always going to /issues.
2026-04-15 00:52:12 +08:00
Bohan Jiang
60c5848794 feat(invitation): dedicated /invite/{id} page for accepting invitations (#1023)
The email CTA now deep-links to /invite/{id} instead of the generic app
URL. If the user isn't logged in, they're redirected to login with a
?next= param that brings them back to the invite page.

Changes:
- Backend: GET /api/invitations/{id} endpoint (enriched with workspace/inviter names)
- Backend: Email template now links to /invite/{invitationId}
- Frontend: Shared InvitePage component (packages/views/invite/)
- Frontend: Web route at (auth)/invite/[id], Desktop route at invite/:id
- Frontend: /invite/ excluded from navigation history persistence
2026-04-15 00:37:53 +08:00
Bohan Jiang
642c6ae5ee docs: add Gemini CLI to all documentation and landing page (#1022)
Gemini CLI support was added to the backend in v0.1.33 but was missing
from all user-facing documentation and the website. Added Gemini CLI
(and Hermes where missing) to the agents table, quickstart guides,
CLI reference, installation docs, self-hosting guide, and landing page
hero section with logo.
2026-04-15 00:28:02 +08:00
Bohan Jiang
1163f684fb feat(invitation): send email notification when inviting a user (#1021)
Uses the existing Resend email service to notify invitees.
Email includes inviter name, workspace name, and a link to the app.
Sent fire-and-forget in a goroutine to avoid blocking the API response.
2026-04-15 00:17:21 +08:00
Bohan Jiang
ff1d348274 feat(security): invitation acceptance flow for workspace members (#1019)
* feat(security): replace instant member-add with invitation acceptance flow

Users invited to a workspace must now explicitly accept the invitation
before becoming a member. This fixes the security vulnerability where
knowing someone's email was enough to auto-register their runtime to
your workspace.

Changes:
- Add workspace_invitation table with pending/accepted/declined/expired states
- Replace CreateMember with CreateInvitation (same endpoint, new behavior)
- Add accept/decline/revoke/list invitation API endpoints
- Add invitation WS events for real-time notification
- Frontend: invitation accept/decline UI in workspace switcher
- Frontend: pending invitations section in members settings tab

* fix(invitation): address PR review nits

- Fix invitation:revoked listener to send event to invitee user (was no-op)
- Remove duplicate queryClient2 in app-sidebar.tsx, reuse existing queryClient
- Add expires_at > now() filter to ListPendingInvitationsByWorkspace query
2026-04-15 00:01:18 +08:00
Bohan Jiang
b4b69f89f6 fix(server): allow members to create and manage their own skills (#1017)
Remove admin/owner-only restriction from skill creation and import routes.
Add canManageSkill helper that lets skill creators manage their own skills,
matching the existing canManageAgent pattern for agents.
2026-04-14 23:48:57 +08:00
Bohan Jiang
a3c6f07668 fix(server): allow members to create agents (#1013)
Remove the owner/admin role restriction on the POST /api/agents endpoint
so that workspace members can also create agents.
2026-04-14 22:51:59 +08:00
Asish Kumar
b2649fb47f fix(realtime): add WebSocket ping/pong heartbeat to detect dead connections (#917)
Without a heartbeat, dead or silently-dropped WebSocket connections are
not detected until the next write fails. This causes goroutine and memory
leaks for each stale client, and breaks real-time updates for users whose
connections are dropped by a load balancer or proxy idle timeout (e.g.
Nginx default 60s, AWS ALB default 60s) without a TCP RST.

This commit applies the standard gorilla/websocket keepalive pattern:

- writePump sends a ping frame every pingPeriod (54 s) using a ticker.
  The ticker replaces the simple range-over-channel loop with a select,
  which also adds a proper write deadline on every write operation.

- readPump installs a pong handler that resets the read deadline on each
  pong, keeping healthy connections alive indefinitely.  A connection
  that misses a pong is detected within pongWait (60 s) and closed,
  which causes readPump to exit and send the client to hub.unregister
  for clean removal.

Timing constants:
  writeWait  = 10 s  (per-write deadline, prevents hung writers)
  pongWait   = 60 s  (max silence before declaring a connection dead)
  pingPeriod = 54 s  (ping interval, 90 % of pongWait)

Also adds user_id and workspace_id to the write-error log line so that
connection problems can be attributed to a specific client in production.

All existing hub tests continue to pass unchanged.

Signed-off-by: Asish Kumar <officialasishkumar@gmail.com>
2026-04-14 21:56:06 +08:00
Bohan Jiang
c2a5ed73e8 fix(web): add /uploads/* rewrite for self-hosted deployments (#1010)
On self-hosted deployments where the frontend is the public entrypoint,
uploaded files return 404 because /uploads/* requests aren't proxied to
the backend. Add a rewrite rule following the existing pattern for /api/*,
/ws, and /auth/*.

Closes #1004
2026-04-14 21:34:47 +08:00
Jiayuan Zhang
f0c0a64ddd feat(cli): support --version and -v flags on root command (#1007)
Use Cobra's built-in version support so `multica --version` and
`multica -v` print the same output as `multica version`.

Closes MUL-743
2026-04-14 21:14:00 +08:00
Naiyuan Qing
2ecddc8fc8 Merge pull request #1002 from multica-ai/chore/remove-desktop-remote-mode
chore(desktop): remove dev:desktop:remote proxy mode
2026-04-14 19:54:43 +08:00
Naiyuan Qing
2a2e6f4746 chore(desktop): remove dev:desktop:remote proxy mode
Drops the VITE_REMOTE_API Vite-proxy path introduced in be8b099c.
The remote-backend proxy is no longer needed; direct dev via
VITE_API_URL covers every workflow we still support.

- remove dev:desktop:remote (root) and dev:remote (desktop) scripts
- revert electron.vite.config.ts to a flat config — no loadEnv, no
  per-route proxies
- simplify App.tsx: single apiBaseUrl/wsUrl branch, and
  DAEMON_TARGET_API_URL derives directly from VITE_API_URL

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-14 19:53:55 +08:00
Bohan Jiang
6538496ee4 fix(daemon): sync workspaces from API before failing on empty runtime list (#1001)
When the CLI config has no watched workspaces (e.g. fresh desktop app
install), loadWatchedWorkspaces returns successfully but registers zero
runtimes. The runtime check immediately after fails with "no runtimes
registered" before workspaceSyncLoop gets a chance to discover
workspaces from the API.

Run one sync cycle inline when the watched list is empty so the daemon
can bootstrap itself without a pre-configured workspace list.
2026-04-14 19:52:52 +08:00
Naiyuan Qing
69ef002bbb Merge pull request #1000 from multica-ai/fix/desktop-titlebar-drag-region
fix(desktop): stop macOS traffic lights from hijacking titlebar & modals
2026-04-14 19:48:45 +08:00
Naiyuan Qing
7dad45d444 feat(desktop): immersive mode hides traffic lights for full-screen modals
Full-screen modals (create-workspace) covered the app titlebar, so the
Back button landed on top of the macOS traffic lights — where native
hit-test always wins and the button couldn't be clicked. The modal
also swallowed the window's drag region.

Introduce a desktop IPC channel window:setImmersive that calls
BrowserWindow.setWindowButtonVisibility, exposed through the existing
desktopAPI preload bridge. A small useImmersiveMode() hook in
@multica/views/platform toggles it for the component's lifetime and
is a no-op on web / non-macOS.

CreateWorkspaceModal now:
- calls useImmersiveMode() so traffic lights disappear while it's open
- adds a transparent top h-10 drag strip to restore window dragging
- moves the Back button from top-6 left-6 to top-12 left-12 with an
  explicit no-drag region so clicks always reach it

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-14 19:41:49 +08:00
Naiyuan Qing
7ade4b432d fix(desktop): pad main top-bar when sidebar collapses so tabs don't sit under traffic lights
Extract the main-area top bar into a MainTopBar component so it can
read sidebar state via useSidebar(). When the sidebar is collapsed,
apply pl-20 (80px) to the drag header so the TabBar starts clear of
the macOS traffic-light hit-test region (~x=16..68) that always
wins over HTML clicks.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-14 19:41:38 +08:00
LinYushen
cbb2cf0c6c chore(desktop): rebuild CLI on every bundle-cli run (#999)
bundle-cli.mjs now invokes `go build` with the same ldflags as
`make build` (version/commit/date) before copying the binary into
resources/bin/. Running this on every `pnpm dev:desktop`, `dev:remote`
and `package` guarantees the bundled CLI matches the current Go source,
so you can't accidentally ship a stale binary after editing server/
code. Go's build cache makes no-op builds ~a few hundred ms.

Graceful fallback preserved: if `go` is not on PATH (frontend-only
contributor), we warn, skip the build, and let cli-bootstrap download
the latest release at runtime. Compile errors remain fatal so broken
Go code blocks dev rather than silently falling back.

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-14 19:33:39 +08:00
Naiyuan Qing
d94b704a71 Merge pull request #993 from multica-ai/feat/chat-reading-width
fix(chat): reading-width container + refresh placeholder on agent switch
2026-04-14 19:17:06 +08:00
Naiyuan Qing
76ba9cfb0b Merge pull request #995 from multica-ai/feat/chat-skeleton
feat(chat): skeleton while switching to an un-cached session
2026-04-14 19:16:48 +08:00
devv-eve
40aa23a528 feat(desktop): daemon management panel with sidebar status bar (#952)
* feat(desktop): add daemon management panel with sidebar status bar

Integrate multica daemon lifecycle management into the desktop app so
users can start/stop/restart the daemon and view live logs without
leaving the UI. Session tokens are automatically synced to the CLI
config file, making daemon authentication transparent.

- daemon-manager.ts: Electron main process module for daemon lifecycle
  (health polling, start/stop via CLI, token sync, log tail)
- Preload bridge: new daemonAPI with IPC for all daemon operations
- Sidebar bottomSlot: persistent daemon status indicator in sidebar
  footer (desktop-only, injected via AppSidebar slot)
- Daemon panel Sheet: right-side drawer with status details, controls,
  and real-time log viewer with auto-scroll and level coloring
- Token sync: on login and app startup, JWT is written to
  ~/.multica/config.json so daemon can authenticate seamlessly

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

* feat(desktop): add P1+P2 daemon features — runtimes card, auto-start, settings

P1: Runtimes page Local Daemon card
- Add topSlot prop to shared RuntimesPage for platform injection
- DaemonRuntimeCard shows status, agents, uptime with Start/Stop/
  Restart/Logs buttons (desktop-only, injected via slot)

P2: Auto-start and auto-stop
- Daemon auto-starts on app launch when user is authenticated
  (controlled by autoStart preference, default: true)
- Daemon auto-stops on app quit (controlled by autoStop preference,
  default: false — daemon keeps running in background by default)
- Preferences persisted to ~/.multica/desktop_prefs.json

P2: Daemon settings tab
- New "Daemon" tab in Settings > My Account section (desktop-only)
- Toggle auto-start and auto-stop behavior
- CLI installation status check with link to install guide
- SettingsPage gains extraAccountTabs prop for platform injection

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

* fix(desktop): address PR review feedback on daemon management

Must-fix:
- before-quit handler now calls event.preventDefault(), awaits
  stopDaemon(), then re-calls app.quit() so the daemon actually
  stops before the app exits
- Add concurrency guard (operationInProgress lock) in daemon-manager
  to reject overlapping start/stop/restart IPC calls
- Extract shared types (DaemonState, DaemonStatus, DaemonPrefs),
  constants (STATE_COLORS, STATE_LABELS), and formatUptime to
  apps/desktop/src/shared/daemon-types.ts — all renderer components
  now import from this single source

Should-fix:
- Log viewer uses monotonic counter (LogEntry.id) instead of array
  index as React key, preventing full re-renders on overflow
- All start/stop/restart handlers now show toast.error() with the
  error message when the operation fails
- startLogTail retries up to 5 times with 2s delay when the log
  file doesn't exist yet (handles first-run case)

Minor:
- Cache findCliBinary() result after first successful lookup

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

* fix(logger): suppress ANSI color codes when stderr is not a TTY

Detect whether stderr is connected to a terminal and set tint's NoColor
option accordingly. Previously daemon.log files contained raw escape
sequences like \033[2m and \033[92m which made them unreadable in the
Desktop log viewer and any non-TTY sink (docker logs, systemd, etc).

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

* feat(daemon): runtime watch/unwatch HTTP endpoints and denylist

Add GET/POST/DELETE /watch handlers on the daemon's health port so
clients (notably Desktop) can add or remove watched workspaces at
runtime without restarting the daemon or editing config.json. Each
handler updates in-memory state under d.mu and persists back to
~/.multica/profiles/<name>/config.json for survival across restarts.

- CLIConfig gains UnwatchedWorkspaces as an explicit opt-out denylist.
  syncWorkspacesFromAPI skips entries in the denylist so a manual
  unwatch isn't silently revived 30s later by the periodic sync.
- loadWatchedWorkspaces tolerates an empty config and returns nil
  instead of erroring out, because Desktop starts daemons with a
  fresh profile and relies on the sync loop / watch endpoint to
  populate the list.

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

* feat(desktop): bundled CLI, per-backend profile, and watch UI

Make the Desktop app self-sufficient: it bundles its own multica
binary, manages its own daemon profile keyed by the backend URL, and
authenticates that daemon with a long-lived PAT it mints on first
login. The daemon panel gains a checkbox list of watched workspaces
and surfaces the active profile + server URL.

CLI bootstrap
- scripts/bundle-cli.mjs copies server/bin/multica into
  apps/desktop/resources/bin/ before electron-vite dev and
  electron-builder package. asarUnpack: resources/** already covers
  this path, so the binary ships with the .app in prod.
- main/cli-bootstrap.ts adds an ensureManagedCli() fallback that
  downloads the latest release from GitHub when no bundled binary
  exists (first launch on a machine without developer tooling).
- daemon-manager.resolveCliBinary prefers bundled > managed > download
  > PATH, so local iteration uses the freshly built binary.

Daemon profile
- resolveActiveProfile now derives a desktop-<host> profile name from
  the target API URL and creates its config.json on demand. Never
  reads or writes the user's hand-configured CLI profiles, avoiding
  the "Desktop polluted my default profile" class of bug.
- syncToken detects a JWT input and exchanges it for a PAT via
  POST /api/tokens; caches the resulting mul_* token in the profile
  config so subsequent launches skip the round-trip.
- startDaemon / stopDaemon / log tail all operate on the resolved
  profile; renderer sets the target URL via a new
  daemon:set-target-api-url IPC.

Workspace watching
- daemon-manager exposes daemon:list-watched / daemon:watch-workspace /
  daemon:unwatch-workspace IPCs backed by the daemon's new /watch
  endpoints.
- App.tsx reconciles the user's workspace list against the daemon's
  watched set whenever TanStack Query updates it — new workspaces are
  registered instantly instead of waiting for the daemon's 30s sync,
  and removed workspaces are unwatched.
- daemon-panel gains a "Watched Workspaces" section with per-workspace
  checkboxes that call watch/unwatch directly. Opt-outs persist in the
  profile's unwatched_workspaces denylist.

Lifecycle states + UI
- DaemonStatus gains `profile`, `serverUrl`, and an `installing_cli`
  state. Panel shows Profile / Server info rows and a "Setting up…"
  blurb during first-run CLI download; failure surfaces a Retry button.
- Status bar renders a spinner during installation and hides the Start
  button until setup finishes.

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

* fix(desktop): register /onboarding route

The create-workspace modal navigates to /onboarding on success, but
the Desktop router only had flat routes (issues, projects, runtimes,
etc.) — resulting in an "Unexpected Application Error! 404 Not Found"
page after creating a new workspace.

Mirror the web app's wiring: render OnboardingWizard with onComplete
pushing to /issues, via the shared navigation adapter.

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

* refactor(desktop): remove sidebar daemon status bar

Drop the bottom-left daemon indicator in favor of the DaemonRuntimeCard
at the top of the Runtimes page, which already shows the same info
plus full Start/Stop/Restart controls and the Logs entry point. A
single canonical place avoids fragmenting daemon status across the UI.

Also remove the now-unused `bottomSlot` prop from AppSidebar — Desktop
was the only consumer, Web never needed it, so keeping it would be
dead scaffolding.

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

* fix(desktop): daemon panel layout and close button

- Logs section now fills the remaining vertical space down to the
  sheet bottom instead of being capped at h-64, which left a huge
  empty area below it. Top section (status, actions, watched list)
  keeps natural height as shrink-0; the watched list gets its own
  max-h-48 scroll so a long list can't push Logs off screen.
- Replace the Sheet's built-in close button with an explicit
  <button> wired directly to onOpenChange(false). The Base UI
  Dialog.Close wrapped in Button via the render prop wasn't firing
  on click in this panel; going straight through the controlled
  state guarantees it responds.

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

* fix(desktop): make daemon panel clickable inside Electron drag region

The sheet opens at the top of the window, which visually overlaps the
TabBar's -webkit-app-region: drag zone. Even though the sheet portals
to document.body, Chromium computes drag regions over the final
composited pixels, so the sheet inherited "drag" and swallowed the
mouseup of every click (mousedown fired but click never resolved) —
including the X close button.

Mark the entire SheetContent popup with -webkit-app-region: no-drag
to subtract it from the drag region. This also fixes future buttons /
checkboxes inside the sheet that would have hit the same issue.

While here, move the close button into the SheetHeader as a flex
sibling of SheetTitle instead of an absolutely positioned overlay —
simpler layout and avoids any stacking-context weirdness.

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

* feat(desktop): clickable daemon runtime card row

The whole Local Daemon row now opens the sheet panel — icon, title,
and status line are all part of one click target. This replaces the
standalone "Logs" button, which was redundant now that clicking
anywhere on the row does the same thing.

The right-side action cluster (Start / Stop / Restart) wraps its
onClick in stopPropagation so pressing those buttons doesn't bubble
up and open the panel.

Keyboard access: Enter / Space on the focused row opens the panel,
with a focus-visible background for feedback.

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

* feat(runtimes): mark Desktop-launched daemons as managed

When the Multica Desktop app spawns the CLI it ships with, the
resulting daemon shares its binary with the Electron bundle — Desktop
is responsible for updating that binary on every release. Letting the
daemon self-update would just get clobbered on the next Desktop launch
and could brick the embedded binary mid-update.

Propagate a "launched_by" signal end-to-end so the UI can hide the
CLI self-update affordance (and the daemon refuses updates as a second
line of defense):

- Desktop's startDaemon spawns execFile with env MULTICA_LAUNCHED_BY=desktop.
- daemon.Config gains LaunchedBy; cmd_daemon reads the env var on boot.
- registerRuntimesForWorkspace includes launched_by in the request body.
- Server DaemonRegister folds launched_by into runtime.metadata (JSONB
  — no migration needed).
- handleUpdate returns a "failed" status with an explanatory message
  when LaunchedBy == "desktop", so even a bypass API call can't trigger
  the self-update path.
- RuntimeDetail extracts metadata.launched_by and passes it to
  UpdateSection, which swaps the Latest / → available / Update button
  cluster for a muted "Managed by Desktop" label.

CLI-only users (brew install, direct tarball) keep the exact same
behavior — the env var is empty, the UI shows the update button,
the daemon still self-updates on request.

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

* fix(desktop): harden daemon manager from PR review

- syncToken now takes userId and mints a fresh PAT on user switch,
  restarting a running daemon so it picks up the new credentials.
  A .desktop-user-id sidecar in each profile records the owner so a
  previous user's cached PAT can't be reused on the next login.
- App.tsx wires onLogout on CoreProvider to daemonAPI.clearToken()
  and daemonAPI.stop() so the cached PAT and live daemon don't
  outlive the session.
- startLogTail replaced with a cross-platform watchFile
  implementation (initial 32 KB window + poll for new bytes,
  handles truncation). spawn("tail") was broken on Windows.
- writeProfileConfig now serializes through a promise chain to
  prevent concurrent writes from corrupting config.json.
- startDaemon keeps the "starting" state until pollOnce confirms
  /health, avoiding a running → stopped flash when the Go daemon
  isn't yet listening after the supervisor returns.

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

* fix(desktop): verify downloaded CLI against checksums.txt

Download goreleaser's checksums.txt alongside the release archive,
parse the sha256 lookup, stream the archive through createHash, and
refuse to install on mismatch or missing entry. Closes the supply-
chain gap where auto-install would execute an unverified binary on
first launch.

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

* chore(desktop): lint and style cleanups from PR review

- eslint.config.mjs: add scripts/**/*.{mjs,js} override with
  globals.node so bundle-cli.mjs lints clean (was erroring on
  undefined process/console).
- daemon-panel.tsx: log level classes now use semantic tokens
  (text-info, text-warning, text-destructive) instead of hardcoded
  Tailwind colors; escape the apostrophe in the retry copy.
- daemon-settings-tab.tsx: import DaemonPrefs from shared/daemon-
  types instead of redefining it.
- runtimes-page.tsx: fix indentation inside the new topSlot wrapper.

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

---------

Co-authored-by: Devv <devv@Devvs-Mac-mini.local>
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-authored-by: yushen <ldnvnbl@gmail.com>
2026-04-14 19:12:39 +08:00
Naiyuan Qing
d3f7570177 feat(chat): skeleton while switching to an un-cached session
Switching to a session whose messages aren't cached showed the empty
state (starter prompts) for the ~300ms the fetch took — jarring, because
you're clicking into an existing conversation, not starting a new one.

Now there's a three-branch render: skeleton while loading, empty state
for real new-chat (activeSessionId === null), messages when ready.
Cached switches still return data synchronously — no flash.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-14 18:49:26 +08:00
Naiyuan Qing
34e452776b fix(chat): reading-width container + refresh placeholder on agent switch
- Wrap ChatMessageList and ChatInput in mx-auto max-w-4xl px-5 so wide
  chat windows don't sprawl — matches the issue-detail / project-detail
  width convention
- draftKey now includes the selected agent id in the new-chat state.
  Tiptap's Placeholder only applies at mount, so key-driven remount is
  the simplest way to refresh it when the user switches agents before
  sending the first message. Side benefit: per-agent new-chat drafts.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-14 18:42:55 +08:00
Bohan Jiang
2551aa53ef fix(docs): use light theme for Star History chart in dark mode (#992)
* docs: add Trendshift GitHub Trending badge to READMEs

Add dynamic GitHub Trending badge from Trendshift.io (repo ID 24695)
to both English and Chinese READMEs, placed below existing CI/stars
badges.

* docs: replace Trendshift badge with Star History chart

Remove the Trendshift trending badge and add a Star History chart
section at the end of both English and Chinese READMEs. The chart
supports dark/light mode and links to the interactive star-history page.

* fix(docs): use light theme for Star History chart in both color schemes

Remove &theme=dark from the dark mode source so the chart always
renders with a light background regardless of GitHub's color scheme.
2026-04-14 18:34:35 +08:00
Naiyuan Qing
d779cbd183 Merge pull request #990 from multica-ai/NevilleQingNY/fix-cmdk-stale-status
fix(views): resolve stale status in cmd+k recent issues list
2026-04-14 18:26:28 +08:00
Naiyuan Qing
10b6afc1ec Merge pull request #988 from multica-ai/feat/chat-redesign
feat(chat): redesign state, header, and unread tracking
2026-04-14 18:24:20 +08:00
Bohan Jiang
4f58f0c8eb docs: add Star History chart to READMEs (#989)
* docs: add Trendshift GitHub Trending badge to READMEs

Add dynamic GitHub Trending badge from Trendshift.io (repo ID 24695)
to both English and Chinese READMEs, placed below existing CI/stars
badges.

* docs: replace Trendshift badge with Star History chart

Remove the Trendshift trending badge and add a Star History chart
section at the end of both English and Chinese READMEs. The chart
supports dark/light mode and links to the interactive star-history page.
2026-04-14 18:24:15 +08:00
Naiyuan Qing
0399e387f8 fix(views): resolve stale status in cmd+k recent issues list
Recent issues store was duplicating server data (title, status, identifier)
in Zustand, violating the single-source-of-truth architecture. Now the store
only tracks visit records (id + visitedAt), and the search command joins
fresh data from the TanStack Query issue list cache at render time.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-14 18:23:27 +08:00
Naiyuan Qing
a744cd4f45 feat(chat): redesign state, header, and unread tracking
State management
- Pending task / live timeline are now Query-cache single source;
  Zustand mirror removed (fixes duplicate assistant render caused by
  the invalidate→refetch race window)
- WS subscriptions moved from ChatWindow to global useRealtimeSync so
  pending state survives minimize and refresh
- New GET /chat/sessions/:id/pending-task to recover live state on mount
- Drafts persisted per-session (was per-workspace)

Unread tracking
- Migration 040: chat_session.unread_since (event-driven; old chats
  stay clean — no mass backfill)
- POST /chat/sessions/:id/read clears unread; broadcasts
  chat:session_read so other devices sync
- New GET /chat/pending-tasks aggregate for the FAB
- ChatFab: brand-color impulse animation while running, brand-dot
  badge of unread session count
- ChatWindow auto-marks read when user is viewing the session

Header redesign
- Two independent dropdowns: agent (avatar + name + My/Others
  grouping) at the input bottom-left; session (title + agent avatar)
  in the header
- ⊕ new-chat button replaces the old + and history buttons
- Session dropdown lists all sessions across agents with avatars
- Empty state: 3 clickable starter prompts that send immediately
- Mention link renderer falls through to default span on null —
  fixes @member/@agent/@all silently disappearing app-wide
- User messages render through Markdown
- Enter submits in chat input only (with IME guard + codeBlock skip);
  bubble menu hidden in chat

Misc
- Partial index on agent_task_queue for fast pending-task lookup
- 2 new storage keys added to clearWorkspaceStorage
- useMarkChatSessionRead has onError rollback
- chat.* namespace logs across store, mutations, components, realtime

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-14 18:21:11 +08:00
Jiayuan Zhang
bfa9bec8c4 docs: add Multica vs Paperclip comparison to README (#980) 2026-04-14 18:06:44 +08:00
Bohan Jiang
bf71802451 fix(server): trigger agent on reply in thread where agent already participated (#981)
When a member replies in a member-started thread without @mentioning the
assigned agent, the on_comment trigger was suppressed — even if the agent
had already replied in that thread. This meant the common flow of
"member posts → agent replies → member follows up" would not re-trigger
the agent on the follow-up.

Add HasAgentRepliedInThread SQL query and check it in isReplyToMemberThread
so that agent participation in a thread is treated as an ongoing conversation.
2026-04-14 18:00:29 +08:00
Jiayuan Zhang
09e6190400 fix(cli): use localhost for CLI callback when app URL is a public hostname (#977)
When `multica login` runs against production (multica.ai), the CLI was
using the app URL hostname as the callback host, producing a callback
URL like `http://multica.ai:PORT/callback`. This URL fails frontend
validation (which only allows localhost and private IPs) and can't
actually reach the CLI's local HTTP server.

Now only private IPs (RFC 1918) are used as the callback host, which
matches the intended self-hosted LAN scenario. Public hostnames
correctly fall back to localhost.

Fixes #974
2026-04-14 17:54:10 +08:00
Naiyuan Qing
0798b5f8bb Merge pull request #982 from multica-ai/agent/agent/85c11509
fix(views): prevent focus jump on modal close
2026-04-14 17:53:51 +08:00
Naiyuan Qing
e568896357 fix(views): prevent focus jumping to random button when closing store-opened modals
Add finalFocus={false} to DialogContent in create-issue and create-workspace
modals so Base UI does not attempt focus restoration on close. These modals
are always opened programmatically via useModalStore (no trigger element),
so there is no meaningful element to return focus to.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-14 17:52:16 +08:00
Bohan Jiang
8748557c7b fix: clarify local vs workspace skills to reduce user confusion (#975)
* fix: clarify that local skills work automatically, workspace skills are for team sharing

Users were confused, thinking they needed to re-upload locally installed skills
(e.g. .claude/skills/) to Multica before agents could use them. Updated UI text
across the skills page, agent skills tab, onboarding, and docs to clearly
distinguish between local skills (auto-discovered) and workspace skills (for
team-wide sharing).

Closes #972

* feat(views): replace inline text with info banner for local skills hint

The "local runtime skills are always available" message was buried in
the description text and easy to miss. Move it into a visible info
callout banner with an icon so users notice it immediately.
2026-04-14 17:49:34 +08:00
Bohan Jiang
7f0c23a6ba Merge pull request #960 from blackhu0804/test/cli-client-context-headers
test(cli): cover API client context headers
2026-04-14 17:14:12 +08:00
Bohan Jiang
e6767d2ba3 Merge pull request #968 from multica-ai/agent/j/0ae3c9f0
docs: add manual testing checklist to PR template
2026-04-14 17:06:30 +08:00
Bohan Jiang
1ceb75e218 Update PULL_REQUEST_TEMPLATE.md 2026-04-14 17:06:13 +08:00
Jiang Bohan
9138c05993 docs: revise PR checklist to match Paperclip-style format
Replace the original checklist + manual testing section with a
unified checklist modeled after the Paperclip open-source project:
thinking path, model disclosure, local tests, test coverage,
UI screenshots, documentation, risk assessment, and reviewer comments.
2026-04-14 17:04:18 +08:00
Naiyuan Qing
091ed7370a Merge pull request #953 from multica-ai/fix/editor-bubble-menu-v2
fix(editor): fix BubbleMenu dropdown clicks by replacing DropdownMenu with Popover
2026-04-14 16:47:18 +08:00
Naiyuan Qing
35557c0b11 fix(test): add missing selection mock in ContentEditor test
The merge from main introduced `editor?.state.selection.empty` in
ContentEditor. The test mock was missing `state.selection`, causing
a TypeError.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-14 16:44:35 +08:00
Naiyuan Qing
03ad47200b merge: resolve conflict with main (accept link-preview.tsx deletion)
# Conflicts:
#	packages/views/editor/link-preview.tsx
2026-04-14 16:39:24 +08:00
Bohan Jiang
93b754de53 Merge pull request #969 from multica-ai/agent/j/696a5ce1
docs: add v0.1.33 changelog (2026-04-14)
2026-04-14 16:37:08 +08:00
Jiang Bohan
609d2e06ae docs: remove desktop auto-update from v0.1.33 changelog 2026-04-14 16:35:59 +08:00
Jiang Bohan
7c436c0dcb docs: add v0.1.33 changelog entry (2026-04-14) 2026-04-14 16:33:41 +08:00
Naiyuan Qing
55ae78b902 fix(editor): replace DropdownMenu with Popover in BubbleMenu to fix focus
Base UI's DropdownMenu uses FloatingFocusManager which steals focus from
the editor (initialFocus + closeOnFocusOut), causing the BubbleMenu to
hide before dropdown item clicks can register. Popover supports
initialFocus={false} and finalFocus={false}, keeping editor focus intact
throughout the interaction.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-14 16:32:02 +08:00
Jiang Bohan
cc00fda513 docs: add manual testing/acceptance checklist to PR template
Adds a new "Manual Testing / Acceptance" section to the PR template
with checklist items for verifying changes in a real environment:
happy path, edge cases, visual regressions, cross-platform testing,
API consumer compatibility, and log inspection.
2026-04-14 16:25:55 +08:00
Bohan Jiang
04e571b02f Merge pull request #964 from multica-ai/feat/agent-env-tab
feat(views): extract environment variables into separate agent tab
2026-04-14 16:18:12 +08:00
Jiang Bohan
c62bd0ca12 feat(views): extract environment variables into separate agent tab
Move the Environment Variables section from the Settings tab into its
own "Environment" tab (KeyRound icon) between Tasks and Settings. Each
tab now has independent save state.
2026-04-14 16:06:06 +08:00
Bohan Jiang
51c7dbbeee Merge pull request #962 from multica-ai/fix/editor-link-preview-mount-crash
fix(editor): avoid accessing editor.view during initial render in link preview
2026-04-14 15:48:38 +08:00
Jiang Bohan
46d745cb60 fix(editor): avoid accessing editor.view during initial render in link preview
EditorLinkPreview's useRef initializer accessed editor.view?.dom which
throws when the editor view is not yet mounted (Tiptap uses a Proxy
that rejects property access before mount). Defer the contextElement
assignment to the selectionUpdate callback where the view is guaranteed
to exist.
2026-04-14 15:47:52 +08:00
Bohan Jiang
0a998d1cef Merge pull request #846 from multica-ai/agent/j/feb218fd
feat(agent): support custom environment variables for router/proxy mode
2026-04-14 15:34:21 +08:00
Bohan Jiang
a366984014 Merge pull request #961 from multica-ai/fix/comment-trigger-new-tag
fix(daemon): emphasize NEW comment in trigger prompt to prevent session confusion
2026-04-14 15:27:43 +08:00
Jiang Bohan
9ba9ea66f8 fix(daemon): emphasize NEW comment in trigger prompt to prevent session confusion
When a comment-triggered task resumes an existing session, the agent
may mistake the new comment for a previous one and skip it. Add [NEW
COMMENT] tag to the prompt and reinforce in AGENTS.md workflow that
the agent must respond to THIS specific comment, not prior ones.
2026-04-14 15:26:49 +08:00
Bohan Jiang
2be6fdae90 Merge pull request #956 from yyy9942/fix/cancel-task-race-condition
fix(server): handle cancel request for already-completed tasks gracefully
2026-04-14 15:20:44 +08:00
Bohan Jiang
653c0adeee Merge pull request #959 from multica-ai/agent/j/e52c9eda
fix(views): issue mentions missing status/title after page refresh
2026-04-14 15:18:06 +08:00
yyy9942
4458753102 fix(server): handle cancel request for already-completed tasks gracefully
When a task finishes between the UI rendering the Stop button and the
user clicking it, CancelAgentTask returns no rows. Previously this
surfaced as a 400 error. Now CancelTask checks for pgx.ErrNoRows and
returns the current task state instead of an error.

Closes #954
2026-04-14 16:15:22 +09:00
black-fe
3c0ed0f732 test(cli): cover API client context headers 2026-04-14 15:13:54 +08:00
Naiyuan Qing
999d0728c5 fix(editor): remove preventDefault from dropdown triggers in BubbleMenu
The onMouseDown preventDefault on HeadingDropdown and ListDropdown
triggers was interfering with Base UI's menu event flow, causing:
- Dropdown appearing at top-left corner (positioning mismatch)
- Menu item clicks not applying formatting

The BubbleMenu plugin's own preventHide mechanism (capture-phase
mousedown listener) already handles preventing the menu from hiding
during dropdown interaction. Our extra preventDefault was redundant
and conflicting.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-14 15:13:32 +08:00
Jiang Bohan
b6a69c113e fix(views): fetch individual issue for mentions not in list cache
Issue mentions in comments showed only the identifier (no status icon
or title) after page refresh when the referenced issue wasn't in the
issueListOptions cache (e.g. done issues beyond the first 50).

Fall back to issueDetailOptions to fetch the individual issue when it's
not found in the list. The detail query is only enabled when the issue
is missing from the list, so it adds no overhead for the common case.
2026-04-14 15:03:42 +08:00
Bohan Jiang
7995f7368f Merge pull request #957 from multica-ai/agent/j/9ecd3271
fix(issues): include done issues in parent/sub-issue picker
2026-04-14 14:56:47 +08:00
Jiang Bohan
ed1a1dc6b1 fix(issues): include done/cancelled issues in parent/sub-issue picker search
The IssuePickerDialog was not passing include_closed: true to searchIssues,
so done and cancelled issues were invisible in the picker.
2026-04-14 14:55:42 +08:00
Naiyuan Qing
97755ae45d feat(editor): add link hover card with URL preview and actions
Show a floating card on link hover with truncated URL, Copy and Open
buttons. Uses @floating-ui/dom computePosition portaled to body
(escapes overflow:hidden). 300ms show delay, 150ms hide delay with
card hover support.

- New link-hover-card.tsx with useLinkHover hook + LinkHoverCard
- Integrated in ContentEditor (disabled when BubbleMenu active)
- Integrated in ReadonlyContent (always active)
- Styled with popover design tokens (matches bubble-menu)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-14 14:53:32 +08:00
Bohan Jiang
7a896d3852 Merge pull request #897 from multica-ai/agent/j/177ad75f
fix(openclaw): handle pretty-printed multi-line JSON output
2026-04-14 14:49:04 +08:00
Bohan Jiang
da63165cdc Merge pull request #955 from multica-ai/agent/j/3c269006
fix(issues): update UI immediately when parent/sub-issue changes
2026-04-14 14:48:38 +08:00
Jiang Bohan
013584ef80 fix(issues): invalidate new parent's children cache on parent_issue_id change
Both the useUpdateIssue mutation and the WS onIssueUpdated handler only
invalidated the OLD parent's children query. When parent_issue_id changes,
the new parent's sub-issues list was stale until page refresh.
2026-04-14 14:46:55 +08:00
Jiang Bohan
bb4944bae2 fix(openclaw): handle pretty-printed multi-line JSON output
OpenClaw outputs its --json result as pretty-printed multi-line JSON to
stderr. The line-by-line scanner never found a valid JSON object on any
single line, causing the raw JSON to be returned as the chat response.

After exhausting line-by-line parsing, try parsing the accumulated
output as a whole before falling back to raw text.

Closes MUL-725
2026-04-14 14:39:32 +08:00
Bohan Jiang
42e392c727 Merge pull request #950 from multica-ai/agent/j/6b9aa53b
feat(cli): add --parent flag to issue update command
2026-04-14 14:37:12 +08:00
Bohan Jiang
158a100779 Merge pull request #949 from multica-ai/agent/j/73a6b30b
feat(issues): add parent/sub-issue linking via More menu
2026-04-14 14:36:59 +08:00
Naiyuan Qing
e178682acd fix(editor): use native BubbleMenu and simplify link click
BubbleMenu:
- Replace custom useFloating + createPortal with Tiptap's native
  <BubbleMenu> component (battle-tested focus management)
- Add scrollTarget (auto-detect nearest scroll container) so the
  plugin repositions on scroll
- Add scroll-aware display:none for nested container clipping
  (plugin's hide middleware can't detect it — virtual element has
  no contextElement)
- Add .trim() to textBetween check to filter whitespace-only selections
- Enable hide middleware for viewport-level hiding

Link click:
- Both editable and readonly modes now open links directly
- Remove EditorLinkPreview component and link-preview.tsx entirely
- ReadonlyContent links use same direct-open pattern

Cleanup:
- Delete link-preview.tsx (not needed)
- Remove @floating-ui/react-dom dependency
- Remove .link-preview-card CSS
- Add @tiptap/extension-bubble-menu dependency

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-14 14:34:52 +08:00
Bohan Jiang
8779db976c Merge pull request #948 from multica-ai/agent/j/400a618f
feat(agent): add live log support for Gemini CLI
2026-04-14 14:31:52 +08:00
Jiang Bohan
eba68c15fd feat(cli): add --parent flag to issue update command
Allows setting or clearing an issue's parent via the CLI:
  multica issue update <id> --parent <parent-id>
  multica issue update <id> --parent ""  # clear parent
2026-04-14 14:24:19 +08:00
Jiang Bohan
345cb984a9 feat(issues): add "Set parent issue" and "Add sub-issue" to More menu
Add two new options to the issue detail More dropdown that let users
link existing issues as parent or sub-issue via a search dialog.
2026-04-14 14:20:48 +08:00
Jiang Bohan
f3355049bc feat(agent): add live log support for Gemini CLI via stream-json
Switch Gemini backend from `-o text` (batch output) to `-o stream-json`
(NDJSON streaming) so tool calls, text, and errors are forwarded to the
UI in real time instead of collected at the end.

Parses all Gemini stream-json event types: init, message, tool_use,
tool_result, error, and result — including per-model token usage from
the result stats.
2026-04-14 14:17:13 +08:00
Naiyuan Qing
dca86acc69 Merge pull request #938 from 1WorldCapture/fix/lyo-7-description-click-focus
fix(views): focus description editor when clicking empty area
2026-04-14 14:06:32 +08:00
Bohan Jiang
c71525e198 Merge pull request #910 from multica-ai/agent/j/openclaw-p0-p1
feat(agent): OpenClaw backend P0+P1 improvements
2026-04-14 14:02:38 +08:00
devv-eve
977dc6479d fix(daemon): prevent task stall when agent process hangs on stdout (#947)
When an agent CLI process hangs (e.g. a tool call blocks on unreachable
I/O), the daemon's scanner blocks indefinitely on stdout, preventing the
Result from ever being sent. This causes tasks to stay in "running"
state permanently with no further events.

Three-layer fix:

1. Agent backends (claude, opencode, openclaw, gemini): add a watchdog
   goroutine that closes the stdout/stderr pipe when the context is
   cancelled, forcing the scanner to unblock. Also set cmd.WaitDelay
   so Go force-closes pipes after 10s if the process doesn't exit.

2. daemon executeAndDrain: add an independent drain timeout (backend
   timeout + 30s buffer) with context-aware select on both the message
   channel and the result channel, so the daemon never blocks forever.

3. daemon ping path: add context-aware select so pings don't deadlock
   if the agent backend stalls.

Closes #925

Co-authored-by: Devv <devv@Devvs-Mac-mini.local>
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-13 23:00:27 -07:00
Jiayuan Zhang
a97bd3da0b fix(auth): support non-localhost CLI callback for self-hosted VMs (#944)
The CLI auth callback was hardcoded to localhost, breaking self-hosted
setups where the browser runs on a different machine than the CLI.

- CLI: derive callback host from configured app URL; bind to 0.0.0.0
  when the app URL is not localhost so remote browsers can reach it
- Frontend: expand validateCliCallback to accept RFC 1918 private IPs
  (10.x, 172.16-31.x, 192.168.x) in addition to localhost

Closes #923
2026-04-14 13:50:02 +08:00
Jiayuan Zhang
9dfe119f47 fix(daemon): use runtime's owner_id for agent migration on upgrade (#941)
* fix(daemon): prevent duplicate runtime registration on profile switch

The daemon_id included a profile name suffix (e.g. "hostname-staging"),
so switching profiles created a new daemon_id that bypassed the UPSERT
dedup constraint, leaving orphaned runtime records in the database.

Three changes:
- Remove profile suffix from daemon_id — use stable hostname only.
  The unique constraint (workspace_id, daemon_id, provider) already
  prevents collisions within the same workspace.
- Auto-migrate agents from old offline runtimes to the newly registered
  runtime during DaemonRegister (same workspace/provider/owner).
- Add TTL-based GC in the runtime sweeper to delete offline runtimes
  with no active agents after 7 days.

Closes MUL-695

* fix(daemon): address code review issues on PR #906

1. Move gcRuntimes() to the main sweep loop — previously it was inside
   sweepStaleRuntimes() after an early return, so it only ran when new
   runtimes were marked stale. Now it runs every sweep cycle independently.

2. Fix DeleteStaleOfflineRuntimes to exclude runtimes with ANY agent
   reference (not just active ones). The FK agent.runtime_id is ON DELETE
   RESTRICT, so archived agents also block deletion.

3. Scope MigrateAgentsToRuntime to the same machine by matching
   daemon_id LIKE '<current_daemon_id>-%'. This prevents cross-machine
   agent migration when the same user has multiple devices.

* fix(daemon): use runtime's owner_id for agent migration, not caller's

The migration was gated on ownerID.Valid which is only true for PAT/JWT
registrations. Daemon token registrations (the common case for background
daemon restarts) had ownerID as zero, skipping migration entirely.

Fix: use registered.OwnerID (preserved via COALESCE on upsert) instead
of the caller's ownerID. This ensures migration runs even when the daemon
re-registers via daemon token after an upgrade.
2026-04-14 13:42:27 +08:00
Bohan Jiang
f2efd4b529 Merge pull request #942 from multica-ai/agent/j/e9dce818
fix(cli): fix Windows login requiring two attempts
2026-04-14 13:19:46 +08:00
Jiang Bohan
a1de20e971 fix(cli): fix Windows login requiring two attempts
On Windows, `cmd /c start <url>` treats `&` in the URL as a shell
command separator, truncating the login URL at the first `&cli_state=`
parameter. This causes the OAuth state validation to fail silently,
requiring users to login a second time.

Adding an empty title argument (`""`) before the URL is the standard
Windows fix — `start` interprets the first quoted argument as a window
title, so without it, URLs containing special characters get mangled.
2026-04-14 13:10:28 +08:00
Jiayuan Zhang
27d0865f5f Merge pull request #920 from sanjay3290/fix/gemini-timeout-status
fix(daemon): correct Gemini backend status on timeout and cancellation
2026-04-14 13:03:17 +08:00
Bohan Jiang
2cd6024851 Merge pull request #820 from zoharbabin/feat/local-storage-and-stdin
feat(cli): add --content-stdin flag to issue comment add
2026-04-14 13:02:01 +08:00
Bohan Jiang
5e74c411dc fix(server): cancel active tasks when issue status changes to cancelled (#940)
When a user cancels an issue, active agent tasks now get cancelled
automatically. Previously, task cancellation only triggered on assignee
changes — the cancelled status was incorrectly treated like any other
agent-managed status transition.

Closes #926
2026-04-14 12:53:45 +08:00
Lyon Liang
418049856f merge: resolve conflicts with upstream/main 2026-04-14 12:49:40 +08:00
Naiyuan Qing
00042c0ec7 Merge pull request #932 from multica-ai/NevilleQingNY/weekly-commit-analysis
fix(editor): rewrite bubble menu, link handling, and link preview cards
2026-04-14 12:03:17 +08:00
devv-eve
7c7d7feed3 fix(storage): scope S3 upload keys by workspace (#936)
* fix(storage): scope S3 upload keys by workspace

Upload keys now use `workspaces/{workspace_id}/{uuid}.{ext}` instead of
flat `{uuid}.{ext}`, isolating file storage per workspace. Files uploaded
without workspace context (e.g. avatars) keep the flat key structure.

Refs: MUL-577

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

* fix(storage): scope user uploads under users/{user_id}/ prefix

Non-workspace uploads (avatars, profile images) now use
`users/{user_id}/{uuid}.{ext}` instead of flat `{uuid}.{ext}`,
matching the workspace-scoped pattern from the previous commit.

Refs: MUL-577

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

* fix(storage): fix LocalStorage for nested key paths

- Add MkdirAll before WriteFile to create intermediate directories
  for workspace/user-scoped keys
- Fix KeyFromURL to preserve full path after /uploads/ prefix instead
  of stripping to just the filename
- Update tests to match new behavior

Refs: MUL-577

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

* fix(upload): validate ownership before writing to storage

Move Storage.Upload after issue_id/comment_id ownership validation
to prevent orphaned files in S3 when validation fails. Previously,
the file was uploaded first and validation happened after, leaving
files in workspace-scoped S3 prefixes even on rejected requests.

Refs: MUL-577

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

* fix(upload): restore workspace membership check before upload

The membership check was accidentally removed during the upload
reordering refactor. Without it, any authenticated user could upload
files to any workspace by setting the X-Workspace-ID header.

Also restores the comment explaining the 200-on-DB-error behavior.

Refs: MUL-577

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

---------

Co-authored-by: Devv <devv@Devvs-Mac-mini.local>
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-13 21:01:50 -07:00
Naiyuan Qing
6a451c1ce7 fix(editor): rewrite bubble menu and link preview with useFloating
Replace Tiptap's BubbleMenu plugin with @floating-ui/react-dom for
all floating editor UI (formatting toolbar, link preview cards).

Architecture:
- useFloating({ strategy:"fixed" }) + createPortal(body) escapes
  all overflow:hidden ancestors (Card component, scroll containers)
- autoUpdate + contextElement monitors all scroll ancestors for
  repositioning; manual update() on transaction for virtual ref changes
- open prop resets isPositioned on visibility change (no stale-position
  flash at 0,0)
- display:none for hiding (not return null which causes blur/focus
  cycle, not visibility:hidden which leaves transition artifacts)
- No blur listener — portal DOM updates cause false editor blurs;
  outside-click + scroll + resize + Escape handle all close cases

Bug fixes:
- BubbleMenu: remove all custom visibility hacks, let selection state
  drive show/hide
- Link preview: new shared card (Copy + Open) for editable editor and
  readonly markdown, portaled to body with fixed positioning
- TitleEditor: use JSON content format (not HTML interpolation that
  loses < > characters)
- Blob URLs: strip from getMarkdown output during upload
- Markdown paste: check clipboard.files first to avoid intercepting
  file paste events
- FileCard: escape HTML attributes in preprocessing
- Link extension: enable linkOnPaste, set defaultProtocol to https,
  switch URL normalization to protocol blocklist (only block
  javascript:/data:/vbscript:)

Dependencies: add @floating-ui/react-dom, remove @tiptap/extension-bubble-menu

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-14 12:00:17 +08:00
devv-eve
8c0708bb5d fix(server): validate workspace membership for subscriptions and uploads (#935)
* fix(server): validate workspace membership for subscription targets and file uploads

Closes MED-1 (cross-workspace subscription injection) and MED-2 (file upload
missing workspace member validation) from the security audit.

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

* test(server): add negative tests for cross-workspace subscription and upload

Address PR review feedback:
- Add tests verifying cross-workspace user_id is rejected with 403 on
  subscribe and unsubscribe
- Add test verifying upload with foreign workspace_id is rejected with 403
- Make isWorkspaceEntity explicitly enumerate "member"/"agent" and reject
  unknown user types

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

---------

Co-authored-by: Devv <devv@Devvs-Mac-mini.local>
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-13 20:22:03 -07:00
Lyon Liang
9170b01739 fix(views): focus description editor when clicking empty area 2026-04-14 11:04:58 +08:00
Zohar Babin
d37595b85e fix(cli): address review feedback on --content-stdin flag
- Make --content and --content-stdin mutually exclusive with explicit error
- Use TrimSuffix instead of TrimRight to only strip the trailing newline
- Return "stdin content is empty" instead of misleading "required" error

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-13 19:14:55 -04:00
Jiayuan Zhang
03310a581a doc: document Homebrew CLI installation (#921) 2026-04-14 05:31:10 +08:00
Sanjay Ramadugu
fe0d450471 fix(daemon): correct Gemini backend status on timeout and cancellation
Check runCtx.Err() before readErr/waitErr so that context-driven
process kills (timeout, user cancellation) report the correct status
("timeout" or "aborted") instead of "failed".

When exec.CommandContext kills the gemini process, io.ReadAll can
return a non-nil error as a side-effect of the closed pipe. The
previous code checked readErr first, masking the real cause. This
aligns gemini.go with the ordering already used in claude.go and
hermes.go.

Fixes #914
2026-04-13 16:30:28 -04:00
Jiayuan Zhang
bc1185f525 Merge pull request #755 from sanjay3290/feat/gemini-backend
feat(daemon): add Google Gemini CLI backend
2026-04-14 02:46:20 +08:00
Bohan Jiang
0d95a7c7ef fix(auth): increase email verification code resend cooldown to 60s (#912)
The 10-second cooldown was too short. Increase to 60 seconds in both
frontend countdown timer and backend rate limit.
2026-04-14 02:35:34 +08:00
KimSeongJun
8587243ab6 web: stabilize login and dashboard redirects (#900) 2026-04-14 02:26:24 +08:00
Bohan Jiang
740d8e773d Merge pull request #841 from multica-ai/agent/j/7bb4859f
feat(desktop): version detection + one-click update
2026-04-14 02:19:25 +08:00
Jiang Bohan
9550e6c4e0 fix(desktop): prevent double-click download and fix dismiss behavior
- Guard handleDownload to only trigger from "available" state
- Only allow dismiss when update is available, not during download/ready
- Use shadcn design tokens (text-success) instead of hardcoded colors
2026-04-14 02:18:55 +08:00
Jiayuan Zhang
880c614039 Merge pull request #905 from multica-ai/agent/emacs/3a193323
fix(issue): inherit parent project for sub-issues
2026-04-14 02:06:26 +08:00
Jiayuan Zhang
f1f693afa5 fix(cli): redirect to web onboarding when new user has no workspaces (#903)
* fix(cli): auto-create workspace for new users during setup

When a new user runs `multica setup` and has no workspaces,
the onboarding flow now auto-creates a default workspace
(named "<name>'s Workspace") instead of failing when the
daemon tries to start with zero watched workspaces.

As a safety net, setup commands also skip daemon start
gracefully if no workspaces are configured, instead of
erroring out.

* fix(cli): redirect to web onboarding instead of auto-creating workspace

When no workspaces exist, the CLI now opens the web onboarding wizard
in the browser and polls until the user completes workspace creation.
This reuses the existing 4-step onboarding flow (workspace → runtime →
agent → done) instead of duplicating creation logic in the CLI.

* fix(cli): address code review — token login crash and misleading success msg

1. Token login (`multica login --token`) on a fresh account no longer
   crashes: waitForOnboarding uses tryResolveAppURL (returns "" instead
   of os.Exit(1)) and falls back to printing manual instructions.

2. Setup commands no longer print "✓ Setup complete!" when onboarding
   was not finished. Shows "⚠ Setup incomplete" with next steps instead.
2026-04-14 02:04:53 +08:00
Jiang Bohan
c148288d5a merge: resolve conflicts with main (deep linking + auto-updater)
Integrate deep link protocol handling, desktopAPI, and auth token flow
from main alongside the auto-updater feature.
2026-04-14 02:02:00 +08:00
Jiayuan Zhang
ff5f6ac2ee fix(daemon): prevent duplicate runtime registration on profile switch (#906)
* fix(daemon): prevent duplicate runtime registration on profile switch

The daemon_id included a profile name suffix (e.g. "hostname-staging"),
so switching profiles created a new daemon_id that bypassed the UPSERT
dedup constraint, leaving orphaned runtime records in the database.

Three changes:
- Remove profile suffix from daemon_id — use stable hostname only.
  The unique constraint (workspace_id, daemon_id, provider) already
  prevents collisions within the same workspace.
- Auto-migrate agents from old offline runtimes to the newly registered
  runtime during DaemonRegister (same workspace/provider/owner).
- Add TTL-based GC in the runtime sweeper to delete offline runtimes
  with no active agents after 7 days.

Closes MUL-695

* fix(daemon): address code review issues on PR #906

1. Move gcRuntimes() to the main sweep loop — previously it was inside
   sweepStaleRuntimes() after an early return, so it only ran when new
   runtimes were marked stale. Now it runs every sweep cycle independently.

2. Fix DeleteStaleOfflineRuntimes to exclude runtimes with ANY agent
   reference (not just active ones). The FK agent.runtime_id is ON DELETE
   RESTRICT, so archived agents also block deletion.

3. Scope MigrateAgentsToRuntime to the same machine by matching
   daemon_id LIKE '<current_daemon_id>-%'. This prevents cross-machine
   agent migration when the same user has multiple devices.
2026-04-14 01:52:34 +08:00
Jiang Bohan
a0d43ca31a feat(agent): OpenClaw backend P0+P1 improvements
Combined P0 and P1 improvements to the OpenClaw agent backend, informed
by PaperClip's adapter architecture:

P0 — User experience:
- Streaming output — emit MessageText as NDJSON events arrive in real
  time, instead of waiting for the final result blob
- Tool use support — parse and emit MessageToolUse/MessageToolResult
  from streaming events, matching Claude and OpenCode backends
- Model & system prompt — pass --model and --system-prompt to the
  OpenClaw CLI when configured

P1 — Robustness:
- Hardened JSON parsing — tryParseOpenclawResult requires lines to
  start with '{', eliminating fragile brace-scanning that could
  false-match JSON fragments in log lines
- Lifecycle event handling — new "lifecycle" event type with phase
  tracking (error/failed/cancelled), plus structured error objects
  (error.name, error.data.message) matching PaperClip's pattern
- Usage field name variants — parseOpenclawUsage supports multiple
  naming conventions (input/inputTokens/input_tokens, cacheRead/
  cachedInputTokens/cache_read_input_tokens, etc.) with incremental
  accumulation across step_finish events

Backwards compatible with the legacy single JSON blob format.
31 tests covering all new functionality.

Closes MUL-726
2026-04-14 01:52:03 +08:00
Jiayuan Zhang
a29ecfe02a test(issue): cover explicit sub-issue project 2026-04-14 01:51:48 +08:00
Jiayuan Zhang
8d3cb21c03 fix(auth): detect cookie-based session during CLI setup flow (#904)
* fix(auth): detect cookie-based session during CLI setup flow

When users run `multica setup` after logging into multica.ai, the CLI
redirects to the login page which only checked localStorage for an
existing session. Since the web app stores auth tokens as HttpOnly
cookies (not localStorage), the session was never detected and users
had to log in again.

Now the login page also tries `api.getMe()` (which sends the HttpOnly
cookie automatically) when no localStorage token exists. A new
`POST /api/cli-token` endpoint lets cookie-authenticated sessions
obtain a bearer token to hand off to the CLI.

* fix(auth): prioritise cookie auth over localStorage in CLI setup flow

Address code review feedback: cookie-first detection avoids authorising
the CLI with a stale or mismatched localStorage token. The useEffect now
calls getMe() without a bearer token first (relying on the HttpOnly
cookie), and only falls back to localStorage if cookie auth fails.

handleCliAuthorize uses an authSourceRef to pick the matching token
source — issueCliToken for cookie sessions, localStorage for token
sessions — preventing the click handler from re-reading a potentially
stale localStorage entry.
2026-04-14 01:46:14 +08:00
Bohan Jiang
2b16cbb27a Merge pull request #908 from multica-ai/agent/j/8cd32f71
fix(selfhost): auto-derive WebSocket URL for LAN access
2026-04-14 01:45:01 +08:00
Jiang Bohan
a757f3a8c4 fix(selfhost): auto-derive WebSocket URL for LAN access (#896)
When NEXT_PUBLIC_WS_URL is not set, the WebSocket URL defaulted to
ws://localhost:8080/ws. This broke real-time features (chat streaming,
live updates, notifications) for self-hosted deployments accessed over
LAN — the browser tried connecting to localhost on the client machine
instead of the Docker host.

Now the web app derives the WebSocket URL from window.location, routing
through the existing Next.js /ws rewrite. This works for localhost, LAN,
and custom domain setups without any extra configuration.

Also adds NEXT_PUBLIC_WS_URL as a Docker build arg for explicit override,
and documents LAN access configuration in SELF_HOSTING_ADVANCED.md.

Closes #896
2026-04-14 01:42:42 +08:00
Jiayuan Zhang
56c38dc521 fix(issue): inherit parent project for sub-issues 2026-04-14 01:30:40 +08:00
Jiayuan Zhang
4bc9969765 fix(scripts): use fully qualified brew package name in install.sh (#901)
BREW_PACKAGE="multica-ai/tap/multica" was defined but never used.
All brew install/upgrade/list commands used the bare name "multica",
which could fail to resolve the correct tap formula. Replace all
occurrences with "$BREW_PACKAGE" to match the Go CLI (update.go)
and Makefile behavior.
2026-04-14 01:19:45 +08:00
Jiayuan Zhang
5b4ee7c5e1 fix(workspace): surface slug conflicts (#895) 2026-04-14 00:09:12 +08:00
Bohan Jiang
b2b909a90f Merge pull request #894 from multica-ai/agent/j/4ae97f0b
revert: handle control_request messages in claude backend (#811)
2026-04-13 23:16:03 +08:00
Jiang Bohan
bf5395f9ee Revert "fix: handle control_request messages in claude backend (auto-approve was dead code) (#811)"
This reverts commit 4d31b1ecee.
2026-04-13 23:11:36 +08:00
Jiayuan Zhang
cd92aad9e1 fix(workspace): auto-retry slug conflicts and show editable URL field (#892)
Workspace creation with duplicate slugs now auto-appends -2, -3, … on
the server side instead of returning 409. The onboarding wizard also
shows an editable Workspace URL field (multica.ai/<slug>) that
auto-generates from the name but can be manually customized.
2026-04-13 23:08:11 +08:00
Bohan Jiang
017f69c123 Merge pull request #881 from tabtablabs-dev/fix/claude-runtime-ping-exit
fix(agent): close Claude stdin after final stream-json result
2026-04-13 22:57:44 +08:00
Bohan Jiang
1e9266f063 fix(install): remove non-existent scoop bucket from Windows installer (#890)
The Install-CliScoop function referenced multica-ai/scoop-bucket.git
which does not exist, causing errors for Windows users with Scoop
installed. Always use direct binary download instead.

Closes #880
2026-04-13 22:53:06 +08:00
Bohan Jiang
1d71df8622 fix(daemon): include dispatched agent identity in CLAUDE.md (#877)
When an agent is triggered via @mention (not as the issue assignee),
the generated CLAUDE.md had no explicit agent identity. The agent would
infer its identity from the issue's assignee field, causing it to skip
work intended for it.

Now CLAUDE.md always includes "You are: <agent-name> (ID: <agent-id>)"
so the agent knows exactly who it is regardless of the issue assignee.

Closes MUL-709
2026-04-13 22:46:36 +08:00
Jiayuan Zhang
576f20f2c7 refactor(cli): separate install from setup, redesign CLI configuration flow (#888)
Decouple install.sh from environment configuration — install.sh now only
installs the CLI binary (and optionally Docker via --with-server), while
all environment configuration moves to `multica setup` subcommands.

Key changes:
- install.sh: remove config writes, rename --local to --with-server
- multica setup: add cloud/self-host subcommands with --server-url,
  --app-url, --port, --frontend-port flags and --profile support
- Add config overwrite protection with interactive prompt
- Remove redundant commands: `config local`, `auth login` alias
- Replace silent multica.ai fallbacks with explicit errors
- Onboarding wizard: dynamically show correct setup command for
  Cloud vs Self-host environments
- Update all docs, landing page, and install scripts for consistency
2026-04-13 22:32:10 +08:00
james
e01fa6bd9e fix(agent): prevent Claude runtime pings from hanging after the model has already finished
Claude's stream-json flow can emit the terminal result event while the
child process still waits on open stdin. Closing stdin as soon as the
final result arrives lets the CLI exit cleanly instead of idling until
the daemon timeout fires.

Constraint: Must preserve the existing Claude stream-json protocol and child-process lifecycle
Rejected: Increase ping timeout only | masks the hang without fixing process exit
Confidence: high
Scope-risk: narrow
Reversibility: clean
Directive: Keep Claude stdin handling aligned with the stream-json terminal result semantics; do not defer closure until goroutine teardown
Tested: Reproduced self-hosted runtime ping timeout locally; verified ping succeeds after closing stdin on result; cd server && go test ./pkg/agent
Not-tested: Full make check; Bedrock/Vertex-specific Claude auth flows
2026-04-13 08:59:34 -05:00
Jiayuan Zhang
f1236b2358 fix(chat): remove archive functionality from chat session history (#879)
Remove the archive button, active/archived grouping, and related
imports from ChatSessionHistory. This also fixes the nested <button>
hydration error (GitHub #875) since the inner archive Button was the
only nested button inside the row's outer <button>.
2026-04-13 21:31:53 +08:00
Bohan Jiang
0b60f78e8a fix(comment): set trigger_comment_id to actual reply, not thread root (#871)
* fix(comment): set trigger_comment_id to actual reply, not thread root

When a user replies in a thread and @mentions an agent, the enqueued
task's trigger_comment_id was incorrectly set to the parent (thread
root) comment instead of the reply that contained the mention. This
caused the agent to read the wrong comment and miss the user's actual
instructions.

Always pass comment.ID to EnqueueTaskForMention so agents see the
comment that triggered them.

Fixes MUL-708

* fix(task): resolve thread root in createAgentComment for reply triggers

With trigger_comment_id now correctly pointing to the actual reply
(not the thread root), createAgentComment must resolve to the thread
root before posting. Otherwise error/system comments would have
parent_id pointing to a nested reply, making them invisible in the
frontend's flat thread grouping.

Part of MUL-708
2026-04-13 19:53:23 +08:00
leaderlemon
5cd58183b2 fix(openclaw): handle JSON results with durationMs but no payloads (#862)
Some OpenClaw JSON outputs contain durationMs but lack payloads field.
The original condition rejected these results, causing the agent to
return "openclaw returned no parseable output" instead of the actual
execution result.

Fix by accepting results that have either payloads OR durationMs > 0.

Fixes #830

Co-authored-by: leaderlemon <leaderlemon@users.noreply.github.com>
2026-04-13 19:41:47 +08:00
Naiyuan Qing
83ff80c3ed Merge pull request #869 from multica-ai/NevilleQingNY/editor-audit
feat(editor): add bubble menu for text formatting
2026-04-13 19:27:16 +08:00
Bohan Jiang
8fb3bd322e fix(auth): AuthInitializer not supporting cookie auth mode (#870)
AuthInitializer only checked for multica_token in localStorage. In
cookie auth mode (introduced by the HttpOnly cookie migration), there
is no localStorage token — so AuthInitializer immediately set the user
to null and triggered a logout redirect on every page load/reload.

Add a cookieAuth code path that calls api.getMe() using the HttpOnly
cookie sent automatically by the browser, matching the auth store's
initialize() logic.

Fixes MUL-705, fixes #864
2026-04-13 19:25:49 +08:00
Naiyuan Qing
06b1b99638 fix(editor): use w-auto for bubble menu dropdown widths
Prevents text wrapping in heading/list dropdown items.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-13 19:23:05 +08:00
Naiyuan Qing
156982dc83 fix(editor): use onClick instead of onSelect for dropdown menu items
base-ui's Menu.Item only supports onClick, not onSelect (which is a
Radix UI API). onSelect was being silently ignored, causing heading
and list dropdown actions to never execute.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-13 19:21:41 +08:00
Naiyuan Qing
b239aa383e fix(editor): bubble menu dropdown/blur/scroll interactions
- Track child dropdown open state via ref to prevent blur handler from
  hiding the menu while a heading/list dropdown is open
- Hide on ancestor scroll only (not sidebar/dropdown scroll)
- Hoist BubbleMenu options to module constant to avoid excessive plugin
  updateOptions dispatches on every render
- Recover bubble menu after scroll via selectionUpdate

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-13 19:18:35 +08:00
Bohan Jiang
e2e5de1b26 docs: add v0.1.28 changelog entry (2026-04-13) (#867) 2026-04-13 19:13:53 +08:00
Naiyuan Qing
0faf1363ee feat(editor): add bubble menu for text formatting
Add a floating toolbar that appears when text is selected in the editor.
Supports inline marks (bold/italic/strike/code), link editing with URL
auto-prefix, heading/list dropdowns, and blockquote toggle. Uses Tiptap's
BubbleMenu with fixed positioning and z-50 to escape overflow containers.
Hides on editor blur and ancestor scroll, recovers on new selection.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-13 19:13:38 +08:00
devv-eve
6c92108b09 fix: replace hardcoded Unix path separators with filepath.Join and os.TempDir (#860)
- cmd_daemon.go: use filepath.Join for PID/log file paths instead of string concat with "/"
- codex_home.go: use os.TempDir() instead of hardcoded "/tmp" for cross-platform fallback

Co-authored-by: Devv <devv@Devvs-Mac-mini.local>
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-13 04:11:51 -07:00
Bohan Jiang
a94c6481dd fix: compute sub-issue progress from database instead of paginated client cache (#865)
The sub-issue progress indicator (e.g. "0/2") was undercounting because
it was computed from the client-side issue list, which only loads the
first 50 done issues. Sub-issues marked as done beyond that page were
excluded from both the total and done counts.

Added a dedicated backend endpoint (GET /api/issues/child-progress) that
aggregates child issue counts directly from the database, ensuring
accurate totals regardless of client-side pagination or filtering.

Fixes MUL-702
2026-04-13 19:10:28 +08:00
Naiyuan Qing
b4de4c9e9f Merge pull request #861 from multica-ai/feat/chat-ui-improvements
feat(chat): overhaul chat UI — resize, animations, session history
2026-04-13 18:31:00 +08:00
Bohan Jiang
7cac8014c9 feat(views): add keyboard navigation to assignee picker (#857)
* feat(views): add keyboard navigation and auto-select to PropertyPicker

Add arrow key (up/down) navigation and Enter key selection to the
searchable PropertyPicker dropdown. When the search narrows results to
a single match, pressing Enter auto-selects it without needing to
arrow-navigate first. Fixes GitHub issue #793.

* fix(views): hide Unassigned option when search filter is active

When the user types a search query in the assignee picker, the
Unassigned option is no longer pinned at the top — it only shows
when there is no active filter.

* feat(views): auto-highlight first result when searching in picker
2026-04-13 18:30:48 +08:00
Naiyuan Qing
be8b099c12 feat(desktop): add remote API proxy mode for dev
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-13 18:27:43 +08:00
Naiyuan Qing
458b1e19e2 feat(chat): improve session history UX and align chat window offset
- Add optimistic update + rollback to archive mutation
- Replace Trash2 with Archive icon (correct semantics)
- Add Tooltip on archive button, replace native title
- Show spinner during archive, toast on error
- Use cn() for className composition
- Align chat window offset to bottom-2 right-2 (match FAB)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-13 18:27:38 +08:00
Naiyuan Qing
acad93163b feat(chat): replace native title with Tooltip on chat header buttons
Use the project's Tooltip component instead of native title attributes
for consistent styling, animation, and accessibility across the app.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-13 18:24:01 +08:00
LinYushen
526e336081 feat(execenv): add Windows fallback for symlink operations (#859)
On Windows, os.Symlink requires Developer Mode or admin privileges.
Extract symlink creation into platform-specific files: on non-Windows,
behavior is unchanged (os.Symlink). On Windows, try os.Symlink first,
then fall back to directory junctions (mklink /J) for dirs and file
copy for files.

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-13 18:23:41 +08:00
Naiyuan Qing
f4ce4c249d chore: remove redundant icon size classes on pin buttons
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-13 18:21:44 +08:00
LinYushen
69f8380b9c Merge pull request #855 from multica-ai/agent/cc-girl/08bf694e
refactor(daemon): separate Unix/Windows platform code (MUL-690)
2026-04-13 18:20:39 +08:00
Naiyuan Qing
2e5af72cdc feat(chat): resizable chat window with animations and improved UX
- Refactor store to persist raw user intent (chatWidth/chatHeight/isExpanded) with no clamp logic
- Add ResizeObserver-based resize hook for dynamic container tracking
- Add drag-to-resize handles (left, top, corner) with pointer capture
- Expand/Restore button uses visual state (isAtMax) not internal flag
- Open/close animation (scale + opacity from bottom-right)
- Resize animation on button click, instant on drag (isDragging gate)
- Move ChatWindow inside content area (absolute, not fixed)
- Add input draft persistence, remove agent prop from message list

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-13 18:20:13 +08:00
yushen
0a0a86da2c fix(daemon): restore HideWindow: true for Windows daemon child process
Prevents console window flash when starting daemon in background on
Windows. This field existed in the original sysproc_windows.go but was
lost during merge conflict resolution.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-13 18:17:55 +08:00
yushen
96e87f7200 merge: resolve main conflicts, consolidate platform files
Main introduced sysproc_unix.go/sysproc_windows.go with a simpler
version of the same refactoring. Our cmd_daemon_unix.go/windows.go
files are more comprehensive (reverse-scan tail, graceful CTRL_BREAK
stop, named constants), so we keep ours and remove the overlapping
sysproc_*.go files. Conflict in cmd_daemon.go resolved using our
function names (notifyShutdownContext, stopDaemonProcess).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-13 18:15:22 +08:00
yushen
9e7d1eb764 fix(daemon): address Windows nits — named const, reverse-scan tail, graceful stop
1. Extract magic number 0x00000200 to createNewProcessGroup const
2. Replace os.ReadFile with reverse-scan from EOF in tailLogFile to
   avoid loading entire log file into memory
3. Try CTRL_BREAK_EVENT for graceful shutdown before falling back to
   process.Kill(); register sigBreak in notifyShutdownContext

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-13 18:12:01 +08:00
LinYushen
007a1ca284 feat(cli): add Windows installation support (#854)
* feat(cli): add Windows installation support (MUL-689)

Add PowerShell install script and Windows binary builds so Windows users
can install the CLI without WSL.

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

* fix(cli): address PR review for Windows install script

- Use GitHub REST API for Get-LatestVersion (PS 5.1 compatible)
- Add SHA256 checksum verification after download
- Use [System.Version] for proper semantic version comparison
- Refactor $arch assignment for readability
- Warn before git reset --hard in Install-Server

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

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-13 18:05:22 +08:00
LinYushen
c5fce56887 feat(release): add Windows build target to GoReleaser (#856)
* feat(release): add Windows build target to GoReleaser

Add windows to goos list, use .zip archive format for Windows builds,
and extract platform-specific SysProcAttr into build-tagged files to
fix cross-compilation.

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

* fix(release): Windows daemon signal handling and process group

Add CREATE_NEW_PROCESS_GROUP to Windows SysProcAttr so the daemon child
process can receive CTRL_BREAK_EVENT. Extract signal handling into
platform-specific helpers: Unix uses SIGTERM for graceful stop, Windows
uses os.Interrupt (CTRL_BREAK_EVENT).

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

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-13 18:05:08 +08:00
Bohan Jiang
04747b45a2 fix(auth): add user-facing task messages endpoint for cookie auth (#853)
The frontend's listTaskMessages() was calling /api/daemon/tasks/{id}/messages
which uses DaemonAuth middleware (requires Authorization header). After the
cookie auth migration (#819), cookie-mode sessions don't send an Authorization
header, causing 401 on this endpoint. The 401 then triggers handleUnauthorized()
which clears the workspace context, cascading into 400 errors on all subsequent
requests.

Fix: add GET /api/tasks/{taskId}/messages under regular user auth middleware,
and update the frontend to use it instead of the daemon endpoint.

Closes #833
2026-04-13 18:03:25 +08:00
Jiayuan Zhang
01232fc2f9 feat(onboarding): add full-screen onboarding wizard for new workspaces (#852)
* feat(onboarding): add full-screen onboarding wizard for new workspaces

Replace auto-provisioned workspace with an interactive 4-step onboarding
wizard: Create Workspace → Connect Runtime → Create Agent → Get Started.

- Remove server-side ensureUserWorkspace() so new users land in onboarding
- Add onboarding wizard in packages/views/onboarding/ (4 steps)
- Wire login/OAuth callbacks to redirect to /onboarding when no workspace
- Add DashboardGuard onboardingPath fallback for workspace-less users
- Sidebar "Create workspace" navigates to /onboarding instead of modal
- Remove CreateWorkspaceModal (replaced by wizard step 1)
- Auto-generate workspace slug from name (no user-facing URL field)
- Unified CLI install flow: install.sh + multica setup (auto-detects local)
- Create onboarding issues on completion with interactive "Say hello" task

* test(auth): update workspace tests to match onboarding flow

Login no longer auto-creates workspaces — new users start with zero
workspaces and create one through the onboarding wizard. Update both
integration and handler tests to assert 0 workspaces after verify-code.
2026-04-13 17:59:51 +08:00
yushen
4372c5f4fa refactor(daemon): separate Unix/Windows platform code with build tags
Extract Unix-only syscalls (Setsid, SIGTERM, tail command) into
cmd_daemon_unix.go and provide Windows alternatives in
cmd_daemon_windows.go using CREATE_NEW_PROCESS_GROUP, process.Kill(),
os.Interrupt, and native Go file reading.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-13 17:54:11 +08:00
Jiang Bohan
a73a9d4036 fix(agent): address PR review — env var blocklist, unmarshal logging, stable React keys
1. Security: add isBlockedEnvKey() blocklist that rejects MULTICA_*
   prefix and critical system vars (HOME, PATH, USER, SHELL, TERM,
   CODEX_HOME) from custom_env injection
2. Observability: log warnings when json.Unmarshal fails on custom_env
   (agentToResponse + claim endpoint)
3. UX: use stable auto-increment IDs for env entry React keys instead
   of array index to prevent input focus/state issues on add/remove
2026-04-13 17:39:02 +08:00
Bohan Jiang
12bf7cac34 fix(security): WebSocket first-message auth (MUL-580) (#848)
* fix(security): use first-message auth for WebSocket instead of URL query param

Token was exposed in URL query parameters (HIGH-4 from security audit),
visible in server/proxy logs, browser history, and referrer headers.

Now non-cookie clients (desktop, CLI) send the token as the first
WebSocket message after the connection opens. Cookie-based auth (web)
continues to work unchanged. Server-side auth priority flipped to
cookie-first.

Closes MUL-580

* fix(security): add auth_ack and fix test JSON construction

Server sends auth_ack after successful first-message auth so the client
knows auth completed before firing reconnect callbacks. Test now uses
json.Marshal instead of string concatenation for the auth message.

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

* fix(test): update WebSocket integration test for first-message auth

The integration test still passed the token as a URL query param,
causing a timeout since the server now expects first-message auth
for non-cookie clients.

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

---------

Co-authored-by: yushen <ldnvnbl@gmail.com>
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-13 17:11:52 +08:00
Naiyuan Qing
64ed0806ff refactor(chat): polish chat UI with design system tokens and components
- Replace raw <button> with <Button variant="ghost" size="icon-sm"> in header and history
- Add aria-expanded:bg-accent to agent selector trigger for open state
- Add max-h-60, w-auto max-w-56, truncate to agent dropdown
- Switch FAB to bg-card, chat window to bg-sidebar
- Switch user message bubble from bg-primary to bg-muted, drop text-primary-foreground
- Reduce user bubble max-w from 85% to 80%
- Remove agent avatar from AI messages, make AI content w-full
- Strip arbitrary text-[10px] from AvatarFallback
- Remove manual icon size overrides inside Button components

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-13 16:57:48 +08:00
Naiyuan Qing
b927684e3d Merge pull request #847 from multica-ai/NevilleQingNY/workspace-create-btn
fix(views): make create workspace button visible
2026-04-13 16:55:01 +08:00
Naiyuan Qing
e9bed4eb13 fix(views): make create workspace button always visible in dropdown
The create workspace button was hidden behind a hover interaction on the
"Workspaces" label, making it very hard to discover. Replace it with a
standard DropdownMenuItem at the bottom of the workspace list.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-13 16:52:13 +08:00
pradeep7127
297b436e65 fix(issue): default create status to todo instead of backlog (#746)
* fix(issue): default create status to todo instead of backlog

Issues created without an explicit status now default to `todo` so the
local daemon picks them up immediately. Previously they defaulted to
`backlog`, which daemons ignore, leaving new issues silently idle until
a user manually moved them.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* test(issue): verify create defaults to todo, explicit backlog still works

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-13 16:49:35 +08:00
Jiang Bohan
4165401d16 feat(agent): support custom environment variables for router/proxy mode
Add per-agent custom_env configuration that gets injected into the agent
subprocess at launch time. This enables users to configure custom API
endpoints (ANTHROPIC_BASE_URL), API keys (ANTHROPIC_API_KEY), and cloud
provider modes (CLAUDE_CODE_USE_BEDROCK, CLAUDE_CODE_USE_VERTEX) without
requiring code changes.

Changes:
- Migration 040: add custom_env JSONB column to agent table
- Backend: custom_env in agent CRUD API + claim endpoint
- Daemon: merge custom_env into subprocess environment variables
- Frontend: env var editor in agent settings (key-value pairs with
  visibility toggle for sensitive values)

Closes #816
Related: #807, #809
2026-04-13 16:47:56 +08:00
Naiyuan Qing
6097f7392e refactor(chat): migrate chat input to ContentEditor + unified SubmitButton
- Replace plain textarea in chat-input with ContentEditor (rich text, matches comment-input structure)
- Extract shared SubmitButton component (idle/loading/running states) to packages/ui/components/common
- Update comment-input to use icon-sm size
- Fix chat-fab Tooltip delay prop (not supported on Root, global 500ms applies)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-13 15:52:39 +08:00
Naiyuan Qing
a749d310dd chore(core): remove ReactQueryDevtools
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-13 15:52:34 +08:00
LinYushen
a473110078 Merge pull request #839 from multica-ai/agent/cc-girl/84c5483e
feat(daemon): add periodic GC for workspace isolation directories
2026-04-13 15:51:16 +08:00
yushen
2f1000d815 merge: resolve conflict with main (runTask refactor + mergeUsage)
Main introduced executeAndDrain/mergeUsage refactor. Resolve by keeping
main's refactored structure and re-applying EnvRoot to the switch/case
in runTask. Rename newTestDaemon → newGCTestDaemon to avoid collision
with the helper added in daemon_test.go on main.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-13 15:51:02 +08:00
Bohan Jiang
dbc6308c20 fix(desktop): strip Origin header from WebSocket requests (#842)
The server's WS origin whitelist (added in #819) rejects connections
from localhost dev origins. Desktop app doesn't need Origin-based
security since it runs in Electron with webSecurity disabled.

Strip the Origin header from WS upgrade requests in the main process
so the server's checkOrigin allows the connection.
2026-04-13 15:50:05 +08:00
Naiyuan Qing
9e8c20df3d Merge remote-tracking branch 'origin/main' into feat/chat-ui-improvements 2026-04-13 15:50:01 +08:00
Cocoon-Break
4d31b1ecee fix: handle control_request messages in claude backend (auto-approve was dead code) (#811)
* fix: handle control_request messages in claude backend to enable auto-approve (Closes #810)

Signed-off-by: cocoon <54054995+kuishou68@users.noreply.github.com>

* fix: defer stdin.Close() inside goroutine so control_request writes can succeed

Signed-off-by: Cocoon-Break <54054995+kuishou68@users.noreply.github.com>

---------

Signed-off-by: cocoon <54054995+kuishou68@users.noreply.github.com>
Signed-off-by: Cocoon-Break <54054995+kuishou68@users.noreply.github.com>
2026-04-13 15:47:28 +08:00
Naiyuan Qing
17ea7797df Merge remote-tracking branch 'origin/main' into feat/chat-ui-improvements 2026-04-13 15:38:35 +08:00
Bohan Jiang
418fe4b18e feat(desktop): implement Google login via deep link (#626)
Desktop Google login flow: click "Continue with Google" → opens default
browser to web login page with platform=desktop → Google OAuth completes
→ web callback redirects to multica://auth/callback?token=<jwt> →
Electron receives deep link, extracts token, completes login.

Changes:
- Register `multica://` protocol in Electron (main process + builder)
- Add single-instance lock with deep link forwarding (macOS + Win/Linux)
- Expose `desktopAPI.onAuthToken` and `openExternal` via preload IPC
- Add `loginWithToken(token)` to core auth store
- Pass `state=platform:desktop` through Google OAuth flow
- Web callback detects desktop state and redirects via deep link
- Desktop renderer listens for auth token and hydrates session
2026-04-13 15:33:14 +08:00
Jiang Bohan
e5881601ad feat(desktop): add auto-update with GitHub releases
Check for updates on startup via electron-updater. When a new version is
detected, show a notification in the bottom-right corner with download
and restart-to-install actions.
2026-04-13 15:31:58 +08:00
Roshan Warrier
e044c7e84b fix(agent): parse openclaw result incrementally (#836)
Co-authored-by: txhno <198242577+txhno@users.noreply.github.com>
2026-04-13 15:29:34 +08:00
Bohan Jiang
afab4dfdef Merge pull request #840 from multica-ai/agent/j/9cf0cf3e
fix(daemon): run repo cache sync in background to unblock heartbeat
2026-04-13 15:27:45 +08:00
Jiang Bohan
99e973ba3e fix(daemon): run repo cache sync in background to unblock heartbeat
The repoCache.Sync() call in loadWatchedWorkspaces runs synchronous git
clone/fetch operations that can take minutes for large repos. Because
heartbeatLoop and pollLoop only start after loadWatchedWorkspaces returns,
the runtime's last_seen_at is never updated during the sync, causing the
server's sweeper to mark it offline after 45 seconds.

Move repo cache sync to a background goroutine so heartbeat and poll
loops start immediately after runtime registration.

Closes #825
2026-04-13 15:19:02 +08:00
Bohan Jiang
6ce0ba46a9 Merge pull request #800 from multica-ai/agent/j/ce0987c2
fix(ws): include issue_id in task:dispatch event
2026-04-13 15:08:12 +08:00
Naiyuan Qing
547da4c3e5 refactor(chat): replace pill FAB with circular icon button + tooltip
- Switch from pill shape (px-4 py-2) to 40×40 circle (size-10)
- Replace Send icon with MessageCircle
- Add hover scale animation (scale-110) and active press (scale-95)
- Add Tooltip with side=top, sideOffset=10, delay=300ms

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-13 15:07:11 +08:00
yushen
14beaa6ce2 fix(daemon): extract pruneWorktree helper for idiomatic defer cancel
The context cancel in pruneRepoWorktrees was called explicitly after
CombinedOutput inside a loop. Extract to a helper method so defer
cancel() works correctly (scoped to the function, not the loop).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-13 15:05:59 +08:00
Jiayuan Zhang
a3eefcf2c4 Revert "feat: add online status indicator on agent & member avatars (#821)" (#837)
This reverts commit 1d64ea4ba6.
2026-04-13 15:03:31 +08:00
yushen
20809052f5 fix(daemon): address GC review feedback
- Move WriteGCMeta from runTask() to handleTask() so it runs after
  task completion, not at start. Mid-task crashes leave orphan dirs
  that get cleaned by GCOrphanTTL.
- Strengthen isBareRepo to check both HEAD and objects/ directory.
- Remove empty workspace directories after all task dirs are cleaned.
- Add 30s context timeout to git worktree prune to prevent hangs.
- Add comprehensive unit tests for shouldCleanTaskDir (8 scenarios),
  cleanTaskDir, gcWorkspace empty-dir cleanup, isBareRepo, and
  WriteGCMeta/ReadGCMeta roundtrip.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-13 15:00:37 +08:00
LinYushen
265d1854c9 fix(daemon): add fallback for failed session resume (#818)
* fix(daemon): add fallback for failed session resume

When the daemon tries to resume a prior session (--resume flag for
Claude, --session for OpenCode, session/resume RPC for Hermes) and the
session no longer exists, the agent fails immediately. This adds a
fallback that retries the execution with a fresh session instead of
marking the task as blocked.

Extracts the execute+drain logic into a reusable executeAndDrain method
to avoid code duplication between the initial attempt and the retry.

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

* fix(daemon): narrow session resume retry and merge usage

Address review feedback:
1. Narrow retry trigger: only retry when result.SessionID == "" (no
   session was established), not on any failure with PriorSessionID set
2. Merge token usage from both attempts so billing is accurate
3. Log errors when the retry itself fails to start
4. Add unit tests for mergeUsage, fallback behavior, and no-retry
   when session was already established

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

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-13 14:47:24 +08:00
yushen
ff206baa6f feat(daemon): add periodic GC for workspace isolation directories
Isolation directories accumulate indefinitely because they're preserved
for session reuse but never cleaned up after the issue is closed.

This adds a background GC loop that periodically scans local workspace
directories and removes those whose issue is done/canceled and hasn't
been updated for 5 days (configurable via MULTICA_GC_TTL). Orphan
directories with no metadata are cleaned after 30 days.

Changes:
- Write .gc_meta.json (issue_id, workspace_id) at task completion
- Add GET /api/daemon/issues/{issueId}/gc-check endpoint for status queries
- Add gcLoop goroutine to daemon with configurable interval/TTL
- Prune stale git worktree references from bare repo caches each cycle
- New env vars: MULTICA_GC_ENABLED, MULTICA_GC_INTERVAL, MULTICA_GC_TTL,
  MULTICA_GC_ORPHAN_TTL

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-13 14:46:48 +08:00
Jiayuan Zhang
1d64ea4ba6 feat: add online status indicator on agent & member avatars (#821)
* feat: add online status indicator dot on agent & member avatars

Backend:
- Track member presence via WebSocket connections in the Hub
- Broadcast member:online/offline events when users connect/disconnect
- Add GET /api/workspaces/{id}/members/online endpoint
- Add member:online and member:offline event type constants

Frontend:
- Add isOnline prop to ActorAvatar with a status dot at top-right corner
- Green dot = online, gray dot = offline, no dot = status unknown
- Fetch online member list via new query, update optimistically on WS events
- Derive agent online status from existing agent.status field
- Wire online status through ActorAvatar views wrapper (enabled by default)

* fix: address code review — fix hub tests and avatar rounding

1. Hub tests: consume the member:online presence event from the first
   connection before asserting on broadcast messages.
2. ActorAvatar: use rounded-[inherit] on the inner wrapper so callers
   can override rounding (e.g. rounded-lg for agent list items).

* fix: consume member:online presence event in integration test

Same fix as the hub unit tests — read and discard the member:online
event before asserting on issue:created in TestWebSocketIntegration.
2026-04-13 14:46:34 +08:00
LinYushen
c8275605c9 fix(auth): fall back to token-mode WS for legacy localStorage users (#831)
* fix(auth): fall back to token-mode WS for users with legacy localStorage token

Users who logged in before the cookie-auth migration still have multica_token
in localStorage but no multica_auth cookie. Forcing cookieAuth=true for every
session caused their WebSocket upgrade to 401 with only workspace_id in the URL.

Detect the legacy token at boot and run that session in token mode (Bearer HTTP
+ URL-param WS). Pure cookie-mode is used only when no legacy token is present,
so new users get the intended path and legacy users migrate naturally on their
next logout/login cycle (logout already clears multica_token).

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

* docs(auth): note sunset plan for legacy-token WS fallback

Make the XSS-exposure tradeoff explicit and give future maintainers a
concrete signal (<1% of sessions) for when to delete the compat branch.

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

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-13 14:40:03 +08:00
Bohan Jiang
c54f9a0bc4 Merge pull request #829 from multica-ai/agent/j/03e8009e
fix(daemon): embed triggering comment content in agent prompt
2026-04-13 14:36:21 +08:00
Naiyuan Qing
30725392ac Merge pull request #827 from multica-ai/refactor/workspace-list-to-react-query
refactor(workspace): migrate workspace list from Zustand to React Query
2026-04-13 14:29:10 +08:00
Naiyuan Qing
3f13605b4c test(views/login): mock useQueryClient to fix No QueryClient error
LoginPage now calls useQueryClient() after the workspace list migration.
Mock it in packages/views tests so render calls don't need wrapping in
QueryClientProvider — setQueryData becomes a no-op spy.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-13 14:25:21 +08:00
Jiang Bohan
93fffad82a fix(daemon): embed triggering comment content in agent prompt
When a task is triggered by a comment, the agent prompt now includes
the comment content directly. This prevents the agent from ignoring
the comment when stale output files exist in a reused workdir.

Closes #805
2026-04-13 14:20:26 +08:00
Naiyuan Qing
2fd344511e fix(realtime): add staleTime: 0 to fetchQuery in WS deleted/removed handlers
workspace:deleted and member:removed handlers were calling fetchQuery
without staleTime: 0. With staleTime: Infinity on the QueryClient, this
returns the cached list (which still contains the deleted/left workspace)
instead of fetching fresh data — so hydrateWorkspace never switches away.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-13 14:20:19 +08:00
Naiyuan Qing
9581e4d870 test(web/login): wrap render with QueryClientProvider
LoginPage now calls useQueryClient() after the workspace list migration.
All test renders need a QueryClientProvider; add a createWrapper() helper.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-13 14:18:11 +08:00
Jiayuan Zhang
cb4f5071ab fix: update X link to @MulticaAI in readme and landing page (#826)
Replace outdated x.com/multica_hq with x.com/MulticaAI in:
- README.zh-CN.md
- Landing page shared config
- Landing page en/zh i18n files
2026-04-13 14:14:34 +08:00
Naiyuan Qing
c76ba2f58e fix(workspace): seed React Query cache at all list-acquisition points
- staleTime: 0 on fetchQuery after leave/delete so fresh data is fetched
- setQueryData before switchWorkspace in createWorkspace so sidebar is
  consistent on first render
- seed workspaceKeys.list() cache in login, Google callback, and
  settings save so the first useQuery(workspaceListOptions()) hit is free
- remove dead onError from WorkspaceStoreOptions (used only by the
  deleted refreshWorkspaces action)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-13 14:00:28 +08:00
Bohan Jiang
bec84e2013 Merge pull request #824 from multica-ai/agent/j/642cc7b4
feat(daemon): add token usage log scanning for OpenCode, OpenClaw, Hermes
2026-04-13 13:50:41 +08:00
Jiang Bohan
2ea778796a feat(daemon): add token usage log scanning for OpenCode, OpenClaw, and Hermes runtimes
Previously only Claude and Codex had log-scanning-level token usage
reporting (Flow B). This adds scanners for the remaining three runtimes:

- OpenCode: reads JSON message files from ~/.local/share/opencode/storage/message/
- OpenClaw: reads JSONL session files from ~/.openclaw/agents/*/sessions/
- Hermes: reads JSONL session files from ~/.hermes/sessions/

All three are registered in Scanner.Scan() and follow the same
(date, provider, model) aggregation pattern as existing scanners.
2026-04-13 13:42:05 +08:00
Naiyuan Qing
43466a6402 refactor: migrate workspace list from Zustand to React Query
- Remove workspaces[] from workspace store — list is server state, belongs in React Query
- Change switchWorkspace(id) → switchWorkspace(ws) — caller provides full object from Query
- Remove createWorkspace/leaveWorkspace/deleteWorkspace store actions (duplicated mutations)
- Remove refreshWorkspaces store action — replaced by qc.fetchQuery + hydrateWorkspace
- Enhance useLeaveWorkspace/useDeleteWorkspace mutations to re-select workspace when current is removed
- useCreateWorkspace mutation now switches to new workspace on success
- AuthInitializer seeds React Query cache on boot to avoid double fetch
- Realtime sync: replace refreshWorkspaces() calls with qc.fetchQuery + hydrateWorkspace
- Sidebar reads workspace list from useQuery(workspaceListOptions()) instead of Zustand
- create-workspace modal and workspace settings tab use mutations directly
- AGENTS.md: rewrite to match current monorepo architecture, pointing to CLAUDE.md

Fixes workspace rename not updating sidebar without page refresh.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-13 13:38:02 +08:00
Bohan Jiang
68b101fe01 Merge pull request #804 from igornumeriano/fix/x-link
fix: update X link to correct handle @MulticaAI
2026-04-13 13:11:34 +08:00
LinYushen
e20c507dcc fix(security): add Content-Security-Policy response header (#822)
Adds CSP middleware to the global middleware chain as a browser-level
defense against XSS: script-src 'self', object-src 'none',
frame-ancestors 'none', base-uri 'self', form-action 'self'.

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-13 12:53:39 +08:00
Zohar Babin
77dbcaefad feat(cli): add --content-stdin flag to issue comment add
Allow agents to pipe comment content through stdin instead of the
--content flag, avoiding shell escaping issues with backticks, quotes,
and other special characters in markdown content.

Usage: cat <<'COMMENT' | multica issue comment add <id> --content-stdin

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-13 00:23:16 -04:00
LinYushen
95bfd7dd96 feat(auth): migrate auth token to HttpOnly Cookie & WebSocket Origin whitelist (#819)
* feat(auth): migrate auth token to HttpOnly cookie & implement WebSocket Origin whitelist

Security improvements from the MUL-566 audit report:

1. Auth token is now set as an HttpOnly, SameSite=Lax cookie on login,
   preventing XSS-based token theft. Cookie-based auth includes CSRF
   protection via double-submit cookie pattern. The Authorization header
   path is preserved for Electron desktop app and CLI/PAT clients.

2. WebSocket upgrader now validates the Origin header against a
   configurable allowlist (ALLOWED_ORIGINS env var), rejecting
   connections from unauthorized origins.

Backend: new auth cookie helpers, middleware reads cookie as fallback,
WS handler accepts cookie auth, Origin whitelist, logout endpoint.
Frontend: CSRF token in API headers, cookie-aware auth store and WS
client, web app opts into cookieAuth mode while desktop keeps tokens.

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

* fix(auth): address PR review — Strict cookies, HMAC-bound CSRF, origin sync

1. SameSite=Lax → SameSite=Strict per spec requirement
2. CSRF token now HMAC-signed with auth token (nonce.signature format),
   preventing subdomain cookie injection attacks
3. allowedWSOrigins uses atomic.Value to eliminate data race
4. Removed magic "cookie" sentinel string in WSProvider — pass null token
   and guard with boolean check instead
5. Removed dead delete uploadHeaders["Content-Type"] in API client

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

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-13 12:13:35 +08:00
Igor Numeriano
3bf7f467a2 fix: update X (Twitter) link to correct handle @MulticaAI
The previous link pointed to https://x.com/multica_hq which returns
a 404. The correct handle is @MulticaAI.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-12 17:38:27 -03:00
Jiayuan Zhang
04238bea22 chore: simplify v0.1.27 changelog — merge related items, remove trivial entries (#803) 2026-04-13 02:58:22 +08:00
Bohan Jiang
c13d365015 Merge pull request #796 from bulai0408/fix/codex-sandbox-network-access
fix(agent): enable network access for Codex sandbox
2026-04-13 01:55:12 +08:00
Jiang Bohan
b271e8915e fix(ws): include issue_id in task:dispatch event to prevent cross-issue UI glitch
broadcastTaskDispatch was the only task event broadcast missing issue_id
in its WebSocket payload. The frontend task:dispatch handler had no way
to filter by issue, causing AgentLiveCard to briefly show activity for
the wrong issue when multiple tabs are open.

Closes https://github.com/multica-ai/multica/issues/791
2026-04-13 01:50:10 +08:00
bulai0408
47eb6cb612 fix(agent): enable network access for Codex sandbox so Multica CLI can reach API
Codex tasks running in workspace-write sandbox mode could not resolve
api.multica.ai because the hardcoded sandbox parameter in thread/start
overrode any config.toml settings, and the default sandbox policy blocks
network access.

Changes:
- Remove hardcoded `sandbox: "workspace-write"` from thread/start RPC —
  let Codex read sandbox config from its own config.toml instead
- Auto-generate config.toml in per-task CODEX_HOME with
  `sandbox_mode = "workspace-write"` and `network_access = true`,
  preserving any existing user settings
- Fix Reuse() to restore CodexHome for Codex provider on workdir reuse

Closes #368
2026-04-13 01:03:43 +08:00
Bohan Jiang
1ee4e0501a fix(handler): add .claude/skills/ candidate path for skills.sh import (#792)
Skills stored under .claude/skills/{name}/SKILL.md (the Claude Code
native discovery convention) were not found during skills.sh import,
causing a 502 error. Add this path to the candidate list.

Fixes #777
2026-04-12 23:39:46 +08:00
Bohan Jiang
544b9bc971 docs: add v0.1.27 changelog entry (2026-04-12) (#790) 2026-04-12 23:35:48 +08:00
tianrking
0c19f0d16f Fix workspace filter sync and align CLI docs (#722)
* Fix workspace filter sync and align CLI docs

* simplify workspace sync subscription in issues page

* docs(self-hosting): align supported agents and daemon env vars
2026-04-12 23:11:40 +08:00
Bohan Jiang
d74d7f2b7b fix(handler): add cycle detection to BatchUpdateIssues parent_issue_id handling (#788)
BatchUpdateIssues was missing the ancestor-walk cycle detection that
single UpdateIssue has. This allowed creating circular parent
relationships (e.g. A→B→A) via the batch API. Added the same
depth-limited walk (up to 10 ancestors) to detect and skip issues
that would create cycles, consistent with UpdateIssue behavior.
2026-04-12 23:03:46 +08:00
Qiaochu Hu
0c2102b951 fix(handler): fix batch operations and error handling bugs (#779)
fix(handler): fix batch operations and error handling bugs
2026-04-12 23:00:40 +08:00
zerion-925
0c28d3cd08 fix: add randomUUID fallback for non-secure contexts (#749)
* fix: fallback when crypto.randomUUID is unavailable

* fix(core): remove Math.random UUID fallback and add tests

---------

Co-authored-by: Zerion <dev@take-app.local>
2026-04-12 22:51:56 +08:00
Jiang Bohan
7312b5650c fix(server): fix ListRuntimeUsage to filter by date range instead of row count (#765)
Replace LIMIT $2 with AND date >= $2 in ListRuntimeUsage query. When a
runtime uses multiple models each day has multiple rows, so a row LIMIT
silently returns fewer days than requested.

Also fixes displayName warnings in issue-detail test mocks and adds
missing setOpen to useCallback deps in search-command.

Co-authored-by: jayavibhavnk <jaya11vibhav@gmail.com>
Closes #731
2026-04-12 22:46:07 +08:00
Jiayuan Zhang
c7e0863419 fix(auth): preserve last workspace ID across re-login (#772)
The logout handler was clearing `multica_workspace_id` from storage,
so re-login always defaulted to the first workspace. The workspace ID
is a user preference, not session-sensitive data — keep it so both
web and desktop restore the correct workspace after re-authentication.

Also pass `lastWorkspaceId` in the desktop login page, which was
previously missing.
2026-04-12 21:33:19 +08:00
Jiayuan Zhang
d7c83bc285 fix(sanitize): preserve code blocks and inline code from HTML entity escaping (#774)
Bluemonday operates on raw text, so characters like && and <> inside
markdown code blocks/inline code were being HTML-escaped (e.g. && → &amp;&amp;),
causing them to render incorrectly in the frontend.

Now extracts fenced code blocks and inline code spans before sanitization,
runs bluemonday on the remaining content, then restores the code verbatim.
2026-04-12 21:32:35 +08:00
Jiayuan Zhang
4285549381 fix(views): navigate to issue in same tab instead of opening new tab (#773)
Issue mention clicks now use push() for same-tab navigation, matching
AppLink behavior. Cmd/Ctrl+Click still opens in a new tab on desktop.
2026-04-12 17:29:54 +08:00
Bohan Jiang
9ed80120e0 fix(views): add missing useFileDropZone and FileDropOverlay mocks in create-issue test (#768)
The create-issue modal started importing useFileDropZone and FileDropOverlay
from the editor module, but the test mock was not updated to include them,
causing CI to fail.
2026-04-12 15:18:15 +08:00
Manish Chauhan
ec586ebc25 fix(pins): scope cache by user and fix sidebar pin action (#664) 2026-04-12 15:02:20 +08:00
Bohan Jiang
ea8cb18f9e Merge pull request #639 from jyf2100/agent/agent/e7cb5f8c
test(web): cover issue creation flow regressions
2026-04-12 14:17:47 +08:00
Bohan Jiang
d011039c58 fix(sweeper): add error logging and dedup for issue reset (#762)
- Log a warning when HasActiveTaskForIssue fails, matching the existing
  pattern for UpdateIssueStatus errors. Silent failures here make
  debugging DB issues unnecessarily difficult.
- Track processed issues to skip redundant GetIssue + HasActiveTaskForIssue
  queries when multiple tasks for the same issue are swept in one cycle.
2026-04-12 14:13:07 +08:00
Gabriel Amazonas
471d4a6838 Update required AI agent CLI list in SELF_HOSTING.md (#734)
Added OpenClaw and OpenCode to the list of required AI agent CLIs.
2026-04-12 14:09:57 +08:00
pradeep7127
bd42552854 fix(sweeper): reset in_progress issues to todo after stale task sweep (#747)
fix(sweeper): reset in_progress issues to todo after stale task sweep
2026-04-12 14:08:54 +08:00
Bohan Jiang
31eeb00b59 fix(storage): clean up variable shadowing and dead code (#761)
- Rename `filepath` local var to `dest` in LocalStorage.Upload to avoid
  shadowing the path/filepath package import
- Remove unused detectContentType and overrideContentType functions from
  util.go (no longer needed after ServeFile switched to http.ServeFile)
2026-04-12 14:06:46 +08:00
Antar Das
d32c419b6d feat(storage): add local file storage fallback (#710)
* feat(storage): add local file storage fallback

- Add local storage implementation for file uploads
- Update .env.example with LOCAL_UPLOAD_DIR and LOCAL_UPLOAD_BASE_URL
- Integrate local storage into server router and handlers
- Add storage abstraction layer with util functions

* ♻️ refactor(storage): improve path handling and file serving

switch from path to filepath for better cross-platform support and replace manual file serving logic with http.ServeFile to enhance security against path traversal. update unit tests to use t.Setenv for cleaner environment variable management.
2026-04-12 14:04:22 +08:00
Jiayuan Zhang
f31a322978 chore: add issue templates and improve PR template (#759)
* chore: add issue templates and improve PR template

Add GitHub issue templates (bug report, feature request) using YAML
forms, referencing hermes-agent's template structure. Update the PR
template with clearer sections for changes made, related issues, and
a more comprehensive checklist.

* chore: add AI disclosure section to PR template

Since most PRs are now authored or co-authored by AI coding tools,
add a dedicated AI Disclosure section to the PR template. Includes
authorship type, tool used, and a human review checklist to ensure
AI-generated code is properly reviewed before merge.

* chore: simplify AI disclosure to focus on prompt sharing

Remove the review-status checklist — it was too heavy and users won't
actually do it. Instead focus on what's useful: which AI tool was used
and what prompt/approach produced the code, so the team can learn from
each other's AI workflows.

* chore: simplify issue templates to lower submission friction

Bug report: just what happened + steps to reproduce (required),
plus an optional context field for logs/env.

Feature request: just what you want and why (required),
plus an optional proposed solution.

Removed all dropdowns, environment fields, checkboxes, and
other fields that discourage users from filing issues.

* chore: add screenshots section to issue templates

Add optional screenshots field to both bug report and feature request
templates so users can attach images for richer context.
2026-04-12 13:58:18 +08:00
Sanjay Ramadugu
f99f50eb0c feat(daemon): add Google Gemini CLI backend
Registers `gemini` as a sixth supported agent provider alongside claude,
codex, opencode, openclaw, and hermes.

- Daemon config probes for `gemini` on PATH (MULTICA_GEMINI_PATH /
  MULTICA_GEMINI_MODEL env overrides mirror the other providers).
- New agent.geminiBackend in pkg/agent/gemini.go: spawns
  `gemini -p <prompt> --yolo -o text [-m <model>] [-r <session>]`,
  reads stdout to completion, and returns a single MessageText plus
  the standard Result struct (Status / Output / DurationMs).
- Execution environment writes a GEMINI.md file into the task workdir
  (mirroring the existing CLAUDE.md / AGENTS.md injection for other
  providers) so Gemini discovers the Multica runtime meta-skill
  through its native mechanism.

Tests:

- pkg/agent/gemini_test.go — unit coverage for buildGeminiArgs
  (baseline, model override, resume session, omit-when-empty).
- internal/daemon/execenv/TestInjectRuntimeConfigGemini — verifies
  GEMINI.md is written and that CLAUDE.md/AGENTS.md are NOT.

Scope (intentional for v1):

- Text output only (`-o text`). Streaming tool events via
  `--output-format stream-json` is a follow-up once we have a
  reliable reproduction of Gemini's event schema.
- No MCP config plumbing. Gemini's `--allowed-mcp-server-names`
  filter pairs well with the per-agent MCP work on feat/per-agent-mcp;
  stacking the two can land as a follow-up.
- No token usage scraping (Gemini's accounting lives on the Google
  Cloud side, not a local JSONL log like claude/codex).
- No session resume wiring beyond accepting the ExecOptions field —
  the daemon does not yet persist Gemini session IDs because the text
  output mode does not expose them.

Migration / env changes:

- New optional environment variables MULTICA_GEMINI_PATH and
  MULTICA_GEMINI_MODEL. Default path is the string "gemini" (resolved
  via PATH at daemon startup). If no Gemini install is detected, the
  provider is simply absent from the runtime — no behavior change for
  existing deployments.
2026-04-11 22:58:49 -04:00
Jiayuan Zhang
5bae3368d7 feat(landing): add install command copy block to hero section (#743)
Adds a terminal-style one-click copy block below the CTA buttons showing
the curl install command, with a copy-to-clipboard button that shows a
checkmark on success.
2026-04-12 02:42:05 +08:00
Jiayuan Zhang
f100b5b707 fix(auth): graceful email degradation for self-hosting (#742)
* fix(auth): log email send errors and gracefully degrade in non-production

In non-production environments (APP_ENV != "production"), if sending the
verification code email fails, log the error as a warning and still return
success. This lets self-hosting users log in with the master code (888888)
even when their Resend configuration is incomplete (e.g. unverified from-domain).

In production, the behavior is unchanged — email failures return 500.

Also adds guidance in .env.example about RESEND_FROM_EMAIL for self-hosters.

Closes #723

* fix(auth): remove APP_ENV degradation, keep error logging only

Remove the APP_ENV-based graceful degradation for email send failures
— it's risky if users forget to set APP_ENV=production. Instead, always
return 500 on email failure (safe for production) and rely on the error
log (slog.Error) with the actual Resend error for debugging.

Self-hosters who don't need real emails should leave RESEND_API_KEY empty
(codes print to stdout, master code 888888 works).
2026-04-12 02:30:01 +08:00
Jiayuan Zhang
701399536f feat(cli): enhance version command with JSON output and build info (#740)
Add --output json flag, build date, Go version, and OS/arch to the
version command. Update Makefile and goreleaser to inject build date.
2026-04-12 02:18:08 +08:00
Jiayuan Zhang
4ca607f888 chore: remove Apache 2.0 license badge from READMEs (#739) 2026-04-12 02:11:45 +08:00
Jiayuan Zhang
29f7959db7 fix(cli): fix install script failing on repeated runs (#738)
The install script crashed silently on repeated `--local` runs due to
three issues:

1. `REPO_URL` includes `.git` suffix which returns 404 when used for
   GitHub releases API — `grep` found no match, exited 1, and
   `set -euo pipefail` killed the script with no error message.

2. `multica version` outputs "multica 0.1.26 (commit: ...)" but the
   version comparison used the full string, so it never matched the
   release tag and always attempted unnecessary upgrades.

3. Interrupted previous clones left a non-empty directory without
   `.git/`, causing `git clone` to fail on retry.
2026-04-12 01:53:39 +08:00
Jiayuan Zhang
bd1a7eb680 fix(cli): add upgrade logic to install script (#736)
When multica CLI is already installed, the install script now checks
for a newer version on GitHub Releases and upgrades automatically.
Homebrew installs use `brew upgrade`; binary installs re-download
the latest release. If already up to date, it skips.
2026-04-12 01:37:34 +08:00
Jiayuan Zhang
3198972d15 docs: add "Switching to Multica Cloud" section to self-hosting guides (#735)
Self-host users had no documented way to reconfigure their CLI for
multica.ai. Add a section after "Stopping Services" in both
SELF_HOSTING.md and self-hosting.mdx explaining the two options:
manual `config set` or re-running the install script without --local.
2026-04-12 01:35:50 +08:00
roc
a35f71f65d test(web): cover issue creation flow regressions 2026-04-10 15:52:43 +08:00
340 changed files with 23838 additions and 3017 deletions

View File

@@ -7,8 +7,10 @@ DATABASE_URL=postgres://multica:multica@localhost:5432/multica?sslmode=disable
# Server
PORT=8080
APP_ENV=
TASK_DOMAIN=localhost
JWT_SECRET=change-me-in-production
MULTICA_SERVER_URL=ws://localhost:8080/ws
MULTICA_SERVER_URL=http://localhost:8080
MULTICA_APP_URL=http://localhost:3000
MULTICA_DAEMON_CONFIG=
MULTICA_WORKSPACE_ID=
@@ -22,6 +24,8 @@ MULTICA_CODEX_WORKDIR=
MULTICA_CODEX_TIMEOUT=20m
# Email (Resend)
# For local/dev use, leave RESEND_API_KEY empty — codes print to stdout, and master code 888888 works.
# For production, set your Resend API key and change RESEND_FROM_EMAIL to a domain verified in your Resend account.
RESEND_API_KEY=
RESEND_FROM_EMAIL=noreply@multica.ai
@@ -40,6 +44,16 @@ CLOUDFRONT_PRIVATE_KEY=
CLOUDFRONT_DOMAIN=
COOKIE_DOMAIN=
# Local file storage (fallback when S3_BUCKET is not set)
LOCAL_UPLOAD_DIR=./data/uploads
LOCAL_UPLOAD_BASE_URL=http://localhost:8080
# Security
# Comma-separated list of allowed origins for CORS and WebSocket connections.
# Defaults to localhost dev origins when unset.
# Example: ALLOWED_ORIGINS=https://app.multica.ai,https://staging.multica.ai
ALLOWED_ORIGINS=
# Frontend
FRONTEND_PORT=3000
FRONTEND_ORIGIN=http://localhost:3000
@@ -48,4 +62,4 @@ NEXT_PUBLIC_WS_URL=ws://localhost:8080/ws
# Remote API (optional) — set to proxy local frontend to a remote backend
# Leave empty to use local backend (localhost:8080)
# REMOTE_API_URL=https://multica-api.copilothub.ai
REMOTE_API_URL=http://localhost:8080

39
.github/ISSUE_TEMPLATE/bug_report.yml vendored Normal file
View File

@@ -0,0 +1,39 @@
name: "Bug Report"
description: Report a bug — something that's broken, crashes, or behaves incorrectly.
title: "[Bug]: "
labels: ["bug"]
body:
- type: textarea
id: description
attributes:
label: What happened?
description: Describe the bug and what you expected instead. Screenshots, error messages, or screen recordings are welcome.
placeholder: |
When I do X, Y happens. I expected Z instead.
validations:
required: true
- type: textarea
id: reproduction
attributes:
label: Steps to reproduce
description: How can we trigger this bug?
placeholder: |
1. Go to '...'
2. Click on '...'
3. See error
validations:
required: true
- type: textarea
id: screenshots
attributes:
label: Screenshots (optional)
description: If applicable, add screenshots or screen recordings to help explain the problem.
- type: textarea
id: context
attributes:
label: Additional context (optional)
description: Environment info, logs, or anything else that might help.
render: shell

1
.github/ISSUE_TEMPLATE/config.yml vendored Normal file
View File

@@ -0,0 +1 @@
blank_issues_enabled: true

View File

@@ -0,0 +1,26 @@
name: "Feature Request"
description: Suggest a new feature or improvement.
title: "[Feature]: "
labels: ["enhancement"]
body:
- type: textarea
id: description
attributes:
label: What do you want and why?
description: Describe the problem you're trying to solve or the improvement you'd like to see.
placeholder: |
I'm trying to do X but there's no way to...
validations:
required: true
- type: textarea
id: solution
attributes:
label: Proposed solution (optional)
description: If you have an idea for how this should work, describe it here.
- type: textarea
id: screenshots
attributes:
label: Screenshots / mockups (optional)
description: If applicable, add screenshots, mockups, or sketches to illustrate your idea.

View File

@@ -1,34 +1,58 @@
## What
## What does this PR do?
<!-- What does this PR do? Keep it to 1-3 sentences. -->
<!-- Describe the change clearly. What problem does it solve? Why is this approach the right one? -->
## Why
<!-- Why is this change needed? Link the related issue. -->
Closes #<!-- issue number -->
## Related Issue
<!-- Link the issue this PR addresses. If no issue exists, consider creating one first. -->
Closes #
## Type of Change
- [ ] Bug fix
- [ ] New feature
- [ ] Refactor / code improvement
- [ ] Documentation
- [ ] Bug fix (non-breaking change that fixes an issue)
- [ ] New feature (non-breaking change that adds functionality)
- [ ] Refactor / code improvement (no behavior change)
- [ ] Documentation update
- [ ] Tests (adding or improving test coverage)
- [ ] CI / infrastructure
- [ ] Other (describe below)
## Changes Made
<!-- List the specific changes. Include file paths for code changes. -->
-
## How to Test
<!-- How can a reviewer verify this works? Steps, commands, or screenshots. -->
<!-- Steps to verify this change works. For bugs: reproduction steps + proof that the fix works. -->
1.
2.
3.
## Checklist
- [ ] `make check` passes (typecheck, unit tests, Go tests, E2E)
- [ ] Changes follow existing code patterns and conventions
- [ ] No unrelated changes included
- [ ] I have included a thinking path that traces from project context to this change
- [ ] I have run tests locally and they pass
- [ ] I have added or updated tests where applicable
- [ ] If this change affects the UI, I have included before/after screenshots
- [ ] I have updated relevant documentation to reflect my changes
- [ ] I have considered and documented any risks above
- [ ] I will address all reviewer comments before requesting merge
## AI Disclosure (optional)
## AI Disclosure
<!-- If AI tools were used: -->
<!-- - Which tool? (e.g., Claude Code, Copilot, Cursor) -->
<!-- - What prompt did you use? Sharing your prompt helps others learn and lets reviewers understand intent. -->
<!-- Most PRs involve AI coding tools — that's totally fine! We're curious about your process. -->
**AI tool used:** <!-- e.g. Claude Code, Cursor, GitHub Copilot, Multica Agent, N/A -->
**Prompt / approach:**
<!-- How did you use AI to produce this code? Share your prompt, conversation link, or describe your approach. This helps the team learn from each other's AI workflows. -->
## Screenshots (optional)
<!-- If applicable, add screenshots showing the change in action. -->

9
.gitignore vendored
View File

@@ -12,10 +12,17 @@ build
bin
dist-electron
*.tsbuildinfo
# ...except electron-builder's source resources dir, which holds tracked
# config files (entitlements, icons) — not build output.
!apps/desktop/build/
!apps/desktop/build/**
# env
.env*
!.env.example
# Desktop production config is public (backend URL, etc.) — track it so
# `pnpm package` produces a release-ready build without extra setup.
!apps/desktop/.env.production
# test coverage
coverage
@@ -48,3 +55,5 @@ _features/
*.dmg
*.app
server/server
data/
.kilo

View File

@@ -11,19 +11,28 @@ builds:
- -s -w
- -X main.version={{.Version}}
- -X main.commit={{.ShortCommit}}
- -X main.date={{.Date}}
env:
- CGO_ENABLED=0
goos:
- darwin
- linux
- windows
goarch:
- amd64
- arm64
ignore:
- goos: windows
goarch: arm64
archives:
- id: default
formats:
- tar.gz
format_overrides:
- goos: windows
formats:
- zip
name_template: "{{ .ProjectName }}_{{ .Os }}_{{ .Arch }}"
checksum:

283
AGENTS.md
View File

@@ -2,273 +2,46 @@
This file provides guidance to AI agents when working with code in this repository.
## Project Context
> **Single source of truth:** This file is a concise pointer document.
> All authoritative architecture, coding rules, commands, and conventions
> live in **CLAUDE.md** at the project root. Read that file first.
Multica is an AI-native task management platform — like Linear, but with AI agents as first-class citizens.
## Quick Reference
- Agents can be assigned issues, create issues, comment, and change status
- Supports local (daemon) and cloud agent runtimes
- Built for 2-10 person AI-native teams
### Architecture
## Architecture
Go backend + monorepo frontend (pnpm workspaces + Turborepo) with shared packages.
**Go backend + standalone Next.js frontend.**
- `server/` — Go backend (Chi router, sqlc, gorilla/websocket)
- `apps/web/` — Next.js frontend (App Router)
- `apps/desktop/` — Electron desktop app
- `packages/core/` — Headless business logic (Zustand stores, React Query hooks, API client)
- `packages/ui/` — Atomic UI components (shadcn/Base UI, zero business logic)
- `packages/views/` — Shared business pages/components
- `packages/tsconfig/` — Shared TypeScript config
- `server/` — Go backend (Chi router, sqlc for DB, gorilla/websocket for real-time)
- `apps/web/` — Next.js 16 frontend (App Router) — self-contained, no shared package dependencies
- `e2e/` — Playwright end-to-end tests
- `scripts/` and root `Makefile` — local setup and verification
### State Management (critical)
### Web App Structure (`apps/web/`)
- **React Query** owns all server state (issues, members, agents, inbox, workspace list)
- **Zustand** owns all client state (current workspace selection, view filters, drafts, modals)
- All Zustand stores live in `packages/core/` — never in `packages/views/` or app directories
- WS events invalidate React Query — never write directly to stores
The frontend uses a **feature-based architecture** with four layers:
### Package Boundaries (hard rules)
```
apps/web/
├── app/ # Routing layer (thin shells — import from features/)
├── features/ # Business logic, organized by domain
├── shared/ # Cross-feature utilities (api client, types, logger)
├── test/ # Shared test utilities and setup
├── public/ # Static assets
```
- `packages/core/` — zero react-dom, zero localStorage, zero process.env
- `packages/ui/` — zero `@multica/core` imports
- `packages/views/` — zero `next/*`, zero `react-router-dom`, use `NavigationAdapter` for routing
- `apps/web/platform/` — only place for Next.js APIs
**`app/`** — Next.js App Router pages. Route files should be thin: import and re-export from `features/`. Layout components and route-specific glue (redirects, auth guards) live here. Shared layout components (e.g. `app-sidebar`) stay in `app/(dashboard)/_components/`.
**`features/`** — Domain modules, each with its own components, hooks, stores, and config:
| Feature | Purpose | Exports |
|---|---|---|
| `features/auth/` | Authentication state | `useAuthStore`, `AuthInitializer` |
| `features/workspace/` | Workspace, members, agents | `useWorkspaceStore`, `useActorName` |
| `features/issues/` | Issue state, components, config | `useIssueStore`, icons, pickers, status/priority config |
| `features/inbox/` | Inbox notification state | `useInboxStore` |
| `features/realtime/` | WebSocket connection + sync | `WSProvider`, `useWSEvent`, `useRealtimeSync` |
| `features/modals/` | Modal registry and state | Modal store and components |
| `features/skills/` | Skill management | Skill components |
**`shared/`** — Code used across multiple features:
- `shared/api/``ApiClient` (REST) and `WSClient` (WebSocket) for backend communication, plus the `api` singleton.
- `shared/types/` — Domain types (Issue, Agent, Workspace, etc.) and WebSocket event types.
- `shared/logger.ts` — Logger utility.
### State Management
- **Zustand** for global client state — one store per feature domain (`features/auth/store.ts`, `features/workspace/store.ts`, `features/issues/store.ts`, `features/inbox/store.ts`).
- **React Context** only for connection lifecycle (`WSProvider` in `features/realtime/`).
- **Local `useState`** for component-scoped UI state (forms, modals, filters).
- Do not use React Context for data that can be a zustand store.
**Store conventions:**
- One store per feature domain. Import via `useAuthStore(selector)` or `useWorkspaceStore(selector)`.
- Stores must not call `useRouter` or any React hooks — keep navigation in components.
- Cross-store reads use `useOtherStore.getState()` inside actions (not hooks).
- Dependency direction: `workspace``auth`, `realtime``auth`, `issues``workspace`. Never reverse.
### Import Aliases
Use `@/` alias (maps to `apps/web/`):
```typescript
import { api } from "@/shared/api";
import type { Issue } from "@/shared/types";
import { useAuthStore } from "@/features/auth";
import { useWorkspaceStore } from "@/features/workspace";
import { useIssueStore } from "@/features/issues";
import { useInboxStore } from "@/features/inbox";
import { useWSEvent } from "@/features/realtime";
import { StatusIcon } from "@/features/issues/components";
```
Within a feature, use relative imports. Between features or to shared, use `@/`.
### Data Flow
```
Browser → ApiClient (shared/api) → REST API (Chi handlers) → sqlc queries → PostgreSQL
Browser ← WSClient (shared/api) ← WebSocket ← Hub.Broadcast() ← Handlers/TaskService
```
### Backend Structure (`server/`)
- **Entry points** (`cmd/`): `server` (HTTP API), `multica` (CLI — daemon, agent management, config), `migrate`
- **Handlers** (`internal/handler/`): One file per domain (issue, comment, agent, auth, daemon, etc.). Each handler holds `Queries`, `DB`, `Hub`, and `TaskService`.
- **Real-time** (`internal/realtime/`): Hub manages WebSocket clients. Server broadcasts events; inbound WS message routing is still TODO.
- **Auth** (`internal/auth/` + `internal/middleware/`): JWT (HS256). Middleware sets `X-User-ID` and `X-User-Email` headers. Login creates user on-the-fly if not found.
- **Task lifecycle** (`internal/service/task.go`): Orchestrates agent work — enqueue → claim → start → complete/fail. Syncs issue status automatically and broadcasts WS events at each transition.
- **Agent SDK** (`pkg/agent/`): Unified `Backend` interface for executing prompts via Claude Code or Codex. Each backend spawns its CLI and streams results via `Session.Messages` + `Session.Result` channels.
- **Daemon** (`internal/daemon/`): Local agent runtime — auto-detects available CLIs (claude, codex), registers runtimes, polls for tasks, routes by provider.
- **CLI** (`internal/cli/`): Shared helpers for the `multica` CLI — API client, config management, output formatting.
- **Events** (`internal/events/`): Internal event bus for decoupled communication between handlers and services.
- **Logging** (`internal/logger/`): Structured logging via slog. `LOG_LEVEL` env var controls level (debug, info, warn, error).
- **Database**: PostgreSQL with pgvector extension (`pgvector/pgvector:pg17`). sqlc generates Go code from SQL in `pkg/db/queries/``pkg/db/generated/`. Migrations in `migrations/`.
- **Routes** (`cmd/server/router.go`): Public routes (auth, health, ws) + protected routes (require JWT) + daemon routes (unauthenticated, separate auth model).
### Multi-tenancy
All queries filter by `workspace_id`. Membership checks gate access. `X-Workspace-ID` header routes requests to the correct workspace.
### Agent Assignees
Assignees are polymorphic — can be a member or an agent. `assignee_type` + `assignee_id` on issues. Agents render with distinct styling (purple background, robot icon).
## Commands
### Commands
```bash
# One-click setup & run
make setup # First-time: ensure shared DB, create app DB, migrate
make start # Start backend + frontend together
make stop # Stop app processes for the current checkout
make db-down # Stop the shared PostgreSQL container
# Frontend
pnpm install
pnpm dev:web # Next.js dev server (port 3000)
pnpm build # Build frontend
make dev # Auto-setup + start everything
pnpm typecheck # TypeScript check
pnpm lint # ESLint via Next.js
pnpm test # TS tests (Vitest)
# Backend (Go)
make dev # Run Go server (port 8080)
make daemon # Run local daemon
make build # Build server + CLI binaries to server/bin/
make cli ARGS="..." # Run multica CLI (e.g. make cli ARGS="config")
pnpm test # TS unit tests (Vitest)
make test # Go tests
make sqlc # Regenerate sqlc code after editing SQL in server/pkg/db/queries/
make migrate-up # Run database migrations
make migrate-down # Rollback migrations
# Run a single Go test
cd server && go test ./internal/handler/ -run TestName
# Run a single TS test
pnpm --filter @multica/web exec vitest run src/path/to/file.test.ts
# Run a single E2E test (requires backend + frontend running)
pnpm exec playwright test e2e/tests/specific-test.spec.ts
# Infrastructure
make db-up # Start shared PostgreSQL (pgvector/pg17 image)
make db-down # Stop shared PostgreSQL
make check # Full verification pipeline
```
### CI Requirements
CI runs on Node 22 and Go 1.24 with a `pgvector/pgvector:pg17` PostgreSQL service. See `.github/workflows/ci.yml`.
### Worktree Support
All checkouts share one PostgreSQL container. Isolation is at the database level — each worktree gets its own DB name and unique ports via `.env.worktree`. Main checkouts use `.env`.
```bash
make worktree-env # Generate .env.worktree with unique DB/ports
make setup-worktree # Setup using .env.worktree
make start-worktree # Start using .env.worktree
```
## Coding Rules
- TypeScript strict mode is enabled; keep types explicit.
- TypeScript in `apps/web` uses 2-space indentation, double quotes, and semicolons.
- Prefer PascalCase for React components, camelCase for hooks and helpers, and colocated test files such as `page.test.tsx`.
- Go code follows standard Go conventions (gofmt, go vet). Use domain-oriented filenames like `issue.go` or `cmd_issue.go`.
- Do not hand-edit generated code in `server/pkg/db/generated/`.
- Keep comments in code **English only**.
- Prefer existing patterns/components over introducing parallel abstractions.
- Unless the user explicitly asks for backwards compatibility, do **not** add compatibility layers, fallback paths, dual-write logic, legacy adapters, or temporary shims.
- If a flow or API is being replaced and the product is not yet live, prefer removing the old path instead of preserving both old and new behavior.
- Treat compatibility code as a maintenance cost, not a default safety mechanism. Avoid "just in case" branches that make the codebase harder to reason about.
- Avoid broad refactors unless required by the task.
## UI/UX Rules
- Prefer shadcn components over custom implementations. Install missing components via `npx shadcn add`.
- **Feature-specific components** → `features/<domain>/components/` — issue icons, pickers, and other domain-bound UI live inside their feature module.
- Use shadcn design tokens for styling (e.g. `bg-primary`, `text-muted-foreground`, `text-destructive`). Avoid hardcoded color values (e.g. `text-red-500`, `bg-gray-100`).
- Do not introduce extra state (useState, context, reducers) unless explicitly required by the design. Prefer zustand stores for shared state over React Context.
- Pay close attention to **overflow** (truncate long text, scrollable containers), **alignment**, and **spacing** consistency.
- When unsure about interaction or state design, ask — the user will provide direction.
## Testing Rules
- **TypeScript**: Vitest with Testing Library. Shared test setup lives in `apps/web/test/`. Mock external/third-party dependencies only.
- **Go**: Standard `go test`. Tests should create their own fixture data in a test database.
- End-to-end tests live in `e2e/*.spec.ts`; `make check` will start missing services automatically, while direct Playwright runs expect the app to already be running.
- Add or update tests whenever you change handlers, CLI commands, daemon behavior, or SQL-backed flows.
## Commit & Pull Request Rules
- Use atomic commits grouped by logical intent.
- Conventional format with scopes:
- `feat(web): ...`, `feat(cli): ...`
- `fix(web): ...`, `fix(cli): ...`
- `refactor(daemon): ...`
- `test(cli): ...`
- `docs: ...`
- `chore(scope): ...`
- Keep PRs focused and include a short description, linked issue or PR number when relevant, screenshots for UI work, and notes for migrations, env changes, or CLI surface changes.
- Before opening a PR, run `make check` or the relevant frontend/backend subset.
## Minimum Pre-Push Checks
```bash
make check # Runs all checks: typecheck, unit tests, Go tests, E2E
```
Run verification only when the user explicitly asks for it.
For targeted checks when requested:
```bash
pnpm typecheck # TypeScript type errors only
pnpm test # TS unit tests only (Vitest)
make test # Go tests only
pnpm exec playwright test # E2E only (requires backend + frontend running)
```
## AI Agent Verification Loop
After writing or modifying code, always run the full verification pipeline:
```bash
make check
```
This runs all checks in sequence:
1. TypeScript typecheck (`pnpm typecheck`)
2. TypeScript unit tests (`pnpm test`)
3. Go tests (`go test ./...`)
4. E2E tests (auto-starts backend + frontend if needed, runs Playwright)
**Workflow:**
- Write code to satisfy the requirement
- Run `make check`
- If any step fails, read the error output, fix the code, and re-run `make check`
- Repeat until all checks pass
- Only then consider the task complete
**Quick iteration:** If you know only TypeScript or Go is affected, run individual checks first for faster feedback, then finish with a full `make check` before marking work complete.
## E2E Test Patterns
E2E tests should be self-contained. Use the `TestApiClient` fixture for data setup/teardown:
```typescript
import { loginAsDefault, createTestApi } from "./helpers";
import type { TestApiClient } from "./fixtures";
let api: TestApiClient;
test.beforeEach(async ({ page }) => {
api = await createTestApi(); // logged-in API client
await loginAsDefault(page); // browser session
});
test.afterEach(async () => {
await api.cleanup(); // delete any data created during the test
});
test("example", async ({ page }) => {
const issue = await api.createIssue("Test Issue"); // create via API
await page.goto(`/issues/${issue.id}`); // test via UI
// api.cleanup() in afterEach removes the issue
});
```
See CLAUDE.md for the complete command reference.

View File

@@ -7,8 +7,7 @@ The `multica` CLI connects your local machine to Multica. It handles authenticat
### Homebrew (macOS/Linux)
```bash
brew tap multica-ai/tap
brew install multica
brew install multica-ai/tap/multica
```
### Build from Source
@@ -22,11 +21,17 @@ cp server/bin/multica /usr/local/bin/multica
### Update
```bash
brew upgrade multica-ai/tap/multica
```
For install script or manual installs, use:
```bash
multica update
```
This auto-detects your installation method (Homebrew or manual) and upgrades accordingly.
`multica update` auto-detects your installation method and upgrades accordingly.
## Quick Start
@@ -35,7 +40,7 @@ This auto-detects your installation method (Homebrew or manual) and upgrades acc
multica setup
# For self-hosted (local) deployments:
multica setup --local
multica setup self-host
```
Or step by step:
@@ -135,6 +140,9 @@ The daemon auto-detects these AI CLIs on your PATH:
|-----|---------|-------------|
| [Claude Code](https://docs.anthropic.com/en/docs/claude-code) | `claude` | Anthropic's coding agent |
| [Codex](https://github.com/openai/codex) | `codex` | OpenAI's coding agent |
| OpenCode | `opencode` | Open-source coding agent |
| OpenClaw | `openclaw` | Open-source coding agent |
| Hermes | `hermes` | Nous Research coding agent |
You need at least one installed. The daemon registers each detected CLI as an available runtime.
@@ -169,29 +177,35 @@ Agent-specific overrides:
| `MULTICA_CLAUDE_MODEL` | Override the Claude model used |
| `MULTICA_CODEX_PATH` | Custom path to the `codex` binary |
| `MULTICA_CODEX_MODEL` | Override the Codex model used |
| `MULTICA_OPENCODE_PATH` | Custom path to the `opencode` binary |
| `MULTICA_OPENCODE_MODEL` | Override the OpenCode model used |
| `MULTICA_OPENCLAW_PATH` | Custom path to the `openclaw` binary |
| `MULTICA_OPENCLAW_MODEL` | Override the OpenClaw model used |
| `MULTICA_HERMES_PATH` | Custom path to the `hermes` binary |
| `MULTICA_HERMES_MODEL` | Override the Hermes model used |
### Self-Hosted Server
When connecting to a self-hosted Multica instance, the easiest approach is:
```bash
# One command — auto-detects local server, configures, authenticates, starts daemon
multica setup --local
# One command — configures for localhost, authenticates, starts daemon
multica setup self-host
# Or for on-premise with custom domains:
multica setup self-host --server-url https://api.example.com --app-url https://app.example.com
```
Or configure manually:
```bash
# Configure for local Docker Compose (default ports)
multica config local
# Or set URLs individually:
# multica config set app_url http://localhost:3000
# multica config set server_url http://localhost:8080
# Set URLs individually
multica config set server_url http://localhost:8080
multica config set app_url http://localhost:3000
# For production with TLS:
# multica config set app_url https://app.example.com
# multica config set server_url https://api.example.com
# multica config set app_url https://app.example.com
multica login
multica daemon start
@@ -202,9 +216,11 @@ multica daemon start
Profiles let you run multiple daemons on the same machine — for example, one for production and one for a staging server.
```bash
# Start a daemon for the staging server
multica --profile staging login
multica --profile staging daemon start
# Set up a staging profile
multica setup self-host --profile staging --server-url https://api-staging.example.com --app-url https://staging.example.com
# Start its daemon
multica daemon start --profile staging
# Default profile runs separately
multica daemon start
@@ -327,17 +343,20 @@ The `runs` command shows all past and current executions for an issue, including
## Setup
```bash
# One-command setup: configure, authenticate, and start the daemon
# One-command setup for Multica Cloud: configure, authenticate, and start the daemon
multica setup
# For local self-hosted deployments (auto-detects or forces local mode)
multica setup --local
# For local self-hosted deployments
multica setup self-host
# Custom ports
multica setup --local --port 9090 --frontend-port 4000
multica setup self-host --port 9090 --frontend-port 4000
# On-premise with custom domains
multica setup self-host --server-url https://api.example.com --app-url https://app.example.com
```
`multica setup` detects whether a local Multica server is running, configures the CLI, opens your browser for authentication, and starts the daemon — all in one step.
`multica setup` configures the CLI, opens your browser for authentication, and starts the daemon — all in one step. Use `multica setup self-host` to connect to a self-hosted server instead of Multica Cloud.
## Configuration
@@ -349,15 +368,6 @@ multica config show
Shows config file path, server URL, app URL, and default workspace.
### Configure for Local Self-Hosted
```bash
multica config local # Uses default ports (8080/3000)
multica config local --port 9090 --frontend-port 4000 # Custom ports
```
Sets `server_url` and `app_url` for a local Docker Compose deployment in one command.
### Set Values
```bash

View File

@@ -27,7 +27,9 @@ multica version
## Step 2: Install the Multica CLI
### Option A: Homebrew (preferred)
> **Windows users:** Skip to [Option C: Windows (PowerShell)](#option-c-windows-powershell) below.
### Option A: Homebrew (preferred — macOS/Linux)
Check if Homebrew is available:
@@ -38,7 +40,7 @@ which brew
If `brew` is found, install via Homebrew:
```bash
brew tap multica-ai/tap && brew install multica
brew install multica-ai/tap/multica
```
Then verify:
@@ -49,7 +51,13 @@ multica version
If the version prints successfully, skip to **Step 3**.
### Option B: Download from GitHub Releases (no Homebrew)
To upgrade later, run:
```bash
brew upgrade multica-ai/tap/multica
```
### Option B: Download from GitHub Releases (macOS/Linux, no Homebrew)
If Homebrew is not available, download the binary directly.
@@ -85,6 +93,27 @@ multica version
- On Linux, you may need `chmod +x /usr/local/bin/multica`.
- If `sudo` is not available, install to a user-writable directory: `mv /tmp/multica ~/.local/bin/multica` and ensure `~/.local/bin` is in `$PATH`.
### Option C: Windows (PowerShell)
Run in PowerShell (no admin required):
```powershell
irm https://raw.githubusercontent.com/multica-ai/multica/main/scripts/install.ps1 | iex
```
This downloads the latest Windows binary from GitHub Releases, installs it to `%USERPROFILE%\.multica\bin\`, and adds it to your user PATH.
Verify:
```powershell
multica version
```
**If this fails:**
- Restart your terminal so the updated PATH takes effect.
- If you use Scoop, the installer will use it automatically: `scoop bucket add multica https://github.com/multica-ai/scoop-bucket.git && scoop install multica`
- If your execution policy blocks the script: `Set-ExecutionPolicy -Scope CurrentUser -ExecutionPolicy RemoteSigned` then re-run.
---
## Step 3: Log in
@@ -136,12 +165,12 @@ Wait 3 seconds, then verify:
multica daemon status
```
Expected output should show `running` status with detected agents (e.g. `claude`, `codex`).
Expected output should show `running` status with detected agents (e.g. `claude`, `codex`, `opencode`, `openclaw`, `hermes`).
**If daemon fails to start:**
- Check logs: `multica daemon logs`
- If a port conflict occurs, the daemon may already be running under a different profile.
- If no agents are detected, ensure at least one AI CLI (`claude` or `codex`) is installed and on the `$PATH`.
- If no agents are detected, ensure at least one AI CLI (`claude`, `codex`, `opencode`, `openclaw`, or `hermes`) is installed and on the `$PATH`.
---
@@ -155,12 +184,12 @@ multica daemon status
Confirm:
1. Status is `running`
2. At least one agent is listed (e.g. `claude`, `codex`)
2. At least one agent is listed (e.g. `claude`, `codex`, `opencode`, `openclaw`, or `hermes`)
3. At least one workspace is being watched
If the agents list is empty, tell the user:
> "The Multica daemon is running but no AI agent CLIs were detected. Please install at least one: [Claude Code](https://docs.anthropic.com/en/docs/claude-code) (`claude`) or [Codex](https://github.com/openai/codex) (`codex`), then restart the daemon with `multica daemon stop && multica daemon start`."
> "The Multica daemon is running but no AI agent CLIs were detected. Please install at least one supported CLI (`claude`, `codex`, `opencode`, `openclaw`, or `hermes`), then restart the daemon with `multica daemon stop && multica daemon start`."
---

15
Caddyfile Normal file
View File

@@ -0,0 +1,15 @@
{$TASK_DOMAIN} {
@next_static path /_next/static/*
header @next_static Cache-Control "public, max-age=31536000, immutable, no-transform"
reverse_proxy /api/* backend:8080
reverse_proxy /auth/send-code backend:8080
reverse_proxy /auth/verify-code backend:8080
reverse_proxy /auth/google backend:8080
reverse_proxy /auth/logout backend:8080
reverse_proxy /ws backend:8080
reverse_proxy /ws/* backend:8080
reverse_proxy /health backend:8080
reverse_proxy /health/* backend:8080
reverse_proxy frontend:3000
}

View File

@@ -37,8 +37,12 @@ RUN pnpm install --frozen-lockfile --offline
# Set build-time env: tells Next.js rewrites to proxy API calls to the backend service
ARG REMOTE_API_URL=http://backend:8080
ARG NEXT_PUBLIC_GOOGLE_CLIENT_ID
ARG NEXT_PUBLIC_API_URL
ARG NEXT_PUBLIC_WS_URL
ENV REMOTE_API_URL=$REMOTE_API_URL
ENV NEXT_PUBLIC_GOOGLE_CLIENT_ID=$NEXT_PUBLIC_GOOGLE_CLIENT_ID
ENV NEXT_PUBLIC_API_URL=$NEXT_PUBLIC_API_URL
ENV NEXT_PUBLIC_WS_URL=$NEXT_PUBLIC_WS_URL
ENV STANDALONE=true
# Build the web app (standalone output for minimal runtime)

View File

@@ -70,7 +70,7 @@ selfhost:
echo ""; \
echo "Next — install the CLI and connect your machine:"; \
echo " brew install multica-ai/tap/multica"; \
echo " multica setup --local"; \
echo " multica setup self-host"; \
else \
echo ""; \
echo "Services are still starting. Check logs:"; \
@@ -190,10 +190,11 @@ multica:
VERSION ?= $(shell git describe --tags --always --dirty 2>/dev/null || echo dev)
COMMIT ?= $(shell git rev-parse --short HEAD 2>/dev/null || echo unknown)
DATE ?= $(shell date -u '+%Y-%m-%dT%H:%M:%SZ')
build:
cd server && go build -o bin/server ./cmd/server
cd server && go build -ldflags "-X main.version=$(VERSION) -X main.commit=$(COMMIT)" -o bin/multica ./cmd/multica
cd server && go build -ldflags "-X main.version=$(VERSION) -X main.commit=$(COMMIT) -X main.date=$(DATE)" -o bin/multica ./cmd/multica
cd server && go build -o bin/migrate ./cmd/migrate
test:

View File

@@ -18,10 +18,9 @@ The open-source managed agents platform.<br/>
Turn coding agents into real teammates — assign tasks, track progress, compound skills.
[![CI](https://github.com/multica-ai/multica/actions/workflows/ci.yml/badge.svg)](https://github.com/multica-ai/multica/actions/workflows/ci.yml)
[![License](https://img.shields.io/badge/License-Apache_2.0-blue.svg)](https://opensource.org/licenses/Apache-2.0)
[![GitHub stars](https://img.shields.io/github/stars/multica-ai/multica?style=flat)](https://github.com/multica-ai/multica/stargazers)
[Website](https://multica.ai) · [Cloud](https://multica.ai/app) · [X](https://x.com/multica_hq) · [Self-Hosting](SELF_HOSTING.md) · [Contributing](CONTRIBUTING.md)
[Website](https://multica.ai) · [Cloud](https://multica.ai/app) · [X](https://x.com/MulticaAI) · [Self-Hosting](SELF_HOSTING.md) · [Contributing](CONTRIBUTING.md)
**English | [简体中文](README.zh-CN.md)**
@@ -51,24 +50,39 @@ Multica manages the full agent lifecycle: from task assignment to execution moni
## Quick Install
### macOS / Linux (Homebrew - recommended)
```bash
brew install multica-ai/tap/multica
```
Use `brew upgrade multica-ai/tap/multica` to keep the CLI current.
### macOS / Linux (install script)
```bash
curl -fsSL https://raw.githubusercontent.com/multica-ai/multica/main/scripts/install.sh | bash
```
Installs the Multica CLI on macOS and Linux. Works with Homebrew or downloads the binary directly.
Use this if Homebrew is not available. The script installs the Multica CLI on macOS and Linux by using Homebrew when it is on `PATH`, otherwise it downloads the binary directly.
After installation:
### Windows (PowerShell)
```bash
multica login # Authenticate (opens browser)
multica daemon start # Start the local agent runtime
multica daemon stop # Stop the daemon when done
```powershell
irm https://raw.githubusercontent.com/multica-ai/multica/main/scripts/install.ps1 | iex
```
> **Self-hosting?** Add `--local` to deploy a full Multica server on your machine:
Then configure, authenticate, and start the daemon in one command:
```bash
multica setup # Connect to Multica Cloud, log in, start daemon
```
> **Self-hosting?** Add `--with-server` to deploy a full Multica server on your machine:
>
> ```bash
> curl -fsSL https://raw.githubusercontent.com/multica-ai/multica/main/scripts/install.sh | bash -s -- --local
> curl -fsSL https://raw.githubusercontent.com/multica-ai/multica/main/scripts/install.sh | bash -s -- --with-server
> multica setup self-host
> ```
>
> Requires Docker. See the [Self-Hosting Guide](SELF_HOSTING.md) for details.
@@ -77,11 +91,10 @@ multica daemon stop # Stop the daemon when done
## Getting Started
### 1. Log in and start the daemon
### 1. Set up and start the daemon
```bash
multica login # Authenticate with your Multica account
multica daemon start # Start the local agent runtime
multica setup # Configure, authenticate, and start the daemon
```
The daemon runs in the background and auto-detects agent CLIs (`claude`, `codex`, `openclaw`, `opencode`) on your PATH.
@@ -102,6 +115,21 @@ Create an issue from the board (or via `multica issue create`), then assign it t
---
## Multica vs Paperclip
| | Multica | Paperclip |
|---|---------|-----------|
| **Focus** | Team AI agent collaboration platform | Solo AI agent company simulator |
| **User model** | Multi-user teams with roles & permissions | Single board operator |
| **Agent interaction** | Issues + Chat conversations | Issues + Heartbeat |
| **Deployment** | Cloud-first | Local-first |
| **Management depth** | Lightweight (Issues / Projects / Labels) | Heavy governance (Org chart / Approvals / Budgets) |
| **Extensibility** | Skills system | Skills + Plugin system |
**TL;DR — Multica is built for teams that want to collaborate with AI agents on real projects together.**
---
## CLI
The `multica` CLI connects your local machine to Multica — authenticate, manage workspaces, and run the agent daemon.
@@ -111,9 +139,8 @@ The `multica` CLI connects your local machine to Multica — authenticate, manag
| `multica login` | Authenticate (opens browser) |
| `multica daemon start` | Start the local agent runtime |
| `multica daemon status` | Check daemon status |
| `multica setup` | One-command setup (configure + login + start daemon) |
| `multica setup --local` | Same, but for self-hosted deployments |
| `multica config local` | Configure CLI for a local self-hosted server |
| `multica setup` | One-command setup for Multica Cloud (configure + login + start daemon) |
| `multica setup self-host` | Same, but for self-hosted deployments |
| `multica issue list` | List issues in your workspace |
| `multica issue create` | Create a new issue |
| `multica update` | Update to the latest version |
@@ -157,3 +184,13 @@ make dev
`make dev` auto-detects your environment (main checkout or worktree), creates the env file, installs dependencies, sets up the database, runs migrations, and starts all services.
See [CONTRIBUTING.md](CONTRIBUTING.md) for the full development workflow, worktree support, testing, and troubleshooting.
## Star History
<a href="https://www.star-history.com/?repos=multica-ai%2Fmultica&type=date&legend=bottom-right">
<picture>
<source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/chart?repos=multica-ai/multica&type=date&legend=top-left" />
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/chart?repos=multica-ai/multica&type=date&legend=top-left" />
<img alt="Star History Chart" src="https://api.star-history.com/chart?repos=multica-ai/multica&type=date&legend=top-left" />
</picture>
</a>

View File

@@ -18,10 +18,9 @@
将编码 Agent 变成真正的队友——分配任务、跟踪进度、积累技能。
[![CI](https://github.com/multica-ai/multica/actions/workflows/ci.yml/badge.svg)](https://github.com/multica-ai/multica/actions/workflows/ci.yml)
[![License](https://img.shields.io/badge/License-Apache_2.0-blue.svg)](https://opensource.org/licenses/Apache-2.0)
[![GitHub stars](https://img.shields.io/github/stars/multica-ai/multica?style=flat)](https://github.com/multica-ai/multica/stargazers)
[官网](https://multica.ai) · [云服务](https://multica.ai/app) · [X](https://x.com/multica_hq) · [自部署指南](SELF_HOSTING.md) · [参与贡献](CONTRIBUTING.md)
[官网](https://multica.ai) · [云服务](https://multica.ai/app) · [X](https://x.com/MulticaAI) · [自部署指南](SELF_HOSTING.md) · [参与贡献](CONTRIBUTING.md)
**[English](README.md) | 简体中文**
@@ -51,24 +50,39 @@ Multica 管理完整的 Agent 生命周期:从任务分配到执行监控再
## 快速安装
### macOS / Linux推荐 Homebrew
```bash
brew install multica-ai/tap/multica
```
后续可用 `brew upgrade multica-ai/tap/multica` 更新 CLI。
### macOS / Linux安装脚本
```bash
curl -fsSL https://raw.githubusercontent.com/multica-ai/multica/main/scripts/install.sh | bash
```
安装 Multica CLI支持 macOS 和 Linux。有 Homebrew 用 Homebrew,没有则直接下载二进制。
如果没有 Homebrew可以使用安装脚本。脚本会安装 Multica CLI检测到 `brew` 时通过 Homebrew 安装,否则直接下载二进制。
安装完成后:
### Windows (PowerShell)
```bash
multica login # 认证(打开浏览器)
multica daemon start # 启动本地 Agent 运行时
multica daemon stop # 停止 daemon
```powershell
irm https://raw.githubusercontent.com/multica-ai/multica/main/scripts/install.ps1 | iex
```
> **自部署?** 加上 `--local` 在本地部署完整的 Multica 服务
安装完成后,一条命令完成配置、认证和启动
```bash
multica setup # 连接 Multica Cloud登录启动 daemon
```
> **自部署?** 加上 `--with-server` 在本地部署完整的 Multica 服务:
>
> ```bash
> curl -fsSL https://raw.githubusercontent.com/multica-ai/multica/main/scripts/install.sh | bash -s -- --local
> curl -fsSL https://raw.githubusercontent.com/multica-ai/multica/main/scripts/install.sh | bash -s -- --with-server
> multica setup self-host
> ```
>
> 需要 Docker。详见 [自部署指南](SELF_HOSTING.md)。
@@ -79,11 +93,10 @@ multica daemon stop # 停止 daemon
安装好 CLI或注册 [Multica 云服务](https://multica.ai))后,按以下步骤将第一个任务分配给 Agent
### 1. 登录并启动 daemon
### 1. 配置并启动 daemon
```bash
multica login # 使用你的 Multica 账号认证
multica daemon start # 启动本地 Agent 运行时
multica setup # 配置、认证、启动 daemon一条命令搞定
```
daemon 在后台运行,保持你的机器与 Multica 的连接。它会自动检测 PATH 中可用的 Agent CLI`claude``codex``openclaw``opencode`)。
@@ -104,6 +117,21 @@ daemon 在后台运行,保持你的机器与 Multica 的连接。它会自动
大功告成!你的 Agent 现在是团队的一员了。 🎉
---
## Multica vs Paperclip
| | Multica | Paperclip |
|---|---------|-----------|
| **定位** | 团队 AI Agent 协作平台 | 个人 AI Agent 公司模拟器 |
| **用户模型** | 多人团队,角色权限 | 单人 Board Operator |
| **Agent 交互** | Issue + Chat 对话 | Issue + Heartbeat |
| **部署** | 云端优先 | 本地优先 |
| **管理深度** | 轻量Issue / Project / Labels | 重度(组织架构 / 审批 / 预算) |
| **扩展** | Skills 系统 | Skills + 插件系统 |
**简单来说Multica 专为团队协作打造,让团队和 AI Agent 一起高效完成项目。**
## 架构
```
@@ -144,3 +172,13 @@ make start
## 开源协议
[Apache 2.0](LICENSE)
## Star History
<a href="https://www.star-history.com/?repos=multica-ai%2Fmultica&type=date&legend=bottom-right">
<picture>
<source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/chart?repos=multica-ai/multica&type=date&legend=top-left" />
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/chart?repos=multica-ai/multica&type=date&legend=top-left" />
<img alt="Star History Chart" src="https://api.star-history.com/chart?repos=multica-ai/multica&type=date&legend=top-left" />
</picture>
</a>

View File

@@ -14,22 +14,27 @@ Each user who runs AI agents locally also installs the **`multica` CLI** and run
## Quick Install (Recommended)
One command to set up everything — server, CLI, and configuration:
Two commands to set up everything — server, CLI, and configuration:
```bash
curl -fsSL https://raw.githubusercontent.com/multica-ai/multica/main/scripts/install.sh | bash -s -- --local
# 1. Install CLI + provision the self-host server
curl -fsSL https://raw.githubusercontent.com/multica-ai/multica/main/scripts/install.sh | bash -s -- --with-server
# 2. Configure CLI, authenticate, and start the daemon
multica setup self-host
```
This automatically clones the repository, starts all services via Docker Compose, and installs the `multica` CLI.
This clones the repository, starts all services via Docker Compose, installs the `multica` CLI, then configures it for localhost.
Once complete, open http://localhost:3000, log in with any email + verification code **`888888`**, then:
```bash
multica login # Authenticate (opens browser)
multica daemon start # Start the agent daemon
```
Open http://localhost:3000, log in with any email + verification code **`888888`**.
> **Prerequisites:** Docker and Docker Compose must be installed. The script checks for this and provides install links if missing.
>
> **CLI only?** If the self-host server is already running and you only need the CLI on a macOS/Linux machine, install it with Homebrew:
>
> ```bash
> brew install multica-ai/tap/multica
> ```
---
@@ -77,11 +82,14 @@ brew install multica-ai/tap/multica
You also need at least one AI agent CLI installed:
- [Claude Code](https://docs.anthropic.com/en/docs/claude-code) (`claude` on PATH)
- [Codex](https://github.com/openai/codex) (`codex` on PATH)
- [OpenClaw](https://github.com/openclaw/openclaw) (`openclaw` on PATH)
- [OpenCode](https://github.com/anomalyco/opencode) (`opencode` on PATH)
- [Hermes](https://github.com/NousResearch/hermes) (`hermes` on PATH)
### b) One-command setup
```bash
multica setup --local
multica setup self-host
```
This automatically:
@@ -90,6 +98,12 @@ This automatically:
3. Discovers your workspaces
4. Starts the daemon in the background
For on-premise deployments with custom domains:
```bash
multica setup self-host --server-url https://api.example.com --app-url https://app.example.com
```
To verify the daemon is running:
```bash
@@ -123,6 +137,18 @@ make selfhost-stop
multica daemon stop
```
## Switching to Multica Cloud
If you've been self-hosting and want to switch your CLI to [Multica Cloud](https://multica.ai):
```bash
multica setup
```
This reconfigures the CLI for multica.ai, re-authenticates, and restarts the daemon. You will be prompted before overwriting the existing configuration.
> Your local Docker services are unaffected. Stop them separately if you no longer need them.
## Rebuilding After Updates
```bash
@@ -162,11 +188,8 @@ If you prefer configuring the CLI step by step instead of `multica setup`:
```bash
# Point CLI to your local server
multica config local
# Or set URLs manually:
# multica config set app_url http://localhost:3000
# multica config set server_url http://localhost:8080
multica config set server_url http://localhost:8080
multica config set app_url http://localhost:3000
# Login (opens browser)
multica login

View File

@@ -66,6 +66,21 @@ These are configured on each user's machine, not on the server:
| `MULTICA_DAEMON_POLL_INTERVAL` | `3s` | How often the daemon polls for tasks |
| `MULTICA_DAEMON_HEARTBEAT_INTERVAL` | `15s` | Heartbeat frequency |
Agent-specific overrides:
| Variable | Description |
|----------|-------------|
| `MULTICA_CLAUDE_PATH` | Custom path to the `claude` binary |
| `MULTICA_CLAUDE_MODEL` | Override the Claude model used |
| `MULTICA_CODEX_PATH` | Custom path to the `codex` binary |
| `MULTICA_CODEX_MODEL` | Override the Codex model used |
| `MULTICA_OPENCODE_PATH` | Custom path to the `opencode` binary |
| `MULTICA_OPENCODE_MODEL` | Override the OpenCode model used |
| `MULTICA_OPENCLAW_PATH` | Custom path to the `openclaw` binary |
| `MULTICA_OPENCLAW_MODEL` | Override the OpenClaw model used |
| `MULTICA_HERMES_PATH` | Custom path to the `hermes` binary |
| `MULTICA_HERMES_MODEL` | Override the Hermes model used |
## Database Setup
Multica requires PostgreSQL 17 with the pgvector extension.
@@ -203,6 +218,26 @@ NEXT_PUBLIC_API_URL=https://api.example.com
NEXT_PUBLIC_WS_URL=wss://api.example.com/ws
```
## LAN / Non-localhost Access
By default, Multica works on `localhost`. If you access it from another machine on the LAN (e.g. `http://192.168.1.100:3000`), you need to tell the backend to accept that origin:
```bash
# .env — replace with your server's LAN IP
FRONTEND_ORIGIN=http://192.168.1.100:3000
CORS_ALLOWED_ORIGINS=http://192.168.1.100:3000
```
Then rebuild:
```bash
docker compose -f docker-compose.selfhost.yml up -d --build
```
The frontend automatically derives the WebSocket URL from the page address, so real-time features (chat streaming, live issue updates, notifications) work over LAN without extra configuration.
> **Note:** If you need to override the WebSocket URL explicitly (e.g. when using a separate backend domain), set `NEXT_PUBLIC_WS_URL` in `.env` and rebuild the frontend image.
## Health Check
The backend exposes a health check endpoint:

View File

@@ -8,15 +8,17 @@ This document is designed for AI agents to execute. Follow these steps exactly t
- Homebrew installed (for CLI)
- At least one AI agent CLI on PATH: `claude` or `codex`
## One-Command Install
## Install
```bash
curl -fsSL https://raw.githubusercontent.com/multica-ai/multica/main/scripts/install.sh | bash -s -- --local
# Install CLI + provision self-host server
curl -fsSL https://raw.githubusercontent.com/multica-ai/multica/main/scripts/install.sh | bash -s -- --with-server
# Configure CLI for localhost, authenticate, and start daemon
multica setup self-host
```
This installs everything: clones the repo, starts Docker services, and installs the CLI.
Wait for the output `✓ Multica is installed and running!` before proceeding.
Wait for the server output `✓ Multica server is running and CLI is ready!` before running `multica setup self-host`.
**Expected result:**
- Frontend at http://localhost:3000
@@ -30,10 +32,10 @@ git clone https://github.com/multica-ai/multica.git
cd multica
make selfhost
brew install multica-ai/tap/multica
multica setup --local
multica setup self-host
```
The `multica setup --local` command will:
The `multica setup self-host` command will:
1. Configure CLI to connect to localhost:8080 / localhost:3000
2. Open a browser for login — use verification code `888888` with any email
3. Discover workspaces automatically
@@ -64,7 +66,7 @@ If the default ports (8080/3000) are in use:
1. Edit `.env` and change `PORT` and `FRONTEND_PORT`
2. Run `make selfhost`
3. Run `multica setup --local --port <PORT> --frontend-port <FRONTEND_PORT>`
3. Run `multica setup self-host --port <PORT> --frontend-port <FRONTEND_PORT>`
## Troubleshooting

View File

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

View File

@@ -4,3 +4,5 @@ out
.DS_Store
.eslintcache
*.log*
# CLI binary bundled at build time (from server/bin/)
resources/bin/

View File

@@ -0,0 +1,24 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<!-- Electron / V8 need JIT and unsigned executable memory under the
hardened runtime. -->
<key>com.apple.security.cs.allow-jit</key>
<true/>
<key>com.apple.security.cs.allow-unsigned-executable-memory</key>
<true/>
<!-- Required so the app can spawn the bundled `multica` Go binary and
any other child processes (e.g. agent CLIs) without Gatekeeper
blocking exec. -->
<key>com.apple.security.cs.disable-library-validation</key>
<true/>
<key>com.apple.security.cs.allow-dyld-environment-variables</key>
<true/>
<!-- Network client — the daemon talks to the backend + GitHub releases. -->
<key>com.apple.security.network.client</key>
<true/>
<key>com.apple.security.network.server</key>
<true/>
</dict>
</plist>

View File

@@ -8,6 +8,10 @@ files:
- "!electron.vite.config.*"
- "!{.eslintignore,.eslintrc.cjs,.prettierignore,.prettierrc.yaml,dev-app-update.yml,CHANGELOG.md,README.md}"
- "!{tsconfig.json,tsconfig.node.json,tsconfig.web.json}"
protocols:
- name: Multica
schemes:
- multica
asarUnpack:
- resources/**
mac:
@@ -15,10 +19,16 @@ mac:
target:
- dmg
- zip
artifactName: ${name}-${version}-${arch}.${ext}
notarize: false
# Hardcoded name avoids the `@multica/desktop-*` subdirectory that
# `${name}` produces for scoped package names.
artifactName: multica-desktop-${version}-${arch}.${ext}
# Notarize via notarytool. Requires APPLE_ID + APPLE_APP_SPECIFIC_PASSWORD
# + APPLE_TEAM_ID env vars at package time. Non-mac contributors are
# unaffected because `pnpm package` already requires the Developer ID
# signing cert — notarization is a strict superset.
notarize: true
dmg:
artifactName: ${name}-${version}.${ext}
artifactName: multica-desktop-${version}-${arch}.${ext}
linux:
target:
- AppImage
@@ -28,4 +38,8 @@ win:
target:
- nsis
artifactName: ${name}-${version}-setup.${ext}
publish:
provider: github
owner: multica-ai
repo: multica
npmRebuild: false

View File

@@ -1,6 +1,13 @@
import globals from "globals";
import reactConfig from "@multica/eslint-config/react";
export default [
...reactConfig,
{ ignores: ["out/", "dist/"] },
{
files: ["scripts/**/*.{mjs,js}"],
languageOptions: {
globals: { ...globals.node },
},
},
];

View File

@@ -4,14 +4,16 @@
"private": true,
"main": "./out/main/index.js",
"scripts": {
"dev": "electron-vite dev",
"build": "electron-vite build",
"bundle-cli": "node scripts/bundle-cli.mjs",
"dev": "pnpm run bundle-cli && electron-vite dev",
"build": "pnpm run bundle-cli && electron-vite build",
"typecheck:node": "tsc --noEmit -p tsconfig.node.json --composite false",
"typecheck:web": "tsc --noEmit -p tsconfig.web.json --composite false",
"typecheck": "pnpm run typecheck:node && pnpm run typecheck:web",
"preview": "electron-vite preview",
"package": "electron-builder",
"package": "node scripts/package.mjs",
"lint": "eslint .",
"test": "vitest run",
"postinstall": "electron-builder install-app-deps"
},
"dependencies": {
@@ -21,11 +23,12 @@
"@dnd-kit/utilities": "^3.2.2",
"@electron-toolkit/preload": "^3.0.2",
"@electron-toolkit/utils": "^4.0.0",
"@fontsource-variable/inter": "^5.2.5",
"@fontsource/geist-mono": "^5.2.7",
"@multica/core": "workspace:*",
"@multica/ui": "workspace:*",
"@multica/views": "workspace:*",
"@fontsource/geist-mono": "^5.2.7",
"@fontsource/geist-sans": "^5.2.5",
"electron-updater": "^6.8.3",
"react-router-dom": "^7.6.0",
"shadcn": "^4.1.0",
"sonner": "^2.0.7",
@@ -45,6 +48,7 @@
"react": "catalog:",
"react-dom": "catalog:",
"tailwindcss": "^4",
"typescript": "catalog:"
"typescript": "catalog:",
"vitest": "catalog:"
}
}

View File

@@ -0,0 +1,110 @@
#!/usr/bin/env node
// Builds the `multica` CLI from server/cmd/multica and copies the binary
// into apps/desktop/resources/bin/ so electron-vite (dev) and electron-
// builder (prod) pick it up. Running this on every dev/build/package
// invocation guarantees the bundled CLI always matches the current Go
// source — no more stale binary surprises. Go's build cache makes the
// no-op case (nothing changed) effectively free.
//
// ldflags mirror `make build` so `multica --version` reports a meaningful
// version / commit / date.
//
// Graceful: if `go` is not installed (e.g. frontend-only contributor), we
// skip the build and fall through to auto-install at runtime. A genuine
// Go compile error is fatal — you want that to block dev, not hide.
import { access, chmod, copyFile, mkdir } from "node:fs/promises";
import { constants } from "node:fs";
import { execFileSync, execSync } from "node:child_process";
import { dirname, join, resolve } from "node:path";
import { fileURLToPath } from "node:url";
const here = dirname(fileURLToPath(import.meta.url));
const repoRoot = resolve(here, "..", "..", "..");
const serverDir = join(repoRoot, "server");
const binName = process.platform === "win32" ? "multica.exe" : "multica";
const srcBinary = join(serverDir, "bin", binName);
const destDir = join(repoRoot, "apps", "desktop", "resources", "bin");
const destBinary = join(destDir, binName);
function sh(cmd) {
try {
return execSync(cmd, { encoding: "utf-8" }).trim();
} catch {
return "";
}
}
function hasGo() {
try {
execSync("go version", { stdio: "pipe" });
return true;
} catch {
return false;
}
}
async function exists(p) {
try {
await access(p, constants.F_OK);
return true;
} catch {
return false;
}
}
if (hasGo()) {
const version = sh("git describe --tags --always --dirty") || "dev";
const commit = sh("git rev-parse --short HEAD") || "unknown";
const date = new Date().toISOString().replace(/\.\d+Z$/, "Z");
const ldflags = `-X main.version=${version} -X main.commit=${commit} -X main.date=${date}`;
console.log(
`[bundle-cli] go build → ${srcBinary} (version=${version} commit=${commit})`,
);
execFileSync(
"go",
[
"build",
"-ldflags",
ldflags,
"-o",
join("bin", binName),
"./cmd/multica",
],
{ cwd: serverDir, stdio: "inherit" },
);
} else {
console.warn(
"[bundle-cli] `go` not found in PATH — skipping CLI build. " +
"Desktop will use whatever is already in resources/bin/, or fall back " +
"to auto-installing the latest release at runtime.",
);
}
if (!(await exists(srcBinary))) {
console.warn(
`[bundle-cli] ${srcBinary} not present — Desktop will fall back to ` +
`auto-installing the latest release at runtime.`,
);
process.exit(0);
}
await mkdir(destDir, { recursive: true });
await copyFile(srcBinary, destBinary);
await chmod(destBinary, 0o755);
// macOS: ad-hoc sign so Gatekeeper doesn't complain when the parent app
// (which itself may be unsigned in dev) spawns the child.
if (process.platform === "darwin") {
try {
execSync(`codesign -s - --force ${JSON.stringify(destBinary)}`, {
stdio: "pipe",
});
} catch {
// Non-fatal. Unsigned binaries still run when the parent app is trusted.
}
}
console.log(`[bundle-cli] bundled ${srcBinary}${destBinary}`);

View File

@@ -0,0 +1,122 @@
#!/usr/bin/env node
// Wrapper around `electron-builder` that keeps the Desktop version in
// lockstep with the CLI. Both are derived from `git describe --tags
// --always --dirty` — the same source GoReleaser reads for the CLI
// binary via the `main.version` ldflag — so a single `vX.Y.Z` tag push
// produces matching CLI and Desktop versions.
//
// Runs the existing bundle-cli.mjs first (so the Go binary is compiled
// and copied into resources/bin/), then invokes electron-builder with
// `-c.extraMetadata.version=<derived>` so the override applies at build
// time without mutating the tracked package.json.
//
// Extra CLI args after `pnpm package --` are forwarded to electron-builder
// unchanged (e.g. `--mac --arm64`).
//
// The `normalizeGitVersion` helper is exported so tests can cover the
// version-derivation logic without shelling out.
import { execFileSync, spawnSync, execSync } from "node:child_process";
import { dirname, resolve } from "node:path";
import { fileURLToPath, pathToFileURL } from "node:url";
const here = dirname(fileURLToPath(import.meta.url));
const desktopRoot = resolve(here, "..");
function sh(cmd) {
try {
return execSync(cmd, { encoding: "utf-8" }).trim();
} catch {
return "";
}
}
/**
* Pure transformation from the `git describe --tags --always --dirty`
* output to the value we feed into electron-builder's extraMetadata.version.
*
* - empty input → null (caller should fall back)
* - "v0.1.36" → "0.1.36"
* - "v0.1.35-14-gf1415e96" → "0.1.35-14-gf1415e96" (semver prerelease)
* - "v0.1.35-…-dirty" → same, dirty suffix preserved
* - "f1415e96" (no tag) → "0.0.0-f1415e96" (fallback)
*
* Leading `v` is stripped so the result is valid semver for package.json.
*/
export function normalizeGitVersion(raw) {
if (!raw) return null;
const stripped = raw.replace(/^v/, "");
if (!/^\d/.test(stripped)) {
// No reachable tag — `git describe` fell back to just the commit hash.
return `0.0.0-${stripped}`;
}
return stripped;
}
function deriveVersion() {
return normalizeGitVersion(sh("git describe --tags --always --dirty"));
}
function main() {
// Step 1: build + bundle the Go CLI via the existing script.
execFileSync("node", [resolve(here, "bundle-cli.mjs")], {
stdio: "inherit",
cwd: desktopRoot,
});
// Step 2: derive the version that should be written into the app.
const version = deriveVersion();
if (version) {
console.log(`[package] Desktop version → ${version} (from git describe)`);
} else {
console.warn(
"[package] could not derive version from git; falling back to package.json",
);
}
// Step 3: assemble electron-builder args.
const passthrough = process.argv.slice(2);
const builderArgs = [];
if (version) builderArgs.push(`-c.extraMetadata.version=${version}`);
// Step 4: gracefully degrade for local dev builds. electron-builder.yml
// sets `notarize: true` so real releases notarize in-build (keeping the
// stapled .app consistent with latest-mac.yml's SHA512). But a mac dev
// who just wants to smoke-test a local package doesn't have Apple
// credentials, and would otherwise hit a hard failure at the notarize
// step. Detect the missing env and flip notarize off for this run only.
if (!process.env.APPLE_TEAM_ID) {
console.warn(
"[package] APPLE_TEAM_ID not set — skipping notarization (local dev build). " +
"Set APPLE_ID + APPLE_APP_SPECIFIC_PASSWORD + APPLE_TEAM_ID for a release build.",
);
builderArgs.push("-c.mac.notarize=false");
}
builderArgs.push(...passthrough);
// Step 5: invoke electron-builder. pnpm puts node_modules/.bin on PATH
// for the script run, so spawnSync finds the binary without needing a
// shell wrapper (avoids any risk of argv interpolation).
const result = spawnSync("electron-builder", builderArgs, {
stdio: "inherit",
cwd: desktopRoot,
});
if (result.error) {
console.error(
"[package] failed to spawn electron-builder:",
result.error.message,
);
process.exit(1);
}
process.exit(result.status ?? 1);
}
// Only run when invoked as a CLI, not when imported by a test file.
if (
process.argv[1] &&
import.meta.url === pathToFileURL(process.argv[1]).href
) {
main();
}

View File

@@ -0,0 +1,39 @@
import { describe, it, expect } from "vitest";
import { normalizeGitVersion } from "./package.mjs";
describe("normalizeGitVersion", () => {
it("returns null for empty / nullish input", () => {
expect(normalizeGitVersion("")).toBe(null);
expect(normalizeGitVersion(null)).toBe(null);
expect(normalizeGitVersion(undefined)).toBe(null);
});
it("strips the leading v on a clean tag", () => {
expect(normalizeGitVersion("v0.1.36")).toBe("0.1.36");
expect(normalizeGitVersion("v1.0.0")).toBe("1.0.0");
});
it("preserves the prerelease suffix between tags", () => {
expect(normalizeGitVersion("v0.1.35-14-gf1415e96")).toBe(
"0.1.35-14-gf1415e96",
);
});
it("preserves the dirty suffix on a modified worktree", () => {
expect(normalizeGitVersion("v0.1.35-14-gf1415e96-dirty")).toBe(
"0.1.35-14-gf1415e96-dirty",
);
});
it("handles v-prefixed prerelease tags", () => {
expect(normalizeGitVersion("v1.0.0-alpha")).toBe("1.0.0-alpha");
expect(normalizeGitVersion("v1.0.0-rc.2")).toBe("1.0.0-rc.2");
});
it("falls back to 0.0.0-<hash> when no tags are reachable", () => {
// `git describe --tags --always` returns just the short commit hash
// when there are no tags in the history at all.
expect(normalizeGitVersion("f1415e96")).toBe("0.0.0-f1415e96");
expect(normalizeGitVersion("abc1234")).toBe("0.0.0-abc1234");
});
});

View File

@@ -0,0 +1,173 @@
import { app } from "electron";
import { execFile } from "child_process";
import { createHash } from "crypto";
import { createReadStream, createWriteStream, existsSync } from "fs";
import { chmod, mkdir, rename, rm } from "fs/promises";
import { join, dirname } from "path";
import { pipeline } from "stream/promises";
import { tmpdir } from "os";
import { Readable } from "stream";
// Desktop bootstraps its own copy of the `multica` CLI into userData on first
// launch, so users never have to brew-install anything. Build-time decoupled:
// we don't bundle the binary into the .app, we download whatever the upstream
// release is at first run.
const GITHUB_LATEST_BASE =
"https://github.com/multica-ai/multica/releases/latest/download";
function platformAssetName(): string {
const osMap: Record<string, string> = {
darwin: "darwin",
linux: "linux",
win32: "windows",
};
const archMap: Record<string, string> = {
x64: "amd64",
arm64: "arm64",
};
const os = osMap[process.platform];
const arch = archMap[process.arch];
if (!os || !arch) {
throw new Error(
`unsupported platform for CLI auto-install: ${process.platform}/${process.arch}`,
);
}
const ext = process.platform === "win32" ? "zip" : "tar.gz";
return `multica_${os}_${arch}.${ext}`;
}
function binaryName(): string {
return process.platform === "win32" ? "multica.exe" : "multica";
}
export function managedCliPath(): string {
return join(app.getPath("userData"), "bin", binaryName());
}
function run(cmd: string, args: string[], cwd?: string): Promise<void> {
return new Promise((resolve, reject) => {
execFile(cmd, args, { cwd }, (err) => (err ? reject(err) : resolve()));
});
}
async function downloadToFile(url: string, dest: string): Promise<void> {
const res = await fetch(url, { redirect: "follow" });
if (!res.ok || !res.body) {
throw new Error(`download failed: ${res.status} ${res.statusText}`);
}
await mkdir(dirname(dest), { recursive: true });
// Node's fetch returns a web ReadableStream; adapt to a Node stream for pipeline.
const nodeStream = Readable.fromWeb(res.body as Parameters<typeof Readable.fromWeb>[0]);
await pipeline(nodeStream, createWriteStream(dest));
}
// Fetch goreleaser's published checksums.txt and parse it into a
// filename → sha256 lookup. Format is `<hex> <filename>` per line.
async function fetchChecksums(): Promise<Map<string, string>> {
const url = `${GITHUB_LATEST_BASE}/checksums.txt`;
const res = await fetch(url, { redirect: "follow" });
if (!res.ok) {
throw new Error(
`checksums.txt fetch failed: ${res.status} ${res.statusText}`,
);
}
const text = await res.text();
const map = new Map<string, string>();
for (const rawLine of text.split("\n")) {
const line = rawLine.trim();
if (!line) continue;
const match = line.match(/^([a-f0-9]{64})\s+\*?(\S+)$/i);
if (match) map.set(match[2], match[1].toLowerCase());
}
return map;
}
async function sha256OfFile(path: string): Promise<string> {
const hash = createHash("sha256");
await pipeline(createReadStream(path), hash);
return hash.digest("hex");
}
async function verifyChecksum(
archivePath: string,
assetName: string,
): Promise<void> {
const checksums = await fetchChecksums();
const expected = checksums.get(assetName);
if (!expected) {
throw new Error(
`no checksum for ${assetName} in checksums.txt — refusing to install unverified binary`,
);
}
const actual = await sha256OfFile(archivePath);
if (actual.toLowerCase() !== expected) {
throw new Error(
`checksum mismatch for ${assetName}: expected ${expected}, got ${actual}`,
);
}
}
async function extractArchive(archive: string, dest: string): Promise<void> {
await mkdir(dest, { recursive: true });
// Modern OSes all ship a `tar` that auto-detects tar.gz and zip:
// - macOS/Linux: GNU tar or bsdtar
// - Windows 10+: bsdtar is bundled as `tar.exe` since build 17063
await run("tar", ["-xf", archive, "-C", dest]);
}
async function installFresh(): Promise<string> {
const target = managedCliPath();
const assetName = platformAssetName();
const url = `${GITHUB_LATEST_BASE}/${assetName}`;
const workDir = join(tmpdir(), `multica-cli-${Date.now()}`);
await mkdir(workDir, { recursive: true });
try {
const archivePath = join(workDir, assetName);
console.log(`[cli-bootstrap] downloading ${url}`);
await downloadToFile(url, archivePath);
console.log(`[cli-bootstrap] verifying ${assetName} against checksums.txt`);
await verifyChecksum(archivePath, assetName);
console.log(`[cli-bootstrap] extracting ${assetName}`);
await extractArchive(archivePath, workDir);
const extractedBin = join(workDir, binaryName());
if (!existsSync(extractedBin)) {
throw new Error(
`archive ${assetName} did not contain ${binaryName()} at its root`,
);
}
await mkdir(dirname(target), { recursive: true });
await rename(extractedBin, target);
await chmod(target, 0o755);
// macOS: ad-hoc sign so spawning the child never hits a gatekeeper quirk.
// Non-fatal: unsigned binaries still execute when the parent app is trusted.
if (process.platform === "darwin") {
await run("codesign", ["-s", "-", "--force", target]).catch((err) => {
console.warn("[cli-bootstrap] ad-hoc codesign failed:", err);
});
}
console.log(`[cli-bootstrap] installed CLI at ${target}`);
return target;
} finally {
await rm(workDir, { recursive: true, force: true }).catch(() => {});
}
}
/**
* Returns the path to a usable `multica` binary. If one is already present at
* the managed userData location, returns it immediately. Otherwise downloads
* the latest release asset for the current platform and installs it.
*/
export async function ensureManagedCli(): Promise<string> {
const target = managedCliPath();
if (existsSync(target)) return target;
return installFresh();
}

View File

@@ -0,0 +1,901 @@
import { app, ipcMain, BrowserWindow } from "electron";
import { execFile } from "child_process";
import {
readFile,
writeFile,
mkdir,
rm,
open,
stat,
} from "fs/promises";
import {
existsSync,
watchFile,
unwatchFile,
type StatsListener,
} from "fs";
import { join } from "path";
import { homedir } from "os";
import type { DaemonStatus, DaemonPrefs } from "../shared/daemon-types";
import { ensureManagedCli, managedCliPath } from "./cli-bootstrap";
import { decideVersionAction } from "./version-decision";
const DEFAULT_HEALTH_PORT = 19514;
const POLL_INTERVAL_MS = 5_000;
const PREFS_PATH = join(homedir(), ".multica", "desktop_prefs.json");
const LOG_TAIL_RETRY_MS = 2_000;
const LOG_TAIL_MAX_RETRIES = 5;
const DEFAULT_PREFS: DaemonPrefs = { autoStart: true, autoStop: false };
interface ActiveProfile {
name: string; // "" = default profile
port: number;
}
let statusPollTimer: ReturnType<typeof setInterval> | null = null;
let logTailWatcher: { path: string; listener: StatsListener } | null = null;
let currentState: DaemonStatus["state"] = "installing_cli";
let getMainWindow: () => BrowserWindow | null = () => null;
let operationInProgress = false;
let cachedCliBinary: string | null | undefined = undefined;
let cliResolvePromise: Promise<string | null> | null = null;
let cachedCliBinaryVersion: string | null | undefined = undefined;
// Set when a CLI version mismatch was detected but the running daemon is
// busy executing tasks. The poll loop retries the check on each tick and
// fires the restart once active_task_count drops to 0.
let pendingVersionRestart = false;
let targetApiBaseUrl: string | null = null;
let activeProfile: ActiveProfile | null = null;
// Serialize all writes to any profile config file. Multiple paths
// (syncToken, resolveActiveProfile, clearToken, watch/unwatch handlers)
// may try to write concurrently; chaining them avoids interleaved writes
// corrupting the JSON.
let configWriteChain: Promise<void> = Promise.resolve();
// Keep the Go impl in sync: server/cmd/multica/cmd_daemon.go healthPortForProfile.
function healthPortForProfile(profile: string): number {
if (!profile) return DEFAULT_HEALTH_PORT;
let sum = 0;
for (const b of Buffer.from(profile, "utf-8")) sum += b;
return DEFAULT_HEALTH_PORT + 1 + (sum % 1000);
}
function profileDir(profile: string): string {
return profile
? join(homedir(), ".multica", "profiles", profile)
: join(homedir(), ".multica");
}
function profileConfigPath(profile: string): string {
return join(profileDir(profile), "config.json");
}
function profileLogPath(profile: string): string {
return join(profileDir(profile), "daemon.log");
}
// Sidecar file that records which Multica user the cached PAT in config.json
// was minted for. The Go CLI/daemon never read or write this file, so it
// survives Go-side config rewrites. Used to detect user switches and mint a
// fresh PAT instead of reusing a token that belongs to a previous user.
function profileUserIdPath(profile: string): string {
return join(profileDir(profile), ".desktop-user-id");
}
async function readProfileUserId(profile: string): Promise<string | null> {
try {
const raw = await readFile(profileUserIdPath(profile), "utf-8");
const trimmed = raw.trim();
return trimmed || null;
} catch {
return null;
}
}
async function writeProfileUserId(
profile: string,
userId: string,
): Promise<void> {
await mkdir(profileDir(profile), { recursive: true });
await writeFile(profileUserIdPath(profile), userId, "utf-8");
}
async function removeProfileUserId(profile: string): Promise<void> {
try {
await rm(profileUserIdPath(profile));
} catch {
// Already gone — nothing to do.
}
}
function normalizeUrl(u: string): string {
if (!u) return "";
try {
const parsed = new URL(u);
return `${parsed.protocol}//${parsed.host}`.toLowerCase();
} catch {
return u.replace(/\/+$/, "").toLowerCase();
}
}
function urlsMatch(a: string, b: string): boolean {
const na = normalizeUrl(a);
const nb = normalizeUrl(b);
return na.length > 0 && na === nb;
}
function sendStatus(status: DaemonStatus): void {
const win = getMainWindow();
win?.webContents.send("daemon:status", status);
}
interface HealthPayload {
status?: string;
pid?: number;
uptime?: string;
daemon_id?: string;
device_name?: string;
server_url?: string;
cli_version?: string;
active_task_count?: number;
agents?: string[];
workspaces?: unknown[];
}
async function fetchHealthAtPort(
port: number,
): Promise<HealthPayload | null> {
try {
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), 2_000);
const res = await fetch(`http://127.0.0.1:${port}/health`, {
signal: controller.signal,
});
clearTimeout(timeout);
if (!res.ok) return null;
return (await res.json()) as HealthPayload;
} catch {
return null;
}
}
// Desktop owns a dedicated CLI profile named after the target API host, so it
// never reads or writes the user's hand-configured profiles. Profile dir:
// ~/.multica/profiles/desktop-<host>/
function deriveProfileName(targetUrl: string): string {
try {
const url = new URL(targetUrl);
const host = url.host.replace(/:/g, "-").toLowerCase();
return `desktop-${host}`;
} catch {
return "desktop";
}
}
async function readProfileConfig(
profile: string,
): Promise<Record<string, unknown>> {
try {
const raw = await readFile(profileConfigPath(profile), "utf-8");
const parsed = JSON.parse(raw);
return parsed && typeof parsed === "object" ? parsed : {};
} catch {
return {};
}
}
async function writeProfileConfig(
profile: string,
cfg: Record<string, unknown>,
): Promise<void> {
const op = async () => {
await mkdir(profileDir(profile), { recursive: true });
await writeFile(
profileConfigPath(profile),
JSON.stringify(cfg, null, 2),
"utf-8",
);
};
const next = configWriteChain.catch(() => {}).then(op);
configWriteChain = next.catch(() => {});
return next;
}
/**
* Returns the Desktop-owned profile for the current target API URL. Creates
* the profile's config.json on demand with `server_url` pinned to the target.
*
* This function never falls back to the default profile, and never touches a
* profile whose name doesn't start with `desktop-`, so the user's manually
* configured CLI profiles are untouched.
*/
async function resolveActiveProfile(): Promise<ActiveProfile> {
const target = targetApiBaseUrl;
if (!target) return { name: "", port: DEFAULT_HEALTH_PORT };
const name = deriveProfileName(target);
const cfg = await readProfileConfig(name);
if (cfg.server_url !== target) {
cfg.server_url = target;
await writeProfileConfig(name, cfg);
console.log(`[daemon] initialized profile "${name}" → ${target}`);
}
return { name, port: healthPortForProfile(name) };
}
async function ensureActiveProfile(): Promise<ActiveProfile> {
if (activeProfile) return activeProfile;
activeProfile = await resolveActiveProfile();
return activeProfile;
}
function invalidateActiveProfile(): void {
activeProfile = null;
}
async function fetchHealth(): Promise<DaemonStatus> {
// While the CLI is being downloaded or has permanently failed, short-circuit
// polling — there's nothing to probe yet and /health calls would just return
// "stopped", which would overwrite the correct setup state in the UI.
if (currentState === "installing_cli" || currentState === "cli_not_found") {
return { state: currentState };
}
const active = await ensureActiveProfile();
const data = await fetchHealthAtPort(active.port);
if (!data || data.status !== "running") {
return {
state: currentState === "starting" ? "starting" : "stopped",
profile: active.name,
};
}
// Safety: if we have a target URL and the daemon on our port reports a
// different server_url, it's not "our" daemon — drop it and re-resolve.
if (
targetApiBaseUrl &&
data.server_url &&
!urlsMatch(data.server_url, targetApiBaseUrl)
) {
invalidateActiveProfile();
return { state: "stopped" };
}
return {
state: "running",
pid: data.pid,
uptime: data.uptime,
daemonId: data.daemon_id,
deviceName: data.device_name,
agents: data.agents ?? [],
workspaceCount: Array.isArray(data.workspaces)
? data.workspaces.length
: 0,
profile: active.name,
serverUrl: data.server_url,
};
}
function findCliOnPath(): string | null {
const candidates = process.platform === "win32" ? ["multica.exe"] : ["multica"];
const paths = (process.env["PATH"] ?? "").split(
process.platform === "win32" ? ";" : ":",
);
if (process.platform === "darwin") {
paths.push("/opt/homebrew/bin", "/usr/local/bin");
}
for (const name of candidates) {
for (const dir of paths) {
const full = join(dir, name);
if (existsSync(full)) return full;
}
}
return null;
}
/**
* Returns the path to the CLI binary bundled inside the Desktop app.
*
* - Dev (`electron-vite dev`): `app.getAppPath()` → `apps/desktop`, resolving
* to `apps/desktop/resources/bin/multica`. `bundle-cli.mjs` populates this
* before dev starts, so iterating on Go changes is "make build → restart".
* - Packaged: `app.getAppPath()` → `<Multica.app>/Contents/Resources/app.asar`.
* electron-builder's `asarUnpack: resources/**` extracts the binary to
* `app.asar.unpacked/`, so we swap the path segment to execute it.
*/
function bundledCliPath(): string {
const binName = process.platform === "win32" ? "multica.exe" : "multica";
return join(app.getAppPath(), "resources", "bin", binName).replace(
"app.asar",
"app.asar.unpacked",
);
}
/**
* Returns a usable `multica` binary path. Priority:
* 1. Cached result from a previous successful resolve.
* 2. Bundled binary shipped with the Desktop app (`bundle-cli.mjs`).
* 3. Managed binary already installed in userData (`managedCliPath`).
* 4. Download + install latest release into userData.
* 5. `multica` on PATH (dev convenience / user-installed via brew).
* Returns `null` only when all of the above fail.
*
* Bundled is preferred so Desktop iterates in lockstep with Go changes in
* the same repo — avoids the 404 / stale-API problem when the Desktop's
* TS side is ahead of the last published CLI release.
*
* This function is idempotent and safe to call concurrently — in-flight
* installs are de-duplicated via `cliResolvePromise`.
*/
async function resolveCliBinary(): Promise<string | null> {
if (cachedCliBinary !== undefined) return cachedCliBinary;
if (cliResolvePromise) return cliResolvePromise;
cliResolvePromise = (async () => {
const bundled = bundledCliPath();
if (existsSync(bundled)) {
console.log(`[daemon] using bundled CLI at ${bundled}`);
cachedCliBinary = bundled;
return bundled;
}
const managed = managedCliPath();
if (existsSync(managed)) {
cachedCliBinary = managed;
return managed;
}
try {
const installed = await ensureManagedCli();
cachedCliBinary = installed;
return installed;
} catch (err) {
console.warn("[daemon] CLI auto-install failed, falling back to PATH:", err);
const onPath = findCliOnPath();
cachedCliBinary = onPath;
return onPath;
}
})();
try {
return await cliResolvePromise;
} finally {
cliResolvePromise = null;
}
}
/**
* Reads the version of the currently resolved CLI binary by invoking
* `multica version --output json`. Cached for the process lifetime — the
* bundled binary doesn't change after `bundle-cli.mjs` runs at dev/build time.
* Returns null on any failure (unknown `go` at bundle time, broken binary,
* etc.) so callers can fail open.
*/
async function getCliBinaryVersion(): Promise<string | null> {
if (cachedCliBinaryVersion !== undefined) return cachedCliBinaryVersion;
const bin = await resolveCliBinary();
if (!bin) {
cachedCliBinaryVersion = null;
return null;
}
try {
const stdout = await new Promise<string>((resolve, reject) => {
execFile(
bin,
["version", "--output", "json"],
{ timeout: 5_000 },
(err, out) => {
if (err) reject(err);
else resolve(out);
},
);
});
const parsed = JSON.parse(stdout) as { version?: string };
cachedCliBinaryVersion = parsed.version ?? null;
} catch (err) {
console.warn("[daemon] failed to read CLI binary version:", err);
cachedCliBinaryVersion = null;
}
return cachedCliBinaryVersion;
}
/**
* Compares the running daemon's `cli_version` against the CLI binary we
* would use to spawn a new one, and restarts only when safe. The decision
* logic itself is in `version-decision.ts` (pure, unit-tested); this
* wrapper handles the async plumbing and side effects.
*
* Restart is only fired when ALL of:
* - a daemon is actually running on the active profile's port
* - both sides report a version and the strings differ
* - `active_task_count` is 0 (no in-flight agent work would be killed)
*
* On a confirmed mismatch while the daemon is busy, `pendingVersionRestart`
* is set; the poll loop retries this function on each 5s tick and will fire
* the restart as soon as the daemon drains.
*/
async function ensureRunningDaemonVersionMatches(): Promise<
"restarted" | "deferred" | "ok" | "not_running"
> {
const active = await ensureActiveProfile();
const running = await fetchHealthAtPort(active.port);
const bundled = await getCliBinaryVersion();
const action = decideVersionAction(bundled, running);
switch (action) {
case "not_running":
pendingVersionRestart = false;
return "not_running";
case "ok":
pendingVersionRestart = false;
return "ok";
case "defer": {
if (!pendingVersionRestart) {
const activeTasks = running?.active_task_count ?? 0;
console.log(
`[daemon] CLI version mismatch (bundled=${bundled} running=${running?.cli_version}); deferring restart until ${activeTasks} active task(s) finish`,
);
}
pendingVersionRestart = true;
return "deferred";
}
case "restart":
console.log(
`[daemon] CLI version mismatch (bundled=${bundled} running=${running?.cli_version}) — restarting daemon`,
);
pendingVersionRestart = false;
await restartDaemon();
return "restarted";
}
}
/**
* Exchange the user's JWT for a long-lived PAT via POST /api/tokens. The
* daemon needs a PAT (or `mul_` / `mdt_` token) because JWTs expire in 30
* days and signatures are tied to a specific backend instance.
*/
async function mintPat(jwt: string): Promise<string> {
if (!targetApiBaseUrl) {
throw new Error("mint PAT: target API URL not set");
}
const url = `${targetApiBaseUrl.replace(/\/+$/, "")}/api/tokens`;
const res = await fetch(url, {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${jwt}`,
},
// Omit expires_in_days → server treats as null → non-expiring PAT.
body: JSON.stringify({ name: "Multica Desktop" }),
});
if (!res.ok) {
const body = await res.text().catch(() => "");
throw new Error(`mint PAT failed: ${res.status} ${res.statusText} ${body}`);
}
const data = (await res.json()) as { token?: unknown };
if (typeof data.token !== "string" || !data.token.startsWith("mul_")) {
throw new Error("mint PAT: response missing token");
}
return data.token;
}
/**
* Ensure the active profile's config.json has a usable token for the daemon.
*
* - Input from the renderer is the user's JWT (from localStorage) plus the
* current user's id, so we can detect session changes.
* - If the profile already has a cached PAT (`mul_...`) AND the sidecar user
* id matches the caller, reuse it — minting fresh on every launch would
* accumulate garbage in the user's tokens page.
* - On user mismatch (or first run) call POST /api/tokens with the JWT to
* mint a fresh PAT, overwriting any stale cached PAT. This is the critical
* path: without it, a previous user's PAT would be used by a new session.
* - If the caller happens to pass a PAT directly, write it through.
* - When we mint fresh and a daemon is already running, restart it so the
* new credentials take effect (the Go daemon reads config at startup).
*/
async function syncToken(
tokenFromRenderer: string,
userId: string,
): Promise<void> {
const active = await ensureActiveProfile();
const config = await readProfileConfig(active.name);
const previousUserId = await readProfileUserId(active.name);
const userChanged = Boolean(previousUserId) && previousUserId !== userId;
const sameUserWithCachedPat =
!userChanged &&
previousUserId === userId &&
typeof config.token === "string" &&
config.token.startsWith("mul_");
let finalToken: string;
if (tokenFromRenderer.startsWith("mul_")) {
finalToken = tokenFromRenderer;
} else if (sameUserWithCachedPat) {
finalToken = config.token as string;
} else {
try {
finalToken = await mintPat(tokenFromRenderer);
console.log(
`[daemon] minted PAT for profile "${active.name}" (user_changed=${userChanged})`,
);
} catch (err) {
console.error("[daemon] failed to mint PAT:", err);
throw err;
}
}
config.token = finalToken;
if (targetApiBaseUrl) config.server_url = targetApiBaseUrl;
await writeProfileConfig(active.name, config);
await writeProfileUserId(active.name, userId);
// If we just rotated credentials onto a running daemon, restart it so the
// in-memory token in the Go process matches the new config.
if (userChanged) {
try {
const existing = await fetchHealthAtPort(active.port);
if (existing?.status === "running") {
console.log(
"[daemon] user switched — restarting daemon with new credentials",
);
void restartDaemon();
}
} catch (err) {
console.warn("[daemon] restart-on-user-switch failed:", err);
}
}
}
async function loadPrefs(): Promise<DaemonPrefs> {
try {
const raw = await readFile(PREFS_PATH, "utf-8");
const parsed = JSON.parse(raw);
return { ...DEFAULT_PREFS, ...parsed };
} catch {
return { ...DEFAULT_PREFS };
}
}
async function savePrefs(prefs: DaemonPrefs): Promise<void> {
const dir = join(homedir(), ".multica");
await mkdir(dir, { recursive: true });
await writeFile(PREFS_PATH, JSON.stringify(prefs, null, 2), "utf-8");
}
async function clearToken(): Promise<void> {
const active = await ensureActiveProfile();
const config = await readProfileConfig(active.name);
if ("token" in config) {
delete config.token;
await writeProfileConfig(active.name, config);
}
// Always drop the sidecar so a subsequent syncToken from any user is
// treated as a fresh mint, not a reuse of a stale cached PAT.
await removeProfileUserId(active.name);
}
async function withGuard<T>(fn: () => Promise<T>): Promise<T | { success: false; error: string }> {
if (operationInProgress) {
return { success: false, error: "Another daemon operation is in progress" };
}
operationInProgress = true;
try {
return await fn();
} finally {
operationInProgress = false;
}
}
function profileArgs(active: ActiveProfile): string[] {
return active.name ? ["--profile", active.name] : [];
}
// Env passed to every CLI child so the daemon process knows it was spawned
// by the Desktop app. The server uses this to mark runtimes as managed and
// hide CLI self-update UI.
const DESKTOP_SPAWN_ENV = {
...process.env,
MULTICA_LAUNCHED_BY: "desktop",
};
async function startDaemon(): Promise<{ success: boolean; error?: string }> {
const bin = await resolveCliBinary();
if (!bin) return { success: false, error: "multica CLI is not installed" };
const active = await ensureActiveProfile();
const existing = await fetchHealthAtPort(active.port);
if (existing?.status === "running") {
pollOnce();
return { success: true };
}
currentState = "starting";
sendStatus({ state: "starting" });
const args = ["daemon", "start", ...profileArgs(active)];
return new Promise((resolve) => {
execFile(
bin,
args,
{ timeout: 20_000, env: DESKTOP_SPAWN_ENV },
(err) => {
if (err) {
currentState = "stopped";
sendStatus({ state: "stopped" });
resolve({ success: false, error: err.message });
return;
}
// Stay in "starting" until pollOnce confirms /health — the CLI
// returning 0 only means the supervisor was spawned, not that the
// daemon process is already listening.
pollOnce();
resolve({ success: true });
},
);
});
}
async function stopDaemon(): Promise<{ success: boolean; error?: string }> {
const bin = await resolveCliBinary();
if (!bin) return { success: false, error: "multica CLI is not installed" };
const active = await ensureActiveProfile();
currentState = "stopping";
sendStatus({ state: "stopping" });
const args = ["daemon", "stop", ...profileArgs(active)];
return new Promise((resolve) => {
execFile(bin, args, { timeout: 15_000 }, (err) => {
if (err) {
resolve({ success: false, error: err.message });
} else {
resolve({ success: true });
}
currentState = "stopped";
sendStatus({ state: "stopped" });
});
});
}
async function restartDaemon(): Promise<{ success: boolean; error?: string }> {
const stopResult = await stopDaemon();
if (!stopResult.success) return stopResult;
return startDaemon();
}
async function pollOnce(): Promise<void> {
const status = await fetchHealth();
currentState = status.state;
sendStatus(status);
// Retry a deferred version-mismatch restart once the daemon drains.
if (pendingVersionRestart && status.state === "running") {
void ensureRunningDaemonVersionMatches();
}
}
function startPolling(): void {
if (statusPollTimer) return;
pollOnce();
statusPollTimer = setInterval(pollOnce, POLL_INTERVAL_MS);
}
/**
* Ensures the CLI binary is available, then transitions into the normal
* stopped/running state machine. Called once at startup and again on
* user-triggered `daemon:retry-install`.
*/
async function bootstrapCli(): Promise<void> {
const bin = await resolveCliBinary();
if (!bin) {
currentState = "cli_not_found";
sendStatus({ state: "cli_not_found" });
return;
}
currentState = "stopped";
sendStatus({ state: "stopped" });
startPolling();
}
function stopPolling(): void {
if (statusPollTimer) {
clearInterval(statusPollTimer);
statusPollTimer = null;
}
}
const LOG_TAIL_INITIAL_WINDOW_BYTES = 32 * 1024;
const LOG_TAIL_INITIAL_LINES = 200;
const LOG_TAIL_POLL_MS = 500;
async function readLogRange(
path: string,
startAt: number,
length: number,
): Promise<string> {
const handle = await open(path, "r");
try {
const buffer = Buffer.alloc(length);
const { bytesRead } = await handle.read(buffer, 0, length, startAt);
return buffer.subarray(0, bytesRead).toString("utf-8");
} finally {
await handle.close();
}
}
function sendLines(win: BrowserWindow, text: string): void {
const lines = text.split("\n").filter((line) => line.length > 0);
for (const line of lines) {
win.webContents.send("daemon:log-line", line);
}
}
// Cross-platform tail -f replacement: read the tail of the file once, then
// poll its stat with fs.watchFile and forward any new bytes since the last
// known offset. watchFile works on macOS, Linux, and Windows; spawn("tail")
// would silently fail on Windows.
function startLogTail(win: BrowserWindow, retryCount = 0): void {
stopLogTail();
void ensureActiveProfile().then(async (active) => {
const logPath = profileLogPath(active.name);
if (!existsSync(logPath)) {
if (retryCount < LOG_TAIL_MAX_RETRIES) {
setTimeout(() => startLogTail(win, retryCount + 1), LOG_TAIL_RETRY_MS);
}
return;
}
let position = 0;
try {
const initialStats = await stat(logPath);
const windowBytes = Math.min(
initialStats.size,
LOG_TAIL_INITIAL_WINDOW_BYTES,
);
const startAt = initialStats.size - windowBytes;
if (windowBytes > 0) {
const text = await readLogRange(logPath, startAt, windowBytes);
const lines = text
.split("\n")
.filter((line) => line.length > 0)
.slice(-LOG_TAIL_INITIAL_LINES);
for (const line of lines) {
win.webContents.send("daemon:log-line", line);
}
}
position = initialStats.size;
} catch (err) {
console.warn("[daemon] log tail initial read failed:", err);
return;
}
const listener: StatsListener = (curr) => {
const target = getMainWindow();
if (!target) return;
// File rotated/truncated — restart from the new beginning.
if (curr.size < position) position = 0;
if (curr.size === position) return;
const from = position;
const length = curr.size - from;
position = curr.size;
readLogRange(logPath, from, length)
.then((text) => sendLines(target, text))
.catch((err) => {
console.warn("[daemon] log tail read failed:", err);
});
};
watchFile(logPath, { interval: LOG_TAIL_POLL_MS }, listener);
logTailWatcher = { path: logPath, listener };
});
}
function stopLogTail(): void {
if (logTailWatcher) {
unwatchFile(logTailWatcher.path, logTailWatcher.listener);
logTailWatcher = null;
}
}
export function setupDaemonManager(
windowGetter: () => BrowserWindow | null,
): void {
getMainWindow = windowGetter;
ipcMain.handle("daemon:set-target-api-url", async (_e, url: string) => {
const normalized = url || null;
if (targetApiBaseUrl !== normalized) {
console.log(`[daemon] target API URL set to ${normalized ?? "(none)"}`);
targetApiBaseUrl = normalized;
invalidateActiveProfile();
await pollOnce();
}
});
ipcMain.handle("daemon:start", () => withGuard(() => startDaemon()));
ipcMain.handle("daemon:stop", () => withGuard(() => stopDaemon()));
ipcMain.handle("daemon:restart", () => withGuard(() => restartDaemon()));
ipcMain.handle("daemon:get-status", () => fetchHealth());
ipcMain.handle(
"daemon:sync-token",
(_event, token: string, userId: string) => syncToken(token, userId),
);
ipcMain.handle("daemon:clear-token", () => clearToken());
ipcMain.handle("daemon:is-cli-installed", async () => {
const bin = await resolveCliBinary();
return bin !== null;
});
ipcMain.handle("daemon:retry-install", async () => {
cachedCliBinary = undefined;
cliResolvePromise = null;
// A retry-install may land a new CLI at a different version; drop the
// cached version string so the next check re-reads the binary.
cachedCliBinaryVersion = undefined;
await bootstrapCli();
});
ipcMain.handle("daemon:get-prefs", () => loadPrefs());
ipcMain.handle(
"daemon:set-prefs",
(_event, prefs: Partial<DaemonPrefs>) =>
loadPrefs().then((cur) => {
const merged = { ...cur, ...prefs };
return savePrefs(merged).then(() => merged);
}),
);
ipcMain.handle("daemon:auto-start", async () => {
const prefs = await loadPrefs();
if (!prefs.autoStart) return;
const bin = await resolveCliBinary();
if (!bin) return;
const health = await fetchHealth();
if (health.state === "running") {
// Daemon is up but may be running an older CLI than the one we just
// bundled. Restart it so the new binary actually takes effect.
await ensureRunningDaemonVersionMatches();
return;
}
await startDaemon();
});
ipcMain.on("daemon:start-log-stream", () => {
const win = getMainWindow();
if (win) startLogTail(win);
});
ipcMain.on("daemon:stop-log-stream", () => {
stopLogTail();
});
// First-run CLI install kicks off here. Status bar shows "Setting up…"
// until the managed binary is on disk (instant on subsequent launches).
currentState = "installing_cli";
sendStatus({ state: "installing_cli" });
void bootstrapCli();
let isQuitting = false;
app.on("before-quit", (event) => {
if (isQuitting) return;
stopPolling();
stopLogTail();
loadPrefs().then(async (prefs) => {
if (prefs.autoStop) {
isQuitting = true;
event.preventDefault();
try {
await stopDaemon();
} catch {
// Best-effort stop on quit
}
app.quit();
}
});
});
}

View File

@@ -1,9 +1,34 @@
import { app, shell, BrowserWindow } from "electron";
import { app, shell, BrowserWindow, ipcMain } from "electron";
import { join } from "path";
import { electronApp, optimizer, is } from "@electron-toolkit/utils";
import { setupAutoUpdater } from "./updater";
import { setupDaemonManager } from "./daemon-manager";
const PROTOCOL = "multica";
let mainWindow: BrowserWindow | null = null;
// --- Deep link helpers ---------------------------------------------------
function handleDeepLink(url: string): void {
try {
const parsed = new URL(url);
if (parsed.protocol !== `${PROTOCOL}:`) return;
// multica://auth/callback?token=<jwt>
if (parsed.hostname === "auth" && parsed.pathname === "/callback") {
const token = parsed.searchParams.get("token");
if (token && mainWindow) {
mainWindow.webContents.send("auth:token", token);
}
}
} catch {
// Ignore malformed URLs
}
}
// --- Window creation -----------------------------------------------------
function createWindow(): void {
mainWindow = new BrowserWindow({
width: 1280,
@@ -21,6 +46,16 @@ function createWindow(): void {
},
});
// Strip Origin header from WebSocket upgrade requests so the server's
// origin whitelist doesn't reject connections from localhost dev origins.
mainWindow.webContents.session.webRequest.onBeforeSendHeaders(
{ urls: ["wss://*/*", "ws://*/*"] },
(details, callback) => {
delete details.requestHeaders["Origin"];
callback({ requestHeaders: details.requestHeaders });
},
);
mainWindow.on("ready-to-show", () => {
mainWindow?.show();
});
@@ -37,19 +72,83 @@ function createWindow(): void {
}
}
app.whenReady().then(() => {
electronApp.setAppUserModelId("ai.multica.desktop");
// --- Protocol registration -----------------------------------------------
app.on("browser-window-created", (_, window) => {
optimizer.watchWindowShortcuts(window);
if (process.defaultApp) {
// In dev, register with the path to the electron binary + app path
app.setAsDefaultProtocolClient(PROTOCOL, process.execPath, [
app.getAppPath(),
]);
} else {
app.setAsDefaultProtocolClient(PROTOCOL);
}
// --- Single instance lock ------------------------------------------------
const gotTheLock = app.requestSingleInstanceLock();
if (!gotTheLock) {
app.quit();
} else {
// Windows/Linux: second instance passes deep link via argv
app.on("second-instance", (_event, argv) => {
if (mainWindow) {
if (mainWindow.isMinimized()) mainWindow.restore();
mainWindow.focus();
}
// On Windows the deep link URL is the last argv entry
const deepLinkUrl = argv.find((arg) => arg.startsWith(`${PROTOCOL}://`));
if (deepLinkUrl) handleDeepLink(deepLinkUrl);
});
createWindow();
app.whenReady().then(() => {
electronApp.setAppUserModelId("ai.multica.desktop");
app.on("activate", () => {
if (BrowserWindow.getAllWindows().length === 0) createWindow();
app.on("browser-window-created", (_, window) => {
optimizer.watchWindowShortcuts(window);
});
// IPC: open URL in default browser (used by renderer for Google login)
ipcMain.handle("shell:openExternal", (_event, url: string) => {
return shell.openExternal(url);
});
// IPC: toggle immersive mode — hides the macOS traffic lights so full-screen
// modals (create-workspace, onboarding) can place UI in the top-left corner
// without fighting the native window controls' hit-test.
ipcMain.handle("window:setImmersive", (_event, immersive: boolean) => {
if (process.platform !== "darwin") return;
mainWindow?.setWindowButtonVisibility(!immersive);
});
createWindow();
setupAutoUpdater(() => mainWindow);
setupDaemonManager(() => mainWindow);
// macOS: deep link arrives via open-url event
app.on("open-url", (_event, url) => {
if (mainWindow) {
if (mainWindow.isMinimized()) mainWindow.restore();
mainWindow.focus();
}
handleDeepLink(url);
});
app.on("activate", () => {
if (BrowserWindow.getAllWindows().length === 0) createWindow();
});
});
});
// Check argv for deep link on cold start (Windows/Linux)
const deepLinkArg = process.argv.find((arg) =>
arg.startsWith(`${PROTOCOL}://`),
);
if (deepLinkArg) {
app.whenReady().then(() => handleDeepLink(deepLinkArg));
}
}
app.on("window-all-closed", () => {
if (process.platform !== "darwin") app.quit();

View File

@@ -0,0 +1,46 @@
import { autoUpdater } from "electron-updater";
import { BrowserWindow, ipcMain } from "electron";
autoUpdater.autoDownload = false;
autoUpdater.autoInstallOnAppQuit = true;
export function setupAutoUpdater(getMainWindow: () => BrowserWindow | null): void {
autoUpdater.on("update-available", (info) => {
const win = getMainWindow();
win?.webContents.send("updater:update-available", {
version: info.version,
releaseNotes: info.releaseNotes,
});
});
autoUpdater.on("download-progress", (progress) => {
const win = getMainWindow();
win?.webContents.send("updater:download-progress", {
percent: progress.percent,
});
});
autoUpdater.on("update-downloaded", () => {
const win = getMainWindow();
win?.webContents.send("updater:update-downloaded");
});
autoUpdater.on("error", (err) => {
console.error("Auto-updater error:", err);
});
ipcMain.handle("updater:download", () => {
return autoUpdater.downloadUpdate();
});
ipcMain.handle("updater:install", () => {
autoUpdater.quitAndInstall(false, true);
});
// Check for updates after a short delay to avoid blocking startup
setTimeout(() => {
autoUpdater.checkForUpdates().catch((err) => {
console.error("Failed to check for updates:", err);
});
}, 5000);
}

View File

@@ -0,0 +1,88 @@
import { describe, it, expect } from "vitest";
import { decideVersionAction } from "./version-decision";
describe("decideVersionAction", () => {
it("returns not_running when health payload is null", () => {
expect(decideVersionAction("v1.0.0", null)).toBe("not_running");
});
it("returns not_running when status is not 'running'", () => {
expect(
decideVersionAction("v1.0.0", { status: "stopped", cli_version: "v1.0.0" }),
).toBe("not_running");
});
it("returns ok when bundled version is unknown (fail safe)", () => {
expect(
decideVersionAction(null, {
status: "running",
cli_version: "v1.0.0",
active_task_count: 0,
}),
).toBe("ok");
});
it("returns ok when running daemon does not report cli_version (older daemon)", () => {
expect(
decideVersionAction("v1.0.0", {
status: "running",
active_task_count: 0,
}),
).toBe("ok");
});
it("returns ok when versions match exactly", () => {
expect(
decideVersionAction("v1.2.3", {
status: "running",
cli_version: "v1.2.3",
active_task_count: 5,
}),
).toBe("ok");
});
it("returns restart when versions differ and daemon is idle", () => {
expect(
decideVersionAction("v1.2.3", {
status: "running",
cli_version: "v1.2.2",
active_task_count: 0,
}),
).toBe("restart");
});
it("treats missing active_task_count as 0 (old daemon that still reports cli_version)", () => {
expect(
decideVersionAction("v1.2.3", {
status: "running",
cli_version: "v1.2.2",
}),
).toBe("restart");
});
it("returns defer when versions differ but daemon is busy", () => {
expect(
decideVersionAction("v1.2.3", {
status: "running",
cli_version: "v1.2.2",
active_task_count: 2,
}),
).toBe("defer");
});
it("transitions defer → restart as tasks drain", () => {
// Same bundled version across three observations while the daemon ages.
const bundled = "v2.0.0";
const base = { status: "running", cli_version: "v1.9.0" } as const;
expect(
decideVersionAction(bundled, { ...base, active_task_count: 3 }),
).toBe("defer");
expect(
decideVersionAction(bundled, { ...base, active_task_count: 1 }),
).toBe("defer");
expect(
decideVersionAction(bundled, { ...base, active_task_count: 0 }),
).toBe("restart");
});
});

View File

@@ -0,0 +1,37 @@
// Pure decision logic for the daemon version-check flow. Kept in its own
// module so it can be unit-tested without mocking Electron, execFile, or
// the HTTP health probe.
export interface VersionCheckHealth {
status?: string;
cli_version?: string;
active_task_count?: number;
}
export type VersionAction = "restart" | "defer" | "ok" | "not_running";
/**
* Decides what the daemon-manager should do given the currently-resolved
* bundled CLI version and the latest /health payload.
*
* not_running: no daemon is up, nothing to do
* ok: versions match, OR either side is unknown (fail safe)
* defer: versions differ but the daemon is busy — wait for drain
* restart: versions differ and the daemon is idle — safe to restart
*
* Pure function: no I/O, no side effects, no module state.
*/
export function decideVersionAction(
bundled: string | null,
running: VersionCheckHealth | null,
): VersionAction {
if (!running || running.status !== "running") return "not_running";
const runningVersion = running.cli_version;
if (!bundled || !runningVersion) return "ok";
if (runningVersion === bundled) return "ok";
const activeTasks = running.active_task_count ?? 0;
if (activeTasks > 0) return "defer";
return "restart";
}

View File

@@ -1,8 +1,64 @@
import { ElectronAPI } from "@electron-toolkit/preload";
interface DesktopAPI {
/** Listen for auth token delivered via deep link. Returns an unsubscribe function. */
onAuthToken: (callback: (token: string) => void) => () => void;
/** Open a URL in the default browser. */
openExternal: (url: string) => Promise<void>;
/** Hide macOS traffic lights for full-screen modals; restore when false. */
setImmersiveMode: (immersive: boolean) => Promise<void>;
}
interface DaemonStatus {
state: "running" | "stopped" | "starting" | "stopping" | "installing_cli" | "cli_not_found";
pid?: number;
uptime?: string;
daemonId?: string;
deviceName?: string;
agents?: string[];
workspaceCount?: number;
profile?: string;
serverUrl?: string;
}
interface DaemonPrefs {
autoStart: boolean;
autoStop: boolean;
}
interface DaemonAPI {
start: () => Promise<{ success: boolean; error?: string }>;
stop: () => Promise<{ success: boolean; error?: string }>;
restart: () => Promise<{ success: boolean; error?: string }>;
getStatus: () => Promise<DaemonStatus>;
onStatusChange: (callback: (status: DaemonStatus) => void) => () => void;
setTargetApiUrl: (url: string) => Promise<void>;
syncToken: (token: string, userId: string) => Promise<void>;
clearToken: () => Promise<void>;
isCliInstalled: () => Promise<boolean>;
getPrefs: () => Promise<DaemonPrefs>;
setPrefs: (prefs: Partial<DaemonPrefs>) => Promise<DaemonPrefs>;
autoStart: () => Promise<void>;
retryInstall: () => Promise<void>;
startLogStream: () => void;
stopLogStream: () => void;
onLogLine: (callback: (line: string) => void) => () => void;
}
interface UpdaterAPI {
onUpdateAvailable: (callback: (info: { version: string; releaseNotes?: string }) => void) => () => void;
onDownloadProgress: (callback: (progress: { percent: number }) => void) => () => void;
onUpdateDownloaded: (callback: () => void) => () => void;
downloadUpdate: () => Promise<void>;
installUpdate: () => Promise<void>;
}
declare global {
interface Window {
electron: ElectronAPI;
desktopAPI: DesktopAPI;
daemonAPI: DaemonAPI;
updater: UpdaterAPI;
}
}

View File

@@ -1,9 +1,106 @@
import { contextBridge } from "electron";
import { contextBridge, ipcRenderer } from "electron";
import { electronAPI } from "@electron-toolkit/preload";
const desktopAPI = {
/** Listen for auth token delivered via deep link */
onAuthToken: (callback: (token: string) => void) => {
const handler = (_event: Electron.IpcRendererEvent, token: string) =>
callback(token);
ipcRenderer.on("auth:token", handler);
return () => {
ipcRenderer.removeListener("auth:token", handler);
};
},
/** Open a URL in the default browser */
openExternal: (url: string) => ipcRenderer.invoke("shell:openExternal", url),
/** Toggle immersive mode — hide macOS traffic lights for full-screen modals */
setImmersiveMode: (immersive: boolean) =>
ipcRenderer.invoke("window:setImmersive", immersive),
};
interface DaemonStatus {
state: "running" | "stopped" | "starting" | "stopping" | "installing_cli" | "cli_not_found";
pid?: number;
uptime?: string;
daemonId?: string;
deviceName?: string;
agents?: string[];
workspaceCount?: number;
profile?: string;
serverUrl?: string;
}
const daemonAPI = {
start: (): Promise<{ success: boolean; error?: string }> =>
ipcRenderer.invoke("daemon:start"),
stop: (): Promise<{ success: boolean; error?: string }> =>
ipcRenderer.invoke("daemon:stop"),
restart: (): Promise<{ success: boolean; error?: string }> =>
ipcRenderer.invoke("daemon:restart"),
getStatus: (): Promise<DaemonStatus> =>
ipcRenderer.invoke("daemon:get-status"),
onStatusChange: (callback: (status: DaemonStatus) => void) => {
const handler = (_: unknown, status: DaemonStatus) => callback(status);
ipcRenderer.on("daemon:status", handler);
return () => ipcRenderer.removeListener("daemon:status", handler);
},
setTargetApiUrl: (url: string): Promise<void> =>
ipcRenderer.invoke("daemon:set-target-api-url", url),
syncToken: (token: string, userId: string): Promise<void> =>
ipcRenderer.invoke("daemon:sync-token", token, userId),
clearToken: (): Promise<void> =>
ipcRenderer.invoke("daemon:clear-token"),
isCliInstalled: (): Promise<boolean> =>
ipcRenderer.invoke("daemon:is-cli-installed"),
getPrefs: (): Promise<{ autoStart: boolean; autoStop: boolean }> =>
ipcRenderer.invoke("daemon:get-prefs"),
setPrefs: (prefs: Partial<{ autoStart: boolean; autoStop: boolean }>): Promise<{ autoStart: boolean; autoStop: boolean }> =>
ipcRenderer.invoke("daemon:set-prefs", prefs),
autoStart: (): Promise<void> =>
ipcRenderer.invoke("daemon:auto-start"),
retryInstall: (): Promise<void> =>
ipcRenderer.invoke("daemon:retry-install"),
startLogStream: () => ipcRenderer.send("daemon:start-log-stream"),
stopLogStream: () => ipcRenderer.send("daemon:stop-log-stream"),
onLogLine: (callback: (line: string) => void) => {
const handler = (_: unknown, line: string) => callback(line);
ipcRenderer.on("daemon:log-line", handler);
return () => ipcRenderer.removeListener("daemon:log-line", handler);
},
};
const updaterAPI = {
onUpdateAvailable: (callback: (info: { version: string; releaseNotes?: string }) => void) => {
const handler = (_: unknown, info: { version: string; releaseNotes?: string }) => callback(info);
ipcRenderer.on("updater:update-available", handler);
return () => ipcRenderer.removeListener("updater:update-available", handler);
},
onDownloadProgress: (callback: (progress: { percent: number }) => void) => {
const handler = (_: unknown, progress: { percent: number }) => callback(progress);
ipcRenderer.on("updater:download-progress", handler);
return () => ipcRenderer.removeListener("updater:download-progress", handler);
},
onUpdateDownloaded: (callback: () => void) => {
const handler = () => callback();
ipcRenderer.on("updater:update-downloaded", handler);
return () => ipcRenderer.removeListener("updater:update-downloaded", handler);
},
downloadUpdate: () => ipcRenderer.invoke("updater:download"),
installUpdate: () => ipcRenderer.invoke("updater:install"),
};
if (process.contextIsolated) {
contextBridge.exposeInMainWorld("electron", electronAPI);
contextBridge.exposeInMainWorld("desktopAPI", desktopAPI);
contextBridge.exposeInMainWorld("daemonAPI", daemonAPI);
contextBridge.exposeInMainWorld("updater", updaterAPI);
} else {
// @ts-expect-error - fallback for non-isolated context
window.electron = electronAPI;
// @ts-expect-error - fallback for non-isolated context
window.desktopAPI = desktopAPI;
// @ts-expect-error - fallback for non-isolated context
window.daemonAPI = daemonAPI;
// @ts-expect-error - fallback for non-isolated context
window.updater = updaterAPI;
}

View File

@@ -1,15 +1,56 @@
import { useEffect } from "react";
import { CoreProvider } from "@multica/core/platform";
import { useAuthStore } from "@multica/core/auth";
import { useWorkspaceStore } from "@multica/core/workspace";
import { api } from "@multica/core/api";
import { ThemeProvider } from "@multica/ui/components/common/theme-provider";
import { MulticaIcon } from "@multica/ui/components/common/multica-icon";
import { Toaster } from "sonner";
import { DesktopLoginPage } from "./pages/login";
import { DesktopShell } from "./components/desktop-layout";
import { UpdateNotification } from "./components/update-notification";
function AppContent() {
const user = useAuthStore((s) => s.user);
const isLoading = useAuthStore((s) => s.isLoading);
// Tell the main process which backend URL we talk to, so daemon-manager
// can pick the matching CLI profile (server_url from ~/.multica config).
useEffect(() => {
window.daemonAPI.setTargetApiUrl(DAEMON_TARGET_API_URL);
}, []);
// Listen for auth token delivered via deep link (multica://auth/callback?token=...)
useEffect(() => {
return window.desktopAPI.onAuthToken(async (token) => {
try {
const loggedIn = await useAuthStore.getState().loginWithToken(token);
await window.daemonAPI.syncToken(token, loggedIn.id);
const wsList = await api.listWorkspaces();
const lastWsId = localStorage.getItem("multica_workspace_id");
useWorkspaceStore.getState().hydrateWorkspace(wsList, lastWsId);
} catch {
// Token invalid or expired — user stays on login page
}
});
}, []);
// Sync token and start the daemon whenever the user logs in.
useEffect(() => {
if (!user) return;
const token = localStorage.getItem("multica_token");
if (!token) return;
const userId = user.id;
(async () => {
try {
await window.daemonAPI.syncToken(token, userId);
await window.daemonAPI.autoStart();
} catch (err) {
console.error("Failed to sync daemon on login", err);
}
})();
}, [user]);
if (isLoading) {
return (
<div className="flex h-screen items-center justify-center">
@@ -22,16 +63,37 @@ function AppContent() {
return <DesktopShell />;
}
// Backend the daemon should connect to — same URL the renderer talks to.
const DAEMON_TARGET_API_URL =
import.meta.env.VITE_API_URL || "http://localhost:8080";
// On logout, clear any cached PAT and stop the daemon so that a subsequent
// login as a different user never inherits the previous user's credentials.
async function handleDaemonLogout() {
try {
await window.daemonAPI.clearToken();
} catch {
// Best-effort — clearing is followed by stop which also hardens state.
}
try {
await window.daemonAPI.stop();
} catch {
// Daemon may already be stopped.
}
}
export default function App() {
return (
<ThemeProvider>
<CoreProvider
apiBaseUrl={import.meta.env.VITE_API_URL || "http://localhost:8080"}
wsUrl={import.meta.env.VITE_WS_URL || "ws://localhost:8080/ws"}
onLogout={handleDaemonLogout}
>
<AppContent />
</CoreProvider>
<Toaster />
<UpdateNotification />
</ThemeProvider>
);
}

View File

@@ -0,0 +1,309 @@
import { useState, useEffect, useRef, useCallback } from "react";
import {
Play,
Square,
RotateCw,
Server,
ChevronDown,
X,
} from "lucide-react";
import { cn } from "@multica/ui/lib/utils";
import { Button } from "@multica/ui/components/ui/button";
import { toast } from "sonner";
import {
Sheet,
SheetContent,
SheetHeader,
SheetTitle,
} from "@multica/ui/components/ui/sheet";
import type { DaemonStatus, DaemonState } from "../../../shared/daemon-types";
import { DAEMON_STATE_COLORS, DAEMON_STATE_LABELS } from "../../../shared/daemon-types";
interface DaemonPanelProps {
open: boolean;
onOpenChange: (open: boolean) => void;
status: DaemonStatus;
}
const LOG_LEVEL_COLORS: Record<string, string> = {
INFO: "text-info",
WARN: "text-warning",
ERROR: "text-destructive",
DEBUG: "text-muted-foreground",
};
function colorizeLogLine(line: string): { level: string; className: string } {
for (const [level, className] of Object.entries(LOG_LEVEL_COLORS)) {
if (line.includes(level)) return { level, className };
}
return { level: "", className: "text-muted-foreground" };
}
function InfoRow({ label, value }: { label: string; value: React.ReactNode }) {
return (
<div className="flex items-baseline justify-between gap-4 py-1">
<span className="shrink-0 text-xs text-muted-foreground">{label}</span>
<span className="truncate text-right text-sm">{value}</span>
</div>
);
}
function StatusDot({ state }: { state: DaemonState }) {
return <span className={cn("inline-block size-2 rounded-full", DAEMON_STATE_COLORS[state])} />;
}
interface LogEntry {
id: number;
line: string;
}
const MAX_LOG_LINES = 500;
let logIdCounter = 0;
export function DaemonPanel({ open, onOpenChange, status }: DaemonPanelProps) {
const [logs, setLogs] = useState<LogEntry[]>([]);
const [autoScroll, setAutoScroll] = useState(true);
const [actionLoading, setActionLoading] = useState(false);
const logContainerRef = useRef<HTMLDivElement>(null);
useEffect(() => {
if (!open) return;
window.daemonAPI.startLogStream();
const unsub = window.daemonAPI.onLogLine((line) => {
setLogs((prev) => {
const next = [...prev, { id: ++logIdCounter, line }];
return next.length > MAX_LOG_LINES ? next.slice(-MAX_LOG_LINES) : next;
});
});
return () => {
unsub();
window.daemonAPI.stopLogStream();
};
}, [open]);
useEffect(() => {
if (autoScroll && logContainerRef.current) {
logContainerRef.current.scrollTop = logContainerRef.current.scrollHeight;
}
}, [logs, autoScroll]);
const handleLogScroll = useCallback(() => {
const el = logContainerRef.current;
if (!el) return;
const atBottom = el.scrollHeight - el.scrollTop - el.clientHeight < 40;
setAutoScroll(atBottom);
}, []);
const scrollToBottom = useCallback(() => {
if (logContainerRef.current) {
logContainerRef.current.scrollTop = logContainerRef.current.scrollHeight;
setAutoScroll(true);
}
}, []);
const handleStart = useCallback(async () => {
setActionLoading(true);
const result = await window.daemonAPI.start();
setActionLoading(false);
if (!result.success) {
toast.error("Failed to start daemon", { description: result.error });
}
}, []);
const handleStop = useCallback(async () => {
setActionLoading(true);
const result = await window.daemonAPI.stop();
setActionLoading(false);
if (!result.success) {
toast.error("Failed to stop daemon", { description: result.error });
}
}, []);
const handleRestart = useCallback(async () => {
setActionLoading(true);
const result = await window.daemonAPI.restart();
setActionLoading(false);
if (!result.success) {
toast.error("Failed to restart daemon", { description: result.error });
}
}, []);
const isTransitioning = status.state === "starting" || status.state === "stopping";
return (
<Sheet open={open} onOpenChange={onOpenChange}>
<SheetContent
side="right"
className="flex flex-col sm:max-w-md"
showCloseButton={false}
style={{ WebkitAppRegion: "no-drag" } as React.CSSProperties}
>
<SheetHeader className="flex-row items-center justify-between gap-2 pr-3">
<SheetTitle className="flex items-center gap-2">
<Server className="size-4" />
Local Daemon
</SheetTitle>
<button
type="button"
onClick={() => onOpenChange(false)}
aria-label="Close"
className="flex size-7 shrink-0 items-center justify-center rounded-md text-muted-foreground transition-colors hover:bg-muted hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
>
<X className="size-4" />
</button>
</SheetHeader>
<div className="flex-1 min-h-0 flex flex-col gap-4 px-4">
<div className="shrink-0 space-y-4">
{/* Status info */}
<div className="rounded-lg border p-3 space-y-0.5">
<InfoRow
label="Status"
value={
<span className="flex items-center gap-1.5">
<StatusDot state={status.state} />
{DAEMON_STATE_LABELS[status.state]}
</span>
}
/>
{status.uptime && <InfoRow label="Uptime" value={status.uptime} />}
<InfoRow label="Profile" value={status.profile || "default"} />
{status.serverUrl && (
<InfoRow
label="Server"
value={
<span className="font-mono text-xs" title={status.serverUrl}>
{status.serverUrl}
</span>
}
/>
)}
{status.agents && status.agents.length > 0 && (
<InfoRow label="Agents" value={status.agents.join(", ")} />
)}
{status.deviceName && <InfoRow label="Device" value={status.deviceName} />}
{status.daemonId && (
<InfoRow
label="Daemon ID"
value={<span className="font-mono text-xs">{status.daemonId}</span>}
/>
)}
{typeof status.workspaceCount === "number" && (
<InfoRow label="Workspaces" value={status.workspaceCount} />
)}
{status.pid && (
<InfoRow
label="PID"
value={<span className="font-mono text-xs">{status.pid}</span>}
/>
)}
</div>
{/* Actions */}
{status.state === "installing_cli" ? (
<div className="rounded-lg border border-dashed p-3 text-sm text-muted-foreground">
Setting up the local runtime this only happens the first time.
</div>
) : status.state === "cli_not_found" ? (
<div className="rounded-lg border border-destructive/40 bg-destructive/5 p-3 space-y-2">
<p className="text-sm">
Couldn&apos;t download the local runtime. Check your network
connection and try again.
</p>
<Button
size="sm"
variant="outline"
onClick={async () => {
setActionLoading(true);
try {
await window.daemonAPI.retryInstall();
} finally {
setActionLoading(false);
}
}}
disabled={actionLoading}
>
<RotateCw className="size-3.5 mr-1.5" />
Retry
</Button>
</div>
) : (
<div className="flex gap-2">
{status.state === "stopped" ? (
<Button size="sm" onClick={handleStart} disabled={actionLoading}>
<Play className="size-3.5 mr-1.5" />
Start
</Button>
) : (
<>
<Button
variant="outline"
size="sm"
onClick={handleStop}
disabled={actionLoading || isTransitioning}
>
<Square className="size-3.5 mr-1.5" />
Stop
</Button>
<Button
variant="outline"
size="sm"
onClick={handleRestart}
disabled={actionLoading || isTransitioning}
>
<RotateCw className="size-3.5 mr-1.5" />
Restart
</Button>
</>
)}
</div>
)}
</div>
{/* Logs — fills remaining vertical space down to the sheet bottom */}
<div className="flex-1 min-h-0 flex flex-col gap-2 pb-4">
<div className="flex items-center justify-between shrink-0">
<h3 className="text-sm font-medium">Logs</h3>
{!autoScroll && (
<Button
variant="ghost"
size="sm"
className="h-6 px-2 text-xs"
onClick={scrollToBottom}
>
<ChevronDown className="size-3 mr-1" />
Scroll to bottom
</Button>
)}
</div>
<div
ref={logContainerRef}
onScroll={handleLogScroll}
className="flex-1 min-h-0 overflow-y-auto rounded-lg border bg-muted/30 p-2 font-mono text-xs leading-relaxed"
>
{logs.length === 0 ? (
<p className="text-muted-foreground/50 text-center py-8">
{status.state === "running"
? "Waiting for logs…"
: "Start the daemon to see logs"}
</p>
) : (
logs.map((entry) => {
const { className } = colorizeLogLine(entry.line);
return (
<div key={entry.id} className={cn("whitespace-pre-wrap break-all", className)}>
{entry.line}
</div>
);
})
)}
</div>
</div>
</div>
</SheetContent>
</Sheet>
);
}

View File

@@ -0,0 +1,155 @@
import { useState, useEffect, useCallback } from "react";
import {
Play,
Square,
RotateCw,
Server,
Activity,
} from "lucide-react";
import { cn } from "@multica/ui/lib/utils";
import { Button } from "@multica/ui/components/ui/button";
import { toast } from "sonner";
import { DaemonPanel } from "./daemon-panel";
import type { DaemonStatus } from "../../../shared/daemon-types";
import { DAEMON_STATE_COLORS, DAEMON_STATE_LABELS, formatUptime } from "../../../shared/daemon-types";
export function DaemonRuntimeCard() {
const [status, setStatus] = useState<DaemonStatus>({ state: "stopped" });
const [panelOpen, setPanelOpen] = useState(false);
const [actionLoading, setActionLoading] = useState(false);
useEffect(() => {
window.daemonAPI.getStatus().then((s) => setStatus(s));
const unsub = window.daemonAPI.onStatusChange((s) => {
setStatus(s);
setActionLoading(false);
});
return unsub;
}, []);
const handleStart = useCallback(async () => {
setActionLoading(true);
const result = await window.daemonAPI.start();
if (!result.success) {
setActionLoading(false);
toast.error("Failed to start daemon", { description: result.error });
}
}, []);
const handleStop = useCallback(async () => {
setActionLoading(true);
const result = await window.daemonAPI.stop();
if (!result.success) {
toast.error("Failed to stop daemon", { description: result.error });
}
}, []);
const handleRestart = useCallback(async () => {
setActionLoading(true);
const result = await window.daemonAPI.restart();
if (!result.success) {
toast.error("Failed to restart daemon", { description: result.error });
}
}, []);
const isTransitioning = status.state === "starting" || status.state === "stopping";
const isRunning = status.state === "running";
const isStopped = status.state === "stopped" || status.state === "cli_not_found";
const stopPropagation = (e: React.MouseEvent) => e.stopPropagation();
return (
<>
<div
role="button"
tabIndex={0}
onClick={() => setPanelOpen(true)}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
setPanelOpen(true);
}
}}
className="border-b px-4 py-3 cursor-pointer transition-colors hover:bg-muted/40 focus-visible:outline-none focus-visible:bg-muted/40"
>
<div className="flex items-start justify-between gap-3">
<div className="flex items-center gap-2.5">
<div className="flex size-8 items-center justify-center rounded-lg bg-muted">
<Server className="size-4 text-muted-foreground" />
</div>
<div>
<h3 className="text-sm font-medium">Local Daemon</h3>
<div className="flex items-center gap-1.5 mt-0.5">
<span className={cn("size-1.5 rounded-full", DAEMON_STATE_COLORS[status.state])} />
<span className="text-xs text-muted-foreground">{DAEMON_STATE_LABELS[status.state]}</span>
{isRunning && status.uptime && (
<>
<span className="text-xs text-muted-foreground">·</span>
<span className="text-xs text-muted-foreground">{formatUptime(status.uptime)}</span>
</>
)}
{isRunning && status.agents && status.agents.length > 0 && (
<>
<span className="text-xs text-muted-foreground">·</span>
<span className="text-xs text-muted-foreground">{status.agents.join(", ")}</span>
</>
)}
</div>
</div>
</div>
<div
className="flex items-center gap-1.5 shrink-0"
onClick={stopPropagation}
>
{isStopped && (
<Button
size="sm"
variant="outline"
onClick={handleStart}
disabled={actionLoading || status.state === "cli_not_found"}
>
{actionLoading ? (
<Activity className="size-3.5 mr-1.5 animate-pulse" />
) : (
<Play className="size-3.5 mr-1.5" />
)}
Start
</Button>
)}
{isRunning && (
<>
<Button
size="sm"
variant="ghost"
onClick={handleRestart}
disabled={actionLoading}
>
<RotateCw className="size-3.5 mr-1.5" />
Restart
</Button>
<Button
size="sm"
variant="outline"
onClick={handleStop}
disabled={actionLoading}
>
<Square className="size-3.5 mr-1.5" />
Stop
</Button>
</>
)}
{isTransitioning && (
<Button size="sm" variant="outline" disabled>
<Activity className="size-3.5 mr-1.5 animate-pulse" />
{DAEMON_STATE_LABELS[status.state]}
</Button>
)}
</div>
</div>
</div>
<DaemonPanel open={panelOpen} onOpenChange={setPanelOpen} status={status} />
</>
);
}

View File

@@ -0,0 +1,103 @@
import { useState, useEffect, useCallback } from "react";
import { Button } from "@multica/ui/components/ui/button";
import { Switch } from "@multica/ui/components/ui/switch";
import type { DaemonPrefs } from "../../../shared/daemon-types";
function SettingRow({
label,
description,
children,
}: {
label: string;
description: string;
children: React.ReactNode;
}) {
return (
<div className="flex items-center justify-between gap-6 py-4">
<div className="min-w-0">
<p className="text-sm font-medium">{label}</p>
<p className="text-sm text-muted-foreground mt-0.5">{description}</p>
</div>
<div className="shrink-0">{children}</div>
</div>
);
}
export function DaemonSettingsTab() {
const [prefs, setPrefs] = useState<DaemonPrefs>({ autoStart: true, autoStop: false });
const [cliInstalled, setCliInstalled] = useState<boolean | null>(null);
const [saving, setSaving] = useState(false);
useEffect(() => {
window.daemonAPI.getPrefs().then(setPrefs);
window.daemonAPI.isCliInstalled().then(setCliInstalled);
}, []);
const updatePref = useCallback(
async (key: keyof DaemonPrefs, value: boolean) => {
setSaving(true);
const updated = await window.daemonAPI.setPrefs({ [key]: value });
setPrefs(updated);
setSaving(false);
},
[],
);
return (
<div>
<h2 className="text-lg font-semibold">Daemon</h2>
<p className="text-sm text-muted-foreground mt-1">
Configure how the local agent daemon behaves with the desktop app.
</p>
<div className="mt-6 divide-y">
<SettingRow
label="Auto-start on launch"
description="Automatically start the daemon when the app opens and you are logged in."
>
<Switch
checked={prefs.autoStart}
onCheckedChange={(checked) => updatePref("autoStart", checked)}
disabled={saving}
/>
</SettingRow>
<SettingRow
label="Auto-stop on quit"
description="Stop the daemon when the desktop app is closed. Disable this to keep the daemon running in the background."
>
<Switch
checked={prefs.autoStop}
onCheckedChange={(checked) => updatePref("autoStop", checked)}
disabled={saving}
/>
</SettingRow>
<div className="py-4">
<p className="text-sm font-medium">CLI Status</p>
<p className="text-sm text-muted-foreground mt-1">
{cliInstalled === null
? "Checking…"
: cliInstalled
? "multica CLI is installed and available in PATH."
: "multica CLI not found. Install it to enable daemon management."}
</p>
{cliInstalled === false && (
<Button
variant="outline"
size="sm"
className="mt-2"
onClick={() =>
window.desktopAPI.openExternal(
"https://github.com/multica-ai/multica#cli-installation",
)
}
>
Installation Guide
</Button>
)}
</div>
</div>
</div>
);
}

View File

@@ -1,9 +1,13 @@
import { useEffect } from "react";
import { ChevronLeft, ChevronRight } from "lucide-react";
import { cn } from "@multica/ui/lib/utils";
import { useTabHistory } from "@/hooks/use-tab-history";
import { useActiveTitleSync } from "@/hooks/use-tab-sync";
import { useTabStore, resolveRouteIcon } from "@/stores/tab-store";
import { SidebarProvider } from "@multica/ui/components/ui/sidebar";
import {
SidebarProvider,
useSidebar,
} from "@multica/ui/components/ui/sidebar";
import { ModalRegistry } from "@multica/views/modals/registry";
import { AppSidebar, DashboardGuard } from "@multica/views/layout";
import { SearchCommand, SearchTrigger } from "@multica/views/search";
@@ -28,6 +32,7 @@ function SidebarTopBar() {
<button
onClick={goBack}
disabled={!canGoBack}
aria-label="Go back"
className="flex size-7 items-center justify-center rounded-md text-muted-foreground transition-colors hover:bg-accent hover:text-foreground disabled:opacity-30 disabled:pointer-events-none"
>
<ChevronLeft className="size-4" />
@@ -35,6 +40,7 @@ function SidebarTopBar() {
<button
onClick={goForward}
disabled={!canGoForward}
aria-label="Go forward"
className="flex size-7 items-center justify-center rounded-md text-muted-foreground transition-colors hover:bg-accent hover:text-foreground disabled:opacity-30 disabled:pointer-events-none"
>
<ChevronRight className="size-4" />
@@ -44,6 +50,23 @@ function SidebarTopBar() {
);
}
// The main area's top bar doubles as a window drag region. When the sidebar
// is collapsed, we pad the left side so tabs don't land under the macOS
// traffic lights (which live at roughly x=16..68 and always hit-test above HTML).
function MainTopBar() {
const { state } = useSidebar();
const sidebarCollapsed = state === "collapsed";
return (
<header
className={cn("h-12 shrink-0", sidebarCollapsed && "pl-20")}
style={{ WebkitAppRegion: "drag" } as React.CSSProperties}
>
<TabBar />
</header>
);
}
function useInternalLinkHandler() {
useEffect(() => {
const handler = (e: Event) => {
@@ -78,24 +101,18 @@ export function DesktopShell() {
<AppSidebar topSlot={<SidebarTopBar />} searchSlot={<SearchTrigger />} />
{/* Right side: header + content container */}
<div className="flex flex-1 min-w-0 flex-col">
{/* Tab bar + drag region */}
<header
className="h-12 shrink-0"
style={{ WebkitAppRegion: "drag" } as React.CSSProperties}
>
<TabBar />
</header>
{/* Content area with inset styling */}
<div className="flex flex-1 min-h-0 flex-col overflow-hidden mr-2 mb-2 ml-0.5 rounded-xl shadow-sm bg-background">
<MainTopBar />
{/* Content area with inset styling — relative so ChatWindow/ChatFab are constrained here */}
<div className="relative flex flex-1 min-h-0 flex-col overflow-hidden mr-2 mb-2 ml-0.5 rounded-xl shadow-sm bg-background">
<TabContent />
<ChatWindow />
<ChatFab />
</div>
</div>
</SidebarProvider>
</div>
<ModalRegistry />
<SearchCommand />
<ChatWindow />
<ChatFab />
</DashboardGuard>
</DesktopNavigationProvider>
);

View File

@@ -0,0 +1,124 @@
import { useCallback, useEffect, useState } from "react";
import { ArrowDownToLine, RefreshCw, X } from "lucide-react";
type UpdateState =
| { status: "idle" }
| { status: "available"; version: string }
| { status: "downloading"; percent: number }
| { status: "ready" };
export function UpdateNotification() {
const [state, setState] = useState<UpdateState>({ status: "idle" });
const [dismissed, setDismissed] = useState(false);
useEffect(() => {
const cleanups: (() => void)[] = [];
cleanups.push(
window.updater.onUpdateAvailable((info) => {
setState({ status: "available", version: info.version });
setDismissed(false);
}),
);
cleanups.push(
window.updater.onDownloadProgress((progress) => {
setState({ status: "downloading", percent: progress.percent });
}),
);
cleanups.push(
window.updater.onUpdateDownloaded(() => {
setState({ status: "ready" });
}),
);
return () => cleanups.forEach((fn) => fn());
}, []);
const handleDownload = useCallback(() => {
// Prevent double-click: immediately transition to downloading state
if (state.status !== "available") return;
setState({ status: "downloading", percent: 0 });
window.updater.downloadUpdate();
}, [state.status]);
const handleInstall = useCallback(() => {
window.updater.installUpdate();
}, []);
// Only allow dismiss when update is available (not during download or ready)
if (state.status === "idle") return null;
if (dismissed && state.status === "available") return null;
return (
<div className="fixed bottom-4 right-4 z-50 w-80 rounded-lg border border-border bg-background p-4 shadow-lg animate-in slide-in-from-bottom-2 fade-in duration-300">
<button
onClick={() => setDismissed(true)}
className="absolute top-2 right-2 rounded-md p-1 text-muted-foreground hover:text-foreground transition-colors"
>
<X className="size-3.5" />
</button>
{state.status === "available" && (
<div className="flex items-start gap-3">
<div className="mt-0.5 rounded-md bg-primary/10 p-1.5">
<ArrowDownToLine className="size-4 text-primary" />
</div>
<div className="flex-1 min-w-0">
<p className="text-sm font-medium">New version available</p>
<p className="text-xs text-muted-foreground mt-0.5">
v{state.version} is ready to download
</p>
<button
onClick={handleDownload}
className="mt-2 inline-flex items-center rounded-md bg-primary px-3 py-1.5 text-xs font-medium text-primary-foreground hover:bg-primary/90 transition-colors"
>
Download update
</button>
</div>
</div>
)}
{state.status === "downloading" && (
<div className="flex items-start gap-3">
<div className="mt-0.5 rounded-md bg-primary/10 p-1.5">
<ArrowDownToLine className="size-4 text-primary animate-pulse" />
</div>
<div className="flex-1 min-w-0">
<p className="text-sm font-medium">Downloading update...</p>
<div className="mt-2 h-1.5 w-full rounded-full bg-muted overflow-hidden">
<div
className="h-full rounded-full bg-primary transition-all duration-300"
style={{ width: `${Math.round(state.percent)}%` }}
/>
</div>
<p className="text-xs text-muted-foreground mt-1">
{Math.round(state.percent)}%
</p>
</div>
</div>
)}
{state.status === "ready" && (
<div className="flex items-start gap-3">
<div className="mt-0.5 rounded-md bg-success/10 p-1.5">
<RefreshCw className="size-4 text-success" />
</div>
<div className="flex-1 min-w-0">
<p className="text-sm font-medium">Update ready</p>
<p className="text-xs text-muted-foreground mt-0.5">
Restart to apply the update
</p>
<button
onClick={handleInstall}
className="mt-2 inline-flex items-center rounded-md bg-primary px-3 py-1.5 text-xs font-medium text-primary-foreground hover:bg-primary/90 transition-colors"
>
Restart now
</button>
</div>
</div>
)}
</div>
);
}

View File

@@ -6,11 +6,27 @@
@custom-variant dark (&:is(.dark *));
/* Geist font: define CSS variables that tokens.css @theme inline references.
Web app gets these from next/font/google; desktop must set them explicitly. */
/* Font stack: Inter for Latin UI text + system Chinese fonts for zh content.
Web app uses the same stack via next/font/google in apps/web/app/layout.tsx —
keep the CJK fallback tail in sync across both files. The Inter primary family
differs by design: next/font produces `__Inter_xxx` (with a synthetic size-adjusted
fallback face to prevent FOUT layout shift); desktop uses fontsource's "Inter Variable".
Both resolve to Inter glyphs, so rendering is identical in practice.
Currently covers English + Simplified Chinese. When ja/ko i18n lands, extend
the tail with Hiragino Kaku Gothic ProN / Yu Gothic / Apple SD Gothic Neo / Malgun Gothic.
Per-character fallback: Latin chars render with Inter, Chinese chars with
PingFang SC (macOS) / Microsoft YaHei (Windows) / Noto Sans CJK SC (Linux).
Mono font has no explicit CJK fallback: CJK chars in code blocks are inherently
non-aligned with a mono grid (Chinese is proportional), so listing CJK fonts
would falsely signal alignment guarantees. Browser default fallback handles
the rare mixed case correctly. */
:root {
--font-sans: "Geist Sans", ui-sans-serif, system-ui, -apple-system, sans-serif;
--font-mono: "Geist Mono", ui-monospace, SFMono-Regular, Menlo, monospace;
--font-sans: "Inter Variable", "Inter", -apple-system, BlinkMacSystemFont,
"Segoe UI", "PingFang SC", "Microsoft YaHei", "Noto Sans CJK SC",
sans-serif;
--font-mono: "Geist Mono", ui-monospace, SFMono-Regular, Menlo, Consolas,
monospace;
}
@source "../../../../../packages/ui/**/*.tsx";

View File

@@ -1,9 +1,9 @@
import ReactDOM from "react-dom/client";
import App from "./App";
import "@fontsource/geist-sans/400.css";
import "@fontsource/geist-sans/500.css";
import "@fontsource/geist-sans/600.css";
import "@fontsource/geist-sans/700.css";
// Inter variable font covers all weights (100-900) in a single file.
// Geist Mono kept as-is for code blocks; CJK is handled by system font fallback
// (see globals.css --font-sans chain). Keep font stack in sync with apps/web/app/layout.tsx.
import "@fontsource-variable/inter";
import "@fontsource/geist-mono/400.css";
import "@fontsource/geist-mono/700.css";
import "./globals.css";

View File

@@ -0,0 +1,17 @@
import { useParams } from "react-router-dom";
import { useQuery } from "@tanstack/react-query";
import { AutopilotDetailPage as AutopilotDetail } from "@multica/views/autopilots/components";
import { useWorkspaceId } from "@multica/core/hooks";
import { autopilotDetailOptions } from "@multica/core/autopilots/queries";
import { useDocumentTitle } from "@/hooks/use-document-title";
export function AutopilotDetailPage() {
const { id } = useParams<{ id: string }>();
const wsId = useWorkspaceId();
const { data } = useQuery(autopilotDetailOptions(wsId, id!));
useDocumentTitle(data ? `${data.autopilot.title}` : "Autopilot");
if (!id) return null;
return <AutopilotDetail autopilotId={id} />;
}

View File

@@ -1,7 +1,19 @@
import { LoginPage } from "@multica/views/auth";
import { MulticaIcon } from "@multica/ui/components/common/multica-icon";
const WEB_URL = import.meta.env.VITE_WEB_URL || "http://localhost:3000";
export function DesktopLoginPage() {
const lastWorkspaceId = localStorage.getItem("multica_workspace_id");
const handleGoogleLogin = () => {
// Open web login page in the default browser with platform=desktop flag.
// The web callback will redirect back via multica:// deep link with the token.
window.desktopAPI.openExternal(
`${WEB_URL}/login?platform=desktop`,
);
};
return (
<div className="flex h-screen flex-col">
{/* Traffic light inset */}
@@ -11,9 +23,11 @@ export function DesktopLoginPage() {
/>
<LoginPage
logo={<MulticaIcon bordered size="lg" />}
lastWorkspaceId={lastWorkspaceId}
onSuccess={() => {
// Auth store update triggers AppContent re-render → shows DesktopShell
}}
onGoogleLogin={handleGoogleLogin}
/>
</div>
);

View File

@@ -7,6 +7,11 @@ import {
import { useAuthStore } from "@multica/core/auth";
import { useTabStore, resolveRouteIcon } from "@/stores/tab-store";
// Public web app URL — injected at build time via .env.production. Falls
// back to the production host for dev builds so "Copy link" yields a URL
// that actually points somewhere a teammate can open.
const APP_URL = import.meta.env.VITE_APP_URL || "https://multica.ai";
/**
* Root-level navigation provider for components outside the per-tab RouterProviders
* (sidebar, search dialog, modals, etc.).
@@ -64,7 +69,7 @@ export function DesktopNavigationProvider({
const tabId = store.openTab(path, title ?? path, icon);
store.setActiveTab(tabId);
},
getShareableUrl: (path: string) => `https://www.multica.ai${path}`,
getShareableUrl: (path: string) => `${APP_URL}${path}`,
}),
[pathname],
);
@@ -107,7 +112,7 @@ export function TabNavigationProvider({
const newTabId = store.openTab(path, title ?? path, icon);
store.setActiveTab(newTabId);
},
getShareableUrl: (path: string) => `https://www.multica.ai${path}`,
getShareableUrl: (path: string) => `${APP_URL}${path}`,
}),
[router, location],
);

View File

@@ -8,14 +8,22 @@ import {
import type { RouteObject } from "react-router-dom";
import { IssueDetailPage } from "./pages/issue-detail-page";
import { ProjectDetailPage } from "./pages/project-detail-page";
import { AutopilotDetailPage } from "./pages/autopilot-detail-page";
import { IssuesPage } from "@multica/views/issues/components";
import { ProjectsPage } from "@multica/views/projects/components";
import { AutopilotsPage } from "@multica/views/autopilots/components";
import { MyIssuesPage } from "@multica/views/my-issues";
import { RuntimesPage } from "@multica/views/runtimes";
import { SkillsPage } from "@multica/views/skills";
import { DaemonRuntimeCard } from "./components/daemon-runtime-card";
import { AgentsPage } from "@multica/views/agents";
import { InboxPage } from "@multica/views/inbox";
import { SettingsPage } from "@multica/views/settings";
import { OnboardingWizard } from "@multica/views/onboarding";
import { InvitePage } from "@multica/views/invite";
import { useNavigation } from "@multica/views/navigation";
import { Server } from "lucide-react";
import { DaemonSettingsTab } from "./components/daemon-settings-tab";
/**
* Sets document.title from the deepest matched route's handle.title.
@@ -47,6 +55,18 @@ function PageShell() {
);
}
function OnboardingRoute() {
const nav = useNavigation();
return <OnboardingWizard onComplete={() => nav.push("/issues")} />;
}
function InviteRoute() {
const matches = useMatches();
const match = matches.find((m) => (m.params as { id?: string }).id);
const id = (match?.params as { id?: string })?.id ?? "";
return <InvitePage invitationId={id} />;
}
/** Route definitions shared by all tabs (no layout wrapper). */
export const appRoutes: RouteObject[] = [
{
@@ -69,6 +89,16 @@ export const appRoutes: RouteObject[] = [
element: <ProjectDetailPage />,
handle: { title: "Project" },
},
{
path: "autopilots",
element: <AutopilotsPage />,
handle: { title: "Autopilot" },
},
{
path: "autopilots/:id",
element: <AutopilotDetailPage />,
handle: { title: "Autopilot" },
},
{
path: "my-issues",
element: <MyIssuesPage />,
@@ -76,15 +106,36 @@ export const appRoutes: RouteObject[] = [
},
{
path: "runtimes",
element: <RuntimesPage />,
element: <RuntimesPage topSlot={<DaemonRuntimeCard />} />,
handle: { title: "Runtimes" },
},
{ path: "skills", element: <SkillsPage />, handle: { title: "Skills" } },
{ path: "agents", element: <AgentsPage />, handle: { title: "Agents" } },
{ path: "inbox", element: <InboxPage />, handle: { title: "Inbox" } },
{
path: "onboarding",
element: <OnboardingRoute />,
handle: { title: "Get Started" },
},
{
path: "invite/:id",
element: <InviteRoute />,
handle: { title: "Accept Invite" },
},
{
path: "settings",
element: <SettingsPage />,
element: (
<SettingsPage
extraAccountTabs={[
{
value: "daemon",
label: "Daemon",
icon: Server,
content: <DaemonSettingsTab />,
},
]}
/>
),
handle: { title: "Settings" },
},
],

View File

@@ -2,6 +2,7 @@ import { create } from "zustand";
import { createJSONStorage, persist } from "zustand/middleware";
import { arrayMove } from "@dnd-kit/sortable";
import { createPersistStorage, defaultStorage } from "@multica/core/platform";
import { createSafeId } from "@multica/core/utils";
import type { DataRouter } from "react-router-dom";
import { createTabRouter } from "../routes";
@@ -69,7 +70,7 @@ export function resolveRouteIcon(pathname: string): string {
const DEFAULT_PATH = "/issues";
function createId(): string {
return crypto.randomUUID();
return createSafeId();
}
function makeTab(path: string, title: string, icon: string): Tab {

View File

@@ -0,0 +1,53 @@
export type DaemonState =
| "running"
| "stopped"
| "starting"
| "stopping"
| "installing_cli"
| "cli_not_found";
export interface DaemonStatus {
state: DaemonState;
pid?: number;
uptime?: string;
daemonId?: string;
deviceName?: string;
agents?: string[];
workspaceCount?: number;
/** CLI profile this daemon belongs to. Empty string means the default profile. */
profile?: string;
/** Backend URL the daemon connects to. */
serverUrl?: string;
}
export interface DaemonPrefs {
autoStart: boolean;
autoStop: boolean;
}
export const DAEMON_STATE_COLORS: Record<DaemonState, string> = {
running: "bg-emerald-500",
stopped: "bg-muted-foreground/40",
starting: "bg-amber-500 animate-pulse",
stopping: "bg-amber-500 animate-pulse",
installing_cli: "bg-sky-500 animate-pulse",
cli_not_found: "bg-red-500",
};
export const DAEMON_STATE_LABELS: Record<DaemonState, string> = {
running: "Running",
stopped: "Stopped",
starting: "Starting…",
stopping: "Stopping…",
installing_cli: "Setting up…",
cli_not_found: "Setup Failed",
};
export function formatUptime(uptime?: string): string {
if (!uptime) return "";
const match = uptime.match(/(?:(\d+)h)?(\d+)m/);
if (!match) return uptime;
const h = match[1] ? `${match[1]}h ` : "";
const m = match[2] ? `${match[2]}m` : "";
return `${h}${m}`.trim() || uptime;
}

View File

@@ -0,0 +1,10 @@
import { defineConfig } from "vitest/config";
export default defineConfig({
test: {
globals: true,
include: ["src/**/*.test.ts", "scripts/**/*.test.mjs"],
environment: "node",
passWithNoTests: true,
},
});

View File

@@ -8,8 +8,7 @@ description: Install the Multica CLI and start the agent daemon.
### Homebrew (macOS/Linux)
```bash
brew tap multica-ai/tap
brew install multica
brew install multica-ai/tap/multica
```
### Build from Source
@@ -48,25 +47,28 @@ rm /tmp/multica.tar.gz
### Update
```bash
brew upgrade multica-ai/tap/multica
```
For install script or manual installs, use:
```bash
multica update
```
This auto-detects your installation method (Homebrew or manual) and upgrades accordingly.
`multica update` auto-detects your installation method and upgrades accordingly.
## Quick Start
```bash
# 1. Authenticate (opens browser for login)
multica login
# 2. Start the agent daemon
multica daemon start
# 3. Done — agents in your watched workspaces can now execute tasks on your machine
# One command: configure, authenticate, and start the daemon
multica setup
```
`multica login` automatically discovers all workspaces you belong to and adds them to the daemon watch list.
This configures the CLI for Multica Cloud, opens your browser for login, discovers your workspaces, and starts the agent daemon.
For self-hosted servers, use `multica setup self-host` instead. See [Self-Hosting](/docs/getting-started/self-hosting) for details.
## Verify
@@ -76,12 +78,16 @@ multica daemon status
Confirm:
1. Status is `running`
2. At least one agent is listed (e.g. `claude`, `codex`)
2. At least one agent is listed (e.g. `claude`, `codex`, `gemini`, `opencode`, `openclaw`, or `hermes`)
3. At least one workspace is being watched
If the agents list is empty, install at least one AI agent CLI:
If the agents list is empty, install at least one supported AI agent CLI:
- [Claude Code](https://docs.anthropic.com/en/docs/claude-code) (`claude`)
- [Codex](https://github.com/openai/codex) (`codex`)
- [Gemini CLI](https://github.com/google-gemini/gemini-cli) (`gemini`)
- OpenCode (`opencode`)
- OpenClaw (`openclaw`)
- Hermes (`hermes`)
Then restart the daemon:

View File

@@ -88,6 +88,10 @@ The daemon auto-detects these AI CLIs on your PATH:
|-----|---------|-------------|
| [Claude Code](https://docs.anthropic.com/en/docs/claude-code) | `claude` | Anthropic's coding agent |
| [Codex](https://github.com/openai/codex) | `codex` | OpenAI's coding agent |
| [Gemini CLI](https://github.com/google-gemini/gemini-cli) | `gemini` | Google's coding agent |
| OpenCode | `opencode` | Open-source coding agent |
| OpenClaw | `openclaw` | Open-source coding agent |
| Hermes | `hermes` | Nous Research coding agent |
You need at least one installed. The daemon registers each detected CLI as an available runtime.
@@ -122,6 +126,14 @@ Agent-specific overrides:
| `MULTICA_CLAUDE_MODEL` | Override the Claude model used |
| `MULTICA_CODEX_PATH` | Custom path to the `codex` binary |
| `MULTICA_CODEX_MODEL` | Override the Codex model used |
| `MULTICA_OPENCODE_PATH` | Custom path to the `opencode` binary |
| `MULTICA_OPENCODE_MODEL` | Override the OpenCode model used |
| `MULTICA_OPENCLAW_PATH` | Custom path to the `openclaw` binary |
| `MULTICA_OPENCLAW_MODEL` | Override the OpenClaw model used |
| `MULTICA_HERMES_PATH` | Custom path to the `hermes` binary |
| `MULTICA_HERMES_MODEL` | Override the Hermes model used |
| `MULTICA_GEMINI_PATH` | Custom path to the `gemini` binary |
| `MULTICA_GEMINI_MODEL` | Override the Gemini model used |
### Self-Hosted Server
@@ -147,9 +159,11 @@ multica config set server_url wss://api.example.com/ws
Profiles let you run multiple daemons on the same machine — for example, one for production and one for a staging server.
```bash
# Start a daemon for the staging server
multica --profile staging login
multica --profile staging daemon start
# Set up a staging profile
multica setup self-host --profile staging --server-url https://api-staging.example.com --app-url https://staging.example.com
# Start its daemon
multica daemon start --profile staging
# Default profile runs separately
multica daemon start

View File

@@ -11,7 +11,7 @@ Go to [multica.ai](https://multica.ai) and create an account.
## 2. Install the CLI and start the daemon
Give this instruction to your AI agent (Claude Code, Codex, OpenClaw, OpenCode, etc.):
Give this instruction to your AI agent (Claude Code, Codex, Gemini CLI, OpenClaw, OpenCode, etc.):
```
Fetch https://github.com/multica-ai/multica/blob/main/CLI_INSTALL.md and follow the instructions to install Multica CLI, log in, and start the daemon on this machine.
@@ -19,17 +19,33 @@ Fetch https://github.com/multica-ai/multica/blob/main/CLI_INSTALL.md and follow
Or install manually:
```bash
# Install
brew tap multica-ai/tap
brew install multica
### macOS / Linux (Homebrew - recommended)
# Authenticate and start
multica login
multica daemon start
```bash
brew install multica-ai/tap/multica
```
The daemon auto-detects available agent CLIs (`claude`, `codex`, `openclaw`, `opencode`) on your PATH. When an agent is assigned a task, the daemon creates an isolated environment, runs the agent, and reports results back.
### macOS / Linux (install script)
```bash
# Install the CLI
curl -fsSL https://raw.githubusercontent.com/multica-ai/multica/main/scripts/install.sh | bash
```
### Windows (PowerShell)
```powershell
irm https://raw.githubusercontent.com/multica-ai/multica/main/scripts/install.ps1 | iex
```
Then configure, authenticate, and start the daemon:
```bash
# Configure, authenticate, and start the daemon
multica setup
```
The daemon auto-detects available agent CLIs (`claude`, `codex`, `gemini`, `openclaw`, `opencode`, `hermes`) on your PATH. When an agent is assigned a task, the daemon creates an isolated environment, runs the agent, and reports results back.
## 3. Verify your runtime
@@ -39,7 +55,7 @@ Open your workspace in the Multica web app. Navigate to **Settings → Runtimes*
## 4. Create an agent
Go to **Settings → Agents** and click **New Agent**. Pick the runtime you just connected and choose a provider (Claude Code, Codex, OpenClaw, or OpenCode). Give your agent a name — this is how it will appear on the board, in comments, and in assignments.
Go to **Settings → Agents** and click **New Agent**. Pick the runtime you just connected and choose a provider (Claude Code, Codex, Gemini CLI, OpenClaw, OpenCode, or Hermes). Give your agent a name — this is how it will appear on the board, in comments, and in assignments.
## 5. Assign your first task

View File

@@ -21,16 +21,21 @@ Each user who wants to run AI agents locally also installs the **`multica` CLI**
## Quick Install
One command to set up everything:
Two commands to set up everything:
```bash
curl -fsSL https://raw.githubusercontent.com/multica-ai/multica/main/scripts/install.sh | bash -s -- --local
# Install CLI + provision self-host server
curl -fsSL https://raw.githubusercontent.com/multica-ai/multica/main/scripts/install.sh | bash -s -- --with-server
# Configure CLI, authenticate, and start the daemon
multica setup self-host
```
This clones the repo, starts all services, installs the CLI, and configures everything. Then:
This clones the repo, starts all services, installs the CLI, and configures it for localhost. Then open http://localhost:3000 — log in with any email + code **`888888`**.
1. Open http://localhost:3000 — log in with any email + code **`888888`**
2. Run `multica login` and `multica daemon start`
<Callout>
If the self-host server is already running and you only need the CLI on a macOS/Linux machine, install it with Homebrew: `brew install multica-ai/tap/multica`.
</Callout>
<Callout>
For a step-by-step setup, see below.
@@ -78,11 +83,15 @@ brew install multica-ai/tap/multica
You also need at least one AI agent CLI:
- [Claude Code](https://docs.anthropic.com/en/docs/claude-code) (`claude` on PATH)
- [Codex](https://github.com/openai/codex) (`codex` on PATH)
- [Gemini CLI](https://github.com/google-gemini/gemini-cli) (`gemini` on PATH)
- OpenCode (`opencode` on PATH)
- OpenClaw (`openclaw` on PATH)
- Hermes (`hermes` on PATH)
### b) One-command setup
```bash
multica setup --local
multica setup self-host
```
This automatically:
@@ -91,6 +100,12 @@ This automatically:
3. Discovers your workspaces
4. Starts the daemon in the background
For on-premise deployments with custom domains:
```bash
multica setup self-host --server-url https://api.example.com --app-url https://app.example.com
```
Verify the daemon is running:
```bash
@@ -98,7 +113,7 @@ multica daemon status
```
<Callout>
Alternatively, configure manually: `multica config local && multica login && multica daemon start`
Alternatively, configure step by step: `multica config set server_url http://localhost:8080 && multica config set app_url http://localhost:3000 && multica login && multica daemon start`
</Callout>
### Step 4 — Verify & Start Using
@@ -118,6 +133,20 @@ make selfhost-stop
multica daemon stop
```
## Switching to Multica Cloud
If you've been self-hosting and want to switch your CLI to [Multica Cloud](https://multica.ai):
```bash
multica setup
```
This reconfigures the CLI for multica.ai, re-authenticates, and restarts the daemon. You will be prompted before overwriting the existing configuration.
<Callout>
Your local Docker services are unaffected. Stop them separately if you no longer need them.
</Callout>
## Rebuilding After Updates
```bash
@@ -191,6 +220,23 @@ These are configured on each user's machine, not on the server:
| `MULTICA_DAEMON_POLL_INTERVAL` | `3s` | How often the daemon polls for tasks |
| `MULTICA_DAEMON_HEARTBEAT_INTERVAL` | `15s` | Heartbeat frequency |
Agent-specific overrides:
| Variable | Description |
|----------|-------------|
| `MULTICA_CLAUDE_PATH` | Custom path to the `claude` binary |
| `MULTICA_CLAUDE_MODEL` | Override the Claude model used |
| `MULTICA_CODEX_PATH` | Custom path to the `codex` binary |
| `MULTICA_CODEX_MODEL` | Override the Codex model used |
| `MULTICA_OPENCODE_PATH` | Custom path to the `opencode` binary |
| `MULTICA_OPENCODE_MODEL` | Override the OpenCode model used |
| `MULTICA_OPENCLAW_PATH` | Custom path to the `openclaw` binary |
| `MULTICA_OPENCLAW_MODEL` | Override the OpenClaw model used |
| `MULTICA_HERMES_PATH` | Custom path to the `hermes` binary |
| `MULTICA_HERMES_MODEL` | Override the Hermes model used |
| `MULTICA_GEMINI_PATH` | Custom path to the `gemini` binary |
| `MULTICA_GEMINI_MODEL` | Override the Gemini model used |
## Database Setup
Multica requires PostgreSQL 17 with the pgvector extension.
@@ -269,6 +315,8 @@ api.example.com {
}
```
For a single-domain setup, route the frontend and backend through one hostname and forward `/api`, `/auth`, `/ws`, and `/health` to the backend while sending everything else to the frontend. This repository now includes a root `Caddyfile` and `docker-compose.selfhost.yml` service for that pattern.
### Nginx
```nginx

View File

@@ -15,7 +15,7 @@ When an agent is assigned a task in Multica:
1. The daemon detects the task assignment
2. It creates an isolated workspace directory
3. It spawns the appropriate agent CLI (Claude Code, Codex, OpenClaw, or OpenCode)
3. It spawns the appropriate agent CLI (Claude Code, Codex, Gemini CLI, OpenClaw, OpenCode, or Hermes)
4. The agent executes autonomously, streaming progress back to Multica
5. Results are reported — success, failure, or blockers
@@ -29,21 +29,28 @@ Real-time progress is streamed via WebSocket so you can follow along in the Mult
|----------|-------------|-------------|
| Claude Code | `claude` | Anthropic's coding agent |
| Codex | `codex` | OpenAI's coding agent |
| Gemini CLI | `gemini` | Google's coding agent |
| OpenClaw | `openclaw` | Open-source coding agent |
| OpenCode | `opencode` | Open-source coding agent |
| Hermes | `hermes` | Nous Research coding agent |
The daemon auto-detects which CLIs are available on your PATH and registers them as available runtimes.
## Reusable Skills
Every solution an agent creates can become a reusable skill for the whole team. Skills compound your team's capabilities over time:
Multica supports two layers of skills:
- **Local skills** — Skills already installed in your local runtime (e.g., `.claude/skills/`, `.config/opencode/skills/`) are automatically discovered and used by agents. You do **not** need to upload them to Multica.
- **Workspace skills** — Skills created or imported in the Multica Skills page are shared across the workspace. They are automatically injected into agent runs as supplementary context, so every team member's agents benefit from them.
Workspace skills are designed for team-wide sharing and collaboration — codify your team's best practices once, and every agent can leverage them:
- Deployments
- Migrations
- Code reviews
- Common patterns
Skills are shared across the workspace, so any agent (or human) can leverage them.
Your skill library compounds over time. Local skills give individual agents their capabilities; workspace skills align the entire team.
## Multi-Workspace Support

View File

@@ -5,14 +5,13 @@ description: Assign your first task to an agent in under 5 minutes.
Once you have the CLI installed (or signed up for [Multica Cloud](https://multica.ai)), follow these steps to assign your first task to an agent.
## 1. Log in and start the daemon
## 1. Set up and start the daemon
```bash
multica login # Authenticate with your Multica account
multica daemon start # Start the local agent runtime
multica setup # Configure, authenticate, and start the daemon
```
The daemon runs in the background and keeps your machine connected to Multica. It auto-detects agent CLIs (`claude`, `codex`, `openclaw`, `opencode`) available on your PATH.
This configures the CLI, opens your browser for login, discovers your workspaces, and starts the agent daemon in the background. It auto-detects agent CLIs (`claude`, `codex`, `gemini`, `openclaw`, `opencode`, `hermes`) available on your PATH.
## 2. Verify your runtime
@@ -22,7 +21,7 @@ Open your workspace in the Multica web app. Navigate to **Settings → Runtimes*
## 3. Create an agent
Go to **Settings → Agents** and click **New Agent**. Pick the runtime you just connected and choose a provider (Claude Code, Codex, OpenClaw, or OpenCode). Give your agent a name — this is how it will appear on the board, in comments, and in assignments.
Go to **Settings → Agents** and click **New Agent**. Pick the runtime you just connected and choose a provider (Claude Code, Codex, Gemini CLI, OpenClaw, OpenCode, or Hermes). Give your agent a name — this is how it will appear on the board, in comments, and in assignments.
## 4. Assign your first task

View File

@@ -7,7 +7,7 @@ description: Multica — the open-source managed agents platform. Turn coding ag
Multica turns coding agents into real teammates. Assign issues to an agent like you'd assign to a colleague — they'll pick up the work, write code, report blockers, and update statuses autonomously.
No more copy-pasting prompts. No more babysitting runs. Your agents show up on the board, participate in conversations, and compound reusable skills over time. Think of it as open-source infrastructure for managed agents — vendor-neutral, self-hosted, and designed for human + AI teams. Works with **Claude Code**, **Codex**, **OpenClaw**, and **OpenCode**.
No more copy-pasting prompts. No more babysitting runs. Your agents show up on the board, participate in conversations, and compound reusable skills over time. Think of it as open-source infrastructure for managed agents — vendor-neutral, self-hosted, and designed for human + AI teams. Works with **Claude Code**, **Codex**, **Gemini CLI**, **OpenClaw**, **OpenCode**, and **Hermes**.
## Features
@@ -24,7 +24,7 @@ No more copy-pasting prompts. No more babysitting runs. Your agents show up on t
| Frontend | Next.js 16 (App Router) |
| Backend | Go (Chi router, sqlc, gorilla/websocket) |
| Database | PostgreSQL 17 with pgvector |
| Agent Runtime | Local daemon executing Claude Code, Codex, OpenClaw, or OpenCode |
| Agent Runtime | Local daemon executing Claude Code, Codex, Gemini CLI, OpenClaw, OpenCode, or Hermes |
```
┌──────────────┐ ┌──────────────┐ ┌──────────────────┐
@@ -35,7 +35,7 @@ No more copy-pasting prompts. No more babysitting runs. Your agents show up on t
┌──────┴───────┐
│ Agent Daemon │ (runs on your machine)
│Claude/Codex/ │
OpenClaw/Code
Gemini/Hermes
└──────────────┘
```

View File

@@ -0,0 +1,24 @@
"use client";
import { useEffect } from "react";
import { useRouter, useParams } from "next/navigation";
import { useAuthStore } from "@multica/core/auth";
import { InvitePage } from "@multica/views/invite";
export default function InviteAcceptPage() {
const router = useRouter();
const params = useParams<{ id: string }>();
const user = useAuthStore((s) => s.user);
const isLoading = useAuthStore((s) => s.isLoading);
// Redirect to login if not authenticated, with a redirect back to this page.
useEffect(() => {
if (!isLoading && !user) {
router.replace(`/login?next=/invite/${params.id}`);
}
}, [isLoading, user, router, params.id]);
if (isLoading || !user) return null;
return <InvitePage invitationId={params.id} />;
}

View File

@@ -1,6 +1,15 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import type { ReactNode } from "react";
function createWrapper() {
const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } });
return ({ children }: { children: ReactNode }) => (
<QueryClientProvider client={qc}>{children}</QueryClientProvider>
);
}
const { mockSendCode, mockVerifyCode, mockHydrateWorkspace } = vi.hoisted(
() => ({
@@ -66,7 +75,7 @@ describe("LoginPage", () => {
});
it("renders login form with email input and continue button", () => {
render(<LoginPage />);
render(<LoginPage />, { wrapper: createWrapper() });
expect(screen.getByText("Sign in to Multica")).toBeInTheDocument();
expect(screen.getByText("Enter your email to get a login code")).toBeInTheDocument();
@@ -78,7 +87,7 @@ describe("LoginPage", () => {
it("does not call sendCode when email is empty", async () => {
const user = userEvent.setup();
render(<LoginPage />);
render(<LoginPage />, { wrapper: createWrapper() });
await user.click(screen.getByRole("button", { name: "Continue" }));
expect(mockSendCode).not.toHaveBeenCalled();
@@ -87,7 +96,7 @@ describe("LoginPage", () => {
it("calls sendCode with email on submit", async () => {
mockSendCode.mockResolvedValueOnce(undefined);
const user = userEvent.setup();
render(<LoginPage />);
render(<LoginPage />, { wrapper: createWrapper() });
await user.type(screen.getByLabelText("Email"), "test@multica.ai");
await user.click(screen.getByRole("button", { name: "Continue" }));
@@ -100,7 +109,7 @@ describe("LoginPage", () => {
it("shows 'Sending code...' while submitting", async () => {
mockSendCode.mockReturnValueOnce(new Promise(() => {}));
const user = userEvent.setup();
render(<LoginPage />);
render(<LoginPage />, { wrapper: createWrapper() });
await user.type(screen.getByLabelText("Email"), "test@multica.ai");
await user.click(screen.getByRole("button", { name: "Continue" }));
@@ -113,7 +122,7 @@ describe("LoginPage", () => {
it("shows verification code step after sending code", async () => {
mockSendCode.mockResolvedValueOnce(undefined);
const user = userEvent.setup();
render(<LoginPage />);
render(<LoginPage />, { wrapper: createWrapper() });
await user.type(screen.getByLabelText("Email"), "test@multica.ai");
await user.click(screen.getByRole("button", { name: "Continue" }));
@@ -126,7 +135,7 @@ describe("LoginPage", () => {
it("shows error when sendCode fails", async () => {
mockSendCode.mockRejectedValueOnce(new Error("Network error"));
const user = userEvent.setup();
render(<LoginPage />);
render(<LoginPage />, { wrapper: createWrapper() });
await user.type(screen.getByLabelText("Email"), "test@multica.ai");
await user.click(screen.getByRole("button", { name: "Continue" }));

View File

@@ -3,6 +3,7 @@
import { Suspense, useEffect } from "react";
import { useSearchParams, useRouter } from "next/navigation";
import { useAuthStore } from "@multica/core/auth";
import { useWorkspaceStore } from "@multica/core/workspace";
import { setLoggedInCookie } from "@/features/auth/auth-cookie";
import { LoginPage, validateCliCallback } from "@multica/views/auth";
@@ -16,6 +17,7 @@ function LoginPageContent() {
const cliCallbackRaw = searchParams.get("cli_callback");
const cliState = searchParams.get("cli_state") || "";
const platform = searchParams.get("platform");
const nextUrl = searchParams.get("next") || "/issues";
// Already authenticated — redirect to dashboard (skip if CLI callback)
@@ -30,14 +32,29 @@ function LoginPageContent() {
? localStorage.getItem("multica_workspace_id")
: null;
const handleSuccess = () => {
const ws = useWorkspaceStore.getState().workspace;
router.push(ws ? nextUrl : "/onboarding");
};
// Build Google OAuth state: encode platform + next URL so the callback
// can redirect to the right place after login.
const googleState = [
platform === "desktop" ? "platform:desktop" : "",
nextUrl !== "/issues" ? `next:${nextUrl}` : "",
]
.filter(Boolean)
.join(",") || undefined;
return (
<LoginPage
onSuccess={() => router.push(nextUrl)}
onSuccess={handleSuccess}
google={
googleClientId
? {
clientId: googleClientId,
redirectUri: `${window.location.origin}/auth/callback`,
state: googleState,
}
: undefined
}

View File

@@ -0,0 +1,23 @@
"use client";
import { useEffect } from "react";
import { useRouter } from "next/navigation";
import { useAuthStore } from "@multica/core/auth";
import { OnboardingWizard } from "@multica/views/onboarding";
export default function OnboardingPage() {
const router = useRouter();
const user = useAuthStore((s) => s.user);
const isLoading = useAuthStore((s) => s.isLoading);
// Redirect to login if not authenticated
useEffect(() => {
if (!isLoading && !user) router.replace("/login");
}, [isLoading, user, router]);
if (isLoading || !user) return null;
return (
<OnboardingWizard onComplete={() => router.push("/issues")} />
);
}

View File

@@ -0,0 +1,13 @@
"use client";
import { use } from "react";
import { AutopilotDetailPage } from "@multica/views/autopilots/components";
export default function Page({
params,
}: {
params: Promise<{ id: string }>;
}) {
const { id } = use(params);
return <AutopilotDetailPage autopilotId={id} />;
}

View File

@@ -0,0 +1,7 @@
"use client";
import { AutopilotsPage } from "@multica/views/autopilots/components";
export default function Page() {
return <AutopilotsPage />;
}

View File

@@ -11,6 +11,8 @@ export default function Layout({ children }: { children: React.ReactNode }) {
loadingIndicator={<MulticaIcon className="size-6" />}
searchSlot={<SearchTrigger />}
extra={<><SearchCommand /><ChatWindow /><ChatFab /></>}
onboardingPath="/onboarding"
loginPath="/login"
>
{children}
</DashboardLayout>

View File

@@ -2,8 +2,10 @@
import { Suspense, useEffect, useState } from "react";
import { useSearchParams, useRouter } from "next/navigation";
import { useQueryClient } from "@tanstack/react-query";
import { useAuthStore } from "@multica/core/auth";
import { useWorkspaceStore } from "@multica/core/workspace";
import { workspaceKeys } from "@multica/core/workspace/queries";
import { api } from "@multica/core/api";
import {
Card,
@@ -12,14 +14,17 @@ import {
CardDescription,
CardContent,
} from "@multica/ui/components/ui/card";
import { Button } from "@multica/ui/components/ui/button";
import { Loader2 } from "lucide-react";
function CallbackContent() {
const router = useRouter();
const searchParams = useSearchParams();
const qc = useQueryClient();
const loginWithGoogle = useAuthStore((s) => s.loginWithGoogle);
const hydrateWorkspace = useWorkspaceStore((s) => s.hydrateWorkspace);
const [error, setError] = useState("");
const [desktopToken, setDesktopToken] = useState<string | null>(null);
useEffect(() => {
const code = searchParams.get("code");
@@ -34,19 +39,68 @@ function CallbackContent() {
return;
}
const state = searchParams.get("state") || "";
const stateParts = state.split(",");
const isDesktop = stateParts.includes("platform:desktop");
const nextPart = stateParts.find((p) => p.startsWith("next:"));
const nextUrl = nextPart ? nextPart.slice(5) : null; // strip "next:" prefix
const redirectUri = `${window.location.origin}/auth/callback`;
loginWithGoogle(code, redirectUri)
.then(async () => {
const wsList = await api.listWorkspaces();
const lastWsId = localStorage.getItem("multica_workspace_id");
await hydrateWorkspace(wsList, lastWsId);
router.push("/issues");
})
.catch((err) => {
setError(err instanceof Error ? err.message : "Login failed");
});
}, [searchParams, loginWithGoogle, hydrateWorkspace, router]);
if (isDesktop) {
// Desktop flow: exchange code for token, then redirect via deep link
api
.googleLogin(code, redirectUri)
.then(({ token }) => {
setDesktopToken(token);
window.location.href = `multica://auth/callback?token=${encodeURIComponent(token)}`;
})
.catch((err) => {
setError(err instanceof Error ? err.message : "Login failed");
});
} else {
// Normal web flow
loginWithGoogle(code, redirectUri)
.then(async () => {
const wsList = await api.listWorkspaces();
qc.setQueryData(workspaceKeys.list(), wsList);
const lastWsId = localStorage.getItem("multica_workspace_id");
const ws = await hydrateWorkspace(wsList, lastWsId);
// Honor the ?next= redirect if present (e.g. /invite/{id})
const defaultDest = ws ? "/issues" : "/onboarding";
router.push(nextUrl || defaultDest);
})
.catch((err) => {
setError(err instanceof Error ? err.message : "Login failed");
});
}
}, [searchParams, loginWithGoogle, hydrateWorkspace, router, qc]);
if (desktopToken) {
return (
<div className="flex min-h-screen items-center justify-center">
<Card className="w-full max-w-sm">
<CardHeader className="text-center">
<CardTitle className="text-2xl">Opening Multica</CardTitle>
<CardDescription>
You should see a prompt to open the Multica desktop app. If
nothing happens, click the button below.
</CardDescription>
</CardHeader>
<CardContent className="flex justify-center">
<Button
variant="outline"
onClick={() => {
window.location.href = `multica://auth/callback?token=${encodeURIComponent(desktopToken)}`;
}}
>
Open Multica Desktop
</Button>
</CardContent>
</Card>
</div>
);
}
if (error) {
return (

View File

@@ -1,5 +1,5 @@
import type { Metadata, Viewport } from "next";
import { Geist, Geist_Mono } from "next/font/google";
import { Inter, Geist_Mono } from "next/font/google";
import { ThemeProvider } from "@/components/theme-provider";
import { Toaster } from "@multica/ui/components/ui/sonner";
import { cn } from "@multica/ui/lib/utils";
@@ -7,8 +7,38 @@ import { WebProviders } from "@/components/web-providers";
import { LocaleSync } from "@/components/locale-sync";
import "./globals.css";
const geist = Geist({ subsets: ["latin"], variable: "--font-sans" });
const geistMono = Geist_Mono({ subsets: ["latin"], variable: "--font-mono" });
// Font stack: Inter for Latin UI text + system Chinese fonts for zh content.
// Desktop app uses the same stack via apps/desktop/src/renderer/src/globals.css —
// keep the CJK fallback tail in sync across both files. The Inter primary family
// differs by design: next/font produces `__Inter_xxx` (with a synthetic size-adjusted
// fallback face to prevent FOUT layout shift); desktop uses fontsource's "Inter Variable".
// Both resolve to Inter glyphs, so rendering is identical in practice.
// Currently covers English + Simplified Chinese. When ja/ko i18n lands, extend
// the tail with Hiragino Kaku Gothic ProN / Yu Gothic / Apple SD Gothic Neo / Malgun Gothic.
// Per-character fallback: Latin chars render with Inter, Chinese chars with
// PingFang SC (macOS) / Microsoft YaHei (Windows) / Noto Sans CJK SC (Linux).
const inter = Inter({
subsets: ["latin"],
variable: "--font-sans",
fallback: [
"-apple-system",
"BlinkMacSystemFont",
"Segoe UI",
"PingFang SC",
"Microsoft YaHei",
"Noto Sans CJK SC",
"sans-serif",
],
});
// Mono font has no explicit CJK fallback: CJK chars in code blocks are inherently
// non-aligned with a mono grid (Chinese is proportional), so listing CJK fonts
// here would falsely signal alignment guarantees. Browser default fallback handles
// the rare mixed case correctly.
const geistMono = Geist_Mono({
subsets: ["latin"],
variable: "--font-mono",
fallback: ["ui-monospace", "SFMono-Regular", "Menlo", "Consolas", "monospace"],
});
export const viewport: Viewport = {
width: "device-width",
@@ -59,7 +89,7 @@ export default function RootLayout({
<html
lang="en"
suppressHydrationWarning
className={cn("antialiased font-sans h-full", geist.variable, geistMono.variable)}
className={cn("antialiased font-sans h-full", inter.variable, geistMono.variable)}
>
<body className="h-full overflow-hidden">
<LocaleSync />

View File

@@ -7,11 +7,38 @@ import {
clearLoggedInCookie,
} from "@/features/auth/auth-cookie";
// Legacy token in localStorage → keep this session in token mode so users who
// logged in before the cookie-auth migration stay authed. They migrate to
// cookie mode on their next logout/login cycle (logout clears multica_token).
// Sunset: once telemetry shows <1% of sessions still carry multica_token,
// delete this branch and hard-code `cookieAuth` — the localStorage token is
// XSS-exposed and is the exact thing the cookie migration exists to remove.
function hasLegacyToken(): boolean {
if (typeof window === "undefined") return false;
try {
return Boolean(window.localStorage.getItem("multica_token"));
} catch {
return false;
}
}
// Derive WebSocket URL from the page origin so self-hosted / LAN deployments
// work without explicit NEXT_PUBLIC_WS_URL. The Next.js rewrite rule
// (/ws → backend) handles proxying.
function deriveWsUrl(): string | undefined {
if (process.env.NEXT_PUBLIC_WS_URL) return process.env.NEXT_PUBLIC_WS_URL;
if (typeof window === "undefined") return undefined;
const proto = window.location.protocol === "https:" ? "wss:" : "ws:";
return `${proto}//${window.location.host}/ws`;
}
export function WebProviders({ children }: { children: React.ReactNode }) {
const cookieAuth = !hasLegacyToken();
return (
<CoreProvider
apiBaseUrl={process.env.NEXT_PUBLIC_API_URL}
wsUrl={process.env.NEXT_PUBLIC_WS_URL}
wsUrl={deriveWsUrl()}
cookieAuth={cookieAuth}
onLogin={setLoggedInCookie}
onLogout={clearLoggedInCookie}
>

View File

@@ -1,5 +1,6 @@
"use client";
import { useCallback, useState } from "react";
import Image from "next/image";
import Link from "next/link";
import { useAuthStore } from "@multica/core/auth";
@@ -7,6 +8,7 @@ import { useLocale } from "../i18n";
import {
ClaudeCodeLogo,
CodexLogo,
GeminiCliLogo,
OpenClawLogo,
OpenCodeLogo,
GitHubMark,
@@ -52,6 +54,8 @@ export function LandingHero() {
GitHub
</Link>
</div>
<InstallCommand />
</div>
<div className="mt-10 flex items-center justify-center gap-8">
@@ -67,6 +71,10 @@ export function LandingHero() {
<CodexLogo className="size-5" />
<span className="text-[15px] font-medium">Codex</span>
</div>
<div className="flex items-center gap-2.5 text-white/80">
<GeminiCliLogo className="size-5" />
<span className="text-[15px] font-medium">Gemini CLI</span>
</div>
<div className="flex items-center gap-2.5 text-white/80">
<OpenClawLogo className="size-5" />
<span className="text-[15px] font-medium">OpenClaw</span>
@@ -87,6 +95,64 @@ export function LandingHero() {
);
}
const INSTALL_COMMAND =
"curl -fsSL https://raw.githubusercontent.com/multica-ai/multica/main/scripts/install.sh | bash";
function InstallCommand() {
const [copied, setCopied] = useState(false);
const handleCopy = useCallback(async () => {
try {
await navigator.clipboard.writeText(INSTALL_COMMAND);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
} catch {
// ignore
}
}, []);
return (
<div className="mx-auto mt-6 max-w-fit">
<button
type="button"
onClick={handleCopy}
className="group flex items-center gap-3 rounded-lg border border-white/10 bg-white/5 px-4 py-2.5 font-mono text-[13px] text-white/70 backdrop-blur-sm transition-colors hover:border-white/20 hover:bg-white/8 hover:text-white/90"
>
<span className="text-white/40">$</span>
<span className="select-all">{INSTALL_COMMAND}</span>
<span className="ml-1 flex size-5 shrink-0 items-center justify-center text-white/40 transition-colors group-hover:text-white/70">
{copied ? (
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
className="size-3.5 text-green-400"
>
<polyline points="20 6 9 17 4 12" />
</svg>
) : (
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
className="size-3.5"
>
<rect x="9" y="9" width="13" height="13" rx="2" ry="2" />
<path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1" />
</svg>
)}
</span>
</button>
</div>
);
}
function LandingBackdrop() {
return (
<div className="pointer-events-none absolute inset-0">

View File

@@ -1,7 +1,7 @@
import { cn } from "@multica/ui/lib/utils";
export const githubUrl = "https://github.com/multica-ai/multica";
export const twitterUrl = "https://x.com/multica_hq";
export const twitterUrl = "https://x.com/MulticaAI";
export function GitHubMark({ className }: { className?: string }) {
return (
@@ -136,6 +136,19 @@ export function OpenClawLogo({ className }: { className?: string }) {
);
}
export function GeminiCliLogo({ className }: { className?: string }) {
return (
<svg
viewBox="0 0 24 24"
aria-hidden="true"
className={className}
fill="currentColor"
>
<path d="M12 0C12 0 12 8 8 12C12 12 12 12 12 24C12 24 12 16 16 12C12 12 12 12 12 0Z" />
</svg>
);
}
export function OpenCodeLogo({ className }: { className?: string }) {
return (
<svg

View File

@@ -126,7 +126,7 @@ export const en: LandingDict = {
{
title: "Install the CLI & connect your machine",
description:
"Run multica login to authenticate, then multica daemon start. The daemon auto-detects Claude Code, Codex, OpenClaw, and OpenCode on your machine \u2014 plug in and go.",
"Run multica setup to configure, authenticate, and start the daemon. It auto-detects Claude Code, Codex, OpenClaw, and OpenCode on your machine \u2014 plug in and go.",
},
{
title: "Create your first agent",
@@ -230,7 +230,7 @@ export const en: LandingDict = {
links: [
{ label: "Documentation", href: githubUrl },
{ label: "API", href: githubUrl },
{ label: "X (Twitter)", href: "https://x.com/multica_hq" },
{ label: "X (Twitter)", href: "https://x.com/MulticaAI" },
],
},
company: {
@@ -277,6 +277,72 @@ export const en: LandingDict = {
fixes: "Bug Fixes",
},
entries: [
{
version: "0.1.33",
date: "2026-04-14",
title: "Gemini CLI & Agent Env Vars",
changes: [],
features: [
"Google Gemini CLI as a new agent runtime with live log streaming",
"Custom environment variables for agents (router/proxy mode) with dedicated settings tab",
"\"Set parent issue\" and \"Add sub-issue\" actions in issue context menu",
"CLI `--parent` flag for issue update and `--content-stdin` for piping comment content",
"Sub-issues inherit parent project automatically",
],
improvements: [
"Editor bubble menu and link preview rewritten for reliability",
"OpenClaw backend P0+P1 improvements (multi-line JSON, incremental parsing)",
"Self-hosted WebSocket URL auto-derived for LAN access",
],
fixes: [
"S3 upload keys scoped by workspace (security)",
"Workspace membership validation for subscriptions and uploads (security)",
"Active tasks auto-cancelled when issue status changes to cancelled",
"Agent task stall when process hangs on stdout",
"Daemon trigger prompt now embeds the actual triggering comment content",
"Login and dashboard redirect stability improvements",
],
},
{
version: "0.1.28",
date: "2026-04-13",
title: "Windows Support, Auth & Onboarding",
changes: [],
features: [
"Windows support — CLI installation, daemon, and release builds",
"Auth migrated to HttpOnly Cookie with WebSocket Origin whitelist",
"Full-screen onboarding wizard for new workspaces",
"Resizable Master Agent chat window with session history improvements",
"Token usage log scanning for OpenCode, OpenClaw, and Hermes runtimes",
],
fixes: [
"WebSocket first-message authentication security fix",
"Content-Security-Policy response header",
"Sub-issue progress computed from database instead of paginated client cache",
],
},
{
version: "0.1.27",
date: "2026-04-12",
title: "One-Click Setup, Self-Hosting & Stability",
changes: [],
features: [
"One-click install & setup — `curl | bash` installs CLI, `--with-server` bootstraps full self-hosting, `multica setup` configures your environment",
"Self-hosted storage — local file fallback when S3 is unavailable, plus custom S3 endpoint support (MinIO)",
"Inline property editing (priority, status, lead) on project list page",
],
improvements: [
"Stale agent tasks auto-swept; agent live card shows immediately without waiting for first message",
"Comment attachments uploaded via CLI now visible in the UI",
"Pinned items scoped per user with fixed sidebar pin action",
],
fixes: [
"Workspace ownership checks on daemon API routes and attachment uploads",
"Markdown sanitizer preserves code blocks from HTML entity escaping",
"Next.js upgraded to ^16.2.3 for CVE-2026-23869",
"OpenClaw backend rewritten to match actual CLI interface",
],
},
{
version: "0.1.24",
date: "2026-04-11",

View File

@@ -126,7 +126,7 @@ export const zh: LandingDict = {
{
title: "\u5b89\u88c5 CLI \u5e76\u8fde\u63a5\u4f60\u7684\u673a\u5668",
description:
"\u8fd0\u884c multica login \u8fdb\u884c\u8ba4\u8bc1\uff0c\u7136\u540e multica daemon start\u3002\u5b88\u62a4\u8fdb\u7a0b\u81ea\u52a8\u68c0\u6d4b\u4f60\u673a\u5668\u4e0a\u7684 Claude Code\u3001Codex\u3001OpenClaw \u548c OpenCode\u2014\u2014\u63d2\u4e0a\u5c31\u7528\u3002",
"运行 multica setup 一键完成配置、认证和启动。守护进程自动检测你机器上的 Claude Code、Codex、OpenClaw OpenCode——插上就用。",
},
{
title: "\u521b\u5efa\u4f60\u7684\u7b2c\u4e00\u4e2a Agent",
@@ -230,7 +230,7 @@ export const zh: LandingDict = {
links: [
{ label: "\u6587\u6863", href: githubUrl },
{ label: "API", href: githubUrl },
{ label: "X (Twitter)", href: "https://x.com/multica_hq" },
{ label: "X (Twitter)", href: "https://x.com/MulticaAI" },
],
},
company: {
@@ -277,6 +277,72 @@ export const zh: LandingDict = {
fixes: "问题修复",
},
entries: [
{
version: "0.1.33",
date: "2026-04-14",
title: "Gemini CLI 与 Agent 环境变量",
changes: [],
features: [
"Google Gemini CLI 作为新的 Agent 运行时,支持实时日志流",
"Agent 自定义环境变量router/proxy 模式),新增专用设置标签页",
"Issue 右键菜单新增「设置父 Issue」和「添加子 Issue」",
"CLI `--parent` 更新父 Issue`--content-stdin` 管道输入评论内容",
"子 Issue 自动继承父级项目",
],
improvements: [
"编辑器气泡菜单和链接预览重写",
"OpenClaw 后端 P0+P1 优化(多行 JSON、增量解析",
"自部署 WebSocket URL 自动适配局域网访问",
],
fixes: [
"S3 上传路径按工作区隔离(安全)",
"订阅和上传新增工作区成员身份校验(安全)",
"Issue 状态改为已取消时自动终止进行中的任务",
"Agent 进程 stdout 挂起导致任务卡住",
"Daemon 触发提示现在嵌入实际的触发评论内容",
"登录和仪表盘跳转稳定性改进",
],
},
{
version: "0.1.28",
date: "2026-04-13",
title: "Windows 支持、认证与引导",
changes: [],
features: [
"Windows 支持——CLI 安装、Daemon 运行和发布构建",
"认证迁移至 HttpOnly CookieWebSocket 新增 Origin 白名单",
"新工作区全屏引导向导",
"Master Agent 聊天窗口可调整大小,会话历史体验优化",
"OpenCode、OpenClaw 和 Hermes 运行时 Token 用量日志扫描",
],
fixes: [
"WebSocket 首条消息认证安全修复",
"新增 Content-Security-Policy 响应头",
"子 Issue 进度改为从数据库计算而非分页客户端缓存",
],
},
{
version: "0.1.27",
date: "2026-04-12",
title: "一键安装、自部署与稳定性",
changes: [],
features: [
"一键安装与配置——`curl | bash` 安装 CLI`--with-server` 完整自部署,`multica setup` 配置连接环境",
"自部署存储——无 S3 时本地文件存储回退,支持自定义 S3 端点MinIO",
"项目列表页支持行内编辑属性(优先级、状态、负责人)",
],
improvements: [
"过期 Agent 任务自动清扫;执行卡片立即显示,无需等待首条消息",
"通过 CLI 上传的评论附件现在可在 UI 中显示",
"置顶项按用户隔离,修复侧边栏置顶操作",
],
fixes: [
"Daemon API 路由和附件上传新增工作区所有权校验",
"Markdown 清洗器保留代码块不被 HTML 实体转义",
"Next.js 升级至 ^16.2.3 修复 CVE-2026-23869",
"OpenClaw 后端重写以匹配实际 CLI 接口",
],
},
{
version: "0.1.24",
date: "2026-04-11",

View File

@@ -45,6 +45,10 @@ const nextConfig: NextConfig = {
source: "/auth/:path*",
destination: `${remoteApiUrl}/auth/:path*`,
},
{
source: "/uploads/:path*",
destination: `${remoteApiUrl}/uploads/:path*`,
},
];
},
};

View File

@@ -1,11 +1,7 @@
import { NextResponse } from "next/server";
import type { NextRequest } from "next/server";
export function proxy(request: NextRequest) {
const loggedIn = request.cookies.has("multica_logged_in");
if (loggedIn) {
return NextResponse.redirect(new URL("/issues", request.url));
}
export function proxy(_request: NextRequest) {
return NextResponse.next();
}

View File

@@ -54,6 +54,8 @@ export const mockAgents: Agent[] = [
status: "idle",
runtime_mode: "cloud",
runtime_config: {},
custom_env: {},
custom_env_redacted: false,
visibility: "workspace",
max_concurrent_tasks: 3,
owner_id: null,
@@ -75,14 +77,9 @@ export const mockAuthValue: Record<string, any> = {
isLoading: false,
login: vi.fn(),
logout: vi.fn(),
workspaces: [mockWorkspace],
switchWorkspace: vi.fn(),
createWorkspace: vi.fn(),
updateWorkspace: vi.fn(),
updateCurrentUser: vi.fn(),
leaveWorkspace: vi.fn(),
deleteWorkspace: vi.fn(),
refreshWorkspaces: vi.fn(),
getMemberName: (userId: string) => {
const m = mockMembers.find((m) => m.user_id === userId);
return m?.name ?? "Unknown";

View File

@@ -5,8 +5,8 @@
# # Edit .env — change JWT_SECRET at minimum
# docker compose -f docker-compose.selfhost.yml up -d
#
# Frontend: http://localhost:3000
# Backend: http://localhost:8080 (also used by CLI/daemon)
# Frontend: https://$TASK_DOMAIN (via Caddy reverse proxy)
# Backend: internal on backend:8080 (health exposed at /health through Caddy)
name: multica
@@ -35,12 +35,13 @@ services:
postgres:
condition: service_healthy
ports:
- "${PORT:-8080}:8080"
- "127.0.0.1:${PORT:-8080}:8080"
environment:
DATABASE_URL: postgres://${POSTGRES_USER:-multica}:${POSTGRES_PASSWORD:-multica}@postgres:5432/${POSTGRES_DB:-multica}?sslmode=disable
APP_ENV: ${APP_ENV:-}
PORT: "8080"
JWT_SECRET: ${JWT_SECRET:-change-me-in-production}
FRONTEND_ORIGIN: ${FRONTEND_ORIGIN:-http://localhost:3000}
FRONTEND_ORIGIN: ${FRONTEND_ORIGIN:-https://${TASK_DOMAIN:-localhost}}
CORS_ALLOWED_ORIGINS: ${CORS_ALLOWED_ORIGINS:-}
RESEND_API_KEY: ${RESEND_API_KEY:-}
RESEND_FROM_EMAIL: ${RESEND_FROM_EMAIL:-noreply@multica.ai}
@@ -60,14 +61,35 @@ services:
context: .
dockerfile: Dockerfile.web
args:
REMOTE_API_URL: http://backend:8080
REMOTE_API_URL: ${REMOTE_API_URL:-http://backend:8080}
NEXT_PUBLIC_GOOGLE_CLIENT_ID: ${NEXT_PUBLIC_GOOGLE_CLIENT_ID:-}
NEXT_PUBLIC_API_URL: ${NEXT_PUBLIC_API_URL:-}
NEXT_PUBLIC_WS_URL: ${NEXT_PUBLIC_WS_URL:-}
depends_on:
- backend
ports:
- "${FRONTEND_PORT:-3000}:3000"
- "127.0.0.1:${FRONTEND_PORT:-3000}:3000"
environment:
HOSTNAME: "0.0.0.0"
NEXT_PUBLIC_API_URL: ${NEXT_PUBLIC_API_URL:-}
NEXT_PUBLIC_WS_URL: ${NEXT_PUBLIC_WS_URL:-}
caddy:
image: caddy:2-alpine
depends_on:
- frontend
- backend
ports:
- "80:80"
- "443:443"
environment:
TASK_DOMAIN: ${TASK_DOMAIN:-localhost}
volumes:
- ./Caddyfile:/etc/caddy/Caddyfile:ro
- caddy_data:/data
- caddy_config:/config
volumes:
pgdata:
caddy_data:
caddy_config:

View File

@@ -69,10 +69,12 @@
| 角色 | 字体 | 用途 |
|------|------|------|
| 正文/UI | Geist Sans (`--font-sans`) | 所有界面文字的默认字体 |
| 正文/UI | Inter (`--font-sans`) | 所有界面文字的默认字体CJK 字符自动 fallback 到系统字体PingFang SC / Microsoft YaHei / Noto Sans CJK SC |
| 代码/数据 | Geist Mono (`--font-mono`) | 代码块、ID、时间戳、等宽数据 |
| 标题 | `--font-heading`= `--font-sans` | 页面标题、区块标题 |
字体栈在 `apps/web/app/layout.tsx``apps/desktop/src/renderer/src/globals.css` 两处声明,修改时需同步。
### 3.2 字号纪律
**整个项目只使用 3 个核心字号 + 1 个特殊字号:**
@@ -98,7 +100,7 @@
| `font-normal` (400) | 正文、描述、大部分文字 |
| `font-medium` (500) | 标签、按钮、导航项、标题、选中状态 |
**禁止** `font-bold` / `font-semibold`——它们在 Geist 字体下显得突兀,破坏界面的"轻"感。如果需要更强的强调,用更大的字号或 `foreground` 色值,而不是加粗。
**禁止** `font-bold` / `font-semibold`——任务管理工具追求信息密度和"轻"感,加粗会破坏层次节奏。如果需要更强的强调,用更大的字号或 `foreground` 色值,而不是加粗。
---

13
e2e/env.ts Normal file
View File

@@ -0,0 +1,13 @@
import { existsSync } from "fs";
import { resolve } from "path";
import { config } from "dotenv";
const envCandidates = [".env.worktree", ".env"];
for (const filename of envCandidates) {
const path = resolve(process.cwd(), filename);
if (existsSync(path)) {
config({ path });
break;
}
}

View File

@@ -4,6 +4,7 @@
* Uses raw fetch so E2E tests have zero build-time coupling to the web app.
*/
import "./env";
import pg from "pg";
const API_BASE = process.env.NEXT_PUBLIC_API_URL ?? `http://localhost:${process.env.PORT ?? "8080"}`;
@@ -21,39 +22,43 @@ export class TestApiClient {
private createdIssueIds: string[] = [];
async login(email: string, name: string) {
// Step 1: Send verification code
const sendRes = await fetch(`${API_BASE}/auth/send-code`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ email }),
});
if (!sendRes.ok) {
// Rate limited — code already sent recently, read it from DB
if (sendRes.status !== 429) {
throw new Error(`send-code failed: ${sendRes.status}`);
}
}
// Step 2: Read code from database
const client = new pg.Client(DATABASE_URL);
await client.connect();
try {
// Keep each E2E login isolated so previous test runs do not trip the
// per-email send-code rate limit.
await client.query("DELETE FROM verification_code WHERE email = $1", [email]);
// Step 1: Send verification code
const sendRes = await fetch(`${API_BASE}/auth/send-code`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ email }),
});
if (!sendRes.ok) {
throw new Error(`send-code failed: ${sendRes.status}`);
}
// Step 2: Read code from database
const result = await client.query(
"SELECT code FROM verification_code WHERE email = $1 AND used = FALSE AND expires_at > now() ORDER BY created_at DESC LIMIT 1",
[email]
[email],
);
if (result.rows.length === 0) {
throw new Error(`No verification code found for ${email}`);
}
const code = result.rows[0].code;
// Step 3: Verify code to get JWT
const verifyRes = await fetch(`${API_BASE}/auth/verify-code`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ email, code }),
body: JSON.stringify({ email, code: result.rows[0].code }),
});
if (!verifyRes.ok) {
throw new Error(`verify-code failed: ${verifyRes.status}`);
}
const data = await verifyRes.json();
this.token = data.token;
// Update user name if needed
@@ -64,6 +69,8 @@ export class TestApiClient {
});
}
await client.query("DELETE FROM verification_code WHERE email = $1", [email]);
return data;
} finally {
await client.end();

View File

@@ -11,11 +11,14 @@ test.describe("Issues", () => {
});
test.afterEach(async () => {
await api.cleanup();
if (api) {
await api.cleanup();
}
});
test("issues page loads with board view", async ({ page }) => {
await expect(page.locator("text=All Issues")).toBeVisible();
await api.createIssue("E2E Board View " + Date.now());
await page.reload();
// Board columns should be visible
await expect(page.locator("text=Backlog")).toBeVisible();
@@ -23,29 +26,36 @@ test.describe("Issues", () => {
await expect(page.locator("text=In Progress")).toBeVisible();
});
test("can switch between board and list view", async ({ page }) => {
await expect(page.locator("text=All Issues")).toBeVisible();
test("can switch from board to list view", async ({ page }) => {
const title = "E2E List Switch " + Date.now();
await api.createIssue(title);
await page.reload();
await expect(page.locator("text=Backlog")).toBeVisible();
// Switch to list view
await page.click("text=List");
await expect(page.locator("text=All Issues")).toBeVisible();
// Switch back to board view
await page.click("text=Board");
await expect(page.locator("text=Backlog")).toBeVisible();
await expect(page.getByText(title)).toBeVisible();
});
test("can create a new issue", async ({ page }) => {
await page.click("text=New Issue");
const newIssueButton = page.getByRole("button", { name: "New Issue" });
await expect(newIssueButton).toBeVisible();
await newIssueButton.click();
const title = "E2E Created " + Date.now();
await page.fill('input[placeholder="Issue title..."]', title);
await page.click("text=Create");
const titleInput = page.getByRole("textbox", { name: "Issue title" });
await expect(titleInput).toBeVisible();
await titleInput.fill(title);
await page.getByRole("button", { name: "Create Issue" }).click();
// New issue should appear on the page
await expect(page.locator(`text=${title}`).first()).toBeVisible({
timeout: 10000,
});
await expect(page.getByText("Issue created")).toBeVisible({ timeout: 10000 });
await expect(
page.getByRole("region", { name: /Notifications/ }).getByText(title),
).toBeVisible();
await page.getByRole("button", { name: "View issue" }).click();
await page.waitForURL(/\/issues\/[\w-]+/);
await expect(page.locator("text=Properties")).toBeVisible();
});
test("can navigate to issue detail page", async ({ page }) => {
@@ -54,7 +64,6 @@ test.describe("Issues", () => {
// Reload to see the new issue
await page.reload();
await expect(page.locator("text=All Issues")).toBeVisible();
// Navigate to the issue detail
const issueLink = page.locator(`a[href="/issues/${issue.id}"]`);
@@ -71,18 +80,15 @@ test.describe("Issues", () => {
).toBeVisible();
});
test("can cancel issue creation", async ({ page }) => {
await page.click("text=New Issue");
test("can dismiss issue creation", async ({ page }) => {
await page.getByRole("button", { name: "New Issue" }).click();
await expect(
page.locator('input[placeholder="Issue title..."]'),
).toBeVisible();
const titleInput = page.getByRole("textbox", { name: "Issue title" });
await expect(titleInput).toBeVisible();
await page.click("text=Cancel");
await page.keyboard.press("Escape");
await expect(
page.locator('input[placeholder="Issue title..."]'),
).not.toBeVisible();
await expect(page.locator("text=New Issue")).toBeVisible();
await expect(titleInput).not.toBeVisible();
await expect(page.getByRole("button", { name: "New Issue" })).toBeVisible();
});
});

View File

@@ -0,0 +1,109 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { ApiClient, ApiError } from "./client";
afterEach(() => {
vi.unstubAllGlobals();
});
describe("ApiClient", () => {
it("preserves HTTP status on failed requests", async () => {
vi.stubGlobal(
"fetch",
vi.fn().mockResolvedValue(
new Response(JSON.stringify({ error: "workspace slug already exists" }), {
status: 409,
statusText: "Conflict",
headers: { "Content-Type": "application/json" },
}),
),
);
const client = new ApiClient("https://api.example.test");
try {
await client.createWorkspace({ name: "Test", slug: "test" });
throw new Error("expected createWorkspace to fail");
} catch (error) {
expect(error).toBeInstanceOf(ApiError);
expect(error).toMatchObject({
message: "workspace slug already exists",
status: 409,
statusText: "Conflict",
});
}
});
it("uses the expected HTTP contract for autopilot endpoints", async () => {
const fetchMock = vi.fn().mockImplementation(() => Promise.resolve(
new Response(JSON.stringify({ autopilots: [], runs: [], total: 0 }), {
status: 200,
headers: { "Content-Type": "application/json" },
}),
));
vi.stubGlobal("fetch", fetchMock);
const client = new ApiClient("https://api.example.test");
await client.listAutopilots({ status: "active" });
await client.getAutopilot("ap-1");
await client.createAutopilot({
title: "Daily triage",
assignee_id: "agent-1",
execution_mode: "create_issue",
});
await client.updateAutopilot("ap-1", { status: "paused" });
await client.deleteAutopilot("ap-1");
await client.triggerAutopilot("ap-1");
await client.listAutopilotRuns("ap-1", { limit: 10, offset: 20 });
await client.createAutopilotTrigger("ap-1", {
kind: "schedule",
cron_expression: "0 9 * * *",
timezone: "UTC",
});
await client.updateAutopilotTrigger("ap-1", "tr-1", { enabled: false });
await client.deleteAutopilotTrigger("ap-1", "tr-1");
const calls = fetchMock.mock.calls.map(([url, init]) => ({
url,
method: init?.method ?? "GET",
body: init?.body,
}));
expect(calls).toMatchObject([
{ url: "https://api.example.test/api/autopilots?status=active", method: "GET" },
{ url: "https://api.example.test/api/autopilots/ap-1", method: "GET" },
{
url: "https://api.example.test/api/autopilots",
method: "POST",
body: JSON.stringify({
title: "Daily triage",
assignee_id: "agent-1",
execution_mode: "create_issue",
}),
},
{
url: "https://api.example.test/api/autopilots/ap-1",
method: "PATCH",
body: JSON.stringify({ status: "paused" }),
},
{ url: "https://api.example.test/api/autopilots/ap-1", method: "DELETE" },
{ url: "https://api.example.test/api/autopilots/ap-1/trigger", method: "POST" },
{ url: "https://api.example.test/api/autopilots/ap-1/runs?limit=10&offset=20", method: "GET" },
{
url: "https://api.example.test/api/autopilots/ap-1/triggers",
method: "POST",
body: JSON.stringify({
kind: "schedule",
cron_expression: "0 9 * * *",
timezone: "UTC",
}),
},
{
url: "https://api.example.test/api/autopilots/ap-1/triggers/tr-1",
method: "PATCH",
body: JSON.stringify({ enabled: false }),
},
{ url: "https://api.example.test/api/autopilots/ap-1/triggers/tr-1", method: "DELETE" },
]);
});
});

View File

@@ -41,6 +41,8 @@ import type {
Attachment,
ChatSession,
ChatMessage,
ChatPendingTask,
PendingChatTasksResponse,
SendChatMessageResponse,
Project,
CreateProjectRequest,
@@ -50,8 +52,20 @@ import type {
CreatePinRequest,
PinnedItemType,
ReorderPinsRequest,
Invitation,
Autopilot,
AutopilotTrigger,
AutopilotRun,
CreateAutopilotRequest,
UpdateAutopilotRequest,
CreateAutopilotTriggerRequest,
UpdateAutopilotTriggerRequest,
ListAutopilotsResponse,
GetAutopilotResponse,
ListAutopilotRunsResponse,
} from "../types";
import { type Logger, noopLogger } from "../logger";
import { createRequestId } from "../utils";
export interface ApiClientOptions {
logger?: Logger;
@@ -63,6 +77,18 @@ export interface LoginResponse {
user: User;
}
export class ApiError extends Error {
readonly status: number;
readonly statusText: string;
constructor(message: string, status: number, statusText: string) {
super(message);
this.name = "ApiError";
this.status = status;
this.statusText = statusText;
}
}
export class ApiClient {
private baseUrl: string;
private token: string | null = null;
@@ -76,6 +102,10 @@ export class ApiClient {
this.logger = options?.logger ?? noopLogger;
}
getBaseUrl(): string {
return this.baseUrl;
}
setToken(token: string | null) {
this.token = token;
}
@@ -84,10 +114,20 @@ export class ApiClient {
this.workspaceId = id;
}
private readCsrfToken(): string | null {
if (typeof document === "undefined") return null;
const match = document.cookie
.split("; ")
.find((c) => c.startsWith("multica_csrf="));
return match ? match.split("=")[1] ?? null : null;
}
private authHeaders(): Record<string, string> {
const headers: Record<string, string> = {};
if (this.token) headers["Authorization"] = `Bearer ${this.token}`;
if (this.workspaceId) headers["X-Workspace-ID"] = this.workspaceId;
const csrf = this.readCsrfToken();
if (csrf) headers["X-CSRF-Token"] = csrf;
return headers;
}
@@ -108,7 +148,7 @@ export class ApiClient {
}
private async fetch<T>(path: string, init?: RequestInit): Promise<T> {
const rid = crypto.randomUUID().slice(0, 8);
const rid = createRequestId();
const start = Date.now();
const method = init?.method ?? "GET";
@@ -132,7 +172,7 @@ export class ApiClient {
const message = await this.parseErrorMessage(res, `API error: ${res.status} ${res.statusText}`);
const logLevel = res.status === 404 ? "warn" : "error";
this.logger[logLevel](`${res.status} ${path}`, { rid, duration: `${Date.now() - start}ms`, error: message });
throw new Error(message);
throw new ApiError(message, res.status, res.statusText);
}
this.logger.info(`${res.status} ${path}`, { rid, duration: `${Date.now() - start}ms` });
@@ -167,6 +207,14 @@ export class ApiClient {
});
}
async logout(): Promise<void> {
await this.fetch("/auth/logout", { method: "POST" });
}
async issueCliToken(): Promise<{ token: string }> {
return this.fetch("/api/cli-token", { method: "POST" });
}
async getMe(): Promise<User> {
return this.fetch("/api/me");
}
@@ -234,6 +282,10 @@ export class ApiClient {
return this.fetch(`/api/issues/${id}/children`);
}
async getChildIssueProgress(): Promise<{ progress: { parent_issue_id: string; total: number; done: number }[] }> {
return this.fetch("/api/issues/child-progress");
}
async deleteIssue(id: string): Promise<void> {
await this.fetch(`/api/issues/${id}`, { method: "DELETE" });
}
@@ -432,7 +484,7 @@ export class ApiClient {
}
async listTaskMessages(taskId: string): Promise<TaskMessagePayload[]> {
return this.fetch(`/api/daemon/tasks/${taskId}/messages`);
return this.fetch(`/api/tasks/${taskId}/messages`);
}
async listTasksByIssue(issueId: string): Promise<AgentTask[]> {
@@ -482,6 +534,11 @@ export class ApiClient {
return this.fetch("/api/inbox/archive-completed", { method: "POST" });
}
// App Config
async getConfig(): Promise<{ cdn_domain: string }> {
return this.fetch("/api/config");
}
// Workspaces
async listWorkspaces(): Promise<Workspace[]> {
return this.fetch("/api/workspaces");
@@ -510,7 +567,7 @@ export class ApiClient {
return this.fetch(`/api/workspaces/${workspaceId}/members`);
}
async createMember(workspaceId: string, data: CreateMemberRequest): Promise<MemberWithUser> {
async createMember(workspaceId: string, data: CreateMemberRequest): Promise<Invitation> {
return this.fetch(`/api/workspaces/${workspaceId}/members`, {
method: "POST",
body: JSON.stringify(data),
@@ -536,6 +593,37 @@ export class ApiClient {
});
}
// Invitations
async listWorkspaceInvitations(workspaceId: string): Promise<Invitation[]> {
return this.fetch(`/api/workspaces/${workspaceId}/invitations`);
}
async revokeInvitation(workspaceId: string, invitationId: string): Promise<void> {
await this.fetch(`/api/workspaces/${workspaceId}/invitations/${invitationId}`, {
method: "DELETE",
});
}
async listMyInvitations(): Promise<Invitation[]> {
return this.fetch("/api/invitations");
}
async getInvitation(invitationId: string): Promise<Invitation> {
return this.fetch(`/api/invitations/${invitationId}`);
}
async acceptInvitation(invitationId: string): Promise<MemberWithUser> {
return this.fetch(`/api/invitations/${invitationId}/accept`, {
method: "POST",
});
}
async declineInvitation(invitationId: string): Promise<void> {
await this.fetch(`/api/invitations/${invitationId}/decline`, {
method: "POST",
});
}
async deleteWorkspace(workspaceId: string): Promise<void> {
await this.fetch(`/api/workspaces/${workspaceId}`, {
method: "DELETE",
@@ -610,7 +698,7 @@ export class ApiClient {
if (opts?.issueId) formData.append("issue_id", opts.issueId);
if (opts?.commentId) formData.append("comment_id", opts.commentId);
const rid = crypto.randomUUID().slice(0, 8);
const rid = createRequestId();
const start = Date.now();
this.logger.info("→ POST /api/upload-file", { rid });
@@ -664,6 +752,18 @@ export class ApiClient {
});
}
async getPendingChatTask(sessionId: string): Promise<ChatPendingTask> {
return this.fetch(`/api/chat/sessions/${sessionId}/pending-task`);
}
async listPendingChatTasks(): Promise<PendingChatTasksResponse> {
return this.fetch(`/api/chat/pending-tasks`);
}
async markChatSessionRead(sessionId: string): Promise<void> {
await this.fetch(`/api/chat/sessions/${sessionId}/read`, { method: "POST" });
}
async cancelTaskById(taskId: string): Promise<void> {
await this.fetch(`/api/tasks/${taskId}/cancel`, { method: "POST" });
}
@@ -729,4 +829,62 @@ export class ApiClient {
body: JSON.stringify(data),
});
}
// Autopilots
async listAutopilots(params?: { status?: string }): Promise<ListAutopilotsResponse> {
const search = new URLSearchParams();
if (params?.status) search.set("status", params.status);
return this.fetch(`/api/autopilots?${search}`);
}
async getAutopilot(id: string): Promise<GetAutopilotResponse> {
return this.fetch(`/api/autopilots/${id}`);
}
async createAutopilot(data: CreateAutopilotRequest): Promise<Autopilot> {
return this.fetch("/api/autopilots", {
method: "POST",
body: JSON.stringify(data),
});
}
async updateAutopilot(id: string, data: UpdateAutopilotRequest): Promise<Autopilot> {
return this.fetch(`/api/autopilots/${id}`, {
method: "PATCH",
body: JSON.stringify(data),
});
}
async deleteAutopilot(id: string): Promise<void> {
await this.fetch(`/api/autopilots/${id}`, { method: "DELETE" });
}
async triggerAutopilot(id: string): Promise<AutopilotRun> {
return this.fetch(`/api/autopilots/${id}/trigger`, { method: "POST" });
}
async listAutopilotRuns(id: string, params?: { limit?: number; offset?: number }): Promise<ListAutopilotRunsResponse> {
const search = new URLSearchParams();
if (params?.limit) search.set("limit", params.limit.toString());
if (params?.offset) search.set("offset", params.offset.toString());
return this.fetch(`/api/autopilots/${id}/runs?${search}`);
}
async createAutopilotTrigger(autopilotId: string, data: CreateAutopilotTriggerRequest): Promise<AutopilotTrigger> {
return this.fetch(`/api/autopilots/${autopilotId}/triggers`, {
method: "POST",
body: JSON.stringify(data),
});
}
async updateAutopilotTrigger(autopilotId: string, triggerId: string, data: UpdateAutopilotTriggerRequest): Promise<AutopilotTrigger> {
return this.fetch(`/api/autopilots/${autopilotId}/triggers/${triggerId}`, {
method: "PATCH",
body: JSON.stringify(data),
});
}
async deleteAutopilotTrigger(autopilotId: string, triggerId: string): Promise<void> {
await this.fetch(`/api/autopilots/${autopilotId}/triggers/${triggerId}`, { method: "DELETE" });
}
}

View File

@@ -1,4 +1,4 @@
export { ApiClient } from "./client";
export { ApiClient, ApiError } from "./client";
export type { ApiClientOptions } from "./client";
export { WSClient } from "./ws-client";

View File

@@ -8,6 +8,7 @@ export class WSClient {
private baseUrl: string;
private token: string | null = null;
private workspaceId: string | null = null;
private cookieAuth = false;
private handlers = new Map<WSEventType, Set<EventHandler>>();
private reconnectTimer: ReturnType<typeof setTimeout> | null = null;
private hasConnectedBefore = false;
@@ -15,40 +16,45 @@ export class WSClient {
private anyHandlers = new Set<(msg: WSMessage) => void>();
private logger: Logger;
constructor(url: string, options?: { logger?: Logger }) {
constructor(url: string, options?: { logger?: Logger; cookieAuth?: boolean }) {
this.baseUrl = url;
this.logger = options?.logger ?? noopLogger;
this.cookieAuth = options?.cookieAuth ?? false;
}
setAuth(token: string, workspaceId: string) {
setAuth(token: string | null, workspaceId: string) {
this.token = token;
this.workspaceId = workspaceId;
}
connect() {
const url = new URL(this.baseUrl);
if (this.token) url.searchParams.set("token", this.token);
// Token is never sent as a URL query parameter — it would be logged by
// proxies, CDNs, and browser history. In cookie mode the HttpOnly cookie
// is sent automatically with the upgrade request. In token mode the token
// is delivered as the first WebSocket message after the connection opens.
if (this.workspaceId)
url.searchParams.set("workspace_id", this.workspaceId);
this.ws = new WebSocket(url.toString());
this.ws.onopen = () => {
this.logger.info("connected");
if (this.hasConnectedBefore) {
for (const cb of this.onReconnectCallbacks) {
try {
cb();
} catch {
// ignore reconnect callback errors
}
}
if (!this.cookieAuth && this.token) {
this.ws!.send(
JSON.stringify({ type: "auth", payload: { token: this.token } }),
);
return;
}
this.hasConnectedBefore = true;
this.onAuthenticated();
};
this.ws.onmessage = (event) => {
const msg = JSON.parse(event.data as string) as WSMessage;
if ((msg as any).type === "auth_ack") {
this.onAuthenticated();
return;
}
this.logger.debug("received", msg.type);
const eventHandlers = this.handlers.get(msg.type);
if (eventHandlers) {
@@ -72,6 +78,20 @@ export class WSClient {
};
}
private onAuthenticated() {
this.logger.info("connected");
if (this.hasConnectedBefore) {
for (const cb of this.onReconnectCallbacks) {
try {
cb();
} catch {
// ignore reconnect callback errors
}
}
}
this.hasConnectedBefore = true;
}
disconnect() {
if (this.reconnectTimer) {
clearTimeout(this.reconnectTimer);

View File

@@ -7,6 +7,8 @@ export interface AuthStoreOptions {
storage: StorageAdapter;
onLogin?: () => void;
onLogout?: () => void;
/** When true, rely on HttpOnly cookies instead of localStorage for auth tokens. */
cookieAuth?: boolean;
}
export interface AuthState {
@@ -17,18 +19,32 @@ export interface AuthState {
sendCode: (email: string) => Promise<void>;
verifyCode: (email: string, code: string) => Promise<User>;
loginWithGoogle: (code: string, redirectUri: string) => Promise<User>;
loginWithToken: (token: string) => Promise<User>;
logout: () => void;
setUser: (user: User) => void;
}
export function createAuthStore(options: AuthStoreOptions) {
const { api, storage, onLogin, onLogout } = options;
const { api, storage, onLogin, onLogout, cookieAuth } = options;
return create<AuthState>((set) => ({
user: null,
isLoading: true,
initialize: async () => {
if (cookieAuth) {
// In cookie mode, the HttpOnly cookie is sent automatically.
// Try to fetch the current user — if the cookie exists the server will accept it.
try {
const user = await api.getMe();
set({ user, isLoading: false });
} catch {
set({ user: null, isLoading: false });
}
return;
}
// Token mode: read from localStorage (Electron / legacy).
const token = storage.getItem("multica_token");
if (!token) {
set({ isLoading: false });
@@ -54,8 +70,11 @@ export function createAuthStore(options: AuthStoreOptions) {
verifyCode: async (email: string, code: string) => {
const { token, user } = await api.verifyCode(email, code);
storage.setItem("multica_token", token);
api.setToken(token);
if (!cookieAuth) {
// Token mode: persist for Electron / legacy.
storage.setItem("multica_token", token);
api.setToken(token);
}
onLogin?.();
set({ user });
return user;
@@ -63,16 +82,30 @@ export function createAuthStore(options: AuthStoreOptions) {
loginWithGoogle: async (code: string, redirectUri: string) => {
const { token, user } = await api.googleLogin(code, redirectUri);
storage.setItem("multica_token", token);
api.setToken(token);
if (!cookieAuth) {
storage.setItem("multica_token", token);
api.setToken(token);
}
onLogin?.();
set({ user });
return user;
},
loginWithToken: async (token: string) => {
storage.setItem("multica_token", token);
api.setToken(token);
const user = await api.getMe();
onLogin?.();
set({ user, isLoading: false });
return user;
},
logout: () => {
if (cookieAuth) {
// Clear server-side HttpOnly cookie.
api.logout().catch(() => {});
}
storage.removeItem("multica_token");
storage.removeItem("multica_workspace_id");
api.setToken(null);
api.setWorkspaceId(null);
onLogout?.();

View File

@@ -0,0 +1,10 @@
export { autopilotKeys, autopilotListOptions, autopilotDetailOptions, autopilotRunsOptions } from "./queries";
export {
useCreateAutopilot,
useUpdateAutopilot,
useDeleteAutopilot,
useTriggerAutopilot,
useCreateAutopilotTrigger,
useUpdateAutopilotTrigger,
useDeleteAutopilotTrigger,
} from "./mutations";

View File

@@ -0,0 +1,130 @@
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { api } from "../api";
import { autopilotKeys } from "./queries";
import { useWorkspaceId } from "../hooks";
import type {
CreateAutopilotRequest,
UpdateAutopilotRequest,
ListAutopilotsResponse,
GetAutopilotResponse,
CreateAutopilotTriggerRequest,
UpdateAutopilotTriggerRequest,
} from "../types";
export function useCreateAutopilot() {
const qc = useQueryClient();
const wsId = useWorkspaceId();
return useMutation({
mutationFn: (data: CreateAutopilotRequest) => api.createAutopilot(data),
onSuccess: (newAutopilot) => {
qc.setQueryData<ListAutopilotsResponse>(autopilotKeys.list(wsId), (old) =>
old && !old.autopilots.some((a) => a.id === newAutopilot.id)
? { ...old, autopilots: [...old.autopilots, newAutopilot], total: old.total + 1 }
: old,
);
},
onSettled: () => {
qc.invalidateQueries({ queryKey: autopilotKeys.list(wsId) });
},
});
}
export function useUpdateAutopilot() {
const qc = useQueryClient();
const wsId = useWorkspaceId();
return useMutation({
mutationFn: ({ id, ...data }: { id: string } & UpdateAutopilotRequest) =>
api.updateAutopilot(id, data),
onMutate: ({ id, ...data }) => {
qc.cancelQueries({ queryKey: autopilotKeys.list(wsId) });
const prevList = qc.getQueryData<ListAutopilotsResponse>(autopilotKeys.list(wsId));
const prevDetail = qc.getQueryData<GetAutopilotResponse>(autopilotKeys.detail(wsId, id));
qc.setQueryData<ListAutopilotsResponse>(autopilotKeys.list(wsId), (old) =>
old ? { ...old, autopilots: old.autopilots.map((a) => (a.id === id ? { ...a, ...data } : a)) } : old,
);
qc.setQueryData<GetAutopilotResponse>(autopilotKeys.detail(wsId, id), (old) =>
old ? { ...old, autopilot: { ...old.autopilot, ...data } } : old,
);
return { prevList, prevDetail, id };
},
onError: (_err, _vars, ctx) => {
if (ctx?.prevList) qc.setQueryData(autopilotKeys.list(wsId), ctx.prevList);
if (ctx?.prevDetail) qc.setQueryData(autopilotKeys.detail(wsId, ctx.id), ctx.prevDetail);
},
onSettled: (_data, _err, vars) => {
qc.invalidateQueries({ queryKey: autopilotKeys.detail(wsId, vars.id) });
qc.invalidateQueries({ queryKey: autopilotKeys.list(wsId) });
},
});
}
export function useDeleteAutopilot() {
const qc = useQueryClient();
const wsId = useWorkspaceId();
return useMutation({
mutationFn: (id: string) => api.deleteAutopilot(id),
onMutate: async (id) => {
await qc.cancelQueries({ queryKey: autopilotKeys.list(wsId) });
const prevList = qc.getQueryData<ListAutopilotsResponse>(autopilotKeys.list(wsId));
qc.setQueryData<ListAutopilotsResponse>(autopilotKeys.list(wsId), (old) =>
old ? { ...old, autopilots: old.autopilots.filter((a) => a.id !== id), total: old.total - 1 } : old,
);
qc.removeQueries({ queryKey: autopilotKeys.detail(wsId, id) });
return { prevList };
},
onError: (_err, _id, ctx) => {
if (ctx?.prevList) qc.setQueryData(autopilotKeys.list(wsId), ctx.prevList);
},
onSettled: () => {
qc.invalidateQueries({ queryKey: autopilotKeys.list(wsId) });
},
});
}
export function useTriggerAutopilot() {
const qc = useQueryClient();
const wsId = useWorkspaceId();
return useMutation({
mutationFn: (id: string) => api.triggerAutopilot(id),
onSettled: (_data, _err, id) => {
qc.invalidateQueries({ queryKey: autopilotKeys.runs(wsId, id) });
qc.invalidateQueries({ queryKey: autopilotKeys.detail(wsId, id) });
},
});
}
export function useCreateAutopilotTrigger() {
const qc = useQueryClient();
const wsId = useWorkspaceId();
return useMutation({
mutationFn: ({ autopilotId, ...data }: { autopilotId: string } & CreateAutopilotTriggerRequest) =>
api.createAutopilotTrigger(autopilotId, data),
onSettled: (_data, _err, vars) => {
qc.invalidateQueries({ queryKey: autopilotKeys.detail(wsId, vars.autopilotId) });
},
});
}
export function useUpdateAutopilotTrigger() {
const qc = useQueryClient();
const wsId = useWorkspaceId();
return useMutation({
mutationFn: ({ autopilotId, triggerId, ...data }: { autopilotId: string; triggerId: string } & UpdateAutopilotTriggerRequest) =>
api.updateAutopilotTrigger(autopilotId, triggerId, data),
onSettled: (_data, _err, vars) => {
qc.invalidateQueries({ queryKey: autopilotKeys.detail(wsId, vars.autopilotId) });
},
});
}
export function useDeleteAutopilotTrigger() {
const qc = useQueryClient();
const wsId = useWorkspaceId();
return useMutation({
mutationFn: ({ autopilotId, triggerId }: { autopilotId: string; triggerId: string }) =>
api.deleteAutopilotTrigger(autopilotId, triggerId),
onSettled: (_data, _err, vars) => {
qc.invalidateQueries({ queryKey: autopilotKeys.detail(wsId, vars.autopilotId) });
},
});
}

View File

@@ -0,0 +1,34 @@
import { queryOptions } from "@tanstack/react-query";
import { api } from "../api";
export const autopilotKeys = {
all: (wsId: string) => ["autopilots", wsId] as const,
list: (wsId: string) => [...autopilotKeys.all(wsId), "list"] as const,
detail: (wsId: string, id: string) =>
[...autopilotKeys.all(wsId), "detail", id] as const,
runs: (wsId: string, id: string) =>
[...autopilotKeys.all(wsId), "runs", id] as const,
};
export function autopilotListOptions(wsId: string) {
return queryOptions({
queryKey: autopilotKeys.list(wsId),
queryFn: () => api.listAutopilots(),
select: (data) => data.autopilots,
});
}
export function autopilotDetailOptions(wsId: string, id: string) {
return queryOptions({
queryKey: autopilotKeys.detail(wsId, id),
queryFn: () => api.getAutopilot(id),
});
}
export function autopilotRunsOptions(wsId: string, id: string) {
return queryOptions({
queryKey: autopilotKeys.runs(wsId, id),
queryFn: () => api.listAutopilotRuns(id),
select: (data) => data.runs,
});
}

View File

@@ -1,4 +1,4 @@
export { createChatStore } from "./store";
export { createChatStore, CHAT_MIN_W, CHAT_MIN_H, CHAT_DEFAULT_W, CHAT_DEFAULT_H, DRAFT_NEW_SESSION } from "./store";
export type { ChatStoreOptions, ChatState, ChatTimelineItem } from "./store";
import type { createChatStore as CreateChatStoreFn } from "./store";

View File

@@ -2,14 +2,67 @@ import { useMutation, useQueryClient } from "@tanstack/react-query";
import { api } from "../api";
import { useWorkspaceId } from "../hooks";
import { chatKeys } from "./queries";
import { createLogger } from "../logger";
import type { ChatSession } from "../types";
const logger = createLogger("chat.mut");
export function useCreateChatSession() {
const qc = useQueryClient();
const wsId = useWorkspaceId();
return useMutation({
mutationFn: (data: { agent_id: string; title?: string }) =>
api.createChatSession(data),
mutationFn: (data: { agent_id: string; title?: string }) => {
logger.info("createChatSession.start", { agent_id: data.agent_id, titleLength: data.title?.length ?? 0 });
return api.createChatSession(data);
},
onSuccess: (session) => {
logger.info("createChatSession.success", { sessionId: session.id, agentId: session.agent_id });
},
onError: (err) => {
logger.error("createChatSession.error", err);
},
onSettled: () => {
qc.invalidateQueries({ queryKey: chatKeys.sessions(wsId) });
qc.invalidateQueries({ queryKey: chatKeys.allSessions(wsId) });
},
});
}
/**
* Clears the session's unread state server-side. Optimistically flips
* has_unread to false in the cached lists so the FAB badge drops
* immediately. The server broadcasts chat:session_read so other devices
* also sync.
*/
export function useMarkChatSessionRead() {
const qc = useQueryClient();
const wsId = useWorkspaceId();
return useMutation({
mutationFn: (sessionId: string) => {
logger.info("markChatSessionRead.start", { sessionId });
return api.markChatSessionRead(sessionId);
},
onMutate: async (sessionId) => {
await qc.cancelQueries({ queryKey: chatKeys.sessions(wsId) });
await qc.cancelQueries({ queryKey: chatKeys.allSessions(wsId) });
const prevSessions = qc.getQueryData<ChatSession[]>(chatKeys.sessions(wsId));
const prevAll = qc.getQueryData<ChatSession[]>(chatKeys.allSessions(wsId));
const clear = (old?: ChatSession[]) =>
old?.map((s) => (s.id === sessionId ? { ...s, has_unread: false } : s));
qc.setQueryData<ChatSession[]>(chatKeys.sessions(wsId), clear);
qc.setQueryData<ChatSession[]>(chatKeys.allSessions(wsId), clear);
return { prevSessions, prevAll };
},
onError: (err, sessionId, ctx) => {
logger.error("markChatSessionRead.error.rollback", { sessionId, err });
if (ctx?.prevSessions) qc.setQueryData(chatKeys.sessions(wsId), ctx.prevSessions);
if (ctx?.prevAll) qc.setQueryData(chatKeys.allSessions(wsId), ctx.prevAll);
},
onSettled: () => {
qc.invalidateQueries({ queryKey: chatKeys.sessions(wsId) });
qc.invalidateQueries({ queryKey: chatKeys.allSessions(wsId) });
@@ -22,8 +75,37 @@ export function useArchiveChatSession() {
const wsId = useWorkspaceId();
return useMutation({
mutationFn: (sessionId: string) => api.archiveChatSession(sessionId),
onSettled: () => {
mutationFn: (sessionId: string) => {
logger.info("archiveChatSession.start", { sessionId });
return api.archiveChatSession(sessionId);
},
onMutate: async (sessionId) => {
await qc.cancelQueries({ queryKey: chatKeys.sessions(wsId) });
await qc.cancelQueries({ queryKey: chatKeys.allSessions(wsId) });
const prevSessions = qc.getQueryData<ChatSession[]>(chatKeys.sessions(wsId));
const prevAll = qc.getQueryData<ChatSession[]>(chatKeys.allSessions(wsId));
// Optimistic: remove from active, mark as archived in allSessions
qc.setQueryData<ChatSession[]>(chatKeys.sessions(wsId), (old) =>
old ? old.filter((s) => s.id !== sessionId) : old,
);
qc.setQueryData<ChatSession[]>(chatKeys.allSessions(wsId), (old) =>
old?.map((s) =>
s.id === sessionId ? { ...s, status: "archived" as const } : s,
),
);
logger.debug("archiveChatSession.optimistic", { sessionId });
return { prevSessions, prevAll };
},
onError: (err, sessionId, ctx) => {
logger.error("archiveChatSession.error.rollback", { sessionId, err });
if (ctx?.prevSessions) qc.setQueryData(chatKeys.sessions(wsId), ctx.prevSessions);
if (ctx?.prevAll) qc.setQueryData(chatKeys.allSessions(wsId), ctx.prevAll);
},
onSettled: (_data, _err, sessionId) => {
logger.debug("archiveChatSession.settled", { sessionId });
qc.invalidateQueries({ queryKey: chatKeys.sessions(wsId) });
qc.invalidateQueries({ queryKey: chatKeys.allSessions(wsId) });
},

View File

@@ -14,6 +14,11 @@ export const chatKeys = {
allSessions: (wsId: string) => [...chatKeys.all(wsId), "sessions", "all"] as const,
session: (wsId: string, id: string) => [...chatKeys.all(wsId), "session", id] as const,
messages: (sessionId: string) => ["chat", "messages", sessionId] as const,
pendingTask: (sessionId: string) => ["chat", "pending-task", sessionId] as const,
/** Aggregate of in-flight chat tasks for the current user — FAB reads this. */
pendingTasks: (wsId: string) => [...chatKeys.all(wsId), "pending-tasks"] as const,
/** Per-task execution messages — shared with issue agent cards. */
taskMessages: (taskId: string) => ["task-messages", taskId] as const,
};
export function chatSessionsOptions(wsId: string) {
@@ -49,3 +54,44 @@ export function chatMessagesOptions(sessionId: string) {
staleTime: Infinity,
});
}
/**
* Pending task for a chat session — the "is something still running?" signal.
* Refetched via WS invalidation in useRealtimeSync when chat:message / chat:done
* / task:completed / task:failed arrive.
*/
export function pendingChatTaskOptions(sessionId: string) {
return queryOptions({
queryKey: chatKeys.pendingTask(sessionId),
queryFn: () => api.getPendingChatTask(sessionId),
enabled: !!sessionId,
staleTime: Infinity,
});
}
/**
* Timeline for a single task — rendered by both the live chat view (while a
* task is running) and AssistantMessage (for completed tasks). WS
* `task:message` events seed this cache in real time via useRealtimeSync.
*/
export function taskMessagesOptions(taskId: string) {
return queryOptions({
queryKey: chatKeys.taskMessages(taskId),
queryFn: () => api.listTaskMessages(taskId),
enabled: !!taskId,
staleTime: Infinity,
});
}
/**
* Aggregate of in-flight chat tasks for the current user in this workspace.
* Drives the FAB "running" indicator while the chat window is minimised —
* no per-session query is active then, so we need this roll-up.
*/
export function pendingChatTasksOptions(wsId: string) {
return queryOptions({
queryKey: chatKeys.pendingTasks(wsId),
queryFn: () => api.listPendingChatTasks(),
staleTime: Infinity,
});
}

View File

@@ -1,10 +1,54 @@
import { create } from "zustand";
import type { StorageAdapter } from "../types";
import { getCurrentWorkspaceId, registerForWorkspaceRehydration } from "../platform/workspace-storage";
import { createLogger } from "../logger";
const logger = createLogger("chat.store");
const AGENT_STORAGE_KEY = "multica:chat:selectedAgentId";
const SESSION_STORAGE_KEY = "multica:chat:activeSessionId";
/** Drafts are stored as one JSON blob per workspace: { [sessionId]: text }. */
const DRAFTS_KEY = "multica:chat:drafts";
/** Placeholder sessionId for a chat that hasn't been created yet. */
export const DRAFT_NEW_SESSION = "__new__";
const CHAT_WIDTH_KEY = "multica:chat:width";
const CHAT_HEIGHT_KEY = "multica:chat:height";
const CHAT_EXPANDED_KEY = "multica:chat:expanded";
function readDrafts(storage: StorageAdapter, key: string): Record<string, string> {
const raw = storage.getItem(key);
if (!raw) return {};
try {
const parsed = JSON.parse(raw);
return typeof parsed === "object" && parsed !== null ? parsed : {};
} catch {
return {};
}
}
function writeDrafts(storage: StorageAdapter, key: string, drafts: Record<string, string>) {
// Prune empty entries so the blob doesn't grow unbounded.
const pruned: Record<string, string> = {};
for (const [k, v] of Object.entries(drafts)) {
if (v) pruned[k] = v;
}
if (Object.keys(pruned).length === 0) {
storage.removeItem(key);
} else {
storage.setItem(key, JSON.stringify(pruned));
}
}
export const CHAT_MIN_W = 360;
export const CHAT_MIN_H = 480;
export const CHAT_DEFAULT_W = 420;
export const CHAT_DEFAULT_H = 600;
/**
* Kept as a public type because existing consumers (chat-message-list,
* views/chat types) import it. Items themselves no longer live in the
* store — they flow through the React Query cache keyed by task id.
*/
export interface ChatTimelineItem {
seq: number;
type: "tool_use" | "tool_result" | "thinking" | "text" | "error";
@@ -16,21 +60,26 @@ export interface ChatTimelineItem {
export interface ChatState {
isOpen: boolean;
isFullscreen: boolean;
activeSessionId: string | null;
pendingTaskId: string | null;
selectedAgentId: string | null;
showHistory: boolean;
timelineItems: ChatTimelineItem[];
/** Drafts per session: sessionId (or DRAFT_NEW_SESSION) → markdown text. */
inputDrafts: Record<string, string>;
/** Raw user-chosen size — no clamp applied. UI layer clamps at render time. */
chatWidth: number;
chatHeight: number;
isExpanded: boolean;
setOpen: (open: boolean) => void;
toggle: () => void;
toggleFullscreen: () => void;
setActiveSession: (id: string | null) => void;
setPendingTask: (taskId: string | null) => void;
setSelectedAgentId: (id: string) => void;
setShowHistory: (show: boolean) => void;
addTimelineItem: (item: ChatTimelineItem) => void;
clearTimeline: () => void;
/** sessionId accepts a real session UUID or DRAFT_NEW_SESSION. */
setInputDraft: (sessionId: string, draft: string) => void;
clearInputDraft: (sessionId: string) => void;
/** Persist raw size and auto-exit expanded mode. */
setChatSize: (width: number, height: number) => void;
setExpanded: (expanded: boolean) => void;
}
export interface ChatStoreOptions {
@@ -45,23 +94,26 @@ export function createChatStore(options: ChatStoreOptions) {
return wsId ? `${base}:${wsId}` : base;
};
const store = create<ChatState>((set) => ({
const store = create<ChatState>((set, get) => ({
isOpen: false,
isFullscreen: false,
activeSessionId: storage.getItem(wsKey(SESSION_STORAGE_KEY)),
pendingTaskId: null,
selectedAgentId: storage.getItem(wsKey(AGENT_STORAGE_KEY)),
showHistory: false,
timelineItems: [],
setOpen: (open) =>
set({ isOpen: open, ...(open ? {} : { isFullscreen: false }) }),
toggle: () =>
set((s) => ({
isOpen: !s.isOpen,
...(s.isOpen ? { isFullscreen: false } : {}),
})),
toggleFullscreen: () => set((s) => ({ isFullscreen: !s.isFullscreen })),
inputDrafts: readDrafts(storage, wsKey(DRAFTS_KEY)),
chatWidth: Number(storage.getItem(CHAT_WIDTH_KEY)) || CHAT_DEFAULT_W,
chatHeight: Number(storage.getItem(CHAT_HEIGHT_KEY)) || CHAT_DEFAULT_H,
isExpanded: storage.getItem(wsKey(CHAT_EXPANDED_KEY)) === "true",
setOpen: (open) => {
logger.debug("setOpen", { from: get().isOpen, to: open });
set({ isOpen: open });
},
toggle: () => {
const next = !get().isOpen;
logger.debug("toggle", { to: next });
set({ isOpen: next });
},
setActiveSession: (id) => {
logger.info("setActiveSession", { from: get().activeSessionId, to: id });
if (id) {
storage.setItem(wsKey(SESSION_STORAGE_KEY), id);
} else {
@@ -69,29 +121,68 @@ export function createChatStore(options: ChatStoreOptions) {
}
set({ activeSessionId: id });
},
setPendingTask: (taskId) => set({ pendingTaskId: taskId, timelineItems: [] }),
setSelectedAgentId: (id) => {
logger.info("setSelectedAgentId", { from: get().selectedAgentId, to: id });
storage.setItem(wsKey(AGENT_STORAGE_KEY), id);
set({ selectedAgentId: id });
},
setShowHistory: (show) => set({ showHistory: show }),
addTimelineItem: (item) =>
set((s) => {
if (s.timelineItems.some((t) => t.seq === item.seq)) return s;
return {
timelineItems: [...s.timelineItems, item].sort(
(a, b) => a.seq - b.seq,
),
};
}),
clearTimeline: () => set({ timelineItems: [] }),
setShowHistory: (show) => {
logger.debug("setShowHistory", { to: show });
set({ showHistory: show });
},
setInputDraft: (sessionId, draft) => {
// Debug level — onUpdate fires on every keystroke.
logger.debug("setInputDraft", { sessionId, length: draft.length });
const next = { ...get().inputDrafts, [sessionId]: draft };
writeDrafts(storage, wsKey(DRAFTS_KEY), next);
set({ inputDrafts: next });
},
clearInputDraft: (sessionId) => {
const current = get().inputDrafts;
if (!(sessionId in current)) {
logger.debug("clearInputDraft skipped (no draft)", { sessionId });
return;
}
logger.info("clearInputDraft", { sessionId });
const next = { ...current };
delete next[sessionId];
writeDrafts(storage, wsKey(DRAFTS_KEY), next);
set({ inputDrafts: next });
},
setChatSize: (w, h) => {
logger.debug("setChatSize", { w, h });
storage.setItem(CHAT_WIDTH_KEY, String(w));
storage.setItem(CHAT_HEIGHT_KEY, String(h));
// Dragging = user chose a manual size → exit expanded mode
storage.removeItem(wsKey(CHAT_EXPANDED_KEY));
set({ chatWidth: w, chatHeight: h, isExpanded: false });
},
setExpanded: (expanded) => {
logger.info("setExpanded", { to: expanded });
if (expanded) {
storage.setItem(wsKey(CHAT_EXPANDED_KEY), "true");
} else {
storage.removeItem(wsKey(CHAT_EXPANDED_KEY));
}
set({ isExpanded: expanded });
},
}));
registerForWorkspaceRehydration(() => {
const nextSession = storage.getItem(wsKey(SESSION_STORAGE_KEY));
const nextAgent = storage.getItem(wsKey(AGENT_STORAGE_KEY));
const nextDrafts = readDrafts(storage, wsKey(DRAFTS_KEY));
logger.info("workspace rehydration", {
prevSession: store.getState().activeSessionId,
nextSession,
prevAgent: store.getState().selectedAgentId,
nextAgent,
draftCount: Object.keys(nextDrafts).length,
});
store.setState({
activeSessionId: storage.getItem(wsKey(SESSION_STORAGE_KEY)),
selectedAgentId: storage.getItem(wsKey(AGENT_STORAGE_KEY)),
timelineItems: [],
activeSessionId: nextSession,
selectedAgentId: nextAgent,
inputDrafts: nextDrafts,
});
});

View File

@@ -0,0 +1,18 @@
import { createStore } from "zustand/vanilla";
import { useStore } from "zustand";
interface ConfigState {
cdnDomain: string;
setCdnDomain: (domain: string) => void;
}
export const configStore = createStore<ConfigState>((set) => ({
cdnDomain: "",
setCdnDomain: (domain) => set({ cdnDomain: domain }),
}));
export function useConfigStore(): ConfigState;
export function useConfigStore<T>(selector: (state: ConfigState) => T): T;
export function useConfigStore<T>(selector?: (state: ConfigState) => T) {
return useStore(configStore, selector as (state: ConfigState) => T);
}

View File

@@ -97,6 +97,7 @@ export function useCreateIssue() {
// Invalidate parent's children query so sub-issues list updates immediately
if (newIssue.parent_issue_id) {
qc.invalidateQueries({ queryKey: issueKeys.children(wsId, newIssue.parent_issue_id) });
qc.invalidateQueries({ queryKey: issueKeys.childProgress(wsId) });
}
},
onSettled: () => {
@@ -167,10 +168,20 @@ export function useUpdateIssue() {
onSettled: (_data, _err, vars, ctx) => {
qc.invalidateQueries({ queryKey: issueKeys.detail(wsId, vars.id) });
qc.invalidateQueries({ queryKey: issueKeys.list(wsId) });
// Invalidate old parent's children cache
if (ctx?.parentId) {
qc.invalidateQueries({
queryKey: issueKeys.children(wsId, ctx.parentId),
});
qc.invalidateQueries({ queryKey: issueKeys.childProgress(wsId) });
}
// Invalidate new parent's children cache when parent_issue_id changed
const newParentId = vars.parent_issue_id;
if (newParentId && newParentId !== ctx?.parentId) {
qc.invalidateQueries({
queryKey: issueKeys.children(wsId, newParentId),
});
qc.invalidateQueries({ queryKey: issueKeys.childProgress(wsId) });
}
},
});
@@ -205,6 +216,7 @@ export function useDeleteIssue() {
qc.invalidateQueries({ queryKey: issueKeys.list(wsId) });
if (ctx?.parentIssueId) {
qc.invalidateQueries({ queryKey: issueKeys.children(wsId, ctx.parentIssueId) });
qc.invalidateQueries({ queryKey: issueKeys.childProgress(wsId) });
}
},
});
@@ -278,10 +290,11 @@ export function useBatchDeleteIssues() {
},
onSettled: (_data, _err, _ids, ctx) => {
qc.invalidateQueries({ queryKey: issueKeys.list(wsId) });
if (ctx?.parentIssueIds) {
if (ctx?.parentIssueIds && ctx.parentIssueIds.size > 0) {
for (const parentId of ctx.parentIssueIds) {
qc.invalidateQueries({ queryKey: issueKeys.children(wsId, parentId) });
}
qc.invalidateQueries({ queryKey: issueKeys.childProgress(wsId) });
}
},
});

View File

@@ -14,6 +14,8 @@ export const issueKeys = {
[...issueKeys.all(wsId), "detail", id] as const,
children: (wsId: string, id: string) =>
[...issueKeys.all(wsId), "children", id] as const,
childProgress: (wsId: string) =>
[...issueKeys.all(wsId), "child-progress"] as const,
timeline: (issueId: string) => ["issues", "timeline", issueId] as const,
reactions: (issueId: string) => ["issues", "reactions", issueId] as const,
subscribers: (issueId: string) =>
@@ -89,6 +91,20 @@ export function issueDetailOptions(wsId: string, id: string) {
});
}
export function childIssueProgressOptions(wsId: string) {
return queryOptions({
queryKey: issueKeys.childProgress(wsId),
queryFn: () => api.getChildIssueProgress(),
select: (data) => {
const map = new Map<string, { done: number; total: number }>();
for (const entry of data.progress) {
map.set(entry.parent_issue_id, { done: entry.done, total: entry.total });
}
return map;
},
});
}
export function childIssuesOptions(wsId: string, id: string) {
return queryOptions({
queryKey: issueKeys.children(wsId, id),

View File

@@ -2,7 +2,6 @@
import { create } from "zustand";
import { createJSONStorage, persist } from "zustand/middleware";
import type { IssueStatus } from "../../types";
import {
createWorkspaceAwareStorage,
registerForWorkspaceRehydration,
@@ -13,25 +12,22 @@ const MAX_RECENT_ISSUES = 20;
export interface RecentIssueEntry {
id: string;
identifier: string;
title: string;
status: IssueStatus;
visitedAt: number;
}
interface RecentIssuesState {
items: RecentIssueEntry[];
recordVisit: (entry: Omit<RecentIssueEntry, "visitedAt">) => void;
recordVisit: (id: string) => void;
}
export const useRecentIssuesStore = create<RecentIssuesState>()(
persist(
(set) => ({
items: [],
recordVisit: (entry) =>
recordVisit: (id) =>
set((state) => {
const filtered = state.items.filter((i) => i.id !== entry.id);
const updated: RecentIssueEntry = { ...entry, visitedAt: Date.now() };
const filtered = state.items.filter((i) => i.id !== id);
const updated: RecentIssueEntry = { id, visitedAt: Date.now() };
return {
items: [updated, ...filtered].slice(0, MAX_RECENT_ISSUES),
};

View File

@@ -20,6 +20,7 @@ export function onIssueCreated(
qc.invalidateQueries({ queryKey: issueKeys.myAll(wsId) });
if (issue.parent_issue_id) {
qc.invalidateQueries({ queryKey: issueKeys.children(wsId, issue.parent_issue_id) });
qc.invalidateQueries({ queryKey: issueKeys.childProgress(wsId) });
}
}
@@ -28,16 +29,19 @@ export function onIssueUpdated(
wsId: string,
issue: Partial<Issue> & { id: string },
) {
// Look up the parent before mutating list state, so we can also keep the
// parent's children cache in sync (powers the sub-issues list shown on
// the parent issue page).
// Look up the OLD parent before mutating list state, so we can keep
// the parent's children cache in sync (powers the sub-issues list
// shown on the parent issue page).
const listData = qc.getQueryData<ListIssuesResponse>(issueKeys.list(wsId));
const detailData = qc.getQueryData<Issue>(issueKeys.detail(wsId, issue.id));
const parentId =
issue.parent_issue_id ??
const oldParentId =
detailData?.parent_issue_id ??
listData?.issues.find((i) => i.id === issue.id)?.parent_issue_id ??
null;
// The NEW parent comes from the WS payload when parent_issue_id changed
const newParentId = issue.parent_issue_id ?? null;
const parentChanged =
issue.parent_issue_id !== undefined && newParentId !== oldParentId;
qc.setQueryData<ListIssuesResponse>(issueKeys.list(wsId), (old) => {
if (!old) return old;
@@ -62,10 +66,25 @@ export function onIssueUpdated(
qc.setQueryData<Issue>(issueKeys.detail(wsId, issue.id), (old) =>
old ? { ...old, ...issue } : old,
);
if (parentId) {
qc.setQueryData<Issue[]>(issueKeys.children(wsId, parentId), (old) =>
old?.map((c) => (c.id === issue.id ? { ...c, ...issue } : c)),
);
// Invalidate old parent's children (issue was removed from it)
if (oldParentId) {
if (parentChanged) {
qc.invalidateQueries({ queryKey: issueKeys.children(wsId, oldParentId) });
} else {
qc.setQueryData<Issue[]>(issueKeys.children(wsId, oldParentId), (old) =>
old?.map((c) => (c.id === issue.id ? { ...c, ...issue } : c)),
);
}
}
// Invalidate new parent's children (issue was added to it)
if (newParentId && parentChanged) {
qc.invalidateQueries({ queryKey: issueKeys.children(wsId, newParentId) });
}
if (oldParentId || newParentId) {
if (issue.status !== undefined || issue.parent_issue_id !== undefined) {
qc.invalidateQueries({ queryKey: issueKeys.childProgress(wsId) });
}
}
}
@@ -96,5 +115,6 @@ export function onIssueDeleted(
qc.removeQueries({ queryKey: issueKeys.children(wsId, issueId) });
if (deleted?.parent_issue_id) {
qc.invalidateQueries({ queryKey: issueKeys.children(wsId, deleted.parent_issue_id) });
qc.invalidateQueries({ queryKey: issueKeys.childProgress(wsId) });
}
}

View File

@@ -5,7 +5,7 @@ import { createJSONStorage, persist } from "zustand/middleware";
import { createPersistStorage } from "../platform/persist-storage";
import { defaultStorage } from "../platform/storage";
const EXCLUDED_PREFIXES = ["/login", "/pair/"];
const EXCLUDED_PREFIXES = ["/login", "/pair/", "/invite/"];
interface NavigationState {
lastPath: string;

View File

@@ -15,6 +15,7 @@
"./api": "./api/index.ts",
"./api/client": "./api/client.ts",
"./api/ws-client": "./api/ws-client.ts",
"./config": "./config/index.ts",
"./auth": "./auth/index.ts",
"./workspace": "./workspace/index.ts",
"./workspace/queries": "./workspace/queries.ts",
@@ -45,6 +46,9 @@
"./projects/queries": "./projects/queries.ts",
"./projects/mutations": "./projects/mutations.ts",
"./projects/config": "./projects/config.ts",
"./autopilots": "./autopilots/index.ts",
"./autopilots/queries": "./autopilots/queries.ts",
"./autopilots/mutations": "./autopilots/mutations.ts",
"./pins": "./pins/index.ts",
"./pins/queries": "./pins/queries.ts",
"./pins/mutations": "./pins/mutations.ts",

View File

@@ -1,5 +1,6 @@
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { api } from "../api";
import { useAuthStore } from "../auth";
import { pinKeys } from "./queries";
import { useWorkspaceId } from "../hooks";
import type { PinnedItem, PinnedItemType } from "../types";
@@ -7,16 +8,17 @@ import type { PinnedItem, PinnedItemType } from "../types";
export function useCreatePin() {
const qc = useQueryClient();
const wsId = useWorkspaceId();
const userId = useAuthStore((s) => s.user?.id ?? "");
return useMutation({
mutationFn: (data: { item_type: PinnedItemType; item_id: string }) =>
api.createPin(data),
onSuccess: (newPin) => {
qc.setQueryData<PinnedItem[]>(pinKeys.list(wsId), (old) =>
qc.setQueryData<PinnedItem[]>(pinKeys.list(wsId, userId), (old) =>
old ? [...old, newPin] : [newPin],
);
},
onSettled: () => {
qc.invalidateQueries({ queryKey: pinKeys.list(wsId) });
qc.invalidateQueries({ queryKey: pinKeys.list(wsId, userId) });
},
});
}
@@ -24,22 +26,23 @@ export function useCreatePin() {
export function useDeletePin() {
const qc = useQueryClient();
const wsId = useWorkspaceId();
const userId = useAuthStore((s) => s.user?.id ?? "");
return useMutation({
mutationFn: ({ itemType, itemId }: { itemType: PinnedItemType; itemId: string }) =>
api.deletePin(itemType, itemId),
onMutate: async ({ itemType, itemId }) => {
await qc.cancelQueries({ queryKey: pinKeys.list(wsId) });
const prev = qc.getQueryData<PinnedItem[]>(pinKeys.list(wsId));
qc.setQueryData<PinnedItem[]>(pinKeys.list(wsId), (old) =>
await qc.cancelQueries({ queryKey: pinKeys.list(wsId, userId) });
const prev = qc.getQueryData<PinnedItem[]>(pinKeys.list(wsId, userId));
qc.setQueryData<PinnedItem[]>(pinKeys.list(wsId, userId), (old) =>
old ? old.filter((p) => !(p.item_type === itemType && p.item_id === itemId)) : old,
);
return { prev };
},
onError: (_err, _vars, ctx) => {
if (ctx?.prev) qc.setQueryData(pinKeys.list(wsId), ctx.prev);
if (ctx?.prev) qc.setQueryData(pinKeys.list(wsId, userId), ctx.prev);
},
onSettled: () => {
qc.invalidateQueries({ queryKey: pinKeys.list(wsId) });
qc.invalidateQueries({ queryKey: pinKeys.list(wsId, userId) });
},
});
}
@@ -47,19 +50,20 @@ export function useDeletePin() {
export function useReorderPins() {
const qc = useQueryClient();
const wsId = useWorkspaceId();
const userId = useAuthStore((s) => s.user?.id ?? "");
return useMutation({
mutationFn: (reorderedPins: PinnedItem[]) => {
const items = reorderedPins.map((p, i) => ({ id: p.id, position: i + 1 }));
return api.reorderPins({ items });
},
onMutate: async (reorderedPins) => {
await qc.cancelQueries({ queryKey: pinKeys.list(wsId) });
const prev = qc.getQueryData<PinnedItem[]>(pinKeys.list(wsId));
qc.setQueryData<PinnedItem[]>(pinKeys.list(wsId), reorderedPins);
await qc.cancelQueries({ queryKey: pinKeys.list(wsId, userId) });
const prev = qc.getQueryData<PinnedItem[]>(pinKeys.list(wsId, userId));
qc.setQueryData<PinnedItem[]>(pinKeys.list(wsId, userId), reorderedPins);
return { prev };
},
onError: (_err, _vars, ctx) => {
if (ctx?.prev) qc.setQueryData(pinKeys.list(wsId), ctx.prev);
if (ctx?.prev) qc.setQueryData(pinKeys.list(wsId, userId), ctx.prev);
},
});
}

View File

@@ -2,13 +2,13 @@ import { queryOptions } from "@tanstack/react-query";
import { api } from "../api";
export const pinKeys = {
all: (wsId: string) => ["pins", wsId] as const,
list: (wsId: string) => [...pinKeys.all(wsId), "list"] as const,
all: (wsId: string, userId: string) => ["pins", wsId, userId] as const,
list: (wsId: string, userId: string) => [...pinKeys.all(wsId, userId), "list"] as const,
};
export function pinListOptions(wsId: string) {
export function pinListOptions(wsId: string, userId: string) {
return queryOptions({
queryKey: pinKeys.list(wsId),
queryKey: pinKeys.list(wsId, userId),
queryFn: () => api.listPins(),
});
}

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