refactor: decouple permission state from attention signal
This commit is contained in:
@@ -185,6 +185,7 @@ function SidebarAgentRow({
|
||||
const relativeCreatedAt = formatTimeAgo(entry.agent.createdAt);
|
||||
const isActive = isSidebarActiveAgent({
|
||||
status: entry.agent.status,
|
||||
pendingPermissionCount: entry.agent.pendingPermissionCount,
|
||||
requiresAttention: entry.agent.requiresAttention,
|
||||
attentionReason: entry.agent.attentionReason,
|
||||
});
|
||||
|
||||
@@ -64,6 +64,7 @@ export function useAggregatedAgents(): AggregatedAgentsResult {
|
||||
lastActivityAt: agent.lastActivityAt,
|
||||
cwd: agent.cwd,
|
||||
provider: agent.provider,
|
||||
pendingPermissionCount: agent.pendingPermissions.length,
|
||||
requiresAttention: agent.requiresAttention,
|
||||
attentionReason: agent.attentionReason,
|
||||
attentionTimestamp: agent.attentionTimestamp,
|
||||
|
||||
@@ -26,6 +26,7 @@ function toAggregatedAgent(params: {
|
||||
lastActivityAt: source.lastActivityAt,
|
||||
cwd: source.cwd,
|
||||
provider: source.provider,
|
||||
pendingPermissionCount: source.pendingPermissions.length,
|
||||
requiresAttention: source.requiresAttention,
|
||||
attentionReason: source.attentionReason,
|
||||
attentionTimestamp: source.attentionTimestamp ?? null,
|
||||
|
||||
@@ -40,6 +40,10 @@ function isMatch(agent: AggregatedAgent, query: string): boolean {
|
||||
}
|
||||
|
||||
function sortAgents(left: AggregatedAgent, right: AggregatedAgent): number {
|
||||
const leftNeedsInput = (left.pendingPermissionCount ?? 0) > 0 ? 1 : 0;
|
||||
const rightNeedsInput = (right.pendingPermissionCount ?? 0) > 0 ? 1 : 0;
|
||||
if (leftNeedsInput !== rightNeedsInput) return rightNeedsInput - leftNeedsInput;
|
||||
|
||||
const leftAttention = left.requiresAttention ? 1 : 0;
|
||||
const rightAttention = right.requiresAttention ? 1 : 0;
|
||||
if (leftAttention !== rightAttention) return rightAttention - leftAttention;
|
||||
|
||||
@@ -30,7 +30,8 @@ function deriveFaviconStatus(
|
||||
return "running";
|
||||
}
|
||||
const hasAttention = agents.some((agent) => agent.requiresAttention);
|
||||
if (hasAttention) {
|
||||
const hasNeedsInput = agents.some((agent) => (agent.pendingPermissionCount ?? 0) > 0);
|
||||
if (hasAttention || hasNeedsInput) {
|
||||
return "attention";
|
||||
}
|
||||
return "none";
|
||||
@@ -41,8 +42,8 @@ function deriveMacDockBadgeCount(
|
||||
): number | undefined {
|
||||
const attentionCount = agents.filter(
|
||||
(agent) =>
|
||||
agent.requiresAttention &&
|
||||
(agent.attentionReason === "permission" || agent.attentionReason === "finished")
|
||||
(agent.pendingPermissionCount ?? 0) > 0 ||
|
||||
(agent.requiresAttention && agent.attentionReason === "finished")
|
||||
).length;
|
||||
if (attentionCount > 0) {
|
||||
return attentionCount;
|
||||
|
||||
@@ -77,6 +77,7 @@ function applySidebarDefaultOrdering(
|
||||
for (const entry of entries) {
|
||||
const bucket = deriveSidebarStateBucket({
|
||||
status: entry.agent.status,
|
||||
pendingPermissionCount: entry.agent.pendingPermissionCount,
|
||||
requiresAttention: entry.agent.requiresAttention,
|
||||
attentionReason: entry.agent.attentionReason,
|
||||
});
|
||||
@@ -135,6 +136,7 @@ function toAggregatedAgent(params: {
|
||||
lastActivityAt: params.lastActivityAt,
|
||||
cwd: source.cwd,
|
||||
provider: source.provider,
|
||||
pendingPermissionCount: source.pendingPermissions.length,
|
||||
requiresAttention: source.requiresAttention,
|
||||
attentionReason: source.attentionReason,
|
||||
attentionTimestamp: source.attentionTimestamp ?? null,
|
||||
@@ -239,6 +241,7 @@ export function useSidebarAgentsList(options?: {
|
||||
const existing = byProject.get(entry.project.projectKey);
|
||||
const isActive = isSidebarActiveAgent({
|
||||
status: entry.agent.status,
|
||||
pendingPermissionCount: entry.agent.pendingPermissionCount,
|
||||
requiresAttention: entry.agent.requiresAttention,
|
||||
attentionReason: entry.agent.attentionReason,
|
||||
});
|
||||
|
||||
@@ -924,6 +924,7 @@ function AgentScreenContent({
|
||||
focusedAgentId,
|
||||
isConnected,
|
||||
requiresAttention: agent?.requiresAttention,
|
||||
attentionReason: agent?.attentionReason,
|
||||
})
|
||||
) {
|
||||
return;
|
||||
|
||||
@@ -29,6 +29,11 @@ export function AgentsScreen({ serverId }: { serverId: string }) {
|
||||
|
||||
const sortedAgents = useMemo(() => {
|
||||
return [...agents].sort((a, b) => {
|
||||
const aNeedsInput = (a.pendingPermissionCount ?? 0) > 0;
|
||||
const bNeedsInput = (b.pendingPermissionCount ?? 0) > 0;
|
||||
if (aNeedsInput && !bNeedsInput) return -1;
|
||||
if (!aNeedsInput && bNeedsInput) return 1;
|
||||
|
||||
if (a.requiresAttention && !b.requiresAttention) return -1;
|
||||
if (!a.requiresAttention && b.requiresAttention) return 1;
|
||||
return 0;
|
||||
|
||||
@@ -995,6 +995,7 @@ export const useSessionStore = create<SessionStore>()(
|
||||
lastActivityAt,
|
||||
cwd: agent.cwd,
|
||||
provider: agent.provider,
|
||||
pendingPermissionCount: agent.pendingPermissions.length,
|
||||
requiresAttention: agent.requiresAttention ?? false,
|
||||
attentionReason: agent.attentionReason ?? null,
|
||||
attentionTimestamp: agent.attentionTimestamp ?? null,
|
||||
|
||||
@@ -14,4 +14,6 @@ export type AgentDirectoryEntry = Pick<
|
||||
| "attentionTimestamp"
|
||||
| "archivedAt"
|
||||
| "labels"
|
||||
>;
|
||||
> & {
|
||||
pendingPermissionCount?: number;
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user