| ... | ... | @@ -37,6 +37,7 @@ import { |
| 37 | 37 | WorkspaceBreadcrumbText, |
| 38 | 38 | } from "../WorkspaceBreadcrumb"; |
| 39 | 39 | import { cn } from "~/lib/utils"; |
| 40 | import { useClientSettings } from "../../hooks/useSettings"; |
| 40 | 41 | |
| 41 | 42 | interface ChatHeaderProps { |
| 42 | 43 | activeThreadEnvironmentId: EnvironmentId; |
| ... | ... | @@ -45,6 +46,8 @@ interface ChatHeaderProps { |
| 45 | 46 | /** Drafts have no server thread yet, so the title carries no action menu. */ |
| 46 | 47 | isServerThread: boolean; |
| 47 | 48 | activeProject: EnvironmentProject | null; |
| 49 | parentThreadLink: { threadId: ThreadId; title: string } | null; |
| 50 | onOpenThread: (threadId: ThreadId) => void; |
| 48 | 51 | rightPanelOpen: boolean; |
| 49 | 52 | onNewThreadInProject: () => void; |
| 50 | 53 | onOpenProjectSettings?: (() => void) | undefined; |
| ... | ... | @@ -77,12 +80,17 @@ export const ChatHeader = memo(function ChatHeader({ |
| 77 | 80 | activeThreadTitle, |
| 78 | 81 | isServerThread, |
| 79 | 82 | activeProject, |
| 83 | parentThreadLink, |
| 84 | onOpenThread, |
| 80 | 85 | rightPanelOpen, |
| 81 | 86 | onNewThreadInProject, |
| 82 | 87 | onOpenProjectSettings, |
| 83 | 88 | }: ChatHeaderProps) { |
| 84 | 89 | const activeProjectName = activeProject?.title; |
| 85 | 90 | const activeProjectCwd = activeProject?.workspaceRoot ?? null; |
| 91 | const interfaceFont = useClientSettings((settings) => settings.fontFamilySans); |
| 92 | const breadcrumbContainerRef = useRef<HTMLDivElement>(null); |
| 93 | const [collapseParentTitle, setCollapseParentTitle] = useState(false); |
| 86 | 94 | const activeThreadRef = useMemo( |
| 87 | 95 | () => scopeThreadRef(activeThreadEnvironmentId, activeThreadId), |
| 88 | 96 | [activeThreadEnvironmentId, activeThreadId], |
| ... | ... | @@ -116,6 +124,44 @@ export const ChatHeader = memo(function ChatHeader({ |
| 116 | 124 | renaming.environmentId === activeThreadEnvironmentId |
| 117 | 125 | ? renaming.title |
| 118 | 126 | : null; |
| 127 | // Leaving rename swaps the input back for the title, which needs a fresh fit. |
| 128 | const isRenamingTitle = renamingTitle !== null; |
| 129 | useEffect(() => { |
| 130 | const list = breadcrumbContainerRef.current?.querySelector("ol"); |
| 131 | if (!list || !parentThreadLink) return; |
| 132 | // Measure the untruncated labels, including a collapsed parent's hidden |
| 133 | // text, so expanding and collapsing never change the fit calculation. |
| 134 | // Width-capped labels (the project) never grow, so their overflow is skipped. |
| 135 | const measure = () => { |
| 136 | const gap = Number.parseFloat(getComputedStyle(list).columnGap) || 0; |
| 137 | const width = Array.from(list.children).reduce( |
| 138 | (total, item) => { |
| 139 | const label = item.querySelector('[data-slot="workspace-breadcrumb-text"]'); |
| 140 | const ellipsis = item.querySelector("[data-parent-breadcrumb-ellipsis]"); |
| 141 | return ( |
| 142 | total + |
| 143 | (item.firstElementChild?.getBoundingClientRect().width ?? 0) + |
| 144 | (label && getComputedStyle(label).maxWidth === "none" |
| 145 | ? label.scrollWidth - label.clientWidth |
| 146 | : 0) - |
| 147 | (ellipsis?.getBoundingClientRect().width ?? 0) |
| 148 | ); |
| 149 | }, |
| 150 | gap * (list.children.length - 1), |
| 151 | ); |
| 152 | setCollapseParentTitle(width > list.clientWidth); |
| 153 | }; |
| 154 | measure(); |
| 155 | const frame = requestAnimationFrame(measure); |
| 156 | document.fonts.addEventListener("loadingdone", measure); |
| 157 | const observer = new ResizeObserver(measure); |
| 158 | observer.observe(list); |
| 159 | return () => { |
| 160 | cancelAnimationFrame(frame); |
| 161 | document.fonts.removeEventListener("loadingdone", measure); |
| 162 | observer.disconnect(); |
| 163 | }; |
| 164 | }, [activeProjectName, activeThreadTitle, parentThreadLink, interfaceFont, isRenamingTitle]); |
| 119 | 165 | const renameCommittedRef = useRef(false); |
| 120 | 166 | const startRename = useCallback(() => { |
| 121 | 167 | if ( |
| ... | ... | @@ -253,6 +299,7 @@ export const ChatHeader = memo(function ChatHeader({ |
| 253 | 299 | ); |
| 254 | 300 | return ( |
| 255 | 301 | <div |
| 302 | ref={breadcrumbContainerRef} |
| 256 | 303 | className={cn( |
| 257 | 304 | "flex min-w-0 flex-1 items-center gap-2 sm:gap-3", |
| 258 | 305 | rightPanelOpen ? "pr-10" : "pr-24", |
| ... | ... | @@ -293,6 +340,40 @@ export const ChatHeader = memo(function ChatHeader({ |
| 293 | 340 | </WorkspaceBreadcrumbSeparator> |
| 294 | 341 | </> |
| 295 | 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-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} |
| 296 | 377 | <WorkspaceBreadcrumbItem current className="min-w-10 flex-1"> |
| 297 | 378 | {renamingTitle !== null ? ( |
| 298 | 379 | <input |