From 20b63db25474038efc1cbecf5bf5a284916f2771 Mon Sep 17 00:00:00 2001 From: root Date: Tue, 29 Sep 2026 15:17:37 +0800 Subject: [PATCH] feat(agent): in-drawer history tab and denser session layout (AGUI-2) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Drawer tabs 当前/历史: searchable run list (status/source/time filters, cursor auto-load), opens runs inside the drawer, keeps scroll and filters, remembers the last tab; 在页面打开 is secondary - Ended runs: append to resume, or 用同目标新建 with 先看方案再执行 toggle - Session page: 88ch conversation column, turns grouped with one timestamp, tool cards collapse to one line, 3+ activities fold; steps/subagents/budget moved to a right rail (collapsible overview bar on narrow screens and in the drawer), long model reports fold behind 展开全文 - Smoke covers the drawer history flow Co-Authored-By: Claude Opus 5.5 --- apps/web/app/api/agent.ts | 28 +- .../components/agent/agent-conversation.tsx | 372 +++++++++++++++--- .../web/app/components/agent/agent-drawer.tsx | 157 ++++++-- .../components/agent/agent-helpers.test.ts | 76 +++- .../components/agent/agent-interaction.tsx | 9 +- .../app/components/agent/agent-run-panel.tsx | 356 ++++++++++++----- .../components/agent/agent-session-list.tsx | 338 ++++++++++++---- apps/web/app/components/agent/agent-store.ts | 80 +++- .../components/agent/agent-subagent-tree.tsx | 43 +- apps/web/app/components/agent/mock.ts | 10 +- .../app/components/agentui/tool-result.tsx | 35 +- apps/web/app/routes/console/agent.$runId.tsx | 25 +- apps/web/app/routes/console/agent.tsx | 24 +- apps/web/scripts/smoke.mjs | 30 ++ 14 files changed, 1234 insertions(+), 349 deletions(-) diff --git a/apps/web/app/api/agent.ts b/apps/web/app/api/agent.ts index 93b758a0..ed991cea 100644 --- a/apps/web/app/api/agent.ts +++ b/apps/web/app/api/agent.ts @@ -13,6 +13,7 @@ import { type AgentRunEvent, type AgentRunMessage, type AgentRunStatus, + type AgentTrigger, Approval, type ApprovalStatus, AuditEvent, @@ -119,21 +120,38 @@ function useAgentMockGate(): boolean { export const agentKeys = { all: ["agent"] as const, - runs: (status?: AgentRunStatus) => ["agent", "list", status ?? "all"] as const, + runs: (status?: AgentRunStatus, trigger?: AgentTrigger) => + ["agent", "list", status ?? "all", trigger ?? "any"] as const, run: (id: string) => ["agent", "detail", id] as const, }; const RUNNING: AgentRunStatus[] = ["queued", "running", "waiting"]; export const isRunActive = (s: AgentRunStatus | undefined) => !!s && RUNNING.includes(s); -export function useAgentRuns(status?: AgentRunStatus) { +/** 会话来源(run.trigger)的界面名:手动 / 巡检 / 事件 / 子会话 */ +export const TRIGGER_LABEL: Record = { + user: "手动", + schedule: "巡检", + event: "事件", + subagent: "子会话", +}; + +/** 会话列表:按状态、来源(trigger)在服务端筛选,游标翻页 */ +export function useAgentRuns(status?: AgentRunStatus, trigger?: AgentTrigger) { const mock = useAgentMockGate(); return useInfiniteQuery({ - queryKey: [...agentKeys.runs(status), mock], + queryKey: [...agentKeys.runs(status, trigger), mock], initialPageParam: undefined as string | undefined, queryFn: async ({ pageParam, signal }) => { - if (mock) return { items: MOCK_RUNS().filter((r) => !status || r.status === status), nextCursor: null }; - return apiParsed(RunPage, AGENT_BASE, { params: cleanParams({ status, cursor: pageParam, limit: 30 }), signal }); + if (mock) + return { + items: MOCK_RUNS().filter((r) => (!status || r.status === status) && (!trigger || r.trigger === trigger)), + nextCursor: null, + }; + return apiParsed(RunPage, AGENT_BASE, { + params: cleanParams({ status, trigger, cursor: pageParam, limit: 30 }), + signal, + }); }, getNextPageParam: (last) => last.nextCursor ?? undefined, refetchInterval: (q) => diff --git a/apps/web/app/components/agent/agent-conversation.tsx b/apps/web/app/components/agent/agent-conversation.tsx index c58ea56e..a9863c31 100644 --- a/apps/web/app/components/agent/agent-conversation.tsx +++ b/apps/web/app/components/agent/agent-conversation.tsx @@ -51,6 +51,7 @@ import { StreamingCursor, StreamingResponse } from "~/components/agentui/streami 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 { Button } from "~/components/ui/button.tsx"; import { errorMessage } from "~/lib/api.ts"; import { cn, formatDateTime } from "~/lib/utils.ts"; import { InlineApproval, OptionsCard } from "./agent-interaction.tsx"; @@ -148,6 +149,38 @@ export function authDenial(call: ToolCallView): string | null { /** 被服务端拒绝(白名单拦截或授权校验失败) */ export const isDenied = (call: ToolCallView) => call.status === "denied" || authDenial(call) !== null; +/** + * 工具卡收起时那一行的摘要:错误取 message;数组 / items 取条数;对象取前几个简单字段; + * 其余截断原文。只做展示,不做判断(纯函数,单测覆盖)。 + */ +export function toolSummary(call: Pick): string | null { + const raw = call.outputSummary?.trim(); + if (!raw) return call.status === "running" ? "等待结果…" : null; + const clip = (t: string) => (t.length > 120 ? `${t.slice(0, 120)}…` : t); + let v: unknown; + try { + v = JSON.parse(raw); + } catch { + return clip(raw.replace(/\s+/g, " ")); + } + if (Array.isArray(v)) return `${v.length} 条`; + if (v && typeof v === "object") { + const o = v as Record; + const err = o.error as { message?: unknown; code?: unknown } | undefined; + if (err && typeof err === "object") return clip(String(err.message ?? err.code ?? "失败")); + for (const k of ["summary", "message"]) if (typeof o[k] === "string") return clip(o[k] as string); + for (const k of ["items", "results", "releases", "candidates"]) + if (Array.isArray(o[k])) return `${(o[k] as unknown[]).length} 条结果`; + const simple = Object.entries(o) + .filter(([, x]) => ["string", "number", "boolean"].includes(typeof x)) + .slice(0, 3) + .map(([k, x]) => `${k}: ${String(x)}`); + if (simple.length) return clip(simple.join(" · ")); + return `${Object.keys(o).length} 个字段`; + } + return clip(String(v)); +} + /** 工具调用卡片:脱敏输入/输出折叠、耗时、scope/风险徽标、幂等键(审计);服务端拒绝时换醒目拒绝态 */ export function ToolCallCard({ call, @@ -198,7 +231,7 @@ export function ToolCallCard({ extra={call.recovery ? 恢复补记 : null} /> } - summary={call.outputSummary ?? undefined} + summary={toolSummary(call) ?? undefined} > {call.inputSummary ? {call.inputSummary} : null} {call.outputSummary ? ( @@ -384,7 +417,112 @@ export function pairAudit(calls: ToolCallView[], audit: InvocationAudit[] | unde return out; } -/** 会话流:用户目标 → 流式文本/思考/工具卡片/拒绝/压缩 → 结束;追加的用户消息按时间插入 */ +// ── 消息分组:同一助手回合(两条用户消息 / 系统提示之间)的文本、思考、工具卡归为一组,时间戳只在组头; +// 组内连续 ≥3 条已结束的思考 / 工具调用再折叠成一行"活动"(被拒绝、运行中的保持展开,醒目可见) ── + +export type ConvEntry = { + key: string; + at: string; + role: "user" | "assistant" | "system"; + /** 可并入"活动"折叠(已结束的思考 / 工具调用) */ + foldable: boolean; + /** 折叠摘要里的名字(能力名 / "思考") */ + label?: string; + failed?: boolean; + node: ReactNode; +}; +export type ConvPart = { kind: "item"; entry: ConvEntry } | { kind: "fold"; key: string; entries: ConvEntry[] }; +export type ConvBlock = + | { kind: "single"; entry: ConvEntry } + | { kind: "turn"; key: string; at: string; parts: ConvPart[] }; + +function foldRuns(parts: ConvPart[], min: number): ConvPart[] { + const out: ConvPart[] = []; + let run: ConvEntry[] = []; + const flush = () => { + if (run.length >= min) out.push({ kind: "fold", key: `f${run[0]?.key}`, entries: run }); + else for (const e of run) out.push({ kind: "item", entry: e }); + run = []; + }; + for (const p of parts) { + if (p.kind === "item" && p.entry.foldable) run.push(p.entry); + else { + flush(); + out.push(p); + } + } + flush(); + return out; +} + +/** 纯函数(单测覆盖):按时间排好的条目 → 用户/系统单条 + 助手回合组 */ +export function groupConversation(entries: ConvEntry[], minFold = 3): ConvBlock[] { + const out: ConvBlock[] = []; + let turn: Extract | null = null; + for (const e of entries) { + if (e.role !== "assistant") { + turn = null; + out.push({ kind: "single", entry: e }); + continue; + } + if (!turn) { + turn = { kind: "turn", key: `t${e.key}`, at: e.at, parts: [] }; + out.push(turn); + } + turn.parts.push({ kind: "item", entry: e }); + } + for (const b of out) if (b.kind === "turn") b.parts = foldRuns(b.parts, minFold); + return out; +} + +function clockText(iso: string) { + const d = new Date(iso); + if (Number.isNaN(d.getTime())) return iso; + const sameDay = new Date().toDateString() === d.toDateString(); + return sameDay + ? d.toLocaleTimeString("zh-CN", { hour: "2-digit", minute: "2-digit", second: "2-digit", hour12: false }) + : formatDateTime(iso); +} + +/** 折叠的一串活动:一行摘要(次数 + 能力名预览 + 失败数),展开看每张卡 */ +function ActivityFold({ entries }: { entries: ConvEntry[] }) { + const id = useId(); + const [open, setOpen] = useState(false); + const tools = entries.filter((e) => e.label !== "思考").length; + const thoughts = entries.length - tools; + const failed = entries.filter((e) => e.failed).length; + const names = [...new Set(entries.filter((e) => e.label && e.label !== "思考").map((e) => e.label))]; + return ( +
+ + +
+ {entries.map((e) => ( +
{e.node}
+ ))} +
+
+
+ ); +} + +/** 会话流:用户目标 → 助手回合(流式文本/思考/工具卡片/方案/审批)→ 结束;追加的用户消息按时间插入 */ export function AgentConversation({ run, live, @@ -413,16 +551,28 @@ export function AgentConversation({ const lastIndex = live.items.length - 1; const lastText = live.items.map((i) => i.kind).lastIndexOf("text"); - type Row = { key: string; at: string; node: ReactNode }; - const rows: Row[] = live.items.map((it, idx) => { + const entries: ConvEntry[] = []; + live.items.forEach((it, idx) => { const streaming = active && idx === lastIndex; const anim = fresh(idx); - let node: ReactNode = null; + const wrap = (node: ReactNode) => + anim ? ( +
+ {node} +
+ ) : ( + node + ); switch (it.kind) { case "text": - node = ( - + entries.push({ + key: it.key, + at: it.at, + role: "assistant", + foldable: false, + node: wrap( {/* 流式中按原文显示(半截 Markdown 会闪),结束后按安全子集渲染 */} {streaming ? it.text : } - - - ); + , + ), + }); break; case "thinking": - node = ( - - - - ); + entries.push({ + key: it.key, + at: it.at, + role: "assistant", + foldable: !streaming, + label: "思考", + node: wrap(), + }); break; - case "tool": - node = ( - - - - ); + case "tool": { + const denied = isDenied(it.call); + entries.push({ + key: it.key, + at: it.call.startedAt, + role: "assistant", + foldable: !denied && it.call.status !== "running" && idx !== lastIndex, + label: capabilityMeta(it.call.capabilityId)?.title ?? it.call.capabilityId, + failed: it.call.status === "failed", + node: wrap(), + }); break; + } case "compaction": - node = ( - - - 上下文压缩({COMPACT_REASON[it.reason] ?? it.reason})·{" "} - {it.done ? (COMPACT_STATUS[it.status ?? "completed"] ?? it.status) : "进行中…"} - - ); + entries.push({ + key: it.key, + at: it.at, + role: "system", + foldable: false, + node: ( + + + 上下文压缩({COMPACT_REASON[it.reason] ?? it.reason})·{" "} + {it.done ? (COMPACT_STATUS[it.status ?? "completed"] ?? it.status) : "进行中…"} + + ), + }); break; case "options": - node = ( - - - - ); + entries.push({ + key: it.key, + at: it.at, + role: "assistant", + foldable: false, + node: wrap(), + }); break; case "approval": - node = ( - - - - ); + entries.push({ + key: it.key, + at: it.at, + role: "assistant", + foldable: false, + node: wrap(), + }); break; case "end": - node = it.ok ? null : ; + if (!it.ok) + entries.push({ key: it.key, at: it.at, role: "system", foldable: false, node: }); break; } - return { key: it.key, at: it.kind === "tool" ? it.call.startedAt : it.at, node }; }); for (const m of userMessages) - rows.push({ + entries.push({ key: `u${m.at}`, at: m.at, + role: "user", + foldable: false, node: , }); - rows.sort((a, b) => a.at.localeCompare(b.at)); + entries.sort((a, b) => a.at.localeCompare(b.at)); + const blocks = groupConversation(entries); + const tail = live.items[lastIndex]?.kind; + const typing = + active && tail !== "text" && tail !== "thinking" ? ( + + + {run.status === "waiting" + ? `${run.summary === "等待选择方案" || run.summary === "等待审批" ? run.summary : "等待中"}…` + : run.status === "queued" + ? "排队中…" + : "正在处理…"} + + ) : null; + const lastBlock = blocks[blocks.length - 1]; + const typingInTurn = typing && lastBlock?.kind === "turn"; return ( -
+
- {rows.map((r) => (r.node ?
{r.node}
: null))} - {active && live.items[lastIndex]?.kind !== "text" && live.items[lastIndex]?.kind !== "thinking" ? ( - - - - {run.status === "waiting" - ? `${run.summary === "等待选择方案" || run.summary === "等待审批" ? run.summary : "等待中"}…` - : run.status === "queued" - ? "排队中…" - : "正在处理…"} - - - ) : null} + {blocks.map((b) => + b.kind === "single" ? ( +
{b.entry.node}
+ ) : ( + + + + + + + Agent + + + {b.parts.map((p) => + p.kind === "fold" ? ( + + ) : ( +
+ {p.entry.node} +
+ ), + )} + {typingInTurn && b === lastBlock ? typing : null} +
+
+ ), + )} + {typing && !typingInTurn ? {typing} : null} {after}
); @@ -552,7 +753,16 @@ function Meter({ } /** 预算用量条:工具调用/token/时长/费用;超限的一项标红,上游未报告的显示"未报告",费用标注估算 */ -export function BudgetBar({ run, live }: { run: AgentRunView; live: AgentStreamState }) { +export function BudgetBar({ + run, + live, + stacked = false, +}: { + run: AgentRunView; + live: AgentStreamState; + /** 侧栏里单列排 */ + stacked?: boolean; +}) { const u = { ...run.usage, ...(live.usage ?? {}) }; const toolCalls = Math.max(run.toolCallCount, live.items.filter((i) => i.kind === "tool").length); const end = [...live.items].reverse().find((i) => i.kind === "end"); @@ -563,7 +773,7 @@ export function BudgetBar({ run, live }: { run: AgentRunView; live: AgentStreamS ? (run.finishedAt ? new Date(run.finishedAt).getTime() : Date.now()) - new Date(run.startedAt).getTime() : undefined); return ( -
+
? "destructive" : "warning"; +/** 长汇报:超过 ~12 行或 600 字按长文处理(纯函数,单测覆盖) */ +export function isLongReport(text: string): boolean { + return text.length > 600 || text.split("\n").length > 12; +} + +/** 长文折叠:收起时限高 + 底部渐隐,"展开全文"/"收起" */ +function LongText({ long, children }: { long: boolean; children: ReactNode }) { + const id = useId(); + const [open, setOpen] = useState(false); + if (!long) return <>{children}; + return ( +
+
+ {children} +
+ +
+ ); +} + /** 最终汇报:左"模型说的(未经核查)",右"系统核实的结果"(服务端从下载计划/下载任务/整理记录汇总,不采信模型自述) */ export function FinalReport({ run, @@ -719,15 +965,17 @@ export function FinalReport({ const imported = report?.organization.filter((o) => o.targetPath) ?? []; return ( -
+

模型说的(未经核查)

- - {said ? : "(没有文字汇报)"} - + + + {said ? : "(没有文字汇报)"} + +

仅供参考,不作为完成依据。

diff --git a/apps/web/app/components/agent/agent-drawer.tsx b/apps/web/app/components/agent/agent-drawer.tsx index f7f00e50..a846a22b 100644 --- a/apps/web/app/components/agent/agent-drawer.tsx +++ b/apps/web/app/components/agent/agent-drawer.tsx @@ -1,27 +1,35 @@ -import { Bot, ExternalLink, Maximize2, Minus, Plus, X } from "lucide-react"; +import { ArrowLeft, Bot, ExternalLink, History, Maximize2, Minus, Plus, X } from "lucide-react"; import { useRef } from "react"; import { Link, useLocation } from "react-router"; import { toast } from "sonner"; -import { isRunActive, useAgentRun, useCreateAgentRun } from "~/api/agent.ts"; +import { agentMockEnabled, isRunActive, useAgentRun, useCreateAgentRun } from "~/api/agent.ts"; import { StreamingCursor } from "~/components/agentui/streaming-response.tsx"; import { Button } from "~/components/ui/button.tsx"; import { Sheet, SheetContent, SheetDescription, SheetHeader, SheetTitle } from "~/components/ui/sheet.tsx"; import { errorMessage } from "~/lib/api.ts"; +import { cn } from "~/lib/utils.ts"; import { capabilityMeta, isDenied, RunStatusBadge } from "./agent-conversation.tsx"; import { AgentRunPanel, GoalComposer } from "./agent-run-panel.tsx"; +import { AgentSessionList } from "./agent-session-list.tsx"; import { type AgentContext, + type AgentDrawerTab, agentShortcutLabel, contextFromPath, contextPrefix, + lastAgentDrawerTab, minimizeAgentDrawer, openAgentDrawer, + openAgentHistoryRun, restoreAgentDrawer, setAgentDrawerOpen, setAgentDrawerRun, + setAgentDrawerTab, + showAgentRunInDrawer, splitContextGoal, useAgentDrawer, } from "./agent-store.ts"; +import { AgentRunOpenContext } from "./agent-subagent-tree.tsx"; import { quickGoalsFor } from "./quick-goals.ts"; import { useAgentStream } from "./use-agent-stream.ts"; @@ -55,18 +63,81 @@ function ContextChips({ context }: { context: AgentContext }) { ); } +const TABS: { value: AgentDrawerTab; label: string }[] = [ + { value: "current", label: "当前" }, + { value: "history", label: "历史" }, +]; + +function DrawerTabs({ tab }: { tab: AgentDrawerTab }) { + return ( +
+ {TABS.map((t) => ( + + ))} +
+ ); +} + +/** 历史 Tab 里打开的一条会话:返回列表 + 目标 + 在页面打开(次要);正文复用会话组件,进行中可继续输入 */ +function HistoryRunView({ runId }: { runId: string }) { + const run = useAgentRun(runId); + const mock = agentMockEnabled(); + return ( +
+
+ +

+ {run.data ? splitContextGoal(run.data.run.goal).text : ""} +

+ +
+ + + +
+ ); +} + /** * Agent 侧栏抽屉:任意页面可打开,带当前页面上下文(entityId / siteId / taskId 等)作为 goal 前缀; + * 顶部 Tab「当前 / 历史」:历史里点一条在抽屉内打开(不跳页),返回列表保留筛选与滚动位置;抽屉记住上次 Tab。 * 运行中可最小化到右下角小窗,流式输出继续。 * 挂载:在 console layout 里渲染一次 ;顶栏放 。 * 页面内打开:import { openAgentDrawer } from "~/components/agent/agent-store.ts"。 */ export function AgentDrawer() { - const { open, minimized, context, runId } = useAgentDrawer(); + const { open, minimized, context, runId, tab, historyRunId } = useAgentDrawer(); const create = useCreateAgentRun(); const prefix = contextPrefix(context); const { pathname } = useLocation(); const content = useRef(null); + const history = tab === "history"; return ( <> @@ -87,20 +158,21 @@ export function AgentDrawer() { if ((e.target as Element | null)?.closest?.(`[${AGENT_LAUNCHER_ATTR}]`)) e.preventDefault(); }} > - + Agent - + {context?.label ? `当前对象:${context.label}` : "交代目标,Agent 在授权范围内执行并汇报。"} ({agentShortcutLabel()} 开关,Esc 关闭) - {context ? : null} + {context && !history ? : null} + - {runId ? ( + {runId && !history ? ( ) : null} -
- {runId ? ( - +
+ {history ? ( + historyRunId ? ( + + ) : ( + + ) + ) : runId ? ( + + + ) : ( )}
-
- {runId ? ( - <> - + + + ) : ( + - - - ) : ( - - )} -
+ )} +
+ )} {minimized && runId ? : null} @@ -237,7 +328,9 @@ export function AgentDrawerTrigger({ className }: { className?: string }) { aria-label="打开 Agent" title={`Agent(${agentShortcutLabel()})`} className={className} - onClick={() => (minimized && runId ? restoreAgentDrawer() : openAgentDrawer(contextFromPath(pathname)))} + onClick={() => + minimized && runId ? restoreAgentDrawer() : openAgentDrawer(contextFromPath(pathname), lastAgentDrawerTab()) + } > diff --git a/apps/web/app/components/agent/agent-helpers.test.ts b/apps/web/app/components/agent/agent-helpers.test.ts index f1858ee5..9e44ea12 100644 --- a/apps/web/app/components/agent/agent-helpers.test.ts +++ b/apps/web/app/components/agent/agent-helpers.test.ts @@ -1,6 +1,16 @@ import type { AgentRunEvent } from "@umt/contracts"; import { describe, expect, it } from "vitest"; -import { authDenial, isDenied, pairAudit, runPlanIds } from "./agent-conversation.tsx"; +import { + authDenial, + type ConvEntry, + groupConversation, + isDenied, + isLongReport, + pairAudit, + runPlanIds, + toolSummary, +} from "./agent-conversation.tsx"; +import { matchesHistory, rangeStart } from "./agent-session-list.tsx"; import { contextFromPath, contextPrefix, splitContextGoal } from "./agent-store.ts"; import { foldEvents, type StreamItem, type ToolCallView } from "./use-agent-stream.ts"; @@ -111,3 +121,67 @@ describe("事件折叠", () => { expect(tools[0]?.kind === "tool" && tools[0].call).toMatchObject({ status: "succeeded", recovery: true }); }); }); + +describe("会话排版(AGUI-2)", () => { + const e = (key: string, role: ConvEntry["role"], foldable = false): ConvEntry => ({ + key, + at: key, + role, + foldable, + label: foldable ? "查本地库存" : undefined, + node: null, + }); + it("同一助手回合归为一组,用户 / 系统条目打断回合", () => { + const blocks = groupConversation([e("a", "assistant"), e("b", "assistant"), e("c", "user"), e("d", "assistant")]); + expect(blocks.map((b) => b.kind)).toEqual(["turn", "single", "turn"]); + const first = blocks[0]; + expect(first?.kind === "turn" && first.parts.length).toBe(2); + }); + it("连续 ≥3 条可折叠的活动并成一行,不足 3 条原样展示", () => { + const [t] = groupConversation([ + e("1", "assistant", true), + e("2", "assistant", true), + e("3", "assistant", true), + e("4", "assistant"), + e("5", "assistant", true), + e("6", "assistant", true), + ]); + expect(t?.kind).toBe("turn"); + if (t?.kind !== "turn") return; + expect(t.parts.map((p) => (p.kind === "fold" ? `fold${p.entries.length}` : p.entry.key))).toEqual([ + "fold3", + "4", + "5", + "6", + ]); + }); + it("工具卡一行摘要", () => { + expect(toolSummary({ status: "running", outputSummary: null })).toBe("等待结果…"); + expect(toolSummary({ status: "succeeded", outputSummary: '{"items":[1,2,3]}' })).toBe("3 条结果"); + expect(toolSummary({ status: "failed", outputSummary: '{"error":{"code":"x","message":"超时"}}' })).toBe("超时"); + expect(toolSummary({ status: "succeeded", outputSummary: '{"found":true,"count":2}' })).toBe( + "found: true · count: 2", + ); + expect(toolSummary({ status: "succeeded", outputSummary: "下载器连接超时" })).toBe("下载器连接超时"); + }); + it("长汇报判定", () => { + expect(isLongReport("短")).toBe(false); + expect(isLongReport("x".repeat(601))).toBe(true); + expect(isLongReport(Array(14).fill("行").join("\n"))).toBe(true); + }); +}); + +describe("会话历史筛选", () => { + const now = new Date("2026-09-29T12:00:00Z").getTime(); + const run = { id: "abcd1234-0000", goal: "[上下文 作品 x] 把 沙丘 收进来", createdAt: "2026-09-25T12:00:00Z" }; + it("搜索匹配目标或 ID 前缀", () => { + expect(matchesHistory(run, { q: "沙丘", range: "" }, now)).toBe(true); + expect(matchesHistory(run, { q: "abcd", range: "" }, now)).toBe(true); + expect(matchesHistory(run, { q: "奥本海默", range: "" }, now)).toBe(false); + }); + it("时间范围按创建时间", () => { + expect(matchesHistory(run, { q: "", range: "7d" }, now)).toBe(true); + expect(matchesHistory(run, { q: "", range: "today" }, now)).toBe(false); + expect(rangeStart("", now)).toBeNull(); + }); +}); diff --git a/apps/web/app/components/agent/agent-interaction.tsx b/apps/web/app/components/agent/agent-interaction.tsx index acd83a7f..b181ab28 100644 --- a/apps/web/app/components/agent/agent-interaction.tsx +++ b/apps/web/app/components/agent/agent-interaction.tsx @@ -32,15 +32,18 @@ export function planTodoItems(steps: AgentPlanStep[]): TodoItem[] { })); } -/** 步骤清单:会话页顶部完整列表(实时勾选);抽屉里压缩成一行进度 */ +/** 步骤清单:会话页右侧栏完整列表(实时勾选);compact 压缩成一行进度 */ export function RunPlan({ plan, active, compact = false, + fill = false, }: { plan: { steps: AgentPlanStep[]; revision: number } | null; active: boolean; compact?: boolean; + /** 侧栏 / 展开的概况条里:不限高、不自动收起 */ + fill?: boolean; }) { if (!plan?.steps.length) return null; const done = plan.steps.filter((s) => s.status === "done" || s.status === "skipped").length; @@ -79,8 +82,8 @@ export function RunPlan({ title="步骤" items={planTodoItems(plan.steps)} defaultOpen - collapseOnComplete={!active} - maxHeightClassName="max-h-56" + collapseOnComplete={!fill && !active} + maxHeightClassName={fill ? "max-h-none" : "max-h-56"} /> ); } diff --git a/apps/web/app/components/agent/agent-run-panel.tsx b/apps/web/app/components/agent/agent-run-panel.tsx index be3d0ee7..fe820833 100644 --- a/apps/web/app/components/agent/agent-run-panel.tsx +++ b/apps/web/app/components/agent/agent-run-panel.tsx @@ -1,7 +1,8 @@ import { useQueryClient } from "@tanstack/react-query"; -import { EventType } from "@umt/contracts"; -import { FlaskConical, RefreshCw, Sparkles, WifiOff } from "lucide-react"; +import { type AgentRunStatus, EventType } from "@umt/contracts"; +import { ChevronDown, FlaskConical, ListChecks, RefreshCw, RotateCcw, Sparkles, WifiOff } from "lucide-react"; import { useId, useRef, useState } from "react"; +import { useNavigate } from "react-router"; import { toast } from "sonner"; import { type AgentSessionOptions, @@ -9,12 +10,16 @@ import { agentMockEnabled, isProposeFirst, isRunActive, + type RunSnapshot, + TRIGGER_LABEL, useAbortAgentRun, useAgentRun, useAppendAgentMessage, + useCreateAgentRun, useRunApprovals, useRunInvocationAudit, } from "~/api/agent.ts"; +import { CHEVRON_CLASS, Disclosure } from "~/components/agentui/disclosure.tsx"; import { MessageScroller } from "~/components/agentui/message-scroller.tsx"; import { PromptInput } from "~/components/agentui/prompt-input.tsx"; import { ConfirmDialog } from "~/components/console/confirm-dialog.tsx"; @@ -37,9 +42,7 @@ import { } from "./agent-conversation.tsx"; import { RunPlan } from "./agent-interaction.tsx"; import { SubagentTree } from "./agent-subagent-tree.tsx"; -import { useAgentStream } from "./use-agent-stream.ts"; - -const TRIGGER_LABEL = { user: "用户发起", event: "事件触发", schedule: "定时", subagent: "子会话" } as const; +import { type AgentStreamState, useAgentStream } from "./use-agent-stream.ts"; /** "先看方案再执行"开关(会话选项,默认关:自动化优先,按步骤直接执行,需授权的动作在步骤处审批) */ export function ProposeFirstToggle({ checked, onChange }: { checked: boolean; onChange: (v: boolean) => void }) { @@ -140,21 +143,160 @@ export function GoalComposer({ ); } -/** 一次运行:头部状态 + 预算条 + 步骤清单 + 流式对话(方案选择 / 审批卡在对应步骤处)+ 最终汇报 + 输入框 */ +function shortTokens(n: number | undefined) { + return n === undefined ? null : n >= 1000 ? `${(n / 1000).toFixed(1)}k token` : `${n} token`; +} + +/** 状态 + 来源 + 时间 + 模型 */ +function RunMeta({ run, status }: { run: RunSnapshot["run"]; status: AgentRunStatus }) { + return ( +
+
+ + {isProposeFirst(run) ? 先看方案再执行 : null} + {agentMockEnabled() ? ( + + + mock 数据 + + ) : null} +
+

+ {TRIGGER_LABEL[run.trigger]} · {formatDateTime(run.createdAt)} + {run.model ? ` · ${run.provider ?? ""}/${run.model}` : ""} +

+
+ ); +} + +/** + * 窄屏 / 抽屉的概况条:收起时一行(状态 · 步骤进度 · 当前步骤 · 用量),展开看步骤清单、子会话与预算。 + * 不 sticky、不压在会话上,方案卡与审批卡始终在会话流里完整可见。 + */ +function RunOverviewBar({ + snapshot, + status, + live, + active, + className, +}: { + snapshot: RunSnapshot; + status: AgentRunStatus; + live: AgentStreamState; + active: boolean; + className?: string; +}) { + const id = useId(); + const [open, setOpen] = useState(false); + const r = snapshot.run; + const steps = live.plan?.steps ?? []; + const done = steps.filter((s) => s.status === "done" || s.status === "skipped").length; + const current = steps.find((s) => s.status === "running") ?? steps.find((s) => s.status === "pending"); + const failed = steps.some((s) => s.status === "failed"); + const u = { ...r.usage, ...(live.usage ?? {}) }; + const calls = Math.max(r.toolCallCount, live.items.filter((i) => i.kind === "tool").length); + const usage = [`${calls} 次调用`, shortTokens(u.totalTokens)].filter(Boolean).join(" · "); + return ( +
+ + {steps.length ? ( +
+
+
+ ) : null} + +
+ + + +
+

预算与用量

+ +
+
+
+
+ ); +} + +/** 已结束的会话:用同一个目标新建(可切"先看方案再执行") */ +function NewFromGoal({ goal, onCreated }: { goal: string; onCreated: (runId: string) => void }) { + const create = useCreateAgentRun(); + const [proposeFirst, setProposeFirst] = useState(false); + return ( +
+ + +
+ ); +} + +/** + * 一次运行。page:会话列 + 右侧栏(状态、步骤、子会话,底部预算与用量);窄于 lg 时侧栏换成顶部可折叠概况条。 + * compact(抽屉):只有概况条。会话列统一最大宽度,输入区固定在底部(停止、续跑、用同目标新建)。 + */ export function AgentRunPanel({ runId, compact = false, className, + onCreated, }: { runId: string; compact?: boolean; className?: string; + /** 用同目标新建后:缺省跳到新会话页;抽屉里改成在抽屉内打开 */ + onCreated?: (runId: string) => void; }) { const run = useAgentRun(runId); const active = isRunActive(run.data?.run.status); const live = useAgentStream(runId, active || !run.data); const abort = useAbortAgentRun(); const append = useAppendAgentMessage(); + const navigate = useNavigate(); const [msg, setMsg] = useState(""); const [confirmStop, setConfirmStop] = useState(false); const sseStatus = useSseStatus(); @@ -190,13 +332,14 @@ export function AgentRunPanel({ const { run: r, report, messages } = run.data; const finished = !isRunActive(r.status) || live.finished; const streaming = active && !live.finished; - const proposeFirst = isProposeFirst(r); - const shownStatus = + const shownStatus: AgentRunStatus = // 事件流已结束但快照还没刷到终态时按运行中显示;waiting(等待下载/入库核验)照实显示 (live.finished && (r.status === "queued" || r.status === "running")) || (r.status === "queued" && live.events.length > 0) ? "running" : r.status; + const ended = !isRunActive(r.status); + const created = onCreated ?? ((id: string) => navigate(`/console/agent/${id}${agentMockEnabled() ? "?mock=1" : ""}`)); const send = (text: string) => append.mutate( @@ -210,101 +353,122 @@ export function AgentRunPanel({ }, ); + const column = "mx-auto w-full max-w-[88ch]"; + return ( -
+
-
- - {proposeFirst ? 先看方案再执行 : null} - - {TRIGGER_LABEL[r.trigger]} · {formatDateTime(r.createdAt)} - {r.model ? ` · ${r.provider ?? ""}/${r.model}` : ""} - - {agentMockEnabled() ? ( - - - mock 数据 - - ) : null} -
- -
- -
- - - - - - {streaming && sseStatus === "reconnecting" ? ( -
- - 实时连接中断,正在重连。期间每 2.5 秒轮询一次。 -
- ) : null} - {live.error ? ( -
- 事件读取失败:{live.error} - -
- ) : null} - - - + ({ at: m.createdAt, text: m.text, pending: m.status === "pending" }))} - after={ - <> - {approvals.items - .filter((a) => !inline.has(a.id)) - .map((a) => ( - - ))} - {finished ? : null} - - } + active={streaming || r.status === "waiting"} + className={cn(column, !compact && "lg:hidden")} /> - -
- setConfirmStop(true)} - stopLabel={abort.isPending ? "停止中…" : "停止"} - minRows={1} - maxRows={6} - maxLength={8000} - aria-label="追加消息" - placeholder={streaming ? "补充或纠正(运行中也可追加)" : "追加消息"} - /> -

- {r.status === "waiting" && (r.summary === "等待选择方案" || r.summary === "等待审批") - ? `${r.summary}:在上面的卡片里处理,会话随即继续。` - : "追加的消息在本轮结束后读取。"}{" "} - Enter 发送,Shift+Enter 换行{streaming ? ",停止 = 结束本次运行" : ""}。 -

+ {streaming && sseStatus === "reconnecting" ? ( +
+ + 实时连接中断,正在重连。期间每 2.5 秒轮询一次。 +
+ ) : null} + {live.error ? ( +
+ 事件读取失败:{live.error} + +
+ ) : null} + + + ({ at: m.createdAt, text: m.text, pending: m.status === "pending" }))} + after={ + <> + {approvals.items + .filter((a) => !inline.has(a.id)) + .map((a) => ( + + ))} + {finished ? : null} + + } + /> + + +
+ setConfirmStop(true)} + stopLabel={abort.isPending ? "停止中…" : "停止"} + minRows={1} + maxRows={6} + maxLength={8000} + aria-label={ended ? "追加消息续跑" : "追加消息"} + placeholder={ended ? "追加消息,在本会话里续跑" : "补充或纠正(运行中也可追加)"} + submitLabel={ended ? "续跑" : "发送"} + toolbar={ + ended ? ( + + ) : isProposeFirst(r) ? ( + 先看方案再执行 + ) : null + } + /> +

+ {r.status === "waiting" && (r.summary === "等待选择方案" || r.summary === "等待审批") + ? `${r.summary}:在会话里的卡片上处理,会话随即继续。` + : ended + ? "续跑沿用本会话的上下文与预算。" + : "追加的消息在本轮结束后读取。"}{" "} + Enter 发送,Shift+Enter 换行。 +

+
+ {compact ? null : ( + + )} + ({ value: s, label: RUN_STATUS_LABEL[s] })); +const TRIGGER_OPTIONS = (["user", "schedule", "event", "subagent"] as AgentTrigger[]).map((t) => ({ + value: t, + label: TRIGGER_LABEL[t], +})); +const RANGE_OPTIONS = [ + { value: "today", label: "今天" }, + { value: "7d", label: "近 7 天" }, + { value: "30d", label: "近 30 天" }, ]; -function usageText(r: AgentRunView) { +/** 时间范围的起点(毫秒);不限为 null */ +export function rangeStart(range: TimeRange, now = Date.now()): number | null { + if (range === "today") { + const d = new Date(now); + d.setHours(0, 0, 0, 0); + return d.getTime(); + } + if (range === "7d") return now - 7 * 86_400_000; + if (range === "30d") return now - 30 * 86_400_000; + return null; +} + +/** 纯函数(单测覆盖):搜索词匹配目标正文、上下文或会话 ID 前缀;时间按创建时间 */ +export function matchesHistory( + run: Pick, + f: Pick, + now = Date.now(), +): boolean { + const start = rangeStart(f.range, now); + if (start !== null && new Date(run.createdAt).getTime() < start) return false; + const q = f.q.trim().toLowerCase(); + if (!q) return true; + return run.goal.toLowerCase().includes(q) || run.id.startsWith(q); +} + +export function usageText(r: Pick) { const parts = [`${r.toolCallCount} 次调用`]; if (r.usage.totalTokens !== undefined) parts.push( @@ -31,104 +69,232 @@ function usageText(r: AgentRunView) { return parts.join(" · "); } -/** 会话列表(侧栏):状态筛选 + 每条的目标/状态/模式/时间/用量 */ +const ALL = "__all__"; + +/** 窄栏里的下拉:只显示取值("全部"时显示维度名),读屏名带维度 */ +function MiniSelect({ + label, + allLabel, + value, + onChange, + options, +}: { + label: string; + allLabel: string; + value: string; + onChange: (v: string) => void; + options: { value: string; label: string }[]; +}) { + return ( + + ); +} + +/** 抽屉里跨开关保留的筛选与滚动位置(模块级,刷新页面才清) */ +const drawerMemory = { filters: EMPTY_FILTERS, scroll: 0 }; + +function RunRow({ r, current, children }: { r: AgentRunView; current: boolean; children: ReactNode }) { + const goal = splitContextGoal(r.goal); + return ( + <> +
+

+ {goal.text} +

+ {children} +
+
+ + {TRIGGER_LABEL[r.trigger]} + {r.scope.proposeFirst === true ? · 先看方案 : null} + +
+

+ {goal.context ? `${goal.context.split(" · ")[0]} · ` : ""} + {usageText(r)} +

+ + ); +} + +/** + * 会话列表:搜索 + 状态 / 来源 / 时间筛选 + 游标加载(滚到底自动加载)。 + * - hrefFor:会话页左栏,点一条跳到该会话页。 + * - onOpen:抽屉里,点一条在抽屉内打开;remember 时筛选与滚动位置在返回列表时恢复。 + */ export function AgentSessionList({ activeId, - status, - onStatusChange, + hrefFor, + onOpen, + remember = false, className, }: { activeId?: string; - status: "" | AgentRunStatus; - onStatusChange: (s: "" | AgentRunStatus) => void; + hrefFor?: (id: string) => string; + onOpen?: (id: string) => void; + remember?: boolean; className?: string; }) { - const runs = useAgentRuns(status || undefined); - const mock = agentMockEnabled(); - const rows = runs.data?.pages.flatMap((p) => p.items) ?? []; + const [f, setF] = useState(remember ? drawerMemory.filters : EMPTY_FILTERS); + const set = (patch: Partial) => + setF((prev) => { + const next = { ...prev, ...patch }; + if (remember) drawerMemory.filters = next; + return next; + }); + const runs = useAgentRuns(f.status || undefined, f.trigger || undefined); + const loaded = runs.data?.pages.flatMap((p) => p.items) ?? []; + const now = Date.now(); + const rows = loaded.filter((r) => matchesHistory(r, f, now)); + const start = rangeStart(f.range, now); + const oldest = loaded.at(-1); + // 列表按创建时间倒序:已加载的最早一条早于时间范围,再往后翻也不会命中 + const canLoadMore = !!runs.hasNextPage && !(start !== null && oldest && new Date(oldest.createdAt).getTime() < start); + const filtered = !!(f.q.trim() || f.status || f.trigger || f.range); + + const scroller = useRef(null); + const sentinel = useRef(null); + const restored = useRef(!remember); + useLayoutEffect(() => { + if (restored.current || !rows.length || !scroller.current) return; + restored.current = true; + scroller.current.scrollTop = drawerMemory.scroll; + }); + const { fetchNextPage, isFetchingNextPage } = runs; + useEffect(() => { + const el = sentinel.current; + if (!el || !canLoadMore || typeof IntersectionObserver === "undefined") return; + const io = new IntersectionObserver( + (es) => { + if (es.some((e) => e.isIntersecting) && !isFetchingNextPage) void fetchNextPage(); + }, + { root: scroller.current, rootMargin: "120px" }, + ); + io.observe(el); + return () => io.disconnect(); + }, [canLoadMore, isFetchingNextPage, fetchNextPage]); return ( diff --git a/apps/web/app/components/agent/agent-store.ts b/apps/web/app/components/agent/agent-store.ts index 7a10eb2b..d4b1826f 100644 --- a/apps/web/app/components/agent/agent-store.ts +++ b/apps/web/app/components/agent/agent-store.ts @@ -16,16 +16,82 @@ export type AgentContext = { goal?: string; }; -type State = { open: boolean; minimized: boolean; context: AgentContext | null; runId: string | null }; +/** 抽屉顶部的两个 Tab:当前(新目标 / 正在看的运行)、历史(会话列表,点一条在抽屉内打开) */ +export type AgentDrawerTab = "current" | "history"; -let state: State = { open: false, minimized: false, context: null, runId: null }; +type State = { + open: boolean; + minimized: boolean; + context: AgentContext | null; + runId: string | null; + tab: AgentDrawerTab; + /** 历史 Tab 里打开的会话(null = 列表) */ + historyRunId: string | null; +}; + +const TAB_KEY = "umt-agent-drawer-tab"; + +function readTab(): AgentDrawerTab { + try { + return globalThis.localStorage?.getItem(TAB_KEY) === "history" ? "history" : "current"; + } catch { + return "current"; + } +} + +function writeTab(tab: AgentDrawerTab) { + try { + globalThis.localStorage?.setItem(TAB_KEY, tab); + } catch { + // 存储不可用时只在本页记住 + } +} + +let state: State = { open: false, minimized: false, context: null, runId: null, tab: "current", historyRunId: null }; +let tabLoaded = false; const listeners = new Set<() => void>(); const emit = () => { for (const l of listeners) l(); }; -export function openAgentDrawer(context: AgentContext | null = null) { - state = { open: true, minimized: false, context, runId: null }; +/** 上次停留的 Tab(抽屉记住;首次从 localStorage 读) */ +export function lastAgentDrawerTab(): AgentDrawerTab { + if (!tabLoaded) { + tabLoaded = true; + state = { ...state, tab: readTab() }; + } + return state.tab; +} + +/** + * 打开抽屉。页面内按钮带着对象打开时落在「当前」开始新目标; + * 快捷键 / 悬浮按钮不指定 tab,回到上次停留的 Tab。 + */ +export function openAgentDrawer(context: AgentContext | null = null, tab: AgentDrawerTab = "current") { + tabLoaded = true; + state = { ...state, open: true, minimized: false, context, runId: null, tab }; + if (tab === "current") writeTab(tab); + emit(); +} + +export function setAgentDrawerTab(tab: AgentDrawerTab) { + tabLoaded = true; + state = { ...state, tab }; + writeTab(tab); + emit(); +} + +/** 历史 Tab:在抽屉内打开一条会话(null 回到列表) */ +export function openAgentHistoryRun(runId: string | null) { + state = { ...state, tab: "history", historyRunId: runId }; + emit(); +} + +/** 把一条会话放到「当前」(同目标新建后、从历史里继续时) */ +export function showAgentRunInDrawer(runId: string) { + tabLoaded = true; + state = { ...state, open: true, minimized: false, runId, tab: "current" }; + writeTab("current"); emit(); } @@ -40,9 +106,9 @@ export function minimizeAgentDrawer() { emit(); } -/** 从小窗恢复抽屉 */ +/** 从小窗恢复抽屉(小窗里的运行在「当前」) */ export function restoreAgentDrawer() { - state = { ...state, open: true, minimized: false }; + state = { ...state, open: true, minimized: false, tab: "current" }; emit(); } @@ -66,7 +132,7 @@ export function isAgentShortcut( export function toggleAgentDrawer(pathname: string) { if (state.open) setAgentDrawerOpen(false); else if (state.minimized && state.runId) restoreAgentDrawer(); - else openAgentDrawer(contextFromPath(pathname)); + else openAgentDrawer(contextFromPath(pathname), lastAgentDrawerTab()); } /** 按当前路由推断页面上下文(顶栏按钮用;页面内按钮直接传更完整的上下文) */ diff --git a/apps/web/app/components/agent/agent-subagent-tree.tsx b/apps/web/app/components/agent/agent-subagent-tree.tsx index 48dd03b1..a22ce036 100644 --- a/apps/web/app/components/agent/agent-subagent-tree.tsx +++ b/apps/web/app/components/agent/agent-subagent-tree.tsx @@ -1,6 +1,6 @@ import type { AgentFamilyUsage, AgentRunNode } from "@umt/contracts"; import { ChevronRight, CornerLeftUp, GitBranch } from "lucide-react"; -import { useState } from "react"; +import { createContext, type ReactNode, useContext, useState } from "react"; import { Link } from "react-router"; import { agentMockEnabled, type RunSnapshot } from "~/api/agent.ts"; import { cn } from "~/lib/utils.ts"; @@ -20,6 +20,27 @@ export function usageLine(u: { toolCalls: number; totalTokens?: number; costUsd? const href = (id: string) => `/console/agent/${id}${agentMockEnabled() ? "?mock=1" : ""}`; +/** 抽屉里提供:点父/子会话在抽屉内打开,不跳页 */ +export const AgentRunOpenContext = createContext<((runId: string) => void) | null>(null); + +const LINK_CLASS = + "line-clamp-2 cursor-pointer rounded-tight text-left text-caption break-words text-foreground outline-none hover:underline focus-visible:ring-2 focus-visible:ring-ring"; + +function RunLink({ id, children }: { id: string; children: ReactNode }) { + const open = useContext(AgentRunOpenContext); + if (open) + return ( + + ); + return ( + + {children} + + ); +} + function budgetText(b: Record) { const parts: string[] = []; if (b.maxToolCalls !== undefined) parts.push(`${b.maxToolCalls} 次调用`); @@ -49,12 +70,7 @@ function Node({ node, byParent, depth }: { node: AgentRunNode; byParent: Map )}
- - {splitContextGoal(node.goal).text} - + {splitContextGoal(node.goal).text}
@@ -82,7 +98,7 @@ function Node({ node, byParent, depth }: { node: AgentRunNode; byParent: Map(); @@ -93,19 +109,14 @@ export function SubagentTree({ snapshot }: { snapshot: RunSnapshot }) { const direct = byParent.get(run.id) ?? []; const caps = ((run.scope as { capabilities?: string[] }).capabilities ?? []).length; return ( -
+
{parent ? (

父会话

- - {splitContextGoal(parent.goal).text} - + {splitContextGoal(parent.goal).text}

第 {run.depth} 层子会话 · 继承父会话的发起人与授权上限 · 能力 {caps} 项(⊆ 父会话)· 划拨预算{" "} {budgetText(run.budget as Record)} @@ -115,7 +126,7 @@ export function SubagentTree({ snapshot }: { snapshot: RunSnapshot }) { ) : null} {direct.length ? (

-

+

子会话 {children.length} 个{familyUsage ? : null}

diff --git a/apps/web/app/components/agent/mock.ts b/apps/web/app/components/agent/mock.ts index 3aeae6c2..08cb2cbd 100644 --- a/apps/web/app/components/agent/mock.ts +++ b/apps/web/app/components/agent/mock.ts @@ -245,7 +245,15 @@ function seed() { const now = Date.now(); add("把 沙丘:第二部 的 4K 收进来", { proposeFirst: false }, now - 3_600_000, 1); add("对比一下 奥本海默 现有版本和站点上的 4K", { proposeFirst: true }, now - 7_200_000, 2); - const failed = add("检查 qb-main 为什么占满磁盘", { proposeFirst: false }, now - 86_400_000, 3); + const failed = add("巡检:检查 qb-main 为什么占满磁盘", { proposeFirst: false }, now - 86_400_000, 3); + failed.run = { ...failed.run, trigger: "schedule" }; + const evt = add( + "新资源到达:看看 奥本海默 的 REMUX 是否值得替换现有版本", + { proposeFirst: false }, + now - 3 * 86_400_000, + 4, + ); + evt.run = { ...evt.run, trigger: "event" }; failed.steps = [ { delayMs: 0, body: { type: "run_started", goal: failed.run.goal, resumed: false } }, { diff --git a/apps/web/app/components/agentui/tool-result.tsx b/apps/web/app/components/agentui/tool-result.tsx index 360226b8..1aed2aea 100644 --- a/apps/web/app/components/agentui/tool-result.tsx +++ b/apps/web/app/components/agentui/tool-result.tsx @@ -2,7 +2,8 @@ // Copyright (c) 2026 Saurabh Chauhan. MIT License, see ./LICENSE-agentui.md // 改造:状态色 blue/emerald/rose → 中性/success/destructive(并配文字);ActionSwapRollText 滚字去掉; // 折叠用 Disclosure;新增 `badges` 槽(scope/风险/幂等等审计徽标)与 `summary`(收起时的一行结果); -// 默认不在完成后自动展开历史卡片(回放时 defaultOpen=false),运行中的卡片展开、完成后按 collapseOnComplete 收起。 +// AGUI-2:收起态压成一行(能力名 · 状态 · 耗时 · 摘要),能力 ID 与徽标移进展开区; +// 默认一律收起(运行中也只显示一行状态);用户点开的卡片完成后保持展开(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"; @@ -61,8 +62,8 @@ export function ToolResult({ badges, summary, icon, - defaultOpen = status === "running", - collapseOnComplete = true, + defaultOpen = false, + collapseOnComplete = false, className, }: ToolResultProps) { const id = useId(); @@ -70,7 +71,6 @@ export function ToolResult({ 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]); @@ -88,28 +88,31 @@ export function ToolResult({ 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" + className="group flex min-h-9 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" > {icon ?? } - - {title} - {tool} - {meta ? {meta} : null} - + {/* 收起时一行:能力名 · 状态 · 耗时 · 摘要;展开后摘要让位给完整输入输出 */} + {title} {st.label} - + {meta ? {meta} : null} + + {!open && summary ? summary : null} + + - {badges ?
{badges}
: null} - {!open && summary ? ( -

{summary}

- ) : null} -
{children}
+
+
+ {tool} + {badges} +
+ {children} +
); diff --git a/apps/web/app/routes/console/agent.$runId.tsx b/apps/web/app/routes/console/agent.$runId.tsx index 031dd589..e918f9f3 100644 --- a/apps/web/app/routes/console/agent.$runId.tsx +++ b/apps/web/app/routes/console/agent.$runId.tsx @@ -1,35 +1,44 @@ -import type { AgentRunStatus } from "@umt/contracts"; import { ArrowLeft } from "lucide-react"; -import { useState } from "react"; import { Link, useParams } from "react-router"; import { agentMockEnabled, useAgentRun } from "~/api/agent.ts"; import { AgentRunPanel } from "~/components/agent/agent-run-panel.tsx"; import { AgentSessionList } from "~/components/agent/agent-session-list.tsx"; +import { splitContextGoal } from "~/components/agent/agent-store.ts"; import { PageHeader } from "~/components/console/page-header.tsx"; import { Button } from "~/components/ui/button.tsx"; +// 会话页:左栏会话列表(xl 以上)· 中间会话列(统一最大宽度,输入区固定在底部)· 右侧栏状态/步骤/预算(lg 以上; +// 更窄时换成会话列顶部的可折叠概况条)。 export default function AgentRunPage() { const { runId = "" } = useParams(); const run = useAgentRun(runId); const mock = agentMockEnabled(); - const [status, setStatus] = useState<"" | AgentRunStatus>(""); + const suffix = mock ? "?mock=1" : ""; return ( <> {run.data.run.goal} : "加载中…"} + description={ + run.data ? ( + + {splitContextGoal(run.data.run.goal).text} + + ) : ( + "加载中…" + ) + } actions={ } /> -
-