fix(web): show local agent thinking until the answer starts

This commit is contained in:
tianma-if committed 2026-10-21 21:08:41 +08:00
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 };
}