fix(webchat): keep media loads pinned to bottom (#97575)

* fix(webchat): keep media loads pinned to bottom

* test(webchat): type message group mock options
This commit is contained in:
Andy Ye
2026-06-28 16:04:06 -07:00
committed by GitHub
parent 33aef6582c
commit 180a960ede
7 changed files with 87 additions and 18 deletions
+1
View File
@@ -3902,6 +3902,7 @@ export function renderApp(state: AppViewState) {
},
showNewMessages: state.chatNewMessagesBelow && !state.chatManualRefreshInFlight,
onScrollToBottom: () => state.scrollToBottom(),
onAssistantAttachmentLoaded: () => state.scheduleChatScroll(),
// Sidebar props for tool output viewing
sidebarOpen: state.sidebarOpen,
sidebarContent: state.sidebarContent,
+1
View File
@@ -182,6 +182,7 @@ export type AppViewState = {
sidebarError: string | null;
splitRatio: number;
scrollToBottom: (opts?: { smooth?: boolean }) => void;
scheduleChatScroll: () => void;
devicesLoading: boolean;
devicesError: string | null;
devicesList: DevicePairingList | null;
+4
View File
@@ -985,6 +985,10 @@ export class OpenClawApp extends LitElement {
);
}
scheduleChatScroll() {
scheduleChatScrollInternal(this as unknown as Parameters<typeof scheduleChatScrollInternal>[0]);
}
async loadAssistantIdentity(opts?: { sessionKey?: string; expectedSessionKey?: string }) {
await loadAssistantIdentityInternal(this, opts);
}
+26
View File
@@ -1461,6 +1461,32 @@ describe("grouped chat rendering", () => {
);
});
it("notifies when assistant audio and video attachment metadata loads", () => {
const container = document.createElement("div");
const onAssistantAttachmentLoaded = vi.fn();
renderAssistantMessage(
container,
{
id: "assistant-media-layout",
role: "assistant",
content:
"Audio and video\nMEDIA:https://example.com/voice.ogg\nMEDIA:https://example.com/clip.mp4",
timestamp: Date.now(),
},
{ showToolCalls: false, onAssistantAttachmentLoaded },
);
expectElement(container, "audio", HTMLAudioElement).dispatchEvent(
new Event("loadedmetadata", { bubbles: true }),
);
expectElement(container, "video", HTMLVideoElement).dispatchEvent(
new Event("loadedmetadata", { bubbles: true }),
);
expect(onAssistantAttachmentLoaded).toHaveBeenCalledTimes(2);
});
it("renders allowed transcript and content image variants", async () => {
resetAssistantAttachmentAvailabilityCacheForTest();
const fetchMock = vi.fn(async (url: string, init?: RequestInit) => {
+18 -2
View File
@@ -421,6 +421,7 @@ type RenderMessageGroupOptions = {
isToolExpanded?: (toolCardId: string) => boolean;
onToggleToolExpanded?: (toolCardId: string) => void;
onRequestUpdate?: () => void;
onAssistantAttachmentLoaded?: () => void;
assistantName?: string;
assistantAvatar?: string | null;
userName?: string | null;
@@ -456,6 +457,7 @@ function buildGroupedMessageRenderOptions(
isToolExpanded: opts.isToolExpanded,
onToggleToolExpanded: opts.onToggleToolExpanded,
onRequestUpdate: opts.onRequestUpdate,
onAssistantAttachmentLoaded: opts.onAssistantAttachmentLoaded,
canvasPluginSurfaceUrl: opts.canvasPluginSurfaceUrl,
basePath: opts.basePath,
localMediaPreviewRoots: opts.localMediaPreviewRoots,
@@ -1373,6 +1375,7 @@ function renderAssistantAttachments(
basePath?: string,
authToken?: string | null,
onRequestUpdate?: () => void,
onAssistantAttachmentLoaded?: () => void,
) {
if (attachments.length === 0) {
return nothing;
@@ -1424,7 +1427,12 @@ function renderAssistantAttachments(
: nothing}
</div>
${attachmentUrl
? html`<audio controls preload="metadata" src=${attachmentUrl}></audio>`
? html`<audio
controls
preload="metadata"
src=${attachmentUrl}
@loadedmetadata=${() => onAssistantAttachmentLoaded?.()}
></audio>`
: availability.status === "unavailable"
? html`<div class="chat-assistant-attachment-card__reason">
${availability.reason}
@@ -1444,7 +1452,12 @@ function renderAssistantAttachments(
}
return html`
<div class="chat-assistant-attachment-card chat-assistant-attachment-card--video">
<video controls preload="metadata" src=${attachmentUrl}></video>
<video
controls
preload="metadata"
src=${attachmentUrl}
@loadedmetadata=${() => onAssistantAttachmentLoaded?.()}
></video>
<a
class="chat-assistant-attachment-card__link"
href=${attachmentUrl}
@@ -1615,6 +1628,7 @@ function renderGroupedMessage(
basePath?: string;
localMediaPreviewRoots?: readonly string[];
assistantAttachmentAuthToken?: string | null;
onAssistantAttachmentLoaded?: () => void;
embedSandboxMode?: EmbedSandboxMode;
allowExternalEmbedUrls?: boolean;
},
@@ -1812,6 +1826,7 @@ function renderGroupedMessage(
opts.basePath,
opts.assistantAttachmentAuthToken,
opts.onRequestUpdate,
opts.onAssistantAttachmentLoaded,
)}
${reasoningMarkdown
? html`<div class="chat-thinking">
@@ -1869,6 +1884,7 @@ function renderGroupedMessage(
opts.basePath,
opts.assistantAttachmentAuthToken,
opts.onRequestUpdate,
opts.onAssistantAttachmentLoaded,
)}
${reasoningMarkdown
? html`<div class="chat-thinking">
+35 -16
View File
@@ -102,23 +102,28 @@ const buildChatItemsMock = vi.hoisted(() =>
}),
);
const renderMessageGroupMock = vi.hoisted(() =>
vi.fn((group: { messages: Array<{ message: unknown }> }) => {
const element = document.createElement("div");
element.className = "chat-group";
element.textContent = group.messages
.map(({ message }) => {
if (typeof message === "object" && message !== null && "content" in message) {
const content = (message as { content?: unknown }).content;
if (typeof content === "string") {
return content;
vi.fn(
(
group: { messages: Array<{ message: unknown }> },
_opts?: { onAssistantAttachmentLoaded?: () => void },
) => {
const element = document.createElement("div");
element.className = "chat-group";
element.textContent = group.messages
.map(({ message }) => {
if (typeof message === "object" && message !== null && "content" in message) {
const content = (message as { content?: unknown }).content;
if (typeof content === "string") {
return content;
}
return content == null ? "" : JSON.stringify(content);
}
return content == null ? "" : JSON.stringify(content);
}
return String(message);
})
.join("\n");
return element;
}),
return String(message);
})
.join("\n");
return element;
},
),
);
const assistantAttachmentRenderVersionMock = vi.hoisted(() => ({ value: 0 }));
@@ -1145,6 +1150,20 @@ describe("chat transcript rendering cache", () => {
expect(renderMessageGroupMock).toHaveBeenCalledTimes(2);
});
it("passes assistant attachment load callbacks to transcript groups", () => {
const onAssistantAttachmentLoaded = vi.fn();
renderChatView({
messages: [{ role: "assistant", content: "MEDIA:https://example.com/voice.ogg" }],
onAssistantAttachmentLoaded,
});
expect(renderMessageGroupMock).toHaveBeenCalledTimes(1);
expect(renderMessageGroupMock.mock.calls[0]?.[1]).toMatchObject({
onAssistantAttachmentLoaded,
});
});
it("rebuilds transcript items when the transcript reference changes", () => {
const toolMessages: unknown[] = [];
const streamSegments: Array<{ text: string; ts: number }> = [];
+2
View File
@@ -163,6 +163,7 @@ export type ChatProps = {
onAttachmentsChange?: (attachments: ChatAttachment[]) => void;
showNewMessages?: boolean;
onScrollToBottom?: () => void;
onAssistantAttachmentLoaded?: () => void;
onRefresh: () => void;
onToggleFocusMode?: () => void;
getDraft?: () => string;
@@ -2282,6 +2283,7 @@ export function renderChat(props: ChatProps) {
expandedToolCards.get(toolCardId) ?? false,
onToggleToolExpanded: toggleToolCardExpanded,
onRequestUpdate: requestUpdate,
onAssistantAttachmentLoaded: props.onAssistantAttachmentLoaded,
assistantName: props.assistantName,
assistantAvatar: assistantIdentity.avatar,
userName: props.userName ?? null,