From 5b5fdacfdaa87c6f8748895e2a765a7c7779dc23 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Tue, 25 Aug 2026 01:34:09 -0700 Subject: [PATCH] feat(ui): dock the session progress card beside a wide composer (#129141) * feat(ui): dock the session progress card beside a wide composer The progress card had two placements: the companion rail when that side panel is open, otherwise a collapsed one-line bar stacked inside the composer box. On a wide chat the composer stays centered at the transcript width, so the space either side of it sits empty while the card is squeezed into the composer. Add a third placement. When the measured free gutter beside the composer is at least 280px, the card docks into it with its full checklist expanded; below that it falls back to the existing composer bar, and an open companion rail still wins. Exactly one placement renders at a time, now expressed as a closed {card, placement} prop so the composer bar and the dock cannot both draw the same card. The gutter is measured from the DOM by a small ResizeObserver controller rather than derived from the pane width: the transcript width is a browser-local setting in arbitrary CSS units, and an open side panel shrinks the conversation column without changing the pane. The dock is positioned absolutely in that gutter, so the transcript and composer never shift when a card appears or is dismissed, and its inline-start edge repeats the composer's own half-width formula through a shared --chat-composer-side-inset token so the two stay in agreement. * fix(ui): keep the new-session composer sized outside the chat surface The composer shell tokens are declared on .chat, but the new-session page reuses .agent-chat__composer-shell outside it. There the var() had no value, so the whole width declaration was invalid and dropped, and the composer stretched full-width instead of holding its 48rem centered box. Give both tokens their literal fallback at the use site, matching the neighbouring --chat-thread-max-width. The custodian surface was already immune because it overrides width outright. Caught by ui/src/e2e/new-session-page.places.e2e.test.ts, which is the regression test for this: it failed on the previous head and passes now. --- docs/tools/progress-card.md | 5 +- ui/src/components/session-progress-card.ts | 2 +- ...ession-progress-live-placement.e2e.test.ts | 28 +++++++ ui/src/pages/chat/chat-composer-gutter.ts | 75 +++++++++++++++++++ ui/src/pages/chat/chat-pane-base.ts | 2 + ui/src/pages/chat/chat-pane-layout-render.ts | 7 +- ui/src/pages/chat/chat-pane-rails.ts | 30 +++++++- ui/src/pages/chat/chat-pane-render.ts | 36 +++++---- ui/src/pages/chat/chat-view.ts | 14 +++- ui/src/styles/chat/layout.css | 10 ++- ui/src/styles/chat/progress-card.css | 31 +++++++- 11 files changed, 212 insertions(+), 28 deletions(-) create mode 100644 ui/src/pages/chat/chat-composer-gutter.ts diff --git a/docs/tools/progress-card.md b/docs/tools/progress-card.md index e23a5ff4fd89..fe98001f3347 100644 --- a/docs/tools/progress-card.md +++ b/docs/tools/progress-card.md @@ -86,9 +86,10 @@ An empty plan plus empty or whitespace-only Markdown also clears it. A successfu The current chat shows exactly one live card: - When the session rail is visible, the card appears in the rail. -- At narrow widths where the rail is hidden, the card appears in the collapsible surface beside the composer. +- Otherwise, when the chat is wide enough that the centered composer leaves a free gutter, the card docks in that space beside the composer with its full checklist expanded. +- At narrower widths the card appears in the collapsible surface inside the composer. -The two placements are mutually exclusive. Hover a session row in the sidebar or a session-reference link in chat to see the same card for that session. All card placements read the same Gateway-backed state and refresh after `progressCard.changed` notifications. +The placements are mutually exclusive. Hover a session row in the sidebar or a session-reference link in chat to see the same card for that session. All card placements read the same Gateway-backed state and refresh after `progressCard.changed` notifications. ## Pin the card to the dashboard diff --git a/ui/src/components/session-progress-card.ts b/ui/src/components/session-progress-card.ts index 69eb863e4e68..793fec7fef3d 100644 --- a/ui/src/components/session-progress-card.ts +++ b/ui/src/components/session-progress-card.ts @@ -5,7 +5,7 @@ import { t } from "../i18n/index.ts"; import { icons } from "./icons.ts"; import { toSanitizedMarkdownHtml } from "./markdown.ts"; -type SessionProgressCardPlacement = "board" | "composer" | "hovercard" | "rail"; +type SessionProgressCardPlacement = "board" | "composer" | "dock" | "hovercard" | "rail"; const STATUS_LABEL_KEYS: Record[0]> = { completed: "sessionProgressCard.status.completed", diff --git a/ui/src/e2e/session-progress-live-placement.e2e.test.ts b/ui/src/e2e/session-progress-live-placement.e2e.test.ts index c9eda8d72c18..b596b0e27c6a 100644 --- a/ui/src/e2e/session-progress-live-placement.e2e.test.ts +++ b/ui/src/e2e/session-progress-live-placement.e2e.test.ts @@ -98,6 +98,34 @@ suite.define(() => { await expect.poll(() => gateway.getRequests("progressCard.get")).toHaveLength(1); const visiblePane = page.locator("openclaw-chat-pane.chat-pane-cache__pane--visible"); + // Wide enough for the composer gutter to hold the card: it docks beside + // the composer instead of stacking inside it. + await page.setViewportSize({ height: 900, width: 1600 }); + const dock = visiblePane.locator('[data-progress-card-placement="dock"]'); + await expect.poll(() => dock.count()).toBe(1); + await expect + .poll(() => visiblePane.locator('[data-progress-card-placement="composer"]').count()) + .toBe(0); + await expect + .poll(async () => { + const dockBounds = await dock.boundingBox(); + const composerBounds = await visiblePane + .locator(".agent-chat__composer-shell") + .boundingBox(); + if (!dockBounds || !composerBounds) { + return false; + } + return ( + dockBounds.x >= composerBounds.x + composerBounds.width && + Math.abs( + dockBounds.y + dockBounds.height - (composerBounds.y + composerBounds.height), + ) <= 1 + ); + }) + .toBe(true); + await captureProof(page, "dock-beside-composer.png"); + + await page.setViewportSize({ height: 900, width: 1280 }); await openChatSidePanelType(page, "Side chat"); await expect .poll(() => visiblePane.locator('[data-progress-card-placement="rail"]').count()) diff --git a/ui/src/pages/chat/chat-composer-gutter.ts b/ui/src/pages/chat/chat-composer-gutter.ts new file mode 100644 index 000000000000..3feec45d2487 --- /dev/null +++ b/ui/src/pages/chat/chat-composer-gutter.ts @@ -0,0 +1,75 @@ +import type { ReactiveController, ReactiveControllerHost } from "lit"; + +type ComposerGutterHost = ReactiveControllerHost & ParentNode; + +/** + * Tracks the free space between the centered composer and one pane edge. + * Measured from the DOM rather than derived from the pane width: the transcript + * width is a user setting in arbitrary CSS units, and an open side panel shrinks + * the conversation column without changing the pane. The progress card docks + * into that gutter once it fits. + */ +export class ComposerGutterController implements ReactiveController { + private observer: ResizeObserver | null = null; + private targets: readonly Element[] = []; + private measured = 0; + + constructor(private readonly host: ComposerGutterHost) { + host.addController(this); + } + + get width(): number { + return this.measured; + } + + hostUpdated(): void { + if (typeof ResizeObserver !== "function") { + return; + } + const shell = this.host.querySelector(".agent-chat__composer-shell"); + const conversation = shell?.parentElement ?? null; + const targets = shell && conversation ? [conversation, shell] : []; + const unchanged = + targets.length === this.targets.length && + targets.every((target, index) => target === this.targets[index]); + if (unchanged) { + return; + } + this.observer?.disconnect(); + this.targets = targets; + if (targets.length === 0) { + return; + } + // Both edges move the gutter: the conversation column follows the side + // panel, and the composer itself resizes with the transcript-width setting. + this.observer ??= new ResizeObserver(() => this.measure()); + for (const target of targets) { + this.observer.observe(target); + } + } + + hostDisconnected(): void { + this.observer?.disconnect(); + this.observer = null; + this.targets = []; + } + + private measure(): void { + const shell = this.targets[1]; + const conversation = this.targets[0]; + if (!(shell instanceof HTMLElement) || !(conversation instanceof HTMLElement)) { + return; + } + const conversationWidth = conversation.clientWidth; + // Hidden panes (split view, pane cache) report 0; keep the last real width. + if (conversationWidth <= 0) { + return; + } + const gutter = Math.max(0, Math.round((conversationWidth - shell.clientWidth) / 2)); + if (gutter === this.measured) { + return; + } + this.measured = gutter; + this.host.requestUpdate(); + } +} diff --git a/ui/src/pages/chat/chat-pane-base.ts b/ui/src/pages/chat/chat-pane-base.ts index 76fbd8556085..9a2231e55b75 100644 --- a/ui/src/pages/chat/chat-pane-base.ts +++ b/ui/src/pages/chat/chat-pane-base.ts @@ -44,6 +44,7 @@ import { PollController } from "../../lit/poll-controller.ts"; import { SubscriptionsController } from "../../lit/subscriptions-controller.ts"; import type { BoardChatDockSize } from "./board-session-surface.ts"; import { ChatComposerCapabilityHost } from "./chat-composer-capability-host.ts"; +import { ComposerGutterController } from "./chat-composer-gutter.ts"; import { sendSessionObserverVisibility } from "./chat-observer.ts"; import { boardChatDockLayout, @@ -195,6 +196,7 @@ export abstract class ChatPaneBase extends OpenClawLightDomElement { this.requestUpdate(), ); protected readonly transcript = new ChatTranscriptController(this); + protected readonly composerGutter = new ComposerGutterController(this); protected readonly taskSidebarTranscript = new ChatTranscriptController(this); protected readonly progressCard = new SessionProgressCardController(this, { gateway: () => this.context?.gateway, diff --git a/ui/src/pages/chat/chat-pane-layout-render.ts b/ui/src/pages/chat/chat-pane-layout-render.ts index 548f1222d543..ac3cb4eee7fd 100644 --- a/ui/src/pages/chat/chat-pane-layout-render.ts +++ b/ui/src/pages/chat/chat-pane-layout-render.ts @@ -12,6 +12,7 @@ import { sidebarPanelDefinitions, sidebarPanelTemplates, } from "./chat-pane-embedded-panels.ts"; +import type { ChatProgressCardPlacement } from "./chat-pane-rails.ts"; import type { ResolvedBoardView } from "./chat-pane-shared.ts"; import { renderSidebarRegion, sidebarRegionCallbacks } from "./chat-pane-sidebar-layout.ts"; import type { ChatPageHost } from "./chat-state-host.ts"; @@ -37,7 +38,7 @@ type ChatPaneLayoutRenderParams = { currentAgentId: string; board: ResolvedBoardView; sidebarLayout: SidebarLayout; - progressCardInRail: boolean; + progressCardPlacement: ChatProgressCardPlacement; onDismissProgressCard?: (card: ProgressCard) => void; sessionWorkspace: SessionWorkspaceProps; backgroundTasks: BackgroundTasksProps; @@ -59,7 +60,7 @@ export abstract class ChatPaneLayoutRender extends ChatPaneBrowserAnnotationRend currentAgentId, board, sidebarLayout, - progressCardInRail, + progressCardPlacement, onDismissProgressCard, sessionWorkspace, backgroundTasks, @@ -125,7 +126,7 @@ export abstract class ChatPaneLayoutRender extends ChatPaneBrowserAnnotationRend startedAt: selectedSession?.startedAt ?? state.chatStreamStartedAt ?? undefined, lastReadAt: selectedSession?.lastReadAt, pullRequests: this.sessionPullRequests, - progressCard: progressCardInRail ? this.progressCard.card : null, + progressCard: progressCardPlacement === "rail" ? this.progressCard.card : null, onDismissProgressCard, companion: companionThread, onCompanionSubmit: (question) => void this.submitSessionCompanionQuestion(question), diff --git a/ui/src/pages/chat/chat-pane-rails.ts b/ui/src/pages/chat/chat-pane-rails.ts index 0339f15057e1..622d302bfac7 100644 --- a/ui/src/pages/chat/chat-pane-rails.ts +++ b/ui/src/pages/chat/chat-pane-rails.ts @@ -14,11 +14,30 @@ import { type ChatPaneSidebarLayout = Parameters[0]; type ChatPaneGatewaySnapshot = Parameters[0]; +export type ChatProgressCardPlacement = "composer" | "dock" | "rail"; + +/* Narrowest gutter that still holds a readable card: the dock keeps a 12px gap + * from the composer and clears the transcript scrollbar strip on the far side, + * so this leaves it ~250px of its own. */ +const PROGRESS_CARD_DOCK_MIN_GUTTER_PX = 280; + +/** Picks the single live progress-card placement for one chat pane. */ +function chatProgressCardPlacement(params: { + companionRailVisible: boolean; + composerGutter: number; +}): ChatProgressCardPlacement { + if (params.companionRailVisible) { + return "rail"; + } + return params.composerGutter >= PROGRESS_CARD_DOCK_MIN_GUTTER_PX ? "dock" : "composer"; +} + /** Builds the two rail models and their shared sidebar slot controls. */ export function createChatPaneRails(params: { state: ChatPageHost; sidebarLayout: ChatPaneSidebarLayout; paneWidth: number; + composerGutter: number; presentationId: string; presented: boolean; gatewaySnapshot: ChatPaneGatewaySnapshot; @@ -70,14 +89,17 @@ export function createChatPaneRails(params: { narrowLayout: false, onToggleCollapsed: () => togglePanelSlot("tasks"), }; - const progressCardInRail = - params.paneWidth >= SIDEBAR_NARROW_BREAKPOINT_PX && - isSidebarSlotVisible(sidebarLayout, "companion"); + const progressCardPlacement = chatProgressCardPlacement({ + companionRailVisible: + params.paneWidth >= SIDEBAR_NARROW_BREAKPOINT_PX && + isSidebarSlotVisible(sidebarLayout, "companion"), + composerGutter: params.composerGutter, + }); return { backgroundTasks, closePanelSlot, openPanelSlot, - progressCardInRail, + progressCardPlacement, sessionWorkspace, }; } diff --git a/ui/src/pages/chat/chat-pane-render.ts b/ui/src/pages/chat/chat-pane-render.ts index bd25b11d1f4b..2da1405c9419 100644 --- a/ui/src/pages/chat/chat-pane-render.ts +++ b/ui/src/pages/chat/chat-pane-render.ts @@ -1,3 +1,4 @@ +import type { ProgressCard } from "@openclaw/gateway-protocol"; import { html, nothing } from "lit"; import { findInlineApproval } from "../../app/approval-presentation.ts"; import { hasOperatorAdminAccess, hasOperatorWriteAccess } from "../../app/operator-access.ts"; @@ -181,7 +182,7 @@ export class ChatPane extends ChatPaneLayoutRender { hasOperatorWriteAccess(gatewaySnapshot.hello?.auth ?? null) && isGatewayMethodAdvertised(gatewaySnapshot, "progressCard.put") === true; const onDismissProgressCard = canDismissProgressCard - ? (card: NonNullable) => { + ? (card: ProgressCard) => { void this.progressCard .dismiss(card) .catch(() => showToast({ message: t("sessionProgressCard.dismissFailed") })); @@ -225,16 +226,22 @@ export class ChatPane extends ChatPaneLayoutRender { ? t("chat.catalog.remoteViewOnly") : t("chat.catalog.unsupportedViewOnly") : null; - const { backgroundTasks, closePanelSlot, openPanelSlot, progressCardInRail, sessionWorkspace } = - createChatPaneRails({ - state, - sidebarLayout, - paneWidth: this.paneWidth, - presentationId: this.presentationId, - presented: this.presented, - gatewaySnapshot, - setObserverVisibility: this.setSessionObserverVisibility, - }); + const { + backgroundTasks, + closePanelSlot, + openPanelSlot, + progressCardPlacement, + sessionWorkspace, + } = createChatPaneRails({ + state, + sidebarLayout, + paneWidth: this.paneWidth, + composerGutter: this.composerGutter.width, + presentationId: this.presentationId, + presented: this.presented, + gatewaySnapshot, + setObserverVisibility: this.setSessionObserverVisibility, + }); const selfUser = resolveCurrentSelfUser({ snapshotUser: gatewaySnapshot.selfUser, presenceEntries: readPresenceEntries(gatewaySnapshot.hello?.snapshot), @@ -315,7 +322,10 @@ export class ChatPane extends ChatPaneLayoutRender { waitingApproval: state.waitingApprovalStatuses.size > 0, compactionStatus: state.compactionStatus, fallbackStatus: state.fallbackStatus, - progressCard: progressCardInRail ? null : this.progressCard.card, + progressCard: + progressCardPlacement === "rail" || !this.progressCard.card + ? null + : { card: this.progressCard.card, placement: progressCardPlacement }, onDismissProgressCard, gatewayQuestionPrompts: catalogKey || sessionParticipationBlocked ? [] : this.questionPrompts, onGatewayQuestionChange: () => { @@ -639,7 +649,7 @@ export class ChatPane extends ChatPaneLayoutRender { currentAgentId, board, sidebarLayout, - progressCardInRail, + progressCardPlacement, onDismissProgressCard, sessionWorkspace, backgroundTasks, diff --git a/ui/src/pages/chat/chat-view.ts b/ui/src/pages/chat/chat-view.ts index d13fabbcae8b..05fa37abc09e 100644 --- a/ui/src/pages/chat/chat-view.ts +++ b/ui/src/pages/chat/chat-view.ts @@ -22,6 +22,7 @@ import { icons } from "../../components/icons.ts"; import type { ImageLightboxItem } from "../../components/image-lightbox.ts"; import type { SessionLinkTarget } from "../../components/markdown-session-links.ts"; import type { PersonActivityRouting } from "../../components/person-activity-link.ts"; +import { renderSessionProgressCard } from "../../components/session-progress-card.ts"; import { t } from "../../i18n/index.ts"; import type { BoardProvider } from "../../lib/board/provider.ts"; import type { @@ -104,7 +105,9 @@ export type ChatProps = ChatTaskSuggestionTrayProps & waitingApproval?: boolean; compactionStatus?: CompactionStatus | null; fallbackStatus?: FallbackStatus | null; - progressCard?: ProgressCard | null; + /* One live placement per view: the pane picks it, so the composer bar and + * the right-gutter dock can never both render the same card. */ + progressCard?: { card: ProgressCard; placement: "composer" | "dock" } | null; onDismissProgressCard?: (card: ProgressCard) => void; gatewayQuestionPrompts?: readonly QuestionPrompt[]; onGatewayQuestionChange?: () => void; @@ -409,7 +412,7 @@ export function renderChat(props: ChatProps) { waitingApproval: props.waitingApproval, compactionStatus: props.compactionStatus, fallbackStatus: props.fallbackStatus, - progressCard: props.progressCard, + progressCard: props.progressCard?.placement === "composer" ? props.progressCard.card : null, onDismissProgressCard: props.onDismissProgressCard, gatewayQuestionPrompts: props.gatewayQuestionPrompts, messages: props.messages, @@ -602,6 +605,13 @@ export function renderChat(props: ChatProps) { sessions: props.swarmSessions ?? [], sessionKey: props.sessionKey, })} + ${props.progressCard?.placement === "dock" + ? renderSessionProgressCard( + props.progressCard.card, + "dock", + props.onDismissProgressCard, + ) + : nothing} ${showModelSetupSplash ? nothing : chatColumnFooter} diff --git a/ui/src/styles/chat/layout.css b/ui/src/styles/chat/layout.css index 59f7e2a84411..a05488c94a7b 100644 --- a/ui/src/styles/chat/layout.css +++ b/ui/src/styles/chat/layout.css @@ -14,6 +14,10 @@ openclaw-chat-page { --chat-thread-max-width: 48rem; /* Keep overlays clear of the edge-hugging scrollbar and add room on wide screens. */ --chat-thread-gutter: 18px; + /* Composer box insets. The docked progress card derives its own edge from + both, so the two never overlap and their bottoms stay on one line. */ + --chat-composer-side-inset: 36px; + --chat-composer-bottom-gap: 14px; --chat-thread-side-inset: clamp(6px, 1vw, 12px); position: relative; @@ -1686,9 +1690,11 @@ button.chat-reply-preview--message:disabled { grid-template-columns: minmax(0, 1fr); align-items: end; gap: 6px; - width: calc(100% - 36px); + /* The new-session page reuses this shell outside .chat, where the tokens are + not defined; the fallbacks keep its geometry identical there. */ + width: calc(100% - var(--chat-composer-side-inset, 36px)); max-width: var(--chat-thread-max-width, 48rem); - margin: 8px auto 14px; + margin: 8px auto var(--chat-composer-bottom-gap, 14px); } /* Floating tray pinned to the conversation's top-right (the positioned diff --git a/ui/src/styles/chat/progress-card.css b/ui/src/styles/chat/progress-card.css index 81b08d7987a4..c1b5dd797722 100644 --- a/ui/src/styles/chat/progress-card.css +++ b/ui/src/styles/chat/progress-card.css @@ -400,6 +400,34 @@ animation: fade-in 0.2s var(--ease-out); } +/* Docked in the conversation's right gutter, beside the centered composer. + The inline-start edge repeats the composer's own half-width formula + (.agent-chat__composer-shell, capped at the transcript width) so the two + never overlap at any transcript-width setting; the pane only renders this + placement once the measured gutter can hold the card. */ +.session-progress-card--dock { + position: absolute; + z-index: 3; + inset-block-end: var(--chat-composer-bottom-gap, 14px); + inset-inline-end: var(--chat-thread-gutter); + inset-inline-start: calc( + 50% + + min( + 50% - var(--chat-composer-side-inset, 36px) / 2, + var(--chat-thread-max-width, 48rem) / 2 + ) + + var(--space-3) + ); + max-height: min(46%, 420px); + overflow-y: auto; + padding: 10px 12px; + border: 1px solid color-mix(in srgb, var(--accent) 24%, var(--border)); + border-radius: var(--radius-md); + background: color-mix(in srgb, var(--accent-subtle) 42%, var(--panel-strong)); + box-shadow: var(--shadow-sm); + animation: fade-in 0.2s var(--ease-out); +} + .session-progress-card--rail { flex: 0 0 auto; margin: 10px 14px 0; @@ -614,7 +642,8 @@ } @media (prefers-reduced-motion: reduce) { - .session-progress-card--composer { + .session-progress-card--composer, + .session-progress-card--dock { animation: none; transition: none; }