diff --git a/electron/desktopWindow.js b/electron/desktopWindow.js index e4f2e558..8dcef0e8 100644 --- a/electron/desktopWindow.js +++ b/electron/desktopWindow.js @@ -195,8 +195,9 @@ export class DesktopWindowManager { this.actions.setActiveTarget(target); this.buildAppMenu(); this.mainWindow.setTitle(`${this.appName} - ${target.name}`); - await this.showContentTarget(target); + const finalUrl = await this.showContentTarget(target); this.emitDesktopState(); + return finalUrl; } async showLauncher() { diff --git a/electron/main.js b/electron/main.js index c97b7de4..665b8c96 100644 --- a/electron/main.js +++ b/electron/main.js @@ -1,4 +1,4 @@ -import { app, BrowserWindow, clipboard, dialog, ipcMain, shell, systemPreferences } from 'electron'; +import { app, BrowserWindow, clipboard, dialog, ipcMain, session, shell, systemPreferences } from 'electron'; import { spawn } from 'node:child_process'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; @@ -259,6 +259,28 @@ async function getEnvironmentLaunchTarget(environment) { }; } +async function hasCloudWebSession() { + const cookies = await session.defaultSession.cookies.get({}); + return cookies.some((cookie) => { + const cookieDomain = String(cookie.domain || ''); + return cookieDomain.includes('cloudcli.ai') + && /-auth-token(?:\.\d+)?$/.test(cookie.name) + && Boolean(cookie.value); + }); +} + +function isCloudAuthRedirect(url) { + if (!url) return false; + try { + const parsed = new URL(url); + const controlPlane = new URL(CLOUDCLI_CONTROL_PLANE_URL); + return parsed.origin === controlPlane.origin + && (parsed.pathname === '/login' || parsed.pathname.startsWith('/auth/')); + } catch { + return false; + } +} + function getDiagnosticsText() { const cloudAccount = cloud.getAccount(); const localState = getLocalState(); @@ -676,8 +698,18 @@ async function openEnvironmentInDesktop(environment) { nextEnvironment = await cloud.startEnvironmentAndWait(environment, REMOTE_START_TIMEOUT_MS); } - const target = await getEnvironmentLaunchTarget(nextEnvironment); - await desktopWindow.showTarget(target); + let target = getEnvironmentTarget(nextEnvironment); + if (!(await hasCloudWebSession())) { + target = await getEnvironmentLaunchTarget(nextEnvironment); + } + + const usedBootstrap = Boolean(target.loadUrl); + const finalUrl = await desktopWindow.showTarget(target); + if (!usedBootstrap && isCloudAuthRedirect(finalUrl)) { + const bootstrapTarget = await getEnvironmentLaunchTarget(nextEnvironment); + bootstrapTarget.forceLoad = true; + await desktopWindow.showTarget(bootstrapTarget); + } return getDesktopState(); } diff --git a/electron/viewHost.js b/electron/viewHost.js index 6153c618..43679a51 100644 --- a/electron/viewHost.js +++ b/electron/viewHost.js @@ -233,19 +233,21 @@ export class ViewHost { } const view = this.getOrCreateTabView(tabId); this.attach(view); - if (view.__cloudcliLoadedUrl !== target.url) { + if (target.forceLoad || view.__cloudcliLoadedUrl !== target.url) { view.__cloudcliLoadingUrl = loadUrl; try { await loadUrlWithTimeout(view.webContents, loadUrl); view.__cloudcliLoadedUrl = target.url; view.__cloudcliStartupHtml = null; delete target.loadUrl; + delete target.forceLoad; } finally { if (view.__cloudcliLoadingUrl === loadUrl) { view.__cloudcliLoadingUrl = null; } } } + return view.webContents.getURL(); } reloadTab(tabId) { diff --git a/src/components/chat/utils/toolGrouping.ts b/src/components/chat/utils/toolGrouping.ts new file mode 100644 index 00000000..c9d56433 --- /dev/null +++ b/src/components/chat/utils/toolGrouping.ts @@ -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; +} diff --git a/src/components/chat/view/subcomponents/ChatMessagesPane.tsx b/src/components/chat/view/subcomponents/ChatMessagesPane.tsx index 5573b31f..d97c944f 100644 --- a/src/components/chat/view/subcomponents/ChatMessagesPane.tsx +++ b/src/components/chat/view/subcomponents/ChatMessagesPane.tsx @@ -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; @@ -118,6 +120,7 @@ export default function ChatMessagesPane({ const messageKeyMapRef = useRef>(new WeakMap()); const allocatedKeysRef = useRef>(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({ )} - {visibleMessages.map((message, index) => { - const prevMessage = index > 0 ? visibleMessages[index - 1] : null; - return ( - - ); - })} + {(() => { + let prevMessage: ChatMessage | null = null; + + return groupedVisibleMessages.map((item) => { + if (isToolGroupItem(item)) { + const groupPrevMessage = prevMessage; + prevMessage = item.messages[item.messages.length - 1] || prevMessage; + + return ( + + ); + } + + const messagePrevMessage = prevMessage; + prevMessage = item; + + return ( + + ); + }); + })()} )} ); } - diff --git a/src/components/chat/view/subcomponents/ToolGroupContainer.tsx b/src/components/chat/view/subcomponents/ToolGroupContainer.tsx new file mode 100644 index 00000000..5fc5e837 --- /dev/null +++ b/src/components/chat/view/subcomponents/ToolGroupContainer.tsx @@ -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 ( +
+ + + {isExpanded && ( +
+ {group.messages.map((message, 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} + /> + ))} +
+ )} +
+ ); +}