From 3ab3ca22ffba18336c73a92eb60564a8b6d5eb0c Mon Sep 17 00:00:00 2001 From: lucaronin Date: Wed, 4 Mar 2026 12:09:02 +0100 Subject: [PATCH] feat: add reasoning streaming, markdown response, structured context snapshot - Rust: add ThinkingDelta event to ClaudeStreamEvent for reasoning chunks - ai-agent.ts: forward ThinkingDelta events via onThinking callback - useAiAgent: stream reasoning live, accumulate response internally, reveal as complete block on done - AiMessage: auto-collapse reasoning when done, use MarkdownContent for response rendering, update tests for new behavior - ai-context: add buildContextSnapshot() for structured JSON context with activeNote, openTabs, noteListFilter, vault summary Co-Authored-By: Claude Opus 4.6 --- src-tauri/src/claude_cli.rs | 9 +++ src/components/AiMessage.test.tsx | 39 ++++++++----- src/components/AiMessage.tsx | 29 ++++++++-- src/hooks/useAiAgent.ts | 32 +++++++++-- src/utils/ai-agent.ts | 5 ++ src/utils/ai-context.ts | 94 +++++++++++++++++++++++++++++-- 6 files changed, 177 insertions(+), 31 deletions(-) diff --git a/src-tauri/src/claude_cli.rs b/src-tauri/src/claude_cli.rs index 3876ccf2..726dfd5f 100644 --- a/src-tauri/src/claude_cli.rs +++ b/src-tauri/src/claude_cli.rs @@ -19,6 +19,8 @@ pub enum ClaudeStreamEvent { Init { session_id: String }, /// Incremental text chunk. TextDelta { text: String }, + /// Incremental thinking/reasoning chunk. + ThinkingDelta { text: String }, /// A tool call started (agent mode only). ToolStart { tool_name: String, @@ -402,6 +404,13 @@ where }); } } + Some("thinking_delta") => { + if let Some(text) = delta["thinking"].as_str() { + emit(ClaudeStreamEvent::ThinkingDelta { + text: text.to_string(), + }); + } + } Some("input_json_delta") => { if let (Some(partial), Some(ref tid)) = (delta["partial_json"].as_str(), &state.current_tool_id) diff --git a/src/components/AiMessage.test.tsx b/src/components/AiMessage.test.tsx index aa8a040f..7137c34d 100644 --- a/src/components/AiMessage.test.tsx +++ b/src/components/AiMessage.test.tsx @@ -2,15 +2,20 @@ import { describe, it, expect, vi } from 'vitest' import { render, screen, fireEvent } from '@testing-library/react' import { AiMessage } from './AiMessage' +vi.mock('./MarkdownContent', () => ({ + MarkdownContent: ({ content }: { content: string }) =>
{content}
, +})) + describe('AiMessage', () => { it('renders user message', () => { render() expect(screen.getByText('Hello AI')).toBeTruthy() }) - it('renders response text', () => { - render() - expect(screen.getByText('Here is the answer')).toBeTruthy() + it('renders response as markdown', () => { + render() + expect(screen.getByTestId('markdown-content')).toBeTruthy() + expect(screen.getByText('Here is the **answer**')).toBeTruthy() }) it('shows undo button with response', () => { @@ -18,23 +23,31 @@ describe('AiMessage', () => { expect(screen.getByTestId('undo-button')).toBeTruthy() }) - it('renders reasoning toggle collapsed by default', () => { - render() + it('shows reasoning expanded while streaming (reasoningDone=false)', () => { + render() expect(screen.getByTestId('reasoning-toggle')).toBeTruthy() - expect(screen.queryByTestId('reasoning-content')).toBeNull() - }) - - it('expands reasoning on toggle click', () => { - render() - fireEvent.click(screen.getByTestId('reasoning-toggle')) expect(screen.getByTestId('reasoning-content')).toBeTruthy() expect(screen.getByText('Thinking about it...')).toBeTruthy() }) - it('collapses reasoning on second click', () => { - render() + it('auto-collapses reasoning when reasoningDone=true', () => { + render() + expect(screen.getByTestId('reasoning-toggle')).toBeTruthy() + expect(screen.queryByTestId('reasoning-content')).toBeNull() + }) + + it('expands collapsed reasoning on toggle click', () => { + render() + // Starts collapsed (reasoningDone=true) + expect(screen.queryByTestId('reasoning-content')).toBeNull() fireEvent.click(screen.getByTestId('reasoning-toggle')) expect(screen.getByTestId('reasoning-content')).toBeTruthy() + }) + + it('collapses expanded reasoning on toggle click', () => { + render() + // Starts expanded (reasoningDone=false) + expect(screen.getByTestId('reasoning-content')).toBeTruthy() fireEvent.click(screen.getByTestId('reasoning-toggle')) expect(screen.queryByTestId('reasoning-content')).toBeNull() }) diff --git a/src/components/AiMessage.tsx b/src/components/AiMessage.tsx index 3202cb12..a47c24c9 100644 --- a/src/components/AiMessage.tsx +++ b/src/components/AiMessage.tsx @@ -1,6 +1,7 @@ -import { useState, useCallback } from 'react' +import { useState, useCallback, useEffect, useRef } from 'react' import { CaretRight, CaretDown, Brain, ArrowCounterClockwise } from '@phosphor-icons/react' import { AiActionCard, type AiActionStatus } from './AiActionCard' +import { MarkdownContent } from './MarkdownContent' export interface AiAction { tool: string @@ -15,6 +16,7 @@ export interface AiAction { export interface AiMessageProps { userMessage: string reasoning?: string + reasoningDone?: boolean actions: AiAction[] response?: string isStreaming?: boolean @@ -44,6 +46,14 @@ function UserBubble({ content }: { content: string }) { function ReasoningBlock({ text, expanded, onToggle }: { text: string; expanded: boolean; onToggle: () => void }) { + const contentRef = useRef(null) + + useEffect(() => { + if (expanded && contentRef.current) { + contentRef.current.scrollTop = contentRef.current.scrollHeight + } + }, [expanded, text]) + return (