merge: ui2/s-agent-ui
Co-Authored-By: Claude Opus 5.5 <[email protected]> Claude-Session: https://claude.ai/code/session_01Cxwgnn2pbzQ7rXv3rq7fei
This commit is contained in:
commit
3df9f35ebe
12 files changed
+732
-68
No files matched your search
@@ -141,22 +141,34 @@ export const TRIGGER_LABEL: Record<AgentTrigger, string> = {
|
|||||||
export type AgentRunGroup = "conversation" | "auto";
|
export type AgentRunGroup = "conversation" | "auto";
|
||||||
const isAutoTrigger = (t: AgentTrigger) => t === "schedule" || t === "event";
|
const isAutoTrigger = (t: AgentTrigger) => t === "schedule" || t === "event";
|
||||||
|
|
||||||
|
/** 会话所在分栏:子会话随父会话(与服务端 group 筛选一致);父会话未知时子会话归「对话」 */
|
||||||
|
export function runGroup(
|
||||||
|
run: Pick<AgentRunView, "trigger">,
|
||||||
|
parent?: Pick<AgentRunView, "trigger"> | null,
|
||||||
|
): AgentRunGroup {
|
||||||
|
const t = run.trigger === "subagent" && parent ? parent.trigger : run.trigger;
|
||||||
|
return isAutoTrigger(t) ? "auto" : "conversation";
|
||||||
|
}
|
||||||
|
|
||||||
export function useAgentRuns(status?: AgentRunStatus, trigger?: AgentTrigger, group?: AgentRunGroup) {
|
export function useAgentRuns(status?: AgentRunStatus, trigger?: AgentTrigger, group?: AgentRunGroup) {
|
||||||
const mock = useAgentMockGate();
|
const mock = useAgentMockGate();
|
||||||
return useInfiniteQuery({
|
return useInfiniteQuery({
|
||||||
queryKey: [...agentKeys.runs(status, trigger, group), mock],
|
queryKey: [...agentKeys.runs(status, trigger, group), mock],
|
||||||
initialPageParam: undefined as string | undefined,
|
initialPageParam: undefined as string | undefined,
|
||||||
queryFn: async ({ pageParam, signal }) => {
|
queryFn: async ({ pageParam, signal }) => {
|
||||||
if (mock)
|
if (mock) {
|
||||||
|
const all = MOCK_RUNS();
|
||||||
|
const byId = new Map(all.map((r) => [r.id, r] as const));
|
||||||
return {
|
return {
|
||||||
items: MOCK_RUNS().filter(
|
items: all.filter(
|
||||||
(r) =>
|
(r) =>
|
||||||
(!status || r.status === status) &&
|
(!status || r.status === status) &&
|
||||||
(!trigger || r.trigger === trigger) &&
|
(!trigger || r.trigger === trigger) &&
|
||||||
(!group || isAutoTrigger(r.trigger) === (group === "auto")),
|
(!group || runGroup(r, r.parentRunId ? byId.get(r.parentRunId) : null) === group),
|
||||||
),
|
),
|
||||||
nextCursor: null,
|
nextCursor: null,
|
||||||
};
|
};
|
||||||
|
}
|
||||||
return apiParsed(RunPage, AGENT_BASE, {
|
return apiParsed(RunPage, AGENT_BASE, {
|
||||||
params: cleanParams({ status, trigger, group, cursor: pageParam, limit: 30 }),
|
params: cleanParams({ status, trigger, group, cursor: pageParam, limit: 30 }),
|
||||||
signal,
|
signal,
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { Bot, ExternalLink, History, Maximize2, Minus, SquarePen, X } from "lucide-react";
|
import { Bot, ExternalLink, History, Maximize2, Minus, SquarePen, X } from "lucide-react";
|
||||||
import { useRef, useState } from "react";
|
import { useEffect, useRef, useState } from "react";
|
||||||
import { Link, useLocation } from "react-router";
|
import { Link, useLocation } from "react-router";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { agentMockEnabled, isRunActive, useAgentRun, useCreateAgentRun } from "~/api/agent.ts";
|
import { agentMockEnabled, isRunActive, useAgentRun, useCreateAgentRun } from "~/api/agent.ts";
|
||||||
@@ -10,24 +10,27 @@ import { useSite } from "~/api/sites.ts";
|
|||||||
import { StreamingCursor } from "~/components/agentui/streaming-response.tsx";
|
import { StreamingCursor } from "~/components/agentui/streaming-response.tsx";
|
||||||
import { Button } from "~/components/ui/button.tsx";
|
import { Button } from "~/components/ui/button.tsx";
|
||||||
import { Popover, PopoverContent, PopoverTrigger } from "~/components/ui/popover.tsx";
|
import { Popover, PopoverContent, PopoverTrigger } from "~/components/ui/popover.tsx";
|
||||||
import { Sheet, SheetContent, SheetDescription, SheetHeader, SheetTitle } from "~/components/ui/sheet.tsx";
|
import { Sheet, SheetClose, SheetContent, SheetDescription, SheetHeader, SheetTitle } from "~/components/ui/sheet.tsx";
|
||||||
import { errorMessage } from "~/lib/api.ts";
|
import { errorMessage } from "~/lib/api.ts";
|
||||||
import { capabilityMeta, isDenied, RunStatusBadge } from "./agent-conversation.tsx";
|
import { capabilityMeta, isDenied, RunStatusBadge } from "./agent-conversation.tsx";
|
||||||
|
import { DRAFT_DRAWER_GOAL, writeDraft } from "./agent-nav.ts";
|
||||||
import { AgentRunPanel, GoalComposer } from "./agent-run-panel.tsx";
|
import { AgentRunPanel, GoalComposer } from "./agent-run-panel.tsx";
|
||||||
import { AgentSessionList } from "./agent-session-list.tsx";
|
import { AgentSessionList, drawerListMemory } from "./agent-session-list.tsx";
|
||||||
import {
|
import {
|
||||||
type AgentContext,
|
type AgentContext,
|
||||||
agentShortcutLabel,
|
agentShortcutLabel,
|
||||||
clearAgentContext,
|
clearAgentContext,
|
||||||
contextFromPath,
|
|
||||||
contextPrefix,
|
contextPrefix,
|
||||||
minimizeAgentDrawer,
|
minimizeAgentDrawer,
|
||||||
newAgentConversation,
|
newAgentConversation,
|
||||||
openAgentDrawer,
|
noteAgentReturnPath,
|
||||||
restoreAgentDrawer,
|
restoreAgentDrawer,
|
||||||
setAgentDrawerOpen,
|
setAgentDrawerOpen,
|
||||||
shortGoalTitle,
|
shortGoalTitle,
|
||||||
showAgentRunInDrawer,
|
showAgentRunInDrawer,
|
||||||
|
syncAgentContextToPath,
|
||||||
|
takeReturnFocus,
|
||||||
|
toggleAgentDrawer,
|
||||||
useAgentDrawer,
|
useAgentDrawer,
|
||||||
} from "./agent-store.ts";
|
} from "./agent-store.ts";
|
||||||
import { AgentRunOpenContext } from "./agent-subagent-tree.tsx";
|
import { AgentRunOpenContext } from "./agent-subagent-tree.tsx";
|
||||||
@@ -71,7 +74,7 @@ function HistoryPopover({ activeId }: { activeId: string | null }) {
|
|||||||
</PopoverTrigger>
|
</PopoverTrigger>
|
||||||
<PopoverContent align="end" className="flex h-[min(32rem,70dvh)] w-[min(22rem,calc(100vw-2rem))] flex-col p-2">
|
<PopoverContent align="end" className="flex h-[min(32rem,70dvh)] w-[min(22rem,calc(100vw-2rem))] flex-col p-2">
|
||||||
<AgentSessionList
|
<AgentSessionList
|
||||||
remember
|
memory={drawerListMemory}
|
||||||
limit={20}
|
limit={20}
|
||||||
activeId={activeId ?? undefined}
|
activeId={activeId ?? undefined}
|
||||||
onOpen={(id) => {
|
onOpen={(id) => {
|
||||||
@@ -98,8 +101,11 @@ function HistoryPopover({ activeId }: { activeId: string | null }) {
|
|||||||
export function AgentDrawer() {
|
export function AgentDrawer() {
|
||||||
const { open, minimized, context, runId } = useAgentDrawer();
|
const { open, minimized, context, runId } = useAgentDrawer();
|
||||||
const create = useCreateAgentRun();
|
const create = useCreateAgentRun();
|
||||||
const { pathname } = useLocation();
|
const { pathname, search } = useLocation();
|
||||||
const content = useRef<HTMLDivElement>(null);
|
const content = useRef<HTMLDivElement>(null);
|
||||||
|
// 记下最近的非智能体页面:会话页「在侧边打开」回到这里
|
||||||
|
useEffect(() => noteAgentReturnPath(pathname, search), [pathname, search]);
|
||||||
|
useEffect(() => syncAgentContextToPath(pathname), [pathname]);
|
||||||
const name = useContextName(context);
|
const name = useContextName(context);
|
||||||
const mock = agentMockEnabled();
|
const mock = agentMockEnabled();
|
||||||
// 目标前缀带名称和 ID:名称给人看,ID 给模型用
|
// 目标前缀带名称和 ID:名称给人看,ID 给模型用
|
||||||
@@ -111,6 +117,7 @@ export function AgentDrawer() {
|
|||||||
<SheetContent
|
<SheetContent
|
||||||
ref={content}
|
ref={content}
|
||||||
side="right"
|
side="right"
|
||||||
|
showCloseButton={false}
|
||||||
className="w-full gap-0 p-0 sm:max-w-xl"
|
className="w-full gap-0 p-0 sm:max-w-xl"
|
||||||
// 打开即可输入:焦点直接给输入框,没有输入框时退回抽屉本身
|
// 打开即可输入:焦点直接给输入框,没有输入框时退回抽屉本身
|
||||||
onOpenAutoFocus={(e) => {
|
onOpenAutoFocus={(e) => {
|
||||||
@@ -120,11 +127,18 @@ export function AgentDrawer() {
|
|||||||
ta.focus();
|
ta.focus();
|
||||||
ta.setSelectionRange(ta.value.length, ta.value.length);
|
ta.setSelectionRange(ta.value.length, ta.value.length);
|
||||||
}}
|
}}
|
||||||
|
// 关闭后焦点回到打开前的位置(快捷键打开没有触发元素,缺省会落到 body)
|
||||||
|
onCloseAutoFocus={(e) => {
|
||||||
|
const el = takeReturnFocus();
|
||||||
|
if (!el) return;
|
||||||
|
e.preventDefault();
|
||||||
|
el.focus();
|
||||||
|
}}
|
||||||
onInteractOutside={(e) => {
|
onInteractOutside={(e) => {
|
||||||
if ((e.target as Element | null)?.closest?.(`[${AGENT_LAUNCHER_ATTR}]`)) e.preventDefault();
|
if ((e.target as Element | null)?.closest?.(`[${AGENT_LAUNCHER_ATTR}]`)) e.preventDefault();
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<SheetHeader className="flex-row items-center gap-1 border-b border-[var(--shell-border)] py-2.5 pr-12 pl-5">
|
<SheetHeader className="flex-row items-center gap-1 border-b border-[var(--shell-border)] py-2.5 pr-3 pl-5">
|
||||||
<SheetTitle className="mr-auto flex items-center gap-2 text-lead">
|
<SheetTitle className="mr-auto flex items-center gap-2 text-lead">
|
||||||
<Bot className="h-4 w-4" />
|
<Bot className="h-4 w-4" />
|
||||||
智能体
|
智能体
|
||||||
@@ -156,6 +170,12 @@ export function AgentDrawer() {
|
|||||||
</Button>
|
</Button>
|
||||||
</>
|
</>
|
||||||
) : null}
|
) : null}
|
||||||
|
{/* 关闭钮放进标题栏这一行,与其它按钮对齐(Sheet 自带的是绝对定位,偏下 5px) */}
|
||||||
|
<SheetClose asChild>
|
||||||
|
<Button variant="ghost" size="icon-sm" aria-label="关闭" title="关闭(Esc)">
|
||||||
|
<X />
|
||||||
|
</Button>
|
||||||
|
</SheetClose>
|
||||||
</SheetHeader>
|
</SheetHeader>
|
||||||
<div className="flex min-h-0 flex-1 flex-col px-4 py-3">
|
<div className="flex min-h-0 flex-1 flex-col px-4 py-3">
|
||||||
{runId ? (
|
{runId ? (
|
||||||
@@ -170,12 +190,16 @@ export function AgentDrawer() {
|
|||||||
context={name}
|
context={name}
|
||||||
onClearContext={clearAgentContext}
|
onClearContext={clearAgentContext}
|
||||||
defaultGoal={context?.goal ?? ""}
|
defaultGoal={context?.goal ?? ""}
|
||||||
|
draftKey={DRAFT_DRAWER_GOAL}
|
||||||
pending={create.isPending}
|
pending={create.isPending}
|
||||||
onSubmit={(goal, opts) =>
|
onSubmit={(goal, opts) =>
|
||||||
create.mutate(
|
create.mutate(
|
||||||
{ goal: `${prefix}${goal}`, ...opts },
|
{ goal: `${prefix}${goal}`, ...opts },
|
||||||
{
|
{
|
||||||
onSuccess: (run) => showAgentRunInDrawer(run.id),
|
onSuccess: (run) => {
|
||||||
|
writeDraft(DRAFT_DRAWER_GOAL, "");
|
||||||
|
showAgentRunInDrawer(run.id);
|
||||||
|
},
|
||||||
onError: (e) => toast.error(errorMessage(e)),
|
onError: (e) => toast.error(errorMessage(e)),
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
@@ -253,9 +277,8 @@ function AgentMiniDock({ runId }: { runId: string }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 顶栏按钮:有最小化的运行时恢复它;否则按当前路由带上下文打开(页面内按钮用 openAgentDrawer 传更完整的上下文) */
|
/** 顶栏按钮(窄屏):与快捷键、悬浮按钮同一套切换(页面内按钮用 openAgentDrawer 传更完整的上下文) */
|
||||||
export function AgentDrawerTrigger({ className }: { className?: string }) {
|
export function AgentDrawerTrigger({ className }: { className?: string }) {
|
||||||
const { minimized, runId } = useAgentDrawer();
|
|
||||||
const { pathname } = useLocation();
|
const { pathname } = useLocation();
|
||||||
return (
|
return (
|
||||||
<Button
|
<Button
|
||||||
@@ -264,7 +287,8 @@ export function AgentDrawerTrigger({ className }: { className?: string }) {
|
|||||||
aria-label="打开智能体"
|
aria-label="打开智能体"
|
||||||
title={`智能体(${agentShortcutLabel()})`}
|
title={`智能体(${agentShortcutLabel()})`}
|
||||||
className={className}
|
className={className}
|
||||||
onClick={() => (minimized && runId ? restoreAgentDrawer() : openAgentDrawer(contextFromPath(pathname)))}
|
{...{ [AGENT_LAUNCHER_ATTR]: "" }}
|
||||||
|
onClick={() => toggleAgentDrawer(pathname)}
|
||||||
>
|
>
|
||||||
<Bot />
|
<Bot />
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
@@ -0,0 +1,220 @@
|
|||||||
|
import type { AgentRunEvent } from "@umt/contracts";
|
||||||
|
import { createElement } from "react";
|
||||||
|
import { renderToStaticMarkup } from "react-dom/server";
|
||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { runGroup } from "~/api/agent.ts";
|
||||||
|
import { followBehavior } from "~/components/agentui/message-scroller.tsx";
|
||||||
|
import {
|
||||||
|
agentListHref,
|
||||||
|
agentRunHref,
|
||||||
|
DRAFT_DRAWER_GOAL,
|
||||||
|
DRAFT_PAGE_GOAL,
|
||||||
|
groupFromSearch,
|
||||||
|
readDraft,
|
||||||
|
resolveGroup,
|
||||||
|
revealScrollTop,
|
||||||
|
runDraftKey,
|
||||||
|
writeDraft,
|
||||||
|
} from "./agent-nav.ts";
|
||||||
|
import { CompactPager } from "./agent-session-list.tsx";
|
||||||
|
import {
|
||||||
|
agentReturnPath,
|
||||||
|
clearAgentContext,
|
||||||
|
isAgentPage,
|
||||||
|
noteAgentReturnPath,
|
||||||
|
openAgentDrawer,
|
||||||
|
outletKey,
|
||||||
|
setAgentDrawerOpen,
|
||||||
|
showAgentRunInDrawer,
|
||||||
|
syncAgentContextToPath,
|
||||||
|
toggleAction,
|
||||||
|
useAgentDrawer,
|
||||||
|
} from "./agent-store.ts";
|
||||||
|
import { cachedRunEvents, cacheRunEvents } from "./use-agent-stream.ts";
|
||||||
|
|
||||||
|
// 智能体页面切换的状态逻辑:整页重挂载 key、分栏与 URL、草稿、滚动策略、快捷键在智能体页面的行为。
|
||||||
|
|
||||||
|
describe("主内容区重挂载 key", () => {
|
||||||
|
it("列表页与各会话页共用一个 key,切换会话不重挂载", () => {
|
||||||
|
expect(outletKey("/console/agent")).toBe("/console/agent");
|
||||||
|
expect(outletKey("/console/agent/0b1c")).toBe("/console/agent");
|
||||||
|
expect(outletKey("/console/agent/9f2e")).toBe(outletKey("/console/agent/0b1c"));
|
||||||
|
});
|
||||||
|
it("其它页面仍按路径重挂载", () => {
|
||||||
|
expect(outletKey("/console/library")).toBe("/console/library");
|
||||||
|
expect(outletKey("/console/agents-x")).toBe("/console/agents-x");
|
||||||
|
expect(isAgentPage("/console/agentx")).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("分栏:对话 / 自动巡检", () => {
|
||||||
|
it("会话所在分栏:巡检 / 事件在自动巡检,子会话随父会话,父会话未知归对话", () => {
|
||||||
|
expect(runGroup({ trigger: "schedule" })).toBe("auto");
|
||||||
|
expect(runGroup({ trigger: "event" })).toBe("auto");
|
||||||
|
expect(runGroup({ trigger: "user" })).toBe("conversation");
|
||||||
|
expect(runGroup({ trigger: "subagent" }, { trigger: "schedule" })).toBe("auto");
|
||||||
|
expect(runGroup({ trigger: "subagent" }, { trigger: "user" })).toBe("conversation");
|
||||||
|
expect(runGroup({ trigger: "subagent" })).toBe("conversation");
|
||||||
|
});
|
||||||
|
it("URL 的 tab 只认两个取值", () => {
|
||||||
|
expect(groupFromSearch("?tab=auto&mock=1")).toBe("auto");
|
||||||
|
expect(groupFromSearch("?tab=conversation")).toBe("conversation");
|
||||||
|
expect(groupFromSearch("?tab=xx")).toBeNull();
|
||||||
|
expect(groupFromSearch("")).toBeNull();
|
||||||
|
});
|
||||||
|
it("取值顺序:URL > 当前会话 > 上次 > 对话", () => {
|
||||||
|
expect(resolveGroup({ fromUrl: "conversation", activeRun: "auto", remembered: "auto" })).toBe("conversation");
|
||||||
|
expect(resolveGroup({ fromUrl: null, activeRun: "auto", remembered: "conversation" })).toBe("auto");
|
||||||
|
// 打开巡检会话、快照还没到:沿用点它时所在的分栏,不先闪到「对话」
|
||||||
|
expect(resolveGroup({ fromUrl: null, activeRun: null, remembered: "auto" })).toBe("auto");
|
||||||
|
expect(resolveGroup({ fromUrl: null })).toBe("conversation");
|
||||||
|
});
|
||||||
|
it("链接:会话页不带 tab(由会话决定),列表页只在自动巡检时带,保留演示开关", () => {
|
||||||
|
expect(agentRunHref("r1")).toBe("/console/agent/r1");
|
||||||
|
expect(agentRunHref("r1", true)).toBe("/console/agent/r1?mock=1");
|
||||||
|
expect(agentListHref()).toBe("/console/agent");
|
||||||
|
expect(agentListHref("auto")).toBe("/console/agent?tab=auto");
|
||||||
|
expect(agentListHref("auto", true)).toBe("/console/agent?tab=auto&mock=1");
|
||||||
|
expect(agentListHref("conversation", true)).toBe("/console/agent?mock=1");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("当前会话滚进可见区", () => {
|
||||||
|
const vp = { scrollTop: 200, height: 300 };
|
||||||
|
it("已经完整可见不滚", () => {
|
||||||
|
expect(revealScrollTop({ top: 250, height: 40 }, vp)).toBeNull();
|
||||||
|
});
|
||||||
|
it("在上方:滚到顶边对齐;在下方:滚到底边刚好露出", () => {
|
||||||
|
expect(revealScrollTop({ top: 120, height: 40 }, vp)).toBe(120);
|
||||||
|
expect(revealScrollTop({ top: 600, height: 40 }, vp)).toBe(340);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("会话列表分页(窄列表一行)", () => {
|
||||||
|
const pager = (p: Partial<Parameters<typeof CompactPager>[0]>) =>
|
||||||
|
renderToStaticMarkup(
|
||||||
|
createElement(CompactPager, { page: 1, pageSize: 20, total: 0, onPageChange: () => {}, ...p }),
|
||||||
|
);
|
||||||
|
it("只有一页不显示", () => {
|
||||||
|
expect(pager({ total: 12 })).toBe("");
|
||||||
|
});
|
||||||
|
it("多页:总数 + 当前页 / 页数,没有每页条数选择", () => {
|
||||||
|
const html = pager({ total: 48, page: 2 });
|
||||||
|
expect(html).toContain("共 48 条");
|
||||||
|
expect(html).toContain("2 / 3");
|
||||||
|
expect(html).not.toContain("每页");
|
||||||
|
});
|
||||||
|
it("还能往后加载时标出已加载条数和「+」", () => {
|
||||||
|
const html = pager({ total: 20, hasMore: true });
|
||||||
|
expect(html).toContain("已加载 20 条");
|
||||||
|
expect(html).toContain("1 / 1+");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("输入草稿", () => {
|
||||||
|
it("按会话分开记,切走再回来还在,不串到别的会话", () => {
|
||||||
|
writeDraft(runDraftKey("a"), "A 的草稿");
|
||||||
|
writeDraft(runDraftKey("b"), "B 的草稿");
|
||||||
|
expect(readDraft(runDraftKey("a"))).toBe("A 的草稿");
|
||||||
|
expect(readDraft(runDraftKey("b"))).toBe("B 的草稿");
|
||||||
|
expect(readDraft(runDraftKey("c"))).toBe("");
|
||||||
|
});
|
||||||
|
it("清空(发送成功)后不再恢复;只有空白也算清空", () => {
|
||||||
|
writeDraft(runDraftKey("a"), "");
|
||||||
|
writeDraft(runDraftKey("b"), " ");
|
||||||
|
expect(readDraft(runDraftKey("a"))).toBe("");
|
||||||
|
expect(readDraft(runDraftKey("b"))).toBe("");
|
||||||
|
});
|
||||||
|
it("列表页与抽屉的新对话输入框各记各的;没有键时不记", () => {
|
||||||
|
writeDraft(DRAFT_PAGE_GOAL, "页面里写了一半");
|
||||||
|
writeDraft(DRAFT_DRAWER_GOAL, "抽屉里写了一半");
|
||||||
|
writeDraft(undefined, "不记");
|
||||||
|
expect(readDraft(DRAFT_PAGE_GOAL)).toBe("页面里写了一半");
|
||||||
|
expect(readDraft(DRAFT_DRAWER_GOAL)).toBe("抽屉里写了一半");
|
||||||
|
expect(readDraft(undefined)).toBe("");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("消息区滚动策略", () => {
|
||||||
|
it("流式的小段增长平滑跟随", () => {
|
||||||
|
expect(followBehavior({ gap: 48, viewport: 600, reduce: false })).toBe("smooth");
|
||||||
|
});
|
||||||
|
it("一次多出一屏以上(打开 / 切回会话铺开历史)直接跳到底", () => {
|
||||||
|
expect(followBehavior({ gap: 1400, viewport: 600, reduce: false })).toBe("auto");
|
||||||
|
});
|
||||||
|
it("减少动态效果时一律直接跳", () => {
|
||||||
|
expect(followBehavior({ gap: 48, viewport: 600, reduce: true })).toBe("auto");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("切回看过的会话:事件缓存", () => {
|
||||||
|
const ev = (seq: number) => ({ seq, at: "2026-10-06T00:00:00Z", type: "text_delta", delta: "x" }) as AgentRunEvent;
|
||||||
|
it("记住最近读到的事件,切回时直接可用", () => {
|
||||||
|
cacheRunEvents("live:a", [ev(0), ev(1)]);
|
||||||
|
expect(cachedRunEvents("live:a").map((e) => e.seq)).toEqual([0, 1]);
|
||||||
|
expect(cachedRunEvents("mock:a")).toEqual([]);
|
||||||
|
});
|
||||||
|
it("空列表不覆盖已有缓存;超过 30 条会话丢掉最早看的", () => {
|
||||||
|
cacheRunEvents("live:a", []);
|
||||||
|
expect(cachedRunEvents("live:a")).toHaveLength(2);
|
||||||
|
for (let i = 0; i < 30; i++) cacheRunEvents(`live:n${i}`, [ev(i)]);
|
||||||
|
expect(cachedRunEvents("live:a")).toEqual([]);
|
||||||
|
expect(cachedRunEvents("live:n29")).toHaveLength(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("Ctrl+J / 智能体入口在不同页面", () => {
|
||||||
|
it("抽屉开着:收起", () => {
|
||||||
|
expect(toggleAction({ open: true, runId: "r" }, "/console/library")).toBe("close");
|
||||||
|
expect(toggleAction({ open: true, runId: null }, "/console/agent/r")).toBe("close");
|
||||||
|
});
|
||||||
|
it("已在智能体页面:聚焦页面输入框,不在抽屉里再开一份同一会话", () => {
|
||||||
|
expect(toggleAction({ open: false, runId: "r" }, "/console/agent/r")).toBe("focus-page");
|
||||||
|
expect(toggleAction({ open: false, runId: null }, "/console/agent")).toBe("focus-page");
|
||||||
|
});
|
||||||
|
it("其它页面:上次的会话还在就接着看,否则开新对话", () => {
|
||||||
|
expect(toggleAction({ open: false, runId: "r" }, "/console/library")).toBe("restore");
|
||||||
|
expect(toggleAction({ open: false, runId: null }, "/console/library")).toBe("open");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("会话页「在侧边打开」回到之前的页面", () => {
|
||||||
|
it("只记非智能体页面,带查询串", () => {
|
||||||
|
noteAgentReturnPath("/console/library", "?filter=unidentified");
|
||||||
|
noteAgentReturnPath("/console/agent");
|
||||||
|
noteAgentReturnPath("/console/agent/r1", "?tab=auto");
|
||||||
|
expect(agentReturnPath()).toBe("/console/library?filter=unidentified");
|
||||||
|
noteAgentReturnPath("/console/sites");
|
||||||
|
expect(agentReturnPath()).toBe("/console/sites");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("抽屉开着时路由变化", () => {
|
||||||
|
// useAgentDrawer 是 useSyncExternalStore,测试里直接读快照
|
||||||
|
const snapshot = () => {
|
||||||
|
let out: ReturnType<typeof useAgentDrawer> | null = null;
|
||||||
|
const Probe = () => {
|
||||||
|
out = useAgentDrawer();
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
renderToStaticMarkup(createElement(Probe));
|
||||||
|
return out as unknown as ReturnType<typeof useAgentDrawer>;
|
||||||
|
};
|
||||||
|
it("新对话的路由上下文跟着页面换", () => {
|
||||||
|
openAgentDrawer({ label: "作品 aaaa", entityId: "aaaa", fromPath: true });
|
||||||
|
syncAgentContextToPath("/console/sites/hdh");
|
||||||
|
expect(snapshot().context).toMatchObject({ siteId: "hdh", fromPath: true });
|
||||||
|
syncAgentContextToPath("/console/home");
|
||||||
|
expect(snapshot().context).toBeNull();
|
||||||
|
});
|
||||||
|
it("页面按钮带的上下文、正在看的会话、抽屉关着时不动", () => {
|
||||||
|
openAgentDrawer({ label: "作品:沙丘", entityId: "e1" });
|
||||||
|
syncAgentContextToPath("/console/sites/hdh");
|
||||||
|
expect(snapshot().context).toMatchObject({ entityId: "e1" });
|
||||||
|
clearAgentContext();
|
||||||
|
showAgentRunInDrawer("r1");
|
||||||
|
syncAgentContextToPath("/console/sites/hdh");
|
||||||
|
expect(snapshot().context).toBeNull();
|
||||||
|
setAgentDrawerOpen(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,127 @@
|
|||||||
|
import { useEffect } from "react";
|
||||||
|
import { useSearchParams } from "react-router";
|
||||||
|
import { type AgentRunGroup, runGroup, useAgentRun } from "~/api/agent.ts";
|
||||||
|
|
||||||
|
// 智能体页面的导航状态(纯函数 + 模块级记忆,单测覆盖):
|
||||||
|
// - 分栏「对话 / 自动巡检」:URL 的 ?tab= 优先,其次当前会话所在分栏,再次上次看的分栏;
|
||||||
|
// - 会话链接、列表页链接的拼法(保留演示开关);
|
||||||
|
// - 页面会话列表的筛选与各分栏滚动位置(列表页 ↔ 会话页之间保留,刷新页面才清)。
|
||||||
|
|
||||||
|
export const TAB_PARAM = "tab";
|
||||||
|
|
||||||
|
/** URL 里的分栏;没写或写错时为 null */
|
||||||
|
export function groupFromSearch(search: string | URLSearchParams): AgentRunGroup | null {
|
||||||
|
const v = (typeof search === "string" ? new URLSearchParams(search) : search).get(TAB_PARAM);
|
||||||
|
return v === "auto" || v === "conversation" ? v : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 分栏取值:URL 显式指定 > 当前会话所在分栏 > 上次看的分栏 > 对话 */
|
||||||
|
export function resolveGroup(o: {
|
||||||
|
fromUrl: AgentRunGroup | null;
|
||||||
|
activeRun?: AgentRunGroup | null;
|
||||||
|
remembered?: AgentRunGroup | null;
|
||||||
|
}): AgentRunGroup {
|
||||||
|
return o.fromUrl ?? o.activeRun ?? o.remembered ?? "conversation";
|
||||||
|
}
|
||||||
|
|
||||||
|
function withQuery(path: string, q: Record<string, string | null>): string {
|
||||||
|
const p = new URLSearchParams();
|
||||||
|
for (const [k, v] of Object.entries(q)) if (v) p.set(k, v);
|
||||||
|
const s = p.toString();
|
||||||
|
return s ? `${path}?${s}` : path;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 会话页链接:分栏由会话本身决定,不带 tab */
|
||||||
|
export function agentRunHref(id: string, mock = false): string {
|
||||||
|
return withQuery(`/console/agent/${id}`, { mock: mock ? "1" : null });
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 列表页链接:「对话」是缺省,不写进 URL */
|
||||||
|
export function agentListHref(group: AgentRunGroup = "conversation", mock = false): string {
|
||||||
|
return withQuery("/console/agent", { [TAB_PARAM]: group === "auto" ? "auto" : null, mock: mock ? "1" : null });
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 页面会话列表的记忆:上次看的分栏、搜索与筛选、各分栏的滚动位置 */
|
||||||
|
export type ListMemory = {
|
||||||
|
group: AgentRunGroup;
|
||||||
|
q: string;
|
||||||
|
view: "all" | "pending" | "failed";
|
||||||
|
scroll: Record<AgentRunGroup, number>;
|
||||||
|
};
|
||||||
|
export const newListMemory = (): ListMemory => ({
|
||||||
|
group: "conversation",
|
||||||
|
q: "",
|
||||||
|
view: "all",
|
||||||
|
scroll: { conversation: 0, auto: 0 },
|
||||||
|
});
|
||||||
|
export const pageListMemory: ListMemory = newListMemory();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 当前项要不要滚进可见区:完全看不见才滚,只滚到刚好露出(nearest),返回新的 scrollTop;不用滚时为 null。
|
||||||
|
* top / height 是当前项相对滚动内容顶部的位置。
|
||||||
|
*/
|
||||||
|
export function revealScrollTop(
|
||||||
|
item: { top: number; height: number },
|
||||||
|
viewport: { scrollTop: number; height: number },
|
||||||
|
): number | null {
|
||||||
|
const bottom = item.top + item.height;
|
||||||
|
const viewBottom = viewport.scrollTop + viewport.height;
|
||||||
|
if (item.top >= viewport.scrollTop && bottom <= viewBottom) return null;
|
||||||
|
if (item.top < viewport.scrollTop) return Math.max(0, item.top);
|
||||||
|
return Math.max(0, bottom - viewport.height);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 会话所在分栏(子会话按父会话);快照没到之前为 null */
|
||||||
|
export function useRunGroup(runId: string | undefined): AgentRunGroup | null {
|
||||||
|
const run = useAgentRun(runId);
|
||||||
|
const r = run.data?.run;
|
||||||
|
const parentId = r?.trigger === "subagent" ? (r.parentRunId ?? undefined) : undefined;
|
||||||
|
const parent = useAgentRun(parentId);
|
||||||
|
if (!r) return null;
|
||||||
|
if (parentId && !parent.data) return null;
|
||||||
|
return runGroup(r, parent.data?.run ?? null);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 页面会话列表的分栏:URL 的 ?tab= 优先;会话页再按当前会话所在分栏,快照没到时沿用上次的分栏(避免闪一下)。
|
||||||
|
* 列表页没有 tab 就是「对话」,URL 与选中项一致。切换分栏只改 URL(replace,不进历史)。
|
||||||
|
*/
|
||||||
|
export function usePageListGroup(activeRun?: AgentRunGroup | null): [AgentRunGroup, (g: AgentRunGroup) => void] {
|
||||||
|
const [params, setParams] = useSearchParams();
|
||||||
|
const onRunPage = activeRun !== undefined;
|
||||||
|
const group = resolveGroup({
|
||||||
|
fromUrl: groupFromSearch(params),
|
||||||
|
activeRun: onRunPage ? activeRun : null,
|
||||||
|
remembered: onRunPage ? pageListMemory.group : null,
|
||||||
|
});
|
||||||
|
useEffect(() => {
|
||||||
|
pageListMemory.group = group;
|
||||||
|
}, [group]);
|
||||||
|
const change = (g: AgentRunGroup) =>
|
||||||
|
setParams(
|
||||||
|
(prev) => {
|
||||||
|
const next = new URLSearchParams(prev);
|
||||||
|
next.set(TAB_PARAM, g);
|
||||||
|
return next;
|
||||||
|
},
|
||||||
|
{ replace: true, preventScrollReset: true },
|
||||||
|
);
|
||||||
|
return [group, change];
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 输入草稿:按会话(和两处新对话输入框)分开记,切换会话、关抽屉不丢;发送成功后清掉。模块级,刷新页面才清 ──
|
||||||
|
|
||||||
|
const drafts = new Map<string, string>();
|
||||||
|
export const DRAFT_PAGE_GOAL = "goal:page";
|
||||||
|
export const DRAFT_DRAWER_GOAL = "goal:drawer";
|
||||||
|
export const runDraftKey = (runId: string) => `run:${runId}`;
|
||||||
|
|
||||||
|
export function readDraft(key: string | undefined): string {
|
||||||
|
return key ? (drafts.get(key) ?? "") : "";
|
||||||
|
}
|
||||||
|
|
||||||
|
export function writeDraft(key: string | undefined, text: string): void {
|
||||||
|
if (!key) return;
|
||||||
|
if (text.trim()) drafts.set(key, text);
|
||||||
|
else drafts.delete(key);
|
||||||
|
}
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
import { useQueryClient } from "@tanstack/react-query";
|
import { useQueryClient } from "@tanstack/react-query";
|
||||||
import { type AgentRunStatus, EventType } from "@umt/contracts";
|
import { type AgentRunStatus, EventType } from "@umt/contracts";
|
||||||
import { ChevronDown, FlaskConical, ListChecks, RefreshCw, WifiOff } from "lucide-react";
|
import { ChevronDown, FlaskConical, ListChecks, RefreshCw, WifiOff } from "lucide-react";
|
||||||
import { useId, useRef, useState } from "react";
|
import { type ReactNode, useId, useRef, useState } from "react";
|
||||||
import { useNavigate } from "react-router";
|
import { useNavigate } from "react-router";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import {
|
import {
|
||||||
@@ -40,6 +40,7 @@ import {
|
|||||||
runPlanIds,
|
runPlanIds,
|
||||||
} from "./agent-conversation.tsx";
|
} from "./agent-conversation.tsx";
|
||||||
import { RunPlan } from "./agent-interaction.tsx";
|
import { RunPlan } from "./agent-interaction.tsx";
|
||||||
|
import { readDraft, runDraftKey, writeDraft } from "./agent-nav.ts";
|
||||||
import { shortGoalTitle } from "./agent-store.ts";
|
import { shortGoalTitle } from "./agent-store.ts";
|
||||||
import { SubagentTree } from "./agent-subagent-tree.tsx";
|
import { SubagentTree } from "./agent-subagent-tree.tsx";
|
||||||
import { callCounts, sessionStatsParts, stepProgress } from "./conversation-model.ts";
|
import { callCounts, sessionStatsParts, stepProgress } from "./conversation-model.ts";
|
||||||
@@ -71,6 +72,7 @@ export function GoalComposer({
|
|||||||
onClearContext,
|
onClearContext,
|
||||||
defaultGoal = "",
|
defaultGoal = "",
|
||||||
suggestions,
|
suggestions,
|
||||||
|
draftKey,
|
||||||
}: {
|
}: {
|
||||||
onSubmit: (goal: string, opts: AgentSessionOptions) => void;
|
onSubmit: (goal: string, opts: AgentSessionOptions) => void;
|
||||||
pending: boolean;
|
pending: boolean;
|
||||||
@@ -80,8 +82,14 @@ export function GoalComposer({
|
|||||||
defaultGoal?: string;
|
defaultGoal?: string;
|
||||||
/** 快捷目标:输入框为空时显示 */
|
/** 快捷目标:输入框为空时显示 */
|
||||||
suggestions?: string[];
|
suggestions?: string[];
|
||||||
|
/** 草稿记在这个键下(没有预填目标时恢复);发送成功后由调用方清掉 */
|
||||||
|
draftKey?: string;
|
||||||
}) {
|
}) {
|
||||||
const [goal, setGoal] = useState(defaultGoal);
|
const [goal, setGoalState] = useState(() => defaultGoal || readDraft(draftKey));
|
||||||
|
const setGoal = (text: string) => {
|
||||||
|
setGoalState(text);
|
||||||
|
writeDraft(draftKey, text);
|
||||||
|
};
|
||||||
const box = useRef<HTMLDivElement>(null);
|
const box = useRef<HTMLDivElement>(null);
|
||||||
const fill = (text: string) => {
|
const fill = (text: string) => {
|
||||||
setGoal(text);
|
setGoal(text);
|
||||||
@@ -281,10 +289,13 @@ export function AgentRunPanel({
|
|||||||
compact = false,
|
compact = false,
|
||||||
className,
|
className,
|
||||||
onCreated,
|
onCreated,
|
||||||
|
actions,
|
||||||
}: {
|
}: {
|
||||||
runId: string;
|
runId: string;
|
||||||
compact?: boolean;
|
compact?: boolean;
|
||||||
className?: string;
|
className?: string;
|
||||||
|
/** 统计行右侧的操作(会话页的「在侧边打开」) */
|
||||||
|
actions?: ReactNode;
|
||||||
/** /重跑 之后:缺省跳到新会话页;抽屉里改成在抽屉内打开 */
|
/** /重跑 之后:缺省跳到新会话页;抽屉里改成在抽屉内打开 */
|
||||||
onCreated?: (runId: string) => void;
|
onCreated?: (runId: string) => void;
|
||||||
}) {
|
}) {
|
||||||
@@ -295,7 +306,12 @@ export function AgentRunPanel({
|
|||||||
const append = useAppendAgentMessage();
|
const append = useAppendAgentMessage();
|
||||||
const create = useCreateAgentRun();
|
const create = useCreateAgentRun();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const [msg, setMsg] = useState("");
|
// 草稿按会话记:切到别的会话再回来还在,不会串到别的会话
|
||||||
|
const [msg, setMsgState] = useState(() => readDraft(runDraftKey(runId)));
|
||||||
|
const setMsg = (text: string) => {
|
||||||
|
setMsgState(text);
|
||||||
|
writeDraft(runDraftKey(runId), text);
|
||||||
|
};
|
||||||
const [confirmStop, setConfirmStop] = useState(false);
|
const [confirmStop, setConfirmStop] = useState(false);
|
||||||
const sseStatus = useSseStatus();
|
const sseStatus = useSseStatus();
|
||||||
const qc = useQueryClient();
|
const qc = useQueryClient();
|
||||||
@@ -379,7 +395,14 @@ export function AgentRunPanel({
|
|||||||
</StatusBadge>
|
</StatusBadge>
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
<RunStatsLine snapshot={run.data} live={live} className={column} />
|
{actions ? (
|
||||||
|
<div className={cn(column, "-my-1 flex min-w-0 items-center gap-2")}>
|
||||||
|
<RunStatsLine snapshot={run.data} live={live} className="min-w-0 flex-1" />
|
||||||
|
<div className="flex shrink-0 items-center gap-1">{actions}</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<RunStatsLine snapshot={run.data} live={live} className={column} />
|
||||||
|
)}
|
||||||
<RunOverviewBar
|
<RunOverviewBar
|
||||||
snapshot={run.data}
|
snapshot={run.data}
|
||||||
status={shownStatus}
|
status={shownStatus}
|
||||||
|
|||||||
@@ -1,16 +1,18 @@
|
|||||||
import { Bot, ChevronRight } from "lucide-react";
|
import { Bot, ChevronLeft, ChevronRight } from "lucide-react";
|
||||||
import { type ReactNode, useLayoutEffect, useRef, useState } from "react";
|
import { type ReactNode, useEffect, useLayoutEffect, useRef, useState } from "react";
|
||||||
import { Link } from "react-router";
|
import { Link } from "react-router";
|
||||||
import { type AgentRunGroup, type AgentRunView, TRIGGER_LABEL, useAgentRuns } from "~/api/agent.ts";
|
import { type AgentRunGroup, type AgentRunView, TRIGGER_LABEL, useAgentRuns } from "~/api/agent.ts";
|
||||||
import { CHEVRON_CLASS } from "~/components/agentui/disclosure.tsx";
|
import { CHEVRON_CLASS } from "~/components/agentui/disclosure.tsx";
|
||||||
import { EmptyState, ErrorState, LoadingState } from "~/components/console/empty-state.tsx";
|
import { EmptyState, ErrorState, LoadingState } from "~/components/console/empty-state.tsx";
|
||||||
import { useListPagination } from "~/components/console/use-list-pagination.ts";
|
import { useListPagination } from "~/components/console/use-list-pagination.ts";
|
||||||
|
import { Button } from "~/components/ui/button.tsx";
|
||||||
import { Input } from "~/components/ui/input.tsx";
|
import { Input } from "~/components/ui/input.tsx";
|
||||||
import { Pagination } from "~/components/ui/pagination.tsx";
|
import type { PaginationProps } from "~/components/ui/pagination.tsx";
|
||||||
import { Tabs, TabsList, TabsTrigger } from "~/components/ui/tabs.tsx";
|
import { Tabs, TabsList, TabsTrigger } from "~/components/ui/tabs.tsx";
|
||||||
import { relTime } from "~/components/workbench/release-fields.ts";
|
import { relTime } from "~/components/workbench/release-fields.ts";
|
||||||
import { cn } from "~/lib/utils.ts";
|
import { cn } from "~/lib/utils.ts";
|
||||||
import { contextLabel, RunStatusBadge } from "./agent-conversation.tsx";
|
import { contextLabel, RunStatusBadge } from "./agent-conversation.tsx";
|
||||||
|
import { type ListMemory, newListMemory, revealScrollTop } from "./agent-nav.ts";
|
||||||
import { shortGoalTitle, splitContextGoal } from "./agent-store.ts";
|
import { shortGoalTitle, splitContextGoal } from "./agent-store.ts";
|
||||||
|
|
||||||
// 会话列表(AGUI-3):会话页左栏与抽屉的「历史」弹层共用。
|
// 会话列表(AGUI-3):会话页左栏与抽屉的「历史」弹层共用。
|
||||||
@@ -80,8 +82,8 @@ export function arrangeHistory(
|
|||||||
return { pinned, rows };
|
return { pinned, rows };
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 抽屉里跨开关保留的筛选与滚动位置(模块级,刷新页面才清) */
|
/** 抽屉历史弹层跨开关保留的分栏、筛选与滚动位置(模块级,刷新页面才清) */
|
||||||
const drawerMemory = { filters: EMPTY_FILTERS, scroll: 0 };
|
export const drawerListMemory: ListMemory = newListMemory();
|
||||||
|
|
||||||
function RunRow({ r, current, trailing }: { r: AgentRunView; current: boolean; trailing?: ReactNode }) {
|
function RunRow({ r, current, trailing }: { r: AgentRunView; current: boolean; trailing?: ReactNode }) {
|
||||||
const goal = splitContextGoal(r.goal);
|
const goal = splitContextGoal(r.goal);
|
||||||
@@ -134,7 +136,12 @@ function Row({
|
|||||||
hrefFor?: (id: string) => string;
|
hrefFor?: (id: string) => string;
|
||||||
onOpen?: (id: string) => void;
|
onOpen?: (id: string) => void;
|
||||||
}) {
|
}) {
|
||||||
const [open, setOpen] = useState(row.children.some((c) => c.id === activeId));
|
const childActive = row.children.some((c) => c.id === activeId);
|
||||||
|
const [open, setOpen] = useState(childActive);
|
||||||
|
// 列表不随换会话重挂载:跳到子会话(会话内链接、概况条)时把父会话展开,当前项可见
|
||||||
|
useEffect(() => {
|
||||||
|
if (childActive) setOpen(true);
|
||||||
|
}, [childActive]);
|
||||||
const item = (r: AgentRunView) => {
|
const item = (r: AgentRunView) => {
|
||||||
const current = r.id === activeId;
|
const current = r.id === activeId;
|
||||||
const cls = cn(
|
const cls = cn(
|
||||||
@@ -142,12 +149,13 @@ function Row({
|
|||||||
current && "pill-selected",
|
current && "pill-selected",
|
||||||
);
|
);
|
||||||
return onOpen ? (
|
return onOpen ? (
|
||||||
<button type="button" className={cls} onClick={() => onOpen(r.id)}>
|
<button type="button" data-run-id={r.id} className={cls} onClick={() => onOpen(r.id)}>
|
||||||
<RunRow r={r} current={current} />
|
<RunRow r={r} current={current} />
|
||||||
</button>
|
</button>
|
||||||
) : (
|
) : (
|
||||||
<Link
|
<Link
|
||||||
to={hrefFor?.(r.id) ?? `/console/agent/${r.id}`}
|
to={hrefFor?.(r.id) ?? `/console/agent/${r.id}`}
|
||||||
|
data-run-id={r.id}
|
||||||
aria-current={current ? "page" : undefined}
|
aria-current={current ? "page" : undefined}
|
||||||
className={cls}
|
className={cls}
|
||||||
>
|
>
|
||||||
@@ -183,33 +191,90 @@ function Row({
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 会话列表:搜索 + 全部 / 等你处理 / 失败 + 按需加载的统一分页。
|
* 窄列表用的分页:一行「共 N 条 · ‹ 1/3 ›」。整页表格的分页(每页条数 + 完整页码)放进 17rem 的左栏
|
||||||
* - hrefFor:会话页左栏,点一条跳到该会话页。
|
* 或历史弹层会折成三行,占掉半个列表。每页条数沿用全站设置。只有一页时不显示。
|
||||||
* - onOpen:抽屉历史弹层,点一条在抽屉内打开;remember 时筛选与滚动位置下次打开时恢复。
|
*/
|
||||||
* - limit:历史弹层的默认每页条数,可调节并翻页查看更早的会话。
|
export function CompactPager({ page, pageSize, total, hasMore = false, busy = false, onPageChange }: PaginationProps) {
|
||||||
|
const pages = Math.max(1, Math.ceil(total / pageSize)) + (hasMore ? 1 : 0);
|
||||||
|
if (pages <= 1) return null;
|
||||||
|
const known = Math.max(1, Math.ceil(total / pageSize));
|
||||||
|
return (
|
||||||
|
<nav
|
||||||
|
aria-label="会话列表分页"
|
||||||
|
aria-busy={busy}
|
||||||
|
className="flex shrink-0 items-center gap-1 border-t border-border/60 pt-1.5 text-meta text-muted-foreground"
|
||||||
|
>
|
||||||
|
<span aria-live="polite" className="min-w-0 flex-1 truncate px-1 tabular-nums">
|
||||||
|
{hasMore ? `已加载 ${total} 条` : `共 ${total} 条`}
|
||||||
|
</span>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="icon-sm"
|
||||||
|
aria-label="上一页"
|
||||||
|
disabled={busy || page <= 1}
|
||||||
|
onClick={() => onPageChange(page - 1)}
|
||||||
|
>
|
||||||
|
<ChevronLeft />
|
||||||
|
</Button>
|
||||||
|
<span className="tabular-nums">
|
||||||
|
{page} / {known}
|
||||||
|
{hasMore ? "+" : ""}
|
||||||
|
</span>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="icon-sm"
|
||||||
|
aria-label="下一页"
|
||||||
|
disabled={busy || page >= pages}
|
||||||
|
onClick={() => onPageChange(page + 1)}
|
||||||
|
>
|
||||||
|
<ChevronRight />
|
||||||
|
</Button>
|
||||||
|
</nav>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 会话列表:分栏 + 搜索 + 全部 / 等你处理 / 失败 + 按需加载的分页。
|
||||||
|
* - hrefFor:列表页 / 会话页左栏,点一条跳到该会话页。
|
||||||
|
* - onOpen:抽屉历史弹层,点一条在抽屉内打开。
|
||||||
|
* - group / onGroupChange:分栏受控(页面把分栏放在 URL 里);不传时列表自己记。
|
||||||
|
* - memory:筛选与各分栏滚动位置记在这里,列表重新挂载(下次打开弹层、列表页 ↔ 会话页)时恢复。
|
||||||
|
* - activeId:当前会话,高亮;第一次出现在列表里且看不见时滚进可见区,是子会话时展开父会话。
|
||||||
|
* - limit:历史弹层的默认每页条数。
|
||||||
*/
|
*/
|
||||||
export function AgentSessionList({
|
export function AgentSessionList({
|
||||||
activeId,
|
activeId,
|
||||||
hrefFor,
|
hrefFor,
|
||||||
onOpen,
|
onOpen,
|
||||||
remember = false,
|
group: controlledGroup,
|
||||||
|
onGroupChange,
|
||||||
|
memory,
|
||||||
limit,
|
limit,
|
||||||
className,
|
className,
|
||||||
}: {
|
}: {
|
||||||
activeId?: string;
|
activeId?: string;
|
||||||
hrefFor?: (id: string) => string;
|
hrefFor?: (id: string) => string;
|
||||||
onOpen?: (id: string) => void;
|
onOpen?: (id: string) => void;
|
||||||
remember?: boolean;
|
group?: AgentRunGroup;
|
||||||
|
onGroupChange?: (group: AgentRunGroup) => void;
|
||||||
|
memory?: ListMemory;
|
||||||
limit?: number;
|
limit?: number;
|
||||||
className?: string;
|
className?: string;
|
||||||
}) {
|
}) {
|
||||||
const [f, setF] = useState<HistoryFilters>(remember ? drawerMemory.filters : EMPTY_FILTERS);
|
const [own, setOwn] = useState<HistoryFilters>(
|
||||||
const set = (patch: Partial<HistoryFilters>) =>
|
memory ? { q: memory.q, view: memory.view, group: memory.group } : EMPTY_FILTERS,
|
||||||
setF((prev) => {
|
);
|
||||||
|
const f: HistoryFilters = controlledGroup ? { ...own, group: controlledGroup } : own;
|
||||||
|
const set = (patch: Partial<HistoryFilters>) => {
|
||||||
|
if (patch.group && controlledGroup) onGroupChange?.(patch.group);
|
||||||
|
setOwn((prev) => {
|
||||||
const next = { ...prev, ...patch };
|
const next = { ...prev, ...patch };
|
||||||
if (remember) drawerMemory.filters = next;
|
if (memory) Object.assign(memory, { q: next.q, view: next.view, group: next.group });
|
||||||
return next;
|
return next;
|
||||||
});
|
});
|
||||||
|
};
|
||||||
const runs = useAgentRuns(undefined, undefined, f.group);
|
const runs = useAgentRuns(undefined, undefined, f.group);
|
||||||
// 等你处理的会话可能是很早以前建的:单独按 waiting 取一页,保证置顶
|
// 等你处理的会话可能是很早以前建的:单独按 waiting 取一页,保证置顶
|
||||||
const waiting = useAgentRuns("waiting", undefined, f.group);
|
const waiting = useAgentRuns("waiting", undefined, f.group);
|
||||||
@@ -231,12 +296,29 @@ export function AgentSessionList({
|
|||||||
const rows = f.view === "all" ? visible.filter((row) => !awaitingUser(row.run)) : visible;
|
const rows = f.view === "all" ? visible.filter((row) => !awaitingUser(row.run)) : visible;
|
||||||
const filtered = !!f.q.trim() || f.view !== "all";
|
const filtered = !!f.q.trim() || f.view !== "all";
|
||||||
|
|
||||||
|
// 滚动:每个分栏第一次有内容时恢复记住的位置,再把当前会话滚进可见区(各只做一次,之后不跟着用户的滚动抢位置)
|
||||||
const scroller = useRef<HTMLDivElement>(null);
|
const scroller = useRef<HTMLDivElement>(null);
|
||||||
const restored = useRef(!remember);
|
const restoredGroup = useRef<AgentRunGroup | null>(null);
|
||||||
|
const revealed = useRef<string | null>(null);
|
||||||
|
const ready = !runs.isPending && visible.length > 0;
|
||||||
useLayoutEffect(() => {
|
useLayoutEffect(() => {
|
||||||
if (restored.current || !rows.length || !scroller.current) return;
|
const el = scroller.current;
|
||||||
restored.current = true;
|
if (!el || !ready) return;
|
||||||
scroller.current.scrollTop = drawerMemory.scroll;
|
if (restoredGroup.current !== f.group) {
|
||||||
|
restoredGroup.current = f.group;
|
||||||
|
el.scrollTop = memory?.scroll[f.group] ?? 0;
|
||||||
|
}
|
||||||
|
const key = `${f.group}|${activeId ?? ""}`;
|
||||||
|
if (!activeId || revealed.current === key) return;
|
||||||
|
const item = el.querySelector<HTMLElement>(`[data-run-id="${activeId}"]`);
|
||||||
|
if (!item) return;
|
||||||
|
revealed.current = key;
|
||||||
|
const top = item.getBoundingClientRect().top - el.getBoundingClientRect().top + el.scrollTop;
|
||||||
|
const next = revealScrollTop(
|
||||||
|
{ top, height: item.offsetHeight },
|
||||||
|
{ scrollTop: el.scrollTop, height: el.clientHeight },
|
||||||
|
);
|
||||||
|
if (next !== null) el.scrollTop = next;
|
||||||
});
|
});
|
||||||
const rowProps = { activeId, hrefFor, onOpen };
|
const rowProps = { activeId, hrefFor, onOpen };
|
||||||
return (
|
return (
|
||||||
@@ -279,7 +361,8 @@ export function AgentSessionList({
|
|||||||
<div
|
<div
|
||||||
ref={scroller}
|
ref={scroller}
|
||||||
onScroll={(e) => {
|
onScroll={(e) => {
|
||||||
if (remember) drawerMemory.scroll = e.currentTarget.scrollTop;
|
// 换栏后还没恢复位置前的滚动(内容变短被浏览器夹回)不记
|
||||||
|
if (memory && restoredGroup.current === f.group) memory.scroll[f.group] = e.currentTarget.scrollTop;
|
||||||
}}
|
}}
|
||||||
className="min-h-0 flex-1 overflow-y-auto"
|
className="min-h-0 flex-1 overflow-y-auto"
|
||||||
>
|
>
|
||||||
@@ -321,7 +404,13 @@ export function AgentSessionList({
|
|||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
{visible.length > 0 || canLoadMore ? <Pagination {...paged.pagination} /> : null}
|
<CompactPager
|
||||||
|
{...paged.pagination}
|
||||||
|
onPageChange={(n) => {
|
||||||
|
paged.pagination.onPageChange(n);
|
||||||
|
if (scroller.current) scroller.current.scrollTop = 0;
|
||||||
|
}}
|
||||||
|
/>
|
||||||
</nav>
|
</nav>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -30,20 +30,50 @@ type State = {
|
|||||||
// AGUI-3:抽屉去掉「当前 / 历史」Tab。头部「历史」弹层点一条就在抽屉里打开,「新对话」回到输入框。
|
// AGUI-3:抽屉去掉「当前 / 历史」Tab。头部「历史」弹层点一条就在抽屉里打开,「新对话」回到输入框。
|
||||||
// 旧版本记在 localStorage 的 Tab(umt-agent-drawer-tab)不再读取。
|
// 旧版本记在 localStorage 的 Tab(umt-agent-drawer-tab)不再读取。
|
||||||
|
|
||||||
|
/** 当前在不在智能体页面(列表页或会话页) */
|
||||||
|
export const isAgentPage = (pathname: string) => /^\/console\/agent(\/|$)/.test(pathname);
|
||||||
|
|
||||||
let state: State = { open: false, minimized: false, context: null, runId: null };
|
let state: State = { open: false, minimized: false, context: null, runId: null };
|
||||||
const listeners = new Set<() => void>();
|
const listeners = new Set<() => void>();
|
||||||
const emit = () => {
|
const emit = () => {
|
||||||
for (const l of listeners) l();
|
for (const l of listeners) l();
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 打开前的焦点:抽屉多从快捷键打开,没有 Radix 触发元素,关闭时焦点会落到 body。
|
||||||
|
* 打开时记下,关闭时还回去(元素还在页面上才还)。
|
||||||
|
*/
|
||||||
|
let returnFocus: HTMLElement | null = null;
|
||||||
|
function rememberFocus() {
|
||||||
|
if (state.open || typeof document === "undefined") return;
|
||||||
|
const el = document.activeElement;
|
||||||
|
returnFocus = el instanceof HTMLElement && el !== document.body ? el : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 取出并清掉打开抽屉前的焦点元素(抽屉 onCloseAutoFocus 用) */
|
||||||
|
export function takeReturnFocus(): HTMLElement | null {
|
||||||
|
const el = returnFocus;
|
||||||
|
returnFocus = null;
|
||||||
|
return el?.isConnected ? el : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 最近一个不是智能体页面的地址:会话页「在侧边打开」回到这里 */
|
||||||
|
let returnPath: string | null = null;
|
||||||
|
export function noteAgentReturnPath(pathname: string, search = "") {
|
||||||
|
if (!isAgentPage(pathname)) returnPath = `${pathname}${search}`;
|
||||||
|
}
|
||||||
|
export const agentReturnPath = () => returnPath;
|
||||||
|
|
||||||
/** 打开抽屉开始新对话(页面内按钮带着对象打开) */
|
/** 打开抽屉开始新对话(页面内按钮带着对象打开) */
|
||||||
export function openAgentDrawer(context: AgentContext | null = null) {
|
export function openAgentDrawer(context: AgentContext | null = null) {
|
||||||
|
rememberFocus();
|
||||||
state = { ...state, open: true, minimized: false, context, runId: null };
|
state = { ...state, open: true, minimized: false, context, runId: null };
|
||||||
emit();
|
emit();
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 在抽屉里打开一条会话(历史弹层、/重跑、子会话链接) */
|
/** 在抽屉里打开一条会话(历史弹层、/重跑、子会话链接、会话页「在侧边打开」) */
|
||||||
export function showAgentRunInDrawer(runId: string) {
|
export function showAgentRunInDrawer(runId: string) {
|
||||||
|
rememberFocus();
|
||||||
state = { ...state, open: true, minimized: false, runId };
|
state = { ...state, open: true, minimized: false, runId };
|
||||||
emit();
|
emit();
|
||||||
}
|
}
|
||||||
@@ -54,6 +84,18 @@ export function newAgentConversation() {
|
|||||||
emit();
|
emit();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 抽屉开着时路由变了(浏览器前进后退、会话里的链接):还在新对话、上下文是按路由推断的,就换成新页面的对象;
|
||||||
|
* 页面按钮带进来的上下文、已经在看的会话不动。
|
||||||
|
*/
|
||||||
|
export function syncAgentContextToPath(pathname: string) {
|
||||||
|
if (!state.open || state.runId || (state.context && !state.context.fromPath)) return;
|
||||||
|
const next = contextFromPath(pathname);
|
||||||
|
if (JSON.stringify(next) === JSON.stringify(state.context)) return;
|
||||||
|
state = { ...state, context: next };
|
||||||
|
emit();
|
||||||
|
}
|
||||||
|
|
||||||
/** 去掉上下文(输入框上的名称标签 ×) */
|
/** 去掉上下文(输入框上的名称标签 ×) */
|
||||||
export function clearAgentContext() {
|
export function clearAgentContext() {
|
||||||
state = { ...state, context: null };
|
state = { ...state, context: null };
|
||||||
@@ -73,6 +115,7 @@ export function minimizeAgentDrawer() {
|
|||||||
|
|
||||||
/** 从小窗恢复抽屉 */
|
/** 从小窗恢复抽屉 */
|
||||||
export function restoreAgentDrawer() {
|
export function restoreAgentDrawer() {
|
||||||
|
rememberFocus();
|
||||||
state = { ...state, open: true, minimized: false };
|
state = { ...state, open: true, minimized: false };
|
||||||
emit();
|
emit();
|
||||||
}
|
}
|
||||||
@@ -90,13 +133,33 @@ export function isAgentShortcut(
|
|||||||
return (e.ctrlKey || e.metaKey) && !e.shiftKey && !e.altKey && e.key.toLowerCase() === "j";
|
return (e.ctrlKey || e.metaKey) && !e.shiftKey && !e.altKey && e.key.toLowerCase() === "j";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type ToggleAction = "close" | "focus-page" | "restore" | "open";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 快捷键 / 悬浮按钮 / 侧栏主入口共用的切换:
|
* 切换要做什么(纯函数,单测覆盖):打开中 → 收起;已在智能体页面 → 聚焦页面的输入框(页面就是智能体,
|
||||||
* 打开中 → 收起;有最小化的运行 → 恢复;否则按当前路由带上下文打开(上次看的会话还在就接着看)。
|
* 不再在抽屉里叠一份同一会话);上次看的会话(含最小化的)还在 → 接着看;否则按当前路由带上下文开新对话。
|
||||||
*/
|
*/
|
||||||
|
export function toggleAction(s: Pick<State, "open" | "runId">, pathname: string): ToggleAction {
|
||||||
|
if (s.open) return "close";
|
||||||
|
if (isAgentPage(pathname)) return "focus-page";
|
||||||
|
return s.runId ? "restore" : "open";
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 智能体页面的输入框(会话页「发消息」、列表页「目标」) */
|
||||||
|
function focusAgentPageInput(): boolean {
|
||||||
|
if (typeof document === "undefined") return false;
|
||||||
|
const ta = document.querySelector<HTMLTextAreaElement>("[data-agent-page] textarea:not([disabled])");
|
||||||
|
if (!ta) return false;
|
||||||
|
ta.focus();
|
||||||
|
ta.setSelectionRange(ta.value.length, ta.value.length);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 快捷键 / 悬浮按钮 / 侧栏主入口 / 顶栏按钮共用的切换 */
|
||||||
export function toggleAgentDrawer(pathname: string) {
|
export function toggleAgentDrawer(pathname: string) {
|
||||||
if (state.open) setAgentDrawerOpen(false);
|
const action = toggleAction(state, pathname);
|
||||||
else if (state.minimized && state.runId) restoreAgentDrawer();
|
if (action === "close") setAgentDrawerOpen(false);
|
||||||
|
else if (action === "focus-page" && focusAgentPageInput()) return;
|
||||||
else if (state.runId) restoreAgentDrawer();
|
else if (state.runId) restoreAgentDrawer();
|
||||||
else openAgentDrawer(contextFromPath(pathname));
|
else openAgentDrawer(contextFromPath(pathname));
|
||||||
}
|
}
|
||||||
@@ -123,6 +186,14 @@ export function contextFromPath(pathname: string): AgentContext | null {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 控制台主内容区的重挂载 key:智能体列表页与各会话页共用一个 key,
|
||||||
|
* 切换会话不重挂载整页、不重播入场动画,左栏的分栏 / 滚动 / 展开状态保留。
|
||||||
|
*/
|
||||||
|
export function outletKey(pathname: string): string {
|
||||||
|
return isAgentPage(pathname) ? "/console/agent" : pathname;
|
||||||
|
}
|
||||||
|
|
||||||
export function useAgentDrawer(): State {
|
export function useAgentDrawer(): State {
|
||||||
return useSyncExternalStore(
|
return useSyncExternalStore(
|
||||||
(l) => {
|
(l) => {
|
||||||
|
|||||||
@@ -284,20 +284,49 @@ export function foldEvents(events: AgentRunEvent[]): StreamItem[] {
|
|||||||
return items;
|
return items;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 最近看过的会话的事件(模块级,最多 30 条会话):切回看过的会话时直接用已读到的事件铺出来,
|
||||||
|
* 再从最后一个 seq 之后补读;不再先空一帧、再整段补读回来。
|
||||||
|
*/
|
||||||
|
const EVENT_CACHE_MAX = 30;
|
||||||
|
const eventCache = new Map<string, AgentRunEvent[]>();
|
||||||
|
|
||||||
|
export function cachedRunEvents(key: string): AgentRunEvent[] {
|
||||||
|
return eventCache.get(key) ?? [];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function cacheRunEvents(key: string, events: AgentRunEvent[]): void {
|
||||||
|
if (!key || !events.length) return;
|
||||||
|
eventCache.delete(key);
|
||||||
|
eventCache.set(key, events);
|
||||||
|
while (eventCache.size > EVENT_CACHE_MAX) {
|
||||||
|
const oldest = eventCache.keys().next().value;
|
||||||
|
if (oldest === undefined) break;
|
||||||
|
eventCache.delete(oldest);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const lastSeqOf = (events: AgentRunEvent[]) => events[events.length - 1]?.seq ?? -1;
|
||||||
|
|
||||||
export function useAgentStream(runId: string | undefined, active: boolean): AgentStreamState {
|
export function useAgentStream(runId: string | undefined, active: boolean): AgentStreamState {
|
||||||
const [events, setEvents] = useState<AgentRunEvent[]>([]);
|
// 演示数据与真实会话分开缓存
|
||||||
|
const cacheKey = runId ? `${agentMockEnabled() ? "mock" : "live"}:${runId}` : "";
|
||||||
|
const [events, setEvents] = useState<AgentRunEvent[]>(() => cachedRunEvents(cacheKey));
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [connected, setConnected] = useState<AgentStreamState["connected"]>("idle");
|
const [connected, setConnected] = useState<AgentStreamState["connected"]>("idle");
|
||||||
const [nonce, setNonce] = useState(0);
|
const [nonce, setNonce] = useState(0);
|
||||||
const lastSeq = useRef(-1);
|
const lastSeq = useRef(lastSeqOf(events));
|
||||||
|
|
||||||
// biome-ignore lint/correctness/useExhaustiveDependencies: 换 run 时重置
|
// biome-ignore lint/correctness/useExhaustiveDependencies: 换 run 时重置(有缓存就从缓存接着读)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setEvents([]);
|
const cached = cachedRunEvents(cacheKey);
|
||||||
|
setEvents(cached);
|
||||||
setError(null);
|
setError(null);
|
||||||
lastSeq.current = -1;
|
lastSeq.current = lastSeqOf(cached);
|
||||||
}, [runId]);
|
}, [runId]);
|
||||||
|
|
||||||
|
useEffect(() => cacheRunEvents(cacheKey, events), [cacheKey, events]);
|
||||||
|
|
||||||
// biome-ignore lint/correctness/useExhaustiveDependencies: nonce 变化 = 手动重试,重新建立补读
|
// biome-ignore lint/correctness/useExhaustiveDependencies: nonce 变化 = 手动重试,重新建立补读
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!runId) return;
|
if (!runId) return;
|
||||||
|
|||||||
@@ -33,6 +33,14 @@ export interface MessageScrollerProps extends Omit<ComponentPropsWithRef<"div">,
|
|||||||
jumpLabel?: string;
|
jumpLabel?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 跟随内容增长时的滚动方式:流式输出的小段增长平滑跟随;一次多出一屏以上(打开 / 切回会话时历史铺开、
|
||||||
|
* 补读回一大段)直接跳到底,不播一段从上滚到底的动画;减少动态效果时一律直接跳。
|
||||||
|
*/
|
||||||
|
export function followBehavior(o: { gap: number; viewport: number; reduce: boolean }): ScrollBehavior {
|
||||||
|
return o.reduce || o.gap > o.viewport ? "auto" : "smooth";
|
||||||
|
}
|
||||||
|
|
||||||
function layoutMs(el: Element | null): number {
|
function layoutMs(el: Element | null): number {
|
||||||
if (!el) return 0;
|
if (!el) return 0;
|
||||||
const v = getComputedStyle(el).getPropertyValue("--duration-layout").trim();
|
const v = getComputedStyle(el).getPropertyValue("--duration-layout").trim();
|
||||||
@@ -113,12 +121,12 @@ export function MessageScroller({
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const content = contentRef.current;
|
const content = contentRef.current;
|
||||||
if (!content || typeof ResizeObserver === "undefined") return;
|
if (!content || typeof ResizeObserver === "undefined") return;
|
||||||
// 首次铺满内容(回放历史)直接跳到底,之后的增长才平滑跟随
|
// 首次铺满内容、一次多出一屏以上直接跳到底,流式的小段增长才平滑跟随
|
||||||
let first = true;
|
|
||||||
const ro = new ResizeObserver(() => {
|
const ro = new ResizeObserver(() => {
|
||||||
if (!followOutput || !followingRef.current) return;
|
const vp = viewportRef.current;
|
||||||
scrollToEnd(reduce || first ? "auto" : "smooth");
|
if (!vp || !followOutput || !followingRef.current) return;
|
||||||
first = false;
|
const gap = vp.scrollHeight - vp.scrollTop - vp.clientHeight;
|
||||||
|
scrollToEnd(followBehavior({ gap, viewport: vp.clientHeight, reduce }));
|
||||||
});
|
});
|
||||||
ro.observe(content);
|
ro.observe(content);
|
||||||
return () => ro.disconnect();
|
return () => ro.disconnect();
|
||||||
|
|||||||
@@ -1,31 +1,67 @@
|
|||||||
import { ArrowLeft } from "lucide-react";
|
import { ArrowLeft, PanelRightOpen } from "lucide-react";
|
||||||
import { Link, useParams } from "react-router";
|
import { Link, useNavigate, useParams } from "react-router";
|
||||||
import { agentMockEnabled } from "~/api/agent.ts";
|
import { agentMockEnabled } from "~/api/agent.ts";
|
||||||
|
import {
|
||||||
|
agentListHref,
|
||||||
|
agentRunHref,
|
||||||
|
pageListMemory,
|
||||||
|
usePageListGroup,
|
||||||
|
useRunGroup,
|
||||||
|
} from "~/components/agent/agent-nav.ts";
|
||||||
import { AgentRunPanel } from "~/components/agent/agent-run-panel.tsx";
|
import { AgentRunPanel } from "~/components/agent/agent-run-panel.tsx";
|
||||||
import { AgentSessionList } from "~/components/agent/agent-session-list.tsx";
|
import { AgentSessionList } from "~/components/agent/agent-session-list.tsx";
|
||||||
|
import { agentReturnPath, showAgentRunInDrawer } from "~/components/agent/agent-store.ts";
|
||||||
import { Button } from "~/components/ui/button.tsx";
|
import { Button } from "~/components/ui/button.tsx";
|
||||||
|
|
||||||
// 会话页(AGUI-3):左栏会话列表(xl 以上)· 会话列占满其余宽度(顶部概况条、底部输入区)。
|
// 会话页(AGUI-3):左栏会话列表(xl 以上)· 会话列占满其余宽度(顶部概况条、底部输入区)。
|
||||||
// 不再有页头目标描述和右侧栏;步骤、子会话、预算与模型在概况条里。
|
// 不再有页头目标描述和右侧栏;步骤、子会话、预算与模型在概况条里。
|
||||||
export default function AgentRunPage() {
|
export default function AgentRunPage() {
|
||||||
const { runId = "" } = useParams();
|
const { runId = "" } = useParams();
|
||||||
|
const navigate = useNavigate();
|
||||||
const mock = agentMockEnabled();
|
const mock = agentMockEnabled();
|
||||||
const suffix = mock ? "?mock=1" : "";
|
// 左栏分栏跟随当前会话(巡检会话及其子会话在「自动巡检」),也可以手动切换(记在 URL)
|
||||||
|
const [group, setGroup] = usePageListGroup(useRunGroup(runId));
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<h1 className="sr-only">智能体会话</h1>
|
<h1 className="sr-only">智能体会话</h1>
|
||||||
<div className="grid grid-cols-1 gap-4 xl:grid-cols-[minmax(0,17rem)_minmax(0,1fr)]">
|
<div data-agent-page className="grid grid-cols-1 gap-4 xl:grid-cols-[minmax(0,17rem)_minmax(0,1fr)]">
|
||||||
<aside className="pg-slab hidden rounded-xl p-3 xl:block xl:h-[calc(100dvh-7rem)] xl:overflow-hidden">
|
<aside className="pg-slab hidden rounded-xl p-3 xl:block xl:h-[calc(100dvh-7rem)] xl:overflow-hidden">
|
||||||
<AgentSessionList activeId={runId} hrefFor={(id) => `/console/agent/${id}${suffix}`} className="h-full" />
|
<AgentSessionList
|
||||||
|
activeId={runId}
|
||||||
|
hrefFor={(id) => agentRunHref(id, mock)}
|
||||||
|
group={group}
|
||||||
|
onGroupChange={setGroup}
|
||||||
|
memory={pageListMemory}
|
||||||
|
className="h-full"
|
||||||
|
/>
|
||||||
</aside>
|
</aside>
|
||||||
<div className="pg-slab flex h-[calc(100dvh-7rem)] min-h-[28rem] flex-col gap-2 rounded-xl p-3 md:p-4">
|
<div className="pg-slab flex h-[calc(100dvh-7rem)] min-h-[28rem] flex-col gap-2 rounded-xl p-3 md:p-4">
|
||||||
<Button asChild variant="ghost" size="sm" className="-mt-1 -ml-2 self-start text-muted-foreground xl:hidden">
|
<Button asChild variant="ghost" size="sm" className="-mt-1 -ml-2 self-start text-muted-foreground xl:hidden">
|
||||||
<Link to={`/console/agent${suffix}`}>
|
<Link to={agentListHref(group, mock)}>
|
||||||
<ArrowLeft />
|
<ArrowLeft />
|
||||||
全部会话
|
全部会话
|
||||||
</Link>
|
</Link>
|
||||||
</Button>
|
</Button>
|
||||||
<AgentRunPanel key={runId} runId={runId} className="min-h-0 flex-1" />
|
<AgentRunPanel
|
||||||
|
key={runId}
|
||||||
|
runId={runId}
|
||||||
|
className="min-h-0 flex-1"
|
||||||
|
actions={
|
||||||
|
// 整页 → 抽屉:回到进智能体页面之前的页面,会话在抽屉里接着看
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon-sm"
|
||||||
|
aria-label="在侧边打开"
|
||||||
|
title="在侧边打开(回到之前的页面)"
|
||||||
|
onClick={() => {
|
||||||
|
showAgentRunInDrawer(runId);
|
||||||
|
navigate(agentReturnPath() ?? "/console");
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<PanelRightOpen />
|
||||||
|
</Button>
|
||||||
|
}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -2,6 +2,13 @@ import { FlaskConical } from "lucide-react";
|
|||||||
import { useNavigate } from "react-router";
|
import { useNavigate } from "react-router";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { agentMockEnabled, useCreateAgentRun } from "~/api/agent.ts";
|
import { agentMockEnabled, useCreateAgentRun } from "~/api/agent.ts";
|
||||||
|
import {
|
||||||
|
agentRunHref,
|
||||||
|
DRAFT_PAGE_GOAL,
|
||||||
|
pageListMemory,
|
||||||
|
usePageListGroup,
|
||||||
|
writeDraft,
|
||||||
|
} from "~/components/agent/agent-nav.ts";
|
||||||
import { GoalComposer } from "~/components/agent/agent-run-panel.tsx";
|
import { GoalComposer } from "~/components/agent/agent-run-panel.tsx";
|
||||||
import { AgentSessionList } from "~/components/agent/agent-session-list.tsx";
|
import { AgentSessionList } from "~/components/agent/agent-session-list.tsx";
|
||||||
import { quickGoalsFor } from "~/components/agent/quick-goals.ts";
|
import { quickGoalsFor } from "~/components/agent/quick-goals.ts";
|
||||||
@@ -14,6 +21,7 @@ export default function AgentPage() {
|
|||||||
const create = useCreateAgentRun();
|
const create = useCreateAgentRun();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const mock = agentMockEnabled();
|
const mock = agentMockEnabled();
|
||||||
|
const [group, setGroup] = usePageListGroup();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
@@ -27,19 +35,32 @@ export default function AgentPage() {
|
|||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
<div className="grid grid-cols-1 gap-4 lg:grid-cols-[minmax(0,20rem)_minmax(0,1fr)] lg:items-start">
|
<div
|
||||||
|
data-agent-page
|
||||||
|
className="grid grid-cols-1 gap-4 lg:grid-cols-[minmax(0,20rem)_minmax(0,1fr)] lg:items-start"
|
||||||
|
>
|
||||||
<div className="pg-slab order-2 rounded-xl p-3 lg:order-1 lg:h-[calc(100dvh-12rem)] lg:overflow-hidden">
|
<div className="pg-slab order-2 rounded-xl p-3 lg:order-1 lg:h-[calc(100dvh-12rem)] lg:overflow-hidden">
|
||||||
<AgentSessionList hrefFor={(id) => `/console/agent/${id}${mock ? "?mock=1" : ""}`} className="h-full" />
|
<AgentSessionList
|
||||||
|
hrefFor={(id) => agentRunHref(id, mock)}
|
||||||
|
group={group}
|
||||||
|
onGroupChange={setGroup}
|
||||||
|
memory={pageListMemory}
|
||||||
|
className="h-full"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="pg-slab order-1 mx-auto w-full max-w-[88ch] rounded-xl p-4 lg:order-2">
|
<div className="pg-slab order-1 mx-auto w-full max-w-[88ch] rounded-xl p-4 lg:order-2">
|
||||||
<GoalComposer
|
<GoalComposer
|
||||||
pending={create.isPending}
|
pending={create.isPending}
|
||||||
suggestions={quickGoalsFor("/console/agent")}
|
suggestions={quickGoalsFor("/console/agent")}
|
||||||
|
draftKey={DRAFT_PAGE_GOAL}
|
||||||
onSubmit={(goal, opts) =>
|
onSubmit={(goal, opts) =>
|
||||||
create.mutate(
|
create.mutate(
|
||||||
{ goal, ...opts },
|
{ goal, ...opts },
|
||||||
{
|
{
|
||||||
onSuccess: (run) => navigate(`/console/agent/${run.id}${mock ? "?mock=1" : ""}`),
|
onSuccess: (run) => {
|
||||||
|
writeDraft(DRAFT_PAGE_GOAL, "");
|
||||||
|
navigate(agentRunHref(run.id, mock));
|
||||||
|
},
|
||||||
onError: (e) => toast.error(errorMessage(e)),
|
onError: (e) => toast.error(errorMessage(e)),
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { useCallback, useEffect, useLayoutEffect, useRef, useState } from "react
|
|||||||
import { Link, Outlet, useLocation, useMatches } from "react-router";
|
import { Link, Outlet, useLocation, useMatches } from "react-router";
|
||||||
import { useSession } from "~/api/auth.ts";
|
import { useSession } from "~/api/auth.ts";
|
||||||
import { AgentDrawer, AgentDrawerTrigger } from "~/components/agent/agent-drawer.tsx";
|
import { AgentDrawer, AgentDrawerTrigger } from "~/components/agent/agent-drawer.tsx";
|
||||||
|
import { outletKey } from "~/components/agent/agent-store.ts";
|
||||||
import { AccountMenu } from "~/components/console/account-menu.tsx";
|
import { AccountMenu } from "~/components/console/account-menu.tsx";
|
||||||
import { AgentLauncher, AgentNavEntry } from "~/components/console/agent-launcher.tsx";
|
import { AgentLauncher, AgentNavEntry } from "~/components/console/agent-launcher.tsx";
|
||||||
import { ErrorState } from "~/components/console/empty-state.tsx";
|
import { ErrorState } from "~/components/console/empty-state.tsx";
|
||||||
@@ -248,7 +249,10 @@ function ConsoleShell({ session }: { session: SessionInfo }) {
|
|||||||
|
|
||||||
{/* 底部留出悬浮按钮的高度,页面末尾的内容能滚到它上面 */}
|
{/* 底部留出悬浮按钮的高度,页面末尾的内容能滚到它上面 */}
|
||||||
<main className={cn("relative flex-1 overflow-x-clip", !wide && "px-4 pt-6 pb-24 md:px-6 md:pt-8")}>
|
<main className={cn("relative flex-1 overflow-x-clip", !wide && "px-4 pt-6 pb-24 md:px-6 md:pt-8")}>
|
||||||
<div key={location.pathname} className={cn(!wide && "mx-auto max-w-6xl", "motion-safe:animate-blur-in")}>
|
<div
|
||||||
|
key={outletKey(location.pathname)}
|
||||||
|
className={cn(!wide && "mx-auto max-w-6xl", "motion-safe:animate-blur-in")}
|
||||||
|
>
|
||||||
<Outlet />
|
<Outlet />
|
||||||
</div>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
|
|||||||
Reference in new issue
Block a user