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:
@@ -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);
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user