Studio: mount chat artifact panel and overlay

This commit is contained in:
wasimysaid 2026-05-25 18:29:49 +02:00
commit 46adefa148

View file

@ -10,6 +10,11 @@ import {
ModelSelector,
} from "@/components/assistant-ui/model-selector";
import { Thread } from "@/components/assistant-ui/thread";
import {
ResizableHandle,
ResizablePanel,
ResizablePanelGroup,
} from "@/components/ui/resizable";
import { useSidebar } from "@/components/ui/sidebar";
import { Tooltip, TooltipContent } from "@/components/ui/tooltip";
import { NativeModelChip } from "@/features/native-intents/components/native-model-chip";
@ -76,6 +81,12 @@ import {
} from "./stores/chat-runtime-store";
import { useExternalProvidersStore } from "./stores/external-providers-store";
import { buildChatTourSteps } from "./tour";
import { ArtifactSurface } from "./artifacts/artifact-surface";
import {
useChatArtifactsStore,
useSelectedChatArtifact,
} from "./artifacts/store";
import type { ChatArtifact, ChatArtifactSurface } from "./artifacts/types";
import type { ChatView, MessageRecord } from "./types";
import {
getStoredChatThread,
@ -154,16 +165,58 @@ function messageHasImage(message: MessageRecord): boolean {
const SingleContent = memo(function SingleContent({
threadId,
newThreadNonce,
}: { threadId?: string; newThreadNonce?: string }): ReactElement {
artifact,
artifactSurface,
onCloseArtifact,
}: {
threadId?: string;
newThreadNonce?: string;
artifact?: ChatArtifact | null;
artifactSurface: ChatArtifactSurface;
onCloseArtifact: () => void;
}): ReactElement {
const openArtifact = useChatArtifactsStore((state) => state.openArtifact);
const showArtifactPanel = Boolean(
artifact &&
artifactSurface === "panel" &&
(!artifact.threadId || !threadId || artifact.threadId === threadId),
);
const threadPane = (
<div className="flex min-h-0 min-w-0 flex-1 basis-0 flex-col overflow-hidden">
<Thread hideWelcome={Boolean(threadId)} targetThreadId={threadId} />
</div>
);
return (
<ChatRuntimeProvider
modelType="base"
initialThreadId={threadId}
newThreadNonce={newThreadNonce}
>
<div className="flex min-h-0 min-w-0 flex-1 basis-0 flex-col overflow-hidden">
<Thread hideWelcome={Boolean(threadId)} targetThreadId={threadId} />
</div>
{showArtifactPanel && artifact ? (
<ResizablePanelGroup
orientation="horizontal"
className="min-h-0 min-w-0 flex-1 basis-0"
>
<ResizablePanel defaultSize={64} minSize={38}>
{threadPane}
</ResizablePanel>
<ResizableHandle withHandle={true} />
<ResizablePanel defaultSize={36} minSize={24} maxSize={55}>
<ArtifactSurface
artifact={artifact}
variant="panel"
onClose={onCloseArtifact}
onOpenFullscreen={() =>
openArtifact(artifact, { surface: "overlay" })
}
/>
</ResizablePanel>
</ResizablePanelGroup>
) : (
threadPane
)}
</ChatRuntimeProvider>
);
});
@ -329,15 +382,17 @@ const LoraCompareContent = memo(function LoraCompareContent({
useEffect(() => {
let isActive = true;
listStoredChatThreads({ pairId }).then((threads) => {
if (!isActive) return;
setBaseThreadId(threads.find((t) => t.modelType === "base")?.id);
setLoraThreadId(threads.find((t) => t.modelType === "lora")?.id);
}).catch((error) => {
if (!isExpectedBackgroundChatStorageError(error)) {
throw error;
}
});
listStoredChatThreads({ pairId })
.then((threads) => {
if (!isActive) return;
setBaseThreadId(threads.find((t) => t.modelType === "base")?.id);
setLoraThreadId(threads.find((t) => t.modelType === "lora")?.id);
})
.catch((error) => {
if (!isExpectedBackgroundChatStorageError(error)) {
throw error;
}
});
return () => {
isActive = false;
};
@ -478,21 +533,25 @@ const GeneralCompareContent = memo(function GeneralCompareContent({
useEffect(() => {
let isActive = true;
listStoredChatThreads({ pairId }).then((threads) => {
if (!isActive) return;
setModel1ThreadId(
threads.find((t) => t.modelType === "model1" || t.modelType === "base")
?.id,
);
setModel2ThreadId(
threads.find((t) => t.modelType === "model2" || t.modelType === "lora")
?.id,
);
}).catch((error) => {
if (!isExpectedBackgroundChatStorageError(error)) {
throw error;
}
});
listStoredChatThreads({ pairId })
.then((threads) => {
if (!isActive) return;
setModel1ThreadId(
threads.find(
(t) => t.modelType === "model1" || t.modelType === "base",
)?.id,
);
setModel2ThreadId(
threads.find(
(t) => t.modelType === "model2" || t.modelType === "lora",
)?.id,
);
})
.catch((error) => {
if (!isExpectedBackgroundChatStorageError(error)) {
throw error;
}
});
return () => {
isActive = false;
};
@ -645,8 +704,7 @@ export function ChatPage(): ReactElement {
} = useChatModelRuntime();
const prevConnectionsEnabledRef = useRef(connectionsEnabled);
useEffect(() => {
const turnedOff =
prevConnectionsEnabledRef.current && !connectionsEnabled;
const turnedOff = prevConnectionsEnabledRef.current && !connectionsEnabled;
if (!connectionsEnabled && isExternalModelId(inferenceParams.checkpoint)) {
clearCheckpoint();
if (turnedOff) {
@ -656,11 +714,7 @@ export function ChatPage(): ReactElement {
}
}
prevConnectionsEnabledRef.current = connectionsEnabled;
}, [
clearCheckpoint,
connectionsEnabled,
inferenceParams.checkpoint,
]);
}, [clearCheckpoint, connectionsEnabled, inferenceParams.checkpoint]);
const pendingNativeModelIntent = useNativeIntentStore(
(state) => state.pendingModelIntent,
);
@ -679,17 +733,19 @@ export function ChatPage(): ReactElement {
const reasoningEnabled = useChatRuntimeStore((s) => s.reasoningEnabled);
const reasoningStyle = useChatRuntimeStore((s) => s.reasoningStyle);
const reasoningEffort = useChatRuntimeStore((s) => s.reasoningEffort);
const supportsReasoningOff = useChatRuntimeStore((s) => s.supportsReasoningOff);
const supportsReasoningOff = useChatRuntimeStore(
(s) => s.supportsReasoningOff,
);
const activeExternalProvider = useMemo(() => {
const selection = parseExternalModelId(inferenceParams.checkpoint);
if (!selection) return null;
return (
externalProvidersForChat.find(
(p) => p.id === selection.providerId,
) ?? null
externalProvidersForChat.find((p) => p.id === selection.providerId) ??
null
);
}, [externalProvidersForChat, inferenceParams.checkpoint]);
const activeExternalProviderType = activeExternalProvider?.providerType ?? null;
const activeExternalProviderType =
activeExternalProvider?.providerType ?? null;
const activeProviderCapabilities = useMemo(() => {
const selection = parseExternalModelId(inferenceParams.checkpoint);
if (!selection) return null;
@ -797,7 +853,9 @@ export function ChatPage(): ReactElement {
(provider?.providerType === "anthropic" ||
provider?.providerType === "openai");
const storedToolsEnabled = loadOptionalBool(CHAT_TOOLS_ENABLED_KEY);
const storedCodeToolsEnabled = loadOptionalBool(CHAT_CODE_TOOLS_ENABLED_KEY);
const storedCodeToolsEnabled = loadOptionalBool(
CHAT_CODE_TOOLS_ENABLED_KEY,
);
const storedImageToolsEnabled = loadOptionalBool(
CHAT_IMAGE_TOOLS_ENABLED_KEY,
);
@ -867,6 +925,20 @@ export function ChatPage(): ReactElement {
return { mode: "single" };
}, [search.thread, search.compare, search.new, activeThreadId]);
const selectedArtifact = useSelectedChatArtifact();
const artifactSurface = useChatArtifactsStore((state) => state.surface);
const closeArtifactSurface = useChatArtifactsStore(
(state) => state.closeArtifactSurface,
);
const artifactViewKey =
view.mode === "single"
? `single:${view.threadId ?? view.newThreadNonce ?? "new"}`
: `compare:${view.pairId}`;
useEffect(() => {
closeArtifactSurface();
}, [artifactViewKey, closeArtifactSurface]);
const hasActiveModel = Boolean(inferenceParams.checkpoint);
const loadNativeModelIntent = useCallback(
async (intent: NativeIntent, loadingDescription: string) => {
@ -953,8 +1025,7 @@ export function ChatPage(): ReactElement {
selectedProvider?.providerType,
selectedExternal?.modelId,
{
isReasoningProvider:
selectedProvider?.isReasoningModel === true,
isReasoningProvider: selectedProvider?.isReasoningModel === true,
},
);
const preferredEffort = store.reasoningEffort;
@ -997,11 +1068,12 @@ export function ChatPage(): ReactElement {
const supportsBuiltinWebSearch = providerSupportsBuiltinWebSearch(
selectedProvider?.providerType,
);
const supportsBuiltinCodeExecution = providerSupportsBuiltinCodeExecution(
selectedProvider?.providerType,
selectedExternal?.modelId,
selectedProvider?.baseUrl,
);
const supportsBuiltinCodeExecution =
providerSupportsBuiltinCodeExecution(
selectedProvider?.providerType,
selectedExternal?.modelId,
selectedProvider?.baseUrl,
);
const supportsBuiltinImageGeneration =
providerSupportsBuiltinImageGeneration(
selectedProvider?.providerType,
@ -1416,6 +1488,11 @@ export function ChatPage(): ReactElement {
return () => window.clearTimeout(timeoutId);
}, [modelSelectorLocked, tour.open]);
const showArtifactOverlay = Boolean(
selectedArtifact &&
(view.mode === "compare" || artifactSurface === "overlay"),
);
return (
<div className="flex min-h-0 min-w-0 flex-1 basis-0 bg-background overflow-hidden">
<GuidedTour {...tour.tourProps} />
@ -1535,6 +1612,9 @@ export function ChatPage(): ReactElement {
key={view.threadId ?? "single"}
threadId={view.threadId}
newThreadNonce={view.newThreadNonce}
artifact={selectedArtifact}
artifactSurface={artifactSurface}
onCloseArtifact={closeArtifactSurface}
/>
) : (
<CompareContent
@ -1547,6 +1627,14 @@ export function ChatPage(): ReactElement {
deleteDisabled={modelOperationInProgress}
/>
)}
{showArtifactOverlay && selectedArtifact ? (
<ArtifactSurface
artifact={selectedArtifact}
variant="overlay"
onClose={closeArtifactSurface}
/>
) : null}
</div>
<ChatSettingsPanel