diff --git a/packages/core/api/client.ts b/packages/core/api/client.ts index 73b46e2561..77631f97a5 100644 --- a/packages/core/api/client.ts +++ b/packages/core/api/client.ts @@ -35,6 +35,7 @@ import type { RuntimePing, RuntimeUpdate, TimelineEntry, + AssigneeFrequencyEntry, TaskMessagePayload, Attachment, ChatSession, @@ -259,6 +260,10 @@ export class ApiClient { return this.fetch(`/api/issues/${issueId}/timeline`); } + async getAssigneeFrequency(): Promise { + return this.fetch("/api/assignee-frequency"); + } + async updateComment(commentId: string, content: string): Promise { return this.fetch(`/api/comments/${commentId}`, { method: "PUT", diff --git a/packages/core/types/activity.ts b/packages/core/types/activity.ts index d14cbebc77..1f33a06801 100644 --- a/packages/core/types/activity.ts +++ b/packages/core/types/activity.ts @@ -1,6 +1,12 @@ import type { Reaction } from "./comment"; import type { Attachment } from "./attachment"; +export interface AssigneeFrequencyEntry { + assignee_type: string; + assignee_id: string; + frequency: number; +} + export interface TimelineEntry { type: "activity" | "comment"; id: string; diff --git a/packages/core/types/index.ts b/packages/core/types/index.ts index 7f6fabbfb9..b66f54f312 100644 --- a/packages/core/types/index.ts +++ b/packages/core/types/index.ts @@ -25,7 +25,7 @@ export type { export type { Workspace, WorkspaceRepo, Member, MemberRole, User, MemberWithUser } from "./workspace"; export type { InboxItem, InboxSeverity, InboxItemType } from "./inbox"; export type { Comment, CommentType, CommentAuthorType, Reaction } from "./comment"; -export type { TimelineEntry } from "./activity"; +export type { TimelineEntry, AssigneeFrequencyEntry } from "./activity"; export type { IssueSubscriber } from "./subscriber"; export type * from "./events"; export type * from "./api"; diff --git a/packages/core/workspace/queries.ts b/packages/core/workspace/queries.ts index 8c847fa7cc..1924e7a27b 100644 --- a/packages/core/workspace/queries.ts +++ b/packages/core/workspace/queries.ts @@ -7,6 +7,7 @@ export const workspaceKeys = { members: (wsId: string) => ["workspaces", wsId, "members"] as const, agents: (wsId: string) => ["workspaces", wsId, "agents"] as const, skills: (wsId: string) => ["workspaces", wsId, "skills"] as const, + assigneeFrequency: (wsId: string) => ["workspaces", wsId, "assignee-frequency"] as const, }; export function workspaceListOptions() { @@ -37,3 +38,10 @@ export function skillListOptions(wsId: string) { queryFn: () => api.listSkills(), }); } + +export function assigneeFrequencyOptions(wsId: string) { + return queryOptions({ + queryKey: workspaceKeys.assigneeFrequency(wsId), + queryFn: () => api.getAssigneeFrequency(), + }); +} diff --git a/packages/views/issues/components/issue-detail.test.tsx b/packages/views/issues/components/issue-detail.test.tsx index e18303d30c..58d49a48c6 100644 --- a/packages/views/issues/components/issue-detail.test.tsx +++ b/packages/views/issues/components/issue-detail.test.tsx @@ -70,6 +70,10 @@ vi.mock("@multica/core/workspace/queries", () => ({ queryKey: ["workspaces", "ws-1", "agents"], queryFn: () => Promise.resolve([]), }), + assigneeFrequencyOptions: () => ({ + queryKey: ["workspaces", "ws-1", "assignee-frequency"], + queryFn: () => Promise.resolve([]), + }), })); // Mock navigation diff --git a/packages/views/issues/components/pickers/assignee-picker.tsx b/packages/views/issues/components/pickers/assignee-picker.tsx index 1e961edd7f..20d4d9019c 100644 --- a/packages/views/issues/components/pickers/assignee-picker.tsx +++ b/packages/views/issues/components/pickers/assignee-picker.tsx @@ -1,13 +1,13 @@ "use client"; -import { useState } from "react"; +import { useMemo, useState } from "react"; import { Lock, UserMinus } from "lucide-react"; import type { Agent, IssueAssigneeType, UpdateIssueRequest } from "@multica/core/types"; import { useQuery } from "@tanstack/react-query"; import { useAuthStore } from "@multica/core/auth"; import { useActorName } from "@multica/core/workspace/hooks"; import { useWorkspaceId } from "@multica/core/hooks"; -import { memberListOptions, agentListOptions } from "@multica/core/workspace/queries"; +import { memberListOptions, agentListOptions, assigneeFrequencyOptions } from "@multica/core/workspace/queries"; import { ActorAvatar } from "../../../common/actor-avatar"; import { PropertyPicker, @@ -50,18 +50,30 @@ export function AssigneePicker({ const wsId = useWorkspaceId(); const { data: members = [] } = useQuery(memberListOptions(wsId)); const { data: agents = [] } = useQuery(agentListOptions(wsId)); + const { data: frequency = [] } = useQuery(assigneeFrequencyOptions(wsId)); const { getActorName } = useActorName(); const currentMember = members.find((m) => m.user_id === user?.id); const memberRole = currentMember?.role; + // Build a lookup map from frequency data for sorting. + const freqMap = useMemo(() => { + const map = new Map(); + for (const entry of frequency) { + map.set(`${entry.assignee_type}:${entry.assignee_id}`, entry.frequency); + } + return map; + }, [frequency]); + + const getFreq = (type: string, id: string) => freqMap.get(`${type}:${id}`) ?? 0; + const query = filter.toLowerCase(); - const filteredMembers = members.filter((m) => - m.name.toLowerCase().includes(query), - ); - const filteredAgents = agents.filter((a) => - !a.archived_at && a.name.toLowerCase().includes(query), - ); + const filteredMembers = members + .filter((m) => m.name.toLowerCase().includes(query)) + .sort((a, b) => getFreq("member", b.user_id) - getFreq("member", a.user_id)); + const filteredAgents = agents + .filter((a) => !a.archived_at && a.name.toLowerCase().includes(query)) + .sort((a, b) => getFreq("agent", b.id) - getFreq("agent", a.id)); const isSelected = (type: string, id: string) => assigneeType === type && assigneeId === id; diff --git a/server/cmd/server/router.go b/server/cmd/server/router.go index f00b2e0aa7..2cb96483a8 100644 --- a/server/cmd/server/router.go +++ b/server/cmd/server/router.go @@ -163,6 +163,9 @@ func NewRouter(pool *pgxpool.Pool, hub *realtime.Hub, bus *events.Bus) chi.Route r.Group(func(r chi.Router) { r.Use(middleware.RequireWorkspaceMember(queries)) + // Assignee frequency + r.Get("/api/assignee-frequency", h.GetAssigneeFrequency) + // Issues r.Route("/api/issues", func(r chi.Router) { r.Get("/search", h.SearchIssues) diff --git a/server/internal/handler/activity.go b/server/internal/handler/activity.go index b73810ebb2..4801ef1a69 100644 --- a/server/internal/handler/activity.go +++ b/server/internal/handler/activity.go @@ -115,3 +115,81 @@ func (h *Handler) ListTimeline(w http.ResponseWriter, r *http.Request) { writeJSON(w, http.StatusOK, timeline) } + +// AssigneeFrequencyEntry represents how often a user assigns to a specific target. +type AssigneeFrequencyEntry struct { + AssigneeType string `json:"assignee_type"` + AssigneeID string `json:"assignee_id"` + Frequency int64 `json:"frequency"` +} + +// GetAssigneeFrequency returns assignee usage frequency for the current user, +// combining data from assignee change activities and initial issue assignments. +func (h *Handler) GetAssigneeFrequency(w http.ResponseWriter, r *http.Request) { + userID, ok := requireUserID(w, r) + if !ok { + return + } + workspaceID := resolveWorkspaceID(r) + + // Aggregate frequency from both data sources. + freq := map[string]int64{} // key: "type:id" + + // Source 1: assignee_changed activities by this user. + activityCounts, err := h.Queries.CountAssigneeChangesByActor(r.Context(), db.CountAssigneeChangesByActorParams{ + WorkspaceID: parseUUID(workspaceID), + ActorID: parseUUID(userID), + }) + if err != nil { + writeError(w, http.StatusInternalServerError, "failed to get assignee frequency") + return + } + for _, row := range activityCounts { + aType, _ := row.AssigneeType.(string) + aID, _ := row.AssigneeID.(string) + if aType != "" && aID != "" { + freq[aType+":"+aID] += row.Frequency + } + } + + // Source 2: issues created by this user with an assignee. + issueCounts, err := h.Queries.CountCreatedIssueAssignees(r.Context(), db.CountCreatedIssueAssigneesParams{ + WorkspaceID: parseUUID(workspaceID), + CreatorID: parseUUID(userID), + }) + if err != nil { + writeError(w, http.StatusInternalServerError, "failed to get assignee frequency") + return + } + for _, row := range issueCounts { + if !row.AssigneeType.Valid || !row.AssigneeID.Valid { + continue + } + key := row.AssigneeType.String + ":" + uuidToString(row.AssigneeID) + freq[key] += row.Frequency + } + + // Build sorted response. + result := make([]AssigneeFrequencyEntry, 0, len(freq)) + for key, count := range freq { + // Split "type:id" — type is always "member" or "agent" (no colons). + var aType, aID string + for i := 0; i < len(key); i++ { + if key[i] == ':' { + aType = key[:i] + aID = key[i+1:] + break + } + } + result = append(result, AssigneeFrequencyEntry{ + AssigneeType: aType, + AssigneeID: aID, + Frequency: count, + }) + } + sort.Slice(result, func(i, j int) bool { + return result[i].Frequency > result[j].Frequency + }) + + writeJSON(w, http.StatusOK, result) +} diff --git a/server/pkg/db/generated/activity.sql.go b/server/pkg/db/generated/activity.sql.go index af2394d995..c491fba8b0 100644 --- a/server/pkg/db/generated/activity.sql.go +++ b/server/pkg/db/generated/activity.sql.go @@ -11,6 +11,53 @@ import ( "github.com/jackc/pgx/v5/pgtype" ) +const countAssigneeChangesByActor = `-- name: CountAssigneeChangesByActor :many +SELECT + details->>'to_type' as assignee_type, + details->>'to_id' as assignee_id, + COUNT(*)::bigint as frequency +FROM activity_log +WHERE workspace_id = $1 + AND actor_id = $2 + AND actor_type = 'member' + AND action = 'assignee_changed' + AND details->>'to_type' IS NOT NULL + AND details->>'to_id' IS NOT NULL +GROUP BY details->>'to_type', details->>'to_id' +` + +type CountAssigneeChangesByActorParams struct { + WorkspaceID pgtype.UUID `json:"workspace_id"` + ActorID pgtype.UUID `json:"actor_id"` +} + +type CountAssigneeChangesByActorRow struct { + AssigneeType interface{} `json:"assignee_type"` + AssigneeID interface{} `json:"assignee_id"` + Frequency int64 `json:"frequency"` +} + +// Count how many times a user assigned each target via assignee_changed activities. +func (q *Queries) CountAssigneeChangesByActor(ctx context.Context, arg CountAssigneeChangesByActorParams) ([]CountAssigneeChangesByActorRow, error) { + rows, err := q.db.Query(ctx, countAssigneeChangesByActor, arg.WorkspaceID, arg.ActorID) + if err != nil { + return nil, err + } + defer rows.Close() + items := []CountAssigneeChangesByActorRow{} + for rows.Next() { + var i CountAssigneeChangesByActorRow + if err := rows.Scan(&i.AssigneeType, &i.AssigneeID, &i.Frequency); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + const createActivity = `-- name: CreateActivity :one INSERT INTO activity_log ( workspace_id, issue_id, actor_type, actor_id, action, details diff --git a/server/pkg/db/generated/issue.sql.go b/server/pkg/db/generated/issue.sql.go index 17c91cd927..996131f6bf 100644 --- a/server/pkg/db/generated/issue.sql.go +++ b/server/pkg/db/generated/issue.sql.go @@ -11,6 +11,52 @@ import ( "github.com/jackc/pgx/v5/pgtype" ) +const countCreatedIssueAssignees = `-- name: CountCreatedIssueAssignees :many +SELECT + assignee_type, + assignee_id, + COUNT(*)::bigint as frequency +FROM issue +WHERE workspace_id = $1 + AND creator_id = $2 + AND creator_type = 'member' + AND assignee_type IS NOT NULL + AND assignee_id IS NOT NULL +GROUP BY assignee_type, assignee_id +` + +type CountCreatedIssueAssigneesParams struct { + WorkspaceID pgtype.UUID `json:"workspace_id"` + CreatorID pgtype.UUID `json:"creator_id"` +} + +type CountCreatedIssueAssigneesRow struct { + AssigneeType pgtype.Text `json:"assignee_type"` + AssigneeID pgtype.UUID `json:"assignee_id"` + Frequency int64 `json:"frequency"` +} + +// Count assignees on issues created by a specific user. +func (q *Queries) CountCreatedIssueAssignees(ctx context.Context, arg CountCreatedIssueAssigneesParams) ([]CountCreatedIssueAssigneesRow, error) { + rows, err := q.db.Query(ctx, countCreatedIssueAssignees, arg.WorkspaceID, arg.CreatorID) + if err != nil { + return nil, err + } + defer rows.Close() + items := []CountCreatedIssueAssigneesRow{} + for rows.Next() { + var i CountCreatedIssueAssigneesRow + if err := rows.Scan(&i.AssigneeType, &i.AssigneeID, &i.Frequency); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + const countIssues = `-- name: CountIssues :one SELECT count(*) FROM issue WHERE workspace_id = $1 diff --git a/server/pkg/db/queries/activity.sql b/server/pkg/db/queries/activity.sql index 0e2dde335a..91a180cc5d 100644 --- a/server/pkg/db/queries/activity.sql +++ b/server/pkg/db/queries/activity.sql @@ -9,3 +9,18 @@ INSERT INTO activity_log ( workspace_id, issue_id, actor_type, actor_id, action, details ) VALUES ($1, $2, $3, $4, $5, $6) RETURNING *; + +-- name: CountAssigneeChangesByActor :many +-- Count how many times a user assigned each target via assignee_changed activities. +SELECT + details->>'to_type' as assignee_type, + details->>'to_id' as assignee_id, + COUNT(*)::bigint as frequency +FROM activity_log +WHERE workspace_id = $1 + AND actor_id = $2 + AND actor_type = 'member' + AND action = 'assignee_changed' + AND details->>'to_type' IS NOT NULL + AND details->>'to_id' IS NOT NULL +GROUP BY details->>'to_type', details->>'to_id'; diff --git a/server/pkg/db/queries/issue.sql b/server/pkg/db/queries/issue.sql index 2c32eb4456..061b0f863d 100644 --- a/server/pkg/db/queries/issue.sql +++ b/server/pkg/db/queries/issue.sql @@ -86,4 +86,18 @@ SELECT * FROM issue WHERE parent_issue_id = $1 ORDER BY position ASC, created_at DESC; +-- name: CountCreatedIssueAssignees :many +-- Count assignees on issues created by a specific user. +SELECT + assignee_type, + assignee_id, + COUNT(*)::bigint as frequency +FROM issue +WHERE workspace_id = $1 + AND creator_id = $2 + AND creator_type = 'member' + AND assignee_type IS NOT NULL + AND assignee_id IS NOT NULL +GROUP BY assignee_type, assignee_id; + -- SearchIssues: moved to handler (dynamic SQL for multi-word search support).