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; }