会话面板与新对话输入框的草稿原来是组件内状态,切到别的会话或 Esc 关抽屉就没了。 改为按会话(以及列表页 / 抽屉两处新对话)分键记在模块里,发送成功后清掉。 Co-Authored-By: Claude Opus 5.5 <[email protected]> Claude-Session: https://claude.ai/code/session_01Cxwgnn2pbzQ7rXv3rq7fei
128 lines
5.0 KiB
TypeScript
128 lines
5.0 KiB
TypeScript
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);
|
|
}
|