refactor: decouple permission state from attention signal

This commit is contained in:
Mohamed Boudra
2026-02-23 15:47:08 +07:00
parent 89a117e1c1
commit f3ebe0545f
18 changed files with 276 additions and 28 deletions
@@ -10,6 +10,7 @@ describe("shouldClearAgentAttentionOnView", () => {
focusedAgentId: "agent-1",
isConnected: true,
requiresAttention: true,
attentionReason: "finished",
})
).toBe(true);
});
@@ -21,6 +22,7 @@ describe("shouldClearAgentAttentionOnView", () => {
focusedAgentId: "agent-1",
isConnected: false,
requiresAttention: true,
attentionReason: "finished",
})
).toBe(false);
});
@@ -32,6 +34,7 @@ describe("shouldClearAgentAttentionOnView", () => {
focusedAgentId: "agent-2",
isConnected: true,
requiresAttention: true,
attentionReason: "finished",
})
).toBe(false);
});
@@ -43,6 +46,7 @@ describe("shouldClearAgentAttentionOnView", () => {
focusedAgentId: "agent-1",
isConnected: true,
requiresAttention: false,
attentionReason: null,
})
).toBe(false);
});
@@ -54,6 +58,19 @@ describe("shouldClearAgentAttentionOnView", () => {
focusedAgentId: "agent-1",
isConnected: true,
requiresAttention: true,
attentionReason: "finished",
})
).toBe(false);
});
it("returns false for permission-shaped attention", () => {
expect(
shouldClearAgentAttentionOnView({
agentId: "agent-1",
focusedAgentId: "agent-1",
isConnected: true,
requiresAttention: true,
attentionReason: "permission",
})
).toBe(false);
});
@@ -3,6 +3,7 @@ interface ShouldClearAgentAttentionOnViewInput {
focusedAgentId: string | null | undefined;
isConnected: boolean;
requiresAttention: boolean | null | undefined;
attentionReason?: "finished" | "error" | "permission" | null | undefined;
}
export function shouldClearAgentAttentionOnView(
@@ -18,5 +19,8 @@ export function shouldClearAgentAttentionOnView(
if (!input.requiresAttention) {
return false;
}
if (input.attentionReason === "permission") {
return false;
}
return input.focusedAgentId === agentId;
}
+10 -6
View File
@@ -192,7 +192,7 @@ export interface ProjectGroup {
projectKey: string;
projectName: string;
agents: AggregatedAgent[];
/** Number of truly active agents (running or requires attention) */
/** Number of truly active agents (running, needs input, or requires attention) */
activeCount: number;
/** Total agents before any limit was applied */
totalCount: number;
@@ -222,11 +222,11 @@ const MAX_INACTIVE_PER_PROJECT = 5;
/**
* Groups agents into active (by project) and inactive (by date) sections.
* Active = running, requires attention, or had activity within the grace period (24 hours).
* Active = running, needs input, requires attention, or had activity within the grace period.
*
* Within each project group:
* - All truly active agents (running/requires attention) are always shown
* - Recently active (within grace period but not running) are limited to MAX_INACTIVE_PER_PROJECT
* - All truly active agents (running/needs input/requires attention) are always shown
* - Recently active (within grace period but not truly active) are limited to MAX_INACTIVE_PER_PROJECT
*/
export function groupAgents(
agents: AggregatedAgent[],
@@ -244,7 +244,9 @@ export function groupAgents(
}
const isRunningOrAttention =
agent.status === "running" || agent.requiresAttention;
agent.status === "running" ||
agent.requiresAttention ||
(agent.pendingPermissionCount ?? 0) > 0;
const ageDiff = now - agent.lastActivityAt.getTime();
const isRecentlyActive = ageDiff < ACTIVE_GRACE_PERIOD_MS;
const isActive = isRunningOrAttention || isRecentlyActive;
@@ -272,7 +274,9 @@ export function groupAgents(
};
const isTrulyActive =
agent.status === "running" || agent.requiresAttention;
agent.status === "running" ||
agent.requiresAttention ||
(agent.pendingPermissionCount ?? 0) > 0;
if (isTrulyActive) {
existing.trulyActive.push(agent);
} else {
@@ -0,0 +1,37 @@
import { describe, expect, it } from "vitest";
import { deriveSidebarStateBucket } from "./sidebar-agent-state";
describe("deriveSidebarStateBucket", () => {
it("prioritizes pending permissions as needs_input", () => {
expect(
deriveSidebarStateBucket({
status: "idle",
pendingPermissionCount: 1,
requiresAttention: false,
attentionReason: null,
})
).toBe("needs_input");
});
it("keeps legacy permission attention in needs_input", () => {
expect(
deriveSidebarStateBucket({
status: "idle",
pendingPermissionCount: 0,
requiresAttention: true,
attentionReason: "permission",
})
).toBe("needs_input");
});
it("treats unread finished agents as attention", () => {
expect(
deriveSidebarStateBucket({
status: "idle",
pendingPermissionCount: 0,
requiresAttention: true,
attentionReason: "finished",
})
).toBe("attention");
});
});
@@ -16,10 +16,16 @@ export type SidebarStateBucket =
export function deriveSidebarStateBucket(input: {
status: AgentLifecycleStatus;
pendingPermissionCount?: number;
requiresAttention?: boolean;
attentionReason?: SidebarAttentionReason;
}): SidebarStateBucket {
if (input.requiresAttention && input.attentionReason === "permission") {
if ((input.pendingPermissionCount ?? 0) > 0) {
return "needs_input";
}
// Legacy fallback for snapshots persisted before permission state was decoupled
// from unread attention.
if (input.attentionReason === "permission") {
return "needs_input";
}
if (input.status === "error" || input.attentionReason === "error") {
@@ -37,6 +43,7 @@ export function deriveSidebarStateBucket(input: {
export function isSidebarActiveAgent(input: {
status: AgentLifecycleStatus;
pendingPermissionCount?: number;
requiresAttention?: boolean;
attentionReason?: SidebarAttentionReason;
}): boolean {