From 89a55076024b73a21c20e9eec1a97cf79f706cf0 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Wed, 12 Aug 2026 20:48:34 -0700 Subject: [PATCH] fix: GitHub project selection cloned repos before Start Session (#122906) * fix(ui): defer GitHub project clone until submit Keep remote project selection as draft state and materialize it before sessions.create on Start Session. * fix(ui): consolidate deferred project selection * test(ui): drop duplicate remote project assertion --- ...w-session-page.github-projects.e2e.test.ts | 30 ++-- .../new-session/draft-place-browser.test.ts | 2 - .../pages/new-session/draft-place-browser.ts | 111 ++++--------- ui/src/pages/new-session/draft-place-state.ts | 77 ++++----- .../new-session/draft-repository-state.ts | 7 +- .../new-session/draft-submission-flow.test.ts | 148 +++++++++++++++++- .../new-session/draft-submission-flow.ts | 30 +++- ui/src/pages/new-session/new-session-page.ts | 15 +- ui/src/pages/new-session/path.ts | 10 ++ ui/src/pages/new-session/project-chip.test.ts | 1 + ui/src/pages/new-session/project-chip.ts | 76 +++++---- 11 files changed, 320 insertions(+), 187 deletions(-) diff --git a/ui/src/e2e/new-session-page.github-projects.e2e.test.ts b/ui/src/e2e/new-session-page.github-projects.e2e.test.ts index b8d5234f6229..35bb1f606385 100644 --- a/ui/src/e2e/new-session-page.github-projects.e2e.test.ts +++ b/ui/src/e2e/new-session-page.github-projects.e2e.test.ts @@ -13,7 +13,7 @@ import { const suite = createNewSessionPageE2eSuite(); suite.define(() => { - it("searches GitHub, clones a remote project with progress, and starts its session", async () => { + it("keeps GitHub selection inert and clones only when the session starts", async () => { await prepareProjectUiProof(); const context = await suite.browser.newContext({ locale: "en-US", @@ -50,7 +50,7 @@ suite.define(() => { "worktrees.branches", ], methodResponses: { - "projects.list": { sequence: [{ projects: [] }, { projects: [clonedProject] }] }, + "projects.list": { projects: [] }, "projects.searchRemote": { credential: "missing", projects: [ @@ -89,24 +89,20 @@ suite.define(() => { await place.getByText("GH_TOKEN is not configured; public GitHub results only.").waitFor(); await place.getByRole("button", { name: /openclaw\/openclaw/u }).click(); - const addRequest = await gateway.waitForRequest("projects.add"); - expect(addRequest.params).toEqual({ gitUrl: "https://github.com/openclaw/openclaw.git" }); - await place.getByRole("status").getByText("Cloning project…").waitFor(); - await captureProjectUiProof(page, "project-cloning.png"); - await gateway.resolveDeferred("projects.add", clonedProject); - - await expect.poll(async () => (await gateway.getRequests("projects.list")).length).toBe(2); - await pollLocatorText(trigger.locator(".new-session-page__trigger-label")).toBe("OpenClaw"); - expect(await trigger.getAttribute("data-project-id")).toBe("openclaw"); - await expect - .poll(async () => (await gateway.getRequests("worktrees.branches")).at(-1)?.params) - .toEqual({ - repoRoot: "/state/projects/fingerprint/openclaw", - includeRepositoryStatus: true, - }); + expect(await gateway.getRequests("projects.add")).toHaveLength(0); + await pollLocatorText(trigger.locator(".new-session-page__trigger-label")).toBe( + "openclaw/openclaw", + ); + expect(await trigger.getAttribute("data-project-id")).toBeNull(); await page.locator(".new-session-page__message").fill("inspect the cloned project"); await page.getByRole("button", { name: "Start session" }).click(); + const addRequest = await gateway.waitForRequest("projects.add"); + expect(addRequest.params).toEqual({ gitUrl: "https://github.com/openclaw/openclaw.git" }); + await captureProjectUiProof(page, "project-cloning.png"); + expect(await gateway.getRequests("sessions.create")).toHaveLength(0); + await gateway.resolveDeferred("projects.add", clonedProject); + const create = await gateway.waitForRequest("sessions.create"); expect(create.params).toMatchObject({ agentId: "main", diff --git a/ui/src/pages/new-session/draft-place-browser.test.ts b/ui/src/pages/new-session/draft-place-browser.test.ts index e2a0a955d082..98becd306408 100644 --- a/ui/src/pages/new-session/draft-place-browser.test.ts +++ b/ui/src/pages/new-session/draft-place-browser.test.ts @@ -58,7 +58,6 @@ function createBrowser(request: (method: string) => Promise) { gateway, () => ({ context, - projectId: "", nodes: [], folder: "", execNode: "", @@ -68,7 +67,6 @@ function createBrowser(request: (method: string) => Promise) { requestUpdate: vi.fn(), onProjectMissing: vi.fn(), onSelectProject: vi.fn(), - onApplyFolder: vi.fn(), onApprovedListing: vi.fn(), querySelector: () => null, activeElement: () => null, diff --git a/ui/src/pages/new-session/draft-place-browser.ts b/ui/src/pages/new-session/draft-place-browser.ts index f03b4108b93e..c49ea28a9fc8 100644 --- a/ui/src/pages/new-session/draft-place-browser.ts +++ b/ui/src/pages/new-session/draft-place-browser.ts @@ -4,7 +4,6 @@ import type { FsListDirResult, ProjectRecord, ProjectRecent, - ProjectsAddResult, ProjectsListResult, ProjectsRegisterResult, ProjectsSearchRemoteResult, @@ -16,7 +15,7 @@ import { canCallGatewayMethod, isGatewayMethodAdvertised } from "../../lib/gatew import type { BrowserTarget, DraftNode } from "./discovery.ts"; import type { DraftGatewayState } from "./draft-gateway-state.ts"; import { folderDisplayName, isAbsolutePath, isKnownWorkspacePath } from "./path.ts"; -import { projectCloneInput } from "./project-chip.ts"; +import { projectCloneInput, type DraftRemoteProject } from "./project-chip.ts"; import { recentPlaces, type RecentPlaceSource } from "./recent-places.ts"; const PROJECT_SEARCH_DEBOUNCE_MS = 300; @@ -24,18 +23,21 @@ type DraftPickerKind = "where" | "project" | "detail"; type DraftPlaceBrowserSnapshot = Readonly<{ context: ApplicationContext | undefined; - projectId: string; nodes: readonly DraftNode[]; folder: string; execNode: string; isAdmin: boolean; }>; +type DraftProjectSelection = + | { kind: "local"; id: string } + | { kind: "remote"; project: DraftRemoteProject } + | null; + type DraftPlaceBrowserCallbacks = { requestUpdate: () => void; onProjectMissing: () => void; onSelectProject: (projectId: string) => void; - onApplyFolder: (folder: string, execNode: string, gatewayApproved: boolean) => void; onApprovedListing: (listing: FsListDirResult) => void; querySelector: (selector: string) => Element | null; activeElement: () => Element | null; @@ -45,10 +47,9 @@ type DraftPlaceBrowserCallbacks = { export class DraftPlaceBrowser { private projectsValue: ProjectRecord[] = []; private projectRecentsValue: ProjectRecent[] | undefined; + private projectSelection: DraftProjectSelection = null; private projectQueryValue = ""; private debouncedProjectQuery = ""; - private projectCloneBusyValue = false; - private projectCloneErrorValue: string | null = null; private browserLoadingValue = false; private browserErrorValue: string | null = null; private browserListingValue: FsListDirResult | null = null; @@ -60,7 +61,6 @@ export class DraftPlaceBrowser { // Live head input; absolute paths stay applicable even without fs.listDir. private browserPathDraftValue = ""; private browserRequestToken = 0; - private projectCloneRequestToken = 0; private projectSearchTimer: ReturnType | undefined; private readonly projectsTask: Task; @@ -94,10 +94,7 @@ export class DraftPlaceBrowser { const projects = result.projects ?? []; this.projectsValue = projects; this.projectRecentsValue = result.recents; - if ( - this.read().projectId && - !projects.some((project) => project.id === this.read().projectId) - ) { + if (this.projectId && !projects.some((project) => project.id === this.projectId)) { this.callbacks.onProjectMissing(); } this.callbacks.requestUpdate(); @@ -151,6 +148,14 @@ export class DraftPlaceBrowser { return this.projectRecentsValue; } + get projectId(): string { + return this.projectSelection?.kind === "local" ? this.projectSelection.id : ""; + } + + get remoteProject(): DraftRemoteProject | null { + return this.projectSelection?.kind === "remote" ? this.projectSelection.project : null; + } + get projectQuery(): string { return this.projectQueryValue; } @@ -181,14 +186,6 @@ export class DraftPlaceBrowser { return error instanceof Error ? error.message : String(error); } - get projectCloneBusy(): boolean { - return this.projectCloneBusyValue; - } - - get projectCloneError(): string | null { - return this.projectCloneErrorValue; - } - get browserLoading(): boolean { return this.browserLoadingValue; } @@ -241,8 +238,23 @@ export class DraftPlaceBrowser { ]); } - selectedProject(projectId: string): ProjectRecord | undefined { - return this.projectsValue.find((project) => project.id === projectId); + selectedProject(): ProjectRecord | undefined { + return this.projectsValue.find((project) => project.id === this.projectId); + } + + selectProject(selection: Exclude) { + this.projectSelection = selection; + } + + recordRemoteProjectId(cloneUrl: string, projectId: string) { + const project = this.remoteProject; + if (project?.cloneUrl === cloneUrl) { + this.projectSelection = { kind: "remote", project: { ...project, projectId } }; + } + } + + clearProjectSelection() { + this.projectSelection = null; } resolveProjectRecents(params: { @@ -283,7 +295,6 @@ export class DraftPlaceBrowser { changeProjectQuery(query: string) { this.projectQueryValue = query; - this.projectCloneErrorValue = null; this.clearProjectSearchTimer(); this.debouncedProjectQuery = ""; void this.projectSearchTask.run([null, false, "", this.gateway.connectionEpoch]); @@ -314,71 +325,17 @@ export class DraftPlaceBrowser { this.callbacks.requestUpdate(); } - async addRemoteProject(gitUrl: string) { - const client = this.gateway.client; - const context = this.read().context; - if ( - !client || - !this.gateway.connected || - this.projectCloneBusyValue || - !context || - !canCallGatewayMethod(context.gateway.snapshot, "projects.add", "operator.write") - ) { - return; - } - const requestId = ++this.projectCloneRequestToken; - const connectionEpoch = this.gateway.connectionEpoch; - this.projectCloneBusyValue = true; - this.projectCloneErrorValue = null; - this.callbacks.requestUpdate(); - try { - const project = await client.request( - "projects.add", - { gitUrl }, - { timeoutMs: null }, - ); - if ( - requestId !== this.projectCloneRequestToken || - client !== this.gateway.client || - connectionEpoch !== this.gateway.connectionEpoch - ) { - return; - } - await this.projectsTask.run([client, true, connectionEpoch]); - if ( - requestId !== this.projectCloneRequestToken || - client !== this.gateway.client || - connectionEpoch !== this.gateway.connectionEpoch - ) { - return; - } - this.callbacks.onSelectProject(project.id); - this.close(); - } catch (error) { - if (requestId === this.projectCloneRequestToken && client === this.gateway.client) { - this.projectCloneErrorValue = error instanceof Error ? error.message : String(error); - } - } finally { - if (requestId === this.projectCloneRequestToken) { - this.projectCloneBusyValue = false; - this.callbacks.requestUpdate(); - } - } - } - resetProjectSearch() { this.clearProjectSearchTimer(); - this.projectCloneRequestToken += 1; this.projectQueryValue = ""; this.debouncedProjectQuery = ""; - this.projectCloneBusyValue = false; - this.projectCloneErrorValue = null; this.callbacks.requestUpdate(); } resetProjects() { this.projectsValue = []; this.projectRecentsValue = undefined; + this.clearProjectSelection(); this.resetProjectSearch(); } diff --git a/ui/src/pages/new-session/draft-place-state.ts b/ui/src/pages/new-session/draft-place-state.ts index 08485c63df6b..5f3e6273b7e6 100644 --- a/ui/src/pages/new-session/draft-place-state.ts +++ b/ui/src/pages/new-session/draft-place-state.ts @@ -17,6 +17,7 @@ import { newSessionSearch } from "./location.ts"; import { NewSessionModelControl } from "./model-control.ts"; import { isKnownWorkspacePath } from "./path.ts"; import type { NewSessionWhere } from "./preferences.ts"; +import type { DraftRemoteProject } from "./project-chip.ts"; type DraftPlaceSnapshot = Readonly<{ context: ApplicationContext | undefined; @@ -34,7 +35,6 @@ type DraftPlaceCallbacks = { export class DraftPlaceState { private agentIdValue = ""; private folderValue = ""; - private projectIdValue = ""; private nodesValue: DraftNode[] = []; private execNodeValue = ""; private cloudProfileIdValue = ""; @@ -65,7 +65,8 @@ export class DraftPlaceState { () => ({ execNode: this.execNodeValue, cloudProfileId: this.cloudProfileIdValue, - selectedProject: this.selectedProject(), + selectedProject: this.browser.selectedProject(), + remoteProjectSelected: Boolean(this.browser.remoteProject), folder: this.folderValue, workspace: this.workspacePath(), workspaceGit: this.selectedAgent()?.workspaceGit === true, @@ -94,10 +95,6 @@ export class DraftPlaceState { return this.folderValue; } - get projectId(): string { - return this.projectIdValue; - } - get worktree(): boolean { return this.repositoryState.worktree; } @@ -147,10 +144,6 @@ export class DraftPlaceState { return this.agents().find((agent) => normalizeAgentId(agent.id) === agentId); } - selectedProject() { - return this.browser.selectedProject(this.projectIdValue); - } - execNodes(): DraftNode[] { return this.nodesValue.filter((node) => node.canExec); } @@ -201,8 +194,8 @@ export class DraftPlaceState { } folderSubmissionBlocked(): boolean { - if (this.projectIdValue) { - return !this.selectedProject(); + if (this.browser.projectId || this.browser.remoteProject) { + return !this.browser.remoteProject && !this.browser.selectedProject(); } if (this.restoredFolderValidation !== "none") { return true; @@ -290,7 +283,7 @@ export class DraftPlaceState { resetDraft() { this.agentSelectedByUser = false; this.folderValue = ""; - this.projectIdValue = ""; + this.browser.clearProjectSelection(); this.browser.resetProjectSearch(); this.folderSelectedByUser = false; this.folderGatewayApproved = false; @@ -326,7 +319,6 @@ export class DraftPlaceState { this.agentSelectedByUser = false; this.folderValue = ""; this.browser.resetProjects(); - this.projectIdValue = ""; this.folderSelectedByUser = false; this.preferredWhereRestore = null; this.preferredProjectRestore = ""; @@ -355,7 +347,7 @@ export class DraftPlaceState { } clearProjectSelection() { - this.projectIdValue = ""; + this.browser.clearProjectSelection(); this.repositoryState.load(); this.callbacks.requestUpdate(); } @@ -376,7 +368,7 @@ export class DraftPlaceState { this.folderSelectedByUser = false; this.folderGatewayApproved = false; this.gatewayApprovedWorkspaceRoots = []; - this.projectIdValue = ""; + this.browser.clearProjectSelection(); this.preferredWhereRestore = null; this.preferredProjectRestore = ""; this.whereSelectedByUser = false; @@ -396,7 +388,7 @@ export class DraftPlaceState { return; } this.execNodeValue = execNode; - this.projectIdValue = ""; + this.browser.clearProjectSelection(); this.cancelRestoredFolderValidation(); if (execNode) { this.cloudProfileIdValue = ""; @@ -430,28 +422,43 @@ export class DraftPlaceState { if (snapshot.submitting || snapshot.pendingCloudSessionKey) { return; } - const project = this.browser.selectedProject(projectId); + const project = this.browser.projects.find((candidate) => candidate.id === projectId); if (!project) { return; } + this.selectProject({ kind: "local", id: project.id }); + } + + selectRemoteProject(project: DraftRemoteProject) { + this.selectProject({ kind: "remote", project }); + } + + private selectProject(selection: Parameters[0]) { + const snapshot = this.read(); + if (snapshot.submitting || snapshot.pendingCloudSessionKey) { + return; + } + this.browser.selectProject(selection); this.cancelRestoredFolderValidation(); this.browser.resetProjectSearch(); - this.projectIdValue = project.id; this.execNodeValue = ""; this.callbacks.onError(null); this.folderSelectedByUser = false; this.projectSelectedByUser = true; this.preferredProjectRestore = ""; this.repositoryState.selectWorktree(Boolean(this.cloudProfileIdValue)); - this.persistPreference({ - projectId: project.id, - where: this.cloudProfileIdValue - ? { kind: "cloud", id: this.cloudProfileIdValue } - : { kind: "local" }, - worktree: this.worktree, - worktreeName: "", - }); + if (selection.kind === "local") { + this.persistPreference({ + projectId: selection.id, + where: this.cloudProfileIdValue + ? { kind: "cloud", id: this.cloudProfileIdValue } + : { kind: "local" }, + worktree: this.worktree, + worktreeName: "", + }); + } this.repositoryState.load(); + this.browser.close(); } selectExecNode(execNode: string) { @@ -473,13 +480,13 @@ export class DraftPlaceState { this.folderValue = execNode ? "" : this.workspacePath(); this.folderSelectedByUser = false; this.folderGatewayApproved = false; - this.projectIdValue = ""; + this.browser.clearProjectSelection(); this.projectSelectedByUser = true; } this.repositoryState.selectWorktree(keepWorktree, false); this.persistPreference({ where: execNode ? { kind: "node", id: execNode } : { kind: "local" }, - projectId: this.projectIdValue, + projectId: this.browser.projectId, folder: this.folderValue, worktree: this.worktree, }); @@ -507,7 +514,7 @@ export class DraftPlaceState { this.repositoryState.forceWorktree(true); this.persistPreference({ where: { kind: "cloud", id: profileId }, - projectId: this.projectIdValue, + projectId: this.browser.projectId, worktree: true, }); this.browser.close(); @@ -535,9 +542,9 @@ export class DraftPlaceState { let preferredProject = this.projectSelectedByUser ? "" : this.preferredProjectRestore; if (preferredWhere?.kind !== "node" && preferredProject) { - const project = this.browser.selectedProject(preferredProject); + const project = this.browser.projects.find((candidate) => candidate.id === preferredProject); if (project) { - this.projectIdValue = project.id; + this.browser.selectProject({ kind: "local", id: project.id }); this.execNodeValue = ""; this.folderSelectedByUser = false; this.preferredProjectRestore = ""; @@ -553,7 +560,7 @@ export class DraftPlaceState { const nodeAvailable = this.execNodes().some((node) => node.nodeId === preferredWhere.id); this.execNodeValue = nodeAvailable ? preferredWhere.id : ""; this.cloudProfileIdValue = ""; - this.projectIdValue = ""; + this.browser.clearProjectSelection(); this.repositoryState.forceWorktree(false); this.preferredWhereRestore = null; this.preferredProjectRestore = ""; @@ -562,7 +569,7 @@ export class DraftPlaceState { const profileAvailable = this.gateway.cloudProfiles.some( (profile) => profile.id === preferredWhere.id, ); - const projectReady = !preferredProject || this.projectIdValue === preferredProject; + const projectReady = !preferredProject || this.browser.projectId === preferredProject; if (profileAvailable && projectReady && this.worktreeAvailable()) { this.execNodeValue = ""; this.cloudProfileIdValue = preferredWhere.id; @@ -594,7 +601,7 @@ export class DraftPlaceState { } private usesCustomFolder(): boolean { - if (this.projectIdValue) { + if (this.browser.projectId || this.browser.remoteProject) { return false; } const folder = this.folderValue.trim(); diff --git a/ui/src/pages/new-session/draft-repository-state.ts b/ui/src/pages/new-session/draft-repository-state.ts index 66d796728f08..a28063899aae 100644 --- a/ui/src/pages/new-session/draft-repository-state.ts +++ b/ui/src/pages/new-session/draft-repository-state.ts @@ -10,6 +10,7 @@ type DraftRepositorySnapshot = Readonly<{ execNode: string; cloudProfileId: string; selectedProject: ProjectRecord | undefined; + remoteProjectSelected: boolean; folder: string; workspace: string; workspaceGit: boolean; @@ -173,7 +174,11 @@ export class DraftRepositoryController { const snapshot = this.read(); this.repositoryValue = { kind: "idle" }; this.baseRefValue = ""; - if (snapshot.execNode || (snapshot.selectedProject && !snapshot.selectedProject.repoRoot)) { + if ( + snapshot.remoteProjectSelected || + snapshot.execNode || + (snapshot.selectedProject && !snapshot.selectedProject.repoRoot) + ) { this.preferredWorktreeRestore = false; return; } diff --git a/ui/src/pages/new-session/draft-submission-flow.test.ts b/ui/src/pages/new-session/draft-submission-flow.test.ts index 6dcebdba1fe2..0ab8a6d6f550 100644 --- a/ui/src/pages/new-session/draft-submission-flow.test.ts +++ b/ui/src/pages/new-session/draft-submission-flow.test.ts @@ -20,6 +20,151 @@ afterEach(() => { }); describe("DraftSubmissionFlow", () => { + it("deduplicates remote materialization and preserves the draft when cloning fails", async () => { + let rejectClone!: (error: Error) => void; + const cloneResult = new Promise((_resolve, reject) => { + rejectClone = reject; + }); + const request = vi.fn((method: string) => { + if (method === "projects.add") { + return cloneResult; + } + return Promise.resolve({}); + }); + const client = { recoveryScope: "principal-a", recoveryScopeReady: true, request }; + const context = { + gateway: { + connection: { gatewayUrl: "ws://gateway.example" }, + snapshot: { + phase: "connected", + client, + hello: { + auth: { role: "operator", scopes: ["operator.read", "operator.write"] }, + features: { methods: ["projects.add", "sessions.create"] }, + }, + }, + }, + agents: { + state: { + agentsList: { + defaultId: "main", + agents: [ + { + id: "main", + workspace: "/workspace", + workspaceGit: false, + model: { primary: "openai/gpt-5.6-luna" }, + }, + ], + }, + }, + }, + sessions: { state: { result: null }, createResult: vi.fn() }, + config: { current: {} }, + } as unknown as ApplicationContext; + const host = new ControllerHost(); + const gateway = new DraftGatewayState( + host, + () => ({ + context, + data: undefined, + isConnected: true, + isAdmin: place?.isAdmin() ?? false, + canStartAsDraft: flow?.canStartAsDraft() ?? false, + visibility: flow?.visibility ?? "normal", + cloudProfileId: place?.cloudProfileId ?? "", + pendingCloud: flow?.pendingCloud ?? { sessionKey: "", gatewayUrl: "", recoveryScope: "" }, + agentsHydrated: place?.agentsHydrated ?? false, + }), + { + requestUpdate: vi.fn(), + updateComplete: () => Promise.resolve(), + onInvalidate: vi.fn(), + onVisibilityRetired: () => flow?.setVisibility("normal"), + onCloudProfileCleared: () => place?.clearCloudProfile(), + onCloudState: (error) => flow?.setError(error), + onPendingCloudReset: () => flow?.resetPendingCloudWithoutClearingStorage(), + onRecoveryReady: (gatewayUrl, recoveryScope) => + flow?.restorePendingCloudRecovery(gatewayUrl, recoveryScope), + onAdoptAgentDefaults: () => place?.adoptAgentDefaults(), + }, + ); + const browser = new DraftPlaceBrowser( + host, + gateway, + () => ({ + context, + nodes: place?.nodes ?? [], + folder: place?.folder ?? "", + execNode: place?.execNode ?? "", + isAdmin: place?.isAdmin() ?? false, + }), + { + requestUpdate: vi.fn(), + onProjectMissing: () => place?.clearProjectSelection(), + onSelectProject: (projectId) => place?.selectProjectId(projectId), + onApprovedListing: (listing) => place?.recordGatewayApprovedListing(listing), + querySelector: () => null, + activeElement: () => null, + body: () => null, + }, + ); + const place = new DraftPlaceState( + gateway, + browser, + () => ({ + context, + data: undefined, + submitting: flow?.submitting ?? false, + pendingCloudSessionKey: flow?.pendingCloud.sessionKey ?? "", + }), + { + requestUpdate: vi.fn(), + onError: (error) => flow?.setError(error), + onClearError: (error) => flow?.clearErrorIf(error), + }, + ); + const flow = new DraftSubmissionFlow( + gateway, + place, + () => ({ context, data: undefined, isConnected: true }), + { requestUpdate: vi.fn(), closeTransientUi: vi.fn() }, + ); + gateway.synchronize(context.gateway); + place.setAgentsHydrated(true); + place.adoptAgentDefaults(); + place.selectRemoteProject({ + identity: "openclaw/openclaw", + cloneUrl: "https://github.com/openclaw/openclaw.git", + }); + flow.setMessage("keep this prompt"); + flow.attachmentDraft.replace([ + { + id: "attachment-1", + dataUrl: "data:text/plain;base64,SGk=", + mimeType: "text/plain", + fileName: "note.txt", + }, + ]); + + const first = flow.submit(); + const duplicate = flow.submit(); + await vi.waitFor(() => + expect(request.mock.calls.filter(([method]) => method === "projects.add")).toHaveLength(1), + ); + rejectClone(new Error("clone failed")); + await Promise.all([first, duplicate]); + + expect(flow.error).toBe("clone failed"); + expect(flow.message).toBe("keep this prompt"); + expect(flow.attachmentDraft.attachments).toHaveLength(1); + expect(place.browser.remoteProject).toMatchObject({ + identity: "openclaw/openclaw", + cloneUrl: "https://github.com/openclaw/openclaw.git", + }); + expect(context.sessions.createResult).not.toHaveBeenCalled(); + }); + it("keeps startup progress active through the navigation handoff", async () => { const createResult = vi.fn(async (params: Record) => ({ key: String(params.key), @@ -128,7 +273,6 @@ describe("DraftSubmissionFlow", () => { gateway, () => ({ context, - projectId: place?.projectId ?? "", nodes: place?.nodes ?? [], folder: place?.folder ?? "", execNode: place?.execNode ?? "", @@ -138,8 +282,6 @@ describe("DraftSubmissionFlow", () => { requestUpdate: vi.fn(), onProjectMissing: () => place?.clearProjectSelection(), onSelectProject: (projectId) => place?.selectProjectId(projectId), - onApplyFolder: (folder, execNode, approved) => - place?.applyFolder(folder, execNode, approved), onApprovedListing: (listing) => place?.recordGatewayApprovedListing(listing), querySelector: () => null, activeElement: () => null, diff --git a/ui/src/pages/new-session/draft-submission-flow.ts b/ui/src/pages/new-session/draft-submission-flow.ts index 0fa86b75fcec..fb9104a04cfd 100644 --- a/ui/src/pages/new-session/draft-submission-flow.ts +++ b/ui/src/pages/new-session/draft-submission-flow.ts @@ -1,4 +1,7 @@ -import type { SessionsCatalogStartTerminalResult } from "../../../../packages/gateway-protocol/src/index.js"; +import type { + ProjectsAddResult, + SessionsCatalogStartTerminalResult, +} from "../../../../packages/gateway-protocol/src/index.js"; import { selectApplicationSession } from "../../app/agent-selection.ts"; import type { ApplicationContext, ApplicationNavigationOptions } from "../../app/context.ts"; import { navigateWithRouteTransition } from "../../app/route-transition.ts"; @@ -157,7 +160,7 @@ export class DraftSubmissionFlow { thinkingLevel: this.place.modelControl.thinkingLevel, visibility: options.visibility ?? this.visibilityValue, attachments: options.attachments, - projectId: this.place.projectId, + projectId: this.place.browser.remoteProject?.projectId ?? this.place.browser.projectId, worktree: this.place.worktree, baseRef: this.place.baseRef, worktreeName: this.place.worktreeName, @@ -174,6 +177,13 @@ export class DraftSubmissionFlow { ): SessionMethodAccess { const gateway = this.read().context?.gateway.snapshot; const pendingCloud = Boolean(this.pendingCloud.sessionKey); + const remoteProject = this.place.browser.remoteProject; + if (!pendingCloud && remoteProject && !remoteProject.projectId) { + return readSessionMethodAccess(gateway, { + method: "projects.add", + requiredScope: "operator.write", + }); + } if (!pendingCloud || this.pendingCloud.phase === "creating") { const createAccess = readSessionMethodAccess(gateway, { method: "sessions.create", @@ -413,6 +423,18 @@ export class DraftSubmissionFlow { this.callbacks.closeTransientUi(); this.callbacks.requestUpdate(); try { + const remoteProject = pendingCloud ? null : this.place.browser.remoteProject; + if (remoteProject && !remoteProject.projectId && !this.place.browser.projectId) { + const project = await submissionClient.request( + "projects.add", + { gitUrl: remoteProject.cloneUrl }, + { timeoutMs: null }, + ); + if (requestId !== this.submitRequestToken || this.gateway.client !== submissionClient) { + return; + } + this.place.browser.recordRemoteProjectId(remoteProject.cloneUrl, project.id); + } const cloudProfileId = this.cloudProfileForSubmission(); const draftRetired = this.visibilityValue === "draft" && !this.canStartAsDraft(); const createParams = this.buildDraftSessionCreateParams({ @@ -599,6 +621,10 @@ export class DraftSubmissionFlow { focusComposer: true, }).options, ); + } catch (error) { + if (requestId === this.submitRequestToken && this.gateway.client === submissionClient) { + this.errorValue = error instanceof Error ? error.message : String(error); + } } finally { if (requestId === this.submitRequestToken) { this.submittingValue = false; diff --git a/ui/src/pages/new-session/new-session-page.ts b/ui/src/pages/new-session/new-session-page.ts index 13fff2db4a2f..3d994942122d 100644 --- a/ui/src/pages/new-session/new-session-page.ts +++ b/ui/src/pages/new-session/new-session-page.ts @@ -104,7 +104,6 @@ class NewSessionPage extends OpenClawLightDomElement { this.gateway, () => ({ context: this.context, - projectId: this.place?.projectId ?? "", nodes: this.place?.nodes ?? [], folder: this.place?.folder ?? "", execNode: this.place?.execNode ?? "", @@ -114,8 +113,6 @@ class NewSessionPage extends OpenClawLightDomElement { requestUpdate: () => this.requestUpdate(), onProjectMissing: () => this.place.clearProjectSelection(), onSelectProject: (projectId) => this.place.selectProjectId(projectId), - onApplyFolder: (folder, execNode, gatewayApproved) => - this.place.applyFolder(folder, execNode, gatewayApproved), onApprovedListing: (listing) => this.place.recordGatewayApprovedListing(listing), querySelector: (selector) => this.querySelector(selector), activeElement: () => this.ownerDocument.activeElement, @@ -389,7 +386,8 @@ class NewSessionPage extends OpenClawLightDomElement { const projectState = resolveProjectChip({ folder: this.place.folder, workspace: this.place.workspacePath(), - projectId: this.place.projectId, + projectId: this.browser.projectId, + selectedRemoteProject: this.browser.remoteProject, projects, recents, projectQuery: this.browser.projectQuery, @@ -412,7 +410,7 @@ class NewSessionPage extends OpenClawLightDomElement { onPopoverHide: () => this.browser.onPopoverHide(kind), onPopoverAfterHide: () => this.browser.onPopoverAfterHide(kind), }); - const submitting = this.submission.submitting || this.browser.projectCloneBusy; + const submitting = this.submission.submitting; const pendingCloud = Boolean(this.submission.pendingCloud.sessionKey); return html`${renderWhereChip({ state: whereState, @@ -448,12 +446,11 @@ class NewSessionPage extends OpenClawLightDomElement { "operator.write", ), remoteProjects: this.browser.projectSearchResult?.projects ?? [], + selectedRemoteProject: this.browser.remoteProject, projectSearchCredentialMissing: this.browser.projectSearchResult?.credential === "missing", projectSearchLoading: this.browser.projectSearchLoading, projectSearchError: this.browser.projectSearchError, - projectCloneBusy: this.browser.projectCloneBusy, - projectCloneError: this.browser.projectCloneError, - projectId: this.place.projectId, + projectId: this.browser.projectId, execNodes, gatewayLabel, execNode: this.place.execNode, @@ -470,7 +467,7 @@ class NewSessionPage extends OpenClawLightDomElement { registeringProject: this.browser.browserRegistering, onSelectProject: (projectId) => this.place.selectProjectId(projectId), onProjectQueryInput: (query) => this.browser.changeProjectQuery(query), - onCloneProject: (gitUrl) => void this.browser.addRemoteProject(gitUrl), + onSelectRemoteProject: (project) => this.place.selectRemoteProject(project), onApplyFolder: (folder, execNode) => this.place.applyFolder( folder, diff --git a/ui/src/pages/new-session/path.ts b/ui/src/pages/new-session/path.ts index a9010ad2cbcf..ff3199c9f330 100644 --- a/ui/src/pages/new-session/path.ts +++ b/ui/src/pages/new-session/path.ts @@ -3,6 +3,16 @@ export function folderDisplayName(path: string): string { return path.split(/[\\/]/).findLast((segment) => segment.length > 0) ?? path; } +export function parentFolderDisplayName(path: string): string | undefined { + const trimmed = path.replace(/[\\/]+$/u, ""); + const separator = Math.max(trimmed.lastIndexOf("/"), trimmed.lastIndexOf("\\")); + if (separator < 0) { + return undefined; + } + const parent = separator === 0 ? trimmed.slice(0, 1) : trimmed.slice(0, separator); + return folderDisplayName(parent) || undefined; +} + export function isAbsolutePath(path: string): boolean { return path.startsWith("/") || path.startsWith("\\") || /^[A-Za-z]:[\\/]/.test(path); } diff --git a/ui/src/pages/new-session/project-chip.test.ts b/ui/src/pages/new-session/project-chip.test.ts index dc7a7f6eedb4..a6b4f023d730 100644 --- a/ui/src/pages/new-session/project-chip.test.ts +++ b/ui/src/pages/new-session/project-chip.test.ts @@ -43,6 +43,7 @@ describe("What chip state", () => { folder: "", workspace: "/workspace", projectId: "", + selectedRemoteProject: null, projects, recents: [ { diff --git a/ui/src/pages/new-session/project-chip.ts b/ui/src/pages/new-session/project-chip.ts index 101e998cc3e9..1275121b1967 100644 --- a/ui/src/pages/new-session/project-chip.ts +++ b/ui/src/pages/new-session/project-chip.ts @@ -9,7 +9,7 @@ import { icons } from "../../components/icons.ts"; import { t } from "../../i18n/index.ts"; import { renderSessionMenuItem } from "./cloud-target.ts"; import type { BrowserTarget, DraftNode } from "./discovery.ts"; -import { folderDisplayName } from "./path.ts"; +import { folderDisplayName, parentFolderDisplayName } from "./path.ts"; import { renderPlaceBrowser } from "./place-browser.ts"; import { disambiguate } from "./place-labels.ts"; @@ -22,15 +22,11 @@ export function projectCloneInput(value: string): string | null { return /^(?:https:\/\/|ssh:\/\/git@|git@[^:]+:)/iu.test(trimmed) ? trimmed : null; } -function parentFolderDisplayName(path: string): string | undefined { - const trimmed = path.replace(/[\\/]+$/u, ""); - const separator = Math.max(trimmed.lastIndexOf("/"), trimmed.lastIndexOf("\\")); - if (separator < 0) { - return undefined; - } - const parent = separator === 0 ? trimmed.slice(0, 1) : trimmed.slice(0, separator); - return folderDisplayName(parent) || undefined; -} +export type DraftRemoteProject = Readonly<{ + identity: string; + cloneUrl: string; + projectId?: string; +}>; type ProjectChipState = Readonly<{ mode: "projects" | "node-path"; @@ -44,6 +40,7 @@ export function resolveProjectChip(params: { folder: string; workspace: string; projectId: string; + selectedRemoteProject: DraftRemoteProject | null; projects: readonly ProjectRecord[]; recents: readonly ProjectRecent[]; projectQuery: string; @@ -65,9 +62,11 @@ export function resolveProjectChip(params: { mode, label: selectedProject ? selectedProject.displayName - : folder - ? folderDisplayName(folder) - : folderDisplayName(params.workspace) || t("newSession.folderPlaceholder"), + : params.selectedRemoteProject?.identity + ? params.selectedRemoteProject.identity + : folder + ? folderDisplayName(folder) + : folderDisplayName(params.workspace) || t("newSession.folderPlaceholder"), localProjects, recents: params.execNode ? params.recents.filter( @@ -98,11 +97,10 @@ export function renderProjectChip(params: { projectSearchAvailable: boolean; projectAddAvailable: boolean; remoteProjects: readonly RemoteProject[]; + selectedRemoteProject: DraftRemoteProject | null; projectSearchCredentialMissing: boolean; projectSearchLoading: boolean; projectSearchError: string | null; - projectCloneBusy: boolean; - projectCloneError: string | null; projectId: string; execNodes: readonly DraftNode[]; gatewayLabel: string; @@ -125,7 +123,7 @@ export function renderProjectChip(params: { onPopoverAfterHide: () => void; onSelectProject: (projectId: string) => void; onProjectQueryInput: (query: string) => void; - onCloneProject: (gitUrl: string) => void; + onSelectRemoteProject: (project: DraftRemoteProject) => void; onApplyFolder: (folder: string, execNode: string) => void; onBrowse: (target: BrowserTarget) => void; onBrowserPathDraftChange: (value: string) => void; @@ -255,15 +253,16 @@ export function renderProjectChip(params: { type="search" placeholder=${t("newSession.projectSearchPlaceholder")} .value=${params.projectQuery} - ?disabled=${params.submitting || - params.pendingCloud || - params.projectCloneBusy} + ?disabled=${params.submitting || params.pendingCloud} @input=${(event: Event) => params.onProjectQueryInput((event.target as HTMLInputElement).value)} @keydown=${(event: KeyboardEvent) => { if (event.key === "Enter" && cloneInput && params.projectAddAvailable) { event.preventDefault(); - params.onCloneProject(cloneInput); + params.onSelectRemoteProject({ + identity: cloneInput, + cloneUrl: cloneInput, + }); } }} /> @@ -278,7 +277,7 @@ export function renderProjectChip(params: { title: project.repoRoot, onSelect: () => params.onSelectProject(project.id), }, - params.submitting || params.projectCloneBusy, + params.submitting, ), )} ${cloneInput && params.projectAddAvailable @@ -288,11 +287,14 @@ export function renderProjectChip(params: { label: cloneInput, icon: icons.gitBranch, sub: t("newSession.cloneProject"), - checked: false, - keepOpen: true, - onSelect: () => params.onCloneProject(cloneInput), + checked: params.selectedRemoteProject?.cloneUrl === cloneInput, + onSelect: () => + params.onSelectRemoteProject({ + identity: cloneInput, + cloneUrl: cloneInput, + }), }, - params.submitting || params.projectCloneBusy, + params.submitting, ) : nothing} ${!cloneInput && query.length >= 2 && params.projectSearchAvailable @@ -322,28 +324,20 @@ export function renderProjectChip(params: { label: project.fullName, icon: icons.gitBranch, sub: project.description ?? t("newSession.cloneProject"), - checked: false, + checked: + params.selectedRemoteProject?.cloneUrl === project.cloneUrl, title: project.webUrl, - keepOpen: true, - onSelect: () => params.onCloneProject(project.cloneUrl), + onSelect: () => + params.onSelectRemoteProject({ + identity: project.fullName, + cloneUrl: project.cloneUrl, + }), }, - params.submitting || - params.projectCloneBusy || - !params.projectAddAvailable, + params.submitting || !params.projectAddAvailable, ), )} ` : nothing} - ${params.projectCloneBusy - ? html`
- ${t("newSession.cloningProject")} -
` - : nothing} - ${params.projectCloneError - ? html`` - : nothing} ${params.projects.length === 0 && params.canWrite && !params.isAdmin ? html`
${t("newSession.projectsAdminHint")}