fix(ui): chat send failures were invisible when the session pane was hidden (#124473)

* fix(ui): always surface terminal chat send failures

Terminal chat send failures were recorded only on the queue item when the
owning pane was not visible (reconnect, alias drift, split-pane routing),
so the operator saw nothing at all — an action ending in silence.

Root cause: every error surface in the send/drain path was gated on
visibleSessionMatches(...), and the FIFO outbox drain treated every
chat.history reconcile rejection as a silent retryable "blocked", so a
non-retryable rejection (e.g. auth loss) wedged the head — and everything
behind it — permanently with no visible outcome.

Fix, at the owner:
- New surfaceChatDeliveryFailure() in steer-lifecycle.ts (the shared
  error-text owner): visible pane keeps the inline chat error; otherwise
  the failure routes through the existing global toast host, naming the
  session. All terminal failure sites in chat-send-delivery,
  chat-outbox-drain, chat-send-queue-state, and steer-lifecycle now use it.
- reconcileStoredChatOutboxHead: a non-retryable GatewayRequestError on the
  head now terminally fails a never-attempted head (unblocking the lane)
  or parks an attempted head as unconfirmed — both with a visible outcome —
  instead of blocking the lane forever.
- Composer disabled-reason: the reason now also renders while a draft hides
  the placeholder, and the cloud-startup-pending gate gets a reason instead
  of a silently disabled composer.

Regression tests: hidden-pane terminal failure surfaces via toast; wedged
head fails visibly and the lane drains the next message; attempted head
parks unconfirmed; disabled reason visible with draft text present.

* fix(ci): raise startup JS baseline for global failure surfacing and pin workspace-sync test clock

The chat send path now imports session-display naming for the global
failure toast, adding ~1 KiB gzip to startup JS (335452 B on CI's Linux
builder, still well under the 358400 B committed cap).

workspace-sync "never commands" asserted the exact dispatch timeoutMs
(777) but the impl derives it from a Date.now() deadline, so any elapsed
ms between admission and dispatch failed the exact-equality assertion on
a loaded runner. Pin the clock like the sibling timeout tests do.

* fix(ui): surface route-switched command failures and agent-scope global toast naming

ClawSweeper review findings on #124473:

- A queued local command failing after the operator navigated away hit
  failCommand(error) with expose=false; the dispatcher's stale-scope
  guard had already withheld the inline error, so a successful state
  write recorded the failure invisibly — the silent class this PR
  removes. Expose it globally when the scope is stale and the owning
  pane is hidden; a stale scope with the pane still visible keeps the
  failed queue chip (the new connection owns the inline surface).
- Global session rows are agent-scoped behind one shared "global" key,
  so the toast row lookup could borrow another agent's label. Match the
  row's agentId to the failed outbox's agent for global keys.

Both regression tests fail pre-fix (stash-verified).
This commit is contained in:
Peter Steinberger
2026-08-16 01:38:06 -07:00
committed by GitHub
parent b097aaab20
commit 32ddbb3be2
10 changed files with 404 additions and 57 deletions
@@ -1,5 +1,5 @@
{
"startupJsGzipBytes": 334391,
"reason": "cumulative 2026-08-15 UI growth plus folder group defaults catalog and lazy New Session route resolution",
"updatedAt": "2026-08-15"
"startupJsGzipBytes": 335452,
"reason": "chat delivery failures surface globally: toast + session-display naming joins startup chat path",
"updatedAt": "2026-08-16"
}
@@ -57,6 +57,10 @@ function createWorkspaceActions(
describe("worker workspace command transport retry", () => {
it("runs never commands once without changing the selected port", async () => {
// Pin the clock: the impl derives the dispatch timeout from a Date.now()
// deadline, so real elapsed ms between admission and dispatch would turn
// the exact 777 assertion below into a loaded-runner flake.
vi.spyOn(Date, "now").mockReturnValue(1_000);
const run = vi.fn(async (argv: string[], _options: CommandOptions) =>
argv.at(-1)?.includes("never-command") ? result(255) : result(),
);
+5 -1
View File
@@ -113,6 +113,9 @@ class OpenClawToastHost extends OpenClawLightDomContentsElement {
}
export function showToast(options: ToastOptions): boolean {
if (typeof document === "undefined") {
return false;
}
const host = document.querySelector<OpenClawToastHost>("openclaw-toast-host");
if (!host) {
queuedToast = options;
@@ -136,7 +139,8 @@ export function showToast(options: ToastOptions): boolean {
return true;
}
if (!customElements.get("openclaw-toast-host")) {
// Guarded so DOM-free (node) consumers of send-failure surfacing can load this module.
if (typeof customElements !== "undefined" && !customElements.get("openclaw-toast-host")) {
customElements.define("openclaw-toast-host", OpenClawToastHost);
}
+14
View File
@@ -234,6 +234,20 @@ describe("renderChatComposer controls", () => {
expect(container.querySelector<HTMLTextAreaElement>("textarea")?.disabled).toBe(true);
});
it("shows the disabled reason even when draft text hides the placeholder", () => {
const reason = "This session is read-only.";
const { container } = renderComposer({
canSend: false,
disabledReason: reason,
draft: "a draft that hides the placeholder",
});
// The placeholder carries the reason only for an empty composer; the
// dedicated reason row must keep the explanation visible alongside a draft.
expect(container.querySelector(".agent-chat__disabled-reason")?.textContent).toContain(reason);
expect(container.querySelector<HTMLTextAreaElement>("textarea")?.disabled).toBe(true);
});
it("switches the primary action between voice, send, queue, and stop", () => {
const onToggleRealtimeTalk = vi.fn();
let view = renderComposer({ onToggleRealtimeTalk });
+64 -20
View File
@@ -30,12 +30,14 @@ import {
type StoredChatOutbox,
type StoredChatOutboxScope,
} from "./composer-persistence.ts";
import { formatConnectError } from "./connect-error.ts";
import { isQueuedMessageBeingEdited } from "./queued-message-edit.ts";
import { isChatBusy } from "./run-lifecycle.ts";
import {
chatMessagesContainQueuedSend,
OFFLINE_QUEUE_STORAGE_ERROR,
preserveQueuedUserTurn,
surfaceChatDeliveryFailure,
} from "./steer-lifecycle.ts";
export type QueuedChatSendResult = "sent" | "pending" | "failed";
@@ -179,16 +181,43 @@ async function readCurrentStoredChatHistory(
limit: 1000,
});
} catch (err) {
const connectionCurrent =
host.client === client && host.connectionEpoch === connectionEpoch && host.connected;
const retryDelayMs = retryableGatewayDelayMs(err);
if (
retryDelayMs !== null &&
host.client === client &&
host.connectionEpoch === connectionEpoch &&
host.connected
) {
scheduleStoredChatOutboxRetry(host, outbox, retryDelayMs, dependencies);
if (retryDelayMs !== null) {
if (connectionCurrent) {
scheduleStoredChatOutboxRetry(host, outbox, retryDelayMs, dependencies);
}
return "blocked";
}
return "blocked";
// An authoritative non-retryable rejection (auth loss, revoked scope) will
// repeat on every drain wakeup; leaving the head silently "blocked" wedges
// the whole FIFO lane forever. Fail or park it visibly so the operator sees
// the outcome and the lane can move past a never-attempted head.
if (!connectionCurrent || !(err instanceof GatewayRequestError)) {
return "blocked";
}
const attempted =
(item.sendAttempts ?? 0) > 0 ||
item.sendRequestStartedAtMs !== undefined ||
item.sendState === "unconfirmed";
const error = attempted ? UNCONFIRMED_CHAT_SEND_ERROR : formatConnectError(err);
const targetState = attempted ? ("unconfirmed" as const) : ("failed" as const);
if (item.sendState === targetState && item.sendError === error) {
return "blocked";
}
const parked = updateQueuedMessageForSession(host, outbox.sessionKey, item.id, (entry) => ({
...entry,
sendError: error,
sendState: targetState,
}));
surfaceChatDeliveryFailure(
host,
outbox.sessionKey,
outbox.agentId,
parked ? error : OFFLINE_QUEUE_STORAGE_ERROR,
);
return parked && !attempted ? "continue" : "blocked";
}
const currentOutbox = readStoredChatOutbox(host, outbox);
const currentItem = currentOutbox?.queue.find((entry) => entry.id === item.id);
@@ -272,8 +301,13 @@ async function reconcileStoredChatOutboxHead(
sendError: UNCONFIRMED_CHAT_SEND_ERROR,
sendState: "unconfirmed",
}));
if (parked && visibleSessionMatches(host, outbox.sessionKey, outbox.agentId)) {
dependencies.setChatError(host, UNCONFIRMED_CHAT_SEND_ERROR);
if (parked) {
surfaceChatDeliveryFailure(
host,
outbox.sessionKey,
outbox.agentId,
UNCONFIRMED_CHAT_SEND_ERROR,
);
}
return "blocked";
}
@@ -411,12 +445,13 @@ async function drainStoredChatOutbox(
visibleSessionMatches(host, outbox.sessionKey, outbox.agentId);
const failCommand = (error: string, expose = false): "blocked" => {
const updated = setCommandState("failed", error);
if (commandScopeIsCurrent()) {
if (!updated) {
dependencies.setChatError(host, OFFLINE_QUEUE_STORAGE_ERROR);
} else if (expose) {
dependencies.setChatError(host, error);
}
if (!updated || expose) {
surfaceChatDeliveryFailure(
host,
outbox.sessionKey,
outbox.agentId,
updated ? error : OFFLINE_QUEUE_STORAGE_ERROR,
);
}
return "blocked";
};
@@ -435,11 +470,17 @@ async function drainStoredChatOutbox(
return "blocked";
}
if (dispatchResult === "failed") {
// A still-current scope already saw the dispatcher's inline error.
// After a route switch the dispatcher withholds it and the pane is
// gone, so the terminal failure must surface globally. A stale scope
// with the pane still visible (connection replaced) keeps the failed
// queue chip instead: the new connection owns the inline surface.
const commandStillCurrent = commandScopeIsCurrent();
const error =
(commandStillCurrent ? host.lastError : null) ??
`Command /${item.localCommandName} failed.`;
return failCommand(error);
const paneHidden = !visibleSessionMatches(host, outbox.sessionKey, outbox.agentId);
return failCommand(error, !commandStillCurrent && paneHidden);
}
if (dispatchResult === "uncertain") {
const currentOutbox = readStoredChatOutbox(host, outbox);
@@ -466,9 +507,12 @@ async function drainStoredChatOutbox(
}
}
if (!removeQueuedMessageWithoutReleasing(host, item.id, outbox.sessionKey)) {
if (commandScopeIsCurrent()) {
dependencies.setChatError(host, OFFLINE_QUEUE_STORAGE_ERROR);
}
surfaceChatDeliveryFailure(
host,
outbox.sessionKey,
outbox.agentId,
OFFLINE_QUEUE_STORAGE_ERROR,
);
return "blocked";
}
if (dispatchResult === "uncertain") {
+5 -1
View File
@@ -207,11 +207,15 @@ export class ChatPane extends ChatPaneBrowserAnnotationRender {
selectedSession.sharingRole === "viewer" &&
isGatewayMethodAdvertised(gatewaySnapshot, "session.suggestions.add") === true &&
isGatewayMethodAdvertised(gatewaySnapshot, "session.suggestions.list") === true;
// Every composer-disabling gate needs a visible reason here or a banner in
// sessionDisabledBanner; a silently disabled composer is a silent failure.
const disabledReason = modelUnavailable
? `${t("modelSetup.failure.auth")}. ${t("modelSetup.failureGuidance.auth")}`
: sessionParticipationBlocked && !suggestionViewer
? t("chat.sessionSharing.readOnlyNotice")
: null;
: cloudStartupPending
? t("newSession.starting")
: null;
const typingEnabled =
multiIdentity &&
hasOperatorWriteAccess(gatewaySnapshot.hello?.auth ?? null) &&
+18 -21
View File
@@ -63,7 +63,11 @@ import { resetChatInputHistoryNavigation } from "./input-history.ts";
import { controlUiNowMs, roundedControlUiDurationMs } from "./performance.ts";
import { hasAbortableSessionRun, isChatBusy, reconcileChatRunLifecycle } from "./run-lifecycle.ts";
import { resetChatScroll, scheduleChatScroll } from "./scroll.ts";
import { formatTerminalChatSendAckError, OFFLINE_QUEUE_STORAGE_ERROR } from "./steer-lifecycle.ts";
import {
formatTerminalChatSendAckError,
OFFLINE_QUEUE_STORAGE_ERROR,
surfaceChatDeliveryFailure,
} from "./steer-lifecycle.ts";
import { resetToolStream } from "./tool-stream.ts";
import { buildUserChatMessageContentBlocks } from "./user-message-content.ts";
@@ -224,9 +228,7 @@ async function sendQueuedChatMessage(
const access = readChatResetTargetAccess(host, options.target);
if (!access.allowed) {
setState("failed", access.reason);
if (visibleSessionMatches(host, sessionKey, prepared.agentId)) {
setChatError(host, access.reason);
}
surfaceChatDeliveryFailure(host, sessionKey, prepared.agentId, access.reason);
return "failed";
}
}
@@ -245,9 +247,7 @@ async function sendQueuedChatMessage(
}
if (!waiting) {
setState("failed", OFFLINE_QUEUE_STORAGE_ERROR);
if (visibleSessionMatches(host, sessionKey, prepared.agentId)) {
setChatError(host, OFFLINE_QUEUE_STORAGE_ERROR);
}
surfaceChatDeliveryFailure(host, sessionKey, prepared.agentId, OFFLINE_QUEUE_STORAGE_ERROR);
}
return "pending";
}
@@ -267,9 +267,7 @@ async function sendQueuedChatMessage(
agentId: prepared.agentId,
}));
if (!sendingItem) {
if (visibleSessionMatches(host, sessionKey, prepared.agentId)) {
setChatError(host, OFFLINE_QUEUE_STORAGE_ERROR);
}
surfaceChatDeliveryFailure(host, sessionKey, prepared.agentId, OFFLINE_QUEUE_STORAGE_ERROR);
return "pending";
}
registerChatSendTiming(host, sendingItem, runId, requestStartedAtMs);
@@ -349,9 +347,9 @@ async function sendQueuedChatMessage(
publishRunStatus: false,
armLocalTerminalReconcile: ack.runId === runId,
});
setChatError(host, error);
restoreComposer(host, options ?? {});
}
surfaceChatDeliveryFailure(host, sessionKey, prepared.agentId, error);
recordChatSendTiming(host, sendingItem, "failed", sendingItem.sendSubmittedAtMs, {
error,
ackStatus: ack.status,
@@ -431,9 +429,7 @@ async function sendQueuedChatMessage(
}
discardChatAttachmentDataUrls(excludeComposerAttachments(host, attachments));
if (retirementFailed) {
if (isVisible()) {
setChatError(host, OFFLINE_QUEUE_STORAGE_ERROR);
}
surfaceChatDeliveryFailure(host, sessionKey, prepared.agentId, OFFLINE_QUEUE_STORAGE_ERROR);
return "pending";
}
return retireOnAck ? "sent" : "pending";
@@ -478,9 +474,12 @@ async function sendQueuedChatMessage(
sendState: safelyRejected ? "failed" : "unconfirmed",
}));
}
if (isVisible()) {
setChatError(host, restore ? error : OFFLINE_QUEUE_STORAGE_ERROR);
}
surfaceChatDeliveryFailure(
host,
sessionKey,
prepared.agentId,
restore ? error : OFFLINE_QUEUE_STORAGE_ERROR,
);
recordChatSendTiming(host, prepared, "failed", prepared.sendSubmittedAtMs, {
error: restore ? error : OFFLINE_QUEUE_STORAGE_ERROR,
});
@@ -503,9 +502,7 @@ async function sendQueuedChatMessage(
sendState: "failed",
}));
}
if (isVisible()) {
setChatError(host, OFFLINE_QUEUE_STORAGE_ERROR);
}
surfaceChatDeliveryFailure(host, sessionKey, prepared.agentId, OFFLINE_QUEUE_STORAGE_ERROR);
recordChatSendTiming(host, prepared, "failed", prepared.sendSubmittedAtMs, {
error: OFFLINE_QUEUE_STORAGE_ERROR,
});
@@ -534,12 +531,12 @@ async function sendQueuedChatMessage(
}
setState("failed", error);
if (isVisible()) {
setChatError(host, error);
restoreComposer(host, options ?? {});
if (activeLeafChanged) {
void Promise.all([loadChatHistory(host), loadChatBranches(host)]);
}
}
surfaceChatDeliveryFailure(host, sessionKey, prepared.agentId, error);
recordChatSendTiming(host, prepared, "failed", prepared.sendSubmittedAtMs, { error });
return "failed";
} finally {
+7 -4
View File
@@ -20,7 +20,7 @@ import { storedChatOutboxScopeKey, type StoredChatOutboxScope } from "./composer
import { controlUiNowMs } from "./performance.ts";
import { hasAbortableSessionRun, isChatBusy } from "./run-lifecycle.ts";
import { scheduleChatScroll } from "./scroll.ts";
import { OFFLINE_QUEUE_STORAGE_ERROR } from "./steer-lifecycle.ts";
import { OFFLINE_QUEUE_STORAGE_ERROR, surfaceChatDeliveryFailure } from "./steer-lifecycle.ts";
const SKILL_WORKSHOP_CONNECTION_CHANGED_ERROR =
"Skill Workshop revision request cancelled because the Gateway connection changed.";
@@ -132,9 +132,12 @@ export function failSkillWorkshopRevisionConnectionChange(
sessionKey,
item.id,
)("failed", SKILL_WORKSHOP_CONNECTION_CHANGED_ERROR);
if (visibleSessionMatches(host, sessionKey, item.agentId)) {
setChatError(host, SKILL_WORKSHOP_CONNECTION_CHANGED_ERROR);
}
surfaceChatDeliveryFailure(
host,
sessionKey,
item.agentId,
SKILL_WORKSHOP_CONNECTION_CHANGED_ERROR,
);
return "failed";
}
+227
View File
@@ -9414,6 +9414,233 @@ describe("handleSendChat", () => {
expect(getChatAttachmentDataUrl(attachment)).toBeNull();
expect(revokeObjectURL).toHaveBeenCalledWith("blob:queued");
});
it("surfaces a terminal send failure through the global toast when the pane is not visible", async () => {
const toastHost = document.createElement("openclaw-toast-host");
document.body.append(toastHost);
const host = makeChatHost({
requestHandlers: {
"chat.history": idleChatHistory("agent:other"),
"chat.send": () => {
throw new GatewayRequestError({
code: "UNAUTHORIZED",
message: "gateway auth failed",
retryable: false,
});
},
},
chatQueue: [
{
id: "hidden-terminal-failure",
text: "fails while another session is visible",
createdAt: 1,
sendAttempts: 0,
sendRunId: "hidden-terminal-run",
sendState: "waiting-idle",
sessionKey: "agent:other",
agentId: "other",
},
],
sessionKey: "agent:main",
sessionsResult: createSessionsResult([
row("agent:other", { hasActiveRun: false, status: "done" }),
]),
});
admitHostQueueItems(host);
await retryReconnectableQueuedChatSends(host);
// The invisible pane's inline error stays untouched; the failure surfaces globally.
expect(host.lastError).toBeNull();
await waitForFast(() => expect(document.body.textContent).toContain("gateway auth failed"));
expect(
listStoredChatOutboxes(host)
.flatMap((outbox) => outbox.queue)
.find((entry) => entry.id === "hidden-terminal-failure"),
).toMatchObject({ sendState: "failed", sendError: "gateway auth failed" });
document.body.replaceChildren();
});
it("fails a never-attempted head visibly and unblocks the lane when head reconcile is rejected as non-retryable", async () => {
const sends: string[] = [];
let historyCalls = 0;
const host = makeChatHost({
requestHandlers: {
"chat.history": () => {
historyCalls += 1;
if (historyCalls === 1) {
throw new GatewayRequestError({
code: "UNAUTHORIZED",
message: "gateway auth failed",
retryable: false,
});
}
return idleChatHistory();
},
"chat.send": (params: unknown) => {
const payload = requireRecord(params, "post-unblock send payload");
sends.push(String(payload.message));
return { runId: payload.idempotencyKey, status: "ok" };
},
},
chatQueue: [
{
id: "wedged-head",
text: "head the gateway rejects",
createdAt: 1,
sendAttempts: 0,
sendRunId: "wedged-head-run",
sendState: "waiting-idle",
sessionKey: "agent:main",
},
{
id: "queued-behind-head",
text: "message stuck behind the head",
createdAt: 2,
sendAttempts: 0,
sendRunId: "queued-behind-run",
sendState: "waiting-idle",
sessionKey: "agent:main",
},
],
});
admitHostQueueItems(host);
const surfacedErrors: (string | null)[] = [];
let trackedError: string | null = host.lastError ?? null;
Object.defineProperty(host, "lastError", {
get: () => trackedError,
set: (value: string | null) => {
trackedError = value;
surfacedErrors.push(value);
},
});
await retryReconnectableQueuedChatSends(host);
// Pre-fix: the head stayed silently "blocked" forever and nothing surfaced.
expect(surfacedErrors).toContain("gateway auth failed");
const stored = listStoredChatOutboxes(host).flatMap((outbox) => outbox.queue);
expect(stored.find((entry) => entry.id === "wedged-head")).toMatchObject({
sendState: "failed",
sendError: "gateway auth failed",
});
// The lane moved past the terminally failed head instead of wedging.
await waitForFast(() => expect(sends).toContain("message stuck behind the head"));
});
it("parks an attempted head as unconfirmed instead of failing it on a non-retryable reconcile rejection", async () => {
const host = makeChatHost({
requestHandlers: {
"chat.history": () => {
throw new GatewayRequestError({
code: "UNAUTHORIZED",
message: "gateway auth failed",
retryable: false,
});
},
},
chatQueue: [
{
id: "attempted-head",
text: "head that may have reached the server",
createdAt: 1,
sendAttempts: 1,
sendRunId: "attempted-head-run",
sendState: "waiting-reconnect",
sessionKey: "agent:main",
},
],
});
admitHostQueueItems(host);
await retryReconnectableQueuedChatSends(host);
// An attempted head may already be a server-side turn; it must park for
// review rather than fail-and-release, and the outcome must be visible.
expect(host.lastError).toBe(
"Delivery could not be confirmed after reconnect. Check the conversation before retrying.",
);
const stored = listStoredChatOutboxes(host).flatMap((outbox) => outbox.queue);
expect(stored.find((entry) => entry.id === "attempted-head")).toMatchObject({
sendState: "unconfirmed",
});
expect(host.request.mock.calls.filter(([method]) => method === "chat.send")).toHaveLength(0);
});
it("surfaces a failed local command globally after a route switch", async () => {
const toastHost = document.createElement("openclaw-toast-host");
document.body.append(toastHost);
const item = createQueuedLocalCommand("route-switched-command", "/think", {
sessionKey: "agent:main:first",
});
// The dispatcher reports failure after the operator navigated away, so its
// stale-scope guard withholds the inline error.
executeSlashCommandMock.mockImplementation(async () => {
host.sessionKey = "agent:main:second";
return { failed: true, content: "think mode rejected" };
});
const host = makeChatHost({
requestHandlers: {
"chat.history": () => idleChatHistory("agent:main:first"),
},
chatQueue: [item],
sessionKey: item.sessionKey,
});
admitHostQueueItems(host);
await retryReconnectableQueuedChatSends(host);
// Pre-fix: the failure was recorded on the queue item with no visible outcome.
expect(host.lastError).toBeNull();
await waitForFast(() => expect(document.body.textContent).toContain("Command /think failed."));
expect(listStoredChatOutboxes(host).flatMap((outbox) => outbox.queue)).toEqual([
expect.objectContaining({ id: item.id, sendState: "failed" }),
]);
document.body.replaceChildren();
});
it("names the failed agent's global session in the toast, not another agent's row", async () => {
const toastHost = document.createElement("openclaw-toast-host");
document.body.append(toastHost);
const host = makeChatHost({
requestHandlers: {
"chat.history": idleChatHistory("global"),
"chat.send": () => {
throw new GatewayRequestError({
code: "UNAUTHORIZED",
message: "gateway auth failed",
retryable: false,
});
},
},
chatQueue: [
{
id: "global-agent-scoped-failure",
text: "fails on the second agent's global session",
createdAt: 1,
sendAttempts: 0,
sendRunId: "global-agent-scoped-run",
sendState: "waiting-idle",
sessionKey: "global",
agentId: "writer",
},
],
sessionKey: "agent:main:elsewhere",
sessionsResult: createSessionsResult([
row("global", { agentId: "main", label: "Main global chat" }),
row("global", { agentId: "writer", label: "Writer global chat" }),
]),
});
admitHostQueueItems(host);
await retryReconnectableQueuedChatSends(host);
await waitForFast(() => expect(document.body.textContent).toContain("gateway auth failed"));
// Global rows share one key; the toast must borrow the failed agent's label.
expect(document.body.textContent).toContain("Writer global chat");
expect(document.body.textContent).not.toContain("Main global chat");
document.body.replaceChildren();
});
});
describe("handleAbortChat", () => {
+57 -7
View File
@@ -4,8 +4,15 @@ import type { SessionsListResult } from "../../api/types.ts";
import { setLastActiveSessionKey } from "../../app/settings.ts";
import { compareChatQueueOrder } from "../../lib/chat/chat-queue-order.ts";
import type { ChatAttachment, ChatQueueItem } from "../../lib/chat/chat-types.ts";
import { resolveSessionDisplayName } from "../../lib/session-display.ts";
import { visibleSessionMatches } from "../../lib/sessions/index.ts";
import { uiSessionRowMatchesSelectedChat } from "../../lib/sessions/session-key.ts";
import {
areUiSessionKeysEquivalent,
isUiGlobalSessionKey,
normalizeAgentId,
uiSessionRowMatchesSelectedChat,
} from "../../lib/sessions/session-key.ts";
import { showToast } from "../../lib/toast.ts";
import { generateUUID } from "../../lib/uuid.ts";
import {
getChatAttachmentDataUrl,
@@ -313,6 +320,43 @@ function setChatError(host: SteerLifecycleHost, error: string | null): void {
host.chatError = error;
}
type ChatDeliveryFailureHost = Parameters<typeof visibleSessionMatches>[0] & {
lastError?: string | null;
chatError?: string | null;
sessionsResult?: SessionsListResult | null;
};
/**
* Terminal delivery failures must always end in a visible outcome. The pane
* showing this session keeps the inline chat error; after reconnect or alias
* drift the owning pane may no longer be on screen, so anything else surfaces
* a global toast naming the session instead of recording the error only on
* the queued row where nobody sees it.
*/
export function surfaceChatDeliveryFailure(
host: ChatDeliveryFailureHost,
sessionKey: string,
agentId: string | undefined,
error: string,
): void {
if (visibleSessionMatches(host, sessionKey, agentId)) {
host.lastError = error;
host.chatError = error;
return;
}
// Global rows are agent-scoped while sharing one "global" key, so an
// agent-less equivalence match could borrow another agent's label.
const scopedAgentId = agentId ? normalizeAgentId(agentId) : undefined;
const row = host.sessionsResult?.sessions.find(
(session) =>
areUiSessionKeysEquivalent(session.key, sessionKey) &&
(!isUiGlobalSessionKey(sessionKey) ||
!scopedAgentId ||
(session.agentId !== undefined && normalizeAgentId(session.agentId) === scopedAgentId)),
);
showToast({ message: `${resolveSessionDisplayName(sessionKey, row)}: ${error}` });
}
export async function sendQueuedChatMessageWithQueueMode(
host: SteerSendHost,
id: string,
@@ -446,9 +490,12 @@ export async function sendQueuedChatMessageWithQueueMode(
sendError: result.error,
sendState: "failed",
}));
if (itemStillVisible) {
setChatError(host, failed ? result.error : OFFLINE_QUEUE_STORAGE_ERROR);
}
surfaceChatDeliveryFailure(
host,
itemSessionKey,
item.agentId,
failed ? result.error : OFFLINE_QUEUE_STORAGE_ERROR,
);
return;
}
const ack = result;
@@ -462,9 +509,12 @@ export async function sendQueuedChatMessageWithQueueMode(
setChatError(host, unconfirmedError);
}
} else {
if (itemStillVisible) {
setChatError(host, formatTerminalChatSendAckError(ack, isSteer ? "steer" : "chat"));
}
surfaceChatDeliveryFailure(
host,
itemSessionKey,
item.agentId,
formatTerminalChatSendAckError(ack, isSteer ? "steer" : "chat"),
);
dependencies.resumeRestoredOutbox(host, id);
}
return;