Compare commits

..

317 Commits

Author SHA1 Message Date
Jiayuan
2a515fc0be feat(web): change Done status label color to purple
Add a dedicated `--done` CSS color token (oklch hue 300) for the Done
status instead of reusing the `--info` token (blue), so other info-colored
elements remain unaffected.
2026-04-04 15:49:06 +08:00
Jiayuan Zhang
451715f5a1 fix(web): prevent Archive Agent button text from wrapping to two lines (#404)
Add w-auto class to DropdownMenuContent on agent detail panel, matching
the pattern used by other dropdowns in the codebase. The default
w-(--anchor-width) was constraining the popup to the icon button width.
2026-04-04 12:59:25 +08:00
Jiayuan Zhang
fdf594155c Merge pull request #396 from multica-ai/feat/comment-list-pagination
feat(comments): add pagination to comment list API and CLI
2026-04-04 01:07:22 +08:00
Jiayuan
c39470a53f fix(comments): address code review feedback on pagination
1. Update CLAUDE.md template to document --limit, --offset, --since
   params and guide agents to use pagination when comments are large
2. Add GetJSONWithHeaders to API client; CLI now prints "Showing X of Y
   comments" to stderr when paginating
3. Cap --since without --limit at 500 server-side to prevent unbounded
   result sets
2026-04-04 01:01:48 +08:00
Jiayuan Zhang
e5dfb34a2a Merge pull request #398 from multica-ai/agent/lambda/df68aca8
fix(inbox): archive at issue level instead of event level
2026-04-04 00:30:04 +08:00
Jiayuan
58549975e0 fix(inbox): archive all items for the same issue instead of just one
The inbox UI deduplicates items by issue_id (showing only the latest
notification per issue). Previously, clicking archive only archived the
single visible item, so older items for the same issue would reappear.

Now archiving operates at the issue level — both the backend and frontend
archive all inbox items sharing the same issue_id.
2026-04-04 00:18:14 +08:00
Jiayuan
0bbc6bc1c5 feat(comments): add pagination support to comment list API and CLI
Add --limit, --offset, and --since flags to `multica issue comment list`
to prevent context window overflow when issues have many comments.

The API endpoint now accepts limit, offset, and since (RFC3339) query
parameters. When paginating, the response includes an X-Total-Count
header with the total number of comments.
2026-04-03 23:53:00 +08:00
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
Bohan Jiang
73b0fd98d0 Merge pull request #231 from multica-ai/feat/remote-api-env-config
feat(web): support REMOTE_API_URL for remote backend proxy
2026-03-31 16:57:32 +08:00
Bohan Jiang
2706e3d4f5 Merge pull request #229 from multica-ai/agent/j/298fd11b
feat(web): show toast with link after creating an issue
2026-03-31 16:53:55 +08:00
Jiang Bohan
89bedb8f5c feat(web): support REMOTE_API_URL env for proxying to remote backend
- Load root .env in next.config.ts so REMOTE_API_URL is available
- Default fallback remains localhost:8080 (no impact on existing setups)
- Add REMOTE_API_URL to .env.example with documentation
2026-03-31 16:53:32 +08:00
LinYushen
94c62b63d3 Merge pull request #228 from multica-ai/fix/s3-cleanup-on-delete
fix(upload): clean up S3 objects when attachments are deleted
2026-03-31 16:49:47 +08:00
yushen
114d2a3acf Merge remote-tracking branch 'origin/main' into fix/s3-cleanup-on-delete
# Conflicts:
#	server/pkg/db/generated/models.go
2026-03-31 16:49:32 +08:00
Jiang Bohan
da086db982 fix(issues): render comments with Markdown to enable issue mention cards
Comment body was using RichTextEditor in read-only mode which doesn't
support IssueMentionCard rendering. Switch to Markdown component so
mention://issue/ links render as rich cards with status + title.
2026-03-31 16:49:03 +08:00
yushen
79cd2a3a5d fix(upload): link attachments to comments via client-side ID tracking
Instead of regex-parsing markdown content to find attachment URLs
(fragile), the frontend now tracks uploaded attachment IDs and sends
them with the comment creation request. The backend links them by ID.

Frontend: upload returns attachment ID, comment/reply inputs collect
IDs during editing session, pass as attachment_ids on submit.
Backend: CreateComment accepts attachment_ids, links by ID+issue scope.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-31 16:47:27 +08:00
Jiang Bohan
6b2df8a356 feat(web): show toast with link after creating an issue
After creating an issue, a success toast appears showing the issue
identifier (e.g. MUL-72) with a "View issue" action button that
navigates to the issue detail page. Similar to Linear's behavior.
2026-03-31 16:43:26 +08:00
Bohan Jiang
dbd4830e35 Merge pull request #223 from multica-ai/agent/j/36b5c91f
feat(issues): add @ mention for issues in comments
2026-03-31 16:38:12 +08:00
yushen
acba0b8139 fix(upload): clean up S3 objects when attachments are deleted
- Add Delete/DeleteKeys/KeyFromURL methods to S3Storage
- DeleteAttachment handler now removes the S3 object after DB delete
- DeleteComment collects attachment URLs before CASCADE, then cleans S3
- DeleteIssue collects all attachment URLs (issue + comment level) before CASCADE, then cleans S3

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-31 16:34:47 +08:00
Jiang Bohan
61a34ba5cc merge: resolve conflicts with main and remote 2026-03-31 16:26:27 +08:00
Jiang Bohan
b8c784dda3 merge: resolve conflicts with main
- Take main's router.go, rich-text-editor.tsx, comment-card.tsx
- Remove deleted daemon_pairing.go
- Keep issue mention card feature
2026-03-31 16:25:20 +08:00
Jiang Bohan
34ee700295 fix(editor): post-process mention shortcodes to markdown link format
The Tiptap Mention extension's createInlineMarkdownSpec serializes
mentions as shortcodes [@ id="..." label="..."] — the .extend()
renderMarkdown override may not reliably take effect.

Added a robust fallback: post-process the editor's markdown output
by replacing shortcodes with [@Label](mention://type/id) using the
Tiptap JSON document for type info. Also preprocess stored shortcodes
in the Markdown renderer for backward compatibility.
2026-03-31 16:23:11 +08:00
LinYushen
2c76a0b905 Merge pull request #227 from multica-ai/fix/fetch-credentials-include
fix(api): add credentials include for cross-origin cookie storage
2026-03-31 16:22:54 +08:00
yushen
d57b98fc78 fix(api): add credentials include to fetch for cross-origin cookie storage
The API at multica-api.copilothub.ai sets CloudFront signed cookies
with Domain=.copilothub.ai, but fetch() defaults to credentials:
'same-origin'. Since the frontend (multica-app.copilothub.ai) and API
are cross-origin, the browser silently drops Set-Cookie headers without
credentials: 'include'.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-31 16:22:25 +08:00
Jiang Bohan
e16f1579a9 feat(issues): render issue mentions as rich cards with status icon
- Fix mention markdown serialization: use renderMarkdown (tiptap/markdown 3.x API)
  instead of addStorage.markdown.serialize which was silently ignored
- Add IssueMentionCard component showing status icon + identifier + title
- Update Markdown renderer to use card for mention://issue/ links
2026-03-31 16:19:32 +08:00
Naiyuan Qing
9e06b02cfa Merge pull request #226 from multica-ai/feature/comment-context-menu
feat(editor): migrate to @tiptap/markdown, TitleEditor, comment UX improvements
2026-03-31 16:19:10 +08:00
Jiang Bohan
e0e52bca64 feat(web): move OK emoji to 2nd position, remove thumbs down 2026-03-31 16:19:03 +08:00
Jiang Bohan
57a5b8b7a4 feat(web): add OK emoji to reaction quick bar 2026-03-31 16:19:02 +08:00
Bohan Jiang
8bbc228469 fix(editor): use correct getMarkdown API for @tiptap/markdown (#219)
The migration from tiptap-markdown to @tiptap/markdown (38e92040)
changed the getMarkdown API location. The old package stored it at
editor.storage.markdown.getMarkdown(), but @tiptap/markdown adds it
directly as editor.getMarkdown(). This caused getEditorMarkdown() to
always return "", preventing description (and any markdown content)
from being saved when creating or editing issues/comments.
2026-03-31 16:19:02 +08:00
Jiayuan
94ddbfb4d9 fix(tests): merge main, renumber migration, fix execenv test assertions
Merge main to pick up 028_task_trigger_comment migration. Renumber
daemon_token migration to 029. Fix execenv tests that expected CLI hints
in issue_context.md after they were moved to CLAUDE.md.
2026-03-31 16:19:02 +08:00
Jiayuan
afdfee78b9 feat(daemon): add authentication for daemon API routes
Issue daemon auth tokens (mdt_) on pairing session claim, bound to
workspace_id + daemon_id with 1-year expiry. Add DaemonAuth middleware
that validates these tokens and falls back to JWT/PAT for backward
compatibility. Apply middleware to all daemon routes except pairing
endpoints.
2026-03-31 16:19:02 +08:00
LinYushen
dc3dec8ebe feat(cli): add multica update command (#218)
* feat(cli): add `multica update` command

Detects whether multica was installed via Homebrew (by resolving the
binary symlink and checking if it lives under a Homebrew prefix).

- Brew installs: runs `brew upgrade multica` automatically.
- Non-brew installs: prints instructions for installing via brew or
  downloading from GitHub releases.
- Checks latest version from the GitHub releases API and skips
  the update if already up to date.

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

* fix(cli): use fully-qualified tap name in brew upgrade

Use `brew upgrade multica-ai/tap/multica` instead of `brew upgrade multica`
to avoid any potential name collision with core formulae.

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-03-31 16:19:02 +08:00
Bohan Jiang
9ceea9c17e fix(editor): use correct getMarkdown API for @tiptap/markdown (#217)
The migration from tiptap-markdown to @tiptap/markdown in 38e92040
broke comment creation. The old package stored getMarkdown() on
editor.storage.markdown, but the official @tiptap/markdown extension
adds it directly to the editor instance (editor.getMarkdown()).

This caused getEditorMarkdown() to always return "", making the
submit button permanently disabled and preventing any comments.

Also fix stale submitting ref in useIssueTimeline dependency array.
2026-03-31 16:19:02 +08:00
Bohan Jiang
02918d8229 perf(web): parallelize auth init and non-blocking dashboard layout (#220)
- Fire getMe() and listWorkspaces() in parallel instead of serially,
  saving one network round-trip (~200ms on cloud)
- Render dashboard sidebar shell immediately once user is authenticated,
  show loading indicator in content area while workspace hydrates

Closes MUL-41
2026-03-31 16:19:02 +08:00
Jiayuan
0eaa6e74a4 fix(daemon): update execenv tests to match current renderIssueContext output
CLI hints like "multica issue get" were moved to CLAUDE.md and are no
longer rendered into issue_context.md. Remove stale assertions.
2026-03-31 16:19:01 +08:00
Jiayuan
d4e121284a feat(inbox): add archive button on individual inbox list items
Show an archive icon on hover for each inbox list item, allowing
users to archive a single message directly from the list without
needing to open the detail panel first.
2026-03-31 16:19:01 +08:00
Jiayuan
fc8969a399 fix(issues): remove duplicate commenter header in collapsible comment
The parent comment's header (avatar, name, time, context menu) is now
the collapsible trigger itself. Only the body, reactions, replies, and
reply input collapse — the header is always visible. This removes the
duplicate author info that appeared when expanded.
2026-03-31 16:19:01 +08:00
Jiayuan
90295d8554 fix(daemon): add CLI hint to issue_context.md
renderIssueContext() now includes a "Quick Start" section with the
`multica issue get` command so agents know how to fetch issue details.
Fixes the TestPrepareDirectoryMode and TestWriteContextFiles failures.
2026-03-31 16:19:01 +08:00
Jiayuan
91c279fd2a feat(issues): make entire comment card collapsible with toggle
Each comment card now has a clickable header with a chevron toggle.
When collapsed, shows author, timestamp, and a content preview.
When expanded, shows the full comment body, replies, and reply input.
2026-03-31 16:19:01 +08:00
Jiayuan
ef4e2d94a0 feat(issues): add collapsible toggle for comment replies
Wrap the replies section in a Collapsible component so users can
collapse/expand replies on a comment thread. The parent comment and
reply input remain always visible. A chevron trigger shows the reply
count (e.g. "3 replies") and rotates on open. Default state is expanded
to preserve existing behavior.
2026-03-31 16:19:00 +08:00
Naiyuan Qing
27987adf37 fix(inbox): use issue_id as selection key instead of inbox item id
- URL param: ?id= → ?issue= (keyed by issue, not notification)
- Multiple notifications for same issue now share selection state
- Archive correctly clears selection when archived item's issue matches

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-31 16:17:41 +08:00
Naiyuan Qing
bb2dd67941 fix(comments): collapsible header overflow, hover style on toggle
- Add shrink-0 to name/time to prevent wrapping when collapsed
- Content preview: min-w-0 flex-1 truncate for proper ellipsis
- Collapsible trigger: add rounded p-0.5 hover:bg-muted for click affordance

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-31 16:15:26 +08:00
Naiyuan Qing
26af15f189 Merge remote-tracking branch 'origin/main' into feature/comment-context-menu 2026-03-31 16:15:16 +08:00
LinYushen
b5674869ed fix(auth): enforce auth on daemon API routes (#224)
* fix(auth): enforce auth middleware and workspace membership on daemon API routes

Daemon routes were registered without the Auth middleware, meaning the
server accepted unauthenticated requests to register runtimes, claim
tasks, etc. The daemon client already sends a Bearer token — the server
just wasn't validating it.

- Split /api/daemon routes: pairing-session endpoints stay public (used
  before the daemon has a token), all others now require Auth middleware
- Add workspace membership check in DaemonRegister so only workspace
  members can register runtimes
- Update test to include X-User-ID header matching the new auth requirement

Closes MUL-90

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

* refactor(daemon): remove dead pairing-session feature

The daemon pairing flow was never completed — the daemon authenticates
via CLI config token, not pairing sessions. Remove all related code:

- Delete daemon_pairing.go handler (4 unused handlers)
- Remove pairing routes from router.go (3 public + 1 protected)
- Delete /pair/local page + test from frontend
- Remove DaemonPairingSession types and API client methods
- Add migration 029 to drop daemon_pairing_session table
- Update LOCAL_DEVELOPMENT.md to reflect actual auth flow

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-03-31 16:13:58 +08:00
Jiang Bohan
a472a0e8e0 fix(editor): override renderMarkdown/parseMarkdown for mention serialization
The @tiptap/markdown extension discovers serializers via the
renderMarkdown extension field, not addStorage(). The previous
addStorage approach was silently ignored, causing mentions to serialize
as shortcode format [@ id="..." label="..."] instead of markdown links.

Now properly overrides renderMarkdown, parseMarkdown, and
markdownTokenizer to serialize mentions as [@Label](mention://type/id)
which the Markdown renderer can handle as clickable links.
2026-03-31 16:09:31 +08:00
Naiyuan Qing
8fae493f01 merge: resolve conflicts with main (file upload support)
- Merge main's file upload (Image extension, Paperclip, useFileUpload)
- Keep our mention/markdown/TitleEditor changes
- Apply RichTextEditor edit/display to main's Collapsible CommentCard layout

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-31 16:07:45 +08:00
Naiyuan Qing
98829fad29 fix(comments): replace optimistic updates with loading state
- Remove temp-xxx optimistic inserts from submitComment/submitReply
- Wait for API response, then insert real comment into timeline
- Add Loader2 spinner to comment/reply submit buttons during loading
- Remove hover card from Markdown.tsx (will be handled via NodeView later)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-31 16:03:13 +08:00
LinYushen
fe0968d96f Merge pull request #213 from multica-ai/feature/file-upload-cloudfront
feat(upload): file upload API with S3 + CloudFront signed cookies
2026-03-31 16:00:19 +08:00
yushen
1e22633301 Merge remote-tracking branch 'origin/main' into feature/file-upload-cloudfront
# Conflicts:
#	apps/web/components/common/rich-text-editor.tsx
#	apps/web/features/issues/components/comment-card.tsx
#	apps/web/package.json
#	pnpm-lock.yaml
2026-03-31 15:59:46 +08:00
yushen
9e23fb76fc fix(upload): harden upload flow — sanitize filenames, refresh CF cookies, deduplicate handlers
- Sanitize Content-Disposition filenames to prevent header injection (strip control chars, quotes, semicolons)
- Add CloudFront cookie refresh middleware so cookies are re-issued when expired
- Log errors in groupAttachments instead of silently swallowing them
- Move useFileUpload hook to shared/hooks/ per project architecture conventions
- Add uploadWithToast helper to deduplicate try/catch/toast pattern across 3 components
- Refactor ApiClient.uploadFile to reuse auth headers, 401 handling, and error parsing
- Allow empty MIME types client-side (let server sniff and decide)
- Constrain Image extension max-width in rich-text-editor to prevent layout overflow

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-31 15:52:40 +08:00
Jiang Bohan
c5b0535a3f fix(markdown): allow mention:// protocol through URL sanitization
react-markdown v10's defaultUrlTransform strips URLs with non-standard
protocols (only http/https/irc/mailto/xmpp allowed). This caused
mention://issue/ links to have empty hrefs, breaking click navigation
to issue detail pages.
2026-03-31 15:52:03 +08:00
Naiyuan Qing
b9ea10c89d fix(comments): unify rendering with RichTextEditor, fix mention/link colors
- Comment display: replace <Markdown> with <RichTextEditor editable={false}>
- Link color: primary → brand (blue)
- Mention color: brand → primary + semibold
- Add MentionHoverCard component with HoverCardTrigger render={<span />}
- Markdown.tsx: sync mention style to text-primary font-semibold

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-31 15:50:22 +08:00
Jiayuan Zhang
5a7b34cab5 Merge pull request #211 from multica-ai/agent/lambda/5e70a174
fix(agent): fix agent visibility defaults and permission model
2026-03-31 15:50:07 +08:00
Jiayuan
feb5f57662 fix: merge main and renumber migration 028 → 030
Main now has 028 (task_trigger_comment) and 029 (daemon_token).
Renumber agent_default_private migration to 030 to avoid conflict.
2026-03-31 15:47:09 +08:00
Jiayuan
ac17fd33bf Merge remote-tracking branch 'origin/main' into agent/lambda/5e70a174 2026-03-31 15:45:59 +08:00
Jiayuan Zhang
a2ea8197e2 Merge pull request #221 from multica-ai/agent/lambda/bd1d07a6
feat(server): trigger agents via @mention in comments
2026-03-31 15:44:41 +08:00
Jiayuan
7aea32cb33 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 15:42:54 +08:00
yushen
f5353c6691 feat(upload): signed URLs for CLI + eager load attachments on comments
- Add CloudFrontSigner.SignedURL() for generating per-resource signed URLs
- Attachment responses include download_url (5-min signed URL for CLI)
- Eager load attachments on comments and timeline (same pattern as reactions)
- Add ListAttachmentsByCommentIDs query for batch loading
- Update Comment and TimelineEntry types with attachments field

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-31 15:42:10 +08:00
Naiyuan Qing
9f03b73809 feat(editor): add TitleEditor component, replace <input> for issue titles
- New TitleEditor: minimal tiptap (Document+Paragraph+Text+Placeholder)
- Single-paragraph constraint prevents Enter from creating new lines
- contenteditable div enables visual word-wrap (no horizontal scroll)
- Enter→submit+blur, Shift+Enter blocked, Escape→blur
- Replace <Input> in create-issue modal and <input> in issue-detail
- Remove titleDraft state/titleFocusedRef/sync effect from issue-detail
- Fix duplicate React key: TitleEditor key={`title-${id}`}

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-31 15:38:42 +08:00
Naiyuan Qing
ac2a4c419f refactor(editor): migrate to @tiptap/markdown, fix mention rendering
- Replace tiptap-markdown with official @tiptap/markdown (markdown→JSON direct, skip DOM)
- Add contentType:"markdown" for proper \n\n paragraph parsing
- Fix mention renderHTML: use mergeAttributes for class/data-type, <a>→<span>
- Fix type attribute leak: add renderHTML:()=>({}) to suppress raw "type" attr
- Link style: permanent underline → hover-only underline (matches read-only)
- Mention style: primary+background pill → brand color text only
- Comment edit: replace <input> with RichTextEditor for consistency

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-31 15:38:29 +08:00
Jiang Bohan
7df140bcda feat(issues): add @ mention for issues in comments
Support mentioning issues via @ in the rich text editor with fuzzy
search on identifier and title. Issue mentions render as clickable
links that navigate to the issue detail page.
2026-03-31 15:38:24 +08:00
Jiayuan Zhang
7b68dd8dda Merge pull request #222 from multica-ai/revert/daemon-auth
revert: daemon authentication for API routes (#214)
2026-03-31 15:33:02 +08:00
Jiayuan
35f77de9cc Revert "Merge pull request #214 from multica-ai/agent/lambda/4771d426"
This reverts commit cfd2fdf70f, reversing
changes made to 987984431b.
2026-03-31 15:30:51 +08:00
Jiayuan
37881adbed 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 15:30:24 +08:00
yushen
15f96468be feat(upload): add attachment table for tracking uploaded files
- Add attachment table with workspace/issue/comment associations
- Upload handler creates attachment record when workspace context exists
- Add GET /api/issues/{id}/attachments and DELETE /api/attachments/{id}
- Frontend passes issueId context during uploads for tracking
- Add Attachment type, listAttachments, deleteAttachment to API client

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-31 15:29:41 +08:00
Bohan Jiang
c44da53080 Merge pull request #215 from multica-ai/agent/j/92dbd4ba
feat(web): add OK emoji to reaction quick bar
2026-03-31 15:28:31 +08:00
Bohan Jiang
a5a2673642 fix(editor): use correct getMarkdown API for @tiptap/markdown (#219)
The migration from tiptap-markdown to @tiptap/markdown (38e92040)
changed the getMarkdown API location. The old package stored it at
editor.storage.markdown.getMarkdown(), but @tiptap/markdown adds it
directly as editor.getMarkdown(). This caused getEditorMarkdown() to
always return "", preventing description (and any markdown content)
from being saved when creating or editing issues/comments.
2026-03-31 15:28:02 +08:00
Jiang Bohan
518d4449d7 feat(web): move OK emoji to 2nd position, remove thumbs down 2026-03-31 15:27:13 +08:00
Jiayuan Zhang
cfd2fdf70f Merge pull request #214 from multica-ai/agent/lambda/4771d426
feat(daemon): add authentication for daemon API routes
2026-03-31 15:27:11 +08:00
LinYushen
987984431b feat(cli): add multica update command (#218)
* feat(cli): add `multica update` command

Detects whether multica was installed via Homebrew (by resolving the
binary symlink and checking if it lives under a Homebrew prefix).

- Brew installs: runs `brew upgrade multica` automatically.
- Non-brew installs: prints instructions for installing via brew or
  downloading from GitHub releases.
- Checks latest version from the GitHub releases API and skips
  the update if already up to date.

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

* fix(cli): use fully-qualified tap name in brew upgrade

Use `brew upgrade multica-ai/tap/multica` instead of `brew upgrade multica`
to avoid any potential name collision with core formulae.

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-03-31 15:24:05 +08:00
Bohan Jiang
d8a8549c8a fix(editor): use correct getMarkdown API for @tiptap/markdown (#217)
The migration from tiptap-markdown to @tiptap/markdown in 38e92040
broke comment creation. The old package stored getMarkdown() on
editor.storage.markdown, but the official @tiptap/markdown extension
adds it directly to the editor instance (editor.getMarkdown()).

This caused getEditorMarkdown() to always return "", making the
submit button permanently disabled and preventing any comments.

Also fix stale submitting ref in useIssueTimeline dependency array.
2026-03-31 15:23:13 +08:00
Bohan Jiang
461dad0dd5 perf(web): parallelize auth init and non-blocking dashboard layout (#220)
- Fire getMe() and listWorkspaces() in parallel instead of serially,
  saving one network round-trip (~200ms on cloud)
- Render dashboard sidebar shell immediately once user is authenticated,
  show loading indicator in content area while workspace hydrates

Closes MUL-41
2026-03-31 15:22:58 +08:00
yushen
423aa38888 feat(upload): add file upload UI — avatar, editor paste/drop, attachments
- Add uploadFile method to ApiClient (FormData + 401 handling)
- Add useFileUpload hook with client-side validation
- ActorAvatar renders actual avatar images with fallback to initials
- Account settings: replace URL input with clickable avatar upload
- RichTextEditor: add Image extension, paste/drop/insertFile support
- Markdown renderer: add img component for uploaded images
- CommentInput & ReplyInput: add paperclip button for file attachments
- Issue description: paste/drop file upload support

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-31 15:17:54 +08:00
Jiayuan
497fce0061 fix(tests): merge main, renumber migration, fix execenv test assertions
Merge main to pick up 028_task_trigger_comment migration. Renumber
daemon_token migration to 029. Fix execenv tests that expected CLI hints
in issue_context.md after they were moved to CLAUDE.md.
2026-03-31 15:17:09 +08:00
Jiayuan
9f56f6af81 feat(daemon): add authentication for daemon API routes
Issue daemon auth tokens (mdt_) on pairing session claim, bound to
workspace_id + daemon_id with 1-year expiry. Add DaemonAuth middleware
that validates these tokens and falls back to JWT/PAT for backward
compatibility. Apply middleware to all daemon routes except pairing
endpoints.
2026-03-31 15:17:09 +08:00
Jiayuan Zhang
56b66908a1 Merge pull request #216 from multica-ai/agent/lambda/92e0a175
feat(inbox): support archiving individual messages from list
2026-03-31 15:16:48 +08:00
Jiayuan
1054e218ed fix(daemon): update execenv tests to match current renderIssueContext output
CLI hints like "multica issue get" were moved to CLAUDE.md and are no
longer rendered into issue_context.md. Remove stale assertions.
2026-03-31 15:15:06 +08:00
Jiayuan Zhang
7d9d6793bc Merge pull request #210 from multica-ai/agent/lambda/cbe4d468
feat(issues): add collapsible toggle for comment replies
2026-03-31 15:12:26 +08:00
Jiayuan
9ba12dffd0 fix(issues): remove duplicate commenter header in collapsible comment
The parent comment's header (avatar, name, time, context menu) is now
the collapsible trigger itself. Only the body, reactions, replies, and
reply input collapse — the header is always visible. This removes the
duplicate author info that appeared when expanded.
2026-03-31 15:07:41 +08:00
Jiayuan
8395479653 feat(inbox): add archive button on individual inbox list items
Show an archive icon on hover for each inbox list item, allowing
users to archive a single message directly from the list without
needing to open the detail panel first.
2026-03-31 15:07:37 +08:00
yushen
978a5af5de fix(upload): remove unnecessary uploads/ key prefix
Single-purpose bucket with randomized hex keys doesn't benefit from
a prefix — no lifecycle policies or access controls scoped to it.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-31 15:01:33 +08:00
yushen
c27b7bab5e fix(upload): sniff content type, sanitize filename, add key prefix
- Use http.DetectContentType() instead of trusting client-declared MIME type
- Sanitize quotes in filename for Content-Disposition header injection
- Add uploads/ prefix to S3 keys for better organization

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-31 14:58:52 +08:00
yushen
edf4c00c08 fix(upload): add file type/size validation, Content-Disposition header
- Add content type allowlist (images, PDF, text, video, audio, zip)
- Enforce 10 MB upload limit via http.MaxBytesReader
- Set Content-Disposition on S3 objects for proper download filenames
- Remove unused CloudFrontSigner.Domain() method

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-31 14:55:27 +08:00
Jiayuan
06424f9ba6 fix(daemon): add CLI hint to issue_context.md
renderIssueContext() now includes a "Quick Start" section with the
`multica issue get` command so agents know how to fetch issue details.
Fixes the TestPrepareDirectoryMode and TestWriteContextFiles failures.
2026-03-31 14:53:05 +08:00
Jiang Bohan
26064e43d1 feat(web): add OK emoji to reaction quick bar 2026-03-31 14:52:30 +08:00
Naiyuan Qing
3b6f64ba8e Merge pull request #212 from multica-ai/feature/comment-context-menu
feat(issues): comment context menu & mention popup positioning fix
2026-03-31 14:46:07 +08:00
Naiyuan Qing
38e92040c4 fix(editor): migrate tiptap-markdown import to @tiptap/markdown
Update import path and remove deprecated config options (html,
transformPastedText, transformCopiedText) that don't exist in the
official @tiptap/markdown package.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-31 14:44:00 +08:00
Jiayuan
d729f9c5be feat(issues): make entire comment card collapsible with toggle
Each comment card now has a clickable header with a chevron toggle.
When collapsed, shows author, timestamp, and a content preview.
When expanded, shows the full comment body, replies, and reply input.
2026-03-31 14:41:44 +08:00
yushen
29a80e057e feat(upload): add file upload API with S3 + CloudFront signed cookies
Add POST /api/upload-file endpoint that uploads files to S3 and returns
CDN URLs protected by CloudFront signed cookies (same pattern as Linear).

Infrastructure:
- Two private S3 buckets (static.multica.ai, static-staging.multica.ai)
- Two CloudFront distributions with OAC and Trusted Key Groups
- ACM wildcard cert in us-east-1, DNS records in Route 53
- RSA signing key stored in AWS Secrets Manager

Backend:
- S3 storage service with CloudFront CDN domain support
- CloudFront signed cookie generation (RSA-SHA1)
- Private key loaded from Secrets Manager (env var fallback for local dev)
- Cookies set on login (VerifyCode) with 72h expiry matching JWT
- Upload handler: multipart form → S3 → CloudFront URL response

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-31 14:41:17 +08:00
Jiayuan
f69aa93a75 feat(agents): add Settings tab for editing agent visibility and properties
Adds a Settings tab to the agent detail panel with:
- Name and description editing
- Visibility toggle (workspace/private) matching the create dialog pattern
- Max concurrent tasks configuration
- Runtime info display (read-only)
2026-03-31 14:40:53 +08:00
Naiyuan Qing
b4bbc16521 fix(issues): use floating-ui for mention popup viewport-aware positioning
Replace hardcoded bottom positioning with @floating-ui/dom computePosition
so the @ mention popup flips above the cursor when near the viewport bottom.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-31 14:39:48 +08:00
Jiayuan
af94e22cba merge: resolve conflict with main (comment context menu icons) 2026-03-31 14:37:35 +08:00
Jiayuan
bedf4a05c8 fix(agent): fix agent visibility defaults and permission model
- Change DB default for agent visibility from 'workspace' to 'private'
- Fix canManageAgent: workspace agents are now manageable by all members,
  private agents remain restricted to owner/admin
- Add private agent visibility check to BatchAssigneePicker (was missing)
2026-03-31 14:34:04 +08:00
Jiayuan
7dafb5127d feat(issues): add collapsible toggle for comment replies
Wrap the replies section in a Collapsible component so users can
collapse/expand replies on a comment thread. The parent comment and
reply input remain always visible. A chevron trigger shows the reply
count (e.g. "3 replies") and rotates on open. Default state is expanded
to preserve existing behavior.
2026-03-31 14:33:09 +08:00
Naiyuan Qing
8fa4c8f576 Merge pull request #208 from multica-ai/feature/comment-context-menu
feat(issues): add context menu to all comments with copy support
2026-03-31 14:21:35 +08:00
Naiyuan Qing
d70ab81363 feat(issues): add context menu to all comments with copy support
Own comments show Copy/Edit/Delete; others' comments show Copy only.
Also adds icons to menu items for consistency with other dropdown menus.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-31 14:15:09 +08:00
LinYushen
961de18c97 feat(agents): reply as thread instead of top-level comment (#205)
* feat(agents): reply as thread instead of top-level comment

When an agent responds to a user comment, the reply is now nested under
the triggering comment (parent_id) instead of appearing as a separate
top-level comment. Also enables on_comment trigger by default for newly
created agents.

- Add trigger_comment_id column to agent_task_queue (migration 028)
- Pass triggering comment ID through EnqueueTaskForIssue → task → createAgentComment
- Include parent_id in WebSocket broadcast for agent comments
- Default agent creation includes both on_assign and on_comment triggers

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

* feat(cli): add --parent flag to comment add for threaded replies

The agent posts comments via the CLI, so the correct fix is giving it a
--parent flag rather than wiring trigger_comment_id through the task
infrastructure. The agent reads the comment list, decides which comment
to reply to, and passes --parent <comment-id>.

- Add --parent flag to `multica issue comment add`
- Update agent runtime instructions to explain --parent usage

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

* feat(daemon): pass trigger_comment_id to agent execution context

The agent now knows which comment triggered its task and gets an explicit
instruction to reply to it using --parent. The trigger_comment_id flows
from the DB through the claim response, daemon Task struct, and into
issue_context.md where the agent sees it.

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

* fix(comments): agent replies to thread root, matching frontend behavior

When the triggering comment is itself a reply (has parent_id), resolve
to the thread root so the agent's reply stays in the same flat thread.
This matches the frontend where all replies share the top-level parent.

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

* feat(cli): show parent_id and full IDs in comment list

The table output now includes a PARENT column and shows full comment IDs
(not truncated) so agents can see thread structure and use --parent.

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

* feat(daemon): instruct agents to always use --output json

Agents now see explicit guidance to use --output json for all read
commands, ensuring they get structured data with full IDs and parent_id
for proper threading.

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

* feat(daemon): differentiate comment-trigger vs assign-trigger context

When triggered by a comment, the agent now gets clear instructions:
- Primary goal is to read and respond to the comment
- Do NOT change issue status just because you replied
- Only change status if explicitly requested

This prevents the agent from seeing "In Review" and stopping, since
it now understands the task is to reply, not to re-evaluate the issue.

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

* fix(daemon): split workflow by trigger type in CLAUDE.md/AGENTS.md

The Workflow section in the agent's runtime config now shows a
comment-reply workflow when triggered by a comment (read comments,
find trigger, reply, don't change status) vs the full assignment
workflow (set in_progress, do work, set in_review).

Previously the agent always saw the assignment workflow, causing it
to check the issue status, see "In Review", and stop without reading
or replying to the triggering comment.

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

* refactor(daemon): remove duplicate workflow from issue_context.md

Workflow instructions now live only in CLAUDE.md/AGENTS.md (runtime_config.go).
issue_context.md keeps just the task data: issue ID, trigger type, and
triggering comment ID.

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

* fix(task): skip duplicate comment on completion for comment-triggered tasks

When triggered by a comment, the agent posts its own reply via CLI
with --parent. The task completion path was also creating a comment
from the agent's stdout output, resulting in duplicates. Now only
assignment-triggered tasks auto-post output as a comment. Error
messages from FailTask are still posted regardless of trigger type.

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-03-31 13:48:39 +08:00
Naiyuan Qing
5517136d73 Merge pull request #207 from multica-ai/feature/ws-self-event-filtering
feat(realtime): WS self-event filtering, issue-detail refactor, sync gap fixes
2026-03-31 13:19:39 +08:00
Naiyuan Qing
6761310038 fix(sync): board-card rollback, inbox status sync, markRead error handling
- board-card: capture prev issue before optimistic update, restore on error
- useRealtimeSync: wire issue:updated WS handler to update inbox issue_status
- inbox: markRead uses optimistic update, refetch on error with toast

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-31 13:09:17 +08:00
Naiyuan Qing
a2e5cbd47b refactor(issues): extract hooks from issue-detail, eliminate dual source of truth
- Remove useState<Issue> mirror anti-pattern — read directly from useIssueStore
- handleUpdateField now writes to global store (board/list sync instantly)
- handleDelete now calls removeIssue (deleted issue disappears from list)
- Extract useIssueTimeline hook (comment CRUD + WS events + reconnect)
- Extract useIssueReactions hook (issue reactions + WS events)
- Extract useIssueSubscribers hook (subscribers + WS events + rollback)
- Add useWSReconnect hook for per-component reconnect handling
- Add React.memo to BoardCardContent, DraggableBoardCard, ListRow
- Add key={id} to RichTextEditor to fix stale description on issue switch
- issue-detail.tsx: 1330 → 979 lines

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-31 13:09:17 +08:00
Naiyuan Qing
3e8c715de8 feat(realtime): skip WS refetch for self-triggered events
Backend WS messages now include actor_id from the Event struct.
Frontend useRealtimeSync skips the debounced refetch when the event
was triggered by the current user, eliminating unnecessary re-renders
of heavy components (~400ms after each user action).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-31 13:09:17 +08:00
Jiayuan Zhang
c1f81427f6 Merge pull request #206 from multica-ai/agent/lambda/6e8277a7
feat(web): add My Issues page with grouped filters
2026-03-31 12:57:02 +08:00
Jiayuan
d199b6c728 feat(web): add My Issues page with grouped filters
Add a new "My Issues" page accessible from the sidebar, positioned
between Inbox and Issues. Issues are grouped into three collapsible
sections: assigned to me, assigned to my agents, and created by me.
2026-03-31 12:53:21 +08:00
Jiayuan Zhang
d183e61653 Merge pull request #204 from multica-ai/agent/lambda/b3857b5a
fix(inbox): skip inbox notification for task:completed events
2026-03-31 12:41:21 +08:00
Jiayuan
43fceb9117 fix(inbox): skip inbox notification for task:completed events
Task completion is already visible from the issue status change,
so a separate inbox notification is unnecessary noise.
2026-03-31 12:26:26 +08:00
Naiyuan Qing
2152fec4ee Merge pull request #203 from multica-ai/forrestchang/issue-filter-assignee-creator
feat(issues): add assignee and creator filters
2026-03-31 11:06:16 +08:00
Naiyuan Qing
0cc9c213b5 feat(issues): add assignee and creator filters with two-level dropdown
Add Assignee and Creator filter categories to the issue board filter menu,
using DropdownMenu sub-menus with hover-visible checkboxes (shadcn official
data-selected pattern from PR #6862).

Key changes:
- view-store: add assigneeFilters, includeNoAssignee, creatorFilters state
  with positive selection model (empty = no filter, selected = show only matching)
- issues-header: two-level DropdownMenu — category list → sub-menu with
  searchable checkbox items, issue counts, avatar grouping (Members/Agents)
- utils/filter: extract shared filterIssues() to eliminate duplication
  between issues-page and issues-header
- Workspace switch clears actor filters via deferred subscription
  (dynamic import to avoid circular dependency)
- 10 new filter behavior tests covering assignee, creator, no-assignee,
  and combined filter scenarios

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-31 11:04:21 +08:00
Naiyuan Qing
1abd3ee918 chore(ui): switch shadcn menuColor from inverted-translucent to default
Replace semi-transparent glassmorphism menus with solid popover backgrounds.
Re-downloaded dropdown-menu, context-menu, select, combobox, menubar, and
button via `npx shadcn add --overwrite`. Also removed `focus:**:text-accent-foreground`
from DropdownMenuCheckboxItem to prevent color cascade into nested components.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-31 11:01:54 +08:00
Jiayuan Zhang
b9fdaf62ac Merge pull request #202 from multica-ai/forrestchang/issue-status-style
feat(ui): restyle issue status and priority with colored badges
2026-03-31 03:28:25 +08:00
Jiayuan
8a61c94b98 feat(ui): restyle issue status and priority with colored badges
- Status labels use colored pill badges (solid bg for active, muted for inactive)
- Board columns have tinted backgrounds matching their status color
- Priority badges use orange (--priority) design token for clear distinction from status
- Issue cards restructured: identifier, title, then assignee/priority/date row
- Agent avatar default color changed from blue to gray
- New Issue button in header changed to solid/primary style
- Reduced hover shadow on board cards
- Added inheritColor prop to StatusIcon and PriorityIcon for badge use
2026-03-31 03:26:43 +08:00
Jiayuan Zhang
32e19f847f Merge pull request #201 from multica-ai/forrestchang/agent-live-output
feat(agent): stream live agent output and execution history
2026-03-30 23:51:07 +08:00
Jiayuan
a00485cf13 feat(security): redact sensitive information in agent live output
Server-side (primary): Apply redact.Text/InputMap on task message
content, output, and input fields before DB persistence and WebSocket
broadcast. Extended redact package with GitLab tokens, JWTs, connection
strings, and PASSWORD/SECRET/TOKEN env var patterns.

Frontend (fallback): redactSecrets utility mirrors server patterns,
applied in buildTimeline and ToolCallRow render as a safety net.
2026-03-30 23:38:49 +08:00
Jiayuan
e20e1b74dc merge: resolve conflicts with main (reactions feature)
Main added reaction routes and event types while this branch added
task message routes and event types. Both sides kept — no code lost.
2026-03-30 23:29:42 +08:00
Jiayuan
83f325c586 fix(ui): apply mono font directly to code/pre elements
Browser UA stylesheet sets font-family: monospace on <code> and <pre>,
overriding the inherited Geist Mono from parent containers. Apply
font-mono explicitly on these elements so they use the project's
monospace font instead of the browser default.
2026-03-30 23:27:09 +08:00
Jiayuan
1e2052c689 feat(agent): improve live output UI and add execution history
- Fix duplicate icons in tool call rows (use chevron only for expand/collapse)
- Show detailed tool information (WebSearch queries, Agent prompts, Skill names)
- Add thinking/reasoning rows with Brain icon and expandable content
- Show tool results as separate chronological entries with previews
- Add TaskRunHistory component for viewing past agent execution logs
- Add listTasksByIssue API endpoint and task-runs route
- Support thinking content blocks in agent SDK (MessageThinking type)
- Improve callID→toolName mapping in daemon message forwarding
2026-03-30 23:10:54 +08:00
Jiayuan
3c93ebaf1c feat(agent): stream live agent output to issue detail page
When an agent is working on an issue, users can now see real-time output
in the issue detail page instead of waiting for completion.

Backend:
- Add task_message table and migration for persisting agent messages
- Add POST /api/daemon/tasks/{id}/messages endpoint for daemon to report
  structured messages (tool_use, tool_result, text, error) in batches
- Add GET /api/daemon/tasks/{id}/messages for catch-up after reconnect
- Add GET /api/issues/{id}/active-task to check for running tasks
- Broadcast task:message events via WebSocket
- Daemon forwards agent session messages with 500ms text throttling

Frontend:
- Add AgentLiveCard component showing live tool calls, text output,
  and progress indicators with auto-scroll
- Wire into issue detail timeline with WS subscription and HTTP catch-up
- Card appears when agent is working, disappears on completion/failure
2026-03-30 22:53:28 +08:00
Jiayuan Zhang
a526b96b6c Merge pull request #200 from multica-ai/forrestchang/comment-reactions
feat(reactions): emoji reactions for comments and issues
2026-03-30 22:40:54 +08:00
Jiayuan
7c1aabbe3a feat(reactions): add emoji reactions for comments and issue descriptions
Add Slack-style emoji reactions to comments and issue descriptions with
full-stack support: database tables, REST API endpoints, real-time
WebSocket sync, optimistic UI updates, and inbox notifications.

- New `comment_reaction` and `issue_reaction` tables with migrations
- POST/DELETE endpoints for adding/removing reactions on both comments
  and issue descriptions
- Real-time WS events (reaction:added/removed, issue_reaction:added/removed)
- Shared ReactionBar component with quick emoji picker and full emoji-mart
  picker (lazy-loaded)
- Optimistic add/remove with rollback on failure
- Inbox notifications for comment author and issue creator when reacted to
- Reactions included in timeline, comment list, and issue detail responses
2026-03-30 22:37:59 +08:00
Jiayuan Zhang
e07b121fa8 Merge pull request #198 from multica-ai/forrestchang/agent-sandbox-security
feat(security): agent output redaction and private agent visibility
2026-03-30 22:30:27 +08:00
Jiayuan
ad697e0029 feat(security): restrict agent management to owner for private agents
Replace blanket owner/admin role check in UpdateAgent and DeleteAgent
with canManageAgent, which requires the requesting user to be the
agent's owner (or a workspace owner/admin) for private agents.
2026-03-30 22:27:19 +08:00
Jiayuan
0491350f1b feat(security): add agent output redaction and private agent assignment enforcement
- Add redact package to detect and mask secrets (AWS keys, private keys,
  API tokens, bearer tokens, credentials, home paths) in agent output
  before posting as comments in TaskService
- Enforce agent visibility on issue assignment: private agents can only
  be assigned by their owner or workspace admins
- Add visibility picker (workspace/private) to CreateAgentDialog,
  default to private
- Grey out unassignable private agents in the assignee picker with
  lock icon indicator
2026-03-30 22:22:04 +08:00
Jiayuan Zhang
72e3ccfe33 Merge pull request #197 from multica-ai/forrestchang/daemon-profile
feat(daemon): add --profile flag for multi-environment isolation
2026-03-30 20:24:03 +08:00
Jiayuan
8fa1b163a6 feat(daemon): add --profile flag for multi-environment isolation
Allow running multiple daemon instances against different servers (e.g.
production and local dev) simultaneously. Each profile gets isolated
config, PID file, log file, health port, and workspaces root.

Usage:
  multica login --profile dev --server-url http://localhost:8080
  multica daemon start --profile dev

Default profile (no --profile flag) behavior is unchanged.

Closes MUL-42
2026-03-30 20:21:23 +08:00
Jiayuan Zhang
564eb55f90 Merge pull request #196 from multica-ai/forrestchang/task-workspace-dir
feat(daemon): group task directories by workspace ID
2026-03-30 20:15:34 +08:00
Jiayuan
7d126cc549 feat(daemon): group task directories by workspace ID
Task execution environments were all created flat under WorkspacesRoot,
mixing tasks from different workspaces. Now tasks are nested under their
workspace ID for clearer organization and easier per-workspace cleanup.
2026-03-30 20:13:30 +08:00
Jiayuan Zhang
9c3ff52363 Merge pull request #194 from multica-ai/fix/daemon-auto-discover-new-workspaces
fix(daemon): auto-discover new workspaces without restart
2026-03-30 19:34:04 +08:00
Jiayuan
219b174819 fix(daemon): sync workspaces immediately on startup
Run syncWorkspacesFromAPI once before entering the periodic ticker
loop so newly created workspaces are discovered without the initial
30-second delay.
2026-03-30 19:32:24 +08:00
Jiayuan Zhang
e27afce90a Merge pull request #195 from multica-ai/mul-39/remove-default-shortcuts
Remove default keyboard shortcuts
2026-03-30 18:48:51 +08:00
Jiayuan
b2e800b605 refactor(web): remove default keyboard shortcuts
Remove the sidebar toggle shortcut (Ctrl/Cmd+B) and theme toggle
hotkey (D key) global keyboard listeners.
2026-03-30 18:47:14 +08:00
Jiayuan
25ed043117 fix(daemon): auto-discover new workspaces without restart
The daemon now periodically fetches the user's workspace list from the
API (every 30s) and adds any new workspaces to the watched config. The
existing config-watch loop then picks up the change and registers
runtimes. This fixes the issue where workspaces created after
`multica login` were not discovered until the daemon was restarted.
2026-03-30 18:08:58 +08:00
LinYushen
457a3eb555 feat(agents): add on_comment trigger option to agent triggers UI (#193)
The backend already supports on_comment triggers but the frontend was
missing the UI to configure them. Adds the "On Comment" trigger type
and "Add On Comment" button alongside the existing On Assign and
Scheduled options.

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-30 17:59:55 +08:00
Jiayuan Zhang
3baf141265 Merge pull request #192 from multica-ai/forrestchang/runtime-default-name
feat(daemon): include device name in runtime display name
2026-03-30 17:56:43 +08:00
Jiayuan
7aa784ccd3 feat(daemon): include device name in runtime display name
Change runtime naming from "Local Claude" to "Claude (hostname)" so
team members can distinguish runtimes in shared workspaces.
2026-03-30 17:54:29 +08:00
Naiyuan Qing
11954dd7a7 Merge pull request #191 from multica-ai/fix/tiptap-link-navigation
fix(editor): enable link navigation and fix copy artifacts
2026-03-30 17:51:27 +08:00
Naiyuan Qing
81b518d0d1 fix(editor): enable link navigation and fix <url> copy artifacts
- Set openOnClick: true so clicking a link opens it in a new tab
- Add Cmd+Click / Ctrl+Click handler as fallback (skips mention:// links)
- Override prosemirror-markdown link serializer to always use [text](url)
  format instead of <url> autolink syntax, fixing angle brackets appearing
  when copying links from the editor

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-30 17:45:20 +08:00
Naiyuan Qing
0a38643ef4 Merge pull request #190 from multica-ai/fix/inbox-selection-after-refresh
fix(inbox): fix inbox items unclickable after page refresh
2026-03-30 17:27:23 +08:00
Naiyuan Qing
b4e8dc3769 fix(inbox): use history.replaceState for selection to fix post-refresh clicks
router.replace() triggers a full server navigation cycle in Next.js 15+,
which can stall after a page refresh (no client route cache), preventing
useSearchParams from updating and making inbox items unclickable.

window.history.replaceState() updates the URL synchronously without
triggering server navigation, which is the recommended approach for
URL state management in Next.js 14.1+.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-30 17:25:17 +08:00
Naiyuan Qing
1a98cac165 Merge pull request #189 from multica-ai/NevilleQingNY/workspace-isolation-and-agent-parity
feat(api): workspace isolation + agent parity + timeline UI fixes
2026-03-30 17:11:10 +08:00
Naiyuan Qing
0c4cef0ff1 fix(issues): improve timeline activity entry alignment and overflow
- Add items-center for vertical centering between icons and text
- Add truncate on activity text to prevent line wrapping
- Unify icon/avatar sizes to 16px for visual consistency
- Remove connector line (will revisit later)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-30 17:09:21 +08:00
285 changed files with 22487 additions and 3853 deletions

View File

@@ -30,8 +30,21 @@ GOOGLE_CLIENT_ID=
GOOGLE_CLIENT_SECRET=
GOOGLE_REDIRECT_URI=http://localhost:3000/auth/callback
# S3 / CloudFront
S3_BUCKET=
S3_REGION=us-west-2
CLOUDFRONT_KEY_PAIR_ID=
CLOUDFRONT_PRIVATE_KEY_SECRET=multica/cloudfront-signing-key
CLOUDFRONT_PRIVATE_KEY=
CLOUDFRONT_DOMAIN=
COOKIE_DOMAIN=
# Frontend
FRONTEND_PORT=3000
FRONTEND_ORIGIN=http://localhost:3000
NEXT_PUBLIC_API_URL=http://localhost:8080
NEXT_PUBLIC_WS_URL=ws://localhost:8080/ws
# Remote API (optional) — set to proxy local frontend to a remote backend
# Leave empty to use local backend (localhost:8080)
# REMOTE_API_URL=https://multica-api.copilothub.ai

1
.eslintcache Normal file

File diff suppressed because one or more lines are too long

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:
@@ -314,18 +314,8 @@ Run the local daemon:
make daemon
```
Normal flow:
1. start the daemon
2. open the pairing link it prints
3. choose the workspace in the browser
4. let the daemon register its local runtime
Debug shortcut:
- you can set `MULTICA_WORKSPACE_ID` in your env file
- this skips normal pairing
- treat it as a local shortcut, not the default workflow
The daemon authenticates using the CLI's stored token (`multica login`).
It registers runtimes for all watched workspaces from the CLI config.
## Troubleshooting

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.

305
README.md
View File

@@ -1,211 +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)**
</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 dependencies
pnpm install
# 2. Copy environment variables for the shared main environment
git clone https://github.com/multica-ai/multica.git
cd multica
cp .env.example .env
# Edit .env — at minimum, change JWT_SECRET
# 3. One-time setup: ensure shared PostgreSQL, create the app DB, run migrations
make setup
# 4. Start backend + frontend
make start
docker compose up -d # Start PostgreSQL
cd server && go run ./cmd/migrate up && cd .. # Run migrations
make start # Start the app
```
Open your configured `FRONTEND_ORIGIN` in the browser. By default that is [http://localhost:3000](http://localhost:3000).
See the [Self-Hosting Guide](SELF_HOSTING.md) for full instructions.
Main checkout uses `.env`. A Git worktree should generate its own `.env.worktree` and use the explicit worktree targets:
```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
## 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
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
# Authenticate and start
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.
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.
### Other Commands
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 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
multica login # Authenticate with your Multica account
multica daemon start # Start the local agent runtime
```
## Environment Variables
The daemon runs in the background and keeps your machine connected to Multica. It auto-detects agent CLIs (`claude`, `codex`) available on your PATH.
See [`.env.example`](.env.example) for all available variables:
### 2. Verify your runtime
- `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
Open your workspace in the Multica web app. Navigate to **Settings → Runtimes** — you should see your machine listed as an active **Runtime**.
## Local Development Notes
> **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.
- `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.
### 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
pnpm install
cp .env.example .env
make setup
make start
```
See [CONTRIBUTING.md](CONTRIBUTING.md) for the full development workflow, worktree support, testing, and troubleshooting.
## License
[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

@@ -14,6 +14,7 @@ import {
Check,
BookOpenText,
SquarePen,
CircleUser,
} from "lucide-react";
import { WorkspaceAvatar } from "@/features/workspace";
import { useIssueDraftStore } from "@/features/issues/stores/draft-store";
@@ -46,6 +47,7 @@ import { useModalStore } from "@/features/modals";
const primaryNav = [
{ href: "/inbox", label: "Inbox", icon: Inbox },
{ href: "/my-issues", label: "My Issues", icon: CircleUser },
{ href: "/issues", label: "Issues", icon: ListTodo },
];
@@ -74,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,
@@ -11,6 +11,7 @@ import {
Wrench,
FileText,
BookOpenText,
MessageSquare,
Timer,
Trash2,
Save,
@@ -24,12 +25,19 @@ import {
MoreHorizontal,
Play,
ChevronDown,
Globe,
Lock,
Settings,
Camera,
Archive,
} from "lucide-react";
import type {
Agent,
AgentStatus,
AgentVisibility,
AgentTool,
AgentTrigger,
AgentTriggerType,
AgentTask,
RuntimeDevice,
CreateAgentRequest,
@@ -63,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";
// ---------------------------------------------------------------------------
@@ -91,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)}`;
@@ -124,6 +127,7 @@ function CreateAgentDialog({
const [name, setName] = useState("");
const [description, setDescription] = useState("");
const [selectedRuntimeId, setSelectedRuntimeId] = useState(runtimes[0]?.id ?? "");
const [visibility, setVisibility] = useState<AgentVisibility>("private");
const [creating, setCreating] = useState(false);
const [runtimeOpen, setRuntimeOpen] = useState(false);
@@ -143,7 +147,11 @@ function CreateAgentDialog({
name: name.trim(),
description: description.trim(),
runtime_id: selectedRuntime.id,
triggers: [{ id: generateId(), type: "on_assign", enabled: true, config: {} }],
visibility,
triggers: [
{ id: generateId(), type: "on_assign", enabled: true, config: {} },
{ id: generateId(), type: "on_comment", enabled: true, config: {} },
],
});
onClose();
} catch (err) {
@@ -187,6 +195,42 @@ function CreateAgentDialog({
/>
</div>
<div>
<Label className="text-xs text-muted-foreground">Visibility</Label>
<div className="mt-1.5 flex gap-2">
<button
type="button"
onClick={() => setVisibility("workspace")}
className={`flex flex-1 items-center gap-2 rounded-lg border px-3 py-2.5 text-sm transition-colors ${
visibility === "workspace"
? "border-primary bg-primary/5"
: "border-border hover:bg-muted"
}`}
>
<Globe className="h-4 w-4 shrink-0 text-muted-foreground" />
<div className="text-left">
<div className="font-medium">Workspace</div>
<div className="text-xs text-muted-foreground">All members can assign</div>
</div>
</button>
<button
type="button"
onClick={() => setVisibility("private")}
className={`flex flex-1 items-center gap-2 rounded-lg border px-3 py-2.5 text-sm transition-colors ${
visibility === "private"
? "border-primary bg-primary/5"
: "border-border hover:bg-muted"
}`}
>
<Lock className="h-4 w-4 shrink-0 text-muted-foreground" />
<div className="text-left">
<div className="font-medium">Private</div>
<div className="text-xs text-muted-foreground">Only you can assign</div>
</div>
</button>
</div>
</div>
<div>
<Label className="text-xs text-muted-foreground">Runtime</Label>
<Popover open={runtimeOpen} onOpenChange={setRuntimeOpen}>
@@ -286,6 +330,7 @@ function AgentListItem({
onClick: () => void;
}) {
const st = statusConfig[agent.status];
const isArchived = !!agent.archived_at;
return (
<button
@@ -294,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" />
) : (
@@ -308,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>
@@ -340,6 +389,8 @@ function InstructionsTab({
setSaving(true);
try {
await onSave(value);
} catch {
// toast handled by parent
} finally {
setSaving(false);
}
@@ -406,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);
@@ -418,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);
}
@@ -661,6 +716,8 @@ function ToolsTab({
setSaving(true);
try {
await onSave(tools);
} catch {
// toast handled by parent
} finally {
setSaving(false);
}
@@ -805,6 +862,8 @@ function TriggersTab({
setSaving(true);
try {
await onSave(triggers);
} catch {
// toast handled by parent
} finally {
setSaving(false);
}
@@ -820,7 +879,7 @@ function TriggersTab({
setTriggers((prev) => prev.filter((t) => t.id !== triggerId));
};
const addTrigger = (type: "on_assign" | "scheduled") => {
const addTrigger = (type: AgentTriggerType) => {
const newTrigger: AgentTrigger = {
id: generateId(),
type,
@@ -869,18 +928,26 @@ function TriggersTab({
<div className="flex h-9 w-9 shrink-0 items-center justify-center rounded-lg bg-muted">
{trigger.type === "on_assign" ? (
<Bot className="h-4 w-4 text-muted-foreground" />
) : trigger.type === "on_comment" ? (
<MessageSquare className="h-4 w-4 text-muted-foreground" />
) : (
<Timer className="h-4 w-4 text-muted-foreground" />
)}
</div>
<div className="min-w-0 flex-1">
<div className="text-sm font-medium">
{trigger.type === "on_assign" ? "On Issue Assign" : "Scheduled"}
{trigger.type === "on_assign"
? "On Issue Assign"
: trigger.type === "on_comment"
? "On Comment"
: "Scheduled"}
</div>
<div className="text-xs text-muted-foreground">
{trigger.type === "on_assign"
? "Runs when an issue is assigned to this agent"
: `Cron: ${(trigger.config as { cron?: string }).cron ?? "Not set"}`}
: trigger.type === "on_comment"
? "Runs when a member comments on the agent's issue"
: `Cron: ${(trigger.config as { cron?: string }).cron ?? "Not set"}`}
</div>
</div>
<div className="flex items-center gap-2">
@@ -959,6 +1026,15 @@ function TriggersTab({
<Bot className="h-3 w-3" />
Add On Assign
</Button>
<Button
variant="outline"
size="xs"
onClick={() => addTrigger("on_comment")}
className="border-dashed text-muted-foreground hover:text-foreground"
>
<MessageSquare className="h-3 w-3" />
Add On Comment
</Button>
<Button
variant="outline"
size="xs"
@@ -993,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>
);
}
@@ -1091,11 +1176,190 @@ function TasksTab({ agent }: { agent: Agent }) {
);
}
// ---------------------------------------------------------------------------
// Settings Tab
// ---------------------------------------------------------------------------
function SettingsTab({
agent,
runtimes,
onSave,
}: {
agent: Agent;
runtimes: RuntimeDevice[];
onSave: (updates: Partial<Agent>) => Promise<void>;
}) {
const [name, setName] = useState(agent.name);
const [description, setDescription] = useState(agent.description ?? "");
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 ||
description !== (agent.description ?? "") ||
visibility !== agent.visibility ||
maxTasks !== agent.max_concurrent_tasks;
const handleSave = async () => {
if (!name.trim()) {
toast.error("Name is required");
return;
}
setSaving(true);
try {
await onSave({ name: name.trim(), description, visibility, max_concurrent_tasks: maxTasks });
toast.success("Settings saved");
} catch {
toast.error("Failed to save settings");
} finally {
setSaving(false);
}
};
const runtimeDevice = runtimes.find((r) => r.id === agent.runtime_id);
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
value={name}
onChange={(e) => setName(e.target.value)}
className="mt-1"
/>
</div>
<div>
<Label className="text-xs text-muted-foreground">Description</Label>
<Input
value={description}
onChange={(e) => setDescription(e.target.value)}
placeholder="What does this agent do?"
className="mt-1"
/>
</div>
<div>
<Label className="text-xs text-muted-foreground">Visibility</Label>
<div className="mt-1.5 flex gap-2">
<button
type="button"
onClick={() => setVisibility("workspace")}
className={`flex flex-1 items-center gap-2 rounded-lg border px-3 py-2.5 text-sm transition-colors ${
visibility === "workspace"
? "border-primary bg-primary/5"
: "border-border hover:bg-muted"
}`}
>
<Globe className="h-4 w-4 shrink-0 text-muted-foreground" />
<div className="text-left">
<div className="font-medium">Workspace</div>
<div className="text-xs text-muted-foreground">All members can assign</div>
</div>
</button>
<button
type="button"
onClick={() => setVisibility("private")}
className={`flex flex-1 items-center gap-2 rounded-lg border px-3 py-2.5 text-sm transition-colors ${
visibility === "private"
? "border-primary bg-primary/5"
: "border-border hover:bg-muted"
}`}
>
<Lock className="h-4 w-4 shrink-0 text-muted-foreground" />
<div className="text-left">
<div className="font-medium">Private</div>
<div className="text-xs text-muted-foreground">Only you can assign</div>
</div>
</button>
</div>
</div>
<div>
<Label className="text-xs text-muted-foreground">Max Concurrent Tasks</Label>
<Input
type="number"
min={1}
max={50}
value={maxTasks}
onChange={(e) => setMaxTasks(Number(e.target.value))}
className="mt-1 w-24"
/>
</div>
<div>
<Label className="text-xs text-muted-foreground">Runtime</Label>
<div className="mt-1 flex items-center gap-2 rounded-lg border px-3 py-2.5 text-sm text-muted-foreground">
{agent.runtime_mode === "cloud" ? (
<Cloud className="h-4 w-4" />
) : (
<Monitor className="h-4 w-4" />
)}
{runtimeDevice?.name ?? (agent.runtime_mode === "cloud" ? "Cloud" : "Local")}
</div>
</div>
<Button onClick={handleSave} disabled={!dirty || saving} size="sm">
{saving ? <Loader2 className="h-3.5 w-3.5 mr-1.5 animate-spin" /> : <Save className="h-3.5 w-3.5 mr-1.5" />}
Save Changes
</Button>
</div>
);
}
// ---------------------------------------------------------------------------
// Agent Detail
// ---------------------------------------------------------------------------
type DetailTab = "instructions" | "skills" | "tools" | "triggers" | "tasks";
type DetailTab = "instructions" | "skills" | "tools" | "triggers" | "tasks" | "settings";
const detailTabs: { id: DetailTab; label: string; icon: typeof FileText }[] = [
{ id: "instructions", label: "Instructions", icon: FileText },
@@ -1103,38 +1367,57 @@ const detailTabs: { id: DetailTab; label: string; icon: typeof FileText }[] = [
{ id: "tools", label: "Tools", icon: Wrench },
{ id: "triggers", label: "Triggers", icon: Timer },
{ id: "tasks", label: "Tasks", icon: ListTodo },
{ id: "settings", label: "Settings", icon: Settings },
];
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" />
@@ -1145,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" className="w-auto">
<DropdownMenuItem
className="text-destructive"
onClick={() => setConfirmArchive(true)}
>
<Trash2 className="h-3.5 w-3.5" />
Archive Agent
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
)}
</div>
{/* Tabs */}
@@ -1207,35 +1492,42 @@ function AgentDetail({
/>
)}
{activeTab === "tasks" && <TasksTab agent={agent} />}
{activeTab === "settings" && (
<SettingsTab
agent={agent}
runtimes={runtimes}
onSave={(updates) => onUpdate(agent.id, updates)}
/>
)}
</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>
@@ -1255,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);
@@ -1266,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);
@@ -1280,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>
);
}
@@ -1315,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}
@@ -1357,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,6 +1,7 @@
"use client";
import { useSearchParams, useRouter } from "next/navigation";
import { useState, useEffect, useCallback } from "react";
import { useSearchParams } from "next/navigation";
import { useDefaultLayout } from "react-resizable-panels";
import { useInboxStore } from "@/features/inbox";
import { IssueDetail, StatusIcon, PriorityIcon } from "@/features/issues/components";
@@ -52,6 +53,7 @@ const typeLabels: Record<InboxItemType, string> = {
task_failed: "Task failed",
agent_blocked: "Agent blocked",
agent_completed: "Agent completed",
reaction_added: "Reacted",
};
function timeAgo(dateStr: string): string {
@@ -126,6 +128,11 @@ function InboxDetailLabel({ item }: { item: InboxItem }) {
if (item.body) return <span>{item.body}</span>;
return <span>{typeLabels[item.type]}</span>;
}
case "reaction_added": {
const emoji = details.emoji;
if (emoji) return <span>Reacted {emoji} to your comment</span>;
return <span>{typeLabels[item.type]}</span>;
}
default:
return <span>{typeLabels[item.type] ?? item.type}</span>;
}
@@ -139,15 +146,17 @@ function InboxListItem({
item,
isSelected,
onClick,
onArchive,
}: {
item: InboxItem;
isSelected: boolean;
onClick: () => void;
onArchive: () => void;
}) {
return (
<button
onClick={onClick}
className={`flex w-full items-center gap-3 px-4 py-2.5 text-left transition-colors ${
className={`group flex w-full items-center gap-3 px-4 py-2.5 text-left transition-colors ${
isSelected ? "bg-accent" : "hover:bg-accent/50"
}`}
>
@@ -168,9 +177,29 @@ function InboxListItem({
{item.title}
</span>
</div>
{item.issue_status && (
<StatusIcon status={item.issue_status} className="h-3.5 w-3.5 shrink-0" />
)}
<div className="flex shrink-0 items-center gap-1">
<span
role="button"
tabIndex={-1}
title="Archive"
onClick={(e) => {
e.stopPropagation();
onArchive();
}}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
e.stopPropagation();
onArchive();
}
}}
className="hidden rounded p-0.5 text-muted-foreground hover:bg-accent hover:text-foreground group-hover:inline-flex"
>
<Archive className="h-3.5 w-3.5" />
</span>
{item.issue_status && (
<StatusIcon status={item.issue_status} className="h-3.5 w-3.5 shrink-0" />
)}
</div>
</div>
<div className="mt-0.5 flex items-center justify-between gap-2">
<p className={`min-w-0 overflow-hidden text-ellipsis whitespace-nowrap text-xs ${item.read ? "text-muted-foreground/60" : "text-muted-foreground"}`}>
@@ -191,15 +220,20 @@ function InboxListItem({
export default function InboxPage() {
const searchParams = useSearchParams();
const router = useRouter();
const selectedId = searchParams.get("id") ?? "";
const setSelectedId = (id: string) => {
if (id) {
router.replace(`/inbox?id=${id}`, { scroll: false });
} else {
router.replace("/inbox", { scroll: false });
}
};
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);
@@ -208,18 +242,20 @@ export default function InboxPage() {
id: "multica_inbox_layout",
});
const selected = items.find((i) => i.id === selectedId) ?? null;
const selected = items.find((i) => (i.issue_id ?? i.id) === selectedKey) ?? null;
const unreadCount = items.filter((i) => !i.read).length;
// Click-to-read: select + auto-mark-read
const handleSelect = async (item: InboxItem) => {
setSelectedId(item.id);
setSelectedKey(item.issue_id ?? item.id);
if (!item.read) {
useInboxStore.getState().markRead(item.id);
try {
await api.markInboxRead(item.id);
useInboxStore.getState().markRead(item.id);
} catch {
// silent — selection still works even if mark-read fails
// Rollback: refetch to get server truth
useInboxStore.getState().fetch();
toast.error("Failed to mark as read");
}
}
};
@@ -228,7 +264,8 @@ export default function InboxPage() {
try {
await api.archiveInbox(id);
useInboxStore.getState().archive(id);
if (selectedId === id) setSelectedId("");
const archived = items.find((i) => i.id === id);
if (archived && (archived.issue_id ?? archived.id) === selectedKey) setSelectedKey("");
} catch {
toast.error("Failed to archive");
}
@@ -248,7 +285,7 @@ export default function InboxPage() {
const handleArchiveAll = async () => {
try {
useInboxStore.getState().archiveAll();
setSelectedId("");
setSelectedKey("");
await api.archiveAllInbox();
} catch {
toast.error("Failed to archive all");
@@ -258,9 +295,9 @@ export default function InboxPage() {
const handleArchiveAllRead = async () => {
try {
const readIds = items.filter((i) => i.read).map((i) => i.id);
const readKeys = items.filter((i) => i.read).map((i) => i.issue_id ?? i.id);
useInboxStore.getState().archiveAllRead();
if (readIds.includes(selectedId)) setSelectedId("");
if (readKeys.includes(selectedKey)) setSelectedKey("");
await api.archiveAllReadInbox();
} catch {
toast.error("Failed to archive read items");
@@ -271,7 +308,7 @@ export default function InboxPage() {
const handleArchiveCompleted = async () => {
try {
await api.archiveCompletedInbox();
setSelectedId("");
setSelectedKey("");
await useInboxStore.getState().fetch();
} catch {
toast.error("Failed to archive completed");
@@ -282,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" />
@@ -314,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 && (
@@ -358,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" />
@@ -369,12 +407,14 @@ export default function InboxPage() {
<InboxListItem
key={item.id}
item={item}
isSelected={item.id === selectedId}
isSelected={(item.issue_id ?? item.id) === selectedKey}
onClick={() => handleSelect(item)}
onArchive={() => handleArchive(item.id)}
/>
))}
</div>
)}
</div>
</div>
</ResizablePanel>
<ResizableHandle />
@@ -383,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

@@ -58,6 +58,7 @@ vi.mock("@/features/workspace", () => ({
if (type === "agent") return "CA";
return "??";
},
getActorAvatarUrl: () => null,
}),
}));
@@ -86,13 +87,16 @@ const stableStoreIssues = vi.hoisted(() => [
},
]);
vi.mock("@/features/issues", () => ({
useIssueStore: (selector: (s: any) => any) =>
selector({ issues: stableStoreIssues }),
useIssueStore: Object.assign(
(selector: (s: any) => any) => selector({ issues: stableStoreIssues }),
{ getState: () => ({ issues: stableStoreIssues, addIssue: vi.fn(), updateIssue: vi.fn(), removeIssue: vi.fn() }) },
),
}));
// Mock ws-context
vi.mock("@/features/realtime", () => ({
useWSEvent: () => {},
useWSReconnect: () => {},
}));
// Mock calendar (react-day-picker needs browser APIs)
@@ -100,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, () => ({
@@ -128,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
@@ -157,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([]),
},
}));
@@ -270,8 +298,9 @@ describe("IssueDetailPage", () => {
});
it("shows 'Issue not found' for missing issue", async () => {
mockGetIssue.mockRejectedValueOnce(new Error("Not found"));
mockListTimeline.mockRejectedValueOnce(new Error("Not found"));
// issue-detail fetches getIssue, useIssueReactions also fetches getIssue
mockGetIssue.mockRejectedValue(new Error("Not found"));
mockListTimeline.mockRejectedValue(new Error("Not found"));
await renderPage("nonexistent-id");
await waitFor(() => {
@@ -291,6 +320,8 @@ describe("IssueDetailPage", () => {
author_type: "member",
author_id: "user-1",
parent_id: null,
reactions: [],
attachments: [],
created_at: "2026-01-18T00:00:00Z",
updated_at: "2026-01-18T00:00:00Z",
};
@@ -326,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

@@ -34,6 +34,7 @@ vi.mock("@/features/workspace", () => ({
getActorName: (type: string, id: string) =>
type === "member" ? "Test User" : "Claude Agent",
getActorInitials: () => "TU",
getActorAvatarUrl: () => null,
}),
useWorkspaceStore: Object.assign(
(selector?: any) => {
@@ -48,7 +49,8 @@ vi.mock("@/features/workspace", () => ({
// Mock WebSocket context
vi.mock("@/features/realtime", () => ({
useWSEvent: vi.fn(),
useWS: () => ({ subscribe: vi.fn(() => () => {}) }),
useWSReconnect: vi.fn(),
useWS: () => ({ subscribe: vi.fn(() => () => {}), onReconnect: vi.fn(() => () => {}) }),
WSProvider: ({ children }: { children: React.ReactNode }) => children,
}));
@@ -105,6 +107,9 @@ const mockViewState = {
viewMode: "board" as const,
statusFilters: [] as string[],
priorityFilters: [] as string[],
assigneeFilters: [] as { type: string; id: string }[],
includeNoAssignee: false,
creatorFilters: [] as { type: string; id: string }[],
sortBy: "position" as const,
sortDirection: "asc" as const,
cardProperties: { priority: true, description: true, assignee: true, dueDate: true },
@@ -112,6 +117,9 @@ const mockViewState = {
setViewMode: vi.fn(),
toggleStatusFilter: vi.fn(),
togglePriorityFilter: vi.fn(),
toggleAssigneeFilter: vi.fn(),
toggleNoAssignee: vi.fn(),
toggleCreatorFilter: vi.fn(),
hideStatus: vi.fn(),
showStatus: vi.fn(),
clearFilters: vi.fn(),
@@ -122,6 +130,7 @@ const mockViewState = {
};
vi.mock("@/features/issues/stores/view-store", () => ({
initFilterWorkspaceSync: vi.fn(),
useIssueViewStore: Object.assign(
(selector?: any) => (selector ? selector(mockViewState) : mockViewState),
{ getState: () => mockViewState, setState: vi.fn() },
@@ -141,16 +150,24 @@ 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" },
todo: { label: "Todo", iconColor: "text-muted-foreground", hoverBg: "hover:bg-accent" },
in_progress: { label: "In Progress", iconColor: "text-warning", hoverBg: "hover:bg-warning/10" },
in_review: { label: "In Review", iconColor: "text-success", hoverBg: "hover:bg-success/10" },
done: { label: "Done", iconColor: "text-info", hoverBg: "hover:bg-info/10" },
done: { label: "Done", iconColor: "text-done", hoverBg: "hover:bg-done/10" },
blocked: { label: "Blocked", iconColor: "text-destructive", hoverBg: "hover:bg-destructive/10" },
cancelled: { label: "Cancelled", iconColor: "text-muted-foreground", hoverBg: "hover:bg-accent" },
},
@@ -322,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]);
@@ -38,12 +38,20 @@ export default function DashboardLayout({
);
}
if (!user || !workspace) return null;
if (!user) return null;
return (
<SidebarProvider className="h-svh">
<AppSidebar />
<SidebarInset className="overflow-hidden">{children}</SidebarInset>
<SidebarInset className="overflow-hidden">
{workspace ? (
children
) : (
<div className="flex flex-1 items-center justify-center">
<MulticaIcon className="size-6 animate-pulse" />
</div>
)}
</SidebarInset>
</SidebarProvider>
);
}

View File

@@ -0,0 +1,7 @@
"use client";
import { MyIssuesPage } from "@/features/my-issues";
export default function Page() {
return <MyIssuesPage />;
}

View File

@@ -1,7 +1,7 @@
"use client";
import { useEffect, useState } from "react";
import { Save } from "lucide-react";
import { useEffect, useRef, useState } from "react";
import { Camera, Loader2, Save } from "lucide-react";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Button } from "@/components/ui/button";
@@ -9,27 +9,48 @@ import { Card, CardContent } from "@/components/ui/card";
import { toast } from "sonner";
import { useAuthStore } from "@/features/auth";
import { api } from "@/shared/api";
import { useFileUpload } from "@/shared/hooks/use-file-upload";
export function AccountTab() {
const user = useAuthStore((s) => s.user);
const setUser = useAuthStore((s) => s.setUser);
const [profileName, setProfileName] = useState(user?.name ?? "");
const [avatarUrl, setAvatarUrl] = useState(user?.avatar_url ?? "");
const [profileSaving, setProfileSaving] = useState(false);
const { upload, uploading } = useFileUpload();
const fileInputRef = useRef<HTMLInputElement>(null);
useEffect(() => {
setProfileName(user?.name ?? "");
setAvatarUrl(user?.avatar_url ?? "");
}, [user]);
const initials = (user?.name ?? "")
.split(" ")
.map((w) => w[0])
.join("")
.toUpperCase()
.slice(0, 2);
const handleAvatarUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (!file) return;
// Reset input so the same file can be re-selected
e.target.value = "";
try {
const result = await upload(file);
if (!result) return;
const updated = await api.updateMe({ avatar_url: result.link });
setUser(updated);
toast.success("Avatar updated");
} catch (err) {
toast.error(err instanceof Error ? err.message : "Failed to upload avatar");
}
};
const handleProfileSave = async () => {
setProfileSaving(true);
try {
const updated = await api.updateMe({
name: profileName,
avatar_url: avatarUrl || undefined,
});
const updated = await api.updateMe({ name: profileName });
setUser(updated);
toast.success("Profile updated");
} catch (e) {
@@ -45,7 +66,46 @@ export function AccountTab() {
<h2 className="text-sm font-semibold">Profile</h2>
<Card>
<CardContent className="space-y-3">
<CardContent className="space-y-4">
{/* Avatar upload */}
<div className="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}
>
{user?.avatar_url ? (
<img
src={user.avatar_url}
alt={user.name}
className="h-full w-full object-cover"
/>
) : (
<span className="flex h-full w-full items-center justify-center text-lg font-semibold text-muted-foreground">
{initials}
</span>
)}
<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>
<Label className="text-xs text-muted-foreground">Name</Label>
<Input
@@ -55,16 +115,6 @@ export function AccountTab() {
className="mt-1"
/>
</div>
<div>
<Label className="text-xs text-muted-foreground">Avatar URL</Label>
<Input
type="url"
value={avatarUrl}
onChange={(e) => setAvatarUrl(e.target.value)}
placeholder="https://example.com/avatar.png"
className="mt-1"
/>
</div>
<div className="flex items-center justify-end gap-2 pt-1">
<Button
size="sm"

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

@@ -29,8 +29,10 @@
--color-success: var(--success);
--color-warning: var(--warning);
--color-info: var(--info);
--color-done: var(--done);
--color-brand: var(--brand);
--color-brand-foreground: var(--brand-foreground);
--color-priority: var(--priority);
--color-canvas: var(--canvas);
--color-accent-foreground: var(--accent-foreground);
--color-accent: var(--accent);
@@ -94,8 +96,10 @@
--success: oklch(0.55 0.16 145);
--warning: oklch(0.75 0.16 85);
--info: oklch(0.55 0.18 250);
--scrollbar-thumb: oklch(0.82 0.003 286);
--scrollbar-thumb-hover: oklch(0.705 0.015 286.067);
--done: oklch(0.55 0.18 300);
--priority: oklch(0.65 0.18 50);
--scrollbar-thumb: oklch(0 0 0 / 10%);
--scrollbar-thumb-hover: oklch(0 0 0 / 18%);
--scrollbar-track: transparent;
}
@@ -137,8 +141,10 @@
--success: oklch(0.65 0.15 145);
--warning: oklch(0.70 0.16 85);
--info: oklch(0.65 0.18 250);
--scrollbar-thumb: oklch(1 0 0 / 15%);
--scrollbar-thumb-hover: oklch(1 0 0 / 30%);
--done: oklch(0.65 0.18 300);
--priority: oklch(0.70 0.18 50);
--scrollbar-thumb: oklch(1 0 0 / 8%);
--scrollbar-thumb-hover: oklch(1 0 0 / 18%);
--scrollbar-track: transparent;
}

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>
);
}

View File

@@ -1,107 +0,0 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { render, screen, waitFor } from "@testing-library/react";
const {
mockGetDaemonPairingSession,
mockApproveDaemonPairingSession,
mockWorkspace,
mockAuthValue,
} = vi.hoisted(() => ({
mockGetDaemonPairingSession: vi.fn(),
mockApproveDaemonPairingSession: vi.fn(),
mockWorkspace: {
id: "05ce77f1-7c45-4735-b1f7-619347f7f76c",
name: "Jiayuan's Workspace",
slug: "jiayuan-05ce77f1",
description: null,
settings: {},
created_at: "2026-03-24T00:00:00Z",
updated_at: "2026-03-24T00:00:00Z",
},
mockAuthValue: {
user: {
id: "user-1",
name: "Jiayuan",
email: "jiayuan@example.com",
avatar_url: null,
created_at: "2026-03-24T00:00:00Z",
updated_at: "2026-03-24T00:00:00Z",
},
workspaces: [] as Array<{
id: string;
name: string;
slug: string;
description: null;
settings: Record<string, never>;
created_at: string;
updated_at: string;
}>,
workspace: null as null | {
id: string;
name: string;
slug: string;
description: null;
settings: Record<string, never>;
created_at: string;
updated_at: string;
},
isLoading: false,
},
}));
mockAuthValue.workspaces = [mockWorkspace];
mockAuthValue.workspace = mockWorkspace;
vi.mock("next/navigation", () => ({
useSearchParams: () => new URLSearchParams("token=test-token"),
}));
vi.mock("@/shared/api", () => ({
api: {
getDaemonPairingSession: mockGetDaemonPairingSession,
approveDaemonPairingSession: mockApproveDaemonPairingSession,
},
}));
vi.mock("@/features/auth", () => ({
useAuthStore: (selector: (s: any) => any) =>
selector(mockAuthValue),
}));
vi.mock("@/features/workspace", () => ({
useWorkspaceStore: (selector: (s: any) => any) =>
selector(mockAuthValue),
}));
import LocalDaemonPairPage from "./page";
describe("LocalDaemonPairPage", () => {
beforeEach(() => {
vi.clearAllMocks();
mockGetDaemonPairingSession.mockResolvedValue({
token: "test-token",
daemon_id: "local-daemon",
device_name: "Jiayuans-MacBook-Pro.local",
runtime_name: "Local Codex",
runtime_type: "codex",
runtime_version: "codex-cli 0.116.0",
workspace_id: mockWorkspace.id,
status: "pending",
approved_at: null,
claimed_at: null,
expires_at: "2026-03-24T07:20:00Z",
link_url: null,
});
});
it("shows the selected workspace name instead of the raw id", async () => {
render(<LocalDaemonPairPage />);
await waitFor(() => {
expect(mockGetDaemonPairingSession).toHaveBeenCalledWith("test-token");
});
expect(await screen.findByText("Jiayuan's Workspace")).toBeInTheDocument();
expect(screen.queryByText(mockWorkspace.id)).not.toBeInTheDocument();
});
});

View File

@@ -1,181 +0,0 @@
"use client";
import Link from "next/link";
import { Suspense, useEffect, useMemo, useState } from "react";
import { useSearchParams } from "next/navigation";
import type { DaemonPairingSession } from "@/shared/types";
import { Button } from "@/components/ui/button";
import { Label } from "@/components/ui/label";
import {
Select,
SelectTrigger,
SelectContent,
SelectItem,
} from "@/components/ui/select";
import { api } from "@/shared/api";
import { useAuthStore } from "@/features/auth";
import { useWorkspaceStore } from "@/features/workspace";
function formatExpiresAt(value: string) {
return new Date(value).toLocaleString("en-US", {
month: "short",
day: "numeric",
hour: "numeric",
minute: "2-digit",
});
}
function LocalDaemonPairPageContent() {
const searchParams = useSearchParams();
const token = searchParams.get("token") ?? "";
const user = useAuthStore((s) => s.user);
const isLoading = useAuthStore((s) => s.isLoading);
const workspace = useWorkspaceStore((s) => s.workspace);
const workspaces = useWorkspaceStore((s) => s.workspaces);
const [session, setSession] = useState<DaemonPairingSession | null>(null);
const [selectedWorkspaceId, setSelectedWorkspaceId] = useState("");
const [loading, setLoading] = useState(true);
const [submitting, setSubmitting] = useState(false);
const [error, setError] = useState("");
const nextLoginURL = useMemo(() => {
const next = `/pair/local?token=${encodeURIComponent(token)}`;
return `/login?next=${encodeURIComponent(next)}`;
}, [token]);
const selectedWorkspace = useMemo(
() => workspaces.find((item) => item.id === selectedWorkspaceId) ?? null,
[selectedWorkspaceId, workspaces],
);
useEffect(() => {
if (!token) {
setError("Missing pairing token.");
setLoading(false);
return;
}
setLoading(true);
api.getDaemonPairingSession(token)
.then((value) => {
setSession(value);
setSelectedWorkspaceId(value.workspace_id || workspace?.id || workspaces[0]?.id || "");
})
.catch((err) => setError(err instanceof Error ? err.message : "Failed to load pairing session."))
.finally(() => setLoading(false));
}, [token, workspace?.id, workspaces]);
const approve = async () => {
if (!token || !selectedWorkspaceId) return;
setSubmitting(true);
setError("");
try {
const approved = await api.approveDaemonPairingSession(token, {
workspace_id: selectedWorkspaceId,
});
setSession(approved);
} catch (err) {
setError(err instanceof Error ? err.message : "Failed to approve pairing session.");
} finally {
setSubmitting(false);
}
};
return (
<div className="flex min-h-screen items-center justify-center bg-canvas px-6 py-12">
<div className="w-full max-w-xl rounded-2xl border bg-background p-8 shadow-sm">
<div>
<h1 className="text-2xl font-semibold">Connect Local Codex Runtime</h1>
<p className="mt-2 text-sm text-muted-foreground">
Approve this pairing request to register your local Codex runtime with a workspace.
</p>
</div>
{loading || isLoading ? (
<div className="mt-8 text-sm text-muted-foreground">Loading pairing session...</div>
) : error ? (
<div className="mt-8 rounded-lg border border-destructive/30 bg-destructive/5 px-4 py-3 text-sm text-destructive">
{error}
</div>
) : session ? (
<>
<div className="mt-6 rounded-xl border bg-muted/30 p-4">
<div className="text-sm font-medium">{session.runtime_name}</div>
<div className="mt-1 text-sm text-muted-foreground">
{session.device_name}
{session.runtime_version ? ` · ${session.runtime_version}` : ""}
</div>
<div className="mt-1 text-xs uppercase tracking-wide text-muted-foreground">
{session.runtime_type}
</div>
<div className="mt-3 text-xs text-muted-foreground">
Expires {formatExpiresAt(session.expires_at)}
</div>
</div>
{!user ? (
<div className="mt-6 space-y-3">
<p className="text-sm text-muted-foreground">
Sign in first, then choose which workspace should own this local runtime.
</p>
<Link
href={nextLoginURL}
className="inline-flex rounded-md bg-primary px-4 py-2 text-sm font-medium text-primary-foreground hover:bg-primary/90"
>
Sign in to continue
</Link>
</div>
) : session.status === "approved" || session.status === "claimed" ? (
<div className="mt-6 rounded-xl border border-success/30 bg-success/5 px-4 py-3 text-sm text-success">
This runtime is linked to a workspace. Return to the daemon window to finish setup.
</div>
) : session.status === "expired" ? (
<div className="mt-6 rounded-xl border border-warning/30 bg-warning/5 px-4 py-3 text-sm text-warning">
This pairing link expired. Restart the daemon to generate a new link.
</div>
) : workspaces.length === 0 ? (
<div className="mt-6 rounded-xl border px-4 py-3 text-sm text-muted-foreground">
You do not have a workspace yet. Create one first, then reopen this pairing link.
</div>
) : (
<div className="mt-6 space-y-4">
<div>
<Label className="mb-2">Workspace</Label>
<Select value={selectedWorkspaceId} onValueChange={(v) => setSelectedWorkspaceId(v ?? "")}>
<SelectTrigger className="w-full">
<span className="flex flex-1 min-w-0 truncate text-left">
{selectedWorkspace?.name ?? "Select workspace"}
</span>
</SelectTrigger>
<SelectContent>
{workspaces.map((item) => (
<SelectItem key={item.id} value={item.id}>
{item.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<Button
type="button"
onClick={approve}
disabled={submitting || !selectedWorkspaceId}
>
{submitting ? "Registering..." : "Register runtime"}
</Button>
</div>
)}
</>
) : null}
</div>
</div>
);
}
export default function LocalDaemonPairPage() {
return (
<Suspense fallback={null}>
<LocalDaemonPairPageContent />
</Suspense>
);
}

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

@@ -19,7 +19,7 @@
"lib": "@/lib",
"hooks": "@/hooks"
},
"menuColor": "inverted-translucent",
"menuColor": "default",
"menuAccent": "subtle",
"registries": {}
}

View File

@@ -1,5 +1,6 @@
"use client";
import { useState, useEffect } from "react";
import { Bot } from "lucide-react";
import { cn } from "@/lib/utils";
import { useActorName } from "@/features/workspace";
@@ -8,8 +9,10 @@ interface ActorAvatarProps {
actorType: string;
actorId: string;
size?: number;
avatarUrl?: string | null;
getName?: (type: string, id: string) => string;
getInitials?: (type: string, id: string) => string;
getAvatarUrl?: (type: string, id: string) => string | null;
className?: string;
}
@@ -17,29 +20,48 @@ function ActorAvatar({
actorType,
actorId,
size = 20,
avatarUrl,
getName,
getInitials,
getAvatarUrl,
className,
}: ActorAvatarProps) {
const actorNameHook = useActorName();
const resolveName = getName ?? actorNameHook.getActorName;
const resolveInitials = getInitials ?? actorNameHook.getActorInitials;
const resolveAvatarUrl = getAvatarUrl ?? actorNameHook.getActorAvatarUrl;
const name = resolveName(actorType, actorId);
const initials = resolveInitials(actorType, actorId);
const isAgent = actorType === "agent";
const resolvedUrl = avatarUrl !== undefined ? avatarUrl : resolveAvatarUrl(actorType, actorId);
const [imgError, setImgError] = useState(false);
// Reset error state when URL changes (e.g. user uploads new avatar)
useEffect(() => {
setImgError(false);
}, [resolvedUrl]);
return (
<div
data-slot="avatar"
className={cn(
"inline-flex shrink-0 items-center justify-center rounded-full font-medium",
isAgent ? "bg-info/10 text-info" : "bg-muted text-muted-foreground",
"inline-flex shrink-0 items-center justify-center rounded-full font-medium overflow-hidden",
"bg-muted text-muted-foreground",
className
)}
style={{ width: size, height: size, fontSize: size * 0.45 }}
title={name}
>
{isAgent ? (
{resolvedUrl && !imgError ? (
<img
src={resolvedUrl}
alt={name}
className="h-full w-full object-cover"
onError={() => setImgError(true)}
/>
) : isAgent ? (
<Bot style={{ width: size * 0.55, height: size * 0.55 }} />
) : (
initials

View File

@@ -0,0 +1,42 @@
"use client";
import { useEffect, useRef, useCallback } from "react";
import data from "@emoji-mart/data";
import { Picker } from "emoji-mart";
interface EmojiPickerProps {
onSelect: (emoji: string) => void;
}
export function EmojiPicker({ onSelect }: EmojiPickerProps) {
const containerRef = useRef<HTMLDivElement>(null);
const onSelectRef = useRef(onSelect);
onSelectRef.current = onSelect;
const handleSelect = useCallback((emoji: { native: string }) => {
onSelectRef.current(emoji.native);
}, []);
useEffect(() => {
const container = containerRef.current;
if (!container) return;
const picker = new Picker({
data,
onEmojiSelect: handleSelect,
theme: "auto",
set: "native",
previewPosition: "none",
skinTonePosition: "search",
maxFrequentRows: 2,
});
container.appendChild(picker as unknown as Node);
return () => {
container.replaceChildren();
};
}, [handleSelect]);
return <div ref={containerRef} />;
}

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

@@ -0,0 +1,89 @@
"use client";
import type { ReactNode } from "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";
interface MentionHoverCardProps {
type: string;
id: string;
children: ReactNode;
}
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}</>;
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">
<ActorAvatar actorType="member" actorId={id} size={32} />
<div className="min-w-0">
<p className="text-sm font-medium truncate">{member.name}</p>
<p className="text-xs text-muted-foreground truncate">{member.email}</p>
</div>
</div>
</HoverCardContent>
</HoverCard>
);
}
if (type === "agent") {
const agent = agents.find((a) => a.id === id);
if (!agent) return <>{children}</>;
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">
<ActorAvatar actorType="agent" actorId={id} size={32} />
<div className="min-w-0">
<p className="text-sm font-medium truncate">{agent.name}</p>
{agent.description && (
<p className="text-xs text-muted-foreground truncate">{agent.description}</p>
)}
</div>
</div>
</HoverCardContent>
</HoverCard>
);
}
return <>{children}</>;
}
export { MentionHoverCard };

View File

@@ -1,196 +0,0 @@
"use client";
import {
forwardRef,
useEffect,
useImperativeHandle,
useState,
} from "react";
import { Bot } from "lucide-react";
import { ReactRenderer } from "@tiptap/react";
import { useWorkspaceStore } from "@/features/workspace";
import type { SuggestionOptions, SuggestionProps } from "@tiptap/suggestion";
// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------
export interface MentionItem {
id: string;
label: string;
type: "member" | "agent";
}
interface MentionListProps {
items: MentionItem[];
command: (item: MentionItem) => void;
}
export interface MentionListRef {
onKeyDown: (props: { event: KeyboardEvent }) => boolean;
}
// ---------------------------------------------------------------------------
// MentionList — the popup rendered inside the editor
// ---------------------------------------------------------------------------
const MentionList = forwardRef<MentionListRef, MentionListProps>(
function MentionList({ items, command }, ref) {
const [selectedIndex, setSelectedIndex] = useState(0);
useEffect(() => {
setSelectedIndex(0);
}, [items]);
const selectItem = (index: number) => {
const item = items[index];
if (item) command(item);
};
useImperativeHandle(ref, () => ({
onKeyDown: ({ event }) => {
if (event.key === "ArrowUp") {
setSelectedIndex((i) => (i + items.length - 1) % items.length);
return true;
}
if (event.key === "ArrowDown") {
setSelectedIndex((i) => (i + 1) % items.length);
return true;
}
if (event.key === "Enter") {
selectItem(selectedIndex);
return true;
}
return false;
},
}));
if (items.length === 0) {
return (
<div className="rounded-md border bg-popover p-2 text-xs text-muted-foreground shadow-md">
No results
</div>
);
}
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>
) : (
<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>
)}
<span className="truncate">{item.label}</span>
</button>
))}
</div>
);
},
);
// ---------------------------------------------------------------------------
// Suggestion config factory
// ---------------------------------------------------------------------------
export function createMentionSuggestion(): Omit<
SuggestionOptions<MentionItem>,
"editor"
> {
return {
items: ({ query }) => {
const { members, agents } = useWorkspaceStore.getState();
const q = query.toLowerCase();
const memberItems: MentionItem[] = members
.filter((m) => m.name.toLowerCase().includes(q))
.map((m) => ({
id: m.user_id,
label: m.name,
type: "member" as const,
}));
const agentItems: MentionItem[] = agents
.filter((a) => a.name.toLowerCase().includes(q))
.map((a) => ({ id: a.id, label: a.name, type: "agent" as const }));
return [...memberItems, ...agentItems].slice(0, 10);
},
render: () => {
let renderer: ReactRenderer<MentionListRef> | null = null;
let popup: HTMLDivElement | null = null;
return {
onStart: (props: SuggestionProps<MentionItem>) => {
renderer = new ReactRenderer(MentionList, {
props: { items: props.items, command: props.command },
editor: props.editor,
});
popup = document.createElement("div");
popup.style.position = "fixed";
popup.style.zIndex = "50";
popup.appendChild(renderer.element);
document.body.appendChild(popup);
updatePosition(popup, props.clientRect);
},
onUpdate: (props: SuggestionProps<MentionItem>) => {
renderer?.updateProps({
items: props.items,
command: props.command,
});
if (popup) updatePosition(popup, props.clientRect);
},
onKeyDown: (props: { event: KeyboardEvent }) => {
if (props.event.key === "Escape") {
cleanup();
return true;
}
return renderer?.ref?.onKeyDown(props) ?? false;
},
onExit: () => {
cleanup();
},
};
function updatePosition(
el: HTMLDivElement,
clientRect: (() => DOMRect | null) | null | undefined,
) {
if (!clientRect) return;
const rect = clientRect();
if (!rect) return;
el.style.left = `${rect.left}px`;
el.style.top = `${rect.bottom + 4}px`;
}
function cleanup() {
renderer?.destroy();
renderer = null;
popup?.remove();
popup = null;
}
},
};
}

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

@@ -0,0 +1,82 @@
"use client";
import { Tooltip, TooltipTrigger, TooltipContent } from "@/components/ui/tooltip";
import { QuickEmojiPicker } from "@/components/common/quick-emoji-picker";
import { useActorName } from "@/features/workspace";
interface ReactionItem {
id: string;
actor_type: string;
actor_id: string;
emoji: string;
}
interface GroupedReaction {
emoji: string;
count: number;
reacted: boolean;
actors: { type: string; id: string }[];
}
function groupReactions(reactions: ReactionItem[], currentUserId?: string): GroupedReaction[] {
const map = new Map<string, GroupedReaction>();
for (const r of reactions) {
let group = map.get(r.emoji);
if (!group) {
group = { emoji: r.emoji, count: 0, reacted: false, actors: [] };
map.set(r.emoji, group);
}
group.count++;
group.actors.push({ type: r.actor_type, id: r.actor_id });
if (r.actor_type === "member" && r.actor_id === currentUserId) {
group.reacted = true;
}
}
return Array.from(map.values());
}
export function ReactionBar({
reactions,
currentUserId,
onToggle,
className,
hideAddButton,
}: {
reactions: ReactionItem[];
currentUserId?: string;
onToggle: (emoji: string) => void;
className?: string;
hideAddButton?: boolean;
}) {
const grouped = groupReactions(reactions, currentUserId);
const { getActorName } = useActorName();
return (
<div className={`flex flex-wrap items-center gap-1.5 ${className ?? ""}`}>
{grouped.map((g) => (
<Tooltip key={g.emoji}>
<TooltipTrigger
render={
<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-brand/15 ${
g.reacted
? "border-brand/30 bg-brand/8 text-brand"
: "border-brand/10 bg-brand/4 text-muted-foreground"
}`}
>
<span>{g.emoji}</span>
<span>{g.count}</span>
</button>
}
/>
<TooltipContent side="top">
{g.actors.map((a) => getActorName(a.type, a.id)).join(", ")}
</TooltipContent>
</Tooltip>
))}
{!hideAddButton && <QuickEmojiPicker onSelect={onToggle} />}
</div>
);
}

View File

@@ -1,155 +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 {
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(--primary);
text-decoration: underline;
text-underline-offset: 2px;
}
/* Mentions */
.rich-text-editor .mention {
color: var(--primary);
background: color-mix(in srgb, var(--primary) 8%, transparent);
padding: 0 0.2em;
border-radius: calc(var(--radius) * 0.5);
font-weight: 500;
text-decoration: none;
}
/* 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,202 +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 { Markdown } from "tiptap-markdown";
import { Extension } from "@tiptap/core";
import { cn } from "@/lib/utils";
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;
}
interface RichTextEditorRef {
getMarkdown: () => string;
clearContent: () => void;
focus: () => void;
}
// ---------------------------------------------------------------------------
// Submit shortcut extension (Mod+Enter)
// ---------------------------------------------------------------------------
// ---------------------------------------------------------------------------
// Mention extension configured for markdown serialization
// Stores as: [@Label](mention://type/id)
// ---------------------------------------------------------------------------
const MentionExtension = Mention.configure({
HTMLAttributes: { class: "mention" },
suggestion: createMentionSuggestion(),
}).extend({
renderHTML({ node, HTMLAttributes }) {
return [
"a",
{
...HTMLAttributes,
href: `mention://${node.attrs.type ?? "member"}/${node.attrs.id}`,
"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",
},
};
},
addStorage() {
return {
markdown: {
serialize(state: { write: (s: string) => void }, node: { attrs: { label?: string; type?: string; id?: string } }) {
state.write(
`[@${node.attrs.label ?? node.attrs.id}](mention://${node.attrs.type ?? "member"}/${node.attrs.id})`,
);
},
parse: {},
},
};
},
});
// ---------------------------------------------------------------------------
// Submit shortcut extension (Mod+Enter)
// ---------------------------------------------------------------------------
function createSubmitExtension(onSubmit: () => void) {
return Extension.create({
name: "submitShortcut",
addKeyboardShortcuts() {
return {
"Mod-Enter": () => {
onSubmit();
return true;
},
};
},
});
}
// ---------------------------------------------------------------------------
// Component
// ---------------------------------------------------------------------------
const RichTextEditor = forwardRef<RichTextEditorRef, RichTextEditorProps>(
function RichTextEditor(
{
defaultValue = "",
onUpdate,
placeholder: placeholderText = "",
editable = true,
className,
debounceMs = 300,
onSubmit,
},
ref,
) {
const debounceRef = useRef<ReturnType<typeof setTimeout>>(undefined);
const onUpdateRef = useRef(onUpdate);
const onSubmitRef = useRef(onSubmit);
// Helper to get markdown from tiptap-markdown storage
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const getEditorMarkdown = (ed: any): string =>
ed?.storage?.markdown?.getMarkdown?.() ?? "";
// Keep refs in sync without recreating editor
onUpdateRef.current = onUpdate;
onSubmitRef.current = onSubmit;
const editor = useEditor({
immediatelyRender: false,
editable,
content: defaultValue,
extensions: [
StarterKit.configure({
heading: { levels: [1, 2, 3] },
link: false,
}),
Placeholder.configure({
placeholder: placeholderText,
}),
Link.configure({
openOnClick: false,
autolink: true,
HTMLAttributes: {
class: "text-primary hover:underline cursor-pointer",
},
}),
Typography,
MentionExtension,
Markdown.configure({
html: false,
transformPastedText: true,
transformCopiedText: true,
}),
createSubmitExtension(() => onSubmitRef.current?.()),
],
onUpdate: ({ editor: ed }) => {
if (!onUpdateRef.current) return;
if (debounceRef.current) clearTimeout(debounceRef.current);
debounceRef.current = setTimeout(() => {
onUpdateRef.current?.(getEditorMarkdown(ed));
}, debounceMs);
},
editorProps: {
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: () => getEditorMarkdown(editor),
clearContent: () => {
editor?.commands.clearContent();
},
focus: () => {
editor?.commands.focus();
},
}));
if (!editor) return null;
return <EditorContent editor={editor} />;
},
);
export { RichTextEditor, type RichTextEditorProps, type RichTextEditorRef };

View File

@@ -141,7 +141,7 @@ export function CodeBlock({
if (mode === 'terminal') {
return (
<pre className={cn('font-mono text-sm whitespace-pre-wrap', className)}>
<code>{code}</code>
<code className="font-mono">{code}</code>
</pre>
)
}
@@ -151,7 +151,7 @@ export function CodeBlock({
if (isLoading || !highlighted) {
return (
<pre className={cn('font-mono text-sm whitespace-pre-wrap', className)}>
<code>{code}</code>
<code className="font-mono">{code}</code>
</pre>
)
}
@@ -159,7 +159,7 @@ export function CodeBlock({
return (
<div
className={cn(
'font-mono text-sm [&_pre]:!bg-transparent [&_pre]:!p-0 [&_pre]:whitespace-pre-wrap [&_pre]:break-all [&_code]:!bg-transparent',
'font-mono text-sm [&_pre]:!bg-transparent [&_pre]:!p-0 [&_pre]:whitespace-pre-wrap [&_pre]:break-all [&_code]:!bg-transparent [&_code]:font-mono [&_pre]:font-mono',
className
)}
dangerouslySetInnerHTML={{ __html: highlighted }}
@@ -206,11 +206,11 @@ export function CodeBlock({
<div className="p-3 overflow-x-auto">
{isLoading || !highlighted ? (
<pre className="font-mono text-sm whitespace-pre-wrap break-all">
<code>{code}</code>
<code className="font-mono">{code}</code>
</pre>
) : (
<div
className="font-mono text-sm [&_pre]:!bg-transparent [&_pre]:!m-0 [&_pre]:!p-0 [&_pre]:whitespace-pre-wrap [&_pre]:break-all [&_code]:!bg-transparent"
className="font-mono text-sm [&_pre]:!bg-transparent [&_pre]:!m-0 [&_pre]:!p-0 [&_pre]:whitespace-pre-wrap [&_pre]:break-all [&_code]:!bg-transparent [&_code]:font-mono [&_pre]:font-mono"
dangerouslySetInnerHTML={{ __html: highlighted }}
/>
)}

View File

@@ -1,10 +1,12 @@
import * as React from 'react'
import ReactMarkdown, { type Components } from 'react-markdown'
import ReactMarkdown, { type Components, defaultUrlTransform } from 'react-markdown'
import rehypeRaw from 'rehype-raw'
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'
/**
* Render modes for markdown content:
@@ -43,6 +45,16 @@ export interface MarkdownProps {
onFileClick?: (path: string) => void
}
/**
* Custom URL transform that allows mention:// protocol (used for @mentions)
* while keeping the default security for all other URLs.
*/
function urlTransform(url: string): string {
if (url.startsWith('mention://')) return url
return defaultUrlTransform(url)
}
// File path detection regex - matches paths starting with /, ~/, or ./
const FILE_PATH_REGEX =
/^(?:\/|~\/|\.\/)[\w\-./@]+\.(?:ts|tsx|js|jsx|mjs|cjs|md|json|yaml|yml|py|go|rs|css|scss|less|html|htm|txt|log|sh|bash|zsh|swift|kt|java|c|cpp|h|hpp|rb|php|xml|toml|ini|cfg|conf|env|sql|graphql|vue|svelte|astro|prisma)$/i
@@ -56,15 +68,26 @@ function createComponents(
onFileClick?: (path: string) => void
): Partial<Components> {
const baseComponents: Partial<Components> = {
// Images: render uploaded images with constrained sizing
img: ({ src, alt }) => (
<img
src={src}
alt={alt ?? ""}
className="max-w-full h-auto rounded-md my-2"
loading="lazy"
/>
),
// Links: Make clickable with callbacks, or render as mention
a: ({ href, children }) => {
// Mention links: mention://member/id or mention://agent/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|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} />
}
return (
<span
className="text-primary font-medium"
style={{ background: 'color-mix(in srgb, var(--primary) 8%, transparent)', padding: '0 0.2em', borderRadius: 'calc(var(--radius) * 0.5)' }}
>
<span className="text-primary font-semibold mx-0.5">
{children}
</span>
)
@@ -276,14 +299,18 @@ export function Markdown({
[mode, onUrlClick, onFileClick]
)
// Preprocess to convert raw URLs and file paths to markdown links
const processedContent = React.useMemo(() => preprocessLinks(children), [children])
// Preprocess: convert mention shortcodes and raw URLs/file paths to markdown links
const processedContent = React.useMemo(
() => preprocessLinks(preprocessMentionShortcodes(children)),
[children]
)
return (
<div className={cn('markdown-content break-words', className)}>
<ReactMarkdown
remarkPlugins={[[remarkGfm, { singleTilde: false }]]}
rehypePlugins={[rehypeRaw]}
urlTransform={urlTransform}
components={components}
>
{processedContent}

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

@@ -1,7 +1,6 @@
"use client"
import * as React from "react"
import { ThemeProvider as NextThemesProvider, useTheme } from "next-themes"
import { ThemeProvider as NextThemesProvider } from "next-themes"
import { TooltipProvider } from "@/components/ui/tooltip"
function ThemeProvider({
@@ -16,7 +15,6 @@ function ThemeProvider({
disableTransitionOnChange
{...props}
>
<ThemeHotkey />
<TooltipProvider delay={500}>
{children}
</TooltipProvider>
@@ -24,51 +22,4 @@ function ThemeProvider({
)
}
function isTypingTarget(target: EventTarget | null) {
if (!(target instanceof HTMLElement)) {
return false
}
return (
target.isContentEditable ||
target.tagName === "INPUT" ||
target.tagName === "TEXTAREA" ||
target.tagName === "SELECT"
)
}
function ThemeHotkey() {
const { resolvedTheme, setTheme } = useTheme()
React.useEffect(() => {
function onKeyDown(event: KeyboardEvent) {
if (!event.key || event.isComposing || event.defaultPrevented || event.repeat) {
return
}
if (event.metaKey || event.ctrlKey || event.altKey) {
return
}
if (event.key.toLowerCase() !== "d") {
return
}
if (isTypingTarget(event.target)) {
return
}
setTheme(resolvedTheme === "dark" ? "light" : "dark")
}
window.addEventListener("keydown", onKeyDown)
return () => {
window.removeEventListener("keydown", onKeyDown)
}
}, [resolvedTheme, setTheme])
return null
}
export { ThemeProvider }

View File

@@ -6,17 +6,17 @@ import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
const buttonVariants = cva(
"group/button inline-flex shrink-0 items-center justify-center rounded-lg border border-transparent bg-clip-padding text-sm font-medium whitespace-nowrap transition-all outline-none select-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 active:translate-y-px disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
"group/button inline-flex shrink-0 items-center justify-center rounded-lg border border-transparent bg-clip-padding text-sm font-medium whitespace-nowrap transition-all outline-none select-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 active:not-aria-[haspopup]:translate-y-px disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
{
variants: {
variant: {
default: "bg-primary text-primary-foreground [a]:hover:bg-primary/80",
outline:
"border-border bg-background hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:border-input dark:bg-input/30 dark:hover:bg-input/50 dark:aria-expanded:bg-muted dark:aria-expanded:text-foreground",
"border-border bg-background hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:border-input dark:bg-input/30 dark:hover:bg-input/50",
secondary:
"bg-secondary text-secondary-foreground hover:bg-secondary/80 aria-expanded:bg-secondary aria-expanded:text-secondary-foreground",
ghost:
"hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:hover:bg-muted/50 dark:aria-expanded:bg-muted dark:aria-expanded:text-foreground",
"hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:hover:bg-muted/50",
destructive:
"bg-destructive/10 text-destructive hover:bg-destructive/20 focus-visible:border-destructive/40 focus-visible:ring-destructive/20 dark:bg-destructive/20 dark:hover:bg-destructive/30 dark:focus-visible:ring-destructive/40",
link: "text-primary underline-offset-4 hover:underline",

View File

@@ -110,10 +110,7 @@ function ComboboxContent({
<ComboboxPrimitive.Popup
data-slot="combobox-content"
data-chips={!!anchor}
className={cn(
"dark group/combobox-content max-h-(--available-height) w-(--anchor-width) max-w-(--available-width) min-w-[calc(var(--anchor-width)+--spacing(7))] origin-(--transform-origin) overflow-hidden rounded-lg text-popover-foreground shadow-md ring-1 ring-foreground/10 duration-100 data-[chips=true]:min-w-(--anchor-width) data-[side=bottom]:slide-in-from-top-2 data-[side=inline-end]:slide-in-from-left-2 data-[side=inline-start]:slide-in-from-right-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 *:data-[slot=input-group]:m-1 *:data-[slot=input-group]:mb-0 *:data-[slot=input-group]:h-8 *:data-[slot=input-group]:border-input/30 *:data-[slot=input-group]:bg-input/30 *:data-[slot=input-group]:shadow-none data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95 animate-none! relative bg-popover/70 before:pointer-events-none before:absolute before:inset-0 before:-z-1 before:rounded-[inherit] before:backdrop-blur-2xl before:backdrop-saturate-150 **:data-[slot$=-item]:focus:bg-foreground/10 **:data-[slot$=-item]:data-highlighted:bg-foreground/10 **:data-[slot$=-separator]:bg-foreground/5 **:data-[slot$=-trigger]:focus:bg-foreground/10 **:data-[slot$=-trigger]:aria-expanded:bg-foreground/10! **:data-[variant=destructive]:focus:bg-foreground/10! **:data-[variant=destructive]:text-accent-foreground! **:data-[variant=destructive]:**:text-accent-foreground!",
className
)}
className={cn("group/combobox-content relative max-h-(--available-height) w-(--anchor-width) max-w-(--available-width) min-w-[calc(var(--anchor-width)+--spacing(7))] origin-(--transform-origin) overflow-hidden rounded-lg bg-popover text-popover-foreground shadow-md ring-1 ring-foreground/10 duration-100 data-[chips=true]:min-w-(--anchor-width) data-[side=bottom]:slide-in-from-top-2 data-[side=inline-end]:slide-in-from-left-2 data-[side=inline-start]:slide-in-from-right-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 *:data-[slot=input-group]:m-1 *:data-[slot=input-group]:mb-0 *:data-[slot=input-group]:h-8 *:data-[slot=input-group]:border-input/30 *:data-[slot=input-group]:bg-input/30 *:data-[slot=input-group]:shadow-none data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95", className )}
{...props}
/>
</ComboboxPrimitive.Positioner>

View File

@@ -52,10 +52,7 @@ function ContextMenuContent({
>
<ContextMenuPrimitive.Popup
data-slot="context-menu-content"
className={cn(
"dark z-50 max-h-(--available-height) min-w-36 origin-(--transform-origin) overflow-x-hidden overflow-y-auto rounded-lg p-1 text-popover-foreground shadow-md ring-1 ring-foreground/10 duration-100 outline-none data-[side=bottom]:slide-in-from-top-2 data-[side=inline-end]:slide-in-from-left-2 data-[side=inline-start]:slide-in-from-right-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95 animate-none! relative bg-popover/70 before:pointer-events-none before:absolute before:inset-0 before:-z-1 before:rounded-[inherit] before:backdrop-blur-2xl before:backdrop-saturate-150 **:data-[slot$=-item]:focus:bg-foreground/10 **:data-[slot$=-item]:data-highlighted:bg-foreground/10 **:data-[slot$=-separator]:bg-foreground/5 **:data-[slot$=-trigger]:focus:bg-foreground/10 **:data-[slot$=-trigger]:aria-expanded:bg-foreground/10! **:data-[variant=destructive]:focus:bg-foreground/10! **:data-[variant=destructive]:text-accent-foreground! **:data-[variant=destructive]:**:text-accent-foreground!",
className
)}
className={cn("z-50 max-h-(--available-height) min-w-36 origin-(--transform-origin) overflow-x-hidden overflow-y-auto rounded-lg bg-popover p-1 text-popover-foreground shadow-md ring-1 ring-foreground/10 duration-100 outline-none data-[side=bottom]:slide-in-from-top-2 data-[side=inline-end]:slide-in-from-left-2 data-[side=inline-start]:slide-in-from-right-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95", className )}
{...props}
/>
</ContextMenuPrimitive.Positioner>
@@ -148,7 +145,7 @@ function ContextMenuSubContent({
return (
<ContextMenuContent
data-slot="context-menu-sub-content"
className="dark shadow-lg animate-none! relative bg-popover/70 before:pointer-events-none before:absolute before:inset-0 before:-z-1 before:rounded-[inherit] before:backdrop-blur-2xl before:backdrop-saturate-150 **:data-[slot$=-item]:focus:bg-foreground/10 **:data-[slot$=-item]:data-highlighted:bg-foreground/10 **:data-[slot$=-separator]:bg-foreground/5 **:data-[slot$=-trigger]:focus:bg-foreground/10 **:data-[slot$=-trigger]:aria-expanded:bg-foreground/10! **:data-[variant=destructive]:focus:bg-foreground/10! **:data-[variant=destructive]:text-accent-foreground! **:data-[variant=destructive]:**:text-accent-foreground!"
className="shadow-lg"
side="right"
{...props}
/>

View File

@@ -41,10 +41,7 @@ function DropdownMenuContent({
>
<MenuPrimitive.Popup
data-slot="dropdown-menu-content"
className={cn(
"dark z-50 max-h-(--available-height) w-(--anchor-width) min-w-32 origin-(--transform-origin) overflow-x-hidden overflow-y-auto rounded-lg p-1 text-popover-foreground shadow-md ring-1 ring-foreground/10 duration-100 outline-none data-[side=bottom]:slide-in-from-top-2 data-[side=inline-end]:slide-in-from-left-2 data-[side=inline-start]:slide-in-from-right-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:overflow-hidden data-closed:fade-out-0 data-closed:zoom-out-95 animate-none! relative bg-popover/70 before:pointer-events-none before:absolute before:inset-0 before:-z-1 before:rounded-[inherit] before:backdrop-blur-2xl before:backdrop-saturate-150 **:data-[slot$=-item]:focus:bg-foreground/10 **:data-[slot$=-item]:data-highlighted:bg-foreground/10 **:data-[slot$=-separator]:bg-foreground/5 **:data-[slot$=-trigger]:focus:bg-foreground/10 **:data-[slot$=-trigger]:aria-expanded:bg-foreground/10! **:data-[variant=destructive]:focus:bg-foreground/10! **:data-[variant=destructive]:text-accent-foreground! **:data-[variant=destructive]:**:text-accent-foreground!",
className
)}
className={cn("z-50 max-h-(--available-height) w-(--anchor-width) min-w-32 origin-(--transform-origin) overflow-x-hidden overflow-y-auto rounded-lg bg-popover p-1 text-popover-foreground shadow-md ring-1 ring-foreground/10 duration-100 outline-none data-[side=bottom]:slide-in-from-top-2 data-[side=inline-end]:slide-in-from-left-2 data-[side=inline-start]:slide-in-from-right-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:overflow-hidden data-closed:fade-out-0 data-closed:zoom-out-95", className )}
{...props}
/>
</MenuPrimitive.Positioner>
@@ -138,10 +135,7 @@ function DropdownMenuSubContent({
return (
<DropdownMenuContent
data-slot="dropdown-menu-sub-content"
className={cn(
"dark w-auto min-w-[96px] rounded-lg p-1 text-popover-foreground shadow-lg ring-1 ring-foreground/10 duration-100 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95 animate-none! relative bg-popover/70 before:pointer-events-none before:absolute before:inset-0 before:-z-1 before:rounded-[inherit] before:backdrop-blur-2xl before:backdrop-saturate-150 **:data-[slot$=-item]:focus:bg-foreground/10 **:data-[slot$=-item]:data-highlighted:bg-foreground/10 **:data-[slot$=-separator]:bg-foreground/5 **:data-[slot$=-trigger]:focus:bg-foreground/10 **:data-[slot$=-trigger]:aria-expanded:bg-foreground/10! **:data-[variant=destructive]:focus:bg-foreground/10! **:data-[variant=destructive]:text-accent-foreground! **:data-[variant=destructive]:**:text-accent-foreground!",
className
)}
className={cn("w-auto min-w-[96px] rounded-lg bg-popover p-1 text-popover-foreground shadow-lg ring-1 ring-foreground/10 duration-100 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95", className )}
align={align}
alignOffset={alignOffset}
side={side}
@@ -165,7 +159,7 @@ function DropdownMenuCheckboxItem({
data-slot="dropdown-menu-checkbox-item"
data-inset={inset}
className={cn(
"relative flex cursor-default items-center gap-1.5 rounded-md py-1 pr-8 pl-1.5 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground focus:**:text-accent-foreground data-inset:pl-7 data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
"relative flex cursor-default items-center gap-1.5 rounded-md py-1 pr-8 pl-1.5 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground data-inset:pl-7 data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
checked={checked}

View File

@@ -80,10 +80,7 @@ function MenubarContent({
align={align}
alignOffset={alignOffset}
sideOffset={sideOffset}
className={cn(
"dark min-w-36 rounded-lg p-1 text-popover-foreground shadow-md ring-1 ring-foreground/10 duration-100 data-[side=bottom]:slide-in-from-top-2 data-[side=inline-end]:slide-in-from-left-2 data-[side=inline-start]:slide-in-from-right-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 animate-none! relative bg-popover/70 before:pointer-events-none before:absolute before:inset-0 before:-z-1 before:rounded-[inherit] before:backdrop-blur-2xl before:backdrop-saturate-150 **:data-[slot$=-item]:focus:bg-foreground/10 **:data-[slot$=-item]:data-highlighted:bg-foreground/10 **:data-[slot$=-separator]:bg-foreground/5 **:data-[slot$=-trigger]:focus:bg-foreground/10 **:data-[slot$=-trigger]:aria-expanded:bg-foreground/10! **:data-[variant=destructive]:focus:bg-foreground/10! **:data-[variant=destructive]:text-accent-foreground! **:data-[variant=destructive]:**:text-accent-foreground!",
className
)}
className={cn("min-w-36 rounded-lg bg-popover p-1 text-popover-foreground shadow-md ring-1 ring-foreground/10 duration-100 data-[side=bottom]:slide-in-from-top-2 data-[side=inline-end]:slide-in-from-left-2 data-[side=inline-start]:slide-in-from-right-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95", className )}
{...props}
/>
)
@@ -257,10 +254,7 @@ function MenubarSubContent({
return (
<DropdownMenuSubContent
data-slot="menubar-sub-content"
className={cn(
"dark min-w-32 rounded-lg p-1 text-popover-foreground shadow-lg ring-1 ring-foreground/10 duration-100 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95 animate-none! relative bg-popover/70 before:pointer-events-none before:absolute before:inset-0 before:-z-1 before:rounded-[inherit] before:backdrop-blur-2xl before:backdrop-saturate-150 **:data-[slot$=-item]:focus:bg-foreground/10 **:data-[slot$=-item]:data-highlighted:bg-foreground/10 **:data-[slot$=-separator]:bg-foreground/5 **:data-[slot$=-trigger]:focus:bg-foreground/10 **:data-[slot$=-trigger]:aria-expanded:bg-foreground/10! **:data-[variant=destructive]:focus:bg-foreground/10! **:data-[variant=destructive]:text-accent-foreground! **:data-[variant=destructive]:**:text-accent-foreground!",
className
)}
className={cn("min-w-32 rounded-lg bg-popover p-1 text-popover-foreground shadow-lg ring-1 ring-foreground/10 duration-100 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95", className )}
{...props}
/>
)

View File

@@ -83,10 +83,7 @@ function SelectContent({
<SelectPrimitive.Popup
data-slot="select-content"
data-align-trigger={alignItemWithTrigger}
className={cn(
"dark isolate z-50 max-h-(--available-height) w-(--anchor-width) min-w-36 origin-(--transform-origin) overflow-x-hidden overflow-y-auto rounded-lg text-popover-foreground shadow-md ring-1 ring-foreground/10 duration-100 data-[align-trigger=true]:animate-none data-[side=bottom]:slide-in-from-top-2 data-[side=inline-end]:slide-in-from-left-2 data-[side=inline-start]:slide-in-from-right-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95 animate-none! relative bg-popover/70 before:pointer-events-none before:absolute before:inset-0 before:-z-1 before:rounded-[inherit] before:backdrop-blur-2xl before:backdrop-saturate-150 **:data-[slot$=-item]:focus:bg-foreground/10 **:data-[slot$=-item]:data-highlighted:bg-foreground/10 **:data-[slot$=-separator]:bg-foreground/5 **:data-[slot$=-trigger]:focus:bg-foreground/10 **:data-[slot$=-trigger]:aria-expanded:bg-foreground/10! **:data-[variant=destructive]:focus:bg-foreground/10! **:data-[variant=destructive]:text-accent-foreground! **:data-[variant=destructive]:**:text-accent-foreground!",
className
)}
className={cn("relative isolate z-50 max-h-(--available-height) w-(--anchor-width) min-w-36 origin-(--transform-origin) overflow-x-hidden overflow-y-auto rounded-lg bg-popover text-popover-foreground shadow-md ring-1 ring-foreground/10 duration-100 data-[align-trigger=true]:animate-none data-[side=bottom]:slide-in-from-top-2 data-[side=inline-end]:slide-in-from-left-2 data-[side=inline-start]:slide-in-from-right-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95", className )}
{...props}
>
<SelectScrollUpButton />

View File

@@ -33,7 +33,6 @@ const SIDEBAR_WIDTH_MAX = 360
const SIDEBAR_WIDTH_STORAGE_KEY = "sidebar_width"
const SIDEBAR_WIDTH_MOBILE = "18rem"
const SIDEBAR_WIDTH_ICON = "3rem"
const SIDEBAR_KEYBOARD_SHORTCUT = "b"
type SidebarContextProps = {
state: "expanded" | "collapsed"
@@ -112,22 +111,6 @@ function SidebarProvider({
return isMobile ? setOpenMobile((open) => !open) : setOpen((open) => !open)
}, [isMobile, setOpen, setOpenMobile])
// Adds a keyboard shortcut to toggle the sidebar.
React.useEffect(() => {
const handleKeyDown = (event: KeyboardEvent) => {
if (
event.key === SIDEBAR_KEYBOARD_SHORTCUT &&
(event.metaKey || event.ctrlKey)
) {
event.preventDefault()
toggleSidebar()
}
}
window.addEventListener("keydown", handleKeyDown)
return () => window.removeEventListener("keydown", handleKeyDown)
}, [toggleSidebar])
// We add a state so that we can do data-state="expanded" or "collapsed".
// This makes it easier to style the sidebar with Tailwind classes.
const state = open ? "expanded" : "collapsed"

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,31 +5,46 @@ 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");
/**
* Initializes auth + workspace state from localStorage on mount.
* Must wrap the app to ensure stores are hydrated before children render.
* Fires getMe() and listWorkspaces() in parallel when a cached token exists.
*/
export function AuthInitializer({ children }: { children: ReactNode }) {
const initialize = useAuthStore((s) => s.initialize);
const user = useAuthStore((s) => s.user);
const isLoading = useAuthStore((s) => s.isLoading);
const hydrateWorkspace = useWorkspaceStore((s) => s.hydrateWorkspace);
useEffect(() => {
initialize();
}, [initialize]);
const token = localStorage.getItem("multica_token");
if (!token) {
clearLoggedInCookie();
useAuthStore.setState({ isLoading: false });
return;
}
useEffect(() => {
if (isLoading || !user) return;
api.setToken(token);
const wsId = localStorage.getItem("multica_workspace_id");
api.listWorkspaces().then((wsList) => {
hydrateWorkspace(wsList, wsId);
}).catch((err) => logger.error("workspace hydration failed", err));
}, [user, isLoading, hydrateWorkspace]);
// Fire getMe and listWorkspaces in parallel
const mePromise = api.getMe();
const wsPromise = api.listWorkspaces();
Promise.all([mePromise, wsPromise])
.then(([user, wsList]) => {
setLoggedInCookie();
useAuthStore.setState({ user, isLoading: false });
useWorkspaceStore.getState().hydrateWorkspace(wsList, wsId);
})
.catch((err) => {
logger.error("auth init failed", err);
api.setToken(null);
api.setWorkspaceId(null);
localStorage.removeItem("multica_token");
localStorage.removeItem("multica_workspace_id");
clearLoggedInCookie();
useAuthStore.setState({ user: null, isLoading: false });
});
}, []);
return <>{children}</>;
}

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

@@ -0,0 +1,325 @@
"use client";
import {
forwardRef,
useCallback,
useEffect,
useImperativeHandle,
useRef,
useState,
} from "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";
// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------
export interface MentionItem {
id: string;
label: string;
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 {
items: MentionItem[];
command: (item: MentionItem) => void;
}
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
// ---------------------------------------------------------------------------
const MentionList = forwardRef<MentionListRef, MentionListProps>(
function MentionList({ items, command }, ref) {
const [selectedIndex, setSelectedIndex] = useState(0);
const itemRefs = useRef<(HTMLButtonElement | null)[]>([]);
useEffect(() => {
setSelectedIndex(0);
}, [items]);
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 }) => {
if (event.key === "ArrowUp") {
setSelectedIndex((i) => (i + items.length - 1) % items.length);
return true;
}
if (event.key === "ArrowDown") {
setSelectedIndex((i) => (i + 1) % items.length);
return true;
}
if (event.key === "Enter") {
selectItem(selectedIndex);
return true;
}
return false;
},
}));
if (items.length === 0) {
return (
<div className="rounded-md border bg-popover p-2 text-xs text-muted-foreground shadow-md">
No results
</div>
);
}
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 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>
{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
// ---------------------------------------------------------------------------
export function createMentionSuggestion(): Omit<
SuggestionOptions<MentionItem>,
"editor"
> {
return {
items: ({ query }) => {
const { members, agents } = useWorkspaceStore.getState();
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) => ({
id: m.user_id,
label: m.name,
type: "member" as const,
}));
const agentItems: MentionItem[] = agents
.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
.filter(
(i) =>
i.identifier.toLowerCase().includes(q) ||
i.title.toLowerCase().includes(q),
)
.map((i) => ({
id: i.id,
label: i.identifier,
type: "issue" as const,
description: i.title,
status: i.status as IssueStatus,
}));
return [...allItem, ...memberItems, ...agentItems, ...issueItems].slice(0, 10);
},
render: () => {
let renderer: ReactRenderer<MentionListRef> | null = null;
let popup: HTMLDivElement | null = null;
return {
onStart: (props: SuggestionProps<MentionItem>) => {
renderer = new ReactRenderer(MentionList, {
props: { items: props.items, command: props.command },
editor: props.editor,
});
popup = document.createElement("div");
popup.style.position = "fixed";
popup.style.zIndex = "50";
popup.appendChild(renderer.element);
document.body.appendChild(popup);
updatePosition(popup, props.clientRect);
},
onUpdate: (props: SuggestionProps<MentionItem>) => {
renderer?.updateProps({
items: props.items,
command: props.command,
});
if (popup) updatePosition(popup, props.clientRect);
},
onKeyDown: (props: { event: KeyboardEvent }) => {
if (props.event.key === "Escape") {
cleanup();
return true;
}
return renderer?.ref?.onKeyDown(props) ?? false;
},
onExit: () => {
cleanup();
},
};
function updatePosition(
el: HTMLDivElement,
clientRect: (() => DOMRect | null) | null | undefined,
) {
if (!clientRect) return;
const virtualEl = {
getBoundingClientRect: () => clientRect() ?? new DOMRect(),
};
computePosition(virtualEl, el, {
placement: "bottom-start",
strategy: "fixed",
middleware: [offset(4), flip(), shift({ padding: 8 })],
}).then(({ x, y }) => {
el.style.left = `${x}px`;
el.style.top = `${y}px`;
});
}
function cleanup() {
renderer?.destroy();
renderer = null;
popup?.remove();
popup = null;
}
},
};
}

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

@@ -0,0 +1,18 @@
/* Title editor: minimal ProseMirror for single-line titles */
.title-editor.ProseMirror {
outline: none;
}
.title-editor.ProseMirror p {
margin: 0;
}
/* Placeholder */
.title-editor .is-editor-empty:first-child::before {
content: attr(data-placeholder);
float: left;
color: var(--muted-foreground);
pointer-events: none;
height: 0;
}

View File

@@ -0,0 +1,143 @@
"use client";
import { forwardRef, useEffect, useImperativeHandle, useRef } from "react";
import { useEditor, EditorContent } from "@tiptap/react";
import { Extension } from "@tiptap/core";
import { Document } from "@tiptap/extension-document";
import { Paragraph } from "@tiptap/extension-paragraph";
import { Text } from "@tiptap/extension-text";
import Placeholder from "@tiptap/extension-placeholder";
import { cn } from "@/lib/utils";
import "./title-editor.css";
// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------
interface TitleEditorProps {
defaultValue?: string;
placeholder?: string;
className?: string;
autoFocus?: boolean;
onSubmit?: () => void;
onBlur?: (value: string) => void;
onChange?: (value: string) => void;
}
interface TitleEditorRef {
getText: () => string;
focus: () => void;
}
// ---------------------------------------------------------------------------
// Single-paragraph document — prevents Enter from creating new lines
// ---------------------------------------------------------------------------
const SingleLineDocument = Document.extend({
content: "paragraph",
});
// ---------------------------------------------------------------------------
// Keyboard shortcuts: Enter → submit, Escape → blur
// ---------------------------------------------------------------------------
function createTitleKeymap(opts: {
onSubmitRef: React.RefObject<(() => void) | undefined>;
}) {
return Extension.create({
name: "titleKeymap",
addKeyboardShortcuts() {
return {
Enter: ({ editor }) => {
opts.onSubmitRef.current?.();
editor.commands.blur();
return true;
},
"Shift-Enter": () => true, // swallow — no line breaks
Escape: ({ editor }) => {
editor.commands.blur();
return true;
},
};
},
});
}
// ---------------------------------------------------------------------------
// Component
// ---------------------------------------------------------------------------
const TitleEditor = forwardRef<TitleEditorRef, TitleEditorProps>(
function TitleEditor(
{
defaultValue = "",
placeholder: placeholderText = "",
className,
autoFocus = false,
onSubmit,
onBlur,
onChange,
},
ref,
) {
const onSubmitRef = useRef(onSubmit);
const onBlurRef = useRef(onBlur);
const onChangeRef = useRef(onChange);
onSubmitRef.current = onSubmit;
onBlurRef.current = onBlur;
onChangeRef.current = onChange;
const editor = useEditor({
immediatelyRender: false,
content: `<p>${defaultValue}</p>`,
extensions: [
SingleLineDocument,
Paragraph,
Text,
Placeholder.configure({
placeholder: placeholderText,
showOnlyCurrent: false,
}),
createTitleKeymap({ onSubmitRef }),
],
editorProps: {
attributes: {
class: cn("title-editor outline-none", className),
role: "textbox",
"aria-multiline": "false",
"aria-label": placeholderText || "Title",
},
},
onUpdate: ({ editor: ed }) => {
onChangeRef.current?.(ed.getText());
},
onBlur: ({ editor: ed }) => {
onBlurRef.current?.(ed.getText());
},
});
// Auto-focus after mount — delay to wait for Dialog open animation
useEffect(() => {
if (autoFocus && editor) {
const timer = setTimeout(() => {
editor.commands.focus("end");
}, 50);
return () => clearTimeout(timer);
}
}, [autoFocus, editor]);
useImperativeHandle(ref, () => ({
getText: () => editor?.getText() ?? "",
focus: () => {
editor?.commands.focus("end");
},
}));
if (!editor) return null;
return <EditorContent editor={editor} />;
},
);
export { TitleEditor, type TitleEditorProps, type TitleEditorRef };

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 });
}
},
@@ -88,9 +90,17 @@ export const useInboxStore = create<InboxState>((set, get) => ({
items: s.items.map((i) => (i.id === id ? { ...i, read: true } : i)),
})),
archive: (id) =>
set((s) => ({
items: s.items.map((i) => (i.id === id ? { ...i, archived: true } : i)),
})),
set((s) => {
const target = s.items.find((i) => i.id === id);
const issueId = target?.issue_id;
return {
items: s.items.map((i) =>
i.id === id || (issueId && i.issue_id === issueId)
? { ...i, archived: true }
: i,
),
};
}),
markAllRead: () =>
set((s) => ({
items: s.items.map((i) => (!i.archived ? { ...i, read: true } : i)),

View File

@@ -0,0 +1,622 @@
"use client";
import { useState, useEffect, useCallback, useRef } from "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, 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 ─────────────────────────────────────────────────
/** A unified timeline entry: tool calls, thinking, text, and errors in chronological order. */
interface TimelineItem {
seq: number;
type: "tool_use" | "tool_result" | "thinking" | "text" | "error";
tool?: string;
content?: string;
input?: Record<string, unknown>;
output?: string;
}
function formatElapsed(startedAt: string): string {
const elapsed = Date.now() - new Date(startedAt).getTime();
const seconds = Math.floor(elapsed / 1000);
if (seconds < 60) return `${seconds}s`;
const minutes = Math.floor(seconds / 60);
const secs = seconds % 60;
return `${minutes}m ${secs}s`;
}
function formatDuration(start: string, end: string): string {
const ms = new Date(end).getTime() - new Date(start).getTime();
const seconds = Math.floor(ms / 1000);
if (seconds < 60) return `${seconds}s`;
const minutes = Math.floor(seconds / 60);
const secs = seconds % 60;
return `${minutes}m ${secs}s`;
}
function shortenPath(p: string): string {
const parts = p.split("/");
if (parts.length <= 3) return p;
return ".../" + parts.slice(-2).join("/");
}
function getToolSummary(item: TimelineItem): string {
if (!item.input) return "";
const inp = item.input as Record<string, string>;
// WebSearch / web search
if (inp.query) return inp.query;
// File operations
if (inp.file_path) return shortenPath(inp.file_path);
if (inp.path) return shortenPath(inp.path);
if (inp.pattern) return inp.pattern;
// Bash
if (inp.description) return String(inp.description);
if (inp.command) {
const cmd = String(inp.command);
return cmd.length > 100 ? cmd.slice(0, 100) + "..." : cmd;
}
// Agent
if (inp.prompt) {
const p = String(inp.prompt);
return p.length > 100 ? p.slice(0, 100) + "..." : p;
}
// Skill
if (inp.skill) return String(inp.skill);
// Fallback: show first string value
for (const v of Object.values(inp)) {
if (typeof v === "string" && v.length > 0 && v.length < 120) return v;
}
return "";
}
/** Build a chronologically ordered timeline from raw messages. */
function buildTimeline(msgs: TaskMessagePayload[]): TimelineItem[] {
const items: TimelineItem[] = [];
for (const msg of msgs) {
items.push({
seq: msg.seq,
type: msg.type,
tool: msg.tool,
content: msg.content ? redactSecrets(msg.content) : msg.content,
input: msg.input,
output: msg.output ? redactSecrets(msg.output) : msg.output,
});
}
return items.sort((a, b) => a.seq - b.seq);
}
// ─── AgentLiveCard (real-time view) ────────────────────────────────────────
interface AgentLiveCardProps {
issueId: string;
agentName?: string;
/** Scroll container ref — needed for sticky sentinel detection. */
scrollContainerRef?: React.RefObject<HTMLDivElement | null>;
}
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(() => {
let cancelled = false;
api.getActiveTaskForIssue(issueId).then(({ task }) => {
if (!cancelled) {
setActiveTask(task);
if (task) {
api.listTaskMessages(task.id).then((msgs) => {
if (!cancelled) {
const timeline = buildTimeline(msgs);
setItems(timeline);
for (const m of msgs) seenSeqs.current.add(`${m.task_id}:${m.seq}`);
}
}).catch(console.error);
}
}
}).catch(console.error);
return () => { cancelled = true; };
}, [issueId]);
// Handle real-time task messages
useWSEvent(
"task:message",
useCallback((payload: unknown) => {
const msg = payload as TaskMessagePayload;
if (msg.issue_id !== issueId) return;
const key = `${msg.task_id}:${msg.seq}`;
if (seenSeqs.current.has(key)) return;
seenSeqs.current.add(key);
setItems((prev) => {
const item: TimelineItem = {
seq: msg.seq,
type: msg.type,
tool: msg.tool,
content: msg.content,
input: msg.input,
output: msg.output,
};
const next = [...prev, item];
next.sort((a, b) => a.seq - b.seq);
return next;
});
}, [issueId]),
);
// Handle task completion/failure
useWSEvent(
"task:completed",
useCallback((payload: unknown) => {
const p = payload as TaskCompletedPayload;
if (p.issue_id !== issueId) return;
setActiveTask(null);
setItems([]);
seenSeqs.current.clear();
setCancelling(false);
}, [issueId]),
);
useWSEvent(
"task:failed",
useCallback((payload: unknown) => {
const p = payload as TaskFailedPayload;
if (p.issue_id !== issueId) return;
setActiveTask(null);
setItems([]);
seenSeqs.current.clear();
setCancelling(false);
}, [issueId]),
);
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(console.error);
}, [issueId, activeTask]),
);
// Elapsed time
useEffect(() => {
if (!activeTask?.started_at && !activeTask?.dispatched_at) return;
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) {
scrollRef.current.scrollTop = scrollRef.current.scrollHeight;
}
}, [items, autoScroll]);
const handleScroll = useCallback(() => {
if (!scrollRef.current) return;
const { scrollTop, scrollHeight, clientHeight } = scrollRef.current;
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 (
<>
{/* 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",
)}
>
{/* 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={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"
>
<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>
{/* 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>
</>
);
}
// ─── TaskRunHistory (past execution logs) ──────────────────────────────────
interface TaskRunHistoryProps {
issueId: string;
}
export function TaskRunHistory({ issueId }: TaskRunHistoryProps) {
const [tasks, setTasks] = useState<AgentTask[]>([]);
const [open, setOpen] = useState(false);
useEffect(() => {
api.listTasksByIssue(issueId).then(setTasks).catch(console.error);
}, [issueId]);
// Refresh when a task completes
useWSEvent(
"task:completed",
useCallback((payload: unknown) => {
const p = payload as TaskCompletedPayload;
if (p.issue_id !== issueId) return;
api.listTasksByIssue(issueId).then(setTasks).catch(console.error);
}, [issueId]),
);
useWSEvent(
"task:failed",
useCallback((payload: unknown) => {
const p = payload as TaskFailedPayload;
if (p.issue_id !== issueId) return;
api.listTasksByIssue(issueId).then(setTasks).catch(console.error);
}, [issueId]),
);
// 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 (
<Collapsible open={open} onOpenChange={setOpen}>
<CollapsibleTrigger className="flex w-full items-center gap-1.5 text-xs text-muted-foreground hover:text-foreground transition-colors py-1">
<ChevronRight className={cn("h-3 w-3 transition-transform", open && "rotate-90")} />
<Clock className="h-3 w-3" />
<span>Execution history ({completedTasks.length})</span>
</CollapsibleTrigger>
<CollapsibleContent>
<div className="mt-1 space-y-2">
{completedTasks.map((task) => (
<TaskRunEntry key={task.id} task={task} />
))}
</div>
</CollapsibleContent>
</Collapsible>
);
}
function TaskRunEntry({ task }: { task: AgentTask }) {
const [open, setOpen] = useState(false);
const [items, setItems] = useState<TimelineItem[] | null>(null);
const loadMessages = useCallback(() => {
if (items !== null) return; // already loaded
api.listTaskMessages(task.id).then((msgs) => {
setItems(buildTimeline(msgs));
}).catch((e) => {
console.error(e);
setItems([]);
});
}, [task.id, items]);
useEffect(() => {
if (open) loadMessages();
}, [open, loadMessages]);
const duration = task.started_at && task.completed_at
? formatDuration(task.started_at, task.completed_at)
: null;
return (
<Collapsible open={open} onOpenChange={setOpen}>
<CollapsibleTrigger className="flex w-full items-center gap-2 rounded px-2 py-1.5 text-xs hover:bg-accent/30 transition-colors border border-transparent hover:border-border">
<ChevronRight className={cn("h-3 w-3 shrink-0 text-muted-foreground transition-transform", open && "rotate-90")} />
{task.status === "completed" ? (
<CheckCircle2 className="h-3.5 w-3.5 shrink-0 text-success" />
) : (
<XCircle className="h-3.5 w-3.5 shrink-0 text-destructive" />
)}
<span className="text-muted-foreground">
{new Date(task.created_at).toLocaleString(undefined, { month: "short", day: "numeric", hour: "2-digit", minute: "2-digit" })}
</span>
{duration && <span className="text-muted-foreground">{duration}</span>}
<span className={cn("ml-auto capitalize", task.status === "completed" ? "text-success" : "text-destructive")}>
{task.status}
</span>
</CollapsibleTrigger>
<CollapsibleContent>
<div className="ml-5 mt-1 max-h-64 overflow-y-auto rounded border bg-muted/30 px-3 py-2 space-y-0.5">
{items === null ? (
<div className="flex items-center gap-2 text-xs text-muted-foreground py-2">
<Loader2 className="h-3 w-3 animate-spin" />
Loading...
</div>
) : items.length === 0 ? (
<p className="text-xs text-muted-foreground py-2">No execution data recorded.</p>
) : (
items.map((item, idx) => (
<TimelineRow key={`${item.seq}-${idx}`} item={item} />
))
)}
</div>
</CollapsibleContent>
</Collapsible>
);
}
// ─── Shared timeline row rendering ──────────────────────────────────────────
function TimelineRow({ item }: { item: TimelineItem }) {
switch (item.type) {
case "tool_use":
return <ToolCallRow item={item} />;
case "tool_result":
return <ToolResultRow item={item} />;
case "thinking":
return <ThinkingRow item={item} />;
case "text":
return <TextRow item={item} />;
case "error":
return <ErrorRow item={item} />;
default:
return null;
}
}
function ToolCallRow({ item }: { item: TimelineItem }) {
const [open, setOpen] = useState(false);
const summary = getToolSummary(item);
const hasInput = item.input && Object.keys(item.input).length > 0;
return (
<Collapsible open={open} onOpenChange={setOpen}>
<CollapsibleTrigger className="flex w-full items-center gap-1.5 rounded px-1 -mx-1 py-0.5 text-xs hover:bg-accent/30 transition-colors">
<ChevronRight
className={cn(
"h-3 w-3 shrink-0 text-muted-foreground transition-transform",
open && "rotate-90",
!hasInput && "invisible",
)}
/>
<span className="font-medium text-foreground shrink-0">{item.tool}</span>
{summary && <span className="truncate text-muted-foreground">{summary}</span>}
</CollapsibleTrigger>
{hasInput && (
<CollapsibleContent>
<pre className="ml-[18px] mt-0.5 max-h-32 overflow-auto rounded bg-muted/50 p-2 text-[11px] text-muted-foreground whitespace-pre-wrap break-all">
{redactSecrets(JSON.stringify(item.input, null, 2))}
</pre>
</CollapsibleContent>
)}
</Collapsible>
);
}
function ToolResultRow({ item }: { item: TimelineItem }) {
const [open, setOpen] = useState(false);
const output = item.output ?? "";
if (!output) return null;
const preview = output.length > 120 ? output.slice(0, 120) + "..." : output;
return (
<Collapsible open={open} onOpenChange={setOpen}>
<CollapsibleTrigger className="flex w-full items-start gap-1.5 rounded px-1 -mx-1 py-0.5 text-xs hover:bg-accent/30 transition-colors">
<ChevronRight
className={cn("h-3 w-3 shrink-0 text-muted-foreground transition-transform mt-0.5", open && "rotate-90")}
/>
<span className="text-muted-foreground/70 truncate">
{item.tool ? `${item.tool} result: ` : "result: "}{preview}
</span>
</CollapsibleTrigger>
<CollapsibleContent>
<pre className="ml-[18px] mt-0.5 max-h-40 overflow-auto rounded bg-muted/50 p-2 text-[11px] text-muted-foreground whitespace-pre-wrap break-all">
{output.length > 4000 ? output.slice(0, 4000) + "\n... (truncated)" : output}
</pre>
</CollapsibleContent>
</Collapsible>
);
}
function ThinkingRow({ item }: { item: TimelineItem }) {
const [open, setOpen] = useState(false);
const text = item.content ?? "";
if (!text) return null;
const preview = text.length > 150 ? text.slice(0, 150) + "..." : text;
return (
<Collapsible open={open} onOpenChange={setOpen}>
<CollapsibleTrigger className="flex w-full items-start gap-1.5 rounded px-1 -mx-1 py-0.5 text-xs hover:bg-accent/30 transition-colors">
<Brain className="h-3 w-3 shrink-0 text-info/60 mt-0.5" />
<span className="text-muted-foreground italic truncate">{preview}</span>
</CollapsibleTrigger>
<CollapsibleContent>
<pre className="ml-[18px] mt-0.5 max-h-40 overflow-auto rounded bg-info/5 p-2 text-[11px] text-muted-foreground whitespace-pre-wrap break-words">
{text}
</pre>
</CollapsibleContent>
</Collapsible>
);
}
function TextRow({ item }: { item: TimelineItem }) {
const text = item.content ?? "";
if (!text.trim()) return null;
const lines = text.trim().split("\n").filter(Boolean);
const last = lines[lines.length - 1] ?? "";
if (!last) return null;
return (
<div className="flex items-start gap-1.5 px-1 -mx-1 py-0.5 text-xs">
<span className="h-3 w-3 shrink-0" />
<span className="text-muted-foreground/60 truncate">{last}</span>
</div>
);
}
function ErrorRow({ item }: { item: TimelineItem }) {
return (
<div className="flex items-start gap-1.5 px-1 -mx-1 py-0.5 text-xs">
<AlertCircle className="h-3 w-3 shrink-0 text-destructive mt-0.5" />
<span className="text-destructive">{item.content}</span>
</div>
);
}

View File

@@ -1,7 +1,7 @@
"use client";
import { useState } from "react";
import { X, Trash2, Bot, UserMinus } from "lucide-react";
import { X, Trash2 } from "lucide-react";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import {
@@ -21,12 +21,12 @@ import {
} from "@/components/ui/popover";
import type { UpdateIssueRequest } from "@/shared/types";
import { ALL_STATUSES, STATUS_CONFIG, PRIORITY_ORDER, PRIORITY_CONFIG } from "@/features/issues/config";
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);
@@ -43,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);
@@ -55,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);
}
@@ -74,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);
@@ -149,8 +149,10 @@ export function BatchActionToolbar() {
}}
className="flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-sm hover:bg-accent transition-colors"
>
<PriorityIcon priority={p} />
<span>{cfg.label}</span>
<span className={`inline-flex items-center gap-1 rounded px-1.5 py-0.5 text-xs font-medium ${cfg.badgeBg} ${cfg.badgeText}`}>
<PriorityIcon priority={p} className="h-3 w-3" inheritColor />
{cfg.label}
</span>
</button>
);
})}
@@ -158,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 */}
@@ -204,117 +210,3 @@ export function BatchActionToolbar() {
);
}
function BatchAssigneePicker({
open,
onOpenChange,
onUpdate,
loading,
}: {
open: boolean;
onOpenChange: (v: boolean) => void;
onUpdate: (updates: UpdateIssueRequest) => void;
loading: boolean;
}) {
const [filter, setFilter] = useState("");
const members = useWorkspaceStore((s) => s.members);
const agents = useWorkspaceStore((s) => s.agents);
const { getActorInitials } = useActorName();
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) => (
<button
key={a.id}
type="button"
onClick={() => {
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 hover:bg-accent transition-colors"
>
<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>
<span>{a.name}</span>
</button>
))}
</div>
)}
</div>
</PopoverContent>
</Popover>
);
}

View File

@@ -1,6 +1,6 @@
"use client";
import { useCallback } from "react";
import { useCallback, memo } from "react";
import Link from "next/link";
import { useSortable } from "@dnd-kit/sortable";
import { CSS } from "@dnd-kit/utilities";
@@ -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", {
@@ -35,24 +36,26 @@ function PickerWrapper({ children }: { children: React.ReactNode }) {
);
}
export function BoardCardContent({
export const BoardCardContent = memo(function BoardCardContent({
issue,
editable = false,
}: {
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(
(updates: Partial<UpdateIssueRequest>) => {
const prev = { ...issue };
useIssueStore.getState().updateIssue(issue.id, updates);
api.updateIssue(issue.id, updates).catch(() => {
useIssueStore.getState().updateIssue(issue.id, prev);
toast.error("Failed to update issue");
});
},
[issue.id]
[issue],
);
const showPriority = storeProperties.priority;
@@ -62,37 +65,12 @@ export function BoardCardContent({
const showBottom = showAssignee || showDueDate;
return (
<div className="rounded-lg border bg-card p-3.5 shadow-[0_1px_2px_0_rgba(0,0,0,0.03)] transition-shadow group-hover:shadow-md">
{/* Priority */}
{showPriority &&
(editable ? (
<PickerWrapper>
<PriorityPicker
priority={issue.priority}
onUpdate={handleUpdate}
trigger={
<>
<PriorityIcon priority={issue.priority} />
<span className={`text-xs font-medium ${priorityCfg.color}`}>
{priorityCfg.label}
</span>
</>
}
/>
</PickerWrapper>
) : (
<div className="flex items-center gap-1.5">
<PriorityIcon priority={issue.priority} />
<span className={`text-xs font-medium ${priorityCfg.color}`}>
{priorityCfg.label}
</span>
</div>
))}
<div className="rounded-lg border bg-card p-3.5 shadow-[0_1px_2px_0_rgba(0,0,0,0.03)] transition-shadow group-hover:shadow-sm">
{/* Row 1: Identifier */}
<p className="text-xs text-muted-foreground">{issue.identifier}</p>
{/* Title */}
<p
className={`text-sm font-medium leading-snug line-clamp-2 ${showPriority ? "mt-2" : ""}`}
>
{/* Row 2: Title */}
<p className="mt-1 text-sm font-medium leading-snug line-clamp-2">
{issue.title}
</p>
@@ -103,73 +81,94 @@ export function BoardCardContent({
</p>
)}
{/* Bottom: assignee + due date */}
{showBottom && (
<div className="mt-3 flex items-center justify-between">
<div className="flex items-center">
{showAssignee &&
(editable ? (
<PickerWrapper>
<AssigneePicker
assigneeType={issue.assignee_type}
assigneeId={issue.assignee_id}
onUpdate={handleUpdate}
trigger={
<ActorAvatar
actorType={issue.assignee_type!}
actorId={issue.assignee_id!}
size={22}
/>
}
/>
</PickerWrapper>
) : (
<ActorAvatar
actorType={issue.assignee_type!}
actorId={issue.assignee_id!}
size={22}
/>
))}
</div>
{showDueDate &&
{/* Row 3: Assignee, priority badge, due date */}
{(showAssignee || showPriority || showDueDate) && (
<div className="mt-3 flex items-center gap-2">
{showAssignee &&
(editable ? (
<PickerWrapper>
<DueDatePicker
dueDate={issue.due_date}
<AssigneePicker
assigneeType={issue.assignee_type}
assigneeId={issue.assignee_id}
onUpdate={handleUpdate}
trigger={
<span
className={`flex items-center gap-1 text-xs ${
new Date(issue.due_date!) < new Date()
? "text-destructive"
: "text-muted-foreground"
}`}
>
<CalendarDays className="size-3" />
{formatDate(issue.due_date!)}
<ActorAvatar
actorType={issue.assignee_type!}
actorId={issue.assignee_id!}
size={22}
/>
}
/>
</PickerWrapper>
) : (
<ActorAvatar
actorType={issue.assignee_type!}
actorId={issue.assignee_id!}
size={22}
/>
))}
{showPriority &&
(editable ? (
<PickerWrapper>
<PriorityPicker
priority={issue.priority}
onUpdate={handleUpdate}
trigger={
<span className={`inline-flex items-center gap-1 rounded px-1.5 py-0.5 text-xs font-medium ${priorityCfg.badgeBg} ${priorityCfg.badgeText}`}>
<PriorityIcon priority={issue.priority} className="h-3 w-3" inheritColor />
{priorityCfg.label}
</span>
}
/>
</PickerWrapper>
) : (
<span
className={`flex items-center gap-1 text-xs ${
new Date(issue.due_date!) < new Date()
? "text-destructive"
: "text-muted-foreground"
}`}
>
<CalendarDays className="size-3" />
{formatDate(issue.due_date!)}
<span className={`inline-flex items-center gap-1 rounded px-1.5 py-0.5 text-xs font-medium ${priorityCfg.badgeBg} ${priorityCfg.badgeText}`}>
<PriorityIcon priority={issue.priority} className="h-3 w-3" inheritColor />
{priorityCfg.label}
</span>
))}
{showDueDate && (
<div className="ml-auto">
{editable ? (
<PickerWrapper>
<DueDatePicker
dueDate={issue.due_date}
onUpdate={handleUpdate}
trigger={
<span
className={`flex items-center gap-1 text-xs ${
new Date(issue.due_date!) < new Date()
? "text-destructive"
: "text-muted-foreground"
}`}
>
<CalendarDays className="size-3" />
{formatDate(issue.due_date!)}
</span>
}
/>
</PickerWrapper>
) : (
<span
className={`flex items-center gap-1 text-xs ${
new Date(issue.due_date!) < new Date()
? "text-destructive"
: "text-muted-foreground"
}`}
>
<CalendarDays className="size-3" />
{formatDate(issue.due_date!)}
</span>
)}
</div>
)}
</div>
)}
</div>
);
}
});
export function DraggableBoardCard({ issue }: { issue: Issue }) {
export const DraggableBoardCard = memo(function DraggableBoardCard({ issue }: { issue: Issue }) {
const {
attributes,
listeners,
@@ -203,4 +202,4 @@ export function DraggableBoardCard({ issue }: { issue: Issue }) {
</Link>
</div>
);
}
});

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),
@@ -43,13 +44,15 @@ export function BoardColumn({
);
return (
<div className="flex w-[280px] shrink-0 flex-col rounded-xl bg-muted/40 p-2">
<div className={`flex w-[280px] shrink-0 flex-col rounded-xl ${cfg.columnBg} p-2`}>
<div className="mb-2 flex items-center justify-between px-1.5">
{/* Left: icon + label + count */}
{/* Left: status badge + count */}
<div className="flex items-center gap-2">
<StatusIcon status={status} className="h-3.5 w-3.5" />
<span className="text-sm font-medium">{cfg.label}</span>
<span className="flex h-5 min-w-5 items-center justify-center rounded-full bg-muted px-1.5 text-xs text-muted-foreground">
<span className={`inline-flex items-center gap-1.5 rounded px-2 py-0.5 text-xs font-semibold ${cfg.badgeBg} ${cfg.badgeText}`}>
<StatusIcon status={status} className="h-3 w-3" inheritColor />
{cfg.label}
</span>
<span className="text-xs text-muted-foreground">
{issues.length}
</span>
</div>
@@ -65,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

@@ -1,7 +1,7 @@
"use client";
import { useState } from "react";
import { MoreHorizontal } from "lucide-react";
import { useRef, useState } from "react";
import { ChevronRight, Copy, MoreHorizontal, Pencil, Trash2 } from "lucide-react";
import { toast } from "sonner";
import { Card } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
@@ -13,10 +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 { Markdown } from "@/components/markdown";
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 { 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";
@@ -25,12 +41,53 @@ import type { TimelineEntry } from "@/shared/types";
// ---------------------------------------------------------------------------
interface CommentCardProps {
issueId: string;
entry: TimelineEntry;
allReplies: Map<string, TimelineEntry[]>;
currentUserId?: string;
onReply: (parentId: string, content: string) => Promise<void>;
onReply: (parentId: string, content: string, attachmentIds?: string[]) => Promise<void>;
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>
);
}
// ---------------------------------------------------------------------------
@@ -38,45 +95,60 @@ interface CommentCardProps {
// ---------------------------------------------------------------------------
function CommentRow({
issueId,
entry,
currentUserId,
onEdit,
onDelete,
onToggleReaction,
}: {
issueId: string;
entry: TimelineEntry;
currentUserId?: string;
onEdit: (commentId: string, content: string) => Promise<void>;
onDelete: (commentId: string) => void;
onToggleReaction: (commentId: string, emoji: string) => void;
}) {
const { getActorName } = useActorName();
const [editing, setEditing] = useState(false);
const [editContent, setEditContent] = useState("");
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 = () => {
setEditContent(entry.content ?? "");
cancelledRef.current = false;
setEditing(true);
};
const cancelEdit = () => {
cancelledRef.current = true;
setEditing(false);
setEditContent("");
};
const saveEdit = async () => {
const trimmed = editContent.trim();
if (!trimmed) return;
if (cancelledRef.current) return;
const trimmed = editEditorRef.current
?.getMarkdown()
?.replace(/(\n\s*)+$/, "")
.trim();
if (!trimmed || trimmed === (entry.content ?? "").trim()) {
setEditing(false);
return;
}
try {
await onEdit(entry.id, trimmed);
setEditing(false);
setEditContent("");
} catch {
toast.error("Failed to update comment");
}
};
const reactions = entry.reactions ?? [];
return (
<div className={`py-3${isTemp ? " opacity-60" : ""}`}>
<div className="flex items-center gap-2.5">
@@ -97,48 +169,94 @@ function CommentRow({
</TooltipContent>
</Tooltip>
{!isTemp && isOwn && (
{!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={startEdit}>Edit</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem onClick={() => onDelete(entry.id)} variant="destructive">
Delete
<DropdownMenuItem onClick={() => {
copyMarkdown(entry.content ?? "");
toast.success("Copied");
}}>
<Copy className="h-3.5 w-3.5" />
Copy
</DropdownMenuItem>
{isOwn && (
<>
<DropdownMenuSeparator />
<DropdownMenuItem onClick={startEdit}>
<Pencil className="h-3.5 w-3.5" />
Edit
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem onClick={() => setConfirmDelete(true)} variant="destructive">
<Trash2 className="h-3.5 w-3.5" />
Delete
</DropdownMenuItem>
</>
)}
</DropdownMenuContent>
</DropdownMenu>
<DeleteCommentDialog
open={confirmDelete}
onOpenChange={setConfirmDelete}
onConfirm={() => onDelete(entry.id)}
/>
</div>
)}
</div>
{editing ? (
<form
onSubmit={(e) => { e.preventDefault(); saveEdit(); }}
className="mt-2 pl-8"
<div
className="mt-1.5 pl-8"
onKeyDown={(e) => { if (e.key === "Escape") cancelEdit(); }}
>
<input
autoFocus
value={editContent}
onChange={(e) => setEditContent(e.target.value)}
aria-label="Edit comment"
className="w-full text-sm bg-transparent border-b border-border outline-none py-1"
onKeyDown={(e) => { if (e.key === "Escape") cancelEdit(); }}
/>
<div className="flex gap-2 mt-1.5">
<Button size="sm" type="submit">Save</Button>
<Button size="sm" variant="ghost" type="button" onClick={cancelEdit}>Cancel</Button>
<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 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>
</form>
) : (
<div className="mt-1.5 pl-8 text-sm leading-relaxed text-foreground/85">
<Markdown mode="minimal">{entry.content ?? ""}</Markdown>
</div>
) : (
<>
<div className="mt-1.5 pl-8 text-sm leading-relaxed text-foreground/85">
<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"
/>
)}
</>
)}
</div>
);
@@ -149,13 +267,55 @@ function CommentRow({
// ---------------------------------------------------------------------------
function CommentCard({
issueId,
entry,
allReplies,
currentUserId,
onReply,
onEdit,
onDelete,
onToggleReaction,
highlightedCommentId,
}: CommentCardProps) {
const { getActorName } = useActorName();
const { uploadWithToast } = useFileUpload();
const [open, setOpen] = useState(true);
const [editing, setEditing] = useState(false);
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 || trimmed === (entry.content ?? "").trim()) {
setEditing(false);
return;
}
try {
await onEdit(entry.id, trimmed);
setEditing(false);
} catch {
toast.error("Failed to update comment");
}
};
// Collect all nested replies recursively into a flat list
const allNestedReplies: TimelineEntry[] = [];
const collectReplies = (parentId: string) => {
@@ -167,40 +327,171 @@ function CommentCard({
};
collectReplies(entry.id);
const replyCount = allNestedReplies.length;
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${entry.id.startsWith("temp-") ? " opacity-60" : ""}`}>
{/* Parent comment */}
<div className="px-4">
<CommentRow
entry={entry}
currentUserId={currentUserId}
onEdit={onEdit}
onDelete={onDelete}
/>
</div>
<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">
<div className="flex items-center gap-2.5">
<CollapsibleTrigger className="shrink-0 rounded p-0.5 text-muted-foreground hover:bg-muted hover:text-foreground transition-colors">
<ChevronRight className={cn("h-3.5 w-3.5 transition-transform", open && "rotate-90")} />
</CollapsibleTrigger>
<ActorAvatar actorType={entry.actor_type} actorId={entry.actor_id} size={24} />
<span className="shrink-0 text-sm font-medium">
{getActorName(entry.actor_type, entry.actor_id)}
</span>
<Tooltip>
<TooltipTrigger
render={
<span className="shrink-0 text-xs text-muted-foreground cursor-default">
{timeAgo(entry.created_at)}
</span>
}
/>
<TooltipContent side="top">
{new Date(entry.created_at).toLocaleString()}
</TooltipContent>
</Tooltip>
{/* Replies — flat, separated by border */}
{allNestedReplies.map((reply) => (
<div key={reply.id} className="border-t border-border/50 px-4">
<CommentRow
entry={reply}
currentUserId={currentUserId}
onEdit={onEdit}
onDelete={onDelete}
/>
{!open && contentPreview && (
<span className="min-w-0 flex-1 truncate text-xs text-muted-foreground">
{contentPreview}
</span>
)}
{!open && replyCount > 0 && (
<span className="shrink-0 text-xs text-muted-foreground">
{replyCount} {replyCount === 1 ? "reply" : "replies"}
</span>
)}
{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="text-muted-foreground">
<MoreHorizontal className="h-4 w-4" />
</Button>
}
/>
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={() => {
copyMarkdown(entry.content ?? "");
toast.success("Copied");
}}>
<Copy className="h-3.5 w-3.5" />
Copy
</DropdownMenuItem>
{isOwn && (
<>
<DropdownMenuSeparator />
<DropdownMenuItem onClick={startEdit}>
<Pencil className="h-3.5 w-3.5" />
Edit
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem onClick={() => setConfirmDelete(true)} variant="destructive">
<Trash2 className="h-3.5 w-3.5" />
Delete
</DropdownMenuItem>
</>
)}
</DropdownMenuContent>
</DropdownMenu>
<DeleteCommentDialog
open={confirmDelete}
onOpenChange={setConfirmDelete}
onConfirm={() => onDelete(entry.id)}
hasReplies
/>
</div>
)}
</div>
</div>
))}
{/* Reply input — always visible at bottom */}
<div className="border-t border-border/50 px-4 py-2.5">
<ReplyInput
placeholder="Leave a reply..."
size="sm"
avatarType="member"
avatarId={currentUserId ?? ""}
onSubmit={(content) => onReply(entry.id, content)}
/>
</div>
{/* Collapsible body */}
<CollapsibleContent>
{/* Parent comment body */}
<div className="px-4 pb-3">
{editing ? (
<div
className="pl-10"
onKeyDown={(e) => { if (e.key === "Escape") cancelEdit(); }}
>
<div className="max-h-48 overflow-y-auto text-sm leading-relaxed">
<ContentEditor
ref={editEditorRef}
defaultValue={entry.content ?? ""}
placeholder="Edit comment..."
onSubmit={saveEdit}
debounceMs={100}
/>
</div>
<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">
<ContentEditor defaultValue={entry.content ?? ""} editable={false} />
</div>
{!isTemp && (
<ReactionBar
reactions={reactions}
currentUserId={currentUserId}
onToggle={(emoji) => onToggleReaction(entry.id, emoji)}
className="mt-1.5 pl-10"
/>
)}
</>
)}
</div>
{/* Replies */}
{allNestedReplies.map((reply) => (
<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}
onDelete={onDelete}
onToggleReaction={onToggleReaction}
/>
</div>
))}
{/* Reply input */}
<div className="border-t border-border/50 px-4 py-2.5">
<ReplyInput
issueId={issueId}
placeholder="Leave a reply..."
size="sm"
avatarType="member"
avatarId={currentUserId ?? ""}
onSubmit={(content, attachmentIds) => onReply(entry.id, content, attachmentIds)}
/>
</div>
</CollapsibleContent>
</Collapsible>
</Card>
);
}

View File

@@ -1,18 +1,26 @@
"use client";
import { useRef, useState } from "react";
import { ArrowUp } 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 {
onSubmit: (content: string) => Promise<void>;
issueId: string;
onSubmit: (content: string, attachmentIds?: string[]) => Promise<void>;
}
function CommentInput({ onSubmit }: CommentInputProps) {
const editorRef = useRef<RichTextEditorRef>(null);
function CommentInput({ issueId, onSubmit }: CommentInputProps) {
const editorRef = useRef<ContentEditorRef>(null);
const [isEmpty, setIsEmpty] = useState(true);
const [submitting, setSubmitting] = useState(false);
const { uploadWithToast } = useFileUpload();
const handleUpload = async (file: File) => {
return await uploadWithToast(file, { issueId });
};
const handleSubmit = async () => {
const content = editorRef.current?.getMarkdown()?.replace(/(\n\s*)+$/, "").trim();
@@ -28,23 +36,32 @@ function CommentInput({ 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())}
onSubmit={handleSubmit}
onUploadFile={handleUpload}
debounceMs={100}
/>
</div>
<div className="absolute bottom-1.5 right-1.5">
<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}
>
<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,8 +1,9 @@
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";
export { CommentInput } from "./comment-input";
export { ReplyInput } from "./reply-input";
export { IssueMentionCard } from "./issue-mention-card";

View File

@@ -1,13 +1,13 @@
"use client";
import { useState, useEffect, useCallback, useRef } 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 { Input } from "@/components/ui/input";
import { RichTextEditor } from "@/components/common/rich-text-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,19 +55,23 @@ 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 { Issue, Comment, IssueSubscriber, UpdateIssueRequest, IssueStatus, IssuePriority, TimelineEntry } from "@/shared/types";
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";
import { api } from "@/shared/api";
import { useAuthStore } from "@/features/auth";
import { useWorkspaceStore, useActorName } from "@/features/workspace";
import { useWSEvent } from "@/features/realtime";
import { useIssueStore } from "@/features/issues";
import type { CommentCreatedPayload, CommentUpdatedPayload, CommentDeletedPayload, SubscriberAddedPayload, SubscriberRemovedPayload, ActivityCreatedPayload } from "@/shared/types";
import { useIssueTimeline } from "@/features/issues/hooks/use-issue-timeline";
import { useIssueReactions } from "@/features/issues/hooks/use-issue-reactions";
import { useIssueSubscribers } from "@/features/issues/hooks/use-issue-subscribers";
import { ReactionBar } from "@/components/common/reaction-bar";
import { useFileUpload } from "@/shared/hooks/use-file-upload";
import { timeAgo } from "@/shared/utils";
function shortDate(date: string | null): string {
@@ -124,19 +130,6 @@ function formatActivity(
}
}
function commentToTimelineEntry(c: Comment): TimelineEntry {
return {
type: "comment",
id: c.id,
actor_type: c.author_type,
actor_id: c.author_id,
content: c.content,
parent_id: c.parent_id,
created_at: c.created_at,
updated_at: c.updated_at,
comment_type: c.type,
};
}
// ---------------------------------------------------------------------------
// Property row
@@ -169,168 +162,142 @@ 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,
});
const sidebarRef = usePanelRef();
const [sidebarOpen, setSidebarOpen] = useState(defaultSidebarOpen);
const [issue, setIssue] = useState<Issue | null>(null);
const [timeline, setTimeline] = useState<TimelineEntry[]>([]);
const [subscribers, setSubscribers] = useState<IssueSubscriber[]>([]);
const [loading, setLoading] = useState(true);
const [submitting, setSubmitting] = useState(false);
const [deleting, setDeleting] = useState(false);
const [titleDraft, setTitleDraft] = useState("");
const titleFocusedRef = useRef(false);
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);
// Watch the global issue store for real-time updates from other users/agents
const storeIssue = useIssueStore((s) => s.issues.find((i) => i.id === id));
const wasLoadedRef = useRef(false);
// Single source of truth: read issue directly from global store
const issue = useIssueStore((s) => s.issues.find((i) => i.id === id)) ?? null;
const [issueLoading, setIssueLoading] = useState(!issue);
// If issue isn't in the store yet, fetch and upsert it
useEffect(() => {
if (storeIssue) {
wasLoadedRef.current = true;
setIssue(storeIssue);
if (!titleFocusedRef.current) {
setTitleDraft(storeIssue.title);
}
} else if (wasLoadedRef.current && !loading) {
// Issue was in the store but is now gone (deleted by another user)
setIssue(null);
if (issue) {
setIssueLoading(false);
return;
}
}, [storeIssue, loading]);
useEffect(() => {
wasLoadedRef.current = false;
setIssue(null);
setTitleDraft("");
setTimeline([]);
setSubscribers([]);
setLoading(true);
Promise.all([api.getIssue(id), api.listTimeline(id), api.listIssueSubscribers(id)])
.then(([iss, entries, subs]) => {
setIssue(iss);
setTitleDraft(iss.title);
setTimeline(entries);
setSubscribers(subs);
setIssueLoading(true);
api
.getIssue(id)
.then((iss) => {
useIssueStore.getState().addIssue(iss);
})
.catch(console.error)
.finally(() => setLoading(false));
}, [id]);
.catch((e) => {
console.error(e);
toast.error("Failed to load issue");
})
.finally(() => setIssueLoading(false));
}, [id, !!issue]);
const handleSubmitComment = async (content: string) => {
if (!content.trim() || submitting || !user) return;
const tempId = "temp-" + Date.now();
const tempEntry: TimelineEntry = {
type: "comment",
id: tempId,
actor_type: "member",
actor_id: user.id,
content,
parent_id: null,
created_at: new Date().toISOString(),
updated_at: new Date().toISOString(),
comment_type: "comment",
// Custom hooks — encapsulate timeline, reactions, subscribers
const {
timeline, loading: timelineLoading, submitting, submitComment, submitReply,
editComment, deleteComment, toggleReaction: handleToggleReaction,
} = useIssueTimeline(id, user?.id);
const {
reactions: issueReactions, loading: reactionsLoading,
toggleReaction: handleToggleIssueReaction,
} = useIssueReactions(id, user?.id);
const {
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);
};
setTimeline((prev) => [...prev, tempEntry]);
setSubmitting(true);
try {
const comment = await api.createComment(id, content);
setTimeline((prev) => prev.map((e) => (e.id === tempId ? commentToTimelineEntry(comment) : e)));
} catch {
setTimeline((prev) => prev.filter((e) => e.id !== tempId));
toast.error("Failed to send comment");
} finally {
setSubmitting(false);
}
};
container.addEventListener("scroll", onScroll, { passive: true });
onScroll();
return () => container.removeEventListener("scroll", onScroll);
}, []);
const handleSubmitReply = async (parentId: string, content: string) => {
if (!content.trim() || !user) return;
try {
const comment = await api.createComment(id, content, "comment", parentId);
setTimeline((prev) => {
if (prev.some((e) => e.id === comment.id)) return prev;
return [...prev, commentToTimelineEntry(comment)];
});
} catch {
toast.error("Failed to send reply");
}
};
const handleEditComment = async (commentId: string, content: string) => {
try {
const updated = await api.updateComment(commentId, content);
setTimeline((prev) => prev.map((e) => (e.id === updated.id ? commentToTimelineEntry(updated) : e)));
} catch {
toast.error("Failed to update comment");
}
};
const handleDeleteComment = async (commentId: string) => {
try {
await api.deleteComment(commentId);
setTimeline((prev) => {
const idsToRemove = new Set<string>([commentId]);
// Recursively collect all descendant IDs
let added = true;
while (added) {
added = false;
for (const e of prev) {
if (e.parent_id && idsToRemove.has(e.parent_id) && !idsToRemove.has(e.id)) {
idsToRemove.add(e.id);
added = true;
}
}
}
return prev.filter((e) => !idsToRemove.has(e.id));
});
} catch {
toast.error("Failed to delete comment");
}
};
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>) => {
if (!issue) return;
const prev = issue;
setIssue((curr) => (curr ? ({ ...curr, ...updates } as Issue) : curr));
const prev = { ...issue };
useIssueStore.getState().updateIssue(id, updates);
api.updateIssue(id, updates).catch(() => {
setIssue(prev);
useIssueStore.getState().updateIssue(id, prev);
toast.error("Failed to update issue");
});
},
[issue, id],
);
const descEditorRef = useRef<ContentEditorRef>(null);
const handleDescriptionUpload = useCallback(
(file: File) => uploadWithToast(file, { issueId: id }),
[uploadWithToast, id],
);
const handleDelete = async () => {
setDeleting(true);
try {
await api.deleteIssue(issue!.id);
useIssueStore.getState().removeIssue(issue!.id);
toast.success("Issue deleted");
if (onDelete) onDelete();
else router.push("/issues");
@@ -340,128 +307,53 @@ export function IssueDetail({ issueId, onDelete, defaultSidebarOpen = true, layo
}
};
// Subscriber state
const isSubscribed = subscribers.some(
(s) => s.user_type === "member" && s.user_id === user?.id
);
const toggleSubscriber = async (userId: string, userType: "member" | "agent", currentlySubscribed: boolean) => {
if (!issue) return;
try {
if (currentlySubscribed) {
await api.unsubscribeFromIssue(id, userId, userType);
setSubscribers((prev) => prev.filter((s) => !(s.user_id === userId && s.user_type === userType)));
} else {
await api.subscribeToIssue(id, userId, userType);
setSubscribers((prev) => {
// Deduplicate: WS event may have already added this subscriber
if (prev.some((s) => s.user_id === userId && s.user_type === userType)) return prev;
return [...prev, { issue_id: id, user_type: userType, user_id: userId, reason: "manual" as const, created_at: new Date().toISOString() }];
});
}
} catch {
toast.error("Failed to update subscriber");
}
};
const handleToggleSubscribe = () => {
if (user) toggleSubscriber(user.id, "member", isSubscribed);
};
// Real-time comment updates
useWSEvent(
"comment:created",
useCallback((payload: unknown) => {
const { comment } = payload as CommentCreatedPayload;
if (comment.issue_id !== id) return;
// Skip own comments — already added locally via API response
if (comment.author_type === "member" && comment.author_id === user?.id) return;
setTimeline((prev) => {
if (prev.some((e) => e.id === comment.id)) return prev;
return [...prev, commentToTimelineEntry(comment)];
});
}, [id, user?.id]),
);
useWSEvent(
"comment:updated",
useCallback((payload: unknown) => {
const { comment } = payload as CommentUpdatedPayload;
if (comment.issue_id === id) {
setTimeline((prev) => prev.map((e) => (e.id === comment.id ? commentToTimelineEntry(comment) : e)));
}
}, [id]),
);
useWSEvent(
"comment:deleted",
useCallback((payload: unknown) => {
const { comment_id, issue_id } = payload as CommentDeletedPayload;
if (issue_id === id) {
setTimeline((prev) => {
const idsToRemove = new Set<string>([comment_id]);
let added = true;
while (added) {
added = false;
for (const e of prev) {
if (e.parent_id && idsToRemove.has(e.parent_id) && !idsToRemove.has(e.id)) {
idsToRemove.add(e.id);
added = true;
}
}
}
return prev.filter((e) => !idsToRemove.has(e.id));
});
}
}, [id]),
);
useWSEvent(
"activity:created",
useCallback((payload: unknown) => {
const p = payload as ActivityCreatedPayload;
if (p.issue_id !== id) return;
const entry = p.entry;
if (!entry || !entry.id) return;
setTimeline((prev) => {
if (prev.some((e) => e.id === entry.id)) return prev;
return [...prev, entry];
});
}, [id]),
);
// Real-time subscriber updates
useWSEvent(
"subscriber:added",
useCallback((payload: unknown) => {
const p = payload as SubscriberAddedPayload;
if (p.issue_id !== id) return;
setSubscribers((prev) => {
if (prev.some((s) => s.user_id === p.user_id && s.user_type === p.user_type)) return prev;
return [...prev, {
issue_id: p.issue_id,
user_type: p.user_type as "member" | "agent",
user_id: p.user_id,
reason: p.reason as IssueSubscriber["reason"],
created_at: new Date().toISOString(),
}];
});
}, [id]),
);
useWSEvent(
"subscriber:removed",
useCallback((payload: unknown) => {
const p = payload as SubscriberRemovedPayload;
if (p.issue_id !== id) return;
setSubscribers((prev) => prev.filter((s) => !(s.user_id === p.user_id && s.user_type === p.user_type)));
}, [id]),
);
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>
);
}
@@ -587,8 +479,10 @@ export function IssueDetail({ issueId, onDelete, defaultSidebarOpen = true, layo
key={p}
onClick={() => handleUpdateField({ priority: p })}
>
<PriorityIcon priority={p} />
{PRIORITY_CONFIG[p].label}
<span className={`inline-flex items-center gap-1 rounded px-1.5 py-0.5 text-xs font-medium ${PRIORITY_CONFIG[p].badgeBg} ${PRIORITY_CONFIG[p].badgeText}`}>
<PriorityIcon priority={p} className="h-3 w-3" inheritColor />
{PRIORITY_CONFIG[p].label}
</span>
{issue.priority === p && <span className="ml-auto text-xs text-muted-foreground"></span>}
</DropdownMenuItem>
))}
@@ -614,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>
@@ -738,38 +628,49 @@ 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">
<input
value={titleDraft}
onChange={(e) => setTitleDraft(e.target.value)}
onFocus={() => { titleFocusedRef.current = true; }}
onBlur={() => {
titleFocusedRef.current = false;
const trimmed = titleDraft.trim();
<TitleEditor
key={`title-${id}`}
defaultValue={issue.title}
placeholder="Issue title"
className="w-full text-2xl font-bold leading-snug tracking-tight"
onBlur={(value) => {
const trimmed = value.trim();
if (trimmed && trimmed !== issue.title) handleUpdateField({ title: trimmed });
else setTitleDraft(issue.title);
}}
onKeyDown={(e) => {
if (e.key === "Enter") {
e.preventDefault();
(e.target as HTMLInputElement).blur();
} else if (e.key === "Escape") {
setTitleDraft(issue.title);
(e.target as HTMLInputElement).blur();
}
}}
className="w-full bg-transparent text-2xl font-bold leading-snug tracking-tight outline-none placeholder:text-muted-foreground"
/>
<RichTextEditor
<ContentEditor
ref={descEditorRef}
key={id}
defaultValue={issue.description || ""}
placeholder="Add description..."
onUpdate={(md) => handleUpdateField({ description: md || undefined })}
onUploadFile={handleDescriptionUpload}
debounceMs={1500}
className="mt-5"
/>
<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" />
{/* Activity / Comments */}
@@ -779,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"
@@ -790,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>
@@ -829,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 (
@@ -853,12 +766,37 @@ export function IssueDetail({ issueId, onDelete, defaultSidebarOpen = true, layo
</Command>
</PopoverContent>
</Popover>
</>)}
</div>
</div>
{/* Agent live output */}
<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} />
</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) {
@@ -909,15 +847,19 @@ export function IssueDetail({ issueId, onDelete, defaultSidebarOpen = true, layo
if (group.type === "comment") {
const entry = group.entries[0]!;
return (
<CommentCard
key={entry.id}
entry={entry}
allReplies={repliesByParent}
currentUserId={user?.id}
onReply={handleSubmitReply}
onEdit={handleEditComment}
onDelete={handleDeleteComment}
/>
<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>
);
}
@@ -928,30 +870,26 @@ export function IssueDetail({ issueId, onDelete, defaultSidebarOpen = true, layo
const isStatusChange = entry.action === "status_changed";
const isPriorityChange = entry.action === "priority_changed";
const isDueDateChange = entry.action === "due_date_changed";
const isLast = idx === group.entries.length - 1;
let leadIcon: React.ReactNode;
if (isStatusChange && details.to) {
leadIcon = <StatusIcon status={details.to as IssueStatus} className="h-3.5 w-3.5 shrink-0" />;
leadIcon = <StatusIcon status={details.to as IssueStatus} className="h-4 w-4 shrink-0" />;
} else if (isPriorityChange && details.to) {
leadIcon = <PriorityIcon priority={details.to as IssuePriority} className="h-3.5 w-3.5 shrink-0" />;
leadIcon = <PriorityIcon priority={details.to as IssuePriority} className="h-4 w-4 shrink-0" />;
} else if (isDueDateChange) {
leadIcon = <Calendar className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />;
leadIcon = <Calendar className="h-4 w-4 shrink-0 text-muted-foreground" />;
} else {
leadIcon = <ActorAvatar actorType={entry.actor_type} actorId={entry.actor_id} size={14} />;
leadIcon = <ActorAvatar actorType={entry.actor_type} actorId={entry.actor_id} size={16} />;
}
return (
<div key={entry.id} className="relative flex text-xs text-muted-foreground">
<div className="mr-2.5 flex w-3.5 shrink-0 justify-center">
<div className="flex h-5 items-center">{leadIcon}</div>
<div key={entry.id} className="flex items-center text-xs text-muted-foreground">
<div className="mr-2 flex w-4 shrink-0 justify-center">
{leadIcon}
</div>
{!isLast && (
<div className="absolute left-[7px] top-5 h-3 w-px bg-border" />
)}
<div className="flex flex-1 items-baseline gap-1">
<span className="font-medium">{getActorName(entry.actor_type, entry.actor_id)}</span>
<span>{formatActivity(entry, getActorName)}</span>
<div className="flex min-w-0 flex-1 items-center gap-1">
<span className="shrink-0 font-medium">{getActorName(entry.actor_type, entry.actor_id)}</span>
<span className="truncate">{formatActivity(entry, getActorName)}</span>
<Tooltip>
<TooltipTrigger
render={
@@ -976,10 +914,24 @@ export function IssueDetail({ issueId, onDelete, defaultSidebarOpen = true, layo
{/* Bottom comment input — no avatar, full width */}
<div className="mt-4">
<CommentInput onSubmit={handleSubmitComment} />
<CommentInput issueId={id} onSubmit={submitComment} />
</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>
@@ -1037,8 +989,10 @@ export function IssueDetail({ issueId, onDelete, defaultSidebarOpen = true, layo
<DropdownMenuContent align="start" className="w-44">
{PRIORITY_ORDER.map((p) => (
<DropdownMenuItem key={p} onClick={() => handleUpdateField({ priority: p })}>
<PriorityIcon priority={p} />
{PRIORITY_CONFIG[p].label}
<span className={`inline-flex items-center gap-1 rounded px-1.5 py-0.5 text-xs font-medium ${PRIORITY_CONFIG[p].badgeBg} ${PRIORITY_CONFIG[p].badgeText}`}>
<PriorityIcon priority={p} className="h-3 w-3" inheritColor />
{PRIORITY_CONFIG[p].label}
</span>
{p === issue.priority && <Check className="ml-auto h-3.5 w-3.5" />}
</DropdownMenuItem>
))}
@@ -1048,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

@@ -0,0 +1,37 @@
"use client";
import Link from "next/link";
import { useIssueStore } from "@/features/issues/store";
import { StatusIcon } from "./status-icon";
interface IssueMentionCardProps {
issueId: string;
/** Fallback text when issue is not in store (e.g. "MUL-7") */
fallbackLabel?: string;
}
export function IssueMentionCard({ issueId, fallbackLabel }: IssueMentionCardProps) {
const issue = useIssueStore((s) => s.issues.find((i) => i.id === issueId));
if (!issue) {
return (
<Link
href={`/issues/${issueId}`}
className="text-primary font-medium cursor-pointer hover:underline"
>
{fallbackLabel ?? issueId.slice(0, 8)}
</Link>
);
}
return (
<Link
href={`/issues/${issueId}`}
className="inline-flex items-center gap-1.5 rounded-md border px-2 py-0.5 text-sm hover:bg-accent transition-colors cursor-pointer no-underline"
>
<StatusIcon status={issue.status} className="h-3.5 w-3.5" />
<span className="font-medium text-muted-foreground">{issue.identifier}</span>
<span className="text-foreground">{issue.title}</span>
</Link>
);
}

View File

@@ -1,18 +1,22 @@
"use client";
import { useMemo } from "react";
import { useMemo, useState } from "react";
import {
ArrowDown,
ArrowUp,
Check,
ChevronDown,
CircleDot,
Columns3,
Filter,
List,
Plus,
SignalHigh,
SlidersHorizontal,
User,
UserMinus,
UserPen,
} from "lucide-react";
import { Button } from "@/components/ui/button";
import { useIssueStore } from "@/features/issues/store";
import {
DropdownMenu,
DropdownMenuTrigger,
@@ -22,6 +26,9 @@ import {
DropdownMenuCheckboxItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuSub,
DropdownMenuSubTrigger,
DropdownMenuSubContent,
} from "@/components/ui/dropdown-menu";
import {
Popover,
@@ -29,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,
@@ -37,202 +43,458 @@ import {
PRIORITY_CONFIG,
} from "@/features/issues/config";
import { StatusIcon, PriorityIcon } from "@/features/issues/components";
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)
// ---------------------------------------------------------------------------
const FILTER_ITEM_CLASS =
"group/fitem pr-1.5! [&>[data-slot=dropdown-menu-checkbox-item-indicator]]:hidden";
function HoverCheck({ checked }: { checked: boolean }) {
return (
<div
className="border-input data-[selected=true]:border-primary data-[selected=true]:bg-primary data-[selected=true]:text-primary-foreground pointer-events-none size-4 shrink-0 rounded-[4px] border transition-all select-none *:[svg]:opacity-0 data-[selected=true]:*:[svg]:opacity-100 opacity-0 group-hover/fitem:opacity-100 group-focus/fitem:opacity-100 data-[selected=true]:opacity-100"
data-selected={checked}
>
<Check className="size-3.5 text-current" />
</div>
);
}
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
function getActiveFilterCount(state: {
statusFilters: string[];
priorityFilters: string[];
assigneeFilters: ActorFilterValue[];
includeNoAssignee: boolean;
creatorFilters: ActorFilterValue[];
}) {
let count = 0;
if (state.statusFilters.length > 0) count++;
if (state.priorityFilters.length > 0) count++;
if (state.assigneeFilters.length > 0 || state.includeNoAssignee) count++;
if (state.creatorFilters.length > 0) count++;
return count;
}
function useIssueCounts(allIssues: Issue[]) {
return useMemo(() => {
const status = new Map<string, number>();
const priority = new Map<string, number>();
const assignee = new Map<string, number>();
const creator = new Map<string, number>();
let noAssignee = 0;
for (const issue of allIssues) {
status.set(issue.status, (status.get(issue.status) ?? 0) + 1);
priority.set(issue.priority, (priority.get(issue.priority) ?? 0) + 1);
if (!issue.assignee_id) {
noAssignee++;
} else {
const aKey = `${issue.assignee_type}:${issue.assignee_id}`;
assignee.set(aKey, (assignee.get(aKey) ?? 0) + 1);
}
const cKey = `${issue.creator_type}:${issue.creator_id}`;
creator.set(cKey, (creator.get(cKey) ?? 0) + 1);
}
return { status, priority, assignee, creator, noAssignee };
}, [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)
// ---------------------------------------------------------------------------
function ActorSubContent({
counts,
selected,
onToggle,
showNoAssignee,
includeNoAssignee,
onToggleNoAssignee,
noAssigneeCount,
}: {
counts: Map<string, number>;
selected: ActorFilterValue[];
onToggle: (value: ActorFilterValue) => void;
showNoAssignee?: boolean;
includeNoAssignee?: boolean;
onToggleNoAssignee?: () => void;
noAssigneeCount?: number;
}) {
const [search, setSearch] = useState("");
const members = useWorkspaceStore((s) => s.members);
const agents = useWorkspaceStore((s) => s.agents);
const query = search.toLowerCase();
const filteredMembers = members.filter((m) =>
m.name.toLowerCase().includes(query),
);
const filteredAgents = agents.filter((a) =>
!a.archived_at && a.name.toLowerCase().includes(query),
);
const isSelected = (type: "member" | "agent", id: string) =>
selected.some((f) => f.type === type && f.id === id);
return (
<>
<div className="px-2 py-1.5 border-b border-foreground/5">
<input
type="text"
value={search}
onChange={(e) => setSearch(e.target.value)}
placeholder="Filter..."
className="w-full bg-transparent text-sm placeholder:text-muted-foreground outline-none"
autoFocus
/>
</div>
<div className="max-h-64 overflow-y-auto p-1">
{showNoAssignee &&
(!query || "no assignee".includes(query) || "unassigned".includes(query)) && (
<DropdownMenuCheckboxItem
checked={includeNoAssignee ?? false}
onCheckedChange={() => onToggleNoAssignee?.()}
className={FILTER_ITEM_CLASS}
>
<HoverCheck checked={includeNoAssignee ?? false} />
<UserMinus className="size-3.5 text-muted-foreground" />
No assignee
{(noAssigneeCount ?? 0) > 0 && (
<span className="ml-auto text-xs text-muted-foreground">
{noAssigneeCount}
</span>
)}
</DropdownMenuCheckboxItem>
)}
{filteredMembers.length > 0 && (
<DropdownMenuGroup>
<DropdownMenuLabel>Members</DropdownMenuLabel>
{filteredMembers.map((m) => {
const checked = isSelected("member", m.user_id);
const count = counts.get(`member:${m.user_id}`) ?? 0;
return (
<DropdownMenuCheckboxItem
key={m.user_id}
checked={checked}
onCheckedChange={() =>
onToggle({ type: "member", id: m.user_id })
}
className={FILTER_ITEM_CLASS}
>
<HoverCheck checked={checked} />
<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">
{count}
</span>
)}
</DropdownMenuCheckboxItem>
);
})}
</DropdownMenuGroup>
)}
{filteredAgents.length > 0 && (
<DropdownMenuGroup>
<DropdownMenuLabel>Agents</DropdownMenuLabel>
{filteredAgents.map((a) => {
const checked = isSelected("agent", a.id);
const count = counts.get(`agent:${a.id}`) ?? 0;
return (
<DropdownMenuCheckboxItem
key={a.id}
checked={checked}
onCheckedChange={() =>
onToggle({ type: "agent", id: a.id })
}
className={FILTER_ITEM_CLASS}
>
<HoverCheck checked={checked} />
<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">
{count}
</span>
)}
</DropdownMenuCheckboxItem>
);
})}
</DropdownMenuGroup>
)}
{filteredMembers.length === 0 && filteredAgents.length === 0 && search && (
<div className="px-2 py-3 text-center text-sm text-muted-foreground">
No results
</div>
)}
</div>
</>
);
}
// ---------------------------------------------------------------------------
// IssuesHeader
// ---------------------------------------------------------------------------
export function IssuesHeader({ scopedIssues }: { scopedIssues: Issue[] }) {
const scope = useIssuesScopeStore((s) => s.scope);
const setScope = useIssuesScopeStore((s) => s.setScope);
export function IssuesHeader() {
const viewMode = useIssueViewStore((s) => s.viewMode);
const statusFilters = useIssueViewStore((s) => s.statusFilters);
const priorityFilters = useIssueViewStore((s) => s.priorityFilters);
const assigneeFilters = useIssueViewStore((s) => s.assigneeFilters);
const includeNoAssignee = useIssueViewStore((s) => s.includeNoAssignee);
const creatorFilters = useIssueViewStore((s) => s.creatorFilters);
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 setSortBy = useIssueViewStore((s) => s.setSortBy);
const setSortDirection = useIssueViewStore((s) => s.setSortDirection);
const toggleCardProperty = useIssueViewStore((s) => s.toggleCardProperty);
const clearFilters = useIssueViewStore((s) => s.clearFilters);
const act = useIssueViewStore.getState();
const allIssues = useIssueStore((s) => s.issues);
const counts = useIssueCounts(scopedIssues);
const filteredCount = useMemo(() => {
return allIssues.filter((i) => {
if (statusFilters.length > 0 && !statusFilters.includes(i.status))
return false;
if (
priorityFilters.length > 0 &&
!priorityFilters.includes(i.priority)
)
return false;
return true;
}).length;
}, [allIssues, statusFilters, priorityFilters]);
const hasActiveFilters =
getActiveFilterCount({
statusFilters,
priorityFilters,
assigneeFilters,
includeNoAssignee,
creatorFilters,
}) > 0;
const sortLabel =
SORT_OPTIONS.find((o) => o.value === sortBy)?.label ?? "Manual";
const hasActiveFilters =
statusFilters.length > 0 || priorityFilters.length > 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>
{/* Right: filter + display + view toggle */}
<div className="flex items-center gap-1">
{/* Filter */}
<Popover>
<PopoverTrigger
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">
{statusFilters.length + priorityFilters.length}
<DropdownMenu>
<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>
<CircleDot className="size-3.5" />
<span className="flex-1">Status</span>
{statusFilters.length > 0 && (
<span className="text-xs text-primary font-medium">
{statusFilters.length}
</span>
)}
</Button>
}
/>
<PopoverContent align="start" className="w-64 p-0">
{/* Status */}
<div className="border-b px-3 py-2.5">
<span className="text-xs font-medium text-muted-foreground">
Status
</span>
<div className="mt-1.5 space-y-0.5">
{ALL_STATUSES.map((s) => (
<label
key={s}
className="flex cursor-pointer items-center gap-2 rounded-md px-1.5 py-1 hover:bg-accent"
onClick={() => toggleStatusFilter(s)}
>
<div
className={`flex h-4 w-4 items-center justify-center rounded border ${
statusFilters.length === 0 || statusFilters.includes(s)
? "border-primary bg-primary"
: "border-input"
}`}
</DropdownMenuSubTrigger>
<DropdownMenuSubContent className="w-auto min-w-48">
{ALL_STATUSES.map((s) => {
const checked = statusFilters.includes(s);
const count = counts.status.get(s) ?? 0;
return (
<DropdownMenuCheckboxItem
key={s}
checked={checked}
onCheckedChange={() => act.toggleStatusFilter(s)}
className={FILTER_ITEM_CLASS}
>
{(statusFilters.length === 0 ||
statusFilters.includes(s)) && (
<svg
viewBox="0 0 12 12"
className="h-3 w-3 text-primary-foreground"
fill="none"
stroke="currentColor"
strokeWidth="2"
>
<path d="M2 6l3 3 5-5" />
</svg>
<HoverCheck checked={checked} />
<StatusIcon status={s} className="h-3.5 w-3.5" />
{STATUS_CONFIG[s].label}
{count > 0 && (
<span className="ml-auto text-xs text-muted-foreground">
{count} {count === 1 ? "issue" : "issues"}
</span>
)}
</div>
<StatusIcon status={s} className="h-3.5 w-3.5" />
<span className="text-sm">{STATUS_CONFIG[s].label}</span>
</label>
))}
</div>
</div>
</DropdownMenuCheckboxItem>
);
})}
</DropdownMenuSubContent>
</DropdownMenuSub>
{/* Priority */}
<div className="border-b px-3 py-2.5">
<span className="text-xs font-medium text-muted-foreground">
Priority
</span>
<div className="mt-1.5 space-y-0.5">
{PRIORITY_ORDER.map((p) => (
<label
key={p}
className="flex cursor-pointer items-center gap-2 rounded-md px-1.5 py-1 hover:bg-accent"
onClick={() => togglePriorityFilter(p)}
>
<div
className={`flex h-4 w-4 items-center justify-center rounded border ${
priorityFilters.length === 0 ||
priorityFilters.includes(p)
? "border-primary bg-primary"
: "border-input"
}`}
<DropdownMenuSub>
<DropdownMenuSubTrigger>
<SignalHigh className="size-3.5" />
<span className="flex-1">Priority</span>
{priorityFilters.length > 0 && (
<span className="text-xs text-primary font-medium">
{priorityFilters.length}
</span>
)}
</DropdownMenuSubTrigger>
<DropdownMenuSubContent className="w-auto min-w-44">
{PRIORITY_ORDER.map((p) => {
const checked = priorityFilters.includes(p);
const count = counts.priority.get(p) ?? 0;
return (
<DropdownMenuCheckboxItem
key={p}
checked={checked}
onCheckedChange={() => act.togglePriorityFilter(p)}
className={FILTER_ITEM_CLASS}
>
{(priorityFilters.length === 0 ||
priorityFilters.includes(p)) && (
<svg
viewBox="0 0 12 12"
className="h-3 w-3 text-primary-foreground"
fill="none"
stroke="currentColor"
strokeWidth="2"
>
<path d="M2 6l3 3 5-5" />
</svg>
<HoverCheck checked={checked} />
<PriorityIcon priority={p} />
{PRIORITY_CONFIG[p].label}
{count > 0 && (
<span className="ml-auto text-xs text-muted-foreground">
{count} {count === 1 ? "issue" : "issues"}
</span>
)}
</div>
<PriorityIcon priority={p} />
<span className="text-sm">{PRIORITY_CONFIG[p].label}</span>
</label>
))}
</div>
</div>
</DropdownMenuCheckboxItem>
);
})}
</DropdownMenuSubContent>
</DropdownMenuSub>
{/* Assignee */}
<DropdownMenuSub>
<DropdownMenuSubTrigger>
<User className="size-3.5" />
<span className="flex-1">Assignee</span>
{(assigneeFilters.length > 0 || includeNoAssignee) && (
<span className="text-xs text-primary font-medium">
{assigneeFilters.length + (includeNoAssignee ? 1 : 0)}
</span>
)}
</DropdownMenuSubTrigger>
<DropdownMenuSubContent className="w-auto min-w-52 p-0">
<ActorSubContent
counts={counts.assignee}
selected={assigneeFilters}
onToggle={act.toggleAssigneeFilter}
showNoAssignee
includeNoAssignee={includeNoAssignee}
onToggleNoAssignee={act.toggleNoAssignee}
noAssigneeCount={counts.noAssignee}
/>
</DropdownMenuSubContent>
</DropdownMenuSub>
{/* Creator */}
<DropdownMenuSub>
<DropdownMenuSubTrigger>
<UserPen className="size-3.5" />
<span className="flex-1">Creator</span>
{creatorFilters.length > 0 && (
<span className="text-xs text-primary font-medium">
{creatorFilters.length}
</span>
)}
</DropdownMenuSubTrigger>
<DropdownMenuSubContent className="w-auto min-w-52 p-0">
<ActorSubContent
counts={counts.creator}
selected={creatorFilters}
onToggle={act.toggleCreatorFilter}
/>
</DropdownMenuSubContent>
</DropdownMenuSub>
{/* Reset */}
{hasActiveFilters && (
<div className="px-3 py-2">
<Button
variant="link"
size="xs"
className="text-muted-foreground hover:text-foreground"
onClick={clearFilters}
>
Reset filters
</Button>
</div>
<>
<DropdownMenuSeparator />
<DropdownMenuItem onClick={act.clearFilters}>
Reset all filters
</DropdownMenuItem>
</>
)}
</PopoverContent>
</Popover>
</DropdownMenuContent>
</DropdownMenu>
{/* 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">
{/* Ordering section */}
<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
@@ -255,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>
@@ -266,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"}
>
@@ -279,7 +541,6 @@ export function IssuesHeader() {
</div>
</div>
{/* Card properties section */}
<div className="px-3 py-2.5">
<span className="text-xs font-medium text-muted-foreground">
Card properties
@@ -294,7 +555,7 @@ export function IssuesHeader() {
<Switch
size="sm"
checked={cardProperties[opt.key]}
onCheckedChange={() => toggleCardProperty(opt.key)}
onCheckedChange={() => act.toggleCardProperty(opt.key)}
/>
</label>
))}
@@ -302,21 +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>
{/* New issue */}
<Button
variant="outline"
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,11 +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 } from "@/features/issues/stores/view-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";
@@ -16,39 +20,39 @@ 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);
const assigneeFilters = useIssueViewStore((s) => s.assigneeFilters);
const includeNoAssignee = useIssueViewStore((s) => s.includeNoAssignee);
const creatorFilters = useIssueViewStore((s) => s.creatorFilters);
useEffect(() => {
initFilterWorkspaceSync();
}, []);
useEffect(() => {
useIssueSelectionStore.getState().clear();
}, [viewMode]);
}, [viewMode, scope]);
const issues = useMemo(() => {
return allIssues.filter((issue) => {
if (statusFilters.length > 0 && !statusFilters.includes(issue.status))
return false;
if (
priorityFilters.length > 0 &&
!priorityFilters.includes(issue.priority)
)
return false;
return true;
});
}, [allIssues, statusFilters, priorityFilters]);
// 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(scopedIssues, { statusFilters, priorityFilters, assigneeFilters, includeNoAssignee, creatorFilters }),
[scopedIssues, statusFilters, priorityFilters, assigneeFilters, includeNoAssignee, creatorFilters],
);
const visibleStatuses = useMemo(() => {
if (statusFilters.length > 0)
@@ -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

@@ -1,5 +1,6 @@
"use client";
import { memo } from "react";
import Link from "next/link";
import type { Issue } from "@/shared/types";
import { ActorAvatar } from "@/components/common/actor-avatar";
@@ -13,7 +14,7 @@ function formatDate(date: string): string {
});
}
export function ListRow({ issue }: { issue: Issue }) {
export const ListRow = memo(function ListRow({ issue }: { issue: Issue }) {
const selected = useIssueSelectionStore((s) => s.selectedIds.has(issue.id));
const toggle = useIssueSelectionStore((s) => s.toggle);
@@ -60,4 +61,4 @@ export function ListRow({ issue }: { issue: Issue }) {
</Link>
</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);
@@ -96,9 +96,11 @@ export function ListView({
</div>
<Accordion.Trigger className="group/trigger flex flex-1 items-center gap-2 px-2 h-full text-left outline-none">
<ChevronRight className="size-3.5 shrink-0 text-muted-foreground transition-transform group-aria-expanded/trigger:rotate-90" />
<StatusIcon status={status} className="h-3.5 w-3.5" />
<span className="text-sm font-medium">{cfg.label}</span>
<span className="flex h-5 min-w-5 items-center justify-center rounded-full bg-muted px-1.5 text-xs text-muted-foreground">
<span className={`inline-flex items-center gap-1.5 rounded px-2 py-0.5 text-xs font-semibold ${cfg.badgeBg} ${cfg.badgeText}`}>
<StatusIcon status={status} className="h-3 w-3" inheritColor />
{cfg.label}
</span>
<span className="text-xs text-muted-foreground">
{statusIssues.length}
</span>
</Accordion.Trigger>

View File

@@ -1,9 +1,11 @@
"use client";
import { useState } from "react";
import { Bot, UserMinus } from "lucide-react";
import type { IssueAssigneeType, UpdateIssueRequest } from "@/shared/types";
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,
@@ -11,29 +13,50 @@ import {
PickerEmpty,
} from "./property-picker";
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;
return false;
}
export function AssigneePicker({
assigneeType,
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;
const query = filter.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: string, id: string) =>
@@ -52,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>
</>
) : (
@@ -105,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>
))}
@@ -117,24 +128,30 @@ export function AssigneePicker({
{/* Agents */}
{filteredAgents.length > 0 && (
<PickerSection label="Agents">
{filteredAgents.map((a) => (
<PickerItem
key={a.id}
selected={isSelected("agent", a.id)}
onClick={() => {
onUpdate({
assignee_type: "agent",
assignee_id: a.id,
});
setOpen(false);
}}
>
<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>
<span>{a.name}</span>
</PickerItem>
))}
{filteredAgents.map((a) => {
const allowed = canAssignAgent(a, user?.id, memberRole);
return (
<PickerItem
key={a.id}
selected={isSelected("agent", a.id)}
disabled={!allowed}
onClick={() => {
if (!allowed) return;
onUpdate({
assignee_type: "agent",
assignee_id: a.id,
});
setOpen(false);
}}
>
<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" />
)}
</PickerItem>
);
})}
</PickerSection>
)}

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

@@ -43,8 +43,10 @@ export function PriorityPicker({
setOpen(false);
}}
>
<PriorityIcon priority={p} />
<span>{c.label}</span>
<span className={`inline-flex items-center gap-1 rounded px-1.5 py-0.5 text-xs font-medium ${c.badgeBg} ${c.badgeText}`}>
<PriorityIcon priority={p} className="h-3 w-3" inheritColor />
{c.label}
</span>
</PickerItem>
);
})}

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