mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-30 13:20:54 +01:00
Add a personal primary agent per company and user. Initialize it from the first human-created agent, expose profile-only switching with confirmation, and use it after recent choices for task and Chat defaults. Persist authenticated preferences, preserve lifecycle and membership rules, keep selections out of shared audit events, and document the API contract. Include the reviewed Storybook surfaces and regression coverage for concurrent choices, onboarding, cross-device updates, and browser journeys. Co-Authored-By: Paperclip <noreply@paperclip.ing>
315 lines
14 KiB
TypeScript
315 lines
14 KiB
TypeScript
import {
|
|
Inbox,
|
|
ListChecks,
|
|
CircleCheck,
|
|
Target,
|
|
LayoutDashboard,
|
|
DollarSign,
|
|
History,
|
|
Search,
|
|
SquarePen,
|
|
Network,
|
|
Boxes,
|
|
Repeat,
|
|
Layers,
|
|
GitBranch,
|
|
Package,
|
|
Settings,
|
|
FolderOpen,
|
|
Unplug,
|
|
MessagesSquare,
|
|
MessageCircle,
|
|
GanttChartSquare,
|
|
LayoutGrid,
|
|
Users,
|
|
} from "lucide-react";
|
|
import { useState, type ReactNode } 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 { useAgentChatEnabled } from "@/hooks/useAgentChatEnabled";
|
|
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({ children }: { children?: ReactNode }) {
|
|
const { openNewIssue } = useDialogActions();
|
|
const { enabled: agentChatEnabled } = useAgentChatEnabled();
|
|
// 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] : []),
|
|
);
|
|
// PAP-670 splits the nav reorganization across two experimental flags.
|
|
// Agent Chat: Chat leads Work as one row with its own agent rail (the
|
|
// streamlined shell only — the legacy shell keeps per-agent rows), and
|
|
// Workspaces leaves to make room. Combined Inbox + Task List: Inbox becomes
|
|
// views inside Tasks, so its row goes and its badge rides on Tasks.
|
|
const chatRail = agentChatEnabled && streamlinedUiEnabled;
|
|
// The merged Tasks page only exists in the streamlined shell, so the legacy
|
|
// shell keeps its Inbox row even with the flag on.
|
|
const combinedInboxTasks = streamlinedUiEnabled && experimentalSettings?.enableCombinedInboxTasks === true;
|
|
const showWorkspacesLink = !chatRail && 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} />
|
|
{!combinedInboxTasks ? (
|
|
<SidebarNavItem
|
|
to="/inbox"
|
|
label="Inbox"
|
|
icon={Inbox}
|
|
badge={inboxBadge.inbox}
|
|
badgeLabel="unread"
|
|
badgeTone={inboxBadge.failedRuns > 0 ? "danger" : "default"}
|
|
alert={inboxBadge.failedRuns > 0}
|
|
/>
|
|
) : null}
|
|
{agentChatEnabled && !chatRail ? <SidebarNavItem to="/chats" label="Chat" icon={MessageCircle} /> : null}
|
|
{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 }}>
|
|
{/* Agent Chat: Chat leads the Work group as a single row — the
|
|
agents you talk to live in the Chat surface's own secondary rail
|
|
(ChatContextualSidebar), not in the primary nav. */}
|
|
{chatRail ? (
|
|
<SidebarNavItem to="/chats" label="Chat" icon={MessageCircle} />
|
|
) : null}
|
|
{/* Combined Inbox + Task List: Inbox is a view inside Tasks, so the
|
|
unread/failed-run badge rides on Tasks. */}
|
|
{combinedInboxTasks ? (
|
|
<SidebarNavItem
|
|
to="/issues"
|
|
label="Tasks"
|
|
icon={CircleCheck}
|
|
badge={inboxBadge.inbox}
|
|
badgeLabel="unread"
|
|
badgeTone={inboxBadge.failedRuns > 0 ? "danger" : "default"}
|
|
alert={inboxBadge.failedRuns > 0}
|
|
/>
|
|
) : (
|
|
<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}
|
|
|
|
{children ?? (streamlinedUiEnabled ? <SidebarAgents streamlined /> : 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>
|
|
);
|
|
}
|