improve(ui): unify the New session draft with the start-screen layout (#105069)

The /new draft page now renders the same welcome block as the empty-chat
start screen, with the draft controls folded in launcher-style: hero on
top, a quiet borderless target row (agent, node, folder, worktree)
directly above the mid-screen composer, and the recent chats below. The
page reuses renderWelcomeState via hint/composer slots so the surfaces
cannot drift apart; the shell treats /new as a chat-like route that owns
its scrolling, with the top inset doubling as the native titlebar drag
band. The welcome is content-sized on this route so a tall draft (open
folder browser, short window) stays scrollable from the top. Also
repairs the stale new-session e2e heading wait (the h1 was removed in

Closes #105068
This commit is contained in:
Peter Steinberger
2026-07-12 09:22:51 +01:00
committed by GitHub
parent 2af4871f63
commit fecd1ca195
5 changed files with 137 additions and 91 deletions

View File

@@ -918,6 +918,9 @@ class OpenClawShell extends OpenClawLightDomElement {
const shellWidth = Math.max(globalThis.innerWidth || 0, NAV_WIDTH_MAX);
// One storage read per render; theme.refresh() re-renders on pref changes.
const uiSettings = loadSettings();
// The new-session draft shares the chat layout: full-height pane that owns
// its scrolling and pins the composer dock to the bottom.
const chatLikeRoute = activeRoute === "chat" || activeRoute === "new-session";
return html`
<openclaw-command-palette
.onNavigate=${(routeId: RouteId) => this.navigate(routeId)}
@@ -928,7 +931,7 @@ class OpenClawShell extends OpenClawLightDomElement {
.onSlashCommand=${this.handleCommandPaletteSlashCommand}
></openclaw-command-palette>
<div
class="shell ${activeRoute === "chat" ? "shell--chat" : ""} ${navCollapsed
class="shell ${chatLikeRoute ? "shell--chat" : ""} ${navCollapsed
? "shell--nav-collapsed"
: ""} ${navDrawerOpen ? "shell--nav-drawer-open" : ""} ${this.onboarding
? "shell--onboarding"
@@ -1046,8 +1049,7 @@ class OpenClawShell extends OpenClawLightDomElement {
`
: nothing}
<main
class="content ${activeRoute === "chat" ? "content--chat" : ""} ${activeRoute ===
"workboard"
class="content ${chatLikeRoute ? "content--chat" : ""} ${activeRoute === "workboard"
? "content--workboard"
: ""}"
tabindex="-1"

View File

@@ -101,8 +101,25 @@ describeControlUiE2e("Control UI new-session page mocked Gateway E2E", () => {
// "+" navigates to the same route with ?agent=<id>).
const response = await page.goto(`${server.baseUrl}new`);
expect(response?.status()).toBe(200);
// The draft page shows the start-screen welcome hero for the agent.
await page.getByRole("heading", { name: "Main" }).waitFor();
await page.locator(".new-session-page__message").waitFor();
// Unified layout: the draft block (target row above the composer) sits
// inside the start-screen welcome, below the hero.
const heroBox = await page.locator(".agent-chat__welcome h2").boundingBox();
const targetsBox = await page.locator(".new-session-page__targets").boundingBox();
const composerBox = await page.locator(".new-session-page__composer").boundingBox();
expect(heroBox).not.toBeNull();
expect(targetsBox).not.toBeNull();
expect(composerBox).not.toBeNull();
expect((heroBox?.y ?? 0) + (heroBox?.height ?? 0)).toBeLessThanOrEqual(
(targetsBox?.y ?? 0) + 1,
);
expect((targetsBox?.y ?? 0) + (targetsBox?.height ?? 0)).toBeLessThanOrEqual(
(composerBox?.y ?? 0) + 1,
);
const folderInput = page.getByRole("textbox", { name: "Folder", exact: true });
await expect.poll(() => folderInput.inputValue()).toBe(WORKSPACE);

View File

@@ -27,6 +27,10 @@ type ChatWelcomeProps = {
assistantName: string;
assistantAvatar: string | null;
assistantAvatarUrl?: string | null;
/** Hero hint override; defaults to the chat slash-command hint. */
hint?: unknown;
/** Rendered between the hero and the recents (the new-session draft composer). */
composer?: unknown;
sessions?: SessionsListResult | null;
sessionKey?: string;
sessionHost?: UiSessionDefaultsHost | null;
@@ -67,7 +71,7 @@ export function resolveAssistantDisplayAvatar(
* minus channel-originated sessions — those live in their channel sections and
* are not something the user "starts" from here.
*/
export function selectWelcomeRecentSessions(
function selectWelcomeRecentSessions(
props: Pick<ChatWelcomeProps, "sessions" | "sessionKey" | "sessionHost">,
): GatewaySessionRow[] {
if (!props.sessions) {
@@ -110,7 +114,7 @@ function renderWelcomeClawd() {
`;
}
export function renderWelcomeRecentSessions(
function renderWelcomeRecentSessions(
rows: GatewaySessionRow[],
onOpenSession: ((sessionKey: string) => void) | undefined,
) {
@@ -133,9 +137,7 @@ export function renderWelcomeRecentSessions(
`;
}
export function renderWelcomeSuggestions(
props: Pick<ChatWelcomeProps, "onDraftChange" | "onSend">,
) {
function renderWelcomeSuggestions(props: Pick<ChatWelcomeProps, "onDraftChange" | "onSend">) {
return html`
<div class="agent-chat__suggestions">
${WELCOME_SUGGESTION_KEYS.map((key) => {
@@ -157,8 +159,7 @@ export function renderWelcomeSuggestions(
`;
}
/** Shared hero (avatar, name, hint) for the chat welcome and the new-session draft. */
export function renderWelcomeHero(
function renderWelcomeHero(
props: Pick<ChatWelcomeProps, "assistantName" | "assistantAvatar" | "assistantAvatarUrl"> & {
hint: unknown;
},
@@ -179,6 +180,7 @@ export function renderWelcomeHero(
`;
}
/** The start-screen welcome block, shared by the empty chat and the new-session draft. */
export function renderWelcomeState(props: ChatWelcomeProps) {
const recentSessions = selectWelcomeRecentSessions(props);
@@ -188,10 +190,13 @@ export function renderWelcomeState(props: ChatWelcomeProps) {
assistantName: props.assistantName,
assistantAvatar: props.assistantAvatar,
assistantAvatarUrl: props.assistantAvatarUrl,
hint: html`${t("chat.welcome.hintBeforeShortcut")} <kbd>/</kbd> ${t(
"chat.welcome.hintAfterShortcut",
)}`,
hint:
props.hint ??
html`${t("chat.welcome.hintBeforeShortcut")} <kbd>/</kbd> ${t(
"chat.welcome.hintAfterShortcut",
)}`,
})}
${props.composer ?? nothing}
${recentSessions.length > 0
? renderWelcomeRecentSessions(recentSessions, props.onOpenSession)
: renderWelcomeSuggestions(props)}

View File

@@ -5,6 +5,7 @@ import { html, nothing } from "lit";
import { property, state } from "lit/decorators.js";
import type { FsListDirResult } from "../../../../packages/gateway-protocol/src/index.js";
import { applicationContext, type ApplicationContext } from "../../app/context.ts";
import { beginNativeWindowDragFromTopInset } from "../../app/native-window-drag.ts";
import { hasOperatorAdminAccess } from "../../app/operator-access.ts";
import { loadSettings } from "../../app/settings.ts";
import { icons } from "../../components/icons.ts";
@@ -15,12 +16,7 @@ import { buildAgentMainSessionKey, normalizeAgentId } from "../../lib/sessions/s
import { normalizeOptionalString } from "../../lib/string-coerce.ts";
import { OpenClawLightDomElement } from "../../lit/openclaw-element.ts";
import { SubscriptionsController } from "../../lit/subscriptions-controller.ts";
import {
renderWelcomeHero,
renderWelcomeRecentSessions,
renderWelcomeSuggestions,
selectWelcomeRecentSessions,
} from "../chat/components/chat-welcome.ts";
import { renderWelcomeState } from "../chat/components/chat-welcome.ts";
import { buildDraftSessionCreateParams } from "./create-params.ts";
type NewSessionRouteData = { agentId?: string };
@@ -711,26 +707,35 @@ class NewSessionPage extends OpenClawLightDomElement {
`;
}
/** Same hero as the chat welcome screen, keyed to the draft's selected agent. */
private renderHero() {
const agent = this.selectedAgent();
const identity = agent?.identity;
/** Target row + composer, rendered mid-screen between the hero and recents. */
private renderDraftBlock() {
const worktreeNameInvalid =
this.worktree &&
this.worktreeName.trim() !== "" &&
!WORKTREE_NAME_PATTERN.test(this.worktreeName.trim());
return html`
<div class="agent-chat__welcome" style="--agent-color: var(--accent)">
${renderWelcomeHero({
assistantName: identity?.name ?? agent?.name ?? agent?.id ?? "",
assistantAvatar: identity?.avatar ?? identity?.emoji ?? null,
assistantAvatarUrl: identity?.avatarUrl ?? null,
hint: t("newSession.hint"),
})}
<div class="new-session-page__draft">
${this.renderTargetBar()} ${this.renderBrowser()}
${worktreeNameInvalid
? html`<div class="new-session-page__error">${t("newSession.worktreeNameInvalid")}</div>`
: nothing}
${this.error ? html`<div class="new-session-page__error">${this.error}</div>` : nothing}
${this.renderComposer()}
</div>
`;
}
/** Recent chats to jump back into, or the canned starters when none exist. */
private renderRecents() {
/** Same welcome block as the empty-chat start screen, keyed to the draft's agent. */
private renderWelcome() {
const agent = this.selectedAgent();
const identity = agent?.identity;
const gateway = this.context?.gateway.snapshot;
const recents = selectWelcomeRecentSessions({
return renderWelcomeState({
assistantName: identity?.name ?? agent?.name ?? agent?.id ?? "",
assistantAvatar: identity?.avatar ?? identity?.emoji ?? null,
assistantAvatarUrl: identity?.avatarUrl ?? null,
hint: t("newSession.hint"),
composer: this.renderDraftBlock(),
sessions: this.context?.sessions.state.result,
sessionKey: buildAgentMainSessionKey({
agentId: this.agentId || "main",
@@ -741,37 +746,22 @@ class NewSessionPage extends OpenClawLightDomElement {
agentsList: this.context?.agents.state.agentsList ?? null,
hello: gateway?.hello ?? null,
},
});
if (recents.length > 0) {
return renderWelcomeRecentSessions(recents, (sessionKey) => {
this.context?.gateway.setSessionKey(sessionKey);
this.context?.navigate("chat", { search: searchForSession(sessionKey) });
});
}
return renderWelcomeSuggestions({
onDraftChange: (next) => {
this.message = next;
},
onSend: () => void this.submit(),
onOpenSession: (sessionKey) => {
this.context?.gateway.setSessionKey(sessionKey);
this.context?.navigate("chat", { search: searchForSession(sessionKey) });
},
});
}
override render() {
const worktreeNameInvalid =
this.worktree &&
this.worktreeName.trim() !== "" &&
!WORKTREE_NAME_PATTERN.test(this.worktreeName.trim());
return html`
<div class="new-session-page">
<div class="new-session-page__inner">
${this.renderHero()} ${this.renderTargetBar()} ${this.renderBrowser()}
${worktreeNameInvalid
? html`<div class="new-session-page__error">
${t("newSession.worktreeNameInvalid")}
</div>`
: nothing}
${this.error ? html`<div class="new-session-page__error">${this.error}</div>` : nothing}
${this.renderComposer()} ${this.renderRecents()}
<div class="new-session-page__scroll" @mousedown=${beginNativeWindowDragFromTopInset}>
${this.renderWelcome()}
</div>
</div>
`;
@@ -801,7 +791,7 @@ class NewSessionPage extends OpenClawLightDomElement {
<div class="agent-chat__composer-combobox">
<textarea
class="new-session-page__message"
rows="4"
rows="3"
placeholder=${t("newSession.messagePlaceholder")}
.value=${this.message}
@input=${(event: Event) => {

View File

@@ -1,52 +1,78 @@
/* Full-page new-session draft: target chips above the shared chat composer
shell (.agent-chat__input). Corner radii stay on the composer's radius
tokens so the draft picker matches the chat picker. */
/* Full-page new-session draft: the start-screen welcome centers in the pane
with the draft block (clean target row + composer) between the hero and the
recent chats, so the draft reads as the start screen plus "where to start"
controls. */
openclaw-new-session-page {
display: flex;
flex: 1 1 0;
min-width: 0;
min-height: 0;
}
.new-session-page {
display: flex;
justify-content: center;
min-height: 100%;
padding: clamp(24px, 10vh, 96px) 20px 32px;
flex-direction: column;
flex: 1 1 0;
width: 100%;
min-width: 0;
min-height: 0;
}
.new-session-page__inner {
/* Mirrors .chat-thread: the top inset doubles as the native macOS titlebar
drag band (see the scroll container's mousedown handler). */
.new-session-page__scroll {
display: flex;
flex-direction: column;
gap: 12px;
width: min(720px, 100%);
flex: 1 1 0;
min-height: 0;
overflow-y: auto;
overflow-x: hidden;
padding: clamp(44px, 5vh, 52px) clamp(6px, 1vw, 12px) 6px;
}
/* The draft page opens with the same hero as the chat welcome screen. The
shared .agent-chat__welcome sizing is built for a full-height chat pane,
so neutralize its stretch/centering inside this column. */
/* The shared welcome stretches (flex: 1) and centers via justify-content,
which clips overflow above the scroll origin once the draft content is
taller than the pane (short window, open folder browser). Content-size it
here; its margin: auto still centers whenever there is spare room. */
.new-session-page .agent-chat__welcome {
flex: initial;
width: 100%;
margin: 0;
padding: 0 0 8px;
flex: 0 0 auto;
min-height: auto;
}
.new-session-page .agent-chat__recents,
.new-session-page .agent-chat__suggestions {
align-self: center;
margin-top: 4px;
/* Draft block inside the centered welcome column; the welcome centers text,
so reset alignment for the controls and composer. */
.new-session-page__draft {
display: flex;
flex-direction: column;
gap: 8px;
width: 100%;
margin-top: 6px;
text-align: left;
}
/* Cursor-style quiet target row: plain muted text controls with the native
select chevrons, no chip borders; hover/focus restores full contrast. */
.new-session-page__targets {
display: flex;
flex-wrap: wrap;
gap: 6px;
align-items: center;
gap: 4px 16px;
padding: 0 2px;
}
.new-session-page__target {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 4px 10px;
border: 1px solid var(--border);
border-radius: var(--radius-md);
background: color-mix(in srgb, var(--bg-elevated) 70%, transparent);
gap: 5px;
padding: 2px 0;
border: 0;
background: none;
font-size: 12px;
color: var(--muted);
}
.new-session-page__target:hover,
.new-session-page__target:focus-within {
color: var(--text);
}
@@ -54,19 +80,32 @@
.new-session-page__target input[type="text"] {
border: 0;
background: transparent;
color: var(--text);
color: inherit;
font-size: 12px;
outline: none;
cursor: pointer;
}
.new-session-page__target input[type="text"] {
cursor: text;
}
.new-session-page__target input[type="text"]::placeholder {
color: inherit;
}
/* Compact by default so the browse button hugs the path; grows only as far
as a long path needs, then ellipsizes via the input's own scroll. */
.new-session-page__target--folder {
flex: 1 1 220px;
flex: 0 1 auto;
min-width: 0;
}
.new-session-page__target--folder input {
flex: 1 1 auto;
width: 150px;
max-width: 40vw;
min-width: 0;
field-sizing: content;
}
.new-session-page__target-icon {
@@ -197,13 +236,6 @@
color: var(--danger);
}
/* Larger draft box than the chat composer default; wins over the shared
.agent-chat__composer-combobox > textarea sizing by specificity. */
.new-session-page__composer textarea.new-session-page__message {
min-height: 112px;
max-height: 40vh;
}
.new-session-page__browser-use {
padding: 6px 14px;
border: 1px solid transparent;