diff --git a/.agents/skills/create-issue-interaction-ui/SKILL.md b/.agents/skills/create-issue-interaction-ui/SKILL.md
index 9c46dacd46..59e22795ba 100644
--- a/.agents/skills/create-issue-interaction-ui/SKILL.md
+++ b/.agents/skills/create-issue-interaction-ui/SKILL.md
@@ -6,7 +6,7 @@ description: >
checkbox confirmations, ask_user_questions, or suggest_tasks.
---
-# Create a new issue-thread interaction UI (developer skill)
+# Create a new issue-thread interaction UI (Developer/maintainer skill)
This skill walks a Paperclip contributor through introducing a new issue-thread
interaction kind from shared contract to issue-detail wiring, helpers, and
@@ -14,6 +14,8 @@ docs. It is intentionally a developer/maintainer skill: the audience is a
human or coding agent making code changes inside `paperclipai/paperclip`, not
the operational agents that run inside a deployed Paperclip company.
+Do NOT install this on production Paperclip agents. This guide is for repository contributors changing Paperclip itself.
+
## When to use
- A new interaction kind is being introduced (compact picker, structured
diff --git a/ui/src/components/SidebarAgents.test.tsx b/ui/src/components/SidebarAgents.test.tsx
index 1630a66f12..1cdfdd49a8 100644
--- a/ui/src/components/SidebarAgents.test.tsx
+++ b/ui/src/components/SidebarAgents.test.tsx
@@ -7,6 +7,7 @@ import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import type { Agent, ResourceMemberships } from "@paperclipai/shared";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { SidebarAgents } from "./SidebarAgents";
+import { queryKeys } from "../lib/queryKeys";
import { TooltipProvider } from "@/components/ui/tooltip";
const mockAgentsApi = vi.hoisted(() => ({
@@ -267,6 +268,7 @@ describe("SidebarAgents", () => {
currentRoot.unmount();
});
}
+ vi.useRealTimers();
queryClient.clear();
container.remove();
document.body.innerHTML = "";
@@ -302,6 +304,23 @@ describe("SidebarAgents", () => {
await flushReact();
}
+ async function renderSidebarAgentsWithFakeTimers() {
+ const currentRoot = createRoot(container);
+ root = currentRoot;
+
+ await act(async () => {
+ currentRoot.render(
+
+
+ ,
+ );
+ });
+ await act(async () => {
+ await Promise.resolve();
+ await vi.advanceTimersByTimeAsync(0);
+ });
+ }
+
async function renderRailSidebarAgents() {
mockSidebarState.collapsed = true;
const currentRoot = createRoot(container);
@@ -645,6 +664,110 @@ describe("SidebarAgents", () => {
expect(seeAllAgentsLink(container)?.getAttribute("href")).toBe("/agents/all");
});
+ it("keeps formerly live agents visible for the streamlined linger window", async () => {
+ vi.useFakeTimers({ now: new Date("2026-01-01T00:00:00Z") });
+ mockAgentsApi.list.mockResolvedValue([
+ makeAgent({ id: "agent-a", name: "Alpha", urlKey: "alpha" }),
+ makeAgent({ id: "agent-b", name: "Bravo", urlKey: "bravo" }),
+ makeAgent({ id: "agent-c", name: "Charlie", urlKey: "charlie" }),
+ makeAgent({ id: "agent-d", name: "Delta", urlKey: "delta" }),
+ ]);
+ mockHeartbeatsApi.liveRunsForCompany.mockResolvedValue([
+ { id: "run-1", agentId: "agent-a", status: "running" },
+ ]);
+
+ await renderSidebarAgentsWithFakeTimers();
+
+ let labels = agentLinkLabels(container);
+ expect(labels).toHaveLength(1);
+ expect(labels[0]).toContain("Alpha");
+ expect(labels[0]).toContain("1 live");
+
+ mockHeartbeatsApi.liveRunsForCompany.mockResolvedValue([]);
+ await act(async () => {
+ queryClient.setQueryData(queryKeys.liveRuns("company-1"), []);
+ });
+ await act(async () => {
+ await Promise.resolve();
+ await vi.advanceTimersByTimeAsync(0);
+ });
+
+ labels = agentLinkLabels(container);
+ expect(labels).toEqual(["Alpha"]);
+ expect(labels.join(" ")).not.toContain("live");
+
+ await act(async () => {
+ await vi.advanceTimersByTimeAsync(120_000);
+ });
+ expect(agentLinkLabels(container)).toEqual(["Alpha"]);
+
+ await act(async () => {
+ await vi.advanceTimersByTimeAsync(1);
+ });
+ expect(agentLinkLabels(container)).toEqual(["Alpha", "Bravo", "Charlie"]);
+ });
+
+ it("expires staggered lingering agents without unrelated sidebar updates", async () => {
+ vi.useFakeTimers({ now: new Date("2026-01-01T00:00:00Z") });
+ mockAgentsApi.list.mockResolvedValue([
+ makeAgent({ id: "agent-a", name: "Alpha", urlKey: "alpha" }),
+ makeAgent({ id: "agent-b", name: "Bravo", urlKey: "bravo" }),
+ makeAgent({ id: "agent-c", name: "Charlie", urlKey: "charlie" }),
+ makeAgent({ id: "agent-d", name: "Delta", urlKey: "delta" }),
+ ]);
+ mockHeartbeatsApi.liveRunsForCompany.mockResolvedValue([
+ { id: "run-1", agentId: "agent-a", status: "running" },
+ ]);
+
+ await renderSidebarAgentsWithFakeTimers();
+ expect(agentLinkLabels(container)[0]).toContain("Alpha");
+
+ mockHeartbeatsApi.liveRunsForCompany.mockResolvedValue([]);
+ await act(async () => {
+ queryClient.setQueryData(queryKeys.liveRuns("company-1"), []);
+ });
+ await act(async () => {
+ await Promise.resolve();
+ await vi.advanceTimersByTimeAsync(0);
+ });
+ expect(agentLinkLabels(container)).toEqual(["Alpha"]);
+
+ mockHeartbeatsApi.liveRunsForCompany.mockResolvedValue([
+ { id: "run-2", agentId: "agent-b", status: "running" },
+ ]);
+ await act(async () => {
+ await vi.advanceTimersByTimeAsync(60_000);
+ queryClient.setQueryData(queryKeys.liveRuns("company-1"), [
+ { id: "run-2", agentId: "agent-b", status: "running" },
+ ]);
+ });
+ await act(async () => {
+ await Promise.resolve();
+ await vi.advanceTimersByTimeAsync(0);
+ });
+ expect(agentLinkLabels(container).join(" ")).toContain("Bravo");
+
+ mockHeartbeatsApi.liveRunsForCompany.mockResolvedValue([]);
+ await act(async () => {
+ queryClient.setQueryData(queryKeys.liveRuns("company-1"), []);
+ });
+ await act(async () => {
+ await Promise.resolve();
+ await vi.advanceTimersByTimeAsync(0);
+ });
+ expect(agentLinkLabels(container)).toEqual(["Alpha", "Bravo"]);
+
+ await act(async () => {
+ await vi.advanceTimersByTimeAsync(60_001);
+ });
+ expect(agentLinkLabels(container)).toEqual(["Bravo"]);
+
+ await act(async () => {
+ await vi.advanceTimersByTimeAsync(60_000);
+ });
+ expect(agentLinkLabels(container)).toEqual(["Alpha", "Bravo", "Charlie"]);
+ });
+
it("shows up to 3 recently-active agents plus a See all link when none are running", async () => {
mockAgentsApi.list.mockResolvedValue(
Array.from({ length: 7 }, (_, index) =>
diff --git a/ui/src/components/SidebarAgents.tsx b/ui/src/components/SidebarAgents.tsx
index 7e681da39b..da1f9dde4f 100644
--- a/ui/src/components/SidebarAgents.tsx
+++ b/ui/src/components/SidebarAgents.tsx
@@ -1,4 +1,4 @@
-import { useCallback, useEffect, useMemo, useState } from "react";
+import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { Link, useLocation } from "@/lib/router";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import {
@@ -63,6 +63,7 @@ import type { Agent } from "@paperclipai/shared";
* recently-active agents plus a "See all agents" link (IA Phase 5).
*/
const RECENT_AGENT_LIMIT = 3;
+const LIVE_AGENT_LINGER_MS = 120_000;
const AGENT_SORT_CHOICES: SidebarSectionRadioChoice[] = [
{ value: "top", label: "Top" },
@@ -298,6 +299,8 @@ function SidebarAgentItem({
export function SidebarAgents({ streamlined = false }: { streamlined?: boolean } = {}) {
const [open, setOpen] = useState(true);
const [pendingAgentIds, setPendingAgentIds] = useState>(() => new Set());
+ const [liveLingerVersion, setLiveLingerVersion] = useState(0);
+ const lastSeenLiveAtRef = useRef