diff --git a/config/control-ui-startup-budget-baseline.json b/config/control-ui-startup-budget-baseline.json index 14ed83f192fa..b7edfc4bca31 100644 --- a/config/control-ui-startup-budget-baseline.json +++ b/config/control-ui-startup-budget-baseline.json @@ -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" } diff --git a/src/gateway/worker-environments/workspace-sync.test.ts b/src/gateway/worker-environments/workspace-sync.test.ts index 3b08b1ad6f22..a6d2a29ffa3c 100644 --- a/src/gateway/worker-environments/workspace-sync.test.ts +++ b/src/gateway/worker-environments/workspace-sync.test.ts @@ -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(), ); diff --git a/ui/src/lib/toast.ts b/ui/src/lib/toast.ts index 5144c6065338..8dd3746cbf4c 100644 --- a/ui/src/lib/toast.ts +++ b/ui/src/lib/toast.ts @@ -113,6 +113,9 @@ class OpenClawToastHost extends OpenClawLightDomContentsElement { } export function showToast(options: ToastOptions): boolean { + if (typeof document === "undefined") { + return false; + } const host = document.querySelector("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); } diff --git a/ui/src/pages/chat/chat-composer.test.ts b/ui/src/pages/chat/chat-composer.test.ts index ad83d1a6263d..d1df1276b0ad 100644 --- a/ui/src/pages/chat/chat-composer.test.ts +++ b/ui/src/pages/chat/chat-composer.test.ts @@ -234,6 +234,20 @@ describe("renderChatComposer controls", () => { expect(container.querySelector("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("textarea")?.disabled).toBe(true); + }); + it("switches the primary action between voice, send, queue, and stop", () => { const onToggleRealtimeTalk = vi.fn(); let view = renderComposer({ onToggleRealtimeTalk }); diff --git a/ui/src/pages/chat/chat-outbox-drain.ts b/ui/src/pages/chat/chat-outbox-drain.ts index c0ecdd8f184a..c2bf734428f6 100644 --- a/ui/src/pages/chat/chat-outbox-drain.ts +++ b/ui/src/pages/chat/chat-outbox-drain.ts @@ -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") { diff --git a/ui/src/pages/chat/chat-pane-render.ts b/ui/src/pages/chat/chat-pane-render.ts index cc98a9966fa6..791b373783a7 100644 --- a/ui/src/pages/chat/chat-pane-render.ts +++ b/ui/src/pages/chat/chat-pane-render.ts @@ -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) && diff --git a/ui/src/pages/chat/chat-send-delivery.ts b/ui/src/pages/chat/chat-send-delivery.ts index da71216916f0..ea6a26377e78 100644 --- a/ui/src/pages/chat/chat-send-delivery.ts +++ b/ui/src/pages/chat/chat-send-delivery.ts @@ -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 { diff --git a/ui/src/pages/chat/chat-send-queue-state.ts b/ui/src/pages/chat/chat-send-queue-state.ts index b7a3aca6b885..0c2c3ee2be5d 100644 --- a/ui/src/pages/chat/chat-send-queue-state.ts +++ b/ui/src/pages/chat/chat-send-queue-state.ts @@ -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"; } diff --git a/ui/src/pages/chat/chat-send.test.ts b/ui/src/pages/chat/chat-send.test.ts index 6391b3fe674c..11c1a4d80ab1 100644 --- a/ui/src/pages/chat/chat-send.test.ts +++ b/ui/src/pages/chat/chat-send.test.ts @@ -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", () => { diff --git a/ui/src/pages/chat/steer-lifecycle.ts b/ui/src/pages/chat/steer-lifecycle.ts index ad4161170c9d..7753dede9237 100644 --- a/ui/src/pages/chat/steer-lifecycle.ts +++ b/ui/src/pages/chat/steer-lifecycle.ts @@ -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[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;