import * as React from "react"; import { api, type ChatWebSocket } from "../api/client"; import type { ChatInputHandle } from "../components/ChatInput"; import type { Message, ProviderConfig } from "../types"; const { useCallback, useRef, useState } = React; type SetState = React.Dispatch>; interface UseChatSendParams { messages: Message[]; loading: boolean; model: string; enableTools: boolean; claudeSessionId: string | null; streamingContent: string; setClaudeSessionId: SetState; setMessages: SetState; setLoading: SetState; setStreamingContent: SetState; setStreamingThinking: SetState; setActivityStatus: SetState; setSideQuestion: SetState<{ question: string; response: string; loading: boolean; } | null>; chatInputRef: React.RefObject; wsRef: React.MutableRefObject; queuedMessagesRef: React.MutableRefObject<{ id: string; text: string }[]>; setQueuedMessages: SetState<{ id: string; text: string }[]>; queueIdCounterRef: React.MutableRefObject; clearMessages: () => void; projectPath: string; } export interface UseChatSendResult { sendMessage: (text: string) => Promise; sendMessageBatch: (texts: string[]) => Promise; cancelGeneration: () => Promise; handleSaveApiKey: () => Promise; clearSession: () => Promise; showApiKeyDialog: boolean; setShowApiKeyDialog: SetState; apiKeyInput: string; setApiKeyInput: SetState; } export function useChatSend({ messages, loading, model, enableTools, claudeSessionId, streamingContent, setClaudeSessionId, setMessages, setLoading, setStreamingContent, setStreamingThinking, setActivityStatus, setSideQuestion, chatInputRef, wsRef, queuedMessagesRef, setQueuedMessages, queueIdCounterRef, clearMessages, projectPath, }: UseChatSendParams): UseChatSendResult { const [showApiKeyDialog, setShowApiKeyDialog] = useState(false); const [apiKeyInput, setApiKeyInput] = useState(""); const pendingMessageRef = useRef(""); const sendMessage = useCallback( async (messageText: string) => { if (!messageText.trim()) return; if (/^\/reset\s*$/i.test(messageText)) { setMessages([]); setClaudeSessionId(null); setStreamingContent(""); setStreamingThinking(""); setActivityStatus(null); setMessages([ { role: "assistant", content: "Session reset. Starting a fresh conversation.", }, ]); return; } const slashMatch = messageText.match(/^\/(\S+)(?:\s+([\s\S]*))?$/); if (slashMatch) { const cmd = slashMatch[1].toLowerCase(); const args = (slashMatch[2] ?? "").trim(); if (cmd !== "btw") { const knownCommands = new Set([ "status", "assign", "start", "show", "move", "delete", "cost", "git", "overview", "rebuild", "loc", "help", "ambient", "htop", "rmtree", "timer", "unblock", "unreleased", "setup", ]); if (knownCommands.has(cmd)) { setMessages((prev: Message[]) => [ ...prev, { role: "user", content: messageText }, ]); try { const result = await api.botCommand(cmd, args, undefined); setMessages((prev: Message[]) => [ ...prev, { role: "assistant", content: result.response }, ]); } catch (e) { setMessages((prev: Message[]) => [ ...prev, { role: "assistant", content: `**Error running command:** ${e}`, }, ]); } return; } setMessages((prev: Message[]) => [ ...prev, { role: "user", content: messageText }, { role: "assistant", content: `Unknown command: \`/${cmd}\`. Type \`/help\` to see available commands.`, }, ]); return; } } const btwMatch = messageText.match(/^\/btw\s+(.+)/s); if (btwMatch) { const question = btwMatch[1].trim(); setSideQuestion({ question, response: "", loading: true }); const isClaudeCode = model === "claude-code-pty"; const provider = isClaudeCode ? "claude-code" : model.startsWith("claude-") ? "anthropic" : "ollama"; const config: ProviderConfig = { provider, model, base_url: "http://localhost:11434", enable_tools: false, }; wsRef.current?.sendSideQuestion(question, messages, config); return; } if (loading) { const newItem = { id: String(queueIdCounterRef.current++), text: messageText, }; queuedMessagesRef.current = [...queuedMessagesRef.current, newItem]; setQueuedMessages([...queuedMessagesRef.current]); return; } const isClaudeCode = model === "claude-code-pty"; if (!isClaudeCode && model.startsWith("claude-")) { const hasKey = await api.getAnthropicApiKeyExists(); if (!hasKey) { pendingMessageRef.current = messageText; setShowApiKeyDialog(true); return; } } const fileRefs = [...messageText.matchAll(/(^|[\s\n])@([^\s@]+)/g)].map( (m) => m[2], ); let expandedText = messageText; if (fileRefs.length > 0) { const expansions = await Promise.allSettled( fileRefs.map(async (ref) => { const contents = await api.readFile(ref); return { ref, contents }; }), ); for (const result of expansions) { if (result.status === "fulfilled") { expandedText += `\n\n[File: ${result.value.ref}]\n\`\`\`\n${result.value.contents}\n\`\`\``; } } } const userMsg: Message = { role: "user", content: expandedText }; const newHistory = [...messages, userMsg]; setMessages(newHistory); setLoading(true); setStreamingContent(""); setStreamingThinking(""); setActivityStatus(null); try { const provider = isClaudeCode ? "claude-code" : model.startsWith("claude-") ? "anthropic" : "ollama"; const config: ProviderConfig = { provider, model, base_url: "http://localhost:11434", enable_tools: enableTools, ...(isClaudeCode && claudeSessionId ? { session_id: claudeSessionId } : {}), }; wsRef.current?.sendChat(newHistory, config); } catch (e) { console.error("Chat error:", e); const errorMessage = String(e); if (!errorMessage.includes("Chat cancelled by user")) { setMessages((prev: Message[]) => [ ...prev, { role: "assistant", content: `**Error:** ${e}` }, ]); } setLoading(false); } }, [ messages, loading, model, enableTools, claudeSessionId, setClaudeSessionId, setMessages, setLoading, setStreamingContent, setStreamingThinking, setActivityStatus, setSideQuestion, wsRef, queuedMessagesRef, setQueuedMessages, queueIdCounterRef, ], ); const sendMessageBatch = useCallback( async (messageTexts: string[]) => { if (messageTexts.length === 0) return; const userMsgs: Message[] = messageTexts.map((text) => ({ role: "user", content: text, })); const newHistory = [...messages, ...userMsgs]; setMessages(newHistory); setLoading(true); setStreamingContent(""); setStreamingThinking(""); setActivityStatus(null); try { const isClaudeCode = model === "claude-code-pty"; const provider = isClaudeCode ? "claude-code" : model.startsWith("claude-") ? "anthropic" : "ollama"; const config: ProviderConfig = { provider, model, base_url: "http://localhost:11434", enable_tools: enableTools, ...(isClaudeCode && claudeSessionId ? { session_id: claudeSessionId } : {}), }; wsRef.current?.sendChat(newHistory, config); } catch (e) { console.error("Chat error:", e); const errorMessage = String(e); if (!errorMessage.includes("Chat cancelled by user")) { setMessages((prev: Message[]) => [ ...prev, { role: "assistant", content: `**Error:** ${e}` }, ]); } setLoading(false); } }, [ messages, model, enableTools, claudeSessionId, setMessages, setLoading, setStreamingContent, setStreamingThinking, setActivityStatus, wsRef, ], ); const cancelGeneration = useCallback(async () => { if (queuedMessagesRef.current.length > 0) { const queued = queuedMessagesRef.current .map((item) => item.text) .join("\n"); chatInputRef.current?.appendToInput(queued); } queuedMessagesRef.current = []; setQueuedMessages([]); try { wsRef.current?.cancel(); await api.cancelChat(); if (streamingContent) { setMessages((prev: Message[]) => [ ...prev, { role: "assistant", content: streamingContent }, ]); setStreamingContent(""); } setStreamingThinking(""); setLoading(false); setActivityStatus(null); } catch (e) { console.error("Failed to cancel chat:", e); } }, [ streamingContent, queuedMessagesRef, setQueuedMessages, chatInputRef, wsRef, setMessages, setStreamingContent, setStreamingThinking, setLoading, setActivityStatus, ]); const handleSaveApiKey = useCallback(async () => { if (!apiKeyInput.trim()) return; try { await api.setAnthropicApiKey(apiKeyInput); setShowApiKeyDialog(false); setApiKeyInput(""); const pendingMessage = pendingMessageRef.current; pendingMessageRef.current = ""; if (pendingMessage.trim()) { sendMessage(pendingMessage); } } catch (e) { console.error("Failed to save API key:", e); alert(`Failed to save API key: ${e}`); } }, [apiKeyInput, sendMessage]); const clearSession = useCallback(async () => { const confirmed = window.confirm( "Are you sure? This will clear all messages and reset the conversation context.", ); if (confirmed) { try { await api.cancelChat(); wsRef.current?.cancel(); } catch (e) { console.error("Failed to cancel chat:", e); } clearMessages(); setStreamingContent(""); setStreamingThinking(""); setLoading(false); setActivityStatus(null); setClaudeSessionId(null); try { localStorage.removeItem(`storykit-claude-session-id:${projectPath}`); } catch { // Ignore — quota or security errors. } } }, [ wsRef, clearMessages, setStreamingContent, setStreamingThinking, setLoading, setActivityStatus, setClaudeSessionId, projectPath, ]); return { sendMessage, sendMessageBatch, cancelGeneration, handleSaveApiKey, clearSession, showApiKeyDialog, setShowApiKeyDialog, apiKeyInput, setApiKeyInput, }; }