From fecd1ca19552fdff334b0d502f870ce03d4f6d10 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sun, 12 Jul 2026 09:22:51 +0100 Subject: [PATCH] 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 --- ui/src/app/app-host.ts | 8 +- ui/src/e2e/new-session-page.e2e.test.ts | 17 +++ ui/src/pages/chat/components/chat-welcome.ts | 25 +++-- ui/src/pages/new-session/new-session-page.ts | 74 ++++++------- ui/src/styles/new-session.css | 104 ++++++++++++------- 5 files changed, 137 insertions(+), 91 deletions(-) diff --git a/ui/src/app/app-host.ts b/ui/src/app/app-host.ts index d5a95bb889d4..8477db892201 100644 --- a/ui/src/app/app-host.ts +++ b/ui/src/app/app-host.ts @@ -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` this.navigate(routeId)} @@ -928,7 +931,7 @@ class OpenClawShell extends OpenClawLightDomElement { .onSlashCommand=${this.handleCommandPaletteSlashCommand} >
{ // "+" navigates to the same route with ?agent=). 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); diff --git a/ui/src/pages/chat/components/chat-welcome.ts b/ui/src/pages/chat/components/chat-welcome.ts index 4bd572f64072..982e7fb944c5 100644 --- a/ui/src/pages/chat/components/chat-welcome.ts +++ b/ui/src/pages/chat/components/chat-welcome.ts @@ -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, ): 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, -) { +function renderWelcomeSuggestions(props: Pick) { return html`
${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 & { 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")} / ${t( - "chat.welcome.hintAfterShortcut", - )}`, + hint: + props.hint ?? + html`${t("chat.welcome.hintBeforeShortcut")} / ${t( + "chat.welcome.hintAfterShortcut", + )}`, })} + ${props.composer ?? nothing} ${recentSessions.length > 0 ? renderWelcomeRecentSessions(recentSessions, props.onOpenSession) : renderWelcomeSuggestions(props)} diff --git a/ui/src/pages/new-session/new-session-page.ts b/ui/src/pages/new-session/new-session-page.ts index 3ef211393b2c..6f3152418372 100644 --- a/ui/src/pages/new-session/new-session-page.ts +++ b/ui/src/pages/new-session/new-session-page.ts @@ -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` -
- ${renderWelcomeHero({ - assistantName: identity?.name ?? agent?.name ?? agent?.id ?? "", - assistantAvatar: identity?.avatar ?? identity?.emoji ?? null, - assistantAvatarUrl: identity?.avatarUrl ?? null, - hint: t("newSession.hint"), - })} +
+ ${this.renderTargetBar()} ${this.renderBrowser()} + ${worktreeNameInvalid + ? html`
${t("newSession.worktreeNameInvalid")}
` + : nothing} + ${this.error ? html`
${this.error}
` : nothing} + ${this.renderComposer()}
`; } - /** 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`
-
- ${this.renderHero()} ${this.renderTargetBar()} ${this.renderBrowser()} - ${worktreeNameInvalid - ? html`
- ${t("newSession.worktreeNameInvalid")} -
` - : nothing} - ${this.error ? html`
${this.error}
` : nothing} - ${this.renderComposer()} ${this.renderRecents()} +
+ ${this.renderWelcome()}
`; @@ -801,7 +791,7 @@ class NewSessionPage extends OpenClawLightDomElement {