From 615181d1279f0d61202f2a7805fb8019777958b1 Mon Sep 17 00:00:00 2001 From: Dev Date: Mon, 27 Jul 2026 00:03:38 +0400 Subject: [PATCH] feat(web): add expandable conversation view and linebreak toggle - Expand/shrink floating button on the conversation tab fills the viewport by restyling DialogContent in place; Esc shrinks before closing the dialog - Linebreak toggle (conversation tab only, off by default) wraps long lines and fully expands all content blocks via shared block-styles helper - Fix useCollapsible isLong returning "" | boolean --- apps/web/src/components/ConversationView.tsx | 9 +- .../src/components/RequestDetailsModal.tsx | 323 +++++++++++------- .../src/components/conversation/Message.tsx | 15 +- .../components/conversation/MessageBubble.tsx | 20 +- .../components/conversation/ThinkingBlock.tsx | 18 +- .../conversation/ToolResultBlock.tsx | 19 +- .../conversation/ToolUsageBlock.tsx | 20 +- .../components/conversation/block-styles.ts | 28 ++ .../conversation/linebreak.test.tsx | 40 +++ apps/web/src/hooks/useCollapsible.ts | 2 +- 10 files changed, 342 insertions(+), 152 deletions(-) create mode 100644 apps/web/src/components/conversation/block-styles.ts create mode 100644 apps/web/src/components/conversation/linebreak.test.tsx diff --git a/apps/web/src/components/ConversationView.tsx b/apps/web/src/components/ConversationView.tsx index 74ed6bbdc..13d3c432d 100644 --- a/apps/web/src/components/ConversationView.tsx +++ b/apps/web/src/components/ConversationView.tsx @@ -17,12 +17,16 @@ interface ConversationViewProps { requestBody?: string | null; responseBody?: string | null; entries?: ConversationEntry[]; + linebreak?: boolean; + expanded?: boolean; } function ConversationViewComponent({ requestBody, responseBody, entries, + linebreak = false, + expanded = false, }: ConversationViewProps) { const messages = useMemo(() => { const resolvedEntries = @@ -85,7 +89,9 @@ function ConversationViewComponent({ } return ( -
+
{messages.map((message, index) => ( ))}
diff --git a/apps/web/src/components/RequestDetailsModal.tsx b/apps/web/src/components/RequestDetailsModal.tsx index 68bc68912..1e9750aa6 100644 --- a/apps/web/src/components/RequestDetailsModal.tsx +++ b/apps/web/src/components/RequestDetailsModal.tsx @@ -11,7 +11,7 @@ import { formatTokens, } from "@ccflare/ui"; import { useQuery } from "@tanstack/react-query"; -import { Eye } from "lucide-react"; +import { Eye, Maximize2, Minimize2 } from "lucide-react"; import { useMemo, useState } from "react"; import { api } from "../api"; import { queryKeys } from "../lib/query-keys"; @@ -20,6 +20,7 @@ import { ConversationView } from "./ConversationView"; import { CopyButton } from "./CopyButton"; import { TokenUsageDisplay } from "./TokenUsageDisplay"; import { Badge } from "./ui/badge"; +import { Button } from "./ui/button"; import { Dialog, DialogContent, @@ -45,6 +46,9 @@ export function RequestDetailsModal({ onClose, }: RequestDetailsModalProps) { const [beautifyMode, setBeautifyMode] = useState(true); + const [linebreakMode, setLinebreakMode] = useState(false); + const [activeTab, setActiveTab] = useState("conversation"); + const [expanded, setExpanded] = useState(false); const { data: conversationChain = [], isLoading: conversationLoading, @@ -73,9 +77,46 @@ export function RequestDetailsModal({ const statusCode = request.response?.status; + const handleOpenChange = (open: boolean) => { + if (!open) { + setExpanded(false); + onClose(); + } + }; + + const conversationContent = conversationLoading ? ( +
+ Loading conversation... +
+ ) : conversationError ? ( +
+ {conversationError instanceof Error + ? conversationError.message + : "Failed to load conversation"} +
+ ) : ( + + ); + return ( - - + + { + if (expanded) { + e.preventDefault(); + setExpanded(false); + } + }} + > @@ -106,119 +147,109 @@ export function RequestDetailsModal({ Rate Limited )}
-
- - +
+ {(activeTab === "conversation" || expanded) && ( +
+ + +
+ )} +
+ + +
- - - Conversation - Request - Response - Metadata - Token Usage - - - - {conversationLoading ? ( -
- Loading conversation... -
- ) : conversationError ? ( -
- {conversationError instanceof Error - ? conversationError.message - : "Failed to load conversation"} -
- ) : ( - - )} -
- - + {conversationContent} +
+ ) : ( + -
-
-

Headers

- formatHeaders(request.request.headers)} - > - Copy - -
-
-								{formatHeaders(request.request.headers)}
-							
-
+ + Conversation + Request + Response + Metadata + Token Usage + - {request.request.body && ( + + {conversationContent} + + +
-

Body

+

Headers

formatBody(request.request.body)} + getValue={() => formatHeaders(request.request.headers)} > Copy
-									{formatBody(request.request.body)}
+									{formatHeaders(request.request.headers)}
 								
- )} -
- - {request.response ? ( - <> + {request.request.body && (
-

Headers

+

Body

- request.response - ? formatHeaders(request.response.headers) - : "" - } + getValue={() => formatBody(request.request.body)} > Copy
-										{formatHeaders(request.response.headers)}
+										{formatBody(request.request.body)}
 									
+ )} +
- {request.response.body && ( + + {request.response ? ( + <>
-

Body

+

Headers

request.response - ? formatBody(request.response.body) + ? formatHeaders(request.response.headers) : "" } > @@ -226,61 +257,101 @@ export function RequestDetailsModal({
-											{formatBody(request.response.body)}
+											{formatHeaders(request.response.headers)}
 										
- )} - - ) : ( -
- {request.error ? ( - <> -

- Error: {request.error} -

-

No response data available

- - ) : ( -

No response data available

- )} -
- )} -
- -
-
-

Request Metadata

- - beautifyMode - ? JSON.stringify(request.meta, null, 2) - : JSON.stringify(request.meta) - } - > - Copy - + {request.response.body && ( +
+
+

Body

+ + request.response + ? formatBody(request.response.body) + : "" + } + > + Copy + +
+
+												{formatBody(request.response.body)}
+											
+
+ )} + + ) : ( +
+ {request.error ? ( + <> +

+ Error: {request.error} +

+

No response data available

+ + ) : ( +

No response data available

+ )} +
+ )} + + + +
+
+

Request Metadata

+ + beautifyMode + ? JSON.stringify(request.meta, null, 2) + : JSON.stringify(request.meta) + } + > + Copy + +
+
+									{beautifyMode
+										? JSON.stringify(request.meta, null, 2)
+										: JSON.stringify(request.meta)}
+								
-
-								{beautifyMode
-									? JSON.stringify(request.meta, null, 2)
-									: JSON.stringify(request.meta)}
-							
-
- + + + + + + + )} - setExpanded((prev) => !prev)} + aria-label={ + expanded ? "Shrink conversation view" : "Expand conversation view" + } > - - - + {expanded ? ( + + ) : ( + + )} + + )} ); diff --git a/apps/web/src/components/conversation/Message.tsx b/apps/web/src/components/conversation/Message.tsx index 4a9a20201..6788c3295 100644 --- a/apps/web/src/components/conversation/Message.tsx +++ b/apps/web/src/components/conversation/Message.tsx @@ -23,6 +23,7 @@ interface MessageProps { tools?: ToolUse[]; toolResults?: ToolResult[]; cleanLineNumbers: (content: string) => string; + linebreak?: boolean; } const ROLE_STYLES: Record = { @@ -38,6 +39,7 @@ function MessageComponent({ tools, toolResults, cleanLineNumbers, + linebreak = false, }: MessageProps) { const isRightAligned = role === "user"; const thinkingBlock = contentBlocks?.find( @@ -104,7 +106,10 @@ function MessageComponent({ {/* Thinking block */} {hasThinking && thinkingBlock && (
- +
)} @@ -121,7 +126,11 @@ function MessageComponent({ )} {displayContent.length > 0 && ( - + )} {/* Tool usage */} @@ -132,6 +141,7 @@ function MessageComponent({ key={`tool-${tool.id || `${tool.name}-${JSON.stringify(tool.input)}`}`} toolName={tool.name} input={tool.input} + linebreak={linebreak} /> ))}
@@ -142,6 +152,7 @@ function MessageComponent({
{toolResults?.map((result, index) => ( = { system: "bg-accent/10", }; -function MessageBubbleComponent({ role, content }: MessageBubbleProps) { +function MessageBubbleComponent({ + role, + content, + linebreak = false, +}: MessageBubbleProps) { const { display, isLong, isExpanded, toggle } = useCollapsible( content, MAX_CHARS_COLLAPSE, @@ -27,14 +33,16 @@ function MessageBubbleComponent({ role, content }: MessageBubbleProps) {
- {display} + {linebreak ? content : display}
- {isLong && ( + {isLong && !linebreak && (
- {isLong && ( + {isLong && !linebreak && (
-
- {display} +
+ {linebreak ? content : display}
); diff --git a/apps/web/src/components/conversation/ToolResultBlock.tsx b/apps/web/src/components/conversation/ToolResultBlock.tsx index fb698baee..424414f88 100644 --- a/apps/web/src/components/conversation/ToolResultBlock.tsx +++ b/apps/web/src/components/conversation/ToolResultBlock.tsx @@ -2,14 +2,19 @@ import { FileText } from "lucide-react"; import React from "react"; import { useCollapsible } from "../../hooks/useCollapsible"; import { Button } from "../ui/button"; +import { blockContentClass } from "./block-styles"; interface ToolResultBlockProps { content: string; + linebreak?: boolean; } const MAX_CHARS_COLLAPSE = 200; -function ToolResultBlockComponent({ content }: ToolResultBlockProps) { +function ToolResultBlockComponent({ + content, + linebreak = false, +}: ToolResultBlockProps) { const { display, isLong, isExpanded, toggle } = useCollapsible( content, MAX_CHARS_COLLAPSE, @@ -22,7 +27,7 @@ function ToolResultBlockComponent({ content }: ToolResultBlockProps) { Tool Result
- {isLong && ( + {isLong && !linebreak && (