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";...@@ -92,6 +92,8 @@ import { useTheme } from "../hooks/useTheme";
92import { useTurnDiffSummaries } from "../hooks/useTurnDiffSummaries";92import { useTurnDiffSummaries } from "../hooks/useTurnDiffSummaries";
93import { useCommandPaletteStore } from "../commandPaletteStore";93import { useCommandPaletteStore } from "../commandPaletteStore";
94import { buildTemporaryWorktreeBranchName } from "@t3tools/shared/git";94import { buildTemporaryWorktreeBranchName } from "@t3tools/shared/git";
95import { useMediaQuery } from "../hooks/useMediaQuery";
96import { RIGHT_PANEL_INLINE_LAYOUT_MEDIA_QUERY } from "../rightPanelLayout";
95import { BranchToolbar } from "./BranchToolbar";97import { BranchToolbar } from "./BranchToolbar";
96import { resolveShortcutCommand, shortcutLabelForCommand } from "../keybindings";98import { resolveShortcutCommand, shortcutLabelForCommand } from "../keybindings";
97import PlanSidebar from "./PlanSidebar";99import PlanSidebar from "./PlanSidebar";
...@@ -171,6 +173,7 @@ import {...@@ -171,6 +173,7 @@ import {
171} from "~/rpc/serverState";173} from "~/rpc/serverState";
172import { sanitizeThreadErrorMessage } from "~/rpc/transportError";174import { sanitizeThreadErrorMessage } from "~/rpc/transportError";
173import { retainThreadDetailSubscription } from "../environments/runtime/service";175import { retainThreadDetailSubscription } from "../environments/runtime/service";
176import { RightPanelSheet } from "./RightPanelSheet";
174177
175const IMAGE_ONLY_BOOTSTRAP_PROMPT =178const IMAGE_ONLY_BOOTSTRAP_PROMPT =
176 "[User attached one or more images without additional text. Respond using the conversation context and the attached image(s).]";179 "[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) {...@@ -675,6 +678,7 @@ export default function ChatView(props: ChatViewProps) {
675 const [pendingUserInputQuestionIndexByRequestId, setPendingUserInputQuestionIndexByRequestId] =678 const [pendingUserInputQuestionIndexByRequestId, setPendingUserInputQuestionIndexByRequestId] =
676 useState<Record<string, number>>({});679 useState<Record<string, number>>({});
677 const [planSidebarOpen, setPlanSidebarOpen] = useState(false);680 const [planSidebarOpen, setPlanSidebarOpen] = useState(false);
681 const shouldUsePlanSidebarSheet = useMediaQuery(RIGHT_PANEL_INLINE_LAYOUT_MEDIA_QUERY);
678 // Tracks whether the user explicitly dismissed the sidebar for the active turn.682 // Tracks whether the user explicitly dismissed the sidebar for the active turn.
679 const planSidebarDismissedForTurnRef = useRef<string | null>(null);683 const planSidebarDismissedForTurnRef = useRef<string | null>(null);
680 // When set, the thread-change reset effect will open the sidebar instead of closing it.684 // 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) {...@@ -1897,6 +1901,11 @@ export default function ChatView(props: ChatViewProps) {
1897 return !open;1901 return !open;
1898 });1902 });
1899 }, [activePlan?.turnId, sidebarProposedPlan?.turnId]);1903 }, [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
1901 const persistThreadSettingsForNextTurn = useCallback(1910 const persistThreadSettingsForNextTurn = useCallback(
1902 async (input: {1911 async (input: {
...@@ -3394,7 +3403,7 @@ export default function ChatView(props: ChatViewProps) {...@@ -3394,7 +3403,7 @@ export default function ChatView(props: ChatViewProps) {
3394 {/* end chat column */}3403 {/* end chat column */}
33953404
3396 {/* Plan sidebar */}3405 {/* Plan sidebar */}
3397 {planSidebarOpen ? (3406 {planSidebarOpen && !shouldUsePlanSidebarSheet ? (
3398 <PlanSidebar3407 <PlanSidebar
3399 activePlan={activePlan}3408 activePlan={activePlan}
3400 activeProposedPlan={sidebarProposedPlan}3409 activeProposedPlan={sidebarProposedPlan}
...@@ -3403,12 +3412,8 @@ export default function ChatView(props: ChatViewProps) {...@@ -3403,12 +3412,8 @@ export default function ChatView(props: ChatViewProps) {
3403 markdownCwd={gitCwd ?? undefined}3412 markdownCwd={gitCwd ?? undefined}
3404 workspaceRoot={activeWorkspaceRoot}3413 workspaceRoot={activeWorkspaceRoot}
3405 timestampFormat={timestampFormat}3414 timestampFormat={timestampFormat}
3406 onClose={() => {3415 mode="sidebar"
3407 setPlanSidebarOpen(false);3416 onClose={closePlanSidebar}
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 }}
3412 />3417 />
3413 ) : null}3418 ) : null}
3414 </div>3419 </div>
...@@ -3430,6 +3435,21 @@ export default function ChatView(props: ChatViewProps) {...@@ -3430,6 +3435,21 @@ export default function ChatView(props: ChatViewProps) {
3430 onAddTerminalContext={addTerminalContextToDraft}3435 onAddTerminalContext={addTerminalContextToDraft}
3431 />3436 />
3432 ))}3437 ))}
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
3434 {expandedImage && (3454 {expandedImage && (
3435 <ExpandedImageDialog preview={expandedImage} onClose={closeExpandedImage} />3455 <ExpandedImageDialog preview={expandedImage} onClose={closeExpandedImage} />
apps/web/src/components/PlanSidebar.tsx+10-1
...@@ -59,6 +59,7 @@ interface PlanSidebarProps {...@@ -59,6 +59,7 @@ interface PlanSidebarProps {
59 markdownCwd: string | undefined;59 markdownCwd: string | undefined;
60 workspaceRoot: string | undefined;60 workspaceRoot: string | undefined;
61 timestampFormat: TimestampFormat;61 timestampFormat: TimestampFormat;
62 mode?: "sheet" | "sidebar";
62 onClose: () => void;63 onClose: () => void;
63}64}
6465
...@@ -70,6 +71,7 @@ const PlanSidebar = memo(function PlanSidebar({...@@ -70,6 +71,7 @@ const PlanSidebar = memo(function PlanSidebar({
70 markdownCwd,71 markdownCwd,
71 workspaceRoot,72 workspaceRoot,
72 timestampFormat,73 timestampFormat,
74 mode = "sidebar",
73 onClose,75 onClose,
74}: PlanSidebarProps) {76}: PlanSidebarProps) {
75 const [proposedPlanExpanded, setProposedPlanExpanded] = useState(false);77 const [proposedPlanExpanded, setProposedPlanExpanded] = useState(false);
...@@ -123,7 +125,14 @@ const PlanSidebar = memo(function PlanSidebar({...@@ -123,7 +125,14 @@ const PlanSidebar = memo(function PlanSidebar({
123 }, [environmentId, planMarkdown, workspaceRoot]);125 }, [environmentId, planMarkdown, workspaceRoot]);
124126
125 return (127 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 >
127 {/* Header */}136 {/* Header */}
128 <div className="flex h-12 shrink-0 items-center justify-between border-b border-border/60 px-3">137 <div className="flex h-12 shrink-0 items-center justify-between border-b border-border/60 px-3">
129 <div className="flex items-center gap-2">138 <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 @@...@@ -1,5 +1,5 @@
1import { createFileRoute, retainSearchParams, useNavigate } from "@tanstack/react-router";1import { 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
4import ChatView from "../components/ChatView";4import ChatView from "../components/ChatView";
5import { threadHasStarted } from "../components/ChatView.logic";5import { threadHasStarted } from "../components/ChatView.logic";
...@@ -17,45 +17,19 @@ import {...@@ -17,45 +17,19 @@ import {
17 stripDiffSearchParams,17 stripDiffSearchParams,
18} from "../diffRouteSearch";18} from "../diffRouteSearch";
19import { useMediaQuery } from "../hooks/useMediaQuery";19import { useMediaQuery } from "../hooks/useMediaQuery";
20import { RIGHT_PANEL_INLINE_LAYOUT_MEDIA_QUERY } from "../rightPanelLayout";
20import { selectEnvironmentState, selectThreadExistsByRef, useStore } from "../store";21import { selectEnvironmentState, selectThreadExistsByRef, useStore } from "../store";
21import { createThreadSelectorByRef } from "../storeSelectors";22import { createThreadSelectorByRef } from "../storeSelectors";
22import { resolveThreadRouteRef, buildThreadRouteParams } from "../threadRoutes";23import { resolveThreadRouteRef, buildThreadRouteParams } from "../threadRoutes";
23import { Sheet, SheetPopup } from "../components/ui/sheet";24import { RightPanelSheet } from "../components/RightPanelSheet";
24import { Sidebar, SidebarInset, SidebarProvider, SidebarRail } from "~/components/ui/sidebar";25import { Sidebar, SidebarInset, SidebarProvider, SidebarRail } from "~/components/ui/sidebar";
2526
26const DiffPanel = lazy(() => import("../components/DiffPanel"));27const DiffPanel = lazy(() => import("../components/DiffPanel"));
27const DIFF_INLINE_LAYOUT_MEDIA_QUERY = "(max-width: 1180px)";
28const DIFF_INLINE_SIDEBAR_WIDTH_STORAGE_KEY = "chat_diff_sidebar_width";28const DIFF_INLINE_SIDEBAR_WIDTH_STORAGE_KEY = "chat_diff_sidebar_width";
29const DIFF_INLINE_DEFAULT_WIDTH = "clamp(28rem,48vw,44rem)";29const DIFF_INLINE_DEFAULT_WIDTH = "clamp(28rem,48vw,44rem)";
30const DIFF_INLINE_SIDEBAR_MIN_WIDTH = 26 * 16;30const DIFF_INLINE_SIDEBAR_MIN_WIDTH = 26 * 16;
31const COMPOSER_COMPACT_MIN_LEFT_CONTROLS_WIDTH_PX = 208;31const 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
59const DiffLoadingFallback = (props: { mode: DiffPanelMode }) => {33const DiffLoadingFallback = (props: { mode: DiffPanelMode }) => {
60 return (34 return (
61 <DiffPanelShell mode={props.mode} header={<DiffPanelHeaderSkeleton />}>35 <DiffPanelShell mode={props.mode} header={<DiffPanelHeaderSkeleton />}>
...@@ -192,7 +166,7 @@ function ChatThreadRouteView() {...@@ -192,7 +166,7 @@ function ChatThreadRouteView() {
192 const serverThreadStarted = threadHasStarted(serverThread);166 const serverThreadStarted = threadHasStarted(serverThread);
193 const environmentHasAnyThreads = environmentHasServerThreads || environmentHasDraftThreads;167 const environmentHasAnyThreads = environmentHasServerThreads || environmentHasDraftThreads;
194 const diffOpen = search.diff === "1";168 const diffOpen = search.diff === "1";
195 const shouldUseDiffSheet = useMediaQuery(DIFF_INLINE_LAYOUT_MEDIA_QUERY);169 const shouldUseDiffSheet = useMediaQuery(RIGHT_PANEL_INLINE_LAYOUT_MEDIA_QUERY);
196 const currentThreadKey = threadRef ? `${threadRef.environmentId}:${threadRef.threadId}` : null;170 const currentThreadKey = threadRef ? `${threadRef.environmentId}:${threadRef.threadId}` : null;
197 const [diffPanelMountState, setDiffPanelMountState] = useState(() => ({171 const [diffPanelMountState, setDiffPanelMountState] = useState(() => ({
198 threadKey: currentThreadKey,172 threadKey: currentThreadKey,
...@@ -293,9 +267,9 @@ function ChatThreadRouteView() {...@@ -293,9 +267,9 @@ function ChatThreadRouteView() {
293 routeKind="server"267 routeKind="server"
294 />268 />
295 </SidebarInset>269 </SidebarInset>
296 <DiffPanelSheet diffOpen={diffOpen} onCloseDiff={closeDiff}>270 <RightPanelSheet open={diffOpen} onClose={closeDiff}>
297 {shouldRenderDiffContent ? <LazyDiffPanel mode="sheet" /> : null}271 {shouldRenderDiffContent ? <LazyDiffPanel mode="sheet" /> : null}
298 </DiffPanelSheet>272 </RightPanelSheet>
299 </>273 </>
300 );274 );
301}275}