diff --git a/apps/desktop/src/components/editor/AiAssistant.vue b/apps/desktop/src/components/editor/AiAssistant.vue index 4aaf8aa7fe..fae495199e 100644 --- a/apps/desktop/src/components/editor/AiAssistant.vue +++ b/apps/desktop/src/components/editor/AiAssistant.vue @@ -78,6 +78,7 @@ import { AI_TABLE_MENTION_CANDIDATE_LIMIT, AI_TABLE_MENTION_SCHEMA_LIMIT, filter import { isAiPromptImeCompositionEvent, shouldSubmitAiPromptOnKeydown } from "@/lib/ai/aiPromptKeyboard"; import { looksLikeActionProposal, containsChinese } from "@/lib/ai/aiProposalDetect"; import { visibleToActualIndex } from "@/lib/ai/aiMessageEdit"; +import { shouldShowReasoningCharCount, reasoningCharCountClass } from "@/lib/ai/aiReasoningPresentation"; const { t } = useI18n(); const settings = useSettingsStore(); @@ -1871,6 +1872,7 @@ async function openExternalUrl(url: string) { {{ t("ai.reasoningProcess") }} + {{ msg.reasoning?.length ?? 0 }} {{ t("ai.chars") }}
{ + it("shows the counter when reasoning exists and the panel is collapsed", () => { + expect(shouldShowReasoningCharCount("正在分析表结构...", false)).toBe(true); + }); + + it("hides the counter when the panel is expanded", () => { + expect(shouldShowReasoningCharCount("正在分析表结构...", true)).toBe(false); + }); + + it("hides the counter when reasoning is empty", () => { + expect(shouldShowReasoningCharCount("", false)).toBe(false); + }); + + it("hides the counter when reasoning is undefined", () => { + expect(shouldShowReasoningCharCount(undefined, false)).toBe(false); + }); +}); + +describe("reasoningCharCountClass", () => { + it("includes the pulse class while the model is still thinking", () => { + const cls = reasoningCharCountClass(true); + expect(cls).toContain(REASONING_CHAR_COUNT_BASE_CLASS); + expect(cls).toContain(REASONING_CHAR_COUNT_PULSE_CLASS); + }); + + it("uses only the base class when thinking has finished", () => { + expect(reasoningCharCountClass(false)).toBe(REASONING_CHAR_COUNT_BASE_CLASS); + }); +}); diff --git a/apps/desktop/src/lib/ai/aiReasoningPresentation.ts b/apps/desktop/src/lib/ai/aiReasoningPresentation.ts new file mode 100644 index 0000000000..1bb305a97a --- /dev/null +++ b/apps/desktop/src/lib/ai/aiReasoningPresentation.ts @@ -0,0 +1,36 @@ +/** + * Pure presentation logic for the collapsed reasoning-process char counter. + * + * Kept side-effect-free so it can be unit-tested without a DOM harness, matching + * the existing pattern of `aiAgentStepPresentation.ts`. The AiAssistant template + * consumes these helpers so its render decisions (show/hide, pulse class, text) + * are covered by Vitest rather than only by the rendered template. + */ + +/** Base CSS classes for the collapsed reasoning char counter span. */ +export const REASONING_CHAR_COUNT_BASE_CLASS = "tabular-nums text-muted-foreground/60"; + +/** Extra class added while the model is still producing reasoning deltas. */ +export const REASONING_CHAR_COUNT_PULSE_CLASS = "animate-pulse"; + +/** + * Decide whether the collapsed reasoning char counter should render. + * Visible only when there is reasoning text AND the panel is collapsed, + * so the counter acts as a "still alive" heartbeat without forcing the + * user to open the panel. + * + * @param reasoning Accumulated reasoning text (may be empty during initial thinking). + * @param expanded Whether the reasoning panel is currently expanded. + */ +export function shouldShowReasoningCharCount(reasoning: string | undefined, expanded: boolean): boolean { + return !expanded && !!reasoning && reasoning.length > 0; +} + +/** + * Return the CSS class string for the counter span. + * Adds a pulse animation while the model is still thinking so the number + * reads as actively growing rather than frozen. + */ +export function reasoningCharCountClass(isThinking: boolean): string { + return isThinking ? `${REASONING_CHAR_COUNT_BASE_CLASS} ${REASONING_CHAR_COUNT_PULSE_CLASS}` : REASONING_CHAR_COUNT_BASE_CLASS; +}