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:
Peter Steinberger 2026-09-11 01:26:25 -07:00 • committed by GitHub
parent d58b2ad33f
commit de57e39408
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
16 changed files with 919 additions and 89 deletions

1
.gitignore vendored
View file

@ -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

View file

@ -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

View file

@ -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.

View 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();
}
});
});

View file

@ -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" });

View 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("");
});
},
);
});

View file

@ -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 }],

View file

@ -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) => {

View file

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

View file

@ -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">

View file

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

View 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);
}
},
};
}

View file

@ -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">

View file

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

View file

@ -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();
}

View file

@ -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?.();
}
}