feat(app): open workspace chats immediately

Reserve the final workspace, agent, and initial message identities before preparation so the normal agent panel can own the conversation throughout creation. Keep follow-ups queued until the first prompt starts and hand failures back to the existing workspace form or failed agent draft.
This commit is contained in:
Mohamed Boudra
2026-10-26 09:50:00 +01:00
parent 428ef707ad
commit 3a3ccd5385
57 changed files with 3222 additions and 601 deletions
+44 -5
View File
@@ -340,11 +340,50 @@ Example: adding a new enum value
### Creation ownership
Creation is owned by the daemon across socket lifetimes. A workspace request can include
its initial agent and prompt. Workspace readiness is published before provider startup;
the app keeps its existing disk-ready navigation and observes the remaining creation.
Callbacks do not advance the workflow. Resource reservations in acknowledgement are
identities, not ready workspace records.
Creation execution is owned by the daemon across socket lifetimes. A workspace request
can include its initial agent and prompt. The app's `workspace-creation/` module owns
chat submission presentation outside React: it reserves final IDs and a message identity,
then opens the agent target before asynchronous preparation or a daemon response.
The New workspace chat entry requires `creationLifecycle`; empty workspaces, terminal
launches, and existing-workspace agent drafts retain their own entry points.
Pending presentation never enters the directory replica as a fabricated resource.
Routes, tabs, and the sidebar retain it until the corresponding authoritative resource
has been observed. Files, terminals, scripts, and agent timeline initialization wait for
real resources. The creating agent opens in the normal agent panel and composer, showing
its submitted configuration. Until the host publishes the agent, its destination cwd is
unknown and its controls are read-only. The host publishes the agent before it starts the
initial prompt, so prompts wait in the agent's queue until creation completes
(`isAgentReadyForPrompts`). `composer/actions.ts` applies that to every prompt path: send
and review feedback queue behind earlier prompts, and send now and queue drain leave the
queue untouched. The queue drains on adoption or when the initial turn ends. The first
message uses composer submission reconciliation; creation never sends it again when the
panel mounts. Completion does not navigate, and draft cleanup checks the captured version
so it cannot erase newer input.
A failed creation goes to the existing owner of whatever the host did not create, using
the receipt's resources and `failedStage`. Creation has no failure UI of its own:
- No workspace: the app returns to New workspace with the submitted draft and shows the
error the way that screen shows its own submit errors.
- Workspace but no agent: the agent tab becomes an existing-workspace agent draft holding
the prompt, in the abandoned create-flow state. Its error and Send retry are that draft's
own; the retry creates the agent in the kept workspace.
- An agent that exists or may exist, including a failed first prompt: creation completes
and the agent tab keeps the agent's own timeline and turn error. Turning it into a draft
would create a second agent on Send.
Queued follow-ups and unsent composer text belong to a composer identity, so they move
with the creation: from the form to the creating agent, back to the form or the agent draft
on failure, and to the created agent after retry (`transferComposerFollowUps`). The initial
prompt stays the form's or draft's input. The agent draft also carries the submitted
setup and context attachments. The host ignores `config.cwd` when a create request names a
workspace, so a draft's retry starts at the workspace root, not a nested fork cwd.
Adopted successful attempts release their payloads; a bounded cache of compact receipts
absorbs duplicate commands from retained forms. The SDK owns reconnect observation; the
app has no resend loop. Pending views survive navigation and pane remount, not app
process death. Resource reservations are identities, not ready workspace records.
The creation journal (`server/creation/`) owns identity and execution for both legacy and
modern creation RPCs. Requesting progress never selects a different journal. Existing
+1
View File
@@ -35715,6 +35715,7 @@
"@gorhom/portal": "^1.0.14",
"@marijn/find-cluster-break": "^1.0.2",
"@mattermost/react-native-paste-input": "2.0.1",
"@noble/hashes": "^2.4.0",
"@react-native-async-storage/async-storage": "2.2.0",
"@react-native-masked-view/masked-view": "0.3.2",
"@react-native/normalize-colors": "0.86.3",
@@ -1,4 +1,11 @@
import {
hideCreationLifecycle,
holdWorkspaceProvisioning,
reportUnknownPromptOutcome,
} from "../support/helpers/creation";
import type { createCreationScenario } from "../support/helpers/creation";
import { expectComposerMode, expectComposerModel } from "../support/helpers/agent-profiles";
import { expectChatHistoryAttachment } from "../support/helpers/assistant-fork";
import { expect } from "../support/fixtures";
import { test } from "../support/creation-fixtures";
@@ -105,26 +112,38 @@ test("new workspace navigation and optimistic prompt precede agent initializatio
await creation.expectAgentCount(1);
});
for (const scenario of [
{ suffix: "", prepare: async () => {} },
for (const entry of [
{
suffix: " after remount",
prepare: async (creation: Awaited<ReturnType<typeof createCreationScenario>>) =>
creation.evictAndReturnToDraft(),
name: "an agent draft",
open: (creation: Awaited<ReturnType<typeof createCreationScenario>>) =>
creation.openAgentDraft(),
button: "Send message",
expectWorkspace: async (_creation: Awaited<ReturnType<typeof createCreationScenario>>) => {},
},
{
name: "a new workspace",
open: (creation: Awaited<ReturnType<typeof createCreationScenario>>) =>
creation.openWorkspaceForm("local"),
button: "Create",
expectWorkspace: (creation: Awaited<ReturnType<typeof createCreationScenario>>) =>
creation.expectOneCreatedWorkspace(),
},
]) {
test(`retrying failed agent initialization preserves its workspace${scenario.suffix}`, async ({
test(`a failed agent start in ${entry.name} keeps the draft, its error and its retry`, async ({
creation,
startup,
}) => {
await creation.openWorkspaceForm("local");
await creation.submitPrompt("Retry this workspace and agent together.", "Create");
await creation.expectAgentStillStarting();
page,
}, testInfo) => {
const prompt = "Retry this agent after its startup fails.";
await entry.open(creation);
await creation.submitPrompt(prompt, entry.button);
await startup.fail();
await creation.expectStartupFailure();
await creation.expectOneCreatedWorkspace();
await scenario.prepare(creation);
await creation.submitPrompt("Retry this workspace and agent together.");
await creation.expectFailedAgentDraft(prompt);
await entry.expectWorkspace(creation);
await page.screenshot({ path: testInfo.outputPath("failed-agent-draft.png") });
await creation.resendFailedDraft();
await creation.expectPromptVisible(prompt);
await entry.expectWorkspace(creation);
await creation.expectAgentCount(1);
});
}
@@ -171,3 +190,323 @@ for (const entry of [
await expect(page.getByText("agent_request_key_conflict", { exact: true })).toHaveCount(0);
});
}
for (const isolation of ["local", "worktree"] as const) {
test(`a held ${isolation} workspace opens the normal agent panel and queues a follow-up`, async ({
creation,
page,
}, testInfo) => {
const prompt = "emit 1 coalesced agent stream updates for the first workspace prompt.";
const followUp = "emit 1 coalesced agent stream updates for the queued follow-up.";
const draft = "Keep this unsent draft through adoption.";
const provisioning = await holdWorkspaceProvisioning(page);
await creation.openWorkspaceForm(isolation);
await creation.submitPrompt(prompt, "Create");
const request = await provisioning.waitForRequest();
await test.step("the final agent panel paints before any host response", async () => {
expect(request.workspaceId).toMatch(/^wks_[a-f0-9]{16}$/);
expect(request.agent!.agentId).toMatch(/^[a-f0-9-]{36}$/);
await expect(page).toHaveURL(new RegExp(`/workspace/${request.workspaceId}$`));
await expect(
page.getByTestId(`workspace-tab-agent_${request.agent!.agentId}`).filter({ visible: true }),
).toBeVisible();
await creation.expectPromptVisible(prompt);
await creation.expectNormalAgentComposer();
await expectComposerModel(page, "Ten second stream");
await expectComposerMode(page, "Load test");
});
await test.step("a follow-up waits in the queue instead of reaching the agent", async () => {
await creation.queueFollowUp(followUp);
await creation.typeComposerDraft(draft);
expect(provisioning.agentPrompts()).toBe(0);
await page.screenshot({ path: testInfo.outputPath("held-normal-panel.png") });
});
await test.step("adoption keeps the same agent, draft and queue order", async () => {
provisioning.release();
await creation.expectOneCreatedWorkspace();
expect(await creation.agentIds()).toEqual([request.agent!.agentId]);
await expect(page).toHaveURL(new RegExp(`/workspace/${request.workspaceId}$`));
await creation.expectUserMessagesInOrder([prompt, followUp]);
expect(provisioning.agentPrompts()).toBe(1);
await creation.expectComposerDraft(draft);
await creation.expectTimelineSettled();
await page.screenshot({ path: testInfo.outputPath("adopted-normal-panel.png") });
});
});
}
test("a held workspace on compact shows the final agent prompt and composer", async ({
creation,
page,
}, testInfo) => {
const provisioning = await holdWorkspaceProvisioning(page);
await creation.openWorkspaceForm("worktree");
await page.setViewportSize({ width: 390, height: 844 });
await creation.submitPrompt("Show my prompt before workspace provisioning.", "Create");
const request = await provisioning.waitForRequest();
await expect(page).toHaveURL(new RegExp(`/workspace/${request.workspaceId}$`));
await creation.expectPromptVisible("Show my prompt before workspace provisioning.");
await creation.expectNormalAgentComposer();
await creation.expectAgentCannotBeStoppedYet();
await page.screenshot({ path: testInfo.outputPath("compact-held.png") });
provisioning.release();
await creation.expectOneCreatedWorkspace();
expect(await creation.agentIds()).toEqual([request.agent!.agentId]);
await creation.expectInitialPromptOnce("Show my prompt before workspace provisioning.");
});
test("workspace creation recovers a lost response without replaying the first prompt", async ({
creation,
workspacePromptRetry,
}) => {
await creation.openWorkspaceForm("worktree");
workspacePromptRetry.holdAcknowledgement();
await creation.submitPrompt("Recover this workspace prompt once.", "Create");
await workspacePromptRetry.waitForDeliveredPrompt();
await creation.expectPromptVisible("Recover this workspace prompt once.");
await creation.evictAndReturnToDraft();
await workspacePromptRetry.disconnectAndReconnect();
await workspacePromptRetry.expectSameAgentAndMessage();
await creation.expectOneCreatedWorkspace();
await creation.expectAgentCount(1);
});
test("a pending workspace survives leaving and remounting before any daemon event", async ({
creation,
page,
}) => {
const provisioning = await holdWorkspaceProvisioning(page);
await creation.openWorkspaceForm("worktree");
await creation.submitPrompt("Keep this pending workspace across remount.", "Create");
await provisioning.waitForRequest();
await creation.expectPromptVisible("Keep this pending workspace across remount.");
await creation.evictAndReturnToDraft();
await creation.expectPromptVisible("Keep this pending workspace across remount.");
provisioning.release();
await creation.expectOneCreatedWorkspace();
await creation.expectAgentCount(1);
});
test("a failed workspace returns to New workspace with the prompt and its error", async ({
creation,
provisioningFailure,
page,
}, testInfo) => {
const prompt = "Keep my prompt when the workspace fails.";
await provisioningFailure.arm();
await creation.openWorkspaceForm("local");
await creation.submitRepeatedly("Create", prompt);
await creation.expectReturnedToNewWorkspace(prompt, "Provisioning outcome is uncertain for test");
await page.screenshot({ path: testInfo.outputPath("failed-workspace-returned.png") });
});
test("a failed workspace keeps its queued follow-up and unsent text through resubmission", async ({
creation,
provisioningFailure,
page,
}, testInfo) => {
const prompt = "emit 1 coalesced agent stream updates for the resubmitted workspace.";
const followUp = "emit 1 coalesced agent stream updates for the resubmitted follow-up.";
const draft = "Keep this unsent text through the failed workspace.";
await provisioningFailure.arm();
const provisioning = await holdWorkspaceProvisioning(page);
await creation.openWorkspaceForm("local");
await creation.submitPrompt(prompt, "Create");
await provisioning.waitForRequest();
await creation.queueFollowUp(followUp);
await creation.typeComposerDraft(draft);
provisioning.release();
await test.step("New workspace shows the prompt, its error and the queued follow-up", async () => {
await creation.expectReturnedToNewWorkspace(
prompt,
"Provisioning outcome is uncertain for test",
);
await creation.expectQueuedFollowUp(followUp);
await page.screenshot({ path: testInfo.outputPath("failed-workspace-follow-up.png") });
});
await test.step("resubmitting delivers the prompt, then the follow-up, once", async () => {
await creation.resubmitWorkspace();
await provisioning.waitForRequests(2);
provisioning.release();
await creation.expectOneCreatedWorkspace();
await creation.expectAgentCount(1);
await creation.expectUserMessagesInOrder([prompt, followUp]);
await creation.expectComposerDraft(draft);
await page.screenshot({ path: testInfo.outputPath("resubmitted-workspace-follow-up.png") });
});
});
test("a failed agent start keeps its queued follow-up and unsent text through retry", async ({
creation,
startup,
page,
}, testInfo) => {
const prompt = "emit 1 coalesced agent stream updates for the retried agent.";
const followUp = "emit 1 coalesced agent stream updates for the retried follow-up.";
const draft = "Keep this unsent text through the failed agent start.";
await creation.openWorkspaceForm("local");
await creation.submitPrompt(prompt, "Create");
await creation.queueFollowUp(followUp);
await creation.typeComposerDraft(draft);
await startup.fail();
await test.step("the agent draft shows the prompt, its error and the queued follow-up", async () => {
await creation.expectFailedAgentDraft(prompt);
await creation.expectQueuedFollowUp(followUp);
await page.screenshot({ path: testInfo.outputPath("failed-agent-follow-up.png") });
});
await test.step("retrying delivers the prompt, then the follow-up, once", async () => {
await creation.resendFailedDraft();
await creation.expectOneCreatedWorkspace();
await creation.expectAgentCount(1);
await creation.expectUserMessagesInOrder([prompt, followUp]);
await creation.expectComposerDraft(draft);
await page.screenshot({ path: testInfo.outputPath("retried-agent-follow-up.png") });
});
});
test("a forked worktree agent that fails to start keeps its setup and context through retry", async ({
creation,
lateStartup,
page,
}, testInfo) => {
const prompt = "Keep this fork's setup, image and history through the retry.";
await creation.openNestedAgentFork("worktree");
await creation.attachImage();
const startup = await lateStartup.arm();
await creation.submitPrompt(prompt, "Create");
await startup.fail();
await test.step("the agent draft keeps the fork's configuration and context", async () => {
await creation.expectFailedAgentDraft(prompt);
await expectComposerModel(page, "e2e-fast-stream");
await expectComposerMode(page, "Load test");
await creation.expectImageVisible();
await expectChatHistoryAttachment(page);
await page.screenshot({ path: testInfo.outputPath("failed-fork-draft.png") });
});
// An agent created in an existing workspace starts at the workspace root: the host ignores
// config.cwd when the request names a workspace, so the nested cwd is not asserted here.
await test.step("retrying creates one agent in the worktree with that configuration and context", async () => {
await creation.resendFailedDraft();
await creation.expectOneCreatedWorkspace();
await creation.expectAgentCount(1);
await creation.expectAgentConfig({ model: "e2e-fast-stream", modeId: "load-test" });
await creation.expectImageVisible();
await expect(
page
.getByTestId("user-message")
.filter({ hasText: "Chat history" })
.filter({ visible: true }),
).toHaveCount(1);
await page.screenshot({ path: testInfo.outputPath("retried-fork-agent.png") });
});
});
test("a forked worktree whose workspace fails keeps its context through resubmission", async ({
creation,
provisioningFailure,
page,
}, testInfo) => {
const prompt = "Keep this fork's history when its workspace fails.";
await creation.openNestedAgentFork("worktree");
await provisioningFailure.arm();
await creation.submitPrompt(prompt, "Create");
await test.step("New workspace shows the prompt, its error and the fork's context", async () => {
await creation.expectReturnedToNewWorkspace(
prompt,
"Provisioning outcome is uncertain for test",
);
await expectChatHistoryAttachment(page);
await page.screenshot({ path: testInfo.outputPath("failed-fork-workspace.png") });
});
await test.step("resubmitting creates one agent with that context", async () => {
await creation.resubmitWorkspace();
await creation.expectOneCreatedWorkspace();
await creation.expectAgentCount(1);
await expect(
page
.getByTestId("user-message")
.filter({ hasText: "Chat history" })
.filter({ visible: true }),
).toHaveCount(1);
await page.screenshot({ path: testInfo.outputPath("resubmitted-fork-workspace.png") });
});
});
test("an unsupported host keeps the draft and asks for an update before creating anything", async ({
creation,
page,
}) => {
const host = await hideCreationLifecycle(page);
await creation.openWorkspaceForm("local");
await creation.submitPrompt("Retain my unsupported host draft.", "Create");
await expect(
page
.getByText("Update this host to create a workspace with an agent.", { exact: true })
.first(),
).toBeVisible();
await expect(page).toHaveURL(/\/new\?/);
host.expectNoCreationRequest();
});
for (const isolation of ["local", "worktree"] as const) {
test(`a fork keeps its image and nested cwd in the optimistic and real ${isolation} agent`, async ({
creation,
page,
}, testInfo) => {
const provisioning = await holdWorkspaceProvisioning(page);
const cwd = await creation.openNestedAgentFork(isolation);
await creation.attachImage();
await creation.submitPrompt("Keep this image and nested cwd.", "Create");
const request = await provisioning.waitForRequest();
expect(request.agent!.config?.cwd).toBe(cwd);
expect(request.agent!.images).toHaveLength(1);
await creation.expectPromptVisible("Keep this image and nested cwd.");
await creation.expectImageVisible();
await page.screenshot({ path: testInfo.outputPath("pending-image-nested-cwd.png") });
provisioning.release();
await creation.expectOneCreatedWorkspace();
await creation.expectNestedAgentCwd();
await creation.expectImageVisible();
await creation.expectInitialPromptOnce("Keep this image and nested cwd.");
});
}
test("closing the pending agent tab leaves host creation running", async ({ creation, page }) => {
const provisioning = await holdWorkspaceProvisioning(page);
await creation.openWorkspaceForm("local");
await creation.submitPrompt("Finish creation after I close the tab.", "Create");
const request = await provisioning.waitForRequest();
await creation.expectPromptVisible("Finish creation after I close the tab.");
await creation.closePendingAgent(request.agent!.agentId!);
provisioning.release();
await creation.expectOneCreatedWorkspace();
await creation.expectAgentCount(1);
await creation.expectInitialPromptOnce("Finish creation after I close the tab.");
});
test("an uncertain prompt receipt keeps the created agent tab and its timeline", async ({
creation,
page,
}) => {
await reportUnknownPromptOutcome(page);
await creation.openWorkspaceForm("local");
const prompt = "emit 1 coalesced agent stream updates for an uncertain first prompt.";
await creation.submitPrompt(prompt, "Create");
await creation.expectAssistantReply();
await creation.expectAgentNotWorking();
await creation.expectOneCreatedWorkspace();
await creation.expectInitialPromptOnce(prompt);
await creation.expectNoCreationRecovery();
await creation.expectAgentCount(1);
});
@@ -92,7 +92,7 @@ for (const version of ["0.2.5", "0.7.2", "0.8.0"]) {
});
}
test("current app creates a workspace, navigates before agent startup, and creates another agent on an old daemon", async ({
test("current app requires a host update for chat workspaces and preserves existing-workspace agent creation", async ({
page,
project,
client,
@@ -104,49 +104,34 @@ for (const version of ["0.2.5", "0.7.2", "0.8.0"]) {
const prompt = "Create this workspace: emit 1 coalesced agent stream updates";
await fillComposerDraft(page, prompt);
await pressSubmitBeforeTheNextRender(page, "Create");
await gate.waitForRequest();
await expect(page).toHaveURL(/\/workspace\//);
await expect(
page.getByTestId("user-message").filter({ visible: true }).filter({ hasText: prompt }),
page
.getByText("Update this host to create a workspace with an agent.", { exact: true })
.first(),
).toBeVisible();
await expect(page).toHaveURL(/\/new\?/);
expect((await client.fetchAgents()).entries).toHaveLength(0);
const workspaces = (await client.fetchWorkspaces()).entries.filter(
(workspace) =>
workspace.projectId === project.projectId && workspace.id !== project.workspaceId,
);
expect(workspaces).toHaveLength(1);
gate.release();
await gate.waitForCreated();
await expect.poll(async () => (await client.fetchAgents()).entries.length).toBe(1);
const first = (await client.fetchAgents()).entries[0]!.agent;
await client.waitForFinish(first.id, 20_000);
await expectPromptOnce(client, first.id, prompt);
await expect(
page.getByTestId(`workspace-tab-agent_${first.id}`).filter({ visible: true }),
).toHaveText(prompt);
expect(
(await client.fetchWorkspaces()).entries.filter(
(workspace) => workspace.projectId === project.projectId,
),
).toHaveLength(1);
await page.goto(buildHostWorkspaceRoute(daemon.serverId, project.workspaceId));
await createAgentTabFromMenu(page);
const secondPrompt = "Create another agent: emit 1 coalesced agent stream updates";
await fillComposerDraft(page, secondPrompt);
await pressSubmitBeforeTheNextRender(page, "Send message");
await gate.waitForRequest();
gate.release();
await gate.waitForCreated();
await expect.poll(async () => (await client.fetchAgents()).entries.length).toBe(2);
const second = (await client.fetchAgents()).entries.find(
({ agent }) => agent.id !== first.id,
)!.agent;
expect(second.workspaceId).toBe(first.workspaceId);
await client.waitForFinish(second.id, 20_000);
await expectPromptOnce(client, second.id, secondPrompt);
await expect.poll(async () => (await client.fetchAgents()).entries.length).toBe(1);
const agent = (await client.fetchAgents()).entries[0]!.agent;
expect(agent.workspaceId).toBe(project.workspaceId);
await client.waitForFinish(agent.id, 20_000);
await expectPromptOnce(client, agent.id, secondPrompt);
await expect(
page.getByTestId(`workspace-tab-agent_${second.id}`).filter({ visible: true }),
page.getByTestId(`workspace-tab-agent_${agent.id}`).filter({ visible: true }),
).toHaveText(secondPrompt);
await expect(
page
.getByTestId("user-message")
.filter({ visible: true })
.filter({ hasText: secondPrompt }),
).toBeVisible();
await page.screenshot({ path: test.info().outputPath("old-daemon-creation.png") });
});
async function expectPromptOnce(client: DaemonClient, agentId: string, text: string) {
+103 -27
View File
@@ -11,27 +11,24 @@ import {
import { installDaemonWebSocketGate } from "./helpers/daemon-websocket-gate";
import { delayBrowserAgentCreatedStatus } from "./helpers/new-workspace";
export const test = base.extend<{
startup: { release(): Promise<void>; fail(): Promise<void> };
promptRejection: void;
creation: Awaited<ReturnType<typeof createCreationScenario>>;
promptRetry: ReturnType<typeof createPromptRetryScenario>;
agentRetries: Awaited<ReturnType<typeof retryNextAgentCreation>>;
delayedCreation: Awaited<ReturnType<typeof delayBrowserAgentCreatedStatus>>;
}>({
startup: async ({ page }, provide) => {
void page;
const client = await connectNewWorkspaceDaemonClient();
const plugin = await copyPluginExample("lifecycle-logger");
const gate = path.join(plugin.directory, "startup.txt");
const failure = path.join(plugin.directory, "startup-failure.txt");
const previous = await client.getDaemonConfig();
try {
await writeFile(gate, "hold");
await writeFile(failure, "fail");
await writeFile(
path.join(plugin.directory, "index.server.ts"),
`
interface StartupGate {
release(): Promise<void>;
fail(): Promise<void>;
dispose(): Promise<void>;
}
/** Holds every later workspace agent's startup until it is released or failed once. */
async function installStartupGate(): Promise<StartupGate> {
const client = await connectNewWorkspaceDaemonClient();
const plugin = await copyPluginExample("lifecycle-logger");
const gate = path.join(plugin.directory, "startup.txt");
const failure = path.join(plugin.directory, "startup-failure.txt");
const previous = await client.getDaemonConfig();
await writeFile(gate, "hold");
await writeFile(failure, "fail");
await writeFile(
path.join(plugin.directory, "index.server.ts"),
`
import { readFile, rm } from "node:fs/promises";
export default function contribute(server) {
return server.before("agent.session_open", async ({ request }) => {
@@ -50,17 +47,88 @@ export default function contribute(server) {
return request;
});
}
`,
);
await client.patchDaemonConfig({ pluginsEnabled: true });
await client.installDirectoryPlugin(plugin.directory);
return {
release: () => rm(gate, { force: true }),
// Replacing the command must not expose a truncated, empty file to the hook.
fail: () => rename(failure, gate),
async dispose() {
await rm(gate, { force: true });
await client.removePlugin("lifecycle-logger");
await client.patchDaemonConfig({ pluginsEnabled: previous.config.pluginsEnabled });
await plugin.cleanup();
await client.close();
},
};
}
export const test = base.extend<{
startup: StartupGate;
/** A startup gate installed mid-test, after agents that must start normally. */
lateStartup: { arm(): Promise<StartupGate> };
promptRejection: void;
/** Fails the next workspace request once it is armed. */
provisioningFailure: { arm(): Promise<void> };
creation: Awaited<ReturnType<typeof createCreationScenario>>;
promptRetry: ReturnType<typeof createPromptRetryScenario>;
workspacePromptRetry: ReturnType<typeof createPromptRetryScenario>;
agentRetries: Awaited<ReturnType<typeof retryNextAgentCreation>>;
delayedCreation: Awaited<ReturnType<typeof delayBrowserAgentCreatedStatus>>;
}>({
startup: async ({ page }, provide) => {
void page;
const gate = await installStartupGate();
try {
await provide(gate);
} finally {
await gate.dispose();
}
},
lateStartup: async ({ page }, provide) => {
void page;
const gates: StartupGate[] = [];
try {
await provide({
arm: async () => {
const gate = await installStartupGate();
gates.push(gate);
return gate;
},
});
} finally {
for (const gate of gates) await gate.dispose();
}
},
provisioningFailure: async ({ page, creation }, provide) => {
void page;
void creation;
const client = await connectNewWorkspaceDaemonClient();
const plugin = await copyPluginExample("lifecycle-logger");
const previous = await client.getDaemonConfig();
try {
const failure = path.join(plugin.directory, "provisioning-failure.txt");
// Fails one workspace request per arm, so a resubmission can succeed.
await writeFile(
path.join(plugin.directory, "index.server.ts"),
`
import { rm, stat } from "node:fs/promises";
export default function contribute(server) {
return server.before("workspace.create", async ({ request }) => {
const failure = ${JSON.stringify(failure)};
if (!(await stat(failure).catch(() => null))) return request;
await rm(failure, { force: true });
throw new Error("Provisioning outcome is uncertain for test");
});
}
`,
);
await client.patchDaemonConfig({ pluginsEnabled: true });
await client.installDirectoryPlugin(plugin.directory);
await provide({
release: () => rm(gate, { force: true }),
// Replacing the command must not expose a truncated, empty file to the hook.
fail: () => rename(failure, gate),
});
await provide({ arm: () => writeFile(failure, "fail") });
} finally {
await rm(gate, { force: true });
await client.removePlugin("lifecycle-logger");
await client.patchDaemonConfig({ pluginsEnabled: previous.config.pluginsEnabled });
await plugin.cleanup();
@@ -114,6 +182,14 @@ export default function contribute(server) {
gate.restore();
}
},
workspacePromptRetry: async ({ page }, provide) => {
const gate = await installDaemonWebSocketGate(page);
try {
await provide(createPromptRetryScenario(page, gate, "workspace"));
} finally {
gate.restore();
}
},
agentRetries: async ({ page }, provide) => {
await provide(await retryNextAgentCreation(page));
},
+325 -13
View File
@@ -1,11 +1,14 @@
import { awaitAssistantMessage } from "./agent-stream";
import { forkMostRecentAssistantTurnToNewWorkspace } from "./assistant-fork";
import type { WorkspaceCreateRequest } from "@getpaseo/protocol/messages";
import { expect, type Page } from "@playwright/test";
import { daemonWsRoutePattern } from "./daemon-port";
import { gotoAppShell } from "./app";
import { gotoWorkspace } from "./launcher";
import { fillComposerDraft } from "./composer";
import { attachComposerTestImage, fillComposerDraft } from "./composer";
import { createAgentTabFromMenu } from "./workspace-tabs";
import {
connectNewWorkspaceDaemonClient,
openNewWorkspaceComposer,
selectWorkspaceIsolation,
loadSessionMessageReaders,
@@ -44,6 +47,43 @@ export async function captureWorkspaceAgentRequest(page: Page, options: { block:
return { waitForRequest: () => request };
}
/** Hold the creation request before the daemon can reserve or provision anything. */
/** Holds each workspace request with an agent until `release` sends the held requests. */
export async function holdWorkspaceProvisioning(page: Page) {
const frames = await loadSessionMessageReaders();
let held: Array<() => void> = [];
const requests: WorkspaceCreateRequest[] = [];
let agentPrompts = 0;
await page.routeWebSocket(daemonWsRoutePattern(), (browser) => {
const server = browser.connectToServer();
browser.onMessage((frame) => {
const message = frames.client(frame);
if (message?.type === "send_agent_message_request") agentPrompts++;
if (message?.type === "workspace.create.request" && message.agent) {
held.push(() => server.send(frame));
requests.push(message);
return;
}
server.send(frame);
});
server.onMessage((frame) => browser.send(frame));
});
async function waitForRequests(count: number) {
await expect.poll(() => requests.length).toBe(count);
return requests[count - 1]!;
}
return {
waitForRequest: () => waitForRequests(1),
waitForRequests,
release: () => {
const sending = held;
held = [];
for (const send of sending) send();
},
agentPrompts: () => agentPrompts,
};
}
export async function pressSubmitBeforeTheNextRender(page: Page, name: string): Promise<void> {
const create = page.getByRole("button", { name, exact: true });
await expect(create).toBeEnabled();
@@ -59,9 +99,11 @@ export async function pressSubmitBeforeTheNextRender(page: Page, name: string):
export async function observeCreationRequests(page: Page) {
const frames = await loadSessionMessageReaders();
const pending = new Set<string>();
let workspaceRequests = 0;
page.on("websocket", (socket) => {
socket.on("framesent", ({ payload }) => {
const request = frames.client(payload);
if (request?.type === "workspace.create.request") workspaceRequests++;
if (
request?.type === "workspace.create.request" ||
request?.type === "agent.create.request"
@@ -84,6 +126,7 @@ export async function observeCreationRequests(page: Page) {
});
});
return {
workspaceRequests: () => workspaceRequests,
async settled() {
await expect.poll(() => pending.size).toBe(0);
},
@@ -138,12 +181,88 @@ export async function retryNextAgentCreation(page: Page) {
};
}
function agentComposer(page: Page) {
// Retained workspaces keep hidden composers mounted beside the focused one.
return page.getByRole("textbox", { name: "Message agent..." }).filter({ visible: true });
}
export async function createCreationScenario(page: Page) {
const requests = await observeCreationRequests(page);
const project = await seedWorkspace({ repoPrefix: "creation-idempotency-" });
const project = await seedWorkspace({
repoPrefix: "creation-idempotency-",
repo: { files: [{ path: "nested/context.txt", content: "Nested agent working directory" }] },
});
let workspaceId = project.workspaceId;
const evictionWorkspaceIds = new Set<string>();
const sourceClients: Array<{ close(): Promise<void> }> = [];
return {
cleanup: project.cleanup,
cleanup: async () => {
for (const client of sourceClients) await client.close();
await project.cleanup();
},
async openNestedAgentFork(isolation: "local" | "worktree") {
const cwd = `${project.repoPath}/nested`;
const sourceClient = await connectNewWorkspaceDaemonClient();
sourceClients.push(sourceClient);
const source = await sourceClient.createWorkspace({
source: { kind: "directory", path: project.repoPath },
agent: {
config: { provider: "mock", model: "e2e-fast-stream", modeId: "load-test", cwd },
initialPrompt: "emit 1 coalesced agent stream updates for the source conversation.",
},
});
if (!source.agent || !source.workspace)
throw new Error(source.error ?? "Missing nested source agent");
evictionWorkspaceIds.add(source.workspace.id);
await project.client.waitForFinish(source.agent.id, 20_000);
await gotoWorkspace(page, source.workspace.id);
await awaitAssistantMessage(page);
await forkMostRecentAssistantTurnToNewWorkspace(page);
await selectWorkspaceIsolation(page, isolation);
return cwd;
},
attachImage: () => attachComposerTestImage(page),
async expectImageVisible() {
await expect(
page
.getByRole("button", { name: "Open image attachment", exact: true })
.filter({ visible: true }),
).toHaveCount(1);
},
async expectInitialPromptOnce(prompt: string) {
const agents = (await project.client.fetchAgents()).entries.filter(
({ agent }) => agent.workspaceId === workspaceId,
);
expect(agents).toHaveLength(1);
const reader = await connectNewWorkspaceDaemonClient();
try {
const timeline = await reader.fetchAgentTimeline(agents[0]!.agent.id);
expect(timeline.entries.filter(({ item }) => item.type === "user_message")).toHaveLength(1);
expect(
timeline.entries.filter(
({ item }) => item.type === "user_message" && item.text === prompt,
),
).toHaveLength(1);
} finally {
await reader.close();
}
},
async expectNestedAgentCwd() {
const workspace = (await project.client.fetchWorkspaces()).entries.find(
(entry) => entry.id === workspaceId,
)!;
const nestedAgent = (await project.client.fetchAgents()).entries.find(
({ agent }) => agent.workspaceId === workspaceId,
)!.agent;
expect(nestedAgent.cwd).toBe(`${workspace.workspaceDirectory}/nested`);
},
async closePendingAgent(agentId: string) {
await page.getByTestId(`workspace-tab-agent_${agentId}`).filter({ visible: true }).hover();
await page.getByTestId(`workspace-agent-close-${agentId}`).filter({ visible: true }).click();
await expect(
page.getByTestId(`workspace-tab-agent_${agentId}`).filter({ visible: true }),
).toHaveCount(0);
},
async openWorkspaceForm(isolation: "local" | "worktree") {
await gotoAppShell(page);
await waitForSidebarHydration(page);
@@ -165,6 +284,74 @@ export async function createCreationScenario(page: Page) {
if (prompt) await fillComposerDraft(page, prompt);
await pressSubmitBeforeTheNextRender(page, button);
},
async expectNormalAgentComposer() {
await expect(agentComposer(page)).toBeEditable();
// Provisioning belongs to the workspace header and sidebar, not the conversation.
await expect(page.getByText("Creating workspace and agent…", { exact: true })).toHaveCount(0);
},
async expectAgentCannotBeStoppedYet() {
await expect(
page.getByRole("button", { name: "Stop agent" }).filter({ visible: true }),
).toBeDisabled();
},
async queueFollowUp(prompt: string) {
await agentComposer(page).fill(prompt);
await agentComposer(page).press("Enter");
await expect(
page.getByRole("button", { name: "Send queued message now" }).filter({ visible: true }),
).toBeVisible();
await expect(page.getByText(prompt, { exact: true }).filter({ visible: true })).toBeVisible();
},
async expectQueuedFollowUp(prompt: string) {
await expect(
page.getByRole("button", { name: "Send queued message now" }).filter({ visible: true }),
).toHaveCount(1);
await expect(page.getByText(prompt, { exact: true }).filter({ visible: true })).toBeVisible();
},
async resubmitWorkspace() {
await page.getByRole("button", { name: "Create", exact: true }).click();
},
async expectAgentConfig(config: { model: string; modeId: string }) {
const agent = (await project.client.fetchAgents()).entries.find(
(entry) => entry.agent.workspaceId === workspaceId,
)!.agent;
expect({ model: agent.model, modeId: agent.currentModeId }).toEqual(config);
},
async typeComposerDraft(text: string) {
await agentComposer(page).fill(text);
// The composer publishes typed text to its draft after the next paint.
await page.evaluate(
() => new Promise((resolve) => requestAnimationFrame(() => setTimeout(resolve, 0))),
);
},
async expectComposerDraft(text: string) {
await expect(agentComposer(page)).toHaveValue(text);
},
async expectAgentNotWorking() {
await expect(
page.getByTestId("turn-working-indicator").filter({ visible: true }),
).toHaveCount(0);
},
async expectTimelineSettled() {
await expect(page.getByText("Updating messages", { exact: true })).toHaveCount(0);
},
async expectUserMessagesInOrder(prompts: string[]) {
const agent = (await project.client.fetchAgents()).entries.find(
(entry) => entry.agent.workspaceId === workspaceId,
);
const reader = await connectNewWorkspaceDaemonClient();
try {
await expect
.poll(async () =>
(await reader.fetchAgentTimeline(agent!.agent.id)).entries.flatMap(({ item }) =>
item.type === "user_message" ? [item.text] : [],
),
)
.toEqual(prompts);
} finally {
await reader.close();
}
},
async expectPromptVisible(prompt?: string) {
// Inactive agent tabs retain their timeline DOM beside the visible draft.
const rows = page.getByTestId("user-message").filter({ visible: true });
@@ -175,7 +362,7 @@ export async function createCreationScenario(page: Page) {
await expect(page.getByTestId("user-message").first()).toBeVisible();
await expect(
page
.locator('[data-testid^="workspace-tab-draft_"][aria-selected="true"]')
.locator('[data-testid^="workspace-tab-agent_"][aria-selected="true"]')
.filter({ visible: true }),
).toBeVisible();
},
@@ -224,15 +411,57 @@ export async function createCreationScenario(page: Page) {
.first(),
).toBeVisible();
},
async expectStartupFailure() {
await expect(page.getByText(/Creation startup failed for test/).first()).toBeVisible();
async expectFailedAgentDraft(prompt: string) {
await requests.settled();
await expect(
page
.locator('[data-testid^="workspace-tab-draft_"][aria-selected="true"]')
.filter({ visible: true }),
).toBeVisible();
await expect(
page
.getByText(/Creation startup failed for test/)
.filter({ visible: true })
.first(),
).toBeVisible();
await expect(agentComposer(page)).toHaveValue(prompt);
await this.expectNoCreationRecovery();
},
async resendFailedDraft() {
await page.getByRole("button", { name: "Send message", exact: true }).click();
},
async expectReturnedToNewWorkspace(prompt: string, error: string) {
await requests.settled();
await expect(page).toHaveURL(/\/new\?/);
await expect(agentComposer(page)).toHaveValue(prompt);
await expect(page.getByText(error).filter({ visible: true }).first()).toBeVisible();
await this.expectNoCreationRecovery();
expect(requests.workspaceRequests()).toBe(1);
expect(
(await project.client.fetchWorkspaces()).entries.filter(
(workspace) =>
workspace.projectId === project.projectId &&
workspace.id !== project.workspaceId &&
!evictionWorkspaceIds.has(workspace.id),
),
).toHaveLength(0);
},
async expectNoCreationRecovery() {
await expect(page.getByRole("button", { name: "Retry creation", exact: true })).toHaveCount(
0,
);
await expect(page.getByRole("button", { name: "Return to draft", exact: true })).toHaveCount(
0,
);
},
async expectOneCreatedWorkspace() {
await expect(page).toHaveURL(/\/workspace\//);
await requests.settled();
const workspaces = (await project.client.fetchWorkspaces()).entries.filter(
(workspace) =>
workspace.projectId === project.projectId && workspace.id !== project.workspaceId,
workspace.projectId === project.projectId &&
workspace.id !== project.workspaceId &&
!evictionWorkspaceIds.has(workspace.id),
);
expect(workspaces).toHaveLength(1);
workspaceId = workspaces[0]!.id;
@@ -265,8 +494,11 @@ export async function createCreationScenario(page: Page) {
.toBe(title);
},
async evictAndReturnToDraft() {
workspaceId = new URL(page.url()).pathname.split("/workspace/")[1]!;
const draft = page
.locator('[data-testid^="workspace-tab-draft_"][aria-selected="true"]')
.locator(
'[data-testid^="workspace-tab-draft_"][aria-selected="true"], [data-testid^="workspace-tab-agent_"][aria-selected="true"]',
)
.filter({ visible: true })
.first();
const draftTestId = await draft.getAttribute("data-testid");
@@ -276,6 +508,7 @@ export async function createCreationScenario(page: Page) {
source: { kind: "directory", path: project.repoPath },
});
if (!result.workspace) throw new Error(result.error ?? "Failed to seed eviction workspace");
evictionWorkspaceIds.add(result.workspace.id);
await switchWorkspaceViaSidebar({
page,
serverId: getServerId(),
@@ -296,15 +529,18 @@ export async function createCreationScenario(page: Page) {
export function createPromptRetryScenario(
page: Page,
gate: Awaited<ReturnType<typeof installDaemonWebSocketGate>>,
kind: "agent" | "workspace" = "agent",
) {
const requestType = `${kind}.create.request`;
const responseType = `${kind}.create.response`;
let firstMessage: ReturnType<typeof gate.getClientRequests>[number] | undefined;
return {
holdAcknowledgement() {
gate.holdNextServerMessage("agent.create.response");
gate.holdNextServerMessage(responseType);
},
async waitForDeliveredPrompt() {
await gate.waitForHeldServerMessage("agent.create.response");
firstMessage = gate.getClientRequests("agent.create.request").at(-1);
await gate.waitForHeldServerMessage(responseType);
firstMessage = gate.getClientRequests(requestType).at(-1);
},
async disconnectAndReconnect() {
const fetches = gate.getClientRequestCount("fetch_agents_request");
@@ -315,13 +551,89 @@ export function createPromptRetryScenario(
.toBeGreaterThan(fetches);
},
async expectSameAgentAndMessage() {
expect(gate.getClientRequestCount("agent.create.request")).toBe(1);
expect(gate.getClientRequestCount(requestType)).toBe(1);
await expect
.poll(() => gate.getClientRequestCount("creation.subscribe.request"))
.toBeGreaterThan(0);
expect(gate.getClientRequests("agent.create.request").at(-1)).toEqual(firstMessage);
expect(gate.getClientRequests(requestType).at(-1)).toEqual(firstMessage);
expect(gate.getClientRequestCount("send_agent_message_request")).toBe(0);
await expect(page.getByTestId("user-message").filter({ visible: true })).toHaveCount(1);
},
};
}
export async function hideCreationLifecycle(page: Page) {
const frames = await loadSessionMessageReaders();
let requests = 0;
await page.routeWebSocket(daemonWsRoutePattern(), (browser) => {
const server = browser.connectToServer();
browser.onMessage((frame) => {
if (frames.client(frame)?.type === "workspace.create.request") requests++;
server.send(frame);
});
server.onMessage((frame) => {
const message = frames.server(frame);
if (
message?.type === "status" &&
message.payload.status === "server_info" &&
typeof message.payload.features === "object" &&
message.payload.features !== null
) {
browser.send(
JSON.stringify({
type: "session",
message: {
...message,
payload: {
...message.payload,
features: { ...message.payload.features, creationLifecycle: false },
},
},
}),
);
return;
}
browser.send(frame);
});
});
return { expectNoCreationRequest: () => expect(requests).toBe(0) };
}
/** Simulate an uncertain receipt after the real daemon registered and ran the initial agent. */
export async function reportUnknownPromptOutcome(page: Page) {
const frames = await loadSessionMessageReaders();
await page.routeWebSocket(daemonWsRoutePattern(), (browser) => {
const server = browser.connectToServer();
browser.onMessage((frame) => server.send(frame));
server.onMessage((frame) => {
const message = frames.server(frame);
if (
message?.type === "workspace.create.response" &&
message.payload.agent &&
message.payload.creation
) {
const error = "Initial prompt outcome is uncertain for test";
browser.send(
JSON.stringify({
...JSON.parse(frame.toString()),
message: {
...message,
payload: {
...message.payload,
error,
creation: {
...message.payload.creation,
phase: "failed",
error,
outcomeUnknown: true,
},
},
},
}),
);
return;
}
browser.send(frame);
});
});
}
@@ -18,6 +18,7 @@ type NewWorkspaceDaemonClient = Pick<
| "createPaseoWorktree"
| "createWorkspace"
| "fetchAgents"
| "fetchAgentTimeline"
| "fetchWorkspaces"
| "getPaseoWorktreeList"
| "getDaemonConfig"
+1
View File
@@ -61,6 +61,7 @@
"@gorhom/portal": "^1.0.14",
"@marijn/find-cluster-break": "^1.0.2",
"@mattermost/react-native-paste-input": "2.0.1",
"@noble/hashes": "^2.4.0",
"@react-native-async-storage/async-storage": "2.2.0",
"@react-native-masked-view/masked-view": "0.3.2",
"@react-native/normalize-colors": "0.86.3",
+6 -2
View File
@@ -1,3 +1,4 @@
import { useAgentCreation } from "@/workspace-creation";
import { useShallow } from "zustand/shallow";
import { useProviderIcon } from "@/components/provider-icons";
import type { PanelDescriptor } from "@/panels/panel-registry";
@@ -33,6 +34,7 @@ export function useAgentDescriptor(
target: { kind: "agent"; agentId: string },
context: { serverId: string },
): PanelDescriptor {
const creation = useAgentCreation(context.serverId, target.agentId);
const descriptorState = useSessionStore(
useShallow((state) => {
const session = state.sessions[context.serverId];
@@ -49,8 +51,10 @@ export function useAgentDescriptor(
};
}),
);
const icon = useProviderIcon(descriptorState.provider, context.serverId);
return buildAgentDescriptor({ ...descriptorState, icon });
const provider = creation ? creation.provider : descriptorState.provider;
const title = creation ? creation.text : descriptorState.title;
const icon = useProviderIcon(provider, context.serverId);
return buildAgentDescriptor({ ...descriptorState, provider, title, icon });
}
export function buildAgentDescriptor(input: {
@@ -1,3 +1,5 @@
import { useWorkspaceCreation } from "@/workspace-creation";
import { CreatingWorkspaceRow } from "@/workspace-creation/ui";
import { memo, useCallback, useMemo, useState, type Ref } from "react";
import { useTranslation } from "react-i18next";
import { View, Text, type GestureResponderEvent } from "react-native";
@@ -58,7 +60,16 @@ interface SidebarWorkspaceRowProps {
dragHandleProps?: DraggableListDragHandleProps;
}
export function SidebarWorkspaceRow({
export function SidebarWorkspaceRow(props: SidebarWorkspaceRowProps) {
const creation = useWorkspaceCreation(props.workspace.serverId, props.workspace.workspaceId);
if (creation && !creation.workspaceObserved)
return (
<CreatingWorkspaceRow attempt={creation} onPress={props.onPress} selected={props.selected} />
);
return <ReadySidebarWorkspaceRow {...props} />;
}
function ReadySidebarWorkspaceRow({
workspace,
selected,
shortcutNumber,
+82
View File
@@ -296,6 +296,7 @@ describe("cancelComposerAgent", () => {
function baseInput(): {
client: ComposerCancelClient & { canceledIds: string[] };
agentId: string;
isAgentPublished: boolean;
isAgentRunning: boolean;
isCancellingAgent: boolean;
isConnected: boolean;
@@ -309,6 +310,7 @@ describe("cancelComposerAgent", () => {
},
},
agentId: "agent",
isAgentPublished: true,
isAgentRunning: true,
isCancellingAgent: false,
isConnected: true,
@@ -340,6 +342,13 @@ describe("cancelComposerAgent", () => {
expect(input.client.canceledIds).toEqual([]);
});
it("does nothing while the host has not published the agent", () => {
const input = baseInput();
const result = cancelComposerAgent({ ...input, isAgentPublished: false });
expect(result).toBeNull();
expect(input.client.canceledIds).toEqual([]);
});
it("does nothing when the agent is already being canceled", () => {
const input = baseInput();
const result = cancelComposerAgent({ ...input, isCancellingAgent: true });
@@ -419,6 +428,29 @@ describe("pickAndPersistImages", () => {
});
describe("dispatchComposerAgentMessage", () => {
it("queues a prompt behind earlier ones without sending while the agent is not ready", async () => {
const client = createFakeSendClient();
const stream = createFakeStream();
const queued: QueuedComposerMessage = { id: "later", text: "later", attachments: [] };
const queue = createFakeQueue(new Map([["agent", [queued]]]));
const outcome = await dispatchComposerAgentMessage({
client,
agentId: "agent",
text: "next follow-up",
attachments: [],
encodeImages: passthroughEncodeImages,
submission: stream,
isAgentReady: false,
queue,
});
expect(outcome).toBe("queued");
expect(client.calls).toEqual([]);
expect(stream.tail.get("agent") ?? []).toEqual([]);
expect(queue.read("agent").map((item) => item.text)).toEqual(["later", "next follow-up"]);
});
it("forwards the configured active-turn intent without provider capability checks", async () => {
const client = createFakeSendClient();
const stream = createFakeStream();
@@ -430,6 +462,8 @@ describe("dispatchComposerAgentMessage", () => {
attachments: [],
encodeImages: async () => [],
submission: stream,
isAgentReady: true,
queue: createFakeQueue(),
activeTurnBehavior: "steer",
});
@@ -446,6 +480,8 @@ describe("dispatchComposerAgentMessage", () => {
attachments: [],
encodeImages: async () => [],
submission: stream,
isAgentReady: true,
queue: createFakeQueue(),
activeTurnBehavior: "steer",
activeTurnId: "turn-1",
});
@@ -458,6 +494,8 @@ describe("dispatchComposerAgentMessage", () => {
attachments: [],
encodeImages: async () => [],
submission: legacy,
isAgentReady: true,
queue: createFakeQueue(),
activeTurnBehavior: "steer",
});
expect(legacy.tail.get("legacy")?.[0]?.turnId).toBeUndefined();
@@ -476,6 +514,8 @@ describe("dispatchComposerAgentMessage", () => {
attachments: [],
encodeImages: passthroughEncodeImages,
submission: stream,
isAgentReady: true,
queue: createFakeQueue(),
}),
).rejects.toBe(rejection);
@@ -496,6 +536,8 @@ describe("dispatchComposerAgentMessage", () => {
attachments: [],
encodeImages: passthroughEncodeImages,
submission: stream,
isAgentReady: true,
queue: createFakeQueue(),
}),
).rejects.toBe(transportError);
@@ -519,6 +561,8 @@ describe("dispatchComposerAgentMessage", () => {
attachments: [],
encodeImages: passthroughEncodeImages,
submission,
isAgentReady: true,
queue: createFakeQueue(),
}),
).rejects.toBe(transportError);
});
@@ -540,6 +584,8 @@ describe("dispatchComposerAgentMessage", () => {
attachments: [],
encodeImages: passthroughEncodeImages,
submission,
isAgentReady: true,
queue: createFakeQueue(),
activeTurnBehavior: "steer",
}),
).rejects.toBe(transportError);
@@ -560,6 +606,8 @@ describe("dispatchComposerAgentMessage", () => {
],
encodeImages: passthroughEncodeImages,
submission: stream,
isAgentReady: true,
queue: createFakeQueue(),
});
expect(client.calls).toHaveLength(1);
@@ -606,6 +654,8 @@ describe("dispatchComposerAgentMessage", () => {
attachmentSubmitFormat: "legacy-github",
encodeImages: passthroughEncodeImages,
submission: stream,
isAgentReady: true,
queue: createFakeQueue(),
});
expect(client.calls[0].options.attachments).toEqual([
@@ -639,6 +689,8 @@ describe("dispatchComposerAgentMessage", () => {
attachments: [],
encodeImages: passthroughEncodeImages,
submission: stream,
isAgentReady: true,
queue: createFakeQueue(),
});
expect(stream.head.get("agent")).toHaveLength(2);
@@ -656,6 +708,8 @@ describe("dispatchComposerAgentMessage", () => {
attachments: [],
encodeImages: passthroughEncodeImages,
submission: stream,
isAgentReady: true,
queue: createFakeQueue(),
});
expect(client.calls[0]?.options).toMatchObject({
@@ -676,6 +730,8 @@ describe("dispatchComposerAgentMessage", () => {
attachments: [review],
encodeImages: passthroughEncodeImages,
submission: stream,
isAgentReady: true,
queue: createFakeQueue(),
});
expect(client.calls[0]?.options.attachments).toEqual([review.attachment]);
@@ -694,6 +750,8 @@ describe("dispatchComposerAgentMessage", () => {
attachments: [browserElement],
encodeImages: passthroughEncodeImages,
submission: stream,
isAgentReady: true,
queue: createFakeQueue(),
});
expect(client.calls[0]?.options.attachments).toEqual([
@@ -796,6 +854,7 @@ describe("sendQueuedComposerMessageNow", () => {
const submitted: Array<{ text: string; attachments: ComposerAttachment[] }> = [];
const result = await sendQueuedComposerMessageNow({
agentId: "agent",
isAgentReady: true,
messageId: "msg-1",
queue,
submitMessage: async (input) => {
@@ -814,6 +873,7 @@ describe("sendQueuedComposerMessageNow", () => {
const submitted: Array<{ text: string; attachments: ComposerAttachment[] }> = [];
const result = await sendQueuedComposerMessageNow({
agentId: "agent",
isAgentReady: true,
messageId: "msg-1",
queue,
submitMessage: async (input) => {
@@ -825,6 +885,27 @@ describe("sendQueuedComposerMessageNow", () => {
expect(submitted).toEqual([{ text: "send me", attachments: [review] }]);
});
it("leaves the queue untouched while the agent is not ready", async () => {
const queued: QueuedComposerMessage[] = [
{ id: "msg-1", text: "first", attachments: [] },
{ id: "msg-2", text: "second", attachments: [] },
];
const queue = createFakeQueue(new Map([["agent", queued]]));
const submitted: Array<{ text: string; attachments: ComposerAttachment[] }> = [];
const result = await sendQueuedComposerMessageNow({
agentId: "agent",
isAgentReady: false,
messageId: "msg-2",
queue,
submitMessage: async (input) => {
submitted.push(input);
},
});
expect(result).toEqual({ status: "waiting" });
expect(submitted).toEqual([]);
expect(queue.state.get("agent")).toBe(queued);
});
it("restores the queued entry to the front and surfaces the error message on failure", async () => {
const queue = createFakeQueue(
new Map([
@@ -839,6 +920,7 @@ describe("sendQueuedComposerMessageNow", () => {
);
const result = await sendQueuedComposerMessageNow({
agentId: "agent",
isAgentReady: true,
messageId: "msg-1",
queue,
submitMessage: async () => {
+16 -2
View File
@@ -170,13 +170,14 @@ export function removeComposerAttachmentAtIndex<T extends ComposerAttachment>(in
export interface CancelComposerAgentInput {
client: ComposerCancelClient | null;
agentId: string;
isAgentPublished: boolean;
isAgentRunning: boolean;
isCancellingAgent: boolean;
isConnected: boolean;
}
export function cancelComposerAgent(input: CancelComposerAgentInput): Promise<void> | null {
if (!input.isAgentRunning || input.isCancellingAgent) return null;
if (!input.isAgentPublished || !input.isAgentRunning || input.isCancellingAgent) return null;
if (!input.isConnected || !input.client) return null;
try {
return Promise.resolve(input.client.cancelAgent(input.agentId));
@@ -188,6 +189,9 @@ export function cancelComposerAgent(input: CancelComposerAgentInput): Promise<vo
export interface DispatchComposerAgentMessageInput {
client: ComposerSendClient;
agentId: string;
/** See isAgentReadyForPrompts. Prompts for an agent that is not ready wait in its queue. */
isAgentReady: boolean;
queue: QueueWriter;
text: string;
attachments: ComposerAttachment[];
attachmentSubmitFormat?: ComposerAttachmentSubmitFormat;
@@ -201,7 +205,12 @@ export interface DispatchComposerAgentMessageInput {
export async function dispatchComposerAgentMessage(
input: DispatchComposerAgentMessageInput,
): Promise<void> {
): Promise<"sent" | "queued"> {
if (!input.isAgentReady) {
// A creating agent takes prompts after its initial one; this waits behind it in the queue.
queueComposerMessage(input);
return "queued";
}
const wirePayload = splitComposerAttachmentsForSubmit(input.attachments, {
format: input.attachmentSubmitFormat,
});
@@ -226,6 +235,7 @@ export async function dispatchComposerAgentMessage(
attachments: wirePayload.attachments,
});
input.submission.accept(input.agentId, clientMessageId);
return "sent";
} catch (error) {
input.submission.reject(input.agentId, clientMessageId);
throw error;
@@ -293,6 +303,8 @@ export function editQueuedComposerMessage(
export interface SendQueuedComposerMessageNowInput {
agentId: string;
/** See DispatchComposerAgentMessageInput. A queued prompt stays in place until then. */
isAgentReady: boolean;
messageId: string;
queue: QueueWriter;
submitMessage: (input: { text: string; attachments: ComposerAttachment[] }) => Promise<void>;
@@ -301,6 +313,7 @@ export interface SendQueuedComposerMessageNowInput {
export type SendQueuedComposerMessageNowResult =
| { status: "missing" }
| { status: "waiting" }
| { status: "submitted" }
| { status: "failed"; errorMessage: string };
@@ -309,6 +322,7 @@ export async function sendQueuedComposerMessageNow(
): Promise<SendQueuedComposerMessageNowResult> {
const item = input.queue.read(input.agentId).find((q) => q.id === input.messageId);
if (!item) return { status: "missing" };
if (!input.isAgentReady) return { status: "waiting" };
input.queue.write((prev) => {
const next = new Map(prev);
next.set(
@@ -35,6 +35,7 @@ import {
} from "@/provider-selection/provider-selection";
import { filterSelectableModels } from "@/provider-selection/model-catalog";
import { useSessionStore } from "@/stores/session-store";
import { useAgentCreation } from "@/workspace-creation";
import { useProvidersSnapshot } from "@/hooks/use-providers-snapshot";
import { resolveProviderDefinition } from "@/utils/provider-definitions";
import { mergeProviderPreferences, useFormPreferences } from "@/hooks/use-form-preferences";
@@ -420,6 +421,56 @@ function selectAgentControlsSlice(
};
}
/** A creating agent shows its submitted configuration, read-only, until the host publishes it. */
function useAgentControlsSource(serverId: string, agentId: string) {
const publishedAgent = useSessionStore(
useShallow((state) => selectAgentControlsSlice(state, serverId, agentId)),
);
const creation = useAgentCreation(serverId, agentId);
const hasClient = useSessionStore((state) => Boolean(state.sessions[serverId]?.client));
const agent = useMemo<AgentControlsSlice>(() => {
if (publishedAgent || !creation) return publishedAgent;
return {
provider: creation.provider,
// The destination is not known yet; never stand in the source directory for it.
cwd: null,
runtimeModelId: null,
model: creation.config.model,
features: undefined,
runtimeThinkingOptionId: null,
thinkingOptionId: creation.config.thinkingOptionId,
lastUsage: undefined,
};
}, [creation, publishedAgent]);
return {
agent,
isPublished: publishedAgent !== null,
canEdit: hasClient && publishedAgent !== null,
submittedModeId: creation?.config.modeId ?? null,
};
}
function ignoreModeSelection(): void {}
function resolveModeControl(input: {
live: AgentModeControlValue | null;
isPublished: boolean;
submittedModeId: string | null;
entry: ReturnType<typeof resolveSnapshotSelectedEntry>;
providerDefinitions: AgentProviderDefinition[];
}): AgentModeControlValue | null {
if (input.isPublished) return input.live;
if (input.entry?.status !== "ready" || !input.entry.modes?.length) return null;
return {
provider: input.entry.provider,
providerDefinitions: input.providerDefinitions,
modeOptions: input.entry.modes,
selectedModeId: input.submittedModeId,
onSelectMode: ignoreModeSelection,
disabled: true,
};
}
function resolveSnapshotSelectedEntry(
snapshotEntries: ReturnType<typeof useProvidersSnapshot>["entries"],
agentProvider: string | undefined,
@@ -1556,14 +1607,13 @@ export const AgentControls = memo(function AgentControls({
isCompactLayout,
}: AgentControlsProps) {
const { updatePreferences } = useFormPreferences();
const agent = useSessionStore(
useShallow((state) => selectAgentControlsSlice(state, serverId, agentId)),
const { agent, isPublished, canEdit, submittedModeId } = useAgentControlsSource(
serverId,
agentId,
);
const client = useSessionStore((state) => state.sessions[serverId]?.client ?? null);
const toast = useToast();
const modeControl = useLiveAgentModeControl(serverId, agentId);
const commandCenterModes = toCommandCenterModes(modeControl);
const modeProviderDefinitions = getModeProviderDefinitions(modeControl);
const liveModeControl = useLiveAgentModeControl(serverId, agentId);
const {
entries: snapshotEntries,
@@ -1586,6 +1636,26 @@ export const AgentControls = memo(function AgentControls({
[agent?.provider, snapshotEntries],
);
const modeControl = useMemo(
() =>
resolveModeControl({
live: liveModeControl,
isPublished,
submittedModeId,
entry: snapshotSelectedEntry,
providerDefinitions: agentProviderDefinitions,
}),
[
agentProviderDefinitions,
isPublished,
liveModeControl,
snapshotSelectedEntry,
submittedModeId,
],
);
const commandCenterModes = toCommandCenterModes(modeControl);
const modeProviderDefinitions = getModeProviderDefinitions(modeControl);
const agentProviderModels = useMemo(
() => buildAgentProviderModels(agent?.provider, models),
[agent?.provider, models],
@@ -1769,7 +1839,7 @@ export const AgentControls = memo(function AgentControls({
const commandCenterRegistration = (
<AgentControlCommandCenterRegistration
sourceId={`agent:${serverId}:${agentId}`}
enabled={Boolean(client)}
enabled={canEdit}
controls={commandCenterControls}
/>
);
@@ -1814,7 +1884,7 @@ export const AgentControls = memo(function AgentControls({
onRetryModelProvider={handleRetryModelProvider}
isRetryingModelProvider={snapshotIsRefreshing}
onDropdownClose={onDropdownClose}
disabled={!client}
disabled={!canEdit}
modeControl={modeControl}
modelSelectorServerId={serverId}
isCompactLayout={isCompactLayout}
@@ -49,6 +49,7 @@ import {
} from "@/workspace-tabs/model";
import { openWorkspaceChanges } from "@/workspace-tabs/open-supporting-view";
import { useSettings } from "@/hooks/use-settings";
import { transferComposerFollowUps } from "@/composer/submission/writer";
const EMPTY_PENDING_PERMISSIONS = new Map();
const DRAFT_CAPABILITIES: AgentCapabilityFlags = {
@@ -184,8 +185,7 @@ async function submitDraftCreateRequest(input: {
...(imagesData && imagesData.length > 0 ? { images: imagesData } : {}),
...(attachmentsArray && attachmentsArray.length > 0 ? { attachments: attachmentsArray } : {}),
};
const creation = useWorkspaceDraftSubmissionStore.getState().creationByDraftId[input.draftId];
const result = creation ? await creation.retry(options) : await client.createAgent(options);
const result = await client.createAgent(options);
return {
agentId: result.id,
@@ -466,10 +466,6 @@ export function WorkspaceDraftAgentTab({
selectModelMessage: t("workspaceSetup.errors.selectModel"),
}),
createRequest: async ({ attempt, text, images, attachments, cwd }) => {
if (pendingAutoSubmit?.agentCreation) {
const result = await pendingAutoSubmit.agentCreation.result;
return { agentId: result.id, result };
}
return submitDraftCreateRequest({
draftId,
attempt,
@@ -491,6 +487,7 @@ export function WorkspaceDraftAgentTab({
clearWorkspaceAttachments({ scopeKey: draftAttachmentScopeKey });
useWorkspaceDraftSubmissionStore.getState().clearDraftSetup({ draftId });
onCreated(result);
transferComposerFollowUps(serverId, tabId, result.id);
},
});
const turnPresentation = useMemo(
+31 -25
View File
@@ -58,6 +58,7 @@ import { useImageAttachmentPicker } from "@/hooks/use-image-attachment-picker";
import {
selectAgentSentMessages,
selectAgentTurnPresentation,
selectIsAgentPublished,
useSessionStore,
} from "@/stores/session-store";
import { useFilePicker } from "@/hooks/use-file-picker";
@@ -88,9 +89,9 @@ import {
toggleForgeAttachmentFromPicker,
uploadFileAttachments,
type AttachmentPersister,
type QueueWriter,
type QueuedComposerMessage,
} from "@/composer/actions";
import { isAgentReadyForPrompts } from "@/workspace-creation";
import { useVoiceOptional } from "@/contexts/voice-context";
import { useToast } from "@/contexts/toast-context";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
@@ -123,7 +124,10 @@ import type { KeyboardActionDefinition } from "@/keyboard/keyboard-action-dispat
import type { MessageInputKeyboardActionKind } from "@/keyboard/actions";
import { submitAgentInput } from "@/composer/submit";
import { ComposerRecall } from "@/composer/recall";
import { createMessageSubmissionWriter } from "@/composer/submission/writer";
import {
createAgentMessageQueueWriter,
createMessageSubmissionWriter,
} from "@/composer/submission/writer";
import { ComposerKeyboardScopeProvider, useComposerKeyboardScope } from "@/composer/keyboard-scope";
import { useAppSettings } from "@/hooks/use-settings";
import { RenderProfile } from "@/utils/render-profiler";
@@ -258,8 +262,8 @@ function resolveCheckoutRemoteUrl(
return checkoutStatus?.remoteUrl ?? null;
}
function buildCancelButtonStyle(isConnected: boolean, isCancellingAgent: boolean): object[] {
const disabled = !isConnected || isCancellingAgent ? styles.buttonDisabled : undefined;
function buildCancelButtonStyle(canCancel: boolean, isCancellingAgent: boolean): object[] {
const disabled = !canCancel || isCancellingAgent ? styles.buttonDisabled : undefined;
return [styles.cancelButton, disabled].filter((value): value is object => Boolean(value));
}
@@ -1083,7 +1087,7 @@ interface ComposerCancelButtonProps {
buttonIconSize: number;
cancelButtonStyle: (object | undefined)[];
handleCancelAgent: () => void;
isConnected: boolean;
canCancel: boolean;
isCancellingAgent: boolean;
agentInterruptKeys: ReturnType<typeof useShortcutKeys>;
t: TFunction;
@@ -1093,7 +1097,7 @@ function ComposerCancelButton({
buttonIconSize,
cancelButtonStyle,
handleCancelAgent,
isConnected,
canCancel,
isCancellingAgent,
agentInterruptKeys,
t,
@@ -1111,7 +1115,7 @@ function ComposerCancelButton({
<Tooltip delayDuration={0} enabledOnDesktop enabledOnMobile={false}>
<TooltipTrigger
onPress={handleCancelAgent}
disabled={!isConnected || isCancellingAgent}
disabled={!canCancel || isCancellingAgent}
accessibilityLabel={accessibilityLabel}
accessibilityRole="button"
style={cancelButtonStyle}
@@ -1306,8 +1310,6 @@ function ComposerContentImpl({
);
const queuedMessages = queuedMessagesRaw ?? EMPTY_ARRAY;
const setQueuedMessages = useSessionStore((state) => state.setQueuedMessages);
const isCompactFormFactor = useIsCompactFormFactor();
const isCompactLayout = resolveCompactLayout(isCompactLayoutOverride, isCompactFormFactor);
const isDesktopWebBreakpoint = resolveIsDesktopWebBreakpoint(isCompactFormFactor);
@@ -1570,9 +1572,12 @@ function ComposerContentImpl({
if (!client) {
throw new Error(t("workspace.terminal.hostDisconnected"));
}
const session = useSessionStore.getState().sessions[serverId];
await dispatchComposerAgentMessage({
client,
agentId: targetAgentId,
isAgentReady: isAgentReadyForPrompts(serverId, targetAgentId),
queue: createAgentMessageQueueWriter(serverId),
text,
attachments: sendAttachments,
attachmentSubmitFormat: resolveComposerAttachmentSubmitFormat({
@@ -1583,10 +1588,7 @@ function ComposerContentImpl({
activeTurnBehavior,
activeTurnId:
activeTurnBehavior === "steer"
? (selectAgentTurnPresentation(
useSessionStore.getState().sessions[serverId],
targetAgentId,
).turnId ?? undefined)
? (selectAgentTurnPresentation(session, targetAgentId).turnId ?? undefined)
: undefined,
});
onAttentionPromptSend?.();
@@ -1620,13 +1622,7 @@ function ComposerContentImpl({
);
const hasAgent = agentState.status !== null;
const queueWriter = useMemo<QueueWriter>(
() => ({
read: (id) => useSessionStore.getState().sessions[serverId]?.queuedMessages?.get(id) ?? [],
write: (updater) => setQueuedMessages(serverId, updater),
}),
[serverId, setQueuedMessages],
);
const queueWriter = useMemo(() => createAgentMessageQueueWriter(serverId), [serverId]);
const queueMessage = useCallback(
(queuedMessage: string, queuedAttachments: ComposerAttachment[]) => {
@@ -1896,6 +1892,10 @@ function ComposerContentImpl({
const cancellation = cancelComposerAgent({
client,
agentId: targetAgentId,
isAgentPublished: selectIsAgentPublished(
useSessionStore.getState().sessions[serverId],
targetAgentId,
),
isAgentRunning,
isCancellingAgent,
isConnected,
@@ -1952,6 +1952,7 @@ function ComposerContentImpl({
// Reuse the regular send path; server-side send atomically interrupts any active run.
const result = await sendQueuedComposerMessageNow({
agentId,
isAgentReady: isAgentReadyForPrompts(serverId, agentId),
messageId: id,
queue: queueWriter,
submitMessage: ({ text, attachments: queuedAttachments }) =>
@@ -1962,7 +1963,7 @@ function ComposerContentImpl({
setSendError(result.errorMessage);
}
},
[agentId, queueWriter, submitMessage, t],
[agentId, queueWriter, serverId, submitMessage, t],
);
const handleQueue = useCallback(
@@ -2010,9 +2011,14 @@ function ComposerContentImpl({
[agentId, recall, replaceUserInput, serverId],
);
const isAgentPublished = useSessionStore((state) =>
selectIsAgentPublished(state.sessions[serverId], agentId),
);
// A creating agent cannot be stopped until the host publishes it.
const canCancelAgent = isConnected && isAgentPublished;
const cancelButtonStyle = useMemo(
() => buildCancelButtonStyle(isConnected, isCancellingAgent),
[isConnected, isCancellingAgent],
() => buildCancelButtonStyle(canCancelAgent, isCancellingAgent),
[canCancelAgent, isCancellingAgent],
);
const isVoiceSwitching = voice?.isVoiceSwitching ?? false;
@@ -2029,7 +2035,7 @@ function ComposerContentImpl({
buttonIconSize={buttonIconSize}
cancelButtonStyle={cancelButtonStyle}
handleCancelAgent={handleCancelAgent}
isConnected={isConnected}
canCancel={canCancelAgent}
isCancellingAgent={isCancellingAgent}
agentInterruptKeys={agentInterruptKeys}
t={t}
@@ -2039,9 +2045,9 @@ function ComposerContentImpl({
agentInterruptKeys,
buttonIconSize,
cancelButtonStyle,
canCancelAgent,
handleCancelAgent,
isCancellingAgent,
isConnected,
t,
],
);
@@ -2,7 +2,9 @@ import { afterEach, expect, test } from "vitest";
import type { DaemonClient } from "@getpaseo/client/internal/daemon-client";
import { useSessionStore } from "@/stores/session-store";
import type { UserMessageItem } from "@/types/stream";
import { createMessageSubmissionWriter } from "./writer";
import { useDraftStore } from "@/stores/draft-store";
import { buildDraftStoreKey } from "@/stores/draft-keys";
import { createMessageSubmissionWriter, transferComposerFollowUps } from "./writer";
const CANONICAL_ONLY_SERVER_ID = "canonical-only";
@@ -47,3 +49,59 @@ test("tracks submissions when canonical prompts are supported", () => {
expect(submissionIds(CANONICAL_ONLY_SERVER_ID)).toEqual(["canonical-only-message"]);
});
const TRANSFER_SERVER_ID = "transfer";
function queued(id: string) {
return { id, text: `follow-up ${id}`, attachments: [] };
}
function queueIds(agentId: string): string[] {
return (
useSessionStore.getState().sessions[TRANSFER_SERVER_ID]?.queuedMessages.get(agentId) ?? []
).map((item) => item.id);
}
function draftText(agentId: string): string | undefined {
return useDraftStore
.getState()
.getDraftInput(buildDraftStoreKey({ serverId: TRANSFER_SERVER_ID, agentId }))?.text;
}
function typeDraft(agentId: string, text: string): void {
useDraftStore.getState().saveDraftInput({
draftKey: buildDraftStoreKey({ serverId: TRANSFER_SERVER_ID, agentId }),
draft: { text, attachments: [] },
});
}
test("follow-ups move with their IDs and order behind the destination's, and only once", () => {
initializeSession(TRANSFER_SERVER_ID, {});
useSessionStore.getState().setQueuedMessages(
TRANSFER_SERVER_ID,
new Map([
["optimistic", [queued("a"), queued("b")]],
["recovery", [queued("earlier")]],
]),
);
typeDraft("optimistic", "typed but unsent");
transferComposerFollowUps(TRANSFER_SERVER_ID, "optimistic", "recovery");
transferComposerFollowUps(TRANSFER_SERVER_ID, "optimistic", "recovery");
transferComposerFollowUps(TRANSFER_SERVER_ID, "recovery", "recovery");
expect(queueIds("recovery")).toEqual(["earlier", "a", "b"]);
expect(queueIds("optimistic")).toEqual([]);
expect(draftText("recovery")).toBe("typed but unsent");
expect(draftText("optimistic")).toBeUndefined();
useSessionStore.getState().clearSession(TRANSFER_SERVER_ID);
});
test("unsent text never replaces a destination draft and stays at its source", () => {
initializeSession(TRANSFER_SERVER_ID, {});
typeDraft("source", "typed on the source");
typeDraft("destination", "already typed here");
transferComposerFollowUps(TRANSFER_SERVER_ID, "source", "destination");
expect(draftText("destination")).toBe("already typed here");
expect(draftText("source")).toBe("typed on the source");
useSessionStore.getState().clearSession(TRANSFER_SERVER_ID);
});
+49 -1
View File
@@ -1,5 +1,7 @@
import type { MessageSubmissionWriter } from "@/composer/actions";
import type { MessageSubmissionWriter, QueueWriter } from "@/composer/actions";
import { useSessionStore } from "@/stores/session-store";
import { useDraftStore, type DraftInput } from "@/stores/draft-store";
import { buildDraftStoreKey } from "@/stores/draft-keys";
import {
appendSubmittedUserMessage,
removeSubmittedUserMessage,
@@ -63,6 +65,14 @@ export function createMessageSubmissionWriter(serverId: string): MessageSubmissi
};
}
export function createAgentMessageQueueWriter(serverId: string): QueueWriter {
return {
read: (agentId) =>
useSessionStore.getState().sessions[serverId]?.queuedMessages.get(agentId) ?? [],
write: (update) => useSessionStore.getState().setQueuedMessages(serverId, update),
};
}
export function handoffCreatedAgentMessageSubmission(
serverId: string,
agentId: string,
@@ -70,3 +80,41 @@ export function handoffCreatedAgentMessageSubmission(
): boolean {
return useSessionStore.getState().handoffCreatedAgentUserMessage(serverId, agentId, message);
}
function isEmptyDraft(draft: DraftInput | undefined): boolean {
return !draft || (!draft.text.trim() && draft.attachments.length === 0);
}
/**
* Moves the queued follow-ups and the unsent agent draft of one composer identity to the identity
* that now owns them, such as a recovery draft or the agent it creates. Queued messages keep their
* IDs and order, after any already queued there; an idle destination agent drains them. The unsent
* draft moves only into an empty slot, and otherwise stays where it is. Repeating is harmless.
*/
export function transferComposerFollowUps(serverId: string, from: string, to: string): void {
if (from === to) return;
const session = useSessionStore.getState().sessions[serverId];
if (session?.queuedMessages.get(from)?.length) {
useSessionStore.getState().setQueuedMessages(serverId, (queues) => {
const moving = queues.get(from);
if (!moving?.length) return queues;
const next = new Map(queues);
next.set(to, [...(queues.get(to) ?? []), ...moving]);
next.delete(from);
return next;
});
const agent = session.agents.get(to) ?? session.agentDetails.get(to);
if (agent?.turn.phase === "idle") {
void import("@/runtime/host-runtime").then(({ getHostRuntimeStore }) =>
getHostRuntimeStore().drainQueuedAgentMessage(serverId, to),
);
}
}
const drafts = useDraftStore.getState();
const source = buildDraftStoreKey({ serverId, agentId: from });
const destination = buildDraftStoreKey({ serverId, agentId: to });
const draft = drafts.getDraftInput(source);
if (!draft || isEmptyDraft(draft) || !isEmptyDraft(drafts.getDraftInput(destination))) return;
drafts.saveDraftInput({ draftKey: destination, draft });
drafts.clearDraftInput({ draftKey: source });
}
@@ -1,3 +1,6 @@
import { useTranslation } from "react-i18next";
import { useWorkspaceCreations } from "@/workspace-creation";
import { creatingSidebarEntry } from "@/workspace-creation/presentation";
import { useMemo, useRef } from "react";
import { useStoreWithEqualityFn } from "zustand/traditional";
import { useCreateFlowStore } from "@/stores/create-flow-store";
@@ -19,6 +22,7 @@ export function useSidebarWorkspaceEntries(
placements: readonly SidebarWorkspacePlacement[],
enabled = true,
): ReadonlyMap<string, SidebarWorkspaceEntry> {
const { t } = useTranslation();
const serverIds = useMemo(
() => Array.from(new Set(placements.map((placement) => placement.serverId))),
[placements],
@@ -32,6 +36,7 @@ export function useSidebarWorkspaceEntries(
const pendingCreateAttempts = useCreateFlowStore((state) =>
enabled ? state.pendingByDraftId : EMPTY_PENDING_CREATE_ATTEMPTS,
);
const creations = useWorkspaceCreations();
const previousEntriesRef = useRef<ReadonlyMap<string, SidebarWorkspaceEntry>>(EMPTY_ENTRIES);
// Collection ownership is intentional: retained sidebars have one cheap
@@ -45,13 +50,25 @@ export function useSidebarWorkspaceEntries(
previousEntriesRef.current = EMPTY_ENTRIES;
return EMPTY_ENTRIES;
}
const entries = buildSidebarWorkspaceEntries({
placements,
sessions,
pendingCreateAttempts,
previousEntries: previousEntriesRef.current,
});
const entries = new Map(
buildSidebarWorkspaceEntries({
placements,
sessions,
pendingCreateAttempts,
previousEntries: previousEntriesRef.current,
}),
);
for (const placement of placements) {
if (entries.has(placement.workspaceKey)) continue;
const creating = creations.some(
(creation) =>
creation.serverId === placement.serverId &&
creation.workspaceId === placement.workspaceId &&
!creation.workspaceObserved,
);
if (creating) entries.set(placement.workspaceKey, creatingSidebarEntry(placement, t));
}
previousEntriesRef.current = entries;
return entries;
}, [enabled, pendingCreateAttempts, placements, sessions]);
}, [enabled, pendingCreateAttempts, placements, sessions, creations, t]);
}
@@ -1,3 +1,6 @@
import { useTranslation } from "react-i18next";
import { useWorkspaceCreations } from "@/workspace-creation";
import { addCreatingSidebarWorkspaces } from "@/workspace-creation/presentation";
import { useHostFeatureMap } from "@/runtime/host-features";
import { useCallback, useEffect, useMemo } from "react";
import { useStoreWithEqualityFn } from "zustand/traditional";
@@ -150,10 +153,12 @@ export function useSidebarWorkspacesList(options?: {
: undefined;
const { projects: hostProjects } = useWorkspaceStructure(directoryServerIds, sort);
const { t } = useTranslation();
const creations = useWorkspaceCreations();
const selectPlacements = useMemo(createSidebarWorkspacePlacementSelector, []);
const sidebarModel = useMemo(
() => selectPlacements(hostProjects),
[hostProjects, selectPlacements],
() => addCreatingSidebarWorkspaces(selectPlacements(hostProjects), creations, t),
[hostProjects, selectPlacements, creations, t],
);
const projects = sidebarModel.projects.length > 0 ? sidebarModel.projects : EMPTY_PROJECTS;
+9
View File
@@ -1339,6 +1339,15 @@ export const ar: TranslationResources = {
},
},
newWorkspace: {
creation: {
creatingWorkspace: "جارٍ إنشاء مساحة العمل…",
creatingLabel: "جارٍ إنشاء مساحة العمل",
updateHost: "حدّث هذا المضيف لإنشاء مساحة عمل تتضمن وكيلاً.",
chooseProjectHost: "اختر مشروعًا ومضيفًا",
projectUnavailable: "المشروع غير متاح على المضيف المحدد",
workspacePending: "لا يزال إنشاء مساحة العمل جاريًا.",
agentPending: "لا يزال إنشاء الوكيل جاريًا.",
},
title: "مساحة عمل جديدة",
create: "يخلق",
isolation: {
+9
View File
@@ -1347,6 +1347,15 @@ export const en = {
},
},
newWorkspace: {
creation: {
creatingWorkspace: "Creating workspace…",
creatingLabel: "Creating workspace",
updateHost: "Update this host to create a workspace with an agent.",
chooseProjectHost: "Choose a project and host",
projectUnavailable: "Project is not available on the selected host",
workspacePending: "Workspace is still being created.",
agentPending: "Agent is still being created.",
},
title: "New workspace",
create: "Create",
isolation: {
+9
View File
@@ -1375,6 +1375,15 @@ export const es: TranslationResources = {
},
},
newWorkspace: {
creation: {
creatingWorkspace: "Creando espacio de trabajo…",
creatingLabel: "Creando espacio de trabajo",
updateHost: "Actualiza este host para crear un espacio de trabajo con un agente.",
chooseProjectHost: "Elige un proyecto y un host",
projectUnavailable: "El proyecto no está disponible en el host seleccionado",
workspacePending: "El espacio de trabajo aún se está creando.",
agentPending: "El agente aún se está creando.",
},
title: "Nuevo espacio de trabajo",
create: "Crear",
isolation: {
+9
View File
@@ -1369,6 +1369,15 @@ export const fr: TranslationResources = {
},
},
newWorkspace: {
creation: {
creatingWorkspace: "Création de l’espace de travail…",
creatingLabel: "Création de l’espace de travail",
updateHost: "Mettez cet hôte à jour pour créer un espace de travail avec un agent.",
chooseProjectHost: "Choisissez un projet et un hôte",
projectUnavailable: "Le projet n’est pas disponible sur l’hôte sélectionné",
workspacePending: "L’espace de travail est en cours de création.",
agentPending: "L’agent est en cours de création.",
},
title: "Nouvel espace de travail",
create: "Créer",
isolation: {
+9
View File
@@ -1353,6 +1353,15 @@ export const ja: TranslationResources = {
},
},
newWorkspace: {
creation: {
creatingWorkspace: "ワークスペースを作成中…",
creatingLabel: "ワークスペースを作成中",
updateHost: "エージェント付きのワークスペースを作成するには、このホストを更新してください。",
chooseProjectHost: "プロジェクトとホストを選択してください",
projectUnavailable: "選択したホストでプロジェクトを利用できません",
workspacePending: "ワークスペースはまだ作成中です。",
agentPending: "エージェントはまだ作成中です。",
},
title: "新しいワークスペース",
create: "作成",
isolation: {
+9
View File
@@ -1346,6 +1346,15 @@ export const ko: TranslationResources = {
},
},
newWorkspace: {
creation: {
creatingWorkspace: "워크스페이스 생성 중…",
creatingLabel: "워크스페이스 생성 중",
updateHost: "에이전트가 있는 워크스페이스를 만들려면 이 호스트를 업데이트하세요.",
chooseProjectHost: "프로젝트와 호스트를 선택하세요",
projectUnavailable: "선택한 호스트에서 프로젝트를 사용할 수 없습니다",
workspacePending: "워크스페이스가 아직 생성 중입니다.",
agentPending: "에이전트가 아직 생성 중입니다.",
},
title: "새 워크스페이스",
create: "생성",
isolation: {
+9
View File
@@ -1365,6 +1365,15 @@ export const ptBR: TranslationResources = {
},
},
newWorkspace: {
creation: {
creatingWorkspace: "Criando espaço de trabalho…",
creatingLabel: "Criando espaço de trabalho",
updateHost: "Atualize este host para criar um espaço de trabalho com um agente.",
chooseProjectHost: "Escolha um projeto e um host",
projectUnavailable: "O projeto não está disponível no host selecionado",
workspacePending: "O espaço de trabalho ainda está sendo criado.",
agentPending: "O agente ainda está sendo criado.",
},
title: "Novo workspace",
create: "Criar",
isolation: {
+9
View File
@@ -1357,6 +1357,15 @@ export const ru: TranslationResources = {
},
},
newWorkspace: {
creation: {
creatingWorkspace: "Создание рабочей области…",
creatingLabel: "Создание рабочей области",
updateHost: "Обновите этот хост, чтобы создать рабочую область с агентом.",
chooseProjectHost: "Выберите проект и хост",
projectUnavailable: "Проект недоступен на выбранном хосте",
workspacePending: "Рабочая область ещё создаётся.",
agentPending: "Агент ещё создаётся.",
},
title: "Новое рабочее пространство",
create: "Создать",
isolation: {
+9
View File
@@ -1328,6 +1328,15 @@ export const zhCN: TranslationResources = {
},
},
newWorkspace: {
creation: {
creatingWorkspace: "正在创建工作区…",
creatingLabel: "正在创建工作区",
updateHost: "请更新此主机,以创建包含代理的工作区。",
chooseProjectHost: "请选择项目和主机",
projectUnavailable: "所选主机上没有此项目",
workspacePending: "工作区仍在创建中。",
agentPending: "代理仍在创建中。",
},
title: "新建 workspace",
create: "创建",
isolation: {
+27 -8
View File
@@ -1,3 +1,5 @@
import { useAgentCreation } from "@/workspace-creation";
import { pendingCreationAgent } from "@/workspace-creation/presentation";
import { getHostRuntimeStore } from "@/runtime/host-runtime";
import { Button } from "@/components/ui/button";
import { LoadingSpinner } from "@/components/ui/loading-spinner";
@@ -472,6 +474,7 @@ function AgentPanelContent({
);
});
const creation = useAgentCreation(serverId, agentId);
const connectionServerId = resolvedServerId ?? null;
const daemon = connectionServerId
? (daemons.find((entry) => entry.serverId === connectionServerId) ?? null)
@@ -485,7 +488,7 @@ function AgentPanelContent({
: runtimeConnectionStatus;
const lastConnectionError = runtimeLastError;
if (!resolvedServerId || (!runtimeClient && !hasCachedAgent)) {
if (!resolvedServerId || (!runtimeClient && !hasCachedAgent && !creation)) {
return (
<AgentSessionUnavailableState
serverLabel={serverLabel}
@@ -548,6 +551,8 @@ function AgentPanelBody({
const agentState = useSessionStore(
useShallow((state) => selectChatAgentState(state, serverId, agentId)),
);
// A creating agent is known locally before the host can return it.
const isCreating = useAgentCreation(serverId, agentId ?? "") !== null;
const [lookupState, setLookupState] = useState<AgentLookupState>({ tag: "idle" });
const lookupAttemptTokenRef = useRef(0);
const retryAgentLookup = useCallback(() => setLookupState({ tag: "idle" }), []);
@@ -558,7 +563,7 @@ function AgentPanelBody({
}, [agentId, serverId]);
useEffect(() => {
if (!agentId) {
if (!agentId || isCreating) {
return;
}
if (agentState.id) {
@@ -606,7 +611,16 @@ function AgentPanelBody({
}
setLookupState({ tag: "error", message });
});
}, [agentId, agentState.id, client, hasSession, isConnected, lookupState.tag, serverId]);
}, [
agentId,
agentState.id,
client,
hasSession,
isConnected,
isCreating,
lookupState.tag,
serverId,
]);
if (lookupState.tag === "not_found") {
return (
@@ -642,7 +656,7 @@ function AgentPanelBody({
}
: null;
if (!agent) {
if (!agent && !isCreating) {
return (
<View style={styles.container} testID="agent-loading">
<View style={styles.errorContainer}>
@@ -755,6 +769,7 @@ function ChatAgentContent({
const hasActiveCreateHandoff = useCreateFlowStore((state) =>
findActiveCreateHandoff({ pendingByDraftId: state.pendingByDraftId, serverId, agentId }),
);
const creation = useAgentCreation(serverId, agentId ?? "");
const hasSession = useSessionStore((state) => Boolean(state.sessions[serverId]));
const [missingAgentState, setMissingAgentState] = useState<AgentScreenMissingState>({
kind: "idle",
@@ -812,6 +827,9 @@ function ChatAgentContent({
[agentState, projectPlacement],
);
const continuity = useMemo<AgentScreenContinuity>(() => {
if (creation) {
return { kind: "optimistic-create", agent: pendingCreationAgent(creation) };
}
if (!hasActiveCreateHandoff || !agentId) {
return { kind: "none" };
}
@@ -825,7 +843,7 @@ function ChatAgentContent({
projectPlacement: agent?.projectPlacement ?? null,
},
};
}, [agent, agentId, hasActiveCreateHandoff, serverId]);
}, [agent, agentId, creation, hasActiveCreateHandoff, serverId]);
const viewState = useAgentScreenStateMachine({
routeKey: `${serverId}:${agentId ?? ""}`,
@@ -898,7 +916,7 @@ function ChatAgentContent({
}, [agentId, serverId]);
useEffect(() => {
if (!agentId) {
if (!agentId || creation) {
return;
}
if (agentState.archivedAt) {
@@ -979,6 +997,7 @@ function ChatAgentContent({
agentState.archivedAt,
hasAppliedAuthoritativeHistory,
agentId,
creation,
client,
hasSession,
isConnected,
@@ -998,8 +1017,8 @@ function ChatAgentContent({
if (nonReadyView) return nonReadyView;
invariant(agentId, "agent id is defined when agent content is ready");
invariant(effectiveAgent, "effectiveAgent is defined when the non-ready view is absent");
const agentCwd = agentState.cwd;
invariant(agentCwd, "agent cwd is defined when agent content is ready");
// Empty while a creating agent waits for the host to choose its directory.
const agentCwd = effectiveAgent.cwd;
const showHistorySyncOverlay =
viewState.tag === "ready" &&
viewState.sync.status === "catching_up" &&
@@ -10,7 +10,11 @@ import { deriveWorkspaceTabModel } from "@/screens/workspace/workspace-tab-model
import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-runtime";
import { useAppSettings } from "@/hooks/use-settings";
import { dispatchComposerAgentMessage } from "@/composer/actions";
import { createMessageSubmissionWriter } from "@/composer/submission/writer";
import { isAgentReadyForPrompts } from "@/workspace-creation";
import {
createAgentMessageQueueWriter,
createMessageSubmissionWriter,
} from "@/composer/submission/writer";
import { useSessionStore, selectAgentTurnPresentation } from "@/stores/session-store";
import { FLOATING_ACTION_BUTTON_CLEARANCE } from "@/components/ui/floating-action-button";
import { useIsCompactFormFactor } from "@/constants/layout";
@@ -77,6 +81,8 @@ export function useReviewFeedback(input: {
await dispatchComposerAgentMessage({
client: targetClient,
agentId: recipient.agentId,
isAgentReady: isAgentReadyForPrompts(serverId, recipient.agentId),
queue: createAgentMessageQueueWriter(serverId),
text: i18n.t("review.feedback.prompt"),
attachments: [attachment],
encodeImages: async () => [],
@@ -21,9 +21,12 @@ import { useSessionStore, type Agent } from "@/stores/session-store";
import { normalizeAgentSnapshot } from "@/utils/agent-snapshots";
import { isAgentArchiving, setAgentArchiving } from "@/hooks/use-archive-agent";
import { queryClient } from "@/data/query-client";
import { workspaceCreations } from "@/workspace-creation/runtime";
import { hasActiveMessageSubmission } from "@/composer/submission/model";
import {
HostRuntimeController,
HostRuntimeStore,
getHostRuntimeStore,
readInitialDaemonConnectionHint,
type HostRuntimeControllerDeps,
type HostRuntimeStorage,
@@ -73,6 +76,8 @@ class FakeDaemonClient {
Awaited<ReturnType<DaemonClient["fetchAgents"]>> | ReturnType<DaemonClient["fetchAgents"]>
> = [];
public sentAgentMessages: Array<Parameters<DaemonClient["sendAgentMessage"]>> = [];
public createWorkspaceRequests: Array<Parameters<DaemonClient["createWorkspace"]>[0]> = [];
public createWorkspaceResponse: ReturnType<DaemonClient["createWorkspace"]> | null = null;
public sendAgentMessageFailures: Error[] = [];
public sendAgentMessageResponses: Promise<void>[] = [];
private agentUpdateListeners = new Set<
@@ -119,6 +124,14 @@ class FakeDaemonClient {
}
}
createWorkspace(
request: Parameters<DaemonClient["createWorkspace"]>[0],
): ReturnType<DaemonClient["createWorkspace"]> {
this.createWorkspaceRequests.push(request);
if (!this.createWorkspaceResponse) throw new Error("Unexpected workspace creation");
return this.createWorkspaceResponse;
}
async connect(): Promise<void> {
this.connectCalls += 1;
this.setConnectionState({ status: "connected" });
@@ -426,6 +439,23 @@ function replicaAgent(snapshot: FetchAgentsEntry["agent"], serverId: string): Ag
return { ...normalizeAgentSnapshot(snapshot, serverId), projectPlacement: null };
}
/** Drains only send to an agent the host has published to this client. */
function publishAgent(serverId: string, agentId: string): void {
const entry = makeFetchAgentsEntry({
id: agentId,
cwd: "/repo",
updatedAt: "2026-07-12T10:00:00.000Z",
});
useSessionStore
.getState()
.setAgents(serverId, new Map([[agentId, replicaAgent(entry.agent, serverId)]]));
}
function queuedTexts(serverId: string, agentId: string): string[] {
const queued = useSessionStore.getState().sessions[serverId]?.queuedMessages.get(agentId) ?? [];
return queued.map((item) => item.text);
}
function agentPermission(id: string): AgentPermissionRequest {
return { id, provider: "codex", name: id, kind: "tool", title: id };
}
@@ -3168,6 +3198,7 @@ describe("HostRuntimeStore", () => {
});
const sessionStore = useSessionStore.getState();
sessionStore.initializeSession(host.serverId, fakeClient as unknown as DaemonClient, 1);
publishAgent(host.serverId, "agent");
sessionStore.updateSessionServerInfo(host.serverId, {
serverId: host.serverId,
hostname: null,
@@ -3233,6 +3264,7 @@ describe("HostRuntimeStore", () => {
});
const sessionStore = useSessionStore.getState();
sessionStore.initializeSession(host.serverId, fakeClient as unknown as DaemonClient, 1);
publishAgent(host.serverId, "agent");
sessionStore.setQueuedMessages(
host.serverId,
new Map([
@@ -3279,6 +3311,7 @@ describe("HostRuntimeStore", () => {
});
const sessionStore = useSessionStore.getState();
sessionStore.initializeSession(host.serverId, fakeClient as unknown as DaemonClient, 1);
publishAgent(host.serverId, "agent");
sessionStore.setQueuedMessages(
host.serverId,
new Map([["agent", [{ id: "first", text: "send once", attachments: [] }]]]),
@@ -3298,6 +3331,212 @@ describe("HostRuntimeStore", () => {
useSessionStore.getState().clearSession(host.serverId);
});
it("keeps queued messages in order without sending while the agent is unpublished", async () => {
const host = makeHost({ serverId: "srv_unpublished_queue_drain" });
const fakeClient = new FakeDaemonClient();
const store = new HostRuntimeStore({
deps: {
createClient: () => fakeClient as unknown as DaemonClient,
connectToDaemon: async () => ({
client: fakeClient as unknown as DaemonClient,
serverId: host.serverId,
hostname: null,
}),
getClientId: async () => "cid_unpublished_queue_drain",
},
});
const sessionStore = useSessionStore.getState();
sessionStore.initializeSession(host.serverId, fakeClient as unknown as DaemonClient, 1);
sessionStore.setQueuedMessages(
host.serverId,
new Map([
[
"agent",
[
{ id: "first", text: "first follow-up", attachments: [] },
{ id: "second", text: "second follow-up", attachments: [] },
],
],
]),
);
store.drainQueuedAgentMessage(host.serverId, "agent");
await vi.waitFor(() => {
expect(queuedTexts(host.serverId, "agent")).toEqual(["first follow-up", "second follow-up"]);
});
expect(fakeClient.sentAgentMessages).toEqual([]);
useSessionStore.getState().clearSession(host.serverId);
});
it("keeps queued messages in place for a published agent whose creation has not completed", async () => {
// The host publishes a created agent before it starts the initial prompt.
const host = makeHost({ serverId: "srv_creating_queue_drain" });
const fakeClient = new FakeDaemonClient();
const store = new HostRuntimeStore({
deps: {
createClient: () => fakeClient as unknown as DaemonClient,
connectToDaemon: async () => ({
client: fakeClient as unknown as DaemonClient,
serverId: host.serverId,
hostname: null,
}),
getClientId: async () => "cid_creating_queue_drain",
},
});
const sessionStore = useSessionStore.getState();
sessionStore.initializeSession(host.serverId, fakeClient as unknown as DaemonClient, 1);
const creation = workspaceCreations.submit({
serverId: host.serverId,
key: "creating-queue-drain",
provider: "codex",
config: {},
projectId: "project",
projectName: "Project",
text: "initial prompt",
prepare: () => new Promise(() => undefined),
});
publishAgent(host.serverId, creation.agentId);
const queued = [
{ id: "first", text: "first follow-up", attachments: [] },
{ id: "second", text: "second follow-up", attachments: [] },
];
sessionStore.setQueuedMessages(host.serverId, new Map([[creation.agentId, queued]]));
store.drainQueuedAgentMessage(host.serverId, creation.agentId);
await Promise.resolve();
expect(
useSessionStore.getState().sessions[host.serverId]?.queuedMessages.get(creation.agentId),
).toBe(queued);
expect(fakeClient.sentAgentMessages).toEqual([]);
useSessionStore.getState().clearSession(host.serverId);
});
it("drains a follow-up once when creation completes after the initial turn already ended", async () => {
const host = makeHost({
serverId: "srv_late_creation_adoption",
connections: [{ id: "direct:lan:6767", type: "directTcp", endpoint: "lan:6767" }],
});
const fakeClient = new FakeDaemonClient();
fakeClient.setConnectionState({ status: "connected" });
fakeClient.fetchAgentsResponses.push(makeFetchAgentsPayload({ entries: [] }));
const completion = new Deferred<Awaited<ReturnType<DaemonClient["createWorkspace"]>>>();
fakeClient.createWorkspaceResponse = completion.promise;
const store = new HostRuntimeStore({
deps: {
createClient: () => fakeClient as unknown as DaemonClient,
connectToDaemon: async () => ({
client: fakeClient as unknown as DaemonClient,
serverId: host.serverId,
hostname: null,
}),
getClientId: async () => "cid_late_creation_adoption",
},
});
// Creation and its adoption drain run against the app's runtime.
const previousRuntime = Reflect.get(globalThis, "__paseoHostRuntimeStore");
Reflect.set(globalThis, "__paseoHostRuntimeStore", store);
expect(getHostRuntimeStore()).toBe(store);
useSessionStore
.getState()
.initializeSession(host.serverId, fakeClient as unknown as DaemonClient, 1);
store.syncHosts([host]);
await waitForHostOnline(store, host.serverId);
await store.refreshAgentDirectory({
serverId: host.serverId,
subscribe: {},
page: { limit: 200 },
});
// Connecting replaced the server info; the host tracks submitted prompts.
useSessionStore.getState().updateSessionServerInfo(host.serverId, {
serverId: host.serverId,
hostname: null,
version: "0.2.6",
features: { canonicalSubmittedPrompts: true },
});
const creation = workspaceCreations.submit({
serverId: host.serverId,
key: "late-creation-adoption",
provider: "codex",
config: {},
projectId: "project",
projectName: "Project",
text: "initial prompt",
prepare: async () => ({
source: { kind: "directory", path: "/repo" },
agent: { config: { provider: "codex", cwd: "/repo" }, initialPrompt: "initial prompt" },
}),
});
await vi.waitFor(() => expect(fakeClient.createWorkspaceRequests).toHaveLength(1));
useSessionStore
.getState()
.setQueuedMessages(
host.serverId,
new Map([[creation.agentId, [{ id: "follow-up", text: "follow-up", attachments: [] }]]]),
);
// The host publishes the agent, which runs its initial turn to completion before the
// creation response arrives. That turn end finds the agent not ready yet.
const published = makeFetchAgentsEntry({
id: creation.agentId,
cwd: "/repo",
updatedAt: "2026-07-12T10:00:00.000Z",
});
store.acceptWorkspaceSnapshots(host.serverId, [
{
createdAt: null,
lastUserMessageAt: null,
id: creation.workspaceId,
projectId: "project",
projectDisplayName: "Project",
projectRootPath: "/repo",
workspaceDirectory: "/repo",
projectKind: "git",
workspaceKind: "checkout",
name: "repo",
status: "done",
archivingAt: null,
statusEnteredAt: null,
diffStat: null,
scripts: [],
},
]);
fakeClient.agentUpdate({
kind: "upsert",
agent: { ...published.agent, status: "running" },
project: published.project,
});
fakeClient.agentUpdate({ kind: "upsert", agent: published.agent, project: published.project });
await Promise.resolve();
expect(fakeClient.sentAgentMessages).toEqual([]);
expect(queuedTexts(host.serverId, creation.agentId)).toEqual(["follow-up"]);
// The initial prompt's local submission has no provider acknowledgement yet.
expect(
hasActiveMessageSubmission(
useSessionStore
.getState()
.sessions[host.serverId]?.messageSubmissions.get(creation.agentId),
),
).toBe(true);
completion.resolve({ workspace: null, error: null } as Awaited<
ReturnType<DaemonClient["createWorkspace"]>
>);
await vi.waitFor(() => expect(fakeClient.sentAgentMessages).toHaveLength(1));
await workspaceCreations.settled(creation);
expect(fakeClient.sentAgentMessages.map(([agentId, text]) => [agentId, text])).toEqual([
[creation.agentId, "follow-up"],
]);
expect(queuedTexts(host.serverId, creation.agentId)).toEqual([]);
store.syncHosts([]);
useSessionStore.getState().clearSession(host.serverId);
Reflect.set(globalThis, "__paseoHostRuntimeStore", previousRuntime);
});
it("drains a queued message as a steer so a turn the agent just started is not interrupted", async () => {
const host = makeHost({ serverId: "srv_steer_queue_drain" });
const fakeClient = new FakeDaemonClient();
@@ -3314,6 +3553,7 @@ describe("HostRuntimeStore", () => {
});
const sessionStore = useSessionStore.getState();
sessionStore.initializeSession(host.serverId, fakeClient as unknown as DaemonClient, 1);
publishAgent(host.serverId, "agent");
sessionStore.setQueuedMessages(
host.serverId,
new Map([["agent", [{ id: "queued", text: "next task", attachments: [] }]]]),
@@ -3342,6 +3582,7 @@ describe("HostRuntimeStore", () => {
});
const sessionStore = useSessionStore.getState();
sessionStore.initializeSession(host.serverId, fakeClient as unknown as DaemonClient, 1);
publishAgent(host.serverId, "agent");
sessionStore.updateSessionServerInfo(host.serverId, {
serverId: host.serverId,
hostname: null,
+12 -8
View File
@@ -73,7 +73,11 @@ import {
import { mountBrowserAutomationDaemonClientHandler } from "@/desktop/browser/automation/handler";
import { schedulesQueryBaseKey } from "@/schedules/aggregated-schedules";
import { dispatchComposerAgentMessage, sendQueuedComposerMessageNow } from "@/composer/actions";
import { createMessageSubmissionWriter } from "@/composer/submission/writer";
import { isAgentReadyForPrompts } from "@/workspace-creation";
import {
createAgentMessageQueueWriter,
createMessageSubmissionWriter,
} from "@/composer/submission/writer";
import { resolveComposerAttachmentSubmitFormat } from "@/composer/attachments/submit";
import { encodeImages } from "@/utils/encode-images";
import { DirectorySync, type RefreshAgentDirectoryResult } from "@/runtime/directory-sync";
@@ -2568,20 +2572,20 @@ export class HostRuntimeStore {
}
this.queuedAgentDrainInFlight.add(drainKey);
const next = queue[0];
const queueWriter = createAgentMessageQueueWriter(serverId);
void sendQueuedComposerMessageNow({
agentId,
isAgentReady: isAgentReadyForPrompts(serverId, agentId),
messageId: next.id,
queue: {
read: (queuedAgentId) =>
useSessionStore.getState().sessions[serverId]?.queuedMessages.get(queuedAgentId) ?? [],
write: (update) => useSessionStore.getState().setQueuedMessages(serverId, update),
},
queue: queueWriter,
submitMessage: async ({ text, attachments }) => {
const supportsForgeAttachments =
useSessionStore.getState().sessions[serverId]?.serverInfo?.features?.forgeSearch === true;
const currentSession = useSessionStore.getState().sessions[serverId];
const supportsForgeAttachments = currentSession?.serverInfo?.features?.forgeSearch === true;
await dispatchComposerAgentMessage({
client,
agentId,
isAgentReady: isAgentReadyForPrompts(serverId, agentId),
queue: queueWriter,
text,
attachments,
attachmentSubmitFormat: resolveComposerAttachmentSubmitFormat({
+49 -381
View File
@@ -1,10 +1,7 @@
import type {
CreateAgentRequestOptions,
CreateWorkspaceRequestOptions,
} from "@getpaseo/client/internal/daemon-client";
import type { AgentSnapshotPayload, CreationSnapshot } from "@getpaseo/protocol/messages";
import { encodeImages } from "@/utils/encode-images";
import { getHostRuntimeStore } from "@/runtime/host-runtime";
import type { CreateWorkspaceRequestOptions } from "@getpaseo/client/internal/daemon-client";
import type { AgentSnapshotPayload, CreationSnapshot } from "@getpaseo/protocol/messages";
import { submitWorkspaceChat, takeWorkspaceCreationError } from "@/workspace-creation";
import { useCallback, useEffect, useMemo, useReducer, useRef, useState } from "react";
import type { ReactNode } from "react";
import { useTranslation } from "react-i18next";
@@ -17,10 +14,6 @@ import { Composer } from "@/composer";
import { ComposerDock } from "@/composer/dock";
import { useComposerEditingSession, useComposerFullscreen } from "@/composer/editing-session";
import { FileDropZone } from "@/components/file-drop/file-drop-zone";
import {
resolveComposerAttachmentSubmitFormat,
splitComposerAttachmentsForSubmit,
} from "@/composer/attachments/submit";
import { TitlebarDragRegion } from "@/components/desktop/titlebar-drag-region";
import { MenuHeader } from "@/components/headers/menu-header";
import { useIsCompactFormFactor } from "@/constants/layout";
@@ -52,7 +45,6 @@ import { normalizeWorkspaceDescriptor, type WorkspaceDescriptor } from "@/stores
import { useWorkspace } from "@/stores/session-store-hooks";
import { buildNewWorkspaceDraftKey, generateDraftId } from "@/stores/draft-keys";
import { useOpenAddProject } from "@/hooks/use-open-add-project";
import { isActiveCreateFlowForDraft, useCreateFlowStore } from "@/stores/create-flow-store";
import {
useWorkspaceDraftSubmissionStore,
type PendingWorkspaceDraftSetup,
@@ -73,19 +65,12 @@ import {
type HostProjectListItem,
} from "@/projects/host-projects";
import { useProjectIcons } from "@/projects/icons";
import type { ComposerAttachment } from "@/attachments/types";
import { useDraftWorkspaceAttachmentScopeKey } from "@/attachments/workspace-attachments-store";
import type { MessagePayload } from "@/composer/types";
import type { UserComposerAttachment } from "@/attachments/types";
import type { AgentAttachment, ForgeSearchItem } from "@getpaseo/protocol/messages";
import type { DaemonClient } from "@getpaseo/client/internal/daemon-client";
import type { AgentProvider } from "@getpaseo/protocol/agent-types";
import type { WorkspaceDraftTabSetup, WorkspaceTabTarget } from "@/workspace-tabs/model";
import type { WorkspaceDraftTabSetup } from "@/workspace-tabs/model";
import { isEmptyWorkspaceSubmission, runCreateEmptyWorkspace } from "./new-workspace-empty";
import {
getWorkspaceNamingAttachments,
remapDraftCwdToWorkspace,
} from "./new-workspace-fork-context";
import {
buildPickerOptionData,
defaultBasePickerItem,
@@ -116,40 +101,8 @@ import {
type ListTerminalsPayload,
upsertCreatedTerminalPayload,
} from "./workspace/terminals/state";
import { captureWorkspaceDraftCleanup } from "./new-workspace/background-handoff";
import { useNewWorkspaceScreenPresence } from "./new-workspace/screen-presence";
function useIsNewWorkspaceDraftHandoffActive(input: {
draftId: string | undefined;
selectedServerId: string;
}): boolean {
const normalizedDraftId = input.draftId?.trim() ?? "";
return useCreateFlowStore((state) =>
isActiveCreateFlowForDraft({
draftId: normalizedDraftId,
serverId: input.selectedServerId,
pending: normalizedDraftId ? state.pendingByDraftId[normalizedDraftId] : null,
}),
);
}
function resolveVisibleDraftContextScopeKeys(input: {
isDraftHandoffActive: boolean;
draftContextScopeKey: string;
}): readonly string[] {
if (input.isDraftHandoffActive || !input.draftContextScopeKey) {
return [];
}
return [input.draftContextScopeKey];
}
function isNewWorkspacePending(input: {
pendingAction: "chat" | "empty" | "terminal" | null;
isDraftHandoffActive: boolean;
}): boolean {
return input.pendingAction !== null || input.isDraftHandoffActive;
}
function buildFirstAgentContext(input: {
prompt: string;
attachments: AgentAttachment[];
@@ -238,43 +191,6 @@ function normalizeBranchDetails(
*/
type SubmitOutcome = "navigated" | "background";
interface SubmitDraftInput {
clearConsumedDraft: () => void;
agentCreation?: {
result: Promise<AgentSnapshotPayload>;
retry: (input: CreateAgentRequestOptions) => Promise<AgentSnapshotPayload>;
};
serverId: string;
draftKey: string;
clearDraft: (lifecycle: "sent" | "abandoned") => void;
draftId?: string;
draftContextScopeKey: string | null;
initialSetup?: WorkspaceDraftTabSetup;
workspaceId: string;
workspaceDirectory: string;
text: string;
attachments: ComposerAttachment[];
provider: AgentProvider;
composerState: NewWorkspaceComposerState;
supportsForgeSearch: boolean;
resolveClient: () => DaemonClient;
isStillOnCreateScreen: () => boolean;
}
type NewWorkspaceComposerState = NonNullable<
ReturnType<typeof useAgentInputDraft>["composerState"]
>;
interface WorkspaceDraftSubmissionConfig {
cwd: string;
provider: AgentProvider;
modeId: string | null;
model: string | null;
thinkingOptionId: string | null;
featureValues: Record<string, unknown> | undefined;
target: WorkspaceTabTarget;
}
interface WorkspaceCreationResult {
workspace: ReturnType<typeof normalizeWorkspaceDescriptor>;
agent?: AgentSnapshotPayload;
@@ -337,67 +253,6 @@ async function createMultiplicityWorkspace(input: {
return { workspace: normalizedWorkspace, agent: payload.agent };
}
interface CreateChatAgentInput {
payload: MessagePayload;
composerState: ReturnType<typeof useAgentInputDraft>["composerState"];
forkDraftSetup?: PendingWorkspaceDraftSetup | null;
ensureWorkspace: (input: {
cwd: string;
prompt: string;
attachments: AgentAttachment[];
withInitialAgent: boolean;
agent?: CreateWorkspaceRequestOptions["agent"];
onEvent?: (snapshot: CreationSnapshot) => void;
}) => Promise<WorkspaceCreationResult>;
serverId: string;
draftKey: string;
clearDraft: (lifecycle: "sent" | "abandoned") => void;
draftId?: string;
draftContextScopeKey: string | null;
supportsForgeSearch: boolean;
resolveClient: () => DaemonClient;
isStillOnCreateScreen: () => boolean;
labels: {
composerStateRequired: string;
selectModel: string;
};
}
function buildWorkspaceDraftSetupFromComposer(input: {
cwd: string;
provider: AgentProvider;
composerState: NewWorkspaceComposerState;
}): WorkspaceDraftTabSetup {
return {
provider: input.provider,
cwd: input.cwd,
modeId: input.composerState.effectiveModeId || null,
model: input.composerState.effectiveModelId || null,
thinkingOptionId: input.composerState.effectiveThinkingOptionId || null,
featureValues: input.composerState.featureValues ?? {},
};
}
function buildWorkspaceDraftSetupForCreatedWorkspace(input: {
forkDraftSetup: PendingWorkspaceDraftSetup | null | undefined;
workspaceDirectory: string;
provider: AgentProvider;
composerState: NewWorkspaceComposerState;
}): WorkspaceDraftTabSetup | undefined {
if (!input.forkDraftSetup) {
return undefined;
}
return buildWorkspaceDraftSetupFromComposer({
cwd: remapDraftCwdToWorkspace({
cwd: input.forkDraftSetup.setup.cwd,
sourceDirectory: input.forkDraftSetup.sourceDirectory,
workspaceDirectory: input.workspaceDirectory,
}),
provider: input.provider,
composerState: input.composerState,
});
}
function buildComposerInitialValues(input: {
initialSetup?: WorkspaceDraftTabSetup | null;
}): CreateAgentInitialValues | undefined {
@@ -412,117 +267,6 @@ function buildComposerInitialValues(input: {
return undefined;
}
const pendingWorkspaceSubmissions = new Map<string, Promise<SubmitOutcome>>();
function runCreateChatAgent(input: CreateChatAgentInput): Promise<SubmitOutcome> {
const key = JSON.stringify([input.serverId, input.draftId]);
const pending = pendingWorkspaceSubmissions.get(key);
if (pending) return pending;
const submission = Promise.resolve().then(() => createWorkspaceChatAgent(input));
pendingWorkspaceSubmissions.set(key, submission);
void submission.finally(() => pendingWorkspaceSubmissions.delete(key)).catch(() => undefined);
return submission;
}
async function createWorkspaceChatAgent(input: CreateChatAgentInput): Promise<SubmitOutcome> {
const { payload, composerState, ensureWorkspace, serverId, clearDraft } = input;
const clearConsumedDraft = captureWorkspaceDraftCleanup(input);
const { text, attachments, cwd } = payload;
if (!composerState) {
throw new Error(input.labels.composerStateRequired);
}
const provider = composerState.selectedProvider;
if (!provider) {
throw new Error(input.labels.selectModel);
}
const attachmentSubmitFormat = resolveComposerAttachmentSubmitFormat({
supportsForgeAttachments: input.supportsForgeSearch,
});
const { attachments: reviewAttachments } = splitComposerAttachmentsForSubmit(attachments, {
format: attachmentSubmitFormat,
});
const workspaceNamingAttachments = getWorkspaceNamingAttachments(reviewAttachments);
const wirePayload = splitComposerAttachmentsForSubmit(attachments, {
format: attachmentSubmitFormat,
});
const images = await encodeImages(wirePayload.images);
let navigated = false;
let outcome: SubmitOutcome = "background";
const initialAgent: NonNullable<CreateWorkspaceRequestOptions["agent"]> = {
config: {
provider,
cwd,
modeId: composerState.effectiveModeId || undefined,
model: composerState.effectiveModelId || undefined,
thinkingOptionId: composerState.effectiveThinkingOptionId || undefined,
featureValues: composerState.featureValues,
},
initialPrompt: text,
clientMessageId: `${input.draftId}:initial-message`,
images: images?.length ? images : undefined,
attachments: wirePayload.attachments?.length ? wirePayload.attachments : undefined,
};
const execute = async (requestedAgent = initialAgent): Promise<AgentSnapshotPayload> => {
const { agent } = await ensureWorkspace({
cwd,
prompt: text,
attachments: workspaceNamingAttachments,
withInitialAgent: true,
agent: requestedAgent,
onEvent: (snapshot) => {
if (!snapshot.workspace || navigated) return;
navigated = true;
if (!input.isStillOnCreateScreen()) return;
const workspace = normalizeWorkspaceDescriptor(snapshot.workspace);
getHostRuntimeStore().acceptWorkspaceSnapshots(serverId, [
{ ...workspace, status: "running" },
]);
const initialSetup = buildWorkspaceDraftSetupForCreatedWorkspace({
forkDraftSetup: input.forkDraftSetup,
workspaceDirectory: workspace.workspaceDirectory,
provider,
composerState,
});
outcome = submitWorkspaceDraft({
clearConsumedDraft,
draftKey: input.draftKey,
draftContextScopeKey: input.draftContextScopeKey,
resolveClient: input.resolveClient,
isStillOnCreateScreen: input.isStillOnCreateScreen,
serverId,
clearDraft,
draftId: input.draftId,
initialSetup,
workspaceId: workspace.id,
workspaceDirectory: workspace.workspaceDirectory,
text,
attachments,
provider,
composerState,
supportsForgeSearch: input.supportsForgeSearch,
agentCreation,
});
},
});
if (!agent) throw new Error("Workspace creation returned no agent");
return agent;
};
const agentCreation = {
result: Promise.resolve().then(() => execute()),
retry: (request: CreateAgentRequestOptions) =>
execute({
...initialAgent,
config: { ...request.config!, cwd },
initialPrompt: request.initialPrompt ?? "",
clientMessageId: initialAgent.clientMessageId,
images: request.images,
attachments: request.attachments,
}),
};
await agentCreation.result;
if (outcome === "background") clearConsumedDraft();
return outcome;
}
function buildComposerConfig(input: {
serverId: string;
workspaceDirectory: string | null;
@@ -552,102 +296,6 @@ function usePendingWorkspaceDraftSetup(
});
}
function resolveWorkspaceDraftSubmissionConfig(input: {
draftId: string;
workspaceDirectory: string;
provider: AgentProvider;
composerState: NewWorkspaceComposerState;
initialSetup?: WorkspaceDraftTabSetup;
}): WorkspaceDraftSubmissionConfig {
const { draftId, workspaceDirectory, provider, composerState, initialSetup } = input;
if (initialSetup) {
return {
cwd: initialSetup.cwd,
provider: initialSetup.provider,
modeId: initialSetup.modeId,
model: initialSetup.model,
thinkingOptionId: initialSetup.thinkingOptionId,
featureValues: initialSetup.featureValues,
target: { kind: "draft", draftId, setup: initialSetup },
};
}
return {
cwd: workspaceDirectory,
provider,
modeId: composerState.effectiveModeId || null,
model: composerState.effectiveModelId || null,
thinkingOptionId: composerState.effectiveThinkingOptionId || null,
featureValues: composerState.featureValues,
target: { kind: "draft", draftId },
};
}
function submitWorkspaceDraft(input: SubmitDraftInput): SubmitOutcome {
const {
serverId,
clearDraft,
draftId: draftIdInput,
workspaceId,
workspaceDirectory,
text,
attachments,
provider,
composerState,
initialSetup,
} = input;
const draftId = draftIdInput?.trim() || generateDraftId();
const clientMessageId = `${draftId}:initial-message`;
const timestamp = Date.now();
const wirePayload = splitComposerAttachmentsForSubmit(attachments, {
format: resolveComposerAttachmentSubmitFormat({
supportsForgeAttachments: input.supportsForgeSearch,
}),
});
const submission = resolveWorkspaceDraftSubmissionConfig({
draftId,
workspaceDirectory,
provider,
composerState,
initialSetup,
});
const started = useCreateFlowStore.getState().trySetPending({
serverId,
draftId,
workspaceId,
agentId: null,
clientMessageId,
text: text.trim(),
timestamp,
...(wirePayload.images.length > 0 ? { images: wirePayload.images } : {}),
...(wirePayload.attachments.length > 0 ? { attachments: wirePayload.attachments } : {}),
});
if (!started) return "navigated";
useWorkspaceDraftSubmissionStore.getState().setPending({
serverId,
workspaceId,
draftId,
text: text.trim(),
attachments,
cwd: submission.cwd,
provider: submission.provider,
clientMessageId,
timestamp,
...(submission.modeId ? { modeId: submission.modeId } : {}),
...(submission.model ? { model: submission.model } : {}),
...(submission.thinkingOptionId ? { thinkingOptionId: submission.thinkingOptionId } : {}),
...(submission.featureValues ? { featureValues: submission.featureValues } : {}),
allowEmptyText: true,
agentCreation: input.agentCreation,
});
clearDraft("sent");
navigateToWorkspace({
serverId,
workspaceId,
target: submission.target,
});
return "navigated";
}
function useNewWorkspaceHostSelector(input: {
initialServerId: string;
allServerIds: string[];
@@ -893,7 +541,7 @@ export function NewWorkspaceScreen({
const [creationResult, setCreationResult] = useState<
WorkspaceCreationResult | { workspace: null }
>({ workspace: null });
const [pendingAction, setPendingAction] = useState<"chat" | "empty" | "terminal" | null>(null);
const [pendingAction, setPendingAction] = useState<"empty" | "terminal" | null>(null);
const [pickerOpen, setPickerOpen] = useState(false);
const [projectPickerOpen, setProjectPickerOpen] = useState(false);
const openAddProjectPicker = useOpenAddProject();
@@ -904,7 +552,6 @@ export function NewWorkspaceScreen({
const projectPickerAnchorRef = useRef<View>(null);
const isolationPickerAnchorRef = useRef<View>(null);
const hostPickerAnchorRef = useRef<View | null>(null);
const isDraftHandoffActive = useIsNewWorkspaceDraftHandoffActive({ draftId, selectedServerId });
const isStillOnCreateScreen = useNewWorkspaceScreenPresence();
// Launch target: what the composer submits to (chat agent, or a terminal
@@ -984,11 +631,18 @@ export function NewWorkspaceScreen({
projects: projectIconTargets,
});
const draftKey = buildNewWorkspaceDraftKey(draftId);
// A chat whose workspace could not be created returns here with its draft.
useEffect(() => {
const message = takeWorkspaceCreationError(draftKey);
if (!message) return;
setErrorMessage(message);
toast.error(message);
}, [draftKey, toast]);
const forkDraftSetup = usePendingWorkspaceDraftSetup(draftId);
const draftContextScopeKey = useDraftWorkspaceAttachmentScopeKey(draftId);
const visibleDraftContextScopeKeys = useMemo(
() => resolveVisibleDraftContextScopeKeys({ isDraftHandoffActive, draftContextScopeKey }),
[draftContextScopeKey, isDraftHandoffActive],
() => (draftContextScopeKey ? [draftContextScopeKey] : []),
[draftContextScopeKey],
);
const chatDraft = useAgentInputDraft({
draftKey,
@@ -1037,7 +691,7 @@ export function NewWorkspaceScreen({
const worktreeSupport = selectedProject
? getWorktreeSupportForHostProject({ project: selectedProject, serverId: selectedServerId })
: "unsupported";
const isPending = isNewWorkspacePending({ pendingAction, isDraftHandoffActive });
const isPending = pendingAction !== null;
const { effectiveIsolation, setIsolation, canCreateWorktree, showRefPicker } =
useWorkspaceIsolation({
supportsMultiplicity: supportsWorkspaceMultiplicity,
@@ -1290,9 +944,9 @@ export function NewWorkspaceScreen({
async (payload: MessagePayload) => {
try {
setErrorMessage(null);
await composerState?.persistFormPreferences();
await updateFormPreferences({ launchTarget });
if (isEmptyWorkspaceSubmission(payload)) {
await composerState?.persistFormPreferences();
await updateFormPreferences({ launchTarget });
setPendingAction("empty");
let outcome: SubmitOutcome = "background";
await runCreateEmptyWorkspace({
@@ -1316,28 +970,36 @@ export function NewWorkspaceScreen({
return;
}
setPendingAction("chat");
const outcome = await runCreateChatAgent({
const attempt = submitWorkspaceChat({
serverId: selectedServerId,
key: creationIdentity.draftId,
returnDraftId: draftId,
project: selectedProject,
sourceDirectory: selectedSourceDirectory,
worktreeSlug: creationIdentity.worktreeSlug,
createsWorktree: !supportsWorkspaceMultiplicity || effectiveIsolation === "worktree",
selectedItem,
payload,
composerState,
forkDraftSetup,
ensureWorkspace,
serverId: selectedServerId,
clearDraft: chatDraft.clear,
draftKey,
draftId: creationIdentity.draftId,
draftContextScopeKey,
supportsForgeSearch,
resolveClient: withConnectedClient,
isStillOnCreateScreen,
labels: {
composerStateRequired: t("newWorkspace.errors.composerStateRequired"),
selectModel: t("newWorkspace.errors.selectModel"),
forkSetup: forkDraftSetup,
client: withConnectedClient(),
queryClient,
draftId: creationIdentity.draftId,
draftKey,
clearDraft: chatDraft.clear,
draftContextScopeKey,
persistPreferences: async () => {
await composerState?.persistFormPreferences();
await updateFormPreferences({ launchTarget });
},
});
if (outcome === "background") {
setPendingAction(null);
}
leaveFullscreen();
navigateToWorkspace({
serverId: selectedServerId,
workspaceId: attempt.workspaceId,
target: { kind: "agent", agentId: attempt.agentId },
});
} catch (error) {
const message = toErrorMessage(error);
setPendingAction(null);
@@ -1346,6 +1008,7 @@ export function NewWorkspaceScreen({
}
},
[
draftId,
composerState,
draftContextScopeKey,
creationIdentity,
@@ -1353,12 +1016,17 @@ export function NewWorkspaceScreen({
draftKey,
ensureWorkspace,
forkDraftSetup,
selectedProject,
selectedSourceDirectory,
selectedItem,
supportsWorkspaceMultiplicity,
effectiveIsolation,
queryClient,
isStillOnCreateScreen,
launchTarget,
leaveFullscreen,
selectedServerId,
supportsForgeSearch,
t,
toast,
updateFormPreferences,
withConnectedClient,
@@ -1,3 +1,4 @@
import { useCreationTabMenuEntries } from "@/workspace-creation/ui";
import { memo, useCallback, useMemo, useRef, useState } from "react";
import { Keyboard, Pressable, Text, View } from "react-native";
import { useTranslation } from "react-i18next";
@@ -202,7 +203,7 @@ function MobileWorkspaceTabOption({
[t],
);
const menuTestIDBase = `workspace-tab-menu-${tab.tabId}`;
const menuEntries = buildWorkspaceTabMenuEntries({
const rawMenuEntries = buildWorkspaceTabMenuEntries({
surface: "mobile",
tab,
index: tabIndex,
@@ -221,6 +222,8 @@ function MobileWorkspaceTabOption({
labels: tabMenuLabels,
});
const menuEntries = useCreationTabMenuEntries(normalizedServerId, tab.target, rawMenuEntries);
const fallbackLabels = useMemo(
() => ({
newTab: t("workspace.tabs.actions.newTab"),
@@ -1,3 +1,4 @@
import { isAgentCreationPending } from "@/workspace-creation";
import { useCallback, useState } from "react";
import { type QueryClient } from "@tanstack/react-query";
import type { DaemonClient } from "@getpaseo/client/internal/daemon-client";
@@ -46,6 +47,7 @@ export function useWorkspaceTabRename(
}
if (tab.target.kind === "agent") {
const { agentId } = tab.target;
if (isAgentCreationPending(normalizedServerId, agentId)) return;
const agent =
useSessionStore.getState().sessions[normalizedServerId]?.agents?.get(agentId) ?? null;
const currentTitle = agent?.title ?? "";
@@ -1,3 +1,4 @@
import { useCreationTabMenuEntries } from "@/workspace-creation/ui";
import { LoadingSpinner } from "@/components/ui/loading-spinner";
import React, {
Fragment,
@@ -756,7 +757,8 @@ function TabChip({
onCloseTab: (tabId: string) => Promise<void> | void;
dragHandleProps: DraggableListDragHandleProps | undefined;
}) {
const { closeButtonTestId, contextMenuTestId, menuEntries } = resolvedTab;
const { closeButtonTestId, contextMenuTestId, menuEntries: rawMenuEntries } = resolvedTab;
const menuEntries = useCreationTabMenuEntries(serverId, tab.target, rawMenuEntries);
const { t } = useTranslation();
const openWith = useFilePanelOpenWithMenu(
{ serverId, workspaceId, tabId: tab.tabId },
@@ -173,3 +173,20 @@ describe("resolveWorkspaceRouteState", () => {
).toEqual({ kind: "ready" });
});
});
it.each(["online", "offline"] as const)(
"keeps a reserved workspace usable while %s and directory snapshots omit it",
(connectionStatus) => {
expect(
resolveWorkspaceRouteState({
hostName: "Laptop",
connectionStatus,
lastError: null,
workspace: null,
hasHydratedWorkspaces: true,
recovery: { kind: "idle" },
pendingCreation: true,
}),
).toEqual({ kind: "ready" });
},
);
@@ -34,7 +34,9 @@ export function resolveWorkspaceRouteState(input: {
workspace: WorkspaceDescriptor | null;
hasHydratedWorkspaces: boolean;
recovery: WorkspaceRecoveryModel;
pendingCreation?: boolean;
}): WorkspaceRouteState {
if (input.pendingCreation) return { kind: "ready" };
if (input.workspace) {
if (input.connectionStatus === "online") {
if (input.recovery.kind === "recoverable" && input.recovery.phase === "restoring") {
@@ -1,3 +1,9 @@
import { i18n } from "@/i18n/i18next";
import {
includeCreatingAgent,
workspaceCreationPresentation,
} from "@/workspace-creation/presentation";
import { isAgentCreationPending, useWorkspaceCreation } from "@/workspace-creation";
import { useHostFeature } from "@/runtime/host-features";
import {
AssistantFileLinkResolverProvider,
@@ -644,13 +650,16 @@ function buildWorkspaceHeaderCheckoutState(input: {
function deriveWorkspaceHeaderFields(input: {
workspace: WorkspaceDescriptor | null;
checkoutState: WorkspaceHeaderCheckoutState;
pendingProjectName?: string;
}): WorkspaceHeaderFields {
const renderState = resolveWorkspaceHeaderRenderState(input);
if (renderState.kind !== "ready") {
return {
isWorkspaceHeaderLoading: true,
workspaceHeaderTitle: "",
workspaceHeaderSubtitle: "",
isWorkspaceHeaderLoading: !input.pendingProjectName,
workspaceHeaderTitle: input.pendingProjectName
? i18n.t("newWorkspace.creation.creatingWorkspace")
: "",
workspaceHeaderSubtitle: input.pendingProjectName ?? "",
isWorkspaceHeaderSubtitleDistinct: false,
isGitCheckout: false,
currentBranchName: null,
@@ -713,6 +722,7 @@ function useResolvedWorkspaceRouteState(input: {
workspace: WorkspaceDescriptor | null;
hasHydratedWorkspaces: boolean;
recovery: WorkspaceRecoveryModel;
pendingCreation: boolean;
}): WorkspaceRouteState {
const hosts = useHosts();
const host = useMemo(
@@ -730,6 +740,7 @@ function useResolvedWorkspaceRouteState(input: {
workspace: input.workspace,
hasHydratedWorkspaces: input.hasHydratedWorkspaces,
recovery: input.recovery,
pendingCreation: input.pendingCreation,
}),
[
hostName,
@@ -738,6 +749,7 @@ function useResolvedWorkspaceRouteState(input: {
input.workspace,
input.hasHydratedWorkspaces,
input.recovery,
input.pendingCreation,
],
);
}
@@ -746,7 +758,9 @@ function shouldInspectWorkspaceRecovery(
hasHydratedWorkspaces: boolean,
workspace: WorkspaceDescriptor | null,
pendingOpenTarget: WorkspaceTabTarget | null,
canInspectCreation: boolean,
): boolean {
if (!canInspectCreation) return false;
return pendingOpenTarget?.kind === "agent" && hasHydratedWorkspaces && workspace === null;
}
@@ -1035,12 +1049,23 @@ function WorkspaceScreenContent({
[workspaceId],
);
const workspaceDescriptor = useWorkspace(normalizedServerId, normalizedWorkspaceId);
const creation = useWorkspaceCreation(normalizedServerId, normalizedWorkspaceId);
const creationPresentation = useMemo(
() => workspaceCreationPresentation(workspaceDescriptor, creation),
[workspaceDescriptor, creation],
);
useEffect(() => {
if (!normalizedServerId || !normalizedWorkspaceId || workspaceDescriptor) return;
if (
!normalizedServerId ||
!normalizedWorkspaceId ||
workspaceDescriptor ||
(creation && !creation.workspaceObserved)
)
return;
void getHostRuntimeStore()
.prepareWorkspaceRoute(normalizedServerId, normalizedWorkspaceId)
.catch(() => undefined);
}, [normalizedServerId, normalizedWorkspaceId, workspaceDescriptor]);
}, [normalizedServerId, normalizedWorkspaceId, workspaceDescriptor, creation]);
// The focused workspace keeps its script status live even when no sidebar or viewed agent
// timeline demands the directory. Retained, hidden workspaces release it.
useEffect(() => {
@@ -1142,7 +1167,7 @@ function WorkspaceScreenContent({
onOpened: revealWorkspaceTab,
workspaceKey: persistenceKey,
hasHydratedWorkspaces,
workspaceExists: workspaceDescriptor !== null,
workspaceExists: creationPresentation.exists,
});
const workspaceRecovery = useWorkspaceRecovery({
serverId: normalizedServerId,
@@ -1151,6 +1176,7 @@ function WorkspaceScreenContent({
hasHydratedWorkspaces,
workspaceDescriptor,
pendingOpenTarget,
creationPresentation.canInspectRecovery,
),
});
@@ -1162,12 +1188,17 @@ function WorkspaceScreenContent({
}),
[normalizedServerId, normalizedWorkspaceId],
);
const workspaceAgentVisibility = useStoreWithEqualityFn(
const directoryAgentVisibility = useStoreWithEqualityFn(
useSessionStore,
selectWorkspaceAgentVisibility,
workspaceAgentVisibilityEqual,
);
const workspaceAgentVisibility = useMemo(
() => includeCreatingAgent(directoryAgentVisibility, creation),
[directoryAgentVisibility, creation],
);
const {
handleTerminalCreated,
handleScriptTerminalSelected,
@@ -1233,6 +1264,7 @@ function WorkspaceScreenContent({
workspace: workspaceDescriptor,
hasHydratedWorkspaces,
recovery: workspaceRecovery.state,
pendingCreation: creationPresentation.workspacePending,
});
const workspaceHeaderCheckoutState = buildWorkspaceHeaderCheckoutState({
isCheckoutStatusLoading,
@@ -1249,6 +1281,7 @@ function WorkspaceScreenContent({
} = deriveWorkspaceHeaderFields({
workspace: workspaceDescriptor,
checkoutState: workspaceHeaderCheckoutState,
pendingProjectName: creationPresentation.projectName,
});
const showMobileAgent = usePanelStore((state) => state.showMobileAgent);
@@ -1412,7 +1445,7 @@ function WorkspaceScreenContent({
() => isMobile || isFocusModeEnabled || !supportsDesktopPaneSplits(),
[isFocusModeEnabled, isMobile],
);
const visibleAgentIds = useVisibleAgentIds({
const tabAgentIds = useVisibleAgentIds({
layout: workspaceLayout,
tabs: uiTabs,
sheetTab,
@@ -1421,6 +1454,13 @@ function WorkspaceScreenContent({
explorerPaneId: explorerSidebarPaneId,
explorerVisible: isExplorerSidebarShowing,
});
const visibleAgentIds = useMemo(
() =>
tabAgentIds.filter(
(agentId) => !creation || creation.agentId !== agentId || creation.agentObserved,
),
[tabAgentIds, creation],
);
useEffect(() => {
for (const agentId of visibleAgentIds) {
void getHostRuntimeStore()
@@ -1834,6 +1874,10 @@ function WorkspaceScreenContent({
const launchWorkspaceTab = useCallback(
(selection: NewTabSelection, destination: WorkspaceTabLaunchDestination) => {
if (!workspaceDirectory) {
toast.error(t("newWorkspace.creation.workspacePending"));
return;
}
if (!persistenceKey) {
return;
}
@@ -1865,7 +1909,15 @@ function WorkspaceScreenContent({
const { browserId } = createWorkspaceBrowser();
openTarget({ kind: "browser", browserId });
},
[createTerminal, createWorkspaceTab, persistenceKey, replaceWorkspaceTabTarget],
[
createTerminal,
createWorkspaceTab,
persistenceKey,
replaceWorkspaceTabTarget,
workspaceDirectory,
t,
toast,
],
);
const handleOpenUrlInBrowserTab = useCallback(
@@ -1951,6 +2003,10 @@ function WorkspaceScreenContent({
const handleCloseAgentTab = useCallback(
async (input: { tabId: string; agentId: string }) => {
const { tabId, agentId } = input;
if (isAgentCreationPending(normalizedServerId, agentId)) {
closeWorkspaceTabWithCleanup({ tabId, target: { kind: "agent", agentId } });
return;
}
await closeTab(tabId, async () => {
if (!normalizedServerId) {
return;
@@ -2164,6 +2220,10 @@ function WorkspaceScreenContent({
const handleReloadAgent = useCallback(
async (agentId: string) => {
if (isAgentCreationPending(normalizedServerId, agentId)) {
toast.show(t("newWorkspace.creation.agentPending"));
return;
}
if (!client || !isConnected) {
toast.error(t("workspace.terminal.hostDisconnected"));
return;
@@ -2249,6 +2309,7 @@ function WorkspaceScreenContent({
}
const groups = classifyBulkClosableTabs(tabsToClose, (agentId) => {
if (isAgentCreationPending(normalizedServerId, agentId)) return "layout-only";
const agent = useSessionStore.getState().sessions[normalizedServerId]?.agents?.get(agentId);
return resolveCloseAgentTabPolicy(agent).kind === "layout-only" ? "layout-only" : "archive";
});
@@ -2280,6 +2341,7 @@ function WorkspaceScreenContent({
groups,
closeTab,
closeLayoutOnlyAgent: async (agentId) => {
if (isAgentCreationPending(normalizedServerId, agentId)) return;
if (!client) {
throw new Error(t("common.errors.daemonClientUnavailable"));
}
+8
View File
@@ -352,6 +352,14 @@ export function selectAgentSentMessages(
return messages;
}
/** Whether the host has published this agent to the client. A creating agent is shown before. */
export function selectIsAgentPublished(
session: SessionState | undefined,
agentId: string,
): boolean {
return Boolean(session?.agents.has(agentId) || session?.agentDetails.has(agentId));
}
export function selectAgentTurnPresentation(
session: SessionState | undefined,
agentId: string,
@@ -45,27 +45,3 @@ test("the production app consumes a draft once and leaves other drafts pending",
expect(store.getState().consumePending(submission)).toBeNull();
expect(Object.keys(store.getState().pendingByDraftId)).toEqual(["draft-2"]);
});
test("consuming draft presentation retains its running creation through remount and retry", () => {
const store = loadProductionStore();
const result = new Promise<never>(() => {});
const creation = { result, retry: () => result };
const submission = {
serverId: "server",
workspaceId: "workspace",
draftId: "draft-one",
text: "hello",
attachments: [],
cwd: "/repo",
provider: "codex",
clientMessageId: "message",
timestamp: 1,
agentCreation: creation,
};
store.getState().setPending(submission);
store.getState().consumePending(submission);
expect(store.getState().pendingByDraftId[submission.draftId]).toBeUndefined();
expect(store.getState().creationByDraftId[submission.draftId]).toBe(creation);
store.getState().clearDraftSetup({ draftId: submission.draftId });
expect(store.getState().creationByDraftId[submission.draftId]).toBeUndefined();
});
@@ -1,16 +1,9 @@
import type { CreateAgentRequestOptions } from "@getpaseo/client/internal/daemon-client";
import type { AgentSnapshotPayload } from "@getpaseo/protocol/messages";
import { create } from "zustand";
import type { ComposerAttachment } from "@/attachments/types";
import type { AgentProvider } from "@getpaseo/protocol/agent-types";
import type { WorkspaceDraftTabSetup } from "@/workspace-tabs/model";
export interface PendingWorkspaceDraftSubmission {
/** Already-running creation. Mounting the draft only observes its result. */
agentCreation?: {
result: Promise<AgentSnapshotPayload>;
retry: (input: CreateAgentRequestOptions) => Promise<AgentSnapshotPayload>;
};
serverId: string;
workspaceId: string;
draftId: string;
@@ -35,7 +28,6 @@ export interface PendingWorkspaceDraftSetup {
interface WorkspaceDraftSubmissionState {
pendingByDraftId: Record<string, PendingWorkspaceDraftSubmission>;
setupByDraftId: Record<string, PendingWorkspaceDraftSetup>;
creationByDraftId: Record<string, NonNullable<PendingWorkspaceDraftSubmission["agentCreation"]>>;
setPending: (submission: PendingWorkspaceDraftSubmission) => void;
setDraftSetup: (input: {
draftId: string;
@@ -69,12 +61,8 @@ export const useWorkspaceDraftSubmissionStore = create<WorkspaceDraftSubmissionS
(set, get) => ({
pendingByDraftId: {},
setupByDraftId: {},
creationByDraftId: {},
setPending: (submission) =>
set((state) => ({
creationByDraftId: submission.agentCreation
? { ...state.creationByDraftId, [submission.draftId]: submission.agentCreation }
: state.creationByDraftId,
pendingByDraftId: {
...state.pendingByDraftId,
[submission.draftId]: submission,
@@ -94,10 +82,8 @@ export const useWorkspaceDraftSubmissionStore = create<WorkspaceDraftSubmissionS
const normalizedDraftId = normalizeDraftId(draftId);
if (!normalizedDraftId) return;
set((state) => {
const creationByDraftId = { ...state.creationByDraftId };
delete creationByDraftId[normalizedDraftId];
const { [normalizedDraftId]: _removed, ...setupByDraftId } = state.setupByDraftId;
return { setupByDraftId, creationByDraftId };
return { setupByDraftId };
});
},
consumePending: (input) => {
@@ -0,0 +1,9 @@
export {
useWorkspaceCreations,
useWorkspaceCreation,
useAgentCreation,
isAgentCreationPending,
isAgentReadyForPrompts,
useIsAgentReadyForPrompts,
} from "./runtime";
export { submitWorkspaceChat, takeWorkspaceCreationError } from "./submit";
@@ -0,0 +1,12 @@
/** Creation input is plain data; copy it without relying on browser-only structuredClone. */
export function captureCreationInput<T>(value: T): T;
export function captureCreationInput(value: unknown): unknown {
if (value instanceof Date) return new Date(value.getTime());
if (Array.isArray(value)) return value.map(captureCreationInput);
if (value !== null && typeof value === "object") {
return Object.fromEntries(
Object.entries(value).map(([key, item]) => [key, captureCreationInput(item)]),
);
}
return value;
}
@@ -0,0 +1,337 @@
import { describe, expect, it } from "vitest";
import type { CreationSnapshot } from "@getpaseo/protocol/messages";
import { createWorkspaceCreationModel } from "../model";
describe("optimistic workspace creation", () => {
it("publishes final identities and immutable input before preparing or receiving anything", async () => {
let prepare!: () => void;
const held = new Promise<void>((resolve) => {
prepare = resolve;
});
const requests: unknown[] = [];
const failures: string[] = [];
const model = createWorkspaceCreationModel({
identities: () => ({
operationKey: "operation",
workspaceId: "wks_0123456789abcdef",
agentId: "agent",
clientMessageId: "message",
}),
execute: async (request) => {
requests.push(request);
return { workspace: null, error: "test failure" };
},
});
const input = {
serverId: "host",
key: "draft",
provider: "codex" as const,
config: {},
projectId: "project",
projectName: "Project",
text: "My prompt",
onWorkspaceFailed: (_attempt: unknown, error: string) => failures.push(error),
prepare: async () => {
await held;
return {
source: { kind: "directory" as const, path: "/project" },
agent: {
config: { provider: "codex" as const, cwd: "/project" },
initialPrompt: "My prompt",
},
};
},
};
const attempt = model.submit(input);
expect(attempt.workspaceId).toBe("wks_0123456789abcdef");
expect(model.getWorkspace("host", attempt.workspaceId)?.text).toBe("My prompt");
expect(model.submit(input)).toBe(attempt);
expect(requests).toEqual([]);
prepare();
await model.settled(attempt);
expect(requests).toHaveLength(1);
expect(failures).toEqual(["test failure"]);
expect(model.getWorkspace("host", attempt.workspaceId)).toBeNull();
});
});
function deferred<T>() {
let resolve!: (value: T) => void;
const promise = new Promise<T>((done) => {
resolve = done;
});
return { promise, resolve };
}
function submission() {
return {
serverId: "host",
key: "draft-version-1",
provider: "codex" as const,
config: {},
projectId: "project",
projectName: "Project",
text: "Original prompt",
prepare: async () => ({
source: { kind: "directory" as const, path: "/project" },
agent: {
config: {
provider: "codex" as const,
cwd: "/project/nested",
featureValues: { option: true },
},
initialPrompt: "Original prompt",
attachments: [
{
type: "text" as const,
mimeType: "text/plain" as const,
text: "Attachment content",
title: "Context",
},
],
},
}),
};
}
function identities() {
return {
operationKey: "operation",
workspaceId: "wks_0123456789abcdef",
agentId: "agent",
clientMessageId: "message",
};
}
/** A host receipt; the workspace and agent payloads only need to be present. */
function failedCreation(update: object): CreationSnapshot {
return {
kind: "workspace",
idempotencyKey: "operation",
revision: 3,
phase: "failed",
workspaceId: identities().workspaceId,
agentId: "agent",
error: "Provider startup failed",
...update,
} as CreationSnapshot;
}
describe("creation lifecycle ownership", () => {
it.each([
["the transport closes before any receipt", { throws: "Client closed" }, "workspace"],
[
"provisioning fails",
{ creation: { failedStage: "workspace", outcomeUnknown: true } },
"workspace",
],
["agent creation fails after provisioning", { creation: { failedStage: "agent" } }, "agent"],
[
"the transport closes after the workspace was published",
{ throws: "Client closed", event: { phase: "workspace_ready", workspace: {} } },
"agent",
],
[
"the agent may exist",
{ creation: { failedStage: "agent", outcomeUnknown: true } },
"complete",
],
[
"the first prompt fails on a created agent",
{ creation: { failedStage: "prompt", outcomeUnknown: true } },
"complete",
],
] as const)("when %s, hands the creation to its %s owner", async (_name, outcome, owner) => {
const handled: string[] = [];
let executions = 0;
const model = createWorkspaceCreationModel({
identities,
execute: async (request) => {
executions++;
if ("event" in outcome)
request.onEvent?.(failedCreation({ ...outcome.event, revision: 1 }));
if ("throws" in outcome) throw new Error(outcome.throws);
return { error: "Provider startup failed", creation: failedCreation(outcome.creation) };
},
onFailed: () => handled.push("prompt rejected"),
});
const attempt = model.submit({
...submission(),
onWorkspaceFailed: () => handled.push("workspace"),
onAgentFailed: () => handled.push("agent"),
onComplete: () => handled.push("complete"),
});
await model.settled(attempt);
expect(handled).toEqual(owner === "complete" ? ["complete"] : ["prompt rejected", owner]);
// A kept agent stays presented until the host publishes it; a failure leaves at once.
expect(model.getWorkspace("host", attempt.workspaceId)?.phase ?? null).toBe(
owner === "complete" ? "complete" : null,
);
expect(executions).toBe(1);
});
it("returns a preparation failure to New workspace without any host execution", async () => {
let executions = 0;
const failures: string[] = [];
const model = createWorkspaceCreationModel({
identities,
execute: async () => {
executions++;
return {};
},
});
const attempt = model.submit({
...submission(),
prepare: async () => {
throw new Error("Image unavailable");
},
onWorkspaceFailed: (_attempt, error) => failures.push(error),
});
await model.settled(attempt);
expect(failures).toEqual(["Image unavailable"]);
expect(executions).toBe(0);
});
it("reports completion and adoption once", async () => {
const lifecycle: string[] = [];
const model = createWorkspaceCreationModel({
identities,
execute: async () => ({}),
onSubmitted: (attempt) => lifecycle.push(`submitted:${attempt.phase}`),
onComplete: (attempt) => lifecycle.push(`complete:${attempt.phase}`),
onAdopted: (attempt) => lifecycle.push(`adopted:${attempt.phase}`),
});
const attempt = model.submit(submission());
await model.settled(attempt);
model.observeResources("host", new Set([attempt.workspaceId]), new Set());
expect(lifecycle).toEqual(["submitted:preparing", "complete:complete"]);
model.observeResources("host", new Set([attempt.workspaceId]), new Set([attempt.agentId]));
model.observeResources("host", new Set([attempt.workspaceId]), new Set([attempt.agentId]));
expect(lifecycle).toEqual(["submitted:preparing", "complete:complete", "adopted:complete"]);
});
it("does not regress receipt revisions or release overlays on empty directory snapshots", async () => {
const response = deferred<{}>();
const observed =
deferred<
NonNullable<
Parameters<Parameters<typeof createWorkspaceCreationModel>[0]["execute"]>[0]["onEvent"]
>
>();
const model = createWorkspaceCreationModel({
identities,
execute: async (request) => {
observed.resolve(request.onEvent!);
return response.promise;
},
});
const attempt = model.submit(submission());
const onEvent = await observed.promise;
const event = {
kind: "workspace" as const,
idempotencyKey: "operation",
workspaceId: attempt.workspaceId,
agentId: attempt.agentId,
revision: 3,
phase: "workspace_ready" as const,
error: null,
};
onEvent(event);
onEvent({ ...event, revision: 1, phase: "accepted" });
model.observeResources("host", new Set(), new Set());
expect(model.getWorkspace("host", attempt.workspaceId)).toMatchObject({
revision: 3,
workspaceObserved: false,
agentObserved: false,
});
model.observeResources("host", new Set([attempt.workspaceId]), new Set([attempt.agentId]));
model.observeResources("host", new Set(), new Set());
response.resolve({});
await model.settled(attempt);
expect(model.getWorkspace("host", attempt.workspaceId)).toBeNull();
});
it("captures presentation without retaining caller mutation and permits a distinct newer intent", async () => {
const response = deferred<{}>();
let next = 0;
const model = createWorkspaceCreationModel({
identities: () => ({ ...identities(), agentId: `agent-${++next}` }),
execute: async () => response.promise,
});
const original = submission();
const first = model.submit(original);
original.text = "Newer draft";
const second = model.submit({ ...original, key: "draft-version-2" });
expect(first.text).toBe("Original prompt");
expect(second.text).toBe("Newer draft");
expect(second.agentId).not.toBe(first.agentId);
response.resolve({});
await model.settled(first);
await model.settled(second);
});
});
describe("settled presentation lifetime", () => {
it("retires adopted presentation while repeated submission still returns its original identities", async () => {
let executions = 0;
const model = createWorkspaceCreationModel({
identities,
execute: async () => {
executions++;
return {};
},
});
const input = submission();
const attempt = model.submit(input);
await model.settled(attempt);
model.observeResources("host", new Set([attempt.workspaceId]), new Set([attempt.agentId]));
expect(model.getSnapshot()).toEqual([]);
const repeated = model.submit(input);
expect(repeated.workspaceId).toBe(attempt.workspaceId);
expect(repeated.agentId).toBe(attempt.agentId);
await model.settled(repeated);
expect(executions).toBe(1);
expect(repeated.text).toBe("");
expect(repeated.message).toBeUndefined();
});
});
it("bounds adopted receipts and keeps in-flight input outside that bound", async () => {
let executions = 0;
let identity = 0;
const held = deferred<{}>();
const model = createWorkspaceCreationModel({
identities: () => ({
...identities(),
workspaceId: `workspace-${++identity}`,
agentId: `agent-${identity}`,
}),
execute: async (request) => {
executions++;
return request.agent?.initialPrompt === "held" ? held.promise : {};
},
});
const pending = model.submit({
...submission(),
key: "pending",
prepare: async () => ({
source: { kind: "directory", path: "/project" },
agent: { provider: "codex", cwd: "/project", initialPrompt: "held" },
}),
});
const first = { ...submission(), key: "settled-0" };
let latest = first;
for (let n = 0; n < 130; n++) {
latest = { ...submission(), key: `settled-${n}` };
const attempt = model.submit(latest);
await model.settled(attempt);
model.observeResources("host", new Set([attempt.workspaceId]), new Set([attempt.agentId]));
}
expect(model.getSnapshot()).toHaveLength(1);
expect(model.getSnapshot()[0]?.text).toBe("Original prompt");
const before = executions;
await model.settled(model.submit(latest));
expect(executions).toBe(before);
await model.settled(model.submit(first));
expect(executions).toBe(before + 1);
held.resolve({});
await model.settled(pending);
});
@@ -0,0 +1,159 @@
import { describe, expect, it } from "vitest";
import { createWorkspaceCreationModel } from "../model";
import {
includeCreatingAgent,
pendingCreationAgent,
workspaceCreationPresentation,
} from "../presentation";
import { addCreatingSidebarWorkspaces } from "../presentation";
import {
buildWorkspaceTabSnapshot,
deriveWorkspaceAgentVisibility,
} from "@/workspace-tabs/agent-visibility";
import { createWorkspaceLayoutStore } from "@/stores/workspace-layout-store";
import type { SidebarWorkspacePlacementModel } from "@/hooks/sidebar-workspaces-view-model";
function pendingCreation() {
let finish!: () => void;
const response = new Promise<{}>((resolve) => {
finish = () => resolve({});
});
const model = createWorkspaceCreationModel({
identities: () => ({
operationKey: "operation",
workspaceId: "wks_0123456789abcdef",
agentId: "reserved-agent",
clientMessageId: "message",
}),
execute: async () => response,
});
const attempt = model.submit({
serverId: "host",
key: "draft",
projectId: "project",
projectName: "Project",
provider: "mock",
config: { model: "e2e-fast-stream", modeId: "load-test" },
text: "My pending prompt",
prepare: async () => ({
source: { kind: "directory", path: "/project" },
agent: { config: { provider: "mock", cwd: "/project" }, initialPrompt: "My pending prompt" },
}),
});
return { model, attempt, finish };
}
describe("creation presentation across directory replacement", () => {
it("keeps the final agent tab through empty snapshots and releases it to ordinary archive rules after completion", async () => {
const { model, attempt, finish } = pendingCreation();
let next = 0;
const layouts = createWorkspaceLayoutStore({
createNodeId: (prefix) => `${prefix}_${++next}`,
createFocusRestorationToken: () => `focus-${++next}`,
});
const workspaceKey = `host:${attempt.workspaceId}`;
const tabId = layouts.getState().openTab({
workspaceKey,
target: { kind: "agent", agentId: attempt.agentId },
intent: "reveal",
});
const reconcileEmptyDirectory = () =>
layouts.getState().reconcileTabs(
workspaceKey,
buildWorkspaceTabSnapshot({
agentVisibility: includeCreatingAgent(
deriveWorkspaceAgentVisibility({
sessionAgents: new Map(),
workspaceId: attempt.workspaceId,
}),
model.getWorkspace("host", attempt.workspaceId),
),
agentsHydrated: true,
terminalsHydrated: true,
knownTerminalIds: [],
standaloneTerminalIds: [],
hasActivePendingTerminalCreate: false,
hasActivePendingDraftCreate: false,
}),
);
reconcileEmptyDirectory();
model.observeResources("host", new Set([attempt.workspaceId]), new Set([attempt.agentId]));
reconcileEmptyDirectory();
expect(
layouts
.getState()
.getWorkspaceTabs(workspaceKey)
.filter((tab) => tab.target.kind === "agent")
.map((tab) => tab.tabId),
).toEqual([tabId]);
finish();
await model.settled(attempt);
reconcileEmptyDirectory();
expect(
layouts
.getState()
.getWorkspaceTabs(workspaceKey)
.filter((tab) => tab.target.kind === "agent"),
).toEqual([]);
});
it("offers pending navigation without inventing an operational workspace directory", () => {
const { attempt, finish } = pendingCreation();
expect(workspaceCreationPresentation(null, attempt)).toEqual({
workspacePending: true,
exists: true,
projectName: "Project",
canInspectRecovery: false,
});
expect(attempt).not.toHaveProperty("workspaceDirectory");
expect(attempt).not.toHaveProperty("capabilities");
// The submitted config is displayable, but the source directory is not the destination.
expect(pendingCreationAgent(attempt)).toMatchObject({
id: "reserved-agent",
provider: "mock",
model: "e2e-fast-stream",
currentModeId: "load-test",
cwd: "",
status: "running",
});
finish();
});
it("keeps one sidebar entry through refresh and hands it to the authoritative directory", async () => {
const { model, attempt, finish } = pendingCreation();
const directory: SidebarWorkspacePlacementModel = {
projects: [
{
viewKey: "project",
projectName: "Project",
projectKind: "git",
iconWorkingDir: "/project",
hosts: [
{
serverId: "host",
projectId: "project",
iconWorkingDir: "/project",
worktreeSupport: "supported",
},
],
workspaces: [],
},
],
workspaces: [],
projectNamesByViewKey: new Map([["project", "Project"]]),
};
const first = addCreatingSidebarWorkspaces(directory, model.getSnapshot());
const refreshed = addCreatingSidebarWorkspaces(
{ ...directory, workspaces: [] },
model.getSnapshot(),
);
expect(first.workspaces).toEqual(refreshed.workspaces);
expect(refreshed.workspaces).toHaveLength(1);
expect(refreshed.workspaces[0]?.workspaceId).toBe(attempt.workspaceId);
expect(refreshed.workspaces[0]?.workspaceDirectory).toBeUndefined();
model.observeResources("host", new Set([attempt.workspaceId]), new Set());
expect(addCreatingSidebarWorkspaces(first, model.getSnapshot()).workspaces).toHaveLength(1);
finish();
await model.settled(attempt);
});
});
@@ -0,0 +1 @@
export { randomUUID } from "expo-crypto";
@@ -0,0 +1,3 @@
export function randomUUID(): string {
return crypto.randomUUID();
}
@@ -0,0 +1,249 @@
import { sha256 } from "@noble/hashes/sha2.js";
import { bytesToHex, utf8ToBytes } from "@noble/hashes/utils.js";
import { captureCreationInput } from "../internal/capture";
import type { CreateWorkspaceRequestOptions } from "@getpaseo/client/internal/daemon-client";
import type { AgentProvider } from "@getpaseo/protocol/agent-types";
import type { CreationSnapshot } from "@getpaseo/protocol/messages";
import type { UserMessageItem } from "@/types/stream";
export interface Submission {
serverId: string;
key: string;
provider: AgentProvider;
/** Submitted agent configuration, shown before the host publishes the agent. */
config: { modeId?: string; model?: string; thinkingOptionId?: string };
projectId: string;
projectName: string;
text: string;
message?: Omit<UserMessageItem, "id" | "clientMessageId">;
prepare: () => Promise<CreateWorkspaceRequestOptions>;
onComplete?: () => void;
/** The host created neither the workspace nor the agent. */
onWorkspaceFailed?: (attempt: CreationAttempt, error: string) => void;
/** The host created the workspace but not the agent. The receipt's workspace, when it has one. */
onAgentFailed?: (
attempt: CreationAttempt,
error: string,
workspace: CreationSnapshot["workspace"],
) => void;
}
type SubmissionHandlers = "prepare" | "onComplete" | "onWorkspaceFailed" | "onAgentFailed";
export interface CreationAttempt extends Omit<Submission, SubmissionHandlers> {
operationKey: string;
workspaceId: string;
agentId: string;
clientMessageId: string;
phase: "preparing" | "creating" | "complete";
revision: number;
workspaceObserved: boolean;
agentObserved: boolean;
}
interface Result {
workspace?: unknown;
error?: string | null;
creation?: CreationSnapshot;
}
interface Operation extends Pick<Submission, "onComplete" | "onWorkspaceFailed" | "onAgentFailed"> {
attempt: CreationAttempt;
prepare: Submission["prepare"] | null;
request?: CreateWorkspaceRequestOptions;
/** The host's latest account of this creation. */
creation?: CreationSnapshot;
running?: Promise<void>;
}
/** What the host is known to have created for a failed creation. */
function createdResources(
attempt: CreationAttempt,
creation: CreationSnapshot | undefined,
): "agent" | "workspace" | "none" {
if (
attempt.agentObserved ||
creation?.agent ||
creation?.failedStage === "prompt" ||
(creation?.failedStage === "agent" && creation.outcomeUnknown)
)
return "agent";
if (attempt.workspaceObserved || creation?.workspace || creation?.failedStage === "agent")
return "workspace";
return "none";
}
export function createWorkspaceCreationModel(dependencies: {
identities: () => Pick<
CreationAttempt,
"operationKey" | "workspaceId" | "agentId" | "clientMessageId"
>;
execute: (request: CreateWorkspaceRequestOptions, serverId: string) => Promise<Result>;
onSubmitted?: (attempt: CreationAttempt) => void;
/** The host did not create the agent; its prompt was never delivered. */
onFailed?: (attempt: CreationAttempt) => void;
onComplete?: (attempt: CreationAttempt) => void;
/** The completed creation's workspace and agent are both published. */
onAdopted?: (attempt: CreationAttempt) => void;
}) {
const listeners = new Set<() => void>();
let snapshot: readonly CreationAttempt[] = [];
const operations = new Map<string, Operation>();
// Current submissions remain owned until resolved or dismissed. Adopted creations
// only need a bounded receipt to absorb duplicate commands from retained forms.
const receipts = new Map<string, CreationAttempt>();
const intentKey = (serverId: string, key: string) =>
bytesToHex(sha256(utf8ToBytes(JSON.stringify([serverId, key]))));
const operationFor = (attempt: CreationAttempt) => operations.get(attempt.key);
function retire(operation: Operation) {
if (
operation.attempt.phase !== "complete" ||
!operation.attempt.workspaceObserved ||
!operation.attempt.agentObserved
)
return;
const receipt = { ...operation.attempt, text: "", message: undefined, projectName: "" };
receipts.set(receipt.key, receipt);
while (receipts.size > 128) receipts.delete(receipts.keys().next().value!);
operations.delete(receipt.key);
publish();
dependencies.onAdopted?.(operation.attempt);
}
function publish() {
snapshot = [...operations.values()].map(({ attempt }) => attempt);
listeners.forEach((listener) => listener());
}
function patch(
operation: NonNullable<ReturnType<typeof operations.get>>,
update: Partial<CreationAttempt>,
) {
operation.attempt = { ...operation.attempt, ...update };
publish();
}
function complete(operation: Operation) {
patch(operation, { phase: "complete" });
dependencies.onComplete?.(operation.attempt);
const onComplete = operation.onComplete;
operation.prepare = null;
operation.onComplete = undefined;
operation.onWorkspaceFailed = undefined;
operation.onAgentFailed = undefined;
operation.request = undefined;
onComplete?.();
retire(operation);
}
/** Hands a failure to the owner of what the host created: the agent, the workspace or nothing. */
function fail(operation: Operation, error: string) {
const created = createdResources(operation.attempt, operation.creation);
if (created === "agent") {
complete(operation);
return;
}
operations.delete(operation.attempt.key);
publish();
dependencies.onFailed?.(operation.attempt);
if (created === "workspace")
operation.onAgentFailed?.(operation.attempt, error, operation.creation?.workspace);
else operation.onWorkspaceFailed?.(operation.attempt, error);
}
async function run(operation: NonNullable<ReturnType<typeof operations.get>>) {
try {
if (!operation.request) {
if (!operation.prepare) throw new Error("Completed creation cannot be submitted again");
const prepared = await operation.prepare();
operation.request = captureCreationInput({
...prepared,
workspaceId: operation.attempt.workspaceId,
idempotencyKey: operation.attempt.operationKey,
agent: {
...prepared.agent,
agentId: operation.attempt.agentId,
clientMessageId: operation.attempt.clientMessageId,
},
});
}
patch(operation, { phase: "creating" });
const result = await dependencies.execute(
{
...captureCreationInput(operation.request),
onEvent: (event) => {
if (event.revision <= operation.attempt.revision) return;
operation.creation = event;
patch(operation, { revision: event.revision });
},
},
operation.attempt.serverId,
);
if (result.creation) operation.creation = result.creation;
if (result.error) {
fail(operation, result.error);
return;
}
complete(operation);
} catch (error) {
fail(operation, error instanceof Error ? error.message : String(error));
} finally {
operation.running = undefined;
}
}
function start(operation: NonNullable<ReturnType<typeof operations.get>>) {
operation.running = Promise.resolve().then(() => run(operation));
}
return {
subscribe(listener: () => void) {
listeners.add(listener);
return () => {
listeners.delete(listener);
};
},
getSnapshot: () => snapshot,
submit(input: Submission): CreationAttempt {
const key = intentKey(input.serverId, input.key);
const existing = operations.get(key);
if (existing) return existing.attempt;
const receipt = receipts.get(key);
if (receipt) return receipt;
const { prepare, onComplete, onWorkspaceFailed, onAgentFailed, ...presentation } = input;
const attempt: CreationAttempt = {
...captureCreationInput(presentation),
key,
...dependencies.identities(),
phase: "preparing",
revision: -1,
workspaceObserved: false,
agentObserved: false,
};
const operation: Operation = {
prepare,
onComplete,
onWorkspaceFailed,
onAgentFailed,
attempt,
};
operations.set(key, operation);
dependencies.onSubmitted?.(operation.attempt);
publish();
start(operation);
return operation.attempt;
},
observeResources(
serverId: string,
workspaces: ReadonlySet<string>,
agents: ReadonlySet<string>,
) {
for (const operation of operations.values()) {
const attempt = operation.attempt;
if (attempt.serverId !== serverId) continue;
const workspaceObserved = attempt.workspaceObserved || workspaces.has(attempt.workspaceId);
const agentObserved = attempt.agentObserved || agents.has(attempt.agentId);
if (
workspaceObserved !== attempt.workspaceObserved ||
agentObserved !== attempt.agentObserved
)
patch(operation, { workspaceObserved, agentObserved });
retire(operation);
}
},
getWorkspace: (serverId: string, workspaceId: string) =>
snapshot.find((item) => item.serverId === serverId && item.workspaceId === workspaceId) ??
null,
getAgent: (serverId: string, agentId: string) =>
snapshot.find((item) => item.serverId === serverId && item.agentId === agentId) ?? null,
settled: (attempt: CreationAttempt) => operationFor(attempt)?.running ?? Promise.resolve(),
};
}
@@ -0,0 +1,113 @@
import { i18n } from "@/i18n/i18next";
import type {
SidebarWorkspaceEntry,
SidebarWorkspacePlacement,
SidebarWorkspacePlacementModel,
} from "@/hooks/sidebar-workspaces-view-model";
import type { WorkspaceDescriptor } from "@/stores/session-store";
import type { WorkspaceAgentVisibility } from "@/workspace-tabs/agent-visibility";
import type { CreationAttempt } from "../model";
import type { AgentScreenAgent } from "@/hooks/use-agent-screen-state-machine";
export function workspaceCreationPresentation(
workspace: WorkspaceDescriptor | null,
attempt: CreationAttempt | null,
) {
const workspacePending = attempt !== null && !attempt.workspaceObserved;
return {
workspacePending,
exists: workspace !== null || attempt !== null,
projectName: attempt?.projectName,
canInspectRecovery: !workspacePending,
};
}
export function includeCreatingAgent(
visibility: WorkspaceAgentVisibility,
attempt: CreationAttempt | null,
): WorkspaceAgentVisibility {
if (!attempt) return visibility;
if (attempt.phase === "complete" && attempt.agentObserved) return visibility;
return {
activeAgentIds: new Set([...visibility.activeAgentIds, attempt.agentId]),
autoOpenAgentIds: new Set([...visibility.autoOpenAgentIds, attempt.agentId]),
};
}
/** The agent as submitted, until the host publishes it. Its destination cwd is not known yet. */
export function pendingCreationAgent(attempt: CreationAttempt): AgentScreenAgent {
return {
serverId: attempt.serverId,
id: attempt.agentId,
provider: attempt.provider,
status: "running",
cwd: "",
workspaceId: attempt.workspaceId,
currentModeId: attempt.config.modeId ?? null,
model: attempt.config.model ?? null,
thinkingOptionId: attempt.config.thinkingOptionId ?? null,
projectPlacement: null,
};
}
export function addCreatingSidebarWorkspaces(
model: SidebarWorkspacePlacementModel,
attempts: readonly CreationAttempt[],
translate = i18n.t,
): SidebarWorkspacePlacementModel {
const pending = attempts.filter((attempt) => !attempt.workspaceObserved);
if (!pending.length) return model;
const projects = model.projects.map((project) => {
const creations = pending.filter((attempt) =>
project.hosts.some(
(host) => host.serverId === attempt.serverId && host.projectId === attempt.projectId,
),
);
if (!creations.length) return project;
const workspaces = [...project.workspaces];
for (const attempt of creations) {
if (
workspaces.some(
(workspace) =>
workspace.serverId === attempt.serverId &&
workspace.workspaceId === attempt.workspaceId,
)
)
continue;
workspaces.push({
workspaceKey: `${attempt.serverId}:${attempt.workspaceId}`,
serverId: attempt.serverId,
workspaceId: attempt.workspaceId,
projectViewKey: project.viewKey,
projectName: project.projectName,
projectKind: project.projectKind,
workspaceKind: "checkout",
name: translate("newWorkspace.creation.creatingWorkspace"),
});
}
return { ...project, workspaces };
});
return { ...model, projects, workspaces: projects.flatMap((project) => project.workspaces) };
}
export function creatingSidebarEntry(
placement: SidebarWorkspacePlacement,
translate = i18n.t,
): SidebarWorkspaceEntry {
return {
...placement,
name: translate("newWorkspace.creation.creatingWorkspace"),
workspaceDirectory: "",
workspaceDirectoryLabel: "",
title: null,
currentBranch: null,
archivingAt: null,
diffStat: null,
prHint: null,
archiveHasUncommittedChanges: null,
archiveUnpushedCommitCount: null,
scripts: [],
hasRunningScripts: false,
statusBucket: "running",
statusEnteredAt: null,
};
}
@@ -0,0 +1,146 @@
import { i18n } from "@/i18n/i18next";
import { selectIsAgentPublished, useSessionStore } from "@/stores/session-store";
import { randomUUID } from "./internal/random-id";
import { useSyncExternalStore } from "react";
import { createWorkspaceCreationModel, type CreationAttempt } from "./model";
import { createMessageSubmissionWriter } from "@/composer/submission/writer";
import { createUserMessage } from "@/types/stream";
export const workspaceCreations = createWorkspaceCreationModel({
identities: () => ({
operationKey: randomUUID(),
workspaceId: `wks_${randomUUID().replaceAll("-", "").slice(0, 16)}`,
agentId: randomUUID(),
clientMessageId: randomUUID(),
}),
execute: async (request, serverId) => {
const { getHostRuntimeStore } = await import("@/runtime/host-runtime");
const runtime = getHostRuntimeStore();
const client = runtime.getClient(serverId);
if (!client) throw new Error(i18n.t("newWorkspace.errors.hostDisconnected"));
const workspaceId = request.workspaceId;
if (!workspaceId) throw new Error("Workspace creation requires a reserved identity");
const releaseDirectory = runtime.acquireDirectoryDemand(serverId);
// Failed and adopted creations both leave the model.
const stop = workspaceCreations.subscribe(() => {
if (workspaceCreations.getWorkspace(serverId, workspaceId)) return;
stop();
releaseDirectory();
});
return client.createWorkspace(request);
},
onSubmitted: (attempt) => {
createMessageSubmissionWriter(attempt.serverId).begin(
attempt.agentId,
createUserMessage({
...attempt.message,
text: attempt.text,
timestamp: attempt.message?.timestamp ?? new Date(),
id: attempt.clientMessageId,
clientMessageId: attempt.clientMessageId,
}),
);
},
onFailed: (attempt) => {
createMessageSubmissionWriter(attempt.serverId).reject(
attempt.agentId,
attempt.clientMessageId,
);
},
onAdopted: (attempt) => {
// Prompts queued during creation waited for this. A running agent drains them when it stops.
// The host started the initial prompt before completing, so its local submission is not
// evidence of a running turn; only the host's turn state is.
const session = useSessionStore.getState().sessions[attempt.serverId];
const agent =
session?.agents.get(attempt.agentId) ?? session?.agentDetails.get(attempt.agentId);
if (agent?.turn.phase !== "idle") return;
void import("@/runtime/host-runtime").then(({ getHostRuntimeStore }) =>
getHostRuntimeStore().drainQueuedAgentMessage(attempt.serverId, attempt.agentId),
);
},
onComplete: (attempt) => {
createMessageSubmissionWriter(attempt.serverId).accept(
attempt.agentId,
attempt.clientMessageId,
);
},
});
export function useWorkspaceCreations() {
return useSyncExternalStore(
workspaceCreations.subscribe,
workspaceCreations.getSnapshot,
workspaceCreations.getSnapshot,
);
}
export function useWorkspaceCreation(
serverId: string,
workspaceId: string,
): CreationAttempt | null {
return (
useWorkspaceCreations().find(
(attempt) =>
attempt.serverId === serverId &&
attempt.workspaceId === workspaceId &&
(attempt.phase !== "complete" || !attempt.workspaceObserved || !attempt.agentObserved),
) ?? null
);
}
export function useAgentCreation(serverId: string, agentId: string): CreationAttempt | null {
return (
useWorkspaceCreations().find(
(attempt) =>
attempt.serverId === serverId &&
attempt.agentId === agentId &&
(attempt.phase !== "complete" || !attempt.agentObserved),
) ?? null
);
}
useSessionStore.subscribe((state, previous) => {
for (const serverId of new Set(
workspaceCreations
.getSnapshot()
.filter((attempt) => !attempt.workspaceObserved || !attempt.agentObserved)
.map((attempt) => attempt.serverId),
)) {
const session = state.sessions[serverId];
const prior = previous.sessions[serverId];
if (
!session ||
(session.workspaces === prior?.workspaces &&
session.agents === prior?.agents &&
session.agentDetails === prior?.agentDetails)
)
continue;
workspaceCreations.observeResources(
serverId,
new Set([...session.workspaces.values()].map((workspace) => workspace.id)),
new Set([...session.agents.keys(), ...session.agentDetails.keys()]),
);
}
});
export function isAgentCreationPending(serverId: string, agentId: string): boolean {
const attempt = workspaceCreations.getAgent(serverId, agentId);
return attempt !== null && (attempt.phase !== "complete" || !attempt.agentObserved);
}
/**
* Whether prompts may be sent to the agent now. The host publishes a created agent before it
* starts the initial prompt, so a creating agent is ready only once its creation completes.
*/
export function isAgentReadyForPrompts(serverId: string, agentId: string): boolean {
return (
selectIsAgentPublished(useSessionStore.getState().sessions[serverId], agentId) &&
!isAgentCreationPending(serverId, agentId)
);
}
export function useIsAgentReadyForPrompts(serverId: string, agentId: string): boolean {
const isPublished = useSessionStore((state) =>
selectIsAgentPublished(state.sessions[serverId], agentId),
);
const creation = useAgentCreation(serverId, agentId);
return isPublished && creation === null;
}
@@ -0,0 +1,288 @@
import { i18n } from "@/i18n/i18next";
import { router } from "expo-router";
import { buildNewWorkspaceRoute } from "@/utils/host-routes";
import type { PendingWorkspaceDraftSetup } from "@/stores/workspace-draft-submission-store";
import { remapDraftCwdToWorkspace } from "@/screens/new-workspace-fork-context";
import { captureCreationInput } from "./internal/capture";
import { useDraftStore } from "@/stores/draft-store";
import {
buildDraftWorkspaceAttachmentScopeKey,
useWorkspaceAttachmentsStore,
} from "@/attachments/workspace-attachments-store";
import {
isWorkspaceAttachment,
userAttachmentsOnly,
} from "@/attachments/workspace-attachment-utils";
import type { QueryClient } from "@tanstack/react-query";
import type { DaemonClient } from "@getpaseo/client/internal/daemon-client";
import type { MessagePayload } from "@/composer/types";
import type { useAgentInputDraft } from "@/composer/draft/input-draft";
import {
splitComposerAttachmentsForSubmit,
resolveComposerAttachmentSubmitFormat,
} from "@/composer/attachments/submit";
import { ensureCheckoutStatus } from "@/git/checkout-status-cache";
import { getHostProjectId, type HostProjectListItem } from "@/projects/host-projects";
import { captureWorkspaceDraftCleanup } from "@/screens/new-workspace/background-handoff";
import { getAttachmentKey } from "@/composer/attachments/workspace-cleanup";
import {
defaultBasePickerItem,
pickerItemToCheckoutRequest,
type PickerItem,
} from "@/screens/new-workspace-picker-item";
import { useSessionStore } from "@/stores/session-store";
import { transferComposerFollowUps } from "@/composer/submission/writer";
import { useCreateFlowStore } from "@/stores/create-flow-store";
import { buildDraftStoreKey, generateDraftId } from "@/stores/draft-keys";
import { useWorkspaceLayoutStore } from "@/stores/workspace-layout-store";
import {
buildWorkspaceTabPersistenceKey,
type WorkspaceDraftTabSetup,
} from "@/workspace-tabs/model";
import type { CreationSnapshot } from "@getpaseo/protocol/messages";
import { encodeImages } from "@/utils/encode-images";
import type { CreationAttempt } from "./model";
import { workspaceCreations } from "./runtime";
type ComposerState = NonNullable<ReturnType<typeof useAgentInputDraft>["composerState"]>;
type DraftCleanupInput = Parameters<typeof captureWorkspaceDraftCleanup>[0];
interface SubmitWorkspaceChatInput extends DraftCleanupInput {
serverId: string;
key: string;
returnDraftId?: string;
project: HostProjectListItem | null;
sourceDirectory: string | null;
worktreeSlug: string;
createsWorktree: boolean;
selectedItem: PickerItem | null;
payload: MessagePayload;
forkSetup: PendingWorkspaceDraftSetup | null;
composerState: ComposerState | null;
supportsForgeSearch: boolean;
client: DaemonClient;
queryClient: QueryClient;
persistPreferences: () => Promise<unknown>;
}
// Failed creations return to New workspace, whose error lives in screen state.
const workspaceErrors = new Map<string, string>();
/** The error of a creation that returned to the New workspace draft with this key, once. */
export function takeWorkspaceCreationError(draftKey: string): string | null {
const error = workspaceErrors.get(draftKey) ?? null;
workspaceErrors.delete(draftKey);
return error;
}
/**
* The composer released the submitted context when it submitted; the draft that takes the
* submission back owns it again. Context added to the draft since then stays as it is.
*/
function restoreSubmittedContext(draftId: string, payload: MessagePayload) {
const scopeKey = buildDraftWorkspaceAttachmentScopeKey(draftId);
const attachments = useWorkspaceAttachmentsStore.getState();
const present = new Set((attachments.attachmentsByScope[scopeKey] ?? []).map(getAttachmentKey));
for (const attachment of payload.attachments.filter(isWorkspaceAttachment)) {
if (!present.has(getAttachmentKey(attachment)))
attachments.addWorkspaceAttachment({ scopeKey, attachment });
}
}
/**
* The workspace exists without its agent, so the input continues as that workspace's agent draft,
* failed exactly as a draft whose own agent creation failed, holding the submitted input, context
* and setup.
*/
function continueAsFailedAgentDraft(input: {
attempt: CreationAttempt;
draftId: string;
payload: MessagePayload;
setup: WorkspaceDraftTabSetup;
sourceDirectory: string;
workspace: CreationSnapshot["workspace"];
error: string;
}) {
const { attempt, draftId } = input;
const workspaceDirectory =
input.workspace?.workspaceDirectory ??
useSessionStore.getState().sessions[attempt.serverId]?.workspaces.get(attempt.workspaceId)
?.workspaceDirectory ??
input.sourceDirectory;
useDraftStore.getState().saveDraftInput({
draftKey: buildDraftStoreKey({ serverId: attempt.serverId, agentId: "", draftId }),
draft: {
text: input.payload.text,
attachments: userAttachmentsOnly(input.payload.attachments),
},
});
restoreSubmittedContext(draftId, input.payload);
const createFlow = useCreateFlowStore.getState();
createFlow.setPending({
draftId,
serverId: attempt.serverId,
workspaceId: attempt.workspaceId,
agentId: null,
clientMessageId: `${draftId}:initial-message`,
text: attempt.text,
timestamp: Date.now(),
});
createFlow.markLifecycle({ draftId, lifecycle: "abandoned", errorMessage: input.error });
const layouts = useWorkspaceLayoutStore.getState();
const workspaceKey = buildWorkspaceTabPersistenceKey({
serverId: attempt.serverId,
workspaceId: attempt.workspaceId,
});
if (!workspaceKey) return;
// The submitted cwd is relative to the source checkout; the draft runs in the created workspace.
const setup = {
...input.setup,
cwd: remapDraftCwdToWorkspace({
cwd: input.setup.cwd,
sourceDirectory: input.sourceDirectory,
workspaceDirectory,
}),
};
const target = { kind: "draft", draftId, setup } as const;
const agentTabs = layouts
.getWorkspaceTabs(workspaceKey)
.filter((tab) => tab.target.kind === "agent" && tab.target.agentId === attempt.agentId);
const draftTabIds = agentTabs.length
? agentTabs.map((tab) => layouts.replaceTab(workspaceKey, tab.tabId, target))
: [layouts.openTab({ workspaceKey, target, intent: "background" })];
// The draft composer owns the follow-ups until its own agent creation hands them on.
const draftTabId = draftTabIds.find((tabId) => tabId !== null);
if (draftTabId) transferComposerFollowUps(attempt.serverId, attempt.agentId, draftTabId);
}
/** Captures one intent. All preparation continues under the app owner after navigation. */
export function submitWorkspaceChat(input: SubmitWorkspaceChatInput) {
const { serverId, project, sourceDirectory, composerState } = input;
if (!useSessionStore.getState().sessions[serverId]?.serverInfo?.features?.creationLifecycle) {
throw new Error(i18n.t("newWorkspace.creation.updateHost"));
}
if (!project || !sourceDirectory)
throw new Error(i18n.t("newWorkspace.creation.chooseProjectHost"));
if (!composerState?.selectedProvider) throw new Error(i18n.t("newWorkspace.errors.selectModel"));
const projectId = getHostProjectId(project, serverId);
if (!projectId) throw new Error(i18n.t("newWorkspace.creation.projectUnavailable"));
const payload = captureCreationInput(input.payload);
const provider = composerState.selectedProvider;
const cwd = input.forkSetup
? remapDraftCwdToWorkspace({
cwd: input.forkSetup.setup.cwd,
sourceDirectory,
workspaceDirectory: sourceDirectory,
})
: payload.cwd;
const config = captureCreationInput({
provider,
cwd,
modeId: composerState.effectiveModeId || undefined,
model: composerState.effectiveModelId || undefined,
thinkingOptionId: composerState.effectiveThinkingOptionId || undefined,
featureValues: composerState.featureValues,
});
const wire = splitComposerAttachmentsForSubmit(payload.attachments, {
format: resolveComposerAttachmentSubmitFormat({
supportsForgeAttachments: input.supportsForgeSearch,
}),
});
const selectedItem = captureCreationInput(input.selectedItem);
const clearConsumedDraft = captureWorkspaceDraftCleanup(input);
const draftVersion = useDraftStore.getState().drafts[input.draftKey]?.version;
const intent = JSON.stringify([
input.key,
draftVersion,
projectId,
sourceDirectory,
input.createsWorktree,
selectedItem,
config,
payload,
]);
const submitted = workspaceCreations.submit({
serverId,
key: intent,
provider,
config: {
modeId: config.modeId,
model: config.model,
thinkingOptionId: config.thinkingOptionId,
},
projectId,
projectName: project.projectName,
text: payload.text,
onWorkspaceFailed: (attempt, error) => {
workspaceErrors.set(input.draftKey, error);
if (input.returnDraftId) restoreSubmittedContext(input.returnDraftId, payload);
// The New workspace composer owns the follow-ups until the form is submitted again.
transferComposerFollowUps(serverId, attempt.agentId, input.draftKey);
router.replace(
buildNewWorkspaceRoute({
serverId,
sourceDirectory,
projectId,
draftId: input.returnDraftId,
}) as never,
);
},
onAgentFailed: (attempt, error, workspace) => {
// Release the form first; the draft then takes the submitted input and its context.
clearConsumedDraft();
continueAsFailedAgentDraft({
attempt,
draftId: input.draftId?.trim() || generateDraftId(),
payload,
setup: {
provider,
cwd,
modeId: config.modeId ?? null,
model: config.model ?? null,
thinkingOptionId: config.thinkingOptionId ?? null,
featureValues: config.featureValues ?? {},
},
sourceDirectory,
workspace,
error,
});
},
message: {
kind: "user_message",
text: payload.text,
timestamp: new Date(),
images: wire.images,
attachments: wire.attachments,
},
prepare: async () => {
await input.persistPreferences();
const images = await encodeImages(wire.images);
const status = input.createsWorktree
? await ensureCheckoutStatus({
queryClient: input.queryClient,
client: input.client,
serverId,
cwd: sourceDirectory,
})
: null;
const checkout = status
? pickerItemToCheckoutRequest(selectedItem ?? defaultBasePickerItem(status))
: undefined;
return {
source: input.createsWorktree
? {
kind: "worktree",
cwd: sourceDirectory,
projectId,
worktreeSlug: input.worktreeSlug,
...checkout,
}
: { kind: "directory", path: sourceDirectory, projectId },
agent: { config, initialPrompt: payload.text, images, attachments: wire.attachments },
};
},
onComplete: clearConsumedDraft,
});
// Follow-ups kept by a returned form continue behind this submission's initial prompt.
transferComposerFollowUps(serverId, input.draftKey, submitted.agentId);
return submitted;
}
@@ -0,0 +1,56 @@
import { useTranslation } from "react-i18next";
import type { WorkspaceTabTarget } from "@/workspace-tabs/model";
import type { WorkspaceTabMenuEntry } from "@/screens/workspace/workspace-tab-menu";
import { useMemo } from "react";
import { PressHighlight } from "@/components/ui/press-highlight";
import { Text } from "react-native";
import { StyleSheet } from "react-native-unistyles";
import { useAgentCreation } from "../runtime";
import type { CreationAttempt } from "../model";
export function CreatingWorkspaceRow({
attempt,
selected,
onPress,
}: {
attempt: CreationAttempt;
selected: boolean;
onPress: () => void;
}) {
const { t } = useTranslation();
const accessibilityState = useMemo(() => ({ selected }), [selected]);
return (
<PressHighlight
accessibilityRole="button"
accessibilityLabel={t("newWorkspace.creation.creatingLabel")}
accessibilityState={accessibilityState}
testID={`sidebar-workspace-row-${attempt.serverId}:${attempt.workspaceId}`}
onPress={onPress}
style={styles.pendingRow}
>
<Text style={styles.status}>{t("newWorkspace.creation.creatingWorkspace")}</Text>
</PressHighlight>
);
}
const styles = StyleSheet.create((theme) => ({
pendingRow: { paddingHorizontal: theme.spacing[4], paddingVertical: theme.spacing[2] },
status: { color: theme.colors.foregroundMuted, fontSize: theme.fontSize.base },
}));
const RESOURCE_ACTIONS = new Set(["rename", "reload-agent", "copy-resume-command"]);
export function useCreationTabMenuEntries(
serverId: string,
target: WorkspaceTabTarget,
entries: WorkspaceTabMenuEntry[],
): WorkspaceTabMenuEntry[] {
const agentId = target.kind === "agent" ? target.agentId : "";
const creation = useAgentCreation(serverId, agentId);
return useMemo(() => {
if (!creation) return entries;
return entries.map((entry) =>
entry.kind === "item" && RESOURCE_ACTIONS.has(entry.key)
? { ...entry, disabled: true }
: entry,
);
}, [creation, entries]);
}
@@ -23,6 +23,38 @@ describe("resolveCreateAgentIntent", () => {
});
});
it("places a combined creation inside its selected workspace and keeps parentage", async () => {
const intent = await resolveCreateAgentIntent({
explicitWorkspaceId: "workspace-new",
workspaceRelativeCwd: "packages/app",
caller: { id: "parent", cwd: "/source", workspaceId: "workspace-source" },
resolveWorkspace: async (workspaceId) => ({ workspaceId, cwd: "/new" }),
createWorkspace: async () => {
throw new Error("Unexpected workspace creation");
},
});
expect(intent.cwd).toBe("/new/packages/app");
expect(intent.workspaceId).toBe("workspace-new");
expect(intent.parentAgentId).toBe("parent");
});
it.each(["../outside", "/outside"])(
"rejects an invalid relative placement: %s",
async (workspaceRelativeCwd) => {
await expect(
resolveCreateAgentIntent({
explicitWorkspaceId: "workspace-new",
workspaceRelativeCwd,
caller: null,
resolveWorkspace: async (workspaceId) => ({ workspaceId, cwd: "/new" }),
createWorkspace: async () => {
throw new Error("Unexpected workspace creation");
},
}),
).rejects.toThrow("inside the selected workspace");
},
);
it("defaults an agent caller to its workspace without creating one", async () => {
let createCount = 0;
const intent = await resolveCreateAgentIntent({
@@ -1,3 +1,4 @@
import { isAbsolute, relative, resolve, sep } from "node:path";
import { PARENT_AGENT_ID_LABEL } from "@getpaseo/protocol/agent-labels";
export interface CreateAgentCaller {
@@ -20,6 +21,9 @@ export interface CreateAgentIntent {
export async function resolveCreateAgentIntent(input: {
explicitWorkspaceId?: string;
// Combined creation remaps a source-relative directory into the selected workspace.
// Ordinary explicit-workspace requests continue to use the workspace's root.
workspaceRelativeCwd?: string;
caller: CreateAgentCaller | null;
labels?: Record<string, string>;
childAgentDefaultLabels?: Record<string, string>;
@@ -47,12 +51,27 @@ export async function resolveCreateAgentIntent(input: {
async function resolvePlacement(input: {
explicitWorkspaceId?: string;
workspaceRelativeCwd?: string;
caller: CreateAgentCaller | null;
resolveWorkspace: (workspaceId: string) => Promise<CreateAgentPlacement>;
createWorkspace: () => Promise<CreateAgentPlacement>;
}): Promise<CreateAgentPlacement> {
if (input.explicitWorkspaceId) {
return input.resolveWorkspace(input.explicitWorkspaceId);
const placement = await input.resolveWorkspace(input.explicitWorkspaceId);
if (input.workspaceRelativeCwd === undefined) return placement;
const cwd = resolve(placement.cwd, input.workspaceRelativeCwd);
const relativeCwd = relative(resolve(placement.cwd), cwd);
if (
isAbsolute(input.workspaceRelativeCwd) ||
relativeCwd === ".." ||
relativeCwd.startsWith(`..${sep}`)
) {
throw new Error("Agent directory must be inside the selected workspace");
}
return { ...placement, cwd };
}
if (input.workspaceRelativeCwd !== undefined) {
throw new Error("A relative agent directory requires an explicit workspace");
}
if (input.caller) {
if (!input.caller.workspaceId) {
+6 -4
View File
@@ -4253,14 +4253,12 @@ export class Session {
...agentInput,
type: "create_agent_request",
requestId,
config: {
...agentInput.config,
cwd: resolve(workspace.workspaceDirectory, relativeCwd),
},
config: agentInput.config,
workspaceId: workspace.id,
},
id,
onReady,
relativeCwd,
);
}
: undefined,
@@ -4381,6 +4379,7 @@ export class Session {
msg: CreateAgentRequestMessage,
agentId?: string,
onAgentReady?: (agent: AgentSnapshotPayload) => Promise<void>,
workspaceRelativeCwd?: string,
): Promise<CreatedAgent> {
const {
config,
@@ -4433,6 +4432,7 @@ export class Session {
request: msg,
createdWorktree,
workspacePromptTitle,
workspaceRelativeCwd,
});
const resolvedCwd = resolve(resolvedIntent.config.cwd);
if (!(await this.filesystem.isDirectory(resolvedCwd))) {
@@ -4506,6 +4506,7 @@ export class Session {
request: CreateAgentRequestMessage;
createdWorktree: CreatePaseoWorktreeWorkflowResult | null;
workspacePromptTitle: string | null;
workspaceRelativeCwd?: string;
}): Promise<ResolvedSessionCreateAgentIntent> {
const { request, createdWorktree } = input;
const callerAgent = request.callerAgentId
@@ -4519,6 +4520,7 @@ export class Session {
const intent = await resolveCreateAgentIntent({
explicitWorkspaceId: createdWorktree?.workspace.workspaceId ?? request.workspaceId,
workspaceRelativeCwd: input.workspaceRelativeCwd,
caller: callerAgent
? { id: callerAgent.id, cwd: callerAgent.cwd, workspaceId: callerAgent.workspaceId }
: null,
@@ -1,5 +1,5 @@
import { execFileSync } from "node:child_process";
import { existsSync, mkdtempSync, readFileSync, writeFileSync } from "node:fs";
import { existsSync, mkdirSync, mkdtempSync, readFileSync, writeFileSync } from "node:fs";
import { rm } from "node:fs/promises";
import { tmpdir } from "node:os";
import path from "node:path";
@@ -928,3 +928,55 @@ test("agent creation rejects legacy worktree intent without provisioning or exec
await rm(tempRoot, { recursive: true, force: true });
}
});
test.each(["local", "worktree"] as const)(
"combined %s creation preserves nested agent placement and rejects invalid source directories",
async (isolation) => {
const daemon = await createTestPaseoDaemon();
const { repoDir, tempRoot } = createGitRepoWithBranch();
mkdirSync(path.join(repoDir, "nested"));
writeFileSync(path.join(repoDir, "nested", "context.txt"), "nested directory");
execFileSync("git", ["add", "."], { cwd: repoDir });
execFileSync("git", ["-c", "commit.gpgsign=false", "commit", "-m", "nested directory"], {
cwd: repoDir,
});
const client = new SocketDaemonClient({
url: `ws://127.0.0.1:${daemon.port}/ws`,
clientId: "nested-placement-test",
webSocketFactory: (url) => new WebSocket(url) as unknown as WebSocketLike,
});
try {
await client.connect();
for (const subdirectory of ["nested", "", "missing", "../outside"]) {
const result = await client.createWorkspace({
source:
isolation === "local"
? { kind: "directory", path: repoDir }
: { kind: "worktree", cwd: repoDir },
agent: { config: { provider: "codex", cwd: path.resolve(repoDir, subdirectory) } },
});
expect(result.workspace).toBeTruthy();
if (subdirectory === "missing" || subdirectory === "../outside") {
expect(result.error).toContain(
subdirectory === "missing"
? "Working directory does not exist"
: "inside the workspace source",
);
expect(result.agent).toBeUndefined();
} else {
expect(result.error).toBeNull();
expect(result.agent?.workspaceId).toBe(result.workspace!.id);
const actual = await client.fetchAgent({ agentId: result.agent!.id });
expect(actual.agent.cwd).toBe(
path.join(result.workspace!.workspaceDirectory!, subdirectory),
);
}
}
} finally {
await client.close();
await daemon.close();
await rm(tempRoot, { recursive: true, force: true, maxRetries: 10, retryDelay: 100 });
}
},
180000,
);