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);
|
||||
},
|
||||
|
||||
setEditorCommand(command: string | null, baseUrl?: string): Promise<EditorSettings> {
|
||||
setEditorCommand(
|
||||
command: string | null,
|
||||
baseUrl?: string,
|
||||
): Promise<EditorSettings> {
|
||||
return requestJson<EditorSettings>(
|
||||
"/settings/editor",
|
||||
{
|
||||
|
||||
@@ -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)
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user