mirror of
https://github.com/getpaseo/paseo.git
synced 2026-10-26 19:22:51 +00:00
Compare commits
3
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c708c263c8 | ||
|
|
0b55d63ab1 | ||
|
|
3a3ccd5385 |
+44
-5
@@ -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 checkout
|
||||
selection, 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
|
||||
|
||||
Generated
+1
@@ -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",
|
||||
|
||||
@@ -13,9 +13,7 @@ import {
|
||||
closeImageLightbox,
|
||||
pressInterruptShortcut,
|
||||
expectComposerDraft,
|
||||
expectComposerDisabled,
|
||||
expectComposerEditable,
|
||||
expectAttachButtonDisabled,
|
||||
fillComposerDraft,
|
||||
dropFileOnComposer,
|
||||
sendDraftToQueue,
|
||||
@@ -268,7 +266,9 @@ test.describe("Composer attachments", () => {
|
||||
}
|
||||
});
|
||||
|
||||
test("composer is locked while new workspace agent is being created", async ({ page }) => {
|
||||
test("composer and attachments stay available while new workspace agent is being created", async ({
|
||||
page,
|
||||
}) => {
|
||||
test.setTimeout(120_000);
|
||||
const serverId = getServerId();
|
||||
|
||||
@@ -298,8 +298,9 @@ test.describe("Composer attachments", () => {
|
||||
await agentCreatedDelay.waitForCreateRequest();
|
||||
await agentCreatedDelay.waitForDelayedCreatedStatus();
|
||||
|
||||
await expectComposerDisabled(page);
|
||||
await expectAttachButtonDisabled(page);
|
||||
await expectComposerEditable(page);
|
||||
await attachImageFromMenu(page, TEST_IMAGE);
|
||||
await expectAttachmentPill(page, "composer-image-attachment-pill");
|
||||
|
||||
agentCreatedDelay.release();
|
||||
|
||||
|
||||
@@ -1,4 +1,12 @@
|
||||
import { expectPickerSelected } from "../support/helpers/new-workspace";
|
||||
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 +113,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 +191,356 @@ 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 preserves its non-default branch through resubmission", async ({
|
||||
creation,
|
||||
provisioningFailure,
|
||||
page,
|
||||
}) => {
|
||||
const prompt = "Keep the selected worktree base when provisioning fails.";
|
||||
const provisioning = await holdWorkspaceProvisioning(page);
|
||||
await creation.openWorkspaceForm("worktree");
|
||||
await creation.selectWorktreeBranch("recovery-base");
|
||||
await provisioningFailure.arm();
|
||||
await creation.submitPrompt(prompt, "Create");
|
||||
const first = await provisioning.waitForRequest();
|
||||
expect(first.source).toMatchObject({
|
||||
kind: "worktree",
|
||||
action: "branch-off",
|
||||
refName: "refs/heads/recovery-base",
|
||||
});
|
||||
provisioning.release();
|
||||
await creation.expectReturnedToNewWorkspace(prompt, "Provisioning outcome is uncertain for test");
|
||||
|
||||
await expectPickerSelected(page, "recovery-base");
|
||||
await creation.resubmitWorkspace();
|
||||
const second = await provisioning.waitForRequests(2);
|
||||
expect(second.source).toMatchObject({
|
||||
kind: "worktree",
|
||||
action: "branch-off",
|
||||
refName: "refs/heads/recovery-base",
|
||||
});
|
||||
provisioning.release();
|
||||
await creation.expectOneCreatedWorkspace();
|
||||
await creation.expectAgentCount(1);
|
||||
});
|
||||
|
||||
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) {
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { observeCreationRequests } from "../support/helpers/creation";
|
||||
import {
|
||||
composerLocator,
|
||||
enterComposerFullscreen,
|
||||
exitComposerFullscreen,
|
||||
fillComposerDraft,
|
||||
resizeComposerViewport,
|
||||
} from "../support/helpers/composer";
|
||||
import { expect, test } from "../support/fixtures";
|
||||
@@ -95,6 +96,7 @@ test.describe("New workspace composer draft", () => {
|
||||
|
||||
test("does not restore a submitted draft after deferred publication", async ({ page }) => {
|
||||
const project = await seedWorkspace({ repoPrefix: "new-workspace-submitted-draft-" });
|
||||
const requests = await observeCreationRequests(page);
|
||||
|
||||
try {
|
||||
await gotoAppShell(page);
|
||||
@@ -134,6 +136,9 @@ test.describe("New workspace composer draft", () => {
|
||||
}, DRAFT);
|
||||
|
||||
await page.waitForURL((url) => url.pathname.includes("/workspace/"), { timeout: 30_000 });
|
||||
// Navigation now precedes creation; wait for success before checking consumed input.
|
||||
await expect.poll(requests.workspaceRequests).toBe(1);
|
||||
await requests.settled();
|
||||
await openGlobalNewWorkspaceComposer(page);
|
||||
await expectNewWorkspaceDraft(page, "");
|
||||
} finally {
|
||||
@@ -142,10 +147,11 @@ test.describe("New workspace composer draft", () => {
|
||||
});
|
||||
});
|
||||
|
||||
test("fullscreen creation returns inline and keeps submission locked when reopened", async ({
|
||||
test("fullscreen creation opens the inline agent composer and keeps follow-up editing available", async ({
|
||||
page,
|
||||
}) => {
|
||||
const project = await seedWorkspace({ repoPrefix: "fullscreen-submit-pending-" });
|
||||
const followUpDraft = `${DRAFT}\nKeep this follow-up draft.`;
|
||||
const delay = await delayBrowserWorkspaceCreatedResponse(page);
|
||||
try {
|
||||
await gotoAppShell(page);
|
||||
@@ -157,25 +163,23 @@ test("fullscreen creation returns inline and keeps submission locked when reopen
|
||||
await page.getByRole("button", { name: "Create", exact: true }).click();
|
||||
await delay.waitForCreateRequest();
|
||||
|
||||
await test.step("submission returns inline with the original draft and spinner", async () => {
|
||||
await test.step("submission opens the normal agent panel inline", async () => {
|
||||
await expect(page).toHaveURL(/\/workspace\//);
|
||||
await expect(page.getByTestId("composer-fullscreen")).toHaveCount(0);
|
||||
await expect(composerLocator(page)).toHaveValue(DRAFT);
|
||||
await expect(composerLocator(page)).not.toBeEditable();
|
||||
await expect(page.getByRole("button", { name: "Create", exact: true })).toBeDisabled();
|
||||
await expect(page.getByRole("progressbar")).toBeVisible();
|
||||
await expect(page.getByTestId("user-message").filter({ visible: true })).toContainText(DRAFT);
|
||||
await expect(composerLocator(page)).toHaveValue("");
|
||||
await expect(composerLocator(page)).toBeEditable();
|
||||
});
|
||||
await test.step("fullscreen can reopen while pending without unlocking or duplicating submission", async () => {
|
||||
await test.step("fullscreen can reopen to edit a follow-up while creation is pending", async () => {
|
||||
await fillComposerDraft(page, followUpDraft);
|
||||
await enterComposerFullscreen(page);
|
||||
await expect(page.getByTestId("composer-fullscreen")).toBeVisible();
|
||||
await expect(composerLocator(page)).toHaveValue(DRAFT);
|
||||
await expect(composerLocator(page)).not.toBeEditable();
|
||||
await expect(page.getByRole("button", { name: "Create", exact: true })).toBeDisabled();
|
||||
await expect(page.getByRole("progressbar")).toBeVisible();
|
||||
await exitComposerFullscreen(page);
|
||||
await expect(composerLocator(page)).toHaveValue(followUpDraft);
|
||||
await expect(composerLocator(page)).toBeEditable();
|
||||
});
|
||||
delay.release();
|
||||
await page.waitForURL((url) => url.pathname.includes("/workspace/"));
|
||||
await expect(composerLocator(page)).toHaveValue("");
|
||||
await expect(composerLocator(page)).toHaveValue(followUpDraft);
|
||||
await expect(composerLocator(page)).toBeEditable();
|
||||
expect(delay.agentRequests).toHaveLength(1);
|
||||
} finally {
|
||||
|
||||
@@ -192,11 +192,16 @@ test.describe("New Workspace dictation submit", () => {
|
||||
await harness.waitForCreateRequest();
|
||||
|
||||
const composer = page.getByRole("textbox", { name: "Message agent..." });
|
||||
await expect(composer).toHaveValue(TRANSCRIPT);
|
||||
await expect(composer).not.toBeEditable();
|
||||
await expect(page).toHaveURL(/\/workspace\//);
|
||||
await expect(page.getByTestId("user-message").filter({ visible: true })).toContainText(
|
||||
TRANSCRIPT,
|
||||
);
|
||||
await expect(composer).toHaveValue("");
|
||||
await expect(composer).toBeEditable();
|
||||
|
||||
harness.failWorkspaceCreation();
|
||||
|
||||
await expect(page).toHaveURL(/\/new\?/);
|
||||
await expect(composer).toBeEditable();
|
||||
await expect(composer).toHaveValue(TRANSCRIPT);
|
||||
await expect(page.getByText(CREATE_FAILURE).first()).toBeVisible();
|
||||
|
||||
@@ -504,7 +504,7 @@ test.describe("New workspace flow", () => {
|
||||
}
|
||||
});
|
||||
|
||||
test("redirects to the optimistic draft tab before agent creation resolves", async ({ page }) => {
|
||||
test("opens the final agent tab before agent creation resolves", async ({ page }) => {
|
||||
const serverId = getServerId();
|
||||
|
||||
const tempRepo = await createTempGitRepo("new-workspace-optimistic-");
|
||||
@@ -568,10 +568,10 @@ test.describe("New workspace flow", () => {
|
||||
await expect(activeWorkspaceDeckEntry).toBeVisible({ timeout: 30_000 });
|
||||
|
||||
const draftTabs = activeWorkspaceDeckEntry.locator('[data-testid^="workspace-tab-draft_"]');
|
||||
await expect(draftTabs).toHaveCount(1, { timeout: 30_000 });
|
||||
await expect(draftTabs).toHaveCount(0);
|
||||
await expect(
|
||||
activeWorkspaceDeckEntry.locator('[data-testid^="workspace-tab-agent_"]'),
|
||||
).toHaveCount(0);
|
||||
).toHaveCount(1, { timeout: 30_000 });
|
||||
|
||||
agentCreatedDelay.release();
|
||||
await expect(
|
||||
|
||||
@@ -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));
|
||||
},
|
||||
|
||||
@@ -1,12 +1,17 @@
|
||||
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,
|
||||
openStartingRefPicker,
|
||||
startingRefRow,
|
||||
selectWorkspaceIsolation,
|
||||
loadSessionMessageReaders,
|
||||
} from "./new-workspace";
|
||||
@@ -44,6 +49,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 +101,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 +128,7 @@ export async function observeCreationRequests(page: Page) {
|
||||
});
|
||||
});
|
||||
return {
|
||||
workspaceRequests: () => workspaceRequests,
|
||||
async settled() {
|
||||
await expect.poll(() => pending.size).toBe(0);
|
||||
},
|
||||
@@ -138,18 +183,101 @@ 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" }],
|
||||
branches: ["recovery-base"],
|
||||
},
|
||||
});
|
||||
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);
|
||||
await openNewWorkspaceComposer(page, project);
|
||||
await selectWorkspaceIsolation(page, isolation);
|
||||
},
|
||||
async selectWorktreeBranch(name: string) {
|
||||
await openStartingRefPicker(page);
|
||||
await startingRefRow(page, `${name}, local branch`).click();
|
||||
},
|
||||
async openAgentDraft() {
|
||||
await gotoWorkspace(page, project.workspaceId);
|
||||
await createAgentTabFromMenu(page);
|
||||
@@ -165,6 +293,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 +371,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 +420,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 +503,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 +517,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 +538,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 +560,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"
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -63,6 +63,7 @@ export interface AgentInputDraft {
|
||||
attachments: UserComposerAttachment[];
|
||||
setAttachments: (updater: AttachmentUpdater) => void;
|
||||
clear: (lifecycle: "sent" | "abandoned") => void;
|
||||
flush: () => void;
|
||||
isHydrated: boolean;
|
||||
attachmentFocusRequestId: number;
|
||||
composerState: DraftComposerState | null;
|
||||
@@ -172,6 +173,8 @@ export function useAgentInputDraft(input: UseAgentInputDraftInput): AgentInputDr
|
||||
[draftKey, textPublication],
|
||||
);
|
||||
|
||||
const flushDraft = useCallback(() => textPublication.flush(), [textPublication]);
|
||||
|
||||
const replaceText = useCallback(
|
||||
(nextText: string) => {
|
||||
textPublication.cancel();
|
||||
@@ -351,6 +354,7 @@ export function useAgentInputDraft(input: UseAgentInputDraftInput): AgentInputDr
|
||||
return {
|
||||
textSource,
|
||||
editText,
|
||||
flush: flushDraft,
|
||||
replaceText,
|
||||
textReplacement,
|
||||
attachments,
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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,4 @@
|
||||
import type { CreationAttempt } from "@/workspace-creation/model";
|
||||
import { deriveEffectiveWorkspaceStatus } from "@/workspace-status";
|
||||
import equal from "fast-deep-equal";
|
||||
import type { PrHint } from "@/git/pr-hint";
|
||||
@@ -146,6 +147,7 @@ export function createSidebarWorkspaceEntry(input: {
|
||||
workspace: WorkspaceDescriptor;
|
||||
projectViewKey?: string;
|
||||
pendingCreateAttempts?: Record<string, PendingCreateAttempt>;
|
||||
workspaceCreations?: readonly CreationAttempt[];
|
||||
workspaceAgentActivity?: ReadonlyMap<string, WorkspaceAgentActivity>;
|
||||
}): SidebarWorkspaceEntry {
|
||||
const projectViewKey = input.projectViewKey ?? input.workspace.projectId;
|
||||
@@ -203,6 +205,7 @@ export function deriveProjectStatusBucket(input: {
|
||||
workspaces: readonly SidebarWorkspacePlacement[];
|
||||
sessions: Record<string, ProjectStatusSession | undefined>;
|
||||
pendingCreateAttempts?: Record<string, PendingCreateAttempt>;
|
||||
workspaceCreations?: readonly CreationAttempt[];
|
||||
}): SidebarStateBucket {
|
||||
const workspaceIdsByServer = new Map<string, string[]>();
|
||||
for (const placement of input.workspaces) {
|
||||
@@ -230,6 +233,7 @@ export function deriveProjectStatusBucket(input: {
|
||||
serverId,
|
||||
workspace,
|
||||
pendingCreateAttempts: input.pendingCreateAttempts,
|
||||
workspaceCreations: input.workspaceCreations,
|
||||
workspaceAgentActivity: session.workspaceAgentActivity,
|
||||
}).status,
|
||||
);
|
||||
@@ -348,6 +352,7 @@ export function buildSidebarWorkspaceEntries(input: {
|
||||
placements: readonly SidebarWorkspacePlacement[];
|
||||
sessions: SidebarWorkspaceSession[];
|
||||
pendingCreateAttempts?: Record<string, PendingCreateAttempt>;
|
||||
workspaceCreations?: readonly CreationAttempt[];
|
||||
previousEntries?: ReadonlyMap<string, SidebarWorkspaceEntry>;
|
||||
}): ReadonlyMap<string, SidebarWorkspaceEntry> {
|
||||
if (input.placements.length === 0 || input.sessions.length === 0) {
|
||||
@@ -374,6 +379,7 @@ export function buildSidebarWorkspaceEntries(input: {
|
||||
workspace,
|
||||
projectViewKey: placement.projectViewKey,
|
||||
pendingCreateAttempts: input.pendingCreateAttempts,
|
||||
workspaceCreations: input.workspaceCreations,
|
||||
workspaceAgentActivity: session.workspaceAgentActivity,
|
||||
});
|
||||
const previousEntry = input.previousEntries?.get(placement.workspaceKey);
|
||||
|
||||
@@ -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,26 @@ 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,
|
||||
workspaceCreations: creations,
|
||||
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";
|
||||
@@ -59,6 +62,7 @@ export function useSidebarProjectStatusBucket(input: {
|
||||
enabled: boolean;
|
||||
}): SidebarStateBucket | null {
|
||||
const { workspaces, enabled } = input;
|
||||
const workspaceCreations = useWorkspaceCreations();
|
||||
const pendingCreateAttempts = useStoreWithEqualityFn(
|
||||
useCreateFlowStore,
|
||||
(state) => state.pendingByDraftId,
|
||||
@@ -72,9 +76,10 @@ export function useSidebarProjectStatusBucket(input: {
|
||||
workspaces,
|
||||
sessions: state.sessions,
|
||||
pendingCreateAttempts,
|
||||
workspaceCreations,
|
||||
});
|
||||
},
|
||||
[enabled, pendingCreateAttempts, workspaces],
|
||||
[enabled, pendingCreateAttempts, workspaces, workspaceCreations],
|
||||
);
|
||||
|
||||
return useStoreWithEqualityFn(useSessionStore, selector, Object.is);
|
||||
@@ -150,10 +155,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;
|
||||
|
||||
@@ -1339,6 +1339,15 @@ export const ar: TranslationResources = {
|
||||
},
|
||||
},
|
||||
newWorkspace: {
|
||||
creation: {
|
||||
creatingWorkspace: "جارٍ إنشاء مساحة العمل…",
|
||||
creatingLabel: "جارٍ إنشاء مساحة العمل",
|
||||
updateHost: "حدّث هذا المضيف لإنشاء مساحة عمل تتضمن وكيلاً.",
|
||||
chooseProjectHost: "اختر مشروعًا ومضيفًا",
|
||||
projectUnavailable: "المشروع غير متاح على المضيف المحدد",
|
||||
workspacePending: "لا يزال إنشاء مساحة العمل جاريًا.",
|
||||
agentPending: "لا يزال إنشاء الوكيل جاريًا.",
|
||||
},
|
||||
title: "مساحة عمل جديدة",
|
||||
create: "يخلق",
|
||||
isolation: {
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -1353,6 +1353,15 @@ export const ja: TranslationResources = {
|
||||
},
|
||||
},
|
||||
newWorkspace: {
|
||||
creation: {
|
||||
creatingWorkspace: "ワークスペースを作成中…",
|
||||
creatingLabel: "ワークスペースを作成中",
|
||||
updateHost: "エージェント付きのワークスペースを作成するには、このホストを更新してください。",
|
||||
chooseProjectHost: "プロジェクトとホストを選択してください",
|
||||
projectUnavailable: "選択したホストでプロジェクトを利用できません",
|
||||
workspacePending: "ワークスペースはまだ作成中です。",
|
||||
agentPending: "エージェントはまだ作成中です。",
|
||||
},
|
||||
title: "新しいワークスペース",
|
||||
create: "作成",
|
||||
isolation: {
|
||||
|
||||
@@ -1346,6 +1346,15 @@ export const ko: TranslationResources = {
|
||||
},
|
||||
},
|
||||
newWorkspace: {
|
||||
creation: {
|
||||
creatingWorkspace: "워크스페이스 생성 중…",
|
||||
creatingLabel: "워크스페이스 생성 중",
|
||||
updateHost: "에이전트가 있는 워크스페이스를 만들려면 이 호스트를 업데이트하세요.",
|
||||
chooseProjectHost: "프로젝트와 호스트를 선택하세요",
|
||||
projectUnavailable: "선택한 호스트에서 프로젝트를 사용할 수 없습니다",
|
||||
workspacePending: "워크스페이스가 아직 생성 중입니다.",
|
||||
agentPending: "에이전트가 아직 생성 중입니다.",
|
||||
},
|
||||
title: "새 워크스페이스",
|
||||
create: "생성",
|
||||
isolation: {
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -1357,6 +1357,15 @@ export const ru: TranslationResources = {
|
||||
},
|
||||
},
|
||||
newWorkspace: {
|
||||
creation: {
|
||||
creatingWorkspace: "Создание рабочей области…",
|
||||
creatingLabel: "Создание рабочей области",
|
||||
updateHost: "Обновите этот хост, чтобы создать рабочую область с агентом.",
|
||||
chooseProjectHost: "Выберите проект и хост",
|
||||
projectUnavailable: "Проект недоступен на выбранном хосте",
|
||||
workspacePending: "Рабочая область ещё создаётся.",
|
||||
agentPending: "Агент ещё создаётся.",
|
||||
},
|
||||
title: "Новое рабочее пространство",
|
||||
create: "Создать",
|
||||
isolation: {
|
||||
|
||||
@@ -1328,6 +1328,15 @@ export const zhCN: TranslationResources = {
|
||||
},
|
||||
},
|
||||
newWorkspace: {
|
||||
creation: {
|
||||
creatingWorkspace: "正在创建工作区…",
|
||||
creatingLabel: "正在创建工作区",
|
||||
updateHost: "请更新此主机,以创建包含代理的工作区。",
|
||||
chooseProjectHost: "请选择项目和主机",
|
||||
projectUnavailable: "所选主机上没有此项目",
|
||||
workspacePending: "工作区仍在创建中。",
|
||||
agentPending: "代理仍在创建中。",
|
||||
},
|
||||
title: "新建 workspace",
|
||||
create: "创建",
|
||||
isolation: {
|
||||
|
||||
@@ -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" &&
|
||||
@@ -1132,6 +1151,7 @@ const ChatAgentReadyContent = memo(function ChatAgentReadyContent({
|
||||
const {
|
||||
textSource,
|
||||
editText,
|
||||
flush,
|
||||
replaceText,
|
||||
textReplacement,
|
||||
attachments,
|
||||
@@ -1145,6 +1165,7 @@ const ChatAgentReadyContent = memo(function ChatAgentReadyContent({
|
||||
(): AgentInputDraft => ({
|
||||
textSource,
|
||||
editText,
|
||||
flush,
|
||||
replaceText,
|
||||
textReplacement,
|
||||
attachments,
|
||||
@@ -1157,6 +1178,7 @@ const ChatAgentReadyContent = memo(function ChatAgentReadyContent({
|
||||
[
|
||||
textSource,
|
||||
editText,
|
||||
flush,
|
||||
replaceText,
|
||||
textReplacement,
|
||||
attachments,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -163,6 +163,25 @@ describe("clearPickerPrAttachmentForTargetChange", () => {
|
||||
});
|
||||
|
||||
describe("reducePickerSelection", () => {
|
||||
it("restores the submitted branch without replacing a newer choice", () => {
|
||||
const submitted = {
|
||||
kind: "branch" as const,
|
||||
name: "recovery-base",
|
||||
refName: "refs/heads/recovery-base",
|
||||
accessibilityLabel: "recovery-base, local branch",
|
||||
};
|
||||
const restoration = { type: "draft-restored" as const, item: submitted };
|
||||
expect(reducePickerSelection(initialPickerSelectionState, restoration).selectedItem).toEqual(
|
||||
submitted,
|
||||
);
|
||||
|
||||
const newer = reducePickerSelection(initialPickerSelectionState, {
|
||||
type: "picker-selected",
|
||||
item: { ...submitted, name: "newer-base", refName: "refs/heads/newer-base" },
|
||||
});
|
||||
expect(reducePickerSelection(newer, restoration)).toBe(newer);
|
||||
});
|
||||
|
||||
it("selects a PR that was newly detected and added", () => {
|
||||
const item = { kind: "github-pr" as const, item: makePrItem(101, "A") };
|
||||
const detected = reducePickerSelection(initialPickerSelectionState, { type: "pr-detected" });
|
||||
|
||||
@@ -13,6 +13,7 @@ export type PickerSelectionEvent =
|
||||
| { type: "pr-detected" }
|
||||
| { type: "pr-added"; item: Extract<PickerItem, { kind: "github-pr" }> }
|
||||
| { type: "picker-selected"; item: PickerItem }
|
||||
| { type: "draft-restored"; item: PickerItem | null }
|
||||
| { type: "target-changed" };
|
||||
|
||||
export const initialPickerSelectionState: PickerSelectionState = {
|
||||
@@ -33,6 +34,8 @@ export function reducePickerSelection(
|
||||
: state;
|
||||
case "picker-selected":
|
||||
return { selectedItem: event.item, allowAutoPrSelection: false };
|
||||
case "draft-restored":
|
||||
return state.selectedItem ? state : { selectedItem: event.item, allowAutoPrSelection: false };
|
||||
case "target-changed":
|
||||
return initialPickerSelectionState;
|
||||
}
|
||||
|
||||
@@ -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, takeWorkspaceCreationFailure } 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
|
||||
@@ -987,8 +634,8 @@ export function NewWorkspaceScreen({
|
||||
const forkDraftSetup = usePendingWorkspaceDraftSetup(draftId);
|
||||
const draftContextScopeKey = useDraftWorkspaceAttachmentScopeKey(draftId);
|
||||
const visibleDraftContextScopeKeys = useMemo(
|
||||
() => resolveVisibleDraftContextScopeKeys({ isDraftHandoffActive, draftContextScopeKey }),
|
||||
[draftContextScopeKey, isDraftHandoffActive],
|
||||
() => (draftContextScopeKey ? [draftContextScopeKey] : []),
|
||||
[draftContextScopeKey],
|
||||
);
|
||||
const chatDraft = useAgentInputDraft({
|
||||
draftKey,
|
||||
@@ -1006,6 +653,14 @@ export function NewWorkspaceScreen({
|
||||
);
|
||||
const selectedItem = pickerSelection.selectedItem;
|
||||
|
||||
useEffect(() => {
|
||||
const failure = takeWorkspaceCreationFailure(draftKey);
|
||||
if (!failure) return;
|
||||
dispatchPickerSelection({ type: "draft-restored", item: failure.selectedItem });
|
||||
setErrorMessage(failure.error);
|
||||
toast.error(failure.error);
|
||||
}, [draftKey, toast]);
|
||||
|
||||
const handleForgeChangeRequestDetected = useCallback(() => {
|
||||
dispatchPickerSelection({ type: "pr-detected" });
|
||||
}, []);
|
||||
@@ -1034,10 +689,11 @@ export function NewWorkspaceScreen({
|
||||
cwd: selectedSourceDirectory ?? "",
|
||||
});
|
||||
|
||||
const flushChatDraft = chatDraft.flush;
|
||||
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 +946,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 +972,38 @@ export function NewWorkspaceScreen({
|
||||
return;
|
||||
}
|
||||
|
||||
setPendingAction("chat");
|
||||
const outcome = await runCreateChatAgent({
|
||||
// Capture the submitted version after publishing any last-keystroke edit.
|
||||
flushChatDraft();
|
||||
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,19 +1012,26 @@ export function NewWorkspaceScreen({
|
||||
}
|
||||
},
|
||||
[
|
||||
draftId,
|
||||
composerState,
|
||||
draftContextScopeKey,
|
||||
creationIdentity,
|
||||
chatDraft.clear,
|
||||
flushChatDraft,
|
||||
draftKey,
|
||||
ensureWorkspace,
|
||||
forkDraftSetup,
|
||||
selectedProject,
|
||||
selectedSourceDirectory,
|
||||
selectedItem,
|
||||
supportsWorkspaceMultiplicity,
|
||||
effectiveIsolation,
|
||||
queryClient,
|
||||
isStillOnCreateScreen,
|
||||
launchTarget,
|
||||
leaveFullscreen,
|
||||
selectedServerId,
|
||||
supportsForgeSearch,
|
||||
t,
|
||||
toast,
|
||||
updateFormPreferences,
|
||||
withConnectedClient,
|
||||
|
||||
@@ -1,12 +1,8 @@
|
||||
import { beforeEach, describe, expect, it } from "vitest";
|
||||
import { useCreateFlowStore } from "@/stores/create-flow-store";
|
||||
import { useDraftStore } from "@/stores/draft-store";
|
||||
import { useWorkspaceAttachmentsStore } from "@/attachments/workspace-attachments-store";
|
||||
import { useWorkspaceDraftSubmissionStore } from "@/stores/workspace-draft-submission-store";
|
||||
import {
|
||||
captureWorkspaceDraftCleanup,
|
||||
createWorkspaceAgentInBackground,
|
||||
} from "./background-handoff";
|
||||
import { captureWorkspaceDraftCleanup } from "./background-handoff";
|
||||
|
||||
const DRAFT_ID = "draft_1";
|
||||
const DRAFT_KEY = "new-workspace";
|
||||
@@ -32,144 +28,96 @@ function captureCleanup() {
|
||||
});
|
||||
}
|
||||
|
||||
function run(createAgent: () => Promise<unknown>, clearConsumedDraft = captureCleanup()) {
|
||||
return createWorkspaceAgentInBackground({ createAgent, clearConsumedDraft });
|
||||
function setSetup(cwd: string) {
|
||||
useWorkspaceDraftSubmissionStore.getState().setDraftSetup({
|
||||
draftId: DRAFT_ID,
|
||||
setup: {
|
||||
cwd,
|
||||
provider: "codex",
|
||||
modeId: null,
|
||||
model: null,
|
||||
thinkingOptionId: null,
|
||||
featureValues: {},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function addContext() {
|
||||
useWorkspaceAttachmentsStore.getState().addWorkspaceAttachment({
|
||||
scopeKey: SCOPE_KEY,
|
||||
attachment: {
|
||||
kind: "chat_history",
|
||||
id: "fork-context",
|
||||
attachment: {
|
||||
type: "text",
|
||||
mimeType: "text/plain",
|
||||
contextKind: "chat_history",
|
||||
title: "Chat history",
|
||||
text: "Preserve this context",
|
||||
},
|
||||
source: { serverId: "local", agentId: "agent-1" },
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
useWorkspaceDraftSubmissionStore.setState({ pendingByDraftId: {}, setupByDraftId: {} });
|
||||
useCreateFlowStore.setState({ pendingByDraftId: {} });
|
||||
useWorkspaceAttachmentsStore.setState({ attachmentsByScope: { [SCOPE_KEY]: [] } });
|
||||
saveDraft("do the thing");
|
||||
});
|
||||
|
||||
describe("createWorkspaceAgentInBackground", () => {
|
||||
it("preserves context added to a newer draft without editing its text", async () => {
|
||||
await run(async () => {
|
||||
useWorkspaceAttachmentsStore.getState().addWorkspaceAttachment({
|
||||
scopeKey: SCOPE_KEY,
|
||||
attachment: {
|
||||
kind: "chat_history",
|
||||
id: "fork-context",
|
||||
attachment: {
|
||||
type: "text",
|
||||
mimeType: "text/plain",
|
||||
contextKind: "chat_history",
|
||||
title: "Chat history",
|
||||
text: "Preserve this context",
|
||||
},
|
||||
source: { serverId: "local", agentId: "agent-1" },
|
||||
},
|
||||
});
|
||||
useWorkspaceDraftSubmissionStore.getState().setDraftSetup({
|
||||
draftId: DRAFT_ID,
|
||||
setup: {
|
||||
cwd: "/work/new-repo",
|
||||
provider: "codex",
|
||||
modeId: null,
|
||||
model: null,
|
||||
thinkingOptionId: null,
|
||||
featureValues: {},
|
||||
},
|
||||
});
|
||||
});
|
||||
expect(useWorkspaceAttachmentsStore.getState().attachmentsByScope[SCOPE_KEY]).toHaveLength(1);
|
||||
expect(useWorkspaceDraftSubmissionStore.getState().setupByDraftId[DRAFT_ID]?.setup.cwd).toBe(
|
||||
"/work/new-repo",
|
||||
);
|
||||
});
|
||||
describe("captureWorkspaceDraftCleanup", () => {
|
||||
it("clears the consumed draft setup, attachments and input", () => {
|
||||
setSetup("/work/repo");
|
||||
addContext();
|
||||
const clearConsumedDraft = captureCleanup();
|
||||
|
||||
it("creates one agent and clears the consumed draft setup, attachments and input", async () => {
|
||||
let calls = 0;
|
||||
useWorkspaceDraftSubmissionStore.getState().setDraftSetup({
|
||||
draftId: DRAFT_ID,
|
||||
setup: {
|
||||
cwd: "/work/repo",
|
||||
provider: "claude",
|
||||
modeId: null,
|
||||
model: null,
|
||||
thinkingOptionId: null,
|
||||
featureValues: {},
|
||||
},
|
||||
});
|
||||
clearConsumedDraft();
|
||||
|
||||
await run(async () => {
|
||||
calls += 1;
|
||||
});
|
||||
|
||||
expect(calls).toBe(1);
|
||||
expect(useWorkspaceDraftSubmissionStore.getState().setupByDraftId).toEqual({});
|
||||
expect(useWorkspaceAttachmentsStore.getState().attachmentsByScope[SCOPE_KEY]).toBeUndefined();
|
||||
expect(draftText()).toBe("");
|
||||
});
|
||||
|
||||
it("preserves context changed while workspace creation was pending", async () => {
|
||||
it("preserves newer setup without requiring a text edit", () => {
|
||||
setSetup("/work/repo");
|
||||
const clearConsumedDraft = captureCleanup();
|
||||
useWorkspaceAttachmentsStore.getState().addWorkspaceAttachment({
|
||||
scopeKey: SCOPE_KEY,
|
||||
attachment: {
|
||||
kind: "chat_history",
|
||||
id: "fork-context",
|
||||
attachment: {
|
||||
type: "text",
|
||||
mimeType: "text/plain",
|
||||
contextKind: "chat_history",
|
||||
title: "Chat history",
|
||||
text: "Preserve this context",
|
||||
},
|
||||
source: { serverId: "local", agentId: "agent-1" },
|
||||
},
|
||||
});
|
||||
await run(async () => undefined, clearConsumedDraft);
|
||||
setSetup("/work/new-repo");
|
||||
|
||||
clearConsumedDraft();
|
||||
|
||||
expect(useWorkspaceDraftSubmissionStore.getState().setupByDraftId[DRAFT_ID]?.setup.cwd).toBe(
|
||||
"/work/new-repo",
|
||||
);
|
||||
expect(draftText()).toBe("do the thing");
|
||||
});
|
||||
|
||||
it("preserves context changed while workspace creation was pending", () => {
|
||||
const clearConsumedDraft = captureCleanup();
|
||||
addContext();
|
||||
|
||||
clearConsumedDraft();
|
||||
|
||||
expect(useWorkspaceAttachmentsStore.getState().attachmentsByScope[SCOPE_KEY]).toHaveLength(1);
|
||||
expect(draftText()).toBe("do the thing");
|
||||
});
|
||||
|
||||
it("leaves no pending submission behind, so no draft tab can create a second agent", async () => {
|
||||
await run(async () => undefined);
|
||||
it("keeps a prompt typed into the shared draft while creation was in flight", () => {
|
||||
const clearConsumedDraft = captureCleanup();
|
||||
saveDraft("a different idea");
|
||||
|
||||
expect(useWorkspaceDraftSubmissionStore.getState().pendingByDraftId).toEqual({});
|
||||
expect(useCreateFlowStore.getState().pendingByDraftId).toEqual({});
|
||||
});
|
||||
|
||||
it("rethrows a failed creation and keeps the prompt for retry", async () => {
|
||||
const failure = new Error("daemon exploded");
|
||||
|
||||
await expect(
|
||||
run(async () => {
|
||||
throw failure;
|
||||
}),
|
||||
).rejects.toThrow(failure);
|
||||
|
||||
expect(draftText()).toBe("do the thing");
|
||||
});
|
||||
|
||||
it("keeps a prompt typed into the shared draft while creation was in flight", async () => {
|
||||
await run(async () => {
|
||||
saveDraft("a different idea");
|
||||
});
|
||||
clearConsumedDraft();
|
||||
|
||||
expect(draftText()).toBe("a different idea");
|
||||
});
|
||||
it("keeps a newer draft written while workspace creation was pending", async () => {
|
||||
useWorkspaceAttachmentsStore.getState().addWorkspaceAttachment({
|
||||
scopeKey: SCOPE_KEY,
|
||||
attachment: {
|
||||
kind: "chat_history",
|
||||
id: "fork-context",
|
||||
attachment: {
|
||||
type: "text",
|
||||
mimeType: "text/plain",
|
||||
contextKind: "chat_history",
|
||||
title: "Chat history",
|
||||
text: "Preserve this context",
|
||||
},
|
||||
source: { serverId: "local", agentId: "agent-1" },
|
||||
},
|
||||
});
|
||||
|
||||
it("keeps unchanged context when a newer draft owns it", () => {
|
||||
addContext();
|
||||
const clearConsumedDraft = captureCleanup();
|
||||
saveDraft("a newer workspace idea");
|
||||
await run(async () => undefined, clearConsumedDraft);
|
||||
|
||||
clearConsumedDraft();
|
||||
|
||||
expect(draftText()).toBe("a newer workspace idea");
|
||||
expect(useWorkspaceAttachmentsStore.getState().attachmentsByScope[SCOPE_KEY]).toHaveLength(1);
|
||||
});
|
||||
|
||||
@@ -34,21 +34,3 @@ export function captureWorkspaceDraftCleanup(input: WorkspaceDraftCleanupInput):
|
||||
input.clearDraft("sent");
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* The workspace draft tab normally issues create_agent once it mounts on the destination screen.
|
||||
* When the user leaves the New workspace screen before creation resolves, that tab never mounts,
|
||||
* so this path issues the request itself. The agent tab opens on its own when the workspace is
|
||||
* next visited — `reconcileTabs` auto-opens agents.
|
||||
*
|
||||
* The caller must not write a pending entry to the draft-submission or create-flow stores on this
|
||||
* path: those drive the draft tab's auto-submit, and the daemon does not dedupe create_agent by
|
||||
* clientMessageId, so a leftover entry would create a second agent when the workspace is opened.
|
||||
*/
|
||||
export async function createWorkspaceAgentInBackground(input: {
|
||||
clearConsumedDraft: () => void;
|
||||
createAgent: () => Promise<unknown>;
|
||||
}): Promise<void> {
|
||||
await input.createAgent();
|
||||
input.clearConsumedDraft();
|
||||
}
|
||||
|
||||
@@ -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"));
|
||||
}
|
||||
|
||||
@@ -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, takeWorkspaceCreationFailure } 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,342 @@
|
||||
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("keeps presentation stable across receipt updates and 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,
|
||||
};
|
||||
const pendingSnapshot = model.getSnapshot();
|
||||
let publications = 0;
|
||||
const stop = model.subscribe(() => publications++);
|
||||
onEvent({ ...event, revision: 1, phase: "accepted" });
|
||||
onEvent(event);
|
||||
expect(model.getSnapshot()).toBe(pendingSnapshot);
|
||||
expect(publications).toBe(0);
|
||||
stop();
|
||||
model.observeResources("host", new Set(), new Set());
|
||||
expect(model.getWorkspace("host", attempt.workspaceId)).toMatchObject({
|
||||
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,238 @@
|
||||
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";
|
||||
workspaceObserved: boolean;
|
||||
agentObserved: boolean;
|
||||
}
|
||||
interface Result {
|
||||
workspace?: unknown;
|
||||
error?: string | null;
|
||||
creation?: CreationSnapshot;
|
||||
}
|
||||
interface Operation extends Pick<Submission, "onComplete" | "onWorkspaceFailed" | "onAgentFailed"> {
|
||||
attempt: CreationAttempt;
|
||||
/** 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 settled and adopted. 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.onComplete = undefined;
|
||||
operation.onWorkspaceFailed = undefined;
|
||||
operation.onAgentFailed = 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: Operation, prepare: Submission["prepare"]) {
|
||||
try {
|
||||
const prepared = await prepare();
|
||||
const 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(
|
||||
{
|
||||
...request,
|
||||
// CreationClient owns receipt ordering; progress alone does not change presentation.
|
||||
onEvent: (event) => {
|
||||
operation.creation = event;
|
||||
},
|
||||
},
|
||||
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: Operation, prepare: Submission["prepare"]) {
|
||||
operation.running = Promise.resolve().then(() => run(operation, prepare));
|
||||
}
|
||||
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",
|
||||
workspaceObserved: false,
|
||||
agentObserved: false,
|
||||
};
|
||||
const operation: Operation = {
|
||||
onComplete,
|
||||
onWorkspaceFailed,
|
||||
onAgentFailed,
|
||||
attempt,
|
||||
};
|
||||
operations.set(key, operation);
|
||||
dependencies.onSubmitted?.(operation.attempt);
|
||||
publish();
|
||||
start(operation, prepare);
|
||||
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,292 @@
|
||||
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>;
|
||||
}
|
||||
|
||||
interface WorkspaceCreationFailure {
|
||||
error: string;
|
||||
selectedItem: PickerItem | null;
|
||||
}
|
||||
|
||||
// The returned draft takes back its form selection and ordinary submit error together.
|
||||
const workspaceFailures = new Map<string, WorkspaceCreationFailure>();
|
||||
|
||||
export function takeWorkspaceCreationFailure(draftKey: string): WorkspaceCreationFailure | null {
|
||||
const failure = workspaceFailures.get(draftKey) ?? null;
|
||||
workspaceFailures.delete(draftKey);
|
||||
return failure;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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) => {
|
||||
workspaceFailures.set(input.draftKey, { error, selectedItem });
|
||||
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]);
|
||||
}
|
||||
@@ -1,3 +1,5 @@
|
||||
import { useWorkspaceCreations } from "@/workspace-creation";
|
||||
import type { CreationAttempt } from "@/workspace-creation/model";
|
||||
import { useShallow } from "zustand/shallow";
|
||||
import { useCreateFlowStore, type PendingCreateAttempt } from "@/stores/create-flow-store";
|
||||
import { useSessionStore, type WorkspaceDescriptor } from "@/stores/session-store";
|
||||
@@ -13,12 +15,21 @@ export function deriveEffectiveWorkspaceStatus(input: {
|
||||
serverId: string;
|
||||
workspace: WorkspaceDescriptor;
|
||||
pendingCreateAttempts?: Record<string, PendingCreateAttempt>;
|
||||
workspaceCreations?: readonly CreationAttempt[];
|
||||
workspaceAgentActivity?: ReadonlyMap<string, WorkspaceAgentActivity>;
|
||||
}): EffectiveWorkspaceStatus {
|
||||
if (input.workspace.status !== "done") {
|
||||
return { status: input.workspace.status, enteredAt: input.workspace.statusEnteredAt };
|
||||
}
|
||||
|
||||
const creation = input.workspaceCreations?.find(
|
||||
(attempt) =>
|
||||
attempt.serverId === input.serverId &&
|
||||
attempt.workspaceId === input.workspace.id &&
|
||||
attempt.phase !== "complete",
|
||||
);
|
||||
if (creation) return { status: "running", enteredAt: creation.message?.timestamp ?? null };
|
||||
|
||||
const pendingStartedAt = getPendingInitialAgentCreateStartedAt({
|
||||
serverId: input.serverId,
|
||||
workspaceId: input.workspace.id,
|
||||
@@ -55,6 +66,7 @@ function getPendingInitialAgentCreateStartedAt(input: {
|
||||
}
|
||||
|
||||
export function useWorkspaceStatus(serverId: string, workspaceId: string) {
|
||||
const workspaceCreations = useWorkspaceCreations();
|
||||
const pendingCreateAttempts = useCreateFlowStore((state) => state.pendingByDraftId);
|
||||
return useSessionStore(
|
||||
useShallow((state) => {
|
||||
@@ -67,6 +79,7 @@ export function useWorkspaceStatus(serverId: string, workspaceId: string) {
|
||||
serverId,
|
||||
workspace,
|
||||
pendingCreateAttempts,
|
||||
workspaceCreations,
|
||||
workspaceAgentActivity: session.workspaceAgentActivity,
|
||||
}).status;
|
||||
}),
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { resolve } from "node:path";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { PARENT_AGENT_ID_LABEL } from "@getpaseo/protocol/agent-labels";
|
||||
import { resolveCreateAgentIntent } from "./intent.js";
|
||||
@@ -23,6 +24,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(resolve("/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) {
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user