Skip to content

Commit a6a33b8

Browse files
author
dolphin
committed
revert(client): keep cofco flat deep-thinking display in daily chat
Drop the mainline deep-thinking timeline refactor (outer collapsible wrapper with duration ticking) that the merge pulled in; restore cofco's flat, no-outer- wrapper rendering (thinking block + tool cards at top level, collapsed by default). Daily-mode only — task mode (linsight) is unaffected.
1 parent c51f0ff commit a6a33b8

3 files changed

Lines changed: 139 additions & 232 deletions

File tree

src/frontend/client/src/components/Chat/Messages/DeepThinkingGroup.tsx

Lines changed: 68 additions & 165 deletions
Original file line numberDiff line numberDiff line change
@@ -1,181 +1,84 @@
11
/**
2-
* DeepThinkingGroup — outer collapsible wrapper around a contiguous run of
3-
* thinking + tool_call events. Header reads "已深度思考" once the run is closed
4-
* by a following text block (or stream end), or "正在深度思考..." while still
5-
* open. (cofco: the "(用时 N 秒)" duration clause is intentionally not rendered.)
6-
* Collapsing the wrapper hides everything inside, including any inner
7-
* ThinkingContent state.
2+
* DeepThinkingGroup — renders a contiguous run of thinking + tool_call events
3+
* as a flat timeline. There is no outer "已深度思考" wrapper anymore: the
4+
* thinking block and each tool-call card sit at the top level, laid out
5+
* sequentially, and each node owns its own (collapsed-by-default) toggle.
6+
*
7+
* The thinking node surfaces its own live status (正在深度思考… → 已深度思考)
8+
* via the timing fields computed here from this group's thinking events.
89
*/
9-
import { Outlined } from "bisheng-icons";
10-
import {
11-
memo,
12-
useCallback,
13-
useEffect,
14-
useMemo,
15-
useRef,
16-
useState,
17-
type FC,
18-
type MouseEvent,
19-
} from "react";
10+
import { memo, useMemo, type FC } from "react";
2011
import type { AgentEvent } from "~/api/chatApi";
21-
import { cn, formatSeconds } from "~/utils";
2212
import ThinkingContent from "./ThinkingContent";
2313
import ToolCallDisplay from "./ToolCallDisplay";
2414

25-
const BUTTON_STYLES = {
26-
base: "group flex w-fit items-center gap-1 text-sm font-medium leading-[22px] text-[#212121]",
27-
icon: "shrink-0 transform-gpu text-[#999999] transition-transform duration-200",
28-
} as const;
29-
3015
export interface DeepThinkingGroupProps {
3116
/** Ordered events in this group — only thinking + tool_call entries. */
3217
events: AgentEvent[];
33-
/** True if this group is the currently-open trailing run. */
18+
/** True if this group is the currently-open trailing run (message still
19+
* streaming, no final answer text yet). Drives the thinking node's
20+
* 正在/已 status. */
3421
isStreaming: boolean;
3522
}
3623

