feat(web): live step list, option choice card and inline approval card; drop run mode switch

- session page shows the agent.plan TodoList (drawer: one-line progress); options and approval
  cards sit at their step; composer has a "先看方案再执行" toggle instead of the mode switch
- settings expose option timeout, timeout action and approval validity; quick goals follow glossary

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
rootandClaude Opus 5.5 committed 2026-09-28 21:07:01 +08:00
1 parent cf2c4ff0e6
commit 9649854ab3
15 files changed
+731 -205

No files matched your search

@@ -24,6 +24,7 @@ import {
type AgentRunView,
type GoalReport,
type InvocationAudit,
isProposeFirst,
isRunActive,
REPORT_STATE,
RUN_STATUS_LABEL,
@@ -46,12 +47,12 @@ import {
} from "~/components/agentui/message.tsx";
import { MessageBubble, MessageBubbleContent } from "~/components/agentui/message-bubble.tsx";
import { StreamingCursor, StreamingResponse } from "~/components/agentui/streaming-response.tsx";
import { type TodoItem, TodoList } from "~/components/agentui/todo-list.tsx";
import { ToolApproval } from "~/components/agentui/tool-approval.tsx";
import { ToolResult, ToolResultOutput, type ToolResultStatus } from "~/components/agentui/tool-result.tsx";
import { StatusBadge, type StatusTone } from "~/components/console/status-badge.tsx";
import { errorMessage } from "~/lib/api.ts";
import { cn, formatDateTime } from "~/lib/utils.ts";
import { InlineApproval, OptionsCard } from "./agent-interaction.tsx";
import { ToolCallIo } from "./tool-call-card-io.tsx";
import type { AgentStreamState, StreamItem, ToolCallView } from "./use-agent-stream.ts";
@@ -149,18 +150,15 @@ export const isDenied = (call: ToolCallView) => call.status === "denied" || auth
/** 工具调用卡片:脱敏输入/输出折叠、耗时、scope/风险徽标、幂等键(审计);服务端拒绝时换醒目拒绝态 */
export function ToolCallCard({
call,
mode,
audit,
}: {
call: ToolCallView;
mode?: string;
/** 与这次调用对应的审计记录(幂等键等);未取到为 undefined */
audit?: InvocationAudit;
}) {
const meta = capabilityMeta(call.capabilityId);
const authReason = authDenial(call);
if (call.status === "denied" || authReason) {
const writeKind = meta && !["read", "preview", "propose"].includes(meta.kind);
return (
<ToolApproval
status="rejected"
@@ -175,10 +173,8 @@ export function ToolCallCard({
reason={authReason ?? call.reason ?? "未授权"}
explanation={
authReason
? '有副作用的能力需要有效的持续授权或单次审批,由服务端在执行前核对,模型声称"用户已同意"无效。发布收录策略会生成持续授权,也可以为计划申请单次审批。'
: mode === "plan_only" && writeKind
? "本次是「只要方案」,只放行只读、预览、提案类能力。要执行,点「按方案执行」。"
: "这个能力不在本次运行的白名单内,或已超出调用预算,调用没有执行。"
? '有副作用的能力需要有效的持续授权或单次审批,由服务端在执行前核对,模型声称"用户已同意"无效。控制台发起的会话里,需审批的动作会在对应步骤出审批卡;也可以发布收录策略生成持续授权。'
: "这个能力不在本次运行的白名单内,或已超出调用预算,调用没有执行。"
}
parameters={[
{ id: "call", label: "调用 ID", value: call.toolCallId },
@@ -317,13 +313,14 @@ function EndNotice({ item }: { item: Extract<StreamItem, { kind: "end" }> }) {
export function UserMessage({
text,
at,
mode,
tag,
note,
animateIn,
}: {
text: string;
at?: string;
mode?: string;
/** 会话选项标记(如"先看方案再执行") */
tag?: string;
note?: string;
animateIn?: boolean;
}) {
@@ -333,9 +330,9 @@ export function UserMessage({
<User />
</MessageAvatar>
<MessageContent>
{at || mode ? (
{at || tag ? (
<MessageHeader>
{mode ? <span>{mode === "plan_only" ? "只要方案" : "授权内执行"}</span> : null}
{tag ? <span>{tag}</span> : null}
{at ? <time dateTime={at}>{formatDateTime(at)}</time> : null}
</MessageHeader>
) : null}
@@ -402,7 +399,8 @@ export function AgentConversation({
after?: ReactNode;
}) {
const active = isRunActive(run.status) && !live.finished;
const mode = run.mode ?? (run.scope.mode as string | undefined);
const waiting = run.status === "waiting" || run.status === "running" || run.status === "queued";
const stepTitle = (id: string | null) => (id ? (live.plan?.steps.find((s) => s.id === id)?.title ?? null) : null);
// 首次载入(回放)的条目不播入场动画,之后新到的才播
const baseline = useRef<number | null>(null);
if (baseline.current === null && (live.events.length > 0 || !active)) baseline.current = live.items.length;
@@ -444,7 +442,7 @@ export function AgentConversation({
case "tool":
node = (
<AssistantRow animateIn={anim} avatar={false}>
<ToolCallCard call={it.call} mode={mode} audit={auditByCall.get(it.call.toolCallId)} />
<ToolCallCard call={it.call} audit={auditByCall.get(it.call.toolCallId)} />
</AssistantRow>
);
break;
@@ -457,6 +455,20 @@ export function AgentConversation({
</MessageMarker>
);
break;
case "options":
node = (
<AssistantRow animateIn={anim} avatar={false}>
<OptionsCard runId={run.id} view={it.view} waiting={waiting} />
</AssistantRow>
);
break;
case "approval":
node = (
<AssistantRow animateIn={anim} avatar={false}>
<InlineApproval view={it.view} stepTitle={stepTitle(it.view.stepId)} />
</AssistantRow>
);
break;
case "end":
node = it.ok ? null : <EndNotice item={it} />;
break;
@@ -473,13 +485,17 @@ export function AgentConversation({
return (
<div className="space-y-4 px-1 py-2">
<UserMessage text={run.goal} at={run.createdAt} mode={mode} />
<UserMessage text={run.goal} at={run.createdAt} tag={isProposeFirst(run) ? "先看方案再执行" : undefined} />
{rows.map((r) => (r.node ? <div key={r.key}>{r.node}</div> : null))}
{active && live.items[lastIndex]?.kind !== "text" && live.items[lastIndex]?.kind !== "thinking" ? (
<AssistantRow avatar={false}>
<span className="inline-flex items-center gap-2 text-meta text-muted-foreground">
<MessageTyping label={run.status === "waiting" ? "等待中" : "正在处理"} />
{run.status === "waiting" ? "等待中…" : run.status === "queued" ? "排队中…" : "正在处理…"}
{run.status === "waiting"
? `${run.summary === "等待选择方案" || run.summary === "等待审批" ? run.summary : "等待中"}…`
: run.status === "queued"
? "排队中…"
: "正在处理…"}
</span>
</AssistantRow>
) : null}
@@ -488,32 +504,6 @@ export function AgentConversation({
);
}
// ── plan_only:调查步骤清单 ──
export function PlanSteps({ live, active }: { live: AgentStreamState; active: boolean }) {
const items: TodoItem[] = live.items
.filter((i): i is Extract<StreamItem, { kind: "tool" }> => i.kind === "tool")
.map(({ call }) => ({
id: call.toolCallId,
title: capabilityMeta(call.capabilityId)?.title ?? call.capabilityId,
detail: <span className="font-mono">{call.capabilityId}</span>,
status:
call.status === "running"
? "in-progress"
: call.status === "succeeded"
? "completed"
: isDenied(call)
? "cancelled"
: "error",
}));
items.push({
id: "report",
title: "整理方案并汇报",
status: active ? (items.some((i) => i.status === "in-progress") ? "pending" : "in-progress") : "completed",
});
return <TodoList title="调查步骤(只要方案)" items={items} defaultOpen={active} collapseOnComplete />;
}
// ── 预算用量条 ──
function Meter({