Story 48: Two Column Layout — Chat Left, Panels Right
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -34,7 +34,10 @@ export const settingsApi = {
|
|||||||
return requestJson<EditorSettings>("/settings/editor", {}, baseUrl);
|
return requestJson<EditorSettings>("/settings/editor", {}, baseUrl);
|
||||||
},
|
},
|
||||||
|
|
||||||
setEditorCommand(command: string | null, baseUrl?: string): Promise<EditorSettings> {
|
setEditorCommand(
|
||||||
|
command: string | null,
|
||||||
|
baseUrl?: string,
|
||||||
|
): Promise<EditorSettings> {
|
||||||
return requestJson<EditorSettings>(
|
return requestJson<EditorSettings>(
|
||||||
"/settings/editor",
|
"/settings/editor",
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -766,7 +766,95 @@ describe("Chat message rendering — unified tool call UI", () => {
|
|||||||
capturedWsHandlers?.onUpdate(messages);
|
capturedWsHandlers?.onUpdate(messages);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
<<<<<<< HEAD
|
||||||
expect(await screen.findByText("Bash(cargo test)")).toBeInTheDocument();
|
expect(await screen.findByText("Bash(cargo test)")).toBeInTheDocument();
|
||||||
expect(await screen.findByText("Read(Cargo.toml)")).toBeInTheDocument();
|
expect(await screen.findByText("Read(Cargo.toml)")).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("Chat two-column layout", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
capturedWsHandlers = null;
|
||||||
|
|
||||||
|
mockedApi.getOllamaModels.mockResolvedValue(["llama3.1"]);
|
||||||
|
mockedApi.getAnthropicApiKeyExists.mockResolvedValue(true);
|
||||||
|
mockedApi.getAnthropicModels.mockResolvedValue([]);
|
||||||
|
mockedApi.getModelPreference.mockResolvedValue(null);
|
||||||
|
mockedApi.setModelPreference.mockResolvedValue(true);
|
||||||
|
mockedApi.cancelChat.mockResolvedValue(true);
|
||||||
|
|
||||||
|
mockedWorkflow.getAcceptance.mockResolvedValue({
|
||||||
|
can_accept: true,
|
||||||
|
reasons: [],
|
||||||
|
warning: null,
|
||||||
|
summary: { total: 0, passed: 0, failed: 0 },
|
||||||
|
missing_categories: [],
|
||||||
|
});
|
||||||
|
mockedWorkflow.getReviewQueueAll.mockResolvedValue({ stories: [] });
|
||||||
|
mockedWorkflow.ensureAcceptance.mockResolvedValue(true);
|
||||||
|
mockedWorkflow.getStoryTodos.mockResolvedValue({ stories: [] });
|
||||||
|
mockedWorkflow.getUpcomingStories.mockResolvedValue({ stories: [] });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders left and right column containers (AC1, AC2)", async () => {
|
||||||
|
render(<Chat projectPath="/tmp/project" onCloseProject={vi.fn()} />);
|
||||||
|
|
||||||
|
expect(await screen.findByTestId("chat-content-area")).toBeInTheDocument();
|
||||||
|
expect(await screen.findByTestId("chat-left-column")).toBeInTheDocument();
|
||||||
|
expect(await screen.findByTestId("chat-right-column")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders chat input inside the left column (AC2, AC5)", async () => {
|
||||||
|
render(<Chat projectPath="/tmp/project" onCloseProject={vi.fn()} />);
|
||||||
|
|
||||||
|
const leftColumn = await screen.findByTestId("chat-left-column");
|
||||||
|
const input = screen.getByPlaceholderText("Send a message...");
|
||||||
|
expect(leftColumn).toContainElement(input);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders panels inside the right column (AC2)", async () => {
|
||||||
|
mockedWorkflow.getReviewQueueAll.mockResolvedValue({ stories: [] });
|
||||||
|
|
||||||
|
render(<Chat projectPath="/tmp/project" onCloseProject={vi.fn()} />);
|
||||||
|
|
||||||
|
const rightColumn = await screen.findByTestId("chat-right-column");
|
||||||
|
const reviewPanel = await screen.findByText("Stories Awaiting Review");
|
||||||
|
expect(rightColumn).toContainElement(reviewPanel);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("uses row flex-direction on wide screens (AC3)", async () => {
|
||||||
|
Object.defineProperty(window, "innerWidth", {
|
||||||
|
writable: true,
|
||||||
|
configurable: true,
|
||||||
|
value: 1200,
|
||||||
|
});
|
||||||
|
window.dispatchEvent(new Event("resize"));
|
||||||
|
|
||||||
|
render(<Chat projectPath="/tmp/project" onCloseProject={vi.fn()} />);
|
||||||
|
|
||||||
|
const contentArea = await screen.findByTestId("chat-content-area");
|
||||||
|
expect(contentArea).toHaveStyle({ flexDirection: "row" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("uses column flex-direction on narrow screens (AC4)", async () => {
|
||||||
|
Object.defineProperty(window, "innerWidth", {
|
||||||
|
writable: true,
|
||||||
|
configurable: true,
|
||||||
|
value: 600,
|
||||||
|
});
|
||||||
|
window.dispatchEvent(new Event("resize"));
|
||||||
|
|
||||||
|
render(<Chat projectPath="/tmp/project" onCloseProject={vi.fn()} />);
|
||||||
|
|
||||||
|
const contentArea = await screen.findByTestId("chat-content-area");
|
||||||
|
expect(contentArea).toHaveStyle({ flexDirection: "column" });
|
||||||
|
|
||||||
|
// Restore wide width for subsequent tests
|
||||||
|
Object.defineProperty(window, "innerWidth", {
|
||||||
|
writable: true,
|
||||||
|
configurable: true,
|
||||||
|
value: 1024,
|
||||||
|
});
|
||||||
|
>>>>>>> 222b581 (Story 48: Two Column Layout — Chat Left, Panels Right)
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -15,6 +15,8 @@ import { UpcomingPanel } from "./UpcomingPanel";
|
|||||||
|
|
||||||
const { useCallback, useEffect, useRef, useState } = React;
|
const { useCallback, useEffect, useRef, useState } = React;
|
||||||
|
|
||||||
|
const NARROW_BREAKPOINT = 900;
|
||||||
|
|
||||||
interface ChatProps {
|
interface ChatProps {
|
||||||
projectPath: string;
|
projectPath: string;
|
||||||
onCloseProject: () => void;
|
onCloseProject: () => void;
|
||||||
@@ -82,6 +84,9 @@ export function Chat({ projectPath, onCloseProject }: ChatProps) {
|
|||||||
null,
|
null,
|
||||||
);
|
);
|
||||||
const [claudeSessionId, setClaudeSessionId] = useState<string | null>(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 storyId = "26_establish_tdd_workflow_and_gates";
|
||||||
const gateStatusColor = isGateLoading
|
const gateStatusColor = isGateLoading
|
||||||
@@ -556,6 +561,13 @@ export function Chat({ projectPath, onCloseProject }: ChatProps) {
|
|||||||
inputRef.current?.focus();
|
inputRef.current?.focus();
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const handleResize = () =>
|
||||||
|
setIsNarrowScreen(window.innerWidth < NARROW_BREAKPOINT);
|
||||||
|
window.addEventListener("resize", handleResize);
|
||||||
|
return () => window.removeEventListener("resize", handleResize);
|
||||||
|
}, []);
|
||||||
|
|
||||||
const cancelGeneration = async () => {
|
const cancelGeneration = async () => {
|
||||||
try {
|
try {
|
||||||
wsRef.current?.cancel();
|
wsRef.current?.cancel();
|
||||||
@@ -697,19 +709,348 @@ export function Chat({ projectPath, onCloseProject }: ChatProps) {
|
|||||||
onToggleTools={setEnableTools}
|
onToggleTools={setEnableTools}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
{/* Two-column content area */}
|
||||||
<div
|
<div
|
||||||
|
data-testid="chat-content-area"
|
||||||
style={{
|
style={{
|
||||||
maxWidth: "768px",
|
display: "flex",
|
||||||
margin: "0 auto",
|
flex: 1,
|
||||||
width: "100%",
|
|
||||||
padding: "12px 24px 0",
|
|
||||||
flexShrink: 1,
|
|
||||||
overflowY: "auto",
|
|
||||||
minHeight: 0,
|
minHeight: 0,
|
||||||
|
flexDirection: isNarrowScreen ? "column" : "row",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
|
{/* Left column: chat messages + input pinned at bottom */}
|
||||||
<div
|
<div
|
||||||
|
data-testid="chat-left-column"
|
||||||
style={{
|
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",
|
display: "flex",
|
||||||
flexDirection: "column",
|
flexDirection: "column",
|
||||||
gap: "12px",
|
gap: "12px",
|
||||||
@@ -762,313 +1103,6 @@ export function Chat({ projectPath, onCloseProject }: ChatProps) {
|
|||||||
</div>
|
</div>
|
||||||
</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 && (
|
{showApiKeyDialog && (
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
|
|||||||
Reference in New Issue
Block a user