37-
/** Pick the earliest started_at across events; fall back to undefined. */
38-
function groupStart(events: AgentEvent[]): number | undefined {
39-
let earliest: number | undefined;
40-
for (const ev of events) {
41-
if (ev.type === "thinking" || ev.type === "tool_call") {
42-
if (ev.started_at != null && (earliest == null || ev.started_at < earliest)) {
43-
earliest = ev.started_at;
44-
}
24+
type ThinkingEvent = Extract<AgentEvent, { type: "thinking" }>;
25+
26+
const DeepThinkingGroup: FC<DeepThinkingGroupProps> = memo(({ events, isStreaming }) => {
27+
const thinkingEvents = useMemo(
28+
() => events.filter((e): e is ThinkingEvent => e.type === "thinking"),
29+
[events],
30+
);
31+
32+
// Concatenated reasoning for the inner ThinkingContent block.
33+
const reasoning = useMemo(
34+
() => thinkingEvents.map((e) => e.content).join("\n\n"),
35+
[thinkingEvents],
36+
);
37+
38+
const toolCalls = useMemo(
39+
() =>
40+
events.filter(
41+
(e): e is Extract<AgentEvent, { type: "tool_call" }> =>
42+
e.type === "tool_call",
43+
),
44+
[events],
45+
);
46+
47+
// Timing for the thinking node: span the earliest start → latest end across
48+
// this group's thinking segments, with a per-segment duration sum fallback.
49+
const { startedAt, endedAt, durationMs } = useMemo(() => {
50+
let start: number | undefined;
51+
let end: number | undefined;
52+
let sum = 0;
53+
for (const e of thinkingEvents) {
54+
if (e.started_at != null && (start == null || e.started_at < start)) start = e.started_at;
55+
if (e.ended_at != null && (end == null || e.ended_at > end)) end = e.ended_at;
56+
sum += e.duration_ms ?? 0;
4557
}
46-
}
47-
return earliest;
48-
}
49-
50-
/** Pick the latest ended_at across events. */
51-
function groupEnd(events: AgentEvent[]): number | undefined {
52-
let latest: number | undefined;
53-
for (const ev of events) {
54-
if (ev.type === "thinking" || ev.type === "tool_call") {
55-
if (ev.ended_at != null && (latest == null || ev.ended_at > latest)) {
56-
latest = ev.ended_at;
57-
}
58-
}
59-
}
60-
return latest;
61-
}
62-
63-
/** Fallback: sum duration_ms when wall-clock fields aren't on legacy rows. */
64-
function durationFallback(events: AgentEvent[]): number {
65-
let sum = 0;
66-
for (const ev of events) {
67-
if (ev.type === "thinking" || ev.type === "tool_call") {
68-
sum += ev.duration_ms ?? 0;
69-
}
70-
}
71-
return sum;
72-
}
73-
74-
const DeepThinkingGroup: FC<DeepThinkingGroupProps> = memo(
75-
({ events, isStreaming }) => {
76-
// Open while the run is live so the user can watch it; closed for
77-
// already-finished groups (history rows mount with isStreaming false).
78-
const [isExpanded, setIsExpanded] = useState(isStreaming);
79-
80-
// Auto-collapse the moment the run finishes — i.e. the main answer
81-
// starts streaming and isStreaming flips false — so focus moves to the
82-
// answer body without a manual click. Manual toggling stays intact
83-
// afterward since we only react to the streaming → done falling edge.
84-
const wasStreamingRef = useRef(isStreaming);
85-
useEffect(() => {
86-
if (wasStreamingRef.current && !isStreaming) {
87-
setIsExpanded(false);
88-
}
89-
wasStreamingRef.current = isStreaming;
90-
}, [isStreaming]);
91-
92-
const start = groupStart(events);
93-
const end = groupEnd(events);
94-
95-
// Live-tick while streaming so the header counter advances every 100ms.
96-
const [tick, setTick] = useState(0);
97-
useEffect(() => {
98-
if (!isStreaming) return;
99-
const id = window.setInterval(() => setTick((t) => t + 1), 100);
100-
return () => window.clearInterval(id);
101-
}, [isStreaming]);
102-
103-
const elapsedMs = (() => {
104-
if (start == null) return durationFallback(events);
105-
// For closed groups with no end_at, fall back to the per-event sum
106-
// so the label doesn't creep upward against Date.now().
107-
if (!isStreaming && end == null) return durationFallback(events);
108-
const stop = isStreaming ? Date.now() : end!;
109-
return Math.max(0, stop - start);
110-
})();
111-
// `tick` is read here so the IIFE re-runs on every interval render.
112-
void tick;
113-
114-
// cofco: never render the "(用时 N 秒)" clause — the label stays plain.
115-
const label = isStreaming ? `正在深度思考...` : `已深度思考`;
116-
117-
const handleClick = useCallback((e: MouseEvent<HTMLButtonElement>) => {
118-
e.preventDefault();
119-
setIsExpanded((prev) => !prev);
120-
}, []);
121-
122-
// Pull thinking content (concat) for the inner ThinkingContent block.
123-
const reasoning = useMemo(
124-
() =>
125-
events
126-
.filter(
127-
(e): e is Extract<AgentEvent, { type: "thinking" }> =>
128-
e.type === "thinking",
129-
)
130-
.map((e) => e.content)
131-
.join("\n\n"),
132-
[events],
133-
);
134-
135-
const toolCalls = useMemo(
136-
() =>
137-
events.filter(
138-
(e): e is Extract<AgentEvent, { type: "tool_call" }> =>
139-
e.type === "tool_call",
140-
),
141-
[events],
142-
);
143-
144-
return (
145-
<div className="flex w-full min-w-0 flex-col gap-3">
146-
<button
147-
type="button"
148-
onClick={handleClick}
149-
className={cn(BUTTON_STYLES.base, isStreaming && "animate-pulse")}
150-
>
151-
<span>{label}</span>
152-
<Outlined.Down
153-
size={16}
154-
className={cn(BUTTON_STYLES.icon, !isExpanded && "-rotate-90")}
155-
/>
156-
</button>
157-
<div
158-
className="grid transition-all duration-300 ease-out"
159-
style={{ gridTemplateRows: isExpanded ? "1fr" : "0fr" }}
160-
>
161-
<div className="overflow-hidden flex flex-col gap-2">
162-
<ThinkingContent
163-
reasoning={reasoning}
164-
showConnector={!!reasoning && toolCalls.length > 0}
165-
/>
166-
{toolCalls.map((tc, i) => (
167-
<ToolCallDisplay
168-
key={tc.tool_call_id || `tc-${i}`}
169-
toolCall={tc}
170-
showConnector={i < toolCalls.length - 1}
171-
/>
172-
))}
173-
</div>
174-
</div>
175-
</div>
176-
);
177-
},
178-
);
58+
return { startedAt: start, endedAt: end, durationMs: sum };
59+
}, [thinkingEvents]);
60+
61+
// The reasoning is still "live" only while the group streams AND the last
62+
// thinking segment hasn't closed (once a tool starts, that segment carries
63+
// an ended_at and the node settles to "已深度思考").
64+
const lastThinking = thinkingEvents[thinkingEvents.length - 1];
65+
const thinkingStreaming = isStreaming && !!lastThinking && lastThinking.ended_at == null;
66+
67+
return (
68+
<div className="flex w-full min-w-0 flex-col gap-3">
69+
<ThinkingContent
70+
reasoning={reasoning}
71+
isStreaming={thinkingStreaming}
72+
startedAt={startedAt}
73+
endedAt={endedAt}
74+
durationMs={durationMs}
75+
/>
76+
{toolCalls.map((tc, i) => (
77+
<ToolCallDisplay key={tc.tool_call_id || `tc-${i}`} toolCall={tc} />
78+
))}
79+
</div>
80+
);
81+
});
17982

18083
DeepThinkingGroup.displayName = "DeepThinkingGroup";
18184

src/frontend/client/src/components/Chat/Messages/ThinkingContent.tsx

Lines changed: 64 additions & 51 deletions
Original file line numberDiff line numberDiff line change
@@ -1,73 +1,86 @@
11
/**
2-
* ThinkingContent — collapsible "思考内容" block. Lives inside a
3-
* DeepThinkingGroup wrapper which owns the duration display, so this
4-
* component intentionally has no timer or duration text.
2+
* ThinkingContent — collapsible thinking block. Mirrors task-mode's
3+
* DeepStepGroup thinking node: the header reads "正在深度思考..." while
4+
* reasoning is live (with a spinner icon + pulsing label), then settles to
5+
* "已深度思考" once the segment closes.
56
*
6-
* Layout follows the timeline-item design: a left rail (16px icon +
7-
* 1px vertical connector) next to a body column (trigger + collapsible text).
7+
* Timing props (startedAt/endedAt/durationMs) are still accepted so callers
8+
* stay unchanged, but the "(用时 N 秒)" clause is intentionally not rendered.
9+
*
10+
* Layout: a left icon column next to a body column (trigger + collapsible text).
811
*/
912
import { Outlined } from "bisheng-icons";
1013
import { memo, useCallback, useState, type FC, type MouseEvent } from "react";
11-
import { useRecoilValue } from "recoil";
1214
import { cn } from "~/utils";
13-
import store from "~/store";
1415

1516
export interface ThinkingContentProps {
1617
reasoning: string;
17-
/** Whether to render the vertical timeline connector below the icon.
18-
* Set to true when something follows in the timeline (e.g., tool cards),
19-
* so the timeline is continuous even when this section is collapsed. */
20-
showConnector?: boolean;
18+
/** True while the reasoning is still streaming (no end frame yet). Drives
19+
* the spinner icon and pulsing label. */
20+
isStreaming?: boolean;
21+
/** Accepted for caller compatibility; duration is no longer rendered. */
22+
startedAt?: number;
23+
/** Accepted for caller compatibility; duration is no longer rendered. */
24+
endedAt?: number;
25+
/** Accepted for caller compatibility; duration is no longer rendered. */
26+
durationMs?: number;
2127
}
2228

23-
const ThinkingContent: FC<ThinkingContentProps> = memo(({ reasoning, showConnector = false }) => {
24-
const showThinkingDefault = useRecoilValue<boolean>(store.showThinking);
25-
const [isExpanded, setIsExpanded] = useState(showThinkingDefault);
29+
const ThinkingContent: FC<ThinkingContentProps> = memo(
30+
({ reasoning, isStreaming = false }) => {
31+
// Always default collapsed — even while reasoning is still streaming —
32+
// so the answer body stays the focus; the user expands to read.
33+
const [isExpanded, setIsExpanded] = useState(false);
2634

27-
const handleClick = useCallback((e: MouseEvent<HTMLButtonElement>) => {
28-
e.preventDefault();
29-
setIsExpanded((prev) => !prev);
30-
}, []);
35+
const handleClick = useCallback((e: MouseEvent<HTMLButtonElement>) => {
36+
e.preventDefault();
37+
setIsExpanded((prev) => !prev);
38+
}, []);
3139

32-
if (!reasoning) return null;
40+
if (!reasoning) return null;
3341

34-
return (
35-
<div className="flex w-full min-w-0 gap-1.5 animate-thinking-appear">
36-
<div className="flex shrink-0 flex-col items-center gap-2 self-stretch pt-[3px]">
37-
<Outlined.CheckCircle size={16} className="shrink-0 text-[#999999]" />
38-
{/* Rail line: keep the timeline continuous to the next node, and
39-
always flank this node's own expanded content. */}
40-
{(showConnector || isExpanded) && (
41-
<div className="w-px flex-1 bg-[#E0E0E0]" aria-hidden="true" />
42-
)}
43-
</div>
44-
<div className="flex min-w-0 flex-1 flex-col pb-3">
45-
<button
46-
type="button"
47-
onClick={handleClick}
48-
className="group flex w-fit max-w-full items-center gap-1 text-sm leading-[22px] text-[#999999] transition-colors hover:text-[#212121]"
49-
>
50-
<span>思考内容</span>
51-
<Outlined.Down
52-
size={16}
42+
const label = isStreaming ? `正在深度思考...` : `已深度思考`;
43+
44+
return (
45+
<div className="flex w-full min-w-0 gap-2 animate-thinking-appear">
46+
<div className="flex shrink-0 items-start pt-[3px]">
47+
{isStreaming && !isExpanded ? (
48+
<Outlined.Loading size={16} className="shrink-0 animate-spin text-primary" />
49+
) : (
50+
<Outlined.Bulb size={16} className="shrink-0 text-[#1D2129]" />
51+
)}
52+
</div>
53+
<div className="flex min-w-0 flex-1 flex-col pb-3">
54+
<button
55+
type="button"
56+
onClick={handleClick}
5357
className={cn(
54-
"shrink-0 transform-gpu transition-transform duration-200",
55-
!isExpanded && "-rotate-90",
58+
"group flex w-fit max-w-full items-center gap-1 text-sm leading-[22px] text-[#999999] transition-colors hover:text-[#212121]",
59+
isStreaming && "animate-pulse group-hover:animate-none",
5660
)}
57-
/>
58-
</button>
59-
<div
60-
className={cn("grid transition-all duration-300 ease-out", isExpanded && "mt-2")}
61-
style={{ gridTemplateRows: isExpanded ? "1fr" : "0fr" }}
62-
>
63-
<div className="min-h-0 overflow-hidden">
64-
<p className="whitespace-pre-wrap text-xs leading-5 text-[#818181]">{reasoning}</p>
61+
>
62+
<span>{label}</span>
63+
<Outlined.Down
64+
size={16}
65+
className={cn(
66+
"shrink-0 transform-gpu transition-transform duration-200",
67+
!isExpanded && "-rotate-90",
68+
)}
69+
/>
70+
</button>
71+
<div
72+
className={cn("grid transition-all duration-300 ease-out", isExpanded && "mt-2")}
73+
style={{ gridTemplateRows: isExpanded ? "1fr" : "0fr" }}
74+
>
75+
<div className="min-h-0 overflow-hidden">
76+
<p className="whitespace-pre-wrap text-xs leading-5 text-[#818181]">{reasoning}</p>
77+
</div>
6578
</div>
6679
</div>
6780
</div>
68-
</div>
69-
);
70-
});
81+
);
82+
},
83+
);
7184

7285
ThinkingContent.displayName = "ThinkingContent";
7386

0 commit comments

Comments
 (0)