mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-26 04:15:48 -06:00
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.
This commit is contained in:
committed by
GitHub
parent
51825130d8
commit
5b5fdacfda
@@ -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
|
||||
|
||||
|
||||
@@ -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<ProgressCardStep["status"], Parameters<typeof t>[0]> = {
|
||||
completed: "sessionProgressCard.status.completed",
|
||||
|
||||
@@ -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())
|
||||
|
||||
@@ -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<HTMLElement>(".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();
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -14,11 +14,30 @@ import {
|
||||
type ChatPaneSidebarLayout = Parameters<typeof isSidebarSlotVisible>[0];
|
||||
type ChatPaneGatewaySnapshot = Parameters<typeof isDesktopPanelAvailable>[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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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<ChatProps["progressCard"]>) => {
|
||||
? (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,
|
||||
|
||||
@@ -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}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user