Compare commits

..

66 Commits

Author SHA1 Message Date
Jiayuan
9bc8dcc053 feat(issues): add Relations section in sidebar for managing issue dependencies
Users can now create, view, and remove issue dependencies directly from
the issue detail sidebar. The Relations section shows existing links with
type labels (blocks, blocked by, related to) and a searchable popover
for adding new relations. Activity entries appear on both issues when
relations change.
2026-04-04 00:05:15 +08:00
Jiayuan
473ec17c65 feat(activity): add issue relation activities to timeline
When issue dependencies are created or removed, both the source and
target issues now receive activity entries in their timeline. This
enables bi-directional visibility — e.g., issue A's timeline shows
"added relation: blocks MUL-456" while issue B shows "added relation:
blocked by MUL-123".

Changes:
- Add CRUD SQL queries and handler endpoints for issue_dependency
- Add issue_dependency:created/removed event types
- Add activity listener that records activities on both issues
- Render relation activities in the frontend with Link2 icon
2026-04-03 23:51:36 +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
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
154 changed files with 7062 additions and 4274 deletions

View File

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

1
.gitignore vendored
View File

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

View File

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

View File

@@ -289,6 +289,23 @@ multica issue comment add <issue-id> --parent <comment-id> --content "Thanks!"
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

118
README.md
View File

