diff --git a/apps/mobile/src/screens/session-execution-panel.test.tsx b/apps/mobile/src/screens/session-execution-panel.test.tsx index f35360d..121c549 100644 --- a/apps/mobile/src/screens/session-execution-panel.test.tsx +++ b/apps/mobile/src/screens/session-execution-panel.test.tsx @@ -69,6 +69,69 @@ test("renders active execution and mutable queued inbox work", () => { expect(callbacks.onCancelInbox).toHaveBeenCalledWith("msg_queued"); }); +test("agent-switch reminders do not become actionable steering cards", () => { + render( + You are in Plan mode." }, + }, + ]} + />, + ); + expect(screen.queryByText("STEERING")).toBeNull(); + expect(screen.queryByRole("button", { name: "Queue next" })).toBeNull(); + expect(screen.queryByRole("button", { name: "Cancel" })).toBeNull(); +}); + +test("keeps a real steering prompt actionable alongside internal mode reminders", () => { + render( + , + ); + expect(screen.getAllByText("STEERING")).toHaveLength(1); + expect(screen.getByText("Please check the tests")).toBeOnTheScreen(); + fireEvent.press(screen.getByRole("button", { name: "Queue next" })); + expect(callbacks.onQueueInbox).toHaveBeenLastCalledWith("msg_followup"); + fireEvent.press(screen.getByRole("button", { name: "Cancel" })); + expect(callbacks.onCancelInbox).toHaveBeenLastCalledWith("msg_followup"); +}); + test("keeps unknown delivery visible until reconciliation finds the stable ID", () => { const admission: PromptAdmission = { durable: false, diff --git a/apps/mobile/src/screens/session-execution-panel.tsx b/apps/mobile/src/screens/session-execution-panel.tsx index 235e2d7..e7e0a1d 100644 --- a/apps/mobile/src/screens/session-execution-panel.tsx +++ b/apps/mobile/src/screens/session-execution-panel.tsx @@ -56,6 +56,9 @@ export function SessionExecutionPanel({ replyingPermissionId?: string | undefined; }) { const { height } = useWindowDimensions(); + // Agent-mode reminders and other control inputs are server-owned inbox work, + // not user prompts that should offer steering or cancellation controls. + const promptInbox = inbox.filter((item) => item.type === "user"); const inboxIds = new Set(inbox.map((item) => item.id)); const localOverlays = admissions.filter( (admission) => @@ -64,7 +67,7 @@ export function SessionExecutionPanel({ ); if ( !active && - inbox.length === 0 && + promptInbox.length === 0 && localOverlays.length === 0 && permissions.length === 0 && !formRequests @@ -166,7 +169,7 @@ export function SessionExecutionPanel({ ))} - {inbox.map((item) => ( + {promptInbox.map((item) => ( diff --git a/apps/mobile/src/screens/session-transcript.tsx b/apps/mobile/src/screens/session-transcript.tsx index b0e397d..73893c8 100644 --- a/apps/mobile/src/screens/session-transcript.tsx +++ b/apps/mobile/src/screens/session-transcript.tsx @@ -498,7 +498,16 @@ export const SessionTranscriptRow = memo(function SessionTranscriptRow({ ); case "agent-switched": - return ; + return ( + + + Agent changed to{" "} + + {sanitizeTranscriptText(capitalize(message.agent), 256)} + + + + ); case "model-switched": return ; case "location-switched": diff --git a/apps/mobile/src/state/connection-event-query-bridge.test.ts b/apps/mobile/src/state/connection-event-query-bridge.test.ts index 61ffd9f..160b3b2 100644 --- a/apps/mobile/src/state/connection-event-query-bridge.test.ts +++ b/apps/mobile/src/state/connection-event-query-bridge.test.ts @@ -340,50 +340,84 @@ test("invalidates only the affected session inbox for inbox events", () => { queryClient.clear(); }); -test("projects permission requests immediately while scheduling REST reconciliation", () => { - const queryClient = new QueryClient(); - const invalidate = jest.spyOn(queryClient, "invalidateQueries"); - const bridge = new ConnectionEventQueryBridge(queryClient, "connection-1", (callback) => - callback(), - ); - const location = { directory: "/workspace" }; - const key = openCodeQueryKeys.permissions("connection-1", location); - queryClient.setQueryData(key, { data: [], location }); - - bridge.apply({ - created: 1, - data: { - action: "shell", - id: "per_test", - resources: ["redacted command"], - sessionID: "session-1", - }, - id: "event-permission", - location, - type: "permission.asked", - }); +test.each([true, false])( + "projects permission requests while scheduling REST reconciliation, existing cache: %s", + (cached) => { + const queryClient = new QueryClient(); + const invalidate = jest.spyOn(queryClient, "invalidateQueries"); + const bridge = new ConnectionEventQueryBridge(queryClient, "connection-1", (callback) => + callback(), + ); + const location = { directory: "/workspace" }; + const key = openCodeQueryKeys.permissions("connection-1", location); + if (cached) queryClient.setQueryData(key, { data: [], location }); - expect(queryClient.getQueryData(key)).toEqual({ - data: [ - { + bridge.apply({ + created: 1, + data: { action: "shell", id: "per_test", resources: ["redacted command"], sessionID: "session-1", }, - ], - location, - }); - const query = queryClient.getQueryCache().find({ queryKey: key }); - expect(query && invalidate.mock.calls[0]?.[0]?.predicate?.(query)).toBe(true); + id: "event-permission", + location, + type: "permission.asked", + }); + + expect(queryClient.getQueryData(key)).toEqual({ + data: [ + { + action: "shell", + id: "per_test", + resources: ["redacted command"], + sessionID: "session-1", + }, + ], + location, + }); + const query = queryClient.getQueryCache().find({ queryKey: key }); + expect(query && invalidate.mock.calls[0]?.[0]?.predicate?.(query)).toBe(true); - bridge.apply({ - created: 2, - data: { reply: "once", requestID: "per_test", sessionID: "session-1" }, - id: "event-permission-replied", + bridge.apply({ + created: 2, + data: { reply: "once", requestID: "per_test", sessionID: "session-1" }, + id: "event-permission-replied", + location, + type: "permission.replied", + }); + expect(queryClient.getQueryData(key)).toEqual({ data: [], location }); + queryClient.clear(); + }, +); + +test("fresh form hints protect cold locations until authoritative reconciliation", () => { + const queryClient = new QueryClient(); + const location = { directory: "/workspace/retired" }; + const key = openCodeQueryKeys.forms("connection-1", location); + const bridge = new ConnectionEventQueryBridge(queryClient, "connection-1", (flush) => flush()); + const event = { + type: "form.created", + id: "event-form", + created: 1, location, - type: "permission.replied", - }); + data: { + form: { + id: "form_test", + sessionID: "ses_test", + title: "Input", + fields: [{ key: "answer", type: "string" }], + }, + }, + } satisfies OpenCodeEvent; + bridge.apply(event); + bridge.apply(event); + expect(queryClient.getQueryData(key)).toEqual({ data: [event.data.form], location }); + expect(queryClient.getQueryState(key)?.isInvalidated).toBe(true); + expect( + queryClient.getQueryData(openCodeQueryKeys.forms("connection-2", location)), + ).toBeUndefined(); + queryClient.setQueryData(key, { data: [], location }); expect(queryClient.getQueryData(key)).toEqual({ data: [], location }); queryClient.clear(); }); diff --git a/apps/mobile/src/state/connection-event-query-bridge.ts b/apps/mobile/src/state/connection-event-query-bridge.ts index 351032f..f286612 100644 --- a/apps/mobile/src/state/connection-event-query-bridge.ts +++ b/apps/mobile/src/state/connection-event-query-bridge.ts @@ -1,4 +1,5 @@ import type { + FormInfo, LocationRef, OpenCodeEvent, PermissionRequest, @@ -54,6 +55,17 @@ export class ConnectionEventQueryBridge { const next = reduceActiveSessions(current, event); if (next !== current) this.queryClient.setQueryData(activeKey, next); reducePermissionQueries(this.queryClient, this.connectionId, event); + if (event.type === "form.created" && event.location) { + const form = event.data.form; + const location = event.location; + const key = openCodeQueryKeys.forms(this.connectionId, location); + this.queryClient.setQueryData<{ data: FormInfo[]; location: unknown }>(key, (current) => ({ + location: current?.location ?? location, + data: current?.data.some((item) => item.id === form.id) + ? current.data + : [...(current?.data ?? []), form], + })); + } const root = eventInvalidationRoot(event); if (root) { @@ -217,6 +229,14 @@ function reducePermissionQueries( ) { return; } + if (event.type === "permission.asked") { + const key = openCodeQueryKeys.permissions(connectionId, event.location); + if (!queryClient.getQueryData(key)) { + // A fresh request protects an otherwise retired location until the exact + // location's authoritative REST snapshot replaces this volatile hint. + queryClient.setQueryData(key, { data: [event.data], location: event.location }); + } + } const queries = queryClient.getQueryCache().findAll({ predicate: (query) => matchesRoot(query, connectionId, "permissions", event.location), }); diff --git a/apps/mobile/src/state/connection-runtime-context.tsx b/apps/mobile/src/state/connection-runtime-context.tsx index 0381d6a..c4e9e6a 100644 --- a/apps/mobile/src/state/connection-runtime-context.tsx +++ b/apps/mobile/src/state/connection-runtime-context.tsx @@ -162,7 +162,14 @@ export function ConnectionRuntimeProvider({ children }: { children: ReactNode }) bridge.apply(event); const eventLocation = event.location; if (eventLocation) { - setEventLocations((current) => appendEventLocation(current, eventLocation)); + setEventLocations((current) => { + const next = appendEventLocation(current, eventLocation); + // Reconsider retired locations when actual attention arrives, + // even if a generic event already introduced the location. + return event.type === "permission.asked" || event.type === "form.created" + ? [...next] + : next; + }); } diagnosticsRef.current = appendDiagnostic(diagnosticsRef.current, { atMs: Date.now(), diff --git a/apps/mobile/src/state/followed-projects-context.tsx b/apps/mobile/src/state/followed-projects-context.tsx index 15dde31..a317d81 100644 --- a/apps/mobile/src/state/followed-projects-context.tsx +++ b/apps/mobile/src/state/followed-projects-context.tsx @@ -345,19 +345,83 @@ export function FollowedProjectsProvider({ children }: { children: ReactNode }) }); const eventLocations = uniqueLocations(runtime.eventLocations); + const activeLocationsResolved = + activeSessionsQuery.isSuccess && + (activeSessionIDs.length === 0 || + (activeAncestryQuery.isSuccess && activeAncestryQuery.data.failures.length === 0)); + const activeLocationsPending = + activeSessionsQuery.isPending || (activeSessionIDs.length > 0 && activeAncestryQuery.isPending); + const protectedLocationKeys = new Set( + [ + ...availableFollowedProjectIDs.flatMap((projectID) => { + const project = projectByID.get(projectID); + return project ? [{ directory: project.canonical }] : []; + }), + ...runtime.attentionLocations, + ...Object.values(activeAncestryQuery.data?.sessions ?? {}).map((session) => session.location), + ...(location ? [location] : []), + ].map(locationKey), + ); + function historicalCandidate(candidate: LocationRef) { + if (protectedLocationKeys.has(locationKey(candidate)) || candidate.workspaceID) + return undefined; + const session = rootSessions.find( + (item) => locationKey(item.location) === locationKey(candidate), + ); + const projectID = session?.projectID ?? inferLocationProjectID(candidate, projects); + const project = projectID ? projectByID.get(projectID) : undefined; + if (!project || !candidate.directory.startsWith(`${project.canonical.replace(/\/$/, "")}/`)) + return undefined; + // Actual attention takes precedence over retirement; generic event locations + // are discovery hints, not evidence of outstanding work. + const permissionSnapshot = queryClient.getQueryData<{ data: PermissionRequest[] }>( + openCodeQueryKeys.permissions(connectionID ?? unresolvedConnectionID, candidate), + ); + const formSnapshot = queryClient.getQueryData<{ data: FormInfo[] }>( + openCodeQueryKeys.forms(connectionID ?? unresolvedConnectionID, candidate), + ); + if (permissionSnapshot?.data.length || formSnapshot?.data.length) return undefined; + return { location: candidate, root: { directory: project.canonical } }; + } const eventLocationsQuery = useQuery({ - enabled: Boolean(client && connectionID && eventLocations.length), + enabled: Boolean( + client && + connectionID && + eventLocations.length && + preferenceReady && + !projectsQuery.isPending && + !activeSessionsQuery.isPending && + (activeSessionIDs.length === 0 || !activeAncestryQuery.isPending), + ), queryFn: async ({ signal }) => { if (!client) throw new Error("CONNECTION_NOT_READY"); const settled = await Promise.allSettled( - eventLocations.map((eventLocation) => - getOpenCodeLocation(client, eventLocation, { signal }), - ), + eventLocations.map(async (eventLocation) => { + const candidate = historicalCandidate(eventLocation); + // Resolving a location initializes it on the server. Check through the + // live project root before touching a retired historical directory. + if (candidate && activeLocationsResolved) { + let exists: boolean | undefined; + try { + exists = await openCodeDirectoryExists( + client, + candidate.root, + eventLocation.directory, + { signal }, + ); + } catch { + // An unavailable probe is not proof that the directory is gone. + if (signal.aborted) throw new Error("LOCATION_CHECK_ABORTED"); + } + if (exists === false) return undefined; + } + return getOpenCodeLocation(client, eventLocation, { signal }); + }), ); return { failures: settled.filter((result) => result.status === "rejected").length, locations: settled.flatMap((result) => - result.status === "fulfilled" ? [result.value] : [], + result.status === "fulfilled" && result.value ? [result.value] : [], ), }; }, @@ -412,42 +476,9 @@ export function FollowedProjectsProvider({ children }: { children: ReactNode }) : []; const candidateLocations = uniqueLocations([...previousLocations, ...discoveredLocations]); discoveredLocationsRef.current = { scopeKey: followedScopeKey, locations: candidateLocations }; - const protectedLocationKeys = new Set( - [ - ...availableFollowedProjectIDs.flatMap((projectID) => { - const project = projectByID.get(projectID); - return project ? [{ directory: project.canonical }] : []; - }), - ...runtime.attentionLocations, - ...runtime.eventLocations, - ...Object.values(activeAncestryQuery.data?.sessions ?? {}).map((session) => session.location), - ...(location ? [location] : []), - ].map(locationKey), - ); const historicalCandidates = candidateLocations.flatMap((candidate) => { - if (protectedLocationKeys.has(locationKey(candidate)) || candidate.workspaceID) return []; - const session = rootSessions.find( - (item) => locationKey(item.location) === locationKey(candidate), - ); - const project = session - ? projectByID.get(session.projectID) - : projects.find( - (project) => - availableFollowedProjectIDs.includes(project.id) && - project.sandboxes.includes(candidate.directory), - ); - if (!project || !candidate.directory.startsWith(`${project.canonical.replace(/\/$/, "")}/`)) - return []; - // Previously observed blocked work takes precedence over retirement. Active - // session lists alone do not cover permission- or form-blocked sessions. - const permissionSnapshot = queryClient.getQueryData<{ data: PermissionRequest[] }>( - openCodeQueryKeys.permissions(connectionID ?? unresolvedConnectionID, candidate), - ); - const formSnapshot = queryClient.getQueryData<{ data: FormInfo[] }>( - openCodeQueryKeys.forms(connectionID ?? unresolvedConnectionID, candidate), - ); - if (permissionSnapshot?.data.length || formSnapshot?.data.length) return []; - return [{ location: candidate, root: { directory: project.canonical } }]; + const historical = historicalCandidate(candidate); + return historical ? [historical] : []; }); const directoryQueries = useQueries({ queries: historicalCandidates.map((candidate) => ({ @@ -472,22 +503,23 @@ export function FollowedProjectsProvider({ children }: { children: ReactNode }) directoryQueries[index], ]), ); - const activeLocationsResolved = - activeSessionsQuery.isSuccess && - (activeSessionIDs.length === 0 || - (activeAncestryQuery.isSuccess && activeAncestryQuery.data.failures.length === 0)); const knownLocations = candidateLocations.filter((candidate) => { const query = directoryQueryByLocation.get(locationKey(candidate)); return !activeLocationsResolved || !query?.isSuccess || query.data !== false; }); + const canCheckInteractions = (candidate: LocationRef) => { + const probe = directoryQueryByLocation.get(locationKey(candidate)); + return ( + !probe || + (!probe.isPending && + !activeLocationsPending && + !(activeLocationsResolved && probe.data === false)) + ); + }; const interactionQueries = useQueries({ queries: knownLocations.flatMap((knownLocation) => [ { - enabled: Boolean( - client && - connectionID && - !directoryQueryByLocation.get(locationKey(knownLocation))?.isPending, - ), + enabled: Boolean(client && connectionID && canCheckInteractions(knownLocation)), queryFn: ({ signal }: { signal: AbortSignal }) => { if (!client) throw new Error("CONNECTION_NOT_READY"); return listOpenCodePermissionRequests(client, knownLocation, { signal }); @@ -498,11 +530,7 @@ export function FollowedProjectsProvider({ children }: { children: ReactNode }) ), }, { - enabled: Boolean( - client && - connectionID && - !directoryQueryByLocation.get(locationKey(knownLocation))?.isPending, - ), + enabled: Boolean(client && connectionID && canCheckInteractions(knownLocation)), queryFn: ({ signal }: { signal: AbortSignal }) => { if (!client) throw new Error("CONNECTION_NOT_READY"); return listOpenCodeFormRequests(client, knownLocation, { signal }); @@ -877,7 +905,15 @@ export function FollowedProjectsProvider({ children }: { children: ReactNode }) ...(availableFollowedProjectIDs.length && normalizedSearch ? [searchSessionsQuery.refetch()] : []), - ...allInteractionQueries.map((query) => query.refetch()), + // refetch() bypasses enabled. Do not initialize a historical + // location while its renewed directory probe is still pending. + ...knownLocations.flatMap((knownLocation, index) => { + if (!canCheckInteractions(knownLocation)) return []; + return interactionQueries + .slice(index * 2, index * 2 + 2) + .map((query) => query.refetch()); + }), + ...supplementalInteractionQueries.map((query) => query.refetch()), ]), replyPermission, ...(permissionReplyMutation.isPending && permissionReplyMutation.variables diff --git a/apps/mobile/src/state/workspace-selection-context.test.tsx b/apps/mobile/src/state/workspace-selection-context.test.tsx index 6e3f633..a78c143 100644 --- a/apps/mobile/src/state/workspace-selection-context.test.tsx +++ b/apps/mobile/src/state/workspace-selection-context.test.tsx @@ -8,7 +8,7 @@ import type { import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { fireEvent, render, screen, waitFor } from "@testing-library/react-native"; import { Pressable, Text } from "react-native"; - +import { ConnectionEventQueryBridge } from "./connection-event-query-bridge"; import { FollowedProjectsProvider, useFollowedProjects } from "./followed-projects-context"; import { openCodeQueryKeys } from "./open-code-query-keys"; @@ -628,16 +628,166 @@ test.each([false, true])( }, ); -test.each(["notification", "event", "cached-form"])( +test("does not resurrect a retired worktree from generic event hints when returning to the project", async () => { + const directory = "/b/.worktree/retired"; + mockListProjectSessions.mockImplementation(async (_client, projectID) => ({ + cursor: {}, + data: [ + projectID === "project-b" + ? mockSession("ses_retired", projectID, directory, 2) + : mockSession("ses_alpha", projectID, "/a", 3), + ], + })); + mockDirectoryExists.mockImplementation( + async (_client, _root, candidate) => candidate !== directory, + ); + const permissions = mockListPermissions.getMockImplementation(); + mockListPermissions.mockImplementation(async (client, location, options) => { + if (location.directory === directory) throw new Error("Location no longer exists"); + if (!permissions) throw new Error("fixture"); + return permissions(client, location, options); + }); + const queryClient = new QueryClient({ + defaultOptions: { queries: { retry: false, gcTime: Infinity } }, + }); + const tree = () => ( + + + + + + ); + let view = render(tree()); + try { + await waitFor(() => expect(screen.getByText("complete:1:1:0")).toBeOnTheScreen()); + mockEventLocations = [{ directory }]; + mockRevision += 1; + view.rerender(tree()); + fireEvent.press(screen.getByRole("button", { name: "Refresh sessions" })); + await waitFor(() => expect(screen.getByText("freshness:current")).toBeOnTheScreen()); + expect(screen.queryByText(/^unavailable:/)).toBeNull(); + expect(mockListPermissions.mock.calls.some((call) => call[1].directory === directory)).toBe( + false, + ); + expect(mockGetOpenCodeLocation.mock.calls.some((call) => call[1].directory === directory)).toBe( + false, + ); + view.unmount(); + view = render(tree()); + await waitFor(() => expect(screen.getByText("complete:1:1:0")).toBeOnTheScreen()); + expect(screen.queryByText(/^unavailable:/)).toBeNull(); + expect(mockListPermissions.mock.calls.some((call) => call[1].directory === directory)).toBe( + false, + ); + expect(mockGetOpenCodeLocation.mock.calls.some((call) => call[1].directory === directory)).toBe( + false, + ); + } finally { + view.unmount(); + queryClient.clear(); + } +}); + +test("fresh attention can reopen a retired location and an empty REST snapshot retires it again", async () => { + const directory = "/b/.worktree/retired"; + mockEventLocations = [{ directory }]; + mockDirectoryExists.mockImplementation( + async (_client, _root, candidate) => candidate !== directory, + ); + mockListProjectSessions.mockImplementation(async (_client, projectID) => ({ + cursor: {}, + data: [ + projectID === "project-b" + ? mockSession("ses_retired", projectID, directory, 2) + : mockSession("ses_alpha", projectID, "/a", 3), + ], + })); + const queryClient = new QueryClient({ + defaultOptions: { queries: { retry: false, gcTime: Infinity } }, + }); + const tree = () => ( + + + + + + ); + const view = render(tree()); + try { + await waitFor(() => expect(screen.getByText("complete:1:1:0")).toBeOnTheScreen()); + expect(mockListForms.mock.calls.some((call) => call[1].directory === directory)).toBe(false); + const bridge = new ConnectionEventQueryBridge(queryClient, "connection-1", (flush) => flush()); + bridge.apply({ + type: "form.created", + id: "evt_new", + created: 2, + location: { directory }, + data: { + form: { + id: "form_new", + sessionID: "ses_retired", + title: "Input", + fields: [{ key: "answer", type: "string" }], + }, + }, + }); + view.rerender(tree()); + await waitFor(() => + expect(mockListForms.mock.calls.some((call) => call[1].directory === directory)).toBe(true), + ); + await waitFor(() => expect(screen.getByText("complete:1:1:0")).toBeOnTheScreen()); + const calls = mockListForms.mock.calls.filter((call) => call[1].directory === directory).length; + view.rerender(tree()); + fireEvent.press(screen.getByRole("button", { name: "Refresh sessions" })); + await waitFor(() => expect(screen.getByText("freshness:current")).toBeOnTheScreen()); + expect(mockListForms.mock.calls.filter((call) => call[1].directory === directory)).toHaveLength( + calls, + ); + } finally { + view.unmount(); + queryClient.clear(); + } +}); + +test.each(["notification", "permission-event", "form-event", "cached-form"])( "keeps %s attention at a missing historical directory", async (source) => { const location = { directory: "/b/sub" }; mockDirectoryExists.mockResolvedValue(false); if (source === "notification") mockAttentionLocations = [location]; - if (source === "event") mockEventLocations = [location]; const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false, gcTime: Infinity } }, }); + if (source === "permission-event" || source === "form-event") { + mockEventLocations = [location]; + const bridge = new ConnectionEventQueryBridge(queryClient, "connection-1", (flush) => + flush(), + ); + bridge.apply( + source === "permission-event" + ? { + type: "permission.asked", + id: "evt_attention", + created: 1, + location, + data: { id: "per_beta", sessionID: "ses_beta", action: "shell", resources: [] }, + } + : { + type: "form.created", + id: "evt_attention", + created: 1, + location, + data: { + form: { + fields: [{ key: "answer", type: "string" }], + id: "form_beta", + sessionID: "ses_beta", + title: "Input", + }, + }, + }, + ); + } if (source === "cached-form") { queryClient.setQueryData(openCodeQueryKeys.forms("connection-1", location), { data: [{ fields: [], id: "form_beta", sessionID: "ses_beta", title: "Input" }],