Compare commits

...

187 Commits

Author SHA1 Message Date
Naiyuan Qing
24f8eb04fc fix(views): prevent focus jumping to random button when closing store-opened modals
Add finalFocus={false} to DialogContent in create-issue and create-workspace
modals so Base UI does not attempt focus restoration on close. These modals
are always opened programmatically via useModalStore (no trigger element),
so there is no meaningful element to return focus to.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-14 17:48:12 +08:00
Bohan Jiang
7f0c23a6ba Merge pull request #960 from blackhu0804/test/cli-client-context-headers
test(cli): cover API client context headers
2026-04-14 17:14:12 +08:00
Bohan Jiang
e6767d2ba3 Merge pull request #968 from multica-ai/agent/j/0ae3c9f0
docs: add manual testing checklist to PR template
2026-04-14 17:06:30 +08:00
Bohan Jiang
1ceb75e218 Update PULL_REQUEST_TEMPLATE.md 2026-04-14 17:06:13 +08:00
Jiang Bohan
9138c05993 docs: revise PR checklist to match Paperclip-style format
Replace the original checklist + manual testing section with a
unified checklist modeled after the Paperclip open-source project:
thinking path, model disclosure, local tests, test coverage,
UI screenshots, documentation, risk assessment, and reviewer comments.
2026-04-14 17:04:18 +08:00
Naiyuan Qing
091ed7370a Merge pull request #953 from multica-ai/fix/editor-bubble-menu-v2
fix(editor): fix BubbleMenu dropdown clicks by replacing DropdownMenu with Popover
2026-04-14 16:47:18 +08:00
Naiyuan Qing
35557c0b11 fix(test): add missing selection mock in ContentEditor test
The merge from main introduced `editor?.state.selection.empty` in
ContentEditor. The test mock was missing `state.selection`, causing
a TypeError.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Three-layer fix:

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

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

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

Closes #925

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

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

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

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

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

Closes MUL-695

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

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

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

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

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

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

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

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

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

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

Refs: MUL-577

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

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

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

Refs: MUL-577

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

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

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

Refs: MUL-577

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

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

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

Refs: MUL-577

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

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

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

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

Refs: MUL-577

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

---------

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

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

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

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

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

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

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

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

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

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

---------

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

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

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

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

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

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

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

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

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

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

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

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

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

Closes MUL-695

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Fixes MUL-708

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

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

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

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

Fixes #830

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

---------

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

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

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

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

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

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

---------

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

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

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

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

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

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

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

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

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

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

Closes MUL-580

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

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

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

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

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

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

---------

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

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

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

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

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

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

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

---------

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

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

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

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

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

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

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

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

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

---------

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

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

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

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

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

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

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

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

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

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

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

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

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

---------

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

---------

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

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

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

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

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

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

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

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

Fixes workspace rename not updating sidebar without page refresh.

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

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

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

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

Security improvements from the MUL-566 audit report:

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

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

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

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

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

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

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

---------

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

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

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

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

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

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

* simplify workspace sync subscription in issues page

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

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

---------

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

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

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

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

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

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

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

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

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

* chore: add AI disclosure section to PR template

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

* chore: simplify AI disclosure to focus on prompt sharing

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

* chore: simplify issue templates to lower submission friction

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

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

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

* chore: add screenshots section to issue templates

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

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

Tests:

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

Scope (intentional for v1):

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

Migration / env changes:

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

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

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

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

Closes #723

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

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

