diff --git a/site/src/pages/AgentsPage/components/ChatConversation/ConversationTimeline.stories.tsx b/site/src/pages/AgentsPage/components/ChatConversation/ConversationTimeline.stories.tsx index 458418ba31..c77a600819 100644 --- a/site/src/pages/AgentsPage/components/ChatConversation/ConversationTimeline.stories.tsx +++ b/site/src/pages/AgentsPage/components/ChatConversation/ConversationTimeline.stories.tsx @@ -64,7 +64,6 @@ const defaultArgs: Omit< "parsedMessages" > = { subagentTitles: new Map(), - isTurnActive: false, }; const meta: Meta = { @@ -572,7 +571,7 @@ export const StickyUserMessageStructure: Story = { }, }; -/** Copy + edit toolbar appears below user messages on hover. */ +/** Copy + edit actions appear below user messages on hover. */ export const UserMessageCopyButton: Story = { args: { ...defaultArgs, @@ -637,7 +636,7 @@ export const UserMessageCopyButton: Story = { }, }; -/** Copy button is present on assistant messages below the response. */ +/** Copy button is present on assistant messages on hover. */ export const AssistantMessageCopyButton: Story = { args: { ...defaultArgs, @@ -663,10 +662,20 @@ export const AssistantMessageCopyButton: Story = { }, play: async ({ canvasElement }) => { const canvas = within(canvasElement); - // The assistant copy button is always visible below - // the response content. - const wrapper = canvas.getByTestId("assistant-copy-button"); - const copyBtn = within(wrapper).getByRole("button", { + // Force the hover-reveal toolbar visible. + for (const el of canvasElement.querySelectorAll("[class]")) { + if ( + el instanceof HTMLElement && + el.className.includes("group-hover/msg:opacity-100") + ) { + el.style.opacity = "1"; + } + } + const actions = canvas.getAllByTestId("message-actions"); + expect(actions.length).toBeGreaterThanOrEqual(1); + // The last message-actions belongs to the assistant. + const assistantActions = actions[actions.length - 1]; + const copyBtn = within(assistantActions).getByRole("button", { name: "Copy message", }); expect(copyBtn).toBeInTheDocument(); @@ -713,10 +722,19 @@ export const AssistantMessageNoCopyWhenToolOnly: Story = { }, play: async ({ canvasElement }) => { const canvas = within(canvasElement); - // Tool-only assistant message should not have a copy button. - expect( - canvas.queryByTestId("assistant-copy-button"), - ).not.toBeInTheDocument(); + // Force the hover-reveal toolbar visible. + for (const el of canvasElement.querySelectorAll("[class]")) { + if ( + el instanceof HTMLElement && + el.className.includes("group-hover/msg:opacity-100") + ) { + el.style.opacity = "1"; + } + } + // Only the user message should have actions; the tool-only + // assistant message has no copyable content. + const actions = canvas.getAllByTestId("message-actions"); + expect(actions).toHaveLength(1); }, }; @@ -750,9 +768,19 @@ export const CopyButtonWritesToClipboard: Story = { try { const canvas = within(canvasElement); - // Find the always-visible assistant copy button. - const wrapper = canvas.getByTestId("assistant-copy-button"); - const copyBtn = within(wrapper).getByRole("button", { + // Force the hover-reveal toolbar visible. + for (const el of canvasElement.querySelectorAll("[class]")) { + if ( + el instanceof HTMLElement && + el.className.includes("group-hover/msg:opacity-100") + ) { + el.style.opacity = "1"; + } + } + // Find the assistant's copy button (last message-actions). + const actions = canvas.getAllByTestId("message-actions"); + const assistantActions = actions[actions.length - 1]; + const copyBtn = within(assistantActions).getByRole("button", { name: "Copy message", }); await userEvent.click(copyBtn); @@ -767,15 +795,10 @@ export const CopyButtonWritesToClipboard: Story = { }, }; -/** - * When the turn is still active (isTurnActive=true), the trailing - * assistant message should NOT show a copy button because the - * content is not yet final. - */ -export const NoCopyButtonDuringActiveTurn: Story = { +/** All messages get copy actions regardless of turn state. */ +export const CopyButtonDuringActiveTurn: Story = { args: { ...defaultArgs, - isTurnActive: true, parsedMessages: buildMessages([ { ...baseMessage, @@ -793,18 +816,22 @@ export const NoCopyButtonDuringActiveTurn: Story = { }, play: async ({ canvasElement }) => { const canvas = within(canvasElement); - expect( - canvas.queryByTestId("assistant-copy-button"), - ).not.toBeInTheDocument(); + // Force the hover-reveal toolbar visible. + for (const el of canvasElement.querySelectorAll("[class]")) { + if ( + el instanceof HTMLElement && + el.className.includes("group-hover/msg:opacity-100") + ) { + el.style.opacity = "1"; + } + } + // Both user and assistant messages should have actions. + const actions = canvas.getAllByTestId("message-actions"); + expect(actions).toHaveLength(2); }, }; -/** - * Regression: copy button appears only on the last assistant message - * in a turn that includes tool calls. The isLastAssistantMessage - * computation must skip tool-role messages when finding turn - * boundaries. - */ +/** All assistant messages with text content get a copy button. */ export const MultiAssistantTurnCopyButton: Story = { args: { ...defaultArgs, @@ -853,16 +880,19 @@ export const MultiAssistantTurnCopyButton: Story = { }, play: async ({ canvasElement }) => { const canvas = within(canvasElement); - // Only the last assistant message in the turn should have the - // copy button. The first assistant message (id=2) has text but - // should not show the button because a later assistant message - // (id=4) continues the turn. - const wrappers = canvas.getAllByTestId("assistant-copy-button"); - expect(wrappers).toHaveLength(1); - - const copyBtn = within(wrappers[0]).getByRole("button", { - name: "Copy message", - }); - expect(copyBtn).toBeInTheDocument(); + // Force the hover-reveal toolbar visible. + for (const el of canvasElement.querySelectorAll("[class]")) { + if ( + el instanceof HTMLElement && + el.className.includes("group-hover/msg:opacity-100") + ) { + el.style.opacity = "1"; + } + } + // The first assistant message (id=2) is mid-chain so its + // actions are hidden. Only the user and the last assistant + // (id=4) get action bars. + const actions = canvas.getAllByTestId("message-actions"); + expect(actions).toHaveLength(2); }, }; diff --git a/site/src/pages/AgentsPage/components/ChatConversation/ConversationTimeline.tsx b/site/src/pages/AgentsPage/components/ChatConversation/ConversationTimeline.tsx index 02a5f21853..d05f3c8df1 100644 --- a/site/src/pages/AgentsPage/components/ChatConversation/ConversationTimeline.tsx +++ b/site/src/pages/AgentsPage/components/ChatConversation/ConversationTimeline.tsx @@ -1,4 +1,4 @@ -import { CopyIcon, FileTextIcon, PencilIcon } from "lucide-react"; +import { FileTextIcon, PencilIcon } from "lucide-react"; import { type FC, Fragment, @@ -10,7 +10,6 @@ import { } from "react"; import type { UrlTransform } from "streamdown"; import type * as TypesGen from "#/api/typesGenerated"; -import { CheckIcon } from "#/components/AnimatedIcons/Check"; import { Button } from "#/components/Button/Button"; import { CopyButton } from "#/components/CopyButton/CopyButton"; import { Spinner } from "#/components/Spinner/Spinner"; @@ -19,7 +18,6 @@ import { TooltipContent, TooltipTrigger, } from "#/components/Tooltip/Tooltip"; -import { useClipboard } from "#/hooks/useClipboard"; import { cn } from "#/utils/cn"; import { decodeInlineTextAttachment, @@ -257,7 +255,6 @@ export const BlockList: FC<{ onImageClick?: (src: string) => void; onTextFileClick?: (content: string) => void; urlTransform?: UrlTransform; - afterResponseSlot?: React.ReactNode; }> = ({ blocks, tools, @@ -271,7 +268,6 @@ export const BlockList: FC<{ onImageClick, onTextFileClick, urlTransform, - afterResponseSlot, }) => { const toolByID = new Map(tools.map((tool) => [tool.id, tool])); @@ -288,11 +284,6 @@ export const BlockList: FC<{ const remainingTools = tools.filter((tool) => !blockToolIDs.has(tool.id)); - const lastResponseIndex = blocks.reduce( - (acc, b, idx) => (b.type === "response" ? idx : acc), - -1, - ); - return ( <> {blocks.map((block, index) => { @@ -316,7 +307,6 @@ export const BlockList: FC<{ return ( {responseEl} - {index === lastResponseIndex ? afterResponseSlot : null} ); } @@ -439,8 +429,8 @@ const ChatMessageItem = memo<{ editingMessageId?: number | null; savingMessageId?: number | null; isAfterEditingMessage?: boolean; - isLastAssistantMessage?: boolean; - isLastInConversation?: boolean; + hideActions?: boolean; + // When true, renders a gradient overlay inside the bubble // that fades text out toward the bottom. Used by the sticky // overlay to indicate truncated content. @@ -458,9 +448,9 @@ const ChatMessageItem = memo<{ editingMessageId, savingMessageId, isAfterEditingMessage = false, - isLastAssistantMessage = false, - isLastInConversation = false, + hideActions = false, fadeFromBottom = false, + urlTransform, mcpServers, subagentTitles, @@ -471,8 +461,6 @@ const ChatMessageItem = memo<{ const isSavingMessage = savingMessageId === message.id; const [previewImage, setPreviewImage] = useState(null); const [previewText, setPreviewText] = useState(null); - const [copyHovered, setCopyHovered] = useState(false); - const { showCopiedSuccess, copyToClipboard } = useClipboard(); if ( parsed.toolResults.length > 0 && parsed.toolCalls.length === 0 && @@ -619,26 +607,9 @@ const ChatMessageItem = memo<{ ) : ( - + -
+
- - - - - - {showCopiedSuccess ? "Copied!" : "Copy message"} - - -
- ) : undefined - } /> {!hasRenderableContent && (
@@ -697,25 +632,26 @@ const ChatMessageItem = memo<{ )} - {isUser && - !isSavingMessage && - (hasCopyableContent || onEditUserMessage) && ( + {!hideActions && + (hasCopyableContent || (isUser && onEditUserMessage)) && (
{hasCopyableContent && ( - + )} - {onEditUserMessage && ( + {isUser && onEditUserMessage && ( - + + Edit message + - Edit message + Edit message )}
@@ -813,6 +750,8 @@ const StickyUserMessage = memo<{ if (!scroller) return; const MIN_HEIGHT = 72; + const STICKY_TOP = 8; + let scrollerTop = scroller.getBoundingClientRect().top; let scrollerHeight = scroller.clientHeight; @@ -827,7 +766,8 @@ const StickyUserMessage = memo<{ if (tooTall) { container.style.setProperty("--clip-h", `${fullHeight}px`); container.style.setProperty("--fade-opacity", "0"); - container.style.top = "0px"; + container.style.top = `${STICKY_TOP}px`; + return; } const sentinelTop = sentinel.getBoundingClientRect().top; @@ -838,7 +778,8 @@ const StickyUserMessage = memo<{ // correct height immediately when isStuck flips. container.style.setProperty("--clip-h", `${fullHeight}px`); container.style.setProperty("--fade-opacity", "0"); - container.style.top = "0px"; + container.style.top = `${STICKY_TOP}px`; + return; } const visible = Math.max(fullHeight - scrolledPast, MIN_HEIGHT); @@ -865,9 +806,9 @@ const StickyUserMessage = memo<{ } if (nextSentinel) { const nextY = nextSentinel.getBoundingClientRect().top - scrollerTop; - container.style.top = `${Math.min(0, nextY - visible)}px`; + container.style.top = `${Math.min(STICKY_TOP, nextY - visible + STICKY_TOP)}px`; } else { - container.style.top = "0px"; + container.style.top = `${STICKY_TOP}px`; } }; updateFnRef.current = update; @@ -962,7 +903,7 @@ const StickyUserMessage = memo<{
; showDesktopPreviews?: boolean; - // When true the current turn is still in progress (the agent - // is streaming or a tool call is running). The copy button on - // the trailing assistant message is suppressed because the - // content is not yet final. - isTurnActive: boolean; + isTurnActive?: boolean; } export const ConversationTimeline = memo( @@ -1074,7 +1011,6 @@ export const ConversationTimeline = memo( mcpServers, computerUseSubagentIds, showDesktopPreviews, - isTurnActive, }) => { if (parsedMessages.length === 0) { return null; @@ -1097,22 +1033,10 @@ export const ConversationTimeline = memo( } return ( -
- {(() => { - const lastAssistantPerTurnIds = new Set(); - let lastAsstId: number | null = null; - for (const { message: m } of parsedMessages) { - if (m.role === "assistant") lastAsstId = m.id; - else if (m.role === "user" && lastAsstId != null) { - lastAssistantPerTurnIds.add(lastAsstId); - lastAsstId = null; - } - } - if (lastAsstId != null && !isTurnActive) { - lastAssistantPerTurnIds.add(lastAsstId); - } - return parsedMessages.map(({ message, parsed }, msgIdx) => - message.role === "user" ? ( +
+ {parsedMessages.map(({ message, parsed }, msgIdx) => { + if (message.role === "user") { + return ( ( savingMessageId={savingMessageId} isAfterEditingMessage={afterEditingMessageIds.has(message.id)} /> - ) : ( - - ), + ); + } + // Hide actions on assistant messages that are not + // the last in a consecutive assistant chain. + const next = parsedMessages[msgIdx + 1]; + const isLastInChain = !next || next.message.role === "user"; + return ( + ); - })()} + })}
); }, diff --git a/site/src/pages/AgentsPage/components/ChatPageContent.tsx b/site/src/pages/AgentsPage/components/ChatPageContent.tsx index 746d63915c..95058771ed 100644 --- a/site/src/pages/AgentsPage/components/ChatPageContent.tsx +++ b/site/src/pages/AgentsPage/components/ChatPageContent.tsx @@ -14,7 +14,6 @@ import { import { ConversationTimeline } from "./ChatConversation/ConversationTimeline"; import { getLatestContextUsage } from "./ChatConversation/chatHelpers"; import { - isActiveChatStatus, selectChatStatus, selectHasStreamState, selectMessagesByID, @@ -66,9 +65,6 @@ export const ChatPageTimeline: FC = ({ }) => { const messagesByID = useChatSelector(store, selectMessagesByID); const orderedMessageIDs = useChatSelector(store, selectOrderedMessageIDs); - const chatStatus = useChatSelector(store, selectChatStatus); - const hasStreamState = useChatSelector(store, selectHasStreamState); - const isTurnActive = isActiveChatStatus(chatStatus) || hasStreamState; const messages = orderedMessageIDs .map((messageID) => messagesByID.get(messageID)) @@ -96,7 +92,6 @@ export const ChatPageTimeline: FC = ({ mcpServers={mcpServers} computerUseSubagentIds={computerUseSubagentIds} showDesktopPreviews={false} - isTurnActive={isTurnActive} />