From 3a3ccd5385e27f71d04b200b61fa7f5dd27418c3 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Mon, 26 Oct 2026 09:48:34 +0100 Subject: [PATCH] feat(app): open workspace chats immediately Reserve the final workspace, agent, and initial message identities before preparation so the normal agent panel can own the conversation throughout creation. Keep follow-ups queued until the first prompt starts and hand failures back to the existing workspace form or failed agent draft. --- docs/architecture.md | 49 +- package-lock.json | 1 + .../e2e/browser/creation-idempotency.spec.ts | 367 ++++++++++++++- .../e2e/browser/creation-old-daemon.spec.ts | 53 +-- packages/app/e2e/support/creation-fixtures.ts | 130 ++++-- packages/app/e2e/support/helpers/creation.ts | 338 +++++++++++++- .../app/e2e/support/helpers/new-workspace.ts | 1 + packages/app/package.json | 1 + packages/app/src/agent-presentation/index.ts | 8 +- .../sidebar/sidebar-workspace-row.tsx | 13 +- packages/app/src/composer/actions.test.ts | 82 ++++ packages/app/src/composer/actions.ts | 18 +- .../app/src/composer/agent-controls/index.tsx | 84 +++- .../app/src/composer/draft/workspace-tab.tsx | 9 +- packages/app/src/composer/index.tsx | 56 ++- .../src/composer/submission/writer.test.ts | 60 ++- .../app/src/composer/submission/writer.ts | 50 +- .../hooks/use-sidebar-workspace-entries.ts | 31 +- .../src/hooks/use-sidebar-workspaces-list.ts | 9 +- packages/app/src/i18n/resources/ar.ts | 9 + packages/app/src/i18n/resources/en.ts | 9 + packages/app/src/i18n/resources/es.ts | 9 + packages/app/src/i18n/resources/fr.ts | 9 + packages/app/src/i18n/resources/ja.ts | 9 + packages/app/src/i18n/resources/ko.ts | 9 + packages/app/src/i18n/resources/pt-BR.ts | 9 + packages/app/src/i18n/resources/ru.ts | 9 + packages/app/src/i18n/resources/zh-CN.ts | 9 + packages/app/src/panels/agent-panel.tsx | 35 +- .../app/src/review/feedback/use-feedback.ts | 8 +- packages/app/src/runtime/host-runtime.test.ts | 241 ++++++++++ packages/app/src/runtime/host-runtime.ts | 20 +- .../app/src/screens/new-workspace-screen.tsx | 430 ++---------------- .../compact-workspace-header/tab-switcher.tsx | 5 +- .../workspace/use-workspace-tab-rename.tsx | 2 + .../workspace/workspace-desktop-tabs-row.tsx | 4 +- .../workspace/workspace-route-state.test.ts | 17 + .../workspace/workspace-route-state.ts | 2 + .../screens/workspace/workspace-screen.tsx | 80 +++- packages/app/src/stores/session-store.ts | 8 + .../workspace-draft-submission-store.test.ts | 24 - .../workspace-draft-submission-store.ts | 16 +- packages/app/src/workspace-creation/index.ts | 9 + .../workspace-creation/internal/capture.ts | 12 + .../workspace-creation/internal/model.test.ts | 337 ++++++++++++++ .../internal/presentation.test.ts | 159 +++++++ .../workspace-creation/internal/random-id.ts | 1 + .../internal/random-id.web.ts | 3 + .../app/src/workspace-creation/model/index.ts | 249 ++++++++++ .../workspace-creation/presentation/index.ts | 113 +++++ .../app/src/workspace-creation/runtime.ts | 146 ++++++ packages/app/src/workspace-creation/submit.ts | 288 ++++++++++++ .../app/src/workspace-creation/ui/index.tsx | 56 +++ .../server/agent/create-agent/intent.test.ts | 32 ++ .../src/server/agent/create-agent/intent.ts | 21 +- packages/server/src/server/session.ts | 10 +- ...rkspace-create-worktree-source.e2e.test.ts | 54 ++- 57 files changed, 3222 insertions(+), 601 deletions(-) create mode 100644 packages/app/src/workspace-creation/index.ts create mode 100644 packages/app/src/workspace-creation/internal/capture.ts create mode 100644 packages/app/src/workspace-creation/internal/model.test.ts create mode 100644 packages/app/src/workspace-creation/internal/presentation.test.ts create mode 100644 packages/app/src/workspace-creation/internal/random-id.ts create mode 100644 packages/app/src/workspace-creation/internal/random-id.web.ts create mode 100644 packages/app/src/workspace-creation/model/index.ts create mode 100644 packages/app/src/workspace-creation/presentation/index.ts create mode 100644 packages/app/src/workspace-creation/runtime.ts create mode 100644 packages/app/src/workspace-creation/submit.ts create mode 100644 packages/app/src/workspace-creation/ui/index.tsx diff --git a/docs/architecture.md b/docs/architecture.md index 29f9c7f817..4eb4590691 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -340,11 +340,50 @@ Example: adding a new enum value ### Creation ownership -Creation is owned by the daemon across socket lifetimes. A workspace request can include -its initial agent and prompt. Workspace readiness is published before provider startup; -the app keeps its existing disk-ready navigation and observes the remaining creation. -Callbacks do not advance the workflow. Resource reservations in acknowledgement are -identities, not ready workspace records. +Creation execution is owned by the daemon across socket lifetimes. A workspace request +can include its initial agent and prompt. The app's `workspace-creation/` module owns +chat submission presentation outside React: it reserves final IDs and a message identity, +then opens the agent target before asynchronous preparation or a daemon response. +The New workspace chat entry requires `creationLifecycle`; empty workspaces, terminal +launches, and existing-workspace agent drafts retain their own entry points. + +Pending presentation never enters the directory replica as a fabricated resource. +Routes, tabs, and the sidebar retain it until the corresponding authoritative resource +has been observed. Files, terminals, scripts, and agent timeline initialization wait for +real resources. The creating agent opens in the normal agent panel and composer, showing +its submitted configuration. Until the host publishes the agent, its destination cwd is +unknown and its controls are read-only. The host publishes the agent before it starts the +initial prompt, so prompts wait in the agent's queue until creation completes +(`isAgentReadyForPrompts`). `composer/actions.ts` applies that to every prompt path: send +and review feedback queue behind earlier prompts, and send now and queue drain leave the +queue untouched. The queue drains on adoption or when the initial turn ends. The first +message uses composer submission reconciliation; creation never sends it again when the +panel mounts. Completion does not navigate, and draft cleanup checks the captured version +so it cannot erase newer input. + +A failed creation goes to the existing owner of whatever the host did not create, using +the receipt's resources and `failedStage`. Creation has no failure UI of its own: + +- No workspace: the app returns to New workspace with the submitted draft and shows the + error the way that screen shows its own submit errors. +- Workspace but no agent: the agent tab becomes an existing-workspace agent draft holding + the prompt, in the abandoned create-flow state. Its error and Send retry are that draft's + own; the retry creates the agent in the kept workspace. +- An agent that exists or may exist, including a failed first prompt: creation completes + and the agent tab keeps the agent's own timeline and turn error. Turning it into a draft + would create a second agent on Send. + +Queued follow-ups and unsent composer text belong to a composer identity, so they move +with the creation: from the form to the creating agent, back to the form or the agent draft +on failure, and to the created agent after retry (`transferComposerFollowUps`). The initial +prompt stays the form's or draft's input. The agent draft also carries the submitted +setup and context attachments. The host ignores `config.cwd` when a create request names a +workspace, so a draft's retry starts at the workspace root, not a nested fork cwd. + +Adopted successful attempts release their payloads; a bounded cache of compact receipts +absorbs duplicate commands from retained forms. The SDK owns reconnect observation; the +app has no resend loop. Pending views survive navigation and pane remount, not app +process death. Resource reservations are identities, not ready workspace records. The creation journal (`server/creation/`) owns identity and execution for both legacy and modern creation RPCs. Requesting progress never selects a different journal. Existing diff --git a/package-lock.json b/package-lock.json index 1f5cce1657..ea6b5bb13f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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", diff --git a/packages/app/e2e/browser/creation-idempotency.spec.ts b/packages/app/e2e/browser/creation-idempotency.spec.ts index 7658addf72..de967609b6 100644 --- a/packages/app/e2e/browser/creation-idempotency.spec.ts +++ b/packages/app/e2e/browser/creation-idempotency.spec.ts @@ -1,4 +1,11 @@ +import { + hideCreationLifecycle, + holdWorkspaceProvisioning, + reportUnknownPromptOutcome, +} from "../support/helpers/creation"; import type { createCreationScenario } from "../support/helpers/creation"; +import { expectComposerMode, expectComposerModel } from "../support/helpers/agent-profiles"; +import { expectChatHistoryAttachment } from "../support/helpers/assistant-fork"; import { expect } from "../support/fixtures"; import { test } from "../support/creation-fixtures"; @@ -105,26 +112,38 @@ test("new workspace navigation and optimistic prompt precede agent initializatio await creation.expectAgentCount(1); }); -for (const scenario of [ - { suffix: "", prepare: async () => {} }, +for (const entry of [ { - suffix: " after remount", - prepare: async (creation: Awaited>) => - creation.evictAndReturnToDraft(), + name: "an agent draft", + open: (creation: Awaited>) => + creation.openAgentDraft(), + button: "Send message", + expectWorkspace: async (_creation: Awaited>) => {}, + }, + { + name: "a new workspace", + open: (creation: Awaited>) => + creation.openWorkspaceForm("local"), + button: "Create", + expectWorkspace: (creation: Awaited>) => + creation.expectOneCreatedWorkspace(), }, ]) { - test(`retrying failed agent initialization preserves its workspace${scenario.suffix}`, async ({ + test(`a failed agent start in ${entry.name} keeps the draft, its error and its retry`, async ({ creation, startup, - }) => { - await creation.openWorkspaceForm("local"); - await creation.submitPrompt("Retry this workspace and agent together.", "Create"); - await creation.expectAgentStillStarting(); + page, + }, testInfo) => { + const prompt = "Retry this agent after its startup fails."; + await entry.open(creation); + await creation.submitPrompt(prompt, entry.button); await startup.fail(); - await creation.expectStartupFailure(); - await creation.expectOneCreatedWorkspace(); - await scenario.prepare(creation); - await creation.submitPrompt("Retry this workspace and agent together."); + await creation.expectFailedAgentDraft(prompt); + await entry.expectWorkspace(creation); + await page.screenshot({ path: testInfo.outputPath("failed-agent-draft.png") }); + await creation.resendFailedDraft(); + await creation.expectPromptVisible(prompt); + await entry.expectWorkspace(creation); await creation.expectAgentCount(1); }); } @@ -171,3 +190,323 @@ for (const entry of [ await expect(page.getByText("agent_request_key_conflict", { exact: true })).toHaveCount(0); }); } + +for (const isolation of ["local", "worktree"] as const) { + test(`a held ${isolation} workspace opens the normal agent panel and queues a follow-up`, async ({ + creation, + page, + }, testInfo) => { + const prompt = "emit 1 coalesced agent stream updates for the first workspace prompt."; + const followUp = "emit 1 coalesced agent stream updates for the queued follow-up."; + const draft = "Keep this unsent draft through adoption."; + const provisioning = await holdWorkspaceProvisioning(page); + await creation.openWorkspaceForm(isolation); + await creation.submitPrompt(prompt, "Create"); + const request = await provisioning.waitForRequest(); + + await test.step("the final agent panel paints before any host response", async () => { + expect(request.workspaceId).toMatch(/^wks_[a-f0-9]{16}$/); + expect(request.agent!.agentId).toMatch(/^[a-f0-9-]{36}$/); + await expect(page).toHaveURL(new RegExp(`/workspace/${request.workspaceId}$`)); + await expect( + page.getByTestId(`workspace-tab-agent_${request.agent!.agentId}`).filter({ visible: true }), + ).toBeVisible(); + await creation.expectPromptVisible(prompt); + await creation.expectNormalAgentComposer(); + await expectComposerModel(page, "Ten second stream"); + await expectComposerMode(page, "Load test"); + }); + + await test.step("a follow-up waits in the queue instead of reaching the agent", async () => { + await creation.queueFollowUp(followUp); + await creation.typeComposerDraft(draft); + expect(provisioning.agentPrompts()).toBe(0); + await page.screenshot({ path: testInfo.outputPath("held-normal-panel.png") }); + }); + + await test.step("adoption keeps the same agent, draft and queue order", async () => { + provisioning.release(); + await creation.expectOneCreatedWorkspace(); + expect(await creation.agentIds()).toEqual([request.agent!.agentId]); + await expect(page).toHaveURL(new RegExp(`/workspace/${request.workspaceId}$`)); + await creation.expectUserMessagesInOrder([prompt, followUp]); + expect(provisioning.agentPrompts()).toBe(1); + await creation.expectComposerDraft(draft); + await creation.expectTimelineSettled(); + await page.screenshot({ path: testInfo.outputPath("adopted-normal-panel.png") }); + }); + }); +} + +test("a held workspace on compact shows the final agent prompt and composer", async ({ + creation, + page, +}, testInfo) => { + const provisioning = await holdWorkspaceProvisioning(page); + await creation.openWorkspaceForm("worktree"); + await page.setViewportSize({ width: 390, height: 844 }); + await creation.submitPrompt("Show my prompt before workspace provisioning.", "Create"); + const request = await provisioning.waitForRequest(); + await expect(page).toHaveURL(new RegExp(`/workspace/${request.workspaceId}$`)); + await creation.expectPromptVisible("Show my prompt before workspace provisioning."); + await creation.expectNormalAgentComposer(); + await creation.expectAgentCannotBeStoppedYet(); + await page.screenshot({ path: testInfo.outputPath("compact-held.png") }); + provisioning.release(); + await creation.expectOneCreatedWorkspace(); + expect(await creation.agentIds()).toEqual([request.agent!.agentId]); + await creation.expectInitialPromptOnce("Show my prompt before workspace provisioning."); +}); + +test("workspace creation recovers a lost response without replaying the first prompt", async ({ + creation, + workspacePromptRetry, +}) => { + await creation.openWorkspaceForm("worktree"); + workspacePromptRetry.holdAcknowledgement(); + await creation.submitPrompt("Recover this workspace prompt once.", "Create"); + await workspacePromptRetry.waitForDeliveredPrompt(); + await creation.expectPromptVisible("Recover this workspace prompt once."); + await creation.evictAndReturnToDraft(); + await workspacePromptRetry.disconnectAndReconnect(); + await workspacePromptRetry.expectSameAgentAndMessage(); + await creation.expectOneCreatedWorkspace(); + await creation.expectAgentCount(1); +}); + +test("a pending workspace survives leaving and remounting before any daemon event", async ({ + creation, + page, +}) => { + const provisioning = await holdWorkspaceProvisioning(page); + await creation.openWorkspaceForm("worktree"); + await creation.submitPrompt("Keep this pending workspace across remount.", "Create"); + await provisioning.waitForRequest(); + await creation.expectPromptVisible("Keep this pending workspace across remount."); + await creation.evictAndReturnToDraft(); + await creation.expectPromptVisible("Keep this pending workspace across remount."); + provisioning.release(); + await creation.expectOneCreatedWorkspace(); + await creation.expectAgentCount(1); +}); + +test("a failed workspace returns to New workspace with the prompt and its error", async ({ + creation, + provisioningFailure, + page, +}, testInfo) => { + const prompt = "Keep my prompt when the workspace fails."; + await provisioningFailure.arm(); + await creation.openWorkspaceForm("local"); + await creation.submitRepeatedly("Create", prompt); + await creation.expectReturnedToNewWorkspace(prompt, "Provisioning outcome is uncertain for test"); + await page.screenshot({ path: testInfo.outputPath("failed-workspace-returned.png") }); +}); + +test("a failed workspace keeps its queued follow-up and unsent text through resubmission", async ({ + creation, + provisioningFailure, + page, +}, testInfo) => { + const prompt = "emit 1 coalesced agent stream updates for the resubmitted workspace."; + const followUp = "emit 1 coalesced agent stream updates for the resubmitted follow-up."; + const draft = "Keep this unsent text through the failed workspace."; + await provisioningFailure.arm(); + const provisioning = await holdWorkspaceProvisioning(page); + await creation.openWorkspaceForm("local"); + await creation.submitPrompt(prompt, "Create"); + await provisioning.waitForRequest(); + await creation.queueFollowUp(followUp); + await creation.typeComposerDraft(draft); + provisioning.release(); + + await test.step("New workspace shows the prompt, its error and the queued follow-up", async () => { + await creation.expectReturnedToNewWorkspace( + prompt, + "Provisioning outcome is uncertain for test", + ); + await creation.expectQueuedFollowUp(followUp); + await page.screenshot({ path: testInfo.outputPath("failed-workspace-follow-up.png") }); + }); + + await test.step("resubmitting delivers the prompt, then the follow-up, once", async () => { + await creation.resubmitWorkspace(); + await provisioning.waitForRequests(2); + provisioning.release(); + await creation.expectOneCreatedWorkspace(); + await creation.expectAgentCount(1); + await creation.expectUserMessagesInOrder([prompt, followUp]); + await creation.expectComposerDraft(draft); + await page.screenshot({ path: testInfo.outputPath("resubmitted-workspace-follow-up.png") }); + }); +}); + +test("a failed agent start keeps its queued follow-up and unsent text through retry", async ({ + creation, + startup, + page, +}, testInfo) => { + const prompt = "emit 1 coalesced agent stream updates for the retried agent."; + const followUp = "emit 1 coalesced agent stream updates for the retried follow-up."; + const draft = "Keep this unsent text through the failed agent start."; + await creation.openWorkspaceForm("local"); + await creation.submitPrompt(prompt, "Create"); + await creation.queueFollowUp(followUp); + await creation.typeComposerDraft(draft); + await startup.fail(); + + await test.step("the agent draft shows the prompt, its error and the queued follow-up", async () => { + await creation.expectFailedAgentDraft(prompt); + await creation.expectQueuedFollowUp(followUp); + await page.screenshot({ path: testInfo.outputPath("failed-agent-follow-up.png") }); + }); + + await test.step("retrying delivers the prompt, then the follow-up, once", async () => { + await creation.resendFailedDraft(); + await creation.expectOneCreatedWorkspace(); + await creation.expectAgentCount(1); + await creation.expectUserMessagesInOrder([prompt, followUp]); + await creation.expectComposerDraft(draft); + await page.screenshot({ path: testInfo.outputPath("retried-agent-follow-up.png") }); + }); +}); + +test("a forked worktree agent that fails to start keeps its setup and context through retry", async ({ + creation, + lateStartup, + page, +}, testInfo) => { + const prompt = "Keep this fork's setup, image and history through the retry."; + await creation.openNestedAgentFork("worktree"); + await creation.attachImage(); + const startup = await lateStartup.arm(); + await creation.submitPrompt(prompt, "Create"); + await startup.fail(); + + await test.step("the agent draft keeps the fork's configuration and context", async () => { + await creation.expectFailedAgentDraft(prompt); + await expectComposerModel(page, "e2e-fast-stream"); + await expectComposerMode(page, "Load test"); + await creation.expectImageVisible(); + await expectChatHistoryAttachment(page); + await page.screenshot({ path: testInfo.outputPath("failed-fork-draft.png") }); + }); + + // An agent created in an existing workspace starts at the workspace root: the host ignores + // config.cwd when the request names a workspace, so the nested cwd is not asserted here. + await test.step("retrying creates one agent in the worktree with that configuration and context", async () => { + await creation.resendFailedDraft(); + await creation.expectOneCreatedWorkspace(); + await creation.expectAgentCount(1); + await creation.expectAgentConfig({ model: "e2e-fast-stream", modeId: "load-test" }); + await creation.expectImageVisible(); + await expect( + page + .getByTestId("user-message") + .filter({ hasText: "Chat history" }) + .filter({ visible: true }), + ).toHaveCount(1); + await page.screenshot({ path: testInfo.outputPath("retried-fork-agent.png") }); + }); +}); + +test("a forked worktree whose workspace fails keeps its context through resubmission", async ({ + creation, + provisioningFailure, + page, +}, testInfo) => { + const prompt = "Keep this fork's history when its workspace fails."; + await creation.openNestedAgentFork("worktree"); + await provisioningFailure.arm(); + await creation.submitPrompt(prompt, "Create"); + + await test.step("New workspace shows the prompt, its error and the fork's context", async () => { + await creation.expectReturnedToNewWorkspace( + prompt, + "Provisioning outcome is uncertain for test", + ); + await expectChatHistoryAttachment(page); + await page.screenshot({ path: testInfo.outputPath("failed-fork-workspace.png") }); + }); + + await test.step("resubmitting creates one agent with that context", async () => { + await creation.resubmitWorkspace(); + await creation.expectOneCreatedWorkspace(); + await creation.expectAgentCount(1); + await expect( + page + .getByTestId("user-message") + .filter({ hasText: "Chat history" }) + .filter({ visible: true }), + ).toHaveCount(1); + await page.screenshot({ path: testInfo.outputPath("resubmitted-fork-workspace.png") }); + }); +}); + +test("an unsupported host keeps the draft and asks for an update before creating anything", async ({ + creation, + page, +}) => { + const host = await hideCreationLifecycle(page); + await creation.openWorkspaceForm("local"); + await creation.submitPrompt("Retain my unsupported host draft.", "Create"); + await expect( + page + .getByText("Update this host to create a workspace with an agent.", { exact: true }) + .first(), + ).toBeVisible(); + await expect(page).toHaveURL(/\/new\?/); + host.expectNoCreationRequest(); +}); + +for (const isolation of ["local", "worktree"] as const) { + test(`a fork keeps its image and nested cwd in the optimistic and real ${isolation} agent`, async ({ + creation, + page, + }, testInfo) => { + const provisioning = await holdWorkspaceProvisioning(page); + const cwd = await creation.openNestedAgentFork(isolation); + await creation.attachImage(); + await creation.submitPrompt("Keep this image and nested cwd.", "Create"); + const request = await provisioning.waitForRequest(); + expect(request.agent!.config?.cwd).toBe(cwd); + expect(request.agent!.images).toHaveLength(1); + await creation.expectPromptVisible("Keep this image and nested cwd."); + await creation.expectImageVisible(); + await page.screenshot({ path: testInfo.outputPath("pending-image-nested-cwd.png") }); + provisioning.release(); + await creation.expectOneCreatedWorkspace(); + await creation.expectNestedAgentCwd(); + await creation.expectImageVisible(); + await creation.expectInitialPromptOnce("Keep this image and nested cwd."); + }); +} + +test("closing the pending agent tab leaves host creation running", async ({ creation, page }) => { + const provisioning = await holdWorkspaceProvisioning(page); + await creation.openWorkspaceForm("local"); + await creation.submitPrompt("Finish creation after I close the tab.", "Create"); + const request = await provisioning.waitForRequest(); + await creation.expectPromptVisible("Finish creation after I close the tab."); + await creation.closePendingAgent(request.agent!.agentId!); + provisioning.release(); + await creation.expectOneCreatedWorkspace(); + await creation.expectAgentCount(1); + await creation.expectInitialPromptOnce("Finish creation after I close the tab."); +}); + +test("an uncertain prompt receipt keeps the created agent tab and its timeline", async ({ + creation, + page, +}) => { + await reportUnknownPromptOutcome(page); + await creation.openWorkspaceForm("local"); + const prompt = "emit 1 coalesced agent stream updates for an uncertain first prompt."; + await creation.submitPrompt(prompt, "Create"); + await creation.expectAssistantReply(); + await creation.expectAgentNotWorking(); + await creation.expectOneCreatedWorkspace(); + await creation.expectInitialPromptOnce(prompt); + await creation.expectNoCreationRecovery(); + await creation.expectAgentCount(1); +}); diff --git a/packages/app/e2e/browser/creation-old-daemon.spec.ts b/packages/app/e2e/browser/creation-old-daemon.spec.ts index 4b418b304a..cc6ecc2998 100644 --- a/packages/app/e2e/browser/creation-old-daemon.spec.ts +++ b/packages/app/e2e/browser/creation-old-daemon.spec.ts @@ -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) { diff --git a/packages/app/e2e/support/creation-fixtures.ts b/packages/app/e2e/support/creation-fixtures.ts index c6d3579206..e869e3d2f2 100644 --- a/packages/app/e2e/support/creation-fixtures.ts +++ b/packages/app/e2e/support/creation-fixtures.ts @@ -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; fail(): Promise }; - promptRejection: void; - creation: Awaited>; - promptRetry: ReturnType; - agentRetries: Awaited>; - delayedCreation: Awaited>; -}>({ - 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; + fail(): Promise; + dispose(): Promise; +} + +/** Holds every later workspace agent's startup until it is released or failed once. */ +async function installStartupGate(): Promise { + 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 }; + promptRejection: void; + /** Fails the next workspace request once it is armed. */ + provisioningFailure: { arm(): Promise }; + creation: Awaited>; + promptRetry: ReturnType; + workspacePromptRetry: ReturnType; + agentRetries: Awaited>; + delayedCreation: Awaited>; +}>({ + 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)); }, diff --git a/packages/app/e2e/support/helpers/creation.ts b/packages/app/e2e/support/helpers/creation.ts index 1c3d5dda89..0c96f6aa52 100644 --- a/packages/app/e2e/support/helpers/creation.ts +++ b/packages/app/e2e/support/helpers/creation.ts @@ -1,11 +1,14 @@ +import { awaitAssistantMessage } from "./agent-stream"; +import { forkMostRecentAssistantTurnToNewWorkspace } from "./assistant-fork"; import type { WorkspaceCreateRequest } from "@getpaseo/protocol/messages"; import { expect, type Page } from "@playwright/test"; import { daemonWsRoutePattern } from "./daemon-port"; import { gotoAppShell } from "./app"; import { gotoWorkspace } from "./launcher"; -import { fillComposerDraft } from "./composer"; +import { attachComposerTestImage, fillComposerDraft } from "./composer"; import { createAgentTabFromMenu } from "./workspace-tabs"; import { + connectNewWorkspaceDaemonClient, openNewWorkspaceComposer, selectWorkspaceIsolation, loadSessionMessageReaders, @@ -44,6 +47,43 @@ export async function captureWorkspaceAgentRequest(page: Page, options: { block: return { waitForRequest: () => request }; } +/** Hold the creation request before the daemon can reserve or provision anything. */ +/** Holds each workspace request with an agent until `release` sends the held requests. */ +export async function holdWorkspaceProvisioning(page: Page) { + const frames = await loadSessionMessageReaders(); + let held: Array<() => void> = []; + const requests: WorkspaceCreateRequest[] = []; + let agentPrompts = 0; + await page.routeWebSocket(daemonWsRoutePattern(), (browser) => { + const server = browser.connectToServer(); + browser.onMessage((frame) => { + const message = frames.client(frame); + if (message?.type === "send_agent_message_request") agentPrompts++; + if (message?.type === "workspace.create.request" && message.agent) { + held.push(() => server.send(frame)); + requests.push(message); + return; + } + server.send(frame); + }); + server.onMessage((frame) => browser.send(frame)); + }); + async function waitForRequests(count: number) { + await expect.poll(() => requests.length).toBe(count); + return requests[count - 1]!; + } + return { + waitForRequest: () => waitForRequests(1), + waitForRequests, + release: () => { + const sending = held; + held = []; + for (const send of sending) send(); + }, + agentPrompts: () => agentPrompts, + }; +} + export async function pressSubmitBeforeTheNextRender(page: Page, name: string): Promise { const create = page.getByRole("button", { name, exact: true }); await expect(create).toBeEnabled(); @@ -59,9 +99,11 @@ export async function pressSubmitBeforeTheNextRender(page: Page, name: string): export async function observeCreationRequests(page: Page) { const frames = await loadSessionMessageReaders(); const pending = new Set(); + let workspaceRequests = 0; page.on("websocket", (socket) => { socket.on("framesent", ({ payload }) => { const request = frames.client(payload); + if (request?.type === "workspace.create.request") workspaceRequests++; if ( request?.type === "workspace.create.request" || request?.type === "agent.create.request" @@ -84,6 +126,7 @@ export async function observeCreationRequests(page: Page) { }); }); return { + workspaceRequests: () => workspaceRequests, async settled() { await expect.poll(() => pending.size).toBe(0); }, @@ -138,12 +181,88 @@ export async function retryNextAgentCreation(page: Page) { }; } +function agentComposer(page: Page) { + // Retained workspaces keep hidden composers mounted beside the focused one. + return page.getByRole("textbox", { name: "Message agent..." }).filter({ visible: true }); +} + export async function createCreationScenario(page: Page) { const requests = await observeCreationRequests(page); - const project = await seedWorkspace({ repoPrefix: "creation-idempotency-" }); + const project = await seedWorkspace({ + repoPrefix: "creation-idempotency-", + repo: { files: [{ path: "nested/context.txt", content: "Nested agent working directory" }] }, + }); let workspaceId = project.workspaceId; + const evictionWorkspaceIds = new Set(); + const sourceClients: Array<{ close(): Promise }> = []; return { - cleanup: project.cleanup, + cleanup: async () => { + for (const client of sourceClients) await client.close(); + await project.cleanup(); + }, + async openNestedAgentFork(isolation: "local" | "worktree") { + const cwd = `${project.repoPath}/nested`; + const sourceClient = await connectNewWorkspaceDaemonClient(); + sourceClients.push(sourceClient); + const source = await sourceClient.createWorkspace({ + source: { kind: "directory", path: project.repoPath }, + agent: { + config: { provider: "mock", model: "e2e-fast-stream", modeId: "load-test", cwd }, + initialPrompt: "emit 1 coalesced agent stream updates for the source conversation.", + }, + }); + if (!source.agent || !source.workspace) + throw new Error(source.error ?? "Missing nested source agent"); + evictionWorkspaceIds.add(source.workspace.id); + await project.client.waitForFinish(source.agent.id, 20_000); + await gotoWorkspace(page, source.workspace.id); + await awaitAssistantMessage(page); + await forkMostRecentAssistantTurnToNewWorkspace(page); + await selectWorkspaceIsolation(page, isolation); + return cwd; + }, + attachImage: () => attachComposerTestImage(page), + async expectImageVisible() { + await expect( + page + .getByRole("button", { name: "Open image attachment", exact: true }) + .filter({ visible: true }), + ).toHaveCount(1); + }, + async expectInitialPromptOnce(prompt: string) { + const agents = (await project.client.fetchAgents()).entries.filter( + ({ agent }) => agent.workspaceId === workspaceId, + ); + expect(agents).toHaveLength(1); + const reader = await connectNewWorkspaceDaemonClient(); + try { + const timeline = await reader.fetchAgentTimeline(agents[0]!.agent.id); + expect(timeline.entries.filter(({ item }) => item.type === "user_message")).toHaveLength(1); + expect( + timeline.entries.filter( + ({ item }) => item.type === "user_message" && item.text === prompt, + ), + ).toHaveLength(1); + } finally { + await reader.close(); + } + }, + async expectNestedAgentCwd() { + const workspace = (await project.client.fetchWorkspaces()).entries.find( + (entry) => entry.id === workspaceId, + )!; + const nestedAgent = (await project.client.fetchAgents()).entries.find( + ({ agent }) => agent.workspaceId === workspaceId, + )!.agent; + expect(nestedAgent.cwd).toBe(`${workspace.workspaceDirectory}/nested`); + }, + async closePendingAgent(agentId: string) { + await page.getByTestId(`workspace-tab-agent_${agentId}`).filter({ visible: true }).hover(); + await page.getByTestId(`workspace-agent-close-${agentId}`).filter({ visible: true }).click(); + await expect( + page.getByTestId(`workspace-tab-agent_${agentId}`).filter({ visible: true }), + ).toHaveCount(0); + }, async openWorkspaceForm(isolation: "local" | "worktree") { await gotoAppShell(page); await waitForSidebarHydration(page); @@ -165,6 +284,74 @@ export async function createCreationScenario(page: Page) { if (prompt) await fillComposerDraft(page, prompt); await pressSubmitBeforeTheNextRender(page, button); }, + async expectNormalAgentComposer() { + await expect(agentComposer(page)).toBeEditable(); + // Provisioning belongs to the workspace header and sidebar, not the conversation. + await expect(page.getByText("Creating workspace and agent…", { exact: true })).toHaveCount(0); + }, + async expectAgentCannotBeStoppedYet() { + await expect( + page.getByRole("button", { name: "Stop agent" }).filter({ visible: true }), + ).toBeDisabled(); + }, + async queueFollowUp(prompt: string) { + await agentComposer(page).fill(prompt); + await agentComposer(page).press("Enter"); + await expect( + page.getByRole("button", { name: "Send queued message now" }).filter({ visible: true }), + ).toBeVisible(); + await expect(page.getByText(prompt, { exact: true }).filter({ visible: true })).toBeVisible(); + }, + async expectQueuedFollowUp(prompt: string) { + await expect( + page.getByRole("button", { name: "Send queued message now" }).filter({ visible: true }), + ).toHaveCount(1); + await expect(page.getByText(prompt, { exact: true }).filter({ visible: true })).toBeVisible(); + }, + async resubmitWorkspace() { + await page.getByRole("button", { name: "Create", exact: true }).click(); + }, + async expectAgentConfig(config: { model: string; modeId: string }) { + const agent = (await project.client.fetchAgents()).entries.find( + (entry) => entry.agent.workspaceId === workspaceId, + )!.agent; + expect({ model: agent.model, modeId: agent.currentModeId }).toEqual(config); + }, + async typeComposerDraft(text: string) { + await agentComposer(page).fill(text); + // The composer publishes typed text to its draft after the next paint. + await page.evaluate( + () => new Promise((resolve) => requestAnimationFrame(() => setTimeout(resolve, 0))), + ); + }, + async expectComposerDraft(text: string) { + await expect(agentComposer(page)).toHaveValue(text); + }, + async expectAgentNotWorking() { + await expect( + page.getByTestId("turn-working-indicator").filter({ visible: true }), + ).toHaveCount(0); + }, + async expectTimelineSettled() { + await expect(page.getByText("Updating messages", { exact: true })).toHaveCount(0); + }, + async expectUserMessagesInOrder(prompts: string[]) { + const agent = (await project.client.fetchAgents()).entries.find( + (entry) => entry.agent.workspaceId === workspaceId, + ); + const reader = await connectNewWorkspaceDaemonClient(); + try { + await expect + .poll(async () => + (await reader.fetchAgentTimeline(agent!.agent.id)).entries.flatMap(({ item }) => + item.type === "user_message" ? [item.text] : [], + ), + ) + .toEqual(prompts); + } finally { + await reader.close(); + } + }, async expectPromptVisible(prompt?: string) { // Inactive agent tabs retain their timeline DOM beside the visible draft. const rows = page.getByTestId("user-message").filter({ visible: true }); @@ -175,7 +362,7 @@ export async function createCreationScenario(page: Page) { await expect(page.getByTestId("user-message").first()).toBeVisible(); await expect( page - .locator('[data-testid^="workspace-tab-draft_"][aria-selected="true"]') + .locator('[data-testid^="workspace-tab-agent_"][aria-selected="true"]') .filter({ visible: true }), ).toBeVisible(); }, @@ -224,15 +411,57 @@ export async function createCreationScenario(page: Page) { .first(), ).toBeVisible(); }, - async expectStartupFailure() { - await expect(page.getByText(/Creation startup failed for test/).first()).toBeVisible(); + async expectFailedAgentDraft(prompt: string) { + await requests.settled(); + await expect( + page + .locator('[data-testid^="workspace-tab-draft_"][aria-selected="true"]') + .filter({ visible: true }), + ).toBeVisible(); + await expect( + page + .getByText(/Creation startup failed for test/) + .filter({ visible: true }) + .first(), + ).toBeVisible(); + await expect(agentComposer(page)).toHaveValue(prompt); + await this.expectNoCreationRecovery(); + }, + async resendFailedDraft() { + await page.getByRole("button", { name: "Send message", exact: true }).click(); + }, + async expectReturnedToNewWorkspace(prompt: string, error: string) { + await requests.settled(); + await expect(page).toHaveURL(/\/new\?/); + await expect(agentComposer(page)).toHaveValue(prompt); + await expect(page.getByText(error).filter({ visible: true }).first()).toBeVisible(); + await this.expectNoCreationRecovery(); + expect(requests.workspaceRequests()).toBe(1); + expect( + (await project.client.fetchWorkspaces()).entries.filter( + (workspace) => + workspace.projectId === project.projectId && + workspace.id !== project.workspaceId && + !evictionWorkspaceIds.has(workspace.id), + ), + ).toHaveLength(0); + }, + async expectNoCreationRecovery() { + await expect(page.getByRole("button", { name: "Retry creation", exact: true })).toHaveCount( + 0, + ); + await expect(page.getByRole("button", { name: "Return to draft", exact: true })).toHaveCount( + 0, + ); }, async expectOneCreatedWorkspace() { await expect(page).toHaveURL(/\/workspace\//); await requests.settled(); const workspaces = (await project.client.fetchWorkspaces()).entries.filter( (workspace) => - workspace.projectId === project.projectId && workspace.id !== project.workspaceId, + workspace.projectId === project.projectId && + workspace.id !== project.workspaceId && + !evictionWorkspaceIds.has(workspace.id), ); expect(workspaces).toHaveLength(1); workspaceId = workspaces[0]!.id; @@ -265,8 +494,11 @@ export async function createCreationScenario(page: Page) { .toBe(title); }, async evictAndReturnToDraft() { + workspaceId = new URL(page.url()).pathname.split("/workspace/")[1]!; const draft = page - .locator('[data-testid^="workspace-tab-draft_"][aria-selected="true"]') + .locator( + '[data-testid^="workspace-tab-draft_"][aria-selected="true"], [data-testid^="workspace-tab-agent_"][aria-selected="true"]', + ) .filter({ visible: true }) .first(); const draftTestId = await draft.getAttribute("data-testid"); @@ -276,6 +508,7 @@ export async function createCreationScenario(page: Page) { source: { kind: "directory", path: project.repoPath }, }); if (!result.workspace) throw new Error(result.error ?? "Failed to seed eviction workspace"); + evictionWorkspaceIds.add(result.workspace.id); await switchWorkspaceViaSidebar({ page, serverId: getServerId(), @@ -296,15 +529,18 @@ export async function createCreationScenario(page: Page) { export function createPromptRetryScenario( page: Page, gate: Awaited>, + kind: "agent" | "workspace" = "agent", ) { + const requestType = `${kind}.create.request`; + const responseType = `${kind}.create.response`; let firstMessage: ReturnType[number] | undefined; return { holdAcknowledgement() { - gate.holdNextServerMessage("agent.create.response"); + gate.holdNextServerMessage(responseType); }, async waitForDeliveredPrompt() { - await gate.waitForHeldServerMessage("agent.create.response"); - firstMessage = gate.getClientRequests("agent.create.request").at(-1); + await gate.waitForHeldServerMessage(responseType); + firstMessage = gate.getClientRequests(requestType).at(-1); }, async disconnectAndReconnect() { const fetches = gate.getClientRequestCount("fetch_agents_request"); @@ -315,13 +551,89 @@ export function createPromptRetryScenario( .toBeGreaterThan(fetches); }, async expectSameAgentAndMessage() { - expect(gate.getClientRequestCount("agent.create.request")).toBe(1); + expect(gate.getClientRequestCount(requestType)).toBe(1); await expect .poll(() => gate.getClientRequestCount("creation.subscribe.request")) .toBeGreaterThan(0); - expect(gate.getClientRequests("agent.create.request").at(-1)).toEqual(firstMessage); + expect(gate.getClientRequests(requestType).at(-1)).toEqual(firstMessage); expect(gate.getClientRequestCount("send_agent_message_request")).toBe(0); await expect(page.getByTestId("user-message").filter({ visible: true })).toHaveCount(1); }, }; } + +export async function hideCreationLifecycle(page: Page) { + const frames = await loadSessionMessageReaders(); + let requests = 0; + await page.routeWebSocket(daemonWsRoutePattern(), (browser) => { + const server = browser.connectToServer(); + browser.onMessage((frame) => { + if (frames.client(frame)?.type === "workspace.create.request") requests++; + server.send(frame); + }); + server.onMessage((frame) => { + const message = frames.server(frame); + if ( + message?.type === "status" && + message.payload.status === "server_info" && + typeof message.payload.features === "object" && + message.payload.features !== null + ) { + browser.send( + JSON.stringify({ + type: "session", + message: { + ...message, + payload: { + ...message.payload, + features: { ...message.payload.features, creationLifecycle: false }, + }, + }, + }), + ); + return; + } + browser.send(frame); + }); + }); + return { expectNoCreationRequest: () => expect(requests).toBe(0) }; +} + +/** Simulate an uncertain receipt after the real daemon registered and ran the initial agent. */ +export async function reportUnknownPromptOutcome(page: Page) { + const frames = await loadSessionMessageReaders(); + await page.routeWebSocket(daemonWsRoutePattern(), (browser) => { + const server = browser.connectToServer(); + browser.onMessage((frame) => server.send(frame)); + server.onMessage((frame) => { + const message = frames.server(frame); + if ( + message?.type === "workspace.create.response" && + message.payload.agent && + message.payload.creation + ) { + const error = "Initial prompt outcome is uncertain for test"; + browser.send( + JSON.stringify({ + ...JSON.parse(frame.toString()), + message: { + ...message, + payload: { + ...message.payload, + error, + creation: { + ...message.payload.creation, + phase: "failed", + error, + outcomeUnknown: true, + }, + }, + }, + }), + ); + return; + } + browser.send(frame); + }); + }); +} diff --git a/packages/app/e2e/support/helpers/new-workspace.ts b/packages/app/e2e/support/helpers/new-workspace.ts index 63034d52a3..fa33fbdb58 100644 --- a/packages/app/e2e/support/helpers/new-workspace.ts +++ b/packages/app/e2e/support/helpers/new-workspace.ts @@ -18,6 +18,7 @@ type NewWorkspaceDaemonClient = Pick< | "createPaseoWorktree" | "createWorkspace" | "fetchAgents" + | "fetchAgentTimeline" | "fetchWorkspaces" | "getPaseoWorktreeList" | "getDaemonConfig" diff --git a/packages/app/package.json b/packages/app/package.json index e2e39ec03d..b94bf60d26 100644 --- a/packages/app/package.json +++ b/packages/app/package.json @@ -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", diff --git a/packages/app/src/agent-presentation/index.ts b/packages/app/src/agent-presentation/index.ts index f3c3f139f7..c6d240879b 100644 --- a/packages/app/src/agent-presentation/index.ts +++ b/packages/app/src/agent-presentation/index.ts @@ -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: { diff --git a/packages/app/src/components/sidebar/sidebar-workspace-row.tsx b/packages/app/src/components/sidebar/sidebar-workspace-row.tsx index 2488e7956e..50b7410f99 100644 --- a/packages/app/src/components/sidebar/sidebar-workspace-row.tsx +++ b/packages/app/src/components/sidebar/sidebar-workspace-row.tsx @@ -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 ( + + ); + return ; +} + +function ReadySidebarWorkspaceRow({ workspace, selected, shortcutNumber, diff --git a/packages/app/src/composer/actions.test.ts b/packages/app/src/composer/actions.test.ts index 697f848a09..68c69136ed 100644 --- a/packages/app/src/composer/actions.test.ts +++ b/packages/app/src/composer/actions.test.ts @@ -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 () => { diff --git a/packages/app/src/composer/actions.ts b/packages/app/src/composer/actions.ts index f810ef19c6..65946424c3 100644 --- a/packages/app/src/composer/actions.ts +++ b/packages/app/src/composer/actions.ts @@ -170,13 +170,14 @@ export function removeComposerAttachmentAtIndex(in export interface CancelComposerAgentInput { client: ComposerCancelClient | null; agentId: string; + isAgentPublished: boolean; isAgentRunning: boolean; isCancellingAgent: boolean; isConnected: boolean; } export function cancelComposerAgent(input: CancelComposerAgentInput): Promise | 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 { +): 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; @@ -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 { 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( diff --git a/packages/app/src/composer/agent-controls/index.tsx b/packages/app/src/composer/agent-controls/index.tsx index 72795289cf..7c5fb7e432 100644 --- a/packages/app/src/composer/agent-controls/index.tsx +++ b/packages/app/src/composer/agent-controls/index.tsx @@ -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(() => { + 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; + 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["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 = ( ); @@ -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} diff --git a/packages/app/src/composer/draft/workspace-tab.tsx b/packages/app/src/composer/draft/workspace-tab.tsx index d00b6f7790..29f082ce76 100644 --- a/packages/app/src/composer/draft/workspace-tab.tsx +++ b/packages/app/src/composer/draft/workspace-tab.tsx @@ -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( diff --git a/packages/app/src/composer/index.tsx b/packages/app/src/composer/index.tsx index d000c269af..f475b00384 100644 --- a/packages/app/src/composer/index.tsx +++ b/packages/app/src/composer/index.tsx @@ -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; t: TFunction; @@ -1093,7 +1097,7 @@ function ComposerCancelButton({ buttonIconSize, cancelButtonStyle, handleCancelAgent, - isConnected, + canCancel, isCancellingAgent, agentInterruptKeys, t, @@ -1111,7 +1115,7 @@ function ComposerCancelButton({ 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( - () => ({ - 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, ], ); diff --git a/packages/app/src/composer/submission/writer.test.ts b/packages/app/src/composer/submission/writer.test.ts index 3ee174d554..0820ab25d0 100644 --- a/packages/app/src/composer/submission/writer.test.ts +++ b/packages/app/src/composer/submission/writer.test.ts @@ -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); +}); diff --git a/packages/app/src/composer/submission/writer.ts b/packages/app/src/composer/submission/writer.ts index f8b40034b6..af9c7c1be9 100644 --- a/packages/app/src/composer/submission/writer.ts +++ b/packages/app/src/composer/submission/writer.ts @@ -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 }); +} diff --git a/packages/app/src/hooks/use-sidebar-workspace-entries.ts b/packages/app/src/hooks/use-sidebar-workspace-entries.ts index c2eb65ac76..8bea1109eb 100644 --- a/packages/app/src/hooks/use-sidebar-workspace-entries.ts +++ b/packages/app/src/hooks/use-sidebar-workspace-entries.ts @@ -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 { + 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>(EMPTY_ENTRIES); // Collection ownership is intentional: retained sidebars have one cheap @@ -45,13 +50,25 @@ export function useSidebarWorkspaceEntries( previousEntriesRef.current = EMPTY_ENTRIES; return EMPTY_ENTRIES; } - const entries = buildSidebarWorkspaceEntries({ - placements, - sessions, - pendingCreateAttempts, - previousEntries: previousEntriesRef.current, - }); + const entries = new Map( + buildSidebarWorkspaceEntries({ + placements, + sessions, + pendingCreateAttempts, + previousEntries: previousEntriesRef.current, + }), + ); + for (const placement of placements) { + if (entries.has(placement.workspaceKey)) continue; + const creating = creations.some( + (creation) => + creation.serverId === placement.serverId && + creation.workspaceId === placement.workspaceId && + !creation.workspaceObserved, + ); + if (creating) entries.set(placement.workspaceKey, creatingSidebarEntry(placement, t)); + } previousEntriesRef.current = entries; return entries; - }, [enabled, pendingCreateAttempts, placements, sessions]); + }, [enabled, pendingCreateAttempts, placements, sessions, creations, t]); } diff --git a/packages/app/src/hooks/use-sidebar-workspaces-list.ts b/packages/app/src/hooks/use-sidebar-workspaces-list.ts index 378c4b4862..90745ec57b 100644 --- a/packages/app/src/hooks/use-sidebar-workspaces-list.ts +++ b/packages/app/src/hooks/use-sidebar-workspaces-list.ts @@ -1,3 +1,6 @@ +import { useTranslation } from "react-i18next"; +import { useWorkspaceCreations } from "@/workspace-creation"; +import { addCreatingSidebarWorkspaces } from "@/workspace-creation/presentation"; import { useHostFeatureMap } from "@/runtime/host-features"; import { useCallback, useEffect, useMemo } from "react"; import { useStoreWithEqualityFn } from "zustand/traditional"; @@ -150,10 +153,12 @@ export function useSidebarWorkspacesList(options?: { : undefined; const { projects: hostProjects } = useWorkspaceStructure(directoryServerIds, sort); + const { t } = useTranslation(); + const creations = useWorkspaceCreations(); const selectPlacements = useMemo(createSidebarWorkspacePlacementSelector, []); const sidebarModel = useMemo( - () => selectPlacements(hostProjects), - [hostProjects, selectPlacements], + () => addCreatingSidebarWorkspaces(selectPlacements(hostProjects), creations, t), + [hostProjects, selectPlacements, creations, t], ); const projects = sidebarModel.projects.length > 0 ? sidebarModel.projects : EMPTY_PROJECTS; diff --git a/packages/app/src/i18n/resources/ar.ts b/packages/app/src/i18n/resources/ar.ts index 108fad41d4..2c9f1421fb 100644 --- a/packages/app/src/i18n/resources/ar.ts +++ b/packages/app/src/i18n/resources/ar.ts @@ -1339,6 +1339,15 @@ export const ar: TranslationResources = { }, }, newWorkspace: { + creation: { + creatingWorkspace: "جارٍ إنشاء مساحة العمل…", + creatingLabel: "جارٍ إنشاء مساحة العمل", + updateHost: "حدّث هذا المضيف لإنشاء مساحة عمل تتضمن وكيلاً.", + chooseProjectHost: "اختر مشروعًا ومضيفًا", + projectUnavailable: "المشروع غير متاح على المضيف المحدد", + workspacePending: "لا يزال إنشاء مساحة العمل جاريًا.", + agentPending: "لا يزال إنشاء الوكيل جاريًا.", + }, title: "مساحة عمل جديدة", create: "يخلق", isolation: { diff --git a/packages/app/src/i18n/resources/en.ts b/packages/app/src/i18n/resources/en.ts index 16bddd9cf9..4d3886da2a 100644 --- a/packages/app/src/i18n/resources/en.ts +++ b/packages/app/src/i18n/resources/en.ts @@ -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: { diff --git a/packages/app/src/i18n/resources/es.ts b/packages/app/src/i18n/resources/es.ts index 2226583781..b12f2d2b60 100644 --- a/packages/app/src/i18n/resources/es.ts +++ b/packages/app/src/i18n/resources/es.ts @@ -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: { diff --git a/packages/app/src/i18n/resources/fr.ts b/packages/app/src/i18n/resources/fr.ts index 65d72d405b..1410892d9a 100644 --- a/packages/app/src/i18n/resources/fr.ts +++ b/packages/app/src/i18n/resources/fr.ts @@ -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: { diff --git a/packages/app/src/i18n/resources/ja.ts b/packages/app/src/i18n/resources/ja.ts index 5829bf4fa3..0667a6afc9 100644 --- a/packages/app/src/i18n/resources/ja.ts +++ b/packages/app/src/i18n/resources/ja.ts @@ -1353,6 +1353,15 @@ export const ja: TranslationResources = { }, }, newWorkspace: { + creation: { + creatingWorkspace: "ワークスペースを作成中…", + creatingLabel: "ワークスペースを作成中", + updateHost: "エージェント付きのワークスペースを作成するには、このホストを更新してください。", + chooseProjectHost: "プロジェクトとホストを選択してください", + projectUnavailable: "選択したホストでプロジェクトを利用できません", + workspacePending: "ワークスペースはまだ作成中です。", + agentPending: "エージェントはまだ作成中です。", + }, title: "新しいワークスペース", create: "作成", isolation: { diff --git a/packages/app/src/i18n/resources/ko.ts b/packages/app/src/i18n/resources/ko.ts index 36a27fc84e..750721a56f 100644 --- a/packages/app/src/i18n/resources/ko.ts +++ b/packages/app/src/i18n/resources/ko.ts @@ -1346,6 +1346,15 @@ export const ko: TranslationResources = { }, }, newWorkspace: { + creation: { + creatingWorkspace: "워크스페이스 생성 중…", + creatingLabel: "워크스페이스 생성 중", + updateHost: "에이전트가 있는 워크스페이스를 만들려면 이 호스트를 업데이트하세요.", + chooseProjectHost: "프로젝트와 호스트를 선택하세요", + projectUnavailable: "선택한 호스트에서 프로젝트를 사용할 수 없습니다", + workspacePending: "워크스페이스가 아직 생성 중입니다.", + agentPending: "에이전트가 아직 생성 중입니다.", + }, title: "새 워크스페이스", create: "생성", isolation: { diff --git a/packages/app/src/i18n/resources/pt-BR.ts b/packages/app/src/i18n/resources/pt-BR.ts index 5a83b4345c..410ddd8c69 100644 --- a/packages/app/src/i18n/resources/pt-BR.ts +++ b/packages/app/src/i18n/resources/pt-BR.ts @@ -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: { diff --git a/packages/app/src/i18n/resources/ru.ts b/packages/app/src/i18n/resources/ru.ts index 135d5fe2f9..c3d8e49e3b 100644 --- a/packages/app/src/i18n/resources/ru.ts +++ b/packages/app/src/i18n/resources/ru.ts @@ -1357,6 +1357,15 @@ export const ru: TranslationResources = { }, }, newWorkspace: { + creation: { + creatingWorkspace: "Создание рабочей области…", + creatingLabel: "Создание рабочей области", + updateHost: "Обновите этот хост, чтобы создать рабочую область с агентом.", + chooseProjectHost: "Выберите проект и хост", + projectUnavailable: "Проект недоступен на выбранном хосте", + workspacePending: "Рабочая область ещё создаётся.", + agentPending: "Агент ещё создаётся.", + }, title: "Новое рабочее пространство", create: "Создать", isolation: { diff --git a/packages/app/src/i18n/resources/zh-CN.ts b/packages/app/src/i18n/resources/zh-CN.ts index 78b0f06442..aa3bc5e830 100644 --- a/packages/app/src/i18n/resources/zh-CN.ts +++ b/packages/app/src/i18n/resources/zh-CN.ts @@ -1328,6 +1328,15 @@ export const zhCN: TranslationResources = { }, }, newWorkspace: { + creation: { + creatingWorkspace: "正在创建工作区…", + creatingLabel: "正在创建工作区", + updateHost: "请更新此主机,以创建包含代理的工作区。", + chooseProjectHost: "请选择项目和主机", + projectUnavailable: "所选主机上没有此项目", + workspacePending: "工作区仍在创建中。", + agentPending: "代理仍在创建中。", + }, title: "新建 workspace", create: "创建", isolation: { diff --git a/packages/app/src/panels/agent-panel.tsx b/packages/app/src/panels/agent-panel.tsx index 8139459b2b..1dc8a59730 100644 --- a/packages/app/src/panels/agent-panel.tsx +++ b/packages/app/src/panels/agent-panel.tsx @@ -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 ( 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({ 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 ( @@ -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({ kind: "idle", @@ -812,6 +827,9 @@ function ChatAgentContent({ [agentState, projectPlacement], ); const continuity = useMemo(() => { + 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" && diff --git a/packages/app/src/review/feedback/use-feedback.ts b/packages/app/src/review/feedback/use-feedback.ts index 0596f3c55f..0f67b6b615 100644 --- a/packages/app/src/review/feedback/use-feedback.ts +++ b/packages/app/src/review/feedback/use-feedback.ts @@ -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 () => [], diff --git a/packages/app/src/runtime/host-runtime.test.ts b/packages/app/src/runtime/host-runtime.test.ts index 33e4f1a316..85c7917c68 100644 --- a/packages/app/src/runtime/host-runtime.test.ts +++ b/packages/app/src/runtime/host-runtime.test.ts @@ -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 > = []; public sentAgentMessages: Array> = []; + public createWorkspaceRequests: Array[0]> = []; + public createWorkspaceResponse: ReturnType | null = null; public sendAgentMessageFailures: Error[] = []; public sendAgentMessageResponses: Promise[] = []; private agentUpdateListeners = new Set< @@ -119,6 +124,14 @@ class FakeDaemonClient { } } + createWorkspace( + request: Parameters[0], + ): ReturnType { + this.createWorkspaceRequests.push(request); + if (!this.createWorkspaceResponse) throw new Error("Unexpected workspace creation"); + return this.createWorkspaceResponse; + } + async connect(): Promise { 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>>(); + 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 + >); + + 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, diff --git a/packages/app/src/runtime/host-runtime.ts b/packages/app/src/runtime/host-runtime.ts index bb953220cb..46d4063279 100644 --- a/packages/app/src/runtime/host-runtime.ts +++ b/packages/app/src/runtime/host-runtime.ts @@ -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({ diff --git a/packages/app/src/screens/new-workspace-screen.tsx b/packages/app/src/screens/new-workspace-screen.tsx index 3cb01ae9df..3ba75ac3c4 100644 --- a/packages/app/src/screens/new-workspace-screen.tsx +++ b/packages/app/src/screens/new-workspace-screen.tsx @@ -1,10 +1,7 @@ -import type { - CreateAgentRequestOptions, - CreateWorkspaceRequestOptions, -} from "@getpaseo/client/internal/daemon-client"; -import type { AgentSnapshotPayload, CreationSnapshot } from "@getpaseo/protocol/messages"; -import { encodeImages } from "@/utils/encode-images"; import { getHostRuntimeStore } from "@/runtime/host-runtime"; +import type { CreateWorkspaceRequestOptions } from "@getpaseo/client/internal/daemon-client"; +import type { AgentSnapshotPayload, CreationSnapshot } from "@getpaseo/protocol/messages"; +import { submitWorkspaceChat, takeWorkspaceCreationError } from "@/workspace-creation"; import { useCallback, useEffect, useMemo, useReducer, useRef, useState } from "react"; import type { ReactNode } from "react"; import { useTranslation } from "react-i18next"; @@ -17,10 +14,6 @@ import { Composer } from "@/composer"; import { ComposerDock } from "@/composer/dock"; import { useComposerEditingSession, useComposerFullscreen } from "@/composer/editing-session"; import { FileDropZone } from "@/components/file-drop/file-drop-zone"; -import { - resolveComposerAttachmentSubmitFormat, - splitComposerAttachmentsForSubmit, -} from "@/composer/attachments/submit"; import { TitlebarDragRegion } from "@/components/desktop/titlebar-drag-region"; import { MenuHeader } from "@/components/headers/menu-header"; import { useIsCompactFormFactor } from "@/constants/layout"; @@ -52,7 +45,6 @@ import { normalizeWorkspaceDescriptor, type WorkspaceDescriptor } from "@/stores import { useWorkspace } from "@/stores/session-store-hooks"; import { buildNewWorkspaceDraftKey, generateDraftId } from "@/stores/draft-keys"; import { useOpenAddProject } from "@/hooks/use-open-add-project"; -import { isActiveCreateFlowForDraft, useCreateFlowStore } from "@/stores/create-flow-store"; import { useWorkspaceDraftSubmissionStore, type PendingWorkspaceDraftSetup, @@ -73,19 +65,12 @@ import { type HostProjectListItem, } from "@/projects/host-projects"; import { useProjectIcons } from "@/projects/icons"; -import type { ComposerAttachment } from "@/attachments/types"; import { useDraftWorkspaceAttachmentScopeKey } from "@/attachments/workspace-attachments-store"; import type { MessagePayload } from "@/composer/types"; import type { UserComposerAttachment } from "@/attachments/types"; import type { AgentAttachment, ForgeSearchItem } from "@getpaseo/protocol/messages"; -import type { DaemonClient } from "@getpaseo/client/internal/daemon-client"; -import type { AgentProvider } from "@getpaseo/protocol/agent-types"; -import type { WorkspaceDraftTabSetup, WorkspaceTabTarget } from "@/workspace-tabs/model"; +import type { WorkspaceDraftTabSetup } from "@/workspace-tabs/model"; import { isEmptyWorkspaceSubmission, runCreateEmptyWorkspace } from "./new-workspace-empty"; -import { - getWorkspaceNamingAttachments, - remapDraftCwdToWorkspace, -} from "./new-workspace-fork-context"; import { buildPickerOptionData, defaultBasePickerItem, @@ -116,40 +101,8 @@ import { type ListTerminalsPayload, upsertCreatedTerminalPayload, } from "./workspace/terminals/state"; -import { captureWorkspaceDraftCleanup } from "./new-workspace/background-handoff"; import { useNewWorkspaceScreenPresence } from "./new-workspace/screen-presence"; -function useIsNewWorkspaceDraftHandoffActive(input: { - draftId: string | undefined; - selectedServerId: string; -}): boolean { - const normalizedDraftId = input.draftId?.trim() ?? ""; - return useCreateFlowStore((state) => - isActiveCreateFlowForDraft({ - draftId: normalizedDraftId, - serverId: input.selectedServerId, - pending: normalizedDraftId ? state.pendingByDraftId[normalizedDraftId] : null, - }), - ); -} - -function resolveVisibleDraftContextScopeKeys(input: { - isDraftHandoffActive: boolean; - draftContextScopeKey: string; -}): readonly string[] { - if (input.isDraftHandoffActive || !input.draftContextScopeKey) { - return []; - } - return [input.draftContextScopeKey]; -} - -function isNewWorkspacePending(input: { - pendingAction: "chat" | "empty" | "terminal" | null; - isDraftHandoffActive: boolean; -}): boolean { - return input.pendingAction !== null || input.isDraftHandoffActive; -} - function buildFirstAgentContext(input: { prompt: string; attachments: AgentAttachment[]; @@ -238,43 +191,6 @@ function normalizeBranchDetails( */ type SubmitOutcome = "navigated" | "background"; -interface SubmitDraftInput { - clearConsumedDraft: () => void; - agentCreation?: { - result: Promise; - retry: (input: CreateAgentRequestOptions) => Promise; - }; - 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["composerState"] ->; - -interface WorkspaceDraftSubmissionConfig { - cwd: string; - provider: AgentProvider; - modeId: string | null; - model: string | null; - thinkingOptionId: string | null; - featureValues: Record | undefined; - target: WorkspaceTabTarget; -} - interface WorkspaceCreationResult { workspace: ReturnType; agent?: AgentSnapshotPayload; @@ -337,67 +253,6 @@ async function createMultiplicityWorkspace(input: { return { workspace: normalizedWorkspace, agent: payload.agent }; } -interface CreateChatAgentInput { - payload: MessagePayload; - composerState: ReturnType["composerState"]; - forkDraftSetup?: PendingWorkspaceDraftSetup | null; - ensureWorkspace: (input: { - cwd: string; - prompt: string; - attachments: AgentAttachment[]; - withInitialAgent: boolean; - agent?: CreateWorkspaceRequestOptions["agent"]; - onEvent?: (snapshot: CreationSnapshot) => void; - }) => Promise; - 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>(); -function runCreateChatAgent(input: CreateChatAgentInput): Promise { - 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 { - 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 = { - 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 => { - 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(null); const isolationPickerAnchorRef = useRef(null); const hostPickerAnchorRef = useRef(null); - const isDraftHandoffActive = useIsNewWorkspaceDraftHandoffActive({ draftId, selectedServerId }); const isStillOnCreateScreen = useNewWorkspaceScreenPresence(); // Launch target: what the composer submits to (chat agent, or a terminal @@ -984,11 +631,18 @@ export function NewWorkspaceScreen({ projects: projectIconTargets, }); const draftKey = buildNewWorkspaceDraftKey(draftId); + // A chat whose workspace could not be created returns here with its draft. + useEffect(() => { + const message = takeWorkspaceCreationError(draftKey); + if (!message) return; + setErrorMessage(message); + toast.error(message); + }, [draftKey, toast]); const forkDraftSetup = usePendingWorkspaceDraftSetup(draftId); const draftContextScopeKey = useDraftWorkspaceAttachmentScopeKey(draftId); const visibleDraftContextScopeKeys = useMemo( - () => resolveVisibleDraftContextScopeKeys({ isDraftHandoffActive, draftContextScopeKey }), - [draftContextScopeKey, isDraftHandoffActive], + () => (draftContextScopeKey ? [draftContextScopeKey] : []), + [draftContextScopeKey], ); const chatDraft = useAgentInputDraft({ draftKey, @@ -1037,7 +691,7 @@ export function NewWorkspaceScreen({ const worktreeSupport = selectedProject ? getWorktreeSupportForHostProject({ project: selectedProject, serverId: selectedServerId }) : "unsupported"; - const isPending = isNewWorkspacePending({ pendingAction, isDraftHandoffActive }); + const isPending = pendingAction !== null; const { effectiveIsolation, setIsolation, canCreateWorktree, showRefPicker } = useWorkspaceIsolation({ supportsMultiplicity: supportsWorkspaceMultiplicity, @@ -1290,9 +944,9 @@ export function NewWorkspaceScreen({ async (payload: MessagePayload) => { try { setErrorMessage(null); - await composerState?.persistFormPreferences(); - await updateFormPreferences({ launchTarget }); if (isEmptyWorkspaceSubmission(payload)) { + await composerState?.persistFormPreferences(); + await updateFormPreferences({ launchTarget }); setPendingAction("empty"); let outcome: SubmitOutcome = "background"; await runCreateEmptyWorkspace({ @@ -1316,28 +970,36 @@ export function NewWorkspaceScreen({ return; } - setPendingAction("chat"); - const outcome = await runCreateChatAgent({ + const attempt = submitWorkspaceChat({ + serverId: selectedServerId, + key: creationIdentity.draftId, + returnDraftId: draftId, + project: selectedProject, + sourceDirectory: selectedSourceDirectory, + worktreeSlug: creationIdentity.worktreeSlug, + createsWorktree: !supportsWorkspaceMultiplicity || effectiveIsolation === "worktree", + selectedItem, payload, composerState, - forkDraftSetup, - ensureWorkspace, - serverId: selectedServerId, - clearDraft: chatDraft.clear, - draftKey, - draftId: creationIdentity.draftId, - draftContextScopeKey, supportsForgeSearch, - resolveClient: withConnectedClient, - isStillOnCreateScreen, - labels: { - composerStateRequired: t("newWorkspace.errors.composerStateRequired"), - selectModel: t("newWorkspace.errors.selectModel"), + forkSetup: forkDraftSetup, + client: withConnectedClient(), + queryClient, + draftId: creationIdentity.draftId, + draftKey, + clearDraft: chatDraft.clear, + draftContextScopeKey, + persistPreferences: async () => { + await composerState?.persistFormPreferences(); + await updateFormPreferences({ launchTarget }); }, }); - if (outcome === "background") { - setPendingAction(null); - } + leaveFullscreen(); + navigateToWorkspace({ + serverId: selectedServerId, + workspaceId: attempt.workspaceId, + target: { kind: "agent", agentId: attempt.agentId }, + }); } catch (error) { const message = toErrorMessage(error); setPendingAction(null); @@ -1346,6 +1008,7 @@ export function NewWorkspaceScreen({ } }, [ + draftId, composerState, draftContextScopeKey, creationIdentity, @@ -1353,12 +1016,17 @@ export function NewWorkspaceScreen({ draftKey, ensureWorkspace, forkDraftSetup, + selectedProject, + selectedSourceDirectory, + selectedItem, + supportsWorkspaceMultiplicity, + effectiveIsolation, + queryClient, isStillOnCreateScreen, launchTarget, leaveFullscreen, selectedServerId, supportsForgeSearch, - t, toast, updateFormPreferences, withConnectedClient, diff --git a/packages/app/src/screens/workspace/compact-workspace-header/tab-switcher.tsx b/packages/app/src/screens/workspace/compact-workspace-header/tab-switcher.tsx index 737e92b4c2..d11db64836 100644 --- a/packages/app/src/screens/workspace/compact-workspace-header/tab-switcher.tsx +++ b/packages/app/src/screens/workspace/compact-workspace-header/tab-switcher.tsx @@ -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"), diff --git a/packages/app/src/screens/workspace/use-workspace-tab-rename.tsx b/packages/app/src/screens/workspace/use-workspace-tab-rename.tsx index 8141d508f4..f7834c993f 100644 --- a/packages/app/src/screens/workspace/use-workspace-tab-rename.tsx +++ b/packages/app/src/screens/workspace/use-workspace-tab-rename.tsx @@ -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 ?? ""; diff --git a/packages/app/src/screens/workspace/workspace-desktop-tabs-row.tsx b/packages/app/src/screens/workspace/workspace-desktop-tabs-row.tsx index f7645ac168..f39046bd42 100644 --- a/packages/app/src/screens/workspace/workspace-desktop-tabs-row.tsx +++ b/packages/app/src/screens/workspace/workspace-desktop-tabs-row.tsx @@ -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; 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 }, diff --git a/packages/app/src/screens/workspace/workspace-route-state.test.ts b/packages/app/src/screens/workspace/workspace-route-state.test.ts index 1bb74ef4ea..3946f709dc 100644 --- a/packages/app/src/screens/workspace/workspace-route-state.test.ts +++ b/packages/app/src/screens/workspace/workspace-route-state.test.ts @@ -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" }); + }, +); diff --git a/packages/app/src/screens/workspace/workspace-route-state.ts b/packages/app/src/screens/workspace/workspace-route-state.ts index 7f8137ace1..b572de0223 100644 --- a/packages/app/src/screens/workspace/workspace-route-state.ts +++ b/packages/app/src/screens/workspace/workspace-route-state.ts @@ -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") { diff --git a/packages/app/src/screens/workspace/workspace-screen.tsx b/packages/app/src/screens/workspace/workspace-screen.tsx index 3617c074e2..b0b24ae12d 100644 --- a/packages/app/src/screens/workspace/workspace-screen.tsx +++ b/packages/app/src/screens/workspace/workspace-screen.tsx @@ -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")); } diff --git a/packages/app/src/stores/session-store.ts b/packages/app/src/stores/session-store.ts index 911573762f..0d2876ecb4 100644 --- a/packages/app/src/stores/session-store.ts +++ b/packages/app/src/stores/session-store.ts @@ -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, diff --git a/packages/app/src/stores/workspace-draft-submission-store.test.ts b/packages/app/src/stores/workspace-draft-submission-store.test.ts index 73e1a40899..317b605767 100644 --- a/packages/app/src/stores/workspace-draft-submission-store.test.ts +++ b/packages/app/src/stores/workspace-draft-submission-store.test.ts @@ -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(() => {}); - 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(); -}); diff --git a/packages/app/src/stores/workspace-draft-submission-store.ts b/packages/app/src/stores/workspace-draft-submission-store.ts index 639701d740..9c978350cd 100644 --- a/packages/app/src/stores/workspace-draft-submission-store.ts +++ b/packages/app/src/stores/workspace-draft-submission-store.ts @@ -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; - retry: (input: CreateAgentRequestOptions) => Promise; - }; serverId: string; workspaceId: string; draftId: string; @@ -35,7 +28,6 @@ export interface PendingWorkspaceDraftSetup { interface WorkspaceDraftSubmissionState { pendingByDraftId: Record; setupByDraftId: Record; - creationByDraftId: Record>; setPending: (submission: PendingWorkspaceDraftSubmission) => void; setDraftSetup: (input: { draftId: string; @@ -69,12 +61,8 @@ export const useWorkspaceDraftSubmissionStore = create ({ 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 { - const creationByDraftId = { ...state.creationByDraftId }; - delete creationByDraftId[normalizedDraftId]; const { [normalizedDraftId]: _removed, ...setupByDraftId } = state.setupByDraftId; - return { setupByDraftId, creationByDraftId }; + return { setupByDraftId }; }); }, consumePending: (input) => { diff --git a/packages/app/src/workspace-creation/index.ts b/packages/app/src/workspace-creation/index.ts new file mode 100644 index 0000000000..a148421dc0 --- /dev/null +++ b/packages/app/src/workspace-creation/index.ts @@ -0,0 +1,9 @@ +export { + useWorkspaceCreations, + useWorkspaceCreation, + useAgentCreation, + isAgentCreationPending, + isAgentReadyForPrompts, + useIsAgentReadyForPrompts, +} from "./runtime"; +export { submitWorkspaceChat, takeWorkspaceCreationError } from "./submit"; diff --git a/packages/app/src/workspace-creation/internal/capture.ts b/packages/app/src/workspace-creation/internal/capture.ts new file mode 100644 index 0000000000..0efbe3f89d --- /dev/null +++ b/packages/app/src/workspace-creation/internal/capture.ts @@ -0,0 +1,12 @@ +/** Creation input is plain data; copy it without relying on browser-only structuredClone. */ +export function captureCreationInput(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; +} diff --git a/packages/app/src/workspace-creation/internal/model.test.ts b/packages/app/src/workspace-creation/internal/model.test.ts new file mode 100644 index 0000000000..e3a8e73ece --- /dev/null +++ b/packages/app/src/workspace-creation/internal/model.test.ts @@ -0,0 +1,337 @@ +import { describe, expect, it } from "vitest"; +import type { CreationSnapshot } from "@getpaseo/protocol/messages"; +import { createWorkspaceCreationModel } from "../model"; + +describe("optimistic workspace creation", () => { + it("publishes final identities and immutable input before preparing or receiving anything", async () => { + let prepare!: () => void; + const held = new Promise((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() { + let resolve!: (value: T) => void; + const promise = new Promise((done) => { + resolve = done; + }); + return { promise, resolve }; +} +function submission() { + return { + serverId: "host", + key: "draft-version-1", + provider: "codex" as const, + config: {}, + projectId: "project", + projectName: "Project", + text: "Original prompt", + prepare: async () => ({ + source: { kind: "directory" as const, path: "/project" }, + agent: { + config: { + provider: "codex" as const, + cwd: "/project/nested", + featureValues: { option: true }, + }, + initialPrompt: "Original prompt", + attachments: [ + { + type: "text" as const, + mimeType: "text/plain" as const, + text: "Attachment content", + title: "Context", + }, + ], + }, + }), + }; +} +function identities() { + return { + operationKey: "operation", + workspaceId: "wks_0123456789abcdef", + agentId: "agent", + clientMessageId: "message", + }; +} + +/** A host receipt; the workspace and agent payloads only need to be present. */ +function failedCreation(update: object): CreationSnapshot { + return { + kind: "workspace", + idempotencyKey: "operation", + revision: 3, + phase: "failed", + workspaceId: identities().workspaceId, + agentId: "agent", + error: "Provider startup failed", + ...update, + } as CreationSnapshot; +} + +describe("creation lifecycle ownership", () => { + it.each([ + ["the transport closes before any receipt", { throws: "Client closed" }, "workspace"], + [ + "provisioning fails", + { creation: { failedStage: "workspace", outcomeUnknown: true } }, + "workspace", + ], + ["agent creation fails after provisioning", { creation: { failedStage: "agent" } }, "agent"], + [ + "the transport closes after the workspace was published", + { throws: "Client closed", event: { phase: "workspace_ready", workspace: {} } }, + "agent", + ], + [ + "the agent may exist", + { creation: { failedStage: "agent", outcomeUnknown: true } }, + "complete", + ], + [ + "the first prompt fails on a created agent", + { creation: { failedStage: "prompt", outcomeUnknown: true } }, + "complete", + ], + ] as const)("when %s, hands the creation to its %s owner", async (_name, outcome, owner) => { + const handled: string[] = []; + let executions = 0; + const model = createWorkspaceCreationModel({ + identities, + execute: async (request) => { + executions++; + if ("event" in outcome) + request.onEvent?.(failedCreation({ ...outcome.event, revision: 1 })); + if ("throws" in outcome) throw new Error(outcome.throws); + return { error: "Provider startup failed", creation: failedCreation(outcome.creation) }; + }, + onFailed: () => handled.push("prompt rejected"), + }); + const attempt = model.submit({ + ...submission(), + onWorkspaceFailed: () => handled.push("workspace"), + onAgentFailed: () => handled.push("agent"), + onComplete: () => handled.push("complete"), + }); + await model.settled(attempt); + expect(handled).toEqual(owner === "complete" ? ["complete"] : ["prompt rejected", owner]); + // A kept agent stays presented until the host publishes it; a failure leaves at once. + expect(model.getWorkspace("host", attempt.workspaceId)?.phase ?? null).toBe( + owner === "complete" ? "complete" : null, + ); + expect(executions).toBe(1); + }); + + it("returns a preparation failure to New workspace without any host execution", async () => { + let executions = 0; + const failures: string[] = []; + const model = createWorkspaceCreationModel({ + identities, + execute: async () => { + executions++; + return {}; + }, + }); + const attempt = model.submit({ + ...submission(), + prepare: async () => { + throw new Error("Image unavailable"); + }, + onWorkspaceFailed: (_attempt, error) => failures.push(error), + }); + await model.settled(attempt); + expect(failures).toEqual(["Image unavailable"]); + expect(executions).toBe(0); + }); + + it("reports completion and adoption once", async () => { + const lifecycle: string[] = []; + const model = createWorkspaceCreationModel({ + identities, + execute: async () => ({}), + onSubmitted: (attempt) => lifecycle.push(`submitted:${attempt.phase}`), + onComplete: (attempt) => lifecycle.push(`complete:${attempt.phase}`), + onAdopted: (attempt) => lifecycle.push(`adopted:${attempt.phase}`), + }); + const attempt = model.submit(submission()); + await model.settled(attempt); + model.observeResources("host", new Set([attempt.workspaceId]), new Set()); + expect(lifecycle).toEqual(["submitted:preparing", "complete:complete"]); + model.observeResources("host", new Set([attempt.workspaceId]), new Set([attempt.agentId])); + model.observeResources("host", new Set([attempt.workspaceId]), new Set([attempt.agentId])); + expect(lifecycle).toEqual(["submitted:preparing", "complete:complete", "adopted:complete"]); + }); + + it("does not regress receipt revisions or release overlays on empty directory snapshots", async () => { + const response = deferred<{}>(); + const observed = + deferred< + NonNullable< + Parameters[0]["execute"]>[0]["onEvent"] + > + >(); + const model = createWorkspaceCreationModel({ + identities, + execute: async (request) => { + observed.resolve(request.onEvent!); + return response.promise; + }, + }); + const attempt = model.submit(submission()); + const onEvent = await observed.promise; + const event = { + kind: "workspace" as const, + idempotencyKey: "operation", + workspaceId: attempt.workspaceId, + agentId: attempt.agentId, + revision: 3, + phase: "workspace_ready" as const, + error: null, + }; + onEvent(event); + onEvent({ ...event, revision: 1, phase: "accepted" }); + model.observeResources("host", new Set(), new Set()); + expect(model.getWorkspace("host", attempt.workspaceId)).toMatchObject({ + revision: 3, + workspaceObserved: false, + agentObserved: false, + }); + model.observeResources("host", new Set([attempt.workspaceId]), new Set([attempt.agentId])); + model.observeResources("host", new Set(), new Set()); + response.resolve({}); + await model.settled(attempt); + expect(model.getWorkspace("host", attempt.workspaceId)).toBeNull(); + }); + + it("captures presentation without retaining caller mutation and permits a distinct newer intent", async () => { + const response = deferred<{}>(); + let next = 0; + const model = createWorkspaceCreationModel({ + identities: () => ({ ...identities(), agentId: `agent-${++next}` }), + execute: async () => response.promise, + }); + const original = submission(); + const first = model.submit(original); + original.text = "Newer draft"; + const second = model.submit({ ...original, key: "draft-version-2" }); + expect(first.text).toBe("Original prompt"); + expect(second.text).toBe("Newer draft"); + expect(second.agentId).not.toBe(first.agentId); + response.resolve({}); + await model.settled(first); + await model.settled(second); + }); +}); + +describe("settled presentation lifetime", () => { + it("retires adopted presentation while repeated submission still returns its original identities", async () => { + let executions = 0; + const model = createWorkspaceCreationModel({ + identities, + execute: async () => { + executions++; + return {}; + }, + }); + const input = submission(); + const attempt = model.submit(input); + await model.settled(attempt); + model.observeResources("host", new Set([attempt.workspaceId]), new Set([attempt.agentId])); + expect(model.getSnapshot()).toEqual([]); + const repeated = model.submit(input); + expect(repeated.workspaceId).toBe(attempt.workspaceId); + expect(repeated.agentId).toBe(attempt.agentId); + await model.settled(repeated); + expect(executions).toBe(1); + expect(repeated.text).toBe(""); + expect(repeated.message).toBeUndefined(); + }); +}); + +it("bounds adopted receipts and keeps in-flight input outside that bound", async () => { + let executions = 0; + let identity = 0; + const held = deferred<{}>(); + const model = createWorkspaceCreationModel({ + identities: () => ({ + ...identities(), + workspaceId: `workspace-${++identity}`, + agentId: `agent-${identity}`, + }), + execute: async (request) => { + executions++; + return request.agent?.initialPrompt === "held" ? held.promise : {}; + }, + }); + const pending = model.submit({ + ...submission(), + key: "pending", + prepare: async () => ({ + source: { kind: "directory", path: "/project" }, + agent: { provider: "codex", cwd: "/project", initialPrompt: "held" }, + }), + }); + const first = { ...submission(), key: "settled-0" }; + let latest = first; + for (let n = 0; n < 130; n++) { + latest = { ...submission(), key: `settled-${n}` }; + const attempt = model.submit(latest); + await model.settled(attempt); + model.observeResources("host", new Set([attempt.workspaceId]), new Set([attempt.agentId])); + } + expect(model.getSnapshot()).toHaveLength(1); + expect(model.getSnapshot()[0]?.text).toBe("Original prompt"); + const before = executions; + await model.settled(model.submit(latest)); + expect(executions).toBe(before); + await model.settled(model.submit(first)); + expect(executions).toBe(before + 1); + held.resolve({}); + await model.settled(pending); +}); diff --git a/packages/app/src/workspace-creation/internal/presentation.test.ts b/packages/app/src/workspace-creation/internal/presentation.test.ts new file mode 100644 index 0000000000..b558f7f87f --- /dev/null +++ b/packages/app/src/workspace-creation/internal/presentation.test.ts @@ -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); + }); +}); diff --git a/packages/app/src/workspace-creation/internal/random-id.ts b/packages/app/src/workspace-creation/internal/random-id.ts new file mode 100644 index 0000000000..f2993ccfa6 --- /dev/null +++ b/packages/app/src/workspace-creation/internal/random-id.ts @@ -0,0 +1 @@ +export { randomUUID } from "expo-crypto"; diff --git a/packages/app/src/workspace-creation/internal/random-id.web.ts b/packages/app/src/workspace-creation/internal/random-id.web.ts new file mode 100644 index 0000000000..5b06c8a610 --- /dev/null +++ b/packages/app/src/workspace-creation/internal/random-id.web.ts @@ -0,0 +1,3 @@ +export function randomUUID(): string { + return crypto.randomUUID(); +} diff --git a/packages/app/src/workspace-creation/model/index.ts b/packages/app/src/workspace-creation/model/index.ts new file mode 100644 index 0000000000..1dbd6a035a --- /dev/null +++ b/packages/app/src/workspace-creation/model/index.ts @@ -0,0 +1,249 @@ +import { sha256 } from "@noble/hashes/sha2.js"; +import { bytesToHex, utf8ToBytes } from "@noble/hashes/utils.js"; +import { captureCreationInput } from "../internal/capture"; +import type { CreateWorkspaceRequestOptions } from "@getpaseo/client/internal/daemon-client"; +import type { AgentProvider } from "@getpaseo/protocol/agent-types"; +import type { CreationSnapshot } from "@getpaseo/protocol/messages"; +import type { UserMessageItem } from "@/types/stream"; + +export interface Submission { + serverId: string; + key: string; + provider: AgentProvider; + /** Submitted agent configuration, shown before the host publishes the agent. */ + config: { modeId?: string; model?: string; thinkingOptionId?: string }; + projectId: string; + projectName: string; + text: string; + message?: Omit; + prepare: () => Promise; + 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 { + operationKey: string; + workspaceId: string; + agentId: string; + clientMessageId: string; + phase: "preparing" | "creating" | "complete"; + revision: number; + workspaceObserved: boolean; + agentObserved: boolean; +} +interface Result { + workspace?: unknown; + error?: string | null; + creation?: CreationSnapshot; +} +interface Operation extends Pick { + attempt: CreationAttempt; + prepare: Submission["prepare"] | null; + request?: CreateWorkspaceRequestOptions; + /** The host's latest account of this creation. */ + creation?: CreationSnapshot; + running?: Promise; +} + +/** 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; + 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(); + // Current submissions remain owned until resolved or dismissed. Adopted creations + // only need a bounded receipt to absorb duplicate commands from retained forms. + const receipts = new Map(); + 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>, + update: Partial, + ) { + operation.attempt = { ...operation.attempt, ...update }; + publish(); + } + function complete(operation: Operation) { + patch(operation, { phase: "complete" }); + dependencies.onComplete?.(operation.attempt); + const onComplete = operation.onComplete; + operation.prepare = null; + operation.onComplete = undefined; + operation.onWorkspaceFailed = undefined; + operation.onAgentFailed = undefined; + operation.request = undefined; + onComplete?.(); + retire(operation); + } + /** Hands a failure to the owner of what the host created: the agent, the workspace or nothing. */ + function fail(operation: Operation, error: string) { + const created = createdResources(operation.attempt, operation.creation); + if (created === "agent") { + complete(operation); + return; + } + operations.delete(operation.attempt.key); + publish(); + dependencies.onFailed?.(operation.attempt); + if (created === "workspace") + operation.onAgentFailed?.(operation.attempt, error, operation.creation?.workspace); + else operation.onWorkspaceFailed?.(operation.attempt, error); + } + async function run(operation: NonNullable>) { + try { + if (!operation.request) { + if (!operation.prepare) throw new Error("Completed creation cannot be submitted again"); + const prepared = await operation.prepare(); + operation.request = captureCreationInput({ + ...prepared, + workspaceId: operation.attempt.workspaceId, + idempotencyKey: operation.attempt.operationKey, + agent: { + ...prepared.agent, + agentId: operation.attempt.agentId, + clientMessageId: operation.attempt.clientMessageId, + }, + }); + } + patch(operation, { phase: "creating" }); + const result = await dependencies.execute( + { + ...captureCreationInput(operation.request), + onEvent: (event) => { + if (event.revision <= operation.attempt.revision) return; + operation.creation = event; + patch(operation, { revision: event.revision }); + }, + }, + operation.attempt.serverId, + ); + if (result.creation) operation.creation = result.creation; + if (result.error) { + fail(operation, result.error); + return; + } + complete(operation); + } catch (error) { + fail(operation, error instanceof Error ? error.message : String(error)); + } finally { + operation.running = undefined; + } + } + function start(operation: NonNullable>) { + operation.running = Promise.resolve().then(() => run(operation)); + } + return { + subscribe(listener: () => void) { + listeners.add(listener); + return () => { + listeners.delete(listener); + }; + }, + getSnapshot: () => snapshot, + submit(input: Submission): CreationAttempt { + const key = intentKey(input.serverId, input.key); + const existing = operations.get(key); + if (existing) return existing.attempt; + const receipt = receipts.get(key); + if (receipt) return receipt; + const { prepare, onComplete, onWorkspaceFailed, onAgentFailed, ...presentation } = input; + const attempt: CreationAttempt = { + ...captureCreationInput(presentation), + key, + ...dependencies.identities(), + phase: "preparing", + revision: -1, + workspaceObserved: false, + agentObserved: false, + }; + const operation: Operation = { + prepare, + onComplete, + onWorkspaceFailed, + onAgentFailed, + attempt, + }; + operations.set(key, operation); + dependencies.onSubmitted?.(operation.attempt); + publish(); + start(operation); + return operation.attempt; + }, + observeResources( + serverId: string, + workspaces: ReadonlySet, + agents: ReadonlySet, + ) { + 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(), + }; +} diff --git a/packages/app/src/workspace-creation/presentation/index.ts b/packages/app/src/workspace-creation/presentation/index.ts new file mode 100644 index 0000000000..1303830c9f --- /dev/null +++ b/packages/app/src/workspace-creation/presentation/index.ts @@ -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, + }; +} diff --git a/packages/app/src/workspace-creation/runtime.ts b/packages/app/src/workspace-creation/runtime.ts new file mode 100644 index 0000000000..6acb8b1e4e --- /dev/null +++ b/packages/app/src/workspace-creation/runtime.ts @@ -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; +} diff --git a/packages/app/src/workspace-creation/submit.ts b/packages/app/src/workspace-creation/submit.ts new file mode 100644 index 0000000000..50c4599fc3 --- /dev/null +++ b/packages/app/src/workspace-creation/submit.ts @@ -0,0 +1,288 @@ +import { i18n } from "@/i18n/i18next"; +import { router } from "expo-router"; +import { buildNewWorkspaceRoute } from "@/utils/host-routes"; +import type { PendingWorkspaceDraftSetup } from "@/stores/workspace-draft-submission-store"; +import { remapDraftCwdToWorkspace } from "@/screens/new-workspace-fork-context"; +import { captureCreationInput } from "./internal/capture"; +import { useDraftStore } from "@/stores/draft-store"; +import { + buildDraftWorkspaceAttachmentScopeKey, + useWorkspaceAttachmentsStore, +} from "@/attachments/workspace-attachments-store"; +import { + isWorkspaceAttachment, + userAttachmentsOnly, +} from "@/attachments/workspace-attachment-utils"; +import type { QueryClient } from "@tanstack/react-query"; +import type { DaemonClient } from "@getpaseo/client/internal/daemon-client"; +import type { MessagePayload } from "@/composer/types"; +import type { useAgentInputDraft } from "@/composer/draft/input-draft"; +import { + splitComposerAttachmentsForSubmit, + resolveComposerAttachmentSubmitFormat, +} from "@/composer/attachments/submit"; +import { ensureCheckoutStatus } from "@/git/checkout-status-cache"; +import { getHostProjectId, type HostProjectListItem } from "@/projects/host-projects"; +import { captureWorkspaceDraftCleanup } from "@/screens/new-workspace/background-handoff"; +import { getAttachmentKey } from "@/composer/attachments/workspace-cleanup"; +import { + defaultBasePickerItem, + pickerItemToCheckoutRequest, + type PickerItem, +} from "@/screens/new-workspace-picker-item"; +import { useSessionStore } from "@/stores/session-store"; +import { transferComposerFollowUps } from "@/composer/submission/writer"; +import { useCreateFlowStore } from "@/stores/create-flow-store"; +import { buildDraftStoreKey, generateDraftId } from "@/stores/draft-keys"; +import { useWorkspaceLayoutStore } from "@/stores/workspace-layout-store"; +import { + buildWorkspaceTabPersistenceKey, + type WorkspaceDraftTabSetup, +} from "@/workspace-tabs/model"; +import type { CreationSnapshot } from "@getpaseo/protocol/messages"; +import { encodeImages } from "@/utils/encode-images"; +import type { CreationAttempt } from "./model"; +import { workspaceCreations } from "./runtime"; + +type ComposerState = NonNullable["composerState"]>; +type DraftCleanupInput = Parameters[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; +} + +// Failed creations return to New workspace, whose error lives in screen state. +const workspaceErrors = new Map(); + +/** The error of a creation that returned to the New workspace draft with this key, once. */ +export function takeWorkspaceCreationError(draftKey: string): string | null { + const error = workspaceErrors.get(draftKey) ?? null; + workspaceErrors.delete(draftKey); + return error; +} + +/** + * The composer released the submitted context when it submitted; the draft that takes the + * submission back owns it again. Context added to the draft since then stays as it is. + */ +function restoreSubmittedContext(draftId: string, payload: MessagePayload) { + const scopeKey = buildDraftWorkspaceAttachmentScopeKey(draftId); + const attachments = useWorkspaceAttachmentsStore.getState(); + const present = new Set((attachments.attachmentsByScope[scopeKey] ?? []).map(getAttachmentKey)); + for (const attachment of payload.attachments.filter(isWorkspaceAttachment)) { + if (!present.has(getAttachmentKey(attachment))) + attachments.addWorkspaceAttachment({ scopeKey, attachment }); + } +} + +/** + * The workspace exists without its agent, so the input continues as that workspace's agent draft, + * failed exactly as a draft whose own agent creation failed, holding the submitted input, context + * and setup. + */ +function continueAsFailedAgentDraft(input: { + attempt: CreationAttempt; + draftId: string; + payload: MessagePayload; + setup: WorkspaceDraftTabSetup; + sourceDirectory: string; + workspace: CreationSnapshot["workspace"]; + error: string; +}) { + const { attempt, draftId } = input; + const workspaceDirectory = + input.workspace?.workspaceDirectory ?? + useSessionStore.getState().sessions[attempt.serverId]?.workspaces.get(attempt.workspaceId) + ?.workspaceDirectory ?? + input.sourceDirectory; + useDraftStore.getState().saveDraftInput({ + draftKey: buildDraftStoreKey({ serverId: attempt.serverId, agentId: "", draftId }), + draft: { + text: input.payload.text, + attachments: userAttachmentsOnly(input.payload.attachments), + }, + }); + restoreSubmittedContext(draftId, input.payload); + const createFlow = useCreateFlowStore.getState(); + createFlow.setPending({ + draftId, + serverId: attempt.serverId, + workspaceId: attempt.workspaceId, + agentId: null, + clientMessageId: `${draftId}:initial-message`, + text: attempt.text, + timestamp: Date.now(), + }); + createFlow.markLifecycle({ draftId, lifecycle: "abandoned", errorMessage: input.error }); + const layouts = useWorkspaceLayoutStore.getState(); + const workspaceKey = buildWorkspaceTabPersistenceKey({ + serverId: attempt.serverId, + workspaceId: attempt.workspaceId, + }); + if (!workspaceKey) return; + // The submitted cwd is relative to the source checkout; the draft runs in the created workspace. + const setup = { + ...input.setup, + cwd: remapDraftCwdToWorkspace({ + cwd: input.setup.cwd, + sourceDirectory: input.sourceDirectory, + workspaceDirectory, + }), + }; + const target = { kind: "draft", draftId, setup } as const; + const agentTabs = layouts + .getWorkspaceTabs(workspaceKey) + .filter((tab) => tab.target.kind === "agent" && tab.target.agentId === attempt.agentId); + const draftTabIds = agentTabs.length + ? agentTabs.map((tab) => layouts.replaceTab(workspaceKey, tab.tabId, target)) + : [layouts.openTab({ workspaceKey, target, intent: "background" })]; + // The draft composer owns the follow-ups until its own agent creation hands them on. + const draftTabId = draftTabIds.find((tabId) => tabId !== null); + if (draftTabId) transferComposerFollowUps(attempt.serverId, attempt.agentId, draftTabId); +} + +/** Captures one intent. All preparation continues under the app owner after navigation. */ +export function submitWorkspaceChat(input: SubmitWorkspaceChatInput) { + const { serverId, project, sourceDirectory, composerState } = input; + if (!useSessionStore.getState().sessions[serverId]?.serverInfo?.features?.creationLifecycle) { + throw new Error(i18n.t("newWorkspace.creation.updateHost")); + } + if (!project || !sourceDirectory) + throw new Error(i18n.t("newWorkspace.creation.chooseProjectHost")); + if (!composerState?.selectedProvider) throw new Error(i18n.t("newWorkspace.errors.selectModel")); + const projectId = getHostProjectId(project, serverId); + if (!projectId) throw new Error(i18n.t("newWorkspace.creation.projectUnavailable")); + const payload = captureCreationInput(input.payload); + const provider = composerState.selectedProvider; + const cwd = input.forkSetup + ? remapDraftCwdToWorkspace({ + cwd: input.forkSetup.setup.cwd, + sourceDirectory, + workspaceDirectory: sourceDirectory, + }) + : payload.cwd; + const config = captureCreationInput({ + provider, + cwd, + modeId: composerState.effectiveModeId || undefined, + model: composerState.effectiveModelId || undefined, + thinkingOptionId: composerState.effectiveThinkingOptionId || undefined, + featureValues: composerState.featureValues, + }); + const wire = splitComposerAttachmentsForSubmit(payload.attachments, { + format: resolveComposerAttachmentSubmitFormat({ + supportsForgeAttachments: input.supportsForgeSearch, + }), + }); + const selectedItem = captureCreationInput(input.selectedItem); + const clearConsumedDraft = captureWorkspaceDraftCleanup(input); + const draftVersion = useDraftStore.getState().drafts[input.draftKey]?.version; + const intent = JSON.stringify([ + input.key, + draftVersion, + projectId, + sourceDirectory, + input.createsWorktree, + selectedItem, + config, + payload, + ]); + const submitted = workspaceCreations.submit({ + serverId, + key: intent, + provider, + config: { + modeId: config.modeId, + model: config.model, + thinkingOptionId: config.thinkingOptionId, + }, + projectId, + projectName: project.projectName, + text: payload.text, + onWorkspaceFailed: (attempt, error) => { + workspaceErrors.set(input.draftKey, error); + if (input.returnDraftId) restoreSubmittedContext(input.returnDraftId, payload); + // The New workspace composer owns the follow-ups until the form is submitted again. + transferComposerFollowUps(serverId, attempt.agentId, input.draftKey); + router.replace( + buildNewWorkspaceRoute({ + serverId, + sourceDirectory, + projectId, + draftId: input.returnDraftId, + }) as never, + ); + }, + onAgentFailed: (attempt, error, workspace) => { + // Release the form first; the draft then takes the submitted input and its context. + clearConsumedDraft(); + continueAsFailedAgentDraft({ + attempt, + draftId: input.draftId?.trim() || generateDraftId(), + payload, + setup: { + provider, + cwd, + modeId: config.modeId ?? null, + model: config.model ?? null, + thinkingOptionId: config.thinkingOptionId ?? null, + featureValues: config.featureValues ?? {}, + }, + sourceDirectory, + workspace, + error, + }); + }, + message: { + kind: "user_message", + text: payload.text, + timestamp: new Date(), + images: wire.images, + attachments: wire.attachments, + }, + prepare: async () => { + await input.persistPreferences(); + const images = await encodeImages(wire.images); + const status = input.createsWorktree + ? await ensureCheckoutStatus({ + queryClient: input.queryClient, + client: input.client, + serverId, + cwd: sourceDirectory, + }) + : null; + const checkout = status + ? pickerItemToCheckoutRequest(selectedItem ?? defaultBasePickerItem(status)) + : undefined; + return { + source: input.createsWorktree + ? { + kind: "worktree", + cwd: sourceDirectory, + projectId, + worktreeSlug: input.worktreeSlug, + ...checkout, + } + : { kind: "directory", path: sourceDirectory, projectId }, + agent: { config, initialPrompt: payload.text, images, attachments: wire.attachments }, + }; + }, + onComplete: clearConsumedDraft, + }); + // Follow-ups kept by a returned form continue behind this submission's initial prompt. + transferComposerFollowUps(serverId, input.draftKey, submitted.agentId); + return submitted; +} diff --git a/packages/app/src/workspace-creation/ui/index.tsx b/packages/app/src/workspace-creation/ui/index.tsx new file mode 100644 index 0000000000..774e4384e9 --- /dev/null +++ b/packages/app/src/workspace-creation/ui/index.tsx @@ -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 ( + + {t("newWorkspace.creation.creatingWorkspace")} + + ); +} +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]); +} diff --git a/packages/server/src/server/agent/create-agent/intent.test.ts b/packages/server/src/server/agent/create-agent/intent.test.ts index 38f390d3f8..f666206ea2 100644 --- a/packages/server/src/server/agent/create-agent/intent.test.ts +++ b/packages/server/src/server/agent/create-agent/intent.test.ts @@ -23,6 +23,38 @@ describe("resolveCreateAgentIntent", () => { }); }); + it("places a combined creation inside its selected workspace and keeps parentage", async () => { + const intent = await resolveCreateAgentIntent({ + explicitWorkspaceId: "workspace-new", + workspaceRelativeCwd: "packages/app", + caller: { id: "parent", cwd: "/source", workspaceId: "workspace-source" }, + resolveWorkspace: async (workspaceId) => ({ workspaceId, cwd: "/new" }), + createWorkspace: async () => { + throw new Error("Unexpected workspace creation"); + }, + }); + expect(intent.cwd).toBe("/new/packages/app"); + expect(intent.workspaceId).toBe("workspace-new"); + expect(intent.parentAgentId).toBe("parent"); + }); + + it.each(["../outside", "/outside"])( + "rejects an invalid relative placement: %s", + async (workspaceRelativeCwd) => { + await expect( + resolveCreateAgentIntent({ + explicitWorkspaceId: "workspace-new", + workspaceRelativeCwd, + caller: null, + resolveWorkspace: async (workspaceId) => ({ workspaceId, cwd: "/new" }), + createWorkspace: async () => { + throw new Error("Unexpected workspace creation"); + }, + }), + ).rejects.toThrow("inside the selected workspace"); + }, + ); + it("defaults an agent caller to its workspace without creating one", async () => { let createCount = 0; const intent = await resolveCreateAgentIntent({ diff --git a/packages/server/src/server/agent/create-agent/intent.ts b/packages/server/src/server/agent/create-agent/intent.ts index c811144b74..f61456a525 100644 --- a/packages/server/src/server/agent/create-agent/intent.ts +++ b/packages/server/src/server/agent/create-agent/intent.ts @@ -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; childAgentDefaultLabels?: Record; @@ -47,12 +51,27 @@ export async function resolveCreateAgentIntent(input: { async function resolvePlacement(input: { explicitWorkspaceId?: string; + workspaceRelativeCwd?: string; caller: CreateAgentCaller | null; resolveWorkspace: (workspaceId: string) => Promise; createWorkspace: () => Promise; }): Promise { 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) { diff --git a/packages/server/src/server/session.ts b/packages/server/src/server/session.ts index f573b1dd95..8135dccb8a 100644 --- a/packages/server/src/server/session.ts +++ b/packages/server/src/server/session.ts @@ -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, + workspaceRelativeCwd?: string, ): Promise { 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 { 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, diff --git a/packages/server/src/server/workspace-create-worktree-source.e2e.test.ts b/packages/server/src/server/workspace-create-worktree-source.e2e.test.ts index fc9efe1684..5f2885e23f 100644 --- a/packages/server/src/server/workspace-create-worktree-source.e2e.test.ts +++ b/packages/server/src/server/workspace-create-worktree-source.e2e.test.ts @@ -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, +);