Skills
-
+
+ setShowCreate(true)}
+ >
+
+
+ }
+ />
+ Create skill
+
{skills.length === 0 ? (
diff --git a/apps/web/package.json b/apps/web/package.json
index a1652a6c..b58ef46e 100644
--- a/apps/web/package.json
+++ b/apps/web/package.json
@@ -17,6 +17,7 @@
"@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2",
"@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/pm": "^3.20.5",
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 17c31730..fb25de27 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -72,6 +72,9 @@ importers:
'@tiptap/extension-link':
specifier: ^3.20.5
version: 3.20.5(@tiptap/core@3.20.5(@tiptap/pm@3.20.5))(@tiptap/pm@3.20.5)
+ '@tiptap/extension-mention':
+ specifier: ^3.20.5
+ version: 3.20.5(@tiptap/core@3.20.5(@tiptap/pm@3.20.5))(@tiptap/pm@3.20.5)(@tiptap/suggestion@3.20.5(@tiptap/core@3.20.5(@tiptap/pm@3.20.5))(@tiptap/pm@3.20.5))
'@tiptap/extension-placeholder':
specifier: ^3.20.5
version: 3.20.5(@tiptap/extensions@3.20.5(@tiptap/core@3.20.5(@tiptap/pm@3.20.5))(@tiptap/pm@3.20.5))
@@ -1380,6 +1383,13 @@ packages:
'@tiptap/core': ^3.20.5
'@tiptap/pm': ^3.20.5
+ '@tiptap/extension-mention@3.20.5':
+ resolution: {integrity: sha512-SEyIV500gAfzylvbWog2gUK6Z6fJhGYXCuGOHAGj+w2Vy3C262w8HXC9uQ+BrY/vdZp8iSpFY4AbTf5xkqkijA==}
+ peerDependencies:
+ '@tiptap/core': ^3.20.5
+ '@tiptap/pm': ^3.20.5
+ '@tiptap/suggestion': ^3.20.5
+
'@tiptap/extension-ordered-list@3.20.5':
resolution: {integrity: sha512-Y/RIE3AxUNYAFKGMM5FLlTVKxxBvOh4JlLp/qYsOCY2nJdH0Jopl2FpfBYc4xoJwFSk8BELJ4Ow0adcYb15ksg==}
peerDependencies:
@@ -1437,6 +1447,12 @@ packages:
'@tiptap/starter-kit@3.20.5':
resolution: {integrity: sha512-L5E2TCGK0EiwmGIlwMsiwNTW1TLbfPF1Dsji4bSKRJnPbccZIMCB6qdId8v/Z+QGm85NVcBHeruQrDlKDddXBA==}
+ '@tiptap/suggestion@3.20.5':
+ resolution: {integrity: sha512-5fqRNgnzYdJ1oDpyLqwrbVsZwvI+5VW/U89LPMvBYM7sFS7Xd0xfyxyAOWcJN4V0zLeTcuElWN3R+IUTLKbU+Q==}
+ peerDependencies:
+ '@tiptap/core': ^3.20.5
+ '@tiptap/pm': ^3.20.5
+
'@ts-morph/common@0.27.0':
resolution: {integrity: sha512-Wf29UqxWDpc+i61k3oIOzcUfQt79PIT9y/MWfAGlrkjg6lBC1hwDECLXPVJAhWjiGbfBCxZd65F/LIZF3+jeJQ==}
@@ -4945,6 +4961,12 @@ snapshots:
'@tiptap/core': 3.20.5(@tiptap/pm@3.20.5)
'@tiptap/pm': 3.20.5
+ '@tiptap/extension-mention@3.20.5(@tiptap/core@3.20.5(@tiptap/pm@3.20.5))(@tiptap/pm@3.20.5)(@tiptap/suggestion@3.20.5(@tiptap/core@3.20.5(@tiptap/pm@3.20.5))(@tiptap/pm@3.20.5))':
+ dependencies:
+ '@tiptap/core': 3.20.5(@tiptap/pm@3.20.5)
+ '@tiptap/pm': 3.20.5
+ '@tiptap/suggestion': 3.20.5(@tiptap/core@3.20.5(@tiptap/pm@3.20.5))(@tiptap/pm@3.20.5)
+
'@tiptap/extension-ordered-list@3.20.5(@tiptap/extension-list@3.20.5(@tiptap/core@3.20.5(@tiptap/pm@3.20.5))(@tiptap/pm@3.20.5))':
dependencies:
'@tiptap/extension-list': 3.20.5(@tiptap/core@3.20.5(@tiptap/pm@3.20.5))(@tiptap/pm@3.20.5)
@@ -5043,6 +5065,11 @@ snapshots:
'@tiptap/extensions': 3.20.5(@tiptap/core@3.20.5(@tiptap/pm@3.20.5))(@tiptap/pm@3.20.5)
'@tiptap/pm': 3.20.5
+ '@tiptap/suggestion@3.20.5(@tiptap/core@3.20.5(@tiptap/pm@3.20.5))(@tiptap/pm@3.20.5)':
+ dependencies:
+ '@tiptap/core': 3.20.5(@tiptap/pm@3.20.5)
+ '@tiptap/pm': 3.20.5
+
'@ts-morph/common@0.27.0':
dependencies:
fast-glob: 3.3.3
diff --git a/server/cmd/server/inbox_listeners.go b/server/cmd/server/inbox_listeners.go
index 1cdeb232..7c8a2dce 100644
--- a/server/cmd/server/inbox_listeners.go
+++ b/server/cmd/server/inbox_listeners.go
@@ -3,6 +3,7 @@ package main
import (
"context"
"log/slog"
+ "regexp"
"github.com/multica-ai/multica/server/internal/events"
"github.com/multica-ai/multica/server/internal/handler"
@@ -11,11 +12,83 @@ import (
"github.com/multica-ai/multica/server/pkg/protocol"
)
+// mention represents a parsed @mention from markdown content.
+type mention struct {
+ Type string // "member" or "agent"
+ ID string // user_id or agent_id
+}
+
+// mentionRe matches [@Label](mention://type/id) in markdown.
+var mentionRe = regexp.MustCompile(`\[@[^\]]*\]\(mention://(member|agent)/([0-9a-fA-F-]+)\)`)
+
+// parseMentions extracts mentions from markdown content.
+func parseMentions(content string) []mention {
+ matches := mentionRe.FindAllStringSubmatch(content, -1)
+ seen := make(map[string]bool)
+ var result []mention
+ for _, m := range matches {
+ key := m[1] + ":" + m[2]
+ if seen[key] {
+ continue
+ }
+ seen[key] = true
+ result = append(result, mention{Type: m[1], ID: m[2]})
+ }
+ return result
+}
+
+// notifyMentionedMembers creates inbox items for each @mentioned member,
+// excluding the actor and any IDs in the skip set.
+func notifyMentionedMembers(
+ bus *events.Bus,
+ queries *db.Queries,
+ e events.Event,
+ mentions []mention,
+ issueID string,
+ issueTitle string,
+ issueStatus string,
+ title string,
+ skip map[string]bool,
+) {
+ for _, m := range mentions {
+ if m.Type != "member" {
+ continue
+ }
+ if m.ID == e.ActorID || skip[m.ID] {
+ continue
+ }
+ item, err := queries.CreateInboxItem(context.Background(), db.CreateInboxItemParams{
+ WorkspaceID: parseUUID(e.WorkspaceID),
+ RecipientType: "member",
+ RecipientID: parseUUID(m.ID),
+ Type: "mentioned",
+ Severity: "info",
+ IssueID: parseUUID(issueID),
+ Title: title,
+ ActorType: util.StrToText(e.ActorType),
+ ActorID: parseUUID(e.ActorID),
+ })
+ if err != nil {
+ slog.Error("mention inbox creation failed", "mentioned_id", m.ID, "error", err)
+ continue
+ }
+ resp := inboxItemToResponse(item)
+ resp["issue_status"] = issueStatus
+ bus.Publish(events.Event{
+ Type: protocol.EventInboxNew,
+ WorkspaceID: e.WorkspaceID,
+ ActorType: e.ActorType,
+ ActorID: e.ActorID,
+ Payload: map[string]any{"item": resp},
+ })
+ }
+}
+
// registerInboxListeners wires up event bus listeners that create inbox
// notifications. This replaces the inline CreateInboxItem calls that were
// previously scattered across issue and comment handlers.
func registerInboxListeners(bus *events.Bus, queries *db.Queries) {
- // issue:created — notify assignee about new assignment
+ // issue:created — notify assignee about new assignment + @mentions in description
bus.Subscribe(protocol.EventIssueCreated, func(e events.Event) {
payload, ok := e.Payload.(map[string]any)
if !ok {
@@ -25,37 +98,46 @@ func registerInboxListeners(bus *events.Bus, queries *db.Queries) {
if !ok {
return
}
- if issue.AssigneeType == nil || issue.AssigneeID == nil {
- return
+
+ // Track who already got notified to avoid duplicates
+ skip := map[string]bool{e.ActorID: true}
+
+ // Notify assignee
+ if issue.AssigneeType != nil && issue.AssigneeID != nil {
+ skip[*issue.AssigneeID] = true
+ item, err := queries.CreateInboxItem(context.Background(), db.CreateInboxItemParams{
+ WorkspaceID: parseUUID(issue.WorkspaceID),
+ RecipientType: *issue.AssigneeType,
+ RecipientID: parseUUID(*issue.AssigneeID),
+ Type: "issue_assigned",
+ Severity: "action_required",
+ IssueID: parseUUID(issue.ID),
+ Title: "New issue assigned: " + issue.Title,
+ Body: util.PtrToText(issue.Description),
+ ActorType: util.StrToText(e.ActorType),
+ ActorID: parseUUID(e.ActorID),
+ })
+ if err != nil {
+ slog.Error("inbox item creation failed", "event", "issue:created", "error", err)
+ } else {
+ resp := inboxItemToResponse(item)
+ resp["issue_status"] = issue.Status
+ bus.Publish(events.Event{
+ Type: protocol.EventInboxNew,
+ WorkspaceID: e.WorkspaceID,
+ ActorType: e.ActorType,
+ ActorID: e.ActorID,
+ Payload: map[string]any{"item": resp},
+ })
+ }
}
- item, err := queries.CreateInboxItem(context.Background(), db.CreateInboxItemParams{
- WorkspaceID: parseUUID(issue.WorkspaceID),
- RecipientType: *issue.AssigneeType,
- RecipientID: parseUUID(*issue.AssigneeID),
- Type: "issue_assigned",
- Severity: "action_required",
- IssueID: parseUUID(issue.ID),
- Title: "New issue assigned: " + issue.Title,
- Body: util.PtrToText(issue.Description),
- ActorType: util.StrToText(e.ActorType),
- ActorID: parseUUID(e.ActorID),
- })
- if err != nil {
- slog.Error("inbox item creation failed", "event", "issue:created", "error", err)
- return
+ // Notify @mentions in description
+ if issue.Description != nil && *issue.Description != "" {
+ mentions := parseMentions(*issue.Description)
+ notifyMentionedMembers(bus, queries, e, mentions, issue.ID, issue.Title, issue.Status,
+ "Mentioned in: "+issue.Title, skip)
}
-
- resp := inboxItemToResponse(item)
- resp["issue_status"] = issue.Status
-
- bus.Publish(events.Event{
- Type: protocol.EventInboxNew,
- WorkspaceID: e.WorkspaceID,
- ActorType: e.ActorType,
- ActorID: e.ActorID,
- Payload: map[string]any{"item": resp},
- })
})
// issue:updated — notify on assignee change and status change
@@ -70,8 +152,10 @@ func registerInboxListeners(bus *events.Bus, queries *db.Queries) {
}
assigneeChanged, _ := payload["assignee_changed"].(bool)
statusChanged, _ := payload["status_changed"].(bool)
+ descriptionChanged, _ := payload["description_changed"].(bool)
prevAssigneeType, _ := payload["prev_assignee_type"].(*string)
prevAssigneeID, _ := payload["prev_assignee_id"].(*string)
+ prevDescription, _ := payload["prev_description"].(*string)
creatorType, _ := payload["creator_type"].(string)
creatorID, _ := payload["creator_id"].(string)
@@ -189,9 +273,33 @@ func registerInboxListeners(bus *events.Bus, queries *db.Queries) {
}
}
}
+
+ // Notify NEW @mentions in description (only mentions that weren't in previous description)
+ if descriptionChanged && issue.Description != nil {
+ newMentions := parseMentions(*issue.Description)
+ if len(newMentions) > 0 {
+ // Build set of previously mentioned IDs
+ prevMentioned := map[string]bool{}
+ if prevDescription != nil {
+ for _, m := range parseMentions(*prevDescription) {
+ prevMentioned[m.Type+":"+m.ID] = true
+ }
+ }
+ // Filter to only new mentions
+ var added []mention
+ for _, m := range newMentions {
+ if !prevMentioned[m.Type+":"+m.ID] {
+ added = append(added, m)
+ }
+ }
+ skip := map[string]bool{actorID: true}
+ notifyMentionedMembers(bus, queries, e, added, issue.ID, issue.Title, issue.Status,
+ "Mentioned in: "+issue.Title, skip)
+ }
+ }
})
- // comment:created — notify issue assignee about new comment
+ // comment:created — notify issue assignee + @mentions in comment
bus.Subscribe(protocol.EventCommentCreated, func(e events.Event) {
payload, ok := e.Payload.(map[string]any)
if !ok {
@@ -206,41 +314,44 @@ func registerInboxListeners(bus *events.Bus, queries *db.Queries) {
issueAssigneeID, _ := payload["issue_assignee_id"].(*string)
issueStatus, _ := payload["issue_status"].(string)
- // Only notify if assignee is a member and is not the commenter
- if issueAssigneeType == nil || issueAssigneeID == nil {
- return
- }
- if *issueAssigneeType != "member" || *issueAssigneeID == e.ActorID {
- return
+ // Track who already got notified
+ skip := map[string]bool{e.ActorID: true}
+
+ // Notify assignee (if member and not the commenter)
+ if issueAssigneeType != nil && issueAssigneeID != nil &&
+ *issueAssigneeType == "member" && *issueAssigneeID != e.ActorID {
+ skip[*issueAssigneeID] = true
+ item, err := queries.CreateInboxItem(context.Background(), db.CreateInboxItemParams{
+ WorkspaceID: parseUUID(e.WorkspaceID),
+ RecipientType: "member",
+ RecipientID: parseUUID(*issueAssigneeID),
+ Type: "mentioned",
+ Severity: "info",
+ IssueID: parseUUID(comment.IssueID),
+ Title: "New comment on: " + issueTitle,
+ Body: util.StrToText(comment.Content),
+ ActorType: util.StrToText(e.ActorType),
+ ActorID: parseUUID(e.ActorID),
+ })
+ if err != nil {
+ slog.Error("inbox item creation failed", "event", "comment:created", "error", err)
+ } else {
+ commentResp := inboxItemToResponse(item)
+ commentResp["issue_status"] = issueStatus
+ bus.Publish(events.Event{
+ Type: protocol.EventInboxNew,
+ WorkspaceID: e.WorkspaceID,
+ ActorType: e.ActorType,
+ ActorID: e.ActorID,
+ Payload: map[string]any{"item": commentResp},
+ })
+ }
}
- item, err := queries.CreateInboxItem(context.Background(), db.CreateInboxItemParams{
- WorkspaceID: parseUUID(e.WorkspaceID),
- RecipientType: "member",
- RecipientID: parseUUID(*issueAssigneeID),
- Type: "mentioned",
- Severity: "info",
- IssueID: parseUUID(comment.IssueID),
- Title: "New comment on: " + issueTitle,
- Body: util.StrToText(comment.Content),
- ActorType: util.StrToText(e.ActorType),
- ActorID: parseUUID(e.ActorID),
- })
- if err != nil {
- slog.Error("inbox item creation failed", "event", "comment:created", "error", err)
- return
- }
-
- commentResp := inboxItemToResponse(item)
- commentResp["issue_status"] = issueStatus
-
- bus.Publish(events.Event{
- Type: protocol.EventInboxNew,
- WorkspaceID: e.WorkspaceID,
- ActorType: e.ActorType,
- ActorID: e.ActorID,
- Payload: map[string]any{"item": commentResp},
- })
+ // Notify @mentions in comment content
+ mentions := parseMentions(comment.Content)
+ notifyMentionedMembers(bus, queries, e, mentions, comment.IssueID, issueTitle, issueStatus,
+ "Mentioned in comment: "+issueTitle, skip)
})
}
diff --git a/server/cmd/server/listeners.go b/server/cmd/server/listeners.go
index 8008d789..3b5bc316 100644
--- a/server/cmd/server/listeners.go
+++ b/server/cmd/server/listeners.go
@@ -28,6 +28,8 @@ func registerListeners(bus *events.Bus, hub *realtime.Hub) {
protocol.EventInboxNew,
protocol.EventInboxRead,
protocol.EventInboxArchived,
+ protocol.EventInboxBatchRead,
+ protocol.EventInboxBatchArchived,
protocol.EventWorkspaceUpdated,
protocol.EventWorkspaceDeleted,
protocol.EventMemberAdded,
diff --git a/server/internal/handler/inbox.go b/server/internal/handler/inbox.go
index 38c513a4..41e243f9 100644
--- a/server/internal/handler/inbox.go
+++ b/server/internal/handler/inbox.go
@@ -1,11 +1,13 @@
package handler
import (
+ "context"
"log/slog"
"net/http"
"strconv"
"github.com/go-chi/chi/v5"
+ "github.com/jackc/pgx/v5/pgtype"
"github.com/multica-ai/multica/server/internal/logger"
db "github.com/multica-ai/multica/server/pkg/db/generated"
"github.com/multica-ai/multica/server/pkg/protocol"
@@ -68,6 +70,18 @@ func inboxRowToResponse(r db.ListInboxItemsRow) InboxItemResponse {
}
}
+func (h *Handler) enrichInboxResponse(ctx context.Context, resp InboxItemResponse, issueID pgtype.UUID) InboxItemResponse {
+ if !issueID.Valid {
+ return resp
+ }
+ issue, err := h.Queries.GetIssue(ctx, issueID)
+ if err == nil {
+ s := issue.Status
+ resp.IssueStatus = &s
+ }
+ return resp
+}
+
func (h *Handler) ListInbox(w http.ResponseWriter, r *http.Request) {
userID, ok := requireUserID(w, r)
if !ok {
@@ -124,7 +138,8 @@ func (h *Handler) MarkInboxRead(w http.ResponseWriter, r *http.Request) {
"recipient_id": uuidToString(item.RecipientID),
})
- writeJSON(w, http.StatusOK, inboxToResponse(item))
+ resp := h.enrichInboxResponse(r.Context(), inboxToResponse(item), item.IssueID)
+ writeJSON(w, http.StatusOK, resp)
}
func (h *Handler) ArchiveInboxItem(w http.ResponseWriter, r *http.Request) {
@@ -145,7 +160,8 @@ func (h *Handler) ArchiveInboxItem(w http.ResponseWriter, r *http.Request) {
"recipient_id": uuidToString(item.RecipientID),
})
- writeJSON(w, http.StatusOK, inboxToResponse(item))
+ resp := h.enrichInboxResponse(r.Context(), inboxToResponse(item), item.IssueID)
+ writeJSON(w, http.StatusOK, resp)
}
func (h *Handler) CountUnreadInbox(w http.ResponseWriter, r *http.Request) {
diff --git a/server/internal/handler/issue.go b/server/internal/handler/issue.go
index 2b6af1b2..1225a83b 100644
--- a/server/internal/handler/issue.go
+++ b/server/internal/handler/issue.go
@@ -375,16 +375,19 @@ func (h *Handler) UpdateIssue(w http.ResponseWriter, r *http.Request) {
assigneeChanged := (req.AssigneeType != nil || req.AssigneeID != nil) &&
(prevIssue.AssigneeType.String != issue.AssigneeType.String || uuidToString(prevIssue.AssigneeID) != uuidToString(issue.AssigneeID))
statusChanged := req.Status != nil && prevIssue.Status != issue.Status
+ descriptionChanged := req.Description != nil && textToPtr(prevIssue.Description) != resp.Description
h.publish(protocol.EventIssueUpdated, workspaceID, "member", userID, map[string]any{
- "issue": resp,
- "assignee_changed": assigneeChanged,
- "status_changed": statusChanged,
- "prev_assignee_type": textToPtr(prevIssue.AssigneeType),
- "prev_assignee_id": uuidToPtr(prevIssue.AssigneeID),
- "prev_status": prevIssue.Status,
- "creator_type": prevIssue.CreatorType,
- "creator_id": uuidToString(prevIssue.CreatorID),
+ "issue": resp,
+ "assignee_changed": assigneeChanged,
+ "status_changed": statusChanged,
+ "description_changed": descriptionChanged,
+ "prev_assignee_type": textToPtr(prevIssue.AssigneeType),
+ "prev_assignee_id": uuidToPtr(prevIssue.AssigneeID),
+ "prev_status": prevIssue.Status,
+ "prev_description": textToPtr(prevIssue.Description),
+ "creator_type": prevIssue.CreatorType,
+ "creator_id": uuidToString(prevIssue.CreatorID),
})
// If assignee or readiness status changed, reconcile the task queue.
diff --git a/server/internal/service/task.go b/server/internal/service/task.go
index 9f61e13f..da2a8332 100644
--- a/server/internal/service/task.go
+++ b/server/internal/service/task.go
@@ -197,7 +197,7 @@ func (s *TaskService) CompleteTask(ctx context.Context, taskID pgtype.UUID, resu
}
if issueErr == nil {
- s.createInboxForIssueCreator(ctx, issue, "review_requested", "attention", "Review requested: "+issue.Title, "")
+ s.createInboxForIssueCreator(ctx, issue, task.AgentID, "review_requested", "attention", "Review requested: "+issue.Title, "")
}
// Reconcile agent status
@@ -233,7 +233,7 @@ func (s *TaskService) FailTask(ctx context.Context, taskID pgtype.UUID, errMsg s
s.createAgentComment(ctx, task.IssueID, task.AgentID, errMsg, "system")
}
if issueErr == nil {
- s.createInboxForIssueCreator(ctx, issue, "agent_blocked", "action_required", "Agent blocked: "+issue.Title, errMsg)
+ s.createInboxForIssueCreator(ctx, issue, task.AgentID, "agent_blocked", "action_required", "Agent blocked: "+issue.Title, errMsg)
}
// Reconcile agent status
@@ -474,7 +474,7 @@ func (s *TaskService) createAgentComment(ctx context.Context, issueID, agentID p
})
}
-func (s *TaskService) createInboxForIssueCreator(ctx context.Context, issue db.Issue, itemType, severity, title, body string) {
+func (s *TaskService) createInboxForIssueCreator(ctx context.Context, issue db.Issue, agentID pgtype.UUID, itemType, severity, title, body string) {
if issue.CreatorType != "member" {
return
}
@@ -487,16 +487,20 @@ func (s *TaskService) createInboxForIssueCreator(ctx context.Context, issue db.I
IssueID: issue.ID,
Title: title,
Body: util.PtrToText(&body),
+ ActorType: util.StrToText("agent"),
+ ActorID: agentID,
})
if err != nil {
return
}
+ resp := inboxToMap(item)
+ resp["issue_status"] = issue.Status
s.Bus.Publish(events.Event{
Type: protocol.EventInboxNew,
WorkspaceID: util.UUIDToString(issue.WorkspaceID),
- ActorType: "system",
- ActorID: "",
- Payload: map[string]any{"item": inboxToMap(item)},
+ ActorType: "agent",
+ ActorID: util.UUIDToString(agentID),
+ Payload: map[string]any{"item": resp},
})
}
@@ -552,6 +556,8 @@ func inboxToMap(item db.InboxItem) map[string]any {
"read": item.Read,
"archived": item.Archived,
"created_at": util.TimestampToString(item.CreatedAt),
+ "actor_type": util.TextToPtr(item.ActorType),
+ "actor_id": util.UUIDToPtr(item.ActorID),
}
}