Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
45 changes: 24 additions & 21 deletions apps/web/src/components/sme/SmeChatWorkspace.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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<HTMLDivElement>(null);
const textareaRef = useRef<HTMLTextAreaElement>(null);
const serverConfigQuery = useQuery(serverConfigQueryOptions());
Expand Down Expand Up @@ -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]);
Expand Down Expand Up @@ -249,36 +254,34 @@ export function SmeChatWorkspace({

<div className="px-4">
<ProviderHealthBanner status={providerStatus} />
{validationQuery.data ? (
{validationQuery.data && !bannerDismissed ? (
<div className="mx-auto max-w-3xl pt-3">
<Alert variant={validationQuery.data.ok ? "default" : "error"}>
<AlertTitle>
{validationQuery.data.ok ? "Provider ready" : "Provider setup required"}
<AlertTitle className="flex items-center justify-between">
<span>
{validationQuery.data.ok ? "Provider ready" : "Provider setup required"}
</span>
<button
type="button"
onClick={() => setBannerDismissed(true)}
className="rounded-md p-0.5 text-muted-foreground transition-colors hover:bg-muted hover:text-foreground"
aria-label="Dismiss"
>
<XIcon className="size-3.5" />
</button>
</AlertTitle>
<AlertDescription className="flex items-center justify-between gap-3">
<span>{validationQuery.data.message}</span>
<span className="flex shrink-0 items-center gap-2">
{!validationQuery.data.ok ? (
<Button
type="button"
size="sm"
variant="outline"
className="gap-1.5"
onClick={() => void validationQuery.refetch()}
onClick={() => setDialogOpen(true)}
>
<RefreshCcwIcon className="size-3.5" />
Test
Settings
</Button>
{!validationQuery.data.ok ? (
<Button
type="button"
size="sm"
variant="outline"
onClick={() => void navigate({ to: "/settings" })}
>
Settings
</Button>
) : null}
</span>
) : null}
</AlertDescription>
</Alert>
</div>
Expand Down
77 changes: 75 additions & 2 deletions apps/web/src/components/sme/SmeConversationDialog.tsx
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -52,6 +59,8 @@ export function SmeConversationDialog({
const [authMethod, setAuthMethod] = useState<SmeAuthMethod>("apiKey");
const [model, setModel] = useState("claude-sonnet-4-6");
const [error, setError] = useState<string | null>(null);
const [testing, setTesting] = useState(false);
const [testResult, setTestResult] = useState<{ ok: boolean; message: string } | null>(null);

const modelOptionsByProvider = useMemo(() => {
const options = getCustomModelOptionsByProvider(settings);
Expand Down Expand Up @@ -93,6 +102,7 @@ export function SmeConversationDialog({
setAuthMethod(nextAuthMethod);
setModel(nextModel);
setError(null);
setTestResult(null);
}, [
conversation,
open,
Expand All @@ -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(
Expand Down Expand Up @@ -233,6 +267,45 @@ export function SmeConversationDialog({
</div>
) : null}

{/* Test Connection */}
{conversation ? (
<div className="space-y-2">
<div className="flex items-center gap-2">
<Button
type="button"
variant="outline"
size="sm"
className="gap-1.5"
disabled={testing}
onClick={() => void handleTestConnection()}
>
{testing ? (
<Loader2Icon className="size-3.5 animate-spin" />
) : (
<RefreshCcwIcon className="size-3.5" />
)}
{testing ? "Testing..." : "Test Connection"}
</Button>
</div>
{testResult ? (
<div
className={`flex items-start gap-2 rounded-xl border px-3 py-2.5 text-xs ${
testResult.ok
? "border-emerald-500/30 bg-emerald-500/5 text-emerald-600 dark:text-emerald-400"
: "border-destructive/30 bg-destructive/5 text-destructive"
}`}
>
{testResult.ok ? (
<CheckCircle2Icon className="mt-px size-3.5 shrink-0" />
) : (
<XCircleIcon className="mt-px size-3.5 shrink-0" />
)}
<span>{testResult.message}</span>
</div>
) : null}
</div>
) : null}

{error ? <p className="text-xs text-destructive">{error}</p> : null}
</DialogPanel>
<DialogFooter>
Expand Down
17 changes: 16 additions & 1 deletion apps/web/src/components/sme/SmeMessageBubble.tsx
Original file line number Diff line number Diff line change
@@ -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;
}
Expand Down Expand Up @@ -37,7 +40,19 @@ export function SmeMessageBubble({ message }: SmeMessageBubbleProps) {
isUser ? "bg-primary text-primary-foreground" : "bg-muted/60 text-foreground",
)}
>
<div className="whitespace-pre-wrap break-words">{message.text}</div>
{isUser ? (
<div className="whitespace-pre-wrap break-words">{message.text}</div>
) : (
<Suspense
fallback={<div className="whitespace-pre-wrap break-words">{message.text}</div>}
>
<ChatMarkdown
text={message.text}
cwd={undefined}
isStreaming={Boolean(message.isStreaming)}
/>
</Suspense>
)}

{/* Streaming cursor */}
{message.isStreaming ? (
Expand Down
Loading