diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index bddbb769..d2990056 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -331,6 +331,7 @@ export type { AgentWakeupRequest, InstanceSchedulerHeartbeatAgent, LiveEvent, + DashboardRunActivityDay, DashboardSummary, ActivityEvent, UserProfileActivitySummary, diff --git a/packages/shared/src/types/dashboard.ts b/packages/shared/src/types/dashboard.ts index 0127a4fd..e4225f43 100644 --- a/packages/shared/src/types/dashboard.ts +++ b/packages/shared/src/types/dashboard.ts @@ -1,3 +1,11 @@ +export interface DashboardRunActivityDay { + date: string; + succeeded: number; + failed: number; + other: number; + total: number; +} + export interface DashboardSummary { companyId: string; agents: { @@ -24,4 +32,5 @@ export interface DashboardSummary { pausedAgents: number; pausedProjects: number; }; + runActivity: DashboardRunActivityDay[]; } diff --git a/packages/shared/src/types/index.ts b/packages/shared/src/types/index.ts index 971bf14f..97d20d01 100644 --- a/packages/shared/src/types/index.ts +++ b/packages/shared/src/types/index.ts @@ -167,7 +167,7 @@ export type { InstanceSchedulerHeartbeatAgent, } from "./heartbeat.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 { UserProfileActivitySummary, diff --git a/server/src/__tests__/dashboard-service.test.ts b/server/src/__tests__/dashboard-service.test.ts new file mode 100644 index 00000000..ad2934fa --- /dev/null +++ b/server/src/__tests__/dashboard-service.test.ts @@ -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; + let tempDb: Awaited> | 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, + }); + }); +}); diff --git a/server/src/services/dashboard.ts b/server/src/services/dashboard.ts index c1169aa9..e02ca96d 100644 --- a/server/src/services/dashboard.ts +++ b/server/src/services/dashboard.ts @@ -1,9 +1,23 @@ import { and, eq, gte, sql } from "drizzle-orm"; 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 { 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) { const budgets = budgetService(db); return { @@ -62,7 +76,9 @@ export function dashboardService(db: Db) { } 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 .select({ monthSpend: sql`coalesce(sum(${costEvents.costCents}), 0)::double precision`, @@ -76,6 +92,38 @@ export function dashboardService(db: Db) { ); const monthSpendCents = Number(monthSpend); + const runActivityDayExpr = sql`to_char(${heartbeatRuns.createdAt} at time zone 'UTC', 'YYYY-MM-DD')`; + const runActivityRows = await db + .select({ + date: runActivityDayExpr, + status: heartbeatRuns.status, + count: sql`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 = company.budgetMonthlyCents > 0 ? (monthSpendCents / company.budgetMonthlyCents) * 100 @@ -103,6 +151,7 @@ export function dashboardService(db: Db) { pausedAgents: budgetOverview.pausedAgentCount, pausedProjects: budgetOverview.pausedProjectCount, }, + runActivity: Array.from(runActivity.values()), }; }, }; diff --git a/ui/src/components/ActiveAgentsPanel.tsx b/ui/src/components/ActiveAgentsPanel.tsx index feca6161..5a5534f9 100644 --- a/ui/src/components/ActiveAgentsPanel.tsx +++ b/ui/src/components/ActiveAgentsPanel.tsx @@ -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) { ) : (
- {runs.map((run) => ( + {visibleRuns.map((run) => ( ))}
)} + {hiddenRunCount > 0 && ( +
+ + {hiddenRunCount} more active/recent run{hiddenRunCount === 1 ? "" : "s"} + +
+ )} ); } -function AgentRunCard({ +const AgentRunCard = memo(function AgentRunCard({ companyId, run, issue, @@ -153,4 +170,4 @@ function AgentRunCard({ ); -} +}); diff --git a/ui/src/components/ActivityCharts.test.tsx b/ui/src/components/ActivityCharts.test.tsx new file mode 100644 index 00000000..7eaf7218 --- /dev/null +++ b/ui/src/components/ActivityCharts.test.tsx @@ -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 { + 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(); + expect(container.textContent).toContain("No runs yet"); + + render(); + expect(container.textContent).toContain("No runs yet"); + }); + + it("still aggregates raw agent runs for detail charts", () => { + render( + , + ); + + expect(container.textContent).not.toContain("No runs yet"); + expect(container.querySelector("[title='2026-04-20: 2 runs']")).not.toBeNull(); + }); +}); diff --git a/ui/src/components/ActivityCharts.tsx b/ui/src/components/ActivityCharts.tsx index f72e4e57..591bcc4e 100644 --- a/ui/src/components/ActivityCharts.tsx +++ b/ui/src/components/ActivityCharts.tsx @@ -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(); - 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(); + 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

No runs yet

; @@ -81,8 +98,8 @@ export function RunActivityChart({ runs }: { runs: HeartbeatRun[] }) {
{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 (
@@ -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(); - 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

No runs yet

; return (
{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 ( diff --git a/ui/src/components/IssueChatThread.tsx b/ui/src/components/IssueChatThread.tsx index 13e494e6..b81fa2ed 100644 --- a/ui/src/components/IssueChatThread.tsx +++ b/ui/src/components/IssueChatThread.tsx @@ -1060,7 +1060,7 @@ function IssueChatUserMessage({ message }: { message: ThreadMessage }) { userProfileMap, }); const authorAvatar = ( - + {avatarUrl ? : null} {initialsForName(resolvedAuthorName)} @@ -1248,7 +1248,7 @@ function IssueChatAssistantMessage({ message }: { message: ThreadMessage }) { return (
- + {agentIcon ? ( ) : ( diff --git a/ui/src/components/RunChatSurface.tsx b/ui/src/components/RunChatSurface.tsx index 71f6b2c1..66371b39 100644 --- a/ui/src/components/RunChatSurface.tsx +++ b/ui/src/components/RunChatSurface.tsx @@ -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( () => active - ? [] + ? EMPTY_LINKED_RUNS : [{ runId: run.id, status: run.status, @@ -45,12 +51,12 @@ export function RunChatSurface({ return ( {}} + onAdd={handleEmbeddedAdd} showComposer={false} showJumpToLatest={false} variant="embedded" @@ -61,4 +67,4 @@ export function RunChatSurface({ includeSucceededRunsWithoutOutput /> ); -} +}); diff --git a/ui/src/components/transcript/useLiveRunTranscripts.test.tsx b/ui/src/components/transcript/useLiveRunTranscripts.test.tsx index 3d3a0634..9fdb4794 100644 --- a/ui/src/components/transcript/useLiveRunTranscripts.test.tsx +++ b/ui/src/components/transcript/useLiveRunTranscripts.test.tsx @@ -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(); + 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(); + 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(); + }); }); diff --git a/ui/src/components/transcript/useLiveRunTranscripts.ts b/ui/src/components/transcript/useLiveRunTranscripts.ts index 5f42a905..991b9658 100644 --- a/ui/src/components/transcript/useLiveRunTranscripts.ts +++ b/ui/src/components/transcript/useLiveRunTranscripts.ts @@ -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()); const logOffsetByRunRef = useRef(new Map()); const missingTerminalLogRunIdsRef = useRef(new Set()); + const transcriptCacheRef = useRef(new Map()); // 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(); const censorUsernameInLogs = generalSettings?.censorUsernameInLogs === true; + const cache = transcriptCacheRef.current; + const currentRunIds = new Set(); 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]); diff --git a/ui/src/lib/inbox.test.ts b/ui/src/lib/inbox.test.ts index 5f6a8eb6..340d302e 100644 --- a/ui/src/lib/inbox.test.ts +++ b/ui/src/lib/inbox.test.ts @@ -295,6 +295,7 @@ const dashboard: DashboardSummary = { pausedAgents: 0, pausedProjects: 0, }, + runActivity: [], }; describe("inbox helpers", () => { diff --git a/ui/src/pages/Dashboard.tsx b/ui/src/pages/Dashboard.tsx index 273fc35d..a5176100 100644 --- a/ui/src/pages/Dashboard.tsx +++ b/ui/src/pages/Dashboard.tsx @@ -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() {
- + @@ -309,7 +300,7 @@ export function Dashboard() { - +