mirror of
https://github.com/paperclipai/paperclip
synced 2026-04-25 17:25:15 +02:00
[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:
@@ -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>
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
102
ui/src/components/ActivityCharts.test.tsx
Normal file
102
ui/src/components/ActivityCharts.test.tsx
Normal 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();
|
||||
});
|
||||
});
|
||||
@@ -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 (
|
||||
|
||||
@@ -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>
|
||||
) : (
|
||||
|
||||
@@ -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
|
||||
/>
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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]);
|
||||
|
||||
@@ -295,6 +295,7 @@ const dashboard: DashboardSummary = {
|
||||
pausedAgents: 0,
|
||||
pausedProjects: 0,
|
||||
},
|
||||
runActivity: [],
|
||||
};
|
||||
|
||||
describe("inbox helpers", () => {
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user