[codex] Harden dashboard run activity charts (#4126)

## Thinking Path

> - Paperclip gives operators a live view of agent work across
dashboards, transcripts, and run activity charts
> - Those views consume live run updates and aggregate run activity from
backend dashboard data
> - Missing or partial run data could make charts brittle, and live
transcript updates were heavier than needed
> - Operators need dashboard data to stay stable even when recent run
payloads are incomplete
> - This pull request hardens dashboard run aggregation, guards chart
rendering, and lightens live run update handling
> - The benefit is a more reliable dashboard during active agent
execution

## What Changed

- Added dashboard run activity types and backend aggregation coverage.
- Guarded activity chart rendering when run data is missing or partial.
- Reduced live transcript update churn in active agent and run chat
surfaces.
- Fixed issue chat avatar alignment in the thread renderer.
- Added focused dashboard, activity chart, and live transcript tests.

## Verification

- `pnpm install --frozen-lockfile --ignore-scripts`
- `pnpm exec vitest run server/src/__tests__/dashboard-service.test.ts
ui/src/components/ActivityCharts.test.tsx
ui/src/components/transcript/useLiveRunTranscripts.test.tsx`
- Result: 8 tests passed, 1 skipped. The embedded Postgres dashboard
service test skipped on this host with the existing PGlite/Postgres init
warning; UI chart and transcript tests passed.

## Risks

- Medium-low risk: aggregation semantics changed, but the UI remains
guarded around incomplete data.
- The dashboard service test is host-skipped here, so CI should confirm
the embedded database path.

> For core feature work, check [`ROADMAP.md`](ROADMAP.md) first and
discuss it in `#dev` before opening the PR. Feature PRs that overlap
with planned core work may need to be redirected — check the roadmap
first. See `CONTRIBUTING.md`.

## Model Used

- OpenAI Codex coding agent based on GPT-5, tool-enabled local shell and
GitHub workflow, exact runtime context window 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 run tests locally and they pass
- [x] I have added or updated tests where applicable
- [x] If this change affects the UI, I have included before/after
screenshots, or documented why targeted component tests are sufficient
here
- [x] I have updated relevant documentation to reflect my changes
- [x] I have considered and documented any risks above
- [x] I will address all Greptile and reviewer comments before
requesting merge

---------

Co-authored-by: Paperclip <noreply@paperclip.ing>
This commit is contained in:
Dotta
2026-04-20 10:34:21 -05:00
committed by GitHub
parent 0f4e4b4c10
commit 4357a3f352
14 changed files with 548 additions and 68 deletions
+1
View File
@@ -331,6 +331,7 @@ export type {
AgentWakeupRequest, AgentWakeupRequest,
InstanceSchedulerHeartbeatAgent, InstanceSchedulerHeartbeatAgent,
LiveEvent, LiveEvent,
DashboardRunActivityDay,
DashboardSummary, DashboardSummary,
ActivityEvent, ActivityEvent,
UserProfileActivitySummary, UserProfileActivitySummary,
+9
View File
@@ -1,3 +1,11 @@
export interface DashboardRunActivityDay {
date: string;
succeeded: number;
failed: number;
other: number;
total: number;
}
export interface DashboardSummary { export interface DashboardSummary {
companyId: string; companyId: string;
agents: { agents: {
@@ -24,4 +32,5 @@ export interface DashboardSummary {
pausedAgents: number; pausedAgents: number;
pausedProjects: number; pausedProjects: number;
}; };
runActivity: DashboardRunActivityDay[];
} }
+1 -1
View File
@@ -167,7 +167,7 @@ export type {
InstanceSchedulerHeartbeatAgent, InstanceSchedulerHeartbeatAgent,
} from "./heartbeat.js"; } from "./heartbeat.js";
export type { LiveEvent } from "./live.js"; export type { LiveEvent } from "./live.js";
export type { DashboardSummary } from "./dashboard.js"; export type { DashboardRunActivityDay, DashboardSummary } from "./dashboard.js";
export type { ActivityEvent } from "./activity.js"; export type { ActivityEvent } from "./activity.js";
export type { export type {
UserProfileActivitySummary, UserProfileActivitySummary,
@@ -0,0 +1,158 @@
import { randomUUID } from "node:crypto";
import { afterAll, afterEach, beforeAll, describe, expect, it } from "vitest";
import { agents, companies, createDb, heartbeatRuns } from "@paperclipai/db";
import {
getEmbeddedPostgresTestSupport,
startEmbeddedPostgresTestDatabase,
} from "./helpers/embedded-postgres.js";
import { dashboardService } from "../services/dashboard.ts";
const embeddedPostgresSupport = await getEmbeddedPostgresTestSupport();
const describeEmbeddedPostgres = embeddedPostgresSupport.supported ? describe : describe.skip;
if (!embeddedPostgresSupport.supported) {
console.warn(
`Skipping embedded Postgres dashboard service tests on this host: ${embeddedPostgresSupport.reason ?? "unsupported environment"}`,
);
}
function utcDay(offsetDays: number): Date {
const now = new Date();
const day = Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate() + offsetDays, 12);
return new Date(day);
}
function utcDateKey(date: Date): string {
return date.toISOString().slice(0, 10);
}
describeEmbeddedPostgres("dashboard service", () => {
let db!: ReturnType<typeof createDb>;
let tempDb: Awaited<ReturnType<typeof startEmbeddedPostgresTestDatabase>> | null = null;
beforeAll(async () => {
tempDb = await startEmbeddedPostgresTestDatabase("paperclip-dashboard-service-");
db = createDb(tempDb.connectionString);
}, 20_000);
afterEach(async () => {
await db.delete(heartbeatRuns);
await db.delete(agents);
await db.delete(companies);
});
afterAll(async () => {
await tempDb?.cleanup();
});
it("aggregates the full 14-day run activity window without recent-run truncation", async () => {
const companyId = randomUUID();
const otherCompanyId = randomUUID();
const agentId = randomUUID();
const otherAgentId = randomUUID();
const today = utcDay(0);
const weekAgo = utcDay(-7);
await db.insert(companies).values([
{
id: companyId,
name: "Paperclip",
issuePrefix: `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`,
requireBoardApprovalForNewAgents: false,
},
{
id: otherCompanyId,
name: "Other",
issuePrefix: `T${otherCompanyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`,
requireBoardApprovalForNewAgents: false,
},
]);
await db.insert(agents).values([
{
id: agentId,
companyId,
name: "CodexCoder",
role: "engineer",
status: "running",
adapterType: "codex_local",
adapterConfig: {},
runtimeConfig: {},
permissions: {},
},
{
id: otherAgentId,
companyId: otherCompanyId,
name: "OtherAgent",
role: "engineer",
status: "running",
adapterType: "codex_local",
adapterConfig: {},
runtimeConfig: {},
permissions: {},
},
]);
await db.insert(heartbeatRuns).values([
...Array.from({ length: 105 }, () => ({
id: randomUUID(),
companyId,
agentId,
invocationSource: "assignment",
status: "succeeded",
createdAt: today,
})),
{
id: randomUUID(),
companyId,
agentId,
invocationSource: "assignment",
status: "failed",
createdAt: weekAgo,
},
{
id: randomUUID(),
companyId,
agentId,
invocationSource: "assignment",
status: "timed_out",
createdAt: weekAgo,
},
{
id: randomUUID(),
companyId,
agentId,
invocationSource: "assignment",
status: "cancelled",
createdAt: weekAgo,
},
{
id: randomUUID(),
companyId: otherCompanyId,
agentId: otherAgentId,
invocationSource: "assignment",
status: "succeeded",
createdAt: weekAgo,
},
]);
const summary = await dashboardService(db).summary(companyId);
expect(summary.runActivity).toHaveLength(14);
const todayBucket = summary.runActivity.find((bucket) => bucket.date === utcDateKey(today));
const weekAgoBucket = summary.runActivity.find((bucket) => bucket.date === utcDateKey(weekAgo));
expect(todayBucket).toMatchObject({
succeeded: 105,
failed: 0,
other: 0,
total: 105,
});
expect(weekAgoBucket).toMatchObject({
succeeded: 0,
failed: 2,
other: 1,
total: 3,
});
});
});
+51 -2
View File
@@ -1,9 +1,23 @@
import { and, eq, gte, sql } from "drizzle-orm"; import { and, eq, gte, sql } from "drizzle-orm";
import type { Db } from "@paperclipai/db"; import type { Db } from "@paperclipai/db";
import { agents, approvals, companies, costEvents, issues } from "@paperclipai/db"; import { agents, approvals, companies, costEvents, heartbeatRuns, issues } from "@paperclipai/db";
import { notFound } from "../errors.js"; import { notFound } from "../errors.js";
import { budgetService } from "./budgets.js"; import { budgetService } from "./budgets.js";
const DASHBOARD_RUN_ACTIVITY_DAYS = 14;
function formatUtcDateKey(date: Date): string {
return date.toISOString().slice(0, 10);
}
function getRecentUtcDateKeys(now: Date, days: number): string[] {
const todayUtc = Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate());
return Array.from({ length: days }, (_, index) => {
const dayOffset = index - (days - 1);
return formatUtcDateKey(new Date(todayUtc + dayOffset * 24 * 60 * 60 * 1000));
});
}
export function dashboardService(db: Db) { export function dashboardService(db: Db) {
const budgets = budgetService(db); const budgets = budgetService(db);
return { return {
@@ -62,7 +76,9 @@ export function dashboardService(db: Db) {
} }
const now = new Date(); const now = new Date();
const monthStart = new Date(now.getFullYear(), now.getMonth(), 1); const monthStart = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), 1));
const runActivityDays = getRecentUtcDateKeys(now, DASHBOARD_RUN_ACTIVITY_DAYS);
const runActivityStart = new Date(`${runActivityDays[0]}T00:00:00.000Z`);
const [{ monthSpend }] = await db const [{ monthSpend }] = await db
.select({ .select({
monthSpend: sql<number>`coalesce(sum(${costEvents.costCents}), 0)::double precision`, monthSpend: sql<number>`coalesce(sum(${costEvents.costCents}), 0)::double precision`,
@@ -76,6 +92,38 @@ export function dashboardService(db: Db) {
); );
const monthSpendCents = Number(monthSpend); const monthSpendCents = Number(monthSpend);
const runActivityDayExpr = sql<string>`to_char(${heartbeatRuns.createdAt} at time zone 'UTC', 'YYYY-MM-DD')`;
const runActivityRows = await db
.select({
date: runActivityDayExpr,
status: heartbeatRuns.status,
count: sql<number>`count(*)::double precision`,
})
.from(heartbeatRuns)
.where(
and(
eq(heartbeatRuns.companyId, companyId),
gte(heartbeatRuns.createdAt, runActivityStart),
),
)
.groupBy(runActivityDayExpr, heartbeatRuns.status);
const runActivity = new Map(
runActivityDays.map((date) => [
date,
{ date, succeeded: 0, failed: 0, other: 0, total: 0 },
]),
);
for (const row of runActivityRows) {
const bucket = runActivity.get(row.date);
if (!bucket) continue;
const count = Number(row.count);
if (row.status === "succeeded") bucket.succeeded += count;
else if (row.status === "failed" || row.status === "timed_out") bucket.failed += count;
else bucket.other += count;
bucket.total += count;
}
const utilization = const utilization =
company.budgetMonthlyCents > 0 company.budgetMonthlyCents > 0
? (monthSpendCents / company.budgetMonthlyCents) * 100 ? (monthSpendCents / company.budgetMonthlyCents) * 100
@@ -103,6 +151,7 @@ export function dashboardService(db: Db) {
pausedAgents: budgetOverview.pausedAgentCount, pausedAgents: budgetOverview.pausedAgentCount,
pausedProjects: budgetOverview.pausedProjectCount, pausedProjects: budgetOverview.pausedProjectCount,
}, },
runActivity: Array.from(runActivity.values()),
}; };
}, },
}; };
+25 -8
View File
@@ -1,4 +1,4 @@
import { useMemo } from "react"; import { memo, useMemo } from "react";
import { Link } from "@/lib/router"; import { Link } from "@/lib/router";
import { useQuery } from "@tanstack/react-query"; import { useQuery } from "@tanstack/react-query";
import type { Issue } from "@paperclipai/shared"; import type { Issue } from "@paperclipai/shared";
@@ -13,6 +13,11 @@ import { RunChatSurface } from "./RunChatSurface";
import { useLiveRunTranscripts } from "./transcript/useLiveRunTranscripts"; import { useLiveRunTranscripts } from "./transcript/useLiveRunTranscripts";
const MIN_DASHBOARD_RUNS = 4; const MIN_DASHBOARD_RUNS = 4;
const DASHBOARD_RUN_CARD_LIMIT = 4;
const DASHBOARD_LOG_POLL_INTERVAL_MS = 15_000;
const DASHBOARD_LOG_READ_LIMIT_BYTES = 64_000;
const DASHBOARD_MAX_CHUNKS_PER_RUN = 40;
const EMPTY_TRANSCRIPT: TranscriptEntry[] = [];
function isRunActive(run: LiveRunForIssue): boolean { function isRunActive(run: LiveRunForIssue): boolean {
return run.status === "queued" || run.status === "running"; return run.status === "queued" || run.status === "running";
@@ -29,10 +34,12 @@ export function ActiveAgentsPanel({ companyId }: ActiveAgentsPanelProps) {
}); });
const runs = liveRuns ?? []; const runs = liveRuns ?? [];
const visibleRuns = useMemo(() => runs.slice(0, DASHBOARD_RUN_CARD_LIMIT), [runs]);
const hiddenRunCount = Math.max(0, runs.length - visibleRuns.length);
const { data: issues } = useQuery({ const { data: issues } = useQuery({
queryKey: [...queryKeys.issues.list(companyId), "with-routine-executions"], queryKey: [...queryKeys.issues.list(companyId), "with-routine-executions"],
queryFn: () => issuesApi.list(companyId, { includeRoutineExecutions: true }), queryFn: () => issuesApi.list(companyId, { includeRoutineExecutions: true }),
enabled: runs.length > 0, enabled: visibleRuns.length > 0,
}); });
const issueById = useMemo(() => { const issueById = useMemo(() => {
@@ -44,9 +51,12 @@ export function ActiveAgentsPanel({ companyId }: ActiveAgentsPanelProps) {
}, [issues]); }, [issues]);
const { transcriptByRun, hasOutputForRun } = useLiveRunTranscripts({ const { transcriptByRun, hasOutputForRun } = useLiveRunTranscripts({
runs, runs: visibleRuns,
companyId, companyId,
maxChunksPerRun: 120, maxChunksPerRun: DASHBOARD_MAX_CHUNKS_PER_RUN,
logPollIntervalMs: DASHBOARD_LOG_POLL_INTERVAL_MS,
logReadLimitBytes: DASHBOARD_LOG_READ_LIMIT_BYTES,
enableRealtimeUpdates: false,
}); });
return ( return (
@@ -60,24 +70,31 @@ export function ActiveAgentsPanel({ companyId }: ActiveAgentsPanelProps) {
</div> </div>
) : ( ) : (
<div className="grid grid-cols-1 gap-2 sm:grid-cols-2 sm:gap-4 xl:grid-cols-4"> <div className="grid grid-cols-1 gap-2 sm:grid-cols-2 sm:gap-4 xl:grid-cols-4">
{runs.map((run) => ( {visibleRuns.map((run) => (
<AgentRunCard <AgentRunCard
key={run.id} key={run.id}
companyId={companyId} companyId={companyId}
run={run} run={run}
issue={run.issueId ? issueById.get(run.issueId) : undefined} issue={run.issueId ? issueById.get(run.issueId) : undefined}
transcript={transcriptByRun.get(run.id) ?? []} transcript={transcriptByRun.get(run.id) ?? EMPTY_TRANSCRIPT}
hasOutput={hasOutputForRun(run.id)} hasOutput={hasOutputForRun(run.id)}
isActive={isRunActive(run)} isActive={isRunActive(run)}
/> />
))} ))}
</div> </div>
)} )}
{hiddenRunCount > 0 && (
<div className="mt-3 flex justify-end text-xs text-muted-foreground">
<Link to="/agents" className="hover:text-foreground hover:underline">
{hiddenRunCount} more active/recent run{hiddenRunCount === 1 ? "" : "s"}
</Link>
</div>
)}
</div> </div>
); );
} }
function AgentRunCard({ const AgentRunCard = memo(function AgentRunCard({
companyId, companyId,
run, run,
issue, issue,
@@ -153,4 +170,4 @@ function AgentRunCard({
</div> </div>
</div> </div>
); );
} });
+102
View File
@@ -0,0 +1,102 @@
// @vitest-environment jsdom
import { act } from "react";
import type { ReactNode } from "react";
import { createRoot, type Root } from "react-dom/client";
import type { HeartbeatRun } from "@paperclipai/shared";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { RunActivityChart, SuccessRateChart } from "./ActivityCharts";
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true;
let container: HTMLDivElement;
let root: Root;
beforeEach(() => {
vi.useFakeTimers();
vi.setSystemTime(new Date("2026-04-20T12:00:00.000Z"));
container = document.createElement("div");
document.body.appendChild(container);
root = createRoot(container);
});
afterEach(() => {
act(() => root.unmount());
container.remove();
vi.useRealTimers();
});
function render(ui: ReactNode) {
act(() => {
root.render(ui);
});
}
function createRun(overrides: Partial<HeartbeatRun> = {}): HeartbeatRun {
return {
id: "run-1",
companyId: "company-1",
agentId: "agent-1",
invocationSource: "on_demand",
triggerDetail: "manual",
status: "succeeded",
startedAt: new Date("2026-04-20T11:58:00.000Z"),
finishedAt: new Date("2026-04-20T11:59:00.000Z"),
error: null,
wakeupRequestId: null,
exitCode: 0,
signal: null,
usageJson: null,
resultJson: null,
sessionIdBefore: null,
sessionIdAfter: null,
logStore: null,
logRef: null,
logBytes: null,
logSha256: null,
logCompressed: false,
stdoutExcerpt: null,
stderrExcerpt: null,
errorCode: null,
externalRunId: null,
processPid: null,
processGroupId: null,
processStartedAt: null,
retryOfRunId: null,
processLossRetryCount: 0,
livenessState: null,
livenessReason: null,
continuationAttempt: 0,
lastUsefulActionAt: null,
nextAction: null,
contextSnapshot: null,
createdAt: new Date("2026-04-20T11:58:00.000Z"),
updatedAt: new Date("2026-04-20T11:59:00.000Z"),
...overrides,
};
}
describe("ActivityCharts", () => {
it("renders empty run charts when dashboard aggregate data is temporarily missing", () => {
render(<RunActivityChart activity={undefined} />);
expect(container.textContent).toContain("No runs yet");
render(<SuccessRateChart activity={undefined} />);
expect(container.textContent).toContain("No runs yet");
});
it("still aggregates raw agent runs for detail charts", () => {
render(
<RunActivityChart
runs={[
createRun({ id: "run-success", status: "succeeded" }),
createRun({ id: "run-failed", status: "failed" }),
]}
/>,
);
expect(container.textContent).not.toContain("No runs yet");
expect(container.querySelector("[title='2026-04-20: 2 runs']")).not.toBeNull();
});
});
+32 -22
View File
@@ -1,4 +1,4 @@
import type { HeartbeatRun } from "@paperclipai/shared"; import type { DashboardRunActivityDay, HeartbeatRun } from "@paperclipai/shared";
/* ---- Utilities ---- */ /* ---- Utilities ---- */
@@ -58,11 +58,14 @@ export function ChartCard({ title, subtitle, children }: { title: string; subtit
/* ---- Chart Components ---- */ /* ---- Chart Components ---- */
export function RunActivityChart({ runs }: { runs: HeartbeatRun[] }) { type RunChartProps =
const days = getLast14Days(); | { activity?: DashboardRunActivityDay[] | null; runs?: never }
| { runs?: HeartbeatRun[] | null; activity?: never };
const grouped = new Map<string, { succeeded: number; failed: number; other: number }>(); function aggregateRuns(runs: readonly HeartbeatRun[] = []): DashboardRunActivityDay[] {
for (const day of days) grouped.set(day, { succeeded: 0, failed: 0, other: 0 }); const days = getLast14Days();
const grouped = new Map<string, DashboardRunActivityDay>();
for (const day of days) grouped.set(day, { date: day, succeeded: 0, failed: 0, other: 0, total: 0 });
for (const run of runs) { for (const run of runs) {
const day = new Date(run.createdAt).toISOString().slice(0, 10); const day = new Date(run.createdAt).toISOString().slice(0, 10);
const entry = grouped.get(day); const entry = grouped.get(day);
@@ -70,10 +73,24 @@ export function RunActivityChart({ runs }: { runs: HeartbeatRun[] }) {
if (run.status === "succeeded") entry.succeeded++; if (run.status === "succeeded") entry.succeeded++;
else if (run.status === "failed" || run.status === "timed_out") entry.failed++; else if (run.status === "failed" || run.status === "timed_out") entry.failed++;
else entry.other++; else entry.other++;
entry.total++;
} }
return Array.from(grouped.values());
}
const maxValue = Math.max(...Array.from(grouped.values()).map(v => v.succeeded + v.failed + v.other), 1); function resolveRunActivity(props: RunChartProps): DashboardRunActivityDay[] {
const hasData = Array.from(grouped.values()).some(v => v.succeeded + v.failed + v.other > 0); if (Array.isArray(props.activity)) return props.activity;
if (Array.isArray(props.runs)) return aggregateRuns(props.runs);
return [];
}
export function RunActivityChart(props: RunChartProps) {
const activity = resolveRunActivity(props);
const days = activity.length > 0 ? activity.map((day) => day.date) : getLast14Days();
const grouped = new Map(activity.map((day) => [day.date, day]));
const maxValue = Math.max(...activity.map(v => v.total), 1);
const hasData = activity.some(v => v.total > 0);
if (!hasData) return <p className="text-xs text-muted-foreground">No runs yet</p>; if (!hasData) return <p className="text-xs text-muted-foreground">No runs yet</p>;
@@ -81,8 +98,8 @@ export function RunActivityChart({ runs }: { runs: HeartbeatRun[] }) {
<div> <div>
<div className="flex items-end gap-[3px] h-20"> <div className="flex items-end gap-[3px] h-20">
{days.map(day => { {days.map(day => {
const entry = grouped.get(day)!; const entry = grouped.get(day) ?? { date: day, succeeded: 0, failed: 0, other: 0, total: 0 };
const total = entry.succeeded + entry.failed + entry.other; const total = entry.total;
const heightPct = (total / maxValue) * 100; const heightPct = (total / maxValue) * 100;
return ( return (
<div key={day} className="flex-1 h-full flex flex-col justify-end" title={`${day}: ${total} runs`}> <div key={day} className="flex-1 h-full flex flex-col justify-end" title={`${day}: ${total} runs`}>
@@ -224,26 +241,19 @@ export function IssueStatusChart({ issues }: { issues: { status: string; created
); );
} }
export function SuccessRateChart({ runs }: { runs: HeartbeatRun[] }) { export function SuccessRateChart(props: RunChartProps) {
const days = getLast14Days(); const activity = resolveRunActivity(props);
const grouped = new Map<string, { succeeded: number; total: number }>(); const days = activity.length > 0 ? activity.map((day) => day.date) : getLast14Days();
for (const day of days) grouped.set(day, { succeeded: 0, total: 0 }); const grouped = new Map(activity.map((day) => [day.date, day]));
for (const run of runs) {
const day = new Date(run.createdAt).toISOString().slice(0, 10);
const entry = grouped.get(day);
if (!entry) continue;
entry.total++;
if (run.status === "succeeded") entry.succeeded++;
}
const hasData = Array.from(grouped.values()).some(v => v.total > 0); const hasData = activity.some(v => v.total > 0);
if (!hasData) return <p className="text-xs text-muted-foreground">No runs yet</p>; if (!hasData) return <p className="text-xs text-muted-foreground">No runs yet</p>;
return ( return (
<div> <div>
<div className="flex items-end gap-[3px] h-20"> <div className="flex items-end gap-[3px] h-20">
{days.map(day => { {days.map(day => {
const entry = grouped.get(day)!; const entry = grouped.get(day) ?? { date: day, succeeded: 0, failed: 0, other: 0, total: 0 };
const rate = entry.total > 0 ? entry.succeeded / entry.total : 0; const rate = entry.total > 0 ? entry.succeeded / entry.total : 0;
const color = entry.total === 0 ? undefined : rate >= 0.8 ? "#10b981" : rate >= 0.5 ? "#eab308" : "#ef4444"; const color = entry.total === 0 ? undefined : rate >= 0.8 ? "#10b981" : rate >= 0.5 ? "#eab308" : "#ef4444";
return ( return (
+2 -2
View File
@@ -1060,7 +1060,7 @@ function IssueChatUserMessage({ message }: { message: ThreadMessage }) {
userProfileMap, userProfileMap,
}); });
const authorAvatar = ( const authorAvatar = (
<Avatar size="sm" className="mt-1 shrink-0"> <Avatar size="sm" className="shrink-0">
{avatarUrl ? <AvatarImage src={avatarUrl} alt={resolvedAuthorName} /> : null} {avatarUrl ? <AvatarImage src={avatarUrl} alt={resolvedAuthorName} /> : null}
<AvatarFallback>{initialsForName(resolvedAuthorName)}</AvatarFallback> <AvatarFallback>{initialsForName(resolvedAuthorName)}</AvatarFallback>
</Avatar> </Avatar>
@@ -1248,7 +1248,7 @@ function IssueChatAssistantMessage({ message }: { message: ThreadMessage }) {
return ( return (
<div id={anchorId}> <div id={anchorId}>
<div className="flex items-start gap-2.5 py-1.5"> <div className="flex items-start gap-2.5 py-1.5">
<Avatar size="sm" className="mt-0.5 shrink-0"> <Avatar size="sm" className="shrink-0">
{agentIcon ? ( {agentIcon ? (
<AvatarFallback><AgentIcon icon={agentIcon} className="h-3.5 w-3.5" /></AvatarFallback> <AvatarFallback><AgentIcon icon={agentIcon} className="h-3.5 w-3.5" /></AvatarFallback>
) : ( ) : (
+14 -8
View File
@@ -1,9 +1,15 @@
import { useMemo } from "react"; import { memo, useMemo } from "react";
import type { TranscriptEntry } from "../adapters"; import type { TranscriptEntry } from "../adapters";
import type { LiveRunForIssue } from "../api/heartbeats"; import type { LiveRunForIssue } from "../api/heartbeats";
import { IssueChatThread } from "./IssueChatThread"; import { IssueChatThread } from "./IssueChatThread";
import type { IssueChatLinkedRun } from "../lib/issue-chat-messages"; import type { IssueChatLinkedRun } from "../lib/issue-chat-messages";
const EMPTY_COMMENTS: [] = [];
const EMPTY_TIMELINE_EVENTS: [] = [];
const EMPTY_LIVE_RUNS: [] = [];
const EMPTY_LINKED_RUNS: [] = [];
const handleEmbeddedAdd = async () => {};
function isRunActive(run: LiveRunForIssue) { function isRunActive(run: LiveRunForIssue) {
return run.status === "queued" || run.status === "running"; return run.status === "queued" || run.status === "running";
} }
@@ -15,18 +21,18 @@ interface RunChatSurfaceProps {
companyId?: string | null; companyId?: string | null;
} }
export function RunChatSurface({ export const RunChatSurface = memo(function RunChatSurface({
run, run,
transcript, transcript,
hasOutput, hasOutput,
companyId, companyId,
}: RunChatSurfaceProps) { }: RunChatSurfaceProps) {
const active = isRunActive(run); const active = isRunActive(run);
const liveRuns = active ? [run] : []; const liveRuns = useMemo(() => (active ? [run] : EMPTY_LIVE_RUNS), [active, run]);
const linkedRuns = useMemo<IssueChatLinkedRun[]>( const linkedRuns = useMemo<IssueChatLinkedRun[]>(
() => () =>
active active
? [] ? EMPTY_LINKED_RUNS
: [{ : [{
runId: run.id, runId: run.id,
status: run.status, status: run.status,
@@ -45,12 +51,12 @@ export function RunChatSurface({
return ( return (
<IssueChatThread <IssueChatThread
comments={[]} comments={EMPTY_COMMENTS}
linkedRuns={linkedRuns} linkedRuns={linkedRuns}
timelineEvents={[]} timelineEvents={EMPTY_TIMELINE_EVENTS}
liveRuns={liveRuns} liveRuns={liveRuns}
companyId={companyId} companyId={companyId}
onAdd={async () => {}} onAdd={handleEmbeddedAdd}
showComposer={false} showComposer={false}
showJumpToLatest={false} showJumpToLatest={false}
variant="embedded" variant="embedded"
@@ -61,4 +67,4 @@ export function RunChatSurface({
includeSucceededRunsWithoutOutput includeSucceededRunsWithoutOutput
/> />
); );
} });
@@ -6,9 +6,10 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { ApiError } from "../../api/client"; import { ApiError } from "../../api/client";
import { useLiveRunTranscripts } from "./useLiveRunTranscripts"; import { useLiveRunTranscripts } from "./useLiveRunTranscripts";
const { useQueryMock, logMock } = vi.hoisted(() => ({ const { useQueryMock, logMock, buildTranscriptMock } = vi.hoisted(() => ({
useQueryMock: vi.fn(() => ({ data: { censorUsernameInLogs: false } })), useQueryMock: vi.fn(() => ({ data: { censorUsernameInLogs: false } })),
logMock: vi.fn(async () => ({ runId: "run-1", store: "memory", logRef: "log-1", content: "", nextOffset: 0 })), logMock: vi.fn(async () => ({ runId: "run-1", store: "memory", logRef: "log-1", content: "", nextOffset: 0 })),
buildTranscriptMock: vi.fn((chunks: unknown[]) => chunks),
})); }));
vi.mock("@tanstack/react-query", () => ({ vi.mock("@tanstack/react-query", () => ({
@@ -28,7 +29,7 @@ vi.mock("../../api/heartbeats", () => ({
})); }));
vi.mock("../../adapters", () => ({ vi.mock("../../adapters", () => ({
buildTranscript: (chunks: unknown[]) => chunks, buildTranscript: buildTranscriptMock,
getUIAdapter: () => null, getUIAdapter: () => null,
onAdapterChange: () => () => {}, onAdapterChange: () => () => {},
})); }));
@@ -73,7 +74,9 @@ describe("useLiveRunTranscripts", () => {
beforeEach(() => { beforeEach(() => {
FakeWebSocket.instances = []; FakeWebSocket.instances = [];
useQueryMock.mockClear(); useQueryMock.mockClear();
logMock.mockClear(); logMock.mockReset();
logMock.mockImplementation(async () => ({ runId: "run-1", store: "memory", logRef: "log-1", content: "", nextOffset: 0 }));
buildTranscriptMock.mockClear();
globalThis.WebSocket = FakeWebSocket as unknown as typeof WebSocket; globalThis.WebSocket = FakeWebSocket as unknown as typeof WebSocket;
}); });
@@ -225,4 +228,91 @@ describe("useLiveRunTranscripts", () => {
}); });
container.remove(); container.remove();
}); });
it("can hydrate active runs without opening the live event socket", async () => {
function Harness() {
useLiveRunTranscripts({
companyId: "company-1",
runs: [{ id: "run-1", status: "running", adapterType: "codex_local" }],
enableRealtimeUpdates: false,
logReadLimitBytes: 64_000,
});
return null;
}
const container = document.createElement("div");
document.body.appendChild(container);
const root = createRoot(container);
await act(async () => {
root.render(<Harness />);
await Promise.resolve();
});
expect(FakeWebSocket.instances).toHaveLength(0);
expect(logMock).toHaveBeenCalledWith("run-1", 0, 64_000);
act(() => {
root.unmount();
});
container.remove();
});
it("rebuilds only the transcript for the run that receives live output", async () => {
function Harness() {
useLiveRunTranscripts({
companyId: "company-1",
runs: [
{ id: "run-1", status: "running", adapterType: "codex_local" },
{ id: "run-2", status: "running", adapterType: "codex_local" },
],
});
return null;
}
const container = document.createElement("div");
document.body.appendChild(container);
const root = createRoot(container);
await act(async () => {
root.render(<Harness />);
await Promise.resolve();
await Promise.resolve();
});
expect(FakeWebSocket.instances).toHaveLength(1);
expect(buildTranscriptMock).toHaveBeenCalledTimes(2);
buildTranscriptMock.mockClear();
await act(async () => {
FakeWebSocket.instances[0]!.onmessage?.(
new MessageEvent("message", {
data: JSON.stringify({
companyId: "company-1",
type: "heartbeat.run.log",
createdAt: "2026-04-20T00:00:00.000Z",
payload: {
runId: "run-1",
ts: "2026-04-20T00:00:00.000Z",
stream: "stdout",
chunk: "hello from run 1\n",
},
}),
}),
);
await Promise.resolve();
});
expect(buildTranscriptMock).toHaveBeenCalledTimes(1);
expect(buildTranscriptMock).toHaveBeenCalledWith(
[{ ts: "2026-04-20T00:00:00.000Z", stream: "stdout", chunk: "hello from run 1\n" }],
null,
{ censorUsernameInLogs: false },
);
act(() => {
root.unmount();
});
container.remove();
});
}); });
@@ -9,6 +9,7 @@ import { queryKeys } from "../../lib/queryKeys";
const LOG_POLL_INTERVAL_MS = 2000; const LOG_POLL_INTERVAL_MS = 2000;
const LOG_READ_LIMIT_BYTES = 256_000; const LOG_READ_LIMIT_BYTES = 256_000;
const EMPTY_RUN_LOG_CHUNKS: RunLogChunk[] = [];
export interface RunTranscriptSource { export interface RunTranscriptSource {
id: string; id: string;
@@ -21,6 +22,9 @@ interface UseLiveRunTranscriptsOptions {
runs: RunTranscriptSource[]; runs: RunTranscriptSource[];
companyId?: string | null; companyId?: string | null;
maxChunksPerRun?: number; maxChunksPerRun?: number;
logPollIntervalMs?: number;
logReadLimitBytes?: number;
enableRealtimeUpdates?: boolean;
} }
function readString(value: unknown): string | null { function readString(value: unknown): string | null {
@@ -71,6 +75,9 @@ export function useLiveRunTranscripts({
runs, runs,
companyId, companyId,
maxChunksPerRun = 200, maxChunksPerRun = 200,
logPollIntervalMs = LOG_POLL_INTERVAL_MS,
logReadLimitBytes = LOG_READ_LIMIT_BYTES,
enableRealtimeUpdates = true,
}: UseLiveRunTranscriptsOptions) { }: UseLiveRunTranscriptsOptions) {
const runsKey = useMemo( const runsKey = useMemo(
() => () =>
@@ -87,6 +94,13 @@ export function useLiveRunTranscripts({
const pendingLogRowsByRunRef = useRef(new Map<string, string>()); const pendingLogRowsByRunRef = useRef(new Map<string, string>());
const logOffsetByRunRef = useRef(new Map<string, number>()); const logOffsetByRunRef = useRef(new Map<string, number>());
const missingTerminalLogRunIdsRef = useRef(new Set<string>()); const missingTerminalLogRunIdsRef = useRef(new Set<string>());
const transcriptCacheRef = useRef(new Map<string, {
adapterType: string;
chunks: RunLogChunk[];
censorUsernameInLogs: boolean;
parserTick: number;
transcript: TranscriptEntry[];
}>());
// Tick counter to force transcript recomputation when dynamic parser loads // Tick counter to force transcript recomputation when dynamic parser loads
const [parserTick, setParserTick] = useState(0); const [parserTick, setParserTick] = useState(0);
useEffect(() => { useEffect(() => {
@@ -167,6 +181,11 @@ export function useLiveRunTranscripts({
missingTerminalLogRunIdsRef.current.delete(runId); missingTerminalLogRunIdsRef.current.delete(runId);
} }
} }
for (const runId of transcriptCacheRef.current.keys()) {
if (!knownRunIds.has(runId)) {
transcriptCacheRef.current.delete(runId);
}
}
}, [normalizedRuns]); }, [normalizedRuns]);
useEffect(() => { useEffect(() => {
@@ -180,7 +199,7 @@ export function useLiveRunTranscripts({
} }
const offset = logOffsetByRunRef.current.get(run.id) ?? 0; const offset = logOffsetByRunRef.current.get(run.id) ?? 0;
try { try {
const result = await heartbeatsApi.log(run.id, offset, LOG_READ_LIMIT_BYTES); const result = await heartbeatsApi.log(run.id, offset, logReadLimitBytes);
if (cancelled) return; if (cancelled) return;
appendChunks(run.id, parsePersistedLogContent(run.id, result.content, pendingLogRowsByRunRef.current)); appendChunks(run.id, parsePersistedLogContent(run.id, result.content, pendingLogRowsByRunRef.current));
@@ -214,19 +233,20 @@ export function useLiveRunTranscripts({
void readAll(); void readAll();
const activeRuns = normalizedRuns.filter((run) => !isTerminalStatus(run.status)); const activeRuns = normalizedRuns.filter((run) => !isTerminalStatus(run.status));
const interval = activeRuns.length > 0 const interval = activeRuns.length > 0 && logPollIntervalMs > 0
? window.setInterval(() => { ? window.setInterval(() => {
void Promise.all(activeRuns.map((run) => readRunLog(run))); void Promise.all(activeRuns.map((run) => readRunLog(run)));
}, LOG_POLL_INTERVAL_MS) }, logPollIntervalMs)
: null; : null;
return () => { return () => {
cancelled = true; cancelled = true;
if (interval !== null) window.clearInterval(interval); if (interval !== null) window.clearInterval(interval);
}; };
}, [normalizedRuns, runIdsKey]); }, [logPollIntervalMs, logReadLimitBytes, normalizedRuns, runIdsKey]);
useEffect(() => { useEffect(() => {
if (!enableRealtimeUpdates) return;
if (!companyId || activeRunIds.size === 0) return; if (!companyId || activeRunIds.size === 0) return;
let closed = false; let closed = false;
@@ -334,19 +354,45 @@ export function useLiveRunTranscripts({
} }
} }
}; };
}, [activeRunIds, companyId, runById]); }, [activeRunIds, companyId, enableRealtimeUpdates, runById]);
const transcriptByRun = useMemo(() => { const transcriptByRun = useMemo(() => {
const next = new Map<string, TranscriptEntry[]>(); const next = new Map<string, TranscriptEntry[]>();
const censorUsernameInLogs = generalSettings?.censorUsernameInLogs === true; const censorUsernameInLogs = generalSettings?.censorUsernameInLogs === true;
const cache = transcriptCacheRef.current;
const currentRunIds = new Set<string>();
for (const run of normalizedRuns) { for (const run of normalizedRuns) {
currentRunIds.add(run.id);
const chunks = chunksByRun.get(run.id) ?? EMPTY_RUN_LOG_CHUNKS;
const cached = cache.get(run.id);
if (
cached &&
cached.adapterType === run.adapterType &&
cached.chunks === chunks &&
cached.censorUsernameInLogs === censorUsernameInLogs &&
cached.parserTick === parserTick
) {
next.set(run.id, cached.transcript);
continue;
}
const adapter = getUIAdapter(run.adapterType); const adapter = getUIAdapter(run.adapterType);
next.set( const transcript = buildTranscript(chunks, adapter, {
run.id, censorUsernameInLogs,
buildTranscript(chunksByRun.get(run.id) ?? [], adapter, { });
censorUsernameInLogs, cache.set(run.id, {
}), adapterType: run.adapterType,
); chunks,
censorUsernameInLogs,
parserTick,
transcript,
});
next.set(run.id, transcript);
}
for (const runId of cache.keys()) {
if (!currentRunIds.has(runId)) {
cache.delete(runId);
}
} }
return next; return next;
}, [chunksByRun, generalSettings?.censorUsernameInLogs, normalizedRuns, parserTick]); }, [chunksByRun, generalSettings?.censorUsernameInLogs, normalizedRuns, parserTick]);
+1
View File
@@ -295,6 +295,7 @@ const dashboard: DashboardSummary = {
pausedAgents: 0, pausedAgents: 0,
pausedProjects: 0, pausedProjects: 0,
}, },
runActivity: [],
}; };
describe("inbox helpers", () => { describe("inbox helpers", () => {
+2 -11
View File
@@ -7,7 +7,6 @@ import { accessApi } from "../api/access";
import { issuesApi } from "../api/issues"; import { issuesApi } from "../api/issues";
import { agentsApi } from "../api/agents"; import { agentsApi } from "../api/agents";
import { projectsApi } from "../api/projects"; import { projectsApi } from "../api/projects";
import { heartbeatsApi } from "../api/heartbeats";
import { buildCompanyUserProfileMap } from "../lib/company-members"; import { buildCompanyUserProfileMap } from "../lib/company-members";
import { useCompany } from "../context/CompanyContext"; import { useCompany } from "../context/CompanyContext";
import { useDialog } from "../context/DialogContext"; import { useDialog } from "../context/DialogContext";
@@ -28,8 +27,6 @@ import { PageSkeleton } from "../components/PageSkeleton";
import type { Agent, Issue } from "@paperclipai/shared"; import type { Agent, Issue } from "@paperclipai/shared";
import { PluginSlotOutlet } from "@/plugins/slots"; import { PluginSlotOutlet } from "@/plugins/slots";
const DASHBOARD_HEARTBEAT_RUN_LIMIT = 100;
function getRecentIssues(issues: Issue[]): Issue[] { function getRecentIssues(issues: Issue[]): Issue[] {
return [...issues] return [...issues]
.sort((a, b) => new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime()); .sort((a, b) => new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime());
@@ -78,12 +75,6 @@ export function Dashboard() {
enabled: !!selectedCompanyId, enabled: !!selectedCompanyId,
}); });
const { data: runs } = useQuery({
queryKey: [...queryKeys.heartbeats(selectedCompanyId!), "limit", DASHBOARD_HEARTBEAT_RUN_LIMIT],
queryFn: () => heartbeatsApi.list(selectedCompanyId!, undefined, DASHBOARD_HEARTBEAT_RUN_LIMIT),
enabled: !!selectedCompanyId,
});
const { data: companyMembers } = useQuery({ const { data: companyMembers } = useQuery({
queryKey: queryKeys.access.companyUserDirectory(selectedCompanyId!), queryKey: queryKeys.access.companyUserDirectory(selectedCompanyId!),
queryFn: () => accessApi.listUserDirectory(selectedCompanyId!), queryFn: () => accessApi.listUserDirectory(selectedCompanyId!),
@@ -300,7 +291,7 @@ export function Dashboard() {
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4"> <div className="grid grid-cols-2 lg:grid-cols-4 gap-4">
<ChartCard title="Run Activity" subtitle="Last 14 days"> <ChartCard title="Run Activity" subtitle="Last 14 days">
<RunActivityChart runs={runs ?? []} /> <RunActivityChart activity={data.runActivity} />
</ChartCard> </ChartCard>
<ChartCard title="Issues by Priority" subtitle="Last 14 days"> <ChartCard title="Issues by Priority" subtitle="Last 14 days">
<PriorityChart issues={issues ?? []} /> <PriorityChart issues={issues ?? []} />
@@ -309,7 +300,7 @@ export function Dashboard() {
<IssueStatusChart issues={issues ?? []} /> <IssueStatusChart issues={issues ?? []} />
</ChartCard> </ChartCard>
<ChartCard title="Success Rate" subtitle="Last 14 days"> <ChartCard title="Success Rate" subtitle="Last 14 days">
<SuccessRateChart runs={runs ?? []} /> <SuccessRateChart activity={data.runActivity} />
</ChartCard> </ChartCard>
</div> </div>