From c14f6015bfe479d313355cb234af1a5c16dbb15f Mon Sep 17 00:00:00 2001 From: shivam <91240327+shivamhwp@users.noreply.github.com> Date: Sun, 20 Sep 2026 20:01:29 +0530 Subject: [PATCH] fix(web): keep narrow chat headers readable and aligned (#12453) --- apps/web/src/components/GitActionsControl.tsx | 281 +++++++++++------- .../src/components/ProjectScriptsControl.tsx | 182 ++++++++---- .../src/components/WorkspaceBreadcrumb.tsx | 29 +- apps/web/src/components/chat/ChatHeader.tsx | 164 +++++++--- apps/web/src/components/chat/OpenInPicker.tsx | 101 +++++-- apps/web/src/components/ui/menu.tsx | 28 +- apps/web/src/components/ui/sidebar.tsx | 7 +- 7 files changed, 563 insertions(+), 229 deletions(-) diff --git a/apps/web/src/components/GitActionsControl.tsx b/apps/web/src/components/GitActionsControl.tsx index 99db055b667b..92c28560135e 100644 --- a/apps/web/src/components/GitActionsControl.tsx +++ b/apps/web/src/components/GitActionsControl.tsx @@ -79,7 +79,16 @@ import { } from "~/components/ui/dialog"; import { Group, GroupSeparator } from "~/components/ui/group"; import { Input } from "~/components/ui/input"; -import { Menu, MenuItem, MenuPopup, MenuTrigger } from "~/components/ui/menu"; +import { + Menu, + MenuItem, + MenuItemLabel, + MenuPopup, + MenuSub, + MenuSubTrigger, + MenuSubPopup, + MenuTrigger, +} from "~/components/ui/menu"; import { Popover, PopoverPopup, PopoverTrigger } from "~/components/ui/popover"; import { ScrollArea } from "~/components/ui/scroll-area"; import { Textarea } from "~/components/ui/textarea"; @@ -108,6 +117,7 @@ import { useOpenLink } from "~/browser/useOpenLink"; import { useOpenPrLink } from "~/lib/openPullRequestLink"; interface GitActionsControlProps { + presentation?: "toolbar" | "menu"; gitCwd: string | null; activeThreadRef: ScopedThreadRef | null; draftId?: DraftId; @@ -374,24 +384,25 @@ function GitActionItemIcon({ function GitQuickActionIcon({ quickAction, SourceControlIcon, + className = "size-3.5", }: { quickAction: GitQuickAction; + className?: string; SourceControlIcon: ReturnType["Icon"]; }) { - const iconClassName = "size-3.5"; - if (quickAction.kind === "open_pr") return ; - if (quickAction.kind === "open_publish") return ; - if (quickAction.kind === "run_pull") return ; + if (quickAction.kind === "open_pr") return ; + if (quickAction.kind === "open_publish") return ; + if (quickAction.kind === "run_pull") return ; if (quickAction.kind === "run_action") { - if (quickAction.action === "commit") return ; + if (quickAction.action === "commit") return ; if (quickAction.action === "push" || quickAction.action === "commit_push") { - return ; + return ; } - return ; + return ; } - if (quickAction.label === "Commit") return ; - if (quickAction.label === "Push") return ; - return ; + if (quickAction.label === "Commit") return ; + if (quickAction.label === "Push") return ; + return ; } interface PublishRepositoryDialogProps { @@ -944,6 +955,7 @@ function PublishRepositoryDialog(props: PublishRepositoryDialogProps) { } export default function GitActionsControl({ + presentation = "toolbar", gitCwd, activeThreadRef, draftId, @@ -1632,33 +1644,161 @@ export default function GitActionsControl({ const canPublishRepository = isRepo && gitStatusForActions !== null && !hasPrimaryRemote; - if (!gitCwd) return null; - - return ( + const initializeGit = () => { + void (async () => { + const result = await initAction.run(); + if (result._tag === "Success" || isAtomCommandInterrupted(result)) { + return; + } + const error = squashAtomCommandFailure(result); + toastManager.add( + stackedThreadToast({ + type: "error", + title: "Git initialization failed", + description: error instanceof Error ? error.message : "An error occurred.", + ...(threadToastData !== undefined ? { data: threadToastData } : {}), + }), + ); + })(); + }; + const gitItems = ( <> - {!isRepo ? ( - + + + ); + })} + {importMenuItems} + + + Add action + + + ); + return ( <> - {primaryScript ? ( + {presentation === "menu" ? ( + <> + {primaryScript && ( + onRunScript(primaryScript)} + > + + Run {primaryScript.name} + + {shortcutLabelForCommand(keybindings, commandForProjectScript(primaryScript.id))} + + + )} + {primaryScript || importableScripts.length > 0 ? ( + + setActionsMenuOpen({ presentation, scripts: open, imports: false }) + } + > + + + Project actions + + + {scriptItems} + + + ) : ( + + + Add project action… + + )} + + ) : primaryScript ? ( setActionsMenuOpen({ scripts: open, imports: false })} + onOpenChange={(open) => + setActionsMenuOpen({ presentation, scripts: open, imports: false }) + } > } > - - {scripts.map((script) => { - const shortcutLabel = shortcutLabelForCommand( - keybindings, - commandForProjectScript(script.id), - ); - return ( - onRunScript(script)} - > - - - {script.runOnWorktreeCreate ? `${script.name} (setup)` : script.name} - - - {shortcutLabel && ( - - {shortcutLabel} - - )} - - - - ); - })} - {importMenuItems} - - - Add action - - + {scriptItems} ) : importableScripts.length > 0 ? ( setActionsMenuOpen({ scripts: false, imports: open })} + onOpenChange={(open) => + setActionsMenuOpen({ presentation, scripts: false, imports: open }) + } > }> diff --git a/apps/web/src/components/WorkspaceBreadcrumb.tsx b/apps/web/src/components/WorkspaceBreadcrumb.tsx index f1c3d8b15d04..015e381aa251 100644 --- a/apps/web/src/components/WorkspaceBreadcrumb.tsx +++ b/apps/web/src/components/WorkspaceBreadcrumb.tsx @@ -1,4 +1,4 @@ -import type { ReactNode } from "react"; +import type { ComponentProps, ReactNode } from "react"; import { cn } from "../lib/utils"; @@ -26,6 +26,23 @@ interface WorkspaceBreadcrumbItemProps { readonly current?: boolean; } +export function WorkspaceBreadcrumbText({ children, className, ...props }: ComponentProps<"span">) { + return ( + + {children} + + ); +} + export function WorkspaceBreadcrumbItem({ children, className, @@ -45,10 +62,16 @@ export function WorkspaceBreadcrumbItem({ ); } -export function WorkspaceBreadcrumbSeparator({ className }: { readonly className?: string }) { +export function WorkspaceBreadcrumbSeparator({ + className, + children = "/", +}: { + readonly className?: string; + readonly children?: ReactNode; +}) { return ( ); } diff --git a/apps/web/src/components/chat/ChatHeader.tsx b/apps/web/src/components/chat/ChatHeader.tsx index fbebc323a950..1c8bc520485f 100644 --- a/apps/web/src/components/chat/ChatHeader.tsx +++ b/apps/web/src/components/chat/ChatHeader.tsx @@ -11,7 +11,7 @@ import { isAtomCommandInterrupted, squashAtomCommandFailure, } from "@t3tools/client-runtime/state/runtime"; -import { ChevronDownIcon } from "lucide-react"; +import { ChevronDownIcon, EllipsisIcon } from "lucide-react"; import { memo, useCallback, @@ -22,6 +22,7 @@ import { type KeyboardEvent as ReactKeyboardEvent, type MouseEvent as ReactMouseEvent, } from "react"; +import { createPortal } from "react-dom"; import GitActionsControl from "../GitActionsControl"; import { isTrailingDoubleClick } from "../Sidebar.logic"; import { type DraftId } from "~/composerDraftStore"; @@ -45,8 +46,12 @@ import { WorkspaceBreadcrumb, WorkspaceBreadcrumbItem, WorkspaceBreadcrumbSeparator, + WorkspaceBreadcrumbText, } from "../WorkspaceBreadcrumb"; import { cn } from "~/lib/utils"; +import { useIsMobile } from "~/hooks/useMediaQuery"; +import { Button } from "../ui/button"; +import { Menu, MenuPopup, MenuSeparator, MenuTrigger } from "../ui/menu"; interface ChatHeaderProps { activeThreadEnvironmentId: EnvironmentId; @@ -155,6 +160,40 @@ export const ChatHeader = memo(function ChatHeader({ breakpoint: { value: HEADER_ACTIONS_EXPANDED_BREAKPOINT_REM, unit: "rem" }, }); }, [panelAnimationDurationMs, panelAnimationsActive]); + const isMobile = useIsMobile(); + // Side panels can leave a desktop header narrower than a phone. + const [isNarrowHeader, setIsNarrowHeader] = useState(false); + useEffect(() => { + const container = headerActionsRef.current?.parentElement; + if (!container) return; + const update = () => setIsNarrowHeader(container.clientWidth < 512); + update(); + const observer = new ResizeObserver(update); + observer.observe(container); + return () => observer.disconnect(); + }, []); + const actionsCollapsed = isMobile || isNarrowHeader; + const [actionsOpen, setActionsOpen] = useState(false); + const [actionsContainer] = useState(() => { + const container = document.createElement("div"); + container.className = "contents"; + return container; + }); + // Reparent the DOM host, not the React controls: rotating a phone or resizing + // a window must not discard an unsaved script or Git dialog. + const mountInlineActions = useCallback( + (node: HTMLDivElement | null) => { + if (node && !actionsCollapsed) node.appendChild(actionsContainer); + }, + [actionsContainer, actionsCollapsed], + ); + const mountMenuActions = useCallback( + (node: HTMLDivElement | null) => { + if (node && actionsCollapsed) node.appendChild(actionsContainer); + }, + [actionsContainer, actionsCollapsed], + ); + if (!actionsCollapsed && actionsOpen) setActionsOpen(false); const primaryEnvironmentId = usePrimaryEnvironmentId(); const activeProjectName = activeProject?.title; const activeProjectCwd = activeProject?.workspaceRoot ?? null; @@ -312,6 +351,50 @@ export const ChatHeader = memo(function ChatHeader({ }, [commitRename], ); + const headerActions = ( + <> + {activeProjectScripts && ( + <> + setActionsOpen(false)} + presentation={actionsCollapsed ? "menu" : "toolbar"} + scripts={activeProjectScripts} + fileScripts={fileScripts} + keybindings={keybindings} + preferredScriptId={preferredScriptId} + onRunScript={onRunProjectScript} + onAddScript={onAddProjectScript} + onUpdateScript={onUpdateProjectScript} + onDeleteScript={onDeleteProjectScript} + /> + + )} + {showOpenInPicker && ( + <> + {actionsCollapsed && activeProjectScripts && } + + + )} + {activeProjectName && gitCwd && ( + <> + {actionsCollapsed && (activeProjectScripts || showOpenInPicker) && } + + + )} + + ); return (
- {activeProjectName} + + {activeProjectName} + New thread in {activeProjectName} - + + / + ) : null} @@ -377,7 +464,9 @@ export const ChatHeader = memo(function ChatHeader({ /> } > -

{activeThreadTitle}

+

+ {activeThreadTitle} +

- {activeThreadTitle} - - } - /> + render={

} + > + {activeThreadTitle} + {activeThreadTitle} )} @@ -405,38 +492,37 @@ export const ChatHeader = memo(function ChatHeader({ data-chat-header-actions className={cn( "flex shrink-0 items-center justify-end gap-2 @3xl/header-actions:gap-3", - rightPanelOpen ? "pr-0" : "pr-16", + // Reserve two panel toggles plus their 4px gaps and 1px edge inset. + // The page header adds 8px more right padding at sm. + rightPanelOpen ? "pr-0" : "pr-[calc(--spacing(18)+1px)] sm:pr-[calc(--spacing(14)+1px)]", "[[data-panel-animations=true]_&]:motion-safe:transition-[padding-right] [[data-panel-animations=true]_&]:motion-safe:[transition-duration:var(--panel-animation-duration)] [[data-panel-animations=true]_&]:motion-safe:ease-out", )} > - {activeProjectScripts && ( - - )} - {showOpenInPicker && ( - - )} - {activeProjectName && ( - - )} + + } + > + + +
+ +
+ {createPortal(headerActions, actionsContainer)} + +

); diff --git a/apps/web/src/components/chat/OpenInPicker.tsx b/apps/web/src/components/chat/OpenInPicker.tsx index 2b1f252ef045..0f8f8cbc45c3 100644 --- a/apps/web/src/components/chat/OpenInPicker.tsx +++ b/apps/web/src/components/chat/OpenInPicker.tsx @@ -15,10 +15,20 @@ import { useRemoteOpenState, } from "../../remoteOpen"; import { useEnvironment } from "../../state/environments"; -import { ChevronDownIcon, FolderClosedIcon } from "lucide-react"; +import { ChevronDownIcon, FolderClosedIcon, SquareArrowOutUpRightIcon } from "lucide-react"; import { Button } from "../ui/button"; import { Group, GroupSeparator } from "../ui/group"; -import { Menu, MenuItem, MenuPopup, MenuShortcut, MenuTrigger } from "../ui/menu"; +import { + Menu, + MenuItem, + MenuItemLabel, + MenuPopup, + MenuShortcut, + MenuSub, + MenuSubTrigger, + MenuSubPopup, + MenuTrigger, +} from "../ui/menu"; import { AntigravityIcon, CursorIcon, @@ -187,6 +197,7 @@ export const OpenInPicker = memo(function OpenInPicker({ keybindings, availableEditors, openInCwd, + presentation = "toolbar", compact = false, enableShortcut = true, }: { @@ -194,6 +205,7 @@ export const OpenInPicker = memo(function OpenInPicker({ keybindings: ResolvedKeybindingsConfig; availableEditors: ReadonlyArray; openInCwd: string | null; + presentation?: "toolbar" | "menu"; compact?: boolean; enableShortcut?: boolean; }) { @@ -274,6 +286,69 @@ export const OpenInPicker = memo(function OpenInPicker({ return () => window.removeEventListener("keydown", handler); }, [enableShortcut, keybindings, openInCwd, openInEditor, preferredEditor]); + const editorItems = ( + <> + {remote.mode === "remote-unavailable" ? ( + + No SSH route to {environmentLabel} + + ) : ( + <> + {options.length === 0 && ( + + No installed editors found + + )} + {options.map(({ label, Icon, value, kind }) => ( + openInEditor(value)} + > + + ))} + {remote.mode === "remote-links" && !remoteHintSeen && ( + + Opens over SSH. Needs your key on {environmentLabel} + + )} + + )} + + ); + if (presentation === "menu") { + return ( + <> + {primaryOption && ( + openInEditor(preferredEditor)} + > + + Open in {primaryOption.label} + {openFavoriteEditorShortcutLabel && ( + {openFavoriteEditorShortcutLabel} + )} + + )} + + + + Open in… + + {editorItems} + + + ); + } + return (
); diff --git a/apps/web/src/components/ui/menu.tsx b/apps/web/src/components/ui/menu.tsx index ffcf0f374c87..3563b0ed01ac 100644 --- a/apps/web/src/components/ui/menu.tsx +++ b/apps/web/src/components/ui/menu.tsx @@ -25,6 +25,7 @@ function MenuPopup({ alignOffset, side = "bottom", anchor, + keepMounted = false, ...props }: MenuPrimitive.Popup.Props & { align?: MenuPrimitive.Positioner.Props["align"]; @@ -32,6 +33,7 @@ function MenuPopup({ alignOffset?: MenuPrimitive.Positioner.Props["alignOffset"]; side?: MenuPrimitive.Positioner.Props["side"]; anchor?: MenuPrimitive.Positioner.Props["anchor"]; + keepMounted?: boolean; }) { const hasExplicitWidthClass = typeof className === "string" && @@ -41,7 +43,7 @@ function MenuPopup({ }); return ( - + ) { + return ( + + ); +} + function MenuCheckboxItem({ className, children, @@ -247,10 +268,12 @@ function MenuSub(props: MenuPrimitive.SubmenuRoot.Props) { function MenuSubTrigger({ className, inset, + density = "default", children, ...props }: MenuPrimitive.SubmenuTrigger.Props & { inset?: boolean; + density?: "default" | "touch"; }) { return ( svg:not(:last-child)]:-mx-0.5 flex min-h-8 cursor-pointer items-center gap-2 rounded-sm px-2 py-1 text-base text-foreground outline-none data-disabled:cursor-not-allowed data-disabled:pointer-events-none data-highlighted:bg-accent data-popup-open:bg-accent data-inset:ps-8 data-highlighted:text-accent-foreground data-popup-open:text-accent-foreground data-disabled:opacity-64 sm:min-h-7 sm:text-sm [&_svg:not([class*='size-'])]:size-4.5 sm:[&_svg:not([class*='size-'])]:size-4 [&_svg:not([class*='text-'])]:text-muted-foreground [&>svg:not(:last-child):not([class*='opacity-'])]:opacity-80 [&_svg]:pointer-events-none [&>svg]:shrink-0", + density === "touch" && "min-h-10 sm:min-h-10", className, )} + data-density={density} data-inset={inset} data-slot="menu-sub-trigger" {...props} @@ -309,6 +334,7 @@ export { MenuGroup, MenuGroup as DropdownMenuGroup, MenuItem, + MenuItemLabel, MenuItem as DropdownMenuItem, MenuCheckboxItem, MenuCheckboxItem as DropdownMenuCheckboxItem, diff --git a/apps/web/src/components/ui/sidebar.tsx b/apps/web/src/components/ui/sidebar.tsx index 32405fe72b06..92f185e6f629 100644 --- a/apps/web/src/components/ui/sidebar.tsx +++ b/apps/web/src/components/ui/sidebar.tsx @@ -155,7 +155,10 @@ function SidebarProvider({
- {isOpen ? : } + {isOpen ? : } Toggle Sidebar );