fix: stabilize desktop environment auth navigation

This commit is contained in:
Simos Mikelatos
2026-06-24 20:09:41 +00:00
parent 81eb966904
commit 490e66ebdb
6 changed files with 301 additions and 26 deletions

View 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;
}

View File

@@ -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>
);
}

View 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>
);
}