diff --git a/studio/frontend/src/components/assistant-ui/thread.tsx b/studio/frontend/src/components/assistant-ui/thread.tsx
index 6a99f30508..da243f37e1 100644
--- a/studio/frontend/src/components/assistant-ui/thread.tsx
+++ b/studio/frontend/src/components/assistant-ui/thread.tsx
@@ -75,7 +75,6 @@ import {
DownloadIcon,
GlobeIcon,
HeadphonesIcon,
- ImageIcon,
LightbulbIcon,
LightbulbOffIcon,
MicIcon,
@@ -89,6 +88,7 @@ import {
Copy01Icon,
Delete02Icon,
Edit03Icon,
+ Image03Icon,
Tick02Icon,
} from "@hugeicons/core-free-icons";
import { HugeiconsIcon } from "@hugeicons/react";
@@ -271,17 +271,17 @@ const GeneratedImageViewportOverlay: FC<{ hideComposer?: boolean }> = ({
className="w-full max-w-[min(100%,46rem)] shrink-0 text-center"
title={overlay.title}
>
-
+
Generated image
{overlay.metadata ? (
-
+
{overlay.metadata}
) : null}
{hideComposer ? null : (
-
- Type edits below, then send.
+
+ Type edits below, then send
)}
@@ -525,13 +525,15 @@ const Composer: FC<{
{
: "Enable image generation"
}
>
-
+
Images
);
diff --git a/studio/frontend/src/components/assistant-ui/tool-ui-image-generation.tsx b/studio/frontend/src/components/assistant-ui/tool-ui-image-generation.tsx
index e32d23acf6..7dfdd903fe 100644
--- a/studio/frontend/src/components/assistant-ui/tool-ui-image-generation.tsx
+++ b/studio/frontend/src/components/assistant-ui/tool-ui-image-generation.tsx
@@ -8,7 +8,7 @@ import { cn } from "@/lib/utils";
import type { ToolCallMessagePartComponent } from "@assistant-ui/react";
import { DownloadIcon, ImageIcon, PencilIcon } from "lucide-react";
import type { CSSProperties, MouseEvent } from "react";
-import { memo, useState } from "react";
+import { memo, useCallback, useEffect, useRef, useState } from "react";
import { useGeneratedImageOverlay } from "./generated-image-overlay-context";
import { Image, downloadImagePart } from "./image";
import {
@@ -63,6 +63,8 @@ type GeneratedImagePart = {
filename?: string;
};
+const CAPTION_COLLAPSED_LINES = 4;
+
const extensionForMime = (mime: string): string => {
switch (mime.toLowerCase()) {
case "image/jpeg":
@@ -119,7 +121,7 @@ function GeneratedImagePlaceholder({ label }: { label: string }) {
return (
220;
const imageMetadata = [imageResult?.size, imageResult?.quality, mime]
.filter(Boolean)
.join(" · ");
@@ -174,8 +178,63 @@ const ImageGenerationToolUIImpl: ToolCallMessagePartComponent = ({
: null;
const [open, setOpen] = useState(true);
+ const [expandedCaptionPrompt, setExpandedCaptionPrompt] = useState<
+ string | null
+ >(null);
+ const [promptOverflow, setPromptOverflow] = useState<{
+ prompt: string;
+ canExpand: boolean;
+ } | null>(null);
+ const captionRef = useRef
(null);
const isPendingImage = !imagePart && status?.type === "running";
+ const promptOverflowMeasured = promptOverflow?.prompt === captionPrompt;
+ const promptCanExpand = promptOverflowMeasured
+ ? promptOverflow.canExpand
+ : false;
+ const promptExpanded = expandedCaptionPrompt === captionPrompt;
+
+ const updatePromptOverflow = useCallback(() => {
+ const captionElement = captionRef.current;
+ if (!captionElement || !captionPrompt) {
+ return;
+ }
+ const computedStyle = window.getComputedStyle(captionElement);
+ const lineHeight = Number.parseFloat(computedStyle.lineHeight);
+ const collapsedHeight =
+ (Number.isFinite(lineHeight) ? lineHeight : 20) *
+ CAPTION_COLLAPSED_LINES;
+ const hasOverflow = captionElement.scrollHeight > collapsedHeight + 1;
+ setPromptOverflow((current) =>
+ current?.prompt === captionPrompt && current.canExpand === hasOverflow
+ ? current
+ : { prompt: captionPrompt, canExpand: hasOverflow },
+ );
+ }, [captionPrompt]);
+
+ useEffect(() => {
+ const captionElement = captionRef.current;
+ if (!captionElement || !captionPrompt) {
+ return;
+ }
+ const frame = window.requestAnimationFrame(updatePromptOverflow);
+ const resizeObserver =
+ typeof ResizeObserver === "undefined"
+ ? null
+ : new ResizeObserver(updatePromptOverflow);
+ resizeObserver?.observe(captionElement);
+ window.addEventListener("resize", updatePromptOverflow);
+ return () => {
+ window.cancelAnimationFrame(frame);
+ resizeObserver?.disconnect();
+ window.removeEventListener("resize", updatePromptOverflow);
+ };
+ }, [captionPrompt, updatePromptOverflow]);
+
+ const shouldClampPrompt =
+ (promptOverflowMeasured ? promptCanExpand : promptLikelyNeedsExpansion) &&
+ !promptExpanded;
+
const runningLabel = "Generating image…";
const completedLabel = formatGeneratedImageLabel(prompt);
@@ -231,21 +290,28 @@ const ImageGenerationToolUIImpl: ToolCallMessagePartComponent = ({
{imagePart ? (
-
+
+

+
-
- {prompt ? (
-
- {prompt}
+ {captionPrompt ? (
+
+
+ {captionPrompt}
+
+ {promptCanExpand ? (
+
+ ) : null}
) : null}
diff --git a/studio/frontend/src/features/chat/api/chat-adapter.ts b/studio/frontend/src/features/chat/api/chat-adapter.ts
index 49a5eebd6b..49622c8090 100644
--- a/studio/frontend/src/features/chat/api/chat-adapter.ts
+++ b/studio/frontend/src/features/chat/api/chat-adapter.ts
@@ -1433,6 +1433,17 @@ export function createOpenAIStreamAdapter(): ChatModelAdapter {
// Tool call content parts — accumulated and yielded cumulatively.
// result is set directly on the tool-call part when tool_end arrives.
const toolCallParts: ToolCallMessagePart[] = [];
+ const orderAssistantContent = (
+ textParts: ReturnType,
+ ) => {
+ const imageToolParts = toolCallParts.filter(
+ (part) => part.toolName === "image_generation",
+ );
+ const otherToolParts = toolCallParts.filter(
+ (part) => part.toolName !== "image_generation",
+ );
+ return [...otherToolParts, ...textParts, ...imageToolParts];
+ };
// Anthropic document_citations tool_event payload, converted to
// Sources-panel source parts at end-of-stream so the inline [N]
// markers have matching entries.
@@ -2036,10 +2047,11 @@ export function createOpenAIStreamAdapter(): ChatModelAdapter {
};
}
}
- // Yield cumulative state so tool UI updates (tools first, text after)
+ // Yield cumulative state so tool UI updates. Search/code tools stay
+ // before the text, while generated images sit after the answer.
const textParts = parseAssistantContent(cumulativeText);
yield {
- content: [...toolCallParts, ...textParts],
+ content: orderAssistantContent(textParts),
metadata: {
timing: buildTiming(
streamStartTime,
@@ -2187,7 +2199,7 @@ export function createOpenAIStreamAdapter(): ChatModelAdapter {
if (parts.length > 0 || toolCallParts.length > 0) {
yield {
- content: [...toolCallParts, ...parts],
+ content: orderAssistantContent(parts),
metadata: {
timing: buildTiming(
streamStartTime,
@@ -2283,8 +2295,7 @@ export function createOpenAIStreamAdapter(): ChatModelAdapter {
yield {
content: [
- ...toolCallParts,
- ...parseAssistantContent(cumulativeText),
+ ...orderAssistantContent(parseAssistantContent(cumulativeText)),
...sourceParts,
...documentCitationParts,
],
diff --git a/studio/frontend/src/features/chat/shared-composer.tsx b/studio/frontend/src/features/chat/shared-composer.tsx
index 76e77d1288..ba4a897f63 100644
--- a/studio/frontend/src/features/chat/shared-composer.tsx
+++ b/studio/frontend/src/features/chat/shared-composer.tsx
@@ -21,7 +21,20 @@ import { isTauri } from "@/lib/api-base";
import { isMultimodalResponse } from "./types/api";
import { getImageInputUnavailableReason } from "./utils/image-input-support";
import { useAui } from "@assistant-ui/react";
-import { ArrowUpIcon, DownloadIcon, GlobeIcon, HeadphonesIcon, ImageIcon, LightbulbIcon, LightbulbOffIcon, MicIcon, PlusIcon, SquareIcon, XIcon } from "lucide-react";
+import {
+ ArrowUpIcon,
+ DownloadIcon,
+ GlobeIcon,
+ HeadphonesIcon,
+ LightbulbIcon,
+ LightbulbOffIcon,
+ MicIcon,
+ PlusIcon,
+ SquareIcon,
+ XIcon,
+} from "lucide-react";
+import { Image03Icon } from "@hugeicons/core-free-icons";
+import { HugeiconsIcon } from "@hugeicons/react";
import { toast } from "@/lib/toast";
import { loadModel, validateModel } from "./api/chat-api";
import { parseExternalModelId, providerTypeSupportsVision } from "./external-providers";
@@ -1115,7 +1128,11 @@ export function SharedComposer({
imageToolsEnabled ? "Disable image generation" : "Enable image generation"
}
>
-
+
Images
)}