diff --git a/src/workspaces/helpers/error-text.ts b/src/workspaces/helpers/error-text.ts new file mode 100644 index 00000000..fc2bf7e4 --- /dev/null +++ b/src/workspaces/helpers/error-text.ts @@ -0,0 +1,18 @@ +/** + * A readable string for anything that was thrown. + * + * `useClient` rejects with a plain `{ status, message }` object, not an `Error`, so the reflex + * `err instanceof Error ? err.message : …` misses the most common failure in this app entirely and + * reports every API error as "unknown error". Check the shape first, the class second. + * + * Truncated because these end up in toasts, and a server that answers a failure with a wall of HTML + * should not push the rest of the screen off it. + */ +export function errorText(err: unknown, fallback = 'unknown error'): string { + if (typeof err === 'object' && err !== null && 'message' in err) { + const message = (err as { message: unknown }).message; + if (typeof message === 'string' && message.trim()) return message.slice(0, 200); + } + if (typeof err === 'string' && err.trim()) return err.slice(0, 200); + return fallback; +} diff --git a/src/workspaces/officerdev/src/apps/Chat/components/MessageBubble.tsx b/src/workspaces/officerdev/src/apps/Chat/components/MessageBubble.tsx index 8e4a22f3..ae680556 100644 --- a/src/workspaces/officerdev/src/apps/Chat/components/MessageBubble.tsx +++ b/src/workspaces/officerdev/src/apps/Chat/components/MessageBubble.tsx @@ -4,8 +4,10 @@ import remarkGfm from 'remark-gfm'; import rehypeRaw from 'rehype-raw'; import rehypeSanitize, { defaultSchema } from 'rehype-sanitize'; import { Volume2, Loader2, Square, Clock, Check, X, CircleSlash } from 'lucide-react'; +import { toast } from '@/components/ui/sonner'; import type { Tone } from '@/components/Data'; import { toneText } from '@/components/Data'; +import { errorText } from 'helpers/error-text'; import type { ChatMessage } from '../types'; import { ToolActivity } from './ToolActivity'; import { QuestionActivity } from './QuestionActivity'; @@ -91,11 +93,16 @@ const ReadAloudButton = ({ id, text }: { id: string; text: string }) => { audio.onerror = () => { audioRef.current = null; setState('idle'); + toast.error('The narration was generated but would not play.'); }; await audio.play(); setState('playing'); - } catch { + } catch (err) { + // Synthesis is slow enough that the spinner is the only feedback there is; when it just stops and + // the icon comes back, the button looks broken rather than failed. Most often this is the TTS + // service being down, which is worth saying out loud. setState('idle'); + toast.error(`Could not read this message aloud: ${errorText(err)}`); } }; diff --git a/src/workspaces/officerdev/src/apps/ChatHistory/SessionList.tsx b/src/workspaces/officerdev/src/apps/ChatHistory/SessionList.tsx index fcc1668e..b00f8ef8 100644 --- a/src/workspaces/officerdev/src/apps/ChatHistory/SessionList.tsx +++ b/src/workspaces/officerdev/src/apps/ChatHistory/SessionList.tsx @@ -4,6 +4,7 @@ import { Plus, MessageSquare, RefreshCw, Trash2, Pencil, Check, X } from 'lucide import { toast } from '@/components/ui/sonner'; import { DataList, DataRow, EmptyBlock, ErrorBlock, LoadingBlock, RelativeTime } from '@/components/Data'; import { usePanelChannel } from 'hooks/usePanelChannel'; +import { errorText } from 'helpers/error-text'; import { useClaudeSessions } from 'state/useClaudeSessions'; import type { SelectedSession } from './ChatDetailPanel'; import { PwdSelector } from './PwdSelector'; @@ -257,12 +258,3 @@ export const SessionList = () => { ); }; - -/** `useClient` throws `{ status, message }`, not an Error, so `err.message` alone misses the common case. */ -function errorText(err: unknown): string { - if (typeof err === 'object' && err !== null && 'message' in err) { - const message = (err as { message: unknown }).message; - if (typeof message === 'string' && message.trim()) return message.slice(0, 200); - } - return err instanceof Error ? err.message : 'unknown error'; -}