Files
PaperClipAI/ui/src/components/SidebarProjects.tsx
T
5b56d430e9 feat(ui): refine core navigation and task detail (#12854)
## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work.
> - The main navigation and task detail view are core operator surfaces.
> - Several controls used different hover states, popover layouts, and
spacing rules.
> - Recent task actions also needed a compact menu and correct inbox
archive behavior.
> - These differences made the interface feel inconsistent and caused
some content to look crowded or clipped.
> - This pull request aligns these surfaces with the Paperclip design
tokens and current interaction patterns.
> - The benefit is a simpler and more consistent operator experience in
light and dark modes.

## Linked Issues or Issue Description

**What happened?**

Profile and organization popovers used inconsistent layouts. Navigation
controls used different hover and selected backgrounds. Task warnings
and the composer could crowd nearby content. Archiving a recent task
could also remove it from more than the inbox.

**Expected behavior**

Popover menus should use the same compact visual language. Navigation
controls should share readable hover and selected tokens. Task detail
content should keep consistent spacing. Archiving should hide a task
from the inbox while keeping it in the task list.

**Steps to reproduce**

1. Open the main sidebar in light or dark mode.
2. Open the profile and organization menus.
3. Hover navigation items, the organization trigger, the profile
trigger, and the feedback flag.
4. Open a task with a warning banner and a long thread.
5. Use the recent task overflow menu and archive a task.

**Paperclip version or commit**

Reproduced on `master` before this branch.

**Deployment mode**

Local dev (`pnpm dev`).

## What Changed

- Rebuilt the profile and organization popovers with compact token-based
layouts.
- Matched organization popover width and alignment to the profile
popover.
- Unified sidebar hover and selected states in light and dark modes.
- Added a recent task overflow menu with rename, archive, and pause or
restart actions.
- Kept archived tasks in the task list while removing them from the
inbox.
- Improved warning banner and composer spacing in task detail views.
- Added and updated focused UI tests for the changed behavior.

## Verification

- `pnpm check:token-gates` passed.
- `pnpm --filter @paperclipai/ui typecheck` passed.
- The seven affected UI test files passed with 216 tests.
- `pnpm --filter @paperclipai/ui build` passed.
- GitHub CI passed the full build, typecheck and release registry,
general test, serialized server, canary dry-run, and end-to-end
matrices.
- Greptile reviewed commit `6e296be85` at 5/5 with no outstanding
actionable findings.

## Risks

- Risk is limited to sidebar presentation, recent task actions, and task
detail layout.
- The recent task archive action now follows inbox-only archive
semantics.
- No database schema or public API contract changed.

> I checked [`ROADMAP.md`](ROADMAP.md). This pull request does not
duplicate planned core work.

## Model Used

- OpenAI Codex, GPT-5.6. The model used high reasoning, tool use, and
code execution. The context window size was not exposed.

## Checklist

- [x] I have included a thinking path that traces from project context
to this change
- [x] I have specified the model used (with version and capability
details)
- [x] I have checked ROADMAP.md and confirmed this PR does not duplicate
planned core work
- [x] I have searched GitHub for duplicate or related PRs and linked
them above
- [x] I have either (a) linked existing issues with `Fixes: #` / `Closes
#` / `Refs #` OR (b) described the issue in-PR following the relevant
issue template
- [x] I have not referenced internal/instance-local Paperclip issues or
links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip`
URLs)
- [x] My branch name describes the change (e.g. `docs/...`, `fix/...`)
and contains no internal Paperclip ticket id or instance-derived details
- [x] I have run tests locally and they pass
- [x] I have added or updated tests where applicable
- [x] I have updated relevant documentation to reflect my changes
- [x] I have considered and documented any risks above
- [x] All Paperclip CI gates are green
- [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups
- [x] I will address all Greptile and reviewer comments before
requesting merge

---------

Co-authored-by: Scott Tong <scott@scottsmbpm5max.lan>
Co-authored-by: Paperclip <noreply@paperclip.ing>
2026-09-04 16:15:21 -07:00

468 lines
16 KiB
TypeScript

import { useCallback, useEffect, useMemo, useState } from "react";
import { NavLink, useLocation } from "@/lib/router";
import { useQuery } from "@tanstack/react-query";
import { FolderOpen, Loader2, LogOut, MoreHorizontal, Plus } from "lucide-react";
import {
DndContext,
MouseSensor,
closestCenter,
type DragEndEvent,
useSensor,
useSensors,
} from "@dnd-kit/core";
import { SortableContext, arrayMove, useSortable, verticalListSortingStrategy } from "@dnd-kit/sortable";
import { CSS } from "@dnd-kit/utilities";
import { useCompany } from "../context/CompanyContext";
import { useDialogActions } from "../context/DialogContext";
import { useSidebar } from "../context/SidebarContext";
import { authApi } from "../api/auth";
import { projectsApi } from "../api/projects";
import { SIDEBAR_SCROLL_RESET_STATE } from "../lib/navigation-scroll";
import { queryKeys } from "../lib/queryKeys";
import { cn, projectRouteRef, SIDEBAR_RAIL_HIDDEN_LABEL } from "../lib/utils";
import { useProjectOrder } from "../hooks/useProjectOrder";
import { resourceMembershipState, useResourceMembershipMutation, useResourceMemberships } from "../hooks/useResourceMemberships";
import { useProjectExternalObjectSummary } from "../hooks/useIssueExternalObjects";
import { BudgetSidebarMarker } from "./BudgetSidebarMarker";
import { ExternalObjectStatusSummary } from "./ExternalObjectStatusSummary";
import { ProjectTile } from "./ProjectTile";
import { SidebarSection, type SidebarSectionRadioChoice } from "./SidebarSection";
import { Button } from "@/components/ui/button";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
import { PluginSlotMount, usePluginSlots } from "@/plugins/slots";
import {
getProjectSortModeStorageKey,
PROJECT_SORT_MODE_UPDATED_EVENT,
readProjectSortMode,
type ProjectSortModeUpdatedDetail,
type ProjectSidebarSortMode,
writeProjectSortMode,
} from "../lib/project-order";
import type { Project } from "@paperclipai/shared";
type ProjectSidebarSlot = ReturnType<typeof usePluginSlots>["slots"][number];
const PROJECT_SORT_CHOICES: SidebarSectionRadioChoice[] = [
{ value: "top", label: "Top" },
{ value: "alphabetical", label: "Alphabetical" },
{ value: "recent", label: "Recent" },
];
const REORDER_POINTER_MEDIA = "(hover: hover) and (pointer: fine)";
type ProjectItemProps = {
activeProjectRef: string | null;
companyId: string | null;
companyPrefix: string | null;
isMobile: boolean;
project: Project;
projectSidebarSlots: ProjectSidebarSlot[];
rail: boolean;
setSidebarOpen: (open: boolean) => void;
onLeaveProject: (project: Project) => void;
leaving?: boolean;
isDragging?: boolean;
};
function projectTimestamp(project: Project): number {
const updated = new Date(project.updatedAt).getTime();
if (Number.isFinite(updated)) return updated;
const created = new Date(project.createdAt).getTime();
return Number.isFinite(created) ? created : 0;
}
function sortProjects(projects: Project[], sortMode: ProjectSidebarSortMode): Project[] {
if (sortMode === "top") return projects;
const sorted = [...projects];
if (sortMode === "alphabetical") {
sorted.sort((left, right) => left.name.localeCompare(right.name, undefined, { sensitivity: "base" }));
return sorted;
}
sorted.sort((left, right) => {
const timeDiff = projectTimestamp(right) - projectTimestamp(left);
return timeDiff !== 0 ? timeDiff : left.name.localeCompare(right.name, undefined, { sensitivity: "base" });
});
return sorted;
}
function hasFineReorderPointer() {
if (typeof window === "undefined" || typeof window.matchMedia !== "function") return true;
return window.matchMedia(REORDER_POINTER_MEDIA).matches;
}
function useFineReorderPointer() {
const [matches, setMatches] = useState(hasFineReorderPointer);
useEffect(() => {
if (typeof window === "undefined" || typeof window.matchMedia !== "function") return;
const query = window.matchMedia(REORDER_POINTER_MEDIA);
const onChange = (event: MediaQueryListEvent) => setMatches(event.matches);
setMatches(query.matches);
query.addEventListener("change", onChange);
return () => query.removeEventListener("change", onChange);
}, []);
return matches;
}
function ProjectItem({
activeProjectRef,
companyId,
companyPrefix,
isMobile,
project,
projectSidebarSlots,
rail,
setSidebarOpen,
onLeaveProject,
leaving = false,
isDragging = false,
}: ProjectItemProps) {
const routeRef = projectRouteRef(project);
const { summary: externalObjectsSummary } = useProjectExternalObjectSummary(project.id);
const link = (
<NavLink
to={`/projects/${routeRef}/issues`}
state={SIDEBAR_SCROLL_RESET_STATE}
onClick={(e) => {
if (isDragging) {
e.preventDefault();
return;
}
if (isMobile) setSidebarOpen(false);
}}
className={cn(
"flex min-w-0 flex-1 items-center gap-2.5 mx-2 rounded-lg px-2 py-1.5 pr-8 pointer-coarse:py-1 text-(length:--text-compact) font-medium transition-colors",
activeProjectRef === routeRef || activeProjectRef === project.id
? "bg-sidebar-accent text-sidebar-accent-foreground"
: "text-foreground/80 hover:bg-sidebar-accent hover:text-sidebar-accent-foreground",
)}
>
<ProjectTile color={project.color ?? null} icon={project.icon ?? null} size="xs" />
<span className={rail ? SIDEBAR_RAIL_HIDDEN_LABEL : "flex-1 truncate"}>{project.name}</span>
{!rail ? <ExternalObjectStatusSummary summary={externalObjectsSummary} compact /> : null}
{!rail && project.pauseReason === "budget" ? <BudgetSidebarMarker title="Project paused by budget" /> : null}
</NavLink>
);
return (
<div className="flex flex-col gap-0.5">
<div className="group/project relative flex items-center">
{rail ? (
// Anchor the tooltip to a wrapper, not the NavLink: Radix `asChild`
// (Slot) drops React Router's function className, which would strip
// `flex` off the <a> and let the in-flow label stack under the icon,
// growing the row. Keeping the <a> out of Slot preserves a 1:1 row
// height with the expanded state so the icon never moves (PAP-10676).
<Tooltip>
<TooltipTrigger asChild>
<div className="min-w-0 flex-1">{link}</div>
</TooltipTrigger>
<TooltipContent side="right">{project.name}</TooltipContent>
</Tooltip>
) : (
link
)}
{!rail && (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
size="icon-xs"
className={cn(
"absolute right-3 top-1/2 h-6 w-6 -translate-y-1/2 transition-opacity data-[state=open]:pointer-events-auto data-[state=open]:opacity-100",
isMobile
? "opacity-100"
: "pointer-events-none opacity-0 group-hover/project:pointer-events-auto group-hover/project:opacity-100 group-focus-within/project:pointer-events-auto group-focus-within/project:opacity-100",
)}
aria-label={`Open actions for ${project.name}`}
>
<MoreHorizontal className="h-3.5 w-3.5" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-44">
<DropdownMenuItem
onClick={() => {
if (leaving) return;
onLeaveProject(project);
}}
disabled={leaving}
>
{leaving ? <Loader2 className="size-4 motion-safe:animate-spin" /> : <LogOut className="size-4" />}
<span>{leaving ? "Leaving..." : "Leave project"}</span>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
)}
</div>
{!rail && projectSidebarSlots.length > 0 && (
<div className="ml-5 flex flex-col gap-0.5">
{projectSidebarSlots.map((slot) => (
<PluginSlotMount
key={`${project.id}:${slot.pluginKey}:${slot.id}`}
slot={slot}
context={{
companyId,
companyPrefix,
projectId: project.id,
projectRef: routeRef,
entityId: project.id,
entityType: "project",
}}
missingBehavior="placeholder"
/>
))}
</div>
)}
</div>
);
}
function SortableProjectItem(props: ProjectItemProps) {
const {
attributes,
listeners,
setNodeRef,
transform,
transition,
isDragging,
} = useSortable({ id: props.project.id });
return (
<div
ref={setNodeRef}
style={{
transform: CSS.Transform.toString(transform),
transition,
zIndex: isDragging ? 10 : undefined,
}}
className={cn(isDragging && "opacity-80")}
{...attributes}
{...listeners}
>
<ProjectItem {...props} isDragging={isDragging} />
</div>
);
}
export function SidebarProjects() {
const [open, setOpen] = useState(true);
const { selectedCompany, selectedCompanyId } = useCompany();
const { openNewProject } = useDialogActions();
const { isMobile, setSidebarOpen, collapsed, peeking } = useSidebar();
const rail = collapsed && !peeking;
const fineReorderPointer = useFineReorderPointer();
const location = useLocation();
const { data: projects } = useQuery({
queryKey: queryKeys.projects.list(selectedCompanyId!),
queryFn: () => projectsApi.list(selectedCompanyId!),
enabled: !!selectedCompanyId,
});
const membershipsQuery = useResourceMemberships(selectedCompanyId);
const membershipMutation = useResourceMembershipMutation(selectedCompanyId);
const { data: session } = useQuery({
queryKey: queryKeys.auth.session,
queryFn: () => authApi.getSession(),
});
const { slots: projectSidebarSlots } = usePluginSlots({
slotTypes: ["projectSidebarItem"],
entityType: "project",
companyId: selectedCompanyId,
enabled: !!selectedCompanyId,
});
const currentUserId = session?.user?.id ?? session?.session?.userId ?? null;
const sortModeStorageKey = useMemo(() => {
if (!selectedCompanyId) return null;
return getProjectSortModeStorageKey(selectedCompanyId, currentUserId);
}, [currentUserId, selectedCompanyId]);
const [sortMode, setSortMode] = useState<ProjectSidebarSortMode>(() => {
if (!sortModeStorageKey) return "top";
return readProjectSortMode(sortModeStorageKey);
});
const visibleProjects = useMemo(
() => (projects ?? []).filter((project: Project) => {
if (!membershipsQuery.isSuccess) return true;
return resourceMembershipState(membershipsQuery.data, "project", project.id) !== "left";
}),
[membershipsQuery.data, membershipsQuery.isSuccess, projects],
);
const { orderedProjects, persistOrder } = useProjectOrder({
projects: visibleProjects,
companyId: selectedCompanyId,
userId: currentUserId,
});
const sortedProjects = useMemo(
() => sortProjects(orderedProjects, sortMode),
[orderedProjects, sortMode],
);
const isTopMode = sortMode === "top";
const canReorderProjects = isTopMode && !isMobile && fineReorderPointer;
const projectMatch = location.pathname.match(/^\/(?:[^/]+\/)?projects\/([^/]+)/);
const activeProjectRef = projectMatch?.[1] ?? null;
const sensors = useSensors(
// Project reordering is intentionally desktop-only; touch should remain tap/scroll behavior.
useSensor(MouseSensor, {
activationConstraint: { distance: 8 },
}),
);
useEffect(() => {
if (!sortModeStorageKey) {
setSortMode("top");
return;
}
setSortMode(readProjectSortMode(sortModeStorageKey));
}, [sortModeStorageKey]);
useEffect(() => {
if (!sortModeStorageKey) return;
const onStorage = (event: StorageEvent) => {
if (event.key !== sortModeStorageKey) return;
setSortMode(readProjectSortMode(sortModeStorageKey));
};
const onCustomEvent = (event: Event) => {
const detail = (event as CustomEvent<ProjectSortModeUpdatedDetail>).detail;
if (!detail || detail.storageKey !== sortModeStorageKey) return;
setSortMode(detail.sortMode);
};
window.addEventListener("storage", onStorage);
window.addEventListener(PROJECT_SORT_MODE_UPDATED_EVENT, onCustomEvent);
return () => {
window.removeEventListener("storage", onStorage);
window.removeEventListener(PROJECT_SORT_MODE_UPDATED_EVENT, onCustomEvent);
};
}, [sortModeStorageKey]);
const persistSortMode = useCallback(
(value: string) => {
const nextSortMode: ProjectSidebarSortMode =
value === "alphabetical" || value === "recent" ? value : "top";
setSortMode(nextSortMode);
if (sortModeStorageKey) {
writeProjectSortMode(sortModeStorageKey, nextSortMode);
}
},
[sortModeStorageKey],
);
const handleDragEnd = useCallback(
(event: DragEndEvent) => {
if (!isTopMode) return;
const { active, over } = event;
if (!over || active.id === over.id) return;
const ids = orderedProjects.map((project) => project.id);
const oldIndex = ids.indexOf(active.id as string);
const newIndex = ids.indexOf(over.id as string);
if (oldIndex === -1 || newIndex === -1) return;
persistOrder(arrayMove(ids, oldIndex, newIndex));
},
[isTopMode, orderedProjects, persistOrder],
);
const leaveProject = useCallback(
(project: Project) => membershipMutation.mutate({
resourceType: "project",
resourceId: project.id,
resourceName: project.name,
state: "left",
}),
[membershipMutation],
);
const projectLeaving = useCallback(
(project: Project) =>
membershipMutation.isPending &&
membershipMutation.variables?.resourceType === "project" &&
membershipMutation.variables.resourceId === project.id,
[membershipMutation.isPending, membershipMutation.variables],
);
const renderProject = (project: Project) => (
<ProjectItem
key={project.id}
activeProjectRef={activeProjectRef}
companyId={selectedCompanyId}
companyPrefix={selectedCompany?.issuePrefix ?? null}
isMobile={isMobile}
project={project}
projectSidebarSlots={projectSidebarSlots}
rail={rail}
setSidebarOpen={setSidebarOpen}
onLeaveProject={leaveProject}
leaving={projectLeaving(project)}
/>
);
return (
<SidebarSection
label="Projects"
collapsible={{ open, onOpenChange: setOpen }}
headerAction={{
ariaLabel: "New project",
icon: Plus,
onClick: openNewProject,
}}
menu={{
ariaLabel: "Projects section actions",
actions: [
{ type: "item", label: "Browse projects", icon: FolderOpen, href: "/projects" },
{ type: "separator" },
],
radioLabel: "Project sort",
radioChoices: PROJECT_SORT_CHOICES,
radioValue: sortMode,
onRadioValueChange: persistSortMode,
}}
>
{canReorderProjects ? (
<DndContext
sensors={sensors}
collisionDetection={closestCenter}
onDragEnd={handleDragEnd}
>
<SortableContext
items={orderedProjects.map((project) => project.id)}
strategy={verticalListSortingStrategy}
>
<div className="flex flex-col gap-0.5">
{orderedProjects.map((project: Project) => (
<SortableProjectItem
key={project.id}
activeProjectRef={activeProjectRef}
companyId={selectedCompanyId}
companyPrefix={selectedCompany?.issuePrefix ?? null}
isMobile={isMobile}
project={project}
projectSidebarSlots={projectSidebarSlots}
rail={rail}
setSidebarOpen={setSidebarOpen}
onLeaveProject={leaveProject}
leaving={projectLeaving(project)}
/>
))}
</div>
</SortableContext>
</DndContext>
) : (
<div className="flex flex-col gap-0.5">
{sortedProjects.map((project: Project) => renderProject(project))}
</div>
)}
</SidebarSection>
);
}