[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
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
121 changed files with 9625 additions and 2044 deletions

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;