authorgravatar for 73941998+UtkarshUsername@users.noreply.github.comUtkarsh Patil <73941998+UtkarshUsername@users.noreply.github.com> 2026-04-16 23:43:59+05:30
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-04-16 11:13:59-07:00
log19d47408a53181446a02fac967846cc15d77d5b8
treebbc6282a756d0794789a4b918d512ab71636a04a
parent3e07f5a6a412be551e1d74fe4600a31e5ef557c4
signature Signed by PGP key B5690EEEBB952194

fix(web): prevent composer controls overlap on narrow windows (make plan sidebar responsive) (#1198)


5 files changed, 75 insertions(+), 40 deletions(-)

apps/web/src/components/ChatView.tsx+27-7
......@@ -92,6 +92,8 @@ import { useTheme } from "../hooks/useTheme";
9292import { useTurnDiffSummaries } from "../hooks/useTurnDiffSummaries";
9393import { useCommandPaletteStore } from "../commandPaletteStore";
9494import { buildTemporaryWorktreeBranchName } from "@t3tools/shared/git";
95import { useMediaQuery } from "../hooks/useMediaQuery";
96import { RIGHT_PANEL_INLINE_LAYOUT_MEDIA_QUERY } from "../rightPanelLayout";
9597import { BranchToolbar } from "./BranchToolbar";
9698import { resolveShortcutCommand, shortcutLabelForCommand } from "../keybindings";
9799import PlanSidebar from "./PlanSidebar";
......@@ -171,6 +173,7 @@ import {
171173} from "~/rpc/serverState";
172174import { sanitizeThreadErrorMessage } from "~/rpc/transportError";
173175import { retainThreadDetailSubscription } from "../environments/runtime/service";
176import { RightPanelSheet } from "./RightPanelSheet";
174177
175178const IMAGE_ONLY_BOOTSTRAP_PROMPT =
176179 "[User attached one or more images without additional text. Respond using the conversation context and the attached image(s).]";
......@@ -675,6 +678,7 @@ export default function ChatView(props: ChatViewProps) {
675678 const [pendingUserInputQuestionIndexByRequestId, setPendingUserInputQuestionIndexByRequestId] =
676679 useState<Record<string, number>>({});
677680 const [planSidebarOpen, setPlanSidebarOpen] = useState(false);
681 const shouldUsePlanSidebarSheet = useMediaQuery(RIGHT_PANEL_INLINE_LAYOUT_MEDIA_QUERY);
678682 // Tracks whether the user explicitly dismissed the sidebar for the active turn.
679683 const planSidebarDismissedForTurnRef = useRef<string | null>(null);
680684 // When set, the thread-change reset effect will open the sidebar instead of closing it.
......@@ -1897,6 +1901,11 @@ export default function ChatView(props: ChatViewProps) {
18971901 return !open;
18981902 });
18991903 }, [activePlan?.turnId, sidebarProposedPlan?.turnId]);
1904 const closePlanSidebar = useCallback(() => {
1905 setPlanSidebarOpen(false);
1906 planSidebarDismissedForTurnRef.current =
1907 activePlan?.turnId ?? sidebarProposedPlan?.turnId ?? "__dismissed__";
1908 }, [activePlan?.turnId, sidebarProposedPlan?.turnId]);
19001909
19011910 const persistThreadSettingsForNextTurn = useCallback(
19021911 async (input: {
......@@ -3394,7 +3403,7 @@ export default function ChatView(props: ChatViewProps) {
33943403 {/* end chat column */}
33953404
33963405 {/* Plan sidebar */}
3397 {planSidebarOpen ? (
3406 {planSidebarOpen && !shouldUsePlanSidebarSheet ? (
33983407 <PlanSidebar
33993408 activePlan={activePlan}
34003409 activeProposedPlan={sidebarProposedPlan}
......@@ -3403,12 +3412,8 @@ export default function ChatView(props: ChatViewProps) {
34033412 markdownCwd={gitCwd ?? undefined}
34043413 workspaceRoot={activeWorkspaceRoot}
34053414 timestampFormat={timestampFormat}
3406 onClose={() => {
3407 setPlanSidebarOpen(false);
3408 // Track that the user explicitly dismissed for this turn so auto-open won't fight them.
3409 planSidebarDismissedForTurnRef.current =
3410 activePlan?.turnId ?? sidebarProposedPlan?.turnId ?? "__dismissed__";
3411 }}
3415 mode="sidebar"
3416 onClose={closePlanSidebar}
34123417 />
34133418 ) : null}
34143419 </div>
......@@ -3430,6 +3435,21 @@ export default function ChatView(props: ChatViewProps) {
34303435 onAddTerminalContext={addTerminalContextToDraft}
34313436 />
34323437 ))}
3438 {shouldUsePlanSidebarSheet ? (
3439 <RightPanelSheet open={planSidebarOpen} onClose={closePlanSidebar}>
3440 <PlanSidebar
3441 activePlan={activePlan}
3442 activeProposedPlan={sidebarProposedPlan}
3443 label={planSidebarLabel}
3444 environmentId={environmentId}
3445 markdownCwd={gitCwd ?? undefined}
3446 workspaceRoot={activeWorkspaceRoot}
3447 timestampFormat={timestampFormat}
3448 mode="sheet"
3449 onClose={closePlanSidebar}
3450 />
3451 </RightPanelSheet>
3452 ) : null}
34333453
34343454 {expandedImage && (
34353455 <ExpandedImageDialog preview={expandedImage} onClose={closeExpandedImage} />
apps/web/src/components/PlanSidebar.tsx+10-1
......@@ -59,6 +59,7 @@ interface PlanSidebarProps {
5959 markdownCwd: string | undefined;
6060 workspaceRoot: string | undefined;
6161 timestampFormat: TimestampFormat;
62 mode?: "sheet" | "sidebar";
6263 onClose: () => void;
6364}
6465
......@@ -70,6 +71,7 @@ const PlanSidebar = memo(function PlanSidebar({
7071 markdownCwd,
7172 workspaceRoot,
7273 timestampFormat,
74 mode = "sidebar",
7375 onClose,
7476}: PlanSidebarProps) {
7577 const [proposedPlanExpanded, setProposedPlanExpanded] = useState(false);
......@@ -123,7 +125,14 @@ const PlanSidebar = memo(function PlanSidebar({
123125 }, [environmentId, planMarkdown, workspaceRoot]);
124126
125127 return (
126 <div className="flex h-full w-[340px] shrink-0 flex-col border-l border-border/70 bg-card/50">
128 <div
129 className={cn(
130 "flex min-h-0 flex-col bg-card/50",
131 mode === "sidebar"
132 ? "h-full w-[340px] shrink-0 border-l border-border/70"
133 : "h-full w-full",
134 )}
135 >
127136 {/* Header */}
128137 <div className="flex h-12 shrink-0 items-center justify-between border-b border-border/60 px-3">
129138 <div className="flex items-center gap-2">
apps/web/src/components/RightPanelSheet.tsx created+30
......@@ -0,0 +1,30 @@
1import { type ReactNode } from "react";
2
3import { RIGHT_PANEL_SHEET_CLASS_NAME } from "../rightPanelLayout";
4import { Sheet, SheetPopup } from "./ui/sheet";
5
6export function RightPanelSheet(props: {
7 children: ReactNode;
8 open: boolean;
9 onClose: () => void;
10}) {
11 return (
12 <Sheet
13 open={props.open}
14 onOpenChange={(open) => {
15 if (!open) {
16 props.onClose();
17 }
18 }}
19 >
20 <SheetPopup
21 side="right"
22 showCloseButton={false}
23 keepMounted
24 className={RIGHT_PANEL_SHEET_CLASS_NAME}
25 >
26 {props.children}
27 </SheetPopup>
28 </Sheet>
29 );
30}
apps/web/src/rightPanelLayout.ts created+2
......@@ -0,0 +1,2 @@
1export const RIGHT_PANEL_INLINE_LAYOUT_MEDIA_QUERY = "(max-width: 1180px)";
2export const RIGHT_PANEL_SHEET_CLASS_NAME = "w-[min(88vw,820px)] max-w-[820px] p-0";
apps/web/src/routes/_chat.$environmentId.$threadId.tsx+6-32
......@@ -1,5 +1,5 @@
11import { createFileRoute, retainSearchParams, useNavigate } from "@tanstack/react-router";
2import { Suspense, lazy, type ReactNode, useCallback, useEffect, useMemo, useState } from "react";
2import { Suspense, lazy, useCallback, useEffect, useMemo, useState } from "react";
33
44import ChatView from "../components/ChatView";
55import { threadHasStarted } from "../components/ChatView.logic";
......@@ -17,45 +17,19 @@ import {
1717 stripDiffSearchParams,
1818} from "../diffRouteSearch";
1919import { useMediaQuery } from "../hooks/useMediaQuery";
20import { RIGHT_PANEL_INLINE_LAYOUT_MEDIA_QUERY } from "../rightPanelLayout";
2021import { selectEnvironmentState, selectThreadExistsByRef, useStore } from "../store";
2122import { createThreadSelectorByRef } from "../storeSelectors";
2223import { resolveThreadRouteRef, buildThreadRouteParams } from "../threadRoutes";
23import { Sheet, SheetPopup } from "../components/ui/sheet";
24import { RightPanelSheet } from "../components/RightPanelSheet";
2425import { Sidebar, SidebarInset, SidebarProvider, SidebarRail } from "~/components/ui/sidebar";
2526
2627const DiffPanel = lazy(() => import("../components/DiffPanel"));
27const DIFF_INLINE_LAYOUT_MEDIA_QUERY = "(max-width: 1180px)";
2828const DIFF_INLINE_SIDEBAR_WIDTH_STORAGE_KEY = "chat_diff_sidebar_width";
2929const DIFF_INLINE_DEFAULT_WIDTH = "clamp(28rem,48vw,44rem)";
3030const DIFF_INLINE_SIDEBAR_MIN_WIDTH = 26 * 16;
3131const COMPOSER_COMPACT_MIN_LEFT_CONTROLS_WIDTH_PX = 208;
3232
33const DiffPanelSheet = (props: {
34 children: ReactNode;
35 diffOpen: boolean;
36 onCloseDiff: () => void;
37}) => {
38 return (
39 <Sheet
40 open={props.diffOpen}
41 onOpenChange={(open) => {
42 if (!open) {
43 props.onCloseDiff();
44 }
45 }}
46 >
47 <SheetPopup
48 side="right"
49 showCloseButton={false}
50 keepMounted
51 className="w-[min(88vw,820px)] max-w-[820px] p-0"
52 >
53 {props.children}
54 </SheetPopup>
55 </Sheet>
56 );
57};
58
5933const DiffLoadingFallback = (props: { mode: DiffPanelMode }) => {
6034 return (
6135 <DiffPanelShell mode={props.mode} header={<DiffPanelHeaderSkeleton />}>
......@@ -192,7 +166,7 @@ function ChatThreadRouteView() {
192166 const serverThreadStarted = threadHasStarted(serverThread);
193167 const environmentHasAnyThreads = environmentHasServerThreads || environmentHasDraftThreads;
194168 const diffOpen = search.diff === "1";
195 const shouldUseDiffSheet = useMediaQuery(DIFF_INLINE_LAYOUT_MEDIA_QUERY);
169 const shouldUseDiffSheet = useMediaQuery(RIGHT_PANEL_INLINE_LAYOUT_MEDIA_QUERY);
196170 const currentThreadKey = threadRef ? `${threadRef.environmentId}:${threadRef.threadId}` : null;
197171 const [diffPanelMountState, setDiffPanelMountState] = useState(() => ({
198172 threadKey: currentThreadKey,
......@@ -293,9 +267,9 @@ function ChatThreadRouteView() {
293267 routeKind="server"
294268 />
295269 </SidebarInset>
296 <DiffPanelSheet diffOpen={diffOpen} onCloseDiff={closeDiff}>
270 <RightPanelSheet open={diffOpen} onClose={closeDiff}>
297271 {shouldRenderDiffContent ? <LazyDiffPanel mode="sheet" /> : null}
298 </DiffPanelSheet>
272 </RightPanelSheet>
299273 </>
300274 );
301275}