- Port agentui (MIT, ashish200729/agentui@b2b752b) Message/MessageBubble/MessageScroller, StreamingResponse, ToolResult/ToolApproval, TodoList, ApprovalCard, CodeBlock/FileDiff, Citations, PromptInput into components/agentui; drop motion/shiki/Next, switch colors, type scale, radii, durations and easing to semantic tokens, honour reduced motion - Conversation: streaming cursor, collapsible thinking, tool cards with kind/scope/risk/ idempotency badges and audit idempotency key, prominent server-rejection state for both whitelist rejections and unauthorized errors, compaction markers, budget meters with over-limit state, approval cards (admin session only), two-column final report (model said vs system verified with task/record links), follow-scroll with jump-to-bottom - Composer: Enter send / Shift+Enter newline, stop = abort (confirmed), append, execute/plan_only switch, "execute this plan" from finished plan_only runs - Session list sidebar with status filter and usage; reconnect/error banners with retry - Drawer: route-derived context (entity/site/task), minimize to a live mini dock - Fix stream refetch race when the run switches from loading to finished Co-Authored-By: Claude Opus 5.5 <[email protected]>
105 lines
3.7 KiB
TypeScript
105 lines
3.7 KiB
TypeScript
// Adapted from agentui (https://github.com/ashish200729/agentui), components/agents/message-bubble.tsx
|
|
// Copyright (c) 2026 Saurabh Chauhan. MIT License, see ./LICENSE-agentui.md
|
|
// 改造:去掉 motion 的 layout 动画与 render 克隆;变体底色换语义 token(soft = foreground 洗层,danger = destructive,
|
|
// warning 为新增);折叠遮罩用 mask-image 渐变到 transparent(不写十六进制),箭头旋转用 duration-base。
|
|
import { ChevronDown } from "lucide-react";
|
|
import { type ComponentPropsWithRef, createContext, useContext, useId, useState } from "react";
|
|
import { cn } from "~/lib/utils.ts";
|
|
import { CHEVRON_CLASS } from "./disclosure.tsx";
|
|
import { MessageSideContext } from "./message.tsx";
|
|
|
|
export type MessageBubbleVariant = "solid" | "soft" | "outline" | "ghost" | "danger" | "warning";
|
|
|
|
const BubbleContext = createContext<{ variant: MessageBubbleVariant; align: "start" | "end" }>({
|
|
variant: "soft",
|
|
align: "start",
|
|
});
|
|
|
|
export function MessageBubble({
|
|
variant = "soft",
|
|
align,
|
|
className,
|
|
...props
|
|
}: ComponentPropsWithRef<"div"> & { variant?: MessageBubbleVariant; align?: "start" | "end" }) {
|
|
const side = useContext(MessageSideContext);
|
|
const resolved = align ?? side ?? "start";
|
|
return (
|
|
<BubbleContext.Provider value={{ variant, align: resolved }}>
|
|
<div
|
|
data-slot="message-bubble"
|
|
data-variant={variant}
|
|
className={cn("flex w-full min-w-0 flex-col", resolved === "end" ? "items-end" : "items-start", className)}
|
|
{...props}
|
|
/>
|
|
</BubbleContext.Provider>
|
|
);
|
|
}
|
|
|
|
const VARIANT: Record<MessageBubbleVariant, string> = {
|
|
solid: "bg-foreground text-background",
|
|
soft: "border border-border/60 bg-foreground/[0.05]",
|
|
outline: "border border-border/70",
|
|
ghost: "w-full max-w-none rounded-none border-0 px-0 py-0",
|
|
danger: "border border-destructive/40 bg-destructive/10 text-destructive",
|
|
warning: "border border-warning/40 bg-warning/10 text-warning",
|
|
};
|
|
|
|
export function MessageBubbleContent({ className, ...props }: ComponentPropsWithRef<"div">) {
|
|
const { variant } = useContext(BubbleContext);
|
|
return (
|
|
<div
|
|
data-slot="message-bubble-content"
|
|
className={cn(
|
|
"relative min-w-9 max-w-[85%] rounded-2xl px-3.5 py-2.5 text-sm leading-6 break-words text-foreground",
|
|
VARIANT[variant],
|
|
className,
|
|
)}
|
|
{...props}
|
|
/>
|
|
);
|
|
}
|
|
|
|
const CLAMP = {
|
|
2: "line-clamp-2",
|
|
3: "line-clamp-3",
|
|
4: "line-clamp-4",
|
|
5: "line-clamp-5",
|
|
6: "line-clamp-6",
|
|
} as const;
|
|
|
|
/** 长消息折叠:收起时按行截断并渐隐 */
|
|
export function MessageBubbleCollapsible({
|
|
collapsedLines = 4,
|
|
moreLabel = "展开",
|
|
lessLabel = "收起",
|
|
className,
|
|
children,
|
|
...props
|
|
}: ComponentPropsWithRef<"div"> & { collapsedLines?: 2 | 3 | 4 | 5 | 6; moreLabel?: string; lessLabel?: string }) {
|
|
const id = useId();
|
|
const [open, setOpen] = useState(false);
|
|
return (
|
|
<div data-state={open ? "open" : "closed"} className={cn("w-full", className)} {...props}>
|
|
<div
|
|
id={id}
|
|
className={cn(
|
|
!open && CLAMP[collapsedLines],
|
|
!open && "[mask-image:linear-gradient(to_bottom,black_68%,transparent)]",
|
|
)}
|
|
>
|
|
{children}
|
|
</div>
|
|
<button
|
|
type="button"
|
|
aria-expanded={open}
|
|
aria-controls={id}
|
|
onClick={() => setOpen(!open)}
|
|
className="mt-1.5 inline-flex h-7 cursor-pointer items-center gap-1 rounded-full px-2 text-meta font-medium text-muted-foreground outline-none transition-colors duration-micro hover:bg-foreground/[0.06] hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring"
|
|
>
|
|
{open ? lessLabel : moreLabel}
|
|
<ChevronDown aria-hidden className={cn("size-3.5", CHEVRON_CLASS, open && "rotate-180")} />
|
|
</button>
|
|
</div>
|
|
);
|
|
}
|