Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 33 additions & 1 deletion apps/web/src/components/chat/ChatHeader.test.ts
Original file line number Diff line number Diff line change
@@ -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");
Expand Down Expand Up @@ -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" },
]);
});
});
107 changes: 80 additions & 27 deletions apps/web/src/components/chat/ChatHeader.tsx
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,
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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>) => {
Expand Down Expand Up @@ -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}
Expand Down Expand Up @@ -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",

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

h-full here resolves to auto: the actions cluster's containing block is the ChatHeader root (ChatHeader.tsx:274), which is a flex item of the items-center topbar <header> (ChatView.tsx:6214) and therefore has an indefinite (content) height, so height: 100% never picks up --workspace-topbar-height. With every control toggled off the three wrappers are display:none, so this container has zero content height — min-w-6 gives 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:

Suggested change
"flex h-full min-w-6 shrink-0 items-center justify-end gap-2 @3xl/header-actions:gap-3",
"flex min-w-6 shrink-0 items-center justify-end gap-2 self-stretch @3xl/header-actions:gap-3",

Posted via Macroscope — UI Consistency

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>

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hidden git control stays active

Low Severity

Hiding Git source control now only applies Tailwind hidden instead of unmounting GitActionsControl. The control keeps running its focus and visibility VCS refresh listeners, status subscription, and draft branch sync, so background git work continues after the user hides the chrome. The actions strip already has min-w-6 for the context-menu hit target, so keeping this tree mounted is not required for that UX.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 9d21704. Configure here.

)}
</div>
</div>
Expand Down
49 changes: 49 additions & 0 deletions apps/web/src/headerControlsStore.test.ts
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,
});
});
});
52 changes: 52 additions & 0 deletions apps/web/src/headerControlsStore.ts
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),
),
},
),
);
Loading