Files
PaperClipAI/ui/src/pages/UserProfile.tsx
T
DottaandPaperclip 40b8075916 feat(ui): show scheduled task waits as Monitoring (#18624)
## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work.
> - Task indicators tell an operator whether work is active, waiting, or
complete.
> - An agent can yield until a saved monitor checks CI or another
external result.
> - The task currently keeps the In Progress icon between those runs.
> - Changing the stored status would affect monitor admission and
recovery.
> - This pull request adds a read-only Monitoring display state with a
blue clock.
> - Operators can see and filter scheduled waits while execution keeps
its existing rules.

## Linked Issues or Issue Description

**What existing behavior does this improve?**

Task status indicators, task collection filters, board columns, and
search facets.

**Current behavior**

A task that waits for a scheduled monitor shows the same In Progress
icon as active work.

**Proposed behavior**

Show Monitoring with a static blue clock when an agent-owned task has a
saved ordinary monitor and no active run, execution lock, review
participant, or active recovery action. Return to the normal task
indicator when the check starts. Preserve review, blocked, terminal, and
quota-recovery states.

**Reason and benefit**

Operators can distinguish scheduled waits from current agent work
without reading the task history.

**Breaking changes**

None to stored task statuses or execution rules. Task reads gain
optional displayStatus. Collections and search gain a separate read
filter. Existing status filters keep their lifecycle meaning.

Related historical work: #4396 covered a monitoring label exemption.
This change uses the saved monitor and a read projection.

## What Changed

- Add one SQL projection for task detail, compact lists, summaries,
relations, search results, and display-status counts.
- Show the blue clock in shared task icons and chips, task detail and
breadcrumbs, properties, references, list rows, recent navigation, and
agent task cards.
- Add Monitoring to task filtering, grouping, sorting, board columns,
and search facets. Prevent a drag from creating this derived state.
- Keep stored lifecycle statuses, monitor scheduling, checkout,
recovery, permissions, and agent status filters unchanged.
- Add database and UI regression coverage, full-page Storybook examples,
and product and access-map documentation.

## Verification

- Passed `pnpm -r typecheck`.
- Passed `pnpm build`.
- Passed `pnpm check:token-gates` and the map/catalog structural checks.
- Ran the new database suite on a disposable PostgreSQL 17 database. It
covers read surfaces, filter pagination/counts, queued/running checks,
rescheduling, completion, retained monitors, review, and recovery holds.
- Ran existing search, list, board, sidebar, task status, and filter
tests. Corrected board queries use displayStatus and eight columns.
- The full local `pnpm test:run` stopped in a chat integration shard: 12
failures (four timeouts and eight FIFO publication assertions). That run
spanned a rebase. A focused rerun on the current source passed all 21
selected cases without assertion or timeout changes. The original cause
is not established; this does not claim the full local suite passed.
Final-head required CI passed (ci / verify and ci / e2e, run
41488267209).
- Passed 82 tests for task API summaries, recovery precedence, search,
and optimistic updates; 54 tests for status cards, database reads, and
board moves; and 27 tests for blocker relations and recent-task cache
updates. One intermediate database setup hit its 20-second hook timeout;
the serial rerun passed.
- Greptile is 5/5 on d003ea672d. All
review threads are resolved. The separate optional Paperclip Review
ended early and returned neutral; GitHub rejected a direct rerun request
with HTTP 404. It is not counted as a completed review.
- Review fixes cover escalated recovery, is:open, optimistic status
changes, blocker-summary projection, and recent-task dedupe.
- Reviewed 15 screenshots from production pages and components with
isolated Storybook API fixtures: task detail/properties/breadcrumb,
list, groups, status filter, board, search/results/filter counts,
reference pills, sibling navigation, agent cards, picker, dashboard,
inbox, profile, watched status-card tasks, light theme, and mobile. This
does not claim deployed staging verification.

## Risks

- Read projections must stay consistent across full, compact, summary,
and search responses. Tests cover those paths.
- Monitoring is not a writable lifecycle status. The board does not
accept drops into its Monitoring lane or onto a monitored card. This
prevents a drag from implying a scheduled check. Move work into In
Progress through that lane instead.
- The read projection adds run/recovery existence checks. Existing
heartbeat_runs_company_issue_created_idx and
issue_recovery_actions_company_source_status_idx indexes cover the
company/task lookup. It adds no worker, timer, recurring query,
migration, or provider call. No production load benchmark is claimed.
- Older clients can ignore displayStatus. Existing lifecycle status
filters keep their behavior.

## Model Used

OpenAI Codex, GPT-6, with reasoning, repository tools, code execution,
and browser tools. The exact backend model identifier 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-11-02 11:35:30 -06:00

388 lines
16 KiB
TypeScript

import { useEffect, useMemo } from "react";
import { useQuery } from "@tanstack/react-query";
import { AlertCircle, UserRound, UserRoundPen } from "lucide-react";
import {
hidesInstancePage,
type UserProfileDailyPoint,
type UserProfileWindowStats,
} from "@paperclipai/shared";
import { Link, useParams } from "@/lib/router";
import { authApi } from "../api/auth";
import { userProfilesApi } from "../api/userProfiles";
import { Avatar, AvatarFallback, AvatarImage } from "../components/ui/avatar";
import { Button } from "../components/ui/button";
import { EmptyState } from "../components/EmptyState";
import { PageSkeleton } from "../components/PageSkeleton";
import { IssueStatusBadge } from "../components/StatusBadge";
import { useBreadcrumbs } from "../context/BreadcrumbContext";
import { useCompany } from "../context/CompanyContext";
import { useHiddenSettings } from "../hooks/useHiddenSettings";
import { queryKeys } from "../lib/queryKeys";
import {
formatCents,
formatDate,
formatNumber,
formatShortDate,
formatTokens,
issueUrl,
providerDisplayName,
relativeTime,
} from "../lib/utils";
const NO_COMPANY = "__none__";
const PROFILE_SETTINGS_PATH = "/company/settings/instance/profile";
function initials(name: string | null | undefined) {
const value = name?.trim() || "User";
const parts = value.split(/\s+/).filter(Boolean);
if (parts.length > 1) return `${parts[0]?.[0] ?? ""}${parts[parts.length - 1]?.[0] ?? ""}`.toUpperCase();
return value.slice(0, 2).toUpperCase();
}
function totalTokens(stats: Pick<UserProfileWindowStats, "inputTokens" | "cachedInputTokens" | "outputTokens">) {
return stats.inputTokens + stats.cachedInputTokens + stats.outputTokens;
}
function completionRate(stats: UserProfileWindowStats) {
if (stats.touchedIssues === 0) return "0%";
return `${Math.round((stats.completedIssues / stats.touchedIssues) * 100)}%`;
}
function HeroStat({ label, value, hint }: { label: string; value: string; hint?: string }) {
return (
<div className="min-w-0">
<div className="text-2xl font-semibold tabular-nums sm:text-3xl">{value}</div>
<div className="mt-1 text-(length:--text-micro) font-medium uppercase tracking-wide text-muted-foreground">{label}</div>
{hint ? <div className="mt-0.5 text-xs text-muted-foreground/70">{hint}</div> : null}
</div>
);
}
function WindowColumn({ stats }: { stats: UserProfileWindowStats }) {
const tokens = totalTokens(stats);
return (
<div className="flex min-w-0 flex-col gap-4 border-l border-border pl-5 first:border-l-0 first:pl-0">
<div className="flex items-baseline justify-between gap-3">
<h2 className="text-(length:--text-micro) font-medium uppercase tracking-wide text-muted-foreground">{stats.label}</h2>
<span className="text-(length:--text-micro) text-muted-foreground tabular-nums">{completionRate(stats)} done</span>
</div>
<div className="grid grid-cols-2 gap-x-5 gap-y-3">
<Metric value={formatNumber(stats.touchedIssues)} label="Touched" />
<Metric value={formatNumber(stats.completedIssues)} label="Completed" />
<Metric value={formatNumber(stats.commentCount)} label="Comments" />
<Metric value={formatNumber(stats.activityCount)} label="Actions" />
</div>
<div className="grid grid-cols-2 gap-x-5 gap-y-1.5 pt-3 text-xs tabular-nums text-muted-foreground">
<span>Tokens</span>
<span className="text-right text-foreground">{formatTokens(tokens)}</span>
<span>Spend</span>
<span className="text-right text-foreground">{formatCents(stats.costCents)}</span>
<span>Created</span>
<span className="text-right text-foreground">{formatNumber(stats.createdIssues)}</span>
<span>Open</span>
<span className="text-right text-foreground">{formatNumber(stats.assignedOpenIssues)}</span>
</div>
</div>
);
}
function Metric({ value, label }: { value: string; label: string }) {
return (
<div className="min-w-0">
<div className="truncate text-xl font-semibold tabular-nums">{value}</div>
<div className="mt-0.5 text-(length:--text-micro) text-muted-foreground">{label}</div>
</div>
);
}
function UsageChart({ points }: { points: UserProfileDailyPoint[] }) {
const totals = points.map((point) => totalTokens(point));
const maxTokens = Math.max(1, ...totals);
const maxCompleted = Math.max(1, ...points.map((point) => point.completedIssues));
const totalTokensSum = totals.reduce((sum, value) => sum + value, 0);
return (
<section>
<div className="flex flex-wrap items-baseline justify-between gap-3 border-b border-border pb-3">
<h2 className="text-sm font-semibold">Last 14 days</h2>
<div className="flex items-baseline gap-4 text-xs text-muted-foreground">
<span className="tabular-nums text-foreground">{formatTokens(totalTokensSum)}</span>
<span>tokens total</span>
</div>
</div>
<div className="mt-6 grid grid-cols-(--gtc-57) items-end gap-1.5 sm:gap-2">
{points.map((point) => {
const tokens = totalTokens(point);
const heightPct = tokens === 0 ? 0 : Math.max(2, Math.round((tokens / maxTokens) * 100));
const completedPct = point.completedIssues === 0
? 0
: Math.max(8, Math.round((point.completedIssues / maxCompleted) * 36));
return (
<div key={point.date} className="group flex h-36 flex-col justify-end">
<div
className="w-full bg-foreground/80 transition-opacity group-hover:bg-foreground"
style={{ height: `${heightPct}%`, minHeight: tokens === 0 ? 1 : undefined }}
title={`${formatShortDate(point.date)}: ${formatTokens(tokens)} tokens, ${point.completedIssues} completed`}
/>
{completedPct > 0 ? (
<div
className="mt-1 w-full rounded-full bg-emerald-500/80"
style={{ height: 2, opacity: Math.min(1, 0.35 + completedPct / 100) }}
/>
) : null}
</div>
);
})}
</div>
<div className="mt-2 grid grid-cols-(--gtc-57) gap-1.5 text-(length:--text-nano) tabular-nums text-muted-foreground sm:gap-2">
{points.map((point, index) => (
<div key={point.date} className="text-center">
{index === 0 || index === 6 || index === 13 ? formatShortDate(point.date) : null}
</div>
))}
</div>
<div className="mt-4 flex flex-wrap items-center gap-4 text-(length:--text-nano) uppercase tracking-wide text-muted-foreground">
<span className="inline-flex items-center gap-1.5">
<span className="h-2 w-2 bg-foreground/80" /> tokens / day
</span>
<span className="inline-flex items-center gap-1.5">
<span className="h-(--sz-3px) w-4 rounded-full bg-emerald-500/80" /> completions
</span>
</div>
</section>
);
}
interface UsageRow {
key: string;
label: string;
sublabel: string;
costCents: number;
inputTokens: number;
cachedInputTokens: number;
outputTokens: number;
}
function UsageList({
title,
empty,
rows,
}: {
title: string;
empty: string;
rows: UsageRow[];
}) {
return (
<section>
<div className="flex items-baseline justify-between gap-3 border-b border-border pb-3">
<h2 className="text-sm font-semibold">{title}</h2>
<span className="text-xs text-muted-foreground tabular-nums">{rows.length}</span>
</div>
{rows.length === 0 ? (
<div className="pt-4 text-sm text-muted-foreground">{empty}</div>
) : (
<ul className="divide-y divide-border">
{rows.map((row) => (
<li key={row.key} className="grid gap-2 py-2.5 sm:grid-cols-(--gtc-17) sm:items-center">
<div className="min-w-0">
<div className="truncate text-sm font-medium">{row.label}</div>
<div className="truncate text-xs text-muted-foreground">{row.sublabel}</div>
</div>
<div className="flex items-baseline gap-4 text-xs tabular-nums sm:justify-end">
<span className="text-muted-foreground">{formatTokens(totalTokens(row))}</span>
<span className="font-medium">{formatCents(row.costCents)}</span>
</div>
</li>
))}
</ul>
)}
</section>
);
}
export function UserProfile() {
const { userSlug = "" } = useParams<{ userSlug: string }>();
const { selectedCompanyId } = useCompany();
const { setBreadcrumbs } = useBreadcrumbs();
const companyId = selectedCompanyId ?? NO_COMPANY;
const { data, isLoading, error } = useQuery({
queryKey: queryKeys.userProfile(companyId, userSlug),
queryFn: () => userProfilesApi.get(companyId, userSlug),
enabled: !!selectedCompanyId && !!userSlug,
});
// The edit link only belongs on the viewer's own profile, and never when the
// hosting operator hides the profile settings surface the link points at.
const { data: session } = useQuery({
queryKey: queryKeys.auth.session,
queryFn: () => authApi.getSession(),
retry: false,
});
const { hidden: hiddenSettings } = useHiddenSettings();
const canEditProfile =
Boolean(data?.user.id) &&
data?.user.id === session?.user.id &&
!hidesInstancePage(hiddenSettings, "instance.profile");
useEffect(() => {
setBreadcrumbs([{ label: "Users" }, { label: data?.user.name ?? userSlug }]);
}, [data?.user.name, setBreadcrumbs, userSlug]);
const allTime = data?.stats.find((entry) => entry.key === "all");
const last7 = data?.stats.find((entry) => entry.key === "last7");
const displayName = data?.user.name?.trim() || data?.user.email?.split("@")[0] || "User";
const agentUsageRows = useMemo<UsageRow[]>(
() =>
(data?.topAgents ?? []).map((row) => ({
key: row.agentId ?? "unknown",
label: row.agentName ?? (row.agentId ? row.agentId.slice(0, 8) : "Paperclip services"),
sublabel: "Task-linked usage",
costCents: row.costCents,
inputTokens: row.inputTokens,
cachedInputTokens: row.cachedInputTokens,
outputTokens: row.outputTokens,
})),
[data?.topAgents],
);
const providerUsageRows = useMemo<UsageRow[]>(
() =>
(data?.topProviders ?? []).map((row) => ({
key: `${row.provider}:${row.biller}:${row.model}`,
label: `${providerDisplayName(row.provider)} / ${row.model}`,
sublabel: `Billed through ${providerDisplayName(row.biller)}`,
costCents: row.costCents,
inputTokens: row.inputTokens,
cachedInputTokens: row.cachedInputTokens,
outputTokens: row.outputTokens,
})),
[data?.topProviders],
);
if (!selectedCompanyId) {
return <EmptyState icon={UserRound} message="Select an organization to view user profiles." />;
}
if (isLoading) {
return <PageSkeleton variant="dashboard" />;
}
if (error || !data) {
return <EmptyState icon={AlertCircle} message="User profile not found for this organization." />;
}
const allTimeTokens = allTime ? totalTokens(allTime) : 0;
const metaParts = [
data.user.membershipRole ?? "member",
data.user.membershipStatus,
`joined ${formatDate(data.user.joinedAt)}`,
];
return (
<div className="space-y-10 pb-10">
<section className="flex flex-col gap-7 border-b border-border pb-8">
<div className="flex flex-wrap items-center gap-5">
<Avatar className="size-16 border border-border" size="lg">
{data.user.image ? <AvatarImage src={data.user.image} alt={displayName} /> : null}
<AvatarFallback className="text-lg font-semibold">{initials(displayName)}</AvatarFallback>
</Avatar>
<div className="min-w-0 flex-1">
<div className="flex flex-wrap items-baseline gap-x-3 gap-y-1">
<h1 className="truncate text-2xl font-semibold">{displayName}</h1>
<span className="text-sm text-muted-foreground">@{data.user.slug}</span>
</div>
<div className="mt-1.5 flex flex-wrap items-center gap-x-3 gap-y-0.5 text-xs text-muted-foreground">
{data.user.email ? <span className="truncate">{data.user.email}</span> : null}
{data.user.email ? <span aria-hidden>·</span> : null}
<span>{metaParts.join(" · ")}</span>
</div>
</div>
{canEditProfile ? (
<Button asChild variant="outline" size="sm">
<Link to={PROFILE_SETTINGS_PATH}>
<UserRoundPen />
Edit profile
</Link>
</Button>
) : null}
</div>
<div className="grid gap-6 sm:grid-cols-2 lg:grid-cols-4">
<HeroStat label="All-time tokens" value={formatTokens(allTimeTokens)} hint={formatCents(allTime?.costCents ?? 0) + " spent"} />
<HeroStat label="Completed" value={formatNumber(allTime?.completedIssues ?? 0)} hint={allTime ? `${completionRate(allTime)} rate` : undefined} />
<HeroStat label="Open assigned" value={formatNumber(allTime?.assignedOpenIssues ?? 0)} hint={`${formatNumber(allTime?.createdIssues ?? 0)} created`} />
<HeroStat label="7-day actions" value={formatNumber(last7?.activityCount ?? 0)} hint={`${formatNumber(last7?.commentCount ?? 0)} comments`} />
</div>
</section>
<section className="grid gap-8 border-b border-border pb-8 lg:grid-cols-3">
{data.stats.map((entry) => <WindowColumn key={entry.key} stats={entry} />)}
</section>
<UsageChart points={data.daily} />
<div className="grid gap-10 pt-2 xl:grid-cols-2">
<section>
<div className="flex items-baseline justify-between gap-3 border-b border-border pb-3">
<h2 className="text-sm font-semibold">Recent tasks</h2>
<span className="text-xs text-muted-foreground tabular-nums">{data.recentIssues.length}</span>
</div>
{data.recentIssues.length === 0 ? (
<div className="pt-4 text-sm text-muted-foreground">No touched tasks yet.</div>
) : (
<ul className="divide-y divide-border">
{data.recentIssues.map((issue) => (
<li key={issue.id}>
<Link
to={issueUrl(issue)}
className="grid gap-2 py-2.5 transition-colors hover:bg-accent/40 sm:grid-cols-(--gtc-58) sm:items-center"
>
<span className="font-mono text-xs text-muted-foreground">{issue.identifier ?? issue.id.slice(0, 8)}</span>
<span className="truncate text-sm">{issue.title}</span>
<span className="flex items-center gap-3 sm:justify-end">
<IssueStatusBadge status={issue.status} displayStatus={issue.displayStatus} />
<span className="text-xs tabular-nums text-muted-foreground">{relativeTime(issue.updatedAt)}</span>
</span>
</Link>
</li>
))}
</ul>
)}
</section>
<section>
<div className="flex items-baseline justify-between gap-3 border-b border-border pb-3">
<h2 className="text-sm font-semibold">Recent activity</h2>
<span className="text-xs text-muted-foreground tabular-nums">{data.recentActivity.length}</span>
</div>
{data.recentActivity.length === 0 ? (
<div className="pt-4 text-sm text-muted-foreground">No direct user actions recorded yet.</div>
) : (
<ul className="divide-y divide-border">
{data.recentActivity.map((event) => (
<li key={event.id} className="grid gap-2 py-2.5 sm:grid-cols-(--gtc-17) sm:items-center">
<div className="min-w-0">
<div className="truncate text-sm">{event.action.replaceAll("_", " ")}</div>
<div className="truncate text-xs text-muted-foreground">
{event.entityType} · {event.entityId.slice(0, 12)}
</div>
</div>
<span className="text-xs tabular-nums text-muted-foreground sm:justify-self-end">{relativeTime(event.createdAt)}</span>
</li>
))}
</ul>
)}
</section>
</div>
<div className="grid gap-10 xl:grid-cols-2">
<UsageList title="Agent attribution" empty="No issue-linked token usage yet." rows={agentUsageRows} />
<UsageList title="Provider mix" empty="No provider usage attributed yet." rows={providerUsageRows} />
</div>
</div>
);
}