feat(web): W4-F rebuild agent conversation and drawer with agentui components

- 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]>
This commit is contained in:
rootandClaude Opus 5.5 committed 2026-09-27 23:08:17 +08:00
1 parent 29cd10b27e
commit 8352e5e881
26 files changed
+3390 -585

No files matched your search

@@ -0,0 +1,32 @@
# agentui 许可证
本目录的组件改造自 [agentui](https://github.com/ashish200729/agentui)(<https://www.agentui.pro/>),
取自提交 `b2b752bcadad032ce555f37dc7c8faabf395cefe`(2026-09-26)的 `components/agents/`。
每个文件头注明了对应的上游文件与改造点:去掉 `motion` / `shiki` / Next.js 依赖,颜色、字号、圆角、
时长、缓动换成本项目的语义 token(见 `docs/design/`),文案改中文,状态机按本项目的事件与对象裁剪。
上游许可证原文如下。
```
MIT License
Copyright (c) 2026 Saurabh Chauhan
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
```
@@ -0,0 +1,139 @@
// Adapted from agentui (https://github.com/ashish200729/agentui), components/agents/approval-card/index.tsx + types.ts
// Copyright (c) 2026 Saurabh Chauhan. MIT License, see ./LICENSE-agentui.md
// 改造:状态集按本项目 Approval 对齐——上游的 submitting 保留,changes-requested/answered 与多步问答模式去掉
// (依赖 motion 的 Checkbox/Radio,且本项目审批只有批准/拒绝);新增 revoked/expired/consumed 终态;
// 状态色换 warning/success/destructive 并配文字;按钮用项目 <Button>(胶囊,主操作 pill-primary);
// 新增 `disabledReason`:按钮不可用时说明原因(如"只有控制台管理员会话能批准")。
import { Check, Clock, LoaderCircle, MessageSquareText, Undo2, X } from "lucide-react";
import type { ReactNode } from "react";
import { Button } from "~/components/ui/button.tsx";
import { cn } from "~/lib/utils.ts";
import { Disclosure } from "./disclosure.tsx";
export type ApprovalCardStatus =
| "pending"
| "submitting"
| "approved"
| "rejected"
| "revoked"
| "expired"
| "consumed";
const LABEL: Record<ApprovalCardStatus, string> = {
pending: "待批准",
submitting: "提交中",
approved: "已批准",
rejected: "已拒绝",
revoked: "已撤销",
expired: "已过期",
consumed: "已执行",
};
const BADGE: Record<ApprovalCardStatus, string> = {
pending: "border-warning/30 bg-warning/10 text-warning",
submitting: "border-border bg-foreground/[0.04] text-muted-foreground",
approved: "border-success/30 bg-success/10 text-success",
consumed: "border-success/30 bg-success/10 text-success",
rejected: "border-destructive/30 bg-destructive/10 text-destructive",
revoked: "border-border bg-foreground/[0.04] text-muted-foreground",
expired: "border-border bg-foreground/[0.04] text-muted-foreground",
};
function StatusIcon({ status }: { status: ApprovalCardStatus }) {
if (status === "submitting") return <LoaderCircle className="size-4 animate-spin motion-reduce:animate-none" />;
if (status === "pending") return <MessageSquareText className="size-4" />;
if (status === "rejected") return <X className="size-4" />;
if (status === "expired") return <Clock className="size-4" />;
if (status === "revoked") return <Undo2 className="size-4" />;
return <Check className="size-4" />;
}
export interface ApprovalCardProps {
title?: ReactNode;
description?: ReactNode;
children?: ReactNode;
status?: ApprovalCardStatus;
badges?: ReactNode;
onApprove?: () => void;
onReject?: () => void;
approveLabel?: ReactNode;
rejectLabel?: ReactNode;
/** 按钮不可用的原因;给了就禁用按钮并显示 */
disabledReason?: string;
/** 终态时的结论文字 */
result?: ReactNode;
className?: string;
}
export function ApprovalCard({
title = "需要批准",
description,
children,
status = "pending",
badges,
onApprove,
onReject,
approveLabel = "批准",
rejectLabel = "拒绝",
disabledReason,
result,
className,
}: ApprovalCardProps) {
const busy = status === "submitting";
const interactive = status === "pending" || busy;
return (
<section
data-state={status}
aria-busy={busy}
aria-label={typeof title === "string" ? title : "审批"}
className={cn(
"w-full min-w-0 overflow-hidden rounded-xl border p-3 text-sm",
status === "pending" ? "border-warning/40 bg-warning/[0.06]" : "border-border/70",
className,
)}
>
<div className="flex items-start gap-3">
<span
aria-hidden
className={cn(
"mt-0.5 grid size-5 shrink-0 place-items-center text-muted-foreground",
status === "pending" && "text-warning",
(status === "approved" || status === "consumed") && "text-success",
status === "rejected" && "text-destructive",
)}
>
<StatusIcon status={status} />
</span>
<div className="min-w-0 flex-1">
<div className="flex min-w-0 flex-wrap items-start gap-x-3 gap-y-1">
<h3 className="min-w-0 flex-1 text-body leading-5 font-medium text-foreground">{title}</h3>
<span className={cn("shrink-0 rounded-full border px-2 py-0.5 text-label font-medium", BADGE[status])}>
{LABEL[status]}
</span>
</div>
{badges ? <div className="mt-1.5 flex flex-wrap items-center gap-1">{badges}</div> : null}
{description ? <p className="mt-1.5 text-caption leading-5 text-muted-foreground">{description}</p> : null}
{children ? <div className="mt-2.5">{children}</div> : null}
<Disclosure open={interactive}>
<div className="mt-3 flex flex-wrap items-center gap-2">
<Button size="sm" disabled={busy || !!disabledReason || !onApprove} onClick={onApprove}>
{approveLabel}
</Button>
<Button
size="sm"
variant="ghost"
disabled={busy || !!disabledReason || !onReject}
onClick={onReject}
className="hover:text-destructive"
>
{rejectLabel}
</Button>
{disabledReason ? <span className="text-meta text-muted-foreground">{disabledReason}</span> : null}
</div>
</Disclosure>
{!interactive ? <p className="mt-1.5 text-caption text-muted-foreground">{result ?? LABEL[status]}</p> : null}
</div>
</div>
</section>
);
}
@@ -0,0 +1,159 @@
// Adapted from agentui (https://github.com/ashish200729/agentui), components/agents/citations.tsx
// Copyright (c) 2026 Saurabh Chauhan. MIT License, see ./LICENSE-agentui.md
// 改造:去掉 favicon 抓取(控制台链接都是站内),改成可选图标槽;站内链接走 react-router 的 Link(`to`),
// 外链仍新窗口打开;新增 `meta`(右侧状态文字);入场/折叠去 motion,用 Disclosure 与 tw-animate;字号/圆角换 token。
import { BookOpenText, ChevronDown, ExternalLink } from "lucide-react";
import { type ReactNode, useId, useState } from "react";
import { Link } from "react-router";
import { cn } from "~/lib/utils.ts";
import { CHEVRON_CLASS, Disclosure } from "./disclosure.tsx";
export interface CitationItem {
id: string;
title: ReactNode;
/** 副标题(如状态、域名) */
domain?: ReactNode;
/** 右侧附加信息(如状态徽章) */
meta?: ReactNode;
icon?: ReactNode;
/** 站内路由 */
to?: string;
/** 外链 */
url?: string;
}
function targetId(prefix: string, id: string) {
return `${prefix}-${id.replace(/[^a-zA-Z0-9_-]/g, "-")}`;
}
/** 行内角标:跳到同 idPrefix 的来源行 */
export function Citation({
citationId,
index,
idPrefix,
className,
}: {
citationId: string;
index: number;
idPrefix: string;
className?: string;
}) {
return (
<a
href={`#${targetId(idPrefix, citationId)}`}
aria-label={`查看来源 ${index}`}
className={cn(
"mx-0.5 inline-flex min-w-4 -translate-y-0.5 items-center justify-center rounded-tight bg-foreground/[0.06] px-1 py-0.5 text-micro leading-none font-semibold text-muted-foreground no-underline outline-none transition-colors duration-micro hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring",
className,
)}
>
{index}
</a>
);
}
function Row({ c, index, prefix }: { c: CitationItem; index: number; prefix: string }) {
const body = (
<>
{c.icon ? (
<span aria-hidden className="grid size-5 shrink-0 place-items-center text-muted-foreground [&_svg]:size-3.5">
{c.icon}
</span>
) : null}
<span className="flex min-w-0 flex-1 flex-wrap items-baseline gap-x-2 gap-y-0.5">
<span className="min-w-0 truncate text-caption font-medium text-foreground/85 transition-colors duration-micro group-hover/citation:text-foreground">
{c.title}
</span>
{c.domain ? <span className="min-w-0 truncate text-meta text-muted-foreground">{c.domain}</span> : null}
</span>
<span className="flex shrink-0 items-center gap-1.5">
{c.meta}
<span className="grid size-5 place-items-center rounded-md bg-foreground/[0.05] text-micro font-semibold text-muted-foreground tabular-nums">
{index}
</span>
{c.url ? <ExternalLink aria-hidden className="size-3.5 text-muted-foreground/60" /> : null}
</span>
</>
);
const cls =
"group/citation flex min-w-0 items-center gap-2 rounded-md px-1.5 py-1 outline-none transition-colors duration-micro hover:bg-foreground/[0.04] focus-visible:ring-2 focus-visible:ring-ring";
const id = targetId(prefix, c.id);
if (c.to)
return (
<Link id={id} to={c.to} className={cls}>
{body}
</Link>
);
if (c.url)
return (
<a id={id} href={c.url} target="_blank" rel="noreferrer noopener" className={cls}>
{body}
</a>
);
return (
<div id={id} className={cls}>
{body}
</div>
);
}
export function CitationList({
citations,
idPrefix,
className,
}: {
citations: CitationItem[];
idPrefix?: string;
className?: string;
}) {
const base = useId();
const prefix = idPrefix ?? `citation-${base.replace(/:/g, "")}`;
return (
<ol className={cn("grid gap-0.5", className)}>
{citations.map((c, i) => (
<li key={c.id} className="min-w-0 animate-in fade-in-0 duration-base motion-reduce:animate-none">
<Row c={c} index={i + 1} prefix={prefix} />
</li>
))}
</ol>
);
}
/** 可折叠的来源列表(上游默认收起;这里给核实结果用,默认展开) */
export function Citations({
citations,
title = "来源",
defaultOpen = false,
idPrefix,
className,
}: {
citations: CitationItem[];
title?: ReactNode;
defaultOpen?: boolean;
idPrefix?: string;
className?: string;
}) {
const id = useId();
const [open, setOpen] = useState(defaultOpen);
return (
<div className={cn("w-full text-sm", className)}>
<button
type="button"
aria-expanded={open}
aria-controls={id}
onClick={() => setOpen(!open)}
className="-ml-1 flex min-h-8 cursor-pointer items-center gap-2 rounded-lg px-1 text-left text-muted-foreground outline-none transition-colors duration-micro hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring"
>
<BookOpenText aria-hidden className="size-4" />
<span className="text-caption font-medium">{title}</span>
<span className="rounded-full bg-foreground/[0.06] px-1.5 py-0.5 text-micro font-semibold tabular-nums">
{citations.length}
</span>
<ChevronDown aria-hidden className={cn("size-3.5", CHEVRON_CLASS, open && "rotate-180")} />
</button>
<Disclosure id={id} open={open}>
<CitationList citations={citations} idPrefix={idPrefix} className="mt-1" />
</Disclosure>
</div>
);
}
@@ -0,0 +1,156 @@
// Adapted from agentui (https://github.com/ashish200729/agentui), components/agents/code-block.tsx + agent-code.tsx
// Copyright (c) 2026 Saurabh Chauhan. MIT License, see ./LICENSE-agentui.md
// 改造:不引 shiki(包体大,且高亮主题带十六进制颜色),纯文本按行渲染;JSON 输入先尝试缩进美化;
// 状态色:streaming 用中性色 + 转圈,complete 用 success;高亮行用 warning 洗层;行号/字号/圆角换 token;
// 去掉 motion 按压;复制失败静默。
import { Check, Copy, FileCode2, LoaderCircle } from "lucide-react";
import { type ReactNode, useEffect, useRef, useState } from "react";
import { cn } from "~/lib/utils.ts";
export type CodeLanguage = "text" | "json" | "bash" | "diff";
/** JSON 文本尝试缩进美化;不是合法 JSON 原样返回(摘要可能被截断) */
export function prettyMaybeJson(s: string): { text: string; language: CodeLanguage } {
const t = s.trim();
if ((t.startsWith("{") && t.endsWith("}")) || (t.startsWith("[") && t.endsWith("]"))) {
try {
return { text: JSON.stringify(JSON.parse(t), null, 2), language: "json" };
} catch {
return { text: s, language: "json" };
}
}
return { text: s, language: "text" };
}
export interface CodeBlockProps {
code: string;
language?: CodeLanguage;
filename?: ReactNode;
status?: "streaming" | "complete";
showLineNumbers?: boolean;
highlightLines?: number[];
/** 最大高度(Tailwind 类,如 max-h-56) */
maxHeightClassName?: string;
wrap?: boolean;
copyable?: boolean;
/** 不显示顶栏(嵌在别的卡片里时) */
bare?: boolean;
tone?: "default" | "destructive";
className?: string;
}
export function CodeBlock({
code,
language = "text",
filename,
status = "complete",
showLineNumbers = false,
highlightLines = [],
maxHeightClassName = "max-h-56",
wrap = true,
copyable = true,
bare = false,
tone = "default",
className,
}: CodeBlockProps) {
const [copied, setCopied] = useState(false);
const t = useRef<number | undefined>(undefined);
useEffect(
() => () => {
if (t.current) window.clearTimeout(t.current);
},
[],
);
const streaming = status === "streaming";
const hl = new Set(highlightLines);
const lines = code.split("\n");
const copy = async () => {
try {
await navigator.clipboard?.writeText(code);
setCopied(true);
if (t.current) window.clearTimeout(t.current);
t.current = window.setTimeout(() => setCopied(false), 1600);
} catch {
// 非安全上下文没有剪贴板
}
};
return (
<div
data-state={status}
aria-busy={streaming}
className={cn(
"w-full min-w-0 overflow-hidden rounded-lg border border-border/60 bg-foreground/[0.03] text-sm",
tone === "destructive" && "border-destructive/30",
className,
)}
>
{bare ? null : (
<div className="flex h-8 items-center gap-2 border-b border-border/50 px-2.5">
<FileCode2 aria-hidden className="size-3.5 shrink-0 text-muted-foreground/70" />
{filename ? <span className="min-w-0 truncate text-meta text-foreground/80">{filename}</span> : null}
<span className="text-micro font-medium tracking-wide text-muted-foreground uppercase">{language}</span>
<span
className={cn(
"ml-auto inline-flex shrink-0 items-center gap-1 text-micro font-medium",
streaming ? "text-muted-foreground" : "text-success",
)}
>
{streaming ? (
<LoaderCircle aria-hidden className="size-3 animate-spin motion-reduce:animate-none" />
) : (
<Check aria-hidden className="size-3" />
)}
{streaming ? "输出中" : "完整"}
</span>
{copyable ? (
<button
type="button"
aria-label={copied ? "已复制" : "复制"}
title={copied ? "已复制" : "复制"}
onClick={copy}
className="grid size-6 shrink-0 cursor-pointer place-items-center rounded-full 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"
>
{copied ? <Check className="size-3.5" /> : <Copy className="size-3.5" />}
</button>
) : null}
</div>
)}
<div className={cn("overflow-auto py-1.5", maxHeightClassName)}>
<pre
className={cn(
"m-0 font-mono text-meta leading-5 text-foreground/85",
tone === "destructive" && "text-destructive",
!wrap && "min-w-max",
)}
>
<code>
{lines.map((line, i) => (
<span
key={i}
className={cn(
"grid min-h-5",
showLineNumbers ? "grid-cols-[2.25rem_minmax(0,1fr)]" : "grid-cols-1",
hl.has(i + 1) && "bg-warning/10",
)}
>
{showLineNumbers ? (
<span className="pr-2 text-right text-muted-foreground/50 tabular-nums select-none">{i + 1}</span>
) : null}
<span
className={cn(
"pr-3",
showLineNumbers ? "pl-1" : "pl-3",
wrap ? "break-all whitespace-pre-wrap" : "whitespace-pre",
)}
>
{line || " "}
</span>
</span>
))}
</code>
</pre>
</div>
</div>
);
}
@@ -0,0 +1,34 @@
// Adapted from agentui (https://github.com/ashish200729/agentui), components/agents/agent-disclosure.tsx
// Copyright (c) 2026 Saurabh Chauhan. MIT License, see ./LICENSE-agentui.md
// 改造:motion 的 clipPath/height 动画换成 CSS grid-rows 0fr→1fr 过渡(duration-layout / ease-standard),
// reduced motion 下不做过渡;收起时 inert + aria-hidden,键盘与读屏都跳过。
import type { ComponentPropsWithRef } from "react";
import { cn } from "~/lib/utils.ts";
export interface DisclosureProps extends ComponentPropsWithRef<"div"> {
open: boolean;
innerClassName?: string;
}
/** 折叠区:展开/收起只动高度与透明度 */
export function Disclosure({ open, className, innerClassName, children, ...props }: DisclosureProps) {
return (
<div
{...props}
data-state={open ? "open" : "closed"}
aria-hidden={!open}
inert={!open}
className={cn(
"grid transition-[grid-template-rows,opacity] duration-layout ease-standard motion-reduce:transition-none",
open ? "grid-rows-[1fr] opacity-100" : "pointer-events-none grid-rows-[0fr] opacity-0",
className,
)}
>
<div className={cn("min-h-0 overflow-hidden", innerClassName)}>{children}</div>
</div>
);
}
/** 折叠箭头:展开时转 180°(旋转过渡 duration-base) */
export const CHEVRON_CLASS =
"shrink-0 text-muted-foreground/60 transition-transform duration-base ease-standard motion-reduce:transition-none";
@@ -0,0 +1,99 @@
// Adapted from agentui (https://github.com/ashish200729/agentui), components/agents/file-diff.tsx
// Copyright (c) 2026 Saurabh Chauhan. MIT License, see ./LICENSE-agentui.md
// 改造:新增/删除行底色 emerald/rose → success/destructive 洗层(并保留 +/− 符号,不只靠颜色);不做语法高亮(去 shiki);
// 折叠用 Disclosure;maxHeight 改 Tailwind 类;用于展示路径类变更(如整理记录的入库目标)。
import { ChevronDown, FileCode2 } from "lucide-react";
import { type ReactNode, useId, useState } from "react";
import { cn } from "~/lib/utils.ts";
import { CHEVRON_CLASS, Disclosure } from "./disclosure.tsx";
export type FileDiffLineType = "added" | "removed" | "context";
export interface FileDiffLine {
id: string;
type?: FileDiffLineType;
content: ReactNode;
/** 行尾附注(如状态) */
note?: ReactNode;
}
function Count({ value, type }: { value: number; type: "added" | "removed" }) {
if (!value) return null;
return (
<span className={cn("font-mono text-meta tabular-nums", type === "added" ? "text-success" : "text-destructive")}>
{type === "added" ? "+" : "−"}
{value}
</span>
);
}
export function FileDiff({
file,
lines,
defaultOpen = true,
maxHeightClassName = "max-h-56",
className,
}: {
file: ReactNode;
lines: FileDiffLine[];
defaultOpen?: boolean;
maxHeightClassName?: string;
className?: string;
}) {
const id = useId();
const [open, setOpen] = useState(defaultOpen);
const added = lines.filter((l) => l.type === "added").length;
const removed = lines.filter((l) => l.type === "removed").length;
return (
<div className={cn("w-full min-w-0 overflow-hidden rounded-lg border border-border/60 text-sm", className)}>
<button
type="button"
aria-expanded={open}
aria-controls={id}
onClick={() => setOpen(!open)}
className="flex h-8 w-full cursor-pointer items-center gap-2 px-2.5 text-left outline-none transition-colors duration-micro hover:bg-foreground/[0.03] focus-visible:ring-2 focus-visible:ring-ring"
>
<FileCode2 aria-hidden className="size-3.5 shrink-0 text-muted-foreground/70" />
<span className="min-w-0 flex-1 truncate text-meta text-foreground/85">{file}</span>
<Count value={added} type="added" />
<Count value={removed} type="removed" />
<ChevronDown aria-hidden className={cn("size-3.5", CHEVRON_CLASS, open && "rotate-180")} />
</button>
<Disclosure id={id} open={open}>
<div className={cn("overflow-auto border-t border-border/50 py-1", maxHeightClassName)}>
<ul className="font-mono text-meta leading-5">
{lines.map((l) => (
<li
key={l.id}
className={cn(
"grid grid-cols-[1.25rem_minmax(0,1fr)_auto] gap-1 pr-2.5",
l.type === "added" && "bg-success/[0.08]",
l.type === "removed" && "bg-destructive/[0.08]",
)}
>
<span
aria-hidden
className={cn(
"text-center select-none",
l.type === "added"
? "text-success"
: l.type === "removed"
? "text-destructive"
: "text-muted-foreground/50",
)}
>
{l.type === "added" ? "+" : l.type === "removed" ? "−" : " "}
</span>
<span className="min-w-0 break-all text-foreground/85">
<span className="sr-only">{l.type === "added" ? "新增:" : l.type === "removed" ? "移除:" : ""}</span>
{l.content}
</span>
{l.note ? <span className="shrink-0 font-sans text-muted-foreground">{l.note}</span> : null}
</li>
))}
</ul>
</div>
</Disclosure>
</div>
);
}
@@ -0,0 +1,104 @@
// 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>
);
}
@@ -0,0 +1,179 @@
// Adapted from agentui (https://github.com/ashish200729/agentui), components/agents/message-scroller.tsx
// Copyright (c) 2026 Saurabh Chauhan. MIT License, see ./LICENSE-agentui.md
// 改造:保留"离底部 followThreshold 以内才跟随、滚轮/触摸/上翻键主动离开、ResizeObserver 跟随内容增长"的状态机;
// 去掉 PreviewRail 导航;新增"回到底部"按钮(离开跟随时出现,点了恢复跟随);reduced motion 下不用平滑滚动;
// 程序滚动的静默期取 --duration-layout 的值,不写字面量时长;log 只播报新增行(additions),流式正文不逐字播报。
import { ArrowDown } from "lucide-react";
import {
type ComponentPropsWithRef,
type ReactNode,
useCallback,
useEffect,
useLayoutEffect,
useRef,
useState,
} from "react";
import { cn } from "~/lib/utils.ts";
import { useReducedMotion } from "./use-reduced-motion.ts";
export interface MessageScrollerProps extends Omit<ComponentPropsWithRef<"div">, "children"> {
children: ReactNode;
/** 用户停在底部附近时,新内容自动滚到底 */
followOutput?: boolean;
/** 离底部多少像素内仍算"跟随中" */
followThreshold?: number;
onFollowChange?: (following: boolean) => void;
/** 滚动区的无障碍名称 */
label?: string;
/** 还在等待流式内容 */
busy?: boolean;
viewportClassName?: string;
contentClassName?: string;
/** "回到底部"按钮文案 */
jumpLabel?: string;
}
function layoutMs(el: Element | null): number {
if (!el) return 0;
const v = getComputedStyle(el).getPropertyValue("--duration-layout").trim();
const n = Number.parseFloat(v);
return Number.isFinite(n) ? (v.endsWith("ms") ? n : n * 1000) : 0;
}
export function MessageScroller({
followOutput = true,
followThreshold = 56,
onFollowChange,
label = "对话记录",
busy,
viewportClassName,
contentClassName,
jumpLabel = "回到底部",
className,
children,
...props
}: MessageScrollerProps) {
const reduce = useReducedMotion();
const viewportRef = useRef<HTMLElement>(null);
const contentRef = useRef<HTMLDivElement>(null);
const followingRef = useRef(followOutput);
const programmatic = useRef(false);
const timer = useRef<number | undefined>(undefined);
const [following, setFollowingState] = useState(true);
const setFollowing = useCallback(
(next: boolean) => {
if (followingRef.current === next) return;
followingRef.current = next;
setFollowingState(next);
onFollowChange?.(next);
},
[onFollowChange],
);
const scrollToEnd = useCallback((behavior: ScrollBehavior) => {
const vp = viewportRef.current;
if (!vp) return;
programmatic.current = true;
if (typeof vp.scrollTo === "function") vp.scrollTo({ top: vp.scrollHeight, behavior });
else vp.scrollTop = vp.scrollHeight;
if (timer.current) window.clearTimeout(timer.current);
// 平滑滚动的静默期:等 scrollend,兜底取 3 × --duration-layout
const done = () => {
programmatic.current = false;
vp.removeEventListener("scrollend", done);
};
vp.addEventListener("scrollend", done, { once: true });
timer.current = window.setTimeout(done, behavior === "smooth" ? layoutMs(vp) * 3 : 0);
}, []);
const lastTop = useRef(0);
const onScroll = useCallback(() => {
const vp = viewportRef.current;
if (!vp) return;
const up = vp.scrollTop < lastTop.current - 1;
lastTop.current = vp.scrollTop;
const nearEnd = vp.scrollHeight - vp.scrollTop - vp.clientHeight <= followThreshold;
// 回到底部附近:恢复跟随;离开底部:只有"往上翻"才算用户离开(程序平滑滚动与内容增长只会往下)
if (nearEnd) setFollowing(true);
else if (up && !programmatic.current) setFollowing(false);
}, [followThreshold, setFollowing]);
const leaveLiveEdge = useCallback(() => {
programmatic.current = false;
}, []);
useLayoutEffect(() => {
followingRef.current = followOutput;
if (!followOutput) return;
const f = requestAnimationFrame(() => scrollToEnd("auto"));
return () => cancelAnimationFrame(f);
}, [followOutput, scrollToEnd]);
useEffect(() => {
const content = contentRef.current;
if (!content || typeof ResizeObserver === "undefined") return;
// 首次铺满内容(回放历史)直接跳到底,之后的增长才平滑跟随
let first = true;
const ro = new ResizeObserver(() => {
if (!followOutput || !followingRef.current) return;
scrollToEnd(reduce || first ? "auto" : "smooth");
first = false;
});
ro.observe(content);
return () => ro.disconnect();
}, [followOutput, reduce, scrollToEnd]);
useEffect(
() => () => {
if (timer.current) window.clearTimeout(timer.current);
},
[],
);
return (
<div data-slot="message-scroller" className={cn("relative min-h-0", className)} {...props}>
<section
ref={viewportRef}
aria-label={label}
// biome-ignore lint/a11y/noNoninteractiveTabindex: 滚动区要能用键盘聚焦后方向键滚动
tabIndex={0}
onScroll={onScroll}
onWheel={leaveLiveEdge}
onTouchStart={leaveLiveEdge}
onKeyDown={(e) => {
if (["ArrowUp", "PageUp", "Home"].includes(e.key)) leaveLiveEdge();
}}
className={cn(
"h-full overflow-y-auto overscroll-contain outline-none [overflow-anchor:none] [scrollbar-gutter:stable] focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-inset",
viewportClassName,
)}
>
<div
ref={contentRef}
role="log"
aria-live="polite"
aria-relevant="additions"
aria-busy={busy}
className={contentClassName}
>
{children}
</div>
</section>
{!following ? (
<button
type="button"
onClick={() => {
setFollowing(true);
scrollToEnd(reduce ? "auto" : "smooth");
}}
className="pg-float absolute bottom-3 left-1/2 z-10 inline-flex h-8 -translate-x-1/2 cursor-pointer items-center gap-1.5 rounded-full px-3 text-meta font-medium text-foreground outline-none animate-in fade-in-0 slide-in-from-bottom-1 duration-base motion-reduce:animate-none focus-visible:ring-2 focus-visible:ring-ring"
>
<ArrowDown aria-hidden className="size-3.5" />
{jumpLabel}
{busy ? <span className="sr-only">(仍在输出)</span> : null}
</button>
) : null}
</div>
);
}
+155
View File
@@ -0,0 +1,155 @@
// Adapted from agentui (https://github.com/ashish200729/agentui), components/agents/message.tsx + message-context.tsx
// Copyright (c) 2026 Saurabh Chauhan. MIT License, see ./LICENSE-agentui.md
// 改造:motion.article 的弹簧入场换成 tw-animate 的 animate-in(duration-base / ease-pop,motion-reduce 关闭);
// 头像底色、字号(text-label / text-meta)、圆角换语义 token;新增 from="system" 行(居中提示,用于压缩/中止等系统消息);
// MessageTyping 三点动画用 animate-pulse + 具名时长的 animation-delay。
import { type ComponentPropsWithRef, createContext, useContext } from "react";
import { cn } from "~/lib/utils.ts";
export type MessageFrom = "user" | "assistant" | "system";
export type MessageSide = "start" | "end";
export const MessageSideContext = createContext<MessageSide | undefined>(undefined);
const MessageContext = createContext<{ from: MessageFrom }>({ from: "assistant" });
export interface MessageProps extends ComponentPropsWithRef<"article"> {
from: MessageFrom;
/** 挂载时播一次入场(新到达的消息用;回放历史时不播) */
animateIn?: boolean;
}
const FROM_LABEL: Record<MessageFrom, string> = { user: "用户消息", assistant: "Agent 消息", system: "系统消息" };
export function Message({ from, animateIn = false, className, children, ...props }: MessageProps) {
return (
<MessageSideContext.Provider value={from === "user" ? "end" : "start"}>
<MessageContext.Provider value={{ from }}>
<article
data-slot="message"
data-from={from}
aria-label={props["aria-label"] ?? FROM_LABEL[from]}
{...props}
className={cn(
"group/message flex w-full min-w-0 items-start gap-2.5",
from === "user" ? "flex-row-reverse" : "flex-row",
animateIn &&
"animate-in fade-in-0 slide-in-from-bottom-1 duration-base ease-pop motion-reduce:animate-none",
className,
)}
>
{children}
</article>
</MessageContext.Provider>
</MessageSideContext.Provider>
);
}
export function MessageGroup({
spacing = "default",
className,
...props
}: ComponentPropsWithRef<"div"> & { spacing?: "compact" | "default" }) {
return (
<div
data-slot="message-group"
className={cn("flex w-full flex-col", spacing === "compact" ? "gap-1.5" : "gap-4", className)}
{...props}
/>
);
}
export function MessageAvatar({
placeholder = false,
className,
...props
}: ComponentPropsWithRef<"div"> & { placeholder?: boolean }) {
return (
<div
data-slot="message-avatar"
aria-hidden
className={cn(
"mt-0.5 grid size-7 shrink-0 place-items-center overflow-hidden rounded-full bg-foreground/[0.06] text-meta font-medium text-muted-foreground [&_svg]:size-3.5",
placeholder && "invisible",
className,
)}
{...props}
/>
);
}
export function MessageContent({ className, ...props }: ComponentPropsWithRef<"div">) {
const { from } = useContext(MessageContext);
return (
<div
data-slot="message-content"
className={cn("flex min-w-0 flex-1 flex-col gap-1.5", from === "user" ? "items-end" : "items-start", className)}
{...props}
/>
);
}
export function MessageHeader({ className, ...props }: ComponentPropsWithRef<"div">) {
const { from } = useContext(MessageContext);
return (
<div
data-slot="message-header"
className={cn(
"flex items-center gap-1.5 px-1 text-label leading-none text-muted-foreground",
from === "user" ? "justify-end" : "justify-start",
className,
)}
{...props}
/>
);
}
export function MessageFooter({ className, ...props }: ComponentPropsWithRef<"div">) {
const { from } = useContext(MessageContext);
return (
<div
data-slot="message-footer"
className={cn(
"flex min-h-5 items-center gap-1 px-1 text-label text-muted-foreground",
from === "user" ? "justify-end" : "justify-start",
className,
)}
{...props}
/>
);
}
/** 居中的系统提示条(上下文压缩、续跑等) */
export function MessageMarker({ className, ...props }: ComponentPropsWithRef<"div">) {
return (
<div
data-slot="message-marker"
role="note"
className={cn(
"mx-auto flex w-fit max-w-[88%] items-center gap-1.5 rounded-full border border-border/60 bg-foreground/[0.04] px-2.5 py-1 text-center text-meta text-muted-foreground",
className,
)}
{...props}
/>
);
}
const DOT_DELAY = ["", "[animation-delay:var(--duration-micro)]", "[animation-delay:var(--duration-layout)]"];
export function MessageTyping({
label = "正在回复",
className,
...props
}: ComponentPropsWithRef<"span"> & { label?: string }) {
return (
<span data-slot="message-typing" className={cn("inline-flex h-5 items-center gap-1", className)} {...props}>
<span className="sr-only">{label}</span>
{DOT_DELAY.map((d, i) => (
<span
key={i}
aria-hidden
className={cn("size-1 rounded-full bg-current opacity-60 animate-pulse motion-reduce:animate-none", d)}
/>
))}
</span>
);
}
@@ -0,0 +1,137 @@
// Adapted from agentui (https://github.com/ashish200729/agentui), components/agents/prompt-input.tsx
// Copyright (c) 2026 Saurabh Chauhan. MIT License, see ./LICENSE-agentui.md
// 改造:保留自增高(隐藏测量层 + minRows/maxRows)、Enter 发送 / Shift+Enter 换行 / 输入法组字中不发送、
// loading 时发送键换成"停止";去掉模型选择与 MorphPopover 动作菜单,换成 `toolbar` 槽(放模式切换等);
// 外框用 border + focus-within 提亮(不做玻璃叠玻璃),按钮用项目 <Button>。
import { ArrowUp, Square } from "lucide-react";
import {
type FormEvent,
type KeyboardEvent,
type ReactNode,
type TextareaHTMLAttributes,
useCallback,
useLayoutEffect,
useRef,
} from "react";
import { Button } from "~/components/ui/button.tsx";
import { cn } from "~/lib/utils.ts";
export interface PromptInputProps
extends Omit<TextareaHTMLAttributes<HTMLTextAreaElement>, "value" | "onChange" | "onSubmit" | "children"> {
value: string;
onValueChange: (v: string) => void;
onSubmit: (v: string) => void;
/** 运行中:发送键变"停止" */
loading?: boolean;
onStop?: () => void;
/** 提交中(禁用发送) */
pending?: boolean;
minRows?: number;
maxRows?: number;
/** 输入框下方左侧工具条 */
toolbar?: ReactNode;
submitLabel?: string;
stopLabel?: string;
/** 顶部附加内容(如上下文前缀) */
header?: ReactNode;
className?: string;
}
/** 行高 24px(text-sm leading-6),与测量层一致 */
const LINE = 24;
export function PromptInput({
value,
onValueChange,
onSubmit,
loading = false,
onStop,
pending = false,
minRows = 2,
maxRows = 8,
toolbar,
submitLabel = "发送",
stopLabel = "停止",
header,
className,
disabled,
placeholder,
"aria-label": ariaLabel = "输入",
...rest
}: PromptInputProps) {
const ta = useRef<HTMLTextAreaElement>(null);
const measure = useRef<HTMLDivElement>(null);
const resize = useCallback(() => {
const t = ta.current;
const m = measure.current;
if (!t || !m) return;
const h = Math.min(Math.max(m.scrollHeight, minRows * LINE), maxRows * LINE);
t.style.height = `${h}px`;
}, [minRows, maxRows]);
// biome-ignore lint/correctness/useExhaustiveDependencies: 内容变化时重新测量
useLayoutEffect(resize, [value, resize]);
const canSubmit = !!value.trim() && !disabled && !pending;
const submit = (e?: FormEvent) => {
e?.preventDefault();
if (!canSubmit) return;
onSubmit(value.trim());
};
const onKeyDown = (e: KeyboardEvent<HTMLTextAreaElement>) => {
if (e.key !== "Enter" || e.shiftKey || e.nativeEvent.isComposing) return;
e.preventDefault();
submit();
};
return (
<form
onSubmit={submit}
className={cn(
"relative w-full min-w-0 rounded-2xl border border-border/80 p-2 transition-colors duration-base focus-within:border-foreground/30",
disabled && "opacity-60",
className,
)}
>
{header}
<div className="relative">
<div
ref={measure}
aria-hidden
className="pointer-events-none invisible absolute inset-x-0 top-0 px-2 pt-1.5 text-sm leading-6 break-words whitespace-pre-wrap"
>
{`${value}​`}
</div>
<textarea
ref={ta}
value={value}
disabled={disabled}
placeholder={placeholder}
aria-label={ariaLabel}
rows={minRows}
{...rest}
onChange={(e) => onValueChange(e.target.value)}
onKeyDown={onKeyDown}
className="block w-full resize-none overflow-y-auto bg-transparent px-2 pt-1.5 text-sm leading-6 text-foreground outline-none placeholder:text-muted-foreground/70"
/>
</div>
<div className="mt-1 flex min-h-8 flex-wrap items-center gap-1.5">
<div className="flex min-w-0 flex-1 flex-wrap items-center gap-1.5">{toolbar}</div>
{loading && onStop ? (
<Button type="button" size="sm" variant="destructive" onClick={onStop} aria-label={stopLabel}>
<Square className="size-3.5" />
{stopLabel}
</Button>
) : null}
<Button
type="submit"
size="icon-sm"
disabled={!canSubmit}
aria-label={submitLabel}
title={`${submitLabel}(Enter)`}
>
<ArrowUp />
</Button>
</div>
</form>
);
}
@@ -0,0 +1,124 @@
// Adapted from agentui (https://github.com/ashish200729/agentui), components/agents/streaming-response.tsx
// Copyright (c) 2026 Saurabh Chauhan. MIT License, see ./LICENSE-agentui.md
// 改造:上游是"整段替换"模型,这里由调用方把 text_delta 累积成全文再传入;新增流式光标(animate-pulse,
// reduced motion 下静止);去掉点赞/点踩(本项目不收集反馈)与 motion 按压动画;来源列表接 Citations;
// error 态用 destructive 语义色。
import { Check, Copy, RotateCcw } from "lucide-react";
import { type ReactNode, useCallback, useEffect, useRef, useState } from "react";
import { cn } from "~/lib/utils.ts";
import { type CitationItem, CitationList } from "./citations.tsx";
export type StreamingResponseStatus = "streaming" | "complete" | "error";
export interface StreamingResponseProps {
children: ReactNode;
status?: StreamingResponseStatus;
/** 复制按钮复制的纯文本 */
copyText?: string;
onRetry?: () => void;
sources?: CitationItem[];
/** 外层已有 aria-live(MessageScroller 的 log)时设 false,避免重复播报 */
announce?: boolean;
showActions?: boolean;
className?: string;
contentClassName?: string;
}
/** 复制成功提示的停留时长(交互停留,非动效) */
const COPIED_HOLD_MS = 1600;
function ActionButton({ label, onClick, children }: { label: string; onClick: () => void; children: ReactNode }) {
return (
<button
type="button"
aria-label={label}
title={label}
onClick={onClick}
className="grid size-7 cursor-pointer place-items-center rounded-md text-muted-foreground outline-none transition-[background-color,color,transform] duration-micro hover:bg-foreground/[0.06] hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring active:scale-[0.95]"
>
{children}
</button>
);
}
/** 流式光标:只在 streaming 时出现 */
export function StreamingCursor({ className }: { className?: string }) {
return (
<span
aria-hidden
data-slot="streaming-cursor"
className={cn(
"ml-0.5 inline-block h-4 w-0.5 translate-y-0.5 rounded-full bg-foreground/70 animate-pulse motion-reduce:animate-none",
className,
)}
/>
);
}
export function StreamingResponse({
children,
status = "streaming",
copyText,
onRetry,
sources = [],
announce = true,
showActions = true,
className,
contentClassName,
}: StreamingResponseProps) {
const [copied, setCopied] = useState(false);
const t = useRef<number | undefined>(undefined);
const streaming = status === "streaming";
useEffect(
() => () => {
if (t.current) window.clearTimeout(t.current);
},
[],
);
const copy = useCallback(async () => {
if (!copyText) return;
try {
await navigator.clipboard?.writeText(copyText);
setCopied(true);
if (t.current) window.clearTimeout(t.current);
t.current = window.setTimeout(() => setCopied(false), COPIED_HOLD_MS);
} catch {
// 剪贴板不可用(非安全上下文)时静默
}
}, [copyText]);
const actions = showActions && !streaming && (copyText || onRetry);
return (
<div data-state={status} aria-busy={streaming} className={cn("w-full min-w-0", className)}>
<div
aria-live={announce ? "polite" : "off"}
className={cn(
"text-sm leading-6 break-words whitespace-pre-wrap text-foreground/90",
status === "error" && "text-destructive",
contentClassName,
)}
>
{children}
{streaming ? <StreamingCursor /> : null}
</div>
{actions ? (
<div className="mt-1 flex items-center gap-0.5 animate-in fade-in-0 duration-base motion-reduce:animate-none">
{copyText ? (
<ActionButton label={copied ? "已复制" : "复制这段回复"} onClick={copy}>
{copied ? <Check className="size-3.5" /> : <Copy className="size-3.5" />}
</ActionButton>
) : null}
{onRetry ? (
<ActionButton label="重试" onClick={onRetry}>
<RotateCcw className="size-3.5" />
</ActionButton>
) : null}
<span className="sr-only" aria-live="polite">
{copied ? "已复制" : ""}
</span>
</div>
) : null}
{sources.length && !streaming ? <CitationList citations={sources} className="mt-2" /> : null}
</div>
);
}
@@ -0,0 +1,184 @@
// Adapted from agentui (https://github.com/ashish200729/agentui), components/agents/todo-list.tsx
// Copyright (c) 2026 Saurabh Chauhan. MIT License, see ./LICENSE-agentui.md
// 改造:SVG 状态图标保留几何(虚线圈=待办、弧线=进行中、勾=完成、叉=取消),pathLength 弹簧动画换成
// CSS 过渡/animate-spin(motion-reduce 关闭);完成色 emerald → success,取消 rose → destructive,且都配 sr-only 文字;
// 表头完成数去掉滚字;折叠用 Disclosure;maxHeight 改成 Tailwind 类;新增 `error` 状态(工具失败)与 `detail` 槽。
import { ChevronDown, ListTodo } from "lucide-react";
import { type ReactNode, useEffect, useId, useRef, useState } from "react";
import { cn } from "~/lib/utils.ts";
import { CHEVRON_CLASS, Disclosure } from "./disclosure.tsx";
export type TodoItemStatus = "pending" | "in-progress" | "completed" | "cancelled" | "error";
export interface TodoItem {
id: string;
title: ReactNode;
status?: TodoItemStatus;
detail?: ReactNode;
}
const LABEL: Record<TodoItemStatus, string> = {
pending: "待办",
"in-progress": "进行中",
completed: "已完成",
cancelled: "被拒绝",
error: "失败",
};
function StatusIcon({ status }: { status: TodoItemStatus }) {
return (
<svg
aria-hidden
viewBox="0 0 24 24"
className={cn(
"mx-0.5 size-5 shrink-0 overflow-visible text-muted-foreground",
status === "in-progress" && "text-foreground",
status === "completed" && "text-success",
(status === "cancelled" || status === "error") && "text-destructive",
)}
>
<circle
cx="12"
cy="12"
r="9"
fill="currentColor"
fillOpacity={status === "completed" ? 0.1 : 0}
stroke="currentColor"
strokeWidth="1.5"
strokeDasharray={status === "pending" ? "2 3" : undefined}
className={cn(status === "in-progress" && "opacity-20")}
/>
{status === "in-progress" ? (
<circle
cx="12"
cy="12"
r="9"
pathLength={1}
strokeDasharray="0.3 1"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
className="origin-center animate-spin motion-reduce:animate-none"
/>
) : null}
{status === "completed" ? (
<path
d="M7.5 12.25 10.5 15.25 16.75 8.75"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
/>
) : null}
{status === "cancelled" || status === "error" ? (
<path
d="M8.5 8.5 15.5 15.5M15.5 8.5 8.5 15.5"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
/>
) : null}
</svg>
);
}
export interface TodoListProps {
items: TodoItem[];
title?: ReactNode;
defaultOpen?: boolean;
collapseOnComplete?: boolean;
/** 列表最大高度(Tailwind 类) */
maxHeightClassName?: string;
emptyText?: ReactNode;
className?: string;
}
export function TodoList({
items,
title = "步骤",
defaultOpen = true,
collapseOnComplete = false,
maxHeightClassName = "max-h-64",
emptyText = "还没有步骤",
className,
}: TodoListProps) {
const id = useId();
const [open, setOpen] = useState(defaultOpen);
const done = items.filter((i) => i.status === "completed").length;
const settled =
items.length > 0 && items.every((i) => i.status && i.status !== "pending" && i.status !== "in-progress");
const prevSettled = useRef(settled);
useEffect(() => {
if (!prevSettled.current && settled && collapseOnComplete) setOpen(false);
prevSettled.current = settled;
}, [settled, collapseOnComplete]);
return (
<section
aria-label="步骤清单"
className={cn("w-full min-w-0 overflow-hidden rounded-xl border border-border/70", className)}
>
<button
id={`${id}-t`}
type="button"
aria-expanded={open}
aria-controls={`${id}-c`}
onClick={() => setOpen(!open)}
className="flex h-10 w-full cursor-pointer items-center gap-2.5 rounded-xl px-3 text-left outline-none transition-colors duration-micro hover:bg-foreground/[0.03] focus-visible:ring-2 focus-visible:ring-ring"
>
<ListTodo aria-hidden className={cn("size-4 text-muted-foreground", settled && "text-success")} />
<h3 className="min-w-0 flex-1 truncate text-caption font-medium text-foreground/90">{title}</h3>
<span
className={cn("shrink-0 text-meta font-medium text-muted-foreground tabular-nums", settled && "text-success")}
>
<span className="sr-only">
已完成 {done} / 共 {items.length}
</span>
<span aria-hidden>
{done}/{items.length}
</span>
</span>
<ChevronDown aria-hidden className={cn("size-3.5", CHEVRON_CLASS, open && "rotate-180")} />
</button>
<Disclosure id={`${id}-c`} role="region" aria-labelledby={`${id}-t`} open={open}>
<div className={cn("overflow-y-auto px-2 pb-2", maxHeightClassName)}>
{items.length ? (
<ol className="space-y-0">
{items.map((item) => {
const s = item.status ?? "pending";
return (
<li
key={item.id}
className="flex min-h-9 min-w-0 items-center gap-2.5 rounded-lg px-1.5 py-1 animate-in fade-in-0 slide-in-from-bottom-1 duration-base motion-reduce:animate-none"
>
<StatusIcon status={s} />
<span className="sr-only">{LABEL[s]}:</span>
<span
className={cn(
"min-w-0 flex-1 truncate text-caption leading-5",
s === "pending" && "text-muted-foreground",
s === "in-progress" && "text-foreground",
s === "completed" && "text-muted-foreground line-through decoration-foreground/30",
(s === "cancelled" || s === "error") && "text-destructive",
)}
>
{item.title}
</span>
{item.detail ? (
<span className="shrink-0 text-meta text-muted-foreground">{item.detail}</span>
) : null}
</li>
);
})}
</ol>
) : (
<p className="px-1.5 py-2 text-caption text-muted-foreground">{emptyText}</p>
)}
</div>
</Disclosure>
</section>
);
}
@@ -0,0 +1,184 @@
// Adapted from agentui (https://github.com/ashish200729/agentui), components/agents/tool-approval.tsx
// Copyright (c) 2026 Saurabh Chauhan. MIT License, see ./LICENSE-agentui.md
// 改造:状态徽章 amber/blue/emerald/rose → warning/中性/success/destructive;去掉 "Always allow"(本项目的持续授权
// 只在设置页按策略版本发放,不在对话里一键放行);新增 `rejected` 醒目态:服务端拒绝了一次工具调用时,
// 整卡换 destructive 材质、role=alert、给出拒绝原因与授权说明;新增 `badges` 槽(scope/风险级别);参数区用 Disclosure。
import { Check, ChevronDown, CircleAlert, LoaderCircle, ShieldAlert, ShieldCheck, X } from "lucide-react";
import { type ReactNode, useId, useState } from "react";
import { Button } from "~/components/ui/button.tsx";
import { cn } from "~/lib/utils.ts";
import { CHEVRON_CLASS, Disclosure } from "./disclosure.tsx";
export type ToolApprovalStatus =
| "pending"
| "approving"
| "approved"
| "denied"
| "rejected"
| "running"
| "complete"
| "error";
export interface ToolApprovalParameter {
id: string;
label: ReactNode;
value: ReactNode;
}
const COPY: Record<ToolApprovalStatus, string> = {
pending: "待授权",
approving: "提交中",
approved: "已批准",
denied: "已拒绝",
rejected: "服务端拒绝",
running: "执行中",
complete: "已完成",
error: "失败",
};
const BADGE: Record<ToolApprovalStatus, string> = {
pending: "border-warning/30 bg-warning/10 text-warning",
approving: "border-border bg-foreground/[0.04] text-muted-foreground",
running: "border-border bg-foreground/[0.04] text-muted-foreground",
approved: "border-success/30 bg-success/10 text-success",
complete: "border-success/30 bg-success/10 text-success",
denied: "border-destructive/30 bg-destructive/10 text-destructive",
rejected: "border-destructive/40 bg-destructive/15 text-destructive",
error: "border-destructive/30 bg-destructive/10 text-destructive",
};
export interface ToolApprovalProps {
tool: ReactNode;
title?: ReactNode;
description?: ReactNode;
parameters?: ToolApprovalParameter[];
status?: ToolApprovalStatus;
badges?: ReactNode;
/** rejected 态:拒绝原因(服务端给出) */
reason?: ReactNode;
/** rejected 态:为什么会被拒绝、怎样才能放行 */
explanation?: ReactNode;
defaultOpen?: boolean;
onApprove?: () => void;
onDeny?: () => void;
/** 为什么按钮不可用(如非管理员会话) */
actionsDisabledReason?: string;
className?: string;
}
export function ToolApproval({
tool,
title = "允许执行这个工具?",
description,
parameters = [],
status = "pending",
badges,
reason,
explanation,
defaultOpen = false,
onApprove,
onDeny,
actionsDisabledReason,
className,
}: ToolApprovalProps) {
const id = useId();
const [open, setOpen] = useState(defaultOpen);
const busy = status === "approving" || status === "running";
const rejected = status === "rejected";
return (
<div
data-state={status}
aria-busy={busy}
role={rejected ? "alert" : undefined}
className={cn(
"w-full min-w-0 overflow-hidden rounded-xl border text-sm",
rejected ? "border-destructive/50 bg-destructive/10" : "border-border/70 bg-foreground/[0.02]",
className,
)}
>
<div className="flex items-start gap-3 p-3">
<span
aria-hidden
className={cn(
"mt-0.5 grid size-8 shrink-0 place-items-center rounded-lg border",
rejected
? "border-destructive/40 bg-destructive/15 text-destructive"
: "border-border/60 text-muted-foreground",
status === "error" && "text-destructive",
)}
>
{busy ? (
<LoaderCircle className="size-4 animate-spin motion-reduce:animate-none" />
) : rejected ? (
<ShieldAlert className="size-4" />
) : status === "error" ? (
<CircleAlert className="size-4" />
) : status === "denied" ? (
<X className="size-4" />
) : status === "approved" || status === "complete" ? (
<Check className="size-4" />
) : (
<ShieldCheck className="size-4" />
)}
</span>
<div className="min-w-0 flex-1">
<div className="flex min-w-0 flex-wrap items-start justify-between gap-x-3 gap-y-1">
<div className="min-w-0">
<div className={cn("font-medium", rejected ? "text-destructive" : "text-foreground")}>{title}</div>
<div className="mt-0.5 font-mono text-meta break-all text-muted-foreground">{tool}</div>
</div>
<span className={cn("shrink-0 rounded-full border px-2 py-0.5 text-label font-medium", BADGE[status])}>
{COPY[status]}
</span>
</div>
{badges ? <div className="mt-1.5 flex flex-wrap items-center gap-1">{badges}</div> : null}
{rejected && reason ? (
<p className="mt-2 text-caption font-medium break-words text-destructive">原因:{reason}</p>
) : null}
{description ? <p className="mt-1.5 text-caption leading-5 text-muted-foreground">{description}</p> : null}
{rejected && explanation ? (
<p className="mt-1.5 text-meta leading-5 text-muted-foreground">{explanation}</p>
) : null}
{parameters.length ? (
<button
type="button"
aria-expanded={open}
aria-controls={id}
onClick={() => setOpen(!open)}
className="mt-2 inline-flex cursor-pointer items-center gap-1 rounded-md text-meta font-medium text-muted-foreground outline-none transition-colors duration-micro hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring"
>
{open ? "收起详情" : "查看详情"}
<ChevronDown aria-hidden className={cn("size-3.5", CHEVRON_CLASS, open && "rotate-180")} />
</button>
) : null}
</div>
</div>
{parameters.length ? (
<Disclosure id={id} open={open}>
<dl className="mx-3 mb-3 grid gap-1.5 rounded-lg border border-border/50 p-2.5">
{parameters.map((p) => (
<div key={p.id} className="grid grid-cols-[minmax(0,7rem)_minmax(0,1fr)] items-start gap-3 text-meta">
<dt className="text-muted-foreground">{p.label}</dt>
<dd className="min-w-0 font-mono break-all text-foreground/85">{p.value}</dd>
</div>
))}
</dl>
</Disclosure>
) : null}
{status === "pending" && (onApprove || onDeny) ? (
<div className="flex flex-wrap items-center gap-2 border-t border-border/60 px-3 py-2.5">
<Button size="sm" onClick={onApprove} disabled={!!actionsDisabledReason || !onApprove}>
批准
</Button>
<Button size="sm" variant="ghost" onClick={onDeny} disabled={!!actionsDisabledReason || !onDeny}>
拒绝
</Button>
{actionsDisabledReason ? (
<span className="text-meta text-muted-foreground">{actionsDisabledReason}</span>
) : null}
</div>
) : null}
</div>
);
}
@@ -0,0 +1,135 @@
// Adapted from agentui (https://github.com/ashish200729/agentui), components/agents/tool-result.tsx
// Copyright (c) 2026 Saurabh Chauhan. MIT License, see ./LICENSE-agentui.md
// 改造:状态色 blue/emerald/rose → 中性/success/destructive(并配文字);ActionSwapRollText 滚字去掉;
// 折叠用 Disclosure;新增 `badges` 槽(scope/风险/幂等等审计徽标)与 `summary`(收起时的一行结果);
// 默认不在完成后自动展开历史卡片(回放时 defaultOpen=false),运行中的卡片展开、完成后按 collapseOnComplete 收起。
import { Ban, Braces, ChevronDown, CircleCheck, CircleX, LoaderCircle, SquareTerminal, Wrench } from "lucide-react";
import { type ReactNode, useCallback, useEffect, useId, useRef, useState } from "react";
import { cn } from "~/lib/utils.ts";
import { CodeBlock, prettyMaybeJson } from "./code-block.tsx";
import { CHEVRON_CLASS, Disclosure } from "./disclosure.tsx";
export type ToolResultStatus = "running" | "success" | "error" | "cancelled";
export type ToolResultKind = "terminal" | "request" | "custom";
const STATUS: Record<ToolResultStatus, { label: string; cls: string }> = {
running: { label: "执行中", cls: "text-muted-foreground" },
success: { label: "成功", cls: "text-success" },
error: { label: "失败", cls: "text-destructive" },
cancelled: { label: "已取消", cls: "text-muted-foreground" },
};
function StatusIcon({ status }: { status: ToolResultStatus }) {
if (status === "running") return <LoaderCircle className="size-3 animate-spin motion-reduce:animate-none" />;
if (status === "success") return <CircleCheck className="size-3" />;
if (status === "error") return <CircleX className="size-3" />;
return <Ban className="size-3" />;
}
function KindIcon({ kind }: { kind: ToolResultKind }) {
if (kind === "terminal") return <SquareTerminal className="size-4" />;
if (kind === "request") return <Braces className="size-4" />;
return <Wrench className="size-4" />;
}
export interface ToolResultProps {
/** 能力/工具 ID(等宽显示) */
tool: ReactNode;
title: ReactNode;
children?: ReactNode;
status?: ToolResultStatus;
kind?: ToolResultKind;
/** 耗时等 */
meta?: ReactNode;
/** 标题行下方的徽标 */
badges?: ReactNode;
/** 收起时显示的一行摘要 */
summary?: ReactNode;
icon?: ReactNode;
defaultOpen?: boolean;
collapseOnComplete?: boolean;
className?: string;
}
export function ToolResult({
tool,
title,
children,
status = "running",
kind = "custom",
meta,
badges,
summary,
icon,
defaultOpen = status === "running",
collapseOnComplete = true,
className,
}: ToolResultProps) {
const id = useId();
const [open, setOpenState] = useState(defaultOpen);
const prev = useRef(status);
const setOpen = useCallback((v: boolean) => setOpenState(v), []);
useEffect(() => {
if (prev.current !== "running" && status === "running") setOpen(true);
if (prev.current === "running" && status !== "running" && collapseOnComplete) setOpen(false);
prev.current = status;
}, [collapseOnComplete, setOpen, status]);
const st = STATUS[status];
return (
<div
data-state={status}
aria-busy={status === "running"}
className={cn("w-full min-w-0 rounded-xl border border-border/70 text-sm", className)}
>
<button
id={`${id}-t`}
type="button"
aria-expanded={open}
aria-controls={`${id}-c`}
onClick={() => setOpen(!open)}
className="group flex min-h-10 w-full min-w-0 cursor-pointer items-center gap-2 rounded-xl px-3 py-1.5 text-left outline-none transition-colors duration-micro hover:bg-foreground/[0.03] focus-visible:ring-2 focus-visible:ring-ring"
>
<span aria-hidden className="grid size-4 shrink-0 place-items-center text-muted-foreground">
{icon ?? <KindIcon kind={kind} />}
</span>
<span className="flex min-w-0 flex-1 flex-wrap items-baseline gap-x-2 gap-y-0.5">
<span className="min-w-0 truncate text-caption font-medium text-foreground/90">{title}</span>
<span className="min-w-0 truncate font-mono text-label text-muted-foreground">{tool}</span>
{meta ? <span className="shrink-0 text-label text-muted-foreground tabular-nums">{meta}</span> : null}
</span>
<span className={cn("inline-flex shrink-0 items-center gap-1 text-label font-medium", st.cls)}>
<StatusIcon status={status} />
{st.label}
</span>
<ChevronDown aria-hidden className={cn("size-3.5", CHEVRON_CLASS, open && "rotate-180")} />
</button>
{badges ? <div className="flex flex-wrap items-center gap-1 px-3 pb-2 pl-9">{badges}</div> : null}
{!open && summary ? (
<p className="-mt-1 truncate px-3 pb-2 pl-9 text-meta text-muted-foreground">{summary}</p>
) : null}
<Disclosure id={`${id}-c`} role="region" aria-labelledby={`${id}-t`} open={open}>
<div className="space-y-2 border-t border-border/60 px-3 py-2.5">{children}</div>
</Disclosure>
</div>
);
}
/** 工具输入/输出块:JSON 自动缩进,长内容块内滚动 */
export function ToolResultOutput({
label,
children,
tone,
}: {
label: ReactNode;
children: string;
tone?: "destructive";
}) {
const { text, language } = prettyMaybeJson(children);
return (
<div className="min-w-0">
<p className="mb-1 text-meta text-muted-foreground">{label}</p>
<CodeBlock code={text} language={language} bare tone={tone === "destructive" ? "destructive" : "default"} />
</div>
);
}
@@ -0,0 +1,21 @@
// Adapted from agentui (https://github.com/ashish200729/agentui) — replaces `useReducedMotion` from motion/react.
// Copyright (c) 2026 Saurabh Chauhan. MIT License, see ./LICENSE-agentui.md
// 改造:不引 motion 库,直接读 prefers-reduced-motion;样式层的减弱动效由 motion-reduce: 变体负责,这里只给 JS 滚动用。
import { useSyncExternalStore } from "react";
const QUERY = "(prefers-reduced-motion: reduce)";
function subscribe(cb: () => void) {
if (typeof window === "undefined" || !window.matchMedia) return () => {};
const mq = window.matchMedia(QUERY);
mq.addEventListener("change", cb);
return () => mq.removeEventListener("change", cb);
}
export function useReducedMotion(): boolean {
return useSyncExternalStore(
subscribe,
() => (typeof window !== "undefined" && window.matchMedia ? window.matchMedia(QUERY).matches : false),
() => false,
);
}