diff --git a/apps/web/src/components/chat/MessagesTimeline.browser.test.tsx b/apps/web/src/components/chat/MessagesTimeline.browser.test.tsx index 0ca1f46f5d7ea..fad8dae5e869b 100644 --- a/apps/web/src/components/chat/MessagesTimeline.browser.test.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.browser.test.tsx @@ -2,7 +2,7 @@ import { injectHtmlRenderBootstrap } from "@t3tools/shared/htmlRender"; import { HtmlRenderDocument } from "../files/BrowserDocumentFrame"; import { flushSync } from "react-dom"; import { AudioPreview } from "../files/AudioPreview"; -import { cdp, page } from "vite-plus/test/browser"; +import { cdp, page, userEvent } from "vite-plus/test/browser"; import { ComposerContextId, EnvironmentId, @@ -434,6 +434,70 @@ describe("MessagesTimeline work rows", () => { } }, ); + + it("opens adjacent subagent previews without repeating the hover delay", async () => { + setup(); + const now = DateTime.makeUnsafe(CREATED_AT); + const task = (id: string, title: string): OrchestrationV2Subagent => ({ + id: NodeId.make(id), + threadId: ThreadId.make("thread-1"), + runId: null, + parentNodeId: NodeId.make("parent-root"), + origin: "app_owned", + createdBy: "agent", + driver: ProviderDriverKind.make("claudeAgent"), + providerInstanceId: ProviderInstanceId.make("claudeAgent"), + providerThreadId: null, + childThreadId: ThreadId.make(`child-${id}`), + nativeTaskRef: null, + prompt: title, + title, + model: null, + status: "completed", + result: `${title} result`, + startedAt: now, + completedAt: now, + updatedAt: now, + }); + const tasks = [task("first", "Review parser"), task("second", "Review renderer")]; + using _threadAtom = vi.spyOn(environmentThreadDetails, "threadAtom").mockReturnValue( + Atom.make({ + projection: { subagents: tasks, turnItems: [], visibleTurnItems: [] }, + }) as never, + ); + await using screen = await renderTimeline( + tasks.map((agent) => + work(`notification-${agent.id}`, { + label: "Notification fallback", + tone: "info", + itemType: "notification", + projectedItem: v2Item("notification", { + id: `notification-${agent.id}`, + source: { + kind: "delegated_task", + childThreadId: agent.childThreadId!, + taskIds: [agent.id], + }, + outcome: "completed", + summary: "Notification fallback", + }), + }), + ), + ); + const openPopup = (title: string) => { + const popup = [...document.querySelectorAll('[data-slot="tooltip-popup"][data-open]')].find( + (element) => element.textContent?.includes(title), + ); + if (!popup) throw new Error(`No open preview for ${title}`); + return popup; + }; + await userEvent.hover(screen.button("Open Review parser")); + await vi.waitFor(() => openPopup("Review parser")); + await userEvent.hover(screen.button("Open Review renderer")); + const popup = await vi.waitFor(() => openPopup("Review renderer")); + // Base UI marks a popup that a Tooltip.Provider group opened without its delay. + expect(popup.getAttribute("data-instant")).toBe("delay"); + }); }); describe("MessagesTimeline messages", () => { diff --git a/apps/web/src/components/chat/MessagesTimeline.tsx b/apps/web/src/components/chat/MessagesTimeline.tsx index 3b8db4484132d..6f4c699ce55ac 100644 --- a/apps/web/src/components/chat/MessagesTimeline.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.tsx @@ -90,7 +90,7 @@ import { } from "./MessagesTimeline.logic"; import { type ThreadFindMatch } from "./threadFind"; import { useThreadFindNavigation } from "./useThreadFindNavigation"; -import { TooltipScrollDismissArea } from "../ui/tooltip"; +import { TooltipProvider, TooltipScrollDismissArea } from "../ui/tooltip"; import { WorktreeSetupCard } from "./WorktreeSetupCard"; import { useOpenPrLink } from "~/lib/openPullRequestLink"; import { useMediaQuery } from "~/hooks/useMediaQuery"; @@ -1293,82 +1293,84 @@ const ConversationTimeline = memo(function ConversationTimeline({ {activityAnnouncer} - - {onCiteAssistantText && citationThreadRef ? ( - + + {onCiteAssistantText && citationThreadRef ? ( + + ) : null} + + ref={setTimelineList} + data={rows} + extraData={listExtraData} + keyExtractor={keyExtractor} + getItemType={getItemType} + renderItem={renderItem} + estimatedItemSize={90} + initialScrollAtEnd={ + !findActive && citationRequest === null && rememberedPosition?.atEnd !== false + } + // Legend needs a data refresh to mount new pins without a scroll event. + dataVersion={`${readyCitationRequest?.key ?? listIdentityKey}:${alwaysRenderKeys.join(",")}`} + {...(alwaysRender ? { alwaysRender } : {})} + onLoad={handleListLoad} + {...(anchoredEndSpace ? { anchoredEndSpace } : {})} + contentInsetEndAdjustment={anchoredEndSpace ? contentInsetEndAdjustment : 0} + maintainScrollAtEnd={ + citationPositioning || + (restoringThreadPosition && rememberedPosition?.atEnd === false) || + anchoredEndSpace || + !liveFollowEnabled || + fullscreenAppRowId !== null + ? false + : isWorking && !prefersReducedMotion && settlingListIdentity === null + ? TIMELINE_MAINTAIN_SCROLL_AT_END_SMOOTH + : TIMELINE_MAINTAIN_SCROLL_AT_END + } + maintainVisibleContentPosition={ + findActive || + citationPositioning || + (restoringThreadPosition && rememberedPosition?.atEnd === false) + ? false + : maintainVisibleContentPosition + } + maintainScrollAtEndThreshold={1} + onScroll={handleScroll} + onItemSizeChanged={scheduleMinimapRefresh} + onMetricsChange={scheduleMinimapRefresh} + className={cn( + "messages-timeline-scroll scrollbar-gutter-both h-full min-h-0 overflow-x-hidden overscroll-y-contain [overflow-anchor:none]", + topFadeEnabled && "topbar-scroll-fade", + )} + ListHeaderComponent={listHeader} + ListFooterComponent={timelineListFooter} /> - ) : null} - - ref={setTimelineList} - data={rows} - extraData={listExtraData} - keyExtractor={keyExtractor} - getItemType={getItemType} - renderItem={renderItem} - estimatedItemSize={90} - initialScrollAtEnd={ - !findActive && citationRequest === null && rememberedPosition?.atEnd !== false - } - // Legend needs a data refresh to mount new pins without a scroll event. - dataVersion={`${readyCitationRequest?.key ?? listIdentityKey}:${alwaysRenderKeys.join(",")}`} - {...(alwaysRender ? { alwaysRender } : {})} - onLoad={handleListLoad} - {...(anchoredEndSpace ? { anchoredEndSpace } : {})} - contentInsetEndAdjustment={anchoredEndSpace ? contentInsetEndAdjustment : 0} - maintainScrollAtEnd={ - citationPositioning || - (restoringThreadPosition && rememberedPosition?.atEnd === false) || - anchoredEndSpace || - !liveFollowEnabled || - fullscreenAppRowId !== null - ? false - : isWorking && !prefersReducedMotion && settlingListIdentity === null - ? TIMELINE_MAINTAIN_SCROLL_AT_END_SMOOTH - : TIMELINE_MAINTAIN_SCROLL_AT_END - } - maintainVisibleContentPosition={ - findActive || - citationPositioning || - (restoringThreadPosition && rememberedPosition?.atEnd === false) - ? false - : maintainVisibleContentPosition - } - maintainScrollAtEndThreshold={1} - onScroll={handleScroll} - onItemSizeChanged={scheduleMinimapRefresh} - onMetricsChange={scheduleMinimapRefresh} - className={cn( - "messages-timeline-scroll scrollbar-gutter-both h-full min-h-0 overflow-x-hidden overscroll-y-contain [overflow-anchor:none]", - topFadeEnabled && "topbar-scroll-fade", - )} - ListHeaderComponent={listHeader} - ListFooterComponent={timelineListFooter} - /> - { - navigateTimeline(); - void listRef.current?.scrollToIndex({ - index: item.rowIndex, - animated: !window.matchMedia("(prefers-reduced-motion: reduce)").matches, - viewOffset: 24, - }); - }} - /> - + { + navigateTimeline(); + void listRef.current?.scrollToIndex({ + index: item.rowIndex, + animated: !window.matchMedia("(prefers-reduced-motion: reduce)").matches, + viewOffset: 24, + }); + }} + /> + + diff --git a/apps/web/src/components/chat/ThreadRelationshipsControl.tsx b/apps/web/src/components/chat/ThreadRelationshipsControl.tsx index 102d9734ded92..753552b9be311 100644 --- a/apps/web/src/components/chat/ThreadRelationshipsControl.tsx +++ b/apps/web/src/components/chat/ThreadRelationshipsControl.tsx @@ -84,7 +84,7 @@ import { ThreadLineageWorkflowRow } from "./ThreadLineageWorkflowRow"; import { openSideChatTab } from "../../lib/sideChatPane"; import { Menu, MenuItem, MenuPopup, MenuTrigger } from "../ui/menu"; -import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; +import { Tooltip, TooltipPopup, TooltipProvider, TooltipTrigger } from "../ui/tooltip"; import { toastManager } from "../ui/toast"; import { Spinner } from "../ui/spinner"; import { cn } from "~/lib/utils"; @@ -941,7 +941,7 @@ export function ThreadRelationshipsPanel(props: { }; return ( - <> + {lineageVisible ? ( ) : null} - + ); }