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 { 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 {
|
import {
|
||||||
buildHostWorkspaceRoute,
|
buildHostWorkspaceRoute,
|
||||||
decodeWorkspaceIdFromPathSegment,
|
decodeWorkspaceIdFromPathSegment,
|
||||||
parseHostWorkspaceRouteFromPathname,
|
parseHostWorkspaceRouteFromPathname,
|
||||||
} from "@/utils/host-routes";
|
} from "@/utils/host-routes";
|
||||||
|
import {
|
||||||
|
resolveWorkspaceIdByExecutionDirectory,
|
||||||
|
resolveWorkspaceMapKeyByIdentity,
|
||||||
|
} from "@/utils/workspace-execution";
|
||||||
|
|
||||||
export interface ActiveWorkspaceSelection {
|
export interface ActiveWorkspaceSelection {
|
||||||
serverId: string;
|
serverId: string;
|
||||||
@@ -45,6 +52,28 @@ export function navigateToWorkspace(
|
|||||||
workspaceId: string,
|
workspaceId: string,
|
||||||
_options: NavigateToWorkspaceOptions = {},
|
_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 };
|
lastWorkspaceSelection = { serverId, workspaceId };
|
||||||
const route = buildHostWorkspaceRoute(serverId, workspaceId) as Href;
|
const route = buildHostWorkspaceRoute(serverId, workspaceId) as Href;
|
||||||
router.dismissTo(route);
|
router.dismissTo(route);
|
||||||
|
|||||||
@@ -1,6 +1,41 @@
|
|||||||
import { describe, expect, it } from "vitest";
|
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", () => {
|
describe("shouldClearAgentAttention", () => {
|
||||||
it("returns true only when the agent is connected and requires attention", () => {
|
it("returns true only when the agent is connected and requires attention", () => {
|
||||||
@@ -106,3 +141,94 @@ describe("shouldClearAgentAttention", () => {
|
|||||||
).toBe(true);
|
).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 {
|
interface ShouldClearAgentAttentionInput {
|
||||||
agentId: string | null | undefined;
|
agentId: string | null | undefined;
|
||||||
isConnected: boolean;
|
isConnected: boolean;
|
||||||
@@ -13,6 +15,47 @@ export type AgentAttentionClearTrigger =
|
|||||||
| "prompt-send"
|
| "prompt-send"
|
||||||
| "agent-blur";
|
| "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 {
|
export function shouldClearAgentAttention(input: ShouldClearAgentAttentionInput): boolean {
|
||||||
const agentId = input.agentId?.trim();
|
const agentId = input.agentId?.trim();
|
||||||
if (!agentId) {
|
if (!agentId) {
|
||||||
|
|||||||
Reference in New Issue
Block a user