575 Commits

Author SHA1 Message Date
Bohan Jiang
a6db465e46 fix(ui/agents): drop Codex-incompatible --model example from custom args tab (#1310)
* fix(agent/codex): route custom_args -m/--model to thread/start payload

Codex agents spawn via `codex app-server --listen stdio://`, which does
not accept `-m` / `--model` (those belong to the normal Codex CLI). When
a user's custom_args still carried those tokens the process exited
before the JSON-RPC initialize handshake with `codex process exited`,
with no actionable error.

Extract `-m <v>`, `--model <v>`, and `--model=<v>` from opts.CustomArgs
before invoking app-server and promote the value into opts.Model, so
that startOrResumeThread can pass it through the `thread/start` payload
where Codex actually reads the field.

Fixes #1308.

* fix(ui/agents): drop Codex-incompatible --model example from custom args tab

The helper text and placeholder suggested `--model claude-sonnet-4-…` as
a custom CLI argument, which is valid for Claude but crashes Codex
agents (its `app-server` subcommand does not accept model flags). Swap
in provider-agnostic copy so the UI no longer steers users into an
invalid configuration for non-Claude runtimes.

Refs #1308.

* revert "fix(agent/codex): route custom_args -m/--model to thread/start payload"

This reverts f18355b2. After review, extracting `-m`/`--model` out of
opts.CustomArgs and promoting them into the thread/start payload is the
wrong shape of fix: agent CLIs have many flags their non-interactive
modes don't accept, and hand-translating a subset case-by-case doesn't
scale — it pushes us toward an ever-growing list of per-backend arg
rewriters.

The preferred direction is to teach users via the UI what command their
custom_args extend (see the launch_header preview in #1312) and let
bad configurations fail loudly. If the resulting error is hard to read
that's a separate improvement we should make on the failure path, not
by silently rewriting user input.

Refs #1308.
2026-04-18 14:25:58 +08:00
Bohan Jiang
163f34f918 feat(agents): show launch mode preview in custom args tab (#1312)
* feat(agent): add LaunchHeader per agent type

Each backend in server/pkg/agent/ hardcodes a stable command skeleton
(e.g. `codex app-server --listen stdio://`, `hermes acp`) before
appending opts.CustomArgs. Surfacing that skeleton lets the UI tell
users which command their custom_args are being appended to, so a
Codex user doesn't mistakenly add `-m gpt-5.4-mini` expecting it to
reach the CLI when the subcommand is actually `app-server`.

Expose only the minimum that aids judgment — binary + subcommand, or a
short mode label when there is no subcommand — and deliberately omit
transport values, internal flags, and env to keep the surface small
and renaming-safe.

Refs #1308.

* feat(handler/runtime): surface launch_header on runtime response

runtimeToResponse now derives launch_header from agent.LaunchHeader,
piggybacking on the runtime's existing provider field so the
frontend's RuntimeDevice gains the skeleton without a new endpoint or
DB query. Client gets the header for free whenever it lists agents'
runtimes — which the custom-args tab already does.

Refs #1308.

* feat(ui/agents): show launch mode preview in custom args tab

Thread the resolved RuntimeDevice from AgentDetail into CustomArgsTab
and render its launch_header as a one-line preview above the args
list, so users see `codex app-server <your args>` (or equivalent per
provider) and can tell whether a CLI-style flag like `--model` will
actually reach the invoked subcommand. Source of truth stays in the
Go backend; the TS type just carries the string.

Refs #1308.
2026-04-18 14:18:42 +08:00
Bohan Jiang
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>
2026-04-18 13:24:01 +08:00
Bohan Jiang
0ed16fc1b1 fix(autopilots): spin the Loader2 icon while a run is in progress (#1305)
The autopilot detail page mapped `status: "running"` to a `Loader2` icon
but rendered it without `animate-spin`, so a manually-triggered run sat
on a static circle until the row flipped to completed/failed and the
user got no visual feedback that anything was happening.

Add an optional `spin: true` flag to the run-status config and apply
`animate-spin` when set. Only the running entry is marked.
2026-04-18 13:05:52 +08:00
Naiyuan Qing
4223d32b37 fix(sidebar): prevent pin drag from reloading page and smooth drop animation (#1271)
- Mark AppLink draggable={false} and add pointer-events-none while
  dragging, so the browser's native <a> drag (which otherwise navigates
  to the pin's href on mouse release) is suppressed.
- Introduce a component-local pinnedItems snapshot gated by an
  isDraggingRef, so a mid-drag TQ cache write (optimistic or WS
  refetch) cannot reorder the DOM under dnd-kit's drop animation.
  Mirrors the pattern already used by board-view.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-17 18:11:39 +08:00
Black
2c1478a69c fix(agents): make issue tasks easier to open from agent details (#1152)
* fix(agents): make issue tasks easier to open from agent details

Make task rows in the Tasks tab navigate directly to the related issue
detail page when issue data is available, using AppLink for cross-platform
compatibility. Rows without resolved issue data remain non-clickable.
Adds a subtle hover shadow to make the interactive area more discoverable.

Closes #1129

* fix(agents): use workspace issue paths in tasks tab

* test(agents): cover tasks tab issue links
2026-04-17 17:40:27 +08:00
Jiayuan Zhang
488aed6abf feat(issues): show project and sub-issue progress as optional card properties (MUL-996) (#1258)
Adds two new toggleable card properties that surface issue context at a glance:
- Project: shows the parent project icon + title when the issue belongs to one.
- Sub-issue progress: gates the existing progress ring behind a card property
  so users can hide it when not useful.

Both default to on; toggled via the existing "Display" popover.
2026-04-17 15:25:45 +08:00
Naiyuan Qing
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>
2026-04-17 14:21:10 +08:00
Naiyuan Qing
c157f74a4d fix(inbox): redirect to issue page when notification not in inbox (#1248)
Shared inbox links (?issue=<id>) pointed to notifications that may no
longer exist in the current user's inbox (archived, or received by
someone else). The detail pane would fall back to an empty state and
leave the user stuck.

After inbox loads, if the selected key has no matching item, replace
the URL with /issues/<id> so the link still resolves to something
meaningful.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-17 13:43:26 +08:00
Bohan Jiang
702156904a fix(views): use createSafeId in custom args tab (#1247)
crypto.randomUUID() is only defined in secure contexts, so self-hosted
HTTP deployments were throwing TypeError on mount and when clicking Add.
Route the id generation through the existing createSafeId() helper so
the tab works in non-secure contexts too.

Fixes #1214
2026-04-17 13:42:55 +08:00
LinYushen
c22a9bd88e fix(runtimes): skip CLI update prompts for desktop-managed runtimes (#1243)
Desktop-launched daemons have their CLI binary overwritten by the Desktop
app on every launch, so any in-app update is reset. The detail panel already
renders 'Managed by Desktop' and hides the Update button when
metadata.launched_by === 'desktop', but the sidebar red dot
(useMyRuntimesNeedUpdate) and the list arrow (useUpdatableRuntimeIds) still
flagged them because runtimeNeedsUpdate() only considered mode/owner/version.

Short-circuit runtimeNeedsUpdate() on launched_by === 'desktop' so all three
surfaces (sidebar dot, list arrow, detail panel) agree and defer CLI
upgrades to the Desktop auto-updater.

Co-authored-by: CC-Girl <cc-girl@multica.ai>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-04-17 12:55:06 +08:00
Naiyuan Qing
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>
2026-04-17 12:15:54 +08:00
Naiyuan Qing
65e2bf937e fix(editor): rewrite bubble menu with @floating-ui/dom for scroll hiding (#1240)
* fix(editor): prevent duplicate image attachments showing as file cards

Images pasted into comments could produce duplicate attachment records
(macOS/Chrome clipboard provides duplicate File entries), causing
AttachmentList to show a spurious file card below the inline image.

Three-layer fix:
- Dedup clipboard files by name+size+type in paste/drop handlers
- Track upload URL→ID mapping instead of accumulating IDs blindly;
  only send IDs for uploads still present in content on submit
- AttachmentList filters duplicate attachments (same file identity)
  where a sibling is already referenced inline — handles old data

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

* fix(editor): rewrite bubble menu with @floating-ui/dom for reliable scroll hiding

Replace Tiptap's native <BubbleMenu> with custom @floating-ui/dom positioning.
The native plugin's virtual element lacked contextElement, so the hide middleware
could only detect viewport clipping — not nested scroll container clipping
(e.g. comment/reply inputs inside a scrollable page).

Key changes:
- contextElement on virtual reference enables hide middleware to detect ALL
  overflow ancestor clipping, not just viewport bounds
- visibility:hidden (not display:none) keeps element measurable for
  computePosition, fixing the comma-selection positioning bug
- autoUpdate monitors all scroll ancestors automatically via contextElement
- Remove: getScrollParent, scrollHiddenRef, manual scroll listener, scrollTarget
- Remove @tiptap/extension-bubble-menu dependency (no longer used)

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-17 11:57:29 +08:00
Naiyuan Qing
763c0cd25f feat(workspace): typed delete confirm + sole-owner leave preflight (#1238)
* feat(workspace): typed delete confirm + sole-owner leave preflight

Harden the Danger Zone on the Workspace settings tab.

**Delete workspace** now requires typing the workspace name exactly
(case-sensitive, no trimming) before the destructive button enables —
GitHub's repo-delete pattern. Deleting cascades into every issue,
agent, skill, and run under the workspace with no soft-delete, so the
friction is deliberate. Enter submits only when matched; the input
clears on close so reopening for a different workspace doesn't leak
the prior attempt.

**Leave workspace** now preflights the sole-owner case the backend
already blocks (server/internal/handler/workspace.go:569 — "workspace
must have at least one owner"). Previously the user clicked Confirm
and got an opaque 400 toast; now the Leave button is disabled upfront
with inline guidance that distinguishes:
 - sole member: "Delete the workspace to leave."
 - sole owner with other members: "Promote another member to owner
   first, or delete the workspace."

Both changes live in packages/views/, so web and desktop get the same
Danger Zone treatment automatically.

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

* review: gate Danger Zone on members fetched + reset typed input on rename

Addresses self-review findings on #1238:

- Previously the Danger Zone rendered immediately with `members = []`, so
  the Delete workspace block (gated on `isOwner`, which is derived from
  an empty members list) would flash in once the query settled. Gate the
  whole section on `membersFetched` so it appears once with correct
  controls.
- Reset `typed` on `workspaceName` change too — if another owner renames
  the workspace while the dialog is open, the already-typed string stops
  matching silently; resetting surfaces the mismatch.
- Added two tests: unicode/special-char names match literally; rename
  mid-dialog clears the input.

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

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-17 11:15:58 +08:00
Naiyuan Qing
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>
2026-04-17 10:55:10 +08:00
Naiyuan Qing
0a1c82730f fix(editor): prevent duplicate image attachments showing as file cards (#1231)
Images pasted into comments could produce duplicate attachment records
(macOS/Chrome clipboard provides duplicate File entries), causing
AttachmentList to show a spurious file card below the inline image.

Three-layer fix:
- Dedup clipboard files by name+size+type in paste/drop handlers
- Track upload URL→ID mapping instead of accumulating IDs blindly;
  only send IDs for uploads still present in content on submit
- AttachmentList filters duplicate attachments (same file identity)
  where a sibling is already referenced inline — handles old data

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-17 10:49:29 +08:00
Jiayuan Zhang
cf8a9647bb refactor(autopilot): make status a toggle instead of a pause/activate button (#1228)
Replace the Pause/Activate button on the detail page with a Switch next
to the title, showing a colored status label. Flipping it toggles
between active and paused via the existing updateAutopilot mutation.
2026-04-17 10:05:10 +08:00
Jiayuan Zhang
3b7abae5b4 refactor(search): collapse cmd+k empty-state commands to primary action (#1225)
Previously every registered Command (New Issue, New Project, three theme
switches, plus contextual Copy actions on issue pages) surfaced on empty
query, leaving only 3–5 rows for Recent in a 400px panel. Low-frequency
commands (theme, copy, New Project) are now revealed by typing, matching
the progressive-disclosure pattern already used for Pages and Switch
Workspace. Refs MUL-991.
2026-04-17 09:09:55 +08:00
Jiayuan Zhang
7843da0315 refactor(issues): lighten board card styling (#1217)
Slimmer 0.5px border, 12/10 asymmetric padding, and a two-layer soft
drop shadow give the kanban card a more weightless look on the board.
2026-04-17 02:15:24 +08:00
Jiayuan Zhang
caa18a6983 feat(search): extend cmd+k palette (theme toggle, new issue/project, copy link, switch workspace) (#1208)
* feat(search): add light/dark/system theme toggle actions to cmd+k

The command palette now surfaces an "Actions" section with theme toggle
items (Light / Dark / System), searchable via keywords like "theme",
"light", "dark", "appearance", or "mode". The active theme is marked
with a check icon.

* feat(search): add quick-win commands to cmd+k palette

Extends the command palette with a "Commands" group that consolidates
theme toggles plus four new actions:

- New Issue / New Project — trigger the global create modals
- Copy Issue Link / Copy Identifier (MUL-xxx) — only when the current
  route is an issue detail page; mirrors the copy-link dropdown logic
  from issue-detail

Adds a "Switch Workspace" group that lists the user's other workspaces
(filtered by name/slug, or by typing "workspace"/"switch") and
navigates to the selected workspace's issues page.

To make "New Project" work from anywhere, the inline CreateProjectDialog
on ProjectsPage is extracted into a global CreateProjectModal mounted
via the existing ModalRegistry + modal store (same pattern as
create-issue / create-workspace). The modal store type gains a
"create-project" variant.

* feat(search): show Commands by default so they're discoverable

Before, cmd+k actions (New Issue / New Project / Copy link / Copy ID /
theme toggles) only appeared when the user typed a matching keyword,
leaving them invisible unless the user already knew they existed.

Now the Commands group renders as soon as the palette opens (no query),
with the whole command list shown; typing narrows it down as before.
Also trims the redundant "⌘K to open this anytime" hint from the empty
state — the palette is already open.
2026-04-17 02:03:03 +08:00
Jiayuan Zhang
8bc20ce161 feat(issues): add newly created issue to cmd+k Recent list (#1213)
Hooks recordVisit into useCreateIssue onSuccess so issues the user just
created appear in cmd+k's Recent section without requiring them to open
the issue first.
2026-04-17 01:45:19 +08:00
Jiayuan Zhang
ec30e46947 feat(issues): persist comment collapse state (#1008)
* feat(issues): persist comment collapse state across page reloads

Store collapsed comment IDs in a workspace-scoped Zustand store backed
by localStorage, replacing the transient useState(true) default.
Comments now remember their collapsed/expanded state per issue.

* test(issues): add useCommentCollapseStore mock to issue-detail tests

The existing vi.mock for @multica/core/issues/stores didn't include the
newly exported useCommentCollapseStore, causing CommentCard to throw at
render time.
2026-04-17 00:14:00 +08:00
Naiyuan Qing
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>
2026-04-16 21:21:20 +08:00
Naiyuan Qing
f3d20fd50d fix(auth): 'Sign in as a different user' performs full logout (#1179)
The NoAccessPage button previously only called nav.push('/login'),
leaving the session cookie, React Query cache, and local auth state
intact. AuthInitializer then silently re-authenticates and bounces the
user right back to the workspace URL — the button appeared broken.

Extract the logout flow (clear per-workspace storage, clear cookies,
clear multica_tabs, queryClient.clear(), authStore.logout(), navigate
to /login) into a shared useLogout() hook in packages/views/auth/.
AppSidebar and NoAccessPage both use it now; any future logout entry
point can too.

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-16 19:43:10 +08:00
Naiyuan Qing
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>
2026-04-16 19:18:43 +08:00
Bohan Jiang
bfe407ac55 fix(editor): include done issues in @ mention search (#1172)
* fix(editor): include done issues in @ mention search

The mention picker filtered against the cached issue list, which only
holds the first page of done issues. Older done issues were unfindable
via @, so users had to hand-write `[MUL-xxx](mention://issue/...)` to
reference them.

Switch the issue portion of the picker to the server-side search
endpoint with `include_closed=true` (matching the global Cmd+K search),
debounced and abortable. Done/cancelled rows render dimmed with a
strikethrough title so they remain visually distinct but selectable.

* fix(editor): unblock member/agent results in @ mention picker

The previous patch made items() async and awaited the server-side issue
search before returning anything, which forced even local member/agent
matches to wait for the 150ms debounce + roundtrip.

Return sync items (members, agents, cached issues) immediately and let
the renderer be updated in-place when extra server results arrive. Also
move the search seq/abort state into the createMentionSuggestion closure
so concurrent ContentEditor instances no longer abort each other's
fetches, and aborts on cleanup so a late response can't write to a
destroyed renderer.

Adds a focused test that locks in the sync member/agent path and the
include_closed=true flag.
2026-04-16 19:10:06 +08:00
Bohan Jiang
0fdd0054b9 fix(views): make autopilot run history rows fully clickable (#1171)
The Run History list only had the 'Issue linked' text as a click target.
Wrap the entire row in AppLink when an issue is linked so the whole row
navigates to the issue.
2026-04-16 18:51:10 +08:00
Bohan Jiang
f029eb01b8 fix(auth): retain stored token on non-401 errors in initialize (#1169)
AuthStore.initialize() cleared the stored token on any error from
`api.getMe()`, which meant a transient failure — backend rolling
restart, network blip, HMR-aborted fetch in local dev — would force
a re-login. On 401 the token is already cleared upstream via
ApiClient.onUnauthorized, so the store's catch block only needs to
reset the in-memory user state.

Check `err instanceof ApiError && err.status === 401` before clearing
workspace context; leave the token in storage for every other error
so the next initialize() can retry.

Adds regression tests covering the 401 / 500 / network-failure / happy
paths.
2026-04-16 18:05:47 +08:00
Naiyuan Qing
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>
2026-04-16 17:38:05 +08:00
yushen
48a8a2793e fix(copilot): use GitHub mark (Invertocat) for runtime icon
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-04-16 17:20:01 +08:00
LinYushen
cd50c31201 feat(agent): add GitHub Copilot CLI backend (#1157)
* feat(agent): add GitHub Copilot CLI backend

Integrate Copilot CLI as a new agent backend using the stable
`-p` JSONL mode (`--output-format json`), following the same
spawn-CLI-scan-JSONL pattern established by claude.go.

Backend (server/pkg/agent/copilot.go):
- Spawn `copilot -p <prompt> --output-format json --allow-all-tools --no-ask-user`
- Parse streaming JSONL events (system/assistant/user/result/log)
- Extract session ID for resume support (`--resume <id>`)
- Accumulate per-model token usage for billing
- Filter blocked args to prevent protocol-critical flag overrides

Daemon config:
- Probe MULTICA_COPILOT_PATH / MULTICA_COPILOT_MODEL env vars
- Copilot uses AGENTS.md (native discovery) and default skills path

Frontend:
- Add Copilot logo SVG and provider switch case

Tests: 14 unit tests covering arg building, event parsing, usage
accumulation, and edge cases. All Go + TS checks pass.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* fix(daemon): add restart subcommand, make daemon uses it

- `daemon start` keeps original behavior: errors if already running
- `daemon restart` stops existing daemon then starts fresh
- `make daemon` now runs `daemon restart --profile local`

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* fix(copilot): address review nits 1-5

- Nit 1: Add MinVersions["copilot"] = "1.0.0"
- Nit 2: Seed activeModel from session.start.data.selectedModel (falls
  back to opts.Model, then "copilot"). First-turn tokens now get correct
  model attribution.
- Nit 3: Handle assistant.reasoning/reasoning_delta → MessageThinking,
  reasoningText in assistant.message → MessageThinking,
  session.warning → MessageLog{warn}
- Nit 4: Extract handleCopilotEvent() method shared by production and
  tests — no more duplicated switch body that can drift
- Nit 5: Deltas write to output buffer as defense-in-depth; if process
  dies before assistant.message, output is non-empty

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

---------

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-04-16 17:14:56 +08:00
devv-eve
c0b4e7e8b8 feat(agent): add Cursor Agent CLI runtime support (#1057)
* feat(agent): add Cursor Agent CLI runtime support

Add cursor-agent as a new agent backend, following the same pattern as
existing providers. The implementation spawns cursor-agent CLI with
stream-json output, parses JSONL events into the unified Message type,
and supports session resume, usage tracking, and auto-approval (--yolo).

Changes:
- server/pkg/agent/cursor.go: cursorBackend implementation
- server/pkg/agent/cursor_test.go: unit tests for args, parsing, errors
- server/pkg/agent/agent.go: register "cursor" in New() factory
- server/internal/daemon/config.go: probe cursor-agent in PATH
- server/internal/daemon/execenv/context.go: cursor skill discovery path
- server/internal/daemon/execenv/runtime_config.go: AGENTS.md injection
- packages/views/.../provider-logo.tsx: cursor logo in UI

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

* fix(agent): address PR review for cursor backend

1. Fix token usage double-counting: usage is now taken exclusively from
   "result" events (session totals). Per-message usage in "assistant"
   events is intentionally ignored. "step_finish" usage is only used as
   fallback when no "result" usage is available.

2. Remove dead code: isCursorUnknownSessionError() and its regex were
   defined but never called. Removed along with corresponding test.

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

* fix(agent): add missing CustomArgs, SystemPrompt, MaxTurns, and debug logging to cursor backend

- Add cursorBlockedArgs and filterCustomArgs support for safe custom arg passthrough
- Add --system-prompt and --max-turns flag support to buildCursorArgs
- Add debug logging of command args before execution (consistent with all other backends)
- Move stdout-close goroutine inside main goroutine (consistent with claude.go pattern)
- Add tests for SystemPrompt/MaxTurns and CustomArgs filtering

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* chore: make daemon uses local profile & update Cursor logo to official brand

- Makefile: make daemon now runs 'daemon start --profile local' for local dev
- Replace Cursor runtime logo with official brand SVG (removed background rect)

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* fix(agent): remove unsupported --system-prompt and --max-turns from cursor-agent

cursor-agent CLI does not support these flags. Instructions are already
injected via AGENTS.md and .cursor/skills/ files.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* fix(agent): prevent step_finish + result usage double-counting in cursor

Split usage accumulation into separate stepUsage and resultUsage maps.
After stream ends, use resultUsage if available (session totals from
result event), otherwise fall back to stepUsage (sum of step_finish).
This prevents 2x counting when result.usage already includes totals.

Added table-driven test covering: result-only, step_finish-only,
step_finish+result (no double count), and multi-model scenarios.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* docs(agent): fix misleading comment on cursor -p flag

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.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>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-04-16 15:54:21 +08:00
Bohan Jiang
efb0c1dccf feat(agent): use official pi.dev wordmark for Pi runtime icon (#1153)
Replace the placeholder Greek-letter π glyph with pi.dev's actual
pixel-art "pi" wordmark on the brand's dark background. Source:
https://pi.dev/logo.svg.
2026-04-16 15:51:02 +08:00
Bohan Jiang
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.
2026-04-16 15:42:40 +08:00
Naiyuan Qing
fe358feff0 Reapply "feat: workspace URL refactor v2 + rollback-safe compat layer (#1138)" (#1139) (#1141)
This reverts commit b30fd98605.
2026-04-16 13:16:35 +08:00
Naiyuan Qing
b30fd98605 Revert "feat: workspace URL refactor v2 + rollback-safe compat layer (#1138)" (#1139)
This reverts commit 75d12c26c5.
2026-04-16 12:26:40 +08:00
Naiyuan Qing
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.

* compat: legacy URL redirect + localStorage double-write for safe rollback

The first attempt at this refactor (#1131) was reverted because existing
users on old URLs (/issues, /projects, etc.) hit 404 immediately after
deploy, and rolling back left them with empty dashboards — the legacy
code reads localStorage["multica_workspace_id"] to attach a workspace
to API requests, but the new code had stopped writing that key.

Two compat layers added on top of the restored refactor:

1. proxy.ts now intercepts legacy route prefixes (/issues/*, /projects/*,
   /agents/*, /inbox/*, /my-issues/*, /autopilots/*, /runtimes/*,
   /skills/*, /settings/*). Logged-in users with a last_workspace_slug
   cookie are 302'd to /{slug}/{rest}, preserving their deep link. Users
   without the cookie bounce through / where the landing page picks a
   workspace client-side. Unauthenticated users go to /login.

2. Both layouts now double-write the workspace id to the legacy
   localStorage key on every workspace entry. New code ignores this key
   — it exists solely so that if this PR ever gets reverted again, the
   legacy build reading the key would still find the correct workspace
   and avoid the empty-dashboard symptom users saw during the rollback.

Net effect: any direction of deploy ↔ rollback is now cache-compatible,
and any direction of old bookmark → new route resolves without 404.

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

* fix(platform): defer rehydrateAllWorkspaceStores to a microtask

Same React 19 render-phase restriction that forced setCurrentWorkspace
to defer its subscriber notifications. rehydrateAllWorkspaceStores
synchronously calls each persist store's rehydrate, which setState()s
the store, which schedules updates on any subscribed component. When
the workspace layout's render-phase ref guard invoked this, React
complained that SearchCommand (a store subscriber) couldn't be
re-rendered while WorkspaceLayout was still rendering.

Fix: queueMicrotask the rehydrate loop and add a pending-flag guard so
rapid workspace switches coalesce into one rehydrate on the final slug.
Persist stores tolerate one microtask of staleness — they hold UI
preferences, not correctness-critical state.

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-16 12:23:41 +08:00
Naiyuan Qing
9b94914bc8 Revert "feat: workspace URL refactor + slug-first API identity (#1131)" (#1137)
This reverts commit 59ace95a1e.
2026-04-16 11:56:15 +08:00
Naiyuan Qing
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>
2026-04-16 11:53:09 +08:00
Naiyuan Qing
3c46c5baa3 fix(editor): add dirty check and allow clearing description (#1132)
Two editor bugs fixed:

1. Descriptions saved unnecessarily on every document change (no dirty
   check). Added onCreate baseline capture + string comparison in the
   debounced onUpdate handler so mutations only fire when content
   actually changes.

2. Clearing a description didn't persist — empty string was converted
   to undefined via `md || undefined`, causing the field to be omitted
   from the API request. Changed to `md` so empty strings reach the
   backend and clear the description via COALESCE.

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-16 11:46:43 +08:00
Naiyuan Qing
c38af55a8e refactor(ui): comprehensive UI craft review — sidebar, headers, detail panels (#1087)
Sidebar:
- Pinned items: StatusIcon for issues, emoji for projects, sm size, mask gradient text fade
- Pinned items: inline X close button (hidden → flex on hover, desktop tab pattern)
- Pinned section: collapsible with chevron + hover count
- Remove unused canvas token

Global components:
- PageHeader: shared component with built-in mobile SidebarTrigger (md:hidden)
- Replace header divs in all 11 dashboard pages with PageHeader
- Remove standalone mobile trigger bar from DashboardLayout
- Tooltip: 200ms delay, remove arrow, popover/border style
- Search dialog: add finalFocus={false}
- SidebarInset: remove shadow-sm
- Button sizing: icon-xs → icon-sm across all non-editor contexts

Issue Detail:
- Simplify breadcrumb to workspace > identifier
- Extract sidebarContent variable shared between ResizablePanel and mobile Sheet
- All sidebar sections collapsible (Properties, Parent issue, Details, Token usage)
- Auto-close sidebar on mobile breakpoint
- Collapsible section headers: text before chevron, !size-3 stroke-[2.5], hover bg

Project Detail:
- Match Issue Detail layout pattern (header inside left ResizablePanel)
- Extract sidebarContent, add mobile Sheet support
- All sidebar sections collapsible (Properties, Progress, Description)
- Header: move three-dot menu to right button group, unified breadcrumb layout
- Auto-close sidebar on mobile breakpoint

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-15 21:50:00 +08:00
Black
0427fd8cc7 fix(daemon): refresh workspace repos on checkout miss (#1085)
Co-authored-by: black-fe <black-fe@gate.me>
2026-04-15 19:10:54 +08:00
Jiayuan Zhang
d930bcaa18 feat(server): trigger agent when issue moves out of backlog (#1006)
* feat(server): trigger agent when issue moves out of backlog

When a member moves an agent-assigned issue from "backlog" to an active
status (e.g. "todo", "in_progress"), enqueue an agent task so the agent
starts working. This lets backlog act as a parking lot where issues can
be assigned to agents without immediately triggering execution.

Applies to both single and batch issue updates.

* fix(server): treat backlog as parking lot — no trigger on create/assign

Address review feedback: creating or assigning an agent to a backlog
issue no longer triggers immediate execution. Only moving out of backlog
to an active status triggers the agent, producing exactly one task.

- shouldEnqueueAgentTask now gates on backlog status
- backlog→active trigger uses isAgentAssigneeReady directly
- Added TestBacklogNoTriggerOnCreate test
- Updated TestBacklogToTodoTriggersAgent to assert exactly 1 task
  across the full create→move path (no manual cleanup)

* feat(ui): show toast hint when assigning agent to backlog issue

Users may not know that backlog issues won't trigger agent execution
until moved to an active status. Show an actionable toast with a
"Move to Todo" button when:

- Assigning an agent to a backlog issue in the detail page
- Creating a backlog issue with an agent assignee

* feat(ui): add "Don't show again" option to backlog agent toast

Users who understand the backlog parking lot behavior can dismiss the
hint permanently. Uses localStorage to persist the preference.

* feat(ui): replace backlog agent toast with AlertDialog

Use a modal dialog instead of a toast notification so users must
explicitly acknowledge the hint. The dialog offers three options:
- "Move to Todo" — changes status and triggers the agent
- "Keep in Backlog" — dismisses without action
- "Don't show again" — persists dismissal in localStorage

* fix(ui): improve backlog agent dialog

* fix(ui): close create dialog behind hint, use checkbox for don't-show-again

1. Create Issue dialog now closes when the backlog agent hint appears,
   so only the hint dialog is visible (not stacked behind).
2. "Don't show again" is now a checkbox instead of a separate button.
   When checked, clicking either "Keep in Backlog" or "Move to Todo"
   persists the preference.

* fix(ui): smooth backlog agent hint dialog

* fix(test): add useUpdateIssue mock to create-issue test

The test mock for @multica/core/issues/mutations was missing the
useUpdateIssue export that create-issue.tsx now imports, causing
CI failure.
2026-04-15 19:07:48 +08:00
LinYushen
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>
2026-04-15 17:57:47 +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
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
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
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