mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-21 16:27:18 +02:00
## 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>
278 lines
12 KiB
TypeScript
278 lines
12 KiB
TypeScript
import {
|
|
Inbox,
|
|
ListChecks,
|
|
CircleCheck,
|
|
Target,
|
|
LayoutDashboard,
|
|
DollarSign,
|
|
History,
|
|
Search,
|
|
SquarePen,
|
|
Network,
|
|
Boxes,
|
|
Repeat,
|
|
Layers,
|
|
GitBranch,
|
|
Package,
|
|
Settings,
|
|
FolderOpen,
|
|
Unplug,
|
|
MessagesSquare,
|
|
GanttChartSquare,
|
|
LayoutGrid,
|
|
Users,
|
|
} from "lucide-react";
|
|
import { useState } from "react";
|
|
import { useQuery } from "@tanstack/react-query";
|
|
import { SidebarSection } from "./SidebarSection";
|
|
import { SidebarNavItem } from "./SidebarNavItem";
|
|
import { SidebarAgents } from "./SidebarAgents";
|
|
import { SidebarProjects } from "./SidebarProjects";
|
|
import { SidebarStarredProjects } from "./SidebarStarredProjects";
|
|
import { SidebarRecentTasks } from "./SidebarRecentTasks";
|
|
import { useDialogActions } from "../context/DialogContext";
|
|
import { useCompany } from "../context/CompanyContext";
|
|
import { useSidebar } from "../context/SidebarContext";
|
|
import { attentionApi } from "../api/attention";
|
|
import { heartbeatsApi } from "../api/heartbeats";
|
|
import { instanceSettingsApi } from "../api/instanceSettings";
|
|
import { queryKeys } from "../lib/queryKeys";
|
|
import { attentionBadgeCount } from "../lib/attention";
|
|
import { useInboxBadge } from "../hooks/useInboxBadge";
|
|
import { useStreamlinedUiEnabled } from "../hooks/useStreamlinedUiEnabled";
|
|
import { usePublishSharedQueryData, useSharedPollingQuery } from "../hooks/useSharedPolling";
|
|
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
|
import { cn, SIDEBAR_RAIL_HIDDEN_LABEL } from "../lib/utils";
|
|
import { PluginSlotOutlet } from "@/plugins/slots";
|
|
import { PluginLauncherOutlet } from "@/plugins/launchers";
|
|
import { SidebarCompanyMenu } from "./SidebarCompanyMenu";
|
|
import { primarySidebarStyles } from "./primary-sidebar-styles";
|
|
|
|
export function Sidebar() {
|
|
const { openNewIssue } = useDialogActions();
|
|
// Every labeled section is collapsible (session-scoped, default open) —
|
|
// one policy across static nav groups and the data-driven sections.
|
|
const [workOpen, setWorkOpen] = useState(true);
|
|
const [organizationOpen, setOrganizationOpen] = useState(true);
|
|
const { selectedCompanyId, selectedCompany } = useCompany();
|
|
const { collapsed, peeking } = useSidebar();
|
|
const { enabled: streamlinedUiEnabled } = useStreamlinedUiEnabled();
|
|
const rail = collapsed && !peeking;
|
|
const inboxBadge = useInboxBadge(selectedCompanyId);
|
|
const { data: experimentalSettings } = useQuery({
|
|
queryKey: queryKeys.instance.experimentalSettings,
|
|
queryFn: () => instanceSettingsApi.getExperimental(),
|
|
});
|
|
const liveRunsQueryKey = queryKeys.liveRuns(selectedCompanyId!);
|
|
const sharedLiveRuns = useSharedPollingQuery({
|
|
companyId: selectedCompanyId,
|
|
resourceKey: "live-runs",
|
|
queryKey: liveRunsQueryKey,
|
|
enabled: !!selectedCompanyId,
|
|
// Event-sourced via LiveUpdatesProvider (GitHub issue 9627) + reconnect reconcile — no
|
|
// interval poll needed. Polling here also re-armed React Query's timer on
|
|
// every live-event cache write, a major source of steady-state churn.
|
|
refetchInterval: false,
|
|
leaderOnly: true,
|
|
});
|
|
const { data: liveRuns, dataUpdatedAt: liveRunsUpdatedAt } = useQuery({
|
|
queryKey: liveRunsQueryKey,
|
|
queryFn: () => heartbeatsApi.liveRunsForCompany(selectedCompanyId!),
|
|
enabled: sharedLiveRuns.enabled,
|
|
refetchInterval: sharedLiveRuns.refetchInterval,
|
|
});
|
|
usePublishSharedQueryData(sharedLiveRuns, liveRuns, liveRunsUpdatedAt);
|
|
const liveRunCount = liveRuns?.length ?? 0;
|
|
const liveIssueIds = new Set(
|
|
(liveRuns ?? []).flatMap((run) => run.issueId ? [run.issueId] : []),
|
|
);
|
|
const showWorkspacesLink = experimentalSettings?.enableIsolatedWorkspaces === true;
|
|
const showPipelines = experimentalSettings?.enablePipelines === true;
|
|
const showStatusCards = experimentalSettings?.enableStatusCards === true;
|
|
const goalsLinkPending = experimentalSettings === undefined;
|
|
const showGoalsLink = experimentalSettings?.enableGoalsSidebarLink === true;
|
|
// Decisions (attention home) is an experimental surface (PAP-13481): the nav
|
|
// item is hidden entirely until the flag is enabled (same no-flash pattern as
|
|
// showWorkspacesLink — it defaults hidden, so no placeholder is needed).
|
|
const showDecisions = experimentalSettings?.enableDecisions === true;
|
|
const { data: attentionFeed } = useQuery({
|
|
queryKey: queryKeys.attention(selectedCompanyId!),
|
|
queryFn: () => attentionApi.list(selectedCompanyId!),
|
|
enabled: !!selectedCompanyId && showDecisions,
|
|
refetchInterval: 60_000,
|
|
});
|
|
const attentionCount = attentionBadgeCount(attentionFeed);
|
|
const showCases = experimentalSettings?.enableCases === true;
|
|
// Conference Room Chat flag (PAP-136/PAP-137): the Conference Room nav item
|
|
// is a new surface, hidden entirely while the flag is off (same no-flash
|
|
// pattern as showWorkspacesLink above).
|
|
const conferenceRoomChatEnabled = experimentalSettings?.enableConferenceRoomChat === true;
|
|
|
|
const pluginContext = {
|
|
companyId: selectedCompanyId,
|
|
companyPrefix: selectedCompany?.issuePrefix ?? null,
|
|
};
|
|
|
|
return (
|
|
<aside
|
|
className={cn(
|
|
"w-full h-full min-h-0 flex flex-col",
|
|
streamlinedUiEnabled
|
|
? primarySidebarStyles.surface
|
|
: "border-r border-border bg-background",
|
|
)}
|
|
>
|
|
{/* Top bar: company name, aligned with top sections and borderless.
|
|
Search deliberately does NOT live here:
|
|
the header's spare width goes to the workspace/organization name,
|
|
which is the user's orientation anchor and truncates otherwise.
|
|
Search is the first nav item below instead. */}
|
|
<div className="flex h-(--sz-60px) shrink-0 items-center gap-1 px-3">
|
|
<SidebarCompanyMenu />
|
|
</div>
|
|
|
|
<nav className={primarySidebarStyles.nav}>
|
|
<div className={primarySidebarStyles.group}>
|
|
{/* New Task button aligned with nav items */}
|
|
{(() => {
|
|
const newTaskButton = (
|
|
<button
|
|
onClick={() => openNewIssue()}
|
|
data-slot="icon-button"
|
|
aria-label={rail ? "New Task" : undefined}
|
|
className={cn(
|
|
"flex items-center gap-2.5 mx-2 rounded-lg px-2 py-1.5 pointer-coarse:py-1 text-(length:--text-compact) font-medium text-foreground/80 transition-colors hover:bg-sidebar-accent hover:text-sidebar-accent-foreground",
|
|
)}
|
|
>
|
|
<SquarePen className="h-4 w-4 shrink-0" />
|
|
<span className={rail ? SIDEBAR_RAIL_HIDDEN_LABEL : "truncate"}>New Task</span>
|
|
</button>
|
|
);
|
|
return rail ? (
|
|
<Tooltip>
|
|
<TooltipTrigger asChild>{newTaskButton}</TooltipTrigger>
|
|
<TooltipContent side="right">New Task</TooltipContent>
|
|
</Tooltip>
|
|
) : (
|
|
newTaskButton
|
|
);
|
|
})()}
|
|
{/* Search moved out of the header so the workspace name keeps the
|
|
width; a nav row also keeps search reachable from the
|
|
collapsed rail, where the old header icon was dropped entirely.
|
|
Cmd/Ctrl+K remains the keyboard path (command palette). */}
|
|
<SidebarNavItem to="/search" label="Search" icon={Search} />
|
|
<SidebarNavItem to="/dashboard" label="Dashboard" icon={LayoutDashboard} liveCount={liveRunCount} />
|
|
<SidebarNavItem
|
|
to="/inbox"
|
|
label="Inbox"
|
|
icon={Inbox}
|
|
badge={inboxBadge.inbox}
|
|
badgeLabel="unread"
|
|
badgeTone={inboxBadge.failedRuns > 0 ? "danger" : "default"}
|
|
alert={inboxBadge.failedRuns > 0}
|
|
/>
|
|
{showDecisions ? (
|
|
<SidebarNavItem
|
|
to="/decisions"
|
|
label="Decisions"
|
|
icon={ListChecks}
|
|
badge={attentionCount}
|
|
badgeLabel="decisions"
|
|
/>
|
|
) : null}
|
|
{showStatusCards ? (
|
|
<SidebarNavItem to="/status" label="Status" icon={LayoutGrid} textBadge="beta" />
|
|
) : null}
|
|
{conferenceRoomChatEnabled ? (
|
|
<SidebarNavItem to="/board-chat" label="Conference Room" icon={MessagesSquare} />
|
|
) : null}
|
|
</div>
|
|
|
|
<SidebarSection label="Work" collapsible={{ open: workOpen, onOpenChange: setWorkOpen }}>
|
|
<SidebarNavItem to="/issues" label="Tasks" icon={CircleCheck} />
|
|
{streamlinedUiEnabled ? (
|
|
<>
|
|
<SidebarNavItem to="/projects" label="Projects" icon={FolderOpen} />
|
|
<SidebarStarredProjects />
|
|
</>
|
|
) : null}
|
|
<SidebarNavItem to="/routines" label="Routines" icon={Repeat} />
|
|
<SidebarNavItem to="/artifacts" label="Artifacts" icon={Package} />
|
|
{showCases ? (
|
|
<SidebarNavItem to="/cases" label="Cases" icon={Layers} textBadge="beta" />
|
|
) : null}
|
|
{showPipelines ? (
|
|
<SidebarNavItem to="/pipelines" label="Pipelines" icon={GitBranch} />
|
|
) : null}
|
|
{showGoalsLink ? (
|
|
<SidebarNavItem to="/goals" label="Goals" icon={Target} />
|
|
) : goalsLinkPending ? (
|
|
<div
|
|
data-testid="sidebar-goals-placeholder"
|
|
className="h-8 pointer-coarse:h-7"
|
|
aria-hidden="true"
|
|
/>
|
|
) : null}
|
|
{showWorkspacesLink ? (
|
|
<SidebarNavItem to="/workspaces" label="Workspaces" icon={GitBranch} />
|
|
) : null}
|
|
<PluginSlotOutlet
|
|
slotTypes={["sidebar"]}
|
|
context={pluginContext}
|
|
className="flex flex-col gap-0.5"
|
|
itemClassName="text-(length:--text-compact) font-medium"
|
|
missingBehavior="placeholder"
|
|
/>
|
|
<PluginLauncherOutlet
|
|
placementZones={["sidebar"]}
|
|
context={pluginContext}
|
|
className="flex flex-col gap-0.5"
|
|
itemClassName="text-(length:--text-compact) font-medium"
|
|
/>
|
|
</SidebarSection>
|
|
|
|
{streamlinedUiEnabled ? (
|
|
<SidebarSection
|
|
label="Org"
|
|
collapsible={{ open: organizationOpen, onOpenChange: setOrganizationOpen }}
|
|
>
|
|
<SidebarNavItem to="/agents" label="Agents" icon={Users} />
|
|
<SidebarNavItem to="/skills" label="Skills" icon={Boxes} />
|
|
<SidebarNavItem to="/apps" label="Connectors" icon={Unplug} />
|
|
<SidebarNavItem to="/activity" label="Audit" icon={History} />
|
|
</SidebarSection>
|
|
) : null}
|
|
|
|
{streamlinedUiEnabled ? (
|
|
<SidebarRecentTasks companyId={selectedCompanyId} liveIssueIds={liveIssueIds} />
|
|
) : (
|
|
<>
|
|
<SidebarProjects />
|
|
<SidebarAgents />
|
|
<SidebarSection
|
|
label="Organization"
|
|
collapsible={{ open: organizationOpen, onOpenChange: setOrganizationOpen }}
|
|
>
|
|
<SidebarNavItem to="/org" label="Org" icon={Network} />
|
|
<SidebarNavItem to="/apps" label="Connectors" icon={Unplug} />
|
|
<SidebarNavItem to="/timeline" label="Timeline" icon={GanttChartSquare} />
|
|
<SidebarNavItem to="/costs" label="Costs" icon={DollarSign} />
|
|
<SidebarNavItem to="/activity" label="Activity" icon={History} />
|
|
<SidebarNavItem to="/company/settings" label="Settings" icon={Settings} />
|
|
</SidebarSection>
|
|
</>
|
|
)}
|
|
|
|
<PluginSlotOutlet
|
|
slotTypes={["sidebarPanel"]}
|
|
context={pluginContext}
|
|
className="flex flex-col gap-3"
|
|
itemClassName="rounded-lg border border-border p-3"
|
|
missingBehavior="placeholder"
|
|
/>
|
|
</nav>
|
|
</aside>
|
|
);
|
|
}
|