feat(webui): polish agent output and app discovery

This commit is contained in:
Xubin Ren
2026-07-22 22:42:31 +08:00
parent b189a37648
commit aa8387fb4d
87 changed files with 6225 additions and 2379 deletions
+25 -131
View File
@@ -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>
/>
);
}