feat(agent): in-drawer history tab and denser session layout (AGUI-2)
- 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 <[email protected]>
This commit is contained in:
1 parent
ac478673bc
commit
20b63db254
14 files changed
+1234
-349
No files matched your search
@@ -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 (
|
||||
<div className="space-y-1.5">
|
||||
<div className="flex flex-wrap items-center gap-1.5">
|
||||
<RunStatusBadge status={status} />
|
||||
{isProposeFirst(run) ? <StatusBadge>先看方案再执行</StatusBadge> : null}
|
||||
{agentMockEnabled() ? (
|
||||
<StatusBadge tone="warning">
|
||||
<FlaskConical className="size-3" />
|
||||
mock 数据
|
||||
</StatusBadge>
|
||||
) : null}
|
||||
</div>
|
||||
<p className="text-meta break-words text-muted-foreground">
|
||||
{TRIGGER_LABEL[run.trigger]} · {formatDateTime(run.createdAt)}
|
||||
{run.model ? ` · ${run.provider ?? ""}/${run.model}` : ""}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 窄屏 / 抽屉的概况条:收起时一行(状态 · 步骤进度 · 当前步骤 · 用量),展开看步骤清单、子会话与预算。
|
||||
* 不 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 (
|
||||
<section aria-label="会话概况" className={cn("shrink-0 rounded-xl border border-border/70", className)}>
|
||||
<button
|
||||
type="button"
|
||||
aria-expanded={open}
|
||||
aria-controls={id}
|
||||
onClick={() => setOpen((v) => !v)}
|
||||
className="flex min-h-10 w-full min-w-0 cursor-pointer items-center gap-2 rounded-xl px-3 py-1.5 text-left text-meta outline-none transition-colors duration-micro hover:bg-foreground/[0.03] focus-visible:ring-2 focus-visible:ring-ring"
|
||||
>
|
||||
<RunStatusBadge status={status} />
|
||||
{steps.length ? (
|
||||
<>
|
||||
<ListChecks
|
||||
aria-hidden
|
||||
className={cn("size-3.5 shrink-0 text-muted-foreground", failed && "text-destructive")}
|
||||
/>
|
||||
<span className="shrink-0 text-foreground tabular-nums">
|
||||
步骤 {done}/{steps.length}
|
||||
</span>
|
||||
</>
|
||||
) : null}
|
||||
<span className="min-w-0 flex-1 truncate text-muted-foreground">
|
||||
{steps.length ? (current ? current.title : failed ? "有步骤失败" : "全部完成") : ""}
|
||||
</span>
|
||||
<span className="hidden shrink-0 text-muted-foreground tabular-nums sm:inline">{usage}</span>
|
||||
<ChevronDown aria-hidden className={cn("size-3.5 shrink-0", CHEVRON_CLASS, open && "rotate-180")} />
|
||||
</button>
|
||||
{steps.length ? (
|
||||
<div className="mx-3 mb-2 h-0.5 overflow-hidden rounded-full bg-foreground/[0.08]" aria-hidden>
|
||||
<div
|
||||
className={cn(
|
||||
"h-full rounded-full transition-[width] duration-layout ease-standard motion-reduce:transition-none",
|
||||
failed ? "bg-destructive" : "bg-foreground/60",
|
||||
)}
|
||||
style={{ width: `${Math.round((done / steps.length) * 100)}%` }}
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
<Disclosure id={id} open={open}>
|
||||
<div className="max-h-[50dvh] space-y-3 overflow-y-auto border-t border-border/60 p-3">
|
||||
<RunMeta run={r} status={status} />
|
||||
<RunPlan plan={live.plan} active={active} fill />
|
||||
<SubagentTree snapshot={snapshot} className="p-2.5" />
|
||||
<div>
|
||||
<p className="mb-2 text-meta text-muted-foreground">预算与用量</p>
|
||||
<BudgetBar run={r} live={live} />
|
||||
</div>
|
||||
</div>
|
||||
</Disclosure>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
/** 已结束的会话:用同一个目标新建(可切"先看方案再执行") */
|
||||
function NewFromGoal({ goal, onCreated }: { goal: string; onCreated: (runId: string) => void }) {
|
||||
const create = useCreateAgentRun();
|
||||
const [proposeFirst, setProposeFirst] = useState(false);
|
||||
return (
|
||||
<div className="flex flex-wrap items-center gap-1.5">
|
||||
<Button
|
||||
type="button"
|
||||
size="xs"
|
||||
variant="outline"
|
||||
disabled={create.isPending}
|
||||
onClick={() =>
|
||||
create.mutate(
|
||||
{ goal, proposeFirst },
|
||||
{ onSuccess: (run) => onCreated(run.id), onError: (e) => toast.error(errorMessage(e)) },
|
||||
)
|
||||
}
|
||||
>
|
||||
<RotateCcw />
|
||||
{create.isPending ? "新建中…" : "用同目标新建"}
|
||||
</Button>
|
||||
<ProposeFirstToggle checked={proposeFirst} onChange={setProposeFirst} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 一次运行。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 (
|
||||
<div className={cn("flex min-h-0 flex-col gap-3", className)}>
|
||||
<div className={cn("flex min-h-0 gap-4", className)}>
|
||||
<RunAnnouncer status={shownStatus} finished={live.finished} />
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<RunStatusBadge status={shownStatus} />
|
||||
{proposeFirst ? <StatusBadge>先看方案再执行</StatusBadge> : null}
|
||||
<span className="min-w-0 text-meta text-muted-foreground">
|
||||
{TRIGGER_LABEL[r.trigger]} · {formatDateTime(r.createdAt)}
|
||||
{r.model ? ` · ${r.provider ?? ""}/${r.model}` : ""}
|
||||
</span>
|
||||
{agentMockEnabled() ? (
|
||||
<StatusBadge tone="warning">
|
||||
<FlaskConical className="size-3" />
|
||||
mock 数据
|
||||
</StatusBadge>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div className={cn("rounded-xl border border-border/70 p-3", compact && "p-2.5")}>
|
||||
<BudgetBar run={r} live={live} />
|
||||
</div>
|
||||
|
||||
<SubagentTree snapshot={run.data} />
|
||||
|
||||
<RunPlan plan={live.plan} active={streaming || r.status === "waiting"} compact={compact} />
|
||||
|
||||
{streaming && sseStatus === "reconnecting" ? (
|
||||
<div
|
||||
role="status"
|
||||
className="flex items-center gap-2 rounded-xl border border-warning/40 bg-warning/10 px-3 py-2 text-caption text-warning"
|
||||
>
|
||||
<WifiOff aria-hidden className="size-4 shrink-0" />
|
||||
<span className="min-w-0 flex-1">实时连接中断,正在重连。期间每 2.5 秒轮询一次。</span>
|
||||
</div>
|
||||
) : null}
|
||||
{live.error ? (
|
||||
<div
|
||||
role="alert"
|
||||
className="flex flex-wrap items-center gap-2 rounded-xl border border-destructive/40 bg-destructive/10 px-3 py-2 text-caption text-destructive"
|
||||
>
|
||||
<span className="min-w-0 flex-1 break-words">事件读取失败:{live.error}</span>
|
||||
<Button size="xs" variant="outline" onClick={live.retry}>
|
||||
<RefreshCw />
|
||||
重试
|
||||
</Button>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<MessageScroller
|
||||
className={cn("min-h-64 flex-1", compact && "min-h-48")}
|
||||
viewportClassName="pr-1"
|
||||
busy={streaming}
|
||||
label="Agent 会话记录"
|
||||
>
|
||||
<AgentConversation
|
||||
run={r}
|
||||
<div className="@container flex min-h-0 min-w-0 flex-1 flex-col gap-3">
|
||||
<RunOverviewBar
|
||||
snapshot={run.data}
|
||||
status={shownStatus}
|
||||
live={live}
|
||||
audit={audit.data}
|
||||
userMessages={messages.map((m) => ({ at: m.createdAt, text: m.text, pending: m.status === "pending" }))}
|
||||
after={
|
||||
<>
|
||||
{approvals.items
|
||||
.filter((a) => !inline.has(a.id))
|
||||
.map((a) => (
|
||||
<ApprovalItem key={a.id} approval={a} />
|
||||
))}
|
||||
{finished ? <FinalReport run={r} report={report} live={live} /> : null}
|
||||
</>
|
||||
}
|
||||
active={streaming || r.status === "waiting"}
|
||||
className={cn(column, !compact && "lg:hidden")}
|
||||
/>
|
||||
</MessageScroller>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<PromptInput
|
||||
value={msg}
|
||||
onValueChange={setMsg}
|
||||
onSubmit={send}
|
||||
pending={append.isPending}
|
||||
loading={streaming}
|
||||
onStop={() => setConfirmStop(true)}
|
||||
stopLabel={abort.isPending ? "停止中…" : "停止"}
|
||||
minRows={1}
|
||||
maxRows={6}
|
||||
maxLength={8000}
|
||||
aria-label="追加消息"
|
||||
placeholder={streaming ? "补充或纠正(运行中也可追加)" : "追加消息"}
|
||||
/>
|
||||
<p className="px-1 text-meta text-muted-foreground">
|
||||
{r.status === "waiting" && (r.summary === "等待选择方案" || r.summary === "等待审批")
|
||||
? `${r.summary}:在上面的卡片里处理,会话随即继续。`
|
||||
: "追加的消息在本轮结束后读取。"}{" "}
|
||||
Enter 发送,Shift+Enter 换行{streaming ? ",停止 = 结束本次运行" : ""}。
|
||||
</p>
|
||||
{streaming && sseStatus === "reconnecting" ? (
|
||||
<div
|
||||
role="status"
|
||||
className={cn(
|
||||
column,
|
||||
"flex items-center gap-2 rounded-xl border border-warning/40 bg-warning/10 px-3 py-2 text-caption text-warning",
|
||||
)}
|
||||
>
|
||||
<WifiOff aria-hidden className="size-4 shrink-0" />
|
||||
<span className="min-w-0 flex-1">实时连接中断,正在重连。期间每 2.5 秒轮询一次。</span>
|
||||
</div>
|
||||
) : null}
|
||||
{live.error ? (
|
||||
<div
|
||||
role="alert"
|
||||
className={cn(
|
||||
column,
|
||||
"flex flex-wrap items-center gap-2 rounded-xl border border-destructive/40 bg-destructive/10 px-3 py-2 text-caption text-destructive",
|
||||
)}
|
||||
>
|
||||
<span className="min-w-0 flex-1 break-words">事件读取失败:{live.error}</span>
|
||||
<Button size="xs" variant="outline" onClick={live.retry}>
|
||||
<RefreshCw />
|
||||
重试
|
||||
</Button>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<MessageScroller
|
||||
className={cn("min-h-48 flex-1", compact && "min-h-40")}
|
||||
viewportClassName="pr-1"
|
||||
contentClassName={column}
|
||||
busy={streaming}
|
||||
label="Agent 会话记录"
|
||||
>
|
||||
<AgentConversation
|
||||
run={r}
|
||||
live={live}
|
||||
audit={audit.data}
|
||||
userMessages={messages.map((m) => ({ at: m.createdAt, text: m.text, pending: m.status === "pending" }))}
|
||||
after={
|
||||
<>
|
||||
{approvals.items
|
||||
.filter((a) => !inline.has(a.id))
|
||||
.map((a) => (
|
||||
<ApprovalItem key={a.id} approval={a} />
|
||||
))}
|
||||
{finished ? <FinalReport run={r} report={report} live={live} /> : null}
|
||||
</>
|
||||
}
|
||||
/>
|
||||
</MessageScroller>
|
||||
|
||||
<div className={cn(column, "shrink-0 space-y-1")}>
|
||||
<PromptInput
|
||||
value={msg}
|
||||
onValueChange={setMsg}
|
||||
onSubmit={send}
|
||||
pending={append.isPending}
|
||||
loading={streaming}
|
||||
onStop={() => setConfirmStop(true)}
|
||||
stopLabel={abort.isPending ? "停止中…" : "停止"}
|
||||
minRows={1}
|
||||
maxRows={6}
|
||||
maxLength={8000}
|
||||
aria-label={ended ? "追加消息续跑" : "追加消息"}
|
||||
placeholder={ended ? "追加消息,在本会话里续跑" : "补充或纠正(运行中也可追加)"}
|
||||
submitLabel={ended ? "续跑" : "发送"}
|
||||
toolbar={
|
||||
ended ? (
|
||||
<NewFromGoal goal={r.goal} onCreated={created} />
|
||||
) : isProposeFirst(r) ? (
|
||||
<span className="px-1 text-meta text-muted-foreground">先看方案再执行</span>
|
||||
) : null
|
||||
}
|
||||
/>
|
||||
<p className="px-1 text-meta text-muted-foreground">
|
||||
{r.status === "waiting" && (r.summary === "等待选择方案" || r.summary === "等待审批")
|
||||
? `${r.summary}:在会话里的卡片上处理,会话随即继续。`
|
||||
: ended
|
||||
? "续跑沿用本会话的上下文与预算。"
|
||||
: "追加的消息在本轮结束后读取。"}{" "}
|
||||
Enter 发送,Shift+Enter 换行。
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{compact ? null : (
|
||||
<aside
|
||||
aria-label="会话概况"
|
||||
className="hidden w-72 shrink-0 flex-col gap-4 overflow-y-auto border-l border-[var(--shell-border)] pl-4 lg:flex"
|
||||
>
|
||||
<RunMeta run={r} status={shownStatus} />
|
||||
<RunPlan plan={live.plan} active={streaming || r.status === "waiting"} fill />
|
||||
<SubagentTree snapshot={run.data} className="p-2.5" />
|
||||
<section aria-label="预算与用量" className="mt-auto rounded-xl border border-border/70 p-3">
|
||||
<p className="mb-2 text-meta text-muted-foreground">预算与用量</p>
|
||||
<BudgetBar run={r} live={live} stacked />
|
||||
</section>
|
||||
</aside>
|
||||
)}
|
||||
|
||||
<ConfirmDialog
|
||||
open={confirmStop}
|
||||
onOpenChange={setConfirmStop}
|
||||
|
||||
Reference in new issue
Block a user