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 (
@@ -53,9 +58,7 @@ export function ChatInputDecoration(props: ChatInputDecorationProps) {
{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 && (
-
- Jump to bottom{" "}
-
- ({formatKeybind(KEYBINDS.JUMP_TO_BOTTOM)})
-
-
- )}
- {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 && (
+
+ Jump to bottom{" "}
+
+ ({formatKeybind(KEYBINDS.JUMP_TO_BOTTOM)})
+
+
+ )}
+ {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 (
+
+ );
+}
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;