feat(ui): attach message queue to composer

This commit is contained in:
vyctorbrzezowski
2026-08-21 05:31:17 -03:00
parent 83a3a9093e
commit e6beac478b
10 changed files with 250 additions and 143 deletions
+38 -1
View File
@@ -79,6 +79,20 @@ const OBSERVER_DEMO_RUN_ID = "mock-session-observer-run";
const PLAN_DEMO_RUN_ID = "mock-plan-run";
const CUSTODIAN_CHAT_REPLY_DELAY_MS = 600;
const CHAT_SEND_REPLY_DELAY_MS = 200;
const COMPOSER_QUEUE_FIXTURE = [
"Audit the composer spacing against the desktop reference.",
"Keep the queue attached to the composer in both themes.",
"Check the row actions and make sure the drag handle only appears while hovering the message.",
"This deliberately longer queued message should wrap onto a second line so the compact row anatomy can be reviewed without clipping or hiding the actual operator text.",
"Verify keyboard reordering before the final visual pass.",
"Capture the light and dark states for comparison.",
].map((text, index) => ({
id: `mock-composer-queue-${index + 1}`,
text,
createdAt: Date.parse("2026-05-22T09:00:00.000Z") + index,
orderKey: index + 1,
kind: "queued" as const,
}));
const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
const uiRoot = path.join(repoRoot, "ui");
@@ -2878,9 +2892,32 @@ function createStatefulMockInitScript(): string {
return `(() => { const __name = (target) => target; (${installControlUiStatefulMocks.toString()})(${CUSTODIAN_CHAT_REPLY_DELAY_MS}, ${CHAT_SEND_REPLY_DELAY_MS}); })();`;
}
function installComposerQueueFixture(queue: typeof COMPOSER_QUEUE_FIXTURE): void {
const install = () => {
const pane = document.querySelector("openclaw-chat-pane") as
| (HTMLElement & {
state?: { chatQueue: typeof queue };
requestUpdate?: () => void;
})
| null;
if (!pane?.state) {
window.setTimeout(install, 50);
return;
}
pane.state.chatQueue = queue;
pane.requestUpdate?.();
};
install();
}
function createComposerQueueFixtureScript(): string {
return `(() => { const __name = (target) => target; (${installComposerQueueFixture.toString()})(${JSON.stringify(COMPOSER_QUEUE_FIXTURE)}); })();`;
}
function createMockGatewayPlugin(scenario: ControlUiMockGatewayScenario): Plugin {
const initScript = escapeScriptContent(createControlUiMockGatewayInitScript(scenario));
const statefulInitScript = escapeScriptContent(createStatefulMockInitScript());
const composerQueueFixtureScript = escapeScriptContent(createComposerQueueFixtureScript());
const bootstrapBody = JSON.stringify(createControlUiMockBootstrapConfig(scenario));
return {
configureServer(server) {
@@ -2898,7 +2935,7 @@ function createMockGatewayPlugin(scenario: ControlUiMockGatewayScenario): Plugin
transformIndexHtml(html) {
return html.replace(
"</head>",
` <script data-openclaw-control-ui-mock-gateway>\n${initScript}\n${statefulInitScript}\n </script>\n </head>`,
` <script data-openclaw-control-ui-mock-gateway>\n${composerQueueFixtureScript}\n${initScript}\n${statefulInitScript}\n </script>\n </head>`,
);
},
};
+4
View File
@@ -288,6 +288,10 @@ export const icons = {
<path d="M13 6h8" />
<path d="M13 12h8" />
<path d="M13 18h8" />`),
queueList: strokeIcon(svg` <path d="M4 6h16" />
<path d="M8 12h12" />
<path d="M8 18h12" />
<path d="m4 10 2 2-2 2" />`),
...toolIcons,
} as const;
+10 -6
View File
@@ -1,3 +1,4 @@
import type { Locator } from "playwright";
import { expect, it } from "vitest";
import {
createChatFlowE2eSuite,
@@ -12,6 +13,11 @@ const suite = createChatFlowE2eSuite();
const QUEUED = ["review the migration", "then update the docs", "finally run the smoke"] as const;
async function openQueuedMessageEditor(row: Locator) {
await row.locator(".chat-queue__more").click();
await row.locator("wa-dropdown-item[value='edit']").click();
}
suite.define(() => {
it("edits a queued message in its row and returns it to its place", async () => {
const context = await suite.newBrowserContext({
@@ -48,7 +54,7 @@ suite.define(() => {
await composer.fill("a separate composer draft");
// Double-click is the shortcut; the pencil on the row is the visible path.
// Double-click remains the shortcut; Edit message in overflow is the visible path.
await page.locator(".chat-queue__item").nth(1).dblclick();
const rowEditor = page.locator(".chat-queue__item").nth(1).locator(".chat-queue__edit-input");
@@ -97,7 +103,7 @@ suite.define(() => {
await composer.fill("a separate composer draft");
const row = page.locator(".chat-queue__item").nth(1);
await row.locator(".chat-queue__edit").click();
await openQueuedMessageEditor(row);
const rowEditor = row.locator(".chat-queue__edit-input");
await rowEditor.waitFor({ timeout: 10_000 });
await rowEditor.fill("a replacement the operator abandons");
@@ -137,7 +143,7 @@ suite.define(() => {
}
const row = page.locator(".chat-queue__item").nth(1);
await row.locator(".chat-queue__edit").click();
await openQueuedMessageEditor(row);
const rowEditor = row.locator(".chat-queue__edit-input");
await rowEditor.waitFor({ timeout: 10_000 });
await composer.fill("a separate composer send");
@@ -205,9 +211,7 @@ suite.define(() => {
await page.locator(".agent-chat__offline-hint").waitFor({ timeout: 10_000 });
const editRow = page.locator(".chat-queue__item", { hasText: "edit before send" });
const editButton = editRow.locator(".chat-queue__edit");
expect(await editButton.isDisabled()).toBe(false);
await editButton.click();
await openQueuedMessageEditor(editRow);
// `hasText` stops matching once the row text becomes a textarea value.
const inlineEditor = page.locator(".chat-queue__edit-input");
await inlineEditor.waitFor({ timeout: 10_000 });
+1
View File
@@ -5560,6 +5560,7 @@ export const en: TranslationMap = {
steer: "Steer",
steerQueuedMessage: "Steer queued message",
removeQueuedMessage: "Remove queued message",
moreActions: "More queued message actions",
reorderQueuedMessage: "Reorder queued message with the arrow keys",
reorderUnavailable: "This message holds its place and cannot be reordered",
editQueuedMessage: "Edit queued message",
@@ -470,7 +470,7 @@ describe("renderChatComposer controls", () => {
expect(onAbort).not.toHaveBeenCalled();
});
it("renders the queued author's avatar before the turn is submitted", async () => {
it("renders the queue glyph before the turn is submitted", async () => {
const { container } = renderComposer({
queue: [
{
@@ -478,15 +478,12 @@ describe("renderChatComposer controls", () => {
text: "queued during the run",
createdAt: 4,
sendState: "waiting-idle",
sender: { id: "profile_123", name: "Alice Example" },
},
],
});
await vi.waitFor(() => {
expect(
container.querySelector(".chat-queue__item .chat-author-avatar__initials")?.textContent,
).toContain("AE");
expect(container.querySelector(".chat-queue__item .chat-queue__icon")).not.toBeNull();
});
});
@@ -504,8 +501,7 @@ describe("renderChatComposer controls", () => {
});
const item = container.querySelector(".chat-queue__item");
expect(item?.classList.contains("chat-queue__item--reconnect")).toBe(true);
expect(item?.querySelector(".chat-queue__dot")).not.toBeNull();
expect(item?.querySelector(".chat-queue__icon")).toBeNull();
expect(item?.querySelector(".chat-queue__icon")).not.toBeNull();
expect(item?.querySelector(".chat-queue__error")).toBeNull();
const badge = item?.querySelector(".chat-queue__badge");
expect(badge?.textContent?.trim()).toBe("Waiting for reconnect");
+15 -23
View File
@@ -91,14 +91,14 @@ describe("chat composer queue reordering", () => {
const rows = container.querySelectorAll(".chat-queue__item");
expect(rows).toHaveLength(2);
expect([...rows].map((row) => row.getAttribute("draggable"))).toEqual(["true", "true"]);
const grips = [...container.querySelectorAll(".chat-queue__grip")];
expect(grips).toHaveLength(2);
expect(grips[0]?.tagName).toBe("BUTTON");
expect(grips[0]?.getAttribute("aria-label")).toBe(t("chat.queue.reorderQueuedMessage"));
expect(grips[0]?.getAttribute("aria-keyshortcuts")).toBe("ArrowUp ArrowDown");
// The row carries no overflow menu: the handle is the whole reorder surface.
expect(container.querySelector("wa-dropdown")).toBeNull();
expect(grips.map((grip) => grip.getAttribute("draggable"))).toEqual(["true", "true"]);
expect([...rows].every((row) => !row.hasAttribute("draggable"))).toBe(true);
expect(container.querySelectorAll("wa-dropdown.chat-queue__overflow")).toHaveLength(2);
});
it.each([
@@ -144,7 +144,7 @@ describe("chat composer queue reordering", () => {
});
expect(container.querySelector(".chat-queue__grip")).toBeNull();
expect(container.querySelector(".chat-queue__item")?.getAttribute("draggable")).toBe("false");
expect(container.querySelector(".chat-queue__item")?.hasAttribute("draggable")).toBe(false);
});
it("reserves the handle column on every row so the pills never shift", () => {
@@ -211,11 +211,6 @@ describe("chat composer queue reordering", () => {
});
const rows = [...container.querySelectorAll(".chat-queue__item")];
expect(rows.map((row) => row.querySelector(".chat-queue__edit") !== null)).toEqual([
true,
false,
true,
]);
expect(rows[1]?.querySelector(".chat-queue__edit-input")).not.toBeNull();
expect(rows[1]?.querySelector(".chat-queue__edit-submit")).not.toBeNull();
expect(rows[1]?.querySelector(".chat-queue__edit-cancel")).not.toBeNull();
@@ -225,13 +220,11 @@ describe("chat composer queue reordering", () => {
true,
]);
const disabled = (selector: string) =>
rows.map((row) => row.querySelector(selector)?.hasAttribute("disabled") ?? false);
expect(disabled(".chat-queue__edit")).toEqual([true, false, true]);
expect(disabled(".chat-queue__remove")).toEqual([false, false, false]);
rows[0]?.querySelector<HTMLButtonElement>(".chat-queue__edit")?.click();
expect(onQueueEdit).not.toHaveBeenCalled();
const editItem = rows[0]?.querySelector("wa-dropdown-item[value='edit']");
expect(editItem?.hasAttribute("disabled")).toBe(true);
expect(rows[2]?.querySelector("wa-dropdown-item[value='edit']")?.hasAttribute("disabled")).toBe(
true,
);
rows[2]?.querySelector<HTMLButtonElement>(".chat-queue__remove")?.click();
expect(onQueueRemove).toHaveBeenCalledWith("c");
@@ -275,7 +268,9 @@ describe("chat composer queue reordering", () => {
});
const rows = [...container.querySelectorAll(".chat-queue__item")];
expect(rows.map((row) => row.getAttribute("draggable"))).toEqual(["false", "true", "true"]);
expect(
rows.map((row) => row.querySelector(".chat-queue__grip")?.getAttribute("draggable")),
).toEqual(["false", "true", "true"]);
});
it("offers no move to a row alone between locked rows, and refuses a drop from across one", () => {
@@ -293,12 +288,9 @@ describe("chat composer queue reordering", () => {
const rows = [...container.querySelectorAll(".chat-queue__item")];
// "a" is a segment of one, so it has nothing to move against.
expect(rows.map((row) => row.getAttribute("draggable"))).toEqual([
"false",
"false",
"true",
"true",
]);
expect(
rows.map((row) => row.querySelector(".chat-queue__grip")?.getAttribute("draggable")),
).toEqual(["false", "false", "true", "true"]);
const dataTransfer = {
types: ["application/x-openclaw-queued-message"],
@@ -101,7 +101,7 @@ function renderChatQueueItem(
) {
const stateLabel = sendStateLabel(item);
const failed = item.sendState === "failed" || item.sendState === "unconfirmed";
const steerMode = item.queueMode === "steer";
const steerMode = item.queueMode === "steer" && !failed;
const reconnecting = item.sendState === "waiting-reconnect";
const busy = item.sendState === "executing-command";
const editing = props.editingId === item.id;
@@ -111,7 +111,7 @@ function renderChatQueueItem(
const moveIndex = segment.indexOf(item.id);
const move = props.onQueueMove;
// Queue-level: once any row can move, every row reserves the handle column so
// the pill and text stay on one x whatever state a row is in. Row-level: only
// the icon and text stay on one x whatever state a row is in. Row-level: only
// a row that may actually move gets a live handle.
const showsHandle = Boolean(move) && reorder.offered;
const canMove = showsHandle && moveIndex >= 0 && segment.length > 1;
@@ -125,23 +125,16 @@ function renderChatQueueItem(
(item.attachments?.length
? t("chat.queue.imageCount", { count: String(item.attachments.length) })
: "");
const itemClass = `chat-queue__item${failed ? " chat-queue__item--failed" : ""}${
reconnecting ? " chat-queue__item--reconnect" : ""
}${editing ? " chat-queue__item--editing" : ""}`;
const itemClass = `chat-queue__item${steerMode ? " chat-queue__item--steered" : ""}${
failed ? " chat-queue__item--failed" : ""
}${reconnecting ? " chat-queue__item--reconnect" : ""}${
editing ? " chat-queue__item--editing" : ""
}`;
// Row order keeps the actions on the first flex line; the error wraps below
// them via flex-basis so failed rows grow by one line instead of a card.
return html`
<div
class=${itemClass}
draggable=${canMove ? "true" : "false"}
@dragstart=${canMove
? (event: DragEvent) => {
event.dataTransfer?.setData(DRAG_MIME, item.id);
if (event.dataTransfer) {
event.dataTransfer.effectAllowed = "move";
}
}
: undefined}
@dragover=${canMove
? (event: DragEvent) => {
if (!event.dataTransfer?.types.includes(DRAG_MIME)) {
@@ -172,11 +165,20 @@ function renderChatQueueItem(
? html`<button
class="chat-queue__grip"
type="button"
draggable=${canMove ? "true" : "false"}
?disabled=${!canMove}
aria-label=${canMove
? t("chat.queue.reorderQueuedMessage")
: t("chat.queue.reorderUnavailable")}
aria-keyshortcuts=${ifDefined(canMove ? "ArrowUp ArrowDown" : undefined)}
@dragstart=${canMove
? (event: DragEvent) => {
event.dataTransfer?.setData(DRAG_MIME, item.id);
if (event.dataTransfer) {
event.dataTransfer.effectAllowed = "move";
}
}
: undefined}
@keydown=${(event: KeyboardEvent) => {
if (!canMove) {
return;
@@ -197,19 +199,9 @@ function renderChatQueueItem(
${reconnecting
? html`<span class="chat-queue__dot" aria-hidden="true"></span>`
: html`<span class="chat-queue__icon" aria-hidden="true">
${failed ? icons.alertTriangle : icons.outbox}
${failed ? icons.alertTriangle : steerMode ? icons.cornerDownRight : icons.queueList}
</span>`}
${renderChatAuthorAvatar(item.sender)}
${steerMode ? html`<span class="chat-queue__badge">${t("chat.queue.steer")}</span>` : nothing}
${editing
? html`<span class="chat-queue__badge">${t("chat.queue.states.editing")}</span>`
: stateLabel
? html`<span
class="chat-queue__badge"
title=${ifDefined(reconnecting ? item.sendError : undefined)}
>${stateLabel}</span
>`
: nothing}
${editing
? html`<textarea
class="chat-queue__edit-input"
@@ -233,7 +225,21 @@ function renderChatQueueItem(
}}
autofocus
></textarea>`
: html`<span class="chat-queue__text" title=${text}>${text}</span>`}
: html`<span class="chat-queue__copy">
<span class="chat-queue__text">${text}</span>
${steerMode
? html`<span class="chat-queue__badge chat-queue__badge--steered"
>${t("chat.queue.states.steering")}</span
>`
: nothing}
${stateLabel && !steerMode
? html`<span
class="chat-queue__badge"
title=${ifDefined(reconnecting ? item.sendError : undefined)}
>${stateLabel}</span
>`
: nothing}
</span>`}
<span class="chat-queue__actions">
${failed && !editing && props.onQueueRetry
? html`
@@ -263,6 +269,7 @@ function renderChatQueueItem(
: nothing}
${editing
? html`
<span class="chat-queue__badge">${t("chat.queue.states.editing")}</span>
<button
class="chat-queue__edit-submit"
type="button"
@@ -280,21 +287,7 @@ function renderChatQueueItem(
${icons.x}
</button>
`
: editable
? html`
<openclaw-tooltip .content=${t("chat.queue.editQueuedMessage")}>
<button
class="chat-queue__edit"
type="button"
?disabled=${!canEdit}
aria-label=${t("chat.queue.editQueuedMessage")}
@click=${() => props.onQueueEdit?.(item.id)}
>
${icons.pencil}
</button>
</openclaw-tooltip>
`
: nothing}
: nothing}
${busy || editing
? nothing
: html`
@@ -312,10 +305,37 @@ function renderChatQueueItem(
}}
@dblclick=${(event: MouseEvent) => event.stopPropagation()}
>
${icons.x}
${icons.trash}
</button>
</openclaw-tooltip>
`}
${editing
? nothing
: html`
<wa-dropdown
class="chat-queue__overflow"
placement="top-end"
@wa-select=${(event: CustomEvent<{ item: { value?: string } }>) => {
if (event.detail.item.value === "edit" && canEdit) {
props.onQueueEdit?.(item.id);
}
}}
>
<button
slot="trigger"
class="chat-queue__more"
type="button"
aria-label=${t("chat.queue.moreActions")}
@dblclick=${(event: MouseEvent) => event.stopPropagation()}
>
${icons.moreHorizontal}
</button>
<wa-dropdown-item value="edit" ?disabled=${!canEdit}>
<span slot="icon" aria-hidden="true">${icons.pencil}</span>
${t("chat.queue.editQueuedMessage")}
</wa-dropdown-item>
</wa-dropdown>
`}
</span>
${
// Reconnect rows auto-retry, so the raw transport error is noise there;
@@ -167,23 +167,24 @@ export function renderChatComposerView(context: ChatComposerViewContext) {
})
: nothing;
const queue = renderChatQueue({
queue: props.queue,
canAbort: showAbortableUi,
onQueueRetry: props.connected && canCompose ? props.onQueueRetry : undefined,
onQueueSteer: props.connected && canCompose ? props.onQueueSteer : undefined,
// Reordering is local bookkeeping, so it stays available while offline —
// exactly when a queue is long enough to need it.
onQueueMove: props.onQueueMove,
onQueueEdit: props.queuedEdit?.onEdit,
onQueueEditChange: props.queuedEdit?.onEditChange,
onQueueEditSubmit: props.queuedEdit?.onEditSubmit,
onQueueEditCancel: props.queuedEdit?.onCancel,
editingId: props.queuedEdit?.editingId ?? null,
editingText: props.queuedEdit?.editingText,
onQueueRemove: props.onQueueRemove,
});
return html`
${renderChatQueue({
queue: props.queue,
canAbort: showAbortableUi,
onQueueRetry: props.connected && canCompose ? props.onQueueRetry : undefined,
onQueueSteer: props.connected && canCompose ? props.onQueueSteer : undefined,
// Reordering is local bookkeeping, so it stays available while offline —
// exactly when a queue is long enough to need it.
onQueueMove: props.onQueueMove,
onQueueEdit: props.queuedEdit?.onEdit,
onQueueEditChange: props.queuedEdit?.onEditChange,
onQueueEditSubmit: props.queuedEdit?.onEditSubmit,
onQueueEditCancel: props.queuedEdit?.onCancel,
editingId: props.queuedEdit?.editingId ?? null,
editingText: props.queuedEdit?.editingText,
onQueueRemove: props.onQueueRemove,
})}
${props.runError
? html`
<div class="chat-run-error" role="alert">
@@ -202,7 +203,7 @@ export function renderChatComposerView(context: ChatComposerViewContext) {
</div>
`
: nothing}
${disabledBanner} ${voiceError}
${disabledBanner} ${voiceError} ${queue}
${showComposerInput
? html`<div
class="agent-chat__input ${props.offline ? "agent-chat__input--offline" : ""}"
+5
View File
@@ -2833,6 +2833,11 @@ button.chat-pr__diff {
--chat-composer-surface: var(--popover);
}
.chat-queue + .agent-chat__input {
border-radius: 0 0 calc(20px * var(--openclaw-corner-radius-scale))
calc(20px * var(--openclaw-corner-radius-scale));
}
.agent-chat__input {
--chat-box-inset: 8px;
/* Height of every control on the composer's input row. The row bottom-aligns
+94 -47
View File
@@ -2911,28 +2911,42 @@ td.data-table-key-col {
flex: 1;
}
/* Chat queue: compact pending rows aligned with the composer shell width. */
/* The queued turns are one attached surface with the composer below. */
.chat-queue {
display: flex;
flex-direction: column;
gap: 4px;
width: calc(100% - 36px);
max-width: 48rem;
margin: 8px auto 0;
overflow: hidden;
width: 100%;
margin-bottom: calc(-1 * var(--chat-composer-shell-gap));
border: 1px solid var(--chat-composer-hairline);
border-bottom: 0;
border-radius: calc(16px * var(--openclaw-corner-radius-scale))
calc(16px * var(--openclaw-corner-radius-scale)) 0 0;
background: var(--chat-composer-surface);
}
.chat-queue__item {
display: flex;
flex-wrap: wrap;
position: relative;
display: grid;
grid-template-columns: 16px 16px minmax(0, 1fr) auto;
align-items: center;
gap: 2px 8px;
min-height: 30px;
padding: 3px 5px 3px 10px;
border-radius: var(--radius-md);
background: color-mix(in srgb, var(--text) 5%, transparent);
column-gap: 8px;
min-height: 42px;
padding: 6px 8px;
border-bottom: 1px solid color-mix(in srgb, var(--text-strong) 8%, transparent);
color: var(--muted);
}
.chat-queue__item:last-child {
border-bottom: 0;
}
.chat-queue__item:hover {
background: color-mix(in srgb, var(--text-strong) 3%, transparent);
}
.chat-queue__item--steered {
background: var(--info-subtle);
}
/* The edited row keeps its slot so the operator can see where the corrected
message lands; the outline says the composer owns it right now.
Editing is an active state, not a failure, so it uses the informational
@@ -2940,7 +2954,6 @@ td.data-table-key-col {
(see the --run token note in base.css), which reads as an error at a glance. */
.chat-queue__item--editing {
background: var(--info-subtle);
box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--info) 34%, transparent);
}
/* Drop target reads as an insertion edge rather than a selected row, so the
@@ -2954,21 +2967,23 @@ td.data-table-key-col {
.chat-queue__grip {
display: inline-flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
width: 12px;
height: 16px;
margin-left: -5px;
width: 16px;
height: 28px;
padding: 0;
border: none;
border-radius: var(--radius-sm);
background: none;
color: color-mix(in srgb, var(--muted) 55%, transparent);
color: var(--muted);
cursor: grab;
opacity: 0;
transition: opacity var(--duration-fast) ease;
}
.chat-queue__item:hover .chat-queue__grip:not(:disabled),
.chat-queue__grip:focus-visible {
color: var(--muted);
opacity: 1;
}
.chat-queue__grip:focus-visible {
@@ -2976,12 +2991,9 @@ td.data-table-key-col {
outline-offset: 1px;
}
/* Still occupies the handle column so every row's pill and text share one x,
and stays visible but dimmer than a live handle: this row is holding its
place, not offering a drag. Hover leaves it alone for the same reason. */
.chat-queue__grip:disabled {
color: color-mix(in srgb, var(--muted) 34%, transparent);
cursor: default;
opacity: 0;
}
.chat-queue__grip svg {
@@ -3028,20 +3040,23 @@ td.data-table-key-col {
animation: pulse-subtle 2s ease-in-out infinite;
}
.chat-queue__item--steered .chat-queue__icon {
color: var(--info);
}
.chat-queue__item--reconnect .chat-queue__badge {
background: color-mix(in srgb, var(--warn) 14%, transparent);
color: color-mix(in srgb, var(--warn) 78%, var(--text-strong));
}
.chat-queue__badge {
flex-shrink: 0;
padding: 1px 7px;
border-radius: 999px;
background: color-mix(in srgb, var(--text) 12%, transparent);
padding: 1px 5px;
border-radius: var(--radius-sm);
background: color-mix(in srgb, var(--text) 8%, transparent);
color: var(--muted);
font-size: 11px;
font-weight: 600;
line-height: 1.5;
font-size: 10px;
font-weight: 500;
line-height: 1.4;
white-space: nowrap;
}
@@ -3057,19 +3072,25 @@ td.data-table-key-col {
color: var(--info);
}
.chat-queue__text {
flex: 1 1 0;
.chat-queue__copy {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 3px 6px;
min-width: 0;
padding-block: 3px;
}
.chat-queue__text {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
color: var(--chat-text);
font-size: 13px;
line-height: 1.4;
line-height: 1.45;
overflow-wrap: anywhere;
}
.chat-queue__edit-input {
flex: 1 1 0;
grid-column: 3;
min-width: 0;
min-height: 26px;
margin: -1px 0;
@@ -3091,12 +3112,12 @@ td.data-table-key-col {
/* Wraps onto its own flex line below the row (see renderChatQueueItem). */
.chat-queue__error {
flex-basis: 100%;
grid-column: 3 / -1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
padding: 0 0 3px 22px;
padding: 0 0 2px;
color: var(--danger);
font-size: 11.5px;
line-height: 1.35;
@@ -3106,15 +3127,15 @@ td.data-table-key-col {
display: inline-flex;
align-items: center;
gap: 2px;
flex-shrink: 0;
margin-left: auto;
grid-column: 4;
}
.chat-queue__action {
display: inline-flex;
align-items: center;
gap: 4px;
padding: 4px 8px;
min-height: 28px;
padding: 4px 7px;
border: none;
border-radius: var(--radius-sm);
background: none;
@@ -3139,8 +3160,8 @@ td.data-table-key-col {
background: color-mix(in srgb, var(--accent) 12%, transparent);
}
.chat-queue__edit,
.chat-queue__remove,
.chat-queue__more,
.chat-queue__edit-submit,
.chat-queue__edit-cancel {
display: inline-flex;
@@ -3158,24 +3179,24 @@ td.data-table-key-col {
/* Inert instead of removed: an open edit keeps every action slot in place so the
rows below it never shift while the operator retypes. */
.chat-queue__edit:disabled,
.chat-queue__remove:disabled,
.chat-queue__more:disabled,
.chat-queue__edit-submit:disabled,
.chat-queue__edit-cancel:disabled {
color: color-mix(in srgb, var(--muted) 34%, transparent);
cursor: default;
}
.chat-queue__edit:hover:not(:disabled),
.chat-queue__remove:hover:not(:disabled),
.chat-queue__more:hover:not(:disabled),
.chat-queue__edit-submit:hover:not(:disabled),
.chat-queue__edit-cancel:hover:not(:disabled) {
color: var(--text);
background: color-mix(in srgb, var(--text) 10%, transparent);
}
.chat-queue__edit svg,
.chat-queue__remove svg,
.chat-queue__more svg,
.chat-queue__edit-submit svg,
.chat-queue__edit-cancel svg {
width: 13px;
@@ -3187,6 +3208,32 @@ td.data-table-key-col {
stroke-linejoin: round;
}
.chat-queue__overflow::part(panel) {
min-width: 180px;
}
.chat-queue__overflow wa-dropdown-item span[slot="icon"] {
display: inline-flex;
}
.chat-queue__overflow wa-dropdown-item svg {
width: 14px;
height: 14px;
}
@media (max-width: 560px) {
.chat-queue__item {
grid-template-columns: 12px 16px minmax(0, 1fr) auto;
column-gap: 6px;
padding-inline: 6px;
}
.chat-queue__steer span,
.chat-queue__retry span {
display: none;
}
}
/* Chat lines */
.chat-line {
display: flex;