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,71 +709,28 @@ 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",
|
display: "flex",
|
||||||
flexDirection: "column",
|
flexDirection: "column",
|
||||||
gap: "12px",
|
flex: "0 0 60%",
|
||||||
|
minHeight: 0,
|
||||||
|
overflow: "hidden",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<AgentPanel stories={upcomingStories} />
|
{/* Scrollable messages area */}
|
||||||
|
|
||||||
<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>
|
|
||||||
|
|
||||||
<div
|
<div
|
||||||
ref={scrollContainerRef}
|
ref={scrollContainerRef}
|
||||||
onScroll={handleScroll}
|
onScroll={handleScroll}
|
||||||
@@ -858,7 +827,9 @@ export function Chat({ projectPath, onCloseProject }: ChatProps) {
|
|||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
// biome-ignore lint/suspicious/noExplicitAny: react-markdown requires any for component props
|
// biome-ignore lint/suspicious/noExplicitAny: react-markdown requires any for component props
|
||||||
code: ({ className, children, ...props }: any) => {
|
code: ({ className, children, ...props }: any) => {
|
||||||
const match = /language-(\w+)/.exec(className || "");
|
const match = /language-(\w+)/.exec(
|
||||||
|
className || "",
|
||||||
|
);
|
||||||
const isInline = !className;
|
const isInline = !className;
|
||||||
return !isInline && match ? (
|
return !isInline && match ? (
|
||||||
<SyntaxHighlighter
|
<SyntaxHighlighter
|
||||||
@@ -1005,6 +976,7 @@ export function Chat({ projectPath, onCloseProject }: ChatProps) {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Chat input pinned at bottom of left column */}
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
padding: "24px",
|
padding: "24px",
|
||||||
@@ -1068,6 +1040,68 @@ export function Chat({ projectPath, onCloseProject }: ChatProps) {
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</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 && (
|
{showApiKeyDialog && (
|
||||||
<div
|
<div
|
||||||
|
|||||||
Reference in New Issue
Block a user