From 009284aface733f390e37e139931cf56692c03b6 Mon Sep 17 00:00:00 2001 From: maria-rcks Date: Mon, 21 Sep 2026 00:06:55 +0000 Subject: [PATCH 1/3] feat(web): port compact workspace menu from ov2 --- apps/web/src/components/BranchToolbar.tsx | 57 +++++ .../BranchToolbarBranchSelector.tsx | 56 ++++- .../BranchToolbarEnvModeSelector.tsx | 81 ++++++-- .../BranchToolbarEnvironmentSelector.tsx | 55 ++++- apps/web/src/components/ChatView.tsx | 33 +++ apps/web/src/components/GitActionsControl.tsx | 117 +++++++++-- .../src/components/ProjectScriptsControl.tsx | 196 ++++++++++++++---- apps/web/src/components/chat/ChatHeader.tsx | 150 +++++--------- apps/web/src/components/chat/OpenInPicker.tsx | 78 +++++-- .../chat/threadDetailsPanelStyles.ts | 36 ++++ 10 files changed, 663 insertions(+), 196 deletions(-) create mode 100644 apps/web/src/components/chat/threadDetailsPanelStyles.ts diff --git a/apps/web/src/components/BranchToolbar.tsx b/apps/web/src/components/BranchToolbar.tsx index cdf3aeffbea18..491f0aa35685a 100644 --- a/apps/web/src/components/BranchToolbar.tsx +++ b/apps/web/src/components/BranchToolbar.tsx @@ -66,6 +66,8 @@ export interface BranchToolbarHandle { } interface BranchToolbarProps { + layout?: "composer" | "panel"; + panelSection?: "all" | "workspace" | "branch"; forceNewWorktree?: boolean; ref?: Ref; environmentId: EnvironmentId; @@ -483,6 +485,8 @@ function useLabelsOverflow(element: HTMLDivElement | null): boolean { } export const BranchToolbar = memo(function BranchToolbar({ + layout = "composer", + panelSection = "all", forceNewWorktree = false, ref, environmentId, @@ -602,6 +606,59 @@ export const BranchToolbar = memo(function BranchToolbar({ if (!hasActiveThread || !activeProject) return null; + if (layout === "panel") { + return ( +
+ {panelSection !== "branch" && showEnvironmentIndicator && availableEnvironments ? ( + + ) : null} + {panelSection !== "branch" ? ( + + ) : null} + {panelSection !== "workspace" && showGitControls ? ( + + ) : null} +
+ ); + } + return ( ; className?: string; @@ -100,6 +107,7 @@ function toBranchActionErrorMessage(error: unknown): string { } export function BranchToolbarBranchSelector({ + displayMode = "toolbar", forceNewWorktree = false, ref, className, @@ -785,7 +793,11 @@ export function BranchToolbarBranchSelector({ value={resolvedActiveBranch} >
handleBranchContextMenu(event, resolvedActiveBranch)} > } + render={
} - className="inline-flex h-7 min-w-0 items-center gap-1 border border-transparent px-[calc(--spacing(2)-1px)] font-normal text-muted-foreground/70 text-xs sm:h-6" + className={cn( + "inline-flex h-7 min-w-0 items-center gap-1 border border-transparent px-[calc(--spacing(2)-1px)] font-normal text-muted-foreground/70 text-xs sm:h-6", + displayMode === "panel" && THREAD_DETAILS_PANEL_LOCKED_ROW_CLASS, + )} data-composer-context-control > {forceNewWorktree ? ( - + ) : activeWorktreePath ? ( - + ) : ( - + )} {effectiveEnvMode === "worktree" ? ( - + ) : activeWorktreePath ? ( - + ) : ( - + )} + {displayMode === "panel" && effectiveEnvMode === "worktree" && !activeWorktreePath ? ( + + Create + + ) : null} {effectiveEnvMode === "worktree" @@ -142,7 +194,12 @@ export const BranchToolbarEnvModeSelector = memo(function BranchToolbarEnvModeSe : resolveCurrentWorkspaceLabel(activeWorktreePath)} - + Workspace diff --git a/apps/web/src/components/BranchToolbarEnvironmentSelector.tsx b/apps/web/src/components/BranchToolbarEnvironmentSelector.tsx index a29bb92e83d00..084d411fbff86 100644 --- a/apps/web/src/components/BranchToolbarEnvironmentSelector.tsx +++ b/apps/web/src/components/BranchToolbarEnvironmentSelector.tsx @@ -1,3 +1,10 @@ +import { cn } from "../lib/utils"; +import { + THREAD_DETAILS_PANEL_ICON_CLASS, + THREAD_DETAILS_PANEL_LOCKED_ROW_CLASS, + THREAD_DETAILS_PANEL_SELECT_ROW_CLASS, + THREAD_DETAILS_PANEL_ROW_POPUP_CLASS, +} from "./chat/threadDetailsPanelStyles"; import type { EnvironmentId } from "@t3tools/contracts"; import { ScaleIcon } from "lucide-react"; import { memo, useMemo } from "react"; @@ -17,6 +24,7 @@ import { import { Tooltip, TooltipPopup, TooltipTrigger } from "./ui/tooltip"; interface BranchToolbarEnvironmentSelectorProps { + displayMode?: "toolbar" | "panel"; autoEnvironmentLabel?: string | undefined; onAutoEnvironment?: (() => void) | undefined; envLocked: boolean; @@ -28,6 +36,7 @@ interface BranchToolbarEnvironmentSelectorProps { } export const BranchToolbarEnvironmentSelector = memo(function BranchToolbarEnvironmentSelector({ + displayMode = "toolbar", autoEnvironmentLabel, onAutoEnvironment, envLocked, @@ -63,16 +72,25 @@ export const BranchToolbarEnvironmentSelector = memo(function BranchToolbarEnvir } - className="inline-flex h-7 min-w-0 max-w-full items-center gap-1 border border-transparent px-[calc(--spacing(2)-1px)] font-normal text-muted-foreground/70 text-xs sm:h-6" + className={cn( + "inline-flex h-7 min-w-0 max-w-full items-center gap-1 border border-transparent px-[calc(--spacing(2)-1px)] font-normal text-muted-foreground/70 text-xs sm:h-6", + displayMode === "panel" && THREAD_DETAILS_PANEL_LOCKED_ROW_CLASS, + )} data-composer-context-control > {autoEnvironmentLabel ? ( - - + Run on {onAutoEnvironment && ( diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 667eb0e11f6c6..3e125b26ea8b3 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -9819,6 +9819,39 @@ export default function ChatView(props: ChatViewProps) { keybindings={keybindings} availableEditors={availableEditors} rightPanelOpen={rightPanelOpen} + branchToolbarProps={{ + forceNewWorktree: multipleModelSelections !== null, + environmentId: activeThread.environmentId, + threadId: activeThread.id, + showGitControls: isGitRepo, + ...(routeKind === "draft" && draftId ? { draftId } : {}), + onEnvModeChange, + startFromOrigin, + onStartFromOriginChange, + ...(canOverrideServerThreadEnvMode + ? { + effectiveEnvModeOverride: envMode, + activeThreadBranchOverride: activeThreadBranch, + onActiveThreadBranchOverrideChange: setPendingServerThreadBranch, + } + : {}), + envLocked, + onComposerFocusRequest: scheduleComposerFocus, + ...(canCheckoutPullRequestIntoThread + ? { onCheckoutPullRequestRequest: openPullRequestDialog } + : {}), + ...(hasMultipleEnvironments ? { onEnvironmentChange } : {}), + autoEnvironmentLabel, + onAutoEnvironment: + draftId && + !envLocked && + hasMultipleEnvironments && + loadBalancingSettings.loadBalancingEnabled + ? onAutoEnvironment + : undefined, + availableEnvironments: logicalProjectEnvironments, + }} + onOpenChanges={isServerThread && isGitRepo ? addDiffSurface : undefined} gitCwd={gitCwd} onNewThreadInProject={handleNewThreadInActiveProject} {...(activeDraftLogicalProjectKey diff --git a/apps/web/src/components/GitActionsControl.tsx b/apps/web/src/components/GitActionsControl.tsx index 92c28560135e2..10ce192e7079a 100644 --- a/apps/web/src/components/GitActionsControl.tsx +++ b/apps/web/src/components/GitActionsControl.tsx @@ -32,6 +32,7 @@ import { ChevronDownIcon, CloudDownloadIcon, CloudUploadIcon, + FileDiffIcon, GitBranchPlusIcon, GitCommitIcon, InfoIcon, @@ -115,9 +116,21 @@ import { type DraftId, useComposerDraftStore } from "~/composerDraftStore"; import { getSourceControlPresentation } from "~/sourceControlPresentation"; import { useOpenLink } from "~/browser/useOpenLink"; import { useOpenPrLink } from "~/lib/openPullRequestLink"; +import { + THREAD_DETAILS_PANEL_CHEVRON_CLASS, + THREAD_DETAILS_PANEL_ICON_CLASS, + THREAD_DETAILS_PANEL_ROW_CLASS, + THREAD_DETAILS_PANEL_ROW_POPUP_CLASS, + THREAD_DETAILS_PANEL_SPLIT_GROUP_CLASS, + THREAD_DETAILS_PANEL_SPLIT_PRIMARY_CLASS, + THREAD_DETAILS_PANEL_SPLIT_SECONDARY_CLASS, + THREAD_DETAILS_PANEL_SPLIT_SEPARATOR_CLASS, +} from "./chat/threadDetailsPanelStyles"; interface GitActionsControlProps { presentation?: "toolbar" | "menu"; + displayMode?: "toolbar" | "panel"; + onOpenChanges?: () => void; gitCwd: string | null; activeThreadRef: ScopedThreadRef | null; draftId?: DraftId; @@ -956,11 +969,16 @@ function PublishRepositoryDialog(props: PublishRepositoryDialogProps) { export default function GitActionsControl({ presentation = "toolbar", + displayMode = "toolbar", + onOpenChanges, gitCwd, activeThreadRef, draftId, onOpenPullRequest, }: GitActionsControlProps) { + const isPanel = displayMode === "panel"; + const ActionGroup = isPanel ? "div" : Group; + const panelAnchorRef = useRef(null); const updateThreadMetadata = useAtomCommand( threadEnvironment.updateMetadata, "thread branch metadata update", @@ -1798,14 +1816,25 @@ export default function GitActionsControl({ ) ) : !isRepo ? ( - ) : ( - + {quickActionDisabledReason ? ( } > - + {quickAction.label} @@ -1833,19 +1872,33 @@ export default function GitActionsControl({ ) : ( )} - + {isPanel ? ( + + )} + {isPanel && isRepo ? ( + + ) : null} + { diff --git a/apps/web/src/components/ProjectScriptsControl.tsx b/apps/web/src/components/ProjectScriptsControl.tsx index 9736821afa67c..58ab1eb986db7 100644 --- a/apps/web/src/components/ProjectScriptsControl.tsx +++ b/apps/web/src/components/ProjectScriptsControl.tsx @@ -7,8 +7,8 @@ import { isAtomCommandInterrupted, squashAtomCommandFailure, } from "@t3tools/client-runtime/state/runtime"; -import { ChevronDownIcon, DownloadIcon, PlusIcon, SettingsIcon } from "lucide-react"; -import { useCallback, useMemo, useState } from "react"; +import { ChevronDownIcon, DownloadIcon, PlusIcon, SettingsIcon, WrenchIcon } from "lucide-react"; +import { useCallback, useMemo, useRef, useState } from "react"; import { commandForProjectScript, primaryProjectScript } from "~/projectScripts"; import { shortcutLabelForCommand } from "~/keybindings"; @@ -38,12 +38,24 @@ import { MenuSubPopup, } from "./ui/menu"; import { Tooltip, TooltipPopup, TooltipTrigger } from "./ui/tooltip"; +import { cn } from "~/lib/utils"; +import { + THREAD_DETAILS_PANEL_CHEVRON_CLASS, + THREAD_DETAILS_PANEL_ICON_CLASS, + THREAD_DETAILS_PANEL_ROW_POPUP_CLASS, + THREAD_DETAILS_PANEL_ROW_CLASS, + THREAD_DETAILS_PANEL_SPLIT_GROUP_CLASS, + THREAD_DETAILS_PANEL_SPLIT_PRIMARY_CLASS, + THREAD_DETAILS_PANEL_SPLIT_SECONDARY_CLASS, + THREAD_DETAILS_PANEL_SPLIT_SEPARATOR_CLASS, +} from "./chat/threadDetailsPanelStyles"; export type { NewProjectScriptInput, ProjectScriptActionResult }; const NO_FILE_SCRIPTS: ReadonlyArray = []; interface ProjectScriptsControlProps { + displayMode?: "toolbar" | "panel"; presentation?: "toolbar" | "menu"; onRequestMenuClose?: () => void; scripts: ReadonlyArray; @@ -61,6 +73,7 @@ interface ProjectScriptsControlProps { } export default function ProjectScriptsControl({ + displayMode = "toolbar", presentation = "toolbar", onRequestMenuClose, scripts, @@ -72,6 +85,9 @@ export default function ProjectScriptsControl({ onUpdateScript, onDeleteScript, }: ProjectScriptsControlProps) { + const isPanel = displayMode === "panel"; + const ActionGroup = isPanel ? "div" : Group; + const panelAnchorRef = useRef(null); const [actionsMenuOpen, setActionsMenuOpen] = useState({ presentation, scripts: false, @@ -273,14 +289,24 @@ export default function ProjectScriptsControl({ )} ) : primaryScript ? ( - + } > - - + + {primaryScript.name} Run {primaryScript.name} - + {isPanel ? ( + + ) : importableScripts.length > 0 ? ( - - setActionsMenuOpen({ presentation, scripts: false, imports: open }) - } - > - }> - - - Add action - - - - - {importMenuItems} - - - Add action - - - + isPanel ? ( +
+ +
+ ) : ( + + setActionsMenuOpen({ presentation, scripts: false, imports: open }) + } + > + } + > + + + Add action + + + + + {importMenuItems} + + + Add action + + + + ) ) : ( - - Add action + + {isPanel ? "Add project script" : "Add action"} - Add action + {isPanel ? "Add project script" : "Add action"} )} diff --git a/apps/web/src/components/chat/ChatHeader.tsx b/apps/web/src/components/chat/ChatHeader.tsx index 1c8bc520485fc..5ad6391fe8a73 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, EllipsisIcon } from "lucide-react"; +import { ChevronDownIcon, ListFilterIcon } from "lucide-react"; import { memo, useCallback, @@ -19,6 +19,7 @@ import { useMemo, useRef, useState, + type ComponentProps, type KeyboardEvent as ReactKeyboardEvent, type MouseEvent as ReactMouseEvent, } from "react"; @@ -40,7 +41,7 @@ import { useThreadActionMenu } from "~/hooks/useThreadActionMenu"; import { readLocalApi } from "~/localApi"; import { threadEnvironment } from "../../state/threads"; import { useAtomCommand } from "../../state/use-atom-command"; -import { observeResponsiveBreakpointFade, usePanelAnimationSettings } from "../../panelAnimations"; +import { BranchToolbar } from "../BranchToolbar"; import { ProjectFavicon } from "../ProjectFavicon"; import { WorkspaceBreadcrumb, @@ -49,9 +50,8 @@ import { 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"; +import { Popover, PopoverPopup, PopoverTrigger } from "../ui/popover"; interface ChatHeaderProps { activeThreadEnvironmentId: EnvironmentId; @@ -67,6 +67,8 @@ interface ChatHeaderProps { keybindings: ResolvedKeybindingsConfig; availableEditors: ReadonlyArray; rightPanelOpen: boolean; + branchToolbarProps: ComponentProps; + onOpenChanges?: (() => void) | undefined; gitCwd: string | null; readonly onOpenPullRequest?: ((number: number) => void) | undefined; onNewThreadInProject: () => void; @@ -101,8 +103,6 @@ export function resolveRenameCommit(input: { // events (the second click dismisses it and dblclick still fires), so it // opens immediately. const TITLE_MENU_OPEN_DELAY_MS = 500; -// Matches the @3xl/header-actions container breakpoint owned by this header. -const HEADER_ACTIONS_EXPANDED_BREAKPOINT_REM = 48; export function shouldShowOpenInPicker(input: { readonly activeProjectName: string | undefined; @@ -136,6 +136,8 @@ export const ChatHeader = memo(function ChatHeader({ keybindings, availableEditors, rightPanelOpen, + branchToolbarProps, + onOpenChanges, gitCwd, onOpenPullRequest, onNewThreadInProject, @@ -145,55 +147,19 @@ export const ChatHeader = memo(function ChatHeader({ onUpdateProjectScript, onDeleteProjectScript, }: ChatHeaderProps) { - const { active: panelAnimationsActive, durationMs: panelAnimationDurationMs } = - usePanelAnimationSettings(); - const headerActionsRef = useRef(null); - useEffect(() => { - const actions = headerActionsRef.current; - const container = actions?.parentElement; - if (!actions || !container) return; - return observeResponsiveBreakpointFade({ - target: actions, - container, - active: panelAnimationsActive, - durationMs: panelAnimationDurationMs, - 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], - ); + // Keep controls mounted while the popup is closed so shortcuts and open dialogs survive. const mountMenuActions = useCallback( (node: HTMLDivElement | null) => { - if (node && actionsCollapsed) node.appendChild(actionsContainer); + if (node) node.appendChild(actionsContainer); }, - [actionsContainer, actionsCollapsed], + [actionsContainer], ); - if (!actionsCollapsed && actionsOpen) setActionsOpen(false); const primaryEnvironmentId = usePrimaryEnvironmentId(); const activeProjectName = activeProject?.title; const activeProjectCwd = activeProject?.workspaceRoot ?? null; @@ -353,11 +319,22 @@ export const ChatHeader = memo(function ChatHeader({ ); const headerActions = ( <> - {activeProjectScripts && ( - <> +
+

Workspace

+ + {showOpenInPicker && ( + + )} + {activeProjectScripts && ( setActionsOpen(false)} - presentation={actionsCollapsed ? "menu" : "toolbar"} scripts={activeProjectScripts} fileScripts={fileScripts} keybindings={keybindings} @@ -367,31 +344,25 @@ export const ChatHeader = memo(function ChatHeader({ onUpdateScript={onUpdateProjectScript} onDeleteScript={onDeleteProjectScript} /> - - )} - {showOpenInPicker && ( - <> - {actionsCollapsed && activeProjectScripts && } - - - )} - {activeProjectName && gitCwd && ( - <> - {actionsCollapsed && (activeProjectScripts || showOpenInPicker) && } - - + )} +
+ {gitCwd && ( +
+

Version Control

+ {branchToolbarProps.showGitControls && ( + + )} + {activeProjectName && ( + + )} +
)} ); @@ -488,7 +459,6 @@ export const ChatHeader = memo(function ChatHeader({
- - } + + } > - - -
- + +
- {createPortal(headerActions, actionsContainer)} - -
+ + {createPortal(headerActions, actionsContainer)} +
); diff --git a/apps/web/src/components/chat/OpenInPicker.tsx b/apps/web/src/components/chat/OpenInPicker.tsx index 0f8f8cbc45c36..e6b02b50822c3 100644 --- a/apps/web/src/components/chat/OpenInPicker.tsx +++ b/apps/web/src/components/chat/OpenInPicker.tsx @@ -4,7 +4,7 @@ import { type EnvironmentId, type ResolvedKeybindingsConfig, } from "@t3tools/contracts"; -import { memo, useCallback, useEffect, useMemo } from "react"; +import { memo, useCallback, useEffect, useMemo, useRef } from "react"; import { isOpenFavoriteEditorShortcut, shortcutLabelForCommand } from "../../keybindings"; import { usePreferredEditor } from "../../editorPreferences"; import { editorLabelForPlatform } from "../../editorLabels"; @@ -59,6 +59,15 @@ import { import { cn, isMacPlatform, isWindowsPlatform } from "~/lib/utils"; import { shellEnvironment } from "~/state/shell"; import { useAtomCommand } from "~/state/use-atom-command"; +import { + THREAD_DETAILS_PANEL_CHEVRON_CLASS, + THREAD_DETAILS_PANEL_ICON_CLASS, + THREAD_DETAILS_PANEL_ROW_POPUP_CLASS, + THREAD_DETAILS_PANEL_SPLIT_GROUP_CLASS, + THREAD_DETAILS_PANEL_SPLIT_PRIMARY_CLASS, + THREAD_DETAILS_PANEL_SPLIT_SECONDARY_CLASS, + THREAD_DETAILS_PANEL_SPLIT_SEPARATOR_CLASS, +} from "./threadDetailsPanelStyles"; type OpenInOption = { label: string; @@ -198,6 +207,7 @@ export const OpenInPicker = memo(function OpenInPicker({ availableEditors, openInCwd, presentation = "toolbar", + displayMode = "toolbar", compact = false, enableShortcut = true, }: { @@ -206,9 +216,13 @@ export const OpenInPicker = memo(function OpenInPicker({ availableEditors: ReadonlyArray; openInCwd: string | null; presentation?: "toolbar" | "menu"; + displayMode?: "toolbar" | "panel"; compact?: boolean; enableShortcut?: boolean; }) { + const isPanel = displayMode === "panel"; + const ActionGroup = isPanel ? "div" : Group; + const panelAnchorRef = useRef(null); const openInEditorMutation = useAtomCommand(shellEnvironment.openInEditor, "open in editor"); const remote = useRemoteOpenState(environmentId); const remoteCapableEditors = useRemoteCapableEditors(); @@ -349,41 +363,73 @@ export const OpenInPicker = memo(function OpenInPicker({ ); } + const primaryLabel = isPanel ? `Open in ${primaryOption?.label ?? "editor"}` : "Open"; + return ( - + - + {isPanel ? ( + + ); }); diff --git a/apps/web/src/components/chat/threadDetailsPanelStyles.ts b/apps/web/src/components/chat/threadDetailsPanelStyles.ts new file mode 100644 index 0000000000000..6fa74967489fb --- /dev/null +++ b/apps/web/src/components/chat/threadDetailsPanelStyles.ts @@ -0,0 +1,36 @@ +/** + * Visual primitives shared by controls when they are rendered in the thread details panel. + * + * The core control variants intentionally become denser at the `sm` breakpoint. The panel has + * its own fixed density, so every size and type override here includes its desktop counterpart. + */ +const THREAD_DETAILS_PANEL_RESTING_BUTTON_SURFACE_CLASS = + "bg-transparent shadow-none before:shadow-none not-disabled:not-active:not-data-pressed:before:shadow-none"; + +const THREAD_DETAILS_PANEL_HOVER_SURFACE_CLASS = + "hover:!bg-black/[0.055] data-pressed:!bg-black/[0.055] dark:hover:!bg-white/[0.075] dark:data-pressed:!bg-white/[0.075]"; + +const THREAD_DETAILS_PANEL_ROW_SURFACE_CLASS = `${THREAD_DETAILS_PANEL_RESTING_BUTTON_SURFACE_CLASS} ${THREAD_DETAILS_PANEL_HOVER_SURFACE_CLASS}`; + +const THREAD_DETAILS_PANEL_SPLIT_BUTTON_SURFACE_CLASS = `${THREAD_DETAILS_PANEL_RESTING_BUTTON_SURFACE_CLASS} hover:!bg-transparent data-pressed:!bg-transparent`; + +export const THREAD_DETAILS_PANEL_ROW_CLASS = `h-7 w-full justify-start gap-2 rounded-md border-transparent bg-transparent px-2 text-xs font-medium text-foreground/80 sm:h-7 sm:text-xs ${THREAD_DETAILS_PANEL_ROW_SURFACE_CLASS}`; + +export const THREAD_DETAILS_PANEL_SELECT_ROW_CLASS = `${THREAD_DETAILS_PANEL_ROW_CLASS} pe-0 before:pointer-events-none before:absolute before:inset-0 [&_[data-slot=select-icon]]:-me-px [&_[data-slot=select-icon]]:relative [&_[data-slot=select-icon]]:flex [&_[data-slot=select-icon]]:h-full [&_[data-slot=select-icon]]:w-8 [&_[data-slot=select-icon]]:shrink-0 [&_[data-slot=select-icon]]:items-center [&_[data-slot=select-icon]]:justify-center [&_[data-slot=select-icon]]:before:absolute [&_[data-slot=select-icon]]:before:-left-px [&_[data-slot=select-icon]]:before:top-1/2 [&_[data-slot=select-icon]]:before:h-4 [&_[data-slot=select-icon]]:before:w-px [&_[data-slot=select-icon]]:before:-translate-y-1/2 [&_[data-slot=select-icon]]:before:bg-border/65 [&_[data-slot=select-icon]>svg]:me-0 [&_[data-slot=select-icon]>svg]:size-4 [&_[data-slot=select-icon]>svg]:text-muted-foreground [&_[data-slot=select-icon]>svg]:opacity-100`; + +export const THREAD_DETAILS_PANEL_LOCKED_ROW_CLASS = + "h-7 w-full justify-start gap-2 rounded-md border border-transparent px-2 text-xs font-medium text-foreground/80 sm:h-7 sm:text-xs"; + +export const THREAD_DETAILS_PANEL_ICON_CLASS = "-mx-0.5 size-4 shrink-0 text-muted-foreground"; + +export const THREAD_DETAILS_PANEL_CHEVRON_CLASS = "size-4 shrink-0 text-muted-foreground"; + +export const THREAD_DETAILS_PANEL_SPLIT_GROUP_CLASS = `group/thread-details-action flex w-full items-center rounded-md ${THREAD_DETAILS_PANEL_HOVER_SURFACE_CLASS}`; + +export const THREAD_DETAILS_PANEL_SPLIT_PRIMARY_CLASS = `h-7 min-w-0 flex-1 justify-start gap-2 rounded-e-none border-transparent bg-transparent px-2 pr-2 text-xs font-medium text-foreground/80 sm:h-7 sm:text-xs ${THREAD_DETAILS_PANEL_SPLIT_BUTTON_SURFACE_CLASS}`; + +export const THREAD_DETAILS_PANEL_SPLIT_SECONDARY_CLASS = `h-7 w-8 rounded-s-none border-transparent bg-transparent px-0 sm:h-7 sm:w-8 ${THREAD_DETAILS_PANEL_SPLIT_BUTTON_SURFACE_CLASS}`; + +export const THREAD_DETAILS_PANEL_SPLIT_SEPARATOR_CLASS = "h-4 w-px shrink-0 bg-border/65"; + +export const THREAD_DETAILS_PANEL_ROW_POPUP_CLASS = "w-(--anchor-width)"; From 78d3bb56af315c5b524164c73a5dd23d3c1cf22b Mon Sep 17 00:00:00 2001 From: maria-rcks Date: Mon, 21 Sep 2026 00:22:29 +0000 Subject: [PATCH 2/3] fix(web): preserve workspace menu dialogs and compact controls --- apps/web/src/components/BranchToolbar.tsx | 2 +- .../components/BranchToolbarEnvModeSelector.tsx | 2 +- .../BranchToolbarEnvironmentSelector.tsx | 2 +- .../src/components/ProjectScriptsControl.tsx | 4 ++-- apps/web/src/components/chat/ChatHeader.tsx | 17 ++--------------- apps/web/src/components/ui/popover.tsx | 11 ++++++++--- 6 files changed, 15 insertions(+), 23 deletions(-) diff --git a/apps/web/src/components/BranchToolbar.tsx b/apps/web/src/components/BranchToolbar.tsx index 491f0aa35685a..e0ccfe3440a2e 100644 --- a/apps/web/src/components/BranchToolbar.tsx +++ b/apps/web/src/components/BranchToolbar.tsx @@ -620,7 +620,7 @@ export const BranchToolbar = memo(function BranchToolbar({ {...(showEnvironmentPicker && onEnvironmentChange ? { onEnvironmentChange } : {})} /> ) : null} - {panelSection !== "branch" ? ( + {panelSection !== "branch" && showGitControls ? ( { event.preventDefault(); diff --git a/apps/web/src/components/chat/ChatHeader.tsx b/apps/web/src/components/chat/ChatHeader.tsx index 5ad6391fe8a73..8f1ce0e17ada0 100644 --- a/apps/web/src/components/chat/ChatHeader.tsx +++ b/apps/web/src/components/chat/ChatHeader.tsx @@ -23,7 +23,6 @@ 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"; @@ -148,18 +147,6 @@ export const ChatHeader = memo(function ChatHeader({ onDeleteProjectScript, }: ChatHeaderProps) { const [actionsOpen, setActionsOpen] = useState(false); - const [actionsContainer] = useState(() => { - const container = document.createElement("div"); - container.className = "contents"; - return container; - }); - // Keep controls mounted while the popup is closed so shortcuts and open dialogs survive. - const mountMenuActions = useCallback( - (node: HTMLDivElement | null) => { - if (node) node.appendChild(actionsContainer); - }, - [actionsContainer], - ); const primaryEnvironmentId = usePrimaryEnvironmentId(); const activeProjectName = activeProject?.title; const activeProjectCwd = activeProject?.workspaceRoot ?? null; @@ -475,15 +462,15 @@ export const ChatHeader = memo(function ChatHeader({ -
+ {headerActions} - {createPortal(headerActions, actionsContainer)}
diff --git a/apps/web/src/components/ui/popover.tsx b/apps/web/src/components/ui/popover.tsx index 4bc76706ab2b0..6ac8de317154d 100644 --- a/apps/web/src/components/ui/popover.tsx +++ b/apps/web/src/components/ui/popover.tsx @@ -26,6 +26,7 @@ function PopoverPopup({ alignOffset = 0, tooltipStyle = false, anchor, + keepMounted = false, ...props }: PopoverPrimitive.Popup.Props & { viewportClassName?: string; @@ -35,9 +36,13 @@ function PopoverPopup({ alignOffset?: PopoverPrimitive.Positioner.Props["alignOffset"]; tooltipStyle?: boolean; anchor?: PopoverPrimitive.Positioner.Props["anchor"]; + keepMounted?: boolean; }) { + // The animated viewport keys children by the active trigger, including closing it. + // Persistent controls need a stable host so their dialogs and shortcuts survive. + const Viewport = keepMounted ? "div" : PopoverPrimitive.Viewport; return ( - + - {children} - + From 8bdb83890b0923d750df0a9a1a4fe7a4ced8a31a Mon Sep 17 00:00:00 2001 From: maria-rcks Date: Mon, 21 Sep 2026 01:46:33 +0000 Subject: [PATCH 3/3] fix(web): add spacing to workspace menu --- apps/web/src/components/BranchToolbar.tsx | 2 +- apps/web/src/components/chat/ChatHeader.tsx | 7 +++++-- 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/apps/web/src/components/BranchToolbar.tsx b/apps/web/src/components/BranchToolbar.tsx index e0ccfe3440a2e..48759d2a74093 100644 --- a/apps/web/src/components/BranchToolbar.tsx +++ b/apps/web/src/components/BranchToolbar.tsx @@ -608,7 +608,7 @@ export const BranchToolbar = memo(function BranchToolbar({ if (layout === "panel") { return ( -
+
{panelSection !== "branch" && showEnvironmentIndicator && availableEnvironments ? ( -
+

Workspace

{showOpenInPicker && ( @@ -334,7 +334,10 @@ export const ChatHeader = memo(function ChatHeader({ )}
{gitCwd && ( -
+

Version Control

{branchToolbarProps.showGitControls && (