mirror of
https://github.com/openclaw/openclaw.git
synced 2026-10-03 01:29:56 +00:00
fix(ui): transcript end hides under the PR chip and Side chat questions cannot wrap (#144703)
* fix(ui): keep the transcript end above the PR chip and let Side chat questions wrap Users following a long coding session saw the newest transcript rows clipped behind the pull-request chip and task-progress dock while the view believed it was at the end and hid the scroll-to-latest button; a width change "fixed" it. Live on a stable team instance the thread rested 48px short with the last row 25px under the PR chip. End-follow runs in a requestAnimationFrame after the commit that grows a row. For task-driven commits (tool-stream sync, session events) that frame precedes the row's ResizeObserver, so the follow scrolled to a stale maximum, and the outstanding end command never settled. The transcript host now records the maximum an end command targeted and re-targets it after commits when the committed maximum moved. The render-lifecycle change capture also tracks chatStreamSegments identity, so Codex-harness preamble growth schedules a follow at all. Pending-offset restoration moves to a sibling module to stay under the file-length cap. The Side chat prompt becomes an auto-growing textarea that reuses the main composer's sizing owner and send-key rule (Enter or the configured modifier shortcut asks, Shift+Enter adds a line); its composer lifecycle lives in chat-session-rail-composer.ts. Proof: chat-transcript-end-follow.browser.test.ts fails on the previous host (distance 48, programmatic true) and passes now; chat-scroll-dock.e2e.test.ts rests 181-601px short without the stream-segment capture and at 0 with it; chat-session-companion-composer.e2e.test.ts covers wrapping, Shift+Enter, verbatim multi-line asks, shrink-back, and modifier shortcuts. * test(ui): initialize settings in retained panel fixture Repair the exact-head CI failures in checks-ui (1/3) and checks-node-compact-large-9. The retained Review fixture must initialize settings before the Side chat shortcut binding reads them. Reuse loadSettings as production does; preserve runtime code and assertions. * test(ui): use reader input before transcript scroll assertions Exact-head CI exposed synthetic scrollTop writes racing outstanding end commands in position-rail, focused-row, and native-history scenarios. Send real wheel input through the existing cancellation owner before asserting reader geometry. Preserve all assertions, timeouts, and runtime behavior. The ten targeted E2E cases, changed checks, and scoped autoreview pass. * fix(ui): re-pin only a reader still resting at the transcript end The first end-follow repair re-targeted any outstanding end command when the committed maximum moved. An instant follow that does not move emits no scroll event, so that command lingered, and a reader who later left the end without an input event (find-in-page, focus navigation, programmatic scroll) was pulled back by the next growth; three e2e tests had been switched to wheel input to hide exactly that. The host now keeps an explicit end anchor: the maximum a reader is known to rest at, set by an instant end-follow or observed after any commit. A commit that moves the end while the reader still sits at the previous end re-pins them; a reader found anywhere else clears the anchor and is never pulled back. The three e2e tests return to their original programmatic scrolls. Proof: chat-transcript-end-follow.browser.test.ts adds a programmatic departure case (348px yank on the previous retarget, 0 now) and an observed-at-end growth case (48px short without the anchor, 0 now); the restored e2e files pass unmodified. * fix(ui): preserve disclosure anchors during end-follow Exact-head CI exposed transcript disclosure expansion moving the clicked row by 120-357 px. Retire the end anchor when the interaction captures its row and defer end reconciliation during its measurement commit, so the pending virtual sizer cannot re-arm a stale end. The three existing disclosure cases fail before and pass after. All three browser end-follow regressions and 20 focused E2E cases pass; changed checks and scoped P0-P2 autoreview are clean.
This commit is contained in:
parent
d58b2ad33f
commit
de57e39408
16 changed files with 919 additions and 89 deletions
1
.gitignore
vendored
1
.gitignore
vendored
|
|
@ -186,6 +186,7 @@ ui/src/ui/theme-variants.browser.test.ts
|
|||
ui/src/ui/__screenshots__
|
||||
ui/src/ui/views/__screenshots__
|
||||
ui/.vitest-attachments
|
||||
ui/.vitest/
|
||||
|
||||
# Generated docs baseline artifacts (locally generated, only drift detectors tracked)
|
||||
docs/.generated/*.json
|
||||
|
|
|
|||
|
|
@ -3832,7 +3832,7 @@ ui/src/pages/chat/components/chat-model-picker-options.ts 2
|
|||
ui/src/pages/chat/components/chat-model-picker.ts 5
|
||||
ui/src/pages/chat/components/chat-pane-header.ts 2
|
||||
ui/src/pages/chat/components/chat-selection-popup.ts 1
|
||||
ui/src/pages/chat/components/chat-session-rail.ts 4
|
||||
ui/src/pages/chat/components/chat-session-rail.ts 3
|
||||
ui/src/pages/chat/components/chat-session-sharing.ts 1
|
||||
ui/src/pages/chat/components/chat-session-workspace.ts 1
|
||||
ui/src/pages/chat/components/chat-sidebar-editor-menu.ts 2
|
||||
|
|
|
|||
|
|
@ -17,7 +17,7 @@ While you watch a running session, the Gateway shows the model's latest safe pre
|
|||
|
||||
Side chat answers questions about the selected session and its project without entering or interrupting the main agent run. On the first question, the Gateway lazily loads a bounded visible snapshot of the selected session before starting the utility model. If history is temporarily unavailable, the question stays visible with **Retry** instead of being treated as an empty session. Side chat uses read-only access to the target session's history/search and agent workspace. Its bounded thread is held in Gateway memory, is restored when you switch sessions in the Control UI, and is cleared by the rail's trash button, a session reset or deletion, Gateway restart, or idle expiry. It never enters `chat.history`, and private reference context is not stored as operator dialogue. Open it with Shift-Command-S on Apple platforms or Ctrl-Shift-S elsewhere, or type `/btw <question>` or `/side <question>` in the main Control UI composer to open the rail and ask there; other clients keep their existing BTW behavior.
|
||||
|
||||
Highlighting text in a chat message offers **Ask in side chat**, which opens the rail with a quoted draft ready to edit.
|
||||
The question box wraps and grows like the main composer; Enter (or your configured send shortcut) asks the question, and Shift+Enter adds a line. Highlighting text in a chat message offers **Ask in side chat**, which opens the rail with a quoted draft ready to edit.
|
||||
|
||||
The headline owns that run's sidebar subtitle instead of heuristic live activity. It is shared with the official iOS and Android session lists. A final done or failed digest remains visible while the session is unread, then the row returns to its normal work subtitle.
|
||||
|
||||
|
|
|
|||
393
ui/src/e2e/chat-scroll-dock.e2e.test.ts
Normal file
393
ui/src/e2e/chat-scroll-dock.e2e.test.ts
Normal file
|
|
@ -0,0 +1,393 @@
|
|||
import { writeFileSync } from "node:fs";
|
||||
import path from "node:path";
|
||||
import type { Page } from "playwright";
|
||||
import { expect, it } from "vitest";
|
||||
import { CONTROL_UI_SESSION_PULL_REQUESTS_CHANGED_EVENT } from "../../../src/gateway/control-ui-contract.js";
|
||||
import { SESSION_PULL_REQUESTS_SUBSCRIBE_METHOD } from "../lib/session-pull-requests.ts";
|
||||
import { CHAT_TRANSCRIPT_END_THRESHOLD_PX } from "../pages/chat/scroll.ts";
|
||||
import { createControlUiE2eArtifactDir } from "../test-helpers/control-ui-e2e-artifacts.ts";
|
||||
import {
|
||||
chatThreadDistanceFromBottom,
|
||||
captureUiProofEnabled,
|
||||
createChatFlowE2eSuite,
|
||||
installMockGateway,
|
||||
scrollChatThreadToTop,
|
||||
waitForChatScrollIdle,
|
||||
} from "./chat-flow.test-support.ts";
|
||||
import { waitForWatchedSessionKey } from "./chat-github-publication.test-support.ts";
|
||||
import { createControlUiE2eContextOptions } from "./control-ui-e2e-suite.test-support.ts";
|
||||
|
||||
const suite = createChatFlowE2eSuite();
|
||||
type DockGeometry = {
|
||||
distance: number;
|
||||
overhang: number;
|
||||
rowKey: string | null;
|
||||
rowHeight: number;
|
||||
sizerHeight: number;
|
||||
latestVisible: string | null;
|
||||
};
|
||||
|
||||
async function dockGeometry(page: Page): Promise<DockGeometry> {
|
||||
return page.locator(".chat-pane-cache__pane--active").evaluate((pane) => {
|
||||
const thread = pane.querySelector<HTMLElement>(".chat-thread");
|
||||
const rows = pane.querySelectorAll<HTMLElement>(".chat-virtual-row");
|
||||
const row = rows.item(rows.length - 1);
|
||||
const sizer = pane.querySelector<HTMLElement>(".chat-virtual-sizer");
|
||||
const dock = pane.querySelector<HTMLElement>(".chat-prs, .agent-chat__composer-shell");
|
||||
if (!thread || !row || !sizer || !dock) {
|
||||
throw new Error("Expected a transcript row, sizer, and composer dock");
|
||||
}
|
||||
return {
|
||||
distance: Math.round(thread.scrollHeight - thread.scrollTop - thread.clientHeight),
|
||||
overhang: Math.round(row.getBoundingClientRect().bottom - dock.getBoundingClientRect().top),
|
||||
rowKey: row.getAttribute("data-virtual-row-key"),
|
||||
rowHeight: row.offsetHeight,
|
||||
sizerHeight: sizer.offsetHeight,
|
||||
latestVisible:
|
||||
pane.querySelector(".chat-scroll-to-bottom")?.getAttribute("data-visible") ?? null,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function expectDockClear(report: Record<string, DockGeometry>): void {
|
||||
for (const [stage, { distance, overhang }] of Object.entries(report)) {
|
||||
expect(
|
||||
distance,
|
||||
`${stage} distance from bottom: ${JSON.stringify(report[stage])}`,
|
||||
).toBeLessThanOrEqual(CHAT_TRANSCRIPT_END_THRESHOLD_PX);
|
||||
expect(overhang, `${stage} last row overhang into the dock`).toBeLessThanOrEqual(0);
|
||||
}
|
||||
}
|
||||
|
||||
suite.define(() => {
|
||||
it("keeps the transcript end visible when the composer dock grows", async () => {
|
||||
const context = await suite.newBrowserContext(createControlUiE2eContextOptions());
|
||||
const page = await context.newPage();
|
||||
const baseTs = Date.now() - 100_000;
|
||||
const historyMessages = Array.from({ length: 40 }, (_, index) => ({
|
||||
content: [{ text: `Dock history ${index}\n${"transcript line\n".repeat(3)}`, type: "text" }],
|
||||
role: index % 2 === 0 ? "assistant" : "user",
|
||||
timestamp: baseTs + index,
|
||||
}));
|
||||
const gateway = await installMockGateway(page, {
|
||||
featureMethods: [
|
||||
"chat.metadata",
|
||||
"chat.startup",
|
||||
"config.get",
|
||||
"progressCard.get",
|
||||
SESSION_PULL_REQUESTS_SUBSCRIBE_METHOD,
|
||||
],
|
||||
historyMessages,
|
||||
methodResponses: {
|
||||
[SESSION_PULL_REQUESTS_SUBSCRIBE_METHOD]: { subscribed: true },
|
||||
"progressCard.get": { card: null },
|
||||
},
|
||||
});
|
||||
const report: Record<string, DockGeometry> = {};
|
||||
const proofDir = captureUiProofEnabled
|
||||
? createControlUiE2eArtifactDir("chat-scroll-dock")
|
||||
: null;
|
||||
try {
|
||||
await page.goto(`${suite.server.baseUrl}chat`);
|
||||
await page.getByText("Dock history 39").waitFor({ timeout: 10_000 });
|
||||
await expect
|
||||
.poll(() => chatThreadDistanceFromBottom(page), { timeout: 10_000 })
|
||||
.toBeLessThanOrEqual(CHAT_TRANSCRIPT_END_THRESHOLD_PX);
|
||||
await waitForChatScrollIdle(page);
|
||||
report.initial = await dockGeometry(page);
|
||||
|
||||
const watchedKey = await waitForWatchedSessionKey(gateway);
|
||||
await gateway.emitGatewayEvent(CONTROL_UI_SESSION_PULL_REQUESTS_CHANGED_EVENT, {
|
||||
sessions: {
|
||||
[watchedKey]: {
|
||||
pullRequests: [
|
||||
{
|
||||
number: 144615,
|
||||
owner: "openclaw",
|
||||
repo: "openclaw",
|
||||
branch: "fix/clawhub-publish-metadata-2026-9-4",
|
||||
title: "fix: publish ClawHub metadata",
|
||||
url: "https://github.com/openclaw/openclaw/pull/144615",
|
||||
state: "open",
|
||||
additions: 295,
|
||||
deletions: 57,
|
||||
checks: { state: "failing", passed: 60, failed: 1, skipped: 0, running: 0 },
|
||||
checksUrl: "https://github.com/openclaw/openclaw/pull/144615/checks",
|
||||
},
|
||||
],
|
||||
rateLimited: false,
|
||||
status: "ready",
|
||||
},
|
||||
},
|
||||
});
|
||||
await page.locator(".chat-pr").first().waitFor();
|
||||
await waitForChatScrollIdle(page);
|
||||
report.afterPr = await dockGeometry(page);
|
||||
|
||||
const card = page.locator('[data-progress-card-placement="composer"]');
|
||||
await gateway.setMethodResponse("progressCard.get", {
|
||||
card: {
|
||||
markdown:
|
||||
"Core npm and Docker publication verified.\n\n- 90 npm plugins + 3 companions verified; core install and Docker digests passed.\n- ClawHub repair CI found a native-Node import regression; owner-boundary fix underway.\n- Repair PR must land before selected-package recovery.\n- 58 ClawHub uploads await owner recovery; selector sync pending.\n- Telegram/Parallels skipped; Vercel mirror advisory failed.",
|
||||
revision: 1,
|
||||
sessionKey: watchedKey,
|
||||
steps: [
|
||||
{ status: "completed", step: "Verify signed tag and frozen release evidence" },
|
||||
{ status: "in_progress", step: "Publish core, plugins, and prepared macOS artifacts" },
|
||||
{ status: "pending", step: "Verify registries, release assets, and stable closeout" },
|
||||
],
|
||||
updatedAt: Date.now(),
|
||||
},
|
||||
});
|
||||
await gateway.emitGatewayEvent("progressCard.changed", {
|
||||
revision: 1,
|
||||
sessionKey: watchedKey,
|
||||
});
|
||||
await expect.poll(() => card.count()).toBe(1);
|
||||
await waitForChatScrollIdle(page);
|
||||
report.afterCard = await dockGeometry(page);
|
||||
|
||||
await scrollChatThreadToTop(page);
|
||||
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);
|
||||
expectDockClear(report);
|
||||
} finally {
|
||||
if (proofDir) {
|
||||
writeFileSync(path.join(proofDir, "geometry.json"), JSON.stringify(report, null, 2));
|
||||
}
|
||||
await context.close();
|
||||
}
|
||||
});
|
||||
|
||||
it("keeps a growing run frame above the PR chip through committed end-follow", async () => {
|
||||
const context = await suite.newBrowserContext(createControlUiE2eContextOptions());
|
||||
const page = await context.newPage();
|
||||
const baseTs = Date.now() - 100_000;
|
||||
const historyMessages = Array.from({ length: 30 }, (_, index) => ({
|
||||
content: [
|
||||
{ text: `Stream history ${index}\n${"transcript line\n".repeat(3)}`, type: "text" },
|
||||
],
|
||||
role: index % 2 === 0 ? "assistant" : "user",
|
||||
timestamp: baseTs + index,
|
||||
}));
|
||||
const runId = "dock-growing-run";
|
||||
const runHistory: unknown[] = [
|
||||
...historyMessages,
|
||||
{
|
||||
role: "user",
|
||||
content: "Inspect the workspace",
|
||||
timestamp: baseTs + 50,
|
||||
__openclaw: { id: "dock-user", idempotencyKey: `${runId}:user`, seq: 31 },
|
||||
},
|
||||
{
|
||||
role: "assistant",
|
||||
phase: "commentary",
|
||||
content:
|
||||
"I will inspect the workspace.\n\n" + "Initial commentary paragraph.\n\n".repeat(20),
|
||||
timestamp: baseTs + 51,
|
||||
__openclaw: { id: "dock-commentary", runId, seq: 32 },
|
||||
},
|
||||
{
|
||||
role: "toolResult",
|
||||
toolCallId: "dock-seed-tool",
|
||||
toolName: "exec",
|
||||
content: [{ type: "text", text: "Initial check complete" }],
|
||||
timestamp: baseTs + 52,
|
||||
__openclaw: { id: "dock-seed-tool", runId, seq: 33 },
|
||||
},
|
||||
];
|
||||
const gateway = await installMockGateway(page, {
|
||||
featureMethods: [
|
||||
"chat.metadata",
|
||||
"chat.send",
|
||||
"chat.startup",
|
||||
"config.get",
|
||||
SESSION_PULL_REQUESTS_SUBSCRIBE_METHOD,
|
||||
],
|
||||
historyMessages: runHistory,
|
||||
inFlightRun: { runId, text: "" },
|
||||
sessionInfo: { activeRunIds: [runId], hasActiveRun: true, key: "agent:main:main" },
|
||||
methodResponses: {
|
||||
[SESSION_PULL_REQUESTS_SUBSCRIBE_METHOD]: { subscribed: true },
|
||||
},
|
||||
});
|
||||
const report: Record<string, DockGeometry> = {};
|
||||
const proofDir = captureUiProofEnabled
|
||||
? createControlUiE2eArtifactDir("chat-scroll-dock")
|
||||
: null;
|
||||
try {
|
||||
await page.goto(`${suite.server.baseUrl}chat`);
|
||||
await page.getByText("Stream history 29").waitFor({ timeout: 10_000 });
|
||||
const watchedKey = await waitForWatchedSessionKey(gateway);
|
||||
await gateway.emitGatewayEvent(CONTROL_UI_SESSION_PULL_REQUESTS_CHANGED_EVENT, {
|
||||
sessions: {
|
||||
[watchedKey]: {
|
||||
pullRequests: [
|
||||
{
|
||||
number: 144615,
|
||||
owner: "openclaw",
|
||||
repo: "openclaw",
|
||||
branch: "fix/clawhub-publish-metadata-2026-9-4",
|
||||
title: "fix: publish ClawHub metadata",
|
||||
url: "https://github.com/openclaw/openclaw/pull/144615",
|
||||
state: "open",
|
||||
additions: 295,
|
||||
deletions: 57,
|
||||
},
|
||||
],
|
||||
rateLimited: false,
|
||||
status: "ready",
|
||||
},
|
||||
},
|
||||
});
|
||||
await page.locator(".chat-pr").first().waitFor();
|
||||
await expect
|
||||
.poll(() => chatThreadDistanceFromBottom(page), { timeout: 10_000 })
|
||||
.toBeLessThanOrEqual(CHAT_TRANSCRIPT_END_THRESHOLD_PX);
|
||||
await waitForChatScrollIdle(page);
|
||||
|
||||
const runRow = page.locator('.chat-virtual-row[data-virtual-row-key^="agent-run:"]').last();
|
||||
const rowKey = await runRow.getAttribute("data-virtual-row-key");
|
||||
let sequence = 0;
|
||||
let text = "";
|
||||
for (let step = 1; step <= 4; step += 1) {
|
||||
const before = await dockGeometry(page);
|
||||
text =
|
||||
`Commentary stage ${step}.\n\n` +
|
||||
"Additional findings with enough detail to occupy another paragraph.\n\n".repeat(
|
||||
step * 4,
|
||||
);
|
||||
await gateway.emitGatewayEvent("agent", {
|
||||
data: { kind: "preamble", itemId: `dock-progress-${step}`, progressText: text },
|
||||
runId,
|
||||
seq: ++sequence,
|
||||
sessionKey: "agent:main:main",
|
||||
stream: "item",
|
||||
ts: Date.now(),
|
||||
});
|
||||
await runRow.getByText(`Commentary stage ${step}.`, { exact: true }).waitFor();
|
||||
await waitForChatScrollIdle(page);
|
||||
const preamble = await dockGeometry(page);
|
||||
report[`preamble${step}`] = preamble;
|
||||
expect(preamble.rowKey).toBe(rowKey);
|
||||
expect(preamble.rowHeight).toBeGreaterThan(before.rowHeight);
|
||||
expect(preamble.sizerHeight - before.sizerHeight).toBe(
|
||||
preamble.rowHeight - before.rowHeight,
|
||||
);
|
||||
await gateway.emitGatewayEvent("agent", {
|
||||
data: {
|
||||
phase: "start",
|
||||
name: "exec",
|
||||
toolCallId: `dock-tool-${step}`,
|
||||
args: { command: `echo check-${step}` },
|
||||
},
|
||||
runId,
|
||||
seq: ++sequence,
|
||||
sessionKey: "agent:main:main",
|
||||
stream: "tool",
|
||||
ts: Date.now(),
|
||||
});
|
||||
await gateway.emitGatewayEvent("agent", {
|
||||
data: {
|
||||
phase: "result",
|
||||
name: "exec",
|
||||
toolCallId: `dock-tool-${step}`,
|
||||
result: { content: [{ type: "text", text: "Check complete." }] },
|
||||
},
|
||||
runId,
|
||||
seq: ++sequence,
|
||||
sessionKey: "agent:main:main",
|
||||
stream: "tool",
|
||||
ts: Date.now(),
|
||||
});
|
||||
runHistory.push(
|
||||
{
|
||||
role: "assistant",
|
||||
content: [{ type: "text", text }],
|
||||
openclawStreamFallback: {
|
||||
replacementText: text,
|
||||
source: "segment",
|
||||
itemId: `dock-progress-${step}`,
|
||||
},
|
||||
timestamp: Date.now(),
|
||||
__openclaw: { id: `dock-progress-${step}`, runId, seq: 34 + step * 2 },
|
||||
},
|
||||
{
|
||||
role: "toolResult",
|
||||
toolCallId: `dock-tool-${step}`,
|
||||
toolName: "exec",
|
||||
content: [{ type: "text", text: "Check complete." }],
|
||||
timestamp: Date.now(),
|
||||
__openclaw: { id: `dock-result-${step}`, runId, seq: 35 + step * 2 },
|
||||
},
|
||||
);
|
||||
await runRow.getByText(`Commentary stage ${step}.`, { exact: true }).waitFor();
|
||||
await waitForChatScrollIdle(page);
|
||||
const after = await dockGeometry(page);
|
||||
report[`commentary${step}`] = after;
|
||||
expect(after.rowKey).toBe(rowKey);
|
||||
expect(after.rowHeight).toBeGreaterThan(before.rowHeight);
|
||||
expect(after.sizerHeight - before.sizerHeight).toBe(after.rowHeight - before.rowHeight);
|
||||
expect(after.latestVisible).toBe("false");
|
||||
}
|
||||
// Completed items are checkpointed before the terminal clears transient activity.
|
||||
const activeSession = { key: "agent:main:main", activeRunIds: [runId], hasActiveRun: true };
|
||||
await gateway.setMethodResponse("chat.history", {
|
||||
messages: runHistory,
|
||||
sessionInfo: activeSession,
|
||||
inFlightRun: { runId, text: "" },
|
||||
});
|
||||
const historyRequests = (await gateway.getRequests("chat.history")).length;
|
||||
await gateway.emitGatewayEvent("sessions.changed", {
|
||||
phase: "message",
|
||||
session: activeSession,
|
||||
});
|
||||
await gateway.waitForRequest("chat.history", { after: historyRequests });
|
||||
await waitForChatScrollIdle(page);
|
||||
report.checkpoint = await dockGeometry(page);
|
||||
|
||||
const finalMessage = {
|
||||
role: "assistant",
|
||||
phase: "final_answer",
|
||||
content: "Workspace checks complete.",
|
||||
timestamp: Date.now(),
|
||||
__openclaw: { id: "dock-final", runId, seq: 44 },
|
||||
};
|
||||
await gateway.setMethodResponse("chat.history", {
|
||||
messages: [...runHistory, finalMessage],
|
||||
sessionInfo: { key: "agent:main:main", activeRunIds: [], hasActiveRun: false },
|
||||
inFlightRun: null,
|
||||
});
|
||||
await gateway.emitGatewayEvent("session.message", {
|
||||
message: finalMessage,
|
||||
messageId: "dock-final",
|
||||
messageSeq: 44,
|
||||
session: {
|
||||
key: "agent:main:main",
|
||||
activeRunIds: [],
|
||||
hasActiveRun: false,
|
||||
status: "done",
|
||||
kind: "direct",
|
||||
updatedAt: Date.now(),
|
||||
},
|
||||
runId,
|
||||
clientRunId: runId,
|
||||
activeRunIds: [],
|
||||
hasActiveRun: false,
|
||||
sessionKey: "agent:main:main",
|
||||
});
|
||||
await page.getByText("Workspace checks complete.", { exact: true }).waitFor();
|
||||
await waitForChatScrollIdle(page);
|
||||
report.final = await dockGeometry(page);
|
||||
expectDockClear(report);
|
||||
} finally {
|
||||
if (proofDir) {
|
||||
writeFileSync(path.join(proofDir, "geometry.json"), JSON.stringify(report, null, 2));
|
||||
}
|
||||
await context.close();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
|
@ -18,7 +18,7 @@ suite.define(() => {
|
|||
historyMessages: [{ role: "assistant", content: selectedText }],
|
||||
});
|
||||
await page.goto(`${suite.server.baseUrl}chat`);
|
||||
const composer = page.locator(".agent-chat__composer-combobox textarea");
|
||||
const composer = page.locator(".agent-chat__composer-shell textarea");
|
||||
await composer.waitFor({ state: "visible" });
|
||||
const text = page.locator(".chat-bubble .chat-text p").filter({ hasText: selectedText });
|
||||
const popup = page.getByRole("toolbar", { name: "Selection actions" });
|
||||
|
|
|
|||
107
ui/src/e2e/chat-session-companion-composer.e2e.test.ts
Normal file
107
ui/src/e2e/chat-session-companion-composer.e2e.test.ts
Normal file
|
|
@ -0,0 +1,107 @@
|
|||
import path from "node:path";
|
||||
import type { Locator, Page } from "playwright";
|
||||
import { expect, it } from "vitest";
|
||||
import type { ChatSendShortcut } from "../app/settings.ts";
|
||||
import { createControlUiE2eArtifactDir } from "../test-helpers/control-ui-e2e-artifacts.ts";
|
||||
import {
|
||||
controlUiBundledSettingsStorageKey,
|
||||
installMockGateway,
|
||||
} from "../test-helpers/control-ui-e2e.ts";
|
||||
import { captureUiProofEnabled } from "./chat-flow.test-support.ts";
|
||||
import { openChatSidePanelType } from "./chat-side-panel.test-support.ts";
|
||||
import { createControlUiE2eSuite } from "./control-ui-e2e-suite.test-support.ts";
|
||||
|
||||
const suite = createControlUiE2eSuite({ name: "side-chat composer" });
|
||||
const viewport = { width: 1440, height: 900 };
|
||||
|
||||
async function openSideChat(page: Page, chatSendShortcut: ChatSendShortcut = "enter") {
|
||||
await page.addInitScript(
|
||||
({ key, shortcut }) => {
|
||||
localStorage.setItem(key, JSON.stringify({ chatSendShortcut: shortcut }));
|
||||
},
|
||||
{ key: controlUiBundledSettingsStorageKey(suite.server.baseUrl), shortcut: chatSendShortcut },
|
||||
);
|
||||
const gateway = await installMockGateway(page, {
|
||||
methodResponses: {
|
||||
"sessions.companion.ask": { answer: "The next step is ready.", ts: 1 },
|
||||
"sessions.companion.state": { exchanges: [] },
|
||||
},
|
||||
});
|
||||
await page.goto(`${suite.server.baseUrl}chat`);
|
||||
await openChatSidePanelType(page, "Side chat");
|
||||
return gateway;
|
||||
}
|
||||
|
||||
function composerGeometry(composer: Locator) {
|
||||
return composer.evaluate((element) => ({
|
||||
height: element.getBoundingClientRect().height,
|
||||
clientWidth: element.clientWidth,
|
||||
scrollWidth: element.scrollWidth,
|
||||
}));
|
||||
}
|
||||
|
||||
suite.define(() => {
|
||||
it("wraps and grows a question, keeps Shift+Enter, then sends and shrinks", async () => {
|
||||
await suite.withPage({ viewport }, async ({ page }) => {
|
||||
const gateway = await openSideChat(page);
|
||||
const composer = page.getByRole("textbox", { name: "Ask in side chat", exact: true });
|
||||
const proofDir = captureUiProofEnabled
|
||||
? createControlUiE2eArtifactDir("chat-session-companion-composer")
|
||||
: null;
|
||||
const empty = await composerGeometry(composer);
|
||||
if (proofDir) {
|
||||
await page.screenshot({ path: path.join(proofDir, "empty.png") });
|
||||
}
|
||||
|
||||
const question = "Explain the next step and the remaining checks for this session. "
|
||||
.repeat(4)
|
||||
.trim();
|
||||
await composer.fill(question);
|
||||
const filled = await composerGeometry(composer);
|
||||
if (proofDir) {
|
||||
await page.screenshot({ path: path.join(proofDir, "wrapped.png") });
|
||||
}
|
||||
expect(filled.scrollWidth).toBeLessThanOrEqual(filled.clientWidth);
|
||||
expect(filled.height).toBeGreaterThan(empty.height);
|
||||
|
||||
const requestsBefore = await gateway.getRequests("sessions.companion.ask");
|
||||
await composer.press("End");
|
||||
await composer.press("Shift+Enter");
|
||||
await page.keyboard.type("Include the final verification.");
|
||||
const multiline = `${question}\nInclude the final verification.`;
|
||||
expect(await composer.inputValue()).toBe(multiline);
|
||||
expect(await gateway.getRequests("sessions.companion.ask")).toHaveLength(
|
||||
requestsBefore.length,
|
||||
);
|
||||
|
||||
await composer.press("Enter");
|
||||
const request = await gateway.waitForRequest("sessions.companion.ask");
|
||||
expect(request.params).toMatchObject({ question: multiline });
|
||||
await expect.poll(() => composer.inputValue()).toBe("");
|
||||
await expect.poll(async () => (await composerGeometry(composer)).height).toBe(empty.height);
|
||||
if (proofDir) {
|
||||
await page.screenshot({ path: path.join(proofDir, "cleared.png") });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
it.each(["Control", "Meta"])(
|
||||
"uses %s+Enter to send when the configured shortcut requires a modifier",
|
||||
async (modifier) => {
|
||||
await suite.withPage({ viewport }, async ({ page }) => {
|
||||
const gateway = await openSideChat(page, "modifier-enter");
|
||||
const composer = page.getByRole("textbox", { name: "Ask in side chat", exact: true });
|
||||
await composer.fill("Explain the next step.");
|
||||
await composer.press("Enter");
|
||||
await page.keyboard.type("Include the checks.");
|
||||
const question = "Explain the next step.\nInclude the checks.";
|
||||
expect(await composer.inputValue()).toBe(question);
|
||||
expect(await gateway.getRequests("sessions.companion.ask")).toHaveLength(0);
|
||||
await composer.press(`${modifier}+Enter`);
|
||||
const request = await gateway.waitForRequest("sessions.companion.ask");
|
||||
expect(request.params).toMatchObject({ question });
|
||||
await expect.poll(() => composer.inputValue()).toBe("");
|
||||
});
|
||||
},
|
||||
);
|
||||
});
|
||||
|
|
@ -52,7 +52,7 @@ suite.define(() => {
|
|||
await expect
|
||||
.poll(() => input.evaluate((element) => document.activeElement === element))
|
||||
.toBe(true);
|
||||
const mainInput = page.locator(".agent-chat__composer-combobox textarea");
|
||||
const mainInput = page.locator(".agent-chat__composer-shell textarea");
|
||||
await mainInput.fill("Keep typing here");
|
||||
await gateway.resolveDeferred("sessions.companion.state", {
|
||||
exchanges: [{ question: "What changed?", answer: "The introduction is ready.", ts: 1 }],
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ import "./components/chat-detail-panel.ts";
|
|||
import { afterEach, beforeEach, describe, expect, it, onTestFinished, vi } from "vitest";
|
||||
import { createDeferred } from "../../../../test/helpers/promise.js";
|
||||
import type { SessionWorkspaceGetResult, SessionWorkspaceListResult } from "../../api/types.ts";
|
||||
import { loadSettings } from "../../app/settings.ts";
|
||||
import type { TaskSummary } from "../../lib/tasks/task-summary.ts";
|
||||
import { gatewayHelloForMethods } from "../../test-helpers/gateway-methods.ts";
|
||||
import { resolveChatAgentId } from "./chat-agent-id.ts";
|
||||
|
|
@ -685,6 +686,7 @@ describe("chat pane embedded panels", () => {
|
|||
sessionKey: "agent:main:review",
|
||||
sidebarContent: null,
|
||||
sidebarLayout: { columns: [] },
|
||||
settings: loadSettings(),
|
||||
} as unknown as ChatPageHost;
|
||||
const mount = document.body.appendChild(document.createElement("div"));
|
||||
const renderPanels = async (layout: SidebarLayout) => {
|
||||
|
|
|
|||
|
|
@ -169,6 +169,7 @@ export function sidebarPanelDefinitions(
|
|||
.pullRequests=${params.pullRequests}
|
||||
.companion=${params.companion}
|
||||
.connected=${state?.connected === true}
|
||||
.sendShortcut=${state?.settings.chatSendShortcut ?? "enter"}
|
||||
.onSubmit=${params.onCompanionSubmit}
|
||||
.onDraftChange=${params.onCompanionDraftChange}
|
||||
.onVisibilityChange=${params.onCompanionVisibilityChange}
|
||||
|
|
|
|||
|
|
@ -669,7 +669,7 @@ function chatHtml(opts: ChatFixtureOptions = {}, mobileNavLayout = false) {
|
|||
<footer class="agent-chat__input chat-session-rail__composer" data-composer-layout="multiline">
|
||||
<div class="agent-chat__composer-input-row">
|
||||
<label class="agent-chat__composer-combobox chat-session-rail__prompt">
|
||||
<input class="chat-session-rail__input" type="text" placeholder="What should I know?" />
|
||||
<textarea class="chat-session-rail__input" rows="1" placeholder="What should I know?"></textarea>
|
||||
</label>
|
||||
</div>
|
||||
<div class="agent-chat__composer-footer">
|
||||
|
|
@ -5605,7 +5605,7 @@ describeBrowserLayout.concurrent("chat responsive browser layout", () => {
|
|||
<form class="agent-chat__input chat-session-rail__composer">
|
||||
<div class="agent-chat__composer-input-row">
|
||||
<label class="agent-chat__composer-combobox chat-session-rail__prompt">
|
||||
<input class="chat-session-rail__input" type="text" placeholder="Ask a question" />
|
||||
<textarea class="chat-session-rail__input" rows="1" placeholder="Ask a question"></textarea>
|
||||
</label>
|
||||
</div>
|
||||
<div class="agent-chat__composer-footer">
|
||||
|
|
|
|||
|
|
@ -24,6 +24,7 @@ export class ChatStateController<TState extends ChatPageHost> implements Reactiv
|
|||
private previousChatLoading = false;
|
||||
private previousChatMessages: unknown[] = [];
|
||||
private previousChatToolMessages: Record<string, unknown>[] = [];
|
||||
private previousChatStreamSegments: ChatPageHost["chatStreamSegments"] = [];
|
||||
private previousGuardianNotices: ChatPageHost["guardianNotices"] = [];
|
||||
private previousChatStream: string | null = null;
|
||||
private previousRealtimeConversation: ChatPageHost["realtimeTalkConversation"] = [];
|
||||
|
|
@ -75,6 +76,7 @@ export class ChatStateController<TState extends ChatPageHost> implements Reactiv
|
|||
this.previousChatLoading = state.chatLoading;
|
||||
this.previousChatMessages = state.chatMessages;
|
||||
this.previousChatToolMessages = state.chatToolMessages;
|
||||
this.previousChatStreamSegments = state.chatStreamSegments;
|
||||
this.previousGuardianNotices = state.guardianNotices;
|
||||
this.previousChatStream = state.chatStream;
|
||||
this.previousRealtimeConversation = state.realtimeTalkConversation;
|
||||
|
|
@ -216,6 +218,7 @@ export class ChatStateController<TState extends ChatPageHost> implements Reactiv
|
|||
const messagesChanged =
|
||||
this.previousChatMessages !== state.chatMessages ||
|
||||
this.previousChatToolMessages !== state.chatToolMessages ||
|
||||
this.previousChatStreamSegments !== state.chatStreamSegments ||
|
||||
this.previousGuardianNotices !== state.guardianNotices ||
|
||||
this.previousRealtimeConversation !== state.realtimeTalkConversation;
|
||||
const streamChanged = this.previousChatStream !== state.chatStream;
|
||||
|
|
@ -225,6 +228,7 @@ export class ChatStateController<TState extends ChatPageHost> implements Reactiv
|
|||
this.previousChatLoading = state.chatLoading;
|
||||
this.previousChatMessages = state.chatMessages;
|
||||
this.previousChatToolMessages = state.chatToolMessages;
|
||||
this.previousChatStreamSegments = state.chatStreamSegments;
|
||||
this.previousGuardianNotices = state.guardianNotices;
|
||||
this.previousChatStream = state.chatStream;
|
||||
this.previousRealtimeConversation = state.realtimeTalkConversation;
|
||||
|
|
|
|||
57
ui/src/pages/chat/components/chat-session-rail-composer.ts
Normal file
57
ui/src/pages/chat/components/chat-session-rail-composer.ts
Normal file
|
|
@ -0,0 +1,57 @@
|
|||
import type { ChatSendShortcut } from "../../../app/settings.ts";
|
||||
import {
|
||||
adjustTextareaHeight,
|
||||
disconnectTextareaOverflowObserver,
|
||||
observeTextareaOverflow,
|
||||
scheduleTextareaHeightAdjustment,
|
||||
} from "./chat-composer-dom.ts";
|
||||
|
||||
export function createSessionRailComposer(options: {
|
||||
submit: () => void;
|
||||
onDraftChange: (draft: string) => void;
|
||||
sendShortcut: () => ChatSendShortcut;
|
||||
}) {
|
||||
let textarea: HTMLTextAreaElement | null = null;
|
||||
const ref = (element?: Element) => {
|
||||
const nextTextarea = element instanceof HTMLTextAreaElement ? element : null;
|
||||
if (textarea && textarea !== nextTextarea) {
|
||||
disconnectTextareaOverflowObserver(textarea);
|
||||
}
|
||||
textarea = nextTextarea;
|
||||
if (nextTextarea) {
|
||||
observeTextareaOverflow(nextTextarea);
|
||||
scheduleTextareaHeightAdjustment(nextTextarea);
|
||||
}
|
||||
};
|
||||
return {
|
||||
ref,
|
||||
dispose() {
|
||||
ref();
|
||||
},
|
||||
syncDraft(draft: string) {
|
||||
if (textarea?.isConnected && textarea.value !== draft) {
|
||||
scheduleTextareaHeightAdjustment(textarea);
|
||||
}
|
||||
},
|
||||
handleKeydown: (event: KeyboardEvent) => {
|
||||
if (event.isComposing || event.keyCode === 229) {
|
||||
return;
|
||||
}
|
||||
const sendShortcutMatches =
|
||||
options.sendShortcut() === "enter" || event.metaKey || event.ctrlKey;
|
||||
if (event.key === "Enter" && !event.shiftKey && sendShortcutMatches) {
|
||||
event.preventDefault();
|
||||
if (!event.repeat) {
|
||||
options.submit();
|
||||
}
|
||||
}
|
||||
},
|
||||
handleInput: (event: InputEvent) => {
|
||||
const target = event.currentTarget;
|
||||
if (target instanceof HTMLTextAreaElement) {
|
||||
adjustTextareaHeight(target);
|
||||
options.onDraftChange(target.value);
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
|
|
@ -3,6 +3,7 @@ import { property, state } from "lit/decorators.js";
|
|||
import { ref } from "lit/directives/ref.js";
|
||||
import type { SessionObserverDigest } from "../../../../../packages/gateway-protocol/src/schema/sessions.js";
|
||||
import type { ControlUiSessionPullRequest } from "../../../../../src/gateway/control-ui-contract.js";
|
||||
import type { ChatSendShortcut } from "../../../app/settings.ts";
|
||||
import { icons } from "../../../components/icons.ts";
|
||||
import { markdownBlocks } from "../../../components/markdown-blocks.ts";
|
||||
import { handleMarkdownCodeBlockClick } from "../../../components/markdown-code-blocks.ts";
|
||||
|
|
@ -20,6 +21,7 @@ import {
|
|||
} from "../chat-observer-display.ts";
|
||||
import type { ChatSessionCompanionThread } from "../chat-session-companion.ts";
|
||||
import { renderMessageMarkdown } from "./chat-message-text.ts";
|
||||
import { createSessionRailComposer } from "./chat-session-rail-composer.ts";
|
||||
|
||||
export type SessionRailMode = "hidden" | "pill" | "expanded";
|
||||
|
||||
|
|
@ -229,6 +231,7 @@ export class ChatSessionRailElement extends OpenClawLightDomElement {
|
|||
draft: "",
|
||||
};
|
||||
@property({ attribute: false }) connected = false;
|
||||
@property({ attribute: false }) sendShortcut: ChatSendShortcut = "enter";
|
||||
@property({ attribute: false }) command: SessionRailCommand | null = null;
|
||||
@property({ attribute: false }) consumedCommandGeneration = 0;
|
||||
@property({ attribute: false }) onCommandConsumed?: (generation: number) => void;
|
||||
|
|
@ -245,9 +248,15 @@ export class ChatSessionRailElement extends OpenClawLightDomElement {
|
|||
private renderedMode: SessionRailMode = "hidden";
|
||||
private reportedMode: SessionRailMode | null = null;
|
||||
private terminalAgeReference = Date.now();
|
||||
private readonly composer = createSessionRailComposer({
|
||||
submit: () => this.submit(),
|
||||
onDraftChange: (draft) => this.onDraftChange?.(draft),
|
||||
sendShortcut: () => this.sendShortcut,
|
||||
});
|
||||
|
||||
override disconnectedCallback() {
|
||||
this.stopClock();
|
||||
this.composer.dispose();
|
||||
super.disconnectedCallback();
|
||||
}
|
||||
|
||||
|
|
@ -298,7 +307,7 @@ export class ChatSessionRailElement extends OpenClawLightDomElement {
|
|||
// Retained tabs stay mounted while hidden. Only a presentation edge owns
|
||||
// focus; history, replies, and reconnects must not interrupt another input.
|
||||
if (changedProperties.has("presented") && this.presented) {
|
||||
this.querySelector<HTMLInputElement>(".chat-session-rail__input:not(:disabled)")?.focus({
|
||||
this.querySelector<HTMLTextAreaElement>(".chat-session-rail__input:not(:disabled)")?.focus({
|
||||
preventScroll: true,
|
||||
});
|
||||
}
|
||||
|
|
@ -568,6 +577,7 @@ export class ChatSessionRailElement extends OpenClawLightDomElement {
|
|||
}
|
||||
|
||||
override render() {
|
||||
this.composer.syncDraft(this.companion.draft);
|
||||
const input = this.input();
|
||||
const mode = this.embedded ? "expanded" : this.railState.mode(input);
|
||||
this.renderedMode = mode;
|
||||
|
|
@ -706,12 +716,15 @@ export class ChatSessionRailElement extends OpenClawLightDomElement {
|
|||
>
|
||||
<div class="agent-chat__composer-input-row">
|
||||
<label class="agent-chat__composer-combobox chat-session-rail__prompt">
|
||||
<input
|
||||
<textarea
|
||||
class="chat-session-rail__input"
|
||||
type="text"
|
||||
rows="1"
|
||||
maxlength="400"
|
||||
autocomplete="off"
|
||||
aria-label=${t("chat.rail.askLabel")}
|
||||
aria-keyshortcuts=${
|
||||
this.sendShortcut === "enter" ? "Enter" : "Control+Enter Meta+Enter"
|
||||
}
|
||||
.value=${this.companion.draft}
|
||||
placeholder=${
|
||||
this.companion.pendingQuestion
|
||||
|
|
@ -719,10 +732,10 @@ export class ChatSessionRailElement extends OpenClawLightDomElement {
|
|||
: t("chat.rail.askPlaceholder")
|
||||
}
|
||||
?disabled=${!this.connected || this.companion.pendingQuestion !== null}
|
||||
@input=${(event: InputEvent) => {
|
||||
this.onDraftChange?.((event.currentTarget as HTMLInputElement).value);
|
||||
}}
|
||||
/>
|
||||
@keydown=${this.composer.handleKeydown}
|
||||
@input=${this.composer.handleInput}
|
||||
${ref(this.composer.ref)}
|
||||
></textarea>
|
||||
</label>
|
||||
</div>
|
||||
<div class="agent-chat__composer-footer">
|
||||
|
|
|
|||
|
|
@ -0,0 +1,172 @@
|
|||
import { LitElement, html } from "lit";
|
||||
import { afterEach, expect, it } from "vitest";
|
||||
import { page } from "vitest/browser";
|
||||
import "../../../styles.css";
|
||||
import "../../../styles/chat.ts";
|
||||
import { ChatTranscriptController } from "./chat-transcript-controller.ts";
|
||||
import type { TranscriptRow } from "./chat-transcript-layout.ts";
|
||||
|
||||
class EndFollowFixture extends LitElement {
|
||||
readonly transcript = new ChatTranscriptController(this);
|
||||
lastRowHeight = 900;
|
||||
|
||||
protected override createRenderRoot() {
|
||||
return this;
|
||||
}
|
||||
|
||||
protected override render() {
|
||||
const rows: TranscriptRow[] = [
|
||||
{ kind: "content", key: "earlier", content: html`<div style="height: 400px">Earlier</div>` },
|
||||
{
|
||||
kind: "content",
|
||||
key: "growing-run",
|
||||
content: html`<div style=${`height: ${this.lastRowHeight}px`}>Growing run</div>`,
|
||||
},
|
||||
];
|
||||
return html`
|
||||
<div
|
||||
class="chat-thread"
|
||||
style="height: 400px; flex: none; padding: 0 0 60px; overflow-anchor: none"
|
||||
>
|
||||
${this.transcript.renderSession(
|
||||
"end-follow-browser",
|
||||
"agent:main:end-follow",
|
||||
(session) => {
|
||||
session.setContentReady(true);
|
||||
return session.render(
|
||||
rows,
|
||||
(row) => (row.kind === "content" ? row.content : null),
|
||||
null,
|
||||
false,
|
||||
);
|
||||
},
|
||||
)}
|
||||
</div>
|
||||
<div class="chat-prs" style="position: relative; height: 38px; margin-top: -38px">
|
||||
Pull request
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
}
|
||||
customElements.define("test-transcript-end-follow", EndFollowFixture);
|
||||
|
||||
let fixture: EndFollowFixture | undefined;
|
||||
afterEach(() => {
|
||||
fixture?.remove();
|
||||
fixture = undefined;
|
||||
});
|
||||
|
||||
async function mountEndFollowFixture() {
|
||||
await page.viewport(1200, 900);
|
||||
const host = new EndFollowFixture();
|
||||
fixture = host;
|
||||
host.style.cssText = "display: block; width: 800px";
|
||||
document.body.append(host);
|
||||
await host.updateComplete;
|
||||
const thread = host.querySelector<HTMLElement>(".chat-thread")!;
|
||||
const row = host.querySelector<HTMLElement>('[data-virtual-row-key="growing-run"]')!;
|
||||
const sizer = host.querySelector<HTMLElement>(".chat-virtual-sizer")!;
|
||||
const dock = host.querySelector<HTMLElement>(".chat-prs")!;
|
||||
const distance = () => thread.scrollHeight - thread.clientHeight - thread.scrollTop;
|
||||
await expect.poll(() => sizer.offsetHeight).toBe(1300);
|
||||
return { host, thread, row, sizer, dock, distance };
|
||||
}
|
||||
|
||||
async function settleFrames() {
|
||||
await new Promise<void>((resolve) => {
|
||||
requestAnimationFrame(() => requestAnimationFrame(() => resolve()));
|
||||
});
|
||||
}
|
||||
|
||||
async function commitTask(host: EndFollowFixture, change: () => void) {
|
||||
await new Promise<void>((resolve) => {
|
||||
setTimeout(() => {
|
||||
change();
|
||||
host.requestUpdate();
|
||||
void host.updateComplete.then(() => resolve());
|
||||
}, 0);
|
||||
});
|
||||
}
|
||||
|
||||
it("tracks an outstanding end command after same-key row measurement grows the sizer", async () => {
|
||||
const { host, thread, row, sizer, dock, distance } = await mountEndFollowFixture();
|
||||
host.transcript.scrollToEnd();
|
||||
await expect.poll(distance).toBe(0);
|
||||
|
||||
const previousMax = thread.scrollHeight - thread.clientHeight;
|
||||
// A task commits growth while an end command is outstanding. Its first
|
||||
// reconciliation frame precedes ResizeObserver's measured-sizer commit.
|
||||
await new Promise<void>((resolve) => {
|
||||
setTimeout(() => {
|
||||
host.transcript.scrollToEnd({ behavior: "auto" });
|
||||
host.lastRowHeight += 48;
|
||||
host.requestUpdate();
|
||||
void host.updateComplete.then(() => resolve());
|
||||
}, 0);
|
||||
});
|
||||
await expect.poll(() => sizer.offsetHeight).toBe(1348);
|
||||
await new Promise<void>((resolve) => {
|
||||
requestAnimationFrame(() => requestAnimationFrame(() => resolve()));
|
||||
});
|
||||
const geometry = {
|
||||
distance: distance(),
|
||||
overhang: row.getBoundingClientRect().bottom - dock.getBoundingClientRect().top,
|
||||
growth: thread.scrollHeight - thread.clientHeight - previousMax,
|
||||
programmatic: host.transcript.isProgrammaticScroll,
|
||||
};
|
||||
expect(geometry, "48px growth must reach the true end").toMatchObject({
|
||||
distance: 0,
|
||||
growth: 48,
|
||||
programmatic: false,
|
||||
});
|
||||
expect(geometry.overhang).toBeLessThanOrEqual(0);
|
||||
});
|
||||
|
||||
it("does not yank a reader who left the end programmatically", async () => {
|
||||
const { host, thread, sizer, distance } = await mountEndFollowFixture();
|
||||
host.transcript.scrollToEnd();
|
||||
await expect.poll(distance).toBe(0);
|
||||
await settleFrames();
|
||||
expect(host.transcript.isProgrammaticScroll).toBe(false);
|
||||
|
||||
const previousEnd = thread.scrollTop;
|
||||
await commitTask(host, () => {
|
||||
thread.scrollTop -= 300;
|
||||
});
|
||||
const movedPosition = thread.scrollTop;
|
||||
expect(previousEnd - movedPosition).toBe(300);
|
||||
await commitTask(host, () => {
|
||||
host.lastRowHeight += 48;
|
||||
});
|
||||
await expect.poll(() => sizer.offsetHeight).toBe(1348);
|
||||
await settleFrames();
|
||||
|
||||
// A never-moved instant end command may linger until reader input or the
|
||||
// next end settle; the reader's position is the contract here.
|
||||
const geometry = {
|
||||
previousEnd,
|
||||
movedPosition,
|
||||
scrollTop: thread.scrollTop,
|
||||
displacement: thread.scrollTop - movedPosition,
|
||||
};
|
||||
expect(Math.abs(geometry.displacement)).toBeLessThanOrEqual(1);
|
||||
});
|
||||
|
||||
it("keeps a reader observed at the end pinned when a row grows without a follow", async () => {
|
||||
const { host, thread, sizer, distance } = await mountEndFollowFixture();
|
||||
// Reach the end through native observation without ever issuing an end command.
|
||||
await commitTask(host, () => {
|
||||
thread.scrollTop = thread.scrollHeight;
|
||||
});
|
||||
await expect.poll(distance).toBe(0);
|
||||
await settleFrames();
|
||||
expect(host.transcript.isProgrammaticScroll).toBe(false);
|
||||
|
||||
await commitTask(host, () => {
|
||||
host.lastRowHeight += 48;
|
||||
});
|
||||
await expect.poll(() => sizer.offsetHeight).toBe(1348);
|
||||
await settleFrames();
|
||||
expect(distance()).toBe(0);
|
||||
expect(host.transcript.isProgrammaticScroll).toBe(false);
|
||||
});
|
||||
|
|
@ -0,0 +1,96 @@
|
|||
import type { Virtualizer } from "@tanstack/virtual-core";
|
||||
import { CHAT_TRANSCRIPT_END_THRESHOLD_PX } from "../scroll.ts";
|
||||
import { maxTranscriptScrollOffset } from "./chat-transcript-geometry.ts";
|
||||
import {
|
||||
CHAT_TRANSCRIPT_SCROLL_RESTORE_STABLE_FRAMES,
|
||||
CHAT_TRANSCRIPT_ZERO_MAX_SETTLE_FRAMES,
|
||||
type ChatTranscriptPendingScrollOffset,
|
||||
} from "./chat-transcript-session.ts";
|
||||
|
||||
export type TranscriptScrollRestoreHost = {
|
||||
readonly offsetState: { pendingScrollOffset: ChatTranscriptPendingScrollOffset | null };
|
||||
getScrollElement(): HTMLDivElement | null;
|
||||
isContentReady(): boolean;
|
||||
getRowCount(): number;
|
||||
readonly virtualizer: Pick<Virtualizer<HTMLDivElement, HTMLElement>, "scrollToOffset">;
|
||||
isConnected(): boolean;
|
||||
getPendingScrollFrame(): number | null;
|
||||
setPendingScrollFrame(frame: number | null): void;
|
||||
requestUpdate(): void;
|
||||
onReaderScroll(): void;
|
||||
};
|
||||
|
||||
export function applyPendingScrollOffset(owner: TranscriptScrollRestoreHost): void {
|
||||
const pending = owner.offsetState.pendingScrollOffset;
|
||||
if (!pending || !owner.isConnected()) {
|
||||
return;
|
||||
}
|
||||
if (owner.isContentReady() && owner.getRowCount() === 0) {
|
||||
settlePendingScroll(owner, 0);
|
||||
return;
|
||||
}
|
||||
const maxOffset = maxTranscriptScrollOffset(owner.getScrollElement());
|
||||
if (maxOffset === null) {
|
||||
return;
|
||||
}
|
||||
if (maxOffset === 0 && pending.offset > 0) {
|
||||
if (owner.isContentReady()) {
|
||||
if (++pending.zeroMaxFrames > CHAT_TRANSCRIPT_ZERO_MAX_SETTLE_FRAMES) {
|
||||
settlePendingScroll(owner, 0);
|
||||
} else {
|
||||
schedulePendingScrollRetry(owner);
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
pending.zeroMaxFrames = 0;
|
||||
const targetOffset = Math.min(pending.offset, maxOffset);
|
||||
const element = owner.getScrollElement();
|
||||
if (element) {
|
||||
element.scrollTop = targetOffset;
|
||||
}
|
||||
owner.virtualizer.scrollToOffset(targetOffset);
|
||||
const currentOffset = owner.getScrollElement()?.scrollTop;
|
||||
const atTarget = currentOffset != null && Math.abs(currentOffset - targetOffset) <= 1;
|
||||
pending.stableFrames = atTarget ? pending.stableFrames + 1 : 0;
|
||||
if (
|
||||
currentOffset != null &&
|
||||
pending.stableFrames > CHAT_TRANSCRIPT_SCROLL_RESTORE_STABLE_FRAMES
|
||||
) {
|
||||
settlePendingScroll(owner, currentOffset);
|
||||
} else {
|
||||
schedulePendingScrollRetry(owner);
|
||||
}
|
||||
}
|
||||
|
||||
function schedulePendingScrollRetry(owner: TranscriptScrollRestoreHost): void {
|
||||
if (!owner.isConnected() || owner.getPendingScrollFrame() !== null) {
|
||||
return;
|
||||
}
|
||||
owner.setPendingScrollFrame(
|
||||
requestAnimationFrame(() => {
|
||||
owner.setPendingScrollFrame(null);
|
||||
if (owner.isConnected() && owner.offsetState.pendingScrollOffset) {
|
||||
owner.requestUpdate();
|
||||
}
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
function settlePendingScroll(owner: TranscriptScrollRestoreHost, scrollTop: number): void {
|
||||
const pending = owner.offsetState.pendingScrollOffset;
|
||||
owner.offsetState.pendingScrollOffset = null;
|
||||
if (!pending) {
|
||||
return;
|
||||
}
|
||||
const maxScrollTop = maxTranscriptScrollOffset(owner.getScrollElement());
|
||||
pending.onSettled?.({
|
||||
scrollTop,
|
||||
anchorToEnd:
|
||||
maxScrollTop === null
|
||||
? owner.isContentReady() && owner.getRowCount() === 0
|
||||
: maxScrollTop - scrollTop <= CHAT_TRANSCRIPT_END_THRESHOLD_PX,
|
||||
});
|
||||
// Publish the restored reader before queued hydration/resize follow runs.
|
||||
owner.onReaderScroll();
|
||||
}
|
||||
|
|
@ -51,11 +51,13 @@ import {
|
|||
previewTranscriptRowKeys,
|
||||
focusedTranscriptRowKey,
|
||||
} from "./chat-transcript-range.ts";
|
||||
import {
|
||||
applyPendingScrollOffset,
|
||||
type TranscriptScrollRestoreHost,
|
||||
} from "./chat-transcript-scroll-restore.ts";
|
||||
import {
|
||||
CHAT_TRANSCRIPT_ESTIMATED_ROW_PX,
|
||||
CHAT_TRANSCRIPT_OVERSCAN,
|
||||
CHAT_TRANSCRIPT_SCROLL_RESTORE_STABLE_FRAMES,
|
||||
CHAT_TRANSCRIPT_ZERO_MAX_SETTLE_FRAMES,
|
||||
type ChatTranscriptSession,
|
||||
type TranscriptCallbacks,
|
||||
type TranscriptHeader,
|
||||
|
|
@ -78,7 +80,9 @@ export class ChatSessionVirtualizerHost implements ReactiveControllerHost, ChatT
|
|||
private headerHeight = 0;
|
||||
private appliedHeaderHeight = 0;
|
||||
private implicitEndAnchorPending: boolean;
|
||||
private endAnchor: number | null = null;
|
||||
private pendingScrollFrame: number | null = null;
|
||||
private readonly scrollRestoreHost: TranscriptScrollRestoreHost;
|
||||
private readonly messageReveal = new ChatMessageReveal();
|
||||
// Lit calls refs before newly rendered nodes are connected. Resolve the
|
||||
// scroll parent lazily or a stable ref can permanently capture null.
|
||||
|
|
@ -122,6 +126,7 @@ export class ChatSessionVirtualizerHost implements ReactiveControllerHost, ChatT
|
|||
if (!anchor) {
|
||||
return;
|
||||
}
|
||||
this.endAnchor = null;
|
||||
this.offsetState.pendingInteractionAnchor = anchor;
|
||||
queueMicrotask(
|
||||
() => this.offsetState.pendingInteractionAnchor === anchor && this.host.requestUpdate(),
|
||||
|
|
@ -289,6 +294,20 @@ export class ChatSessionVirtualizerHost implements ReactiveControllerHost, ChatT
|
|||
scrollEndThreshold: -1,
|
||||
overscan: CHAT_TRANSCRIPT_OVERSCAN,
|
||||
});
|
||||
this.scrollRestoreHost = {
|
||||
offsetState: this.offsetState,
|
||||
virtualizer: this.virtualizerController.getVirtualizer(),
|
||||
getScrollElement: () => this.scrollElement,
|
||||
isContentReady: () => this.contentReady,
|
||||
getRowCount: () => this.rowKeys.length,
|
||||
isConnected: () => this.connected,
|
||||
getPendingScrollFrame: () => this.pendingScrollFrame,
|
||||
setPendingScrollFrame: (frame) => {
|
||||
this.pendingScrollFrame = frame;
|
||||
},
|
||||
requestUpdate: this.requestUpdate,
|
||||
onReaderScroll: () => this.callbacks.onReaderScroll?.(),
|
||||
};
|
||||
if (initialOffset !== null) {
|
||||
this.offsetState.pendingScrollOffset = {
|
||||
offset: initialOffset,
|
||||
|
|
@ -337,6 +356,7 @@ export class ChatSessionVirtualizerHost implements ReactiveControllerHost, ChatT
|
|||
for (const controller of this.controllers) {
|
||||
controller.hostUpdated?.();
|
||||
}
|
||||
const interactionResizePending = this.offsetState.pendingInteractionAnchor !== null;
|
||||
this.reconcileInteractionResize();
|
||||
if (
|
||||
!this.offsetState.touching &&
|
||||
|
|
@ -351,7 +371,11 @@ export class ChatSessionVirtualizerHost implements ReactiveControllerHost, ChatT
|
|||
this.host.requestUpdate();
|
||||
}
|
||||
this.reconcileImplicitEndAnchor();
|
||||
this.applyPendingScrollOffset();
|
||||
applyPendingScrollOffset(this.scrollRestoreHost);
|
||||
// Disclosure measurement owns this commit; its sizer lands on the next update.
|
||||
if (!interactionResizePending) {
|
||||
this.reconcileEndAnchor();
|
||||
}
|
||||
}
|
||||
|
||||
disconnect(): void {
|
||||
|
|
@ -523,12 +547,44 @@ export class ChatSessionVirtualizerHost implements ReactiveControllerHost, ChatT
|
|||
return false;
|
||||
}
|
||||
this.cancelScroll();
|
||||
this.offsetState.scrollCommand = { behavior, target: "end" };
|
||||
this.offsetState.scrollCommand = {
|
||||
behavior,
|
||||
target: "end",
|
||||
};
|
||||
this.virtualizerController.getVirtualizer().scrollToEnd({ behavior });
|
||||
if (behavior !== "smooth") {
|
||||
this.endAnchor = maxTranscriptScrollOffset(this.scrollElement);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
private reconcileEndAnchor(): void {
|
||||
const { pendingScrollOffset, touching, touchScrolling } = this.offsetState;
|
||||
if (pendingScrollOffset || touching || touchScrolling) {
|
||||
return;
|
||||
}
|
||||
const element = this.scrollElement;
|
||||
const max = maxTranscriptScrollOffset(element);
|
||||
if (!element || max === null) {
|
||||
return;
|
||||
}
|
||||
if (Math.abs(max - element.scrollTop) <= 1) {
|
||||
this.endAnchor = max;
|
||||
return;
|
||||
}
|
||||
if (this.endAnchor === null) {
|
||||
return;
|
||||
}
|
||||
if (Math.abs(element.scrollTop - this.endAnchor) > 1) {
|
||||
this.endAnchor = null;
|
||||
return;
|
||||
}
|
||||
// Row measurement can move the end after a follow or without a new command.
|
||||
this.scrollToEnd({ source: "auto", behavior: "auto" });
|
||||
}
|
||||
|
||||
private cancelScroll(): void {
|
||||
this.endAnchor = null;
|
||||
this.prependAnchor.clear();
|
||||
if (this.offsetState.scrollCommand === null && !this.offsetState.pendingScrollOffset) {
|
||||
return;
|
||||
|
|
@ -712,76 +768,4 @@ export class ChatSessionVirtualizerHost implements ReactiveControllerHost, ChatT
|
|||
virtualizer.scrollToOffset(0);
|
||||
this.host.requestUpdate();
|
||||
}
|
||||
|
||||
private applyPendingScrollOffset(): void {
|
||||
const pending = this.offsetState.pendingScrollOffset;
|
||||
if (!pending || !this.connected) {
|
||||
return;
|
||||
}
|
||||
if (this.contentReady && this.rowKeys.length === 0) {
|
||||
this.settlePendingScroll(0);
|
||||
return;
|
||||
}
|
||||
const maxOffset = maxTranscriptScrollOffset(this.scrollElement);
|
||||
if (maxOffset === null) {
|
||||
return;
|
||||
}
|
||||
if (maxOffset === 0 && pending.offset > 0) {
|
||||
if (this.contentReady) {
|
||||
if (++pending.zeroMaxFrames > CHAT_TRANSCRIPT_ZERO_MAX_SETTLE_FRAMES) {
|
||||
this.settlePendingScroll(0);
|
||||
} else {
|
||||
this.schedulePendingScrollRetry();
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
pending.zeroMaxFrames = 0;
|
||||
const targetOffset = Math.min(pending.offset, maxOffset);
|
||||
if (this.scrollElement) {
|
||||
this.scrollElement.scrollTop = targetOffset;
|
||||
}
|
||||
this.virtualizerController.getVirtualizer().scrollToOffset(targetOffset);
|
||||
const currentOffset = this.scrollElement?.scrollTop;
|
||||
const atTarget = currentOffset != null && Math.abs(currentOffset - targetOffset) <= 1;
|
||||
pending.stableFrames = atTarget ? pending.stableFrames + 1 : 0;
|
||||
if (
|
||||
currentOffset != null &&
|
||||
pending.stableFrames > CHAT_TRANSCRIPT_SCROLL_RESTORE_STABLE_FRAMES
|
||||
) {
|
||||
this.settlePendingScroll(currentOffset);
|
||||
} else {
|
||||
this.schedulePendingScrollRetry();
|
||||
}
|
||||
}
|
||||
|
||||
private schedulePendingScrollRetry(): void {
|
||||
if (!this.connected || this.pendingScrollFrame !== null) {
|
||||
return;
|
||||
}
|
||||
this.pendingScrollFrame = requestAnimationFrame(() => {
|
||||
this.pendingScrollFrame = null;
|
||||
if (this.connected && this.offsetState.pendingScrollOffset) {
|
||||
this.host.requestUpdate();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private settlePendingScroll(scrollTop: number): void {
|
||||
const pending = this.offsetState.pendingScrollOffset;
|
||||
this.offsetState.pendingScrollOffset = null;
|
||||
if (!pending) {
|
||||
return;
|
||||
}
|
||||
const maxScrollTop = maxTranscriptScrollOffset(this.scrollElement);
|
||||
pending.onSettled?.({
|
||||
scrollTop,
|
||||
anchorToEnd:
|
||||
maxScrollTop === null
|
||||
? this.contentReady && this.rowKeys.length === 0
|
||||
: maxScrollTop - scrollTop <= CHAT_TRANSCRIPT_END_THRESHOLD_PX,
|
||||
});
|
||||
// Publish the restored reader before queued hydration/resize follow runs.
|
||||
this.callbacks.onReaderScroll?.();
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue