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>
);
}