From 32ab4f8e47f55a983f056d6d438ea134ac22237a Mon Sep 17 00:00:00 2001 From: Dotta Date: Thu, 12 Mar 2026 16:12:38 -0500 Subject: [PATCH] Add me and unassigned assignee options Co-Authored-By: Paperclip --- ui/src/components/IssueProperties.tsx | 29 +++--- ui/src/components/IssuesList.tsx | 123 ++++++++++++++++++++------ ui/src/context/DialogContext.tsx | 1 + ui/src/lib/assignees.test.ts | 53 +++++++++++ ui/src/lib/assignees.ts | 51 +++++++++++ ui/src/pages/IssueDetail.tsx | 3 +- 6 files changed, 219 insertions(+), 41 deletions(-) create mode 100644 ui/src/lib/assignees.test.ts create mode 100644 ui/src/lib/assignees.ts diff --git a/ui/src/components/IssueProperties.tsx b/ui/src/components/IssueProperties.tsx index ca8e1bd4..cf4b6a43 100644 --- a/ui/src/components/IssueProperties.tsx +++ b/ui/src/components/IssueProperties.tsx @@ -10,6 +10,7 @@ import { useCompany } from "../context/CompanyContext"; import { queryKeys } from "../lib/queryKeys"; import { useProjectOrder } from "../hooks/useProjectOrder"; import { getRecentAssigneeIds, sortAgentsByRecency, trackRecentAssignee } from "../lib/recent-assignees"; +import { formatAssigneeUserLabel } from "../lib/assignees"; import { StatusIcon } from "./StatusIcon"; import { PriorityIcon } from "./PriorityIcon"; import { Identity } from "./Identity"; @@ -206,14 +207,7 @@ export function IssueProperties({ issue, onUpdate, inline }: IssuePropertiesProp const assignee = issue.assigneeAgentId ? agents?.find((a) => a.id === issue.assigneeAgentId) : null; - const userLabel = (userId: string | null | undefined) => - userId - ? userId === "local-board" - ? "Board" - : currentUserId && userId === currentUserId - ? "Me" - : userId.slice(0, 5) - : null; + const userLabel = (userId: string | null | undefined) => formatAssigneeUserLabel(userId, currentUserId); const assigneeUserLabel = userLabel(issue.assigneeUserId); const creatorUserLabel = userLabel(issue.createdByUserId); @@ -349,7 +343,22 @@ export function IssueProperties({ issue, onUpdate, inline }: IssuePropertiesProp > No assignee - {issue.createdByUserId && ( + {currentUserId && ( + + )} + {issue.createdByUserId && issue.createdByUserId !== currentUserId && ( )} {sortedAgents diff --git a/ui/src/components/IssuesList.tsx b/ui/src/components/IssuesList.tsx index 6899bd5c..442f8ae4 100644 --- a/ui/src/components/IssuesList.tsx +++ b/ui/src/components/IssuesList.tsx @@ -3,7 +3,9 @@ import { useQuery } from "@tanstack/react-query"; import { useDialog } from "../context/DialogContext"; import { useCompany } from "../context/CompanyContext"; import { issuesApi } from "../api/issues"; +import { authApi } from "../api/auth"; import { queryKeys } from "../lib/queryKeys"; +import { formatAssigneeUserLabel } from "../lib/assignees"; import { groupBy } from "../lib/groupBy"; import { formatDate, cn } from "../lib/utils"; import { timeAgo } from "../lib/timeAgo"; @@ -87,11 +89,20 @@ function toggleInArray(arr: string[], value: string): string[] { return arr.includes(value) ? arr.filter((v) => v !== value) : [...arr, value]; } -function applyFilters(issues: Issue[], state: IssueViewState): Issue[] { +function applyFilters(issues: Issue[], state: IssueViewState, currentUserId?: string | null): Issue[] { let result = issues; if (state.statuses.length > 0) result = result.filter((i) => state.statuses.includes(i.status)); if (state.priorities.length > 0) result = result.filter((i) => state.priorities.includes(i.priority)); - if (state.assignees.length > 0) result = result.filter((i) => i.assigneeAgentId != null && state.assignees.includes(i.assigneeAgentId)); + if (state.assignees.length > 0) { + result = result.filter((issue) => { + for (const assignee of state.assignees) { + if (assignee === "__unassigned" && !issue.assigneeAgentId && !issue.assigneeUserId) return true; + if (assignee === "__me" && currentUserId && issue.assigneeUserId === currentUserId) return true; + if (issue.assigneeAgentId === assignee) return true; + } + return false; + }); + } if (state.labels.length > 0) result = result.filter((i) => (i.labelIds ?? []).some((id) => state.labels.includes(id))); return result; } @@ -165,6 +176,11 @@ export function IssuesList({ }: IssuesListProps) { const { selectedCompanyId } = useCompany(); const { openNewIssue } = useDialog(); + const { data: session } = useQuery({ + queryKey: queryKeys.auth.session, + queryFn: () => authApi.getSession(), + }); + const currentUserId = session?.user?.id ?? session?.session?.userId ?? null; // Scope the storage key per company so folding/view state is independent across companies. const scopedKey = selectedCompanyId ? `${viewStateKey}:${selectedCompanyId}` : viewStateKey; @@ -224,9 +240,9 @@ export function IssuesList({ const filtered = useMemo(() => { const sourceIssues = normalizedIssueSearch.length > 0 ? searchedIssues : issues; - const filteredByControls = applyFilters(sourceIssues, viewState); + const filteredByControls = applyFilters(sourceIssues, viewState, currentUserId); return sortIssues(filteredByControls, viewState); - }, [issues, searchedIssues, viewState, normalizedIssueSearch]); + }, [issues, searchedIssues, viewState, normalizedIssueSearch, currentUserId]); const { data: labels } = useQuery({ queryKey: queryKeys.issues.labels(selectedCompanyId!), @@ -253,13 +269,21 @@ export function IssuesList({ .map((p) => ({ key: p, label: statusLabel(p), items: groups[p]! })); } // assignee - const groups = groupBy(filtered, (i) => i.assigneeAgentId ?? "__unassigned"); + const groups = groupBy( + filtered, + (issue) => issue.assigneeAgentId ?? (issue.assigneeUserId ? `__user:${issue.assigneeUserId}` : "__unassigned"), + ); return Object.keys(groups).map((key) => ({ key, - label: key === "__unassigned" ? "Unassigned" : (agentName(key) ?? key.slice(0, 8)), + label: + key === "__unassigned" + ? "Unassigned" + : key.startsWith("__user:") + ? (formatAssigneeUserLabel(key.slice("__user:".length), currentUserId) ?? "User") + : (agentName(key) ?? key.slice(0, 8)), items: groups[key]!, })); - }, [filtered, viewState.groupBy, agents]); // eslint-disable-line react-hooks/exhaustive-deps + }, [filtered, viewState.groupBy, agents, agentName, currentUserId]); const newIssueDefaults = (groupKey?: string) => { const defaults: Record = {}; @@ -267,13 +291,16 @@ export function IssuesList({ if (groupKey) { if (viewState.groupBy === "status") defaults.status = groupKey; else if (viewState.groupBy === "priority") defaults.priority = groupKey; - else if (viewState.groupBy === "assignee" && groupKey !== "__unassigned") defaults.assigneeAgentId = groupKey; + else if (viewState.groupBy === "assignee" && groupKey !== "__unassigned") { + if (groupKey.startsWith("__user:")) defaults.assigneeUserId = groupKey.slice("__user:".length); + else defaults.assigneeAgentId = groupKey; + } } return defaults; }; - const assignIssue = (issueId: string, assigneeAgentId: string | null) => { - onUpdateIssue(issueId, { assigneeAgentId, assigneeUserId: null }); + const assignIssue = (issueId: string, assigneeAgentId: string | null, assigneeUserId: string | null = null) => { + onUpdateIssue(issueId, { assigneeAgentId, assigneeUserId }); setAssigneePickerIssueId(null); setAssigneeSearch(""); }; @@ -419,22 +446,37 @@ export function IssuesList({ {/* Assignee */} - {agents && agents.length > 0 && ( -
- Assignee -
- {agents.map((agent) => ( - - ))} -
+
+ Assignee +
+ + {currentUserId && ( + + )} + {(agents ?? []).map((agent) => ( + + ))}
- )} +
{labels && labels.length > 0 && (
@@ -683,6 +725,13 @@ export function IssuesList({ > {issue.assigneeAgentId && agentName(issue.assigneeAgentId) ? ( + ) : issue.assigneeUserId ? ( + + + + + {formatAssigneeUserLabel(issue.assigneeUserId, currentUserId) ?? "User"} + ) : ( @@ -701,7 +750,7 @@ export function IssuesList({ > setAssigneeSearch(e.target.value)} autoFocus @@ -710,16 +759,32 @@ export function IssuesList({ + {currentUserId && ( + + )} {(agents ?? []) .filter((agent) => { if (!assigneeSearch.trim()) return true; @@ -737,7 +802,7 @@ export function IssuesList({ onClick={(e) => { e.preventDefault(); e.stopPropagation(); - assignIssue(issue.id, agent.id); + assignIssue(issue.id, agent.id, null); }} > diff --git a/ui/src/context/DialogContext.tsx b/ui/src/context/DialogContext.tsx index ef7b12b8..904ceb88 100644 --- a/ui/src/context/DialogContext.tsx +++ b/ui/src/context/DialogContext.tsx @@ -5,6 +5,7 @@ interface NewIssueDefaults { priority?: string; projectId?: string; assigneeAgentId?: string; + assigneeUserId?: string; title?: string; description?: string; } diff --git a/ui/src/lib/assignees.test.ts b/ui/src/lib/assignees.test.ts new file mode 100644 index 00000000..1ce22ef7 --- /dev/null +++ b/ui/src/lib/assignees.test.ts @@ -0,0 +1,53 @@ +import { describe, expect, it } from "vitest"; +import { + assigneeValueFromSelection, + currentUserAssigneeOption, + formatAssigneeUserLabel, + parseAssigneeValue, +} from "./assignees"; + +describe("assignee selection helpers", () => { + it("encodes and parses agent assignees", () => { + const value = assigneeValueFromSelection({ assigneeAgentId: "agent-123" }); + + expect(value).toBe("agent:agent-123"); + expect(parseAssigneeValue(value)).toEqual({ + assigneeAgentId: "agent-123", + assigneeUserId: null, + }); + }); + + it("encodes and parses current-user assignees", () => { + const [option] = currentUserAssigneeOption("local-board"); + + expect(option).toEqual({ + id: "user:local-board", + label: "Me", + searchText: "me board human local-board", + }); + expect(parseAssigneeValue(option.id)).toEqual({ + assigneeAgentId: null, + assigneeUserId: "local-board", + }); + }); + + it("treats an empty selection as no assignee", () => { + expect(parseAssigneeValue("")).toEqual({ + assigneeAgentId: null, + assigneeUserId: null, + }); + }); + + it("keeps backward compatibility for raw agent ids in saved drafts", () => { + expect(parseAssigneeValue("legacy-agent-id")).toEqual({ + assigneeAgentId: "legacy-agent-id", + assigneeUserId: null, + }); + }); + + it("formats current and board user labels consistently", () => { + expect(formatAssigneeUserLabel("user-1", "user-1")).toBe("Me"); + expect(formatAssigneeUserLabel("local-board", "someone-else")).toBe("Board"); + expect(formatAssigneeUserLabel("user-abcdef", "someone-else")).toBe("user-"); + }); +}); diff --git a/ui/src/lib/assignees.ts b/ui/src/lib/assignees.ts new file mode 100644 index 00000000..274bcd40 --- /dev/null +++ b/ui/src/lib/assignees.ts @@ -0,0 +1,51 @@ +export interface AssigneeSelection { + assigneeAgentId: string | null; + assigneeUserId: string | null; +} + +export interface AssigneeOption { + id: string; + label: string; + searchText?: string; +} + +export function assigneeValueFromSelection(selection: Partial): string { + if (selection.assigneeAgentId) return `agent:${selection.assigneeAgentId}`; + if (selection.assigneeUserId) return `user:${selection.assigneeUserId}`; + return ""; +} + +export function parseAssigneeValue(value: string): AssigneeSelection { + if (!value) { + return { assigneeAgentId: null, assigneeUserId: null }; + } + if (value.startsWith("agent:")) { + const assigneeAgentId = value.slice("agent:".length); + return { assigneeAgentId: assigneeAgentId || null, assigneeUserId: null }; + } + if (value.startsWith("user:")) { + const assigneeUserId = value.slice("user:".length); + return { assigneeAgentId: null, assigneeUserId: assigneeUserId || null }; + } + // Backward compatibility for older drafts/defaults that stored a raw agent id. + return { assigneeAgentId: value, assigneeUserId: null }; +} + +export function currentUserAssigneeOption(currentUserId: string | null | undefined): AssigneeOption[] { + if (!currentUserId) return []; + return [{ + id: assigneeValueFromSelection({ assigneeUserId: currentUserId }), + label: "Me", + searchText: currentUserId === "local-board" ? "me board human local-board" : `me human ${currentUserId}`, + }]; +} + +export function formatAssigneeUserLabel( + userId: string | null | undefined, + currentUserId: string | null | undefined, +): string | null { + if (!userId) return null; + if (currentUserId && userId === currentUserId) return "Me"; + if (userId === "local-board") return "Board"; + return userId.slice(0, 5); +} diff --git a/ui/src/pages/IssueDetail.tsx b/ui/src/pages/IssueDetail.tsx index bb152e17..9a43f26a 100644 --- a/ui/src/pages/IssueDetail.tsx +++ b/ui/src/pages/IssueDetail.tsx @@ -304,8 +304,7 @@ export function IssueDetail() { options.push({ id: `agent:${agent.id}`, label: agent.name }); } if (currentUserId) { - const label = currentUserId === "local-board" ? "Board" : "Me (Board)"; - options.push({ id: `user:${currentUserId}`, label }); + options.push({ id: `user:${currentUserId}`, label: "Me" }); } return options; }, [agents, currentUserId]);