From 95f36ddd3fc4451673b5659861a4c824a2702263 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Mon, 9 Mar 2026 10:01:33 -0700 Subject: [PATCH 1/7] Fix plan composer layout and sidebar flex shrink behavior - add `min-w-0` to chat/flex containers to prevent composer/sidebar overflow - move `BranchToolbar` into the chat column so layout stays aligned with plan sidebar - extract sidebar inset base classes to shared logic and add a regression test for `min-w-0` --- apps/web/src/components/ChatView.tsx | 20 ++++++++++---------- apps/web/src/components/ui/sidebar.logic.ts | 2 ++ apps/web/src/components/ui/sidebar.test.tsx | 9 +++++++++ apps/web/src/components/ui/sidebar.tsx | 3 ++- 4 files changed, 23 insertions(+), 11 deletions(-) create mode 100644 apps/web/src/components/ui/sidebar.logic.ts create mode 100644 apps/web/src/components/ui/sidebar.test.tsx diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 3362622b7e..52085daa45 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -3468,7 +3468,7 @@ export default function ChatView({ threadId }: ChatViewProps) { onDismiss={() => setThreadError(activeThread.id, null)} /> {/* Main content area with optional plan sidebar */} -
+
{/* Chat column */}
@@ -3953,6 +3953,15 @@ export default function ChatView({ threadId }: ChatViewProps) {
+ {isGitRepo && ( + + )} +
{/* end chat column */} {/* Plan sidebar */} @@ -3974,15 +3983,6 @@ export default function ChatView({ threadId }: ChatViewProps) { ) : null}
{/* end horizontal flex container */} - {isGitRepo && ( - - )} - {(() => { if (!terminalState.terminalOpen || !activeProject) { return null; diff --git a/apps/web/src/components/ui/sidebar.logic.ts b/apps/web/src/components/ui/sidebar.logic.ts new file mode 100644 index 0000000000..c9e4b4c1d8 --- /dev/null +++ b/apps/web/src/components/ui/sidebar.logic.ts @@ -0,0 +1,2 @@ +export const SIDEBAR_INSET_BASE_CLASSNAME = + "relative flex min-w-0 w-full flex-1 flex-col bg-background"; diff --git a/apps/web/src/components/ui/sidebar.test.tsx b/apps/web/src/components/ui/sidebar.test.tsx new file mode 100644 index 0000000000..99bf2d0179 --- /dev/null +++ b/apps/web/src/components/ui/sidebar.test.tsx @@ -0,0 +1,9 @@ +import { describe, expect, it } from "vitest"; + +import { SIDEBAR_INSET_BASE_CLASSNAME } from "./sidebar.logic"; + +describe("SidebarInset", () => { + it("stays shrinkable inside flex layouts", () => { + expect(SIDEBAR_INSET_BASE_CLASSNAME).toContain("min-w-0"); + }); +}); diff --git a/apps/web/src/components/ui/sidebar.tsx b/apps/web/src/components/ui/sidebar.tsx index ba77bddc27..ef79926ef2 100644 --- a/apps/web/src/components/ui/sidebar.tsx +++ b/apps/web/src/components/ui/sidebar.tsx @@ -18,6 +18,7 @@ import { import { Skeleton } from "~/components/ui/skeleton"; import { Tooltip, TooltipPopup, TooltipTrigger } from "~/components/ui/tooltip"; import { useMediaQuery } from "~/hooks/useMediaQuery"; +import { SIDEBAR_INSET_BASE_CLASSNAME } from "./sidebar.logic"; const SIDEBAR_COOKIE_NAME = "sidebar_state"; const SIDEBAR_COOKIE_MAX_AGE = 60 * 60 * 24 * 7; @@ -595,7 +596,7 @@ function SidebarInset({ className, ...props }: React.ComponentProps<"main">) { return (
Date: Mon, 9 Mar 2026 10:45:16 -0700 Subject: [PATCH 2/7] Adapt composer footer to compact controls on narrow widths - add shared footer layout breakpoint helper with tests - switch chat composer footer to compact menu when width is constrained - preserve plan sidebar toggle behavior while consolidating footer actions --- apps/web/src/components/ChatView.tsx | 350 +++++++++++++----- .../components/composerFooterLayout.test.ts | 37 ++ .../src/components/composerFooterLayout.ts | 12 + 3 files changed, 309 insertions(+), 90 deletions(-) create mode 100644 apps/web/src/components/composerFooterLayout.test.ts create mode 100644 apps/web/src/components/composerFooterLayout.ts diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 52085daa45..6c8b8b74eb 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -214,6 +214,7 @@ import { useComposerDraftStore, useComposerThreadDraft, } from "../composerDraftStore"; +import { shouldUseCompactComposerFooter } from "./composerFooterLayout"; import { selectThreadTerminalState, useTerminalStateStore } from "../terminalStateStore"; import { clamp } from "effect/Number"; import { ComposerPromptEditor, type ComposerPromptEditorHandle } from "./ComposerPromptEditor"; @@ -639,6 +640,7 @@ export default function ChatView({ threadId }: ChatViewProps) { useState>({}); const [expandedWorkGroups, setExpandedWorkGroups] = useState>({}); const [planSidebarOpen, setPlanSidebarOpen] = useState(false); + const [isComposerFooterCompact, setIsComposerFooterCompact] = useState(false); // Tracks whether the user explicitly dismissed the sidebar for the active turn. const planSidebarDismissedForTurnRef = useRef(null); // When set, the thread-change reset effect will open the sidebar instead of closing it. @@ -935,6 +937,7 @@ export default function ChatView({ threadId }: ChatViewProps) { isComposerApprovalState || pendingUserInputs.length > 0 || (showPlanFollowUpPrompt && activeProposedPlan !== null); + const composerFooterHasWideActions = showPlanFollowUpPrompt || activePendingProgress !== null; useEffect(() => { if (!activePendingProgress) { return; @@ -1672,6 +1675,19 @@ export default function ChatView({ threadId }: ChatViewProps) { const toggleInteractionMode = useCallback(() => { handleInteractionModeChange(interactionMode === "plan" ? "default" : "plan"); }, [handleInteractionModeChange, interactionMode]); + const togglePlanSidebar = useCallback(() => { + setPlanSidebarOpen((open) => { + if (open) { + const turnKey = activePlan?.turnId ?? activeProposedPlan?.turnId ?? null; + if (turnKey) { + planSidebarDismissedForTurnRef.current = turnKey; + } + } else { + planSidebarDismissedForTurnRef.current = null; + } + return !open; + }); + }, [activePlan?.turnId, activeProposedPlan?.turnId]); const persistThreadSettingsForNextTurn = useCallback( async (input: { @@ -1890,12 +1906,22 @@ export default function ChatView({ threadId }: ChatViewProps) { if (!composerForm) return; composerFormHeightRef.current = composerForm.getBoundingClientRect().height; + setIsComposerFooterCompact( + shouldUseCompactComposerFooter(composerForm.clientWidth, { + hasWideActions: composerFooterHasWideActions, + }), + ); if (typeof ResizeObserver === "undefined") return; const observer = new ResizeObserver((entries) => { const [entry] = entries; if (!entry) return; + const nextCompact = shouldUseCompactComposerFooter(entry.contentRect.width, { + hasWideActions: composerFooterHasWideActions, + }); + setIsComposerFooterCompact((previous) => (previous === nextCompact ? previous : nextCompact)); + const nextHeight = entry.contentRect.height; const previousHeight = composerFormHeightRef.current; composerFormHeightRef.current = nextHeight; @@ -1909,7 +1935,7 @@ export default function ChatView({ threadId }: ChatViewProps) { return () => { observer.disconnect(); }; - }, [activeThread?.id, scheduleStickToBottom]); + }, [activeThread?.id, composerFooterHasWideActions, scheduleStickToBottom]); useEffect(() => { if (!shouldAutoScrollRef.current) return; scheduleStickToBottom(); @@ -3679,10 +3705,23 @@ export default function ChatView({ threadId }: ChatViewProps) { /> ) : ( -
-
+
+
{/* Provider/model picker */} - {selectedProvider === "codex" && selectedEffort != null ? ( + {isComposerFooterCompact ? ( + + void handleRuntimeModeChange( + runtimeMode === "full-access" ? "approval-required" : "full-access", + ) + } + /> + ) : ( <> + {selectedProvider === "codex" && selectedEffort != null ? ( + <> + + + + ) : null} + - - - ) : null} - {/* Divider */} - + - {/* Interaction mode toggle */} - - - {/* Divider */} - - - {/* Runtime mode toggle */} - - - {/* Plan sidebar toggle */} - {(activePlan || activeProposedPlan || planSidebarOpen) ? ( - <> + + + {(activePlan || activeProposedPlan || planSidebarOpen) ? ( + <> + + + + ) : null} - ) : null} + )}
{/* Right side: send / stop button */} @@ -5534,6 +5578,8 @@ const ProviderModelPicker = memo(function ProviderModelPicker(props: { model: ModelSlug; lockedProvider: ProviderKind | null; modelOptionsByProvider: Record>; + serviceTierSetting: AppServiceTier; + compact?: boolean; disabled?: boolean; onProviderModelChange: (provider: ProviderKind, model: ModelSlug) => void; }) { @@ -5559,12 +5605,20 @@ const ProviderModelPicker = memo(function ProviderModelPicker(props: {
) : (
{/* Right side: send / stop button */} -
+
{isPreparingWorktree ? ( Preparing worktree... ) : null} diff --git a/apps/web/src/routes/_chat.$threadId.tsx b/apps/web/src/routes/_chat.$threadId.tsx index 2593eb7605..ccd2002777 100644 --- a/apps/web/src/routes/_chat.$threadId.tsx +++ b/apps/web/src/routes/_chat.$threadId.tsx @@ -15,6 +15,7 @@ const DIFF_INLINE_LAYOUT_MEDIA_QUERY = "(max-width: 1180px)"; const DIFF_INLINE_SIDEBAR_WIDTH_STORAGE_KEY = "chat_diff_sidebar_width"; const DIFF_INLINE_DEFAULT_WIDTH = "clamp(28rem,48vw,44rem)"; const DIFF_INLINE_SIDEBAR_MIN_WIDTH = 26 * 16; +const COMPOSER_COMPACT_MIN_LEFT_CONTROLS_WIDTH_PX = 208; const DiffPanelSheet = (props: { children: ReactNode; @@ -91,8 +92,23 @@ const DiffPanelInlineSidebar = (props: { composerViewport.clientWidth - viewportPaddingLeft - viewportPaddingRight, ); const formRect = composerForm.getBoundingClientRect(); + const composerFooter = composerForm.querySelector("[data-chat-composer-footer='true']"); + const composerRightActions = composerForm.querySelector( + "[data-chat-composer-actions='right']", + ); + const composerRightActionsWidth = composerRightActions?.getBoundingClientRect().width ?? 0; + const composerFooterGap = composerFooter + ? Number.parseFloat(window.getComputedStyle(composerFooter).columnGap) || + Number.parseFloat(window.getComputedStyle(composerFooter).gap) || + 0 + : 0; + const minimumComposerWidth = + COMPOSER_COMPACT_MIN_LEFT_CONTROLS_WIDTH_PX + + composerRightActionsWidth + + composerFooterGap; const hasComposerOverflow = composerForm.scrollWidth > composerForm.clientWidth + 0.5; const overflowsViewport = formRect.width > viewportContentWidth + 0.5; + const violatesMinimumComposerWidth = composerForm.clientWidth + 0.5 < minimumComposerWidth; if (previousSidebarWidth.length > 0) { wrapper.style.setProperty("--sidebar-width", previousSidebarWidth); @@ -100,7 +116,7 @@ const DiffPanelInlineSidebar = (props: { wrapper.style.removeProperty("--sidebar-width"); } - return !hasComposerOverflow && !overflowsViewport; + return !hasComposerOverflow && !overflowsViewport && !violatesMinimumComposerWidth; }, [], ); From 9bbb6000150849840d48b318861f3de4e1b60e20 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Mon, 9 Mar 2026 18:57:17 +0000 Subject: [PATCH 7/7] fix: stabilize onToggleRuntimeMode with useCallback to preserve memo Co-authored-by: Julius Marminge Applied via @cursor push command --- apps/web/src/components/ChatView.tsx | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 565ce201ff..5fb47b1a9a 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -1675,6 +1675,11 @@ export default function ChatView({ threadId }: ChatViewProps) { const toggleInteractionMode = useCallback(() => { handleInteractionModeChange(interactionMode === "plan" ? "default" : "plan"); }, [handleInteractionModeChange, interactionMode]); + const toggleRuntimeMode = useCallback(() => { + void handleRuntimeModeChange( + runtimeMode === "full-access" ? "approval-required" : "full-access", + ); + }, [handleRuntimeModeChange, runtimeMode]); const togglePlanSidebar = useCallback(() => { setPlanSidebarOpen((open) => { if (open) { @@ -3745,11 +3750,7 @@ export default function ChatView({ threadId }: ChatViewProps) { onCodexFastModeChange={onCodexFastModeChange} onToggleInteractionMode={toggleInteractionMode} onTogglePlanSidebar={togglePlanSidebar} - onToggleRuntimeMode={() => - void handleRuntimeModeChange( - runtimeMode === "full-access" ? "approval-required" : "full-access", - ) - } + onToggleRuntimeMode={toggleRuntimeMode} /> ) : ( <>