From 7eb7348d509c78d35a7d5bb9a0d1ef0f2710b149 Mon Sep 17 00:00:00 2001
From: Haile <118998054+blackmammoth@users.noreply.github.com>
Date: Wed, 1 Jul 2026 14:57:03 +0300
Subject: [PATCH] Feat/design improvements and minor bug fixes (#939)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
* fix(shell): hide prompt options on desktop
* fix(chat): group continuous same-tool runs more consistently
Consecutive tool calls (Edit, Read, Grep, etc.) grouped inconsistently:
- The group threshold was 3, so a run of only 2 calls stayed ungrouped
while a run of 3 collapsed — making two back-to-back edits look
different from three.
- A run was broken by any interleaved message, including ones that render
nothing (reasoning hidden when showThinking is off). Providers like
Codex interleave hidden reasoning between tool calls, so visually
continuous edits intermittently failed to group.
Lower TOOL_GROUP_THRESHOLD to 2 and skip non-rendered messages when
extending a run, so any 2+ consecutive same-tool calls collapse reliably.
ChatMessagesPane now passes showThinking into groupConsecutiveTools.
* fix(chat): stabilize message scroll controls
* fix: update command menu positioning
* fix(chat): refine load all overlay behavior
* fix(chat): hide load all prompt after final page
* fix(chat): remove auto scroll quick setting
* fix(chat): unify messages and composer into centered column
Constrain both ChatMessagesPane content and ChatComposer to the same
max-w-3xl centered column. Previously only
the composer had a max-width, causing messages to fill the full width
while the input stayed narrow, making them visually misaligned with
large empty gutters on either side.
* style(ui): rework light/dark theme to make it visually consistent
Rework the color system around warm neutrals and route hardcoded
surfaces through theme tokens for consistency.
- Theme tokens (index.css, ThemeContext): warm cream light mode and
neutral charcoal dark mode, replacing the pure-white/blue-tinted
palette; update PWA theme-color meta
- Code blocks: soft grey background in light mode via
oneLight/oneDark, and drop the Tailwind Typography
shell that
framed the highlighter in a dark box
- Dropdowns/panels: convert CommandMenu, Quick Settings, and the JSON
response block from hardcoded gray/slate to popover/muted/border
tokens
- Git panel: Publish button purple -> primary blue
- Composer: drop top padding so the input sits flush with the thread
* fix: use app theme for code editor
* style(chat): unify composer toolbar heights and declutter slash-command modal
- Composer: give the permission-mode and token-usage buttons a fixed
h-8 so every bottom-toolbar control shares one height
- CommandResultModal: replace the blue gradient header (gradient fill,
glow blobs, blue eyebrow + icon chip) with a clean neutral header on
popover/muted tokens
* fix(chat): header ellipsis, Codex logo on light theme, portal copy menu
- MainContentTitle: truncate the session title with an ellipsis instead
of horizontal-scrolling it
- MessageComponent: use text-foreground for the provider logo chip so the
currentColor Codex/OpenAI mark is visible on the light theme
- MessageCopyControl: render the copy-format dropdown in a portal so it
escapes the chat message's `contain: paint` clip box; anchor it to the
trigger, flip above near the viewport bottom, close on scroll/resize
* style(mcp): remove purple accents and portal the server form modal
- Replace the purple provider-button colors, heading icon, and form
submit button with the primary token (no purple in the MCP UI)
- Portal the add/edit MCP server modal to document.body so its fixed
overlay covers the full viewport, fixing the white band at the top
caused by the Settings dialog's transformed tab content becoming the
containing block
* style(ui): use Merriweather serif for chat text and Encode Sans for the rest of the UI
* fix: align activity indicator with composer input width
Wrap ActivityIndicator in the same mx-auto max-w-3xl container as the
text input so the "Analyzing…" label and Stop button stay within the
input's boundaries instead of spanning the full window width.
* style: improve thinking and stop button placements
* style(auth): modernize login, setup, and onboarding screens
* fix(chat): correct invalid dark-mode hover on AskUserQuestion options
* fix: remove unnecessary auto expand tools
* fix: resolve coderabbit comments
* fix(chat): widen chat layout and sidebar titles
* fix(branding): update CloudCLI wordmark styling
---------
Co-authored-by: Simos Mikelatos
---
index.html | 10 +-
src/components/auth/view/AuthErrorAlert.tsx | 10 +-
src/components/auth/view/AuthInputField.tsx | 57 +++++--
.../auth/view/AuthLoadingScreen.tsx | 33 ++--
src/components/auth/view/AuthScreenLayout.tsx | 34 +++--
src/components/auth/view/LoginForm.tsx | 14 +-
src/components/auth/view/SetupForm.tsx | 27 +++-
.../chat/hooks/useChatSessionState.ts | 96 ++++++++----
src/components/chat/tools/ToolRenderer.tsx | 10 +-
.../AskUserQuestionPanel.tsx | 4 +-
src/components/chat/types/types.ts | 2 -
src/components/chat/utils/toolGrouping.ts | 37 +++--
src/components/chat/view/ChatInterface.tsx | 29 ++--
.../view/subcomponents/ActivityIndicator.tsx | 56 +++++--
.../chat/view/subcomponents/ChatComposer.tsx | 40 ++---
.../view/subcomponents/ChatMessagesPane.tsx | 108 ++++++-------
.../chat/view/subcomponents/CommandMenu.tsx | 37 +++--
.../view/subcomponents/CommandResultModal.tsx | 67 ++++-----
.../subcomponents/LoadAllMessagesOverlay.tsx | 68 +++++++++
.../chat/view/subcomponents/Markdown.tsx | 17 ++-
.../view/subcomponents/MessageComponent.tsx | 52 ++-----
.../view/subcomponents/MessageCopyControl.tsx | 61 ++++++--
.../ProviderSelectionEmptyState.tsx | 4 +-
.../view/subcomponents/TokenUsageSummary.tsx | 2 +-
.../view/subcomponents/ToolGroupContainer.tsx | 3 -
.../code-editor/constants/settings.ts | 2 -
.../hooks/useCodeEditorSettings.ts | 19 +--
.../code-editor/view/CodeEditor.tsx | 5 +-
.../markdown/MarkdownCodeBlock.tsx | 12 +-
.../markdown/MarkdownPreview.tsx | 3 +
.../git-panel/view/GitPanelHeader.tsx | 2 +-
.../main-content/view/MainContent.tsx | 4 +-
.../view/subcomponents/MainContentTitle.tsx | 2 +-
src/components/mcp/constants.ts | 10 +-
src/components/mcp/view/McpServers.tsx | 2 +-
.../mcp/view/modals/McpServerFormModal.tsx | 10 +-
src/components/onboarding/view/Onboarding.tsx | 33 ++--
.../subcomponents/AgentConnectionCard.tsx | 18 +--
.../subcomponents/AgentConnectionsStep.tsx | 14 +-
.../subcomponents/GitConfigurationStep.tsx | 16 +-
.../subcomponents/OnboardingStepProgress.tsx | 16 +-
.../quick-settings-panel/constants.ts | 18 +--
src/components/quick-settings-panel/types.ts | 2 -
.../view/QuickSettingsContent.tsx | 15 +-
.../view/QuickSettingsPanelHeader.tsx | 6 +-
.../view/QuickSettingsPanelView.tsx | 6 +-
.../view/QuickSettingsSection.tsx | 2 +-
.../view/QuickSettingsToggleRow.tsx | 4 +-
.../settings/constants/constants.ts | 1 -
.../settings/hooks/useSettingsController.ts | 2 -
src/components/settings/types/types.ts | 1 -
src/components/settings/view/Settings.tsx | 1 -
.../settings/view/tabs/AboutTab.tsx | 12 +-
.../view/tabs/AppearanceSettingsTab.tsx | 13 --
.../sections/VersionInfoSection.tsx | 9 +-
src/components/shell/view/Shell.tsx | 2 +-
.../view/subcomponents/SidebarHeader.tsx | 8 +-
.../view/subcomponents/SidebarSessionItem.tsx | 4 +-
src/constants/branding.ts | 2 +
src/contexts/ThemeContext.jsx | 4 +-
src/hooks/useUiPreferences.ts | 4 -
src/i18n/locales/de/settings.json | 3 -
src/i18n/locales/en/settings.json | 3 -
src/i18n/locales/fr/settings.json | 3 -
src/i18n/locales/it/settings.json | 3 -
src/i18n/locales/ja/settings.json | 3 -
src/i18n/locales/ko/settings.json | 3 -
src/i18n/locales/ru/settings.json | 3 -
src/i18n/locales/tr/settings.json | 3 -
src/i18n/locales/zh-CN/settings.json | 3 -
src/i18n/locales/zh-TW/settings.json | 3 -
src/index.css | 142 ++++++++++++------
tailwind.config.js | 4 +
73 files changed, 788 insertions(+), 550 deletions(-)
create mode 100644 src/components/chat/view/subcomponents/LoadAllMessagesOverlay.tsx
create mode 100644 src/constants/branding.ts
diff --git a/index.html b/index.html
index 37d2217d..1fdfcc7c 100644
--- a/index.html
+++ b/index.html
@@ -6,7 +6,15 @@
CloudCLI UI
-
+
+
+
+
+
+
diff --git a/src/components/auth/view/AuthErrorAlert.tsx b/src/components/auth/view/AuthErrorAlert.tsx
index 96a6be41..b9b65625 100644
--- a/src/components/auth/view/AuthErrorAlert.tsx
+++ b/src/components/auth/view/AuthErrorAlert.tsx
@@ -1,3 +1,5 @@
+import { AlertCircle } from 'lucide-react';
+
type AuthErrorAlertProps = {
errorMessage: string;
};
@@ -8,8 +10,12 @@ export default function AuthErrorAlert({ errorMessage }: AuthErrorAlertProps) {
}
return (
-
-
{errorMessage}
+
);
}
diff --git a/src/components/auth/view/AuthInputField.tsx b/src/components/auth/view/AuthInputField.tsx
index b382a059..86b68854 100644
--- a/src/components/auth/view/AuthInputField.tsx
+++ b/src/components/auth/view/AuthInputField.tsx
@@ -1,3 +1,7 @@
+import { useState } from 'react';
+import type { ComponentType } from 'react';
+import { Eye, EyeOff } from 'lucide-react';
+
type AuthInputFieldProps = {
id: string;
label: string;
@@ -8,13 +12,14 @@ type AuthInputFieldProps = {
type?: 'text' | 'password' | 'email';
name?: string;
autoComplete?: string;
+ icon?: ComponentType<{ className?: string }>;
};
/**
* A labelled input field for authentication forms.
* Renders a `
` / ` ` pair and forwards browser autofill hints
* (`name`, `autoComplete`) so that password managers can identify and fill
- * the field correctly.
+ * the field correctly. Password fields gain a show/hide visibility toggle.
*/
export default function AuthInputField({
id,
@@ -26,24 +31,48 @@ export default function AuthInputField({
type = 'text',
name,
autoComplete,
+ icon: Icon,
}: AuthInputFieldProps) {
+ const [isPasswordVisible, setIsPasswordVisible] = useState(false);
+
+ const isPasswordField = type === 'password';
+ const resolvedType = isPasswordField && isPasswordVisible ? 'text' : type;
+
return (
);
}
diff --git a/src/components/auth/view/AuthLoadingScreen.tsx b/src/components/auth/view/AuthLoadingScreen.tsx
index 9b4987d5..86a80ce2 100644
--- a/src/components/auth/view/AuthLoadingScreen.tsx
+++ b/src/components/auth/view/AuthLoadingScreen.tsx
@@ -1,30 +1,37 @@
-import { MessageSquare } from 'lucide-react';
+import { CLOUDCLI_WORDMARK_FONT_FAMILY } from '../../../constants/branding';
-const loadingDotAnimationDelays = ['0s', '0.1s', '0.2s'];
+const loadingDotAnimationDelays = ['0s', '0.15s', '0.3s'];
export default function AuthLoadingScreen() {
return (
-
-
-
-
-
+
+
+
+
+
+
+
-
CloudCLI
-
-
+
+ CloudCLI
+
+
Loading authentication state…
+
{loadingDotAnimationDelays.map((delay) => (
))}
-
-
Loading...
);
diff --git a/src/components/auth/view/AuthScreenLayout.tsx b/src/components/auth/view/AuthScreenLayout.tsx
index d53ff95f..6f7f66be 100644
--- a/src/components/auth/view/AuthScreenLayout.tsx
+++ b/src/components/auth/view/AuthScreenLayout.tsx
@@ -1,5 +1,4 @@
import type { ReactNode } from 'react';
-import { MessageSquare } from 'lucide-react';
import { IS_PLATFORM } from '../../../constants/config';
type AuthScreenLayoutProps = {
@@ -18,29 +17,38 @@ export default function AuthScreenLayout({
logo,
}: AuthScreenLayoutProps) {
return (
-
-
-
+
+ {/* Ambient, on-brand backdrop that gives the screen depth without
+ competing with the card content. Fixed so it stays put while the
+ form scrolls on short viewports. */}
+
+
+
+
-
+
{logo ?? (
-
-
+
+
)}
-
{title}
-
{description}
+
{title}
+
{description}
- {children}
+
{children}
-
-
{footerText}
+
{!IS_PLATFORM && (
-
+
diff --git a/src/components/auth/view/LoginForm.tsx b/src/components/auth/view/LoginForm.tsx
index cf26ca3d..c806f420 100644
--- a/src/components/auth/view/LoginForm.tsx
+++ b/src/components/auth/view/LoginForm.tsx
@@ -1,6 +1,7 @@
import { useCallback, useState } from 'react';
import type { FormEvent } from 'react';
import { useTranslation } from 'react-i18next';
+import { Loader2, Lock, User } from 'lucide-react';
import { useAuth } from '../context/AuthContext';
import AuthErrorAlert from './AuthErrorAlert';
import AuthInputField from './AuthInputField';
@@ -69,6 +70,7 @@ export default function LoginForm() {
placeholder={t('login.placeholders.username')}
isDisabled={isSubmitting}
autoComplete="username"
+ icon={User}
/>
@@ -87,9 +90,16 @@ export default function LoginForm() {
- {isSubmitting ? t('login.loading') : t('login.submit')}
+ {isSubmitting ? (
+ <>
+
+ {t('login.loading')}
+ >
+ ) : (
+ t('login.submit')
+ )}
diff --git a/src/components/auth/view/SetupForm.tsx b/src/components/auth/view/SetupForm.tsx
index 213ce165..c657658e 100644
--- a/src/components/auth/view/SetupForm.tsx
+++ b/src/components/auth/view/SetupForm.tsx
@@ -1,5 +1,6 @@
import { useCallback, useState } from 'react';
import type { FormEvent } from 'react';
+import { Loader2, Lock, ShieldCheck, User } from 'lucide-react';
import { useAuth } from '../context/AuthContext';
import AuthErrorAlert from './AuthErrorAlert';
import AuthInputField from './AuthInputField';
@@ -85,7 +86,6 @@ export default function SetupForm() {
title="Welcome to CloudCLI"
description="Set up your account to get started"
footerText="This is a single-user system. Only one account can be created."
- logo={
}
>
diff --git a/src/components/chat/hooks/useChatSessionState.ts b/src/components/chat/hooks/useChatSessionState.ts
index 780e5ec3..37540895 100644
--- a/src/components/chat/hooks/useChatSessionState.ts
+++ b/src/components/chat/hooks/useChatSessionState.ts
@@ -18,7 +18,6 @@ interface UseChatSessionStateArgs {
selectedSession: ProjectSession | null;
ws: WebSocket | null;
sendMessage: (message: unknown) => void;
- autoScrollToBottom?: boolean;
externalMessageUpdate?: number;
newSessionTrigger?: number;
processingSessions?: SessionActivityMap;
@@ -96,7 +95,6 @@ export function useChatSessionState({
selectedSession,
ws,
sendMessage,
- autoScrollToBottom,
externalMessageUpdate,
newSessionTrigger,
processingSessions,
@@ -121,6 +119,7 @@ export function useChatSessionState({
const [viewHiddenCount, setViewHiddenCount] = useState(0);
const scrollContainerRef = useRef
(null);
+ const wasNearTopRef = useRef(false);
const [searchTarget, setSearchTarget] = useState<{ timestamp?: string; uuid?: string; snippet?: string } | null>(null);
const searchScrollActiveRef = useRef(false);
const isLoadingSessionRef = useRef(false);
@@ -185,6 +184,7 @@ export function useChatSessionState({
setShowLoadAllOverlay(false);
setViewHiddenCount(0);
setSearchTarget(null);
+ wasNearTopRef.current = false;
searchScrollActiveRef.current = false;
topLoadLockRef.current = false;
pendingScrollRestoreRef.current = null;
@@ -336,12 +336,34 @@ export function useChatSessionState({
const slot = await sessionStore.fetchMore(selectedSession.id, {
limit: MESSAGES_PER_PAGE,
});
- if (!slot || slot.serverMessages.length === 0) return false;
+ if (!slot) return false;
+ if (slot.serverMessages.length === 0) {
+ if (!slot.hasMore) {
+ setHasMoreMessages(false);
+ allMessagesLoadedRef.current = true;
+ setAllMessagesLoaded(true);
+ if (loadAllOverlayTimerRef.current) {
+ clearTimeout(loadAllOverlayTimerRef.current);
+ loadAllOverlayTimerRef.current = null;
+ }
+ setShowLoadAllOverlay(false);
+ }
+ return false;
+ }
pendingScrollRestoreRef.current = { height: previousScrollHeight, top: previousScrollTop };
setHasMoreMessages(slot.hasMore);
setTotalMessages(slot.total);
setVisibleMessageCount((prev) => prev + MESSAGES_PER_PAGE);
+ if (!slot.hasMore) {
+ allMessagesLoadedRef.current = true;
+ setAllMessagesLoaded(true);
+ if (loadAllOverlayTimerRef.current) {
+ clearTimeout(loadAllOverlayTimerRef.current);
+ loadAllOverlayTimerRef.current = null;
+ }
+ setShowLoadAllOverlay(false);
+ }
return true;
} finally {
isLoadingMoreRef.current = false;
@@ -357,8 +379,25 @@ export function useChatSessionState({
const nearBottom = isNearBottom();
setIsUserScrolledUp(!nearBottom);
+ const scrolledNearTop = container.scrollTop < 100;
+
+ // "Load all" prompt: appear (with fade-in) when the user reaches the top
+ if (scrolledNearTop && hasMoreMessages && !allMessagesLoadedRef.current) {
+ if (!wasNearTopRef.current) {
+ wasNearTopRef.current = true;
+ if (loadAllOverlayTimerRef.current) clearTimeout(loadAllOverlayTimerRef.current);
+
+ setShowLoadAllOverlay(true);
+ loadAllOverlayTimerRef.current = setTimeout(() => {
+ setShowLoadAllOverlay(false);
+ loadAllOverlayTimerRef.current = null;
+ }, 2500);
+ }
+ } else if (!scrolledNearTop) {
+ wasNearTopRef.current = false;
+ }
+
if (!allMessagesLoadedRef.current) {
- const scrolledNearTop = container.scrollTop < 100;
if (!scrolledNearTop) { topLoadLockRef.current = false; return; }
if (topLoadLockRef.current) {
if (container.scrollTop > 20) topLoadLockRef.current = false;
@@ -367,7 +406,7 @@ export function useChatSessionState({
const didLoad = await loadOlderMessages(container);
if (didLoad) topLoadLockRef.current = true;
}
- }, [isNearBottom, loadOlderMessages]);
+ }, [hasMoreMessages, isNearBottom, loadOlderMessages]);
useLayoutEffect(() => {
if (!pendingScrollRestoreRef.current || !scrollContainerRef.current) return;
@@ -386,6 +425,7 @@ export function useChatSessionState({
}
topLoadLockRef.current = false;
pendingScrollRestoreRef.current = null;
+ wasNearTopRef.current = false;
setIsUserScrolledUp(false);
}, [selectedProject?.projectId, selectedSession?.id]);
@@ -492,6 +532,7 @@ export function useChatSessionState({
setLoadAllJustFinished(false);
setShowLoadAllOverlay(false);
setViewHiddenCount(0);
+ wasNearTopRef.current = false;
if (loadAllOverlayTimerRef.current) clearTimeout(loadAllOverlayTimerRef.current);
if (loadAllFinishedTimerRef.current) clearTimeout(loadAllFinishedTimerRef.current);
@@ -546,7 +587,7 @@ export function useChatSessionState({
if (!isProcessing) {
await sessionStore.refreshFromServer(selectedSession.id);
- if (Boolean(autoScrollToBottom) && isNearBottom()) {
+ if (isNearBottom()) {
setTimeout(() => scrollToBottom(), 200);
}
}
@@ -557,7 +598,6 @@ export function useChatSessionState({
reloadExternalMessages();
}, [
- autoScrollToBottom,
externalMessageUpdate,
isNearBottom,
scrollToBottom,
@@ -689,10 +729,9 @@ export function useChatSessionState({
}, [chatMessages, visibleMessageCount]);
useEffect(() => {
- if (!autoScrollToBottom && scrollContainerRef.current) {
- const container = scrollContainerRef.current;
- scrollPositionRef.current = { height: container.scrollHeight, top: container.scrollTop };
- }
+ const container = scrollContainerRef.current;
+ if (!container) return;
+ scrollPositionRef.current = { height: container.scrollHeight, top: container.scrollTop };
});
useEffect(() => {
@@ -700,8 +739,8 @@ export function useChatSessionState({
if (isLoadingMoreRef.current || isLoadingMoreMessages || pendingScrollRestoreRef.current) return;
if (searchScrollActiveRef.current) return;
- if (autoScrollToBottom) {
- if (!isUserScrolledUp) setTimeout(() => scrollToBottom(), 50);
+ if (!isUserScrolledUp) {
+ setTimeout(() => scrollToBottom(), 50);
return;
}
@@ -711,7 +750,7 @@ export function useChatSessionState({
const newHeight = container.scrollHeight;
const heightDiff = newHeight - prevHeight;
if (heightDiff > 0 && prevTop > 0) container.scrollTop = prevTop + heightDiff;
- }, [autoScrollToBottom, chatMessages.length, isLoadingMoreMessages, isUserScrolledUp, scrollToBottom]);
+ }, [chatMessages.length, isLoadingMoreMessages, isUserScrolledUp, scrollToBottom]);
useEffect(() => {
const container = scrollContainerRef.current;
@@ -720,23 +759,8 @@ export function useChatSessionState({
return () => container.removeEventListener('scroll', handleScroll);
}, [handleScroll]);
- // "Load all" overlay
- const prevLoadingRef = useRef(false);
- useEffect(() => {
- const wasLoading = prevLoadingRef.current;
- prevLoadingRef.current = isLoadingMoreMessages;
-
- if (wasLoading && !isLoadingMoreMessages && hasMoreMessages) {
- if (loadAllOverlayTimerRef.current) clearTimeout(loadAllOverlayTimerRef.current);
- setShowLoadAllOverlay(true);
- loadAllOverlayTimerRef.current = setTimeout(() => setShowLoadAllOverlay(false), 2000);
- }
- if (!hasMoreMessages && !isLoadingMoreMessages) {
- if (loadAllOverlayTimerRef.current) clearTimeout(loadAllOverlayTimerRef.current);
- setShowLoadAllOverlay(false);
- }
- return () => { if (loadAllOverlayTimerRef.current) clearTimeout(loadAllOverlayTimerRef.current); };
- }, [isLoadingMoreMessages, hasMoreMessages]);
+ // "Load all" overlay visibility is driven by scroll-to-top in handleScroll;
+ // timers are cleared on session change via the reset effect above.
const loadAllMessages = useCallback(async () => {
if (!selectedSession || !selectedProject) return;
@@ -746,6 +770,10 @@ export function useChatSessionState({
isLoadingMoreRef.current = true;
setIsLoadingAllMessages(true);
setShowLoadAllOverlay(true);
+ if (loadAllOverlayTimerRef.current) {
+ clearTimeout(loadAllOverlayTimerRef.current);
+ loadAllOverlayTimerRef.current = null;
+ }
const container = scrollContainerRef.current;
const previousScrollHeight = container ? container.scrollHeight : 0;
@@ -772,7 +800,11 @@ export function useChatSessionState({
setLoadAllJustFinished(true);
if (loadAllFinishedTimerRef.current) clearTimeout(loadAllFinishedTimerRef.current);
- loadAllFinishedTimerRef.current = setTimeout(() => { setLoadAllJustFinished(false); setShowLoadAllOverlay(false); }, 1000);
+ loadAllFinishedTimerRef.current = setTimeout(() => {
+ setLoadAllJustFinished(false);
+ setShowLoadAllOverlay(false);
+ loadAllFinishedTimerRef.current = null;
+ }, 2500);
} else {
allMessagesLoadedRef.current = false;
setShowLoadAllOverlay(false);
diff --git a/src/components/chat/tools/ToolRenderer.tsx b/src/components/chat/tools/ToolRenderer.tsx
index 0d9e1f6a..f9ebfbe5 100644
--- a/src/components/chat/tools/ToolRenderer.tsx
+++ b/src/components/chat/tools/ToolRenderer.tsx
@@ -24,7 +24,6 @@ interface ToolRendererProps {
onFileOpen?: (filePath: string, diffInfo?: any) => void;
createDiff?: (oldStr: string, newStr: string) => DiffLine[];
selectedProject?: Project | null;
- autoExpandTools?: boolean;
showRawParameters?: boolean;
rawToolInput?: string;
isSubagentContainer?: boolean;
@@ -80,7 +79,6 @@ export const ToolRenderer: React.FC = memo(({
onFileOpen,
createDiff,
selectedProject,
- autoExpandTools = false,
showRawParameters = false,
rawToolInput,
isSubagentContainer,
@@ -151,8 +149,8 @@ export const ToolRenderer: React.FC = memo(({
output={output}
isError={Boolean(toolResult?.isError)}
status={toolStatus !== 'completed' ? toolStatus : undefined}
- // Commands stay collapsed by default (even consecutive ones); only
- // failures auto-expand so they remain visible.
+ // Commands stay collapsed by default; only failures auto-expand so they
+ // remain visible.
defaultOpen={false}
/>
);
@@ -199,7 +197,7 @@ export const ToolRenderer: React.FC = memo(({
= memo(({
const defaultOpen = displayConfig.defaultOpen !== undefined
? displayConfig.defaultOpen
- : autoExpandTools;
+ : false;
const contentProps = displayConfig.getContentProps?.(parsedData, {
selectedProject,
diff --git a/src/components/chat/tools/components/InteractiveRenderers/AskUserQuestionPanel.tsx b/src/components/chat/tools/components/InteractiveRenderers/AskUserQuestionPanel.tsx
index 5a75390f..ed7e8c97 100644
--- a/src/components/chat/tools/components/InteractiveRenderers/AskUserQuestionPanel.tsx
+++ b/src/components/chat/tools/components/InteractiveRenderers/AskUserQuestionPanel.tsx
@@ -229,7 +229,7 @@ export const AskUserQuestionPanel: React.FC = ({
className={`group flex w-full items-center gap-2.5 rounded-lg border px-3 py-2 text-left transition-all duration-150 ${
isSelected
? 'border-blue-300 bg-blue-50/80 ring-1 ring-blue-200/50 dark:border-blue-600 dark:bg-blue-900/25 dark:ring-blue-700/30'
- : 'dark:hover:bg-gray-750/50 border-gray-200 hover:border-gray-300 hover:bg-gray-50/60 dark:border-gray-700/60 dark:hover:border-gray-600'
+ : 'border-gray-200 hover:border-gray-300 hover:bg-gray-50/60 dark:border-gray-700/60 dark:hover:border-gray-600 dark:hover:bg-gray-700/40'
}`}
>
{/* Keyboard hint */}
@@ -277,7 +277,7 @@ export const AskUserQuestionPanel: React.FC = ({
className={`group flex w-full items-center gap-2.5 rounded-lg border px-3 py-2 text-left transition-all duration-150 ${
isOtherOn
? 'border-blue-300 bg-blue-50/80 ring-1 ring-blue-200/50 dark:border-blue-600 dark:bg-blue-900/25 dark:ring-blue-700/30'
- : 'dark:hover:bg-gray-750/50 border-dashed border-gray-200 hover:border-gray-300 hover:bg-gray-50/60 dark:border-gray-700/60 dark:hover:border-gray-600'
+ : 'border-dashed border-gray-200 hover:border-gray-300 hover:bg-gray-50/60 dark:border-gray-700/60 dark:hover:border-gray-600 dark:hover:bg-gray-700/40'
}`}
>
void;
onSessionEstablished?: (sessionId: string, context: SessionEstablishedContext) => void;
onShowSettings?: () => void;
- autoExpandTools?: boolean;
showRawParameters?: boolean;
showThinking?: boolean;
- autoScrollToBottom?: boolean;
sendByCtrlEnter?: boolean;
externalMessageUpdate?: number;
newSessionTrigger?: number;
diff --git a/src/components/chat/utils/toolGrouping.ts b/src/components/chat/utils/toolGrouping.ts
index c9d56433..6a964571 100644
--- a/src/components/chat/utils/toolGrouping.ts
+++ b/src/components/chat/utils/toolGrouping.ts
@@ -1,6 +1,6 @@
import type { ChatMessage } from '../types/types';
-export const TOOL_GROUP_THRESHOLD = 3;
+export const TOOL_GROUP_THRESHOLD = 2;
export interface ToolGroupItem {
_isGroup: true;
@@ -19,7 +19,17 @@ function isGroupableToolMessage(message: ChatMessage): message is ChatMessage &
return Boolean(message.isToolUse && message.toolName && !message.isSubagentContainer);
}
-export function groupConsecutiveTools(messages: ChatMessage[]): MessageListItem[] {
+// Messages that render nothing (e.g. reasoning hidden when showThinking is off)
+// shouldn't split an otherwise-continuous run of the same tool — providers like
+// Codex interleave hidden reasoning between consecutive tool calls.
+function rendersNothing(message: ChatMessage, showThinking: boolean): boolean {
+ return Boolean(message.isThinking && !showThinking);
+}
+
+export function groupConsecutiveTools(
+ messages: ChatMessage[],
+ showThinking: boolean = true,
+): MessageListItem[] {
const items: MessageListItem[] = [];
let index = 0;
@@ -35,13 +45,22 @@ export function groupConsecutiveTools(messages: ChatMessage[]): MessageListItem[
const run: ChatMessage[] = [message];
let nextIndex = index + 1;
- while (
- nextIndex < messages.length &&
- isGroupableToolMessage(messages[nextIndex]) &&
- messages[nextIndex].toolName === message.toolName
- ) {
- run.push(messages[nextIndex]);
- nextIndex += 1;
+ while (nextIndex < messages.length) {
+ const candidate = messages[nextIndex];
+
+ // Skip invisible interleaved messages so they don't break the run.
+ if (rendersNothing(candidate, showThinking)) {
+ nextIndex += 1;
+ continue;
+ }
+
+ if (isGroupableToolMessage(candidate) && candidate.toolName === message.toolName) {
+ run.push(candidate);
+ nextIndex += 1;
+ continue;
+ }
+
+ break;
}
if (run.length >= TOOL_GROUP_THRESHOLD) {
diff --git a/src/components/chat/view/ChatInterface.tsx b/src/components/chat/view/ChatInterface.tsx
index b466c6ba..2fdcba57 100644
--- a/src/components/chat/view/ChatInterface.tsx
+++ b/src/components/chat/view/ChatInterface.tsx
@@ -1,5 +1,6 @@
import React, { useCallback, useEffect, useMemo, useRef } from 'react';
import { useTranslation } from 'react-i18next';
+import { ArrowDownIcon } from 'lucide-react';
import { useTasksSettings } from '../../../contexts/TasksSettingsContext';
import { useWebSocket } from '../../../contexts/WebSocketContext';
@@ -30,10 +31,8 @@ function ChatInterface({
onNavigateToSession,
onSessionEstablished,
onShowSettings,
- autoExpandTools,
showRawParameters,
showThinking,
- autoScrollToBottom,
sendByCtrlEnter,
externalMessageUpdate,
newSessionTrigger,
@@ -124,7 +123,6 @@ function ChatInterface({
selectedSession,
ws,
sendMessage,
- autoScrollToBottom,
externalMessageUpdate,
newSessionTrigger,
processingSessions,
@@ -185,7 +183,7 @@ function ChatInterface({
handlePermissionDecision,
handleGrantToolPermission,
handleInputFocusChange,
- isInputFocused: _isInputFocused,
+ isInputFocused,
commandModalPayload,
closeCommandModal,
showCostModal,
@@ -356,13 +354,27 @@ function ChatInterface({
onFileOpen={onFileOpen}
onShowSettings={onShowSettings}
onGrantToolPermission={handleGrantToolPermission}
- autoExpandTools={autoExpandTools}
showRawParameters={showRawParameters}
showThinking={showThinking}
selectedProject={selectedProject}
/>
-
+ {isUserScrolledUp && chatMessages.length > 0 && (
+
+ )}
+
+ 0}
- onScrollToBottom={scrollToBottomAndReset}
onSubmit={handleSubmit}
isDragActive={isDragActive}
attachedImages={attachedImages}
@@ -414,6 +423,7 @@ function ChatInterface({
onTextareaPaste={handlePaste}
onTextareaScrollSync={syncInputOverlayScroll}
onTextareaInput={handleTextareaInput}
+ isInputFocused={isInputFocused}
onInputFocusChange={handleInputFocusChange}
placeholder={t('input.placeholder', {
provider:
@@ -430,6 +440,7 @@ function ChatInterface({
isTextareaExpanded={isTextareaExpanded}
sendByCtrlEnter={sendByCtrlEnter}
/>
+
diff --git a/src/components/chat/view/subcomponents/ActivityIndicator.tsx b/src/components/chat/view/subcomponents/ActivityIndicator.tsx
index afb30af4..f235c2fb 100644
--- a/src/components/chat/view/subcomponents/ActivityIndicator.tsx
+++ b/src/components/chat/view/subcomponents/ActivityIndicator.tsx
@@ -7,6 +7,7 @@ import type { SessionActivity } from '../../../../hooks/useSessionProtection';
type ActivityIndicatorProps = {
activity: SessionActivity | null;
onAbort?: () => void;
+ isInputFocused?: boolean;
};
const ACTION_KEYS = [
@@ -18,6 +19,7 @@ const ACTION_KEYS = [
'claudeStatus.actions.reasoning',
];
const DEFAULT_ACTION_WORDS = ['Thinking', 'Processing', 'Analyzing', 'Working', 'Computing', 'Reasoning'];
+const EXIT_ANIMATION_MS = 220;
/**
* Minimal response-in-progress indicator, in the spirit of the inline status
@@ -26,11 +28,31 @@ const DEFAULT_ACTION_WORDS = ['Thinking', 'Processing', 'Analyzing', 'Working',
* session has an entry in the processing map; it disappears the instant that
* entry is removed.
*/
-export default function ActivityIndicator({ activity, onAbort }: ActivityIndicatorProps) {
+export default function ActivityIndicator({ activity, onAbort, isInputFocused = false }: ActivityIndicatorProps) {
const { t } = useTranslation('chat');
- const startedAt = activity?.startedAt ?? null;
+ const [renderedActivity, setRenderedActivity] = useState
(activity);
+ const [isExiting, setIsExiting] = useState(false);
+ const startedAt = renderedActivity?.startedAt ?? null;
const [elapsedSeconds, setElapsedSeconds] = useState(0);
+ useEffect(() => {
+ if (activity) {
+ setRenderedActivity(activity);
+ setIsExiting(false);
+ return;
+ }
+
+ if (!renderedActivity) return;
+
+ setIsExiting(true);
+ const timer = setTimeout(() => {
+ setRenderedActivity(null);
+ setIsExiting(false);
+ }, EXIT_ANIMATION_MS);
+
+ return () => clearTimeout(timer);
+ }, [activity, renderedActivity]);
+
useEffect(() => {
if (startedAt === null) return;
const update = () => setElapsedSeconds(Math.max(0, Math.floor((Date.now() - startedAt) / 1000)));
@@ -39,10 +61,10 @@ export default function ActivityIndicator({ activity, onAbort }: ActivityIndicat
return () => clearInterval(timer);
}, [startedAt]);
- if (!activity) return null;
+ if (!renderedActivity) return null;
const actionWords = ACTION_KEYS.map((key, i) => t(key, { defaultValue: DEFAULT_ACTION_WORDS[i] }));
- const label = (activity.statusText || actionWords[Math.floor(elapsedSeconds / 4) % actionWords.length])
+ const label = (renderedActivity.statusText || actionWords[Math.floor(elapsedSeconds / 4) % actionWords.length])
.replace(/\.+$/, '');
const minutes = Math.floor(elapsedSeconds / 60);
@@ -50,19 +72,31 @@ export default function ActivityIndicator({ activity, onAbort }: ActivityIndicat
const elapsedLabel = minutes < 1
? t('claudeStatus.elapsed.seconds', { count: seconds, defaultValue: '{{count}}s' })
: t('claudeStatus.elapsed.minutesSeconds', { minutes, seconds, defaultValue: '{{minutes}}m {{seconds}}s' });
+ const tabSurfaceClassName = [
+ 'chat-activity-tab inline-flex h-8 items-center rounded-b-none rounded-t-lg border border-b-0 bg-card px-3 text-xs transition-all duration-200',
+ isInputFocused
+ ? 'border-primary/30 shadow-[0_-1px_2px_hsl(var(--foreground)/0.08),1px_0_2px_hsl(var(--foreground)/0.06),-1px_0_2px_hsl(var(--foreground)/0.06)]'
+ : 'border-border/50 shadow-[0_-1px_1px_hsl(var(--foreground)/0.04),1px_0_1px_hsl(var(--foreground)/0.03),-1px_0_1px_hsl(var(--foreground)/0.03)]',
+ ].join(' ');
return (
-
-
-
-
{`${label}…`}
-
{elapsedLabel}
+
+
+
+
+ {`${label}…`}
+ {elapsedLabel}
+
- {activity.canInterrupt && onAbort && (
+ {renderedActivity.canInterrupt && onAbort && (
diff --git a/src/components/chat/view/subcomponents/ChatComposer.tsx b/src/components/chat/view/subcomponents/ChatComposer.tsx
index 6077ca2b..cc3f397c 100644
--- a/src/components/chat/view/subcomponents/ChatComposer.tsx
+++ b/src/components/chat/view/subcomponents/ChatComposer.tsx
@@ -11,7 +11,7 @@ import type {
RefObject,
TouchEvent,
} from 'react';
-import { ImageIcon, MessageSquareIcon, XIcon, ArrowDownIcon, Loader2 } from 'lucide-react';
+import { ImageIcon, MessageSquareIcon, XIcon, Loader2 } from 'lucide-react';
import { useVoiceInput } from '../../hooks/useVoiceInput';
import { useVoiceAvailable } from '../../hooks/useVoiceAvailable';
@@ -68,9 +68,6 @@ interface ChatComposerProps {
onToggleCommandMenu: () => void;
hasInput: boolean;
onClearInput: () => void;
- isUserScrolledUp: boolean;
- hasMessages: boolean;
- onScrollToBottom: () => void;
onSubmit: (event: FormEvent | MouseEvent | TouchEvent) => void;
isDragActive: boolean;
attachedImages: File[];
@@ -101,6 +98,7 @@ interface ChatComposerProps {
onTextareaPaste: (event: ClipboardEvent) => void;
onTextareaScrollSync: (target: HTMLTextAreaElement) => void;
onTextareaInput: (event: FormEvent) => void;
+ isInputFocused?: boolean;
onInputFocusChange?: (focused: boolean) => void;
placeholder: string;
isTextareaExpanded: boolean;
@@ -122,9 +120,6 @@ export default function ChatComposer({
onToggleCommandMenu,
hasInput,
onClearInput,
- isUserScrolledUp,
- hasMessages,
- onScrollToBottom,
onSubmit,
isDragActive,
attachedImages,
@@ -155,6 +150,7 @@ export default function ChatComposer({
onTextareaPaste,
onTextareaScrollSync,
onTextareaInput,
+ isInputFocused = false,
onInputFocusChange,
placeholder,
isTextareaExpanded,
@@ -201,15 +197,18 @@ export default function ChatComposer({
// Hide the thinking/status bar while any permission request is pending
const hasPendingPermissions = pendingPermissionRequests.length > 0;
+ const hasActivityIndicator = Boolean(activity && !hasPendingPermissions);
return (
-
+
{!hasPendingPermissions && (
-
+
)}
{pendingPermissionRequests.length > 0 && (
-
+
)}
- {!hasQuestionPanel &&
- {isUserScrolledUp && hasMessages && (
-
- )}
+ {!hasQuestionPanel &&
{showFileDropdown && filteredFiles.length > 0 && (
{filteredFiles.map((file, index) => (
@@ -271,7 +258,10 @@ export default function ChatComposer({
) => void}
status={isLoading ? 'streaming' : 'ready'}
- className={isTextareaExpanded ? 'chat-input-expanded' : ''}
+ className={[
+ isTextareaExpanded ? 'chat-input-expanded' : '',
+ hasActivityIndicator ? 'rounded-t-none' : '',
+ ].filter(Boolean).join(' ')}
{...getRootProps()}
>
{isDragActive && (
@@ -349,7 +339,7 @@ export default function ChatComposer({
;
@@ -61,7 +62,6 @@ interface ChatMessagesPaneProps {
onFileOpen?: (filePath: string, diffInfo?: unknown) => void;
onShowSettings?: () => void;
onGrantToolPermission: (suggestion: { entry: string; toolName: string }) => { success: boolean };
- autoExpandTools?: boolean;
showRawParameters?: boolean;
showThinking?: boolean;
selectedProject: Project;
@@ -111,48 +111,59 @@ function ChatMessagesPane({
onFileOpen,
onShowSettings,
onGrantToolPermission,
- autoExpandTools,
showRawParameters,
showThinking,
selectedProject,
}: ChatMessagesPaneProps) {
const { t } = useTranslation('chat');
- const messageKeyMapRef = useRef>(new WeakMap());
- const allocatedKeysRef = useRef>(new Set());
- const generatedMessageKeyCounterRef = useRef(0);
- const groupedVisibleMessages = useMemo(() => groupConsecutiveTools(visibleMessages), [visibleMessages]);
+ const groupedVisibleMessages = useMemo(
+ () => groupConsecutiveTools(visibleMessages, Boolean(showThinking)),
+ [visibleMessages, showThinking],
+ );
- // Keep keys stable across prepends so existing MessageComponent instances retain local state.
- const getMessageKey = useCallback((message: ChatMessage) => {
- const existingKey = messageKeyMapRef.current.get(message);
- if (existingKey) {
- return existingKey;
+ // Stable, deterministic keys for the messages rendered this pass.
+ //
+ // `normalizedToChatMessages` rebuilds fresh ChatMessage objects on every store
+ // update, so caching keys by object identity (or via a cross-render allocation
+ // Set) minted a brand-new key for the *same* logical message on each prepend —
+ // remounting the whole list, which disconnects the scroll-restore anchor and
+ // reflows heights, jumping the viewport to the bottom. Deriving keys purely
+ // from this render's ordered messages (intrinsic key, disambiguated by
+ // occurrence index on collision) yields the same key for the same message
+ // order, so React preserves existing DOM nodes and component state on prepend.
+ const messageKeyMap = useMemo(() => {
+ const keys = new WeakMap();
+ const occurrences = new Map();
+ const assign = (message: ChatMessage) => {
+ const intrinsicKey = getIntrinsicMessageKey(message) ?? 'message-generated';
+ const seen = occurrences.get(intrinsicKey) ?? 0;
+ occurrences.set(intrinsicKey, seen + 1);
+ keys.set(message, seen === 0 ? intrinsicKey : `${intrinsicKey}__${seen}`);
+ };
+ for (const item of groupedVisibleMessages) {
+ if (isToolGroupItem(item)) {
+ item.messages.forEach(assign);
+ } else {
+ assign(item);
+ }
}
+ return keys;
+ }, [groupedVisibleMessages]);
- const intrinsicKey = getIntrinsicMessageKey(message);
- let candidateKey = intrinsicKey;
-
- if (!candidateKey || allocatedKeysRef.current.has(candidateKey)) {
- do {
- generatedMessageKeyCounterRef.current += 1;
- candidateKey = intrinsicKey
- ? `${intrinsicKey}-${generatedMessageKeyCounterRef.current}`
- : `message-generated-${generatedMessageKeyCounterRef.current}`;
- } while (allocatedKeysRef.current.has(candidateKey));
- }
-
- allocatedKeysRef.current.add(candidateKey);
- messageKeyMapRef.current.set(message, candidateKey);
- return candidateKey;
- }, []);
+ const getMessageKey = useCallback(
+ (message: ChatMessage) =>
+ messageKeyMap.get(message) ?? getIntrinsicMessageKey(message) ?? 'message-generated',
+ [messageKeyMap],
+ );
return (
+
{(isLoadingSessionMessages || isProcessing) && chatMessages.length === 0 ? (
@@ -208,35 +219,13 @@ function ChatMessagesPane({
)}
- {/* Floating "Load all messages" overlay */}
- {(showLoadAllOverlay || isLoadingAllMessages || loadAllJustFinished) && (
-
- {loadAllJustFinished ? (
-
-
-
-
-
{t('session.messages.allLoaded')}
-
- ) : (
-
- {isLoadingAllMessages && (
-
- )}
-
- {isLoadingAllMessages
- ? t('session.messages.loadingAll')
- : <>{t('session.messages.loadAll')} {totalMessages > 0 && `(${totalMessages})`}>
- }
-
-
- )}
-
- )}
+
{/* Legacy message count indicator (for non-paginated view) */}
{!hasMoreMessages && chatMessages.length > visibleMessageCount && (
@@ -273,7 +262,6 @@ function ChatMessagesPane({
onFileOpen={onFileOpen}
onShowSettings={onShowSettings}
onGrantToolPermission={onGrantToolPermission}
- autoExpandTools={autoExpandTools}
showRawParameters={showRawParameters}
showThinking={showThinking}
selectedProject={selectedProject}
@@ -294,7 +282,6 @@ function ChatMessagesPane({
onFileOpen={onFileOpen}
onShowSettings={onShowSettings}
onGrantToolPermission={onGrantToolPermission}
- autoExpandTools={autoExpandTools}
showRawParameters={showRawParameters}
showThinking={showThinking}
selectedProject={selectedProject}
@@ -305,6 +292,7 @@ function ChatMessagesPane({
})()}
>
)}
+
);
}
diff --git a/src/components/chat/view/subcomponents/CommandMenu.tsx b/src/components/chat/view/subcomponents/CommandMenu.tsx
index 3e6116a0..51b90e09 100644
--- a/src/components/chat/view/subcomponents/CommandMenu.tsx
+++ b/src/components/chat/view/subcomponents/CommandMenu.tsx
@@ -1,5 +1,6 @@
import { useEffect, useRef } from 'react';
-import type { CSSProperties } from 'react';
+import { createPortal } from 'react-dom';
+import type { CSSProperties, ReactElement } from 'react';
import {
CornerDownLeft,
Folder,
@@ -77,6 +78,7 @@ const namespaceAccentClasses: Record
= {
const MENU_EDGE_GAP = 16;
const MENU_MAX_HEIGHT = 360;
+const MENU_MIN_HEIGHT = 160;
const getCommandKey = (command: CommandMenuCommand) =>
`${command.name}::${command.namespace || command.type || 'other'}::${command.path || ''}`;
@@ -92,8 +94,9 @@ const getMenuPosition = (position: { top: number; left: number; bottom?: number
if (typeof window === 'undefined') {
return { position: 'fixed', top: '16px', left: '16px' };
}
+ const maxAnchorBottom = Math.max(MENU_EDGE_GAP, window.innerHeight - MENU_EDGE_GAP - MENU_MIN_HEIGHT);
if (window.innerWidth < 640) {
- const anchorBottom = Math.max(MENU_EDGE_GAP, position.bottom ?? 90);
+ const anchorBottom = Math.min(Math.max(MENU_EDGE_GAP, position.bottom ?? 90), maxAnchorBottom);
return {
position: 'fixed',
bottom: `${anchorBottom}px`,
@@ -104,7 +107,7 @@ const getMenuPosition = (position: { top: number; left: number; bottom?: number
maxHeight: `min(54vh, calc(100vh - ${anchorBottom}px - ${MENU_EDGE_GAP}px))`,
};
}
- const anchorBottom = Math.max(MENU_EDGE_GAP, position.bottom ?? 90);
+ const anchorBottom = Math.min(Math.max(MENU_EDGE_GAP, position.bottom ?? 90), maxAnchorBottom);
const clampedLeft = Math.max(
MENU_EDGE_GAP,
Math.min(position.left, window.innerWidth - 440 - MENU_EDGE_GAP),
@@ -216,12 +219,14 @@ export default function CommandMenu({
: ['builtin', 'skill', 'project', 'user', 'other'];
const extraNamespaces = Object.keys(groupedCommands).filter((namespace) => !preferredOrder.includes(namespace));
const orderedNamespaces = [...preferredOrder, ...extraNamespaces].filter((namespace) => groupedCommands[namespace]);
+ const renderInPortal = (node: ReactElement) =>
+ typeof document === 'undefined' ? node : createPortal(node, document.body);
if (commands.length === 0) {
- return (
+ return renderInPortal(
{orderedNamespaces.map((namespace) => (
{orderedNamespaces.length > 1 && (
-
+
{namespaceLabels[namespace] || namespace}
-
+
{(groupedCommands[namespace] || []).length}
@@ -268,15 +273,15 @@ export default function CommandMenu({
aria-selected={isSelected}
className={`command-item group relative mb-1 flex cursor-pointer items-start gap-2 rounded-md border px-2.5 py-2 transition-all ${
isSelected
- ? 'border-sky-200 bg-sky-50 shadow-sm dark:border-cyan-400/30 dark:bg-cyan-400/10'
- : 'border-transparent bg-transparent hover:border-gray-200 hover:bg-gray-50/90 dark:hover:border-slate-700 dark:hover:bg-slate-900/80'
+ ? 'border-primary/30 bg-primary/10 shadow-sm'
+ : 'border-transparent bg-transparent hover:border-border hover:bg-accent'
}`}
onMouseEnter={() => onSelect && commandIndex >= 0 && onSelect(command, commandIndex, true)}
onClick={() => onSelect && commandIndex >= 0 && onSelect(command, commandIndex, false)}
onMouseDown={(event) => event.preventDefault()}
>
{isSelected && (
-
+
)}
@@ -284,20 +289,20 @@ export default function CommandMenu({
{command.name}
{command.metadata?.type && (
-
+
{command.metadata.type}
)}
{command.description && (
{command.description}
@@ -305,7 +310,7 @@ export default function CommandMenu({
)}
{isSelected && (
-
+
)}
diff --git a/src/components/chat/view/subcomponents/CommandResultModal.tsx b/src/components/chat/view/subcomponents/CommandResultModal.tsx
index d80a97d6..00a2894b 100644
--- a/src/components/chat/view/subcomponents/CommandResultModal.tsx
+++ b/src/components/chat/view/subcomponents/CommandResultModal.tsx
@@ -565,46 +565,41 @@ export default function CommandResultModal({
{activeMeta?.title || 'Command Result'}
-
-
-
-
-
-
-
-
-
-
- {activeMeta?.eyebrow}
-
-
- {activeMeta?.title}
-
-
- {activeMeta?.subtitle}
-
-
-
-
-
+
-
-
+
+
+
+
+ {activeMeta?.eyebrow}
+
+
+ {activeMeta?.title}
+
+
+ {activeMeta?.subtitle}
+
+
+
+
+
+
diff --git a/src/components/chat/view/subcomponents/LoadAllMessagesOverlay.tsx b/src/components/chat/view/subcomponents/LoadAllMessagesOverlay.tsx
new file mode 100644
index 00000000..ef246756
--- /dev/null
+++ b/src/components/chat/view/subcomponents/LoadAllMessagesOverlay.tsx
@@ -0,0 +1,68 @@
+import { useTranslation } from 'react-i18next';
+
+const loadAllOverlayAnimationStyle = `
+@keyframes loadAllOverlayAutoFade {
+ 0%, 80% { opacity: 1; }
+ 100% { opacity: 0; }
+}
+
+@media (prefers-reduced-motion: reduce) {
+ .load-all-overlay-auto-fade {
+ animation: none !important;
+ }
+}
+`;
+
+interface LoadAllMessagesOverlayProps {
+ showLoadAllOverlay: boolean;
+ isLoadingAllMessages: boolean;
+ loadAllJustFinished: boolean;
+ totalMessages: number;
+ onLoadAllMessages: () => void;
+}
+
+export default function LoadAllMessagesOverlay({
+ showLoadAllOverlay,
+ isLoadingAllMessages,
+ loadAllJustFinished,
+ totalMessages,
+ onLoadAllMessages,
+}: LoadAllMessagesOverlayProps) {
+ const { t } = useTranslation('chat');
+
+ if (!showLoadAllOverlay && !isLoadingAllMessages && !loadAllJustFinished) {
+ return null;
+ }
+
+ return (
+
+
+ {loadAllJustFinished ? (
+
+
+
+
+
{t('session.messages.allLoaded')}
+
+ ) : (
+
+ {isLoadingAllMessages && (
+
+ )}
+
+ {isLoadingAllMessages
+ ? t('session.messages.loadingAll')
+ : <>{t('session.messages.loadAll')} {totalMessages > 0 && `(${totalMessages})`}>}
+
+
+ )}
+
+ );
+}
diff --git a/src/components/chat/view/subcomponents/Markdown.tsx b/src/components/chat/view/subcomponents/Markdown.tsx
index fc1b9f19..4bacad2c 100644
--- a/src/components/chat/view/subcomponents/Markdown.tsx
+++ b/src/components/chat/view/subcomponents/Markdown.tsx
@@ -4,11 +4,12 @@ import remarkGfm from 'remark-gfm';
import remarkMath from 'remark-math';
import rehypeKatex from 'rehype-katex';
import { Prism as SyntaxHighlighter } from 'react-syntax-highlighter';
-import { oneDark } from 'react-syntax-highlighter/dist/esm/styles/prism';
+import { oneDark, oneLight } from 'react-syntax-highlighter/dist/esm/styles/prism';
import { useTranslation } from 'react-i18next';
import { normalizeInlineCodeFences } from '../../utils/chatFormatting';
import { copyTextToClipboard } from '../../../../utils/clipboard';
import { usePaletteOps } from '../../../../contexts/PaletteOpsContext';
+import { useTheme } from '../../../../contexts/ThemeContext';
type MarkdownProps = {
children: React.ReactNode;
@@ -59,6 +60,7 @@ type CodeBlockProps = {
const CodeBlock = ({ node, inline, className, children, ...props }: CodeBlockProps) => {
const { t } = useTranslation('chat');
+ const { isDarkMode } = useTheme();
const [copied, setCopied] = useState(false);
const raw = Array.isArray(children) ? children.join('') : String(children ?? '');
const looksMultiline = /[\r\n]/.test(raw);
@@ -96,7 +98,7 @@ const CodeBlock = ({ node, inline, className, children, ...props }: CodeBlockPro
}
})
}
- className="absolute right-2 top-2 z-10 rounded-md border border-gray-600 bg-gray-700/80 px-2 py-1 text-xs text-white opacity-0 transition-opacity hover:bg-gray-700 focus:opacity-100 active:opacity-100 group-hover:opacity-100"
+ className="absolute right-2 top-2 z-10 rounded-md border border-border bg-card/90 px-2 py-1 text-xs text-foreground/80 opacity-0 transition-opacity hover:bg-muted focus:opacity-100 active:opacity-100 group-hover:opacity-100"
title={copied ? t('codeBlock.copied') : t('codeBlock.copyCode')}
aria-label={copied ? t('codeBlock.copied') : t('codeBlock.copyCode')}
>
@@ -132,17 +134,20 @@ const CodeBlock = ({ node, inline, className, children, ...props }: CodeBlockPro
@@ -154,6 +159,10 @@ const CodeBlock = ({ node, inline, className, children, ...props }: CodeBlockPro
const markdownComponents = {
code: CodeBlock,
+ // CodeBlock renders its own syntax-highlighted ; this passthrough stops
+ // react-markdown (and Tailwind Typography) from wrapping it in a second,
+ // dark-themed shell that would frame the block.
+ pre: ({ children }: { children?: React.ReactNode }) => <>{children}>,
blockquote: ({ children }: { children?: React.ReactNode }) => (
{children}
diff --git a/src/components/chat/view/subcomponents/MessageComponent.tsx b/src/components/chat/view/subcomponents/MessageComponent.tsx
index e9615a85..b486da61 100644
--- a/src/components/chat/view/subcomponents/MessageComponent.tsx
+++ b/src/components/chat/view/subcomponents/MessageComponent.tsx
@@ -1,4 +1,4 @@
-import { memo, useEffect, useMemo, useRef, useState } from 'react';
+import { memo, useMemo, useRef } from 'react';
import { useTranslation } from 'react-i18next';
import SessionProviderLogo from '../../../llm-logo-provider/SessionProviderLogo';
@@ -30,7 +30,6 @@ type MessageComponentProps = {
onFileOpen?: (filePath: string, diffInfo?: unknown) => void;
onShowSettings?: () => void;
onGrantToolPermission?: (suggestion: ClaudePermissionSuggestion) => PermissionGrantResult | null | undefined;
- autoExpandTools?: boolean;
showRawParameters?: boolean;
showThinking?: boolean;
selectedProject?: Project | null;
@@ -45,7 +44,7 @@ type InteractiveOption = {
const COPY_HIDDEN_TOOL_NAMES = new Set(['Bash', 'Edit', 'Write', 'ApplyPatch']);
-const MessageComponent = memo(({ message, prevMessage, createDiff, onFileOpen, autoExpandTools, showRawParameters, showThinking, selectedProject, provider }: MessageComponentProps) => {
+const MessageComponent = memo(({ message, prevMessage, createDiff, onFileOpen, showRawParameters, showThinking, selectedProject, provider }: MessageComponentProps) => {
const { t } = useTranslation('chat');
const isGrouped = prevMessage && prevMessage.type === message.type &&
((prevMessage.type === 'assistant') ||
@@ -53,7 +52,6 @@ const MessageComponent = memo(({ message, prevMessage, createDiff, onFileOpen, a
(prevMessage.type === 'tool') ||
(prevMessage.type === 'error'));
const messageRef = useRef(null);
- const [isExpanded, setIsExpanded] = useState(false);
const userCopyContent = String(message.content || '');
const formattedMessageContent = useMemo(
() => formatUsageLimitText(String(message.content || '')),
@@ -72,32 +70,6 @@ const MessageComponent = memo(({ message, prevMessage, createDiff, onFileOpen, a
!message.isThinking;
- useEffect(() => {
- const node = messageRef.current;
- if (!autoExpandTools || !node || !message.isToolUse) return;
-
- const observer = new IntersectionObserver(
- (entries) => {
- entries.forEach((entry) => {
- if (entry.isIntersecting && !isExpanded) {
- setIsExpanded(true);
- const details = node.querySelectorAll('details');
- details.forEach((detail) => {
- detail.open = true;
- });
- }
- });
- },
- { threshold: 0.1 }
- );
-
- observer.observe(node);
-
- return () => {
- observer.unobserve(node);
- };
- }, [autoExpandTools, isExpanded, message.isToolUse]);
-
const formattedTime = useMemo(() => new Date(message.timestamp).toLocaleTimeString(), [message.timestamp]);
const shouldHideThinkingMessage = Boolean(message.isThinking && !showThinking);
@@ -115,7 +87,7 @@ const MessageComponent = memo(({ message, prevMessage, createDiff, onFileOpen, a
/* User message bubble on the right */
-
+
{message.content}
{message.images && message.images.length > 0 && (
@@ -166,7 +138,7 @@ const MessageComponent = memo(({ message, prevMessage, createDiff, onFileOpen, a
🔧
) : (
-
+
)}
@@ -194,7 +166,7 @@ const MessageComponent = memo(({ message, prevMessage, createDiff, onFileOpen, a
<>
-
+
{String(message.displayText || '')}
@@ -210,7 +182,6 @@ const MessageComponent = memo(({ message, prevMessage, createDiff, onFileOpen, a
onFileOpen={onFileOpen}
createDiff={createDiff}
selectedProject={selectedProject}
- autoExpandTools={autoExpandTools}
showRawParameters={showRawParameters}
rawToolInput={typeof message.toolInput === 'string' ? message.toolInput : undefined}
isSubagentContainer={message.isSubagentContainer}
@@ -233,7 +204,7 @@ const MessageComponent = memo(({ message, prevMessage, createDiff, onFileOpen, a
{t('messageTypes.error')}
-
+
{String(message.toolResult.content || '')}
@@ -250,7 +221,6 @@ const MessageComponent = memo(({ message, prevMessage, createDiff, onFileOpen, a
onFileOpen={onFileOpen}
createDiff={createDiff}
selectedProject={selectedProject}
- autoExpandTools={autoExpandTools}
/>
)
@@ -342,7 +312,7 @@ const MessageComponent = memo(({ message, prevMessage, createDiff, onFileOpen, a
-
+
{message.content}
@@ -377,15 +347,15 @@ const MessageComponent = memo(({ message, prevMessage, createDiff, onFileOpen, a
return (
-
+
-
+
-
+
{formatted}
@@ -399,7 +369,7 @@ const MessageComponent = memo(({ message, prevMessage, createDiff, onFileOpen, a
// Normal rendering for non-JSON content
return message.type === 'assistant' ? (
-
+
{content}
) : (
diff --git a/src/components/chat/view/subcomponents/MessageCopyControl.tsx b/src/components/chat/view/subcomponents/MessageCopyControl.tsx
index aeacd45c..c02b5676 100644
--- a/src/components/chat/view/subcomponents/MessageCopyControl.tsx
+++ b/src/components/chat/view/subcomponents/MessageCopyControl.tsx
@@ -1,4 +1,6 @@
import { useEffect, useMemo, useRef, useState } from 'react';
+import type { CSSProperties } from 'react';
+import { createPortal } from 'react-dom';
import { useTranslation } from 'react-i18next';
import { copyTextToClipboard } from '../../../../utils/clipboard';
@@ -49,9 +51,32 @@ const MessageCopyControl = ({
const [selectedFormat, setSelectedFormat] = useState(defaultFormat);
const [copied, setCopied] = useState(false);
const [isDropdownOpen, setIsDropdownOpen] = useState(false);
+ const [menuStyle, setMenuStyle] = useState({});
const dropdownRef = useRef(null);
+ const triggerRef = useRef(null);
+ const menuRef = useRef(null);
const copyFeedbackTimerRef = useRef | null>(null);
+ // The dropdown is rendered in a portal so it escapes the chat message's
+ // `contain: paint` box (which would otherwise clip it). Anchor it to the
+ // trigger, flipping above when there isn't room below.
+ const openDropdown = () => {
+ const rect = triggerRef.current?.getBoundingClientRect();
+ if (rect) {
+ const ESTIMATED_MENU_HEIGHT = 84;
+ const openUp = rect.bottom + ESTIMATED_MENU_HEIGHT + 8 > window.innerHeight;
+ setMenuStyle({
+ position: 'fixed',
+ right: Math.max(8, window.innerWidth - rect.right),
+ zIndex: 1000,
+ ...(openUp
+ ? { bottom: window.innerHeight - rect.top + 4 }
+ : { top: rect.bottom + 4 }),
+ });
+ }
+ setIsDropdownOpen(true);
+ };
+
const copyFormatOptions: CopyFormatOption[] = useMemo(
() => [
{
@@ -83,18 +108,28 @@ const MessageCopyControl = ({
}, [defaultFormat]);
useEffect(() => {
- // Close the dropdown when clicking anywhere outside this control.
+ if (!isDropdownOpen) return;
+
+ // Close when clicking outside both the control and the portaled menu.
const closeOnOutsideClick = (event: MouseEvent) => {
- if (!isDropdownOpen) return;
const target = event.target as Node;
- if (dropdownRef.current && !dropdownRef.current.contains(target)) {
- setIsDropdownOpen(false);
+ if (dropdownRef.current?.contains(target) || menuRef.current?.contains(target)) {
+ return;
}
+ setIsDropdownOpen(false);
};
+ // The menu is fixed-positioned; close it if the page scrolls so it can't
+ // detach from the trigger.
+ const closeOnScroll = () => setIsDropdownOpen(false);
+
window.addEventListener('mousedown', closeOnOutsideClick);
+ window.addEventListener('scroll', closeOnScroll, true);
+ window.addEventListener('resize', closeOnScroll);
return () => {
window.removeEventListener('mousedown', closeOnOutsideClick);
+ window.removeEventListener('scroll', closeOnScroll, true);
+ window.removeEventListener('resize', closeOnScroll);
};
}, [isDropdownOpen]);
@@ -170,8 +205,9 @@ const MessageCopyControl = ({
{canSelectCopyFormat && (
<>
setIsDropdownOpen((prev) => !prev)}
+ onClick={() => (isDropdownOpen ? setIsDropdownOpen(false) : openDropdown())}
className={`rounded px-1 py-0.5 transition-colors ${toneClass}`}
aria-label={t('copyMessage.selectFormat', { defaultValue: 'Select copy format' })}
title={t('copyMessage.selectFormat', { defaultValue: 'Select copy format' })}
@@ -186,8 +222,12 @@ const MessageCopyControl = ({
- {isDropdownOpen && (
-
+ {isDropdownOpen && createPortal(
+
{copyFormatOptions.map((option) => {
const isSelected = option.format === selectedFormat;
return (
@@ -196,15 +236,16 @@ const MessageCopyControl = ({
type="button"
onClick={() => handleFormatChange(option.format)}
className={`block w-full rounded px-2 py-1.5 text-left transition-colors ${isSelected
- ? 'bg-gray-100 text-gray-900 dark:bg-gray-800 dark:text-gray-100'
- : 'text-gray-700 hover:bg-gray-50 dark:text-gray-300 dark:hover:bg-gray-800/60'
+ ? 'bg-accent text-foreground'
+ : 'text-foreground hover:bg-accent'
}`}
>
{option.label}
);
})}
-
+
,
+ document.body,
)}
>
)}
diff --git a/src/components/chat/view/subcomponents/ProviderSelectionEmptyState.tsx b/src/components/chat/view/subcomponents/ProviderSelectionEmptyState.tsx
index 6d97ca88..a2bc74e8 100644
--- a/src/components/chat/view/subcomponents/ProviderSelectionEmptyState.tsx
+++ b/src/components/chat/view/subcomponents/ProviderSelectionEmptyState.tsx
@@ -186,7 +186,7 @@ export default function ProviderSelectionEmptyState({
if (!selectedSession && !currentSessionId) {
return (