diff --git a/ui/src/e2e/session-ownership.e2e.test.ts b/ui/src/e2e/session-ownership.e2e.test.ts index 55ff56edf08d..26608d48c336 100644 --- a/ui/src/e2e/session-ownership.e2e.test.ts +++ b/ui/src/e2e/session-ownership.e2e.test.ts @@ -244,7 +244,8 @@ describeControlUiE2e("Control UI session ownership", () => { }); await currentPage.goto(`${server?.baseUrl ?? ""}new`); - const draftToggle = currentPage.getByLabel("Start as draft"); + // Playwright check()/isChecked() support role="switch" buttons via aria-checked. + const draftToggle = currentPage.getByRole("switch", { name: "Draft", exact: true }); await draftToggle.waitFor(); await captureUiProof(currentPage, "02-create-draft-available.png"); await draftToggle.check(); @@ -326,7 +327,7 @@ describeControlUiE2e("Control UI session ownership", () => { }); await currentPage.goto(`${server?.baseUrl ?? ""}new`); - const draftToggle = currentPage.getByLabel("Start as draft"); + const draftToggle = currentPage.getByRole("switch", { name: "Draft", exact: true }); await draftToggle.check(); await gateway.setSessionSharingPolicy({ allowedSessionVisibilities: ["shared"], @@ -356,6 +357,6 @@ describeControlUiE2e("Control UI session ownership", () => { await currentPage.goto(`${server?.baseUrl ?? ""}new`); await currentPage.locator(".new-session-page__message").waitFor(); - expect(await currentPage.getByLabel("Start as draft").count()).toBe(0); + expect(await currentPage.getByRole("switch", { name: "Draft", exact: true }).count()).toBe(0); }); }); diff --git a/ui/src/i18n/locales/en.ts b/ui/src/i18n/locales/en.ts index 5f51f581da9c..ec200c07e4bb 100644 --- a/ui/src/i18n/locales/en.ts +++ b/ui/src/i18n/locales/en.ts @@ -568,7 +568,8 @@ export const en: TranslationMap = { worktreeNameInvalid: "Worktree names use lowercase letters, digits, and dashes.", incognito: "Incognito", incognitoDescription: "Keep this thread only until the Gateway restarts", - startAsDraft: "Start as draft", + draft: "Draft", + draftDescription: "Keep this thread to yourself until you publish it", messagePlaceholder: "What should this thread work on?", readingAttachment: "Reading attachment", start: "Start thread", diff --git a/ui/src/pages/new-session/catalog-target.ts b/ui/src/pages/new-session/catalog-target.ts index 27b101bb9d2e..234b0d4be3cb 100644 --- a/ui/src/pages/new-session/catalog-target.ts +++ b/ui/src/pages/new-session/catalog-target.ts @@ -78,7 +78,6 @@ export function renderBar(params: { data?: NewSessionRouteData; agentSelect: unknown; placeSelect: unknown; - draftVisibilityControl: unknown; retrying: boolean; onRetry: () => void; }) { @@ -86,7 +85,7 @@ export function renderBar(params: { return html`
${renderTarget(params.data)} ${isTarget(params.data) ? nothing : params.agentSelect} - ${params.placeSelect} ${params.draftVisibilityControl} + ${params.placeSelect} ${pending ? html` ${t("newSession.catalogUnavailable")} diff --git a/ui/src/pages/new-session/composer.test.ts b/ui/src/pages/new-session/composer.test.ts index 955dcf7014e4..7e1de7e5a4a4 100644 --- a/ui/src/pages/new-session/composer.test.ts +++ b/ui/src/pages/new-session/composer.test.ts @@ -5,6 +5,7 @@ import { afterEach, describe, expect, it, vi } from "vitest"; import { waitForFast } from "../../test-helpers/wait-for.ts"; import { NewSessionAttachmentDraft } from "./attachment-draft.ts"; import { renderNewSessionDraftComposer } from "./composer.ts"; +import type { NewSessionVisibility } from "./create-params.ts"; import { NewSessionModelControl } from "./model-control.ts"; const attachmentDrafts: NewSessionAttachmentDraft[] = []; @@ -13,8 +14,9 @@ function renderComposer( overrides: { submitting?: boolean; messageLocked?: boolean; - incognito?: boolean; - onToggleIncognito?: () => void; + visibility?: NewSessionVisibility; + draftAvailable?: boolean; + onVisibilityChange?: (visibility: NewSessionVisibility) => void; } = {}, ) { const container = document.createElement("div"); @@ -28,13 +30,14 @@ function renderComposer( context: undefined, isCatalogTarget: true, message: "", - incognito: overrides.incognito, + visibility: overrides.visibility, + draftAvailable: overrides.draftAvailable, modelControl: new NewSessionModelControl(() => undefined), requiresModifier: false, submitting: overrides.submitting ?? false, messageLocked: overrides.messageLocked, onInput: () => undefined, - onToggleIncognito: overrides.onToggleIncognito, + onVisibilityChange: overrides.onVisibilityChange, onSubmit: () => undefined, }), container, @@ -63,22 +66,44 @@ afterEach(() => { }); describe("new-session composer attachment drops", () => { - it("renders the incognito switch off by default and forwards toggles", () => { - const onToggleIncognito = vi.fn(); - const { composer } = renderComposer({ onToggleIncognito }); - const toggle = composer.querySelector('[role="switch"]'); + it("renders only the incognito pill when drafts are unavailable, off by default", () => { + const onVisibilityChange = vi.fn(); + const { composer } = renderComposer({ onVisibilityChange }); + const switches = composer.querySelectorAll('[role="switch"]'); - expect(toggle?.getAttribute("aria-checked")).toBe("false"); - toggle?.click(); - expect(onToggleIncognito).toHaveBeenCalledOnce(); + expect(switches).toHaveLength(1); + expect(switches[0]?.getAttribute("aria-checked")).toBe("false"); + switches[0]?.click(); + expect(onVisibilityChange).toHaveBeenCalledWith("incognito"); }); it("renders a distinct active state when incognito is selected", () => { - const { composer } = renderComposer({ incognito: true }); + const { composer } = renderComposer({ visibility: "incognito" }); const toggle = composer.querySelector('[role="switch"]'); expect(toggle?.getAttribute("aria-checked")).toBe("true"); - expect(toggle?.classList.contains("new-session-page__incognito--active")).toBe(true); + expect(toggle?.classList.contains("new-session-page__visibility--active")).toBe(true); + }); + + it("keeps the visibility pills mutually exclusive", () => { + const onVisibilityChange = vi.fn(); + const { composer } = renderComposer({ + draftAvailable: true, + visibility: "incognito", + onVisibilityChange, + }); + const [draftPill, incognitoPill] = Array.from( + composer.querySelectorAll('[role="switch"]'), + ); + + expect(draftPill?.textContent).toContain("Draft"); + expect(draftPill?.getAttribute("aria-checked")).toBe("false"); + expect(incognitoPill?.getAttribute("aria-checked")).toBe("true"); + + draftPill?.click(); + expect(onVisibilityChange).toHaveBeenCalledWith("draft"); + incognitoPill?.click(); + expect(onVisibilityChange).toHaveBeenCalledWith("normal"); }); it("adds a dropped file through the shared attachment handling", async () => { diff --git a/ui/src/pages/new-session/composer.ts b/ui/src/pages/new-session/composer.ts index 25a8bbf3334e..2de79a91f7dd 100644 --- a/ui/src/pages/new-session/composer.ts +++ b/ui/src/pages/new-session/composer.ts @@ -13,6 +13,7 @@ import { renderChatAttachmentMenu, } from "../chat/components/chat-attachments.ts"; import type { NewSessionAttachmentDraft } from "./attachment-draft.ts"; +import type { NewSessionVisibility } from "./create-params.ts"; import type { NewSessionModelControl } from "./model-control.ts"; type NewSessionComposerOptions = { @@ -26,14 +27,39 @@ type NewSessionComposerOptions = { requiresModifier: boolean; submitting: boolean; messageLocked?: boolean; - incognito?: boolean; + visibility?: NewSessionVisibility; + draftAvailable?: boolean; onAttachmentsChange: (attachments: ChatAttachment[]) => void; onPendingReadsChange: (delta: 1 | -1) => void; onInput: (message: string) => void; - onToggleIncognito?: () => void; + onVisibilityChange?: (visibility: NewSessionVisibility) => void; onSubmit: () => void; }; +/** Mutually exclusive visibility pills: selecting one clears the other, re-click returns to normal. */ +function renderVisibilityPill(params: { + mode: Exclude; + icon: unknown; + label: string; + description: string; + options: NewSessionComposerOptions; +}) { + const active = params.options.visibility === params.mode; + return html` + + `; +} + export function renderDraftError(message: string) { return html`
${options.pendingAttachmentReads > 0 @@ -209,13 +238,14 @@ export function renderNewSessionDraftComposer(options: { context: import("../../app/context.ts").ApplicationContext | undefined; isCatalogTarget: boolean; message: string; - incognito?: boolean; + visibility?: NewSessionVisibility; + draftAvailable?: boolean; modelControl: NewSessionModelControl; requiresModifier: boolean; submitting: boolean; messageLocked?: boolean; onInput: (message: string) => void; - onToggleIncognito?: () => void; + onVisibilityChange?: (visibility: NewSessionVisibility) => void; onSubmit: () => void; }) { const readSignal = options.attachmentDraft.readSignal; @@ -224,7 +254,8 @@ export function renderNewSessionDraftComposer(options: { canSubmit: options.canSubmit, getAttachments: () => options.attachmentDraft.attachments, message: options.message, - incognito: options.incognito, + visibility: options.visibility, + draftAvailable: options.draftAvailable, modelControl: options.isCatalogTarget ? nothing : options.modelControl.render({ @@ -245,7 +276,7 @@ export function renderNewSessionDraftComposer(options: { }, onPendingReadsChange: (delta) => options.attachmentDraft.updatePending(readSignal, delta), onInput: options.onInput, - onToggleIncognito: options.onToggleIncognito, + onVisibilityChange: options.onVisibilityChange, onSubmit: options.onSubmit, }); } diff --git a/ui/src/pages/new-session/create-params.test.ts b/ui/src/pages/new-session/create-params.test.ts index 11b6be7ac0d5..4b54e5c123e2 100644 --- a/ui/src/pages/new-session/create-params.test.ts +++ b/ui/src/pages/new-session/create-params.test.ts @@ -46,12 +46,12 @@ describe("buildDraftSessionCreateParams", () => { ).toEqual({ agentId: "main", message: "hello" }); }); - it("adds incognito only when the draft toggle is on", () => { + it("adds incognito only when that visibility is selected", () => { expect( buildDraftSessionCreateParams({ agentId: "main", message: "private task", - incognito: true, + visibility: "incognito", worktree: false, }), ).toEqual({ agentId: "main", message: "private task", incognito: true }); @@ -63,7 +63,7 @@ describe("buildDraftSessionCreateParams", () => { agentId: "main", message: "private work in progress", worktree: false, - startAsDraft: true, + visibility: "draft", }), ).toEqual({ agentId: "main", diff --git a/ui/src/pages/new-session/create-params.ts b/ui/src/pages/new-session/create-params.ts index 85050b184bf2..91952c2a8a3a 100644 --- a/ui/src/pages/new-session/create-params.ts +++ b/ui/src/pages/new-session/create-params.ts @@ -3,6 +3,12 @@ import { normalizeOptionalString } from "../../lib/string-coerce.ts"; const WORKTREE_NAME_PATTERN = /^[a-z0-9][a-z0-9-]{0,63}$/; +/** + * One closed visibility mode instead of independent incognito/draft booleans: + * an incognito session is never persisted, so "incognito draft" is unrepresentable. + */ +export type NewSessionVisibility = "normal" | "draft" | "incognito"; + export function canStartSessionAsDraft(params: { allowedVisibilities?: readonly string[]; hasMultipleIdentities?: boolean; @@ -24,7 +30,7 @@ export function buildDraftSessionCreateParams(draft: { message: string; model?: string; thinkingLevel?: string; - incognito?: boolean; + visibility?: NewSessionVisibility; attachments?: unknown[]; worktree: boolean; baseRef?: string; @@ -33,7 +39,6 @@ export function buildDraftSessionCreateParams(draft: { workspace?: string; execNode?: string; catalogId?: string; - startAsDraft?: boolean; }): Record { const cwd = normalizeOptionalString(draft.cwd); const workspace = normalizeOptionalString(draft.workspace); @@ -46,8 +51,8 @@ export function buildDraftSessionCreateParams(draft: { ...(normalizeOptionalString(draft.key) ? { key: normalizeOptionalString(draft.key) } : {}), agentId: normalizeAgentId(draft.agentId), message: draft.message, - ...(draft.incognito ? { incognito: true } : {}), - ...(draft.startAsDraft ? { visibility: "draft" } : {}), + ...(draft.visibility === "incognito" ? { incognito: true } : {}), + ...(draft.visibility === "draft" ? { visibility: "draft" } : {}), ...(draft.attachments?.length ? { attachments: draft.attachments } : {}), ...(catalogId ? { catalogId } : {}), ...(!catalogId && model ? { model } : {}), diff --git a/ui/src/pages/new-session/new-session-page.ts b/ui/src/pages/new-session/new-session-page.ts index 5714c623f69a..23fd216c0f54 100644 --- a/ui/src/pages/new-session/new-session-page.ts +++ b/ui/src/pages/new-session/new-session-page.ts @@ -33,6 +33,7 @@ import { buildDraftSessionCreateParams, canStartSessionAsDraft, isWorktreeNameValid, + type NewSessionVisibility, } from "./create-params.ts"; import { type BrowserTarget, @@ -47,7 +48,7 @@ import { NewSessionModelControl } from "./model-control.ts"; import { isAbsolutePath } from "./path.ts"; import { renderPlaceSelect } from "./place-picker.ts"; import { retainRejectedInitialTurn } from "./rejected-initial-turn.ts"; -import { renderAgentSelect, renderStartAsDraftToggle } from "./target-controls.ts"; +import { renderAgentSelect } from "./target-controls.ts"; const CATALOG_RETRY_DELAYS_MS = [0, 1_000, 3_000] as const; @@ -60,9 +61,8 @@ class NewSessionPage extends OpenClawLightDomElement { @state() private agentId = ""; @state() private folder = ""; @state() private worktree = false; - @state() private incognito = false; + @state() private visibility: NewSessionVisibility = "normal"; @state() private worktreeName = ""; - @state() private startAsDraft = false; @state() private baseRef = ""; @state() private repository: DraftRepositoryState = { kind: "idle" }; @state() private nodes: DraftNode[] = []; @@ -178,8 +178,8 @@ class NewSessionPage extends OpenClawLightDomElement { this.gatewayRecoveryScope = recoveryScope.next; this.gatewayRecoveryScopeReady = snapshot.client?.recoveryScopeReady === true; this.gatewayConnected = connected; - if (this.startAsDraft && !this.canStartAsDraft()) { - this.startAsDraft = false; + if (this.visibility === "draft" && !this.canStartAsDraft()) { + this.visibility = "normal"; } if (gatewayUrlChanged || identityChanged || connectionChanged || recoveryScope.changed) { this.invalidateGatewayDiscovery(gatewayUrlChanged || recoveryScope.changed); @@ -241,9 +241,8 @@ class NewSessionPage extends OpenClawLightDomElement { this.folder = ""; this.folderSelectedByUser = false; this.worktree = false; - this.incognito = false; + this.visibility = "normal"; this.worktreeName = ""; - this.startAsDraft = false; this.baseRefEditGeneration += 1; this.nodes = []; this.execNode = ""; @@ -470,9 +469,8 @@ class NewSessionPage extends OpenClawLightDomElement { this.folder = ""; this.folderSelectedByUser = false; this.worktree = false; - this.incognito = false; + this.visibility = "normal"; this.worktreeName = ""; - this.startAsDraft = false; this.baseRef = ""; this.repository = { kind: "idle" }; this.execNode = ""; @@ -486,7 +484,7 @@ class NewSessionPage extends OpenClawLightDomElement { this.agentId = this.pendingCloud.agentId; this.cloudProfileId = this.pendingCloud.profileId; this.worktree = true; - this.incognito = this.pendingCloud.createParams?.incognito === true; + this.visibility = this.pendingCloud.createParams?.incognito === true ? "incognito" : "normal"; // Show the staged repo (not the agent workspace) while the draft is locked. this.folder = this.pendingCloud.createParams?.cwd ?? ""; this.pendingCloud.restored = false; @@ -538,7 +536,7 @@ class NewSessionPage extends OpenClawLightDomElement { this.agentId = recovery.agentId; this.cloudProfileId = recovery.profileId; this.worktree = true; - this.incognito = recovery.createParams?.incognito === true; + this.visibility = recovery.createParams?.incognito === true ? "incognito" : "normal"; // Show the staged repo (not the agent workspace) while the draft is locked. this.folder = recovery.createParams?.cwd ?? ""; this.message = recovery.message; @@ -801,12 +799,14 @@ class NewSessionPage extends OpenClawLightDomElement { } try { const cloudProfileId = this.cloudProfileForSubmission(); + // Draft mode can go stale if sharing policy changed since it was selected. + const draftRetired = this.visibility === "draft" && !this.canStartAsDraft(); const createParams = buildDraftSessionCreateParams({ agentId: this.agentId, message: cloudProfileId ? "" : message, model: this.modelControl.selected, thinkingLevel: this.modelControl.thinkingLevel, - incognito: this.incognito, + visibility: draftRetired ? "normal" : this.visibility, attachments: cloudProfileId ? undefined : apiAttachments, worktree: this.worktree, baseRef: this.baseRef, @@ -815,7 +815,6 @@ class NewSessionPage extends OpenClawLightDomElement { workspace: this.workspacePath(), execNode: this.execNode, catalogId: this.data?.catalogId, - startAsDraft: this.startAsDraft && this.canStartAsDraft(), }); const cloudCreateParams = cloudProfileId ? pendingCloud @@ -828,7 +827,7 @@ class NewSessionPage extends OpenClawLightDomElement { gatewayUrl: submissionGatewayUrl, recoveryScope: submissionRecoveryScope, createParams, - persistent: !this.incognito, + persistent: this.visibility !== "incognito", }) : undefined; if (cloudProfileId && !pendingCloud && !cloudCreateParams) { @@ -1368,17 +1367,6 @@ class NewSessionPage extends OpenClawLightDomElement { data: this.data, agentSelect: agents.length > 1 ? this.renderAgentSelect(agents) : nothing, placeSelect: this.renderPlaceSelect(), - draftVisibilityControl: this.canStartAsDraft() - ? renderStartAsDraftToggle({ - checked: this.startAsDraft, - disabled: this.submitting || Boolean(this.pendingCloud.sessionKey), - onChange: (checked) => { - if (!this.submitting && !this.pendingCloud.sessionKey) { - this.startAsDraft = checked; - } - }, - }) - : nothing, retrying: this.catalogRetrying, onRetry: this.handleCatalogRetry, }); @@ -1403,7 +1391,8 @@ class NewSessionPage extends OpenClawLightDomElement { context: this.context, isCatalogTarget: catalog.isTarget(this.data), message: this.message, - incognito: this.incognito, + visibility: this.visibility, + draftAvailable: this.canStartAsDraft(), modelControl: this.modelControl, requiresModifier: loadSettings().chatSendShortcut === "modifier-enter", submitting: this.submitting, @@ -1413,9 +1402,9 @@ class NewSessionPage extends OpenClawLightDomElement { this.message = message; } }, - onToggleIncognito: () => { + onVisibilityChange: (visibility) => { if (!this.submitting && !this.pendingCloud.sessionKey) { - this.incognito = !this.incognito; + this.visibility = visibility; } }, onSubmit: () => void this.submit(), diff --git a/ui/src/pages/new-session/target-controls.test.ts b/ui/src/pages/new-session/target-controls.test.ts deleted file mode 100644 index d1fe276bde1f..000000000000 --- a/ui/src/pages/new-session/target-controls.test.ts +++ /dev/null @@ -1,21 +0,0 @@ -import { render } from "lit"; -import { describe, expect, it, vi } from "vitest"; -import { renderStartAsDraftToggle } from "./target-controls.ts"; - -describe("start-as-draft control", () => { - it("renders a labeled checkbox and reports the selected state", () => { - const container = document.createElement("div"); - const onChange = vi.fn(); - render(renderStartAsDraftToggle({ checked: false, disabled: false, onChange }), container); - - const input = container.querySelector('input[type="checkbox"]'); - expect(container.textContent).toContain("Start as draft"); - expect(input?.checked).toBe(false); - if (!input) { - throw new Error("expected draft checkbox"); - } - input.checked = true; - input.dispatchEvent(new Event("change")); - expect(onChange).toHaveBeenCalledWith(true); - }); -}); diff --git a/ui/src/pages/new-session/target-controls.ts b/ui/src/pages/new-session/target-controls.ts index e728ca8b77f7..ea9b2a63f88a 100644 --- a/ui/src/pages/new-session/target-controls.ts +++ b/ui/src/pages/new-session/target-controls.ts @@ -31,21 +31,3 @@ export function renderAgentSelect(params: { `; } - -export function renderStartAsDraftToggle(params: { - checked: boolean; - disabled: boolean; - onChange: (checked: boolean) => void; -}) { - return html``; -} diff --git a/ui/src/styles/new-session.css b/ui/src/styles/new-session.css index 23d64866919e..beac86b29b0c 100644 --- a/ui/src/styles/new-session.css +++ b/ui/src/styles/new-session.css @@ -53,7 +53,7 @@ openclaw-new-session-page { text-align: left; } -.new-session-page__incognito { +.new-session-page__visibility { display: inline-flex; align-items: center; gap: 5px; @@ -68,25 +68,25 @@ openclaw-new-session-page { cursor: pointer; } -.new-session-page__incognito:hover:not(:disabled), -.new-session-page__incognito--active { +.new-session-page__visibility:hover:not(:disabled), +.new-session-page__visibility--active { background: color-mix(in srgb, var(--bg-hover) 84%, transparent); color: var(--text); } -.new-session-page__incognito--active { +.new-session-page__visibility--active { background: color-mix(in srgb, var(--accent) 18%, var(--bg-elevated)); color: var(--accent); box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--accent) 42%, transparent); font-weight: 650; } -.new-session-page__incognito:disabled { +.new-session-page__visibility:disabled { cursor: default; opacity: 0.55; } -.new-session-page__incognito svg { +.new-session-page__visibility svg { width: 12px; height: 12px; fill: none; @@ -179,21 +179,6 @@ openclaw-new-session-page { opacity: 0.6; } -.new-session-page__draft-toggle { - gap: 6px; - cursor: pointer; -} - -.new-session-page__draft-toggle input { - margin: 0; - accent-color: var(--accent); -} - -.new-session-page__draft-toggle:has(input:disabled) { - cursor: default; - opacity: 0.6; -} - /* Keep the anchor focusable while Web Awesome finishes its hide animation. */ .new-session-page__trigger--hiding { pointer-events: none;