Compare commits

..

180 Commits

Author SHA1 Message Date
Bohan Jiang
beeb8bc107 Merge pull request #361 from JimmyPang02/fix/issue-mention-on-comment
fix: issue mentions should not suppress on_comment trigger
2026-04-03 18:42:16 +08:00
Naiyuan Qing
5548d60dbb Revert "fix(issues): prevent sticky mini bar oscillation with height placeholder"
This reverts commit 9fb25f4543.
2026-04-03 18:35:52 +08:00
Naiyuan Qing
9fb25f4543 fix(issues): prevent sticky mini bar oscillation with height placeholder
When the agent live card collapses to sticky mode, its height drops from
~320px to ~40px. This layout shift caused content below to jump up,
re-triggering IntersectionObserver and creating an infinite loop.

Fix: capture the card's expanded height before collapsing, then set
minHeight on a wrapper div to preserve the space. Content below stays
put, sentinel stays out of view, no oscillation.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-03 18:30:09 +08:00
Bohan Jiang
33140d4c5a docs(web): add v0.1.6 changelog entry (#391)
* docs(web): add v0.1.5 changelog entry for 2026-04-02

* docs(web): add v0.1.6 changelog entry for 2026-04-03
2026-04-03 16:39:56 +08:00
Jiayuan Zhang
9b8cc0870b Merge pull request #388 from multica-ai/agent/lambda/1286014b
feat(cli): add agent, skill, and runtime management commands
2026-04-03 16:14:37 +08:00
Naiyuan Qing
ce40b66c60 Merge pull request #390 from multica-ai/feat/agent-live-card-sticky-minibar
feat(issues): sticky mini bar for agent live card + toast icon colors
2026-04-03 16:09:27 +08:00
Naiyuan Qing
56b49cb2a6 feat(issues): use ActorAvatar in agent live card header
Replace hand-rolled Bot icon circle with ActorAvatar component so
agent custom avatars display correctly, consistent with comment cards
and other agent-rendered UI.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-03 16:08:12 +08:00
Naiyuan Qing
4353340ea6 feat(issues): sticky mini bar for agent live card + toast icon colors
Agent live card now uses the sentinel pattern to detect when it scrolls
out of view. When stuck, it collapses to a compact header bar with brand
styling and backdrop blur, with a ChevronUp button to scroll back.
When scrolled back into view, the card seamlessly expands to full view.

Also adds semantic colors to Sonner toast icons (success/info/warning/
error/loading) and fixes icon-to-text alignment in toasts globally.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-03 16:05:35 +08:00
Jiayuan
91cbf32fd1 fix(cli): address code review feedback for agent/skill/runtime commands
- Add --config flag to skill create/update (accepts JSON string)
- Add --runtime-config flag to agent create/update (accepts JSON string)
- Add --yes flag to skill delete with confirmation prompt
- Improve agent skills set error message (guide users to --skill-ids '')
- Validate --days range (1-365) in runtime usage
- Include last known status in ping/update timeout errors
2026-04-03 16:00:12 +08:00
Jiayuan
10b482fac2 feat(cli): add agent, skill, and runtime management commands
Expand CLI coverage for agent/skill/runtime APIs that previously had no
CLI wrappers despite having fully implemented backend endpoints.

Agent commands (was: only list):
- agent get/create/update/archive/restore/tasks
- agent skills list/set
- agent list --include-archived

Skill commands (new, was: 0% coverage):
- skill list/get/create/update/delete/import
- skill files list/upsert/delete

Runtime commands (new, was: 0% coverage):
- runtime list/usage/activity/ping/update
- ping and update support --wait for polling
2026-04-03 15:41:17 +08:00
Jiayuan Zhang
0024208354 Merge pull request #387 from multica-ai/fix/filter-archived-agents-from-dropdowns
fix(web): filter archived agents from dropdown selectors
2026-04-03 15:41:01 +08:00
Bohan Jiang
32a3a3543d docs(web): add v0.1.5 changelog entry for 2026-04-02 (#386) 2026-04-03 15:40:15 +08:00
Jiayuan
e314badf18 fix(web): filter archived agents from all dropdown selectors
Add `!a.archived_at` check to agent filters in create-issue modal,
issues-header filter panel, issue-detail assignee dropdown, and
issue-detail subscriber list.

assignee-picker and mention-suggestion already filter correctly.
2026-04-03 15:39:27 +08:00
Bohan Jiang
fc6405e4be fix(trigger): allow on_comment when thread root @mentions assignee agent (#382)
When a member-started thread root @mentions the assignee agent, replies
in that thread should trigger on_comment — the thread is a conversation
with the agent, not a member-to-member chat.

Previously isReplyToMemberThread only checked the reply content for
assignee mentions. Now it also checks the parent (thread root) content.
This fixes a gap where path 1 (on_comment) suppressed the trigger and
path 2 (on_mention) skipped the assignee, leaving no trigger path.
2026-04-03 15:07:39 +08:00
devv-eve
7b610a4013 feat(agents): hide archived agents from default list (#373)
* feat(agents): hide archived agents from default list

Archived agents are now filtered out of the default agent list view.
A toggle button (archive icon) appears when archived agents exist,
allowing users to switch between viewing active and archived agents.
The @mention suggestion list already filters out archived agents.

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

* fix(agents): show "No active agents" when all agents are archived

When there are archived agents but no active ones, the empty state now
shows "No active agents" instead of "No agents yet" to avoid confusion.

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

---------

Co-authored-by: Devv <devv@Devvs-Mac-mini.local>
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-03 13:59:36 +08:00
Bohan Jiang
978e81a268 fix(inbox): prevent comment highlight from re-triggering on every timeline change (#374)
The useEffect that scrolls to and highlights a comment had timeline.length
in its dependency array. When a new reply was posted, timeline.length changed,
re-triggering the scroll and highlight animation. Added a ref to track whether
we've already highlighted for the current highlightCommentId so it only fires once.
2026-04-03 13:47:20 +08:00
Bohan Jiang
c9c8230271 feat(trigger): inherit thread-root mentions for reply-triggered agent tasks (#375)
When a top-level comment @mentions an agent (non-assignee), subsequent
replies in the same thread now also trigger that agent via on_mention.
Previously only the current comment's mentions were checked, so replies
without an explicit re-mention would silently skip the agent.

Extends enqueueMentionedAgentTasks to accept the parent comment and
merge its parsed mentions (deduplicated) into the trigger set, reusing
all existing guards (self-trigger, assignee skip, visibility, dedup).

Closes MUL-177
2026-04-03 13:46:07 +08:00
Naiyuan Qing
b84543e634 Merge pull request #371 from multica-ai/refactor/editor-unified-markdown-pipeline
refactor(editor): unify editor with single markdown pipeline
2026-04-03 11:22:58 +08:00
Naiyuan Qing
6c651f4be5 docs(editor): annotate key files with design decisions and pitfalls
Add architecture comments to content-editor.tsx, markdown-paste.ts,
extensions/index.ts, mention-view.tsx, content-editor.css, and
preprocess.ts explaining: why single markdown pipeline, why
data-pm-slice for paste detection, typography benchmarks, mention
card sizing rationale, and what was removed from the old system.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-03 11:21:54 +08:00
Naiyuan Qing
b5924ffa99 fix(editor): reliable markdown paste and inline code line spacing
Markdown paste: replace heuristic-based detection with a single
deterministic check — only use HTML clipboard path when source is
another ProseMirror editor (identified by data-pm-slice attribute).
All other pastes (VS Code, text editors, terminals, .md files) parse
text/plain as Markdown via @tiptap/markdown.

Inline code: add box-decoration-break: clone and line-height: 2 so
multi-line inline code renders with proper spacing between lines.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-03 11:17:03 +08:00
Naiyuan Qing
30640436c4 refactor(editor): style links with brand color and subtle underline
- Use var(--brand) instead of var(--primary) for link color (blue vs
  near-black)
- Add default underline at 40% opacity, full opacity on hover
- Remove Tailwind HTMLAttributes from Link extensions — let CSS control
  all link styling uniformly
- Mention cards unaffected (a.issue-mention overrides with color: inherit)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-03 10:47:19 +08:00
Naiyuan Qing
eb355dbc9c fix(editor): improve mention card sizing and vertical alignment
- Restore card to readable size (py-0.5, px-2, text-xs, rounded-md)
- Add max-w-72 and truncate on title for long issue names
- Move vertical-align: middle to [data-node-view-wrapper] (outermost
  inline element) instead of inner <a> — fixes centering within line
- Always render card style even when issue not in store (fallback shows
  identifier only)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-03 10:44:04 +08:00
Naiyuan Qing
f34ed091e7 fix(editor): make issue mention card fit within text line height
Reduce card dimensions so it doesn't overflow the paragraph line box:
- Padding: py-0.5 → py-px (4px → 1px vertical)
- Font size: text-sm → text-xs with leading-relaxed
- Alignment: vertical-align: middle via CSS selector

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-03 10:33:08 +08:00
Naiyuan Qing
d9a6b8c8ed refactor(editor): polish typography and fix mention card alignment
Typography adjustments based on GitHub/Tailwind prose research:
- Heading sizes: h1 22px, h2 18px, h3 15px (was 18/16/14 — h3 was
  indistinguishable from body text)
- Paragraph spacing: 10px (was 8px)
- List indentation: 20px for ul (was 16px)
- Code block margin: 12px (was 8px)
- Blockquote border: 3px (was 2px)

Issue mention card fixes:
- Vertical alignment: align-text-bottom (was align-middle, caused
  upward shift)
- Internal gap: gap-2 (was gap-1.5)
- Horizontal padding: px-2.5 (was px-2)
- External margin: mx-0.5 for breathing room with surrounding text

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-03 10:29:41 +08:00
Naiyuan Qing
27e58d91af refactor(editor): unify editor into features/editor with single markdown pipeline
Replace three divergent data paths (Marked HTML loading, regex post-processing
saving, separate paste parsing) with one symmetric path through @tiptap/markdown.

Key changes:
- Create features/editor/ module with ContentEditor (unified edit+readonly)
  and TitleEditor, replacing components/common/ editor files
- Load content via contentType: 'markdown' instead of markdownToHtml() hack
- Save content via editor.getMarkdown() directly, no post-processing
- Merge RichTextEditor + ReadonlyEditor into single ContentEditor with
  editable prop
- Extract extensions into separate modules (mention, file-upload,
  markdown-paste, submit-shortcut, code-block-view)
- Extract shared preprocessMentionShortcodes to components/markdown/mentions.ts
- Add copyMarkdown utility for clipboard operations
- Upgrade all @tiptap packages from 3.20.5 to 3.22.1 (lexer isolation fix,
  HTML entity roundtrip fix, table alignment support)
- Delete markdownToHtml.ts, readonly-editor.tsx, and 10 old component files

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-03 10:28:29 +08:00
Jimmy Peng
6799458807 fix: issue mentions should not suppress on_comment trigger 2026-04-03 01:12:38 +08:00
devv-eve
8eb1caa72b fix(agent): instruct agents to use download_url for attachments (#356)
* fix(agent): instruct agents to use download_url for attachments

Agents were not aware of the signed vs unsigned URL distinction in
attachments, causing failures when trying to read images. Added an
Attachments section to the generated CLAUDE.md/AGENTS.md template that
tells agents to always use `download_url`. Also increased signed URL
expiry from 5 to 30 minutes to better accommodate agent processing time.

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

* feat(cli): add `multica attachment download` command

Adds a dedicated CLI command for downloading attachments by ID. The
command fetches attachment metadata from the API (which returns a fresh
signed URL), downloads the file, and saves it locally. This eliminates
the need for agents to understand signed vs unsigned URLs.

Changes:
- New `multica attachment download <id>` CLI command
- New `GET /api/attachments/{id}` backend endpoint
- `DownloadFile` helper on APIClient
- Updated CLAUDE.md template to document the command

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

* fix(cli): sanitize filename and add download size limit

- Use filepath.Base on attachment filename to prevent path traversal
- Add 100MB size limit to DownloadFile (matches upload limit)
- Include response body in download error messages for debugging

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

---------

Co-authored-by: Devv <devv@Devvs-Mac-mini.local>
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-02 07:45:42 -07:00
Naiyuan Qing
35b379d688 Merge pull request #354 from multica-ai/refactor/editor-typography-polish
refactor(editor): polish typography for better visual hierarchy
2026-04-02 20:14:08 +08:00
Naiyuan Qing
392a8d7c8c refactor(editor): polish typography for better visual hierarchy
- Heading hierarchy: H1 bumped to 1.125rem with letter-spacing to
  distinguish from H2 (both were 1rem). Margins normalized to 0.5rem
  baseline rhythm.
- List items: increased spacing from 0.125rem to 0.25rem for readability.
  Remove paragraph margins inside list items (Tiptap wraps li content
  in <p> tags which inherited 0.5rem margins).
- Nested lists: bullet style progression (disc → circle → square) and
  numbering progression (decimal → lower-alpha → lower-roman).
- Blockquotes: tighter paragraph spacing inside, nested blockquotes get
  lighter border for depth indication.
- Inline code: border-radius uses semantic --radius-sm token.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-02 20:10:45 +08:00
Naiyuan Qing
fd744c331e Merge pull request #352 from multica-ai/agent/naiyuan-agent/2a52f202
fix(inbox): parse issue query param on initial page load
2026-04-02 19:32:38 +08:00
Naiyuan Qing
a98f165458 Merge pull request #353 from multica-ai/fix/editor-markdown-rendering
fix(editor): reliable markdown rendering via marked HTML pipeline
2026-04-02 19:26:18 +08:00
Naiyuan Qing
097630c733 fix(editor): reliable markdown rendering via marked HTML pipeline
Replace @tiptap/markdown's beta contentType: "markdown" parser with a
dedicated marked-based HTML pipeline for loading markdown content.

The @tiptap/markdown parser silently drops content in complex documents
(tables, nested lists, mentions). Instead, we now:

1. Pre-convert mention links to <span data-type="mention"> HTML
2. Render markdown to HTML via a dedicated Marked instance with a custom
   renderer that wraps table cell content in <p> tags (required by
   Tiptap's TableCell block+ content spec)
3. Load as HTML — Tiptap's ProseMirror HTML parser handles everything
4. Keep @tiptap/markdown extension only for getMarkdown() serialization

Also adds Table extension support and aligns CSS with the old Markdown
component's minimal mode styling.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-02 19:24:33 +08:00
Naiyuan Qing
c3cca50f27 fix(inbox): parse issue query param on initial page load
Use useState for selectedKey instead of deriving directly from
useSearchParams(), so the issue ID from ?issue=<id> is reliably
captured on mount. window.history.replaceState() doesn't always
sync back to useSearchParams() in Next.js, causing the detail
panel to show empty when entering via a shared inbox link.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-02 18:51:14 +08:00
LinYushen
36ba23b3cd fix(test): use auth.JWTSecret() in integration tests instead of hardcoded secret (#349)
The integration tests hardcoded the old default JWT secret while .env
sets a different JWT_SECRET, causing all authenticated requests to fail
with 401. Use auth.JWTSecret() so tests stay in sync with the server.

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-02 18:21:21 +08:00
Naiyuan Qing
5df444ba00 Merge pull request #347 from multica-ai/agent/naiyuan-agent/00dfb0e6
fix(realtime): prevent full refetch on issue/inbox WS events
2026-04-02 18:17:07 +08:00
Naiyuan Qing
e6a1ff4354 Merge pull request #348 from multica-ai/fix/unify-image-upload-flow
fix(editor): unify image upload flow for paste and button
2026-04-02 18:05:11 +08:00
Naiyuan Qing
7cc4e63e0e fix(editor): unify image upload flow for paste and button
Paste/drop and attachment button previously used separate upload paths.
The button uploaded first then called insertFile (which replaced the
current selection), while paste inserted a blob preview first. This
caused the second image to overwrite the first when both were used.

Now both paths share the same flow via uploadAndInsertFile():
blob preview with uploading animation → background upload → replace URL.

- Extract shared uploadAndInsertFile() function
- Replace insertFile ref method with uploadFile (inserts at doc end)
- Simplify FileUploadButton to onSelect(file) — no more onUpload/onInsert
- Wire onUploadFile in comment edit mode (was missing, upload was no-op)
- Unify image border-radius CSS for both editing and readonly modes

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-02 18:02:28 +08:00
Quake Wang
36db325d50 feat(daemon): add opencode as supported agent provider (#341)
* feat(daemon): add opencode as supported agent provider

Add opencode backend alongside claude and codex. The backend spawns
`opencode run --format json`, parses streaming JSON events (text,
tool_use, error, step_start/finish), and supports --prompt for system
prompts. Includes CLI detection, AGENTS.md runtime config, native skill
discovery via .config/opencode/skills/, and 21 tests covering handlers,
JSON parsing, and integration-level processEvents scenarios.

* chore: add .tool-versions to gitignore
2026-04-02 17:52:07 +08:00
Naiyuan Qing
d751373368 fix(realtime): handle issue/inbox events granularly to prevent full refetch
When viewing an issue in the inbox, WS events like issue:updated and
inbox:new triggered full store refetches, causing unnecessary loading
flashes and redundant API calls. Now these events update the store
in-place using the event payload data instead of refetching everything.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-02 17:36:44 +08:00
LinYushen
09764c5f51 feat(agent): replace hard delete with archive/restore (#346)
* feat(agent): replace hard delete with archive/restore

Replace agent deletion with soft archive pattern. Archived agents
are preserved in the database with all historical references intact
but cannot be assigned, mentioned, or trigger tasks.

Backend:
- Add archived_at/archived_by columns to agent table (migration 031)
- Replace DELETE /api/agents/{id} with POST /api/agents/{id}/archive
- Add POST /api/agents/{id}/restore endpoint
- ListAgents excludes archived by default (?include_archived=true to include)
- Skip archived agents in task triggers (on_assign, on_comment, on_mention)
- Block assignment to archived agents
- Cancel pending tasks on archive
- New events: agent:archived, agent:restored (replacing agent:deleted)

Frontend:
- Agent type includes archived_at/archived_by fields
- Mention autocomplete and assignee picker filter out archived agents
- Agent list shows archived agents with muted styling
- Agent detail shows archive banner with restore button
- Delete button replaced with Archive button and updated confirmation dialog
- API client: archiveAgent/restoreAgent replace deleteAgent

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

* fix(agent): self-review fixes for archive feature

- Fix: workspace store now fetches agents with include_archived=true
  so archived agents are actually visible in the frontend (the archived
  UI was dead code before — ListAgents excludes archived by default)
- Fix: add error logging for CancelAgentTasksByAgent in ArchiveAgent
- Fix: add idempotency guards — return 409 Conflict when archiving
  an already-archived agent or restoring a non-archived agent
- Fix: revert unnecessary extra GetAgent query in ReconcileAgentStatus
  (archived agents won't have running tasks after CancelAgentTasksByAgent)

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-02 17:33:52 +08:00
Naiyuan Qing
565afed447 Merge pull request #345 from multica-ai/fix/link-sticky-cursor
fix(editor): prevent link mark from sticking to cursor
2026-04-02 17:01:35 +08:00
Naiyuan Qing
222f60d2dd fix(editor): prevent link mark from sticking to cursor
Override Link extension `inclusive: false` via `.extend()` to decouple
it from `autolink: true`. Tiptap's source ties `inclusive` to `autolink`,
causing typed text after a link to inherit the link mark.

Also set `linkOnPaste: false` — autolink's PasteRule still auto-detects
pasted URLs without the sticky cursor issue.

Refs: ueberdosis/tiptap#2571, ueberdosis/tiptap#4249

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-02 16:59:20 +08:00
Naiyuan Qing
e8c2a8eff9 Merge pull request #342 from multica-ai/NevilleQingNY/fetch-latest
feat(web): skeleton loading, error toasts, confirmation dialogs
2026-04-02 16:49:36 +08:00
Naiyuan Qing
7f0cb106bd feat(web): add skeleton loading, error toasts, and confirmation dialogs
- Replace all "Loading..." text with structured skeleton screens
  (Issue Detail, Agents, Skills, Runtimes, Tokens, Usage)
- Add toast.error for all API failures that were previously silent
  (Agents CRUD, Skills CRUD, workspace store, issue/inbox stores,
   timeline/reactions/subscribers hooks, agent-live-card)
- Add toast.success for mutations (agent update/delete, skill CRUD)
- Add confirmation dialogs for destructive actions
  (comment delete, token revoke)
- Add empty states for Issues and My Issues pages
- Fix hydrateWorkspace resilience: each request catches independently
  so partial failures don't block workspace entry
- Fix React key warning in issue-detail timeline rendering

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-02 16:46:56 +08:00
Naiyuan Qing
c7fda85a3e Merge pull request #340 from multica-ai/feat/unified-tiptap-editor
feat(editor): unify Tiptap editor for editing and readonly display
2026-04-02 16:40:02 +08:00
Naiyuan Qing
b8b4731602 fix(editor): use ProseMirror schema for image upload state
Address code review feedback:
- Replace rAF + DOM query with Image extension `uploading` attribute
  managed by ProseMirror schema (no race conditions)
- Remove redundant removeAttribute call (setNodeMarkup rebuilds DOM)
- Restore pulse animation on img[data-uploading] for upload feedback
- Remove dev mock from use-file-upload.ts (was blocking real uploads)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-02 16:36:59 +08:00
Naiyuan Qing
fe975fb2bb feat(editor): unify Tiptap editor for editing and readonly display
Replace react-markdown in comment/reply display with Tiptap readonly mode,
ensuring visual consistency between editing and viewing. Extract shared
BaseMentionExtension with MentionView NodeView used in both modes — issue
mentions render as inline cards with StatusIcon, clickable to open in new tab.
Redesign mention suggestion popup with grouped sections (Users/Issues),
agent badges, and StatusIcon for issues.

- New: mention-extension.ts (shared mention core)
- New: mention-view.tsx (shared NodeView for both modes)
- New: readonly-editor.tsx (lightweight Tiptap readonly wrapper)
- Modified: rich-text-editor.tsx (import from shared mention-extension)
- Modified: rich-text-editor.css (readonly + issue-mention overrides)
- Modified: comment-card.tsx (Markdown → ReadonlyEditor)
- Modified: mention-suggestion.tsx (grouped UI, StatusIcon, agent badge)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-02 16:35:52 +08:00
LinYushen
fc0ef0fcd8 fix(ci): update backend Go version from 1.24 to 1.26.1 (#337)
Align the CI backend job with the Go version declared in server/go.mod
and used in the Dockerfile (golang:1.26-alpine).

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-02 16:11:05 +08:00
Bohan Jiang
62b7c0cfa2 docs: add Quickstart section to README (#335)
Add an end-to-end onboarding guide covering login, daemon setup,
runtime verification, agent creation, and first task assignment.
Updated both English and Chinese READMEs.
2026-04-02 15:39:45 +08:00
LinYushen
606930725a feat(daemon): support direct download update for non-Homebrew installs (#334)
* feat(daemon): support direct download update for non-Homebrew installs

Previously, CLI auto-update only worked for Homebrew installations. Non-brew
binaries would fail with "not installed via Homebrew". Now the daemon and
`multica update` fall back to downloading the release binary directly from
GitHub Releases when Homebrew is not detected.

Also fixes:
- Daemon restart now uses the current executable's absolute path instead of
  searching PATH, ensuring the updated binary is used
- Brew installs preserve the symlink path so the new Cellar version is picked up
- Daemon startup logs now include the CLI version
- Update UI auto-clears "restarting" status after 5s to show the new version

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

* fix(cli): remove dead DetectNewBinaryPath and guard against nil latest version

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-02 15:38:06 +08:00
Naiyuan Qing
ed9aef8f39 Merge pull request #333 from multica-ai/agent/naiyuan-agent/074f02da
feat(web): add canonical URL, robots directive, and login tagline update
2026-04-02 14:50:51 +08:00
Naiyuan Qing
856a254252 fix(web): update login page test to match new tagline
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-02 14:41:40 +08:00
LinYushen
682dc20ba9 fix(runtime): display multica CLI version instead of agent CLI version (#332)
The runtime detail page was showing the agent CLI version (claude/codex)
as "CLI Version" because metadata.version stored the agent version from
agent.DetectVersion(). The multica CLI version was never sent.

Fix: daemon now sends cli_version in the registration request, server
stores it as metadata.cli_version alongside the existing agent version,
and frontend reads metadata.cli_version.

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-02 14:40:35 +08:00
Naiyuan Qing
17418f37b2 feat(web): update SEO metadata based on landing page
Align website title, description, and meta tags with the landing page
messaging. Add Open Graph, Twitter Card tags, sitemap.ts, and robots.ts.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-02 14:28:28 +08:00
LinYushen
eba2e7eacf Merge pull request #327 from multica-ai/agent/j/53c0348f
feat(inbox): auto-scroll to comment and jump-to-bottom button
2026-04-02 14:28:26 +08:00
LinYushen
fdba410f11 feat(runtime): support CLI update from web runtime page (#331)
* feat(runtime): support CLI update from web runtime page

Add the ability to update the CLI daemon from the web Runtime detail page.
When a newer version is available on GitHub Releases, an update button
appears. Clicking it sends an update command through the server to the
daemon via the heartbeat mechanism (same pattern as ping). The daemon
executes `brew upgrade`, reports the result, and restarts itself with the
new binary.

Changes across all three layers:
- Frontend: version display, GitHub latest check, UpdateSection component
- Server: UpdateStore (in-memory), heartbeat extension, 3 new endpoints
- CLI: shared update logic, daemon handleUpdate + graceful restart

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

* fix(runtime): handle 'running' status in ReportUpdateResult

The daemon sends {"status":"running"} when it starts executing the
update, but ReportUpdateResult treated any non-"completed" status as
failure — immediately marking the update as failed before brew upgrade
even ran.

Fix: use a switch statement to handle "running" as a no-op (status is
already "running" from PopPending), and also timeout running updates
after 120 seconds in case brew upgrade hangs.

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-02 14:12:49 +08:00
LinYushen
a80d61f8e1 fix(task): enforce per-issue serial execution in task claiming (#330)
Add NOT EXISTS check to ClaimAgentTask SQL to prevent claiming a queued
task when the same issue already has a dispatched/running task. This
ensures serial execution within an issue while preserving parallel
execution across different issues (concurrency group pattern).

Also add defensive guard in the frontend task:dispatch handler to avoid
replacing an active task's LiveLog timeline mid-execution.

Closes MUL-183

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-02 14:12:00 +08:00
LinYushen
ff616de82b fix(upload): remove file type allowlist to support all file types (#329)
* fix(upload): remove file type allowlist to support all file types

Removes the hardcoded MIME type allowlist from both frontend and backend
that was blocking uploads of file types like Word documents (.docx).
File size limit (10 MB) is still enforced. Content type detection is
preserved for metadata storage.

Closes MUL-123

* feat(upload): increase file size limit from 10 MB to 100 MB

Updates both frontend and backend to allow uploads up to 100 MB.
2026-04-02 13:55:50 +08:00
Bohan Jiang
f353e8db59 feat(mentions): support @mentioning issues + server-side auto-expansion (#242)
* feat(mentions): support @mentioning issues in comments

- Extend MentionItem type to include "issue" alongside "member"/"agent"
- Add issue search (by identifier and title) to mention suggestion dropdown
- Render issue mentions with CircleDot icon in autocomplete popup
- Issue mentions serialize as [MUL-117 Title](mention://issue/id) (no @ prefix)
- Markdown renderer shows issue mentions as clickable links to /issues/:id
- Backend mentionRe regex updated to match issue mention type

* feat(mentions): auto-expand issue identifiers and add mention format to agent instructions

1. Path A — CLAUDE.md template (runtime_config.go):
   Add a "## Mentions" section teaching agents the mention serialization
   format for issues, members, and agents. All agents automatically
   receive this via the auto-generated CLAUDE.md.

2. Approach 2 — Server-side auto-conversion (internal/mention/):
   New ExpandIssueIdentifiers() utility that scans comment content for
   bare issue identifiers (e.g. MUL-117) and replaces them with
   [MUL-117](mention://issue/<uuid>) mention links. Skips code blocks,
   inline code, and existing markdown links. Integrated into both:
   - handler.CreateComment (HTTP API path)
   - service.createAgentComment (agent task output path)
2026-04-02 13:48:53 +08:00
Jiang Bohan
575bbd7f60 feat(inbox): auto-scroll to comment from notification and add jump-to-bottom button
When clicking an inbox notification, the issue detail now scrolls to and
briefly highlights the relevant comment. Also adds a floating "Jump to
bottom" button on issue pages with long timelines.

Backend: store comment_id in inbox notification details for new_comment
and reaction_added events. Frontend: pass highlightCommentId through to
IssueDetail, add id attributes to comment elements, and track scroll
position for the jump-to-bottom button.
2026-04-02 13:43:05 +08:00
LinYushen
cd1b1155c1 Merge pull request #324 from multica-ai/fix/agent-permission-model
fix(agent): revise agent permission model for visibility and mentions
2026-04-02 12:51:09 +08:00
yushen
68da1efd74 fix(agent): revise agent permission model for visibility and mentions
- ListAgents: private agents are now visible to all workspace members
  (previously hidden from non-owner members)
- Mentions: private agents can only be @mentioned by the agent owner or
  workspace admin/owner; regular members' mentions of private agents are
  silently ignored
- Settings (update/delete/skills) and assign were already correctly
  restricted in previous PRs

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-02 12:45:31 +08:00
LinYushen
bea739cba5 Merge pull request #322 from multica-ai/fix/agent-skill-permission
fix(agent): allow members to manage skills on their own agents
2026-04-02 12:36:50 +08:00
LinYushen
b88c7f1afa Merge pull request #321 from multica-ai/agent/yushen-claude/1fe82e4b
feat(web): extract Repositories into standalone settings tab
2026-04-02 12:26:14 +08:00
yushen
f05f3face3 fix(agent): allow members to manage skills on their own agents
SetAgentSkills previously only allowed workspace owner/admin roles,
blocking members from adding skills to their own agents. Now uses
canManageAgent which allows agent owners too.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-02 12:25:19 +08:00
yushen
bf713d3ad5 feat(web): extract Repositories into standalone settings tab
Move the Repositories section from the General workspace settings page
into its own dedicated tab in the Settings sidebar, making it a
first-class entry alongside General and Members. This reduces the
navigation depth from 3 clicks + scroll to a single click.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-02 12:14:59 +08:00
LinYushen
ac06e7f4a3 fix(agent): restrict agent management to owner and workspace admins (#320)
Members could previously modify any workspace-visible agent. Now only
the agent owner or workspace owner/admin can update or delete an agent,
regardless of visibility.

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-02 12:13:45 +08:00
Jiayuan Zhang
0659865645 Merge pull request #314 from multica-ai/agent/lambda/0fa89561
feat(cli): add issue runs and run-messages commands
2026-04-02 03:37:16 +08:00
Jiayuan
ab505fd39c docs: add issue runs and run-messages to CLI documentation
Update the dynamic agent instructions (runtime_config.go) and the
CLI reference (CLI_AND_DAEMON.md) to document the new execution
history commands.
2026-04-02 03:34:25 +08:00
Jiayuan Zhang
7b19ad7ccc Merge pull request #313 from multica-ai/agent/lambda/9c6590aa
fix(server): broadcast sweeper task:failed events to correct workspace
2026-04-02 03:33:37 +08:00
Jiayuan
6b0c9bba9e feat(cli): add issue runs and run-messages commands
Add two new CLI commands so agents can access execution history:
- `multica issue runs <issue-id>` lists all task executions for an issue
- `multica issue run-messages <task-id>` lists messages for an execution

Also adds --since query param support to the ListTaskMessages backend
handler for incremental message fetching.
2026-04-02 03:30:33 +08:00
Jiayuan
60e66f32b3 test(server): add integration tests for sweeper event broadcasting fix
Tests verify:
- Stale running tasks: task:failed event has WorkspaceID set
- Agent status reconciliation: agent returns to "idle" after sweep
- Stale dispatched tasks: same correctness for dispatch timeout path
2026-04-02 03:30:32 +08:00
Jiayuan
eb35bc5dc9 fix(server): broadcast sweeper task:failed events to correct workspace
The runtime sweeper was publishing task:failed events without a
WorkspaceID, causing them to be silently dropped by the WS listener.
This meant frontends never received notification when stale/orphaned
tasks were failed by the sweeper — the live log card kept showing
"Agent is working" and the agent status remained "working" indefinitely.

- Look up workspace_id from issue table for each swept task
- Set WorkspaceID on published events so they reach the correct WS room
- Reconcile agent status after sweeping so agents return to "idle"
2026-04-02 03:20:59 +08:00
Jiayuan Zhang
99fdf39c50 Merge pull request #265 from multica-ai/agent/lambda/f37e2ab8
docs: redesign README for public repo
2026-04-02 01:59:21 +08:00
Jiayuan
e4b2053d90 docs: align README messaging with landing page copy
- Replace "project management platform" framing with landing page
  positioning: "turns coding agents into real teammates"
- Use landing page hero subheading for tagline
- Rewrite "What is Multica?" to emphasize agent autonomy, not PM features
- Update features to match landing page sections (autonomous execution,
  reusable skills, unified runtimes)
- Apply same changes to Chinese README using landing page zh translations
2026-04-02 01:56:29 +08:00
Jiayuan
1264a1fa67 docs: fix Cloud link and add Chinese README
- Change Cloud link from app.multica.ai to multica.ai/app
- Add README.zh-CN.md with full Chinese translation
- Add language switcher (English | 简体中文) to both READMEs
2026-04-02 01:46:16 +08:00
Jiayuan
c4a24c1a86 docs: refine README layout per feedback
- Move banner image to very top, remove feature background images
- Move product screenshot into "What is Multica?" section
- Fix logo SVG: use icon-only (no text) for reliable GitHub rendering
- Use markdown heading for "Multica" text instead of SVG text element
- Clean up features into a single bullet list
2026-04-02 01:09:38 +08:00
Jiayuan
479d69305d docs: redesign README with landing page content and images
- Add centered header with logo (dark/light mode), badges, and nav links
- Use landing page headline "Your next 10 hires won't be human."
- Add hero banner illustration and product screenshot from landing page
- Feature sections with inline images (teammates, runtimes)
- Rewrite feature descriptions to match landing page messaging
- Add architecture table alongside diagram
- Create logo SVGs in docs/assets/
- All links verified against main branch (CLI_AND_DAEMON.md, CONTRIBUTING.md, etc.)
2026-04-02 00:46:02 +08:00
Jiayuan Zhang
85d00fde57 Merge pull request #312 from multica-ai/fix/at-all-suppress-trigger
fix(server): @all mentions should not trigger agent execution
2026-04-02 00:37:26 +08:00
Jiayuan
02c9d9f0b0 fix(server): @all mentions should not trigger agent execution
@all is a broadcast to all workspace members — it should not trigger
the assignee agent's on_comment. Previously @all was treated as
"includes everyone" and allowed the trigger.

Changes:
- commentMentionsOthersButNotAssignee now checks HasMentionAll() early
  and returns true (suppress) when @all is present
- Fix authRequestWithAgent test helper that was making a duplicate HTTP
  request (one as member, one as agent)

Tests: 5 new @all unit test cases, 2 new @all integration test cases.
2026-04-02 00:33:21 +08:00
Jiayuan Zhang
05fcf35ab9 Merge pull request #311 from multica-ai/agent/lambda/18dbd9cb
docs(web): add v0.1.4 changelog for 2026-04-01
2026-04-02 00:27:35 +08:00
Jiayuan
f315e55cd6 docs(web): add v0.1.4 changelog entry for 2026-04-01
Add changelog for April 1st releases covering My Issues page,
i18n support, about/changelog pages, agent avatars, attachment
improvements, unified avatar rendering, and Apache 2.0 license.
2026-04-02 00:24:04 +08:00
Jiayuan Zhang
8f1526d2bb Merge pull request #306 from multica-ai/agent/lambda/832eb090
fix(server): improve comment trigger logic for agent execution
2026-04-01 22:28:52 +08:00
Naiyuan Qing
9add49b832 Merge pull request #308 from multica-ai/revert-307-feat/global-search
Revert "feat: add global issue search"
2026-04-01 22:24:58 +08:00
Naiyuan Qing
b28bac6bb7 Revert "feat: add global issue search" 2026-04-01 22:24:35 +08:00
Naiyuan Qing
59ebf30cf0 Merge pull request #307 from multica-ai/feat/global-search
feat: add global issue search
2026-04-01 22:13:17 +08:00
Jiayuan
b41536467d test(server): add integration tests for comment trigger logic
End-to-end tests through the full HTTP router + real database:

TestCommentTriggerOnComment (6 subtests):
- Top-level comment without mentions → triggers agent
- Top-level comment mentioning only others → suppresses trigger
- Top-level comment mentioning assignee → triggers agent
- Reply to agent thread without mentions → triggers agent
- Reply to member thread without mentions → suppresses trigger (Bohan's bug)
- Reply to member thread mentioning assignee → triggers agent

TestCommentTriggerOnAssignNoStatusGate:
- Assigning agent to in_progress issue → triggers (no todo restriction)

TestCommentTriggerOnMentionNoStatusGate:
- @mentioning agent on done issue → triggers (no status gate)

TestCommentTriggerCoalescing:
- Rapid-fire comments → only 1 task created (dedup)
2026-04-01 22:11:46 +08:00
Naiyuan Qing
40d29bea50 feat: add global issue search with sidebar button and modal
Add search functionality to quickly find issues by title:
- Backend: add search param (ILIKE) to ListIssues query
- Frontend: search modal using CommandDialog with skeleton loading
- Sidebar: ghost-style search button next to create issue button
- Handle CJK input method composition to avoid premature searches
- Responsive max-height for small screens

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-01 22:11:28 +08:00
Jiayuan
4f0c2afcb7 test(server): add unit tests for comment trigger logic
Tests cover all on_comment trigger decision scenarios:
- commentMentionsOthersButNotAssignee: 6 cases
- isReplyToMemberThread: 7 cases
- Combined trigger decision: 9 cases matching the full decision table
- agentHasTriggerEnabled: 7 cases (nil, empty, explicit, backwards compat)
- defaultAgentTriggers: validates all 3 triggers present and enabled
2026-04-01 22:01:33 +08:00
Jiayuan
eb5aaf003c fix(server): remove status gates from on_assign and on_mention triggers
on_assign: Remove the todo-only restriction. Assignment is an explicit
human action — if someone assigns an agent to a done/in_progress issue,
they want the agent triggered (e.g. to fix a problem found after close).

on_mention: Remove the done/cancelled check. @mention is an explicit
action and should work on any issue status. The agent can reopen the
issue if needed.
2026-04-01 21:46:20 +08:00
Jiayuan
04da4bc3b7 fix(server): improve comment trigger logic for agent execution
- Add thread-aware on_comment suppression: when a member replies in a
  thread started by another member without @mentioning the assignee
  agent, the on_comment trigger is now suppressed. This fixes the bug
  where member-to-member conversations incorrectly triggered the
  assigned agent.

- Add terminal status check to on_mention: enqueueMentionedAgentTasks
  now skips done/cancelled issues, consistent with on_comment behavior.

- Write explicit default triggers on agent creation: new agents get
  [on_assign, on_comment, on_mention] all enabled, instead of relying
  on null/empty = all enabled. Existing agents with empty triggers
  still work via backwards-compat fallback in agentHasTriggerEnabled.

- Consolidate trigger check logic into shared agentHasTriggerEnabled
  helper, fixing inconsistency where empty [] was handled differently
  by isAgentTriggerEnabled (returned false) vs isAgentMentionTriggerEnabled
  (returned true).

- Add documentation comments explaining the intentional status gate
  difference: on_assign fires only for todo (start new work), while
  on_comment fires for any non-terminal status (conversational).
2026-04-01 21:37:33 +08:00
Jiayuan Zhang
6b9341f7ad Merge pull request #304 from multica-ai/agent/lambda/95b033ec
feat(mentions): support @all to mention all workspace members
2026-04-01 21:03:28 +08:00
Jiayuan
095b7f8185 feat(mentions): support @all to mention all workspace members
Add @all mention type that notifies all workspace members (excluding
agents). Includes backend parsing, notification expansion to all members,
and frontend UI with autocomplete suggestion, rendering, and hover card.
2026-04-01 20:58:33 +08:00
Naiyuan Qing
e68091e4a8 Merge pull request #302 from multica-ai/fix/header-button-style-consistency
fix(web): mention picker avatar & keyboard scroll
2026-04-01 19:36:36 +08:00
Naiyuan Qing
8815d27e1d fix(web): use ActorAvatar in mention picker and fix keyboard scroll
- Replace inline initials/Bot icon with ActorAvatar component so
  mention suggestions show real profile pictures consistently
- Add scrollIntoView on keyboard navigation so the selected item
  stays visible when the list overflows

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-01 19:35:23 +08:00
Naiyuan Qing
71dec10201 Merge pull request #300 from multica-ai/fix/header-button-style-consistency
fix(web): unify header button style consistency
2026-04-01 19:07:19 +08:00
Naiyuan Qing
d602873a5c fix(web): unify header button text color across Issues and My Issues
Right-side icon buttons (filter, display, view) were using default foreground
color while left-side scope buttons used text-muted-foreground, causing visual
inconsistency.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-01 19:05:33 +08:00
yushen
9bcc35bf61 docs: add CLI release instructions to CLAUDE.md
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-01 18:54:18 +08:00
Naiyuan Qing
ae1c05af60 Merge pull request #293 from multica-ai/feature/issues-scope-tabs
feat(web): add scope tabs to Issues and My Issues pages
2026-04-01 18:49:07 +08:00
Naiyuan Qing
2b1b588187 Merge pull request #294 from multica-ai/NevilleQingNY/merge-dev-to-main
Merge dev into main
2026-04-01 18:43:27 +08:00
Naiyuan Qing
75b25539ab fix(web): remove duplicate ActorAvatar import from agents page
Auto-merge introduced a duplicate import line — removed the extra one.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-01 18:32:29 +08:00
Naiyuan Qing
815f16ddd6 Merge remote-tracking branch 'origin/dev' into NevilleQingNY/merge-dev-to-main
# Conflicts:
#	apps/web/features/issues/components/issue-detail.tsx
#	apps/web/features/issues/components/issues-header.tsx
#	apps/web/features/issues/components/pickers/assignee-picker.tsx
2026-04-01 18:31:28 +08:00
Naiyuan Qing
8719e2cedd feat(web): add scope tabs to Issues and My Issues pages
Redesign both Issues and My Issues headers with Linear-style layout:
- Left: scope pill buttons (All/Members/Agents for Issues; Assigned/Created/My Agents for My Issues)
- Right: compact icon buttons for Filter, Display, and View toggle
- Selected scope has accent background, all buttons use consistent outline variant
- Filter active indicator uses brand-colored dot
- Tooltips on all buttons with 500ms global delay
- Remove New Issue button and issue count from headers
- Scope selection persisted to localStorage

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-01 18:30:40 +08:00
Naiyuan Qing
808a0e9c38 Merge pull request #289 from multica-ai/NevilleQingNY/auth-root-redirect
feat(web): server-side redirect / → /issues for logged-in users
2026-04-01 18:25:49 +08:00
Naiyuan Qing
f1e5bc7925 feat(web): redirect logged-in users from / to /issues via server-side proxy
Use a lightweight cookie (multica_logged_in) + Next.js 16 proxy to
302-redirect authenticated users visiting / straight to /issues.
Unauthenticated visitors (and search engine crawlers) continue to see
the full landing page with zero flash.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-01 18:20:57 +08:00
LinYushen
20a2332c94 Merge pull request #285 from multica-ai/feature/attachment-improvements
feat: improve attachment support across issue and comment APIs
2026-04-01 18:11:57 +08:00
yushen
9c249f0770 feat(server,cli): improve attachment support across issue and comment APIs
- Add --attachment flag to `multica issue create` CLI command
- Fix CreateComment response to include linked attachments instead of empty array
- Include attachments inline in GetIssue API response (matching Jira/ClickUp pattern)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-01 18:10:43 +08:00
Naiyuan Qing
8db970cd17 Merge pull request #270 from multica-ai/fix/avatar-consistency-clean
refactor(web): unify all avatar rendering with ActorAvatar
2026-04-01 17:23:19 +08:00
Naiyuan Qing
a18a24f904 Merge pull request #273 from multica-ai/NevilleQingNY/my-issues-kanban
feat(web): My Issues kanban board + list view + filtering
2026-04-01 17:17:26 +08:00
Naiyuan Qing
421e0a2429 Merge pull request #272 from multica-ai/feature/homepage-seo-optimization
feat(web): SEO optimization and auth flow improvements
2026-04-01 17:11:14 +08:00
Naiyuan Qing
09376dc879 Merge pull request #269 from multica-ai/NevilleQingNY/my-issues-kanban
feat(web): My Issues kanban board + list view + filtering
2026-04-01 17:02:33 +08:00
Naiyuan Qing
4d74091f8d refactor(web): unify all avatar rendering with ActorAvatar
Replace all inline avatar implementations (initials divs, Bot icons,
inline img tags) with the shared ActorAvatar component for consistency.

- Extend AssigneePicker with controlled open/onOpenChange, triggerRender,
  and align props to support batch toolbar and other contexts
- Replace BatchAssigneePicker (~130 lines) with shared AssigneePicker
- Replace issue-detail sidebar inline DropdownMenu with AssigneePicker
- Add canAssignAgent filtering to issue-detail more menu
- Replace inline avatars in: filter panel, members-tab, agents page,
  mention-hover-card, subscribers AvatarGroup
- Add data-slot="avatar" to ActorAvatar for AvatarGroup compatibility
- Add triggerRender prop to PropertyPicker for custom trigger elements

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-01 16:58:43 +08:00
Naiyuan Qing
fb8829fc12 Merge pull request #268 from multica-ai/feature/homepage-seo-optimization
feat(web): SEO optimization and auth flow improvements
2026-04-01 16:57:44 +08:00
Naiyuan Qing
3c5a3b5e6a feat(web): add kanban board + list view + filtering to My Issues page
Upgrade /my-issues from a simple accordion to a full-featured view
matching /issues — kanban board, list view, status/priority filtering,
sorting, and display settings, scoped to the user's own issues.

Key changes:
- Extract view store factory (createIssueViewStore) using zustand v5
  vanilla createStore + React Context for shared component reuse
- Create ViewStoreProvider + useViewStore/useViewStoreApi hooks
- Decouple BoardView, BoardColumn, BoardCard, ListView from global
  useIssueViewStore — they now read from context
- New independent persisted store for /my-issues (multica_my_issues_view)
- Simplified MyIssuesHeader (no assignee/creator filters)
- Pre-filter logic: assigned to me ∪ my agents ∪ created by me
- Generalize workspace sync to clear filters on all registered stores
- Fix existing debt: text-[10px] → text-xs, w-44 → w-auto, reduce
  unnecessary selector subscriptions in both headers

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-01 16:56:22 +08:00
Naiyuan Qing
3bd504fe5f feat(web): SEO optimization and auth flow improvements for landing pages
- Split landing pages into Server/Client Components to enable Next.js metadata exports
- Add robots.ts, sitemap.ts, JSON-LD structured data, OpenGraph and viewport config
- Fix i18n hydration mismatch: detect locale server-side via cookie/Accept-Language header
- Replace localStorage with cookie for locale persistence (SSR-readable)
- Dynamic <html lang> based on locale cookie
- Optimize images with next/image (avif/webp formats, quality config)
- Add /homepage route as always-visible landing page (no auth redirect)
- Auth-aware CTA buttons: show "Dashboard"/"进入工作台" for logged-in users
- Login page redirects authenticated users to dashboard
- Unify logout/401 redirect to "/" instead of "/login"
- Fix title template to avoid double "Multica" suffix on homepage

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-01 16:54:11 +08:00
LinYushen
39c5cf2cbe Merge pull request #261 from multica-ai/agent/j/7ba59661
feat(agents): add avatar upload to agent settings page
2026-04-01 16:18:00 +08:00
LinYushen
005025b05c fix(server): allow @agent mentions to trigger regardless of issue status (#267)
Remove terminal status (done/cancelled) checks that blocked @agent
mention triggers and task claiming. Agents should always be triggerable
via explicit @mentions, regardless of the issue's current status.

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-01 16:15:59 +08:00
Jiayuan Zhang
7204626f1d Merge pull request #262 from yihong0618/hy/make_ascii_show_better
docs: make ascii Architecture show better
2026-04-01 16:11:19 +08:00
Naiyuan Qing
57cb32f09a Merge pull request #256 from multica-ai/fix/assignee-avatar-consistency
refactor(web): unify assignee dropdowns with ActorAvatar
2026-04-01 16:04:34 +08:00
Naiyuan Qing
1e4cd346ef fix(web): resolve agents/page.tsx merge conflict with dev (avatar upload feature)
Keep ActorAvatar for agent list/detail avatars over dev's inline
img+initials. Also unify the new avatar upload preview to use
ActorAvatar instead of getInitials helper.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-01 16:03:17 +08:00
Bohan Jiang
f933b5f3a7 Merge pull request #266 from multica-ai/agent/j/7ba59661
feat(agents): add avatar upload to agent settings page
2026-04-01 16:00:13 +08:00
LinYushen
3e96240cec docs: align AGENTS.md with CLAUDE.md content (#263)
AGENTS.md was a minimal 17-line summary while CLAUDE.md had comprehensive
project documentation. Updated AGENTS.md to include all sections from
CLAUDE.md (architecture, state management, backend structure, UI/UX rules,
worktree support, E2E patterns, verification loop) while preserving
AGENTS.md-unique details (naming conventions, test directory, PR guidelines).

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-01 15:58:20 +08:00
Jiayuan Zhang
1bc491fc65 Merge pull request #253 from theowenyoung/patch-1
Update installation command for multica-cli
2026-04-01 15:57:30 +08:00
LinYushen
98e7d27acc feat(cli): add --attachment flag to issue comment add (#260)
Add file attachment support to `multica issue comment add`. The CLI
uploads files via multipart form to /api/upload-file, collects the
returned attachment IDs, and passes them when creating the comment.

Usage: multica issue comment add <issue-id> --content "..." --attachment file1.png --attachment file2.pdf

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-01 15:57:23 +08:00
Naiyuan Qing
cc922367be fix(web): resolve merge conflicts with dev and unify remaining inline avatars
Merge dev into fix/assignee-avatar-consistency, keeping our unified
AssigneePicker over dev's inline DropdownMenu additions.

Also replace remaining inline avatar implementations with ActorAvatar:
- members-tab.tsx: member list row initials → ActorAvatar
- agents/page.tsx: agent list item & detail header initials → ActorAvatar
- mention-hover-card.tsx: inline Bot icon → ActorAvatar
- issue-detail.tsx: subscribers AvatarGroup fallback → ActorAvatar
- actor-avatar.tsx: add data-slot="avatar" for AvatarGroup compatibility

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-01 15:52:48 +08:00
yihong0618
c36b6f3e07 docs: make ascii Architecture show better
Signed-off-by: yihong0618 <zouzou0208@gmail.com>
2026-04-01 15:52:15 +08:00
Jiang Bohan
ab48eafe61 feat(agents): add avatar upload to agent settings page
Add avatar upload UI to the agent SettingsTab, matching the existing
member avatar upload pattern. Also update the agent list item and
detail header to display the uploaded avatar image.
2026-04-01 15:49:01 +08:00
Bohan Jiang
5ec4bcd627 Merge pull request #241 from multica-ai/agent/j/7fc3e0e2
feat(agent): add task cancellation with stop button
2026-04-01 15:43:37 +08:00
Naiyuan Qing
f891a5bbd7 refactor(web): unify assignee dropdowns with ActorAvatar and shared AssigneePicker
- Replace inline initials/Bot divs with ActorAvatar across all assignee UIs
- Replace issue-detail sidebar DropdownMenu with shared AssigneePicker
- Delete BatchAssigneePicker (~130 lines), reuse AssigneePicker in controlled mode
- Add controlled mode (open/onOpenChange), align, and triggerRender props to AssigneePicker/PropertyPicker
- Add canAssignAgent visibility check to issue-detail more menu
- Clean up unused imports (Bot, useAuthStore, useWorkspaceStore, etc.)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-01 15:37:33 +08:00
Bohan Jiang
daaa4deaf7 Merge pull request #255 from multica-ai/agent/j/7fc3e0e2
Agent/j/7fc3e0e2
2026-04-01 14:58:54 +08:00
Naiyuan Qing
fe963d672d Merge pull request #254 from multica-ai/NevilleQingNY/sync-remote
fix(web): delay TitleEditor autofocus for Dialog animation
2026-04-01 14:35:00 +08:00
Owen
0d214dc321 Update installation command for multica-cli 2026-04-01 14:28:18 +08:00
Naiyuan Qing
946b4a277e fix(web): delay TitleEditor autofocus to avoid Dialog animation conflict
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-01 14:15:31 +08:00
Jiayuan Zhang
a2eb16995e Merge pull request #251 from multica-ai/forrestchang-patch-1
fix(docs): Update URL for getting started with Multica
2026-04-01 14:11:41 +08:00
Jiayuan Zhang
094962d9aa fix(docs): Update URL for getting started with Multica 2026-04-01 14:11:24 +08:00
Jiayuan Zhang
9a00fd5146 Merge pull request #250 from multica-ai/agent/lambda/4c494a4f
docs: add CLI/Daemon guide and rename LOCAL_DEVELOPMENT → CONTRIBUTING
2026-04-01 14:07:07 +08:00
Jiayuan
0f2dfdbca8 docs: add CLI/Daemon guide and rename LOCAL_DEVELOPMENT.md to CONTRIBUTING.md
- Add CLI_AND_DAEMON.md with full command reference, daemon configuration,
  profiles, and self-hosted setup instructions
- Rename LOCAL_DEVELOPMENT.md → CONTRIBUTING.md for conventional naming
- Update README.md references and link to the new CLI guide
2026-04-01 14:05:19 +08:00
Jiayuan Zhang
c6e67bd41e Merge pull request #249 from multica-ai/agent/lambda/32ad91e3
chore: change license to Apache 2.0
2026-04-01 13:46:24 +08:00
yushen
1a0d8c282a feat(cli): default to production URLs (api.multica.ai, multica.ai)
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-01 13:27:58 +08:00
Jiayuan
c52c6e69c3 chore: change license to Apache 2.0
Add Apache License 2.0 LICENSE file and update .goreleaser.yml
reference from MIT to Apache-2.0.
2026-04-01 13:18:57 +08:00
Jiang Bohan
4780540bd2 merge: resolve conflicts with main 2026-04-01 13:12:23 +08:00
Bohan Jiang
8218d7e87d Merge pull request #247 from multica-ai/fix/issue-mention-rendering
fix(issues): use Markdown component for read-only comment display
2026-04-01 13:06:27 +08:00
Naiyuan Qing
57e48c1d6b Merge pull request #246 from multica-ai/forrestchang/landing-page
feat(web): add landing page with i18n (EN/中文)
2026-04-01 08:20:53 +08:00
Naiyuan Qing
afdefa9b65 Merge branch 'dev' of https://github.com/multica-ai/multica into dev 2026-04-01 08:19:40 +08:00
Naiyuan Qing
a4d222da46 Merge branch 'forrestchang/landing-page' into dev 2026-04-01 08:19:11 +08:00
Jiayuan
bf0765de26 chore(web): update next-env.d.ts routes path 2026-04-01 05:51:28 +08:00
Jiayuan
7e2b328e3c feat(web): add i18n support to landing page with Simplified Chinese
- Add dictionary-based i18n system (en/zh) with React Context provider
- Extract all hardcoded text from landing components into translation dictionaries
- Auto-detect browser language, persist preference in localStorage
- Add language switcher (EN/中文) to footer
- Add Noto Serif SC font for Chinese serif headings
- Rewrite about page Chinese copy with user-provided translation
2026-04-01 05:47:40 +08:00
Jiayuan
0b4c6b3910 feat(web): add about, changelog pages and fix landing header for light backgrounds
- Rewrite about page with Multica name origin (Multics → multiplexed
  agents) and project philosophy
- Replace placeholder changelog with real entries from git history
  (v0.1.0–v0.1.3)
- Add variant prop to LandingHeader (dark/light) so it renders
  correctly on white-background subpages
- Extract landing page into separate component files
2026-04-01 05:16:24 +08:00
Jiayuan Zhang
82e45fb0a2 Merge pull request #245 from multica-ai/forrestchang/fix-dropdown-avatars
fix(ui): show avatar images in assignee dropdowns
2026-04-01 04:29:27 +08:00
Jiayuan
c0166e5264 fix(ui): show avatar images in assignee dropdowns
Replace inline div placeholders (initials/Bot icon) with ActorAvatar
component so actual user and agent avatar images render in all assignee
picker dropdowns.
2026-04-01 03:50:47 +08:00
Jiayuan
e9ce376b96 feat(web): add how-it-works, open-source, FAQ, and footer sections to landing page
Add four new sections: How it Works (4-step onboarding guide), Open Source
(self-host / no lock-in highlights), FAQ (centered accordion), and Footer
(link columns + giant logo with Game of Life animation).
2026-04-01 00:18:25 +08:00
Jiayuan
11041052d0 feat(web): add features section and product hero image to landing page
Add scroll-driven features section with 4 tabs (Teammates, Autonomous,
Skills, Runtimes) using IntersectionObserver for auto-switching on scroll.
Replace hero placeholder with actual product screenshot. Remove background
gradient overlay.
2026-03-31 23:02:23 +08:00
Jiayuan
99ac6a6736 feat(web): add multica landing page 2026-03-31 22:18:19 +08:00
Bohan Jiang
ed3bcaea6b Merge pull request #244 from multica-ai/fix/issue-mention-rendering
fix(issues): use Markdown component for read-only comment display
2026-03-31 19:29:12 +08:00
Jiang Bohan
449011e60b fix(issues): use Markdown component for read-only comment display
The RichTextEditor in read-only mode doesn't support IssueMentionCard
rendering for issue mentions with status, title, and navigation.
Switch to the Markdown component which already handles mention://issue/
links with the IssueMentionCard component.
2026-03-31 19:25:31 +08:00
Naiyuan Qing
16efcaca43 Merge pull request #243 from multica-ai/fix/inbox-header-scroll
fix(inbox): pin header and only scroll content in list panel
2026-03-31 19:17:57 +08:00
Naiyuan Qing
e2453cc040 fix(inbox): pin header and only scroll content in inbox list panel
The inbox left panel had the header inside the overflow-y-auto container,
causing it to scroll away with the list items. Changed to flex-col layout
with shrink-0 header and flex-1 overflow-y-auto content area.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-31 19:16:23 +08:00
Naiyuan Qing
b589d733ca fix(inbox): pin header and only scroll content in inbox list panel
The inbox left panel had the header inside the overflow-y-auto container,
causing it to scroll away with the list items. Changed to flex-col layout
with shrink-0 header and flex-1 overflow-y-auto content area.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-31 19:13:47 +08:00
Bohan Jiang
62eafe5f99 Merge pull request #237 from multica-ai/agent/j/f5e0099c
fix(server): subscribe @mentioned users in issue descriptions
2026-03-31 19:00:07 +08:00
Jiayuan Zhang
7188fd8f2a Merge pull request #236 from multica-ai/agent/lambda/b34769c1
fix(issues): show execution logs for mention-triggered agent tasks
2026-03-31 18:52:19 +08:00
Jiayuan Zhang
46ea12e4cf Merge pull request #238 from multica-ai/agent/lambda/6279e21b
fix(inbox): remove hardcoded 50-item limit from inbox list query
2026-03-31 18:44:51 +08:00
Jiang Bohan
365b5b5f0e feat(agent): add task cancellation with stop button
Users can now interrupt running agents via a "Stop" button on the live
card. The daemon polls task status every 5 seconds and kills the agent
process when cancellation is detected.

Changes:
- New CancelAgentTask SQL query and CancelTask service method
- POST /api/issues/{id}/tasks/{taskId}/cancel endpoint
- Daemon polls GetTaskStatus during execution, cancels context on match
- Frontend: Stop button on AgentLiveCard, task:cancelled WS event
2026-03-31 18:43:37 +08:00
Naiyuan Qing
4c3d9ed1a1 Merge pull request #239 from multica-ai/feature/editor-ux-improvements
feat(ui): editor UX improvements — lowlight, upload, emoji, comment editing
2026-03-31 18:39:36 +08:00
Naiyuan Qing
9a37af4ca1 feat(ui): editor UX improvements — lowlight, upload, emoji, comment editing
- New QuickEmojiPicker: shared SmilePlus + 8 quick emojis + full picker
- New FileUploadButton: reusable Paperclip upload trigger
- New CodeBlockView: React NodeView with language label + copy button
- CodeBlockLowlight: syntax highlighting in editor (replaces plain codeBlock)
- ReactionBar: brand-tinted pill styles, hideAddButton prop
- Comment header: emoji picker + three-dot menu in top-right
- Comment edit: inline editing with brand border, blur-to-save, Escape-to-cancel
- RichTextEditor: add onBlur prop, markdown paste extension
- Create issue: upload button in footer
- Issue detail: upload button next to reaction bar
- Comment/reply: use FileUploadButton, loading spinners, no optimistic updates

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-31 18:37:53 +08:00
Jiayuan
38cad92e7e fix(inbox): remove hardcoded 50-item limit from inbox list query
The ListInbox endpoint defaulted to LIMIT 50 while the frontend fetched
all items without pagination, causing items beyond the first 50 to be
silently dropped.
2026-03-31 18:36:41 +08:00
Jiang Bohan
c8ab2e8642 fix(server): subscribe @mentioned users in issue descriptions
When a user @mentions someone in an issue description (create or update),
the mentioned users were notified via inbox but not added as subscribers.
This adds subscriber registration for mentioned users in both the
issue:created and issue:updated event listeners, mirroring the existing
notification logic.
2026-03-31 18:36:15 +08:00
Jiayuan
68ca281761 test(issues): add missing api mocks for agent task endpoints
Add getActiveTaskForIssue, listTasksByIssue, and listTaskMessages mocks
to the issue detail page test. These are now called unconditionally since
the assigneeType guard was removed.
2026-03-31 18:33:23 +08:00
Jiayuan
0431ee2ee0 fix(issues): show execution logs for mention-triggered agent tasks
AgentLiveCard and TaskRunHistory were gated on assigneeType === "agent",
so mention-triggered tasks on non-agent-assigned issues never showed
their execution logs. Remove that guard so any issue with agent tasks
displays live output and execution history.

Also populate IssueID in ListTaskMessages response so the live card's
WS event filtering works correctly on catch-up after reconnect.
2026-03-31 18:27:37 +08:00
Jiayuan Zhang
3f612c37f2 Merge pull request #230 from multica-ai/agent/lambda/bba7b0bd
fix(web): show executing agent name in task log title
2026-03-31 17:56:54 +08:00
Jiayuan Zhang
00af622d0f Merge pull request #235 from multica-ai/agent/lambda/d99edc96
docs: rewrite README and add self-hosting guide
2026-03-31 17:56:10 +08:00
Jiayuan
400739f3c9 docs: rewrite README for users and add self-hosting guide
Rewrite README.md from a developer-focused quick start to a user-facing
project overview covering features, cloud vs self-host, CLI usage, and
architecture. Add SELF_HOSTING.md with complete deployment instructions
including configuration reference, database setup, reverse proxy examples
(Caddy/nginx), agent daemon setup, and upgrade procedures.
2026-03-31 17:51:05 +08:00
Naiyuan Qing
03d2a5546c Merge pull request #232 from multica-ai/fix/inbox-issue-detail-key
fix(inbox): reset IssueDetail state on issue switch
2026-03-31 17:17:41 +08:00
Naiyuan Qing
9a6925bc8b fix(agents): add key to AgentDetail to reset state on agent switch
Same issue as inbox: without a key, React reuses the AgentDetail
instance when switching agents. This causes activeTab and SettingsTab
form values (name, description, visibility, maxTasks) to persist
from the previously selected agent.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-31 17:07:23 +08:00
Bohan Jiang
d0ad32a5b9 Merge pull request #233 from multica-ai/agent/j/298fd11b
feat(web): redesign issue creation toast to match Linear
2026-03-31 17:04:48 +08:00
Jiayuan
f2289bd733 fix(test): make createComment assertion resilient to trailing undefined args
On CI (Node 22 / ubuntu), mockCreateComment receives extra undefined
args. Check only the first two positional args instead of exact match.
2026-03-31 17:04:29 +08:00
Jiang Bohan
68217d2967 feat(web): redesign issue creation toast to match Linear's style
Replace the small one-line sonner toast with a richer card showing:
- Green checkmark with "Issue created" title
- Status icon + issue identifier + title on second line
- Clickable "View issue" link to navigate to the new issue
2026-03-31 17:02:23 +08:00
Jiayuan
259f2823bc fix(web): show executing agent name in task log, not assigned agent
When a task is executed by a mentioned agent (not the assigned one),
the live card now resolves the agent name from activeTask.agent_id
instead of using the assignee-based agentName prop.
2026-03-31 16:58:50 +08:00
Jiayuan
c902460eae fix(server): suppress assignee on_comment when mentions target others
When a comment @mentions anyone but not the assignee agent, the
assignee's on_comment trigger is now suppressed. This prevents the
assignee agent from being re-triggered when users share results with
colleagues or ask other agents for help.

The rule: @mention is an intent signal — if you're talking to someone
else, the assignee agent should not respond.
2026-03-31 16:58:50 +08:00
Jiayuan
3646ec5a53 feat(server): trigger agents via @mention in comments
When a user @mentions an agent in any issue's comment, the system now
enqueues a task for that agent. The agent reads the issue context and
replies to the triggering comment thread.

Changes:
- Add shared util.ParseMentions for mention parsing (used by both
  comment handler and notification listeners)
- Add EnqueueTaskForMention to TaskService for explicit agent targeting
- Add on_mention trigger type support in agent trigger config
- Add HasPendingTaskForIssueAndAgent SQL query for per-agent dedup
- Add enqueueMentionedAgentTasks in CreateComment handler

Safety: prevents self-trigger (agent mentioning itself), dedup with
assignee on_comment trigger, terminal issue status check, and per-agent
pending task dedup.
2026-03-31 16:58:50 +08:00
Naiyuan Qing
e2c466ffa1 fix(inbox): add key to IssueDetail to reset state on issue switch
Without a key, React reuses the IssueDetail component instance when
switching between inbox items. This causes stale internal state
(e.g. TaskRunHistory tasks) from the previous issue to persist,
showing execution history from a different issue.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-31 16:58:05 +08:00
190 changed files with 14622 additions and 2712 deletions

View File

@@ -57,7 +57,7 @@ jobs:
- name: Setup Go
uses: actions/setup-go@v5
with:
go-version: "1.24"
go-version: "1.26.1"
cache-dependency-path: server/go.sum
- name: Build

1
.gitignore vendored
View File

@@ -36,6 +36,7 @@ apps/web/test-results/
# local settings
.claude/
.tool-versions
# feature tracking
_features/

View File

@@ -47,7 +47,7 @@ brews:
directory: Formula
homepage: "https://github.com/multica-ai/multica"
description: "Multica CLI — local agent runtime and management tool for the Multica platform"
license: "MIT"
license: "Apache-2.0"
install: |
bin.install "multica"
test: |

278
AGENTS.md
View File

@@ -1,16 +1,274 @@
# Repository Guidelines
## Project Structure & Module Organization
`apps/web/` contains the Next.js 16 frontend: routes live in `app/`, reusable UI in `components/`, feature code in `features/`, test utilities in `test/`, and static assets in `public/`. `server/` contains the Go backend: entry points are in `cmd/{server,multica,migrate}`, application logic lives in `internal/`, migrations are in `migrations/`, and SQL lives under `pkg/db/queries/` with generated sqlc output in `pkg/db/generated/`. `e2e/` holds Playwright coverage. `scripts/` and the root `Makefile` drive local setup and verification.
This file provides guidance to AI agents when working with code in this repository.
## Build, Test, and Development Commands
Use `make setup` for first-time setup: it installs dependencies, ensures PostgreSQL is running, and applies migrations. Use `make start` to run backend and frontend together with `.env` or `.env.worktree`. For single-surface work, use `pnpm dev:web` for the frontend and `make dev` for the Go server. Run `pnpm test` for Vitest, `make test` for Go tests, and `make check` for the full pipeline: typecheck, frontend unit tests, Go tests, then Playwright. After changing SQL in `server/pkg/db/queries/*.sql`, run `make sqlc`.
## Project Context
## Coding Style & Naming Conventions
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 should stay `gofmt`-clean and use domain-oriented filenames like `issue.go` or `cmd_issue.go`. Do not hand-edit generated code in `server/pkg/db/generated/`.
Multica is an AI-native task management platform — like Linear, but with AI agents as first-class citizens.
## Testing Guidelines
Frontend unit tests use Vitest with Testing Library and shared setup from `apps/web/test/`. 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. Backend tests use Gos standard `*_test.go` pattern. Add or update tests whenever you change handlers, CLI commands, daemon behavior, or SQL-backed flows.
- 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
## Commit & Pull Request Guidelines
Recent history follows conventional commits with scopes, for example `feat(web): ...`, `fix(cli): ...`, `refactor(daemon): ...`, `test(cli): ...`, and `docs: ...`. 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.
## Architecture
**Go backend + standalone Next.js frontend.**
- `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
### Web App Structure (`apps/web/`)
The frontend uses a **feature-based architecture** with four layers:
```
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
```
**`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
```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
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")
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
```
### 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
});
```

View File

@@ -149,7 +149,7 @@ make db-down # Stop shared PostgreSQL
### CI Requirements
CI runs on Node 22 and Go 1.24 with a `pgvector/pgvector:pg17` PostgreSQL service. See `.github/workflows/ci.yml`.
CI runs on Node 22 and Go 1.26.1 with a `pgvector/pgvector:pg17` PostgreSQL service. See `.github/workflows/ci.yml`.
### Worktree Support
@@ -197,6 +197,16 @@ make start-worktree # Start using .env.worktree
- `test(scope): ...`
- `chore(scope): ...`
## CLI Release
**Prerequisite:** A CLI release must accompany every Production deployment. When deploying to Production, always release a new CLI version as part of the process.
1. Create a tag on the `main` branch: `git tag v0.x.x`
2. Push the tag: `git push origin v0.x.x`
3. GitHub Actions automatically triggers `release.yml`: runs Go tests → GoReleaser builds multi-platform binaries → publishes to GitHub Releases + Homebrew tap
By default, bump the patch version each release (e.g. `v0.1.12``v0.1.13`), unless the user specifies a specific version.
## Minimum Pre-Push Checks
```bash

345
CLI_AND_DAEMON.md Normal file
View File

@@ -0,0 +1,345 @@
# CLI and Agent Daemon Guide
The `multica` CLI connects your local machine to Multica. It handles authentication, workspace management, issue tracking, and runs the agent daemon that executes AI tasks locally.
## Installation
### Homebrew (macOS/Linux)
```bash
brew tap multica-ai/tap
brew install multica-cli
```
### Build from Source
```bash
git clone https://github.com/multica-ai/multica.git
cd multica
make build
cp server/bin/multica /usr/local/bin/multica
```
### Update
```bash
multica update
```
This auto-detects your installation method (Homebrew or manual) and upgrades accordingly.
## Quick Start
```bash
# 1. Authenticate (opens browser for login)
multica login
# 2. Start the agent daemon
multica daemon start
# 3. Done — agents in your watched workspaces can now execute tasks on your machine
```
`multica login` automatically discovers all workspaces you belong to and adds them to the daemon watch list.
## Authentication
### Browser Login
```bash
multica login
```
Opens your browser for OAuth authentication, creates a 90-day personal access token, and auto-configures your workspaces.
### Token Login
```bash
multica login --token
```
Authenticate by pasting a personal access token directly. Useful for headless environments.
### Check Status
```bash
multica auth status
```
Shows your current server, user, and token validity.
### Logout
```bash
multica auth logout
```
Removes the stored authentication token.
## Agent Daemon
The daemon is the local agent runtime. It detects available AI CLIs on your machine, registers them with the Multica server, and executes tasks when agents are assigned work.
### Start
```bash
multica daemon start
```
By default, the daemon runs in the background and logs to `~/.multica/daemon.log`.
To run in the foreground (useful for debugging):
```bash
multica daemon start --foreground
```
### Stop
```bash
multica daemon stop
```
### Status
```bash
multica daemon status
multica daemon status --output json
```
Shows PID, uptime, detected agents, and watched workspaces.
### Logs
```bash
multica daemon logs # Last 50 lines
multica daemon logs -f # Follow (tail -f)
multica daemon logs -n 100 # Last 100 lines
```
### Supported Agents
The daemon auto-detects these AI CLIs on your PATH:
| CLI | Command | Description |
|-----|---------|-------------|
| [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 |
You need at least one installed. The daemon registers each detected CLI as an available runtime.
### How It Works
1. On start, the daemon detects installed agent CLIs and registers a runtime for each agent in each watched workspace
2. It polls the server at a configurable interval (default: 3s) for claimed tasks
3. When a task arrives, it creates an isolated workspace directory, spawns the agent CLI, and streams results back
4. Heartbeats are sent periodically (default: 15s) so the server knows the daemon is alive
5. On shutdown, all runtimes are deregistered
### Configuration
Daemon behavior is configured via flags or environment variables:
| Setting | Flag | Env Variable | Default |
|---------|------|--------------|---------|
| Poll interval | `--poll-interval` | `MULTICA_DAEMON_POLL_INTERVAL` | `3s` |
| Heartbeat interval | `--heartbeat-interval` | `MULTICA_DAEMON_HEARTBEAT_INTERVAL` | `15s` |
| Agent timeout | `--agent-timeout` | `MULTICA_AGENT_TIMEOUT` | `2h` |
| Max concurrent tasks | `--max-concurrent-tasks` | `MULTICA_DAEMON_MAX_CONCURRENT_TASKS` | `20` |
| Daemon ID | `--daemon-id` | `MULTICA_DAEMON_ID` | hostname |
| Device name | `--device-name` | `MULTICA_DAEMON_DEVICE_NAME` | hostname |
| Runtime name | `--runtime-name` | `MULTICA_AGENT_RUNTIME_NAME` | `Local Agent` |
| Workspaces root | — | `MULTICA_WORKSPACES_ROOT` | `~/multica_workspaces` |
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 |
### Self-Hosted Server
When connecting to a self-hosted Multica instance, point the CLI to your server before logging in:
```bash
export MULTICA_APP_URL=https://app.example.com
export MULTICA_SERVER_URL=wss://api.example.com/ws
multica login
multica daemon start
```
Or set them persistently:
```bash
multica config set app_url https://app.example.com
multica config set server_url wss://api.example.com/ws
```
### Profiles
Profiles let you run multiple daemons on the same machine — for example, one for production and one for a staging server.
```bash
# Start a daemon for the staging server
multica --profile staging login
multica --profile staging daemon start
# Default profile runs separately
multica daemon start
```
Each profile gets its own config directory (`~/.multica/profiles/<name>/`), daemon state, health port, and workspace root.
## Workspaces
### List Workspaces
```bash
multica workspace list
```
Watched workspaces are marked with `*`. The daemon only processes tasks for watched workspaces.
### Watch / Unwatch
```bash
multica workspace watch <workspace-id>
multica workspace unwatch <workspace-id>
```
### Get Details
```bash
multica workspace get <workspace-id>
multica workspace get <workspace-id> --output json
```
### List Members
```bash
multica workspace members <workspace-id>
```
## Issues
### List Issues
```bash
multica issue list
multica issue list --status in_progress
multica issue list --priority urgent --assignee "Agent Name"
multica issue list --limit 20 --output json
```
Available filters: `--status`, `--priority`, `--assignee`, `--limit`.
### Get Issue
```bash
multica issue get <id>
multica issue get <id> --output json
```
### Create Issue
```bash
multica issue create --title "Fix login bug" --description "..." --priority high --assignee "Lambda"
```
Flags: `--title` (required), `--description`, `--status`, `--priority`, `--assignee`, `--parent`, `--due-date`.
### Update Issue
```bash
multica issue update <id> --title "New title" --priority urgent
```
### Assign Issue
```bash
multica issue assign <id> --to "Lambda"
multica issue assign <id> --unassign
```
### Change Status
```bash
multica issue status <id> in_progress
```
Valid statuses: `backlog`, `todo`, `in_progress`, `in_review`, `done`, `blocked`, `cancelled`.
### Comments
```bash
# List comments
multica issue comment list <issue-id>
# Add a comment
multica issue comment add <issue-id> --content "Looks good, merging now"
# Reply to a specific comment
multica issue comment add <issue-id> --parent <comment-id> --content "Thanks!"
# Delete a comment
multica issue comment delete <comment-id>
```
### Execution History
```bash
# List all execution runs for an issue
multica issue runs <issue-id>
multica issue runs <issue-id> --output json
# View messages for a specific execution run
multica issue run-messages <task-id>
multica issue run-messages <task-id> --output json
# Incremental fetch (only messages after a given sequence number)
multica issue run-messages <task-id> --since 42 --output json
```
The `runs` command shows all past and current executions for an issue, including running tasks. The `run-messages` command shows the detailed message log (tool calls, thinking, text, errors) for a single run. Use `--since` for efficient polling of in-progress runs.
## Configuration
### View Config
```bash
multica config show
```
Shows config file path, server URL, app URL, and default workspace.
### Set Values
```bash
multica config set server_url wss://api.example.com/ws
multica config set app_url https://app.example.com
multica config set workspace_id <workspace-id>
```
## Other Commands
```bash
multica version # Show CLI version and commit hash
multica update # Update to latest version
multica agent list # List agents in the current workspace
```
## Output Formats
Most commands support `--output` with two formats:
- `table` — human-readable table (default for list commands)
- `json` — structured JSON (useful for scripting and automation)
```bash
multica issue list --output json
multica daemon status --output json
```

View File

@@ -1,6 +1,6 @@
# Local Development Guide
# Contributing Guide
This guide documents the intended local development workflow for Multica.
This guide documents the local development workflow for contributors working on the Multica codebase.
It covers:

199
LICENSE Normal file
View File

@@ -0,0 +1,199 @@
Apache License
Version 2.0, January 2004
http://www.apache.org/licenses/
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
1. Definitions.
"License" shall mean the terms and conditions for use, reproduction,
and distribution as defined by Sections 1 through 9 of this document.
"Licensor" shall mean the copyright owner or entity authorized by
the copyright owner that is granting the License.
"Legal Entity" shall mean the union of the acting entity and all
other entities that control, are controlled by, or are under common
control with that entity. For the purposes of this definition,
"control" means (i) the power, direct or indirect, to cause the
direction or management of such entity, whether by contract or
otherwise, or (ii) ownership of fifty percent (50%) or more of the
outstanding shares, or (iii) beneficial ownership of such entity.
"You" (or "Your") shall mean an individual or Legal Entity
exercising permissions granted by this License.
"Source" form shall mean the preferred form for making modifications,
including but not limited to software source code, documentation
source, and configuration files.
"Object" form shall mean any form resulting from mechanical
transformation or translation of a Source form, including but
not limited to compiled object code, generated documentation,
and conversions to other media types.
"Work" shall mean the work of authorship, whether in Source or
Object form, made available under the License, as indicated by a
copyright notice that is included in or attached to the work
(an example is provided in the Appendix below).
"Derivative Works" shall mean any work, whether in Source or Object
form, that is based on (or derived from) the Work and for which the
editorial revisions, annotations, elaborations, or other modifications
represent, as a whole, an original work of authorship. For the purposes
of this License, Derivative Works shall not include works that remain
separable from, or merely link (or bind by name) to the interfaces of,
the Work and Derivative Works thereof.
"Contribution" shall mean any work of authorship, including
the original version of the Work and any modifications or additions
to that Work or Derivative Works thereof, that is intentionally
submitted to the Licensor for inclusion in the Work by the copyright owner
or by an individual or Legal Entity authorized to submit on behalf of
the copyright owner. For the purposes of this definition, "submitted"
means any form of electronic, verbal, or written communication sent
to the Licensor or its representatives, including but not limited to
communication on electronic mailing lists, source code control systems,
and issue tracking systems that are managed by, or on behalf of, the
Licensor for the purpose of discussing and improving the Work, but
excluding communication that is conspicuously marked or otherwise
designated in writing by the copyright owner as "Not a Contribution."
"Contributor" shall mean Licensor and any individual or Legal Entity
on behalf of whom a Contribution has been received by the Licensor and
subsequently incorporated within the Work.
2. Grant of Copyright License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
copyright license to reproduce, prepare Derivative Works of,
publicly display, publicly perform, sublicense, and distribute the
Work and such Derivative Works in Source or Object form.
3. Grant of Patent License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
(except as stated in this section) patent license to make, have made,
use, offer to sell, sell, import, and otherwise transfer the Work,
where such license applies only to those patent claims licensable
by such Contributor that are necessarily infringed by their
Contribution(s) alone or by combination of their Contribution(s)
with the Work to which such Contribution(s) was submitted. If You
institute patent litigation against any entity (including a
cross-claim or counterclaim in a lawsuit) alleging that the Work
or a Contribution incorporated within the Work constitutes direct
or contributory patent infringement, then any patent licenses
granted to You under this License for that Work shall terminate
as of the date such litigation is filed.
4. Redistribution. You may reproduce and distribute copies of the
Work or Derivative Works thereof in any medium, with or without
modifications, and in Source or Object form, provided that You
meet the following conditions:
(a) You must give any other recipients of the Work or
Derivative Works a copy of this License; and
(b) You must cause any modified files to carry prominent notices
stating that You changed the files; and
(c) You must retain, in the Source form of any Derivative Works
that You distribute, all copyright, patent, trademark, and
attribution notices from the Source form of the Work,
excluding those notices that do not pertain to any part of
the Derivative Works; and
(d) If the Work includes a "NOTICE" text file as part of its
distribution, then any Derivative Works that You distribute must
include a readable copy of the attribution notices contained
within such NOTICE file, excluding any notices that do not
pertain to any part of the Derivative Works, in at least one
of the following places: within a NOTICE text file distributed
as part of the Derivative Works; within the Source form or
documentation, if provided along with the Derivative Works; or,
within a display generated by the Derivative Works, if and
wherever such third-party notices normally appear. The contents
of the NOTICE file are for informational purposes only and
do not modify the License. You may add Your own attribution
notices within Derivative Works that You distribute, alongside
or as an addendum to the NOTICE text from the Work, provided
that such additional attribution notices cannot be construed
as modifying the License.
You may add Your own copyright statement to Your modifications and
may provide additional or different license terms and conditions
for use, reproduction, or distribution of Your modifications, or
for any such Derivative Works as a whole, provided Your use,
reproduction, and distribution of the Work otherwise complies with
the conditions stated in this License.
5. Submission of Contributions. Unless You explicitly state otherwise,
any Contribution intentionally submitted for inclusion in the Work
by You to the Licensor shall be under the terms and conditions of
this License, without any additional terms or conditions.
Notwithstanding the above, nothing herein shall supersede or modify
the terms of any separate license agreement you may have executed
with Licensor regarding such Contributions.
6. Trademarks. This License does not grant permission to use the trade
names, trademarks, service marks, or product names of the Licensor,
except as required for reasonable and customary use in describing the
origin of the Work and reproducing the content of the NOTICE file.
7. Disclaimer of Warranty. Unless required by applicable law or
agreed to in writing, Licensor provides the Work (and each
Contributor provides its Contributions) on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
implied, including, without limitation, any warranties or conditions
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
PARTICULAR PURPOSE. You are solely responsible for determining the
appropriateness of using or redistributing the Work and assume any
risks associated with Your exercise of permissions under this License.
8. Limitation of Liability. In no event and under no legal theory,
whether in tort (including negligence), contract, or otherwise,
unless required by applicable law (such as deliberate and grossly
negligent acts) or agreed to in writing, shall any Contributor be
liable to You for damages, including any direct, indirect, special,
incidental, or consequential damages of any character arising as a
result of this License or out of the use or inability to use the
Work (including but not limited to damages for loss of goodwill,
work stoppage, computer failure or malfunction, or any and all
other commercial damages or losses), even if such Contributor
has been advised of the possibility of such damages.
9. Accepting Warranty or Additional Liability. While redistributing
the Work or Derivative Works thereof, You may choose to offer,
and charge a fee for, acceptance of support, warranty, indemnity,
or other liability obligations and/or rights consistent with this
License. However, in accepting such obligations, You may act only
on Your own behalf and on Your sole responsibility, not on behalf
of any other Contributor, and only if You agree to indemnify,
defend, and hold each Contributor harmless for any liability
incurred by, or claims asserted against, such Contributor by reason
of your accepting any such warranty or additional liability.
END OF TERMS AND CONDITIONS
APPENDIX: How to apply the Apache License to your work.
To apply the Apache License to your work, attach the following
boilerplate notice, with the fields enclosed by brackets "[]"
replaced with your own identifying information. (Don't include
the brackets!) The text should be enclosed in the appropriate
comment syntax for the file format. Please also get an
"Implied Patent License" from your patent counsel.
Copyright 2025 Multica
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.

331
README.md
View File

@@ -1,237 +1,154 @@
<p align="center">
<img src="docs/assets/banner.jpg" alt="Multica — humans and agents, side by side" width="100%">
</p>
<div align="center">
<picture>
<source media="(prefers-color-scheme: dark)" srcset="docs/assets/logo-dark.svg">
<source media="(prefers-color-scheme: light)" srcset="docs/assets/logo-light.svg">
<img alt="Multica" src="docs/assets/logo-light.svg" width="50">
</picture>
# Multica
AI-native task management platform — like Linear, but with AI agents as first-class citizens.
**Your next 10 hires won't be human.**
For the full local development workflow, see [Local Development Guide](LOCAL_DEVELOPMENT.md).
Open-source platform that turns coding agents into real teammates.<br/>
Assign tasks, track progress, compound skills — manage your human + agent workforce in one place.
## Prerequisites
[![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)
- [Node.js](https://nodejs.org/) (v20+)
- [pnpm](https://pnpm.io/) (v10.28+)
- [Go](https://go.dev/) (v1.26+)
- [Docker](https://www.docker.com/)
[Website](https://multica.ai) · [Cloud](https://multica.ai/app) · [Self-Hosting](SELF_HOSTING.md) · [Contributing](CONTRIBUTING.md)
## Quick Start
**English | [简体中文](README.zh-CN.md)**
### Using Multica (CLI)
</div>
## What is Multica?
Multica turns coding agents into real teammates. Assign issues to an agent like you'd assign to a colleague — they'll pick up the work, write code, report blockers, and update statuses autonomously.
No more copy-pasting prompts. No more babysitting runs. Your agents show up on the board, participate in conversations, and compound reusable skills over time. Works with **Claude Code** and **Codex**.
<p align="center">
<img src="docs/assets/hero-screenshot.png" alt="Multica board view" width="800">
</p>
## Features
- **Agents as Teammates** — assign to an agent like you'd assign to a colleague. They have profiles, show up on the board, post comments, create issues, and report blockers proactively.
- **Autonomous Execution** — set it and forget it. Full task lifecycle management (enqueue, claim, start, complete/fail) with real-time progress streaming via WebSocket.
- **Reusable Skills** — every solution becomes a reusable skill for the whole team. Deployments, migrations, code reviews — skills compound your team's capabilities over time.
- **Unified Runtimes** — one dashboard for all your compute. Local daemons and cloud runtimes, auto-detection of available CLIs, real-time monitoring.
- **Multi-Workspace** — organize work across teams with workspace-level isolation. Each workspace has its own agents, issues, and settings.
## Getting Started
### Multica Cloud
The fastest way to get started — no setup required: **[multica.ai](https://multica.ai)**
### Self-Host with Docker
```bash
# 1. Install the CLI
git clone https://github.com/multica-ai/multica.git
cd multica
cp .env.example .env
# Edit .env — at minimum, change JWT_SECRET
docker compose up -d # Start PostgreSQL
cd server && go run ./cmd/migrate up && cd .. # Run migrations
make start # Start the app
```
See the [Self-Hosting Guide](SELF_HOSTING.md) for full instructions.
## CLI
The `multica` CLI connects your local machine to Multica — authenticate, manage workspaces, and run the agent daemon.
```bash
# Install
brew tap multica-ai/tap
brew install multica-cli
brew install multica
# 2. Login and auto-watch your workspaces
# Authenticate and start
multica login
# 3. Start the local agent daemon
multica daemon start
```
The daemon auto-detects available agent CLIs (`claude`, `codex`) on your PATH and begins polling your watched workspaces for tasks.
The daemon auto-detects available agent CLIs (`claude`, `codex`) on your PATH. When an agent is assigned a task, the daemon creates an isolated environment, runs the agent, and reports results back.
Manage which workspaces the daemon monitors:
See the [CLI and Daemon Guide](CLI_AND_DAEMON.md) for the full command reference, daemon configuration, and advanced usage.
## Quickstart
Once you have the CLI installed (or signed up for [Multica Cloud](https://multica.ai)), follow these steps to assign your first task to an agent:
### 1. Log in and start the daemon
```bash
multica workspace list # List all workspaces (* = watched)
multica workspace watch <workspace-id> # Add a workspace to the watch list
multica workspace unwatch <workspace-id> # Remove a workspace from the watch list
multica login # Authenticate with your Multica account
multica daemon start # Start the local agent runtime
```
### Local Development
The daemon runs in the background and keeps your machine connected to Multica. It auto-detects agent CLIs (`claude`, `codex`) available on your PATH.
### 2. Verify your runtime
Open your workspace in the Multica web app. Navigate to **Settings → Runtimes** — you should see your machine listed as an active **Runtime**.
> **What is a Runtime?** A Runtime is a compute environment that can execute agent tasks. It can be your local machine (via the daemon) or a cloud instance. Each runtime reports which agent CLIs are available, so Multica knows where to route work.
### 3. Create an agent
Go to **Settings → Agents** and click **New Agent**. Pick the runtime you just connected and choose a provider (Claude Code or Codex). Give your agent a name — this is how it will appear on the board, in comments, and in assignments.
### 4. Assign your first task
Create an issue from the board (or via `multica issue create`), then assign it to your new agent. The agent will automatically pick up the task, execute it on your runtime, and report progress — just like a human teammate.
That's it! Your agent is now part of the team. 🎉
## Architecture
```
┌──────────────┐ ┌──────────────┐ ┌──────────────────┐
│ Next.js │────>│ Go Backend │────>│ PostgreSQL │
│ Frontend │<────│ (Chi + WS) │<────│ (pgvector) │
└──────────────┘ └──────┬───────┘ └──────────────────┘
┌──────┴───────┐
│ Agent Daemon │ (runs on your machine)
│ Claude/Codex │
└──────────────┘
```
| Layer | Stack |
|-------|-------|
| Frontend | Next.js 16 (App Router) |
| Backend | Go (Chi router, sqlc, gorilla/websocket) |
| Database | PostgreSQL 17 with pgvector |
| Agent Runtime | Local daemon executing Claude Code or Codex |
## Development
For contributors working on the Multica codebase, see the [Contributing Guide](CONTRIBUTING.md).
**Prerequisites:** [Node.js](https://nodejs.org/) v20+, [pnpm](https://pnpm.io/) v10.28+, [Go](https://go.dev/) v1.26+, [Docker](https://www.docker.com/)
```bash
# 1. Install dependencies
pnpm install
# 2. Copy environment variables for the shared main environment
cp .env.example .env
# 3. One-time setup: ensure shared PostgreSQL, create the app DB, run migrations
make setup
# 4. Start backend + frontend
make start
```
Open your configured `FRONTEND_ORIGIN` in the browser. By default that is [http://localhost:3000](http://localhost:3000).
See [CONTRIBUTING.md](CONTRIBUTING.md) for the full development workflow, worktree support, testing, and troubleshooting.
Main checkout uses `.env`. A Git worktree should generate its own `.env.worktree` and use the explicit worktree targets:
## License
```bash
make worktree-env
make setup-worktree
make start-worktree
```
Every checkout shares the same PostgreSQL container on `localhost:5432`. Isolation now happens at the database level:
- `.env` typically uses `POSTGRES_DB=multica`
- each `.env.worktree` gets its own `POSTGRES_DB`, such as `multica_my_feature_702`
- backend/frontend ports still stay unique per worktree
That keeps one Docker container and one volume, while still isolating schema and data per worktree.
## Project Structure
```
├── server/ # Go backend (Chi + sqlc + gorilla/websocket)
│ ├── cmd/ # server, daemon, migrate
│ ├── internal/ # Core business logic
│ ├── migrations/ # SQL migrations
│ └── sqlc.yaml # sqlc config
├── apps/
│ └── web/ # Next.js 16 frontend
├── packages/ # Shared TypeScript packages
│ ├── ui/ # Component library (shadcn/ui + Radix)
│ ├── types/ # Shared type definitions
│ ├── sdk/ # API client SDK
│ ├── store/ # State management
│ ├── hooks/ # Shared React hooks
│ └── utils/ # Utility functions
├── Makefile # Backend commands
├── docker-compose.yml # PostgreSQL + pgvector
└── .env.example # Environment variable template
```
## Commands
### Frontend
| Command | Description |
|---------|-------------|
| `pnpm dev:web` | Start Next.js dev server (uses `FRONTEND_PORT`, default `3000`) |
| `pnpm build` | Build all TypeScript packages |
| `pnpm typecheck` | Run TypeScript type checking |
| `pnpm test` | Run TypeScript tests |
### Backend
| Command | Description |
|---------|-------------|
| `make dev` | Run Go server (uses `PORT`, default `8080`) |
| `make daemon` | Run local agent daemon |
| `make multica ARGS="version"` | Run the local `multica` CLI without installing it |
| `make test` | Run Go tests |
| `make build` | Build server & daemon binaries |
| `make sqlc` | Regenerate sqlc code from SQL |
### Database
| Command | Description |
|---------|-------------|
| `make db-up` | Start the shared PostgreSQL container |
| `make db-down` | Stop the shared PostgreSQL container |
| `make migrate-up` | Ensure the current DB exists, then run migrations |
| `make migrate-down` | Rollback database migrations for the current DB |
| `make worktree-env` | Generate an isolated `.env.worktree` for the current worktree |
| `make setup-main` / `make start-main` | Force use of the shared main `.env` |
| `make setup-worktree` / `make start-worktree` | Force use of isolated `.env.worktree` |
## CLI (`multica`)
The CLI manages authentication, workspace configuration, and the local agent daemon.
### Install
```bash
brew tap multica-ai/tap
brew install multica-cli
```
Or build from source:
```bash
make build
cp server/bin/multica /usr/local/bin/multica # or ~/.local/bin/multica
```
For local development, you can also run the CLI directly from the repo:
```bash
make multica ARGS="version"
make multica ARGS="auth status"
```
For browser-based auth from source, make sure the local frontend is running at `FRONTEND_ORIGIN` first, for example with `make start`, `make start-main`, or `make start-worktree`.
### Authentication
```bash
multica login # Authenticate and auto-watch your workspaces
multica auth login # Legacy auth-only flow
multica auth login --token # Legacy token-only auth flow
multica auth status # Show current auth status
multica auth logout # Remove stored token
```
Credentials are saved to `~/.multica/config.json`.
### Workspaces
```bash
multica workspace list # List all workspaces you belong to
multica workspace get # Show the current workspace details/context
```
### Daemon Watch List
The daemon monitors one or more workspaces for tasks. Manage which workspaces are watched:
```bash
multica workspace watch <workspace-id> # Add a workspace to the watch list
multica workspace unwatch <workspace-id> # Remove a workspace from the watch list
multica workspace list # Show all workspaces (watched ones marked with *)
```
The watch list is stored in `~/.multica/config.json`. Changes are picked up by a running daemon within 5 seconds (hot-reload).
### Local Agent Daemon
The daemon polls watched workspaces for tasks and executes them using locally installed AI agents (Claude Code, Codex).
```bash
# 1. Authenticate
multica login
# 2. Add workspaces to watch
multica workspace watch <workspace-id>
# 3. Start the daemon
multica daemon start
```
The daemon auto-detects available agent CLIs (`claude`, `codex`) on your PATH. When a task is claimed, it creates an isolated execution environment, runs the agent, and reports results back to the server.
### Other Commands
```bash
multica agent list # List agents in the current workspace
multica daemon status # Show local daemon status
multica config # Show CLI configuration
multica config show # Compatibility alias for config display
multica version # Show CLI version
```
## Environment Variables
See [`.env.example`](.env.example) for all available variables:
- `DATABASE_URL` — PostgreSQL connection string
- `POSTGRES_DB` — Database name for the current checkout or worktree
- `POSTGRES_PORT` — Shared PostgreSQL host port (fixed to `5432`)
- `PORT` — Backend server port (default: 8080)
- `FRONTEND_PORT` / `FRONTEND_ORIGIN` — Frontend port and browser origin
- `JWT_SECRET` — JWT signing secret
- `MULTICA_APP_URL` — Browser origin for CLI login callback (default: `http://localhost:3000`)
- `MULTICA_DAEMON_ID` / `MULTICA_DAEMON_DEVICE_NAME` — Stable daemon identity for runtime registration
- `MULTICA_CLAUDE_PATH` / `MULTICA_CLAUDE_MODEL` — Claude Code executable and optional model override
- `MULTICA_CODEX_PATH` / `MULTICA_CODEX_MODEL` — Codex executable and optional model override
- `MULTICA_WORKSPACES_ROOT` — Base directory for agent execution environments (default: `~/multica_workspaces`)
- `NEXT_PUBLIC_API_URL` — Frontend → backend API URL
- `NEXT_PUBLIC_WS_URL` — Frontend → backend WebSocket URL
## Local Development Notes
- `make setup`, `make start`, `make dev`, and `make test` now require an env file. They fail fast if `.env` or `.env.worktree` is missing.
- `make stop` only stops the backend/frontend processes for the current checkout. It does not stop the shared PostgreSQL container.
- Use `make db-down` only when you explicitly want to shut down the shared local PostgreSQL instance for every checkout.
[Apache 2.0](LICENSE)

154
README.zh-CN.md Normal file
View File

@@ -0,0 +1,154 @@
<p align="center">
<img src="docs/assets/banner.jpg" alt="Multica — 人类与 AI并肩前行" width="100%">
</p>
<div align="center">
<picture>
<source media="(prefers-color-scheme: dark)" srcset="docs/assets/logo-dark.svg">
<source media="(prefers-color-scheme: light)" srcset="docs/assets/logo-light.svg">
<img alt="Multica" src="docs/assets/logo-light.svg" width="50">
</picture>
# Multica
**你的下一批员工,不是人类。**
开源平台,将编码 Agent 变成真正的队友。<br/>
分配任务、跟踪进度、积累技能——在一个地方管理你的人类 + 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) · [自部署指南](SELF_HOSTING.md) · [参与贡献](CONTRIBUTING.md)
**[English](README.md) | 简体中文**
</div>
## Multica 是什么?
Multica 将编码 Agent 变成真正的队友。像分配给同事一样分配给 Agent——它们会自主接手工作、编写代码、报告阻塞问题、更新状态。
不再需要复制粘贴 prompt不再需要盯着运行过程。你的 Agent 出现在看板上、参与对话、随着时间积累可复用的技能。支持 **Claude Code****Codex**
<p align="center">
<img src="docs/assets/hero-screenshot.png" alt="Multica 看板视图" width="800">
</p>
## 功能特性
- **Agent 即队友** — 像分配给同事一样分配给 Agent。它们有个人档案、出现在看板上、发表评论、创建 Issue、主动报告阻塞问题。
- **自主执行** — 设置后无需管理。完整的任务生命周期管理(排队、认领、执行、完成/失败),通过 WebSocket 实时推送进度。
- **可复用技能** — 每个解决方案都成为全团队可复用的技能。部署、数据库迁移、代码审查——技能让团队能力随时间持续增长。
- **统一运行时** — 一个控制台管理所有算力。本地 daemon 和云端运行时,自动检测可用 CLI实时监控。
- **多工作区** — 按团队组织工作,工作区级别隔离。每个工作区有独立的 Agent、Issue 和设置。
## 快速开始
### Multica 云服务
最快的上手方式,无需任何配置:**[multica.ai](https://multica.ai)**
### Docker 自部署
```bash
git clone https://github.com/multica-ai/multica.git
cd multica
cp .env.example .env
# 编辑 .env — 至少修改 JWT_SECRET
docker compose up -d # 启动 PostgreSQL
cd server && go run ./cmd/migrate up && cd .. # 运行数据库迁移
make start # 启动应用
```
完整部署文档请参阅 [自部署指南](SELF_HOSTING.md)。
## CLI
`multica` CLI 将你的本地机器连接到 Multica — 用于认证、管理工作区和运行 Agent daemon。
```bash
# 安装
brew tap multica-ai/tap
brew install multica
# 认证并启动
multica login
multica daemon start
```
daemon 会自动检测 PATH 中可用的 Agent CLI`claude``codex`)。当 Agent 被分配任务时daemon 会创建隔离环境、运行 Agent、并将结果回传。
完整命令参考请参阅 [CLI 与 Daemon 指南](CLI_AND_DAEMON.md)。
## 快速上手
安装好 CLI或注册 [Multica 云服务](https://multica.ai))后,按以下步骤将第一个任务分配给 Agent
### 1. 登录并启动 daemon
```bash
multica login # 使用你的 Multica 账号认证
multica daemon start # 启动本地 Agent 运行时
```
daemon 在后台运行,保持你的机器与 Multica 的连接。它会自动检测 PATH 中可用的 Agent CLI`claude``codex`)。
### 2. 确认运行时已连接
在 Multica Web 端打开你的工作区,进入 **设置 → 运行时Runtimes**,你应该能看到你的机器已作为一个活跃的 **Runtime** 出现在列表中。
> **什么是 Runtime运行时** Runtime 是可以执行 Agent 任务的计算环境。它可以是你的本地机器(通过 daemon 连接),也可以是云端实例。每个 Runtime 会上报可用的 Agent CLIMultica 据此决定将任务路由到哪里执行。
### 3. 创建 Agent
进入 **设置 → Agents**,点击 **新建 Agent**。选择你刚连接的 Runtime选择 ProviderClaude Code 或 Codex并为 Agent 起个名字——它将以这个名字出现在看板、评论和任务分配中。
### 4. 分配你的第一个任务
在看板上创建一个 Issue或通过 `multica issue create` 命令创建),然后将其分配给你的新 Agent。Agent 会自动接手任务、在你的 Runtime 上执行、并实时汇报进度——就像一个真正的队友一样。
大功告成!你的 Agent 现在是团队的一员了。 🎉
## 架构
```
┌──────────────┐ ┌──────────────┐ ┌──────────────────┐
│ Next.js │────>│ Go 后端 │────>│ PostgreSQL │
│ 前端 │<────│ (Chi + WS) │<────│ (pgvector) │
└──────────────┘ └──────┬───────┘ └──────────────────┘
┌──────┴───────┐
│ Agent Daemon │ (运行在你的机器上)
│ Claude/Codex │
└──────────────┘
```
| 层级 | 技术栈 |
|------|--------|
| 前端 | Next.js 16 (App Router) |
| 后端 | Go (Chi router, sqlc, gorilla/websocket) |
| 数据库 | PostgreSQL 17 with pgvector |
| Agent 运行时 | 本地 daemon 执行 Claude Code 或 Codex |
## 开发
参与 Multica 代码贡献,请参阅 [贡献指南](CONTRIBUTING.md)。
**环境要求:** [Node.js](https://nodejs.org/) v20+, [pnpm](https://pnpm.io/) v10.28+, [Go](https://go.dev/) v1.26+, [Docker](https://www.docker.com/)
```bash
pnpm install
cp .env.example .env
make setup
make start
```
完整的开发流程、worktree 支持、测试和问题排查请参阅 [CONTRIBUTING.md](CONTRIBUTING.md)。
## 开源协议
[Apache 2.0](LICENSE)

278
SELF_HOSTING.md Normal file
View File

@@ -0,0 +1,278 @@
# Self-Hosting Guide
This guide walks you through deploying Multica on your own infrastructure.
## Architecture Overview
Multica has three components:
| Component | Description | Technology |
|-----------|-------------|------------|
| **Backend** | REST API + WebSocket server | Go (single binary) |
| **Frontend** | Web application | Next.js 16 |
| **Database** | Primary data store | PostgreSQL 17 with pgvector |
Additionally, each user who wants to run AI agents locally installs the **`multica` CLI** and runs the **agent daemon** on their own machine.
## Prerequisites
- Docker and Docker Compose (recommended), or:
- Go 1.26+ (to build from source)
- Node.js 20+ and pnpm 10.28+ (to build the frontend)
- PostgreSQL 17 with the pgvector extension
## Quick Start (Docker Compose)
```bash
git clone https://github.com/multica-ai/multica.git
cd multica
cp .env.example .env
```
Edit `.env` with your production values (see [Configuration](#configuration) below), then:
```bash
# Start PostgreSQL
docker compose up -d
# Build the backend
make build
# Run database migrations
DATABASE_URL="your-database-url" ./server/bin/migrate up
# Start the backend server
DATABASE_URL="your-database-url" PORT=8080 ./server/bin/server
```
For the frontend:
```bash
pnpm install
pnpm build
# Start the frontend (production mode)
cd apps/web
REMOTE_API_URL=http://localhost:8080 pnpm start
```
## Configuration
All configuration is done via environment variables. Copy `.env.example` as a starting point.
### Required Variables
| Variable | Description | Example |
|----------|-------------|---------|
| `DATABASE_URL` | PostgreSQL connection string | `postgres://multica:multica@localhost:5432/multica?sslmode=disable` |
| `JWT_SECRET` | **Must change from default.** Secret key for signing JWT tokens. Use a long random string. | `openssl rand -hex 32` |
| `FRONTEND_ORIGIN` | URL where the frontend is served (used for CORS) | `https://app.example.com` |
### Email (Required for Authentication)
Multica uses email-based magic link authentication via [Resend](https://resend.com).
| Variable | Description |
|----------|-------------|
| `RESEND_API_KEY` | Your Resend API key |
| `RESEND_FROM_EMAIL` | Sender email address (default: `noreply@multica.ai`) |
### Google OAuth (Optional)
| Variable | Description |
|----------|-------------|
| `GOOGLE_CLIENT_ID` | Google OAuth client ID |
| `GOOGLE_CLIENT_SECRET` | Google OAuth client secret |
| `GOOGLE_REDIRECT_URI` | OAuth callback URL (e.g. `https://app.example.com/auth/callback`) |
### File Storage (Optional)
For file uploads and attachments, configure S3 and CloudFront:
| Variable | Description |
|----------|-------------|
| `S3_BUCKET` | S3 bucket name |
| `S3_REGION` | AWS region (default: `us-west-2`) |
| `CLOUDFRONT_DOMAIN` | CloudFront distribution domain |
| `CLOUDFRONT_KEY_PAIR_ID` | CloudFront key pair ID for signed URLs |
| `CLOUDFRONT_PRIVATE_KEY` | CloudFront private key (PEM format) |
| `COOKIE_DOMAIN` | Domain for CloudFront auth cookies |
### Server
| Variable | Default | Description |
|----------|---------|-------------|
| `PORT` | `8080` | Backend server port |
| `FRONTEND_PORT` | `3000` | Frontend port |
| `CORS_ALLOWED_ORIGINS` | Value of `FRONTEND_ORIGIN` | Comma-separated list of allowed origins |
| `LOG_LEVEL` | `info` | Log level: `debug`, `info`, `warn`, `error` |
### CLI / Daemon
These are configured on each user's machine, not on the server:
| Variable | Default | Description |
|----------|---------|-------------|
| `MULTICA_SERVER_URL` | `ws://localhost:8080/ws` | WebSocket URL for daemon → server connection |
| `MULTICA_APP_URL` | `http://localhost:3000` | Frontend URL for CLI login flow |
| `MULTICA_DAEMON_POLL_INTERVAL` | `3s` | How often the daemon polls for tasks |
| `MULTICA_DAEMON_HEARTBEAT_INTERVAL` | `15s` | Heartbeat frequency |
## Database Setup
Multica requires PostgreSQL 17 with the pgvector extension.
### Using the Included Docker Compose
```bash
docker compose up -d postgres
```
This starts a `pgvector/pgvector:pg17` container on port 5432 with default credentials (`multica`/`multica`).
### Using Your Own PostgreSQL
Ensure the pgvector extension is available:
```sql
CREATE EXTENSION IF NOT EXISTS vector;
```
### Running Migrations
Migrations must be run before starting the server:
```bash
# Using the built binary
./server/bin/migrate up
# Or from source
cd server && go run ./cmd/migrate up
```
## Reverse Proxy
In production, put a reverse proxy in front of both the backend and frontend to handle TLS and routing.
### Caddy (Recommended)
```
app.example.com {
reverse_proxy localhost:3000
}
api.example.com {
reverse_proxy localhost:8080
}
```
### Nginx
```nginx
# Frontend
server {
listen 443 ssl;
server_name app.example.com;
ssl_certificate /path/to/cert.pem;
ssl_certificate_key /path/to/key.pem;
location / {
proxy_pass http://localhost:3000;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
}
# Backend API
server {
listen 443 ssl;
server_name api.example.com;
ssl_certificate /path/to/cert.pem;
ssl_certificate_key /path/to/key.pem;
location / {
proxy_pass http://localhost:8080;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
# WebSocket support
location /ws {
proxy_pass http://localhost:8080;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_set_header Host $host;
proxy_read_timeout 86400;
}
}
```
When using separate domains for frontend and backend, set these environment variables accordingly:
```bash
# Backend
FRONTEND_ORIGIN=https://app.example.com
CORS_ALLOWED_ORIGINS=https://app.example.com
# Frontend
REMOTE_API_URL=https://api.example.com
NEXT_PUBLIC_API_URL=https://api.example.com
NEXT_PUBLIC_WS_URL=wss://api.example.com/ws
```
## Health Check
The backend exposes a health check endpoint:
```
GET /health
→ {"status":"ok"}
```
Use this for load balancer health checks or monitoring.
## Setting Up the Agent Daemon
Each team member who wants to run AI agents locally needs to:
1. **Install the CLI**
```bash
brew tap multica-ai/tap
brew install multica-cli
```
2. **Install an AI agent CLI** — at least one of:
- [Claude Code](https://docs.anthropic.com/en/docs/claude-code) (`claude` on PATH)
- [Codex](https://github.com/openai/codex) (`codex` on PATH)
3. **Authenticate and start**
```bash
# Point CLI to your server
export MULTICA_APP_URL=https://app.example.com
export MULTICA_SERVER_URL=wss://api.example.com/ws
# Login (opens browser)
multica login
# Start the daemon
multica daemon start
```
The daemon auto-detects installed agent CLIs and registers itself with the server. When an agent is assigned a task in Multica, the daemon picks it up, creates an isolated workspace, runs the agent, and reports results back.
## Upgrading
1. Pull the latest code or image
2. Run migrations: `./server/bin/migrate up`
3. Restart the backend and frontend
Migrations are forward-only and safe to run on a live database. They are idempotent — running them multiple times has no effect.

View File

@@ -50,7 +50,7 @@ describe("LoginPage", () => {
render(<LoginPage />);
expect(screen.getByText("Multica")).toBeInTheDocument();
expect(screen.getByText("AI-native task management")).toBeInTheDocument();
expect(screen.getByText("Turn coding agents into real teammates")).toBeInTheDocument();
expect(screen.getByLabelText("Email")).toBeInTheDocument();
expect(
screen.getByRole("button", { name: "Continue" })

View File

@@ -46,11 +46,20 @@ function redirectToCliCallback(
function LoginPageContent() {
const router = useRouter();
const user = useAuthStore((s) => s.user);
const isLoading = useAuthStore((s) => s.isLoading);
const sendCode = useAuthStore((s) => s.sendCode);
const verifyCode = useAuthStore((s) => s.verifyCode);
const hydrateWorkspace = useWorkspaceStore((s) => s.hydrateWorkspace);
const searchParams = useSearchParams();
// Already authenticated — redirect to dashboard
useEffect(() => {
if (!isLoading && user && !searchParams.get("cli_callback")) {
router.replace(searchParams.get("next") || "/issues");
}
}, [isLoading, user, router, searchParams]);
const [step, setStep] = useState<"email" | "code" | "cli_confirm">("email");
const [email, setEmail] = useState("");
const [code, setCode] = useState("");
@@ -277,7 +286,7 @@ function LoginPageContent() {
<Card className="w-full max-w-sm">
<CardHeader className="text-center">
<CardTitle className="text-2xl">Multica</CardTitle>
<CardDescription>AI-native task management</CardDescription>
<CardDescription>Turn coding agents into real teammates</CardDescription>
</CardHeader>
<CardContent>
<form id="login-form" onSubmit={handleSendCode} className="space-y-4">

View File

@@ -76,9 +76,9 @@ export function AppSidebar() {
const unreadCount = useInboxStore((s) => s.unreadCount());
const logout = () => {
router.push("/");
authLogout();
useWorkspaceStore.getState().clearWorkspace();
router.push("/login");
};
return (

View File

@@ -1,6 +1,6 @@
"use client";
import { useState, useEffect } from "react";
import { useState, useEffect, useRef, useMemo } from "react";
import { useDefaultLayout } from "react-resizable-panels";
import {
Bot,
@@ -28,6 +28,8 @@ import {
Globe,
Lock,
Settings,
Camera,
Archive,
} from "lucide-react";
import type {
Agent,
@@ -69,11 +71,14 @@ import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { toast } from "sonner";
import { Skeleton } from "@/components/ui/skeleton";
import { api } from "@/shared/api";
import { useAuthStore } from "@/features/auth";
import { useWorkspaceStore } from "@/features/workspace";
import { useRuntimeStore } from "@/features/runtimes";
import { useIssueStore } from "@/features/issues";
import { ActorAvatar } from "@/components/common/actor-avatar";
import { useFileUpload } from "@/shared/hooks/use-file-upload";
// ---------------------------------------------------------------------------
@@ -97,14 +102,6 @@ const taskStatusConfig: Record<string, { label: string; icon: typeof CheckCircle
cancelled: { label: "Cancelled", icon: XCircle, color: "text-muted-foreground" },
};
function getInitials(name: string): string {
return name
.split(/[\s-]+/)
.map((w) => w[0])
.join("")
.toUpperCase()
.slice(0, 2);
}
function generateId(): string {
return `${Date.now()}-${Math.random().toString(36).slice(2, 9)}`;
@@ -333,6 +330,7 @@ function AgentListItem({
onClick: () => void;
}) {
const st = statusConfig[agent.status];
const isArchived = !!agent.archived_at;
return (
<button
@@ -341,13 +339,11 @@ function AgentListItem({
isSelected ? "bg-accent" : "hover:bg-accent/50"
}`}
>
<div className="flex h-8 w-8 shrink-0 items-center justify-center rounded-lg bg-muted text-xs font-semibold">
{getInitials(agent.name)}
</div>
<ActorAvatar actorType="agent" actorId={agent.id} size={32} className={`rounded-lg ${isArchived ? "opacity-50 grayscale" : ""}`} />
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<span className="truncate text-sm font-medium">{agent.name}</span>
<span className={`truncate text-sm font-medium ${isArchived ? "text-muted-foreground" : ""}`}>{agent.name}</span>
{agent.runtime_mode === "cloud" ? (
<Cloud className="h-3 w-3 text-muted-foreground" />
) : (
@@ -355,8 +351,14 @@ function AgentListItem({
)}
</div>
<div className="flex items-center gap-1.5 mt-0.5">
<span className={`h-1.5 w-1.5 rounded-full ${st.dot}`} />
<span className={`text-xs ${st.color}`}>{st.label}</span>
{isArchived ? (
<span className="text-xs text-muted-foreground">Archived</span>
) : (
<>
<span className={`h-1.5 w-1.5 rounded-full ${st.dot}`} />
<span className={`text-xs ${st.color}`}>{st.label}</span>
</>
)}
</div>
</div>
</button>
@@ -387,6 +389,8 @@ function InstructionsTab({
setSaving(true);
try {
await onSave(value);
} catch {
// toast handled by parent
} finally {
setSaving(false);
}
@@ -453,6 +457,8 @@ function SkillsTab({
const newIds = [...agent.skills.map((s) => s.id), skillId];
await api.setAgentSkills(agent.id, { skill_ids: newIds });
await refreshAgents();
} catch (e) {
toast.error(e instanceof Error ? e.message : "Failed to add skill");
} finally {
setSaving(false);
setShowPicker(false);
@@ -465,6 +471,8 @@ function SkillsTab({
const newIds = agent.skills.filter((s) => s.id !== skillId).map((s) => s.id);
await api.setAgentSkills(agent.id, { skill_ids: newIds });
await refreshAgents();
} catch (e) {
toast.error(e instanceof Error ? e.message : "Failed to remove skill");
} finally {
setSaving(false);
}
@@ -708,6 +716,8 @@ function ToolsTab({
setSaving(true);
try {
await onSave(tools);
} catch {
// toast handled by parent
} finally {
setSaving(false);
}
@@ -852,6 +862,8 @@ function TriggersTab({
setSaving(true);
try {
await onSave(triggers);
} catch {
// toast handled by parent
} finally {
setSaving(false);
}
@@ -1057,8 +1069,17 @@ function TasksTab({ agent }: { agent: Agent }) {
if (loading) {
return (
<div className="flex items-center justify-center py-12 text-sm text-muted-foreground">
Loading tasks...
<div className="space-y-2">
{Array.from({ length: 3 }).map((_, i) => (
<div key={i} className="flex items-center gap-3 rounded-lg border px-4 py-3">
<Skeleton className="h-4 w-4 rounded shrink-0" />
<div className="flex-1 space-y-1.5">
<Skeleton className="h-4 w-1/2" />
<Skeleton className="h-3 w-1/3" />
</div>
<Skeleton className="h-4 w-16" />
</div>
))}
</div>
);
}
@@ -1173,6 +1194,22 @@ function SettingsTab({
const [visibility, setVisibility] = useState<AgentVisibility>(agent.visibility);
const [maxTasks, setMaxTasks] = useState(agent.max_concurrent_tasks);
const [saving, setSaving] = useState(false);
const { upload, uploading } = useFileUpload();
const fileInputRef = useRef<HTMLInputElement>(null);
const handleAvatarUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (!file) return;
e.target.value = "";
try {
const result = await upload(file);
if (!result) return;
await onSave({ avatar_url: result.link });
toast.success("Avatar updated");
} catch (err) {
toast.error(err instanceof Error ? err.message : "Failed to upload avatar");
}
};
const dirty =
name !== agent.name ||
@@ -1200,6 +1237,37 @@ function SettingsTab({
return (
<div className="max-w-lg space-y-6">
<div>
<Label className="text-xs text-muted-foreground">Avatar</Label>
<div className="mt-1.5 flex items-center gap-4">
<button
type="button"
className="group relative h-16 w-16 shrink-0 rounded-full bg-muted overflow-hidden focus:outline-none focus-visible:ring-2 focus-visible:ring-ring"
onClick={() => fileInputRef.current?.click()}
disabled={uploading}
>
<ActorAvatar actorType="agent" actorId={agent.id} size={64} className="rounded-none" />
<div className="absolute inset-0 flex items-center justify-center bg-black/40 opacity-0 transition-opacity group-hover:opacity-100">
{uploading ? (
<Loader2 className="h-5 w-5 animate-spin text-white" />
) : (
<Camera className="h-5 w-5 text-white" />
)}
</div>
</button>
<input
ref={fileInputRef}
type="file"
accept="image/*"
className="hidden"
onChange={handleAvatarUpload}
/>
<div className="text-xs text-muted-foreground">
Click to upload avatar
</div>
</div>
</div>
<div>
<Label className="text-xs text-muted-foreground">Name</Label>
<Input
@@ -1306,32 +1374,50 @@ function AgentDetail({
agent,
runtimes,
onUpdate,
onDelete,
onArchive,
onRestore,
}: {
agent: Agent;
runtimes: RuntimeDevice[];
onUpdate: (id: string, data: Partial<Agent>) => Promise<void>;
onDelete: (id: string) => Promise<void>;
onArchive: (id: string) => Promise<void>;
onRestore: (id: string) => Promise<void>;
}) {
const st = statusConfig[agent.status];
const runtimeDevice = getRuntimeDevice(agent, runtimes);
const [activeTab, setActiveTab] = useState<DetailTab>("instructions");
const [confirmDelete, setConfirmDelete] = useState(false);
const [confirmArchive, setConfirmArchive] = useState(false);
const isArchived = !!agent.archived_at;
return (
<div className="flex h-full flex-col">
{/* Archive Banner */}
{isArchived && (
<div className="flex items-center gap-2 bg-muted/50 px-4 py-2 text-xs text-muted-foreground border-b">
<AlertCircle className="h-3.5 w-3.5 shrink-0" />
<span className="flex-1">This agent is archived. It cannot be assigned or mentioned.</span>
<Button variant="outline" size="sm" className="h-6 text-xs" onClick={() => onRestore(agent.id)}>
Restore
</Button>
</div>
)}
{/* Header */}
<div className="flex h-12 shrink-0 items-center gap-3 border-b px-4">
<div className="flex h-7 w-7 shrink-0 items-center justify-center rounded-md bg-muted text-xs font-bold">
{getInitials(agent.name)}
</div>
<ActorAvatar actorType="agent" actorId={agent.id} size={28} className={`rounded-md ${isArchived ? "opacity-50" : ""}`} />
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<h2 className="text-sm font-semibold truncate">{agent.name}</h2>
<span className={`flex items-center gap-1.5 text-xs ${st.color}`}>
<span className={`h-1.5 w-1.5 rounded-full ${st.dot}`} />
{st.label}
</span>
<h2 className={`text-sm font-semibold truncate ${isArchived ? "text-muted-foreground" : ""}`}>{agent.name}</h2>
{isArchived ? (
<span className="rounded-md bg-muted px-1.5 py-0.5 text-xs font-medium text-muted-foreground">
Archived
</span>
) : (
<span className={`flex items-center gap-1.5 text-xs ${st.color}`}>
<span className={`h-1.5 w-1.5 rounded-full ${st.dot}`} />
{st.label}
</span>
)}
<span className="flex items-center gap-1 rounded-md bg-muted px-1.5 py-0.5 text-xs font-medium text-muted-foreground">
{agent.runtime_mode === "cloud" ? (
<Cloud className="h-3 w-3" />
@@ -1342,24 +1428,26 @@ function AgentDetail({
</span>
</div>
</div>
<DropdownMenu>
<DropdownMenuTrigger
render={
<Button variant="ghost" size="icon-sm" />
}
>
<MoreHorizontal className="h-4 w-4 text-muted-foreground" />
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem
className="text-destructive"
onClick={() => setConfirmDelete(true)}
{!isArchived && (
<DropdownMenu>
<DropdownMenuTrigger
render={
<Button variant="ghost" size="icon-sm" />
}
>
<Trash2 className="h-3.5 w-3.5" />
Delete Agent
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
<MoreHorizontal className="h-4 w-4 text-muted-foreground" />
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem
className="text-destructive"
onClick={() => setConfirmArchive(true)}
>
<Trash2 className="h-3.5 w-3.5" />
Archive Agent
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
)}
</div>
{/* Tabs */}
@@ -1413,33 +1501,33 @@ function AgentDetail({
)}
</div>
{/* Delete Confirmation */}
{confirmDelete && (
<Dialog open onOpenChange={(v) => { if (!v) setConfirmDelete(false); }}>
{/* Archive Confirmation */}
{confirmArchive && (
<Dialog open onOpenChange={(v) => { if (!v) setConfirmArchive(false); }}>
<DialogContent className="max-w-sm" showCloseButton={false}>
<div className="flex items-center gap-3">
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-full bg-destructive/10">
<AlertCircle className="h-5 w-5 text-destructive" />
</div>
<DialogHeader className="flex-1 gap-1">
<DialogTitle className="text-sm font-semibold">Delete agent?</DialogTitle>
<DialogTitle className="text-sm font-semibold">Archive agent?</DialogTitle>
<DialogDescription className="text-xs">
This will permanently delete &quot;{agent.name}&quot; and all its configuration.
&quot;{agent.name}&quot; will be archived. It won&apos;t be assignable or mentionable, but all history is preserved. You can restore it later.
</DialogDescription>
</DialogHeader>
</div>
<DialogFooter>
<Button variant="ghost" onClick={() => setConfirmDelete(false)}>
<Button variant="ghost" onClick={() => setConfirmArchive(false)}>
Cancel
</Button>
<Button
variant="destructive"
onClick={() => {
setConfirmDelete(false);
onDelete(agent.id);
setConfirmArchive(false);
onArchive(agent.id);
}}
>
Delete
Archive
</Button>
</DialogFooter>
</DialogContent>
@@ -1459,6 +1547,7 @@ export default function AgentsPage() {
const agents = useWorkspaceStore((s) => s.agents);
const refreshAgents = useWorkspaceStore((s) => s.refreshAgents);
const [selectedId, setSelectedId] = useState<string>("");
const [showArchived, setShowArchived] = useState(false);
const [showCreate, setShowCreate] = useState(false);
const runtimes = useRuntimeStore((s) => s.runtimes);
const fetchRuntimes = useRuntimeStore((s) => s.fetchRuntimes);
@@ -1470,12 +1559,19 @@ export default function AgentsPage() {
if (workspace) fetchRuntimes();
}, [workspace, fetchRuntimes]);
// Select first agent on initial load
const filteredAgents = useMemo(
() => showArchived ? agents.filter((a) => !!a.archived_at) : agents.filter((a) => !a.archived_at),
[agents, showArchived],
);
const archivedCount = useMemo(() => agents.filter((a) => !!a.archived_at).length, [agents]);
// Select first agent on initial load or when filter changes
useEffect(() => {
if (agents.length > 0 && !selectedId) {
setSelectedId(agents[0]!.id);
if (filteredAgents.length > 0 && !filteredAgents.some((a) => a.id === selectedId)) {
setSelectedId(filteredAgents[0]!.id);
}
}, [agents, selectedId]);
}, [filteredAgents, selectedId]);
const handleCreate = async (data: CreateAgentRequest) => {
const agent = await api.createAgent(data);
@@ -1484,25 +1580,74 @@ export default function AgentsPage() {
};
const handleUpdate = async (id: string, data: Record<string, unknown>) => {
await api.updateAgent(id, data as UpdateAgentRequest);
await refreshAgents();
try {
await api.updateAgent(id, data as UpdateAgentRequest);
await refreshAgents();
toast.success("Agent updated");
} catch (e) {
toast.error(e instanceof Error ? e.message : "Failed to update agent");
throw e;
}
};
const handleDelete = async (id: string) => {
await api.deleteAgent(id);
if (selectedId === id) {
const remaining = agents.filter((a) => a.id !== id);
setSelectedId(remaining[0]?.id ?? "");
const handleArchive = async (id: string) => {
try {
await api.archiveAgent(id);
await refreshAgents();
toast.success("Agent archived");
} catch (e) {
toast.error(e instanceof Error ? e.message : "Failed to archive agent");
}
};
const handleRestore = async (id: string) => {
try {
await api.restoreAgent(id);
await refreshAgents();
toast.success("Agent restored");
} catch (e) {
toast.error(e instanceof Error ? e.message : "Failed to restore agent");
}
await refreshAgents();
};
const selected = agents.find((a) => a.id === selectedId) ?? null;
if (isLoading) {
return (
<div className="flex flex-1 min-h-0 items-center justify-center text-sm text-muted-foreground">
Loading...
<div className="flex flex-1 min-h-0">
{/* List skeleton */}
<div className="w-72 border-r">
<div className="flex h-12 items-center justify-between border-b px-4">
<Skeleton className="h-4 w-16" />
<Skeleton className="h-6 w-6 rounded" />
</div>
<div className="divide-y">
{Array.from({ length: 4 }).map((_, i) => (
<div key={i} className="flex items-center gap-3 px-4 py-3">
<Skeleton className="h-8 w-8 rounded-full" />
<div className="flex-1 space-y-1.5">
<Skeleton className="h-4 w-24" />
<Skeleton className="h-3 w-16" />
</div>
</div>
))}
</div>
</div>
{/* Detail skeleton */}
<div className="flex-1 p-6 space-y-6">
<div className="flex items-center gap-3">
<Skeleton className="h-10 w-10 rounded-full" />
<div className="space-y-1.5">
<Skeleton className="h-5 w-32" />
<Skeleton className="h-3 w-20" />
</div>
</div>
<div className="space-y-3">
<Skeleton className="h-8 w-full rounded-lg" />
<Skeleton className="h-8 w-full rounded-lg" />
<Skeleton className="h-8 w-3/4 rounded-lg" />
</div>
</div>
</div>
);
}
@@ -1519,30 +1664,46 @@ export default function AgentsPage() {
<div className="overflow-y-auto h-full border-r">
<div className="flex h-12 items-center justify-between border-b px-4">
<h1 className="text-sm font-semibold">Agents</h1>
<Button
variant="ghost"
size="icon-xs"
onClick={() => setShowCreate(true)}
>
<Plus className="h-4 w-4 text-muted-foreground" />
</Button>
<div className="flex items-center gap-1">
{archivedCount > 0 && (
<Button
variant={showArchived ? "secondary" : "ghost"}
size="icon-xs"
onClick={() => setShowArchived(!showArchived)}
title={showArchived ? "Show active agents" : "Show archived agents"}
>
<Archive className="h-4 w-4 text-muted-foreground" />
</Button>
)}
<Button
variant="ghost"
size="icon-xs"
onClick={() => setShowCreate(true)}
>
<Plus className="h-4 w-4 text-muted-foreground" />
</Button>
</div>
</div>
{agents.length === 0 ? (
{filteredAgents.length === 0 ? (
<div className="flex flex-col items-center justify-center px-4 py-12">
<Bot className="h-8 w-8 text-muted-foreground/40" />
<p className="mt-3 text-sm text-muted-foreground">No agents yet</p>
<Button
onClick={() => setShowCreate(true)}
size="xs"
className="mt-3"
>
<Plus className="h-3 w-3" />
Create Agent
</Button>
<p className="mt-3 text-sm text-muted-foreground">
{showArchived ? "No archived agents" : archivedCount > 0 ? "No active agents" : "No agents yet"}
</p>
{!showArchived && (
<Button
onClick={() => setShowCreate(true)}
size="xs"
className="mt-3"
>
<Plus className="h-3 w-3" />
Create Agent
</Button>
)}
</div>
) : (
<div className="divide-y">
{agents.map((agent) => (
{filteredAgents.map((agent) => (
<AgentListItem
key={agent.id}
agent={agent}
@@ -1561,10 +1722,12 @@ export default function AgentsPage() {
{/* Right column — agent detail */}
{selected ? (
<AgentDetail
key={selected.id}
agent={selected}
runtimes={runtimes}
onUpdate={handleUpdate}
onDelete={handleDelete}
onArchive={handleArchive}
onRestore={handleRestore}
/>
) : (
<div className="flex h-full flex-col items-center justify-center text-muted-foreground">

View File

@@ -1,5 +1,6 @@
"use client";
import { useState, useEffect, useCallback } from "react";
import { useSearchParams } from "next/navigation";
import { useDefaultLayout } from "react-resizable-panels";
import { useInboxStore } from "@/features/inbox";
@@ -219,11 +220,20 @@ function InboxListItem({
export default function InboxPage() {
const searchParams = useSearchParams();
const selectedKey = searchParams.get("issue") ?? "";
const setSelectedKey = (key: string) => {
const urlIssue = searchParams.get("issue") ?? "";
const [selectedKey, setSelectedKeyState] = useState(() => urlIssue);
// Sync from URL when searchParams change (e.g. Next.js navigation)
useEffect(() => {
setSelectedKeyState(urlIssue);
}, [urlIssue]);
const setSelectedKey = useCallback((key: string) => {
setSelectedKeyState(key);
const url = key ? `/inbox?issue=${key}` : "/inbox";
window.history.replaceState(null, "", url);
};
}, []);
const items = useInboxStore((s) => s.dedupedItems());
const loading = useInboxStore((s) => s.loading);
@@ -309,11 +319,11 @@ export default function InboxPage() {
return (
<ResizablePanelGroup orientation="horizontal" className="flex-1 min-h-0" defaultLayout={defaultLayout} onLayoutChanged={onLayoutChanged}>
<ResizablePanel id="list" defaultSize={320} minSize={240} maxSize={480} groupResizeBehavior="preserve-pixel-size">
<div className="overflow-y-auto border-r h-full">
<div className="flex h-12 items-center border-b px-4">
<div className="flex flex-col border-r h-full">
<div className="flex h-12 shrink-0 items-center border-b px-4">
<Skeleton className="h-5 w-16" />
</div>
<div className="space-y-1 p-2">
<div className="flex-1 min-h-0 overflow-y-auto space-y-1 p-2">
{Array.from({ length: 5 }).map((_, i) => (
<div key={i} className="flex items-center gap-3 px-4 py-2.5">
<Skeleton className="h-7 w-7 shrink-0 rounded-full" />
@@ -341,8 +351,8 @@ export default function InboxPage() {
<ResizablePanelGroup orientation="horizontal" className="flex-1 min-h-0" defaultLayout={defaultLayout} onLayoutChanged={onLayoutChanged}>
<ResizablePanel id="list" defaultSize={320} minSize={240} maxSize={480} groupResizeBehavior="preserve-pixel-size">
{/* Left column — inbox list */}
<div className="overflow-y-auto border-r h-full">
<div className="flex h-12 items-center justify-between border-b px-4">
<div className="flex flex-col border-r h-full">
<div className="flex h-12 shrink-0 items-center justify-between border-b px-4">
<div className="flex items-center gap-2">
<h1 className="text-sm font-semibold">Inbox</h1>
{unreadCount > 0 && (
@@ -385,6 +395,7 @@ export default function InboxPage() {
</DropdownMenu>
</div>
<div className="flex-1 min-h-0 overflow-y-auto">
{items.length === 0 ? (
<div className="flex flex-col items-center justify-center py-16 text-muted-foreground">
<Inbox className="mb-3 h-8 w-8 text-muted-foreground/50" />
@@ -403,6 +414,7 @@ export default function InboxPage() {
))}
</div>
)}
</div>
</div>
</ResizablePanel>
<ResizableHandle />
@@ -411,9 +423,11 @@ export default function InboxPage() {
<div className="flex flex-col min-h-0 h-full">
{selected?.issue_id ? (
<IssueDetail
key={selected.id}
issueId={selected.issue_id}
defaultSidebarOpen={false}
layoutId="multica_inbox_issue_detail_layout"
highlightCommentId={selected.details?.comment_id ?? undefined}
onDelete={() => {
handleArchive(selected.id);
}}

View File

@@ -104,9 +104,9 @@ vi.mock("@/components/ui/calendar", () => ({
Calendar: () => null,
}));
// Mock RichTextEditor (Tiptap needs real DOM)
vi.mock("@/components/common/rich-text-editor", () => ({
RichTextEditor: forwardRef(({ defaultValue, onUpdate, placeholder, onSubmit }: any, ref: any) => {
// Mock ContentEditor (Tiptap needs real DOM)
vi.mock("@/features/editor", () => ({
ContentEditor: forwardRef(({ defaultValue, onUpdate, placeholder, onSubmit }: any, ref: any) => {
const valueRef = useRef(defaultValue || "");
const [value, setValue] = useState(defaultValue || "");
useImperativeHandle(ref, () => ({
@@ -132,6 +132,27 @@ vi.mock("@/components/common/rich-text-editor", () => ({
/>
);
}),
TitleEditor: forwardRef(({ defaultValue, placeholder, onBlur, onChange }: any, ref: any) => {
const valueRef = useRef(defaultValue || "");
const [value, setValue] = useState(defaultValue || "");
useImperativeHandle(ref, () => ({
getText: () => valueRef.current,
focus: () => {},
}));
return (
<input
value={value}
onChange={(e) => {
valueRef.current = e.target.value;
setValue(e.target.value);
onChange?.(e.target.value);
}}
onBlur={() => onBlur?.(valueRef.current)}
placeholder={placeholder}
data-testid="title-editor"
/>
);
}),
}));
// Mock Markdown renderer
@@ -161,6 +182,9 @@ vi.mock("@/shared/api", () => ({
listIssueSubscribers: vi.fn().mockResolvedValue([]),
subscribeToIssue: vi.fn().mockResolvedValue(undefined),
unsubscribeFromIssue: vi.fn().mockResolvedValue(undefined),
getActiveTaskForIssue: vi.fn().mockResolvedValue({ task: null }),
listTasksByIssue: vi.fn().mockResolvedValue([]),
listTaskMessages: vi.fn().mockResolvedValue([]),
},
}));
@@ -333,10 +357,10 @@ describe("IssueDetailPage", () => {
await user.click(submitBtn);
await waitFor(() => {
expect(mockCreateComment).toHaveBeenCalledWith(
"issue-1",
"New test comment",
);
expect(mockCreateComment).toHaveBeenCalled();
const [issueId, content] = mockCreateComment.mock.calls[0]!;
expect(issueId).toBe("issue-1");
expect(content).toBe("New test comment");
});
await waitFor(() => {

View File

@@ -150,9 +150,17 @@ vi.mock("@/features/issues/stores/view-store", () => ({
],
}));
// Mock view store context (shared components read from context)
vi.mock("@/features/issues/stores/view-store-context", () => ({
ViewStoreProvider: ({ children }: { children: React.ReactNode }) => children,
useViewStore: (selector?: any) => (selector ? selector(mockViewState) : mockViewState),
useViewStoreApi: () => ({ getState: () => mockViewState, setState: vi.fn(), subscribe: vi.fn() }),
}));
// Mock issue config
vi.mock("@/features/issues/config", () => ({
ALL_STATUSES: ["backlog", "todo", "in_progress", "in_review", "done", "blocked", "cancelled"],
BOARD_STATUSES: ["backlog", "todo", "in_progress", "in_review", "done", "blocked"],
STATUS_ORDER: ["backlog", "todo", "in_progress", "in_review", "done", "blocked", "cancelled"],
STATUS_CONFIG: {
backlog: { label: "Backlog", iconColor: "text-muted-foreground", hoverBg: "hover:bg-accent" },
@@ -331,23 +339,26 @@ describe("IssuesPage", () => {
expect(screen.getByText("Issues")).toBeInTheDocument();
});
it("shows 'New Issue' button", () => {
it("shows scope buttons", () => {
mockStoreState.loading = false;
mockStoreState.issues = [];
render(<IssuesPage />);
expect(screen.getByText("New Issue")).toBeInTheDocument();
expect(screen.getByText("All")).toBeInTheDocument();
expect(screen.getByText("Members")).toBeInTheDocument();
expect(screen.getByText("Agents")).toBeInTheDocument();
});
it("shows filter buttons", () => {
it("shows filter and display icon buttons", () => {
mockStoreState.loading = false;
mockStoreState.issues = mockIssues;
render(<IssuesPage />);
expect(screen.getByText("Filter")).toBeInTheDocument();
expect(screen.getByText("Display")).toBeInTheDocument();
// Filter and Display are now icon-only buttons, verify they render as buttons
const buttons = screen.getAllByRole("button");
expect(buttons.length).toBeGreaterThan(0);
});
it("shows empty board view when no issues exist", () => {

View File

@@ -22,7 +22,7 @@ export default function DashboardLayout({
useEffect(() => {
if (!isLoading && !user) {
router.push("/login");
router.push("/");
}
}, [user, isLoading, router]);

View File

@@ -2,6 +2,7 @@
import { useState } from "react";
import { Crown, Shield, User, Plus, MoreHorizontal, UserMinus, Users } from "lucide-react";
import { ActorAvatar } from "@/components/common/actor-avatar";
import type { MemberWithUser, MemberRole } from "@/shared/types";
import { Input } from "@/components/ui/input";
import { Button } from "@/components/ui/button";
@@ -70,14 +71,7 @@ function MemberRow({
return (
<div className="flex items-center gap-3 px-4 py-3">
<div className="flex h-8 w-8 shrink-0 items-center justify-center rounded-full bg-muted text-xs font-semibold">
{member.name
.split(" ")
.map((w) => w[0])
.join("")
.toUpperCase()
.slice(0, 2)}
</div>
<ActorAvatar actorType="member" actorId={member.user_id} size={32} />
<div className="min-w-0 flex-1">
<div className="text-sm font-medium truncate">{member.name}</div>
<div className="text-xs text-muted-foreground truncate">{member.email}</div>

View File

@@ -0,0 +1,129 @@
"use client";
import { useEffect, useState } from "react";
import { Save, Plus, Trash2 } from "lucide-react";
import { Input } from "@/components/ui/input";
import { Button } from "@/components/ui/button";
import { Card, CardContent } from "@/components/ui/card";
import { toast } from "sonner";
import { useAuthStore } from "@/features/auth";
import { useWorkspaceStore } from "@/features/workspace";
import { api } from "@/shared/api";
import type { WorkspaceRepo } from "@/shared/types";
export function RepositoriesTab() {
const user = useAuthStore((s) => s.user);
const workspace = useWorkspaceStore((s) => s.workspace);
const members = useWorkspaceStore((s) => s.members);
const updateWorkspace = useWorkspaceStore((s) => s.updateWorkspace);
const [repos, setRepos] = useState<WorkspaceRepo[]>(workspace?.repos ?? []);
const [saving, setSaving] = useState(false);
const currentMember = members.find((m) => m.user_id === user?.id) ?? null;
const canManageWorkspace = currentMember?.role === "owner" || currentMember?.role === "admin";
useEffect(() => {
setRepos(workspace?.repos ?? []);
}, [workspace]);
const handleSave = async () => {
if (!workspace) return;
setSaving(true);
try {
const updated = await api.updateWorkspace(workspace.id, { repos });
updateWorkspace(updated);
toast.success("Repositories saved");
} catch (e) {
toast.error(e instanceof Error ? e.message : "Failed to save repositories");
} finally {
setSaving(false);
}
};
const handleAddRepo = () => {
setRepos([...repos, { url: "", description: "" }]);
};
const handleRemoveRepo = (index: number) => {
setRepos(repos.filter((_, i) => i !== index));
};
const handleRepoChange = (index: number, field: keyof WorkspaceRepo, value: string) => {
setRepos(repos.map((r, i) => (i === index ? { ...r, [field]: value } : r)));
};
if (!workspace) return null;
return (
<div className="space-y-8">
<section className="space-y-4">
<h2 className="text-sm font-semibold">Repositories</h2>
<Card>
<CardContent className="space-y-3">
<p className="text-xs text-muted-foreground">
GitHub repositories associated with this workspace. Agents use these to clone and work on code.
</p>
{repos.map((repo, index) => (
<div key={index} className="flex gap-2">
<div className="flex-1 space-y-1.5">
<Input
type="url"
value={repo.url}
onChange={(e) => handleRepoChange(index, "url", e.target.value)}
disabled={!canManageWorkspace}
placeholder="https://github.com/org/repo"
className="text-sm"
/>
<Input
type="text"
value={repo.description}
onChange={(e) => handleRepoChange(index, "description", e.target.value)}
disabled={!canManageWorkspace}
placeholder="Description (e.g. Go backend + Next.js frontend)"
className="text-sm"
/>
</div>
{canManageWorkspace && (
<Button
variant="ghost"
size="icon"
className="mt-0.5 shrink-0 text-muted-foreground hover:text-destructive"
onClick={() => handleRemoveRepo(index)}
>
<Trash2 className="h-3.5 w-3.5" />
</Button>
)}
</div>
))}
{canManageWorkspace && (
<div className="flex items-center justify-between pt-1">
<Button variant="outline" size="sm" onClick={handleAddRepo}>
<Plus className="h-3 w-3" />
Add repository
</Button>
<Button
size="sm"
onClick={handleSave}
disabled={saving}
>
<Save className="h-3 w-3" />
{saving ? "Saving..." : "Save"}
</Button>
</div>
)}
{!canManageWorkspace && (
<p className="text-xs text-muted-foreground">
Only admins and owners can manage repositories.
</p>
)}
</CardContent>
</Card>
</section>
</div>
);
}

View File

@@ -22,6 +22,17 @@ import {
DialogDescription,
DialogFooter,
} from "@/components/ui/dialog";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@/components/ui/alert-dialog";
import { Skeleton } from "@/components/ui/skeleton";
import { toast } from "sonner";
import { api } from "@/shared/api";
@@ -33,13 +44,17 @@ export function TokensTab() {
const [newToken, setNewToken] = useState<string | null>(null);
const [tokenCopied, setTokenCopied] = useState(false);
const [tokenRevoking, setTokenRevoking] = useState<string | null>(null);
const [revokeConfirmId, setRevokeConfirmId] = useState<string | null>(null);
const [tokensLoading, setTokensLoading] = useState(true);
const loadTokens = useCallback(async () => {
try {
const list = await api.listPersonalAccessTokens();
setTokens(list);
} catch {
// ignore — tokens section simply stays empty
} catch (e) {
toast.error(e instanceof Error ? e.message : "Failed to load tokens");
} finally {
setTokensLoading(false);
}
}, []);
@@ -117,7 +132,21 @@ export function TokensTab() {
</CardContent>
</Card>
{tokens.length > 0 && (
{tokensLoading ? (
<div className="space-y-2">
{Array.from({ length: 2 }).map((_, i) => (
<Card key={i}>
<CardContent className="flex items-center gap-3">
<div className="flex-1 space-y-1.5">
<Skeleton className="h-4 w-32" />
<Skeleton className="h-3 w-48" />
</div>
<Skeleton className="h-8 w-8 rounded" />
</CardContent>
</Card>
))}
</div>
) : tokens.length > 0 && (
<div className="space-y-2">
{tokens.map((t) => (
<Card key={t.id}>
@@ -135,7 +164,7 @@ export function TokensTab() {
<Button
variant="ghost"
size="icon-sm"
onClick={() => handleRevokeToken(t.id)}
onClick={() => setRevokeConfirmId(t.id)}
disabled={tokenRevoking === t.id}
aria-label={`Revoke ${t.name}`}
>
@@ -152,6 +181,29 @@ export function TokensTab() {
)}
</section>
<AlertDialog open={!!revokeConfirmId} onOpenChange={(v) => { if (!v) setRevokeConfirmId(null); }}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Revoke token</AlertDialogTitle>
<AlertDialogDescription>
This token will be permanently revoked and can no longer be used. This cannot be undone.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction
variant="destructive"
onClick={async () => {
if (revokeConfirmId) await handleRevokeToken(revokeConfirmId);
setRevokeConfirmId(null);
}}
>
Revoke
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
<Dialog open={!!newToken} onOpenChange={(v) => { if (!v) { setNewToken(null); setTokenCopied(false); } }}>
<DialogContent>
<DialogHeader>

View File

@@ -1,7 +1,7 @@
"use client";
import { useEffect, useState } from "react";
import { Save, LogOut, Plus, Trash2 } from "lucide-react";
import { Save, LogOut } from "lucide-react";
import { Input } from "@/components/ui/input";
import { Textarea } from "@/components/ui/textarea";
import { Label } from "@/components/ui/label";
@@ -21,7 +21,6 @@ import { toast } from "sonner";
import { useAuthStore } from "@/features/auth";
import { useWorkspaceStore } from "@/features/workspace";
import { api } from "@/shared/api";
import type { WorkspaceRepo } from "@/shared/types";
export function WorkspaceTab() {
const user = useAuthStore((s) => s.user);
@@ -34,7 +33,6 @@ export function WorkspaceTab() {
const [name, setName] = useState(workspace?.name ?? "");
const [description, setDescription] = useState(workspace?.description ?? "");
const [context, setContext] = useState(workspace?.context ?? "");
const [repos, setRepos] = useState<WorkspaceRepo[]>(workspace?.repos ?? []);
const [saving, setSaving] = useState(false);
const [actionId, setActionId] = useState<string | null>(null);
const [confirmAction, setConfirmAction] = useState<{
@@ -52,7 +50,6 @@ export function WorkspaceTab() {
setName(workspace?.name ?? "");
setDescription(workspace?.description ?? "");
setContext(workspace?.context ?? "");
setRepos(workspace?.repos ?? []);
}, [workspace]);
const handleSave = async () => {
@@ -63,7 +60,6 @@ export function WorkspaceTab() {
name,
description,
context,
repos,
});
updateWorkspace(updated);
toast.success("Workspace settings saved");
@@ -74,18 +70,6 @@ export function WorkspaceTab() {
}
};
const handleAddRepo = () => {
setRepos([...repos, { url: "", description: "" }]);
};
const handleRemoveRepo = (index: number) => {
setRepos(repos.filter((_, i) => i !== index));
};
const handleRepoChange = (index: number, field: keyof WorkspaceRepo, value: string) => {
setRepos(repos.map((r, i) => (i === index ? { ...r, [field]: value } : r)));
};
const handleLeaveWorkspace = () => {
if (!workspace) return;
setConfirmAction({
@@ -191,69 +175,6 @@ export function WorkspaceTab() {
</Card>
</section>
{/* Repositories */}
<section className="space-y-4">
<h2 className="text-sm font-semibold">Repositories</h2>
<Card>
<CardContent className="space-y-3">
<p className="text-xs text-muted-foreground">
GitHub repositories associated with this workspace. Agents use these to clone and work on code.
</p>
{repos.map((repo, index) => (
<div key={index} className="flex gap-2">
<div className="flex-1 space-y-1.5">
<Input
type="url"
value={repo.url}
onChange={(e) => handleRepoChange(index, "url", e.target.value)}
disabled={!canManageWorkspace}
placeholder="https://github.com/org/repo"
className="text-sm"
/>
<Input
type="text"
value={repo.description}
onChange={(e) => handleRepoChange(index, "description", e.target.value)}
disabled={!canManageWorkspace}
placeholder="Description (e.g. Go backend + Next.js frontend)"
className="text-sm"
/>
</div>
{canManageWorkspace && (
<Button
variant="ghost"
size="icon"
className="mt-0.5 shrink-0 text-muted-foreground hover:text-destructive"
onClick={() => handleRemoveRepo(index)}
>
<Trash2 className="h-3.5 w-3.5" />
</Button>
)}
</div>
))}
{canManageWorkspace && (
<div className="flex items-center justify-between pt-1">
<Button variant="outline" size="sm" onClick={handleAddRepo}>
<Plus className="h-3 w-3" />
Add repository
</Button>
<Button
size="sm"
onClick={handleSave}
disabled={saving || !name.trim() || !canManageWorkspace}
>
<Save className="h-3 w-3" />
{saving ? "Saving..." : "Save"}
</Button>
</div>
)}
</CardContent>
</Card>
</section>
{/* Danger Zone */}
<section className="space-y-4">
<div className="flex items-center gap-2">

View File

@@ -1,6 +1,6 @@
"use client";
import { User, Palette, Key, Settings, Users } from "lucide-react";
import { User, Palette, Key, Settings, Users, FolderGit2 } from "lucide-react";
import { Tabs, TabsList, TabsTrigger, TabsContent } from "@/components/ui/tabs";
import { useWorkspaceStore } from "@/features/workspace";
import { AccountTab } from "./_components/account-tab";
@@ -8,6 +8,7 @@ import { AppearanceTab } from "./_components/general-tab";
import { TokensTab } from "./_components/tokens-tab";
import { WorkspaceTab } from "./_components/workspace-tab";
import { MembersTab } from "./_components/members-tab";
import { RepositoriesTab } from "./_components/repositories-tab";
const accountTabs = [
{ value: "profile", label: "Profile", icon: User },
@@ -17,6 +18,7 @@ const accountTabs = [
const workspaceTabs = [
{ value: "workspace", label: "General", icon: Settings },
{ value: "repositories", label: "Repositories", icon: FolderGit2 },
{ value: "members", label: "Members", icon: Users },
];
@@ -60,6 +62,7 @@ export default function SettingsPage() {
<TabsContent value="appearance"><AppearanceTab /></TabsContent>
<TabsContent value="tokens"><TokensTab /></TabsContent>
<TabsContent value="workspace"><WorkspaceTab /></TabsContent>
<TabsContent value="repositories"><RepositoriesTab /></TabsContent>
<TabsContent value="members"><MembersTab /></TabsContent>
</div>
</div>

View File

@@ -0,0 +1,21 @@
import type { Metadata } from "next";
import { AboutPageClient } from "@/features/landing/components/about-page-client";
export const metadata: Metadata = {
title: "About",
description:
"Learn about Multica — multiplexed information and computing agent. An open-source AI-native task management platform.",
openGraph: {
title: "About Multica",
description:
"The story behind Multica and why we're building AI-native task management.",
url: "/about",
},
alternates: {
canonical: "/about",
},
};
export default function AboutPage() {
return <AboutPageClient />;
}

View File

@@ -0,0 +1,20 @@
import type { Metadata } from "next";
import { ChangelogPageClient } from "@/features/landing/components/changelog-page-client";
export const metadata: Metadata = {
title: "Changelog",
description:
"See what's new in Multica — latest features, improvements, and fixes.",
openGraph: {
title: "Changelog | Multica",
description: "Latest updates and releases from Multica.",
url: "/changelog",
},
alternates: {
canonical: "/changelog",
},
};
export default function ChangelogPage() {
return <ChangelogPageClient />;
}

View File

@@ -0,0 +1,21 @@
import type { Metadata } from "next";
import { MulticaLanding } from "@/features/landing/components/multica-landing";
export const metadata: Metadata = {
title: "Homepage",
description:
"Multica — open-source platform that turns coding agents into real teammates. Assign tasks, track progress, compound skills.",
openGraph: {
title: "Multica — AI-Native Task Management",
description:
"Manage your human + agent workforce in one place.",
url: "/homepage",
},
alternates: {
canonical: "/homepage",
},
};
export default function HomepagePage() {
return <MulticaLanding />;
}

View File

@@ -0,0 +1,75 @@
import { cookies, headers } from "next/headers";
import { Instrument_Serif, Noto_Serif_SC } from "next/font/google";
import { LocaleProvider } from "@/features/landing/i18n";
import type { Locale } from "@/features/landing/i18n";
const instrumentSerif = Instrument_Serif({
subsets: ["latin"],
weight: "400",
variable: "--font-serif",
});
const notoSerifSC = Noto_Serif_SC({
subsets: ["latin"],
weight: "400",
variable: "--font-serif-zh",
});
const jsonLd = {
"@context": "https://schema.org",
"@graph": [
{
"@type": "Organization",
name: "Multica",
url: "https://www.multica.ai",
sameAs: ["https://github.com/multica-ai/multica"],
},
{
"@type": "SoftwareApplication",
name: "Multica",
applicationCategory: "ProjectManagement",
operatingSystem: "Web",
description:
"AI-native task management platform that turns coding agents into real teammates.",
offers: {
"@type": "Offer",
price: "0",
priceCurrency: "USD",
},
},
],
};
async function getInitialLocale(): Promise<Locale> {
// 1. User's explicit preference (cookie set when they switch language)
const cookieStore = await cookies();
const stored = cookieStore.get("multica-locale")?.value;
if (stored === "en" || stored === "zh") return stored;
// 2. Detect from Accept-Language header
const headersList = await headers();
const acceptLang = headersList.get("accept-language") ?? "";
if (acceptLang.includes("zh")) return "zh";
return "en";
}
export default async function LandingLayout({
children,
}: {
children: React.ReactNode;
}) {
const initialLocale = await getInitialLocale();
return (
<>
<script
type="application/ld+json"
dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd) }}
/>
<div className={`${instrumentSerif.variable} ${notoSerifSC.variable} h-full overflow-x-hidden overflow-y-auto bg-white`}>
<LocaleProvider initialLocale={initialLocale}>{children}</LocaleProvider>
</div>
</>
);
}

View File

@@ -0,0 +1,23 @@
import type { Metadata } from "next";
import { MulticaLanding } from "@/features/landing/components/multica-landing";
export const metadata: Metadata = {
title: {
absolute: "Multica — AI-Native Task Management",
},
description:
"Open-source platform that turns coding agents into real teammates. Assign tasks, track progress, compound skills.",
openGraph: {
title: "Multica — AI-Native Task Management",
description:
"Manage your human + agent workforce in one place.",
url: "/",
},
alternates: {
canonical: "/",
},
};
export default function LandingPage() {
return <MulticaLanding />;
}

View File

@@ -30,3 +30,12 @@
background-color: var(--sidebar-accent);
color: var(--sidebar-accent-foreground);
}
/* Sonner toast: align icon to first line of text, not vertically centered */
[data-sonner-toast] {
align-items: flex-start !important;
}
[data-sonner-toast] [data-icon] {
margin-top: 2.5px;
}

View File

@@ -1,4 +1,5 @@
import type { Metadata } from "next";
import type { Metadata, Viewport } from "next";
import { cookies } from "next/headers";
import { Geist, Geist_Mono } from "next/font/google";
import { ThemeProvider } from "@/components/theme-provider";
import { Toaster } from "@/components/ui/sonner";
@@ -11,23 +12,56 @@ import "./globals.css";
const geist = Geist({ subsets: ["latin"], variable: "--font-sans" });
const geistMono = Geist_Mono({ subsets: ["latin"], variable: "--font-mono" });
export const viewport: Viewport = {
width: "device-width",
initialScale: 1,
themeColor: [
{ media: "(prefers-color-scheme: light)", color: "#ffffff" },
{ media: "(prefers-color-scheme: dark)", color: "#05070b" },
],
};
export const metadata: Metadata = {
title: "Multica",
description: "AI-native task management",
metadataBase: new URL("https://www.multica.ai"),
title: {
default: "Multica — AI-Native Task Management",
template: "%s | Multica",
},
description:
"Open-source platform that turns coding agents into real teammates. Assign tasks, track progress, compound skills.",
icons: {
icon: [{ url: "/favicon.svg", type: "image/svg+xml" }],
shortcut: ["/favicon.svg"],
},
openGraph: {
type: "website",
siteName: "Multica",
locale: "en_US",
},
twitter: {
card: "summary_large_image",
},
alternates: {
canonical: "/",
},
robots: {
index: true,
follow: true,
},
};
export default function RootLayout({
export default async function RootLayout({
children,
}: {
children: React.ReactNode;
}) {
const cookieStore = await cookies();
const locale = cookieStore.get("multica-locale")?.value;
const lang = locale === "zh" ? "zh" : "en";
return (
<html
lang="en"
lang={lang}
suppressHydrationWarning
className={cn("antialiased font-sans h-full", geist.variable, geistMono.variable)}
>

View File

@@ -1,21 +0,0 @@
"use client";
import { useEffect } from "react";
import { useRouter } from "next/navigation";
import { useNavigationStore } from "@/features/navigation";
import { MulticaIcon } from "@/components/multica-icon";
export default function Home() {
const router = useRouter();
useEffect(() => {
const lastPath = useNavigationStore.getState().lastPath;
router.replace(lastPath);
}, [router]);
return (
<div className="flex h-screen items-center justify-center">
<MulticaIcon className="size-6" />
</div>
);
}

28
apps/web/app/robots.ts Normal file
View File

@@ -0,0 +1,28 @@
import type { MetadataRoute } from "next";
export default function robots(): MetadataRoute.Robots {
const baseUrl = "https://www.multica.ai";
return {
rules: [
{
userAgent: "*",
allow: ["/", "/about", "/changelog"],
disallow: [
"/api/",
"/ws",
"/auth/",
"/issues",
"/board",
"/inbox",
"/agents",
"/settings",
"/my-issues",
"/runtimes",
"/skills",
],
},
],
sitemap: `${baseUrl}/sitemap.xml`,
};
}

26
apps/web/app/sitemap.ts Normal file
View File

@@ -0,0 +1,26 @@
import type { MetadataRoute } from "next";
export default function sitemap(): MetadataRoute.Sitemap {
const baseUrl = "https://www.multica.ai";
return [
{
url: baseUrl,
lastModified: new Date("2026-04-01"),
changeFrequency: "weekly",
priority: 1.0,
},
{
url: `${baseUrl}/about`,
lastModified: new Date("2026-04-01"),
changeFrequency: "monthly",
priority: 0.7,
},
{
url: `${baseUrl}/changelog`,
lastModified: new Date("2026-04-01"),
changeFrequency: "weekly",
priority: 0.6,
},
];
}

View File

@@ -45,6 +45,7 @@ function ActorAvatar({
return (
<div
data-slot="avatar"
className={cn(
"inline-flex shrink-0 items-center justify-center rounded-full font-medium overflow-hidden",
"bg-muted text-muted-foreground",

View File

@@ -0,0 +1,57 @@
"use client";
import { useRef } from "react";
import { Paperclip } from "lucide-react";
import { cn } from "@/lib/utils";
interface FileUploadButtonProps {
/** Called with the selected File — caller handles upload. */
onSelect: (file: File) => void;
disabled?: boolean;
className?: string;
size?: "sm" | "default";
}
function FileUploadButton({
onSelect,
disabled,
className,
size = "default",
}: FileUploadButtonProps) {
const inputRef = useRef<HTMLInputElement>(null);
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (!file) return;
e.target.value = "";
onSelect(file);
};
const iconSize = size === "sm" ? "h-3.5 w-3.5" : "h-4 w-4";
const btnSize = size === "sm" ? "h-6 w-6" : "h-7 w-7";
return (
<>
<button
type="button"
onClick={() => inputRef.current?.click()}
disabled={disabled}
className={cn(
"inline-flex items-center justify-center rounded-full text-muted-foreground hover:bg-accent hover:text-foreground transition-colors disabled:opacity-50 disabled:pointer-events-none",
btnSize,
className,
)}
>
<Paperclip className={iconSize} />
</button>
<input
ref={inputRef}
type="file"
className="hidden"
onChange={handleChange}
/>
</>
);
}
export { FileUploadButton, type FileUploadButtonProps };

View File

@@ -1,7 +1,7 @@
"use client";
import type { ReactNode } from "react";
import { Bot } from "lucide-react";
import { Users } from "lucide-react";
import { HoverCard, HoverCardTrigger, HoverCardContent } from "@/components/ui/hover-card";
import { ActorAvatar } from "@/components/common/actor-avatar";
import { useWorkspaceStore } from "@/features/workspace";
@@ -16,6 +16,27 @@ function MentionHoverCard({ type, id, children }: MentionHoverCardProps) {
const members = useWorkspaceStore((s) => s.members);
const agents = useWorkspaceStore((s) => s.agents);
if (type === "all") {
return (
<HoverCard>
<HoverCardTrigger render={<span />} className="cursor-default">
{children}
</HoverCardTrigger>
<HoverCardContent align="start" className="w-auto min-w-48 max-w-72">
<div className="flex items-center gap-2.5">
<div className="flex h-8 w-8 shrink-0 items-center justify-center rounded-full bg-primary/10">
<Users className="h-4 w-4 text-primary" />
</div>
<div className="min-w-0">
<p className="text-sm font-medium">All members</p>
<p className="text-xs text-muted-foreground">Notifies all workspace members</p>
</div>
</div>
</HoverCardContent>
</HoverCard>
);
}
if (type === "member") {
const member = members.find((m) => m.user_id === id);
if (!member) return <>{children}</>;
@@ -49,9 +70,7 @@ function MentionHoverCard({ type, id, children }: MentionHoverCardProps) {
</HoverCardTrigger>
<HoverCardContent align="start" className="w-auto min-w-48 max-w-72">
<div className="flex items-center gap-2.5">
<div className="flex h-8 w-8 shrink-0 items-center justify-center rounded-full bg-muted">
<Bot className="h-4 w-4 text-muted-foreground" />
</div>
<ActorAvatar actorType="agent" actorId={id} size={32} />
<div className="min-w-0">
<p className="text-sm font-medium truncate">{agent.name}</p>
{agent.description && (

View File

@@ -0,0 +1,79 @@
"use client";
import { useState, lazy, Suspense } from "react";
import { SmilePlus } from "lucide-react";
import { Popover, PopoverTrigger, PopoverContent } from "@/components/ui/popover";
const EmojiPicker = lazy(() =>
import("@/components/common/emoji-picker").then((m) => ({ default: m.EmojiPicker })),
);
const QUICK_EMOJIS = ["👍", "👌", "❤️", "😄", "🎉", "😕", "🚀", "👀"];
interface QuickEmojiPickerProps {
onSelect: (emoji: string) => void;
align?: "start" | "end";
className?: string;
}
function QuickEmojiPicker({ onSelect, align = "start", className }: QuickEmojiPickerProps) {
const [open, setOpen] = useState(false);
const [showFull, setShowFull] = useState(false);
const handleOpenChange = (v: boolean) => {
setOpen(v);
if (!v) setShowFull(false);
};
const handleSelect = (emoji: string) => {
onSelect(emoji);
setOpen(false);
setShowFull(false);
};
return (
<Popover open={open} onOpenChange={handleOpenChange}>
<PopoverTrigger
render={
<button
type="button"
className={`inline-flex items-center justify-center h-6 w-6 rounded-full text-muted-foreground hover:bg-accent hover:text-foreground transition-colors ${className ?? ""}`}
>
<SmilePlus className="h-3.5 w-3.5" />
</button>
}
/>
<PopoverContent align={align} className="w-auto p-0">
{showFull ? (
<Suspense fallback={<div className="p-4 text-sm text-muted-foreground">Loading...</div>}>
<EmojiPicker onSelect={handleSelect} />
</Suspense>
) : (
<div className="p-2">
<div className="flex gap-1">
{QUICK_EMOJIS.map((emoji) => (
<button
key={emoji}
type="button"
onClick={() => handleSelect(emoji)}
className="h-8 w-8 flex items-center justify-center rounded hover:bg-accent text-base transition-colors"
>
{emoji}
</button>
))}
</div>
<button
type="button"
onClick={() => setShowFull(true)}
className="mt-1.5 w-full text-xs text-muted-foreground hover:text-foreground text-center py-1 rounded hover:bg-accent transition-colors"
>
More emojis...
</button>
</div>
)}
</PopoverContent>
</Popover>
);
}
export { QuickEmojiPicker };

View File

@@ -1,17 +1,9 @@
"use client";
import { useState, lazy, Suspense } from "react";
import { SmilePlus } from "lucide-react";
import { Popover, PopoverTrigger, PopoverContent } from "@/components/ui/popover";
import { Tooltip, TooltipTrigger, TooltipContent } from "@/components/ui/tooltip";
import { QuickEmojiPicker } from "@/components/common/quick-emoji-picker";
import { useActorName } from "@/features/workspace";
const EmojiPicker = lazy(() =>
import("@/components/common/emoji-picker").then((m) => ({ default: m.EmojiPicker })),
);
const QUICK_EMOJIS = ["👍", "👌", "❤️", "😄", "🎉", "😕", "🚀", "👀"];
interface ReactionItem {
id: string;
actor_type: string;
@@ -48,22 +40,17 @@ export function ReactionBar({
currentUserId,
onToggle,
className,
hideAddButton,
}: {
reactions: ReactionItem[];
currentUserId?: string;
onToggle: (emoji: string) => void;
className?: string;
hideAddButton?: boolean;
}) {
const [pickerOpen, setPickerOpen] = useState(false);
const [showFullPicker, setShowFullPicker] = useState(false);
const grouped = groupReactions(reactions, currentUserId);
const { getActorName } = useActorName();
const handlePickerOpenChange = (open: boolean) => {
setPickerOpen(open);
if (!open) setShowFullPicker(false);
};
return (
<div className={`flex flex-wrap items-center gap-1.5 ${className ?? ""}`}>
{grouped.map((g) => (
@@ -73,10 +60,10 @@ export function ReactionBar({
<button
type="button"
onClick={() => onToggle(g.emoji)}
className={`inline-flex items-center gap-1 rounded-full border px-2 py-0.5 text-xs transition-colors hover:bg-accent ${
className={`inline-flex items-center gap-1 rounded-full border px-2 py-0.5 text-xs transition-colors hover:bg-brand/15 ${
g.reacted
? "border-primary/40 bg-primary/10 text-primary"
: "border-border text-muted-foreground"
? "border-brand/30 bg-brand/8 text-brand"
: "border-brand/10 bg-brand/4 text-muted-foreground"
}`}
>
<span>{g.emoji}</span>
@@ -89,56 +76,7 @@ export function ReactionBar({
</TooltipContent>
</Tooltip>
))}
<Popover open={pickerOpen} onOpenChange={handlePickerOpenChange}>
<PopoverTrigger
render={
<button
type="button"
className="inline-flex items-center justify-center h-6 w-6 rounded-full text-muted-foreground hover:bg-accent hover:text-foreground transition-colors"
>
<SmilePlus className="h-3.5 w-3.5" />
</button>
}
/>
<PopoverContent align="start" className="w-auto p-0">
{showFullPicker ? (
<Suspense fallback={<div className="p-4 text-sm text-muted-foreground">Loading...</div>}>
<EmojiPicker
onSelect={(emoji) => {
onToggle(emoji);
setPickerOpen(false);
setShowFullPicker(false);
}}
/>
</Suspense>
) : (
<div className="p-2">
<div className="flex gap-1">
{QUICK_EMOJIS.map((emoji) => (
<button
key={emoji}
type="button"
onClick={() => {
onToggle(emoji);
setPickerOpen(false);
}}
className="h-8 w-8 flex items-center justify-center rounded hover:bg-accent text-base transition-colors"
>
{emoji}
</button>
))}
</div>
<button
type="button"
onClick={() => setShowFullPicker(true)}
className="mt-1.5 w-full text-xs text-muted-foreground hover:text-foreground text-center py-1 rounded hover:bg-accent transition-colors"
>
More emojis...
</button>
</div>
)}
</PopoverContent>
</Popover>
{!hideAddButton && <QuickEmojiPicker onSelect={onToggle} />}
</div>
);
}

View File

@@ -1,158 +0,0 @@
/* Rich text editor: ProseMirror styles using shadcn design tokens */
.rich-text-editor.ProseMirror {
color: var(--foreground);
caret-color: var(--foreground);
}
.rich-text-editor.ProseMirror:focus {
outline: none;
}
/* Placeholder */
.rich-text-editor .is-editor-empty:first-child::before {
content: attr(data-placeholder);
float: left;
color: var(--muted-foreground);
pointer-events: none;
height: 0;
}
/* Headings */
.rich-text-editor h1 {
font-size: 1.125rem;
font-weight: 700;
margin-top: 1.25rem;
margin-bottom: 0.5rem;
line-height: 1.4;
}
.rich-text-editor h2 {
font-size: 1rem;
font-weight: 600;
margin-top: 1rem;
margin-bottom: 0.5rem;
line-height: 1.4;
}
.rich-text-editor h3 {
font-size: 0.875rem;
font-weight: 600;
margin-top: 0.75rem;
margin-bottom: 0.25rem;
line-height: 1.4;
}
/* Paragraphs */
.rich-text-editor p {
margin-top: 0.375rem;
margin-bottom: 0.375rem;
line-height: 1.625;
}
/* First child should not have top margin */
.rich-text-editor > *:first-child {
margin-top: 0;
}
/* Last child should not have bottom margin */
.rich-text-editor > *:last-child {
margin-bottom: 0;
}
/* Lists */
.rich-text-editor ul {
list-style-type: disc;
padding-inline-start: 1.25rem;
margin: 0.375rem 0;
}
.rich-text-editor ol {
list-style-type: decimal;
padding-inline-start: 1.25rem;
margin: 0.375rem 0;
}
.rich-text-editor li {
margin: 0.125rem 0;
line-height: 1.625;
}
.rich-text-editor li::marker {
color: var(--muted-foreground);
}
/* Inline code */
.rich-text-editor code {
font-family: var(--font-mono, ui-monospace, monospace);
font-size: 0.8em;
background: var(--muted);
color: var(--foreground);
padding: 0.15em 0.35em;
border-radius: calc(var(--radius) * 0.6);
}
/* Code blocks */
.rich-text-editor pre {
font-family: var(--font-mono, ui-monospace, monospace);
background: var(--muted);
border-radius: var(--radius);
padding: 0.75rem 1rem;
margin: 0.5rem 0;
overflow-x: auto;
}
.rich-text-editor pre code {
background: none;
padding: 0;
font-size: 0.8125rem;
line-height: 1.6;
}
/* Blockquotes */
.rich-text-editor blockquote {
border-left: 2px solid var(--border);
padding-left: 0.75rem;
margin: 0.5rem 0;
color: var(--muted-foreground);
}
/* Horizontal rules */
.rich-text-editor hr {
border: none;
border-top: 1px solid var(--border);
margin: 1rem 0;
}
/* Links */
.rich-text-editor a {
color: var(--brand);
text-decoration: none;
}
.rich-text-editor a:hover {
text-decoration: underline;
text-underline-offset: 2px;
}
/* Mentions */
.rich-text-editor .mention {
color: var(--primary);
font-weight: 600;
text-decoration: none;
margin: 0 0.125rem;
}
/* Strong / emphasis */
.rich-text-editor strong {
font-weight: 600;
}
.rich-text-editor em {
font-style: italic;
}
.rich-text-editor s {
text-decoration: line-through;
color: var(--muted-foreground);
}

View File

@@ -1,354 +0,0 @@
"use client";
import {
forwardRef,
useEffect,
useImperativeHandle,
useRef,
} from "react";
import { useEditor, EditorContent } from "@tiptap/react";
import StarterKit from "@tiptap/starter-kit";
import Placeholder from "@tiptap/extension-placeholder";
import Link from "@tiptap/extension-link";
import Typography from "@tiptap/extension-typography";
import Mention from "@tiptap/extension-mention";
import Image from "@tiptap/extension-image";
import { Markdown } from "@tiptap/markdown";
import { Extension, mergeAttributes } from "@tiptap/core";
import { Plugin, PluginKey } from "@tiptap/pm/state";
import { cn } from "@/lib/utils";
import type { UploadResult } from "@/shared/hooks/use-file-upload";
import { createMentionSuggestion } from "./mention-suggestion";
import "./rich-text-editor.css";
// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------
interface RichTextEditorProps {
defaultValue?: string;
onUpdate?: (markdown: string) => void;
placeholder?: string;
editable?: boolean;
className?: string;
debounceMs?: number;
onSubmit?: () => void;
onUploadFile?: (file: File) => Promise<UploadResult | null>;
}
interface RichTextEditorRef {
getMarkdown: () => string;
clearContent: () => void;
focus: () => void;
insertFile: (filename: string, url: string, isImage: boolean) => void;
}
const LinkExtension = Link.configure({
openOnClick: true,
autolink: true,
HTMLAttributes: {
class: "text-primary hover:underline cursor-pointer",
},
});
const MentionExtension = Mention.configure({
HTMLAttributes: { class: "mention" },
suggestion: createMentionSuggestion(),
}).extend({
renderHTML({ node, HTMLAttributes }) {
return [
"span",
mergeAttributes(
{ "data-type": "mention" },
this.options.HTMLAttributes,
HTMLAttributes,
{
"data-mention-type": node.attrs.type ?? "member",
"data-mention-id": node.attrs.id,
},
),
`@${node.attrs.label ?? node.attrs.id}`,
];
},
addAttributes() {
return {
...this.parent?.(),
type: {
default: "member",
parseHTML: (el: HTMLElement) =>
el.getAttribute("data-mention-type") ?? "member",
renderHTML: () => ({}),
},
};
},
// @tiptap/markdown: custom tokenizer to parse [@Label](mention://type/id)
markdownTokenizer: {
name: "mention",
level: "inline" as const,
start(src: string) {
return src.search(/\[@[^\]]+\]\(mention:\/\//);
},
tokenize(src: string) {
const match = src.match(
/^\[@([^\]]+)\]\(mention:\/\/(\w+)\/([^)]+)\)/,
);
if (!match) return undefined;
return {
type: "mention",
raw: match[0],
attributes: { label: match[1], type: match[2], id: match[3] },
};
},
},
// eslint-disable-next-line @typescript-eslint/no-explicit-any
parseMarkdown: (token: any, helpers: any) => {
return helpers.createNode("mention", token.attributes);
},
// eslint-disable-next-line @typescript-eslint/no-explicit-any
renderMarkdown: (node: any) => {
const { id, label, type = "member" } = node.attrs || {};
return `[@${label ?? id}](mention://${type}/${id})`;
},
});
// ---------------------------------------------------------------------------
// Submit shortcut extension (Mod+Enter)
// ---------------------------------------------------------------------------
function createSubmitExtension(onSubmit: () => void) {
return Extension.create({
name: "submitShortcut",
addKeyboardShortcuts() {
return {
"Mod-Enter": () => {
onSubmit();
return true;
},
};
},
});
}
// ---------------------------------------------------------------------------
// File upload extension (paste + drop)
// ---------------------------------------------------------------------------
function createFileUploadExtension(
onUploadFileRef: React.RefObject<((file: File) => Promise<UploadResult | null>) | undefined>,
) {
return Extension.create({
name: "fileUpload",
addProseMirrorPlugins() {
const { editor } = this;
const handleFiles = async (files: FileList, pos?: number) => {
const handler = onUploadFileRef.current;
if (!handler) return false;
let handled = false;
for (const file of Array.from(files)) {
handled = true;
try {
const result = await handler(file);
if (!result) continue;
const isImage = file.type.startsWith("image/");
if (isImage) {
editor
.chain()
.focus()
.setImage({ src: result.link, alt: result.filename })
.run();
} else {
// Insert as a markdown link
const linkText = `[${result.filename}](${result.link})`;
if (pos !== undefined) {
editor.chain().focus().insertContentAt(pos, linkText).run();
} else {
editor.chain().focus().insertContent(linkText).run();
}
}
} catch {
// Upload errors handled by the hook/caller via toast
}
}
return handled;
};
return [
new Plugin({
key: new PluginKey("fileUpload"),
props: {
handlePaste(_view, event) {
const files = event.clipboardData?.files;
if (!files?.length) return false;
if (!onUploadFileRef.current) return false;
handleFiles(files);
return true;
},
handleDrop(_view, event) {
const files = (event as DragEvent).dataTransfer?.files;
if (!files?.length) return false;
if (!onUploadFileRef.current) return false;
handleFiles(files);
return true;
},
},
}),
];
},
});
}
// ---------------------------------------------------------------------------
// Component
// ---------------------------------------------------------------------------
const RichTextEditor = forwardRef<RichTextEditorRef, RichTextEditorProps>(
function RichTextEditor(
{
defaultValue = "",
onUpdate,
placeholder: placeholderText = "",
editable = true,
className,
debounceMs = 300,
onSubmit,
onUploadFile,
},
ref,
) {
const debounceRef = useRef<ReturnType<typeof setTimeout>>(undefined);
const onUpdateRef = useRef(onUpdate);
const onSubmitRef = useRef(onSubmit);
const onUploadFileRef = useRef(onUploadFile);
// Helper to get markdown from @tiptap/markdown extension.
// Post-processes mention shortcodes [@ id="..." label="..."] → markdown
// links, using the Tiptap JSON doc for type info, in case the
// renderMarkdown override doesn't take effect.
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const getEditorMarkdown = (ed: any): string => {
const md: string = ed?.getMarkdown?.() ?? "";
if (!md || !md.includes("[@ ")) return md;
// Build type map from editor JSON (which always has the type attr)
const json = ed?.getJSON?.();
const typeMap = new Map<string, string>();
// eslint-disable-next-line @typescript-eslint/no-explicit-any
function walk(node: any) {
if (node?.type === "mention" && node.attrs?.id) {
typeMap.set(node.attrs.id, node.attrs.type || "member");
}
if (node?.content) node.content.forEach(walk);
}
if (json) walk(json);
return md.replace(
/\[@\s+([^\]]*)\]/g,
(match: string, attrString: string) => {
const attrs: Record<string, string> = {};
const re = /(\w+)="([^"]*)"/g;
let m;
while ((m = re.exec(attrString)) !== null) {
if (m[1] && m[2] !== undefined) attrs[m[1]] = m[2];
}
const { id, label } = attrs;
if (!id || !label) return match;
const type = typeMap.get(id) || "member";
const display = type === "issue" ? label : `@${label}`;
return `[${display}](mention://${type}/${id})`;
},
);
};
// Keep refs in sync without recreating editor
onUpdateRef.current = onUpdate;
onSubmitRef.current = onSubmit;
onUploadFileRef.current = onUploadFile;
const editor = useEditor({
immediatelyRender: false,
editable,
content: defaultValue || "",
contentType: defaultValue ? "markdown" : undefined,
extensions: [
StarterKit.configure({
heading: { levels: [1, 2, 3] },
link: false,
}),
Placeholder.configure({
placeholder: placeholderText,
}),
LinkExtension,
Typography,
MentionExtension,
Image.configure({
inline: false,
allowBase64: false,
HTMLAttributes: { style: "max-width: 100%; height: auto;" },
}),
Markdown,
createSubmitExtension(() => onSubmitRef.current?.()),
createFileUploadExtension(onUploadFileRef),
],
onUpdate: ({ editor: ed }) => {
if (!onUpdateRef.current) return;
if (debounceRef.current) clearTimeout(debounceRef.current);
debounceRef.current = setTimeout(() => {
onUpdateRef.current?.(ed.getMarkdown());
}, debounceMs);
},
editorProps: {
handleDOMEvents: {
click(_view, event) {
if (event.metaKey || event.ctrlKey) {
const link = (event.target as HTMLElement).closest("a");
const href = link?.getAttribute("href");
if (href && !href.startsWith("mention://")) {
window.open(href, "_blank", "noopener,noreferrer");
event.preventDefault();
return true;
}
}
return false;
},
},
attributes: {
class: cn("rich-text-editor text-sm outline-none", className),
},
},
});
// Cleanup debounce on unmount
useEffect(() => {
return () => {
if (debounceRef.current) clearTimeout(debounceRef.current);
};
}, []);
useImperativeHandle(ref, () => ({
getMarkdown: () => editor?.getMarkdown() ?? "",
clearContent: () => {
editor?.commands.clearContent();
},
focus: () => {
editor?.commands.focus();
},
insertFile: (filename: string, url: string, isImage: boolean) => {
if (!editor) return;
if (isImage) {
editor.chain().focus().setImage({ src: url, alt: filename }).run();
} else {
editor.chain().focus().insertContent(`[${filename}](${url})`).run();
}
},
}));
if (!editor) return null;
return <EditorContent editor={editor} />;
},
);
export { RichTextEditor, type RichTextEditorProps, type RichTextEditorRef };

View File

@@ -5,6 +5,7 @@ import remarkGfm from 'remark-gfm'
import { cn } from '@/lib/utils'
import { CodeBlock, InlineCode } from './CodeBlock'
import { preprocessLinks } from './linkify'
import { preprocessMentionShortcodes } from './mentions'
import { IssueMentionCard } from '@/features/issues/components/issue-mention-card'
/**
@@ -53,27 +54,6 @@ function urlTransform(url: string): string {
return defaultUrlTransform(url)
}
/**
* Convert legacy mention shortcodes [@ id="UUID" label="LABEL"] to markdown
* link format [@LABEL](mention://member/UUID) so they render as styled mentions.
*/
function preprocessMentionShortcodes(text: string): string {
if (!text.includes('[@ ')) return text
return text.replace(
/\[@\s+([^\]]*)\]/g,
(match, attrString: string) => {
const attrs: Record<string, string> = {}
const re = /(\w+)="([^"]*)"/g
let m
while ((m = re.exec(attrString)) !== null) {
if (m[1] && m[2] !== undefined) attrs[m[1]] = m[2]
}
const { id, label } = attrs
if (!id || !label) return match
return `[@${label}](mention://member/${id})`
}
)
}
// File path detection regex - matches paths starting with /, ~/, or ./
const FILE_PATH_REGEX =
@@ -99,9 +79,9 @@ function createComponents(
),
// Links: Make clickable with callbacks, or render as mention
a: ({ href, children }) => {
// Mention links: mention://member/id, mention://agent/id, mention://issue/id
// Mention links: mention://member/id, mention://agent/id, mention://issue/id, mention://all/all
if (href?.startsWith('mention://')) {
const mentionMatch = href.match(/^mention:\/\/(member|agent|issue)\/(.+)$/)
const mentionMatch = href.match(/^mention:\/\/(member|agent|issue|all)\/(.+)$/)
if (mentionMatch?.[1] === 'issue' && mentionMatch[2]) {
const label = typeof children === 'string' ? children : Array.isArray(children) ? children.join('') : undefined
return <IssueMentionCard issueId={mentionMatch[2]} fallbackLabel={label} />

View File

@@ -2,3 +2,4 @@ export { Markdown, MemoizedMarkdown, type MarkdownProps, type RenderMode } from
export { CodeBlock, InlineCode, type CodeBlockProps } from './CodeBlock'
export { StreamingMarkdown, type StreamingMarkdownProps } from './StreamingMarkdown'
export { preprocessLinks, detectLinks, hasLinks } from './linkify'
export { preprocessMentionShortcodes } from './mentions'

View File

@@ -0,0 +1,25 @@
/**
* Convert legacy mention shortcodes [@ id="UUID" label="LABEL"] to the
* standard markdown link format [@LABEL](mention://member/UUID).
*
* These shortcodes exist in older database records from a previous mention
* serialization format. This function normalises them so downstream parsers
* (Tiptap @tiptap/markdown, react-markdown) only need to handle one syntax.
*/
export function preprocessMentionShortcodes(text: string): string {
if (!text.includes("[@ ")) return text;
return text.replace(
/\[@\s+([^\]]*)\]/g,
(match, attrString: string) => {
const attrs: Record<string, string> = {};
const re = /(\w+)="([^"]*)"/g;
let m;
while ((m = re.exec(attrString)) !== null) {
if (m[1] && m[2] !== undefined) attrs[m[1]] = m[2];
}
const { id, label } = attrs;
if (!id || !label) return match;
return `[@${label}](mention://member/${id})`;
},
);
}

View File

@@ -13,19 +13,19 @@ const Toaster = ({ ...props }: ToasterProps) => {
className="toaster group"
icons={{
success: (
<CircleCheckIcon className="size-4" />
<CircleCheckIcon className="size-4 text-success" />
),
info: (
<InfoIcon className="size-4" />
<InfoIcon className="size-4 text-info" />
),
warning: (
<TriangleAlertIcon className="size-4" />
<TriangleAlertIcon className="size-4 text-warning" />
),
error: (
<OctagonXIcon className="size-4" />
<OctagonXIcon className="size-4 text-destructive" />
),
loading: (
<Loader2Icon className="size-4 animate-spin" />
<Loader2Icon className="size-4 animate-spin text-brand" />
),
}}
style={

View File

@@ -0,0 +1,9 @@
const COOKIE_NAME = "multica_logged_in";
export function setLoggedInCookie() {
document.cookie = `${COOKIE_NAME}=1; path=/; max-age=31536000; samesite=lax`;
}
export function clearLoggedInCookie() {
document.cookie = `${COOKIE_NAME}=; path=/; max-age=0`;
}

View File

@@ -5,6 +5,7 @@ import { useAuthStore } from "./store";
import { useWorkspaceStore } from "@/features/workspace";
import { api } from "@/shared/api";
import { createLogger } from "@/shared/logger";
import { setLoggedInCookie, clearLoggedInCookie } from "./auth-cookie";
const logger = createLogger("auth");
@@ -16,6 +17,7 @@ export function AuthInitializer({ children }: { children: ReactNode }) {
useEffect(() => {
const token = localStorage.getItem("multica_token");
if (!token) {
clearLoggedInCookie();
useAuthStore.setState({ isLoading: false });
return;
}
@@ -29,6 +31,7 @@ export function AuthInitializer({ children }: { children: ReactNode }) {
Promise.all([mePromise, wsPromise])
.then(([user, wsList]) => {
setLoggedInCookie();
useAuthStore.setState({ user, isLoading: false });
useWorkspaceStore.getState().hydrateWorkspace(wsList, wsId);
})
@@ -38,6 +41,7 @@ export function AuthInitializer({ children }: { children: ReactNode }) {
api.setWorkspaceId(null);
localStorage.removeItem("multica_token");
localStorage.removeItem("multica_workspace_id");
clearLoggedInCookie();
useAuthStore.setState({ user: null, isLoading: false });
});
}, []);

View File

@@ -3,6 +3,7 @@
import { create } from "zustand";
import type { User } from "@/shared/types";
import { api } from "@/shared/api";
import { setLoggedInCookie, clearLoggedInCookie } from "./auth-cookie";
interface AuthState {
user: User | null;
@@ -48,6 +49,7 @@ export const useAuthStore = create<AuthState>((set) => ({
const { token, user } = await api.verifyCode(email, code);
localStorage.setItem("multica_token", token);
api.setToken(token);
setLoggedInCookie();
set({ user });
return user;
},
@@ -57,6 +59,7 @@ export const useAuthStore = create<AuthState>((set) => ({
localStorage.removeItem("multica_workspace_id");
api.setToken(null);
api.setWorkspaceId(null);
clearLoggedInCookie();
set({ user: null });
},

View File

@@ -0,0 +1,389 @@
/*
* ContentEditor typography — ProseMirror styles using shadcn design tokens.
*
* Design tier: "Compact" (same tier as Linear, Slack). Optimized for short-form
* content (issue descriptions, comments) that users scan, not long-form reading.
*
* Typography values benchmarked against (April 2026):
* - github-markdown-css (GitHub's markdown renderer)
* - @tailwindcss/typography prose-sm preset
* - Linear's editor (Tiptap-based, 14px body)
*
* Key decisions:
* Body: 14px (text-sm), line-height 1.625 (between GitHub 1.5 and Tailwind 1.714)
* Headings: h1=22px (1.57x), h2=18px (1.29x), h3=15px (1.07x) — compact but
* with clear hierarchy. Previous h3 was 14px (same as body = no differentiation).
* Paragraph spacing: 10px (was 8px; GitHub uses 10px, Tailwind prose-sm uses 16px)
* List indent: 20px for ul (was 16px; standard is 22-32px)
* Code block margin: 12px (was 8px; gives breathing room between code and prose)
* Blockquote border: 3px (was 2px; GitHub/Tailwind both use 4px)
* Links: var(--brand) blue with 40% opacity underline (was var(--primary) near-black)
*
* Inline elements (mention cards, inline code) that exceed line-height:
* The browser auto-expands the line box for lines containing taller inline
* elements. Controlled via vertical-align on [data-node-view-wrapper] and
* box-decoration-break: clone on inline code.
*/
.rich-text-editor.ProseMirror {
color: var(--foreground);
caret-color: var(--foreground);
}
.rich-text-editor.ProseMirror:focus {
outline: none;
}
/* Placeholder */
.rich-text-editor .is-editor-empty:first-child::before {
content: attr(data-placeholder);
float: left;
color: var(--muted-foreground);
pointer-events: none;
height: 0;
}
/* Headings — compact but with clear visual hierarchy */
.rich-text-editor h1 {
font-size: 1.375rem;
font-weight: 700;
margin-top: 1.5rem;
margin-bottom: 0.5rem;
line-height: 1.3;
letter-spacing: -0.01em;
}
.rich-text-editor h2 {
font-size: 1.125rem;
font-weight: 600;
margin-top: 1.5rem;
margin-bottom: 0.5rem;
line-height: 1.35;
}
.rich-text-editor h3 {
font-size: 0.9375rem;
font-weight: 600;
margin-top: 1rem;
margin-bottom: 0.5rem;
line-height: 1.4;
}
/* Paragraphs */
.rich-text-editor p {
margin-top: 0.625rem;
margin-bottom: 0.625rem;
line-height: 1.625;
}
/* First child should not have top margin */
.rich-text-editor > *:first-child {
margin-top: 0;
}
/* Last child should not have bottom margin */
.rich-text-editor > *:last-child {
margin-bottom: 0;
}
/* Lists */
.rich-text-editor ul {
list-style-type: disc;
padding-inline-start: 1.25rem;
padding-inline-end: 0.5rem;
margin: 0.5rem 0;
}
.rich-text-editor ol {
list-style-type: decimal;
padding-inline-start: 1.5rem;
margin: 0.5rem 0;
}
.rich-text-editor li {
margin: 0.25rem 0;
line-height: 1.625;
}
.rich-text-editor li + li {
margin-top: 0.25rem;
}
.rich-text-editor li::marker {
color: var(--muted-foreground);
}
/* Remove paragraph margins inside list items (Tiptap wraps li content in <p>) */
.rich-text-editor li > p {
margin: 0;
}
.rich-text-editor li > p + p {
margin-top: 0.25rem;
}
/* Nested lists — bullet style progression and tighter spacing */
.rich-text-editor ul ul {
list-style-type: circle;
margin: 0.25rem 0;
}
.rich-text-editor ul ul ul {
list-style-type: square;
}
.rich-text-editor ol ol {
list-style-type: lower-alpha;
margin: 0.25rem 0;
}
.rich-text-editor ol ol ol {
list-style-type: lower-roman;
}
/* Inline code */
.rich-text-editor code {
font-family: var(--font-mono, ui-monospace, monospace);
font-size: 0.875rem;
background: color-mix(in srgb, var(--foreground) 3%, transparent);
border: 1px solid color-mix(in srgb, var(--foreground) 5%, transparent);
color: color-mix(in srgb, var(--foreground) 75%, transparent);
padding: 0.125rem 0.375rem;
border-radius: var(--radius-sm);
box-decoration-break: clone;
-webkit-box-decoration-break: clone;
line-height: 2;
}
/* Code blocks */
.rich-text-editor pre {
font-family: var(--font-mono, ui-monospace, monospace);
background: var(--muted);
border-radius: var(--radius);
padding: 0.75rem 1rem;
margin: 0.75rem 0;
overflow-x: auto;
}
.rich-text-editor pre code {
background: none;
border: none;
color: var(--foreground);
padding: 0;
font-size: 0.8125rem;
line-height: 1.6;
}
/* Syntax highlighting — lowlight (hljs) */
.rich-text-editor .hljs-keyword,
.rich-text-editor .hljs-selector-tag,
.rich-text-editor .hljs-built_in { color: oklch(0.55 0.16 255); }
.rich-text-editor .hljs-string,
.rich-text-editor .hljs-addition { color: oklch(0.55 0.14 155); }
.rich-text-editor .hljs-comment,
.rich-text-editor .hljs-quote { color: var(--muted-foreground); font-style: italic; }
.rich-text-editor .hljs-number,
.rich-text-editor .hljs-literal { color: oklch(0.58 0.16 30); }
.rich-text-editor .hljs-title,
.rich-text-editor .hljs-section,
.rich-text-editor .hljs-title\.function_ { color: oklch(0.55 0.14 280); }
.rich-text-editor .hljs-attr,
.rich-text-editor .hljs-attribute { color: oklch(0.58 0.12 60); }
.rich-text-editor .hljs-variable,
.rich-text-editor .hljs-template-variable { color: oklch(0.58 0.14 20); }
.rich-text-editor .hljs-type,
.rich-text-editor .hljs-title\.class_ { color: oklch(0.55 0.14 200); }
.rich-text-editor .hljs-deletion { color: oklch(0.55 0.2 25); }
.rich-text-editor .hljs-meta { color: var(--muted-foreground); }
/* Dark mode overrides */
.dark .rich-text-editor .hljs-keyword,
.dark .rich-text-editor .hljs-selector-tag,
.dark .rich-text-editor .hljs-built_in { color: oklch(0.7 0.14 255); }
.dark .rich-text-editor .hljs-string,
.dark .rich-text-editor .hljs-addition { color: oklch(0.7 0.14 155); }
.dark .rich-text-editor .hljs-number,
.dark .rich-text-editor .hljs-literal { color: oklch(0.72 0.14 30); }
.dark .rich-text-editor .hljs-title,
.dark .rich-text-editor .hljs-section,
.dark .rich-text-editor .hljs-title\.function_ { color: oklch(0.72 0.12 280); }
.dark .rich-text-editor .hljs-attr,
.dark .rich-text-editor .hljs-attribute { color: oklch(0.72 0.1 60); }
.dark .rich-text-editor .hljs-variable,
.dark .rich-text-editor .hljs-template-variable { color: oklch(0.72 0.12 20); }
.dark .rich-text-editor .hljs-type,
.dark .rich-text-editor .hljs-title\.class_ { color: oklch(0.72 0.12 200); }
.dark .rich-text-editor .hljs-deletion { color: oklch(0.7 0.18 25); }
/* Tables */
.rich-text-editor .tableWrapper {
overflow-x: auto;
margin: 1rem 0;
border: 1px solid var(--border);
border-radius: var(--radius);
}
.rich-text-editor table {
min-width: 100%;
border-collapse: collapse;
}
.rich-text-editor colgroup {
display: none;
}
.rich-text-editor thead {
background: color-mix(in srgb, var(--muted) 50%, transparent);
}
.rich-text-editor tbody tr {
border-top: 1px solid var(--border);
}
.rich-text-editor tr:hover td {
background: color-mix(in srgb, var(--muted) 30%, transparent);
transition: background 0.15s;
}
.rich-text-editor th,
.rich-text-editor td {
text-align: left;
padding: 0.625rem 1rem;
font-size: 0.875rem;
}
.rich-text-editor th {
font-weight: 600;
}
/* Remove paragraph margin inside table cells */
.rich-text-editor th p,
.rich-text-editor td p {
margin: 0;
}
/* Blockquotes */
.rich-text-editor blockquote {
border-left: 3px solid color-mix(in srgb, var(--muted-foreground) 30%, transparent);
padding-left: 0.75rem;
margin: 0.625rem 0;
color: var(--muted-foreground);
font-style: italic;
}
.rich-text-editor blockquote p {
margin-top: 0.25rem;
margin-bottom: 0.25rem;
}
.rich-text-editor blockquote > *:first-child {
margin-top: 0;
}
.rich-text-editor blockquote > *:last-child {
margin-bottom: 0;
}
.rich-text-editor blockquote blockquote {
margin-top: 0.25rem;
margin-bottom: 0.25rem;
border-left-color: color-mix(in srgb, var(--muted-foreground) 15%, transparent);
}
/* Horizontal rules */
.rich-text-editor hr {
border: none;
border-top: 1px solid var(--border);
margin: 1rem 0;
}
/* Links */
.rich-text-editor a {
color: var(--brand);
text-decoration: underline;
text-decoration-color: color-mix(in srgb, var(--brand) 40%, transparent);
text-underline-offset: 2px;
cursor: pointer;
}
.rich-text-editor a:hover {
text-decoration-color: var(--brand);
}
/* Issue mention cards — inline cards that sit within text flow */
.rich-text-editor a.issue-mention {
color: inherit;
text-decoration: none;
}
.rich-text-editor a.issue-mention:hover {
text-decoration: none;
}
/* Mentions */
.rich-text-editor .mention {
color: var(--primary);
font-weight: 600;
text-decoration: none;
margin: 0 0.125rem;
}
/* Strong / emphasis */
.rich-text-editor strong {
font-weight: 600;
}
.rich-text-editor em {
font-style: italic;
}
.rich-text-editor s {
text-decoration: line-through;
color: var(--muted-foreground);
}
/* Readonly mode overrides */
.rich-text-editor.readonly.ProseMirror {
caret-color: transparent;
cursor: default;
}
/* Mention NodeView inline layout fix */
.rich-text-editor [data-node-view-wrapper] {
display: inline;
vertical-align: middle;
}
/* Images — shared styling for both editing and readonly */
.rich-text-editor img {
border-radius: var(--radius);
margin: 0.5rem 0;
}
/* Uploading image placeholder — data-uploading attribute managed by ProseMirror schema */
.rich-text-editor img[data-uploading] {
opacity: 0.5;
border-radius: var(--radius);
animation: rte-upload-pulse 2s cubic-bezier(0.4, 0, 0.6, 1) infinite;
}
@keyframes rte-upload-pulse {
0%, 100% { opacity: 0.5; }
50% { opacity: 0.3; }
}

View File

@@ -0,0 +1,198 @@
"use client";
/**
* ContentEditor — the single rich-text editor for the entire application.
*
* Architecture decisions (April 2026 refactor):
*
* 1. ONE COMPONENT for both editing and readonly display. The `editable` prop
* controls the mode. Previously we had RichTextEditor + ReadonlyEditor as
* separate components with duplicated extension configs — this caused
* visual inconsistency between edit and display modes.
*
* 2. ONE MARKDOWN PIPELINE via @tiptap/markdown. Content is loaded with
* `contentType: 'markdown'` and saved with `editor.getMarkdown()`.
* Previously we had a custom `markdownToHtml()` pipeline (Marked library)
* for loading and regex post-processing for saving — two asymmetric paths
* that caused roundtrip inconsistencies. The @tiptap/markdown extension
* (v3.21.0+) handles table cell <p> wrapping and custom mention tokenizers
* natively, eliminating the need for the HTML detour.
*
* 3. PREPROCESSING is minimal: only legacy mention shortcode migration and
* URL linkification (preprocessMarkdown). No HTML conversion.
*
* Tech: Tiptap v3.22.1 (ProseMirror wrapper), @tiptap/markdown for
* bidirectional Markdown ↔ ProseMirror JSON conversion.
*/
import {
forwardRef,
useEffect,
useImperativeHandle,
useRef,
} from "react";
import { useEditor, EditorContent } from "@tiptap/react";
import { cn } from "@/lib/utils";
import type { UploadResult } from "@/shared/hooks/use-file-upload";
import { createEditorExtensions } from "./extensions";
import { uploadAndInsertFile } from "./extensions/file-upload";
import { preprocessMarkdown } from "./utils/preprocess";
import "./content-editor.css";
// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------
interface ContentEditorProps {
defaultValue?: string;
onUpdate?: (markdown: string) => void;
placeholder?: string;
editable?: boolean;
className?: string;
debounceMs?: number;
onSubmit?: () => void;
onBlur?: () => void;
onUploadFile?: (file: File) => Promise<UploadResult | null>;
}
interface ContentEditorRef {
getMarkdown: () => string;
clearContent: () => void;
focus: () => void;
uploadFile: (file: File) => void;
}
// ---------------------------------------------------------------------------
// Component
// ---------------------------------------------------------------------------
const ContentEditor = forwardRef<ContentEditorRef, ContentEditorProps>(
function ContentEditor(
{
defaultValue = "",
onUpdate,
placeholder: placeholderText = "",
editable = true,
className,
debounceMs = 300,
onSubmit,
onBlur,
onUploadFile,
},
ref,
) {
const debounceRef = useRef<ReturnType<typeof setTimeout>>(undefined);
const onUpdateRef = useRef(onUpdate);
const onSubmitRef = useRef(onSubmit);
const onBlurRef = useRef(onBlur);
const onUploadFileRef = useRef(onUploadFile);
const prevContentRef = useRef(defaultValue);
// Keep refs in sync without recreating editor
onUpdateRef.current = onUpdate;
onSubmitRef.current = onSubmit;
onBlurRef.current = onBlur;
onUploadFileRef.current = onUploadFile;
const editor = useEditor({
immediatelyRender: false,
editable,
content: defaultValue ? preprocessMarkdown(defaultValue) : "",
contentType: defaultValue ? "markdown" : undefined,
extensions: createEditorExtensions({
editable,
placeholder: placeholderText,
onSubmitRef,
onUploadFileRef,
}),
onUpdate: ({ editor: ed }) => {
if (!onUpdateRef.current) return;
if (debounceRef.current) clearTimeout(debounceRef.current);
debounceRef.current = setTimeout(() => {
onUpdateRef.current?.(ed.getMarkdown());
}, debounceMs);
},
onBlur: () => {
onBlurRef.current?.();
},
editorProps: {
handleDOMEvents: {
click(_view, event) {
const target = event.target as HTMLElement;
// Skip links inside NodeView wrappers — they handle their own clicks
if (target.closest("[data-node-view-wrapper]")) return false;
const link = target.closest("a");
const href = link?.getAttribute("href");
if (!href || href.startsWith("mention://")) return false;
if (!editable) {
// Readonly: any click on link opens new tab
event.preventDefault();
window.open(href, "_blank", "noopener,noreferrer");
return true;
}
if (event.metaKey || event.ctrlKey) {
// Edit mode: Cmd/Ctrl+click opens link
window.open(href, "_blank", "noopener,noreferrer");
event.preventDefault();
return true;
}
return false;
},
},
attributes: {
class: cn(
"rich-text-editor text-sm outline-none",
!editable && "readonly",
className,
),
},
},
});
// Cleanup debounce on unmount
useEffect(() => {
return () => {
if (debounceRef.current) clearTimeout(debounceRef.current);
};
}, []);
// Readonly content update: when defaultValue changes and editor is readonly,
// re-set the content (e.g. after editing a comment, the readonly view updates)
useEffect(() => {
if (!editor || editable) return;
if (defaultValue === prevContentRef.current) return;
prevContentRef.current = defaultValue;
const processed = defaultValue ? preprocessMarkdown(defaultValue) : "";
if (processed) {
editor.commands.setContent(processed, { contentType: "markdown" });
} else {
editor.commands.clearContent();
}
}, [editor, editable, defaultValue]);
useImperativeHandle(ref, () => ({
getMarkdown: () => editor?.getMarkdown() ?? "",
clearContent: () => {
editor?.commands.clearContent();
},
focus: () => {
editor?.commands.focus();
},
uploadFile: (file: File) => {
if (!editor || !onUploadFileRef.current) return;
const endPos = editor.state.doc.content.size;
uploadAndInsertFile(editor, file, onUploadFileRef.current, endPos);
},
}));
if (!editor) return null;
return <EditorContent editor={editor} />;
},
);
export { ContentEditor, type ContentEditorProps, type ContentEditorRef };

View File

@@ -0,0 +1,52 @@
"use client";
import { useState } from "react";
import { NodeViewWrapper, NodeViewContent } from "@tiptap/react";
import type { NodeViewProps } from "@tiptap/react";
import { Copy, Check } from "lucide-react";
function CodeBlockView({ node }: NodeViewProps) {
const [copied, setCopied] = useState(false);
const language = node.attrs.language || "";
const handleCopy = async () => {
const text = node.textContent;
if (!text) return;
await navigator.clipboard.writeText(text);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
};
return (
<NodeViewWrapper className="code-block-wrapper group/code relative my-2">
<div
contentEditable={false}
className="code-block-header absolute top-0 right-0 z-10 flex items-center gap-1.5 px-2 py-1.5 opacity-0 transition-opacity group-hover/code:opacity-100"
>
{language && (
<span className="text-xs text-muted-foreground select-none">
{language}
</span>
)}
<button
type="button"
onClick={handleCopy}
className="flex h-6 w-6 items-center justify-center rounded text-muted-foreground hover:bg-muted hover:text-foreground transition-colors"
title="Copy code"
>
{copied ? (
<Check className="h-3.5 w-3.5" />
) : (
<Copy className="h-3.5 w-3.5" />
)}
</button>
</div>
<pre spellCheck={false}>
{/* @ts-expect-error -- NodeViewContent supports as="code" at runtime */}
<NodeViewContent as="code" />
</pre>
</NodeViewWrapper>
);
}
export { CodeBlockView };

View File

@@ -0,0 +1,119 @@
import { Extension } from "@tiptap/core";
import { Plugin, PluginKey } from "@tiptap/pm/state";
import type { UploadResult } from "@/shared/hooks/use-file-upload";
// eslint-disable-next-line @typescript-eslint/no-explicit-any
function removeImageBySrc(editor: any, src: string) {
if (!editor) return;
const { tr } = editor.state;
let deleted = false;
editor.state.doc.descendants((node: any, pos: number) => {
if (deleted) return false;
if (node.type.name === "image" && node.attrs.src === src) {
tr.delete(pos, pos + node.nodeSize);
deleted = true;
return false;
}
});
if (deleted) editor.view.dispatch(tr);
}
/**
* Shared upload flow: insert blob preview → upload → replace with real URL.
* Used by both paste/drop (at cursor) and button upload (at end of doc).
*/
export async function uploadAndInsertFile(
// eslint-disable-next-line @typescript-eslint/no-explicit-any
editor: any,
file: File,
handler: (file: File) => Promise<UploadResult | null>,
pos?: number,
) {
const isImage = file.type.startsWith("image/");
if (isImage) {
const blobUrl = URL.createObjectURL(file);
const imgAttrs = { src: blobUrl, alt: file.name, uploading: true };
if (pos !== undefined) {
editor.chain().focus().insertContentAt(pos, { type: "image", attrs: imgAttrs }).run();
} else {
editor.chain().focus().setImage(imgAttrs).run();
}
try {
const result = await handler(file);
if (result) {
const { tr } = editor.state;
editor.state.doc.descendants((node: { type: { name: string }; attrs: { src: string } }, nodePos: number) => {
if (node.type.name === "image" && node.attrs.src === blobUrl) {
tr.setNodeMarkup(nodePos, undefined, {
...node.attrs,
src: result.link,
alt: result.filename,
uploading: false,
});
}
});
editor.view.dispatch(tr);
} else {
removeImageBySrc(editor, blobUrl);
}
} catch {
removeImageBySrc(editor, blobUrl);
} finally {
URL.revokeObjectURL(blobUrl);
}
} else {
// Non-image: upload first, then insert link
const result = await handler(file);
if (!result) return;
const linkText = `[${result.filename}](${result.link})`;
if (pos !== undefined) {
editor.chain().focus().insertContentAt(pos, linkText).run();
} else {
editor.chain().focus().insertContent(linkText).run();
}
}
}
export function createFileUploadExtension(
onUploadFileRef: React.RefObject<((file: File) => Promise<UploadResult | null>) | undefined>,
) {
return Extension.create({
name: "fileUpload",
addProseMirrorPlugins() {
const { editor } = this;
const handleFiles = async (files: FileList) => {
const handler = onUploadFileRef.current;
if (!handler) return false;
for (const file of Array.from(files)) {
await uploadAndInsertFile(editor, file, handler);
}
return true;
};
return [
new Plugin({
key: new PluginKey("fileUpload"),
props: {
handlePaste(_view, event) {
const files = event.clipboardData?.files;
if (!files?.length) return false;
if (!onUploadFileRef.current) return false;
handleFiles(files);
return true;
},
handleDrop(_view, event) {
const files = (event as DragEvent).dataTransfer?.files;
if (!files?.length) return false;
if (!onUploadFileRef.current) return false;
handleFiles(files);
return true;
},
},
}),
];
},
});
}

View File

@@ -0,0 +1,125 @@
/**
* Shared extension factory for ContentEditor.
*
* One function builds the extension array for BOTH edit and readonly modes.
* This ensures visual consistency — the same extensions parse and render
* content identically regardless of mode.
*
* Split:
* - Both modes: StarterKit, CodeBlock, Link, Image, Table, Markdown, Mention
* - Edit only: Typography, Placeholder, markdownPaste, submitShortcut,
* fileUpload, Mention suggestion popup
*
* Link config differs: edit mode has autolink (detects URLs while typing),
* readonly does not (prevents false positives on display).
*
* Mention suggestion is only attached in edit mode — readonly doesn't need
* the autocomplete popup.
*
* All link styling is controlled by content-editor.css (var(--brand) color),
* not Tailwind HTMLAttributes, to keep a single source of truth.
*/
import type { RefObject } from "react";
import StarterKit from "@tiptap/starter-kit";
import CodeBlockLowlight from "@tiptap/extension-code-block-lowlight";
import { common, createLowlight } from "lowlight";
import Placeholder from "@tiptap/extension-placeholder";
import Link from "@tiptap/extension-link";
import Typography from "@tiptap/extension-typography";
import Image from "@tiptap/extension-image";
import TableRow from "@tiptap/extension-table-row";
import TableHeader from "@tiptap/extension-table-header";
import TableCell from "@tiptap/extension-table-cell";
import { Table } from "@tiptap/extension-table";
import { Markdown } from "@tiptap/markdown";
import { ReactNodeViewRenderer } from "@tiptap/react";
import type { AnyExtension } from "@tiptap/core";
import type { UploadResult } from "@/shared/hooks/use-file-upload";
import { BaseMentionExtension } from "./mention-extension";
import { createMentionSuggestion } from "./mention-suggestion";
import { CodeBlockView } from "./code-block-view";
import { createMarkdownPasteExtension } from "./markdown-paste";
import { createSubmitExtension } from "./submit-shortcut";
import { createFileUploadExtension } from "./file-upload";
const lowlight = createLowlight(common);
const LinkEditable = Link.extend({ inclusive: false }).configure({
openOnClick: true,
autolink: true,
linkOnPaste: false,
});
const LinkReadonly = Link.configure({
openOnClick: false,
autolink: false,
});
const ImageExtension = Image.extend({
addAttributes() {
return {
...this.parent?.(),
uploading: {
default: false,
renderHTML: (attrs: Record<string, unknown>) =>
attrs.uploading ? { "data-uploading": "" } : {},
parseHTML: (el: HTMLElement) => el.hasAttribute("data-uploading"),
},
};
},
}).configure({
inline: false,
allowBase64: false,
HTMLAttributes: { style: "max-width: 100%; height: auto;" },
});
export interface EditorExtensionsOptions {
editable: boolean;
placeholder?: string;
onSubmitRef?: RefObject<(() => void) | undefined>;
onUploadFileRef?: RefObject<
((file: File) => Promise<UploadResult | null>) | undefined
>;
}
export function createEditorExtensions(
options: EditorExtensionsOptions,
): AnyExtension[] {
const { editable, placeholder: placeholderText } = options;
const extensions: AnyExtension[] = [
StarterKit.configure({
heading: { levels: [1, 2, 3] },
link: false,
codeBlock: false,
}),
CodeBlockLowlight.extend({
addNodeView() {
return ReactNodeViewRenderer(CodeBlockView);
},
}).configure({ lowlight }),
editable ? LinkEditable : LinkReadonly,
ImageExtension,
Table.configure({ resizable: false }),
TableRow,
TableHeader,
TableCell,
Markdown,
BaseMentionExtension.configure({
HTMLAttributes: { class: "mention" },
...(editable ? { suggestion: createMentionSuggestion() } : {}),
}),
];
if (editable) {
extensions.push(
Typography,
Placeholder.configure({ placeholder: placeholderText }),
createMarkdownPasteExtension(),
createSubmitExtension(() => options.onSubmitRef?.current?.()),
createFileUploadExtension(options.onUploadFileRef!),
);
}
return extensions;
}

View File

@@ -0,0 +1,67 @@
/**
* Markdown paste extension — ensures pasted text is parsed as Markdown.
*
* Problem: The browser clipboard can contain BOTH text/plain and text/html.
* ProseMirror always prefers text/html when present (hardcoded in
* parseFromClipboard: `let asText = !html`). When copying from VS Code,
* text editors, or .md files, the OS wraps text in <pre>/<div> HTML tags.
* ProseMirror parses these as code blocks — wrong.
*
* Solution: Use `handlePaste` (the only ProseMirror prop that runs for ALL
* paste events and has access to raw ClipboardEvent). We check for
* `data-pm-slice` in the HTML — this attribute is added by ProseMirror's
* own clipboard serializer. If present, the source is another ProseMirror
* editor and its HTML is structurally correct — let ProseMirror handle it.
* Otherwise, ignore the HTML and parse text/plain as Markdown.
*
* Why not clipboardTextParser? It only runs when there's NO text/html on
* the clipboard (ProseMirror source: `let asText = !!text && !html`).
*
* Why not heuristic detection (looksLikeMarkdown / hasRichHtml)? Unreliable.
* VS Code's HTML contains <code> tags that fool rich-content detectors.
* Markdown pattern matching has too many edge cases. The data-pm-slice
* check is deterministic — no false positives.
*/
import { Extension } from "@tiptap/core";
import { Plugin, PluginKey } from "@tiptap/pm/state";
import { Slice } from "@tiptap/pm/model";
export function createMarkdownPasteExtension() {
return Extension.create({
name: "markdownPaste",
addProseMirrorPlugins() {
const { editor } = this;
return [
new Plugin({
key: new PluginKey("markdownPaste"),
props: {
handlePaste(view, event) {
if (!editor.markdown) return false;
const clipboard = event.clipboardData;
if (!clipboard) return false;
const text = clipboard.getData("text/plain");
if (!text) return false;
const html = clipboard.getData("text/html");
// If HTML contains data-pm-slice, the source is another
// ProseMirror editor — let ProseMirror use its native HTML
// clipboard path to preserve exact node structure.
if (html && html.includes("data-pm-slice")) return false;
// Everything else (VS Code, text editors, .md files, terminals,
// web pages): parse text/plain as Markdown.
const json = editor.markdown.parse(text);
const node = editor.schema.nodeFromJSON(json);
const slice = Slice.maxOpen(node.content);
const tr = view.state.tr.replaceSelection(slice);
view.dispatch(tr);
return true;
},
},
}),
];
},
});
}

View File

@@ -0,0 +1,64 @@
import Mention from "@tiptap/extension-mention";
import { mergeAttributes } from "@tiptap/core";
import { ReactNodeViewRenderer } from "@tiptap/react";
import { MentionView } from "./mention-view";
export const BaseMentionExtension = Mention.extend({
addNodeView() {
return ReactNodeViewRenderer(MentionView);
},
renderHTML({ node, HTMLAttributes }) {
const type = node.attrs.type ?? "member";
const prefix = type === "issue" ? "" : "@";
return [
"span",
mergeAttributes(
{ "data-type": "mention" },
this.options.HTMLAttributes,
HTMLAttributes,
{
"data-mention-type": node.attrs.type ?? "member",
"data-mention-id": node.attrs.id,
},
),
`${prefix}${node.attrs.label ?? node.attrs.id}`,
];
},
addAttributes() {
return {
...this.parent?.(),
type: {
default: "member",
parseHTML: (el: HTMLElement) =>
el.getAttribute("data-mention-type") ?? "member",
renderHTML: () => ({}),
},
};
},
markdownTokenizer: {
name: "mention",
level: "inline" as const,
start(src: string) {
return src.search(/\[@?[^\]]+\]\(mention:\/\//);
},
tokenize(src: string) {
const match = src.match(
/^\[@?([^\]]+)\]\(mention:\/\/(\w+)\/([^)]+)\)/,
);
if (!match) return undefined;
return {
type: "mention",
raw: match[0],
attributes: { label: match[1], type: match[2] ?? "member", id: match[3] },
};
},
},
parseMarkdown: (token: any, helpers: any) => {
return helpers.createNode("mention", token.attributes);
},
renderMarkdown: (node: any) => {
const { id, label, type = "member" } = node.attrs || {};
const prefix = type === "issue" ? "" : "@";
return `[${prefix}${label ?? id}](mention://${type}/${id})`;
},
});

View File

@@ -2,15 +2,20 @@
import {
forwardRef,
useCallback,
useEffect,
useImperativeHandle,
useRef,
useState,
} from "react";
import { Bot, Hash } from "lucide-react";
import { ReactRenderer } from "@tiptap/react";
import { computePosition, offset, flip, shift } from "@floating-ui/dom";
import { useWorkspaceStore } from "@/features/workspace";
import { useIssueStore } from "@/features/issues";
import { ActorAvatar } from "@/components/common/actor-avatar";
import { StatusIcon } from "@/features/issues/components/status-icon";
import { Badge } from "@/components/ui/badge";
import type { IssueStatus } from "@/shared/types";
import type { SuggestionOptions, SuggestionProps } from "@tiptap/suggestion";
// ---------------------------------------------------------------------------
@@ -20,9 +25,11 @@ import type { SuggestionOptions, SuggestionProps } from "@tiptap/suggestion";
export interface MentionItem {
id: string;
label: string;
type: "member" | "agent" | "issue";
/** Secondary text shown below the label (e.g. issue title) */
type: "member" | "agent" | "issue" | "all";
/** Secondary text shown beside the label (e.g. issue title) */
description?: string;
/** Issue status for StatusIcon rendering */
status?: IssueStatus;
}
interface MentionListProps {
@@ -34,6 +41,33 @@ export interface MentionListRef {
onKeyDown: (props: { event: KeyboardEvent }) => boolean;
}
// ---------------------------------------------------------------------------
// Group items by section
// ---------------------------------------------------------------------------
interface MentionGroup {
label: string;
items: MentionItem[];
}
function groupItems(items: MentionItem[]): MentionGroup[] {
const users: MentionItem[] = [];
const issues: MentionItem[] = [];
for (const item of items) {
if (item.type === "issue") {
issues.push(item);
} else {
users.push(item);
}
}
const groups: MentionGroup[] = [];
if (users.length > 0) groups.push({ label: "Users", items: users });
if (issues.length > 0) groups.push({ label: "Issues", items: issues });
return groups;
}
// ---------------------------------------------------------------------------
// MentionList — the popup rendered inside the editor
// ---------------------------------------------------------------------------
@@ -41,15 +75,23 @@ export interface MentionListRef {
const MentionList = forwardRef<MentionListRef, MentionListProps>(
function MentionList({ items, command }, ref) {
const [selectedIndex, setSelectedIndex] = useState(0);
const itemRefs = useRef<(HTMLButtonElement | null)[]>([]);
useEffect(() => {
setSelectedIndex(0);
}, [items]);
const selectItem = (index: number) => {
const item = items[index];
if (item) command(item);
};
useEffect(() => {
itemRefs.current[selectedIndex]?.scrollIntoView({ block: "nearest" });
}, [selectedIndex]);
const selectItem = useCallback(
(index: number) => {
const item = items[index];
if (item) command(item);
},
[items, command],
);
useImperativeHandle(ref, () => ({
onKeyDown: ({ event }) => {
@@ -77,47 +119,93 @@ const MentionList = forwardRef<MentionListRef, MentionListProps>(
);
}
const groups = groupItems(items);
// Build a flat index mapping: globalIndex → item
let globalIndex = 0;
return (
<div className="rounded-md border bg-popover py-1 shadow-md min-w-[180px] max-h-[240px] overflow-y-auto">
{items.map((item, index) => (
<button
key={`${item.type}-${item.id}`}
className={`flex w-full items-center gap-2 px-2.5 py-1.5 text-left text-sm transition-colors ${
index === selectedIndex ? "bg-accent" : "hover:bg-accent/50"
}`}
onClick={() => selectItem(index)}
>
{item.type === "agent" ? (
<span className="inline-flex h-5 w-5 shrink-0 items-center justify-center rounded-full bg-info/10 text-info">
<Bot className="h-3 w-3" />
</span>
) : item.type === "issue" ? (
<span className="inline-flex h-5 w-5 shrink-0 items-center justify-center rounded-full bg-primary/10 text-primary">
<Hash className="h-3 w-3" />
</span>
) : (
<span className="inline-flex h-5 w-5 shrink-0 items-center justify-center rounded-full bg-muted text-muted-foreground text-[9px] font-medium">
{item.label
.split(" ")
.map((w) => w[0])
.join("")
.toUpperCase()
.slice(0, 2)}
</span>
)}
<div className="flex flex-col min-w-0">
<span className="truncate">{item.label}</span>
{item.description && (
<span className="truncate text-xs text-muted-foreground">{item.description}</span>
)}
<div className="rounded-md border bg-popover py-1 shadow-md w-72 max-h-[300px] overflow-y-auto">
{groups.map((group) => (
<div key={group.label}>
<div className="px-3 py-1.5 text-xs font-medium text-muted-foreground">
{group.label}
</div>
</button>
{group.items.map((item) => {
const idx = globalIndex++;
return (
<MentionRow
key={`${item.type}-${item.id}`}
item={item}
selected={idx === selectedIndex}
onSelect={() => selectItem(idx)}
buttonRef={(el) => { itemRefs.current[idx] = el; }}
/>
);
})}
</div>
))}
</div>
);
},
);
// ---------------------------------------------------------------------------
// MentionRow — single item in the list
// ---------------------------------------------------------------------------
function MentionRow({
item,
selected,
onSelect,
buttonRef,
}: {
item: MentionItem;
selected: boolean;
onSelect: () => void;
buttonRef: (el: HTMLButtonElement | null) => void;
}) {
if (item.type === "issue") {
return (
<button
ref={buttonRef}
className={`flex w-full items-center gap-2.5 px-3 py-1.5 text-left text-xs transition-colors ${
selected ? "bg-accent" : "hover:bg-accent/50"
}`}
onClick={onSelect}
>
{item.status && (
<StatusIcon status={item.status} className="h-3.5 w-3.5 shrink-0" />
)}
<span className="shrink-0 text-muted-foreground">{item.label}</span>
{item.description && (
<span className="truncate text-muted-foreground">{item.description}</span>
)}
</button>
);
}
return (
<button
ref={buttonRef}
className={`flex w-full items-center gap-2.5 px-3 py-1.5 text-left text-xs transition-colors ${
selected ? "bg-accent" : "hover:bg-accent/50"
}`}
onClick={onSelect}
>
<ActorAvatar
actorType={item.type === "all" ? "member" : item.type}
actorId={item.id}
size={20}
/>
<span className="truncate font-medium">{item.label}</span>
{item.type === "agent" && (
<Badge variant="outline" className="ml-auto text-[10px] h-4 px-1.5">Agent</Badge>
)}
</button>
);
}
// ---------------------------------------------------------------------------
// Suggestion config factory
// ---------------------------------------------------------------------------
@@ -132,6 +220,12 @@ export function createMentionSuggestion(): Omit<
const { issues } = useIssueStore.getState();
const q = query.toLowerCase();
// Show "All members" option when query is empty or matches "all"
const allItem: MentionItem[] =
"all members".includes(q) || "all".includes(q)
? [{ id: "all", label: "All members", type: "all" as const }]
: [];
const memberItems: MentionItem[] = members
.filter((m) => m.name.toLowerCase().includes(q))
.map((m) => ({
@@ -141,7 +235,7 @@ export function createMentionSuggestion(): Omit<
}));
const agentItems: MentionItem[] = agents
.filter((a) => a.name.toLowerCase().includes(q))
.filter((a) => !a.archived_at && a.name.toLowerCase().includes(q))
.map((a) => ({ id: a.id, label: a.name, type: "agent" as const }));
const issueItems: MentionItem[] = issues
@@ -155,9 +249,10 @@ export function createMentionSuggestion(): Omit<
label: i.identifier,
type: "issue" as const,
description: i.title,
status: i.status as IssueStatus,
}));
return [...memberItems, ...agentItems, ...issueItems].slice(0, 10);
return [...allItem, ...memberItems, ...agentItems, ...issueItems].slice(0, 10);
},
render: () => {

View File

@@ -0,0 +1,79 @@
"use client";
/**
* MentionView — NodeView for rendering @mentions inline in the editor.
*
* Member/agent mentions: plain "@Name" text with .mention class styling.
* Issue mentions: inline card with StatusIcon + identifier + title.
*
* Issue card sizing: must fit within the paragraph line box (14px * 1.625
* = 22.75px). Card uses text-xs (12px) + py-0.5 + border ≈ 22px total.
* vertical-align: middle is set on the [data-node-view-wrapper] in CSS
* (not on the <a> tag) because the wrapper is the outermost inline element
* that participates in line box calculation. Setting it on the inner <a>
* had no effect since the wrapper was already positioned.
*
* Fallback: when issue is not in the Zustand store (deleted or other
* workspace), the same card style is used with just the identifier from
* fallbackLabel — no visual degradation to a plain text link.
*/
import { NodeViewWrapper } from "@tiptap/react";
import type { NodeViewProps } from "@tiptap/react";
import { useIssueStore } from "@/features/issues/store";
import { StatusIcon } from "@/features/issues/components/status-icon";
export function MentionView({ node }: NodeViewProps) {
const { type, id, label } = node.attrs;
if (type === "issue") {
return (
<NodeViewWrapper as="span" className="inline">
<IssueMention issueId={id} fallbackLabel={label} />
</NodeViewWrapper>
);
}
return (
<NodeViewWrapper as="span" className="inline">
<span className="mention">@{label ?? id}</span>
</NodeViewWrapper>
);
}
function IssueMention({
issueId,
fallbackLabel,
}: {
issueId: string;
fallbackLabel?: string;
}) {
const issue = useIssueStore((s) => s.issues.find((i) => i.id === issueId));
const handleClick = (e: React.MouseEvent) => {
e.preventDefault();
e.stopPropagation();
window.open(`/issues/${issueId}`, "_blank", "noopener,noreferrer");
};
const cardClass =
"issue-mention inline-flex items-center gap-1.5 rounded-md border mx-0.5 px-2 py-0.5 text-xs hover:bg-accent transition-colors cursor-pointer max-w-72";
if (!issue) {
return (
<a href={`/issues/${issueId}`} onClick={handleClick} className={cardClass}>
<span className="font-medium text-muted-foreground">
{fallbackLabel ?? issueId.slice(0, 8)}
</span>
</a>
);
}
return (
<a href={`/issues/${issueId}`} onClick={handleClick} className={cardClass}>
<StatusIcon status={issue.status} className="h-3.5 w-3.5 shrink-0" />
<span className="font-medium text-muted-foreground shrink-0">{issue.identifier}</span>
<span className="text-foreground truncate">{issue.title}</span>
</a>
);
}

View File

@@ -0,0 +1,15 @@
import { Extension } from "@tiptap/core";
export function createSubmitExtension(onSubmit: () => void) {
return Extension.create({
name: "submitShortcut",
addKeyboardShortcuts() {
return {
"Mod-Enter": () => {
onSubmit();
return true;
},
};
},
});
}

View File

@@ -0,0 +1,11 @@
export {
ContentEditor,
type ContentEditorProps,
type ContentEditorRef,
} from "./content-editor";
export {
TitleEditor,
type TitleEditorProps,
type TitleEditorRef,
} from "./title-editor";
export { copyMarkdown } from "./utils/clipboard";

View File

@@ -117,11 +117,13 @@ const TitleEditor = forwardRef<TitleEditorRef, TitleEditorProps>(
},
});
// Auto-focus after mount
// Auto-focus after mount — delay to wait for Dialog open animation
useEffect(() => {
if (autoFocus && editor) {
// Move cursor to end
editor.commands.focus("end");
const timer = setTimeout(() => {
editor.commands.focus("end");
}, 50);
return () => clearTimeout(timer);
}
}, [autoFocus, editor]);

View File

@@ -0,0 +1,6 @@
/**
* Copy markdown content to the clipboard.
*/
export async function copyMarkdown(markdown: string): Promise<void> {
await navigator.clipboard.writeText(markdown);
}

View File

@@ -0,0 +1,24 @@
import { preprocessLinks } from "@/components/markdown/linkify";
import { preprocessMentionShortcodes } from "@/components/markdown/mentions";
/**
* Preprocess a markdown string before loading into Tiptap via contentType: 'markdown'.
*
* This is the ONLY transform applied before @tiptap/markdown parses the content.
* It does NOT convert to HTML — that was the old markdownToHtml.ts pipeline which
* was deleted in the April 2026 refactor.
*
* Two string→string transforms on raw Markdown:
* 1. Legacy mention shortcodes [@ id="..." label="..."] → [@Label](mention://member/id)
* (old serialization format in database, migrated on read)
* 2. Raw URLs → markdown links via linkify-it (so they render as clickable Link nodes)
*
* After this, @tiptap/markdown's parse() handles everything else: headings, lists,
* tables, code blocks, and our custom mention tokenizer ([@Name](mention://type/id)).
*/
export function preprocessMarkdown(markdown: string): string {
if (!markdown) return "";
const step1 = preprocessMentionShortcodes(markdown);
const step2 = preprocessLinks(step1);
return step2;
}

View File

@@ -2,6 +2,7 @@
import { create } from "zustand";
import type { InboxItem, IssueStatus } from "@/shared/types";
import { toast } from "sonner";
import { api } from "@/shared/api";
import { createLogger } from "@/shared/logger";
@@ -72,6 +73,7 @@ export const useInboxStore = create<InboxState>((set, get) => ({
set({ items: data, loading: false });
} catch (err) {
logger.error("fetch failed", err);
toast.error("Failed to load inbox");
if (isInitialLoad) set({ loading: false });
}
},

View File

@@ -1,13 +1,16 @@
"use client";
import { useState, useEffect, useCallback, useRef } from "react";
import { Bot, ChevronRight, Loader2, ArrowDown, Brain, AlertCircle, Clock, CheckCircle2, XCircle } from "lucide-react";
import { Bot, ChevronRight, ChevronUp, Loader2, ArrowDown, Brain, AlertCircle, Clock, CheckCircle2, XCircle, Square } from "lucide-react";
import { api } from "@/shared/api";
import { useWSEvent } from "@/features/realtime";
import type { TaskMessagePayload, TaskCompletedPayload, TaskFailedPayload } from "@/shared/types/events";
import type { TaskMessagePayload, TaskCompletedPayload, TaskFailedPayload, TaskCancelledPayload } from "@/shared/types/events";
import type { AgentTask } from "@/shared/types/agent";
import { cn } from "@/lib/utils";
import { toast } from "sonner";
import { ActorAvatar } from "@/components/common/actor-avatar";
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible";
import { useActorName } from "@/features/workspace";
import { redactSecrets } from "../utils/redact";
// ─── Shared types & helpers ─────────────────────────────────────────────────
@@ -96,26 +99,25 @@ function buildTimeline(msgs: TaskMessagePayload[]): TimelineItem[] {
interface AgentLiveCardProps {
issueId: string;
assigneeType: string | null;
assigneeId: string | null;
agentName?: string;
/** Scroll container ref — needed for sticky sentinel detection. */
scrollContainerRef?: React.RefObject<HTMLDivElement | null>;
}
export function AgentLiveCard({ issueId, assigneeType, assigneeId, agentName }: AgentLiveCardProps) {
export function AgentLiveCard({ issueId, agentName, scrollContainerRef }: AgentLiveCardProps) {
const { getActorName } = useActorName();
const [activeTask, setActiveTask] = useState<AgentTask | null>(null);
const [items, setItems] = useState<TimelineItem[]>([]);
const [elapsed, setElapsed] = useState("");
const [autoScroll, setAutoScroll] = useState(true);
const [cancelling, setCancelling] = useState(false);
const [isStuck, setIsStuck] = useState(false);
const scrollRef = useRef<HTMLDivElement>(null);
const sentinelRef = useRef<HTMLDivElement>(null);
const seenSeqs = useRef(new Set<string>());
// Check for active task on mount
useEffect(() => {
if (assigneeType !== "agent" || !assigneeId) {
setActiveTask(null);
return;
}
let cancelled = false;
api.getActiveTaskForIssue(issueId).then(({ task }) => {
if (!cancelled) {
@@ -127,13 +129,13 @@ export function AgentLiveCard({ issueId, assigneeType, assigneeId, agentName }:
setItems(timeline);
for (const m of msgs) seenSeqs.current.add(`${m.task_id}:${m.seq}`);
}
}).catch(() => {});
}).catch(console.error);
}
}
}).catch(() => {});
}).catch(console.error);
return () => { cancelled = true; };
}, [issueId, assigneeType, assigneeId]);
}, [issueId]);
// Handle real-time task messages
useWSEvent(
@@ -170,6 +172,7 @@ export function AgentLiveCard({ issueId, assigneeType, assigneeId, agentName }:
setActiveTask(null);
setItems([]);
seenSeqs.current.clear();
setCancelling(false);
}, [issueId]),
);
@@ -181,32 +184,72 @@ export function AgentLiveCard({ issueId, assigneeType, assigneeId, agentName }:
setActiveTask(null);
setItems([]);
seenSeqs.current.clear();
setCancelling(false);
}, [issueId]),
);
// Pick up new tasks
useWSEvent(
"task:cancelled",
useCallback((payload: unknown) => {
const p = payload as TaskCancelledPayload;
if (p.issue_id !== issueId) return;
setActiveTask(null);
setItems([]);
seenSeqs.current.clear();
setCancelling(false);
}, [issueId]),
);
// Pick up new tasks — skip if we're already showing an active task to avoid
// replacing its timeline mid-execution (per-issue serialization in the
// backend prevents this race, but this is a defensive safeguard).
useWSEvent(
"task:dispatch",
useCallback(() => {
if (activeTask) return;
api.getActiveTaskForIssue(issueId).then(({ task }) => {
if (task) {
setActiveTask(task);
setItems([]);
seenSeqs.current.clear();
}
}).catch(() => {});
}, [issueId]),
}).catch(console.error);
}, [issueId, activeTask]),
);
// Elapsed time
useEffect(() => {
if (!activeTask?.started_at && !activeTask?.dispatched_at) return;
const ref = activeTask.started_at ?? activeTask.dispatched_at!;
setElapsed(formatElapsed(ref));
const interval = setInterval(() => setElapsed(formatElapsed(ref)), 1000);
const startRef = activeTask.started_at ?? activeTask.dispatched_at!;
setElapsed(formatElapsed(startRef));
const interval = setInterval(() => setElapsed(formatElapsed(startRef)), 1000);
return () => clearInterval(interval);
}, [activeTask?.started_at, activeTask?.dispatched_at]);
// Sentinel pattern: detect when the card is scrolled past and becomes "stuck"
useEffect(() => {
const sentinel = sentinelRef.current;
const root = scrollContainerRef?.current;
if (!sentinel || !root || !activeTask) {
setIsStuck(false);
return;
}
const observer = new IntersectionObserver(
(entries) => {
if (entries[0]) setIsStuck(!entries[0].isIntersecting);
},
{ root, threshold: 0, rootMargin: "-40px 0px 0px 0px" },
);
observer.observe(sentinel);
return () => observer.disconnect();
}, [scrollContainerRef, activeTask]);
const scrollToCard = useCallback(() => {
sentinelRef.current?.scrollIntoView({ behavior: "smooth", block: "center" });
}, []);
// Auto-scroll
useEffect(() => {
if (autoScroll && scrollRef.current) {
@@ -220,57 +263,118 @@ export function AgentLiveCard({ issueId, assigneeType, assigneeId, agentName }:
setAutoScroll(scrollHeight - scrollTop - clientHeight < 40);
}, []);
const handleCancel = useCallback(async () => {
if (!activeTask || cancelling) return;
setCancelling(true);
try {
await api.cancelTask(issueId, activeTask.id);
} catch (e) {
toast.error(e instanceof Error ? e.message : "Failed to cancel task");
setCancelling(false);
}
}, [activeTask, issueId, cancelling]);
if (!activeTask) return null;
const toolCount = items.filter((i) => i.type === "tool_use").length;
const name = (activeTask.agent_id ? getActorName("agent", activeTask.agent_id) : agentName) ?? "Agent";
return (
<div className="rounded-lg border border-info/20 bg-info/5">
{/* Header */}
<div className="flex items-center gap-2 px-3 py-2">
<div className="flex items-center justify-center h-5 w-5 rounded-full bg-info/10 text-info shrink-0">
<Bot className="h-3 w-3" />
</div>
<div className="flex items-center gap-1.5 text-xs font-medium min-w-0">
<Loader2 className="h-3 w-3 animate-spin text-info shrink-0" />
<span className="truncate">{agentName ?? "Agent"} is working</span>
</div>
<span className="ml-auto text-xs text-muted-foreground tabular-nums shrink-0">{elapsed}</span>
{toolCount > 0 && (
<span className="text-xs text-muted-foreground shrink-0">
{toolCount} tool {toolCount === 1 ? "call" : "calls"}
</span>
<>
{/* Sentinel — zero-height element that IntersectionObserver watches */}
<div ref={sentinelRef} className="mt-4 h-0 pointer-events-none" aria-hidden />
<div
className={cn(
"rounded-lg border transition-all duration-200",
isStuck
? "sticky top-4 z-10 shadow-md border-brand/30 bg-brand/10 backdrop-blur-md"
: "border-info/20 bg-info/5",
)}
</div>
{/* Timeline content */}
{items.length > 0 && (
<div
ref={scrollRef}
onScroll={handleScroll}
className="relative max-h-80 overflow-y-auto border-t border-info/10 px-3 py-2 space-y-0.5"
>
{items.map((item, idx) => (
<TimelineRow key={`${item.seq}-${idx}`} item={item} />
))}
{!autoScroll && (
>
{/* Header */}
<div className="flex items-center gap-2 px-3 py-2">
{activeTask.agent_id ? (
<ActorAvatar actorType="agent" actorId={activeTask.agent_id} size={20} />
) : (
<div className={cn(
"flex items-center justify-center h-5 w-5 rounded-full shrink-0",
isStuck ? "bg-brand/15 text-brand" : "bg-info/10 text-info",
)}>
<Bot className="h-3 w-3" />
</div>
)}
<div className="flex items-center gap-1.5 text-xs font-medium min-w-0">
<Loader2 className={cn("h-3 w-3 animate-spin shrink-0", isStuck ? "text-brand" : "text-info")} />
<span className="truncate">{name} is working</span>
</div>
<span className="ml-auto text-xs text-muted-foreground tabular-nums shrink-0">{elapsed}</span>
{!isStuck && toolCount > 0 && (
<span className="text-xs text-muted-foreground shrink-0">
{toolCount} tool {toolCount === 1 ? "call" : "calls"}
</span>
)}
{isStuck ? (
<button
onClick={() => {
if (scrollRef.current) {
scrollRef.current.scrollTop = scrollRef.current.scrollHeight;
setAutoScroll(true);
}
}}
className="sticky bottom-0 left-1/2 -translate-x-1/2 flex items-center gap-1 rounded-full bg-background border px-2 py-0.5 text-xs text-muted-foreground hover:text-foreground shadow-sm"
onClick={scrollToCard}
className="flex items-center gap-1 rounded px-1.5 py-0.5 text-xs text-muted-foreground hover:text-foreground transition-colors shrink-0"
title="Scroll to live card"
>
<ArrowDown className="h-3 w-3" />
Latest
<ChevronUp className="h-3.5 w-3.5" />
</button>
) : (
<button
onClick={handleCancel}
disabled={cancelling}
className="flex items-center gap-1 rounded px-1.5 py-0.5 text-xs text-muted-foreground hover:text-destructive hover:bg-destructive/10 transition-colors disabled:opacity-50 shrink-0"
title="Stop agent"
>
{cancelling ? (
<Loader2 className="h-3 w-3 animate-spin" />
) : (
<Square className="h-3 w-3" />
)}
<span>Stop</span>
</button>
)}
</div>
)}
</div>
{/* Timeline content — collapses when stuck */}
<div
className={cn(
"overflow-hidden transition-all duration-200",
isStuck ? "max-h-0 opacity-0" : "max-h-[20rem] opacity-100",
)}
>
{items.length > 0 && (
<div
ref={scrollRef}
onScroll={handleScroll}
className="relative max-h-80 overflow-y-auto border-t border-info/10 px-3 py-2 space-y-0.5"
>
{items.map((item, idx) => (
<TimelineRow key={`${item.seq}-${idx}`} item={item} />
))}
{!autoScroll && (
<button
onClick={() => {
if (scrollRef.current) {
scrollRef.current.scrollTop = scrollRef.current.scrollHeight;
setAutoScroll(true);
}
}}
className="sticky bottom-0 left-1/2 -translate-x-1/2 flex items-center gap-1 rounded-full bg-background border px-2 py-0.5 text-xs text-muted-foreground hover:text-foreground shadow-sm"
>
<ArrowDown className="h-3 w-3" />
Latest
</button>
)}
</div>
)}
</div>
</div>
</>
);
}
@@ -278,17 +382,15 @@ export function AgentLiveCard({ issueId, assigneeType, assigneeId, agentName }:
interface TaskRunHistoryProps {
issueId: string;
assigneeType: string | null;
}
export function TaskRunHistory({ issueId, assigneeType }: TaskRunHistoryProps) {
export function TaskRunHistory({ issueId }: TaskRunHistoryProps) {
const [tasks, setTasks] = useState<AgentTask[]>([]);
const [open, setOpen] = useState(false);
useEffect(() => {
if (assigneeType !== "agent") return;
api.listTasksByIssue(issueId).then(setTasks).catch(() => {});
}, [issueId, assigneeType]);
api.listTasksByIssue(issueId).then(setTasks).catch(console.error);
}, [issueId]);
// Refresh when a task completes
useWSEvent(
@@ -296,7 +398,7 @@ export function TaskRunHistory({ issueId, assigneeType }: TaskRunHistoryProps) {
useCallback((payload: unknown) => {
const p = payload as TaskCompletedPayload;
if (p.issue_id !== issueId) return;
api.listTasksByIssue(issueId).then(setTasks).catch(() => {});
api.listTasksByIssue(issueId).then(setTasks).catch(console.error);
}, [issueId]),
);
@@ -305,11 +407,21 @@ export function TaskRunHistory({ issueId, assigneeType }: TaskRunHistoryProps) {
useCallback((payload: unknown) => {
const p = payload as TaskFailedPayload;
if (p.issue_id !== issueId) return;
api.listTasksByIssue(issueId).then(setTasks).catch(() => {});
api.listTasksByIssue(issueId).then(setTasks).catch(console.error);
}, [issueId]),
);
const completedTasks = tasks.filter((t) => t.status === "completed" || t.status === "failed");
// Refresh when a task is cancelled
useWSEvent(
"task:cancelled",
useCallback((payload: unknown) => {
const p = payload as TaskCancelledPayload;
if (p.issue_id !== issueId) return;
api.listTasksByIssue(issueId).then(setTasks).catch(console.error);
}, [issueId]),
);
const completedTasks = tasks.filter((t) => t.status === "completed" || t.status === "failed" || t.status === "cancelled");
if (completedTasks.length === 0) return null;
return (
@@ -338,7 +450,10 @@ function TaskRunEntry({ task }: { task: AgentTask }) {
if (items !== null) return; // already loaded
api.listTaskMessages(task.id).then((msgs) => {
setItems(buildTimeline(msgs));
}).catch(() => setItems([]));
}).catch((e) => {
console.error(e);
setItems([]);
});
}, [task.id, items]);
useEffect(() => {

View File

@@ -1,7 +1,7 @@
"use client";
import { useState } from "react";
import { X, Trash2, Bot, Lock, UserMinus } from "lucide-react";
import { X, Trash2 } from "lucide-react";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import {
@@ -19,15 +19,14 @@ import {
PopoverTrigger,
PopoverContent,
} from "@/components/ui/popover";
import type { Agent, UpdateIssueRequest } from "@/shared/types";
import type { UpdateIssueRequest } from "@/shared/types";
import { ALL_STATUSES, STATUS_CONFIG, PRIORITY_ORDER, PRIORITY_CONFIG } from "@/features/issues/config";
import { useAuthStore } from "@/features/auth";
import { useWorkspaceStore, useActorName } from "@/features/workspace";
import { useIssueStore } from "@/features/issues/store";
import { useIssueSelectionStore } from "@/features/issues/stores/selection-store";
import { api } from "@/shared/api";
import { StatusIcon } from "./status-icon";
import { PriorityIcon } from "./priority-icon";
import { AssigneePicker } from "./pickers";
export function BatchActionToolbar() {
const selectedIds = useIssueSelectionStore((s) => s.selectedIds);
@@ -44,7 +43,7 @@ export function BatchActionToolbar() {
const ids = Array.from(selectedIds);
const handleBatchUpdate = async (updates: UpdateIssueRequest) => {
const handleBatchUpdate = async (updates: Partial<UpdateIssueRequest>) => {
setLoading(true);
try {
await api.batchUpdateIssues(ids, updates);
@@ -56,7 +55,7 @@ export function BatchActionToolbar() {
toast.error("Failed to update issues");
api.listIssues({ limit: 200 }).then((res) => {
useIssueStore.getState().setIssues(res.issues);
});
}).catch(console.error);
} finally {
setLoading(false);
}
@@ -75,7 +74,7 @@ export function BatchActionToolbar() {
toast.error("Failed to delete issues");
api.listIssues({ limit: 200 }).then((res) => {
useIssueStore.getState().setIssues(res.issues);
});
}).catch(console.error);
} finally {
setLoading(false);
setDeleteOpen(false);
@@ -161,11 +160,15 @@ export function BatchActionToolbar() {
</Popover>
{/* Assignee */}
<BatchAssigneePicker
<AssigneePicker
assigneeType={null}
assigneeId={null}
onUpdate={handleBatchUpdate}
open={assigneeOpen}
onOpenChange={setAssigneeOpen}
onUpdate={handleBatchUpdate}
loading={loading}
triggerRender={<Button variant="ghost" size="sm" disabled={loading} />}
trigger="Assignee"
align="center"
/>
{/* Delete */}
@@ -207,136 +210,3 @@ export function BatchActionToolbar() {
);
}
function canAssignAgent(agent: Agent, userId: string | undefined, memberRole: string | undefined): boolean {
if (agent.visibility !== "private") return true;
if (agent.owner_id === userId) return true;
if (memberRole === "owner" || memberRole === "admin") return true;
return false;
}
function BatchAssigneePicker({
open,
onOpenChange,
onUpdate,
loading,
}: {
open: boolean;
onOpenChange: (v: boolean) => void;
onUpdate: (updates: UpdateIssueRequest) => void;
loading: boolean;
}) {
const [filter, setFilter] = useState("");
const user = useAuthStore((s) => s.user);
const members = useWorkspaceStore((s) => s.members);
const agents = useWorkspaceStore((s) => s.agents);
const { getActorInitials } = useActorName();
const currentMember = members.find((m) => m.user_id === user?.id);
const memberRole = currentMember?.role;
const query = filter.toLowerCase();
const filteredMembers = members.filter((m) =>
m.name.toLowerCase().includes(query),
);
const filteredAgents = agents.filter((a) =>
a.name.toLowerCase().includes(query),
);
return (
<Popover
open={open}
onOpenChange={(v) => {
onOpenChange(v);
if (!v) setFilter("");
}}
>
<PopoverTrigger
render={
<Button variant="ghost" size="sm" disabled={loading} />
}
>
Assignee
</PopoverTrigger>
<PopoverContent align="center" className="w-52 p-0">
<div className="px-2 py-1.5 border-b">
<input
type="text"
value={filter}
onChange={(e) => setFilter(e.target.value)}
placeholder="Assign to..."
className="w-full bg-transparent text-sm placeholder:text-muted-foreground outline-none"
/>
</div>
<div className="p-1 max-h-60 overflow-y-auto">
<button
type="button"
onClick={() => {
onUpdate({ assignee_type: null, assignee_id: null });
onOpenChange(false);
}}
className="flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-sm hover:bg-accent transition-colors"
>
<UserMinus className="h-3.5 w-3.5 text-muted-foreground" />
<span className="text-muted-foreground">Unassigned</span>
</button>
{filteredMembers.length > 0 && (
<div>
<div className="px-2 pt-2 pb-1 text-xs font-medium text-muted-foreground uppercase tracking-wider">
Members
</div>
{filteredMembers.map((m) => (
<button
key={m.user_id}
type="button"
onClick={() => {
onUpdate({ assignee_type: "member", assignee_id: m.user_id });
onOpenChange(false);
}}
className="flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-sm hover:bg-accent transition-colors"
>
<div className="inline-flex size-4.5 shrink-0 items-center justify-center rounded-full bg-muted text-[8px] font-medium text-muted-foreground">
{getActorInitials("member", m.user_id)}
</div>
<span>{m.name}</span>
</button>
))}
</div>
)}
{filteredAgents.length > 0 && (
<div>
<div className="px-2 pt-2 pb-1 text-xs font-medium text-muted-foreground uppercase tracking-wider">
Agents
</div>
{filteredAgents.map((a) => {
const allowed = canAssignAgent(a, user?.id, memberRole);
return (
<button
key={a.id}
type="button"
disabled={!allowed}
onClick={() => {
if (!allowed) return;
onUpdate({ assignee_type: "agent", assignee_id: a.id });
onOpenChange(false);
}}
className={`flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-sm transition-colors ${allowed ? "hover:bg-accent" : "opacity-50 cursor-not-allowed"}`}
>
<div className={`inline-flex size-4.5 shrink-0 items-center justify-center rounded-full ${allowed ? "bg-info/10 text-info" : "bg-muted text-muted-foreground"}`}>
<Bot className="size-2.5" />
</div>
<span className={allowed ? "" : "text-muted-foreground"}>{a.name}</span>
{a.visibility === "private" && (
<Lock className="ml-auto h-3 w-3 text-muted-foreground" />
)}
</button>
);
})}
</div>
)}
</div>
</PopoverContent>
</Popover>
);
}

View File

@@ -13,7 +13,8 @@ import { useIssueStore } from "@/features/issues/store";
import { PriorityIcon } from "./priority-icon";
import { PriorityPicker, AssigneePicker, DueDatePicker } from "./pickers";
import { PRIORITY_CONFIG } from "@/features/issues/config";
import { useIssueViewStore, type CardProperties } from "@/features/issues/stores/view-store";
import type { CardProperties } from "@/features/issues/stores/view-store";
import { useViewStore } from "@/features/issues/stores/view-store-context";
function formatDate(date: string): string {
return new Date(date).toLocaleDateString("en-US", {
@@ -42,7 +43,7 @@ export const BoardCardContent = memo(function BoardCardContent({
issue: Issue;
editable?: boolean;
}) {
const storeProperties = useIssueViewStore((s) => s.cardProperties);
const storeProperties = useViewStore((s) => s.cardProperties);
const priorityCfg = PRIORITY_CONFIG[issue.priority];
const handleUpdate = useCallback(

View File

@@ -15,7 +15,7 @@ import {
} from "@/components/ui/dropdown-menu";
import { STATUS_CONFIG } from "@/features/issues/config";
import { useModalStore } from "@/features/modals";
import { useIssueViewStore } from "@/features/issues/stores/view-store";
import { useViewStore, useViewStoreApi } from "@/features/issues/stores/view-store-context";
import { sortIssues } from "@/features/issues/utils/sort";
import { StatusIcon } from "./status-icon";
import { DraggableBoardCard } from "./board-card";
@@ -29,8 +29,9 @@ export function BoardColumn({
}) {
const cfg = STATUS_CONFIG[status];
const { setNodeRef, isOver } = useDroppable({ id: status });
const sortBy = useIssueViewStore((s) => s.sortBy);
const sortDirection = useIssueViewStore((s) => s.sortDirection);
const viewStoreApi = useViewStoreApi();
const sortBy = useViewStore((s) => s.sortBy);
const sortDirection = useViewStore((s) => s.sortDirection);
const sortedIssues = useMemo(
() => sortIssues(issues, sortBy, sortDirection),
@@ -67,7 +68,7 @@ export function BoardColumn({
}
/>
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={() => useIssueViewStore.getState().hideStatus(status)}>
<DropdownMenuItem onClick={() => viewStoreApi.getState().hideStatus(status)}>
<EyeOff className="size-3.5" />
Hide column
</DropdownMenuItem>

View File

@@ -23,7 +23,7 @@ import {
DropdownMenuItem,
} from "@/components/ui/dropdown-menu";
import { ALL_STATUSES, STATUS_CONFIG } from "@/features/issues/config";
import { useIssueViewStore } from "@/features/issues/stores/view-store";
import { useViewStoreApi } from "@/features/issues/stores/view-store-context";
import { StatusIcon } from "./status-icon";
import { BoardColumn } from "./board-column";
import { BoardCardContent } from "./board-card";
@@ -205,6 +205,7 @@ function HiddenColumnsPanel({
hiddenStatuses: IssueStatus[];
issues: Issue[];
}) {
const viewStoreApi = useViewStoreApi();
return (
<div className="flex w-[240px] shrink-0 flex-col">
<div className="mb-2 flex items-center gap-2 px-1">
@@ -242,7 +243,7 @@ function HiddenColumnsPanel({
<DropdownMenuContent align="end">
<DropdownMenuItem
onClick={() =>
useIssueViewStore.getState().showStatus(status)
viewStoreApi.getState().showStatus(status)
}
>
<Eye className="size-3.5" />

View File

@@ -13,14 +13,26 @@ import {
DropdownMenuSeparator,
} from "@/components/ui/dropdown-menu";
import { Tooltip, TooltipTrigger, TooltipContent } from "@/components/ui/tooltip";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@/components/ui/alert-dialog";
import { Collapsible, CollapsibleTrigger, CollapsibleContent } from "@/components/ui/collapsible";
import { ActorAvatar } from "@/components/common/actor-avatar";
import { ReactionBar } from "@/components/common/reaction-bar";
import { QuickEmojiPicker } from "@/components/common/quick-emoji-picker";
import { cn } from "@/lib/utils";
import { useActorName } from "@/features/workspace";
import { timeAgo } from "@/shared/utils";
import { RichTextEditor, type RichTextEditorRef } from "@/components/common/rich-text-editor";
import { Markdown } from "@/components/markdown";
import { ContentEditor, type ContentEditorRef, copyMarkdown } from "@/features/editor";
import { FileUploadButton } from "@/components/common/file-upload-button";
import { useFileUpload } from "@/shared/hooks/use-file-upload";
import { ReplyInput } from "./reply-input";
import type { TimelineEntry } from "@/shared/types";
@@ -37,6 +49,45 @@ interface CommentCardProps {
onEdit: (commentId: string, content: string) => Promise<void>;
onDelete: (commentId: string) => void;
onToggleReaction: (commentId: string, emoji: string) => void;
/** ID of the comment to highlight (flash animation). */
highlightedCommentId?: string | null;
}
// ---------------------------------------------------------------------------
// Shared delete confirmation dialog
// ---------------------------------------------------------------------------
function DeleteCommentDialog({
open,
onOpenChange,
onConfirm,
hasReplies,
}: {
open: boolean;
onOpenChange: (open: boolean) => void;
onConfirm: () => void;
hasReplies?: boolean;
}) {
return (
<AlertDialog open={open} onOpenChange={onOpenChange}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Delete comment</AlertDialogTitle>
<AlertDialogDescription>
{hasReplies
? "This comment and all its replies will be permanently deleted. This cannot be undone."
: "This comment will be permanently deleted. This cannot be undone."}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction variant="destructive" onClick={onConfirm}>
Delete
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
);
}
// ---------------------------------------------------------------------------
@@ -44,12 +95,14 @@ interface CommentCardProps {
// ---------------------------------------------------------------------------
function CommentRow({
issueId,
entry,
currentUserId,
onEdit,
onDelete,
onToggleReaction,
}: {
issueId: string;
entry: TimelineEntry;
currentUserId?: string;
onEdit: (commentId: string, content: string) => Promise<void>;
@@ -58,25 +111,34 @@ function CommentRow({
}) {
const { getActorName } = useActorName();
const [editing, setEditing] = useState(false);
const editEditorRef = useRef<RichTextEditorRef>(null);
const editEditorRef = useRef<ContentEditorRef>(null);
const cancelledRef = useRef(false);
const { uploadWithToast } = useFileUpload();
const isOwn = entry.actor_type === "member" && entry.actor_id === currentUserId;
const isTemp = entry.id.startsWith("temp-");
const [confirmDelete, setConfirmDelete] = useState(false);
const startEdit = () => {
cancelledRef.current = false;
setEditing(true);
};
const cancelEdit = () => {
cancelledRef.current = true;
setEditing(false);
};
const saveEdit = async () => {
if (cancelledRef.current) return;
const trimmed = editEditorRef.current
?.getMarkdown()
?.replace(/(\n\s*)+$/, "")
.trim();
if (!trimmed) return;
if (!trimmed || trimmed === (entry.content ?? "").trim()) {
setEditing(false);
return;
}
try {
await onEdit(entry.id, trimmed);
setEditing(false);
@@ -108,17 +170,22 @@ function CommentRow({
</Tooltip>
{!isTemp && (
<div className="ml-auto flex items-center gap-0.5">
<QuickEmojiPicker
onSelect={(emoji) => onToggleReaction(entry.id, emoji)}
align="end"
/>
<DropdownMenu>
<DropdownMenuTrigger
render={
<Button variant="ghost" size="icon-xs" className="ml-auto text-muted-foreground">
<Button variant="ghost" size="icon-xs" className="text-muted-foreground">
<MoreHorizontal className="h-4 w-4" />
</Button>
}
/>
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={() => {
navigator.clipboard.writeText(entry.content ?? "");
copyMarkdown(entry.content ?? "");
toast.success("Copied");
}}>
<Copy className="h-3.5 w-3.5" />
@@ -132,7 +199,7 @@ function CommentRow({
Edit
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem onClick={() => onDelete(entry.id)} variant="destructive">
<DropdownMenuItem onClick={() => setConfirmDelete(true)} variant="destructive">
<Trash2 className="h-3.5 w-3.5" />
Delete
</DropdownMenuItem>
@@ -140,38 +207,52 @@ function CommentRow({
)}
</DropdownMenuContent>
</DropdownMenu>
<DeleteCommentDialog
open={confirmDelete}
onOpenChange={setConfirmDelete}
onConfirm={() => onDelete(entry.id)}
/>
</div>
)}
</div>
{editing ? (
<div
className="mt-2 pl-8"
className="mt-1.5 pl-8"
onKeyDown={(e) => { if (e.key === "Escape") cancelEdit(); }}
>
<div className="max-h-48 overflow-y-auto rounded-md border border-border px-3 py-2">
<RichTextEditor
<div className="max-h-48 overflow-y-auto text-sm leading-relaxed">
<ContentEditor
ref={editEditorRef}
defaultValue={entry.content ?? ""}
placeholder="Edit comment..."
onSubmit={saveEdit}
onUploadFile={(file) => uploadWithToast(file, { issueId })}
debounceMs={100}
/>
</div>
<div className="flex gap-2 mt-1.5">
<Button size="sm" onClick={saveEdit}>Save</Button>
<Button size="sm" variant="ghost" onClick={cancelEdit}>Cancel</Button>
<div className="flex items-center justify-between mt-2">
<FileUploadButton
size="sm"
onSelect={(file) => editEditorRef.current?.uploadFile(file)}
/>
<div className="flex items-center gap-2">
<Button size="sm" variant="ghost" onClick={cancelEdit}>Cancel</Button>
<Button size="sm" variant="outline" onClick={saveEdit}>Save</Button>
</div>
</div>
</div>
) : (
<>
<div className="mt-1.5 pl-8 text-sm leading-relaxed text-foreground/85">
<Markdown mode="minimal">{entry.content ?? ""}</Markdown>
<ContentEditor defaultValue={entry.content ?? ""} editable={false} />
</div>
{!isTemp && (
<ReactionBar
reactions={reactions}
currentUserId={currentUserId}
onToggle={(emoji) => onToggleReaction(entry.id, emoji)}
hideAddButton
className="mt-1.5 pl-8"
/>
)}
@@ -194,29 +275,39 @@ function CommentCard({
onEdit,
onDelete,
onToggleReaction,
highlightedCommentId,
}: CommentCardProps) {
const { getActorName } = useActorName();
const { uploadWithToast } = useFileUpload();
const [open, setOpen] = useState(true);
const [editing, setEditing] = useState(false);
const editEditorRef = useRef<RichTextEditorRef>(null);
const editEditorRef = useRef<ContentEditorRef>(null);
const cancelledRef = useRef(false);
const isOwn = entry.actor_type === "member" && entry.actor_id === currentUserId;
const isTemp = entry.id.startsWith("temp-");
const [confirmDelete, setConfirmDelete] = useState(false);
const startEdit = () => {
cancelledRef.current = false;
setEditing(true);
};
const cancelEdit = () => {
cancelledRef.current = true;
setEditing(false);
};
const saveEdit = async () => {
if (cancelledRef.current) return;
const trimmed = editEditorRef.current
?.getMarkdown()
?.replace(/(\n\s*)+$/, "")
.trim();
if (!trimmed) return;
if (!trimmed || trimmed === (entry.content ?? "").trim()) {
setEditing(false);
return;
}
try {
await onEdit(entry.id, trimmed);
setEditing(false);
@@ -240,8 +331,10 @@ function CommentCard({
const contentPreview = (entry.content ?? "").replace(/\n/g, " ").slice(0, 80);
const reactions = entry.reactions ?? [];
const isHighlighted = highlightedCommentId === entry.id;
return (
<Card className={`!py-0 !gap-0 overflow-hidden${isTemp ? " opacity-60" : ""}`}>
<Card className={cn("!py-0 !gap-0 overflow-hidden transition-colors duration-700", isTemp && "opacity-60", isHighlighted && "ring-2 ring-brand/50 bg-brand/5")}>
<Collapsible open={open} onOpenChange={setOpen}>
{/* Header — always visible, acts as toggle */}
<div className="px-4 py-3">
@@ -278,17 +371,22 @@ function CommentCard({
)}
{open && !isTemp && (
<div className="ml-auto flex items-center gap-0.5">
<QuickEmojiPicker
onSelect={(emoji) => onToggleReaction(entry.id, emoji)}
align="end"
/>
<DropdownMenu>
<DropdownMenuTrigger
render={
<Button variant="ghost" size="icon-xs" className="ml-auto text-muted-foreground">
<Button variant="ghost" size="icon-xs" className="text-muted-foreground">
<MoreHorizontal className="h-4 w-4" />
</Button>
}
/>
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={() => {
navigator.clipboard.writeText(entry.content ?? "");
copyMarkdown(entry.content ?? "");
toast.success("Copied");
}}>
<Copy className="h-3.5 w-3.5" />
@@ -302,7 +400,7 @@ function CommentCard({
Edit
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem onClick={() => onDelete(entry.id)} variant="destructive">
<DropdownMenuItem onClick={() => setConfirmDelete(true)} variant="destructive">
<Trash2 className="h-3.5 w-3.5" />
Delete
</DropdownMenuItem>
@@ -310,6 +408,13 @@ function CommentCard({
)}
</DropdownMenuContent>
</DropdownMenu>
<DeleteCommentDialog
open={confirmDelete}
onOpenChange={setConfirmDelete}
onConfirm={() => onDelete(entry.id)}
hasReplies
/>
</div>
)}
</div>
</div>
@@ -323,8 +428,8 @@ function CommentCard({
className="pl-10"
onKeyDown={(e) => { if (e.key === "Escape") cancelEdit(); }}
>
<div className="max-h-48 overflow-y-auto rounded-md border border-border px-3 py-2">
<RichTextEditor
<div className="max-h-48 overflow-y-auto text-sm leading-relaxed">
<ContentEditor
ref={editEditorRef}
defaultValue={entry.content ?? ""}
placeholder="Edit comment..."
@@ -332,15 +437,21 @@ function CommentCard({
debounceMs={100}
/>
</div>
<div className="flex gap-2 mt-1.5">
<Button size="sm" onClick={saveEdit}>Save</Button>
<Button size="sm" variant="ghost" onClick={cancelEdit}>Cancel</Button>
<div className="flex items-center justify-between mt-2">
<FileUploadButton
size="sm"
onSelect={(file) => editEditorRef.current?.uploadFile(file)}
/>
<div className="flex items-center gap-2">
<Button size="sm" variant="ghost" onClick={cancelEdit}>Cancel</Button>
<Button size="sm" variant="outline" onClick={saveEdit}>Save</Button>
</div>
</div>
</div>
) : (
<>
<div className="pl-10 text-sm leading-relaxed text-foreground/85">
<Markdown mode="minimal">{entry.content ?? ""}</Markdown>
<ContentEditor defaultValue={entry.content ?? ""} editable={false} />
</div>
{!isTemp && (
<ReactionBar
@@ -356,8 +467,9 @@ function CommentCard({
{/* Replies */}
{allNestedReplies.map((reply) => (
<div key={reply.id} className="border-t border-border/50 px-4">
<div key={reply.id} id={`comment-${reply.id}`} className={cn("border-t border-border/50 px-4 transition-colors duration-700", highlightedCommentId === reply.id && "bg-brand/5")}>
<CommentRow
issueId={issueId}
entry={reply}
currentUserId={currentUserId}
onEdit={onEdit}

View File

@@ -1,9 +1,10 @@
"use client";
import { useRef, useState } from "react";
import { ArrowUp, Loader2, Paperclip } from "lucide-react";
import { ArrowUp, Loader2 } from "lucide-react";
import { Button } from "@/components/ui/button";
import { RichTextEditor, type RichTextEditorRef } from "@/components/common/rich-text-editor";
import { ContentEditor, type ContentEditorRef } from "@/features/editor";
import { FileUploadButton } from "@/components/common/file-upload-button";
import { useFileUpload } from "@/shared/hooks/use-file-upload";
interface CommentInputProps {
@@ -12,27 +13,13 @@ interface CommentInputProps {
}
function CommentInput({ issueId, onSubmit }: CommentInputProps) {
const editorRef = useRef<RichTextEditorRef>(null);
const fileInputRef = useRef<HTMLInputElement>(null);
const attachmentIdsRef = useRef<string[]>([]);
const editorRef = useRef<ContentEditorRef>(null);
const [isEmpty, setIsEmpty] = useState(true);
const [submitting, setSubmitting] = useState(false);
const { uploadWithToast, uploading } = useFileUpload();
const { uploadWithToast } = useFileUpload();
const handleUpload = async (file: File) => {
const result = await uploadWithToast(file, { issueId });
if (result) attachmentIdsRef.current.push(result.id);
return result;
};
const handleFileSelect = async (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (!file) return;
e.target.value = "";
const result = await handleUpload(file);
if (result) {
editorRef.current?.insertFile(result.filename, result.link, file.type.startsWith("image/"));
}
return await uploadWithToast(file, { issueId });
};
const handleSubmit = async () => {
@@ -40,10 +27,8 @@ function CommentInput({ issueId, onSubmit }: CommentInputProps) {
if (!content || submitting) return;
setSubmitting(true);
try {
const ids = attachmentIdsRef.current.length > 0 ? [...attachmentIdsRef.current] : undefined;
await onSubmit(content, ids);
await onSubmit(content);
editorRef.current?.clearContent();
attachmentIdsRef.current = [];
setIsEmpty(true);
} finally {
setSubmitting(false);
@@ -51,9 +36,9 @@ function CommentInput({ issueId, onSubmit }: CommentInputProps) {
};
return (
<div className="relative rounded-lg bg-card ring-1 ring-border">
<div className="min-h-20 max-h-48 overflow-y-auto px-3 py-2 pb-8">
<RichTextEditor
<div className="relative flex max-h-56 flex-col rounded-lg bg-card pb-8 ring-1 ring-border">
<div className="flex-1 min-h-0 overflow-y-auto px-3 py-2">
<ContentEditor
ref={editorRef}
placeholder="Leave a comment..."
onUpdate={(md) => setIsEmpty(!md.trim())}
@@ -62,28 +47,21 @@ function CommentInput({ issueId, onSubmit }: CommentInputProps) {
debounceMs={100}
/>
</div>
<div className="absolute bottom-1.5 right-1.5 flex items-center gap-1">
<Button
variant="ghost"
size="icon-sm"
onClick={() => fileInputRef.current?.click()}
disabled={uploading}
className="text-muted-foreground hover:text-foreground"
>
<Paperclip className="h-4 w-4" />
</Button>
<input
ref={fileInputRef}
type="file"
className="hidden"
onChange={handleFileSelect}
<div className="absolute bottom-1 right-1.5 flex items-center gap-1">
<FileUploadButton
size="sm"
onSelect={(file) => editorRef.current?.uploadFile(file)}
/>
<Button
size="icon-sm"
size="icon-xs"
disabled={isEmpty || submitting}
onClick={handleSubmit}
>
{submitting ? <Loader2 className="h-4 w-4 animate-spin" /> : <ArrowUp className="h-4 w-4" />}
{submitting ? (
<Loader2 className="h-3.5 w-3.5 animate-spin" />
) : (
<ArrowUp className="h-3.5 w-3.5" />
)}
</Button>
</div>
</div>

View File

@@ -1,6 +1,6 @@
export { StatusIcon } from "./status-icon";
export { PriorityIcon } from "./priority-icon";
export { StatusPicker, PriorityPicker, AssigneePicker, DueDatePicker } from "./pickers";
export { StatusPicker, PriorityPicker, AssigneePicker, canAssignAgent, DueDatePicker } from "./pickers";
export { IssueDetail } from "./issue-detail";
export { IssuesPage } from "./issues-page";
export { CommentCard } from "./comment-card";

View File

@@ -1,13 +1,13 @@
"use client";
import { useState, useEffect, useCallback, memo } from "react";
import { useState, useEffect, useCallback, useRef, memo } from "react";
import { useDefaultLayout, usePanelRef } from "react-resizable-panels";
import Link from "next/link";
import { useRouter } from "next/navigation";
import {
Bot,
Calendar,
Check,
ChevronDown,
ChevronLeft,
ChevronRight,
Link2,
@@ -19,6 +19,7 @@ import {
X,
} from "lucide-react";
import { toast } from "sonner";
import { Skeleton } from "@/components/ui/skeleton";
import {
AlertDialog,
AlertDialogAction,
@@ -43,8 +44,9 @@ import {
DropdownMenuSubContent,
} from "@/components/ui/dropdown-menu";
import { ResizablePanelGroup, ResizablePanel, ResizableHandle } from "@/components/ui/resizable";
import { RichTextEditor } from "@/components/common/rich-text-editor";
import { TitleEditor } from "@/components/common/title-editor";
import { ContentEditor, type ContentEditorRef } from "@/features/editor";
import { FileUploadButton } from "@/components/common/file-upload-button";
import { TitleEditor } from "@/features/editor";
import {
Tooltip,
TooltipTrigger,
@@ -53,11 +55,11 @@ import {
import { Popover, PopoverTrigger, PopoverContent } from "@/components/ui/popover";
import { Checkbox } from "@/components/ui/checkbox";
import { Command, CommandInput, CommandList, CommandEmpty, CommandGroup, CommandItem } from "@/components/ui/command";
import { Avatar, AvatarFallback, AvatarGroup, AvatarGroupCount } from "@/components/ui/avatar";
import { AvatarGroup, AvatarGroupCount } from "@/components/ui/avatar";
import { ActorAvatar } from "@/components/common/actor-avatar";
import type { UpdateIssueRequest, IssueStatus, IssuePriority, TimelineEntry } from "@/shared/types";
import { ALL_STATUSES, STATUS_CONFIG, PRIORITY_ORDER, PRIORITY_CONFIG } from "@/features/issues/config";
import { StatusIcon, PriorityIcon, DueDatePicker } from "@/features/issues/components";
import { StatusIcon, PriorityIcon, DueDatePicker, AssigneePicker, canAssignAgent } from "@/features/issues/components";
import { CommentCard } from "./comment-card";
import { CommentInput } from "./comment-input";
import { AgentLiveCard, TaskRunHistory } from "./agent-live-card";
@@ -160,26 +162,29 @@ interface IssueDetailProps {
onDelete?: () => void;
defaultSidebarOpen?: boolean;
layoutId?: string;
/** When set, the issue detail will auto-scroll to this comment and briefly highlight it. */
highlightCommentId?: string;
}
// ---------------------------------------------------------------------------
// IssueDetail
// ---------------------------------------------------------------------------
export function IssueDetail({ issueId, onDelete, defaultSidebarOpen = true, layoutId = "multica_issue_detail_layout" }: IssueDetailProps) {
export function IssueDetail({ issueId, onDelete, defaultSidebarOpen = true, layoutId = "multica_issue_detail_layout", highlightCommentId }: IssueDetailProps) {
const id = issueId;
const router = useRouter();
const user = useAuthStore((s) => s.user);
const workspace = useWorkspaceStore((s) => s.workspace);
const members = useWorkspaceStore((s) => s.members);
const agents = useWorkspaceStore((s) => s.agents);
const currentMemberRole = members.find((m) => m.user_id === user?.id)?.role;
// Issue navigation
const allIssues = useIssueStore((s) => s.issues);
const currentIndex = allIssues.findIndex((i) => i.id === id);
const prevIssue = currentIndex > 0 ? allIssues[currentIndex - 1] : null;
const nextIssue = currentIndex < allIssues.length - 1 ? allIssues[currentIndex + 1] : null;
const { getActorName, getActorInitials } = useActorName();
const { getActorName } = useActorName();
const { uploadWithToast } = useFileUpload();
const { defaultLayout, onLayoutChanged } = useDefaultLayout({
id: layoutId,
@@ -190,6 +195,10 @@ export function IssueDetail({ issueId, onDelete, defaultSidebarOpen = true, layo
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
const [propertiesOpen, setPropertiesOpen] = useState(true);
const [detailsOpen, setDetailsOpen] = useState(true);
const scrollContainerRef = useRef<HTMLDivElement>(null);
const [showScrollBottom, setShowScrollBottom] = useState(false);
const [highlightedId, setHighlightedId] = useState<string | null>(null);
const didHighlightRef = useRef<string | null>(null);
// Single source of truth: read issue directly from global store
const issue = useIssueStore((s) => s.issues.find((i) => i.id === id)) ?? null;
@@ -207,27 +216,63 @@ export function IssueDetail({ issueId, onDelete, defaultSidebarOpen = true, layo
.then((iss) => {
useIssueStore.getState().addIssue(iss);
})
.catch(console.error)
.catch((e) => {
console.error(e);
toast.error("Failed to load issue");
})
.finally(() => setIssueLoading(false));
}, [id, !!issue]);
// Custom hooks — encapsulate timeline, reactions, subscribers
const {
timeline, submitting, submitComment, submitReply,
timeline, loading: timelineLoading, submitting, submitComment, submitReply,
editComment, deleteComment, toggleReaction: handleToggleReaction,
} = useIssueTimeline(id, user?.id);
const {
reactions: issueReactions,
reactions: issueReactions, loading: reactionsLoading,
toggleReaction: handleToggleIssueReaction,
} = useIssueReactions(id, user?.id);
const {
subscribers, isSubscribed, toggleSubscribe: handleToggleSubscribe, toggleSubscriber,
subscribers, loading: subscribersLoading, isSubscribed, toggleSubscribe: handleToggleSubscribe, toggleSubscriber,
} = useIssueSubscribers(id, user?.id);
const loading = issueLoading;
// Scroll to highlighted comment once timeline loads (fire only once per highlightCommentId)
useEffect(() => {
if (!highlightCommentId || timeline.length === 0) return;
if (didHighlightRef.current === highlightCommentId) return;
const el = document.getElementById(`comment-${highlightCommentId}`);
if (el) {
didHighlightRef.current = highlightCommentId;
requestAnimationFrame(() => {
el.scrollIntoView({ behavior: "smooth", block: "center" });
setHighlightedId(highlightCommentId);
const timer = setTimeout(() => setHighlightedId(null), 2000);
return () => clearTimeout(timer);
});
}
}, [highlightCommentId, timeline.length]);
// Track scroll position for jump-to-bottom button
useEffect(() => {
const container = scrollContainerRef.current;
if (!container) return;
const onScroll = () => {
const { scrollTop, scrollHeight, clientHeight } = container;
setShowScrollBottom(scrollHeight - scrollTop - clientHeight > 200);
};
container.addEventListener("scroll", onScroll, { passive: true });
onScroll();
return () => container.removeEventListener("scroll", onScroll);
}, []);
const scrollToBottom = useCallback(() => {
scrollContainerRef.current?.scrollTo({ top: scrollContainerRef.current.scrollHeight, behavior: "smooth" });
}, []);
// Issue field updates — write directly to the global store (single source of truth)
const handleUpdateField = useCallback(
(updates: Partial<UpdateIssueRequest>) => {
@@ -242,6 +287,7 @@ export function IssueDetail({ issueId, onDelete, defaultSidebarOpen = true, layo
[issue, id],
);
const descEditorRef = useRef<ContentEditorRef>(null);
const handleDescriptionUpload = useCallback(
(file: File) => uploadWithToast(file, { issueId: id }),
[uploadWithToast, id],
@@ -263,8 +309,51 @@ export function IssueDetail({ issueId, onDelete, defaultSidebarOpen = true, layo
if (loading) {
return (
<div className="flex flex-1 min-h-0 items-center justify-center text-sm text-muted-foreground">
Loading...
<div className="flex flex-1 min-h-0 flex-col">
{/* Header skeleton */}
<div className="flex h-12 shrink-0 items-center gap-2 border-b px-4">
<Skeleton className="h-4 w-16" />
<Skeleton className="h-4 w-4" />
<Skeleton className="h-4 w-24" />
</div>
<div className="flex flex-1 min-h-0">
{/* Content skeleton */}
<div className="flex-1 p-8 space-y-6">
<Skeleton className="h-8 w-3/4" />
<div className="space-y-2">
<Skeleton className="h-4 w-full" />
<Skeleton className="h-4 w-5/6" />
<Skeleton className="h-4 w-2/3" />
</div>
<Skeleton className="h-px w-full" />
<div className="space-y-3">
<Skeleton className="h-4 w-20" />
<div className="flex items-start gap-3">
<Skeleton className="h-8 w-8 rounded-full" />
<div className="flex-1 space-y-2">
<Skeleton className="h-4 w-32" />
<Skeleton className="h-16 w-full rounded-lg" />
</div>
</div>
</div>
</div>
{/* Sidebar skeleton */}
<div className="w-64 border-l p-4 space-y-4">
{Array.from({ length: 4 }).map((_, i) => (
<div key={i} className="flex items-center justify-between">
<Skeleton className="h-3 w-16" />
<Skeleton className="h-5 w-24" />
</div>
))}
<Skeleton className="h-px w-full" />
{Array.from({ length: 3 }).map((_, i) => (
<div key={i} className="flex items-center justify-between">
<Skeleton className="h-3 w-16" />
<Skeleton className="h-4 w-28" />
</div>
))}
</div>
</div>
</div>
);
}
@@ -419,21 +508,17 @@ export function IssueDetail({ issueId, onDelete, defaultSidebarOpen = true, layo
key={m.user_id}
onClick={() => handleUpdateField({ assignee_type: "member", assignee_id: m.user_id })}
>
<div className="inline-flex size-4 shrink-0 items-center justify-center rounded-full bg-muted text-[8px] font-medium text-muted-foreground">
{getActorInitials("member", m.user_id)}
</div>
<ActorAvatar actorType="member" actorId={m.user_id} size={16} />
{m.name}
{issue.assignee_type === "member" && issue.assignee_id === m.user_id && <span className="ml-auto text-xs text-muted-foreground"></span>}
</DropdownMenuItem>
))}
{agents.map((a) => (
{agents.filter((a) => !a.archived_at && canAssignAgent(a, user?.id, currentMemberRole)).map((a) => (
<DropdownMenuItem
key={a.id}
onClick={() => handleUpdateField({ assignee_type: "agent", assignee_id: a.id })}
>
<div className="inline-flex size-4 shrink-0 items-center justify-center rounded-full bg-info/10 text-info">
<Bot className="size-2.5" />
</div>
<ActorAvatar actorType="agent" actorId={a.id} size={16} />
{a.name}
{issue.assignee_type === "agent" && issue.assignee_id === a.id && <span className="ml-auto text-xs text-muted-foreground"></span>}
</DropdownMenuItem>
@@ -543,7 +628,7 @@ export function IssueDetail({ issueId, onDelete, defaultSidebarOpen = true, layo
</div>
{/* Content — scrollable */}
<div className="flex-1 overflow-y-auto">
<div ref={scrollContainerRef} className="relative flex-1 overflow-y-auto">
<div className="mx-auto w-full max-w-4xl px-8 py-8">
<TitleEditor
key={`title-${id}`}
@@ -556,7 +641,8 @@ export function IssueDetail({ issueId, onDelete, defaultSidebarOpen = true, layo
}}
/>
<RichTextEditor
<ContentEditor
ref={descEditorRef}
key={id}
defaultValue={issue.description || ""}
placeholder="Add description..."
@@ -566,12 +652,24 @@ export function IssueDetail({ issueId, onDelete, defaultSidebarOpen = true, layo
className="mt-5"
/>
<ReactionBar
reactions={issueReactions}
currentUserId={user?.id}
onToggle={handleToggleIssueReaction}
className="mt-3"
/>
<div className="flex items-center gap-1 mt-3">
{reactionsLoading ? (
<div className="flex items-center gap-1">
<Skeleton className="h-7 w-14 rounded-full" />
<Skeleton className="h-7 w-14 rounded-full" />
</div>
) : (
<ReactionBar
reactions={issueReactions}
currentUserId={user?.id}
onToggle={handleToggleIssueReaction}
/>
)}
<FileUploadButton
size="sm"
onSelect={(file) => descEditorRef.current?.uploadFile(file)}
/>
</div>
<div className="my-8 border-t" />
@@ -582,6 +680,15 @@ export function IssueDetail({ issueId, onDelete, defaultSidebarOpen = true, layo
<h2 className="text-base font-semibold">Activity</h2>
</div>
<div className="flex items-center gap-2">
{subscribersLoading ? (
<div className="flex items-center gap-1">
<Skeleton className="h-4 w-16" />
<div className="flex -space-x-1">
<Skeleton className="h-6 w-6 rounded-full" />
<Skeleton className="h-6 w-6 rounded-full" />
</div>
</div>
) : (<>
<button
onClick={handleToggleSubscribe}
className="text-xs text-muted-foreground hover:text-foreground transition-colors"
@@ -593,9 +700,12 @@ export function IssueDetail({ issueId, onDelete, defaultSidebarOpen = true, layo
{subscribers.length > 0 ? (
<AvatarGroup>
{subscribers.slice(0, 4).map((sub) => (
<Avatar key={`${sub.user_type}-${sub.user_id}`} size="sm">
<AvatarFallback>{getActorInitials(sub.user_type, sub.user_id)}</AvatarFallback>
</Avatar>
<ActorAvatar
key={`${sub.user_type}-${sub.user_id}`}
actorType={sub.user_type}
actorId={sub.user_id}
size={24}
/>
))}
{subscribers.length > 4 && (
<AvatarGroupCount>+{subscribers.length - 4}</AvatarGroupCount>
@@ -632,9 +742,9 @@ export function IssueDetail({ issueId, onDelete, defaultSidebarOpen = true, layo
})}
</CommandGroup>
)}
{agents.length > 0 && (
{agents.filter((a) => !a.archived_at).length > 0 && (
<CommandGroup heading="Agents">
{agents.map((a) => {
{agents.filter((a) => !a.archived_at).map((a) => {
const sub = subscribers.find((s) => s.user_type === "agent" && s.user_id === a.id);
const isSubbed = !!sub;
return (
@@ -656,27 +766,37 @@ export function IssueDetail({ issueId, onDelete, defaultSidebarOpen = true, layo
</Command>
</PopoverContent>
</Popover>
</>)}
</div>
</div>
{/* Agent live output */}
<div className="mt-4">
<AgentLiveCard
issueId={id}
assigneeType={issue.assignee_type}
assigneeId={issue.assignee_id}
agentName={issue.assignee_type === "agent" && issue.assignee_id ? getActorName("agent", issue.assignee_id) : undefined}
/>
</div>
<AgentLiveCard
issueId={id}
agentName={issue.assignee_type === "agent" && issue.assignee_id ? getActorName("agent", issue.assignee_id) : undefined}
scrollContainerRef={scrollContainerRef}
/>
{/* Agent execution history */}
<div className="mt-3">
<TaskRunHistory issueId={id} assigneeType={issue.assignee_type} />
<TaskRunHistory issueId={id} />
</div>
{/* Timeline entries */}
<div className="mt-4 flex flex-col gap-3">
{(() => {
{timelineLoading ? (
<div className="space-y-4">
{Array.from({ length: 3 }).map((_, i) => (
<div key={i} className="flex items-start gap-3 px-4">
<Skeleton className="h-8 w-8 rounded-full shrink-0" />
<div className="flex-1 space-y-2">
<Skeleton className="h-4 w-32" />
<Skeleton className="h-16 w-full rounded-lg" />
</div>
</div>
))}
</div>
) : (() => {
const topLevel = timeline.filter((e) => e.type === "activity" || !e.parent_id);
const repliesByParent = new Map<string, TimelineEntry[]>();
for (const e of timeline) {
@@ -727,17 +847,19 @@ export function IssueDetail({ issueId, onDelete, defaultSidebarOpen = true, layo
if (group.type === "comment") {
const entry = group.entries[0]!;
return (
<CommentCard
key={entry.id}
issueId={id}
entry={entry}
allReplies={repliesByParent}
currentUserId={user?.id}
onReply={submitReply}
onEdit={editComment}
onDelete={deleteComment}
onToggleReaction={handleToggleReaction}
/>
<div key={entry.id} id={`comment-${entry.id}`}>
<CommentCard
issueId={id}
entry={entry}
allReplies={repliesByParent}
currentUserId={user?.id}
onReply={submitReply}
onEdit={editComment}
onDelete={deleteComment}
onToggleReaction={handleToggleReaction}
highlightedCommentId={highlightedId}
/>
</div>
);
}
@@ -796,6 +918,20 @@ export function IssueDetail({ issueId, onDelete, defaultSidebarOpen = true, layo
</div>
</div>
</div>
{/* Jump to bottom button */}
{showScrollBottom && (
<div className="sticky bottom-4 flex justify-center pointer-events-none">
<Button
variant="secondary"
size="sm"
className="pointer-events-auto shadow-md"
onClick={scrollToBottom}
>
<ChevronDown className="mr-1 h-3.5 w-3.5" />
Jump to bottom
</Button>
</div>
)}
</div>
</div>
</ResizablePanel>
@@ -866,60 +1002,12 @@ export function IssueDetail({ issueId, onDelete, defaultSidebarOpen = true, layo
{/* Assignee */}
<PropRow label="Assignee">
<DropdownMenu>
<DropdownMenuTrigger className="flex items-center gap-1.5 cursor-pointer rounded px-1 -mx-1 hover:bg-accent/30 transition-colors overflow-hidden">
{issue.assignee_type && issue.assignee_id ? (
<>
<ActorAvatar
actorType={issue.assignee_type}
actorId={issue.assignee_id}
size={18}
/>
<span className="truncate">{getActorName(issue.assignee_type, issue.assignee_id)}</span>
</>
) : (
<span className="text-muted-foreground">Unassigned</span>
)}
</DropdownMenuTrigger>
<DropdownMenuContent align="start" className="w-52">
<DropdownMenuItem onClick={() => handleUpdateField({ assignee_type: null, assignee_id: null })}>
<UserMinus className="h-3.5 w-3.5 text-muted-foreground" />
Unassigned
</DropdownMenuItem>
{members.length > 0 && (
<>
<DropdownMenuSeparator />
<DropdownMenuGroup>
<DropdownMenuLabel>Members</DropdownMenuLabel>
{members.map((m) => (
<DropdownMenuItem key={m.user_id} onClick={() => handleUpdateField({ assignee_type: "member", assignee_id: m.user_id })}>
<div className="inline-flex size-4 shrink-0 items-center justify-center rounded-full bg-muted text-[8px] font-medium text-muted-foreground">
{getActorInitials("member", m.user_id)}
</div>
{m.name}
</DropdownMenuItem>
))}
</DropdownMenuGroup>
</>
)}
{agents.length > 0 && (
<>
<DropdownMenuSeparator />
<DropdownMenuGroup>
<DropdownMenuLabel>Agents</DropdownMenuLabel>
{agents.map((a) => (
<DropdownMenuItem key={a.id} onClick={() => handleUpdateField({ assignee_type: "agent", assignee_id: a.id })}>
<div className="inline-flex size-4 shrink-0 items-center justify-center rounded-full bg-info/10 text-info">
<Bot className="size-2.5" />
</div>
{a.name}
</DropdownMenuItem>
))}
</DropdownMenuGroup>
</>
)}
</DropdownMenuContent>
</DropdownMenu>
<AssigneePicker
assigneeType={issue.assignee_type}
assigneeId={issue.assignee_id}
onUpdate={handleUpdateField}
align="start"
/>
</PropRow>
{/* Due date */}

View File

@@ -4,14 +4,12 @@ import { useMemo, useState } from "react";
import {
ArrowDown,
ArrowUp,
Bot,
Check,
ChevronDown,
CircleDot,
Columns3,
Filter,
List,
Plus,
SignalHigh,
SlidersHorizontal,
User,
@@ -19,7 +17,6 @@ import {
UserPen,
} from "lucide-react";
import { Button } from "@/components/ui/button";
import { useIssueStore } from "@/features/issues/store";
import {
DropdownMenu,
DropdownMenuTrigger,
@@ -39,7 +36,6 @@ import {
PopoverContent,
} from "@/components/ui/popover";
import { Switch } from "@/components/ui/switch";
import { useModalStore } from "@/features/modals";
import {
ALL_STATUSES,
STATUS_CONFIG,
@@ -47,20 +43,24 @@ import {
PRIORITY_CONFIG,
} from "@/features/issues/config";
import { StatusIcon, PriorityIcon } from "@/features/issues/components";
import { useWorkspaceStore, useActorName } from "@/features/workspace";
import { useWorkspaceStore } from "@/features/workspace";
import { ActorAvatar } from "@/components/common/actor-avatar";
import {
useIssueViewStore,
SORT_OPTIONS,
CARD_PROPERTY_OPTIONS,
type ActorFilterValue,
} from "@/features/issues/stores/view-store";
import {
useIssuesScopeStore,
type IssuesScope,
} from "@/features/issues/stores/issues-scope-store";
import { filterIssues } from "@/features/issues/utils/filter";
import { Tooltip, TooltipTrigger, TooltipContent } from "@/components/ui/tooltip";
import type { Issue } from "@/shared/types";
// ---------------------------------------------------------------------------
// HoverCheck — shadcn official pattern (PR #6862)
// Uses data-selected attr instead of Checkbox component to avoid
// DropdownMenuCheckboxItem's focus:**:text-accent-foreground cascade.
// ---------------------------------------------------------------------------
const FILTER_ITEM_CLASS =
@@ -123,6 +123,16 @@ function useIssueCounts(allIssues: Issue[]) {
}, [allIssues]);
}
// ---------------------------------------------------------------------------
// Scope config
// ---------------------------------------------------------------------------
const SCOPES: { value: IssuesScope; label: string; description: string }[] = [
{ value: "all", label: "All", description: "All issues in this workspace" },
{ value: "members", label: "Members", description: "Issues assigned to team members" },
{ value: "agents", label: "Agents", description: "Issues assigned to AI agents" },
];
// ---------------------------------------------------------------------------
// Actor sub-menu content (shared between Assignee and Creator)
// ---------------------------------------------------------------------------
@@ -147,14 +157,12 @@ function ActorSubContent({
const [search, setSearch] = useState("");
const members = useWorkspaceStore((s) => s.members);
const agents = useWorkspaceStore((s) => s.agents);
const { getActorInitials } = useActorName();
const query = search.toLowerCase();
const filteredMembers = members.filter((m) =>
m.name.toLowerCase().includes(query),
);
const filteredAgents = agents.filter((a) =>
a.name.toLowerCase().includes(query),
!a.archived_at && a.name.toLowerCase().includes(query),
);
const isSelected = (type: "member" | "agent", id: string) =>
@@ -208,9 +216,7 @@ function ActorSubContent({
className={FILTER_ITEM_CLASS}
>
<HoverCheck checked={checked} />
<div className="inline-flex size-4.5 shrink-0 items-center justify-center rounded-full bg-muted text-[8px] font-medium text-muted-foreground">
{getActorInitials("member", m.user_id)}
</div>
<ActorAvatar actorType="member" actorId={m.user_id} size={18} />
<span className="truncate">{m.name}</span>
{count > 0 && (
<span className="ml-auto text-xs text-muted-foreground">
@@ -239,9 +245,7 @@ function ActorSubContent({
className={FILTER_ITEM_CLASS}
>
<HoverCheck checked={checked} />
<div className="inline-flex size-4.5 shrink-0 items-center justify-center rounded-full bg-info/10 text-info">
<Bot className="size-2.5" />
</div>
<ActorAvatar actorType="agent" actorId={a.id} size={18} />
<span className="truncate">{a.name}</span>
{count > 0 && (
<span className="ml-auto text-xs text-muted-foreground">
@@ -268,7 +272,10 @@ function ActorSubContent({
// IssuesHeader
// ---------------------------------------------------------------------------
export function IssuesHeader() {
export function IssuesHeader({ scopedIssues }: { scopedIssues: Issue[] }) {
const scope = useIssuesScopeStore((s) => s.scope);
const setScope = useIssuesScopeStore((s) => s.setScope);
const viewMode = useIssueViewStore((s) => s.viewMode);
const statusFilters = useIssueViewStore((s) => s.statusFilters);
const priorityFilters = useIssueViewStore((s) => s.priorityFilters);
@@ -278,85 +285,71 @@ export function IssuesHeader() {
const sortBy = useIssueViewStore((s) => s.sortBy);
const sortDirection = useIssueViewStore((s) => s.sortDirection);
const cardProperties = useIssueViewStore((s) => s.cardProperties);
const setViewMode = useIssueViewStore((s) => s.setViewMode);
const toggleStatusFilter = useIssueViewStore((s) => s.toggleStatusFilter);
const togglePriorityFilter = useIssueViewStore((s) => s.togglePriorityFilter);
const toggleAssigneeFilter = useIssueViewStore((s) => s.toggleAssigneeFilter);
const toggleNoAssignee = useIssueViewStore((s) => s.toggleNoAssignee);
const toggleCreatorFilter = useIssueViewStore((s) => s.toggleCreatorFilter);
const clearFilters = useIssueViewStore((s) => s.clearFilters);
const setSortBy = useIssueViewStore((s) => s.setSortBy);
const setSortDirection = useIssueViewStore((s) => s.setSortDirection);
const toggleCardProperty = useIssueViewStore((s) => s.toggleCardProperty);
const act = useIssueViewStore.getState();
const allIssues = useIssueStore((s) => s.issues);
const counts = useIssueCounts(allIssues);
const counts = useIssueCounts(scopedIssues);
const filteredCount = useMemo(
() => filterIssues(allIssues, { statusFilters, priorityFilters, assigneeFilters, includeNoAssignee, creatorFilters }).length,
[allIssues, statusFilters, priorityFilters, assigneeFilters, includeNoAssignee, creatorFilters],
);
const filterCount = getActiveFilterCount({
statusFilters,
priorityFilters,
assigneeFilters,
includeNoAssignee,
creatorFilters,
});
const hasActiveFilters =
getActiveFilterCount({
statusFilters,
priorityFilters,
assigneeFilters,
includeNoAssignee,
creatorFilters,
}) > 0;
const sortLabel =
SORT_OPTIONS.find((o) => o.value === sortBy)?.label ?? "Manual";
const hasActiveFilters = filterCount > 0;
return (
<div className="flex h-12 shrink-0 items-center justify-between px-4">
<div className="flex items-center gap-2">
{/* View toggle */}
<DropdownMenu>
<DropdownMenuTrigger
render={
<Button variant="outline" size="sm">
{viewMode === "board" ? <Columns3 className="size-3.5" /> : <List className="size-3.5" />}
{viewMode === "board" ? "Board" : "List"}
</Button>
}
/>
<DropdownMenuContent align="start" className="w-auto">
<DropdownMenuGroup>
<DropdownMenuLabel>View</DropdownMenuLabel>
<DropdownMenuItem onClick={() => setViewMode("board")}>
<Columns3 />
Board
</DropdownMenuItem>
<DropdownMenuItem onClick={() => setViewMode("list")}>
<List />
List
</DropdownMenuItem>
</DropdownMenuGroup>
</DropdownMenuContent>
</DropdownMenu>
{/* Left: scope buttons */}
<div className="flex items-center gap-1">
{SCOPES.map((s) => (
<Tooltip key={s.value}>
<TooltipTrigger
render={
<Button
variant="outline"
size="sm"
className={
scope === s.value
? "bg-accent text-accent-foreground hover:bg-accent/80"
: "text-muted-foreground"
}
onClick={() => setScope(s.value)}
>
{s.label}
</Button>
}
/>
<TooltipContent side="bottom">{s.description}</TooltipContent>
</Tooltip>
))}
</div>
{/* Filter — DropdownMenu with sub-menus */}
{/* Right: filter + display + view toggle */}
<div className="flex items-center gap-1">
{/* Filter */}
<DropdownMenu>
<DropdownMenuTrigger
render={
<Button
variant="outline"
size="sm"
className={hasActiveFilters ? "border-primary/50 text-primary" : ""}
>
<Filter className="size-3.5" />
Filter
{hasActiveFilters && (
<span className="flex h-4 min-w-4 items-center justify-center rounded-full bg-primary px-1 text-[10px] font-medium text-primary-foreground">
{filterCount}
</span>
)}
</Button>
}
/>
<DropdownMenuContent align="start" className="w-44">
<Tooltip>
<DropdownMenuTrigger
render={
<TooltipTrigger
render={
<Button variant="outline" size="icon-sm" className="relative text-muted-foreground">
<Filter className="size-4" />
{hasActiveFilters && (
<span className="absolute top-0 right-0 size-1.5 rounded-full bg-brand" />
)}
</Button>
}
/>
}
/>
<TooltipContent side="bottom">Filter</TooltipContent>
</Tooltip>
<DropdownMenuContent align="end" className="w-auto">
{/* Status */}
<DropdownMenuSub>
<DropdownMenuSubTrigger>
@@ -376,7 +369,7 @@ export function IssuesHeader() {
<DropdownMenuCheckboxItem
key={s}
checked={checked}
onCheckedChange={() => toggleStatusFilter(s)}
onCheckedChange={() => act.toggleStatusFilter(s)}
className={FILTER_ITEM_CLASS}
>
<HoverCheck checked={checked} />
@@ -412,7 +405,7 @@ export function IssuesHeader() {
<DropdownMenuCheckboxItem
key={p}
checked={checked}
onCheckedChange={() => togglePriorityFilter(p)}
onCheckedChange={() => act.togglePriorityFilter(p)}
className={FILTER_ITEM_CLASS}
>
<HoverCheck checked={checked} />
@@ -444,10 +437,10 @@ export function IssuesHeader() {
<ActorSubContent
counts={counts.assignee}
selected={assigneeFilters}
onToggle={toggleAssigneeFilter}
onToggle={act.toggleAssigneeFilter}
showNoAssignee
includeNoAssignee={includeNoAssignee}
onToggleNoAssignee={toggleNoAssignee}
onToggleNoAssignee={act.toggleNoAssignee}
noAssigneeCount={counts.noAssignee}
/>
</DropdownMenuSubContent>
@@ -468,7 +461,7 @@ export function IssuesHeader() {
<ActorSubContent
counts={counts.creator}
selected={creatorFilters}
onToggle={toggleCreatorFilter}
onToggle={act.toggleCreatorFilter}
/>
</DropdownMenuSubContent>
</DropdownMenuSub>
@@ -477,7 +470,7 @@ export function IssuesHeader() {
{hasActiveFilters && (
<>
<DropdownMenuSeparator />
<DropdownMenuItem onClick={clearFilters}>
<DropdownMenuItem onClick={act.clearFilters}>
Reset all filters
</DropdownMenuItem>
</>
@@ -487,15 +480,21 @@ export function IssuesHeader() {
{/* Display settings */}
<Popover>
<PopoverTrigger
render={
<Button variant="outline" size="sm">
<SlidersHorizontal className="size-3.5" />
Display
</Button>
}
/>
<PopoverContent align="start" className="w-64 p-0">
<Tooltip>
<PopoverTrigger
render={
<TooltipTrigger
render={
<Button variant="outline" size="icon-sm" className="text-muted-foreground">
<SlidersHorizontal className="size-4" />
</Button>
}
/>
}
/>
<TooltipContent side="bottom">Display settings</TooltipContent>
</Tooltip>
<PopoverContent align="end" className="w-64 p-0">
<div className="border-b px-3 py-2.5">
<span className="text-xs font-medium text-muted-foreground">
Ordering
@@ -518,7 +517,7 @@ export function IssuesHeader() {
{SORT_OPTIONS.map((opt) => (
<DropdownMenuItem
key={opt.value}
onClick={() => setSortBy(opt.value)}
onClick={() => act.setSortBy(opt.value)}
>
{opt.label}
</DropdownMenuItem>
@@ -529,7 +528,7 @@ export function IssuesHeader() {
variant="outline"
size="icon-sm"
onClick={() =>
setSortDirection(sortDirection === "asc" ? "desc" : "asc")
act.setSortDirection(sortDirection === "asc" ? "desc" : "asc")
}
title={sortDirection === "asc" ? "Ascending" : "Descending"}
>
@@ -556,7 +555,7 @@ export function IssuesHeader() {
<Switch
size="sm"
checked={cardProperties[opt.key]}
onCheckedChange={() => toggleCardProperty(opt.key)}
onCheckedChange={() => act.toggleCardProperty(opt.key)}
/>
</label>
))}
@@ -564,19 +563,43 @@ export function IssuesHeader() {
</div>
</PopoverContent>
</Popover>
</div>
<div className="flex items-center gap-3">
<span className="text-xs text-muted-foreground">
{filteredCount} {filteredCount === 1 ? "Issue" : "Issues"}
</span>
<Button
size="sm"
onClick={() => useModalStore.getState().open("create-issue")}
>
<Plus />
New Issue
</Button>
{/* View toggle */}
<DropdownMenu>
<Tooltip>
<DropdownMenuTrigger
render={
<TooltipTrigger
render={
<Button variant="outline" size="icon-sm" className="text-muted-foreground">
{viewMode === "board" ? (
<Columns3 className="size-4" />
) : (
<List className="size-4" />
)}
</Button>
}
/>
}
/>
<TooltipContent side="bottom">
{viewMode === "board" ? "Board view" : "List view"}
</TooltipContent>
</Tooltip>
<DropdownMenuContent align="end" className="w-auto">
<DropdownMenuGroup>
<DropdownMenuLabel>View</DropdownMenuLabel>
<DropdownMenuItem onClick={() => act.setViewMode("board")}>
<Columns3 />
Board
</DropdownMenuItem>
<DropdownMenuItem onClick={() => act.setViewMode("list")}>
<List />
List
</DropdownMenuItem>
</DropdownMenuGroup>
</DropdownMenuContent>
</DropdownMenu>
</div>
</div>
);

View File

@@ -2,12 +2,15 @@
import { useCallback, useEffect, useMemo } from "react";
import { toast } from "sonner";
import { ChevronRight } from "lucide-react";
import { ChevronRight, ListTodo } from "lucide-react";
import type { IssueStatus } from "@/shared/types";
import { Skeleton } from "@/components/ui/skeleton";
import { useIssueStore } from "@/features/issues/store";
import { useIssueViewStore, initFilterWorkspaceSync } from "@/features/issues/stores/view-store";
import { useIssuesScopeStore } from "@/features/issues/stores/issues-scope-store";
import { ViewStoreProvider } from "@/features/issues/stores/view-store-context";
import { filterIssues } from "@/features/issues/utils/filter";
import { BOARD_STATUSES } from "@/features/issues/config";
import { useWorkspaceStore } from "@/features/workspace";
import { WorkspaceAvatar } from "@/features/workspace";
import { api } from "@/shared/api";
@@ -17,19 +20,11 @@ import { BoardView } from "./board-view";
import { ListView } from "./list-view";
import { BatchActionToolbar } from "./batch-action-toolbar";
const BOARD_STATUSES: IssueStatus[] = [
"backlog",
"todo",
"in_progress",
"in_review",
"done",
"blocked",
];
export function IssuesPage() {
const allIssues = useIssueStore((s) => s.issues);
const loading = useIssueStore((s) => s.loading);
const workspace = useWorkspaceStore((s) => s.workspace);
const scope = useIssuesScopeStore((s) => s.scope);
const viewMode = useIssueViewStore((s) => s.viewMode);
const statusFilters = useIssueViewStore((s) => s.statusFilters);
const priorityFilters = useIssueViewStore((s) => s.priorityFilters);
@@ -43,11 +38,20 @@ export function IssuesPage() {
useEffect(() => {
useIssueSelectionStore.getState().clear();
}, [viewMode]);
}, [viewMode, scope]);
// Scope pre-filter: narrow by assignee type
const scopedIssues = useMemo(() => {
if (scope === "members")
return allIssues.filter((i) => i.assignee_type === "member");
if (scope === "agents")
return allIssues.filter((i) => i.assignee_type === "agent");
return allIssues;
}, [allIssues, scope]);
const issues = useMemo(
() => filterIssues(allIssues, { statusFilters, priorityFilters, assigneeFilters, includeNoAssignee, creatorFilters }),
[allIssues, statusFilters, priorityFilters, assigneeFilters, includeNoAssignee, creatorFilters],
() => filterIssues(scopedIssues, { statusFilters, priorityFilters, assigneeFilters, includeNoAssignee, creatorFilters }),
[scopedIssues, statusFilters, priorityFilters, assigneeFilters, includeNoAssignee, creatorFilters],
);
const visibleStatuses = useMemo(() => {
@@ -63,9 +67,10 @@ export function IssuesPage() {
const handleMoveIssue = useCallback(
(issueId: string, newStatus: IssueStatus, newPosition?: number) => {
// Auto-switch to manual sort so drag ordering is preserved
if (useIssueViewStore.getState().sortBy !== "position") {
useIssueViewStore.getState().setSortBy("position");
useIssueViewStore.getState().setSortDirection("asc");
const viewState = useIssueViewStore.getState();
if (viewState.sortBy !== "position") {
viewState.setSortBy("position");
viewState.setSortDirection("asc");
}
const updates: Partial<{ status: IssueStatus; position: number }> = {
@@ -79,7 +84,7 @@ export function IssuesPage() {
toast.error("Failed to move issue");
api.listIssues({ limit: 200 }).then((res) => {
useIssueStore.getState().setIssues(res.issues);
});
}).catch(console.error);
});
},
[]
@@ -121,24 +126,34 @@ export function IssuesPage() {
<span className="text-sm font-medium">Issues</span>
</div>
{/* Header 2: View toggle + filters */}
<IssuesHeader />
{/* Header 2: Scope tabs + filters */}
<IssuesHeader scopedIssues={scopedIssues} />
{/* Content: scrollable */}
<div className="flex flex-col flex-1 min-h-0">
{viewMode === "board" ? (
<BoardView
issues={issues}
allIssues={allIssues}
visibleStatuses={visibleStatuses}
hiddenStatuses={hiddenStatuses}
onMoveIssue={handleMoveIssue}
/>
<ViewStoreProvider store={useIssueViewStore}>
{scopedIssues.length === 0 ? (
<div className="flex flex-1 min-h-0 flex-col items-center justify-center gap-2 text-muted-foreground">
<ListTodo className="h-10 w-10 text-muted-foreground/40" />
<p className="text-sm">No issues yet</p>
<p className="text-xs">Create an issue to get started.</p>
</div>
) : (
<ListView issues={issues} visibleStatuses={visibleStatuses} />
<div className="flex flex-col flex-1 min-h-0">
{viewMode === "board" ? (
<BoardView
issues={issues}
allIssues={scopedIssues}
visibleStatuses={visibleStatuses}
hiddenStatuses={hiddenStatuses}
onMoveIssue={handleMoveIssue}
/>
) : (
<ListView issues={issues} visibleStatuses={visibleStatuses} />
)}
</div>
)}
</div>
{viewMode === "list" && <BatchActionToolbar />}
{viewMode === "list" && <BatchActionToolbar />}
</ViewStoreProvider>
</div>
);
}

View File

@@ -8,7 +8,7 @@ import { Button } from "@/components/ui/button";
import type { Issue, IssueStatus } from "@/shared/types";
import { STATUS_CONFIG } from "@/features/issues/config";
import { useModalStore } from "@/features/modals";
import { useIssueViewStore } from "@/features/issues/stores/view-store";
import { useViewStore } from "@/features/issues/stores/view-store-context";
import { useIssueSelectionStore } from "@/features/issues/stores/selection-store";
import { sortIssues } from "@/features/issues/utils/sort";
import { StatusIcon } from "./status-icon";
@@ -21,12 +21,12 @@ export function ListView({
issues: Issue[];
visibleStatuses: IssueStatus[];
}) {
const sortBy = useIssueViewStore((s) => s.sortBy);
const sortDirection = useIssueViewStore((s) => s.sortDirection);
const listCollapsedStatuses = useIssueViewStore(
const sortBy = useViewStore((s) => s.sortBy);
const sortDirection = useViewStore((s) => s.sortDirection);
const listCollapsedStatuses = useViewStore(
(s) => s.listCollapsedStatuses
);
const toggleListCollapsed = useIssueViewStore(
const toggleListCollapsed = useViewStore(
(s) => s.toggleListCollapsed
);
const selectedIds = useIssueSelectionStore((s) => s.selectedIds);

View File

@@ -1,10 +1,11 @@
"use client";
import { useState } from "react";
import { Bot, Lock, UserMinus } from "lucide-react";
import { Lock, UserMinus } from "lucide-react";
import type { Agent, IssueAssigneeType, UpdateIssueRequest } from "@/shared/types";
import { useAuthStore } from "@/features/auth";
import { useWorkspaceStore, useActorName } from "@/features/workspace";
import { ActorAvatar } from "@/components/common/actor-avatar";
import {
PropertyPicker,
PickerItem,
@@ -12,7 +13,7 @@ import {
PickerEmpty,
} from "./property-picker";
function canAssignAgent(agent: Agent, userId: string | undefined, memberRole: string | undefined): boolean {
export function canAssignAgent(agent: Agent, userId: string | undefined, memberRole: string | undefined): boolean {
if (agent.visibility !== "private") return true;
if (agent.owner_id === userId) return true;
if (memberRole === "owner" || memberRole === "admin") return true;
@@ -24,18 +25,28 @@ export function AssigneePicker({
assigneeId,
onUpdate,
trigger: customTrigger,
triggerRender,
open: controlledOpen,
onOpenChange: controlledOnOpenChange,
align,
}: {
assigneeType: IssueAssigneeType | null;
assigneeId: string | null;
onUpdate: (updates: Partial<UpdateIssueRequest>) => void;
trigger?: React.ReactNode;
triggerRender?: React.ReactElement;
open?: boolean;
onOpenChange?: (v: boolean) => void;
align?: "start" | "center" | "end";
}) {
const [open, setOpen] = useState(false);
const [internalOpen, setInternalOpen] = useState(false);
const open = controlledOpen ?? internalOpen;
const setOpen = controlledOnOpenChange ?? setInternalOpen;
const [filter, setFilter] = useState("");
const user = useAuthStore((s) => s.user);
const members = useWorkspaceStore((s) => s.members);
const agents = useWorkspaceStore((s) => s.agents);
const { getActorName, getActorInitials } = useActorName();
const { getActorName } = useActorName();
const currentMember = members.find((m) => m.user_id === user?.id);
const memberRole = currentMember?.role;
@@ -45,7 +56,7 @@ export function AssigneePicker({
m.name.toLowerCase().includes(query),
);
const filteredAgents = agents.filter((a) =>
a.name.toLowerCase().includes(query),
!a.archived_at && a.name.toLowerCase().includes(query),
);
const isSelected = (type: string, id: string) =>
@@ -64,25 +75,15 @@ export function AssigneePicker({
if (!v) setFilter("");
}}
width="w-52"
align={align}
searchable
searchPlaceholder="Assign to..."
onSearchChange={setFilter}
triggerRender={triggerRender}
trigger={
customTrigger ? customTrigger : assigneeType && assigneeId ? (
<>
<div
className={`inline-flex shrink-0 items-center justify-center rounded-full font-medium text-[8px] size-4.5 ${
assigneeType === "agent"
? "bg-info/10 text-info"
: "bg-muted text-muted-foreground"
}`}
>
{assigneeType === "agent" ? (
<Bot className="size-2.5" />
) : (
getActorInitials(assigneeType, assigneeId)
)}
</div>
<ActorAvatar actorType={assigneeType} actorId={assigneeId} size={18} />
<span className="truncate">{triggerLabel}</span>
</>
) : (
@@ -117,9 +118,7 @@ export function AssigneePicker({
setOpen(false);
}}
>
<div className="inline-flex size-4.5 shrink-0 items-center justify-center rounded-full bg-muted text-[8px] font-medium text-muted-foreground">
{getActorInitials("member", m.user_id)}
</div>
<ActorAvatar actorType="member" actorId={m.user_id} size={18} />
<span>{m.name}</span>
</PickerItem>
))}
@@ -145,9 +144,7 @@ export function AssigneePicker({
setOpen(false);
}}
>
<div className={`inline-flex size-4.5 shrink-0 items-center justify-center rounded-full ${allowed ? "bg-info/10 text-info" : "bg-muted text-muted-foreground"}`}>
<Bot className="size-2.5" />
</div>
<ActorAvatar actorType="agent" actorId={a.id} size={18} />
<span className={allowed ? "" : "text-muted-foreground"}>{a.name}</span>
{a.visibility === "private" && (
<Lock className="ml-auto h-3 w-3 text-muted-foreground" />

View File

@@ -1,5 +1,5 @@
export { PropertyPicker, PickerItem, PickerSection, PickerEmpty } from "./property-picker";
export { StatusPicker } from "./status-picker";
export { PriorityPicker } from "./priority-picker";
export { AssigneePicker } from "./assignee-picker";
export { AssigneePicker, canAssignAgent } from "./assignee-picker";
export { DueDatePicker } from "./due-date-picker";

View File

@@ -16,6 +16,7 @@ export function PropertyPicker({
open,
onOpenChange,
trigger,
triggerRender,
width = "w-48",
align = "end",
searchable = false,
@@ -26,6 +27,7 @@ export function PropertyPicker({
open: boolean;
onOpenChange: (v: boolean) => void;
trigger: React.ReactNode;
triggerRender?: React.ReactElement;
width?: string;
align?: "start" | "center" | "end";
searchable?: boolean;
@@ -48,7 +50,10 @@ export function PropertyPicker({
return (
<Popover open={open} onOpenChange={handleOpenChange}>
<PopoverTrigger className="flex items-center gap-1.5 cursor-pointer rounded px-1 -mx-1 hover:bg-accent/30 transition-colors overflow-hidden">
<PopoverTrigger
className={triggerRender ? undefined : "flex items-center gap-1.5 cursor-pointer rounded px-1 -mx-1 hover:bg-accent/30 transition-colors overflow-hidden"}
render={triggerRender}
>
{trigger}
</PopoverTrigger>
<PopoverContent align={align} className={`${width} gap-0 p-0`}>

View File

@@ -1,11 +1,12 @@
"use client";
import { useRef, useState } from "react";
import { ArrowUp, Loader2, Paperclip } from "lucide-react";
import { Button } from "@/components/ui/button";
import { RichTextEditor, type RichTextEditorRef } from "@/components/common/rich-text-editor";
import { useRef, useState, useEffect } from "react";
import { ArrowUp, Loader2 } from "lucide-react";
import { ContentEditor, type ContentEditorRef } from "@/features/editor";
import { FileUploadButton } from "@/components/common/file-upload-button";
import { ActorAvatar } from "@/components/common/actor-avatar";
import { useFileUpload } from "@/shared/hooks/use-file-upload";
import { cn } from "@/lib/utils";
// ---------------------------------------------------------------------------
// Types
@@ -32,27 +33,26 @@ function ReplyInput({
onSubmit,
size = "default",
}: ReplyInputProps) {
const editorRef = useRef<RichTextEditorRef>(null);
const fileInputRef = useRef<HTMLInputElement>(null);
const attachmentIdsRef = useRef<string[]>([]);
const editorRef = useRef<ContentEditorRef>(null);
const measureRef = useRef<HTMLDivElement>(null);
const [isEmpty, setIsEmpty] = useState(true);
const [isExpanded, setIsExpanded] = useState(false);
const [submitting, setSubmitting] = useState(false);
const { uploadWithToast, uploading } = useFileUpload();
const { uploadWithToast } = useFileUpload();
useEffect(() => {
const el = measureRef.current;
if (!el) return;
const observer = new ResizeObserver((entries) => {
const entry = entries[0];
if (entry) setIsExpanded(entry.contentRect.height > 32);
});
observer.observe(el);
return () => observer.disconnect();
}, []);
const handleUpload = async (file: File) => {
const result = await uploadWithToast(file, { issueId });
if (result) attachmentIdsRef.current.push(result.id);
return result;
};
const handleFileSelect = async (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (!file) return;
e.target.value = "";
const result = await handleUpload(file);
if (result) {
editorRef.current?.insertFile(result.filename, result.link, file.type.startsWith("image/"));
}
return await uploadWithToast(file, { issueId });
};
const handleSubmit = async () => {
@@ -60,10 +60,8 @@ function ReplyInput({
if (!content || submitting) return;
setSubmitting(true);
try {
const ids = attachmentIdsRef.current.length > 0 ? [...attachmentIdsRef.current] : undefined;
await onSubmit(content, ids);
await onSubmit(content);
editorRef.current?.clearContent();
attachmentIdsRef.current = [];
setIsEmpty(true);
} finally {
setSubmitting(false);
@@ -73,62 +71,50 @@ function ReplyInput({
const avatarSize = size === "sm" ? 22 : 28;
return (
<div className="flex items-start gap-2.5">
<div className="group/editor flex items-start gap-2.5">
<ActorAvatar
actorType={avatarType}
actorId={avatarId}
size={avatarSize}
className="mt-0.5 shrink-0"
/>
<div className="min-w-0 flex-1">
<div
className={`overflow-y-auto text-sm ${
size === "sm" ? "max-h-32" : "max-h-48"
}`}
>
<RichTextEditor
ref={editorRef}
placeholder={placeholder}
onUpdate={(md) => setIsEmpty(!md.trim())}
onSubmit={handleSubmit}
onUploadFile={handleUpload}
debounceMs={100}
/>
</div>
<div
className={`grid transition-all duration-150 ${
isEmpty ? "grid-rows-[0fr] opacity-0" : "grid-rows-[1fr] opacity-100"
}`}
>
<div className="overflow-hidden">
<div className="flex items-center justify-end gap-1 pt-1">
<Button
variant="ghost"
size="icon-xs"
onClick={() => fileInputRef.current?.click()}
disabled={uploading}
tabIndex={isEmpty ? -1 : 0}
className="text-muted-foreground hover:text-foreground"
>
<Paperclip className="h-3.5 w-3.5" />
</Button>
<input
ref={fileInputRef}
type="file"
className="hidden"
onChange={handleFileSelect}
/>
<Button
size="icon-xs"
disabled={isEmpty || submitting}
onClick={handleSubmit}
tabIndex={isEmpty ? -1 : 0}
>
{submitting ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <ArrowUp className="h-3.5 w-3.5" />}
</Button>
</div>
<div
className={cn(
"relative min-w-0 flex-1 flex flex-col",
size === "sm" ? "max-h-40" : "max-h-56",
isExpanded && "pb-7",
)}
>
<div className="flex-1 min-h-0 overflow-y-auto pr-14">
<div ref={measureRef}>
<ContentEditor
ref={editorRef}
placeholder={placeholder}
onUpdate={(md) => setIsEmpty(!md.trim())}
onSubmit={handleSubmit}
onUploadFile={handleUpload}
debounceMs={100}
/>
</div>
</div>
<div className="absolute bottom-0 right-0 flex items-center gap-1 text-muted-foreground transition-colors group-focus-within/editor:text-foreground">
<FileUploadButton
size="sm"
onSelect={(file) => editorRef.current?.uploadFile(file)}
/>
<button
type="button"
disabled={isEmpty || submitting}
onClick={handleSubmit}
className="inline-flex h-6 w-6 items-center justify-center rounded-full text-muted-foreground hover:bg-accent hover:text-foreground transition-colors disabled:opacity-50 disabled:pointer-events-none"
>
{submitting ? (
<Loader2 className="h-3.5 w-3.5 animate-spin" />
) : (
<ArrowUp className="h-3.5 w-3.5" />
)}
</button>
</div>
</div>
</div>
);

View File

@@ -1,2 +1,2 @@
export { STATUS_ORDER, ALL_STATUSES, STATUS_CONFIG } from "./status";
export { STATUS_ORDER, ALL_STATUSES, BOARD_STATUSES, STATUS_CONFIG } from "./status";
export { PRIORITY_ORDER, PRIORITY_CONFIG } from "./priority";

View File

@@ -20,6 +20,16 @@ export const ALL_STATUSES: IssueStatus[] = [
"cancelled",
];
/** Statuses shown as board columns (excludes cancelled). */
export const BOARD_STATUSES: IssueStatus[] = [
"backlog",
"todo",
"in_progress",
"in_review",
"done",
"blocked",
];
export const STATUS_CONFIG: Record<
IssueStatus,
{

View File

@@ -7,8 +7,8 @@ import type {
IssueReactionRemovedPayload,
} from "@/shared/types";
import { api } from "@/shared/api";
import { useWSEvent, useWSReconnect } from "@/features/realtime";
import { toast } from "sonner";
import { useWSEvent, useWSReconnect } from "@/features/realtime";
export function useIssueReactions(issueId: string, userId?: string) {
const [reactions, setReactions] = useState<IssueReaction[]>([]);
@@ -21,7 +21,10 @@ export function useIssueReactions(issueId: string, userId?: string) {
api
.getIssue(issueId)
.then((iss) => setReactions(iss.reactions ?? []))
.catch(console.error)
.catch((e) => {
console.error(e);
toast.error("Failed to load reactions");
})
.finally(() => setLoading(false));
}, [issueId]);

View File

@@ -7,8 +7,8 @@ import type {
SubscriberRemovedPayload,
} from "@/shared/types";
import { api } from "@/shared/api";
import { useWSEvent, useWSReconnect } from "@/features/realtime";
import { toast } from "sonner";
import { useWSEvent, useWSReconnect } from "@/features/realtime";
export function useIssueSubscribers(issueId: string, userId?: string) {
const [subscribers, setSubscribers] = useState<IssueSubscriber[]>([]);
@@ -21,7 +21,10 @@ export function useIssueSubscribers(issueId: string, userId?: string) {
api
.listIssueSubscribers(issueId)
.then((subs) => setSubscribers(subs))
.catch(console.error)
.catch((e) => {
console.error(e);
toast.error("Failed to load subscribers");
})
.finally(() => setLoading(false));
}, [issueId]);

View File

@@ -41,7 +41,10 @@ export function useIssueTimeline(issueId: string, userId?: string) {
api
.listTimeline(issueId)
.then((entries) => setTimeline(entries))
.catch(console.error)
.catch((e) => {
console.error(e);
toast.error("Failed to load activity");
})
.finally(() => setLoading(false));
}, [issueId]);

View File

@@ -1,4 +1,5 @@
export { useIssueStore } from "./store";
export { useIssueViewStore } from "./stores/view-store";
export { useIssueViewStore, createIssueViewStore } from "./stores/view-store";
export { ViewStoreProvider, useViewStore } from "./stores/view-store-context";
export { StatusIcon, PriorityIcon, StatusPicker, PriorityPicker, AssigneePicker } from "./components";
export * from "./config";

View File

@@ -2,6 +2,7 @@
import { create } from "zustand";
import type { Issue } from "@/shared/types";
import { toast } from "sonner";
import { api } from "@/shared/api";
import { createLogger } from "@/shared/logger";
@@ -34,6 +35,7 @@ export const useIssueStore = create<IssueState>((set, get) => ({
set({ issues: res.issues, loading: false });
} catch (err) {
logger.error("fetch failed", err);
toast.error("Failed to load issues");
if (isInitialLoad) set({ loading: false });
}
},

View File

@@ -0,0 +1,21 @@
"use client";
import { create } from "zustand";
import { persist } from "zustand/middleware";
export type IssuesScope = "all" | "members" | "agents";
interface IssuesScopeState {
scope: IssuesScope;
setScope: (scope: IssuesScope) => void;
}
export const useIssuesScopeStore = create<IssuesScopeState>()(
persist(
(set) => ({
scope: "all",
setScope: (scope) => set({ scope }),
}),
{ name: "multica_issues_scope" },
),
);

View File

@@ -0,0 +1,35 @@
"use client";
import { createContext, useContext } from "react";
import { useStore, type StoreApi } from "zustand";
import type { IssueViewState } from "./view-store";
const ViewStoreContext = createContext<StoreApi<IssueViewState> | null>(null);
export function ViewStoreProvider({
store,
children,
}: {
store: StoreApi<IssueViewState>;
children: React.ReactNode;
}) {
return (
<ViewStoreContext.Provider value={store}>
{children}
</ViewStoreContext.Provider>
);
}
export function useViewStore<T>(selector: (state: IssueViewState) => T): T {
const store = useContext(ViewStoreContext);
if (!store)
throw new Error("useViewStore must be used within ViewStoreProvider");
return useStore(store, selector);
}
export function useViewStoreApi(): StoreApi<IssueViewState> {
const store = useContext(ViewStoreContext);
if (!store)
throw new Error("useViewStoreApi must be used within ViewStoreProvider");
return store;
}

View File

@@ -1,6 +1,7 @@
"use client";
import { create } from "zustand";
import { createStore, type StoreApi } from "zustand/vanilla";
import { persist } from "zustand/middleware";
import type { IssueStatus, IssuePriority } from "@/shared/types";
import { ALL_STATUSES } from "@/features/issues/config";
@@ -36,7 +37,7 @@ export const CARD_PROPERTY_OPTIONS: { key: keyof CardProperties; label: string }
{ key: "dueDate", label: "Due date" },
];
interface IssueViewState {
export interface IssueViewState {
viewMode: ViewMode;
statusFilters: IssueStatus[];
priorityFilters: IssuePriority[];
@@ -62,130 +63,142 @@ interface IssueViewState {
toggleListCollapsed: (status: IssueStatus) => void;
}
export const useIssueViewStore = create<IssueViewState>()(
persist(
(set) => ({
viewMode: "board",
export const viewStoreSlice = (set: StoreApi<IssueViewState>["setState"]): IssueViewState => ({
viewMode: "board",
statusFilters: [],
priorityFilters: [],
assigneeFilters: [],
includeNoAssignee: false,
creatorFilters: [],
sortBy: "position",
sortDirection: "asc",
cardProperties: {
priority: true,
description: true,
assignee: true,
dueDate: true,
},
listCollapsedStatuses: [],
setViewMode: (mode) => set({ viewMode: mode }),
toggleStatusFilter: (status) =>
set((state) => ({
statusFilters: state.statusFilters.includes(status)
? state.statusFilters.filter((s) => s !== status)
: [...state.statusFilters, status],
})),
togglePriorityFilter: (priority) =>
set((state) => ({
priorityFilters: state.priorityFilters.includes(priority)
? state.priorityFilters.filter((p) => p !== priority)
: [...state.priorityFilters, priority],
})),
toggleAssigneeFilter: (value) =>
set((state) => {
const exists = state.assigneeFilters.some(
(f) => f.type === value.type && f.id === value.id,
);
return {
assigneeFilters: exists
? state.assigneeFilters.filter(
(f) => !(f.type === value.type && f.id === value.id),
)
: [...state.assigneeFilters, value],
};
}),
toggleNoAssignee: () =>
set((state) => ({ includeNoAssignee: !state.includeNoAssignee })),
toggleCreatorFilter: (value) =>
set((state) => {
const exists = state.creatorFilters.some(
(f) => f.type === value.type && f.id === value.id,
);
return {
creatorFilters: exists
? state.creatorFilters.filter(
(f) => !(f.type === value.type && f.id === value.id),
)
: [...state.creatorFilters, value],
};
}),
hideStatus: (status) =>
set((state) => {
// If no filter active, activate filter with all EXCEPT this one
if (state.statusFilters.length === 0) {
return { statusFilters: ALL_STATUSES.filter((s) => s !== status) };
}
return {
statusFilters: state.statusFilters.filter((s) => s !== status),
};
}),
showStatus: (status) =>
set((state) => {
if (state.statusFilters.length === 0) return state;
if (state.statusFilters.includes(status)) return state;
return { statusFilters: [...state.statusFilters, status] };
}),
clearFilters: () =>
set({
statusFilters: [],
priorityFilters: [],
assigneeFilters: [],
includeNoAssignee: false,
creatorFilters: [],
sortBy: "position",
sortDirection: "asc",
cardProperties: {
priority: true,
description: true,
assignee: true,
dueDate: true,
},
listCollapsedStatuses: [],
setViewMode: (mode) => set({ viewMode: mode }),
toggleStatusFilter: (status) =>
set((state) => ({
statusFilters: state.statusFilters.includes(status)
? state.statusFilters.filter((s) => s !== status)
: [...state.statusFilters, status],
})),
togglePriorityFilter: (priority) =>
set((state) => ({
priorityFilters: state.priorityFilters.includes(priority)
? state.priorityFilters.filter((p) => p !== priority)
: [...state.priorityFilters, priority],
})),
toggleAssigneeFilter: (value) =>
set((state) => {
const exists = state.assigneeFilters.some(
(f) => f.type === value.type && f.id === value.id,
);
return {
assigneeFilters: exists
? state.assigneeFilters.filter(
(f) => !(f.type === value.type && f.id === value.id),
)
: [...state.assigneeFilters, value],
};
}),
toggleNoAssignee: () =>
set((state) => ({ includeNoAssignee: !state.includeNoAssignee })),
toggleCreatorFilter: (value) =>
set((state) => {
const exists = state.creatorFilters.some(
(f) => f.type === value.type && f.id === value.id,
);
return {
creatorFilters: exists
? state.creatorFilters.filter(
(f) => !(f.type === value.type && f.id === value.id),
)
: [...state.creatorFilters, value],
};
}),
hideStatus: (status) =>
set((state) => {
// If no filter active, activate filter with all EXCEPT this one
if (state.statusFilters.length === 0) {
return { statusFilters: ALL_STATUSES.filter((s) => s !== status) };
}
return {
statusFilters: state.statusFilters.filter((s) => s !== status),
};
}),
showStatus: (status) =>
set((state) => {
if (state.statusFilters.length === 0) return state;
if (state.statusFilters.includes(status)) return state;
return { statusFilters: [...state.statusFilters, status] };
}),
clearFilters: () =>
set({
statusFilters: [],
priorityFilters: [],
assigneeFilters: [],
includeNoAssignee: false,
creatorFilters: [],
}),
setSortBy: (field) => set({ sortBy: field }),
setSortDirection: (dir) => set({ sortDirection: dir }),
toggleCardProperty: (key) =>
set((state) => ({
cardProperties: {
...state.cardProperties,
[key]: !state.cardProperties[key],
},
})),
toggleListCollapsed: (status) =>
set((state) => ({
listCollapsedStatuses: state.listCollapsedStatuses.includes(status)
? state.listCollapsedStatuses.filter((s) => s !== status)
: [...state.listCollapsedStatuses, status],
})),
}),
{
name: "multica_issues_view",
partialize: (state) => ({
viewMode: state.viewMode,
statusFilters: state.statusFilters,
priorityFilters: state.priorityFilters,
assigneeFilters: state.assigneeFilters,
includeNoAssignee: state.includeNoAssignee,
creatorFilters: state.creatorFilters,
sortBy: state.sortBy,
sortDirection: state.sortDirection,
cardProperties: state.cardProperties,
listCollapsedStatuses: state.listCollapsedStatuses,
}),
}
)
setSortBy: (field) => set({ sortBy: field }),
setSortDirection: (dir) => set({ sortDirection: dir }),
toggleCardProperty: (key) =>
set((state) => ({
cardProperties: {
...state.cardProperties,
[key]: !state.cardProperties[key],
},
})),
toggleListCollapsed: (status) =>
set((state) => ({
listCollapsedStatuses: state.listCollapsedStatuses.includes(status)
? state.listCollapsedStatuses.filter((s) => s !== status)
: [...state.listCollapsedStatuses, status],
})),
});
export const viewStorePersistOptions = (name: string) => ({
name,
partialize: (state: IssueViewState) => ({
viewMode: state.viewMode,
statusFilters: state.statusFilters,
priorityFilters: state.priorityFilters,
assigneeFilters: state.assigneeFilters,
includeNoAssignee: state.includeNoAssignee,
creatorFilters: state.creatorFilters,
sortBy: state.sortBy,
sortDirection: state.sortDirection,
cardProperties: state.cardProperties,
listCollapsedStatuses: state.listCollapsedStatuses,
}),
});
/** Factory: creates a vanilla StoreApi for use with React Context. */
export function createIssueViewStore(persistKey: string): StoreApi<IssueViewState> {
return createStore<IssueViewState>()(
persist(viewStoreSlice, viewStorePersistOptions(persistKey))
);
}
/** Global singleton for the /issues page. */
export const useIssueViewStore = create<IssueViewState>()(
persist(viewStoreSlice, viewStorePersistOptions("multica_issues_view"))
);
// Clear actor-based filters when workspace switches (IDs are workspace-scoped).
// Clear filters on all registered view stores when workspace switches.
// Deferred to avoid circular dependency: view-store → workspace → issues → view-store.
let _filterSubInitialized = false;
export function initFilterWorkspaceSync() {
if (_filterSubInitialized) return;
_filterSubInitialized = true;
const _syncedStores = new Set<StoreApi<IssueViewState>>();
let _workspaceSyncInitialized = false;
export function registerViewStoreForWorkspaceSync(store: StoreApi<IssueViewState>) {
_syncedStores.add(store);
if (_workspaceSyncInitialized) return;
_workspaceSyncInitialized = true;
// Dynamic import breaks the circular module evaluation chain.
import("@/features/workspace").then(({ useWorkspaceStore }) => {
@@ -193,9 +206,13 @@ export function initFilterWorkspaceSync() {
useWorkspaceStore.subscribe((state) => {
const id = state.workspace?.id;
if (prevId && id !== prevId) {
useIssueViewStore.getState().clearFilters();
for (const s of _syncedStores) s.getState().clearFilters();
}
prevId = id;
});
});
}
/** Backward-compatible alias — registers the global singleton for workspace sync. */
export const initFilterWorkspaceSync = () =>
registerViewStoreForWorkspaceSync(useIssueViewStore);

View File

@@ -0,0 +1,62 @@
"use client";
import Link from "next/link";
import { LandingHeader } from "./landing-header";
import { LandingFooter } from "./landing-footer";
import { GitHubMark, githubUrl } from "./shared";
import { useLocale } from "../i18n";
export function AboutPageClient() {
const { t } = useLocale();
const n = t.about.nameLine;
return (
<>
<LandingHeader variant="light" />
<main className="bg-white text-[#0a0d12]">
<div className="mx-auto max-w-[720px] px-4 py-16 sm:px-6 sm:py-20 lg:py-24">
<h1 className="font-[family-name:var(--font-serif)] text-[2.6rem] leading-[1.05] tracking-[-0.03em] sm:text-[3.4rem]">
{t.about.title}
</h1>
<div className="mt-8 space-y-6 text-[15px] leading-[1.8] text-[#0a0d12]/70 sm:text-[16px]">
<p>
{n.prefix}
<strong className="font-semibold text-[#0a0d12]">
{n.mul}
</strong>
{n.tiplexed}
<strong className="font-semibold text-[#0a0d12]">
{n.i}
</strong>
{n.nformationAnd}
<strong className="font-semibold text-[#0a0d12]">
{n.c}
</strong>
{n.omputing}
<strong className="font-semibold text-[#0a0d12]">
{n.a}
</strong>
{n.gent}
</p>
{t.about.paragraphs.map((p, i) => (
<p key={i}>{p}</p>
))}
</div>
<div className="mt-12">
<Link
href={githubUrl}
target="_blank"
rel="noreferrer"
className="inline-flex items-center gap-2.5 rounded-[12px] bg-[#0a0d12] px-5 py-3 text-[14px] font-semibold text-white transition-colors hover:bg-[#0a0d12]/88"
>
<GitHubMark className="size-4" />
{t.about.cta}
</Link>
</div>
</div>
</main>
<LandingFooter />
</>
);
}

View File

@@ -0,0 +1,55 @@
"use client";
import { LandingHeader } from "./landing-header";
import { LandingFooter } from "./landing-footer";
import { useLocale } from "../i18n";
export function ChangelogPageClient() {
const { t } = useLocale();
return (
<>
<LandingHeader variant="light" />
<main className="bg-white text-[#0a0d12]">
<div className="mx-auto max-w-[720px] px-4 py-16 sm:px-6 sm:py-20 lg:py-24">
<h1 className="font-[family-name:var(--font-serif)] text-[2.6rem] leading-[1.05] tracking-[-0.03em] sm:text-[3.4rem]">
{t.changelog.title}
</h1>
<p className="mt-4 text-[15px] leading-7 text-[#0a0d12]/60 sm:text-[16px]">
{t.changelog.subtitle}
</p>
<div className="mt-16 space-y-16">
{t.changelog.entries.map((release) => (
<div key={release.version} className="relative">
<div className="flex items-baseline gap-3">
<span className="text-[13px] font-semibold tabular-nums">
v{release.version}
</span>
<span className="text-[13px] text-[#0a0d12]/40">
{release.date}
</span>
</div>
<h2 className="mt-2 text-[20px] font-semibold leading-snug sm:text-[22px]">
{release.title}
</h2>
<ul className="mt-4 space-y-2">
{release.changes.map((change) => (
<li
key={change}
className="flex items-start gap-2.5 text-[14px] leading-[1.7] text-[#0a0d12]/60 sm:text-[15px]"
>
<span className="mt-2.5 h-1 w-1 shrink-0 rounded-full bg-[#0a0d12]/30" />
{change}
</li>
))}
</ul>
</div>
))}
</div>
</div>
</main>
<LandingFooter />
</>
);
}

View File

@@ -0,0 +1,70 @@
"use client";
import { useState } from "react";
import { cn } from "@/lib/utils";
import { useLocale } from "../i18n";
export function FAQSection() {
const { t } = useLocale();
const [openIndex, setOpenIndex] = useState<number | null>(null);
return (
<section id="faq" className="bg-[#f8f8f8] text-[#0a0d12]">
<div className="mx-auto max-w-[860px] px-4 py-24 sm:px-6 sm:py-32 lg:py-40">
<div className="text-center">
<p className="text-[11px] font-semibold uppercase tracking-[0.16em] text-[#0a0d12]/40">
{t.faq.label}
</p>
<h2 className="mt-4 font-[family-name:var(--font-serif)] text-[2.6rem] leading-[1.05] tracking-[-0.03em] sm:text-[3.4rem] lg:text-[4.2rem]">
{t.faq.headline}
</h2>
</div>
<div className="mt-14 divide-y divide-[#0a0d12]/10 sm:mt-16">
{t.faq.items.map((faq, i) => (
<div key={i}>
<button
onClick={() => setOpenIndex(openIndex === i ? null : i)}
className="flex w-full items-start justify-between gap-4 py-6 text-left"
>
<span className="text-[16px] font-semibold leading-snug text-[#0a0d12] sm:text-[17px]">
{faq.question}
</span>
<span
className={cn(
"mt-0.5 flex size-6 shrink-0 items-center justify-center rounded-full border border-[#0a0d12]/12 text-[#0a0d12]/40 transition-transform",
openIndex === i && "rotate-45",
)}
>
<svg
width="12"
height="12"
viewBox="0 0 12 12"
fill="none"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
>
<path d="M6 1v10M1 6h10" />
</svg>
</span>
</button>
<div
className={cn(
"grid transition-[grid-template-rows] duration-200 ease-out",
openIndex === i ? "grid-rows-[1fr]" : "grid-rows-[0fr]",
)}
>
<div className="overflow-hidden">
<p className="pb-6 pr-12 text-[14px] leading-[1.7] text-[#0a0d12]/56 sm:text-[15px]">
{faq.answer}
</p>
</div>
</div>
</div>
))}
</div>
</div>
</section>
);
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,60 @@
"use client";
import Link from "next/link";
import { useAuthStore } from "@/features/auth";
import { useLocale } from "../i18n";
import { GitHubMark, githubUrl, heroButtonClassName } from "./shared";
export function HowItWorksSection() {
const { t } = useLocale();
const user = useAuthStore((s) => s.user);
return (
<section id="how-it-works" className="bg-[#05070b] text-white">
<div className="mx-auto max-w-[1320px] px-4 py-24 sm:px-6 sm:py-32 lg:px-8 lg:py-40">
<p className="text-[11px] font-semibold uppercase tracking-[0.16em] text-white/40">
{t.howItWorks.label}
</p>
<h2 className="mt-4 font-[family-name:var(--font-serif)] text-[2.6rem] leading-[1.05] tracking-[-0.03em] sm:text-[3.4rem] lg:text-[4.2rem]">
{t.howItWorks.headlineMain}
<br />
<span className="text-white/40">{t.howItWorks.headlineFaded}</span>
</h2>
<div className="mt-20 grid gap-px overflow-hidden rounded-2xl border border-white/10 bg-white/10 sm:grid-cols-2 lg:grid-cols-4">
{t.howItWorks.steps.map((step, i) => (
<div
key={i}
className="flex flex-col bg-[#05070b] p-8 lg:p-10"
>
<span className="text-[13px] font-semibold tabular-nums text-white/28">
{String(i + 1).padStart(2, "0")}
</span>
<h3 className="mt-4 text-[17px] font-semibold leading-snug text-white sm:text-[18px]">
{step.title}
</h3>
<p className="mt-3 text-[14px] leading-[1.7] text-white/50 sm:text-[15px]">
{step.description}
</p>
</div>
))}
</div>
<div className="mt-14 flex flex-wrap items-center gap-4">
<Link href={user ? "/issues" : "/login"} className={heroButtonClassName("solid")}>
{user ? t.header.dashboard : t.howItWorks.cta}
</Link>
<Link
href={githubUrl}
target="_blank"
rel="noreferrer"
className={heroButtonClassName("ghost")}
>
<GitHubMark className="size-4" />
{t.howItWorks.ctaGithub}
</Link>
</div>
</div>
</section>
);
}

View File

@@ -0,0 +1,109 @@
"use client";
import Link from "next/link";
import { MulticaIcon } from "@/components/multica-icon";
import { cn } from "@/lib/utils";
import { useAuthStore } from "@/features/auth";
import { useLocale, locales, localeLabels } from "../i18n";
export function LandingFooter() {
const { t, locale, setLocale } = useLocale();
const user = useAuthStore((s) => s.user);
const groups = Object.values(t.footer.groups);
return (
<footer className="bg-[#0a0d12] text-white">
<div className="mx-auto max-w-[1320px] px-4 sm:px-6 lg:px-8">
{/* Top: CTA + link columns */}
<div className="flex flex-col gap-12 border-b border-white/10 py-16 sm:py-20 lg:flex-row lg:gap-20">
{/* Left — newsletter / CTA */}
<div className="lg:w-[340px] lg:shrink-0">
<Link href="#product" className="flex items-center gap-3">
<MulticaIcon className="size-5 text-white" noSpin />
<span className="text-[18px] font-semibold tracking-[0.04em] lowercase">
multica
</span>
</Link>
<p className="mt-4 max-w-[300px] text-[14px] leading-[1.7] text-white/50 sm:text-[15px]">
{t.footer.tagline}
</p>
<div className="mt-6">
<Link
href={user ? "/issues" : "/login"}
className="inline-flex items-center justify-center rounded-[11px] bg-white px-5 py-2.5 text-[13px] font-semibold text-[#0a0d12] transition-colors hover:bg-white/88"
>
{user ? t.header.dashboard : t.footer.cta}
</Link>
</div>
</div>
{/* Right — link columns */}
<div className="grid flex-1 grid-cols-2 gap-8 sm:grid-cols-4">
{groups.map((group) => (
<div key={group.label}>
<h4 className="text-[12px] font-semibold uppercase tracking-[0.1em] text-white/40">
{group.label}
</h4>
<ul className="mt-4 flex flex-col gap-2.5">
{group.links.map((link) => (
<li key={link.label}>
<Link
href={link.href}
{...(link.href.startsWith("http")
? { target: "_blank", rel: "noreferrer" }
: {})}
className="text-[14px] text-white/50 transition-colors hover:text-white"
>
{link.label}
</Link>
</li>
))}
</ul>
</div>
))}
</div>
</div>
{/* Bottom: copyright + language switcher */}
<div className="flex items-center justify-between py-6">
<p className="text-[13px] text-white/36">
{t.footer.copyright.replace(
"{year}",
String(new Date().getFullYear()),
)}
</p>
<div className="flex items-center">
{locales.map((l, i) => (
<button
key={l}
onClick={() => setLocale(l)}
className={cn(
"px-1.5 py-1 text-[12px] font-medium transition-colors",
l === locale
? "text-white/70"
: "text-white/30 hover:text-white/50",
i > 0 && "border-l border-white/16",
)}
>
{localeLabels[l]}
</button>
))}
</div>
</div>
{/* Giant logo */}
<div className="relative overflow-hidden pb-4">
<div className="flex items-end gap-6 sm:gap-8">
<MulticaIcon
className="size-[clamp(4rem,12vw,10rem)] shrink-0 text-white"
noSpin
/>
<span className="font-[family-name:var(--font-serif)] text-[clamp(6rem,22vw,16rem)] font-normal leading-[0.82] tracking-[-0.04em] text-white lowercase">
multica
</span>
</div>
</div>
</div>
</footer>
);
}

View File

@@ -0,0 +1,66 @@
"use client";
import Link from "next/link";
import { MulticaIcon } from "@/components/multica-icon";
import { cn } from "@/lib/utils";
import { useAuthStore } from "@/features/auth";
import { useLocale } from "../i18n";
import { GitHubMark, githubUrl, headerButtonClassName } from "./shared";
export function LandingHeader({
variant = "dark",
}: {
variant?: "dark" | "light";
}) {
const { t } = useLocale();
const user = useAuthStore((s) => s.user);
return (
<header
className={cn(
"inset-x-0 top-0 z-30",
variant === "dark"
? "absolute bg-transparent"
: "border-b border-[#0a0d12]/8 bg-white",
)}
>
<div className="mx-auto flex h-[76px] max-w-[1320px] items-center justify-between px-4 sm:px-6 lg:px-8">
<Link href="/" className="flex items-center gap-3">
<MulticaIcon
className={cn(
"size-5",
variant === "dark" ? "text-white" : "text-[#0a0d12]",
)}
noSpin
/>
<span
className={cn(
"text-[18px] font-semibold tracking-[0.04em] lowercase sm:text-[20px]",
variant === "dark" ? "text-white/92" : "text-[#0a0d12]",
)}
>
multica
</span>
</Link>
<div className="flex items-center gap-2.5 sm:gap-3">
<Link
href={githubUrl}
target="_blank"
rel="noreferrer"
className={headerButtonClassName("ghost", variant)}
>
<GitHubMark className="size-3.5" />
{t.header.github}
</Link>
<Link
href={user ? "/issues" : "/login"}
className={headerButtonClassName("solid", variant)}
>
{user ? t.header.dashboard : t.header.login}
</Link>
</div>
</div>
</header>
);
}

View File

@@ -0,0 +1,110 @@
"use client";
import Image from "next/image";
import Link from "next/link";
import { useAuthStore } from "@/features/auth";
import { useLocale } from "../i18n";
import {
ClaudeCodeLogo,
CodexLogo,
GitHubMark,
githubUrl,
heroButtonClassName,
} from "./shared";
export function LandingHero() {
const { t } = useLocale();
const user = useAuthStore((s) => s.user);
return (
<div className="relative min-h-full overflow-hidden bg-[#05070b] text-white">
<LandingBackdrop />
<main className="relative z-10">
<section
id="product"
className="mx-auto max-w-[1320px] px-4 pb-16 pt-28 sm:px-6 sm:pt-32 lg:px-8 lg:pb-24 lg:pt-36"
>
<div className="mx-auto max-w-[1120px] text-center">
<h1 className="font-[family-name:var(--font-serif)] text-[3.65rem] leading-[0.93] tracking-[-0.038em] text-white drop-shadow-[0_10px_34px_rgba(0,0,0,0.32)] sm:text-[4.85rem] lg:text-[6.4rem]">
{t.hero.headlineLine1}
<br />
{t.hero.headlineLine2}
</h1>
<p className="mx-auto mt-7 max-w-[820px] text-[15px] leading-7 text-white/84 sm:text-[17px]">
{t.hero.subheading}
</p>
<div className="mt-8 flex flex-wrap items-center justify-center gap-3">
<Link href={user ? "/issues" : "/login"} className={heroButtonClassName("solid")}>
{user ? t.header.dashboard : t.hero.cta}
</Link>
<Link
href={githubUrl}
target="_blank"
rel="noreferrer"
className={heroButtonClassName("ghost")}
>
<GitHubMark className="size-4" />
GitHub
</Link>
</div>
</div>
<div className="mt-10 flex items-center justify-center gap-8">
<span className="text-[15px] text-white/50">
{t.hero.worksWith}
</span>
<div className="flex items-center gap-6">
<div className="flex items-center gap-2.5 text-white/80">
<ClaudeCodeLogo className="size-5" />
<span className="text-[15px] font-medium">Claude Code</span>
</div>
<div className="flex items-center gap-2.5 text-white/80">
<CodexLogo className="size-5" />
<span className="text-[15px] font-medium">Codex</span>
</div>
</div>
</div>
<div id="preview" className="mt-10 sm:mt-12">
<ProductImage alt={t.hero.imageAlt} />
</div>
</section>
</main>
</div>
);
}
function LandingBackdrop() {
return (
<div className="pointer-events-none absolute inset-0">
<Image
src="/images/landing-bg.jpg"
alt=""
fill
priority
className="object-cover object-center"
/>
</div>
);
}
function ProductImage({ alt }: { alt: string }) {
return (
<div>
<div className="relative overflow-hidden border border-white/14">
<Image
src="/images/landing-hero.png"
alt={alt}
width={3532}
height={2382}
className="block h-auto w-full"
sizes="(max-width: 1320px) 100vw, 1320px"
quality={85}
/>
</div>
</div>
);
}

View File

@@ -0,0 +1,26 @@
"use client";
import { LandingHeader } from "./landing-header";
import { LandingHero } from "./landing-hero";
import { FeaturesSection } from "./features-section";
import { HowItWorksSection } from "./how-it-works-section";
import { OpenSourceSection } from "./open-source-section";
import { FAQSection } from "./faq-section";
import { LandingFooter } from "./landing-footer";
export function MulticaLanding() {
return (
<>
<div className="relative">
<LandingHeader />
<LandingHero />
</div>
<FeaturesSection />
<HowItWorksSection />
<OpenSourceSection />
<FAQSection />
<LandingFooter />
</>
);
}

View File

@@ -0,0 +1,59 @@
"use client";
import Link from "next/link";
import { useLocale } from "../i18n";
import { GitHubMark, githubUrl } from "./shared";
export function OpenSourceSection() {
const { t } = useLocale();
return (
<section id="open-source" className="bg-white text-[#0a0d12]">
<div className="mx-auto max-w-[1320px] px-4 py-24 sm:px-6 sm:py-32 lg:px-8 lg:py-40">
<div className="flex flex-col gap-16 lg:flex-row lg:items-start lg:gap-24">
{/* Left column — heading + CTA */}
<div className="lg:w-[480px] lg:shrink-0">
<p className="text-[11px] font-semibold uppercase tracking-[0.16em] text-[#0a0d12]/40">
{t.openSource.label}
</p>
<h2 className="mt-4 font-[family-name:var(--font-serif)] text-[2.6rem] leading-[1.05] tracking-[-0.03em] sm:text-[3.4rem] lg:text-[4.2rem]">
{t.openSource.headlineLine1}
<br />
{t.openSource.headlineLine2}
</h2>
<p className="mt-6 max-w-[420px] text-[15px] leading-7 text-[#0a0d12]/60 sm:text-[16px]">
{t.openSource.description}
</p>
<div className="mt-8 flex flex-wrap items-center gap-3">
<Link
href={githubUrl}
target="_blank"
rel="noreferrer"
className="inline-flex items-center justify-center gap-2.5 rounded-[12px] bg-[#0a0d12] px-5 py-3 text-[14px] font-semibold text-white transition-colors hover:bg-[#0a0d12]/88"
>
<GitHubMark className="size-4" />
{t.openSource.cta}
</Link>
</div>
</div>
{/* Right column — highlight grid */}
<div className="flex-1">
<div className="grid gap-px overflow-hidden rounded-2xl border border-[#0a0d12]/8 bg-[#0a0d12]/8 sm:grid-cols-2">
{t.openSource.highlights.map((item) => (
<div key={item.title} className="bg-white p-8 lg:p-10">
<h3 className="text-[17px] font-semibold leading-snug text-[#0a0d12] sm:text-[18px]">
{item.title}
</h3>
<p className="mt-3 text-[14px] leading-[1.7] text-[#0a0d12]/56 sm:text-[15px]">
{item.description}
</p>
</div>
))}
</div>
</div>
</div>
</div>
</section>
);
}

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