Files
huskies/frontend/src/components/Chat.tsx
T

479 lines
12 KiB
TypeScript

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 { 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<string[]>([]);
const [claudeModels, setClaudeModels] = useState<string[]>([]);
const [claudeContextWindowMap, setClaudeContextWindowMap] = useState<
Map<string, number>
>(new Map());
const [hasAnthropicKey, setHasAnthropicKey] = useState(false);
const [claudeSessionId, setClaudeSessionId] = useState<string | null>(() => {
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<AgentConfigInfo[]>([]);
const [selectedWorkItemId, setSelectedWorkItemId] = useState<string | null>(
null,
);
const [showHelp, setShowHelp] = useState(false);
const [queuedMessages, setQueuedMessages] = useState<
{ id: string; text: string }[]
>([]);
const [pendingAutoSendBatch, setPendingAutoSendBatch] = useState<
string[] | null
>(null);
const chatInputRef = useRef<ChatInputHandle>(null);
const scrollContainerRef = useRef<HTMLDivElement>(null);
const messagesEndRef = useRef<HTMLDivElement>(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<string>();
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 (
<div
className="chat-container"
style={{
display: "flex",
flexDirection: "column",
height: "100%",
backgroundColor: "#171717",
color: "#ececec",
}}
>
<ChatHeader
projectPath={projectPath}
onCloseProject={onCloseProject}
contextUsage={contextUsage}
onClearSession={clearSession}
model={model}
availableModels={availableModels}
claudeModels={claudeModels}
hasAnthropicKey={hasAnthropicKey}
onModelChange={(newModel) => {
setModel(newModel);
api.setModelPreference(newModel).catch(console.error);
}}
enableTools={enableTools}
onToggleTools={setEnableTools}
wsConnected={wsConnected}
oauthStatus={oauthStatus}
/>
<div
data-testid="chat-content-area"
style={{
display: "flex",
flex: 1,
minHeight: 0,
flexDirection: isNarrowScreen ? "column" : "row",
}}
>
<div
data-testid="chat-left-column"
style={{
display: "flex",
flexDirection: "column",
flex: "0 0 60%",
minHeight: 0,
overflow: "hidden",
}}
>
<ChatMessageList
messages={messages}
loading={loading}
streamingContent={streamingContent}
streamingThinking={streamingThinking}
activityStatus={activityStatus}
wizardState={wizardState}
setWizardState={setWizardState}
needsOnboarding={needsOnboarding}
setNeedsOnboarding={setNeedsOnboarding}
sendMessage={sendMessage}
scrollContainerRef={scrollContainerRef}
messagesEndRef={messagesEndRef}
onScroll={handleScroll}
onboardingTriggeredRef={onboardingTriggeredRef}
/>
{reconciliationActive && (
<ReconciliationBanner reconciliationEvents={reconciliationEvents} />
)}
<ChatInput
ref={chatInputRef}
loading={loading}
queuedMessages={queuedMessages}
onSubmit={sendMessage}
onCancel={cancelGeneration}
onRemoveQueuedMessage={handleRemoveQueuedMessage}
/>
</div>
<ChatPipelinePanel
isNarrowScreen={isNarrowScreen}
pipeline={pipeline}
pipelineVersion={pipelineVersion}
agentConfigVersion={agentConfigVersion}
agentStateVersion={agentStateVersion}
storyTokenCosts={storyTokenCosts}
agentRoster={agentRoster}
busyAgentNames={busyAgentNames}
selectedWorkItemId={selectedWorkItemId}
serverLogs={serverLogs}
onSelectWorkItem={setSelectedWorkItemId}
onCloseWorkItem={() => setSelectedWorkItemId(null)}
onStartAgent={handleStartAgent}
onStopAgent={handleStopAgent}
onDeleteItem={handleDeleteItem}
/>
</div>
{showApiKeyDialog && (
<ApiKeyDialog
apiKeyInput={apiKeyInput}
onApiKeyChange={setApiKeyInput}
onSave={handleSaveApiKey}
onCancel={() => {
setShowApiKeyDialog(false);
setApiKeyInput("");
}}
/>
)}
{permissionQueue.length > 0 && (
<PermissionDialog
permissionQueue={permissionQueue}
onResponse={handlePermissionResponse}
/>
)}
{showHelp && <HelpOverlay onDismiss={() => setShowHelp(false)} />}
{sideQuestion && (
<SideQuestionOverlay
question={sideQuestion.question}
response={sideQuestion.response}
loading={sideQuestion.loading}
onDismiss={() => setSideQuestion(null)}
/>
)}
</div>
);
}