diff --git a/docs/agent-lifecycle.md b/docs/agent-lifecycle.md index c0aad974f6..26c9df8823 100644 --- a/docs/agent-lifecycle.md +++ b/docs/agent-lifecycle.md @@ -334,10 +334,13 @@ Running provider-native subagents contribute `running` to the workspace owned by A finished or failed workspace can be marked unread after it has been reviewed. The daemon restores attention on its newest eligible workspace-root agent, `error` for an agent whose lifecycle is -`error` and `finished` otherwise, without sending a new notification. Opening the workspace clears that attention through the normal focus flow. +`error` and `finished` otherwise, without sending a new notification. Opening a workspace restores +its selected tab; attention on another agent remains unread until that agent is focused or the +workspace is explicitly marked read. -Attention is set by the agent finishing or failing and cleared by the client's -`workspace.clear_attention`, which fires when the user reads the chat. Loading an agent's runtime is +Attention is set by the agent finishing or failing and acknowledged by deliberate focus or +interaction with its chat, including repeat clicks in an already focused pane. A completion +arriving while focused stays unread until the next interaction; leaving does not acknowledge it. Loading an agent's runtime is neither, so resuming carries the stored attention and the stored last-activity time through untouched. Forging either makes a background resume look like the user read a workspace and like the agent worked in it just now, which rewrites the sidebar timestamp permanently — persisted diff --git a/docs/expo-router.md b/docs/expo-router.md index 9a9e5e3634..f81c194ffb 100644 --- a/docs/expo-router.md +++ b/docs/expo-router.md @@ -68,7 +68,7 @@ When app-wide routes such as `/new`, `/settings`, or `/sessions` navigate back into a host workspace, use `navigateToWorkspace()`. Do not make the caller branch on its current route. -Pass only `serverId` and `workspaceId` for normal attention-aware navigation. +Pass only `serverId` and `workspaceId` to restore the workspace’s selected tab. When the action names a specific tab, pass it as `target`; that explicit choice is authoritative. Callers should not choose between separate route and tab navigation APIs. diff --git a/packages/app/e2e/browser/agent-timeline-sync-retry.spec.ts b/packages/app/e2e/browser/agent-timeline-sync-retry.spec.ts index 2f3fcab53a..f68efffd02 100644 --- a/packages/app/e2e/browser/agent-timeline-sync-retry.spec.ts +++ b/packages/app/e2e/browser/agent-timeline-sync-retry.spec.ts @@ -18,8 +18,10 @@ test.describe("Agent timeline sync retry", () => { const gate = await installDaemonWebSocketGate(page); const agent = await seedLongMockAgentTimeline({ turns: 2 }); try { + gate.holdNextClientRequest("clear_agent_attention"); await openAgentTimeline(page, agent); await expectTimelinePromptVisible(page, agent.newestPrompt); + await gate.waitForHeldClientRequest(); gate.failTimelineResponses(agent.agentId); await disconnectViewedTimeline(page, gate); diff --git a/packages/app/e2e/browser/sidebar-workspace-mark-unread.spec.ts b/packages/app/e2e/browser/sidebar-workspace-mark-unread.spec.ts index ccf29179b8..a2a695af5f 100644 --- a/packages/app/e2e/browser/sidebar-workspace-mark-unread.spec.ts +++ b/packages/app/e2e/browser/sidebar-workspace-mark-unread.spec.ts @@ -1,12 +1,13 @@ import type { Page } from "@playwright/test"; import { test as base, expect } from "../support/fixtures"; -import { gotoAppShell } from "../support/helpers/app"; import { openAgentRoute, seedMockAgentWorkspace, type MockAgentWorkspace, } from "../support/helpers/mock-agent"; import { expectOpenTabCount } from "../support/helpers/sheet"; +import { installDaemonWebSocketGate } from "../support/helpers/daemon-websocket-gate"; +import { expectReconnectingToastGone } from "../support/helpers/workspace-ui"; import { getServerId } from "../support/helpers/server-id"; import { closeMobileAgentSidebar, openMobileAgentSidebar } from "../support/helpers/sidebar"; @@ -101,11 +102,13 @@ async function leaveMarkedWorkspaceAndReopen(page: Page, { subject, other }: Fin } async function completeTurnAndLeave(page: Page, { subject, other }: FinishedWorkspaces) { - await test.step("ordinary completion still clears on departure", async () => { + await test.step("ordinary completion stays unread on departure and clears on return", async () => { await subject.client.sendAgentMessage(subject.agentId, "Finish another turn."); await subject.client.waitForFinish(subject.agentId, 20_000); await expectStatus(page, subject.workspaceId, "attention"); await openWorkspace(page, other.workspaceId); + await expectStatus(page, subject.workspaceId, "attention"); + await openWorkspace(page, subject.workspaceId); await expectStatus(page, subject.workspaceId, "done"); }); } @@ -141,13 +144,15 @@ async function leaveMarkedWorkspaceAndReopenOnCompact( } async function completeTurnAndLeaveOnCompact(page: Page, { subject, other }: FinishedWorkspaces) { - await test.step("ordinary completion still clears on departure", async () => { + await test.step("ordinary completion stays unread on departure and clears on return", async () => { await closeMobileAgentSidebar(page); await subject.client.sendAgentMessage(subject.agentId, "Finish another turn."); await subject.client.waitForFinish(subject.agentId, 20_000); await openMobileAgentSidebar(page); await expectStatus(page, subject.workspaceId, "attention"); await openWorkspaceOnCompact(page, other.workspaceId); + await expectStatus(page, subject.workspaceId, "attention"); + await openWorkspaceOnCompact(page, subject.workspaceId); await expectStatus(page, subject.workspaceId, "done"); }); } @@ -194,11 +199,12 @@ async function markUnreadThenResumeChat(page: Page, workspaceId: string) { }); } -test("manual unread survives departure and clears on reopening without changing normal completions", async ({ +test("manual unread survives departure and clears on reopening and ordinary completion survives departure", async ({ page, workspaces, }) => { - await gotoAppShell(page); + await openAgentRoute(page, workspaces.subject); + await expect(page.getByText("Finish this turn.", { exact: true })).toBeVisible(); await openWorkspace(page, workspaces.other.workspaceId); await markBackgroundWorkspaceAndReopen(page, workspaces.subject.workspaceId); await leaveMarkedWorkspaceAndRead(page, workspaces); @@ -207,16 +213,19 @@ test("manual unread survives departure and clears on reopening without changing await markBackgroundWorkspaceAndRead(page, workspaces.subject.workspaceId); }); -test("clicking a multi-agent workspace reveals and clears its marked agent", async ({ +test("clicking a multi-agent workspace preserves its selected agent and leaves the other unread", async ({ page, workspaces, }) => { await openAgentRoute(page, workspaces.subject); + await expect(page.getByText("Finish this turn.", { exact: true })).toBeVisible(); await expectSelectedAgent(page, workspaces.subject.agentId); await openWorkspace(page, workspaces.other.workspaceId); - const newest = await addFinishedAgent(workspaces.subject); - await markBackgroundWorkspaceAndReopen(page, workspaces.subject.workspaceId); - await expectSelectedAgent(page, newest.id); + await addFinishedAgent(workspaces.subject); + await markAsUnread(page, workspaces.subject.workspaceId); + await openWorkspace(page, workspaces.subject.workspaceId); + await expectSelectedAgent(page, workspaces.subject.agentId); + await expectStatus(page, workspaces.subject.workspaceId, "attention"); }); test("manual unread survives leaving the current workspace on compact layout", async ({ @@ -230,7 +239,7 @@ test("manual unread survives leaving the current workspace on compact layout", a await completeTurnAndLeaveOnCompact(page, workspaces); }); -async function reopenMarkedWorkspaceAfterBackgroundTurn(page: Page, workspace: MockAgentWorkspace) { +async function reopenWorkspaceAfterBackgroundTurn(page: Page, workspace: MockAgentWorkspace) { const route = page.url(); // End this app session before the turn, so its cached transcript cannot contain the new prompt. await page.goto("about:blank"); @@ -265,9 +274,72 @@ test("reopening a marked unread workspace restores only its agent tab and curren await test.step("mark the current workspace unread, then close and reopen the app", async () => { await openMobileAgentSidebar(page); await markAsUnread(page, workspaces.subject.workspaceId); - await reopenMarkedWorkspaceAfterBackgroundTurn(page, workspaces.subject); + await reopenWorkspaceAfterBackgroundTurn(page, workspaces.subject); }); await test.step("restore the main tab without Explorer entries and load its latest history", async () => { await expectRestoredCompactChat(page); }); }); + +test("clicking the already focused timeline acknowledges a new completion", async ({ + page, + workspaces, +}) => { + await openAgentRoute(page, workspaces.subject); + await expect(page.getByText("Finish this turn.", { exact: true })).toBeVisible(); + await workspaces.subject.client.sendAgentMessage( + workspaces.subject.agentId, + "Finish another turn.", + ); + await workspaces.subject.client.waitForFinish(workspaces.subject.agentId, 20_000); + await expectStatus(page, workspaces.subject.workspaceId, "attention"); + await page.getByText("Finish another turn.", { exact: true }).click(); + await expectStatus(page, workspaces.subject.workspaceId, "done"); +}); + +async function reconnectWithoutInteracting( + page: Page, + gate: Awaited>, + agentId: string, +) { + await gate.drop(); + await gate.waitForBlockedConnection(); + gate.holdTimelineResponses(agentId); + gate.restore(); + await gate.waitForHeldTimelineResponse(); + await expectReconnectingToastGone(page); + gate.releaseHeldTimelineResponses(); + await expect(page.getByRole("alert").filter({ hasText: "Updating messages" })).toHaveCount(0); +} + +test("reconnecting a focused chat preserves its unread completion until another click", async ({ + page, + workspaces, +}) => { + const gate = await installDaemonWebSocketGate(page); + await openAgentRoute(page, workspaces.subject); + await expect(page.getByText("Finish this turn.", { exact: true })).toBeVisible(); + await workspaces.subject.client.sendAgentMessage( + workspaces.subject.agentId, + "Unread across reconnect.", + ); + await workspaces.subject.client.waitForFinish(workspaces.subject.agentId, 20_000); + await expectStatus(page, workspaces.subject.workspaceId, "attention"); + await reconnectWithoutInteracting(page, gate, workspaces.subject.agentId); + await expectStatus(page, workspaces.subject.workspaceId, "attention"); + await page.getByText("Unread across reconnect.", { exact: true }).click(); + await expectStatus(page, workspaces.subject.workspaceId, "done"); +}); + +test("a cached read chat acknowledges a completion that arrived while the app was closed", async ({ + page, + workspaces, +}) => { + await openCompactWorkspace(page, workspaces.subject); + await expectStatus(page, workspaces.subject.workspaceId, "done"); + await closeMobileAgentSidebar(page); + await reopenWorkspaceAfterBackgroundTurn(page, workspaces.subject); + await expectRestoredCompactChat(page); + await openMobileAgentSidebar(page); + await expectStatus(page, workspaces.subject.workspaceId, "done"); +}); diff --git a/packages/app/src/hooks/use-agent-attention-clear.ts b/packages/app/src/hooks/use-agent-attention-clear.ts index e1501e0dc9..16a4d77ddb 100644 --- a/packages/app/src/hooks/use-agent-attention-clear.ts +++ b/packages/app/src/hooks/use-agent-attention-clear.ts @@ -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(() => 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, }; } diff --git a/packages/app/src/panels/agent-panel.tsx b/packages/app/src/panels/agent-panel.tsx index 1373a5d283..c610ec2410 100644 --- a/packages/app/src/panels/agent-panel.tsx +++ b/packages/app/src/panels/agent-panel.tsx @@ -677,8 +677,6 @@ function ChatAgentContent({ const { api: toastApi, toast: toastState, dismiss: dismissToast } = useToastHost(); const { isArchivingAgent } = useArchiveAgent(); const streamViewRef = useRef(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 = ( - + {showsComposer ? null : } { - if (!isPaneFocused && paneId) onFocusPane(paneId); + if (paneId) onFocusPane(paneId); return false; - }, [isPaneFocused, onFocusPane, paneId]); + }, [onFocusPane, paneId]); return ( diff --git a/packages/app/src/stores/navigation-active-workspace-store/index.ts b/packages/app/src/stores/navigation-active-workspace-store/index.ts index def8c73cc4..2b6fbff11f 100644 --- a/packages/app/src/stores/navigation-active-workspace-store/index.ts +++ b/packages/app/src/stores/navigation-active-workspace-store/index.ts @@ -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), diff --git a/packages/app/src/stores/navigation-active-workspace-store/navigation.test.ts b/packages/app/src/stores/navigation-active-workspace-store/navigation.test.ts index a3bb69821a..c3cf58a3bd 100644 --- a/packages/app/src/stores/navigation-active-workspace-store/navigation.test.ts +++ b/packages/app/src/stores/navigation-active-workspace-store/navigation.test.ts @@ -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 = {}) { 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( diff --git a/packages/app/src/stores/navigation-active-workspace-store/navigation.ts b/packages/app/src/stores/navigation-active-workspace-store/navigation.ts index d856a9bb36..6d94ed81a3 100644 --- a/packages/app/src/stores/navigation-active-workspace-store/navigation.ts +++ b/packages/app/src/stores/navigation-active-workspace-store/navigation.ts @@ -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 | null | undefined; - getSessionAgents: (serverId: string) => Iterable; 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); diff --git a/packages/app/src/stores/workspace-layout-store.test.ts b/packages/app/src/stores/workspace-layout-store.test.ts index 8b478b7d27..2c3e6f15a4 100644 --- a/packages/app/src/stores/workspace-layout-store.test.ts +++ b/packages/app/src/stores/workspace-layout-store.test.ts @@ -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 }); +}); diff --git a/packages/app/src/stores/workspace-layout-store.ts b/packages/app/src/stores/workspace-layout-store.ts index 43387bc743..f66650c7ca 100644 --- a/packages/app/src/stores/workspace-layout-store.ts +++ b/packages/app/src/stores/workspace-layout-store.ts @@ -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>; /** 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, }; }); }, diff --git a/packages/app/src/utils/agent-attention.test.ts b/packages/app/src/utils/agent-attention.test.ts index ac5a6f84f3..dcc8d6e197 100644 --- a/packages/app/src/utils/agent-attention.test.ts +++ b/packages/app/src/utils/agent-attention.test.ts @@ -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 & 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, - 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); }); }); diff --git a/packages/app/src/utils/agent-attention.ts b/packages/app/src/utils/agent-attention.ts index a3e5df6279..c90e3d94a6 100644 --- a/packages/app/src/utils/agent-attention.ts +++ b/packages/app/src/utils/agent-attention.ts @@ -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; + 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(); + }, + }; }