Compare commits

...

64 Commits

Author SHA1 Message Date
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
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
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
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
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
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
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
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
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
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
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
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
132 changed files with 4381 additions and 986 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,56 @@
## 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
- [ ] I searched for [existing PRs](https://github.com/multica-ai/multica/pulls) to make sure this isn't a duplicate
- [ ] My commit messages follow [Conventional Commits](https://www.conventionalcommits.org/) (`fix(scope):`, `feat(scope):`, etc.)
- [ ] `make check` passes (typecheck, unit tests, Go tests, E2E)
- [ ] Changes follow existing code patterns and conventions
- [ ] No unrelated changes included
## 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,6 +11,7 @@ builds:
- -s -w
- -X main.version={{.Version}}
- -X main.commit={{.ShortCommit}}
- -X main.date={{.Date}}
env:
- CGO_ENABLED=0
goos:

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

@@ -135,6 +135,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,6 +172,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

View File

@@ -136,12 +136,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 +155,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

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

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) | 简体中文**

View File

@@ -71,12 +71,16 @@ Each team member who wants to run AI agents locally needs to:
### a) Install the CLI and an AI agent
```bash
brew install multica-ai/tap/multica
brew tap multica-ai/tap
brew install 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

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.

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:

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",

View File

@@ -1,9 +1,32 @@
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";
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,
@@ -37,19 +60,72 @@ 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();
// 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

@@ -1,8 +1,16 @@
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>;
}
declare global {
interface Window {
electron: ElectronAPI;
desktopAPI: DesktopAPI;
}
}

View File

@@ -1,9 +1,26 @@
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),
};
if (process.contextIsolated) {
contextBridge.exposeInMainWorld("electron", electronAPI);
contextBridge.exposeInMainWorld("desktopAPI", desktopAPI);
} else {
// @ts-expect-error - fallback for non-isolated context
window.electron = electronAPI;
// @ts-expect-error - fallback for non-isolated context
window.desktopAPI = desktopAPI;
}

View File

@@ -1,5 +1,8 @@
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";
@@ -10,6 +13,20 @@ 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,12 +39,14 @@ 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>

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

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

@@ -76,12 +76,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

View File

@@ -72,12 +72,16 @@ The daemon runs on your local machine (not inside Docker). It detects installed
### a) Install the CLI and an AI agent
```bash
brew install multica-ai/tap/multica
brew tap multica-ai/tap
brew install 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
@@ -211,6 +215,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

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

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

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");
await hydrateWorkspace(wsList, lastWsId);
router.push("/issues");
})
.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,28 @@ 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;
}
}
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}
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

