Files
PaperClipAI/ui/src/components/Sidebar.production.tsx
T
DottaandPaperclip e6dcd1e8e9 feat: add Commentary to Audit with filters and shareable messages (#16884)
## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work.
> - Agents can submit suggestions and complaints while they work.
> - Operators currently need database access to read those messages.
> - That makes it hard to find feedback from one agent or share a
specific message.
> - This pull request adds a standard Commentary tab in Audit.
> - Operators can filter messages, browse 50 at a time, and share links
to full message dialogs.

## Linked Issues or Issue Description

Refs #15367, which added feedback storage and agent submission. The
duplicate search found no open PR for a commentary reader.

**Subsystem affected**

Server, shared API contracts, and the board UI.

**Problem or motivation**

Agent suggestions and complaints are stored but have no board reader.
Operators must query the database to inspect feedback.

**Proposed solution**

Add Commentary to Audit without an experimental toggle. Show previews,
agent and message-type filters, text search, and 50-message pages.
Opening a row updates the URL. A copied URL opens the full message with
its agent, task, and run context.

**Alternatives considered**

Direct database queries remain useful for operators, but do not provide
a normal product workflow. An experimental setting would add setup to a
read-only audit page.

**Roadmap alignment**

This completes the operator view for the existing feedback feature. It
supports human oversight and does not change agent execution or task
ownership.

## What Changed

- Add company-scoped, board-only list and detail endpoints with audit
permission and source-task privacy checks.
- Return bounded previews and counts. Fetch full bodies only when a
message opens.
- Add the Commentary tab, search, filters, 50-row pagination, and
URL-based selection. Audit tab destinations work in both layouts, with a
classic sidebar entry for Commentary.
- Add production Storybook examples for populated, filtered, linked,
long, paginated, empty, loading, failed, denied, mobile, and light
states.
- Update API documentation and the feature and agent access maps.

## Verification

- Final head `abc4a251c14cfbfec007542214beec528f2b18bf`: 55 checks pass;
two conditional Storybook checks are skipped. The PR workflow is
successful. Greptile is 5/5 with all findings resolved.
- Focused database, HTTP, submission, OpenAPI, and UI regression tests
cover company isolation, task privacy, filter bounds, 50-row pagination,
and copied message links.
- Browser Storybook journeys cover row opening, direct links, combined
filters, search, and pagination. Storybook uses synthetic API fixtures;
database authorization is tested separately.
- Token gates, feature maps, and Storybook catalog validation pass.
- `pnpm -r typecheck`, `pnpm build`, and `pnpm build-storybook` pass on
the rebased branch. All 80 focused tests and all 37 Audit tests pass,
including the real App route table in both layouts, task
grant/revocation through HTTP, deleted-task privacy, and URL UI
regressions. The full suite passes in the parallel PR CI matrix. The
local serial full-suite run was stopped before completion after checking
the recorded 94-minute serial duration. Local focused tests, workspace
typecheck, production build, and final server/UI checks pass.
- The linked-message Storybook test initially asserted against the
loading dialog. It now waits for the message body; both direct-link and
row-open journeys pass.
- Native and legacy deleted-task privacy regressions failed before the
fix and pass afterward. The reader checks the run task binding after the
task foreign key clears.
- A rapid search-then-agent-filter browser regression reproduced the
search overwriting the selected agent. The pending search now restarts
when URL filters change. The same browser test passes with both filters
applied.
- CI found two existing AuditHub assertions that still expected five
tabs. They now assert the six-tab model and selected-company Commentary
rendering. All 37 Audit tests pass. All other test and build jobs passed
before this test-only correction.

- The [agent-chat attachment browser
test](https://github.com/paperclipai/paperclip/actions/runs/39307881171/job/118250189664)
timed out waiting for an attachment preview. The [same shard passed on
the prior
head](https://github.com/paperclipai/paperclip/actions/runs/39306658703/job/118245498343)
with identical application code; the intervening change only updates
AuditHub unit tests. The single retry passed on the same head. The
precise cause of the earlier failure remains unconfirmed.

## Risks

- Commentary may contain sensitive task context. The reader requires
board access and audit permission, and filters source-task visibility
before returning previews, counts, or full bodies.
- Text search scans stored message bodies. Pages are bounded and full
bodies are fetched on demand.
- Offset pagination can shift if new messages arrive while paging.
Ordering uses timestamp and ID for stable ties.
- No database migration or agent submission behavior changes are
required.

## Model Used

- OpenAI Codex, GPT-6 family, with reasoning, tool use, browser
interaction, and code execution. The exact backend model ID and
context-window size are not exposed in this session.

## 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: Paperclip <noreply@paperclip.ing>
2026-10-19 14:21:13 -05:00

252 lines
11 KiB
TypeScript

import {
Inbox,
ListChecks,
CircleDot,
Target,
LayoutDashboard,
DollarSign,
History,
Search,
SquarePen,
Network,
Boxes,
Repeat,
Layers,
GitBranch,
Package,
Settings,
FolderOpen,
Unplug,
MessagesSquare,
GanttChartSquare,
LayoutGrid,
} from "lucide-react";
import { useState } from "react";
import { useQuery } from "@tanstack/react-query";
import { SidebarSection } from "./SidebarSection";
import { SidebarNavItem } from "./SidebarNavItem.production";
import { SidebarAgents } from "./SidebarAgents.production";
import { SidebarProjects } from "./SidebarProjects";
import { SidebarStarredProjects } from "./SidebarStarredProjects.production";
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 { 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.production";
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 [companyOpen, setCompanyOpen] = useState(true);
const { selectedCompanyId, selectedCompany } = useCompany();
const { collapsed, peeking } = useSidebar();
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 showWorkspacesLink = experimentalSettings?.enableIsolatedWorkspaces === true;
const showApps = experimentalSettings?.enableApps === 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;
// Streamlined left navigation (top-level Projects link + starred children) is
// now the standard product sidebar (PAP-12472). The former experimental
// opt-out was retired; classic per-project collapsible mode is no longer
// user-selectable. Kept as a constant so the classic branch below stays as a
// documented reference until it is fully removed. Routes are unaffected.
const streamlined = 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="w-full h-full min-h-0 border-r border-border bg-background flex flex-col">
{/* 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 items-center gap-1 px-3 h-12 shrink-0">
<SidebarCompanyMenu />
</div>
<nav className="flex-1 min-h-0 overflow-y-auto scrollbar-auto-hide flex flex-col gap-4 pointer-coarse:gap-3 px-3 py-2">
<div className="flex flex-col gap-0.5">
{/* New Task button aligned with nav items */}
{(() => {
const newTaskButton = (
<button
onClick={() => openNewIssue()}
data-slot="icon-button"
aria-label={rail ? "New Task" : undefined}
className="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 hover:bg-accent/50 hover:text-foreground transition-colors"
>
<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={CircleDot} />
{showCases ? (
<SidebarNavItem to="/cases" label="Cases" icon={Layers} textBadge="beta" />
) : null}
<SidebarNavItem to="/routines" label="Routines" icon={Repeat} />
{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}
<SidebarNavItem to="/artifacts" label="Artifacts" icon={Package} />
<SidebarNavItem to="/skills" label="Skills" icon={Boxes} />
{showWorkspacesLink ? (
<SidebarNavItem to="/workspaces" label="Workspaces" icon={GitBranch} />
) : null}
{streamlined ? (
<>
<SidebarNavItem to="/projects" label="Projects" icon={FolderOpen} />
<SidebarStarredProjects />
</>
) : null}
<PluginSlotOutlet
errorBehavior="hidden"
slotTypes={["sidebar"]}
context={pluginContext}
className="flex flex-col gap-0.5"
itemClassName="text-(length:--text-compact) font-medium"
missingBehavior="placeholder"
/>
<PluginLauncherOutlet
errorBehavior="hidden"
placementZones={["sidebar"]}
context={pluginContext}
className="flex flex-col gap-0.5"
itemClassName="text-(length:--text-compact) font-medium"
/>
</SidebarSection>
{/* Classic mode restores the per-project collapsible below Work. */}
{streamlined ? null : <SidebarProjects />}
<SidebarAgents streamlined={streamlined} />
<SidebarSection label="Company" collapsible={{ open: companyOpen, onOpenChange: setCompanyOpen }}>
<SidebarNavItem to="/org" label="Org" icon={Network} />
{showApps ? <SidebarNavItem to="/apps" label="Connectors" icon={Unplug} /> : null}
<SidebarNavItem to="/timeline" label="Timeline" icon={GanttChartSquare} />
<SidebarNavItem to="/costs" label="Costs" icon={DollarSign} />
{/* One entry — /audit merged into the rich Activity feed (PAP-16302). */}
<SidebarNavItem to="/activity" label="Activity" icon={History} />
<SidebarNavItem to="/activity/commentary" label="Commentary" icon={History} />
<SidebarNavItem to="/company/settings" label="Settings" icon={Settings} />
</SidebarSection>
<PluginSlotOutlet
errorBehavior="hidden"
slotTypes={["sidebarPanel"]}
context={pluginContext}
className="flex flex-col gap-3"
itemClassName="rounded-lg border border-border p-3"
missingBehavior="placeholder"
/>
</nav>
</aside>
);
}