diff --git a/CHANGELOG.md b/CHANGELOG.md index a6c96c5b649..872d271cffa 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -59,6 +59,7 @@ Docs: https://docs.openclaw.ai ### Fixes +- Control UI/Talk: make failed Talk startup errors dismissable and clear the stale Talk error state when dismissed, so missing realtime voice provider configuration does not leave a permanent chat banner. Fixes #77071. Thanks @ijoshdavis. - Web fetch: late-bind `web_fetch` config and provider fallback metadata from the active runtime snapshot, matching `web_search` so long-lived tools do not use stale fetch provider settings. Thanks @vincentkoc. - Discord: clear stale startup probe bot/application status when the async bot probe throws, not just when it returns a degraded probe result. Thanks @vincentkoc. - Web search: scope explicit bundled `web_search` provider runtime loading through manifest ownership, so selecting DuckDuckGo/Gemini/etc. does not import unrelated bundled providers or log their optional dependency failures. Thanks @vincentkoc. diff --git a/ui/src/styles/components.css b/ui/src/styles/components.css index 651f3e29865..3a0233a499a 100644 --- a/ui/src/styles/components.css +++ b/ui/src/styles/components.css @@ -1543,6 +1543,40 @@ position: relative; } +.callout--dismissible { + display: flex; + align-items: flex-start; + gap: 10px; +} + +.callout__content { + min-width: 0; + flex: 1; +} + +.callout__dismiss { + width: 28px; + height: 28px; + display: inline-flex; + align-items: center; + justify-content: center; + flex: 0 0 auto; + border: 0; + border-radius: var(--radius-sm); + background: transparent; + color: currentColor; + cursor: pointer; +} + +.callout__dismiss:hover { + background: rgba(255, 255, 255, 0.08); +} + +.callout__dismiss svg { + width: 16px; + height: 16px; +} + .callout.danger { border-color: rgba(239, 68, 68, 0.25); background: linear-gradient(135deg, rgba(239, 68, 68, 0.08) 0%, rgba(239, 68, 68, 0.04) 100%); diff --git a/ui/src/ui/app-render.helpers.node.test.ts b/ui/src/ui/app-render.helpers.node.test.ts index 6e19369080a..f0682191ef4 100644 --- a/ui/src/ui/app-render.helpers.node.test.ts +++ b/ui/src/ui/app-render.helpers.node.test.ts @@ -36,6 +36,7 @@ vi.mock("./controllers/sessions.ts", () => ({ import { createChatSession, + dismissChatError, isCronSessionKey, parseSessionKey, resolveAssistantAttachmentAuthToken, @@ -771,6 +772,7 @@ describe("switchChatSession", () => { chatRunId: "run-1", chatSideResultTerminalRuns: new Set(["btw-run-1"]), chatStreamStartedAt: 1, + sessionsShowArchived: false, settings, applySettings(next: typeof settings) { state.settings = next; @@ -904,3 +906,25 @@ describe("switchChatSession", () => { }); }); }); + +describe("dismissChatError", () => { + it("clears persistent Talk error state", () => { + const state = { + lastError: 'Realtime voice provider "openai" is not configured', + lastErrorCode: "UNAVAILABLE", + realtimeTalkActive: false, + realtimeTalkStatus: "error", + realtimeTalkDetail: 'Realtime voice provider "openai" is not configured', + realtimeTalkTranscript: "partial transcript", + } as AppViewState; + + dismissChatError(state); + + expect(state.lastError).toBeNull(); + expect(state.lastErrorCode).toBeNull(); + expect(state.realtimeTalkActive).toBe(false); + expect(state.realtimeTalkStatus).toBe("idle"); + expect(state.realtimeTalkDetail).toBeNull(); + expect(state.realtimeTalkTranscript).toBeNull(); + }); +}); diff --git a/ui/src/ui/app-render.helpers.ts b/ui/src/ui/app-render.helpers.ts index a1c897b26f0..ca377befc81 100644 --- a/ui/src/ui/app-render.helpers.ts +++ b/ui/src/ui/app-render.helpers.ts @@ -619,6 +619,17 @@ export function switchChatSession(state: AppViewState, nextSessionKey: string) { void refreshSessionOptions(state); } +export function dismissChatError(state: AppViewState) { + state.lastError = null; + state.lastErrorCode = null; + if (state.realtimeTalkStatus === "error") { + state.realtimeTalkActive = false; + state.realtimeTalkStatus = "idle"; + state.realtimeTalkDetail = null; + state.realtimeTalkTranscript = null; + } +} + export async function createChatSession(state: AppViewState) { if (!state.client || !state.connected) { return; @@ -650,7 +661,7 @@ export async function createChatSession(state: AppViewState) { limit: 0, includeGlobal: true, includeUnknown: true, - showArchived: Boolean(state.sessionsShowArchived), + showArchived: state.sessionsShowArchived, }, ); if ( @@ -681,7 +692,7 @@ async function refreshSessionOptions(state: AppViewState) { limit: 0, includeGlobal: true, includeUnknown: true, - showArchived: Boolean(state.sessionsShowArchived), + showArchived: state.sessionsShowArchived, }); } diff --git a/ui/src/ui/app-render.ts b/ui/src/ui/app-render.ts index 98869e14edf..1e37f2b6ecd 100644 --- a/ui/src/ui/app-render.ts +++ b/ui/src/ui/app-render.ts @@ -15,6 +15,7 @@ import { renderSidebarConnectionStatus, renderTopbarThemeModeToggle, createChatSession, + dismissChatError, switchChatSession, } from "./app-render.helpers.ts"; import { warnQueryToken } from "./app-settings.ts"; @@ -2364,6 +2365,7 @@ export function renderApp(state: AppViewState) { canSend: state.connected, disabledReason: chatDisabledReason, error: state.lastError, + onDismissError: () => dismissChatError(state), sessions: state.sessionsResult, focusMode: chatFocus, autoExpandToolCalls: false, diff --git a/ui/src/ui/chat/session-controls.ts b/ui/src/ui/chat/session-controls.ts index 199bf4d0c77..fa098a19aaa 100644 --- a/ui/src/ui/chat/session-controls.ts +++ b/ui/src/ui/chat/session-controls.ts @@ -77,7 +77,7 @@ async function refreshSessionOptions(state: AppViewState) { limit: 0, includeGlobal: true, includeUnknown: true, - showArchived: Boolean(state.sessionsShowArchived), + showArchived: state.sessionsShowArchived, }); } diff --git a/ui/src/ui/views/chat.test.ts b/ui/src/ui/views/chat.test.ts index 2fefb4b8e3b..0daad37b70e 100644 --- a/ui/src/ui/views/chat.test.ts +++ b/ui/src/ui/views/chat.test.ts @@ -389,6 +389,7 @@ function renderChatView(overrides: Partial[0]> = { onSend: () => undefined, onCompact: () => undefined, onToggleRealtimeTalk: () => undefined, + onDismissError: () => undefined, onAbort: () => undefined, onQueueRemove: () => undefined, onQueueSteer: () => undefined, @@ -494,6 +495,24 @@ describe("chat voice controls", () => { expect(container.querySelector('[aria-label="Start Talk"]')).not.toBeNull(); expect(container.querySelector('[aria-label="Voice input"]')).toBeNull(); }); + + it("lets users dismiss Talk start errors", () => { + const onDismissError = vi.fn(); + const container = renderChatView({ + error: 'Realtime voice provider "openai" is not configured', + realtimeTalkStatus: "error", + realtimeTalkDetail: 'Realtime voice provider "openai" is not configured', + onDismissError, + }); + + expect(container.querySelector('[role="alert"]')?.textContent).toContain( + 'Realtime voice provider "openai" is not configured', + ); + + container.querySelector('[aria-label="Dismiss error"]')?.click(); + + expect(onDismissError).toHaveBeenCalledTimes(1); + }); }); describe("chat slash menu accessibility", () => { diff --git a/ui/src/ui/views/chat.ts b/ui/src/ui/views/chat.ts index d492c37e5bc..f13f199ec97 100644 --- a/ui/src/ui/views/chat.ts +++ b/ui/src/ui/views/chat.ts @@ -111,6 +111,7 @@ export type ChatProps = { onCompact?: () => void | Promise; onOpenSessionCheckpoints?: () => void | Promise; onToggleRealtimeTalk?: () => void; + onDismissError?: () => void; onAbort?: () => void; onQueueRemove: (id: string) => void; onQueueSteer?: (id: string) => void; @@ -1136,7 +1137,26 @@ export function renderChat(props: ChatProps) { @dragover=${(e: DragEvent) => e.preventDefault()} > ${props.disabledReason ? html`
${props.disabledReason}
` : nothing} - ${props.error ? html`
${props.error}
` : nothing} + ${props.error + ? html` + + ` + : nothing} ${props.focusMode ? html`