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

@@ -195,8 +195,9 @@ export class DesktopWindowManager {
this.actions.setActiveTarget(target); this.actions.setActiveTarget(target);
this.buildAppMenu(); this.buildAppMenu();
this.mainWindow.setTitle(`${this.appName} - ${target.name}`); this.mainWindow.setTitle(`${this.appName} - ${target.name}`);
await this.showContentTarget(target); const finalUrl = await this.showContentTarget(target);
this.emitDesktopState(); this.emitDesktopState();
return finalUrl;
} }
async showLauncher() { async showLauncher() {

View File

@@ -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 { spawn } from 'node:child_process';
import path from 'node:path'; import path from 'node:path';
import { fileURLToPath } from 'node:url'; 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() { function getDiagnosticsText() {
const cloudAccount = cloud.getAccount(); const cloudAccount = cloud.getAccount();
const localState = getLocalState(); const localState = getLocalState();
@@ -676,8 +698,18 @@ async function openEnvironmentInDesktop(environment) {
nextEnvironment = await cloud.startEnvironmentAndWait(environment, REMOTE_START_TIMEOUT_MS); nextEnvironment = await cloud.startEnvironmentAndWait(environment, REMOTE_START_TIMEOUT_MS);
} }
const target = await getEnvironmentLaunchTarget(nextEnvironment); let target = getEnvironmentTarget(nextEnvironment);
await desktopWindow.showTarget(target); 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(); return getDesktopState();
} }

View File

@@ -233,19 +233,21 @@ export class ViewHost {
} }
const view = this.getOrCreateTabView(tabId); const view = this.getOrCreateTabView(tabId);
this.attach(view); this.attach(view);
if (view.__cloudcliLoadedUrl !== target.url) { if (target.forceLoad || view.__cloudcliLoadedUrl !== target.url) {
view.__cloudcliLoadingUrl = loadUrl; view.__cloudcliLoadingUrl = loadUrl;
try { try {
await loadUrlWithTimeout(view.webContents, loadUrl); await loadUrlWithTimeout(view.webContents, loadUrl);
view.__cloudcliLoadedUrl = target.url; view.__cloudcliLoadedUrl = target.url;
view.__cloudcliStartupHtml = null; view.__cloudcliStartupHtml = null;
delete target.loadUrl; delete target.loadUrl;
delete target.forceLoad;
} finally { } finally {
if (view.__cloudcliLoadingUrl === loadUrl) { if (view.__cloudcliLoadingUrl === loadUrl) {
view.__cloudcliLoadingUrl = null; view.__cloudcliLoadingUrl = null;
} }
} }
} }
return view.webContents.getURL();
} }
reloadTab(tabId) { reloadTab(tabId) {

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 { useTranslation } from 'react-i18next';
import { useCallback, useRef } from 'react'; import { useCallback, useMemo, useRef } from 'react';
import type { Dispatch, RefObject, SetStateAction } from 'react'; import type { Dispatch, RefObject, SetStateAction } from 'react';
import type { ChatMessage } from '../../types/types'; import type { ChatMessage } from '../../types/types';
@@ -10,9 +10,11 @@ import type {
ProviderModelsDefinition, ProviderModelsDefinition,
} from '../../../../types/app'; } from '../../../../types/app';
import { getIntrinsicMessageKey } from '../../utils/messageKeys'; import { getIntrinsicMessageKey } from '../../utils/messageKeys';
import { groupConsecutiveTools, isToolGroupItem } from '../../utils/toolGrouping';
import MessageComponent from './MessageComponent'; import MessageComponent from './MessageComponent';
import ProviderSelectionEmptyState from './ProviderSelectionEmptyState'; import ProviderSelectionEmptyState from './ProviderSelectionEmptyState';
import ToolGroupContainer from './ToolGroupContainer';
interface ChatMessagesPaneProps { interface ChatMessagesPaneProps {
scrollContainerRef: RefObject<HTMLDivElement>; scrollContainerRef: RefObject<HTMLDivElement>;
@@ -118,6 +120,7 @@ export default function ChatMessagesPane({
const messageKeyMapRef = useRef<WeakMap<ChatMessage, string>>(new WeakMap()); const messageKeyMapRef = useRef<WeakMap<ChatMessage, string>>(new WeakMap());
const allocatedKeysRef = useRef<Set<string>>(new Set()); const allocatedKeysRef = useRef<Set<string>>(new Set());
const generatedMessageKeyCounterRef = useRef(0); const generatedMessageKeyCounterRef = useRef(0);
const groupedVisibleMessages = useMemo(() => groupConsecutiveTools(visibleMessages), [visibleMessages]);
// Keep keys stable across prepends so existing MessageComponent instances retain local state. // Keep keys stable across prepends so existing MessageComponent instances retain local state.
const getMessageKey = useCallback((message: ChatMessage) => { const getMessageKey = useCallback((message: ChatMessage) => {
@@ -252,28 +255,56 @@ export default function ChatMessagesPane({
</div> </div>
)} )}
{visibleMessages.map((message, index) => { {(() => {
const prevMessage = index > 0 ? visibleMessages[index - 1] : null; let prevMessage: ChatMessage | null = null;
return (
<MessageComponent return groupedVisibleMessages.map((item) => {
key={getMessageKey(message)} if (isToolGroupItem(item)) {
message={message} const groupPrevMessage = prevMessage;
prevMessage={prevMessage} prevMessage = item.messages[item.messages.length - 1] || prevMessage;
createDiff={createDiff}
onFileOpen={onFileOpen} return (
onShowSettings={onShowSettings} <ToolGroupContainer
onGrantToolPermission={onGrantToolPermission} key={`tool-group-${getMessageKey(item.messages[0])}`}
autoExpandTools={autoExpandTools} group={item}
showRawParameters={showRawParameters} prevMessage={groupPrevMessage}
showThinking={showThinking} createDiff={createDiff}
selectedProject={selectedProject} getMessageKey={getMessageKey}
provider={provider} 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> </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>
);
}