fix(agent): 输入草稿按会话保留,切换会话、关抽屉不丢

会话面板与新对话输入框的草稿原来是组件内状态,切到别的会话或 Esc 关抽屉就没了。
改为按会话(以及列表页 / 抽屉两处新对话)分键记在模块里,发送成功后清掉。

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01Cxwgnn2pbzQ7rXv3rq7fei
This commit is contained in:
rootandClaude Opus 5.5 committed 2026-10-06 18:02:50 +08:00
1 parent 164a27fd80
commit 9f499ac7c2
5 files changed
+86 -6

No files matched your search

@@ -13,6 +13,7 @@ import { Popover, PopoverContent, PopoverTrigger } from "~/components/ui/popover
import { Sheet, SheetContent, SheetDescription, SheetHeader, SheetTitle } from "~/components/ui/sheet.tsx"; import { Sheet, 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, drawerListMemory } from "./agent-session-list.tsx"; import { AgentSessionList, drawerListMemory } from "./agent-session-list.tsx";
import { import {
@@ -170,12 +171,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)),
}, },
) )
@@ -2,7 +2,18 @@ import { createElement } from "react";
import { renderToStaticMarkup } from "react-dom/server"; import { renderToStaticMarkup } from "react-dom/server";
import { describe, expect, it } from "vitest"; import { describe, expect, it } from "vitest";
import { runGroup } from "~/api/agent.ts"; import { runGroup } from "~/api/agent.ts";
import { agentListHref, agentRunHref, groupFromSearch, resolveGroup, revealScrollTop } from "./agent-nav.ts"; 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 { CompactPager } from "./agent-session-list.tsx";
import { isAgentPage, outletKey } from "./agent-store.ts"; import { isAgentPage, outletKey } from "./agent-store.ts";
@@ -84,3 +95,27 @@ describe("会话列表分页(窄列表一行)", () => {
expect(html).toContain("1 / 1+"); 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("");
});
});
@@ -108,3 +108,20 @@ export function usePageListGroup(activeRun?: AgentRunGroup | null): [AgentRunGro
); );
return [group, change]; 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);
}
@@ -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);
@@ -295,7 +303,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();
+12 -2
View File
@@ -2,7 +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, pageListMemory, usePageListGroup } from "~/components/agent/agent-nav.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";
@@ -43,11 +49,15 @@ export default function AgentPage() {
<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(agentRunHref(run.id, mock)), onSuccess: (run) => {
writeDraft(DRAFT_PAGE_GOAL, "");
navigate(agentRunHref(run.id, mock));
},
onError: (e) => toast.error(errorMessage(e)), onError: (e) => toast.error(errorMessage(e)),
}, },
) )