mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-12 21:53:00 -06:00
fix(ui): model picker shifts when another user starts typing (#122809)
* fix(ui): keep composer model picker stable while typing * test(ui): prove typing keeps model picker stable Co-authored-by: Colin Johnson <colin@solvely.net> --------- Co-authored-by: Peter Steinberger <steipete@gmail.com>
This commit is contained in:
@@ -99,8 +99,16 @@ suite.define(() => {
|
||||
|
||||
await page.goto(controlUiSessionUrl(suite.server.baseUrl, sessionKey));
|
||||
const composer = page.locator(".agent-chat__composer-combobox textarea");
|
||||
const modelTrigger = page.locator(".chat-controls__model-trigger");
|
||||
const outsideTypingIndicator = page.locator(".agent-chat__typing-indicator--outside");
|
||||
await gateway.waitForRequest("session.suggestions.list");
|
||||
await expect(composer).toBeEnabled();
|
||||
await modelTrigger.waitFor();
|
||||
const idleModelBox = await modelTrigger.boundingBox();
|
||||
if (idleModelBox === null) {
|
||||
throw new Error("Expected the model trigger before remote typing");
|
||||
}
|
||||
await expect(outsideTypingIndicator).toHaveCount(0);
|
||||
await gateway.emitGatewayEvent("session.typing", {
|
||||
sessionKey: "main",
|
||||
sessionId: "session-main",
|
||||
@@ -110,6 +118,19 @@ suite.define(() => {
|
||||
ts: Date.now(),
|
||||
});
|
||||
await expect(page.locator(".agent-chat__typing-text")).toHaveText("Owner is typing…");
|
||||
const [typingModelBox, typingIndicatorBox, composerShellBox] = await Promise.all([
|
||||
modelTrigger.boundingBox(),
|
||||
outsideTypingIndicator.boundingBox(),
|
||||
page.locator(".agent-chat__composer-shell").boundingBox(),
|
||||
]);
|
||||
if (typingModelBox === null || typingIndicatorBox === null || composerShellBox === null) {
|
||||
throw new Error("Expected the composer layout after remote typing");
|
||||
}
|
||||
expect(Math.abs(typingModelBox.x - idleModelBox.x)).toBeLessThanOrEqual(0.5);
|
||||
expect(Math.abs(typingModelBox.y - idleModelBox.y)).toBeLessThanOrEqual(2);
|
||||
expect(typingIndicatorBox.y + typingIndicatorBox.height).toBeLessThanOrEqual(
|
||||
composerShellBox.y + 1,
|
||||
);
|
||||
await composer.fill("Try the focused change");
|
||||
const typing = await gateway.waitForRequest("session.typing");
|
||||
expect(typing.params).toMatchObject({ sessionId: "session-main" });
|
||||
|
||||
@@ -209,12 +209,14 @@ describe("renderChatComposer controls", () => {
|
||||
actionLabel: "Unarchive",
|
||||
onAction,
|
||||
},
|
||||
typingActors: [{ id: "ayaan", label: "Ayaan" }],
|
||||
});
|
||||
|
||||
const banner = container.querySelector(".agent-chat__disabled-banner");
|
||||
expect(banner?.textContent).toContain("This session is archived.");
|
||||
expect(container.querySelector(".agent-chat__input")).toBeNull();
|
||||
expect(container.querySelector("textarea")).toBeNull();
|
||||
expect(container.querySelector(".agent-chat__typing-indicator--outside")).toBeNull();
|
||||
banner?.querySelector<HTMLButtonElement>("button")?.click();
|
||||
expect(onAction).toHaveBeenCalledOnce();
|
||||
button(container, t("chat.runControls.stopGenerating")).click();
|
||||
@@ -832,39 +834,6 @@ describe("renderChatComposer controls", () => {
|
||||
});
|
||||
|
||||
describe("renderChatComposer status", () => {
|
||||
it.each([
|
||||
{
|
||||
actors: [{ id: "ayaan", label: "Ayaan" }],
|
||||
expectedText: "Ayaan is typing…",
|
||||
expectedAvatars: 1,
|
||||
},
|
||||
{
|
||||
actors: [
|
||||
{ id: "ayaan", label: "Ayaan" },
|
||||
{ id: "liam", label: "Liam" },
|
||||
{ id: "maya", label: "Maya" },
|
||||
{ id: "zoe", label: "Zoe" },
|
||||
],
|
||||
expectedText: "Ayaan, Liam, Maya, Zoe are typing…",
|
||||
expectedAvatars: 3,
|
||||
},
|
||||
])(
|
||||
"keeps $expectedText in the permanent composer footer",
|
||||
({ actors, expectedText, expectedAvatars }) => {
|
||||
const { container } = renderComposer({ typingActors: actors });
|
||||
|
||||
const indicator = container.querySelector(".agent-chat__typing-indicator");
|
||||
expect(indicator?.closest(".agent-chat__composer-footer")).not.toBeNull();
|
||||
expect(indicator?.closest(".agent-chat__input")?.firstElementChild).not.toBe(indicator);
|
||||
expect(indicator?.querySelectorAll(".chat-author-avatar")).toHaveLength(expectedAvatars);
|
||||
// The status text already names every typer; avatars must stay out of the
|
||||
// accessibility tree or screen readers announce each name twice.
|
||||
const avatars = indicator?.querySelector(".agent-chat__typing-avatars");
|
||||
expect(avatars?.getAttribute("aria-hidden")).toBe("true");
|
||||
expect(indicator?.textContent).toContain(expectedText);
|
||||
},
|
||||
);
|
||||
|
||||
it("swaps the expanded question with the composer and restores its draft and focus", async () => {
|
||||
const container = document.createElement("div");
|
||||
document.body.append(container);
|
||||
@@ -876,6 +845,7 @@ describe("renderChatComposer status", () => {
|
||||
gatewayQuestionPrompts: [],
|
||||
composerControls: html`<button type="button">Model</button>`,
|
||||
onRequestUpdate: vi.fn(),
|
||||
typingActors: [{ id: "ayaan", label: "Ayaan" }],
|
||||
});
|
||||
composerProps.onDraftChange = (next) => {
|
||||
composerProps.draft = next;
|
||||
@@ -900,6 +870,7 @@ describe("renderChatComposer status", () => {
|
||||
await panel.updateComplete;
|
||||
expect(container.querySelector(".agent-chat__input")).toBeNull();
|
||||
expect(container.querySelector(".agent-chat__composer-footer")).toBeNull();
|
||||
expect(container.querySelector(".agent-chat__typing-indicator--outside")).toBeNull();
|
||||
expect(document.activeElement).toBe(panel.querySelector(".chat-question-panel"));
|
||||
expect(composerProps.draft).toBe("Keep this draft while composing");
|
||||
|
||||
|
||||
@@ -425,6 +425,18 @@ function chatHtml(opts: ChatFixtureOptions = {}, mobileNavLayout = false) {
|
||||
</openclaw-chat-session-rail>`
|
||||
: ""
|
||||
}
|
||||
${
|
||||
opts.crowdedComposerFooter
|
||||
? `<div class="agent-chat__typing-indicator agent-chat__typing-indicator--outside" role="status">
|
||||
<span class="agent-chat__typing-avatars" aria-hidden="true">
|
||||
<span class="chat-author-avatar">A</span>
|
||||
<span class="chat-author-avatar">B</span>
|
||||
<span class="chat-author-avatar">C</span>
|
||||
</span>
|
||||
<span class="agent-chat__typing-text">Alexandria, Bartholomew, and Cassandra are typing</span>
|
||||
</div>`
|
||||
: ""
|
||||
}
|
||||
<div class="agent-chat__composer-shell">
|
||||
<div class="agent-chat__input">
|
||||
${
|
||||
@@ -483,18 +495,6 @@ function chatHtml(opts: ChatFixtureOptions = {}, mobileNavLayout = false) {
|
||||
</div>
|
||||
<div class="agent-chat__composer-footer">
|
||||
${composerControlsHtml(opts.crowdedComposerFooter)}
|
||||
${
|
||||
opts.crowdedComposerFooter
|
||||
? `<div class="agent-chat__typing-indicator" role="status">
|
||||
<span class="agent-chat__typing-avatars" aria-hidden="true">
|
||||
<span class="chat-author-avatar">A</span>
|
||||
<span class="chat-author-avatar">B</span>
|
||||
<span class="chat-author-avatar">C</span>
|
||||
</span>
|
||||
<span class="agent-chat__typing-text">Alexandria, Bartholomew, and Cassandra are typing</span>
|
||||
</div>`
|
||||
: ""
|
||||
}
|
||||
<div class="agent-chat__composer-meta">
|
||||
<div class="context-usage">
|
||||
<details>
|
||||
@@ -2533,7 +2533,7 @@ describeBrowserLayout.concurrent("chat responsive browser layout", () => {
|
||||
modelLabel: rectFor(".chat-controls__model-trigger .chat-controls__inline-select-label"),
|
||||
overrides: rectFor(".agent-chat__session-overrides-pill"),
|
||||
status: rectFor(".agent-chat__composer-run-status"),
|
||||
typing: rectFor(".agent-chat__typing-indicator"),
|
||||
typing: rectFor(".agent-chat__typing-indicator--outside"),
|
||||
};
|
||||
});
|
||||
|
||||
@@ -2559,11 +2559,11 @@ describeBrowserLayout.concurrent("chat responsive browser layout", () => {
|
||||
[layout.status, layout.overrides],
|
||||
[layout.overrides, layout.model],
|
||||
[layout.model, layout.effort],
|
||||
[layout.effort, layout.typing],
|
||||
[layout.typing, layout.meta],
|
||||
[layout.effort, layout.meta],
|
||||
] as const) {
|
||||
expect(rectsOverlap(left, right)).toBe(false);
|
||||
}
|
||||
expect(rectsOverlap(layout.typing, layout.footer)).toBe(false);
|
||||
} finally {
|
||||
await closeBrowserPage(page);
|
||||
}
|
||||
|
||||
@@ -748,6 +748,67 @@ function renderChatInto(container: HTMLElement, overrides: Partial<ChatProps> =
|
||||
render(renderChat(createChatProps(overrides)), container);
|
||||
}
|
||||
|
||||
describe("chat typing status", () => {
|
||||
it.each([
|
||||
{
|
||||
actors: [{ id: "ayaan", label: "Ayaan" }],
|
||||
expectedText: "Ayaan is typing…",
|
||||
expectedAvatars: 1,
|
||||
},
|
||||
{
|
||||
actors: [
|
||||
{ id: "ayaan", label: "Ayaan" },
|
||||
{ id: "liam", label: "Liam" },
|
||||
{ id: "maya", label: "Maya" },
|
||||
{ id: "zoe", label: "Zoe" },
|
||||
],
|
||||
expectedText: "Ayaan, Liam, Maya, Zoe are typing…",
|
||||
expectedAvatars: 3,
|
||||
},
|
||||
])("renders $expectedText above the composer", ({ actors, expectedText, expectedAvatars }) => {
|
||||
const container = renderChatView({ typingActors: actors });
|
||||
const indicator = container.querySelector(".agent-chat__typing-indicator--outside");
|
||||
|
||||
expect(indicator?.nextElementSibling?.classList.contains("agent-chat__composer-shell")).toBe(
|
||||
true,
|
||||
);
|
||||
expect(indicator?.closest(".agent-chat__composer-shell")).toBeNull();
|
||||
expect(indicator?.querySelectorAll(".chat-author-avatar")).toHaveLength(expectedAvatars);
|
||||
expect(
|
||||
indicator?.querySelector(".agent-chat__typing-avatars")?.getAttribute("aria-hidden"),
|
||||
).toBe("true");
|
||||
expect(indicator?.textContent).toContain(expectedText);
|
||||
});
|
||||
|
||||
it("keeps queue and error state above the typing status", () => {
|
||||
const container = renderChatView({
|
||||
typingActors: [{ id: "ayaan", label: "Ayaan" }],
|
||||
runError: { summary: "Gateway unavailable" },
|
||||
queue: [{ id: "queued", text: "Try again", createdAt: 1 }],
|
||||
});
|
||||
const indicator = requireElement(
|
||||
container,
|
||||
".agent-chat__typing-indicator--outside",
|
||||
"typing status",
|
||||
);
|
||||
|
||||
expect(indicator.previousElementSibling?.classList.contains("chat-run-error")).toBe(true);
|
||||
expect(indicator.nextElementSibling?.classList.contains("agent-chat__composer-shell")).toBe(
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
it("hides typing status with the model setup composer", () => {
|
||||
const container = renderChatView({
|
||||
canSend: false,
|
||||
modelSetupRequired: true,
|
||||
typingActors: [{ id: "ayaan", label: "Ayaan" }],
|
||||
});
|
||||
|
||||
expect(container.querySelector(".agent-chat__typing-indicator--outside")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
function createSessionWorkspace(
|
||||
overrides: Partial<NonNullable<ChatProps["sessionWorkspace"]>> = {},
|
||||
): NonNullable<ChatProps["sessionWorkspace"]> {
|
||||
|
||||
@@ -176,6 +176,29 @@ export function renderChatComposerView(context: ChatComposerViewContext) {
|
||||
</div>
|
||||
`
|
||||
: nothing}
|
||||
${showComposerInput && props.typingActors?.length
|
||||
? html`<div
|
||||
class="agent-chat__typing-indicator agent-chat__typing-indicator--outside"
|
||||
role="status"
|
||||
>
|
||||
<!-- Avatars stay aria-hidden: the status text already names every
|
||||
typer, and role="img" avatars would announce each name twice. -->
|
||||
<span class="agent-chat__typing-avatars" aria-hidden="true">
|
||||
${props.typingActors
|
||||
.slice(0, 3)
|
||||
.map((actor) => renderChatAuthorAvatar({ id: actor.id, name: actor.label }))}
|
||||
</span>
|
||||
<span class="agent-chat__typing-text"
|
||||
>${props.typingActors.length === 1
|
||||
? t("chat.sessionSuggestions.typing", {
|
||||
name: props.typingActors[0]?.label ?? "",
|
||||
})
|
||||
: t("chat.sessionSuggestions.typingMany", {
|
||||
names: props.typingActors.map((actor) => actor.label).join(", "),
|
||||
})}</span
|
||||
>
|
||||
</div>`
|
||||
: nothing}
|
||||
<div class="agent-chat__composer-shell">
|
||||
${questionPanelProps
|
||||
? html`
|
||||
@@ -505,28 +528,6 @@ export function renderChatComposerView(context: ChatComposerViewContext) {
|
||||
</div>
|
||||
`
|
||||
: nothing}
|
||||
${props.typingActors?.length
|
||||
? html`<div class="agent-chat__typing-indicator" role="status">
|
||||
<!-- Avatars stay aria-hidden: the status text already names every
|
||||
typer, and role="img" avatars would announce each name twice. -->
|
||||
<span class="agent-chat__typing-avatars" aria-hidden="true">
|
||||
${props.typingActors
|
||||
.slice(0, 3)
|
||||
.map((actor) =>
|
||||
renderChatAuthorAvatar({ id: actor.id, name: actor.label }),
|
||||
)}
|
||||
</span>
|
||||
<span class="agent-chat__typing-text"
|
||||
>${props.typingActors.length === 1
|
||||
? t("chat.sessionSuggestions.typing", {
|
||||
name: props.typingActors[0]?.label ?? "",
|
||||
})
|
||||
: t("chat.sessionSuggestions.typingMany", {
|
||||
names: props.typingActors.map((actor) => actor.label).join(", "),
|
||||
})}</span
|
||||
>
|
||||
</div>`
|
||||
: nothing}
|
||||
<div class="agent-chat__composer-meta">${contextNotice}</div>
|
||||
</div>
|
||||
</div>`
|
||||
|
||||
@@ -1756,6 +1756,14 @@ button.chat-reply-preview--message:disabled {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.agent-chat__typing-indicator--outside {
|
||||
box-sizing: border-box;
|
||||
width: calc(100% - 36px);
|
||||
max-width: var(--chat-thread-max-width, 48rem);
|
||||
margin: 0 auto -2px;
|
||||
padding-inline: 8px;
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.session-suggestion {
|
||||
grid-template-columns: auto minmax(0, 1fr);
|
||||
@@ -3772,11 +3780,6 @@ button.chat-reply-preview--message:disabled {
|
||||
min-width: 44px;
|
||||
}
|
||||
|
||||
.agent-chat__input .agent-chat__typing-indicator {
|
||||
flex: 1 1 auto;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* The borderless trigger is 30px on desktop; keep a >=44px touch target
|
||||
inside the mobile composer row. */
|
||||
.agent-chat__input .agent-chat__composer-controls .chat-controls__inline-select-trigger {
|
||||
|
||||
Reference in New Issue
Block a user