feat(ui): unify new-thread draft and incognito into one visibility control (#113799)

This commit is contained in:
Peter Steinberger
2026-07-25 13:06:37 -07:00
committed by GitHub
parent 71f7f9e4c2
commit a7d194ac74
11 changed files with 130 additions and 133 deletions
+4 -3
View File
@@ -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);
});
});
+2 -1
View File
@@ -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",
+1 -2
View File
@@ -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`
<div class="new-session-page__triggers">
${renderTarget(params.data)} ${isTarget(params.data) ? nothing : params.agentSelect}
${params.placeSelect} ${params.draftVisibilityControl}
${params.placeSelect}
${pending
? html`<span class="new-session-page__catalog-unavailable">
${t("newSession.catalogUnavailable")}
+38 -13
View File
@@ -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<HTMLButtonElement>('[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<HTMLButtonElement>('[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<HTMLButtonElement>('[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<HTMLButtonElement>('[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 () => {
+50 -19
View File
@@ -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<NewSessionVisibility, "normal">;
icon: unknown;
label: string;
description: string;
options: NewSessionComposerOptions;
}) {
const active = params.options.visibility === params.mode;
return html`
<button
type="button"
class="new-session-page__visibility ${active ? "new-session-page__visibility--active" : ""}"
role="switch"
aria-checked=${String(active)}
?disabled=${params.options.submitting || params.options.messageLocked}
title=${params.description}
@click=${() => params.options.onVisibilityChange?.(active ? "normal" : params.mode)}
>
<span aria-hidden="true">${params.icon}</span>${params.label}
</button>
`;
}
export function renderDraftError(message: string) {
return html`
<div class="callout danger new-session-page__error new-session-page__alert" role="alert">
@@ -176,19 +202,22 @@ function renderNewSessionComposer(options: NewSessionComposerOptions) {
${options.modelControl && options.modelControl !== nothing
? html`<div class="chat-composer-model-control">${options.modelControl}</div>`
: nothing}
<button
type="button"
class="new-session-page__incognito ${options.incognito
? "new-session-page__incognito--active"
: ""}"
role="switch"
aria-checked=${String(options.incognito === true)}
?disabled=${options.submitting || options.messageLocked}
title=${t("newSession.incognitoDescription")}
@click=${() => options.onToggleIncognito?.()}
>
<span aria-hidden="true">${icons.lock}</span>${t("newSession.incognito")}
</button>
${options.draftAvailable
? renderVisibilityPill({
mode: "draft",
icon: "👻",
label: t("newSession.draft"),
description: t("newSession.draftDescription"),
options,
})
: nothing}
${renderVisibilityPill({
mode: "incognito",
icon: icons.lock,
label: t("newSession.incognito"),
description: t("newSession.incognitoDescription"),
options,
})}
</div>
</div>
${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,
});
}
@@ -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",
+9 -4
View File
@@ -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<string, unknown> {
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 } : {}),
+17 -28
View File
@@ -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(),
@@ -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<HTMLInputElement>('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);
});
});
@@ -31,21 +31,3 @@ export function renderAgentSelect(params: {
</span>
`;
}
export function renderStartAsDraftToggle(params: {
checked: boolean;
disabled: boolean;
onChange: (checked: boolean) => void;
}) {
return html`<label class="new-session-page__trigger new-session-page__draft-toggle">
<input
type="checkbox"
.checked=${params.checked}
?disabled=${params.disabled}
@change=${(event: Event) =>
params.onChange((event.currentTarget as HTMLInputElement).checked)}
/>
<span aria-hidden="true">👻</span>
<span>${t("newSession.startAsDraft")}</span>
</label>`;
}
+6 -21
View File
@@ -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;