refactor(reasoning): make channel plugins own reasoning rendering
Reasoning was being shipped to every channel as a generic progress message with a `_reasoning: true` flag. Two problems with that: 1. Channels without a low-emphasis UI primitive (Telegram, Slack, Discord, Feishu...) would dump raw model thoughts as ordinary replies, polluting the conversation. 2. The agent loop double-gated by inspecting `channels_config`, which coupled the loop to display policy. Treat reasoning as its own plugin action — `BaseChannel.send_reasoning` defaults to a documented no-op; channels that have a fitting affordance override. ChannelManager routes `_reasoning` outbounds to that method only when the channel opts in via `show_reasoning` (camelCase alias `showReasoning` mirrors `sendProgress`). Plugins that don't override silently drop reasoning — "no fit, no leak" is the contract. Reference implementation lands for WebSocket / WebUI: a new `kind: "reasoning"` frame, parked on the active assistant bubble as a collapsible `Thinking` group above the answer. CLI keeps its existing direct path (it doesn't go through the bus). `ChannelsConfig.show_reasoning` flips to `true` by default — only adapted channels surface anything, others stay quiet. Loop net diff is -3 lines: the `channels_config.show_reasoning` check moves out, leaving emit_reasoning a one-liner that publishes and trusts the channel to decide. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -1,5 +1,5 @@
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { Check, ChevronRight, Copy, FileIcon, ImageIcon, PlaySquare, Wrench } from "lucide-react";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { Check, ChevronRight, Copy, FileIcon, ImageIcon, PlaySquare, Sparkles, Wrench } from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import { ImageLightbox } from "@/components/ImageLightbox";
|
||||
@@ -85,12 +85,14 @@ export function MessageBubble({ message }: MessageBubbleProps) {
|
||||
|
||||
const empty = message.content.trim().length === 0;
|
||||
const media = message.media ?? [];
|
||||
const reasoning = message.role === "assistant" ? message.reasoning ?? [] : [];
|
||||
const showAssistantActions = message.role === "assistant" && !message.isStreaming && !empty;
|
||||
return (
|
||||
<div className={cn("w-full text-[15px]", baseAnim)} style={{ lineHeight: "var(--cjk-line-height)" }}>
|
||||
{empty && message.isStreaming ? (
|
||||
{reasoning.length > 0 ? <ReasoningBubble lines={reasoning} /> : null}
|
||||
{empty && message.isStreaming && reasoning.length === 0 ? (
|
||||
<TypingDots />
|
||||
) : (
|
||||
) : empty && message.isStreaming ? null : (
|
||||
<>
|
||||
<MarkdownText>{message.content}</MarkdownText>
|
||||
{message.isStreaming && <StreamCursor />}
|
||||
@@ -433,3 +435,53 @@ function TraceGroup({ message, animClass }: TraceGroupProps) {
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface ReasoningBubbleProps {
|
||||
lines: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Subordinate "thinking" trace shown above an assistant turn. Mirrors the
|
||||
* CLI's italic dim ``ChevronRight`` row visually; collapsible because
|
||||
* reasoning from models like DeepSeek-R1 / o-series can run long. Defaults
|
||||
* to expanded while the answer is still streaming (so the user sees the
|
||||
* model "thinking out loud"), but the toggle persists across rerenders.
|
||||
*/
|
||||
function ReasoningBubble({ lines }: ReasoningBubbleProps) {
|
||||
const { t } = useTranslation();
|
||||
const [open, setOpen] = useState(true);
|
||||
const text = useMemo(() => lines.join("\n\n"), [lines]);
|
||||
return (
|
||||
<div className="mb-2 w-full animate-in fade-in-0 slide-in-from-top-1 duration-200">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOpen((v) => !v)}
|
||||
className={cn(
|
||||
"flex w-full items-center gap-2 rounded-md px-2 py-1.5",
|
||||
"text-xs text-muted-foreground transition-colors hover:bg-muted/45",
|
||||
)}
|
||||
aria-expanded={open}
|
||||
>
|
||||
<Sparkles className="h-3.5 w-3.5" aria-hidden />
|
||||
<span className="font-medium">{t("message.reasoning", { defaultValue: "Thinking" })}</span>
|
||||
<ChevronRight
|
||||
aria-hidden
|
||||
className={cn(
|
||||
"ml-auto h-3.5 w-3.5 transition-transform duration-200",
|
||||
open && "rotate-90",
|
||||
)}
|
||||
/>
|
||||
</button>
|
||||
{open && (
|
||||
<div
|
||||
className={cn(
|
||||
"mt-1 whitespace-pre-wrap break-words border-l border-muted-foreground/20 pl-3",
|
||||
"text-[12.5px] italic leading-relaxed text-muted-foreground/85",
|
||||
)}
|
||||
>
|
||||
{text}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user