mirror of
https://github.com/siteboon/claudecodeui.git
synced 2026-06-26 13:35:49 +08:00
fix: stabilize desktop environment auth navigation
This commit is contained in:
62
src/components/chat/utils/toolGrouping.ts
Normal file
62
src/components/chat/utils/toolGrouping.ts
Normal file
@@ -0,0 +1,62 @@
|
||||
import type { ChatMessage } from '../types/types';
|
||||
|
||||
export const TOOL_GROUP_THRESHOLD = 3;
|
||||
|
||||
export interface ToolGroupItem {
|
||||
_isGroup: true;
|
||||
toolName: string;
|
||||
messages: ChatMessage[];
|
||||
timestamp: ChatMessage['timestamp'];
|
||||
}
|
||||
|
||||
export type MessageListItem = ChatMessage | ToolGroupItem;
|
||||
|
||||
export function isToolGroupItem(item: MessageListItem): item is ToolGroupItem {
|
||||
return '_isGroup' in item && (item as ToolGroupItem)._isGroup === true;
|
||||
}
|
||||
|
||||
function isGroupableToolMessage(message: ChatMessage): message is ChatMessage & { toolName: string } {
|
||||
return Boolean(message.isToolUse && message.toolName && !message.isSubagentContainer);
|
||||
}
|
||||
|
||||
export function groupConsecutiveTools(messages: ChatMessage[]): MessageListItem[] {
|
||||
const items: MessageListItem[] = [];
|
||||
let index = 0;
|
||||
|
||||
while (index < messages.length) {
|
||||
const message = messages[index];
|
||||
|
||||
if (!isGroupableToolMessage(message)) {
|
||||
items.push(message);
|
||||
index += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
if (run.length >= TOOL_GROUP_THRESHOLD) {
|
||||
items.push({
|
||||
_isGroup: true,
|
||||
toolName: message.toolName,
|
||||
messages: run,
|
||||
timestamp: message.timestamp,
|
||||
});
|
||||
} else {
|
||||
items.push(...run);
|
||||
}
|
||||
|
||||
index = nextIndex;
|
||||
}
|
||||
|
||||
return items;
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useCallback, useRef } from 'react';
|
||||
import { useCallback, useMemo, useRef } from 'react';
|
||||
import type { Dispatch, RefObject, SetStateAction } from 'react';
|
||||
|
||||
import type { ChatMessage } from '../../types/types';
|
||||
@@ -10,9 +10,11 @@ import type {
|
||||
ProviderModelsDefinition,
|
||||
} from '../../../../types/app';
|
||||
import { getIntrinsicMessageKey } from '../../utils/messageKeys';
|
||||
import { groupConsecutiveTools, isToolGroupItem } from '../../utils/toolGrouping';
|
||||
|
||||
import MessageComponent from './MessageComponent';
|
||||
import ProviderSelectionEmptyState from './ProviderSelectionEmptyState';
|
||||
import ToolGroupContainer from './ToolGroupContainer';
|
||||
|
||||
interface ChatMessagesPaneProps {
|
||||
scrollContainerRef: RefObject<HTMLDivElement>;
|
||||
@@ -118,6 +120,7 @@ export default function ChatMessagesPane({
|
||||
const messageKeyMapRef = useRef<WeakMap<ChatMessage, string>>(new WeakMap());
|
||||
const allocatedKeysRef = useRef<Set<string>>(new Set());
|
||||
const generatedMessageKeyCounterRef = useRef(0);
|
||||
const groupedVisibleMessages = useMemo(() => groupConsecutiveTools(visibleMessages), [visibleMessages]);
|
||||
|
||||
// Keep keys stable across prepends so existing MessageComponent instances retain local state.
|
||||
const getMessageKey = useCallback((message: ChatMessage) => {
|
||||
@@ -252,28 +255,56 @@ export default function ChatMessagesPane({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{visibleMessages.map((message, index) => {
|
||||
const prevMessage = index > 0 ? visibleMessages[index - 1] : null;
|
||||
return (
|
||||
<MessageComponent
|
||||
key={getMessageKey(message)}
|
||||
message={message}
|
||||
prevMessage={prevMessage}
|
||||
createDiff={createDiff}
|
||||
onFileOpen={onFileOpen}
|
||||
onShowSettings={onShowSettings}
|
||||
onGrantToolPermission={onGrantToolPermission}
|
||||
autoExpandTools={autoExpandTools}
|
||||
showRawParameters={showRawParameters}
|
||||
showThinking={showThinking}
|
||||
selectedProject={selectedProject}
|
||||
provider={provider}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
{(() => {
|
||||
let prevMessage: ChatMessage | null = null;
|
||||
|
||||
return groupedVisibleMessages.map((item) => {
|
||||
if (isToolGroupItem(item)) {
|
||||
const groupPrevMessage = prevMessage;
|
||||
prevMessage = item.messages[item.messages.length - 1] || prevMessage;
|
||||
|
||||
return (
|
||||
<ToolGroupContainer
|
||||
key={`tool-group-${getMessageKey(item.messages[0])}`}
|
||||
group={item}
|
||||
prevMessage={groupPrevMessage}
|
||||
createDiff={createDiff}
|
||||
getMessageKey={getMessageKey}
|
||||
onFileOpen={onFileOpen}
|
||||
onShowSettings={onShowSettings}
|
||||
onGrantToolPermission={onGrantToolPermission}
|
||||
autoExpandTools={autoExpandTools}
|
||||
showRawParameters={showRawParameters}
|
||||
showThinking={showThinking}
|
||||
selectedProject={selectedProject}
|
||||
provider={provider}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const messagePrevMessage = prevMessage;
|
||||
prevMessage = item;
|
||||
|
||||
return (
|
||||
<MessageComponent
|
||||
key={getMessageKey(item)}
|
||||
message={item}
|
||||
prevMessage={messagePrevMessage}
|
||||
createDiff={createDiff}
|
||||
onFileOpen={onFileOpen}
|
||||
onShowSettings={onShowSettings}
|
||||
onGrantToolPermission={onGrantToolPermission}
|
||||
autoExpandTools={autoExpandTools}
|
||||
showRawParameters={showRawParameters}
|
||||
showThinking={showThinking}
|
||||
selectedProject={selectedProject}
|
||||
provider={provider}
|
||||
/>
|
||||
);
|
||||
});
|
||||
})()}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
147
src/components/chat/view/subcomponents/ToolGroupContainer.tsx
Normal file
147
src/components/chat/view/subcomponents/ToolGroupContainer.tsx
Normal file
@@ -0,0 +1,147 @@
|
||||
import { useMemo, useState } from 'react';
|
||||
import { ChevronRight } from 'lucide-react';
|
||||
|
||||
import type { ChatMessage, ClaudePermissionSuggestion, PermissionGrantResult, Provider } from '../../types/types';
|
||||
import type { Project } from '../../../../types/app';
|
||||
import type { ToolGroupItem } from '../../utils/toolGrouping';
|
||||
import { getToolConfig } from '../../tools';
|
||||
|
||||
import MessageComponent from './MessageComponent';
|
||||
|
||||
type DiffLine = {
|
||||
type: string;
|
||||
content: string;
|
||||
lineNum: number;
|
||||
};
|
||||
|
||||
interface ToolGroupContainerProps {
|
||||
group: ToolGroupItem;
|
||||
prevMessage: ChatMessage | null;
|
||||
createDiff: (oldStr: string, newStr: string) => DiffLine[];
|
||||
getMessageKey: (message: ChatMessage) => string;
|
||||
onFileOpen?: (filePath: string, diffInfo?: unknown) => void;
|
||||
onShowSettings?: () => void;
|
||||
onGrantToolPermission?: (suggestion: ClaudePermissionSuggestion) => PermissionGrantResult | null | undefined;
|
||||
autoExpandTools?: boolean;
|
||||
showRawParameters?: boolean;
|
||||
showThinking?: boolean;
|
||||
selectedProject?: Project | null;
|
||||
provider: Provider | string;
|
||||
}
|
||||
|
||||
function parseToolInput(toolInput: unknown): unknown {
|
||||
if (typeof toolInput !== 'string') {
|
||||
return toolInput;
|
||||
}
|
||||
|
||||
try {
|
||||
return JSON.parse(toolInput);
|
||||
} catch {
|
||||
return toolInput;
|
||||
}
|
||||
}
|
||||
|
||||
function getToolInputPreview(message: ChatMessage): string {
|
||||
const config = getToolConfig(message.toolName || 'UnknownTool').input;
|
||||
const parsedInput = parseToolInput(message.toolInput);
|
||||
const title = typeof config.title === 'function' ? config.title(parsedInput) : config.title;
|
||||
const value = config.getValue?.(parsedInput);
|
||||
|
||||
return String(value || title || message.displayText || message.content || '').trim();
|
||||
}
|
||||
|
||||
function getToolGroupIcon(icon: string | undefined, toolName: string): string {
|
||||
if (icon === 'terminal') {
|
||||
return '$';
|
||||
}
|
||||
|
||||
return icon || toolName.slice(0, 1).toUpperCase();
|
||||
}
|
||||
|
||||
export default function ToolGroupContainer({
|
||||
group,
|
||||
prevMessage,
|
||||
createDiff,
|
||||
getMessageKey,
|
||||
onFileOpen,
|
||||
onShowSettings,
|
||||
onGrantToolPermission,
|
||||
autoExpandTools,
|
||||
showRawParameters,
|
||||
showThinking,
|
||||
selectedProject,
|
||||
provider,
|
||||
}: ToolGroupContainerProps) {
|
||||
const [isExpanded, setIsExpanded] = useState(false);
|
||||
const config = getToolConfig(group.toolName).input;
|
||||
const label = config.label || group.toolName;
|
||||
const borderClass = config.colorScheme?.border || 'border-border';
|
||||
const iconClass = config.colorScheme?.icon || 'text-muted-foreground';
|
||||
const icon = getToolGroupIcon(config.icon, group.toolName);
|
||||
|
||||
const preview = useMemo(() => {
|
||||
const visiblePreviews = group.messages
|
||||
.slice(0, 2)
|
||||
.map(getToolInputPreview)
|
||||
.filter(Boolean);
|
||||
|
||||
const extraCount = group.messages.length - visiblePreviews.length;
|
||||
const previewText = visiblePreviews.join(', ');
|
||||
|
||||
if (!previewText) {
|
||||
return extraCount > 0 ? `+${extraCount} more` : '';
|
||||
}
|
||||
|
||||
return extraCount > 0 ? `${previewText}, +${extraCount} more` : previewText;
|
||||
}, [group.messages]);
|
||||
|
||||
return (
|
||||
<div className="chat-message tool px-3 sm:px-0" data-message-timestamp={group.timestamp || undefined}>
|
||||
<button
|
||||
type="button"
|
||||
className={`group flex w-full items-center gap-2 border-l-2 ${borderClass} rounded-r-md bg-muted/25 px-3 py-2 text-left transition-colors hover:bg-muted/40 dark:bg-muted/10 dark:hover:bg-muted/20`}
|
||||
onClick={() => setIsExpanded((current) => !current)}
|
||||
aria-expanded={isExpanded}
|
||||
>
|
||||
<ChevronRight
|
||||
className={`h-3.5 w-3.5 flex-shrink-0 text-muted-foreground transition-transform ${isExpanded ? 'rotate-90' : ''}`}
|
||||
aria-hidden
|
||||
/>
|
||||
<span className={`${iconClass} flex h-5 w-5 flex-shrink-0 items-center justify-center rounded bg-background/80 text-xs font-medium`}>
|
||||
{icon}
|
||||
</span>
|
||||
<span className="min-w-0 flex-shrink-0 text-xs font-medium text-foreground">{label}</span>
|
||||
<span className="flex-shrink-0 rounded-full bg-muted px-1.5 py-0.5 text-[10px] font-medium text-muted-foreground">
|
||||
x{group.messages.length}
|
||||
</span>
|
||||
{preview && (
|
||||
<>
|
||||
<span className="text-[10px] text-muted-foreground/40">/</span>
|
||||
<span className="min-w-0 truncate font-mono text-xs text-muted-foreground">{preview}</span>
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
|
||||
{isExpanded && (
|
||||
<div className="mt-2 space-y-3 sm:space-y-4">
|
||||
{group.messages.map((message, index) => (
|
||||
<MessageComponent
|
||||
key={getMessageKey(message)}
|
||||
message={message}
|
||||
prevMessage={index > 0 ? group.messages[index - 1] : prevMessage}
|
||||
createDiff={createDiff}
|
||||
onFileOpen={onFileOpen}
|
||||
onShowSettings={onShowSettings}
|
||||
onGrantToolPermission={onGrantToolPermission}
|
||||
autoExpandTools={autoExpandTools}
|
||||
showRawParameters={showRawParameters}
|
||||
showThinking={showThinking}
|
||||
selectedProject={selectedProject}
|
||||
provider={provider}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user