mirror of
https://github.com/paperclipai/paperclip
synced 2026-04-25 17:25:15 +02:00
[codex] Add runtime lifecycle recovery and live issue visibility (#4419)
This commit is contained in:
@@ -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 />} />
|
||||
|
||||
30
ui/src/api/heartbeats.test.ts
Normal file
30
ui/src/api/heartbeats.test.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
@@ -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"),
|
||||
};
|
||||
|
||||
152
ui/src/components/ActiveAgentsPanel.test.tsx
Normal file
152
ui/src/components/ActiveAgentsPanel.test.tsx
Normal 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();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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">
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 () => {});
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -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 ? (
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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)}
|
||||
|
||||
@@ -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([
|
||||
|
||||
@@ -1044,6 +1044,7 @@ export function IssueProperties({
|
||||
<PropertyRow label="Status">
|
||||
<StatusIcon
|
||||
status={issue.status}
|
||||
blockerAttention={issue.blockerAttention}
|
||||
onChange={(status) => onUpdate({ status })}
|
||||
showLabel
|
||||
/>
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
72
ui/src/components/StatusIcon.test.tsx
Normal file
72
ui/src/components/StatusIcon.test.tsx
Normal 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");
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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([
|
||||
{
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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"]);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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.",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
|
||||
64
ui/src/pages/DashboardLive.tsx
Normal file
64
ui/src/pages/DashboardLive.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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),
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
],
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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],
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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">
|
||||
|
||||
Reference in New Issue
Block a user