feat(webui): polish agent output and app discovery
This commit is contained in:
@@ -12,15 +12,15 @@ import {
|
||||
Clock3,
|
||||
Copy,
|
||||
ImageIcon,
|
||||
Sparkles,
|
||||
Wrench,
|
||||
} from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import { AttachmentTile } from "@/components/AttachmentTile";
|
||||
import { ImageLightbox } from "@/components/ImageLightbox";
|
||||
import { MarkdownText, preloadMarkdownText } from "@/components/MarkdownText";
|
||||
import { MarkdownText } from "@/components/MarkdownText";
|
||||
import { SlashCommandText } from "@/components/SlashCommandText";
|
||||
import { ReasoningRow } from "@/components/thread/activity/ReasoningRow";
|
||||
import { UserMessageText } from "@/components/UserMessageText";
|
||||
import {
|
||||
Tooltip,
|
||||
@@ -111,7 +111,7 @@ function MessageCopyButton({ content }: { content: string }) {
|
||||
onClick={onCopy}
|
||||
aria-label={label}
|
||||
className={cn(
|
||||
"inline-flex h-8 w-8 shrink-0 items-center justify-center rounded-full",
|
||||
"touch-target inline-flex h-8 w-8 shrink-0 items-center justify-center rounded-full",
|
||||
"transition-colors hover:bg-muted/55 hover:text-foreground",
|
||||
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring",
|
||||
)}
|
||||
@@ -128,15 +128,7 @@ function MessageCopyButton({ content }: { content: string }) {
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Render a single message. Following agent-chat-ui: user turns are a rounded
|
||||
* "pill" right-aligned with a muted fill; assistant turns render as bare
|
||||
* markdown so prose/code read like a document rather than a chat bubble.
|
||||
* Each turn fades+slides in for a touch of motion polish.
|
||||
*
|
||||
* Trace rows (tool-call hints, progress breadcrumbs) render as a subdued
|
||||
* collapsible group so intermediate steps never masquerade as replies.
|
||||
*/
|
||||
/** Render user turns as compact bubbles and assistant turns as document-like prose. */
|
||||
export function MessageBubble({
|
||||
message,
|
||||
showCopyAction = true,
|
||||
@@ -250,11 +242,10 @@ export function MessageBubble({
|
||||
text={reasoning}
|
||||
streaming={reasoningStreaming}
|
||||
hasBodyBelow={!empty}
|
||||
onOpenFilePreview={onOpenFilePreview}
|
||||
/>
|
||||
) : null}
|
||||
{empty && message.isStreaming && !hasReasoning ? (
|
||||
<TypingDots />
|
||||
<ThinkingState />
|
||||
) : empty && message.isStreaming ? null : (
|
||||
<>
|
||||
{automationSourceLabel ? (
|
||||
@@ -263,12 +254,14 @@ export function MessageBubble({
|
||||
triggerLabel={automationTriggeredLabel}
|
||||
/>
|
||||
) : null}
|
||||
<MarkdownText
|
||||
streaming={!!message.isStreaming}
|
||||
onOpenFilePreview={onOpenFilePreview}
|
||||
>
|
||||
{message.content}
|
||||
</MarkdownText>
|
||||
<div data-assistant-selectable={message.isStreaming ? undefined : "true"}>
|
||||
<MarkdownText
|
||||
streaming={!!message.isStreaming}
|
||||
onOpenFilePreview={onOpenFilePreview}
|
||||
>
|
||||
{message.content}
|
||||
</MarkdownText>
|
||||
</div>
|
||||
{media.length > 0 ? <MessageMedia media={media} align="left" /> : null}
|
||||
{showAssistantFooterRow ? (
|
||||
<TooltipProvider delayDuration={220} skipDelayDuration={80}>
|
||||
@@ -284,7 +277,7 @@ export function MessageBubble({
|
||||
onClick={onForkFromHere}
|
||||
aria-label={forkLabel}
|
||||
className={cn(
|
||||
"inline-flex h-8 w-8 shrink-0 items-center justify-center rounded-full",
|
||||
"touch-target inline-flex h-8 w-8 shrink-0 items-center justify-center rounded-full",
|
||||
"transition-colors hover:bg-muted/55 hover:text-foreground",
|
||||
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring",
|
||||
)}
|
||||
@@ -433,10 +426,6 @@ function MessageMedia({
|
||||
/**
|
||||
* Right-aligned preview row for images attached to a user turn.
|
||||
*
|
||||
* Visual follows agent-chat-ui: a single wrapping row of fixed-size square
|
||||
* thumbnails that stay modest next to the text pill regardless of how many
|
||||
* images are attached.
|
||||
*
|
||||
* The URL is expected to be a self-contained ``data:`` URL (the Composer
|
||||
* hands the normalized base64 payload to the optimistic bubble so that the
|
||||
* preview survives React StrictMode double-mount — blob URLs would be
|
||||
@@ -570,33 +559,21 @@ function UserImageCell({
|
||||
);
|
||||
}
|
||||
|
||||
/** Pre-token-arrival placeholder: three bouncing dots. */
|
||||
function TypingDots() {
|
||||
/** Quiet pre-token state that occupies a stable line in the answer column. */
|
||||
function ThinkingState() {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<span
|
||||
aria-label={t("message.assistantTyping")}
|
||||
className="inline-flex items-center gap-1 py-1"
|
||||
className="inline-flex min-h-7 items-center py-1 text-[13px]"
|
||||
>
|
||||
<Dot delay="0ms" />
|
||||
<Dot delay="150ms" />
|
||||
<Dot delay="300ms" />
|
||||
<StreamingLabelSheen active>
|
||||
{t("message.reasoningStreaming", { defaultValue: "Thinking…" })}
|
||||
</StreamingLabelSheen>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function Dot({ delay }: { delay: string }) {
|
||||
return (
|
||||
<span
|
||||
style={{ animationDelay: delay }}
|
||||
className={cn(
|
||||
"inline-block h-1.5 w-1.5 rounded-full bg-muted-foreground/60",
|
||||
"animate-bounce",
|
||||
)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
/** L→R sheen on the glyphs themselves; inactive labels stay solid muted text. */
|
||||
export function StreamingLabelSheen({
|
||||
children,
|
||||
@@ -630,105 +607,22 @@ interface ReasoningBubbleProps {
|
||||
text: string;
|
||||
streaming: boolean;
|
||||
hasBodyBelow: boolean;
|
||||
/** When true, skip the slide-in wrapper (used inside ``AgentActivityCluster``). */
|
||||
embeddedInCluster?: boolean;
|
||||
onOpenFilePreview?: (path: string) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Subordinate "thinking" trace shown above an assistant turn.
|
||||
*
|
||||
* Lifecycle:
|
||||
* - While ``streaming`` is true (``reasoning_delta`` frames still arriving),
|
||||
* the bubble defaults to open and the header shows a sheen + pulse so
|
||||
* the user sees the model "thinking out loud" in real time.
|
||||
* - Expanded reasoning uses the same Markdown pipeline as assistant replies
|
||||
* (deferred while streaming to reduce parser thrash), so headings and
|
||||
* emphasis render instead of leaking raw ``###`` / ``**``.
|
||||
* - On ``reasoning_end`` the bubble auto-collapses for prose density —
|
||||
* the user can re-expand to inspect the chain of thought. The local
|
||||
* toggle persists once the user interacts.
|
||||
*/
|
||||
export function ReasoningBubble({
|
||||
text,
|
||||
streaming,
|
||||
hasBodyBelow,
|
||||
embeddedInCluster = false,
|
||||
onOpenFilePreview,
|
||||
}: ReasoningBubbleProps) {
|
||||
const { t } = useTranslation();
|
||||
const [userToggled, setUserToggled] = useState(false);
|
||||
const [openLocal, setOpenLocal] = useState(true);
|
||||
const open = userToggled ? openLocal : streaming;
|
||||
const onToggle = () => {
|
||||
setUserToggled(true);
|
||||
setOpenLocal((v) => (userToggled ? !v : !open));
|
||||
};
|
||||
useEffect(() => {
|
||||
if (open && text.length > 0) {
|
||||
preloadMarkdownText();
|
||||
}
|
||||
}, [open, text.length]);
|
||||
return (
|
||||
<div
|
||||
<ReasoningRow
|
||||
text={text}
|
||||
streaming={streaming}
|
||||
className={cn(
|
||||
"w-full",
|
||||
!embeddedInCluster && "animate-in fade-in-0 slide-in-from-top-1 duration-200",
|
||||
"animate-in fade-in-0 slide-in-from-top-1 duration-200",
|
||||
hasBodyBelow && "mb-2",
|
||||
)}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onToggle}
|
||||
className={cn(
|
||||
"group flex w-full items-center gap-2 rounded-md px-2 py-1.5",
|
||||
"text-xs text-muted-foreground transition-colors hover:bg-muted/45",
|
||||
)}
|
||||
aria-expanded={open}
|
||||
aria-live={streaming ? "polite" : undefined}
|
||||
>
|
||||
<Sparkles
|
||||
className={cn("h-3.5 w-3.5", streaming && "animate-pulse")}
|
||||
aria-hidden
|
||||
/>
|
||||
<StreamingLabelSheen active={streaming} className="min-w-0 flex-1 text-left">
|
||||
{streaming
|
||||
? t("message.reasoningStreaming", { defaultValue: "Thinking…" })
|
||||
: t("message.reasoning", { defaultValue: "Thinking" })}
|
||||
</StreamingLabelSheen>
|
||||
<ChevronRight
|
||||
aria-hidden
|
||||
className={cn(
|
||||
"ml-auto h-3.5 w-3.5 transition-transform duration-200",
|
||||
open && "rotate-90",
|
||||
)}
|
||||
/>
|
||||
</button>
|
||||
{open && text.length > 0 && (
|
||||
<div
|
||||
className={cn(
|
||||
"mt-1 min-w-0 border-l border-muted-foreground/20 pl-3",
|
||||
!embeddedInCluster && "animate-in fade-in-0 slide-in-from-top-1 duration-200",
|
||||
)}
|
||||
>
|
||||
<MarkdownText
|
||||
streaming={streaming}
|
||||
onOpenFilePreview={onOpenFilePreview}
|
||||
className={cn(
|
||||
"text-[12.5px] italic text-muted-foreground/88",
|
||||
"prose-p:my-1.5 prose-li:my-0.5",
|
||||
"prose-headings:mt-2 prose-headings:mb-1 prose-headings:font-medium",
|
||||
"prose-headings:text-muted-foreground/92 prose-strong:text-muted-foreground",
|
||||
"prose-h1:text-[15px] prose-h2:text-[13.5px] prose-h3:text-[12.5px] prose-h4:text-[12px]",
|
||||
"prose-a:text-blue-500 prose-a:underline hover:prose-a:text-blue-600 dark:prose-a:text-blue-300 dark:hover:prose-a:text-blue-200",
|
||||
"prose-code:text-[0.92em]",
|
||||
)}
|
||||
>
|
||||
{text}
|
||||
</MarkdownText>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user