From 103601705ddd074d204b06ec4fee584c37119745 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Sat, 9 May 2026 16:07:14 +0800 Subject: [PATCH] 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. --- .../navigation-active-workspace-store.ts | 29 ++++ .../app/src/utils/agent-attention.test.ts | 128 +++++++++++++++++- packages/app/src/utils/agent-attention.ts | 43 ++++++ 3 files changed, 199 insertions(+), 1 deletion(-) diff --git a/packages/app/src/stores/navigation-active-workspace-store.ts b/packages/app/src/stores/navigation-active-workspace-store.ts index c6f51cd21..930f13e14 100644 --- a/packages/app/src/stores/navigation-active-workspace-store.ts +++ b/packages/app/src/stores/navigation-active-workspace-store.ts @@ -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); diff --git a/packages/app/src/utils/agent-attention.test.ts b/packages/app/src/utils/agent-attention.test.ts index 4d8dda788..e05759306 100644 --- a/packages/app/src/utils/agent-attention.test.ts +++ b/packages/app/src/utils/agent-attention.test.ts @@ -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 & Pick): 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"); + }); +}); diff --git a/packages/app/src/utils/agent-attention.ts b/packages/app/src/utils/agent-attention.ts index 9dd387c30..8db905a49 100644 --- a/packages/app/src/utils/agent-attention.ts +++ b/packages/app/src/utils/agent-attention.ts @@ -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) {