[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

View File

@@ -1,4 +1,4 @@
import { useMemo } from "react";
import { memo, useMemo } from "react";
import { Link } from "@/lib/router";
import { useQuery } from "@tanstack/react-query";
import type { Issue } from "@paperclipai/shared";
@@ -13,6 +13,11 @@ import { RunChatSurface } from "./RunChatSurface";
import { useLiveRunTranscripts } from "./transcript/useLiveRunTranscripts";
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 {
return run.status === "queued" || run.status === "running";
@@ -29,10 +34,12 @@ export function ActiveAgentsPanel({ companyId }: ActiveAgentsPanelProps) {
});
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({
queryKey: [...queryKeys.issues.list(companyId), "with-routine-executions"],
queryFn: () => issuesApi.list(companyId, { includeRoutineExecutions: true }),
enabled: runs.length > 0,
enabled: visibleRuns.length > 0,
});
const issueById = useMemo(() => {
@@ -44,9 +51,12 @@ export function ActiveAgentsPanel({ companyId }: ActiveAgentsPanelProps) {
}, [issues]);
const { transcriptByRun, hasOutputForRun } = useLiveRunTranscripts({
runs,
runs: visibleRuns,
companyId,
maxChunksPerRun: 120,
maxChunksPerRun: DASHBOARD_MAX_CHUNKS_PER_RUN,
logPollIntervalMs: DASHBOARD_LOG_POLL_INTERVAL_MS,
logReadLimitBytes: DASHBOARD_LOG_READ_LIMIT_BYTES,
enableRealtimeUpdates: false,
});
return (
@@ -60,24 +70,31 @@ export function ActiveAgentsPanel({ companyId }: ActiveAgentsPanelProps) {
</div>
) : (
<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
key={run.id}
companyId={companyId}
run={run}
issue={run.issueId ? issueById.get(run.issueId) : undefined}
transcript={transcriptByRun.get(run.id) ?? []}
transcript={transcriptByRun.get(run.id) ?? EMPTY_TRANSCRIPT}
hasOutput={hasOutputForRun(run.id)}
isActive={isRunActive(run)}
/>
))}
</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>
);
}
function AgentRunCard({
const AgentRunCard = memo(function AgentRunCard({
companyId,
run,
issue,
@@ -153,4 +170,4 @@ function AgentRunCard({
</div>
</div>
);
}
});

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();
});
});

View File

