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

@@ -1,4 +1,10 @@
import { type AgentRunEvent, type AgentUsageTotals, EventType } from "@umt/contracts";
import {
type AgentOptionItem,
type AgentPlanStep,
type AgentRunEvent,
type AgentUsageTotals,
EventType,
} from "@umt/contracts";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { agentMockEnabled, fetchRunEvents } from "~/api/agent.ts";
import { sse } from "~/lib/sse.ts";
@@ -23,6 +29,8 @@ export type StreamItem =
| { kind: "thinking"; key: string; text: string; at: string }
| { kind: "tool"; key: string; call: ToolCallView }
| { kind: "compaction"; key: string; at: string; reason: string; status: string | null; done: boolean }
| { kind: "options"; key: string; at: string; view: OptionsView }
| { kind: "approval"; key: string; at: string; view: ApprovalView }
| {
kind: "end";
key: string;
@@ -33,9 +41,43 @@ export type StreamItem =
budget: string | null;
};
/** agent.propose_options 的一次提问(options_proposed + options_resolved) */
export type OptionsView = {
decisionId: string;
question: string;
options: AgentOptionItem[];
recommendedId: string | null;
stepId: string | null;
expiresAt: string;
timeoutAction: "recommended" | "stop";
resolved: {
outcome: "chosen" | "rejected" | "stop";
choice: string | null;
reason: string | null;
by: string;
} | null;
};
/** 步骤处的审批卡(approval_requested + approval_resolved;挂起期间以审批对象的实时状态为准) */
export type ApprovalView = {
approvalId: string;
capabilityId: string;
planType: string;
planId: string;
payloadHash: string;
expectedRevision: number;
riskLevel: "low" | "medium" | "high";
summary: string;
stepId: string | null;
expiresAt: string;
resolved: { status: string; reason: string | null; decidedBy: string | null } | null;
};
export type AgentStreamState = {
events: AgentRunEvent[];
items: StreamItem[];
/** 最新的步骤清单(agent.plan);没有调用过为 null */
plan: { steps: AgentPlanStep[]; revision: number } | null;
usage: AgentUsageTotals | null;
finished: boolean;
error: string | null;
@@ -50,6 +92,8 @@ const SSE_TYPES: string[] = [EventType.agentTextDelta, EventType.agentToolCall,
export function foldEvents(events: AgentRunEvent[]): StreamItem[] {
const items: StreamItem[] = [];
const tools = new Map<string, ToolCallView>();
const asks = new Map<string, OptionsView>();
const approvals = new Map<string, ApprovalView>();
for (const e of events) {
const last = items[items.length - 1];
switch (e.type) {
@@ -146,6 +190,49 @@ export function foldEvents(events: AgentRunEvent[]): StreamItem[] {
});
}
break;
case "options_proposed": {
const view: OptionsView = {
decisionId: e.decisionId,
question: e.question,
options: e.options,
recommendedId: e.recommendedId,
stepId: e.stepId,
expiresAt: e.expiresAt,
timeoutAction: e.timeoutAction,
resolved: null,
};
asks.set(e.decisionId, view);
items.push({ kind: "options", key: `o${e.decisionId}`, at: e.at, view });
break;
}
case "options_resolved": {
const v = asks.get(e.decisionId);
if (v) v.resolved = { outcome: e.outcome, choice: e.choice, reason: e.reason, by: e.by };
break;
}
case "approval_requested": {
const view: ApprovalView = {
approvalId: e.approvalId,
capabilityId: e.capabilityId,
planType: e.planType,
planId: e.planId,
payloadHash: e.payloadHash,
expectedRevision: e.expectedRevision,
riskLevel: e.riskLevel,
summary: e.summary,
stepId: e.stepId,
expiresAt: e.expiresAt,
resolved: null,
};
approvals.set(e.approvalId, view);
items.push({ kind: "approval", key: `a${e.approvalId}`, at: e.at, view });
break;
}
case "approval_resolved": {
const v = approvals.get(e.approvalId);
if (v) v.resolved = { status: e.status, reason: e.reason, decidedBy: e.decidedBy };
break;
}
case "run_completed":
items.push({
kind: "end",
@@ -255,6 +342,15 @@ export function useAgentStream(runId: string | undefined, active: boolean): Agen
const items = foldEvents(events);
const end = [...events].reverse().find((e) => e.type === "run_completed" || e.type === "run_failed");
const usage = end && (end.type === "run_completed" || end.type === "run_failed") ? end.usage : null;
return { events, items, usage, finished: !!end, error, connected, retry };
return { events, items, plan: latestPlan(events), usage, finished: !!end, error, connected, retry };
}, [events, error, connected, retry]);
}
/** 最新的步骤清单(最后一条 plan_updated) */
export function latestPlan(events: AgentRunEvent[]): AgentStreamState["plan"] {
for (let i = events.length - 1; i >= 0; i--) {
const e = events[i]!;
if (e.type === "plan_updated") return { steps: e.steps, revision: e.revision };
}
return null;
}