huskies: merge 473_refactor_split_chat_tsx_into_smaller_components

This commit is contained in:
dave
2026-04-04 15:12:03 +00:00
parent d4979ae492
commit fa99f19198
9 changed files with 1679 additions and 1228 deletions
+439
View File
@@ -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<T> = React.Dispatch<React.SetStateAction<T>>;
interface UseChatSendParams {
messages: Message[];
loading: boolean;
model: string;
enableTools: boolean;
claudeSessionId: string | null;
streamingContent: string;
setClaudeSessionId: SetState<string | null>;
setMessages: SetState<Message[]>;
setLoading: SetState<boolean>;
setStreamingContent: SetState<string>;
setStreamingThinking: SetState<string>;
setActivityStatus: SetState<string | null>;
setSideQuestion: SetState<{
question: string;
response: string;
loading: boolean;
} | null>;
chatInputRef: React.RefObject<ChatInputHandle | null>;
wsRef: React.MutableRefObject<ChatWebSocket | null>;
queuedMessagesRef: React.MutableRefObject<{ id: string; text: string }[]>;
setQueuedMessages: SetState<{ id: string; text: string }[]>;
queueIdCounterRef: React.MutableRefObject<number>;
clearMessages: () => void;
projectPath: string;
}
export interface UseChatSendResult {
sendMessage: (text: string) => Promise<void>;
sendMessageBatch: (texts: string[]) => Promise<void>;
cancelGeneration: () => Promise<void>;
handleSaveApiKey: () => Promise<void>;
clearSession: () => Promise<void>;
showApiKeyDialog: boolean;
setShowApiKeyDialog: SetState<boolean>;
apiKeyInput: string;
setApiKeyInput: SetState<string>;
}
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<string>("");
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,
};
}
+280
View File
@@ -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<T> = React.Dispatch<React.SetStateAction<T>>;
interface UseChatWebSocketParams {
setMessages: SetState<Message[]>;
setLoading: SetState<boolean>;
setClaudeSessionId: SetState<string | null>;
queuedMessagesRef: React.MutableRefObject<{ id: string; text: string }[]>;
setQueuedMessages: SetState<{ id: string; text: string }[]>;
setPendingAutoSendBatch: SetState<string[] | null>;
}
interface ReconciliationEvent {
id: string;
storyId: string;
status: string;
message: string;
}
export interface UseChatWebSocketResult {
wsRef: React.MutableRefObject<ChatWebSocket | null>;
wsConnected: boolean;
streamingContent: string;
setStreamingContent: SetState<string>;
streamingThinking: string;
setStreamingThinking: SetState<string>;
activityStatus: string | null;
setActivityStatus: SetState<string | null>;
permissionQueue: {
requestId: string;
toolName: string;
toolInput: Record<string, unknown>;
}[];
setPermissionQueue: SetState<
{
requestId: string;
toolName: string;
toolInput: Record<string, unknown>;
}[]
>;
pipeline: PipelineState;
pipelineVersion: number;
reconciliationActive: boolean;
reconciliationEvents: ReconciliationEvent[];
agentConfigVersion: number;
agentStateVersion: number;
needsOnboarding: boolean;
setNeedsOnboarding: SetState<boolean>;
wizardState: WizardStateData | null;
setWizardState: SetState<WizardStateData | null>;
sideQuestion: {
question: string;
response: string;
loading: boolean;
} | null;
setSideQuestion: SetState<{
question: string;
response: string;
loading: boolean;
} | null>;
serverLogs: LogEntry[];
storyTokenCosts: Map<string, number>;
}
export function useChatWebSocket({
setMessages,
setLoading,
setClaudeSessionId,
queuedMessagesRef,
setQueuedMessages,
setPendingAutoSendBatch,
}: UseChatWebSocketParams): UseChatWebSocketResult {
const wsRef = useRef<ChatWebSocket | null>(null);
const [wsConnected, setWsConnected] = useState(false);
const [streamingContent, setStreamingContent] = useState("");
const [streamingThinking, setStreamingThinking] = useState("");
const [activityStatus, setActivityStatus] = useState<string | null>(null);
const [permissionQueue, setPermissionQueue] = useState<
{
requestId: string;
toolName: string;
toolInput: Record<string, unknown>;
}[]
>([]);
const [pipeline, setPipeline] = useState<PipelineState>({
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<WizardStateData | null>(null);
const [sideQuestion, setSideQuestion] = useState<{
question: string;
response: string;
loading: boolean;
} | null>(null);
const [serverLogs, setServerLogs] = useState<LogEntry[]>([]);
const [storyTokenCosts, setStoryTokenCosts] = useState<Map<string, number>>(
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,
};
}