diff --git a/apps/web/src/components/chat/ChatHeader.test.ts b/apps/web/src/components/chat/ChatHeader.test.ts index a200a2069240..a8c7a53faf74 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: "✓\u00A0Action scripts" }, + { id: "openIn", label: "✓\u00A0Open in editor" }, + { id: "git", label: "✓\u00A0Git 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: "\u00A0\u00A0Action scripts" }, + { id: "openIn", label: "✓\u00A0Open in editor" }, + { id: "git", label: "\u00A0\u00A0Git source control" }, + ]); + }); +}); diff --git a/apps/web/src/components/chat/ChatHeader.tsx b/apps/web/src/components/chat/ChatHeader.tsx index 08e0422dd255..0c7b19a4b197 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 ? "✓\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) => { @@ -282,6 +327,7 @@ export const ChatHeader = memo(function ChatHeader({