Files
openclaw/apps/linux/ui/quickchat.js
Peter Steinberger bc8d0da092 feat(linux): Quick Chat streamed replies and precise pairing states (#110632)
* feat(linux): stream Quick Chat gateway events

* feat(linux): render streamed Quick Chat replies

* fix(linux): correlate Quick Chat streams by run

* style(linux): rustfmt

* test(linux): move Quick Chat stream helper tests into the tooling vitest project

* test: type the Linux quickchat stream harness for strict checks

* test: explicit harness shape for the root test typecheck
2026-07-18 13:22:48 +01:00

775 lines
23 KiB
JavaScript

// Pure stream helpers stay above browser bindings so the Node regression test can exercise the
// Gateway-compatible assembler without constructing a WebView.
function chatMessageText(message) {
const content = message?.content;
if (Array.isArray(content)) {
const textBlocks = content
.filter((block) => block?.type === "text" && typeof block.text === "string")
.map((block) => block.text);
if (textBlocks.length > 0) {
return textBlocks.join("\n\n");
}
}
// Match the Control UI fallback contract in ui/src/lib/chat/message-extract.ts: typed content
// blocks, then plain-string content, then top-level message.text.
if (typeof content === "string") {
return content;
}
return typeof message?.text === "string" ? message.text : null;
}
function assembleChatDelta(currentText, payload) {
const snapshot = chatMessageText(payload?.message);
if (typeof payload?.deltaText === "string") {
if (payload.replace === true) {
return payload.deltaText;
}
if (currentText === null) {
return snapshot ?? payload.deltaText;
}
if (snapshot !== null) {
const prefixLength = snapshot.length - payload.deltaText.length;
if (
prefixLength !== currentText.length ||
snapshot.slice(0, prefixLength) !== currentText
) {
return snapshot;
}
}
return `${currentText}${payload.deltaText}`;
}
return snapshot;
}
const tauri = window["__TAURI__"];
const { invoke } = tauri.core;
const { listen } = tauri.event;
const elements = {
agentAvatar: document.querySelector("#agent-avatar"),
agentChip: document.querySelector("#agent-chip"),
agentList: document.querySelector("#agent-list"),
agentMenu: document.querySelector("#agent-menu"),
composer: document.querySelector("#composer"),
input: document.querySelector("#message"),
reply: document.querySelector("#reply"),
replyAgentAvatar: document.querySelector("#reply-agent-avatar"),
replyAgentName: document.querySelector("#reply-agent-name"),
replyError: document.querySelector("#reply-error"),
replyScroll: document.querySelector("#reply-scroll"),
replyState: document.querySelector("#reply-state"),
replyText: document.querySelector("#reply-text"),
replyThinking: document.querySelector("#reply-thinking"),
send: document.querySelector("#send"),
sendIcon: document.querySelector("#send-icon"),
shortcutCapture: document.querySelector("#shortcut-capture"),
shortcutError: document.querySelector("#shortcut-error"),
shortcutReset: document.querySelector("#shortcut-reset"),
shortcutSettings: document.querySelector("#shortcut-settings"),
shortcutSettingsButton: document.querySelector("#shortcut-settings-button"),
shortcutValue: document.querySelector("#shortcut-value"),
status: document.querySelector("#status"),
};
const reducedMotion = window.matchMedia("(prefers-reduced-motion: reduce)");
let agents = [];
let activeIdentity = { id: "", name: "Agent", isDefault: true };
let selectingAgent = false;
let sending = false;
let accepted = false;
let hiding = false;
let hideTimer = null;
let acceptedTimer = null;
let visibilitySequence = 0;
let popoverSequence = 0;
let sendError = "";
let gatewayState = "down";
let gatewayNotice = "";
let gatewayDisconnectSequence = 0;
let openPopover = null;
let menuIndex = 0;
let capturingShortcut = false;
let activeReply = null;
let pendingChatEvents = [];
const MAX_PENDING_CHAT_EVENTS = 64;
function friendlyError(error, fallback = "Could not send the message.") {
if (typeof error === "string") {
return error;
}
return error?.message || fallback;
}
function setError(message = "") {
elements.status.textContent = message;
elements.composer.classList.toggle("has-error", Boolean(message));
}
function renderStatus() {
if (gatewayState === "pairing-required") {
setError(gatewayNotice || "Approve this device in the dashboard (Nodes)");
return;
}
if (gatewayState === "credential-required") {
setError(
gatewayNotice || "Gateway requires a credential — open the dashboard on the gateway host",
);
return;
}
if (gatewayState === "tls-failure") {
setError("Gateway TLS trust failed — check the certificate fingerprint");
return;
}
setError(
gatewayState === "up" ? sendError : gatewayNotice || "Gateway unreachable — retrying",
);
}
function setGatewayState(payload) {
const wasUp = gatewayState === "up";
gatewayState = payload?.state || "down";
gatewayNotice = typeof payload?.notice === "string" ? payload.notice : "";
if (gatewayState !== "up") {
gatewayDisconnectSequence += 1;
terminalizeDisconnectedReply();
}
renderStatus();
updateSendButton();
if (gatewayState === "up" && !wasUp) {
void refreshAgents();
}
}
function updateSendButton() {
const empty = !elements.input.value.trim();
const streaming = activeReply !== null && !activeReply.terminal;
elements.send.disabled =
gatewayState !== "up" || empty || selectingAgent || sending || accepted || streaming;
elements.send.classList.toggle("sending", sending);
elements.send.classList.toggle("accepted", accepted);
elements.sendIcon.textContent = sending ? "" : accepted ? "✓" : "↑";
elements.input.readOnly = sending || accepted || streaming;
}
function nameHue(name) {
let hash = 0;
for (const character of name) {
hash = (hash * 31 + character.codePointAt(0)) >>> 0;
}
return hash % 360;
}
function renderAvatarFallback(target, identity) {
const name = identity?.name?.trim() || identity?.id?.trim() || "Agent";
const initial = [...name][0]?.toUpperCase() || "A";
target.replaceChildren(document.createTextNode(identity?.emoji?.trim() || initial));
}
function renderAvatar(target, identity) {
const name = identity?.name?.trim() || identity?.id?.trim() || "Agent";
target.style.setProperty("--agent-hue", nameHue(name));
const avatarUrl = identity?.avatarUrl?.trim();
if (!avatarUrl || !/^(?:https?:|data:)/i.test(avatarUrl)) {
renderAvatarFallback(target, identity);
return;
}
const image = document.createElement("img");
image.alt = "";
image.draggable = false;
image.addEventListener("error", () => {
if (target.contains(image)) {
renderAvatarFallback(target, identity);
}
});
image.src = avatarUrl;
target.replaceChildren(image);
}
function resetAccepted() {
window.clearTimeout(acceptedTimer);
acceptedTimer = null;
accepted = false;
}
function clearReply() {
activeReply = null;
elements.reply.hidden = true;
elements.reply.classList.remove("has-error", "is-terminal");
elements.replyError.textContent = "";
elements.replyState.textContent = "";
elements.replyText.textContent = "";
elements.replyThinking.hidden = true;
}
function scrollReplyToEnd() {
window.requestAnimationFrame(() => {
elements.replyScroll.scrollTop = elements.replyScroll.scrollHeight;
});
}
function renderReplyText() {
// Deliberately plain text: this small native surface avoids a Markdown dependency and preserves
// whitespace, leaving Markdown punctuation visible instead of interpreting agent output.
elements.replyText.textContent = activeReply?.text || "";
scrollReplyToEnd();
}
function stopReplyThinking() {
elements.replyThinking.hidden = true;
}
function terminalizeDisconnectedReply() {
if (!activeReply || activeReply.terminal) {
return;
}
// Chat events are not replayed after a socket gap. Unlock the composer instead of leaving a
// reply waiting forever for a terminal frame that may have been lost while disconnected.
activeReply.terminal = true;
stopReplyThinking();
elements.reply.classList.add("has-error", "is-terminal");
elements.replyState.textContent = "Interrupted";
elements.replyError.textContent = "Connection lost before the reply completed.";
scrollReplyToEnd();
}
function replyTargetMatches(target, payload) {
if (!target || payload?.sessionKey !== target.sessionKey) {
return false;
}
return target.agentId == null || payload?.agentId === target.agentId;
}
function startReply(target, identity, runId) {
activeReply = {
runId,
target: {
sessionKey: target.sessionKey,
agentId: typeof target.agentId === "string" ? target.agentId : null,
},
terminal: false,
text: null,
};
elements.reply.hidden = false;
elements.reply.classList.remove("has-error", "is-terminal");
elements.replyError.textContent = "";
elements.replyState.textContent = "";
elements.replyText.textContent = "";
elements.replyThinking.textContent = reducedMotion.matches ? "…" : "Thinking…";
elements.replyThinking.hidden = false;
renderAvatar(elements.replyAgentAvatar, identity);
elements.replyAgentName.textContent = identity?.name?.trim() || "Agent";
void invoke("quickchat_set_expanded", { expanded: true });
}
function applyChatEvent(payload) {
if (!activeReply) {
return;
}
// The chat.send ACK owns this reply. Exact runId equality is primary; the routing target remains
// a secondary guard so concurrent turns from other surfaces never enter this reply area.
if (payload?.runId !== activeReply.runId || !replyTargetMatches(activeReply.target, payload)) {
return;
}
if (activeReply.terminal) {
return;
}
const hasTextUpdate =
typeof payload?.deltaText === "string" || chatMessageText(payload?.message) !== null;
if (hasTextUpdate) {
const nextText = assembleChatDelta(activeReply.text, payload);
if (nextText !== null) {
activeReply.text = nextText;
renderReplyText();
}
}
if (payload?.state === "delta") {
stopReplyThinking();
return;
}
if (!["final", "aborted", "error"].includes(payload?.state)) {
return;
}
activeReply.terminal = true;
pendingChatEvents = [];
stopReplyThinking();
elements.reply.classList.add("is-terminal");
if (payload.state === "final") {
elements.replyState.textContent = "Done";
} else if (payload.state === "aborted") {
activeReply.text = `${activeReply.text || ""}${activeReply.text ? "\n\n" : ""}(stopped)`;
elements.replyState.textContent = "Stopped";
renderReplyText();
} else {
elements.reply.classList.add("has-error");
elements.replyState.textContent = "Error";
elements.replyError.textContent =
typeof payload.errorMessage === "string" && payload.errorMessage.trim()
? payload.errorMessage
: "Gateway reply failed.";
scrollReplyToEnd();
}
updateSendButton();
}
function handleChatEvent(payload) {
if (activeReply) {
applyChatEvent(payload);
return;
}
if (sending) {
// The Gateway may stream before the chat.send ack reaches invoke; replay only after the native
// command returns the accepted routing target, then apply the same session/run filters.
if (pendingChatEvents.length === MAX_PENDING_CHAT_EVENTS) {
pendingChatEvents.shift();
}
pendingChatEvents.push(payload);
}
}
function renderIdentity(identity) {
const name = identity?.name?.trim() || "Agent";
activeIdentity = { ...identity, name };
renderAvatar(elements.agentAvatar, activeIdentity);
elements.agentChip.title = name;
elements.input.placeholder = `Message ${name}`;
renderAgentList();
}
function renderAgentList() {
elements.agentList.replaceChildren();
for (const [index, agent] of agents.entries()) {
const option = document.createElement("button");
option.className = "agent-option";
option.type = "button";
option.dataset.agentId = agent.id;
option.setAttribute("role", "menuitemradio");
const active = agent.id === activeIdentity.id;
option.setAttribute("aria-checked", String(active));
option.tabIndex = index === menuIndex ? 0 : -1;
const avatar = document.createElement("span");
avatar.className = "agent-avatar-mini";
avatar.setAttribute("aria-hidden", "true");
renderAvatar(avatar, agent);
const name = document.createElement("span");
name.className = "agent-option-name";
name.textContent = agent.name;
const check = document.createElement("span");
check.className = "agent-check";
check.setAttribute("aria-hidden", "true");
check.textContent = active ? "✓" : "";
option.append(avatar, name, check);
option.addEventListener("click", () => {
void selectAgent(agent.id);
});
elements.agentList.append(option);
}
}
async function refreshIdentity() {
try {
renderIdentity(await invoke("quickchat_identity"));
} catch {
renderIdentity({ id: "", name: "Agent", isDefault: true });
}
}
async function refreshAgents() {
try {
agents = await invoke("quickchat_agents");
} catch {
agents = [];
}
await refreshIdentity();
}
async function selectAgent(agentId) {
if (selectingAgent) {
return;
}
selectingAgent = true;
updateSendButton();
try {
await invoke("quickchat_select_agent", { agentId });
await refreshIdentity();
closePopover();
} catch (error) {
sendError = friendlyError(error, "Could not select that agent.");
renderStatus();
} finally {
selectingAgent = false;
updateSendButton();
}
}
function resetShortcutCapture() {
capturingShortcut = false;
elements.shortcutCapture.textContent = "Press new shortcut";
}
function setPopoverVisibility(kind) {
openPopover = kind;
elements.agentMenu.hidden = kind !== "agents";
elements.shortcutSettings.hidden = kind !== "shortcut";
elements.agentChip.setAttribute("aria-expanded", String(kind === "agents"));
elements.shortcutSettingsButton.setAttribute("aria-expanded", String(kind === "shortcut"));
document.body.classList.toggle("overlay-open", Boolean(kind));
if (kind !== "shortcut") {
resetShortcutCapture();
}
}
async function openNamedPopover(kind) {
if (openPopover === kind) {
closePopover();
return;
}
const sequence = ++popoverSequence;
try {
await invoke("quickchat_set_expanded", { expanded: true });
} catch (error) {
sendError = friendlyError(error, "Could not open Quick Chat settings.");
renderStatus();
return;
}
if (sequence !== popoverSequence) {
return;
}
setPopoverVisibility(kind);
if (kind === "agents") {
const selectedIndex = agents.findIndex((agent) => agent.id === activeIdentity.id);
menuIndex = selectedIndex >= 0 ? selectedIndex : 0;
renderAgentList();
elements.agentList.querySelectorAll(".agent-option")[menuIndex]?.focus();
} else {
elements.shortcutError.textContent = "";
elements.shortcutCapture.focus();
}
}
function closePopover(focusInput = true, compact = true) {
++popoverSequence;
setPopoverVisibility(null);
if (compact) {
void invoke("quickchat_set_expanded", { expanded: !elements.reply.hidden });
}
if (focusInput) {
elements.input.focus();
}
}
function focusMenuOption(index) {
const options = [...elements.agentList.querySelectorAll(".agent-option")];
if (options.length === 0) {
return;
}
menuIndex = (index + options.length) % options.length;
for (const [optionIndex, option] of options.entries()) {
option.tabIndex = optionIndex === menuIndex ? 0 : -1;
}
options[menuIndex].focus();
}
function renderShortcutStatus(shortcut) {
const supported = shortcut?.supported === true;
elements.shortcutSettingsButton.hidden = !supported;
elements.shortcutValue.textContent = shortcut?.accelerator || "";
if (!supported && openPopover === "shortcut") {
closePopover();
}
}
async function refreshShortcutStatus() {
try {
renderShortcutStatus(await invoke("quickchat_shortcut"));
} catch {
renderShortcutStatus({ supported: false });
}
}
function acceleratorFromEvent(event) {
let key = "";
if (/^Key[A-Z]$/.test(event.code)) {
key = event.code;
} else if (/^Digit[0-9]$/.test(event.code)) {
key = event.code;
} else if (event.code === "Space") {
key = "Space";
} else if (/^F(?:[1-9]|1[0-9]|2[0-4])$/.test(event.code)) {
key = event.code;
}
if (!key) {
return null;
}
const parts = [];
if (event.ctrlKey) parts.push("Ctrl");
if (event.altKey) parts.push("Alt");
if (event.shiftKey) parts.push("Shift");
if (event.metaKey) parts.push("Super");
if (parts.length === 0) {
return null;
}
parts.push(key);
return parts.join("+");
}
async function saveShortcut(accelerator) {
elements.shortcutError.textContent = "";
try {
const status = await invoke("quickchat_set_shortcut", { accelerator });
renderShortcutStatus(status);
resetShortcutCapture();
} catch (error) {
elements.shortcutError.textContent = friendlyError(
error,
"Could not update the Quick Chat shortcut.",
);
resetShortcutCapture();
elements.shortcutCapture.focus();
}
}
async function requestHide() {
if (hiding) {
return;
}
visibilitySequence += 1;
const hideSequence = visibilitySequence;
hiding = true;
pendingChatEvents = [];
closePopover(false, false);
document.body.classList.remove("shown");
window.clearTimeout(hideTimer);
hideTimer = window.setTimeout(
async () => {
try {
await invoke("quickchat_hide");
resetAccepted();
clearReply();
} catch (error) {
if (visibilitySequence === hideSequence) {
sendError = friendlyError(error);
renderStatus();
document.body.classList.add("shown");
elements.input.focus();
}
} finally {
if (visibilitySequence === hideSequence) {
hiding = false;
}
}
},
reducedMotion.matches ? 45 : 120,
);
}
function reveal() {
window.clearTimeout(hideTimer);
resetAccepted();
hiding = false;
setPopoverVisibility(null);
renderStatus();
updateSendButton();
document.body.classList.remove("shown");
window.requestAnimationFrame(() => {
document.body.classList.add("shown");
elements.input.focus();
});
if (gatewayState === "up") {
void refreshAgents();
}
void refreshShortcutStatus();
}
async function send(openDashboard) {
const message = elements.input.value.trim();
if (gatewayState !== "up" || !message || selectingAgent || sending || accepted) {
return;
}
sending = true;
const sendDisconnectSequence = gatewayDisconnectSequence;
const sendVisibilitySequence = visibilitySequence;
clearReply();
pendingChatEvents = [];
void invoke("quickchat_set_expanded", { expanded: false });
sendError = "";
renderStatus();
updateSendButton();
try {
const sentIdentity = { ...activeIdentity };
const result = await invoke("quickchat_send", { message });
if (!result || typeof result.sessionKey !== "string") {
throw new Error("Gateway accepted the message without a routing target.");
}
if (typeof result.runId !== "string" || !result.runId) {
throw new Error("Gateway accepted the message without a run ID.");
}
sending = false;
sendError = "";
elements.input.value = "";
if (visibilitySequence !== sendVisibilitySequence || hiding) {
pendingChatEvents = [];
updateSendButton();
return;
}
accepted = true;
startReply(result, sentIdentity, result.runId);
const bufferedEvents = pendingChatEvents;
pendingChatEvents = [];
for (const payload of bufferedEvents) {
applyChatEvent(payload);
}
if (gatewayDisconnectSequence !== sendDisconnectSequence || gatewayState !== "up") {
terminalizeDisconnectedReply();
}
updateSendButton();
if (openDashboard) {
void invoke("quickchat_show_dashboard");
}
acceptedTimer = window.setTimeout(() => {
accepted = false;
acceptedTimer = null;
updateSendButton();
}, 450);
} catch (error) {
sending = false;
pendingChatEvents = [];
if (visibilitySequence !== sendVisibilitySequence || hiding) {
updateSendButton();
return;
}
sendError = friendlyError(error);
renderStatus();
updateSendButton();
elements.input.focus();
// A strict send failure can mean the pinned agent vanished; re-sync the chip.
void refreshAgents();
}
}
elements.input.addEventListener("input", () => {
sendError = "";
renderStatus();
updateSendButton();
});
elements.input.addEventListener("keydown", (event) => {
if (event.defaultPrevented || event.isComposing || event.keyCode === 229) {
return;
}
if (event.key === "Escape") {
event.preventDefault();
if (openPopover) {
closePopover();
} else {
void requestHide();
}
return;
}
if (event.key === "Enter" && !openPopover) {
event.preventDefault();
void send(event.ctrlKey || event.metaKey);
}
});
elements.agentChip.addEventListener("click", () => {
void openNamedPopover("agents");
});
elements.shortcutSettingsButton.addEventListener("click", () => {
void openNamedPopover("shortcut");
});
elements.shortcutCapture.addEventListener("click", () => {
capturingShortcut = true;
elements.shortcutError.textContent = "";
elements.shortcutCapture.textContent = "Press keys…";
elements.shortcutCapture.focus();
});
elements.shortcutReset.addEventListener("click", () => {
void saveShortcut(null);
});
elements.send.addEventListener("click", () => {
void send(false);
});
document.addEventListener(
"keydown",
(event) => {
if (!openPopover) {
return;
}
if (event.key === "Escape") {
event.preventDefault();
event.stopPropagation();
closePopover();
return;
}
if (openPopover === "agents") {
if (event.key === "ArrowDown" || event.key === "ArrowUp") {
event.preventDefault();
event.stopPropagation();
focusMenuOption(menuIndex + (event.key === "ArrowDown" ? 1 : -1));
} else if (event.key === "Enter") {
event.preventDefault();
event.stopPropagation();
elements.agentList.querySelectorAll(".agent-option")[menuIndex]?.click();
}
return;
}
if (openPopover === "shortcut" && capturingShortcut) {
const accelerator = acceleratorFromEvent(event);
if (!accelerator) {
return;
}
event.preventDefault();
event.stopPropagation();
void saveShortcut(accelerator);
}
},
true,
);
document.addEventListener("pointerdown", (event) => {
if (!openPopover) {
return;
}
const target = event.target;
const insidePopover =
elements.agentMenu.contains(target) ||
elements.agentChip.contains(target) ||
elements.shortcutSettings.contains(target) ||
elements.shortcutSettingsButton.contains(target);
if (!insidePopover) {
closePopover(false);
}
});
await listen("quickchat:shown", () => {
visibilitySequence += 1;
reveal();
});
await listen("quickchat:hide-requested", () => {
void requestHide();
});
await listen("quickchat:gateway-state", (event) => {
setGatewayState(event.payload);
});
await listen("quickchat:chat-event", (event) => {
handleChatEvent(event.payload);
});
const readySequence = visibilitySequence;
try {
const shouldShow = await invoke("quickchat_ready");
if (visibilitySequence === readySequence) {
if (shouldShow) {
reveal();
} else {
void requestHide();
}
}
} catch {
void requestHide();
}