@@ -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,28 @@ export const en: LandingDict = {
fixes: "Bug Fixes",
},
entries: [
{
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, `--local` bootstraps full self-hosting, `multica setup` auto-detects local server",
"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

@@ -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,28 @@ export const zh: LandingDict = {
fixes: "问题修复",
},
entries: [
{
version: "0.1.27",
date: "2026-04-12",
title: "一键安装、自部署与稳定性",
changes: [],
features: [
"一键安装与配置——`curl | bash` 安装 CLI`--local` 完整自部署,`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

@@ -75,14 +75,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";

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

@@ -52,6 +52,7 @@ import type {
ReorderPinsRequest,
} from "../types";
import { type Logger, noopLogger } from "../logger";
import { createRequestId } from "../utils";
export interface ApiClientOptions {
logger?: Logger;
@@ -84,10 +85,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 +119,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";
@@ -167,6 +178,10 @@ export class ApiClient {
});
}
async logout(): Promise<void> {
await this.fetch("/auth/logout", { method: "POST" });
}
async getMe(): Promise<User> {
return this.fetch("/api/me");
}
@@ -610,7 +625,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

@@ -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,19 +16,23 @@ 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);
// In cookie mode, the browser sends the HttpOnly cookie automatically
// with the WebSocket upgrade request — no token in URL needed.
if (!this.cookieAuth && this.token)
url.searchParams.set("token", this.token);
if (this.workspaceId)
url.searchParams.set("workspace_id", this.workspaceId);

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

@@ -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";
@@ -21,6 +23,8 @@ export function AuthInitializer({
onLogout?: () => void;
storage?: StorageAdapter;
}) {
const qc = useQueryClient();
useEffect(() => {
const token = storage.getItem("multica_token");
if (!token) {
@@ -37,6 +41,8 @@ export function AuthInitializer({
.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,13 +63,14 @@ 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>
@@ -76,6 +80,7 @@ export function CoreProvider({
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

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

@@ -13,6 +13,14 @@ 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 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(

View File

@@ -1,6 +1,7 @@
"use client";
import { useState, useEffect, useCallback, 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;
}
// ---------------------------------------------------------------------------
@@ -86,7 +92,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("");
@@ -167,6 +175,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 +187,7 @@ export function LoginPage({
setLoading(false);
}
},
[email, onSuccess, cliCallback, lastWorkspaceId, onTokenObtained],
[email, onSuccess, cliCallback, lastWorkspaceId, onTokenObtained, qc],
);
const handleResend = async () => {
@@ -204,6 +213,10 @@ export function LoginPage({
};
const handleGoogleLogin = () => {
if (onGoogleLogin) {
onGoogleLogin();
return;
}
if (!google) return;
const params = new URLSearchParams({
client_id: google.clientId,
@@ -213,6 +226,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 +385,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,9 +1,12 @@
"use client";
import { useQuery } from "@tanstack/react-query";
import { ArrowLeft, MessageSquare, Archive, Trash2 } from "lucide-react";
import { ArrowLeft, MessageSquare, Archive, Bot, Loader2 } from "lucide-react";
import { toast } from "sonner";
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";
@@ -34,10 +37,12 @@ export function ChatSessionHistory() {
const handleArchive = (e: React.MouseEvent, sessionId: string) => {
e.stopPropagation();
archiveSession.mutate(sessionId);
if (activeSessionId === sessionId) {
setActiveSession(null);
}
archiveSession.mutate(sessionId, {
onError: () => toast.error("Failed to archive session"),
});
};
const activeSessions = sessions.filter((s) => s.status === "active");
@@ -47,12 +52,21 @@ export function ChatSessionHistory() {
<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>
@@ -71,6 +85,7 @@ export function ChatSessionHistory() {
sessions={activeSessions}
agentMap={agentMap}
activeSessionId={activeSessionId}
archivingId={archiveSession.isPending ? (archiveSession.variables as string) : null}
onSelect={handleSelectSession}
onArchive={handleArchive}
/>
@@ -81,6 +96,7 @@ export function ChatSessionHistory() {
sessions={archivedSessions}
agentMap={agentMap}
activeSessionId={activeSessionId}
archivingId={null}
onSelect={handleSelectSession}
/>
)}
@@ -96,6 +112,7 @@ function SessionGroup({
sessions,
agentMap,
activeSessionId,
archivingId,
onSelect,
onArchive,
}: {
@@ -103,6 +120,7 @@ function SessionGroup({
sessions: ChatSession[];
agentMap: Map<string, Agent>;
activeSessionId: string | null;
archivingId: string | null;
onSelect: (session: ChatSession) => void;
onArchive?: (e: React.MouseEvent, sessionId: string) => void;
}) {
@@ -119,6 +137,7 @@ function SessionGroup({
session={session}
agent={agentMap.get(session.agent_id) ?? null}
isActive={session.id === activeSessionId}
isArchiving={session.id === archivingId}
onSelect={() => onSelect(session)}
onArchive={onArchive ? (e) => onArchive(e, session.id) : undefined}
/>
@@ -131,12 +150,14 @@ function SessionItem({
session,
agent,
isActive,
isArchiving,
onSelect,
onArchive,
}: {
session: ChatSession;
agent: Agent | null;
isActive: boolean;
isArchiving: boolean;
onSelect: () => void;
onArchive?: (e: React.MouseEvent) => void;
}) {
@@ -145,13 +166,14 @@ function SessionItem({
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(
"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",
)}
>
<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>
@@ -174,13 +196,25 @@ function SessionItem({
</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>
<Tooltip>
<TooltipTrigger
render={
<Button
variant="ghost"
size="icon-sm"
className={cn(
"shrink-0 mt-0.5 text-muted-foreground",
!isArchiving && "invisible group-hover:visible",
)}
onClick={onArchive}
disabled={isArchiving}
/>
}
>
{isArchiving ? <Loader2 className="animate-spin" /> : <Archive />}
</TooltipTrigger>
<TooltipContent side="bottom">Archive</TooltipContent>
</Tooltip>
)}
</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

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

@@ -53,7 +53,7 @@ function IssueMention({
}) {
const wsId = useWorkspaceId();
const { data: issues = [] } = useQuery(issueListOptions(wsId));
const { openInNewTab } = useNavigation();
const { push, openInNewTab } = useNavigation();
const issue = issues.find((i) => i.id === issueId);
const issuePath = `/issues/${issueId}`;
@@ -61,11 +61,13 @@ function IssueMention({
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

@@ -86,7 +86,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 +94,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} />

View File

@@ -208,7 +208,9 @@ export function AgentLiveCard({ issueId }: AgentLiveCardProps) {
// Pick up newly dispatched tasks
useWSEvent(
"task:dispatch",
useCallback(() => {
useCallback((payload: unknown) => {
const p = payload as { issue_id?: string };
if (p.issue_id && p.issue_id !== issueId) return;
api.getActiveTasksForIssue(issueId).then(({ tasks }) => {
setTaskStates((prev) => {
const next = new Map(prev);

View File

@@ -66,14 +66,14 @@ function CommentInput({ issueId, onSubmit }: CommentInputProps) {
onSelect={(file) => editorRef.current?.uploadFile(file)}
/>
<Button
size="icon-xs"
size="icon-sm"
disabled={isEmpty || submitting}
onClick={handleSubmit}
>
{submitting ? (
<Loader2 className="h-3.5 w-3.5 animate-spin" />
<Loader2 className="animate-spin" />
) : (
<ArrowUp className="h-3.5 w-3.5" />
<ArrowUp />
)}
</Button>
</div>

View File

@@ -94,7 +94,10 @@ vi.mock("../../editor", () => ({
ReadonlyContent: ({ content }: { content: string }) => (
<div data-testid="readonly-content">{content}</div>
),
ContentEditor: forwardRef(({ defaultValue, onUpdate, placeholder }: any, ref: any) => {
ContentEditor: forwardRef(function MockContentEditor(
{ defaultValue, onUpdate, placeholder }: any,
ref: any,
) {
const valueRef = useRef(defaultValue || "");
const [value, setValue] = useState(defaultValue || "");
useImperativeHandle(ref, () => ({
@@ -116,7 +119,10 @@ vi.mock("../../editor", () => ({
/>
);
}),
TitleEditor: forwardRef(({ defaultValue, placeholder, onBlur, onChange }: any, ref: any) => {
TitleEditor: forwardRef(function MockTitleEditor(
{ defaultValue, placeholder, onBlur, onChange }: any,
ref: any,
) {
const valueRef = useRef(defaultValue || "");
const [value, setValue] = useState(defaultValue || "");
useImperativeHandle(ref, () => ({

View File

@@ -195,6 +195,7 @@ export function IssueDetail({ issueId, onDelete, defaultSidebarOpen = true, layo
const id = issueId;
const router = useNavigation();
const user = useAuthStore((s) => s.user);
const userId = useAuthStore((s) => s.user?.id);
const workspace = useWorkspaceStore((s) => s.workspace);
// Issue navigation — read from TQ list cache
@@ -264,7 +265,10 @@ export function IssueDetail({ issueId, onDelete, defaultSidebarOpen = true, layo
const { data: usage } = useQuery(issueUsageOptions(id));
// Pinned state
const { data: pinnedItems = [] } = useQuery(pinListOptions(wsId));
const { data: pinnedItems = [] } = useQuery({
...pinListOptions(wsId, userId ?? ""),
enabled: !!userId,
});
const isPinned = pinnedItems.some((p) => p.item_type === "issue" && p.item_id === id);
const createPin = useCreatePin();
const deletePin = useDeletePin();
@@ -501,7 +505,7 @@ export function IssueDetail({ issueId, onDelete, defaultSidebarOpen = true, layo
}
}}
>
{isPinned ? <PinOff className="h-4 w-4" /> : <Pin className="h-4 w-4" />}
{isPinned ? <PinOff /> : <Pin />}
</Button>
}
/>

View File

@@ -38,7 +38,7 @@ export function IssuesPage() {
const includeNoProject = useIssueViewStore((s) => s.includeNoProject);
useEffect(() => {
initFilterWorkspaceSync();
initFilterWorkspaceSync((cb) => useWorkspaceStore.subscribe((s) => cb(s.workspace?.id)));
}, []);
useEffect(() => {

View File

@@ -43,6 +43,7 @@ import {
SidebarFooter,
SidebarMenu,
SidebarMenuButton,
SidebarMenuAction,
SidebarMenuItem,
SidebarRail,
} from "@multica/ui/components/ui/sidebar";
@@ -58,12 +59,13 @@ import {
import { Tooltip, TooltipTrigger, TooltipContent } from "@multica/ui/components/ui/tooltip";
import { useAuthStore } from "@multica/core/auth";
import { useWorkspaceStore } from "@multica/core/workspace";
import { workspaceListOptions } from "@multica/core/workspace/queries";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { inboxKeys, deduplicateInboxItems } from "@multica/core/inbox/queries";
import { api } from "@multica/core/api";
import { useModalStore } from "@multica/core/modals";
import { useMyRuntimesNeedUpdate } from "@multica/core/runtimes/hooks";
import { pinKeys } from "@multica/core/pins/queries";
import { pinListOptions } from "@multica/core/pins/queries";
import { useDeletePin, useReorderPins } from "@multica/core/pins/mutations";
import type { PinnedItem } from "@multica/core/types";
@@ -93,7 +95,6 @@ function DraftDot() {
function SortablePinItem({ pin, pathname, onUnpin }: { pin: PinnedItem; pathname: string; onUnpin: () => void }) {
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ id: pin.id });
const wasDragged = useRef(false);
const { push } = useNavigation();
useEffect(() => {
if (isDragging) wasDragged.current = true;
@@ -114,12 +115,13 @@ function SortablePinItem({ pin, pathname, onUnpin }: { pin: PinnedItem; pathname
>
<SidebarMenuButton
isActive={isActive}
onClick={() => {
render={<AppLink href={href} />}
onClick={(event) => {
if (wasDragged.current) {
wasDragged.current = false;
event.preventDefault();
return;
}
push(href);
}}
className="text-muted-foreground hover:not-data-active:bg-sidebar-accent/70 data-active:bg-sidebar-accent data-active:text-sidebar-accent-foreground"
>
@@ -129,17 +131,17 @@ function SortablePinItem({ pin, pathname, onUnpin }: { pin: PinnedItem; pathname
<FolderKanban className="size-4 shrink-0" />
)}
<span className="truncate">{label}</span>
<button
className="ml-auto opacity-0 group-hover/pin:opacity-100 transition-opacity p-0.5 rounded hover:bg-accent shrink-0"
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
onUnpin();
}}
>
<PinOff className="size-3 text-muted-foreground" />
</button>
</SidebarMenuButton>
<SidebarMenuAction
showOnHover
onClick={(event) => {
event.preventDefault();
event.stopPropagation();
onUnpin();
}}
>
<PinOff className="size-3 text-muted-foreground" />
</SidebarMenuAction>
</SidebarMenuItem>
);
}
@@ -158,10 +160,11 @@ interface AppSidebarProps {
export function AppSidebar({ topSlot, searchSlot, headerClassName, headerStyle }: AppSidebarProps = {}) {
const { pathname, push } = useNavigation();
const user = useAuthStore((s) => s.user);
const userId = useAuthStore((s) => s.user?.id);
const authLogout = useAuthStore((s) => s.logout);
const workspace = useWorkspaceStore((s) => s.workspace);
const workspaces = useWorkspaceStore((s) => s.workspaces);
const switchWorkspace = useWorkspaceStore((s) => s.switchWorkspace);
const { data: workspaces = [] } = useQuery(workspaceListOptions());
const wsId = workspace?.id;
const { data: inboxItems = [] } = useQuery({
@@ -174,10 +177,9 @@ export function AppSidebar({ topSlot, searchSlot, headerClassName, headerStyle }
[inboxItems],
);
const hasRuntimeUpdates = useMyRuntimesNeedUpdate(wsId);
const { data: pinnedItems = [] } = useQuery<PinnedItem[]>({
queryKey: wsId ? pinKeys.list(wsId) : ["pins", "disabled"],
queryFn: () => api.listPins(),
enabled: !!wsId,
const { data: pinnedItems = [] } = useQuery({
...pinListOptions(wsId ?? "", userId ?? ""),
enabled: !!wsId && !!userId,
});
const deletePin = useDeletePin();
const reorderPins = useReorderPins();
@@ -277,7 +279,7 @@ export function AppSidebar({ topSlot, searchSlot, headerClassName, headerStyle }
onClick={() => {
if (ws.id !== workspace?.id) {
push("/issues");
switchWorkspace(ws.id);
switchWorkspace(ws);
}
}}
>

View File

@@ -8,7 +8,7 @@ import { DashboardGuard } from "./dashboard-guard";
interface DashboardLayoutProps {
children: ReactNode;
/** Sibling of SidebarInset (e.g. SearchCommand, ChatWindow) */
/** Rendered inside SidebarInset (e.g. ChatWindow, ChatFab — absolute-positioned overlays) */
extra?: ReactNode;
/** Rendered inside sidebar header as a search trigger */
searchSlot?: ReactNode;
@@ -33,14 +33,14 @@ export function DashboardLayout({
>
<SidebarProvider className="h-svh">
<AppSidebar searchSlot={searchSlot} />
<SidebarInset className="overflow-hidden">
<SidebarInset className="relative overflow-hidden">
<div className="flex h-10 shrink-0 items-center border-b px-2 md:hidden">
<SidebarTrigger />
</div>
{children}
<ModalRegistry />
{extra}
</SidebarInset>
{extra}
</SidebarProvider>
</DashboardGuard>
);

View File

@@ -0,0 +1,223 @@
import { forwardRef, useImperativeHandle, useRef, useState } from "react";
import { describe, it, expect, vi, beforeEach } from "vitest";
import { render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
const mockPush = vi.hoisted(() => vi.fn());
const mockCreateIssue = vi.hoisted(() => vi.fn());
const mockSetDraft = vi.hoisted(() => vi.fn());
const mockClearDraft = vi.hoisted(() => vi.fn());
const mockToastCustom = vi.hoisted(() => vi.fn());
const mockToastDismiss = vi.hoisted(() => vi.fn());
const mockToastError = vi.hoisted(() => vi.fn());
const mockDraftStore = {
draft: {
title: "",
description: "",
status: "todo" as const,
priority: "none" as const,
assigneeType: undefined,
assigneeId: undefined,
dueDate: null,
},
setDraft: mockSetDraft,
clearDraft: mockClearDraft,
};
vi.mock("../navigation", () => ({
useNavigation: () => ({ push: mockPush }),
}));
vi.mock("@multica/core/workspace", () => ({
useWorkspaceStore: Object.assign(
(selector?: (state: { workspace: { name: string } }) => unknown) => {
const state = { workspace: { name: "Test Workspace" } };
return selector ? selector(state) : state;
},
{ getState: () => ({ workspace: { name: "Test Workspace" } }) },
),
}));
vi.mock("@multica/core/issues/stores/draft-store", () => ({
useIssueDraftStore: Object.assign(
(selector?: (state: typeof mockDraftStore) => unknown) =>
(selector ? selector(mockDraftStore) : mockDraftStore),
{ getState: () => mockDraftStore },
),
}));
vi.mock("@multica/core/issues/mutations", () => ({
useCreateIssue: () => ({ mutateAsync: mockCreateIssue }),
}));
vi.mock("@multica/core/hooks/use-file-upload", () => ({
useFileUpload: () => ({ uploadWithToast: vi.fn() }),
}));
vi.mock("@multica/core/api", () => ({
api: {},
}));
vi.mock("../editor", () => ({
useFileDropZone: () => ({ isDragOver: false, dropZoneProps: {} }),
FileDropOverlay: () => null,
ContentEditor: forwardRef(({ defaultValue, onUpdate, placeholder }: any, ref: any) => {
const valueRef = useRef(defaultValue || "");
const [value, setValue] = useState(defaultValue || "");
useImperativeHandle(ref, () => ({
getMarkdown: () => valueRef.current,
uploadFile: vi.fn(),
}));
return (
<textarea
value={value}
placeholder={placeholder}
onChange={(e) => {
valueRef.current = e.target.value;
setValue(e.target.value);
onUpdate?.(e.target.value);
}}
/>
);
}),
TitleEditor: ({ defaultValue, placeholder, onChange, onSubmit }: any) => {
const [value, setValue] = useState(defaultValue || "");
return (
<input
value={value}
placeholder={placeholder}
onChange={(e) => {
setValue(e.target.value);
onChange?.(e.target.value);
}}
onKeyDown={(e) => {
if (e.key === "Enter") onSubmit?.();
}}
/>
);
},
}));
vi.mock("../issues/components", () => ({
StatusIcon: ({ status }: { status: string }) => <span data-testid="status-icon">{status}</span>,
StatusPicker: () => <div data-testid="status-picker" />,
PriorityPicker: () => <div data-testid="priority-picker" />,
AssigneePicker: () => <div data-testid="assignee-picker" />,
DueDatePicker: () => <div data-testid="due-date-picker" />,
}));
vi.mock("../projects/components/project-picker", () => ({
ProjectPicker: () => <div data-testid="project-picker" />,
}));
vi.mock("@multica/ui/components/ui/dialog", () => ({
Dialog: ({ children }: { children: React.ReactNode }) => <div data-testid="dialog-root">{children}</div>,
DialogContent: ({ children, className }: { children: React.ReactNode; className?: string }) => (
<div className={className}>{children}</div>
),
DialogTitle: ({ children, className }: { children: React.ReactNode; className?: string }) => (
<div className={className}>{children}</div>
),
}));
vi.mock("@multica/ui/components/ui/tooltip", () => ({
Tooltip: ({ children }: { children: React.ReactNode }) => <>{children}</>,
TooltipTrigger: ({ render }: { render: React.ReactNode }) => <>{render}</>,
TooltipContent: ({ children }: { children: React.ReactNode }) => <>{children}</>,
}));
vi.mock("@multica/ui/components/ui/button", () => ({
Button: ({
children,
disabled,
onClick,
type = "button",
}: {
children: React.ReactNode;
disabled?: boolean;
onClick?: () => void;
type?: "button" | "submit" | "reset";
}) => (
<button type={type} disabled={disabled} onClick={onClick}>
{children}
</button>
),
}));
vi.mock("@multica/ui/components/common/file-upload-button", () => ({
FileUploadButton: ({ onSelect }: { onSelect: (file: File) => void }) => (
<button type="button" onClick={() => onSelect(new File(["test"], "test.txt"))}>
Upload file
</button>
),
}));
vi.mock("@multica/ui/lib/utils", () => ({
cn: (...values: Array<string | false | null | undefined>) => values.filter(Boolean).join(" "),
}));
vi.mock("sonner", () => ({
toast: {
custom: mockToastCustom,
dismiss: mockToastDismiss,
error: mockToastError,
},
}));
import { CreateIssueModal } from "./create-issue";
describe("CreateIssueModal", () => {
beforeEach(() => {
vi.clearAllMocks();
mockCreateIssue.mockResolvedValue({
id: "issue-123",
identifier: "TES-123",
title: "Ship create issue regression coverage",
status: "todo",
});
});
it("shows success feedback with a direct path to the new issue", async () => {
const user = userEvent.setup();
const onClose = vi.fn();
render(<CreateIssueModal onClose={onClose} />);
await user.type(screen.getByPlaceholderText("Issue title"), " Ship create issue regression coverage ");
await user.click(screen.getByRole("button", { name: "Create Issue" }));
await waitFor(() => {
expect(mockCreateIssue).toHaveBeenCalledWith({
title: "Ship create issue regression coverage",
description: undefined,
status: "todo",
priority: "none",
assignee_type: undefined,
assignee_id: undefined,
due_date: undefined,
attachment_ids: undefined,
parent_issue_id: undefined,
project_id: undefined,
});
});
expect(mockClearDraft).toHaveBeenCalled();
expect(onClose).toHaveBeenCalled();
expect(mockToastCustom).toHaveBeenCalledTimes(1);
const renderToast = mockToastCustom.mock.calls[0]?.[0];
expect(typeof renderToast).toBe("function");
render(renderToast("toast-1"));
expect(screen.getByText("Issue created")).toBeInTheDocument();
expect(screen.getByText(/TES-123/)).toBeInTheDocument();
expect(screen.getByText(/Ship create issue regression coverage/)).toBeInTheDocument();
await user.click(screen.getByRole("button", { name: "View issue" }));
expect(mockPush).toHaveBeenCalledWith("/issues/issue-123");
expect(mockToastDismiss).toHaveBeenCalledWith("toast-1");
});
});

View File

@@ -14,15 +14,15 @@ import {
DialogDescription,
} from "@multica/ui/components/ui/dialog";
import { Card, CardContent } from "@multica/ui/components/ui/card";
import { useWorkspaceStore } from "@multica/core/workspace";
import { useCreateWorkspace } from "@multica/core/workspace/mutations";
const SLUG_REGEX = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
export function CreateWorkspaceModal({ onClose }: { onClose: () => void }) {
const router = useNavigation();
const createWorkspace = useCreateWorkspace();
const [name, setName] = useState("");
const [slug, setSlug] = useState("");
const [creating, setCreating] = useState(false);
const slugError =
slug.length > 0 && !SLUG_REGEX.test(slug)
@@ -41,24 +41,20 @@ export function CreateWorkspaceModal({ onClose }: { onClose: () => void }) {
);
};
const handleCreate = async () => {
const handleCreate = () => {
if (!canSubmit) return;
setCreating(true);
try {
const { createWorkspace, switchWorkspace } =
useWorkspaceStore.getState();
const ws = await createWorkspace({
name: name.trim(),
slug: slug.trim(),
});
onClose();
router.push("/issues");
await switchWorkspace(ws.id);
} catch {
toast.error("Failed to create workspace");
} finally {
setCreating(false);
}
createWorkspace.mutate(
{ name: name.trim(), slug: slug.trim() },
{
onSuccess: () => {
onClose();
router.push("/issues");
},
onError: () => {
toast.error("Failed to create workspace");
},
},
);
};
return (
@@ -125,9 +121,9 @@ export function CreateWorkspaceModal({ onClose }: { onClose: () => void }) {
className="w-full"
size="lg"
onClick={handleCreate}
disabled={creating || !canSubmit}
disabled={createWorkspace.isPending || !canSubmit}
>
{creating ? "Creating..." : "Create workspace"}
{createWorkspace.isPending ? "Creating..." : "Create workspace"}
</Button>
</div>
</DialogContent>

View File

@@ -7,6 +7,7 @@ import { useQuery } from "@tanstack/react-query";
import { cn } from "@multica/ui/lib/utils";
import { toast } from "sonner";
import type { Issue, IssueStatus, ProjectStatus, ProjectPriority } from "@multica/core/types";
import { useAuthStore } from "@multica/core/auth";
import { projectDetailOptions } from "@multica/core/projects/queries";
import { useUpdateProject, useDeleteProject } from "@multica/core/projects/mutations";
import { pinListOptions } from "@multica/core/pins";
@@ -193,6 +194,7 @@ function ProjectIssuesContent({ projectIssues }: { projectIssues: Issue[] }) {
export function ProjectDetail({ projectId }: { projectId: string }) {
const wsId = useWorkspaceId();
const router = useNavigation();
const userId = useAuthStore((s) => s.user?.id);
const workspaceName = useWorkspaceStore((s) => s.workspace?.name);
const { data: project, isLoading } = useQuery(projectDetailOptions(wsId, projectId));
const { data: allIssues = [] } = useQuery(issueListOptions(wsId));
@@ -201,7 +203,10 @@ export function ProjectDetail({ projectId }: { projectId: string }) {
const { getActorName } = useActorName();
const updateProject = useUpdateProject();
const deleteProject = useDeleteProject();
const { data: pinnedItems = [] } = useQuery(pinListOptions(wsId));
const { data: pinnedItems = [] } = useQuery({
...pinListOptions(wsId, userId ?? ""),
enabled: !!userId,
});
const isPinned = pinnedItems.some((p) => p.item_type === "project" && p.item_id === projectId);
const createPin = useCreatePin();
const deletePinMut = useDeletePin();

View File

@@ -228,7 +228,7 @@ export function SearchCommand() {
setOpen(false);
push(href);
},
[push],
[push, setOpen],
);
return (

View File

@@ -18,21 +18,24 @@ import {
AlertDialogAction,
} from "@multica/ui/components/ui/alert-dialog";
import { toast } from "sonner";
import { useQuery } from "@tanstack/react-query";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { useAuthStore } from "@multica/core/auth";
import { useWorkspaceStore } from "@multica/core/workspace";
import { useLeaveWorkspace, useDeleteWorkspace } from "@multica/core/workspace/mutations";
import { useWorkspaceId } from "@multica/core/hooks";
import { memberListOptions } from "@multica/core/workspace/queries";
import { memberListOptions, workspaceKeys } from "@multica/core/workspace/queries";
import { api } from "@multica/core/api";
import type { Workspace } from "@multica/core/types";
export function WorkspaceTab() {
const user = useAuthStore((s) => s.user);
const workspace = useWorkspaceStore((s) => s.workspace);
const wsId = useWorkspaceId();
const { data: members = [] } = useQuery(memberListOptions(wsId));
const qc = useQueryClient();
const updateWorkspace = useWorkspaceStore((s) => s.updateWorkspace);
const leaveWorkspace = useWorkspaceStore((s) => s.leaveWorkspace);
const deleteWorkspace = useWorkspaceStore((s) => s.deleteWorkspace);
const leaveWorkspace = useLeaveWorkspace();
const deleteWorkspace = useDeleteWorkspace();
const [name, setName] = useState(workspace?.name ?? "");
const [description, setDescription] = useState(workspace?.description ?? "");
@@ -66,6 +69,9 @@ export function WorkspaceTab() {
context,
});
updateWorkspace(updated);
qc.setQueryData(workspaceKeys.list(), (old: Workspace[] | undefined) =>
old?.map((ws) => (ws.id === updated.id ? updated : ws)),
);
toast.success("Workspace settings saved");
} catch (e) {
toast.error(e instanceof Error ? e.message : "Failed to save workspace settings");
@@ -83,7 +89,7 @@ export function WorkspaceTab() {
onConfirm: async () => {
setActionId("leave");
try {
await leaveWorkspace(workspace.id);
await leaveWorkspace.mutateAsync(workspace.id);
} catch (e) {
toast.error(e instanceof Error ? e.message : "Failed to leave workspace");
} finally {
@@ -102,7 +108,7 @@ export function WorkspaceTab() {
onConfirm: async () => {
setActionId("delete-workspace");
try {
await deleteWorkspace(workspace.id);
await deleteWorkspace.mutateAsync(workspace.id);
} catch (e) {
toast.error(e instanceof Error ? e.message : "Failed to delete workspace");
} finally {

View File

@@ -1,3 +1,4 @@
import "./e2e/env";
import { defineConfig } from "@playwright/test";
export default defineConfig({

View File

@@ -17,7 +17,7 @@ var updateCmd = &cobra.Command{
}
func runUpdate(_ *cobra.Command, _ []string) error {
fmt.Fprintf(os.Stderr, "Current version: %s (commit: %s)\n", version, commit)
fmt.Fprintf(os.Stderr, "Current version: %s (commit: %s, built: %s)\n", version, commit, date)
// Check latest version from GitHub.
latest, err := cli.FetchLatestRelease()

View File

@@ -1,15 +1,42 @@
package main
import (
"encoding/json"
"fmt"
"os"
"runtime"
"github.com/spf13/cobra"
)
func init() {
versionCmd.Flags().String("output", "text", "Output format: text or json")
}
var versionCmd = &cobra.Command{
Use: "version",
Short: "Print version information",
Run: func(_ *cobra.Command, _ []string) {
fmt.Printf("multica %s (commit: %s)\n", version, commit)
},
RunE: runVersion,
}
func runVersion(cmd *cobra.Command, _ []string) error {
output, _ := cmd.Flags().GetString("output")
if output == "json" {
info := map[string]string{
"version": version,
"commit": commit,
"date": date,
"go": runtime.Version(),
"os": runtime.GOOS,
"arch": runtime.GOARCH,
}
enc := json.NewEncoder(os.Stdout)
enc.SetIndent("", " ")
return enc.Encode(info)
}
fmt.Printf("multica %s (commit: %s, built: %s)\n", version, commit, date)
fmt.Printf("go: %s, os/arch: %s/%s\n", runtime.Version(), runtime.GOOS, runtime.GOARCH)
return nil
}

View File

@@ -10,6 +10,7 @@ import (
var (
version = "dev"
commit = "unknown"
date = "unknown"
)
var rootCmd = &cobra.Command{

View File

@@ -56,9 +56,21 @@ func allowedOrigins() []string {
func NewRouter(pool *pgxpool.Pool, hub *realtime.Hub, bus *events.Bus) chi.Router {
queries := db.New(pool)
emailSvc := service.NewEmailService()
// Initialize storage with S3 as primary, fallback to local
var store storage.Storage
s3 := storage.NewS3StorageFromEnv()
if s3 != nil {
store = s3
} else {
local := storage.NewLocalStorageFromEnv()
if local != nil {
store = local
}
}
cfSigner := auth.NewCloudFrontSignerFromEnv()
h := handler.New(queries, pool, hub, bus, emailSvc, s3, cfSigner)
h := handler.New(queries, pool, hub, bus, emailSvc, store, cfSigner)
r := chi.NewRouter()
@@ -66,10 +78,16 @@ func NewRouter(pool *pgxpool.Pool, hub *realtime.Hub, bus *events.Bus) chi.Route
r.Use(chimw.RequestID)
r.Use(middleware.RequestLogger)
r.Use(chimw.Recoverer)
r.Use(middleware.ContentSecurityPolicy)
origins := allowedOrigins()
// Share allowed origins with WebSocket origin checker.
realtime.SetAllowedOrigins(origins)
r.Use(cors.Handler(cors.Options{
AllowedOrigins: allowedOrigins(),
AllowedOrigins: origins,
AllowedMethods: []string{"GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"},
AllowedHeaders: []string{"Accept", "Authorization", "Content-Type", "X-Workspace-ID", "X-Request-ID", "X-Agent-ID", "X-Task-ID"},
AllowedHeaders: []string{"Accept", "Authorization", "Content-Type", "X-Workspace-ID", "X-Request-ID", "X-Agent-ID", "X-Task-ID", "X-CSRF-Token"},
AllowCredentials: true,
MaxAge: 300,
}))
@@ -87,10 +105,19 @@ func NewRouter(pool *pgxpool.Pool, hub *realtime.Hub, bus *events.Bus) chi.Route
realtime.HandleWebSocket(hub, mc, pr, w, r)
})
// Local file serving (when using local storage)
if local, ok := store.(*storage.LocalStorage); ok {
r.Get("/uploads/*", func(w http.ResponseWriter, r *http.Request) {
file := strings.TrimPrefix(r.URL.Path, "/uploads/")
local.ServeFile(w, r, file)
})
}
// Auth (public)
r.Post("/auth/send-code", h.SendCode)
r.Post("/auth/verify-code", h.VerifyCode)
r.Post("/auth/google", h.GoogleLogin)
r.Post("/auth/logout", h.Logout)
// Daemon API routes (require daemon token or valid user token)
r.Route("/api/daemon", func(r chi.Router) {

View File

@@ -134,12 +134,36 @@ func broadcastFailedTasks(ctx context.Context, queries *db.Queries, bus *events.
}
affectedAgents := make(map[string]pgtype.UUID)
processedIssues := make(map[string]bool)
for _, ft := range items {
// Look up workspace ID from the issue so the event reaches the right WS room.
workspaceID := ""
if issue, err := queries.GetIssue(ctx, ft.IssueID); err == nil {
workspaceID = util.UUIDToString(issue.WorkspaceID)
// If the issue is still in_progress and no other active tasks remain,
// reset it back to todo so the daemon can pick it up again.
issueKey := util.UUIDToString(ft.IssueID)
if issue.Status == "in_progress" && !processedIssues[issueKey] {
processedIssues[issueKey] = true
hasActive, checkErr := queries.HasActiveTaskForIssue(ctx, ft.IssueID)
if checkErr != nil {
slog.Warn("runtime sweeper: failed to check active tasks for issue",
"issue_id", issueKey,
"error", checkErr,
)
} else if !hasActive {
if _, updateErr := queries.UpdateIssueStatus(ctx, db.UpdateIssueStatusParams{
ID: ft.IssueID,
Status: "todo",
}); updateErr != nil {
slog.Warn("runtime sweeper: failed to reset stuck issue to todo",
"issue_id", issueKey,
"error", updateErr,
)
}
}
}
}
bus.Publish(events.Event{

View File

@@ -300,6 +300,168 @@ func TestSweepDispatchedStaleTask(t *testing.T) {
}
}
// TestSweepResetsInProgressIssueToTodo verifies the core fix: when the sweeper
// force-fails a stale task whose issue is still in_progress (because the daemon
// crashed mid-run), the issue is reset back to todo so the daemon can re-queue it.
//
// Without this fix the issue stays in_progress permanently — the agent never runs
// to update the status because it was never dispatched.
func TestSweepResetsInProgressIssueToTodo(t *testing.T) {
if testPool == nil {
t.Skip("no database connection")
}
ctx := context.Background()
// Use the same agent/runtime as the other sweeper tests.
var agentID, runtimeID string
err := testPool.QueryRow(ctx, `
SELECT a.id, a.runtime_id FROM agent a
JOIN member m ON m.workspace_id = a.workspace_id
JOIN "user" u ON u.id = m.user_id
WHERE u.email = $1
LIMIT 1
`, integrationTestEmail).Scan(&agentID, &runtimeID)
if err != nil {
t.Fatalf("failed to find test agent: %v", err)
}
// Create an issue already in in_progress (simulates a daemon crash mid-run).
var issueID string
err = testPool.QueryRow(ctx, `
INSERT INTO issue (workspace_id, title, status, priority, creator_type, creator_id, assignee_type, assignee_id)
SELECT $1, 'Stuck in_progress issue', 'in_progress', 'none', 'member', m.user_id, 'agent', $2
FROM member m WHERE m.workspace_id = $1 LIMIT 1
RETURNING id
`, testWorkspaceID, agentID).Scan(&issueID)
if err != nil {
t.Fatalf("failed to create test issue: %v", err)
}
t.Cleanup(func() {
testPool.Exec(ctx, `DELETE FROM agent_task_queue WHERE issue_id = $1`, issueID)
testPool.Exec(ctx, `DELETE FROM issue WHERE id = $1`, issueID)
})
// Create a stale running task for the issue (3 hours old — beyond any timeout).
var taskID string
err = testPool.QueryRow(ctx, `
INSERT INTO agent_task_queue (agent_id, runtime_id, issue_id, status, priority, dispatched_at, started_at)
VALUES ($1, $2, $3, 'running', 0, now() - interval '3 hours', now() - interval '3 hours')
RETURNING id
`, agentID, runtimeID, issueID).Scan(&taskID)
if err != nil {
t.Fatalf("failed to create stale task: %v", err)
}
queries := db.New(testPool)
bus := events.New()
// Fail the stale task (running timeout of 1 second — our task is 3 hours old).
failedTasks, err := queries.FailStaleTasks(ctx, db.FailStaleTasksParams{
DispatchTimeoutSecs: 300.0,
RunningTimeoutSecs: 1.0,
})
if err != nil {
t.Fatalf("FailStaleTasks failed: %v", err)
}
// Confirm our task was swept.
found := false
for _, ft := range failedTasks {
if ft.ID.Bytes == parseUUIDBytes(taskID) {
found = true
break
}
}
if !found {
t.Fatalf("expected task %s to be in failed tasks, got %v", taskID, failedTasks)
}
// This is what we're testing: issue must be reset from in_progress → todo.
broadcastFailedTasks(ctx, queries, bus, failedTasks)
var issueStatus string
err = testPool.QueryRow(ctx, `SELECT status FROM issue WHERE id = $1`, issueID).Scan(&issueStatus)
if err != nil {
t.Fatalf("failed to query issue status: %v", err)
}
if issueStatus != "todo" {
t.Fatalf("expected issue status 'todo' after sweep, got '%s' — issue is stuck", issueStatus)
}
}
// TestSweepDoesNotResetIssueAlreadyInReview verifies that the sweeper only resets
// issues that are truly stuck in in_progress — it must not clobber issues whose
// agents already moved them forward (e.g. to in_review) before the task timed out.
func TestSweepDoesNotResetIssueAlreadyInReview(t *testing.T) {
if testPool == nil {
t.Skip("no database connection")
}
ctx := context.Background()
var agentID, runtimeID string
err := testPool.QueryRow(ctx, `
SELECT a.id, a.runtime_id FROM agent a
JOIN member m ON m.workspace_id = a.workspace_id
JOIN "user" u ON u.id = m.user_id
WHERE u.email = $1
LIMIT 1
`, integrationTestEmail).Scan(&agentID, &runtimeID)
if err != nil {
t.Fatalf("failed to find test agent: %v", err)
}
// Issue already advanced to in_review by the agent before the task timed out.
var issueID string
err = testPool.QueryRow(ctx, `
INSERT INTO issue (workspace_id, title, status, priority, creator_type, creator_id, assignee_type, assignee_id)
SELECT $1, 'Already in_review issue', 'in_review', 'none', 'member', m.user_id, 'agent', $2
FROM member m WHERE m.workspace_id = $1 LIMIT 1
RETURNING id
`, testWorkspaceID, agentID).Scan(&issueID)
if err != nil {
t.Fatalf("failed to create test issue: %v", err)
}
t.Cleanup(func() {
testPool.Exec(ctx, `DELETE FROM agent_task_queue WHERE issue_id = $1`, issueID)
testPool.Exec(ctx, `DELETE FROM issue WHERE id = $1`, issueID)
})
var taskID string
err = testPool.QueryRow(ctx, `
INSERT INTO agent_task_queue (agent_id, runtime_id, issue_id, status, priority, dispatched_at, started_at)
VALUES ($1, $2, $3, 'running', 0, now() - interval '3 hours', now() - interval '3 hours')
RETURNING id
`, agentID, runtimeID, issueID).Scan(&taskID)
if err != nil {
t.Fatalf("failed to create stale task: %v", err)
}
queries := db.New(testPool)
bus := events.New()
failedTasks, err := queries.FailStaleTasks(ctx, db.FailStaleTasksParams{
DispatchTimeoutSecs: 300.0,
RunningTimeoutSecs: 1.0,
})
if err != nil {
t.Fatalf("FailStaleTasks failed: %v", err)
}
broadcastFailedTasks(ctx, queries, bus, failedTasks)
// Issue should remain in_review — the sweeper must not clobber agent progress.
var issueStatus string
err = testPool.QueryRow(ctx, `SELECT status FROM issue WHERE id = $1`, issueID).Scan(&issueStatus)
if err != nil {
t.Fatalf("failed to query issue status: %v", err)
}
if issueStatus != "in_review" {
t.Fatalf("expected issue status 'in_review' to be preserved, got '%s'", issueStatus)
}
}
// parseUUIDBytes converts a UUID string to the 16-byte array used by pgtype.UUID.
func parseUUIDBytes(s string) [16]byte {
s = strings.ReplaceAll(s, "-", "")

View File

@@ -0,0 +1,152 @@
package auth
import (
"crypto/hmac"
"crypto/rand"
"crypto/sha256"
"encoding/hex"
"net/http"
"os"
"strings"
"time"
)
const (
AuthCookieName = "multica_auth"
CSRFCookieName = "multica_csrf"
authCookieMaxAge = 30 * 24 * 60 * 60 // 30 days in seconds
)
func cookieDomain() string {
return strings.TrimSpace(os.Getenv("COOKIE_DOMAIN"))
}
func isSecureCookie() bool {
env := os.Getenv("APP_ENV")
return env == "production" || env == "staging"
}
// generateCSRFToken creates a CSRF token bound to the auth token via HMAC.
// Format: hex(nonce) + "." + hex(HMAC-SHA256(nonce, authToken)).
// This ensures an attacker who can write cookies on a subdomain cannot forge
// a valid CSRF token without knowing the auth token.
func generateCSRFToken(authToken string) (string, error) {
nonce := make([]byte, 16)
if _, err := rand.Read(nonce); err != nil {
return "", err
}
nonceHex := hex.EncodeToString(nonce)
mac := hmac.New(sha256.New, []byte(authToken))
mac.Write(nonce)
sig := hex.EncodeToString(mac.Sum(nil))
return nonceHex + "." + sig, nil
}
// SetAuthCookies sets the HttpOnly auth cookie and the readable CSRF cookie on the response.
func SetAuthCookies(w http.ResponseWriter, token string) error {
secure := isSecureCookie()
domain := cookieDomain()
http.SetCookie(w, &http.Cookie{
Name: AuthCookieName,
Value: token,
Path: "/",
Domain: domain,
MaxAge: authCookieMaxAge,
Expires: time.Now().Add(30 * 24 * time.Hour),
HttpOnly: true,
Secure: secure,
SameSite: http.SameSiteStrictMode,
})
csrfToken, err := generateCSRFToken(token)
if err != nil {
return err
}
http.SetCookie(w, &http.Cookie{
Name: CSRFCookieName,
Value: csrfToken,
Path: "/",
Domain: domain,
MaxAge: authCookieMaxAge,
Expires: time.Now().Add(30 * 24 * time.Hour),
HttpOnly: false,
Secure: secure,
SameSite: http.SameSiteStrictMode,
})
return nil
}
// ClearAuthCookies removes the auth and CSRF cookies.
func ClearAuthCookies(w http.ResponseWriter) {
domain := cookieDomain()
secure := isSecureCookie()
http.SetCookie(w, &http.Cookie{
Name: AuthCookieName,
Value: "",
Path: "/",
Domain: domain,
MaxAge: -1,
Expires: time.Unix(0, 0),
HttpOnly: true,
Secure: secure,
SameSite: http.SameSiteStrictMode,
})
http.SetCookie(w, &http.Cookie{
Name: CSRFCookieName,
Value: "",
Path: "/",
Domain: domain,
MaxAge: -1,
Expires: time.Unix(0, 0),
HttpOnly: false,
Secure: secure,
SameSite: http.SameSiteStrictMode,
})
}
// ValidateCSRF checks the X-CSRF-Token header against the auth cookie.
// The CSRF token is HMAC-signed with the auth token, so the server verifies
// the signature rather than simply comparing cookie == header.
// Returns true if validation passes (including for safe methods that don't need CSRF).
func ValidateCSRF(r *http.Request) bool {
switch r.Method {
case http.MethodGet, http.MethodHead, http.MethodOptions:
return true
}
csrfHeader := r.Header.Get("X-CSRF-Token")
if csrfHeader == "" {
return false
}
authCookie, err := r.Cookie(AuthCookieName)
if err != nil || authCookie.Value == "" {
return false
}
parts := strings.SplitN(csrfHeader, ".", 2)
if len(parts) != 2 {
return false
}
nonce, err := hex.DecodeString(parts[0])
if err != nil {
return false
}
expectedSig, err := hex.DecodeString(parts[1])
if err != nil {
return false
}
mac := hmac.New(sha256.New, []byte(authCookie.Value))
mac.Write(nonce)
return hmac.Equal(mac.Sum(nil), expectedSig)
}

View File

@@ -178,11 +178,14 @@ func (d *Daemon) loadWatchedWorkspaces(ctx context.Context) error {
}
d.mu.Unlock()
// Sync workspace repos to local cache.
// Sync workspace repos to local cache in the background so heartbeat
// and poll loops are not blocked by slow git clone/fetch operations.
if d.repoCache != nil && len(resp.Repos) > 0 {
if err := d.repoCache.Sync(ws.ID, repoDataToInfo(resp.Repos)); err != nil {
d.logger.Warn("repo cache sync failed", "workspace_id", ws.ID, "error", err)
}
go func(wsID string, repos []RepoData) {
if err := d.repoCache.Sync(wsID, repoDataToInfo(repos)); err != nil {
d.logger.Warn("repo cache sync failed", "workspace_id", wsID, "error", err)
}
}(ws.ID, resp.Repos)
}
d.logger.Info("watching workspace", "workspace_id", ws.ID, "name", ws.Name, "runtimes", len(resp.Runtimes), "repos", len(resp.Repos))
@@ -407,11 +410,13 @@ func (d *Daemon) reloadWorkspaces(ctx context.Context) {
}
d.mu.Unlock()
// Sync workspace repos to local cache.
// Sync workspace repos to local cache in the background.
if d.repoCache != nil && len(resp.Repos) > 0 {
if err := d.repoCache.Sync(id, repoDataToInfo(resp.Repos)); err != nil {
d.logger.Warn("repo cache sync failed", "workspace_id", id, "error", err)
}
go func(wsID string, repos []RepoData) {
if err := d.repoCache.Sync(wsID, repoDataToInfo(repos)); err != nil {
d.logger.Warn("repo cache sync failed", "workspace_id", wsID, "error", err)
}
}(id, resp.Repos)
}
d.logger.Info("now watching workspace", "workspace_id", id, "name", name)
@@ -976,17 +981,89 @@ func (d *Daemon) runTask(ctx context.Context, task Task, provider string, taskLo
taskStart := time.Now()
session, err := backend.Execute(ctx, prompt, agent.ExecOptions{
execOpts := agent.ExecOptions{
Cwd: env.WorkDir,
Model: entry.Model,
Timeout: d.cfg.AgentTimeout,
ResumeSessionID: task.PriorSessionID,
})
}
result, tools, err := d.executeAndDrain(ctx, backend, prompt, execOpts, taskLog, task.ID)
if err != nil {
return TaskResult{}, err
}
// Drain message channel — forward to server for live output + log locally.
// Fallback: if session resume failed before establishing a session, retry
// with a fresh session. We check SessionID == "" to distinguish a resume
// failure (no session established) from a failure during actual execution.
if result.Status == "failed" && task.PriorSessionID != "" && result.SessionID == "" {
firstUsage := result.Usage
taskLog.Warn("session resume failed, retrying with fresh session", "error", result.Error)
execOpts.ResumeSessionID = ""
retryResult, retryTools, retryErr := d.executeAndDrain(ctx, backend, prompt, execOpts, taskLog, task.ID)
if retryErr != nil {
taskLog.Error("fresh session also failed to start", "error", retryErr)
} else {
result = retryResult
result.Usage = mergeUsage(firstUsage, result.Usage)
tools = retryTools
}
}
elapsed := time.Since(taskStart).Round(time.Second)
taskLog.Info("agent finished",
"status", result.Status,
"duration", elapsed.String(),
"tools", tools,
)
// Convert agent usage map to task usage entries.
var usageEntries []TaskUsageEntry
for model, u := range result.Usage {
if u.InputTokens == 0 && u.OutputTokens == 0 && u.CacheReadTokens == 0 && u.CacheWriteTokens == 0 {
continue
}
usageEntries = append(usageEntries, TaskUsageEntry{
Provider: provider,
Model: model,
InputTokens: u.InputTokens,
OutputTokens: u.OutputTokens,
CacheReadTokens: u.CacheReadTokens,
CacheWriteTokens: u.CacheWriteTokens,
})
}
switch result.Status {
case "completed":
if result.Output == "" {
return TaskResult{}, fmt.Errorf("%s returned empty output", provider)
}
return TaskResult{
Status: "completed",
Comment: result.Output,
SessionID: result.SessionID,
WorkDir: env.WorkDir,
Usage: usageEntries,
}, nil
case "timeout":
return TaskResult{}, fmt.Errorf("%s timed out after %s", provider, d.cfg.AgentTimeout)
default:
errMsg := result.Error
if errMsg == "" {
errMsg = fmt.Sprintf("%s execution %s", provider, result.Status)
}
return TaskResult{Status: "blocked", Comment: errMsg, Usage: usageEntries}, nil
}
}
// executeAndDrain runs a backend, drains its message stream (forwarding to the
// server), and waits for the final result.
func (d *Daemon) executeAndDrain(ctx context.Context, backend agent.Backend, prompt string, opts agent.ExecOptions, taskLog *slog.Logger, taskID string) (agent.Result, int32, error) {
session, err := backend.Execute(ctx, prompt, opts)
if err != nil {
return agent.Result{}, 0, err
}
var toolCount atomic.Int32
go func() {
var seq atomic.Int32
@@ -994,11 +1071,10 @@ func (d *Daemon) runTask(ctx context.Context, task Task, provider string, taskLo
var pendingText strings.Builder
var pendingThinking strings.Builder
var batch []TaskMessageData
callIDToTool := map[string]string{} // track callID → tool name for tool_result
callIDToTool := map[string]string{}
flush := func() {
mu.Lock()
// Flush any accumulated thinking as a single message.
if pendingThinking.Len() > 0 {
s := seq.Add(1)
batch = append(batch, TaskMessageData{
@@ -1008,7 +1084,6 @@ func (d *Daemon) runTask(ctx context.Context, task Task, provider string, taskLo
})
pendingThinking.Reset()
}
// Flush any accumulated text as a single message.
if pendingText.Len() > 0 {
s := seq.Add(1)
batch = append(batch, TaskMessageData{
@@ -1024,14 +1099,13 @@ func (d *Daemon) runTask(ctx context.Context, task Task, provider string, taskLo
if len(toSend) > 0 {
sendCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
if err := d.client.ReportTaskMessages(sendCtx, task.ID, toSend); err != nil {
if err := d.client.ReportTaskMessages(sendCtx, taskID, toSend); err != nil {
taskLog.Debug("failed to report task messages", "error", err)
}
cancel()
}
}
// Periodically flush accumulated text/thinking messages.
ticker := time.NewTicker(500 * time.Millisecond)
defer ticker.Stop()
@@ -1072,7 +1146,6 @@ func (d *Daemon) runTask(ctx context.Context, task Task, provider string, taskLo
if len(output) > 8192 {
output = output[:8192]
}
// Resolve tool name from callID if not set directly.
toolName := msg.Tool
if toolName == "" && msg.CallID != "" {
mu.Lock()
@@ -1114,54 +1187,33 @@ func (d *Daemon) runTask(ctx context.Context, task Task, provider string, taskLo
}
close(done)
flush() // Final flush after channel closes.
flush()
}()
result := <-session.Result
elapsed := time.Since(taskStart).Round(time.Second)
taskLog.Info("agent finished",
"status", result.Status,
"duration", elapsed.String(),
"tools", toolCount.Load(),
)
return result, toolCount.Load(), nil
}
// Convert agent usage map to task usage entries.
var usageEntries []TaskUsageEntry
for model, u := range result.Usage {
if u.InputTokens == 0 && u.OutputTokens == 0 && u.CacheReadTokens == 0 && u.CacheWriteTokens == 0 {
continue
}
usageEntries = append(usageEntries, TaskUsageEntry{
Provider: provider,
Model: model,
InputTokens: u.InputTokens,
OutputTokens: u.OutputTokens,
CacheReadTokens: u.CacheReadTokens,
CacheWriteTokens: u.CacheWriteTokens,
})
func mergeUsage(a, b map[string]agent.TokenUsage) map[string]agent.TokenUsage {
if len(a) == 0 {
return b
}
switch result.Status {
case "completed":
if result.Output == "" {
return TaskResult{}, fmt.Errorf("%s returned empty output", provider)
}
return TaskResult{
Status: "completed",
Comment: result.Output,
SessionID: result.SessionID,
WorkDir: env.WorkDir,
Usage: usageEntries,
}, nil
case "timeout":
return TaskResult{}, fmt.Errorf("%s timed out after %s", provider, d.cfg.AgentTimeout)
default:
errMsg := result.Error
if errMsg == "" {
errMsg = fmt.Sprintf("%s execution %s", provider, result.Status)
}
return TaskResult{Status: "blocked", Comment: errMsg, Usage: usageEntries}, nil
if len(b) == 0 {
return a
}
merged := make(map[string]agent.TokenUsage, len(a)+len(b))
for model, u := range a {
merged[model] = u
}
for model, u := range b {
existing := merged[model]
existing.InputTokens += u.InputTokens
existing.OutputTokens += u.OutputTokens
existing.CacheReadTokens += u.CacheReadTokens
existing.CacheWriteTokens += u.CacheWriteTokens
merged[model] = existing
}
return merged
}
// repoDataToInfo converts daemon RepoData to repocache RepoInfo.

View File

@@ -1,9 +1,15 @@
package daemon
import (
"context"
"log/slog"
"net/http"
"net/http/httptest"
"strings"
"sync/atomic"
"testing"
"github.com/multica-ai/multica/server/pkg/agent"
)
func TestNormalizeServerBaseURL(t *testing.T) {
@@ -66,6 +72,53 @@ func TestBuildPromptNoIssueDetails(t *testing.T) {
}
}
func TestBuildPromptCommentTriggered(t *testing.T) {
t.Parallel()
issueID := "a1b2c3d4-e5f6-7890-abcd-ef1234567890"
commentID := "c1c2c3c4-d5d6-7890-abcd-ef1234567890"
commentContent := "请把报告翻译成英文"
prompt := BuildPrompt(Task{
IssueID: issueID,
TriggerCommentID: commentID,
TriggerCommentContent: commentContent,
Agent: &AgentData{Name: "Test"},
})
// Prompt should contain the comment content directly.
for _, want := range []string{
issueID,
commentContent,
"comment that triggered this task",
} {
if !strings.Contains(prompt, want) {
t.Fatalf("prompt missing %q", want)
}
}
// Should still contain CLI hint for fetching issue context.
if !strings.Contains(prompt, "multica issue get") {
t.Fatal("prompt missing CLI hint for issue context")
}
}
func TestBuildPromptCommentTriggeredNoContent(t *testing.T) {
t.Parallel()
// When TriggerCommentID is set but content is empty (e.g. fetch failed),
// it should still use the comment prompt path.
prompt := BuildPrompt(Task{
IssueID: "test-id",
TriggerCommentID: "comment-id",
Agent: &AgentData{Name: "Test"},
})
if !strings.Contains(prompt, "multica issue get") {
t.Fatal("prompt missing CLI hint")
}
}
func TestIsWorkspaceNotFoundError(t *testing.T) {
t.Parallel()
@@ -83,3 +136,147 @@ func TestIsWorkspaceNotFoundError(t *testing.T) {
t.Fatal("did not expect 500 to be treated as workspace not found")
}
}
func TestMergeUsage(t *testing.T) {
t.Parallel()
a := map[string]agent.TokenUsage{
"model-a": {InputTokens: 10, OutputTokens: 5},
}
b := map[string]agent.TokenUsage{
"model-a": {InputTokens: 20, OutputTokens: 10, CacheReadTokens: 3},
"model-b": {InputTokens: 100},
}
merged := mergeUsage(a, b)
if got := merged["model-a"]; got.InputTokens != 30 || got.OutputTokens != 15 || got.CacheReadTokens != 3 {
t.Fatalf("model-a: expected {30,15,3,0}, got %+v", got)
}
if got := merged["model-b"]; got.InputTokens != 100 {
t.Fatalf("model-b: expected InputTokens=100, got %+v", got)
}
if got := mergeUsage(nil, b); len(got) != 2 {
t.Fatal("mergeUsage(nil, b) should return b")
}
if got := mergeUsage(a, nil); len(got) != 1 {
t.Fatal("mergeUsage(a, nil) should return a")
}
}
// fakeBackend is a test double for agent.Backend that returns preconfigured
// results. Each call to Execute pops the next entry from the results slice.
type fakeBackend struct {
calls []agent.ExecOptions
results []agent.Result
errors []error
idx atomic.Int32
}
func (b *fakeBackend) Execute(_ context.Context, _ string, opts agent.ExecOptions) (*agent.Session, error) {
i := int(b.idx.Add(1)) - 1
b.calls = append(b.calls, opts)
if i < len(b.errors) && b.errors[i] != nil {
return nil, b.errors[i]
}
msgCh := make(chan agent.Message)
resCh := make(chan agent.Result, 1)
close(msgCh)
resCh <- b.results[i]
return &agent.Session{Messages: msgCh, Result: resCh}, nil
}
func newTestDaemon(t *testing.T) *Daemon {
t.Helper()
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusOK)
}))
t.Cleanup(srv.Close)
return &Daemon{
client: NewClient(srv.URL),
logger: slog.Default(),
}
}
func TestExecuteAndDrain_ResumeFailureFallback(t *testing.T) {
t.Parallel()
d := newTestDaemon(t)
ctx := context.Background()
taskLog := slog.Default()
fb := &fakeBackend{
results: []agent.Result{
{Status: "failed", Error: "session not found", Usage: map[string]agent.TokenUsage{
"m1": {InputTokens: 5},
}},
{Status: "completed", Output: "done", SessionID: "new-sess", Usage: map[string]agent.TokenUsage{
"m1": {InputTokens: 10, OutputTokens: 20},
}},
},
}
// First attempt: resume fails (no SessionID in result).
opts := agent.ExecOptions{ResumeSessionID: "stale-id"}
result, _, err := d.executeAndDrain(ctx, fb, "prompt", opts, taskLog, "task-1")
if err != nil {
t.Fatalf("first call error: %v", err)
}
if result.Status != "failed" || result.SessionID != "" {
t.Fatalf("expected failed result with empty SessionID, got %+v", result)
}
// Simulate the retry logic from runTask.
if result.Status == "failed" && result.SessionID == "" {
firstUsage := result.Usage
opts.ResumeSessionID = ""
retryResult, _, retryErr := d.executeAndDrain(ctx, fb, "prompt", opts, taskLog, "task-1")
if retryErr != nil {
t.Fatalf("retry error: %v", retryErr)
}
result = retryResult
result.Usage = mergeUsage(firstUsage, result.Usage)
}
if result.Status != "completed" || result.Output != "done" {
t.Fatalf("expected completed result, got %+v", result)
}
if result.SessionID != "new-sess" {
t.Fatalf("expected new-sess, got %s", result.SessionID)
}
// Usage should be merged.
if u := result.Usage["m1"]; u.InputTokens != 15 || u.OutputTokens != 20 {
t.Fatalf("expected merged usage {15,20}, got %+v", u)
}
// Second call should NOT have ResumeSessionID.
if fb.calls[1].ResumeSessionID != "" {
t.Fatal("retry should not have ResumeSessionID")
}
}
func TestExecuteAndDrain_NoRetryWhenSessionEstablished(t *testing.T) {
t.Parallel()
d := newTestDaemon(t)
fb := &fakeBackend{
results: []agent.Result{
{Status: "failed", Error: "model error", SessionID: "valid-sess"},
},
}
opts := agent.ExecOptions{ResumeSessionID: "some-id"}
result, _, err := d.executeAndDrain(context.Background(), fb, "p", opts, slog.Default(), "t")
if err != nil {
t.Fatal(err)
}
// SessionID is set → session was established → should NOT retry.
shouldRetry := result.Status == "failed" && result.SessionID == ""
if shouldRetry {
t.Fatal("should not retry when SessionID is present")
}
if int(fb.idx.Load()) != 1 {
t.Fatalf("expected 1 call, got %d", fb.idx.Load())
}
}

View File

@@ -6,6 +6,7 @@ import (
"log/slog"
"os"
"path/filepath"
"strings"
)
// Directories to symlink from the shared ~/.codex/ into the per-task CODEX_HOME.
@@ -66,6 +67,12 @@ func prepareCodexHome(codexHome string, logger *slog.Logger) error {
}
}
// Ensure config.toml has workspace-write sandbox with network access enabled.
// Codex needs network access to reach the Multica API (api.multica.ai).
if err := ensureCodexNetworkAccess(filepath.Join(codexHome, "config.toml")); err != nil {
logger.Warn("execenv: codex-home ensure network access failed", "error", err)
}
return nil
}
@@ -137,6 +144,54 @@ func ensureSymlink(src, dst string) error {
return os.Symlink(src, dst)
}
// defaultCodexConfig is the minimal config.toml for Codex tasks.
// It sets workspace-write sandbox mode with network access enabled so the
// Multica CLI can reach api.multica.ai.
const defaultCodexConfig = `sandbox_mode = "workspace-write"
[sandbox_workspace_write]
network_access = true
`
// ensureCodexNetworkAccess ensures that config.toml exists and contains the
// sandbox_workspace_write section with network_access = true. If the file
// doesn't exist, it creates one with defaults. If it exists but lacks the
// network_access setting, the section is appended.
func ensureCodexNetworkAccess(configPath string) error {
data, err := os.ReadFile(configPath)
if os.IsNotExist(err) {
// No config.toml — create with defaults.
return os.WriteFile(configPath, []byte(defaultCodexConfig), 0o644)
}
if err != nil {
return fmt.Errorf("read config.toml: %w", err)
}
content := string(data)
// If the file already has network_access configured under sandbox_workspace_write, leave it alone.
if strings.Contains(content, "[sandbox_workspace_write]") && strings.Contains(content, "network_access") {
return nil
}
// Append the section. If sandbox_mode is already set, only append the section block.
var appendStr string
if strings.Contains(content, "[sandbox_workspace_write]") {
// Section exists but missing network_access — append the key under it.
content = strings.Replace(content, "[sandbox_workspace_write]", "[sandbox_workspace_write]\nnetwork_access = true", 1)
return os.WriteFile(configPath, []byte(content), 0o644)
}
// Section doesn't exist — append both sandbox_mode (if missing) and the section.
appendStr = "\n"
if !strings.Contains(content, "sandbox_mode") {
appendStr += "sandbox_mode = \"workspace-write\"\n"
}
appendStr += "\n[sandbox_workspace_write]\nnetwork_access = true\n"
return os.WriteFile(configPath, append(data, []byte(appendStr)...), 0o644)
}
// copyFileIfExists copies src to dst. If src doesn't exist, it's a no-op.
// If dst already exists, it's not overwritten.
func copyFileIfExists(src, dst string) error {

View File

@@ -140,6 +140,18 @@ func Reuse(workDir, provider string, task TaskContextForEnv, logger *slog.Logger
logger.Warn("execenv: refresh context files failed", "error", err)
}
// Restore CodexHome for Codex provider — the per-task codex-home directory
// lives alongside the workdir. Re-run prepareCodexHome to ensure config
// (especially network access) is up to date.
if provider == "codex" {
codexHome := filepath.Join(env.RootDir, "codex-home")
if err := prepareCodexHome(codexHome, logger); err != nil {
logger.Warn("execenv: refresh codex-home failed", "error", err)
} else {
env.CodexHome = codexHome
}
}
logger.Info("execenv: reusing env", "workdir", workDir)
return env
}

View File

@@ -664,10 +664,14 @@ func TestPrepareCodexHomeSeedsFromShared(t *testing.T) {
t.Errorf("config.json content = %q", data)
}
// config.toml should be copied.
// config.toml should be copied and have network access appended.
data, _ = os.ReadFile(filepath.Join(codexHome, "config.toml"))
if string(data) != `model = "o3"` {
t.Errorf("config.toml content = %q", data)
tomlStr := string(data)
if !strings.Contains(tomlStr, `model = "o3"`) {
t.Errorf("config.toml missing original model setting, got: %q", tomlStr)
}
if !strings.Contains(tomlStr, "network_access = true") {
t.Errorf("config.toml missing network_access, got: %q", tomlStr)
}
// instructions.md should be copied.
@@ -689,17 +693,25 @@ func TestPrepareCodexHomeSkipsMissingFiles(t *testing.T) {
t.Fatalf("prepareCodexHome failed: %v", err)
}
// Directory should only contain the sessions symlink (no auth.json, no config.json, etc.).
// Directory should contain sessions symlink + auto-generated config.toml.
entries, err := os.ReadDir(codexHome)
if err != nil {
t.Fatalf("failed to read codex-home: %v", err)
}
if len(entries) != 1 {
names := make([]string, len(entries))
for i, e := range entries {
names[i] = e.Name()
entryNames := make(map[string]bool, len(entries))
for _, e := range entries {
entryNames[e.Name()] = true
}
if !entryNames["sessions"] {
t.Error("expected sessions symlink")
}
if !entryNames["config.toml"] {
t.Error("expected config.toml (auto-generated for network access)")
}
for name := range entryNames {
if name != "sessions" && name != "config.toml" {
t.Errorf("unexpected entry: %s", name)
}
t.Errorf("expected only sessions symlink in codex-home, got: %v", names)
}
// sessions should be a symlink to the shared sessions dir.
sessionsPath := filepath.Join(codexHome, "sessions")
@@ -712,6 +724,176 @@ func TestPrepareCodexHomeSkipsMissingFiles(t *testing.T) {
}
}
func TestEnsureCodexNetworkAccessCreatesDefault(t *testing.T) {
t.Parallel()
dir := t.TempDir()
configPath := filepath.Join(dir, "config.toml")
if err := ensureCodexNetworkAccess(configPath); err != nil {
t.Fatalf("ensureCodexNetworkAccess failed: %v", err)
}
data, err := os.ReadFile(configPath)
if err != nil {
t.Fatalf("failed to read config.toml: %v", err)
}
s := string(data)
if !strings.Contains(s, `sandbox_mode = "workspace-write"`) {
t.Error("missing sandbox_mode")
}
if !strings.Contains(s, "[sandbox_workspace_write]") {
t.Error("missing [sandbox_workspace_write] section")
}
if !strings.Contains(s, "network_access = true") {
t.Error("missing network_access = true")
}
}
func TestEnsureCodexNetworkAccessPreservesExisting(t *testing.T) {
t.Parallel()
dir := t.TempDir()
configPath := filepath.Join(dir, "config.toml")
existing := `model = "o3"
[sandbox_workspace_write]
network_access = true
`
os.WriteFile(configPath, []byte(existing), 0o644)
if err := ensureCodexNetworkAccess(configPath); err != nil {
t.Fatalf("ensureCodexNetworkAccess failed: %v", err)
}
data, _ := os.ReadFile(configPath)
if string(data) != existing {
t.Errorf("config should be unchanged, got:\n%s", data)
}
}
func TestEnsureCodexNetworkAccessAppendsToExisting(t *testing.T) {
t.Parallel()
dir := t.TempDir()
configPath := filepath.Join(dir, "config.toml")
existing := `model = "o3"
sandbox_mode = "workspace-write"
`
os.WriteFile(configPath, []byte(existing), 0o644)
if err := ensureCodexNetworkAccess(configPath); err != nil {
t.Fatalf("ensureCodexNetworkAccess failed: %v", err)
}
data, _ := os.ReadFile(configPath)
s := string(data)
if !strings.Contains(s, `model = "o3"`) {
t.Error("lost existing model setting")
}
if !strings.Contains(s, "[sandbox_workspace_write]") {
t.Error("missing [sandbox_workspace_write] section")
}
if !strings.Contains(s, "network_access = true") {
t.Error("missing network_access = true")
}
}
func TestEnsureCodexNetworkAccessAddsMissingKey(t *testing.T) {
t.Parallel()
dir := t.TempDir()
configPath := filepath.Join(dir, "config.toml")
// Section exists but without network_access.
existing := `[sandbox_workspace_write]
allow_commands = ["git"]
`
os.WriteFile(configPath, []byte(existing), 0o644)
if err := ensureCodexNetworkAccess(configPath); err != nil {
t.Fatalf("ensureCodexNetworkAccess failed: %v", err)
}
data, _ := os.ReadFile(configPath)
s := string(data)
if !strings.Contains(s, "network_access = true") {
t.Error("missing network_access = true")
}
if !strings.Contains(s, `allow_commands = ["git"]`) {
t.Error("lost existing allow_commands")
}
}
func TestPrepareCodexHomeEnsuresNetworkAccess(t *testing.T) {
// Cannot use t.Parallel() with t.Setenv.
// Empty shared home — no config.toml to copy.
sharedHome := t.TempDir()
t.Setenv("CODEX_HOME", sharedHome)
codexHome := filepath.Join(t.TempDir(), "codex-home")
if err := prepareCodexHome(codexHome, testLogger()); err != nil {
t.Fatalf("prepareCodexHome failed: %v", err)
}
// config.toml should be created with network access defaults.
data, err := os.ReadFile(filepath.Join(codexHome, "config.toml"))
if err != nil {
t.Fatalf("config.toml not created: %v", err)
}
s := string(data)
if !strings.Contains(s, "network_access = true") {
t.Error("config.toml missing network_access = true")
}
if !strings.Contains(s, `sandbox_mode = "workspace-write"`) {
t.Error("config.toml missing sandbox_mode")
}
}
func TestReuseRestoresCodexHome(t *testing.T) {
// Cannot use t.Parallel() with t.Setenv.
sharedHome := t.TempDir()
t.Setenv("CODEX_HOME", sharedHome)
workspacesRoot := t.TempDir()
// First, Prepare a codex env.
env, err := Prepare(PrepareParams{
WorkspacesRoot: workspacesRoot,
WorkspaceID: "ws-codex-reuse",
TaskID: "e5f6a7b8-c9d0-1234-efab-567890123456",
AgentName: "Codex Agent",
Provider: "codex",
Task: TaskContextForEnv{IssueID: "reuse-test"},
}, testLogger())
if err != nil {
t.Fatalf("Prepare failed: %v", err)
}
defer env.Cleanup(true)
if env.CodexHome == "" {
t.Fatal("expected CodexHome to be set after Prepare")
}
// Reuse should restore CodexHome.
reused := Reuse(env.WorkDir, "codex", TaskContextForEnv{IssueID: "reuse-test"}, testLogger())
if reused == nil {
t.Fatal("Reuse returned nil")
}
if reused.CodexHome == "" {
t.Fatal("expected CodexHome to be restored after Reuse")
}
// Verify config.toml has network access.
data, err := os.ReadFile(filepath.Join(reused.CodexHome, "config.toml"))
if err != nil {
t.Fatalf("config.toml not found in reused CodexHome: %v", err)
}
if !strings.Contains(string(data), "network_access = true") {
t.Error("reused config.toml missing network_access = true")
}
}
func TestEnsureSymlinkRepairsBrokenLink(t *testing.T) {
t.Parallel()
dir := t.TempDir()

View File

@@ -12,6 +12,9 @@ func BuildPrompt(task Task) string {
if task.ChatSessionID != "" {
return buildChatPrompt(task)
}
if task.TriggerCommentID != "" {
return buildCommentPrompt(task)
}
var b strings.Builder
b.WriteString("You are running as a local coding agent for a Multica workspace.\n\n")
fmt.Fprintf(&b, "Your assigned issue ID is: %s\n\n", task.IssueID)
@@ -19,6 +22,21 @@ func BuildPrompt(task Task) string {
return b.String()
}
// buildCommentPrompt constructs a prompt for comment-triggered tasks.
// The triggering comment content is embedded directly so the agent cannot
// miss it, even when stale output files exist in a reused workdir.
func buildCommentPrompt(task Task) string {
var b strings.Builder
b.WriteString("You are running as a local coding agent for a Multica workspace.\n\n")
fmt.Fprintf(&b, "Your assigned issue ID is: %s\n\n", task.IssueID)
if task.TriggerCommentContent != "" {
b.WriteString("A user left a comment that triggered this task. Here is their message:\n\n")
fmt.Fprintf(&b, "> %s\n\n", task.TriggerCommentContent)
}
fmt.Fprintf(&b, "Start by running `multica issue get %s --output json` to understand your task, then complete it.\n", task.IssueID)
return b.String()
}
// buildChatPrompt constructs a prompt for interactive chat tasks.
func buildChatPrompt(task Task) string {
var b strings.Builder

View File

@@ -32,9 +32,10 @@ type Task struct {
Repos []RepoData `json:"repos,omitempty"`
PriorSessionID string `json:"prior_session_id,omitempty"` // Claude session ID from a previous task on this issue
PriorWorkDir string `json:"prior_work_dir,omitempty"` // work_dir from a previous task on this issue
TriggerCommentID string `json:"trigger_comment_id,omitempty"` // comment that triggered this task
ChatSessionID string `json:"chat_session_id,omitempty"` // non-empty for chat tasks
ChatMessage string `json:"chat_message,omitempty"` // user message content for chat tasks
TriggerCommentID string `json:"trigger_comment_id,omitempty"` // comment that triggered this task
TriggerCommentContent string `json:"trigger_comment_content,omitempty"` // content of the triggering comment
ChatSessionID string `json:"chat_session_id,omitempty"` // non-empty for chat tasks
ChatMessage string `json:"chat_message,omitempty"` // user message content for chat tasks
}
// AgentData holds agent details returned by the claim endpoint.

View File

@@ -0,0 +1,173 @@
package usage
import (
"bufio"
"encoding/json"
"os"
"path/filepath"
"time"
)
// scanHermes reads Hermes JSONL session files from
// ~/.hermes/sessions/*.jsonl
// and extracts token usage from assistant message and usage_update entries.
//
// Hermes communicates via the ACP (Agent Communication Protocol) and logs
// session events as JSONL. Token usage appears in:
// - "assistant" messages with a "usage" field
// - "usage_update" notification entries with cumulative token snapshots
func (s *Scanner) scanHermes() []Record {
root := hermesSessionRoot()
if root == "" {
return nil
}
// Glob for session files: sessions/*.jsonl
pattern := filepath.Join(root, "*.jsonl")
files, err := filepath.Glob(pattern)
if err != nil {
s.logger.Debug("hermes glob error", "error", err)
return nil
}
var allRecords []Record
for _, f := range files {
record := s.parseHermesFile(f)
if record != nil {
allRecords = append(allRecords, *record)
}
}
return mergeRecords(allRecords)
}
// hermesSessionRoot returns the Hermes sessions directory.
func hermesSessionRoot() string {
if hermesHome := os.Getenv("HERMES_HOME"); hermesHome != "" {
dir := filepath.Join(hermesHome, "sessions")
if info, err := os.Stat(dir); err == nil && info.IsDir() {
return dir
}
}
home, err := os.UserHomeDir()
if err != nil {
return ""
}
// Check common locations.
candidates := []string{
filepath.Join(home, ".hermes", "sessions"),
filepath.Join(home, ".local", "share", "hermes", "sessions"),
filepath.Join(home, ".config", "hermes", "sessions"),
}
for _, dir := range candidates {
if info, err := os.Stat(dir); err == nil && info.IsDir() {
return dir
}
}
return ""
}
// hermesLine represents a line in a Hermes session JSONL file.
// Hermes session logs contain both message events and notification events.
type hermesLine struct {
Type string `json:"type"`
Timestamp string `json:"timestamp"` // RFC3339
Model string `json:"model"`
Usage *struct {
InputTokens int64 `json:"inputTokens"`
OutputTokens int64 `json:"outputTokens"`
CachedReadTokens int64 `json:"cachedReadTokens"`
ThoughtTokens int64 `json:"thoughtTokens"`
} `json:"usage"`
}
// parseHermesFile extracts the final cumulative token usage from a Hermes session file.
// Hermes usage_update events are cumulative snapshots — the last one in the file
// represents the total usage for the session. Returns nil if no usage data found.
func (s *Scanner) parseHermesFile(path string) *Record {
f, err := os.Open(path)
if err != nil {
return nil
}
defer f.Close()
var lastUsage *struct {
InputTokens int64 `json:"inputTokens"`
OutputTokens int64 `json:"outputTokens"`
CachedReadTokens int64 `json:"cachedReadTokens"`
ThoughtTokens int64 `json:"thoughtTokens"`
}
var lastModel string
var lastTimestamp string
scanner := bufio.NewScanner(f)
scanner.Buffer(make([]byte, 0, 256*1024), 1024*1024)
for scanner.Scan() {
line := scanner.Bytes()
// Fast pre-filter.
if !bytesContains(line, `"usage"`) && !bytesContains(line, `"inputTokens"`) {
continue
}
var entry hermesLine
if err := json.Unmarshal(line, &entry); err != nil {
continue
}
if entry.Usage == nil {
continue
}
// Take the latest usage snapshot (cumulative).
lastUsage = entry.Usage
if entry.Model != "" {
lastModel = entry.Model
}
if entry.Timestamp != "" {
lastTimestamp = entry.Timestamp
}
}
if lastUsage == nil {
return nil
}
if lastUsage.InputTokens == 0 && lastUsage.OutputTokens == 0 {
return nil
}
// Parse timestamp for date.
var date string
if lastTimestamp != "" {
if ts, err := time.Parse(time.RFC3339Nano, lastTimestamp); err == nil {
date = ts.Local().Format("2006-01-02")
} else if ts, err := time.Parse(time.RFC3339, lastTimestamp); err == nil {
date = ts.Local().Format("2006-01-02")
}
}
if date == "" {
// Fall back to file modification time.
if info, err := os.Stat(path); err == nil {
date = info.ModTime().Local().Format("2006-01-02")
} else {
return nil
}
}
model := lastModel
if model == "" {
model = "unknown"
}
return &Record{
Date: date,
Provider: "hermes",
Model: model,
InputTokens: lastUsage.InputTokens,
OutputTokens: lastUsage.OutputTokens + lastUsage.ThoughtTokens,
CacheReadTokens: lastUsage.CachedReadTokens,
}
}

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