diff --git a/docs/pr-screenshots/pr-4616/sidebar-agent-actions.png b/docs/pr-screenshots/pr-4616/sidebar-agent-actions.png new file mode 100644 index 00000000000..143c88127e0 Binary files /dev/null and b/docs/pr-screenshots/pr-4616/sidebar-agent-actions.png differ diff --git a/docs/pr-screenshots/pr-4616/sidebar-agent-row.png b/docs/pr-screenshots/pr-4616/sidebar-agent-row.png new file mode 100644 index 00000000000..1da57eb6085 Binary files /dev/null and b/docs/pr-screenshots/pr-4616/sidebar-agent-row.png differ diff --git a/server/src/__tests__/heartbeat-process-recovery.test.ts b/server/src/__tests__/heartbeat-process-recovery.test.ts index 3ba89428196..838b34b6613 100644 --- a/server/src/__tests__/heartbeat-process-recovery.test.ts +++ b/server/src/__tests__/heartbeat-process-recovery.test.ts @@ -1472,6 +1472,42 @@ describeEmbeddedPostgres("heartbeat orphaned process recovery", () => { }, ); + it("still re-enqueues stranded assigned todo recovery when an old queued wake exists", async () => { + const { companyId, agentId, issueId, runId } = await seedStrandedIssueFixture({ + status: "todo", + runStatus: "failed", + }); + await db.insert(agentWakeupRequests).values({ + companyId, + agentId, + source: "assignment", + triggerDetail: "system", + reason: "issue_assigned", + payload: { issueId }, + status: "queued", + }); + const heartbeat = heartbeatService(db); + + const result = await heartbeat.reconcileStrandedAssignedIssues(); + expect(result.assignmentDispatched).toBe(0); + expect(result.dispatchRequeued).toBe(1); + expect(result.continuationRequeued).toBe(0); + expect(result.escalated).toBe(0); + expect(result.issueIds).toEqual([issueId]); + + const runs = await db + .select() + .from(heartbeatRuns) + .where(eq(heartbeatRuns.agentId, agentId)); + expect(runs).toHaveLength(2); + + const retryRun = runs.find((row) => row.id !== runId); + expect((retryRun?.contextSnapshot as Record)?.retryReason).toBe("assignment_recovery"); + if (retryRun) { + await waitForRunToSettle(heartbeat, retryRun.id); + } + }); + it("blocks assigned todo work after the one automatic dispatch recovery was already used", async () => { const { companyId, agentId, issueId, runId } = await seedStrandedIssueFixture({ status: "todo", diff --git a/server/src/__tests__/routines-service.test.ts b/server/src/__tests__/routines-service.test.ts index 1395f248c4c..e59dce51b23 100644 --- a/server/src/__tests__/routines-service.test.ts +++ b/server/src/__tests__/routines-service.test.ts @@ -460,6 +460,89 @@ describeEmbeddedPostgres("routine service live-execution coalescing", () => { expect(inboxIssues.map((issue) => issue.id)).toContain(previousIssue.id); }); + it("touches a skipped active routine issue for the manual runner's inbox", async () => { + const { agentId, companyId, issueSvc, routine, svc } = await seedFixture(); + const userId = randomUUID(); + const previousRunId = randomUUID(); + const liveHeartbeatRunId = randomUUID(); + + await db + .update(routines) + .set({ concurrencyPolicy: "skip_if_active" }) + .where(eq(routines.id, routine.id)); + + const previousIssue = await issueSvc.create(companyId, { + projectId: routine.projectId, + title: routine.title, + description: routine.description, + status: "in_progress", + priority: routine.priority, + assigneeAgentId: routine.assigneeAgentId, + originKind: "routine_execution", + originId: routine.id, + originRunId: previousRunId, + }); + + await db.insert(routineRuns).values({ + id: previousRunId, + companyId, + routineId: routine.id, + triggerId: null, + source: "manual", + status: "issue_created", + triggeredAt: new Date("2026-03-20T12:00:00.000Z"), + linkedIssueId: previousIssue.id, + }); + await db.insert(heartbeatRuns).values({ + id: liveHeartbeatRunId, + companyId, + agentId, + invocationSource: "assignment", + triggerDetail: "system", + status: "running", + contextSnapshot: { issueId: previousIssue.id }, + startedAt: new Date("2026-03-20T12:01:00.000Z"), + }); + await db + .update(issues) + .set({ + checkoutRunId: liveHeartbeatRunId, + executionRunId: liveHeartbeatRunId, + executionLockedAt: new Date("2026-03-20T12:01:00.000Z"), + }) + .where(eq(issues.id, previousIssue.id)); + await db.insert(issueInboxArchives).values({ + companyId, + issueId: previousIssue.id, + userId, + archivedAt: new Date("2026-03-20T12:02:00.000Z"), + }); + + const run = await svc.runRoutine(routine.id, { source: "manual" }, { userId }); + + expect(run.status).toBe("skipped"); + expect(run.linkedIssueId).toBe(previousIssue.id); + await expect( + db.select().from(issueInboxArchives).where(eq(issueInboxArchives.issueId, previousIssue.id)), + ).resolves.toHaveLength(0); + await expect( + db.select().from(issueReadStates).where(eq(issueReadStates.issueId, previousIssue.id)), + ).resolves.toEqual([ + expect.objectContaining({ + companyId, + issueId: previousIssue.id, + userId, + }), + ]); + + const inboxIssues = await issueSvc.list(companyId, { + touchedByUserId: userId, + inboxArchivedByUserId: userId, + includeRoutineExecutions: true, + }); + expect(inboxIssues.map((issue) => issue.id)).toContain(previousIssue.id); + }); + it("does not coalesce live routine runs with different resolved variables", async () => { const { companyId, agentId, projectId, svc } = await seedFixture(); const variableRoutine = await svc.create( diff --git a/server/src/services/recovery/service.ts b/server/src/services/recovery/service.ts index 56f354d5288..f544f4085fa 100644 --- a/server/src/services/recovery/service.ts +++ b/server/src/services/recovery/service.ts @@ -345,7 +345,7 @@ export function recoveryService(db: Db, deps: { enqueueWakeup: RecoveryWakeup }) .where( and( eq(agentWakeupRequests.companyId, companyId), - inArray(agentWakeupRequests.status, ["queued", "deferred_issue_execution"]), + eq(agentWakeupRequests.status, "deferred_issue_execution"), sql`${agentWakeupRequests.payload} ->> 'issueId' = ${issueId}`, ), ) @@ -356,6 +356,21 @@ export function recoveryService(db: Db, deps: { enqueueWakeup: RecoveryWakeup }) return Boolean(run || deferredWake); } + async function hasQueuedIssueWake(companyId: string, issueId: string) { + return db + .select({ id: agentWakeupRequests.id }) + .from(agentWakeupRequests) + .where( + and( + eq(agentWakeupRequests.companyId, companyId), + eq(agentWakeupRequests.status, "queued"), + sql`${agentWakeupRequests.payload} ->> 'issueId' = ${issueId}`, + ), + ) + .limit(1) + .then((rows) => Boolean(rows[0])); + } + async function enqueueStrandedIssueRecovery(input: { issueId: string; agentId: string; @@ -1619,6 +1634,11 @@ export function recoveryService(db: Db, deps: { enqueueWakeup: RecoveryWakeup }) if (issue.status === "todo") { if (!latestRun) { + if (await hasQueuedIssueWake(issue.companyId, issue.id)) { + result.skipped += 1; + continue; + } + if (await isInvocationBudgetBlocked(issue, agentId)) { result.skipped += 1; continue; diff --git a/ui/src/components/InlineEntitySelector.tsx b/ui/src/components/InlineEntitySelector.tsx index cae1897712e..0ac378b5fee 100644 --- a/ui/src/components/InlineEntitySelector.tsx +++ b/ui/src/components/InlineEntitySelector.tsx @@ -1,4 +1,4 @@ -import { forwardRef, useEffect, useMemo, useRef, useState, type ReactNode } from "react"; +import { forwardRef, useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from "react"; import { Check } from "lucide-react"; import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"; import { orderItemsBySelectedAndRecent } from "../lib/recent-selections"; @@ -75,17 +75,17 @@ export const InlineEntitySelector = forwardRef option.id === value) ?? null; - function setHighlightedIndexValue(next: number | ((current: number) => number)) { + const setHighlightedIndexValue = useCallback((next: number | ((current: number) => number)) => { const resolved = typeof next === "function" ? next(highlightedIndexRef.current) : next; highlightedIndexRef.current = resolved; setHighlightedIndex(resolved); - } + }, []); useEffect(() => { if (!open) return; const selectedIndex = filteredOptions.findIndex((option) => option.id === value); setHighlightedIndexValue(selectedIndex >= 0 ? selectedIndex : 0); - }, [filteredOptions, open, value]); + }, [filteredOptions, open, setHighlightedIndexValue, value]); const commitSelection = (index: number, moveNext: boolean) => { const option = filteredOptions[index] ?? filteredOptions[0]; diff --git a/ui/src/components/IssueChatThread.tsx b/ui/src/components/IssueChatThread.tsx index 7a04ef0d65d..16ecd53db83 100644 --- a/ui/src/components/IssueChatThread.tsx +++ b/ui/src/components/IssueChatThread.tsx @@ -2148,6 +2148,14 @@ function findMessageAnchorIndex(messages: readonly ThreadMessage[], anchorId: st return messages.findIndex((message) => issueChatMessageAnchorId(message) === anchorId); } +export function findLatestCommentMessageIndex(messages: readonly ThreadMessage[]): number { + for (let index = messages.length - 1; index >= 0; index -= 1) { + const anchorId = issueChatMessageAnchorId(messages[index]); + if (anchorId && anchorId.startsWith("comment-")) return index; + } + return -1; +} + type VirtualizedVisibleAnchorSnapshot = { anchorId: string; index: number; @@ -3227,6 +3235,19 @@ export function IssueChatThread({ }, [location.hash, messageAnchorIndex, messages, useVirtualizedThread]); function handleJumpToLatest() { + // Prefer the latest comment row over whichever run/timeline row happens to + // sort last in the merged feed — see PAP-2672. Fall back to the trailing + // row when no comment row exists (run-only/embedded outputs). + const latestCommentIndex = findLatestCommentMessageIndex(messages); + if (latestCommentIndex >= 0) { + const latestCommentAnchor = issueChatMessageAnchorId(messages[latestCommentIndex]); + if ( + latestCommentAnchor + && scrollToThreadAnchor(latestCommentAnchor, { align: "end", behavior: "smooth" }) + ) { + return; + } + } if (useVirtualizedThread) { virtualizedThreadRef.current?.scrollToLatest({ behavior: "smooth" }); return; diff --git a/ui/src/components/SidebarAgents.test.tsx b/ui/src/components/SidebarAgents.test.tsx index 3d28df2207b..ee3ef2aac4e 100644 --- a/ui/src/components/SidebarAgents.test.tsx +++ b/ui/src/components/SidebarAgents.test.tsx @@ -241,4 +241,76 @@ describe("SidebarAgents", () => { expect(mockAgentsApi.resume).toHaveBeenCalledWith("agent-1", "company-1"); expect(mockPushToast).toHaveBeenCalledWith(expect.objectContaining({ title: "Agent resumed" })); }); + + it("only shows updating state for the agent currently being changed", async () => { + mockAgentsApi.list.mockResolvedValue([ + makeAgent({ id: "agent-1", name: "Alpha", urlKey: "alpha" }), + makeAgent({ id: "agent-2", name: "Beta", urlKey: "beta" }), + ]); + mockAgentsApi.pause.mockImplementation(() => new Promise(() => {})); + const currentRoot = createRoot(container); + root = currentRoot; + + await act(async () => { + currentRoot.render( + + + , + ); + }); + await flushReact(); + await openAgentMenu(); + + const pauseItem = Array.from(document.body.querySelectorAll('[data-slot="dropdown-menu-item"]')) + .find((element) => element.textContent?.includes("Pause agent")); + expect(pauseItem).toBeTruthy(); + + await act(async () => { + pauseItem?.dispatchEvent(new MouseEvent("click", { bubbles: true })); + }); + await flushReact(); + await openAgentMenu("Open actions for Beta"); + + const betaPauseItem = Array.from( + document.body.querySelectorAll('[data-slot="dropdown-menu-item"]'), + ) + .find((element) => element.textContent?.includes("Pause agent")); + expect(betaPauseItem).toBeTruthy(); + expect(document.body.textContent).not.toContain("Updating..."); + }); + + it("does not offer sidebar resume for budget-paused agents", async () => { + mockAgentsApi.list.mockResolvedValue([ + makeAgent({ + status: "paused", + pauseReason: "budget", + pausedAt: new Date("2026-01-02T00:00:00Z"), + }), + ]); + const currentRoot = createRoot(container); + root = currentRoot; + + await act(async () => { + currentRoot.render( + + + , + ); + }); + await flushReact(); + await openAgentMenu(); + + const budgetPausedItem = Array.from( + document.body.querySelectorAll('[data-slot="dropdown-menu-item"]'), + ) + .find((element) => element.textContent?.includes("Budget paused")); + expect(budgetPausedItem).toBeTruthy(); + + await act(async () => { + budgetPausedItem?.dispatchEvent(new MouseEvent("click", { bubbles: true })); + }); + await flushReact(); + + expect(mockAgentsApi.resume).not.toHaveBeenCalled(); + }); }); diff --git a/ui/src/components/SidebarAgents.tsx b/ui/src/components/SidebarAgents.tsx index 8022195e69c..6075bfe7c82 100644 --- a/ui/src/components/SidebarAgents.tsx +++ b/ui/src/components/SidebarAgents.tsx @@ -61,7 +61,14 @@ function SidebarAgentItem({ const editHref = `${agentUrl(agent)}/configuration`; const isActive = activeAgentId === routeRef; const isPaused = agent.status === "paused"; + const isBudgetPaused = isPaused && agent.pauseReason === "budget"; const pauseResumeLabel = isPaused ? "Resume agent" : "Pause agent"; + const pauseResumeDisabled = disabled || agent.status === "pending_approval" || isBudgetPaused; + const pauseResumeDisabledLabel = disabled + ? "Updating..." + : isBudgetPaused + ? "Budget paused" + : pauseResumeLabel; return (
@@ -130,11 +137,15 @@ function SidebarAgentItem({ onPauseResume(agent, isPaused ? "resume" : "pause")} - disabled={disabled || agent.status === "pending_approval"} + onClick={() => { + if (pauseResumeDisabled) return; + onPauseResume(agent, isPaused ? "resume" : "pause"); + }} + disabled={pauseResumeDisabled} + title={isBudgetPaused ? "Agent was paused by budget limits" : undefined} > {isPaused ? : } - {disabled ? "Updating..." : pauseResumeLabel} + {pauseResumeDisabledLabel} @@ -144,6 +155,7 @@ function SidebarAgentItem({ export function SidebarAgents() { const [open, setOpen] = useState(true); + const [pendingAgentIds, setPendingAgentIds] = useState>(() => new Set()); const queryClient = useQueryClient(); const { selectedCompanyId } = useCompany(); const { openNewAgent } = useDialogActions(); @@ -198,6 +210,13 @@ export function SidebarAgents() { action === "pause" ? agentsApi.pause(agent.id, selectedCompanyId ?? undefined) : agentsApi.resume(agent.id, selectedCompanyId ?? undefined), + onMutate: ({ agent }) => { + setPendingAgentIds((current) => { + const next = new Set(current); + next.add(agent.id); + return next; + }); + }, onSuccess: async (_agent, { agent, action }) => { if (selectedCompanyId) { await Promise.all([ @@ -223,6 +242,13 @@ export function SidebarAgents() { tone: "error", }); }, + onSettled: (_data, _error, { agent }) => { + setPendingAgentIds((current) => { + const next = new Set(current); + next.delete(agent.id); + return next; + }); + }, }); return ( @@ -263,7 +289,7 @@ export function SidebarAgents() { activeAgentId={activeAgentId} activeTab={activeTab} agent={agent} - disabled={pauseResumeAgent.isPending} + disabled={pendingAgentIds.has(agent.id)} isMobile={isMobile} onPauseResume={(targetAgent, action) => pauseResumeAgent.mutate({ agent: targetAgent, action })} runCount={runCount} diff --git a/ui/src/context/CompanyContext.test.tsx b/ui/src/context/CompanyContext.test.tsx index 0e26a5b82cf..d31b6e33d2e 100644 --- a/ui/src/context/CompanyContext.test.tsx +++ b/ui/src/context/CompanyContext.test.tsx @@ -5,6 +5,7 @@ import { createRoot, type Root } from "react-dom/client"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import type { Company } from "@paperclipai/shared"; +import { queryKeys } from "../lib/queryKeys"; import { CompanyProvider, resolveBootstrapCompanySelection, @@ -141,8 +142,8 @@ describe("CompanyProvider", () => { }); }); - afterEach(() => { - act(() => { + afterEach(async () => { + await act(async () => { root.unmount(); }); queryClient.clear(); @@ -170,11 +171,11 @@ describe("CompanyProvider", () => { it("replaces a stale stored company id with the first loaded company", async () => { localStorage.setItem("paperclip.selectedCompanyId", "stale-company"); - let resolveCompanies!: (companies: Company[]) => void; - const companiesPromise = new Promise((resolve) => { - resolveCompanies = resolve; + queryClient.setQueryData(queryKeys.companies.all, { + companies: [makeCompany("company-1")], + unauthorized: false, }); - mockCompaniesApi.list.mockReturnValue(companiesPromise); + mockCompaniesApi.list.mockImplementation(() => new Promise(() => {})); const seen: Array = []; await act(async () => { @@ -187,14 +188,7 @@ describe("CompanyProvider", () => { ); }); - await act(async () => { - resolveCompanies([makeCompany("company-1")]); - await companiesPromise; - }); - - await vi.waitFor(() => { - expect(seen).toEqual([null, "company-1"]); - expect(localStorage.getItem("paperclip.selectedCompanyId")).toBe("company-1"); - }); + expect(seen).toEqual([null, "company-1"]); + expect(localStorage.getItem("paperclip.selectedCompanyId")).toBe("company-1"); }); }); diff --git a/ui/src/index.css b/ui/src/index.css index d0957f757b5..8fe5185ec79 100644 --- a/ui/src/index.css +++ b/ui/src/index.css @@ -442,7 +442,7 @@ align-items: center; gap: 0.25rem; margin: 0 0.1rem; - padding: 0 0.45rem; + padding: 0 0.625rem; border: 1px solid var(--border); border-radius: 999px; font-size: 0.75rem; @@ -862,7 +862,7 @@ span.paperclip-project-mention-chip { align-items: center; gap: 0.25rem; margin: 0 0.1rem; - padding: 0 0.45rem; + padding: 0 0.625rem; border: 1px solid var(--border); border-radius: 999px; font-size: 0.75rem;