| ... | @@ -37,6 +37,7 @@ import { | ... | @@ -37,6 +37,7 @@ import { |
| 37 | WorkspaceBreadcrumbText, | 37 | WorkspaceBreadcrumbText, |
| 38 | } from "../WorkspaceBreadcrumb"; | 38 | } from "../WorkspaceBreadcrumb"; |
| 39 | import { cn } from "~/lib/utils"; | 39 | import { cn } from "~/lib/utils"; |
| | 40 | import { useClientSettings } from "../../hooks/useSettings"; |
| 40 | | 41 | |
| 41 | interface ChatHeaderProps { | 42 | interface ChatHeaderProps { |
| 42 | activeThreadEnvironmentId: EnvironmentId; | 43 | activeThreadEnvironmentId: EnvironmentId; |
| ... | @@ -45,6 +46,8 @@ interface ChatHeaderProps { | ... | @@ -45,6 +46,8 @@ interface ChatHeaderProps { |
| 45 | /** Drafts have no server thread yet, so the title carries no action menu. */ | 46 | /** Drafts have no server thread yet, so the title carries no action menu. */ |
| 46 | isServerThread: boolean; | 47 | isServerThread: boolean; |
| 47 | activeProject: EnvironmentProject | null; | 48 | activeProject: EnvironmentProject | null; |
| | 49 | parentThreadLink: { threadId: ThreadId; title: string } | null; |
| | 50 | onOpenThread: (threadId: ThreadId) => void; |
| 48 | rightPanelOpen: boolean; | 51 | rightPanelOpen: boolean; |
| 49 | onNewThreadInProject: () => void; | 52 | onNewThreadInProject: () => void; |
| 50 | onOpenProjectSettings?: (() => void) | undefined; | 53 | onOpenProjectSettings?: (() => void) | undefined; |
| ... | @@ -77,12 +80,17 @@ export const ChatHeader = memo(function ChatHeader({ | ... | @@ -77,12 +80,17 @@ export const ChatHeader = memo(function ChatHeader({ |
| 77 | activeThreadTitle, | 80 | activeThreadTitle, |
| 78 | isServerThread, | 81 | isServerThread, |
| 79 | activeProject, | 82 | activeProject, |
| | 83 | parentThreadLink, |
| | 84 | onOpenThread, |
| 80 | rightPanelOpen, | 85 | rightPanelOpen, |
| 81 | onNewThreadInProject, | 86 | onNewThreadInProject, |
| 82 | onOpenProjectSettings, | 87 | onOpenProjectSettings, |
| 83 | }: ChatHeaderProps) { | 88 | }: ChatHeaderProps) { |
| 84 | const activeProjectName = activeProject?.title; | 89 | const activeProjectName = activeProject?.title; |
| 85 | const activeProjectCwd = activeProject?.workspaceRoot ?? null; | 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 | const activeThreadRef = useMemo( | 94 | const activeThreadRef = useMemo( |
| 87 | () => scopeThreadRef(activeThreadEnvironmentId, activeThreadId), | 95 | () => scopeThreadRef(activeThreadEnvironmentId, activeThreadId), |
| 88 | [activeThreadEnvironmentId, activeThreadId], | 96 | [activeThreadEnvironmentId, activeThreadId], |
| ... | @@ -116,6 +124,44 @@ export const ChatHeader = memo(function ChatHeader({ | ... | @@ -116,6 +124,44 @@ export const ChatHeader = memo(function ChatHeader({ |
| 116 | renaming.environmentId === activeThreadEnvironmentId | 124 | renaming.environmentId === activeThreadEnvironmentId |
| 117 | ? renaming.title | 125 | ? renaming.title |
| 118 | : null; | 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 | const renameCommittedRef = useRef(false); | 165 | const renameCommittedRef = useRef(false); |
| 120 | const startRename = useCallback(() => { | 166 | const startRename = useCallback(() => { |
| 121 | if ( | 167 | if ( |
| ... | @@ -253,6 +299,7 @@ export const ChatHeader = memo(function ChatHeader({ | ... | @@ -253,6 +299,7 @@ export const ChatHeader = memo(function ChatHeader({ |
| 253 | ); | 299 | ); |
| 254 | return ( | 300 | return ( |
| 255 | <div | 301 | <div |
| | 302 | ref={breadcrumbContainerRef} |
| 256 | className={cn( | 303 | className={cn( |
| 257 | "flex min-w-0 flex-1 items-center gap-2 sm:gap-3", | 304 | "flex min-w-0 flex-1 items-center gap-2 sm:gap-3", |
| 258 | rightPanelOpen ? "pr-10" : "pr-24", | 305 | rightPanelOpen ? "pr-10" : "pr-24", |
| ... | @@ -293,6 +340,40 @@ export const ChatHeader = memo(function ChatHeader({ | ... | @@ -293,6 +340,40 @@ export const ChatHeader = memo(function ChatHeader({ |
| 293 | </WorkspaceBreadcrumbSeparator> | 340 | </WorkspaceBreadcrumbSeparator> |
| 294 | </> | 341 | </> |
| 295 | ) : null} | 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 | <WorkspaceBreadcrumbItem current className="min-w-10 flex-1"> | 377 | <WorkspaceBreadcrumbItem current className="min-w-10 flex-1"> |
| 297 | {renamingTitle !== null ? ( | 378 | {renamingTitle !== null ? ( |
| 298 | <input | 379 | <input |