Compare commits

...

1 Commits

Author SHA1 Message Date
Jiayuan Zhang
c69b71eda1 feat(assign): sort members & agents by user's assignment frequency
The Assign dropdown now sorts members and agents by how frequently the
current user assigns issues to them. Frequency is computed from two
sources: assignee_changed activities in the activity log and initial
assignments on issues created by the user.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-10 18:39:52 +08:00
12 changed files with 247 additions and 9 deletions

View File

@@ -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<AssigneeFrequencyEntry[]> {
return this.fetch("/api/assignee-frequency");
}
async updateComment(commentId: string, content: string): Promise<Comment> {
return this.fetch(`/api/comments/${commentId}`, {
method: "PUT",

View File

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

View File

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

View File

@@ -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(),
});
}

View File

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

View File

@@ -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<string, number>();
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;

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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