mirror of
https://github.com/getpaseo/paseo.git
synced 2026-11-09 18:04:55 +00:00
fix(app): reach a compact subagent's composer and keep a focus selection
A compact sheet has no composer, so focus() on a subagent without a tab opened a read-only sheet and timed out. It now promotes the sheet to a tab, the same path as Open as tab. A pending focus ran inside the effect that attached the input, before a later effect in that commit applied a prompt set alongside it, so its selection was clamped against the old text. It now runs after the commit.
This commit is contained in:
+1
-1
@@ -713,7 +713,7 @@ implementations provide it:
|
||||
`DraftAgentControls` provides `ComposerAgent` (settings), and the adapter enforces the lock.
|
||||
|
||||
`focus` differs per implementation on purpose. A running agent's model reveals the agent with
|
||||
`navigateToAgent` when `ComposerInput.isRevealed()` is false, and holds one pending request until an
|
||||
`navigateToAgentComposer` when `ComposerInput.isRevealed()` is false, and holds one pending request until an
|
||||
input that is revealed attaches. `isRevealed` is stricter than `isLive`: a compact overlay panel and
|
||||
an unfocused pane leave the input live but unable to take the keyboard, so the agent is revealed
|
||||
first, which also moves pane focus. `Composer` makes `revealed` part of `ComposerInput`, so a change
|
||||
|
||||
@@ -3,6 +3,7 @@ import {
|
||||
CHECKLIST,
|
||||
withComposerToolsExample,
|
||||
withPluginFocusingAComposer,
|
||||
withPluginFocusingASubagent,
|
||||
withPluginHoldingNewWorkspaceComposer,
|
||||
withPluginWritingToAnotherWorkspace,
|
||||
} from "../support/helpers/plugin-composers-by-reference";
|
||||
@@ -116,3 +117,12 @@ test("focus rejects on a New workspace composer the user has left", async ({ pag
|
||||
await author.expectHeldComposerFocusResult("The composer is not on screen.");
|
||||
});
|
||||
});
|
||||
|
||||
test("focus opens a compact subagent as a tab with its composer", async ({ page }) => {
|
||||
test.setTimeout(120_000);
|
||||
await withPluginFocusingASubagent(page, async (author) => {
|
||||
await author.openParentAgent();
|
||||
await author.focusSubagent();
|
||||
await author.expectSubagentTabReadyToType();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -4,10 +4,12 @@ import path from "node:path";
|
||||
import { expect, test, type Page } from "@playwright/test";
|
||||
import { gotoAppShell } from "./app";
|
||||
import { openCommandCenter } from "./command-center";
|
||||
import { openAgentRoute } from "./mock-agent";
|
||||
import { openAgentRoute, seedMockAgentWorkspace } from "./mock-agent";
|
||||
import { connectNewWorkspaceDaemonClient, openGlobalNewWorkspaceComposer } from "./new-workspace";
|
||||
import { copyPluginExample, pluginRequirements } from "./plugin-fixture";
|
||||
import { seedWorkspace } from "./seed-client";
|
||||
import { expectNoSheetOpen, expectOpenTabCount } from "./sheet";
|
||||
import { seedSubagent } from "./subagents";
|
||||
import { getServerId } from "./server-id";
|
||||
import { createAgentTabFromMenu, ensureExplorerSidebar } from "./workspace-tabs";
|
||||
|
||||
@@ -461,3 +463,81 @@ export default function contribute(client) {
|
||||
await workspace.cleanup();
|
||||
}
|
||||
}
|
||||
|
||||
type FocusAgentGlobal = typeof globalThis & {
|
||||
focusAgent?: (agentId: string) => Promise<void>;
|
||||
};
|
||||
|
||||
export async function withPluginFocusingASubagent(
|
||||
page: Page,
|
||||
run: (author: {
|
||||
openParentAgent(): Promise<void>;
|
||||
focusSubagent(): Promise<void>;
|
||||
expectSubagentTabReadyToType(): Promise<void>;
|
||||
}) => Promise<void>,
|
||||
) {
|
||||
const workspace = await seedMockAgentWorkspace({
|
||||
repoPrefix: "focus-subagent-",
|
||||
title: "Focus parent",
|
||||
});
|
||||
const child = await seedSubagent(
|
||||
{ client: workspace.client, repoPath: workspace.cwd, workspaceId: workspace.workspaceId },
|
||||
{ parentId: workspace.agentId, title: "Focus child" },
|
||||
);
|
||||
const client = await connectNewWorkspaceDaemonClient({ ownProjects: false });
|
||||
const previous = await client.getDaemonConfig();
|
||||
const directory = await mkdtemp(path.join(tmpdir(), "paseo-focus-subagent-"));
|
||||
await writeFile(
|
||||
path.join(directory, "paseo-plugin.json"),
|
||||
JSON.stringify({ id: "focus-subagent", requirements: pluginRequirements }),
|
||||
);
|
||||
await writeFile(
|
||||
path.join(directory, "index.client.tsx"),
|
||||
`export default function contribute(client) {
|
||||
globalThis.focusAgent = (agentId) => client.composers({ agentId })[0].focus();
|
||||
return () => {};
|
||||
}`,
|
||||
);
|
||||
try {
|
||||
await client.patchDaemonConfig({ pluginsEnabled: true });
|
||||
await client.installDirectoryPlugin(directory);
|
||||
await page.setViewportSize(COMPACT);
|
||||
await gotoAppShell(page);
|
||||
await run({
|
||||
openParentAgent: () =>
|
||||
test.step("open the parent agent on a phone", async () => {
|
||||
await openAgentRoute(page, {
|
||||
workspaceId: workspace.workspaceId,
|
||||
agentId: workspace.agentId,
|
||||
});
|
||||
await expect(prompt(page)).toBeVisible({ timeout: 30_000 });
|
||||
await expectOpenTabCount(page, 1);
|
||||
await page.waitForFunction(() => (globalThis as FocusAgentGlobal).focusAgent);
|
||||
}),
|
||||
focusSubagent: () =>
|
||||
test.step("focus the subagent, which has no tab", async () => {
|
||||
const result = await page.evaluate(
|
||||
(agentId) =>
|
||||
(globalThis as FocusAgentGlobal).focusAgent?.(agentId).then(
|
||||
() => "focused",
|
||||
(error: Error) => error.message,
|
||||
),
|
||||
child.id,
|
||||
);
|
||||
expect(result).toBe("focused");
|
||||
}),
|
||||
expectSubagentTabReadyToType: () =>
|
||||
test.step("the subagent is a tab with the cursor in its composer", async () => {
|
||||
await expectNoSheetOpen(page);
|
||||
await expectOpenTabCount(page, 2);
|
||||
await expect(prompt(page)).toBeFocused();
|
||||
}),
|
||||
});
|
||||
} finally {
|
||||
await client.removePlugin("focus-subagent");
|
||||
await client.patchDaemonConfig({ pluginsEnabled: previous.config.pluginsEnabled });
|
||||
await client.close();
|
||||
await rm(directory, { recursive: true, force: true });
|
||||
await workspace.cleanup();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -202,6 +202,25 @@ describe("agent composer focus", () => {
|
||||
expect(focused).toEqual([undefined]);
|
||||
});
|
||||
|
||||
it("focuses after the commit that revealed the input has applied a pending prompt", async () => {
|
||||
openSession(agent());
|
||||
const model = composer({ reveal() {} });
|
||||
const focusing = model.focus({ selection: { start: 0, end: 5 } });
|
||||
let text = "";
|
||||
const textWhenFocused: string[] = [];
|
||||
model.attachInput({
|
||||
...mountedInput({ revealed: true }).input,
|
||||
focus: async () => {
|
||||
textWhenFocused.push(text);
|
||||
},
|
||||
});
|
||||
text = "HELLO";
|
||||
|
||||
await focusing;
|
||||
|
||||
expect(textWhenFocused).toEqual(["HELLO"]);
|
||||
});
|
||||
|
||||
it("waits for a retained input to come on screen before focusing it", async () => {
|
||||
openSession(agent());
|
||||
let reveals = 0;
|
||||
|
||||
@@ -15,7 +15,7 @@ import { useDraftStore } from "@/stores/draft-store";
|
||||
import { toDraftInputIfReady, type DraftRecord } from "@/stores/draft-store/state";
|
||||
import { useSessionStore, type Agent } from "@/stores/session-store";
|
||||
import type { UserComposerAttachment } from "@/attachments/types";
|
||||
import { navigateToAgent } from "@/utils/navigate-to-agent";
|
||||
import { navigateToAgentComposer } from "@/utils/navigate-to-agent";
|
||||
|
||||
/** The mounted composer's input. While attached, prompt commands go through it. */
|
||||
export interface MountedComposerInput {
|
||||
@@ -236,7 +236,11 @@ export function createAgentComposer(
|
||||
function focusIfRevealed(input: MountedComposerInput) {
|
||||
if (!pendingFocus || !input.isRevealed()) return;
|
||||
const { options, settle } = takePendingFocus()!;
|
||||
input.focus(options).then(settle.resolve, settle.reject);
|
||||
// Attaching runs in a commit's effects, and a prompt set alongside this request can be applied
|
||||
// by a later effect in that commit. A selection has to see that text.
|
||||
queueMicrotask(() => {
|
||||
input.focus(options).then(settle.resolve, settle.reject);
|
||||
});
|
||||
}
|
||||
|
||||
function unheld() {
|
||||
@@ -355,7 +359,7 @@ export function agentComposer(serverId: string, agentId: string): AgentComposer
|
||||
composer = createAgentComposer(serverId, agentId, {
|
||||
queryClient: singletonQueryClient,
|
||||
onUnheld: releaseReleasableComposers,
|
||||
reveal: () => navigateToAgent({ serverId, agentId }),
|
||||
reveal: () => navigateToAgentComposer({ serverId, agentId }),
|
||||
});
|
||||
composers.set(key, composer);
|
||||
}
|
||||
|
||||
@@ -1,22 +1,47 @@
|
||||
import { router, type Href } from "expo-router";
|
||||
import { navigateToWorkspace } from "@/stores/navigation-active-workspace-store";
|
||||
import { useSessionStore } from "@/stores/session-store";
|
||||
import { useWorkspaceLayoutStore } from "@/stores/workspace-layout-store";
|
||||
import { normalizeWorkspaceOpaqueId } from "@/utils/workspace-identity";
|
||||
import { buildWorkspaceTabPersistenceKey } from "@/workspace-tabs/model";
|
||||
import { resolveNavigateToAgent, type NavigateToAgentInput } from "./resolve";
|
||||
|
||||
export type { NavigateToAgentInput } from "./resolve";
|
||||
|
||||
function readAgentNavTarget({ serverId, agentId }: { serverId: string; agentId: string }) {
|
||||
const session = useSessionStore.getState().sessions[serverId];
|
||||
const agent = session?.agents.get(agentId) ?? session?.agentDetails.get(agentId);
|
||||
return {
|
||||
agentWorkspaceId: agent?.workspaceId,
|
||||
};
|
||||
}
|
||||
|
||||
export function navigateToAgent(input: NavigateToAgentInput): string {
|
||||
return resolveNavigateToAgent(input, {
|
||||
readAgentNavTarget: ({ serverId, agentId }) => {
|
||||
const session = useSessionStore.getState().sessions[serverId];
|
||||
const agent = session?.agents.get(agentId) ?? session?.agentDetails.get(agentId);
|
||||
return {
|
||||
agentWorkspaceId: agent?.workspaceId,
|
||||
};
|
||||
},
|
||||
readAgentNavTarget,
|
||||
navigateToHostAgent: (route) => {
|
||||
router.navigate(route as Href);
|
||||
},
|
||||
navigateToWorkspace,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Navigates to the agent where its composer is. A compact sheet is for looking and has no composer,
|
||||
* so a subagent that lands in one becomes a tab, as Open as tab does.
|
||||
*/
|
||||
export function navigateToAgentComposer(input: NavigateToAgentInput): void {
|
||||
navigateToAgent(input);
|
||||
const workspaceId = normalizeWorkspaceOpaqueId(
|
||||
readAgentNavTarget(input).agentWorkspaceId ?? input.workspaceId,
|
||||
);
|
||||
const workspaceKey = workspaceId
|
||||
? buildWorkspaceTabPersistenceKey({ serverId: input.serverId, workspaceId })
|
||||
: null;
|
||||
if (!workspaceKey) return;
|
||||
const layout = useWorkspaceLayoutStore.getState();
|
||||
const sheet = layout.sheetsByWorkspace[workspaceKey]?.find(
|
||||
({ tab }) => tab.target.kind === "agent" && tab.target.agentId === input.agentId,
|
||||
);
|
||||
if (sheet) layout.openTabInFront(workspaceKey, sheet);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user