From 29a6adfc9ed22d8f15d55c96dfed830020a2812f Mon Sep 17 00:00:00 2001 From: Val Alexander Date: Fri, 10 Apr 2026 03:19:06 -0500 Subject: [PATCH] Add connection test controls and render SME replies as markdown - Let users dismiss the setup warning and open settings from the banner - Add a connection test inside the conversation dialog with success and error states - Render assistant messages with markdown via lazy loading while keeping user text plain --- .../src/components/sme/SmeChatWorkspace.tsx | 45 ++++++----- .../components/sme/SmeConversationDialog.tsx | 77 ++++++++++++++++++- .../src/components/sme/SmeMessageBubble.tsx | 17 +++- 3 files changed, 115 insertions(+), 24 deletions(-) diff --git a/apps/web/src/components/sme/SmeChatWorkspace.tsx b/apps/web/src/components/sme/SmeChatWorkspace.tsx index 2ad6614b..06e73bd9 100644 --- a/apps/web/src/components/sme/SmeChatWorkspace.tsx +++ b/apps/web/src/components/sme/SmeChatWorkspace.tsx @@ -5,10 +5,10 @@ import { BookOpenIcon, Settings2Icon, SparklesIcon, - RefreshCcwIcon, + XIcon, } from "lucide-react"; import type { SmeConversationId, SmeMessage, SmeMessageId } from "@okcode/contracts"; -import { useNavigate } from "@tanstack/react-router"; +import type { RegisteredRouter } from "@tanstack/react-router"; import { getProviderStartOptions, useAppSettings } from "~/appSettings"; import { ProviderHealthBanner } from "~/components/chat/ProviderHealthBanner"; @@ -62,6 +62,7 @@ export function SmeChatWorkspace({ const [inputText, setInputText] = useState(""); const [sending, setSending] = useState(false); const [dialogOpen, setDialogOpen] = useState(false); + const [bannerDismissed, setBannerDismissed] = useState(false); const messagesEndRef = useRef(null); const textareaRef = useRef(null); const serverConfigQuery = useQuery(serverConfigQueryOptions()); @@ -99,6 +100,10 @@ export function SmeChatWorkspace({ validationQuery.isLoading || (validationQuery.data ? !validationQuery.data.ok : false); + useEffect(() => { + setBannerDismissed(false); + }, [conversationId]); + useEffect(() => { messagesEndRef.current?.scrollIntoView({ behavior: "smooth" }); }, [messages, streamingText]); @@ -249,36 +254,34 @@ export function SmeChatWorkspace({
- {validationQuery.data ? ( + {validationQuery.data && !bannerDismissed ? (
- - {validationQuery.data.ok ? "Provider ready" : "Provider setup required"} + + + {validationQuery.data.ok ? "Provider ready" : "Provider setup required"} + + {validationQuery.data.message} - + {!validationQuery.data.ok ? ( - {!validationQuery.data.ok ? ( - - ) : null} - + ) : null}
diff --git a/apps/web/src/components/sme/SmeConversationDialog.tsx b/apps/web/src/components/sme/SmeConversationDialog.tsx index f61a3eea..d490c12e 100644 --- a/apps/web/src/components/sme/SmeConversationDialog.tsx +++ b/apps/web/src/components/sme/SmeConversationDialog.tsx @@ -1,10 +1,17 @@ import type { ProviderKind, SmeAuthMethod, SmeConversation } from "@okcode/contracts"; -import { useEffect, useMemo, useState } from "react"; -import { Settings2Icon } from "lucide-react"; +import { useCallback, useEffect, useMemo, useState } from "react"; +import { + CheckCircle2Icon, + Loader2Icon, + RefreshCcwIcon, + Settings2Icon, + XCircleIcon, +} from "lucide-react"; import { useNavigate } from "@tanstack/react-router"; import { getCustomModelOptionsByProvider, + getProviderStartOptions, resolveAppModelSelection, useAppSettings, } from "~/appSettings"; @@ -52,6 +59,8 @@ export function SmeConversationDialog({ const [authMethod, setAuthMethod] = useState("apiKey"); const [model, setModel] = useState("claude-sonnet-4-6"); const [error, setError] = useState(null); + const [testing, setTesting] = useState(false); + const [testResult, setTestResult] = useState<{ ok: boolean; message: string } | null>(null); const modelOptionsByProvider = useMemo(() => { const options = getCustomModelOptionsByProvider(settings); @@ -93,6 +102,7 @@ export function SmeConversationDialog({ setAuthMethod(nextAuthMethod); setModel(nextModel); setError(null); + setTestResult(null); }, [ conversation, open, @@ -101,8 +111,32 @@ export function SmeConversationDialog({ settings.customOpenClawModels, ]); + const providerOptions = useMemo(() => getProviderStartOptions(settings), [settings]); + + const handleTestConnection = useCallback(async () => { + if (!conversation || testing) return; + setTesting(true); + setTestResult(null); + try { + const api = ensureNativeApi(); + const result = await api.sme.validateSetup({ + conversationId: conversation.conversationId, + providerOptions, + }); + setTestResult(result); + } catch (cause) { + setTestResult({ + ok: false, + message: cause instanceof Error ? cause.message : "Connection test failed.", + }); + } finally { + setTesting(false); + } + }, [conversation, providerOptions, testing]); + const handleProviderChange = (nextProvider: ProviderKind) => { setProvider(nextProvider); + setTestResult(null); const nextAuthMethod = getDefaultSmeAuthMethod(nextProvider); setAuthMethod(nextAuthMethod); setModel( @@ -233,6 +267,45 @@ export function SmeConversationDialog({
) : null} + {/* Test Connection */} + {conversation ? ( +
+
+ +
+ {testResult ? ( +
+ {testResult.ok ? ( + + ) : ( + + )} + {testResult.message} +
+ ) : null} +
+ ) : null} + {error ?

{error}

: null} diff --git a/apps/web/src/components/sme/SmeMessageBubble.tsx b/apps/web/src/components/sme/SmeMessageBubble.tsx index d0a28dc4..9534e9ea 100644 --- a/apps/web/src/components/sme/SmeMessageBubble.tsx +++ b/apps/web/src/components/sme/SmeMessageBubble.tsx @@ -1,8 +1,11 @@ +import { lazy, Suspense } from "react"; import { UserIcon, SparklesIcon } from "lucide-react"; import type { SmeMessage } from "@okcode/contracts"; import { cn } from "~/lib/utils"; +const ChatMarkdown = lazy(() => import("~/components/ChatMarkdown")); + interface SmeMessageBubbleProps { message: SmeMessage; } @@ -37,7 +40,19 @@ export function SmeMessageBubble({ message }: SmeMessageBubbleProps) { isUser ? "bg-primary text-primary-foreground" : "bg-muted/60 text-foreground", )} > -
{message.text}
+ {isUser ? ( +
{message.text}
+ ) : ( + {message.text}} + > + + + )} {/* Streaming cursor */} {message.isStreaming ? (