/ lib / use-slide-chat.ts
use-slide-chat.ts
  1  import { useReducer, useCallback, useEffect, useRef } from 'react';
  2  import type { ReviewGuide, Provider, ModelId, SendSlideChatRequest } from './types';
  3  
  4  export interface ChatMessage {
  5    id: string;
  6    role: 'user' | 'assistant';
  7    content: string;
  8    isStreaming?: boolean;
  9    toolCalls?: string[];
 10  }
 11  
 12  type MessagesMap = Map<number, ChatMessage[]>;
 13  
 14  type Action =
 15    | { type: 'SEND'; slideIndex: number; userMessage: ChatMessage; assistantMessage: ChatMessage }
 16    | { type: 'APPEND_CHUNK'; slideIndex: number; assistantId: string; chunk: string }
 17    | { type: 'TOOL_USE'; slideIndex: number; assistantId: string; toolName: string }
 18    | { type: 'FINALIZE'; slideIndex: number; assistantId: string }
 19    | { type: 'ERROR'; slideIndex: number; assistantId: string; error: string }
 20    | { type: 'CLEAR'; slideIndex: number };
 21  
 22  function reducer(state: MessagesMap, action: Action): MessagesMap {
 23    const next = new Map(state);
 24  
 25    switch (action.type) {
 26      case 'SEND': {
 27        const existing = next.get(action.slideIndex) ?? [];
 28        next.set(action.slideIndex, [...existing, action.userMessage, action.assistantMessage]);
 29        return next;
 30      }
 31      case 'APPEND_CHUNK': {
 32        const msgs = next.get(action.slideIndex);
 33        if (!msgs) return state;
 34        next.set(
 35          action.slideIndex,
 36          msgs.map((m) => (m.id === action.assistantId ? { ...m, content: m.content + action.chunk } : m))
 37        );
 38        return next;
 39      }
 40      case 'TOOL_USE': {
 41        const msgs = next.get(action.slideIndex);
 42        if (!msgs) return state;
 43        next.set(
 44          action.slideIndex,
 45          msgs.map((m) => {
 46            if (m.id !== action.assistantId) return m;
 47            const existing = m.toolCalls ?? [];
 48            if (existing.includes(action.toolName)) return m;
 49            return { ...m, toolCalls: [...existing, action.toolName] };
 50          })
 51        );
 52        return next;
 53      }
 54      case 'FINALIZE': {
 55        const msgs = next.get(action.slideIndex);
 56        if (!msgs) return state;
 57        next.set(
 58          action.slideIndex,
 59          msgs.map((m) => (m.id === action.assistantId ? { ...m, isStreaming: false } : m))
 60        );
 61        return next;
 62      }
 63      case 'ERROR': {
 64        const msgs = next.get(action.slideIndex);
 65        if (!msgs) return state;
 66        next.set(
 67          action.slideIndex,
 68          msgs.map((m) =>
 69            m.id === action.assistantId ? { ...m, content: m.content || action.error, isStreaming: false } : m
 70          )
 71        );
 72        return next;
 73      }
 74      case 'CLEAR': {
 75        next.delete(action.slideIndex);
 76        return next;
 77      }
 78    }
 79  }
 80  
 81  export function useSlideChat(review: ReviewGuide, provider: Provider, model: ModelId) {
 82    const [messages, dispatch] = useReducer(reducer, new Map<number, ChatMessage[]>());
 83    const streamingRef = useRef<{ slideIndex: number; assistantId: string } | null>(null);
 84    const isStreamingRef = useRef(false);
 85  
 86    useEffect(() => {
 87      const handler = (chunk: string) => {
 88        const current = streamingRef.current;
 89        if (!current) return;
 90        dispatch({ type: 'APPEND_CHUNK', slideIndex: current.slideIndex, assistantId: current.assistantId, chunk });
 91      };
 92  
 93      const toolHandler = (toolName: string) => {
 94        const current = streamingRef.current;
 95        if (!current) return;
 96        dispatch({ type: 'TOOL_USE', slideIndex: current.slideIndex, assistantId: current.assistantId, toolName });
 97      };
 98  
 99      window.electronAPI.onChatProgress(handler);
100      window.electronAPI.onChatToolUse(toolHandler);
101      return () => {
102        window.electronAPI.offChatProgress();
103        window.electronAPI.offChatToolUse();
104      };
105    }, []);
106  
107    const getMessages = useCallback(
108      (slideIndex: number): ChatMessage[] => {
109        return messages.get(slideIndex) ?? [];
110      },
111      [messages]
112    );
113  
114    const send = useCallback(
115      async (slideIndex: number, question: string) => {
116        if (isStreamingRef.current) return;
117  
118        const slide = review.slides[slideIndex - 1];
119  
120        const userMsg: ChatMessage = { id: crypto.randomUUID(), role: 'user', content: question };
121        const assistantMsg: ChatMessage = { id: crypto.randomUUID(), role: 'assistant', content: '', isStreaming: true };
122  
123        dispatch({ type: 'SEND', slideIndex, userMessage: userMsg, assistantMessage: assistantMsg });
124        streamingRef.current = { slideIndex, assistantId: assistantMsg.id };
125        isStreamingRef.current = true;
126  
127        const existingMessages = messages.get(slideIndex) ?? [];
128        const history = existingMessages
129          .filter((m) => !m.isStreaming && m.content)
130          .map((m) => ({ role: m.role, content: m.content }));
131  
132        const diffContent = slide.diffHunks.map((h) => `--- ${h.filePath}\n${h.hunkHeader}\n${h.content}`).join('\n\n');
133  
134        const req: SendSlideChatRequest = {
135          prTitle: review.prTitle,
136          prDescription: review.prDescription,
137          summary: review.summary,
138          slideTitle: slide.title,
139          slideNarrative: slide.narrative,
140          slideReviewFocus: slide.reviewFocus,
141          affectedFiles: slide.affectedFiles,
142          diffContent,
143          history,
144          question,
145          provider,
146          model,
147        };
148  
149        try {
150          await window.electronAPI.sendSlideChat(req);
151          dispatch({ type: 'FINALIZE', slideIndex, assistantId: assistantMsg.id });
152        } catch (err) {
153          const errorMsg = err instanceof Error ? err.message : 'Failed to get response';
154          dispatch({ type: 'ERROR', slideIndex, assistantId: assistantMsg.id, error: errorMsg });
155        } finally {
156          streamingRef.current = null;
157          isStreamingRef.current = false;
158        }
159      },
160      [review, provider, model, messages]
161    );
162  
163    const clearSlide = useCallback((slideIndex: number) => {
164      dispatch({ type: 'CLEAR', slideIndex });
165    }, []);
166  
167    const isStreaming = isStreamingRef.current;
168  
169    return { getMessages, send, isStreaming, clearSlide };
170  }