diff --git a/src/browser/components/ChatPane/ChatInputDecoration.tsx b/src/browser/components/ChatPane/ChatInputDecoration.tsx index ac8dfe3fe4..b9e4a9c02d 100644 --- a/src/browser/components/ChatPane/ChatInputDecoration.tsx +++ b/src/browser/components/ChatPane/ChatInputDecoration.tsx @@ -1,6 +1,8 @@ import type { ReactNode } from "react"; import { ChevronDown, ChevronRight } from "lucide-react"; import { cn } from "@/common/lib/utils"; +import { CHAT_DOCK_GUTTER_CLASS } from "@/constants/layout"; +import { useChatDockColumnWidthClass } from "./chatDockColumn"; interface ChatInputDecorationProps { expanded: boolean; @@ -27,9 +29,11 @@ interface ChatInputDecorationProps { // drifting again as individual decorations evolve, while `renderExpanded` // keeps large hidden detail trees out of collapsed rerenders. export function ChatInputDecoration(props: ChatInputDecorationProps) { + const columnWidthClass = useChatDockColumnWidthClass(); + return (
{props.expanded && props.renderExpanded && ( -
- {props.renderExpanded()} -
+
{props.renderExpanded()}
)} ); diff --git a/src/browser/components/ChatPane/ChatPane.tsx b/src/browser/components/ChatPane/ChatPane.tsx index d658d5d92b..bd466497a5 100644 --- a/src/browser/components/ChatPane/ChatPane.tsx +++ b/src/browser/components/ChatPane/ChatPane.tsx @@ -86,6 +86,12 @@ import { useSendMessageOptions } from "@/browser/hooks/useSendMessageOptions"; import type { TerminalSessionCreateOptions } from "@/browser/utils/terminal"; import { useAPI } from "@/browser/contexts/API"; import { useChatTranscriptFullWidth } from "@/browser/hooks/useChatTranscriptFullWidth"; +import { CHAT_DOCK_GUTTER_CLASS } from "@/constants/layout"; +import { + ChatDockColumnProvider, + ChatDockSurface, + useChatDockColumnWidthClass, +} from "./chatDockColumn"; import { useTranscriptDensity } from "@/browser/hooks/useTranscriptDensity"; import { useReviews } from "@/browser/hooks/useReviews"; import { ReviewsBanner } from "../ReviewsBanner/ReviewsBanner"; @@ -1756,73 +1762,75 @@ const ChatPaneContent: React.FC = (props) => { of the scrollport never changes with dock height (send-flash invariant). `bg-surface-primary` keeps transcript content from showing through gaps between decoration banners. */} -
- {!autoScroll && ( - - )} - {transcriptOnly ? ( - // Transcript-only workspaces keep their historical transcript, but the whole - // composer surface is replaced with a single read-only notice. - - ) : ( - void handleEditQueuedMessage()} - onSendQueuedImmediately={ - workspaceState?.canInterrupt ? handleSendQueuedImmediately : undefined - } - reviews={reviews} - onCheckReviews={handleCheckReviews} - /> - )} -
+ +
+ {!autoScroll && ( + + )} + {transcriptOnly ? ( + // Transcript-only workspaces keep their historical transcript, but the whole + // composer surface is replaced with a single read-only notice. + + ) : ( + void handleEditQueuedMessage()} + onSendQueuedImmediately={ + workspaceState?.canInterrupt ? handleSendQueuedImmediately : undefined + } + reviews={reviews} + onCheckReviews={handleCheckReviews} + /> + )} +
+
{transcriptContextMenu.menu} @@ -1833,9 +1841,13 @@ const ChatPaneContent: React.FC = (props) => { }; const TranscriptOnlyNoticePane: React.FC = () => { + const columnWidthClass = useChatDockColumnWidthClass(); + return ( -
-
+
+

{TRANSCRIPT_ONLY_NOTICE}

@@ -1901,11 +1913,13 @@ const ChatInputPane: React.FC = (props) => { addDecorationEntry({ key: "compaction-warning", node: ( - + + + ), }); } @@ -1913,11 +1927,13 @@ const ChatInputPane: React.FC = (props) => { addDecorationEntry({ key: "context-switch-warning", node: ( - + + + ), }); } @@ -1975,11 +1991,13 @@ const ChatInputPane: React.FC = (props) => { addDecorationEntry({ key: "pre-stream-agent-task", node: ( -
- {props.preStreamAgentTaskStatus === "starting" - ? "This agent task is starting and will become editable after launch accepts the initial prompt." - : "This agent task is queued and will start automatically when a parallel slot is available."} -
+ +
+ {props.preStreamAgentTaskStatus === "starting" + ? "This agent task is starting and will become editable after launch accepts the initial prompt." + : "This agent task is queued and will start automatically when a parallel slot is available."} +
+
), }); } diff --git a/src/browser/components/ChatPane/chatDockColumn.tsx b/src/browser/components/ChatPane/chatDockColumn.tsx new file mode 100644 index 0000000000..f79b48926d --- /dev/null +++ b/src/browser/components/ChatPane/chatDockColumn.tsx @@ -0,0 +1,26 @@ +import { createContext, useContext, type ReactNode } from "react"; +import { CHAT_DOCK_GUTTER_CLASS } from "@/constants/layout"; + +// Read from context rather than useChatTranscriptFullWidth because that hook also syncs the backend +// config, and one fetch plus subscription per docked surface would be wasteful. +const ChatDockFullWidthContext = createContext(false); + +export const ChatDockColumnProvider = ChatDockFullWidthContext.Provider; + +/** Lines a docked surface up with the transcript column in whichever width mode is active. */ +export function useChatDockColumnWidthClass(): string { + return useContext(ChatDockFullWidthContext) ? "w-full" : "mx-auto w-full max-w-4xl"; +} + +/** Gutter plus column for docked surfaces that carry no padding of their own. */ +export function ChatDockSurface(props: { children: ReactNode }) { + const columnWidthClass = useChatDockColumnWidthClass(); + + return ( +
+
+ {props.children} +
+
+ ); +} diff --git a/src/browser/components/CompactionWarning/CompactionWarning.tsx b/src/browser/components/CompactionWarning/CompactionWarning.tsx index 94a8ee4e4c..a8ac6fa417 100644 --- a/src/browser/components/CompactionWarning/CompactionWarning.tsx +++ b/src/browser/components/CompactionWarning/CompactionWarning.tsx @@ -44,7 +44,7 @@ export const CompactionWarning: React.FC<{ return (
diff --git a/src/browser/components/ConcurrentLocalWarning/ConcurrentLocalWarning.tsx b/src/browser/components/ConcurrentLocalWarning/ConcurrentLocalWarning.tsx index 3182b7a963..f03497e15e 100644 --- a/src/browser/components/ConcurrentLocalWarning/ConcurrentLocalWarning.tsx +++ b/src/browser/components/ConcurrentLocalWarning/ConcurrentLocalWarning.tsx @@ -2,6 +2,8 @@ import React, { useMemo, useSyncExternalStore } from "react"; import { AlertTriangle } from "lucide-react"; import { useWorkspaceContext } from "@/browser/contexts/WorkspaceContext"; import { useWorkspaceStoreRaw } from "@/browser/stores/WorkspaceStore"; +import { CHAT_DOCK_GUTTER_CLASS } from "@/constants/layout"; +import { useChatDockColumnWidthClass } from "@/browser/components/ChatPane/chatDockColumn"; import { cn } from "@/common/lib/utils"; import { isLocalProjectRuntime } from "@/common/types/runtime"; import type { RuntimeConfig } from "@/common/types/runtime"; @@ -85,11 +87,13 @@ export const ConcurrentLocalWarningView: React.FC = ( props ) => { + const columnWidthClass = useChatDockColumnWidthClass(); + return ( -
+
); diff --git a/src/browser/components/ContextSwitchWarning/ContextSwitchWarning.tsx b/src/browser/components/ContextSwitchWarning/ContextSwitchWarning.tsx index 84e8bb4553..14d5cfc6f3 100644 --- a/src/browser/components/ContextSwitchWarning/ContextSwitchWarning.tsx +++ b/src/browser/components/ContextSwitchWarning/ContextSwitchWarning.tsx @@ -27,7 +27,7 @@ export const ContextSwitchWarning: React.FC = (props) => { return (
diff --git a/src/browser/components/ProjectPage/ProjectPage.tsx b/src/browser/components/ProjectPage/ProjectPage.tsx index 5100124161..c1105d7665 100644 --- a/src/browser/components/ProjectPage/ProjectPage.tsx +++ b/src/browser/components/ProjectPage/ProjectPage.tsx @@ -290,17 +290,6 @@ export const ProjectPage: React.FC = ({ {isNonGitRepo && ( )} - {/* Keep the heading outside the provider branch to prevent layout shifts during setup. */} -

- Let’s get building. -

{/* Show configure prompt when no providers, otherwise show ChatInput */} {!providersLoading && !hasProviders ? ( diff --git a/src/browser/features/ChatInput/ChatInput.stories.tsx b/src/browser/features/ChatInput/ChatInput.stories.tsx index 00fba7f6a0..09e5245c79 100644 --- a/src/browser/features/ChatInput/ChatInput.stories.tsx +++ b/src/browser/features/ChatInput/ChatInput.stories.tsx @@ -13,6 +13,9 @@ import { } from "@/browser/stories/storyPlayHelpers.js"; import { within, userEvent, waitFor } from "@storybook/test"; +// Tailwind's `max-w-4xl` in px, the cap the centered transcript and composer columns share. +const CENTERED_COLUMN_MAX_WIDTH_PX = 896; + const meta = { ...appMeta, title: "App/Chat/Input" }; export default meta; @@ -338,3 +341,129 @@ export const EditingMessage: AppStory = { }, }, }; + +const DOCK_ALIGNMENT_MESSAGES = [ + createUserMessage("msg-1", "Widen the transcript and check the composer edges", { + historySequence: 1, + }), + createAssistantMessage("msg-2", "The composer should span the same column.", { + historySequence: 2, + // 64% of the default model's 1M window: past the auto-compaction warning point (threshold 70 + // minus a 10 point advance) without reaching force-compaction. The warning is one of the + // surfaces wrapped in ChatDockSurface rather than in a decoration of its own. + contextUsage: { inputTokens: 640_000, outputTokens: 4_000 }, + }), +]; + +// Renders a ChatInputDecoration, so the assertions cover a decoration and not only the composer. +const DOCK_ALIGNMENT_BACKGROUND_PROCESS = { + id: "bash_1", + pid: 4242, + script: "npm run dev", + displayName: "Dev Server", + startTime: STABLE_TIMESTAMP, + status: "running" as const, +}; + +function setupDockAlignmentStory(fullWidth: boolean) { + collapseLeftSidebar(); + return setupSimpleChatStory({ + workspaceId: fullWidth ? "ws-dock-align-full" : "ws-dock-align-centered", + messages: DOCK_ALIGNMENT_MESSAGES, + backgroundProcesses: [DOCK_ALIGNMENT_BACKGROUND_PROCESS], + chatTranscriptFullWidth: fullWidth, + }); +} + +/** + * Asserts every surface in the composer dock shares the transcript column's left and right edges. + * The dock cancels the transcript scrollport's gutter to paint full-bleed, so each surface inside it + * has to re-apply that gutter and follow the transcript's width mode. + */ +async function assertDockSurfacesMatchTranscript( + storyRoot: HTMLElement, + expectation: "capped" | "full-width" +) { + const transcript = storyRoot.querySelector('[aria-label="Conversation transcript"]'); + if (!transcript) throw new Error("Transcript column not rendered"); + const scrollport = transcript.parentElement; + if (!scrollport) throw new Error("Transcript scrollport not rendered"); + + const surfaces: Array<[string, HTMLElement]> = []; + const addSurface = (label: string, selector: string) => { + const element = storyRoot.querySelector(selector); + if (!element) throw new Error(`Docked ${label} not rendered`); + surfaces.push([label, element]); + }; + addSurface("composer", '[data-component="ChatInputSurface"]'); + addSurface("decoration", '[data-component="ChatInputDecorationStack"] button'); + const wrapped = storyRoot.querySelectorAll('[data-component="ChatDockSurface"]'); + if (wrapped.length === 0) throw new Error("No ChatDockSurface-wrapped entry rendered"); + wrapped.forEach((element, index) => surfaces.push([`wrapped surface ${index}`, element])); + + await waitFor(() => { + const column = transcript.getBoundingClientRect(); + const available = scrollport.clientWidth; + if (expectation === "full-width") { + // Docked surfaces used to hardcode the same 4xl cap the centered column uses, so below that + // width they line up regardless and the regression would pass unnoticed. + if (column.width <= CENTERED_COLUMN_MAX_WIDTH_PX) { + throw new Error( + `Transcript is ${Math.round(column.width)}px wide, so this story is not in full-width mode` + ); + } + } else { + if (available <= CENTERED_COLUMN_MAX_WIDTH_PX) { + throw new Error( + `Scrollport is only ${available}px wide, so the centered cap is not what limits the column` + ); + } + if (Math.round(column.width) !== CENTERED_COLUMN_MAX_WIDTH_PX) { + throw new Error( + `Centered transcript should stop at the cap, measured ${Math.round(column.width)}px` + ); + } + } + + for (const [label, element] of surfaces) { + const bounds = element.getBoundingClientRect(); + const leftGap = Math.round(bounds.left - column.left); + const rightGap = Math.round(column.right - bounds.right); + if (leftGap !== 0 || rightGap !== 0) { + throw new Error( + `Docked ${label} is inset from the transcript column by ${leftGap}px left and ${rightGap}px right` + ); + } + } + }); +} + +export const FullWidthTranscriptAlignment: AppStory = { + render: () => setupDockAlignmentStory(true)} />, + parameters: { + ...appMeta.parameters, + pixel: PIXEL_DISABLED, + }, + play: async ({ canvasElement }) => { + const storyRoot = document.getElementById("storybook-root") ?? canvasElement; + await waitForChatInputAutofocusDone(storyRoot); + blurActiveElement(); + + await assertDockSurfacesMatchTranscript(storyRoot, "full-width"); + }, +}; + +export const CenteredTranscriptAlignment: AppStory = { + render: () => setupDockAlignmentStory(false)} />, + parameters: { + ...appMeta.parameters, + pixel: PIXEL_DISABLED, + }, + play: async ({ canvasElement }) => { + const storyRoot = document.getElementById("storybook-root") ?? canvasElement; + await waitForChatInputAutofocusDone(storyRoot); + blurActiveElement(); + + await assertDockSurfacesMatchTranscript(storyRoot, "capped"); + }, +}; diff --git a/src/browser/features/ChatInput/index.tsx b/src/browser/features/ChatInput/index.tsx index 3e40990448..bcf4bc035c 100644 --- a/src/browser/features/ChatInput/index.tsx +++ b/src/browser/features/ChatInput/index.tsx @@ -225,8 +225,10 @@ import { COMPOSER_ICON_ONLY_HIDE_CLASS, COMPOSER_PRO_HIDE_CLASS, COMPOSER_WORKSPACE_ICON_ONLY_HIDE_CLASS, + CHAT_DOCK_GUTTER_CLASS, CREATION_COLUMN_MAX_WIDTH_CLASS, } from "@/constants/layout"; +import { useChatDockColumnWidthClass } from "@/browser/components/ChatPane/chatDockColumn"; // localStorage quotas are environment-dependent and relatively small. // Be conservative here so we can warn the user before writes start failing. @@ -1262,6 +1264,8 @@ const ChatInputInner: React.FC = (props) => { } }, [agentAiDefaults, agentId, creationParentProjectPath, defaultModel, variant]); + const chatDockColumnWidthClass = useChatDockColumnWidthClass(); + // Expose ChatInput auto-focus completion for Storybook/tests. const chatInputSectionRef = useRef(null); const setChatInputAutoFocusState = useCallback((state: "pending" | "done") => { @@ -3234,12 +3238,12 @@ const ChatInputInner: React.FC = (props) => { variant === "creation" ? `w-full ${CREATION_COLUMN_MAX_WIDTH_CLASS}` : // WorkspaceFooterBar owns the bottom safe-area inset. - "bg-surface-primary px-4 pb-2" + `bg-surface-primary pb-2 ${CHAT_DOCK_GUTTER_CLASS}` )} data-component="ChatInputSection" data-autofocus-state="done" > -
+
{/* Toasts (overlay) */}
diff --git a/src/browser/stories/helpers/chatSetup.ts b/src/browser/stories/helpers/chatSetup.ts index 3dedc32c52..81c2ed8e97 100644 --- a/src/browser/stories/helpers/chatSetup.ts +++ b/src/browser/stories/helpers/chatSetup.ts @@ -88,6 +88,8 @@ export interface SimpleChatSetupOptions { }>; /** Mock clearLogs result */ clearLogsResult?: { success: boolean; error?: string | null }; + /** Full-width transcript preference returned by the mock config API. */ + chatTranscriptFullWidth?: boolean; } /** @@ -177,6 +179,7 @@ export function setupSimpleChatStory(opts: SimpleChatSetupOptions): APIClient { invalidAgentSkills: opts.invalidAgentSkills, logEntries: opts.logEntries, clearLogsResult: opts.clearLogsResult, + chatTranscriptFullWidth: opts.chatTranscriptFullWidth, }); } diff --git a/src/constants/layout.ts b/src/constants/layout.ts index 43aed5dbf0..e0a8acf259 100644 --- a/src/constants/layout.ts +++ b/src/constants/layout.ts @@ -7,6 +7,11 @@ export const LEFT_SIDEBAR_COLLAPSED_WIDTH_PX = 20; // floating reopen affordance so the header content doesn't sit underneath it. export const WORKSPACE_MENU_BAR_LEFT_SIDEBAR_COLLAPSED_PADDING_PX = 60; export const CREATION_COLUMN_MAX_WIDTH_CLASS = "max-w-[67rem]"; + +// The composer dock cancels the transcript scrollport's padding to paint full-bleed, so surfaces +// inside it re-apply this gutter to land on the same edges as transcript rows. Tailwind scans source +// text, so this has to stay a literal class string. +export const CHAT_DOCK_GUTTER_CLASS = "px-[15px]"; // Minimum height globals.css gives touch targets on coarse-pointer viewports. Shared so tests can // reproduce that environment, which Storybook and Pixel cannot: neither emulates `pointer: coarse`. export const MOBILE_TOUCH_TARGET_PX = 44;