import { type EnvironmentId, type EditorId, type ProjectScript, type ResolvedKeybindingsConfig, type ThreadId,} from "@t3tools/contracts";import { scopeThreadRef } from "@t3tools/client-runtime/environment";import type { EnvironmentProject } from "@t3tools/client-runtime/state/shell";import { isAtomCommandInterrupted, squashAtomCommandFailure,} from "@t3tools/client-runtime/state/runtime";import { ChevronDownIcon } from "lucide-react";import { memo, useCallback, useEffect, useMemo, useRef, useState, type KeyboardEvent as ReactKeyboardEvent, type MouseEvent as ReactMouseEvent,} from "react";import GitActionsControl from "../GitActionsControl";import { isTrailingDoubleClick } from "../Sidebar.logic";import { type DraftId } from "~/composerDraftStore";import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip";import { toastManager } from "../ui/toast";import ProjectScriptsControl, { type NewProjectScriptInput, type ProjectScriptActionResult,} from "../ProjectScriptsControl";import { OpenInPicker } from "./OpenInPicker";import { useRemoteOpenState, type RemoteOpenMode } from "../../remoteOpen";import { usePrimaryEnvironmentId } from "../../state/environments";import { useT3ProjectFileScripts } from "~/hooks/useT3ProjectFileScripts";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 { ProjectFavicon } from "../ProjectFavicon";import { WorkspaceBreadcrumb, WorkspaceBreadcrumbItem, WorkspaceBreadcrumbSeparator,} from "../WorkspaceBreadcrumb";import { cn } from "~/lib/utils"; interface ChatHeaderProps { activeThreadEnvironmentId: EnvironmentId; activeThreadId: ThreadId; draftId?: DraftId; activeThreadTitle: string; /** Drafts have no server thread yet, so the title carries no action menu. */ isServerThread: boolean; activeProject: EnvironmentProject | null; openInCwd: string | null; activeProjectScripts: ReadonlyArray<ProjectScript> | undefined; preferredScriptId: string | null; keybindings: ResolvedKeybindingsConfig; availableEditors: ReadonlyArray<EditorId>; rightPanelOpen: boolean; gitCwd: string | null; readonly onOpenPullRequest?: ((number: number) => void) | undefined; onNewThreadInProject: () => void; onOpenProjectSettings?: (() => void) | undefined; onRunProjectScript: (script: ProjectScript) => void; onAddProjectScript: (input: NewProjectScriptInput) => Promise<ProjectScriptActionResult>; onUpdateProjectScript: ( scriptId: string, input: NewProjectScriptInput, ) => Promise<ProjectScriptActionResult>; onDeleteProjectScript: (scriptId: string) => Promise<ProjectScriptActionResult>;} /** * Rename commit rule shared with the sidebar's inline rename: trim, reject * empty (the caller toasts), and skip the mutation when nothing changed. */export function resolveRenameCommit(input: { readonly title: string; readonly originalTitle: string;}): { action: "commit"; title: string } | { action: "reject-empty" } | { action: "noop" } { const trimmed = input.title.trim(); if (trimmed.length === 0) return { action: "reject-empty" }; if (trimmed === input.originalTitle) return { action: "noop" }; return { action: "commit", title: trimmed };} // How long a click on the thread title waits before opening the action menu,// so a double-click-to-rename can cancel it first. Only the native desktop// menu needs this: it swallows input while open, so the wait must cover the// OS double-click interval. The browser fallback menu keeps seeing DOM// 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; readonly activeThreadEnvironmentId: EnvironmentId; readonly primaryEnvironmentId: EnvironmentId | null; readonly remoteOpenMode: RemoteOpenMode;}): boolean { if (!input.activeProjectName) return false; if ( input.primaryEnvironmentId !== null && input.activeThreadEnvironmentId === input.primaryEnvironmentId ) { return true; } // Remote environments get the picker in deep-link mode (or its explicit // "no SSH route" state). Non-primary local backends (e.g. WSL) keep it // hidden, matching pre-remote behavior. return input.remoteOpenMode !== "local-exec";} export const ChatHeader = memo(function ChatHeader({ activeThreadEnvironmentId, activeThreadId, draftId, activeThreadTitle, isServerThread, activeProject, openInCwd, activeProjectScripts, preferredScriptId, keybindings, availableEditors, rightPanelOpen, gitCwd, onOpenPullRequest, onNewThreadInProject, onOpenProjectSettings, onRunProjectScript, onAddProjectScript, onUpdateProjectScript, onDeleteProjectScript,}: ChatHeaderProps) { const { active: panelAnimationsActive, durationMs: panelAnimationDurationMs } = usePanelAnimationSettings(); const headerActionsRef = useRef<HTMLDivElement | null>(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 primaryEnvironmentId = usePrimaryEnvironmentId(); const activeProjectName = activeProject?.title; const activeProjectCwd = activeProject?.workspaceRoot ?? null; const fileScripts = useT3ProjectFileScripts( activeThreadEnvironmentId, activeProjectScripts ? activeProjectCwd : null, ); const remoteOpenState = useRemoteOpenState(activeThreadEnvironmentId); const showOpenInPicker = shouldShowOpenInPicker({ activeProjectName, activeThreadEnvironmentId, primaryEnvironmentId, remoteOpenMode: remoteOpenState.mode, }); const activeThreadRef = useMemo( () => scopeThreadRef(activeThreadEnvironmentId, activeThreadId), [activeThreadEnvironmentId, activeThreadId], ); const updateThreadMetadata = useAtomCommand(threadEnvironment.updateMetadata, { reportFailure: false, }); // Inline rename, keyed by thread: navigating away drops an in-progress // rename instead of committing stale text. Cleared on thread change (not // just hidden) so returning to the thread doesn't revive the old draft. const [renaming, setRenaming] = useState<{ threadId: ThreadId; title: string } | null>(null); if (renaming !== null && renaming.threadId !== activeThreadId) { setRenaming(null); } const renamingTitle = renaming?.threadId === activeThreadId ? renaming.title : null; const renameCommittedRef = useRef(false); const startRename = useCallback(() => { renameCommittedRef.current = false; setRenaming({ threadId: activeThreadId, title: activeThreadTitle }); }, [activeThreadId, activeThreadTitle]); const commitRename = useCallback( (title: string) => { setRenaming(null); const resolution = resolveRenameCommit({ title, originalTitle: activeThreadTitle }); if (resolution.action === "reject-empty") { toastManager.add({ type: "warning", title: "Thread title cannot be empty" }); return; } if (resolution.action === "noop") return; void updateThreadMetadata({ environmentId: activeThreadEnvironmentId, input: { threadId: activeThreadId, title: resolution.title }, }).then((result) => { if (result._tag === "Failure" && !isAtomCommandInterrupted(result)) { const error = squashAtomCommandFailure(result); toastManager.add({ type: "error", title: "Failed to rename thread", description: error instanceof Error ? error.message : "An error occurred.", }); } }); }, [activeThreadEnvironmentId, activeThreadId, activeThreadTitle, updateThreadMetadata], ); const { openMenu, closeMenu } = useThreadActionMenu({ threadRef: isServerThread ? activeThreadRef : null, projectCwd: activeProjectCwd, onStartRename: startRename, }); const titleButtonRef = useRef<HTMLButtonElement | null>(null); const titleMenuTimerRef = useRef<number | null>(null); const cancelPendingTitleMenu = useCallback(() => { if (titleMenuTimerRef.current === null) return; clearTimeout(titleMenuTimerRef.current); titleMenuTimerRef.current = null; }, []); // Drop a pending menu-open when the thread changes or the header unmounts, // so it can never fire for a thread the user already left. useEffect( () => () => { cancelPendingTitleMenu(); }, [activeThreadId, cancelPendingTitleMenu], ); const openTitleMenuNow = useCallback(() => { cancelPendingTitleMenu(); const rect = titleButtonRef.current?.getBoundingClientRect(); if (!rect) return; openMenu({ x: rect.left, y: rect.bottom + 4 }); }, [cancelPendingTitleMenu, openMenu]); const openMenuFromTitle = useCallback( (event: ReactMouseEvent<HTMLButtonElement>) => { // The trailing click of a double-click belongs to rename, not the menu. if (isTrailingDoubleClick(event.detail)) return; // Keyboard activation and the explicit chevron affordance can never be // the first half of a double-click, so they open without waiting. const clickedChevron = (event.target as HTMLElement).closest("[data-thread-title-chevron]") !== null; if (event.detail === 0 || clickedChevron || window.desktopBridge === undefined) { openTitleMenuNow(); return; } // Stay pending long enough for dblclick to cancel the open before the // native menu appears and swallows the second click. cancelPendingTitleMenu(); titleMenuTimerRef.current = window.setTimeout(() => { titleMenuTimerRef.current = null; openTitleMenuNow(); }, TITLE_MENU_OPEN_DELAY_MS); }, [cancelPendingTitleMenu, openTitleMenuNow], ); const handleTitleDoubleClick = useCallback( (event: ReactMouseEvent) => { if (event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) return; // The chevron is the explicit menu affordance; only the title text renames. if ((event.target as HTMLElement).closest("[data-thread-title-chevron]") !== null) return; cancelPendingTitleMenu(); closeMenu(); startRename(); }, [cancelPendingTitleMenu, closeMenu, startRename], ); const handleHeaderContextMenu = useCallback( (event: ReactMouseEvent) => { if (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; if (!isServerThread && onOpenProjectSettings === undefined) return; cancelPendingTitleMenu(); event.preventDefault(); if (!isServerThread) { const api = readLocalApi(); if (!api) return; void api.contextMenu .show([{ id: "project-settings", label: "Project settings", icon: "settings" }], { x: event.clientX, y: event.clientY, }) .then((action) => { if (action === "project-settings") onOpenProjectSettings?.(); }); return; } openMenu({ x: event.clientX, y: event.clientY }); }, [cancelPendingTitleMenu, isServerThread, onOpenProjectSettings, openMenu, renamingTitle], ); const handleRenameKeyDown = useCallback( (event: ReactKeyboardEvent<HTMLInputElement>) => { if (event.nativeEvent.isComposing || event.keyCode === 229) return; if (event.key === "Enter") { renameCommittedRef.current = true; commitRename(event.currentTarget.value); } else if (event.key === "Escape") { renameCommittedRef.current = true; setRenaming(null); } }, [commitRename], ); return ( <div className="@container/header-actions flex min-w-0 flex-1 items-center gap-2 sm:gap-3" onContextMenu={handleHeaderContextMenu} > <WorkspaceBreadcrumb ariaLabel="Thread breadcrumb" className="flex-1 overflow-clip [overflow-clip-margin:2px]" > {/* The project always leads the header: knowing which project a thread lives in is priority zero, and the thread title alone doesn't answer it. */} {activeProject ? ( <> <WorkspaceBreadcrumbItem className="shrink"> <Tooltip> <TooltipTrigger render={ <button type="button" aria-label={`New thread in ${activeProjectName}`} onClick={onNewThreadInProject} className="inline-flex min-w-0 max-w-full cursor-pointer items-center gap-1.5 rounded-sm text-muted-foreground transition-colors hover:text-foreground focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-ring" /> } > <ProjectFavicon project={activeProject} className="size-3.5" /> <span className="max-w-40 truncate">{activeProjectName}</span> </TooltipTrigger> <TooltipPopup side="top">New thread in {activeProjectName}</TooltipPopup> </Tooltip> </WorkspaceBreadcrumbItem> <WorkspaceBreadcrumbSeparator /> </> ) : null} <WorkspaceBreadcrumbItem current className="min-w-10 flex-1"> {renamingTitle !== null ? ( <input autoFocus aria-label="Thread title" className="min-w-0 flex-1 rounded-sm bg-transparent text-sm font-medium text-foreground outline-none ring-1 ring-ring/50 focus:ring-ring" defaultValue={renamingTitle} onBlur={(event) => { if (renameCommittedRef.current) return; commitRename(event.currentTarget.value); }} onFocus={(event) => event.currentTarget.select()} onKeyDown={handleRenameKeyDown} /> ) : isServerThread ? ( <Tooltip> <TooltipTrigger render={ <button ref={titleButtonRef} type="button" aria-label={`Thread actions for ${activeThreadTitle}`} aria-haspopup="menu" onClick={openMenuFromTitle} onDoubleClick={handleTitleDoubleClick} onBlur={cancelPendingTitleMenu} className="group/thread-title inline-flex min-w-0 max-w-full cursor-pointer items-center gap-1 rounded-sm text-left focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-ring" /> } > <h2 className="min-w-0 truncate">{activeThreadTitle}</h2> <ChevronDownIcon aria-hidden data-thread-title-chevron className="size-3.5 shrink-0 text-muted-foreground opacity-0 transition-opacity group-hover/thread-title:opacity-100 group-focus-visible/thread-title:opacity-100" /> </TooltipTrigger> <TooltipPopup side="top">{activeThreadTitle}</TooltipPopup> </Tooltip> ) : ( <Tooltip> <TooltipTrigger render={ <h2 aria-label={activeThreadTitle} className="min-w-0 flex-1 truncate"> {activeThreadTitle} </h2> } /> <TooltipPopup side="top">{activeThreadTitle}</TooltipPopup> </Tooltip> )} </WorkspaceBreadcrumbItem> </WorkspaceBreadcrumb> <div ref={headerActionsRef} data-chat-header-actions className={cn( "flex shrink-0 items-center justify-end gap-2 @3xl/header-actions:gap-3", rightPanelOpen ? "pr-0" : "pr-16", "[[data-panel-animations=true]_&]:motion-safe:transition-[padding-right] [[data-panel-animations=true]_&]:motion-safe:[transition-duration:var(--panel-animation-duration)] [[data-panel-animations=true]_&]:motion-safe:ease-out", )} > {activeProjectScripts && ( <ProjectScriptsControl scripts={activeProjectScripts} fileScripts={fileScripts} keybindings={keybindings} preferredScriptId={preferredScriptId} onRunScript={onRunProjectScript} onAddScript={onAddProjectScript} onUpdateScript={onUpdateProjectScript} onDeleteScript={onDeleteProjectScript} /> )} {showOpenInPicker && ( <OpenInPicker environmentId={activeThreadEnvironmentId} keybindings={keybindings} availableEditors={availableEditors} openInCwd={openInCwd} /> )} {activeProjectName && ( <GitActionsControl gitCwd={gitCwd} activeThreadRef={scopeThreadRef(activeThreadEnvironmentId, activeThreadId)} onOpenPullRequest={onOpenPullRequest} {...(draftId ? { draftId } : {})} /> )} </div> </div> );});
import {
type EnvironmentId,
type EditorId,
type ProjectScript,
type ResolvedKeybindingsConfig,
type ThreadId,
} from "@t3tools/contracts";
import { scopeThreadRef } from "@t3tools/client-runtime/environment";
import type { EnvironmentProject } from "@t3tools/client-runtime/state/shell";
import {
isAtomCommandInterrupted,
squashAtomCommandFailure,
} from "@t3tools/client-runtime/state/runtime";
import { ChevronDownIcon } from "lucide-react";
import {
memo,
useCallback,
useEffect,
useMemo,
useRef,
useState,
type KeyboardEvent as ReactKeyboardEvent,
type MouseEvent as ReactMouseEvent,
} from "react";
import GitActionsControl from "../GitActionsControl";
import { isTrailingDoubleClick } from "../Sidebar.logic";
import { type DraftId } from "~/composerDraftStore";
import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip";
import { toastManager } from "../ui/toast";
import ProjectScriptsControl, {
type NewProjectScriptInput,
type ProjectScriptActionResult,
} from "../ProjectScriptsControl";
import { OpenInPicker } from "./OpenInPicker";
import { useRemoteOpenState, type RemoteOpenMode } from "../../remoteOpen";
import { usePrimaryEnvironmentId } from "../../state/environments";
import { useT3ProjectFileScripts } from "~/hooks/useT3ProjectFileScripts";
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 { ProjectFavicon } from "../ProjectFavicon";
import {
WorkspaceBreadcrumb,
WorkspaceBreadcrumbItem,
WorkspaceBreadcrumbSeparator,
} from "../WorkspaceBreadcrumb";
import { cn } from "~/lib/utils";
interface ChatHeaderProps {
activeThreadEnvironmentId: EnvironmentId;
activeThreadId: ThreadId;
draftId?: DraftId;
activeThreadTitle: string;
/** Drafts have no server thread yet, so the title carries no action menu. */
isServerThread: boolean;
activeProject: EnvironmentProject | null;
openInCwd: string | null;
activeProjectScripts: ReadonlyArray<ProjectScript> | undefined;
preferredScriptId: string | null;
keybindings: ResolvedKeybindingsConfig;
availableEditors: ReadonlyArray<EditorId>;
rightPanelOpen: boolean;
gitCwd: string | null;
readonly onOpenPullRequest?: ((number: number) => void) | undefined;
onNewThreadInProject: () => void;
onOpenProjectSettings?: (() => void) | undefined;
onRunProjectScript: (script: ProjectScript) => void;
onAddProjectScript: (input: NewProjectScriptInput) => Promise<ProjectScriptActionResult>;
onUpdateProjectScript: (
scriptId: string,
input: NewProjectScriptInput,
) => Promise<ProjectScriptActionResult>;
onDeleteProjectScript: (scriptId: string) => Promise<ProjectScriptActionResult>;
}
/**
* Rename commit rule shared with the sidebar's inline rename: trim, reject
* empty (the caller toasts), and skip the mutation when nothing changed.
*/
export function resolveRenameCommit(input: {
readonly title: string;
readonly originalTitle: string;
}): { action: "commit"; title: string } | { action: "reject-empty" } | { action: "noop" } {
const trimmed = input.title.trim();
if (trimmed.length === 0) return { action: "reject-empty" };
if (trimmed === input.originalTitle) return { action: "noop" };
return { action: "commit", title: trimmed };
}
// How long a click on the thread title waits before opening the action menu,
// so a double-click-to-rename can cancel it first. Only the native desktop
// menu needs this: it swallows input while open, so the wait must cover the
// OS double-click interval. The browser fallback menu keeps seeing DOM
// 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;
readonly activeThreadEnvironmentId: EnvironmentId;
readonly primaryEnvironmentId: EnvironmentId | null;
readonly remoteOpenMode: RemoteOpenMode;
}): boolean {
if (!input.activeProjectName) return false;
if (
input.primaryEnvironmentId !== null &&
input.activeThreadEnvironmentId === input.primaryEnvironmentId
) {
return true;
}
// Remote environments get the picker in deep-link mode (or its explicit
// "no SSH route" state). Non-primary local backends (e.g. WSL) keep it
// hidden, matching pre-remote behavior.
return input.remoteOpenMode !== "local-exec";
}
export const ChatHeader = memo(function ChatHeader({
activeThreadEnvironmentId,
activeThreadId,
draftId,
activeThreadTitle,
isServerThread,
activeProject,
openInCwd,
activeProjectScripts,
preferredScriptId,
keybindings,
availableEditors,
rightPanelOpen,
gitCwd,
onOpenPullRequest,
onNewThreadInProject,
onOpenProjectSettings,
onRunProjectScript,
onAddProjectScript,
onUpdateProjectScript,
onDeleteProjectScript,
}: ChatHeaderProps) {
const { active: panelAnimationsActive, durationMs: panelAnimationDurationMs } =
usePanelAnimationSettings();
const headerActionsRef = useRef<HTMLDivElement | null>(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 primaryEnvironmentId = usePrimaryEnvironmentId();
const activeProjectName = activeProject?.title;
const activeProjectCwd = activeProject?.workspaceRoot ?? null;
const fileScripts = useT3ProjectFileScripts(
activeThreadEnvironmentId,
activeProjectScripts ? activeProjectCwd : null,
);
const remoteOpenState = useRemoteOpenState(activeThreadEnvironmentId);
const showOpenInPicker = shouldShowOpenInPicker({
activeProjectName,
activeThreadEnvironmentId,
primaryEnvironmentId,
remoteOpenMode: remoteOpenState.mode,
});
const activeThreadRef = useMemo(
() => scopeThreadRef(activeThreadEnvironmentId, activeThreadId),
[activeThreadEnvironmentId, activeThreadId],
);
const updateThreadMetadata = useAtomCommand(threadEnvironment.updateMetadata, {
reportFailure: false,
});
// Inline rename, keyed by thread: navigating away drops an in-progress
// rename instead of committing stale text. Cleared on thread change (not
// just hidden) so returning to the thread doesn't revive the old draft.
const [renaming, setRenaming] = useState<{ threadId: ThreadId; title: string } | null>(null);
if (renaming !== null && renaming.threadId !== activeThreadId) {
setRenaming(null);
}
const renamingTitle = renaming?.threadId === activeThreadId ? renaming.title : null;
const renameCommittedRef = useRef(false);
const startRename = useCallback(() => {
renameCommittedRef.current = false;
setRenaming({ threadId: activeThreadId, title: activeThreadTitle });
}, [activeThreadId, activeThreadTitle]);
const commitRename = useCallback(
(title: string) => {
setRenaming(null);
const resolution = resolveRenameCommit({ title, originalTitle: activeThreadTitle });
if (resolution.action === "reject-empty") {
toastManager.add({ type: "warning", title: "Thread title cannot be empty" });
return;
}
if (resolution.action === "noop") return;
void updateThreadMetadata({
environmentId: activeThreadEnvironmentId,
input: { threadId: activeThreadId, title: resolution.title },
}).then((result) => {
if (result._tag === "Failure" && !isAtomCommandInterrupted(result)) {
const error = squashAtomCommandFailure(result);
toastManager.add({
type: "error",
title: "Failed to rename thread",
description: error instanceof Error ? error.message : "An error occurred.",
});
}
});
},
[activeThreadEnvironmentId, activeThreadId, activeThreadTitle, updateThreadMetadata],
);
const { openMenu, closeMenu } = useThreadActionMenu({
threadRef: isServerThread ? activeThreadRef : null,
projectCwd: activeProjectCwd,
onStartRename: startRename,
});
const titleButtonRef = useRef<HTMLButtonElement | null>(null);
const titleMenuTimerRef = useRef<number | null>(null);
const cancelPendingTitleMenu = useCallback(() => {
if (titleMenuTimerRef.current === null) return;
clearTimeout(titleMenuTimerRef.current);
titleMenuTimerRef.current = null;
}, []);
// Drop a pending menu-open when the thread changes or the header unmounts,
// so it can never fire for a thread the user already left.
useEffect(
() => () => {
cancelPendingTitleMenu();
},
[activeThreadId, cancelPendingTitleMenu],
);
const openTitleMenuNow = useCallback(() => {
cancelPendingTitleMenu();
const rect = titleButtonRef.current?.getBoundingClientRect();
if (!rect) return;
openMenu({ x: rect.left, y: rect.bottom + 4 });
}, [cancelPendingTitleMenu, openMenu]);
const openMenuFromTitle = useCallback(
(event: ReactMouseEvent<HTMLButtonElement>) => {
// The trailing click of a double-click belongs to rename, not the menu.
if (isTrailingDoubleClick(event.detail)) return;
// Keyboard activation and the explicit chevron affordance can never be
// the first half of a double-click, so they open without waiting.
const clickedChevron =
(event.target as HTMLElement).closest("[data-thread-title-chevron]") !== null;
if (event.detail === 0 || clickedChevron || window.desktopBridge === undefined) {
openTitleMenuNow();
return;
}
// Stay pending long enough for dblclick to cancel the open before the
// native menu appears and swallows the second click.
cancelPendingTitleMenu();
titleMenuTimerRef.current = window.setTimeout(() => {
titleMenuTimerRef.current = null;
openTitleMenuNow();
}, TITLE_MENU_OPEN_DELAY_MS);
},
[cancelPendingTitleMenu, openTitleMenuNow],
);
const handleTitleDoubleClick = useCallback(
(event: ReactMouseEvent) => {
if (event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) return;
// The chevron is the explicit menu affordance; only the title text renames.
if ((event.target as HTMLElement).closest("[data-thread-title-chevron]") !== null) return;
cancelPendingTitleMenu();
closeMenu();
startRename();
},
[cancelPendingTitleMenu, closeMenu, startRename],
);
const handleHeaderContextMenu = useCallback(
(event: ReactMouseEvent) => {
if (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;
if (!isServerThread && onOpenProjectSettings === undefined) return;
cancelPendingTitleMenu();
event.preventDefault();
if (!isServerThread) {
const api = readLocalApi();
if (!api) return;
void api.contextMenu
.show([{ id: "project-settings", label: "Project settings", icon: "settings" }], {
x: event.clientX,
y: event.clientY,
})
.then((action) => {
if (action === "project-settings") onOpenProjectSettings?.();
});
return;
}
openMenu({ x: event.clientX, y: event.clientY });
},
[cancelPendingTitleMenu, isServerThread, onOpenProjectSettings, openMenu, renamingTitle],
);
const handleRenameKeyDown = useCallback(
(event: ReactKeyboardEvent<HTMLInputElement>) => {
if (event.nativeEvent.isComposing || event.keyCode === 229) return;
if (event.key === "Enter") {
renameCommittedRef.current = true;
commitRename(event.currentTarget.value);
} else if (event.key === "Escape") {
renameCommittedRef.current = true;
setRenaming(null);
}
},
[commitRename],
);
return (
<div
className="@container/header-actions flex min-w-0 flex-1 items-center gap-2 sm:gap-3"
onContextMenu={handleHeaderContextMenu}
>
<WorkspaceBreadcrumb
ariaLabel="Thread breadcrumb"
className="flex-1 overflow-clip [overflow-clip-margin:2px]"
>
{/* The project always leads the header: knowing which project a
thread lives in is priority zero, and the thread title alone
doesn't answer it. */}
{activeProject ? (
<>
<WorkspaceBreadcrumbItem className="shrink">
<Tooltip>
<TooltipTrigger
render={
<button
type="button"
aria-label={`New thread in ${activeProjectName}`}
onClick={onNewThreadInProject}
className="inline-flex min-w-0 max-w-full cursor-pointer items-center gap-1.5 rounded-sm text-muted-foreground transition-colors hover:text-foreground focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-ring"
/>
}
>
<ProjectFavicon project={activeProject} className="size-3.5" />
<span className="max-w-40 truncate">{activeProjectName}</span>
</TooltipTrigger>
<TooltipPopup side="top">New thread in {activeProjectName}</TooltipPopup>
</Tooltip>
</WorkspaceBreadcrumbItem>
<WorkspaceBreadcrumbSeparator />
</>
) : null}
<WorkspaceBreadcrumbItem current className="min-w-10 flex-1">
{renamingTitle !== null ? (
<input
autoFocus
aria-label="Thread title"
className="min-w-0 flex-1 rounded-sm bg-transparent text-sm font-medium text-foreground outline-none ring-1 ring-ring/50 focus:ring-ring"
defaultValue={renamingTitle}
onBlur={(event) => {
if (renameCommittedRef.current) return;
commitRename(event.currentTarget.value);
}}
onFocus={(event) => event.currentTarget.select()}
onKeyDown={handleRenameKeyDown}
/>
) : isServerThread ? (
<Tooltip>
<TooltipTrigger
render={
<button
ref={titleButtonRef}
type="button"
aria-label={`Thread actions for ${activeThreadTitle}`}
aria-haspopup="menu"
onClick={openMenuFromTitle}
onDoubleClick={handleTitleDoubleClick}
onBlur={cancelPendingTitleMenu}
className="group/thread-title inline-flex min-w-0 max-w-full cursor-pointer items-center gap-1 rounded-sm text-left focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-ring"
/>
}
>
<h2 className="min-w-0 truncate">{activeThreadTitle}</h2>
<ChevronDownIcon
aria-hidden
data-thread-title-chevron
className="size-3.5 shrink-0 text-muted-foreground opacity-0 transition-opacity group-hover/thread-title:opacity-100 group-focus-visible/thread-title:opacity-100"
/>
</TooltipTrigger>
<TooltipPopup side="top">{activeThreadTitle}</TooltipPopup>
</Tooltip>
) : (
<Tooltip>
<TooltipTrigger
render={
<h2 aria-label={activeThreadTitle} className="min-w-0 flex-1 truncate">
{activeThreadTitle}
</h2>
}
/>
<TooltipPopup side="top">{activeThreadTitle}</TooltipPopup>
</Tooltip>
)}
</WorkspaceBreadcrumbItem>
</WorkspaceBreadcrumb>
<div
ref={headerActionsRef}
data-chat-header-actions
className={cn(
"flex shrink-0 items-center justify-end gap-2 @3xl/header-actions:gap-3",
rightPanelOpen ? "pr-0" : "pr-16",
"[[data-panel-animations=true]_&]:motion-safe:transition-[padding-right] [[data-panel-animations=true]_&]:motion-safe:[transition-duration:var(--panel-animation-duration)] [[data-panel-animations=true]_&]:motion-safe:ease-out",
)}
>
{activeProjectScripts && (
<ProjectScriptsControl
scripts={activeProjectScripts}
fileScripts={fileScripts}
keybindings={keybindings}
preferredScriptId={preferredScriptId}
onRunScript={onRunProjectScript}
onAddScript={onAddProjectScript}
onUpdateScript={onUpdateProjectScript}
onDeleteScript={onDeleteProjectScript}
/>
)}
{showOpenInPicker && (
<OpenInPicker
environmentId={activeThreadEnvironmentId}
keybindings={keybindings}
availableEditors={availableEditors}
openInCwd={openInCwd}
/>
)}
{activeProjectName && (
<GitActionsControl
gitCwd={gitCwd}
activeThreadRef={scopeThreadRef(activeThreadEnvironmentId, activeThreadId)}
onOpenPullRequest={onOpenPullRequest}
{...(draftId ? { draftId } : {})}
/>
)}
</div>
</div>
);
});