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:
Haley Park
2026-08-12 14:38:50 -07:00
committed by GitHub
parent 105d357b12
commit e703f7af2a
13 changed files with 417 additions and 250 deletions
@@ -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 />
@@ -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 ? (