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 6365e03731
commit 32ab4f8e47
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 { 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
</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
className={cn(
"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" />
{creatorUserLabel ? `Assign to ${creatorUserLabel === "Me" ? "me" : creatorUserLabel}` : "Assign to requester"}
{creatorUserLabel ? `Assign to ${creatorUserLabel}` : "Assign to requester"}
</button>
)}
{sortedAgents

View File

@@ -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<string, string> = {};
@@ -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({
</div>
{/* Assignee */}
{agents && agents.length > 0 && (
<div className="space-y-1">
<span className="text-xs text-muted-foreground">Assignee</span>
<div className="space-y-0.5 max-h-32 overflow-y-auto">
{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 className="space-y-1">
<span className="text-xs text-muted-foreground">Assignee</span>
<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">
<Checkbox
checked={viewState.assignees.includes("__unassigned")}
onCheckedChange={() => updateView({ assignees: toggleInArray(viewState.assignees, "__unassigned") })}
/>
<span className="text-sm">No assignee</span>
</label>
{currentUserId && (
<label className="flex items-center gap-2 px-2 py-1 rounded-sm hover:bg-accent/50 cursor-pointer">
<Checkbox
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>
{labels && labels.length > 0 && (
<div className="space-y-1">
@@ -683,6 +725,13 @@ export function IssuesList({
>
{issue.assigneeAgentId && agentName(issue.assigneeAgentId) ? (
<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 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
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}
onChange={(e) => setAssigneeSearch(e.target.value)}
autoFocus
@@ -710,16 +759,32 @@ export function IssuesList({
<button
className={cn(
"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) => {
e.preventDefault();
e.stopPropagation();
assignIssue(issue.id, null);
assignIssue(issue.id, null, null);
}}
>
No assignee
</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 ?? [])
.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);
}}
>
<Identity name={agent.name} size="sm" className="min-w-0" />

View File

@@ -5,6 +5,7 @@ interface NewIssueDefaults {
priority?: string;
projectId?: string;
assigneeAgentId?: string;
assigneeUserId?: string;
title?: 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 });
}
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]);