diff --git a/frontend/src/components/ApiKeyDialog.tsx b/frontend/src/components/ApiKeyDialog.tsx new file mode 100644 index 00000000..64840288 --- /dev/null +++ b/frontend/src/components/ApiKeyDialog.tsx @@ -0,0 +1,112 @@ +interface ApiKeyDialogProps { + apiKeyInput: string; + onApiKeyChange: (value: string) => void; + onSave: () => void; + onCancel: () => void; +} + +export function ApiKeyDialog({ + apiKeyInput, + onApiKeyChange, + onSave, + onCancel, +}: ApiKeyDialogProps) { + return ( +
+
+

+ Enter Anthropic API Key +

+

+ To use Claude models, please enter your Anthropic API key. Your key + will be stored server-side and reused across sessions. +

+ onApiKeyChange(e.target.value)} + onKeyDown={(e) => e.key === "Enter" && onSave()} + placeholder="sk-ant-..." + style={{ + width: "100%", + padding: "12px", + borderRadius: "8px", + border: "1px solid #555", + backgroundColor: "#1a1a1a", + color: "#ececec", + fontSize: "1em", + marginBottom: "20px", + outline: "none", + }} + /> +
+ + +
+
+
+ ); +} diff --git a/frontend/src/components/Chat.tsx b/frontend/src/components/Chat.tsx index 14cd9fff..bb443c02 100644 --- a/frontend/src/components/Chat.tsx +++ b/frontend/src/components/Chat.tsx @@ -1,167 +1,27 @@ import * as React from "react"; -import Markdown from "react-markdown"; -import { Prism as SyntaxHighlighter } from "react-syntax-highlighter"; -import { oneDark } from "react-syntax-highlighter/dist/esm/styles/prism"; import type { AgentConfigInfo } from "../api/agents"; import { agentsApi } from "../api/agents"; -import type { - AnthropicModelInfo, - OAuthStatus, - PipelineState, - WizardStateData, -} from "../api/client"; -import { api, ChatWebSocket } from "../api/client"; +import type { AnthropicModelInfo, OAuthStatus } from "../api/client"; +import { api } from "../api/client"; import { useChatHistory } from "../hooks/useChatHistory"; -import type { Message, ProviderConfig } from "../types"; -import { AgentPanel } from "./AgentPanel"; +import { useChatSend } from "../hooks/useChatSend"; +import { useChatWebSocket } from "../hooks/useChatWebSocket"; +import { estimateTokens, getContextWindowSize } from "../utils/chatUtils"; +import { ApiKeyDialog } from "./ApiKeyDialog"; import { ChatHeader } from "./ChatHeader"; import type { ChatInputHandle } from "./ChatInput"; import { ChatInput } from "./ChatInput"; +import { ChatMessageList } from "./ChatMessageList"; +import { ChatPipelinePanel } from "./ChatPipelinePanel"; import { HelpOverlay } from "./HelpOverlay"; -import { LozengeFlyProvider } from "./LozengeFlyContext"; -import { MessageItem } from "./MessageItem"; -import type { LogEntry } from "./ServerLogsPanel"; -import { ServerLogsPanel } from "./ServerLogsPanel"; -import SetupWizard from "./SetupWizard"; +import { PermissionDialog } from "./PermissionDialog"; +import { ReconciliationBanner } from "./ReconciliationBanner"; import { SideQuestionOverlay } from "./SideQuestionOverlay"; -import { StagePanel } from "./StagePanel"; -import { WorkItemDetailPanel } from "./WorkItemDetailPanel"; const { useCallback, useEffect, useMemo, useRef, useState } = React; -/** Fixed-height thinking trace block that auto-scrolls to bottom as text arrives. */ -function ThinkingBlock({ text }: { text: string }) { - const scrollRef = useRef(null); - - useEffect(() => { - const el = scrollRef.current; - if (el) { - el.scrollTop = el.scrollHeight; - } - }, [text]); - - return ( -
- - thinking - - {text} -
- ); -} - -/** Streaming message renderer — stable component to avoid recreation on each render. */ -function StreamingMessage({ content }: { content: string }) { - return ( - { - const match = /language-(\w+)/.exec(className || ""); - const isInline = !className; - return !isInline && match ? ( - - {String(children).replace(/\n$/, "")} - - ) : ( - - {children} - - ); - }, - }} - > - {content} - - ); -} - const NARROW_BREAKPOINT = 900; -function formatToolActivity(toolName: string): string { - switch (toolName) { - // Built-in provider tool names - case "read_file": - case "Read": - return "Reading file..."; - case "write_file": - case "Write": - case "Edit": - return "Writing file..."; - case "list_directory": - case "Glob": - return "Listing files..."; - case "search_files": - case "Grep": - return "Searching files..."; - case "exec_shell": - case "Bash": - return "Executing command..."; - // Claude Code additional tool names - case "Task": - return "Running task..."; - case "WebFetch": - return "Fetching web content..."; - case "WebSearch": - return "Searching the web..."; - case "NotebookEdit": - return "Editing notebook..."; - case "TodoWrite": - return "Updating tasks..."; - default: - return `Using ${toolName}...`; - } -} - -const estimateTokens = (text: string): number => Math.ceil(text.length / 4); - -const getContextWindowSize = ( - modelName: string, - claudeContextWindows?: Map, -): number => { - if (modelName.startsWith("claude-")) { - return claudeContextWindows?.get(modelName) ?? 200000; - } - if (modelName.includes("llama3")) return 8192; - if (modelName.includes("qwen2.5")) return 32768; - if (modelName.includes("deepseek")) return 16384; - return 8192; -}; - interface ChatProps { projectPath: string; onCloseProject: () => void; @@ -182,18 +42,7 @@ export function Chat({ const [claudeContextWindowMap, setClaudeContextWindowMap] = useState< Map >(new Map()); - const [streamingContent, setStreamingContent] = useState(""); - const [streamingThinking, setStreamingThinking] = useState(""); - const [showApiKeyDialog, setShowApiKeyDialog] = useState(false); - const [apiKeyInput, setApiKeyInput] = useState(""); const [hasAnthropicKey, setHasAnthropicKey] = useState(false); - const [pipeline, setPipeline] = useState({ - backlog: [], - current: [], - qa: [], - merge: [], - done: [], - }); const [claudeSessionId, setClaudeSessionId] = useState(() => { try { return ( @@ -204,73 +53,98 @@ export function Chat({ return null; } }); - const [activityStatus, setActivityStatus] = useState(null); - const [permissionQueue, setPermissionQueue] = useState< - { - requestId: string; - toolName: string; - toolInput: Record; - }[] - >([]); const [isNarrowScreen, setIsNarrowScreen] = useState( window.innerWidth < NARROW_BREAKPOINT, ); - const [reconciliationActive, setReconciliationActive] = useState(false); - const [reconciliationEvents, setReconciliationEvents] = useState< - { id: string; storyId: string; status: string; message: string }[] - >([]); - const reconciliationEventIdRef = useRef(0); - const [agentConfigVersion, setAgentConfigVersion] = useState(0); - const [agentStateVersion, setAgentStateVersion] = useState(0); - const [pipelineVersion, setPipelineVersion] = useState(0); const [agentRoster, setAgentRoster] = useState([]); - const [storyTokenCosts, setStoryTokenCosts] = useState>( - new Map(), - ); - const [needsOnboarding, setNeedsOnboarding] = useState(false); - const [wizardState, setWizardState] = useState(null); - const onboardingTriggeredRef = useRef(false); const [selectedWorkItemId, setSelectedWorkItemId] = useState( null, ); - - const handleDeleteItem = React.useCallback( - (item: import("../api/client").PipelineStageItem) => { - api.deleteStory(item.story_id).catch((err: unknown) => { - console.error("Failed to delete story:", err); - }); - }, - [], - ); + const [showHelp, setShowHelp] = useState(false); const [queuedMessages, setQueuedMessages] = useState< { id: string; text: string }[] >([]); - const [sideQuestion, setSideQuestion] = useState<{ - question: string; - response: string; - loading: boolean; - } | null>(null); - const [showHelp, setShowHelp] = useState(false); - const [serverLogs, setServerLogs] = useState([]); - const [wsConnected, setWsConnected] = useState(false); - // Ref so stale WebSocket callbacks can read the current queued messages - const queuedMessagesRef = useRef<{ id: string; text: string }[]>([]); - const queueIdCounterRef = useRef(0); - // Trigger state: set to a batch of message strings to fire auto-send after loading ends const [pendingAutoSendBatch, setPendingAutoSendBatch] = useState< string[] | null >(null); - const wsRef = useRef(null); const chatInputRef = useRef(null); - const messagesEndRef = useRef(null); const scrollContainerRef = useRef(null); + const messagesEndRef = useRef(null); const shouldAutoScrollRef = useRef(true); const lastScrollTopRef = useRef(0); const userScrolledUpRef = useRef(false); - const pendingMessageRef = useRef(""); + const queuedMessagesRef = useRef<{ id: string; text: string }[]>([]); + const queueIdCounterRef = useRef(0); + const onboardingTriggeredRef = useRef(false); + + const { + wsRef, + wsConnected, + streamingContent, + setStreamingContent, + streamingThinking, + setStreamingThinking, + activityStatus, + setActivityStatus, + permissionQueue, + setPermissionQueue, + pipeline, + pipelineVersion, + reconciliationActive, + reconciliationEvents, + agentConfigVersion, + agentStateVersion, + needsOnboarding, + setNeedsOnboarding, + wizardState, + setWizardState, + sideQuestion, + setSideQuestion, + serverLogs, + storyTokenCosts, + } = useChatWebSocket({ + setMessages, + setLoading, + setClaudeSessionId, + queuedMessagesRef, + setQueuedMessages, + setPendingAutoSendBatch, + }); + + const { + sendMessage, + sendMessageBatch, + cancelGeneration, + handleSaveApiKey, + clearSession, + showApiKeyDialog, + setShowApiKeyDialog, + apiKeyInput, + setApiKeyInput, + } = useChatSend({ + messages, + loading, + model, + enableTools, + claudeSessionId, + streamingContent, + setClaudeSessionId, + setMessages, + setLoading, + setStreamingContent, + setStreamingThinking, + setActivityStatus, + setSideQuestion, + chatInputRef, + wsRef, + queuedMessagesRef, + setQueuedMessages, + queueIdCounterRef, + clearMessages, + projectPath, + }); - // Agents currently running or pending across all pipeline stages. const busyAgentNames = useMemo(() => { const busy = new Set(); const allItems = [ @@ -291,27 +165,21 @@ export function Chat({ }, [pipeline]); const contextUsage = useMemo(() => { - let totalTokens = 0; - totalTokens += 200; - + let totalTokens = 200; for (const msg of messages) { totalTokens += estimateTokens(msg.content); if (msg.tool_calls) { totalTokens += estimateTokens(JSON.stringify(msg.tool_calls)); } } - if (streamingContent) { totalTokens += estimateTokens(streamingContent); } - const contextWindow = getContextWindowSize(model, claudeContextWindowMap); - const percentage = Math.round((totalTokens / contextWindow) * 100); - return { used: totalTokens, total: contextWindow, - percentage, + percentage: Math.round((totalTokens / contextWindow) * 100), }; }, [messages, streamingContent, model, claudeContextWindowMap]); @@ -339,12 +207,9 @@ export function Chat({ a.toLowerCase().localeCompare(b.toLowerCase()), ); setAvailableModels(sortedModels); - try { const savedModel = await api.getModelPreference(); - if (savedModel) { - setModel(savedModel); - } + if (savedModel) setModel(savedModel); } catch (e) { console.error(e); } @@ -359,12 +224,12 @@ export function Chat({ if (!exists) return; return api.getAnthropicModels().then((models: AnthropicModelInfo[]) => { if (models.length > 0) { - const sortedModels = models.sort((a, b) => + const sorted = models.sort((a, b) => a.id.toLowerCase().localeCompare(b.id.toLowerCase()), ); - setClaudeModels(sortedModels.map((m) => m.id)); + setClaudeModels(sorted.map((m) => m.id)); setClaudeContextWindowMap( - new Map(sortedModels.map((m) => [m.id, m.context_window])), + new Map(sorted.map((m) => [m.id, m.context_window])), ); } else { setClaudeModels([]); @@ -379,195 +244,6 @@ export function Chat({ }); }, []); - useEffect(() => { - const ws = new ChatWebSocket(); - wsRef.current = ws; - - ws.connect({ - onToken: (content) => { - setStreamingContent((prev: string) => prev + content); - }, - onThinkingToken: (content) => { - setStreamingThinking((prev: string) => prev + content); - }, - onUpdate: (history) => { - setMessages(history); - setStreamingContent(""); - setStreamingThinking(""); - const last = history[history.length - 1]; - if (last?.role === "assistant" && !last.tool_calls) { - setLoading(false); - setActivityStatus(null); - if (queuedMessagesRef.current.length > 0) { - const batch = queuedMessagesRef.current.map((item) => item.text); - queuedMessagesRef.current = []; - setQueuedMessages([]); - setPendingAutoSendBatch(batch); - } - } - }, - onSessionId: (sessionId) => { - setClaudeSessionId(sessionId); - }, - onError: (message) => { - console.error("WebSocket error:", message); - setLoading(false); - setActivityStatus(null); - const markdownMessage = message.replace( - /(https?:\/\/[^\s]+)/g, - "[$1]($1)", - ); - setMessages((prev) => [ - ...prev, - { role: "assistant", content: markdownMessage }, - ]); - if (queuedMessagesRef.current.length > 0) { - const batch = queuedMessagesRef.current.map((item) => item.text); - queuedMessagesRef.current = []; - setQueuedMessages([]); - setPendingAutoSendBatch(batch); - } - }, - onPipelineState: (state) => { - setPipeline(state); - setPipelineVersion((v) => v + 1); - const allItems = [ - ...state.backlog, - ...state.current, - ...state.qa, - ...state.merge, - ...state.done, - ]; - for (const item of allItems) { - api - .getTokenCost(item.story_id) - .then((cost) => { - if (cost.total_cost_usd > 0) { - setStoryTokenCosts((prev) => { - const next = new Map(prev); - next.set(item.story_id, cost.total_cost_usd); - return next; - }); - } - }) - .catch(() => { - // Silently ignore — cost data may not exist yet. - }); - } - }, - onPermissionRequest: (requestId, toolName, toolInput) => { - setPermissionQueue((prev) => [ - ...prev, - { requestId, toolName, toolInput }, - ]); - }, - onActivity: (toolName) => { - setActivityStatus(formatToolActivity(toolName)); - }, - onReconciliationProgress: (storyId, status, message) => { - if (status === "done") { - setReconciliationActive(false); - } else { - setReconciliationActive(true); - setReconciliationEvents((prev) => { - const id = String(reconciliationEventIdRef.current++); - const next = [...prev, { id, storyId, status, message }]; - // Keep only the last 8 events to avoid the banner growing too tall. - return next.slice(-8); - }); - } - }, - onAgentConfigChanged: () => { - setAgentConfigVersion((v) => v + 1); - }, - onAgentStateChanged: () => { - setAgentStateVersion((v) => v + 1); - }, - onOnboardingStatus: (onboarding: boolean) => { - setNeedsOnboarding(onboarding); - }, - onWizardState: (state: WizardStateData) => { - setWizardState(state); - }, - onSideQuestionToken: (content) => { - setSideQuestion((prev) => - prev ? { ...prev, response: prev.response + content } : prev, - ); - }, - onSideQuestionDone: (response) => { - setSideQuestion((prev) => - prev ? { ...prev, response, loading: false } : prev, - ); - }, - onLogEntry: (timestamp, level, message) => { - setServerLogs((prev) => [...prev, { timestamp, level, message }]); - }, - onConnected: () => { - setWsConnected(true); - }, - }); - - return () => { - ws.close(); - wsRef.current = null; - }; - }, []); - - const scrollToBottom = useCallback(() => { - const element = scrollContainerRef.current; - if (element) { - element.scrollTop = element.scrollHeight; - // Read scrollTop back after assignment: the browser caps it at - // (scrollHeight - clientHeight), so storing scrollHeight would - // make handleScroll incorrectly interpret the next scroll event - // as an upward scroll and disable auto-scrolling. - lastScrollTopRef.current = element.scrollTop; - } - }, []); - - const handleScroll = () => { - const element = scrollContainerRef.current; - if (!element) return; - - const currentScrollTop = element.scrollTop; - const isAtBottom = - element.scrollHeight - element.scrollTop - element.clientHeight < 5; - - if (currentScrollTop < lastScrollTopRef.current) { - userScrolledUpRef.current = true; - shouldAutoScrollRef.current = false; - } - - if (isAtBottom) { - userScrolledUpRef.current = false; - shouldAutoScrollRef.current = true; - } - - lastScrollTopRef.current = currentScrollTop; - }; - - const autoScrollKey = - messages.length + streamingContent.length + streamingThinking.length; - - useEffect(() => { - if ( - autoScrollKey >= 0 && - shouldAutoScrollRef.current && - !userScrolledUpRef.current - ) { - scrollToBottom(); - } - }, [autoScrollKey, scrollToBottom]); - - // Auto-send all queued messages as a batch when loading ends - useEffect(() => { - if (pendingAutoSendBatch && pendingAutoSendBatch.length > 0) { - const batch = pendingAutoSendBatch; - setPendingAutoSendBatch(null); - sendMessageBatch(batch); - } - }, [pendingAutoSendBatch]); - useEffect(() => { const handleResize = () => setIsNarrowScreen(window.innerWidth < NARROW_BREAKPOINT); @@ -575,7 +251,6 @@ export function Chat({ return () => window.removeEventListener("resize", handleResize); }, []); - // Fetch agent roster whenever the config changes so the start button knows available agents. useEffect(() => { agentsApi .getAgentConfig() @@ -585,6 +260,47 @@ export function Chat({ }); }, [agentConfigVersion]); + const scrollToBottom = useCallback(() => { + const el = scrollContainerRef.current; + if (el) { + el.scrollTop = el.scrollHeight; + lastScrollTopRef.current = el.scrollTop; + } + }, []); + + const handleScroll = () => { + const el = scrollContainerRef.current; + if (!el) return; + const currentScrollTop = el.scrollTop; + const isAtBottom = el.scrollHeight - el.scrollTop - el.clientHeight < 5; + if (currentScrollTop < lastScrollTopRef.current) { + userScrolledUpRef.current = true; + shouldAutoScrollRef.current = false; + } + if (isAtBottom) { + userScrolledUpRef.current = false; + shouldAutoScrollRef.current = true; + } + lastScrollTopRef.current = currentScrollTop; + }; + + const autoScrollKey = + messages.length + streamingContent.length + streamingThinking.length; + + useEffect(() => { + if (shouldAutoScrollRef.current && !userScrolledUpRef.current) { + scrollToBottom(); + } + }, [autoScrollKey, scrollToBottom]); + + useEffect(() => { + if (pendingAutoSendBatch && pendingAutoSendBatch.length > 0) { + const batch = pendingAutoSendBatch; + setPendingAutoSendBatch(null); + sendMessageBatch(batch); + } + }, [pendingAutoSendBatch, sendMessageBatch]); + const handleStartAgent = useCallback( async (storyId: string, agentName?: string) => { try { @@ -596,287 +312,27 @@ export function Chat({ [], ); - const cancelGeneration = async () => { - // Preserve queued messages by appending them to the chat input box - 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(); + const handleStopAgent = useCallback((storyId: string, agentName: string) => { + agentsApi.stopAgent(storyId, agentName).catch((err: unknown) => { + console.error("Failed to stop agent:", err); + }); + }, []); - if (streamingContent) { - setMessages((prev: Message[]) => [ - ...prev, - { role: "assistant", content: streamingContent }, - ]); - setStreamingContent(""); - } + const handleDeleteItem = useCallback( + (item: import("../api/client").PipelineStageItem) => { + api.deleteStory(item.story_id).catch((err: unknown) => { + console.error("Failed to delete story:", err); + }); + }, + [], + ); - setStreamingThinking(""); - setLoading(false); - setActivityStatus(null); - } catch (e) { - console.error("Failed to cancel chat:", e); - } - }; - - const sendMessage = async (messageText: string) => { - if (!messageText.trim()) return; - - // /reset — clear session and message history without LLM - if (/^\/reset\s*$/i.test(messageText)) { - setMessages([]); - setClaudeSessionId(null); - setStreamingContent(""); - setStreamingThinking(""); - setActivityStatus(null); - setMessages([ - { - role: "assistant", - content: "Session reset. Starting a fresh conversation.", - }, - ]); - return; - } - - // Slash commands forwarded to the backend bot command endpoint - const slashMatch = messageText.match(/^\/(\S+)(?:\s+([\s\S]*))?$/); - if (slashMatch) { - const cmd = slashMatch[1].toLowerCase(); - const args = (slashMatch[2] ?? "").trim(); - - // Ignore commands handled elsewhere - 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)) { - // Show the slash command in chat as a user message (display only) - 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; - } - - // Unknown slash command - setMessages((prev: Message[]) => [ - ...prev, - { role: "user", content: messageText }, - { - role: "assistant", - content: `Unknown command: \`/${cmd}\`. Type \`/help\` to see available commands.`, - }, - ]); - return; - } - } - - // /btw — answered from context without disrupting main chat - 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; - } - - // Agent is busy — queue the message instead of dropping it - 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; - } - } - - // Expand @file references: append file contents as context - const fileRefs = [...messageText.matchAll(/(^|[\s\n])@([^\s@]+)/g)].map( - (m) => m[2], + const handleRemoveQueuedMessage = useCallback((id: string) => { + queuedMessagesRef.current = queuedMessagesRef.current.filter( + (item) => item.id !== id, ); - 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); - } - }; - - const sendMessageBatch = 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); - } - }; - - const handleSaveApiKey = 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}`); - } - }; + setQueuedMessages([...queuedMessagesRef.current]); + }, []); const handlePermissionResponse = (approved: boolean, alwaysAllow = false) => { const current = permissionQueue[0]; @@ -889,46 +345,6 @@ export function Chat({ setPermissionQueue((prev) => prev.slice(1)); }; - const clearSession = 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. - } - } - }; - - const handleRemoveQueuedMessage = useCallback((id: string) => { - queuedMessagesRef.current = queuedMessagesRef.current.filter( - (item) => item.id !== id, - ); - setQueuedMessages([...queuedMessagesRef.current]); - }, []); - - const handleStopAgent = useCallback((storyId: string, agentName: string) => { - agentsApi.stopAgent(storyId, agentName).catch((err: unknown) => { - console.error("Failed to stop agent:", err); - }); - }, []); - return (
- {/* Two-column content area */}
- {/* Left column: chat messages + input pinned at bottom */}
- {/* Scrollable messages area */} -
-
- {wizardState && - !wizardState.completed && - messages.length === 0 && - !loading && ( - - )} - {needsOnboarding && - !wizardState && - messages.length === 0 && - !loading && ( -
-

- Welcome to Huskies -

-

- This project needs to be set up before you can start - writing stories. The agent will guide you through - configuring your project goals and tech stack. -

- -
- )} - {messages.map((msg: Message, idx: number) => ( - - ))} - {loading && streamingThinking && ( - - )} - {loading && streamingContent && ( -
-
-
- -
-
-
- )} - {loading && - (activityStatus != null || - (!streamingContent && !streamingThinking)) && ( -
- - {activityStatus ?? "Thinking..."} - -
- )} -
-
-
+ onboardingTriggeredRef={onboardingTriggeredRef} + /> - {/* Startup reconciliation progress banner */} {reconciliationActive && ( -
-
- Reconciling startup state... -
- {reconciliationEvents.map((evt) => ( -
- {evt.storyId ? `[${evt.storyId}] ` : ""} - {evt.message} -
- ))} -
+ )} - {/* Chat input pinned at bottom of left column */}
- {/* Right column: panels independently scrollable */} -
- - {selectedWorkItemId ? ( - setSelectedWorkItemId(null)} - /> - ) : ( - <> - - - setSelectedWorkItemId(item.story_id)} - onStopAgent={handleStopAgent} - onDeleteItem={handleDeleteItem} - /> - setSelectedWorkItemId(item.story_id)} - onStopAgent={handleStopAgent} - onDeleteItem={handleDeleteItem} - /> - setSelectedWorkItemId(item.story_id)} - onStopAgent={handleStopAgent} - onDeleteItem={handleDeleteItem} - /> - setSelectedWorkItemId(item.story_id)} - agentRoster={agentRoster} - busyAgentNames={busyAgentNames} - onStartAgent={handleStartAgent} - onStopAgent={handleStopAgent} - onDeleteItem={handleDeleteItem} - /> - setSelectedWorkItemId(item.story_id)} - agentRoster={agentRoster} - busyAgentNames={busyAgentNames} - onStartAgent={handleStartAgent} - onStopAgent={handleStopAgent} - onDeleteItem={handleDeleteItem} - /> - - - )} - -
+ setSelectedWorkItemId(null)} + onStartAgent={handleStartAgent} + onStopAgent={handleStopAgent} + onDeleteItem={handleDeleteItem} + />
{showApiKeyDialog && ( -
{ + setShowApiKeyDialog(false); + setApiKeyInput(""); }} - > -
-

- Enter Anthropic API Key -

-

- To use Claude models, please enter your Anthropic API key. Your - key will be stored server-side and reused across sessions. -

- setApiKeyInput(e.target.value)} - onKeyDown={(e) => e.key === "Enter" && handleSaveApiKey()} - placeholder="sk-ant-..." - style={{ - width: "100%", - padding: "12px", - borderRadius: "8px", - border: "1px solid #555", - backgroundColor: "#1a1a1a", - color: "#ececec", - fontSize: "1em", - marginBottom: "20px", - outline: "none", - }} - /> -
- - -
-
-
+ /> )} + {permissionQueue.length > 0 && ( -
-
-

- Permission Request - {permissionQueue.length > 1 && ( - - (+{permissionQueue.length - 1} queued) - - )} -

-

- The agent wants to use the{" "} - - {permissionQueue[0].toolName} - {" "} - tool. Do you approve? -

- {Object.keys(permissionQueue[0].toolInput).length > 0 && ( -
-								{JSON.stringify(permissionQueue[0].toolInput, null, 2)}
-							
- )} -
- - - -
-
-
+ )} {showHelp && setShowHelp(false)} />} diff --git a/frontend/src/components/ChatMessageList.tsx b/frontend/src/components/ChatMessageList.tsx new file mode 100644 index 00000000..d1a4ff67 --- /dev/null +++ b/frontend/src/components/ChatMessageList.tsx @@ -0,0 +1,278 @@ +import * as React from "react"; +import Markdown from "react-markdown"; +import { Prism as SyntaxHighlighter } from "react-syntax-highlighter"; +import { oneDark } from "react-syntax-highlighter/dist/esm/styles/prism"; +import type { WizardStateData } from "../api/client"; +import type { Message } from "../types"; +import { MessageItem } from "./MessageItem"; +import SetupWizard from "./SetupWizard"; + +const { useEffect, useRef } = React; + +/** Fixed-height thinking trace block that auto-scrolls to bottom as text arrives. */ +export function ThinkingBlock({ text }: { text: string }) { + const scrollRef = useRef(null); + + useEffect(() => { + const el = scrollRef.current; + if (el) { + el.scrollTop = el.scrollHeight; + } + }, [text]); + + return ( +
+ + thinking + + {text} +
+ ); +} + +/** Streaming message renderer — stable component to avoid recreation on each render. */ +export function StreamingMessage({ content }: { content: string }) { + return ( + { + const match = /language-(\w+)/.exec(className || ""); + const isInline = !className; + return !isInline && match ? ( + + {String(children).replace(/\n$/, "")} + + ) : ( + + {children} + + ); + }, + }} + > + {content} + + ); +} + +interface ChatMessageListProps { + messages: Message[]; + loading: boolean; + streamingContent: string; + streamingThinking: string; + activityStatus: string | null; + wizardState: WizardStateData | null; + setWizardState: React.Dispatch>; + needsOnboarding: boolean; + setNeedsOnboarding: React.Dispatch>; + sendMessage: (text: string) => void; + scrollContainerRef: React.RefObject; + messagesEndRef: React.RefObject; + onScroll: () => void; + onboardingTriggeredRef: React.MutableRefObject; +} + +export function ChatMessageList({ + messages, + loading, + streamingContent, + streamingThinking, + activityStatus, + wizardState, + setWizardState, + needsOnboarding, + setNeedsOnboarding, + sendMessage, + scrollContainerRef, + messagesEndRef, + onScroll, + onboardingTriggeredRef, +}: ChatMessageListProps) { + return ( +
+
+ {wizardState && + !wizardState.completed && + messages.length === 0 && + !loading && ( + + )} + {needsOnboarding && + !wizardState && + messages.length === 0 && + !loading && ( +
+

+ Welcome to Huskies +

+

+ This project needs to be set up before you can start writing + stories. The agent will guide you through configuring your + project goals and tech stack. +

+ +
+ )} + {messages.map((msg: Message, idx: number) => ( + + ))} + {loading && streamingThinking && ( + + )} + {loading && streamingContent && ( +
+
+
+ +
+
+
+ )} + {loading && + (activityStatus != null || + (!streamingContent && !streamingThinking)) && ( +
+ {activityStatus ?? "Thinking..."} +
+ )} +
+
+
+ ); +} diff --git a/frontend/src/components/ChatPipelinePanel.tsx b/frontend/src/components/ChatPipelinePanel.tsx new file mode 100644 index 00000000..ba4fc6d3 --- /dev/null +++ b/frontend/src/components/ChatPipelinePanel.tsx @@ -0,0 +1,124 @@ +import type { AgentConfigInfo } from "../api/agents"; +import type { PipelineStageItem, PipelineState } from "../api/client"; +import { AgentPanel } from "./AgentPanel"; +import { LozengeFlyProvider } from "./LozengeFlyContext"; +import type { LogEntry } from "./ServerLogsPanel"; +import { ServerLogsPanel } from "./ServerLogsPanel"; +import { StagePanel } from "./StagePanel"; +import { WorkItemDetailPanel } from "./WorkItemDetailPanel"; + +interface ChatPipelinePanelProps { + isNarrowScreen: boolean; + pipeline: PipelineState; + pipelineVersion: number; + agentConfigVersion: number; + agentStateVersion: number; + storyTokenCosts: Map; + agentRoster: AgentConfigInfo[]; + busyAgentNames: Set; + selectedWorkItemId: string | null; + serverLogs: LogEntry[]; + onSelectWorkItem: (id: string) => void; + onCloseWorkItem: () => void; + onStartAgent: (storyId: string, agentName?: string) => void; + onStopAgent: (storyId: string, agentName: string) => void; + onDeleteItem: (item: PipelineStageItem) => void; +} + +export function ChatPipelinePanel({ + isNarrowScreen, + pipeline, + pipelineVersion, + agentConfigVersion, + agentStateVersion, + storyTokenCosts, + agentRoster, + busyAgentNames, + selectedWorkItemId, + serverLogs, + onSelectWorkItem, + onCloseWorkItem, + onStartAgent, + onStopAgent, + onDeleteItem, +}: ChatPipelinePanelProps) { + return ( +
+ + {selectedWorkItemId ? ( + + ) : ( + <> + + onSelectWorkItem(item.story_id)} + onStopAgent={onStopAgent} + onDeleteItem={onDeleteItem} + /> + onSelectWorkItem(item.story_id)} + onStopAgent={onStopAgent} + onDeleteItem={onDeleteItem} + /> + onSelectWorkItem(item.story_id)} + onStopAgent={onStopAgent} + onDeleteItem={onDeleteItem} + /> + onSelectWorkItem(item.story_id)} + agentRoster={agentRoster} + busyAgentNames={busyAgentNames} + onStartAgent={onStartAgent} + onStopAgent={onStopAgent} + onDeleteItem={onDeleteItem} + /> + onSelectWorkItem(item.story_id)} + agentRoster={agentRoster} + busyAgentNames={busyAgentNames} + onStartAgent={onStartAgent} + onStopAgent={onStopAgent} + onDeleteItem={onDeleteItem} + /> + + + )} + +
+ ); +} diff --git a/frontend/src/components/PermissionDialog.tsx b/frontend/src/components/PermissionDialog.tsx new file mode 100644 index 00000000..3c8afe73 --- /dev/null +++ b/frontend/src/components/PermissionDialog.tsx @@ -0,0 +1,144 @@ +interface PermissionRequest { + requestId: string; + toolName: string; + toolInput: Record; +} + +interface PermissionDialogProps { + permissionQueue: PermissionRequest[]; + onResponse: (approved: boolean, alwaysAllow?: boolean) => void; +} + +export function PermissionDialog({ + permissionQueue, + onResponse, +}: PermissionDialogProps) { + const current = permissionQueue[0]; + if (!current) return null; + + return ( +
+
+

+ Permission Request + {permissionQueue.length > 1 && ( + + (+{permissionQueue.length - 1} queued) + + )} +

+

+ The agent wants to use the{" "} + {current.toolName} tool. + Do you approve? +

+ {Object.keys(current.toolInput).length > 0 && ( +
+						{JSON.stringify(current.toolInput, null, 2)}
+					
+ )} +
+ + + +
+
+
+ ); +} diff --git a/frontend/src/components/ReconciliationBanner.tsx b/frontend/src/components/ReconciliationBanner.tsx new file mode 100644 index 00000000..bd85d89e --- /dev/null +++ b/frontend/src/components/ReconciliationBanner.tsx @@ -0,0 +1,59 @@ +interface ReconciliationEvent { + id: string; + storyId: string; + status: string; + message: string; +} + +interface ReconciliationBannerProps { + reconciliationEvents: ReconciliationEvent[]; +} + +export function ReconciliationBanner({ + reconciliationEvents, +}: ReconciliationBannerProps) { + return ( +
+
+ Reconciling startup state... +
+ {reconciliationEvents.map((evt) => ( +
+ {evt.storyId ? `[${evt.storyId}] ` : ""} + {evt.message} +
+ ))} +
+ ); +} diff --git a/frontend/src/hooks/useChatSend.ts b/frontend/src/hooks/useChatSend.ts new file mode 100644 index 00000000..ae0bbf0b --- /dev/null +++ b/frontend/src/hooks/useChatSend.ts @@ -0,0 +1,439 @@ +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, + }; +} diff --git a/frontend/src/hooks/useChatWebSocket.ts b/frontend/src/hooks/useChatWebSocket.ts new file mode 100644 index 00000000..a3738cee --- /dev/null +++ b/frontend/src/hooks/useChatWebSocket.ts @@ -0,0 +1,280 @@ +import * as React from "react"; +import type { PipelineState, WizardStateData } from "../api/client"; +import { api, ChatWebSocket } from "../api/client"; +import type { LogEntry } from "../components/ServerLogsPanel"; +import type { Message } from "../types"; +import { formatToolActivity } from "../utils/chatUtils"; + +const { useEffect, useRef, useState } = React; + +type SetState = React.Dispatch>; + +interface UseChatWebSocketParams { + setMessages: SetState; + setLoading: SetState; + setClaudeSessionId: SetState; + queuedMessagesRef: React.MutableRefObject<{ id: string; text: string }[]>; + setQueuedMessages: SetState<{ id: string; text: string }[]>; + setPendingAutoSendBatch: SetState; +} + +interface ReconciliationEvent { + id: string; + storyId: string; + status: string; + message: string; +} + +export interface UseChatWebSocketResult { + wsRef: React.MutableRefObject; + wsConnected: boolean; + streamingContent: string; + setStreamingContent: SetState; + streamingThinking: string; + setStreamingThinking: SetState; + activityStatus: string | null; + setActivityStatus: SetState; + permissionQueue: { + requestId: string; + toolName: string; + toolInput: Record; + }[]; + setPermissionQueue: SetState< + { + requestId: string; + toolName: string; + toolInput: Record; + }[] + >; + pipeline: PipelineState; + pipelineVersion: number; + reconciliationActive: boolean; + reconciliationEvents: ReconciliationEvent[]; + agentConfigVersion: number; + agentStateVersion: number; + needsOnboarding: boolean; + setNeedsOnboarding: SetState; + wizardState: WizardStateData | null; + setWizardState: SetState; + sideQuestion: { + question: string; + response: string; + loading: boolean; + } | null; + setSideQuestion: SetState<{ + question: string; + response: string; + loading: boolean; + } | null>; + serverLogs: LogEntry[]; + storyTokenCosts: Map; +} + +export function useChatWebSocket({ + setMessages, + setLoading, + setClaudeSessionId, + queuedMessagesRef, + setQueuedMessages, + setPendingAutoSendBatch, +}: UseChatWebSocketParams): UseChatWebSocketResult { + const wsRef = useRef(null); + const [wsConnected, setWsConnected] = useState(false); + const [streamingContent, setStreamingContent] = useState(""); + const [streamingThinking, setStreamingThinking] = useState(""); + const [activityStatus, setActivityStatus] = useState(null); + const [permissionQueue, setPermissionQueue] = useState< + { + requestId: string; + toolName: string; + toolInput: Record; + }[] + >([]); + const [pipeline, setPipeline] = useState({ + backlog: [], + current: [], + qa: [], + merge: [], + done: [], + }); + const [pipelineVersion, setPipelineVersion] = useState(0); + const [reconciliationActive, setReconciliationActive] = useState(false); + const [reconciliationEvents, setReconciliationEvents] = useState< + ReconciliationEvent[] + >([]); + const reconciliationEventIdRef = useRef(0); + const [agentConfigVersion, setAgentConfigVersion] = useState(0); + const [agentStateVersion, setAgentStateVersion] = useState(0); + const [needsOnboarding, setNeedsOnboarding] = useState(false); + const [wizardState, setWizardState] = useState(null); + const [sideQuestion, setSideQuestion] = useState<{ + question: string; + response: string; + loading: boolean; + } | null>(null); + const [serverLogs, setServerLogs] = useState([]); + const [storyTokenCosts, setStoryTokenCosts] = useState>( + new Map(), + ); + + useEffect(() => { + const ws = new ChatWebSocket(); + wsRef.current = ws; + + ws.connect({ + onToken: (content) => { + setStreamingContent((prev: string) => prev + content); + }, + onThinkingToken: (content) => { + setStreamingThinking((prev: string) => prev + content); + }, + onUpdate: (history) => { + setMessages(history); + setStreamingContent(""); + setStreamingThinking(""); + const last = history[history.length - 1]; + if (last?.role === "assistant" && !last.tool_calls) { + setLoading(false); + setActivityStatus(null); + if (queuedMessagesRef.current.length > 0) { + const batch = queuedMessagesRef.current.map((item) => item.text); + queuedMessagesRef.current = []; + setQueuedMessages([]); + setPendingAutoSendBatch(batch); + } + } + }, + onSessionId: (sessionId) => { + setClaudeSessionId(sessionId); + }, + onError: (message) => { + console.error("WebSocket error:", message); + setLoading(false); + setActivityStatus(null); + const markdownMessage = message.replace( + /(https?:\/\/[^\s]+)/g, + "[$1]($1)", + ); + setMessages((prev) => [ + ...prev, + { role: "assistant", content: markdownMessage }, + ]); + if (queuedMessagesRef.current.length > 0) { + const batch = queuedMessagesRef.current.map((item) => item.text); + queuedMessagesRef.current = []; + setQueuedMessages([]); + setPendingAutoSendBatch(batch); + } + }, + onPipelineState: (state) => { + setPipeline(state); + setPipelineVersion((v) => v + 1); + const allItems = [ + ...state.backlog, + ...state.current, + ...state.qa, + ...state.merge, + ...state.done, + ]; + for (const item of allItems) { + api + .getTokenCost(item.story_id) + .then((cost) => { + if (cost.total_cost_usd > 0) { + setStoryTokenCosts((prev) => { + const next = new Map(prev); + next.set(item.story_id, cost.total_cost_usd); + return next; + }); + } + }) + .catch(() => { + // Silently ignore — cost data may not exist yet. + }); + } + }, + onPermissionRequest: (requestId, toolName, toolInput) => { + setPermissionQueue((prev) => [ + ...prev, + { requestId, toolName, toolInput }, + ]); + }, + onActivity: (toolName) => { + setActivityStatus(formatToolActivity(toolName)); + }, + onReconciliationProgress: (storyId, status, message) => { + if (status === "done") { + setReconciliationActive(false); + } else { + setReconciliationActive(true); + setReconciliationEvents((prev) => { + const id = String(reconciliationEventIdRef.current++); + const next = [...prev, { id, storyId, status, message }]; + // Keep only the last 8 events to avoid the banner growing too tall. + return next.slice(-8); + }); + } + }, + onAgentConfigChanged: () => { + setAgentConfigVersion((v) => v + 1); + }, + onAgentStateChanged: () => { + setAgentStateVersion((v) => v + 1); + }, + onOnboardingStatus: (onboarding: boolean) => { + setNeedsOnboarding(onboarding); + }, + onWizardState: (state: WizardStateData) => { + setWizardState(state); + }, + onSideQuestionToken: (content) => { + setSideQuestion((prev) => + prev ? { ...prev, response: prev.response + content } : prev, + ); + }, + onSideQuestionDone: (response) => { + setSideQuestion((prev) => + prev ? { ...prev, response, loading: false } : prev, + ); + }, + onLogEntry: (timestamp, level, message) => { + setServerLogs((prev) => [...prev, { timestamp, level, message }]); + }, + onConnected: () => { + setWsConnected(true); + }, + }); + + return () => { + ws.close(); + wsRef.current = null; + }; + }, []); + + return { + wsRef, + wsConnected, + streamingContent, + setStreamingContent, + streamingThinking, + setStreamingThinking, + activityStatus, + setActivityStatus, + permissionQueue, + setPermissionQueue, + pipeline, + pipelineVersion, + reconciliationActive, + reconciliationEvents, + agentConfigVersion, + agentStateVersion, + needsOnboarding, + setNeedsOnboarding, + wizardState, + setWizardState, + sideQuestion, + setSideQuestion, + serverLogs, + storyTokenCosts, + }; +} diff --git a/frontend/src/utils/chatUtils.ts b/frontend/src/utils/chatUtils.ts new file mode 100644 index 00000000..2186a6f8 --- /dev/null +++ b/frontend/src/utils/chatUtils.ts @@ -0,0 +1,50 @@ +export function formatToolActivity(toolName: string): string { + switch (toolName) { + // Built-in provider tool names + case "read_file": + case "Read": + return "Reading file..."; + case "write_file": + case "Write": + case "Edit": + return "Writing file..."; + case "list_directory": + case "Glob": + return "Listing files..."; + case "search_files": + case "Grep": + return "Searching files..."; + case "exec_shell": + case "Bash": + return "Executing command..."; + // Claude Code additional tool names + case "Task": + return "Running task..."; + case "WebFetch": + return "Fetching web content..."; + case "WebSearch": + return "Searching the web..."; + case "NotebookEdit": + return "Editing notebook..."; + case "TodoWrite": + return "Updating tasks..."; + default: + return `Using ${toolName}...`; + } +} + +export const estimateTokens = (text: string): number => + Math.ceil(text.length / 4); + +export const getContextWindowSize = ( + modelName: string, + claudeContextWindows?: Map, +): number => { + if (modelName.startsWith("claude-")) { + return claudeContextWindows?.get(modelName) ?? 200000; + } + if (modelName.includes("llama3")) return 8192; + if (modelName.includes("qwen2.5")) return 32768; + if (modelName.includes("deepseek")) return 16384; + return 8192; +};