mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-25 11:55:47 -06:00
feat(ui): attach message queue to composer
This commit is contained in:
@@ -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>`,
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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" : ""}"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user