From 1697afb578e5005de9012ea736d34f8048e075a5 Mon Sep 17 00:00:00 2001 From: Brian Pravato Date: Sun, 16 Aug 2026 07:54:49 -0300 Subject: [PATCH 1/3] feat(web): allow customizing topbar header controls visibility via context menu --- .../src/components/chat/ChatHeader.test.ts | 34 ++++++++- apps/web/src/components/chat/ChatHeader.tsx | 76 ++++++++++++++++--- apps/web/src/headerControlsStore.test.ts | 49 ++++++++++++ apps/web/src/headerControlsStore.ts | 52 +++++++++++++ 4 files changed, 200 insertions(+), 11 deletions(-) create mode 100644 apps/web/src/headerControlsStore.test.ts create mode 100644 apps/web/src/headerControlsStore.ts diff --git a/apps/web/src/components/chat/ChatHeader.test.ts b/apps/web/src/components/chat/ChatHeader.test.ts index a200a2069240..fd2bf149c246 100644 --- a/apps/web/src/components/chat/ChatHeader.test.ts +++ b/apps/web/src/components/chat/ChatHeader.test.ts @@ -1,7 +1,11 @@ import { EnvironmentId } from "@t3tools/contracts"; import { describe, expect, it } from "vite-plus/test"; -import { resolveRenameCommit, shouldShowOpenInPicker } from "./ChatHeader"; +import { + buildHeaderControlsContextMenuItems, + resolveRenameCommit, + shouldShowOpenInPicker, +} from "./ChatHeader"; describe("shouldShowOpenInPicker", () => { const primaryEnvironmentId = EnvironmentId.make("environment-primary"); @@ -82,3 +86,31 @@ describe("resolveRenameCommit", () => { }); }); }); + +describe("buildHeaderControlsContextMenuItems", () => { + it("formats checked state when all controls are visible", () => { + const items = buildHeaderControlsContextMenuItems({ + scripts: true, + openIn: true, + git: true, + }); + expect(items).toEqual([ + { id: "scripts", label: "✓ Action scripts" }, + { id: "openIn", label: "✓ Open in editor" }, + { id: "git", label: "✓ Git source control" }, + ]); + }); + + it("formats unchecked state when controls are hidden", () => { + const items = buildHeaderControlsContextMenuItems({ + scripts: false, + openIn: true, + git: false, + }); + expect(items).toEqual([ + { id: "scripts", label: " Action scripts" }, + { id: "openIn", label: "✓ Open in editor" }, + { id: "git", label: " Git source control" }, + ]); + }); +}); diff --git a/apps/web/src/components/chat/ChatHeader.tsx b/apps/web/src/components/chat/ChatHeader.tsx index 08e0422dd255..c680cf8085e4 100644 --- a/apps/web/src/components/chat/ChatHeader.tsx +++ b/apps/web/src/components/chat/ChatHeader.tsx @@ -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[] { + return [ + { + id: "scripts", + label: `${visibility.scripts ? "✓ " : " "}Action scripts`, + }, + { + id: "openIn", + label: `${visibility.openIn ? "✓ " : " "}Open in editor`, + }, + { + id: "git", + label: `${visibility.git ? "✓ " : " "}Git source control`, + }, + ]; +} + export function shouldShowOpenInPicker(input: { readonly activeProjectName: string | undefined; readonly activeThreadEnvironmentId: EnvironmentId; @@ -200,16 +222,48 @@ 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 (!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 }); + const isActionsArea = Boolean( + (event.target as HTMLElement).closest("[data-chat-header-actions]"), + ); + if (isActionsArea) { + void handleHeaderActionsContextMenu(event); + return; + } + const isTitleArea = Boolean( + (event.target as HTMLElement).closest("[data-thread-title-anchor]"), + ); + if (isServerThread && renamingTitle === null && isTitleArea) { + event.preventDefault(); + openMenu({ x: event.clientX, y: event.clientY }); + return; + } + void handleHeaderActionsContextMenu(event); }, - [isServerThread, openMenu, renamingTitle], + [handleHeaderActionsContextMenu, isServerThread, openMenu, renamingTitle], ); const handleRenameKeyDown = useCallback( (event: ReactKeyboardEvent) => { @@ -282,6 +336,7 @@ export const ChatHeader = memo(function ChatHeader({