Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 10 additions & 1 deletion src/ai/fetch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -709,9 +709,18 @@ export const aiFetchStreamingResponse = async ({
providerId: model.provider,
model: baseModel,
middleware: [
// Extract `<think>...</think>` from content when present. Do NOT pass
// `startWithReasoning` from `model.startWithReasoning` — that DB flag
// means “model can think / show Thinking chip”, while the middleware
// option means “treat all content as reasoning until </think>”.
// Ollama thinking models emit a native `reasoning` field and plain
// answer `content` with no closing tag; wiring the chip flag through
// here classifies the answer as reasoning forever and never emits
// reasoning-end (stuck spinner with the reply stuck in the muted
// Thinking pane).
extractReasoningMiddleware({
tagName: 'think',
startWithReasoning: Boolean(model.startWithReasoning),
startWithReasoning: false,
}),
],
})
Expand Down
36 changes: 18 additions & 18 deletions src/components/chat/reasoning-group-title.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -34,30 +34,30 @@ const activeToolLabel = (tool: ToolOrDynamicToolUIPart, mcpTools?: UIMessageMeta
export const ReasoningGroupTitle = ({ totalDuration, isGroupReasoning, tools, mcpTools }: ReasoningGroupTitleProps) => {
const activeIndex = tools.length - 1
const activeTool = tools[activeIndex]
const loadingLabel = activeTool ? activeToolLabel(activeTool, mcpTools) : null
// While streaming with no tool yet, still show a label — an empty title next
// to the spinner reads as a hung chat (common for thinking-only turns).
const loadingLabel = activeTool ? activeToolLabel(activeTool, mcpTools) : 'Thinking'

return (
<div className="relative">
<AnimatePresence mode="wait">
{isGroupReasoning ? (
loadingLabel ? (
<m.div
key={`tool-${activeIndex}`}
// Skip entrance animation for tools already in progress (e.g., when switching back to a chat with active streaming)
initial={activeTool?.state === 'input-streaming' ? { y: 20, opacity: 0 } : { y: 0, opacity: 1 }}
animate={{ y: 0, opacity: 1, scale: 1 }}
exit={{ y: -20, opacity: 0 }}
transition={{ duration: 0.3, ease: [0.4, 0, 0.2, 1] }}
className="w-full"
<m.div
key={activeTool ? `tool-${activeIndex}` : 'thinking'}
// Skip entrance animation for tools already in progress (e.g., when switching back to a chat with active streaming)
initial={activeTool?.state === 'input-streaming' ? { y: 20, opacity: 0 } : { y: 0, opacity: 1 }}
animate={{ y: 0, opacity: 1, scale: 1 }}
exit={{ y: -20, opacity: 0 }}
transition={{ duration: 0.3, ease: [0.4, 0, 0.2, 1] }}
className="w-full"
>
<span
data-testid="tool-status"
className="text-xs text-muted-foreground italic animate-pulse truncate min-w-0"
>
<span
data-testid="tool-status"
className="text-xs text-muted-foreground italic animate-pulse truncate min-w-0"
>
{loadingLabel}
</span>
</m.div>
) : null
{loadingLabel}
</span>
</m.div>
) : (
<m.div
key="completed"
Expand Down
3 changes: 3 additions & 0 deletions src/components/chat/reasoning-group.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -254,6 +254,9 @@ describe('ReasoningGroup', () => {
// ReasoningDisplay should render the reasoning text when streaming
// It might be in a specific container, so check the container
expect(container.textContent).toContain('Let me think about this...')
// Thinking-only turns must keep a visible status label (empty title + spinner
// reads as a hung chat).
expect(screen.getByTestId('tool-status')).toHaveTextContent('Thinking')
})

it('should not render ReasoningDisplay when hasTextPart is true', () => {
Expand Down
Loading