From 471db4bdb5c8bd2febf97bb7c9818595f3048e6c Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Fri, 6 Nov 2026 20:55:16 +0700 Subject: [PATCH] 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 --- docs/agent-lifecycle.md | 9 +- docs/expo-router.md | 2 +- .../browser/agent-timeline-sync-retry.spec.ts | 2 + .../sidebar-workspace-mark-unread.spec.ts | 94 ++++- .../src/hooks/use-agent-attention-clear.ts | 124 +++---- packages/app/src/panels/agent-panel.tsx | 45 +-- .../screens/workspace/workspace-screen.tsx | 4 +- .../index.ts | 5 +- .../navigation.test.ts | 31 +- .../navigation.ts | 27 +- .../src/stores/workspace-layout-store.test.ts | 33 ++ .../app/src/stores/workspace-layout-store.ts | 48 ++- .../app/src/utils/agent-attention.test.ts | 350 +++++------------- packages/app/src/utils/agent-attention.ts | 118 +++--- 14 files changed, 383 insertions(+), 509 deletions(-) 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(); + }, + }; }