mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-12 21:53:00 -06:00
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
This commit is contained in:
committed by
GitHub
parent
aa35346a8e
commit
89a5507602
@@ -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",
|
||||
|
||||
@@ -58,7 +58,6 @@ function createBrowser(request: (method: string) => Promise<unknown>) {
|
||||
gateway,
|
||||
() => ({
|
||||
context,
|
||||
projectId: "",
|
||||
nodes: [],
|
||||
folder: "",
|
||||
execNode: "",
|
||||
@@ -68,7 +67,6 @@ function createBrowser(request: (method: string) => Promise<unknown>) {
|
||||
requestUpdate: vi.fn(),
|
||||
onProjectMissing: vi.fn(),
|
||||
onSelectProject: vi.fn(),
|
||||
onApplyFolder: vi.fn(),
|
||||
onApprovedListing: vi.fn(),
|
||||
querySelector: () => null,
|
||||
activeElement: () => null,
|
||||
|
||||
@@ -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<typeof globalThis.setTimeout> | undefined;
|
||||
|
||||
private readonly projectsTask: Task<readonly unknown[], ProjectsListResult>;
|
||||
@@ -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<DraftProjectSelection, null>) {
|
||||
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<ProjectsAddResult>(
|
||||
"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();
|
||||
}
|
||||
|
||||
|
||||
@@ -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<DraftPlaceBrowser["selectProject"]>[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();
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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<never>((_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<string, unknown>) => ({
|
||||
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,
|
||||
|
||||
@@ -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<ProjectsAddResult>(
|
||||
"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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -43,6 +43,7 @@ describe("What chip state", () => {
|
||||
folder: "",
|
||||
workspace: "/workspace",
|
||||
projectId: "",
|
||||
selectedRemoteProject: null,
|
||||
projects,
|
||||
recents: [
|
||||
{
|
||||
|
||||
@@ -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`<div class="new-session-page__project-status" role="status">
|
||||
${t("newSession.cloningProject")}
|
||||
</div>`
|
||||
: nothing}
|
||||
${params.projectCloneError
|
||||
? html`<div class="new-session-page__project-error" role="alert">
|
||||
${params.projectCloneError}
|
||||
</div>`
|
||||
: nothing}
|
||||
${params.projects.length === 0 && params.canWrite && !params.isAdmin
|
||||
? html`<div class="new-session-page__menu-note">
|
||||
${t("newSession.projectsAdminHint")}
|
||||
|
||||
Reference in New Issue
Block a user