diff --git a/ui/src/components/IssueFiltersPopover.tsx b/ui/src/components/IssueFiltersPopover.tsx index 321d9b1d227..2702ea0c7e5 100644 --- a/ui/src/components/IssueFiltersPopover.tsx +++ b/ui/src/components/IssueFiltersPopover.tsx @@ -1,7 +1,10 @@ +import { useMemo, useState } from "react"; import { Button } from "@/components/ui/button"; +import { Badge } from "@/components/ui/badge"; import { Checkbox } from "@/components/ui/checkbox"; +import { Input } from "@/components/ui/input"; import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"; -import { Filter, X, User, HardDrive } from "lucide-react"; +import { Bot, Filter, HardDrive, Search, User, X } from "lucide-react"; import { PriorityIcon } from "./PriorityIcon"; import { StatusIcon } from "./StatusIcon"; import { @@ -14,6 +17,7 @@ import { toggleIssueFilterValue, type IssueFilterState, } from "../lib/issue-filters"; +import { formatAssigneeUserLabel } from "../lib/assignees"; type AgentOption = { id: string; @@ -36,6 +40,13 @@ type WorkspaceOption = { name: string; }; +type CreatorOption = { + id: string; + label: string; + kind: "agent" | "user"; + searchText?: string; +}; + export function IssueFiltersPopover({ state, onChange, @@ -48,6 +59,7 @@ export function IssueFiltersPopover({ buttonVariant = "ghost", iconOnly = false, workspaces, + creators, }: { state: IssueFilterState; onChange: (patch: Partial) => void; @@ -60,7 +72,39 @@ export function IssueFiltersPopover({ buttonVariant?: "ghost" | "outline"; iconOnly?: boolean; workspaces?: WorkspaceOption[]; + creators?: CreatorOption[]; }) { + const [creatorSearch, setCreatorSearch] = useState(""); + const creatorOptions = creators ?? []; + const creatorOptionById = useMemo( + () => new Map(creatorOptions.map((option) => [option.id, option])), + [creatorOptions], + ); + const normalizedCreatorSearch = creatorSearch.trim().toLowerCase(); + const visibleCreatorOptions = useMemo(() => { + if (!normalizedCreatorSearch) return creatorOptions; + return creatorOptions.filter((option) => + `${option.label} ${option.searchText ?? ""}`.toLowerCase().includes(normalizedCreatorSearch), + ); + }, [creatorOptions, normalizedCreatorSearch]); + const selectedCreatorOptions = useMemo( + () => state.creators.map((creatorId) => { + const knownOption = creatorOptionById.get(creatorId); + if (knownOption) return knownOption; + if (creatorId.startsWith("agent:")) { + const agentId = creatorId.slice("agent:".length); + return { id: creatorId, label: agentId.slice(0, 8), kind: "agent" as const }; + } + const userId = creatorId.startsWith("user:") ? creatorId.slice("user:".length) : creatorId; + return { + id: creatorId, + label: formatAssigneeUserLabel(userId, currentUserId) ?? userId.slice(0, 5), + kind: "user" as const, + }; + }), + [creatorOptionById, currentUserId, state.creators], + ); + return ( @@ -191,6 +235,60 @@ export function IssueFiltersPopover({ + {creatorOptions.length > 0 ? ( +
+ Creator + {selectedCreatorOptions.length > 0 ? ( +
+ {selectedCreatorOptions.map((creator) => ( + + {creator.kind === "agent" ? : } + {creator.label} + + + ))} +
+ ) : null} +
+ + setCreatorSearch(event.target.value)} + placeholder="Search creators..." + className="h-8 pl-7 text-xs" + /> +
+
+ {visibleCreatorOptions.length > 0 ? visibleCreatorOptions.map((creator) => { + const selected = state.creators.includes(creator.id); + return ( + + ); + }) : ( +
No creators match.
+ )} +
+
+ ) : null} + {projects && projects.length > 0 ? (
Project diff --git a/ui/src/components/IssuesList.tsx b/ui/src/components/IssuesList.tsx index 1f8ebcdcba7..45d3f188a37 100644 --- a/ui/src/components/IssuesList.tsx +++ b/ui/src/components/IssuesList.tsx @@ -19,6 +19,7 @@ import { defaultIssueFilterState, issueFilterLabel, issuePriorityOrder, + normalizeIssueFilterState, resolveIssueFilterWorkspaceId, issueStatusOrder, type IssueFilterState, @@ -86,7 +87,10 @@ const defaultViewState: IssueViewState = { function getViewState(key: string): IssueViewState { try { const raw = localStorage.getItem(key); - if (raw) return { ...defaultViewState, ...JSON.parse(raw) }; + if (raw) { + const parsed = JSON.parse(raw); + return { ...defaultViewState, ...parsed, ...normalizeIssueFilterState(parsed) }; + } } catch { /* ignore */ } return { ...defaultViewState }; } @@ -161,6 +165,13 @@ interface Agent { name: string; } +type CreatorOption = { + id: string; + label: string; + kind: "agent" | "user"; + searchText?: string; +}; + type ProjectOption = Pick & Partial>; type IssueListRequestFilters = NonNullable[1]>; @@ -432,6 +443,66 @@ export function IssuesList({ .map(([id, name]) => ({ id, name })); }, [workspaceNameMap]); + const creatorOptions = useMemo(() => { + const options = new Map(); + const knownAgentIds = new Set(); + + if (currentUserId) { + options.set(`user:${currentUserId}`, { + id: `user:${currentUserId}`, + label: currentUserId === "local-board" ? "Board" : "Me", + kind: "user", + searchText: currentUserId === "local-board" ? "board me human local-board" : `me board human ${currentUserId}`, + }); + } + + for (const issue of issues) { + if (issue.createdByUserId) { + const id = `user:${issue.createdByUserId}`; + if (!options.has(id)) { + options.set(id, { + id, + label: formatAssigneeUserLabel(issue.createdByUserId, currentUserId) ?? issue.createdByUserId.slice(0, 5), + kind: "user", + searchText: `${issue.createdByUserId} board user human`, + }); + } + } + } + + for (const agent of agents ?? []) { + knownAgentIds.add(agent.id); + const id = `agent:${agent.id}`; + if (!options.has(id)) { + options.set(id, { + id, + label: agent.name, + kind: "agent", + searchText: `${agent.name} ${agent.id} agent`, + }); + } + } + + for (const issue of issues) { + if (issue.createdByAgentId && !knownAgentIds.has(issue.createdByAgentId)) { + const id = `agent:${issue.createdByAgentId}`; + if (!options.has(id)) { + options.set(id, { + id, + label: issue.createdByAgentId.slice(0, 8), + kind: "agent", + searchText: `${issue.createdByAgentId} agent`, + }); + } + } + } + + return [...options.values()].sort((a, b) => { + if (a.kind !== b.kind) return a.kind === "user" ? -1 : 1; + return a.label.localeCompare(b.label); + }); + }, [agents, currentUserId, issues]); + const visibleIssueColumnSet = useMemo(() => new Set(visibleIssueColumns), [visibleIssueColumns]); const availableIssueColumns = useMemo( () => getAvailableInboxIssueColumns(isolatedWorkspacesEnabled), @@ -671,6 +742,7 @@ export function IssuesList({ onChange={updateView} activeFilterCount={activeFilterCount} agents={agents} + creators={creatorOptions} projects={projects?.map((project) => ({ id: project.id, name: project.name }))} labels={labels?.map((label) => ({ id: label.id, name: label.name, color: label.color }))} currentUserId={currentUserId} diff --git a/ui/src/lib/inbox.test.ts b/ui/src/lib/inbox.test.ts index 3c1008b3b7d..834c262c890 100644 --- a/ui/src/lib/inbox.test.ts +++ b/ui/src/lib/inbox.test.ts @@ -716,6 +716,7 @@ describe("inbox helpers", () => { statuses: ["in_progress"], priorities: [], assignees: [], + creators: [], labels: [], projects: [], workspaces: [], @@ -734,6 +735,7 @@ describe("inbox helpers", () => { statuses: [], priorities: [], assignees: [], + creators: [], labels: [], projects: [], workspaces: [], @@ -752,6 +754,7 @@ describe("inbox helpers", () => { statuses: [], priorities: [], assignees: [], + creators: [], labels: [], projects: [], workspaces: [], @@ -816,6 +819,7 @@ describe("inbox helpers", () => { statuses: ["todo"], priorities: ["high"], assignees: ["agent-1"], + creators: ["user:user-1"], labels: ["label-1"], projects: ["project-1"], workspaces: ["workspace-1"], @@ -829,6 +833,7 @@ describe("inbox helpers", () => { statuses: ["done"], priorities: [], assignees: [], + creators: [], labels: [], projects: [], workspaces: [], @@ -843,6 +848,7 @@ describe("inbox helpers", () => { statuses: ["todo"], priorities: ["high"], assignees: ["agent-1"], + creators: ["user:user-1"], labels: ["label-1"], projects: ["project-1"], workspaces: ["workspace-1"], @@ -856,6 +862,7 @@ describe("inbox helpers", () => { statuses: ["done"], priorities: [], assignees: [], + creators: [], labels: [], projects: [], workspaces: [], @@ -872,6 +879,7 @@ describe("inbox helpers", () => { statuses: ["todo", 123], priorities: "high", assignees: ["agent-1"], + creators: ["user:user-1", 42], labels: null, projects: ["project-1"], workspaces: ["workspace-1", false], @@ -886,6 +894,7 @@ describe("inbox helpers", () => { statuses: ["todo"], priorities: [], assignees: ["agent-1"], + creators: ["user:user-1"], labels: [], projects: ["project-1"], workspaces: ["workspace-1"], diff --git a/ui/src/lib/inbox.ts b/ui/src/lib/inbox.ts index 43f116451de..6b66f66f2d2 100644 --- a/ui/src/lib/inbox.ts +++ b/ui/src/lib/inbox.ts @@ -9,6 +9,7 @@ import type { import { applyIssueFilters, defaultIssueFilterState, + normalizeIssueFilterState, type IssueFilterState, } from "./issue-filters"; @@ -137,25 +138,6 @@ const defaultInboxFilterPreferences: InboxFilterPreferences = { issueFilters: defaultIssueFilterState, }; -function normalizeStringArray(value: unknown): string[] { - if (!Array.isArray(value)) return []; - return value.filter((entry): entry is string => typeof entry === "string"); -} - -function normalizeIssueFilterState(value: unknown): IssueFilterState { - if (!value || typeof value !== "object") return { ...defaultIssueFilterState }; - const candidate = value as Partial>; - return { - statuses: normalizeStringArray(candidate.statuses), - priorities: normalizeStringArray(candidate.priorities), - assignees: normalizeStringArray(candidate.assignees), - labels: normalizeStringArray(candidate.labels), - projects: normalizeStringArray(candidate.projects), - workspaces: normalizeStringArray(candidate.workspaces), - hideRoutineExecutions: candidate.hideRoutineExecutions === true, - }; -} - function normalizeInboxCategoryFilter(value: unknown): InboxCategoryFilter { return value === "issues_i_touched" || value === "join_requests" @@ -244,7 +226,7 @@ export function loadCollapsedInboxGroupKeys( const raw = localStorage.getItem(storageKey); if (!raw) return new Set(); const parsed = JSON.parse(raw); - return new Set(normalizeStringArray(parsed)); + return new Set(Array.isArray(parsed) ? parsed.filter((entry): entry is string => typeof entry === "string") : []); } catch { return new Set(); } diff --git a/ui/src/lib/issue-filters.test.ts b/ui/src/lib/issue-filters.test.ts new file mode 100644 index 00000000000..523a2222d6e --- /dev/null +++ b/ui/src/lib/issue-filters.test.ts @@ -0,0 +1,69 @@ +// @vitest-environment node + +import { describe, expect, it } from "vitest"; +import type { Issue } from "@paperclipai/shared"; +import { applyIssueFilters, countActiveIssueFilters, defaultIssueFilterState } from "./issue-filters"; + +function makeIssue(overrides: Partial = {}): Issue { + return { + id: overrides.id ?? "issue-1", + companyId: "company-1", + projectId: null, + projectWorkspaceId: null, + goalId: null, + parentId: null, + title: "Issue", + description: null, + status: "todo", + priority: "medium", + assigneeAgentId: null, + assigneeUserId: null, + checkoutRunId: null, + executionRunId: null, + executionAgentNameKey: null, + executionLockedAt: null, + createdByAgentId: null, + createdByUserId: null, + issueNumber: 1, + identifier: "PAP-1", + requestDepth: 0, + billingCode: null, + assigneeAdapterOverrides: null, + executionWorkspaceId: null, + executionWorkspacePreference: null, + executionWorkspaceSettings: null, + startedAt: null, + completedAt: null, + cancelledAt: null, + hiddenAt: null, + labels: [], + labelIds: [], + createdAt: new Date("2026-04-15T00:00:00.000Z"), + updatedAt: new Date("2026-04-15T00:00:00.000Z"), + ...overrides, + }; +} + +describe("issue filters", () => { + it("filters issues by creator across agents and users", () => { + const issues = [ + makeIssue({ id: "agent-match", createdByAgentId: "agent-1" }), + makeIssue({ id: "user-match", createdByUserId: "user-1" }), + makeIssue({ id: "excluded", createdByAgentId: "agent-2", createdByUserId: "user-2" }), + ]; + + const filtered = applyIssueFilters(issues, { + ...defaultIssueFilterState, + creators: ["agent:agent-1", "user:user-1"], + }); + + expect(filtered.map((issue) => issue.id)).toEqual(["agent-match", "user-match"]); + }); + + it("counts creator filters as an active filter group", () => { + expect(countActiveIssueFilters({ + ...defaultIssueFilterState, + creators: ["user:user-1"], + })).toBe(1); + }); +}); diff --git a/ui/src/lib/issue-filters.ts b/ui/src/lib/issue-filters.ts index c9ec4a5bade..4a64b839f3f 100644 --- a/ui/src/lib/issue-filters.ts +++ b/ui/src/lib/issue-filters.ts @@ -4,6 +4,7 @@ export type IssueFilterState = { statuses: string[]; priorities: string[]; assignees: string[]; + creators: string[]; labels: string[]; projects: string[]; workspaces: string[]; @@ -14,6 +15,7 @@ export const defaultIssueFilterState: IssueFilterState = { statuses: [], priorities: [], assignees: [], + creators: [], labels: [], projects: [], workspaces: [], @@ -41,6 +43,26 @@ export function issueFilterArraysEqual(a: string[], b: string[]): boolean { return sortedA.every((value, index) => value === sortedB[index]); } +function normalizeIssueFilterValueArray(value: unknown): string[] { + if (!Array.isArray(value)) return []; + return value.filter((entry): entry is string => typeof entry === "string"); +} + +export function normalizeIssueFilterState(value: unknown): IssueFilterState { + if (!value || typeof value !== "object") return { ...defaultIssueFilterState }; + const candidate = value as Partial>; + return { + statuses: normalizeIssueFilterValueArray(candidate.statuses), + priorities: normalizeIssueFilterValueArray(candidate.priorities), + assignees: normalizeIssueFilterValueArray(candidate.assignees), + creators: normalizeIssueFilterValueArray(candidate.creators), + labels: normalizeIssueFilterValueArray(candidate.labels), + projects: normalizeIssueFilterValueArray(candidate.projects), + workspaces: normalizeIssueFilterValueArray(candidate.workspaces), + hideRoutineExecutions: candidate.hideRoutineExecutions === true, + }; +} + export function toggleIssueFilterValue(values: string[], value: string): string[] { return values.includes(value) ? values.filter((existing) => existing !== value) : [...values, value]; } @@ -73,6 +95,15 @@ export function applyIssueFilters( return false; }); } + if (state.creators.length > 0) { + result = result.filter((issue) => { + for (const creator of state.creators) { + if (creator.startsWith("agent:") && issue.createdByAgentId === creator.slice("agent:".length)) return true; + if (creator.startsWith("user:") && issue.createdByUserId === creator.slice("user:".length)) return true; + } + return false; + }); + } if (state.labels.length > 0) { result = result.filter((issue) => (issue.labelIds ?? []).some((id) => state.labels.includes(id))); } @@ -96,6 +127,7 @@ export function countActiveIssueFilters( if (state.statuses.length > 0) count += 1; if (state.priorities.length > 0) count += 1; if (state.assignees.length > 0) count += 1; + if (state.creators.length > 0) count += 1; if (state.labels.length > 0) count += 1; if (state.projects.length > 0) count += 1; if (state.workspaces.length > 0) count += 1; diff --git a/ui/src/pages/Inbox.tsx b/ui/src/pages/Inbox.tsx index 6cf23a8b7da..5134bd9b1a2 100644 --- a/ui/src/pages/Inbox.tsx +++ b/ui/src/pages/Inbox.tsx @@ -23,6 +23,7 @@ import { countActiveIssueFilters, type IssueFilterState, } from "../lib/issue-filters"; +import { formatAssigneeUserLabel } from "../lib/assignees"; import { armIssueDetailInboxQuickArchive, createIssueDetailLocationState, @@ -149,6 +150,12 @@ type SectionKey = /** A flat navigation entry for keyboard j/k traversal that includes expanded children. */ type NavEntry = InboxKeyboardNavEntry; +type CreatorOption = { + id: string; + label: string; + kind: "agent" | "user"; + searchText?: string; +}; function firstNonEmptyLine(value: string | null | undefined): string | null { if (!value) return null; @@ -796,6 +803,66 @@ export function Inbox() { () => visibleTouchedIssues.filter((issue) => issue.isUnreadForMe), [visibleTouchedIssues], ); + const creatorOptions = useMemo(() => { + const options = new Map(); + const sourceIssues = [...mineIssues, ...touchedIssues]; + + if (currentUserId) { + options.set(`user:${currentUserId}`, { + id: `user:${currentUserId}`, + label: currentUserId === "local-board" ? "Board" : "Me", + kind: "user", + searchText: currentUserId === "local-board" ? "board me human local-board" : `me board human ${currentUserId}`, + }); + } + + for (const issue of sourceIssues) { + if (issue.createdByUserId) { + const id = `user:${issue.createdByUserId}`; + if (!options.has(id)) { + options.set(id, { + id, + label: formatAssigneeUserLabel(issue.createdByUserId, currentUserId) ?? issue.createdByUserId.slice(0, 5), + kind: "user", + searchText: `${issue.createdByUserId} board user human`, + }); + } + } + } + + const knownAgentIds = new Set(); + for (const agent of agents ?? []) { + knownAgentIds.add(agent.id); + const id = `agent:${agent.id}`; + if (!options.has(id)) { + options.set(id, { + id, + label: agent.name, + kind: "agent", + searchText: `${agent.name} ${agent.id} agent`, + }); + } + } + + for (const issue of sourceIssues) { + if (issue.createdByAgentId && !knownAgentIds.has(issue.createdByAgentId)) { + const id = `agent:${issue.createdByAgentId}`; + if (!options.has(id)) { + options.set(id, { + id, + label: issue.createdByAgentId.slice(0, 8), + kind: "agent", + searchText: `${issue.createdByAgentId} agent`, + }); + } + } + } + + return [...options.values()].sort((a, b) => { + if (a.kind !== b.kind) return a.kind === "user" ? -1 : 1; + return a.label.localeCompare(b.label); + }); + }, [agents, currentUserId, mineIssues, touchedIssues]); const issuesToRender = useMemo( () => { if (tab === "mine") return visibleMineIssues; @@ -1814,6 +1881,7 @@ export function Inbox() { onChange={updateIssueFilters} activeFilterCount={activeIssueFilterCount} agents={agents} + creators={creatorOptions} projects={projects?.map((project) => ({ id: project.id, name: project.name }))} labels={labels?.map((label) => ({ id: label.id, name: label.name, color: label.color }))} currentUserId={currentUserId}