mirror of
https://github.com/openclaw/openclaw.git
synced 2026-10-03 17:53:39 +00:00
feat(ui): collapse task progress while reading history (#147295)
* feat(ui): collapse task progress while reading history Respect task-scoped manual disclosure choices, expand only at the live bottom, and keep dock resizing from changing reader intent. Co-authored-by: steipete <58493+steipete@users.noreply.github.com> * fix(ui): soften task progress closing motion Ease closing over 240ms while keeping quick opening, synchronize the content and chevron, and honor reduced motion. Cover the native timeline, reversal continuity, and scroll integration in Chromium. Co-authored-by: steipete <58493+steipete@users.noreply.github.com> Co-authored-by: vyctorbrzezowski <51521767+vyctorbrzezowski@users.noreply.github.com> --------- Co-authored-by: steipete <58493+steipete@users.noreply.github.com> Co-authored-by: vyctorbrzezowski <51521767+vyctorbrzezowski@users.noreply.github.com>
This commit is contained in:
parent
c7ad6ef60f
commit
8a294aebc6
22 changed files with 515 additions and 50 deletions
|
|
@ -93,6 +93,8 @@ Channels with progress drafts show the latest checklist in active `partial`, `bl
|
|||
|
||||
The current chat keeps exactly one live card, in the collapsible surface inside the composer, at every width. Opening a side panel does not move it out of the conversation. The dashboard widget and the session hovercard are separate read-only placements: 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. A notification is a refresh hint, including a null revision; clients confirm a removal with a read or clear response for that session and agent.
|
||||
|
||||
The composer card automatically folds into its current-step summary when you scroll away from the latest messages and expands when you return to the bottom. Manually expanding or collapsing it keeps that choice for the current task, including progress updates and the final response. A new run, a different session, or a cleared-and-recreated card starts with automatic behavior again. The existing **Collapse task progress by default** preference still keeps active work compact; without a manual override, its matching final expands the card only when you are at the bottom.
|
||||
|
||||
Transient refresh failures retain the last loaded card. The dashboard widget shows a retry notice until a refresh succeeds. If the Gateway reports that the connection no longer participates in the session, clients hide the card until access is restored and a refresh succeeds.
|
||||
|
||||
The composer and dashboard placements show the local time of the last progress update. The hovercard instead shows the current-or-next plan step and its completed/total count, followed by Markdown in a separate Agent Notepad when a note is present.
|
||||
|
|
|
|||
|
|
@ -374,7 +374,7 @@ describe("renderSessionProgressCard", () => {
|
|||
'[data-progress-card-placement="composer"]',
|
||||
);
|
||||
expect(card?.open).toBe(false);
|
||||
card!.open = true;
|
||||
card!.querySelector("summary")!.click();
|
||||
|
||||
render(
|
||||
renderSessionProgressCard(
|
||||
|
|
@ -416,15 +416,10 @@ describe("renderSessionProgressCard", () => {
|
|||
);
|
||||
expect(card?.open).toBe(false);
|
||||
|
||||
card!.open = true;
|
||||
renderRun("run-1", null);
|
||||
expect(card?.open).toBe(true);
|
||||
|
||||
card!.open = false;
|
||||
renderRun(null, "run-1");
|
||||
expect(card?.open).toBe(true);
|
||||
|
||||
card!.open = false;
|
||||
card!.querySelector("summary")!.click();
|
||||
renderRun(null, "run-1");
|
||||
expect(card?.open).toBe(false);
|
||||
|
||||
|
|
@ -434,7 +429,7 @@ describe("renderSessionProgressCard", () => {
|
|||
expect(card?.open).toBe(true);
|
||||
});
|
||||
|
||||
it("does not change disclosure at run boundaries when auto-collapse is disabled", () => {
|
||||
it("preserves manual disclosure through final and resets it for a new run", () => {
|
||||
const container = document.createElement("div");
|
||||
const renderRun = (activeRunId: string | null, completedRunId: string | null) =>
|
||||
render(
|
||||
|
|
@ -458,11 +453,85 @@ describe("renderSessionProgressCard", () => {
|
|||
);
|
||||
expect(card?.open).toBe(true);
|
||||
|
||||
card!.open = false;
|
||||
card!.querySelector("summary")!.click();
|
||||
renderRun(null, "run-1");
|
||||
expect(card?.open).toBe(false);
|
||||
renderRun("run-2", null);
|
||||
expect(card?.open).toBe(false);
|
||||
expect(card?.open).toBe(true);
|
||||
});
|
||||
|
||||
it.each([false, true])(
|
||||
"follows reading position until a manual choice (initial history: %s)",
|
||||
(readingHistory) => {
|
||||
const container = document.createElement("div");
|
||||
const renderPosition = (history: boolean, activeRunId = "run-1", revision = 2) =>
|
||||
render(
|
||||
renderSessionProgressCard(
|
||||
{ ...progressCard, revision },
|
||||
"composer",
|
||||
undefined,
|
||||
"running",
|
||||
RUN_STARTED_MS,
|
||||
undefined,
|
||||
true,
|
||||
false,
|
||||
{ activeRunId, readingHistory: history },
|
||||
),
|
||||
container,
|
||||
);
|
||||
renderPosition(readingHistory);
|
||||
const card = container.querySelector<HTMLDetailsElement>("details")!;
|
||||
expect(card.open).toBe(!readingHistory);
|
||||
renderPosition(!readingHistory);
|
||||
expect(card.open).toBe(readingHistory);
|
||||
renderPosition(readingHistory);
|
||||
expect(card.open).toBe(!readingHistory);
|
||||
|
||||
card.querySelector("summary")!.click();
|
||||
expect(card.open).toBe(readingHistory);
|
||||
renderPosition(!readingHistory);
|
||||
renderPosition(readingHistory, "run-1", 3);
|
||||
expect(card.open).toBe(readingHistory);
|
||||
|
||||
renderPosition(false, "run-2", 4);
|
||||
expect(card.open).toBe(true);
|
||||
renderPosition(true, "run-2", 5);
|
||||
expect(card.open).toBe(false);
|
||||
},
|
||||
);
|
||||
|
||||
it("keeps a manual collapse through final and resets it on the next task", () => {
|
||||
const container = document.createElement("div");
|
||||
const renderRun = (
|
||||
activeRunId: string | null,
|
||||
completedRunId: string | null,
|
||||
readingHistory = false,
|
||||
) =>
|
||||
render(
|
||||
renderSessionProgressCard(
|
||||
progressCard,
|
||||
"composer",
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
true,
|
||||
true,
|
||||
{ activeRunId, completedRunId, readingHistory },
|
||||
),
|
||||
container,
|
||||
);
|
||||
renderRun("run-1", null);
|
||||
const card = container.querySelector<HTMLDetailsElement>("details")!;
|
||||
card.querySelector("summary")!.click();
|
||||
card.querySelector("summary")!.click();
|
||||
renderRun(null, "run-1");
|
||||
expect(card.open).toBe(false);
|
||||
renderRun("run-2", "run-1");
|
||||
renderRun(null, "run-2", true);
|
||||
expect(card.open).toBe(false);
|
||||
renderRun(null, "run-2");
|
||||
expect(card.open).toBe(true);
|
||||
});
|
||||
|
||||
it("keeps the collapsed counter in the summary action column", () => {
|
||||
|
|
@ -634,7 +703,7 @@ describe("renderSessionProgressCard", () => {
|
|||
'[data-progress-card-placement="composer"]',
|
||||
);
|
||||
expect(card?.open).toBe(true);
|
||||
card!.open = false;
|
||||
card!.querySelector("summary")!.click();
|
||||
|
||||
render(
|
||||
renderSessionProgressCard(
|
||||
|
|
@ -662,7 +731,7 @@ describe("renderSessionProgressCard", () => {
|
|||
const first = container.querySelector<HTMLDetailsElement>(
|
||||
'[data-progress-card-placement="composer"]',
|
||||
);
|
||||
first!.open = false;
|
||||
first!.querySelector("summary")!.click();
|
||||
|
||||
render(
|
||||
renderSessionProgressCard({ ...progressCard, sessionKey: "agent:main:next" }, "composer"),
|
||||
|
|
|
|||
|
|
@ -96,12 +96,15 @@ const progressActivityTime = directive(ProgressActivityTimeDirective);
|
|||
type ComposerProgressRunLifecycle = {
|
||||
activeRunId?: string | null;
|
||||
completedRunId?: string | null;
|
||||
readingHistory?: boolean;
|
||||
};
|
||||
|
||||
type ComposerDisclosureOwner = {
|
||||
activeRunId: string | null;
|
||||
handledCompletedRunId: string | null;
|
||||
sessionKey: string;
|
||||
automaticOpen: boolean;
|
||||
manualOpen?: boolean;
|
||||
};
|
||||
|
||||
const composerDisclosureOwners = new WeakMap<HTMLDetailsElement, ComposerDisclosureOwner>();
|
||||
|
|
@ -118,24 +121,22 @@ function reconcileComposerDisclosure(
|
|||
}
|
||||
const activeRunId = lifecycle?.activeRunId ?? null;
|
||||
const completedRunId = lifecycle?.completedRunId ?? null;
|
||||
const owner = composerDisclosureOwners.get(element);
|
||||
let owner = composerDisclosureOwners.get(element);
|
||||
if (!owner || owner.sessionKey !== sessionKey) {
|
||||
element.open = initialOpen;
|
||||
composerDisclosureOwners.set(element, {
|
||||
owner = {
|
||||
activeRunId,
|
||||
handledCompletedRunId: completedRunId,
|
||||
sessionKey,
|
||||
});
|
||||
return;
|
||||
}
|
||||
// Run boundaries intentionally override the native disclosure. Same-run
|
||||
// rerenders leave the operator's manual open/closed choice untouched.
|
||||
if (activeRunId && activeRunId !== owner.activeRunId) {
|
||||
automaticOpen: initialOpen,
|
||||
};
|
||||
composerDisclosureOwners.set(element, owner);
|
||||
} else if (activeRunId && activeRunId !== owner.activeRunId) {
|
||||
// A new run starts a fresh task choice. Revisions and completion belong
|
||||
// to the same task and must not discard an explicit disclosure choice.
|
||||
owner.activeRunId = activeRunId;
|
||||
owner.handledCompletedRunId = null;
|
||||
if (collapseByDefault) {
|
||||
element.open = false;
|
||||
}
|
||||
owner.manualOpen = undefined;
|
||||
owner.automaticOpen = !collapseByDefault;
|
||||
}
|
||||
if (
|
||||
completedRunId &&
|
||||
|
|
@ -143,9 +144,22 @@ function reconcileComposerDisclosure(
|
|||
completedRunId !== owner.handledCompletedRunId
|
||||
) {
|
||||
owner.handledCompletedRunId = completedRunId;
|
||||
if (collapseByDefault) {
|
||||
element.open = true;
|
||||
}
|
||||
owner.automaticOpen = true;
|
||||
}
|
||||
element.open = owner.manualOpen ?? (owner.automaticOpen && !lifecycle?.readingHistory);
|
||||
}
|
||||
|
||||
function handleComposerDisclosureClick(event: MouseEvent): void {
|
||||
const summary = event.currentTarget;
|
||||
const element = summary instanceof HTMLElement ? summary.parentElement : null;
|
||||
if (!(element instanceof HTMLDetailsElement) || event.defaultPrevented) {
|
||||
return;
|
||||
}
|
||||
const owner = composerDisclosureOwners.get(element);
|
||||
if (owner) {
|
||||
// Summary activation covers pointer and keyboard input. A toggle event also
|
||||
// fires for automatic changes, so it cannot establish operator intent.
|
||||
owner.manualOpen = !element.open;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -449,7 +463,11 @@ export function renderSessionProgressCard(
|
|||
),
|
||||
)}
|
||||
>
|
||||
<summary class="session-progress-card__summary" aria-label=${summaryLabel}>
|
||||
<summary
|
||||
class="session-progress-card__summary"
|
||||
aria-label=${summaryLabel}
|
||||
@click=${handleComposerDisclosureClick}
|
||||
>
|
||||
<span
|
||||
class="session-progress-card__summary-indicator session-progress-card__current-marker${
|
||||
complete || effectiveSessionStatus === "done"
|
||||
|
|
|
|||
|
|
@ -146,14 +146,78 @@ suite.define(() => {
|
|||
await expect.poll(() => card.count()).toBe(1);
|
||||
await waitForChatScrollIdle(page);
|
||||
report.afterCard = await dockGeometry(page);
|
||||
await expect.poll(() => card.getAttribute("open")).toBe("");
|
||||
if (proofDir) {
|
||||
await page.screenshot({ path: path.join(proofDir, "01-expanded-at-bottom.png") });
|
||||
}
|
||||
|
||||
await scrollChatThreadToTop(page);
|
||||
if (proofDir) {
|
||||
await waitForChatScrollIdle(page);
|
||||
await page.screenshot({ path: path.join(proofDir, "02-reading-history.png") });
|
||||
}
|
||||
await expect.poll(() => card.getAttribute("open")).toBeNull();
|
||||
await waitForChatScrollIdle(page);
|
||||
expect(await page.locator(".chat-thread").evaluate((thread) => thread.scrollTop)).toBe(0);
|
||||
const button = page.locator(".chat-scroll-to-bottom[data-visible='true']");
|
||||
await button.waitFor();
|
||||
await button.click();
|
||||
await waitForChatScrollIdle(page);
|
||||
report.afterButton = await dockGeometry(page);
|
||||
await expect.poll(() => card.getAttribute("open")).toBe("");
|
||||
expectDockClear(report);
|
||||
if (proofDir) {
|
||||
await page.screenshot({ path: path.join(proofDir, "03-returned-to-bottom.png") });
|
||||
}
|
||||
|
||||
// Shrinking the dock can clamp the offset to its new end. That resize
|
||||
// must not be mistaken for a reader returning to the latest messages.
|
||||
await page.locator(".chat-thread").evaluate((thread) => {
|
||||
thread.scrollTop -= 32;
|
||||
});
|
||||
await waitForChatScrollIdle(page);
|
||||
expect(await card.getAttribute("open")).toBeNull();
|
||||
await page.locator(".chat-thread").dispatchEvent("pointerdown");
|
||||
await waitForChatScrollIdle(page);
|
||||
expect(await card.getAttribute("open")).toBeNull();
|
||||
await page.locator(".chat-thread").hover();
|
||||
await page.mouse.wheel(0, -1);
|
||||
await waitForChatScrollIdle(page);
|
||||
expect(await card.getAttribute("open")).toBeNull();
|
||||
await page.mouse.wheel(0, 600);
|
||||
await waitForChatScrollIdle(page);
|
||||
await expect.poll(() => card.getAttribute("open")).toBe("");
|
||||
|
||||
await page.locator(".chat-thread").evaluate((thread) => {
|
||||
thread.scrollTop -= 32;
|
||||
});
|
||||
await waitForChatScrollIdle(page);
|
||||
await page.locator(".chat-thread").evaluate((thread) => {
|
||||
const touch = new Touch({ identifier: 1, target: thread, clientY: 200 });
|
||||
thread.dispatchEvent(new TouchEvent("touchstart", { touches: [touch] }));
|
||||
});
|
||||
await waitForChatScrollIdle(page);
|
||||
expect(await card.getAttribute("open")).toBeNull();
|
||||
await page.locator(".chat-thread").evaluate((thread) => {
|
||||
const touch = new Touch({ identifier: 1, target: thread, clientY: 100 });
|
||||
thread.dispatchEvent(new TouchEvent("touchmove", { touches: [touch] }));
|
||||
thread.dispatchEvent(new TouchEvent("touchend"));
|
||||
});
|
||||
await waitForChatScrollIdle(page);
|
||||
expect(await card.getAttribute("open")).toBe("");
|
||||
|
||||
// Keyboard activation, like pointer input, pins the explicit choice.
|
||||
await card.locator("summary").press("Enter");
|
||||
await scrollChatThreadToTop(page);
|
||||
await button.click();
|
||||
await waitForChatScrollIdle(page);
|
||||
expect(await card.getAttribute("open")).toBeNull();
|
||||
await scrollChatThreadToTop(page);
|
||||
await card.locator("summary").click();
|
||||
await button.click();
|
||||
await waitForChatScrollIdle(page);
|
||||
await scrollChatThreadToTop(page);
|
||||
expect(await card.getAttribute("open")).toBe("");
|
||||
} finally {
|
||||
if (proofDir) {
|
||||
writeFileSync(path.join(proofDir, "geometry.json"), JSON.stringify(report, null, 2));
|
||||
|
|
|
|||
|
|
@ -50,7 +50,7 @@ async function expectInsideProgressBody(item: Locator): Promise<void> {
|
|||
const suite = createChatFlowE2eSuite();
|
||||
|
||||
suite.define(() => {
|
||||
it("collapses each enabled run, expands its final, and preserves manual disclosure", async () => {
|
||||
it("collapses enabled runs and preserves manual disclosure through finals", async () => {
|
||||
const sessionKey = "agent:main:progress-final-expand";
|
||||
const proofDir = captureUiProofEnabled
|
||||
? path.join(suite.artifactDir, "session-progress-live-placement")
|
||||
|
|
@ -181,11 +181,8 @@ suite.define(() => {
|
|||
await page
|
||||
.locator(".chat-bubble p", { hasText: "The first progress cycle is complete." })
|
||||
.waitFor();
|
||||
await expect.poll(() => card.getAttribute("open")).toBe("");
|
||||
await captureLifecycleState("05-run-one-final-auto-expanded.png");
|
||||
|
||||
await card.locator("summary").click();
|
||||
await expect.poll(() => card.getAttribute("open")).toBeNull();
|
||||
await captureLifecycleState("05-run-one-final-keeps-manual-collapse.png");
|
||||
await setProgressCard(3, "Run one final card revision", [
|
||||
{ status: "completed", step: "Inspect first run" },
|
||||
{ status: "completed", step: "Verify first run" },
|
||||
|
|
|
|||
197
ui/src/e2e/session-progress-motion.e2e.test.ts
Normal file
197
ui/src/e2e/session-progress-motion.e2e.test.ts
Normal file
|
|
@ -0,0 +1,197 @@
|
|||
import { mkdir, writeFile } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { expect, it } from "vitest";
|
||||
import {
|
||||
captureUiProofEnabled,
|
||||
createChatFlowE2eSuite,
|
||||
installMockGateway,
|
||||
waitForChatScrollIdle,
|
||||
} from "./chat-flow.test-support.ts";
|
||||
import { createControlUiE2eContextOptions } from "./control-ui-e2e-suite.test-support.ts";
|
||||
|
||||
const suite = createChatFlowE2eSuite();
|
||||
type InspectedAnimation = {
|
||||
id: string;
|
||||
name: string;
|
||||
type: string;
|
||||
source?: { duration: number };
|
||||
};
|
||||
|
||||
suite.define(() => {
|
||||
it.each(["no-preference", "reduce"] as const)(
|
||||
"folds task progress smoothly without an abrupt start (%s)",
|
||||
async (reducedMotion) => {
|
||||
const proofDir = captureUiProofEnabled
|
||||
? path.join(suite.artifactDir, "session-progress-motion", reducedMotion)
|
||||
: null;
|
||||
if (proofDir) {
|
||||
await mkdir(proofDir, { recursive: true });
|
||||
}
|
||||
const context = await suite.newBrowserContext({
|
||||
...createControlUiE2eContextOptions(),
|
||||
reducedMotion,
|
||||
...(proofDir ? { recordVideo: { dir: proofDir, size: { width: 1280, height: 900 } } } : {}),
|
||||
});
|
||||
const page = await context.newPage();
|
||||
const video = page.video();
|
||||
const sessionKey = "agent:main:main";
|
||||
await installMockGateway(page, {
|
||||
sessionKey,
|
||||
featureMethods: ["chat.metadata", "chat.startup", "progressCard.get"],
|
||||
historyMessages: Array.from({ length: 40 }, (_, index) => ({
|
||||
role: index % 2 ? "assistant" : "user",
|
||||
content: [{ type: "text", text: `History ${index}\n${"Reading context.\n".repeat(3)}` }],
|
||||
timestamp: index + 1,
|
||||
})),
|
||||
methodResponses: {
|
||||
"progressCard.get": {
|
||||
card: {
|
||||
sessionKey,
|
||||
revision: 1,
|
||||
updatedAt: Date.now(),
|
||||
markdown: "Checking the task progress interaction.",
|
||||
steps: [
|
||||
{ step: "Inspect the current behavior", status: "completed" },
|
||||
{ step: "Make the closing motion gentle", status: "in_progress" },
|
||||
{ step: "Verify the reading position stays put", status: "pending" },
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
const card = page.locator('[data-progress-card-placement="composer"]');
|
||||
const cardHeight = () => card.evaluate((element) => element.getBoundingClientRect().height);
|
||||
try {
|
||||
await page.goto(`${suite.server.baseUrl}chat`);
|
||||
await card.locator(".session-progress-card__body").waitFor();
|
||||
await waitForChatScrollIdle(page);
|
||||
if (proofDir) {
|
||||
await writeFile(
|
||||
path.join(proofDir, "crop.json"),
|
||||
JSON.stringify(await card.boundingBox()),
|
||||
);
|
||||
await page.waitForTimeout(400); // Recording-only pacing, not an assertion wait.
|
||||
}
|
||||
await page.locator(".chat-thread").hover();
|
||||
await page.mouse.wheel(0, -600);
|
||||
await expect.poll(() => card.getAttribute("open")).toBeNull();
|
||||
await waitForChatScrollIdle(page);
|
||||
const closed = await cardHeight();
|
||||
if (proofDir) {
|
||||
await page.waitForTimeout(400);
|
||||
}
|
||||
await page.locator('.chat-scroll-to-bottom[data-visible="true"]').click();
|
||||
await expect.poll(() => card.getAttribute("open")).toBe("");
|
||||
await waitForChatScrollIdle(page);
|
||||
const before = await cardHeight();
|
||||
expect(before - closed).toBeGreaterThan(50);
|
||||
if (proofDir) {
|
||||
await page.waitForTimeout(400);
|
||||
}
|
||||
|
||||
// Native details content lives in the UA shadow tree, outside
|
||||
// Element.getAnimations(). The inspector freezes its real CSS timeline
|
||||
// so curve assertions do not depend on runner frame rate or sleeps.
|
||||
const inspector = await context.newCDPSession(page);
|
||||
const observed: InspectedAnimation[] = [];
|
||||
await inspector.send("Animation.enable");
|
||||
inspector.on(
|
||||
"Animation.animationStarted",
|
||||
({ animation }: { animation: InspectedAnimation }) => {
|
||||
if (animation.type === "CSSTransition") {
|
||||
observed.push(animation);
|
||||
}
|
||||
},
|
||||
);
|
||||
await inspector.send("Animation.setPlaybackRate", { playbackRate: 0 });
|
||||
await card.locator("summary").click();
|
||||
await page.evaluate(
|
||||
() =>
|
||||
new Promise<void>((resolve) => {
|
||||
requestAnimationFrame(() => requestAnimationFrame(() => resolve()));
|
||||
}),
|
||||
);
|
||||
if (reducedMotion === "no-preference") {
|
||||
await expect
|
||||
.poll(() => observed.some((animation) => animation.name === "height"))
|
||||
.toBe(true);
|
||||
}
|
||||
const duration =
|
||||
observed.find((animation) => animation.name === "height")?.source?.duration ?? 0;
|
||||
const ids = observed.map((animation) => animation.id);
|
||||
const samples: Array<{ height: number; opacity: number }> = [];
|
||||
const sample = () =>
|
||||
card.evaluate((element) => ({
|
||||
height: element.getBoundingClientRect().height,
|
||||
opacity: Number(getComputedStyle(element, "::details-content").opacity),
|
||||
}));
|
||||
if (duration) {
|
||||
for (const fraction of [0, 0.25, 0.5, 0.75]) {
|
||||
await inspector.send("Animation.seekAnimations", {
|
||||
animations: ids,
|
||||
currentTime: duration * fraction,
|
||||
});
|
||||
samples.push(await sample());
|
||||
}
|
||||
await inspector.send("Animation.seekAnimations", {
|
||||
animations: ids,
|
||||
currentTime: duration * 0.25,
|
||||
});
|
||||
} else {
|
||||
samples.push(await sample());
|
||||
}
|
||||
if (proofDir) {
|
||||
await writeFile(
|
||||
path.join(proofDir, "motion.json"),
|
||||
JSON.stringify({ before, closed, duration, samples }, null, 2),
|
||||
);
|
||||
await card.screenshot({
|
||||
path: path.join(proofDir, "quarter-close.png"),
|
||||
animations: "allow",
|
||||
});
|
||||
}
|
||||
if (reducedMotion === "reduce") {
|
||||
expect(duration).toBe(0);
|
||||
expect(await cardHeight()).toBe(closed);
|
||||
} else {
|
||||
expect(duration).toBeGreaterThanOrEqual(200);
|
||||
expect(duration).toBeLessThanOrEqual(300);
|
||||
expect((samples[1]!.height - closed) / (samples[0]!.height - closed)).toBeGreaterThan(
|
||||
0.6,
|
||||
);
|
||||
expect(samples[1]!.opacity).toBeGreaterThan(0.6);
|
||||
for (let index = 1; index < samples.length; index += 1) {
|
||||
expect(samples[index]!.height).toBeLessThanOrEqual(samples[index - 1]!.height);
|
||||
}
|
||||
const reversingFrom = await cardHeight();
|
||||
observed.length = 0;
|
||||
await card.locator("summary").click();
|
||||
await page.evaluate(
|
||||
() =>
|
||||
new Promise<void>((resolve) => {
|
||||
requestAnimationFrame(() => requestAnimationFrame(() => resolve()));
|
||||
}),
|
||||
);
|
||||
await expect
|
||||
.poll(() => observed.some((animation) => animation.name === "height"))
|
||||
.toBe(true);
|
||||
expect(Math.abs((await cardHeight()) - reversingFrom)).toBeLessThanOrEqual(1);
|
||||
if (observed.length) {
|
||||
await inspector.send("Animation.setPaused", {
|
||||
animations: observed.map((animation) => animation.id),
|
||||
paused: false,
|
||||
});
|
||||
}
|
||||
await inspector.send("Animation.setPlaybackRate", { playbackRate: 1 });
|
||||
await expect.poll(cardHeight).toBe(before);
|
||||
}
|
||||
} finally {
|
||||
await page.close();
|
||||
if (proofDir && video) {
|
||||
await video.saveAs(path.join(proofDir, "closing-cycle.webm"));
|
||||
}
|
||||
await suite.closeBrowserContext(context);
|
||||
}
|
||||
},
|
||||
);
|
||||
});
|
||||
|
|
@ -998,7 +998,7 @@ const enSettings = {
|
|||
"Enter a CSS width such as 960px, 82%, min(1280px, 82%), or calc(100% - 2rem).",
|
||||
collapseTaskProgress: "Collapse task progress by default",
|
||||
collapseTaskProgressHint:
|
||||
"Keep task progress collapsed while work is active, then expand it when the response finishes.",
|
||||
"Keep task progress collapsed while work is active, then expand it at the bottom when the response finishes. Manual changes last for the current task.",
|
||||
},
|
||||
sessionSources: {
|
||||
title: "Session sources",
|
||||
|
|
|
|||
|
|
@ -229,6 +229,7 @@ export function makeChatHost(
|
|||
chatHasAutoScrolled: false,
|
||||
chatUserNearBottom: true,
|
||||
chatFollowLocked: false,
|
||||
chatReadingHistory: false,
|
||||
chatNewMessagesBelow: false,
|
||||
applySettings: vi.fn((patch: Partial<UiSettings>) => {
|
||||
// Chat pages own display/layout settings; active-session persistence belongs to pane bindings.
|
||||
|
|
|
|||
|
|
@ -318,7 +318,7 @@ export abstract class ChatPaneBase extends OpenClawLightDomElement {
|
|||
);
|
||||
protected readonly transcript = new ChatTranscriptController(this, {
|
||||
onViewportResize: () => this.chatState.handleTranscriptResize(),
|
||||
onReaderScroll: () => this.state && handleChatScrollTakeover(this.state),
|
||||
onReaderScroll: (towardEnd) => this.state && handleChatScrollTakeover(this.state, towardEnd),
|
||||
});
|
||||
protected readonly progressCard = new SessionProgressCardController(this, {
|
||||
gateway: () => this.context?.gateway,
|
||||
|
|
|
|||
|
|
@ -130,6 +130,7 @@ export function createTestChatPane(params: {
|
|||
chatHasAutoScrolled: false,
|
||||
chatUserNearBottom: true,
|
||||
chatFollowLocked: false,
|
||||
chatReadingHistory: false,
|
||||
chatNewMessagesBelow: false,
|
||||
handleChatScroll: vi.fn(),
|
||||
renderLifecycle: { afterCommit: () => () => {}, invalidate: () => {} },
|
||||
|
|
|
|||
|
|
@ -398,6 +398,7 @@ export class ChatPane extends ChatPaneLayoutRender {
|
|||
providerPolicyNotice: catalogKey ? null : state.providerPolicyNotice,
|
||||
progressCard: this.progressCard.card,
|
||||
collapseTaskProgress: state.settings.chatCollapseTaskProgress === true,
|
||||
readingHistory: state.chatReadingHistory,
|
||||
onDismissProgressCard,
|
||||
gatewayQuestionPrompts:
|
||||
catalogKey || sessionParticipationBlocked
|
||||
|
|
|
|||
|
|
@ -117,6 +117,7 @@ export type ChatPageHost = ChatHost &
|
|||
chatHasAutoScrolled: boolean;
|
||||
chatUserNearBottom: boolean;
|
||||
chatFollowLocked: boolean;
|
||||
chatReadingHistory: boolean;
|
||||
chatIsProgrammaticScroll?: () => boolean;
|
||||
chatScrollElement?: () => HTMLElement | null;
|
||||
chatScrollToEnd?: (options: ChatScrollToEndOptions) => boolean;
|
||||
|
|
|
|||
|
|
@ -261,6 +261,7 @@ export function createPageState(
|
|||
chatHasAutoScrolled: false,
|
||||
chatUserNearBottom: true,
|
||||
chatFollowLocked: false,
|
||||
chatReadingHistory: false,
|
||||
sidebarLayout: normalizeSidebarLayout(settings.sidebarSessionLayouts?.[sidebarSessionKey]),
|
||||
sidebarContent: null,
|
||||
sidebarFocusPanelId: settings.sidebarSessionActivePanels?.[sidebarSessionKey] ?? "",
|
||||
|
|
|
|||
|
|
@ -93,6 +93,7 @@ export type ChatComposerProps = ChatAttachmentControlsProps & {
|
|||
progressCard?: ProgressCard | null;
|
||||
runActive?: boolean;
|
||||
collapseTaskProgress?: boolean;
|
||||
readingHistory?: boolean;
|
||||
runId?: string | null;
|
||||
onDismissProgressCard?: (card: ProgressCard) => void;
|
||||
gatewayQuestionPrompts?: readonly QuestionPrompt[];
|
||||
|
|
|
|||
|
|
@ -287,6 +287,7 @@ export function renderChatComposerView(context: ChatComposerViewContext) {
|
|||
props.collapseTaskProgress,
|
||||
{
|
||||
activeRunId: props.runId,
|
||||
readingHistory: props.readingHistory,
|
||||
completedRunId: props.runStatus?.phase === "done" ? props.runStatus.runId : null,
|
||||
},
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -669,7 +669,10 @@ describe("chat transcript controller", () => {
|
|||
requestUpdate: vi.fn(),
|
||||
updateComplete: Promise.resolve(true),
|
||||
},
|
||||
{ onViewportResize, onReaderScroll: () => handleChatScrollTakeover(policy) },
|
||||
{
|
||||
onViewportResize,
|
||||
onReaderScroll: (towardEnd) => handleChatScrollTakeover(policy, towardEnd),
|
||||
},
|
||||
);
|
||||
const rows: TestContentRow[] = Array.from({ length: 12 }, (_, index) => ({
|
||||
kind: "content",
|
||||
|
|
|
|||
|
|
@ -35,7 +35,7 @@ type OffsetOwner = {
|
|||
readonly prependAnchor: TranscriptPrependAnchor;
|
||||
cancelScroll(): void;
|
||||
requestUpdate(): void;
|
||||
onReaderScroll(): void;
|
||||
onReaderScroll(towardEnd?: boolean): void;
|
||||
};
|
||||
|
||||
/** Observe native offsets and input with the transcript's touch and command lifecycle. */
|
||||
|
|
@ -46,6 +46,7 @@ export function observeTranscriptOffset(
|
|||
): () => void {
|
||||
const element = owner.getScrollElement();
|
||||
let nativeOffset = element?.scrollTop ?? 0;
|
||||
let touchY: number | undefined;
|
||||
const publishOffset = (offset: number, scrolling: boolean) => {
|
||||
if (
|
||||
scrolling &&
|
||||
|
|
@ -76,6 +77,7 @@ export function observeTranscriptOffset(
|
|||
};
|
||||
owner.state.syncNativeOffset = syncOffset;
|
||||
const finishTouch = () => {
|
||||
touchY = undefined;
|
||||
owner.state.touching = false;
|
||||
// Idle may have arrived while the finger was still down; no further
|
||||
// offset notification is guaranteed after releasing a stationary touch.
|
||||
|
|
@ -102,6 +104,7 @@ export function observeTranscriptOffset(
|
|||
}
|
||||
if (event.type === "touchstart") {
|
||||
owner.state.touching = true;
|
||||
touchY = event instanceof TouchEvent ? event.touches[0]?.clientY : undefined;
|
||||
}
|
||||
owner.state.pendingInteractionAnchor = null;
|
||||
// Contact alone does not supersede a captured message. Actual native
|
||||
|
|
@ -114,8 +117,23 @@ export function observeTranscriptOffset(
|
|||
owner.cancelScroll();
|
||||
}
|
||||
syncOffset();
|
||||
owner.onReaderScroll();
|
||||
const towardEnd =
|
||||
(event instanceof WheelEvent && event.deltaY > 0) ||
|
||||
(event instanceof KeyboardEvent &&
|
||||
(["ArrowDown", "PageDown", "End"].includes(event.key) ||
|
||||
(event.key === " " && !event.shiftKey)));
|
||||
owner.onReaderScroll(towardEnd);
|
||||
};
|
||||
const moveTouch = (event: TouchEvent) => {
|
||||
const nextY = event.touches[0]?.clientY;
|
||||
// At a resize-clamped end there may be no offset event. Contact alone is
|
||||
// not a return; only a gesture moving toward the end can resume following.
|
||||
if (touchY !== undefined && nextY !== undefined && nextY < touchY) {
|
||||
owner.onReaderScroll(true);
|
||||
}
|
||||
touchY = nextY;
|
||||
};
|
||||
element?.addEventListener("touchmove", moveTouch, { passive: true });
|
||||
for (const type of ["wheel", "touchstart", "keydown", "pointerdown"]) {
|
||||
element?.addEventListener(type, interrupt, { passive: true });
|
||||
}
|
||||
|
|
@ -159,6 +177,7 @@ export function observeTranscriptOffset(
|
|||
owner.state.touchScrolling = false;
|
||||
element?.removeEventListener("scrollend", finishScroll);
|
||||
element?.removeEventListener("touchend", finishTouch);
|
||||
element?.removeEventListener("touchmove", moveTouch);
|
||||
element?.removeEventListener("touchcancel", finishTouch);
|
||||
for (const type of ["wheel", "touchstart", "keydown", "pointerdown"]) {
|
||||
element?.removeEventListener(type, interrupt);
|
||||
|
|
|
|||
|
|
@ -17,7 +17,7 @@ export type ChatTranscriptPendingScrollOffset = {
|
|||
|
||||
export type TranscriptCallbacks = {
|
||||
onViewportResize?: () => void;
|
||||
onReaderScroll?: () => void;
|
||||
onReaderScroll?: (towardEnd?: boolean) => void;
|
||||
};
|
||||
|
||||
export const CHAT_TRANSCRIPT_ESTIMATED_ROW_PX = 120;
|
||||
|
|
|
|||
|
|
@ -282,7 +282,7 @@ export class ChatSessionVirtualizerHost implements ReactiveControllerHost, ChatT
|
|||
prependAnchor: this.prependAnchor,
|
||||
cancelScroll: () => this.cancelScroll(),
|
||||
requestUpdate: () => this.host.requestUpdate(),
|
||||
onReaderScroll: () => this.callbacks.onReaderScroll?.(),
|
||||
onReaderScroll: (towardEnd) => this.callbacks.onReaderScroll?.(towardEnd),
|
||||
},
|
||||
instance,
|
||||
callback,
|
||||
|
|
|
|||
|
|
@ -46,6 +46,7 @@ function createScrollHost(
|
|||
chatHasAutoScrolled: false,
|
||||
chatUserNearBottom: true,
|
||||
chatFollowLocked: false,
|
||||
chatReadingHistory: false,
|
||||
chatNewMessagesBelow: false,
|
||||
chatIsProgrammaticScroll: () => false,
|
||||
chatScrollToEnd: vi.fn((options: ChatScrollToEndOptions) => {
|
||||
|
|
@ -164,6 +165,32 @@ describe("handleChatScroll", () => {
|
|||
expect(host.chatUserNearBottom).toBe(false);
|
||||
});
|
||||
|
||||
it("keeps reader control when a shrinking dock clamps the viewport to its new end", () => {
|
||||
const { host, container } = createScrollHost({
|
||||
scrollHeight: 2000,
|
||||
scrollTop: 1600,
|
||||
clientHeight: 400,
|
||||
});
|
||||
host.chatLastScrollTop = 1600;
|
||||
handleChatScroll(host, createScrollEvent(2000, 1568, 400));
|
||||
expect(host.chatFollowLocked).toBe(true);
|
||||
handleChatScroll(host, createScrollEvent(2000, 1400, 600));
|
||||
handleChatScroll(host, createScrollEvent(2000, 1400, 600));
|
||||
expect(host.chatFollowLocked).toBe(true);
|
||||
expect(host.chatUserNearBottom).toBe(false);
|
||||
|
||||
Object.defineProperty(container, "clientHeight", { value: 600 });
|
||||
container.scrollTop = 1400;
|
||||
const invalidate = vi.fn();
|
||||
host.renderLifecycle.invalidate = invalidate;
|
||||
handleChatScrollTakeover(host);
|
||||
expect(host.chatReadingHistory).toBe(true);
|
||||
expect(host.chatFollowLocked).toBe(true);
|
||||
handleChatScrollTakeover(host, true);
|
||||
expect(host.chatFollowLocked).toBe(false);
|
||||
expect(invalidate).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("publishes the indicator transition when the user returns to bottom", () => {
|
||||
const { host } = createScrollHost({});
|
||||
host.chatNewMessagesBelow = true;
|
||||
|
|
@ -544,6 +571,39 @@ describe("scheduleChatScroll", () => {
|
|||
expect(host.chatNewMessagesBelow).toBe(false);
|
||||
});
|
||||
|
||||
it("settles an explicit return after a dock resize already clamped the viewport to the end", () => {
|
||||
const { host } = createScrollHost({ scrollTop: 1600, clientHeight: 400 });
|
||||
host.chatHasAutoScrolled = true;
|
||||
host.chatFollowLocked = true;
|
||||
host.chatReadingHistory = true;
|
||||
host.chatLastScrollTop = 1600;
|
||||
host.chatScrollToEnd.mockImplementation(() => true);
|
||||
|
||||
scheduleChatScroll(host, true, true, { source: "manual" });
|
||||
expect(host.chatFollowLocked).toBe(false);
|
||||
expect(host.chatReadingHistory).toBe(false);
|
||||
});
|
||||
|
||||
it("keeps reading history until a smooth return actually reaches the end", () => {
|
||||
const { host, container } = createScrollHost({ scrollTop: 1200, clientHeight: 400 });
|
||||
host.chatHasAutoScrolled = true;
|
||||
host.chatFollowLocked = true;
|
||||
host.chatReadingHistory = true;
|
||||
host.chatLastScrollTop = 1200;
|
||||
host.chatScrollToEnd.mockImplementation(() => true);
|
||||
host.chatIsProgrammaticScroll = () => container.scrollTop < 1592;
|
||||
|
||||
scheduleChatScroll(host, true, true, { source: "manual" });
|
||||
expect(host.chatFollowLocked).toBe(false);
|
||||
expect(host.chatReadingHistory).toBe(true);
|
||||
container.scrollTop = 1400;
|
||||
handleChatScroll(host, createScrollEvent(2000, 1400, 400));
|
||||
expect(host.chatReadingHistory).toBe(true);
|
||||
container.scrollTop = 1600;
|
||||
handleChatScroll(host, createScrollEvent(2000, 1600, 400));
|
||||
expect(host.chatReadingHistory).toBe(false);
|
||||
});
|
||||
|
||||
it.each(["commit", "resize", "schedule"] as const)(
|
||||
"preserves a pending manual jump across an automatic %s",
|
||||
(update) => {
|
||||
|
|
|
|||
|
|
@ -93,6 +93,7 @@ export type ChatScrollHost = {
|
|||
chatHasAutoScrolled: boolean;
|
||||
chatUserNearBottom: boolean;
|
||||
chatFollowLocked: boolean;
|
||||
chatReadingHistory: boolean;
|
||||
chatNewMessagesBelow: boolean;
|
||||
chatIsProgrammaticScroll?: () => boolean;
|
||||
chatScrollElement?: () => HTMLElement | null;
|
||||
|
|
@ -176,6 +177,11 @@ function applyChatScroll(
|
|||
host.chatFollowLocked = false;
|
||||
host.chatUserNearBottom = true;
|
||||
setNewMessagesBelow(host, false);
|
||||
// A return issued at an already-clamped end produces no native scroll event.
|
||||
// Settle it here; an actual smooth journey waits for its final position event.
|
||||
if (manualScroll && distanceFromBottom <= CHAT_TRANSCRIPT_END_THRESHOLD_PX) {
|
||||
updateChatScrollPosition(host, target, "toward-end");
|
||||
}
|
||||
}
|
||||
|
||||
function queueChatScroll(
|
||||
|
|
@ -252,20 +258,20 @@ export function handleChatScroll(host: ChatScrollHost, event: Event): void {
|
|||
updateChatScrollPosition(host, container);
|
||||
}
|
||||
|
||||
export function handleChatScrollTakeover(host: ChatScrollHost): void {
|
||||
export function handleChatScrollTakeover(host: ChatScrollHost, towardEnd = false): void {
|
||||
cancelChatScroll(host);
|
||||
const container = host.chatScrollElement?.();
|
||||
if (container) {
|
||||
// Intent can stop a smooth scroll without moving a pixel. Retire queued
|
||||
// follow work and publish reader policy even without a native scroll event.
|
||||
updateChatScrollPosition(host, container, true);
|
||||
updateChatScrollPosition(host, container, towardEnd ? "toward-end" : "reader");
|
||||
}
|
||||
}
|
||||
|
||||
function updateChatScrollPosition(
|
||||
host: ChatScrollHost,
|
||||
container: HTMLElement,
|
||||
takeover = false,
|
||||
takeover: false | "reader" | "toward-end" = false,
|
||||
): void {
|
||||
const scrollTop = Math.max(0, container.scrollTop);
|
||||
const delta = scrollTop - host.chatLastScrollTop;
|
||||
|
|
@ -274,18 +280,29 @@ function updateChatScrollPosition(
|
|||
// Ignore downward scroll events that we triggered, including intermediate
|
||||
// smooth-scroll frames. A real user scroll-up must still pass through so
|
||||
// streaming stops pinning them back to the bottom.
|
||||
const isUserScrollUp = takeover || delta < 0;
|
||||
const isUserScrollUp = takeover !== false || delta < 0;
|
||||
if (host.chatIsProgrammaticScroll?.() && !isUserScrollUp) {
|
||||
return;
|
||||
}
|
||||
const distanceFromBottom = container.scrollHeight - container.scrollTop - container.clientHeight;
|
||||
const wasReadingHistory = host.chatReadingHistory;
|
||||
if (isUserScrollUp && distanceFromBottom > CHAT_TRANSCRIPT_END_THRESHOLD_PX) {
|
||||
// Taking control before initial history settles must retire its queued
|
||||
// force-scroll. Otherwise that delayed commit can overwrite the viewport.
|
||||
host.chatHasAutoScrolled = true;
|
||||
host.chatFollowLocked = true;
|
||||
} else if (distanceFromBottom <= CHAT_TRANSCRIPT_END_THRESHOLD_PX) {
|
||||
host.chatReadingHistory = true;
|
||||
} else if (
|
||||
distanceFromBottom <= CHAT_TRANSCRIPT_END_THRESHOLD_PX &&
|
||||
(delta > 0 || takeover === "toward-end")
|
||||
) {
|
||||
// A shrinking dock can clamp scrollTop backwards to the new end. Only
|
||||
// reader movement toward the end or fresh input resumes following.
|
||||
host.chatFollowLocked = false;
|
||||
host.chatReadingHistory = false;
|
||||
}
|
||||
if (host.chatReadingHistory !== wasReadingHistory) {
|
||||
host.renderLifecycle.invalidate();
|
||||
}
|
||||
host.chatUserNearBottom = !host.chatFollowLocked && distanceFromBottom < NEAR_BOTTOM_THRESHOLD;
|
||||
|
||||
|
|
@ -301,6 +318,7 @@ export function resetChatScroll(host: ChatScrollHost): void {
|
|||
host.chatHasAutoScrolled = false;
|
||||
host.chatUserNearBottom = true;
|
||||
host.chatFollowLocked = false;
|
||||
host.chatReadingHistory = false;
|
||||
host.chatLastScrollTop = 0;
|
||||
host.chatLastScrollHeight = 0;
|
||||
host.chatNewMessagesBelow = false;
|
||||
|
|
|
|||
|
|
@ -127,6 +127,8 @@
|
|||
|
||||
.session-progress-card--composer {
|
||||
--session-progress-marker-size: var(--chat-stack-icon-size, 14px);
|
||||
--session-progress-disclosure-duration: 240ms;
|
||||
--session-progress-disclosure-ease: var(--ease-in-out);
|
||||
|
||||
position: relative;
|
||||
display: block;
|
||||
|
|
@ -146,16 +148,23 @@
|
|||
backdrop-filter: blur(8px);
|
||||
}
|
||||
|
||||
/* Ease into closing instead of losing most of the body in the first frame.
|
||||
Opening keeps its quicker response; the chevron shares the fold timeline. */
|
||||
.session-progress-card--composer[open] {
|
||||
--session-progress-disclosure-duration: 180ms;
|
||||
--session-progress-disclosure-ease: cubic-bezier(0.23, 1, 0.32, 1);
|
||||
}
|
||||
|
||||
.session-progress-card--composer::details-content {
|
||||
block-size: 0;
|
||||
overflow: hidden;
|
||||
opacity: 0;
|
||||
transform: translateY(-4px);
|
||||
transition:
|
||||
content-visibility 180ms allow-discrete,
|
||||
block-size 180ms cubic-bezier(0.23, 1, 0.32, 1),
|
||||
opacity 140ms cubic-bezier(0.23, 1, 0.32, 1),
|
||||
transform 180ms cubic-bezier(0.23, 1, 0.32, 1);
|
||||
content-visibility var(--session-progress-disclosure-duration) allow-discrete,
|
||||
block-size var(--session-progress-disclosure-duration) var(--session-progress-disclosure-ease),
|
||||
opacity var(--session-progress-disclosure-duration) var(--session-progress-disclosure-ease),
|
||||
transform var(--session-progress-disclosure-duration) var(--session-progress-disclosure-ease);
|
||||
transition-behavior: allow-discrete;
|
||||
}
|
||||
|
||||
|
|
@ -716,7 +725,7 @@
|
|||
color: var(--muted);
|
||||
transition:
|
||||
color 140ms ease,
|
||||
transform 140ms cubic-bezier(0.23, 1, 0.32, 1);
|
||||
transform var(--session-progress-disclosure-duration) var(--session-progress-disclosure-ease);
|
||||
}
|
||||
|
||||
.session-progress-card__summary-chevron svg {
|
||||
|
|
@ -868,7 +877,9 @@
|
|||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.session-progress-card--composer,
|
||||
.session-progress-card--composer::details-content,
|
||||
.session-progress-card__summary,
|
||||
.session-progress-card__summary-chevron,
|
||||
.session-progress-card__summary-indicator > .session-run-spinner {
|
||||
animation: none;
|
||||
transition: none;
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue