authorgravatar for maria@kuuro.netmaria <maria@kuuro.net> 2026-10-09 16:22:15-03:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-10-09 16:22:15-03:00
log5722496b79079ac317a984f3baca8c88f41af070
treeccac2805b68f1cf13de389179caf9bf6b3ca2988
parent1fd558d9e25493e2a49da625aa35dc80a52b0257
signature Signed by PGP key B5690EEEBB952194

feat(web): add parent thread breadcrumb navigation (#16666)

Co-authored-by: maria-rcks <254055478+maria-rcks@users.noreply.github.com>

2 files changed, 83 insertions(+), 0 deletions(-)

apps/web/src/components/ChatView.tsx+2
......@@ -11307,6 +11307,8 @@ export default function ChatView(props: ChatViewProps) {
1130711307 isServerThread={isServerThread}
1130811308 activeThreadTitle={activeThread.title}
1130911309 activeProject={activeProject ?? null}
11310 parentThreadLink={parentThreadLink}
11311 onOpenThread={onOpenRelatedThread}
1131011312 rightPanelOpen={inlineRightPanelOwnsTitleBar}
1131111313 onNewThreadInProject={handleNewThreadInActiveProject}
1131211314 {...(activeDraftLogicalProjectKey
apps/web/src/components/chat/ChatHeader.tsx+81
......@@ -37,6 +37,7 @@ import {
3737 WorkspaceBreadcrumbText,
3838} from "../WorkspaceBreadcrumb";
3939import { cn } from "~/lib/utils";
40import { useClientSettings } from "../../hooks/useSettings";
4041
4142interface ChatHeaderProps {
4243 activeThreadEnvironmentId: EnvironmentId;
......@@ -45,6 +46,8 @@ interface ChatHeaderProps {
4546 /** Drafts have no server thread yet, so the title carries no action menu. */
4647 isServerThread: boolean;
4748 activeProject: EnvironmentProject | null;
49 parentThreadLink: { threadId: ThreadId; title: string } | null;
50 onOpenThread: (threadId: ThreadId) => void;
4851 rightPanelOpen: boolean;
4952 onNewThreadInProject: () => void;
5053 onOpenProjectSettings?: (() => void) | undefined;
......@@ -77,12 +80,17 @@ export const ChatHeader = memo(function ChatHeader({
7780 activeThreadTitle,
7881 isServerThread,
7982 activeProject,
83 parentThreadLink,
84 onOpenThread,
8085 rightPanelOpen,
8186 onNewThreadInProject,
8287 onOpenProjectSettings,
8388}: ChatHeaderProps) {
8489 const activeProjectName = activeProject?.title;
8590 const activeProjectCwd = activeProject?.workspaceRoot ?? null;
91 const interfaceFont = useClientSettings((settings) => settings.fontFamilySans);
92 const breadcrumbContainerRef = useRef<HTMLDivElement>(null);
93 const [collapseParentTitle, setCollapseParentTitle] = useState(false);
8694 const activeThreadRef = useMemo(
8795 () => scopeThreadRef(activeThreadEnvironmentId, activeThreadId),
8896 [activeThreadEnvironmentId, activeThreadId],
......@@ -116,6 +124,44 @@ export const ChatHeader = memo(function ChatHeader({
116124 renaming.environmentId === activeThreadEnvironmentId
117125 ? renaming.title
118126 : 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]);
119165 const renameCommittedRef = useRef(false);
120166 const startRename = useCallback(() => {
121167 if (
......@@ -253,6 +299,7 @@ export const ChatHeader = memo(function ChatHeader({
253299 );
254300 return (
255301 <div
302 ref={breadcrumbContainerRef}
256303 className={cn(
257304 "flex min-w-0 flex-1 items-center gap-2 sm:gap-3",
258305 rightPanelOpen ? "pr-10" : "pr-24",
......@@ -293,6 +340,40 @@ export const ChatHeader = memo(function ChatHeader({
293340 </WorkspaceBreadcrumbSeparator>
294341 </>
295342 ) : 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}
296377 <WorkspaceBreadcrumbItem current className="min-w-10 flex-1">
297378 {renamingTitle !== null ? (
298379 <input