[codex] Add runtime lifecycle recovery and live issue visibility (#4419)

This commit is contained in:
Dotta
2026-04-24 15:50:32 -05:00
committed by GitHub
parent 9a8d219949
commit 5a0c1979cf
121 changed files with 9625 additions and 2044 deletions

View File

@@ -4,6 +4,7 @@ import { Layout } from "./components/Layout";
import { OnboardingWizard } from "./components/OnboardingWizard";
import { CloudAccessGate } from "./components/CloudAccessGate";
import { Dashboard } from "./pages/Dashboard";
import { DashboardLive } from "./pages/DashboardLive";
import { Companies } from "./pages/Companies";
import { Agents } from "./pages/Agents";
import { AgentDetail } from "./pages/AgentDetail";
@@ -58,6 +59,7 @@ function boardRoutes() {
<>
<Route index element={<Navigate to="dashboard" replace />} />
<Route path="dashboard" element={<Dashboard />} />
<Route path="dashboard/live" element={<DashboardLive />} />
<Route path="onboarding" element={<OnboardingRoutePage />} />
<Route path="companies" element={<Companies />} />
<Route path="company/settings" element={<CompanySettings />} />

View File

@@ -0,0 +1,30 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
const mockApi = vi.hoisted(() => ({
get: vi.fn(),
}));
vi.mock("./client", () => ({
api: mockApi,
}));
import { heartbeatsApi } from "./heartbeats";
describe("heartbeatsApi.liveRunsForCompany", () => {
beforeEach(() => {
mockApi.get.mockReset();
mockApi.get.mockResolvedValue([]);
});
it("keeps the legacy numeric minCount signature", async () => {
await heartbeatsApi.liveRunsForCompany("company-1", 4);
expect(mockApi.get).toHaveBeenCalledWith("/companies/company-1/live-runs?minCount=4");
});
it("passes minCount and limit options to the company live-runs endpoint", async () => {
await heartbeatsApi.liveRunsForCompany("company-1", { minCount: 50, limit: 50 });
expect(mockApi.get).toHaveBeenCalledWith("/companies/company-1/live-runs?minCount=50&limit=50");
});
});

View File

@@ -1,4 +1,9 @@
import type { HeartbeatRun, HeartbeatRunEvent, InstanceSchedulerHeartbeatAgent, WorkspaceOperation } from "@paperclipai/shared";
import type {
HeartbeatRun,
HeartbeatRunEvent,
InstanceSchedulerHeartbeatAgent,
WorkspaceOperation,
} from "@paperclipai/shared";
import { api } from "./client";
export interface RunLivenessFields {
@@ -20,12 +25,15 @@ export interface ActiveRunForIssue {
agentId: string;
agentName: string;
adapterType: string;
logBytes?: number | null;
lastOutputBytes?: number | null;
issueId?: string | null;
livenessState?: RunLivenessFields["livenessState"];
livenessReason?: string | null;
continuationAttempt?: number;
lastUsefulActionAt?: string | Date | null;
nextAction?: string | null;
outputSilence?: HeartbeatRun["outputSilence"];
}
export interface LiveRunForIssue {
@@ -39,12 +47,23 @@ export interface LiveRunForIssue {
agentId: string;
agentName: string;
adapterType: string;
logBytes?: number | null;
lastOutputBytes?: number | null;
issueId?: string | null;
livenessState?: RunLivenessFields["livenessState"];
livenessReason?: string | null;
continuationAttempt?: number;
lastUsefulActionAt?: string | null;
nextAction?: string | null;
outputSilence?: HeartbeatRun["outputSilence"];
}
export interface WatchdogDecisionInput {
runId: string;
decision: "snooze" | "continue" | "dismissed_false_positive";
evaluationIssueId?: string | null;
reason?: string | null;
snoozedUntil?: string | null;
}
export const heartbeatsApi = {
@@ -71,12 +90,31 @@ export const heartbeatsApi = {
`/workspace-operations/${operationId}/log?offset=${encodeURIComponent(String(offset))}&limitBytes=${encodeURIComponent(String(limitBytes))}`,
),
cancel: (runId: string) => api.post<void>(`/heartbeat-runs/${runId}/cancel`, {}),
recordWatchdogDecision: (input: WatchdogDecisionInput) =>
api.post(`/heartbeat-runs/${input.runId}/watchdog-decisions`, {
decision: input.decision,
evaluationIssueId: input.evaluationIssueId ?? null,
reason: input.reason ?? null,
snoozedUntil: input.snoozedUntil ?? null,
}),
liveRunsForIssue: (issueId: string) =>
api.get<LiveRunForIssue[]>(`/issues/${issueId}/live-runs`),
activeRunForIssue: (issueId: string) =>
api.get<ActiveRunForIssue | null>(`/issues/${issueId}/active-run`),
liveRunsForCompany: (companyId: string, minCount?: number) =>
api.get<LiveRunForIssue[]>(`/companies/${companyId}/live-runs${minCount ? `?minCount=${minCount}` : ""}`),
liveRunsForCompany: (
companyId: string,
options?: number | { minCount?: number; limit?: number },
) => {
const searchParams = new URLSearchParams();
if (typeof options === "number") {
searchParams.set("minCount", String(options));
} else if (options) {
if (options.minCount) searchParams.set("minCount", String(options.minCount));
if (options.limit) searchParams.set("limit", String(options.limit));
}
const qs = searchParams.toString();
return api.get<LiveRunForIssue[]>(`/companies/${companyId}/live-runs${qs ? `?${qs}` : ""}`);
},
listInstanceSchedulerAgents: () =>
api.get<InstanceSchedulerHeartbeatAgent[]>("/instance/scheduler-heartbeats"),
};

View File

@@ -0,0 +1,152 @@
// @vitest-environment jsdom
import { act, type ReactNode } from "react";
import { createRoot } from "react-dom/client";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { ActiveAgentsPanel } from "./ActiveAgentsPanel";
const mockHeartbeatsApi = vi.hoisted(() => ({
liveRunsForCompany: vi.fn(),
}));
const mockIssuesApi = vi.hoisted(() => ({
list: vi.fn(),
}));
vi.mock("@/lib/router", () => ({
Link: ({ to, children, ...props }: { to: string; children: ReactNode }) => (
<a href={to} {...props}>
{children}
</a>
),
}));
vi.mock("../api/heartbeats", () => ({
heartbeatsApi: mockHeartbeatsApi,
}));
vi.mock("../api/issues", () => ({
issuesApi: mockIssuesApi,
}));
vi.mock("./Identity", () => ({
Identity: ({ name }: { name: string }) => <span>{name}</span>,
}));
vi.mock("./RunChatSurface", () => ({
RunChatSurface: () => <div>Run output</div>,
}));
vi.mock("./transcript/useLiveRunTranscripts", () => ({
useLiveRunTranscripts: () => ({
transcriptByRun: new Map(),
hasOutputForRun: () => false,
}),
}));
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true;
async function flushReact() {
await act(async () => {
await Promise.resolve();
await new Promise((resolve) => window.setTimeout(resolve, 0));
});
}
function createRun(index: number) {
return {
id: `run-${index}`,
status: "running",
invocationSource: "assignment",
triggerDetail: null,
startedAt: "2026-04-24T12:00:00.000Z",
finishedAt: null,
createdAt: `2026-04-24T12:00:0${index}.000Z`,
agentId: `agent-${index}`,
agentName: `Agent ${index}`,
adapterType: "codex_local",
issueId: null,
};
}
describe("ActiveAgentsPanel", () => {
let container: HTMLDivElement;
beforeEach(() => {
container = document.createElement("div");
document.body.appendChild(container);
mockHeartbeatsApi.liveRunsForCompany.mockResolvedValue([1, 2, 3, 4, 5].map(createRun));
mockIssuesApi.list.mockResolvedValue([]);
});
afterEach(() => {
container.remove();
document.body.innerHTML = "";
vi.clearAllMocks();
});
it("links hidden active/recent runs to the full live dashboard", async () => {
const root = createRoot(container);
const queryClient = new QueryClient({
defaultOptions: { queries: { retry: false } },
});
await act(async () => {
root.render(
<QueryClientProvider client={queryClient}>
<ActiveAgentsPanel companyId="company-1" />
</QueryClientProvider>,
);
});
await flushReact();
expect(mockHeartbeatsApi.liveRunsForCompany).toHaveBeenCalledWith("company-1", {
minCount: 4,
limit: undefined,
});
const moreLink = [...container.querySelectorAll("a")].find((anchor) =>
anchor.textContent?.includes("more active/recent"),
);
expect(moreLink?.getAttribute("href")).toBe("/dashboard/live");
await act(async () => {
root.unmount();
});
});
it("can request the full live dashboard page limit without a hidden-runs link", async () => {
const root = createRoot(container);
const queryClient = new QueryClient({
defaultOptions: { queries: { retry: false } },
});
await act(async () => {
root.render(
<QueryClientProvider client={queryClient}>
<ActiveAgentsPanel
companyId="company-1"
minRunCount={50}
fetchLimit={50}
cardLimit={50}
queryScope="dashboard-live"
showMoreLink={false}
/>
</QueryClientProvider>,
);
});
await flushReact();
expect(mockHeartbeatsApi.liveRunsForCompany).toHaveBeenCalledWith("company-1", {
minCount: 50,
limit: 50,
});
expect(container.textContent).not.toContain("more active/recent");
await act(async () => {
root.unmount();
});
});
});

View File

@@ -25,16 +25,36 @@ function isRunActive(run: LiveRunForIssue): boolean {
interface ActiveAgentsPanelProps {
companyId: string;
title?: string;
minRunCount?: number;
fetchLimit?: number;
cardLimit?: number;
gridClassName?: string;
cardClassName?: string;
emptyMessage?: string;
queryScope?: string;
showMoreLink?: boolean;
}
export function ActiveAgentsPanel({ companyId }: ActiveAgentsPanelProps) {
export function ActiveAgentsPanel({
companyId,
title = "Agents",
minRunCount = MIN_DASHBOARD_RUNS,
fetchLimit,
cardLimit = DASHBOARD_RUN_CARD_LIMIT,
gridClassName,
cardClassName,
emptyMessage = "No recent agent runs.",
queryScope = "dashboard",
showMoreLink = true,
}: ActiveAgentsPanelProps) {
const { data: liveRuns } = useQuery({
queryKey: [...queryKeys.liveRuns(companyId), "dashboard"],
queryFn: () => heartbeatsApi.liveRunsForCompany(companyId, MIN_DASHBOARD_RUNS),
queryKey: [...queryKeys.liveRuns(companyId), queryScope, { minRunCount, fetchLimit }],
queryFn: () => heartbeatsApi.liveRunsForCompany(companyId, { minCount: minRunCount, limit: fetchLimit }),
});
const runs = liveRuns ?? [];
const visibleRuns = useMemo(() => runs.slice(0, DASHBOARD_RUN_CARD_LIMIT), [runs]);
const visibleRuns = useMemo(() => runs.slice(0, cardLimit), [cardLimit, runs]);
const hiddenRunCount = Math.max(0, runs.length - visibleRuns.length);
const { data: issues } = useQuery({
queryKey: [...queryKeys.issues.list(companyId), "with-routine-executions"],
@@ -62,14 +82,14 @@ export function ActiveAgentsPanel({ companyId }: ActiveAgentsPanelProps) {
return (
<div>
<h3 className="mb-3 text-sm font-semibold uppercase tracking-wide text-muted-foreground">
Agents
{title}
</h3>
{runs.length === 0 ? (
<div className="rounded-xl border border-border p-4">
<p className="text-sm text-muted-foreground">No recent agent runs.</p>
<p className="text-sm text-muted-foreground">{emptyMessage}</p>
</div>
) : (
<div className="grid grid-cols-1 gap-2 sm:grid-cols-2 sm:gap-4 xl:grid-cols-4">
<div className={cn("grid grid-cols-1 gap-2 sm:grid-cols-2 sm:gap-4 xl:grid-cols-4", gridClassName)}>
{visibleRuns.map((run) => (
<AgentRunCard
key={run.id}
@@ -79,13 +99,14 @@ export function ActiveAgentsPanel({ companyId }: ActiveAgentsPanelProps) {
transcript={transcriptByRun.get(run.id) ?? EMPTY_TRANSCRIPT}
hasOutput={hasOutputForRun(run.id)}
isActive={isRunActive(run)}
className={cardClassName}
/>
))}
</div>
)}
{hiddenRunCount > 0 && (
{showMoreLink && hiddenRunCount > 0 && (
<div className="mt-3 flex justify-end text-xs text-muted-foreground">
<Link to="/agents" className="hover:text-foreground hover:underline">
<Link to="/dashboard/live" className="hover:text-foreground hover:underline">
{hiddenRunCount} more active/recent run{hiddenRunCount === 1 ? "" : "s"}
</Link>
</div>
@@ -101,6 +122,7 @@ const AgentRunCard = memo(function AgentRunCard({
transcript,
hasOutput,
isActive,
className,
}: {
companyId: string;
run: LiveRunForIssue;
@@ -108,6 +130,7 @@ const AgentRunCard = memo(function AgentRunCard({
transcript: TranscriptEntry[];
hasOutput: boolean;
isActive: boolean;
className?: string;
}) {
return (
<div className={cn(
@@ -115,6 +138,7 @@ const AgentRunCard = memo(function AgentRunCard({
isActive
? "border-cyan-500/25 bg-cyan-500/[0.04] shadow-[0_16px_40px_rgba(6,182,212,0.08)]"
: "border-border bg-background/70",
className,
)}>
<div className="border-b border-border/60 px-3 py-3">
<div className="flex items-start justify-between gap-2">

View File

@@ -56,6 +56,10 @@ function createRun(overrides: Partial<HeartbeatRun> = {}): HeartbeatRun {
logBytes: null,
logSha256: null,
logCompressed: false,
lastOutputAt: null,
lastOutputSeq: 0,
lastOutputStream: null,
lastOutputBytes: null,
stdoutExcerpt: null,
stderrExcerpt: null,
errorCode: null,

View File

@@ -178,6 +178,46 @@ describe("CommentThread", () => {
});
});
it("shows follow-up badges on explicit follow-up comments and timeline rows", () => {
const root = createRoot(container);
act(() => {
root.render(
<MemoryRouter>
<CommentThread
comments={[{
id: "comment-1",
companyId: "company-1",
issueId: "issue-1",
authorAgentId: null,
authorUserId: "local-board",
body: "Please continue validation.",
followUpRequested: true,
createdAt: new Date("2026-03-11T10:00:00.000Z"),
updatedAt: new Date("2026-03-11T10:00:00.000Z"),
}]}
timelineEvents={[{
id: "event-1",
actorType: "agent",
actorId: "agent-1",
createdAt: new Date("2026-03-11T10:00:00.000Z"),
commentId: "comment-1",
followUpRequested: true,
}]}
onAdd={async () => {}}
/>
</MemoryRouter>,
);
});
expect(container.textContent).toContain("Follow-up");
expect(container.textContent).toContain("requested follow-up");
act(() => {
root.unmount();
});
});
it("hides the reopen control and infers reopen for closed agent-assigned issues", async () => {
const root = createRoot(container);
const onAdd = vi.fn(async () => {});

View File

@@ -9,6 +9,7 @@ import type {
IssueComment,
} from "@paperclipai/shared";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { ArrowRight, Check, Copy, Paperclip } from "lucide-react";
import { Avatar, AvatarFallback } from "@/components/ui/avatar";
import { Identity } from "./Identity";
@@ -32,6 +33,7 @@ interface CommentWithRunMeta extends IssueComment {
clientStatus?: "pending" | "queued";
queueState?: "queued";
queueTargetRunId?: string | null;
followUpRequested?: boolean;
}
interface LinkedRunItem {
@@ -341,6 +343,7 @@ function CommentCard({
const isHighlighted = highlightCommentId === comment.id;
const isPending = comment.clientStatus === "pending";
const isQueued = queued || comment.queueState === "queued" || comment.clientStatus === "queued";
const followUpRequested = comment.followUpRequested === true;
return (
<div
@@ -371,6 +374,11 @@ function CommentCard({
Queued
</span>
) : null}
{followUpRequested ? (
<Badge variant="outline" className="text-[10px] uppercase tracking-[0.14em]">
Follow-up
</Badge>
) : null}
{companyId && !isPending ? (
<PluginSlotOutlet
slotTypes={["commentContextMenuItem"]}
@@ -478,6 +486,7 @@ function TimelineEventCard({
currentUserId?: string | null;
}) {
const actorName = formatTimelineActorName(event.actorType, event.actorId, agentMap, currentUserId);
const actionLabel = event.followUpRequested ? "requested follow-up" : "updated this task";
return (
<div id={`activity-${event.id}`} className="flex items-start gap-2.5 py-1.5">
@@ -488,7 +497,7 @@ function TimelineEventCard({
<div className="min-w-0 flex-1 space-y-1.5">
<div className="flex flex-wrap items-baseline gap-x-1.5 gap-y-1 text-sm">
<span className="font-medium text-foreground">{actorName}</span>
<span className="text-muted-foreground">updated this task</span>
<span className="text-muted-foreground">{actionLabel}</span>
<a
href={`#activity-${event.id}`}
className="text-sm text-muted-foreground transition-colors hover:text-foreground hover:underline"
@@ -742,12 +751,20 @@ export function CommentThread({
const hasScrolledRef = useRef(false);
const timeline = useMemo<TimelineItem[]>(() => {
const commentItems: TimelineItem[] = comments.map((comment) => ({
kind: "comment",
id: comment.id,
createdAtMs: new Date(comment.createdAt).getTime(),
comment,
}));
const followUpCommentIds = new Set(
timelineEvents
.filter((event) => event.followUpRequested && event.commentId)
.map((event) => event.commentId as string),
);
const commentItems: TimelineItem[] = comments.map((comment) => {
const followUpRequested = comment.followUpRequested === true || followUpCommentIds.has(comment.id);
return {
kind: "comment",
id: comment.id,
createdAtMs: new Date(comment.createdAt).getTime(),
comment: followUpRequested ? { ...comment, followUpRequested } : comment,
};
});
const approvalItems: TimelineItem[] = linkedApprovals.map((approval) => ({
kind: "approval",
id: approval.id,

View File

@@ -318,6 +318,50 @@ describe("IssueChatThread", () => {
});
});
it("shows explicit follow-up badges and event copy", () => {
const root = createRoot(container);
act(() => {
root.render(
<MemoryRouter>
<IssueChatThread
comments={[{
id: "comment-1",
companyId: "company-1",
issueId: "issue-1",
authorAgentId: null,
authorUserId: "local-board",
body: "Please continue validation.",
followUpRequested: true,
createdAt: new Date("2026-03-11T10:00:00.000Z"),
updatedAt: new Date("2026-03-11T10:00:00.000Z"),
}]}
linkedRuns={[]}
timelineEvents={[{
id: "event-1",
actorType: "agent",
actorId: "agent-1",
createdAt: new Date("2026-03-11T10:00:00.000Z"),
commentId: "comment-1",
followUpRequested: true,
}]}
liveRuns={[]}
onAdd={async () => {}}
showComposer={false}
enableLiveTranscriptPolling={false}
/>
</MemoryRouter>,
);
});
expect(container.textContent).toContain("Follow-up");
expect(container.textContent).toContain("requested follow-up");
act(() => {
root.unmount();
});
});
it("shows unresolved blocker context above the composer", () => {
const root = createRoot(container);
@@ -359,6 +403,59 @@ describe("IssueChatThread", () => {
});
});
it("shows terminal blocker context when an immediate blocker is transitively blocked", () => {
const root = createRoot(container);
act(() => {
root.render(
<MemoryRouter>
<IssueChatThread
comments={[]}
linkedRuns={[]}
timelineEvents={[]}
liveRuns={[]}
issueStatus="blocked"
blockedBy={[
{
id: "blocker-1",
identifier: "PAP-2167",
title: "Phase 7 review",
status: "blocked",
priority: "medium",
assigneeAgentId: "agent-1",
assigneeUserId: null,
terminalBlockers: [
{
id: "terminal-1",
identifier: "PAP-2201",
title: "Security sign-off",
status: "todo",
priority: "high",
assigneeAgentId: "agent-2",
assigneeUserId: null,
},
],
},
]}
onAdd={async () => {}}
enableLiveTranscriptPolling={false}
/>
</MemoryRouter>,
);
});
expect(container.textContent).toContain("PAP-2167");
expect(container.textContent).toContain("Phase 7 review");
expect(container.textContent).toContain("Ultimately waiting on");
expect(container.textContent).toContain("PAP-2201");
expect(container.textContent).toContain("Security sign-off");
expect(container.querySelector('[data-issue-path-id="PAP-2201"]')).not.toBeNull();
act(() => {
root.unmount();
});
});
it("shows paused assigned agent context above the composer", () => {
const root = createRoot(container);
const pausedAgent = {
@@ -1363,6 +1460,66 @@ describe("IssueChatThread", () => {
});
});
it("keeps a running chain-of-thought in the Working state between commands", () => {
const root = createRoot(container);
act(() => {
root.render(
<MemoryRouter>
<IssueChatThread
comments={[]}
linkedRuns={[]}
timelineEvents={[]}
liveRuns={[{
id: "run-1",
issueId: "issue-1",
status: "running",
invocationSource: "comment",
triggerDetail: null,
startedAt: "2026-04-06T12:00:00.000Z",
finishedAt: null,
createdAt: "2026-04-06T12:00:00.000Z",
agentId: "agent-1",
agentName: "Agent 1",
adapterType: "codex_local",
}]}
transcriptsByRunId={new Map([
[
"run-1",
[
{
kind: "tool_call",
ts: "2026-04-06T12:00:10.000Z",
name: "command_execution",
toolUseId: "tool-1",
input: { command: "pnpm test" },
},
{
kind: "tool_result",
ts: "2026-04-06T12:00:20.000Z",
toolUseId: "tool-1",
toolName: "command_execution",
content: "Tests passed",
isError: false,
},
],
],
])}
onAdd={async () => {}}
enableLiveTranscriptPolling={false}
/>
</MemoryRouter>,
);
});
expect(container.textContent).toContain("Working");
expect(container.textContent).not.toContain("Worked");
act(() => {
root.unmount();
});
});
it("folds chain-of-thought when the same message transitions from running to complete", () => {
expect(resolveAssistantMessageFoldedState({
messageId: "message-1",

View File

@@ -58,6 +58,7 @@ import { buildIssueThreadInteractionSummary, isIssueThreadInteraction } from "..
import { resolveIssueChatTranscriptRuns } from "../lib/issueChatTranscriptRuns";
import type { IssueTimelineAssignee, IssueTimelineEvent } from "../lib/issue-timeline-events";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
import {
Dialog,
@@ -353,6 +354,26 @@ function IssueBlockedNotice({
if (blockers.length === 0 && issueStatus !== "blocked") return null;
const blockerLabel = blockers.length === 1 ? "the linked issue" : "the linked issues";
const terminalBlockers = blockers
.flatMap((blocker) => blocker.terminalBlockers ?? [])
.filter((blocker, index, all) => all.findIndex((candidate) => candidate.id === blocker.id) === index);
const renderBlockerChip = (blocker: IssueRelationIssueSummary) => {
const issuePathId = blocker.identifier ?? blocker.id;
return (
<IssueLinkQuicklook
key={blocker.id}
issuePathId={issuePathId}
to={createIssueDetailPath(issuePathId)}
className="inline-flex max-w-full items-center gap-1 rounded-md border border-amber-300/70 bg-background/80 px-2 py-1 font-mono text-xs text-amber-950 transition-colors hover:border-amber-500 hover:bg-amber-100 hover:underline dark:border-amber-500/40 dark:bg-background/40 dark:text-amber-100 dark:hover:bg-amber-500/15"
>
<span>{blocker.identifier ?? blocker.id.slice(0, 8)}</span>
<span className="max-w-[18rem] truncate font-sans text-[11px] text-amber-800 dark:text-amber-200">
{blocker.title}
</span>
</IssueLinkQuicklook>
);
};
return (
<div className="mb-3 rounded-md border border-amber-300/70 bg-amber-50/90 px-3 py-2.5 text-sm text-amber-950 shadow-sm dark:border-amber-500/40 dark:bg-amber-500/10 dark:text-amber-100">
@@ -366,22 +387,15 @@ function IssueBlockedNotice({
</p>
{blockers.length > 0 ? (
<div className="flex flex-wrap gap-1.5">
{blockers.map((blocker) => {
const issuePathId = blocker.identifier ?? blocker.id;
return (
<IssueLinkQuicklook
key={blocker.id}
issuePathId={issuePathId}
to={createIssueDetailPath(issuePathId)}
className="inline-flex max-w-full items-center gap-1 rounded-md border border-amber-300/70 bg-background/80 px-2 py-1 font-mono text-xs text-amber-950 transition-colors hover:border-amber-500 hover:bg-amber-100 hover:underline dark:border-amber-500/40 dark:bg-background/40 dark:text-amber-100 dark:hover:bg-amber-500/15"
>
<span>{blocker.identifier ?? blocker.id.slice(0, 8)}</span>
<span className="max-w-[18rem] truncate font-sans text-[11px] text-amber-800 dark:text-amber-200">
{blocker.title}
</span>
</IssueLinkQuicklook>
);
})}
{blockers.map(renderBlockerChip)}
</div>
) : null}
{terminalBlockers.length > 0 ? (
<div className="flex flex-wrap items-center gap-1.5 pt-0.5">
<span className="text-xs font-medium text-amber-800 dark:text-amber-200">
Ultimately waiting on
</span>
{terminalBlockers.map(renderBlockerChip)}
</div>
) : null}
</div>
@@ -754,8 +768,7 @@ function IssueChatChainOfThought({
(p): p is ToolCallMessagePart => p.type === "tool-call",
);
const hasActiveTool = toolParts.some((t) => t.result === undefined);
const isActive = isMessageRunning && hasActiveTool;
const isActive = isMessageRunning;
const [expanded, setExpanded] = useState(isActive);
const rawSegments = Array.isArray(custom.chainOfThoughtSegments)
@@ -1196,6 +1209,7 @@ function IssueChatUserMessage({ message }: { message: ThreadMessage }) {
const authorName = typeof custom.authorName === "string" ? custom.authorName : null;
const authorUserId = typeof custom.authorUserId === "string" ? custom.authorUserId : null;
const queued = custom.queueState === "queued" || custom.clientStatus === "queued";
const followUpRequested = custom.followUpRequested === true;
const queueReason = typeof custom.queueReason === "string" ? custom.queueReason : null;
const queueBadgeLabel = queueReason === "hold" ? "\u23f8 Deferred wake" : "Queued";
const pending = custom.clientStatus === "pending";
@@ -1221,6 +1235,11 @@ function IssueChatUserMessage({ message }: { message: ThreadMessage }) {
<div className={cn("flex min-w-0 max-w-[85%] flex-col", isCurrentUser && "items-end")}>
<div className={cn("mb-1 flex items-center gap-2 px-1", isCurrentUser ? "justify-end" : "justify-start")}>
<span className="text-sm font-medium text-foreground">{resolvedAuthorName}</span>
{followUpRequested ? (
<Badge variant="outline" className="text-[10px] uppercase tracking-[0.14em]">
Follow-up
</Badge>
) : null}
</div>
<div
className={cn(
@@ -1396,6 +1415,7 @@ function IssueChatAssistantMessage({ message }: { message: ThreadMessage }) {
};
const activeVote = commentId ? feedbackVoteByTargetId.get(commentId) ?? null : null;
const followUpRequested = custom.followUpRequested === true;
return (
<div id={anchorId}>
@@ -1429,6 +1449,11 @@ function IssueChatAssistantMessage({ message }: { message: ThreadMessage }) {
) : (
<div className="mb-1.5 flex items-center gap-2">
<span className="text-sm font-medium text-foreground">{authorName}</span>
{followUpRequested ? (
<Badge variant="outline" className="text-[10px] uppercase tracking-[0.14em]">
Follow-up
</Badge>
) : null}
{isRunning ? (
<span className="inline-flex items-center gap-1 rounded-full border border-cyan-400/40 bg-cyan-500/10 px-2 py-0.5 text-[10px] font-medium uppercase tracking-[0.14em] text-cyan-700 dark:text-cyan-200">
<Loader2 className="h-3 w-3 animate-spin" />
@@ -1944,7 +1969,9 @@ function IssueChatSystemMessage({ message }: { message: ThreadMessage }) {
<div className="min-w-0 space-y-1">
<div className={cn("flex flex-wrap items-baseline gap-x-1.5 gap-y-0.5 text-xs", isCurrentUser && "justify-end")}>
<span className="font-medium text-foreground">{actorName}</span>
<span className="text-muted-foreground">updated this task</span>
<span className="text-muted-foreground">
{custom.followUpRequested === true ? "requested follow-up" : "updated this task"}
</span>
<a
href={anchorId ? `#${anchorId}` : undefined}
className="text-xs text-muted-foreground transition-colors hover:text-foreground hover:underline"
@@ -2551,6 +2578,8 @@ export function IssueChatThread({
agentId: activeRun.agentId,
agentName: activeRun.agentName,
adapterType: activeRun.adapterType,
logBytes: activeRun.logBytes,
lastOutputBytes: activeRun.lastOutputBytes,
});
}
return [...deduped.values()].sort((a, b) => new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime());

View File

@@ -150,7 +150,7 @@ export function InboxIssueMetaLeading({
<>
{showStatus ? (
<span className="hidden shrink-0 sm:inline-flex">
{statusSlot ?? <StatusIcon status={issue.status} />}
{statusSlot ?? <StatusIcon status={issue.status} blockerAttention={issue.blockerAttention} />}
</span>
) : null}
{showIdentifier ? (

View File

@@ -79,5 +79,6 @@ describe("IssueFiltersPopover", () => {
element.className.includes("md:grid-cols-3"),
);
expect(layoutGrid?.className).toContain("grid-cols-1");
expect(popoverContent?.textContent).toContain("Live runs only");
});
});

View File

@@ -344,9 +344,16 @@ export function IssueFiltersPopover({
</div>
) : null}
{enableRoutineVisibilityFilter ? (
<div className="space-y-1">
<span className="text-xs text-muted-foreground">Visibility</span>
<div className="space-y-1">
<span className="text-xs text-muted-foreground">Visibility</span>
<label className="flex cursor-pointer items-center gap-2 rounded-sm px-2 py-1 hover:bg-accent/50">
<Checkbox
checked={state.liveOnly}
onCheckedChange={(checked) => onChange({ liveOnly: checked === true })}
/>
<span className="text-sm">Live runs only</span>
</label>
{enableRoutineVisibilityFilter ? (
<label className="flex cursor-pointer items-center gap-2 rounded-sm px-2 py-1 hover:bg-accent/50">
<Checkbox
checked={state.hideRoutineExecutions}
@@ -354,8 +361,8 @@ export function IssueFiltersPopover({
/>
<span className="text-sm">Hide routine runs</span>
</label>
</div>
) : null}
) : null}
</div>
</div>
</div>
</div>

View File

@@ -44,7 +44,7 @@ export function IssueQuicklookCard({
return (
<div className={cn("space-y-2", compact && "space-y-1.5")}>
<div className="flex items-start gap-2">
<StatusIcon status={issue.status} className="mt-0.5 shrink-0" />
<StatusIcon status={issue.status} blockerAttention={issue.blockerAttention} className="mt-0.5 shrink-0" />
<RouterDom.Link
to={linkTo}
state={linkState ?? withIssueDetailHeaderSeed(null, issue)}

View File

@@ -83,7 +83,9 @@ vi.mock("../lib/assignees", () => ({
}));
vi.mock("./StatusIcon", () => ({
StatusIcon: ({ status }: { status: string }) => <span>{status}</span>,
StatusIcon: ({ status, blockerAttention }: { status: string; blockerAttention?: Issue["blockerAttention"] }) => (
<span data-status-icon-state={blockerAttention?.state}>{status}</span>
),
}));
vi.mock("./PriorityIcon", () => ({
@@ -392,6 +394,29 @@ describe("IssueProperties", () => {
act(() => root.unmount());
});
it("passes blocker attention to the sidebar status icon", async () => {
const root = renderProperties(container, {
issue: createIssue({
status: "blocked",
blockerAttention: {
state: "covered",
reason: "active_child",
unresolvedBlockerCount: 1,
coveredBlockerCount: 1,
attentionBlockerCount: 0,
sampleBlockerIdentifier: "PAP-2",
},
}),
childIssues: [],
onUpdate: vi.fn(),
});
await flush();
expect(container.querySelector('[data-status-icon-state="covered"]')?.textContent).toBe("blocked");
act(() => root.unmount());
});
it("renders blocked-by issues as direct chips and edits them from an add action", async () => {
const onUpdate = vi.fn();
mockIssuesApi.list.mockResolvedValue([

View File

@@ -1044,6 +1044,7 @@ export function IssueProperties({
<PropertyRow label="Status">
<StatusIcon
status={issue.status}
blockerAttention={issue.blockerAttention}
onChange={(status) => onUpdate({ status })}
showLabel
/>

View File

@@ -69,7 +69,7 @@ export function IssueRow({
)}
>
<span className="shrink-0 pt-px sm:hidden">
{mobileLeading ?? <StatusIcon status={issue.status} className={selectedStatusClass} />}
{mobileLeading ?? <StatusIcon status={issue.status} blockerAttention={issue.blockerAttention} className={selectedStatusClass} />}
</span>
<span className="flex min-w-0 flex-1 flex-col gap-1 sm:contents">
<span className="line-clamp-2 text-sm sm:order-2 sm:min-w-0 sm:flex-1 sm:truncate sm:line-clamp-none">
@@ -82,7 +82,7 @@ export function IssueRow({
{desktopMetaLeading ?? (
<>
<span className="hidden shrink-0 sm:inline-flex">
<StatusIcon status={issue.status} className={selectedStatusClass} />
<StatusIcon status={issue.status} blockerAttention={issue.blockerAttention} className={selectedStatusClass} />
</span>
<span className="shrink-0 font-mono text-xs text-muted-foreground">
{identifier}

View File

@@ -6,6 +6,7 @@ import { createRoot, type Root } from "react-dom/client";
import type { Issue, RunLivenessState } from "@paperclipai/shared";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type { RunForIssue } from "../api/activity";
import type { ActiveRunForIssue } from "../api/heartbeats";
import { IssueRunLedgerContent } from "./IssueRunLedger";
vi.mock("@/lib/router", () => ({
@@ -99,6 +100,35 @@ function createIssue(overrides: Partial<Issue> = {}): Issue {
};
}
function createActiveRun(overrides: Partial<ActiveRunForIssue> = {}): ActiveRunForIssue {
return {
id: "run-live-1",
status: "running",
invocationSource: "assignment",
triggerDetail: null,
startedAt: "2026-04-18T19:58:00.000Z",
finishedAt: null,
createdAt: "2026-04-18T19:58:00.000Z",
agentId: "agent-1",
agentName: "CodexCoder",
adapterType: "codex_local",
outputSilence: {
lastOutputAt: "2026-04-18T19:00:00.000Z",
lastOutputSeq: 4,
lastOutputStream: "stdout",
silenceStartedAt: "2026-04-18T19:30:00.000Z",
silenceAgeMs: 45 * 60 * 1000,
level: "critical",
suspicionThresholdMs: 10 * 60 * 1000,
criticalThresholdMs: 30 * 60 * 1000,
snoozedUntil: null,
evaluationIssueId: "issue-eval-1",
evaluationIssueIdentifier: "PAP-404",
},
...overrides,
};
}
function renderLedger(props: Partial<ComponentProps<typeof IssueRunLedgerContent>> = {}) {
render(
<IssueRunLedgerContent
@@ -108,6 +138,8 @@ function renderLedger(props: Partial<ComponentProps<typeof IssueRunLedgerContent
issueStatus={props.issueStatus ?? "in_progress"}
childIssues={props.childIssues ?? []}
agentMap={props.agentMap ?? new Map([["agent-1", { name: "CodexCoder" }]])}
pendingWatchdogDecision={props.pendingWatchdogDecision}
onWatchdogDecision={props.onWatchdogDecision}
/>,
);
}
@@ -223,7 +255,8 @@ describe("IssueRunLedger", () => {
expect(container.textContent).toContain("Transient failure");
expect(container.textContent).toContain("Next retry");
expect(container.textContent).toContain("Retry exhausted");
expect(container.textContent).toContain("No further automatic retry queued");
expect(container.textContent).toContain("no further automatic retry will be queued");
expect(container.textContent).toContain("Manual intervention required");
});
it("shows timeout, cancel, and budget stop reasons without raw logs", () => {
@@ -302,4 +335,35 @@ describe("IssueRunLedger", () => {
expect(container.textContent).toContain("2 older runs not shown");
});
it("renders stale-run banner, watchdog actions, and silence badge for live runs", () => {
const onWatchdogDecision = vi.fn();
renderLedger({
runs: [createRun({ runId: "run-live-1", status: "running", finishedAt: null })],
activeRun: createActiveRun(),
onWatchdogDecision,
});
expect(container.textContent).toContain("Stale-run watchdog alert");
expect(container.textContent).toContain("PAP-404");
expect(container.textContent).toContain("Stale run");
const watchdogBanner = Array.from(container.querySelectorAll("p"))
.find((node) => node.textContent?.includes("Stale-run watchdog alert"))
?.closest("div");
expect(watchdogBanner?.className).toContain("border-red-500/30");
expect(watchdogBanner?.className).toContain("bg-red-500/10");
const continueButton = Array.from(container.querySelectorAll("button")).find(
(button) => button.textContent?.includes("Continue monitoring"),
);
expect(continueButton).not.toBeUndefined();
act(() => {
continueButton?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
});
expect(onWatchdogDecision).toHaveBeenCalledWith({
runId: "run-live-1",
decision: "continue",
evaluationIssueId: "issue-eval-1",
});
});
});

View File

@@ -1,9 +1,14 @@
import { useMemo } from "react";
import type { Issue, Agent } from "@paperclipai/shared";
import { useQuery } from "@tanstack/react-query";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { Link } from "@/lib/router";
import { activityApi, type RunForIssue, type RunLivenessState } from "../api/activity";
import { heartbeatsApi, type ActiveRunForIssue, type LiveRunForIssue } from "../api/heartbeats";
import {
heartbeatsApi,
type ActiveRunForIssue,
type LiveRunForIssue,
type WatchdogDecisionInput,
} from "../api/heartbeats";
import { cn, relativeTime } from "../lib/utils";
import { queryKeys } from "../lib/queryKeys";
import { keepPreviousDataForSameQueryTail } from "../lib/query-placeholder-data";
@@ -24,11 +29,14 @@ type IssueRunLedgerContentProps = {
issueStatus: Issue["status"];
childIssues: Issue[];
agentMap: ReadonlyMap<string, Pick<Agent, "name">>;
pendingWatchdogDecision?: WatchdogDecisionInput["decision"] | null;
onWatchdogDecision?: (input: WatchdogDecisionInput) => void;
};
type LedgerRun = RunForIssue & {
isLive?: boolean;
agentName?: string;
outputSilence?: ActiveRunForIssue["outputSilence"];
};
type LivenessCopy = {
@@ -96,6 +104,28 @@ const MISSING_LIVENESS_COPY: LivenessCopy = {
const TERMINAL_CHILD_STATUSES = new Set<Issue["status"]>(["done", "cancelled"]);
const ACTIVE_RUN_STATUSES = new Set(["queued", "running"]);
type RunOutputSilenceLevel = NonNullable<ActiveRunForIssue["outputSilence"]>["level"];
type RunOutputSilenceCopy = {
label: string;
tone: string;
};
const RUN_OUTPUT_SILENCE_COPY: Partial<Record<RunOutputSilenceLevel, RunOutputSilenceCopy>> = {
suspicious: {
label: "Silence watch",
tone: "border-amber-500/30 bg-amber-500/10 text-amber-700 dark:text-amber-300",
},
critical: {
label: "Stale run",
tone: "border-red-500/30 bg-red-500/10 text-red-700 dark:text-red-300",
},
snoozed: {
label: "Silence snoozed",
tone: "border-cyan-500/30 bg-cyan-500/10 text-cyan-700 dark:text-cyan-300",
},
};
function asRecord(value: unknown): Record<string, unknown> | null {
if (typeof value !== "object" || value === null || Array.isArray(value)) return null;
return value as Record<string, unknown>;
@@ -143,6 +173,7 @@ function liveRunToLedgerRun(run: LiveRunForIssue | ActiveRunForIssue): LedgerRun
usageJson: null,
resultJson: null,
isLive: run.status === "queued" || run.status === "running",
outputSilence: run.outputSilence,
};
}
@@ -155,10 +186,25 @@ function mergeRuns(
for (const run of runs) byId.set(run.runId, run);
for (const run of liveRuns ?? []) {
const existing = byId.get(run.id);
byId.set(run.id, existing ? { ...existing, isLive: true, agentName: run.agentName } : liveRunToLedgerRun(run));
byId.set(
run.id,
existing
? { ...existing, isLive: true, agentName: run.agentName, outputSilence: run.outputSilence }
: liveRunToLedgerRun(run),
);
}
if (activeRun && !byId.has(activeRun.id)) {
byId.set(activeRun.id, liveRunToLedgerRun(activeRun));
if (activeRun) {
const existing = byId.get(activeRun.id);
if (existing) {
byId.set(activeRun.id, {
...existing,
isLive: isActiveRun(existing) || isActiveRun(activeRun),
agentName: activeRun.agentName,
outputSilence: activeRun.outputSilence,
});
} else {
byId.set(activeRun.id, liveRunToLedgerRun(activeRun));
}
}
return [...byId.values()].sort((a, b) => {
@@ -252,6 +298,17 @@ function compactAgentName(run: LedgerRun, agentMap: ReadonlyMap<string, Pick<Age
return run.agentName ?? agentMap.get(run.agentId)?.name ?? run.agentId.slice(0, 8);
}
function formatSilenceAge(ms: number | null | undefined) {
if (!ms || ms <= 0) return null;
const totalMinutes = Math.floor(ms / 60_000);
if (totalMinutes < 1) return "under 1 minute";
if (totalMinutes < 60) return `${totalMinutes} minute${totalMinutes === 1 ? "" : "s"}`;
const hours = Math.floor(totalMinutes / 60);
const minutes = totalMinutes % 60;
if (minutes === 0) return `${hours} hour${hours === 1 ? "" : "s"}`;
return `${hours}h ${minutes}m`;
}
export function IssueRunLedger({
issueId,
issueStatus,
@@ -259,6 +316,7 @@ export function IssueRunLedger({
agentMap,
hasLiveRuns,
}: IssueRunLedgerProps) {
const queryClient = useQueryClient();
const { data: runs } = useQuery({
queryKey: queryKeys.issues.runs(issueId),
queryFn: () => activityApi.runsForIssue(issueId),
@@ -279,6 +337,13 @@ export function IssueRunLedger({
refetchInterval: hasLiveRuns ? false : 3000,
placeholderData: keepPreviousDataForSameQueryTail<ActiveRunForIssue | null>(issueId),
});
const watchdogDecision = useMutation({
mutationFn: (input: WatchdogDecisionInput) => heartbeatsApi.recordWatchdogDecision(input),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: queryKeys.issues.activeRun(issueId) });
queryClient.invalidateQueries({ queryKey: queryKeys.issues.liveRuns(issueId) });
},
});
return (
<IssueRunLedgerContent
@@ -288,6 +353,8 @@ export function IssueRunLedger({
issueStatus={issueStatus}
childIssues={childIssues}
agentMap={agentMap}
pendingWatchdogDecision={watchdogDecision.variables?.decision ?? null}
onWatchdogDecision={(input) => watchdogDecision.mutate(input)}
/>
);
}
@@ -299,9 +366,19 @@ export function IssueRunLedgerContent({
issueStatus,
childIssues,
agentMap,
pendingWatchdogDecision,
onWatchdogDecision,
}: IssueRunLedgerContentProps) {
const ledgerRuns = useMemo(() => mergeRuns(runs, liveRuns, activeRun), [activeRun, liveRuns, runs]);
const latestRun = ledgerRuns[0] ?? null;
const latestSilentRun = useMemo(
() =>
ledgerRuns.find((run) =>
isActiveRun(run)
&& (run.outputSilence?.level === "critical" || run.outputSilence?.level === "suspicious"),
) ?? null,
[ledgerRuns],
);
const children = childIssueSummary(childIssues);
return (
@@ -360,6 +437,86 @@ export function IssueRunLedgerContent({
</div>
) : null}
{latestSilentRun?.outputSilence ? (
<div
className={cn(
"rounded-md border px-3 py-2 text-xs",
latestSilentRun.outputSilence.level === "critical"
? "border-red-500/30 bg-red-500/10 text-red-900 dark:text-red-200"
: "border-amber-500/30 bg-amber-500/10 text-amber-900 dark:text-amber-200",
)}
>
<p className="font-medium">
{latestSilentRun.outputSilence.level === "critical"
? "Stale-run watchdog alert"
: "Output silence watchdog warning"}
</p>
<p className="mt-1">
Latest active run has been silent for{" "}
{formatSilenceAge(latestSilentRun.outputSilence.silenceAgeMs) ?? "an extended period"}.
{latestSilentRun.outputSilence.evaluationIssueIdentifier ? (
<>
{" "}
Review{" "}
<Link
to={`/issues/${latestSilentRun.outputSilence.evaluationIssueIdentifier}`}
className="font-medium underline underline-offset-2"
>
{latestSilentRun.outputSilence.evaluationIssueIdentifier}
</Link>
{" "}for recovery context.
</>
) : null}
</p>
{onWatchdogDecision ? (
<div className="mt-2 flex flex-wrap gap-1.5">
<button
type="button"
className="rounded-md border border-border bg-background/80 px-2 py-1 text-[11px] text-foreground hover:bg-background"
onClick={() =>
onWatchdogDecision({
runId: latestSilentRun.runId,
decision: "continue",
evaluationIssueId: latestSilentRun.outputSilence?.evaluationIssueId ?? null,
})}
disabled={pendingWatchdogDecision != null}
>
Continue monitoring
</button>
<button
type="button"
className="rounded-md border border-border bg-background/80 px-2 py-1 text-[11px] text-foreground hover:bg-background"
onClick={() =>
onWatchdogDecision({
runId: latestSilentRun.runId,
decision: "snooze",
evaluationIssueId: latestSilentRun.outputSilence?.evaluationIssueId ?? null,
snoozedUntil: new Date(Date.now() + 60 * 60 * 1000).toISOString(),
reason: "Snoozed from issue run ledger",
})}
disabled={pendingWatchdogDecision != null}
>
Snooze 1h
</button>
<button
type="button"
className="rounded-md border border-border bg-background/80 px-2 py-1 text-[11px] text-foreground hover:bg-background"
onClick={() =>
onWatchdogDecision({
runId: latestSilentRun.runId,
decision: "dismissed_false_positive",
evaluationIssueId: latestSilentRun.outputSilence?.evaluationIssueId ?? null,
reason: "Dismissed from issue run ledger",
})}
disabled={pendingWatchdogDecision != null}
>
Mark false positive
</button>
</div>
) : null}
</div>
) : null}
{ledgerRuns.length === 0 ? (
<div className="rounded-md border border-dashed border-border px-3 py-3 text-sm text-muted-foreground">
Historical runs without liveness metadata will appear here once linked to this issue.
@@ -418,6 +575,16 @@ export function IssueRunLedgerContent({
{retryState.badgeLabel}
</span>
) : null}
{run.outputSilence && RUN_OUTPUT_SILENCE_COPY[run.outputSilence.level] ? (
<span
className={cn(
"rounded-md border px-1.5 py-0.5 text-[11px] font-medium",
RUN_OUTPUT_SILENCE_COPY[run.outputSilence.level]?.tone,
)}
>
{RUN_OUTPUT_SILENCE_COPY[run.outputSilence.level]?.label}
</span>
) : null}
</div>
<div className="grid gap-2 text-xs text-muted-foreground sm:grid-cols-3">

View File

@@ -170,6 +170,24 @@ async function waitForAssertion(assertion: () => void, attempts = 20) {
throw lastError;
}
async function waitForMicrotaskAssertion(assertion: () => void, attempts = 20) {
let lastError: unknown;
for (let attempt = 0; attempt < attempts; attempt += 1) {
try {
assertion();
return;
} catch (error) {
lastError = error;
await act(async () => {
await Promise.resolve();
});
}
}
throw lastError;
}
function renderWithQueryClient(node: ReactNode, container: HTMLDivElement) {
const root = createRoot(container);
const queryClient = new QueryClient({
@@ -393,6 +411,10 @@ describe("IssuesList", () => {
}),
);
localStorage.setItem(
"paperclip:test-issues:company-1",
JSON.stringify({ statuses: ["done"] }),
);
mockIssuesApi.list.mockResolvedValue(serverIssues);
const { root } = renderWithQueryClient(
@@ -407,14 +429,14 @@ describe("IssuesList", () => {
container,
);
await waitForAssertion(() => {
await waitForMicrotaskAssertion(() => {
expect(container.textContent).toContain("Showing up to 200 matches. Refine the search to narrow further.");
});
act(() => {
root.unmount();
});
});
}, 10_000);
it("loads board issues with a separate result limit for each status column", async () => {
localStorage.setItem(
@@ -544,8 +566,8 @@ describe("IssuesList", () => {
);
await waitForAssertion(() => {
expect(container.querySelectorAll('[data-testid="issue-row"]')).toHaveLength(150);
expect(container.textContent).toContain("Rendering 150 of 220 issues");
expect(container.querySelectorAll('[data-testid="issue-row"]')).toHaveLength(100);
expect(container.textContent).toContain("Rendering 100 of 220 issues");
});
act(() => {

View File

@@ -23,6 +23,7 @@ import {
issuePriorityOrder,
normalizeIssueFilterState,
resolveIssueFilterWorkspaceId,
shouldIncludeIssueFilterWorkspaceOption,
issueStatusOrder,
type IssueFilterState,
} from "../lib/issue-filters";
@@ -61,7 +62,7 @@ import { ISSUE_STATUSES, type Issue, type Project } from "@paperclipai/shared";
const ISSUE_SEARCH_DEBOUNCE_MS = 250;
const ISSUE_SEARCH_RESULT_LIMIT = 200;
const ISSUE_BOARD_COLUMN_RESULT_LIMIT = 200;
const INITIAL_ISSUE_ROW_RENDER_LIMIT = 150;
const INITIAL_ISSUE_ROW_RENDER_LIMIT = 100;
const ISSUE_ROW_RENDER_BATCH_SIZE = 150;
const ISSUE_ROW_RENDER_BATCH_DELAY_MS = 0;
const boardIssueStatuses = ISSUE_STATUSES;
@@ -483,6 +484,10 @@ export function IssuesList({
}
return map;
}, [projects]);
const defaultProjectWorkspaceIds = useMemo(
() => new Set(defaultProjectWorkspaceIdByProjectId.values()),
[defaultProjectWorkspaceIdByProjectId],
);
const executionWorkspaceById = useMemo(() => {
const map = new Map<string, {
@@ -499,17 +504,27 @@ export function IssuesList({
}
return map;
}, [executionWorkspaces]);
const issueFilterWorkspaceContext = useMemo(() => ({
executionWorkspaceById,
defaultProjectWorkspaceIdByProjectId,
}), [defaultProjectWorkspaceIdByProjectId, executionWorkspaceById]);
const workspaceNameMap = useMemo(() => {
const map = new Map<string, string>();
for (const [workspaceId, workspace] of projectWorkspaceById) {
if (!shouldIncludeIssueFilterWorkspaceOption({ id: workspaceId }, defaultProjectWorkspaceIds)) continue;
map.set(workspaceId, workspace.name);
}
for (const [workspaceId, workspace] of executionWorkspaceById) {
if (!shouldIncludeIssueFilterWorkspaceOption({
id: workspaceId,
mode: workspace.mode,
projectWorkspaceId: workspace.projectWorkspaceId,
}, defaultProjectWorkspaceIds)) continue;
map.set(workspaceId, workspace.name);
}
return map;
}, [executionWorkspaceById, projectWorkspaceById]);
}, [defaultProjectWorkspaceIds, executionWorkspaceById, projectWorkspaceById]);
const workspaceOptions = useMemo(() => {
const options = new Map<string, string>();
@@ -635,9 +650,27 @@ export function IssuesList({
const searchScopedIssues = normalizedIssueSearch.length > 0 && searchWithinLoadedIssues
? sourceIssues.filter((issue) => issueMatchesLocalSearch(issue, normalizedIssueSearch))
: sourceIssues;
const filteredByControls = applyIssueFilters(searchScopedIssues, viewState, currentUserId, enableRoutineVisibilityFilter);
const filteredByControls = applyIssueFilters(
searchScopedIssues,
viewState,
currentUserId,
enableRoutineVisibilityFilter,
liveIssueIds,
issueFilterWorkspaceContext,
);
return sortIssues(filteredByControls, viewState);
}, [boardIssues, issues, searchedIssues, searchWithinLoadedIssues, viewState, normalizedIssueSearch, currentUserId, enableRoutineVisibilityFilter]);
}, [
boardIssues,
issues,
searchedIssues,
searchWithinLoadedIssues,
viewState,
normalizedIssueSearch,
currentUserId,
enableRoutineVisibilityFilter,
liveIssueIds,
issueFilterWorkspaceContext,
]);
const { data: labels } = useQuery({
queryKey: queryKeys.issues.labels(selectedCompanyId!),
@@ -664,7 +697,10 @@ export function IssuesList({
.map((p) => ({ key: p, label: issueFilterLabel(p), items: groups[p]! }));
}
if (viewState.groupBy === "workspace") {
const groups = groupBy(filtered, (issue) => resolveIssueFilterWorkspaceId(issue) ?? "__no_workspace");
const groups = groupBy(
filtered,
(issue) => resolveIssueFilterWorkspaceId(issue, issueFilterWorkspaceContext) ?? "__no_workspace",
);
return Object.keys(groups)
.sort((a, b) => {
// Groups with items first, "no workspace" last
@@ -708,7 +744,17 @@ export function IssuesList({
: (agentName(key) ?? key.slice(0, 8)),
items: groups[key]!,
}));
}, [filtered, viewState.groupBy, agents, agentName, currentUserId, workspaceNameMap, issueTitleMap, companyUserLabelMap]);
}, [
filtered,
issueFilterWorkspaceContext,
viewState.groupBy,
agents,
agentName,
currentUserId,
workspaceNameMap,
issueTitleMap,
companyUserLabelMap,
]);
useEffect(() => {
if (viewState.viewMode !== "list") return;
@@ -1087,7 +1133,7 @@ export function IssuesList({
</button>
) : (
<span onClick={(e) => { e.preventDefault(); e.stopPropagation(); }}>
<StatusIcon status={issue.status} onChange={(s) => onUpdateIssue(issue.id, { status: s })} />
<StatusIcon status={issue.status} blockerAttention={issue.blockerAttention} onChange={(s) => onUpdateIssue(issue.id, { status: s })} />
</span>
)
}
@@ -1111,7 +1157,7 @@ export function IssuesList({
showIdentifier={visibleIssueColumnSet.has("id") && availableIssueColumnSet.has("id")}
statusSlot={(
<span onClick={(e) => { e.preventDefault(); e.stopPropagation(); }}>
<StatusIcon status={issue.status} onChange={(s) => onUpdateIssue(issue.id, { status: s })} />
<StatusIcon status={issue.status} blockerAttention={issue.blockerAttention} onChange={(s) => onUpdateIssue(issue.id, { status: s })} />
</span>
)}
/>
@@ -1125,7 +1171,7 @@ export function IssuesList({
columns={visibleTrailingIssueColumns}
projectName={issueProject?.name ?? null}
projectColor={issueProject?.color ?? null}
workspaceId={resolveIssueFilterWorkspaceId(issue)}
workspaceId={resolveIssueFilterWorkspaceId(issue, issueFilterWorkspaceContext)}
workspaceName={resolveIssueWorkspaceName(issue, {
executionWorkspaceById,
projectWorkspaceById,

View File

@@ -59,6 +59,8 @@ export function LiveRunWidget({ issueId, companyId }: LiveRunWidgetProps) {
agentId: activeRun.agentId,
agentName: activeRun.agentName,
adapterType: activeRun.adapterType,
logBytes: activeRun.logBytes,
lastOutputBytes: activeRun.lastOutputBytes,
issueId,
});
}

View File

@@ -0,0 +1,72 @@
// @vitest-environment node
import { renderToStaticMarkup } from "react-dom/server";
import { describe, expect, it } from "vitest";
import { StatusIcon } from "./StatusIcon";
describe("StatusIcon", () => {
it("renders covered blocked issues with the cyan covered state visual", () => {
const html = renderToStaticMarkup(
<StatusIcon
status="blocked"
blockerAttention={{
state: "covered",
reason: "active_child",
unresolvedBlockerCount: 1,
coveredBlockerCount: 1,
attentionBlockerCount: 0,
sampleBlockerIdentifier: "PAP-2",
}}
/>,
);
expect(html).toContain('data-blocker-attention-state="covered"');
expect(html).toContain('aria-label="Blocked · waiting on active sub-issue PAP-2"');
expect(html).toContain('title="Blocked · waiting on active sub-issue PAP-2"');
expect(html).toContain("border-cyan-600");
expect(html).not.toContain("border-red-600");
expect(html).not.toContain("border-dashed");
expect(html).toContain("-bottom-0.5");
});
it("uses covered blocked copy for the active dependency count matrix", () => {
const html = renderToStaticMarkup(
<StatusIcon
status="blocked"
blockerAttention={{
state: "covered",
reason: "active_dependency",
unresolvedBlockerCount: 2,
coveredBlockerCount: 2,
attentionBlockerCount: 0,
sampleBlockerIdentifier: null,
}}
/>,
);
expect(html).toContain('aria-label="Blocked · covered by 2 active dependencies"');
expect(html).toContain("border-cyan-600");
expect(html).not.toContain("border-dashed");
});
it("keeps normal blocked issues on the attention-required visual", () => {
const html = renderToStaticMarkup(
<StatusIcon
status="blocked"
blockerAttention={{
state: "needs_attention",
reason: "attention_required",
unresolvedBlockerCount: 1,
coveredBlockerCount: 0,
attentionBlockerCount: 1,
sampleBlockerIdentifier: "PAP-2",
}}
/>,
);
expect(html).not.toContain('data-blocker-attention-state="covered"');
expect(html).toContain('aria-label="Blocked · 1 unresolved blocker needs attention"');
expect(html).toContain("border-red-600");
expect(html).not.toContain("border-dashed");
});
});

View File

@@ -1,4 +1,5 @@
import { useState } from "react";
import type { IssueBlockerAttention } from "@paperclipai/shared";
import { cn } from "../lib/utils";
import { issueStatusIcon, issueStatusIconDefault } from "../lib/status-colors";
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
@@ -12,15 +13,49 @@ function statusLabel(status: string): string {
interface StatusIconProps {
status: string;
blockerAttention?: IssueBlockerAttention | null;
onChange?: (status: string) => void;
className?: string;
showLabel?: boolean;
}
export function StatusIcon({ status, onChange, className, showLabel }: StatusIconProps) {
function blockedAttentionLabel(blockerAttention: IssueBlockerAttention | null | undefined) {
if (!blockerAttention || blockerAttention.state === "none") return "Blocked";
if (blockerAttention.reason === "active_child") {
const count = blockerAttention.coveredBlockerCount;
if (count === 1 && blockerAttention.sampleBlockerIdentifier) {
return `Blocked · waiting on active sub-issue ${blockerAttention.sampleBlockerIdentifier}`;
}
if (count === 1) return "Blocked · waiting on 1 active sub-issue";
return `Blocked · waiting on ${count} active sub-issues`;
}
if (blockerAttention.reason === "active_dependency") {
const count = blockerAttention.coveredBlockerCount;
if (count === 1 && blockerAttention.sampleBlockerIdentifier) {
return `Blocked · covered by active dependency ${blockerAttention.sampleBlockerIdentifier}`;
}
if (count === 1) return "Blocked · covered by 1 active dependency";
return `Blocked · covered by ${count} active dependencies`;
}
if (blockerAttention.reason === "attention_required") {
const count = blockerAttention.unresolvedBlockerCount;
return `Blocked · ${count} unresolved ${count === 1 ? "blocker needs" : "blockers need"} attention`;
}
return "Blocked";
}
export function StatusIcon({ status, blockerAttention, onChange, className, showLabel }: StatusIconProps) {
const [open, setOpen] = useState(false);
const colorClass = issueStatusIcon[status] ?? issueStatusIconDefault;
const isCoveredBlocked = status === "blocked" && blockerAttention?.state === "covered";
const colorClass = isCoveredBlocked
? "text-cyan-600 border-cyan-600 dark:text-cyan-400 dark:border-cyan-400"
: issueStatusIcon[status] ?? issueStatusIconDefault;
const isDone = status === "done";
const ariaLabel = status === "blocked" ? blockedAttentionLabel(blockerAttention) : statusLabel(status);
const circle = (
<span
@@ -30,10 +65,16 @@ export function StatusIcon({ status, onChange, className, showLabel }: StatusIco
onChange && !showLabel && "cursor-pointer",
className
)}
data-blocker-attention-state={isCoveredBlocked ? "covered" : undefined}
aria-label={ariaLabel}
title={ariaLabel}
>
{isDone && (
<span className="absolute inset-0 m-auto h-2 w-2 rounded-full bg-current" />
)}
{isCoveredBlocked && (
<span className="absolute -bottom-0.5 -right-0.5 h-2 w-2 rounded-full border border-background bg-current" />
)}
</span>
);

View File

@@ -110,4 +110,23 @@ describe("RunTranscriptView", () => {
expect(html).toMatch(/<li[^>]*>posted issue update<\/li>/);
expect(html).not.toContain("result");
});
it("windows large raw transcripts instead of rendering every entry at once", () => {
const entries: TranscriptEntry[] = Array.from({ length: 500 }, (_, index) => ({
kind: "stdout",
ts: `2026-03-12T00:${String(index % 60).padStart(2, "0")}:00.000Z`,
text: `line-${index}`,
}));
const html = renderToStaticMarkup(
<ThemeProvider>
<RunTranscriptView mode="raw" entries={entries} />
</ThemeProvider>,
);
expect(html).toContain("line-0");
expect(html).toContain("line-179");
expect(html).not.toContain("line-250");
expect(html).not.toContain("line-499");
});
});

View File

@@ -1,4 +1,4 @@
import { useMemo, useState } from "react";
import { useEffect, useMemo, useRef, useState } from "react";
import type { TranscriptEntry } from "../../adapters";
import { MarkdownBody } from "../MarkdownBody";
import { cn, formatTokens } from "../../lib/utils";
@@ -16,6 +16,11 @@ import {
export type TranscriptMode = "nice" | "raw";
export type TranscriptDensity = "comfortable" | "compact";
const RAW_VIRTUALIZATION_THRESHOLD = 300;
const RAW_OVERSCAN_ROWS = 40;
const RAW_ESTIMATED_ROW_HEIGHT = 36;
const RAW_INITIAL_ROWS = 180;
interface RunTranscriptViewProps {
entries: TranscriptEntry[];
mode?: TranscriptMode;
@@ -1347,6 +1352,34 @@ function TranscriptStdoutRow({
);
}
function findScrollParent(element: HTMLElement): HTMLElement | Window {
let current = element.parentElement;
while (current) {
const style = window.getComputedStyle(current);
if (/(auto|scroll)/.test(style.overflowY) && current.scrollHeight > current.clientHeight) {
return current;
}
current = current.parentElement;
}
return window;
}
function rawEntryContent(entry: TranscriptEntry): string {
if (entry.kind === "tool_call") {
return `${entry.name}\n${formatToolPayload(entry.input)}`;
}
if (entry.kind === "tool_result") {
return formatToolPayload(entry.content);
}
if (entry.kind === "result") {
return `${entry.text}\n${formatTokens(entry.inputTokens)} / ${formatTokens(entry.outputTokens)} / $${entry.costUsd.toFixed(6)}`;
}
if (entry.kind === "init") {
return `model=${entry.model}${entry.sessionId ? ` session=${entry.sessionId}` : ""}`;
}
return entry.text;
}
function RawTranscriptView({
entries,
density,
@@ -1355,11 +1388,63 @@ function RawTranscriptView({
density: TranscriptDensity;
}) {
const compact = density === "compact";
const listRef = useRef<HTMLDivElement | null>(null);
const shouldVirtualize = entries.length > RAW_VIRTUALIZATION_THRESHOLD;
const [range, setRange] = useState(() => ({
start: 0,
end: Math.min(entries.length, shouldVirtualize ? RAW_INITIAL_ROWS : entries.length),
}));
useEffect(() => {
if (!shouldVirtualize) {
setRange({ start: 0, end: entries.length });
return;
}
const list = listRef.current;
if (!list) return;
const scrollParent = findScrollParent(list);
const updateRange = () => {
const scrollElement: HTMLElement | null = scrollParent === window ? null : (scrollParent as HTMLElement);
const scrollerTop = scrollElement ? scrollElement.getBoundingClientRect().top : 0;
const scrollerHeight = scrollElement ? scrollElement.clientHeight : window.innerHeight;
const listTop = list.getBoundingClientRect().top;
const visibleTop = Math.max(0, scrollerTop - listTop);
const visibleBottom = Math.max(visibleTop + scrollerHeight, 0);
const nextStart = Math.max(0, Math.floor(visibleTop / RAW_ESTIMATED_ROW_HEIGHT) - RAW_OVERSCAN_ROWS);
const nextEnd = Math.min(
entries.length,
Math.ceil(visibleBottom / RAW_ESTIMATED_ROW_HEIGHT) + RAW_OVERSCAN_ROWS,
);
setRange((current) => (
current.start === nextStart && current.end === nextEnd
? current
: { start: nextStart, end: nextEnd }
));
};
updateRange();
const frame = window.requestAnimationFrame(updateRange);
scrollParent.addEventListener("scroll", updateRange, { passive: true });
window.addEventListener("resize", updateRange);
return () => {
window.cancelAnimationFrame(frame);
scrollParent.removeEventListener("scroll", updateRange);
window.removeEventListener("resize", updateRange);
};
}, [entries.length, shouldVirtualize]);
const visibleEntries = shouldVirtualize ? entries.slice(range.start, range.end) : entries;
const topSpacer = shouldVirtualize ? range.start * RAW_ESTIMATED_ROW_HEIGHT : 0;
const bottomSpacer = shouldVirtualize ? Math.max(0, entries.length - range.end) * RAW_ESTIMATED_ROW_HEIGHT : 0;
return (
<div className={cn("font-mono", compact ? "space-y-1 text-[11px]" : "space-y-1.5 text-xs")}>
{entries.map((entry, idx) => (
<div ref={listRef} className={cn("font-mono", compact ? "space-y-1 text-[11px]" : "space-y-1.5 text-xs")}>
{topSpacer > 0 && <div aria-hidden="true" style={{ height: topSpacer }} />}
{visibleEntries.map((entry, idx) => (
<div
key={`${entry.kind}-${entry.ts}-${idx}`}
key={`${entry.kind}-${entry.ts}-${range.start + idx}`}
className={cn(
"grid gap-x-3",
"grid-cols-[auto_1fr]",
@@ -1369,18 +1454,11 @@ function RawTranscriptView({
{entry.kind}
</span>
<pre className="min-w-0 whitespace-pre-wrap break-words text-foreground/80">
{entry.kind === "tool_call"
? `${entry.name}\n${formatToolPayload(entry.input)}`
: entry.kind === "tool_result"
? formatToolPayload(entry.content)
: entry.kind === "result"
? `${entry.text}\n${formatTokens(entry.inputTokens)} / ${formatTokens(entry.outputTokens)} / $${entry.costUsd.toFixed(6)}`
: entry.kind === "init"
? `model=${entry.model}${entry.sessionId ? ` session=${entry.sessionId}` : ""}`
: entry.text}
{rawEntryContent(entry)}
</pre>
</div>
))}
{bottomSpacer > 0 && <div aria-hidden="true" style={{ height: bottomSpacer }} />}
</div>
);
}
@@ -1396,7 +1474,10 @@ export function RunTranscriptView({
className,
thinkingClassName,
}: RunTranscriptViewProps) {
const blocks = useMemo(() => normalizeTranscript(entries, streaming), [entries, streaming]);
const blocks = useMemo(
() => (mode === "raw" ? [] : normalizeTranscript(entries, streaming)),
[entries, mode, streaming],
);
const visibleBlocks = limit ? blocks.slice(-limit) : blocks;
const visibleEntries = limit ? entries.slice(-limit) : entries;

View File

@@ -258,6 +258,34 @@ describe("useLiveRunTranscripts", () => {
container.remove();
});
it("starts persisted-log hydration from the newest bytes when the visible window is truncated", async () => {
function Harness() {
useLiveRunTranscripts({
companyId: "company-1",
runs: [{ id: "run-1", status: "running", adapterType: "codex_local", lastOutputBytes: 100_000 }],
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(logMock).toHaveBeenCalledWith("run-1", 36_000, 64_000);
act(() => {
root.unmount();
});
container.remove();
});
it("rebuilds only the transcript for the run that receives live output", async () => {
function Harness() {
useLiveRunTranscripts({

View File

@@ -16,6 +16,8 @@ export interface RunTranscriptSource {
status: string;
adapterType: string;
hasStoredOutput?: boolean;
logBytes?: number | null;
lastOutputBytes?: number | null;
}
interface UseLiveRunTranscriptsOptions {
@@ -35,6 +37,19 @@ function isTerminalStatus(status: string): boolean {
return status === "failed" || status === "timed_out" || status === "cancelled" || status === "succeeded";
}
function runKnownLogBytes(run: RunTranscriptSource): number | null {
const bytes = run.status === "queued"
? run.logBytes
: run.lastOutputBytes ?? run.logBytes;
return typeof bytes === "number" && Number.isFinite(bytes) && bytes > 0 ? bytes : null;
}
export function resolveInitialLogOffset(run: RunTranscriptSource, limitBytes: number): number {
const knownBytes = runKnownLogBytes(run);
if (knownBytes === null) return 0;
return Math.max(0, knownBytes - Math.max(0, limitBytes));
}
function parsePersistedLogContent(
runId: string,
content: string,
@@ -82,7 +97,11 @@ export function useLiveRunTranscripts({
const runsKey = useMemo(
() =>
runs
.map((run) => `${run.id}:${run.status}:${run.adapterType}:${run.hasStoredOutput === true ? "1" : "0"}`)
.map((run) => {
const logBytes = typeof run.logBytes === "number" ? run.logBytes : "";
const lastOutputBytes = typeof run.lastOutputBytes === "number" ? run.lastOutputBytes : "";
return `${run.id}:${run.status}:${run.adapterType}:${run.hasStoredOutput === true ? "1" : "0"}:${logBytes}:${lastOutputBytes}`;
})
.sort((a, b) => a.localeCompare(b))
.join(","),
[runs],
@@ -197,7 +216,7 @@ export function useLiveRunTranscripts({
if (missingTerminalLogRunIdsRef.current.has(run.id)) {
return;
}
const offset = logOffsetByRunRef.current.get(run.id) ?? 0;
const offset = logOffsetByRunRef.current.get(run.id) ?? resolveInitialLogOffset(run, logReadLimitBytes);
try {
const result = await heartbeatsApi.log(run.id, offset, logReadLimitBytes);
if (cancelled) return;

View File

@@ -147,6 +147,10 @@ function makeRun(id: string, status: HeartbeatRun["status"], createdAt: string,
logBytes: null,
logSha256: null,
logCompressed: false,
lastOutputAt: null,
lastOutputSeq: 0,
lastOutputStream: null,
lastOutputBytes: null,
errorCode: null,
externalRunId: null,
processPid: null,
@@ -837,6 +841,7 @@ describe("inbox helpers", () => {
labels: [],
projects: [],
workspaces: [],
liveOnly: false,
hideRoutineExecutions: true,
},
}).map((issue) => issue.id),
@@ -856,6 +861,7 @@ describe("inbox helpers", () => {
labels: [],
projects: [],
workspaces: [],
liveOnly: false,
hideRoutineExecutions: true,
},
}),
@@ -875,6 +881,7 @@ describe("inbox helpers", () => {
labels: [],
projects: [],
workspaces: [],
liveOnly: false,
hideRoutineExecutions: true,
},
}),
@@ -940,6 +947,7 @@ describe("inbox helpers", () => {
labels: ["label-1"],
projects: ["project-1"],
workspaces: ["workspace-1"],
liveOnly: true,
hideRoutineExecutions: false,
},
});
@@ -954,6 +962,7 @@ describe("inbox helpers", () => {
labels: [],
projects: [],
workspaces: [],
liveOnly: false,
hideRoutineExecutions: true,
},
});
@@ -969,6 +978,7 @@ describe("inbox helpers", () => {
labels: ["label-1"],
projects: ["project-1"],
workspaces: ["workspace-1"],
liveOnly: true,
hideRoutineExecutions: false,
},
});
@@ -983,6 +993,7 @@ describe("inbox helpers", () => {
labels: [],
projects: [],
workspaces: [],
liveOnly: false,
hideRoutineExecutions: true,
},
});
@@ -1000,6 +1011,7 @@ describe("inbox helpers", () => {
labels: null,
projects: ["project-1"],
workspaces: ["workspace-1", false],
liveOnly: "yes",
hideRoutineExecutions: "yes",
},
}));
@@ -1015,6 +1027,7 @@ describe("inbox helpers", () => {
labels: [],
projects: ["project-1"],
workspaces: ["workspace-1"],
liveOnly: false,
hideRoutineExecutions: false,
},
});

View File

@@ -445,6 +445,7 @@ export function getInboxSearchSupplementIssues({
issueFilters,
currentUserId,
enableRoutineVisibilityFilter = false,
liveIssueIds,
}: {
query: string;
filteredWorkItems: InboxWorkItem[];
@@ -453,6 +454,7 @@ export function getInboxSearchSupplementIssues({
issueFilters: IssueFilterState;
currentUserId?: string | null;
enableRoutineVisibilityFilter?: boolean;
liveIssueIds?: ReadonlySet<string>;
}): Issue[] {
const normalizedQuery = query.trim();
if (!normalizedQuery) return [];
@@ -462,7 +464,7 @@ export function getInboxSearchSupplementIssues({
.map((item) => item.issue.id),
...archivedSearchIssues.map((issue) => issue.id),
]);
return applyIssueFilters(remoteIssues, issueFilters, currentUserId, enableRoutineVisibilityFilter)
return applyIssueFilters(remoteIssues, issueFilters, currentUserId, enableRoutineVisibilityFilter, liveIssueIds)
.filter((issue) => !visibleIssueIds.has(issue.id));
}

View File

@@ -31,6 +31,7 @@ export interface IssueChatComment extends IssueComment {
queueState?: "queued";
queueTargetRunId?: string | null;
queueReason?: "hold" | "active_run" | "other";
followUpRequested?: boolean;
}
export interface IssueChatLinkedRun {
@@ -43,6 +44,7 @@ export interface IssueChatLinkedRun {
startedAt: Date | string | null;
finishedAt?: Date | string | null;
hasStoredOutput?: boolean;
logBytes?: number | null;
}
export interface IssueChatTranscriptEntry {
@@ -318,6 +320,7 @@ function createCommentMessage(args: {
queueTargetRunId: comment.queueTargetRunId ?? null,
queueReason: comment.queueReason ?? null,
interruptedRunId: comment.interruptedRunId ?? null,
followUpRequested: comment.followUpRequested === true,
};
if (comment.authorAgentId) {
@@ -356,7 +359,9 @@ function createTimelineEventMessage(args: {
? "System"
: (formatAssigneeUserLabel(event.actorId, currentUserId, userLabelMap) ?? "Board");
const lines: string[] = [`${actorName} updated this issue`];
const lines: string[] = [
event.followUpRequested ? `${actorName} requested follow-up` : `${actorName} updated this issue`,
];
if (event.statusChange) {
lines.push(
`Status: ${event.statusChange.from ?? "none"} -> ${event.statusChange.to ?? "none"}`,
@@ -387,6 +392,7 @@ function createTimelineEventMessage(args: {
actorId: event.actorId,
statusChange: event.statusChange ?? null,
assigneeChange: event.assigneeChange ?? null,
followUpRequested: event.followUpRequested === true,
},
},
};

View File

@@ -2,7 +2,13 @@
import { describe, expect, it } from "vitest";
import type { Issue } from "@paperclipai/shared";
import { applyIssueFilters, countActiveIssueFilters, defaultIssueFilterState } from "./issue-filters";
import {
applyIssueFilters,
countActiveIssueFilters,
defaultIssueFilterState,
resolveIssueFilterWorkspaceId,
shouldIncludeIssueFilterWorkspaceOption,
} from "./issue-filters";
function makeIssue(overrides: Partial<Issue> = {}): Issue {
return {
@@ -66,4 +72,100 @@ describe("issue filters", () => {
creators: ["user:user-1"],
})).toBe(1);
});
it("filters issues to live issue ids when live-only is enabled", () => {
const issues = [
makeIssue({ id: "live-issue" }),
makeIssue({ id: "idle-issue" }),
];
const filtered = applyIssueFilters(
issues,
{ ...defaultIssueFilterState, liveOnly: true },
null,
false,
new Set(["live-issue"]),
);
expect(filtered.map((issue) => issue.id)).toEqual(["live-issue"]);
});
it("counts the live-only filter as an active filter group", () => {
expect(countActiveIssueFilters({
...defaultIssueFilterState,
liveOnly: true,
})).toBe(1);
});
it("does not treat default project workspaces as workspace filter matches", () => {
const issue = makeIssue({
id: "default-workspace-issue",
projectId: "project-1",
projectWorkspaceId: "workspace-default",
});
const workspaceContext = {
defaultProjectWorkspaceIdByProjectId: new Map([["project-1", "workspace-default"]]),
};
expect(resolveIssueFilterWorkspaceId(issue, workspaceContext)).toBeNull();
expect(applyIssueFilters(
[issue],
{ ...defaultIssueFilterState, workspaces: ["workspace-default"] },
null,
false,
undefined,
workspaceContext,
)).toEqual([]);
});
it("does not treat shared default execution workspaces as workspace filter matches", () => {
const issue = makeIssue({
id: "shared-default-issue",
projectId: "project-1",
projectWorkspaceId: "workspace-default",
executionWorkspaceId: "execution-shared-default",
});
const workspaceContext = {
executionWorkspaceById: new Map([[
"execution-shared-default",
{ mode: "shared_workspace", projectWorkspaceId: "workspace-default" },
]]),
defaultProjectWorkspaceIdByProjectId: new Map([["project-1", "workspace-default"]]),
};
expect(resolveIssueFilterWorkspaceId(issue, workspaceContext)).toBeNull();
expect(shouldIncludeIssueFilterWorkspaceOption(
{ id: "execution-shared-default", mode: "shared_workspace", projectWorkspaceId: "workspace-default" },
new Set(["workspace-default"]),
)).toBe(false);
});
it("keeps non-default project and isolated execution workspaces filterable", () => {
const featureIssue = makeIssue({
id: "feature-issue",
projectId: "project-1",
projectWorkspaceId: "workspace-feature",
});
const executionIssue = makeIssue({
id: "execution-issue",
projectId: "project-1",
projectWorkspaceId: "workspace-default",
executionWorkspaceId: "execution-isolated",
});
const workspaceContext = {
executionWorkspaceById: new Map([[
"execution-isolated",
{ mode: "isolated_workspace", projectWorkspaceId: "workspace-default" },
]]),
defaultProjectWorkspaceIdByProjectId: new Map([["project-1", "workspace-default"]]),
};
expect(resolveIssueFilterWorkspaceId(featureIssue, workspaceContext)).toBe("workspace-feature");
expect(resolveIssueFilterWorkspaceId(executionIssue, workspaceContext)).toBe("execution-isolated");
expect(shouldIncludeIssueFilterWorkspaceOption({ id: "workspace-feature" }, new Set(["workspace-default"]))).toBe(true);
expect(shouldIncludeIssueFilterWorkspaceOption(
{ id: "execution-isolated", mode: "isolated_workspace", projectWorkspaceId: "workspace-default" },
new Set(["workspace-default"]),
)).toBe(true);
});
});

View File

@@ -1,5 +1,15 @@
import type { Issue } from "@paperclipai/shared";
export type IssueFilterWorkspaceLookup = {
mode?: string | null;
projectWorkspaceId?: string | null;
};
export type IssueFilterWorkspaceContext = {
executionWorkspaceById?: ReadonlyMap<string, IssueFilterWorkspaceLookup>;
defaultProjectWorkspaceIdByProjectId?: ReadonlyMap<string, string>;
};
export type IssueFilterState = {
statuses: string[];
priorities: string[];
@@ -8,6 +18,7 @@ export type IssueFilterState = {
labels: string[];
projects: string[];
workspaces: string[];
liveOnly?: boolean;
hideRoutineExecutions: boolean;
};
@@ -19,6 +30,7 @@ export const defaultIssueFilterState: IssueFilterState = {
labels: [],
projects: [],
workspaces: [],
liveOnly: false,
hideRoutineExecutions: false,
};
@@ -59,6 +71,7 @@ export function normalizeIssueFilterState(value: unknown): IssueFilterState {
labels: normalizeIssueFilterValueArray(candidate.labels),
projects: normalizeIssueFilterValueArray(candidate.projects),
workspaces: normalizeIssueFilterValueArray(candidate.workspaces),
liveOnly: candidate.liveOnly === true,
hideRoutineExecutions: candidate.hideRoutineExecutions === true,
};
}
@@ -68,9 +81,41 @@ export function toggleIssueFilterValue(values: string[], value: string): string[
}
export function resolveIssueFilterWorkspaceId(
issue: Pick<Issue, "executionWorkspaceId" | "projectWorkspaceId">,
issue: Pick<Issue, "executionWorkspaceId" | "projectId" | "projectWorkspaceId">,
context: IssueFilterWorkspaceContext = {},
): string | null {
return issue.executionWorkspaceId ?? issue.projectWorkspaceId ?? null;
const defaultProjectWorkspaceId = issue.projectId
? context.defaultProjectWorkspaceIdByProjectId?.get(issue.projectId) ?? null
: null;
if (issue.executionWorkspaceId) {
const executionWorkspace = context.executionWorkspaceById?.get(issue.executionWorkspaceId) ?? null;
const linkedProjectWorkspaceId =
executionWorkspace?.projectWorkspaceId ?? issue.projectWorkspaceId ?? null;
const isDefaultSharedExecutionWorkspace =
executionWorkspace?.mode === "shared_workspace"
&& linkedProjectWorkspaceId != null
&& linkedProjectWorkspaceId === defaultProjectWorkspaceId;
if (isDefaultSharedExecutionWorkspace) return null;
return issue.executionWorkspaceId;
}
if (issue.projectWorkspaceId) {
if (issue.projectWorkspaceId === defaultProjectWorkspaceId) return null;
return issue.projectWorkspaceId;
}
return null;
}
export function shouldIncludeIssueFilterWorkspaceOption(
workspace: { id: string; mode?: string | null; projectWorkspaceId?: string | null },
defaultProjectWorkspaceIds: ReadonlySet<string>,
): boolean {
if (defaultProjectWorkspaceIds.has(workspace.id)) return false;
return !(workspace.mode === "shared_workspace"
&& workspace.projectWorkspaceId != null
&& defaultProjectWorkspaceIds.has(workspace.projectWorkspaceId));
}
export function applyIssueFilters(
@@ -78,8 +123,13 @@ export function applyIssueFilters(
state: IssueFilterState,
currentUserId?: string | null,
enableRoutineVisibilityFilter = false,
liveIssueIds?: ReadonlySet<string>,
workspaceContext: IssueFilterWorkspaceContext = {},
): Issue[] {
let result = issues;
if (state.liveOnly) {
result = result.filter((issue) => liveIssueIds?.has(issue.id) === true);
}
if (enableRoutineVisibilityFilter && state.hideRoutineExecutions) {
result = result.filter((issue) => issue.originKind !== "routine_execution");
}
@@ -112,7 +162,7 @@ export function applyIssueFilters(
}
if (state.workspaces.length > 0) {
result = result.filter((issue) => {
const workspaceId = resolveIssueFilterWorkspaceId(issue);
const workspaceId = resolveIssueFilterWorkspaceId(issue, workspaceContext);
return workspaceId != null && state.workspaces.includes(workspaceId);
});
}
@@ -131,6 +181,7 @@ export function countActiveIssueFilters(
if (state.labels.length > 0) count += 1;
if (state.projects.length > 0) count += 1;
if (state.workspaces.length > 0) count += 1;
if (state.liveOnly) count += 1;
if (enableRoutineVisibilityFilter && state.hideRoutineExecutions) count += 1;
return count;
}

View File

@@ -126,6 +126,80 @@ describe("extractIssueTimelineEvents", () => {
]);
});
it("marks explicit follow-up timeline updates", () => {
const events = extractIssueTimelineEvents([
{
id: "evt-follow-up",
companyId: "company-1",
actorType: "agent",
actorId: "agent-1",
action: "issue.updated",
entityType: "issue",
entityId: "issue-1",
agentId: "agent-1",
runId: "run-1",
createdAt: new Date("2026-03-31T12:01:00.000Z"),
details: {
status: "todo",
reopened: true,
reopenedFrom: "done",
source: "comment",
commentId: "comment-1",
resumeIntent: true,
followUpRequested: true,
},
},
] satisfies ActivityEvent[]);
expect(events).toEqual([
{
id: "evt-follow-up",
createdAt: new Date("2026-03-31T12:01:00.000Z"),
actorType: "agent",
actorId: "agent-1",
commentId: "comment-1",
followUpRequested: true,
statusChange: {
from: "done",
to: "todo",
},
},
]);
});
it("synthesizes non-status follow-up rows from comment activity", () => {
const events = extractIssueTimelineEvents([
{
id: "evt-comment-follow-up",
companyId: "company-1",
actorType: "agent",
actorId: "agent-1",
action: "issue.comment_added",
entityType: "issue",
entityId: "issue-1",
agentId: "agent-1",
runId: "run-1",
createdAt: new Date("2026-03-31T12:01:00.000Z"),
details: {
commentId: "comment-1",
resumeIntent: true,
followUpRequested: true,
},
},
] satisfies ActivityEvent[]);
expect(events).toEqual([
{
id: "evt-comment-follow-up",
createdAt: new Date("2026-03-31T12:01:00.000Z"),
actorType: "agent",
actorId: "agent-1",
commentId: "comment-1",
followUpRequested: true,
},
]);
});
it("ignores issue updates without visible status or assignee transitions", () => {
const events = extractIssueTimelineEvents([
{

View File

@@ -18,6 +18,8 @@ export interface IssueTimelineEvent {
from: IssueTimelineAssignee;
to: IssueTimelineAssignee;
};
commentId?: string | null;
followUpRequested?: boolean;
}
function asRecord(value: unknown): Record<string, unknown> | null {
@@ -53,11 +55,26 @@ export function extractIssueTimelineEvents(activity: ActivityEvent[] | null | un
const events: IssueTimelineEvent[] = [];
for (const event of activity ?? []) {
if (event.action !== "issue.updated") continue;
const details = asRecord(event.details);
if (!details) continue;
if (event.action === "issue.comment_added") {
if (details.followUpRequested !== true && details.resumeIntent !== true) continue;
if (details.reopened === true) continue;
const commentId = nullableString(details.commentId);
events.push({
id: event.id,
createdAt: event.createdAt,
actorType: event.actorType,
actorId: event.actorId,
commentId,
followUpRequested: true,
});
continue;
}
if (event.action !== "issue.updated") continue;
const previous = asRecord(details._previous);
const timelineEvent: IssueTimelineEvent = {
id: event.id,
@@ -65,6 +82,10 @@ export function extractIssueTimelineEvents(activity: ActivityEvent[] | null | un
actorType: event.actorType,
actorId: event.actorId,
};
if (details.followUpRequested === true || details.resumeIntent === true) {
timelineEvent.followUpRequested = true;
timelineEvent.commentId = nullableString(details.commentId);
}
if (hasOwn(details, "status")) {
const from = nullableString(previous?.status) ?? nullableString(details.reopenedFrom);
@@ -96,7 +117,7 @@ export function extractIssueTimelineEvents(activity: ActivityEvent[] | null | un
}
}
if (timelineEvent.statusChange || timelineEvent.assigneeChange) {
if (timelineEvent.statusChange || timelineEvent.assigneeChange || timelineEvent.followUpRequested) {
events.push(timelineEvent);
}
}

View File

@@ -15,6 +15,8 @@ export function resolveIssueChatTranscriptRuns(args: {
id: run.id,
status: run.status,
adapterType: run.adapterType,
logBytes: run.logBytes,
lastOutputBytes: run.lastOutputBytes,
});
}
@@ -23,6 +25,8 @@ export function resolveIssueChatTranscriptRuns(args: {
id: activeRun.id,
status: activeRun.status,
adapterType: activeRun.adapterType,
logBytes: activeRun.logBytes,
lastOutputBytes: activeRun.lastOutputBytes,
});
}
@@ -35,6 +39,7 @@ export function resolveIssueChatTranscriptRuns(args: {
status: run.status,
adapterType,
hasStoredOutput: run.hasStoredOutput,
logBytes: run.logBytes,
});
}

View File

@@ -12,6 +12,7 @@ export type IssueDetailHeaderSeed = {
identifier: string | null;
title: string;
status: Issue["status"];
blockerAttention?: Issue["blockerAttention"];
priority: Issue["priority"];
projectId: string | null;
projectName: string | null;
@@ -47,11 +48,15 @@ function isIssueDetailHeaderSeed(value: unknown): value is IssueDetailHeaderSeed
candidate.originKind === undefined || typeof candidate.originKind === "string";
const hasOriginId =
candidate.originId === undefined || candidate.originId === null || typeof candidate.originId === "string";
const hasBlockerAttention =
candidate.blockerAttention === undefined
|| (typeof candidate.blockerAttention === "object" && candidate.blockerAttention !== null);
return (
typeof candidate.id === "string"
&& (candidate.identifier === null || typeof candidate.identifier === "string")
&& typeof candidate.title === "string"
&& typeof candidate.status === "string"
&& hasBlockerAttention
&& typeof candidate.priority === "string"
&& (candidate.projectId === null || typeof candidate.projectId === "string")
&& (candidate.projectName === null || typeof candidate.projectName === "string")
@@ -66,6 +71,7 @@ function createIssueDetailHeaderSeed(issue: Issue): IssueDetailHeaderSeed {
identifier: issue.identifier ?? null,
title: issue.title,
status: issue.status,
blockerAttention: issue.blockerAttention,
priority: issue.priority,
projectId: issue.projectId ?? null,
projectName: issue.project?.name ?? null,

View File

@@ -57,6 +57,19 @@ describe("collectLiveIssueIds", () => {
adapterType: "codex_local",
issueId: "issue-2",
},
{
id: "run-5",
status: "succeeded",
invocationSource: "scheduler",
triggerDetail: null,
startedAt: "2026-04-20T10:04:00.000Z",
finishedAt: "2026-04-20T10:05:00.000Z",
createdAt: "2026-04-20T10:04:00.000Z",
agentId: "agent-5",
agentName: "Done",
adapterType: "codex_local",
issueId: "completed-issue",
},
];
expect([...collectLiveIssueIds(liveRuns)]).toEqual(["issue-1", "issue-2"]);

View File

@@ -1,9 +1,13 @@
import type { LiveRunForIssue } from "../api/heartbeats";
function isLiveRunStatus(status: string): boolean {
return status === "queued" || status === "running";
}
export function collectLiveIssueIds(liveRuns: readonly LiveRunForIssue[] | null | undefined): Set<string> {
const ids = new Set<string>();
for (const run of liveRuns ?? []) {
if (run.issueId) ids.add(run.issueId);
if (run.issueId && isLiveRunStatus(run.status)) ids.add(run.issueId);
}
return ids;
}

View File

@@ -36,7 +36,8 @@ describe("runRetryState", () => {
).toMatchObject({
kind: "exhausted",
badgeLabel: "Retry exhausted",
detail: "Attempt 4 · Transient failure · No further automatic retry queued",
detail: "Attempt 4 · Transient failure · Automatic retries exhausted",
secondary: "Bounded retry exhausted after 4 scheduled attempts; no further automatic retry will be queued Manual intervention required.",
});
});
});

View File

@@ -76,8 +76,10 @@ export function describeRunRetryState(run: RetryAwareRun): RunRetryStateSummary
kind: "exhausted",
badgeLabel: "Retry exhausted",
tone: "border-amber-500/30 bg-amber-500/10 text-amber-700 dark:text-amber-300",
detail: joinFragments([attemptLabel, reasonLabel, "No further automatic retry queued"]),
secondary: exhaustedReason,
detail: joinFragments([attemptLabel, reasonLabel, "Automatic retries exhausted"]),
secondary: exhaustedReason.includes("Manual intervention required")
? exhaustedReason
: `${exhaustedReason} Manual intervention required.`,
retryOfRunId,
};
}

View File

@@ -110,6 +110,8 @@ const runStatusIcons: Record<string, { icon: typeof CheckCircle2; color: string
cancelled: { icon: Slash, color: "text-neutral-500 dark:text-neutral-400" },
};
const RUN_LOG_PAGE_BYTES = 256_000;
const REDACTED_ENV_VALUE = "***REDACTED***";
const SECRET_ENV_KEY_RE =
/(api[-_]?key|access[-_]?token|auth(?:_?token)?|authorization|bearer|secret|passwd|password|credential|jwt|private[-_]?key|cookie|connectionstring)/i;
@@ -3473,6 +3475,8 @@ function LogViewer({ run, adapterType }: { run: HeartbeatRun; adapterType: strin
const [logLoading, setLogLoading] = useState(!!run.logRef);
const [logError, setLogError] = useState<string | null>(null);
const [logOffset, setLogOffset] = useState(0);
const [hasMoreLog, setHasMoreLog] = useState(false);
const [loadingMoreLog, setLoadingMoreLog] = useState(false);
const [isFollowing, setIsFollowing] = useState(false);
const [isStreamingConnected, setIsStreamingConnected] = useState(false);
const [transcriptMode, setTranscriptMode] = useState<TranscriptMode>("nice");
@@ -3627,6 +3631,8 @@ function LogViewer({ run, adapterType }: { run: HeartbeatRun; adapterType: strin
pendingLogLineRef.current = "";
setLogLines([]);
setLogOffset(0);
setHasMoreLog(false);
setLoadingMoreLog(false);
setLogError(null);
if (!run.logRef && !isLive) {
@@ -3637,25 +3643,14 @@ function LogViewer({ run, adapterType }: { run: HeartbeatRun; adapterType: strin
}
setLogLoading(true);
const firstLimit =
typeof run.logBytes === "number" && run.logBytes > 0
? Math.min(Math.max(run.logBytes + 1024, 256_000), 2_000_000)
: 256_000;
const load = async () => {
try {
let offset = 0;
let first = true;
while (!cancelled) {
const result = await heartbeatsApi.log(run.id, offset, first ? firstLimit : 256_000);
if (cancelled) break;
appendLogContent(result.content, result.nextOffset === undefined);
const next = result.nextOffset ?? offset + result.content.length;
setLogOffset(next);
offset = next;
first = false;
if (result.nextOffset === undefined || isLive) break;
}
const result = await heartbeatsApi.log(run.id, 0, RUN_LOG_PAGE_BYTES);
if (cancelled) return;
appendLogContent(result.content, result.nextOffset === undefined);
const next = result.nextOffset ?? result.content.length;
setLogOffset(next);
setHasMoreLog(!isLive && result.nextOffset !== undefined);
} catch (err) {
if (!cancelled) {
if (isLive && isRunLogUnavailable(err)) {
@@ -3675,6 +3670,23 @@ function LogViewer({ run, adapterType }: { run: HeartbeatRun; adapterType: strin
};
}, [run.id, run.logRef, run.logBytes, isLive]);
async function loadMorePersistedLog() {
if (loadingMoreLog || !hasMoreLog) return;
setLoadingMoreLog(true);
setLogError(null);
try {
const result = await heartbeatsApi.log(run.id, logOffset, RUN_LOG_PAGE_BYTES);
appendLogContent(result.content, result.nextOffset === undefined);
const next = result.nextOffset ?? logOffset + result.content.length;
setLogOffset(next);
setHasMoreLog(result.nextOffset !== undefined);
} catch (err) {
setLogError(err instanceof Error ? err.message : "Failed to load more run log");
} finally {
setLoadingMoreLog(false);
}
}
// Poll for live updates
useEffect(() => {
if (!isLive || isStreamingConnected) return;
@@ -3941,6 +3953,25 @@ function LogViewer({ run, adapterType }: { run: HeartbeatRun; adapterType: strin
streaming={isLive}
emptyMessage={run.logRef ? "Waiting for transcript..." : "No persisted transcript for this run."}
/>
{hasMoreLog && (
<div className="mt-3 flex flex-wrap items-center gap-2 border-t border-border/60 pt-3">
<Button
type="button"
variant="outline"
size="xs"
onClick={loadMorePersistedLog}
disabled={loadingMoreLog}
>
{loadingMoreLog ? "Loading..." : "Load more log"}
</Button>
<span className="text-xs text-muted-foreground">
Showing the first {Math.round(logOffset / 1024).toLocaleString("en-US")} KB
{typeof run.logBytes === "number" && run.logBytes > 0
? ` of ${Math.round(run.logBytes / 1024).toLocaleString("en-US")} KB`
: ""}
</span>
</div>
)}
{logError && (
<div className="mt-3 rounded-xl border border-red-500/20 bg-red-500/[0.06] px-3 py-2 text-xs text-red-700 dark:text-red-300">
{logError}

View File

@@ -356,7 +356,7 @@ export function Dashboard() {
<div className="flex items-start gap-2 sm:items-center sm:gap-3">
{/* Status icon - left column on mobile */}
<span className="shrink-0 sm:hidden">
<StatusIcon status={issue.status} />
<StatusIcon status={issue.status} blockerAttention={issue.blockerAttention} />
</span>
{/* Right column on mobile: title + metadata stacked */}
@@ -365,7 +365,7 @@ export function Dashboard() {
{issue.title}
</span>
<span className="flex items-center gap-2 sm:order-1 sm:shrink-0">
<span className="hidden sm:inline-flex"><StatusIcon status={issue.status} /></span>
<span className="hidden sm:inline-flex"><StatusIcon status={issue.status} blockerAttention={issue.blockerAttention} /></span>
<span className="text-xs font-mono text-muted-foreground">
{issue.identifier ?? issue.id.slice(0, 8)}
</span>

View File

@@ -0,0 +1,64 @@
import { useEffect } from "react";
import { ArrowLeft, RadioTower } from "lucide-react";
import { Link } from "@/lib/router";
import { ActiveAgentsPanel } from "../components/ActiveAgentsPanel";
import { EmptyState } from "../components/EmptyState";
import { useBreadcrumbs } from "../context/BreadcrumbContext";
import { useCompany } from "../context/CompanyContext";
const DASHBOARD_LIVE_RUN_LIMIT = 50;
export function DashboardLive() {
const { selectedCompanyId, companies } = useCompany();
const { setBreadcrumbs } = useBreadcrumbs();
useEffect(() => {
setBreadcrumbs([
{ label: "Dashboard", href: "/dashboard" },
{ label: "Live runs" },
]);
}, [setBreadcrumbs]);
if (!selectedCompanyId) {
return (
<EmptyState
icon={RadioTower}
message={companies.length === 0 ? "Create a company to view live runs." : "Select a company to view live runs."}
/>
);
}
return (
<div className="space-y-5">
<div className="flex flex-col gap-3 sm:flex-row sm:items-end sm:justify-between">
<div>
<Link
to="/dashboard"
className="inline-flex items-center gap-1.5 text-sm text-muted-foreground transition-colors hover:text-foreground"
>
<ArrowLeft className="h-3.5 w-3.5" />
Dashboard
</Link>
<h1 className="mt-2 text-2xl font-semibold tracking-normal text-foreground">Live agent runs</h1>
<p className="mt-1 text-sm text-muted-foreground">
Active runs first, followed by the most recent completed runs.
</p>
</div>
<div className="text-sm text-muted-foreground">Showing up to {DASHBOARD_LIVE_RUN_LIMIT}</div>
</div>
<ActiveAgentsPanel
companyId={selectedCompanyId}
title="Active / recent"
minRunCount={DASHBOARD_LIVE_RUN_LIMIT}
fetchLimit={DASHBOARD_LIVE_RUN_LIMIT}
cardLimit={DASHBOARD_LIVE_RUN_LIMIT}
gridClassName="gap-3 md:grid-cols-2 2xl:grid-cols-3"
cardClassName="h-[420px]"
emptyMessage="No active or recent agent runs."
queryScope="dashboard-live"
showMoreLink={false}
/>
</div>
);
}

View File

@@ -25,6 +25,7 @@ import {
} from "../components/WorkspaceRuntimeControls";
import { useBreadcrumbs } from "../context/BreadcrumbContext";
import { useCompany } from "../context/CompanyContext";
import { collectLiveIssueIds } from "../lib/liveIssueIds";
import { queryKeys } from "../lib/queryKeys";
import { cn, formatDateTime, issueUrl, projectRouteRef, projectWorkspaceUrl } from "../lib/utils";
@@ -271,13 +272,7 @@ function ExecutionWorkspaceIssuesList({
refetchInterval: 5000,
});
const liveIssueIds = useMemo(() => {
const ids = new Set<string>();
for (const run of liveRuns ?? []) {
if (run.issueId) ids.add(run.issueId);
}
return ids;
}, [liveRuns]);
const liveIssueIds = useMemo(() => collectLiveIssueIds(liveRuns), [liveRuns]);
const updateIssue = useMutation({
mutationFn: ({ id, data }: { id: string; data: Record<string, unknown> }) => issuesApi.update(id, data),

View File

@@ -141,6 +141,10 @@ describe("FailedRunInboxRow", () => {
logBytes: null,
logSha256: null,
logCompressed: false,
lastOutputAt: null,
lastOutputSeq: 0,
lastOutputStream: null,
lastOutputBytes: null,
errorCode: null,
externalRunId: null,
processPid: null,

View File

@@ -23,6 +23,7 @@ import {
countActiveIssueFilters,
type IssueFilterState,
} from "../lib/issue-filters";
import { collectLiveIssueIds } from "../lib/liveIssueIds";
import { formatAssigneeUserLabel } from "../lib/assignees";
import { buildCompanyUserLabelMap, buildCompanyUserProfileMap } from "../lib/company-members";
import {
@@ -826,6 +827,7 @@ export function Inbox() {
enabled: !!selectedCompanyId,
refetchInterval: 5000,
});
const liveIssueIds = useMemo(() => collectLiveIssueIds(liveRuns), [liveRuns]);
const { data: companyMembers } = useQuery({
queryKey: queryKeys.access.companyUserDirectory(selectedCompanyId!),
queryFn: () => accessApi.listUserDirectory(selectedCompanyId!),
@@ -845,12 +847,12 @@ export function Inbox() {
const mineIssues = useMemo(() => getRecentTouchedIssues(mineIssuesRaw), [mineIssuesRaw]);
const touchedIssues = useMemo(() => getRecentTouchedIssues(touchedIssuesRaw), [touchedIssuesRaw]);
const visibleMineIssues = useMemo(
() => applyIssueFilters(mineIssues, issueFilters, currentUserId, true),
[mineIssues, issueFilters, currentUserId],
() => applyIssueFilters(mineIssues, issueFilters, currentUserId, true, liveIssueIds),
[mineIssues, issueFilters, currentUserId, liveIssueIds],
);
const visibleTouchedIssues = useMemo(
() => applyIssueFilters(touchedIssues, issueFilters, currentUserId, true),
[touchedIssues, issueFilters, currentUserId],
() => applyIssueFilters(touchedIssues, issueFilters, currentUserId, true, liveIssueIds),
[touchedIssues, issueFilters, currentUserId, liveIssueIds],
);
const unreadTouchedIssues = useMemo(
() => visibleTouchedIssues.filter((issue) => issue.isUnreadForMe),
@@ -1004,14 +1006,6 @@ export function Inbox() {
),
[heartbeatRuns, dismissedAtByKey],
);
const liveIssueIds = useMemo(() => {
const ids = new Set<string>();
for (const run of liveRuns ?? []) {
if (run.issueId) ids.add(run.issueId);
}
return ids;
}, [liveRuns]);
const approvalsToRender = useMemo(() => {
let filtered = getApprovalsForTab(approvals ?? [], tab, allApprovalFilter, currentUserId);
if (tab === "mine") {
@@ -1159,12 +1153,14 @@ export function Inbox() {
issueFilters,
currentUserId,
enableRoutineVisibilityFilter: true,
liveIssueIds,
}),
[
archivedSearchIssues,
currentUserId,
filteredWorkItems,
issueFilters,
liveIssueIds,
normalizedSearchQuery,
remoteIssueSearchResults,
],

View File

@@ -56,6 +56,8 @@ export function InstanceExperimentalSettings() {
const enableEnvironments = experimentalQuery.data?.enableEnvironments === true;
const enableIsolatedWorkspaces = experimentalQuery.data?.enableIsolatedWorkspaces === true;
const autoRestartDevServerWhenIdle = experimentalQuery.data?.autoRestartDevServerWhenIdle === true;
const enableIssueGraphLivenessAutoRecovery =
experimentalQuery.data?.enableIssueGraphLivenessAutoRecovery === true;
return (
<div className="max-w-4xl space-y-6">
@@ -128,6 +130,28 @@ export function InstanceExperimentalSettings() {
/>
</div>
</section>
<section className="rounded-xl border border-border bg-card p-5">
<div className="flex items-start justify-between gap-4">
<div className="space-y-1.5">
<h2 className="text-sm font-semibold">Auto-Create Issue Recovery Tasks</h2>
<p className="max-w-2xl text-sm text-muted-foreground">
Let the heartbeat scheduler create recovery issues for issue dependency chains that have been stalled for
at least 24 hours.
</p>
</div>
<ToggleSwitch
checked={enableIssueGraphLivenessAutoRecovery}
onCheckedChange={() =>
toggleMutation.mutate({
enableIssueGraphLivenessAutoRecovery: !enableIssueGraphLivenessAutoRecovery,
})
}
disabled={toggleMutation.isPending}
aria-label="Toggle issue graph liveness auto-recovery"
/>
</div>
</section>
</div>
);
}

View File

@@ -229,7 +229,9 @@ vi.mock("../components/ScrollToBottom", () => ({
}));
vi.mock("../components/StatusIcon", () => ({
StatusIcon: ({ status }: { status: string }) => <span>{status}</span>,
StatusIcon: ({ status, blockerAttention }: { status: string; blockerAttention?: Issue["blockerAttention"] }) => (
<span data-status-icon-state={blockerAttention?.state}>{status}</span>
),
}));
vi.mock("../components/PriorityIcon", () => ({
@@ -814,6 +816,31 @@ describe("IssueDetail", () => {
expect(consoleErrorSpy).not.toHaveBeenCalled();
});
it("passes blocker attention to the issue detail header status icon", async () => {
mockIssuesApi.get.mockResolvedValue(createIssue({
status: "blocked",
blockerAttention: {
state: "covered",
reason: "active_child",
unresolvedBlockerCount: 1,
coveredBlockerCount: 1,
attentionBlockerCount: 0,
sampleBlockerIdentifier: "PAP-2",
},
}));
await act(async () => {
root.render(
<QueryClientProvider client={queryClient}>
<IssueDetail />
</QueryClientProvider>,
);
});
await flushReact();
expect(container.querySelector('[data-status-icon-state="covered"]')?.textContent).toBe("blocked");
});
it("refreshes subtree pause state after resuming a hold", async () => {
const childIssue = createIssue({
id: "child-1",
@@ -1150,11 +1177,24 @@ describe("IssueDetail", () => {
.find((element) =>
typeof element.className === "string"
&& element.className.includes("overflow-y-auto")
&& element.textContent?.includes("Reason (required)"),
&& element.textContent?.includes("Reason (optional)"),
);
expect(bodyScrollRegion?.className).toContain("min-h-0");
expect(bodyScrollRegion?.className).toContain("overscroll-contain");
const cancelApplyButton = Array.from(dialogContent!.querySelectorAll("button"))
.find((button) => button.textContent?.trim() === "Cancel 24 issues") as HTMLButtonElement | undefined;
expect(cancelApplyButton).toBeTruthy();
expect(cancelApplyButton!.disabled).toBe(true);
const confirmationCheckbox = dialogContent!.querySelector('input[type="checkbox"]') as HTMLInputElement | null;
expect(confirmationCheckbox).toBeTruthy();
await act(async () => {
confirmationCheckbox!.click();
});
await flushReact();
expect(cancelApplyButton!.disabled).toBe(false);
const footer = Array.from(dialogContent!.querySelectorAll("div"))
.find((element) =>
typeof element.className === "string"

View File

@@ -382,7 +382,7 @@ function IssueDetailLoadingState({
<div className="flex items-center gap-2 min-w-0 flex-wrap">
{headerSeed ? (
<>
<StatusIcon status={headerSeed.status} />
<StatusIcon status={headerSeed.status} blockerAttention={headerSeed.blockerAttention} />
<PriorityIcon priority={headerSeed.priority} />
{identifier ? (
<span className="text-sm font-mono text-muted-foreground shrink-0">{identifier}</span>
@@ -692,6 +692,7 @@ const IssueDetailChatTab = memo(function IssueDetailChatTab({
const commentsWithRunMeta = useMemo<IssueDetailComment[]>(() => {
const activeRunStartedAt = runningIssueRun?.startedAt ?? runningIssueRun?.createdAt ?? null;
const runMetaByCommentId = new Map<string, { runId: string; runAgentId: string | null; interruptedRunId: string | null }>();
const followUpCommentIds = new Set<string>();
const agentIdByRunId = new Map<string, string>();
for (const run of resolvedLinkedRuns) {
@@ -710,10 +711,22 @@ const IssueDetailChatTab = memo(function IssueDetailChatTab({
interruptedRunId,
});
}
for (const evt of resolvedActivity) {
if (evt.action !== "issue.comment_added") continue;
const details = evt.details ?? {};
const commentId = typeof details["commentId"] === "string" ? details["commentId"] : null;
if (!commentId) continue;
if (details["followUpRequested"] === true || details["resumeIntent"] === true) {
followUpCommentIds.add(commentId);
}
}
return comments.map((comment) => {
const meta = runMetaByCommentId.get(comment.id);
const nextComment: IssueDetailComment = meta ? { ...comment, ...meta } : { ...comment };
if (followUpCommentIds.has(comment.id)) {
nextComment.followUpRequested = true;
}
const queuedTargetRunId = locallyQueuedCommentRunIds.get(comment.id) ?? null;
const locallyQueuedComment = applyLocalQueuedIssueCommentState(nextComment, {
queuedTargetRunId,
@@ -2702,7 +2715,7 @@ export function IssueDetail() {
const canApplyTreeControl =
Boolean(treeControlPreview)
&& !treeControlPreviewLoading
&& (treeControlMode !== "cancel" || (treeControlReason.trim().length > 0 && treeControlCancelConfirmed));
&& (treeControlMode !== "cancel" || treeControlCancelConfirmed);
const attachmentUploadButton = (
<>
<input
@@ -2840,6 +2853,7 @@ export function IssueDetail() {
<div className="flex items-center gap-2 min-w-0 flex-wrap">
<StatusIcon
status={issue.status}
blockerAttention={issue.blockerAttention}
onChange={(status) => updateIssue.mutate({ status })}
/>
<PriorityIcon
@@ -3448,7 +3462,7 @@ export function IssueDetail() {
<div className="space-y-1.5">
<label className="text-xs text-muted-foreground">
{treeControlMode === "cancel" ? "Reason (required)" : "Reason (optional)"}
Reason (optional)
</label>
<Textarea
value={treeControlReason}

View File

@@ -56,7 +56,7 @@ export function MyIssues() {
title={issue.title}
to={`/issues/${issue.identifier ?? issue.id}`}
leading={
<StatusIcon status={issue.status} />
<StatusIcon status={issue.status} blockerAttention={issue.blockerAttention} />
}
trailing={
<span className="text-xs text-muted-foreground">

View File

@@ -24,6 +24,7 @@ import { PageSkeleton } from "../components/PageSkeleton";
import { PageTabBar } from "../components/PageTabBar";
import { ProjectWorkspacesContent } from "../components/ProjectWorkspacesContent";
import { buildProjectWorkspaceSummaries } from "../lib/project-workspaces-tab";
import { collectLiveIssueIds } from "../lib/liveIssueIds";
import { projectRouteRef } from "../lib/utils";
import { Button } from "@/components/ui/button";
import { Tabs } from "@/components/ui/tabs";
@@ -175,13 +176,7 @@ function ProjectIssuesList({ projectId, companyId }: { projectId: string; compan
enabled: !!companyId,
});
const liveIssueIds = useMemo(() => {
const ids = new Set<string>();
for (const run of liveRuns ?? []) {
if (run.issueId) ids.add(run.issueId);
}
return ids;
}, [liveRuns]);
const liveIssueIds = useMemo(() => collectLiveIssueIds(liveRuns), [liveRuns]);
const { data: issues, isLoading, error } = useQuery({
queryKey: queryKeys.issues.listByProject(companyId, projectId),

View File

@@ -13,6 +13,7 @@ import { useToastActions } from "../context/ToastContext";
import { queryKeys } from "../lib/queryKeys";
import { groupBy } from "../lib/groupBy";
import { createIssueDetailLocationState } from "../lib/issueDetailBreadcrumb";
import { collectLiveIssueIds } from "../lib/liveIssueIds";
import { getRecentAssigneeIds, sortAgentsByRecency, trackRecentAssignee } from "../lib/recent-assignees";
import { getRecentProjectIds, trackRecentProject } from "../lib/recent-projects";
import { ToggleSwitch } from "@/components/ui/toggle-switch";
@@ -492,13 +493,7 @@ export function Routines() {
() => new Map((projects ?? []).map((project) => [project.id, project])),
[projects],
);
const liveIssueIds = useMemo(() => {
const ids = new Set<string>();
for (const run of liveRuns ?? []) {
if (run.issueId) ids.add(run.issueId);
}
return ids;
}, [liveRuns]);
const liveIssueIds = useMemo(() => collectLiveIssueIds(liveRuns), [liveRuns]);
const routineGroups = useMemo(
() => buildRoutineGroups(routines ?? [], routineViewState.groupBy, projectById, agentById),
[agentById, projectById, routineViewState.groupBy, routines],

View File

@@ -114,6 +114,10 @@ function makeHeartbeatRun(overrides: Partial<HeartbeatRun>): HeartbeatRun {
logBytes: 0,
logSha256: null,
logCompressed: false,
lastOutputAt: null,
lastOutputSeq: 0,
lastOutputStream: null,
lastOutputBytes: null,
stdoutExcerpt: null,
stderrExcerpt: null,
errorCode: null,

View File

@@ -1,15 +1,19 @@
import { useState } from "react";
import type { Meta, StoryObj } from "@storybook/react-vite";
import { AGENT_STATUSES, ISSUE_PRIORITIES, ISSUE_STATUSES } from "@paperclipai/shared";
import type { IssueBlockerAttention } from "@paperclipai/shared";
import { Bot, CheckCircle2, Clock3, DollarSign, FolderKanban, Inbox, MessageSquare, Users } from "lucide-react";
import { CopyText } from "@/components/CopyText";
import { EmptyState } from "@/components/EmptyState";
import { Identity } from "@/components/Identity";
import { IssueRow } from "@/components/IssueRow";
import { MetricCard } from "@/components/MetricCard";
import { PriorityIcon } from "@/components/PriorityIcon";
import { QuotaBar } from "@/components/QuotaBar";
import { StatusBadge } from "@/components/StatusBadge";
import { StatusIcon } from "@/components/StatusIcon";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { createIssue } from "../fixtures/paperclipData";
function Section({
eyebrow,
@@ -31,6 +35,156 @@ function Section({
);
}
type CoveredBlockedCell = {
label: string;
status: string;
blockerAttention: IssueBlockerAttention | null;
expectedVisual: string;
expectedCopy: string;
};
const coveredBlockedMatrix: CoveredBlockedCell[] = [
{
label: "Normal blocked",
status: "blocked",
blockerAttention: null,
expectedVisual: "solid red ring",
expectedCopy: "Blocked",
},
{
label: "Covered by 1 active child",
status: "blocked",
blockerAttention: {
state: "covered",
reason: "active_child",
unresolvedBlockerCount: 1,
coveredBlockerCount: 1,
attentionBlockerCount: 0,
sampleBlockerIdentifier: "PAP-2175",
},
expectedVisual: "cyan ring",
expectedCopy: "Blocked · waiting on active sub-issue PAP-2175",
},
{
label: "Covered by N active children",
status: "blocked",
blockerAttention: {
state: "covered",
reason: "active_child",
unresolvedBlockerCount: 3,
coveredBlockerCount: 3,
attentionBlockerCount: 0,
sampleBlockerIdentifier: null,
},
expectedVisual: "cyan ring",
expectedCopy: "Blocked · waiting on 3 active sub-issues",
},
{
label: "Covered by active dependency",
status: "blocked",
blockerAttention: {
state: "covered",
reason: "active_dependency",
unresolvedBlockerCount: 1,
coveredBlockerCount: 1,
attentionBlockerCount: 0,
sampleBlockerIdentifier: "PAP-1918",
},
expectedVisual: "cyan ring",
expectedCopy: "Blocked · covered by active dependency PAP-1918",
},
{
label: "Covered by N active dependencies",
status: "blocked",
blockerAttention: {
state: "covered",
reason: "active_dependency",
unresolvedBlockerCount: 2,
coveredBlockerCount: 2,
attentionBlockerCount: 0,
sampleBlockerIdentifier: null,
},
expectedVisual: "cyan ring",
expectedCopy: "Blocked · covered by 2 active dependencies",
},
{
label: "Mixed: 1 covered, 1 needs attention",
status: "blocked",
blockerAttention: {
state: "needs_attention",
reason: "attention_required",
unresolvedBlockerCount: 2,
coveredBlockerCount: 1,
attentionBlockerCount: 1,
sampleBlockerIdentifier: null,
},
expectedVisual: "solid red ring",
expectedCopy: "Blocked · 2 unresolved blockers need attention",
},
{
label: "Needs attention (single blocker)",
status: "blocked",
blockerAttention: {
state: "needs_attention",
reason: "attention_required",
unresolvedBlockerCount: 1,
coveredBlockerCount: 0,
attentionBlockerCount: 1,
sampleBlockerIdentifier: "PAP-1042",
},
expectedVisual: "solid red ring",
expectedCopy: "Blocked · 1 unresolved blocker needs attention",
},
{
label: "Non-blocked with prop ignored",
status: "in_progress",
blockerAttention: {
state: "covered",
reason: "active_child",
unresolvedBlockerCount: 1,
coveredBlockerCount: 1,
attentionBlockerCount: 0,
sampleBlockerIdentifier: "PAP-2175",
},
expectedVisual: "yellow ring",
expectedCopy: "In Progress",
},
];
const coveredBlockedIssue = createIssue({
id: "issue-covered-blocked-story",
identifier: "PAP-2178",
issueNumber: 2178,
title: "Covered blocked visual state: final acceptance",
status: "blocked",
priority: "medium",
blockerAttention: coveredBlockedMatrix[1]!.blockerAttention ?? undefined,
lastActivityAt: new Date("2026-04-24T13:40:00.000Z"),
updatedAt: new Date("2026-04-24T13:40:00.000Z"),
});
function CoveredBlockedSurface({ mode, size }: { mode: "light" | "dark"; size: "desktop" | "mobile" }) {
const isDark = mode === "dark";
const isMobile = size === "mobile";
return (
<div className={isDark ? "dark" : undefined}>
<div className="rounded-lg border border-border bg-background text-foreground">
<div className="border-b border-border px-3 py-2 text-xs font-medium text-muted-foreground">
{size} · {mode}
</div>
<div className={isMobile ? "max-w-[340px]" : "min-w-[620px]"}>
<IssueRow
issue={coveredBlockedIssue}
mobileMeta={<StatusBadge status={coveredBlockedIssue.status} />}
trailingMeta="waiting on PAP-2175"
/>
</div>
</div>
</div>
);
}
function StatusLanguage() {
const [priority, setPriority] = useState("high");
@@ -73,6 +227,41 @@ function StatusLanguage() {
</div>
</Section>
<Section eyebrow="Covered blocked" title="Blocked attention state matrix">
<div className="grid gap-3 md:grid-cols-2 xl:grid-cols-4">
{coveredBlockedMatrix.map((item) => (
<div
key={item.label}
className="flex min-h-[136px] flex-col justify-between rounded-lg border border-border bg-background/70 p-4"
>
<div className="flex items-start justify-between gap-3">
<div>
<div className="text-sm font-medium">{item.label}</div>
<div className="mt-1 text-xs text-muted-foreground">{item.expectedVisual}</div>
</div>
<StatusIcon status={item.status} blockerAttention={item.blockerAttention} />
</div>
<div className="mt-4 rounded-md bg-muted/45 px-2.5 py-2 font-mono text-[11px] leading-5 text-muted-foreground">
{item.expectedCopy}
</div>
</div>
))}
</div>
<p className="mt-3 text-xs text-muted-foreground">
Tooltip and aria-label copy begin with "Blocked · " for cells 2-7; cells 6 and 7 retain the solid red ring
and mention blockers that need attention.
</p>
</Section>
<Section eyebrow="Covered blocked" title="IssueRow desktop and mobile surfaces">
<div className="grid gap-4 xl:grid-cols-2">
<CoveredBlockedSurface mode="light" size="desktop" />
<CoveredBlockedSurface mode="dark" size="desktop" />
<CoveredBlockedSurface mode="light" size="mobile" />
<CoveredBlockedSurface mode="dark" size="mobile" />
</div>
</Section>
<Section eyebrow="Priority" title="Static labels and editable popover trigger">
<div className="grid gap-4 lg:grid-cols-[1fr_320px]">
<div className="grid gap-3 sm:grid-cols-2">