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

@@ -1,5 +1,5 @@
import { type AgentRunEvent, type AgentUsageTotals, EventType } from "@umt/contracts";
import { useEffect, useMemo, useRef, useState } from "react";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { agentMockEnabled, fetchRunEvents } from "~/api/agent.ts";
import { sse } from "~/lib/sse.ts";
@@ -40,6 +40,8 @@ export type AgentStreamState = {
finished: boolean;
error: string | null;
connected: "sse" | "poll" | "mock" | "idle";
/** 读取失败后手动重试(从最后收到的 seq 之后补读) */
retry: () => void;
};
const SSE_TYPES: string[] = [EventType.agentTextDelta, EventType.agentToolCall, EventType.agentRunStatus];
@@ -177,9 +179,8 @@ export function useAgentStream(runId: string | undefined, active: boolean): Agen
const [events, setEvents] = useState<AgentRunEvent[]>([]);
const [error, setError] = useState<string | null>(null);
const [connected, setConnected] = useState<AgentStreamState["connected"]>("idle");
const [nonce, setNonce] = useState(0);
const lastSeq = useRef(-1);
const inflight = useRef(false);
const pending = useRef(false);
// biome-ignore lint/correctness/useExhaustiveDependencies: 换 run 时重置
useEffect(() => {
@@ -188,10 +189,14 @@ export function useAgentStream(runId: string | undefined, active: boolean): Agen
lastSeq.current = -1;
}, [runId]);
// biome-ignore lint/correctness/useExhaustiveDependencies: nonce 变化 = 手动重试,重新建立补读
useEffect(() => {
if (!runId) return;
let stopped = false;
const ctrl = new AbortController();
// 在途/待补标记按本次 effect 独立:active 变化重建 effect 时,被中止的旧请求不会把新一轮的补读挡在门外
const inflight = { current: false };
const pending = { current: false };
const pull = async () => {
if (inflight.current) {
@@ -243,12 +248,13 @@ export function useAgentStream(runId: string | undefined, active: boolean): Agen
unsubscribe?.();
setConnected("idle");
};
}, [runId, active]);
}, [runId, active, nonce]);
const retry = useCallback(() => setNonce((n) => n + 1), []);
return useMemo(() => {
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 };
}, [events, error, connected]);
return { events, items, usage, finished: !!end, error, connected, retry };
}, [events, error, connected, retry]);
}