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

@@ -34,7 +34,10 @@ export const settingsApi = {
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>(
"/settings/editor",
{

View File

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

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,71 +709,28 @@ 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",
gap: "12px",
flex: "0 0 60%",
minHeight: 0,
overflow: "hidden",
}}
>
<AgentPanel stories={upcomingStories} />
<ReviewPanel
reviewQueue={reviewQueue}
isReviewLoading={isReviewLoading}
reviewError={reviewError}
proceedingStoryId={proceedingStoryId}
storyId={storyId}
isGateLoading={isGateLoading}
proceedError={proceedError}
proceedSuccess={proceedSuccess}
lastReviewRefresh={lastReviewRefresh}
onRefresh={refreshReviewQueue}
onProceed={handleProceed}
/>
<GatePanel
gateState={gateState}
gateStatusLabel={gateStatusLabel}
gateStatusColor={gateStatusColor}
isGateLoading={isGateLoading}
gateError={gateError}
coverageError={coverageError}
lastGateRefresh={lastGateRefresh}
onRefresh={() => refreshGateState(storyId)}
onCollectCoverage={handleCollectCoverage}
isCollectingCoverage={isCollectingCoverage}
/>
<TodoPanel
todos={storyTodos}
isTodoLoading={isTodoLoading}
todoError={todoError}
lastTodoRefresh={lastTodoRefresh}
onRefresh={refreshTodos}
/>
<UpcomingPanel
stories={upcomingStories}
isLoading={isUpcomingLoading}
error={upcomingError}
lastRefresh={lastUpcomingRefresh}
onRefresh={refreshUpcomingStories}
/>
</div>
</div>
{/* Scrollable messages area */}
<div
ref={scrollContainerRef}
onScroll={handleScroll}
@@ -858,7 +827,9 @@ export function Chat({ projectPath, onCloseProject }: ChatProps) {
// 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 match = /language-(\w+)/.exec(
className || "",
);
const isInline = !className;
return !isInline && match ? (
<SyntaxHighlighter
@@ -1005,6 +976,7 @@ export function Chat({ projectPath, onCloseProject }: ChatProps) {
</div>
</div>
{/* Chat input pinned at bottom of left column */}
<div
style={{
padding: "24px",
@@ -1068,6 +1040,68 @@ export function Chat({ projectPath, onCloseProject }: ChatProps) {
</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",
}}
>
<AgentPanel stories={upcomingStories} />
<ReviewPanel
reviewQueue={reviewQueue}
isReviewLoading={isReviewLoading}
reviewError={reviewError}
proceedingStoryId={proceedingStoryId}
storyId={storyId}
isGateLoading={isGateLoading}
proceedError={proceedError}
proceedSuccess={proceedSuccess}
lastReviewRefresh={lastReviewRefresh}
onRefresh={refreshReviewQueue}
onProceed={handleProceed}
/>
<GatePanel
gateState={gateState}
gateStatusLabel={gateStatusLabel}
gateStatusColor={gateStatusColor}
isGateLoading={isGateLoading}
gateError={gateError}
coverageError={coverageError}
lastGateRefresh={lastGateRefresh}
onRefresh={() => refreshGateState(storyId)}
onCollectCoverage={handleCollectCoverage}
isCollectingCoverage={isCollectingCoverage}
/>
<TodoPanel
todos={storyTodos}
isTodoLoading={isTodoLoading}
todoError={todoError}
lastTodoRefresh={lastTodoRefresh}
onRefresh={refreshTodos}
/>
<UpcomingPanel
stories={upcomingStories}
isLoading={isUpcomingLoading}
error={upcomingError}
lastRefresh={lastUpcomingRefresh}
onRefresh={refreshUpcomingStories}
/>
</div>
</div>
{showApiKeyDialog && (
<div