1import {
2 AuthOrchestrationOperateScope,
3 type EnvironmentId,
4 type ThreadId,
5} from "@t3tools/contracts";
6import { scopeThreadRef } from "@t3tools/client-runtime/environment";
7import type { EnvironmentProject } from "@t3tools/client-runtime/state/shell";
8import {
9 isAtomCommandInterrupted,
10 squashAtomCommandFailure,
11} from "@t3tools/client-runtime/state/runtime";
12import { ChevronDownIcon } from "lucide-react";
13import {
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";
23import { isTrailingDoubleClick } from "../Sidebar.logic";
24import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip";
25import { toastManager } from "../ui/toast";
26import { useThreadActionMenu } from "~/hooks/useThreadActionMenu";
27import { readLocalApi } from "~/localApi";
28import { threadEnvironment } from "../../state/threads";
29import { useAtomCommand } from "../../state/use-atom-command";
30import { useOrchestrationCommand } from "../../state/use-orchestration-command";
31import { readEnvironmentScope, useEnvironmentScope } from "../../state/session";
32import { ProjectFavicon } from "../ProjectFavicon";
33import {
34 WorkspaceBreadcrumb,
35 WorkspaceBreadcrumbItem,
36 WorkspaceBreadcrumbSeparator,
37 WorkspaceBreadcrumbText,
38} from "../WorkspaceBreadcrumb";
39import { observeResize } from "~/lib/observeResize";
40import { cn } from "~/lib/utils";
41import { useClientSettings } from "../../hooks/useSettings";
42
43interface 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 */
61export 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.
77const TITLE_MENU_OPEN_DELAY_MS = 500;
78export 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});