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
+112
View File
@@ -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 (
<div
style={{
position: "fixed",
top: 0,
left: 0,
right: 0,
bottom: 0,
backgroundColor: "rgba(0, 0, 0, 0.7)",
display: "flex",
alignItems: "center",
justifyContent: "center",
zIndex: 1000,
}}
>
<div
style={{
backgroundColor: "#2f2f2f",
padding: "32px",
borderRadius: "12px",
maxWidth: "500px",
width: "90%",
border: "1px solid #444",
}}
>
<h2 style={{ marginTop: 0, color: "#ececec" }}>
Enter Anthropic API Key
</h2>
<p
style={{
color: "#aaa",
fontSize: "0.9em",
marginBottom: "20px",
}}
>
To use Claude models, please enter your Anthropic API key. Your key
will be stored server-side and reused across sessions.
</p>
<input
type="password"
value={apiKeyInput}
onChange={(e) => 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",
}}
/>
<div
style={{
display: "flex",
gap: "12px",
justifyContent: "flex-end",
}}
>
<button
type="button"
onClick={onCancel}
style={{
padding: "10px 20px",
borderRadius: "8px",
border: "1px solid #555",
backgroundColor: "transparent",
color: "#aaa",
cursor: "pointer",
fontSize: "0.9em",
}}
>
Cancel
</button>
<button
type="button"
onClick={onSave}
disabled={!apiKeyInput.trim()}
style={{
padding: "10px 20px",
borderRadius: "8px",
border: "none",
backgroundColor: apiKeyInput.trim() ? "#ececec" : "#555",
color: apiKeyInput.trim() ? "#000" : "#888",
cursor: apiKeyInput.trim() ? "pointer" : "not-allowed",
fontSize: "0.9em",
}}
>
Save Key
</button>
</div>
</div>
</div>
);
}
File diff suppressed because it is too large Load Diff
+278
View File
@@ -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<HTMLDivElement>(null);
useEffect(() => {
const el = scrollRef.current;
if (el) {
el.scrollTop = el.scrollHeight;
}
}, [text]);
return (
<div
data-testid="chat-thinking-block"
ref={scrollRef}
style={{
maxHeight: "96px",
overflowY: "auto",
background: "#161b22",
border: "1px solid #2d333b",
borderRadius: "6px",
padding: "6px 10px",
fontSize: "0.78em",
fontFamily: "monospace",
color: "#6e7681",
fontStyle: "italic",
whiteSpace: "pre-wrap",
wordBreak: "break-word",
lineHeight: "1.4",
marginBottom: "8px",
}}
>
<span
style={{
display: "block",
fontSize: "0.8em",
color: "#444c56",
marginBottom: "4px",
fontStyle: "normal",
letterSpacing: "0.04em",
textTransform: "uppercase",
}}
>
thinking
</span>
{text}
</div>
);
}
/** Streaming message renderer — stable component to avoid recreation on each render. */
export function StreamingMessage({ content }: { content: string }) {
return (
<Markdown
components={{
// biome-ignore lint/suspicious/noExplicitAny: react-markdown requires any for component props
code: ({ className, children, ...props }: any) => {
const match = /language-(\w+)/.exec(className || "");
const isInline = !className;
return !isInline && match ? (
<SyntaxHighlighter
// biome-ignore lint/suspicious/noExplicitAny: oneDark style types are incompatible
style={oneDark as any}
language={match[1]}
PreTag="div"
>
{String(children).replace(/\n$/, "")}
</SyntaxHighlighter>
) : (
<code className={className} {...props}>
{children}
</code>
);
},
}}
>
{content}
</Markdown>
);
}
interface ChatMessageListProps {
messages: Message[];
loading: boolean;
streamingContent: string;
streamingThinking: string;
activityStatus: string | null;
wizardState: WizardStateData | null;
setWizardState: React.Dispatch<React.SetStateAction<WizardStateData | null>>;
needsOnboarding: boolean;
setNeedsOnboarding: React.Dispatch<React.SetStateAction<boolean>>;
sendMessage: (text: string) => void;
scrollContainerRef: React.RefObject<HTMLDivElement | null>;
messagesEndRef: React.RefObject<HTMLDivElement | null>;
onScroll: () => void;
onboardingTriggeredRef: React.MutableRefObject<boolean>;
}
export function ChatMessageList({
messages,
loading,
streamingContent,
streamingThinking,
activityStatus,
wizardState,
setWizardState,
needsOnboarding,
setNeedsOnboarding,
sendMessage,
scrollContainerRef,
messagesEndRef,
onScroll,
onboardingTriggeredRef,
}: ChatMessageListProps) {
return (
<div
ref={scrollContainerRef}
onScroll={onScroll}
style={{
flex: 1,
overflowY: "auto",
padding: "20px 0",
display: "flex",
flexDirection: "column",
gap: "24px",
}}
>
<div
style={{
maxWidth: "768px",
margin: "0 auto",
width: "100%",
padding: "0 24px",
display: "flex",
flexDirection: "column",
gap: "24px",
}}
>
{wizardState &&
!wizardState.completed &&
messages.length === 0 &&
!loading && (
<SetupWizard
wizardState={wizardState}
onWizardUpdate={setWizardState}
sendMessage={sendMessage}
/>
)}
{needsOnboarding &&
!wizardState &&
messages.length === 0 &&
!loading && (
<div
data-testid="onboarding-welcome"
style={{
padding: "24px",
borderRadius: "12px",
background: "#1c2a1c",
border: "1px solid #2d4a2d",
marginBottom: "8px",
}}
>
<h3
style={{
margin: "0 0 8px 0",
color: "#a0d4a0",
fontSize: "1.1rem",
}}
>
Welcome to Huskies
</h3>
<p
style={{
margin: "0 0 16px 0",
color: "#ccc",
lineHeight: 1.5,
}}
>
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.
</p>
<button
type="button"
data-testid="onboarding-start-button"
onClick={() => {
if (onboardingTriggeredRef.current) return;
onboardingTriggeredRef.current = true;
setNeedsOnboarding(false);
sendMessage(
"I just created a new project. Help me set it up.",
);
}}
style={{
padding: "10px 20px",
borderRadius: "8px",
border: "none",
backgroundColor: "#a0d4a0",
color: "#1a1a1a",
cursor: "pointer",
fontSize: "0.95rem",
fontWeight: 600,
}}
>
Start Project Setup
</button>
</div>
)}
{messages.map((msg: Message, idx: number) => (
<MessageItem
// biome-ignore lint/suspicious/noArrayIndexKey: Message has no stable ID
key={`msg-${idx}-${msg.role}-${msg.content.substring(0, 20)}`}
msg={msg}
/>
))}
{loading && streamingThinking && (
<ThinkingBlock text={streamingThinking} />
)}
{loading && streamingContent && (
<div
style={{
display: "flex",
flexDirection: "column",
alignItems: "flex-start",
}}
>
<div
style={{
maxWidth: "100%",
padding: "0",
borderRadius: "0",
background: "transparent",
color: "#ececec",
border: "none",
fontFamily: "inherit",
fontSize: "1em",
fontWeight: "500",
whiteSpace: "normal",
lineHeight: "1.6",
}}
>
<div className="markdown-body">
<StreamingMessage content={streamingContent} />
</div>
</div>
</div>
)}
{loading &&
(activityStatus != null ||
(!streamingContent && !streamingThinking)) && (
<div
data-testid="activity-indicator"
style={{
alignSelf: "flex-start",
color: "#888",
fontSize: "0.9em",
marginTop: "10px",
}}
>
<span className="pulse">{activityStatus ?? "Thinking..."}</span>
</div>
)}
<div ref={messagesEndRef} />
</div>
</div>
);
}
@@ -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<string, number>;
agentRoster: AgentConfigInfo[];
busyAgentNames: Set<string>;
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 (
<div
data-testid="chat-right-column"
style={{
flex: "0 0 40%",
overflowY: "auto",
borderLeft: isNarrowScreen ? "none" : "1px solid #333",
borderTop: isNarrowScreen ? "1px solid #333" : "none",
padding: "12px",
display: "flex",
flexDirection: "column",
gap: "12px",
}}
>
<LozengeFlyProvider pipeline={pipeline}>
{selectedWorkItemId ? (
<WorkItemDetailPanel
storyId={selectedWorkItemId}
pipelineVersion={pipelineVersion}
onClose={onCloseWorkItem}
/>
) : (
<>
<AgentPanel
configVersion={agentConfigVersion}
stateVersion={agentStateVersion}
/>
<StagePanel
title="Done"
items={pipeline.done ?? []}
costs={storyTokenCosts}
onItemClick={(item) => onSelectWorkItem(item.story_id)}
onStopAgent={onStopAgent}
onDeleteItem={onDeleteItem}
/>
<StagePanel
title="To Merge"
items={pipeline.merge}
costs={storyTokenCosts}
onItemClick={(item) => onSelectWorkItem(item.story_id)}
onStopAgent={onStopAgent}
onDeleteItem={onDeleteItem}
/>
<StagePanel
title="QA"
items={pipeline.qa}
costs={storyTokenCosts}
onItemClick={(item) => onSelectWorkItem(item.story_id)}
onStopAgent={onStopAgent}
onDeleteItem={onDeleteItem}
/>
<StagePanel
title="Current"
items={pipeline.current}
costs={storyTokenCosts}
onItemClick={(item) => onSelectWorkItem(item.story_id)}
agentRoster={agentRoster}
busyAgentNames={busyAgentNames}
onStartAgent={onStartAgent}
onStopAgent={onStopAgent}
onDeleteItem={onDeleteItem}
/>
<StagePanel
title="Backlog"
items={pipeline.backlog}
costs={storyTokenCosts}
onItemClick={(item) => onSelectWorkItem(item.story_id)}
agentRoster={agentRoster}
busyAgentNames={busyAgentNames}
onStartAgent={onStartAgent}
onStopAgent={onStopAgent}
onDeleteItem={onDeleteItem}
/>
<ServerLogsPanel logs={serverLogs} />
</>
)}
</LozengeFlyProvider>
</div>
);
}
@@ -0,0 +1,144 @@
interface PermissionRequest {
requestId: string;
toolName: string;
toolInput: Record<string, unknown>;
}
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 (
<div
style={{
position: "fixed",
top: 0,
left: 0,
right: 0,
bottom: 0,
backgroundColor: "rgba(0, 0, 0, 0.7)",
display: "flex",
alignItems: "center",
justifyContent: "center",
zIndex: 1000,
}}
>
<div
style={{
backgroundColor: "#2f2f2f",
padding: "32px",
borderRadius: "12px",
maxWidth: "520px",
width: "90%",
border: "1px solid #444",
}}
>
<h2 style={{ marginTop: 0, color: "#ececec" }}>
Permission Request
{permissionQueue.length > 1 && (
<span
style={{
fontSize: "0.6em",
color: "#888",
marginLeft: "8px",
}}
>
(+{permissionQueue.length - 1} queued)
</span>
)}
</h2>
<p
style={{
color: "#aaa",
fontSize: "0.9em",
marginBottom: "12px",
}}
>
The agent wants to use the{" "}
<strong style={{ color: "#ececec" }}>{current.toolName}</strong> tool.
Do you approve?
</p>
{Object.keys(current.toolInput).length > 0 && (
<pre
style={{
background: "#1a1a1a",
border: "1px solid #333",
borderRadius: "6px",
padding: "12px",
fontSize: "0.8em",
color: "#ccc",
overflowX: "auto",
maxHeight: "200px",
marginBottom: "20px",
whiteSpace: "pre-wrap",
wordBreak: "break-word",
}}
>
{JSON.stringify(current.toolInput, null, 2)}
</pre>
)}
<div
style={{
display: "flex",
gap: "12px",
justifyContent: "flex-end",
}}
>
<button
type="button"
onClick={() => onResponse(false)}
style={{
padding: "10px 20px",
borderRadius: "8px",
border: "1px solid #555",
backgroundColor: "transparent",
color: "#aaa",
cursor: "pointer",
fontSize: "0.9em",
}}
>
Deny
</button>
<button
type="button"
onClick={() => onResponse(true)}
style={{
padding: "10px 20px",
borderRadius: "8px",
border: "none",
backgroundColor: "#ececec",
color: "#000",
cursor: "pointer",
fontSize: "0.9em",
}}
>
Approve
</button>
<button
type="button"
onClick={() => onResponse(true, true)}
style={{
padding: "10px 20px",
borderRadius: "8px",
border: "none",
backgroundColor: "#4caf50",
color: "#fff",
cursor: "pointer",
fontSize: "0.9em",
}}
>
Always Allow
</button>
</div>
</div>
</div>
);
}
@@ -0,0 +1,59 @@
interface ReconciliationEvent {
id: string;
storyId: string;
status: string;
message: string;
}
interface ReconciliationBannerProps {
reconciliationEvents: ReconciliationEvent[];
}
export function ReconciliationBanner({
reconciliationEvents,
}: ReconciliationBannerProps) {
return (
<div
data-testid="reconciliation-banner"
style={{
padding: "6px 24px",
background: "#1c2a1c",
borderTop: "1px solid #2d4a2d",
fontSize: "0.8em",
color: "#7ec87e",
maxHeight: "100px",
overflowY: "auto",
flexShrink: 0,
}}
>
<div
style={{
fontWeight: 600,
marginBottom: "2px",
color: "#a0d4a0",
}}
>
Reconciling startup state...
</div>
{reconciliationEvents.map((evt) => (
<div
key={evt.id}
style={{
color:
evt.status === "failed"
? "#d07070"
: evt.status === "advanced"
? "#80c880"
: "#666",
whiteSpace: "nowrap",
overflow: "hidden",
textOverflow: "ellipsis",
}}
>
{evt.storyId ? `[${evt.storyId}] ` : ""}
{evt.message}
</div>
))}
</div>
);
}
+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,
};
}
+50
View File
@@ -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<string, number>,
): 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;
};