Compare commits

...

1 Commits

Author SHA1 Message Date
Jamison Lahman
1bee6536f8 chore(fe): move message padding to chat container 2025-12-19 08:12:15 -08:00
4 changed files with 399 additions and 404 deletions

View File

@@ -122,15 +122,15 @@ function MessageEditing({
return (
<div className="w-full">
<div
className={cn(
className={
"w-full h-full border rounded-16 overflow-hidden p-3 flex flex-col gap-2"
)}
}
>
<textarea
ref={textareaRef}
className={cn(
className={
"w-full h-full resize-none outline-none bg-transparent overflow-y-scroll whitespace-normal break-word"
)}
}
aria-multiline
role="textarea"
value={editedContent}
@@ -226,108 +226,106 @@ export default function HumanMessage({
return (
<div
id="onyx-human-message"
className="pt-5 pb-1 w-full lg:px-5 flex justify-center -mr-6 relative"
className="text-user-text pt-5 pb-1 w-full flex justify-end -mr-6 relative"
onMouseEnter={() => setIsHovered(true)}
onMouseLeave={() => setIsHovered(false)}
>
<div className={cn("text-user-text max-w-[790px] px-4 w-full")}>
<FileDisplay alignBubble files={files || []} />
<div className="flex flex-wrap justify-end break-words">
{isEditing ? (
<MessageEditing
content={content}
onSubmitEdit={(editedContent) => {
onEdit?.(editedContent);
setContent(editedContent);
setIsEditing(false);
}}
onCancelEdit={() => setIsEditing(false)}
/>
) : typeof content === "string" ? (
<>
<div className="md:max-w-[25rem] flex basis-[100%] md:basis-auto justify-end md:order-1">
<div
className={
"max-w-[25rem] whitespace-break-spaces rounded-t-16 rounded-bl-16 bg-background-tint-02 py-2 px-3"
}
>
<Text mainContentBody>{content}</Text>
</div>
<FileDisplay alignBubble files={files || []} />
<div className="flex flex-wrap justify-end break-words">
{isEditing ? (
<MessageEditing
content={content}
onSubmitEdit={(editedContent) => {
onEdit?.(editedContent);
setContent(editedContent);
setIsEditing(false);
}}
onCancelEdit={() => setIsEditing(false)}
/>
) : typeof content === "string" ? (
<>
<div className="md:max-w-[25rem] flex basis-[100%] md:basis-auto justify-end md:order-1">
<div
className={
"max-w-[25rem] whitespace-break-spaces rounded-t-16 rounded-bl-16 bg-background-tint-02 py-2 px-3"
}
>
<Text mainContentBody>{content}</Text>
</div>
{onEdit &&
isHovered &&
!isEditing &&
(!files || files.length === 0) ? (
<div className="flex flex-row gap-1 p-1">
<CopyIconButton
getCopyText={() => content}
tertiary
data-testid="HumanMessage/copy-button"
/>
<IconButton
icon={SvgEdit}
tertiary
tooltip="Edit"
onClick={() => {
setIsEditing(true);
setIsHovered(false);
}}
data-testid="HumanMessage/edit-button"
/>
</div>
) : (
<div className="w-7 h-10" />
)}
</>
) : (
<>
{onEdit &&
isHovered &&
!isEditing &&
(!files || files.length === 0) ? (
<div className="my-auto">
<IconButton
icon={SvgEdit}
onClick={() => {
setIsEditing(true);
setIsHovered(false);
}}
tertiary
tooltip="Edit"
/>
</div>
) : (
<div className="h-[27px]" />
)}
<div className="ml-auto rounded-lg p-1">{content}</div>
</>
)}
<div className="md:min-w-[100%] flex justify-end order-1 mt-1">
{currentMessageInd !== undefined &&
onMessageSelection &&
otherMessagesCanSwitchTo &&
otherMessagesCanSwitchTo.length > 1 && (
<MessageSwitcher
disableForStreaming={disableSwitchingForStreaming}
currentPage={currentMessageInd + 1}
totalPages={otherMessagesCanSwitchTo.length}
handlePrevious={() => {
stopGenerating();
const prevMessage = getPreviousMessage();
if (prevMessage !== undefined) {
onMessageSelection(prevMessage);
}
}}
handleNext={() => {
stopGenerating();
const nextMessage = getNextMessage();
if (nextMessage !== undefined) {
onMessageSelection(nextMessage);
}
}}
</div>
{onEdit &&
isHovered &&
!isEditing &&
(!files || files.length === 0) ? (
<div className="flex flex-row gap-1 p-1">
<CopyIconButton
getCopyText={() => content}
tertiary
data-testid="HumanMessage/copy-button"
/>
)}
</div>
<IconButton
icon={SvgEdit}
tertiary
tooltip="Edit"
onClick={() => {
setIsEditing(true);
setIsHovered(false);
}}
data-testid="HumanMessage/edit-button"
/>
</div>
) : (
<div className="w-7 h-10" />
)}
</>
) : (
<>
{onEdit &&
isHovered &&
!isEditing &&
(!files || files.length === 0) ? (
<div className="my-auto">
<IconButton
icon={SvgEdit}
onClick={() => {
setIsEditing(true);
setIsHovered(false);
}}
tertiary
tooltip="Edit"
/>
</div>
) : (
<div className="h-[27px]" />
)}
<div className="ml-auto rounded-lg p-1">{content}</div>
</>
)}
<div className="md:min-w-[100%] flex justify-end order-1 mt-1">
{currentMessageInd !== undefined &&
onMessageSelection &&
otherMessagesCanSwitchTo &&
otherMessagesCanSwitchTo.length > 1 && (
<MessageSwitcher
disableForStreaming={disableSwitchingForStreaming}
currentPage={currentMessageInd + 1}
totalPages={otherMessagesCanSwitchTo.length}
handlePrevious={() => {
stopGenerating();
const prevMessage = getPreviousMessage();
if (prevMessage !== undefined) {
onMessageSelection(prevMessage);
}
}}
handleNext={() => {
stopGenerating();
const nextMessage = getNextMessage();
if (nextMessage !== undefined) {
onMessageSelection(nextMessage);
}
}}
/>
)}
</div>
</div>
</div>

View File

@@ -444,199 +444,192 @@ export default function AIMessage({
data-testid={displayComplete ? "onyx-ai-message" : undefined}
className="pb-5 md:pt-5 relative flex"
>
<div className="mx-auto w-[min(50rem,100%)] px-4 max-w-message-max">
<div className="flex items-start">
<AgentAvatar agent={chatState.assistant} size={24} />
<div className="max-w-message-max break-words pl-4">
<div
ref={markdownRef}
className="overflow-x-visible max-w-content-max focus:outline-none select-text"
onCopy={(e) => {
if (markdownRef.current) {
handleCopy(e, markdownRef as RefObject<HTMLDivElement>);
}
}}
>
{groupedPackets.length === 0 ? (
// Show blinking dot when no content yet but message is generating
<BlinkingDot addMargin />
) : (
(() => {
// Simple split: tools vs non-tools
const toolGroups = groupedPackets.filter(
(group) =>
group.packets[0] &&
isToolPacket(group.packets[0], false)
);
<div className="flex items-start">
<AgentAvatar agent={chatState.assistant} size={24} />
<div className="max-w-message-max break-words pl-4">
<div
ref={markdownRef}
className="overflow-x-visible max-w-content-max focus:outline-none select-text"
onCopy={(e) => {
if (markdownRef.current) {
handleCopy(e, markdownRef as RefObject<HTMLDivElement>);
}
}}
>
{groupedPackets.length === 0 ? (
// Show blinking dot when no content yet but message is generating
<BlinkingDot addMargin />
) : (
(() => {
// Simple split: tools vs non-tools
const toolGroups = groupedPackets.filter(
(group) =>
group.packets[0] && isToolPacket(group.packets[0], false)
);
// Non-tools include messages AND image generation
const displayGroups =
finalAnswerComing || toolGroups.length === 0
? groupedPackets.filter(
(group) =>
group.packets[0] &&
isDisplayPacket(group.packets[0])
)
: [];
// Non-tools include messages AND image generation
const displayGroups =
finalAnswerComing || toolGroups.length === 0
? groupedPackets.filter(
(group) =>
group.packets[0] &&
isDisplayPacket(group.packets[0])
)
: [];
return (
<>
{/* Render tool groups in multi-tool renderer */}
{toolGroups.length > 0 && (
<MultiToolRenderer
packetGroups={toolGroups}
chatState={effectiveChatState}
isComplete={finalAnswerComing}
isFinalAnswerComing={finalAnswerComingRef.current}
stopPacketSeen={stopPacketSeen}
isStreaming={globalChatState === "streaming"}
onAllToolsDisplayed={() =>
setFinalAnswerComing(true)
return (
<>
{/* Render tool groups in multi-tool renderer */}
{toolGroups.length > 0 && (
<MultiToolRenderer
packetGroups={toolGroups}
chatState={effectiveChatState}
isComplete={finalAnswerComing}
isFinalAnswerComing={finalAnswerComingRef.current}
stopPacketSeen={stopPacketSeen}
isStreaming={globalChatState === "streaming"}
onAllToolsDisplayed={() => setFinalAnswerComing(true)}
/>
)}
{/* Render all display groups (messages + image generation) in main area */}
{displayGroups.map((displayGroup, index) => (
<RendererComponent
key={`${displayGroup.turn_index}-${displayGroup.tab_index}`}
packets={displayGroup.packets}
chatState={effectiveChatState}
onComplete={() => {
// if we've reverted to final answer not coming, don't set display complete
// this happens when using claude and a tool calling packet comes after
// some message packets
// Only mark complete on the last display group
if (
finalAnswerComingRef.current &&
index === displayGroups.length - 1
) {
setDisplayComplete(true);
}
/>
)}
{/* Render all display groups (messages + image generation) in main area */}
{displayGroups.map((displayGroup, index) => (
<RendererComponent
key={`${displayGroup.turn_index}-${displayGroup.tab_index}`}
packets={displayGroup.packets}
chatState={effectiveChatState}
onComplete={() => {
// if we've reverted to final answer not coming, don't set display complete
// this happens when using claude and a tool calling packet comes after
// some message packets
// Only mark complete on the last display group
if (
finalAnswerComingRef.current &&
index === displayGroups.length - 1
) {
setDisplayComplete(true);
}
}}
animate={false}
stopPacketSeen={stopPacketSeen}
>
{({ content }) => <div>{content}</div>}
</RendererComponent>
))}
</>
);
})()
)}
</div>
{/* Feedback buttons - only show when streaming is complete */}
{stopPacketSeen && displayComplete && (
<div className="flex md:flex-row justify-between items-center w-full mt-1 transition-transform duration-300 ease-in-out transform opacity-100">
<TooltipGroup>
<div className="flex items-center gap-x-0.5">
{includeMessageSwitcher && (
<div className="-mx-1">
<MessageSwitcher
currentPage={(currentMessageInd ?? 0) + 1}
totalPages={otherMessagesCanSwitchTo?.length || 0}
handlePrevious={() => {
const prevMessage = getPreviousMessage();
if (
prevMessage !== undefined &&
onMessageSelection
) {
onMessageSelection(prevMessage);
}
}}
handleNext={() => {
const nextMessage = getNextMessage();
if (
nextMessage !== undefined &&
onMessageSelection
) {
onMessageSelection(nextMessage);
}
}}
/>
</div>
)}
<CopyIconButton
getCopyText={() =>
convertMarkdownTablesToTsv(getTextContent(rawPackets))
}
getHtmlContent={() =>
markdownRef.current?.innerHTML || ""
}
tertiary
data-testid="AIMessage/copy-button"
/>
<IconButton
icon={SvgThumbsUp}
onClick={() => handleFeedbackClick("like")}
tertiary
transient={isFeedbackTransient("like")}
tooltip={
currentFeedback === "like"
? "Remove Like"
: "Good Response"
}
data-testid="AIMessage/like-button"
/>
<IconButton
icon={SvgThumbsDown}
onClick={() => handleFeedbackClick("dislike")}
tertiary
transient={isFeedbackTransient("dislike")}
tooltip={
currentFeedback === "dislike"
? "Remove Dislike"
: "Bad Response"
}
data-testid="AIMessage/dislike-button"
/>
{chatState.regenerate && llmManager && (
<div data-testid="AIMessage/regenerate">
<LLMPopover
llmManager={llmManager}
currentModelName={chatState.overriddenModel}
onSelect={(modelName) => {
const llmDescriptor =
parseLlmDescriptor(modelName);
chatState.regenerate!(llmDescriptor);
}}
folded
/>
</div>
)}
{nodeId &&
(citations.length > 0 || documentMap.size > 0) && (
<CitedSourcesToggle
citations={citations}
documentMap={documentMap}
nodeId={nodeId}
onToggle={(toggledNodeId) => {
// Toggle sidebar if clicking on the same message
if (
selectedMessageForDocDisplay ===
toggledNodeId &&
documentSidebarVisible
) {
updateCurrentDocumentSidebarVisible(false);
updateCurrentSelectedNodeForDocDisplay(null);
} else {
updateCurrentSelectedNodeForDocDisplay(
toggledNodeId
);
updateCurrentDocumentSidebarVisible(true);
}
}}
/>
)}
</div>
</TooltipGroup>
</div>
}}
animate={false}
stopPacketSeen={stopPacketSeen}
>
{({ content }) => <div>{content}</div>}
</RendererComponent>
))}
</>
);
})()
)}
</div>
{/* Feedback buttons - only show when streaming is complete */}
{stopPacketSeen && displayComplete && (
<div className="flex md:flex-row justify-between items-center w-full mt-1 transition-transform duration-300 ease-in-out transform opacity-100">
<TooltipGroup>
<div className="flex items-center gap-x-0.5">
{includeMessageSwitcher && (
<div className="-mx-1">
<MessageSwitcher
currentPage={(currentMessageInd ?? 0) + 1}
totalPages={otherMessagesCanSwitchTo?.length || 0}
handlePrevious={() => {
const prevMessage = getPreviousMessage();
if (
prevMessage !== undefined &&
onMessageSelection
) {
onMessageSelection(prevMessage);
}
}}
handleNext={() => {
const nextMessage = getNextMessage();
if (
nextMessage !== undefined &&
onMessageSelection
) {
onMessageSelection(nextMessage);
}
}}
/>
</div>
)}
<CopyIconButton
getCopyText={() =>
convertMarkdownTablesToTsv(getTextContent(rawPackets))
}
getHtmlContent={() =>
markdownRef.current?.innerHTML || ""
}
tertiary
data-testid="AIMessage/copy-button"
/>
<IconButton
icon={SvgThumbsUp}
onClick={() => handleFeedbackClick("like")}
tertiary
transient={isFeedbackTransient("like")}
tooltip={
currentFeedback === "like"
? "Remove Like"
: "Good Response"
}
data-testid="AIMessage/like-button"
/>
<IconButton
icon={SvgThumbsDown}
onClick={() => handleFeedbackClick("dislike")}
tertiary
transient={isFeedbackTransient("dislike")}
tooltip={
currentFeedback === "dislike"
? "Remove Dislike"
: "Bad Response"
}
data-testid="AIMessage/dislike-button"
/>
{chatState.regenerate && llmManager && (
<div data-testid="AIMessage/regenerate">
<LLMPopover
llmManager={llmManager}
currentModelName={chatState.overriddenModel}
onSelect={(modelName) => {
const llmDescriptor = parseLlmDescriptor(modelName);
chatState.regenerate!(llmDescriptor);
}}
folded
/>
</div>
)}
{nodeId &&
(citations.length > 0 || documentMap.size > 0) && (
<CitedSourcesToggle
citations={citations}
documentMap={documentMap}
nodeId={nodeId}
onToggle={(toggledNodeId) => {
// Toggle sidebar if clicking on the same message
if (
selectedMessageForDocDisplay === toggledNodeId &&
documentSidebarVisible
) {
updateCurrentDocumentSidebarVisible(false);
updateCurrentSelectedNodeForDocDisplay(null);
} else {
updateCurrentSelectedNodeForDocDisplay(
toggledNodeId
);
updateCurrentDocumentSidebarVisible(true);
}
}}
/>
)}
</div>
</TooltipGroup>
</div>
)}
</div>
</div>
</div>

View File

@@ -76,7 +76,7 @@ export default function SharedChatDisplay({
</div>
{isMounted ? (
<div className="w-full px-8">
<div className="max-w-[50rem] m-auto">
{messages.map((message, i) => {
if (message.type === "user") {
return (

View File

@@ -198,133 +198,137 @@ const ChatUI = React.forwardRef(
<div
key={currentChatSessionId}
ref={scrollContainerRef}
className="flex-1 min-h-0 overflow-y-auto overflow-x-hidden default-scrollbar"
className="flex flex-1 justify-center min-h-0 px-6 overflow-y-auto overflow-x-hidden default-scrollbar"
onScroll={handleScroll}
>
{messages.map((message, i) => {
const messageReactComponentKey = `message-${message.nodeId}`;
const parentMessage = message.parentNodeId
? messageTree?.get(message.parentNodeId)
: null;
<div>
{messages.map((message, i) => {
const messageReactComponentKey = `message-${message.nodeId}`;
const parentMessage = message.parentNodeId
? messageTree?.get(message.parentNodeId)
: null;
if (message.type === "user") {
const nextMessage =
messages.length > i + 1 ? messages[i + 1] : null;
if (message.type === "user") {
const nextMessage =
messages.length > i + 1 ? messages[i + 1] : null;
return (
<div
id={messageReactComponentKey}
key={messageReactComponentKey}
>
<HumanMessage
disableSwitchingForStreaming={
(nextMessage && nextMessage.is_generating) || false
}
stopGenerating={stopGenerating}
content={message.message}
files={message.files}
messageId={message.messageId}
onEdit={(editedContent) => {
if (
message.messageId !== undefined &&
message.messageId !== null
) {
handleEditWithMessageId(
editedContent,
message.messageId
);
}
}}
otherMessagesCanSwitchTo={
parentMessage?.childrenNodeIds ?? emptyChildrenIds
}
onMessageSelection={onMessageSelection}
/>
</div>
);
} else if (message.type === "assistant") {
if ((error || loadError) && i === messages.length - 1) {
return (
<div
key={`error-${message.nodeId}`}
className="max-w-message-max mx-auto"
id={messageReactComponentKey}
key={messageReactComponentKey}
>
<ErrorBanner
resubmit={handleResubmitLastMessage}
error={error || loadError || ""}
errorCode={message.errorCode || undefined}
isRetryable={message.isRetryable ?? true}
details={message.errorDetails || undefined}
stackTrace={message.stackTrace || undefined}
<HumanMessage
disableSwitchingForStreaming={
(nextMessage && nextMessage.is_generating) || false
}
stopGenerating={stopGenerating}
content={message.message}
files={message.files}
messageId={message.messageId}
onEdit={(editedContent) => {
if (
message.messageId !== undefined &&
message.messageId !== null
) {
handleEditWithMessageId(
editedContent,
message.messageId
);
}
}}
otherMessagesCanSwitchTo={
parentMessage?.childrenNodeIds ?? emptyChildrenIds
}
onMessageSelection={onMessageSelection}
/>
</div>
);
} else if (message.type === "assistant") {
if ((error || loadError) && i === messages.length - 1) {
return (
<div
key={`error-${message.nodeId}`}
className="max-w-message-max mx-auto"
>
<ErrorBanner
resubmit={handleResubmitLastMessage}
error={error || loadError || ""}
errorCode={message.errorCode || undefined}
isRetryable={message.isRetryable ?? true}
details={message.errorDetails || undefined}
stackTrace={message.stackTrace || undefined}
/>
</div>
);
}
// NOTE: it's fine to use the previous entry in messageHistory
// since this is a "parsed" version of the message tree
// so the previous message is guaranteed to be the parent of the current message
const previousMessage = i !== 0 ? messages[i - 1] : null;
const regenerate =
message.messageId !== undefined && previousMessage
? createRegenerator({
messageId: message.messageId,
parentMessage: previousMessage,
})
: undefined;
const chatStateData = {
assistant: liveAssistant,
docs: message.documents ?? emptyDocs,
citations: message.citations,
setPresentingDocument,
regenerate,
overriddenModel: llmManager.currentLlm?.modelName,
researchType: message.researchType,
};
return (
<div
id={`message-${message.nodeId}`}
key={messageReactComponentKey}
>
<AIMessage
rawPackets={message.packets}
chatState={chatStateData}
nodeId={message.nodeId}
messageId={message.messageId}
currentFeedback={message.currentFeedback}
llmManager={llmManager}
otherMessagesCanSwitchTo={
parentMessage?.childrenNodeIds ?? emptyChildrenIds
}
onMessageSelection={onMessageSelection}
/>
</div>
);
}
})}
// NOTE: it's fine to use the previous entry in messageHistory
// since this is a "parsed" version of the message tree
// so the previous message is guaranteed to be the parent of the current message
const previousMessage = i !== 0 ? messages[i - 1] : null;
const regenerate =
message.messageId !== undefined && previousMessage
? createRegenerator({
messageId: message.messageId,
parentMessage: previousMessage,
})
: undefined;
const chatStateData = {
assistant: liveAssistant,
docs: message.documents ?? emptyDocs,
citations: message.citations,
setPresentingDocument,
regenerate,
overriddenModel: llmManager.currentLlm?.modelName,
researchType: message.researchType,
};
return (
<div
id={`message-${message.nodeId}`}
key={messageReactComponentKey}
>
<AIMessage
rawPackets={message.packets}
chatState={chatStateData}
nodeId={message.nodeId}
messageId={message.messageId}
currentFeedback={message.currentFeedback}
llmManager={llmManager}
otherMessagesCanSwitchTo={
parentMessage?.childrenNodeIds ?? emptyChildrenIds
}
onMessageSelection={onMessageSelection}
/>
</div>
);
}
})}
{(((error !== null || loadError !== null) &&
messages[messages.length - 1]?.type === "user") ||
messages[messages.length - 1]?.type === "error") && (
<div className="max-w-message-max mx-auto">
<ErrorBanner
resubmit={handleResubmitLastMessage}
error={error || loadError || ""}
errorCode={
messages[messages.length - 1]?.errorCode || undefined
}
isRetryable={
messages[messages.length - 1]?.isRetryable ?? true
}
details={
messages[messages.length - 1]?.errorDetails || undefined
}
stackTrace={
messages[messages.length - 1]?.stackTrace || undefined
}
/>
</div>
)}
{(((error !== null || loadError !== null) &&
messages[messages.length - 1]?.type === "user") ||
messages[messages.length - 1]?.type === "error") && (
<div className="max-w-message-max mx-auto">
<ErrorBanner
resubmit={handleResubmitLastMessage}
error={error || loadError || ""}
errorCode={
messages[messages.length - 1]?.errorCode || undefined
}
isRetryable={messages[messages.length - 1]?.isRetryable ?? true}
details={
messages[messages.length - 1]?.errorDetails || undefined
}
stackTrace={
messages[messages.length - 1]?.stackTrace || undefined
}
/>
</div>
)}
<div ref={endDivRef} />
<div ref={endDivRef} />
</div>
</div>
</div>
);