Inline thinking bubbles with summary/full modes (#18033)

Co-authored-by: Jacob Richman <jacob314@gmail.com>
This commit is contained in:
Dmitry Lyalin
2026-02-09 19:24:41 -08:00
committed by GitHub
parent 7a132512cf
commit d3cfbdb3b7
26 changed files with 719 additions and 26 deletions

View File

@@ -2505,6 +2505,110 @@ describe('useGeminiStream', () => {
});
});
describe('Thought Reset', () => {
it('should keep full thinking entries in history when mode is full', async () => {
const fullThinkingSettings: LoadedSettings = {
...mockLoadedSettings,
merged: {
...mockLoadedSettings.merged,
ui: { inlineThinkingMode: 'full' },
},
} as unknown as LoadedSettings;
mockSendMessageStream.mockReturnValue(
(async function* () {
yield {
type: ServerGeminiEventType.Thought,
value: {
subject: 'Full thought',
description: 'Detailed thinking',
},
};
yield {
type: ServerGeminiEventType.Content,
value: 'Response',
};
})(),
);
const { result } = renderHookWithProviders(() =>
useGeminiStream(
new MockedGeminiClientClass(mockConfig),
[],
mockAddItem,
mockConfig,
fullThinkingSettings,
mockOnDebugMessage,
mockHandleSlashCommand,
false,
() => 'vscode' as EditorType,
() => {},
() => Promise.resolve(),
false,
() => {},
() => {},
() => {},
80,
24,
),
);
await act(async () => {
await result.current.submitQuery('Test query');
});
expect(mockAddItem).toHaveBeenCalledWith(
expect.objectContaining({
type: 'thinking',
thought: expect.objectContaining({ subject: 'Full thought' }),
}),
expect.any(Number),
);
});
it('keeps thought transient and clears it on first non-thought event', async () => {
mockSendMessageStream.mockReturnValue(
(async function* () {
yield {
type: ServerGeminiEventType.Thought,
value: {
subject: 'Assessing intent',
description: 'Inspecting context',
},
};
yield {
type: ServerGeminiEventType.Content,
value: 'Model response content',
};
yield {
type: ServerGeminiEventType.Finished,
value: { reason: 'STOP', usageMetadata: undefined },
};
})(),
);
const { result } = renderTestHook();
await act(async () => {
await result.current.submitQuery('Test query');
});
await waitFor(() => {
expect(mockAddItem).toHaveBeenCalledWith(
expect.objectContaining({
type: 'gemini',
text: 'Model response content',
}),
expect.any(Number),
);
});
expect(result.current.thought).toBeNull();
expect(mockAddItem).not.toHaveBeenCalledWith(
expect.objectContaining({ type: 'thinking' }),
expect.any(Number),
);
});
it('should reset thought to null when starting a new prompt', async () => {
// First, simulate a response with a thought
mockSendMessageStream.mockReturnValue(

View File

@@ -50,6 +50,7 @@ import type {
import { type Part, type PartListUnion, FinishReason } from '@google/genai';
import type {
HistoryItem,
HistoryItemThinking,
HistoryItemWithoutId,
HistoryItemToolGroup,
IndividualToolCallDisplay,
@@ -61,6 +62,7 @@ import { isAtCommand, isSlashCommand } from '../utils/commandUtils.js';
import { useShellCommandProcessor } from './shellCommandProcessor.js';
import { handleAtCommand } from './atCommandProcessor.js';
import { findLastSafeSplitPoint } from '../utils/markdownUtilities.js';
import { getInlineThinkingMode } from '../utils/inlineThinkingMode.js';
import { useStateAndRef } from './useStateAndRef.js';
import type { UseHistoryManagerReturn } from './useHistoryManager.js';
import { useLogger } from './useLogger.js';
@@ -192,9 +194,11 @@ export const useGeminiStream = (
const turnCancelledRef = useRef(false);
const activeQueryIdRef = useRef<string | null>(null);
const [isResponding, setIsResponding] = useState<boolean>(false);
const [thought, setThought] = useState<ThoughtSummary | null>(null);
const [thought, thoughtRef, setThought] =
useStateAndRef<ThoughtSummary | null>(null);
const [pendingHistoryItem, pendingHistoryItemRef, setPendingHistoryItem] =
useStateAndRef<HistoryItemWithoutId | null>(null);
const [lastGeminiActivityTime, setLastGeminiActivityTime] =
useState<number>(0);
const [pushedToolCallIds, pushedToolCallIdsRef, setPushedToolCallIds] =
@@ -753,6 +757,7 @@ export const useGeminiStream = (
pendingHistoryItemRef.current?.type !== 'gemini' &&
pendingHistoryItemRef.current?.type !== 'gemini_content'
) {
// Flush any pending item before starting gemini content
if (pendingHistoryItemRef.current) {
addItem(pendingHistoryItemRef.current, userMessageTimestamp);
}
@@ -798,6 +803,23 @@ export const useGeminiStream = (
[addItem, pendingHistoryItemRef, setPendingHistoryItem],
);
const handleThoughtEvent = useCallback(
(eventValue: ThoughtSummary, userMessageTimestamp: number) => {
setThought(eventValue);
if (getInlineThinkingMode(settings) === 'full') {
addItem(
{
type: 'thinking',
thought: eventValue,
} as HistoryItemThinking,
userMessageTimestamp,
);
}
},
[addItem, settings, setThought],
);
const handleUserCancelledEvent = useCallback(
(userMessageTimestamp: number) => {
if (turnCancelledRef.current) {
@@ -1067,10 +1089,17 @@ export const useGeminiStream = (
let geminiMessageBuffer = '';
const toolCallRequests: ToolCallRequestInfo[] = [];
for await (const event of stream) {
if (
event.type !== ServerGeminiEventType.Thought &&
thoughtRef.current !== null
) {
setThought(null);
}
switch (event.type) {
case ServerGeminiEventType.Thought:
setLastGeminiActivityTime(Date.now());
setThought(event.value);
handleThoughtEvent(event.value, userMessageTimestamp);
break;
case ServerGeminiEventType.Content:
setLastGeminiActivityTime(Date.now());
@@ -1157,6 +1186,8 @@ export const useGeminiStream = (
},
[
handleContentEvent,
handleThoughtEvent,
thoughtRef,
handleUserCancelledEvent,
handleErrorEvent,
scheduleToolCalls,
@@ -1171,6 +1202,7 @@ export const useGeminiStream = (
addItem,
pendingHistoryItemRef,
setPendingHistoryItem,
setThought,
],
);
const submitQuery = useCallback(
@@ -1351,6 +1383,7 @@ export const useGeminiStream = (
config,
startNewPrompt,
getPromptCount,
setThought,
],
);