import * as React from "react"; import type { AgentConfigInfo } from "../api/agents"; import { agentsApi } from "../api/agents"; import type { AnthropicModelInfo, OAuthStatus } from "../api/client"; import { api } from "../api/client"; import { useChatHistory } from "../hooks/useChatHistory"; import { useChatSend } from "../hooks/useChatSend"; import { useChatWebSocket } from "../hooks/useChatWebSocket"; import { estimateTokens, getContextWindowSize } from "../utils/chatUtils"; import { ApiKeyDialog } from "./ApiKeyDialog"; import { BotConfigPage } from "./BotConfigPage"; import { SettingsPage } from "./SettingsPage"; 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 { PermissionDialog } from "./PermissionDialog"; import { ReconciliationBanner } from "./ReconciliationBanner"; import { SideQuestionOverlay } from "./SideQuestionOverlay"; const { useCallback, useEffect, useMemo, useRef, useState } = React; const NARROW_BREAKPOINT = 900; interface ChatProps { projectPath: string; onCloseProject: () => void; oauthStatus?: OAuthStatus | null; } export function Chat({ projectPath, onCloseProject, oauthStatus = null, }: ChatProps) { const { messages, setMessages, clearMessages } = useChatHistory(projectPath); const [loading, setLoading] = useState(false); const [model, setModel] = useState("claude-code-pty"); const [enableTools, setEnableTools] = useState(true); const [availableModels, setAvailableModels] = useState([]); const [claudeModels, setClaudeModels] = useState([]); const [claudeContextWindowMap, setClaudeContextWindowMap] = useState< Map >(new Map()); const [hasAnthropicKey, setHasAnthropicKey] = useState(false); const [claudeSessionId, setClaudeSessionId] = useState(() => { try { return ( localStorage.getItem(`storykit-claude-session-id:${projectPath}`) ?? null ); } catch { return null; } }); const [isNarrowScreen, setIsNarrowScreen] = useState( window.innerWidth < NARROW_BREAKPOINT, ); const [agentRoster, setAgentRoster] = useState([]); const [selectedWorkItemId, setSelectedWorkItemId] = useState( null, ); const [showHelp, setShowHelp] = useState(false); const [view, setView] = useState<"chat" | "bot-config" | "settings">("chat"); const [queuedMessages, setQueuedMessages] = useState< { id: string; text: string }[] >([]); const [pendingAutoSendBatch, setPendingAutoSendBatch] = useState< string[] | null >(null); const chatInputRef = useRef(null); const scrollContainerRef = useRef(null); const messagesEndRef = useRef(null); const shouldAutoScrollRef = useRef(true); const lastScrollTopRef = useRef(0); const userScrolledUpRef = useRef(false); 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, }); const busyAgentNames = useMemo(() => { const busy = new Set(); const allItems = [ ...pipeline.backlog, ...pipeline.current, ...pipeline.qa, ...pipeline.merge, ]; for (const item of allItems) { if ( item.agent && (item.agent.status === "running" || item.agent.status === "pending") ) { busy.add(item.agent.agent_name); } } return busy; }, [pipeline]); const contextUsage = useMemo(() => { 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); return { used: totalTokens, total: contextWindow, percentage: Math.round((totalTokens / contextWindow) * 100), }; }, [messages, streamingContent, model, claudeContextWindowMap]); useEffect(() => { try { if (claudeSessionId !== null) { localStorage.setItem( `storykit-claude-session-id:${projectPath}`, claudeSessionId, ); } else { localStorage.removeItem(`storykit-claude-session-id:${projectPath}`); } } catch { // Ignore — quota or security errors. } }, [claudeSessionId, projectPath]); useEffect(() => { api .getOllamaModels() .then(async (models) => { if (models.length > 0) { const sortedModels = models.sort((a, b) => a.toLowerCase().localeCompare(b.toLowerCase()), ); setAvailableModels(sortedModels); try { const savedModel = await api.getModelPreference(); if (savedModel) setModel(savedModel); } catch (e) { console.error(e); } } }) .catch((err) => console.error(err)); api .getAnthropicApiKeyExists() .then((exists) => { setHasAnthropicKey(exists); if (!exists) return; return api.getAnthropicModels().then((models: AnthropicModelInfo[]) => { if (models.length > 0) { const sorted = models.sort((a, b) => a.id.toLowerCase().localeCompare(b.id.toLowerCase()), ); setClaudeModels(sorted.map((m) => m.id)); setClaudeContextWindowMap( new Map(sorted.map((m) => [m.id, m.context_window])), ); } else { setClaudeModels([]); setClaudeContextWindowMap(new Map()); } }); }) .catch((err) => { console.error(err); setHasAnthropicKey(false); setClaudeModels([]); }); }, []); useEffect(() => { const handleResize = () => setIsNarrowScreen(window.innerWidth < NARROW_BREAKPOINT); window.addEventListener("resize", handleResize); return () => window.removeEventListener("resize", handleResize); }, []); useEffect(() => { agentsApi .getAgentConfig() .then(setAgentRoster) .catch(() => { // Silently ignore — roster unavailable. }); }, [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 { await agentsApi.startAgent(storyId, agentName); } catch (err) { console.error("Failed to start agent:", err); } }, [], ); const handleStopAgent = useCallback((storyId: string, agentName: string) => { agentsApi.stopAgent(storyId, agentName).catch((err: unknown) => { console.error("Failed to stop agent:", err); }); }, []); const handleDeleteItem = useCallback( (item: import("../api/client").PipelineStageItem) => { api.deleteStory(item.story_id).catch((err: unknown) => { console.error("Failed to delete story:", err); }); }, [], ); const handleRemoveQueuedMessage = useCallback((id: string) => { queuedMessagesRef.current = queuedMessagesRef.current.filter( (item) => item.id !== id, ); setQueuedMessages([...queuedMessagesRef.current]); }, []); const handlePermissionResponse = (approved: boolean, alwaysAllow = false) => { const current = permissionQueue[0]; if (!current) return; wsRef.current?.sendPermissionResponse( current.requestId, approved, alwaysAllow, ); setPermissionQueue((prev) => prev.slice(1)); }; return (
{ setModel(newModel); api.setModelPreference(newModel).catch(console.error); }} enableTools={enableTools} onToggleTools={setEnableTools} wsConnected={wsConnected} oauthStatus={oauthStatus} onShowBotConfig={() => setView("bot-config")} onShowSettings={() => setView("settings")} /> {view === "bot-config" && ( setView("chat")} /> )} {view === "settings" && ( setView("chat")} /> )}
{reconciliationActive && ( )}
setSelectedWorkItemId(null)} onStartAgent={handleStartAgent} onStopAgent={handleStopAgent} onDeleteItem={handleDeleteItem} />
{showApiKeyDialog && ( { setShowApiKeyDialog(false); setApiKeyInput(""); }} /> )} {permissionQueue.length > 0 && ( )} {showHelp && setShowHelp(false)} />} {sideQuestion && ( setSideQuestion(null)} /> )}
); }