From 3709901db3e3536a061655a0547a6e0362845d2d Mon Sep 17 00:00:00 2001 From: Forgotten Date: Thu, 26 Feb 2026 08:53:03 -0600 Subject: [PATCH] commit --- ui/src/components/InlineEntitySelector.tsx | 182 +++++++ ui/src/components/IssueProperties.tsx | 539 ++++++++++++--------- ui/src/components/NewIssueDialog.tsx | 245 +++++----- ui/src/components/ui/dialog.tsx | 2 +- ui/src/components/ui/scroll-area.tsx | 4 +- ui/src/pages/IssueDetail.tsx | 2 +- 6 files changed, 618 insertions(+), 356 deletions(-) create mode 100644 ui/src/components/InlineEntitySelector.tsx diff --git a/ui/src/components/InlineEntitySelector.tsx b/ui/src/components/InlineEntitySelector.tsx new file mode 100644 index 00000000..1759a4e3 --- /dev/null +++ b/ui/src/components/InlineEntitySelector.tsx @@ -0,0 +1,182 @@ +import { forwardRef, useEffect, useMemo, useRef, useState, type ReactNode } from "react"; +import { Check } from "lucide-react"; +import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"; +import { cn } from "../lib/utils"; + +export interface InlineEntityOption { + id: string; + label: string; + searchText?: string; +} + +interface InlineEntitySelectorProps { + value: string; + options: InlineEntityOption[]; + placeholder: string; + noneLabel: string; + searchPlaceholder: string; + emptyMessage: string; + onChange: (id: string) => void; + onConfirm?: () => void; + className?: string; + renderTriggerValue?: (option: InlineEntityOption | null) => ReactNode; + renderOption?: (option: InlineEntityOption, isSelected: boolean) => ReactNode; +} + +export const InlineEntitySelector = forwardRef( + function InlineEntitySelector( + { + value, + options, + placeholder, + noneLabel, + searchPlaceholder, + emptyMessage, + onChange, + onConfirm, + className, + renderTriggerValue, + renderOption, + }, + ref, + ) { + const [open, setOpen] = useState(false); + const [query, setQuery] = useState(""); + const [highlightedIndex, setHighlightedIndex] = useState(0); + const inputRef = useRef(null); + + const allOptions = useMemo( + () => [{ id: "", label: noneLabel, searchText: noneLabel }, ...options], + [noneLabel, options], + ); + + const filteredOptions = useMemo(() => { + const term = query.trim().toLowerCase(); + if (!term) return allOptions; + return allOptions.filter((option) => { + const haystack = `${option.label} ${option.searchText ?? ""}`.toLowerCase(); + return haystack.includes(term); + }); + }, [allOptions, query]); + + const currentOption = options.find((option) => option.id === value) ?? null; + + useEffect(() => { + if (!open) return; + const selectedIndex = filteredOptions.findIndex((option) => option.id === value); + setHighlightedIndex(selectedIndex >= 0 ? selectedIndex : 0); + }, [filteredOptions, open, value]); + + const commitSelection = (index: number, moveNext: boolean) => { + const option = filteredOptions[index] ?? filteredOptions[0]; + if (option) onChange(option.id); + setOpen(false); + setQuery(""); + if (moveNext && onConfirm) { + requestAnimationFrame(() => { + onConfirm(); + }); + } + }; + + return ( + { + setOpen(next); + if (!next) setQuery(""); + }} + > + + + + { + event.preventDefault(); + inputRef.current?.focus(); + }} + > + { + setQuery(event.target.value); + }} + onKeyDown={(event) => { + if (event.key === "ArrowDown") { + event.preventDefault(); + setHighlightedIndex((current) => + filteredOptions.length === 0 ? 0 : (current + 1) % filteredOptions.length, + ); + return; + } + if (event.key === "ArrowUp") { + event.preventDefault(); + setHighlightedIndex((current) => { + if (filteredOptions.length === 0) return 0; + return current <= 0 ? filteredOptions.length - 1 : current - 1; + }); + return; + } + if (event.key === "Enter") { + event.preventDefault(); + commitSelection(highlightedIndex, true); + return; + } + if (event.key === "Tab" && !event.shiftKey) { + event.preventDefault(); + commitSelection(highlightedIndex, true); + return; + } + if (event.key === "Escape") { + event.preventDefault(); + setOpen(false); + } + }} + /> +
+ {filteredOptions.length === 0 ? ( +

{emptyMessage}

+ ) : ( + filteredOptions.map((option, index) => { + const isSelected = option.id === value; + const isHighlighted = index === highlightedIndex; + return ( + + ); + }) + )} +
+
+
+ ); + }, +); diff --git a/ui/src/components/IssueProperties.tsx b/ui/src/components/IssueProperties.tsx index ecea6994..1074a1be 100644 --- a/ui/src/components/IssueProperties.tsx +++ b/ui/src/components/IssueProperties.tsx @@ -20,6 +20,7 @@ import { AgentIcon } from "./AgentIconPicker"; interface IssuePropertiesProps { issue: Issue; onUpdate: (data: Record) => void; + inline?: boolean; } function PropertyRow({ label, children }: { label: string; children: React.ReactNode }) { @@ -31,7 +32,69 @@ function PropertyRow({ label, children }: { label: string; children: React.React ); } -export function IssueProperties({ issue, onUpdate }: IssuePropertiesProps) { +/** Renders a Popover on desktop, or an inline collapsible section on mobile (inline mode). */ +function PropertyPicker({ + inline, + label, + open, + onOpenChange, + triggerContent, + triggerClassName, + popoverClassName, + popoverAlign = "end", + extra, + children, +}: { + inline?: boolean; + label: string; + open: boolean; + onOpenChange: (open: boolean) => void; + triggerContent: React.ReactNode; + triggerClassName?: string; + popoverClassName?: string; + popoverAlign?: "start" | "center" | "end"; + extra?: React.ReactNode; + children: React.ReactNode; +}) { + const btnCn = cn( + "inline-flex items-center gap-1.5 cursor-pointer hover:bg-accent/50 rounded px-1 -mx-1 py-0.5 transition-colors", + triggerClassName, + ); + + if (inline) { + return ( +
+ + + {extra} + + {open && ( +
+ {children} +
+ )} +
+ ); + } + + return ( + + + + + + + {children} + + + {extra} + + ); +} + +export function IssueProperties({ issue, onUpdate, inline }: IssuePropertiesProps) { const { selectedCompanyId } = useCompany(); const queryClient = useQueryClient(); const companyId = issue.companyId ?? selectedCompanyId; @@ -104,6 +167,217 @@ export function IssueProperties({ issue, onUpdate }: IssuePropertiesProps) { ? agents?.find((a) => a.id === issue.assigneeAgentId) : null; + const labelsTrigger = (issue.labels ?? []).length > 0 ? ( +
+ {(issue.labels ?? []).slice(0, 3).map((label) => ( + + {label.name} + + ))} + {(issue.labels ?? []).length > 3 && ( + +{(issue.labels ?? []).length - 3} + )} +
+ ) : ( + <> + + No labels + + ); + + const labelsContent = ( + <> + setLabelSearch(e.target.value)} + autoFocus={!inline} + /> +
+ {(labels ?? []) + .filter((label) => { + if (!labelSearch.trim()) return true; + return label.name.toLowerCase().includes(labelSearch.toLowerCase()); + }) + .map((label) => { + const selected = (issue.labelIds ?? []).includes(label.id); + return ( +
+ + +
+ ); + })} +
+
+
+ setNewLabelColor(e.target.value)} + /> + setNewLabelName(e.target.value)} + /> +
+ +
+ + ); + + const assigneeTrigger = assignee ? ( + + ) : ( + <> + + Unassigned + + ); + + const assigneeContent = ( + <> + setAssigneeSearch(e.target.value)} + autoFocus={!inline} + /> +
+ + {(agents ?? []) + .filter((a) => a.status !== "terminated") + .filter((a) => { + if (!assigneeSearch.trim()) return true; + const q = assigneeSearch.toLowerCase(); + return a.name.toLowerCase().includes(q); + }) + .map((a) => ( + + ))} +
+ + ); + + const projectTrigger = issue.projectId ? ( + <> + p.id === issue.projectId)?.color ?? "#6366f1" }} + /> + {projectName(issue.projectId)} + + ) : ( + <> + + No project + + ); + + const projectContent = ( + <> + setProjectSearch(e.target.value)} + autoFocus={!inline} + /> +
+ + {(projects ?? []) + .filter((p) => { + if (!projectSearch.trim()) return true; + const q = projectSearch.toLowerCase(); + return p.name.toLowerCase().includes(q); + }) + .map((p) => ( + + ))} +
+ + ); + return (
@@ -123,166 +397,26 @@ export function IssueProperties({ issue, onUpdate }: IssuePropertiesProps) { /> - - { setLabelsOpen(open); if (!open) setLabelSearch(""); }}> - - - - - setLabelSearch(e.target.value)} - autoFocus - /> -
- {(labels ?? []) - .filter((label) => { - if (!labelSearch.trim()) return true; - return label.name.toLowerCase().includes(labelSearch.toLowerCase()); - }) - .map((label) => { - const selected = (issue.labelIds ?? []).includes(label.id); - return ( -
- - -
- ); - })} -
-
-
- setNewLabelColor(e.target.value)} - /> - setNewLabelName(e.target.value)} - /> -
- -
-
-
-
+ { setLabelsOpen(open); if (!open) setLabelSearch(""); }} + triggerContent={labelsTrigger} + triggerClassName="min-w-0 max-w-full" + popoverClassName="w-64" + > + {labelsContent} + - - { setAssigneeOpen(open); if (!open) setAssigneeSearch(""); }}> - - - - - setAssigneeSearch(e.target.value)} - autoFocus - /> -
- - {(agents ?? []) - .filter((a) => a.status !== "terminated") - .filter((a) => { - if (!assigneeSearch.trim()) return true; - const q = assigneeSearch.toLowerCase(); - return a.name.toLowerCase().includes(q); - }) - .map((a) => ( - - ))} -
-
-
- {issue.assigneeAgentId && ( + { setAssigneeOpen(open); if (!open) setAssigneeSearch(""); }} + triggerContent={assigneeTrigger} + popoverClassName="w-52" + extra={issue.assigneeAgentId ? ( - )} -
+ ) : undefined} + > + {assigneeContent} + - - { setProjectOpen(open); if (!open) setProjectSearch(""); }}> - - - - - setProjectSearch(e.target.value)} - autoFocus - /> -
- - {(projects ?? []) - .filter((p) => { - if (!projectSearch.trim()) return true; - const q = projectSearch.toLowerCase(); - return p.name.toLowerCase().includes(q); - }) - .map((p) => ( - - ))} -
-
-
- {issue.projectId && ( + { setProjectOpen(open); if (!open) setProjectSearch(""); }} + triggerContent={projectTrigger} + triggerClassName="min-w-0 max-w-full" + popoverClassName="w-fit min-w-[11rem]" + extra={issue.projectId ? ( - )} -
+ ) : undefined} + > + {projectContent} + {issue.parentId && ( diff --git a/ui/src/components/NewIssueDialog.tsx b/ui/src/components/NewIssueDialog.tsx index f651fcb0..eb140bcd 100644 --- a/ui/src/components/NewIssueDialog.tsx +++ b/ui/src/components/NewIssueDialog.tsx @@ -1,4 +1,4 @@ -import { useState, useEffect, useRef, useCallback, type ChangeEvent } from "react"; +import { useState, useEffect, useRef, useCallback, useMemo, type ChangeEvent } from "react"; import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; import { useDialog } from "../context/DialogContext"; import { useCompany } from "../context/CompanyContext"; @@ -27,8 +27,6 @@ import { ArrowUp, ArrowDown, AlertTriangle, - User, - Hexagon, Tag, Calendar, Paperclip, @@ -37,7 +35,7 @@ import { cn } from "../lib/utils"; import { issueStatusText, issueStatusTextDefault, priorityColor, priorityColorDefault } from "../lib/status-colors"; import { MarkdownEditor, type MarkdownEditorRef } from "./MarkdownEditor"; import { AgentIcon } from "./AgentIconPicker"; -import type { Project, Agent } from "@paperclip/shared"; +import { InlineEntitySelector, type InlineEntityOption } from "./InlineEntitySelector"; const DRAFT_KEY = "paperclip:issue-draft"; const DEBOUNCE_MS = 800; @@ -101,12 +99,11 @@ export function NewIssueDialog() { // Popover states const [statusOpen, setStatusOpen] = useState(false); const [priorityOpen, setPriorityOpen] = useState(false); - const [assigneeOpen, setAssigneeOpen] = useState(false); - const [assigneeSearch, setAssigneeSearch] = useState(""); - const [projectOpen, setProjectOpen] = useState(false); const [moreOpen, setMoreOpen] = useState(false); const descriptionEditorRef = useRef(null); const attachInputRef = useRef(null); + const assigneeSelectorRef = useRef(null); + const projectSelectorRef = useRef(null); const { data: agents } = useQuery({ queryKey: queryKeys.agents.list(selectedCompanyId!), @@ -245,6 +242,26 @@ export function NewIssueDialog() { const currentPriority = priorities.find((p) => p.value === priority); const currentAssignee = (agents ?? []).find((a) => a.id === assigneeId); const currentProject = (projects ?? []).find((p) => p.id === projectId); + const assigneeOptions = useMemo( + () => + (agents ?? []) + .filter((agent) => agent.status !== "terminated") + .map((agent) => ({ + id: agent.id, + label: agent.name, + searchText: `${agent.name} ${agent.role} ${agent.title ?? ""}`, + })), + [agents], + ); + const projectOptions = useMemo( + () => + (projects ?? []).map((project) => ({ + id: project.id, + label: project.name, + searchText: project.description ?? "", + })), + [projects], + ); return ( - setTitle(e.target.value)} + onChange={(e) => { + setTitle(e.target.value); + e.target.style.height = "auto"; + e.target.style.height = `${e.target.scrollHeight}px`; + }} onKeyDown={(e) => { - if (e.key === "Tab" && !e.shiftKey) { + if (e.key === "Enter" && !e.metaKey && !e.ctrlKey) { e.preventDefault(); descriptionEditorRef.current?.focus(); } + if (e.key === "Tab" && !e.shiftKey) { + e.preventDefault(); + assigneeSelectorRef.current?.focus(); + } }} autoFocus />
+
+
+
+ For + { + projectSelectorRef.current?.focus(); + }} + renderTriggerValue={(option) => + option && currentAssignee ? ( + <> + + {option.label} + + ) : ( + Assignee + ) + } + renderOption={(option) => { + if (!option.id) return {option.label}; + const assignee = (agents ?? []).find((agent) => agent.id === option.id); + return ( + <> + + {option.label} + + ); + }} + /> + in + { + descriptionEditorRef.current?.focus(); + }} + renderTriggerValue={(option) => + option && currentProject ? ( + <> + + {option.label} + + ) : ( + Project + ) + } + renderOption={(option) => { + if (!option.id) return {option.label}; + const project = (projects ?? []).find((item) => item.id === option.id); + return ( + <> + + {option.label} + + ); + }} + /> +
+
+
+ {/* Description */} -
+
- {/* Assignee chip */} - { setAssigneeOpen(open); if (!open) setAssigneeSearch(""); }}> - - - - - setAssigneeSearch(e.target.value)} - autoFocus - /> -
- - {(agents ?? []) - .filter((a) => a.status !== "terminated") - .filter((a) => { - if (!assigneeSearch.trim()) return true; - const q = assigneeSearch.toLowerCase(); - return a.name.toLowerCase().includes(q); - }) - .map((a) => ( - - ))} -
-
-
- - {/* Project chip */} - - - - - -
- - {(projects ?? []).map((p) => ( - - ))} -
-
-
- {/* Labels chip (placeholder) */}