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
@@ -1,12 +1,10 @@
import { useCallback, useEffect, useRef, useState } from "react";
import { useEffect, useMemo, useState } from "react";
import { AppState } from "react-native";
import type { DaemonClient } from "@getpaseo/client/internal/daemon-client";
import {
shouldClearAgentAttention,
type AgentAttentionClearTrigger,
} from "@/utils/agent-attention";
import { DaemonConnectionError, type DaemonClient } from "@getpaseo/client/internal/daemon-client";
import { createAgentAttentionController } from "@/utils/agent-attention";
import { getIsAppActivelyVisible } from "@/utils/app-visibility";
import { isWeb } from "@/constants/platform";
import { useStableEvent } from "@/hooks/use-stable-event";
type AttentionReason = "finished" | "error" | "permission" | null | undefined;
@@ -14,54 +12,48 @@ interface UseAgentAttentionClearParams {
agentId: string | null | undefined;
client: DaemonClient | null;
isConnected: boolean;
requiresAttention: boolean | null | undefined;
isAgentDataReady: boolean;
requiresAttention: boolean;
attentionReason: AttentionReason;
isScreenFocused: boolean;
focusRequest: object | null;
onError: (error: unknown) => void;
}
interface AgentAttentionClearController {
clearOnInputFocus: () => void;
clearOnPromptSend: () => void;
clearOnAgentBlur: () => void;
}
export function useAgentAttentionClear({
agentId,
client,
isConnected,
isAgentDataReady,
requiresAttention,
attentionReason,
isScreenFocused,
focusRequest,
onError,
}: UseAgentAttentionClearParams): AgentAttentionClearController {
const [isAppVisible, setIsAppVisible] = useState<boolean>(() => getIsAppActivelyVisible());
const deferredFocusEntryClearRef = useRef(false);
const prevRequiresAttentionRef = useRef(Boolean(requiresAttention));
const prevActivelyViewedRef = useRef(isScreenFocused && getIsAppActivelyVisible());
const prevScreenFocusedRef = useRef(false);
const prevAppVisibleRef = useRef(getIsAppActivelyVisible());
const clearAttention = useCallback(
(trigger: AgentAttentionClearTrigger) => {
const resolvedAgentId = agentId?.trim();
if (!client || !resolvedAgentId) {
return;
}
if (
!shouldClearAgentAttention({
agentId: resolvedAgentId,
isConnected,
requiresAttention,
attentionReason,
trigger,
hasDeferredFocusEntryClear: deferredFocusEntryClearRef.current,
})
) {
return;
}
deferredFocusEntryClearRef.current = false;
client.clearAgentAttention(resolvedAgentId).catch(() => {});
},
[agentId, attentionReason, client, isConnected, requiresAttention],
const clearAttention = useStableEvent(async (id: string) => {
if (client) await client.clearAgentAttention(id);
});
const reportError = useStableEvent((error: unknown) => {
// The timeline's reconnect status already explains a lost connection.
if (error instanceof DaemonConnectionError && error.code === "DAEMON_CONNECTION_LOST") return;
onError(error);
});
const controller = useMemo(
() =>
createAgentAttentionController({
clear: async () => {
if (agentId) await clearAttention(agentId);
},
onError: reportError,
}),
[agentId, clearAttention, reportError],
);
useEffect(() => {
@@ -90,46 +82,28 @@ export function useAgentAttentionClear({
}, []);
useEffect(() => {
if (!requiresAttention) {
deferredFocusEntryClearRef.current = false;
}
}, [requiresAttention]);
useEffect(() => {
const isActivelyViewed = isScreenFocused && isAppVisible;
if (
!prevRequiresAttentionRef.current &&
Boolean(requiresAttention) &&
prevActivelyViewedRef.current &&
isActivelyViewed
) {
deferredFocusEntryClearRef.current = true;
}
prevRequiresAttentionRef.current = Boolean(requiresAttention);
prevActivelyViewedRef.current = isActivelyViewed;
}, [isAppVisible, isScreenFocused, requiresAttention]);
useEffect(() => {
const enteredScreenFocus = !prevScreenFocusedRef.current && isScreenFocused && isAppVisible;
const resumedIntoFocusedAgent = !prevAppVisibleRef.current && isAppVisible && isScreenFocused;
if (enteredScreenFocus || resumedIntoFocusedAgent) {
clearAttention("focus-entry");
}
prevScreenFocusedRef.current = isScreenFocused;
prevAppVisibleRef.current = isAppVisible;
}, [clearAttention, isAppVisible, isScreenFocused]);
controller.update({
focused: isScreenFocused && isAppVisible,
ready: Boolean(client && agentId && isConnected && isAgentDataReady),
requiresAttention,
attentionReason,
focusRequest,
});
}, [
controller,
client,
agentId,
isConnected,
isAgentDataReady,
isScreenFocused,
isAppVisible,
requiresAttention,
attentionReason,
focusRequest,
]);
return {
clearOnInputFocus: useCallback(() => {
clearAttention("input-focus");
}, [clearAttention]),
clearOnPromptSend: useCallback(() => {
clearAttention("prompt-send");
}, [clearAttention]),
clearOnAgentBlur: useCallback(() => {
clearAttention("agent-blur");
}, [clearAttention]),
clearOnInputFocus: controller.focus,
clearOnPromptSend: controller.focus,
};
}
+23 -22
View File
@@ -677,8 +677,6 @@ function ChatAgentContent({
const { api: toastApi, toast: toastState, dismiss: dismissToast } = useToastHost();
const { isArchivingAgent } = useArchiveAgent();
const streamViewRef = useRef<AgentStreamViewHandle>(null);
const clearOnAgentBlurRef = useRef<() => void>(() => {});
const wasPaneFocusedRef = useRef(isPaneFocused);
const initAttemptTokenRef = useRef(0);
const routeBottomAnchorRequestRef = useRef<{
routeKey: string;
@@ -753,35 +751,31 @@ function ChatAgentContent({
const hasHydratedHistoryBefore =
hasAppliedAuthoritativeHistory || replicaTimelineStatus === "painted";
const focusRequest = useWorkspaceLayoutStore((state) => {
const request = state.focusRequest;
return request?.workspaceKey === `${serverId}:${workspaceId}` && request.tabId === tabId
? request
: null;
});
const reportAttentionError = useCallback(
(error: unknown) => {
toastApi.show(String(error), { variant: "error", durationMs: null });
},
[toastApi],
);
const attentionController = useAgentAttentionClear({
agentId,
client,
isConnected,
isAgentDataReady: agentHistorySyncGeneration === historySyncGeneration,
requiresAttention: agentState.requiresAttention,
attentionReason: agentState.attentionReason,
isScreenFocused: isPaneFocused,
focusRequest,
onError: reportAttentionError,
});
useEffect(() => {
clearOnAgentBlurRef.current = attentionController.clearOnAgentBlur;
}, [attentionController.clearOnAgentBlur]);
const isArchivingCurrentAgent = Boolean(agentId && isArchivingAgent({ serverId, agentId }));
useEffect(() => {
if (wasPaneFocusedRef.current && !isPaneFocused) {
clearOnAgentBlurRef.current();
}
wasPaneFocusedRef.current = isPaneFocused;
}, [isPaneFocused]);
useEffect(() => {
return () => {
if (wasPaneFocusedRef.current) {
clearOnAgentBlurRef.current();
}
};
}, []);
const isInitializing = agentId ? isInitializingFromMap : false;
const isHistorySyncing = useMemo(() => {
if (!agentId || !isInitializing) {
@@ -1185,8 +1179,15 @@ const ChatAgentReadyContent = memo(function ChatAgentReadyContent({
) : (
agentComposerSection
);
const handleAttentionInteraction = useCallback(() => {
onAttentionInputFocus();
return false;
}, [onAttentionInputFocus]);
const streamContent = (
<View style={animatedStaticStyles.content}>
<View
style={animatedStaticStyles.content}
onStartShouldSetResponderCapture={handleAttentionInteraction}
>
{showsComposer ? null : <SheetAgentFacts serverId={serverId} agentId={agentId} />}
<RenderProfile id={`AgentStreamSection:${agentId}`}>
<AgentStreamSection
@@ -380,9 +380,9 @@ const MobileMountedTabSlot = memo(function MobileMountedTabSlot({
[buildPaneContentModel, paneId, tabDescriptor],
);
const handleTouch = useCallback(() => {
if (!isPaneFocused && paneId) onFocusPane(paneId);
if (paneId) onFocusPane(paneId);
return false;
}, [isPaneFocused, onFocusPane, paneId]);
}, [onFocusPane, paneId]);
return (
<RenderProfile id={`MobileMountedTabSlot:${tabDescriptor.kind}:${tabDescriptor.tabId}`}>
@@ -37,12 +37,11 @@ const lastWorkspaceSelectionStore = createLastWorkspaceSelectionStore(
function navigateDeps(): NavigateToWorkspaceDeps {
return {
revealTab: revealWorkspaceTab,
revealWorkspace: () => {
revealWorkspace: (workspaceKey) => {
useWorkspaceLayoutStore.getState().focusWorkspace(workspaceKey);
usePanelStore.getState().showMobileAgent();
},
getSessionWorkspaces: (serverId) => useSessionStore.getState().sessions[serverId]?.workspaces,
getSessionAgents: (serverId) =>
useSessionStore.getState().sessions[serverId]?.agents.values() ?? [],
isWorkspaceLayoutHydrated: () => useWorkspaceLayoutStore.persist.hasHydrated(),
deferOpenTab: (input) => useWorkspaceLayoutStore.getState().deferOpenTab(input),
openTab: (input) => useWorkspaceLayoutStore.getState().openTab(input),
@@ -9,7 +9,7 @@ import {
type NavigateToLastWorkspaceDeps,
type NavigateToWorkspaceDeps,
} from "./navigation";
import type { Agent, WorkspaceDescriptor } from "@/stores/session-store";
import type { WorkspaceDescriptor } from "@/stores/session-store";
interface RecordedTab {
workspaceKey: string;
@@ -35,7 +35,6 @@ function createFakeDeps(overrides: Partial<NavigateToWorkspaceDeps> = {}) {
return null;
},
getSessionWorkspaces: () => null,
getSessionAgents: () => [] as Agent[],
isWorkspaceLayoutHydrated: () => true,
openTab: ({ workspaceKey, target, pin = false }) => {
openedTabs.push({ workspaceKey, target, pin });
@@ -105,49 +104,27 @@ describe("workspace navigation", () => {
expect(remembered).toEqual([{ serverId: "server-1", workspaceId: "workspace-a" }]);
});
it("focuses the attention agent's tab when a workspace has one", () => {
it("reveals the workspace without choosing a different tab", () => {
const workspace = {
id: "workspace-a",
workspaceDirectory: "/repo/workspace-a",
} as WorkspaceDescriptor;
const agent = {
id: "agent-1",
cwd: "/repo/workspace-a",
workspaceId: "workspace-a",
requiresAttention: true,
attentionReason: "permission",
} as unknown as Agent;
const { deps, openedTabs } = createFakeDeps({
getSessionWorkspaces: () => new Map([[workspace.id, workspace]]),
getSessionAgents: () => [agent],
});
navigateToWorkspace({ serverId: "server-1", workspaceId: "workspace-a" }, deps);
expect(openedTabs).toEqual([
{
workspaceKey: "server-1:workspace-a",
target: { kind: "agent", agentId: "agent-1" },
pin: false,
},
]);
expect(openedTabs).toEqual([]);
});
it("keeps an explicit tab authoritative over an attention agent", () => {
it("opens an explicitly requested tab", () => {
const workspace = {
id: "workspace-a",
workspaceDirectory: "/repo/workspace-a",
} as WorkspaceDescriptor;
const agent = {
id: "agent-1",
cwd: "/repo/workspace-a",
workspaceId: "workspace-a",
requiresAttention: true,
attentionReason: "permission",
} as unknown as Agent;
const { deps, openedTabs } = createFakeDeps({
getSessionWorkspaces: () => new Map([[workspace.id, workspace]]),
getSessionAgents: () => [agent],
});
navigateToWorkspace(
@@ -1,14 +1,10 @@
import type { Agent, WorkspaceDescriptor } from "@/stores/session-store";
import { pickAttentionAgent } from "@/utils/agent-attention";
import type { WorkspaceDescriptor } from "@/stores/session-store";
import {
buildHostWorkspaceRoute,
decodeWorkspaceIdFromPathSegment,
parseHostWorkspaceRouteFromPathname,
} from "@/utils/host-routes";
import {
normalizeWorkspaceOpaqueId,
resolveWorkspaceMapKeyByIdentity,
} from "@/utils/workspace-identity";
import { resolveWorkspaceMapKeyByIdentity } from "@/utils/workspace-identity";
import type { ActiveWorkspaceSelection } from "@/stores/last-workspace-selection";
import type { WorkspaceTabTarget } from "@/workspace-tabs/model";
import { prepareWorkspaceTab, type PrepareWorkspaceTabDeps } from "@/utils/prepare-workspace-tab";
@@ -33,13 +29,12 @@ export interface NavigateToWorkspaceInput {
export interface NavigateToWorkspaceDeps extends PrepareWorkspaceTabDeps {
getSessionWorkspaces: (serverId: string) => Map<string, WorkspaceDescriptor> | null | undefined;
getSessionAgents: (serverId: string) => Iterable<Agent>;
isWorkspaceLayoutHydrated: () => boolean;
deferOpenTab: (input: OpenWorkspaceTabInput) => null;
rememberLastWorkspace: (selection: ActiveWorkspaceSelection) => void;
navigateToRoute: (route: string) => void;
revealTab: (workspaceKey: string, tabId: string | null) => void;
revealWorkspace: () => void;
revealWorkspace: (workspaceKey: string) => void;
}
export interface NavigateToLastWorkspaceDeps extends NavigateToWorkspaceDeps {
@@ -104,21 +99,7 @@ export function navigateToWorkspace(
if (input.target) {
prepareWorkspaceTab({ ...input, target: input.target }, { openTab });
} else {
const workspaceAgents = resolvedWorkspaceId
? Array.from(deps.getSessionAgents(input.serverId)).filter(
(agent) => normalizeWorkspaceOpaqueId(agent.workspaceId) === resolvedWorkspaceId,
)
: [];
const attentionAgentId = pickAttentionAgent(workspaceAgents);
if (attentionAgentId && resolvedWorkspaceId) {
openTab({
workspaceKey: `${input.serverId}:${resolvedWorkspaceId}`,
target: { kind: "agent", agentId: attentionAgentId },
intent: "reveal",
});
} else {
deps.revealWorkspace();
}
deps.revealWorkspace(`${input.serverId}:${resolvedWorkspaceId ?? input.workspaceId}`);
}
const route = buildHostWorkspaceRoute(input.serverId, input.workspaceId);
@@ -5244,3 +5244,36 @@ describe("pending workspace opens", () => {
expect(store.getState().pendingOpenByWorkspace).toEqual({});
});
});
it("records repeated deliberate focus even when the selected pane and tab do not change", () => {
const store = createWorkspaceLayoutStore(workspaceLayoutIds);
const workspaceKey = "server:focus";
const tabId = store
.getState()
.openTab({ workspaceKey, target: { kind: "agent", agentId: "agent" }, intent: "reveal" })!;
const paneId = store.getState().layoutByWorkspace[workspaceKey]!.focusedPaneId!;
for (const focus of [
() => store.getState().focusPane(workspaceKey, paneId),
() => store.getState().focusTab(workspaceKey, tabId),
() => store.getState().selectTabInPane(workspaceKey, paneId, tabId),
]) {
focus();
const first = store.getState().focusRequest;
focus();
expect(store.getState().focusRequest).toEqual({ workspaceKey, tabId });
expect(store.getState().focusRequest).not.toBe(first);
}
});
it("reentering a workspace restores its suspended pane without choosing another tab", () => {
const store = createWorkspaceLayoutStore(workspaceLayoutIds);
const workspaceKey = "server:resume";
const tabId = store
.getState()
.openTab({ workspaceKey, target: { kind: "agent", agentId: "agent" }, intent: "reveal" })!;
const paneId = store.getState().layoutByWorkspace[workspaceKey]!.focusedPaneId;
store.getState().unfocusPane(workspaceKey);
store.getState().focusWorkspace(workspaceKey);
expect(store.getState().layoutByWorkspace[workspaceKey]!.focusedPaneId).toBe(paneId);
expect(store.getState().focusRequest).toEqual({ workspaceKey, tabId });
});
@@ -128,6 +128,8 @@ export type WorkspaceTabDestination =
| { kind: "sheet"; tabId: string };
interface WorkspaceLayoutStore {
/** Deliberate focus, including reselecting the current tab. Never persisted. */
focusRequest: { workspaceKey: string; tabId: string } | null;
panelRevisionByWorkspace: Record<string, Record<string, number>>;
/** Published once by the app shell; callers never pass a form factor to open a tab. */
layoutMode: WorkspaceLayoutMode;
@@ -210,6 +212,7 @@ interface WorkspaceLayoutStore {
* (archiving agents, killing terminals) first. No-ops on the last visible pane.
*/
closePane: (workspaceKey: string, paneId: string) => void;
focusWorkspace: (workspaceKey: string) => void;
focusPane: (workspaceKey: string, paneId: string) => void;
unfocusPane: (workspaceKey: string) => string | null;
restorePaneFocus: (workspaceKey: string, token: string) => void;
@@ -1083,6 +1086,7 @@ export function createWorkspaceLayoutStore(
},
layoutMode: "split-panes",
sheetsByWorkspace: {},
focusRequest: null,
layoutByWorkspace: {},
splitSizesByWorkspace: {},
explorerSidebarWidthByWorkspace: {},
@@ -1135,6 +1139,7 @@ export function createWorkspaceLayoutStore(
});
set((state) => ({
...withSheets(state, normalizedWorkspaceKey, next),
focusRequest: { workspaceKey: normalizedWorkspaceKey, tabId: sheet.tab.tabId },
...advancePanelRevision(
state,
normalizedWorkspaceKey,
@@ -1198,6 +1203,9 @@ export function createWorkspaceLayoutStore(
set((state) => ({
...withoutFocusRestoration(state, normalizedWorkspaceKey),
...advancePanelRevision(state, normalizedWorkspaceKey, result.tabId, normalizedTarget),
...(input.intent === "background"
? {}
: { focusRequest: { workspaceKey: normalizedWorkspaceKey, tabId: result.tabId } }),
// A tab brought to the front dismisses the sheet over it.
...(input.intent === "background" ? {} : dismissSheets(state, normalizedWorkspaceKey)),
hiddenAgentIdsByWorkspace:
@@ -1506,18 +1514,16 @@ export function createWorkspaceLayoutStore(
} else {
nextLayout = focusTabInLayout({ layout, tabId: normalizedTabId });
}
if (!nextLayout) {
return state;
}
if (!tabPane) return state;
return {
focusRequest: { workspaceKey: normalizedWorkspaceKey, tabId: normalizedTabId },
...withoutFocusRestoration(state, normalizedWorkspaceKey),
// Choosing a tab dismisses the sheets that cover it.
...dismissSheets(state, normalizedWorkspaceKey),
layoutByWorkspace: {
...state.layoutByWorkspace,
[normalizedWorkspaceKey]: nextLayout,
},
layoutByWorkspace: nextLayout
? { ...state.layoutByWorkspace, [normalizedWorkspaceKey]: nextLayout }
: state.layoutByWorkspace,
};
});
},
@@ -1535,13 +1541,15 @@ export function createWorkspaceLayoutStore(
paneId: normalizedPaneId,
tabId: normalizedTabId,
});
return nextLayout
const pane = findPaneById(layout.root, normalizedPaneId);
return pane?.tabIds.includes(normalizedTabId)
? {
focusRequest: { workspaceKey: normalizedWorkspaceKey, tabId: normalizedTabId },
// Choosing a tab dismisses the sheets that cover it.
...dismissSheets(state, normalizedWorkspaceKey),
layoutByWorkspace: {
...state.layoutByWorkspace,
[normalizedWorkspaceKey]: nextLayout,
[normalizedWorkspaceKey]: nextLayout ?? layout,
},
}
: state;
@@ -1971,6 +1979,13 @@ export function createWorkspaceLayoutStore(
};
});
},
focusWorkspace: (workspaceKey) => {
const state = get();
const paneId =
state.layoutByWorkspace[workspaceKey]?.focusedPaneId ??
state.focusRestorationByWorkspace[workspaceKey]?.restorePaneId;
if (paneId) state.focusPane(workspaceKey, paneId);
},
focusPane: (workspaceKey, paneId) => {
const normalizedWorkspaceKey = trimNonEmpty(workspaceKey);
const normalizedPaneId = trimNonEmpty(paneId);
@@ -1991,16 +2006,17 @@ export function createWorkspaceLayoutStore(
layout,
paneId: normalizedPaneId,
});
if (!nextLayout) {
return state;
}
const pane = findPaneById(layout.root, normalizedPaneId);
if (!pane) return state;
return {
focusRequest: pane.focusedTabId
? { workspaceKey: normalizedWorkspaceKey, tabId: pane.focusedTabId }
: null,
...withoutFocusRestoration(state, normalizedWorkspaceKey),
layoutByWorkspace: {
...state.layoutByWorkspace,
[normalizedWorkspaceKey]: nextLayout,
},
layoutByWorkspace: nextLayout
? { ...state.layoutByWorkspace, [normalizedWorkspaceKey]: nextLayout }
: state.layoutByWorkspace,
};
});
},
+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();
},
};
}