fix: unify chat process and reply presentation

This commit is contained in:
2026-10-29 17:54:34 +08:00
parent 7621bf0f86
commit 9474788db6
46 changed files with 3160 additions and 500 deletions
@@ -0,0 +1,103 @@
import { useLayoutEffect, useRef, useState } from "react";
import { useAnimationControls, useReducedMotion } from "motion/react";
import { useStickToBottomContext } from "use-stick-to-bottom";
import type { AgentChatMessage } from "../types";
import { agentEase } from "../components/agent-motion";
type ReadingAnchor = { element: HTMLElement; text: string; top: number };
const readable = "p, h1, h2, h3, li, summary";
/** A final report is readable before transport cleanup finishes. Handoff once. */
export function useAgentAnswerTransition(message: AgentChatMessage, running: boolean) {
const ready = Boolean(message.finalAnswer) || !running;
const [committed, setCommitted] = useState(ready);
const [phase, setPhase] = useState(ready ? "settled" : "streaming");
const previous = useRef(message);
const root = useRef<HTMLDivElement>(null);
const anchor = useRef<{ scroll: HTMLElement; reading?: ReadingAnchor; top: number; release: () => void } | null>(null);
const controls = useAnimationControls();
const reducedMotion = useReducedMotion();
const { scrollRef, stopScroll, state } = useStickToBottomContext();
useLayoutEffect(() => { if (!ready) previous.current = message; }, [message, ready]);
useLayoutEffect(() => {
if (!ready && committed) {
setCommitted(false);
setPhase("streaming");
controls.set({ opacity: 1 });
return;
}
if (!ready || committed) return;
let disposed = false;
const scroll = scrollRef.current;
const element = root.current;
if (scroll && element) {
anchor.current?.release();
anchor.current = null;
const viewport = scroll.getBoundingClientRect();
// Use the same near-bottom state as the conversation's follow button.
const following = state.isAtBottom || state.isNearBottom;
const reading = !following ? [...scroll.querySelectorAll<HTMLElement>(readable)].find((node) => {
const bounds = node.getBoundingClientRect();
return bounds.height > 0 && bounds.bottom > viewport.top + 12 && bounds.top < viewport.bottom;
}) : undefined;
stopScroll();
const overflowAnchor = scroll.style.overflowAnchor;
scroll.style.overflowAnchor = "none";
const cancel = () => { anchor.current?.release(); anchor.current = null; };
const events = ["wheel", "pointerdown", "touchmove", "keydown"];
for (const event of events) scroll.addEventListener(event, cancel, { passive: true });
anchor.current = { scroll,
reading: reading ? { element: reading, text: reading.textContent ?? "", top: reading.getBoundingClientRect().top } : undefined,
top: Math.max(viewport.top + 12, element.getBoundingClientRect().top),
release: () => {
scroll.style.overflowAnchor = overflowAnchor;
for (const event of events) scroll.removeEventListener(event, cancel);
},
};
}
const commit = () => { if (!disposed) { setCommitted(true); setPhase(reducedMotion ? "settled" : "entering"); } };
if (reducedMotion) commit();
else {
setPhase("leaving");
void controls.start({ opacity: 0 }, { duration: 0.08, ease: agentEase }).then(commit);
}
return () => { disposed = true; controls.stop(); };
}, [committed, controls, ready, reducedMotion, scrollRef, state, stopScroll]);
useLayoutEffect(() => {
if (!committed) return;
let disposed = false;
let frame: number | undefined;
const restore = () => {
const current = anchor.current;
if (!current || !root.current) return;
let target: HTMLElement | null = root.current.querySelector("[data-answer-parts]") ?? root.current;
const reading = current.reading;
if (reading) {
target = reading.element.isConnected && reading.element.textContent === reading.text && reading.element.getBoundingClientRect().height > 0 ? reading.element
: [...current.scroll.querySelectorAll<HTMLElement>(readable)].find((node) => node.textContent === reading.text && node.getBoundingClientRect().height > 0) ?? target;
}
if (target) current.scroll.scrollTop += target.getBoundingClientRect().top - (reading?.top ?? current.top);
};
restore();
frame = requestAnimationFrame(restore);
const finish = () => {
if (disposed) return;
restore();
anchor.current?.release();
anchor.current = null;
setPhase("settled");
};
if (reducedMotion) {
controls.set({ opacity: 1 });
frame = requestAnimationFrame(() => { restore(); frame = requestAnimationFrame(finish); });
}
else void controls.start({ opacity: 1 }, { duration: 0.12, ease: agentEase }).then(finish);
return () => { disposed = true; if (frame !== undefined) cancelAnimationFrame(frame); controls.stop(); };
}, [committed, controls, reducedMotion]);
useLayoutEffect(() => () => { anchor.current?.release(); anchor.current = null; }, []);
return { message: ready && !committed ? previous.current : message, completed: committed, phase, root, controls };
}
@@ -0,0 +1,51 @@
import { act, renderHook } from "@testing-library/react";
import { afterEach, expect, it, vi } from "vitest";
import { useAgentConnection } from "./use-agent-connection";
afterEach(() => vi.useRealTimers());
it("rechecks an offline service, probes on focus and clears work on unmount", async () => {
vi.useFakeTimers();
const getHealth = vi.fn().mockRejectedValueOnce(new Error("offline")).mockResolvedValue(undefined);
const client = { getHealth };
const { result, unmount } = renderHook(() => useAgentConnection(client, "project-a"));
await act(async () => {});
expect(result.current.connectionStatus).toBe("offline");
await act(async () => vi.advanceTimersByTimeAsync(1_000));
expect(result.current.connectionStatus).toBe("online");
expect(result.current.recoveryVersion).toBe(1);
await act(async () => window.dispatchEvent(new Event("focus")));
expect(getHealth).toHaveBeenCalledTimes(3);
unmount();
expect(vi.getTimerCount()).toBe(0);
});
it("discards a late result from a previous client and scope", async () => {
vi.useFakeTimers();
let finish!: () => void;
let oldSignal!: AbortSignal;
const oldClient = { getHealth: vi.fn((signal: AbortSignal) => { oldSignal = signal; return new Promise<void>((resolve) => { finish = resolve; }); }) };
const nextClient = { getHealth: vi.fn(async () => { throw new Error("next service offline"); }) };
const { result, rerender, unmount } = renderHook(({ client, scope }) => useAgentConnection(client, scope), { initialProps: { client: oldClient, scope: "old" } });
await act(async () => {});
rerender({ client: nextClient, scope: "next" });
await act(async () => {});
expect(oldSignal.aborted).toBe(true);
await act(async () => finish());
expect(result.current.connectionStatus).toBe("offline");
unmount();
});
it("aborts a stalled probe and recovers on the next retry", async () => {
vi.useFakeTimers();
const getHealth = vi.fn().mockImplementationOnce((signal: AbortSignal) => new Promise<void>((_resolve, reject) => {
signal.addEventListener("abort", () => reject(new DOMException("timeout", "AbortError")), { once: true });
})).mockResolvedValue(undefined);
const client = { getHealth };
const { result, unmount } = renderHook(() => useAgentConnection(client, "project"));
await act(async () => vi.advanceTimersByTimeAsync(5_000));
expect(result.current.connectionStatus).toBe("offline");
await act(async () => vi.advanceTimersByTimeAsync(1_000));
expect(result.current.connectionStatus).toBe("online");
unmount();
});
@@ -0,0 +1,80 @@
import { useCallback, useEffect, useRef, useState } from "react";
import type { AgentApiClient } from "../api/client";
import type { AgentConnectionStatus } from "../types";
/** Service readiness is independent of a conversation's business result. */
export function useAgentConnection(client: Pick<AgentApiClient, "getHealth">, scope: string) {
const [snapshot, setSnapshot] = useState({ client, scope, status: "connecting" as AgentConnectionStatus, recoveryVersion: 0 });
const check = useRef<() => void>(() => {});
const recheck = useCallback(() => check.current(), []);
useEffect(() => {
let disposed = false;
let timer: ReturnType<typeof setTimeout> | undefined;
let timeout: ReturnType<typeof setTimeout> | undefined;
let controller: AbortController | undefined;
let failures = 0;
let status: AgentConnectionStatus = "connecting";
let recoveryVersion = 0;
const publish = (next: AgentConnectionStatus) => {
if (disposed) return;
if (next === "online" && status === "offline") recoveryVersion++;
status = next;
setSnapshot((previous) => previous.client === client && previous.scope === scope && previous.status === status && previous.recoveryVersion === recoveryVersion
? previous : { client, scope, status, recoveryVersion });
};
const probe = async () => {
if (disposed || controller) return;
clearTimeout(timer);
if (!navigator.onLine) { publish("offline"); return; }
const pending = new AbortController();
controller = pending;
timeout = setTimeout(() => pending.abort(), 5_000);
try {
await client.getHealth(pending.signal);
if (disposed || controller !== pending) return;
failures = 0;
publish("online");
} catch {
if (disposed || controller !== pending) return;
failures++;
publish("offline");
} finally {
if (controller === pending) {
clearTimeout(timeout);
controller = undefined;
if (!disposed) timer = setTimeout(() => void probe(), failures ? Math.min(1_000 * 2 ** Math.min(failures - 1, 4), 15_000) : 15_000);
}
}
};
const reconnect = () => { void probe(); };
const visible = () => { if (document.visibilityState === "visible") reconnect(); };
const offline = () => {
clearTimeout(timer);
clearTimeout(timeout);
const pending = controller;
controller = undefined;
pending?.abort();
publish("offline");
};
check.current = reconnect;
publish("connecting");
reconnect();
window.addEventListener("focus", reconnect);
window.addEventListener("online", reconnect);
window.addEventListener("offline", offline);
document.addEventListener("visibilitychange", visible);
return () => {
disposed = true;
clearTimeout(timer);
clearTimeout(timeout);
controller?.abort();
check.current = () => {};
window.removeEventListener("focus", reconnect);
window.removeEventListener("online", reconnect);
window.removeEventListener("offline", offline);
document.removeEventListener("visibilitychange", visible);
};
}, [client, scope]);
return { connectionStatus: snapshot.client === client && snapshot.scope === scope ? snapshot.status : "connecting" as const,
recoveryVersion: snapshot.client === client && snapshot.scope === scope ? snapshot.recoveryVersion : 0, recheck };
}
@@ -0,0 +1,138 @@
import { act } from "react";
import { createRoot } from "react-dom/client";
import { expect, it, vi } from "vitest";
import type { InteractionRequest, InteractionRequestApi } from "../api/interaction-requests";
import { useInteractionRequests } from "./use-interaction-requests";
const record = (status: InteractionRequest["status"] = "pending", revision = 0): InteractionRequest => ({ version: "agent-interaction-request@1", interaction_id: "i", message_id: "m", turn_id: "t", call_id: "c", questions: [{ question_id: "q", title: "确认范围", body: "选择范围", mode: "single", options: [{ id: "all", label: "全部" }], required: true, allow_custom: true }], status, revision, draft_revision: 0, draft: [], created_at: "now" });
it("debounces draft CAS and never continues on open; late pending reads cannot revive accepted answers", async () => {
vi.useFakeTimers();
const root = createRoot(document.createElement("div"));
let readResolve!: (value: { items: InteractionRequest[] }) => void;
const api: InteractionRequestApi = { listInteractionRequests: vi.fn(async () => ({ items: [record()] })), getInteractionRequest: vi.fn(async () => record("answered", 1)), writeInteractionRequest: vi.fn(async (_session, _id, action) => action === "draft" ? { ...record(), draft_revision: 1 } : { ...record("answered", 1), delivery: { native_message_id: "native", revision: 0, status: "queued" as const } }) };
const resume = vi.fn(async () => {});
let current!: ReturnType<typeof useInteractionRequests>;
function Probe({ session = "first" }: { session?: string }) { current = useInteractionRequests(api, "actor:project", session, resume); return null; }
try {
await act(async () => root.render(<Probe />));
expect(resume).not.toHaveBeenCalled();
await act(async () => current.update("i", [{ question_id: "q", selected_option_ids: ["all"], custom_text: "" }]));
await act(async () => vi.advanceTimersByTimeAsync(499));
expect(api.writeInteractionRequest).not.toHaveBeenCalled();
await act(async () => vi.advanceTimersByTimeAsync(1));
expect(api.writeInteractionRequest).toHaveBeenCalledWith("first", "i", "draft", expect.objectContaining({ expected_draft_revision: 0 }));
vi.mocked(api.listInteractionRequests).mockImplementationOnce(() => new Promise((resolve) => { readResolve = resolve; }));
let refresh!: Promise<void>;
await act(async () => { refresh = current.refresh(); });
// Finish checking then submit. A stale read dispatched before submit must not win afterward.
await act(async () => { readResolve({ items: [record()] }); await refresh; });
await act(async () => current.act("i", "reply"));
expect(current.entries[0].record.status).toBe("answered");
expect(resume).toHaveBeenCalledWith("first", "i");
await act(async () => current.refresh());
expect(current.entries[0].record.status).toBe("answered");
await act(async () => root.render(<Probe session="second" />));
expect(current.entries[0].record.status).toBe("pending");
await act(async () => root.render(<Probe />));
expect(current.entries[0].record.status).toBe("answered");
} finally { await act(async () => root.unmount()); vi.useRealTimers(); }
});
it("binds a delayed submission to its original session and keeps the next session unchanged", async () => {
const root = createRoot(document.createElement("div"));
let resolveReply!: (value: InteractionRequest) => void;
const api: InteractionRequestApi = { listInteractionRequests: vi.fn(async () => ({ items: [record()] })), getInteractionRequest: vi.fn(async () => record()), writeInteractionRequest: vi.fn(() => new Promise<InteractionRequest>((resolve) => { resolveReply = resolve; })) };
const resume = vi.fn(async () => {});
let current!: ReturnType<typeof useInteractionRequests>;
function Probe({ session }: { session: string }) { current = useInteractionRequests(api, "actor", session, resume); return null; }
try {
await act(async () => root.render(<Probe session="first" />));
await act(async () => current.update("i", [{ question_id: "q", selected_option_ids: ["all"], custom_text: "first draft" }]));
let submission!: Promise<void>;
await act(async () => { submission = current.act("i", "reply"); });
await act(async () => root.render(<Probe session="second" />));
await act(async () => { resolveReply({ ...record("answered", 1), delivery: { native_message_id: "native", revision: 0, status: "queued" } }); await submission; });
expect(resume).toHaveBeenCalledWith("first", "i");
expect(current.entries[0].record.status).toBe("pending");
expect(current.entries[0].draft).toEqual([]);
await act(async () => root.render(<Probe session="first" />));
expect(current.entries[0].record.status).toBe("answered");
} finally { await act(async () => root.unmount()); }
});
it("keeps delivery revisions monotonic and ignores superseded reads", async () => {
const root = createRoot(document.createElement("div"));
const delivered = { ...record("answered", 1), delivery: { native_message_id: "native", revision: 2, status: "delivered" as const } };
const api: InteractionRequestApi = { listInteractionRequests: vi.fn(async () => ({ items: [delivered] })), getInteractionRequest: vi.fn(async () => delivered), writeInteractionRequest: vi.fn(async () => delivered) };
const resume = vi.fn(async () => {});
let current!: ReturnType<typeof useInteractionRequests>;
function Probe() { current = useInteractionRequests(api, "actor", "session", resume); return null; }
try {
await act(async () => root.render(<Probe />));
vi.mocked(api.listInteractionRequests).mockResolvedValueOnce({ items: [{ ...delivered, delivery: { ...delivered.delivery, revision: 1, status: "queued" } }] });
await act(async () => current.refresh());
expect(current.entries[0].record.delivery?.status).toBe("delivered");
let first!: (value: { items: InteractionRequest[] }) => void;
let second!: (value: { items: InteractionRequest[] }) => void;
vi.mocked(api.listInteractionRequests).mockImplementationOnce(() => new Promise((resolve) => { first = resolve; })).mockImplementationOnce(() => new Promise((resolve) => { second = resolve; }));
let a!: Promise<void>, b!: Promise<void>;
await act(async () => { a = current.refresh(); b = current.refresh(); });
await act(async () => { first({ items: [delivered] }); await a; });
expect(current.checking).toBe(true);
await act(async () => { second({ items: [delivered] }); await b; });
expect(current.checking).toBe(false);
} finally { await act(async () => root.unmount()); }
});
it("purges revoked snapshots and prevents pending reads and old callbacks from restoring them", async () => {
const { InteractionRequestApiError } = await import("../api/interaction-requests");
const root = createRoot(document.createElement("div"));
const api: InteractionRequestApi = { listInteractionRequests: vi.fn(async () => ({ items: [record()] })), getInteractionRequest: vi.fn(async () => record()), writeInteractionRequest: vi.fn(async () => record()) };
const resume = vi.fn(async () => {});
let current!: ReturnType<typeof useInteractionRequests>;
function Probe({ client = api }: { client?: InteractionRequestApi }) { current = useInteractionRequests(client, "actor", "session", resume); return null; }
try {
await act(async () => root.render(<Probe />));
const staleAction = current.act;
vi.mocked(api.listInteractionRequests).mockRejectedValueOnce(new InteractionRequestApiError("无权访问", 403));
await act(async () => current.refresh());
expect(current.entries).toEqual([]);
await act(async () => staleAction("i", "reply"));
expect(api.writeInteractionRequest).not.toHaveBeenCalled();
const next = { ...api, listInteractionRequests: vi.fn(async () => ({ items: [] })) };
await act(async () => root.render(<Probe client={next} />));
expect(current.entries).toEqual([]);
await act(async () => staleAction("i", "reply"));
expect(resume).not.toHaveBeenCalled();
} finally { await act(async () => root.unmount()); }
});
it("flushes the old session draft on switch without waiting for the debounce", async () => {
vi.useFakeTimers();
const root = createRoot(document.createElement("div"));
const api: InteractionRequestApi = { listInteractionRequests: vi.fn(async () => ({ items: [record()] })), getInteractionRequest: vi.fn(async () => record()), writeInteractionRequest: vi.fn(async () => ({ ...record(), draft_revision: 1 })) };
const resume = vi.fn(async () => {});
let current!: ReturnType<typeof useInteractionRequests>;
function Probe({ session }: { session: string }) { current = useInteractionRequests(api, "actor", session, resume); return null; }
try {
await act(async () => root.render(<Probe session="first" />));
await act(async () => current.update("i", [{ question_id: "q", selected_option_ids: [], custom_text: "保留的输入" }]));
expect(api.writeInteractionRequest).not.toHaveBeenCalled();
await act(async () => root.render(<Probe session="second" />));
expect(api.writeInteractionRequest).toHaveBeenCalledWith("first", "i", "draft", expect.objectContaining({ answers: [{ question_id: "q", selected_option_ids: [], custom_text: "保留的输入" }] }));
expect(current.entries[0].draft).toEqual([]);
} finally { await act(async () => root.unmount()); vi.useRealTimers(); }
});
it("drops an in-flight accepted reply after the API identity changes", async () => {
const root = createRoot(document.createElement("div"));
let resolveReply!: (value: InteractionRequest) => void;
const api: InteractionRequestApi = { listInteractionRequests: vi.fn(async () => ({ items: [record()] })), getInteractionRequest: vi.fn(async () => record()), writeInteractionRequest: vi.fn(() => new Promise<InteractionRequest>((resolve) => { resolveReply = resolve; })) };
const other = { ...api, listInteractionRequests: vi.fn(async () => ({ items: [] })) };
const resume = vi.fn(async () => {});
let current!: ReturnType<typeof useInteractionRequests>;
function Probe({ client }: { client: InteractionRequestApi }) { current = useInteractionRequests(client, "project", "session", resume); return null; }
try {
await act(async () => root.render(<Probe client={api} />));
let submission!: Promise<void>;
await act(async () => { submission = current.act("i", "reply"); });
await act(async () => root.render(<Probe client={other} />));
await act(async () => { resolveReply({ ...record("answered", 1), delivery: { native_message_id: "native", revision: 0, status: "queued" } }); await submission; });
expect(current.entries).toEqual([]);
expect(resume).not.toHaveBeenCalled();
} finally { await act(async () => root.unmount()); }
});
@@ -0,0 +1,124 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { InteractionRequestApiError, canReplaceInteractionRequest, type InteractionAnswer, type InteractionRequest, type InteractionRequestApi } from "../api/interaction-requests";
const version = "agent-interaction-request@1" as const;
const requestId = () => crypto.randomUUID();
type Entry = { record: InteractionRequest; draft: InteractionAnswer[]; dirty: boolean; busy: boolean; saving?: boolean; error?: string; conflict?: InteractionAnswer[]; submission?: { id: string; answers: InteractionAnswer[] }; draftWrite?: { id: string; answers: InteractionAnswer[]; revision: number } };
export type InteractionRequestEntry = Entry;
const denied = (error: unknown) => error instanceof Error && "status" in error && typeof error.status === "number" && [401, 403, 404].includes(error.status);
export function useInteractionRequests(api: InteractionRequestApi, scope: string, sessionId: string | null, onContinue: (sessionId: string, interactionId: string) => Promise<void>) {
const identity = useRef({ api, scope, generation: 0, revoked: false });
const cache = useRef(new Map<string, Map<string, Entry>>());
const checked = useRef(new Set<string>());
const sequences = useRef(new Map<string, number>());
if (identity.current.api !== api || identity.current.scope !== scope) {
identity.current = { api, scope, generation: identity.current.generation + 1, revoked: false };
cache.current.clear(); checked.current.clear(); sequences.current.clear();
}
const generation = identity.current.generation;
const valid = useCallback(() => identity.current.generation === generation && !identity.current.revoked, [generation]);
const key = `${scope}:${generation}:${sessionId ?? ""}`;
const active = useRef(key); active.current = key;
const [tick, render] = useState(0);
const [checkingKey, setCheckingKey] = useState<string>();
const [loadError, setLoadError] = useState<{ key: string; message: string }>();
const notify = useCallback(() => render((value) => value + 1), []);
const entries = useMemo(() => {
const existing = cache.current.get(key) ?? new Map<string, Entry>();
cache.current.set(key, existing);
return existing;
}, [key]);
const purge = useCallback((error: unknown) => {
if (!valid() || !denied(error)) return false;
cache.current.forEach((items) => items.clear());
checked.current.clear(); sequences.current.clear();
identity.current.generation += 1;
identity.current.revoked = true;
setLoadError({ key, message: error instanceof Error ? error.message : "提问访问权限已失效" });
notify(); return true;
}, [key, notify, valid]);
const refresh = useCallback(async () => {
if (!sessionId || !valid()) return;
const target = entries;
const sequence = (sequences.current.get(key) ?? 0) + 1;
sequences.current.set(key, sequence);
const latest = () => valid() && sequences.current.get(key) === sequence;
setCheckingKey(key); checked.current.delete(key);
try {
const result = await api.listInteractionRequests(sessionId);
if (!latest()) return;
for (const record of result.items) {
const previous = target.get(record.interaction_id);
if (previous && !canReplaceInteractionRequest(record, previous.record)) continue;
const next: Entry = { ...previous, record, draft: previous?.dirty ? previous.draft : record.draft, dirty: previous?.dirty ?? false, busy: previous?.busy ?? false,
...(record.status !== "pending" && previous?.dirty ? { conflict: previous.draft } : {}) };
target.set(record.interaction_id, previous ? Object.assign(previous, next) : next);
}
checked.current.add(key);
if (active.current === key) setLoadError(undefined);
} catch (error) {
if (latest() && !purge(error) && active.current === key) setLoadError({ key, message: error instanceof Error ? error.message : "提问核对失败" });
} finally { if (latest() && active.current === key) { setCheckingKey(undefined); notify(); } }
}, [api, entries, key, sessionId, notify, valid, purge]);
useEffect(() => { void refresh(); }, [refresh]);
const update = (interactionId: string, draft: InteractionAnswer[]) => {
const entry = entries.get(interactionId);
if (!valid() || !entry || entry.record.status !== "pending" || entry.busy) return;
entry.draft = draft; entry.dirty = true; entry.error = undefined; entry.submission = undefined; notify();
};
const saveDrafts = useCallback(() => {
if (!sessionId || !valid() || !checked.current.has(key)) return;
for (const entry of entries.values()) {
if (!entry.dirty || entry.busy || entry.saving || entry.error || entry.record.status !== "pending") continue;
entry.saving = true;
const write = entry.draftWrite ?? { id: requestId(), answers: entry.draft, revision: entry.record.draft_revision };
entry.draftWrite = write;
void api.writeInteractionRequest(sessionId, entry.record.interaction_id, "draft", { version, request_id: write.id, expected_draft_revision: write.revision, answers: write.answers }).then((record) => {
if (!valid() || !canReplaceInteractionRequest(record, entry.record)) return;
entry.record = record; entry.draftWrite = undefined;
if (entry.draft === write.answers) entry.dirty = false;
}).catch((error: unknown) => {
if (!valid() || purge(error)) return;
if (error instanceof InteractionRequestApiError && error.current && canReplaceInteractionRequest(error.current, entry.record)) {
entry.record = error.current; entry.draftWrite = undefined;
if (error.current.status !== "pending") entry.conflict = write.answers;
}
entry.error = error instanceof Error ? error.message : "草稿未保存";
}).finally(() => { entry.saving = false; if (valid() && active.current === key) notify(); });
}
}, [api, entries, key, notify, purge, sessionId, valid]);
useEffect(() => {
const timer = window.setTimeout(saveDrafts, 500);
return () => window.clearTimeout(timer);
}, [saveDrafts, tick]);
// Flush the owning session before replacing its subscription. Identity changes invalidate this closure.
useEffect(() => () => saveDrafts(), [saveDrafts]);
const act = async (interactionId: string, action: "reply" | "skip" | "cancel" | "resume") => {
if (!sessionId || !valid() || !checked.current.has(key)) return;
const entry = entries.get(interactionId);
if (!entry || entry.busy || checkingKey === key) return;
entry.busy = true; entry.error = undefined; notify();
const submission = entry.submission ?? { id: requestId(), answers: structuredClone(entry.draft) };
if (action === "reply") entry.submission = submission;
try {
const record = await api.writeInteractionRequest(sessionId, interactionId, action, { version, request_id: action === "reply" ? submission.id : requestId(), expected_revision: action === "resume" ? entry.record.delivery?.revision ?? 0 : entry.record.revision, ...(action === "reply" ? { answers: submission.answers } : {}) });
if (!valid()) return;
if (canReplaceInteractionRequest(record, entry.record)) entry.record = record;
entry.dirty = false;
if (action !== "cancel" && record.delivery && ["queued", "blocked", "dispatching"].includes(record.delivery.status)) await onContinue(sessionId, interactionId);
} catch (error) {
if (!valid() || purge(error)) return;
if (error instanceof InteractionRequestApiError && error.current && canReplaceInteractionRequest(error.current, entry.record)) { entry.record = error.current; entry.conflict = submission.answers; }
entry.error = error instanceof Error ? error.message : "操作失败,请重试";
try {
const record = await api.getInteractionRequest(sessionId, interactionId);
if (valid() && canReplaceInteractionRequest(record, entry.record)) entry.record = record;
} catch (readError) { purge(readError); }
} finally { entry.busy = false; if (valid() && active.current === key) notify(); }
};
const list = [...entries.values()];
const checking = Boolean(sessionId) && (!checked.current.has(key) || checkingKey === key);
return { entries: list, checking, error: loadError && (loadError.key === key || identity.current.revoked) ? loadError.message : undefined, refresh, update, act,
awaitingInput: list.some(({ record }) => record.status === "pending" || (record.delivery && ["queued", "dispatching", "blocked"].includes(record.delivery.status))) };
}
export type InteractionRequestsController = ReturnType<typeof useInteractionRequests>;