Compare commits

..

49 Commits

Author SHA1 Message Date
Jiang Bohan
d7a4c3a1aa feat(agent): log full command line at debug level when spawning agents
Add a debug-level log line in every agent backend (claude, codex,
opencode, openclaw, gemini, hermes) that prints the executable path
and full argument list when spawning the agent process. Helps diagnose
custom args, model overrides, and other CLI flag issues.
2026-04-15 16:20:12 +08:00
Bohan Jiang
129a8b927f fix(views): auto-split whitespace in custom args entries (#1065)
Users naturally type `--model claude-sonnet-4-20250514` on one line,
but the backend needs them as separate tokens. Now `entriesToArgs`
splits each entry by whitespace before saving, so the API receives
`["--model", "claude-sonnet-4-20250514"]` instead of a single string.

Also updated placeholder and description to show the natural input
format.
2026-04-15 15:17:06 +08:00
Bohan Jiang
ce447c7f06 feat(agent): add custom CLI arguments support (#986)
* feat(agent): add custom CLI arguments support

Allow users to configure custom CLI arguments per agent that get
appended to the agent subprocess command at launch time. This enables
use cases like specifying different models (--model o3), max turns,
or other provider-specific flags without needing separate runtimes.

Changes:
- Add custom_args JSONB column to agent table (migration 041)
- Update API handler to accept/return custom_args in create/update
- Pass custom_args through claim endpoint to daemon
- Append custom_args to CLI commands for all agent backends
- Add ExecOptions.CustomArgs field in agent package
- Add Custom Args tab in agent detail UI
- Add --custom-args flag to CLI agent create/update commands

Closes MUL-802

* fix(agent): filter protocol-critical flags from custom_args

Add per-backend filtering of custom_args to prevent users from
accidentally overriding flags that the daemon hardcodes for its
communication protocol (e.g. --output-format, --input-format,
--permission-mode for Claude).

This follows the same pattern as custom_env's isBlockedEnvKey: we
only block the small, stable set of flags that would break the
daemon↔agent protocol — not every possible dangerous flag. Workspace
members are trusted for everything else.

Each backend defines its own blocked set:
- Claude: -p, --output-format, --input-format, --permission-mode
- Gemini: -p, --yolo, -o
- Codex: --listen
- OpenCode: --format
- OpenClaw: --local, --json, --session-id, --message
- Hermes: none (ACP is positional)

Includes unit tests for the filtering logic.

* fix(agent): address code review nits for custom_args

- Replace module-level `nextArgId` counter with `crypto.randomUUID()`
  in custom-args-tab.tsx to avoid SSR ID conflicts
- Add unit tests for custom args passthrough and blocked-arg filtering
  in both Claude and Gemini arg builders
2026-04-15 14:58:53 +08:00
LinYushen
5dad1f0915 fix(selfhost): clear hardcoded NEXT_PUBLIC_API_URL/WS_URL defaults (#1063)
The .env.example had hardcoded http://localhost:8080 defaults for
NEXT_PUBLIC_API_URL and NEXT_PUBLIC_WS_URL. When users copied .env.example
to .env and customized the backend port, the old defaults would still get
baked into the frontend at docker build time via NEXT_PUBLIC_WS_URL build
arg, causing API/WebSocket connection failures.

With empty defaults:
- Docker selfhost: frontend uses relative paths, Next.js rewrites proxy
  to backend internally — works regardless of external port config
- Local dev (make dev): Makefile sets these to localhost:$PORT automatically
- Browser fallback: deriveWsUrl() auto-derives WebSocket URL from page
  origin when NEXT_PUBLIC_WS_URL is empty

Closes #1055

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-15 14:56:30 +08:00
LinYushen
c0db3e0e76 Revert "feat(selfhost): add single-domain Caddy setup (#899)" (#1062)
This reverts commit 100146c49e.
2026-04-15 14:44:47 +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
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
180 changed files with 10372 additions and 1076 deletions

View File

@@ -55,8 +55,10 @@ ALLOWED_ORIGINS=
# Frontend
FRONTEND_PORT=3000
FRONTEND_ORIGIN=http://localhost:3000
NEXT_PUBLIC_API_URL=http://localhost:8080
NEXT_PUBLIC_WS_URL=ws://localhost:8080/ws
# Leave empty — auto-derived from page origin in browser, set by Makefile for local dev.
# Only set explicitly if frontend and backend are on different domains.
NEXT_PUBLIC_API_URL=
NEXT_PUBLIC_WS_URL=
# Remote API (optional) — set to proxy local frontend to a remote backend
# Leave empty to use local backend (localhost:8080)

7
.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

View File

@@ -189,7 +189,7 @@ See [CONTRIBUTING.md](CONTRIBUTING.md) for the full development workflow, worktr
<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&theme=dark&legend=top-left" />
<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>

View File

@@ -177,7 +177,7 @@ make start
<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&theme=dark&legend=top-left" />
<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>

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

@@ -19,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

View File

@@ -1,41 +1,26 @@
import { resolve } from "path";
import { defineConfig, externalizeDepsPlugin } from "electron-vite";
import { loadEnv } from "vite";
import react from "@vitejs/plugin-react";
import tailwindcss from "@tailwindcss/vite";
export default defineConfig(({ mode }) => {
const env = loadEnv(mode, process.cwd(), "");
const remoteApi = env.VITE_REMOTE_API;
const remoteWs = remoteApi?.replace(/^https/, "wss").replace(/^http/, "ws");
return {
main: {
plugins: [externalizeDepsPlugin()],
export default defineConfig({
main: {
plugins: [externalizeDepsPlugin()],
},
preload: {
plugins: [externalizeDepsPlugin()],
},
renderer: {
server: {
port: 5173,
strictPort: true,
},
preload: {
plugins: [externalizeDepsPlugin()],
},
renderer: {
server: {
port: 5173,
strictPort: true,
...(remoteApi && {
proxy: {
"/api": { target: remoteApi, changeOrigin: true },
"/auth": { target: remoteApi, changeOrigin: true },
"/uploads": { target: remoteApi, changeOrigin: true },
"/ws": { target: remoteWs, changeOrigin: true, ws: true },
},
}),
},
plugins: [react(), tailwindcss()],
resolve: {
alias: {
"@": resolve("src/renderer/src"),
},
dedupe: ["react", "react-dom"],
plugins: [react(), tailwindcss()],
resolve: {
alias: {
"@": resolve("src/renderer/src"),
},
dedupe: ["react", "react-dom"],
},
};
},
});

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,15 +4,16 @@
"private": true,
"main": "./out/main/index.js",
"scripts": {
"dev": "electron-vite dev",
"dev:remote": "electron-vite dev --mode remote",
"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": {
@@ -22,8 +23,8 @@
"@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",
"@fontsource/geist-sans": "^5.2.5",
"@multica/core": "workspace:*",
"@multica/ui": "workspace:*",
"@multica/views": "workspace:*",
@@ -47,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

@@ -2,6 +2,7 @@ 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";
@@ -113,9 +114,18 @@ if (!gotTheLock) {
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) => {

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

@@ -5,6 +5,44 @@ interface DesktopAPI {
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 {
@@ -19,6 +57,7 @@ declare global {
interface Window {
electron: ElectronAPI;
desktopAPI: DesktopAPI;
daemonAPI: DaemonAPI;
updater: UpdaterAPI;
}
}

View File

@@ -13,6 +13,60 @@ const desktopAPI = {
},
/** 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 = {
@@ -38,6 +92,7 @@ const updaterAPI = {
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
@@ -45,5 +100,7 @@ if (process.contextIsolated) {
// @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

@@ -14,11 +14,18 @@ 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 {
await useAuthStore.getState().loginWithToken(token);
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);
@@ -28,6 +35,22 @@ function AppContent() {
});
}, []);
// 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">
@@ -40,14 +63,32 @@ function AppContent() {
return <DesktopShell />;
}
const remoteProxy = Boolean(import.meta.env.VITE_REMOTE_API);
// 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={remoteProxy ? "" : (import.meta.env.VITE_API_URL || "http://localhost:8080")}
wsUrl={remoteProxy ? "ws://localhost:5173/ws" : (import.meta.env.VITE_WS_URL || "ws://localhost:8080/ws")}
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>

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,13 +101,7 @@ 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>
<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 />

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

@@ -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

@@ -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

@@ -78,12 +78,13 @@ multica daemon status
Confirm:
1. Status is `running`
2. At least one agent is listed (e.g. `claude`, `codex`, `opencode`, `openclaw`, or `hermes`)
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 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`)

View File

@@ -88,6 +88,7 @@ The daemon auto-detects these AI CLIs on your PATH:
|-----|---------|-------------|
| [Claude Code](https://docs.anthropic.com/en/docs/claude-code) | `claude` | Anthropic's coding agent |
| [Codex](https://github.com/openai/codex) | `codex` | OpenAI's coding agent |
| [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 |
@@ -131,6 +132,8 @@ Agent-specific overrides:
| `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

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.
@@ -45,7 +45,7 @@ Then configure, authenticate, and start the daemon:
multica setup
```
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.
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
@@ -55,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

@@ -83,6 +83,7 @@ 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)
@@ -233,6 +234,8 @@ Agent-specific overrides:
| `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

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,8 +29,10 @@ 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.

View File

@@ -11,7 +11,7 @@ Once you have the CLI installed (or signed up for [Multica Cloud](https://multic
multica setup # Configure, authenticate, and start the daemon
```
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`, `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
@@ -21,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

@@ -37,6 +37,15 @@ function LoginPageContent() {
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={handleSuccess}
@@ -45,7 +54,7 @@ function LoginPageContent() {
? {
clientId: googleClientId,
redirectUri: `${window.location.origin}/auth/callback`,
state: platform === "desktop" ? "platform:desktop" : undefined,
state: googleState,
}
: undefined
}

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

@@ -39,8 +39,11 @@ function CallbackContent() {
return;
}
const state = searchParams.get("state");
const isDesktop = state === "platform:desktop";
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`;
@@ -63,7 +66,9 @@ function CallbackContent() {
qc.setQueryData(workspaceKeys.list(), wsList);
const lastWsId = localStorage.getItem("multica_workspace_id");
const ws = await hydrateWorkspace(wsList, lastWsId);
router.push(ws ? "/issues" : "/onboarding");
// 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");

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

@@ -8,6 +8,7 @@ import { useLocale } from "../i18n";
import {
ClaudeCodeLogo,
CodexLogo,
GeminiCliLogo,
OpenClawLogo,
OpenCodeLogo,
GitHubMark,
@@ -70,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>

View File

@@ -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

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

View File

@@ -55,6 +55,8 @@ export const mockAgents: Agent[] = [
runtime_mode: "cloud",
runtime_config: {},
custom_env: {},
custom_args: [],
custom_env_redacted: false,
visibility: "workspace",
max_concurrent_tasks: 3,
owner_id: null,

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` 色值,而不是加粗。
---

View File

@@ -6,7 +6,6 @@
"scripts": {
"dev:web": "turbo dev --filter=@multica/web",
"dev:desktop": "turbo dev --filter=@multica/desktop",
"dev:desktop:remote": "pnpm --filter @multica/desktop dev:remote",
"build": "turbo build",
"typecheck": "turbo typecheck",
"test": "turbo test",

View File

@@ -32,4 +32,78 @@ describe("ApiClient", () => {
});
}
});
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

@@ -52,6 +52,17 @@ 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";
@@ -523,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");
@@ -551,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),
@@ -577,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",
@@ -782,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

@@ -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

@@ -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

@@ -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

@@ -5,6 +5,7 @@ import { useQueryClient } from "@tanstack/react-query";
import { getApi } from "../api";
import { useAuthStore } from "../auth";
import { useWorkspaceStore } from "../workspace";
import { configStore } from "../config";
import { workspaceKeys } from "../workspace/queries";
import { createLogger } from "../logger";
import { defaultStorage } from "./storage";
@@ -31,6 +32,11 @@ export function AuthInitializer({
const api = getApi();
const wsId = storage.getItem("multica_workspace_id");
// Fetch app config (CDN domain, etc.) in the background — non-blocking.
api.getConfig().then((cfg) => {
if (cfg.cdn_domain) configStore.getState().setCdnDomain(cfg.cdn_domain);
}).catch(() => { /* config is optional — legacy file card matching degrades gracefully */ });
if (cookieAuth) {
// Cookie mode: the HttpOnly cookie is sent automatically by the browser.
// Call the API to check if the session is still valid.

View File

@@ -12,6 +12,7 @@ import { defaultStorage } from "../platform/storage";
import { issueKeys } from "../issues/queries";
import { projectKeys } from "../projects/queries";
import { pinKeys } from "../pins/queries";
import { autopilotKeys } from "../autopilots/queries";
import { runtimeKeys } from "../runtimes/queries";
import {
onIssueCreated,
@@ -44,6 +45,7 @@ import type {
TaskCompletedPayload,
TaskFailedPayload,
ChatDonePayload,
InvitationCreatedPayload,
} from "../types";
const chatWsLogger = createLogger("chat.ws");
@@ -116,6 +118,10 @@ export function useRealtimeSync(
const wsId = workspaceStore.getState().workspace?.id;
if (wsId) qc.invalidateQueries({ queryKey: runtimeKeys.all(wsId) });
},
autopilot: () => {
const wsId = workspaceStore.getState().workspace?.id;
if (wsId) qc.invalidateQueries({ queryKey: autopilotKeys.all(wsId) });
},
};
const timers = new Map<string, ReturnType<typeof setTimeout>>();
@@ -286,13 +292,42 @@ export function useRealtimeSync(
const myUserId = authStore.getState().user?.id;
if (member.user_id === myUserId) {
qc.invalidateQueries({ queryKey: workspaceKeys.list() });
qc.invalidateQueries({ queryKey: workspaceKeys.myInvitations() });
onToast?.(
`You were invited to ${workspace_name ?? "a workspace"}`,
`You joined ${workspace_name ?? "a workspace"}`,
"info",
);
}
});
// invitation:created — notify the invitee of a new pending invitation
const unsubInvitationCreated = ws.on("invitation:created", (p) => {
const { workspace_name } = p as InvitationCreatedPayload;
qc.invalidateQueries({ queryKey: workspaceKeys.myInvitations() });
onToast?.(
`You were invited to ${workspace_name ?? "a workspace"}`,
"info",
);
});
// invitation:accepted / declined / revoked — refresh invitation lists
const unsubInvitationAccepted = ws.on("invitation:accepted", () => {
const currentWsId = workspaceStore.getState().workspace?.id;
if (currentWsId) {
qc.invalidateQueries({ queryKey: workspaceKeys.invitations(currentWsId) });
qc.invalidateQueries({ queryKey: workspaceKeys.members(currentWsId) });
}
});
const unsubInvitationDeclined = ws.on("invitation:declined", () => {
const currentWsId = workspaceStore.getState().workspace?.id;
if (currentWsId) {
qc.invalidateQueries({ queryKey: workspaceKeys.invitations(currentWsId) });
}
});
const unsubInvitationRevoked = ws.on("invitation:revoked", () => {
qc.invalidateQueries({ queryKey: workspaceKeys.myInvitations() });
});
// --- Chat / task events (global, survives ChatWindow unmount) ---
//
// Single source of truth: the Query cache. No Zustand writes here — the
@@ -409,6 +444,10 @@ export function useRealtimeSync(
unsubWsDeleted();
unsubMemberRemoved();
unsubMemberAdded();
unsubInvitationCreated();
unsubInvitationAccepted();
unsubInvitationDeclined();
unsubInvitationRevoked();
unsubTaskMessage();
unsubChatMessage();
unsubChatDone();
@@ -436,6 +475,7 @@ export function useRealtimeSync(
qc.invalidateQueries({ queryKey: workspaceKeys.skills(wsId) });
qc.invalidateQueries({ queryKey: projectKeys.all(wsId) });
qc.invalidateQueries({ queryKey: runtimeKeys.all(wsId) });
qc.invalidateQueries({ queryKey: autopilotKeys.all(wsId) });
}
qc.invalidateQueries({ queryKey: workspaceKeys.list() });
} catch (e) {

View File

@@ -48,6 +48,8 @@ export interface Agent {
runtime_mode: AgentRuntimeMode;
runtime_config: Record<string, unknown>;
custom_env: Record<string, string>;
custom_args: string[];
custom_env_redacted: boolean;
visibility: AgentVisibility;
status: AgentStatus;
max_concurrent_tasks: number;
@@ -67,6 +69,7 @@ export interface CreateAgentRequest {
runtime_id: string;
runtime_config?: Record<string, unknown>;
custom_env?: Record<string, string>;
custom_args?: string[];
visibility?: AgentVisibility;
max_concurrent_tasks?: number;
}
@@ -79,6 +82,7 @@ export interface UpdateAgentRequest {
runtime_id?: string;
runtime_config?: Record<string, unknown>;
custom_env?: Record<string, string>;
custom_args?: string[];
visibility?: AgentVisibility;
status?: AgentStatus;
max_concurrent_tasks?: number;

View File

@@ -0,0 +1,108 @@
export type AutopilotStatus = "active" | "paused" | "archived";
export type AutopilotExecutionMode = "create_issue" | "run_only";
export type AutopilotTriggerKind = "schedule" | "webhook" | "api";
export type AutopilotRunStatus = "issue_created" | "running" | "completed" | "failed";
export type AutopilotRunSource = "schedule" | "manual" | "webhook" | "api";
export interface Autopilot {
id: string;
workspace_id: string;
project_id: string | null;
title: string;
description: string | null;
assignee_id: string;
priority: string;
status: AutopilotStatus;
execution_mode: AutopilotExecutionMode;
issue_title_template: string | null;
created_by_type: string;
created_by_id: string;
last_run_at: string | null;
created_at: string;
updated_at: string;
}
export interface AutopilotTrigger {
id: string;
autopilot_id: string;
kind: AutopilotTriggerKind;
enabled: boolean;
cron_expression: string | null;
timezone: string | null;
next_run_at: string | null;
webhook_token: string | null;
label: string | null;
last_fired_at: string | null;
created_at: string;
updated_at: string;
}
export interface AutopilotRun {
id: string;
autopilot_id: string;
trigger_id: string | null;
source: AutopilotRunSource;
status: AutopilotRunStatus;
issue_id: string | null;
task_id: string | null;
triggered_at: string;
completed_at: string | null;
failure_reason: string | null;
trigger_payload: unknown;
result: unknown;
created_at: string;
}
export interface CreateAutopilotRequest {
title: string;
description?: string;
assignee_id: string;
project_id?: string;
priority?: string;
execution_mode: AutopilotExecutionMode;
issue_title_template?: string;
}
export interface UpdateAutopilotRequest {
title?: string;
description?: string | null;
assignee_id?: string;
project_id?: string | null;
priority?: string;
status?: AutopilotStatus;
execution_mode?: AutopilotExecutionMode;
issue_title_template?: string | null;
}
export interface CreateAutopilotTriggerRequest {
kind: AutopilotTriggerKind;
cron_expression?: string;
timezone?: string;
label?: string;
}
export interface UpdateAutopilotTriggerRequest {
enabled?: boolean;
cron_expression?: string;
timezone?: string;
label?: string;
}
export interface ListAutopilotsResponse {
autopilots: Autopilot[];
total: number;
}
export interface GetAutopilotResponse {
autopilot: Autopilot;
triggers: AutopilotTrigger[];
}
export interface ListAutopilotRunsResponse {
runs: AutopilotRun[];
total: number;
}

View File

@@ -3,7 +3,7 @@ import type { Agent } from "./agent";
import type { InboxItem } from "./inbox";
import type { Comment, Reaction } from "./comment";
import type { TimelineEntry } from "./activity";
import type { Workspace, MemberWithUser } from "./workspace";
import type { Workspace, MemberWithUser, Invitation } from "./workspace";
import type { Project } from "./project";
// WebSocket event types (matching Go server protocol/events.go)
@@ -53,7 +53,11 @@ export type WSEventType =
| "project:updated"
| "project:deleted"
| "pin:created"
| "pin:deleted";
| "pin:deleted"
| "invitation:created"
| "invitation:accepted"
| "invitation:declined"
| "invitation:revoked";
export interface WSMessage<T = unknown> {
type: WSEventType;
@@ -259,3 +263,23 @@ export interface ProjectUpdatedPayload {
export interface ProjectDeletedPayload {
project_id: string;
}
export interface InvitationCreatedPayload {
invitation: Invitation;
workspace_name?: string;
}
export interface InvitationAcceptedPayload {
invitation_id: string;
member: MemberWithUser;
}
export interface InvitationDeclinedPayload {
invitation_id: string;
invitee_email: string;
}
export interface InvitationRevokedPayload {
invitation_id: string;
invitee_email: string;
}

View File

@@ -22,7 +22,7 @@ export type {
RuntimeUpdateStatus,
IssueUsageSummary,
} from "./agent";
export type { Workspace, WorkspaceRepo, Member, MemberRole, User, MemberWithUser } from "./workspace";
export type { Workspace, WorkspaceRepo, Member, MemberRole, User, MemberWithUser, Invitation } from "./workspace";
export type { InboxItem, InboxSeverity, InboxItemType } from "./inbox";
export type { Comment, CommentType, CommentAuthorType, Reaction } from "./comment";
export type { TimelineEntry, AssigneeFrequencyEntry } from "./activity";
@@ -34,3 +34,20 @@ export type { ChatSession, ChatMessage, ChatPendingTask, PendingChatTaskItem, Pe
export type { StorageAdapter } from "./storage";
export type { Project, ProjectStatus, ProjectPriority, CreateProjectRequest, UpdateProjectRequest, ListProjectsResponse } from "./project";
export type { PinnedItem, PinnedItemType, CreatePinRequest, ReorderPinsRequest } from "./pin";
export type {
Autopilot,
AutopilotStatus,
AutopilotExecutionMode,
AutopilotTrigger,
AutopilotTriggerKind,
AutopilotRun,
AutopilotRunStatus,
AutopilotRunSource,
CreateAutopilotRequest,
UpdateAutopilotRequest,
CreateAutopilotTriggerRequest,
UpdateAutopilotTriggerRequest,
ListAutopilotsResponse,
GetAutopilotResponse,
ListAutopilotRunsResponse,
} from "./autopilot";

View File

@@ -45,3 +45,19 @@ export interface MemberWithUser {
email: string;
avatar_url: string | null;
}
export interface Invitation {
id: string;
workspace_id: string;
inviter_id: string;
invitee_email: string;
invitee_user_id: string | null;
role: MemberRole;
status: "pending" | "accepted" | "declined" | "expired";
created_at: string;
updated_at: string;
expires_at: string;
inviter_name?: string;
inviter_email?: string;
workspace_name?: string;
}

View File

@@ -5,6 +5,8 @@ export const workspaceKeys = {
all: (wsId: string) => ["workspaces", wsId] as const,
list: () => ["workspaces", "list"] as const,
members: (wsId: string) => ["workspaces", wsId, "members"] as const,
invitations: (wsId: string) => ["workspaces", wsId, "invitations"] as const,
myInvitations: () => ["invitations", "mine"] as const,
agents: (wsId: string) => ["workspaces", wsId, "agents"] as const,
skills: (wsId: string) => ["workspaces", wsId, "skills"] as const,
assigneeFrequency: (wsId: string) => ["workspaces", wsId, "assignee-frequency"] as const,
@@ -39,6 +41,20 @@ export function skillListOptions(wsId: string) {
});
}
export function invitationListOptions(wsId: string) {
return queryOptions({
queryKey: workspaceKeys.invitations(wsId),
queryFn: () => api.listWorkspaceInvitations(wsId),
});
}
export function myInvitationListOptions() {
return queryOptions({
queryKey: workspaceKeys.myInvitations(),
queryFn: () => api.listMyInvitations(),
});
}
export function assigneeFrequencyOptions(wsId: string) {
return queryOptions({
queryKey: workspaceKeys.assigneeFrequency(wsId),

View File

@@ -3,8 +3,10 @@ import ReactMarkdown, { type Components, defaultUrlTransform } from 'react-markd
import rehypeRaw from 'rehype-raw'
import rehypeSanitize, { defaultSchema } from 'rehype-sanitize'
import remarkGfm from 'remark-gfm'
import { FileText, Download } from 'lucide-react'
import { cn } from '@multica/ui/lib/utils'
import { CodeBlock, InlineCode } from './CodeBlock'
import { preprocessFileCards } from './file-cards'
import { preprocessLinks } from './linkify'
import { preprocessMentionShortcodes } from './mentions'
@@ -48,6 +50,11 @@ export interface MarkdownProps {
* When not provided, mentions render as a simple styled span.
*/
renderMention?: (props: { type: string; id: string }) => React.ReactNode
/**
* CDN hostname for file card detection (e.g. "multica-static.copilothub.ai").
* When provided, enables file card preprocessing and rendering.
*/
cdnDomain?: string
}
// Sanitization schema — extends GitHub defaults to allow code highlighting classes
@@ -60,6 +67,12 @@ const sanitizeSchema = {
},
attributes: {
...defaultSchema.attributes,
div: [
...(defaultSchema.attributes?.div ?? []),
'dataType',
'dataHref',
'dataFilename',
],
code: [
...(defaultSchema.attributes?.code ?? []),
['className', /^language-/],
@@ -93,9 +106,37 @@ function createComponents(
mode: RenderMode,
onUrlClick?: (url: string) => void,
onFileClick?: (path: string) => void,
renderMention?: (props: { type: string; id: string }) => React.ReactNode
renderMention?: (props: { type: string; id: string }) => React.ReactNode,
): Partial<Components> {
const baseComponents: Partial<Components> = {
// FileCard: intercept <div data-type="fileCard"> from preprocessFileCards
div: ({ node, children, ...props }) => {
const dataType = node?.properties?.dataType as string | undefined
if (dataType === 'fileCard') {
const rawHref = (node?.properties?.dataHref as string) || ''
// Only allow http(s) URLs to prevent javascript: and other dangerous schemes.
const href = /^https?:\/\//i.test(rawHref) ? rawHref : ''
const filename = (node?.properties?.dataFilename as string) || ''
return (
<div className="my-1 flex items-center gap-2 rounded-md border border-border bg-muted/50 px-2.5 py-1 transition-colors hover:bg-muted">
<FileText className="size-4 shrink-0 text-muted-foreground" />
<div className="min-w-0 flex-1">
<p className="truncate text-sm">{filename}</p>
</div>
{href && (
<button
type="button"
className="shrink-0 rounded-md p-1 text-muted-foreground transition-colors hover:bg-secondary hover:text-foreground"
onClick={() => window.open(href, '_blank', 'noopener,noreferrer')}
>
<Download className="size-3.5" />
</button>
)}
</div>
)
}
return <div {...props}>{children}</div>
},
// Images: render uploaded images with constrained sizing
img: ({ src, alt }) => (
<img
@@ -337,17 +378,23 @@ export function Markdown({
className,
onUrlClick,
onFileClick,
renderMention
renderMention,
cdnDomain
}: MarkdownProps): React.JSX.Element {
const components = React.useMemo(
() => createComponents(mode, onUrlClick, onFileClick, renderMention),
[mode, onUrlClick, onFileClick, renderMention]
)
// Preprocess: convert mention shortcodes and raw URLs/file paths to markdown links
// Preprocess: convert mention shortcodes, raw URLs, and file cards to renderable content
const processedContent = React.useMemo(
() => preprocessLinks(preprocessMentionShortcodes(children)),
[children]
() => {
let result = preprocessMentionShortcodes(children)
result = preprocessLinks(result)
result = preprocessFileCards(result, cdnDomain ?? '')
return result
},
[children, cdnDomain]
)
return (

View File

@@ -9,6 +9,7 @@ export interface StreamingMarkdownProps {
onUrlClick?: (url: string) => void
onFileClick?: (path: string) => void
renderMention?: (props: { type: string; id: string }) => React.ReactNode
cdnDomain?: string
}
interface Block {
@@ -136,7 +137,8 @@ const MemoizedBlock = React.memo(
className,
onUrlClick,
onFileClick,
renderMention
renderMention,
cdnDomain
}: {
content: string
mode: RenderMode
@@ -144,9 +146,10 @@ const MemoizedBlock = React.memo(
onUrlClick?: (url: string) => void
onFileClick?: (path: string) => void
renderMention?: (props: { type: string; id: string }) => React.ReactNode
cdnDomain?: string
}) {
return (
<Markdown mode={mode} className={className} onUrlClick={onUrlClick} onFileClick={onFileClick} renderMention={renderMention}>
<Markdown mode={mode} className={className} onUrlClick={onUrlClick} onFileClick={onFileClick} renderMention={renderMention} cdnDomain={cdnDomain}>
{content}
</Markdown>
)
@@ -181,7 +184,8 @@ export function StreamingMarkdown({
className,
onUrlClick,
onFileClick,
renderMention
renderMention,
cdnDomain
}: StreamingMarkdownProps): React.JSX.Element {
// Split into blocks - memoized to avoid recomputation
// Must be called unconditionally to satisfy Rules of Hooks
@@ -193,7 +197,7 @@ export function StreamingMarkdown({
// Not streaming - use simple Markdown (no block splitting needed)
if (!isStreaming) {
return (
<Markdown mode={mode} className={className} onUrlClick={onUrlClick} onFileClick={onFileClick} renderMention={renderMention}>
<Markdown mode={mode} className={className} onUrlClick={onUrlClick} onFileClick={onFileClick} renderMention={renderMention} cdnDomain={cdnDomain}>
{content}
</Markdown>
)
@@ -222,6 +226,7 @@ export function StreamingMarkdown({
onUrlClick={onUrlClick}
onFileClick={onFileClick}
renderMention={renderMention}
cdnDomain={cdnDomain}
/>
)
})}

View File

@@ -0,0 +1,89 @@
/**
* File card preprocessing for markdown content.
*
* Converts file-card syntax into HTML divs that can be rendered by
* react-markdown with a custom `div` component.
*
* Two syntaxes are supported:
* 1. `!file[name](url)` — new unambiguous syntax (no hostname check needed)
* 2. `[name](cdnUrl)` — legacy syntax, matched by CDN hostname on own line
*
* Output: `<div data-type="fileCard" data-href="url" data-filename="name"></div>`
*
* All functions are pure — no global state, no imports from core/ or views/.
*/
const IMAGE_EXTS = /\.(png|jpe?g|gif|webp|svg|ico|bmp|tiff?)$/i
/** New syntax: !file[name](url) — unambiguous, no hostname matching needed. */
const NEW_FILE_CARD_RE = /^!file\[([^\]]*)\]\((https?:\/\/[^)]+)\)$/
/** Legacy syntax: [name](cdnUrl) on its own line — matched by CDN hostname. */
const FILE_LINK_LINE = /^\[([^\]]+)\]\((https?:\/\/[^)]+)\)$/
function escapeAttr(s: string): string {
return s.replace(/&/g, '&amp;').replace(/"/g, '&quot;').replace(/</g, '&lt;')
}
function toFileCardHtml(filename: string, url: string): string {
return `<div data-type="fileCard" data-href="${escapeAttr(url)}" data-filename="${escapeAttr(filename)}"></div>`
}
/**
* Check if a URL points to our upload CDN.
*
* Uses exact hostname match against `cdnDomain` (e.g. "multica-static.copilothub.ai"),
* and also matches any `.amazonaws.com` subdomain as a fallback for direct S3 URLs.
*/
export function isCdnUrl(url: string, cdnDomain: string): boolean {
try {
const u = new URL(url)
return u.hostname === cdnDomain || u.hostname.endsWith('.amazonaws.com')
} catch {
return false
}
}
/**
* Check if a CDN URL is a non-image file that should render as a file card.
* Image URLs (png, jpg, etc.) are excluded — they render as inline images.
*/
export function isFileCardUrl(url: string, cdnDomain: string): boolean {
try {
return isCdnUrl(url, cdnDomain) && !IMAGE_EXTS.test(new URL(url).pathname)
} catch {
return false
}
}
/**
* Preprocess markdown to convert file-card syntax into HTML divs.
*
* Handles both `!file[name](url)` (new syntax) and legacy `[name](cdnUrl)`
* lines. Only standalone lines are matched — inline links are left untouched.
*
* @param markdown Raw markdown string
* @param cdnDomain CDN hostname for legacy link detection (e.g. "multica-static.copilothub.ai")
*/
export function preprocessFileCards(markdown: string, cdnDomain: string): string {
return markdown
.split('\n')
.map((line) => {
const trimmed = line.trim()
// New syntax: !file[name](url) — always a file card, no hostname check needed.
const newMatch = trimmed.match(NEW_FILE_CARD_RE)
if (newMatch) {
return toFileCardHtml(newMatch[1]!, newMatch[2]!)
}
// Legacy: [name](cdnUrl) on its own line — CDN hostname matching.
const match = trimmed.match(FILE_LINK_LINE)
if (!match) return line
const filename = match[1]!
const url = match[2]!
if (!isFileCardUrl(url, cdnDomain)) return line
return toFileCardHtml(filename, url)
})
.join('\n')
}

View File

@@ -3,3 +3,4 @@ export { CodeBlock, InlineCode, type CodeBlockProps } from './CodeBlock'
export { StreamingMarkdown, type StreamingMarkdownProps } from './StreamingMarkdown'
export { preprocessLinks, detectLinks, hasLinks } from './linkify'
export { preprocessMentionShortcodes } from './mentions'
export { preprocessFileCards, isCdnUrl, isFileCardUrl } from './file-cards'

View File

@@ -73,5 +73,9 @@
}
html {
@apply font-sans;
/* Auto-insert 1/4em space between CJK ideographs and Latin letters/numerals.
* Native CSS text-autospace (Chrome 119+, Electron recent versions).
* Progressive enhancement: browsers that don't support it simply ignore the rule. */
text-autospace: ideograph-alpha ideograph-numeric;
}
}

View File

@@ -12,8 +12,9 @@ import {
MoreHorizontal,
Settings,
KeyRound,
Terminal,
} from "lucide-react";
import type { Agent, RuntimeDevice } from "@multica/core/types";
import type { Agent, RuntimeDevice, MemberWithUser } from "@multica/core/types";
import {
Dialog,
DialogContent,
@@ -36,30 +37,36 @@ import { SkillsTab } from "./tabs/skills-tab";
import { TasksTab } from "./tabs/tasks-tab";
import { SettingsTab } from "./tabs/settings-tab";
import { EnvTab } from "./tabs/env-tab";
import { CustomArgsTab } from "./tabs/custom-args-tab";
function getRuntimeDevice(agent: Agent, runtimes: RuntimeDevice[]): RuntimeDevice | undefined {
return runtimes.find((runtime) => runtime.id === agent.runtime_id);
}
type DetailTab = "instructions" | "skills" | "tasks" | "env" | "settings";
type DetailTab = "instructions" | "skills" | "tasks" | "env" | "custom_args" | "settings";
const detailTabs: { id: DetailTab; label: string; icon: typeof FileText }[] = [
{ id: "instructions", label: "Instructions", icon: FileText },
{ id: "skills", label: "Skills", icon: BookOpenText },
{ id: "tasks", label: "Tasks", icon: ListTodo },
{ id: "env", label: "Environment", icon: KeyRound },
{ id: "custom_args", label: "Custom Args", icon: Terminal },
{ id: "settings", label: "Settings", icon: Settings },
];
export function AgentDetail({
agent,
runtimes,
members,
currentUserId,
onUpdate,
onArchive,
onRestore,
}: {
agent: Agent;
runtimes: RuntimeDevice[];
members: MemberWithUser[];
currentUserId: string | null;
onUpdate: (id: string, data: Partial<Agent>) => Promise<void>;
onArchive: (id: string) => Promise<void>;
onRestore: (id: string) => Promise<void>;
@@ -163,6 +170,13 @@ export function AgentDetail({
{activeTab === "tasks" && <TasksTab agent={agent} />}
{activeTab === "env" && (
<EnvTab
agent={agent}
readOnly={agent.custom_env_redacted}
onSave={(updates) => onUpdate(agent.id, updates)}
/>
)}
{activeTab === "custom_args" && (
<CustomArgsTab
agent={agent}
onSave={(updates) => onUpdate(agent.id, updates)}
/>
@@ -171,6 +185,8 @@ export function AgentDetail({
<SettingsTab
agent={agent}
runtimes={runtimes}
members={members}
currentUserId={currentUserId}
onSave={(updates) => onUpdate(agent.id, updates)}
/>
)}

View File

@@ -17,13 +17,14 @@ import { useAuthStore } from "@multica/core/auth";
import { runtimeListOptions } from "@multica/core/runtimes/queries";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { useWorkspaceId } from "@multica/core/hooks";
import { agentListOptions, workspaceKeys } from "@multica/core/workspace/queries";
import { agentListOptions, memberListOptions, workspaceKeys } from "@multica/core/workspace/queries";
import { CreateAgentDialog } from "./create-agent-dialog";
import { AgentListItem } from "./agent-list-item";
import { AgentDetail } from "./agent-detail";
export function AgentsPage() {
const isLoading = useAuthStore((s) => s.isLoading);
const currentUser = useAuthStore((s) => s.user);
const qc = useQueryClient();
const wsId = useWorkspaceId();
const { data: agents = [] } = useQuery(agentListOptions(wsId));
@@ -31,6 +32,7 @@ export function AgentsPage() {
const [showArchived, setShowArchived] = useState(false);
const [showCreate, setShowCreate] = useState(false);
const { data: runtimes = [], isLoading: runtimesLoading } = useQuery(runtimeListOptions(wsId));
const { data: members = [] } = useQuery(memberListOptions(wsId));
const { defaultLayout, onLayoutChanged } = useDefaultLayout({
id: "multica_agents_layout",
});
@@ -201,6 +203,8 @@ export function AgentsPage() {
key={selected.id}
agent={selected}
runtimes={runtimes}
members={members}
currentUserId={currentUser?.id ?? null}
onUpdate={handleUpdate}
onArchive={handleArchive}
onRestore={handleRestore}
@@ -225,6 +229,8 @@ export function AgentsPage() {
<CreateAgentDialog
runtimes={runtimes}
runtimesLoading={runtimesLoading}
members={members}
currentUserId={currentUser?.id ?? null}
onClose={() => setShowCreate(false)}
onCreate={handleCreate}
/>

View File

@@ -1,11 +1,13 @@
"use client";
import { useState, useEffect } from "react";
import { useState, useEffect, useMemo } from "react";
import { Cloud, ChevronDown, Globe, Lock, Loader2 } from "lucide-react";
import { ProviderLogo } from "../../runtimes/components/provider-logo";
import { ActorAvatar } from "../../common/actor-avatar";
import type {
AgentVisibility,
RuntimeDevice,
MemberWithUser,
CreateAgentRequest,
} from "@multica/core/types";
import {
@@ -26,29 +28,55 @@ import { Input } from "@multica/ui/components/ui/input";
import { Label } from "@multica/ui/components/ui/label";
import { toast } from "sonner";
type RuntimeFilter = "mine" | "all";
export function CreateAgentDialog({
runtimes,
runtimesLoading,
members,
currentUserId,
onClose,
onCreate,
}: {
runtimes: RuntimeDevice[];
runtimesLoading?: boolean;
members: MemberWithUser[];
currentUserId: string | null;
onClose: () => void;
onCreate: (data: CreateAgentRequest) => Promise<void>;
}) {
const [name, setName] = useState("");
const [description, setDescription] = useState("");
const [selectedRuntimeId, setSelectedRuntimeId] = useState(runtimes[0]?.id ?? "");
const [visibility, setVisibility] = useState<AgentVisibility>("private");
const [creating, setCreating] = useState(false);
const [runtimeOpen, setRuntimeOpen] = useState(false);
const [runtimeFilter, setRuntimeFilter] = useState<RuntimeFilter>("mine");
const getOwnerMember = (ownerId: string | null) => {
if (!ownerId) return null;
return members.find((m) => m.user_id === ownerId) ?? null;
};
const hasOtherRuntimes = runtimes.some((r) => r.owner_id !== currentUserId);
const filteredRuntimes = useMemo(() => {
const filtered = runtimeFilter === "mine" && currentUserId
? runtimes.filter((r) => r.owner_id === currentUserId)
: runtimes;
return [...filtered].sort((a, b) => {
if (a.owner_id === currentUserId && b.owner_id !== currentUserId) return -1;
if (a.owner_id !== currentUserId && b.owner_id === currentUserId) return 1;
return 0;
});
}, [runtimes, runtimeFilter, currentUserId]);
const [selectedRuntimeId, setSelectedRuntimeId] = useState(filteredRuntimes[0]?.id ?? "");
useEffect(() => {
if (!selectedRuntimeId && runtimes[0]) {
setSelectedRuntimeId(runtimes[0].id);
if (!selectedRuntimeId && filteredRuntimes[0]) {
setSelectedRuntimeId(filteredRuntimes[0].id);
}
}, [runtimes, selectedRuntimeId]);
}, [filteredRuntimes, selectedRuntimeId]);
const selectedRuntime = runtimes.find((d) => d.id === selectedRuntimeId) ?? null;
@@ -141,7 +169,35 @@ export function CreateAgentDialog({
</div>
<div className="min-w-0">
<Label className="text-xs text-muted-foreground">Runtime</Label>
<div className="flex items-center justify-between">
<Label className="text-xs text-muted-foreground">Runtime</Label>
{hasOtherRuntimes && (
<div className="flex items-center gap-0.5 rounded-md bg-muted p-0.5">
<button
type="button"
onClick={() => { setRuntimeFilter("mine"); setSelectedRuntimeId(""); }}
className={`rounded px-2 py-0.5 text-xs font-medium transition-colors ${
runtimeFilter === "mine"
? "bg-background text-foreground shadow-sm"
: "text-muted-foreground hover:text-foreground"
}`}
>
Mine
</button>
<button
type="button"
onClick={() => { setRuntimeFilter("all"); setSelectedRuntimeId(""); }}
className={`rounded px-2 py-0.5 text-xs font-medium transition-colors ${
runtimeFilter === "all"
? "bg-background text-foreground shadow-sm"
: "text-muted-foreground hover:text-foreground"
}`}
>
All
</button>
</div>
)}
</div>
<Popover open={runtimeOpen} onOpenChange={setRuntimeOpen}>
<PopoverTrigger
disabled={runtimes.length === 0 && !runtimesLoading}
@@ -166,42 +222,56 @@ export function CreateAgentDialog({
)}
</div>
<div className="truncate text-xs text-muted-foreground">
{selectedRuntime?.device_info ?? "Register a runtime before creating an agent"}
{selectedRuntime
? (getOwnerMember(selectedRuntime.owner_id)?.name ?? selectedRuntime.device_info)
: "Register a runtime before creating an agent"}
</div>
</div>
<ChevronDown className={`h-4 w-4 shrink-0 text-muted-foreground transition-transform ${runtimeOpen ? "rotate-180" : ""}`} />
</PopoverTrigger>
<PopoverContent align="start" className="w-[var(--anchor-width)] p-1 max-h-60 overflow-y-auto">
{runtimes.map((device) => (
<button
key={device.id}
onClick={() => {
setSelectedRuntimeId(device.id);
setRuntimeOpen(false);
}}
className={`flex w-full items-center gap-3 rounded-md px-3 py-2.5 text-left text-sm transition-colors ${
device.id === selectedRuntimeId ? "bg-accent" : "hover:bg-accent/50"
}`}
>
<ProviderLogo provider={device.provider} className="h-4 w-4 shrink-0" />
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<span className="truncate font-medium">{device.name}</span>
{device.runtime_mode === "cloud" && (
<span className="shrink-0 rounded bg-info/10 px-1.5 py-0.5 text-xs font-medium text-info">
Cloud
</span>
)}
</div>
<div className="truncate text-xs text-muted-foreground">{device.device_info}</div>
</div>
<span
className={`h-2 w-2 shrink-0 rounded-full ${
device.status === "online" ? "bg-success" : "bg-muted-foreground/40"
{filteredRuntimes.map((device) => {
const ownerMember = getOwnerMember(device.owner_id);
return (
<button
key={device.id}
onClick={() => {
setSelectedRuntimeId(device.id);
setRuntimeOpen(false);
}}
className={`flex w-full items-center gap-3 rounded-md px-3 py-2.5 text-left text-sm transition-colors ${
device.id === selectedRuntimeId ? "bg-accent" : "hover:bg-accent/50"
}`}
/>
</button>
))}
>
<ProviderLogo provider={device.provider} className="h-4 w-4 shrink-0" />
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<span className="truncate font-medium">{device.name}</span>
{device.runtime_mode === "cloud" && (
<span className="shrink-0 rounded bg-info/10 px-1.5 py-0.5 text-xs font-medium text-info">
Cloud
</span>
)}
</div>
<div className="mt-0.5 flex items-center gap-1 text-xs text-muted-foreground">
{ownerMember ? (
<>
<ActorAvatar actorType="member" actorId={ownerMember.user_id} size={14} />
<span className="truncate">{ownerMember.name}</span>
</>
) : (
<span className="truncate">{device.device_info}</span>
)}
</div>
</div>
<span
className={`h-2 w-2 shrink-0 rounded-full ${
device.status === "online" ? "bg-success" : "bg-muted-foreground/40"
}`}
/>
</button>
);
})}
</PopoverContent>
</Popover>
</div>

View File

@@ -0,0 +1,126 @@
"use client";
import { useState } from "react";
import {
Loader2,
Save,
Plus,
Trash2,
} from "lucide-react";
import type { Agent } from "@multica/core/types";
import { Button } from "@multica/ui/components/ui/button";
import { Input } from "@multica/ui/components/ui/input";
import { Label } from "@multica/ui/components/ui/label";
import { toast } from "sonner";
interface ArgEntry {
id: string;
value: string;
}
function argsToEntries(args: string[]): ArgEntry[] {
return args.map((value) => ({ id: crypto.randomUUID(), value }));
}
function entriesToArgs(entries: ArgEntry[]): string[] {
return entries.flatMap((e) => e.value.trim().split(/\s+/)).filter(Boolean);
}
export function CustomArgsTab({
agent,
onSave,
}: {
agent: Agent;
onSave: (updates: Partial<Agent>) => Promise<void>;
}) {
const [entries, setEntries] = useState<ArgEntry[]>(
argsToEntries(agent.custom_args ?? []),
);
const [saving, setSaving] = useState(false);
const currentArgs = entriesToArgs(entries);
const originalArgs = agent.custom_args ?? [];
const dirty = JSON.stringify(currentArgs) !== JSON.stringify(originalArgs);
const addEntry = () => {
setEntries([...entries, { id: crypto.randomUUID(), value: "" }]);
};
const removeEntry = (index: number) => {
setEntries(entries.filter((_, i) => i !== index));
};
const updateEntry = (index: number, value: string) => {
setEntries(
entries.map((entry, i) => (i === index ? { ...entry, value } : entry)),
);
};
const handleSave = async () => {
setSaving(true);
try {
await onSave({ custom_args: currentArgs });
toast.success("Custom arguments saved");
} catch {
toast.error("Failed to save custom arguments");
} finally {
setSaving(false);
}
};
return (
<div className="max-w-lg space-y-4">
<div className="flex items-center justify-between">
<div>
<Label className="text-xs text-muted-foreground">
Custom Arguments
</Label>
<p className="text-xs text-muted-foreground mt-0.5">
Additional CLI arguments appended to the agent command at launch
(e.g. --model claude-sonnet-4-20250514)
</p>
</div>
<Button
type="button"
variant="outline"
size="sm"
onClick={addEntry}
className="h-7 gap-1 text-xs"
>
<Plus className="h-3 w-3" />
Add
</Button>
</div>
{entries.length > 0 && (
<div className="space-y-2">
{entries.map((entry, index) => (
<div key={entry.id} className="flex items-center gap-2">
<Input
value={entry.value}
onChange={(e) => updateEntry(index, e.target.value)}
placeholder="--model claude-sonnet-4-20250514"
className="flex-1 font-mono text-xs"
/>
<button
type="button"
onClick={() => removeEntry(index)}
className="shrink-0 text-muted-foreground hover:text-destructive"
>
<Trash2 className="h-3.5 w-3.5" />
</button>
</div>
))}
</div>
)}
<Button onClick={handleSave} disabled={!dirty || saving} size="sm">
{saving ? (
<Loader2 className="h-3.5 w-3.5 mr-1.5 animate-spin" />
) : (
<Save className="h-3.5 w-3.5 mr-1.5" />
)}
Save
</Button>
</div>
);
}

View File

@@ -8,6 +8,7 @@ import {
Trash2,
Eye,
EyeOff,
Lock,
} from "lucide-react";
import type { Agent } from "@multica/core/types";
import { Button } from "@multica/ui/components/ui/button";
@@ -46,9 +47,11 @@ function entriesToEnvMap(entries: EnvEntry[]): Record<string, string> {
export function EnvTab({
agent,
readOnly = false,
onSave,
}: {
agent: Agent;
readOnly?: boolean;
onSave: (updates: Partial<Agent>) => Promise<void>;
}) {
const [envEntries, setEnvEntries] = useState<EnvEntry[]>(
@@ -111,6 +114,45 @@ export function EnvTab({
}
};
if (readOnly) {
return (
<div className="max-w-lg space-y-4">
<div>
<Label className="text-xs text-muted-foreground">
Environment Variables
</Label>
<p className="text-xs text-muted-foreground mt-0.5">
Injected into the agent process at launch. Values are hidden only the agent owner or workspace admin can view and edit them.
</p>
</div>
{envEntries.length > 0 ? (
<div className="space-y-2">
{envEntries.map((entry) => (
<div key={entry.id} className="flex items-center gap-2">
<Input
value={entry.key}
readOnly
className="w-[40%] font-mono text-xs bg-muted"
/>
<div className="relative flex-1">
<Input
type="password"
value="****"
readOnly
className="pr-8 font-mono text-xs bg-muted"
/>
<Lock className="absolute right-2 top-1/2 -translate-y-1/2 h-3.5 w-3.5 text-muted-foreground" />
</div>
</div>
))}
</div>
) : (
<p className="text-xs text-muted-foreground italic">No environment variables configured.</p>
)}
</div>
);
}
return (
<div className="max-w-lg space-y-4">
<div className="flex items-center justify-between">

View File

@@ -1,9 +1,7 @@
"use client";
import { useState, useRef } from "react";
import { useState, useRef, useMemo } from "react";
import {
Cloud,
Monitor,
Loader2,
Save,
Globe,
@@ -11,7 +9,7 @@ import {
Camera,
ChevronDown,
} from "lucide-react";
import type { Agent, AgentVisibility, RuntimeDevice } from "@multica/core/types";
import type { Agent, AgentVisibility, RuntimeDevice, MemberWithUser } from "@multica/core/types";
import {
Popover,
PopoverTrigger,
@@ -24,14 +22,21 @@ import { toast } from "sonner";
import { api } from "@multica/core/api";
import { useFileUpload } from "@multica/core/hooks/use-file-upload";
import { ActorAvatar } from "../../../common/actor-avatar";
import { ProviderLogo } from "../../../runtimes/components/provider-logo";
type RuntimeFilter = "mine" | "all";
export function SettingsTab({
agent,
runtimes,
members,
currentUserId,
onSave,
}: {
agent: Agent;
runtimes: RuntimeDevice[];
members: MemberWithUser[];
currentUserId: string | null;
onSave: (updates: Partial<Agent>) => Promise<void>;
}) {
const [name, setName] = useState(agent.name);
@@ -40,11 +45,31 @@ export function SettingsTab({
const [maxTasks, setMaxTasks] = useState(agent.max_concurrent_tasks);
const [selectedRuntimeId, setSelectedRuntimeId] = useState(agent.runtime_id);
const [runtimeOpen, setRuntimeOpen] = useState(false);
const [runtimeFilter, setRuntimeFilter] = useState<RuntimeFilter>("mine");
const [saving, setSaving] = useState(false);
const { upload, uploading } = useFileUpload(api);
const fileInputRef = useRef<HTMLInputElement>(null);
const getOwnerMember = (ownerId: string | null) => {
if (!ownerId) return null;
return members.find((m) => m.user_id === ownerId) ?? null;
};
const hasOtherRuntimes = runtimes.some((r) => r.owner_id !== currentUserId);
const filteredRuntimes = useMemo(() => {
const filtered = runtimeFilter === "mine" && currentUserId
? runtimes.filter((r) => r.owner_id === currentUserId)
: runtimes;
return [...filtered].sort((a, b) => {
if (a.owner_id === currentUserId && b.owner_id !== currentUserId) return -1;
if (a.owner_id !== currentUserId && b.owner_id === currentUserId) return 1;
return 0;
});
}, [runtimes, runtimeFilter, currentUserId]);
const selectedRuntime = runtimes.find((d) => d.id === selectedRuntimeId) ?? null;
const selectedOwnerMember = selectedRuntime ? getOwnerMember(selectedRuntime.owner_id) : null;
const handleAvatarUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
@@ -191,16 +216,44 @@ export function SettingsTab({
</div>
<div>
<Label className="text-xs text-muted-foreground">Runtime</Label>
<div className="flex items-center justify-between">
<Label className="text-xs text-muted-foreground">Runtime</Label>
{hasOtherRuntimes && (
<div className="flex items-center gap-0.5 rounded-md bg-muted p-0.5">
<button
type="button"
onClick={() => setRuntimeFilter("mine")}
className={`rounded px-2 py-0.5 text-xs font-medium transition-colors ${
runtimeFilter === "mine"
? "bg-background text-foreground shadow-sm"
: "text-muted-foreground hover:text-foreground"
}`}
>
Mine
</button>
<button
type="button"
onClick={() => setRuntimeFilter("all")}
className={`rounded px-2 py-0.5 text-xs font-medium transition-colors ${
runtimeFilter === "all"
? "bg-background text-foreground shadow-sm"
: "text-muted-foreground hover:text-foreground"
}`}
>
All
</button>
</div>
)}
</div>
<Popover open={runtimeOpen} onOpenChange={setRuntimeOpen}>
<PopoverTrigger
disabled={runtimes.length === 0}
className="flex w-full items-center gap-3 rounded-lg border border-border bg-background px-3 py-2.5 mt-1.5 text-left text-sm transition-colors hover:bg-muted disabled:pointer-events-none disabled:opacity-50"
>
{selectedRuntime?.runtime_mode === "cloud" ? (
<Cloud className="h-4 w-4 shrink-0 text-muted-foreground" />
{selectedRuntime ? (
<ProviderLogo provider={selectedRuntime.provider} className="h-4 w-4 shrink-0" />
) : (
<Monitor className="h-4 w-4 shrink-0 text-muted-foreground" />
<ProviderLogo provider="" className="h-4 w-4 shrink-0" />
)}
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
@@ -214,46 +267,56 @@ export function SettingsTab({
)}
</div>
<div className="truncate text-xs text-muted-foreground">
{selectedRuntime?.device_info ?? "Select a runtime"}
{selectedRuntime ? (
selectedOwnerMember ? selectedOwnerMember.name : selectedRuntime.device_info
) : "Select a runtime"}
</div>
</div>
<ChevronDown className={`h-4 w-4 shrink-0 text-muted-foreground transition-transform ${runtimeOpen ? "rotate-180" : ""}`} />
</PopoverTrigger>
<PopoverContent align="start" className="w-[var(--anchor-width)] p-1 max-h-60 overflow-y-auto">
{runtimes.map((device) => (
<button
key={device.id}
onClick={() => {
setSelectedRuntimeId(device.id);
setRuntimeOpen(false);
}}
className={`flex w-full items-center gap-3 rounded-md px-3 py-2.5 text-left text-sm transition-colors ${
device.id === selectedRuntimeId ? "bg-accent" : "hover:bg-accent/50"
}`}
>
{device.runtime_mode === "cloud" ? (
<Cloud className="h-4 w-4 shrink-0 text-muted-foreground" />
) : (
<Monitor className="h-4 w-4 shrink-0 text-muted-foreground" />
)}
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<span className="truncate font-medium">{device.name}</span>
{device.runtime_mode === "cloud" && (
<span className="shrink-0 rounded bg-info/10 px-1.5 py-0.5 text-xs font-medium text-info">
Cloud
</span>
)}
</div>
<div className="truncate text-xs text-muted-foreground">{device.device_info}</div>
</div>
<span
className={`h-2 w-2 shrink-0 rounded-full ${
device.status === "online" ? "bg-success" : "bg-muted-foreground/40"
{filteredRuntimes.map((device) => {
const ownerMember = getOwnerMember(device.owner_id);
return (
<button
key={device.id}
onClick={() => {
setSelectedRuntimeId(device.id);
setRuntimeOpen(false);
}}
className={`flex w-full items-center gap-3 rounded-md px-3 py-2.5 text-left text-sm transition-colors ${
device.id === selectedRuntimeId ? "bg-accent" : "hover:bg-accent/50"
}`}
/>
</button>
))}
>
<ProviderLogo provider={device.provider} className="h-4 w-4 shrink-0" />
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<span className="truncate font-medium">{device.name}</span>
{device.runtime_mode === "cloud" && (
<span className="shrink-0 rounded bg-info/10 px-1.5 py-0.5 text-xs font-medium text-info">
Cloud
</span>
)}
</div>
<div className="mt-0.5 flex items-center gap-1 text-xs text-muted-foreground">
{ownerMember ? (
<>
<ActorAvatar actorType="member" actorId={ownerMember.user_id} size={14} />
<span className="truncate">{ownerMember.name}</span>
</>
) : (
<span className="truncate">{device.device_info}</span>
)}
</div>
</div>
<span
className={`h-2 w-2 shrink-0 rounded-full ${
device.status === "online" ? "bg-success" : "bg-muted-foreground/40"
}`}
/>
</button>
);
})}
</PopoverContent>
</Popover>
</div>

View File

@@ -0,0 +1,552 @@
"use client";
import { useState, useEffect } from "react";
import { Zap, Play, Pause, Clock, Plus, Trash2, CheckCircle2, XCircle, Loader2, Pencil } from "lucide-react";
import { useQuery } from "@tanstack/react-query";
import { autopilotDetailOptions, autopilotRunsOptions } from "@multica/core/autopilots/queries";
import {
useUpdateAutopilot,
useDeleteAutopilot,
useTriggerAutopilot,
useCreateAutopilotTrigger,
useDeleteAutopilotTrigger,
} from "@multica/core/autopilots/mutations";
import { agentListOptions } from "@multica/core/workspace/queries";
import { useWorkspaceId } from "@multica/core/hooks";
import { useActorName } from "@multica/core/workspace/hooks";
import { useNavigation, AppLink } from "../../navigation";
import { ActorAvatar } from "../../common/actor-avatar";
import { Skeleton } from "@multica/ui/components/ui/skeleton";
import { Button } from "@multica/ui/components/ui/button";
import { cn } from "@multica/ui/lib/utils";
import { toast } from "sonner";
import {
Dialog,
DialogContent,
DialogTitle,
} from "@multica/ui/components/ui/dialog";
import {
Select,
SelectTrigger,
SelectValue,
SelectContent,
SelectItem,
} from "@multica/ui/components/ui/select";
import {
TriggerConfigSection,
getDefaultTriggerConfig,
toCronExpression,
} from "./trigger-config";
import type { TriggerConfig } from "./trigger-config";
import type { AutopilotRun, AutopilotTrigger } from "@multica/core/types";
function formatDate(date: string): string {
return new Date(date).toLocaleString(undefined, {
month: "short",
day: "numeric",
hour: "2-digit",
minute: "2-digit",
});
}
const RUN_STATUS_CONFIG: Record<string, { label: string; color: string; icon: typeof CheckCircle2 }> = {
issue_created: { label: "Issue Created", color: "text-blue-500", icon: Clock },
running: { label: "Running", color: "text-blue-500", icon: Loader2 },
completed: { label: "Completed", color: "text-emerald-500", icon: CheckCircle2 },
failed: { label: "Failed", color: "text-destructive", icon: XCircle },
};
function RunRow({ run }: { run: AutopilotRun }) {
const cfg = (RUN_STATUS_CONFIG[run.status] ?? RUN_STATUS_CONFIG["issue_created"])!;
const StatusIcon = cfg.icon;
return (
<div className="flex items-center gap-3 px-4 py-2.5 text-sm hover:bg-accent/30 transition-colors">
<StatusIcon className={cn("h-4 w-4 shrink-0", cfg.color)} />
<span className={cn("w-24 shrink-0 text-xs font-medium", cfg.color)}>{cfg.label}</span>
<span className="w-16 shrink-0 text-xs text-muted-foreground capitalize">{run.source}</span>
<span className="flex-1 min-w-0 text-xs text-muted-foreground truncate">
{run.issue_id ? (
<AppLink href={`/issues/${run.issue_id}`} className="hover:underline">
Issue linked
</AppLink>
) : run.failure_reason ? (
<span className="text-destructive">{run.failure_reason}</span>
) : null}
</span>
<span className="w-32 shrink-0 text-right text-xs text-muted-foreground tabular-nums">
{formatDate(run.triggered_at || run.created_at)}
</span>
</div>
);
}
function TriggerRow({ trigger, autopilotId }: { trigger: AutopilotTrigger; autopilotId: string }) {
const deleteTrigger = useDeleteAutopilotTrigger();
return (
<div className="flex items-center gap-3 rounded-md border px-3 py-2">
<Clock className="h-4 w-4 shrink-0 text-muted-foreground" />
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<span className="text-sm font-medium capitalize">{trigger.kind}</span>
{trigger.label && (
<span className="text-xs text-muted-foreground">({trigger.label})</span>
)}
{!trigger.enabled && (
<span className="text-xs bg-muted px-1.5 py-0.5 rounded">Disabled</span>
)}
</div>
{trigger.cron_expression && (
<div className="text-xs text-muted-foreground mt-0.5">
{trigger.cron_expression}
{trigger.timezone && ` (${trigger.timezone})`}
</div>
)}
{trigger.next_run_at && (
<div className="text-xs text-muted-foreground">
Next: {formatDate(trigger.next_run_at)}
</div>
)}
</div>
<Button
size="icon"
variant="ghost"
className="h-7 w-7 shrink-0"
onClick={() => {
deleteTrigger.mutate({ autopilotId, triggerId: trigger.id });
toast.success("Trigger deleted");
}}
>
<Trash2 className="h-3.5 w-3.5 text-muted-foreground" />
</Button>
</div>
);
}
const PRIORITY_OPTIONS = [
{ value: "urgent", label: "Urgent" },
{ value: "high", label: "High" },
{ value: "medium", label: "Medium" },
{ value: "low", label: "Low" },
{ value: "none", label: "None" },
];
const EXECUTION_MODE_OPTIONS = [
{ value: "create_issue", label: "Create Issue" },
{ value: "run_only", label: "Run Only" },
];
function EditAutopilotDialog({
open,
onOpenChange,
autopilot,
agents,
}: {
open: boolean;
onOpenChange: (open: boolean) => void;
autopilot: { id: string; title: string; description?: string | null; assignee_id: string; priority: string; execution_mode: string; issue_title_template?: string | null };
agents: { id: string; name: string; archived_at?: string | null }[];
}) {
const updateAutopilot = useUpdateAutopilot();
const [title, setTitle] = useState(autopilot.title);
const [description, setDescription] = useState(autopilot.description ?? "");
const [assigneeId, setAssigneeId] = useState(autopilot.assignee_id);
const [priority, setPriority] = useState(autopilot.priority);
const [executionMode, setExecutionMode] = useState(autopilot.execution_mode);
const [submitting, setSubmitting] = useState(false);
const activeAgents = agents.filter((a) => !a.archived_at);
// Sync form when autopilot data changes (e.g. after optimistic update)
useEffect(() => {
setTitle(autopilot.title);
setDescription(autopilot.description ?? "");
setAssigneeId(autopilot.assignee_id);
setPriority(autopilot.priority);
setExecutionMode(autopilot.execution_mode);
}, [autopilot]);
const handleSubmit = async () => {
if (!title.trim() || !assigneeId || submitting) return;
setSubmitting(true);
try {
await updateAutopilot.mutateAsync({
id: autopilot.id,
title: title.trim(),
description: description.trim() || null,
assignee_id: assigneeId,
priority,
execution_mode: executionMode as "create_issue" | "run_only",
});
onOpenChange(false);
toast.success("Autopilot updated");
} catch {
toast.error("Failed to update autopilot");
} finally {
setSubmitting(false);
}
};
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-lg">
<DialogTitle>Edit Autopilot</DialogTitle>
<div className="space-y-4 pt-2">
{/* Name */}
<div>
<label className="text-xs font-medium text-muted-foreground">Name</label>
<input
type="text"
value={title}
onChange={(e) => setTitle(e.target.value)}
placeholder="e.g. Daily code review"
className="mt-1 w-full rounded-md border bg-background px-3 py-2 text-sm outline-none focus:ring-1 focus:ring-ring"
autoFocus
/>
</div>
{/* Prompt */}
<div>
<label className="text-xs font-medium text-muted-foreground">Prompt</label>
<textarea
value={description}
onChange={(e) => setDescription(e.target.value)}
placeholder="Step-by-step instructions for the agent..."
rows={6}
className="mt-1 w-full rounded-md border bg-background px-3 py-2 text-sm outline-none focus:ring-1 focus:ring-ring resize-y"
/>
</div>
{/* Agent + Priority */}
<div className="grid grid-cols-2 gap-3">
<div>
<label className="text-xs font-medium text-muted-foreground">Agent</label>
<Select value={assigneeId} onValueChange={(v) => v && setAssigneeId(v)}>
<SelectTrigger className="mt-1 w-full">
<SelectValue>
{(value: string | null) => {
if (!value) return "Select agent...";
const agent = activeAgents.find((a) => a.id === value);
return agent?.name ?? "Unknown Agent";
}}
</SelectValue>
</SelectTrigger>
<SelectContent>
{activeAgents.map((a) => (
<SelectItem key={a.id} value={a.id}>{a.name}</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div>
<label className="text-xs font-medium text-muted-foreground">Priority</label>
<Select value={priority} onValueChange={(v) => v && setPriority(v)}>
<SelectTrigger className="mt-1 w-full">
<SelectValue>
{(value: string | null) => PRIORITY_OPTIONS.find((o) => o.value === value)?.label ?? "Medium"}
</SelectValue>
</SelectTrigger>
<SelectContent>
{PRIORITY_OPTIONS.map((o) => (
<SelectItem key={o.value} value={o.value}>{o.label}</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
{/* Execution Mode */}
<div>
<label className="text-xs font-medium text-muted-foreground">Execution Mode</label>
<Select value={executionMode} onValueChange={(v) => v && setExecutionMode(v)}>
<SelectTrigger className="mt-1 w-full">
<SelectValue>
{(value: string | null) => EXECUTION_MODE_OPTIONS.find((o) => o.value === value)?.label ?? "Create Issue"}
</SelectValue>
</SelectTrigger>
<SelectContent>
{EXECUTION_MODE_OPTIONS.map((o) => (
<SelectItem key={o.value} value={o.value}>{o.label}</SelectItem>
))}
</SelectContent>
</Select>
</div>
{/* Actions */}
<div className="flex justify-end gap-2 pt-1">
<Button size="sm" variant="outline" onClick={() => onOpenChange(false)}>
Cancel
</Button>
<Button size="sm" onClick={handleSubmit} disabled={!title.trim() || !assigneeId || submitting}>
{submitting ? "Saving..." : "Save"}
</Button>
</div>
</div>
</DialogContent>
</Dialog>
);
}
function AddTriggerDialog({
open,
onOpenChange,
autopilotId,
}: {
open: boolean;
onOpenChange: (open: boolean) => void;
autopilotId: string;
}) {
const createTrigger = useCreateAutopilotTrigger();
const [config, setConfig] = useState<TriggerConfig>(getDefaultTriggerConfig);
const [label, setLabel] = useState("");
const [submitting, setSubmitting] = useState(false);
const handleSubmit = async () => {
if (submitting) return;
const cronExpr = toCronExpression(config);
if (!cronExpr.trim()) return;
setSubmitting(true);
try {
await createTrigger.mutateAsync({
autopilotId,
kind: "schedule",
cron_expression: cronExpr,
timezone: config.timezone || undefined,
label: label.trim() || undefined,
});
onOpenChange(false);
setConfig(getDefaultTriggerConfig());
setLabel("");
toast.success("Trigger added");
} catch {
toast.error("Failed to add trigger");
} finally {
setSubmitting(false);
}
};
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-sm">
<DialogTitle>Add Trigger</DialogTitle>
<div className="space-y-4 pt-2">
<TriggerConfigSection config={config} onChange={setConfig} />
<div>
<label className="text-xs font-medium text-muted-foreground">Label (optional)</label>
<input
type="text"
value={label}
onChange={(e) => setLabel(e.target.value)}
placeholder="e.g. Weekday morning"
className="mt-1 w-full rounded-md border bg-background px-3 py-2 text-sm outline-none focus:ring-1 focus:ring-ring"
/>
</div>
<div className="flex justify-end pt-1">
<Button size="sm" onClick={handleSubmit} disabled={submitting}>
{submitting ? "Adding..." : "Add trigger"}
</Button>
</div>
</div>
</DialogContent>
</Dialog>
);
}
export function AutopilotDetailPage({ autopilotId }: { autopilotId: string }) {
const wsId = useWorkspaceId();
const router = useNavigation();
const { getActorName } = useActorName();
const { data, isLoading } = useQuery(autopilotDetailOptions(wsId, autopilotId));
const { data: runs = [], isLoading: runsLoading } = useQuery(autopilotRunsOptions(wsId, autopilotId));
const { data: agents = [] } = useQuery(agentListOptions(wsId));
const updateAutopilot = useUpdateAutopilot();
const deleteAutopilot = useDeleteAutopilot();
const triggerAutopilot = useTriggerAutopilot();
const [triggerDialogOpen, setTriggerDialogOpen] = useState(false);
const [editDialogOpen, setEditDialogOpen] = useState(false);
if (isLoading) {
return (
<div className="p-6 space-y-4">
<Skeleton className="h-8 w-64" />
<Skeleton className="h-40 w-full" />
</div>
);
}
if (!data) {
return (
<div className="flex items-center justify-center h-full text-muted-foreground">
Autopilot not found
</div>
);
}
const { autopilot, triggers } = data;
const handleRunNow = async () => {
try {
await triggerAutopilot.mutateAsync(autopilotId);
toast.success("Autopilot triggered");
} catch (e: any) {
toast.error(e?.message || "Failed to trigger autopilot");
}
};
const handleDelete = async () => {
try {
await deleteAutopilot.mutateAsync(autopilotId);
toast.success("Autopilot deleted");
router.push("/autopilots");
} catch {
toast.error("Failed to delete autopilot");
}
};
const handleToggleStatus = () => {
const newStatus = autopilot.status === "active" ? "paused" : "active";
updateAutopilot.mutate({ id: autopilotId, status: newStatus });
};
return (
<div className="flex h-full flex-col">
{/* Header */}
<div className="flex h-12 shrink-0 items-center justify-between border-b px-5">
<div className="flex items-center gap-2">
<AppLink href="/autopilots" className="text-muted-foreground hover:text-foreground transition-colors">
<Zap className="h-4 w-4" />
</AppLink>
<span className="text-muted-foreground">/</span>
<h1 className="text-sm font-medium truncate">{autopilot.title}</h1>
<span className={cn(
"ml-1 inline-flex items-center gap-1 rounded px-1.5 py-0.5 text-xs font-medium",
autopilot.status === "active" ? "bg-emerald-500/10 text-emerald-500" :
autopilot.status === "paused" ? "bg-amber-500/10 text-amber-500" :
"bg-muted text-muted-foreground",
)}>
{autopilot.status}
</span>
</div>
<div className="flex items-center gap-2">
<Button size="sm" variant="outline" onClick={() => setEditDialogOpen(true)}>
<Pencil className="h-3.5 w-3.5 mr-1" />
Edit
</Button>
<Button size="sm" variant="outline" onClick={handleToggleStatus}>
{autopilot.status === "active" ? (
<><Pause className="h-3.5 w-3.5 mr-1" /> Pause</>
) : (
<><Play className="h-3.5 w-3.5 mr-1" /> Activate</>
)}
</Button>
<Button size="sm" onClick={handleRunNow} disabled={autopilot.status !== "active" || triggerAutopilot.isPending}>
<Play className="h-3.5 w-3.5 mr-1" />
{triggerAutopilot.isPending ? "Running..." : "Run now"}
</Button>
</div>
</div>
<div className="flex-1 overflow-y-auto">
<div className="max-w-4xl mx-auto p-6 space-y-8">
{/* Properties */}
<section className="space-y-4">
<h2 className="text-sm font-medium text-muted-foreground uppercase tracking-wider">Properties</h2>
<div className="grid grid-cols-2 gap-4 text-sm">
<div>
<label className="text-xs text-muted-foreground">Agent</label>
<div className="mt-1 flex items-center gap-2">
<ActorAvatar actorType="agent" actorId={autopilot.assignee_id} size={20} />
<span>{getActorName("agent", autopilot.assignee_id)}</span>
</div>
</div>
<div>
<label className="text-xs text-muted-foreground">Priority</label>
<div className="mt-1 capitalize">{autopilot.priority}</div>
</div>
<div>
<label className="text-xs text-muted-foreground">Execution Mode</label>
<div className="mt-1">
{autopilot.execution_mode === "create_issue" ? "Create Issue" : "Run Only"}
</div>
</div>
{autopilot.description && (
<div className="col-span-2">
<label className="text-xs text-muted-foreground">Prompt</label>
<div className="mt-1 whitespace-pre-wrap text-sm">{autopilot.description}</div>
</div>
)}
</div>
</section>
{/* Triggers */}
<section className="space-y-3">
<div className="flex items-center justify-between">
<h2 className="text-sm font-medium text-muted-foreground uppercase tracking-wider">Triggers</h2>
<Button size="sm" variant="outline" onClick={() => setTriggerDialogOpen(true)}>
<Plus className="h-3.5 w-3.5 mr-1" />
Add trigger
</Button>
</div>
{triggers.length === 0 ? (
<div className="rounded-md border border-dashed p-4 text-center text-sm text-muted-foreground">
No triggers configured. Add a schedule to run automatically.
</div>
) : (
<div className="space-y-2">
{triggers.map((t) => (
<TriggerRow key={t.id} trigger={t} autopilotId={autopilotId} />
))}
</div>
)}
</section>
{/* Run History */}
<section className="space-y-3">
<h2 className="text-sm font-medium text-muted-foreground uppercase tracking-wider">Run History</h2>
{runsLoading ? (
<div className="space-y-1">
{Array.from({ length: 3 }).map((_, i) => (
<Skeleton key={i} className="h-10 w-full" />
))}
</div>
) : runs.length === 0 ? (
<div className="rounded-md border border-dashed p-4 text-center text-sm text-muted-foreground">
No runs yet. Click &quot;Run now&quot; to trigger manually.
</div>
) : (
<div className="rounded-md border overflow-hidden">
{runs.map((run) => (
<RunRow key={run.id} run={run} />
))}
</div>
)}
</section>
{/* Danger zone */}
<section className="space-y-3 pt-4 border-t">
<h2 className="text-sm font-medium text-destructive uppercase tracking-wider">Danger Zone</h2>
<Button size="sm" variant="destructive" onClick={handleDelete}>
<Trash2 className="h-3.5 w-3.5 mr-1" />
Delete autopilot
</Button>
</section>
</div>
</div>
<AddTriggerDialog
open={triggerDialogOpen}
onOpenChange={setTriggerDialogOpen}
autopilotId={autopilotId}
/>
<EditAutopilotDialog
open={editDialogOpen}
onOpenChange={setEditDialogOpen}
autopilot={autopilot}
agents={agents}
/>
</div>
);
}

View File

@@ -0,0 +1,423 @@
"use client";
import { useState } from "react";
import { Plus, Zap, Play, Pause, AlertCircle, Newspaper, GitPullRequest, Bug, BarChart3, Shield, FileSearch } from "lucide-react";
import { useQuery } from "@tanstack/react-query";
import { autopilotListOptions } from "@multica/core/autopilots/queries";
import { useCreateAutopilot, useCreateAutopilotTrigger } from "@multica/core/autopilots/mutations";
import { agentListOptions } from "@multica/core/workspace/queries";
import { useWorkspaceId } from "@multica/core/hooks";
import { useActorName } from "@multica/core/workspace/hooks";
import { AppLink } from "../../navigation";
import { ActorAvatar } from "../../common/actor-avatar";
import { Skeleton } from "@multica/ui/components/ui/skeleton";
import { Button } from "@multica/ui/components/ui/button";
import { cn } from "@multica/ui/lib/utils";
import { toast } from "sonner";
import {
Dialog,
DialogContent,
DialogTitle,
} from "@multica/ui/components/ui/dialog";
import {
Select,
SelectTrigger,
SelectValue,
SelectContent,
SelectItem,
} from "@multica/ui/components/ui/select";
import {
TriggerConfigSection,
getDefaultTriggerConfig,
toCronExpression,
} from "./trigger-config";
import type { TriggerConfig } from "./trigger-config";
import type { Autopilot } from "@multica/core/types";
import type { TriggerFrequency } from "./trigger-config";
interface AutopilotTemplate {
title: string;
prompt: string;
summary: string;
icon: typeof Zap;
frequency: TriggerFrequency;
time: string;
}
const TEMPLATES: AutopilotTemplate[] = [
{
title: "Daily news digest",
summary: "Search and summarize today's news for the team",
prompt: `1. Search the web for news and announcements published today only (strictly today's date)
2. Filter for topics relevant to our team and industry
3. For each item, write a short summary including: title, source, key takeaways
4. Compile everything into a single digest post
5. Post the digest as a comment on this issue and @mention all workspace members`,
icon: Newspaper,
frequency: "daily",
time: "09:00",
},
{
title: "PR review reminder",
summary: "Flag stale pull requests that need review",
prompt: `1. List all open pull requests in the repository
2. Identify PRs that have been open for more than 24 hours without a review
3. For each stale PR, note the author, age, and a one-line summary of the change
4. Post a comment on this issue listing all stale PRs with links
5. @mention the team to remind them to review`,
icon: GitPullRequest,
frequency: "weekdays",
time: "10:00",
},
{
title: "Bug triage",
summary: "Assess and prioritize new bug reports",
prompt: `1. List all issues with status "triage" or "backlog" that have not been prioritized
2. For each issue, read the description and any attached logs or screenshots
3. Assess severity (critical / high / medium / low) based on user impact and scope
4. Set the priority field on the issue accordingly
5. Add a comment explaining your assessment and suggested next steps`,
icon: Bug,
frequency: "weekdays",
time: "09:00",
},
{
title: "Weekly progress report",
summary: "Compile a weekly summary of team progress",
prompt: `1. Gather all issues completed (status "done") in the past 7 days
2. Gather all issues currently in progress
3. Identify any blocked issues and their blockers
4. Calculate key metrics: issues closed, issues opened, net change
5. Write a structured weekly report with sections: Completed, In Progress, Blocked, Metrics
6. Post the report as a comment on this issue`,
icon: BarChart3,
frequency: "weekly",
time: "17:00",
},
{
title: "Dependency audit",
summary: "Scan for security vulnerabilities and outdated packages",
prompt: `1. Run dependency audit tools on the project (npm audit, go vuln check, etc.)
2. Identify any packages with known security vulnerabilities
3. List outdated packages that are more than 2 major versions behind
4. For each finding, note the severity, affected package, and recommended fix
5. Post a summary report as a comment with actionable items`,
icon: Shield,
frequency: "weekly",
time: "08:00",
},
{
title: "Documentation check",
summary: "Review recent changes for documentation gaps",
prompt: `1. List all code changes merged in the past 7 days (via git log)
2. For each significant change, check if related documentation was updated
3. Identify any new APIs, config options, or features missing documentation
4. Create a list of documentation gaps with file paths and suggested content
5. Post the findings as a comment on this issue`,
icon: FileSearch,
frequency: "weekly",
time: "14:00",
},
];
function formatRelativeDate(date: string): string {
const diff = Date.now() - new Date(date).getTime();
const days = Math.floor(diff / (1000 * 60 * 60 * 24));
if (days < 1) return "Today";
if (days === 1) return "1d ago";
if (days < 30) return `${days}d ago`;
const months = Math.floor(days / 30);
return `${months}mo ago`;
}
const STATUS_CONFIG: Record<string, { label: string; color: string; icon: typeof Zap }> = {
active: { label: "Active", color: "text-emerald-500", icon: Play },
paused: { label: "Paused", color: "text-amber-500", icon: Pause },
archived: { label: "Archived", color: "text-muted-foreground", icon: AlertCircle },
};
const EXECUTION_MODE_LABELS: Record<string, string> = {
create_issue: "Create Issue",
run_only: "Run Only",
};
function AutopilotRow({ autopilot }: { autopilot: Autopilot }) {
const { getActorName } = useActorName();
const statusCfg = (STATUS_CONFIG[autopilot.status] ?? STATUS_CONFIG["active"])!;
const StatusIcon = statusCfg.icon;
return (
<div className="group/row flex h-11 items-center gap-2 px-5 text-sm transition-colors hover:bg-accent/40">
<AppLink
href={`/autopilots/${autopilot.id}`}
className="flex min-w-0 flex-1 items-center gap-2"
>
<Zap className="h-4 w-4 shrink-0 text-muted-foreground" />
<span className="min-w-0 flex-1 truncate font-medium">{autopilot.title}</span>
</AppLink>
{/* Agent */}
<span className="flex w-32 items-center gap-1.5 shrink-0">
<ActorAvatar actorType="agent" actorId={autopilot.assignee_id} size={18} />
<span className="truncate text-xs text-muted-foreground">
{getActorName("agent", autopilot.assignee_id)}
</span>
</span>
{/* Mode */}
<span className="w-24 shrink-0 text-center text-xs text-muted-foreground">
{EXECUTION_MODE_LABELS[autopilot.execution_mode] ?? autopilot.execution_mode}
</span>
{/* Status */}
<span className={cn("flex w-20 items-center justify-center gap-1 shrink-0 text-xs", statusCfg.color)}>
<StatusIcon className="h-3 w-3" />
{statusCfg.label}
</span>
{/* Last run */}
<span className="w-20 shrink-0 text-right text-xs text-muted-foreground tabular-nums">
{autopilot.last_run_at ? formatRelativeDate(autopilot.last_run_at) : "--"}
</span>
</div>
);
}
function CreateAutopilotDialog({
open,
onOpenChange,
template,
}: {
open: boolean;
onOpenChange: (open: boolean) => void;
template?: AutopilotTemplate | null;
}) {
const wsId = useWorkspaceId();
const { data: agents = [] } = useQuery(agentListOptions(wsId));
const createAutopilot = useCreateAutopilot();
const createTrigger = useCreateAutopilotTrigger();
const [title, setTitle] = useState("");
const [description, setDescription] = useState("");
const [assigneeId, setAssigneeId] = useState("");
const [triggerConfig, setTriggerConfig] = useState<TriggerConfig>(getDefaultTriggerConfig);
const [submitting, setSubmitting] = useState(false);
// Apply template when it changes
const [appliedTemplate, setAppliedTemplate] = useState<AutopilotTemplate | null | undefined>(null);
if (template !== appliedTemplate && open) {
setAppliedTemplate(template);
if (template) {
setTitle(template.title);
setDescription(template.prompt);
setTriggerConfig({
...getDefaultTriggerConfig(),
frequency: template.frequency,
time: template.time,
});
}
}
const activeAgents = agents.filter((a) => !a.archived_at);
const handleSubmit = async () => {
if (!title.trim() || !assigneeId || submitting) return;
setSubmitting(true);
try {
const autopilot = await createAutopilot.mutateAsync({
title: title.trim(),
description: description.trim() || undefined,
assignee_id: assigneeId,
execution_mode: "create_issue",
});
// Attach schedule trigger
try {
await createTrigger.mutateAsync({
autopilotId: autopilot.id,
kind: "schedule",
cron_expression: toCronExpression(triggerConfig),
timezone: triggerConfig.timezone,
});
} catch {
toast.error("Autopilot created, but trigger failed to save");
}
onOpenChange(false);
setTitle("");
setDescription("");
setAssigneeId("");
setTriggerConfig(getDefaultTriggerConfig());
toast.success("Autopilot created");
} catch {
toast.error("Failed to create autopilot");
} finally {
setSubmitting(false);
}
};
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-lg">
<DialogTitle>New Autopilot</DialogTitle>
<div className="space-y-5 pt-2">
{/* Name */}
<div>
<label className="text-xs font-medium text-muted-foreground">Name</label>
<input
type="text"
value={title}
onChange={(e) => setTitle(e.target.value)}
placeholder="e.g. Daily code review"
className="mt-1 w-full rounded-md border bg-background px-3 py-2 text-sm outline-none focus:ring-1 focus:ring-ring"
autoFocus
/>
</div>
{/* Prompt */}
<div>
<label className="text-xs font-medium text-muted-foreground">Prompt</label>
<textarea
value={description}
onChange={(e) => setDescription(e.target.value)}
placeholder="Step-by-step instructions for the agent..."
rows={6}
className="mt-1 w-full rounded-md border bg-background px-3 py-2 text-sm outline-none focus:ring-1 focus:ring-ring resize-y"
/>
</div>
{/* Agent */}
<div>
<label className="text-xs font-medium text-muted-foreground">Agent</label>
<Select value={assigneeId} onValueChange={(v) => v && setAssigneeId(v)}>
<SelectTrigger className="mt-1 w-full">
<SelectValue>
{(value: string | null) => {
if (!value) return "Select agent...";
const agent = activeAgents.find((a) => a.id === value);
return agent?.name ?? "Unknown Agent";
}}
</SelectValue>
</SelectTrigger>
<SelectContent>
{activeAgents.map((a) => (
<SelectItem key={a.id} value={a.id}>
{a.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{/* Schedule */}
<div>
<label className="text-xs font-medium text-muted-foreground">Schedule</label>
<div className="mt-2">
<TriggerConfigSection config={triggerConfig} onChange={setTriggerConfig} />
</div>
</div>
{/* Actions */}
<div className="flex justify-end gap-2 pt-1">
<Button size="sm" variant="outline" onClick={() => onOpenChange(false)}>
Cancel
</Button>
<Button size="sm" onClick={handleSubmit} disabled={!title.trim() || !assigneeId || submitting}>
{submitting ? "Creating..." : "Create"}
</Button>
</div>
</div>
</DialogContent>
</Dialog>
);
}
export function AutopilotsPage() {
const wsId = useWorkspaceId();
const { data: autopilots = [], isLoading } = useQuery(autopilotListOptions(wsId));
const [createOpen, setCreateOpen] = useState(false);
const [selectedTemplate, setSelectedTemplate] = useState<AutopilotTemplate | null>(null);
const openCreate = (template?: AutopilotTemplate) => {
setSelectedTemplate(template ?? null);
setCreateOpen(true);
};
return (
<div className="flex h-full flex-col">
{/* Header */}
<div className="flex h-12 shrink-0 items-center justify-between border-b px-5">
<div className="flex items-center gap-2">
<Zap className="h-4 w-4 text-muted-foreground" />
<h1 className="text-sm font-medium">Autopilot</h1>
{!isLoading && autopilots.length > 0 && (
<span className="text-xs text-muted-foreground tabular-nums">{autopilots.length}</span>
)}
</div>
<Button size="sm" variant="outline" onClick={() => openCreate()}>
<Plus className="h-3.5 w-3.5 mr-1" />
New autopilot
</Button>
</div>
{/* Table */}
<div className="flex-1 overflow-y-auto">
{isLoading ? (
<div className="p-5 space-y-1">
{Array.from({ length: 4 }).map((_, i) => (
<Skeleton key={i} className="h-11 w-full" />
))}
</div>
) : autopilots.length === 0 ? (
<div className="flex flex-col items-center py-16 px-5">
<Zap className="h-10 w-10 mb-3 text-muted-foreground opacity-30" />
<p className="text-sm text-muted-foreground">No autopilots yet</p>
<p className="text-xs text-muted-foreground mt-1 mb-6">
Schedule recurring tasks for your AI agents. Pick a template or start from scratch.
</p>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-3 w-full max-w-3xl">
{TEMPLATES.map((t) => {
const Icon = t.icon;
return (
<button
key={t.title}
type="button"
className="flex items-start gap-3 rounded-lg border p-3 text-left transition-colors hover:bg-accent/40"
onClick={() => openCreate(t)}
>
<Icon className="h-5 w-5 shrink-0 text-muted-foreground mt-0.5" />
<div className="min-w-0">
<div className="text-sm font-medium">{t.title}</div>
<div className="text-xs text-muted-foreground mt-0.5 line-clamp-2">{t.summary}</div>
</div>
</button>
);
})}
</div>
<Button size="sm" variant="outline" className="mt-4" onClick={() => openCreate()}>
<Plus className="h-3.5 w-3.5 mr-1" />
Start from scratch
</Button>
</div>
) : (
<>
{/* Column headers */}
<div className="sticky top-0 z-[1] flex h-8 items-center gap-2 border-b bg-muted/30 px-5 text-xs font-medium text-muted-foreground">
<span className="shrink-0 w-4" />
<span className="min-w-0 flex-1">Name</span>
<span className="w-32 shrink-0">Agent</span>
<span className="w-24 text-center shrink-0">Mode</span>
<span className="w-20 text-center shrink-0">Status</span>
<span className="w-20 text-right shrink-0">Last run</span>
</div>
{autopilots.map((autopilot) => (
<AutopilotRow key={autopilot.id} autopilot={autopilot} />
))}
</>
)}
</div>
<CreateAutopilotDialog open={createOpen} onOpenChange={setCreateOpen} template={selectedTemplate} />
</div>
);
}

View File

@@ -0,0 +1,2 @@
export { AutopilotsPage } from "./autopilots-page";
export { AutopilotDetailPage } from "./autopilot-detail-page";

View File

@@ -0,0 +1,284 @@
"use client";
import { useMemo } from "react";
import { cn } from "@multica/ui/lib/utils";
import {
Select,
SelectTrigger,
SelectValue,
SelectContent,
SelectItem,
} from "@multica/ui/components/ui/select";
export type TriggerFrequency = "hourly" | "daily" | "weekdays" | "weekly" | "custom";
export interface TriggerConfig {
frequency: TriggerFrequency;
time: string; // HH:MM
dayOfWeek: number; // 0=Sun … 6=Sat
cronExpression: string; // only used when frequency === "custom"
timezone: string; // IANA
}
// ---------------------------------------------------------------------------
// Constants
// ---------------------------------------------------------------------------
const FREQUENCIES: { value: TriggerFrequency; label: string }[] = [
{ value: "hourly", label: "Hourly" },
{ value: "daily", label: "Daily" },
{ value: "weekdays", label: "Weekdays" },
{ value: "weekly", label: "Weekly" },
{ value: "custom", label: "Custom" },
];
const DAYS_OF_WEEK = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"];
const COMMON_TIMEZONES = [
"UTC",
"America/New_York",
"America/Chicago",
"America/Denver",
"America/Los_Angeles",
"America/Sao_Paulo",
"Europe/London",
"Europe/Paris",
"Europe/Berlin",
"Europe/Moscow",
"Asia/Dubai",
"Asia/Kolkata",
"Asia/Singapore",
"Asia/Shanghai",
"Asia/Tokyo",
"Asia/Seoul",
"Australia/Sydney",
"Pacific/Auckland",
];
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
export function getLocalTimezone(): string {
try {
return Intl.DateTimeFormat().resolvedOptions().timeZone;
} catch {
return "UTC";
}
}
function getTimezoneOffset(tz: string): string {
if (tz === "UTC") return "UTC";
try {
const parts = new Intl.DateTimeFormat("en-US", {
timeZone: tz,
timeZoneName: "shortOffset",
}).formatToParts(new Date());
return parts.find((p) => p.type === "timeZoneName")?.value ?? tz;
} catch {
return tz;
}
}
function getTimezoneLabel(tz: string): string {
if (tz === "UTC") return "UTC";
const city = tz.split("/").pop()?.replace(/_/g, " ") ?? tz;
return `${city} (${getTimezoneOffset(tz)})`;
}
function formatTime12h(time: string): string {
const [h, m] = time.split(":");
const hour = parseInt(h ?? "9", 10);
const min = parseInt(m ?? "0", 10);
const ampm = hour >= 12 ? "PM" : "AM";
return `${hour % 12 || 12}:${min.toString().padStart(2, "0")} ${ampm}`;
}
// ---------------------------------------------------------------------------
// Public helpers
// ---------------------------------------------------------------------------
export function getDefaultTriggerConfig(): TriggerConfig {
return {
frequency: "daily",
time: "09:00",
dayOfWeek: 1,
cronExpression: "0 9 * * 1-5",
timezone: getLocalTimezone(),
};
}
export function toCronExpression(cfg: TriggerConfig): string {
const [h, m] = cfg.time.split(":");
const hour = parseInt(h ?? "9", 10);
const min = parseInt(m ?? "0", 10);
switch (cfg.frequency) {
case "hourly":
return `${min} * * * *`;
case "daily":
return `${min} ${hour} * * *`;
case "weekdays":
return `${min} ${hour} * * 1-5`;
case "weekly":
return `${min} ${hour} * * ${cfg.dayOfWeek}`;
case "custom":
return cfg.cronExpression;
}
}
export function describeTrigger(cfg: TriggerConfig): string {
const offset = getTimezoneOffset(cfg.timezone);
switch (cfg.frequency) {
case "hourly": {
const min = parseInt(cfg.time.split(":")[1] ?? "0", 10);
return `Runs every hour at :${min.toString().padStart(2, "0")}`;
}
case "daily":
return `Runs daily at ${formatTime12h(cfg.time)} ${offset}`;
case "weekdays":
return `Runs weekdays at ${formatTime12h(cfg.time)} ${offset}`;
case "weekly":
return `Runs every ${DAYS_OF_WEEK[cfg.dayOfWeek]} at ${formatTime12h(cfg.time)} ${offset}`;
case "custom":
return `Custom schedule: ${cfg.cronExpression}`;
}
}
// ---------------------------------------------------------------------------
// Component
// ---------------------------------------------------------------------------
export function TriggerConfigSection({
config,
onChange,
}: {
config: TriggerConfig;
onChange: (config: TriggerConfig) => void;
}) {
const timezones = useMemo(() => {
const local = getLocalTimezone();
const set = new Set(COMMON_TIMEZONES);
return set.has(local) ? COMMON_TIMEZONES : [local, ...COMMON_TIMEZONES];
}, []);
return (
<div className="space-y-3">
{/* Frequency tabs */}
<div className="flex flex-wrap gap-1">
{FREQUENCIES.map((f) => (
<button
key={f.value}
type="button"
className={cn(
"rounded-md px-3 py-1.5 text-xs font-medium transition-colors",
config.frequency === f.value
? "bg-foreground text-background"
: "bg-muted text-muted-foreground hover:text-foreground",
)}
onClick={() => onChange({ ...config, frequency: f.value })}
>
{f.label}
</button>
))}
</div>
{config.frequency === "custom" ? (
/* Custom cron input */
<div>
<label className="text-xs text-muted-foreground">Cron Expression</label>
<input
type="text"
value={config.cronExpression}
onChange={(e) => onChange({ ...config, cronExpression: e.target.value })}
placeholder="0 9 * * 1-5"
className="mt-1 w-full rounded-md border bg-background px-3 py-2 text-sm font-mono outline-none focus:ring-1 focus:ring-ring"
/>
<p className="text-xs text-muted-foreground mt-1">
Standard 5-field cron (min hour dom month dow)
</p>
</div>
) : (
<>
{/* Time + Timezone row */}
<div className="flex gap-3">
{config.frequency === "hourly" ? (
<div className="w-24">
<label className="text-xs text-muted-foreground">Minute</label>
<input
type="number"
min={0}
max={59}
value={parseInt(config.time.split(":")[1] ?? "0", 10)}
onChange={(e) => {
const min = Math.max(0, Math.min(59, parseInt(e.target.value) || 0));
onChange({ ...config, time: `00:${min.toString().padStart(2, "0")}` });
}}
className="mt-1 w-full rounded-md border bg-background px-3 py-2 text-sm font-mono outline-none focus:ring-1 focus:ring-ring"
/>
</div>
) : (
<>
<div className="w-28">
<label className="text-xs text-muted-foreground">Time</label>
<input
type="time"
value={config.time}
onChange={(e) => onChange({ ...config, time: e.target.value || config.time })}
className="mt-1 w-full rounded-md border bg-background px-3 py-2 text-sm font-mono outline-none focus:ring-1 focus:ring-ring"
/>
</div>
<div className="flex-1 min-w-0">
<label className="text-xs text-muted-foreground">Timezone</label>
<Select
value={config.timezone}
onValueChange={(v) => v && onChange({ ...config, timezone: v })}
>
<SelectTrigger className="mt-1 w-full">
<SelectValue>
{() => getTimezoneLabel(config.timezone)}
</SelectValue>
</SelectTrigger>
<SelectContent>
{timezones.map((tz) => (
<SelectItem key={tz} value={tz}>
{getTimezoneLabel(tz)}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</>
)}
</div>
{/* Day-of-week selector for weekly */}
{config.frequency === "weekly" && (
<div>
<label className="text-xs text-muted-foreground">Day</label>
<div className="flex gap-1 mt-1">
{DAYS_OF_WEEK.map((day, i) => (
<button
key={day}
type="button"
className={cn(
"rounded-md px-2.5 py-1 text-xs font-medium transition-colors",
config.dayOfWeek === i
? "bg-foreground text-background"
: "bg-muted text-muted-foreground hover:text-foreground",
)}
onClick={() => onChange({ ...config, dayOfWeek: i })}
>
{day}
</button>
))}
</div>
</div>
)}
</>
)}
{/* Human-readable preview */}
<p className="text-xs text-muted-foreground">{describeTrigger(config)}</p>
</div>
);
}

View File

@@ -14,15 +14,8 @@ interface ChatInputProps {
onStop?: () => void;
isRunning?: boolean;
disabled?: boolean;
/** Name of the currently selected agent, used in the default placeholder. */
/** Name of the currently selected agent, used in the placeholder. */
agentName?: string;
/**
* Full override for the placeholder text. When present, supersedes the
* agentName-based default and the archived-session message. Caller uses
* this to communicate agent-availability reasons (archived agent,
* no_agents, etc.).
*/
placeholderOverride?: string;
/** Rendered at the bottom-left of the input bar — typically the agent picker. */
leftAdornment?: ReactNode;
}
@@ -33,7 +26,6 @@ export function ChatInput({
isRunning,
disabled,
agentName,
placeholderOverride,
leftAdornment,
}: ChatInputProps) {
const editorRef = useRef<ContentEditorRef>(null);
@@ -74,13 +66,11 @@ export function ChatInput({
setIsEmpty(true);
};
const placeholder =
placeholderOverride ??
(disabled
? "This session is archived"
: agentName
? `Tell ${agentName} what to do…`
: "Tell me what to do…");
const placeholder = disabled
? "This session is archived"
: agentName
? `Tell ${agentName} what to do…`
: "Tell me what to do…";
return (
<div className="px-5 pb-3 pt-0">

View File

@@ -34,62 +34,11 @@ import { ChatInput } from "./chat-input";
import { ChatResizeHandles } from "./chat-resize-handles";
import { useChatResize } from "./use-chat-resize";
import { createLogger } from "@multica/core/logger";
import { toast } from "sonner";
import type { Agent, ChatMessage, ChatSession } from "@multica/core/types";
const uiLogger = createLogger("chat.ui");
const apiLogger = createLogger("chat.api");
/**
* What we know about the agent the UI is currently tied to, plus whether
* the user can actually send in this state. Derived each render from the
* current session, selected agent, and available agents.
*/
type AgentUnavailableReason =
| "no_agents" // workspace has no available agents at all
| "archived" // agent exists but is archived (read-only session)
| "missing"; // session refers to an agent that no longer exists
interface AgentState {
/** Agent to display (possibly archived). Null when nothing to show. */
agent: Agent | null;
/** Whether the user can send a message in this state. */
canSend: boolean;
/** Why the user can't send. Absent when canSend is true. */
reason?: AgentUnavailableReason;
}
function sendBlockedMessage(reason: AgentUnavailableReason | undefined): string {
switch (reason) {
case "no_agents":
return "No agents available — create one first";
case "archived":
return "This agent is archived and can't receive messages";
case "missing":
return "This session's agent no longer exists";
default:
return "Can't send right now";
}
}
function placeholderFor(
reason: AgentUnavailableReason | undefined,
agentName: string | undefined,
isSessionArchived: boolean,
): string {
if (isSessionArchived) return "This session is archived";
switch (reason) {
case "no_agents":
return "Create an agent to start chatting";
case "archived":
return "This agent is archived — conversation is read-only";
case "missing":
return "This session's agent is no longer available";
default:
return agentName ? `Tell ${agentName} what to do…` : "Tell me what to do…";
}
}
export function ChatWindow() {
const wsId = useWorkspaceId();
const isOpen = useChatStore((s) => s.isOpen);
@@ -123,6 +72,12 @@ export function ChatWindow() {
);
const pendingTaskId = pendingTask?.task_id ?? null;
// Check if current session is archived
const currentSession = activeSessionId
? allSessions.find((s) => s.id === activeSessionId)
: null;
const isSessionArchived = currentSession?.status === "archived";
const qc = useQueryClient();
const createSession = useCreateChatSession();
const markRead = useMarkChatSessionRead();
@@ -133,33 +88,11 @@ export function ChatWindow() {
(a) => !a.archived_at && canAssignAgent(a, user?.id, memberRole),
);
// Current session (may be null for a fresh new chat). Used both to bound
// the agent we show and to flag read-only sessions below.
const currentSession = activeSessionId
? allSessions.find((s) => s.id === activeSessionId)
: null;
const isSessionArchived = currentSession?.status === "archived";
// Resolve which agent the UI is tied to, plus whether the user can send.
// Priority when a session is active: the session's bound agent from the
// FULL list (may be archived — we still render it, read-only). Without a
// session we pick the user's preference from the available set.
const agentState = useMemo<AgentState>(() => {
if (currentSession) {
const bound = agents.find((a) => a.id === currentSession.agent_id) ?? null;
if (!bound) return { agent: null, canSend: false, reason: "missing" };
if (bound.archived_at) return { agent: bound, canSend: false, reason: "archived" };
return { agent: bound, canSend: true };
}
const picked =
availableAgents.find((a) => a.id === selectedAgentId) ??
availableAgents[0] ??
null;
if (picked) return { agent: picked, canSend: true };
return { agent: null, canSend: false, reason: "no_agents" };
}, [currentSession, agents, availableAgents, selectedAgentId]);
const activeAgent = agentState.agent;
// Resolve selected agent: stored preference → first available
const activeAgent =
availableAgents.find((a) => a.id === selectedAgentId) ??
availableAgents[0] ??
null;
// Mount / unmount logging. ChatWindow lives in DashboardLayout, so this
// fires on layout mount (login / workspace switch / fresh page load).
@@ -223,11 +156,8 @@ export function ChatWindow() {
const handleSend = useCallback(
async (content: string) => {
if (!agentState.canSend || !activeAgent) {
apiLogger.warn("sendChatMessage skipped", { reason: agentState.reason });
// Surface why — handleSend is usually triggered by button or Enter,
// silent failure is confusing.
toast.error(sendBlockedMessage(agentState.reason));
if (!activeAgent) {
apiLogger.warn("sendChatMessage skipped: no active agent");
return;
}
@@ -241,59 +171,47 @@ export function ChatWindow() {
contentLength: content.length,
});
try {
if (!sessionId) {
const session = await createSession.mutateAsync({
agent_id: activeAgent.id,
title: content.slice(0, 50),
});
sessionId = session.id;
setActiveSession(sessionId);
}
// Optimistic: show user message immediately.
const optimistic: ChatMessage = {
id: `optimistic-${Date.now()}`,
chat_session_id: sessionId,
role: "user",
content,
task_id: null,
created_at: new Date().toISOString(),
};
qc.setQueryData<ChatMessage[]>(
chatKeys.messages(sessionId),
(old) => (old ? [...old, optimistic] : [optimistic]),
);
apiLogger.debug("sendChatMessage.optimistic", { sessionId, optimisticId: optimistic.id });
const result = await api.sendChatMessage(sessionId, content);
apiLogger.info("sendChatMessage.success", {
sessionId,
messageId: result.message_id,
taskId: result.task_id,
if (!sessionId) {
const session = await createSession.mutateAsync({
agent_id: activeAgent.id,
title: content.slice(0, 50),
});
// Seed pending-task optimistically so the spinner shows instantly —
// the WS chat:message handler will invalidate + refetch to confirm.
qc.setQueryData(chatKeys.pendingTask(sessionId), {
task_id: result.task_id,
status: "queued",
});
qc.invalidateQueries({ queryKey: chatKeys.messages(sessionId) });
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
apiLogger.error("sendChatMessage.error", { err });
// Drop the optimistic message — refetch the real list so the user's
// bubble doesn't dangle without a reply.
if (sessionId) {
qc.invalidateQueries({ queryKey: chatKeys.messages(sessionId) });
}
toast.error(`Failed to send: ${message}`);
sessionId = session.id;
setActiveSession(sessionId);
}
// Optimistic: show user message immediately.
const optimistic: ChatMessage = {
id: `optimistic-${Date.now()}`,
chat_session_id: sessionId,
role: "user",
content,
task_id: null,
created_at: new Date().toISOString(),
};
qc.setQueryData<ChatMessage[]>(
chatKeys.messages(sessionId),
(old) => (old ? [...old, optimistic] : [optimistic]),
);
apiLogger.debug("sendChatMessage.optimistic", { sessionId, optimisticId: optimistic.id });
const result = await api.sendChatMessage(sessionId, content);
apiLogger.info("sendChatMessage.success", {
sessionId,
messageId: result.message_id,
taskId: result.task_id,
});
// Seed pending-task optimistically so the spinner shows instantly —
// the WS chat:message handler will invalidate + refetch to confirm.
qc.setQueryData(chatKeys.pendingTask(sessionId), {
task_id: result.task_id,
status: "queued",
});
qc.invalidateQueries({ queryKey: chatKeys.messages(sessionId) });
},
[
activeSessionId,
activeAgent,
agentState,
createSession,
setActiveSession,
qc,
@@ -472,23 +390,17 @@ export function ChatWindow() {
) : (
<EmptyState
agentName={activeAgent?.name}
reason={agentState.reason}
onPickPrompt={(text) => handleSend(text)}
/>
)}
{/* Input — disabled for archived sessions or when no agent can accept */}
{/* Input — disabled for archived sessions */}
<ChatInput
onSend={handleSend}
onStop={handleStop}
isRunning={!!pendingTaskId}
disabled={isSessionArchived || !agentState.canSend}
disabled={isSessionArchived}
agentName={activeAgent?.name}
placeholderOverride={placeholderFor(
agentState.reason,
activeAgent?.name,
!!isSessionArchived,
)}
leftAdornment={
<AgentDropdown
agents={availableAgents}
@@ -685,25 +597,11 @@ const STARTER_PROMPTS: { icon: string; text: string }[] = [
function EmptyState({
agentName,
reason,
onPickPrompt,
}: {
agentName?: string;
reason?: AgentUnavailableReason;
onPickPrompt: (text: string) => void;
}) {
// Can't chat → show the reason instead of the starter prompts.
if (reason === "no_agents") {
return (
<div className="flex flex-1 flex-col items-center justify-center gap-2 px-6 py-8 text-center">
<h3 className="text-base font-semibold">No agents yet</h3>
<p className="text-sm text-muted-foreground max-w-xs">
Create an agent from the Agents tab to start chatting.
</p>
</div>
);
}
return (
<div className="flex flex-1 flex-col items-center justify-center gap-5 px-6 py-8">
<div className="text-center space-y-1">

View File

@@ -6,6 +6,7 @@ import {
type MarkdownProps as MarkdownBaseProps,
type RenderMode,
} from "@multica/ui/markdown";
import { useConfigStore } from "@multica/core/config";
import { IssueMentionCard } from "../issues/components/issue-mention-card";
export type { RenderMode };
@@ -30,27 +31,13 @@ function defaultRenderMention({
}
/**
* App-level Markdown wrapper that injects IssueMentionCard via renderMention.
* Callers that need custom mention rendering can pass their own renderMention prop.
* App-level Markdown wrapper that injects IssueMentionCard via renderMention
* and cdnDomain from the config store for file card rendering.
*/
export function Markdown(props: MarkdownProps): React.JSX.Element {
return <MarkdownBase renderMention={defaultRenderMention} {...props} />;
const cdnDomain = useConfigStore((s) => s.cdnDomain);
return <MarkdownBase renderMention={defaultRenderMention} cdnDomain={cdnDomain} {...props} />;
}
export const MemoizedMarkdown = React.memo(
Markdown,
(prevProps, nextProps) => {
if (prevProps.id && nextProps.id) {
return (
prevProps.id === nextProps.id &&
prevProps.children === nextProps.children &&
prevProps.mode === nextProps.mode
);
}
return (
prevProps.children === nextProps.children &&
prevProps.mode === nextProps.mode
);
},
);
export const MemoizedMarkdown = React.memo(Markdown);
MemoizedMarkdown.displayName = "MemoizedMarkdown";

View File

@@ -12,6 +12,7 @@
import { useState, useEffect, useCallback, useRef } from "react";
import { BubbleMenu } from "@tiptap/react/menus";
import { useEditorState } from "@tiptap/react";
import type { Editor } from "@tiptap/core";
import { NodeSelection } from "@tiptap/pm/state";
import type { EditorState } from "@tiptap/pm/state";
@@ -112,12 +113,14 @@ function MarkButton({
icon: Icon,
label,
shortcut,
isActive,
}: {
editor: Editor;
mark: InlineMark;
icon: React.ComponentType<{ className?: string }>;
label: string;
shortcut: string;
isActive: boolean;
}) {
return (
<Tooltip>
@@ -125,7 +128,7 @@ function MarkButton({
render={
<Toggle
size="sm"
pressed={editor.isActive(mark)}
pressed={isActive}
onPressedChange={() => toggleMarkActions[mark](editor)}
onMouseDown={(e) => e.preventDefault()}
/>
@@ -239,9 +242,8 @@ function LinkEditBar({
// Heading Dropdown
// ---------------------------------------------------------------------------
function HeadingDropdown({ editor, onOpenChange }: { editor: Editor; onOpenChange: (open: boolean) => void }) {
function HeadingDropdown({ editor, onOpenChange, activeLevel }: { editor: Editor; onOpenChange: (open: boolean) => void; activeLevel: number | undefined }) {
const [open, setOpen] = useState(false);
const activeLevel = [1, 2, 3].find((l) => editor.isActive("heading", { level: l }));
const label = activeLevel ? `H${activeLevel}` : "Text";
const items = [
{ label: "Normal Text", icon: Type, active: !activeLevel, action: () => editor.chain().focus().setParagraph().run() },
@@ -296,10 +298,8 @@ function HeadingDropdown({ editor, onOpenChange }: { editor: Editor; onOpenChang
// List Dropdown
// ---------------------------------------------------------------------------
function ListDropdown({ editor, onOpenChange }: { editor: Editor; onOpenChange: (open: boolean) => void }) {
function ListDropdown({ editor, onOpenChange, isBullet, isOrdered }: { editor: Editor; onOpenChange: (open: boolean) => void; isBullet: boolean; isOrdered: boolean }) {
const [open, setOpen] = useState(false);
const isBullet = editor.isActive("bulletList");
const isOrdered = editor.isActive("orderedList");
const handleOpenChange = useCallback((next: boolean) => {
setOpen(next);
@@ -359,10 +359,42 @@ function ListDropdown({ editor, onOpenChange }: { editor: Editor; onOpenChange:
function EditorBubbleMenu({ editor }: { editor: Editor }) {
const [mode, setMode] = useState<"toolbar" | "link-edit">("toolbar");
const [scrollTarget, setScrollTarget] = useState<HTMLElement | Window>(window);
const menuElRef = useRef<HTMLDivElement>(null);
// Find the real scroll container once on mount
// Precise subscription to formatting state — only re-renders when these
// values actually change, replacing direct editor.isActive() calls that
// relied on the parent re-rendering on every transaction.
const fmt = useEditorState({
editor,
selector: ({ editor: e }) => ({
bold: e.isActive("bold"),
italic: e.isActive("italic"),
strike: e.isActive("strike"),
code: e.isActive("code"),
link: e.isActive("link"),
blockquote: e.isActive("blockquote"),
bulletList: e.isActive("bulletList"),
orderedList: e.isActive("orderedList"),
heading1: e.isActive("heading", { level: 1 }),
heading2: e.isActive("heading", { level: 2 }),
heading3: e.isActive("heading", { level: 3 }),
}),
});
// Find the real scroll container once the editor view is ready.
// editor.view.dom throws if the view hasn't been mounted yet or has been
// destroyed — the Proxy only stubs state/isDestroyed, everything else throws.
// This race happens on fast page transitions in Desktop (Inbox switching)
// because useEditor delays destruction via setTimeout(..., 1) for StrictMode
// survival (TipTap issue #7346).
useEffect(() => {
setScrollTarget(getScrollParent(editor.view.dom));
const detect = () => {
if (!editor.isInitialized) return; // view not ready yet
setScrollTarget(getScrollParent(editor.view.dom));
};
detect();
editor.on("create", detect);
return () => { editor.off("create", detect); };
}, [editor]);
// Hide when the selection scrolls outside the scroll container's
@@ -384,7 +416,14 @@ function EditorBubbleMenu({ editor }: { editor: Editor }) {
}
return;
}
const coords = editor.view.coordsAtPos(editor.state.selection.from);
// editor.view.coordsAtPos throws if the view has been destroyed
// during a fast unmount race (same Proxy guard as view.dom above).
let coords: { top: number };
try {
coords = editor.view.coordsAtPos(editor.state.selection.from);
} catch {
return;
}
const rect = el.getBoundingClientRect();
const visible = coords.top >= rect.top && coords.top <= rect.bottom;
if (scrollHiddenRef.current !== !visible) {
@@ -418,6 +457,7 @@ function EditorBubbleMenu({ editor }: { editor: Editor }) {
return (
<BubbleMenu
ref={menuElRef}
editor={editor}
shouldShow={shouldShowBubbleMenu}
updateDelay={0}
@@ -433,6 +473,17 @@ function EditorBubbleMenu({ editor }: { editor: Editor }) {
shift: { padding: 8 },
hide: true,
scrollTarget,
// Tiptap's React wrapper initialises the menu element with
// position:absolute, but computePosition (called right after
// show()) needs position:fixed so that getOffsetParent returns
// the viewport instead of a positioned ancestor. Without this,
// the first positioning computes coordinates relative to the
// wrong containing block and the menu flies off-screen.
onShow: () => {
if (menuElRef.current) {
menuElRef.current.style.position = "fixed";
}
},
}}
>
{mode === "link-edit" ? (
@@ -440,25 +491,25 @@ function EditorBubbleMenu({ editor }: { editor: Editor }) {
) : (
<TooltipProvider delay={300}>
<div className="bubble-menu">
<MarkButton editor={editor} mark="bold" icon={Bold} label="Bold" shortcut={`${mod}+B`} />
<MarkButton editor={editor} mark="italic" icon={Italic} label="Italic" shortcut={`${mod}+I`} />
<MarkButton editor={editor} mark="strike" icon={Strikethrough} label="Strikethrough" shortcut={`${mod}+Shift+S`} />
<MarkButton editor={editor} mark="code" icon={Code} label="Code" shortcut={`${mod}+E`} />
<MarkButton editor={editor} mark="bold" icon={Bold} label="Bold" shortcut={`${mod}+B`} isActive={fmt.bold} />
<MarkButton editor={editor} mark="italic" icon={Italic} label="Italic" shortcut={`${mod}+I`} isActive={fmt.italic} />
<MarkButton editor={editor} mark="strike" icon={Strikethrough} label="Strikethrough" shortcut={`${mod}+Shift+S`} isActive={fmt.strike} />
<MarkButton editor={editor} mark="code" icon={Code} label="Code" shortcut={`${mod}+E`} isActive={fmt.code} />
<Separator orientation="vertical" className="mx-0.5 h-5" />
<Tooltip>
<TooltipTrigger render={
<Toggle size="sm" pressed={editor.isActive("link")} onPressedChange={() => setMode("link-edit")} onMouseDown={(e) => e.preventDefault()} />
<Toggle size="sm" pressed={fmt.link} onPressedChange={() => setMode("link-edit")} onMouseDown={(e) => e.preventDefault()} />
}>
<Link2 className="size-3.5" />
</TooltipTrigger>
<TooltipContent side="top" sideOffset={8}>Link</TooltipContent>
</Tooltip>
<Separator orientation="vertical" className="mx-0.5 h-5" />
<HeadingDropdown editor={editor} onOpenChange={handleMenuOpenChange} />
<ListDropdown editor={editor} onOpenChange={handleMenuOpenChange} />
<HeadingDropdown editor={editor} onOpenChange={handleMenuOpenChange} activeLevel={fmt.heading1 ? 1 : fmt.heading2 ? 2 : fmt.heading3 ? 3 : undefined} />
<ListDropdown editor={editor} onOpenChange={handleMenuOpenChange} isBullet={fmt.bulletList} isOrdered={fmt.orderedList} />
<Tooltip>
<TooltipTrigger render={
<Toggle size="sm" pressed={editor.isActive("blockquote")} onPressedChange={() => editor.chain().focus().toggleBlockquote().run()} onMouseDown={(e) => e.preventDefault()} />
<Toggle size="sm" pressed={fmt.blockquote} onPressedChange={() => editor.chain().focus().toggleBlockquote().run()} onMouseDown={(e) => e.preventDefault()} />
}>
<Quote className="size-3.5" />
</TooltipTrigger>

View File

@@ -39,6 +39,7 @@ import { useQueryClient } from "@tanstack/react-query";
import { createEditorExtensions } from "./extensions";
import { uploadAndInsertFile } from "./extensions/file-upload";
import { preprocessMarkdown } from "./utils/preprocess";
import { openLink, isMentionHref } from "./utils/link-handler";
import { EditorBubbleMenu } from "./bubble-menu";
import { useLinkHover, LinkHoverCard } from "./link-hover-card";
import "./content-editor.css";
@@ -122,6 +123,9 @@ const ContentEditor = forwardRef<ContentEditorRef, ContentEditorProps>(
const editor = useEditor({
immediatelyRender: false,
// Note: in v3.22.1 the default is already false/undefined (same behavior).
// Explicit for clarity — the real perf win is useEditorState in BubbleMenu.
shouldRerenderOnTransaction: false,
editable,
content: defaultValue ? preprocessMarkdown(defaultValue) : "",
contentType: defaultValue ? "markdown" : undefined,
@@ -152,18 +156,10 @@ const ContentEditor = forwardRef<ContentEditorRef, ContentEditorProps>(
const link = target.closest("a");
const href = link?.getAttribute("href");
if (!href || href.startsWith("mention://")) return false;
if (!href || isMentionHref(href)) return false;
// Open the link. Internal paths use multica:navigate
// (Electron hash-router safe), external open in new tab.
event.preventDefault();
if (href.startsWith("/")) {
window.dispatchEvent(
new CustomEvent("multica:navigate", { detail: { path: href } }),
);
} else {
window.open(href, "_blank", "noopener,noreferrer");
}
openLink(href);
return true;
},
},

View File

@@ -4,9 +4,14 @@
* FileCard — Tiptap node extension for rendering uploaded non-image files
* as styled cards instead of plain markdown links.
*
* Markdown serialization: `[filename](href)` — standard link syntax.
* Preprocessing in preprocess.ts converts standalone CDN file links back
* to fileCard HTML on load, completing the roundtrip.
* Markdown serialization: `!file[filename](href)` — custom syntax that is
* unambiguous (standard `[name](url)` is indistinguishable from regular links).
*
* Loading pipeline: preprocessFileCards in preprocess.ts converts both the
* new `!file[name](url)` syntax AND legacy `[name](cdnUrl)` lines into HTML
* divs BEFORE @tiptap/markdown parses the content. The markdownTokenizer
* below acts as a fallback for any direct markdown parsing that bypasses
* preprocessing.
*/
import { Node, mergeAttributes } from "@tiptap/core";
@@ -15,30 +20,6 @@ import type { NodeViewProps } from "@tiptap/react";
import { FileText, Loader2, Download } from "lucide-react";
// ---------------------------------------------------------------------------
// CDN URL detection
// ---------------------------------------------------------------------------
const IMAGE_EXTS = /\.(png|jpe?g|gif|webp|svg|ico|bmp|tiff?)$/i;
/** Check if a URL points to our upload CDN (CloudFront or S3 bucket). */
export function isCdnUrl(url: string): boolean {
try {
const u = new URL(url);
return (
u.hostname.endsWith(".copilothub.ai") ||
u.hostname.endsWith(".amazonaws.com")
);
} catch {
return false;
}
}
/** Check if a CDN URL is a non-image file that should render as a file card. */
export function isFileCardUrl(url: string): boolean {
return isCdnUrl(url) && !IMAGE_EXTS.test(new URL(url).pathname);
}
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
@@ -146,10 +127,31 @@ export const FileCardExtension = Node.create({
];
},
// Markdown serialization: fileCard → [filename](href)
// Markdown: custom !file[name](url) syntax for unambiguous roundtrip.
// Standard [name](url) is indistinguishable from regular links — the old
// regex-based CDN hostname matching in preprocessFileCards was fragile.
markdownTokenizer: {
name: "fileCard",
level: "block" as const,
start(src: string) {
return src.search(/^!file\[/m);
},
tokenize(src: string) {
const match = src.match(/^!file\[([^\]]*)\]\((https?:\/\/[^)]+)\)/);
if (!match) return undefined;
return {
type: "fileCard",
raw: match[0],
attributes: { filename: match[1], href: match[2] },
};
},
},
parseMarkdown: (token: any, helpers: any) => {
return helpers.createNode("fileCard", token.attributes);
},
renderMarkdown: (node: any) => {
const { href, filename } = node.attrs || {};
return `[${filename || "file"}](${href})`;
return `!file[${filename || "file"}](${href})`;
},
addNodeView() {

View File

@@ -15,20 +15,7 @@ import { computePosition, offset, flip, shift } from "@floating-ui/dom";
import { ExternalLink, Copy } from "lucide-react";
import { toast } from "sonner";
import { Button } from "@multica/ui/components/ui/button";
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
function openLink(href: string) {
if (href.startsWith("/")) {
window.dispatchEvent(
new CustomEvent("multica:navigate", { detail: { path: href } }),
);
} else {
window.open(href, "_blank", "noopener,noreferrer");
}
}
import { openLink, isMentionHref } from "./utils/link-handler";
function truncateUrl(url: string, max = 48): string {
if (url.length <= max) return url;
@@ -77,7 +64,10 @@ function useLinkHover(containerRef: React.RefObject<HTMLElement | null>, disable
const link = target.closest("a") as HTMLAnchorElement | null;
if (!link) return;
const href = link.getAttribute("href");
if (!href || href.startsWith("mention://")) return;
if (!href || isMentionHref(href)) return;
// Issue mention cards render as <a class="issue-mention"> — they
// display their own rich info, a URL hover card is redundant.
if (link.classList.contains("issue-mention")) return;
clearTimeout(hideTimer.current);
showTimer.current = window.setTimeout(() => {

View File

@@ -34,6 +34,7 @@ import { useNavigation } from "../navigation";
import { IssueMentionCard } from "../issues/components/issue-mention-card";
import { ImageLightbox } from "./extensions/image-view";
import { useLinkHover, LinkHoverCard } from "./link-hover-card";
import { openLink, isMentionHref } from "./utils/link-handler";
import { preprocessMarkdown } from "./utils/preprocess";
import "./content-editor.css";
@@ -112,7 +113,7 @@ function IssueMentionLink({ issueId, label }: { issueId: string; label?: string
const components: Partial<Components> = {
// Links — route mention:// to mention components, others show preview card
a: ({ href, children }) => {
if (href?.startsWith("mention://")) {
if (isMentionHref(href)) {
const match = href.match(
/^mention:\/\/(member|agent|issue|all)\/(.+)$/,
);
@@ -135,14 +136,7 @@ const components: Partial<Components> = {
href={href}
onClick={(e) => {
e.preventDefault();
if (!href) return;
if (href.startsWith("/")) {
window.dispatchEvent(
new CustomEvent("multica:navigate", { detail: { path: href } }),
);
} else {
window.open(href, "_blank", "noopener,noreferrer");
}
if (href) openLink(href);
}}
>
{children}

View File

@@ -0,0 +1,22 @@
/**
* Shared link handling utilities for the editor system.
*
* Used by content-editor (ProseMirror click handler), readonly-content
* (react-markdown link component), and link-hover-card (Open button).
*/
/** Open a link — internal paths dispatch multica:navigate, external open new tab. */
export function openLink(href: string): void {
if (href.startsWith("/")) {
window.dispatchEvent(
new CustomEvent("multica:navigate", { detail: { path: href } }),
);
} else {
window.open(href, "_blank", "noopener,noreferrer");
}
}
/** Check if a href is a mention protocol link (should not be opened as a regular link). */
export function isMentionHref(href: string | null | undefined): href is string {
return !!href && href.startsWith("mention://");
}

View File

@@ -1,6 +1,5 @@
import { preprocessLinks } from "@multica/ui/markdown";
import { preprocessMentionShortcodes } from "@multica/ui/markdown";
import { isFileCardUrl } from "../extensions/file-card";
import { preprocessLinks, preprocessMentionShortcodes, preprocessFileCards } from "@multica/ui/markdown";
import { configStore } from "@multica/core/config";
/**
* Preprocess a markdown string before loading into Tiptap via contentType: 'markdown'.
@@ -13,40 +12,14 @@ import { isFileCardUrl } from "../extensions/file-card";
* 1. Legacy mention shortcodes [@ id="..." label="..."] → [@Label](mention://member/id)
* (old serialization format in database, migrated on read)
* 2. Raw URLs → markdown links via linkify-it (so they render as clickable Link nodes)
* 3. CDN file links on their own line → HTML div for fileCard node parsing
* 3. File card syntax (new !file[name](url) + legacy [name](cdnUrl)) → HTML div for
* fileCard node parsing
*/
export function preprocessMarkdown(markdown: string): string {
if (!markdown) return "";
const cdnDomain = configStore.getState().cdnDomain;
const step1 = preprocessMentionShortcodes(markdown);
const step2 = preprocessLinks(step1);
const step3 = preprocessFileCards(step2);
const step3 = preprocessFileCards(step2, cdnDomain);
return step3;
}
/**
* Convert standalone `[name](cdnUrl)` lines into HTML that Tiptap's fileCard
* parseHTML can recognise. Only matches non-image CDN URLs on their own line.
*
* Input: `[report.pdf](https://multica-static.copilothub.ai/xxx.pdf)`
* Output: `<div data-type="fileCard" data-href="url" data-filename="report.pdf"></div>`
*/
const FILE_LINK_LINE = /^\[([^\]]+)\]\((https?:\/\/[^)]+)\)$/;
function escapeAttr(s: string): string {
return s.replace(/&/g, "&amp;").replace(/"/g, "&quot;").replace(/</g, "&lt;");
}
function preprocessFileCards(markdown: string): string {
return markdown
.split("\n")
.map((line) => {
const trimmed = line.trim();
const match = trimmed.match(FILE_LINK_LINE);
if (!match) return line;
const filename = match[1]!;
const url = match[2]!;
if (!isFileCardUrl(url)) return line;
return `<div data-type="fileCard" data-href="${escapeAttr(url)}" data-filename="${escapeAttr(filename)}"></div>`;
})
.join("\n");
}

View File

@@ -0,0 +1,2 @@
export { InvitePage } from "./invite-page";
export type { InvitePageProps } from "./invite-page";

View File

@@ -0,0 +1,185 @@
"use client";
import { useState } from "react";
import { useQuery } from "@tanstack/react-query";
import { api } from "@multica/core/api";
import { useWorkspaceStore } from "@multica/core/workspace";
import { workspaceKeys, workspaceListOptions } from "@multica/core/workspace/queries";
import { useQueryClient } from "@tanstack/react-query";
import { useNavigation } from "../navigation";
import { Button } from "@multica/ui/components/ui/button";
import { Card, CardContent } from "@multica/ui/components/ui/card";
import { Users, Check, X } from "lucide-react";
export interface InvitePageProps {
invitationId: string;
}
export function InvitePage({ invitationId }: InvitePageProps) {
const { push } = useNavigation();
const switchWorkspace = useWorkspaceStore((s) => s.switchWorkspace);
const qc = useQueryClient();
const [accepting, setAccepting] = useState(false);
const [declining, setDeclining] = useState(false);
const [error, setError] = useState<string | null>(null);
const [done, setDone] = useState<"accepted" | "declined" | null>(null);
const { data: invitation, isLoading, error: fetchError } = useQuery({
queryKey: ["invitation", invitationId],
queryFn: () => api.getInvitation(invitationId),
});
const handleAccept = async () => {
setAccepting(true);
setError(null);
try {
await api.acceptInvitation(invitationId);
setDone("accepted");
// Refresh workspace list and switch to the new workspace.
const wsList = await qc.fetchQuery({ ...workspaceListOptions(), staleTime: 0 });
const ws = wsList.find((w) => w.id === invitation?.workspace_id);
if (ws) {
switchWorkspace(ws);
}
qc.invalidateQueries({ queryKey: workspaceKeys.myInvitations() });
// Navigate to the workspace after a short delay for the success state.
setTimeout(() => push("/issues"), 1000);
} catch (e) {
setError(e instanceof Error ? e.message : "Failed to accept invitation");
} finally {
setAccepting(false);
}
};
const handleDecline = async () => {
setDeclining(true);
setError(null);
try {
await api.declineInvitation(invitationId);
setDone("declined");
qc.invalidateQueries({ queryKey: workspaceKeys.myInvitations() });
} catch (e) {
setError(e instanceof Error ? e.message : "Failed to decline invitation");
} finally {
setDeclining(false);
}
};
if (isLoading) {
return (
<div className="flex min-h-screen items-center justify-center">
<div className="text-sm text-muted-foreground">Loading invitation...</div>
</div>
);
}
if (fetchError || !invitation) {
return (
<div className="flex min-h-screen items-center justify-center">
<Card className="w-full max-w-md">
<CardContent className="flex flex-col items-center gap-4 py-12">
<div className="flex h-12 w-12 items-center justify-center rounded-full bg-muted">
<X className="h-6 w-6 text-muted-foreground" />
</div>
<h2 className="text-lg font-semibold">Invitation not found</h2>
<p className="text-sm text-muted-foreground text-center">
This invitation may have expired, been revoked, or doesn't belong to your account.
</p>
<Button variant="outline" onClick={() => push("/issues")}>
Go to dashboard
</Button>
</CardContent>
</Card>
</div>
);
}
if (done === "accepted") {
return (
<div className="flex min-h-screen items-center justify-center">
<Card className="w-full max-w-md">
<CardContent className="flex flex-col items-center gap-4 py-12">
<div className="flex h-12 w-12 items-center justify-center rounded-full bg-primary/10">
<Check className="h-6 w-6 text-primary" />
</div>
<h2 className="text-lg font-semibold">You joined {invitation.workspace_name}!</h2>
<p className="text-sm text-muted-foreground">Redirecting to workspace...</p>
</CardContent>
</Card>
</div>
);
}
if (done === "declined") {
return (
<div className="flex min-h-screen items-center justify-center">
<Card className="w-full max-w-md">
<CardContent className="flex flex-col items-center gap-4 py-12">
<h2 className="text-lg font-semibold">Invitation declined</h2>
<p className="text-sm text-muted-foreground">You won't be added to this workspace.</p>
<Button variant="outline" onClick={() => push("/issues")}>
Go to dashboard
</Button>
</CardContent>
</Card>
</div>
);
}
const isExpired = invitation.status !== "pending";
const isAlreadyHandled = invitation.status === "accepted" || invitation.status === "declined";
return (
<div className="flex min-h-screen items-center justify-center">
<Card className="w-full max-w-md">
<CardContent className="flex flex-col items-center gap-6 py-12">
<div className="flex h-14 w-14 items-center justify-center rounded-full bg-primary/10">
<Users className="h-7 w-7 text-primary" />
</div>
<div className="text-center space-y-2">
<h2 className="text-xl font-semibold">
Join {invitation.workspace_name ?? "workspace"}
</h2>
<p className="text-sm text-muted-foreground">
<strong>{invitation.inviter_name || invitation.inviter_email}</strong>{" "}
invited you to join as {invitation.role === "admin" ? "an admin" : "a member"}.
</p>
</div>
{isAlreadyHandled ? (
<div className="text-sm text-muted-foreground">
This invitation has already been {invitation.status}.
</div>
) : isExpired ? (
<div className="text-sm text-muted-foreground">
This invitation has expired.
</div>
) : (
<div className="flex gap-3 w-full">
<Button
variant="outline"
className="flex-1"
onClick={handleDecline}
disabled={accepting || declining}
>
{declining ? "Declining..." : "Decline"}
</Button>
<Button
className="flex-1"
onClick={handleAccept}
disabled={accepting || declining}
>
{accepting ? "Joining..." : "Accept & Join"}
</Button>
</div>
)}
{error && (
<p className="text-sm text-destructive text-center">{error}</p>
)}
</CardContent>
</Card>
</div>
);
}

View File

@@ -70,7 +70,9 @@ export function useIssueTimeline(issueId: string, userId?: string) {
(old) => {
if (!old) return old;
if (old.some((e) => e.id === comment.id)) return old;
return [...old, commentToTimelineEntry(comment)];
return [...old, commentToTimelineEntry(comment)].sort(
(a, b) => a.created_at.localeCompare(b.created_at),
);
},
);
},
@@ -144,7 +146,9 @@ export function useIssueTimeline(issueId: string, userId?: string) {
(old) => {
if (!old) return old;
if (old.some((e) => e.id === entry.id)) return old;
return [...old, entry];
return [...old, entry].sort(
(a, b) => a.created_at.localeCompare(b.created_at),
);
},
);
},

View File

@@ -27,8 +27,8 @@ import {
SquarePen,
CircleUser,
FolderKanban,
Ellipsis,
PinOff,
Zap,
} from "lucide-react";
import { WorkspaceAvatar } from "../workspace/workspace-avatar";
import { ActorAvatar } from "@multica/ui/components/common/actor-avatar";
@@ -56,10 +56,15 @@ import {
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@multica/ui/components/ui/dropdown-menu";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@multica/ui/components/ui/popover";
import { useAuthStore } from "@multica/core/auth";
import { useWorkspaceStore } from "@multica/core/workspace";
import { workspaceListOptions } from "@multica/core/workspace/queries";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { workspaceListOptions, myInvitationListOptions, workspaceKeys } from "@multica/core/workspace/queries";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { inboxKeys, deduplicateInboxItems } from "@multica/core/inbox/queries";
import { api } from "@multica/core/api";
import { useModalStore } from "@multica/core/modals";
@@ -76,6 +81,7 @@ const personalNav = [
const workspaceNav = [
{ href: "/issues", label: "Issues", icon: ListTodo },
{ href: "/projects", label: "Projects", icon: FolderKanban },
{ href: "/autopilots", label: "Autopilot", icon: Zap },
{ href: "/agents", label: "Agents", icon: Bot },
];
@@ -164,6 +170,7 @@ export function AppSidebar({ topSlot, searchSlot, headerClassName, headerStyle }
const workspace = useWorkspaceStore((s) => s.workspace);
const switchWorkspace = useWorkspaceStore((s) => s.switchWorkspace);
const { data: workspaces = [] } = useQuery(workspaceListOptions());
const { data: myInvitations = [] } = useQuery(myInvitationListOptions());
const wsId = workspace?.id;
const { data: inboxItems = [] } = useQuery({
@@ -197,6 +204,19 @@ export function AppSidebar({ topSlot, searchSlot, headerClassName, headerStyle }
);
const queryClient = useQueryClient();
const acceptInvitationMut = useMutation({
mutationFn: (id: string) => api.acceptInvitation(id),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: workspaceKeys.myInvitations() });
queryClient.invalidateQueries({ queryKey: workspaceKeys.list() });
},
});
const declineInvitationMut = useMutation({
mutationFn: (id: string) => api.declineInvitation(id),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: workspaceKeys.myInvitations() });
},
});
const logout = () => {
queryClient.clear();
authLogout();
@@ -287,6 +307,44 @@ export function AppSidebar({ topSlot, searchSlot, headerClassName, headerStyle }
Create workspace
</DropdownMenuItem>
</DropdownMenuGroup>
{myInvitations.length > 0 && (
<>
<DropdownMenuSeparator />
<DropdownMenuGroup>
<DropdownMenuLabel className="text-xs text-muted-foreground">
Pending invitations
</DropdownMenuLabel>
{myInvitations.map((inv) => (
<div key={inv.id} className="flex items-center gap-2 px-2 py-1.5">
<WorkspaceAvatar name={inv.workspace_name ?? "W"} size="sm" />
<span className="flex-1 truncate text-sm">{inv.workspace_name ?? "Workspace"}</span>
<button
type="button"
className="text-xs px-2 py-0.5 rounded bg-primary text-primary-foreground hover:bg-primary/90 disabled:opacity-50"
disabled={acceptInvitationMut.isPending}
onClick={(e) => {
e.stopPropagation();
acceptInvitationMut.mutate(inv.id);
}}
>
Join
</button>
<button
type="button"
className="text-xs px-2 py-0.5 rounded bg-muted text-muted-foreground hover:bg-muted/80 disabled:opacity-50"
disabled={declineInvitationMut.isPending}
onClick={(e) => {
e.stopPropagation();
declineInvitationMut.mutate(inv.id);
}}
>
Decline
</button>
</div>
))}
</DropdownMenuGroup>
</>
)}
<DropdownMenuSeparator />
<DropdownMenuGroup>
<DropdownMenuItem variant="destructive" onClick={logout}>
@@ -423,33 +481,51 @@ export function AppSidebar({ topSlot, searchSlot, headerClassName, headerStyle }
<SidebarFooter className="p-2">
<div className="border-t pt-2">
<div className="flex items-center gap-2.5 rounded-md px-2 py-1.5">
<ActorAvatar
name={user?.name ?? ""}
initials={(user?.name ?? "U").charAt(0).toUpperCase()}
avatarUrl={user?.avatar_url}
size={28}
/>
<div className="min-w-0 flex-1">
<p className="truncate text-sm font-medium leading-tight">
{user?.name}
</p>
<p className="truncate text-xs text-muted-foreground leading-tight">
{user?.email}
</p>
</div>
<DropdownMenu>
<DropdownMenuTrigger className="flex h-7 w-7 shrink-0 items-center justify-center rounded-md text-muted-foreground hover:bg-accent hover:text-accent-foreground transition-colors">
<Ellipsis className="size-4" />
</DropdownMenuTrigger>
<DropdownMenuContent align="end" side="top" sideOffset={4}>
<DropdownMenuItem variant="destructive" onClick={logout}>
<Popover>
<PopoverTrigger className="flex w-full items-center gap-2.5 rounded-md px-2 py-1.5 hover:bg-accent transition-colors cursor-pointer">
<ActorAvatar
name={user?.name ?? ""}
initials={(user?.name ?? "U").charAt(0).toUpperCase()}
avatarUrl={user?.avatar_url}
size={28}
/>
<div className="min-w-0 flex-1 text-left">
<p className="truncate text-sm font-medium leading-tight">
{user?.name}
</p>
<p className="truncate text-xs text-muted-foreground leading-tight">
{user?.email}
</p>
</div>
</PopoverTrigger>
<PopoverContent side="top" sideOffset={8} align="start" className="w-48 p-0">
<div className="flex items-center gap-2.5 px-2.5 py-2 border-b">
<ActorAvatar
name={user?.name ?? ""}
initials={(user?.name ?? "U").charAt(0).toUpperCase()}
avatarUrl={user?.avatar_url}
size={32}
/>
<div className="min-w-0 flex-1">
<p className="truncate text-sm font-medium">
{user?.name}
</p>
<p className="truncate text-xs text-muted-foreground">
{user?.email}
</p>
</div>
</div>
<div className="p-1">
<button
onClick={logout}
className="flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-sm text-destructive hover:bg-destructive/10 transition-colors cursor-pointer"
>
<LogOut className="h-3.5 w-3.5" />
Log out
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
</button>
</div>
</PopoverContent>
</Popover>
</div>
</SidebarFooter>
<SidebarRail />

View File

@@ -2,6 +2,7 @@
import { useRef, useState } from "react";
import { useNavigation } from "../navigation";
import { useImmersiveMode } from "../platform";
import { toast } from "sonner";
import { ArrowLeft } from "lucide-react";
import { Input } from "@multica/ui/components/ui/input";
@@ -24,6 +25,11 @@ import {
} from "../workspace/slug";
export function CreateWorkspaceModal({ onClose }: { onClose: () => void }) {
// This modal is full-screen, so it covers the app titlebar. On macOS desktop
// we hide the traffic lights for its lifetime so the Back button in the top-
// left corner isn't stolen by the native controls' hit-test. No-op elsewhere.
useImmersiveMode();
const router = useNavigation();
const createWorkspace = useCreateWorkspace();
const [name, setName] = useState("");
@@ -87,10 +93,20 @@ export function CreateWorkspaceModal({ onClose }: { onClose: () => void }) {
showCloseButton={false}
className="inset-0 flex h-full w-full max-w-none sm:max-w-none translate-0 flex-col items-center justify-center rounded-none bg-background ring-0 shadow-none"
>
{/* Top drag region — restores window-drag ability that the full-screen
modal would otherwise swallow. Transparent; web browsers ignore the
-webkit-app-region property, so this is safe cross-platform. */}
<div
aria-hidden
className="absolute inset-x-0 top-0 h-10"
style={{ WebkitAppRegion: "drag" } as React.CSSProperties}
/>
<Button
variant="ghost"
size="sm"
className="absolute top-6 left-6 text-muted-foreground"
className="absolute top-12 left-12 text-muted-foreground"
style={{ WebkitAppRegion: "no-drag" } as React.CSSProperties}
onClick={onClose}
>
<ArrowLeft className="h-4 w-4" />

View File

@@ -19,6 +19,7 @@
"./issues/utils/sort": "./issues/utils/sort.ts",
"./issues/utils/redact": "./issues/utils/redact.ts",
"./projects/components": "./projects/components/index.ts",
"./autopilots/components": "./autopilots/components/index.ts",
"./modals/registry": "./modals/registry.tsx",
"./modals/create-issue": "./modals/create-issue.tsx",
"./my-issues": "./my-issues/index.ts",
@@ -32,7 +33,9 @@
"./search": "./search/index.ts",
"./chat": "./chat/index.ts",
"./settings": "./settings/index.ts",
"./onboarding": "./onboarding/index.ts"
"./onboarding": "./onboarding/index.ts",
"./invite": "./invite/index.ts",
"./platform": "./platform/index.ts"
},
"dependencies": {
"@base-ui/react": "^1.3.0",

View File

@@ -0,0 +1 @@
export { useImmersiveMode } from "./use-immersive-mode";

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