Keep workspace tabs predictable and clear attention on deliberate focus (#8935)

* fix(app): preserve workspace tabs and acknowledge deliberate agent focus

Workspace navigation no longer chooses an attention agent. Preserve repeated focus actions so an already selected chat can be acknowledged, and stop consuming attention on departure.

* fix(app): retain deliberate focus until fresh agent data arrives

Connection readiness permits a pending focus acknowledgement without creating a new focus action. Keep that intent across client changes and wait for the current timeline synchronization generation before consuming it.

* fix(app): preserve reconnect feedback during attention acknowledgement
This commit is contained in:
Mohamed Boudra
2026-11-06 20:55:16 +07:00
committed by GitHub
parent 68fc16b855
commit 471db4bdb5
14 changed files with 383 additions and 509 deletions
+99 -251
View File
@@ -1,269 +1,117 @@
import { describe, expect, it } from "vitest";
import type { Agent } from "@/stores/session-store";
import { pickAttentionAgent, shouldClearAgentAttention } from "@/utils/agent-attention";
import { createAgentAttentionController } 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,
turn: rest.turn ?? { phase: "idle", cancellationRequestId: null },
};
}
describe("agent attention acknowledgement", () => {
function setup() {
const calls: string[] = [];
const errors: unknown[] = [];
const controller = createAgentAttentionController({
clear: async () => {
calls.push("clear");
},
onError: (failure) => errors.push(failure),
});
const state = {
focused: true,
ready: true,
requiresAttention: false,
attentionReason: "finished" as const,
focusRequest: null as object | null,
};
return { controller, state, calls, errors };
}
describe("shouldClearAgentAttention", () => {
it("returns true only when the agent is connected and requires attention", () => {
expect(
shouldClearAgentAttention({
agentId: "agent-1",
isConnected: true,
requiresAttention: true,
attentionReason: "finished",
}),
).toBe(true);
it("acknowledges entry, but keeps attention arriving while focused until another focus action", async () => {
const { controller, state, calls } = setup();
controller.update(state);
state.requiresAttention = true;
controller.update(state);
expect(calls).toEqual([]);
controller.update({ ...state, focusRequest: {} });
expect(calls).toEqual(["clear"]);
await Promise.resolve();
controller.update({ ...state, focusRequest: {} });
expect(calls).toEqual(["clear", "clear"]);
});
it("returns false when the app is disconnected", () => {
expect(
shouldClearAgentAttention({
agentId: "agent-1",
isConnected: false,
requiresAttention: true,
attentionReason: "finished",
}),
).toBe(false);
it("clears on entry and never clears on leaving", async () => {
const { controller, state, calls } = setup();
controller.update({ ...state, requiresAttention: true });
expect(calls).toEqual(["clear"]);
await Promise.resolve();
controller.update({ ...state, focused: false, requiresAttention: true });
expect(calls).toEqual(["clear"]);
controller.update({ ...state, requiresAttention: true });
expect(calls).toEqual(["clear", "clear"]);
});
it("returns false when attention is already clear", () => {
expect(
shouldClearAgentAttention({
agentId: "agent-1",
isConnected: true,
requiresAttention: false,
attentionReason: null,
}),
).toBe(false);
it("waits for connection and agent data before consuming entry", () => {
const { controller, state, calls } = setup();
controller.update({ ...state, ready: false });
controller.update({ ...state, requiresAttention: true });
expect(calls).toEqual(["clear"]);
});
it("returns false for empty agent ids", () => {
expect(
shouldClearAgentAttention({
agentId: "",
isConnected: true,
requiresAttention: true,
attentionReason: "finished",
}),
).toBe(false);
it("does not treat reconnecting as another focus action", () => {
const { controller, state, calls } = setup();
controller.update(state);
controller.update({ ...state, requiresAttention: true });
controller.update({ ...state, requiresAttention: true, ready: false });
controller.update({ ...state, requiresAttention: true });
expect(calls).toEqual([]);
controller.focus();
expect(calls).toEqual(["clear"]);
});
it("returns false for permission-shaped attention", () => {
expect(
shouldClearAgentAttention({
agentId: "agent-1",
isConnected: true,
requiresAttention: true,
attentionReason: "permission",
}),
).toBe(false);
it("holds a repeat focus while cached data is waiting for synchronization", () => {
const { controller, state, calls } = setup();
controller.update(state);
controller.update({ ...state, ready: false });
controller.focus();
controller.update({ ...state, ready: false, requiresAttention: true });
expect(calls).toEqual([]);
controller.update({ ...state, requiresAttention: true });
expect(calls).toEqual(["clear"]);
});
it("returns false for focus entry when clear was deferred while already focused", () => {
expect(
shouldClearAgentAttention({
agentId: "agent-1",
isConnected: true,
requiresAttention: true,
attentionReason: "finished",
trigger: "focus-entry",
hasDeferredFocusEntryClear: true,
}),
).toBe(false);
it("drops a pending focus when the user leaves before fresh data arrives", () => {
const { controller, state, calls } = setup();
controller.update({ ...state, ready: false });
controller.update({ ...state, focused: false, requiresAttention: true });
expect(calls).toEqual([]);
});
it("returns true for explicit follow-up entrypoints after deferred focus clear", () => {
expect(
shouldClearAgentAttention({
agentId: "agent-1",
isConnected: true,
requiresAttention: true,
attentionReason: "finished",
trigger: "input-focus",
hasDeferredFocusEntryClear: true,
}),
).toBe(true);
it("keeps permission attention and deduplicates an in-flight acknowledgement", () => {
const { controller, state, calls } = setup();
controller.update({ ...state, requiresAttention: true, attentionReason: "permission" });
controller.focus();
expect(calls).toEqual([]);
controller.update({ ...state, requiresAttention: true });
controller.focus();
controller.focus();
expect(calls).toEqual(["clear"]);
});
expect(
shouldClearAgentAttention({
agentId: "agent-1",
isConnected: true,
requiresAttention: true,
attentionReason: "finished",
trigger: "prompt-send",
hasDeferredFocusEntryClear: true,
}),
).toBe(true);
expect(
shouldClearAgentAttention({
agentId: "agent-1",
isConnected: true,
requiresAttention: true,
attentionReason: "finished",
trigger: "agent-blur",
hasDeferredFocusEntryClear: 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 subagents even when they require attention", () => {
expect(
pickAttentionAgent([
createAgent({
id: "subagent",
parentAgentId: "parent-agent",
requiresAttention: true,
attentionReason: "permission",
attentionTimestamp: new Date("2026-01-01T00:00:00.000Z"),
}),
createAgent({
id: "top-level-agent",
requiresAttention: true,
attentionReason: "finished",
attentionTimestamp: new Date("2026-01-02T00:00:00.000Z"),
}),
]),
).toBe("top-level-agent");
});
it("returns null when only subagents require attention", () => {
expect(
pickAttentionAgent([
createAgent({
id: "subagent",
parentAgentId: "parent-agent",
requiresAttention: true,
attentionReason: "permission",
attentionTimestamp: new Date("2026-01-01T00:00:00.000Z"),
}),
]),
).toBeNull();
});
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");
it("reports failure and allows the next interaction to retry", async () => {
const errors: unknown[] = [];
let calls = 0;
const error = new Error("Disconnected");
const controller = createAgentAttentionController({
clear: async () => {
if (++calls === 1) throw error;
},
onError: (failure) => errors.push(failure),
});
controller.update({
focused: true,
ready: true,
requiresAttention: true,
attentionReason: "finished",
focusRequest: null,
});
await Promise.resolve();
expect(errors).toEqual([error]);
controller.focus();
expect(calls).toBe(2);
});
});
+43 -75
View File
@@ -1,81 +1,49 @@
import type { Agent } from "@/stores/session-store";
interface ShouldClearAgentAttentionInput {
agentId: string | null | undefined;
isConnected: boolean;
requiresAttention: boolean | null | undefined;
attentionReason?: "finished" | "error" | "permission" | null | undefined;
trigger?: AgentAttentionClearTrigger;
hasDeferredFocusEntryClear?: boolean;
interface AttentionState {
focused: boolean;
ready: boolean;
requiresAttention: boolean;
attentionReason: "finished" | "error" | "permission" | null | undefined;
focusRequest: object | null;
}
export type AgentAttentionClearTrigger =
| "focus-entry"
| "input-focus"
| "prompt-send"
| "agent-blur";
export function createAgentAttentionController(deps: {
clear: () => Promise<void>;
onError: (error: unknown) => void;
}) {
let state: AttentionState | null = null;
let inFlight = false;
let pendingFocus = false;
const ATTENTION_REASON_PRIORITY = {
permission: 0,
error: 1,
finished: 2,
} as const;
function getAttentionPriority(reason: Agent["attentionReason"]): number | null {
if (!reason) {
return null;
function acknowledgeFocus() {
if (!pendingFocus || !state?.focused || !state.ready) return;
pendingFocus = false;
if (!state.requiresAttention || state.attentionReason === "permission" || inFlight) return;
inFlight = true;
void deps.clear().then(
() => {
inFlight = false;
return undefined;
},
(error: unknown) => {
inFlight = false;
deps.onError(error);
},
);
}
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;
}
if (agent.parentAgentId) {
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) {
return false;
}
if (!input.isConnected) {
return false;
}
if (!input.requiresAttention) {
return false;
}
if (input.attentionReason === "permission") {
return false;
}
if (input.trigger === "focus-entry" && input.hasDeferredFocusEntryClear === true) {
return false;
}
return true;
return {
focus() {
if (!state?.focused) return;
pendingFocus = true;
acknowledgeFocus();
},
update(next: AttentionState) {
const entered = next.focused && !state?.focused;
const requested = next.focusRequest !== null && next.focusRequest !== state?.focusRequest;
state = { ...next };
if (!next.focused) pendingFocus = false;
else if (entered || requested) pendingFocus = true;
acknowledgeFocus();
},
};
}