mirror of
https://github.com/cline/cline.git
synced 2026-10-29 17:42:34 +08:00
style(desktop): refine session chat layout (#13205)
* style(ui): refine chat message surfaces and actions * feat(desktop): refine session transcript layout * style(desktop): refine conversation composer * style(ui): format chat theme changes
This commit is contained in:
@@ -113,7 +113,7 @@ function ComboboxContent({
|
||||
>
|
||||
<ComboboxPrimitive.Popup
|
||||
className={cn(
|
||||
"bg-popover text-popover-foreground data-open:animate-in data-closed:animate-out data-closed:fade-out-0 data-open:fade-in-0 data-closed:zoom-out-95 data-open:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 ring-foreground/10 *:data-[slot=input-group]:bg-input/30 *:data-[slot=input-group]:border-input/30 group/combobox-content relative max-h-96 w-(--anchor-width) max-w-(--available-width) min-w-[calc(var(--anchor-width)+--spacing(7))] origin-(--transform-origin) overflow-hidden rounded-md shadow-md ring-1 duration-100 data-[chips=true]:min-w-(--anchor-width) *:data-[slot=input-group]:m-1 *:data-[slot=input-group]:mb-0 *:data-[slot=input-group]:h-8 *:data-[slot=input-group]:shadow-none",
|
||||
"bg-popover text-popover-foreground data-open:animate-in data-closed:animate-out data-closed:fade-out-0 data-open:fade-in-0 data-closed:zoom-out-95 data-open:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 ring-foreground/10 *:data-[slot=input-group]:bg-input/30 *:data-[slot=input-group]:border-input/30 group/combobox-content relative max-h-96 w-(--anchor-width) max-w-(--available-width) min-w-[calc(var(--anchor-width)+(--spacing(7)))] origin-(--transform-origin) overflow-hidden rounded-md shadow-md ring-1 duration-100 data-[chips=true]:min-w-(--anchor-width) *:data-[slot=input-group]:m-1 *:data-[slot=input-group]:mb-0 *:data-[slot=input-group]:h-8 *:data-[slot=input-group]:shadow-none",
|
||||
className,
|
||||
)}
|
||||
data-chips={!!anchor}
|
||||
@@ -129,7 +129,7 @@ function ComboboxList({ className, ...props }: ComboboxPrimitive.List.Props) {
|
||||
return (
|
||||
<ComboboxPrimitive.List
|
||||
className={cn(
|
||||
"max-h-[min(calc(--spacing(96)---spacing(9)),calc(var(--available-height)---spacing(9)))] scroll-py-1 overflow-y-auto p-1 data-empty:p-0",
|
||||
"max-h-[min(calc(--spacing(96)-(--spacing(9))),calc(var(--available-height)-(--spacing(9))))] scroll-py-1 overflow-y-auto p-1 data-empty:p-0",
|
||||
className,
|
||||
)}
|
||||
data-slot="combobox-list"
|
||||
|
||||
@@ -42,7 +42,7 @@ function DropdownMenuContent({
|
||||
data-slot="dropdown-menu-content"
|
||||
sideOffset={sideOffset}
|
||||
className={cn(
|
||||
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 max-h-(--radix-dropdown-menu-content-available-height) min-w-[8rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border p-1 shadow-md",
|
||||
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 max-h-(--radix-dropdown-menu-content-available-height) min-w-32 origin-(--radix-dropdown-menu-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border p-1 shadow-md",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
@@ -74,7 +74,7 @@ function DropdownMenuItem({
|
||||
data-inset={inset}
|
||||
data-variant={variant}
|
||||
className={cn(
|
||||
"focus:bg-surface-hover focus:text-foreground data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 dark:data-[variant=destructive]:focus:bg-destructive/20 data-[variant=destructive]:focus:text-destructive data-[variant=destructive]:*:[svg]:!text-destructive [&_svg:not([class*='text-'])]:text-muted-foreground relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
"focus:bg-surface-hover focus:text-foreground data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 dark:data-[variant=destructive]:focus:bg-destructive/20 data-[variant=destructive]:focus:text-destructive data-[variant=destructive]:*:[svg]:text-destructive! [&_svg:not([class*='text-'])]:text-muted-foreground relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-disabled:pointer-events-none data-disabled:opacity-50 data-inset:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
@@ -92,7 +92,7 @@ function DropdownMenuCheckboxItem({
|
||||
<DropdownMenuPrimitive.CheckboxItem
|
||||
data-slot="dropdown-menu-checkbox-item"
|
||||
className={cn(
|
||||
"focus:bg-surface-hover focus:text-foreground relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
"focus:bg-surface-hover focus:text-foreground relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
className,
|
||||
)}
|
||||
checked={checked}
|
||||
@@ -128,7 +128,7 @@ function DropdownMenuRadioItem({
|
||||
<DropdownMenuPrimitive.RadioItem
|
||||
data-slot="dropdown-menu-radio-item"
|
||||
className={cn(
|
||||
"focus:bg-surface-hover focus:text-foreground relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
"focus:bg-surface-hover focus:text-foreground relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
@@ -155,7 +155,7 @@ function DropdownMenuLabel({
|
||||
data-slot="dropdown-menu-label"
|
||||
data-inset={inset}
|
||||
className={cn(
|
||||
"px-2 py-1.5 text-sm font-medium data-[inset]:pl-8",
|
||||
"px-2 py-1.5 text-sm font-medium data-inset:pl-8",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
@@ -211,7 +211,7 @@ function DropdownMenuSubTrigger({
|
||||
data-slot="dropdown-menu-sub-trigger"
|
||||
data-inset={inset}
|
||||
className={cn(
|
||||
"focus:bg-surface-hover focus:text-foreground data-[state=open]:bg-surface-hover data-[state=open]:text-foreground [&_svg:not([class*='text-'])]:text-muted-foreground flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
"focus:bg-surface-hover focus:text-foreground data-[state=open]:bg-surface-hover data-[state=open]:text-foreground [&_svg:not([class*='text-'])]:text-muted-foreground flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-inset:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
@@ -230,7 +230,7 @@ function DropdownMenuSubContent({
|
||||
<DropdownMenuPrimitive.SubContent
|
||||
data-slot="dropdown-menu-sub-content"
|
||||
className={cn(
|
||||
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 min-w-[8rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-hidden rounded-md border p-1 shadow-lg",
|
||||
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 min-w-32 origin-(--radix-dropdown-menu-content-transform-origin) overflow-hidden rounded-md border p-1 shadow-lg",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
|
||||
@@ -141,9 +141,8 @@ const EFFORT_LEVELS: ReasoningEffortOption[] = [
|
||||
{ label: "High", value: "high" },
|
||||
{ label: "Extra", value: "xhigh" },
|
||||
];
|
||||
// The composer keeps a steady two-line height whether or not it has focus —
|
||||
// resizing on blur made the bottom of the conversation jump around.
|
||||
const PROMPT_INPUT_ROWS = 2;
|
||||
const PROMPT_INPUT_COLLAPSED_ROWS = 1;
|
||||
const PROMPT_INPUT_EXPANDED_ROWS = 2;
|
||||
const PROMPT_INPUT_MAX_ROWS = 5;
|
||||
const PROMPT_INPUT_LINE_HEIGHT_REM = 1.25;
|
||||
|
||||
@@ -378,6 +377,7 @@ function ChatInputBarImpl({
|
||||
}, [onSend, promptInput, setPromptInput]);
|
||||
const fileInputRef = useRef<HTMLInputElement | null>(null);
|
||||
const promptInputRef = useRef<HTMLTextAreaElement | null>(null);
|
||||
const [promptInputFocused, setPromptInputFocused] = useState(false);
|
||||
const [cursorIndex, setCursorIndex] = useState(() => promptInput.length);
|
||||
// Mention/slash detection is derived synchronously from the input +
|
||||
// cursor. Deriving (rather than syncing through effects) keeps a keystroke
|
||||
@@ -429,7 +429,10 @@ function ChatInputBarImpl({
|
||||
: !hasExplicitReasoningSelection && modelSupportsReasoning === false
|
||||
? "None"
|
||||
: (EFFORT_LEVELS[effortIndex]?.label ?? "Reasoning");
|
||||
const promptInputRows = PROMPT_INPUT_ROWS;
|
||||
const promptInputRows =
|
||||
variant === "welcome" || promptInputFocused
|
||||
? PROMPT_INPUT_EXPANDED_ROWS
|
||||
: PROMPT_INPUT_COLLAPSED_ROWS;
|
||||
const handleEffortChange = useCallback(
|
||||
(value: string) => {
|
||||
if (modelSupportsReasoning !== true) {
|
||||
@@ -656,12 +659,17 @@ function ChatInputBarImpl({
|
||||
className={cn(
|
||||
"bg-card",
|
||||
variant === "welcome"
|
||||
? "overflow-visible rounded-xl border border-border/90 bg-card/90 shadow-[0_24px_80px_-56px_color-mix(in_oklab,var(--primary)_72%,transparent)] backdrop-blur-md"
|
||||
: "border-t border-border bg-card/95 backdrop-blur-sm",
|
||||
? "overflow-visible rounded-xl border border-border/90 bg-surface-1/40 shadow-[0_24px_80px_-56px_color-mix(in_oklab,var(--primary)_72%,transparent)] backdrop-blur-md"
|
||||
: "overflow-visible rounded-xl border border-border bg-surface-2 backdrop-blur-sm focus-within:border-primary/50 focus-within:ring-1 focus-within:ring-primary/20",
|
||||
)}
|
||||
>
|
||||
{/* Input area */}
|
||||
<div className={cn("px-4 py-3", variant === "welcome" && "pb-2 pt-4")}>
|
||||
<div
|
||||
className={cn(
|
||||
"px-4 py-3",
|
||||
variant === "welcome" ? "pb-2 pt-4" : "py-4",
|
||||
)}
|
||||
>
|
||||
<AgentPromptQueue
|
||||
items={displayPromptsInQueue}
|
||||
onEdit={onEditPromptInQueue}
|
||||
@@ -754,14 +762,25 @@ function ChatInputBarImpl({
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{/* biome-ignore lint/a11y/noStaticElementInteractions: Empty composer space forwards pointer focus to the nested textarea; keyboard users focus the textarea directly. */}
|
||||
<div
|
||||
className={cn(
|
||||
// Focus feedback lands instantly — no transition — so the
|
||||
// border reads as state, not animation.
|
||||
"flex items-end gap-2 rounded-lg border border-border bg-background px-3 py-2.5 focus-within:border-primary/50 focus-within:ring-1 focus-within:ring-primary/20",
|
||||
variant === "welcome" &&
|
||||
"min-h-16 rounded-none border-0 bg-transparent px-0 py-0 focus-within:ring-0",
|
||||
variant === "welcome"
|
||||
? "min-h-16 rounded-none border-0 bg-transparent px-0 py-0 focus-within:ring-0"
|
||||
: "min-h-24 items-start rounded-none border-0 bg-transparent px-0 py-0 focus-within:border-transparent focus-within:ring-0",
|
||||
)}
|
||||
onMouseDown={(event) => {
|
||||
const target = event.target;
|
||||
if (
|
||||
target instanceof HTMLElement &&
|
||||
target.closest("button, input, textarea")
|
||||
) {
|
||||
return;
|
||||
}
|
||||
event.preventDefault();
|
||||
promptInputRef.current?.focus();
|
||||
}}
|
||||
>
|
||||
<textarea
|
||||
aria-activedescendant={
|
||||
@@ -796,6 +815,8 @@ function ChatInputBarImpl({
|
||||
e.currentTarget.selectionStart ?? promptInput.length,
|
||||
)
|
||||
}
|
||||
onBlur={() => setPromptInputFocused(false)}
|
||||
onFocus={() => setPromptInputFocused(true)}
|
||||
onPaste={(e) => {
|
||||
const images = imageFilesFromClipboard(e.clipboardData);
|
||||
if (images.length > 0) {
|
||||
@@ -898,7 +919,12 @@ function ChatInputBarImpl({
|
||||
}}
|
||||
value={promptInput}
|
||||
/>
|
||||
<div className="flex shrink-0 items-center gap-2">
|
||||
<div
|
||||
className={cn(
|
||||
"flex shrink-0 items-center gap-2",
|
||||
variant === "conversation" && "self-end",
|
||||
)}
|
||||
>
|
||||
{canAbort && (
|
||||
<button
|
||||
aria-label="Stop agent"
|
||||
@@ -956,7 +982,7 @@ function ChatInputBarImpl({
|
||||
</div>
|
||||
|
||||
{/* Composer settings */}
|
||||
<div className="flex min-w-0 items-center justify-between gap-x-3 gap-y-2 border-t border-border px-2 py-2 text-sm text-muted-foreground">
|
||||
<div className="flex min-w-0 items-center justify-between gap-x-3 gap-y-2 rounded-b-xl border-t border-border bg-muted/20 px-2 py-2 text-sm text-muted-foreground">
|
||||
<div className="flex min-w-0 flex-auto flex-wrap items-center gap-2 max-[560px]:flex-nowrap">
|
||||
<button
|
||||
aria-label="Attach files"
|
||||
@@ -1527,10 +1553,7 @@ function TokenUsageRing({ usage }: { usage: TokenUsage }) {
|
||||
strokeWidth="4"
|
||||
/>
|
||||
<circle
|
||||
className={cn(
|
||||
"transition-[stroke,stroke-dashoffset] duration-200",
|
||||
ringColorClass,
|
||||
)}
|
||||
className={cn("", ringColorClass)}
|
||||
cx="11"
|
||||
cy="11"
|
||||
fill="none"
|
||||
@@ -1560,13 +1583,13 @@ function TokenUsageRing({ usage }: { usage: TokenUsage }) {
|
||||
<div className="mt-2 flex h-1 overflow-hidden rounded-full bg-muted">
|
||||
<div
|
||||
aria-hidden="true"
|
||||
className="h-full shrink-0 bg-primary transition-[width] duration-200"
|
||||
className="h-full shrink-0 bg-primary transition-[width] duration-120"
|
||||
data-token-kind="uncached-input"
|
||||
style={{ width: segmentWidth(uncachedInputTokens) }}
|
||||
/>
|
||||
<div
|
||||
aria-hidden="true"
|
||||
className="h-full shrink-0 bg-primary/60 transition-[background,width] duration-200"
|
||||
className="h-full shrink-0 bg-primary/60 transition-[background,width] duration-120"
|
||||
data-token-kind="cached-input"
|
||||
style={{
|
||||
backgroundImage:
|
||||
@@ -1576,7 +1599,7 @@ function TokenUsageRing({ usage }: { usage: TokenUsage }) {
|
||||
/>
|
||||
<div
|
||||
aria-hidden="true"
|
||||
className="h-full shrink-0 bg-blue-500 transition-[background,width] duration-200"
|
||||
className="h-full shrink-0 bg-blue-500 transition-[background,width] duration-120"
|
||||
data-token-kind="output"
|
||||
style={{
|
||||
backgroundImage:
|
||||
|
||||
@@ -613,8 +613,9 @@ describe("ChatMessages tool disclosures", () => {
|
||||
const messageList = message?.parentElement;
|
||||
const content = message?.querySelector(".cline-chat-message-content");
|
||||
|
||||
// The list and the content column each own their spacing via gap-2...
|
||||
expect(messageList?.classList.contains("gap-2")).toBe(true);
|
||||
// The list owns conversation spacing via gap-8, while the content column
|
||||
// keeps the tighter gap-2 spacing between blocks within one message.
|
||||
expect(messageList?.classList.contains("gap-8")).toBe(true);
|
||||
expect(content?.classList.contains("flex")).toBe(true);
|
||||
expect(content?.classList.contains("flex-col")).toBe(true);
|
||||
expect(content?.classList.contains("gap-2")).toBe(true);
|
||||
@@ -666,19 +667,12 @@ describe("ChatMessages tool disclosures", () => {
|
||||
);
|
||||
|
||||
expect(userMessage?.classList.contains("relative")).toBe(true);
|
||||
expect(userActions?.classList.contains("absolute")).toBe(true);
|
||||
expect(userActions?.classList.contains("right-0")).toBe(true);
|
||||
expect(userActions?.classList.contains("top-full")).toBe(true);
|
||||
expect(userActions?.classList.contains("pt-2")).toBe(true);
|
||||
expect(userActions?.getAttribute("data-side")).toBe("end");
|
||||
expect(assistantMessage?.classList.contains("relative")).toBe(true);
|
||||
expect(assistantActions?.classList.contains("absolute")).toBe(true);
|
||||
expect(assistantActions?.classList.contains("left-0")).toBe(true);
|
||||
expect(assistantActions?.classList.contains("top-full")).toBe(true);
|
||||
expect(assistantActions?.classList.contains("pt-2")).toBe(true);
|
||||
expect(assistantActions?.getAttribute("data-side")).toBe("start");
|
||||
expect(assistantActions?.getAttribute("data-visible")).toBe("true");
|
||||
const userAction = userActions?.querySelector(".cline-chat-message-action");
|
||||
expect(userAction?.classList.contains("min-w-0")).toBe(true);
|
||||
expect(userAction?.classList.contains("p-0")).toBe(true);
|
||||
expect(userAction?.getAttribute("data-slot")).toBe("icon-button");
|
||||
const assistantActionButtons = [
|
||||
...(assistantActions?.querySelectorAll(".cline-chat-message-action") ??
|
||||
[]),
|
||||
@@ -686,9 +680,7 @@ describe("ChatMessages tool disclosures", () => {
|
||||
expect(assistantActionButtons).toHaveLength(2);
|
||||
expect(
|
||||
assistantActionButtons.every(
|
||||
(action) =>
|
||||
action.classList.contains("min-w-0") &&
|
||||
action.classList.contains("p-0"),
|
||||
(action) => action.getAttribute("data-slot") === "icon-button",
|
||||
),
|
||||
).toBe(true);
|
||||
expect(userActions?.querySelector("time")?.getAttribute("datetime")).toBe(
|
||||
|
||||
@@ -42,6 +42,7 @@ import {
|
||||
} from "./messages/tool-approval-panel";
|
||||
import { ToolMessageBlock } from "./messages/tool-message-block";
|
||||
import { buildToolPresentation } from "./messages/tool-summaries";
|
||||
import { SessionContent } from "./session-content";
|
||||
|
||||
type ChatMessagesProps = {
|
||||
sessionId: string | null;
|
||||
@@ -476,173 +477,186 @@ function ChatMessagesImpl({
|
||||
>
|
||||
<ConversationContent
|
||||
className={cn(
|
||||
"relative mx-auto min-h-full w-full min-w-0 max-w-full",
|
||||
showIdleDetails ? "p-0" : "px-6 pt-6 pb-14",
|
||||
"min-h-full w-full min-w-0",
|
||||
showIdleDetails ? "p-0" : "px-6",
|
||||
)}
|
||||
>
|
||||
{showIdleDetails ? null : (
|
||||
<div className="flex min-h-full w-full min-w-0 flex-col gap-2">
|
||||
{renderItems.map((item) => {
|
||||
if (item.type === "tools") {
|
||||
<SessionContent
|
||||
className={cn(
|
||||
"relative min-h-full",
|
||||
showIdleDetails ? "p-0" : "pt-6 pb-20",
|
||||
)}
|
||||
>
|
||||
{showIdleDetails ? null : (
|
||||
<div className="flex min-h-full w-full min-w-0 flex-col gap-8">
|
||||
{renderItems.map((item) => {
|
||||
if (item.type === "tools") {
|
||||
return (
|
||||
<ToolMessageBlock
|
||||
key={`tools_${item.messages[0]?.id ?? "empty"}`}
|
||||
messages={item.messages}
|
||||
/>
|
||||
);
|
||||
}
|
||||
const { agentRole, message, reasoningMessages } = item;
|
||||
const firstReasoningMessage = reasoningMessages[0];
|
||||
const lastReasoningMessage = reasoningMessages.at(-1);
|
||||
const reasoningContent = reasoningMessages
|
||||
.map((reasoningMessage) =>
|
||||
reasoningMessage.reasoning?.trim(),
|
||||
)
|
||||
.filter((content): content is string => Boolean(content))
|
||||
.join("\n\n");
|
||||
return (
|
||||
<ToolMessageBlock
|
||||
key={`tools_${item.messages[0]?.id ?? "empty"}`}
|
||||
messages={item.messages}
|
||||
<MessageBubble
|
||||
agentRole={agentRole}
|
||||
isLastAssistantMessage={
|
||||
message.role === "assistant" &&
|
||||
lastConversationMessage === message
|
||||
}
|
||||
isStreaming={streamingMessageId === message.id}
|
||||
key={message.id}
|
||||
message={message}
|
||||
runCount={userRunCountByMessage.get(message)}
|
||||
onExpandImage={handleExpandImage}
|
||||
onCopyMessage={handleCopyMessage}
|
||||
onEditMessage={
|
||||
onEditMessage ? requestEditMessage : undefined
|
||||
}
|
||||
editDisabled={
|
||||
!onEditMessage ||
|
||||
status === "starting" ||
|
||||
status === "running" ||
|
||||
status === "stopping" ||
|
||||
isSessionSwitching ||
|
||||
sessionVersioningPending
|
||||
}
|
||||
editError={editErrors[message.id]}
|
||||
editPending={editingMessageId === message.id}
|
||||
onRestoreCheckpoint={
|
||||
onRestoreCheckpoint
|
||||
? requestRestoreCheckpoint
|
||||
: undefined
|
||||
}
|
||||
restoreDisabled={
|
||||
!onRestoreCheckpoint ||
|
||||
status === "starting" ||
|
||||
status === "running" ||
|
||||
status === "stopping" ||
|
||||
isSessionSwitching ||
|
||||
sessionVersioningPending
|
||||
}
|
||||
restoreError={checkpointErrors[message.id]}
|
||||
restorePending={
|
||||
checkpointActions[message.id] === "undoing"
|
||||
}
|
||||
wasCopied={copiedMessageId === message.id}
|
||||
onForkSession={
|
||||
onForkSession ? handleForkSession : undefined
|
||||
}
|
||||
forkDisabled={
|
||||
status === "starting" ||
|
||||
status === "running" ||
|
||||
status === "stopping" ||
|
||||
isSessionSwitching ||
|
||||
sessionVersioningPending
|
||||
}
|
||||
forkPending={forkingMessageId === message.id}
|
||||
forkError={forkErrors[message.id]}
|
||||
reasoningContent={reasoningContent}
|
||||
reasoningRedacted={reasoningMessages.some(
|
||||
(reasoningMessage) =>
|
||||
reasoningMessage.reasoningRedacted === true,
|
||||
)}
|
||||
thoughtDurationMilliseconds={
|
||||
firstReasoningMessage && lastReasoningMessage
|
||||
? getThoughtDurationMilliseconds(
|
||||
previousTimestampByMessage.get(
|
||||
firstReasoningMessage,
|
||||
),
|
||||
lastReasoningMessage.createdAt,
|
||||
)
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
const { agentRole, message, reasoningMessages } = item;
|
||||
const firstReasoningMessage = reasoningMessages[0];
|
||||
const lastReasoningMessage = reasoningMessages.at(-1);
|
||||
const reasoningContent = reasoningMessages
|
||||
.map((reasoningMessage) => reasoningMessage.reasoning?.trim())
|
||||
.filter((content): content is string => Boolean(content))
|
||||
.join("\n\n");
|
||||
return (
|
||||
<MessageBubble
|
||||
agentRole={agentRole}
|
||||
isLastAssistantMessage={
|
||||
message.role === "assistant" &&
|
||||
lastConversationMessage === message
|
||||
})}
|
||||
{pendingToolApprovals.length > 0 ? (
|
||||
<ToolApprovalPanel
|
||||
items={pendingToolApprovals}
|
||||
onApprove={(requestId) =>
|
||||
handleToolApprovalDecision(
|
||||
requestId,
|
||||
"approving",
|
||||
onApproveToolApproval,
|
||||
)
|
||||
}
|
||||
isStreaming={streamingMessageId === message.id}
|
||||
key={message.id}
|
||||
message={message}
|
||||
runCount={userRunCountByMessage.get(message)}
|
||||
onExpandImage={handleExpandImage}
|
||||
onCopyMessage={handleCopyMessage}
|
||||
onEditMessage={
|
||||
onEditMessage ? requestEditMessage : undefined
|
||||
}
|
||||
editDisabled={
|
||||
!onEditMessage ||
|
||||
status === "starting" ||
|
||||
status === "running" ||
|
||||
status === "stopping" ||
|
||||
isSessionSwitching ||
|
||||
sessionVersioningPending
|
||||
}
|
||||
editError={editErrors[message.id]}
|
||||
editPending={editingMessageId === message.id}
|
||||
onRestoreCheckpoint={
|
||||
onRestoreCheckpoint ? requestRestoreCheckpoint : undefined
|
||||
}
|
||||
restoreDisabled={
|
||||
!onRestoreCheckpoint ||
|
||||
status === "starting" ||
|
||||
status === "running" ||
|
||||
status === "stopping" ||
|
||||
isSessionSwitching ||
|
||||
sessionVersioningPending
|
||||
}
|
||||
restoreError={checkpointErrors[message.id]}
|
||||
restorePending={checkpointActions[message.id] === "undoing"}
|
||||
wasCopied={copiedMessageId === message.id}
|
||||
onForkSession={
|
||||
onForkSession ? handleForkSession : undefined
|
||||
}
|
||||
forkDisabled={
|
||||
status === "starting" ||
|
||||
status === "running" ||
|
||||
status === "stopping" ||
|
||||
isSessionSwitching ||
|
||||
sessionVersioningPending
|
||||
}
|
||||
forkPending={forkingMessageId === message.id}
|
||||
forkError={forkErrors[message.id]}
|
||||
reasoningContent={reasoningContent}
|
||||
reasoningRedacted={reasoningMessages.some(
|
||||
(reasoningMessage) =>
|
||||
reasoningMessage.reasoningRedacted === true,
|
||||
)}
|
||||
thoughtDurationMilliseconds={
|
||||
firstReasoningMessage && lastReasoningMessage
|
||||
? getThoughtDurationMilliseconds(
|
||||
previousTimestampByMessage.get(
|
||||
firstReasoningMessage,
|
||||
),
|
||||
lastReasoningMessage.createdAt,
|
||||
)
|
||||
: undefined
|
||||
onReject={(requestId) =>
|
||||
handleToolApprovalDecision(
|
||||
requestId,
|
||||
"rejecting",
|
||||
onRejectToolApproval,
|
||||
)
|
||||
}
|
||||
pendingActions={toolApprovalActions}
|
||||
requestErrors={toolApprovalErrors}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
{pendingToolApprovals.length > 0 ? (
|
||||
<ToolApprovalPanel
|
||||
items={pendingToolApprovals}
|
||||
onApprove={(requestId) =>
|
||||
handleToolApprovalDecision(
|
||||
requestId,
|
||||
"approving",
|
||||
onApproveToolApproval,
|
||||
)
|
||||
}
|
||||
onReject={(requestId) =>
|
||||
handleToolApprovalDecision(
|
||||
requestId,
|
||||
"rejecting",
|
||||
onRejectToolApproval,
|
||||
)
|
||||
}
|
||||
pendingActions={toolApprovalActions}
|
||||
requestErrors={toolApprovalErrors}
|
||||
/>
|
||||
) : null}
|
||||
{askQuestionItems.length > 0 ? (
|
||||
<AgentAskQuestion
|
||||
errors={askQuestionErrors}
|
||||
items={askQuestionItems}
|
||||
onAnswer={handleAskQuestionAnswer}
|
||||
pendingAnswers={askQuestionActions}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
)}
|
||||
{showSwitchTransition ? (
|
||||
hasMessages ? (
|
||||
<div className="pointer-events-none absolute right-6 top-6 z-20 rounded-full border border-border/70 bg-background/90 px-3 py-1.5 text-xs text-muted-foreground shadow-sm backdrop-blur-[1px]">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<Loader2 className="h-3.5 w-3.5 animate-spin" />
|
||||
Switching session...
|
||||
</div>
|
||||
) : null}
|
||||
{askQuestionItems.length > 0 ? (
|
||||
<AgentAskQuestion
|
||||
errors={askQuestionErrors}
|
||||
items={askQuestionItems}
|
||||
onAnswer={handleAskQuestionAnswer}
|
||||
pendingAnswers={askQuestionActions}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
) : (
|
||||
<div className="rounded-xl border border-border/70 bg-card p-4">
|
||||
<div className="mb-3 flex items-center gap-2 text-sm text-muted-foreground">
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
Loading session...
|
||||
)}
|
||||
{showSwitchTransition ? (
|
||||
hasMessages ? (
|
||||
<div className="pointer-events-none absolute right-6 top-6 z-20 rounded-full border border-border/70 bg-background/90 px-3 py-1.5 text-xs text-muted-foreground shadow-sm backdrop-blur-[1px]">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<Loader2 className="h-3.5 w-3.5 animate-spin" />
|
||||
Switching session...
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-3">
|
||||
<div className="h-4 w-2/5 animate-pulse rounded bg-muted/70" />
|
||||
<div className="h-4 w-4/5 animate-pulse rounded bg-muted/70" />
|
||||
<div className="h-4 w-3/5 animate-pulse rounded bg-muted/70" />
|
||||
) : (
|
||||
<div className="rounded-xl border border-border/70 bg-card p-4">
|
||||
<div className="mb-3 flex items-center gap-2 text-sm text-muted-foreground">
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
Loading session...
|
||||
</div>
|
||||
<div className="space-y-3">
|
||||
<div className="h-4 w-2/5 animate-pulse rounded bg-muted/70" />
|
||||
<div className="h-4 w-4/5 animate-pulse rounded bg-muted/70" />
|
||||
<div className="h-4 w-3/5 animate-pulse rounded bg-muted/70" />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
) : null}
|
||||
{(status === "starting" || isAwaitingFirstOutput) &&
|
||||
!isSessionSwitching ? (
|
||||
<div className="flex min-h-7 items-center gap-2 py-1 text-sm font-medium text-muted-foreground">
|
||||
<Loader2 className="size-4 animate-spin" />
|
||||
<span className={STREAMING_TITLE_CLASS}>Thinking...</span>
|
||||
</div>
|
||||
)
|
||||
) : null}
|
||||
{(status === "starting" || isAwaitingFirstOutput) &&
|
||||
!isSessionSwitching ? (
|
||||
<div className="flex min-h-7 items-center gap-2 py-1 text-sm font-medium text-muted-foreground">
|
||||
<Loader2 className="size-4 animate-spin" />
|
||||
<span className={STREAMING_TITLE_CLASS}>Thinking...</span>
|
||||
</div>
|
||||
) : null}
|
||||
{chatTransportState !== "connected" && !shouldShowErrorBanner ? (
|
||||
<div className="mt-4 flex items-center gap-2 text-xs text-muted-foreground">
|
||||
<Loader2 className="h-3.5 w-3.5 animate-spin" />
|
||||
{chatTransportState === "reconnecting"
|
||||
? "Reconnecting chat..."
|
||||
: chatTransportState === "unavailable"
|
||||
? "Chat backend unavailable"
|
||||
: "Connecting chat..."}
|
||||
</div>
|
||||
) : null}
|
||||
{shouldShowErrorBanner ? (
|
||||
<div className="cline-chat-selectable mt-4 rounded-md border border-destructive/40 bg-destructive/10 p-3 text-sm text-destructive">
|
||||
{error}
|
||||
</div>
|
||||
) : null}
|
||||
) : null}
|
||||
{chatTransportState !== "connected" && !shouldShowErrorBanner ? (
|
||||
<div className="mt-4 flex items-center gap-2 text-xs text-muted-foreground">
|
||||
<Loader2 className="h-3.5 w-3.5 animate-spin" />
|
||||
{chatTransportState === "reconnecting"
|
||||
? "Reconnecting chat..."
|
||||
: chatTransportState === "unavailable"
|
||||
? "Chat backend unavailable"
|
||||
: "Connecting chat..."}
|
||||
</div>
|
||||
) : null}
|
||||
{shouldShowErrorBanner ? (
|
||||
<div className="cline-chat-selectable mt-4 rounded-md border border-destructive/40 bg-destructive/10 p-3 text-sm text-destructive">
|
||||
{error}
|
||||
</div>
|
||||
) : null}
|
||||
</SessionContent>
|
||||
</ConversationContent>
|
||||
</ConversationViewport>
|
||||
<ConversationScrollButton />
|
||||
|
||||
+3
-14
@@ -126,7 +126,7 @@ export const MessageBubble = memo(function MessageBubble({
|
||||
</time>
|
||||
) : null;
|
||||
|
||||
// Spacing between blocks comes solely from the conversation list's `gap-2`
|
||||
// Spacing between blocks comes solely from the conversation list's `gap-8`
|
||||
// and this content column's `gap-2`; blocks must not add their own margins.
|
||||
return (
|
||||
<AgentMessage className="relative flex flex-col gap-2" from={agentRole}>
|
||||
@@ -173,13 +173,9 @@ export const MessageBubble = memo(function MessageBubble({
|
||||
|
||||
{shouldRenderUserActions ? (
|
||||
<>
|
||||
<MessageActions
|
||||
className="absolute right-0 top-full z-10 pt-2"
|
||||
visible={keepUserActionsVisible}
|
||||
>
|
||||
<MessageActions side="end" visible={keepUserActionsVisible}>
|
||||
{onCopyMessage ? (
|
||||
<MessageAction
|
||||
className="min-w-0 p-0 text-muted-foreground/70 hover:text-foreground"
|
||||
label={wasCopied ? "Copied user message" : "Copy user message"}
|
||||
onClick={() => void onCopyMessage(message.id, message.content)}
|
||||
title={wasCopied ? "Copied" : "Copy message"}
|
||||
@@ -193,7 +189,6 @@ export const MessageBubble = memo(function MessageBubble({
|
||||
) : null}
|
||||
{onEditMessage && runCount && displayContent.trim() ? (
|
||||
<MessageAction
|
||||
className="min-w-0 p-0 text-muted-foreground/70 hover:text-foreground"
|
||||
disabled={editDisabled || editPending}
|
||||
label="Edit user message"
|
||||
onClick={() =>
|
||||
@@ -210,7 +205,6 @@ export const MessageBubble = memo(function MessageBubble({
|
||||
) : null}
|
||||
{checkpoint ? (
|
||||
<MessageAction
|
||||
className="min-w-0 p-0 text-muted-foreground/70 hover:text-foreground"
|
||||
disabled={restoreDisabled || restorePending}
|
||||
label="Restore checkpoint"
|
||||
onClick={() =>
|
||||
@@ -241,13 +235,9 @@ export const MessageBubble = memo(function MessageBubble({
|
||||
) : null}
|
||||
|
||||
{shouldRenderAssistantActions ? (
|
||||
<MessageActions
|
||||
className="absolute left-0 top-full z-10 pt-2"
|
||||
visible={keepAssistantActionsVisible}
|
||||
>
|
||||
<MessageActions side="start" visible={keepAssistantActionsVisible}>
|
||||
{onCopyMessage ? (
|
||||
<MessageAction
|
||||
className="min-w-0 p-0 text-muted-foreground/70 hover:text-foreground"
|
||||
label={
|
||||
wasCopied
|
||||
? "Copied assistant message"
|
||||
@@ -265,7 +255,6 @@ export const MessageBubble = memo(function MessageBubble({
|
||||
) : null}
|
||||
{onForkSession ? (
|
||||
<MessageAction
|
||||
className="min-w-0 p-0 text-muted-foreground/70 hover:text-foreground"
|
||||
disabled={forkDisabled || forkPending}
|
||||
label="Fork session"
|
||||
onClick={() => void onForkSession(message.id)}
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
import type { ReactNode } from "react";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export function SessionContent({
|
||||
children,
|
||||
className,
|
||||
}: {
|
||||
children: ReactNode;
|
||||
className?: string;
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"mx-auto w-full min-w-0 max-w-(--breakpoint-lg)",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -11,6 +11,7 @@ import type { ReactNode } from "react";
|
||||
import { useEffect, useMemo } from "react";
|
||||
import { useWorkspace } from "@/contexts/workspace-context";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { SessionContent } from "./session-content";
|
||||
import { WelcomeWorkspaceControls } from "./welcome-workspace-controls";
|
||||
|
||||
/** Code-centric starters, shown only when the folder is a git repository. */
|
||||
@@ -210,10 +211,10 @@ export function WelcomeScreen({
|
||||
{active && notice ? notice : null}
|
||||
|
||||
<div
|
||||
className={active ? "mt-4 w-full" : "z-20 shrink-0"}
|
||||
className={active ? "mt-4 w-full" : "z-20 shrink-0 px-6 pb-6"}
|
||||
key="persistent-composer"
|
||||
>
|
||||
{composer}
|
||||
{active ? composer : <SessionContent>{composer}</SessionContent>}
|
||||
</div>
|
||||
|
||||
{active ? (
|
||||
|
||||
Reference in New Issue
Block a user