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) {...@@ -11307,6 +11307,8 @@ export default function ChatView(props: ChatViewProps) {
11307 isServerThread={isServerThread}11307 isServerThread={isServerThread}
11308 activeThreadTitle={activeThread.title}11308 activeThreadTitle={activeThread.title}
11309 activeProject={activeProject ?? null}11309 activeProject={activeProject ?? null}
11310 parentThreadLink={parentThreadLink}
11311 onOpenThread={onOpenRelatedThread}
11310 rightPanelOpen={inlineRightPanelOwnsTitleBar}11312 rightPanelOpen={inlineRightPanelOwnsTitleBar}
11311 onNewThreadInProject={handleNewThreadInActiveProject}11313 onNewThreadInProject={handleNewThreadInActiveProject}
11312 {...(activeDraftLogicalProjectKey11314 {...(activeDraftLogicalProjectKey
apps/web/src/components/chat/ChatHeader.tsx+81
...@@ -37,6 +37,7 @@ import {...@@ -37,6 +37,7 @@ import {
37 WorkspaceBreadcrumbText,37 WorkspaceBreadcrumbText,
38} from "../WorkspaceBreadcrumb";38} from "../WorkspaceBreadcrumb";
39import { cn } from "~/lib/utils";39import { cn } from "~/lib/utils";
40import { useClientSettings } from "../../hooks/useSettings";
4041
41interface ChatHeaderProps {42interface 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 === activeThreadEnvironmentId124 renaming.environmentId === activeThreadEnvironmentId
117 ? renaming.title125 ? 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 <div301 <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 <input379 <input