mirror of
https://github.com/multica-ai/multica.git
synced 2026-07-22 17:49:48 +02:00
v0.2.10
904 Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
637bdc8eb3 |
feat(analytics): full PostHog pipeline + 6 funnel events (MUL-1122) (#1367)
* feat(analytics): add PostHog client with async batch shipping Introduces server/internal/analytics, the shipping layer for the product funnel defined in docs/analytics.md. Capture is non-blocking — events are enqueued into a bounded channel and a background worker batches them to PostHog's /batch/ endpoint. A broken backend drops events rather than blocking request handlers. Local dev and self-hosted instances run a noop client until the operator sets POSTHOG_API_KEY. This is PR 1 of MUL-1122; signup and workspace_created emission land in the follow-up commit so this change is independently reviewable. * feat(server): emit signup and workspace_created analytics events Wires analytics.Client through handler.New and main, then emits the first two funnel events: - signup fires from findOrCreateUser (which now reports isNew), covering both the verification-code and Google OAuth entry points — a single emission site guarantees Google signups aren't missed. - workspace_created fires after the CreateWorkspace transaction commits, with is_first_workspace computed from a post-commit ListWorkspaces count so we can distinguish fresh-user activation from returning-user expansion. Tests use analytics.NoopClient so nothing ships from test runs. PR 1 of MUL-1122; runtime_registered and issue_executed follow in later PRs per the plan. * refactor(analytics): drop is_first_workspace from workspace_created Stamping "is this the user's first workspace?" at emit time races under concurrent CreateWorkspace requests: two transactions committing close together can both read a post-commit count greater than one and both emit false. Fixing it at the SQL layer requires a schema change we don't want in PR 1. PostHog answers the same question exactly from the event stream (funnel on "first time user does X" / cohort on $initial_event), so removing the property loses no information and makes the emit side race-free. * docs(analytics): document self-host safety defaults Spell out why self-hosted instances never ship events upstream by default (empty POSTHOG_API_KEY → noop client) and explain how operators can point at their own PostHog project without any code change. * feat(analytics): emit runtime_registered, issue_executed, team_invite_* Three server-side funnel events, all gated on first-time state transitions so retries and re-runs don't inflate the WAW buckets: - runtime_registered fires from DaemonRegister when UpsertAgentRuntime reports (xmax = 0) — i.e. the row was inserted, not updated. Heartbeats and re-registrations stay silent. - issue_executed fires from CompleteTask after an atomic UPDATE issue SET first_executed_at = now() WHERE id = $1 AND first_executed_at IS NULL flips the column for the first time. Retries, re-assignments, and comment-triggered follow-up tasks hit the WHERE clause and no-op. Carries nth_issue_for_workspace so the ≥1/≥2/≥5/≥10 buckets filter without extra queries. - team_invite_sent fires from CreateInvitation and team_invite_accepted from AcceptInvitation, closing the expansion funnel. Adds a 050 migration for issue.first_executed_at plus a partial index so the workspace-scoped executed-count query doesn't scan the never-executed tail. * feat(config): surface PostHog key via /api/config Extends AppConfig with posthog_key / posthog_host sourced from env on every request (so operators can rotate the key via secret refresh without a restart). Reading the key off the server — rather than baking it into the frontend bundle via NEXT_PUBLIC_* — means self-hosted instances inherit the blank key automatically and never ship events upstream. * feat(analytics): wire posthog-js identify + UTM capture on the client Adds @multica/core/analytics — a thin wrapper around posthog-js that owns attribution capture and identity merge. Posthog-js config comes from /api/config (not NEXT_PUBLIC_*), so self-hosted instances whose server returns an empty key automatically run the SDK inert. captureSignupSource stamps a multica_signup_source cookie with UTM params and the referrer's origin (never the full referrer — that can leak OAuth code/state in the callback URL). The backend signup event reads this cookie on new-user creation. Identity flows: - auth-initializer fires identify() right after getMe() resolves, on both cookie and token paths. A getConfig/getMe race is handled by buffering a pending identify inside the analytics module and flushing it once initAnalytics finishes. - auth store calls identify() on verifyCode / loginWithGoogle / loginWithToken and resetAnalytics() on logout so the next login merges cleanly without bleeding events. * docs(analytics): describe runtime_registered, issue_executed, invite events Fills in the schema for the remaining funnel events. Captures the design commentary that belongs next to the contract rather than in a PR description — in particular why issue_executed uses the atomic first_executed_at flip instead of counting task-terminal events, and why runtime_registered relies on xmax = 0 rather than a query-then-write. * fix(analytics): drop non-atomic nth_issue_for_workspace from issue_executed Computing the workspace's Nth-issue ordinal at emit time is not atomic under concurrent first-completions — two transactions can both run MarkIssueFirstExecuted, then both run CountExecutedIssuesInWorkspace, and both observe count=1 before either has committed, so both events go out stamped as n=1. Serialising it would mean a per-workspace advisory lock or a SERIALIZABLE-isolated tx; PostHog answers the same question exactly at query time via row_number() partitioned by workspace_id, so the emit-time property adds risk without adding information. Removes the property from analytics.IssueExecuted, deletes the unused CountExecutedIssuesInWorkspace query, and regenerates sqlc. The partial index stays — any future workspace-scoped executed-issue query will want it. * fix(analytics): wire $pageview and harden signup_source cookie payload Two frontend fixes from the PR review: - PageviewTracker, mounted under WebProviders, fires capturePageview on every Next.js App Router path / query-string change. Without this the capturePageview helper in @multica/core/analytics was never called and the acquisition funnel's / → signup step was empty. - captureSignupSource now caps each UTM / referrer value at 96 chars *before* JSON.stringify, and drops the whole cookie when the serialised payload still exceeds 512 chars. Previously the overall slice(0, 256) could leave a half-JSON string on the wire that neither the backend nor PostHog could parse. Both capturePageview and identify now buffer a single pending call when fired before initAnalytics resolves — otherwise the initial "/" pageview and same-turn login identify race the /api/config fetch and get dropped. resetAnalytics clears both buffers so a logout→login cycle stays clean. * fix(analytics): URL-decode signup_source cookie on read Go does not URL-decode Cookie.Value automatically, so the frontend's JSON-then-encodeURIComponent payload was landing in PostHog as percent-encoded garbage (%7B%22utm_source...). Unescape on read so the backend receives the original JSON string the frontend intended, and drop values that fail to decode or exceed the server-side cap — sending truncated garbage is worse than sending nothing. Oversized-cookie guard matches the frontend's SIGNUP_SOURCE_MAX_LEN. * docs(analytics): reflect nth-issue drop, $pageview wiring, cookie encoding Pulls the schema doc back in line with the code: issue_executed no longer advertises nth_issue_for_workspace (with a note about why PostHog derives it at query time instead), the frontend $pageview section names the actual PageviewTracker component that fires it, and the signup_source section documents the per-value cap / overall drop rule and the encode-on-write / decode-on-read contract. --------- Co-authored-by: Jiang Bohan <bhjiang@outlook.com> |
||
|
|
6f63fae41a |
feat(desktop): support macOS cross-platform packaging (#1262)
* feat(desktop): support macOS cross-platform packaging * fix(desktop): use releaseType instead of publishingType in electron-builder publish config publishingType is not a valid electron-builder key; the correct GitHub provider option is releaseType. The previous value was silently ignored, causing uploads to be skipped and breaking auto-update. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * feat(release): standardize artifact naming across desktop and CLI Unified scheme: `multica-<kind>-<version>-<platform>-<arch>.<ext>` so a filename alone reveals kind, version, platform, and CPU arch. Desktop (apps/desktop/electron-builder.yml): mac → multica-desktop-<v>-mac-<arch>.{dmg,zip} linux → multica-desktop-<v>-linux-<arch>.{deb,AppImage} (fixes `\${name}` expanding the scoped `@multica/desktop` into a broken `@multica/desktop-*` filename path) windows → multica-desktop-<v>-windows-<arch>.exe CLI (.goreleaser.yml): multica_<os>_<arch>.tar.gz → multica-cli-<v>-<os>-<arch>.tar.gz (adds `-cli` marker + version; switches `_` to `-` for consistency) Matrix update in apps/desktop/scripts/package.mjs `--all-platforms`: - drop mac x64 (Intel not a target yet) - add linux arm64 Final: mac arm64, win x64/arm64, linux x64/arm64. Downstream updates so install paths match the new CLI names: - scripts/install.sh - scripts/install.ps1 (URL + checksum regex) - CLI_INSTALL.md Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * feat(release): use multica_{os}_{arch} CLI archive naming Standardize on the GoReleaser default 'multica_{os}_{arch}.{tar.gz|zip}' asset names. Install scripts and the desktop CLI bootstrap now resolve assets via checksums.txt so they work without hardcoding versions. The Go self-update path queries the GitHub release API and accepts either the new or legacy 'multica-cli-<version>-...' names so existing releases keep updating cleanly. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * feat(release): ship both legacy and versioned CLI archive names GoReleaser now produces both 'multica_{os}_{arch}.{ext}' (legacy) and 'multica-cli-{version}-{os}-{arch}.{ext}' (versioned) archives in every release. The legacy name keeps already-released CLIs self-updating; the versioned name is what new clients should use going forward. Self-update / install paths flipped to prefer the versioned name and fall back to legacy: - server/internal/cli/update.go (multica update) - apps/desktop/src/main/cli-release-asset.ts (desktop CLI bootstrap) - scripts/install.sh, scripts/install.ps1 (fresh install) Homebrew formula is pinned to the versioned archive via 'ids: [versioned]'. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * feat(desktop): also build Linux .rpm packages Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * feat(release): build Linux/Windows Desktop installers in CI; detect Windows ARM64 in install.ps1 Address review feedback on PR #1262: - .github/workflows/release.yml: add a 'desktop' job that runs after the CLI 'release' job and packages the Desktop installers for Linux (AppImage/deb/rpm) and Windows (NSIS) on x64 and arm64, then publishes them to the same GitHub Release via electron-builder. macOS Desktop continues to ship through the manual release-desktop skill so it can be signed and notarized with Apple Developer credentials. - scripts/install.ps1: detect Windows ARM64 hosts via RuntimeInformation::OSArchitecture so the new windows-arm64 CLI archive is downloaded on ARM64 machines instead of always falling back to amd64. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * fix(release): split Windows arm64 auto-update channel to avoid latest.yml collision electron-builder's update metadata file is hardcoded to `latest.yml` for Windows regardless of arch (only Linux gets an arch-suffixed name; see app-builder-lib's getArchPrefixForUpdateFile). With two separate electron-builder invocations for Windows x64 and arm64, both publish `latest.yml` to the same GitHub Release and the second upload silently overwrites the first — leaving one of the two architectures with auto- update metadata pointing at the other arch's installer. Route Windows arm64 to its own `latest-arm64` channel: * scripts/package.mjs appends `-c.publish.channel=latest-arm64` only for the Windows arm64 invocation, so x64 keeps producing `latest.yml` and arm64 produces `latest-arm64.yml` alongside it. * updater.ts pins `autoUpdater.channel = 'latest-arm64'` on Windows arm64 clients so they fetch the matching metadata file. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --------- Co-authored-by: Devv <devv@Devvs-Mac-mini.local> Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> |
||
|
|
4368e1be18 |
docs: add v0.2.8 changelog (2026-04-20) (#1418)
Summarizes recent releases (v0.2.7 → v0.2.8) on the landing page Change Log in both en and zh. Co-authored-by: Lambda <f252c2c5-7d1d-4f3c-b394-a61abfe673fc@users.noreply.multica.ai> |
||
|
|
b291db11c2 |
feat(agents): add per-agent model field with provider-aware dropdown (#1399)
Adds a first-class `model` field on agents so users can pick the LLM model from the create / settings UI instead of editing `custom_env` / `custom_args`. Each provider's dropdown is populated from the live CLI when possible (`opencode models`, `pi --list-models`, `openclaw agents list --json`, `cursor-agent --list-models`, hermes ACP `session/new` → `SessionModelState`), with a static catalog for providers that don't enumerate.
Daemon resolves the runtime model as `agent.model → MULTICA_<PROVIDER>_MODEL → ""` — empty passes through so each backend's CLI picks its own default, avoiding static-guess drift.
Per-provider honouring:
- Claude / Codex / OpenCode / Cursor / Gemini / Pi / Copilot — CLI `--model` / thread payload.
- OpenClaw — `opts.Model` is mapped to `--agent <name>` (the CLI rejects `--model`).
- Hermes — `session/set_model` ACP RPC; stderr is sniffed for provider-level errors so HTTP 4xx from the configured LLM surfaces instead of "empty output"; explicit-model failures mark the task `failed`.
Supporting changes: migration 050 adds `agent.model`; daemon ↔ server heartbeat piggyback carries a model-discovery request; new REST endpoints under `/api/runtimes/{id}/models`; `multica agent create --model` / `update --model`; shared `ModelDropdown` in `packages/views/agents` (searchable, creatable, provider-grouped, default-badge, runtime-supported gate).
|
||
|
|
824d943848 |
fix(auth): derive cookie Secure flag from FRONTEND_ORIGIN scheme (#1390)
The session cookie's Secure flag was tied to APP_ENV, and the docker-compose self-host stack defaults APP_ENV to "production". On plain-HTTP self-host deployments (LAN IP, private network) the browser silently drops Secure cookies, leaving every subsequent /api/* call anonymous and surfacing as 401 "auth: no token found" right after a successful login. Derive Secure from the scheme of FRONTEND_ORIGIN so HTTPS origins get Secure cookies and plain-HTTP origins get non-secure cookies the browser will actually store. Also harden cookieDomain() against the other common trap: COOKIE_DOMAIN=<ip>, which RFC 6265 forbids and browsers reject. Log a one-shot warning and fall back to host-only. Docs: correct the COOKIE_DOMAIN description (it was labelled as CloudFront-only but applies to session cookies too) and call out the IP-literal pitfall in SELF_HOSTING_ADVANCED.md, self-hosting.mdx, and .env.example. Refs #1321 |
||
|
|
193046fabc |
docs: add v0.2.7 changelog (2026-04-18) (#1385)
* docs: add v0.2.7 changelog entry (2026-04-18) * docs: trim v0.2.7 changelog to headline items |
||
|
|
62a7c05589 |
feat(desktop): hourly update poll + manual check button in settings (#1366)
* feat(desktop): hourly update poll + manual check button in settings The previous updater only ran one check 5s after launch, so a missed or failed initial check meant the user had to fully restart the app to see a new release. Add a 1h background poll for long sessions and a "Check now" button under a new Updates tab in Settings so the user can trigger a check on demand without waiting. The button reuses the existing autoUpdater pipeline — when an update is available the existing corner notification still drives the download flow; the settings tab only surfaces the immediate check result (up-to-date / available / error). * fix(desktop): trust electron-updater's isUpdateAvailable for the manual check Per review: deriving `available` from a version-string compare is wrong — `updateInfo.version` can differ from `app.getVersion()` while electron-updater still suppresses `update-available` (pre-release channels, staged rollouts, downgrade scenarios, min-system-version gates). In those cases the settings tab would say "vX is available" but no corner download prompt would ever appear. Use `result?.isUpdateAvailable` instead, which is electron-updater's own answer. |
||
|
|
4ce3e5ddf4 |
fix(auth): hand off session to Desktop when web is already logged in (#1364)
When Desktop opens /login?platform=desktop in the browser and the user already has a valid web session, the page previously bounced them to their workspace and Desktop never received a token. Now we mint a bearer token via issueCliToken and redirect through the multica:// deep link so Desktop completes sign-in without a second Google round-trip. Refs: MUL-1080 |
||
|
|
b428f36ca6 |
feat: add ALLOW_SIGNUP + ALLOWED_EMAIL_* for self-hosted instances (#1098)
Closes #930 - Added environment variables to control signups - Updated frontend to hide signup text when disabled - Added backend check to block new user creation via magic link - Updated .env.example |
||
|
|
6cd49e132d |
docs(selfhost): clarify 888888 master code is disabled by default in Docker (#1313)
Following #1307, the Docker self-host stack defaults to APP_ENV=production, which disables the 888888 master verification code on auth.go:169. The installer banners and self-hosting docs still told operators to log in with 888888, leaving them stuck. Update install.sh, install.ps1, SELF_HOSTING.md, SELF_HOSTING_ADVANCED.md, and self-hosting.mdx to document the three login paths: configure RESEND_API_KEY (recommended), set APP_ENV=development to enable 888888 for private evaluation, or read the dev verification code from backend container logs. Also warn against enabling APP_ENV=development on public instances. |
||
|
|
2317533da4 |
fix(auth): validate next= redirect target to prevent open redirect (#1309)
* refactor(auth): add sanitizeNextUrl helper in @multica/core/auth Extracts a reusable helper that returns a post-login redirect URL only when it's a safe single-slash relative path, and null otherwise. Rejects absolute URLs, protocol-relative URLs, backslashes, and control characters so call sites can safely pass the result to router.push(). Keeping the rule in a single helper (with direct unit tests) avoids each consumer re-implementing the validation and drifting. * fix(auth): validate next= redirect target to prevent open redirect Closes #1116 Next.js router.push accepts absolute URLs, so a crafted `/login?next=https://evil.example` would send the user off-origin after a successful login. The Google OAuth callback has the same vector via the `state=next:<url>` payload. Sanitize both entry points through `sanitizeNextUrl` from `@multica/core/auth` so only safe single-slash relative paths survive; null results fall through to the existing workspace-list-based default without any hard-coded path. --------- Co-authored-by: JunghwanNA <70629228+shaun0927@users.noreply.github.com> |
||
|
|
c85c43ed0e | docs: add v0.2.5 changelog entry (2026-04-17) (#1269) | ||
|
|
eecb3a2bc8 |
fix(desktop): use releaseType instead of publishingType in electron-builder publish config (#1268)
electron-builder 26.8.1 rejects publishingType under the GitHub publisher; the correct option for selecting draft/prerelease/release is releaseType. Using publishingType caused schema validation to fail during packaging. Co-authored-by: Devv <devv@Devvs-Mac-mini.local> Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> |
||
|
|
bf31fa4b39 |
fix(web): move /docs rewrite to beforeFiles (#1266)
* feat(docs): mount docs site at /docs subpath via basePath + multi-zone Configure the Fumadocs site so it can be served at multica.ai/docs: - Add basePath: '/docs' to apps/docs/next.config.mjs - Flatten routes: drop standalone home, render content/docs/index.mdx at the root, move catch-all from app/docs/[[...slug]] to app/[...slug] - Wrap children with DocsLayout in the root layout (was a separate segment-level layout under app/docs/) - Set source loader baseUrl to '/' so URL slugs no longer carry the basePath (Next.js prepends it automatically) - Strip the now-redundant '/docs/' prefix from internal MDX links and drop the duplicate "Documentation" nav entry - Add app/not-found.tsx for App Router 404 handling Wire up multi-zone routing so apps/web proxies /docs/* to the docs app: - Add DOCS_URL env (default http://localhost:4000) and rewrites for /docs and /docs/:path* in apps/web/next.config.ts - Whitelist DOCS_URL in turbo.json globalEnv * fix(web): move /docs rewrite to beforeFiles so [workspaceSlug] doesn't shadow it The /docs rewrite was running in the default afterFiles slot, which is evaluated *after* file-system routing. apps/web/app/[workspaceSlug]/ matched /docs first as a workspace named "docs" (which doesn't exist) and returned 404 before the rewrite to the docs Vercel project ever fired. Splitting rewrites into beforeFiles/afterFiles puts /docs and /docs/:path* ahead of route resolution so they always proxy to the docs zone. |
||
|
|
7c6158f3c9 |
feat(docs): mount docs site at /docs subpath via basePath + multi-zone (#1160)
Configure the Fumadocs site so it can be served at multica.ai/docs: - Add basePath: '/docs' to apps/docs/next.config.mjs - Flatten routes: drop standalone home, render content/docs/index.mdx at the root, move catch-all from app/docs/[[...slug]] to app/[...slug] - Wrap children with DocsLayout in the root layout (was a separate segment-level layout under app/docs/) - Set source loader baseUrl to '/' so URL slugs no longer carry the basePath (Next.js prepends it automatically) - Strip the now-redundant '/docs/' prefix from internal MDX links and drop the duplicate "Documentation" nav entry - Add app/not-found.tsx for App Router 404 handling Wire up multi-zone routing so apps/web proxies /docs/* to the docs app: - Add DOCS_URL env (default http://localhost:4000) and rewrites for /docs and /docs/:path* in apps/web/next.config.ts - Whitelist DOCS_URL in turbo.json globalEnv |
||
|
|
e9131dfe2b |
fix(web): remove dashboard loading.tsx to eliminate double skeleton flash (#1256)
The route-level loading.tsx creates a Suspense boundary that shows a generic skeleton on every page navigation within the dashboard. Since every page already handles its own data-loading skeleton via TanStack Query isLoading, this causes two sequential skeleton flashes: loading.tsx skeleton → page skeleton → content. Removing it makes the old page stay visible during route transitions (typically <100ms), then the new page renders directly with its own skeleton — a single, smooth transition. Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com> |
||
|
|
fe01d58064 |
docs(cli): document project commands and --project flag for issues (#1253)
The project CRUD commands (list, get, create, update, delete, status) and the `--project` flag on issue commands have been implemented in the CLI but were not yet documented. Add them to both the docs site reference and the repo-level CLI_AND_DAEMON.md so the feature is discoverable. Closes MUL-867 Co-authored-by: Eve <eve@multica.ai> Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> |
||
|
|
fc1938fe7d |
refactor(desktop): centralize shell.openExternal through a single wrapper (#1255)
Make the http/https scheme allowlist structurally enforced instead of a convention. Move the allowlist check + shell.openExternal call into a single openExternalSafely wrapper in external-url.ts, have both main-process call sites (the IPC handler and setWindowOpenHandler) go through it, and add an ESLint no-restricted-syntax rule that bans direct shell.openExternal usage anywhere under apps/desktop/src/main/ except external-url.ts itself. This is the follow-up to #1124: same safety guarantee, but a reviewer can no longer accidentally reintroduce a bare shell.openExternal somewhere that bypasses the check — the lint rule catches it at CI time. Also restores the scheme info in the warn log (lost when the helper was extracted). Test coverage extended to the cases the original PR review flagged but didn't ship: casing (FILE:// / HTTPS://), javascript: / data:, ftp / smb, vscode:// / ms-msdt:, mailto / tel, credentials-in-URL, empty / malformed. Added two openExternalSafely tests (electron mocked) confirming allowed URLs forward and rejected URLs do not. Closes a follow-up bullet from the internal #1115 / #1124 review. |
||
|
|
1ea6e6a078 |
fix(desktop): restrict shell.openExternal to http/https schemes (#1124)
* fix(desktop): restrict shell.openExternal to http/https schemes The Electron main-process IPC handler for shell:openExternal called shell.openExternal with whatever string the renderer passed, with no scheme validation. Under this app's intentional webSecurity: false and sandbox: false configuration (#648), any unsafe content path in the renderer reaching this IPC becomes a way to dispatch arbitrary OS protocol handlers — file://, smb://, vscode://, Windows ms-msdt:, and so on. Parse the URL and reject anything outside http/https (the only schemes any legitimate call site uses today). Matches the Electron security checklist guidance for openExternal on non-isolated renderers. Closes #1115 * Close the desktop external-open gap on target=_blank links The original fix validated only the IPC path, but the renderer could still trigger shell.openExternal through setWindowOpenHandler for target="_blank" links and window.open(). This change reuses one allowlist helper for both sinks and adds a focused unit test for the helper contract. Constraint: Desktop shell.openExternal must stay limited to http/https despite webSecurity=false and sandbox=false Rejected: Duplicate URL validation logic in each sink | easy to drift and harder to test Confidence: high Scope-risk: narrow Reversibility: clean Directive: Keep all desktop external-open paths on the same validator so new sinks do not bypass the allowlist Tested: pnpm --dir /Users/jh0927/Workspace/multica-pr1124-followup --filter @multica/desktop test -- src/main/external-url.test.ts Tested: pnpm --dir /Users/jh0927/Workspace/multica-pr1124-followup --filter @multica/desktop typecheck Not-tested: Full desktop app manual smoke run Related: #1115 --------- Co-authored-by: shaun0927 <shaun0927@users.noreply.github.com> |
||
|
|
c15212c0e4 |
fix(views): align skeleton loading states with actual page layouts (#1251)
- Issues/MyIssues: remove incorrect border-b from toolbar skeleton, add viewMode-aware skeleton (list vs board) - Issue Detail: fix content padding (max-w-4xl mx-auto) and sidebar width (w-80), remove independent reactions/subscribers/timeline skeleton flashes — components now render with empty defaults - Agents/Skills: gate skeleton on data query isLoading instead of auth store isLoading so skeleton covers actual data fetch - Projects/Autopilots: add sticky column header skeleton row - Autopilot Detail: add PageHeader skeleton, flesh out section structure - Invite: replace plain text with Card-shaped skeleton - Chat: migrate ChatMessageSkeleton to use Skeleton component - Workspace layout: show MulticaIcon loading indicator instead of blank Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com> |
||
|
|
dcd050ca69 |
fix(desktop): set electron-builder publishingType to release (#1242)
Our CLI release flow pre-creates a *published* GitHub Release via `gh release create`. electron-builder's default `publishingType: draft` conflicts with `existingType=release` and causes the DMG/ZIP/blockmaps/ latest-mac.yml uploads to be silently skipped, which breaks electron-updater auto-update on installed clients (observed on v0.2.4, had to fall back to `gh release upload` manually). Explicitly setting `publishingType: release` aligns electron-builder with our release flow so desktop artifacts are uploaded to the existing published release automatically. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> |
||
|
|
80a24bf627 |
refactor(desktop): tabs are per-workspace, not cross-workspace (#1239)
* refactor(desktop): tabs are per-workspace, not cross-workspace Tabs are now grouped by workspace in the store; the TabBar shows only the active workspace's tabs, and switching workspace swaps the visible group. Before this change tabs were a flat list that spanned workspaces, which produced a confusing experience: working in acme with three tabs, then switching to butter and back, still showed whatever tabs you happened to open while you were in butter alongside your acme work. The bug had the same shape as the pre-workspace-overlay bug we fixed in #1237 — a concept ("workspace") was encoded in data (tab paths) but ignored by the UI that displayed it (TabBar). The fix is structural: make the data model match the concept. Key changes: - **Schema**: `{ activeWorkspaceSlug, byWorkspace: {slug: {tabs, activeTabId}} }`. The invariant "every tab belongs to a workspace group" is enforced at sanitize time and at migration time; there is no longer a root `/` sentinel. - **NavigationAdapter** detects cross-workspace pushes and delegates to `switchWorkspace(slug, path)` instead of navigating the active tab's router. All existing call sites in shared code (sidebar dropdown, settings post-delete redirect, invite-accept, cmd+k) keep calling `push(paths.workspace(x).issues())` unchanged. - **TabContent** renders only the active workspace's tabs under Activity. Cross-workspace state preservation is an explicit non-goal — switching workspaces should feel like switching. - **WorkspaceRouteLayout** auto-heal no longer navigates the tab router to `/`. Stale-slug cleanup is a store-level op (`validateWorkspaceSlugs`) that drops the whole stale group in one go. - **App.tsx** bootstrap seeds `activeWorkspaceSlug` when null and the user has workspaces; the new-workspace overlay opens/closes based on workspace count independently of any route. - **Persistence migration** (v1 → v2) groups old flat tabs by extracted slug, drops root / transition / reserved-slug tabs, and picks an active workspace from the old active tab's owning group. No data loss for existing users with workspace-scoped tabs. Web is unchanged — tabs are a desktop-only concept. `packages/views`, `packages/core`, `apps/web` are all untouched. `setCurrentWorkspace` in core remains the single source of truth for the API client's workspace header, driven by `WorkspaceRouteLayout` as before. Tests: 19 tab-store tests (sanitize, migration, switchWorkspace, validate, close-last-reseeds, reset). 38 desktop tests total pass. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * review: stable selectors + defensive guards on tab-store Addresses self-review findings on #1239. **C1 — perf cliff from unstable selector returns.** The previous `useActiveTab()` selector used `.find()` inside, so every router tick on the active tab (which replaces the Tab object via immutable spread in updateTab / updateTabHistory) forced every subscriber to re-render. Replaced with finer-grained selectors: - `useActiveTabIdentity()` — { slug, tabId } primitives (stable across unrelated updates). - `useActiveTabRouter()` — stable object reference for a tab's lifetime. - `useActiveTabHistory()` — { historyIndex, historyLength } numbers. `useTabHistory` and `DesktopNavigationProvider` now consume the primitive selectors, so back/forward buttons don't churn on every path change. A non-hook `getActiveTab(state)` helper covers the event-handler case. **I1 — `switchWorkspace` no-ops on empty slug.** Defensive guard in case a malformed path ever reaches the adapter's detector. **I2 — merge warns on path/slug mismatch.** Previously silent drop; now `console.warn` makes the condition visible during debugging. **Misc — TabRouterInner takes `tab` prop directly.** Passing the Tab object eliminates a redundant store read per rendered tab. Known follow-up (not this PR): `packages/core/realtime/use-realtime-sync.ts` still uses `window.location.assign` for workspace-deleted eviction — that's a full renderer reload on desktop, which post-refactor wastes the careful in-memory tab state we just set up. Fixing cleanly requires a navigation-callback injection pattern through CoreProvider, which is cross-cutting and deserves its own PR. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(workspace): navigate away BEFORE leave/delete mutation to avoid CancelledError Symptom: deleting the current workspace logged "current workspace deleted, switching" from the realtime handler and surfaced an "Uncaught (in promise) CancelledError" from TanStack Query's refetchQueries batch. Root cause: a three-way race between the mutation's own invalidateQueries(workspaceKeys.list()), the settings page's navigateAwayFromCurrentWorkspace() fetchQuery, and the realtime workspace:deleted handler's relocateAfterWorkspaceLoss fetchQuery. All three refetched the same query concurrently; TanStack Query cancelled the in-flight loser(s), and the rejection bubbled out of invalidateQueries as an unhandled promise rejection. Fix: invert the order. Compute the destination from the current cached workspace list, navigate immediately, *then* fire the mutation. By the time the backend fires workspace:deleted, the active workspace is already something else — the realtime handler's "current === deleted" check fails and its relocate branch no-ops. Only one refetch happens (the mutation's onSettled), no race, no cancellation. navigateAwayFromCurrentWorkspace no longer needs async/fetchQuery since it reads from cache and returns before the mutation fires. Applies to both Leave and Delete flows. Both web and desktop benefit since the code is in packages/views. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(desktop): clear workspace singleton + flex drag strip + defer seeding Three issues that the last round of delete-workspace fixes missed. **1. `setCurrentWorkspace` singleton leaks after delete.** Navigating before the mutation (prior fix) changed the URL but nothing cleared the core platform's currentSlug/currentWsId singleton. Three downstream consumers still believed the deleted workspace was active: - `useRealtimeSync`'s `workspace:deleted` handler: its `getCurrentWsId() === deleted` check fired, triggering a parallel relocate that raced the mutation's invalidate and the settings page's navigate — CancelledError + `window.location.assign` (white screen reload). - Chrome gating: `{slug && <AppSidebar />}` stayed truthy, the sidebar mounted, and `useWorkspaceId` inside it threw because the workspace was gone from the list cache. - API client's `X-Workspace-Slug` header: stale on the next call. Fix: `navigateAwayFromCurrentWorkspace` now calls `setCurrentWorkspace(null, null)` before pushing. The next workspace's `WorkspaceRouteLayout` re-sets the singleton when it mounts; for the last-workspace case, null is the correct state (overlay has no workspace context). Same family as the previous logout bug: persist only writes to storage, reset on logout must also wipe in-memory state. Here the singleton is another in-memory bit that survives a URL change if we don't explicitly clear it. **2. "Cannot update a component while rendering" warning.** The per-workspace-tabs refactor kept the validate+seed call in render phase (matching the pre-refactor pattern). It worked before because `validateWorkspaceSlugs` is idempotent; the new `switchWorkspace` seed is not, and triggers a TabBar re-render during AppContent's render. Moved to `useLayoutEffect` — synchronously after render, before paint, no flicker. **3. Welcome-screen drag region didn't work on desktop.** The absolute-positioned `h-10 z-10` drag strip relied on z-index stacking to beat the content wrapper's no-drag for hit-testing, which wasn't reliable for `-webkit-app-region` on the overlay. Replaced with a flex child (`h-12 shrink-0` at top of the overlay's flex-col), so the drag region owns its own layout space — any pixel in the top 48 is unambiguously drag. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * docs(CLAUDE): desktop-specific rules — routing, singleton, drag, UX split Codifies the lessons from the recent desktop refactor series (#1237, #1238, #1239) so future work doesn't re-derive them from bugs. Covers: - **Route categories** (session / transition / error) — explains why `/workspaces/new` and `/invite/:id` are overlay state, not routes, on desktop; stale slugs auto-heal instead of rendering error pages. - **`setCurrentWorkspace` singleton hygiene** — unmount doesn't clear it; any code leaving workspace context must call `setCurrentWorkspace(null, null)` explicitly. - **Workspace destructive operations ordering** — navigate first, mutate after, to avoid the three-way refetch race that surfaces as CancelledError + full-page reload. - **Tab isolation** — tabs are grouped per workspace; cross-workspace push is intercepted by the navigation adapter and translated into switchWorkspace. - **Drag region pattern** — flex child at top, not absolute overlay; `-webkit-app-region` hit-testing is unreliable with z-index stacking. - **UX vs platform chrome split** — UX affordances (Back, Log out, welcome copy) in packages/views/; platform chrome (drag, immersive mode, tab system) in desktop-only code. Also patches the Cross-Platform Development Rules' rule #2 which previously said "add a route in both apps" unconditionally — added the exception for pre-workspace transition flows pointing at the new Desktop-specific Rules section. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> |
||
|
|
c2f7dc49f8 |
refactor(desktop): model pre-workspace flows as window overlays, not tab routes (#1237)
Previously /workspaces/new and /invite/:id were tab routes on desktop. That meant the TabBar rendered on top of flows that conceptually aren't "places" the user sits at — creating a workspace or accepting an invite is a one-shot transition, not a session. The mismatch also produced several downstream bugs: tab state persisted these paths, the invite deep link had no clean dispatch target, and NoAccessPage leaked TabBar chrome when a workspace slug went stale. Fix by recognising the underlying category mistake: on desktop, these flows are application state, not routes. Move them to a window-level overlay driven by a small Zustand store; the navigation adapter intercepts pushes to the corresponding paths and routes them to the overlay instead. Web keeps the routes (users need shareable URLs and back-button semantics), so shared view components are reused as-is. UX affordances (Back button when dismissable, Log out escape) live in the shared NewWorkspacePage/InvitePage so both platforms render identical content; the desktop overlay is now a thin platform shell (drag strip + useImmersiveMode) that wraps the shared UX. Web wires onBack based on whether the user has any workspaces. Also addresses several related issues uncovered along the way: - Logout now resets the in-memory tab + overlay stores (previously only localStorage was cleared, so the next login inherited the prior user's tabs). - WorkspaceRouteLayout auto-heals a stale workspace slug by navigating to "/" instead of rendering NoAccessPage — on desktop without a URL bar, "no access" is always stale state, not a legitimate destination. - IndexRedirect overlay lifecycle is bidirectional: opens when wsList is empty, closes when it becomes non-empty (realtime workspace:added would otherwise leave the overlay stuck open). - tryRouteToOverlay resets the current tab to "/" when opening the new-workspace overlay; otherwise workspace-scoped components under the overlay continue to render and throw when the workspace they reference disappears from the cache (reproducible by deleting the last workspace from Settings). - handleDeepLink now accepts multica://invite/<id>, IPC'd through to the renderer and opened as an invite overlay. Email template still links to https:// (unchanged), but the desktop dispatch path is now wired for a future "open in desktop app" bridge. Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> |
||
|
|
d7a8e9041e |
refactor(landing): tighten hero — CTAs, install copy, works-with wrap, LCP priority (#1227)
* refactor(landing): tighten hero — CTAs, install copy, works-with wrap, LCP priority - Drop GitHub button from hero CTAs (already in header) so the primary Start / Download Desktop pair is the clear path. - Split InstallCommand: outer is no longer a <button>, so text selection no longer fights with copy. Mobile gets full-width with break-all; desktop keeps the compact pill. Copy button has aria-label. - Fix invalid `hover:bg-white/8` opacity to `hover:bg-white/[0.08]` so the install pill's hover background actually renders. - Add `flex-wrap` and gap-y to the "Works with" row so the label + 5 logos can stack on small screens instead of overflowing horizontally. - Move `priority` from the decorative backdrop image onto the product hero image (the actual LCP candidate) to stop background bytes from starving the foreground. * refactor(landing): remove install command from hero Per design feedback, the install command pill is removed from the hero. The download path now flows through the Download Desktop CTA only; install instructions remain available in the docs and README. |
||
|
|
6e980925cf |
chore(desktop): DESKTOP_APP_SUFFIX env for parallel-worktree dev (#1215)
Dev Electron uses a single userData path ("Multica Canary") derived from
the app name, which also locates the single-instance lock. Two worktrees
running dev simultaneously fight for that lock — the second `app.quit()`s
silently before opening a window.
DESKTOP_APP_SUFFIX appends to the app name + userData path so each
worktree can claim its own lock:
DESKTOP_APP_SUFFIX=foo → "Multica Canary foo"
Default (no env var) keeps behavior unchanged.
Complements the existing DESKTOP_RENDERER_PORT env from #1210 so a full
"run a second dev Electron" setup looks like:
DESKTOP_RENDERER_PORT=15173 DESKTOP_APP_SUFFIX=foo pnpm dev:desktop
|
||
|
|
8816e1669c |
feat(desktop): brand dev build as Multica Canary with bundled icon (#1210)
* feat(desktop): brand dev build as Multica Canary with bundled icon pnpm dev:desktop ran under the stock Electron name and default icon, making it indistinguishable from any other Electron dev app in the dock. Set a Canary app name + userData path and point the macOS dock icon and BrowserWindow icon at the bundled resources/icon.png so the dev build is visually branded. * feat(desktop): allow overriding renderer port via DESKTOP_RENDERER_PORT Lets a second worktree run `pnpm dev:desktop` while a primary checkout already holds the default Vite dev port 5173 — required to actually exercise the "Multica Canary" branding in isolation. * feat(desktop): rebrand Electron.app Info.plist so dev shows Multica Canary app.setName() can't override the macOS menu bar title or Cmd+Tab label — those come from CFBundleName baked into the running bundle's Info.plist. Patch the bundled Electron.app's plist during `pnpm dev:desktop` so dev launches read "Multica Canary" everywhere, not "Electron". Idempotent; unlinks before rewriting so we don't mutate a pnpm-store inode shared with other projects. |
||
|
|
fe6208c61f |
fix(desktop): strip leading '--' so --publish reaches electron-builder (#1199)
When invoked as `pnpm package -- --mac --arm64 --publish always`, the bare `--` separator that pnpm inserts was forwarded into electron-builder's argv. This terminated option parsing, causing `--publish always` to be treated as positional arguments instead of a named flag. As a result electron-builder built locally but never uploaded artifacts to the GitHub Release (isPublish: false). Add `stripLeadingSeparator()` to remove the leading `--` before passing args through. Includes unit tests. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> |
||
|
|
336f90fd26 |
fix(desktop): new tab inherits current workspace + guard against malformed tab paths (#1198)
* fix(desktop): new tab inherits current workspace + guard against malformed tab paths
Three layered fixes for the same root cause: tab URLs were being
constructed without a workspace slug in some code paths, triggering
NoAccessPage whenever the router interpreted the first segment as a
(non-existent) workspace slug.
## Layer 1 — tab-bar "+" button now inherits current workspace
The handler had a hardcoded `path = "/issues"` left over from before
the slug URL refactor. Without a workspace prefix, the router saw
`workspaceSlug = "issues"` and rendered NoAccessPage. Read
`getCurrentSlug()` and build `/{slug}/issues` instead. Falls back to
"/" (→ IndexRedirect) when there is no current workspace.
This matches terminal/IDE new-tab semantics: new tab opens in the
same workspace as the active tab, not in `wsList[0]`.
## Layer 2 — validateWorkspaceSlugs runs synchronously
PR #1178 added startup validation of persisted tab slugs against the
current workspace list, but ran it in a useEffect. useEffect fires
AFTER commit, so the initial render would briefly show NoAccessPage
on a stale slug before the effect reset the tab path. Moving the call
into render phase eliminates that flash; zustand supports setState in
render, and the validator is idempotent (early-returns if nothing
changed) so this doesn't loop.
## Layer 3 — tab store rejects malformed paths at construction
Any path whose first segment is a reserved slug (e.g. "/issues",
"/login") clearly lacks a workspace prefix and is a caller bug.
sanitizeTabPath catches these at makeTab time, rewrites to "/", and
logs a console.warn naming the offending path so the bug can be fixed
at source. Any future new-tab entry point that forgets the slug will
not reach NoAccessPage.
Net effect: NoAccessPage is reserved for its legitimate purpose —
users navigating to URLs they genuinely don't have access to — and
can no longer be triggered by system bugs.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* review: read new-tab workspace from active tab + unify sanitize + add tests
Three follow-ups from self-review of PR #1198:
1. Resolve the current workspace from the active tab's path instead of
from getCurrentSlug(). With N tabs mounted under <Activity>, every
WorkspaceRouteLayout calls setCurrentWorkspace() in render — the
singleton ends up holding "whichever tab rendered last", which is
non-deterministic. activeTabId is the unambiguous source of truth
for "which workspace is the user actually looking at right now".
2. Unify the persist merge's stale-path detection with sanitizeTabPath.
The merge previously checked ROUTE_ICONS (dashboard segments only);
sanitizeTabPath uses isReservedSlug (dashboard + auth + platform +
RFC 2142 + hostname confusables). Same code path now, wider
coverage, and one source of truth.
3. Add unit tests for sanitizeTabPath: root pass-through, global paths,
valid workspace-scoped paths, malformed paths (reserved first
segment) rejected with console.warn, and user slugs that happen to
look path-like but aren't reserved.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
|
||
|
|
6d6bc5a6f2 |
fix(routing): rename /new-workspace to /workspaces/new + extend reserved slug list (#1188)
* fix(routing): rename /new-workspace to /workspaces/new + extend reserved slug list
Two related changes:
1. Rename the global workspace-creation route from /new-workspace to
/workspaces/new. The hyphenated word-group `new-workspace` is a
common user workspace name (last deploy was blocked by a real user
with exactly this slug). Industry consensus from auditing Linear,
Vercel, Notion, Slack, GitHub: zero major SaaS uses hyphenated
word-group root routes — they all use single words or `/{noun}/{verb}`
pairs. Reserving the noun `workspaces` automatically protects the
entire `/workspaces/*` subtree, so future workspace-related routes
(`/workspaces/{id}/edit`, `/workspaces/{id}/billing`, etc.) need no
additional reserved slugs or audit migrations.
2. Extend the reserved slug list to cover the minimal set recommended by
the URL-design audit: full auth flow vocab, RFC 2142 mailbox names
(postmaster, abuse, noreply...), hostname confusables (mail, ftp,
static, cdn...), and likely-future platform routes (docs, support,
status, legal, privacy, terms, security, etc.). Production data
audit confirmed zero conflicts for every newly added slug, so
migration 047 (the safety net) passes cleanly.
Slugs intentionally NOT added despite being in scope of the audit:
admin, multica, new, setup, www. Each has one production workspace
already using it; adding them now would block deploy. They will be
handled in a follow-up PR via owner outreach + targeted rename.
Also adds a CLAUDE.md convention rule: new global routes MUST use a
single word or `/{noun}/{verb}` pair, never hyphenated word groups.
This prevents the pattern from regenerating itself.
This PR does NOT resolve the currently-blocked prd deploy — that requires
the existing `slug='new-workspace'` workspace (owner: Dhruv Raina) to be
renamed by ops. After that workspace is renamed and migration 046 passes,
this PR's migration 047 will also pass on its first run.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* review: drop migration 046, sweep stale comments, drive reserved test from map
Address code review on PR #1188:
1. Delete migration 046 (audit_new_workspace_slug). It audits "new-workspace"
which is no longer a reserved slug after this PR's rename. Removing 046
has an unexpected upside: it directly unblocks the currently-stuck prd
deploy. Migration 046 had never successfully applied (it was the source
of the deploy block); the audit-only nature means down-rollback is a
no-op. The user workspace previously caught by 046 (slug='new-workspace',
owner: Dhruv Raina) is now safe — `new-workspace` is no longer reserved,
so the slug correctly resolves to that workspace and the global route
`/workspaces/new` doesn't shadow it.
2. Refactor workspace_test.go to drive its reserved-slug list from the
reservedSlugs map directly via `for slug := range reservedSlugs`. The
previous hand-copied list was already drifting (40-ish entries vs 58 in
the map). Now drift is impossible.
3. Sweep ~10 stale `/new-workspace` references in code comments to
`/workspaces/new`. Comments only — runtime unchanged. The references
in reserved-slugs.ts/workspace_reserved_slugs.go and CLAUDE.md are
intentionally kept as anti-pattern examples ("don't add hyphenated
word-group root routes like /new-workspace").
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
|
||
|
|
fe13259cc6 |
fix(desktop): validate persisted tab slugs against current workspace list (#1178)
Desktop tabs persist their full path to localStorage (multica_tabs), so a tab path like /naiyuan/issues survives app restarts, account switches, and workspace deletions. Any stale slug caused WorkspaceRouteLayout to render NoAccessPage immediately on login — the user saw "Workspace not available" every time they opened the app, with no way to recover except manually opening a new tab or clearing localStorage. Root cause: persisted URL strings outlive the server-state they reference. The auth initializer fetches a fresh workspace list on every startup, but nothing validated the tab paths against it. Fix: add tab-store.validateWorkspaceSlugs(validSlugs). Runs on every change to the workspace list query data (login, background refetch, realtime workspace:deleted). Any tab whose first path segment isn't in the valid slug set is reset to `/`, where IndexRedirect picks a live workspace (or /new-workspace if the user has none). Idempotent, so over-triggering is safe. Tabs on global paths (/login, /new-workspace, /invite/...) are left alone. Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com> |
||
|
|
6a2432b16b |
refactor: remove onboarding flow, fix daemon zero-workspace bootstrap (#1175)
* fix(daemon): allow startup with zero workspaces The daemon used to fail fast with "no runtimes registered" when the initial workspace sync returned zero workspaces. This masked a latent bug: a newly-signed-up user has no workspaces yet, so the daemon would crash immediately after login instead of waiting for the first workspace to be created. workspaceSyncLoop already polls every 30s (daemon.go:107, 365) to discover new workspaces — the fail-fast check at startup was bypassing this dynamic discovery. Remove the check so the daemon stays resident and picks up the first workspace whenever it appears. PR #1001 partially addressed this for the "server has workspaces but local CLI config is empty" case. This finishes the job for the true zero-workspace state, which until now was masked by the onboarding wizard always creating a workspace before the daemon started. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * refactor(views): extract CreateWorkspaceForm for reuse Modal and the upcoming /new-workspace page share the same form + mutation + slug validation. Extract to a shared component so they can't drift. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * feat(views): add NoAccessPage for unknown or inaccessible workspace slugs Rendered when the URL slug doesn't resolve to a workspace the user has access to. Deliberately doesn't distinguish 404 vs 403 to avoid letting attackers enumerate workspace slugs. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * feat(paths): add /new-workspace route and reserve slug on both sides Adds paths.newWorkspace() builder, registers /new-workspace as a global (pre-workspace) prefix, and reserves the "new-workspace" slug on both frontend and backend (kept in sync per convention). Existing "onboarding" reservation retained — removing it would desync FE/BE and leaves no future fallback if an onboarding route is revived. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * chore(migrations): audit no existing workspace uses 'new-workspace' slug Migration 046 blocks deploy if any workspace in the DB has slug = 'new-workspace', which would shadow the new global workspace creation route at /new-workspace. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * feat: add /new-workspace route on web and desktop Renders the CreateWorkspaceForm as a full-page workspace creation flow, used as the destination for first-time users with zero workspaces. Replaces the 4-step onboarding wizard with a single form. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * feat: show NoAccessPage on unknown workspace slug, hold null during active removal Layouts render NoAccessPage when the URL slug doesn't resolve to an accessible workspace — except when the slug previously resolved during this layout instance's lifetime. URL and cache are two asynchronous signals: there will always be a short window where the URL still points at the old workspace but the cache has already been invalidated (e.g. just after a delete/leave mutation, or a realtime workspace:deleted event). Rendering NoAccessPage during that window would flash "Workspace not available" with recovery buttons in front of a user who just deleted the workspace themselves — jarring and wrong. useWorkspaceSeen classifies the two cases: - slug was seen before, now gone → user's intent is changing (caller is navigating away); render null, no flash - slug never seen → user is genuinely looking at an inaccessible workspace (stale bookmark, revoked access, link from a former teammate); render NoAccessPage with recovery options NoAccessPage deliberately does not distinguish 404 vs 403 to avoid letting attackers enumerate workspace slugs. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * refactor: redirect zero-workspace users to /new-workspace instead of /onboarding Switches 8 call sites and the CLI: - Web: login, auth callback, landing redirect-if-authenticated - Desktop: routes.tsx IndexRedirect - Shared: dashboard guard, invite page fallback, workspace-tab on delete, realtime sync on workspace loss - CLI: cmd_login.go waitForOnboarding now opens /new-workspace Also adds /new-workspace to navigation store's lastPath exclusion list so it doesn't get persisted as a 'last visited' page. Adds a desktop App.tsx effect that restarts the daemon when workspace count transitions 0 → ≥1, so first-workspace creation triggers immediate daemon pickup rather than waiting up to 30s for the daemon's workspaceSyncLoop. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * refactor: remove onboarding flow The 4-step onboarding wizard (workspace → runtime → agent → demo issues) is replaced by: - /new-workspace: a single-page workspace creation form (Phase 3) - NoAccessPage: explicit feedback when a slug doesn't resolve (Phase 4) - daemon zero-workspace bootstrap (Phase 1) so the daemon doesn't crash before the user creates their first workspace - desktop daemon restart on first workspace creation (Phase 5) for instant pickup instead of the 30s workspaceSyncLoop tick Deletions: - packages/views/onboarding/ (OnboardingWizard + 4 step components + tests) - apps/web/app/(auth)/onboarding/page.tsx - apps/desktop/src/renderer/src/components/onboarding-gate.tsx (+test) - OnboardingGate wrapper in desktop-layout.tsx - OnboardingRoute + /onboarding route in desktop routes.tsx - paths.onboarding() builder + /onboarding from GLOBAL_PREFIXES - packages/views/package.json onboarding export - /onboarding from navigation store's EXCLUDED_PREFIXES Retained (intentional): - 'onboarding' in RESERVED_SLUGS (both FE + BE) — kept for FE/BE sync and future-proofing if /onboarding is ever revived Also drops 4 demo issues that onboarding used to create on the new workspace ('Say hello', 'Set up repo', etc.). New workspaces are now fully empty; all list views already render empty-state UI correctly. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * chore: clean stale 'onboarding' references in comments and CLI helpers Batch cleanup of references to the removed onboarding flow: - 13 comment sites mentioning 'onboarding' updated to reflect the new /new-workspace flow or removed where no longer accurate - CLI waitForOnboarding renamed to waitForWorkspaceCreation (function name + docstring); behavior unchanged The 'onboarding' reserved slug entries (frontend + backend) are intentionally retained — see prior commit rationale. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * refactor(views): extract shared NewWorkspacePage shell The web (/new-workspace) and desktop (NewWorkspaceRoute) pages had identical outer layout — same container, heading, and copy — with only the onSuccess navigation primitive differing. That's exactly the No-Duplication Rule pattern: extract the shared UI, inject the platform-specific behavior. The apps now only own the thin auth guard (web needs it, desktop routes below WorkspaceRouteLayout already handle it) and the onSuccess → navigate call. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * refactor: remove rollback compat layer and tighten daemon restart trigger Two cleanup items: 1. Drop localStorage['multica_workspace_id'] double-write in both workspace layouts. That write was added as a rollback safety net for the workspace-slug URL refactor (PR #1138) — the refactor has since landed and stabilized, so the compat shim is no longer needed. Per CLAUDE.md: don't keep compat layers beyond their purpose. 2. Tighten the desktop daemon-restart trigger. The previous ref-based logic fired a restart on any 0→1 workspace-count transition, including account switches (user A logout → user B login). Scope it precisely to 'this session started with zero workspaces and just gained one' using a three-state ref (null=undecided, true=empty-start, false=already-restarted-or-started-nonempty). Account switches are already handled by daemon-manager.ts on token change, so this avoids a redundant restart there. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix(auth): redirect to /login on logout and unauthenticated workspace visits Two gaps previously left users stuck on blank workspace pages: 1. app-sidebar logout() cleared all state but never moved the URL. The current path is /{workspaceSlug}/... which has no meaning without auth; the workspace layout would then see user=null, render null (via the hasBeenSeen short-circuit), and the user saw a blank page thinking logout didn't work. 2. The workspace layouts (web + desktop) had no !user handling at all. Any path that leaves user=null — token expiration, cross-tab logout, or fresh visit to a workspace URL without a session — resulted in the same blank screen. Fix: - app-sidebar.logout() explicitly push(paths.login()) after authLogout() to cover the primary (user-initiated) logout path. - Both workspace layouts get a defensive useEffect that redirects to /login whenever auth has settled and user is null. Covers token expiration, realtime logout, and any other silent session loss. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com> |
||
|
|
3a5f94cbdd |
docs: add v0.2.1 changelog (2026-04-16) (#1177)
* docs: add v0.2.1 changelog entry (2026-04-16) * docs: swap desktop download with workspace URL refactor in v0.2.1 |
||
|
|
94c9d2807a |
fix(core): collapse workspace rehydrate side effect into setCurrentWorkspace (#1164)
Problem ------- On desktop, creating a new tab triggered thousands of chat-store rehydration logs per second (sustained for seconds). Same session, same workspace — nothing actually changed. `pnpm test` was clean; the bug only manifests at runtime with React 19 Activity + multi-tab. Root cause ---------- Every tab's WorkspaceRouteLayout kept its own `syncedSlugRef` to decide "did slug change since last sync". That model assumes one layout instance equals one workspace context — true on web, false on desktop where N tabs each mount their own layout. Activity remounts + tab-router-sync stirring the tab store caused per-layout refs to drift out of agreement with the module-level truth, so each ref independently called `rehydrateAllWorkspaceStores()`. The existing microtask dedup only coalesced same-tick calls; successive ticks each scheduled another iteration through every registered rehydrate fn. Fix --- Move the "did slug actually change?" decision to where the truth lives: inside `setCurrentWorkspace` itself. The singleton now: - Returns immediately when the slug is already current (idempotent). - Fires slug subscribers + persist rehydrate as internal side effects when (and only when) the slug transitions. Layouts are simplified to "feed the URL slug in"; they no longer maintain a ref guard or call rehydrate explicitly. N tabs feeding the same slug is naturally a no-op after the first — the model no longer depends on "one layout instance" as an implicit invariant. Also hardens the original render-time race that motivated the v2 refactor: both layouts now gate on `!listFetched || !workspace` so `useWorkspaceId()` in descendants is guaranteed non-null. Public API ---------- `rehydrateAllWorkspaceStores` removed from `@multica/core/platform` exports — it's now purely an internal effect of `setCurrentWorkspace`. The function itself is deleted; the rehydrate loop lives inline in `setCurrentWorkspace`. Tests ----- Four new tests covering the new semantics: single rehydrate on mount, same-slug noop across repeat calls, real workspace switch fires again, logout → re-entry into same workspace fires again. Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com> |
||
|
|
fa804c2215 |
feat(web): add Desktop download entry to landing page (#1093)
Add a "Download Desktop" button in the hero section alongside the existing CTA and GitHub buttons, linking to the latest GitHub release. Also add a Desktop link in the footer product group for both EN and ZH. |
||
|
|
e3a1b951fb |
fix(desktop): allow dev and production instances to coexist (#1155)
Dev mode now uses a separate app name ('Multica Dev') and userData path
before acquiring the single-instance lock, so the lock file no longer
collides with the packaged production app. The AppUserModelId is also
differentiated (ai.multica.desktop.dev vs ai.multica.desktop).
This follows the same pattern VS Code uses for Stable / Insiders
coexistence: isolate identity before requestSingleInstanceLock().
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
|
||
|
|
8c518c350a |
feat(agent): add Pi agent runtime support (#1064)
* feat(agent): add Pi agent runtime support
Add Pi as a new agent runtime provider, following the established adapter
pattern. Pi CLI outputs JSONL events which are parsed for messages, tool
calls, and usage tracking.
Backend:
- New piBackend implementing the Backend interface (pi.go)
- Pi CLI discovery via MULTICA_PI_PATH env var or PATH lookup
- JSONL event stream parsing (agent_start, message_update, thinking_update,
tool_execution_start/end, agent_end)
- Usage scanner for ~/.pi/sessions/*.jsonl files
- Runtime config injection via AGENTS.md
- Skill injection to .pi/agent/skills/
Frontend:
- Pi provider logo (teal π icon)
- Pi label in transcript dialog
Docs:
- Updated all provider lists in README, CLI_INSTALL, and docs
* fix(agent): filter Pi usage scanner to agent_end events only
Address review feedback: restrict usage parsing to agent_end events
which contain cumulative totals, preventing potential inaccuracy if
Pi adds usage fields to other event types in the future.
* fix(agent): align Pi runtime with real CLI flags, event schema, and custom_args
- Flags: Pi's CLI uses `--mode json` (not `--output-format jsonl`), has no
`--yolo` (explicit `--tools` allowlist instead), takes the prompt as a
positional argument (not `-p <prompt>`), splits model as
`--provider <name> --model <id>`, and treats `--session` as a file path
that must exist before spawn.
- Event parsing: rewrite the stream event struct to match Pi's actual
JSON event schema (`message_update.assistantMessageEvent.delta`,
`turn_end.message.usage.{input,output,cacheRead,cacheWrite}`, etc.).
- Sessions: generate/persist session files under ~/.multica/pi-sessions/
and use the file path as the opaque SessionID returned to the daemon.
- Usage scanner: read assistant `message` events from the same session
files (Pi's session-file schema, distinct from the stdout stream).
- Custom args: consume `ExecOptions.CustomArgs` via `filterCustomArgs`
with a Pi-specific blocked set (`-p`, `--print`, `--mode`, `--session`)
so Pi matches the pattern shared by every other agent backend.
|
||
|
|
b5c6a9b8f0 |
fix(desktop): reserve traffic-light space and surface trigger when sidebar is hidden (#1144)
The top bar pads `pl-20` for the macOS traffic lights only when `state === "collapsed"`, but the shadcn sidebar also hides itself in mobile mode (<768px) where `state` stays `"expanded"` and only `isMobile` flips. In that case tabs slid under the traffic lights and no UI affordance existed to bring the sidebar back (since the in-sidebar trigger went off-canvas with it). Treat both as "sidebar not in main flow", apply the padding, and render a `SidebarTrigger` in the header (with `no-drag` so the window drag region doesn't swallow the click). Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com> |
||
|
|
fe358feff0 |
Reapply "feat: workspace URL refactor v2 + rollback-safe compat layer (#1138)" (#1139) (#1141)
This reverts commit
|
||
|
|
b30fd98605 |
Revert "feat: workspace URL refactor v2 + rollback-safe compat layer (#1138)" (#1139)
This reverts commit
|
||
|
|
75d12c26c5 |
feat: workspace URL refactor v2 + rollback-safe compat layer (#1138)
* Reapply "feat: workspace URL refactor + slug-first API identity (#1131)" (#1137)
This reverts commit
|
||
|
|
9b94914bc8 |
Revert "feat: workspace URL refactor + slug-first API identity (#1131)" (#1137)
This reverts commit
|
||
|
|
59ace95a1e |
feat: workspace URL refactor + slug-first API identity (#1131)
* feat: workspace URL refactor + slug-first API identity
Make the URL the single source of truth for workspace identity.
All workspace-scoped URLs now carry the workspace slug as the first
path segment (/{slug}/issues, /{slug}/projects, etc.), matching the
industry standard (Linear, Notion, Vercel, GitHub).
## Key architectural changes
**URL-driven workspace identity:**
- Web routes moved under app/[workspaceSlug]/(dashboard)/
- Desktop routes nested under /:workspaceSlug
- paths.ts builder centralises all URL construction
- reserved-slugs validation (backend + frontend + DB migration audit)
**Slug-first API contract:**
- Frontend sends X-Workspace-Slug header (from URL) instead of X-Workspace-ID (UUID)
- Backend middleware resolves slug → UUID via GetWorkspaceBySlug, falls back to
X-Workspace-ID for CLI/daemon backwards compatibility
- WebSocket auth accepts ?workspace_slug query param with SlugResolver callback
**State cleanup:**
- Deleted: useWorkspaceStore (Zustand mirror), switchWorkspace/hydrateWorkspace/
clearWorkspace, localStorage["multica_workspace_id"], api._workspaceId
- useCurrentWorkspace() derives from URL slug + React Query workspace list
- useWorkspaceId() is now a bridge hook (no Context, derives from useCurrentWorkspace)
- WorkspaceIdProvider removed from DashboardGuard
- Paired module vars (slug + UUID) in workspace-storage.ts for non-React consumers
**Layout simplified:**
- Render-phase ref guard sets workspace context synchronously (no async gate)
- DashboardGuard handles auth redirect, loading state, and workspace resolution
- Subscriber notifications deferred via queueMicrotask (React 19 compat)
- persist namespace uses slug (immutable) instead of UUID
## Issues resolved
MUL-43 (share links), MUL-509 (mobile workspace switch), MUL-723 (workspace in URL),
MUL-727 (create workspace flash), MUL-728 (delete workspace no-navigate),
MUL-820 (sidebar Join not switching)
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix: resolve code review C3/C4/C5/C6 — desktop deadlock + hardcoded paths
C3: Desktop OnboardingGate was calling useCurrentWorkspace() outside
WorkspaceSlugProvider → always null → permanent onboarding deadlock.
Rewrite to use useQuery(workspaceListOptions()) which reads React Query
cache directly without slug context. Remove DashboardGuard from
DesktopShell (auth gating handled by AppContent, workspace routing by
WorkspaceRouteLayout per-tab).
C4: Landing page "Dashboard" links hardcoded /issues (no longer valid).
Changed to / — proxy handles redirect to /{lastSlug}/issues.
C5: autopilots-page.tsx had one hardcoded /autopilots/${id} link.
Changed to wsPaths.autopilotDetail(id).
C6: inbox-page.tsx hardcoded /inbox paths. Changed to wsPaths.inbox().
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix(desktop): wrap shell in WorkspaceSlugProvider from module var
AppSidebar calls useWorkspacePaths() → useRequiredWorkspaceSlug() which
throws outside WorkspaceSlugProvider. In the desktop shell, the sidebar
renders at the shell level (outside any tab's WorkspaceRouteLayout).
Fix: DesktopShell reads the current slug via useSyncExternalStore on
the workspace-storage singleton. When slug is available, wraps the
entire shell in WorkspaceSlugProvider. When null (first mount before
any tab's WorkspaceRouteLayout sets it), shows a loading spinner.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix(desktop): migrate old tab paths + fix shell slug deadlock
Tab store rehydration: old-format paths like "/issues/abc" (missing
workspace slug prefix) are reset to "/" so IndexRedirect picks the
correct workspace. Detection: if the first segment is a known route
name (issues, projects, etc.) rather than a workspace slug, it's an
old-format path.
Desktop shell: TabContent must always render (not gated behind slug
check) so WorkspaceRouteLayout can mount and call setCurrentWorkspace.
Only sidebar and shell-level UI (chat, modals, search) gate on slug
being present.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
|
||
|
|
5a44c255fe | docs: add v0.2.0 changelog entry (2026-04-15) (#1078) | ||
|
|
8a55473bb8 |
fix(desktop): evaluate daemon spawn env lazily to pick up PATH fix (#1088)
DESKTOP_SPAWN_ENV was a top-level const in daemon-manager.ts that snapshotted process.env at module load. Because ESM imports are hoisted and evaluated before main/index.ts runs fix-path, the snapshot captured launchd's minimal PATH — missing ~/.local/bin, Homebrew, etc. The main process then had the corrected PATH, but every spawned daemon inherited the stale one and failed with "no agent CLI found" on fresh GUI launches. Convert it to desktopSpawnEnv() so process.env is read at call time, after fix-path has already updated it. Co-authored-by: Devv <devv@Devvs-Mac-mini.local> Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com> |
||
|
|
ce94c80f5a |
fix(desktop): read VITE_APP_URL for Google login external redirect (#1086)
The desktop login was reading VITE_WEB_URL, which is defined nowhere in the committed env files. In production builds the variable was undefined, so Google login opened http://localhost:3000/login?platform=desktop instead of https://multica.ai/login?platform=desktop. Switch to VITE_APP_URL, which is already set in apps/desktop/.env.production and is the same variable platform/navigation.tsx uses for shareable links. Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com> |
||
|
|
176f1bfdbb |
refactor(desktop): keep only create-workspace step in onboarding (#1083)
Fresh desktop accounts no longer need to walk through runtime, agent, and get-started steps before reaching the app. Once the workspace is created, the onboarding gate hands off directly to the main shell. Web onboarding is unchanged. Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com> |
||
|
|
0e8a7b1734 |
fix(desktop): make packaged app usable for fresh accounts (#1074)
* feat(desktop): add macOS app icon Replace the default electron-vite scaffold icon with the Multica asterisk icon. Adds build/icon.icns so electron-builder picks it up automatically via the `buildResources: build` config — no YAML change needed. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix(desktop): run electron-vite build inside package script The package wrapper only ran bundle-cli.mjs and electron-builder, so electron-builder silently packaged whatever was already in out/. On a fresh checkout (or after a partial build) this shipped an app with a missing renderer bundle, which white-screens on launch. Add an explicit `electron-vite build` step between bundle-cli and electron-builder so `pnpm package` is self-contained. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix(desktop): restore shell PATH in main process for GUI launches macOS/Linux GUI launches inherit a minimal PATH from launchd that omits ~/.zshrc, Homebrew, nvm, ~/.local/bin, and other shell config. Child processes spawned from the main process — including the bundled multica CLI used by daemon-manager — inherit the same stripped PATH, so the CLI fails to locate agent binaries like claude, codex, opencode, etc. with "no agent CLI found: … ensure it is on PATH". Use `fix-path` to recover the real shell PATH at startup, then prepend common install locations (/opt/homebrew/bin, /usr/local/bin, ~/.local/bin) as a fallback for broken shell rc or non-interactive $SHELL. Runs before setupDaemonManager so every subsequent spawn sees the corrected PATH. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix(desktop): show onboarding wizard when authed user has no workspace Desktop is a single-shell architecture — every route, including /onboarding, lives inside DashboardGuard. The guard returns its loading fallback whenever workspace is null, so a fresh account that logs in with no workspaces ends up stuck on the spinner forever: the `replace(onboardingPath)` redirect navigates the tab router, but DashboardGuard still blocks its children because workspace is still null. Handle the empty-workspace case in DesktopShell itself: render OnboardingWizard as a full-screen takeover, bypassing DashboardGuard. A ref-based flag freezes the "needs onboarding" decision at first mount so creating a workspace mid-wizard (step 0) doesn't unmount the wizard and dump the user into the main shell before steps 1-3 (runtime, agent, get started) finish. Also add a local `bootstrapping` flag in AppContent so DesktopShell doesn't mount until the deep-link login chain (loginWithToken → syncToken → listWorkspaces → hydrateWorkspace) fully resolves. Without it, the shell would briefly see `!workspace` before hydration lands, causing users with existing workspaces to flash the wizard (or, with the ref freeze, get stuck in it permanently). Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * refactor(desktop): extract OnboardingGate with test coverage Pull the "render onboarding wizard when authed user has no workspace" logic out of DesktopShell into a dedicated OnboardingGate component. Replaces the ref-based freeze with a lazy useState initializer (`useState(() => !hasWorkspace)`), which is React's idiomatic pattern for "capture a value once at mount". The freeze semantics are unchanged: creating a workspace in step 0 of the wizard must not unmount it, because steps 1-3 still need to run; only `onComplete` flips the gate back to the main shell. Also de-duplicates the wrapping DesktopNavigationProvider — both branches of the shell now share a single provider instead of re-mounting one per branch. Wire up jsdom + @testing-library/react in the desktop vitest config (mirroring packages/views) and add three deterministic tests covering: 1. children render when hasWorkspace is true at mount 2. wizard stays mounted when hasWorkspace flips to true mid-flow 3. onComplete transitions the gate to children Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * refactor(desktop): drop redundant syncToken call in deep-link login daemonAPI.syncToken was called twice on a deep-link login: once inside the deep-link handler's bootstrapping chain, and again in the useEffect([user]) that reacts to the user state change. Both calls spawn a multica CLI subprocess over IPC, wasting ~1-2s of startup time on the critical login path. Keep the [user] effect (it covers the session-restore path too) and drop the explicit call from the deep-link handler. Net effect: login latency shrinks, behavior is unchanged. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com> |
||
|
|
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 |
||
|
|
c0db3e0e76 |
Revert "feat(selfhost): add single-domain Caddy setup (#899)" (#1062)
This reverts commit
|
||
|
|
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> |