Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
66 changes: 65 additions & 1 deletion apps/web/src/components/chat/MessagesTimeline.browser.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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", () => {
Expand Down
154 changes: 78 additions & 76 deletions apps/web/src/components/chat/MessagesTimeline.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -1293,82 +1293,84 @@ const ConversationTimeline = memo(function ConversationTimeline({
{activityAnnouncer}
<MarkdownFindContext value={findActive}>
<TimelineRowActivityCtx value={activityState}>
<TooltipScrollDismissArea
ref={attachTimelineViewportElement}
className="relative h-full min-h-0"
data-assistant-citation-viewport="true"
>
{onCiteAssistantText && citationThreadRef ? (
<AssistantSelectionToolbar
viewport={timelineViewportElement}
threadRef={citationThreadRef}
onCite={onCiteAssistantText}
<TooltipProvider>
<TooltipScrollDismissArea
ref={attachTimelineViewportElement}
className="relative h-full min-h-0"
data-assistant-citation-viewport="true"
>
{onCiteAssistantText && citationThreadRef ? (
<AssistantSelectionToolbar
viewport={timelineViewportElement}
threadRef={citationThreadRef}
onCite={onCiteAssistantText}
/>
) : null}
<LegendList<MessagesTimelineRow>
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}
<LegendList<MessagesTimelineRow>
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}
/>
<TimelineMinimap
items={minimapItems}
hasPersistentGutter={minimapHasPersistentGutter}
hitStripWidth={minimapHitStripWidth}
currentIndex={minimapCurrentIndex}
atEnd={minimapAtEnd}
stripMap={minimapStripMap}
onWheel={forwardMinimapWheel}
onSelect={(item) => {
navigateTimeline();
void listRef.current?.scrollToIndex({
index: item.rowIndex,
animated: !window.matchMedia("(prefers-reduced-motion: reduce)").matches,
viewOffset: 24,
});
}}
/>
</TooltipScrollDismissArea>
<TimelineMinimap
items={minimapItems}
hasPersistentGutter={minimapHasPersistentGutter}
hitStripWidth={minimapHitStripWidth}
currentIndex={minimapCurrentIndex}
atEnd={minimapAtEnd}
stripMap={minimapStripMap}
onWheel={forwardMinimapWheel}
onSelect={(item) => {
navigateTimeline();
void listRef.current?.scrollToIndex({
index: item.rowIndex,
animated: !window.matchMedia("(prefers-reduced-motion: reduce)").matches,
viewOffset: 24,
});
}}
/>
</TooltipScrollDismissArea>
</TooltipProvider>
</TimelineRowActivityCtx>
</MarkdownFindContext>
</>
Expand Down
6 changes: 3 additions & 3 deletions apps/web/src/components/chat/ThreadRelationshipsControl.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -941,7 +941,7 @@ export function ThreadRelationshipsPanel(props: {
};

return (
<>
<TooltipProvider>
{lineageVisible ? (
<ThreadDetailsSection
headingId="thread-details-lineage-heading"
Expand Down Expand Up @@ -1028,6 +1028,6 @@ export function ThreadRelationshipsPanel(props: {
</ThreadLineageGroup>
</ThreadDetailsSection>
) : null}
</>
</TooltipProvider>
);
}
Loading