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:
Peter Steinberger
2026-08-12 20:48:34 -07:00
committed by GitHub
parent aa35346a8e
commit 89a5507602
11 changed files with 320 additions and 187 deletions
@@ -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,
+34 -77
View File
@@ -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();
} }
+42 -35
View File
@@ -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;
+6 -9
View File
@@ -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,
+10
View File
@@ -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: [
{ {
+35 -41
View File
@@ -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")}