feat(app): focus attention-needing agent tab on workspace navigation (#828)

When navigating to a workspace, auto-open the tab for the agent that
most urgently needs attention (permission > error > finished, oldest
first). Adds pickAttentionAgent + wires it into navigateToWorkspace.
This commit is contained in:
Mohamed Boudra
2026-05-09 16:07:14 +08:00
committed by GitHub
parent 68ceac492c
commit 103601705d
3 changed files with 199 additions and 1 deletions
@@ -1,9 +1,16 @@
import { router, useLocalSearchParams, usePathname, type Href } from "expo-router";
import { useSessionStore } from "@/stores/session-store";
import { useWorkspaceLayoutStore } from "@/stores/workspace-layout-store";
import { pickAttentionAgent } from "@/utils/agent-attention";
import {
buildHostWorkspaceRoute,
decodeWorkspaceIdFromPathSegment,
parseHostWorkspaceRouteFromPathname,
} from "@/utils/host-routes";
import {
resolveWorkspaceIdByExecutionDirectory,
resolveWorkspaceMapKeyByIdentity,
} from "@/utils/workspace-execution";
export interface ActiveWorkspaceSelection {
serverId: string;
@@ -45,6 +52,28 @@ export function navigateToWorkspace(
workspaceId: string,
_options: NavigateToWorkspaceOptions = {},
) {
const session = useSessionStore.getState().sessions[serverId];
const resolvedWorkspaceId = resolveWorkspaceMapKeyByIdentity({
workspaces: session?.workspaces,
workspaceId,
});
const workspaceAgents = resolvedWorkspaceId
? Array.from(session?.agents.values() ?? []).filter(
(agent) =>
resolveWorkspaceIdByExecutionDirectory({
workspaces: session?.workspaces?.values(),
workspaceDirectory: agent.cwd,
}) === resolvedWorkspaceId,
)
: [];
const attentionAgentId = pickAttentionAgent(workspaceAgents);
if (attentionAgentId && resolvedWorkspaceId) {
useWorkspaceLayoutStore.getState().openTabFocused(`${serverId}:${resolvedWorkspaceId}`, {
kind: "agent",
agentId: attentionAgentId,
});
}
lastWorkspaceSelection = { serverId, workspaceId };
const route = buildHostWorkspaceRoute(serverId, workspaceId) as Href;
router.dismissTo(route);
+127 -1
View File
@@ -1,6 +1,41 @@
import { describe, expect, it } from "vitest";
import type { Agent } from "@/stores/session-store";
import { pickAttentionAgent, shouldClearAgentAttention } from "@/utils/agent-attention";
import { shouldClearAgentAttention } from "./agent-attention";
function createAgent(input: Partial<Agent> & Pick<Agent, "id">): Agent {
const { id, ...rest } = input;
return {
serverId: "server-1",
id,
provider: "codex",
status: "idle",
createdAt: new Date("2026-01-01T00:00:00.000Z"),
updatedAt: new Date("2026-01-01T00:00:00.000Z"),
lastUserMessageAt: null,
lastActivityAt: new Date("2026-01-01T00:00:00.000Z"),
capabilities: {
supportsStreaming: true,
supportsSessionPersistence: true,
supportsDynamicModes: true,
supportsMcpServers: true,
supportsReasoningStream: true,
supportsToolInvocations: true,
},
currentModeId: null,
availableModes: [],
pendingPermissions: [],
persistence: null,
title: null,
cwd: "/repo/worktree",
model: null,
parentAgentId: null,
labels: {},
requiresAttention: false,
attentionReason: null,
attentionTimestamp: null,
...rest,
};
}
describe("shouldClearAgentAttention", () => {
it("returns true only when the agent is connected and requires attention", () => {
@@ -106,3 +141,94 @@ describe("shouldClearAgentAttention", () => {
).toBe(true);
});
});
describe("pickAttentionAgent", () => {
it("returns null for empty input", () => {
expect(pickAttentionAgent([])).toBeNull();
});
it("returns null when no agent requires attention", () => {
expect(
pickAttentionAgent([
createAgent({ id: "agent-1", requiresAttention: false, attentionReason: "permission" }),
createAgent({ id: "agent-2", requiresAttention: false, attentionReason: null }),
]),
).toBeNull();
});
it("returns the single permission agent", () => {
expect(
pickAttentionAgent([
createAgent({
id: "agent-1",
requiresAttention: true,
attentionReason: "permission",
attentionTimestamp: new Date("2026-01-03T00:00:00.000Z"),
}),
]),
).toBe("agent-1");
});
it("prioritizes permission over error over finished", () => {
expect(
pickAttentionAgent([
createAgent({
id: "finished-agent",
requiresAttention: true,
attentionReason: "finished",
attentionTimestamp: new Date("2026-01-01T00:00:00.000Z"),
}),
createAgent({
id: "error-agent",
requiresAttention: true,
attentionReason: "error",
attentionTimestamp: new Date("2026-01-02T00:00:00.000Z"),
}),
createAgent({
id: "permission-agent",
requiresAttention: true,
attentionReason: "permission",
attentionTimestamp: new Date("2026-01-03T00:00:00.000Z"),
}),
]),
).toBe("permission-agent");
});
it("breaks ties by oldest attention timestamp", () => {
expect(
pickAttentionAgent([
createAgent({
id: "newer-permission-agent",
requiresAttention: true,
attentionReason: "permission",
attentionTimestamp: new Date("2026-01-03T00:00:00.000Z"),
}),
createAgent({
id: "older-permission-agent",
requiresAttention: true,
attentionReason: "permission",
attentionTimestamp: new Date("2026-01-01T00:00:00.000Z"),
}),
]),
).toBe("older-permission-agent");
});
it("ignores agents with requiresAttention true but null reason", () => {
expect(
pickAttentionAgent([
createAgent({
id: "ignored-agent",
requiresAttention: true,
attentionReason: null,
attentionTimestamp: new Date("2026-01-01T00:00:00.000Z"),
}),
createAgent({
id: "error-agent",
requiresAttention: true,
attentionReason: "error",
attentionTimestamp: new Date("2026-01-02T00:00:00.000Z"),
}),
]),
).toBe("error-agent");
});
});
+43
View File
@@ -1,3 +1,5 @@
import type { Agent } from "@/stores/session-store";
interface ShouldClearAgentAttentionInput {
agentId: string | null | undefined;
isConnected: boolean;
@@ -13,6 +15,47 @@ export type AgentAttentionClearTrigger =
| "prompt-send"
| "agent-blur";
const ATTENTION_REASON_PRIORITY = {
permission: 0,
error: 1,
finished: 2,
} as const;
function getAttentionPriority(reason: Agent["attentionReason"]): number | null {
if (!reason) {
return null;
}
return ATTENTION_REASON_PRIORITY[reason];
}
export function pickAttentionAgent(agents: Agent[]): string | null {
let selectedAgentId: string | null = null;
let selectedPriority = Number.POSITIVE_INFINITY;
let selectedTimestamp = Number.POSITIVE_INFINITY;
for (const agent of agents) {
if (agent.requiresAttention !== true) {
continue;
}
const priority = getAttentionPriority(agent.attentionReason);
if (priority === null) {
continue;
}
const timestamp = agent.attentionTimestamp?.getTime() ?? Number.POSITIVE_INFINITY;
const isHigherPriority = priority < selectedPriority;
const isOlderAtSamePriority = priority === selectedPriority && timestamp < selectedTimestamp;
if (isHigherPriority || isOlderAtSamePriority) {
selectedAgentId = agent.id;
selectedPriority = priority;
selectedTimestamp = timestamp;
}
}
return selectedAgentId;
}
export function shouldClearAgentAttention(input: ShouldClearAgentAttentionInput): boolean {
const agentId = input.agentId?.trim();
if (!agentId) {