Self-hosters who don't need real emails should leave RESEND_API_KEY empty
(codes print to stdout, master code 888888 works).
2026-04-12 02:30:01 +08:00
Jiayuan Zhang
701399536f feat(cli): enhance version command with JSON output and build info (#740)
Add --output json flag, build date, Go version, and OS/arch to the
version command. Update Makefile and goreleaser to inject build date.
2026-04-12 02:18:08 +08:00
Jiayuan Zhang
4ca607f888 chore: remove Apache 2.0 license badge from READMEs (#739) 2026-04-12 02:11:45 +08:00
roc
a35f71f65d test(web): cover issue creation flow regressions 2026-04-10 15:52:43 +08:00
226 changed files with 12522 additions and 2036 deletions

View File

@@ -22,6 +22,8 @@ MULTICA_CODEX_WORKDIR=
MULTICA_CODEX_TIMEOUT=20m
# Email (Resend)
# For local/dev use, leave RESEND_API_KEY empty — codes print to stdout, and master code 888888 works.
# For production, set your Resend API key and change RESEND_FROM_EMAIL to a domain verified in your Resend account.
RESEND_API_KEY=
RESEND_FROM_EMAIL=noreply@multica.ai
@@ -40,6 +42,16 @@ CLOUDFRONT_PRIVATE_KEY=
CLOUDFRONT_DOMAIN=
COOKIE_DOMAIN=
# Local file storage (fallback when S3_BUCKET is not set)
LOCAL_UPLOAD_DIR=./data/uploads
LOCAL_UPLOAD_BASE_URL=http://localhost:8080
# Security
# Comma-separated list of allowed origins for CORS and WebSocket connections.
# Defaults to localhost dev origins when unset.
# Example: ALLOWED_ORIGINS=https://app.multica.ai,https://staging.multica.ai
ALLOWED_ORIGINS=
# Frontend
FRONTEND_PORT=3000
FRONTEND_ORIGIN=http://localhost:3000

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

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

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

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

View File

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

View File

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

2
.gitignore vendored
View File

@@ -48,3 +48,5 @@ _features/
*.dmg
*.app
server/server
data/
.kilo

View File

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

283
AGENTS.md
View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -18,10 +18,9 @@ The open-source managed agents platform.<br/>
Turn coding agents into real teammates — assign tasks, track progress, compound skills.
[![CI](https://github.com/multica-ai/multica/actions/workflows/ci.yml/badge.svg)](https://github.com/multica-ai/multica/actions/workflows/ci.yml)
[![License](https://img.shields.io/badge/License-Apache_2.0-blue.svg)](https://opensource.org/licenses/Apache-2.0)
[![GitHub stars](https://img.shields.io/github/stars/multica-ai/multica?style=flat)](https://github.com/multica-ai/multica/stargazers)
[Website](https://multica.ai) · [Cloud](https://multica.ai/app) · [X](https://x.com/multica_hq) · [Self-Hosting](SELF_HOSTING.md) · [Contributing](CONTRIBUTING.md)
[Website](https://multica.ai) · [Cloud](https://multica.ai/app) · [X](https://x.com/MulticaAI) · [Self-Hosting](SELF_HOSTING.md) · [Contributing](CONTRIBUTING.md)
**English | [简体中文](README.zh-CN.md)**
@@ -51,24 +50,39 @@ Multica manages the full agent lifecycle: from task assignment to execution moni
## Quick Install
### macOS / Linux (Homebrew - recommended)
```bash
brew install multica-ai/tap/multica
```
Use `brew upgrade multica-ai/tap/multica` to keep the CLI current.
### macOS / Linux (install script)
```bash
curl -fsSL https://raw.githubusercontent.com/multica-ai/multica/main/scripts/install.sh | bash
```
Installs the Multica CLI on macOS and Linux. Works with Homebrew or downloads the binary directly.
Use this if Homebrew is not available. The script installs the Multica CLI on macOS and Linux by using Homebrew when it is on `PATH`, otherwise it downloads the binary directly.
After installation:
### Windows (PowerShell)
```bash
multica login # Authenticate (opens browser)
multica daemon start # Start the local agent runtime
multica daemon stop # Stop the daemon when done
```powershell
irm https://raw.githubusercontent.com/multica-ai/multica/main/scripts/install.ps1 | iex
```
> **Self-hosting?** Add `--local` to deploy a full Multica server on your machine:
Then configure, authenticate, and start the daemon in one command:
```bash
multica setup # Connect to Multica Cloud, log in, start daemon
```
> **Self-hosting?** Add `--with-server` to deploy a full Multica server on your machine:
>
> ```bash
> curl -fsSL https://raw.githubusercontent.com/multica-ai/multica/main/scripts/install.sh | bash -s -- --local
> curl -fsSL https://raw.githubusercontent.com/multica-ai/multica/main/scripts/install.sh | bash -s -- --with-server
> multica setup self-host
> ```
>
> Requires Docker. See the [Self-Hosting Guide](SELF_HOSTING.md) for details.
@@ -77,11 +91,10 @@ multica daemon stop # Stop the daemon when done
## Getting Started
### 1. Log in and start the daemon
### 1. Set up and start the daemon
```bash
multica login # Authenticate with your Multica account
multica daemon start # Start the local agent runtime
multica setup # Configure, authenticate, and start the daemon
```
The daemon runs in the background and auto-detects agent CLIs (`claude`, `codex`, `openclaw`, `opencode`) on your PATH.
@@ -111,9 +124,8 @@ The `multica` CLI connects your local machine to Multica — authenticate, manag
| `multica login` | Authenticate (opens browser) |
| `multica daemon start` | Start the local agent runtime |
| `multica daemon status` | Check daemon status |
| `multica setup` | One-command setup (configure + login + start daemon) |
| `multica setup --local` | Same, but for self-hosted deployments |
| `multica config local` | Configure CLI for a local self-hosted server |
| `multica setup` | One-command setup for Multica Cloud (configure + login + start daemon) |
| `multica setup self-host` | Same, but for self-hosted deployments |
| `multica issue list` | List issues in your workspace |
| `multica issue create` | Create a new issue |
| `multica update` | Update to the latest version |

View File

@@ -18,10 +18,9 @@
将编码 Agent 变成真正的队友——分配任务、跟踪进度、积累技能。
[![CI](https://github.com/multica-ai/multica/actions/workflows/ci.yml/badge.svg)](https://github.com/multica-ai/multica/actions/workflows/ci.yml)
[![License](https://img.shields.io/badge/License-Apache_2.0-blue.svg)](https://opensource.org/licenses/Apache-2.0)
[![GitHub stars](https://img.shields.io/github/stars/multica-ai/multica?style=flat)](https://github.com/multica-ai/multica/stargazers)
[官网](https://multica.ai) · [云服务](https://multica.ai/app) · [X](https://x.com/multica_hq) · [自部署指南](SELF_HOSTING.md) · [参与贡献](CONTRIBUTING.md)
[官网](https://multica.ai) · [云服务](https://multica.ai/app) · [X](https://x.com/MulticaAI) · [自部署指南](SELF_HOSTING.md) · [参与贡献](CONTRIBUTING.md)
**[English](README.md) | 简体中文**
@@ -51,24 +50,39 @@ Multica 管理完整的 Agent 生命周期:从任务分配到执行监控再
## 快速安装
### macOS / Linux推荐 Homebrew
```bash
brew install multica-ai/tap/multica
```
后续可用 `brew upgrade multica-ai/tap/multica` 更新 CLI。
### macOS / Linux安装脚本
```bash
curl -fsSL https://raw.githubusercontent.com/multica-ai/multica/main/scripts/install.sh | bash
```
安装 Multica CLI支持 macOS 和 Linux。有 Homebrew 用 Homebrew,没有则直接下载二进制。
如果没有 Homebrew可以使用安装脚本。脚本会安装 Multica CLI检测到 `brew` 时通过 Homebrew 安装,否则直接下载二进制。
安装完成后:
### Windows (PowerShell)
```bash
multica login # 认证(打开浏览器)
multica daemon start # 启动本地 Agent 运行时
multica daemon stop # 停止 daemon
```powershell
irm https://raw.githubusercontent.com/multica-ai/multica/main/scripts/install.ps1 | iex
```
> **自部署?** 加上 `--local` 在本地部署完整的 Multica 服务
安装完成后,一条命令完成配置、认证和启动
```bash
multica setup # 连接 Multica Cloud登录启动 daemon
```
> **自部署?** 加上 `--with-server` 在本地部署完整的 Multica 服务:
>
> ```bash
> curl -fsSL https://raw.githubusercontent.com/multica-ai/multica/main/scripts/install.sh | bash -s -- --local
> curl -fsSL https://raw.githubusercontent.com/multica-ai/multica/main/scripts/install.sh | bash -s -- --with-server
> multica setup self-host
> ```
>
> 需要 Docker。详见 [自部署指南](SELF_HOSTING.md)。
@@ -79,11 +93,10 @@ multica daemon stop # 停止 daemon
安装好 CLI或注册 [Multica 云服务](https://multica.ai))后,按以下步骤将第一个任务分配给 Agent
### 1. 登录并启动 daemon
### 1. 配置并启动 daemon
```bash
multica login # 使用你的 Multica 账号认证
multica daemon start # 启动本地 Agent 运行时
multica setup # 配置、认证、启动 daemon一条命令搞定
```
daemon 在后台运行,保持你的机器与 Multica 的连接。它会自动检测 PATH 中可用的 Agent CLI`claude``codex``openclaw``opencode`)。

View File

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

View File

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

View File

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

View File

@@ -8,6 +8,10 @@ files:
- "!electron.vite.config.*"
- "!{.eslintignore,.eslintrc.cjs,.prettierignore,.prettierrc.yaml,dev-app-update.yml,CHANGELOG.md,README.md}"
- "!{tsconfig.json,tsconfig.node.json,tsconfig.web.json}"
protocols:
- name: Multica
schemes:
- multica
asarUnpack:
- resources/**
mac:
@@ -28,4 +32,8 @@ win:
target:
- nsis
artifactName: ${name}-${version}-setup.${ext}
publish:
provider: github
owner: multica-ai
repo: multica
npmRebuild: false

View File

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

View File

@@ -5,6 +5,7 @@
"main": "./out/main/index.js",
"scripts": {
"dev": "electron-vite dev",
"dev:remote": "electron-vite dev --mode remote",
"build": "electron-vite build",
"typecheck:node": "tsc --noEmit -p tsconfig.node.json --composite false",
"typecheck:web": "tsc --noEmit -p tsconfig.web.json --composite false",
@@ -21,11 +22,12 @@
"@dnd-kit/utilities": "^3.2.2",
"@electron-toolkit/preload": "^3.0.2",
"@electron-toolkit/utils": "^4.0.0",
"@fontsource/geist-mono": "^5.2.7",
"@fontsource/geist-sans": "^5.2.5",
"@multica/core": "workspace:*",
"@multica/ui": "workspace:*",
"@multica/views": "workspace:*",
"@fontsource/geist-mono": "^5.2.7",
"@fontsource/geist-sans": "^5.2.5",
"electron-updater": "^6.8.3",
"react-router-dom": "^7.6.0",
"shadcn": "^4.1.0",
"sonner": "^2.0.7",

View File

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

View File

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

View File

@@ -1,8 +1,25 @@
import { ElectronAPI } from "@electron-toolkit/preload";
interface DesktopAPI {
/** Listen for auth token delivered via deep link. Returns an unsubscribe function. */
onAuthToken: (callback: (token: string) => void) => () => void;
/** Open a URL in the default browser. */
openExternal: (url: string) => Promise<void>;
}
interface UpdaterAPI {
onUpdateAvailable: (callback: (info: { version: string; releaseNotes?: string }) => void) => () => void;
onDownloadProgress: (callback: (progress: { percent: number }) => void) => () => void;
onUpdateDownloaded: (callback: () => void) => () => void;
downloadUpdate: () => Promise<void>;
installUpdate: () => Promise<void>;
}
declare global {
interface Window {
electron: ElectronAPI;
desktopAPI: DesktopAPI;
updater: UpdaterAPI;
}
}

View File

@@ -1,9 +1,49 @@
import { contextBridge } from "electron";
import { contextBridge, ipcRenderer } from "electron";
import { electronAPI } from "@electron-toolkit/preload";
const desktopAPI = {
/** Listen for auth token delivered via deep link */
onAuthToken: (callback: (token: string) => void) => {
const handler = (_event: Electron.IpcRendererEvent, token: string) =>
callback(token);
ipcRenderer.on("auth:token", handler);
return () => {
ipcRenderer.removeListener("auth:token", handler);
};
},
/** Open a URL in the default browser */
openExternal: (url: string) => ipcRenderer.invoke("shell:openExternal", url),
};
const updaterAPI = {
onUpdateAvailable: (callback: (info: { version: string; releaseNotes?: string }) => void) => {
const handler = (_: unknown, info: { version: string; releaseNotes?: string }) => callback(info);
ipcRenderer.on("updater:update-available", handler);
return () => ipcRenderer.removeListener("updater:update-available", handler);
},
onDownloadProgress: (callback: (progress: { percent: number }) => void) => {
const handler = (_: unknown, progress: { percent: number }) => callback(progress);
ipcRenderer.on("updater:download-progress", handler);
return () => ipcRenderer.removeListener("updater:download-progress", handler);
},
onUpdateDownloaded: (callback: () => void) => {
const handler = () => callback();
ipcRenderer.on("updater:update-downloaded", handler);
return () => ipcRenderer.removeListener("updater:update-downloaded", handler);
},
downloadUpdate: () => ipcRenderer.invoke("updater:download"),
installUpdate: () => ipcRenderer.invoke("updater:install"),
};
if (process.contextIsolated) {
contextBridge.exposeInMainWorld("electron", electronAPI);
contextBridge.exposeInMainWorld("desktopAPI", desktopAPI);
contextBridge.exposeInMainWorld("updater", updaterAPI);
} else {
// @ts-expect-error - fallback for non-isolated context
window.electron = electronAPI;
// @ts-expect-error - fallback for non-isolated context
window.desktopAPI = desktopAPI;
// @ts-expect-error - fallback for non-isolated context
window.updater = updaterAPI;
}

View File

@@ -1,15 +1,33 @@
import { useEffect } from "react";
import { CoreProvider } from "@multica/core/platform";
import { useAuthStore } from "@multica/core/auth";
import { useWorkspaceStore } from "@multica/core/workspace";
import { api } from "@multica/core/api";
import { ThemeProvider } from "@multica/ui/components/common/theme-provider";
import { MulticaIcon } from "@multica/ui/components/common/multica-icon";
import { Toaster } from "sonner";
import { DesktopLoginPage } from "./pages/login";
import { DesktopShell } from "./components/desktop-layout";
import { UpdateNotification } from "./components/update-notification";
function AppContent() {
const user = useAuthStore((s) => s.user);
const isLoading = useAuthStore((s) => s.isLoading);
// Listen for auth token delivered via deep link (multica://auth/callback?token=...)
useEffect(() => {
return window.desktopAPI.onAuthToken(async (token) => {
try {
await useAuthStore.getState().loginWithToken(token);
const wsList = await api.listWorkspaces();
const lastWsId = localStorage.getItem("multica_workspace_id");
useWorkspaceStore.getState().hydrateWorkspace(wsList, lastWsId);
} catch {
// Token invalid or expired — user stays on login page
}
});
}, []);
if (isLoading) {
return (
<div className="flex h-screen items-center justify-center">
@@ -22,16 +40,19 @@ function AppContent() {
return <DesktopShell />;
}
const remoteProxy = Boolean(import.meta.env.VITE_REMOTE_API);
export default function App() {
return (
<ThemeProvider>
<CoreProvider
apiBaseUrl={import.meta.env.VITE_API_URL || "http://localhost:8080"}
wsUrl={import.meta.env.VITE_WS_URL || "ws://localhost:8080/ws"}
apiBaseUrl={remoteProxy ? "" : (import.meta.env.VITE_API_URL || "http://localhost:8080")}
wsUrl={remoteProxy ? "ws://localhost:5173/ws" : (import.meta.env.VITE_WS_URL || "ws://localhost:8080/ws")}
>
<AppContent />
</CoreProvider>
<Toaster />
<UpdateNotification />
</ThemeProvider>
);
}

View File

@@ -85,17 +85,17 @@ export function DesktopShell() {
>
<TabBar />
</header>
{/* Content area with inset styling */}
<div className="flex flex-1 min-h-0 flex-col overflow-hidden mr-2 mb-2 ml-0.5 rounded-xl shadow-sm bg-background">
{/* Content area with inset styling — relative so ChatWindow/ChatFab are constrained here */}
<div className="relative flex flex-1 min-h-0 flex-col overflow-hidden mr-2 mb-2 ml-0.5 rounded-xl shadow-sm bg-background">
<TabContent />
<ChatWindow />
<ChatFab />
</div>
</div>
</SidebarProvider>
</div>
<ModalRegistry />
<SearchCommand />
<ChatWindow />
<ChatFab />
</DashboardGuard>
</DesktopNavigationProvider>
);

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -19,14 +19,30 @@ Fetch https://github.com/multica-ai/multica/blob/main/CLI_INSTALL.md and follow
Or install manually:
```bash
# Install
brew tap multica-ai/tap
brew install multica
### macOS / Linux (Homebrew - recommended)
# Authenticate and start
multica login
multica daemon start
```bash
brew install multica-ai/tap/multica
```
### macOS / Linux (install script)
```bash
# Install the CLI
curl -fsSL https://raw.githubusercontent.com/multica-ai/multica/main/scripts/install.sh | bash
```
### Windows (PowerShell)
```powershell
irm https://raw.githubusercontent.com/multica-ai/multica/main/scripts/install.ps1 | iex
```
Then configure, authenticate, and start the daemon:
```bash
# Configure, authenticate, and start the daemon
multica setup
```
The daemon auto-detects available agent CLIs (`claude`, `codex`, `openclaw`, `opencode`) on your PATH. When an agent is assigned a task, the daemon creates an isolated environment, runs the agent, and reports results back.

View File

@@ -21,16 +21,21 @@ Each user who wants to run AI agents locally also installs the **`multica` CLI**
## Quick Install
One command to set up everything:
Two commands to set up everything:
```bash
curl -fsSL https://raw.githubusercontent.com/multica-ai/multica/main/scripts/install.sh | bash -s -- --local
# Install CLI + provision self-host server
curl -fsSL https://raw.githubusercontent.com/multica-ai/multica/main/scripts/install.sh | bash -s -- --with-server
# Configure CLI, authenticate, and start the daemon
multica setup self-host
```
This clones the repo, starts all services, installs the CLI, and configures everything. Then:
This clones the repo, starts all services, installs the CLI, and configures it for localhost. Then open http://localhost:3000 — log in with any email + code **`888888`**.
1. Open http://localhost:3000 — log in with any email + code **`888888`**
2. Run `multica login` and `multica daemon start`
<Callout>
If the self-host server is already running and you only need the CLI on a macOS/Linux machine, install it with Homebrew: `brew install multica-ai/tap/multica`.
</Callout>
<Callout>
For a step-by-step setup, see below.
@@ -78,11 +83,14 @@ brew install multica-ai/tap/multica
You also need at least one AI agent CLI:
- [Claude Code](https://docs.anthropic.com/en/docs/claude-code) (`claude` on PATH)
- [Codex](https://github.com/openai/codex) (`codex` on PATH)
- OpenCode (`opencode` on PATH)
- OpenClaw (`openclaw` on PATH)
- Hermes (`hermes` on PATH)
### b) One-command setup
```bash
multica setup --local
multica setup self-host
```
This automatically:
@@ -91,6 +99,12 @@ This automatically:
3. Discovers your workspaces
4. Starts the daemon in the background
For on-premise deployments with custom domains:
```bash
multica setup self-host --server-url https://api.example.com --app-url https://app.example.com
```
Verify the daemon is running:
```bash
@@ -98,7 +112,7 @@ multica daemon status
```
<Callout>
Alternatively, configure manually: `multica config local && multica login && multica daemon start`
Alternatively, configure step by step: `multica config set server_url http://localhost:8080 && multica config set app_url http://localhost:3000 && multica login && multica daemon start`
</Callout>
### Step 4 — Verify & Start Using
@@ -123,16 +137,10 @@ multica daemon stop
If you've been self-hosting and want to switch your CLI to [Multica Cloud](https://multica.ai):
```bash
multica config set server_url https://api.multica.ai
multica config set app_url https://multica.ai
multica login
multica setup
```
Or re-run the install script without `--local` — it will reconfigure the CLI automatically:
```bash
curl -fsSL https://raw.githubusercontent.com/multica-ai/multica/main/scripts/install.sh | bash
```
This reconfigures the CLI for multica.ai, re-authenticates, and restarts the daemon. You will be prompted before overwriting the existing configuration.
<Callout>
Your local Docker services are unaffected. Stop them separately if you no longer need them.
@@ -211,6 +219,21 @@ These are configured on each user's machine, not on the server:
| `MULTICA_DAEMON_POLL_INTERVAL` | `3s` | How often the daemon polls for tasks |
| `MULTICA_DAEMON_HEARTBEAT_INTERVAL` | `15s` | Heartbeat frequency |
Agent-specific overrides:
| Variable | Description |
|----------|-------------|
| `MULTICA_CLAUDE_PATH` | Custom path to the `claude` binary |
| `MULTICA_CLAUDE_MODEL` | Override the Claude model used |
| `MULTICA_CODEX_PATH` | Custom path to the `codex` binary |
| `MULTICA_CODEX_MODEL` | Override the Codex model used |
| `MULTICA_OPENCODE_PATH` | Custom path to the `opencode` binary |
| `MULTICA_OPENCODE_MODEL` | Override the OpenCode model used |
| `MULTICA_OPENCLAW_PATH` | Custom path to the `openclaw` binary |
| `MULTICA_OPENCLAW_MODEL` | Override the OpenClaw model used |
| `MULTICA_HERMES_PATH` | Custom path to the `hermes` binary |
| `MULTICA_HERMES_MODEL` | Override the Hermes model used |
## Database Setup
Multica requires PostgreSQL 17 with the pgvector extension.

View File

@@ -5,14 +5,13 @@ description: Assign your first task to an agent in under 5 minutes.
Once you have the CLI installed (or signed up for [Multica Cloud](https://multica.ai)), follow these steps to assign your first task to an agent.
## 1. Log in and start the daemon
## 1. Set up and start the daemon
```bash
multica login # Authenticate with your Multica account
multica daemon start # Start the local agent runtime
multica setup # Configure, authenticate, and start the daemon
```
The daemon runs in the background and keeps your machine connected to Multica. It auto-detects agent CLIs (`claude`, `codex`, `openclaw`, `opencode`) available on your PATH.
This configures the CLI, opens your browser for login, discovers your workspaces, and starts the agent daemon in the background. It auto-detects agent CLIs (`claude`, `codex`, `openclaw`, `opencode`) available on your PATH.
## 2. Verify your runtime

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,7 +1,7 @@
import { cn } from "@multica/ui/lib/utils";
export const githubUrl = "https://github.com/multica-ai/multica";
export const twitterUrl = "https://x.com/multica_hq";
export const twitterUrl = "https://x.com/MulticaAI";
export function GitHubMark({ className }: { className?: string }) {
return (

View File

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

View File

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

View File

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

View File

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

View File

@@ -62,6 +62,7 @@ services:
args:
REMOTE_API_URL: http://backend:8080
NEXT_PUBLIC_GOOGLE_CLIENT_ID: ${NEXT_PUBLIC_GOOGLE_CLIENT_ID:-}
NEXT_PUBLIC_WS_URL: ${NEXT_PUBLIC_WS_URL:-}
depends_on:
- backend
ports:

13
e2e/env.ts Normal file
View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,35 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { ApiClient, ApiError } from "./client";
afterEach(() => {
vi.unstubAllGlobals();
});
describe("ApiClient", () => {
it("preserves HTTP status on failed requests", async () => {
vi.stubGlobal(
"fetch",
vi.fn().mockResolvedValue(
new Response(JSON.stringify({ error: "workspace slug already exists" }), {
status: 409,
statusText: "Conflict",
headers: { "Content-Type": "application/json" },
}),
),
);
const client = new ApiClient("https://api.example.test");
try {
await client.createWorkspace({ name: "Test", slug: "test" });
throw new Error("expected createWorkspace to fail");
} catch (error) {
expect(error).toBeInstanceOf(ApiError);
expect(error).toMatchObject({
message: "workspace slug already exists",
status: 409,
statusText: "Conflict",
});
}
});
});

View File

@@ -52,6 +52,7 @@ import type {
ReorderPinsRequest,
} from "../types";
import { type Logger, noopLogger } from "../logger";
import { createRequestId } from "../utils";
export interface ApiClientOptions {
logger?: Logger;
@@ -63,6 +64,18 @@ export interface LoginResponse {
user: User;
}
export class ApiError extends Error {
readonly status: number;
readonly statusText: string;
constructor(message: string, status: number, statusText: string) {
super(message);
this.name = "ApiError";
this.status = status;
this.statusText = statusText;
}
}
export class ApiClient {
private baseUrl: string;
private token: string | null = null;
@@ -76,6 +89,10 @@ export class ApiClient {
this.logger = options?.logger ?? noopLogger;
}
getBaseUrl(): string {
return this.baseUrl;
}
setToken(token: string | null) {
this.token = token;
}
@@ -84,10 +101,20 @@ export class ApiClient {
this.workspaceId = id;
}
private readCsrfToken(): string | null {
if (typeof document === "undefined") return null;
const match = document.cookie
.split("; ")
.find((c) => c.startsWith("multica_csrf="));
return match ? match.split("=")[1] ?? null : null;
}
private authHeaders(): Record<string, string> {
const headers: Record<string, string> = {};
if (this.token) headers["Authorization"] = `Bearer ${this.token}`;
if (this.workspaceId) headers["X-Workspace-ID"] = this.workspaceId;
const csrf = this.readCsrfToken();
if (csrf) headers["X-CSRF-Token"] = csrf;
return headers;
}
@@ -108,7 +135,7 @@ export class ApiClient {
}
private async fetch<T>(path: string, init?: RequestInit): Promise<T> {
const rid = crypto.randomUUID().slice(0, 8);
const rid = createRequestId();
const start = Date.now();
const method = init?.method ?? "GET";
@@ -132,7 +159,7 @@ export class ApiClient {
const message = await this.parseErrorMessage(res, `API error: ${res.status} ${res.statusText}`);
const logLevel = res.status === 404 ? "warn" : "error";
this.logger[logLevel](`${res.status} ${path}`, { rid, duration: `${Date.now() - start}ms`, error: message });
throw new Error(message);
throw new ApiError(message, res.status, res.statusText);
}
this.logger.info(`${res.status} ${path}`, { rid, duration: `${Date.now() - start}ms` });
@@ -167,6 +194,14 @@ export class ApiClient {
});
}
async logout(): Promise<void> {
await this.fetch("/auth/logout", { method: "POST" });
}
async issueCliToken(): Promise<{ token: string }> {
return this.fetch("/api/cli-token", { method: "POST" });
}
async getMe(): Promise<User> {
return this.fetch("/api/me");
}
@@ -234,6 +269,10 @@ export class ApiClient {
return this.fetch(`/api/issues/${id}/children`);
}
async getChildIssueProgress(): Promise<{ progress: { parent_issue_id: string; total: number; done: number }[] }> {
return this.fetch("/api/issues/child-progress");
}
async deleteIssue(id: string): Promise<void> {
await this.fetch(`/api/issues/${id}`, { method: "DELETE" });
}
@@ -432,7 +471,7 @@ export class ApiClient {
}
async listTaskMessages(taskId: string): Promise<TaskMessagePayload[]> {
return this.fetch(`/api/daemon/tasks/${taskId}/messages`);
return this.fetch(`/api/tasks/${taskId}/messages`);
}
async listTasksByIssue(issueId: string): Promise<AgentTask[]> {
@@ -610,7 +649,7 @@ export class ApiClient {
if (opts?.issueId) formData.append("issue_id", opts.issueId);
if (opts?.commentId) formData.append("comment_id", opts.commentId);
const rid = crypto.randomUUID().slice(0, 8);
const rid = createRequestId();
const start = Date.now();
this.logger.info("→ POST /api/upload-file", { rid });

View File

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

View File

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

View File

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

View File

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

View File

@@ -2,6 +2,7 @@ import { useMutation, useQueryClient } from "@tanstack/react-query";
import { api } from "../api";
import { useWorkspaceId } from "../hooks";
import { chatKeys } from "./queries";
import type { ChatSession } from "../types";
export function useCreateChatSession() {
const qc = useQueryClient();
@@ -23,6 +24,29 @@ export function useArchiveChatSession() {
return useMutation({
mutationFn: (sessionId: string) => api.archiveChatSession(sessionId),
onMutate: async (sessionId) => {
await qc.cancelQueries({ queryKey: chatKeys.sessions(wsId) });
await qc.cancelQueries({ queryKey: chatKeys.allSessions(wsId) });
const prevSessions = qc.getQueryData<ChatSession[]>(chatKeys.sessions(wsId));
const prevAll = qc.getQueryData<ChatSession[]>(chatKeys.allSessions(wsId));
// Optimistic: remove from active, mark as archived in allSessions
qc.setQueryData<ChatSession[]>(chatKeys.sessions(wsId), (old) =>
old ? old.filter((s) => s.id !== sessionId) : old,
);
qc.setQueryData<ChatSession[]>(chatKeys.allSessions(wsId), (old) =>
old?.map((s) =>
s.id === sessionId ? { ...s, status: "archived" as const } : s,
),
);
return { prevSessions, prevAll };
},
onError: (_err, _id, ctx) => {
if (ctx?.prevSessions) qc.setQueryData(chatKeys.sessions(wsId), ctx.prevSessions);
if (ctx?.prevAll) qc.setQueryData(chatKeys.allSessions(wsId), ctx.prevAll);
},
onSettled: () => {
qc.invalidateQueries({ queryKey: chatKeys.sessions(wsId) });
qc.invalidateQueries({ queryKey: chatKeys.allSessions(wsId) });

View File

@@ -4,6 +4,15 @@ import { getCurrentWorkspaceId, registerForWorkspaceRehydration } from "../platf
const AGENT_STORAGE_KEY = "multica:chat:selectedAgentId";
const SESSION_STORAGE_KEY = "multica:chat:activeSessionId";
const DRAFT_KEY = "multica:chat:draft";
const CHAT_WIDTH_KEY = "multica:chat:width";
const CHAT_HEIGHT_KEY = "multica:chat:height";
const CHAT_EXPANDED_KEY = "multica:chat:expanded";
export const CHAT_MIN_W = 360;
export const CHAT_MIN_H = 480;
export const CHAT_DEFAULT_W = 420;
export const CHAT_DEFAULT_H = 600;
export interface ChatTimelineItem {
seq: number;
@@ -16,21 +25,29 @@ export interface ChatTimelineItem {
export interface ChatState {
isOpen: boolean;
isFullscreen: boolean;
activeSessionId: string | null;
pendingTaskId: string | null;
selectedAgentId: string | null;
showHistory: boolean;
timelineItems: ChatTimelineItem[];
inputDraft: string;
/** Raw user-chosen size — no clamp applied. UI layer clamps at render time. */
chatWidth: number;
chatHeight: number;
isExpanded: boolean;
setOpen: (open: boolean) => void;
toggle: () => void;
toggleFullscreen: () => void;
setActiveSession: (id: string | null) => void;
setPendingTask: (taskId: string | null) => void;
setSelectedAgentId: (id: string) => void;
setShowHistory: (show: boolean) => void;
addTimelineItem: (item: ChatTimelineItem) => void;
clearTimeline: () => void;
setInputDraft: (draft: string) => void;
clearInputDraft: () => void;
/** Persist raw size and auto-exit expanded mode. */
setChatSize: (width: number, height: number) => void;
setExpanded: (expanded: boolean) => void;
}
export interface ChatStoreOptions {
@@ -47,20 +64,17 @@ export function createChatStore(options: ChatStoreOptions) {
const store = create<ChatState>((set) => ({
isOpen: false,
isFullscreen: false,
activeSessionId: storage.getItem(wsKey(SESSION_STORAGE_KEY)),
pendingTaskId: null,
selectedAgentId: storage.getItem(wsKey(AGENT_STORAGE_KEY)),
showHistory: false,
timelineItems: [],
setOpen: (open) =>
set({ isOpen: open, ...(open ? {} : { isFullscreen: false }) }),
toggle: () =>
set((s) => ({
isOpen: !s.isOpen,
...(s.isOpen ? { isFullscreen: false } : {}),
})),
toggleFullscreen: () => set((s) => ({ isFullscreen: !s.isFullscreen })),
inputDraft: storage.getItem(wsKey(DRAFT_KEY)) ?? "",
chatWidth: Number(storage.getItem(CHAT_WIDTH_KEY)) || CHAT_DEFAULT_W,
chatHeight: Number(storage.getItem(CHAT_HEIGHT_KEY)) || CHAT_DEFAULT_H,
isExpanded: storage.getItem(wsKey(CHAT_EXPANDED_KEY)) === "true",
setOpen: (open) => set({ isOpen: open }),
toggle: () => set((s) => ({ isOpen: !s.isOpen })),
setActiveSession: (id) => {
if (id) {
storage.setItem(wsKey(SESSION_STORAGE_KEY), id);
@@ -75,6 +89,18 @@ export function createChatStore(options: ChatStoreOptions) {
set({ selectedAgentId: id });
},
setShowHistory: (show) => set({ showHistory: show }),
setInputDraft: (draft) => {
if (draft) {
storage.setItem(wsKey(DRAFT_KEY), draft);
} else {
storage.removeItem(wsKey(DRAFT_KEY));
}
set({ inputDraft: draft });
},
clearInputDraft: () => {
storage.removeItem(wsKey(DRAFT_KEY));
set({ inputDraft: "" });
},
addTimelineItem: (item) =>
set((s) => {
if (s.timelineItems.some((t) => t.seq === item.seq)) return s;
@@ -85,12 +111,28 @@ export function createChatStore(options: ChatStoreOptions) {
};
}),
clearTimeline: () => set({ timelineItems: [] }),
setChatSize: (w, h) => {
storage.setItem(CHAT_WIDTH_KEY, String(w));
storage.setItem(CHAT_HEIGHT_KEY, String(h));
// Dragging = user chose a manual size → exit expanded mode
storage.removeItem(wsKey(CHAT_EXPANDED_KEY));
set({ chatWidth: w, chatHeight: h, isExpanded: false });
},
setExpanded: (expanded) => {
if (expanded) {
storage.setItem(wsKey(CHAT_EXPANDED_KEY), "true");
} else {
storage.removeItem(wsKey(CHAT_EXPANDED_KEY));
}
set({ isExpanded: expanded });
},
}));
registerForWorkspaceRehydration(() => {
store.setState({
activeSessionId: storage.getItem(wsKey(SESSION_STORAGE_KEY)),
selectedAgentId: storage.getItem(wsKey(AGENT_STORAGE_KEY)),
inputDraft: storage.getItem(wsKey(DRAFT_KEY)) ?? "",
timelineItems: [],
});
});

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,9 +1,11 @@
"use client";
import { useEffect, type ReactNode } from "react";
import { useQueryClient } from "@tanstack/react-query";
import { getApi } from "../api";
import { useAuthStore } from "../auth";
import { useWorkspaceStore } from "../workspace";
import { workspaceKeys } from "../workspace/queries";
import { createLogger } from "../logger";
import { defaultStorage } from "./storage";
import type { StorageAdapter } from "../types/storage";
@@ -15,13 +17,39 @@ export function AuthInitializer({
onLogin,
onLogout,
storage = defaultStorage,
cookieAuth,
}: {
children: ReactNode;
onLogin?: () => void;
onLogout?: () => void;
storage?: StorageAdapter;
cookieAuth?: boolean;
}) {
const qc = useQueryClient();
useEffect(() => {
const api = getApi();
const wsId = storage.getItem("multica_workspace_id");
if (cookieAuth) {
// Cookie mode: the HttpOnly cookie is sent automatically by the browser.
// Call the API to check if the session is still valid.
Promise.all([api.getMe(), api.listWorkspaces()])
.then(([user, wsList]) => {
onLogin?.();
useAuthStore.setState({ user, isLoading: false });
qc.setQueryData(workspaceKeys.list(), wsList);
useWorkspaceStore.getState().hydrateWorkspace(wsList, wsId);
})
.catch((err) => {
logger.error("cookie auth init failed", err);
onLogout?.();
useAuthStore.setState({ user: null, isLoading: false });
});
return;
}
// Token mode: read from localStorage (Electron / legacy).
const token = storage.getItem("multica_token");
if (!token) {
onLogout?.();
@@ -29,14 +57,14 @@ export function AuthInitializer({
return;
}
const api = getApi();
api.setToken(token);
const wsId = storage.getItem("multica_workspace_id");
Promise.all([api.getMe(), api.listWorkspaces()])
.then(([user, wsList]) => {
onLogin?.();
useAuthStore.setState({ user, isLoading: false });
// Seed React Query cache so components don't need a second fetch
qc.setQueryData(workspaceKeys.list(), wsList);
useWorkspaceStore.getState().hydrateWorkspace(wsList, wsId);
})
.catch((err) => {

View File

@@ -25,6 +25,7 @@ function initCore(
storage: StorageAdapter,
onLogin?: () => void,
onLogout?: () => void,
cookieAuth?: boolean,
) {
if (initialized) return;
@@ -37,13 +38,15 @@ function initCore(
});
setApiInstance(api);
// Hydrate token from storage
const token = storage.getItem("multica_token");
if (token) api.setToken(token);
// In token mode, hydrate token from storage.
if (!cookieAuth) {
const token = storage.getItem("multica_token");
if (token) api.setToken(token);
}
const wsId = storage.getItem("multica_workspace_id");
if (wsId) api.setWorkspaceId(wsId);
authStore = createAuthStore({ api, storage, onLogin, onLogout });
authStore = createAuthStore({ api, storage, onLogin, onLogout, cookieAuth });
registerAuthStore(authStore);
workspaceStore = createWorkspaceStore(api, { storage });
@@ -60,22 +63,24 @@ export function CoreProvider({
apiBaseUrl = "",
wsUrl = "ws://localhost:8080/ws",
storage = defaultStorage,
cookieAuth,
onLogin,
onLogout,
}: CoreProviderProps) {
// Initialize singletons on first render only. Dependencies are read-once:
// apiBaseUrl, storage, and callbacks are set at app boot and never change at runtime.
// eslint-disable-next-line react-hooks/exhaustive-deps
useMemo(() => initCore(apiBaseUrl, storage, onLogin, onLogout), []);
useMemo(() => initCore(apiBaseUrl, storage, onLogin, onLogout, cookieAuth), []);
return (
<QueryProvider>
<AuthInitializer onLogin={onLogin} onLogout={onLogout} storage={storage}>
<AuthInitializer onLogin={onLogin} onLogout={onLogout} storage={storage} cookieAuth={cookieAuth}>
<WSProvider
wsUrl={wsUrl}
authStore={authStore}
workspaceStore={workspaceStore}
storage={storage}
cookieAuth={cookieAuth}
>
{children}
</WSProvider>

View File

@@ -8,6 +8,8 @@ export interface CoreProviderProps {
wsUrl?: string;
/** Storage adapter. Default: SSR-safe localStorage wrapper. */
storage?: StorageAdapter;
/** Use HttpOnly cookies for auth instead of localStorage tokens. Default: false. */
cookieAuth?: boolean;
/** Called after successful login (e.g. set cookie for Next.js middleware). */
onLogin?: () => void;
/** Called after logout (e.g. clear cookie). */

View File

@@ -2,16 +2,14 @@
import { useState } from "react";
import { QueryClientProvider } from "@tanstack/react-query";
import { ReactQueryDevtools } from "@tanstack/react-query-devtools";
import { createQueryClient } from "./query-client";
import type { ReactNode } from "react";
export function QueryProvider({ children, showDevtools = true }: { children: ReactNode; showDevtools?: boolean }) {
export function QueryProvider({ children }: { children: ReactNode }) {
const [queryClient] = useState(createQueryClient);
return (
<QueryClientProvider client={queryClient}>
{children}
{showDevtools && <ReactQueryDevtools initialIsOpen={false} />}
</QueryClientProvider>
);
}

View File

@@ -35,6 +35,8 @@ export interface WSProviderProps {
workspaceStore: UseBoundStore<StoreApi<WorkspaceStore>>;
/** Platform-specific storage adapter for reading auth tokens */
storage: StorageAdapter;
/** When true, use HttpOnly cookies instead of token query param for WS auth. */
cookieAuth?: boolean;
/** Optional callback for showing toast messages (platform-specific, e.g. sonner) */
onToast?: (message: string, type?: "info" | "error") => void;
}
@@ -45,6 +47,7 @@ export function WSProvider({
authStore,
workspaceStore,
storage,
cookieAuth,
onToast,
}: WSProviderProps) {
const user = authStore((s) => s.user);
@@ -54,10 +57,15 @@ export function WSProvider({
useEffect(() => {
if (!user || !workspace) return;
const token = storage.getItem("multica_token");
if (!token) return;
// In token mode we need a token from storage; in cookie mode the HttpOnly
// cookie is sent automatically with the WS upgrade request.
const token = cookieAuth ? null : storage.getItem("multica_token");
if (!cookieAuth && !token) return;
const ws = new WSClient(wsUrl, { logger: createLogger("ws") });
const ws = new WSClient(wsUrl, {
logger: createLogger("ws"),
cookieAuth,
});
ws.setAuth(token, workspace.id);
setWsClient(ws);
ws.connect();
@@ -66,7 +74,7 @@ export function WSProvider({
ws.disconnect();
setWsClient(null);
};
}, [user, workspace, wsUrl, storage]);
}, [user, workspace, wsUrl, storage, cookieAuth]);
const stores: RealtimeSyncStores = { authStore, workspaceStore };

View File

@@ -20,7 +20,7 @@ import {
} from "../issues/ws-updaters";
import { onInboxNew, onInboxInvalidate, onInboxIssueStatusChanged } from "../inbox/ws-updaters";
import { inboxKeys } from "../inbox/queries";
import { workspaceKeys } from "../workspace/queries";
import { workspaceKeys, workspaceListOptions } from "../workspace/queries";
import type {
MemberAddedPayload,
WorkspaceDeletedPayload,
@@ -102,7 +102,8 @@ export function useRealtimeSync(
},
pin: () => {
const wsId = workspaceStore.getState().workspace?.id;
if (wsId) qc.invalidateQueries({ queryKey: pinKeys.all(wsId) });
const userId = authStore.getState().user?.id;
if (wsId && userId) qc.invalidateQueries({ queryKey: pinKeys.all(wsId, userId) });
},
daemon: () => {
const wsId = workspaceStore.getState().workspace?.id;
@@ -250,7 +251,9 @@ export function useRealtimeSync(
if (currentWs?.id === workspace_id) {
logger.warn("current workspace deleted, switching");
onToast?.("This workspace was deleted", "info");
workspaceStore.getState().refreshWorkspaces();
qc.fetchQuery({ ...workspaceListOptions(), staleTime: 0 }).then((wsList) => {
workspaceStore.getState().hydrateWorkspace(wsList);
});
}
});
@@ -262,7 +265,9 @@ export function useRealtimeSync(
if (wsId) clearWorkspaceStorage(defaultStorage, wsId);
logger.warn("removed from workspace, switching");
onToast?.("You were removed from this workspace", "info");
workspaceStore.getState().refreshWorkspaces();
qc.fetchQuery({ ...workspaceListOptions(), staleTime: 0 }).then((wsList) => {
workspaceStore.getState().hydrateWorkspace(wsList);
});
}
});
@@ -270,7 +275,7 @@ export function useRealtimeSync(
const { member, workspace_name } = p as MemberAddedPayload;
const myUserId = authStore.getState().user?.id;
if (member.user_id === myUserId) {
workspaceStore.getState().refreshWorkspaces();
qc.invalidateQueries({ queryKey: workspaceKeys.list() });
onToast?.(
`You were invited to ${workspace_name ?? "a workspace"}`,
"info",

View File

@@ -47,6 +47,7 @@ export interface Agent {
avatar_url: string | null;
runtime_mode: AgentRuntimeMode;
runtime_config: Record<string, unknown>;
custom_env: Record<string, string>;
visibility: AgentVisibility;
status: AgentStatus;
max_concurrent_tasks: number;
@@ -65,6 +66,7 @@ export interface CreateAgentRequest {
avatar_url?: string;
runtime_id: string;
runtime_config?: Record<string, unknown>;
custom_env?: Record<string, string>;
visibility?: AgentVisibility;
max_concurrent_tasks?: number;
}
@@ -76,6 +78,7 @@ export interface UpdateAgentRequest {
avatar_url?: string;
runtime_id?: string;
runtime_config?: Record<string, unknown>;
custom_env?: Record<string, string>;
visibility?: AgentVisibility;
status?: AgentStatus;
max_concurrent_tasks?: number;

View File

@@ -0,0 +1,33 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { createRequestId, createSafeId, generateUUID } from "./utils";
afterEach(() => {
vi.unstubAllGlobals();
vi.restoreAllMocks();
});
describe("utils id helpers", () => {
it("generateUUID returns a valid UUID v4", () => {
const id = generateUUID();
expect(id).toMatch(/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i);
});
it("createSafeId falls back when crypto.randomUUID is unavailable", () => {
vi.stubGlobal("crypto", {
getRandomValues: (arr: Uint8Array) => {
for (let i = 0; i < arr.length; i++) arr[i] = i;
return arr;
},
});
const id = createSafeId();
expect(id).toMatch(/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i);
});
it("createRequestId defaults to length 8 and respects custom length", () => {
vi.spyOn(globalThis.crypto, "randomUUID").mockReturnValue("12345678-1234-4abc-8def-1234567890ab");
expect(createRequestId()).toBe("12345678");
expect(createRequestId(12)).toBe("123456781234");
});
});

View File

@@ -8,3 +8,43 @@ export function timeAgo(dateStr: string): string {
const days = Math.floor(hours / 24);
return `${days}d ago`;
}
export function generateUUID(): string {
const cryptoObj = globalThis.crypto;
if (!cryptoObj?.getRandomValues) {
throw new Error("Secure UUID generation requires crypto.getRandomValues");
}
const bytes = new Uint8Array(16);
cryptoObj.getRandomValues(bytes);
bytes[6] = ((bytes[6] ?? 0) & 0x0f) | 0x40; // version 4
bytes[8] = ((bytes[8] ?? 0) & 0x3f) | 0x80; // variant 1
const hex = Array.from(bytes, (b) => b.toString(16).padStart(2, "0")).join("");
return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`;
}
/**
* Generate an id that prefers crypto.randomUUID but falls back in non-secure contexts.
*/
export function createSafeId(): string {
const cryptoObj = globalThis.crypto;
if (cryptoObj?.randomUUID) {
try {
return cryptoObj.randomUUID();
} catch {
// Fall through to fallback.
}
}
return generateUUID();
}
/** Request id helper used for logs/tracing headers. */
export function createRequestId(length = 8): string {
return createSafeId().replace(/-/g, "").slice(0, length);
}

View File

@@ -1,12 +1,19 @@
import { useMutation, useQueryClient } from "@tanstack/react-query";
import type { Workspace } from "../types";
import { api } from "../api";
import { workspaceKeys } from "./queries";
import { workspaceKeys, workspaceListOptions } from "./queries";
import { useWorkspaceStore } from "./index";
export function useCreateWorkspace() {
const qc = useQueryClient();
return useMutation({
mutationFn: (data: { name: string; slug: string; description?: string }) =>
api.createWorkspace(data),
onSuccess: (newWs) => {
// Add to cache before switching so sidebar list is consistent on first render
qc.setQueryData(workspaceKeys.list(), (old: Workspace[] = []) => [...old, newWs]);
useWorkspaceStore.getState().switchWorkspace(newWs);
},
onSettled: () => {
qc.invalidateQueries({ queryKey: workspaceKeys.list() });
},
@@ -17,6 +24,14 @@ export function useLeaveWorkspace() {
const qc = useQueryClient();
return useMutation({
mutationFn: (workspaceId: string) => api.leaveWorkspace(workspaceId),
onSuccess: async (_, workspaceId) => {
const currentWsId = useWorkspaceStore.getState().workspace?.id;
if (currentWsId === workspaceId) {
// staleTime: 0 forces a real network fetch — cache still has the left workspace
const wsList = await qc.fetchQuery({ ...workspaceListOptions(), staleTime: 0 });
useWorkspaceStore.getState().hydrateWorkspace(wsList);
}
},
onSettled: () => {
qc.invalidateQueries({ queryKey: workspaceKeys.list() });
},
@@ -27,6 +42,14 @@ export function useDeleteWorkspace() {
const qc = useQueryClient();
return useMutation({
mutationFn: (workspaceId: string) => api.deleteWorkspace(workspaceId),
onSuccess: async (_, workspaceId) => {
const currentWsId = useWorkspaceStore.getState().workspace?.id;
if (currentWsId === workspaceId) {
// staleTime: 0 forces a real network fetch — cache still has the deleted workspace
const wsList = await qc.fetchQuery({ ...workspaceListOptions(), staleTime: 0 });
useWorkspaceStore.getState().hydrateWorkspace(wsList);
}
},
onSettled: () => {
qc.invalidateQueries({ queryKey: workspaceKeys.list() });
},

View File

@@ -8,29 +8,25 @@ const logger = createLogger("workspace-store");
interface WorkspaceStoreOptions {
storage?: StorageAdapter;
onError?: (message: string) => void;
}
interface WorkspaceState {
workspace: Workspace | null;
workspaces: Workspace[];
}
interface WorkspaceActions {
/**
* Pick a workspace from a list and set it as current.
* The list itself is NOT stored here — it lives in React Query.
*/
hydrateWorkspace: (
wsList: Workspace[],
preferredWorkspaceId?: string | null,
) => Workspace | null;
switchWorkspace: (workspaceId: string) => void;
refreshWorkspaces: () => Promise<Workspace[]>;
/** Switch to a workspace. Caller provides the full object (from React Query). */
switchWorkspace: (ws: Workspace) => void;
/** Update current workspace data in place (e.g. after rename). */
updateWorkspace: (ws: Workspace) => void;
createWorkspace: (data: {
name: string;
slug: string;
description?: string;
}) => Promise<Workspace>;
leaveWorkspace: (workspaceId: string) => Promise<void>;
deleteWorkspace: (workspaceId: string) => Promise<void>;
clearWorkspace: () => void;
}
@@ -38,17 +34,13 @@ export type WorkspaceStore = WorkspaceState & WorkspaceActions;
export function createWorkspaceStore(api: ApiClient, options?: WorkspaceStoreOptions) {
const storage = options?.storage;
const onError = options?.onError;
return create<WorkspaceStore>((set, get) => ({
// State
return create<WorkspaceStore>((set) => ({
// Only the currently selected workspace (UI state).
// The workspace list is server state and lives in React Query.
workspace: null,
workspaces: [],
// Actions
hydrateWorkspace: (wsList, preferredWorkspaceId) => {
set({ workspaces: wsList });
const nextWorkspace =
(preferredWorkspaceId
? wsList.find((item) => item.id === preferredWorkspaceId)
@@ -72,80 +64,29 @@ export function createWorkspaceStore(api: ApiClient, options?: WorkspaceStoreOpt
set({ workspace: nextWorkspace });
logger.debug("hydrate workspace", nextWorkspace.name, nextWorkspace.id);
// Members, agents, skills, issues, inbox are all managed by TanStack Query.
// They auto-fetch when components mount with the workspace ID in their query key.
return nextWorkspace;
},
switchWorkspace: (workspaceId) => {
logger.info("switching to", workspaceId);
const { workspaces, hydrateWorkspace } = get();
const ws = workspaces.find((item) => item.id === workspaceId);
if (!ws) return;
switchWorkspace: (ws) => {
logger.info("switching to", ws.id);
api.setWorkspaceId(ws.id);
setCurrentWorkspaceId(ws.id);
rehydrateAllWorkspaceStores();
storage?.setItem("multica_workspace_id", ws.id);
// All data caches (issues, inbox, members, agents, skills, runtimes)
// are managed by TanStack Query, keyed by wsId — auto-refetch on switch.
set({ workspace: ws });
hydrateWorkspace(workspaces, ws.id);
},
refreshWorkspaces: async () => {
const { workspace, hydrateWorkspace } = get();
const storedWorkspaceId = storage?.getItem("multica_workspace_id") ?? null;
try {
const wsList = await api.listWorkspaces();
hydrateWorkspace(wsList, workspace?.id ?? storedWorkspaceId);
return wsList;
} catch (e) {
logger.error("failed to refresh workspaces", e);
onError?.("Failed to refresh workspaces");
return get().workspaces;
}
},
updateWorkspace: (ws) => {
set((state) => ({
workspace: state.workspace?.id === ws.id ? ws : state.workspace,
workspaces: state.workspaces.map((item) =>
item.id === ws.id ? ws : item,
),
}));
},
createWorkspace: async (data) => {
const ws = await api.createWorkspace(data);
set((state) => ({ workspaces: [...state.workspaces, ws] }));
return ws;
},
leaveWorkspace: async (workspaceId) => {
await api.leaveWorkspace(workspaceId);
const { workspace, hydrateWorkspace } = get();
const wsList = await api.listWorkspaces();
const preferredWorkspaceId =
workspace?.id === workspaceId ? null : (workspace?.id ?? null);
hydrateWorkspace(wsList, preferredWorkspaceId);
},
deleteWorkspace: async (workspaceId) => {
await api.deleteWorkspace(workspaceId);
const { workspace, hydrateWorkspace } = get();
const wsList = await api.listWorkspaces();
const preferredWorkspaceId =
workspace?.id === workspaceId ? null : (workspace?.id ?? null);
hydrateWorkspace(wsList, preferredWorkspaceId);
},
clearWorkspace: () => {
api.setWorkspaceId(null);
setCurrentWorkspaceId(null);
rehydrateAllWorkspaceStores();
set({ workspace: null, workspaces: [] });
set({ workspace: null });
},
}));
}

View File

@@ -0,0 +1,34 @@
"use client";
import { ArrowUp, Loader2, Square } from "lucide-react";
import { Button } from "@multica/ui/components/ui/button";
interface SubmitButtonProps {
onClick: () => void;
disabled?: boolean;
loading?: boolean;
running?: boolean;
onStop?: () => void;
}
function SubmitButton({ onClick, disabled, loading, running, onStop }: SubmitButtonProps) {
if (running) {
return (
<Button size="icon-sm" onClick={onStop}>
<Square className="fill-current" />
</Button>
);
}
return (
<Button size="icon-sm" disabled={disabled || loading} onClick={onClick}>
{loading ? (
<Loader2 className="animate-spin" />
) : (
<ArrowUp />
)}
</Button>
);
}
export { SubmitButton, type SubmitButtonProps };

View File

@@ -11,6 +11,7 @@ import {
AlertCircle,
MoreHorizontal,
Settings,
KeyRound,
} from "lucide-react";
import type { Agent, RuntimeDevice } from "@multica/core/types";
import {
@@ -34,17 +35,19 @@ import { InstructionsTab } from "./tabs/instructions-tab";
import { SkillsTab } from "./tabs/skills-tab";
import { TasksTab } from "./tabs/tasks-tab";
import { SettingsTab } from "./tabs/settings-tab";
import { EnvTab } from "./tabs/env-tab";
function getRuntimeDevice(agent: Agent, runtimes: RuntimeDevice[]): RuntimeDevice | undefined {
return runtimes.find((runtime) => runtime.id === agent.runtime_id);
}
type DetailTab = "instructions" | "skills" | "tasks" | "settings";
type DetailTab = "instructions" | "skills" | "tasks" | "env" | "settings";
const detailTabs: { id: DetailTab; label: string; icon: typeof FileText }[] = [
{ id: "instructions", label: "Instructions", icon: FileText },
{ id: "skills", label: "Skills", icon: BookOpenText },
{ id: "tasks", label: "Tasks", icon: ListTodo },
{ id: "env", label: "Environment", icon: KeyRound },
{ id: "settings", label: "Settings", icon: Settings },
];
@@ -158,6 +161,12 @@ export function AgentDetail({
<SkillsTab agent={agent} />
)}
{activeTab === "tasks" && <TasksTab agent={agent} />}
{activeTab === "env" && (
<EnvTab
agent={agent}
onSave={(updates) => onUpdate(agent.id, updates)}
/>
)}
{activeTab === "settings" && (
<SettingsTab
agent={agent}

View File

@@ -0,0 +1,191 @@
"use client";
import { useState } from "react";
import {
Loader2,
Save,
Plus,
Trash2,
Eye,
EyeOff,
} from "lucide-react";
import type { Agent } from "@multica/core/types";
import { Button } from "@multica/ui/components/ui/button";
import { Input } from "@multica/ui/components/ui/input";
import { Label } from "@multica/ui/components/ui/label";
import { toast } from "sonner";
let nextEnvId = 0;
interface EnvEntry {
id: number;
key: string;
value: string;
visible: boolean;
}
function envMapToEntries(env: Record<string, string>): EnvEntry[] {
return Object.entries(env).map(([key, value]) => ({
id: nextEnvId++,
key,
value,
visible: false,
}));
}
function entriesToEnvMap(entries: EnvEntry[]): Record<string, string> {
const map: Record<string, string> = {};
for (const entry of entries) {
const key = entry.key.trim();
if (key) {
map[key] = entry.value;
}
}
return map;
}
export function EnvTab({
agent,
onSave,
}: {
agent: Agent;
onSave: (updates: Partial<Agent>) => Promise<void>;
}) {
const [envEntries, setEnvEntries] = useState<EnvEntry[]>(
envMapToEntries(agent.custom_env ?? {}),
);
const [saving, setSaving] = useState(false);
const currentEnvMap = entriesToEnvMap(envEntries);
const originalEnvMap = agent.custom_env ?? {};
const dirty =
JSON.stringify(currentEnvMap) !== JSON.stringify(originalEnvMap);
const addEnvEntry = () => {
setEnvEntries([
...envEntries,
{ id: nextEnvId++, key: "", value: "", visible: true },
]);
};
const removeEnvEntry = (index: number) => {
setEnvEntries(envEntries.filter((_, i) => i !== index));
};
const updateEnvEntry = (
index: number,
field: "key" | "value",
val: string,
) => {
setEnvEntries(
envEntries.map((entry, i) =>
i === index ? { ...entry, [field]: val } : entry,
),
);
};
const toggleEnvVisibility = (index: number) => {
setEnvEntries(
envEntries.map((entry, i) =>
i === index ? { ...entry, visible: !entry.visible } : entry,
),
);
};
const handleSave = async () => {
const keys = envEntries.filter((e) => e.key.trim()).map((e) => e.key.trim());
const uniqueKeys = new Set(keys);
if (uniqueKeys.size < keys.length) {
toast.error("Duplicate environment variable keys");
return;
}
setSaving(true);
try {
await onSave({ custom_env: currentEnvMap });
toast.success("Environment variables saved");
} catch {
toast.error("Failed to save environment variables");
} finally {
setSaving(false);
}
};
return (
<div className="max-w-lg space-y-4">
<div className="flex items-center justify-between">
<div>
<Label className="text-xs text-muted-foreground">
Environment Variables
</Label>
<p className="text-xs text-muted-foreground mt-0.5">
Injected into the agent process at launch (e.g. ANTHROPIC_API_KEY,
ANTHROPIC_BASE_URL)
</p>
</div>
<Button
type="button"
variant="outline"
size="sm"
onClick={addEnvEntry}
className="h-7 gap-1 text-xs"
>
<Plus className="h-3 w-3" />
Add
</Button>
</div>
{envEntries.length > 0 && (
<div className="space-y-2">
{envEntries.map((entry, index) => (
<div key={entry.id} className="flex items-center gap-2">
<Input
value={entry.key}
onChange={(e) => updateEnvEntry(index, "key", e.target.value)}
placeholder="KEY"
className="w-[40%] font-mono text-xs"
/>
<div className="relative flex-1">
<Input
type={entry.visible ? "text" : "password"}
value={entry.value}
onChange={(e) =>
updateEnvEntry(index, "value", e.target.value)
}
placeholder="value"
className="pr-8 font-mono text-xs"
/>
<button
type="button"
onClick={() => toggleEnvVisibility(index)}
className="absolute right-2 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
>
{entry.visible ? (
<EyeOff className="h-3.5 w-3.5" />
) : (
<Eye className="h-3.5 w-3.5" />
)}
</button>
</div>
<button
type="button"
onClick={() => removeEnvEntry(index)}
className="shrink-0 text-muted-foreground hover:text-destructive"
>
<Trash2 className="h-3.5 w-3.5" />
</button>
</div>
))}
</div>
)}
<Button onClick={handleSave} disabled={!dirty || saving} size="sm">
{saving ? (
<Loader2 className="h-3.5 w-3.5 mr-1.5 animate-spin" />
) : (
<Save className="h-3.5 w-3.5 mr-1.5" />
)}
Save
</Button>
</div>
);
}

View File

@@ -72,6 +72,7 @@ export function SettingsTab({
toast.error("Name is required");
return;
}
setSaving(true);
try {
await onSave({

View File

@@ -13,6 +13,15 @@ const mockApiListWorkspaces = vi.hoisted(() => vi.fn());
const mockApiVerifyCode = vi.hoisted(() => vi.fn());
const mockApiSetToken = vi.hoisted(() => vi.fn());
const mockApiGetMe = vi.hoisted(() => vi.fn());
const mockApiIssueCliToken = vi.hoisted(() => vi.fn());
const mockSetQueryData = vi.hoisted(() => vi.fn());
vi.mock("@tanstack/react-query", async () => {
const actual = await vi.importActual<typeof import("@tanstack/react-query")>(
"@tanstack/react-query",
);
return { ...actual, useQueryClient: () => ({ setQueryData: mockSetQueryData }) };
});
vi.mock("@multica/core/auth", () => ({
useAuthStore: Object.assign(
@@ -50,6 +59,7 @@ vi.mock("@multica/core/api", () => ({
verifyCode: mockApiVerifyCode,
setToken: mockApiSetToken,
getMe: mockApiGetMe,
issueCliToken: mockApiIssueCliToken,
},
}));
@@ -80,7 +90,8 @@ describe("LoginPage", () => {
beforeEach(() => {
vi.useFakeTimers({ shouldAdvanceTime: true });
vi.clearAllMocks();
// Default: no existing session
// Default: no existing session (getMe rejects when no auth)
mockApiGetMe.mockRejectedValue(new Error("unauthorized"));
localStorage.clear();
// Reset window.location for tests that change it
Object.defineProperty(window, "location", {
@@ -293,7 +304,7 @@ describe("LoginPage", () => {
).toBeInTheDocument();
});
// After transitioning to code step, cooldown is 10s
// After transitioning to code step, cooldown is 60s
const resendBtn = screen.getByRole("button", { name: /resend in/i });
expect(resendBtn).toBeDisabled();
});
@@ -329,9 +340,9 @@ describe("LoginPage", () => {
// sendCode was called once for the initial send
expect(mockSendCode).toHaveBeenCalledTimes(1);
// Advance past the 10s cooldown one second at a time so React can
// Advance past the 60s cooldown one second at a time so React can
// process each setCooldown state update between ticks.
for (let i = 0; i < 11; i++) {
for (let i = 0; i < 61; i++) {
await act(async () => {
vi.advanceTimersByTime(1_000);
});
@@ -377,11 +388,14 @@ describe("LoginPage", () => {
it("shows cli_confirm step when existing session + cliCallback", async () => {
localStorage.setItem("multica_token", "existing-jwt");
mockApiGetMe.mockResolvedValueOnce({
id: "u-1",
email: "user@example.com",
name: "Test User",
});
// Cookie attempt fails first, then localStorage fallback succeeds
mockApiGetMe
.mockRejectedValueOnce(new Error("no cookie"))
.mockResolvedValueOnce({
id: "u-1",
email: "user@example.com",
name: "Test User",
});
render(
<LoginPage
@@ -406,11 +420,14 @@ describe("LoginPage", () => {
it("CLI authorize button redirects to callback URL", async () => {
localStorage.setItem("multica_token", "existing-jwt");
mockApiGetMe.mockResolvedValueOnce({
id: "u-1",
email: "user@example.com",
name: "Test User",
});
// Cookie attempt fails, localStorage fallback succeeds
mockApiGetMe
.mockRejectedValueOnce(new Error("no cookie"))
.mockResolvedValueOnce({
id: "u-1",
email: "user@example.com",
name: "Test User",
});
const onTokenObtained = vi.fn();
render(
@@ -438,11 +455,14 @@ describe("LoginPage", () => {
it("'Use a different account' returns to email step", async () => {
localStorage.setItem("multica_token", "existing-jwt");
mockApiGetMe.mockResolvedValueOnce({
id: "u-1",
email: "user@example.com",
name: "Test User",
});
// Cookie attempt fails, localStorage fallback succeeds
mockApiGetMe
.mockRejectedValueOnce(new Error("no cookie"))
.mockResolvedValueOnce({
id: "u-1",
email: "user@example.com",
name: "Test User",
});
render(
<LoginPage
@@ -467,6 +487,65 @@ describe("LoginPage", () => {
).toBeInTheDocument();
});
// -------------------------------------------------------------------------
// CLI callback — cookie-based session (no localStorage token)
// -------------------------------------------------------------------------
it("detects cookie-based session and shows cli_confirm when no localStorage token", async () => {
// No localStorage token — getMe succeeds via HttpOnly cookie
mockApiGetMe.mockResolvedValueOnce({
id: "u-1",
email: "cookie@example.com",
name: "Cookie User",
});
render(
<LoginPage
onSuccess={onSuccess}
cliCallback={{ url: "http://localhost:9876/callback", state: "abc" }}
/>,
);
await waitFor(() => {
expect(screen.getByText(/authorize cli/i)).toBeInTheDocument();
});
expect(screen.getByText(/cookie@example.com/)).toBeInTheDocument();
});
it("CLI authorize with cookie session calls issueCliToken and redirects", async () => {
// No localStorage token — getMe succeeds via cookie
mockApiGetMe.mockResolvedValueOnce({
id: "u-1",
email: "cookie@example.com",
name: "Cookie User",
});
mockApiIssueCliToken.mockResolvedValueOnce({ token: "fresh-jwt" });
const onTokenObtained = vi.fn();
render(
<LoginPage
onSuccess={onSuccess}
onTokenObtained={onTokenObtained}
cliCallback={{ url: "http://localhost:9876/callback", state: "abc" }}
/>,
);
await waitFor(() => {
expect(screen.getByText(/authorize cli/i)).toBeInTheDocument();
});
const user = userEvent.setup();
await user.click(screen.getByRole("button", { name: /^authorize$/i }));
await waitFor(() => {
expect(mockApiIssueCliToken).toHaveBeenCalled();
expect(onTokenObtained).toHaveBeenCalled();
expect(window.location.href).toContain(
"http://localhost:9876/callback?token=fresh-jwt&state=abc",
);
});
});
// -------------------------------------------------------------------------
// CLI callback — code verification redirects
// -------------------------------------------------------------------------
@@ -647,12 +726,34 @@ describe("validateCliCallback", () => {
expect(validateCliCallback("http://127.0.0.1:8080/cb")).toBe(true);
});
it("accepts 10.x.x.x private IPs", () => {
expect(validateCliCallback("http://10.0.0.5:9876/callback")).toBe(true);
expect(validateCliCallback("http://10.255.255.255:1234/cb")).toBe(true);
});
it("accepts 172.16-31.x.x private IPs", () => {
expect(validateCliCallback("http://172.16.0.1:9876/callback")).toBe(true);
expect(validateCliCallback("http://172.31.255.255:1234/cb")).toBe(true);
});
it("rejects 172.x outside 16-31 range", () => {
expect(validateCliCallback("http://172.15.0.1:9876/callback")).toBe(false);
expect(validateCliCallback("http://172.32.0.1:9876/callback")).toBe(false);
});
it("accepts 192.168.x.x private IPs", () => {
expect(validateCliCallback("http://192.168.1.131:41117/callback")).toBe(true);
expect(validateCliCallback("http://192.168.0.1:8080/cb")).toBe(true);
});
it("rejects https:// URLs", () => {
expect(validateCliCallback("https://localhost:9876/callback")).toBe(false);
});
it("rejects non-localhost hosts", () => {
it("rejects public IPs and domains", () => {
expect(validateCliCallback("http://evil.com:9876/callback")).toBe(false);
expect(validateCliCallback("http://8.8.8.8:9876/callback")).toBe(false);
expect(validateCliCallback("http://192.169.1.1:9876/callback")).toBe(false);
});
it("rejects invalid URLs", () => {

View File

@@ -1,6 +1,7 @@
"use client";
import { useState, useEffect, useCallback, type ReactNode } from "react";
import { useState, useEffect, useCallback, useRef, type ReactNode } from "react";
import { useQueryClient } from "@tanstack/react-query";
import {
Card,
CardHeader,
@@ -19,6 +20,7 @@ import {
} from "@multica/ui/components/ui/input-otp";
import { useAuthStore } from "@multica/core/auth";
import { useWorkspaceStore } from "@multica/core/workspace";
import { workspaceKeys } from "@multica/core/workspace/queries";
import { api } from "@multica/core/api";
import type { User } from "@multica/core/types";
@@ -29,6 +31,8 @@ import type { User } from "@multica/core/types";
interface GoogleAuthConfig {
clientId: string;
redirectUri: string;
/** Opaque state passed through Google OAuth (e.g. "platform:desktop"). */
state?: string;
}
interface CliCallbackConfig {
@@ -51,6 +55,8 @@ interface LoginPageProps {
lastWorkspaceId?: string | null;
/** Called after a token is obtained (e.g. to set cookies). */
onTokenObtained?: () => void;
/** Override Google login handler (e.g. desktop opens browser externally). When provided, renders the Google button even if `google` config is omitted. */
onGoogleLogin?: () => void;
}
// ---------------------------------------------------------------------------
@@ -62,14 +68,22 @@ function redirectToCliCallback(url: string, token: string, state: string) {
window.location.href = `${url}${separator}token=${encodeURIComponent(token)}&state=${encodeURIComponent(state)}`;
}
/** Validate that a CLI callback URL points to localhost over HTTP. */
/**
* Validate that a CLI callback URL points to a safe host over HTTP.
* Allows localhost and private/LAN IPs (RFC 1918) to support self-hosted setups
* on local VMs while blocking arbitrary public hosts.
*/
export function validateCliCallback(cliCallback: string): boolean {
try {
const cbUrl = new URL(cliCallback);
if (cbUrl.protocol !== "http:") return false;
if (cbUrl.hostname !== "localhost" && cbUrl.hostname !== "127.0.0.1")
return false;
return true;
const h = cbUrl.hostname;
if (h === "localhost" || h === "127.0.0.1") return true;
// Allow RFC 1918 private IPs: 10.x.x.x, 172.16-31.x.x, 192.168.x.x
if (/^10\./.test(h)) return true;
if (/^172\.(1[6-9]|2\d|3[01])\./.test(h)) return true;
if (/^192\.168\./.test(h)) return true;
return false;
} catch {
return false;
}
@@ -86,7 +100,9 @@ export function LoginPage({
cliCallback,
lastWorkspaceId,
onTokenObtained,
onGoogleLogin,
}: LoginPageProps) {
const qc = useQueryClient();
const [step, setStep] = useState<"email" | "code" | "cli_confirm">("email");
const [email, setEmail] = useState("");
const [code, setCode] = useState("");
@@ -94,23 +110,43 @@ export function LoginPage({
const [loading, setLoading] = useState(false);
const [cooldown, setCooldown] = useState(0);
const [existingUser, setExistingUser] = useState<User | null>(null);
// Tracks how the existing session was detected so handleCliAuthorize
// uses the matching token source (cookie → issueCliToken, localStorage → direct).
const authSourceRef = useRef<"cookie" | "localStorage">("cookie");
// Check for existing session when CLI callback is present
// Check for existing session when CLI callback is present.
// Prioritises cookie auth (= current browser session) to avoid authorising
// the CLI with a stale or mismatched localStorage token.
useEffect(() => {
if (!cliCallback) return;
const token = localStorage.getItem("multica_token");
if (!token) return;
api.setToken(token);
// Ensure no stale bearer token interferes — we want to test the cookie first.
api.setToken(null);
api
.getMe()
.then((user) => {
authSourceRef.current = "cookie";
setExistingUser(user);
setStep("cli_confirm");
})
.catch(() => {
api.setToken(null);
localStorage.removeItem("multica_token");
// Cookie auth failed — fall back to localStorage token
const token = localStorage.getItem("multica_token");
if (!token) return;
api.setToken(token);
api
.getMe()
.then((user) => {
authSourceRef.current = "localStorage";
setExistingUser(user);
setStep("cli_confirm");
})
.catch(() => {
api.setToken(null);
localStorage.removeItem("multica_token");
});
});
}, [cliCallback]);
@@ -134,7 +170,7 @@ export function LoginPage({
await useAuthStore.getState().sendCode(email);
setStep("code");
setCode("");
setCooldown(10);
setCooldown(60);
} catch (err) {
setError(
err instanceof Error
@@ -167,6 +203,7 @@ export function LoginPage({
// Normal path
await useAuthStore.getState().verifyCode(email, value);
const wsList = await api.listWorkspaces();
qc.setQueryData(workspaceKeys.list(), wsList);
useWorkspaceStore.getState().hydrateWorkspace(wsList, lastWorkspaceId);
onTokenObtained?.();
onSuccess();
@@ -178,7 +215,7 @@ export function LoginPage({
setLoading(false);
}
},
[email, onSuccess, cliCallback, lastWorkspaceId, onTokenObtained],
[email, onSuccess, cliCallback, lastWorkspaceId, onTokenObtained, qc],
);
const handleResend = async () => {
@@ -186,7 +223,7 @@ export function LoginPage({
setError("");
try {
await useAuthStore.getState().sendCode(email);
setCooldown(10);
setCooldown(60);
} catch (err) {
setError(
err instanceof Error ? err.message : "Failed to resend code",
@@ -194,16 +231,39 @@ export function LoginPage({
}
};
const handleCliAuthorize = () => {
const handleCliAuthorize = async () => {
if (!cliCallback) return;
const token = localStorage.getItem("multica_token");
if (!token) return;
setLoading(true);
onTokenObtained?.();
redirectToCliCallback(cliCallback.url, token, cliCallback.state);
try {
let token: string;
if (authSourceRef.current === "localStorage") {
// Session was detected via localStorage — reuse that token directly.
const stored = localStorage.getItem("multica_token");
if (!stored) throw new Error("token missing");
token = stored;
} else {
// Session was detected via cookie — obtain a bearer token from the server.
const res = await api.issueCliToken();
token = res.token;
}
onTokenObtained?.();
redirectToCliCallback(cliCallback.url, token, cliCallback.state);
} catch {
setError("Failed to authorize CLI. Please log in again.");
setExistingUser(null);
setStep("email");
setLoading(false);
}
};
const handleGoogleLogin = () => {
if (onGoogleLogin) {
onGoogleLogin();
return;
}
if (!google) return;
const params = new URLSearchParams({
client_id: google.clientId,
@@ -213,6 +273,7 @@ export function LoginPage({
access_type: "offline",
prompt: "select_account",
});
if (google.state) params.set("state", google.state);
window.location.href = `https://accounts.google.com/o/oauth2/v2/auth?${params}`;
};
@@ -371,7 +432,7 @@ export function LoginPage({
>
{loading ? "Sending code..." : "Continue"}
</Button>
{google && (
{(google || onGoogleLogin) && (
<>
<div className="relative w-full">
<div className="absolute inset-0 flex items-center">

View File

@@ -1,7 +1,12 @@
"use client";
import { Send } from "lucide-react";
import { MessageCircle } from "lucide-react";
import { useChatStore } from "@multica/core/chat";
import {
Tooltip,
TooltipTrigger,
TooltipContent,
} from "@multica/ui/components/ui/tooltip";
export function ChatFab() {
const isOpen = useChatStore((s) => s.isOpen);
@@ -10,12 +15,14 @@ export function ChatFab() {
if (isOpen) return null;
return (
<button
onClick={toggle}
className="fixed bottom-4 right-4 z-50 flex items-center gap-2 rounded-full border bg-background px-4 py-2 text-sm font-medium text-muted-foreground shadow-sm transition-colors hover:bg-accent hover:text-accent-foreground"
>
<Send className="size-3.5" />
Ask Multica
</button>
<Tooltip>
<TooltipTrigger
onClick={toggle}
className="absolute bottom-2 right-2 z-50 flex size-10 cursor-pointer items-center justify-center rounded-full ring-1 ring-foreground/10 bg-card text-muted-foreground shadow-sm transition-transform hover:scale-110 hover:text-accent-foreground active:scale-95"
>
<MessageCircle className="size-5" />
</TooltipTrigger>
<TooltipContent side="top" sideOffset={10}>Ask Multica</TooltipContent>
</Tooltip>
);
}

View File

@@ -1,7 +1,9 @@
"use client";
import { useState, useRef, useCallback } from "react";
import { ArrowUp, Square } from "lucide-react";
import { useRef, useState } from "react";
import { ContentEditor, type ContentEditorRef } from "../../editor";
import { SubmitButton } from "@multica/ui/components/common/submit-button";
import { useChatStore } from "@multica/core/chat";
interface ChatInputProps {
onSend: (content: string) => void;
@@ -11,70 +13,44 @@ interface ChatInputProps {
}
export function ChatInput({ onSend, onStop, isRunning, disabled }: ChatInputProps) {
const [value, setValue] = useState("");
const textareaRef = useRef<HTMLTextAreaElement>(null);
const editorRef = useRef<ContentEditorRef>(null);
const inputDraft = useChatStore((s) => s.inputDraft);
const setInputDraft = useChatStore((s) => s.setInputDraft);
const clearInputDraft = useChatStore((s) => s.clearInputDraft);
const [isEmpty, setIsEmpty] = useState(!inputDraft.trim());
const handleSend = useCallback(() => {
const trimmed = value.trim();
if (!trimmed || isRunning || disabled) return;
onSend(trimmed);
setValue("");
if (textareaRef.current) {
textareaRef.current.style.height = "auto";
}
textareaRef.current?.focus();
}, [value, isRunning, disabled, onSend]);
const handleKeyDown = useCallback(
(e: React.KeyboardEvent<HTMLTextAreaElement>) => {
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault();
handleSend();
}
},
[handleSend],
);
const handleInput = useCallback(() => {
const el = textareaRef.current;
if (!el) return;
el.style.height = "auto";
el.style.height = Math.min(el.scrollHeight, 120) + "px";
}, []);
const handleSend = () => {
const content = editorRef.current?.getMarkdown()?.replace(/(\n\s*)+$/, "").trim();
if (!content || isRunning || disabled) return;
onSend(content);
editorRef.current?.clearContent();
clearInputDraft();
setIsEmpty(true);
};
return (
<div className="border-t bg-muted/30 p-3">
<div className="rounded-lg border bg-background">
<textarea
ref={textareaRef}
value={value}
onChange={(e) => {
setValue(e.target.value);
handleInput();
}}
onKeyDown={handleKeyDown}
placeholder={disabled ? "This session is archived" : "Ask Multica..."}
disabled={isRunning || disabled}
className="block w-full resize-none bg-transparent px-3 pt-3 pb-2 text-sm placeholder:text-muted-foreground focus:outline-none disabled:opacity-50"
rows={1}
/>
<div className="flex items-center justify-end px-2 pb-2">
{isRunning ? (
<button
onClick={onStop}
className="flex size-7 items-center justify-center rounded-full bg-foreground text-background transition-opacity hover:opacity-80"
>
<Square className="size-3 fill-current" />
</button>
) : (
<button
onClick={handleSend}
disabled={!value.trim() || disabled}
className="flex size-7 items-center justify-center rounded-full bg-foreground text-background transition-opacity hover:opacity-80 disabled:opacity-30"
>
<ArrowUp className="size-4" />
</button>
)}
<div className="p-2 pt-0">
<div className="relative flex min-h-16 max-h-40 flex-col rounded-lg bg-card pb-8 border-1 border-border transition-colors focus-within:border-brand">
<div className="flex-1 min-h-0 overflow-y-auto px-3 py-2">
<ContentEditor
ref={editorRef}
defaultValue={inputDraft}
placeholder={disabled ? "This session is archived" : "Ask Multica..."}
onUpdate={(md) => {
setIsEmpty(!md.trim());
setInputDraft(md);
}}
onSubmit={handleSend}
debounceMs={100}
/>
</div>
<div className="absolute bottom-1 right-1.5 flex items-center gap-1">
<SubmitButton
onClick={handleSend}
disabled={isEmpty || !!disabled}
running={isRunning}
onStop={onStop}
/>
</div>
</div>
</div>

View File

@@ -1,98 +1,82 @@
"use client";
import { useState, useEffect, useRef } from "react";
import { useState, useRef } from "react";
import { useQuery } from "@tanstack/react-query";
import { cn } from "@multica/ui/lib/utils";
import { Avatar, AvatarFallback, AvatarImage } from "@multica/ui/components/ui/avatar";
import {
Collapsible,
CollapsibleContent,
CollapsibleTrigger,
} from "@multica/ui/components/ui/collapsible";
import { Bot, Loader2, ChevronRight, ChevronDown, Brain, AlertCircle } from "lucide-react";
import { Loader2, ChevronRight, ChevronDown, Brain, AlertCircle } from "lucide-react";
import { useScrollFade } from "@multica/ui/hooks/use-scroll-fade";
import { useAutoScroll } from "@multica/ui/hooks/use-auto-scroll";
import { api } from "@multica/core/api";
import { Markdown } from "@multica/views/common/markdown";
import type { ChatMessage, Agent, TaskMessagePayload } from "@multica/core/types";
import type { ChatMessage, TaskMessagePayload } from "@multica/core/types";
import type { ChatTimelineItem } from "@multica/core/chat";
// ─── Public component ────────────────────────────────────────────────────
interface ChatMessageListProps {
messages: ChatMessage[];
agent: Agent | null;
timelineItems: ChatTimelineItem[];
isWaiting: boolean;
}
export function ChatMessageList({
messages,
agent,
timelineItems,
isWaiting,
}: ChatMessageListProps) {
const bottomRef = useRef<HTMLDivElement>(null);
useEffect(() => {
bottomRef.current?.scrollIntoView({ behavior: "smooth" });
}, [messages, timelineItems]);
const scrollRef = useRef<HTMLDivElement>(null);
const fadeStyle = useScrollFade(scrollRef);
useAutoScroll(scrollRef);
const hasTimeline = timelineItems.length > 0;
return (
<div className="flex-1 overflow-y-auto px-4 py-3 space-y-4">
<div
ref={scrollRef}
style={fadeStyle}
className="flex-1 overflow-y-auto px-4 py-3 space-y-4"
>
{messages.map((msg) => (
<MessageBubble key={msg.id} message={msg} agent={agent} />
<MessageBubble key={msg.id} message={msg} />
))}
{/* Live streaming timeline */}
{hasTimeline && (
<div className="flex items-start gap-3">
<AgentAvatar agent={agent} />
<div className="min-w-0 flex-1 space-y-1.5">
<TimelineView items={timelineItems} />
</div>
<div className="w-full space-y-1.5">
<TimelineView items={timelineItems} />
</div>
)}
{isWaiting && !hasTimeline && (
<div className="flex items-start gap-3">
<AgentAvatar agent={agent} />
<div className="flex items-center pt-1">
<Loader2 className="size-4 animate-spin text-muted-foreground" />
</div>
</div>
<Loader2 className="size-4 animate-spin text-muted-foreground" />
)}
<div ref={bottomRef} />
</div>
);
}
// ─── Message bubbles ─────────────────────────────────────────────────────
function MessageBubble({
message,
agent,
}: {
message: ChatMessage;
agent: Agent | null;
}) {
function MessageBubble({ message }: { message: ChatMessage }) {
if (message.role === "user") {
return (
<div className="flex justify-end">
<div className="rounded-2xl bg-primary px-3.5 py-2 text-sm text-primary-foreground max-w-[85%] whitespace-pre-wrap break-words">
<div className="rounded-2xl bg-muted px-3.5 py-2 text-sm max-w-[80%] whitespace-pre-wrap break-words">
{message.content}
</div>
</div>
);
}
return <AssistantMessage message={message} agent={agent} />;
return <AssistantMessage message={message} />;
}
function AssistantMessage({
message,
agent,
}: {
message: ChatMessage;
agent: Agent | null;
}) {
const taskId = message.task_id;
@@ -116,17 +100,14 @@ function AssistantMessage({
);
return (
<div className="flex items-start gap-3">
<AgentAvatar agent={agent} />
<div className="min-w-0 flex-1 space-y-1.5">
{timeline.length > 0 ? (
<TimelineView items={timeline} />
) : (
<div className="text-sm leading-relaxed prose prose-sm dark:prose-invert max-w-none">
<Markdown>{message.content}</Markdown>
</div>
)}
</div>
<div className="w-full space-y-1.5">
{timeline.length > 0 ? (
<TimelineView items={timeline} />
) : (
<div className="text-sm leading-relaxed prose prose-sm dark:prose-invert max-w-none">
<Markdown>{message.content}</Markdown>
</div>
)}
</div>
);
}
@@ -356,13 +337,3 @@ function ErrorRow({ item }: { item: ChatTimelineItem }) {
// ─── Shared ──────────────────────────────────────────────────────────────
function AgentAvatar({ agent }: { agent: Agent | null }) {
return (
<Avatar className="size-6 shrink-0 mt-0.5">
{agent?.avatar_url && <AvatarImage src={agent.avatar_url} />}
<AvatarFallback className="bg-purple-100 text-purple-700">
<Bot className="size-3" />
</AvatarFallback>
</Avatar>
);
}

View File

@@ -0,0 +1,34 @@
"use client";
import React from "react";
type DragDir = "left" | "top" | "corner";
interface ChatResizeHandlesProps {
onDragStart: (e: React.PointerEvent, dir: DragDir) => void;
}
export function ChatResizeHandles({ onDragStart }: ChatResizeHandlesProps) {
return (
<>
{/* Left edge — expands width when dragged left */}
<div
aria-hidden
onPointerDown={(e) => onDragStart(e, "left")}
className="absolute left-0 top-4 bottom-0 w-1 z-10 cursor-col-resize"
/>
{/* Top edge — expands height when dragged up */}
<div
aria-hidden
onPointerDown={(e) => onDragStart(e, "top")}
className="absolute top-0 left-4 right-0 h-1 z-10 cursor-row-resize"
/>
{/* Top-left corner — expands both width and height */}
<div
aria-hidden
onPointerDown={(e) => onDragStart(e, "corner")}
className="absolute top-0 left-0 size-4 z-20 cursor-nw-resize"
/>
</>
);
}

View File

@@ -1,13 +1,14 @@
"use client";
import { useQuery } from "@tanstack/react-query";
import { ArrowLeft, MessageSquare, Archive, Trash2 } from "lucide-react";
import { ArrowLeft, MessageSquare, Bot } from "lucide-react";
import { cn } from "@multica/ui/lib/utils";
import { Button } from "@multica/ui/components/ui/button";
import { Tooltip, TooltipTrigger, TooltipContent } from "@multica/ui/components/ui/tooltip";
import { Avatar, AvatarFallback, AvatarImage } from "@multica/ui/components/ui/avatar";
import { Bot } from "lucide-react";
import { useWorkspaceId } from "@multica/core/hooks";
import { agentListOptions } from "@multica/core/workspace/queries";
import { allChatSessionsOptions } from "@multica/core/chat/queries";
import { useArchiveChatSession } from "@multica/core/chat/mutations";
import { useChatStore } from "@multica/core/chat";
import type { ChatSession, Agent } from "@multica/core/types";
@@ -21,7 +22,6 @@ export function ChatSessionHistory() {
const { data: sessions = [] } = useQuery(allChatSessionsOptions(wsId));
const { data: agents = [] } = useQuery(agentListOptions(wsId));
const archiveSession = useArchiveChatSession();
const agentMap = new Map(agents.map((a) => [a.id, a]));
@@ -32,27 +32,25 @@ export function ChatSessionHistory() {
setShowHistory(false);
};
const handleArchive = (e: React.MouseEvent, sessionId: string) => {
e.stopPropagation();
archiveSession.mutate(sessionId);
if (activeSessionId === sessionId) {
setActiveSession(null);
}
};
const activeSessions = sessions.filter((s) => s.status === "active");
const archivedSessions = sessions.filter((s) => s.status === "archived");
return (
<div className="flex flex-1 flex-col overflow-hidden">
{/* Header */}
<div className="flex items-center gap-2 border-b px-4 py-2.5">
<button
onClick={() => setShowHistory(false)}
className="flex size-7 items-center justify-center rounded-md text-muted-foreground transition-colors hover:bg-accent hover:text-accent-foreground"
>
<ArrowLeft className="size-3.5" />
</button>
<Tooltip>
<TooltipTrigger
render={
<Button
variant="ghost"
size="icon-sm"
className="text-muted-foreground"
onClick={() => setShowHistory(false)}
/>
}
>
<ArrowLeft />
</TooltipTrigger>
<TooltipContent side="bottom">Back</TooltipContent>
</Tooltip>
<span className="text-sm font-medium">Chat History</span>
</div>
@@ -64,94 +62,47 @@ export function ChatSessionHistory() {
<span className="text-sm">No chat sessions yet</span>
</div>
) : (
<>
{activeSessions.length > 0 && (
<SessionGroup
label="Active"
sessions={activeSessions}
agentMap={agentMap}
activeSessionId={activeSessionId}
onSelect={handleSelectSession}
onArchive={handleArchive}
<div>
{sessions.map((session) => (
<SessionItem
key={session.id}
session={session}
agent={agentMap.get(session.agent_id) ?? null}
isActive={session.id === activeSessionId}
onSelect={() => handleSelectSession(session)}
/>
)}
{archivedSessions.length > 0 && (
<SessionGroup
label="Archived"
sessions={archivedSessions}
agentMap={agentMap}
activeSessionId={activeSessionId}
onSelect={handleSelectSession}
/>
)}
</>
))}
</div>
)}
</div>
</div>
);
}
function SessionGroup({
label,
sessions,
agentMap,
activeSessionId,
onSelect,
onArchive,
}: {
label: string;
sessions: ChatSession[];
agentMap: Map<string, Agent>;
activeSessionId: string | null;
onSelect: (session: ChatSession) => void;
onArchive?: (e: React.MouseEvent, sessionId: string) => void;
}) {
return (
<div>
<div className="px-4 pt-3 pb-1">
<span className="text-xs font-medium text-muted-foreground uppercase tracking-wider">
{label}
</span>
</div>
{sessions.map((session) => (
<SessionItem
key={session.id}
session={session}
agent={agentMap.get(session.agent_id) ?? null}
isActive={session.id === activeSessionId}
onSelect={() => onSelect(session)}
onArchive={onArchive ? (e) => onArchive(e, session.id) : undefined}
/>
))}
</div>
);
}
function SessionItem({
session,
agent,
isActive,
onSelect,
onArchive,
}: {
session: ChatSession;
agent: Agent | null;
isActive: boolean;
onSelect: () => void;
onArchive?: (e: React.MouseEvent) => void;
}) {
const timeAgo = formatTimeAgo(session.updated_at);
return (
<button
onClick={onSelect}
className={`group flex w-full items-start gap-3 px-4 py-2.5 text-left transition-colors hover:bg-accent/50 ${
isActive ? "bg-accent/30" : ""
}`}
className={cn(
"flex w-full items-start gap-3 px-4 py-2.5 text-left transition-colors hover:bg-accent/50",
isActive && "bg-accent/30",
)}
>
<Avatar className="size-6 shrink-0 mt-0.5">
{agent?.avatar_url && <AvatarImage src={agent.avatar_url} />}
<AvatarFallback className="bg-purple-100 text-purple-700 text-[10px]">
<AvatarFallback className="bg-purple-100 text-purple-700">
<Bot className="size-3" />
</AvatarFallback>
</Avatar>
@@ -160,9 +111,6 @@ function SessionItem({
<span className="truncate text-sm font-medium">
{session.title || "Untitled"}
</span>
{session.status === "archived" && (
<Archive className="size-3 shrink-0 text-muted-foreground" />
)}
</div>
<div className="flex items-center gap-1.5 mt-0.5">
{agent && (
@@ -173,15 +121,6 @@ function SessionItem({
<span className="text-xs text-muted-foreground/60">{timeAgo}</span>
</div>
</div>
{onArchive && (
<button
onClick={onArchive}
title="Archive"
className="invisible group-hover:visible flex size-6 items-center justify-center rounded-md text-muted-foreground transition-colors hover:bg-accent hover:text-destructive shrink-0 mt-0.5"
>
<Trash2 className="size-3" />
</button>
)}
</button>
);
}

View File

@@ -1,13 +1,18 @@
"use client";
import { useCallback, useEffect, useRef } from "react";
import React, { useCallback, useEffect, useRef } from "react";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { Minus, Maximize2, Minimize2, Send, ChevronDown, Bot, Plus, History } from "lucide-react";
import { Avatar, AvatarFallback, AvatarImage } from "@multica/ui/components/ui/avatar";
import { Button } from "@multica/ui/components/ui/button";
import { Tooltip, TooltipTrigger, TooltipContent } from "@multica/ui/components/ui/tooltip";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@multica/ui/components/ui/dropdown-menu";
import { useWorkspaceId } from "@multica/core/hooks";
@@ -26,19 +31,19 @@ import { useChatStore } from "@multica/core/chat";
import { ChatMessageList } from "./chat-message-list";
import { ChatInput } from "./chat-input";
import { ChatSessionHistory } from "./chat-session-history";
import { ChatResizeHandles } from "./chat-resize-handles";
import { useChatResize } from "./use-chat-resize";
import { useWS } from "@multica/core/realtime";
import type { TaskMessagePayload, ChatDonePayload, Agent, ChatMessage } from "@multica/core/types";
export function ChatWindow() {
const wsId = useWorkspaceId();
const isOpen = useChatStore((s) => s.isOpen);
const isFullscreen = useChatStore((s) => s.isFullscreen);
const activeSessionId = useChatStore((s) => s.activeSessionId);
const pendingTaskId = useChatStore((s) => s.pendingTaskId);
const timelineItems = useChatStore((s) => s.timelineItems);
const selectedAgentId = useChatStore((s) => s.selectedAgentId);
const setOpen = useChatStore((s) => s.setOpen);
const toggleFullscreen = useChatStore((s) => s.toggleFullscreen);
const showHistory = useChatStore((s) => s.showHistory);
const setActiveSession = useChatStore((s) => s.setActiveSession);
const setPendingTask = useChatStore((s) => s.setPendingTask);
@@ -46,7 +51,6 @@ export function ChatWindow() {
const clearTimeline = useChatStore((s) => s.clearTimeline);
const setSelectedAgentId = useChatStore((s) => s.setSelectedAgentId);
const setShowHistory = useChatStore((s) => s.setShowHistory);
const user = useAuthStore((s) => s.user);
const { data: agents = [] } = useQuery(agentListOptions(wsId));
const { data: members = [] } = useQuery(memberListOptions(wsId));
@@ -221,57 +225,105 @@ export function ChatWindow() {
[setSelectedAgentId, setActiveSession],
);
if (!isOpen) return null;
const windowRef = useRef<HTMLDivElement>(null);
const { renderWidth, renderHeight, isAtMax, boundsReady, isDragging, toggleExpand, startDrag } = useChatResize(windowRef);
const hasMessages = messages.length > 0 || timelineItems.length > 0;
const containerClass = isFullscreen
? "fixed inset-y-0 right-0 z-50 flex flex-col w-[50%] border-l bg-background shadow-2xl"
: "fixed bottom-4 right-4 z-50 flex flex-col w-[420px] h-[600px] rounded-xl border bg-background shadow-2xl overflow-hidden";
const isVisible = isOpen && boundsReady;
const containerClass = "absolute bottom-2 right-2 z-50 flex flex-col rounded-xl ring-1 ring-foreground/10 bg-sidebar shadow-2xl overflow-hidden";
const containerStyle: React.CSSProperties = {
width: `${renderWidth}px`,
height: `${renderHeight}px`,
opacity: isVisible ? 1 : 0,
transform: isVisible ? "scale(1)" : "scale(0.95)",
transformOrigin: "bottom right",
pointerEvents: isOpen ? "auto" : "none",
transition: isDragging
? "none"
: "width 200ms ease-out, height 200ms ease-out, opacity 150ms ease-out, transform 150ms ease-out",
};
return (
<div className={containerClass}>
<div ref={windowRef} className={containerClass} style={containerStyle}>
<ChatResizeHandles onDragStart={startDrag} />
{/* Header */}
{!showHistory && (
<div className="flex items-center justify-between border-b px-4 py-2.5">
<AgentSelector
agents={availableAgents}
activeAgent={activeAgent}
userId={user?.id}
onSelect={handleSelectAgent}
/>
<div className="flex items-center gap-0.5">
<button
onClick={() => setShowHistory(true)}
title="Chat history"
className="flex size-7 items-center justify-center rounded-md text-muted-foreground transition-colors hover:bg-accent hover:text-accent-foreground"
>
<History className="size-3.5" />
</button>
<button
onClick={() => {
setActiveSession(null);
clearTimeline();
setPendingTask(null);
}}
title="New chat"
className="flex size-7 items-center justify-center rounded-md text-muted-foreground transition-colors hover:bg-accent hover:text-accent-foreground"
>
<Plus className="size-3.5" />
</button>
<button
onClick={toggleFullscreen}
title={isFullscreen ? "Exit fullscreen" : "Fullscreen"}
className="flex size-7 items-center justify-center rounded-md text-muted-foreground transition-colors hover:bg-accent hover:text-accent-foreground"
>
{isFullscreen ? <Minimize2 className="size-3.5" /> : <Maximize2 className="size-3.5" />}
</button>
<button
onClick={() => setOpen(false)}
title="Minimize"
className="flex size-7 items-center justify-center rounded-md text-muted-foreground transition-colors hover:bg-accent hover:text-accent-foreground"
>
<Minus className="size-3.5" />
</button>
<Tooltip>
<TooltipTrigger
render={
<Button
variant="ghost"
size="icon-sm"
className="text-muted-foreground"
onClick={() => setShowHistory(true)}
/>
}
>
<History />
</TooltipTrigger>
<TooltipContent side="bottom">Chat history</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger
render={
<Button
variant="ghost"
size="icon-sm"
className="text-muted-foreground"
onClick={() => {
setActiveSession(null);
clearTimeline();
setPendingTask(null);
}}
/>
}
>
<Plus />
</TooltipTrigger>
<TooltipContent side="bottom">New chat</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger
render={
<Button
variant="ghost"
size="icon-sm"
className="text-muted-foreground"
onClick={toggleExpand}
/>
}
>
{isAtMax ? <Minimize2 /> : <Maximize2 />}
</TooltipTrigger>
<TooltipContent side="bottom">
{isAtMax ? "Restore" : "Expand"}
</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger
render={
<Button
variant="ghost"
size="icon-sm"
className="text-muted-foreground"
onClick={() => setOpen(false)}
/>
}
>
<Minus />
</TooltipTrigger>
<TooltipContent side="bottom">Minimize</TooltipContent>
</Tooltip>
</div>
</div>
)}
@@ -284,7 +336,6 @@ export function ChatWindow() {
{hasMessages ? (
<ChatMessageList
messages={messages}
agent={activeAgent}
timelineItems={timelineItems}
isWaiting={!!pendingTaskId}
/>
@@ -308,10 +359,12 @@ export function ChatWindow() {
function AgentSelector({
agents,
activeAgent,
userId,
onSelect,
}: {
agents: Agent[];
activeAgent: Agent | null;
userId: string | undefined;
onSelect: (agent: Agent) => void;
}) {
if (!activeAgent) {
@@ -327,24 +380,48 @@ function AgentSelector({
);
}
const myAgents = agents.filter((a) => a.owner_id === userId);
const othersAgents = agents.filter((a) => a.owner_id !== userId);
return (
<DropdownMenu>
<DropdownMenuTrigger className="flex items-center gap-2 rounded-md px-1.5 py-1 -ml-1.5 transition-colors hover:bg-accent">
<DropdownMenuTrigger className="flex items-center gap-2 rounded-md px-1.5 py-1 -ml-1.5 transition-colors hover:bg-accent aria-expanded:bg-accent">
<AgentAvatarSmall agent={activeAgent} />
<span className="text-sm font-medium">{activeAgent.name}</span>
<ChevronDown className="size-3 text-muted-foreground" />
</DropdownMenuTrigger>
<DropdownMenuContent align="start">
{agents.map((agent) => (
<DropdownMenuItem
key={agent.id}
onClick={() => onSelect(agent)}
className="flex items-center gap-2"
>
<AgentAvatarSmall agent={agent} />
<span>{agent.name}</span>
</DropdownMenuItem>
))}
<DropdownMenuContent align="start" className="max-h-60 w-auto max-w-56">
{myAgents.length > 0 && (
<DropdownMenuGroup>
<DropdownMenuLabel>My Agents</DropdownMenuLabel>
{myAgents.map((agent) => (
<DropdownMenuItem
key={agent.id}
onClick={() => onSelect(agent)}
className="flex min-w-0 items-center gap-2"
>
<AgentAvatarSmall agent={agent} />
<span className="truncate">{agent.name}</span>
</DropdownMenuItem>
))}
</DropdownMenuGroup>
)}
{myAgents.length > 0 && othersAgents.length > 0 && <DropdownMenuSeparator />}
{othersAgents.length > 0 && (
<DropdownMenuGroup>
<DropdownMenuLabel>Others</DropdownMenuLabel>
{othersAgents.map((agent) => (
<DropdownMenuItem
key={agent.id}
onClick={() => onSelect(agent)}
className="flex min-w-0 items-center gap-2"
>
<AgentAvatarSmall agent={agent} />
<span className="truncate">{agent.name}</span>
</DropdownMenuItem>
))}
</DropdownMenuGroup>
)}
</DropdownMenuContent>
</DropdownMenu>
);
@@ -354,7 +431,7 @@ function AgentAvatarSmall({ agent }: { agent: Agent }) {
return (
<Avatar className="size-5">
{agent.avatar_url && <AvatarImage src={agent.avatar_url} />}
<AvatarFallback className="bg-purple-100 text-purple-700 text-[10px]">
<AvatarFallback className="bg-purple-100 text-purple-700">
<Bot className="size-3" />
</AvatarFallback>
</Avatar>

View File

@@ -0,0 +1,135 @@
"use client";
import React, { useRef, useCallback, useState, useEffect } from "react";
import { CHAT_MIN_W, CHAT_MIN_H, useChatStore } from "@multica/core/chat";
type DragDir = "left" | "top" | "corner";
const MAX_RATIO = 0.9;
const FALLBACK_MAX_W = 800;
const FALLBACK_MAX_H = 700;
function clamp(v: number, min: number, max: number) {
return Math.max(min, Math.min(max, v));
}
export function useChatResize(
windowRef: React.RefObject<HTMLDivElement | null>,
) {
const chatWidth = useChatStore((s) => s.chatWidth);
const chatHeight = useChatStore((s) => s.chatHeight);
const isExpanded = useChatStore((s) => s.isExpanded);
const setChatSize = useChatStore((s) => s.setChatSize);
const setExpanded = useChatStore((s) => s.setExpanded);
// ── Container bounds via ResizeObserver ────────────────────────────────
const boundsRef = useRef({ maxW: FALLBACK_MAX_W, maxH: FALLBACK_MAX_H });
const [boundsReady, setBoundsReady] = useState(false);
const [isDragging, setIsDragging] = useState(false);
const [, setRevision] = useState(0);
useEffect(() => {
const el = windowRef.current;
const parent = el?.parentElement;
if (!parent) return;
const update = () => {
boundsRef.current = {
maxW: Math.floor(parent.clientWidth * MAX_RATIO),
maxH: Math.floor(parent.clientHeight * MAX_RATIO),
};
setBoundsReady(true);
setRevision((r) => r + 1);
};
// Measure immediately (parent is already in DOM at this point)
update();
const ro = new ResizeObserver(update);
ro.observe(parent);
return () => ro.disconnect();
}, [windowRef]);
// ── Derive rendered size ──────────────────────────────────────────────
const { maxW, maxH } = boundsRef.current;
const renderWidth = isExpanded ? maxW : clamp(chatWidth, CHAT_MIN_W, maxW);
const renderHeight = isExpanded ? maxH : clamp(chatHeight, CHAT_MIN_H, maxH);
// ── Expand / Restore ──────────────────────────────────────────────────
const isAtMax = renderWidth >= maxW && renderHeight >= maxH;
const toggleExpand = useCallback(() => {
if (isExpanded || isAtMax) {
setChatSize(CHAT_MIN_W, CHAT_MIN_H);
} else {
setExpanded(true);
}
}, [isExpanded, isAtMax, setChatSize, setExpanded]);
// ── Drag ──────────────────────────────────────────────────────────────
const dragRef = useRef<{
startX: number;
startY: number;
startW: number;
startH: number;
dir: DragDir;
} | null>(null);
const startDrag = useCallback(
(e: React.PointerEvent, dir: DragDir) => {
e.preventDefault();
(e.target as HTMLElement).setPointerCapture(e.pointerId);
dragRef.current = {
startX: e.clientX,
startY: e.clientY,
startW: renderWidth,
startH: renderHeight,
dir,
};
setIsDragging(true);
const onPointerMove = (ev: PointerEvent) => {
const d = dragRef.current;
if (!d) return;
const { maxW: mw, maxH: mh } = boundsRef.current;
const rawW =
dir === "left" || dir === "corner"
? d.startW - (ev.clientX - d.startX)
: d.startW;
const rawH =
dir === "top" || dir === "corner"
? d.startH - (ev.clientY - d.startY)
: d.startH;
setChatSize(clamp(rawW, CHAT_MIN_W, mw), clamp(rawH, CHAT_MIN_H, mh));
};
const onPointerUp = () => {
dragRef.current = null;
setIsDragging(false);
document.removeEventListener("pointermove", onPointerMove);
document.removeEventListener("pointerup", onPointerUp);
document.body.style.cursor = "";
document.body.style.userSelect = "";
};
document.addEventListener("pointermove", onPointerMove);
document.addEventListener("pointerup", onPointerUp);
const cursorMap: Record<DragDir, string> = {
left: "col-resize",
top: "row-resize",
corner: "nw-resize",
};
document.body.style.cursor = cursorMap[dir];
document.body.style.userSelect = "none";
},
[renderWidth, renderHeight, setChatSize],
);
return { renderWidth, renderHeight, isAtMax, boundsReady, isDragging, toggleExpand, startDrag };
}

View File

@@ -0,0 +1,474 @@
"use client";
/**
* EditorBubbleMenu — floating formatting toolbar for text selection.
*
* Uses Tiptap's native <BubbleMenu> component which has battle-tested
* focus management (preventHide flag, relatedTarget checks, mousedown
* capture). We only add scroll-container visibility detection on top,
* because the plugin's hide middleware can't detect nested scroll
* container clipping (virtual element has no contextElement).
*/
import { useState, useEffect, useCallback, useRef } from "react";
import { BubbleMenu } from "@tiptap/react/menus";
import type { Editor } from "@tiptap/core";
import { NodeSelection } from "@tiptap/pm/state";
import type { EditorState } from "@tiptap/pm/state";
import type { EditorView } from "@tiptap/pm/view";
import { Toggle } from "@multica/ui/components/ui/toggle";
import { Separator } from "@multica/ui/components/ui/separator";
import {
Tooltip,
TooltipTrigger,
TooltipContent,
TooltipProvider,
} from "@multica/ui/components/ui/tooltip";
import {
Popover,
PopoverTrigger,
PopoverContent,
} from "@multica/ui/components/ui/popover";
import { Input } from "@multica/ui/components/ui/input";
import { Button } from "@multica/ui/components/ui/button";
import {
Bold,
Italic,
Strikethrough,
Code,
Link2,
List,
ListOrdered,
Quote,
ChevronDown,
Check,
X,
Unlink,
Type,
Heading1,
Heading2,
Heading3,
} from "lucide-react";
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
function shouldShowBubbleMenu({
editor,
view,
state,
from,
to,
}: {
editor: Editor;
view: EditorView;
state: EditorState;
oldState?: EditorState;
from: number;
to: number;
}) {
if (!editor.isEditable) return false;
if (state.selection.empty) return false;
if (!state.doc.textBetween(from, to).trim().length) return false;
if (state.selection instanceof NodeSelection) return false;
if (!view.hasFocus()) return false;
const $from = state.doc.resolve(from);
if ($from.parent.type.name === "codeBlock") return false;
return true;
}
const isMac =
typeof navigator !== "undefined" && /Mac/.test(navigator.platform);
const mod = isMac ? "\u2318" : "Ctrl";
/** Walk up from `el` to find the nearest ancestor with overflow: auto/scroll. */
function getScrollParent(el: HTMLElement): HTMLElement | Window {
let parent = el.parentElement;
while (parent) {
const style = getComputedStyle(parent);
if (/(auto|scroll)/.test(style.overflow + style.overflowY)) return parent;
parent = parent.parentElement;
}
return window;
}
// ---------------------------------------------------------------------------
// Mark Toggle Button
// ---------------------------------------------------------------------------
type InlineMark = "bold" | "italic" | "strike" | "code";
const toggleMarkActions: Record<InlineMark, (editor: Editor) => void> = {
bold: (e) => e.chain().focus().toggleBold().run(),
italic: (e) => e.chain().focus().toggleItalic().run(),
strike: (e) => e.chain().focus().toggleStrike().run(),
code: (e) => e.chain().focus().toggleCode().run(),
};
function MarkButton({
editor,
mark,
icon: Icon,
label,
shortcut,
}: {
editor: Editor;
mark: InlineMark;
icon: React.ComponentType<{ className?: string }>;
label: string;
shortcut: string;
}) {
return (
<Tooltip>
<TooltipTrigger
render={
<Toggle
size="sm"
pressed={editor.isActive(mark)}
onPressedChange={() => toggleMarkActions[mark](editor)}
onMouseDown={(e) => e.preventDefault()}
/>
}
>
<Icon className="size-3.5" />
</TooltipTrigger>
<TooltipContent side="top" sideOffset={8}>
{label}
<span className="ml-1.5 text-muted-foreground">{shortcut}</span>
</TooltipContent>
</Tooltip>
);
}
// ---------------------------------------------------------------------------
// URL normalisation
// ---------------------------------------------------------------------------
/** Protocols that can execute code in the browser — the only ones we block. */
const DANGEROUS_PROTOCOL_RE = /^(javascript|data|vbscript):/i;
const HAS_PROTOCOL_RE = /^[a-z][a-z0-9+.-]*:\/?\/?/i;
const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
/**
* Normalise a user-entered URL: add protocol, detect mailto, block XSS.
*
* Uses a blocklist (not allowlist) for protocols — only `javascript:`,
* `data:`, and `vbscript:` are blocked. All other protocols pass through
* because they can't execute code in the browser and are legitimate
* deep-link targets in a team tool (slack://, vscode://, figma://).
* Tiptap's `isAllowedUri` in the `setLink` command provides a second
* safety layer.
*/
function normalizeUrl(input: string): string {
const trimmed = input.trim();
if (!trimmed) return "";
if (trimmed.startsWith("/")) return trimmed;
if (DANGEROUS_PROTOCOL_RE.test(trimmed)) return "";
if (HAS_PROTOCOL_RE.test(trimmed)) return trimmed;
if (EMAIL_RE.test(trimmed)) return `mailto:${trimmed}`;
if (trimmed.startsWith("//")) return `https:${trimmed}`;
return `https://${trimmed}`;
}
// ---------------------------------------------------------------------------
// Link Edit Bar
// ---------------------------------------------------------------------------
function LinkEditBar({
editor,
onClose,
}: {
editor: Editor;
onClose: () => void;
}) {
const existingHref = editor.getAttributes("link").href as string | undefined;
const [url, setUrl] = useState(existingHref ?? "");
const inputRef = useRef<HTMLInputElement>(null);
useEffect(() => {
const t = setTimeout(() => inputRef.current?.focus(), 0);
return () => clearTimeout(t);
}, []);
const apply = useCallback(() => {
const href = normalizeUrl(url);
if (!href) {
editor.chain().focus().extendMarkRange("link").unsetLink().run();
} else {
editor.chain().focus().extendMarkRange("link").setLink({ href }).run();
}
onClose();
}, [editor, url, onClose]);
const remove = useCallback(() => {
editor.chain().focus().extendMarkRange("link").unsetLink().run();
onClose();
}, [editor, onClose]);
return (
<div className="bubble-menu-link-edit" onMouseDown={(e) => e.preventDefault()}>
<Input
ref={inputRef}
value={url}
onChange={(e) => setUrl(e.target.value)}
placeholder="https://..."
aria-label="URL"
className="h-7 flex-1 text-xs"
onKeyDown={(e) => {
if (e.key === "Enter") { e.preventDefault(); apply(); }
if (e.key === "Escape") { e.preventDefault(); onClose(); editor.commands.focus(); }
}}
/>
<Button size="icon-xs" variant="ghost" onClick={apply} onMouseDown={(e) => e.preventDefault()}>
<Check className="size-3.5" />
</Button>
{existingHref && (
<Button size="icon-xs" variant="ghost" onClick={remove} onMouseDown={(e) => e.preventDefault()}>
<Unlink className="size-3.5" />
</Button>
)}
<Button size="icon-xs" variant="ghost" onClick={() => { onClose(); editor.commands.focus(); }} onMouseDown={(e) => e.preventDefault()}>
<X className="size-3.5" />
</Button>
</div>
);
}
// ---------------------------------------------------------------------------
// Heading Dropdown
// ---------------------------------------------------------------------------
function HeadingDropdown({ editor, onOpenChange }: { editor: Editor; onOpenChange: (open: boolean) => void }) {
const [open, setOpen] = useState(false);
const activeLevel = [1, 2, 3].find((l) => editor.isActive("heading", { level: l }));
const label = activeLevel ? `H${activeLevel}` : "Text";
const items = [
{ label: "Normal Text", icon: Type, active: !activeLevel, action: () => editor.chain().focus().setParagraph().run() },
{ label: "Heading 1", icon: Heading1, active: activeLevel === 1, action: () => editor.chain().focus().toggleHeading({ level: 1 }).run() },
{ label: "Heading 2", icon: Heading2, active: activeLevel === 2, action: () => editor.chain().focus().toggleHeading({ level: 2 }).run() },
{ label: "Heading 3", icon: Heading3, active: activeLevel === 3, action: () => editor.chain().focus().toggleHeading({ level: 3 }).run() },
];
const handleOpenChange = useCallback((next: boolean) => {
setOpen(next);
onOpenChange(next);
}, [onOpenChange]);
return (
<Popover modal={false} open={open} onOpenChange={handleOpenChange}>
<PopoverTrigger
className="inline-flex h-7 items-center gap-0.5 rounded-md px-1.5 text-xs font-medium hover:bg-muted"
onMouseDown={(e) => e.preventDefault()}
>
{label}
<ChevronDown className="size-3" />
</PopoverTrigger>
<PopoverContent
side="bottom"
sideOffset={8}
align="start"
className="w-auto min-w-32 p-1"
initialFocus={false}
finalFocus={false}
>
{items.map((item) => (
<button
key={item.label}
className="flex w-full cursor-default items-center gap-2 rounded-md px-1.5 py-1 text-xs outline-hidden select-none hover:bg-accent hover:text-accent-foreground"
onMouseDown={(e) => {
e.preventDefault();
item.action();
handleOpenChange(false);
}}
>
<item.icon className="size-3.5" />
{item.label}
{item.active && <Check className="ml-auto size-3.5" />}
</button>
))}
</PopoverContent>
</Popover>
);
}
// ---------------------------------------------------------------------------
// List Dropdown
// ---------------------------------------------------------------------------
function ListDropdown({ editor, onOpenChange }: { editor: Editor; onOpenChange: (open: boolean) => void }) {
const [open, setOpen] = useState(false);
const isBullet = editor.isActive("bulletList");
const isOrdered = editor.isActive("orderedList");
const handleOpenChange = useCallback((next: boolean) => {
setOpen(next);
onOpenChange(next);
}, [onOpenChange]);
return (
<Popover modal={false} open={open} onOpenChange={handleOpenChange}>
<Tooltip>
<TooltipTrigger render={
<PopoverTrigger className="inline-flex h-7 items-center gap-0.5 rounded-md px-1.5 text-xs font-medium hover:bg-muted aria-pressed:bg-muted" aria-pressed={isBullet || isOrdered} onMouseDown={(e) => e.preventDefault()} />
}>
<List className="size-3.5" />
<ChevronDown className="size-3" />
</TooltipTrigger>
<TooltipContent side="top" sideOffset={8}>List</TooltipContent>
</Tooltip>
<PopoverContent
side="bottom"
sideOffset={8}
align="start"
className="w-auto min-w-32 p-1"
initialFocus={false}
finalFocus={false}
>
<button
className="flex w-full cursor-default items-center gap-2 rounded-md px-1.5 py-1 text-xs outline-hidden select-none hover:bg-accent hover:text-accent-foreground"
onMouseDown={(e) => {
e.preventDefault();
editor.chain().focus().toggleBulletList().run();
handleOpenChange(false);
}}
>
<List className="size-3.5" /> Bullet List
{isBullet && <Check className="ml-auto size-3.5" />}
</button>
<button
className="flex w-full cursor-default items-center gap-2 rounded-md px-1.5 py-1 text-xs outline-hidden select-none hover:bg-accent hover:text-accent-foreground"
onMouseDown={(e) => {
e.preventDefault();
editor.chain().focus().toggleOrderedList().run();
handleOpenChange(false);
}}
>
<ListOrdered className="size-3.5" /> Ordered List
{isOrdered && <Check className="ml-auto size-3.5" />}
</button>
</PopoverContent>
</Popover>
);
}
// ---------------------------------------------------------------------------
// Main Bubble Menu — native Tiptap <BubbleMenu>
// ---------------------------------------------------------------------------
function EditorBubbleMenu({ editor }: { editor: Editor }) {
const [mode, setMode] = useState<"toolbar" | "link-edit">("toolbar");
const [scrollTarget, setScrollTarget] = useState<HTMLElement | Window>(window);
// Find the real scroll container once on mount
useEffect(() => {
setScrollTarget(getScrollParent(editor.view.dom));
}, [editor]);
// Hide when the selection scrolls outside the scroll container's
// visible area. The plugin's hide middleware can't detect this because
// its virtual reference element has no contextElement — Floating UI
// only checks viewport bounds. We use `display` (not managed by the
// plugin) as an additive visibility layer.
const scrollHiddenRef = useRef(false);
const [, forceRender] = useState(0);
useEffect(() => {
if (scrollTarget === window) return;
const el = scrollTarget as HTMLElement;
const onScroll = () => {
if (editor.state.selection.empty) {
if (scrollHiddenRef.current) {
scrollHiddenRef.current = false;
forceRender((n) => n + 1);
}
return;
}
const coords = editor.view.coordsAtPos(editor.state.selection.from);
const rect = el.getBoundingClientRect();
const visible = coords.top >= rect.top && coords.top <= rect.bottom;
if (scrollHiddenRef.current !== !visible) {
scrollHiddenRef.current = !visible;
forceRender((n) => n + 1);
}
};
el.addEventListener("scroll", onScroll, { passive: true });
return () => el.removeEventListener("scroll", onScroll);
}, [editor, scrollTarget]);
// Reset scroll-hidden and mode when selection changes
useEffect(() => {
const handler = () => {
setMode("toolbar");
if (scrollHiddenRef.current) {
scrollHiddenRef.current = false;
forceRender((n) => n + 1);
}
};
editor.on("selectionUpdate", handler);
return () => { editor.off("selectionUpdate", handler); };
}, [editor]);
// Refocus editor when Base UI dropdown closes
const handleMenuOpenChange = useCallback(
(open: boolean) => { if (!open) editor.commands.focus(); },
[editor],
);
return (
<BubbleMenu
editor={editor}
shouldShow={shouldShowBubbleMenu}
updateDelay={0}
style={{
zIndex: 50,
display: scrollHiddenRef.current ? "none" : undefined,
}}
options={{
strategy: "fixed",
placement: "top",
offset: 8,
flip: true,
shift: { padding: 8 },
hide: true,
scrollTarget,
}}
>
{mode === "link-edit" ? (
<LinkEditBar editor={editor} onClose={() => { setMode("toolbar"); editor.commands.focus(); }} />
) : (
<TooltipProvider delay={300}>
<div className="bubble-menu">
<MarkButton editor={editor} mark="bold" icon={Bold} label="Bold" shortcut={`${mod}+B`} />
<MarkButton editor={editor} mark="italic" icon={Italic} label="Italic" shortcut={`${mod}+I`} />
<MarkButton editor={editor} mark="strike" icon={Strikethrough} label="Strikethrough" shortcut={`${mod}+Shift+S`} />
<MarkButton editor={editor} mark="code" icon={Code} label="Code" shortcut={`${mod}+E`} />
<Separator orientation="vertical" className="mx-0.5 h-5" />
<Tooltip>
<TooltipTrigger render={
<Toggle size="sm" pressed={editor.isActive("link")} onPressedChange={() => setMode("link-edit")} onMouseDown={(e) => e.preventDefault()} />
}>
<Link2 className="size-3.5" />
</TooltipTrigger>
<TooltipContent side="top" sideOffset={8}>Link</TooltipContent>
</Tooltip>
<Separator orientation="vertical" className="mx-0.5 h-5" />
<HeadingDropdown editor={editor} onOpenChange={handleMenuOpenChange} />
<ListDropdown editor={editor} onOpenChange={handleMenuOpenChange} />
<Tooltip>
<TooltipTrigger render={
<Toggle size="sm" pressed={editor.isActive("blockquote")} onPressedChange={() => editor.chain().focus().toggleBlockquote().run()} onMouseDown={(e) => e.preventDefault()} />
}>
<Quote className="size-3.5" />
</TooltipTrigger>
<TooltipContent side="top" sideOffset={8}>Quote</TooltipContent>
</Tooltip>
</div>
</TooltipProvider>
)}
</BubbleMenu>
);
}
export { EditorBubbleMenu };

View File

@@ -28,6 +28,7 @@
.rich-text-editor.ProseMirror {
color: var(--foreground);
caret-color: var(--foreground);
min-height: 100%;
}
.rich-text-editor.ProseMirror:focus {
@@ -457,4 +458,51 @@
.rich-text-editor .image-toolbar button:hover {
background: color-mix(in srgb, white 15%, transparent);
}
}
/* Bubble menu — floating toolbar pill */
.bubble-menu {
display: flex;
align-items: center;
gap: 1px;
padding: 0.25rem;
background: var(--popover);
border: 1px solid color-mix(in srgb, var(--foreground) 10%, transparent);
border-radius: var(--radius);
box-shadow:
0 4px 12px color-mix(in srgb, black 12%, transparent),
0 0 0 1px color-mix(in srgb, black 4%, transparent);
}
/* Link edit mode — inline URL input */
.bubble-menu-link-edit {
display: flex;
align-items: center;
gap: 0.25rem;
padding: 0.25rem;
background: var(--popover);
border: 1px solid color-mix(in srgb, var(--foreground) 10%, transparent);
border-radius: var(--radius);
box-shadow:
0 4px 12px color-mix(in srgb, black 12%, transparent),
0 0 0 1px color-mix(in srgb, black 4%, transparent);
min-width: 300px;
}
/* Link hover card — shows URL + actions on link hover */
.link-hover-card {
display: flex;
align-items: center;
gap: 0.25rem;
padding: 0.25rem 0.25rem 0.25rem 0.5rem;
background: var(--popover);
border: 1px solid color-mix(in srgb, var(--foreground) 10%, transparent);
border-radius: var(--radius);
box-shadow:
0 4px 12px color-mix(in srgb, black 12%, transparent),
0 0 0 1px color-mix(in srgb, black 4%, transparent);
max-width: min(360px, calc(100vw - 2rem));
white-space: nowrap;
}

View File

@@ -0,0 +1,76 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { fireEvent, render, screen } from "@testing-library/react";
const mockFocus = vi.hoisted(() => vi.fn());
vi.mock("@tanstack/react-query", () => ({
useQueryClient: () => ({}),
}));
vi.mock("./extensions", () => ({
createEditorExtensions: () => [],
}));
vi.mock("./extensions/file-upload", () => ({
uploadAndInsertFile: vi.fn(),
}));
vi.mock("./utils/preprocess", () => ({
preprocessMarkdown: (value: string) => value,
}));
vi.mock("./bubble-menu", () => ({
EditorBubbleMenu: () => null,
}));
vi.mock("@tiptap/react", () => ({
useEditor: () => ({
commands: {
focus: mockFocus,
clearContent: vi.fn(),
},
getMarkdown: () => "",
state: {
doc: {
content: {
size: 0,
},
},
selection: {
empty: true,
},
},
}),
EditorContent: ({ className }: { className?: string }) => (
<div className={className} data-testid="editor-content">
<div className="ProseMirror rich-text-editor" data-testid="prosemirror" />
</div>
),
}));
import { ContentEditor } from "./content-editor";
describe("ContentEditor", () => {
beforeEach(() => {
vi.clearAllMocks();
});
it("focuses the editor when clicking the empty container area", () => {
render(<ContentEditor placeholder="Add description..." />);
const shell = screen.getByTestId("editor-content").parentElement;
expect(shell).not.toBeNull();
fireEvent.mouseDown(shell!);
expect(mockFocus).toHaveBeenCalledWith("end");
});
it("does not hijack clicks that land inside the ProseMirror node", () => {
render(<ContentEditor placeholder="Add description..." />);
fireEvent.mouseDown(screen.getByTestId("prosemirror"));
expect(mockFocus).not.toHaveBeenCalled();
});
});

View File

@@ -30,6 +30,7 @@ import {
useEffect,
useImperativeHandle,
useRef,
type MouseEvent as ReactMouseEvent,
} from "react";
import { useEditor, EditorContent } from "@tiptap/react";
import { cn } from "@multica/ui/lib/utils";
@@ -38,8 +39,22 @@ import { useQueryClient } from "@tanstack/react-query";
import { createEditorExtensions } from "./extensions";
import { uploadAndInsertFile } from "./extensions/file-upload";
import { preprocessMarkdown } from "./utils/preprocess";
import { EditorBubbleMenu } from "./bubble-menu";
import { useLinkHover, LinkHoverCard } from "./link-hover-card";
import "./content-editor.css";
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
/** Blob URLs (blob:http://…) are process-local and expire on reload. Strip them
* from serialised markdown so they never reach the database. */
const BLOB_IMAGE_RE = /!\[[^\]]*\]\(blob:[^)]*\)\n?/g;
function stripBlobUrls(md: string): string {
return md.replace(BLOB_IMAGE_RE, "");
}
// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------
@@ -61,6 +76,8 @@ interface ContentEditorRef {
clearContent: () => void;
focus: () => void;
uploadFile: (file: File) => void;
/** True when file uploads are still in progress. */
hasActiveUploads: () => boolean;
}
// ---------------------------------------------------------------------------
@@ -113,7 +130,7 @@ const ContentEditor = forwardRef<ContentEditorRef, ContentEditorProps>(
if (!onUpdateRef.current) return;
if (debounceRef.current) clearTimeout(debounceRef.current);
debounceRef.current = setTimeout(() => {
onUpdateRef.current?.(ed.getMarkdown());
onUpdateRef.current?.(stripBlobUrls(ed.getMarkdown()));
}, debounceMs);
},
onBlur: () => {
@@ -130,33 +147,17 @@ const ContentEditor = forwardRef<ContentEditorRef, ContentEditorProps>(
const href = link?.getAttribute("href");
if (!href || href.startsWith("mention://")) return false;
const openLink = () => {
if (href.startsWith("/")) {
// Internal path — dispatch custom event so the app can handle it
// (direct window.open breaks in Electron hash router)
window.dispatchEvent(
new CustomEvent("multica:navigate", { detail: { path: href } }),
);
} else {
window.open(href, "_blank", "noopener,noreferrer");
}
};
if (!editable) {
// Readonly: any click on link opens new tab
event.preventDefault();
openLink();
return true;
// Open the link. Internal paths use multica:navigate
// (Electron hash-router safe), external open in new tab.
event.preventDefault();
if (href.startsWith("/")) {
window.dispatchEvent(
new CustomEvent("multica:navigate", { detail: { path: href } }),
);
} else {
window.open(href, "_blank", "noopener,noreferrer");
}
if (event.metaKey || event.ctrlKey) {
// Edit mode: Cmd/Ctrl+click opens link
event.preventDefault();
openLink();
return true;
}
return false;
return true;
},
},
attributes: {
@@ -191,7 +192,7 @@ const ContentEditor = forwardRef<ContentEditorRef, ContentEditorProps>(
}, [editor, editable, defaultValue]);
useImperativeHandle(ref, () => ({
getMarkdown: () => editor?.getMarkdown() ?? "",
getMarkdown: () => stripBlobUrls(editor?.getMarkdown() ?? ""),
clearContent: () => {
editor?.commands.clearContent();
},
@@ -203,13 +204,44 @@ const ContentEditor = forwardRef<ContentEditorRef, ContentEditorProps>(
const endPos = editor.state.doc.content.size;
uploadAndInsertFile(editor, file, onUploadFileRef.current, endPos);
},
hasActiveUploads: () => {
if (!editor) return false;
let uploading = false;
editor.state.doc.descendants((node) => {
if (node.attrs.uploading) uploading = true;
return !uploading;
});
return uploading;
},
}));
// Link hover card — disabled when BubbleMenu is active (has selection)
const wrapperRef = useRef<HTMLDivElement>(null);
const hoverDisabled = !editor?.state.selection.empty;
const hover = useLinkHover(wrapperRef, hoverDisabled);
const handleContainerMouseDown = (event: ReactMouseEvent<HTMLDivElement>) => {
if (!editable || !editor) return;
const target = event.target as HTMLElement;
if (target.closest(".ProseMirror")) return;
if (target.closest("a, button, input, textarea, [role='button'], [data-node-view-wrapper]")) return;
event.preventDefault();
editor.commands.focus("end");
};
if (!editor) return null;
return (
<div className="relative min-h-full">
<EditorContent editor={editor} />
<div
ref={wrapperRef}
className="relative flex min-h-full flex-col"
onMouseDown={handleContainerMouseDown}
>
<EditorContent className="flex-1 min-h-full" editor={editor} />
{editable && <EditorBubbleMenu editor={editor} />}
<LinkHoverCard {...hover} />
</div>
);
},

View File

@@ -1,6 +1,7 @@
import { Extension } from "@tiptap/core";
import { Plugin, PluginKey } from "@tiptap/pm/state";
import type { UploadResult } from "@multica/core/hooks/use-file-upload";
import { createSafeId } from "@multica/core/utils";
/** Find and remove a fileCard node by uploadId. */
@@ -109,7 +110,7 @@ export async function uploadAndInsertFile(
}
} else {
// Non-image: insert skeleton fileCard → upload → finalize with real URL
const uploadId = crypto.randomUUID();
const uploadId = createSafeId();
const cardAttrs = { filename: file.name, href: "", fileSize: file.size, uploading: true, uploadId };
const insertContent = { type: "fileCard", attrs: cardAttrs };
if (pos !== undefined) {

View File

@@ -47,9 +47,10 @@ import { ImageView } from "./image-view";
const lowlight = createLowlight(common);
const LinkEditable = Link.extend({ inclusive: false }).configure({
openOnClick: true,
openOnClick: false,
autolink: true,
linkOnPaste: false,
linkOnPaste: true,
defaultProtocol: "https",
});
const LinkReadonly = Link.configure({
@@ -103,6 +104,9 @@ export function createEditorExtensions(
return ReactNodeViewRenderer(CodeBlockView);
},
}).configure({ lowlight }),
// ⚠️ Link MUST appear before markdownPaste in this array.
// linkOnPaste relies on Link's handlePaste plugin firing first;
// markdownPaste's handlePaste is a catch-all that returns true.
editable ? LinkEditable : LinkReadonly,
ImageExtension,
Table.configure({ resizable: false }),

View File

@@ -40,6 +40,9 @@ export function createMarkdownPasteExtension() {
const clipboard = event.clipboardData;
if (!clipboard) return false;
// If clipboard has files, defer to the fileUpload extension.
if (clipboard.files?.length) return false;
const text = clipboard.getData("text/plain");
if (!text) return false;

View File

@@ -21,7 +21,7 @@
import { NodeViewWrapper } from "@tiptap/react";
import type { NodeViewProps } from "@tiptap/react";
import { useQuery } from "@tanstack/react-query";
import { issueListOptions } from "@multica/core/issues/queries";
import { issueListOptions, issueDetailOptions } from "@multica/core/issues/queries";
import { useWorkspaceId } from "@multica/core/hooks";
import { useNavigation } from "../../navigation";
import { StatusIcon } from "../../issues/components/status-icon";
@@ -53,19 +53,28 @@ function IssueMention({
}) {
const wsId = useWorkspaceId();
const { data: issues = [] } = useQuery(issueListOptions(wsId));
const { openInNewTab } = useNavigation();
const issue = issues.find((i) => i.id === issueId);
const { push, openInNewTab } = useNavigation();
const listIssue = issues.find((i) => i.id === issueId);
const { data: detailIssue } = useQuery({
...issueDetailOptions(wsId, issueId),
enabled: !listIssue,
});
const issue = listIssue ?? detailIssue;
const issuePath = `/issues/${issueId}`;
const tabTitle = issue ? `${issue.identifier}: ${issue.title}` : undefined;
const handleClick = (e: React.MouseEvent) => {
e.preventDefault();
e.stopPropagation();
if (openInNewTab) {
openInNewTab(issuePath, tabTitle);
} else {
window.open(issuePath, "_blank", "noopener,noreferrer");
if (e.metaKey || e.ctrlKey || e.shiftKey) {
if (openInNewTab) {
openInNewTab(issuePath, tabTitle);
}
return;
}
push(issuePath);
};
const cardClass =

View File

@@ -0,0 +1,236 @@
"use client";
/**
* LinkHoverCard — floating card shown on link hover.
*
* Displays the URL with Copy and Open actions. Portaled to body
* with position:fixed to escape overflow:hidden containers.
* Shows after 300ms hover delay, hides after 150ms mouse-out
* (cancelled if mouse enters the card).
*/
import { useState, useEffect, useCallback, useRef } from "react";
import { createPortal } from "react-dom";
import { computePosition, offset, flip, shift } from "@floating-ui/dom";
import { ExternalLink, Copy } from "lucide-react";
import { toast } from "sonner";
import { Button } from "@multica/ui/components/ui/button";
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
function openLink(href: string) {
if (href.startsWith("/")) {
window.dispatchEvent(
new CustomEvent("multica:navigate", { detail: { path: href } }),
);
} else {
window.open(href, "_blank", "noopener,noreferrer");
}
}
function truncateUrl(url: string, max = 48): string {
if (url.length <= max) return url;
try {
const u = new URL(url);
const origin = u.origin;
const rest = url.slice(origin.length);
if (rest.length <= 10) return url;
return `${origin}${rest.slice(0, max - origin.length - 1)}`;
} catch {
return `${url.slice(0, max - 1)}`;
}
}
// ---------------------------------------------------------------------------
// Hook — manages hover state with enter/leave delays
// ---------------------------------------------------------------------------
const SHOW_DELAY = 300;
const HIDE_DELAY = 150;
interface HoverState {
visible: boolean;
href: string;
anchorEl: HTMLAnchorElement | null;
}
function useLinkHover(containerRef: React.RefObject<HTMLElement | null>, disabled?: boolean) {
const [state, setState] = useState<HoverState>({ visible: false, href: "", anchorEl: null });
const showTimer = useRef(0);
const hideTimer = useRef(0);
const cardRef = useRef<HTMLDivElement>(null);
const clearTimers = useCallback(() => {
clearTimeout(showTimer.current);
clearTimeout(hideTimer.current);
}, []);
// Container mouse events — detect <a> hover
useEffect(() => {
const container = containerRef.current;
if (!container || disabled) return;
const onMouseOver = (e: MouseEvent) => {
const target = e.target as HTMLElement;
const link = target.closest("a") as HTMLAnchorElement | null;
if (!link) return;
const href = link.getAttribute("href");
if (!href || href.startsWith("mention://")) return;
clearTimeout(hideTimer.current);
showTimer.current = window.setTimeout(() => {
setState({ visible: true, href, anchorEl: link });
}, SHOW_DELAY);
};
const onMouseOut = (e: MouseEvent) => {
const related = e.relatedTarget as HTMLElement | null;
// Don't hide if mouse moved to the hover card
if (related && cardRef.current?.contains(related)) return;
// Don't hide if mouse moved to another part of the same link
const link = (e.target as HTMLElement).closest("a");
if (link && link.contains(related)) return;
clearTimeout(showTimer.current);
hideTimer.current = window.setTimeout(() => {
setState((s) => ({ ...s, visible: false }));
}, HIDE_DELAY);
};
container.addEventListener("mouseover", onMouseOver);
container.addEventListener("mouseout", onMouseOut);
return () => {
container.removeEventListener("mouseover", onMouseOver);
container.removeEventListener("mouseout", onMouseOut);
clearTimers();
};
}, [containerRef, disabled, clearTimers]);
// Card mouse events — keep visible while hovering the card
const onCardEnter = useCallback(() => {
clearTimeout(hideTimer.current);
}, []);
const onCardLeave = useCallback(() => {
hideTimer.current = window.setTimeout(() => {
setState((s) => ({ ...s, visible: false }));
}, HIDE_DELAY);
}, []);
return { ...state, cardRef, onCardEnter, onCardLeave };
}
// ---------------------------------------------------------------------------
// Component
// ---------------------------------------------------------------------------
function LinkHoverCard({
visible,
href,
anchorEl,
cardRef,
onCardEnter,
onCardLeave,
}: {
visible: boolean;
href: string;
anchorEl: HTMLAnchorElement | null;
cardRef: React.RefObject<HTMLDivElement | null>;
onCardEnter: () => void;
onCardLeave: () => void;
}) {
const [pos, setPos] = useState({ top: 0, left: 0 });
const [positioned, setPositioned] = useState(false);
// Position the card when the portal div is mounted (ref callback).
// Using useEffect would race with portal rendering — the div might
// not be in the DOM yet when the effect runs.
const setCardRef = useCallback(
(node: HTMLDivElement | null) => {
(cardRef as React.MutableRefObject<HTMLDivElement | null>).current = node;
if (!node || !anchorEl) {
setPositioned(false);
return;
}
computePosition(anchorEl, node, {
placement: "bottom-start",
strategy: "fixed",
middleware: [offset(4), flip(), shift({ padding: 8 })],
}).then(({ x, y }) => {
setPos({ top: y, left: x });
setPositioned(true);
});
},
[anchorEl, cardRef],
);
// Reset positioned when hidden
useEffect(() => {
if (!visible) setPositioned(false);
}, [visible]);
if (!visible || !anchorEl) return null;
const handleCopy = async (e: React.MouseEvent) => {
e.stopPropagation();
e.preventDefault();
try {
await navigator.clipboard.writeText(href);
toast.success("Link copied");
} catch {
toast.error("Failed to copy");
}
};
const handleOpen = (e: React.MouseEvent) => {
e.stopPropagation();
e.preventDefault();
openLink(href);
};
return createPortal(
<div
ref={setCardRef}
className="link-hover-card"
style={{
position: "fixed",
top: pos.top,
left: pos.left,
zIndex: 50,
display: positioned ? undefined : "none",
}}
onMouseEnter={onCardEnter}
onMouseLeave={onCardLeave}
>
<span
className="min-w-0 flex-1 truncate text-xs text-muted-foreground px-1"
title={href}
>
{truncateUrl(href)}
</span>
<Button
size="icon-xs"
variant="ghost"
className="text-muted-foreground"
onClick={handleCopy}
title="Copy link"
>
<Copy className="size-3.5" />
</Button>
<Button
size="icon-xs"
variant="ghost"
className="text-muted-foreground"
onClick={handleOpen}
title="Open link"
>
<ExternalLink className="size-3.5" />
</Button>
</div>,
document.body,
);
}
export { useLinkHover, LinkHoverCard };

View File

@@ -16,7 +16,7 @@
* - Rendering mentions with the same IssueMentionCard component and .mention class
*/
import { useMemo, useState } from "react";
import { useMemo, useRef, useState } from "react";
import ReactMarkdown, {
defaultUrlTransform,
type Components,
@@ -33,6 +33,7 @@ import { cn } from "@multica/ui/lib/utils";
import { useNavigation } from "../navigation";
import { IssueMentionCard } from "../issues/components/issue-mention-card";
import { ImageLightbox } from "./extensions/image-view";
import { useLinkHover, LinkHoverCard } from "./link-hover-card";
import { preprocessMarkdown } from "./utils/preprocess";
import "./content-editor.css";
@@ -86,7 +87,7 @@ function urlTransform(url: string): string {
// ---------------------------------------------------------------------------
function IssueMentionLink({ issueId, label }: { issueId: string; label?: string }) {
const { openInNewTab } = useNavigation();
const { push, openInNewTab } = useNavigation();
const path = `/issues/${issueId}`;
return (
<span
@@ -94,11 +95,13 @@ function IssueMentionLink({ issueId, label }: { issueId: string; label?: string
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
if (openInNewTab) {
openInNewTab(path, label);
} else {
window.open(path, "_blank", "noopener,noreferrer");
if (e.metaKey || e.ctrlKey || e.shiftKey) {
if (openInNewTab) {
openInNewTab(path, label);
}
return;
}
push(path);
}}
>
<IssueMentionCard issueId={issueId} fallbackLabel={label} />
@@ -107,7 +110,7 @@ function IssueMentionLink({ issueId, label }: { issueId: string; label?: string
}
const components: Partial<Components> = {
// Links — route mention:// to mention components, others open in new tab
// Links — route mention:// to mention components, others show preview card
a: ({ href, children }) => {
if (href?.startsWith("mention://")) {
const match = href.match(
@@ -126,13 +129,20 @@ const components: Partial<Components> = {
return <span className="mention">{children}</span>;
}
// Regular links — open in new tab
// Regular links — open directly on click
return (
<a
href={href}
onClick={(e) => {
e.preventDefault();
if (href) window.open(href, "_blank", "noopener,noreferrer");
if (!href) return;
if (href.startsWith("/")) {
window.dispatchEvent(
new CustomEvent("multica:navigate", { detail: { path: href } }),
);
} else {
window.open(href, "_blank", "noopener,noreferrer");
}
}}
>
{children}
@@ -271,9 +281,11 @@ interface ReadonlyContentProps {
export function ReadonlyContent({ content, className }: ReadonlyContentProps) {
const processed = useMemo(() => preprocessMarkdown(content), [content]);
const wrapperRef = useRef<HTMLDivElement>(null);
const hover = useLinkHover(wrapperRef);
return (
<div className={cn("rich-text-editor readonly text-sm", className)}>
<div ref={wrapperRef} className={cn("rich-text-editor readonly text-sm", className)}>
<ReactMarkdown
remarkPlugins={[[remarkGfm, { singleTilde: false }]]}
rehypePlugins={[rehypeRaw, [rehypeSanitize, sanitizeSchema]]}
@@ -282,6 +294,7 @@ export function ReadonlyContent({ content, className }: ReadonlyContentProps) {
>
{processed}
</ReactMarkdown>
<LinkHoverCard {...hover} />
</div>
);
}

View File

@@ -90,7 +90,9 @@ const TitleEditor = forwardRef<TitleEditorRef, TitleEditorProps>(
const editor = useEditor({
immediatelyRender: false,
content: `<p>${defaultValue}</p>`,
content: defaultValue
? { type: "doc", content: [{ type: "paragraph", content: [{ type: "text", text: defaultValue }] }] }
: "",
extensions: [
SingleLineDocument,
Paragraph,

View File

@@ -32,6 +32,10 @@ export function preprocessMarkdown(markdown: string): string {
*/
const FILE_LINK_LINE = /^\[([^\]]+)\]\((https?:\/\/[^)]+)\)$/;
function escapeAttr(s: string): string {
return s.replace(/&/g, "&amp;").replace(/"/g, "&quot;").replace(/</g, "&lt;");
}
function preprocessFileCards(markdown: string): string {
return markdown
.split("\n")
@@ -42,7 +46,7 @@ function preprocessFileCards(markdown: string): string {
const filename = match[1]!;
const url = match[2]!;
if (!isFileCardUrl(url)) return line;
return `<div data-type="fileCard" data-href="${url}" data-filename="${filename}"></div>`;
return `<div data-type="fileCard" data-href="${escapeAttr(url)}" data-filename="${escapeAttr(filename)}"></div>`;
})
.join("\n");
}

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