fix(control-ui): dismiss talk startup errors

This commit is contained in:
Peter Steinberger
2026-05-04 08:31:56 +01:00
parent 585ce38015
commit e11a8a84ac
8 changed files with 115 additions and 4 deletions

View File

@@ -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.

View File

@@ -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%);

View File

@@ -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();
});
});

View File

@@ -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,
});
}

View File

@@ -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,

View File

@@ -77,7 +77,7 @@ async function refreshSessionOptions(state: AppViewState) {
limit: 0,
includeGlobal: true,
includeUnknown: true,
showArchived: Boolean(state.sessionsShowArchived),
showArchived: state.sessionsShowArchived,
});
}

View File

@@ -389,6 +389,7 @@ function renderChatView(overrides: Partial<Parameters<typeof renderChat>[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<HTMLButtonElement>('[aria-label="Dismiss error"]')?.click();
expect(onDismissError).toHaveBeenCalledTimes(1);
});
});
describe("chat slash menu accessibility", () => {

View File

@@ -111,6 +111,7 @@ export type ChatProps = {
onCompact?: () => void | Promise<void>;
onOpenSessionCheckpoints?: () => void | Promise<void>;
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`<div class="callout">${props.disabledReason}</div>` : nothing}
${props.error ? html`<div class="callout danger">${props.error}</div>` : nothing}
${props.error
? html`
<div class="callout danger callout--dismissible" role="alert">
<span class="callout__content">${props.error}</span>
${props.onDismissError
? html`
<button
class="callout__dismiss"
type="button"
@click=${props.onDismissError}
aria-label="Dismiss error"
title="Dismiss error"
>
${icons.x}
</button>
`
: nothing}
</div>
`
: nothing}
${props.focusMode
? html`
<button