diff --git a/apps/mobile/src/features/threads/ThreadAgentsSheet.tsx b/apps/mobile/src/features/threads/ThreadAgentsSheet.tsx index e78239955e4af..f21cde9deff4c 100644 --- a/apps/mobile/src/features/threads/ThreadAgentsSheet.tsx +++ b/apps/mobile/src/features/threads/ThreadAgentsSheet.tsx @@ -141,9 +141,7 @@ function AgentRow(props: { }) { const { subagent } = props; const { presentationById } = useEnvironments(); - const target = subagentThreadTarget(subagent, props.environmentId, (id) => - presentationById.has(id), - ); + const target = subagentThreadTarget(subagent, props.environmentId, presentationById); const row = ( @@ -164,7 +162,9 @@ function AgentRow(props: { accessibilityHint={ subagent.remoteChild === undefined ? "Provider-managed agent. Its work appears in the transcript." - : `Runs on ${subagent.remoteChild.label}. Connect to it to open its thread.` + : presentationById.has(subagent.remoteChild.environmentId) + ? `Runs on ${subagent.remoteChild.label}. Turn it on in Settings to open its thread.` + : `Runs on ${subagent.remoteChild.label}. Connect to it to open its thread.` } > {row} diff --git a/apps/mobile/src/features/threads/thread-subagent-group.tsx b/apps/mobile/src/features/threads/thread-subagent-group.tsx index 734ebf4583e03..73f78051efab1 100644 --- a/apps/mobile/src/features/threads/thread-subagent-group.tsx +++ b/apps/mobile/src/features/threads/thread-subagent-group.tsx @@ -189,10 +189,8 @@ export function ThreadSubagentGroup(props: { {!grouped || expanded ? ( {agents.map((agent) => { - // A task in a linked environment opens there, if this app is connected to it. - const target = subagentThreadTarget(agent, props.environmentId, (id) => - presentationById.has(id), - ); + // A task in a linked environment opens there, if it is enabled in this app. + const target = subagentThreadTarget(agent, props.environmentId, presentationById); return ( { ).toBeNull(); }); }); + +describe("subagentThreadTarget", () => { + const here = EnvironmentId.make("env-here"); + const laptop = EnvironmentId.make("env-laptop"); + const remote = { + childThreadId: null, + remoteChild: { + environmentId: laptop, + threadId: ThreadId.make("remote-thread"), + label: "Laptop", + }, + }; + + it("opens a remote task only in an enabled saved environment", () => { + expect( + subagentThreadTarget(remote, here, new Map([[laptop, { entry: { enabled: true } }]])), + ).toEqual({ environmentId: laptop, threadId: "remote-thread" }); + expect( + subagentThreadTarget(remote, here, new Map([[laptop, { entry: { enabled: false } }]])), + ).toBeNull(); + expect(subagentThreadTarget(remote, here, new Map())).toBeNull(); + }); +}); diff --git a/apps/mobile/src/features/threads/threadAgentsPresentation.ts b/apps/mobile/src/features/threads/threadAgentsPresentation.ts index 64c9b232ed424..5f62fa2985e6a 100644 --- a/apps/mobile/src/features/threads/threadAgentsPresentation.ts +++ b/apps/mobile/src/features/threads/threadAgentsPresentation.ts @@ -35,19 +35,19 @@ export interface SubagentRowPresentation { /** * Where an agent's thread opens: here, or in the linked environment that runs - * it. Null when there is no thread, or this app is not connected to that - * environment. + * it. Null when there is no thread, or that environment is not saved and + * enabled in this app. */ export function subagentThreadTarget( subagent: Pick, environmentId: EnvironmentId, - isKnownEnvironment: (environmentId: EnvironmentId) => boolean, + environments: ReadonlyMap, ): { readonly environmentId: EnvironmentId; readonly threadId: ThreadId } | null { if (subagent.childThreadId !== null) { return { environmentId, threadId: subagent.childThreadId }; } const remote = subagent.remoteChild; - return remote !== undefined && isKnownEnvironment(remote.environmentId) + return remote !== undefined && environments.get(remote.environmentId)?.entry.enabled === true ? { environmentId: remote.environmentId, threadId: remote.threadId } : null; } diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 9c015a74b9b9b..0155cd1ad2382 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -938,8 +938,8 @@ export default function ChatView(props: ChatViewProps) { return { title: delegatedFromThread?.title ?? delegatedFrom.title, environmentLabel: delegatedFromEnvironment?.label ?? activeThread?.linkOrigin?.label ?? "", - // Only an environment this client is connected to can open the parent. - ...(delegatedFromEnvironment === null + // Only an environment enabled in this client can open the parent. + ...(delegatedFromEnvironment?.entry.enabled !== true ? {} : { open: (): void => { diff --git a/apps/web/src/components/chat/ThreadRelationshipsControl.remote-parent.browser.test.tsx b/apps/web/src/components/chat/ThreadRelationshipsControl.remote-parent.browser.test.tsx index 08108cd835885..d51bd6712bbda 100644 --- a/apps/web/src/components/chat/ThreadRelationshipsControl.remote-parent.browser.test.tsx +++ b/apps/web/src/components/chat/ThreadRelationshipsControl.remote-parent.browser.test.tsx @@ -41,7 +41,7 @@ vi.mock(import("../../state/entities"), async (original) => ({ })); vi.mock(import("../../state/environments"), async (original) => ({ ...(await original()), - useEnvironment: (() => ({ label: "Linked" })) as never, + useEnvironment: (() => ({ label: "Linked", entry: { enabled: true } })) as never, })); vi.mock(import("../ui/toast"), async (original) => { const actual = await original(); diff --git a/apps/web/src/components/chat/ThreadRelationshipsControl.tsx b/apps/web/src/components/chat/ThreadRelationshipsControl.tsx index 23e0da34bf7f6..99769953ffbe1 100644 --- a/apps/web/src/components/chat/ThreadRelationshipsControl.tsx +++ b/apps/web/src/components/chat/ThreadRelationshipsControl.tsx @@ -268,6 +268,8 @@ function RemoteSubagentRow(props: { }) { const navigate = useNavigate(); const environment = useEnvironment(props.agent.remoteChild.environmentId); + // A saved environment that is switched off can't open its threads. + const canOpen = environment?.entry.enabled === true; const { remoteChild } = props.agent; const status = props.agent.status; const failed = status === "failed"; @@ -285,9 +287,9 @@ function RemoteSubagentRow(props: { size="sm" variant="ghost" part="row" - aria-disabled={environment === null || undefined} + aria-disabled={!canOpen || undefined} onClick={() => { - if (environment === null) return; + if (!canOpen) return; void navigate({ to: "/$environmentId/$threadId", params: buildThreadRouteParams( @@ -322,9 +324,11 @@ function RemoteSubagentRow(props: { @@ -346,6 +350,7 @@ function DelegatedFromRow(props: { const router = useRouter(); const { delegatedFrom } = props; const environment = useEnvironment(delegatedFrom.environmentId); + const canOpen = environment?.entry.enabled === true; const parentRef = scopeThreadRef(delegatedFrom.environmentId, delegatedFrom.threadId); // The title it had at delegation, until this client sees the parent itself. const parent = useQuietThreadShell(parentRef); @@ -390,9 +395,9 @@ function DelegatedFromRow(props: { size="sm" variant="ghost" part="row" - aria-disabled={environment === null || undefined} + aria-disabled={!canOpen || undefined} onClick={() => { - if (environment === null) return; + if (!canOpen) return; void openParent(); }} /> @@ -418,9 +423,11 @@ function DelegatedFromRow(props: { diff --git a/apps/web/src/components/chat/V2LifecycleRow.tsx b/apps/web/src/components/chat/V2LifecycleRow.tsx index 920586f6d1dbb..085b906ebe6f2 100644 --- a/apps/web/src/components/chat/V2LifecycleRow.tsx +++ b/apps/web/src/components/chat/V2LifecycleRow.tsx @@ -423,11 +423,11 @@ function SubagentTimelineLink(props: { const remoteEnvironment = useEnvironment(remote?.environmentId ?? null); const navigate = useNavigate(); const threadId = props.threadId; - // A task in a linked environment opens there, if this client is connected to it. + // A task in a linked environment opens there, if it is enabled in this client. const open = threadId !== null ? () => props.onOpenThread(threadId) - : remote !== undefined && remoteEnvironment !== null + : remote !== undefined && remoteEnvironment?.entry.enabled === true ? () => void navigate({ to: "/$environmentId/$threadId",