@@ -1,28 +1,57 @@
<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 project management — like Linear, but with AI agents as first-class team members.
**Your next 10 hires won't be human.**
Multica lets you manage tasks and collaborate with AI agents the same way you work with human teammates. Agents can be assigned issues, post comments, update statuses, and execute work autonomously on your local machine.
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.
[![CI](https://github.com/multica-ai/multica/actions/workflows/ci.yml/badge.svg)](https://github.com/multica-ai/multica/actions/workflows/ci.yml)
[![License](https://img.shields.io/badge/License-Apache_2.0-blue.svg)](https://opensource.org/licenses/Apache-2.0)
[![GitHub stars](https://img.shields.io/github/stars/multica-ai/multica?style=flat)](https://github.com/multica-ai/multica/stargazers)
[Website](https://multica.ai) · [Cloud](https://multica.ai/app) · [Self-Hosting](SELF_HOSTING.md) · [Contributing](CONTRIBUTING.md)
**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
- **AI agents as teammates** — assign issues to agents, mention them in comments, and let them do the work
- **Local agent runtime** — agents run on your machine using Claude Code or Codex, with full access to your codebase
- **Real-time collaboration** — WebSocket-powered live updates across the board
- **Multi-workspace** — organize work across teams with workspace-level isolation
- **Familiar UX** — if you've used Linear, you'll feel right at home
- **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
### Use Multica Cloud
### Multica Cloud
The fastest way to get started: [multica.ai](https://multica.ai)
The fastest way to get started — no setup required: **[multica.ai](https://multica.ai)**
### Self-Host
Run Multica on your own infrastructure. See the [Self-Hosting Guide](SELF_HOSTING.md) for full instructions.
Quick start with Docker:
### Self-Host with Docker
```bash
git clone https://github.com/multica-ai/multica.git
@@ -30,14 +59,13 @@ cd multica
cp .env.example .env
# Edit .env — at minimum, change JWT_SECRET
# Start PostgreSQL
docker compose up -d
# Build and run the backend
cd server && go run ./cmd/migrate up && cd ..
make start
docker compose up -d # Start PostgreSQL
cd server && go run ./cmd/migrate up && cd .. # Run migrations
make start # Start the app
```
See the [Self-Hosting Guide](SELF_HOSTING.md) for full instructions.
## CLI
The `multica` CLI connects your local machine to Multica — authenticate, manage workspaces, and run the agent daemon.
@@ -56,6 +84,35 @@ The daemon auto-detects available agent CLIs (`claude`, `codex`) on your PATH. W
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 login # Authenticate with your Multica account
multica daemon start # Start the local agent runtime
```
The daemon runs in the background and keeps your machine connected to Multica. It auto-detects agent CLIs (`claude`, `codex`) available on your PATH.
### 2. Verify your runtime
Open your workspace in the Multica web app. Navigate to **Settings → Runtimes** — you should see your machine listed as an active **Runtime**.
> **What is a Runtime?** A Runtime is a compute environment that can execute agent tasks. It can be your local machine (via the daemon) or a cloud instance. Each runtime reports which agent CLIs are available, so Multica knows where to route work.
### 3. Create an agent
Go to **Settings → Agents** and click **New Agent**. Pick the runtime you just connected and choose a provider (Claude Code or Codex). Give your agent a name — this is how it will appear on the board, in comments, and in assignments.
### 4. Assign your first task
Create an issue from the board (or via `multica issue create`), then assign it to your new agent. The agent will automatically pick up the task, execute it on your runtime, and report progress — just like a human teammate.
That's it! Your agent is now part of the team. 🎉
## Architecture
```
@@ -70,23 +127,18 @@ See the [CLI and Daemon Guide](CLI_AND_DAEMON.md) for the full command reference
└──────────────┘
```
- **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
| 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/)
### Quick Start
**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
@@ -99,4 +151,4 @@ See [CONTRIBUTING.md](CONTRIBUTING.md) for the full development workflow, worktr
## License
See [LICENSE](LICENSE) for details.
[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)

View File

@@ -1,20 +0,0 @@
{
"colors": [
{
"color": {
"color-space": "srgb",
"components": {
"alpha": "1.000",
"blue": "0.996",
"green": "0.388",
"red": "0.384"
}
},
"idiom": "universal"
}
],
"info": {
"author": "xcode",
"version": 1
}
}

View File

@@ -1,13 +0,0 @@
{
"images": [
{
"idiom": "universal",
"platform": "ios",
"size": "1024x1024"
}
],
"info": {
"author": "xcode",
"version": 1
}
}

View File

@@ -1,6 +0,0 @@
{
"info": {
"author": "xcode",
"version": 1
}
}

View File

@@ -1,37 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CFBundleDevelopmentRegion</key>
<string>$(DEVELOPMENT_LANGUAGE)</string>
<key>CFBundleExecutable</key>
<string>$(EXECUTABLE_NAME)</string>
<key>CFBundleIdentifier</key>
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
<key>CFBundleInfoDictionaryVersion</key>
<string>6.0</string>
<key>CFBundleName</key>
<string>$(PRODUCT_NAME)</string>
<key>CFBundlePackageType</key>
<string>$(PRODUCT_BUNDLE_PACKAGE_TYPE)</string>
<key>CFBundleShortVersionString</key>
<string>$(MARKETING_VERSION)</string>
<key>CFBundleVersion</key>
<string>$(CURRENT_PROJECT_VERSION)</string>
<key>LSRequiresIPhoneOS</key>
<true/>
<key>UIApplicationSceneManifest</key>
<dict>
<key>UIApplicationSupportsMultipleScenes</key>
<false/>
</dict>
<key>UILaunchScreen</key>
<dict/>
<key>UISupportedInterfaceOrientations</key>
<array>
<string>UIInterfaceOrientationPortrait</string>
<string>UIInterfaceOrientationLandscapeLeft</string>
<string>UIInterfaceOrientationLandscapeRight</string>
</array>
</dict>
</plist>

View File

@@ -1,29 +0,0 @@
import Foundation
struct Agent: Codable, Identifiable, Hashable, Sendable {
let id: String
let workspaceId: String
let name: String
let description: String
let instructions: String?
let avatarURL: String?
let status: AgentStatus
let createdAt: String
let updatedAt: String
enum CodingKeys: String, CodingKey {
case id, name, description, instructions, status
case workspaceId = "workspace_id"
case avatarURL = "avatar_url"
case createdAt = "created_at"
case updatedAt = "updated_at"
}
}
enum AgentStatus: String, Codable, Sendable {
case idle
case working
case blocked
case error
case offline
}

View File

@@ -1,61 +0,0 @@
import Foundation
struct AgentTask: Codable, Identifiable, Sendable {
let id: String
let agentId: String
let runtimeId: String?
let issueId: String
let status: TaskStatus
let priority: Int?
let dispatchedAt: String?
let startedAt: String?
let completedAt: String?
let error: String?
let createdAt: String
enum CodingKeys: String, CodingKey {
case id, status, priority, error
case agentId = "agent_id"
case runtimeId = "runtime_id"
case issueId = "issue_id"
case dispatchedAt = "dispatched_at"
case startedAt = "started_at"
case completedAt = "completed_at"
case createdAt = "created_at"
}
}
enum TaskStatus: String, Codable, Sendable {
case queued
case dispatched
case running
case completed
case failed
case cancelled
var label: String {
switch self {
case .queued: "Queued"
case .dispatched: "Dispatched"
case .running: "Running"
case .completed: "Completed"
case .failed: "Failed"
case .cancelled: "Cancelled"
}
}
var iconName: String {
switch self {
case .queued: "clock"
case .dispatched: "arrow.right.circle"
case .running: "play.circle.fill"
case .completed: "checkmark.circle.fill"
case .failed: "xmark.circle.fill"
case .cancelled: "minus.circle.fill"
}
}
var isActive: Bool {
self == .queued || self == .dispatched || self == .running
}
}

View File

@@ -1,46 +0,0 @@
import Foundation
struct Comment: Codable, Identifiable, Sendable {
let id: String
let issueId: String
let authorType: String
let authorId: String
let content: String
let type: String
let parentId: String?
let attachments: [Attachment]?
let createdAt: String
let updatedAt: String
// Joined fields from server
let authorName: String?
let authorAvatarURL: String?
enum CodingKeys: String, CodingKey {
case id, content, type, attachments
case issueId = "issue_id"
case authorType = "author_type"
case authorId = "author_id"
case parentId = "parent_id"
case createdAt = "created_at"
case updatedAt = "updated_at"
case authorName = "author_name"
case authorAvatarURL = "author_avatar_url"
}
var isFromAgent: Bool {
authorType == "agent"
}
}
struct Attachment: Codable, Identifiable, Sendable {
let id: String
let filename: String
let contentType: String?
let url: String?
enum CodingKeys: String, CodingKey {
case id, filename, url
case contentType = "content_type"
}
}

View File

@@ -1,152 +0,0 @@
import Foundation
enum IssueStatus: String, Codable, CaseIterable, Sendable {
case backlog
case todo
case inProgress = "in_progress"
case inReview = "in_review"
case done
case blocked
case cancelled
var label: String {
switch self {
case .backlog: "Backlog"
case .todo: "Todo"
case .inProgress: "In Progress"
case .inReview: "In Review"
case .done: "Done"
case .blocked: "Blocked"
case .cancelled: "Cancelled"
}
}
var iconName: String {
switch self {
case .backlog: "circle.dashed"
case .todo: "circle"
case .inProgress: "circle.lefthalf.filled"
case .inReview: "eye.circle"
case .done: "checkmark.circle.fill"
case .blocked: "xmark.circle"
case .cancelled: "minus.circle"
}
}
var color: String {
switch self {
case .backlog: "gray"
case .todo: "gray"
case .inProgress: "yellow"
case .inReview: "blue"
case .done: "green"
case .blocked: "red"
case .cancelled: "gray"
}
}
}
enum IssuePriority: String, Codable, CaseIterable, Sendable {
case urgent
case high
case medium
case low
case none
var label: String {
switch self {
case .urgent: "Urgent"
case .high: "High"
case .medium: "Medium"
case .low: "Low"
case .none: "None"
}
}
var iconName: String {
switch self {
case .urgent: "exclamationmark.3"
case .high: "exclamationmark.2"
case .medium: "exclamationmark"
case .low: "minus"
case .none: "minus"
}
}
var sortOrder: Int {
switch self {
case .urgent: 0
case .high: 1
case .medium: 2
case .low: 3
case .none: 4
}
}
}
struct Issue: Codable, Identifiable, Hashable, Sendable {
let id: String
let workspaceId: String
let number: Int
let identifier: String
let title: String
let description: String?
let status: IssueStatus
let priority: IssuePriority
let assigneeType: String?
let assigneeId: String?
let creatorType: String
let creatorId: String
let parentIssueId: String?
let position: Int
let dueDate: String?
let createdAt: String
let updatedAt: String
enum CodingKeys: String, CodingKey {
case id, number, identifier, title, description, status, priority, position
case workspaceId = "workspace_id"
case assigneeType = "assignee_type"
case assigneeId = "assignee_id"
case creatorType = "creator_type"
case creatorId = "creator_id"
case parentIssueId = "parent_issue_id"
case dueDate = "due_date"
case createdAt = "created_at"
case updatedAt = "updated_at"
}
var isAssignedToAgent: Bool {
assigneeType == "agent"
}
}
struct CreateIssueRequest: Codable, Sendable {
let title: String
let description: String?
let status: String?
let priority: String?
let assigneeType: String?
let assigneeId: String?
enum CodingKeys: String, CodingKey {
case title, description, status, priority
case assigneeType = "assignee_type"
case assigneeId = "assignee_id"
}
}
struct UpdateIssueRequest: Codable, Sendable {
var title: String?
var description: String?
var status: String?
var priority: String?
var assigneeType: String?
var assigneeId: String?
enum CodingKeys: String, CodingKey {
case title, description, status, priority
case assigneeType = "assignee_type"
case assigneeId = "assignee_id"
}
}

View File

@@ -1,55 +0,0 @@
import Foundation
struct Member: Codable, Identifiable, Hashable, Sendable {
let id: String
let userId: String
let workspaceId: String
let role: String
let user: User?
let createdAt: String
enum CodingKeys: String, CodingKey {
case id, role, user
case userId = "user_id"
case workspaceId = "workspace_id"
case createdAt = "created_at"
}
var displayName: String {
user?.name ?? "Unknown"
}
}
/// Unified type for displaying assignees (either member or agent)
enum Assignee: Identifiable, Hashable, Sendable {
case member(Member)
case agent(Agent)
var id: String {
switch self {
case .member(let m): m.id
case .agent(let a): a.id
}
}
var name: String {
switch self {
case .member(let m): m.displayName
case .agent(let a): a.name
}
}
var typeName: String {
switch self {
case .member: "member"
case .agent: "agent"
}
}
var entityId: String {
switch self {
case .member(let m): m.userId
case .agent(let a): a.id
}
}
}

View File

@@ -1,71 +0,0 @@
import Foundation
struct TaskMessage: Codable, Identifiable, Sendable {
let taskId: String
let issueId: String?
let seq: Int
let type: MessageType
let tool: String?
let content: String?
let input: [String: AnyCodable]?
let output: String?
var id: String { "\(taskId)-\(seq)" }
enum CodingKeys: String, CodingKey {
case seq, type, tool, content, input, output
case taskId = "task_id"
case issueId = "issue_id"
}
}
enum MessageType: String, Codable, Sendable {
case text
case thinking
case toolUse = "tool_use"
case toolResult = "tool_result"
case error
}
// Simple wrapper for heterogeneous JSON values
struct AnyCodable: Codable, @unchecked Sendable {
let value: Any
init(_ value: Any) {
self.value = value
}
init(from decoder: Decoder) throws {
let container = try decoder.singleValueContainer()
if let string = try? container.decode(String.self) {
value = string
} else if let int = try? container.decode(Int.self) {
value = int
} else if let double = try? container.decode(Double.self) {
value = double
} else if let bool = try? container.decode(Bool.self) {
value = bool
} else if let dict = try? container.decode([String: AnyCodable].self) {
value = dict.mapValues(\.value)
} else if let array = try? container.decode([AnyCodable].self) {
value = array.map(\.value)
} else {
value = NSNull()
}
}
func encode(to encoder: Encoder) throws {
var container = encoder.singleValueContainer()
if let string = value as? String {
try container.encode(string)
} else if let int = value as? Int {
try container.encode(int)
} else if let double = value as? Double {
try container.encode(double)
} else if let bool = value as? Bool {
try container.encode(bool)
} else {
try container.encodeNil()
}
}
}

View File

@@ -1,22 +0,0 @@
import Foundation
struct User: Codable, Identifiable, Hashable, Sendable {
let id: String
let name: String
let email: String
let avatarURL: String?
let createdAt: String
let updatedAt: String
enum CodingKeys: String, CodingKey {
case id, name, email
case avatarURL = "avatar_url"
case createdAt = "created_at"
case updatedAt = "updated_at"
}
}
struct AuthResponse: Codable, Sendable {
let token: String
let user: User
}

View File

@@ -1,23 +0,0 @@
import Foundation
struct Workspace: Codable, Identifiable, Sendable {
let id: String
let name: String
let slug: String
let description: String?
let issuePrefix: String
let createdAt: String
let updatedAt: String
enum CodingKeys: String, CodingKey {
case id, name, slug, description
case issuePrefix = "issue_prefix"
case createdAt = "created_at"
case updatedAt = "updated_at"
}
}
struct WorkspaceRepo: Codable, Sendable {
let url: String
let description: String?
}

View File

@@ -1,10 +0,0 @@
import SwiftUI
@main
struct MulticaApp: App {
var body: some Scene {
WindowGroup {
ContentView()
}
}
}

View File

@@ -1,274 +0,0 @@
import Foundation
enum APIError: LocalizedError {
case invalidURL
case unauthorized
case serverError(String)
case networkError(Error)
case decodingError(Error)
var errorDescription: String? {
switch self {
case .invalidURL: "Invalid URL"
case .unauthorized: "Session expired. Please log in again."
case .serverError(let msg): msg
case .networkError(let err): err.localizedDescription
case .decodingError(let err): "Failed to parse response: \(err.localizedDescription)"
}
}
}
@MainActor
final class APIClient: Sendable {
static let shared = APIClient()
// Configure these for your server
#if DEBUG
let baseURL = "http://localhost:8080"
#else
let baseURL = "https://api.multica.ai"
#endif
private let session: URLSession
private let decoder: JSONDecoder
private init() {
let config = URLSessionConfiguration.default
config.timeoutIntervalForRequest = 30
session = URLSession(configuration: config)
decoder = JSONDecoder()
}
var token: String? {
get { KeychainHelper.read(key: "auth_token") }
set {
if let newValue {
KeychainHelper.save(key: "auth_token", value: newValue)
} else {
KeychainHelper.delete(key: "auth_token")
}
}
}
var workspaceId: String? {
get { UserDefaults.standard.string(forKey: "workspace_id") }
set { UserDefaults.standard.set(newValue, forKey: "workspace_id") }
}
// MARK: - Auth
func sendCode(email: String) async throws {
let body = ["email": email]
let _: EmptyResponse = try await post("/auth/send-code", body: body, authenticated: false)
}
func verifyCode(email: String, code: String) async throws -> AuthResponse {
let body = ["email": email, "code": code]
return try await post("/auth/verify-code", body: body, authenticated: false)
}
// MARK: - Workspaces
func listWorkspaces() async throws -> [Workspace] {
try await get("/api/workspaces")
}
func getWorkspace(_ id: String) async throws -> Workspace {
try await get("/api/workspaces/\(id)")
}
// MARK: - Issues
func listIssues(
status: String? = nil,
priority: String? = nil,
assigneeId: String? = nil,
limit: Int = 200,
offset: Int = 0
) async throws -> [Issue] {
var params: [(String, String)] = [
("limit", "\(limit)"),
("offset", "\(offset)"),
]
if let status { params.append(("status", status)) }
if let priority { params.append(("priority", priority)) }
if let assigneeId { params.append(("assignee_id", assigneeId)) }
return try await get("/api/issues", queryItems: params)
}
func getIssue(_ id: String) async throws -> Issue {
try await get("/api/issues/\(id)")
}
func createIssue(_ req: CreateIssueRequest) async throws -> Issue {
try await post("/api/issues", body: req)
}
func updateIssue(_ id: String, _ req: UpdateIssueRequest) async throws -> Issue {
try await put("/api/issues/\(id)", body: req)
}
func deleteIssue(_ id: String) async throws {
let _: EmptyResponse = try await request("DELETE", path: "/api/issues/\(id)")
}
// MARK: - Comments
func listComments(issueId: String) async throws -> [Comment] {
try await get("/api/issues/\(issueId)/comments")
}
func createComment(issueId: String, content: String, parentId: String? = nil) async throws -> Comment {
var body: [String: String] = ["content": content]
if let parentId { body["parent_id"] = parentId }
return try await post("/api/issues/\(issueId)/comments", body: body)
}
// MARK: - Members & Agents
func listMembers(workspaceId: String) async throws -> [Member] {
try await get("/api/workspaces/\(workspaceId)/members")
}
func listAgents() async throws -> [Agent] {
try await get("/api/agents")
}
// MARK: - Tasks
func getActiveTask(issueId: String) async throws -> AgentTask? {
do {
return try await get("/api/issues/\(issueId)/active-task")
} catch APIError.serverError {
return nil
}
}
func listTaskRuns(issueId: String) async throws -> [AgentTask] {
try await get("/api/issues/\(issueId)/task-runs")
}
func listTaskMessages(taskId: String) async throws -> [TaskMessage] {
try await get("/api/tasks/\(taskId)/messages")
}
func cancelTask(issueId: String, taskId: String) async throws {
let _: EmptyResponse = try await post("/api/issues/\(issueId)/tasks/\(taskId)/cancel", body: EmptyBody())
}
// MARK: - Timeline
func listTimeline(issueId: String) async throws -> [TimelineEntry] {
try await get("/api/issues/\(issueId)/timeline")
}
// MARK: - Networking Helpers
private func get<T: Decodable>(_ path: String, queryItems: [(String, String)] = []) async throws -> T {
try await request("GET", path: path, queryItems: queryItems)
}
private func post<T: Decodable, B: Encodable>(_ path: String, body: B, authenticated: Bool = true) async throws -> T {
try await request("POST", path: path, body: body, authenticated: authenticated)
}
private func put<T: Decodable, B: Encodable>(_ path: String, body: B) async throws -> T {
try await request("PUT", path: path, body: body)
}
private func request<T: Decodable>(
_ method: String,
path: String,
queryItems: [(String, String)] = [],
body: (any Encodable)? = nil,
authenticated: Bool = true
) async throws -> T {
guard var components = URLComponents(string: baseURL + path) else {
throw APIError.invalidURL
}
if !queryItems.isEmpty {
components.queryItems = queryItems.map { URLQueryItem(name: $0.0, value: $0.1) }
}
guard let url = components.url else {
throw APIError.invalidURL
}
var request = URLRequest(url: url)
request.httpMethod = method
if authenticated {
guard let token else { throw APIError.unauthorized }
request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization")
}
if let workspaceId, authenticated {
request.setValue(workspaceId, forHTTPHeaderField: "X-Workspace-ID")
}
if let body {
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
request.httpBody = try JSONEncoder().encode(body)
}
let (data, response) : (Data, URLResponse)
do {
(data, response) = try await session.data(for: request)
} catch {
throw APIError.networkError(error)
}
guard let httpResponse = response as? HTTPURLResponse else {
throw APIError.serverError("Invalid response")
}
if httpResponse.statusCode == 401 {
throw APIError.unauthorized
}
if httpResponse.statusCode >= 400 {
if let errorBody = try? JSONDecoder().decode(ErrorResponse.self, from: data) {
throw APIError.serverError(errorBody.error)
}
throw APIError.serverError("Server error (\(httpResponse.statusCode))")
}
// Handle empty responses
if T.self == EmptyResponse.self {
return EmptyResponse() as! T
}
do {
return try decoder.decode(T.self, from: data)
} catch {
throw APIError.decodingError(error)
}
}
}
struct EmptyResponse: Decodable {}
struct EmptyBody: Encodable {}
struct ErrorResponse: Decodable {
let error: String
}
struct TimelineEntry: Codable, Identifiable, Sendable {
let id: String
let issueId: String?
let actorType: String?
let actorId: String?
let action: String?
let field: String?
let oldValue: String?
let newValue: String?
let createdAt: String
enum CodingKeys: String, CodingKey {
case id, action, field
case issueId = "issue_id"
case actorType = "actor_type"
case actorId = "actor_id"
case oldValue = "old_value"
case newValue = "new_value"
case createdAt = "created_at"
}
}

View File

@@ -1,42 +0,0 @@
import Foundation
import Security
enum KeychainHelper {
private static let service = "ai.multica.app"
static func save(key: String, value: String) {
guard let data = value.data(using: .utf8) else { return }
let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: service,
kSecAttrAccount as String: key,
]
SecItemDelete(query as CFDictionary)
var newItem = query
newItem[kSecValueData as String] = data
SecItemAdd(newItem as CFDictionary, nil)
}
static func read(key: String) -> String? {
let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: service,
kSecAttrAccount as String: key,
kSecReturnData as String: true,
kSecMatchLimit as String: kSecMatchLimitOne,
]
var result: AnyObject?
let status = SecItemCopyMatching(query as CFDictionary, &result)
guard status == errSecSuccess, let data = result as? Data else { return nil }
return String(data: data, encoding: .utf8)
}
static func delete(key: String) {
let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: service,
kSecAttrAccount as String: key,
]
SecItemDelete(query as CFDictionary)
}
}

View File

@@ -1,122 +0,0 @@
import Foundation
struct WSEvent: @unchecked Sendable {
let type: String
let payload: [String: Any]
let actorId: String?
var prefix: String {
String(type.prefix(while: { $0 != ":" }))
}
}
@MainActor
final class WebSocketClient: ObservableObject {
static let shared = WebSocketClient()
@Published var isConnected = false
private var webSocketTask: URLSessionWebSocketTask?
private var handlers: [(String, @Sendable (WSEvent) -> Void)] = []
private var reconnectTask: Task<Void, Never>?
private let session = URLSession(configuration: .default)
private init() {}
func connect() {
guard let token = APIClient.shared.token,
let workspaceId = APIClient.shared.workspaceId else { return }
let wsScheme: String
#if DEBUG
wsScheme = "ws"
#else
wsScheme = "wss"
#endif
let baseHost = APIClient.shared.baseURL
.replacingOccurrences(of: "http://", with: "")
.replacingOccurrences(of: "https://", with: "")
guard let url = URL(string: "\(wsScheme)://\(baseHost)/ws?token=\(token)&workspace_id=\(workspaceId)") else {
return
}
webSocketTask = session.webSocketTask(with: url)
webSocketTask?.resume()
isConnected = true
receiveMessage()
}
func disconnect() {
reconnectTask?.cancel()
reconnectTask = nil
webSocketTask?.cancel(with: .goingAway, reason: nil)
webSocketTask = nil
isConnected = false
handlers.removeAll()
}
func on(_ eventType: String, handler: @escaping @Sendable (WSEvent) -> Void) {
handlers.append((eventType, handler))
}
func onPrefix(_ prefix: String, handler: @escaping @Sendable (WSEvent) -> Void) {
handlers.append(("prefix:\(prefix)", handler))
}
private func receiveMessage() {
webSocketTask?.receive { [weak self] result in
Task { @MainActor in
guard let self else { return }
switch result {
case .success(let message):
switch message {
case .string(let text):
self.handleMessage(text)
case .data(let data):
if let text = String(data: data, encoding: .utf8) {
self.handleMessage(text)
}
@unknown default:
break
}
self.receiveMessage()
case .failure:
self.isConnected = false
self.scheduleReconnect()
}
}
}
}
private func handleMessage(_ text: String) {
guard let data = text.data(using: .utf8),
let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
let type = json["type"] as? String else { return }
let payload = json["payload"] as? [String: Any] ?? [:]
let actorId = json["actor_id"] as? String
let event = WSEvent(type: type, payload: payload, actorId: actorId)
for (pattern, handler) in handlers {
if pattern == type {
handler(event)
} else if pattern.hasPrefix("prefix:") {
let prefix = String(pattern.dropFirst(7))
if event.prefix == prefix {
handler(event)
}
}
}
}
private func scheduleReconnect() {
reconnectTask?.cancel()
reconnectTask = Task {
try? await Task.sleep(for: .seconds(3))
guard !Task.isCancelled else { return }
self.connect()
}
}
}

View File

@@ -1,65 +0,0 @@
import Foundation
@MainActor
@Observable
final class AuthViewModel {
var email = ""
var code = ""
var isLoading = false
var error: String?
var codeSent = false
var isAuthenticated = false
var user: User?
init() {
// Check for existing token
if APIClient.shared.token != nil {
isAuthenticated = true
}
}
func sendCode() async {
guard !email.isEmpty else {
error = "Please enter your email"
return
}
isLoading = true
error = nil
do {
try await APIClient.shared.sendCode(email: email)
codeSent = true
} catch {
self.error = error.localizedDescription
}
isLoading = false
}
func verifyCode() async {
guard !code.isEmpty else {
error = "Please enter the verification code"
return
}
isLoading = true
error = nil
do {
let response = try await APIClient.shared.verifyCode(email: email, code: code)
APIClient.shared.token = response.token
user = response.user
isAuthenticated = true
} catch {
self.error = error.localizedDescription
}
isLoading = false
}
func logout() {
APIClient.shared.token = nil
APIClient.shared.workspaceId = nil
WebSocketClient.shared.disconnect()
isAuthenticated = false
user = nil
email = ""
code = ""
codeSent = false
}
}

View File

@@ -1,132 +0,0 @@
import Foundation
@MainActor
@Observable
final class IssueDetailViewModel {
var issue: Issue
var comments: [Comment] = []
var taskRuns: [AgentTask] = []
var activeTask: AgentTask?
var taskMessages: [TaskMessage] = []
var isLoading = false
var error: String?
init(issue: Issue) {
self.issue = issue
}
func loadAll() async {
isLoading = true
do {
async let commentsResult = APIClient.shared.listComments(issueId: issue.id)
async let taskRunsResult = APIClient.shared.listTaskRuns(issueId: issue.id)
async let activeTaskResult = APIClient.shared.getActiveTask(issueId: issue.id)
comments = try await commentsResult
taskRuns = try await taskRunsResult
activeTask = try await activeTaskResult
// Load messages for active task
if let task = activeTask {
taskMessages = try await APIClient.shared.listTaskMessages(taskId: task.id)
}
} catch {
self.error = error.localizedDescription
}
isLoading = false
}
func updateStatus(_ status: IssueStatus) async {
do {
let updated = try await APIClient.shared.updateIssue(issue.id, UpdateIssueRequest(status: status.rawValue))
issue = updated
} catch {
self.error = error.localizedDescription
}
}
func updatePriority(_ priority: IssuePriority) async {
do {
let updated = try await APIClient.shared.updateIssue(issue.id, UpdateIssueRequest(priority: priority.rawValue))
issue = updated
} catch {
self.error = error.localizedDescription
}
}
func updateAssignee(type: String?, id: String?) async {
do {
let updated = try await APIClient.shared.updateIssue(issue.id, UpdateIssueRequest(
assigneeType: type ?? "",
assigneeId: id ?? ""
))
issue = updated
} catch {
self.error = error.localizedDescription
}
}
func updateTitle(_ title: String) async {
do {
let updated = try await APIClient.shared.updateIssue(issue.id, UpdateIssueRequest(title: title))
issue = updated
} catch {
self.error = error.localizedDescription
}
}
func addComment(_ content: String) async {
do {
let comment = try await APIClient.shared.createComment(issueId: issue.id, content: content)
comments.append(comment)
} catch {
self.error = error.localizedDescription
}
}
func loadTaskMessages(taskId: String) async {
do {
taskMessages = try await APIClient.shared.listTaskMessages(taskId: taskId)
} catch {
self.error = error.localizedDescription
}
}
func setupRealtimeUpdates() {
let issueId = issue.id
WebSocketClient.shared.on("task:message") { [weak self] event in
Task { @MainActor in
guard let self,
let payload = event.payload["issue_id"] as? String,
payload == issueId else { return }
// Reload messages for active task
if let task = self.activeTask {
await self.loadTaskMessages(taskId: task.id)
}
}
}
WebSocketClient.shared.onPrefix("task") { [weak self] event in
Task { @MainActor in
guard let self else { return }
await self.loadAll()
}
}
WebSocketClient.shared.onPrefix("comment") { [weak self] event in
Task { @MainActor in
guard let self else { return }
self.comments = (try? await APIClient.shared.listComments(issueId: issueId)) ?? self.comments
}
}
WebSocketClient.shared.on("issue:updated") { [weak self] event in
Task { @MainActor in
guard let self else { return }
if let updated = try? await APIClient.shared.getIssue(issueId) {
self.issue = updated
}
}
}
}
}

View File

@@ -1,63 +0,0 @@
import Foundation
@MainActor
@Observable
final class IssueListViewModel {
var issues: [Issue] = []
var isLoading = false
var error: String?
var statusFilter: IssueStatus?
var searchText = ""
var filteredIssues: [Issue] {
var result = issues
if let statusFilter {
result = result.filter { $0.status == statusFilter }
}
if !searchText.isEmpty {
result = result.filter {
$0.title.localizedCaseInsensitiveContains(searchText) ||
$0.identifier.localizedCaseInsensitiveContains(searchText)
}
}
return result
}
// Group issues by status for sectioned display
var issuesByStatus: [(IssueStatus, [Issue])] {
let grouped = Dictionary(grouping: filteredIssues, by: \.status)
let order: [IssueStatus] = [.inProgress, .todo, .inReview, .blocked, .backlog, .done, .cancelled]
return order.compactMap { status in
guard let issues = grouped[status], !issues.isEmpty else { return nil }
return (status, issues)
}
}
func loadIssues() async {
isLoading = true
error = nil
do {
issues = try await APIClient.shared.listIssues()
} catch {
self.error = error.localizedDescription
}
isLoading = false
}
func refresh() async {
do {
issues = try await APIClient.shared.listIssues()
} catch {
self.error = error.localizedDescription
}
}
func deleteIssue(_ issue: Issue) async {
do {
try await APIClient.shared.deleteIssue(issue.id)
issues.removeAll { $0.id == issue.id }
} catch {
self.error = error.localizedDescription
}
}
}

View File

@@ -1,70 +0,0 @@
import Foundation
@MainActor
@Observable
final class WorkspaceViewModel {
var workspaces: [Workspace] = []
var selectedWorkspace: Workspace?
var members: [Member] = []
var agents: [Agent] = []
var isLoading = false
var error: String?
var hasSelectedWorkspace: Bool {
selectedWorkspace != nil
}
func loadWorkspaces() async {
isLoading = true
error = nil
do {
workspaces = try await APIClient.shared.listWorkspaces()
// Auto-select if there's a saved workspace or only one
if let savedId = APIClient.shared.workspaceId,
let saved = workspaces.first(where: { $0.id == savedId }) {
await selectWorkspace(saved)
} else if workspaces.count == 1 {
await selectWorkspace(workspaces[0])
}
} catch {
self.error = error.localizedDescription
}
isLoading = false
}
func selectWorkspace(_ workspace: Workspace) async {
selectedWorkspace = workspace
APIClient.shared.workspaceId = workspace.id
WebSocketClient.shared.disconnect()
WebSocketClient.shared.connect()
await loadWorkspaceData()
}
func loadWorkspaceData() async {
guard let workspace = selectedWorkspace else { return }
do {
async let membersResult = APIClient.shared.listMembers(workspaceId: workspace.id)
async let agentsResult = APIClient.shared.listAgents()
members = try await membersResult
agents = try await agentsResult
} catch {
self.error = error.localizedDescription
}
}
/// All possible assignees (members + agents)
var assignees: [Assignee] {
let memberAssignees = members.map { Assignee.member($0) }
let agentAssignees = agents.map { Assignee.agent($0) }
return memberAssignees + agentAssignees
}
func assigneeName(type: String?, id: String?) -> String {
guard let type, let id else { return "Unassigned" }
if type == "agent" {
return agents.first(where: { $0.id == id })?.name ?? "Agent"
} else {
return members.first(where: { $0.userId == id })?.displayName ?? "Member"
}
}
}

View File

@@ -1,105 +0,0 @@
import SwiftUI
struct LoginView: View {
@Bindable var viewModel: AuthViewModel
var body: some View {
NavigationStack {
VStack(spacing: 32) {
Spacer()
VStack(spacing: 8) {
Image(systemName: "bolt.circle.fill")
.font(.system(size: 64))
.foregroundStyle(Color.accentColor)
Text("Multica")
.font(.largeTitle.bold())
Text("AI-native project management")
.font(.subheadline)
.foregroundStyle(.secondary)
}
if !viewModel.codeSent {
emailForm
} else {
codeForm
}
if let error = viewModel.error {
Text(error)
.font(.caption)
.foregroundStyle(.red)
.multilineTextAlignment(.center)
}
Spacer()
Spacer()
}
.padding(.horizontal, 32)
.navigationBarHidden(true)
}
}
private var emailForm: some View {
VStack(spacing: 16) {
TextField("Email address", text: $viewModel.email)
.textFieldStyle(.roundedBorder)
.textContentType(.emailAddress)
.keyboardType(.emailAddress)
.autocapitalization(.none)
.disableAutocorrection(true)
Button {
Task { await viewModel.sendCode() }
} label: {
if viewModel.isLoading {
ProgressView()
.frame(maxWidth: .infinity)
} else {
Text("Send Code")
.frame(maxWidth: .infinity)
}
}
.buttonStyle(.borderedProminent)
.controlSize(.large)
.disabled(viewModel.email.isEmpty || viewModel.isLoading)
}
}
private var codeForm: some View {
VStack(spacing: 16) {
Text("Enter the 6-digit code sent to \(viewModel.email)")
.font(.subheadline)
.foregroundStyle(.secondary)
.multilineTextAlignment(.center)
TextField("Verification code", text: $viewModel.code)
.textFieldStyle(.roundedBorder)
.keyboardType(.numberPad)
.multilineTextAlignment(.center)
.font(.title2.monospaced())
Button {
Task { await viewModel.verifyCode() }
} label: {
if viewModel.isLoading {
ProgressView()
.frame(maxWidth: .infinity)
} else {
Text("Verify")
.frame(maxWidth: .infinity)
}
}
.buttonStyle(.borderedProminent)
.controlSize(.large)
.disabled(viewModel.code.isEmpty || viewModel.isLoading)
Button("Use a different email") {
viewModel.codeSent = false
viewModel.code = ""
viewModel.error = nil
}
.font(.caption)
}
}
}

View File

@@ -1,111 +0,0 @@
import SwiftUI
struct CommentListView: View {
@Bindable var viewModel: IssueDetailViewModel
@State private var newComment = ""
@State private var isSending = false
var body: some View {
LazyVStack(alignment: .leading, spacing: 0) {
if viewModel.comments.isEmpty && !viewModel.isLoading {
Text("No comments yet")
.font(.subheadline)
.foregroundStyle(.secondary)
.frame(maxWidth: .infinity)
.padding(.vertical, 32)
}
ForEach(viewModel.comments) { comment in
CommentRowView(comment: comment)
if comment.id != viewModel.comments.last?.id {
Divider().padding(.leading, 48)
}
}
// New comment input
VStack(spacing: 8) {
Divider()
HStack(alignment: .bottom, spacing: 8) {
TextField("Add a comment...", text: $newComment, axis: .vertical)
.textFieldStyle(.roundedBorder)
.lineLimit(1...5)
Button {
Task { await sendComment() }
} label: {
if isSending {
ProgressView()
.controlSize(.small)
} else {
Image(systemName: "arrow.up.circle.fill")
.font(.title2)
}
}
.disabled(newComment.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty || isSending)
}
.padding(.horizontal)
.padding(.vertical, 8)
}
}
}
private func sendComment() async {
let text = newComment.trimmingCharacters(in: .whitespacesAndNewlines)
guard !text.isEmpty else { return }
isSending = true
await viewModel.addComment(text)
newComment = ""
isSending = false
}
}
struct CommentRowView: View {
let comment: Comment
var body: some View {
HStack(alignment: .top, spacing: 10) {
AssigneeAvatar(
type: comment.authorType,
name: comment.authorName ?? "?",
size: 28
)
VStack(alignment: .leading, spacing: 4) {
HStack {
Text(comment.authorName ?? (comment.isFromAgent ? "Agent" : "User"))
.font(.subheadline.bold())
if comment.isFromAgent {
Text("BOT")
.font(.caption2.bold())
.padding(.horizontal, 4)
.padding(.vertical, 1)
.background(.purple.opacity(0.15))
.foregroundStyle(.purple)
.clipShape(RoundedRectangle(cornerRadius: 3))
}
Spacer()
Text(relativeDate(comment.createdAt))
.font(.caption2)
.foregroundStyle(.tertiary)
}
Text(comment.content)
.font(.subheadline)
.foregroundStyle(.primary)
}
}
.padding(.horizontal)
.padding(.vertical, 10)
}
private func relativeDate(_ isoString: String) -> String {
let formatter = ISO8601DateFormatter()
formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
guard let date = formatter.date(from: isoString) ?? ISO8601DateFormatter().date(from: isoString) else {
return ""
}
let relative = RelativeDateTimeFormatter()
relative.unitsStyle = .abbreviated
return relative.localizedString(for: date, relativeTo: Date())
}
}

View File

@@ -1,33 +0,0 @@
import SwiftUI
struct AssigneeAvatar: View {
let type: String?
let name: String
var size: CGFloat = 24
var body: some View {
ZStack {
Circle()
.fill(type == "agent" ? Color.purple.opacity(0.2) : Color.gray.opacity(0.2))
.frame(width: size, height: size)
if type == "agent" {
Image(systemName: "cpu")
.font(.system(size: size * 0.5))
.foregroundStyle(.purple)
} else {
Text(initials)
.font(.system(size: size * 0.4, weight: .medium))
.foregroundStyle(.secondary)
}
}
}
private var initials: String {
let parts = name.split(separator: " ")
if parts.count >= 2 {
return "\(parts[0].prefix(1))\(parts[1].prefix(1))".uppercased()
}
return String(name.prefix(2)).uppercased()
}
}

View File

@@ -1,29 +0,0 @@
import SwiftUI
struct PriorityIcon: View {
let priority: IssuePriority
var size: CGFloat = 14
var body: some View {
Group {
switch priority {
case .urgent:
Image(systemName: "exclamationmark.3")
.foregroundStyle(.red)
case .high:
Image(systemName: "exclamationmark.2")
.foregroundStyle(.orange)
case .medium:
Image(systemName: "exclamationmark")
.foregroundStyle(.yellow)
case .low:
Image(systemName: "arrow.down")
.foregroundStyle(.blue)
case .none:
Image(systemName: "minus")
.foregroundStyle(.gray)
}
}
.font(.system(size: size, weight: .medium))
}
}

View File

@@ -1,46 +0,0 @@
import SwiftUI
struct StatusBadge: View {
let status: IssueStatus
var body: some View {
Label(status.label, systemImage: status.iconName)
.font(.caption)
.foregroundStyle(statusColor)
}
private var statusColor: Color {
switch status {
case .backlog: .gray
case .todo: .primary
case .inProgress: .yellow
case .inReview: .blue
case .done: .green
case .blocked: .red
case .cancelled: .gray
}
}
}
struct StatusIcon: View {
let status: IssueStatus
var size: CGFloat = 16
var body: some View {
Image(systemName: status.iconName)
.font(.system(size: size))
.foregroundStyle(statusColor)
}
private var statusColor: Color {
switch status {
case .backlog: .gray
case .todo: .primary
case .inProgress: .yellow
case .inReview: .blue
case .done: .green
case .blocked: .red
case .cancelled: .gray
}
}
}

View File

@@ -1,38 +0,0 @@
import SwiftUI
struct ContentView: View {
@State private var authVM = AuthViewModel()
@State private var workspaceVM = WorkspaceViewModel()
@State private var issueListVM = IssueListViewModel()
var body: some View {
Group {
if !authVM.isAuthenticated {
LoginView(viewModel: authVM)
} else if !workspaceVM.hasSelectedWorkspace {
WorkspacePickerView(viewModel: workspaceVM)
} else {
IssueListView(viewModel: issueListVM, workspaceVM: workspaceVM)
}
}
.onReceive(NotificationCenter.default.publisher(for: .logout)) { _ in
authVM.logout()
workspaceVM.selectedWorkspace = nil
issueListVM.issues = []
}
.onChange(of: workspaceVM.hasSelectedWorkspace) { _, hasWorkspace in
if hasWorkspace {
Task { await issueListVM.loadIssues() }
setupRealtimeSync()
}
}
}
private func setupRealtimeSync() {
WebSocketClient.shared.onPrefix("issue") { _ in
Task { @MainActor in
await issueListVM.refresh()
}
}
}
}

View File

@@ -1,96 +0,0 @@
import SwiftUI
struct CreateIssueView: View {
@Environment(\.dismiss) private var dismiss
@Bindable var workspaceVM: WorkspaceViewModel
var onCreate: (Issue) -> Void
@State private var title = ""
@State private var description = ""
@State private var status: IssueStatus = .todo
@State private var priority: IssuePriority = .none
@State private var selectedAssignee: Assignee?
@State private var isSubmitting = false
@State private var error: String?
var body: some View {
NavigationStack {
Form {
Section {
TextField("Title", text: $title)
TextField("Description (optional)", text: $description, axis: .vertical)
.lineLimit(3...8)
}
Section("Properties") {
// Status picker
Picker("Status", selection: $status) {
ForEach(IssueStatus.allCases, id: \.self) { s in
Label(s.label, systemImage: s.iconName).tag(s)
}
}
// Priority picker
Picker("Priority", selection: $priority) {
ForEach(IssuePriority.allCases, id: \.self) { p in
Label(p.label, systemImage: p.iconName).tag(p)
}
}
// Assignee picker
Picker("Assignee", selection: $selectedAssignee) {
Text("Unassigned").tag(nil as Assignee?)
ForEach(workspaceVM.assignees) { assignee in
Label(
assignee.name,
systemImage: assignee.typeName == "agent" ? "cpu" : "person"
).tag(assignee as Assignee?)
}
}
}
if let error {
Section {
Text(error)
.foregroundStyle(.red)
.font(.caption)
}
}
}
.navigationTitle("New Issue")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .cancellationAction) {
Button("Cancel") { dismiss() }
}
ToolbarItem(placement: .confirmationAction) {
Button("Create") {
Task { await createIssue() }
}
.disabled(title.isEmpty || isSubmitting)
}
}
}
}
private func createIssue() async {
isSubmitting = true
error = nil
do {
let request = CreateIssueRequest(
title: title,
description: description.isEmpty ? nil : description,
status: status.rawValue,
priority: priority.rawValue,
assigneeType: selectedAssignee?.typeName,
assigneeId: selectedAssignee?.entityId
)
let issue = try await APIClient.shared.createIssue(request)
onCreate(issue)
dismiss()
} catch {
self.error = error.localizedDescription
}
isSubmitting = false
}
}

View File

@@ -1,182 +0,0 @@
import SwiftUI
struct IssueDetailView: View {
@Bindable var viewModel: IssueDetailViewModel
@Bindable var workspaceVM: WorkspaceViewModel
@State private var selectedTab = 0
var body: some View {
ScrollView {
VStack(alignment: .leading, spacing: 0) {
issueHeader
Divider()
propertiesSection
Divider()
// Tab bar
Picker("", selection: $selectedTab) {
Text("Comments").tag(0)
Text("Activity").tag(1)
if viewModel.issue.isAssignedToAgent {
Text("Agent Runs").tag(2)
}
}
.pickerStyle(.segmented)
.padding()
switch selectedTab {
case 0:
CommentListView(viewModel: viewModel)
case 1:
activitySection
case 2:
TaskRunsView(viewModel: viewModel)
default:
EmptyView()
}
}
}
.navigationTitle(viewModel.issue.identifier)
.navigationBarTitleDisplayMode(.inline)
.task {
await viewModel.loadAll()
viewModel.setupRealtimeUpdates()
}
.alert("Error", isPresented: .init(
get: { viewModel.error != nil },
set: { if !$0 { viewModel.error = nil } }
)) {
Button("OK") { viewModel.error = nil }
} message: {
Text(viewModel.error ?? "")
}
}
private var issueHeader: some View {
VStack(alignment: .leading, spacing: 12) {
Text(viewModel.issue.title)
.font(.title2.bold())
if let desc = viewModel.issue.description, !desc.isEmpty {
Text(desc)
.font(.body)
.foregroundStyle(.secondary)
}
// Active task banner
if let task = viewModel.activeTask, task.status.isActive {
HStack(spacing: 8) {
ProgressView()
.controlSize(.small)
Text("Agent is working on this issue...")
.font(.caption)
.foregroundStyle(.secondary)
Spacer()
Button("View Logs") {
selectedTab = 2
}
.font(.caption)
}
.padding(10)
.background(.purple.opacity(0.1))
.clipShape(RoundedRectangle(cornerRadius: 8))
}
}
.padding()
}
private var propertiesSection: some View {
VStack(spacing: 0) {
// Status
propertyRow(label: "Status") {
Menu {
ForEach(IssueStatus.allCases, id: \.self) { status in
Button {
Task { await viewModel.updateStatus(status) }
} label: {
Label(status.label, systemImage: status.iconName)
}
}
} label: {
StatusBadge(status: viewModel.issue.status)
}
}
Divider().padding(.leading)
// Priority
propertyRow(label: "Priority") {
Menu {
ForEach(IssuePriority.allCases, id: \.self) { priority in
Button {
Task { await viewModel.updatePriority(priority) }
} label: {
Label(priority.label, systemImage: priority.iconName)
}
}
} label: {
HStack(spacing: 4) {
PriorityIcon(priority: viewModel.issue.priority)
Text(viewModel.issue.priority.label)
.font(.subheadline)
}
}
}
Divider().padding(.leading)
// Assignee
propertyRow(label: "Assignee") {
Menu {
Button("Unassigned") {
Task { await viewModel.updateAssignee(type: nil, id: nil) }
}
Divider()
ForEach(workspaceVM.assignees) { assignee in
Button {
Task { await viewModel.updateAssignee(type: assignee.typeName, id: assignee.entityId) }
} label: {
Label(
assignee.name,
systemImage: assignee.typeName == "agent" ? "cpu" : "person"
)
}
}
} label: {
let name = workspaceVM.assigneeName(type: viewModel.issue.assigneeType, id: viewModel.issue.assigneeId)
HStack(spacing: 6) {
AssigneeAvatar(type: viewModel.issue.assigneeType, name: name, size: 20)
Text(name)
.font(.subheadline)
}
}
}
}
}
private func propertyRow<Content: View>(label: String, @ViewBuilder content: () -> Content) -> some View {
HStack {
Text(label)
.font(.subheadline)
.foregroundStyle(.secondary)
.frame(width: 80, alignment: .leading)
content()
Spacer()
}
.padding(.horizontal)
.padding(.vertical, 10)
}
private var activitySection: some View {
LazyVStack(alignment: .leading, spacing: 0) {
if viewModel.isLoading {
ProgressView()
.frame(maxWidth: .infinity)
.padding()
} else {
Text("Activity timeline")
.font(.caption)
.foregroundStyle(.secondary)
.padding()
}
}
}
}

View File

@@ -1,113 +0,0 @@
import SwiftUI
struct IssueListView: View {
@Bindable var viewModel: IssueListViewModel
@Bindable var workspaceVM: WorkspaceViewModel
@State private var showCreateIssue = false
var body: some View {
NavigationStack {
Group {
if viewModel.isLoading && viewModel.issues.isEmpty {
ProgressView("Loading issues...")
} else if viewModel.filteredIssues.isEmpty {
ContentUnavailableView.search(text: viewModel.searchText)
} else {
issueList
}
}
.navigationTitle(workspaceVM.selectedWorkspace?.name ?? "Issues")
.searchable(text: $viewModel.searchText, prompt: "Search issues...")
.toolbar {
ToolbarItem(placement: .topBarTrailing) {
Button {
showCreateIssue = true
} label: {
Image(systemName: "plus")
}
}
ToolbarItem(placement: .topBarTrailing) {
Menu {
Button("All") { viewModel.statusFilter = nil }
Divider()
ForEach(IssueStatus.allCases, id: \.self) { status in
Button {
viewModel.statusFilter = status
} label: {
Label(status.label, systemImage: status.iconName)
}
}
} label: {
Image(systemName: viewModel.statusFilter != nil ? "line.3.horizontal.decrease.circle.fill" : "line.3.horizontal.decrease.circle")
}
}
ToolbarItem(placement: .topBarLeading) {
Menu {
Button("Switch Workspace") {
workspaceVM.selectedWorkspace = nil
}
Button("Logout", role: .destructive) {
NotificationCenter.default.post(name: .logout, object: nil)
}
} label: {
Image(systemName: "person.circle")
}
}
}
.refreshable {
await viewModel.refresh()
}
.sheet(isPresented: $showCreateIssue) {
CreateIssueView(workspaceVM: workspaceVM) { newIssue in
viewModel.issues.insert(newIssue, at: 0)
}
}
.task {
await viewModel.loadIssues()
}
}
}
private var issueList: some View {
List {
ForEach(viewModel.issuesByStatus, id: \.0) { status, issues in
Section {
ForEach(issues) { issue in
NavigationLink(value: issue) {
IssueRowView(
issue: issue,
assigneeName: workspaceVM.assigneeName(type: issue.assigneeType, id: issue.assigneeId)
)
}
}
.onDelete { indexSet in
for index in indexSet {
let issue = issues[index]
Task { await viewModel.deleteIssue(issue) }
}
}
} header: {
HStack(spacing: 6) {
StatusIcon(status: status, size: 14)
Text(status.label)
.font(.caption.weight(.semibold))
Text("\(issues.count)")
.font(.caption2)
.foregroundStyle(.secondary)
}
}
}
}
.listStyle(.insetGrouped)
.navigationDestination(for: Issue.self) { issue in
IssueDetailView(
viewModel: IssueDetailViewModel(issue: issue),
workspaceVM: workspaceVM
)
}
}
}
extension Notification.Name {
static let logout = Notification.Name("logout")
}

View File

@@ -1,36 +0,0 @@
import SwiftUI
struct IssueRowView: View {
let issue: Issue
let assigneeName: String
var body: some View {
HStack(spacing: 10) {
StatusIcon(status: issue.status)
VStack(alignment: .leading, spacing: 4) {
HStack(spacing: 6) {
Text(issue.identifier)
.font(.caption.monospaced())
.foregroundStyle(.secondary)
PriorityIcon(priority: issue.priority, size: 10)
}
Text(issue.title)
.font(.subheadline)
.lineLimit(2)
}
Spacer()
if issue.assigneeId != nil {
AssigneeAvatar(
type: issue.assigneeType,
name: assigneeName,
size: 24
)
}
}
.padding(.vertical, 2)
}
}

View File

@@ -1,138 +0,0 @@
import SwiftUI
struct TaskMessagesView: View {
let messages: [TaskMessage]
let isLive: Bool
var body: some View {
LazyVStack(alignment: .leading, spacing: 2) {
if messages.isEmpty {
Text("No log messages")
.font(.caption)
.foregroundStyle(.secondary)
.frame(maxWidth: .infinity)
.padding()
}
ForEach(messages) { message in
TaskMessageRow(message: message)
}
if isLive && !messages.isEmpty {
HStack(spacing: 6) {
ProgressView()
.controlSize(.mini)
Text("Streaming...")
.font(.caption2)
.foregroundStyle(.secondary)
}
.padding(.horizontal, 12)
.padding(.vertical, 4)
}
}
}
}
struct TaskMessageRow: View {
let message: TaskMessage
@State private var isExpanded = false
var body: some View {
VStack(alignment: .leading, spacing: 2) {
switch message.type {
case .text:
textMessage
case .thinking:
thinkingMessage
case .toolUse:
toolUseMessage
case .toolResult:
toolResultMessage
case .error:
errorMessage
}
}
.padding(.horizontal, 12)
.padding(.vertical, 3)
}
private var textMessage: some View {
Text(message.content ?? "")
.font(.caption.monospaced())
.foregroundStyle(.primary)
}
private var thinkingMessage: some View {
VStack(alignment: .leading, spacing: 2) {
HStack(spacing: 4) {
Image(systemName: "brain")
.font(.caption2)
Text("Thinking")
.font(.caption2.weight(.medium))
}
.foregroundStyle(.purple.opacity(0.7))
if let content = message.content, !content.isEmpty {
Text(content)
.font(.caption2.monospaced())
.foregroundStyle(.secondary)
.lineLimit(isExpanded ? nil : 3)
.onTapGesture { isExpanded.toggle() }
}
}
}
private var toolUseMessage: some View {
VStack(alignment: .leading, spacing: 2) {
HStack(spacing: 4) {
Image(systemName: "wrench")
.font(.caption2)
Text(message.tool ?? "Tool")
.font(.caption2.weight(.semibold).monospaced())
}
.foregroundStyle(.blue)
if let content = message.content, !content.isEmpty {
Text(content)
.font(.caption2.monospaced())
.foregroundStyle(.secondary)
.lineLimit(isExpanded ? nil : 2)
.onTapGesture { isExpanded.toggle() }
}
}
}
private var toolResultMessage: some View {
VStack(alignment: .leading, spacing: 2) {
HStack(spacing: 4) {
Image(systemName: "arrow.turn.down.left")
.font(.caption2)
Text(message.tool ?? "Result")
.font(.caption2.weight(.medium).monospaced())
}
.foregroundStyle(.green)
if let output = message.output, !output.isEmpty {
Text(output)
.font(.caption2.monospaced())
.foregroundStyle(.secondary)
.lineLimit(isExpanded ? nil : 3)
.onTapGesture { isExpanded.toggle() }
}
}
}
private var errorMessage: some View {
HStack(spacing: 4) {
Image(systemName: "exclamationmark.triangle")
.font(.caption2)
Text(message.content ?? "Error")
.font(.caption2.monospaced())
}
.foregroundStyle(.red)
}
}

View File

@@ -1,206 +0,0 @@
import SwiftUI
struct TaskRunsView: View {
@Bindable var viewModel: IssueDetailViewModel
var body: some View {
LazyVStack(alignment: .leading, spacing: 0) {
if viewModel.taskRuns.isEmpty && !viewModel.isLoading {
VStack(spacing: 8) {
Image(systemName: "cpu")
.font(.title)
.foregroundStyle(.secondary)
Text("No agent runs yet")
.font(.subheadline)
.foregroundStyle(.secondary)
Text("Assign this issue to an agent to trigger execution.")
.font(.caption)
.foregroundStyle(.tertiary)
}
.frame(maxWidth: .infinity)
.padding(.vertical, 32)
}
// Active task with live logs
if let activeTask = viewModel.activeTask, activeTask.status.isActive {
VStack(alignment: .leading, spacing: 8) {
HStack {
ProgressView()
.controlSize(.small)
Text("Running")
.font(.subheadline.bold())
.foregroundStyle(.purple)
Spacer()
Text("Started \(relativeDate(activeTask.startedAt ?? activeTask.createdAt))")
.font(.caption2)
.foregroundStyle(.secondary)
}
.padding(.horizontal)
.padding(.top, 12)
TaskMessagesView(messages: viewModel.taskMessages, isLive: true)
}
.background(.purple.opacity(0.03))
Divider()
}
// Historical runs
ForEach(viewModel.taskRuns.filter { t in
viewModel.activeTask.map { $0.id != t.id } ?? true
}) { task in
NavigationLink {
TaskRunDetailView(task: task)
} label: {
TaskRunRow(task: task)
}
Divider().padding(.leading)
}
}
}
private func relativeDate(_ isoString: String) -> String {
let formatter = ISO8601DateFormatter()
formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
guard let date = formatter.date(from: isoString) ?? ISO8601DateFormatter().date(from: isoString) else {
return ""
}
let relative = RelativeDateTimeFormatter()
relative.unitsStyle = .abbreviated
return relative.localizedString(for: date, relativeTo: Date())
}
}
struct TaskRunRow: View {
let task: AgentTask
var body: some View {
HStack(spacing: 10) {
Image(systemName: task.status.iconName)
.foregroundStyle(taskColor)
.font(.body)
VStack(alignment: .leading, spacing: 3) {
HStack {
Text(task.status.label)
.font(.subheadline.weight(.medium))
if let error = task.error {
Text(error)
.font(.caption2)
.foregroundStyle(.red)
.lineLimit(1)
}
}
HStack(spacing: 12) {
Text(formatDate(task.createdAt))
.font(.caption2)
.foregroundStyle(.secondary)
if let started = task.startedAt, let completed = task.completedAt {
Text(duration(from: started, to: completed))
.font(.caption2)
.foregroundStyle(.secondary)
}
}
}
Spacer()
Image(systemName: "chevron.right")
.font(.caption)
.foregroundStyle(.tertiary)
}
.padding(.horizontal)
.padding(.vertical, 10)
}
private var taskColor: Color {
switch task.status {
case .completed: .green
case .failed: .red
case .running: .purple
case .cancelled: .gray
default: .secondary
}
}
private func formatDate(_ iso: String) -> String {
let formatter = ISO8601DateFormatter()
formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
guard let date = formatter.date(from: iso) ?? ISO8601DateFormatter().date(from: iso) else { return iso }
let df = DateFormatter()
df.dateStyle = .short
df.timeStyle = .short
return df.string(from: date)
}
private func duration(from start: String, to end: String) -> String {
let formatter = ISO8601DateFormatter()
formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
guard let s = formatter.date(from: start) ?? ISO8601DateFormatter().date(from: start),
let e = formatter.date(from: end) ?? ISO8601DateFormatter().date(from: end) else { return "" }
let interval = e.timeIntervalSince(s)
if interval < 60 { return "\(Int(interval))s" }
if interval < 3600 { return "\(Int(interval / 60))m \(Int(interval.truncatingRemainder(dividingBy: 60)))s" }
return "\(Int(interval / 3600))h \(Int((interval.truncatingRemainder(dividingBy: 3600)) / 60))m"
}
}
struct TaskRunDetailView: View {
let task: AgentTask
@State private var messages: [TaskMessage] = []
@State private var isLoading = true
var body: some View {
ScrollView {
VStack(alignment: .leading, spacing: 16) {
// Task info header
HStack {
Image(systemName: task.status.iconName)
.foregroundStyle(taskColor)
Text(task.status.label)
.font(.headline)
Spacer()
}
.padding(.horizontal)
if let error = task.error {
Text(error)
.font(.caption)
.foregroundStyle(.red)
.padding(.horizontal)
}
Divider()
if isLoading {
ProgressView("Loading execution log...")
.frame(maxWidth: .infinity)
.padding()
} else {
TaskMessagesView(messages: messages, isLive: false)
}
}
.padding(.top)
}
.navigationTitle("Run Details")
.navigationBarTitleDisplayMode(.inline)
.task {
do {
messages = try await APIClient.shared.listTaskMessages(taskId: task.id)
} catch {
// silently fail
}
isLoading = false
}
}
private var taskColor: Color {
switch task.status {
case .completed: .green
case .failed: .red
case .running: .purple
case .cancelled: .gray
default: .secondary
}
}
}

View File

@@ -1,47 +0,0 @@
import SwiftUI
struct WorkspacePickerView: View {
@Bindable var viewModel: WorkspaceViewModel
var body: some View {
NavigationStack {
Group {
if viewModel.isLoading {
ProgressView("Loading workspaces...")
} else if viewModel.workspaces.isEmpty {
ContentUnavailableView(
"No Workspaces",
systemImage: "folder",
description: Text("You don't belong to any workspaces yet.")
)
} else {
List(viewModel.workspaces) { workspace in
Button {
Task { await viewModel.selectWorkspace(workspace) }
} label: {
HStack {
VStack(alignment: .leading, spacing: 4) {
Text(workspace.name)
.font(.headline)
if let desc = workspace.description, !desc.isEmpty {
Text(desc)
.font(.caption)
.foregroundStyle(.secondary)
}
}
Spacer()
Image(systemName: "chevron.right")
.foregroundStyle(.tertiary)
}
}
.foregroundStyle(.primary)
}
}
}
.navigationTitle("Workspaces")
.task {
await viewModel.loadWorkspaces()
}
}
}
}

View File

@@ -1,64 +0,0 @@
# Multica iOS App
MVP iOS client for the Multica platform — issue management with AI agent execution log viewing.
## Requirements
- Xcode 16+
- iOS 17.0+
- [XcodeGen](https://github.com/yonaskolb/XcodeGen) (for generating the Xcode project)
## Setup
```bash
# Install XcodeGen if you don't have it
brew install xcodegen
# Generate Xcode project
cd apps/ios
xcodegen generate
# Open in Xcode
open Multica.xcodeproj
```
## Configuration
By default, the app connects to `http://localhost:8080` in debug builds. To change the API URL, edit `Multica/Services/APIClient.swift`.
## Features
- **Authentication** — Passwordless email login (send code → verify)
- **Workspace selection** — Pick from your workspaces
- **Issue list** — Grouped by status, searchable, with status filtering
- **Issue detail** — View/edit title, status, priority, and assignee
- **Comments** — View and add comments with threaded display
- **Agent task runs** — View all historical agent executions for an issue
- **Execution logs** — Real-time streaming of agent tool use, thinking, and output
- **Real-time sync** — WebSocket connection for live updates
## Architecture
- **SwiftUI** with `@Observable` (iOS 17+)
- **MVVM** — ViewModels use `@Observable` macro
- **URLSession** for HTTP networking
- **URLSessionWebSocketTask** for real-time
- **Keychain** for secure token storage
- No third-party dependencies
## Structure
```
Multica/
├── MulticaApp.swift # App entry point
├── Models/ # Codable data models
├── Services/ # API client, WebSocket, Keychain
├── ViewModels/ # @Observable view models
└── Views/
├── Auth/ # Login + code verification
├── Workspace/ # Workspace picker
├── Issues/ # List, detail, create
├── Comments/ # Comment list + input
├── Tasks/ # Agent runs + execution logs
└── Components/ # Shared UI (badges, icons, avatars)
```

View File

@@ -1,24 +0,0 @@
name: Multica
options:
bundleIdPrefix: ai.multica
deploymentTarget:
iOS: "17.0"
xcodeVersion: "16.0"
generateEmptyDirectories: true
settings:
base:
SWIFT_VERSION: "5.9"
MARKETING_VERSION: "1.0.0"
CURRENT_PROJECT_VERSION: 1
DEVELOPMENT_TEAM: ""
targets:
Multica:
type: application
platform: iOS
sources:
- Multica
settings:
base:
INFOPLIST_FILE: Multica/Info.plist
PRODUCT_BUNDLE_IDENTIFIER: ai.multica.app
ASSETCATALOG_COMPILER_APPICON_NAME: AppIcon

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

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

@@ -1,6 +1,6 @@
"use client";
import { useState, useEffect, useRef } from "react";
import { useState, useEffect, useRef, useMemo } from "react";
import { useDefaultLayout } from "react-resizable-panels";
import {
Bot,
@@ -29,6 +29,7 @@ import {
Lock,
Settings,
Camera,
Archive,
} from "lucide-react";
import type {
Agent,
@@ -70,6 +71,7 @@ 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";
@@ -328,6 +330,7 @@ function AgentListItem({
onClick: () => void;
}) {
const st = statusConfig[agent.status];
const isArchived = !!agent.archived_at;
return (
<button
@@ -336,11 +339,11 @@ function AgentListItem({
isSelected ? "bg-accent" : "hover:bg-accent/50"
}`}
>
<ActorAvatar actorType="agent" actorId={agent.id} size={32} className="rounded-lg" />
<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" />
) : (
@@ -348,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>
@@ -380,6 +389,8 @@ function InstructionsTab({
setSaving(true);
try {
await onSave(value);
} catch {
// toast handled by parent
} finally {
setSaving(false);
}
@@ -446,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);
@@ -458,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);
}
@@ -701,6 +716,8 @@ function ToolsTab({
setSaving(true);
try {
await onSave(tools);
} catch {
// toast handled by parent
} finally {
setSaving(false);
}
@@ -845,6 +862,8 @@ function TriggersTab({
setSaving(true);
try {
await onSave(triggers);
} catch {
// toast handled by parent
} finally {
setSaving(false);
}
@@ -1050,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>
);
}
@@ -1346,30 +1374,50 @@ function AgentDetail({
agent,
runtimes,
onUpdate,
onDelete,
onArchive,
onRestore,
}: {
agent: Agent;
runtimes: RuntimeDevice[];
onUpdate: (id: string, data: Partial<Agent>) => Promise<void>;
onDelete: (id: string) => Promise<void>;
onArchive: (id: string) => Promise<void>;
onRestore: (id: string) => Promise<void>;
}) {
const st = statusConfig[agent.status];
const runtimeDevice = getRuntimeDevice(agent, runtimes);
const [activeTab, setActiveTab] = useState<DetailTab>("instructions");
const [confirmDelete, setConfirmDelete] = useState(false);
const [confirmArchive, setConfirmArchive] = useState(false);
const isArchived = !!agent.archived_at;
return (
<div className="flex h-full flex-col">
{/* Archive Banner */}
{isArchived && (
<div className="flex items-center gap-2 bg-muted/50 px-4 py-2 text-xs text-muted-foreground border-b">
<AlertCircle className="h-3.5 w-3.5 shrink-0" />
<span className="flex-1">This agent is archived. It cannot be assigned or mentioned.</span>
<Button variant="outline" size="sm" className="h-6 text-xs" onClick={() => onRestore(agent.id)}>
Restore
</Button>
</div>
)}
{/* Header */}
<div className="flex h-12 shrink-0 items-center gap-3 border-b px-4">
<ActorAvatar actorType="agent" actorId={agent.id} size={28} className="rounded-md" />
<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" />
@@ -1380,24 +1428,26 @@ function AgentDetail({
</span>
</div>
</div>
<DropdownMenu>
<DropdownMenuTrigger
render={
<Button variant="ghost" size="icon-sm" />
}
>
<MoreHorizontal className="h-4 w-4 text-muted-foreground" />
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem
className="text-destructive"
onClick={() => setConfirmDelete(true)}
{!isArchived && (
<DropdownMenu>
<DropdownMenuTrigger
render={
<Button variant="ghost" size="icon-sm" />
}
>
<Trash2 className="h-3.5 w-3.5" />
Delete Agent
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
<MoreHorizontal className="h-4 w-4 text-muted-foreground" />
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem
className="text-destructive"
onClick={() => setConfirmArchive(true)}
>
<Trash2 className="h-3.5 w-3.5" />
Archive Agent
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
)}
</div>
{/* Tabs */}
@@ -1451,33 +1501,33 @@ function AgentDetail({
)}
</div>
{/* Delete Confirmation */}
{confirmDelete && (
<Dialog open onOpenChange={(v) => { if (!v) setConfirmDelete(false); }}>
{/* Archive Confirmation */}
{confirmArchive && (
<Dialog open onOpenChange={(v) => { if (!v) setConfirmArchive(false); }}>
<DialogContent className="max-w-sm" showCloseButton={false}>
<div className="flex items-center gap-3">
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-full bg-destructive/10">
<AlertCircle className="h-5 w-5 text-destructive" />
</div>
<DialogHeader className="flex-1 gap-1">
<DialogTitle className="text-sm font-semibold">Delete agent?</DialogTitle>
<DialogTitle className="text-sm font-semibold">Archive agent?</DialogTitle>
<DialogDescription className="text-xs">
This will permanently delete &quot;{agent.name}&quot; and all its configuration.
&quot;{agent.name}&quot; will be archived. It won&apos;t be assignable or mentionable, but all history is preserved. You can restore it later.
</DialogDescription>
</DialogHeader>
</div>
<DialogFooter>
<Button variant="ghost" onClick={() => setConfirmDelete(false)}>
<Button variant="ghost" onClick={() => setConfirmArchive(false)}>
Cancel
</Button>
<Button
variant="destructive"
onClick={() => {
setConfirmDelete(false);
onDelete(agent.id);
setConfirmArchive(false);
onArchive(agent.id);
}}
>
Delete
Archive
</Button>
</DialogFooter>
</DialogContent>
@@ -1497,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);
@@ -1508,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);
@@ -1522,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>
);
}
@@ -1557,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}
@@ -1603,7 +1726,8 @@ export default function AgentsPage() {
agent={selected}
runtimes={runtimes}
onUpdate={handleUpdate}
onDelete={handleDelete}
onArchive={handleArchive}
onRestore={handleRestore}
/>
) : (
<div className="flex h-full flex-col items-center justify-center text-muted-foreground">

View File

@@ -1,5 +1,6 @@
"use client";
import { useState, useEffect, useCallback } from "react";
import { useSearchParams } from "next/navigation";
import { useDefaultLayout } from "react-resizable-panels";
import { useInboxStore } from "@/features/inbox";
@@ -219,11 +220,20 @@ function InboxListItem({
export default function InboxPage() {
const searchParams = useSearchParams();
const selectedKey = searchParams.get("issue") ?? "";
const setSelectedKey = (key: string) => {
const urlIssue = searchParams.get("issue") ?? "";
const [selectedKey, setSelectedKeyState] = useState(() => urlIssue);
// Sync from URL when searchParams change (e.g. Next.js navigation)
useEffect(() => {
setSelectedKeyState(urlIssue);
}, [urlIssue]);
const setSelectedKey = useCallback((key: string) => {
setSelectedKeyState(key);
const url = key ? `/inbox?issue=${key}` : "/inbox";
window.history.replaceState(null, "", url);
};
}, []);
const items = useInboxStore((s) => s.dedupedItems());
const loading = useInboxStore((s) => s.loading);
@@ -413,10 +423,11 @@ export default function InboxPage() {
<div className="flex flex-col min-h-0 h-full">
{selected?.issue_id ? (
<IssueDetail
key={selected.issue_id}
key={selected.id}
issueId={selected.issue_id}
defaultSidebarOpen={false}
layoutId="multica_inbox_issue_detail_layout"
highlightCommentId={selected.details?.comment_id ?? undefined}
onDelete={() => {
handleArchive(selected.id);
}}

View File

@@ -104,9 +104,9 @@ vi.mock("@/components/ui/calendar", () => ({
Calendar: () => null,
}));
// Mock RichTextEditor (Tiptap needs real DOM)
vi.mock("@/components/common/rich-text-editor", () => ({
RichTextEditor: forwardRef(({ defaultValue, onUpdate, placeholder, onSubmit }: any, ref: any) => {
// Mock ContentEditor (Tiptap needs real DOM)
vi.mock("@/features/editor", () => ({
ContentEditor: forwardRef(({ defaultValue, onUpdate, placeholder, onSubmit }: any, ref: any) => {
const valueRef = useRef(defaultValue || "");
const [value, setValue] = useState(defaultValue || "");
useImperativeHandle(ref, () => ({
@@ -132,6 +132,27 @@ vi.mock("@/components/common/rich-text-editor", () => ({
/>
);
}),
TitleEditor: forwardRef(({ defaultValue, placeholder, onBlur, onChange }: any, ref: any) => {
const valueRef = useRef(defaultValue || "");
const [value, setValue] = useState(defaultValue || "");
useImperativeHandle(ref, () => ({
getText: () => valueRef.current,
focus: () => {},
}));
return (
<input
value={value}
onChange={(e) => {
valueRef.current = e.target.value;
setValue(e.target.value);
onChange?.(e.target.value);
}}
onBlur={() => onBlur?.(valueRef.current)}
placeholder={placeholder}
data-testid="title-editor"
/>
);
}),
}));
// Mock Markdown renderer
@@ -164,6 +185,9 @@ vi.mock("@/shared/api", () => ({
getActiveTaskForIssue: vi.fn().mockResolvedValue({ task: null }),
listTasksByIssue: vi.fn().mockResolvedValue([]),
listTaskMessages: vi.fn().mockResolvedValue([]),
listDependencies: vi.fn().mockResolvedValue([]),
createDependency: vi.fn().mockResolvedValue({}),
deleteDependency: vi.fn().mockResolvedValue(undefined),
},
}));

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

@@ -41,6 +41,13 @@ export const metadata: Metadata = {
twitter: {
card: "summary_large_image",
},
alternates: {
canonical: "/",
},
robots: {
index: true,
follow: true,
},
};
export default async function RootLayout({

View File

@@ -3,33 +3,28 @@
import { useRef } from "react";
import { Paperclip } from "lucide-react";
import { cn } from "@/lib/utils";
import type { UploadResult } from "@/shared/hooks/use-file-upload";
interface FileUploadButtonProps {
onUpload: (file: File) => Promise<UploadResult | null>;
onInsert?: (result: UploadResult, isImage: boolean) => void;
/** Called with the selected File — caller handles upload. */
onSelect: (file: File) => void;
disabled?: boolean;
className?: string;
size?: "sm" | "default";
}
function FileUploadButton({
onUpload,
onInsert,
onSelect,
disabled,
className,
size = "default",
}: FileUploadButtonProps) {
const inputRef = useRef<HTMLInputElement>(null);
const handleChange = async (e: React.ChangeEvent<HTMLInputElement>) => {
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (!file) return;
e.target.value = "";
const result = await onUpload(file);
if (result && onInsert) {
onInsert(result, file.type.startsWith("image/"));
}
onSelect(file);
};
const iconSize = size === "sm" ? "h-3.5 w-3.5" : "h-4 w-4";

View File

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

View File

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

View File

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

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

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

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

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,6 @@
/**
* Copy markdown content to the clipboard.
*/
export async function copyMarkdown(markdown: string): Promise<void> {
await navigator.clipboard.writeText(markdown);
}

View File

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

View File

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

View File

@@ -7,6 +7,7 @@ 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 { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible";
import { useActorName } from "@/features/workspace";
import { redactSecrets } from "../utils/redact";
@@ -123,10 +124,10 @@ export function AgentLiveCard({ issueId, agentName }: AgentLiveCardProps) {
setItems(timeline);
for (const m of msgs) seenSeqs.current.add(`${m.task_id}:${m.seq}`);
}
}).catch(() => {});
}).catch(console.error);
}
}
}).catch(() => {});
}).catch(console.error);
return () => { cancelled = true; };
}, [issueId]);
@@ -194,18 +195,21 @@ export function AgentLiveCard({ issueId, agentName }: AgentLiveCardProps) {
}, [issueId]),
);
// Pick up new tasks
// Pick up new tasks — skip if we're already showing an active task to avoid
// replacing its timeline mid-execution (per-issue serialization in the
// backend prevents this race, but this is a defensive safeguard).
useWSEvent(
"task:dispatch",
useCallback(() => {
if (activeTask) return;
api.getActiveTaskForIssue(issueId).then(({ task }) => {
if (task) {
setActiveTask(task);
setItems([]);
seenSeqs.current.clear();
}
}).catch(() => {});
}, [issueId]),
}).catch(console.error);
}, [issueId, activeTask]),
);
// Elapsed time
@@ -235,7 +239,8 @@ export function AgentLiveCard({ issueId, agentName }: AgentLiveCardProps) {
setCancelling(true);
try {
await api.cancelTask(issueId, activeTask.id);
} catch {
} catch (e) {
toast.error(e instanceof Error ? e.message : "Failed to cancel task");
setCancelling(false);
}
}, [activeTask, issueId, cancelling]);
@@ -318,7 +323,7 @@ export function TaskRunHistory({ issueId }: TaskRunHistoryProps) {
const [open, setOpen] = useState(false);
useEffect(() => {
api.listTasksByIssue(issueId).then(setTasks).catch(() => {});
api.listTasksByIssue(issueId).then(setTasks).catch(console.error);
}, [issueId]);
// Refresh when a task completes
@@ -327,7 +332,7 @@ export function TaskRunHistory({ issueId }: TaskRunHistoryProps) {
useCallback((payload: unknown) => {
const p = payload as TaskCompletedPayload;
if (p.issue_id !== issueId) return;
api.listTasksByIssue(issueId).then(setTasks).catch(() => {});
api.listTasksByIssue(issueId).then(setTasks).catch(console.error);
}, [issueId]),
);
@@ -336,7 +341,7 @@ export function TaskRunHistory({ issueId }: TaskRunHistoryProps) {
useCallback((payload: unknown) => {
const p = payload as TaskFailedPayload;
if (p.issue_id !== issueId) return;
api.listTasksByIssue(issueId).then(setTasks).catch(() => {});
api.listTasksByIssue(issueId).then(setTasks).catch(console.error);
}, [issueId]),
);
@@ -346,7 +351,7 @@ export function TaskRunHistory({ issueId }: TaskRunHistoryProps) {
useCallback((payload: unknown) => {
const p = payload as TaskCancelledPayload;
if (p.issue_id !== issueId) return;
api.listTasksByIssue(issueId).then(setTasks).catch(() => {});
api.listTasksByIssue(issueId).then(setTasks).catch(console.error);
}, [issueId]),
);
@@ -379,7 +384,10 @@ function TaskRunEntry({ task }: { task: AgentTask }) {
if (items !== null) return; // already loaded
api.listTaskMessages(task.id).then((msgs) => {
setItems(buildTimeline(msgs));
}).catch(() => setItems([]));
}).catch((e) => {
console.error(e);
setItems([]);
});
}, [task.id, items]);
useEffect(() => {

View File

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

View File

@@ -13,6 +13,16 @@ import {
DropdownMenuSeparator,
} from "@/components/ui/dropdown-menu";
import { Tooltip, TooltipTrigger, TooltipContent } from "@/components/ui/tooltip";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@/components/ui/alert-dialog";
import { Collapsible, CollapsibleTrigger, CollapsibleContent } from "@/components/ui/collapsible";
import { ActorAvatar } from "@/components/common/actor-avatar";
import { ReactionBar } from "@/components/common/reaction-bar";
@@ -20,8 +30,7 @@ import { QuickEmojiPicker } from "@/components/common/quick-emoji-picker";
import { cn } from "@/lib/utils";
import { useActorName } from "@/features/workspace";
import { timeAgo } from "@/shared/utils";
import { RichTextEditor, type RichTextEditorRef } from "@/components/common/rich-text-editor";
import { Markdown } from "@/components/markdown/Markdown";
import { ContentEditor, type ContentEditorRef, copyMarkdown } from "@/features/editor";
import { FileUploadButton } from "@/components/common/file-upload-button";
import { useFileUpload } from "@/shared/hooks/use-file-upload";
import { ReplyInput } from "./reply-input";
@@ -40,6 +49,45 @@ interface CommentCardProps {
onEdit: (commentId: string, content: string) => Promise<void>;
onDelete: (commentId: string) => void;
onToggleReaction: (commentId: string, emoji: string) => void;
/** ID of the comment to highlight (flash animation). */
highlightedCommentId?: string | null;
}
// ---------------------------------------------------------------------------
// Shared delete confirmation dialog
// ---------------------------------------------------------------------------
function DeleteCommentDialog({
open,
onOpenChange,
onConfirm,
hasReplies,
}: {
open: boolean;
onOpenChange: (open: boolean) => void;
onConfirm: () => void;
hasReplies?: boolean;
}) {
return (
<AlertDialog open={open} onOpenChange={onOpenChange}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Delete comment</AlertDialogTitle>
<AlertDialogDescription>
{hasReplies
? "This comment and all its replies will be permanently deleted. This cannot be undone."
: "This comment will be permanently deleted. This cannot be undone."}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction variant="destructive" onClick={onConfirm}>
Delete
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
);
}
// ---------------------------------------------------------------------------
@@ -63,12 +111,13 @@ function CommentRow({
}) {
const { getActorName } = useActorName();
const [editing, setEditing] = useState(false);
const editEditorRef = useRef<RichTextEditorRef>(null);
const editEditorRef = useRef<ContentEditorRef>(null);
const cancelledRef = useRef(false);
const { uploadWithToast } = useFileUpload();
const isOwn = entry.actor_type === "member" && entry.actor_id === currentUserId;
const isTemp = entry.id.startsWith("temp-");
const [confirmDelete, setConfirmDelete] = useState(false);
const startEdit = () => {
cancelledRef.current = false;
@@ -136,7 +185,7 @@ function CommentRow({
/>
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={() => {
navigator.clipboard.writeText(entry.content ?? "");
copyMarkdown(entry.content ?? "");
toast.success("Copied");
}}>
<Copy className="h-3.5 w-3.5" />
@@ -150,7 +199,7 @@ function CommentRow({
Edit
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem onClick={() => onDelete(entry.id)} variant="destructive">
<DropdownMenuItem onClick={() => setConfirmDelete(true)} variant="destructive">
<Trash2 className="h-3.5 w-3.5" />
Delete
</DropdownMenuItem>
@@ -158,6 +207,11 @@ function CommentRow({
)}
</DropdownMenuContent>
</DropdownMenu>
<DeleteCommentDialog
open={confirmDelete}
onOpenChange={setConfirmDelete}
onConfirm={() => onDelete(entry.id)}
/>
</div>
)}
</div>
@@ -168,19 +222,19 @@ function CommentRow({
onKeyDown={(e) => { if (e.key === "Escape") cancelEdit(); }}
>
<div className="max-h-48 overflow-y-auto text-sm leading-relaxed">
<RichTextEditor
<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"
onUpload={(file) => uploadWithToast(file, { issueId })}
onInsert={(result, isImage) => editEditorRef.current?.insertFile(result.filename, result.link, isImage)}
onSelect={(file) => editEditorRef.current?.uploadFile(file)}
/>
<div className="flex items-center gap-2">
<Button size="sm" variant="ghost" onClick={cancelEdit}>Cancel</Button>
@@ -191,7 +245,7 @@ function CommentRow({
) : (
<>
<div className="mt-1.5 pl-8 text-sm leading-relaxed text-foreground/85">
<Markdown mode="minimal">{entry.content ?? ""}</Markdown>
<ContentEditor defaultValue={entry.content ?? ""} editable={false} />
</div>
{!isTemp && (
<ReactionBar
@@ -221,16 +275,18 @@ function CommentCard({
onEdit,
onDelete,
onToggleReaction,
highlightedCommentId,
}: CommentCardProps) {
const { getActorName } = useActorName();
const { uploadWithToast } = useFileUpload();
const [open, setOpen] = useState(true);
const [editing, setEditing] = useState(false);
const editEditorRef = useRef<RichTextEditorRef>(null);
const editEditorRef = useRef<ContentEditorRef>(null);
const cancelledRef = useRef(false);
const isOwn = entry.actor_type === "member" && entry.actor_id === currentUserId;
const isTemp = entry.id.startsWith("temp-");
const [confirmDelete, setConfirmDelete] = useState(false);
const startEdit = () => {
cancelledRef.current = false;
@@ -275,8 +331,10 @@ function CommentCard({
const contentPreview = (entry.content ?? "").replace(/\n/g, " ").slice(0, 80);
const reactions = entry.reactions ?? [];
const isHighlighted = highlightedCommentId === entry.id;
return (
<Card className={`!py-0 !gap-0 overflow-hidden${isTemp ? " opacity-60" : ""}`}>
<Card className={cn("!py-0 !gap-0 overflow-hidden transition-colors duration-700", isTemp && "opacity-60", isHighlighted && "ring-2 ring-brand/50 bg-brand/5")}>
<Collapsible open={open} onOpenChange={setOpen}>
{/* Header — always visible, acts as toggle */}
<div className="px-4 py-3">
@@ -328,7 +386,7 @@ function CommentCard({
/>
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={() => {
navigator.clipboard.writeText(entry.content ?? "");
copyMarkdown(entry.content ?? "");
toast.success("Copied");
}}>
<Copy className="h-3.5 w-3.5" />
@@ -342,7 +400,7 @@ function CommentCard({
Edit
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem onClick={() => onDelete(entry.id)} variant="destructive">
<DropdownMenuItem onClick={() => setConfirmDelete(true)} variant="destructive">
<Trash2 className="h-3.5 w-3.5" />
Delete
</DropdownMenuItem>
@@ -350,6 +408,12 @@ function CommentCard({
)}
</DropdownMenuContent>
</DropdownMenu>
<DeleteCommentDialog
open={confirmDelete}
onOpenChange={setConfirmDelete}
onConfirm={() => onDelete(entry.id)}
hasReplies
/>
</div>
)}
</div>
@@ -365,7 +429,7 @@ function CommentCard({
onKeyDown={(e) => { if (e.key === "Escape") cancelEdit(); }}
>
<div className="max-h-48 overflow-y-auto text-sm leading-relaxed">
<RichTextEditor
<ContentEditor
ref={editEditorRef}
defaultValue={entry.content ?? ""}
placeholder="Edit comment..."
@@ -376,8 +440,7 @@ function CommentCard({
<div className="flex items-center justify-between mt-2">
<FileUploadButton
size="sm"
onUpload={(file) => uploadWithToast(file, { issueId })}
onInsert={(result, isImage) => editEditorRef.current?.insertFile(result.filename, result.link, isImage)}
onSelect={(file) => editEditorRef.current?.uploadFile(file)}
/>
<div className="flex items-center gap-2">
<Button size="sm" variant="ghost" onClick={cancelEdit}>Cancel</Button>
@@ -388,7 +451,7 @@ function CommentCard({
) : (
<>
<div className="pl-10 text-sm leading-relaxed text-foreground/85">
<Markdown mode="minimal">{entry.content ?? ""}</Markdown>
<ContentEditor defaultValue={entry.content ?? ""} editable={false} />
</div>
{!isTemp && (
<ReactionBar
@@ -404,7 +467,7 @@ function CommentCard({
{/* Replies */}
{allNestedReplies.map((reply) => (
<div key={reply.id} className="border-t border-border/50 px-4">
<div key={reply.id} id={`comment-${reply.id}`} className={cn("border-t border-border/50 px-4 transition-colors duration-700", highlightedCommentId === reply.id && "bg-brand/5")}>
<CommentRow
issueId={issueId}
entry={reply}

View File

@@ -3,7 +3,7 @@
import { useRef, useState } from "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";
@@ -13,16 +13,13 @@ interface CommentInputProps {
}
function CommentInput({ issueId, onSubmit }: CommentInputProps) {
const editorRef = useRef<RichTextEditorRef>(null);
const attachmentIdsRef = useRef<string[]>([]);
const editorRef = useRef<ContentEditorRef>(null);
const [isEmpty, setIsEmpty] = useState(true);
const [submitting, setSubmitting] = useState(false);
const { uploadWithToast, uploading } = useFileUpload();
const { uploadWithToast } = useFileUpload();
const handleUpload = async (file: File) => {
const result = await uploadWithToast(file, { issueId });
if (result) attachmentIdsRef.current.push(result.id);
return result;
return await uploadWithToast(file, { issueId });
};
const handleSubmit = async () => {
@@ -30,10 +27,8 @@ function CommentInput({ issueId, onSubmit }: CommentInputProps) {
if (!content || submitting) return;
setSubmitting(true);
try {
const ids = attachmentIdsRef.current.length > 0 ? [...attachmentIdsRef.current] : undefined;
await onSubmit(content, ids);
await onSubmit(content);
editorRef.current?.clearContent();
attachmentIdsRef.current = [];
setIsEmpty(true);
} finally {
setSubmitting(false);
@@ -43,7 +38,7 @@ function CommentInput({ issueId, onSubmit }: CommentInputProps) {
return (
<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">
<RichTextEditor
<ContentEditor
ref={editorRef}
placeholder="Leave a comment..."
onUpdate={(md) => setIsEmpty(!md.trim())}
@@ -55,11 +50,7 @@ function CommentInput({ issueId, onSubmit }: CommentInputProps) {
<div className="absolute bottom-1 right-1.5 flex items-center gap-1">
<FileUploadButton
size="sm"
onUpload={handleUpload}
onInsert={(result, isImage) =>
editorRef.current?.insertFile(result.filename, result.link, isImage)
}
disabled={uploading}
onSelect={(file) => editorRef.current?.uploadFile(file)}
/>
<Button
size="icon-xs"

View File

@@ -7,17 +7,20 @@ import { useRouter } from "next/navigation";
import {
Calendar,
Check,
ChevronDown,
ChevronLeft,
ChevronRight,
Link2,
MoreHorizontal,
PanelRight,
Plus,
Trash2,
UserMinus,
Users,
X,
} from "lucide-react";
import { toast } from "sonner";
import { Skeleton } from "@/components/ui/skeleton";
import {
AlertDialog,
AlertDialogAction,
@@ -42,9 +45,9 @@ import {
DropdownMenuSubContent,
} from "@/components/ui/dropdown-menu";
import { ResizablePanelGroup, ResizablePanel, ResizableHandle } from "@/components/ui/resizable";
import { RichTextEditor } from "@/components/common/rich-text-editor";
import { ContentEditor, type ContentEditorRef } from "@/features/editor";
import { FileUploadButton } from "@/components/common/file-upload-button";
import { TitleEditor } from "@/components/common/title-editor";
import { TitleEditor } from "@/features/editor";
import {
Tooltip,
TooltipTrigger,
@@ -55,7 +58,7 @@ import { Checkbox } from "@/components/ui/checkbox";
import { Command, CommandInput, CommandList, CommandEmpty, CommandGroup, CommandItem } from "@/components/ui/command";
import { AvatarGroup, AvatarGroupCount } from "@/components/ui/avatar";
import { ActorAvatar } from "@/components/common/actor-avatar";
import type { UpdateIssueRequest, IssueStatus, IssuePriority, TimelineEntry } from "@/shared/types";
import type { UpdateIssueRequest, IssueStatus, IssuePriority, IssueDependency, IssueDependencyType, TimelineEntry } from "@/shared/types";
import { ALL_STATUSES, STATUS_CONFIG, PRIORITY_ORDER, PRIORITY_CONFIG } from "@/features/issues/config";
import { StatusIcon, PriorityIcon, DueDatePicker, AssigneePicker, canAssignAgent } from "@/features/issues/components";
import { CommentCard } from "./comment-card";
@@ -123,11 +126,42 @@ function formatActivity(
return "completed the task";
case "task_failed":
return "task failed";
case "issue_relation_added": {
const relType = details.relation_type ?? "";
const identifier = details.related_issue_identifier ?? "?";
const label = relationTypeLabel(relType);
return `added relation: ${label} ${identifier}`;
}
case "issue_relation_removed": {
const relType = details.relation_type ?? "";
const identifier = details.related_issue_identifier ?? "?";
const label = relationTypeLabel(relType);
return `removed relation: ${label} ${identifier}`;
}
default:
return entry.action ?? "";
}
}
function relationTypeLabel(type: string): string {
switch (type) {
case "blocks":
return "blocks";
case "blocked_by":
return "blocked by";
case "related":
return "related to";
default:
return type;
}
}
function inverseRelType(type: string): string {
if (type === "blocks") return "blocked_by";
if (type === "blocked_by") return "blocks";
return type;
}
// ---------------------------------------------------------------------------
// Property row
@@ -160,13 +194,15 @@ 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);
@@ -190,7 +226,16 @@ export function IssueDetail({ issueId, onDelete, defaultSidebarOpen = true, layo
const [deleting, setDeleting] = useState(false);
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
const [propertiesOpen, setPropertiesOpen] = useState(true);
const [relationsOpen, setRelationsOpen] = useState(true);
const [detailsOpen, setDetailsOpen] = useState(true);
const [dependencies, setDependencies] = useState<IssueDependency[]>([]);
const [addRelOpen, setAddRelOpen] = useState(false);
const [relSearch, setRelSearch] = useState("");
const [relType, setRelType] = useState<IssueDependencyType>("related");
const scrollContainerRef = useRef<HTMLDivElement>(null);
const [showScrollBottom, setShowScrollBottom] = useState(false);
const [highlightedId, setHighlightedId] = useState<string | null>(null);
const didHighlightRef = useRef<string | null>(null);
// Single source of truth: read issue directly from global store
const issue = useIssueStore((s) => s.issues.find((i) => i.id === id)) ?? null;
@@ -208,27 +253,89 @@ export function IssueDetail({ issueId, onDelete, defaultSidebarOpen = true, layo
.then((iss) => {
useIssueStore.getState().addIssue(iss);
})
.catch(console.error)
.catch((e) => {
console.error(e);
toast.error("Failed to load issue");
})
.finally(() => setIssueLoading(false));
}, [id, !!issue]);
// Custom hooks — encapsulate timeline, reactions, subscribers
const {
timeline, submitting, submitComment, submitReply,
timeline, loading: timelineLoading, submitting, submitComment, submitReply,
editComment, deleteComment, toggleReaction: handleToggleReaction,
} = useIssueTimeline(id, user?.id);
const {
reactions: issueReactions,
reactions: issueReactions, loading: reactionsLoading,
toggleReaction: handleToggleIssueReaction,
} = useIssueReactions(id, user?.id);
const {
subscribers, isSubscribed, toggleSubscribe: handleToggleSubscribe, toggleSubscriber,
subscribers, loading: subscribersLoading, isSubscribed, toggleSubscribe: handleToggleSubscribe, toggleSubscriber,
} = useIssueSubscribers(id, user?.id);
const loading = issueLoading;
// Fetch issue dependencies
const fetchDependencies = useCallback(() => {
api.listDependencies(id).then(setDependencies).catch(() => {});
}, [id]);
useEffect(() => { fetchDependencies(); }, [fetchDependencies]);
const handleAddDependency = useCallback(async (targetIssueId: string, type: IssueDependencyType) => {
try {
await api.createDependency(id, targetIssueId, type);
fetchDependencies();
setAddRelOpen(false);
setRelSearch("");
} catch {
toast.error("Failed to add relation");
}
}, [id, fetchDependencies]);
const handleRemoveDependency = useCallback(async (depId: string) => {
try {
await api.deleteDependency(id, depId);
fetchDependencies();
} catch {
toast.error("Failed to remove relation");
}
}, [id, fetchDependencies]);
// Scroll to highlighted comment once timeline loads (fire only once per highlightCommentId)
useEffect(() => {
if (!highlightCommentId || timeline.length === 0) return;
if (didHighlightRef.current === highlightCommentId) return;
const el = document.getElementById(`comment-${highlightCommentId}`);
if (el) {
didHighlightRef.current = highlightCommentId;
requestAnimationFrame(() => {
el.scrollIntoView({ behavior: "smooth", block: "center" });
setHighlightedId(highlightCommentId);
const timer = setTimeout(() => setHighlightedId(null), 2000);
return () => clearTimeout(timer);
});
}
}, [highlightCommentId, timeline.length]);
// Track scroll position for jump-to-bottom button
useEffect(() => {
const container = scrollContainerRef.current;
if (!container) return;
const onScroll = () => {
const { scrollTop, scrollHeight, clientHeight } = container;
setShowScrollBottom(scrollHeight - scrollTop - clientHeight > 200);
};
container.addEventListener("scroll", onScroll, { passive: true });
onScroll();
return () => container.removeEventListener("scroll", onScroll);
}, []);
const scrollToBottom = useCallback(() => {
scrollContainerRef.current?.scrollTo({ top: scrollContainerRef.current.scrollHeight, behavior: "smooth" });
}, []);
// Issue field updates — write directly to the global store (single source of truth)
const handleUpdateField = useCallback(
(updates: Partial<UpdateIssueRequest>) => {
@@ -243,7 +350,7 @@ export function IssueDetail({ issueId, onDelete, defaultSidebarOpen = true, layo
[issue, id],
);
const descEditorRef = useRef<import("@/components/common/rich-text-editor").RichTextEditorRef>(null);
const descEditorRef = useRef<ContentEditorRef>(null);
const handleDescriptionUpload = useCallback(
(file: File) => uploadWithToast(file, { issueId: id }),
[uploadWithToast, id],
@@ -265,8 +372,51 @@ export function IssueDetail({ issueId, onDelete, defaultSidebarOpen = true, layo
if (loading) {
return (
<div className="flex flex-1 min-h-0 items-center justify-center text-sm text-muted-foreground">
Loading...
<div className="flex flex-1 min-h-0 flex-col">
{/* Header skeleton */}
<div className="flex h-12 shrink-0 items-center gap-2 border-b px-4">
<Skeleton className="h-4 w-16" />
<Skeleton className="h-4 w-4" />
<Skeleton className="h-4 w-24" />
</div>
<div className="flex flex-1 min-h-0">
{/* Content skeleton */}
<div className="flex-1 p-8 space-y-6">
<Skeleton className="h-8 w-3/4" />
<div className="space-y-2">
<Skeleton className="h-4 w-full" />
<Skeleton className="h-4 w-5/6" />
<Skeleton className="h-4 w-2/3" />
</div>
<Skeleton className="h-px w-full" />
<div className="space-y-3">
<Skeleton className="h-4 w-20" />
<div className="flex items-start gap-3">
<Skeleton className="h-8 w-8 rounded-full" />
<div className="flex-1 space-y-2">
<Skeleton className="h-4 w-32" />
<Skeleton className="h-16 w-full rounded-lg" />
</div>
</div>
</div>
</div>
{/* Sidebar skeleton */}
<div className="w-64 border-l p-4 space-y-4">
{Array.from({ length: 4 }).map((_, i) => (
<div key={i} className="flex items-center justify-between">
<Skeleton className="h-3 w-16" />
<Skeleton className="h-5 w-24" />
</div>
))}
<Skeleton className="h-px w-full" />
{Array.from({ length: 3 }).map((_, i) => (
<div key={i} className="flex items-center justify-between">
<Skeleton className="h-3 w-16" />
<Skeleton className="h-4 w-28" />
</div>
))}
</div>
</div>
</div>
);
}
@@ -541,7 +691,7 @@ export function IssueDetail({ issueId, onDelete, defaultSidebarOpen = true, layo
</div>
{/* Content — scrollable */}
<div className="flex-1 overflow-y-auto">
<div ref={scrollContainerRef} className="relative flex-1 overflow-y-auto">
<div className="mx-auto w-full max-w-4xl px-8 py-8">
<TitleEditor
key={`title-${id}`}
@@ -554,7 +704,7 @@ export function IssueDetail({ issueId, onDelete, defaultSidebarOpen = true, layo
}}
/>
<RichTextEditor
<ContentEditor
ref={descEditorRef}
key={id}
defaultValue={issue.description || ""}
@@ -566,15 +716,21 @@ export function IssueDetail({ issueId, onDelete, defaultSidebarOpen = true, layo
/>
<div className="flex items-center gap-1 mt-3">
<ReactionBar
reactions={issueReactions}
currentUserId={user?.id}
onToggle={handleToggleIssueReaction}
/>
{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"
onUpload={handleDescriptionUpload}
onInsert={(result, isImage) => descEditorRef.current?.insertFile(result.filename, result.link, isImage)}
onSelect={(file) => descEditorRef.current?.uploadFile(file)}
/>
</div>
@@ -587,6 +743,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"
@@ -664,6 +829,7 @@ export function IssueDetail({ issueId, onDelete, defaultSidebarOpen = true, layo
</Command>
</PopoverContent>
</Popover>
</>)}
</div>
</div>
@@ -682,7 +848,19 @@ export function IssueDetail({ issueId, onDelete, defaultSidebarOpen = true, layo
{/* 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) {
@@ -733,17 +911,19 @@ export function IssueDetail({ issueId, onDelete, defaultSidebarOpen = true, layo
if (group.type === "comment") {
const entry = group.entries[0]!;
return (
<CommentCard
key={entry.id}
issueId={id}
entry={entry}
allReplies={repliesByParent}
currentUserId={user?.id}
onReply={submitReply}
onEdit={editComment}
onDelete={deleteComment}
onToggleReaction={handleToggleReaction}
/>
<div key={entry.id} id={`comment-${entry.id}`}>
<CommentCard
issueId={id}
entry={entry}
allReplies={repliesByParent}
currentUserId={user?.id}
onReply={submitReply}
onEdit={editComment}
onDelete={deleteComment}
onToggleReaction={handleToggleReaction}
highlightedCommentId={highlightedId}
/>
</div>
);
}
@@ -754,6 +934,7 @@ 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 isRelationChange = entry.action === "issue_relation_added" || entry.action === "issue_relation_removed";
let leadIcon: React.ReactNode;
if (isStatusChange && details.to) {
@@ -762,6 +943,8 @@ export function IssueDetail({ issueId, onDelete, defaultSidebarOpen = true, layo
leadIcon = <PriorityIcon priority={details.to as IssuePriority} className="h-4 w-4 shrink-0" />;
} else if (isDueDateChange) {
leadIcon = <Calendar className="h-4 w-4 shrink-0 text-muted-foreground" />;
} else if (isRelationChange) {
leadIcon = <Link2 className="h-4 w-4 shrink-0 text-muted-foreground" />;
} else {
leadIcon = <ActorAvatar actorType={entry.actor_type} actorId={entry.actor_id} size={16} />;
}
@@ -802,6 +985,20 @@ export function IssueDetail({ issueId, onDelete, defaultSidebarOpen = true, layo
</div>
</div>
</div>
{/* Jump to bottom button */}
{showScrollBottom && (
<div className="sticky bottom-4 flex justify-center pointer-events-none">
<Button
variant="secondary"
size="sm"
className="pointer-events-auto shadow-md"
onClick={scrollToBottom}
>
<ChevronDown className="mr-1 h-3.5 w-3.5" />
Jump to bottom
</Button>
</div>
)}
</div>
</div>
</ResizablePanel>
@@ -890,6 +1087,102 @@ export function IssueDetail({ issueId, onDelete, defaultSidebarOpen = true, layo
</div>}
</div>
{/* Relations section */}
<div>
<div className="flex items-center mb-2">
<button
className={`flex flex-1 items-center gap-1 text-xs font-medium transition-colors ${relationsOpen ? "" : "text-muted-foreground hover:text-foreground"}`}
onClick={() => setRelationsOpen(!relationsOpen)}
>
<ChevronRight className={`h-3.5 w-3.5 shrink-0 text-muted-foreground transition-transform ${relationsOpen ? "rotate-90" : ""}`} />
Relations
{dependencies.length > 0 && <span className="text-muted-foreground ml-1">({dependencies.length})</span>}
</button>
<Popover open={addRelOpen} onOpenChange={setAddRelOpen}>
<PopoverTrigger
render={
<button className="p-0.5 rounded hover:bg-accent/50 text-muted-foreground hover:text-foreground transition-colors">
<Plus className="h-3.5 w-3.5" />
</button>
}
/>
<PopoverContent align="end" className="w-72 p-0">
<div className="p-2 border-b">
<div className="flex gap-1 mb-2">
{(["related", "blocks", "blocked_by"] as const).map((t) => (
<button
key={t}
className={`px-2 py-0.5 text-xs rounded-full transition-colors ${relType === t ? "bg-primary text-primary-foreground" : "bg-muted text-muted-foreground hover:text-foreground"}`}
onClick={() => setRelType(t)}
>
{relationTypeLabel(t)}
</button>
))}
</div>
</div>
<Command>
<CommandInput placeholder="Search issues..." value={relSearch} onValueChange={setRelSearch} />
<CommandList>
<CommandEmpty>No issues found</CommandEmpty>
<CommandGroup>
{allIssues
.filter((i) => i.id !== id)
.filter((i) => !dependencies.some(
(d) => (d.issue_id === id ? d.depends_on_issue_id : d.issue_id) === i.id
))
.slice(0, 20)
.map((i) => (
<CommandItem
key={i.id}
value={`${i.identifier} ${i.title}`}
onSelect={() => handleAddDependency(i.id, relType)}
>
<span className="text-muted-foreground shrink-0 mr-1.5">{i.identifier}</span>
<span className="truncate">{i.title}</span>
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
</div>
{relationsOpen && dependencies.length > 0 && (
<div className="space-y-1 pl-2">
{dependencies.map((dep) => {
// Determine which side is the "other" issue
const isSource = dep.issue_id === id;
const otherIdentifier = isSource ? dep.depends_on_issue_identifier : dep.issue_identifier;
const otherTitle = isSource ? dep.depends_on_issue_title : dep.issue_title;
const otherIssueId = isSource ? dep.depends_on_issue_id : dep.issue_id;
// Show the relation type from this issue's perspective
const displayType = isSource ? dep.type : inverseRelType(dep.type);
return (
<div key={dep.id} className="group flex items-center gap-1.5 text-xs rounded-md px-2 -mx-2 min-h-7 hover:bg-accent/50 transition-colors">
<Link2 className="h-3 w-3 shrink-0 text-muted-foreground" />
<span className="shrink-0 text-muted-foreground">{relationTypeLabel(displayType)}</span>
<Link href={`/issues/${otherIssueId}`} className="flex items-center gap-1 min-w-0 hover:underline">
<span className="shrink-0 text-muted-foreground">{otherIdentifier}</span>
<span className="truncate">{otherTitle}</span>
</Link>
<button
className="ml-auto shrink-0 opacity-0 group-hover:opacity-100 p-0.5 rounded hover:bg-accent transition-all text-muted-foreground hover:text-destructive"
onClick={() => handleRemoveDependency(dep.id)}
>
<X className="h-3 w-3" />
</button>
</div>
);
})}
</div>
)}
{relationsOpen && dependencies.length === 0 && (
<div className="pl-2 text-xs text-muted-foreground">No relations</div>
)}
</div>
{/* Details section */}
<div>
<button

View File

@@ -2,7 +2,7 @@
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";
@@ -84,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);
});
},
[]
@@ -131,19 +131,27 @@ export function IssuesPage() {
{/* Content: scrollable */}
<ViewStoreProvider store={useIssueViewStore}>
<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>
{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>
) : (
<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>
)}
{viewMode === "list" && <BatchActionToolbar />}
</ViewStoreProvider>
</div>

View File

@@ -56,7 +56,7 @@ export function AssigneePicker({
m.name.toLowerCase().includes(query),
);
const filteredAgents = agents.filter((a) =>
a.name.toLowerCase().includes(query),
!a.archived_at && a.name.toLowerCase().includes(query),
);
const isSelected = (type: string, id: string) =>

View File

@@ -2,7 +2,7 @@
import { useRef, useState, useEffect } from "react";
import { ArrowUp, Loader2 } from "lucide-react";
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 { ActorAvatar } from "@/components/common/actor-avatar";
import { useFileUpload } from "@/shared/hooks/use-file-upload";
@@ -33,13 +33,12 @@ function ReplyInput({
onSubmit,
size = "default",
}: ReplyInputProps) {
const editorRef = useRef<RichTextEditorRef>(null);
const editorRef = useRef<ContentEditorRef>(null);
const measureRef = useRef<HTMLDivElement>(null);
const attachmentIdsRef = useRef<string[]>([]);
const [isEmpty, setIsEmpty] = useState(true);
const [isExpanded, setIsExpanded] = useState(false);
const [submitting, setSubmitting] = useState(false);
const { uploadWithToast, uploading } = useFileUpload();
const { uploadWithToast } = useFileUpload();
useEffect(() => {
const el = measureRef.current;
@@ -53,9 +52,7 @@ function ReplyInput({
}, []);
const handleUpload = async (file: File) => {
const result = await uploadWithToast(file, { issueId });
if (result) attachmentIdsRef.current.push(result.id);
return result;
return await uploadWithToast(file, { issueId });
};
const handleSubmit = async () => {
@@ -63,10 +60,8 @@ function ReplyInput({
if (!content || submitting) return;
setSubmitting(true);
try {
const ids = attachmentIdsRef.current.length > 0 ? [...attachmentIdsRef.current] : undefined;
await onSubmit(content, ids);
await onSubmit(content);
editorRef.current?.clearContent();
attachmentIdsRef.current = [];
setIsEmpty(true);
} finally {
setSubmitting(false);
@@ -92,7 +87,7 @@ function ReplyInput({
>
<div className="flex-1 min-h-0 overflow-y-auto pr-14">
<div ref={measureRef}>
<RichTextEditor
<ContentEditor
ref={editorRef}
placeholder={placeholder}
onUpdate={(md) => setIsEmpty(!md.trim())}
@@ -105,11 +100,7 @@ function ReplyInput({
<div className="absolute bottom-0 right-0 flex items-center gap-1 text-muted-foreground transition-colors group-focus-within/editor:text-foreground">
<FileUploadButton
size="sm"
onUpload={handleUpload}
onInsert={(result, isImage) =>
editorRef.current?.insertFile(result.filename, result.link, isImage)
}
disabled={uploading}
onSelect={(file) => editorRef.current?.uploadFile(file)}
/>
<button
type="button"

View File

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

View File

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

View File

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

View File

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

View File

@@ -25,8 +25,8 @@ import {
import { Calendar } from "@/components/ui/calendar";
import { Tooltip, TooltipTrigger, TooltipContent } from "@/components/ui/tooltip";
import { Button } from "@/components/ui/button";
import { RichTextEditor, type RichTextEditorRef } from "@/components/common/rich-text-editor";
import { TitleEditor } from "@/components/common/title-editor";
import { ContentEditor, type ContentEditorRef } from "@/features/editor";
import { TitleEditor } from "@/features/editor";
import { StatusIcon, PriorityIcon } from "@/features/issues/components";
import { ALL_STATUSES, STATUS_CONFIG, PRIORITY_ORDER, PRIORITY_CONFIG } from "@/features/issues/config";
import { useWorkspaceStore, useActorName } from "@/features/workspace";
@@ -77,7 +77,7 @@ export function CreateIssueModal({ onClose, data }: { onClose: () => void; data?
const clearDraft = useIssueDraftStore((s) => s.clearDraft);
const [title, setTitle] = useState(draft.title);
const descEditorRef = useRef<RichTextEditorRef>(null);
const descEditorRef = useRef<ContentEditorRef>(null);
const [status, setStatus] = useState<IssueStatus>((data?.status as IssueStatus) || draft.status);
const [priority, setPriority] = useState<IssuePriority>(draft.priority);
const [submitting, setSubmitting] = useState(false);
@@ -231,7 +231,7 @@ export function CreateIssueModal({ onClose, data }: { onClose: () => void; data?
{/* Description — takes remaining space */}
<div className="flex-1 min-h-0 overflow-y-auto px-5">
<RichTextEditor
<ContentEditor
ref={descEditorRef}
defaultValue={draft.description}
placeholder="Add description..."
@@ -419,8 +419,7 @@ export function CreateIssueModal({ onClose, data }: { onClose: () => void; data?
{/* Footer */}
<div className="flex items-center justify-between px-4 py-3 border-t shrink-0">
<FileUploadButton
onUpload={handleUpload}
onInsert={(result, isImage) => descEditorRef.current?.insertFile(result.filename, result.link, isImage)}
onSelect={(file) => descEditorRef.current?.uploadFile(file)}
/>
<Button size="sm" onClick={handleSubmit} disabled={!title.trim() || submitting}>
{submitting ? "Creating..." : "Create Issue"}

View File

@@ -3,7 +3,7 @@
import { useCallback, useEffect, useMemo } from "react";
import { useStore } from "zustand";
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 { useAuthStore } from "@/features/auth";
@@ -124,7 +124,7 @@ export function MyIssuesPage() {
toast.error("Failed to move issue");
api.listIssues({ limit: 200 }).then((res) => {
useIssueStore.getState().setIssues(res.issues);
});
}).catch(console.error);
});
},
[],
@@ -171,19 +171,27 @@ export function MyIssuesPage() {
{/* Content: scrollable */}
<ViewStoreProvider store={myIssuesViewStore}>
<div className="flex flex-col flex-1 min-h-0">
{viewMode === "board" ? (
<BoardView
issues={issues}
allIssues={myIssues}
visibleStatuses={visibleStatuses}
hiddenStatuses={hiddenStatuses}
onMoveIssue={handleMoveIssue}
/>
) : (
<ListView issues={issues} visibleStatuses={visibleStatuses} />
)}
</div>
{myIssues.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 assigned to you</p>
<p className="text-xs">Issues you create or are assigned to will appear here.</p>
</div>
) : (
<div className="flex flex-col flex-1 min-h-0">
{viewMode === "board" ? (
<BoardView
issues={issues}
allIssues={myIssues}
visibleStatuses={visibleStatuses}
hiddenStatuses={hiddenStatuses}
onMoveIssue={handleMoveIssue}
/>
) : (
<ListView issues={issues} visibleStatuses={visibleStatuses} />
)}
</div>
)}
{viewMode === "list" && <BatchActionToolbar />}
</ViewStoreProvider>
</div>

View File

@@ -14,6 +14,9 @@ import type {
WorkspaceDeletedPayload,
MemberRemovedPayload,
IssueUpdatedPayload,
IssueCreatedPayload,
IssueDeletedPayload,
InboxNewPayload,
} from "@/shared/types";
const logger = createLogger("realtime-sync");
@@ -34,8 +37,12 @@ export function useRealtimeSync(ws: WSClient | null) {
useEffect(() => {
if (!ws) return;
// Event types handled by specific handlers below — skip generic refresh
const specificEvents = new Set([
"issue:updated", "issue:created", "issue:deleted", "inbox:new",
]);
const refreshMap: Record<string, () => void> = {
issue: () => void useIssueStore.getState().fetch(),
inbox: () => void useInboxStore.getState().fetch(),
agent: () => void useWorkspaceStore.getState().refreshAgents(),
member: () => void useWorkspaceStore.getState().refreshMembers(),
@@ -74,21 +81,40 @@ export function useRealtimeSync(ws: WSClient | null) {
logger.debug("skipping self-event", msg.type);
return;
}
if (specificEvents.has(msg.type)) return;
const prefix = msg.type.split(":")[0] ?? "";
const refresh = refreshMap[prefix];
if (refresh) debouncedRefresh(prefix, refresh);
});
// --- Side-effect handlers (toast, navigation, cross-store sync) ---
// --- Specific event handlers (granular updates, no full refetch) ---
// Keep inbox issue_status in sync when issues change
const unsubIssueUpdated = ws.on("issue:updated", (p) => {
const { issue } = p as IssueUpdatedPayload;
if (issue?.id && issue?.status) {
if (!issue?.id) return;
useIssueStore.getState().updateIssue(issue.id, issue);
if (issue.status) {
useInboxStore.getState().updateIssueStatus(issue.id, issue.status);
}
});
const unsubIssueCreated = ws.on("issue:created", (p) => {
const { issue } = p as IssueCreatedPayload;
if (issue) useIssueStore.getState().addIssue(issue);
});
const unsubIssueDeleted = ws.on("issue:deleted", (p) => {
const { issue_id } = p as IssueDeletedPayload;
if (issue_id) useIssueStore.getState().removeIssue(issue_id);
});
const unsubInboxNew = ws.on("inbox:new", (p) => {
const { item } = p as InboxNewPayload;
if (item) useInboxStore.getState().addItem(item);
});
// --- Side-effect handlers (toast, navigation) ---
const unsubWsDeleted = ws.on("workspace:deleted", (p) => {
const { workspace_id } = p as WorkspaceDeletedPayload;
const currentWs = useWorkspaceStore.getState().workspace;
@@ -123,6 +149,9 @@ export function useRealtimeSync(ws: WSClient | null) {
return () => {
unsubAny();
unsubIssueUpdated();
unsubIssueCreated();
unsubIssueDeleted();
unsubInboxNew();
unsubWsDeleted();
unsubMemberRemoved();
unsubMemberAdded();
@@ -145,8 +174,8 @@ export function useRealtimeSync(ws: WSClient | null) {
useWorkspaceStore.getState().refreshMembers(),
useWorkspaceStore.getState().refreshSkills(),
]);
} catch {
// Silently fail; next reconnect will retry
} catch (e) {
logger.error("reconnect refetch failed", e);
}
});

View File

@@ -2,9 +2,24 @@ import type { AgentRuntime } from "@/shared/types";
import { formatLastSeen } from "../utils";
import { RuntimeModeIcon, StatusBadge, InfoField } from "./shared";
import { PingSection } from "./ping-section";
import { UpdateSection } from "./update-section";
import { UsageSection } from "./usage-section";
function getCliVersion(metadata: Record<string, unknown>): string | null {
if (
metadata &&
typeof metadata.cli_version === "string" &&
metadata.cli_version
) {
return metadata.cli_version;
}
return null;
}
export function RuntimeDetail({ runtime }: { runtime: AgentRuntime }) {
const cliVersion =
runtime.runtime_mode === "local" ? getCliVersion(runtime.metadata) : null;
return (
<div className="flex h-full flex-col">
{/* Header */}
@@ -43,6 +58,20 @@ export function RuntimeDetail({ runtime }: { runtime: AgentRuntime }) {
)}
</div>
{/* CLI Version & Update */}
{runtime.runtime_mode === "local" && (
<div>
<h3 className="text-xs font-medium text-muted-foreground mb-3">
CLI Version
</h3>
<UpdateSection
runtimeId={runtime.id}
currentVersion={cliVersion}
isOnline={runtime.status === "online"}
/>
</div>
)}
{/* Connection Test */}
<div>
<h3 className="text-xs font-medium text-muted-foreground mb-3">

View File

@@ -8,6 +8,7 @@ import {
ResizablePanel,
ResizableHandle,
} from "@/components/ui/resizable";
import { Skeleton } from "@/components/ui/skeleton";
import { useAuthStore } from "@/features/auth";
import { useWorkspaceStore } from "@/features/workspace";
import { useWSEvent } from "@/features/realtime";
@@ -44,8 +45,36 @@ export default function RuntimesPage() {
if (isLoading || fetching) {
return (
<div className="flex h-full 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-20" />
</div>
<div className="divide-y">
{Array.from({ length: 3 }).map((_, i) => (
<div key={i} className="flex items-center gap-3 px-4 py-3">
<Skeleton className="h-5 w-5 rounded" />
<div className="flex-1 space-y-1.5">
<Skeleton className="h-4 w-28" />
<Skeleton className="h-3 w-20" />
</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-5 w-5 rounded" />
<Skeleton className="h-5 w-32" />
</div>
<div className="space-y-3">
{Array.from({ length: 3 }).map((_, i) => (
<Skeleton key={i} className="h-16 w-full rounded-lg" />
))}
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,230 @@
import { useState, useEffect, useCallback, useRef } from "react";
import {
Loader2,
CheckCircle2,
XCircle,
ArrowUpCircle,
Check,
} from "lucide-react";
import { Button } from "@/components/ui/button";
import { api } from "@/shared/api";
import type { RuntimeUpdateStatus } from "@/shared/types";
const GITHUB_RELEASES_URL =
"https://api.github.com/repos/multica-ai/multica/releases/latest";
const CACHE_TTL_MS = 10 * 60 * 1000; // 10 minutes
let cachedLatestVersion: string | null = null;
let cachedAt = 0;
async function fetchLatestVersion(): Promise<string | null> {
if (cachedLatestVersion && Date.now() - cachedAt < CACHE_TTL_MS) {
return cachedLatestVersion;
}
try {
const resp = await fetch(GITHUB_RELEASES_URL, {
headers: { Accept: "application/vnd.github+json" },
});
if (!resp.ok) return null;
const data = await resp.json();
cachedLatestVersion = data.tag_name ?? null;
cachedAt = Date.now();
return cachedLatestVersion;
} catch {
return null;
}
}
function stripV(v: string): string {
return v.replace(/^v/, "");
}
function isNewer(latest: string, current: string): boolean {
const l = stripV(latest).split(".").map(Number);
const c = stripV(current).split(".").map(Number);
for (let i = 0; i < Math.max(l.length, c.length); i++) {
const lv = l[i] ?? 0;
const cv = c[i] ?? 0;
if (lv > cv) return true;
if (lv < cv) return false;
}
return false;
}
const statusConfig: Record<
RuntimeUpdateStatus,
{ label: string; icon: typeof Loader2; color: string }
> = {
pending: {
label: "Waiting for daemon...",
icon: Loader2,
color: "text-muted-foreground",
},
running: {
label: "Updating...",
icon: Loader2,
color: "text-info",
},
completed: {
label: "Update complete. Daemon is restarting...",
icon: CheckCircle2,
color: "text-success",
},
failed: { label: "Update failed", icon: XCircle, color: "text-destructive" },
timeout: { label: "Timeout", icon: XCircle, color: "text-warning" },
};
interface UpdateSectionProps {
runtimeId: string;
currentVersion: string | null;
isOnline: boolean;
}
export function UpdateSection({
runtimeId,
currentVersion,
isOnline,
}: UpdateSectionProps) {
const [latestVersion, setLatestVersion] = useState<string | null>(null);
const [status, setStatus] = useState<RuntimeUpdateStatus | null>(null);
const [error, setError] = useState("");
const [output, setOutput] = useState("");
const [updating, setUpdating] = useState(false);
const pollRef = useRef<ReturnType<typeof setInterval> | null>(null);
const cleanup = useCallback(() => {
if (pollRef.current) {
clearInterval(pollRef.current);
pollRef.current = null;
}
}, []);
useEffect(() => cleanup, [cleanup]);
// Fetch latest version on mount.
useEffect(() => {
fetchLatestVersion().then(setLatestVersion);
}, []);
const handleUpdate = async () => {
if (!latestVersion) return;
cleanup();
setUpdating(true);
setStatus("pending");
setError("");
setOutput("");
try {
const update = await api.initiateUpdate(runtimeId, latestVersion);
pollRef.current = setInterval(async () => {
try {
const result = await api.getUpdateResult(runtimeId, update.id);
setStatus(result.status as RuntimeUpdateStatus);
if (result.status === "completed") {
setOutput(result.output ?? "");
setUpdating(false);
cleanup();
// Auto-clear status after a few seconds so the UI
// refreshes to show the new version from the re-fetched runtime data.
setTimeout(() => setStatus(null), 5000);
} else if (
result.status === "failed" ||
result.status === "timeout"
) {
setError(result.error ?? "Unknown error");
setUpdating(false);
cleanup();
}
} catch {
// ignore poll errors
}
}, 2000);
} catch {
setStatus("failed");
setError("Failed to initiate update");
setUpdating(false);
}
};
const hasUpdate =
currentVersion &&
latestVersion &&
isNewer(latestVersion, currentVersion);
const config = status ? statusConfig[status] : null;
const Icon = config?.icon;
const isActive = status === "pending" || status === "running";
return (
<div className="space-y-2">
<div className="flex items-center gap-2 flex-wrap">
<span className="text-xs text-muted-foreground">CLI Version:</span>
<span className="text-xs font-mono">
{currentVersion ?? "unknown"}
</span>
{!hasUpdate && currentVersion && latestVersion && !status && (
<span className="inline-flex items-center gap-1 text-xs text-success">
<Check className="h-3 w-3" />
Latest
</span>
)}
{hasUpdate && !status && (
<>
<span className="text-xs text-muted-foreground"></span>
<span className="text-xs font-mono text-info">
{latestVersion}
</span>
<span className="text-xs text-muted-foreground">available</span>
</>
)}
{hasUpdate && isOnline && !status && (
<Button
variant="outline"
size="xs"
onClick={handleUpdate}
disabled={updating}
>
<ArrowUpCircle className="h-3 w-3" />
Update
</Button>
)}
{config && Icon && (
<span
className={`inline-flex items-center gap-1 text-xs ${config.color}`}
>
<Icon className={`h-3 w-3 ${isActive ? "animate-spin" : ""}`} />
{config.label}
</span>
)}
</div>
{status === "completed" && output && (
<div className="rounded-lg border bg-success/5 px-3 py-2">
<p className="text-xs text-success">{output}</p>
</div>
)}
{(status === "failed" || status === "timeout") && error && (
<div className="rounded-lg border border-destructive/20 bg-destructive/5 px-3 py-2">
<p className="text-xs text-destructive">{error}</p>
{status === "failed" && (
<Button
variant="ghost"
size="xs"
className="mt-1"
onClick={handleUpdate}
>
Retry
</Button>
)}
</div>
)}
</div>
);
}

View File

@@ -2,6 +2,7 @@
import { useState, useEffect } from "react";
import { BarChart3 } from "lucide-react";
import { Skeleton } from "@/components/ui/skeleton";
import type { RuntimeUsage } from "@/shared/types";
import { api } from "@/shared/api";
import { formatTokens, estimateCost, aggregateByDate } from "../utils";
@@ -38,7 +39,22 @@ export function UsageSection({ runtimeId }: { runtimeId: string }) {
if (loading) {
return (
<div className="text-xs text-muted-foreground">Loading usage...</div>
<div className="space-y-4">
<div className="flex items-center gap-1">
{Array.from({ length: 3 }).map((_, i) => (
<Skeleton key={i} className="h-8 w-12 rounded-md" />
))}
</div>
<div className="grid grid-cols-4 gap-3">
{Array.from({ length: 4 }).map((_, i) => (
<Skeleton key={i} className="h-16 rounded-lg" />
))}
</div>
<div className="grid grid-cols-1 xl:grid-cols-2 gap-4">
<Skeleton className="h-64 rounded-lg" />
<Skeleton className="h-64 rounded-lg" />
</div>
</div>
);
}

View File

@@ -30,6 +30,8 @@ import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
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";
@@ -352,6 +354,7 @@ function SkillDetail({
);
const [selectedPath, setSelectedPath] = useState(SKILL_MD);
const [saving, setSaving] = useState(false);
const [loadingFiles, setLoadingFiles] = useState(false);
const [confirmDelete, setConfirmDelete] = useState(false);
const [showAddFile, setShowAddFile] = useState(false);
@@ -365,10 +368,13 @@ function SkillDetail({
// Fetch full skill (with files) on selection change
useEffect(() => {
setSelectedPath(SKILL_MD);
setLoadingFiles(true);
api.getSkill(skill.id).then((full) => {
useWorkspaceStore.getState().upsertSkill(full);
setFiles((full.files ?? []).map((f) => ({ path: f.path, content: f.content })));
});
}).catch((e) => {
toast.error(e instanceof Error ? e.message : "Failed to load skill files");
}).finally(() => setLoadingFiles(false));
}, [skill.id]);
// Build the virtual file map
@@ -392,6 +398,8 @@ function SkillDetail({
content,
files: files.filter((f) => f.path.trim()),
});
} catch {
// toast handled by parent
} finally {
setSaving(false);
}
@@ -514,22 +522,40 @@ function SkillDetail({
</div>
</div>
<div className="flex-1 overflow-y-auto">
<FileTree
filePaths={filePaths}
selectedPath={selectedPath}
onSelect={setSelectedPath}
/>
{loadingFiles ? (
<div className="p-3 space-y-2">
<Skeleton className="h-4 w-full" />
<Skeleton className="h-4 w-3/4" />
<Skeleton className="h-4 w-1/2" />
</div>
) : (
<FileTree
filePaths={filePaths}
selectedPath={selectedPath}
onSelect={setSelectedPath}
/>
)}
</div>
</div>
{/* File viewer */}
<div className="flex-1 min-w-0">
{loadingFiles ? (
<div className="p-4 space-y-3">
<Skeleton className="h-4 w-full" />
<Skeleton className="h-4 w-5/6" />
<Skeleton className="h-4 w-4/6" />
<Skeleton className="h-4 w-full" />
<Skeleton className="h-4 w-3/4" />
</div>
) : (
<FileViewer
key={selectedPath}
path={selectedPath}
content={selectedContent}
onChange={handleFileContentChange}
/>
)}
</div>
</div>
@@ -604,34 +630,83 @@ export default function SkillsPage() {
const skill = await api.createSkill(data);
upsertSkill(skill);
setSelectedId(skill.id);
toast.success("Skill created");
};
const handleImport = async (url: string) => {
const skill = await api.importSkill({ url });
upsertSkill(skill);
setSelectedId(skill.id);
toast.success("Skill imported");
};
const handleUpdate = async (id: string, data: UpdateSkillRequest) => {
const updated = await api.updateSkill(id, data);
upsertSkill(updated);
try {
const updated = await api.updateSkill(id, data);
upsertSkill(updated);
toast.success("Skill saved");
} catch (e) {
toast.error(e instanceof Error ? e.message : "Failed to save skill");
throw e;
}
};
const handleDelete = async (id: string) => {
await api.deleteSkill(id);
if (selectedId === id) {
const remaining = skills.filter((s) => s.id !== id);
setSelectedId(remaining[0]?.id ?? "");
try {
await api.deleteSkill(id);
if (selectedId === id) {
const remaining = skills.filter((s) => s.id !== id);
setSelectedId(remaining[0]?.id ?? "");
}
removeSkill(id);
toast.success("Skill deleted");
} catch (e) {
toast.error(e instanceof Error ? e.message : "Failed to delete skill");
}
removeSkill(id);
};
const selected = skills.find((s) => s.id === selectedId) ?? null;
if (isLoading) {
return (
<div className="flex h-full 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: 3 }).map((_, i) => (
<div key={i} className="flex items-center gap-3 px-4 py-3">
<Skeleton className="h-8 w-8 rounded-lg" />
<div className="flex-1 space-y-1.5">
<Skeleton className="h-4 w-28" />
<Skeleton className="h-3 w-40" />
</div>
</div>
))}
</div>
</div>
{/* Detail skeleton */}
<div className="flex-1 flex flex-col">
<div className="flex items-center gap-3 border-b px-4 py-3">
<Skeleton className="h-8 w-8 rounded-lg" />
<Skeleton className="h-8 w-40" />
<Skeleton className="h-8 w-56" />
</div>
<div className="flex flex-1 min-h-0">
<div className="w-48 border-r p-3 space-y-2">
<Skeleton className="h-4 w-full" />
<Skeleton className="h-4 w-3/4" />
</div>
<div className="flex-1 p-4 space-y-2">
<Skeleton className="h-4 w-full" />
<Skeleton className="h-4 w-5/6" />
<Skeleton className="h-4 w-2/3" />
</div>
</div>
</div>
</div>
);
}

View File

@@ -5,6 +5,7 @@ import type { Workspace, MemberWithUser, Agent, Skill } from "@/shared/types";
import { useIssueStore } from "@/features/issues";
import { useInboxStore } from "@/features/inbox";
import { useRuntimeStore } from "@/features/runtimes";
import { toast } from "sonner";
import { api } from "@/shared/api";
import { createLogger } from "@/shared/logger";
@@ -76,11 +77,19 @@ export const useWorkspaceStore = create<WorkspaceStore>((set, get) => ({
logger.debug("hydrate workspace", nextWorkspace.name, nextWorkspace.id);
const [nextMembers, nextAgents, nextSkills] = await Promise.all([
api.listMembers(nextWorkspace.id),
api.listAgents({ workspace_id: nextWorkspace.id }),
api.listMembers(nextWorkspace.id).catch((e) => {
logger.error("failed to load members", e);
toast.error("Failed to load members");
return [] as MemberWithUser[];
}),
api.listAgents({ workspace_id: nextWorkspace.id, include_archived: true }).catch((e) => {
logger.error("failed to load agents", e);
toast.error("Failed to load agents");
return [] as Agent[];
}),
api.listSkills().catch(() => [] as Skill[]),
useIssueStore.getState().fetch(),
useInboxStore.getState().fetch(),
useIssueStore.getState().fetch().catch(() => {}),
useInboxStore.getState().fetch().catch(() => {}),
]);
logger.info("hydrate complete", "members:", nextMembers.length, "agents:", nextAgents.length);
set({ members: nextMembers, agents: nextAgents, skills: nextSkills });
@@ -113,16 +122,27 @@ export const useWorkspaceStore = create<WorkspaceStore>((set, get) => ({
refreshWorkspaces: async () => {
const { workspace, hydrateWorkspace } = get();
const storedWorkspaceId = localStorage.getItem("multica_workspace_id");
const wsList = await api.listWorkspaces();
await hydrateWorkspace(wsList, workspace?.id ?? storedWorkspaceId);
return wsList;
try {
const wsList = await api.listWorkspaces();
await hydrateWorkspace(wsList, workspace?.id ?? storedWorkspaceId);
return wsList;
} catch (e) {
logger.error("failed to refresh workspaces", e);
toast.error("Failed to refresh workspaces");
return get().workspaces;
}
},
refreshMembers: async () => {
const { workspace } = get();
if (!workspace) return;
const members = await api.listMembers(workspace.id);
set({ members });
try {
const members = await api.listMembers(workspace.id);
set({ members });
} catch (e) {
logger.error("failed to refresh members", e);
toast.error("Failed to refresh members");
}
},
updateAgent: (id, updates) =>
@@ -133,23 +153,33 @@ export const useWorkspaceStore = create<WorkspaceStore>((set, get) => ({
refreshAgents: async () => {
const { workspace } = get();
if (!workspace) return;
const agents = await api.listAgents({ workspace_id: workspace.id });
set({ agents });
try {
const agents = await api.listAgents({ workspace_id: workspace.id, include_archived: true });
set({ agents });
} catch (e) {
logger.error("failed to refresh agents", e);
toast.error("Failed to refresh agents");
}
},
refreshSkills: async () => {
const { workspace, skills: existing } = get();
if (!workspace) return;
const fetched = await api.listSkills();
// listSkills doesn't include files — preserve files from existing entries
const filesById = new Map(
existing.filter((s) => s.files?.length).map((s) => [s.id, s.files]),
);
const merged = fetched.map((s) => ({
...s,
files: s.files ?? filesById.get(s.id) ?? [],
}));
set({ skills: merged });
try {
const fetched = await api.listSkills();
// listSkills doesn't include files — preserve files from existing entries
const filesById = new Map(
existing.filter((s) => s.files?.length).map((s) => [s.id, s.files]),
);
const merged = fetched.map((s) => ({
...s,
files: s.files ?? filesById.get(s.id) ?? [],
}));
set({ skills: merged });
} catch (e) {
logger.error("failed to refresh skills", e);
toast.error("Failed to refresh skills");
}
},
upsertSkill: (skill) => {

View File

@@ -18,16 +18,21 @@
"@dnd-kit/utilities": "^3.2.2",
"@emoji-mart/data": "^1.2.1",
"@floating-ui/dom": "^1.7.6",
"@tiptap/extension-code-block-lowlight": "3.20.5",
"@tiptap/extension-image": "^3.20.5",
"@tiptap/extension-link": "^3.20.5",
"@tiptap/extension-mention": "^3.20.5",
"@tiptap/extension-placeholder": "^3.20.5",
"@tiptap/extension-typography": "^3.20.5",
"@tiptap/markdown": "^3.20.5",
"@tiptap/pm": "^3.20.5",
"@tiptap/react": "^3.20.5",
"@tiptap/starter-kit": "^3.20.5",
"@tiptap/extension-code-block-lowlight": "^3.22.1",
"@tiptap/extension-image": "^3.22.1",
"@tiptap/extension-link": "^3.22.1",
"@tiptap/extension-mention": "^3.22.1",
"@tiptap/suggestion": "^3.22.1",
"@tiptap/extension-placeholder": "^3.22.1",
"@tiptap/extension-table": "^3.22.1",
"@tiptap/extension-table-cell": "^3.22.1",
"@tiptap/extension-table-header": "^3.22.1",
"@tiptap/extension-table-row": "^3.22.1",
"@tiptap/extension-typography": "^3.22.1",
"@tiptap/markdown": "^3.22.1",
"@tiptap/pm": "^3.22.1",
"@tiptap/react": "^3.22.1",
"@tiptap/starter-kit": "^3.22.1",
"@types/linkify-it": "^5.0.0",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",

View File

@@ -31,9 +31,12 @@ import type {
RuntimeUsage,
RuntimeHourlyActivity,
RuntimePing,
RuntimeUpdate,
TimelineEntry,
TaskMessagePayload,
Attachment,
IssueDependency,
IssueDependencyType,
} from "@/shared/types";
import { type Logger, noopLogger } from "@/shared/logger";
@@ -225,6 +228,22 @@ export class ApiClient {
return this.fetch(`/api/issues/${issueId}/timeline`);
}
// Issue dependencies
async listDependencies(issueId: string): Promise<IssueDependency[]> {
return this.fetch(`/api/issues/${issueId}/dependencies`);
}
async createDependency(issueId: string, dependsOnIssueId: string, type: IssueDependencyType): Promise<IssueDependency> {
return this.fetch(`/api/issues/${issueId}/dependencies`, {
method: "POST",
body: JSON.stringify({ depends_on_issue_id: dependsOnIssueId, type }),
});
}
async deleteDependency(issueId: string, depId: string): Promise<void> {
await this.fetch(`/api/issues/${issueId}/dependencies/${depId}`, { method: "DELETE" });
}
async updateComment(commentId: string, content: string): Promise<Comment> {
return this.fetch(`/api/comments/${commentId}`, {
method: "PUT",
@@ -290,10 +309,11 @@ export class ApiClient {
}
// Agents
async listAgents(params?: { workspace_id?: string }): Promise<Agent[]> {
async listAgents(params?: { workspace_id?: string; include_archived?: boolean }): Promise<Agent[]> {
const search = new URLSearchParams();
const wsId = params?.workspace_id ?? this.workspaceId;
if (wsId) search.set("workspace_id", wsId);
if (params?.include_archived) search.set("include_archived", "true");
return this.fetch(`/api/agents?${search}`);
}
@@ -315,8 +335,12 @@ export class ApiClient {
});
}
async deleteAgent(id: string): Promise<void> {
await this.fetch(`/api/agents/${id}`, { method: "DELETE" });
async archiveAgent(id: string): Promise<Agent> {
return this.fetch(`/api/agents/${id}/archive`, { method: "POST" });
}
async restoreAgent(id: string): Promise<Agent> {
return this.fetch(`/api/agents/${id}/restore`, { method: "POST" });
}
async listRuntimes(params?: { workspace_id?: string }): Promise<AgentRuntime[]> {
@@ -344,6 +368,23 @@ export class ApiClient {
return this.fetch(`/api/runtimes/${runtimeId}/ping/${pingId}`);
}
async initiateUpdate(
runtimeId: string,
targetVersion: string,
): Promise<RuntimeUpdate> {
return this.fetch(`/api/runtimes/${runtimeId}/update`, {
method: "POST",
body: JSON.stringify({ target_version: targetVersion }),
});
}
async getUpdateResult(
runtimeId: string,
updateId: string,
): Promise<RuntimeUpdate> {
return this.fetch(`/api/runtimes/${runtimeId}/update/${updateId}`);
}
async listAgentTasks(agentId: string): Promise<AgentTask[]> {
return this.fetch(`/api/agents/${agentId}/tasks`);
}

View File

@@ -5,31 +5,7 @@ import { toast } from "sonner";
import { api } from "@/shared/api";
import type { Attachment } from "@/shared/types";
const MAX_FILE_SIZE = 10 * 1024 * 1024; // 10 MB
const ALLOWED_TYPES = new Set([
"image/png",
"image/jpeg",
"image/gif",
"image/webp",
"image/svg+xml",
"application/pdf",
"text/plain",
"text/csv",
"application/json",
"video/mp4",
"video/webm",
"audio/mpeg",
"audio/wav",
"application/zip",
]);
function isAllowedType(type: string): boolean {
// Empty MIME type (browser couldn't determine) — let the server sniff and decide.
if (!type) return true;
const mediaType = type.split(";")[0] ?? "";
return ALLOWED_TYPES.has(mediaType.trim().toLowerCase());
}
const MAX_FILE_SIZE = 100 * 1024 * 1024; // 100 MB
export interface UploadResult {
id: string;
@@ -48,10 +24,7 @@ export function useFileUpload() {
const upload = useCallback(
async (file: File, ctx?: UploadContext): Promise<UploadResult | null> => {
if (file.size > MAX_FILE_SIZE) {
throw new Error("File exceeds 10 MB limit");
}
if (!isAllowedType(file.type)) {
throw new Error(`File type not allowed: ${file.type}`);
throw new Error("File exceeds 100 MB limit");
}
setUploading(true);

View File

@@ -73,6 +73,8 @@ export interface Agent {
triggers: AgentTrigger[];
created_at: string;
updated_at: string;
archived_at: string | null;
archived_by: string | null;
}
export interface CreateAgentRequest {
@@ -174,3 +176,21 @@ export interface RuntimeHourlyActivity {
hour: number;
count: number;
}
export type RuntimeUpdateStatus =
| "pending"
| "running"
| "completed"
| "failed"
| "timeout";
export interface RuntimeUpdate {
id: string;
runtime_id: string;
status: RuntimeUpdateStatus;
target_version: string;
output?: string;
error?: string;
created_at: string;
updated_at: string;
}

View File

@@ -15,7 +15,8 @@ export type WSEventType =
| "comment:deleted"
| "agent:status"
| "agent:created"
| "agent:deleted"
| "agent:archived"
| "agent:restored"
| "task:dispatch"
| "task:progress"
| "task:completed"
@@ -71,9 +72,12 @@ export interface AgentCreatedPayload {
agent: Agent;
}
export interface AgentDeletedPayload {
agent_id: string;
workspace_id: string;
export interface AgentArchivedPayload {
agent: Agent;
}
export interface AgentRestoredPayload {
agent: Agent;
}
export interface InboxNewPayload {

View File

@@ -1,4 +1,4 @@
export type { Issue, IssueStatus, IssuePriority, IssueAssigneeType, IssueReaction } from "./issue";
export type { Issue, IssueStatus, IssuePriority, IssueAssigneeType, IssueReaction, IssueDependency, IssueDependencyType } from "./issue";
export type {
Agent,
AgentStatus,
@@ -21,6 +21,8 @@ export type {
RuntimeHourlyActivity,
RuntimePing,
RuntimePingStatus,
RuntimeUpdate,
RuntimeUpdateStatus,
} from "./agent";
export type { Workspace, WorkspaceRepo, Member, MemberRole, User, MemberWithUser } from "./workspace";
export type { InboxItem, InboxSeverity, InboxItemType } from "./inbox";

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