From 5722496b79079ac317a984f3baca8c88f41af070 Mon Sep 17 00:00:00 2001 From: maria Date: Fri, 9 Oct 2026 16:22:15 -0300 Subject: [PATCH] feat(web): add parent thread breadcrumb navigation (#16666) Co-authored-by: maria-rcks <254055478+maria-rcks@users.noreply.github.com> --- apps/web/src/components/ChatView.tsx | 2 + apps/web/src/components/chat/ChatHeader.tsx | 81 +++++++++++++++++++++ 2 files changed, 83 insertions(+) diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 214b0605c833851019918c28e6307b19304bee4c..dbee50a28ac78d8c25e4444eb375750f7b5ea8d8 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -11307,6 +11307,8 @@ export default function ChatView(props: ChatViewProps) { isServerThread={isServerThread} activeThreadTitle={activeThread.title} activeProject={activeProject ?? null} + parentThreadLink={parentThreadLink} + onOpenThread={onOpenRelatedThread} rightPanelOpen={inlineRightPanelOwnsTitleBar} onNewThreadInProject={handleNewThreadInActiveProject} {...(activeDraftLogicalProjectKey diff --git a/apps/web/src/components/chat/ChatHeader.tsx b/apps/web/src/components/chat/ChatHeader.tsx index 51c7ebb15f47bfac2fe4583469d76c60b43ecf71..b50a40ecfb91275f08b3a88bbd8330cee169462b 100644 --- a/apps/web/src/components/chat/ChatHeader.tsx +++ b/apps/web/src/components/chat/ChatHeader.tsx @@ -37,6 +37,7 @@ import { WorkspaceBreadcrumbText, } from "../WorkspaceBreadcrumb"; import { cn } from "~/lib/utils"; +import { useClientSettings } from "../../hooks/useSettings"; interface ChatHeaderProps { activeThreadEnvironmentId: EnvironmentId; @@ -45,6 +46,8 @@ interface ChatHeaderProps { /** Drafts have no server thread yet, so the title carries no action menu. */ isServerThread: boolean; activeProject: EnvironmentProject | null; + parentThreadLink: { threadId: ThreadId; title: string } | null; + onOpenThread: (threadId: ThreadId) => void; rightPanelOpen: boolean; onNewThreadInProject: () => void; onOpenProjectSettings?: (() => void) | undefined; @@ -77,12 +80,17 @@ export const ChatHeader = memo(function ChatHeader({ activeThreadTitle, isServerThread, activeProject, + parentThreadLink, + onOpenThread, rightPanelOpen, onNewThreadInProject, onOpenProjectSettings, }: ChatHeaderProps) { const activeProjectName = activeProject?.title; const activeProjectCwd = activeProject?.workspaceRoot ?? null; + const interfaceFont = useClientSettings((settings) => settings.fontFamilySans); + const breadcrumbContainerRef = useRef(null); + const [collapseParentTitle, setCollapseParentTitle] = useState(false); const activeThreadRef = useMemo( () => scopeThreadRef(activeThreadEnvironmentId, activeThreadId), [activeThreadEnvironmentId, activeThreadId], @@ -116,6 +124,44 @@ export const ChatHeader = memo(function ChatHeader({ renaming.environmentId === activeThreadEnvironmentId ? renaming.title : null; + // Leaving rename swaps the input back for the title, which needs a fresh fit. + const isRenamingTitle = renamingTitle !== null; + useEffect(() => { + const list = breadcrumbContainerRef.current?.querySelector("ol"); + if (!list || !parentThreadLink) return; + // Measure the untruncated labels, including a collapsed parent's hidden + // text, so expanding and collapsing never change the fit calculation. + // Width-capped labels (the project) never grow, so their overflow is skipped. + const measure = () => { + const gap = Number.parseFloat(getComputedStyle(list).columnGap) || 0; + const width = Array.from(list.children).reduce( + (total, item) => { + const label = item.querySelector('[data-slot="workspace-breadcrumb-text"]'); + const ellipsis = item.querySelector("[data-parent-breadcrumb-ellipsis]"); + return ( + total + + (item.firstElementChild?.getBoundingClientRect().width ?? 0) + + (label && getComputedStyle(label).maxWidth === "none" + ? label.scrollWidth - label.clientWidth + : 0) - + (ellipsis?.getBoundingClientRect().width ?? 0) + ); + }, + gap * (list.children.length - 1), + ); + setCollapseParentTitle(width > list.clientWidth); + }; + measure(); + const frame = requestAnimationFrame(measure); + document.fonts.addEventListener("loadingdone", measure); + const observer = new ResizeObserver(measure); + observer.observe(list); + return () => { + cancelAnimationFrame(frame); + document.fonts.removeEventListener("loadingdone", measure); + observer.disconnect(); + }; + }, [activeProjectName, activeThreadTitle, parentThreadLink, interfaceFont, isRenamingTitle]); const renameCommittedRef = useRef(false); const startRename = useCallback(() => { if ( @@ -253,6 +299,7 @@ export const ChatHeader = memo(function ChatHeader({ ); return (
) : null} + {parentThreadLink ? ( + <> + + + onOpenThread(parentThreadLink.threadId)} + 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" + /> + } + > + + {parentThreadLink.title} + + {collapseParentTitle ? ( + + ... + + ) : null} + + {parentThreadLink.title} + + + + / + + + ) : null} {renamingTitle !== null ? (