mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-30 13:20:54 +01:00
## Thinking Path > - Paperclip helps people manage AI agents and their work. > - People need consistent UI patterns to review and control that work. > - The current board uses related layouts and controls with different spacing, type, and behavior. > - That drift makes it hard for a designer or an agent to build a new page from a reliable reference. > - Storybook can show the shared patterns and the states they need to support. > - This pull request adds design proposals, a local sandbox, and reusable UI controls so people can compare the patterns in context. > - The benefit is a clearer design system that agents can use to build consistent UI. ## Linked Issues or Issue Description **Subsystem affected** `ui/` — React board UI and Storybook. **Problem or motivation** Board pages repeat visual and interaction decisions. Storybook did not provide a complete reference for task views, conversation states, typography, toolbars, tabs, and light/dark colors. People could not compare a proposed pattern with the running board in one place. **Proposed solution** Add a staged Design Proposals catalog with foundations, components, layouts, and page specimens. Add an opt-in sandbox. Reuse production components where possible. Include synthetic conversation states and shared controls so reviewers can compare and refine them. **Alternatives considered** Separate static mockups would not show keyboard behavior, responsive layout, or real component states. Rebuilding each page only in Storybook would allow the reference to drift from the board. **Roadmap alignment** This improves existing board UI and Skills Manager surfaces. It does not add a new control-plane capability. ## What Changed - Added dark and light color references, typography and conversation specimens, and component stories for buttons, tabs, toolbars, sidebar, popovers, activity, and task lists. - Added a local design sandbox with synthetic task conversation playback and representative states. - Shared collection toolbars and navigation patterns across task, agent, project, routine, skill, connector, and artifact pages. - Added the streamlined Skills tabs and a My Skills collection that uses the shared skill cards. - Restored saved-thread access during synthetic playback, kept connector searchbox semantics, and updated reproduction checks after moving trial stories. - Documented the proposed patterns and added focused UI, catalog, and browser checks. ## Verification - `pnpm check:token-gates`, `node scripts/check-maps.mjs`, and `node scripts/storybook-catalog.mjs --check` passed. - Full workspace `pnpm -r typecheck` and `pnpm build` passed after merging master. After the review fixes, focused UI typecheck, `pnpm build-storybook`, 23 Skills Sources tests, and all 15 Storybook typography browser tests passed. - Focused task-thread and system-notice suites passed: 251 tests. Connector Browse passed 84 tests; the connector, attachment, recovery, and Storybook flyout browser journeys passed locally. - A local full Vitest run hit one intermittent chat-channels integration failure; that test passed in isolation. GitHub CI [39347996059](https://github.com/paperclipai/paperclip/actions/runs/39347996059) passed on the exact latest commit: 55 successful checks and two skipped draft-only Storybook jobs. - Greptile reviewed the exact latest commit at 5/5 with no new comments or open review threads. GitHub reports the branch as cleanly mergeable. ## Risks - This PR changes 206 files and exceeds the repository's 100-file review target. Review by commit and consider a scoped split before merge. - Design Proposals are staged references. Fixture interactions do not prove API behavior, permissions, or provider execution. - UI changes to shared controls may affect pages outside the proposal sandbox. Focused browser checks and the full CI matrix passed. ## Model Used - OpenAI Codex, GPT-6 family. The exact runtime model ID and context window are not exposed in this session. The model used reasoning, code execution, browser inspection, and local tests. Earlier branch commits were also AI-assisted in this Codex conversation. ## 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 available in this session) - [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 where applicable - [x] I have either linked existing issues or described the issue in-PR following the relevant issue template - [x] I have not referenced internal or instance-local Paperclip issues or links - [x] My branch name describes the change and contains no internal ticket id - [x] I have run focused 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 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@scotts-mbp-m5-max.local> Co-authored-by: Paperclip <noreply@paperclip.ing>
572 lines
25 KiB
TypeScript
572 lines
25 KiB
TypeScript
import { AgentIdentity } from "../components/AgentIdentity";
|
|
import { TaskSummaryList } from "../components/TaskSummaryList";
|
|
import { useDesignSandbox } from "../context/DesignSandboxContext";
|
|
import { useEffect, useMemo, useRef, useState } from "react";
|
|
import { useLocation } from "@/lib/router";
|
|
import {
|
|
onboardingStepForCompany,
|
|
shouldRouteAgentlessCompanyToOnboarding,
|
|
} from "../lib/onboarding-route";
|
|
import { claimOnboardingOffer } from "../lib/onboarding-auto-open";
|
|
import { Link } from "@/lib/router";
|
|
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
|
import { dashboardApi } from "../api/dashboard";
|
|
import { activityApi } from "../api/activity";
|
|
import { accessApi } from "../api/access";
|
|
import { issuesApi } from "../api/issues";
|
|
import { agentsApi } from "../api/agents";
|
|
import { projectsApi } from "../api/projects";
|
|
import { buildCompanyUserProfileMap } from "../lib/company-members";
|
|
import { useCompany } from "../context/CompanyContext";
|
|
import { useDialogActions } from "../context/DialogContext";
|
|
import { useBreadcrumbs } from "../context/BreadcrumbContext";
|
|
import { queryKeys } from "../lib/queryKeys";
|
|
import { MetricCard } from "../components/MetricCard";
|
|
import { EmptyState } from "../components/EmptyState";
|
|
import { StatusIcon } from "../components/StatusIcon";
|
|
import { usePublishSharedQueryData, useSharedPollingQuery } from "../hooks/useSharedPolling";
|
|
|
|
import { ActivityRow } from "../components/ActivityRow";
|
|
import { ActivityList } from "../components/ActivityList";
|
|
import { timeAgo } from "../lib/timeAgo";
|
|
import { cn, formatCents } from "../lib/utils";
|
|
import { SHOW_TASK_PRIORITY_UI } from "../lib/ui-flags";
|
|
import { Bot, CircleDot, DollarSign, ShieldCheck, LayoutDashboard, PauseCircle } from "lucide-react";
|
|
import { ActiveAgentsPanel } from "../components/ActiveAgentsPanel";
|
|
import { ChartCard, RunActivityChart, PriorityChart, IssueStatusChart, SuccessRateChart } from "../components/ActivityCharts";
|
|
import { PageSkeleton } from "../components/PageSkeleton";
|
|
import { Card } from "@/components/ui/card";
|
|
import { Button } from "@/components/ui/button";
|
|
import { InlineBanner } from "../components/InlineBanner";
|
|
import type { Agent, Issue } from "@paperclipai/shared";
|
|
import { PluginSlotOutlet } from "@/plugins/slots";
|
|
import { SmokeLabDashboardCard } from "../components/SmokeLabDashboardCard";
|
|
|
|
const DASHBOARD_ACTIVITY_LIMIT = 10;
|
|
|
|
function getRecentIssues(issues: Issue[]): Issue[] {
|
|
return [...issues]
|
|
.sort((a, b) => new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime());
|
|
}
|
|
|
|
export type PausedAgentBanner =
|
|
| { kind: "imported"; pausedImportedAgentIds: string[] }
|
|
| { kind: "all-paused" }
|
|
| null;
|
|
|
|
/**
|
|
* Which paused-agents banner the dashboard should show. Import-paused agents
|
|
* get the specific banner with a bulk resume (they were parked by the import
|
|
* safety default and stay parked until someone acts); otherwise a company
|
|
* whose agents are ALL paused gets a generic explanation, because from the
|
|
* outside it is indistinguishable from a broken company.
|
|
*/
|
|
export function derivePausedAgentBanner(agents: Agent[] | undefined): PausedAgentBanner {
|
|
if (!agents || agents.length === 0) return null;
|
|
const importedPaused = agents.filter(
|
|
(agent) => agent.status === "paused" && agent.pauseReason === "import",
|
|
);
|
|
if (importedPaused.length > 0) {
|
|
return { kind: "imported", pausedImportedAgentIds: importedPaused.map((agent) => agent.id) };
|
|
}
|
|
if (agents.every((agent) => agent.status === "paused")) return { kind: "all-paused" };
|
|
return null;
|
|
}
|
|
|
|
export function Dashboard() {
|
|
const designSandbox = useDesignSandbox();
|
|
const { selectedCompanyId, companies } = useCompany();
|
|
const { openOnboarding } = useDialogActions();
|
|
const location = useLocation();
|
|
const { setBreadcrumbs } = useBreadcrumbs();
|
|
const [animatedActivityIds, setAnimatedActivityIds] = useState<Set<string>>(new Set());
|
|
const seenActivityIdsRef = useRef<Set<string>>(new Set());
|
|
const hydratedActivityRef = useRef(false);
|
|
const activityAnimationTimersRef = useRef<number[]>([]);
|
|
|
|
// `isFetching` is read alongside the data: a cached list is served while its
|
|
// refetch runs, and an empty one from before the first hire must not pass
|
|
// for the company's current state — see `shouldRouteAgentlessCompanyToOnboarding`.
|
|
const { data: agents, isFetching: agentsRefreshing } = useQuery({
|
|
queryKey: queryKeys.agents.list(selectedCompanyId!),
|
|
queryFn: () => agentsApi.list(selectedCompanyId!),
|
|
enabled: !!selectedCompanyId,
|
|
});
|
|
|
|
// Bulk resume for agents parked by a company import. Sequential on purpose
|
|
// (mirrors the import page's activation checklist); a per-agent failure is
|
|
// tolerated so one bad agent never blocks the rest, and the refetch below
|
|
// re-renders the banner with whatever remains paused.
|
|
const queryClient = useQueryClient();
|
|
const resumeImportedAgents = useMutation({
|
|
mutationFn: async () => {
|
|
const targets = derivePausedAgentBanner(agents);
|
|
if (!targets || targets.kind !== "imported") return;
|
|
for (const agentId of targets.pausedImportedAgentIds) {
|
|
try {
|
|
await agentsApi.resume(agentId, selectedCompanyId ?? undefined);
|
|
} catch {
|
|
// Leave the agent paused; the banner re-renders with the remainder.
|
|
}
|
|
}
|
|
},
|
|
onSettled: async () => {
|
|
await Promise.all([
|
|
queryClient.invalidateQueries({ queryKey: queryKeys.agents.list(selectedCompanyId!) }),
|
|
queryClient.invalidateQueries({ queryKey: queryKeys.dashboard(selectedCompanyId!) }),
|
|
]);
|
|
},
|
|
});
|
|
|
|
// A company with no agent cannot do anything — no runs, no tasks, nothing
|
|
// to show. The banner below already says so and offers a link; this takes
|
|
// the customer there instead of asking them to notice.
|
|
//
|
|
// It also closes the gap a Cloud-provisioned stack falls into. Cloud creates
|
|
// the company before the tenant boots, so the companyless redirect never
|
|
// fires and a seeded customer lands here, on an empty dashboard, straight
|
|
// out of signup.
|
|
//
|
|
// Opened as the dialog rather than navigated to: the wizard is already
|
|
// mounted globally, so there is no route to race and no redirect to loop.
|
|
// Placed with the other hooks — the early returns below mean anything
|
|
// further down would be called conditionally.
|
|
//
|
|
// The company and the step are both passed. Opening with empty options would
|
|
// start the wizard at the front door with no company, and the new-company
|
|
// path there would create a *second* company instead of giving this one an
|
|
// agent.
|
|
const shouldOpenOnboarding = shouldRouteAgentlessCompanyToOnboarding({
|
|
pathname: location.pathname,
|
|
agentsLoaded: agents !== undefined,
|
|
agentsRefreshing,
|
|
agentCount: agents?.length ?? 0,
|
|
});
|
|
// Auto-open once per company. Every input to the effect sits behind a query,
|
|
// so a refetch re-runs it, and the customer can also navigate away and come
|
|
// back — both would otherwise call `openOnboarding` again and reopen a
|
|
// wizard that was deliberately closed. `claimOnboardingOffer` holds the
|
|
// companies already offered; see it for why that outlives this component.
|
|
useEffect(() => {
|
|
if (!shouldOpenOnboarding || !selectedCompanyId) return;
|
|
if (!claimOnboardingOffer(selectedCompanyId)) return;
|
|
openOnboarding({
|
|
companyId: selectedCompanyId,
|
|
initialStep: onboardingStepForCompany(),
|
|
});
|
|
// No mission lookup to wait on any more: the step this opens is the same
|
|
// whatever the goals say, so waiting only delayed the open.
|
|
}, [shouldOpenOnboarding, selectedCompanyId, openOnboarding]);
|
|
|
|
useEffect(() => {
|
|
setBreadcrumbs([{ label: "Dashboard" }]);
|
|
}, [setBreadcrumbs]);
|
|
|
|
const dashboardQueryKey = queryKeys.dashboard(selectedCompanyId!);
|
|
const sharedDashboard = useSharedPollingQuery({
|
|
companyId: selectedCompanyId,
|
|
resourceKey: "dashboard",
|
|
queryKey: dashboardQueryKey,
|
|
enabled: !!selectedCompanyId,
|
|
});
|
|
const { data, isLoading, error, dataUpdatedAt: dashboardUpdatedAt } = useQuery({
|
|
queryKey: dashboardQueryKey,
|
|
queryFn: () => dashboardApi.summary(selectedCompanyId!),
|
|
enabled: !!selectedCompanyId,
|
|
});
|
|
usePublishSharedQueryData(sharedDashboard, data, dashboardUpdatedAt);
|
|
|
|
const activityQueryKey = [...queryKeys.activity(selectedCompanyId!), { limit: DASHBOARD_ACTIVITY_LIMIT }] as const;
|
|
const sharedActivity = useSharedPollingQuery({
|
|
companyId: selectedCompanyId,
|
|
resourceKey: `activity:limit:${DASHBOARD_ACTIVITY_LIMIT}`,
|
|
queryKey: activityQueryKey,
|
|
enabled: !!selectedCompanyId,
|
|
});
|
|
const { data: activity, dataUpdatedAt: activityUpdatedAt } = useQuery({
|
|
queryKey: activityQueryKey,
|
|
queryFn: () => activityApi.list(selectedCompanyId!, { limit: DASHBOARD_ACTIVITY_LIMIT }),
|
|
enabled: !!selectedCompanyId,
|
|
});
|
|
usePublishSharedQueryData(sharedActivity, activity, activityUpdatedAt);
|
|
|
|
const { data: issues } = useQuery({
|
|
queryKey: queryKeys.issues.list(selectedCompanyId!),
|
|
queryFn: () => issuesApi.list(selectedCompanyId!),
|
|
enabled: !!selectedCompanyId,
|
|
});
|
|
|
|
const { data: projects } = useQuery({
|
|
queryKey: queryKeys.projects.list(selectedCompanyId!, { includeArchived: true }),
|
|
queryFn: () => projectsApi.list(selectedCompanyId!, { includeArchived: true }),
|
|
enabled: !!selectedCompanyId,
|
|
});
|
|
|
|
const { data: companyMembers } = useQuery({
|
|
queryKey: queryKeys.access.companyUserDirectory(selectedCompanyId!),
|
|
queryFn: () => accessApi.listUserDirectory(selectedCompanyId!),
|
|
enabled: !!selectedCompanyId,
|
|
});
|
|
|
|
const userProfileMap = useMemo(
|
|
() => buildCompanyUserProfileMap(companyMembers?.users),
|
|
[companyMembers?.users],
|
|
);
|
|
|
|
const recentIssues = issues ? getRecentIssues(issues) : [];
|
|
const recentActivity = useMemo(() => (activity ?? []).slice(0, 10), [activity]);
|
|
|
|
useEffect(() => {
|
|
for (const timer of activityAnimationTimersRef.current) {
|
|
window.clearTimeout(timer);
|
|
}
|
|
activityAnimationTimersRef.current = [];
|
|
seenActivityIdsRef.current = new Set();
|
|
hydratedActivityRef.current = false;
|
|
setAnimatedActivityIds(new Set());
|
|
}, [selectedCompanyId]);
|
|
|
|
useEffect(() => {
|
|
if (recentActivity.length === 0) return;
|
|
|
|
const seen = seenActivityIdsRef.current;
|
|
const currentIds = recentActivity.map((event) => event.id);
|
|
|
|
if (!hydratedActivityRef.current) {
|
|
for (const id of currentIds) seen.add(id);
|
|
hydratedActivityRef.current = true;
|
|
return;
|
|
}
|
|
|
|
const newIds = currentIds.filter((id) => !seen.has(id));
|
|
if (newIds.length === 0) {
|
|
for (const id of currentIds) seen.add(id);
|
|
return;
|
|
}
|
|
|
|
setAnimatedActivityIds((prev) => {
|
|
const next = new Set(prev);
|
|
for (const id of newIds) next.add(id);
|
|
return next;
|
|
});
|
|
|
|
for (const id of newIds) seen.add(id);
|
|
|
|
const timer = window.setTimeout(() => {
|
|
setAnimatedActivityIds((prev) => {
|
|
const next = new Set(prev);
|
|
for (const id of newIds) next.delete(id);
|
|
return next;
|
|
});
|
|
activityAnimationTimersRef.current = activityAnimationTimersRef.current.filter((t) => t !== timer);
|
|
}, 980);
|
|
activityAnimationTimersRef.current.push(timer);
|
|
}, [recentActivity]);
|
|
|
|
useEffect(() => {
|
|
return () => {
|
|
for (const timer of activityAnimationTimersRef.current) {
|
|
window.clearTimeout(timer);
|
|
}
|
|
};
|
|
}, []);
|
|
|
|
const agentMap = useMemo(() => {
|
|
const map = new Map<string, Agent>();
|
|
for (const a of agents ?? []) map.set(a.id, a);
|
|
return map;
|
|
}, [agents]);
|
|
|
|
const entityNameMap = useMemo(() => {
|
|
const map = new Map<string, string>();
|
|
for (const i of issues ?? []) map.set(`issue:${i.id}`, i.identifier ?? i.id.slice(0, 8));
|
|
for (const a of agents ?? []) map.set(`agent:${a.id}`, a.name);
|
|
for (const p of projects ?? []) map.set(`project:${p.id}`, p.name);
|
|
return map;
|
|
}, [issues, agents, projects]);
|
|
|
|
const entityTitleMap = useMemo(() => {
|
|
const map = new Map<string, string>();
|
|
for (const i of issues ?? []) map.set(`issue:${i.id}`, i.title);
|
|
return map;
|
|
}, [issues]);
|
|
|
|
const agentName = (id: string | null) => {
|
|
if (!id || !agents) return null;
|
|
return agents.find((a) => a.id === id)?.name ?? null;
|
|
};
|
|
|
|
if (!selectedCompanyId) {
|
|
if (companies.length === 0) {
|
|
return (
|
|
<EmptyState
|
|
icon={LayoutDashboard}
|
|
message="Welcome to Paperclip. Set up your first organization and agent to get started."
|
|
action="Get Started"
|
|
onAction={openOnboarding}
|
|
/>
|
|
);
|
|
}
|
|
return (
|
|
<EmptyState icon={LayoutDashboard} message="Create or select an organization to view the dashboard." />
|
|
);
|
|
}
|
|
|
|
if (isLoading) {
|
|
return <PageSkeleton variant="dashboard" />;
|
|
}
|
|
|
|
// Same rule as the auto-offer above: a list still being refreshed may be the
|
|
// empty one cached before the first hire, and the banner's "Create one here"
|
|
// opens the same agent step the offer does.
|
|
const hasNoAgents = agents !== undefined && !agentsRefreshing && agents.length === 0;
|
|
const pausedBanner = derivePausedAgentBanner(agents);
|
|
const pausedImportedCount =
|
|
pausedBanner?.kind === "imported" ? pausedBanner.pausedImportedAgentIds.length : 0;
|
|
|
|
return (
|
|
<div className="space-y-6">
|
|
{error && <p className="text-sm text-destructive">{error.message}</p>}
|
|
|
|
{pausedBanner?.kind === "imported" ? (
|
|
<InlineBanner
|
|
tone="warning"
|
|
icon={PauseCircle}
|
|
title={`${pausedImportedCount} imported agent${pausedImportedCount === 1 ? " is" : "s are"} paused and will not run.`}
|
|
actions={
|
|
<Button
|
|
size="sm"
|
|
onClick={() => resumeImportedAgents.mutate()}
|
|
disabled={resumeImportedAgents.isPending}
|
|
data-testid="dashboard-resume-imported-agents"
|
|
>
|
|
{resumeImportedAgents.isPending ? "Resuming…" : "Resume all"}
|
|
</Button>
|
|
}
|
|
>
|
|
Agents from an organization import arrive paused as a safety default. Resume them so assigned tasks can start.
|
|
</InlineBanner>
|
|
) : pausedBanner?.kind === "all-paused" ? (
|
|
<InlineBanner
|
|
tone="warning"
|
|
icon={PauseCircle}
|
|
title="All agents in this organization are paused — nothing will run."
|
|
actions={
|
|
<Button variant="ghost" size="sm" asChild>
|
|
<Link to="/agents">Review agents</Link>
|
|
</Button>
|
|
}
|
|
>
|
|
Resume at least one agent to let assigned tasks start.
|
|
</InlineBanner>
|
|
) : null}
|
|
|
|
{hasNoAgents && (
|
|
<div className="flex items-center justify-between gap-3 rounded-md border border-amber-300 bg-amber-50 px-4 py-3 dark:border-amber-500/25 dark:bg-amber-950/60">
|
|
<div className="flex items-center gap-2.5">
|
|
<Bot className="h-4 w-4 text-amber-600 dark:text-amber-400 shrink-0" />
|
|
<p className="text-sm text-amber-900 dark:text-amber-100">
|
|
You have no agents.
|
|
</p>
|
|
</div>
|
|
<button
|
|
onClick={() => openOnboarding({ initialStep: 3, companyId: selectedCompanyId! })}
|
|
className="text-sm font-medium text-amber-700 hover:text-amber-900 dark:text-amber-300 dark:hover:text-amber-100 underline underline-offset-2 shrink-0"
|
|
>
|
|
Create one here
|
|
</button>
|
|
</div>
|
|
)}
|
|
|
|
<ActiveAgentsPanel companyId={selectedCompanyId!} dedupeLinkedTasks />
|
|
|
|
{data && (
|
|
<>
|
|
{data.budgets.activeIncidents > 0 ? (
|
|
<div className="flex items-start justify-between gap-3 rounded-xl border border-red-500/20 bg-(image:--gradient-extract-1) px-4 py-3">
|
|
<div className="flex items-start gap-2.5">
|
|
<PauseCircle className="mt-0.5 h-4 w-4 shrink-0 text-red-700 dark:text-red-300" />
|
|
<div>
|
|
<p className="text-sm font-medium text-red-950 dark:text-red-50">
|
|
{data.budgets.activeIncidents} active budget incident{data.budgets.activeIncidents === 1 ? "" : "s"}
|
|
</p>
|
|
<p className="text-xs text-red-900/70 dark:text-red-100/70">
|
|
{data.budgets.pausedAgents} agents paused · {data.budgets.pausedProjects} projects paused · {data.budgets.pendingApprovals} pending budget approvals
|
|
</p>
|
|
</div>
|
|
</div>
|
|
<Link to="/costs" className="text-sm underline underline-offset-2 text-red-900 dark:text-red-100">
|
|
Open budgets
|
|
</Link>
|
|
</div>
|
|
) : null}
|
|
|
|
<div className={cn("grid grid-cols-2 xl:grid-cols-4", designSandbox ? "gap-4" : "gap-1 sm:gap-2")}>
|
|
<MetricCard
|
|
appearance={designSandbox ? "card" : "plain"}
|
|
icon={Bot}
|
|
value={data.agents.active + data.agents.running + data.agents.paused + data.agents.error}
|
|
label="Agents Enabled"
|
|
to="/agents"
|
|
description={
|
|
<span>
|
|
{data.agents.running} running{", "}
|
|
{data.agents.paused} paused{", "}
|
|
{data.agents.error} errors
|
|
</span>
|
|
}
|
|
/>
|
|
<MetricCard
|
|
appearance={designSandbox ? "card" : "plain"}
|
|
icon={CircleDot}
|
|
value={data.tasks.inProgress}
|
|
label="Tasks In Progress"
|
|
to="/issues"
|
|
description={
|
|
<span>
|
|
{data.tasks.open} open{", "}
|
|
{data.tasks.blocked} blocked
|
|
</span>
|
|
}
|
|
/>
|
|
<MetricCard
|
|
appearance={designSandbox ? "card" : "plain"}
|
|
icon={DollarSign}
|
|
value={formatCents(data.costs.monthSpendCents)}
|
|
label="Month Spend"
|
|
to="/costs"
|
|
description={
|
|
<span>
|
|
{data.costs.monthBudgetCents > 0
|
|
? `${data.costs.monthUtilizationPercent}% of ${formatCents(data.costs.monthBudgetCents)} budget`
|
|
: "Unlimited budget"}
|
|
</span>
|
|
}
|
|
/>
|
|
<MetricCard
|
|
appearance={designSandbox ? "card" : "plain"}
|
|
icon={ShieldCheck}
|
|
value={data.pendingApprovals + data.budgets.pendingApprovals}
|
|
label="Pending Approvals"
|
|
to="/approvals"
|
|
description={
|
|
<span>
|
|
{data.budgets.pendingApprovals > 0
|
|
? `${data.budgets.pendingApprovals} budget overrides awaiting board review`
|
|
: "Awaiting board review"}
|
|
</span>
|
|
}
|
|
/>
|
|
</div>
|
|
|
|
<SmokeLabDashboardCard companyId={selectedCompanyId!} />
|
|
|
|
<div className={cn("grid grid-cols-2 gap-4", SHOW_TASK_PRIORITY_UI ? "lg:grid-cols-4" : "lg:grid-cols-3")}>
|
|
<ChartCard title="Run Activity" subtitle="Last 14 days">
|
|
<RunActivityChart activity={data.runActivity} />
|
|
</ChartCard>
|
|
{/* PAP-411: "Tasks by Priority" chart hidden behind SHOW_TASK_PRIORITY_UI. */}
|
|
{SHOW_TASK_PRIORITY_UI && (
|
|
<ChartCard title="Tasks by Priority" subtitle="Last 14 days">
|
|
<PriorityChart issues={issues ?? []} />
|
|
</ChartCard>
|
|
)}
|
|
<ChartCard title="Tasks by Status" subtitle="Last 14 days">
|
|
<IssueStatusChart issues={issues ?? []} />
|
|
</ChartCard>
|
|
<ChartCard title="Success Rate" subtitle="Last 14 days">
|
|
<SuccessRateChart activity={data.runActivity} />
|
|
</ChartCard>
|
|
</div>
|
|
|
|
<PluginSlotOutlet
|
|
slotTypes={["dashboardWidget"]}
|
|
context={{ companyId: selectedCompanyId }}
|
|
className="grid gap-4 md:grid-cols-2"
|
|
// design-allow(card-pattern): class-string prop consumed by the plugin outlet; a component can't be passed here (C5a Run 3)
|
|
itemClassName="rounded-lg border bg-card p-4 shadow-sm"
|
|
/>
|
|
|
|
<div className="grid md:grid-cols-2 gap-4">
|
|
{/* Recent Activity */}
|
|
{recentActivity.length > 0 && (
|
|
<div className="min-w-0">
|
|
<h3 className="text-sm font-semibold text-muted-foreground uppercase tracking-wide mb-3">
|
|
Recent Activity
|
|
</h3>
|
|
<Card className="@container block py-0 divide-y divide-border overflow-hidden">
|
|
{designSandbox ? <ActivityList events={recentActivity} agentMap={agentMap} userProfileMap={userProfileMap} entityNameMap={entityNameMap} entityTitleMap={entityTitleMap} rowClassName={event => animatedActivityIds.has(event.id) ? "activity-row-enter" : undefined} /> : recentActivity.map((event) => (
|
|
<ActivityRow
|
|
key={event.id}
|
|
event={event}
|
|
agentMap={agentMap}
|
|
userProfileMap={userProfileMap}
|
|
entityNameMap={entityNameMap}
|
|
entityTitleMap={entityTitleMap}
|
|
className={animatedActivityIds.has(event.id) ? "activity-row-enter" : undefined}
|
|
/>
|
|
))}
|
|
</Card>
|
|
</div>
|
|
)}
|
|
|
|
{/* Recent Tasks */}
|
|
<div className="min-w-0">
|
|
<h3 className="text-sm font-semibold text-muted-foreground uppercase tracking-wide mb-3">
|
|
Recent Tasks
|
|
</h3>
|
|
{recentIssues.length === 0 ? (
|
|
<Card className="block p-4">
|
|
<p className="text-sm text-muted-foreground">No tasks yet.</p>
|
|
</Card>
|
|
) : (
|
|
designSandbox ? <Card className="block overflow-hidden py-0">
|
|
<TaskSummaryList issues={recentIssues.slice(0, 10)} />
|
|
</Card> : <Card className="@container block py-0 divide-y divide-border overflow-hidden">
|
|
{recentIssues.slice(0, 10).map((issue) => (
|
|
<Link
|
|
key={issue.id}
|
|
to={`/issues/${issue.identifier ?? issue.id}`}
|
|
className="dashboard-list-row text-sm cursor-pointer hover:bg-accent/50 transition-colors no-underline text-inherit block"
|
|
>
|
|
<div className="flex items-start gap-2 @xl:grid @xl:grid-cols-(--dashboard-task-list-columns) @xl:items-baseline">
|
|
<span className="flex size-6 shrink-0 items-center justify-end @xl:self-center">
|
|
<StatusIcon status={issue.status} externalConversationState={issue.externalConversationState} blockerAttention={issue.blockerAttention} />
|
|
</span>
|
|
<span className="flex min-w-0 flex-1 flex-col gap-1 @xl:contents">
|
|
<span className="flex min-w-0 items-baseline gap-2 @xl:contents">
|
|
<span className="min-w-0 flex-1 truncate text-sm leading-6" title={issue.title}>
|
|
{issue.title}
|
|
</span>
|
|
<span className="ml-auto shrink-0 truncate text-right font-mono text-(length:--text-micro) text-muted-foreground @xl:col-start-4 @xl:row-start-1 @xl:w-(--dashboard-list-id-width)">
|
|
{issue.identifier ?? issue.id.slice(0, 8)}
|
|
</span>
|
|
</span>
|
|
<span className="flex min-h-6 min-w-0 items-center gap-2 @xl:contents">
|
|
<span className="flex min-w-0 flex-1 items-center @xl:col-start-3 @xl:row-start-1 @xl:self-center">
|
|
{issue.assigneeAgentId && (() => {
|
|
const name = agentName(issue.assigneeAgentId);
|
|
return name
|
|
? <AgentIdentity agent={agents?.find(agent => agent.id === issue.assigneeAgentId) ?? { id: issue.assigneeAgentId ?? undefined, name }} size="sm" className="max-w-32" />
|
|
: null;
|
|
})()}
|
|
</span>
|
|
<span className="ml-auto w-(--dashboard-list-time-width) shrink-0 whitespace-nowrap text-right text-xs text-muted-foreground">
|
|
{timeAgo(issue.updatedAt)}
|
|
</span>
|
|
</span>
|
|
</span>
|
|
</div>
|
|
</Link>
|
|
))}
|
|
</Card>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
</>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|