Sort assignee picker: recent selections first, then alphabetical
Tracks most recently selected assignees in localStorage and sorts both the issue properties and new issue dialog assignee lists accordingly. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -1,4 +1,4 @@
|
|||||||
import { useState } from "react";
|
import { useMemo, useState } from "react";
|
||||||
import { Link } from "@/lib/router";
|
import { Link } from "@/lib/router";
|
||||||
import type { Issue } from "@paperclipai/shared";
|
import type { Issue } from "@paperclipai/shared";
|
||||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
@@ -9,6 +9,7 @@ import { projectsApi } from "../api/projects";
|
|||||||
import { useCompany } from "../context/CompanyContext";
|
import { useCompany } from "../context/CompanyContext";
|
||||||
import { queryKeys } from "../lib/queryKeys";
|
import { queryKeys } from "../lib/queryKeys";
|
||||||
import { useProjectOrder } from "../hooks/useProjectOrder";
|
import { useProjectOrder } from "../hooks/useProjectOrder";
|
||||||
|
import { getRecentAssigneeIds, sortAgentsByRecency, trackRecentAssignee } from "../lib/recent-assignees";
|
||||||
import { StatusIcon } from "./StatusIcon";
|
import { StatusIcon } from "./StatusIcon";
|
||||||
import { PriorityIcon } from "./PriorityIcon";
|
import { PriorityIcon } from "./PriorityIcon";
|
||||||
import { Identity } from "./Identity";
|
import { Identity } from "./Identity";
|
||||||
@@ -181,6 +182,12 @@ export function IssueProperties({ issue, onUpdate, inline }: IssuePropertiesProp
|
|||||||
return project ? projectUrl(project) : `/projects/${id}`;
|
return project ? projectUrl(project) : `/projects/${id}`;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const recentAssigneeIds = useMemo(() => getRecentAssigneeIds(), [assigneeOpen]);
|
||||||
|
const sortedAgents = useMemo(
|
||||||
|
() => sortAgentsByRecency((agents ?? []).filter((a) => a.status !== "terminated"), recentAssigneeIds),
|
||||||
|
[agents, recentAssigneeIds],
|
||||||
|
);
|
||||||
|
|
||||||
const assignee = issue.assigneeAgentId
|
const assignee = issue.assigneeAgentId
|
||||||
? agents?.find((a) => a.id === issue.assigneeAgentId)
|
? agents?.find((a) => a.id === issue.assigneeAgentId)
|
||||||
: null;
|
: null;
|
||||||
@@ -342,8 +349,7 @@ export function IssueProperties({ issue, onUpdate, inline }: IssuePropertiesProp
|
|||||||
{creatorUserLabel ? `Assign to ${creatorUserLabel === "Me" ? "me" : creatorUserLabel}` : "Assign to requester"}
|
{creatorUserLabel ? `Assign to ${creatorUserLabel === "Me" ? "me" : creatorUserLabel}` : "Assign to requester"}
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
{(agents ?? [])
|
{sortedAgents
|
||||||
.filter((a) => a.status !== "terminated")
|
|
||||||
.filter((a) => {
|
.filter((a) => {
|
||||||
if (!assigneeSearch.trim()) return true;
|
if (!assigneeSearch.trim()) return true;
|
||||||
const q = assigneeSearch.toLowerCase();
|
const q = assigneeSearch.toLowerCase();
|
||||||
@@ -356,7 +362,7 @@ export function IssueProperties({ issue, onUpdate, inline }: IssuePropertiesProp
|
|||||||
"flex items-center gap-2 w-full px-2 py-1.5 text-xs rounded hover:bg-accent/50",
|
"flex items-center gap-2 w-full px-2 py-1.5 text-xs rounded hover:bg-accent/50",
|
||||||
a.id === issue.assigneeAgentId && "bg-accent"
|
a.id === issue.assigneeAgentId && "bg-accent"
|
||||||
)}
|
)}
|
||||||
onClick={() => { onUpdate({ assigneeAgentId: a.id, assigneeUserId: null }); setAssigneeOpen(false); }}
|
onClick={() => { trackRecentAssignee(a.id); onUpdate({ assigneeAgentId: a.id, assigneeUserId: null }); setAssigneeOpen(false); }}
|
||||||
>
|
>
|
||||||
<AgentIcon icon={a.icon} className="shrink-0 h-3 w-3 text-muted-foreground" />
|
<AgentIcon icon={a.icon} className="shrink-0 h-3 w-3 text-muted-foreground" />
|
||||||
{a.name}
|
{a.name}
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import { authApi } from "../api/auth";
|
|||||||
import { assetsApi } from "../api/assets";
|
import { assetsApi } from "../api/assets";
|
||||||
import { queryKeys } from "../lib/queryKeys";
|
import { queryKeys } from "../lib/queryKeys";
|
||||||
import { useProjectOrder } from "../hooks/useProjectOrder";
|
import { useProjectOrder } from "../hooks/useProjectOrder";
|
||||||
|
import { getRecentAssigneeIds, sortAgentsByRecency, trackRecentAssignee } from "../lib/recent-assignees";
|
||||||
import {
|
import {
|
||||||
Dialog,
|
Dialog,
|
||||||
DialogContent,
|
DialogContent,
|
||||||
@@ -472,16 +473,18 @@ export function NewIssueDialog() {
|
|||||||
: assigneeAdapterType === "opencode_local"
|
: assigneeAdapterType === "opencode_local"
|
||||||
? ISSUE_THINKING_EFFORT_OPTIONS.opencode_local
|
? ISSUE_THINKING_EFFORT_OPTIONS.opencode_local
|
||||||
: ISSUE_THINKING_EFFORT_OPTIONS.claude_local;
|
: ISSUE_THINKING_EFFORT_OPTIONS.claude_local;
|
||||||
|
const recentAssigneeIds = useMemo(() => getRecentAssigneeIds(), [newIssueOpen]);
|
||||||
const assigneeOptions = useMemo<InlineEntityOption[]>(
|
const assigneeOptions = useMemo<InlineEntityOption[]>(
|
||||||
() =>
|
() =>
|
||||||
(agents ?? [])
|
sortAgentsByRecency(
|
||||||
.filter((agent) => agent.status !== "terminated")
|
(agents ?? []).filter((agent) => agent.status !== "terminated"),
|
||||||
.map((agent) => ({
|
recentAssigneeIds,
|
||||||
id: agent.id,
|
).map((agent) => ({
|
||||||
label: agent.name,
|
id: agent.id,
|
||||||
searchText: `${agent.name} ${agent.role} ${agent.title ?? ""}`,
|
label: agent.name,
|
||||||
})),
|
searchText: `${agent.name} ${agent.role} ${agent.title ?? ""}`,
|
||||||
[agents],
|
})),
|
||||||
|
[agents, recentAssigneeIds],
|
||||||
);
|
);
|
||||||
const projectOptions = useMemo<InlineEntityOption[]>(
|
const projectOptions = useMemo<InlineEntityOption[]>(
|
||||||
() =>
|
() =>
|
||||||
@@ -637,7 +640,7 @@ export function NewIssueDialog() {
|
|||||||
noneLabel="No assignee"
|
noneLabel="No assignee"
|
||||||
searchPlaceholder="Search assignees..."
|
searchPlaceholder="Search assignees..."
|
||||||
emptyMessage="No assignees found."
|
emptyMessage="No assignees found."
|
||||||
onChange={setAssigneeId}
|
onChange={(id) => { if (id) trackRecentAssignee(id); setAssigneeId(id); }}
|
||||||
onConfirm={() => {
|
onConfirm={() => {
|
||||||
projectSelectorRef.current?.focus();
|
projectSelectorRef.current?.focus();
|
||||||
}}
|
}}
|
||||||
|
|||||||
36
ui/src/lib/recent-assignees.ts
Normal file
36
ui/src/lib/recent-assignees.ts
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
const STORAGE_KEY = "paperclip:recent-assignees";
|
||||||
|
const MAX_RECENT = 10;
|
||||||
|
|
||||||
|
export function getRecentAssigneeIds(): string[] {
|
||||||
|
try {
|
||||||
|
const raw = localStorage.getItem(STORAGE_KEY);
|
||||||
|
if (!raw) return [];
|
||||||
|
const parsed = JSON.parse(raw);
|
||||||
|
return Array.isArray(parsed) ? parsed : [];
|
||||||
|
} catch {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function trackRecentAssignee(agentId: string): void {
|
||||||
|
if (!agentId) return;
|
||||||
|
const recent = getRecentAssigneeIds().filter((id) => id !== agentId);
|
||||||
|
recent.unshift(agentId);
|
||||||
|
if (recent.length > MAX_RECENT) recent.length = MAX_RECENT;
|
||||||
|
localStorage.setItem(STORAGE_KEY, JSON.stringify(recent));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function sortAgentsByRecency<T extends { id: string; name: string }>(
|
||||||
|
agents: T[],
|
||||||
|
recentIds: string[],
|
||||||
|
): T[] {
|
||||||
|
const recentIndex = new Map(recentIds.map((id, i) => [id, i]));
|
||||||
|
return [...agents].sort((a, b) => {
|
||||||
|
const aRecent = recentIndex.get(a.id);
|
||||||
|
const bRecent = recentIndex.get(b.id);
|
||||||
|
if (aRecent !== undefined && bRecent !== undefined) return aRecent - bRecent;
|
||||||
|
if (aRecent !== undefined) return -1;
|
||||||
|
if (bRecent !== undefined) return 1;
|
||||||
|
return a.name.localeCompare(b.name);
|
||||||
|
});
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user