Files
PaperClipAI/ui/src/pages/Dashboard.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

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} displayStatus={issue.displayStatus} 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>
);
}