Add me and unassigned assignee options

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
Dotta
2026-03-12 16:12:38 -05:00
parent 575a2fd83f
commit 2246d5f1eb
6 changed files with 219 additions and 41 deletions

View File

@@ -10,6 +10,7 @@ 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 { getRecentAssigneeIds, sortAgentsByRecency, trackRecentAssignee } from "../lib/recent-assignees";
import { formatAssigneeUserLabel } from "../lib/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";
@@ -206,14 +207,7 @@ export function IssueProperties({ issue, onUpdate, inline }: IssuePropertiesProp
const assignee = issue.assigneeAgentId const assignee = issue.assigneeAgentId
? agents?.find((a) => a.id === issue.assigneeAgentId) ? agents?.find((a) => a.id === issue.assigneeAgentId)
: null; : null;
const userLabel = (userId: string | null | undefined) => const userLabel = (userId: string | null | undefined) => formatAssigneeUserLabel(userId, currentUserId);
userId
? userId === "local-board"
? "Board"
: currentUserId && userId === currentUserId
? "Me"
: userId.slice(0, 5)
: null;
const assigneeUserLabel = userLabel(issue.assigneeUserId); const assigneeUserLabel = userLabel(issue.assigneeUserId);
const creatorUserLabel = userLabel(issue.createdByUserId); const creatorUserLabel = userLabel(issue.createdByUserId);
@@ -349,7 +343,22 @@ export function IssueProperties({ issue, onUpdate, inline }: IssuePropertiesProp
> >
No assignee No assignee
</button> </button>
{issue.createdByUserId && ( {currentUserId && (
<button
className={cn(
"flex items-center gap-2 w-full px-2 py-1.5 text-xs rounded hover:bg-accent/50",
issue.assigneeUserId === currentUserId && "bg-accent",
)}
onClick={() => {
onUpdate({ assigneeAgentId: null, assigneeUserId: currentUserId });
setAssigneeOpen(false);
}}
>
<User className="h-3 w-3 shrink-0 text-muted-foreground" />
Assign to me
</button>
)}
{issue.createdByUserId && issue.createdByUserId !== currentUserId && (
<button <button
className={cn( className={cn(
"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",
@@ -361,7 +370,7 @@ export function IssueProperties({ issue, onUpdate, inline }: IssuePropertiesProp
}} }}
> >
<User className="h-3 w-3 shrink-0 text-muted-foreground" /> <User className="h-3 w-3 shrink-0 text-muted-foreground" />
{creatorUserLabel ? `Assign to ${creatorUserLabel === "Me" ? "me" : creatorUserLabel}` : "Assign to requester"} {creatorUserLabel ? `Assign to ${creatorUserLabel}` : "Assign to requester"}
</button> </button>
)} )}
{sortedAgents {sortedAgents

View File

@@ -3,7 +3,9 @@ import { useQuery } from "@tanstack/react-query";
import { useDialog } from "../context/DialogContext"; import { useDialog } from "../context/DialogContext";
import { useCompany } from "../context/CompanyContext"; import { useCompany } from "../context/CompanyContext";
import { issuesApi } from "../api/issues"; import { issuesApi } from "../api/issues";
import { authApi } from "../api/auth";
import { queryKeys } from "../lib/queryKeys"; import { queryKeys } from "../lib/queryKeys";
import { formatAssigneeUserLabel } from "../lib/assignees";
import { groupBy } from "../lib/groupBy"; import { groupBy } from "../lib/groupBy";
import { formatDate, cn } from "../lib/utils"; import { formatDate, cn } from "../lib/utils";
import { timeAgo } from "../lib/timeAgo"; 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]; 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; let result = issues;
if (state.statuses.length > 0) result = result.filter((i) => state.statuses.includes(i.status)); 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.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))); if (state.labels.length > 0) result = result.filter((i) => (i.labelIds ?? []).some((id) => state.labels.includes(id)));
return result; return result;
} }
@@ -165,6 +176,11 @@ export function IssuesList({
}: IssuesListProps) { }: IssuesListProps) {
const { selectedCompanyId } = useCompany(); const { selectedCompanyId } = useCompany();
const { openNewIssue } = useDialog(); 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. // Scope the storage key per company so folding/view state is independent across companies.
const scopedKey = selectedCompanyId ? `${viewStateKey}:${selectedCompanyId}` : viewStateKey; const scopedKey = selectedCompanyId ? `${viewStateKey}:${selectedCompanyId}` : viewStateKey;
@@ -224,9 +240,9 @@ export function IssuesList({
const filtered = useMemo(() => { const filtered = useMemo(() => {
const sourceIssues = normalizedIssueSearch.length > 0 ? searchedIssues : issues; const sourceIssues = normalizedIssueSearch.length > 0 ? searchedIssues : issues;
const filteredByControls = applyFilters(sourceIssues, viewState); const filteredByControls = applyFilters(sourceIssues, viewState, currentUserId);
return sortIssues(filteredByControls, viewState); return sortIssues(filteredByControls, viewState);
}, [issues, searchedIssues, viewState, normalizedIssueSearch]); }, [issues, searchedIssues, viewState, normalizedIssueSearch, currentUserId]);
const { data: labels } = useQuery({ const { data: labels } = useQuery({
queryKey: queryKeys.issues.labels(selectedCompanyId!), queryKey: queryKeys.issues.labels(selectedCompanyId!),
@@ -253,13 +269,21 @@ export function IssuesList({
.map((p) => ({ key: p, label: statusLabel(p), items: groups[p]! })); .map((p) => ({ key: p, label: statusLabel(p), items: groups[p]! }));
} }
// assignee // 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) => ({ return Object.keys(groups).map((key) => ({
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]!, 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 newIssueDefaults = (groupKey?: string) => {
const defaults: Record<string, string> = {}; const defaults: Record<string, string> = {};
@@ -267,13 +291,16 @@ export function IssuesList({
if (groupKey) { if (groupKey) {
if (viewState.groupBy === "status") defaults.status = groupKey; if (viewState.groupBy === "status") defaults.status = groupKey;
else if (viewState.groupBy === "priority") defaults.priority = 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; return defaults;
}; };
const assignIssue = (issueId: string, assigneeAgentId: string | null) => { const assignIssue = (issueId: string, assigneeAgentId: string | null, assigneeUserId: string | null = null) => {
onUpdateIssue(issueId, { assigneeAgentId, assigneeUserId: null }); onUpdateIssue(issueId, { assigneeAgentId, assigneeUserId });
setAssigneePickerIssueId(null); setAssigneePickerIssueId(null);
setAssigneeSearch(""); setAssigneeSearch("");
}; };
@@ -419,22 +446,37 @@ export function IssuesList({
</div> </div>
{/* Assignee */} {/* Assignee */}
{agents && agents.length > 0 && ( <div className="space-y-1">
<div className="space-y-1"> <span className="text-xs text-muted-foreground">Assignee</span>
<span className="text-xs text-muted-foreground">Assignee</span> <div className="space-y-0.5 max-h-32 overflow-y-auto">
<div className="space-y-0.5 max-h-32 overflow-y-auto"> <label className="flex items-center gap-2 px-2 py-1 rounded-sm hover:bg-accent/50 cursor-pointer">
{agents.map((agent) => ( <Checkbox
<label key={agent.id} className="flex items-center gap-2 px-2 py-1 rounded-sm hover:bg-accent/50 cursor-pointer"> checked={viewState.assignees.includes("__unassigned")}
<Checkbox onCheckedChange={() => updateView({ assignees: toggleInArray(viewState.assignees, "__unassigned") })}
checked={viewState.assignees.includes(agent.id)} />
onCheckedChange={() => updateView({ assignees: toggleInArray(viewState.assignees, agent.id) })} <span className="text-sm">No assignee</span>
/> </label>
<span className="text-sm">{agent.name}</span> {currentUserId && (
</label> <label className="flex items-center gap-2 px-2 py-1 rounded-sm hover:bg-accent/50 cursor-pointer">
))} <Checkbox
</div> checked={viewState.assignees.includes("__me")}
onCheckedChange={() => updateView({ assignees: toggleInArray(viewState.assignees, "__me") })}
/>
<User className="h-3.5 w-3.5 text-muted-foreground" />
<span className="text-sm">Me</span>
</label>
)}
{(agents ?? []).map((agent) => (
<label key={agent.id} className="flex items-center gap-2 px-2 py-1 rounded-sm hover:bg-accent/50 cursor-pointer">
<Checkbox
checked={viewState.assignees.includes(agent.id)}
onCheckedChange={() => updateView({ assignees: toggleInArray(viewState.assignees, agent.id) })}
/>
<span className="text-sm">{agent.name}</span>
</label>
))}
</div> </div>
)} </div>
{labels && labels.length > 0 && ( {labels && labels.length > 0 && (
<div className="space-y-1"> <div className="space-y-1">
@@ -683,6 +725,13 @@ export function IssuesList({
> >
{issue.assigneeAgentId && agentName(issue.assigneeAgentId) ? ( {issue.assigneeAgentId && agentName(issue.assigneeAgentId) ? (
<Identity name={agentName(issue.assigneeAgentId)!} size="sm" /> <Identity name={agentName(issue.assigneeAgentId)!} size="sm" />
) : issue.assigneeUserId ? (
<span className="inline-flex items-center gap-1.5 text-xs">
<span className="inline-flex h-5 w-5 items-center justify-center rounded-full border border-dashed border-muted-foreground/35 bg-muted/30">
<User className="h-3 w-3" />
</span>
{formatAssigneeUserLabel(issue.assigneeUserId, currentUserId) ?? "User"}
</span>
) : ( ) : (
<span className="inline-flex items-center gap-1.5 text-xs text-muted-foreground"> <span className="inline-flex items-center gap-1.5 text-xs text-muted-foreground">
<span className="inline-flex h-5 w-5 items-center justify-center rounded-full border border-dashed border-muted-foreground/35 bg-muted/30"> <span className="inline-flex h-5 w-5 items-center justify-center rounded-full border border-dashed border-muted-foreground/35 bg-muted/30">
@@ -701,7 +750,7 @@ export function IssuesList({
> >
<input <input
className="mb-1 w-full border-b border-border bg-transparent px-2 py-1.5 text-xs outline-none placeholder:text-muted-foreground/50" className="mb-1 w-full border-b border-border bg-transparent px-2 py-1.5 text-xs outline-none placeholder:text-muted-foreground/50"
placeholder="Search agents..." placeholder="Search assignees..."
value={assigneeSearch} value={assigneeSearch}
onChange={(e) => setAssigneeSearch(e.target.value)} onChange={(e) => setAssigneeSearch(e.target.value)}
autoFocus autoFocus
@@ -710,16 +759,32 @@ export function IssuesList({
<button <button
className={cn( className={cn(
"flex w-full items-center gap-2 rounded px-2 py-1.5 text-xs hover:bg-accent/50", "flex w-full items-center gap-2 rounded px-2 py-1.5 text-xs hover:bg-accent/50",
!issue.assigneeAgentId && "bg-accent", !issue.assigneeAgentId && !issue.assigneeUserId && "bg-accent",
)} )}
onClick={(e) => { onClick={(e) => {
e.preventDefault(); e.preventDefault();
e.stopPropagation(); e.stopPropagation();
assignIssue(issue.id, null); assignIssue(issue.id, null, null);
}} }}
> >
No assignee No assignee
</button> </button>
{currentUserId && (
<button
className={cn(
"flex w-full items-center gap-2 rounded px-2 py-1.5 text-left text-xs hover:bg-accent/50",
issue.assigneeUserId === currentUserId && "bg-accent",
)}
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
assignIssue(issue.id, null, currentUserId);
}}
>
<User className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
<span>Me</span>
</button>
)}
{(agents ?? []) {(agents ?? [])
.filter((agent) => { .filter((agent) => {
if (!assigneeSearch.trim()) return true; if (!assigneeSearch.trim()) return true;
@@ -737,7 +802,7 @@ export function IssuesList({
onClick={(e) => { onClick={(e) => {
e.preventDefault(); e.preventDefault();
e.stopPropagation(); e.stopPropagation();
assignIssue(issue.id, agent.id); assignIssue(issue.id, agent.id, null);
}} }}
> >
<Identity name={agent.name} size="sm" className="min-w-0" /> <Identity name={agent.name} size="sm" className="min-w-0" />

View File

@@ -5,6 +5,7 @@ interface NewIssueDefaults {
priority?: string; priority?: string;
projectId?: string; projectId?: string;
assigneeAgentId?: string; assigneeAgentId?: string;
assigneeUserId?: string;
title?: string; title?: string;
description?: string; description?: string;
} }

View File

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

51
ui/src/lib/assignees.ts Normal file
View File

@@ -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<AssigneeSelection>): 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);
}

View File

@@ -304,8 +304,7 @@ export function IssueDetail() {
options.push({ id: `agent:${agent.id}`, label: agent.name }); options.push({ id: `agent:${agent.id}`, label: agent.name });
} }
if (currentUserId) { if (currentUserId) {
const label = currentUserId === "local-board" ? "Board" : "Me (Board)"; options.push({ id: `user:${currentUserId}`, label: "Me" });
options.push({ id: `user:${currentUserId}`, label });
} }
return options; return options;
}, [agents, currentUserId]); }, [agents, currentUserId]);