Files
storkit/frontend/src/components/Chat.tsx

1276 lines
33 KiB
TypeScript
Raw Normal View History

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 { PipelineState } from "../api/client";
import { api, ChatWebSocket } from "../api/client";
import { useChatHistory } from "../hooks/useChatHistory";
import type { Message, ProviderConfig } from "../types";
import { AgentPanel } from "./AgentPanel";
import { ChatHeader } from "./ChatHeader";
import type { ChatInputHandle } from "./ChatInput";
import { ChatInput } from "./ChatInput";
import { HelpOverlay } from "./HelpOverlay";
import { LozengeFlyProvider } from "./LozengeFlyContext";
import { MessageItem } from "./MessageItem";
import type { LogEntry } from "./ServerLogsPanel";
import { ServerLogsPanel } from "./ServerLogsPanel";
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<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. */
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>
);
}
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): number => {
if (modelName.startsWith("claude-")) return 200000;
if (modelName.includes("llama3")) return 8192;
if (modelName.includes("qwen2.5")) return 32768;
if (modelName.includes("deepseek")) return 16384;
return 8192;
};
interface ChatProps {
2026-02-16 20:34:03 +00:00
projectPath: string;
onCloseProject: () => void;
}
export function Chat({ projectPath, onCloseProject }: ChatProps) {
const { messages, setMessages, clearMessages } = useChatHistory(projectPath);
2026-02-16 20:34:03 +00:00
const [loading, setLoading] = useState(false);
const [model, setModel] = useState("claude-code-pty");
2026-02-16 20:34:03 +00:00
const [enableTools, setEnableTools] = useState(true);
const [availableModels, setAvailableModels] = useState<string[]>([]);
const [claudeModels, setClaudeModels] = useState<string[]>([]);
const [streamingContent, setStreamingContent] = useState("");
const [streamingThinking, setStreamingThinking] = useState("");
2026-02-16 20:34:03 +00:00
const [showApiKeyDialog, setShowApiKeyDialog] = useState(false);
const [apiKeyInput, setApiKeyInput] = useState("");
const [hasAnthropicKey, setHasAnthropicKey] = useState(false);
const [pipeline, setPipeline] = useState<PipelineState>({
backlog: [],
current: [],
qa: [],
merge: [],
done: [],
});
const [claudeSessionId, setClaudeSessionId] = useState<string | null>(() => {
try {
return (
localStorage.getItem(`storykit-claude-session-id:${projectPath}`) ??
null
);
} catch {
return null;
}
});
const [activityStatus, setActivityStatus] = useState<string | null>(null);
const [permissionQueue, setPermissionQueue] = useState<
{
requestId: string;
toolName: string;
toolInput: Record<string, unknown>;
}[]
>([]);
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 [needsOnboarding, setNeedsOnboarding] = useState(false);
const onboardingTriggeredRef = useRef(false);
const [selectedWorkItemId, setSelectedWorkItemId] = useState<string | null>(
null,
);
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<LogEntry[]>([]);
// 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);
2026-02-16 20:34:03 +00:00
const wsRef = useRef<ChatWebSocket | null>(null);
const chatInputRef = useRef<ChatInputHandle>(null);
2026-02-16 20:34:03 +00:00
const messagesEndRef = useRef<HTMLDivElement>(null);
const scrollContainerRef = useRef<HTMLDivElement>(null);
const shouldAutoScrollRef = useRef(true);
const lastScrollTopRef = useRef(0);
const userScrolledUpRef = useRef(false);
const pendingMessageRef = useRef<string>("");
const contextUsage = useMemo(() => {
2026-02-16 20:34:03 +00:00
let totalTokens = 0;
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);
const percentage = Math.round((totalTokens / contextWindow) * 100);
return {
used: totalTokens,
total: contextWindow,
percentage,
};
}, [messages, streamingContent, model]);
2026-02-16 20:34:03 +00:00
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]);
2026-02-16 20:34:03 +00:00
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) => {
if (models.length > 0) {
const sortedModels = models.sort((a, b) =>
a.toLowerCase().localeCompare(b.toLowerCase()),
);
setClaudeModels(sortedModels);
} else {
setClaudeModels([]);
}
});
2026-02-16 20:34:03 +00:00
})
.catch((err) => {
console.error(err);
setHasAnthropicKey(false);
setClaudeModels([]);
});
}, []);
useEffect(() => {
const ws = new ChatWebSocket();
wsRef.current = ws;
ws.connect({
onToken: (content) => {
setStreamingContent((prev: string) => prev + content);
},
onThinkingToken: (content) => {
setStreamingThinking((prev: string) => prev + content);
},
2026-02-16 20:34:03 +00:00
onUpdate: (history) => {
setMessages(history);
setStreamingContent("");
setStreamingThinking("");
2026-02-16 20:34:03 +00:00
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);
}
2026-02-16 20:34:03 +00:00
}
},
onSessionId: (sessionId) => {
setClaudeSessionId(sessionId);
},
2026-02-16 20:34:03 +00:00
onError: (message) => {
console.error("WebSocket error:", message);
setLoading(false);
setActivityStatus(null);
if (queuedMessagesRef.current.length > 0) {
const batch = queuedMessagesRef.current.map((item) => item.text);
queuedMessagesRef.current = [];
setQueuedMessages([]);
setPendingAutoSendBatch(batch);
}
2026-02-16 20:34:03 +00:00
},
onPipelineState: (state) => {
setPipeline(state);
setPipelineVersion((v) => v + 1);
},
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);
},
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 }]);
},
2026-02-16 20:34:03 +00:00
});
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;
2026-02-16 20:34:03 +00:00
}
}, []);
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;
2026-02-16 20:34:03 +00:00
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);
window.addEventListener("resize", handleResize);
return () => window.removeEventListener("resize", handleResize);
}, []);
2026-02-16 20:34:03 +00:00
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([]);
2026-02-16 20:34:03 +00:00
try {
wsRef.current?.cancel();
await api.cancelChat();
if (streamingContent) {
setMessages((prev: Message[]) => [
...prev,
{ role: "assistant", content: streamingContent },
]);
setStreamingContent("");
}
setStreamingThinking("");
2026-02-16 20:34:03 +00:00
setLoading(false);
setActivityStatus(null);
2026-02-16 20:34:03 +00:00
} catch (e) {
console.error("Failed to cancel chat:", e);
}
};
const sendMessage = async (messageText: string) => {
if (!messageText.trim()) return;
// /help — show available slash commands overlay
if (/^\/help\s*$/i.test(messageText)) {
setShowHelp(true);
return;
}
// /btw <question> — 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;
}
2026-02-16 20:34:03 +00:00
const isClaudeCode = model === "claude-code-pty";
if (!isClaudeCode && model.startsWith("claude-")) {
2026-02-16 20:34:03 +00:00
const hasKey = await api.getAnthropicApiKeyExists();
if (!hasKey) {
pendingMessageRef.current = messageText;
2026-02-16 20:34:03 +00:00
setShowApiKeyDialog(true);
return;
}
}
// Expand @file references: append file contents as context
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 };
2026-02-16 20:34:03 +00:00
const newHistory = [...messages, userMsg];
setMessages(newHistory);
setLoading(true);
setStreamingContent("");
setStreamingThinking("");
setActivityStatus(null);
2026-02-16 20:34:03 +00:00
try {
const provider = isClaudeCode
? "claude-code"
: model.startsWith("claude-")
? "anthropic"
: "ollama";
2026-02-16 20:34:03 +00:00
const config: ProviderConfig = {
provider,
2026-02-16 20:34:03 +00:00
model,
base_url: "http://localhost:11434",
enable_tools: enableTools,
...(isClaudeCode && claudeSessionId
? { session_id: claudeSessionId }
: {}),
2026-02-16 20:34:03 +00:00
};
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}` },
]);
}
2026-02-16 20:34:03 +00:00
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}`);
}
};
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));
};
2026-02-16 20:34:03 +00:00
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();
2026-02-16 20:34:03 +00:00
setStreamingContent("");
setStreamingThinking("");
2026-02-16 20:34:03 +00:00
setLoading(false);
setActivityStatus(null);
setClaudeSessionId(null);
try {
localStorage.removeItem(`storykit-claude-session-id:${projectPath}`);
} catch {
// Ignore — quota or security errors.
}
2026-02-16 20:34:03 +00:00
}
};
const handleRemoveQueuedMessage = useCallback((id: string) => {
queuedMessagesRef.current = queuedMessagesRef.current.filter(
(item) => item.id !== id,
);
setQueuedMessages([...queuedMessagesRef.current]);
}, []);
2026-02-16 20:34:03 +00:00
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}
/>
{/* Two-column content area */}
2026-02-16 20:34:03 +00:00
<div
data-testid="chat-content-area"
2026-02-16 20:34:03 +00:00
style={{
display: "flex",
flex: 1,
minHeight: 0,
flexDirection: isNarrowScreen ? "column" : "row",
2026-02-16 20:34:03 +00:00
}}
>
{/* Left column: chat messages + input pinned at bottom */}
2026-02-16 20:34:03 +00:00
<div
data-testid="chat-left-column"
2026-02-16 20:34:03 +00:00
style={{
display: "flex",
flexDirection: "column",
flex: "0 0 60%",
minHeight: 0,
overflow: "hidden",
2026-02-16 20:34:03 +00:00
}}
>
{/* Scrollable messages area */}
<div
ref={scrollContainerRef}
onScroll={handleScroll}
style={{
flex: 1,
overflowY: "auto",
padding: "20px 0",
display: "flex",
flexDirection: "column",
gap: "24px",
}}
>
2026-02-16 20:34:03 +00:00
<div
style={{
maxWidth: "768px",
margin: "0 auto",
width: "100%",
padding: "0 24px",
2026-02-16 20:34:03 +00:00
display: "flex",
flexDirection: "column",
gap: "24px",
2026-02-16 20:34:03 +00:00
}}
>
{needsOnboarding && 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 Story Kit
</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
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>
2026-02-16 20:34:03 +00:00
</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} />
2026-02-16 20:34:03 +00:00
</div>
</div>
{/* Startup reconciliation progress banner */}
{reconciliationActive && (
<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>
)}
{/* Chat input pinned at bottom of left column */}
<ChatInput
ref={chatInputRef}
loading={loading}
queuedMessages={queuedMessages}
onSubmit={sendMessage}
onCancel={cancelGeneration}
onRemoveQueuedMessage={handleRemoveQueuedMessage}
/>
2026-02-16 20:34:03 +00:00
</div>
{/* Right column: panels independently scrollable */}
2026-02-16 20:34:03 +00:00
<div
data-testid="chat-right-column"
2026-02-16 20:34:03 +00:00
style={{
flex: "0 0 40%",
overflowY: "auto",
borderLeft: isNarrowScreen ? "none" : "1px solid #333",
borderTop: isNarrowScreen ? "1px solid #333" : "none",
padding: "12px",
2026-02-16 20:34:03 +00:00
display: "flex",
flexDirection: "column",
gap: "12px",
2026-02-16 20:34:03 +00:00
}}
>
<LozengeFlyProvider pipeline={pipeline}>
{selectedWorkItemId ? (
<WorkItemDetailPanel
storyId={selectedWorkItemId}
pipelineVersion={pipelineVersion}
onClose={() => setSelectedWorkItemId(null)}
/>
) : (
<>
<AgentPanel
configVersion={agentConfigVersion}
stateVersion={agentStateVersion}
/>
<StagePanel
title="Done"
items={pipeline.done ?? []}
onItemClick={(item) => setSelectedWorkItemId(item.story_id)}
/>
<StagePanel
title="To Merge"
items={pipeline.merge}
onItemClick={(item) => setSelectedWorkItemId(item.story_id)}
/>
<StagePanel
title="QA"
items={pipeline.qa}
onItemClick={(item) => setSelectedWorkItemId(item.story_id)}
/>
<StagePanel
title="Current"
items={pipeline.current}
onItemClick={(item) => setSelectedWorkItemId(item.story_id)}
/>
<StagePanel
title="Backlog"
items={pipeline.backlog}
onItemClick={(item) => setSelectedWorkItemId(item.story_id)}
/>
<ServerLogsPanel logs={serverLogs} />
</>
)}
</LozengeFlyProvider>
2026-02-16 20:34:03 +00:00
</div>
</div>
{showApiKeyDialog && (
<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) => 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",
}}
/>
<div
style={{
display: "flex",
gap: "12px",
justifyContent: "flex-end",
}}
>
<button
type="button"
onClick={() => {
setShowApiKeyDialog(false);
setApiKeyInput("");
pendingMessageRef.current = "";
}}
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={handleSaveApiKey}
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>
)}
{permissionQueue.length > 0 && (
<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" }}>
{permissionQueue[0].toolName}
</strong>{" "}
tool. Do you approve?
</p>
{Object.keys(permissionQueue[0].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(permissionQueue[0].toolInput, null, 2)}
</pre>
)}
<div
style={{
display: "flex",
gap: "12px",
justifyContent: "flex-end",
}}
>
<button
type="button"
onClick={() => handlePermissionResponse(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={() => handlePermissionResponse(true)}
style={{
padding: "10px 20px",
borderRadius: "8px",
border: "none",
backgroundColor: "#ececec",
color: "#000",
cursor: "pointer",
fontSize: "0.9em",
}}
>
Approve
</button>
<button
type="button"
onClick={() => handlePermissionResponse(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>
)}
{showHelp && <HelpOverlay onDismiss={() => setShowHelp(false)} />}
{sideQuestion && (
<SideQuestionOverlay
question={sideQuestion.question}
response={sideQuestion.response}
loading={sideQuestion.loading}
onDismiss={() => setSideQuestion(null)}
/>
)}
2026-02-16 20:34:03 +00:00
</div>
);
}