-
Notifications
You must be signed in to change notification settings - Fork 6.1k
feat(web): allow customizing topbar header controls visibility via context menu #7204
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
1697afb
440583f
9d21704
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,5 +1,6 @@ | ||
| import { | ||
| type EnvironmentId, | ||
| type ContextMenuItem, | ||
| type EditorId, | ||
| type ProjectScript, | ||
| type ResolvedKeybindingsConfig, | ||
|
|
@@ -42,6 +43,8 @@ import { | |
| WorkspaceBreadcrumbItem, | ||
| WorkspaceBreadcrumbSeparator, | ||
| } from "../WorkspaceBreadcrumb"; | ||
| import { readLocalApi } from "~/localApi"; | ||
| import { type HeaderControlsVisibility, useHeaderControlsStore } from "~/headerControlsStore"; | ||
| import { cn } from "~/lib/utils"; | ||
|
|
||
| interface ChatHeaderProps { | ||
|
|
@@ -88,6 +91,25 @@ export function resolveRenameCommit(input: { | |
| return { action: "commit", title: trimmed }; | ||
| } | ||
|
|
||
| export function buildHeaderControlsContextMenuItems( | ||
| visibility: HeaderControlsVisibility, | ||
| ): readonly ContextMenuItem<keyof HeaderControlsVisibility>[] { | ||
| return [ | ||
| { | ||
| id: "scripts", | ||
| label: `${visibility.scripts ? "✓\u00A0" : "\u00A0\u00A0"}Action scripts`, | ||
| }, | ||
| { | ||
| id: "openIn", | ||
| label: `${visibility.openIn ? "✓\u00A0" : "\u00A0\u00A0"}Open in editor`, | ||
| }, | ||
| { | ||
| id: "git", | ||
| label: `${visibility.git ? "✓\u00A0" : "\u00A0\u00A0"}Git source control`, | ||
| }, | ||
| ]; | ||
| } | ||
|
|
||
| export function shouldShowOpenInPicker(input: { | ||
| readonly activeProjectName: string | undefined; | ||
| readonly activeThreadEnvironmentId: EnvironmentId; | ||
|
|
@@ -200,16 +222,39 @@ export const ChatHeader = memo(function ChatHeader({ | |
| if (!rect) return; | ||
| openMenu({ x: rect.left, y: rect.bottom + 4 }); | ||
| }, [openMenu]); | ||
| const visibility = useHeaderControlsStore((state) => state.visibility); | ||
|
|
||
| const handleHeaderActionsContextMenu = useCallback(async (event: ReactMouseEvent) => { | ||
| event.preventDefault(); | ||
| event.stopPropagation(); | ||
|
|
||
| const api = readLocalApi(); | ||
| if (!api) return; | ||
|
|
||
| const currentVisibility = useHeaderControlsStore.getState().visibility; | ||
| const items = buildHeaderControlsContextMenuItems(currentVisibility); | ||
|
|
||
| const selected = await api.contextMenu.show(items, { | ||
| x: event.clientX, | ||
| y: event.clientY, | ||
| }); | ||
|
|
||
| if (selected) { | ||
| useHeaderControlsStore.getState().toggleControl(selected); | ||
| } | ||
| }, []); | ||
|
|
||
| const handleHeaderContextMenu = useCallback( | ||
| (event: ReactMouseEvent) => { | ||
| if ((event.target as HTMLElement).closest("[data-chat-header-actions]")) { | ||
| void handleHeaderActionsContextMenu(event); | ||
| return; | ||
| } | ||
| if (!isServerThread || renamingTitle !== null) return; | ||
| // The right-side controls (git, scripts, open-in) keep their own | ||
| // behavior; only the breadcrumb area opens the thread menu. | ||
| if ((event.target as HTMLElement).closest("[data-chat-header-actions]")) return; | ||
| event.preventDefault(); | ||
| openMenu({ x: event.clientX, y: event.clientY }); | ||
| }, | ||
| [isServerThread, openMenu, renamingTitle], | ||
| [handleHeaderActionsContextMenu, isServerThread, openMenu, renamingTitle], | ||
| ); | ||
| const handleRenameKeyDown = useCallback( | ||
| (event: ReactKeyboardEvent<HTMLInputElement>) => { | ||
|
|
@@ -282,6 +327,7 @@ export const ChatHeader = memo(function ChatHeader({ | |
| <button | ||
| ref={titleButtonRef} | ||
| type="button" | ||
| data-thread-title-anchor="" | ||
| aria-label={`Thread actions for ${activeThreadTitle}`} | ||
| aria-haspopup="menu" | ||
| onClick={openMenuFromTitle} | ||
|
|
@@ -313,38 +359,45 @@ export const ChatHeader = memo(function ChatHeader({ | |
| </WorkspaceBreadcrumb> | ||
| <div | ||
| data-chat-header-actions | ||
| onContextMenu={handleHeaderActionsContextMenu} | ||
| className={cn( | ||
| "flex shrink-0 items-center justify-end gap-2 @3xl/header-actions:gap-3", | ||
| "flex h-full min-w-6 shrink-0 items-center justify-end gap-2 @3xl/header-actions:gap-3", | ||
| rightPanelOpen ? "pr-0" : "pr-16", | ||
| )} | ||
| > | ||
| {activeProjectScripts && ( | ||
| <ProjectScriptsControl | ||
| scripts={activeProjectScripts} | ||
| fileScripts={fileScripts} | ||
| keybindings={keybindings} | ||
| preferredScriptId={preferredScriptId} | ||
| onRunScript={onRunProjectScript} | ||
| onAddScript={onAddProjectScript} | ||
| onUpdateScript={onUpdateProjectScript} | ||
| onDeleteScript={onDeleteProjectScript} | ||
| /> | ||
| <div className={cn(!visibility.scripts && "hidden")}> | ||
| <ProjectScriptsControl | ||
| scripts={activeProjectScripts} | ||
| fileScripts={fileScripts} | ||
| keybindings={keybindings} | ||
| preferredScriptId={preferredScriptId} | ||
| onRunScript={onRunProjectScript} | ||
| onAddScript={onAddProjectScript} | ||
| onUpdateScript={onUpdateProjectScript} | ||
| onDeleteScript={onDeleteProjectScript} | ||
| /> | ||
| </div> | ||
| )} | ||
| {showOpenInPicker && ( | ||
| <OpenInPicker | ||
| environmentId={activeThreadEnvironmentId} | ||
| keybindings={keybindings} | ||
| availableEditors={availableEditors} | ||
| openInCwd={openInCwd} | ||
| /> | ||
| <div className={cn(!visibility.openIn && "hidden")}> | ||
| <OpenInPicker | ||
| environmentId={activeThreadEnvironmentId} | ||
| keybindings={keybindings} | ||
| availableEditors={availableEditors} | ||
| openInCwd={openInCwd} | ||
| /> | ||
| </div> | ||
| )} | ||
| {activeProjectName && ( | ||
| <GitActionsControl | ||
| gitCwd={gitCwd} | ||
| activeThreadRef={scopeThreadRef(activeThreadEnvironmentId, activeThreadId)} | ||
| onOpenPullRequest={onOpenPullRequest} | ||
| {...(draftId ? { draftId } : {})} | ||
| /> | ||
| <div className={cn(!visibility.git && "hidden")}> | ||
| <GitActionsControl | ||
| gitCwd={gitCwd} | ||
| activeThreadRef={scopeThreadRef(activeThreadEnvironmentId, activeThreadId)} | ||
| onOpenPullRequest={onOpenPullRequest} | ||
| {...(draftId ? { draftId } : {})} | ||
| /> | ||
| </div> | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Hidden git control stays activeLow Severity Hiding Git source control now only applies Tailwind Reviewed by Cursor Bugbot for commit 9d21704. Configure here. |
||
| )} | ||
| </div> | ||
| </div> | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,49 @@ | ||
| import { beforeEach, describe, expect, it } from "vite-plus/test"; | ||
| import { useHeaderControlsStore } from "./headerControlsStore"; | ||
|
|
||
| describe("headerControlsStore", () => { | ||
| beforeEach(() => { | ||
| useHeaderControlsStore.getState().resetVisibility(); | ||
| }); | ||
|
|
||
| it("initializes with all controls visible by default", () => { | ||
| const { visibility } = useHeaderControlsStore.getState(); | ||
| expect(visibility).toEqual({ | ||
| scripts: true, | ||
| openIn: true, | ||
| git: true, | ||
| }); | ||
| }); | ||
|
|
||
| it("toggles individual control visibility", () => { | ||
| useHeaderControlsStore.getState().toggleControl("scripts"); | ||
| expect(useHeaderControlsStore.getState().visibility.scripts).toBe(false); | ||
| expect(useHeaderControlsStore.getState().visibility.openIn).toBe(true); | ||
| expect(useHeaderControlsStore.getState().visibility.git).toBe(true); | ||
|
|
||
| useHeaderControlsStore.getState().toggleControl("scripts"); | ||
| expect(useHeaderControlsStore.getState().visibility.scripts).toBe(true); | ||
| }); | ||
|
|
||
| it("sets specific control visibility", () => { | ||
| useHeaderControlsStore.getState().setControlVisibility("openIn", false); | ||
| expect(useHeaderControlsStore.getState().visibility.openIn).toBe(false); | ||
|
|
||
| useHeaderControlsStore.getState().setControlVisibility("openIn", true); | ||
| expect(useHeaderControlsStore.getState().visibility.openIn).toBe(true); | ||
| }); | ||
|
|
||
| it("resets visibility to defaults", () => { | ||
| useHeaderControlsStore.getState().setControlVisibility("scripts", false); | ||
| useHeaderControlsStore.getState().setControlVisibility("git", false); | ||
| expect(useHeaderControlsStore.getState().visibility.scripts).toBe(false); | ||
| expect(useHeaderControlsStore.getState().visibility.git).toBe(false); | ||
|
|
||
| useHeaderControlsStore.getState().resetVisibility(); | ||
| expect(useHeaderControlsStore.getState().visibility).toEqual({ | ||
| scripts: true, | ||
| openIn: true, | ||
| git: true, | ||
| }); | ||
| }); | ||
| }); |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,52 @@ | ||
| import { create } from "zustand"; | ||
| import { createJSONStorage, persist } from "zustand/middleware"; | ||
| import { resolveStorage } from "./lib/storage"; | ||
|
|
||
| export interface HeaderControlsVisibility { | ||
| scripts: boolean; | ||
| openIn: boolean; | ||
| git: boolean; | ||
| } | ||
|
|
||
| export interface HeaderControlsStoreState { | ||
| visibility: HeaderControlsVisibility; | ||
| toggleControl: (control: keyof HeaderControlsVisibility) => void; | ||
| setControlVisibility: (control: keyof HeaderControlsVisibility, visible: boolean) => void; | ||
| resetVisibility: () => void; | ||
| } | ||
|
|
||
| const DEFAULT_VISIBILITY: HeaderControlsVisibility = { | ||
| scripts: true, | ||
| openIn: true, | ||
| git: true, | ||
| }; | ||
|
|
||
| export const useHeaderControlsStore = create<HeaderControlsStoreState>()( | ||
| persist( | ||
| (set) => ({ | ||
| visibility: DEFAULT_VISIBILITY, | ||
| toggleControl: (control) => | ||
| set((state) => ({ | ||
| visibility: { | ||
| ...state.visibility, | ||
| [control]: !state.visibility[control], | ||
| }, | ||
| })), | ||
| setControlVisibility: (control, visible) => | ||
| set((state) => ({ | ||
| visibility: { | ||
| ...state.visibility, | ||
| [control]: visible, | ||
| }, | ||
| })), | ||
| resetVisibility: () => set({ visibility: DEFAULT_VISIBILITY }), | ||
| }), | ||
| { | ||
| name: "t3code:header-controls:v1", | ||
| version: 1, | ||
| storage: createJSONStorage(() => | ||
| resolveStorage(typeof window !== "undefined" ? window.localStorage : undefined), | ||
| ), | ||
| }, | ||
| ), | ||
| ); |


There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
h-fullhere resolves toauto: the actions cluster's containing block is the ChatHeader root (ChatHeader.tsx:274), which is a flex item of theitems-centertopbar<header>(ChatView.tsx:6214) and therefore has an indefinite (content) height, soheight: 100%never picks up--workspace-topbar-height. With every control toggled off the three wrappers aredisplay:none, so this container has zero content height —min-w-6gives width but the box is 24x0 and cannot be right-clicked. Since this context menu is the only way to re-enable the controls and the choice is persisted in localStorage, hiding all three leaves the header controls unrecoverable.Consider stretching to the row instead of relying on a percentage height, so the empty region keeps a real hit area:
Posted via Macroscope — UI Consistency