Story 48: Two Column Layout — Chat Left, Panels Right

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Dave
2026-02-20 15:53:24 +00:00
parent 6f6f9983a7
commit e7d4590997
3 changed files with 439 additions and 314 deletions

View File

@@ -15,6 +15,8 @@ import { UpcomingPanel } from "./UpcomingPanel";
const { useCallback, useEffect, useRef, useState } = React;
const NARROW_BREAKPOINT = 900;
interface ChatProps {
projectPath: string;
onCloseProject: () => void;
@@ -82,6 +84,9 @@ export function Chat({ projectPath, onCloseProject }: ChatProps) {
null,
);
const [claudeSessionId, setClaudeSessionId] = useState<string | null>(null);
const [isNarrowScreen, setIsNarrowScreen] = useState(
window.innerWidth < NARROW_BREAKPOINT,
);
const storyId = "26_establish_tdd_workflow_and_gates";
const gateStatusColor = isGateLoading
@@ -556,6 +561,13 @@ export function Chat({ projectPath, onCloseProject }: ChatProps) {
inputRef.current?.focus();
}, []);
useEffect(() => {
const handleResize = () =>
setIsNarrowScreen(window.innerWidth < NARROW_BREAKPOINT);
window.addEventListener("resize", handleResize);
return () => window.removeEventListener("resize", handleResize);
}, []);
const cancelGeneration = async () => {
try {
wsRef.current?.cancel();
@@ -697,19 +709,348 @@ export function Chat({ projectPath, onCloseProject }: ChatProps) {
onToggleTools={setEnableTools}
/>
{/* Two-column content area */}
<div
data-testid="chat-content-area"
style={{
maxWidth: "768px",
margin: "0 auto",
width: "100%",
padding: "12px 24px 0",
flexShrink: 1,
overflowY: "auto",
display: "flex",
flex: 1,
minHeight: 0,
flexDirection: isNarrowScreen ? "column" : "row",
}}
>
{/* Left column: chat messages + input pinned at bottom */}
<div
data-testid="chat-left-column"
style={{
display: "flex",
flexDirection: "column",
flex: "0 0 60%",
minHeight: 0,
overflow: "hidden",
}}
>
{/* Scrollable messages area */}
<div
ref={scrollContainerRef}
onScroll={handleScroll}
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",
}}
>
{messages.map((msg: Message, idx: number) => (
<div
key={`msg-${idx}-${msg.role}-${msg.content.substring(0, 20)}`}
style={{
display: "flex",
flexDirection: "column",
alignItems: msg.role === "user" ? "flex-end" : "flex-start",
}}
>
<div
style={{
maxWidth: "100%",
padding: msg.role === "user" ? "10px 16px" : "0",
borderRadius: msg.role === "user" ? "20px" : "0",
background:
msg.role === "user"
? "#2f2f2f"
: msg.role === "tool"
? "#222"
: "transparent",
color: "#ececec",
border: msg.role === "tool" ? "1px solid #333" : "none",
fontFamily: msg.role === "tool" ? "monospace" : "inherit",
fontSize: msg.role === "tool" ? "0.85em" : "1em",
fontWeight: "500",
whiteSpace: msg.role === "tool" ? "pre-wrap" : "normal",
lineHeight: "1.6",
}}
>
{msg.role === "user" ? (
msg.content
) : msg.role === "tool" ? (
<details style={{ cursor: "pointer" }}>
<summary
style={{
color: "#aaa",
fontSize: "0.9em",
marginBottom: "8px",
listStyle: "none",
display: "flex",
alignItems: "center",
gap: "6px",
}}
>
<span style={{ fontSize: "0.8em" }}></span>
<span>
Tool Output
{msg.tool_call_id && ` (${msg.tool_call_id})`}
</span>
</summary>
<pre
style={{
maxHeight: "300px",
overflow: "auto",
margin: 0,
padding: "8px",
background: "#1a1a1a",
borderRadius: "4px",
fontSize: "0.85em",
whiteSpace: "pre-wrap",
wordBreak: "break-word",
}}
>
{msg.content}
</pre>
</details>
) : (
<div className="markdown-body">
<Markdown
components={{
// eslint-disable-next-line @typescript-eslint/no-explicit-any
// 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>
);
},
}}
>
{msg.content}
</Markdown>
</div>
)}
{msg.tool_calls && (
<div
style={{
marginTop: "12px",
fontSize: "0.85em",
color: "#aaa",
display: "flex",
flexDirection: "column",
gap: "8px",
}}
>
{msg.tool_calls.map((tc: ToolCall, i: number) => {
let argsSummary = "";
try {
const args = JSON.parse(tc.function.arguments);
const firstKey = Object.keys(args)[0];
if (firstKey && args[firstKey]) {
argsSummary = String(args[firstKey]);
if (argsSummary.length > 50) {
argsSummary = `${argsSummary.substring(0, 47)}...`;
}
}
} catch (_e) {
// ignore
}
return (
<div
key={`tool-${i}-${tc.function.name}`}
style={{
display: "flex",
alignItems: "center",
gap: "8px",
fontFamily: "monospace",
}}
>
<span style={{ color: "#888" }}></span>
<span
style={{
background: "#333",
padding: "2px 6px",
borderRadius: "4px",
}}
>
{tc.function.name}
{argsSummary && `(${argsSummary})`}
</span>
</div>
);
})}
</div>
)}
</div>
</div>
))}
{loading && streamingContent && (
<div
style={{
display: "flex",
flexDirection: "column",
alignItems: "flex-start",
}}
>
<div
style={{
maxWidth: "85%",
padding: "16px 20px",
borderRadius: "12px",
background: "#262626",
color: "#fff",
border: "1px solid #404040",
fontFamily: "system-ui, -apple-system, sans-serif",
fontSize: "0.95rem",
fontWeight: 400,
whiteSpace: "pre-wrap",
lineHeight: 1.6,
}}
>
<Markdown
components={{
// eslint-disable-next-line @typescript-eslint/no-explicit-any
// 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>
);
},
}}
>
{streamingContent}
</Markdown>
</div>
</div>
)}
{loading && !streamingContent && (
<div
style={{
alignSelf: "flex-start",
color: "#888",
fontSize: "0.9em",
marginTop: "10px",
}}
>
<span className="pulse">Thinking...</span>
</div>
)}
<div ref={messagesEndRef} />
</div>
</div>
{/* Chat input pinned at bottom of left column */}
<div
style={{
padding: "24px",
background: "#171717",
display: "flex",
justifyContent: "center",
}}
>
<div
style={{
maxWidth: "768px",
width: "100%",
display: "flex",
gap: "8px",
alignItems: "center",
}}
>
<input
ref={inputRef}
value={input}
onChange={(e) => setInput(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") {
sendMessage();
}
}}
placeholder="Send a message..."
style={{
flex: 1,
padding: "14px 20px",
borderRadius: "24px",
border: "1px solid #333",
outline: "none",
fontSize: "1rem",
fontWeight: "500",
background: "#2f2f2f",
color: "#ececec",
boxShadow: "0 2px 6px rgba(0,0,0,0.02)",
}}
/>
<button
type="button"
onClick={loading ? cancelGeneration : () => sendMessage()}
disabled={!loading && !input.trim()}
style={{
background: "#ececec",
color: "black",
border: "none",
borderRadius: "50%",
width: "32px",
height: "32px",
display: "flex",
alignItems: "center",
justifyContent: "center",
cursor: "pointer",
opacity: !loading && !input.trim() ? 0.5 : 1,
flexShrink: 0,
}}
>
{loading ? "■" : "↑"}
</button>
</div>
</div>
</div>
{/* Right column: panels independently scrollable */}
<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",
@@ -762,313 +1103,6 @@ export function Chat({ projectPath, onCloseProject }: ChatProps) {
</div>
</div>
<div
ref={scrollContainerRef}
onScroll={handleScroll}
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",
}}
>
{messages.map((msg: Message, idx: number) => (
<div
key={`msg-${idx}-${msg.role}-${msg.content.substring(0, 20)}`}
style={{
display: "flex",
flexDirection: "column",
alignItems: msg.role === "user" ? "flex-end" : "flex-start",
}}
>
<div
style={{
maxWidth: "100%",
padding: msg.role === "user" ? "10px 16px" : "0",
borderRadius: msg.role === "user" ? "20px" : "0",
background:
msg.role === "user"
? "#2f2f2f"
: msg.role === "tool"
? "#222"
: "transparent",
color: "#ececec",
border: msg.role === "tool" ? "1px solid #333" : "none",
fontFamily: msg.role === "tool" ? "monospace" : "inherit",
fontSize: msg.role === "tool" ? "0.85em" : "1em",
fontWeight: "500",
whiteSpace: msg.role === "tool" ? "pre-wrap" : "normal",
lineHeight: "1.6",
}}
>
{msg.role === "user" ? (
msg.content
) : msg.role === "tool" ? (
<details style={{ cursor: "pointer" }}>
<summary
style={{
color: "#aaa",
fontSize: "0.9em",
marginBottom: "8px",
listStyle: "none",
display: "flex",
alignItems: "center",
gap: "6px",
}}
>
<span style={{ fontSize: "0.8em" }}></span>
<span>
Tool Output
{msg.tool_call_id && ` (${msg.tool_call_id})`}
</span>
</summary>
<pre
style={{
maxHeight: "300px",
overflow: "auto",
margin: 0,
padding: "8px",
background: "#1a1a1a",
borderRadius: "4px",
fontSize: "0.85em",
whiteSpace: "pre-wrap",
wordBreak: "break-word",
}}
>
{msg.content}
</pre>
</details>
) : (
<div className="markdown-body">
<Markdown
components={{
// eslint-disable-next-line @typescript-eslint/no-explicit-any
// 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>
);
},
}}
>
{msg.content}
</Markdown>
</div>
)}
{msg.tool_calls && (
<div
style={{
marginTop: "12px",
fontSize: "0.85em",
color: "#aaa",
display: "flex",
flexDirection: "column",
gap: "8px",
}}
>
{msg.tool_calls.map((tc: ToolCall, i: number) => {
let argsSummary = "";
try {
const args = JSON.parse(tc.function.arguments);
const firstKey = Object.keys(args)[0];
if (firstKey && args[firstKey]) {
argsSummary = String(args[firstKey]);
if (argsSummary.length > 50) {
argsSummary = `${argsSummary.substring(0, 47)}...`;
}
}
} catch (_e) {
// ignore
}
return (
<div
key={`tool-${i}-${tc.function.name}`}
style={{
display: "flex",
alignItems: "center",
gap: "8px",
fontFamily: "monospace",
}}
>
<span style={{ color: "#888" }}></span>
<span
style={{
background: "#333",
padding: "2px 6px",
borderRadius: "4px",
}}
>
{tc.function.name}
{argsSummary && `(${argsSummary})`}
</span>
</div>
);
})}
</div>
)}
</div>
</div>
))}
{loading && streamingContent && (
<div
style={{
display: "flex",
flexDirection: "column",
alignItems: "flex-start",
}}
>
<div
style={{
maxWidth: "85%",
padding: "16px 20px",
borderRadius: "12px",
background: "#262626",
color: "#fff",
border: "1px solid #404040",
fontFamily: "system-ui, -apple-system, sans-serif",
fontSize: "0.95rem",
fontWeight: 400,
whiteSpace: "pre-wrap",
lineHeight: 1.6,
}}
>
<Markdown
components={{
// eslint-disable-next-line @typescript-eslint/no-explicit-any
// 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>
);
},
}}
>
{streamingContent}
</Markdown>
</div>
</div>
)}
{loading && !streamingContent && (
<div
style={{
alignSelf: "flex-start",
color: "#888",
fontSize: "0.9em",
marginTop: "10px",
}}
>
<span className="pulse">Thinking...</span>
</div>
)}
<div ref={messagesEndRef} />
</div>
</div>
<div
style={{
padding: "24px",
background: "#171717",
display: "flex",
justifyContent: "center",
}}
>
<div
style={{
maxWidth: "768px",
width: "100%",
display: "flex",
gap: "8px",
alignItems: "center",
}}
>
<input
ref={inputRef}
value={input}
onChange={(e) => setInput(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") {
sendMessage();
}
}}
placeholder="Send a message..."
style={{
flex: 1,
padding: "14px 20px",
borderRadius: "24px",
border: "1px solid #333",
outline: "none",
fontSize: "1rem",
fontWeight: "500",
background: "#2f2f2f",
color: "#ececec",
boxShadow: "0 2px 6px rgba(0,0,0,0.02)",
}}
/>
<button
type="button"
onClick={loading ? cancelGeneration : () => sendMessage()}
disabled={!loading && !input.trim()}
style={{
background: "#ececec",
color: "black",
border: "none",
borderRadius: "50%",
width: "32px",
height: "32px",
display: "flex",
alignItems: "center",
justifyContent: "center",
cursor: "pointer",
opacity: !loading && !input.trim() ? 0.5 : 1,
flexShrink: 0,
}}
>
{loading ? "■" : "↑"}
</button>
</div>
</div>
{showApiKeyDialog && (
<div
style={{