From d32a290b7c208d4a831b3604b89844267e4e1d1c Mon Sep 17 00:00:00 2001 From: joshavant <830519+joshavant@users.noreply.github.com> Date: Tue, 11 Aug 2026 09:18:21 -0500 Subject: [PATCH] fix(ui): preserve live Activity viewport --- ui/src/e2e/activity-run-inspector.e2e.test.ts | 91 +++++++++++++++++++ ui/src/pages/activity/run-inspector.css | 1 + ui/src/styles/activity.css | 36 ++++++++ 3 files changed, 128 insertions(+) diff --git a/ui/src/e2e/activity-run-inspector.e2e.test.ts b/ui/src/e2e/activity-run-inspector.e2e.test.ts index 109e5e1ad7f5..86033f6cced3 100644 --- a/ui/src/e2e/activity-run-inspector.e2e.test.ts +++ b/ui/src/e2e/activity-run-inspector.e2e.test.ts @@ -272,6 +272,97 @@ describeControlUiE2e("Control UI durable Activity run inspector", () => { } }); + it("keeps a populated Live activity stream bounded after adding the mode switcher", async () => { + const context = await newContext(); + const page = await context.newPage(); + const gateway = await installMockGateway(page, { sessionKey: "main" }); + + try { + await page.goto(`${server.baseUrl}activity`); + await page.getByText("No activity yet.", { exact: true }).waitFor(); + + for (let index = 0; index < 40; index += 1) { + await gateway.emitGatewayEvent("agent", { + runId: `run-layout-${index}`, + seq: 1, + stream: "tool", + ts: Date.now() + index, + sessionKey: "main", + data: { + phase: "start", + name: `layout_tool_${index}`, + toolCallId: `tool-layout-${index}`, + args: {}, + }, + }); + } + + const stream = page.locator(".activity-stream"); + await expect.poll(() => page.locator(".activity-entry").count()).toBe(40); + const layout = await page.locator("#activity-mode-panel").evaluate((modePanel) => { + const livePanel = modePanel.querySelector("#activity-live-panel"); + const stream = modePanel.querySelector(".activity-stream"); + if (!livePanel || !stream) { + throw new Error("Live Activity layout is incomplete"); + } + const modeStyle = getComputedStyle(modePanel); + const liveStyle = getComputedStyle(livePanel); + const workspace = modePanel.closest(".settings-workspace--fill-height"); + return { + documentScrollHeight: document.documentElement.scrollHeight, + liveDisplay: liveStyle.display, + liveFlexGrow: liveStyle.flexGrow, + modeDisplay: modeStyle.display, + modeFlexGrow: modeStyle.flexGrow, + streamBottom: stream.getBoundingClientRect().bottom, + streamClientHeight: stream.clientHeight, + streamScrollHeight: stream.scrollHeight, + viewportHeight: window.innerHeight, + workspaceBottom: workspace?.getBoundingClientRect().bottom ?? 0, + }; + }); + expect(layout.modeDisplay).toBe("flex"); + expect(layout.modeFlexGrow).toBe("1"); + expect(layout.liveDisplay).toBe("flex"); + expect(layout.liveFlexGrow).toBe("1"); + expect(layout.streamScrollHeight).toBeGreaterThan(layout.streamClientHeight); + expect(layout.streamBottom).toBeLessThanOrEqual(layout.workspaceBottom + 1); + expect(layout.documentScrollHeight).toBeLessThanOrEqual(layout.viewportHeight + 1); + await stream.evaluate((element) => { + element.scrollTop = element.scrollHeight; + element.dispatchEvent(new Event("scroll")); + }); + await expect + .poll(() => + stream.evaluate( + (element) => element.scrollHeight - element.scrollTop - element.clientHeight, + ), + ) + .toBeLessThanOrEqual(1); + await screenshot(page, "13-populated-live-activity.png"); + + await page.setViewportSize({ height: 900, width: 720 }); + const mobileLayout = await page.locator("main.content").evaluate((content) => { + const outlet = content.querySelector("openclaw-router-outlet"); + const stream = content.querySelector(".activity-stream"); + if (!outlet || !stream) { + throw new Error("Mobile Live Activity layout is incomplete"); + } + return { + contentClientHeight: content.clientHeight, + contentOverflowY: getComputedStyle(content).overflowY, + contentScrollHeight: content.scrollHeight, + outletDisplay: getComputedStyle(outlet).display, + }; + }); + expect(mobileLayout.contentOverflowY).toBe("auto"); + expect(mobileLayout.outletDisplay).toBe("block"); + expect(mobileLayout.contentScrollHeight).toBeGreaterThan(mobileLayout.contentClientHeight); + } finally { + await context.close(); + } + }); + it("renders empty, typed unavailable, corrupt, and expired results without guessing", async () => { const context = await newContext(); const page = await context.newPage(); diff --git a/ui/src/pages/activity/run-inspector.css b/ui/src/pages/activity/run-inspector.css index 93b58d3a4f0b..f942c78e82be 100644 --- a/ui/src/pages/activity/run-inspector.css +++ b/ui/src/pages/activity/run-inspector.css @@ -7,6 +7,7 @@ outline-offset: 2px; } +.settings-workspace--fill-height #activity-mode-panel, .settings-workspace--fill-height #activity-live-panel { flex: 1 1 auto; min-height: 0; diff --git a/ui/src/styles/activity.css b/ui/src/styles/activity.css index 0305afd4acdc..ff82f9fd15dc 100644 --- a/ui/src/styles/activity.css +++ b/ui/src/styles/activity.css @@ -2,6 +2,30 @@ design language; only the stream (entries, previews) keeps bespoke styles as the in-group escape hatch. */ +/* The route host must hand the bounded shell height to the fill-height + workspace; otherwise a populated stream expands the main page and defeats + its own scroll/auto-follow owner. */ +.content:has(openclaw-activity-page) { + overflow: hidden; +} + +.content:has(openclaw-activity-page) > openclaw-router-outlet { + display: flex; + flex-direction: column; + width: 100%; + height: 100%; + min-height: 0; + margin-top: 0; +} + +openclaw-activity-page { + display: contents; +} + +.content:has(openclaw-activity-page) .content-header { + flex: 0 0 auto; +} + /* Fill-height chain: the section, group, and stream absorb the remaining viewport height inside .settings-workspace--fill-height (settings.css). */ .settings-workspace--fill-height .activity-page { @@ -214,3 +238,15 @@ max-height: calc(100vh - 360px); } } + +@media (max-width: 768px), (max-width: 932px) and (max-height: 500px) and (orientation: landscape) { + .content:has(openclaw-activity-page) { + overflow-y: auto; + } + + .content:has(openclaw-activity-page) > openclaw-router-outlet { + display: block; + width: auto; + height: auto; + } +}