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 | undefined; preferredScriptId: string | null; keybindings: ResolvedKeybindingsConfig; availableEditors: ReadonlyArray; rightPanelOpen: boolean; gitCwd: string | null; readonly onOpenPullRequest?: ((number: number) => void) | undefined; onNewThreadInProject: () => void; onOpenProjectSettings?: (() => void) | undefined; onRunProjectScript: (script: ProjectScript) => void; onAddProjectScript: (input: NewProjectScriptInput) => Promise; onUpdateProjectScript: ( scriptId: string, input: NewProjectScriptInput, ) => Promise; onDeleteProjectScript: (scriptId: string) => Promise; } /** * 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(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(null); const titleMenuTimerRef = useRef(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) => { // 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) => { 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 (
{/* 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 ? ( <> } > {activeProjectName} New thread in {activeProjectName} ) : null} {renamingTitle !== null ? ( { if (renameCommittedRef.current) return; commitRename(event.currentTarget.value); }} onFocus={(event) => event.currentTarget.select()} onKeyDown={handleRenameKeyDown} /> ) : isServerThread ? ( } >

{activeThreadTitle}

{activeThreadTitle}
) : ( {activeThreadTitle} } /> {activeThreadTitle} )}
{activeProjectScripts && ( )} {showOpenInPicker && ( )} {activeProjectName && ( )}
); });