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();
|
const suite = createNewSessionPageE2eSuite();
|
||||||
|
|
||||||
suite.define(() => {
|
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();
|
await prepareProjectUiProof();
|
||||||
const context = await suite.browser.newContext({
|
const context = await suite.browser.newContext({
|
||||||
locale: "en-US",
|
locale: "en-US",
|
||||||
@@ -50,7 +50,7 @@ suite.define(() => {
|
|||||||
"worktrees.branches",
|
"worktrees.branches",
|
||||||
],
|
],
|
||||||
methodResponses: {
|
methodResponses: {
|
||||||
"projects.list": { sequence: [{ projects: [] }, { projects: [clonedProject] }] },
|
"projects.list": { projects: [] },
|
||||||
"projects.searchRemote": {
|
"projects.searchRemote": {
|
||||||
credential: "missing",
|
credential: "missing",
|
||||||
projects: [
|
projects: [
|
||||||
@@ -89,24 +89,20 @@ suite.define(() => {
|
|||||||
await place.getByText("GH_TOKEN is not configured; public GitHub results only.").waitFor();
|
await place.getByText("GH_TOKEN is not configured; public GitHub results only.").waitFor();
|
||||||
await place.getByRole("button", { name: /openclaw\/openclaw/u }).click();
|
await place.getByRole("button", { name: /openclaw\/openclaw/u }).click();
|
||||||
|
|
||||||
const addRequest = await gateway.waitForRequest("projects.add");
|
expect(await gateway.getRequests("projects.add")).toHaveLength(0);
|
||||||
expect(addRequest.params).toEqual({ gitUrl: "https://github.com/openclaw/openclaw.git" });
|
await pollLocatorText(trigger.locator(".new-session-page__trigger-label")).toBe(
|
||||||
await place.getByRole("status").getByText("Cloning project…").waitFor();
|
"openclaw/openclaw",
|
||||||
await captureProjectUiProof(page, "project-cloning.png");
|
);
|
||||||
await gateway.resolveDeferred("projects.add", clonedProject);
|
expect(await trigger.getAttribute("data-project-id")).toBeNull();
|
||||||
|
|
||||||
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,
|
|
||||||
});
|
|
||||||
|
|
||||||
await page.locator(".new-session-page__message").fill("inspect the cloned project");
|
await page.locator(".new-session-page__message").fill("inspect the cloned project");
|
||||||
await page.getByRole("button", { name: "Start session" }).click();
|
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");
|
const create = await gateway.waitForRequest("sessions.create");
|
||||||
expect(create.params).toMatchObject({
|
expect(create.params).toMatchObject({
|
||||||
agentId: "main",
|
agentId: "main",
|
||||||
|
|||||||
@@ -58,7 +58,6 @@ function createBrowser(request: (method: string) => Promise<unknown>) {
|
|||||||
gateway,
|
gateway,
|
||||||
() => ({
|
() => ({
|
||||||
context,
|
context,
|
||||||
projectId: "",
|
|
||||||
nodes: [],
|
nodes: [],
|
||||||
folder: "",
|
folder: "",
|
||||||
execNode: "",
|
execNode: "",
|
||||||
@@ -68,7 +67,6 @@ function createBrowser(request: (method: string) => Promise<unknown>) {
|
|||||||
requestUpdate: vi.fn(),
|
requestUpdate: vi.fn(),
|
||||||
onProjectMissing: vi.fn(),
|
onProjectMissing: vi.fn(),
|
||||||
onSelectProject: vi.fn(),
|
onSelectProject: vi.fn(),
|
||||||
onApplyFolder: vi.fn(),
|
|
||||||
onApprovedListing: vi.fn(),
|
onApprovedListing: vi.fn(),
|
||||||
querySelector: () => null,
|
querySelector: () => null,
|
||||||
activeElement: () => null,
|
activeElement: () => null,
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ import type {
|
|||||||
FsListDirResult,
|
FsListDirResult,
|
||||||
ProjectRecord,
|
ProjectRecord,
|
||||||
ProjectRecent,
|
ProjectRecent,
|
||||||
ProjectsAddResult,
|
|
||||||
ProjectsListResult,
|
ProjectsListResult,
|
||||||
ProjectsRegisterResult,
|
ProjectsRegisterResult,
|
||||||
ProjectsSearchRemoteResult,
|
ProjectsSearchRemoteResult,
|
||||||
@@ -16,7 +15,7 @@ import { canCallGatewayMethod, isGatewayMethodAdvertised } from "../../lib/gatew
|
|||||||
import type { BrowserTarget, DraftNode } from "./discovery.ts";
|
import type { BrowserTarget, DraftNode } from "./discovery.ts";
|
||||||
import type { DraftGatewayState } from "./draft-gateway-state.ts";
|
import type { DraftGatewayState } from "./draft-gateway-state.ts";
|
||||||
import { folderDisplayName, isAbsolutePath, isKnownWorkspacePath } from "./path.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";
|
import { recentPlaces, type RecentPlaceSource } from "./recent-places.ts";
|
||||||
|
|
||||||
const PROJECT_SEARCH_DEBOUNCE_MS = 300;
|
const PROJECT_SEARCH_DEBOUNCE_MS = 300;
|
||||||
@@ -24,18 +23,21 @@ type DraftPickerKind = "where" | "project" | "detail";
|
|||||||
|
|
||||||
type DraftPlaceBrowserSnapshot = Readonly<{
|
type DraftPlaceBrowserSnapshot = Readonly<{
|
||||||
context: ApplicationContext | undefined;
|
context: ApplicationContext | undefined;
|
||||||
projectId: string;
|
|
||||||
nodes: readonly DraftNode[];
|
nodes: readonly DraftNode[];
|
||||||
folder: string;
|
folder: string;
|
||||||
execNode: string;
|
execNode: string;
|
||||||
isAdmin: boolean;
|
isAdmin: boolean;
|
||||||
}>;
|
}>;
|
||||||
|
|
||||||
|
type DraftProjectSelection =
|
||||||
|
| { kind: "local"; id: string }
|
||||||
|
| { kind: "remote"; project: DraftRemoteProject }
|
||||||
|
| null;
|
||||||
|
|
||||||
type DraftPlaceBrowserCallbacks = {
|
type DraftPlaceBrowserCallbacks = {
|
||||||
requestUpdate: () => void;
|
requestUpdate: () => void;
|
||||||
onProjectMissing: () => void;
|
onProjectMissing: () => void;
|
||||||
onSelectProject: (projectId: string) => void;
|
onSelectProject: (projectId: string) => void;
|
||||||
onApplyFolder: (folder: string, execNode: string, gatewayApproved: boolean) => void;
|
|
||||||
onApprovedListing: (listing: FsListDirResult) => void;
|
onApprovedListing: (listing: FsListDirResult) => void;
|
||||||
querySelector: (selector: string) => Element | null;
|
querySelector: (selector: string) => Element | null;
|
||||||
activeElement: () => Element | null;
|
activeElement: () => Element | null;
|
||||||
@@ -45,10 +47,9 @@ type DraftPlaceBrowserCallbacks = {
|
|||||||
export class DraftPlaceBrowser {
|
export class DraftPlaceBrowser {
|
||||||
private projectsValue: ProjectRecord[] = [];
|
private projectsValue: ProjectRecord[] = [];
|
||||||
private projectRecentsValue: ProjectRecent[] | undefined;
|
private projectRecentsValue: ProjectRecent[] | undefined;
|
||||||
|
private projectSelection: DraftProjectSelection = null;
|
||||||
private projectQueryValue = "";
|
private projectQueryValue = "";
|
||||||
private debouncedProjectQuery = "";
|
private debouncedProjectQuery = "";
|
||||||
private projectCloneBusyValue = false;
|
|
||||||
private projectCloneErrorValue: string | null = null;
|
|
||||||
private browserLoadingValue = false;
|
private browserLoadingValue = false;
|
||||||
private browserErrorValue: string | null = null;
|
private browserErrorValue: string | null = null;
|
||||||
private browserListingValue: FsListDirResult | 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.
|
// Live head input; absolute paths stay applicable even without fs.listDir.
|
||||||
private browserPathDraftValue = "";
|
private browserPathDraftValue = "";
|
||||||
private browserRequestToken = 0;
|
private browserRequestToken = 0;
|
||||||
private projectCloneRequestToken = 0;
|
|
||||||
private projectSearchTimer: ReturnType<typeof globalThis.setTimeout> | undefined;
|
private projectSearchTimer: ReturnType<typeof globalThis.setTimeout> | undefined;
|
||||||
|
|
||||||
private readonly projectsTask: Task<readonly unknown[], ProjectsListResult>;
|
private readonly projectsTask: Task<readonly unknown[], ProjectsListResult>;
|
||||||
@@ -94,10 +94,7 @@ export class DraftPlaceBrowser {
|
|||||||
const projects = result.projects ?? [];
|
const projects = result.projects ?? [];
|
||||||
this.projectsValue = projects;
|
this.projectsValue = projects;
|
||||||
this.projectRecentsValue = result.recents;
|
this.projectRecentsValue = result.recents;
|
||||||
if (
|
if (this.projectId && !projects.some((project) => project.id === this.projectId)) {
|
||||||
this.read().projectId &&
|
|
||||||
!projects.some((project) => project.id === this.read().projectId)
|
|
||||||
) {
|
|
||||||
this.callbacks.onProjectMissing();
|
this.callbacks.onProjectMissing();
|
||||||
}
|
}
|
||||||
this.callbacks.requestUpdate();
|
this.callbacks.requestUpdate();
|
||||||
@@ -151,6 +148,14 @@ export class DraftPlaceBrowser {
|
|||||||
return this.projectRecentsValue;
|
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 {
|
get projectQuery(): string {
|
||||||
return this.projectQueryValue;
|
return this.projectQueryValue;
|
||||||
}
|
}
|
||||||
@@ -181,14 +186,6 @@ export class DraftPlaceBrowser {
|
|||||||
return error instanceof Error ? error.message : String(error);
|
return error instanceof Error ? error.message : String(error);
|
||||||
}
|
}
|
||||||
|
|
||||||
get projectCloneBusy(): boolean {
|
|
||||||
return this.projectCloneBusyValue;
|
|
||||||
}
|
|
||||||
|
|
||||||
get projectCloneError(): string | null {
|
|
||||||
return this.projectCloneErrorValue;
|
|
||||||
}
|
|
||||||
|
|
||||||
get browserLoading(): boolean {
|
get browserLoading(): boolean {
|
||||||
return this.browserLoadingValue;
|
return this.browserLoadingValue;
|
||||||
}
|
}
|
||||||
@@ -241,8 +238,23 @@ export class DraftPlaceBrowser {
|
|||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
selectedProject(projectId: string): ProjectRecord | undefined {
|
selectedProject(): ProjectRecord | undefined {
|
||||||
return this.projectsValue.find((project) => project.id === projectId);
|
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: {
|
resolveProjectRecents(params: {
|
||||||
@@ -283,7 +295,6 @@ export class DraftPlaceBrowser {
|
|||||||
|
|
||||||
changeProjectQuery(query: string) {
|
changeProjectQuery(query: string) {
|
||||||
this.projectQueryValue = query;
|
this.projectQueryValue = query;
|
||||||
this.projectCloneErrorValue = null;
|
|
||||||
this.clearProjectSearchTimer();
|
this.clearProjectSearchTimer();
|
||||||
this.debouncedProjectQuery = "";
|
this.debouncedProjectQuery = "";
|
||||||
void this.projectSearchTask.run([null, false, "", this.gateway.connectionEpoch]);
|
void this.projectSearchTask.run([null, false, "", this.gateway.connectionEpoch]);
|
||||||
@@ -314,71 +325,17 @@ export class DraftPlaceBrowser {
|
|||||||
this.callbacks.requestUpdate();
|
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() {
|
resetProjectSearch() {
|
||||||
this.clearProjectSearchTimer();
|
this.clearProjectSearchTimer();
|
||||||
this.projectCloneRequestToken += 1;
|
|
||||||
this.projectQueryValue = "";
|
this.projectQueryValue = "";
|
||||||
this.debouncedProjectQuery = "";
|
this.debouncedProjectQuery = "";
|
||||||
this.projectCloneBusyValue = false;
|
|
||||||
this.projectCloneErrorValue = null;
|
|
||||||
this.callbacks.requestUpdate();
|
this.callbacks.requestUpdate();
|
||||||
}
|
}
|
||||||
|
|
||||||
resetProjects() {
|
resetProjects() {
|
||||||
this.projectsValue = [];
|
this.projectsValue = [];
|
||||||
this.projectRecentsValue = undefined;
|
this.projectRecentsValue = undefined;
|
||||||
|
this.clearProjectSelection();
|
||||||
this.resetProjectSearch();
|
this.resetProjectSearch();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ import { newSessionSearch } from "./location.ts";
|
|||||||
import { NewSessionModelControl } from "./model-control.ts";
|
import { NewSessionModelControl } from "./model-control.ts";
|
||||||
import { isKnownWorkspacePath } from "./path.ts";
|
import { isKnownWorkspacePath } from "./path.ts";
|
||||||
import type { NewSessionWhere } from "./preferences.ts";
|
import type { NewSessionWhere } from "./preferences.ts";
|
||||||
|
import type { DraftRemoteProject } from "./project-chip.ts";
|
||||||
|
|
||||||
type DraftPlaceSnapshot = Readonly<{
|
type DraftPlaceSnapshot = Readonly<{
|
||||||
context: ApplicationContext | undefined;
|
context: ApplicationContext | undefined;
|
||||||
@@ -34,7 +35,6 @@ type DraftPlaceCallbacks = {
|
|||||||
export class DraftPlaceState {
|
export class DraftPlaceState {
|
||||||
private agentIdValue = "";
|
private agentIdValue = "";
|
||||||
private folderValue = "";
|
private folderValue = "";
|
||||||
private projectIdValue = "";
|
|
||||||
private nodesValue: DraftNode[] = [];
|
private nodesValue: DraftNode[] = [];
|
||||||
private execNodeValue = "";
|
private execNodeValue = "";
|
||||||
private cloudProfileIdValue = "";
|
private cloudProfileIdValue = "";
|
||||||
@@ -65,7 +65,8 @@ export class DraftPlaceState {
|
|||||||
() => ({
|
() => ({
|
||||||
execNode: this.execNodeValue,
|
execNode: this.execNodeValue,
|
||||||
cloudProfileId: this.cloudProfileIdValue,
|
cloudProfileId: this.cloudProfileIdValue,
|
||||||
selectedProject: this.selectedProject(),
|
selectedProject: this.browser.selectedProject(),
|
||||||
|
remoteProjectSelected: Boolean(this.browser.remoteProject),
|
||||||
folder: this.folderValue,
|
folder: this.folderValue,
|
||||||
workspace: this.workspacePath(),
|
workspace: this.workspacePath(),
|
||||||
workspaceGit: this.selectedAgent()?.workspaceGit === true,
|
workspaceGit: this.selectedAgent()?.workspaceGit === true,
|
||||||
@@ -94,10 +95,6 @@ export class DraftPlaceState {
|
|||||||
return this.folderValue;
|
return this.folderValue;
|
||||||
}
|
}
|
||||||
|
|
||||||
get projectId(): string {
|
|
||||||
return this.projectIdValue;
|
|
||||||
}
|
|
||||||
|
|
||||||
get worktree(): boolean {
|
get worktree(): boolean {
|
||||||
return this.repositoryState.worktree;
|
return this.repositoryState.worktree;
|
||||||
}
|
}
|
||||||
@@ -147,10 +144,6 @@ export class DraftPlaceState {
|
|||||||
return this.agents().find((agent) => normalizeAgentId(agent.id) === agentId);
|
return this.agents().find((agent) => normalizeAgentId(agent.id) === agentId);
|
||||||
}
|
}
|
||||||
|
|
||||||
selectedProject() {
|
|
||||||
return this.browser.selectedProject(this.projectIdValue);
|
|
||||||
}
|
|
||||||
|
|
||||||
execNodes(): DraftNode[] {
|
execNodes(): DraftNode[] {
|
||||||
return this.nodesValue.filter((node) => node.canExec);
|
return this.nodesValue.filter((node) => node.canExec);
|
||||||
}
|
}
|
||||||
@@ -201,8 +194,8 @@ export class DraftPlaceState {
|
|||||||
}
|
}
|
||||||
|
|
||||||
folderSubmissionBlocked(): boolean {
|
folderSubmissionBlocked(): boolean {
|
||||||
if (this.projectIdValue) {
|
if (this.browser.projectId || this.browser.remoteProject) {
|
||||||
return !this.selectedProject();
|
return !this.browser.remoteProject && !this.browser.selectedProject();
|
||||||
}
|
}
|
||||||
if (this.restoredFolderValidation !== "none") {
|
if (this.restoredFolderValidation !== "none") {
|
||||||
return true;
|
return true;
|
||||||
@@ -290,7 +283,7 @@ export class DraftPlaceState {
|
|||||||
resetDraft() {
|
resetDraft() {
|
||||||
this.agentSelectedByUser = false;
|
this.agentSelectedByUser = false;
|
||||||
this.folderValue = "";
|
this.folderValue = "";
|
||||||
this.projectIdValue = "";
|
this.browser.clearProjectSelection();
|
||||||
this.browser.resetProjectSearch();
|
this.browser.resetProjectSearch();
|
||||||
this.folderSelectedByUser = false;
|
this.folderSelectedByUser = false;
|
||||||
this.folderGatewayApproved = false;
|
this.folderGatewayApproved = false;
|
||||||
@@ -326,7 +319,6 @@ export class DraftPlaceState {
|
|||||||
this.agentSelectedByUser = false;
|
this.agentSelectedByUser = false;
|
||||||
this.folderValue = "";
|
this.folderValue = "";
|
||||||
this.browser.resetProjects();
|
this.browser.resetProjects();
|
||||||
this.projectIdValue = "";
|
|
||||||
this.folderSelectedByUser = false;
|
this.folderSelectedByUser = false;
|
||||||
this.preferredWhereRestore = null;
|
this.preferredWhereRestore = null;
|
||||||
this.preferredProjectRestore = "";
|
this.preferredProjectRestore = "";
|
||||||
@@ -355,7 +347,7 @@ export class DraftPlaceState {
|
|||||||
}
|
}
|
||||||
|
|
||||||
clearProjectSelection() {
|
clearProjectSelection() {
|
||||||
this.projectIdValue = "";
|
this.browser.clearProjectSelection();
|
||||||
this.repositoryState.load();
|
this.repositoryState.load();
|
||||||
this.callbacks.requestUpdate();
|
this.callbacks.requestUpdate();
|
||||||
}
|
}
|
||||||
@@ -376,7 +368,7 @@ export class DraftPlaceState {
|
|||||||
this.folderSelectedByUser = false;
|
this.folderSelectedByUser = false;
|
||||||
this.folderGatewayApproved = false;
|
this.folderGatewayApproved = false;
|
||||||
this.gatewayApprovedWorkspaceRoots = [];
|
this.gatewayApprovedWorkspaceRoots = [];
|
||||||
this.projectIdValue = "";
|
this.browser.clearProjectSelection();
|
||||||
this.preferredWhereRestore = null;
|
this.preferredWhereRestore = null;
|
||||||
this.preferredProjectRestore = "";
|
this.preferredProjectRestore = "";
|
||||||
this.whereSelectedByUser = false;
|
this.whereSelectedByUser = false;
|
||||||
@@ -396,7 +388,7 @@ export class DraftPlaceState {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
this.execNodeValue = execNode;
|
this.execNodeValue = execNode;
|
||||||
this.projectIdValue = "";
|
this.browser.clearProjectSelection();
|
||||||
this.cancelRestoredFolderValidation();
|
this.cancelRestoredFolderValidation();
|
||||||
if (execNode) {
|
if (execNode) {
|
||||||
this.cloudProfileIdValue = "";
|
this.cloudProfileIdValue = "";
|
||||||
@@ -430,28 +422,43 @@ export class DraftPlaceState {
|
|||||||
if (snapshot.submitting || snapshot.pendingCloudSessionKey) {
|
if (snapshot.submitting || snapshot.pendingCloudSessionKey) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const project = this.browser.selectedProject(projectId);
|
const project = this.browser.projects.find((candidate) => candidate.id === projectId);
|
||||||
if (!project) {
|
if (!project) {
|
||||||
return;
|
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.cancelRestoredFolderValidation();
|
||||||
this.browser.resetProjectSearch();
|
this.browser.resetProjectSearch();
|
||||||
this.projectIdValue = project.id;
|
|
||||||
this.execNodeValue = "";
|
this.execNodeValue = "";
|
||||||
this.callbacks.onError(null);
|
this.callbacks.onError(null);
|
||||||
this.folderSelectedByUser = false;
|
this.folderSelectedByUser = false;
|
||||||
this.projectSelectedByUser = true;
|
this.projectSelectedByUser = true;
|
||||||
this.preferredProjectRestore = "";
|
this.preferredProjectRestore = "";
|
||||||
this.repositoryState.selectWorktree(Boolean(this.cloudProfileIdValue));
|
this.repositoryState.selectWorktree(Boolean(this.cloudProfileIdValue));
|
||||||
this.persistPreference({
|
if (selection.kind === "local") {
|
||||||
projectId: project.id,
|
this.persistPreference({
|
||||||
where: this.cloudProfileIdValue
|
projectId: selection.id,
|
||||||
? { kind: "cloud", id: this.cloudProfileIdValue }
|
where: this.cloudProfileIdValue
|
||||||
: { kind: "local" },
|
? { kind: "cloud", id: this.cloudProfileIdValue }
|
||||||
worktree: this.worktree,
|
: { kind: "local" },
|
||||||
worktreeName: "",
|
worktree: this.worktree,
|
||||||
});
|
worktreeName: "",
|
||||||
|
});
|
||||||
|
}
|
||||||
this.repositoryState.load();
|
this.repositoryState.load();
|
||||||
|
this.browser.close();
|
||||||
}
|
}
|
||||||
|
|
||||||
selectExecNode(execNode: string) {
|
selectExecNode(execNode: string) {
|
||||||
@@ -473,13 +480,13 @@ export class DraftPlaceState {
|
|||||||
this.folderValue = execNode ? "" : this.workspacePath();
|
this.folderValue = execNode ? "" : this.workspacePath();
|
||||||
this.folderSelectedByUser = false;
|
this.folderSelectedByUser = false;
|
||||||
this.folderGatewayApproved = false;
|
this.folderGatewayApproved = false;
|
||||||
this.projectIdValue = "";
|
this.browser.clearProjectSelection();
|
||||||
this.projectSelectedByUser = true;
|
this.projectSelectedByUser = true;
|
||||||
}
|
}
|
||||||
this.repositoryState.selectWorktree(keepWorktree, false);
|
this.repositoryState.selectWorktree(keepWorktree, false);
|
||||||
this.persistPreference({
|
this.persistPreference({
|
||||||
where: execNode ? { kind: "node", id: execNode } : { kind: "local" },
|
where: execNode ? { kind: "node", id: execNode } : { kind: "local" },
|
||||||
projectId: this.projectIdValue,
|
projectId: this.browser.projectId,
|
||||||
folder: this.folderValue,
|
folder: this.folderValue,
|
||||||
worktree: this.worktree,
|
worktree: this.worktree,
|
||||||
});
|
});
|
||||||
@@ -507,7 +514,7 @@ export class DraftPlaceState {
|
|||||||
this.repositoryState.forceWorktree(true);
|
this.repositoryState.forceWorktree(true);
|
||||||
this.persistPreference({
|
this.persistPreference({
|
||||||
where: { kind: "cloud", id: profileId },
|
where: { kind: "cloud", id: profileId },
|
||||||
projectId: this.projectIdValue,
|
projectId: this.browser.projectId,
|
||||||
worktree: true,
|
worktree: true,
|
||||||
});
|
});
|
||||||
this.browser.close();
|
this.browser.close();
|
||||||
@@ -535,9 +542,9 @@ export class DraftPlaceState {
|
|||||||
let preferredProject = this.projectSelectedByUser ? "" : this.preferredProjectRestore;
|
let preferredProject = this.projectSelectedByUser ? "" : this.preferredProjectRestore;
|
||||||
|
|
||||||
if (preferredWhere?.kind !== "node" && preferredProject) {
|
if (preferredWhere?.kind !== "node" && preferredProject) {
|
||||||
const project = this.browser.selectedProject(preferredProject);
|
const project = this.browser.projects.find((candidate) => candidate.id === preferredProject);
|
||||||
if (project) {
|
if (project) {
|
||||||
this.projectIdValue = project.id;
|
this.browser.selectProject({ kind: "local", id: project.id });
|
||||||
this.execNodeValue = "";
|
this.execNodeValue = "";
|
||||||
this.folderSelectedByUser = false;
|
this.folderSelectedByUser = false;
|
||||||
this.preferredProjectRestore = "";
|
this.preferredProjectRestore = "";
|
||||||
@@ -553,7 +560,7 @@ export class DraftPlaceState {
|
|||||||
const nodeAvailable = this.execNodes().some((node) => node.nodeId === preferredWhere.id);
|
const nodeAvailable = this.execNodes().some((node) => node.nodeId === preferredWhere.id);
|
||||||
this.execNodeValue = nodeAvailable ? preferredWhere.id : "";
|
this.execNodeValue = nodeAvailable ? preferredWhere.id : "";
|
||||||
this.cloudProfileIdValue = "";
|
this.cloudProfileIdValue = "";
|
||||||
this.projectIdValue = "";
|
this.browser.clearProjectSelection();
|
||||||
this.repositoryState.forceWorktree(false);
|
this.repositoryState.forceWorktree(false);
|
||||||
this.preferredWhereRestore = null;
|
this.preferredWhereRestore = null;
|
||||||
this.preferredProjectRestore = "";
|
this.preferredProjectRestore = "";
|
||||||
@@ -562,7 +569,7 @@ export class DraftPlaceState {
|
|||||||
const profileAvailable = this.gateway.cloudProfiles.some(
|
const profileAvailable = this.gateway.cloudProfiles.some(
|
||||||
(profile) => profile.id === preferredWhere.id,
|
(profile) => profile.id === preferredWhere.id,
|
||||||
);
|
);
|
||||||
const projectReady = !preferredProject || this.projectIdValue === preferredProject;
|
const projectReady = !preferredProject || this.browser.projectId === preferredProject;
|
||||||
if (profileAvailable && projectReady && this.worktreeAvailable()) {
|
if (profileAvailable && projectReady && this.worktreeAvailable()) {
|
||||||
this.execNodeValue = "";
|
this.execNodeValue = "";
|
||||||
this.cloudProfileIdValue = preferredWhere.id;
|
this.cloudProfileIdValue = preferredWhere.id;
|
||||||
@@ -594,7 +601,7 @@ export class DraftPlaceState {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private usesCustomFolder(): boolean {
|
private usesCustomFolder(): boolean {
|
||||||
if (this.projectIdValue) {
|
if (this.browser.projectId || this.browser.remoteProject) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
const folder = this.folderValue.trim();
|
const folder = this.folderValue.trim();
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ type DraftRepositorySnapshot = Readonly<{
|
|||||||
execNode: string;
|
execNode: string;
|
||||||
cloudProfileId: string;
|
cloudProfileId: string;
|
||||||
selectedProject: ProjectRecord | undefined;
|
selectedProject: ProjectRecord | undefined;
|
||||||
|
remoteProjectSelected: boolean;
|
||||||
folder: string;
|
folder: string;
|
||||||
workspace: string;
|
workspace: string;
|
||||||
workspaceGit: boolean;
|
workspaceGit: boolean;
|
||||||
@@ -173,7 +174,11 @@ export class DraftRepositoryController {
|
|||||||
const snapshot = this.read();
|
const snapshot = this.read();
|
||||||
this.repositoryValue = { kind: "idle" };
|
this.repositoryValue = { kind: "idle" };
|
||||||
this.baseRefValue = "";
|
this.baseRefValue = "";
|
||||||
if (snapshot.execNode || (snapshot.selectedProject && !snapshot.selectedProject.repoRoot)) {
|
if (
|
||||||
|
snapshot.remoteProjectSelected ||
|
||||||
|
snapshot.execNode ||
|
||||||
|
(snapshot.selectedProject && !snapshot.selectedProject.repoRoot)
|
||||||
|
) {
|
||||||
this.preferredWorktreeRestore = false;
|
this.preferredWorktreeRestore = false;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -20,6 +20,151 @@ afterEach(() => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe("DraftSubmissionFlow", () => {
|
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 () => {
|
it("keeps startup progress active through the navigation handoff", async () => {
|
||||||
const createResult = vi.fn(async (params: Record<string, unknown>) => ({
|
const createResult = vi.fn(async (params: Record<string, unknown>) => ({
|
||||||
key: String(params.key),
|
key: String(params.key),
|
||||||
@@ -128,7 +273,6 @@ describe("DraftSubmissionFlow", () => {
|
|||||||
gateway,
|
gateway,
|
||||||
() => ({
|
() => ({
|
||||||
context,
|
context,
|
||||||
projectId: place?.projectId ?? "",
|
|
||||||
nodes: place?.nodes ?? [],
|
nodes: place?.nodes ?? [],
|
||||||
folder: place?.folder ?? "",
|
folder: place?.folder ?? "",
|
||||||
execNode: place?.execNode ?? "",
|
execNode: place?.execNode ?? "",
|
||||||
@@ -138,8 +282,6 @@ describe("DraftSubmissionFlow", () => {
|
|||||||
requestUpdate: vi.fn(),
|
requestUpdate: vi.fn(),
|
||||||
onProjectMissing: () => place?.clearProjectSelection(),
|
onProjectMissing: () => place?.clearProjectSelection(),
|
||||||
onSelectProject: (projectId) => place?.selectProjectId(projectId),
|
onSelectProject: (projectId) => place?.selectProjectId(projectId),
|
||||||
onApplyFolder: (folder, execNode, approved) =>
|
|
||||||
place?.applyFolder(folder, execNode, approved),
|
|
||||||
onApprovedListing: (listing) => place?.recordGatewayApprovedListing(listing),
|
onApprovedListing: (listing) => place?.recordGatewayApprovedListing(listing),
|
||||||
querySelector: () => null,
|
querySelector: () => null,
|
||||||
activeElement: () => 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 { selectApplicationSession } from "../../app/agent-selection.ts";
|
||||||
import type { ApplicationContext, ApplicationNavigationOptions } from "../../app/context.ts";
|
import type { ApplicationContext, ApplicationNavigationOptions } from "../../app/context.ts";
|
||||||
import { navigateWithRouteTransition } from "../../app/route-transition.ts";
|
import { navigateWithRouteTransition } from "../../app/route-transition.ts";
|
||||||
@@ -157,7 +160,7 @@ export class DraftSubmissionFlow {
|
|||||||
thinkingLevel: this.place.modelControl.thinkingLevel,
|
thinkingLevel: this.place.modelControl.thinkingLevel,
|
||||||
visibility: options.visibility ?? this.visibilityValue,
|
visibility: options.visibility ?? this.visibilityValue,
|
||||||
attachments: options.attachments,
|
attachments: options.attachments,
|
||||||
projectId: this.place.projectId,
|
projectId: this.place.browser.remoteProject?.projectId ?? this.place.browser.projectId,
|
||||||
worktree: this.place.worktree,
|
worktree: this.place.worktree,
|
||||||
baseRef: this.place.baseRef,
|
baseRef: this.place.baseRef,
|
||||||
worktreeName: this.place.worktreeName,
|
worktreeName: this.place.worktreeName,
|
||||||
@@ -174,6 +177,13 @@ export class DraftSubmissionFlow {
|
|||||||
): SessionMethodAccess {
|
): SessionMethodAccess {
|
||||||
const gateway = this.read().context?.gateway.snapshot;
|
const gateway = this.read().context?.gateway.snapshot;
|
||||||
const pendingCloud = Boolean(this.pendingCloud.sessionKey);
|
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") {
|
if (!pendingCloud || this.pendingCloud.phase === "creating") {
|
||||||
const createAccess = readSessionMethodAccess(gateway, {
|
const createAccess = readSessionMethodAccess(gateway, {
|
||||||
method: "sessions.create",
|
method: "sessions.create",
|
||||||
@@ -413,6 +423,18 @@ export class DraftSubmissionFlow {
|
|||||||
this.callbacks.closeTransientUi();
|
this.callbacks.closeTransientUi();
|
||||||
this.callbacks.requestUpdate();
|
this.callbacks.requestUpdate();
|
||||||
try {
|
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 cloudProfileId = this.cloudProfileForSubmission();
|
||||||
const draftRetired = this.visibilityValue === "draft" && !this.canStartAsDraft();
|
const draftRetired = this.visibilityValue === "draft" && !this.canStartAsDraft();
|
||||||
const createParams = this.buildDraftSessionCreateParams({
|
const createParams = this.buildDraftSessionCreateParams({
|
||||||
@@ -599,6 +621,10 @@ export class DraftSubmissionFlow {
|
|||||||
focusComposer: true,
|
focusComposer: true,
|
||||||
}).options,
|
}).options,
|
||||||
);
|
);
|
||||||
|
} catch (error) {
|
||||||
|
if (requestId === this.submitRequestToken && this.gateway.client === submissionClient) {
|
||||||
|
this.errorValue = error instanceof Error ? error.message : String(error);
|
||||||
|
}
|
||||||
} finally {
|
} finally {
|
||||||
if (requestId === this.submitRequestToken) {
|
if (requestId === this.submitRequestToken) {
|
||||||
this.submittingValue = false;
|
this.submittingValue = false;
|
||||||
|
|||||||
@@ -104,7 +104,6 @@ class NewSessionPage extends OpenClawLightDomElement {
|
|||||||
this.gateway,
|
this.gateway,
|
||||||
() => ({
|
() => ({
|
||||||
context: this.context,
|
context: this.context,
|
||||||
projectId: this.place?.projectId ?? "",
|
|
||||||
nodes: this.place?.nodes ?? [],
|
nodes: this.place?.nodes ?? [],
|
||||||
folder: this.place?.folder ?? "",
|
folder: this.place?.folder ?? "",
|
||||||
execNode: this.place?.execNode ?? "",
|
execNode: this.place?.execNode ?? "",
|
||||||
@@ -114,8 +113,6 @@ class NewSessionPage extends OpenClawLightDomElement {
|
|||||||
requestUpdate: () => this.requestUpdate(),
|
requestUpdate: () => this.requestUpdate(),
|
||||||
onProjectMissing: () => this.place.clearProjectSelection(),
|
onProjectMissing: () => this.place.clearProjectSelection(),
|
||||||
onSelectProject: (projectId) => this.place.selectProjectId(projectId),
|
onSelectProject: (projectId) => this.place.selectProjectId(projectId),
|
||||||
onApplyFolder: (folder, execNode, gatewayApproved) =>
|
|
||||||
this.place.applyFolder(folder, execNode, gatewayApproved),
|
|
||||||
onApprovedListing: (listing) => this.place.recordGatewayApprovedListing(listing),
|
onApprovedListing: (listing) => this.place.recordGatewayApprovedListing(listing),
|
||||||
querySelector: (selector) => this.querySelector(selector),
|
querySelector: (selector) => this.querySelector(selector),
|
||||||
activeElement: () => this.ownerDocument.activeElement,
|
activeElement: () => this.ownerDocument.activeElement,
|
||||||
@@ -389,7 +386,8 @@ class NewSessionPage extends OpenClawLightDomElement {
|
|||||||
const projectState = resolveProjectChip({
|
const projectState = resolveProjectChip({
|
||||||
folder: this.place.folder,
|
folder: this.place.folder,
|
||||||
workspace: this.place.workspacePath(),
|
workspace: this.place.workspacePath(),
|
||||||
projectId: this.place.projectId,
|
projectId: this.browser.projectId,
|
||||||
|
selectedRemoteProject: this.browser.remoteProject,
|
||||||
projects,
|
projects,
|
||||||
recents,
|
recents,
|
||||||
projectQuery: this.browser.projectQuery,
|
projectQuery: this.browser.projectQuery,
|
||||||
@@ -412,7 +410,7 @@ class NewSessionPage extends OpenClawLightDomElement {
|
|||||||
onPopoverHide: () => this.browser.onPopoverHide(kind),
|
onPopoverHide: () => this.browser.onPopoverHide(kind),
|
||||||
onPopoverAfterHide: () => this.browser.onPopoverAfterHide(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);
|
const pendingCloud = Boolean(this.submission.pendingCloud.sessionKey);
|
||||||
return html`${renderWhereChip({
|
return html`${renderWhereChip({
|
||||||
state: whereState,
|
state: whereState,
|
||||||
@@ -448,12 +446,11 @@ class NewSessionPage extends OpenClawLightDomElement {
|
|||||||
"operator.write",
|
"operator.write",
|
||||||
),
|
),
|
||||||
remoteProjects: this.browser.projectSearchResult?.projects ?? [],
|
remoteProjects: this.browser.projectSearchResult?.projects ?? [],
|
||||||
|
selectedRemoteProject: this.browser.remoteProject,
|
||||||
projectSearchCredentialMissing: this.browser.projectSearchResult?.credential === "missing",
|
projectSearchCredentialMissing: this.browser.projectSearchResult?.credential === "missing",
|
||||||
projectSearchLoading: this.browser.projectSearchLoading,
|
projectSearchLoading: this.browser.projectSearchLoading,
|
||||||
projectSearchError: this.browser.projectSearchError,
|
projectSearchError: this.browser.projectSearchError,
|
||||||
projectCloneBusy: this.browser.projectCloneBusy,
|
projectId: this.browser.projectId,
|
||||||
projectCloneError: this.browser.projectCloneError,
|
|
||||||
projectId: this.place.projectId,
|
|
||||||
execNodes,
|
execNodes,
|
||||||
gatewayLabel,
|
gatewayLabel,
|
||||||
execNode: this.place.execNode,
|
execNode: this.place.execNode,
|
||||||
@@ -470,7 +467,7 @@ class NewSessionPage extends OpenClawLightDomElement {
|
|||||||
registeringProject: this.browser.browserRegistering,
|
registeringProject: this.browser.browserRegistering,
|
||||||
onSelectProject: (projectId) => this.place.selectProjectId(projectId),
|
onSelectProject: (projectId) => this.place.selectProjectId(projectId),
|
||||||
onProjectQueryInput: (query) => this.browser.changeProjectQuery(query),
|
onProjectQueryInput: (query) => this.browser.changeProjectQuery(query),
|
||||||
onCloneProject: (gitUrl) => void this.browser.addRemoteProject(gitUrl),
|
onSelectRemoteProject: (project) => this.place.selectRemoteProject(project),
|
||||||
onApplyFolder: (folder, execNode) =>
|
onApplyFolder: (folder, execNode) =>
|
||||||
this.place.applyFolder(
|
this.place.applyFolder(
|
||||||
folder,
|
folder,
|
||||||
|
|||||||
@@ -3,6 +3,16 @@ export function folderDisplayName(path: string): string {
|
|||||||
return path.split(/[\\/]/).findLast((segment) => segment.length > 0) ?? path;
|
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 {
|
export function isAbsolutePath(path: string): boolean {
|
||||||
return path.startsWith("/") || path.startsWith("\\") || /^[A-Za-z]:[\\/]/.test(path);
|
return path.startsWith("/") || path.startsWith("\\") || /^[A-Za-z]:[\\/]/.test(path);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -43,6 +43,7 @@ describe("What chip state", () => {
|
|||||||
folder: "",
|
folder: "",
|
||||||
workspace: "/workspace",
|
workspace: "/workspace",
|
||||||
projectId: "",
|
projectId: "",
|
||||||
|
selectedRemoteProject: null,
|
||||||
projects,
|
projects,
|
||||||
recents: [
|
recents: [
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ import { icons } from "../../components/icons.ts";
|
|||||||
import { t } from "../../i18n/index.ts";
|
import { t } from "../../i18n/index.ts";
|
||||||
import { renderSessionMenuItem } from "./cloud-target.ts";
|
import { renderSessionMenuItem } from "./cloud-target.ts";
|
||||||
import type { BrowserTarget, DraftNode } from "./discovery.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 { renderPlaceBrowser } from "./place-browser.ts";
|
||||||
import { disambiguate } from "./place-labels.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;
|
return /^(?:https:\/\/|ssh:\/\/git@|git@[^:]+:)/iu.test(trimmed) ? trimmed : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
function parentFolderDisplayName(path: string): string | undefined {
|
export type DraftRemoteProject = Readonly<{
|
||||||
const trimmed = path.replace(/[\\/]+$/u, "");
|
identity: string;
|
||||||
const separator = Math.max(trimmed.lastIndexOf("/"), trimmed.lastIndexOf("\\"));
|
cloneUrl: string;
|
||||||
if (separator < 0) {
|
projectId?: string;
|
||||||
return undefined;
|
}>;
|
||||||
}
|
|
||||||
const parent = separator === 0 ? trimmed.slice(0, 1) : trimmed.slice(0, separator);
|
|
||||||
return folderDisplayName(parent) || undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
type ProjectChipState = Readonly<{
|
type ProjectChipState = Readonly<{
|
||||||
mode: "projects" | "node-path";
|
mode: "projects" | "node-path";
|
||||||
@@ -44,6 +40,7 @@ export function resolveProjectChip(params: {
|
|||||||
folder: string;
|
folder: string;
|
||||||
workspace: string;
|
workspace: string;
|
||||||
projectId: string;
|
projectId: string;
|
||||||
|
selectedRemoteProject: DraftRemoteProject | null;
|
||||||
projects: readonly ProjectRecord[];
|
projects: readonly ProjectRecord[];
|
||||||
recents: readonly ProjectRecent[];
|
recents: readonly ProjectRecent[];
|
||||||
projectQuery: string;
|
projectQuery: string;
|
||||||
@@ -65,9 +62,11 @@ export function resolveProjectChip(params: {
|
|||||||
mode,
|
mode,
|
||||||
label: selectedProject
|
label: selectedProject
|
||||||
? selectedProject.displayName
|
? selectedProject.displayName
|
||||||
: folder
|
: params.selectedRemoteProject?.identity
|
||||||
? folderDisplayName(folder)
|
? params.selectedRemoteProject.identity
|
||||||
: folderDisplayName(params.workspace) || t("newSession.folderPlaceholder"),
|
: folder
|
||||||
|
? folderDisplayName(folder)
|
||||||
|
: folderDisplayName(params.workspace) || t("newSession.folderPlaceholder"),
|
||||||
localProjects,
|
localProjects,
|
||||||
recents: params.execNode
|
recents: params.execNode
|
||||||
? params.recents.filter(
|
? params.recents.filter(
|
||||||
@@ -98,11 +97,10 @@ export function renderProjectChip(params: {
|
|||||||
projectSearchAvailable: boolean;
|
projectSearchAvailable: boolean;
|
||||||
projectAddAvailable: boolean;
|
projectAddAvailable: boolean;
|
||||||
remoteProjects: readonly RemoteProject[];
|
remoteProjects: readonly RemoteProject[];
|
||||||
|
selectedRemoteProject: DraftRemoteProject | null;
|
||||||
projectSearchCredentialMissing: boolean;
|
projectSearchCredentialMissing: boolean;
|
||||||
projectSearchLoading: boolean;
|
projectSearchLoading: boolean;
|
||||||
projectSearchError: string | null;
|
projectSearchError: string | null;
|
||||||
projectCloneBusy: boolean;
|
|
||||||
projectCloneError: string | null;
|
|
||||||
projectId: string;
|
projectId: string;
|
||||||
execNodes: readonly DraftNode[];
|
execNodes: readonly DraftNode[];
|
||||||
gatewayLabel: string;
|
gatewayLabel: string;
|
||||||
@@ -125,7 +123,7 @@ export function renderProjectChip(params: {
|
|||||||
onPopoverAfterHide: () => void;
|
onPopoverAfterHide: () => void;
|
||||||
onSelectProject: (projectId: string) => void;
|
onSelectProject: (projectId: string) => void;
|
||||||
onProjectQueryInput: (query: string) => void;
|
onProjectQueryInput: (query: string) => void;
|
||||||
onCloneProject: (gitUrl: string) => void;
|
onSelectRemoteProject: (project: DraftRemoteProject) => void;
|
||||||
onApplyFolder: (folder: string, execNode: string) => void;
|
onApplyFolder: (folder: string, execNode: string) => void;
|
||||||
onBrowse: (target: BrowserTarget) => void;
|
onBrowse: (target: BrowserTarget) => void;
|
||||||
onBrowserPathDraftChange: (value: string) => void;
|
onBrowserPathDraftChange: (value: string) => void;
|
||||||
@@ -255,15 +253,16 @@ export function renderProjectChip(params: {
|
|||||||
type="search"
|
type="search"
|
||||||
placeholder=${t("newSession.projectSearchPlaceholder")}
|
placeholder=${t("newSession.projectSearchPlaceholder")}
|
||||||
.value=${params.projectQuery}
|
.value=${params.projectQuery}
|
||||||
?disabled=${params.submitting ||
|
?disabled=${params.submitting || params.pendingCloud}
|
||||||
params.pendingCloud ||
|
|
||||||
params.projectCloneBusy}
|
|
||||||
@input=${(event: Event) =>
|
@input=${(event: Event) =>
|
||||||
params.onProjectQueryInput((event.target as HTMLInputElement).value)}
|
params.onProjectQueryInput((event.target as HTMLInputElement).value)}
|
||||||
@keydown=${(event: KeyboardEvent) => {
|
@keydown=${(event: KeyboardEvent) => {
|
||||||
if (event.key === "Enter" && cloneInput && params.projectAddAvailable) {
|
if (event.key === "Enter" && cloneInput && params.projectAddAvailable) {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
params.onCloneProject(cloneInput);
|
params.onSelectRemoteProject({
|
||||||
|
identity: cloneInput,
|
||||||
|
cloneUrl: cloneInput,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
@@ -278,7 +277,7 @@ export function renderProjectChip(params: {
|
|||||||
title: project.repoRoot,
|
title: project.repoRoot,
|
||||||
onSelect: () => params.onSelectProject(project.id),
|
onSelect: () => params.onSelectProject(project.id),
|
||||||
},
|
},
|
||||||
params.submitting || params.projectCloneBusy,
|
params.submitting,
|
||||||
),
|
),
|
||||||
)}
|
)}
|
||||||
${cloneInput && params.projectAddAvailable
|
${cloneInput && params.projectAddAvailable
|
||||||
@@ -288,11 +287,14 @@ export function renderProjectChip(params: {
|
|||||||
label: cloneInput,
|
label: cloneInput,
|
||||||
icon: icons.gitBranch,
|
icon: icons.gitBranch,
|
||||||
sub: t("newSession.cloneProject"),
|
sub: t("newSession.cloneProject"),
|
||||||
checked: false,
|
checked: params.selectedRemoteProject?.cloneUrl === cloneInput,
|
||||||
keepOpen: true,
|
onSelect: () =>
|
||||||
onSelect: () => params.onCloneProject(cloneInput),
|
params.onSelectRemoteProject({
|
||||||
|
identity: cloneInput,
|
||||||
|
cloneUrl: cloneInput,
|
||||||
|
}),
|
||||||
},
|
},
|
||||||
params.submitting || params.projectCloneBusy,
|
params.submitting,
|
||||||
)
|
)
|
||||||
: nothing}
|
: nothing}
|
||||||
${!cloneInput && query.length >= 2 && params.projectSearchAvailable
|
${!cloneInput && query.length >= 2 && params.projectSearchAvailable
|
||||||
@@ -322,28 +324,20 @@ export function renderProjectChip(params: {
|
|||||||
label: project.fullName,
|
label: project.fullName,
|
||||||
icon: icons.gitBranch,
|
icon: icons.gitBranch,
|
||||||
sub: project.description ?? t("newSession.cloneProject"),
|
sub: project.description ?? t("newSession.cloneProject"),
|
||||||
checked: false,
|
checked:
|
||||||
|
params.selectedRemoteProject?.cloneUrl === project.cloneUrl,
|
||||||
title: project.webUrl,
|
title: project.webUrl,
|
||||||
keepOpen: true,
|
onSelect: () =>
|
||||||
onSelect: () => params.onCloneProject(project.cloneUrl),
|
params.onSelectRemoteProject({
|
||||||
|
identity: project.fullName,
|
||||||
|
cloneUrl: project.cloneUrl,
|
||||||
|
}),
|
||||||
},
|
},
|
||||||
params.submitting ||
|
params.submitting || !params.projectAddAvailable,
|
||||||
params.projectCloneBusy ||
|
|
||||||
!params.projectAddAvailable,
|
|
||||||
),
|
),
|
||||||
)}
|
)}
|
||||||
`
|
`
|
||||||
: nothing}
|
: 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
|
${params.projects.length === 0 && params.canWrite && !params.isAdmin
|
||||||
? html`<div class="new-session-page__menu-note">
|
? html`<div class="new-session-page__menu-note">
|
||||||
${t("newSession.projectsAdminHint")}
|
${t("newSession.projectsAdminHint")}
|
||||||
|
|||||||
Reference in New Issue
Block a user