| 1 | import { |
| 2 | AuthOrchestrationOperateScope, |
| 3 | type EnvironmentId, |
| 4 | type ThreadId, |
| 5 | } from "@t3tools/contracts"; |
| 6 | import { scopeThreadRef } from "@t3tools/client-runtime/environment"; |
| 7 | import type { EnvironmentProject } from "@t3tools/client-runtime/state/shell"; |
| 8 | import { |
| 9 | isAtomCommandInterrupted, |
| 10 | squashAtomCommandFailure, |
| 11 | } from "@t3tools/client-runtime/state/runtime"; |
| 12 | import { ChevronDownIcon } from "lucide-react"; |
| 13 | import { |
| 14 | memo, |
| 15 | useCallback, |
| 16 | useEffect, |
| 17 | useMemo, |
| 18 | useRef, |
| 19 | useState, |
| 20 | type KeyboardEvent as ReactKeyboardEvent, |
| 21 | type MouseEvent as ReactMouseEvent, |
| 22 | } from "react"; |
| 23 | import { isTrailingDoubleClick } from "../Sidebar.logic"; |
| 24 | import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; |
| 25 | import { toastManager } from "../ui/toast"; |
| 26 | import { useThreadActionMenu } from "~/hooks/useThreadActionMenu"; |
| 27 | import { readLocalApi } from "~/localApi"; |
| 28 | import { threadEnvironment } from "../../state/threads"; |
| 29 | import { useAtomCommand } from "../../state/use-atom-command"; |
| 30 | import { useOrchestrationCommand } from "../../state/use-orchestration-command"; |
| 31 | import { readEnvironmentScope, useEnvironmentScope } from "../../state/session"; |
| 32 | import { ProjectFavicon } from "../ProjectFavicon"; |
| 33 | import { |
| 34 | WorkspaceBreadcrumb, |
| 35 | WorkspaceBreadcrumbItem, |
| 36 | WorkspaceBreadcrumbSeparator, |
| 37 | WorkspaceBreadcrumbText, |
| 38 | } from "../WorkspaceBreadcrumb"; |
| 39 | import { observeResize } from "~/lib/observeResize"; |
| 40 | import { cn } from "~/lib/utils"; |
| 41 | import { useClientSettings } from "../../hooks/useSettings"; |
| 42 | |
| 43 | interface ChatHeaderProps { |
| 44 | activeThreadEnvironmentId: EnvironmentId; |
| 45 | activeThreadId: ThreadId; |
| 46 | activeThreadTitle: string; |
| 47 | /** Drafts have no server thread yet, so the title carries no action menu. */ |
| 48 | isServerThread: boolean; |
| 49 | activeProject: EnvironmentProject | null; |
| 50 | parentThreadLink: { threadId: ThreadId; title: string } | null; |
| 51 | onOpenThread: (threadId: ThreadId) => void; |
| 52 | rightPanelOpen: boolean; |
| 53 | onNewThreadInProject: () => void; |
| 54 | onOpenProjectSettings?: (() => void) | undefined; |
| 55 | } |
| 56 | |
| 57 | /** |
| 58 | * Rename commit rule shared with the sidebar's inline rename: trim, reject |
| 59 | * empty (the caller toasts), and skip the mutation when nothing changed. |
| 60 | */ |
| 61 | export function resolveRenameCommit(input: { |
| 62 | readonly title: string; |
| 63 | readonly originalTitle: string; |
| 64 | }): { action: "commit"; title: string } | { action: "reject-empty" } | { action: "noop" } { |
| 65 | const trimmed = input.title.trim(); |
| 66 | if (trimmed.length === 0) return { action: "reject-empty" }; |
| 67 | if (trimmed === input.originalTitle) return { action: "noop" }; |
| 68 | return { action: "commit", title: trimmed }; |
| 69 | } |
| 70 | |
| 71 | // How long a click on the thread title waits before opening the action menu, |
| 72 | // so a double-click-to-rename can cancel it first. Only the native desktop |
| 73 | // menu needs this: it swallows input while open, so the wait must cover the |
| 74 | // OS double-click interval. The browser fallback menu keeps seeing DOM |
| 75 | // events (the second click dismisses it and dblclick still fires), so it |
| 76 | // opens immediately. |
| 77 | const TITLE_MENU_OPEN_DELAY_MS = 500; |
| 78 | export const ChatHeader = memo(function ChatHeader({ |
| 79 | activeThreadEnvironmentId, |
| 80 | activeThreadId, |
| 81 | activeThreadTitle, |
| 82 | isServerThread, |
| 83 | activeProject, |
| 84 | parentThreadLink, |
| 85 | onOpenThread, |
| 86 | rightPanelOpen, |
| 87 | onNewThreadInProject, |
| 88 | onOpenProjectSettings, |
| 89 | }: ChatHeaderProps) { |
| 90 | const activeProjectName = activeProject?.title; |
| 91 | const activeProjectCwd = activeProject?.workspaceRoot ?? null; |
| 92 | const interfaceFont = useClientSettings((settings) => settings.fontFamilySans); |
| 93 | const breadcrumbContainerRef = useRef<HTMLDivElement>(null); |
| 94 | const [collapseParentTitle, setCollapseParentTitle] = useState(false); |
| 95 | const activeThreadRef = useMemo( |
| 96 | () => scopeThreadRef(activeThreadEnvironmentId, activeThreadId), |
| 97 | [activeThreadEnvironmentId, activeThreadId], |
| 98 | ); |
| 99 | const canOperateThread = useEnvironmentScope( |
| 100 | activeThreadEnvironmentId, |
| 101 | AuthOrchestrationOperateScope, |
| 102 | ); |
| 103 | const updateThreadMetadata = useOrchestrationCommand(threadEnvironment.updateMetadata, { |
| 104 | reportFailure: false, |
| 105 | }); |
| 106 | // Inline rename, keyed by thread: navigating away drops an in-progress |
| 107 | // rename instead of committing stale text. Cleared on thread change (not |
| 108 | // just hidden) so returning to the thread doesn't revive the old draft. |
| 109 | const [renaming, setRenaming] = useState<{ |
| 110 | threadId: ThreadId; |
| 111 | environmentId: EnvironmentId; |
| 112 | title: string; |
| 113 | } | null>(null); |
| 114 | if ( |
| 115 | renaming !== null && |
| 116 | (renaming.threadId !== activeThreadId || |
| 117 | renaming.environmentId !== activeThreadEnvironmentId || |
| 118 | !canOperateThread) |
| 119 | ) { |
| 120 | setRenaming(null); |
| 121 | } |
| 122 | const renamingTitle = |
| 123 | canOperateThread && |
| 124 | renaming?.threadId === activeThreadId && |
| 125 | renaming.environmentId === activeThreadEnvironmentId |
| 126 | ? renaming.title |
| 127 | : null; |
| 128 | // Leaving rename swaps the input back for the title, which needs a fresh fit. |
| 129 | const isRenamingTitle = renamingTitle !== null; |
| 130 | useEffect(() => { |
| 131 | const list = breadcrumbContainerRef.current?.querySelector("ol"); |
| 132 | if (!list || !parentThreadLink) return; |
| 133 | // Measure the untruncated labels, including a collapsed parent's hidden |
| 134 | // text, so expanding and collapsing never change the fit calculation. |
| 135 | // Width-capped labels (the project) never grow, so their overflow is skipped. |
| 136 | const measure = () => { |
| 137 | const gap = Number.parseFloat(getComputedStyle(list).columnGap) || 0; |
| 138 | const width = Array.from(list.children).reduce( |
| 139 | (total, item) => { |
| 140 | const label = item.querySelector('[data-slot="workspace-breadcrumb-text"]'); |
| 141 | const ellipsis = item.querySelector("[data-parent-breadcrumb-ellipsis]"); |
| 142 | return ( |
| 143 | total + |
| 144 | (item.firstElementChild?.getBoundingClientRect().width ?? 0) + |
| 145 | (label && getComputedStyle(label).maxWidth === "none" |
| 146 | ? label.scrollWidth - label.clientWidth |
| 147 | : 0) - |
| 148 | (ellipsis?.getBoundingClientRect().width ?? 0) |
| 149 | ); |
| 150 | }, |
| 151 | gap * (list.children.length - 1), |
| 152 | ); |
| 153 | setCollapseParentTitle(width > list.clientWidth); |
| 154 | }; |
| 155 | measure(); |
| 156 | const frame = requestAnimationFrame(measure); |
| 157 | document.fonts.addEventListener("loadingdone", measure); |
| 158 | const stopObserving = observeResize(list, measure); |
| 159 | return () => { |
| 160 | cancelAnimationFrame(frame); |
| 161 | document.fonts.removeEventListener("loadingdone", measure); |
| 162 | stopObserving(); |
| 163 | }; |
| 164 | }, [activeProjectName, activeThreadTitle, parentThreadLink, interfaceFont, isRenamingTitle]); |
| 165 | const renameCommittedRef = useRef(false); |
| 166 | const startRename = useCallback(() => { |
| 167 | if ( |
| 168 | !isServerThread || |
| 169 | !readEnvironmentScope(activeThreadEnvironmentId, AuthOrchestrationOperateScope) |
| 170 | ) |
| 171 | return; |
| 172 | renameCommittedRef.current = false; |
| 173 | setRenaming({ |
| 174 | environmentId: activeThreadEnvironmentId, |
| 175 | threadId: activeThreadId, |
| 176 | title: activeThreadTitle, |
| 177 | }); |
| 178 | }, [activeThreadEnvironmentId, activeThreadId, activeThreadTitle, isServerThread]); |
| 179 | const commitRename = useCallback( |
| 180 | (title: string) => { |
| 181 | setRenaming(null); |
| 182 | if (!readEnvironmentScope(activeThreadEnvironmentId, AuthOrchestrationOperateScope)) return; |
| 183 | const resolution = resolveRenameCommit({ title, originalTitle: activeThreadTitle }); |
| 184 | if (resolution.action === "reject-empty") { |
| 185 | toastManager.add({ type: "warning", title: "Thread title cannot be empty" }); |
| 186 | return; |
| 187 | } |
| 188 | if (resolution.action === "noop") return; |
| 189 | void updateThreadMetadata({ |
| 190 | environmentId: activeThreadEnvironmentId, |
| 191 | input: { threadId: activeThreadId, title: resolution.title }, |
| 192 | }).then((result) => { |
| 193 | if (result._tag === "Failure" && !isAtomCommandInterrupted(result)) { |
| 194 | const error = squashAtomCommandFailure(result); |
| 195 | toastManager.add({ |
| 196 | type: "error", |
| 197 | title: "Failed to rename thread", |
| 198 | description: error instanceof Error ? error.message : "An error occurred.", |
| 199 | }); |
| 200 | } |
| 201 | }); |
| 202 | }, |
| 203 | [activeThreadEnvironmentId, activeThreadId, activeThreadTitle, updateThreadMetadata], |
| 204 | ); |
| 205 | const { openMenu, closeMenu } = useThreadActionMenu({ |
| 206 | threadRef: isServerThread ? activeThreadRef : null, |
| 207 | projectCwd: activeProjectCwd, |
| 208 | onStartRename: startRename, |
| 209 | }); |
| 210 | const titleButtonRef = useRef<HTMLButtonElement | null>(null); |
| 211 | const titleMenuTimerRef = useRef<number | null>(null); |
| 212 | const cancelPendingTitleMenu = useCallback(() => { |
| 213 | if (titleMenuTimerRef.current === null) return; |
| 214 | clearTimeout(titleMenuTimerRef.current); |
| 215 | titleMenuTimerRef.current = null; |
| 216 | }, []); |
| 217 | // Drop a pending menu-open when the thread changes or the header unmounts, |
| 218 | // so it can never fire for a thread the user already left. |
| 219 | useEffect( |
| 220 | () => () => { |
| 221 | cancelPendingTitleMenu(); |
| 222 | }, |
| 223 | [activeThreadEnvironmentId, activeThreadId, cancelPendingTitleMenu], |
| 224 | ); |
| 225 | const openTitleMenuNow = useCallback(() => { |
| 226 | cancelPendingTitleMenu(); |
| 227 | const rect = titleButtonRef.current?.getBoundingClientRect(); |
| 228 | if (!rect) return; |
| 229 | openMenu({ x: rect.left, y: rect.bottom + 4 }); |
| 230 | }, [cancelPendingTitleMenu, openMenu]); |
| 231 | const openMenuFromTitle = useCallback( |
| 232 | (event: ReactMouseEvent<HTMLButtonElement>) => { |
| 233 | // The trailing click of a double-click belongs to rename, not the menu. |
| 234 | if (isTrailingDoubleClick(event.detail)) return; |
| 235 | // Keyboard activation and the explicit chevron affordance can never be |
| 236 | // the first half of a double-click, so they open without waiting. |
| 237 | const clickedChevron = |
| 238 | (event.target as HTMLElement).closest("[data-thread-title-chevron]") !== null; |
| 239 | if (event.detail === 0 || clickedChevron || window.desktopBridge === undefined) { |
| 240 | openTitleMenuNow(); |
| 241 | return; |
| 242 | } |
| 243 | // Stay pending long enough for dblclick to cancel the open before the |
| 244 | // native menu appears and swallows the second click. |
| 245 | cancelPendingTitleMenu(); |
| 246 | titleMenuTimerRef.current = window.setTimeout(() => { |
| 247 | titleMenuTimerRef.current = null; |
| 248 | openTitleMenuNow(); |
| 249 | }, TITLE_MENU_OPEN_DELAY_MS); |
| 250 | }, |
| 251 | [cancelPendingTitleMenu, openTitleMenuNow], |
| 252 | ); |
| 253 | const handleTitleDoubleClick = useCallback( |
| 254 | (event: ReactMouseEvent) => { |
| 255 | if (event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) return; |
| 256 | // The chevron is the explicit menu affordance; only the title text renames. |
| 257 | if ((event.target as HTMLElement).closest("[data-thread-title-chevron]") !== null) return; |
| 258 | cancelPendingTitleMenu(); |
| 259 | closeMenu(); |
| 260 | startRename(); |
| 261 | }, |
| 262 | [cancelPendingTitleMenu, closeMenu, startRename], |
| 263 | ); |
| 264 | const handleHeaderContextMenu = useCallback( |
| 265 | (event: ReactMouseEvent) => { |
| 266 | if (renamingTitle !== null) return; |
| 267 | if (!isServerThread && onOpenProjectSettings === undefined) return; |
| 268 | cancelPendingTitleMenu(); |
| 269 | event.preventDefault(); |
| 270 | if (!isServerThread) { |
| 271 | const api = readLocalApi(); |
| 272 | if (!api) return; |
| 273 | void api.contextMenu |
| 274 | .show([{ id: "project-settings", label: "Project settings", icon: "settings" }], { |
| 275 | x: event.clientX, |
| 276 | y: event.clientY, |
| 277 | }) |
| 278 | .then((action) => { |
| 279 | if (action === "project-settings") onOpenProjectSettings?.(); |
| 280 | }); |
| 281 | return; |
| 282 | } |
| 283 | openMenu({ x: event.clientX, y: event.clientY }); |
| 284 | }, |
| 285 | [cancelPendingTitleMenu, isServerThread, onOpenProjectSettings, openMenu, renamingTitle], |
| 286 | ); |
| 287 | const handleRenameKeyDown = useCallback( |
| 288 | (event: ReactKeyboardEvent<HTMLInputElement>) => { |
| 289 | if (event.nativeEvent.isComposing || event.keyCode === 229) return; |
| 290 | if (event.key === "Enter") { |
| 291 | renameCommittedRef.current = true; |
| 292 | commitRename(event.currentTarget.value); |
| 293 | } else if (event.key === "Escape") { |
| 294 | renameCommittedRef.current = true; |
| 295 | setRenaming(null); |
| 296 | } |
| 297 | }, |
| 298 | [commitRename], |
| 299 | ); |
| 300 | return ( |
| 301 | <div |
| 302 | ref={breadcrumbContainerRef} |
| 303 | className={cn( |
| 304 | "flex min-w-0 flex-1 items-center gap-2 sm:gap-3", |
| 305 | rightPanelOpen ? "pr-10" : "pr-24", |
| 306 | )} |
| 307 | onContextMenu={handleHeaderContextMenu} |
| 308 | > |
| 309 | <WorkspaceBreadcrumb |
| 310 | ariaLabel="Thread breadcrumb" |
| 311 | className="flex-1 overflow-clip [overflow-clip-margin:2px]" |
| 312 | > |
| 313 | {/* The project always leads the header: knowing which project a |
| 314 | thread lives in is priority zero, and the thread title alone |
| 315 | doesn't answer it. */} |
| 316 | {activeProject ? ( |
| 317 | <> |
| 318 | <WorkspaceBreadcrumbItem className="shrink"> |
| 319 | <Tooltip> |
| 320 | <TooltipTrigger |
| 321 | render={ |
| 322 | <button |
| 323 | type="button" |
| 324 | aria-label={`New thread in ${activeProjectName}`} |
| 325 | onClick={onNewThreadInProject} |
| 326 | 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-inset focus-visible:ring-ring" |
| 327 | /> |
| 328 | } |
| 329 | > |
| 330 | <ProjectFavicon project={activeProject} className="size-3.5" /> |
| 331 | <WorkspaceBreadcrumbText className="max-w-40"> |
| 332 | {activeProjectName} |
| 333 | </WorkspaceBreadcrumbText> |
| 334 | </TooltipTrigger> |
| 335 | <TooltipPopup side="top">New thread in {activeProjectName}</TooltipPopup> |
| 336 | </Tooltip> |
| 337 | </WorkspaceBreadcrumbItem> |
| 338 | <WorkspaceBreadcrumbSeparator> |
| 339 | <WorkspaceBreadcrumbText>/</WorkspaceBreadcrumbText> |
| 340 | </WorkspaceBreadcrumbSeparator> |
| 341 | </> |
| 342 | ) : null} |
| 343 | {parentThreadLink ? ( |
| 344 | <> |
| 345 | <WorkspaceBreadcrumbItem> |
| 346 | <Tooltip> |
| 347 | <TooltipTrigger |
| 348 | render={ |
| 349 | <button |
| 350 | type="button" |
| 351 | aria-label={`Open parent thread: ${parentThreadLink.title}`} |
| 352 | onClick={() => onOpenThread(parentThreadLink.threadId)} |
| 353 | className="inline-flex min-w-0 max-w-full cursor-pointer items-center rounded-sm text-muted-foreground transition-colors hover:text-foreground focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring" |
| 354 | /> |
| 355 | } |
| 356 | > |
| 357 | <WorkspaceBreadcrumbText |
| 358 | aria-hidden |
| 359 | className={collapseParentTitle ? "w-0" : undefined} |
| 360 | > |
| 361 | {parentThreadLink.title} |
| 362 | </WorkspaceBreadcrumbText> |
| 363 | {collapseParentTitle ? ( |
| 364 | <span aria-hidden data-parent-breadcrumb-ellipsis> |
| 365 | ... |
| 366 | </span> |
| 367 | ) : null} |
| 368 | </TooltipTrigger> |
| 369 | <TooltipPopup side="top">{parentThreadLink.title}</TooltipPopup> |
| 370 | </Tooltip> |
| 371 | </WorkspaceBreadcrumbItem> |
| 372 | <WorkspaceBreadcrumbSeparator> |
| 373 | <WorkspaceBreadcrumbText>/</WorkspaceBreadcrumbText> |
| 374 | </WorkspaceBreadcrumbSeparator> |
| 375 | </> |
| 376 | ) : null} |
| 377 | <WorkspaceBreadcrumbItem current className="min-w-10 flex-1"> |
| 378 | {renamingTitle !== null ? ( |
| 379 | <input |
| 380 | autoFocus |
| 381 | aria-label="Thread title" |
| 382 | className="min-w-0 flex-1 rounded-sm bg-transparent text-sm font-medium text-foreground outline-none ring-1 ring-inset ring-ring/50 focus:ring-ring" |
| 383 | defaultValue={renamingTitle} |
| 384 | onBlur={(event) => { |
| 385 | if (renameCommittedRef.current) return; |
| 386 | // Focus landing on a navigation button means the rename was |
| 387 | // abandoned — discard it rather than persisting a half-draft. |
| 388 | if ( |
| 389 | event.relatedTarget instanceof HTMLElement && |
| 390 | event.relatedTarget.closest("button") |
| 391 | ) { |
| 392 | setRenaming(null); |
| 393 | return; |
| 394 | } |
| 395 | commitRename(event.currentTarget.value); |
| 396 | }} |
| 397 | onFocus={(event) => event.currentTarget.select()} |
| 398 | onKeyDown={handleRenameKeyDown} |
| 399 | /> |
| 400 | ) : isServerThread ? ( |
| 401 | <Tooltip> |
| 402 | <TooltipTrigger |
| 403 | render={ |
| 404 | <button |
| 405 | ref={titleButtonRef} |
| 406 | type="button" |
| 407 | aria-label={`Thread actions for ${activeThreadTitle}`} |
| 408 | aria-haspopup="menu" |
| 409 | onClick={openMenuFromTitle} |
| 410 | onDoubleClick={canOperateThread ? handleTitleDoubleClick : undefined} |
| 411 | onBlur={cancelPendingTitleMenu} |
| 412 | 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-inset focus-visible:ring-ring" |
| 413 | /> |
| 414 | } |
| 415 | > |
| 416 | <h2 className="min-w-0"> |
| 417 | <WorkspaceBreadcrumbText>{activeThreadTitle}</WorkspaceBreadcrumbText> |
| 418 | </h2> |
| 419 | <ChevronDownIcon |
| 420 | aria-hidden |
| 421 | data-thread-title-chevron |
| 422 | 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" |
| 423 | /> |
| 424 | </TooltipTrigger> |
| 425 | <TooltipPopup side="top">{activeThreadTitle}</TooltipPopup> |
| 426 | </Tooltip> |
| 427 | ) : ( |
| 428 | <Tooltip> |
| 429 | <TooltipTrigger |
| 430 | render={<h2 aria-label={activeThreadTitle} className="min-w-0 flex-1" />} |
| 431 | > |
| 432 | <WorkspaceBreadcrumbText>{activeThreadTitle}</WorkspaceBreadcrumbText> |
| 433 | </TooltipTrigger> |
| 434 | <TooltipPopup side="top">{activeThreadTitle}</TooltipPopup> |
| 435 | </Tooltip> |
| 436 | )} |
| 437 | </WorkspaceBreadcrumbItem> |
| 438 | </WorkspaceBreadcrumb> |
| 439 | </div> |
| 440 | ); |
| 441 | }); |