@@ -1,4 +1,4 @@
import type { HeartbeatRun } from "@paperclipai/shared";
import type { DashboardRunActivityDay, HeartbeatRun } from "@paperclipai/shared";
/* ---- Utilities ---- */
@@ -58,11 +58,14 @@ export function ChartCard({ title, subtitle, children }: { title: string; subtit
/* ---- Chart Components ---- */
export function RunActivityChart({ runs }: { runs: HeartbeatRun[] }) {
const days = getLast14Days();
type RunChartProps =
| { activity?: DashboardRunActivityDay[] | null; runs?: never }
| { runs?: HeartbeatRun[] | null; activity?: never };
const grouped = new Map<string, { succeeded: number; failed: number; other: number }>();
for (const day of days) grouped.set(day, { succeeded: 0, failed: 0, other: 0 });
function aggregateRuns(runs: readonly HeartbeatRun[] = []): DashboardRunActivityDay[] {
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) {
const day = new Date(run.createdAt).toISOString().slice(0, 10);
const entry = grouped.get(day);
@@ -70,10 +73,24 @@ export function RunActivityChart({ runs }: { runs: HeartbeatRun[] }) {
if (run.status === "succeeded") entry.succeeded++;
else if (run.status === "failed" || run.status === "timed_out") entry.failed++;
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);
const hasData = Array.from(grouped.values()).some(v => v.succeeded + v.failed + v.other > 0);
function resolveRunActivity(props: RunChartProps): DashboardRunActivityDay[] {
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>;
@@ -81,8 +98,8 @@ export function RunActivityChart({ runs }: { runs: HeartbeatRun[] }) {
<div>
<div className="flex items-end gap-[3px] h-20">
{days.map(day => {
const entry = grouped.get(day)!;
const total = entry.succeeded + entry.failed + entry.other;
const entry = grouped.get(day) ?? { date: day, succeeded: 0, failed: 0, other: 0, total: 0 };
const total = entry.total;
const heightPct = (total / maxValue) * 100;
return (
<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[] }) {
const days = getLast14Days();
const grouped = new Map<string, { succeeded: number; total: number }>();
for (const day of days) grouped.set(day, { succeeded: 0, total: 0 });
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++;
}
export function SuccessRateChart(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 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>;
return (
<div>
<div className="flex items-end gap-[3px] h-20">
{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 color = entry.total === 0 ? undefined : rate >= 0.8 ? "#10b981" : rate >= 0.5 ? "#eab308" : "#ef4444";
return (

View File

@@ -1060,7 +1060,7 @@ function IssueChatUserMessage({ message }: { message: ThreadMessage }) {
userProfileMap,
});
const authorAvatar = (
<Avatar size="sm" className="mt-1 shrink-0">
<Avatar size="sm" className="shrink-0">
{avatarUrl ? <AvatarImage src={avatarUrl} alt={resolvedAuthorName} /> : null}
<AvatarFallback>{initialsForName(resolvedAuthorName)}</AvatarFallback>
</Avatar>
@@ -1248,7 +1248,7 @@ function IssueChatAssistantMessage({ message }: { message: ThreadMessage }) {
return (
<div id={anchorId}>
<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 ? (
<AvatarFallback><AgentIcon icon={agentIcon} className="h-3.5 w-3.5" /></AvatarFallback>
) : (

View File

@@ -1,9 +1,15 @@
import { useMemo } from "react";
import { memo, useMemo } from "react";
import type { TranscriptEntry } from "../adapters";
import type { LiveRunForIssue } from "../api/heartbeats";
import { IssueChatThread } from "./IssueChatThread";
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) {
return run.status === "queued" || run.status === "running";
}
@@ -15,18 +21,18 @@ interface RunChatSurfaceProps {
companyId?: string | null;
}
export function RunChatSurface({
export const RunChatSurface = memo(function RunChatSurface({
run,
transcript,
hasOutput,
companyId,
}: RunChatSurfaceProps) {
const active = isRunActive(run);
const liveRuns = active ? [run] : [];
const liveRuns = useMemo(() => (active ? [run] : EMPTY_LIVE_RUNS), [active, run]);
const linkedRuns = useMemo<IssueChatLinkedRun[]>(
() =>
active
? []
? EMPTY_LINKED_RUNS
: [{
runId: run.id,
status: run.status,
@@ -45,12 +51,12 @@ export function RunChatSurface({
return (
<IssueChatThread
comments={[]}
comments={EMPTY_COMMENTS}
linkedRuns={linkedRuns}
timelineEvents={[]}
timelineEvents={EMPTY_TIMELINE_EVENTS}
liveRuns={liveRuns}
companyId={companyId}
onAdd={async () => {}}
onAdd={handleEmbeddedAdd}
showComposer={false}
showJumpToLatest={false}
variant="embedded"
@@ -61,4 +67,4 @@ export function RunChatSurface({
includeSucceededRunsWithoutOutput
/>
);
}
});

View File

@@ -6,9 +6,10 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { ApiError } from "../../api/client";
import { useLiveRunTranscripts } from "./useLiveRunTranscripts";
const { useQueryMock, logMock } = vi.hoisted(() => ({
const { useQueryMock, logMock, buildTranscriptMock } = vi.hoisted(() => ({
useQueryMock: vi.fn(() => ({ data: { censorUsernameInLogs: false } })),
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", () => ({
@@ -28,7 +29,7 @@ vi.mock("../../api/heartbeats", () => ({
}));
vi.mock("../../adapters", () => ({
buildTranscript: (chunks: unknown[]) => chunks,
buildTranscript: buildTranscriptMock,
getUIAdapter: () => null,
onAdapterChange: () => () => {},
}));
@@ -73,7 +74,9 @@ describe("useLiveRunTranscripts", () => {
beforeEach(() => {
FakeWebSocket.instances = [];
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;
});
@@ -225,4 +228,91 @@ describe("useLiveRunTranscripts", () => {
});
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();
});
});

View File

@@ -9,6 +9,7 @@ import { queryKeys } from "../../lib/queryKeys";
const LOG_POLL_INTERVAL_MS = 2000;
const LOG_READ_LIMIT_BYTES = 256_000;
const EMPTY_RUN_LOG_CHUNKS: RunLogChunk[] = [];
export interface RunTranscriptSource {
id: string;
@@ -21,6 +22,9 @@ interface UseLiveRunTranscriptsOptions {
runs: RunTranscriptSource[];
companyId?: string | null;
maxChunksPerRun?: number;
logPollIntervalMs?: number;
logReadLimitBytes?: number;
enableRealtimeUpdates?: boolean;
}
function readString(value: unknown): string | null {
@@ -71,6 +75,9 @@ export function useLiveRunTranscripts({
runs,
companyId,
maxChunksPerRun = 200,
logPollIntervalMs = LOG_POLL_INTERVAL_MS,
logReadLimitBytes = LOG_READ_LIMIT_BYTES,
enableRealtimeUpdates = true,
}: UseLiveRunTranscriptsOptions) {
const runsKey = useMemo(
() =>
@@ -87,6 +94,13 @@ export function useLiveRunTranscripts({
const pendingLogRowsByRunRef = useRef(new Map<string, string>());
const logOffsetByRunRef = useRef(new Map<string, number>());
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
const [parserTick, setParserTick] = useState(0);
useEffect(() => {
@@ -167,6 +181,11 @@ export function useLiveRunTranscripts({
missingTerminalLogRunIdsRef.current.delete(runId);
}
}
for (const runId of transcriptCacheRef.current.keys()) {
if (!knownRunIds.has(runId)) {
transcriptCacheRef.current.delete(runId);
}
}
}, [normalizedRuns]);
useEffect(() => {
@@ -180,7 +199,7 @@ export function useLiveRunTranscripts({
}
const offset = logOffsetByRunRef.current.get(run.id) ?? 0;
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;
appendChunks(run.id, parsePersistedLogContent(run.id, result.content, pendingLogRowsByRunRef.current));
@@ -214,19 +233,20 @@ export function useLiveRunTranscripts({
void readAll();
const activeRuns = normalizedRuns.filter((run) => !isTerminalStatus(run.status));
const interval = activeRuns.length > 0
const interval = activeRuns.length > 0 && logPollIntervalMs > 0
? window.setInterval(() => {
void Promise.all(activeRuns.map((run) => readRunLog(run)));
}, LOG_POLL_INTERVAL_MS)
}, logPollIntervalMs)
: null;
return () => {
cancelled = true;
if (interval !== null) window.clearInterval(interval);
};
}, [normalizedRuns, runIdsKey]);
}, [logPollIntervalMs, logReadLimitBytes, normalizedRuns, runIdsKey]);
useEffect(() => {
if (!enableRealtimeUpdates) return;
if (!companyId || activeRunIds.size === 0) return;
let closed = false;
@@ -334,19 +354,45 @@ export function useLiveRunTranscripts({
}
}
};
}, [activeRunIds, companyId, runById]);
}, [activeRunIds, companyId, enableRealtimeUpdates, runById]);
const transcriptByRun = useMemo(() => {
const next = new Map<string, TranscriptEntry[]>();
const censorUsernameInLogs = generalSettings?.censorUsernameInLogs === true;
const cache = transcriptCacheRef.current;
const currentRunIds = new Set<string>();
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);
next.set(
run.id,
buildTranscript(chunksByRun.get(run.id) ?? [], adapter, {
censorUsernameInLogs,
}),
);
const transcript = buildTranscript(chunks, 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;
}, [chunksByRun, generalSettings?.censorUsernameInLogs, normalizedRuns, parserTick]);

View File

@@ -295,6 +295,7 @@ const dashboard: DashboardSummary = {
pausedAgents: 0,
pausedProjects: 0,
},
runActivity: [],
};
describe("inbox helpers", () => {

View File

@@ -7,7 +7,6 @@ import { accessApi } from "../api/access";
import { issuesApi } from "../api/issues";
import { agentsApi } from "../api/agents";
import { projectsApi } from "../api/projects";
import { heartbeatsApi } from "../api/heartbeats";
import { buildCompanyUserProfileMap } from "../lib/company-members";
import { useCompany } from "../context/CompanyContext";
import { useDialog } from "../context/DialogContext";
@@ -28,8 +27,6 @@ import { PageSkeleton } from "../components/PageSkeleton";
import type { Agent, Issue } from "@paperclipai/shared";
import { PluginSlotOutlet } from "@/plugins/slots";
const DASHBOARD_HEARTBEAT_RUN_LIMIT = 100;
function getRecentIssues(issues: Issue[]): Issue[] {
return [...issues]
.sort((a, b) => new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime());
@@ -78,12 +75,6 @@ export function Dashboard() {
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({
queryKey: queryKeys.access.companyUserDirectory(selectedCompanyId!),
queryFn: () => accessApi.listUserDirectory(selectedCompanyId!),
@@ -300,7 +291,7 @@ export function Dashboard() {
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4">
<ChartCard title="Run Activity" subtitle="Last 14 days">
<RunActivityChart runs={runs ?? []} />
<RunActivityChart activity={data.runActivity} />
</ChartCard>
<ChartCard title="Issues by Priority" subtitle="Last 14 days">
<PriorityChart issues={issues ?? []} />
@@ -309,7 +300,7 @@ export function Dashboard() {
<IssueStatusChart issues={issues ?? []} />
</ChartCard>
<ChartCard title="Success Rate" subtitle="Last 14 days">
<SuccessRateChart runs={runs ?? []} />
<SuccessRateChart activity={data.runActivity} />
</ChartCard>
</div>