fix(web): show local agent thinking until the answer starts
This commit is contained in:
1 parent
e15792d0a6
commit
ccb9ccafe3
5 files changed
+221
-39
No files matched your search
@@ -1661,7 +1661,12 @@ function AiSidebarSession({
|
||||
</Message>
|
||||
<Message from="assistant">
|
||||
{turn.reasoning.trim() || turn.tools.length ? (
|
||||
<AiSidebarLocalProcess reasoning={turn.reasoning} tools={turn.tools} running={turn.status === "running"} />
|
||||
<AiSidebarLocalProcess
|
||||
reasoning={turn.reasoning}
|
||||
tools={turn.tools}
|
||||
running={turn.status === "running"}
|
||||
answering={turn.response.trim().length > 0 || turn.images.length > 0}
|
||||
/>
|
||||
) : turn.status === "running" ? (
|
||||
<p role="status" className="flex items-center gap-2 text-xs text-slate-500">
|
||||
<Loader2 className="h-3.5 w-3.5 animate-spin" aria-hidden="true" />
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import { createElement } from "react";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
import { createInstance } from "i18next";
|
||||
import { I18nextProvider } from "react-i18next";
|
||||
import { zhCN } from "../../../../../packages/shared/src/i18n/zh-CN.ts";
|
||||
import { AiSidebarLocalProcess } from "./AiSidebarLocalProcess.tsx";
|
||||
|
||||
const renderProcess = async (props) => {
|
||||
const i18n = createInstance();
|
||||
await i18n.init({ lng: "zh-CN", resources: { "zh-CN": { translation: zhCN } } });
|
||||
return renderToStaticMarkup(createElement(
|
||||
I18nextProvider,
|
||||
{ i18n },
|
||||
createElement(AiSidebarLocalProcess, props),
|
||||
));
|
||||
};
|
||||
|
||||
describe("local agent thinking process", () => {
|
||||
test("opens streamed reasoning while the agent is still thinking", async () => {
|
||||
const markup = await renderProcess({
|
||||
reasoning: "先按名称和价格把 12 个节点对齐。",
|
||||
tools: [{ id: "tool-1", name: "view_file", status: "in_progress", title: "查看当前笔记" }],
|
||||
running: true,
|
||||
});
|
||||
expect(markup).toContain("思考过程");
|
||||
expect(markup).toContain("先按名称和价格把 12 个节点对齐。");
|
||||
expect(markup).toContain('data-state="open"');
|
||||
expect(markup).not.toContain('data-state="closed"');
|
||||
});
|
||||
|
||||
test("starts collapsed once the answer is already on screen", async () => {
|
||||
const markup = await renderProcess({
|
||||
reasoning: "先按名称和价格把 12 个节点对齐。",
|
||||
tools: [],
|
||||
running: true,
|
||||
answering: true,
|
||||
});
|
||||
expect(markup).toContain('data-state="closed"');
|
||||
expect(markup).not.toContain('data-state="open"');
|
||||
});
|
||||
|
||||
test("keeps a finished turn collapsed", async () => {
|
||||
const markup = await renderProcess({
|
||||
reasoning: "已经对过价格。",
|
||||
tools: [],
|
||||
running: false,
|
||||
});
|
||||
expect(markup).toContain("思考过程");
|
||||
expect(markup).toContain('data-state="closed"');
|
||||
expect(markup).not.toContain('data-state="open"');
|
||||
});
|
||||
});
|
||||
@@ -1,8 +1,10 @@
|
||||
import { CollapsibleContent } from "@/components/ui/collapsible";
|
||||
import { Reasoning, ReasoningTrigger } from "@/components/ai-elements/reasoning";
|
||||
import { Loader2 } from "lucide-react";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { AiSidebarMessage } from "./AiSidebarMessage";
|
||||
import { initialLocalProcessDisclosure, LOCAL_PROCESS_CLOSE_DELAY_MS, syncLocalProcessDisclosure, toggleLocalProcessDisclosure } from "./local-process-disclosure";
|
||||
|
||||
type ToolRow = { id: string; name: string; status: string; title?: string };
|
||||
|
||||
@@ -29,56 +31,88 @@ function statusKey(status: string) {
|
||||
return "pending";
|
||||
}
|
||||
|
||||
export function AiSidebarLocalProcess({ reasoning, tools, running }: { reasoning: string; tools: ToolRow[]; running: boolean }) {
|
||||
export function AiSidebarLocalProcess({ reasoning, tools, running, answering = false }: { reasoning: string; tools: ToolRow[]; running: boolean; answering?: boolean }) {
|
||||
const { t } = useTranslation();
|
||||
const phase = { running, answering };
|
||||
const [disclosure, setDisclosure] = useState(() => initialLocalProcessDisclosure(phase));
|
||||
const disclosureRef = useRef(disclosure);
|
||||
disclosureRef.current = disclosure;
|
||||
const scroller = useRef<HTMLDivElement>(null);
|
||||
const thinking = running && !answering;
|
||||
useEffect(() => {
|
||||
const next = syncLocalProcessDisclosure(disclosureRef.current, { running, answering });
|
||||
if ("closeSoon" in next) {
|
||||
const timer = window.setTimeout(() => {
|
||||
setDisclosure((current) => current.heldOpen || !current.open ? current : { ...current, open: false });
|
||||
}, LOCAL_PROCESS_CLOSE_DELAY_MS);
|
||||
return () => window.clearTimeout(timer);
|
||||
}
|
||||
const current = disclosureRef.current;
|
||||
if (next.open !== current.open || next.dismissed !== current.dismissed || next.heldOpen !== current.heldOpen) {
|
||||
setDisclosure(next);
|
||||
}
|
||||
}, [running, answering]);
|
||||
useEffect(() => {
|
||||
const node = scroller.current;
|
||||
if (!thinking || !disclosure.open || !node) return;
|
||||
node.scrollTop = node.scrollHeight;
|
||||
}, [reasoning, tools, thinking, disclosure.open]);
|
||||
if (!reasoning.trim() && !tools.length) return null;
|
||||
return (
|
||||
<Reasoning isStreaming={running} defaultOpen={false} className="mb-2">
|
||||
<Reasoning
|
||||
isStreaming={thinking}
|
||||
open={disclosure.open}
|
||||
defaultOpen={false}
|
||||
onOpenChange={(next) => setDisclosure((current) => toggleLocalProcessDisclosure(current, next, phase))}
|
||||
className="mb-2"
|
||||
>
|
||||
<ReasoningTrigger
|
||||
className="-ml-1 min-h-7 w-fit gap-1.5 rounded-sm px-1 py-1 text-xs leading-4 text-slate-500 hover:text-slate-800 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-slate-400 [&_svg]:size-3"
|
||||
getThinkingMessage={() => (
|
||||
<span className="flex items-center gap-1.5">
|
||||
{t("companion.process")}
|
||||
{running ? <Loader2 className="size-3 animate-spin" aria-hidden="true" /> : null}
|
||||
{thinking ? <Loader2 className="size-3 animate-spin" aria-hidden="true" /> : null}
|
||||
</span>
|
||||
)}
|
||||
/>
|
||||
<CollapsibleContent className="mt-2 max-h-80 overflow-y-auto rounded-lg border border-slate-200 bg-slate-50/70 p-3 text-xs leading-5 text-slate-600">
|
||||
{reasoning.trim() ? (
|
||||
<div className="[&_p]:my-1 [&_h1]:my-1 [&_h2]:my-1 [&_h3]:my-1 [&_li]:my-0">
|
||||
<AiSidebarMessage className="text-xs leading-5">{reasoning}</AiSidebarMessage>
|
||||
</div>
|
||||
) : null}
|
||||
{tools.length ? (
|
||||
<div className={reasoning.trim() ? "mt-3 border-t border-slate-200 pt-2" : ""}>
|
||||
<p className="mb-1.5 text-[11px] font-medium text-slate-500">{t("aiAssistant.sidebar.processTools.activity")}</p>
|
||||
<ul className="space-y-1.5">
|
||||
{tools.slice(-5).map((tool) => {
|
||||
const { label, detail } = toolSummary(tool, t);
|
||||
const state = statusKey(tool.status);
|
||||
return (
|
||||
<li key={tool.id} className="flex min-w-0 items-center gap-2">
|
||||
<span className={`size-1.5 shrink-0 rounded-full ${state === "running" ? "bg-sky-500" : state === "failed" ? "bg-rose-500" : "bg-slate-400"}`} aria-hidden="true" />
|
||||
<span className="min-w-0 flex-1 truncate">
|
||||
{label}{detail ? <span className="text-slate-400"> · {detail}</span> : null}
|
||||
</span>
|
||||
<span className="shrink-0 text-[11px] text-slate-400">{t(`aiAssistant.sidebar.processTools.${state}`)}</span>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
<details className="mt-2 text-[11px] text-slate-500">
|
||||
<summary className="w-fit cursor-pointer rounded-sm hover:text-slate-800 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-slate-400">
|
||||
{t("aiAssistant.sidebar.processTools.raw")}
|
||||
</summary>
|
||||
<ul className="mt-1.5 space-y-1 border-l border-slate-200 pl-2 font-mono leading-4">
|
||||
{tools.map((tool) => (
|
||||
<li key={tool.id} className="break-all">{tool.title || tool.name} · {tool.status}</li>
|
||||
))}
|
||||
<CollapsibleContent className="mt-2 rounded-lg border border-slate-200 bg-slate-50/70 text-xs leading-5 text-slate-600">
|
||||
<div ref={scroller} className="max-h-80 overflow-y-auto p-3">
|
||||
{reasoning.trim() ? (
|
||||
<div className="[&_p]:my-1 [&_h1]:my-1 [&_h2]:my-1 [&_h3]:my-1 [&_li]:my-0">
|
||||
<AiSidebarMessage className="text-xs leading-5" isAnimating={thinking}>{reasoning}</AiSidebarMessage>
|
||||
</div>
|
||||
) : null}
|
||||
{tools.length ? (
|
||||
<div className={reasoning.trim() ? "mt-3 border-t border-slate-200 pt-2" : ""}>
|
||||
<p className="mb-1.5 text-[11px] font-medium text-slate-500">{t("aiAssistant.sidebar.processTools.activity")}</p>
|
||||
<ul className="space-y-1.5">
|
||||
{tools.slice(-5).map((tool) => {
|
||||
const { label, detail } = toolSummary(tool, t);
|
||||
const state = statusKey(tool.status);
|
||||
return (
|
||||
<li key={tool.id} className="flex min-w-0 items-center gap-2">
|
||||
<span className={`size-1.5 shrink-0 rounded-full ${state === "running" ? "bg-sky-500" : state === "failed" ? "bg-rose-500" : "bg-slate-400"}`} aria-hidden="true" />
|
||||
<span className="min-w-0 flex-1 truncate">
|
||||
{label}{detail ? <span className="text-slate-400"> · {detail}</span> : null}
|
||||
</span>
|
||||
<span className="shrink-0 text-[11px] text-slate-400">{t(`aiAssistant.sidebar.processTools.${state}`)}</span>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
</details>
|
||||
</div>
|
||||
) : null}
|
||||
<details className="mt-2 text-[11px] text-slate-500">
|
||||
<summary className="w-fit cursor-pointer rounded-sm hover:text-slate-800 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-slate-400">
|
||||
{t("aiAssistant.sidebar.processTools.raw")}
|
||||
</summary>
|
||||
<ul className="mt-1.5 space-y-1 border-l border-slate-200 pl-2 font-mono leading-4">
|
||||
{tools.map((tool) => (
|
||||
<li key={tool.id} className="break-all">{tool.title || tool.name} · {tool.status}</li>
|
||||
))}
|
||||
</ul>
|
||||
</details>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</CollapsibleContent>
|
||||
</Reasoning>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import {
|
||||
initialLocalProcessDisclosure,
|
||||
LOCAL_PROCESS_CLOSE_DELAY_MS,
|
||||
syncLocalProcessDisclosure,
|
||||
toggleLocalProcessDisclosure,
|
||||
} from "./local-process-disclosure.ts";
|
||||
|
||||
const thinking = { running: true, answering: false };
|
||||
const answering = { running: true, answering: true };
|
||||
const finished = { running: false, answering: true };
|
||||
|
||||
describe("local process disclosure", () => {
|
||||
test("starts open only while thinking", () => {
|
||||
expect(initialLocalProcessDisclosure(thinking).open).toBe(true);
|
||||
expect(initialLocalProcessDisclosure(answering).open).toBe(false);
|
||||
expect(initialLocalProcessDisclosure(finished).open).toBe(false);
|
||||
expect(LOCAL_PROCESS_CLOSE_DELAY_MS).toBe(1000);
|
||||
});
|
||||
|
||||
test("stays open through thinking and schedules a close once the answer starts", () => {
|
||||
const open = initialLocalProcessDisclosure(thinking);
|
||||
expect(syncLocalProcessDisclosure(open, thinking)).toEqual(open);
|
||||
expect(syncLocalProcessDisclosure(open, answering)).toEqual({ closeSoon: true });
|
||||
expect(syncLocalProcessDisclosure(open, finished)).toEqual({ closeSoon: true });
|
||||
});
|
||||
|
||||
test("does not reopen a panel the user collapsed while thinking", () => {
|
||||
const collapsed = toggleLocalProcessDisclosure(initialLocalProcessDisclosure(thinking), false, thinking);
|
||||
expect(collapsed).toEqual({ open: false, dismissed: true, heldOpen: false });
|
||||
expect(syncLocalProcessDisclosure(collapsed, thinking)).toEqual(collapsed);
|
||||
expect(syncLocalProcessDisclosure(collapsed, answering)).toEqual(collapsed);
|
||||
});
|
||||
|
||||
test("keeps a manual reopen after the answer starts", () => {
|
||||
const closed = { open: false, dismissed: false, heldOpen: false };
|
||||
const reopened = toggleLocalProcessDisclosure(closed, true, answering);
|
||||
expect(reopened.heldOpen).toBe(true);
|
||||
expect(syncLocalProcessDisclosure(reopened, answering)).toEqual(reopened);
|
||||
expect(syncLocalProcessDisclosure(reopened, finished)).toEqual(reopened);
|
||||
});
|
||||
|
||||
test("still closes when the user reopens during thinking and the answer then starts", () => {
|
||||
const collapsed = toggleLocalProcessDisclosure(initialLocalProcessDisclosure(thinking), false, thinking);
|
||||
const reopened = toggleLocalProcessDisclosure(collapsed, true, thinking);
|
||||
expect(reopened).toEqual({ open: true, dismissed: false, heldOpen: false });
|
||||
expect(syncLocalProcessDisclosure(reopened, answering)).toEqual({ closeSoon: true });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,41 @@
|
||||
export const LOCAL_PROCESS_CLOSE_DELAY_MS = 1000;
|
||||
|
||||
export type LocalProcessPhase = { running: boolean; answering: boolean };
|
||||
|
||||
export type LocalProcessDisclosure = {
|
||||
open: boolean;
|
||||
/** The user collapsed the panel while thinking was still the only output. */
|
||||
dismissed: boolean;
|
||||
/** The user reopened the panel after the answer had already started. */
|
||||
heldOpen: boolean;
|
||||
};
|
||||
|
||||
export const initialLocalProcessDisclosure = (phase: LocalProcessPhase): LocalProcessDisclosure => ({
|
||||
open: phase.running && !phase.answering,
|
||||
dismissed: false,
|
||||
heldOpen: false,
|
||||
});
|
||||
|
||||
export function syncLocalProcessDisclosure(
|
||||
current: LocalProcessDisclosure,
|
||||
phase: LocalProcessPhase,
|
||||
): LocalProcessDisclosure | { closeSoon: true } {
|
||||
if (current.heldOpen) return current;
|
||||
if (phase.running && !phase.answering) {
|
||||
if (current.dismissed) return { ...current, open: false };
|
||||
return { open: true, dismissed: false, heldOpen: false };
|
||||
}
|
||||
if (!current.open) return { ...current, open: false };
|
||||
return { closeSoon: true };
|
||||
}
|
||||
|
||||
export function toggleLocalProcessDisclosure(
|
||||
current: LocalProcessDisclosure,
|
||||
open: boolean,
|
||||
phase: LocalProcessPhase,
|
||||
): LocalProcessDisclosure {
|
||||
if (open) {
|
||||
return { open: true, dismissed: false, heldOpen: !phase.running || phase.answering };
|
||||
}
|
||||
return { open: false, dismissed: phase.running && !phase.answering, heldOpen: false };
|
||||
}
|
||||
Reference in new issue
Block a user