From e7d4590997e3950bd01ee6479cb949006381fbff Mon Sep 17 00:00:00 2001 From: Dave Date: Fri, 20 Feb 2026 15:53:24 +0000 Subject: [PATCH] =?UTF-8?q?Story=2048:=20Two=20Column=20Layout=20=E2=80=94?= =?UTF-8?q?=20Chat=20Left,=20Panels=20Right?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.6 --- frontend/src/api/settings.ts | 5 +- frontend/src/components/Chat.test.tsx | 88 ++++ frontend/src/components/Chat.tsx | 660 ++++++++++++++------------ 3 files changed, 439 insertions(+), 314 deletions(-) diff --git a/frontend/src/api/settings.ts b/frontend/src/api/settings.ts index 81fcd24..baf241b 100644 --- a/frontend/src/api/settings.ts +++ b/frontend/src/api/settings.ts @@ -34,7 +34,10 @@ export const settingsApi = { return requestJson("/settings/editor", {}, baseUrl); }, - setEditorCommand(command: string | null, baseUrl?: string): Promise { + setEditorCommand( + command: string | null, + baseUrl?: string, + ): Promise { return requestJson( "/settings/editor", { diff --git a/frontend/src/components/Chat.test.tsx b/frontend/src/components/Chat.test.tsx index df43e32..0938ad2 100644 --- a/frontend/src/components/Chat.test.tsx +++ b/frontend/src/components/Chat.test.tsx @@ -766,7 +766,95 @@ describe("Chat message rendering — unified tool call UI", () => { capturedWsHandlers?.onUpdate(messages); }); +<<<<<<< HEAD expect(await screen.findByText("Bash(cargo test)")).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(); + + 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(); + + 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(); + + 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(); + + 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(); + + 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) + }); +}); diff --git a/frontend/src/components/Chat.tsx b/frontend/src/components/Chat.tsx index fffa847..3a80766 100644 --- a/frontend/src/components/Chat.tsx +++ b/frontend/src/components/Chat.tsx @@ -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(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 */}
+ {/* Left column: chat messages + input pinned at bottom */}
+ {/* Scrollable messages area */} +
+
+ {messages.map((msg: Message, idx: number) => ( +
+
+ {msg.role === "user" ? ( + msg.content + ) : msg.role === "tool" ? ( +
+ + + + Tool Output + {msg.tool_call_id && ` (${msg.tool_call_id})`} + + +
+													{msg.content}
+												
+
+ ) : ( +
+ { + const match = /language-(\w+)/.exec( + className || "", + ); + const isInline = !className; + return !isInline && match ? ( + + {String(children).replace(/\n$/, "")} + + ) : ( + + {children} + + ); + }, + }} + > + {msg.content} + +
+ )} + + {msg.tool_calls && ( +
+ {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 ( +
+ + + {tc.function.name} + {argsSummary && `(${argsSummary})`} + +
+ ); + })} +
+ )} +
+
+ ))} + {loading && streamingContent && ( +
+
+ { + const match = /language-(\w+)/.exec(className || ""); + const isInline = !className; + return !isInline && match ? ( + + {String(children).replace(/\n$/, "")} + + ) : ( + + {children} + + ); + }, + }} + > + {streamingContent} + +
+
+ )} + {loading && !streamingContent && ( +
+ Thinking... +
+ )} +
+
+
+ + {/* Chat input pinned at bottom of left column */} +
+
+ 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)", + }} + /> + +
+
+
+ + {/* Right column: panels independently scrollable */} +
-
-
- {messages.map((msg: Message, idx: number) => ( -
-
- {msg.role === "user" ? ( - msg.content - ) : msg.role === "tool" ? ( -
- - - - Tool Output - {msg.tool_call_id && ` (${msg.tool_call_id})`} - - -
-											{msg.content}
-										
-
- ) : ( -
- { - const match = /language-(\w+)/.exec(className || ""); - const isInline = !className; - return !isInline && match ? ( - - {String(children).replace(/\n$/, "")} - - ) : ( - - {children} - - ); - }, - }} - > - {msg.content} - -
- )} - - {msg.tool_calls && ( -
- {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 ( -
- - - {tc.function.name} - {argsSummary && `(${argsSummary})`} - -
- ); - })} -
- )} -
-
- ))} - {loading && streamingContent && ( -
-
- { - const match = /language-(\w+)/.exec(className || ""); - const isInline = !className; - return !isInline && match ? ( - - {String(children).replace(/\n$/, "")} - - ) : ( - - {children} - - ); - }, - }} - > - {streamingContent} - -
-
- )} - {loading && !streamingContent && ( -
- Thinking... -
- )} -
-
-
- -
-
- 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)", - }} - /> - -
-
- {showApiKeyDialog && (