fix: improve desktop chat performance

This commit is contained in:
Simos Mikelatos
2026-06-24 20:49:24 +00:00
parent fe116a7138
commit 8ad18f8587
11 changed files with 153 additions and 28 deletions

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

View File

@@ -682,10 +682,18 @@ export class DesktopWindowManager {
}
configurePermissions() {
session.defaultSession.setPermissionRequestHandler((webContents, permission, callback) => {
const isAllowedPermission = (webContents, permission) => {
const sourceUrl = webContents.getURL();
const allowedPermissions = new Set(['clipboard-read', 'media']);
callback(isAllowedPermissionOrigin(sourceUrl, this.getCloudState().controlPlaneUrl) && allowedPermissions.has(permission));
const allowedPermissions = new Set(['clipboard-read', 'media', 'notifications']);
return isAllowedPermissionOrigin(sourceUrl, this.getCloudState().controlPlaneUrl) && allowedPermissions.has(permission);
};
session.defaultSession.setPermissionRequestHandler((webContents, permission, callback) => {
callback(isAllowedPermission(webContents, permission));
});
session.defaultSession.setPermissionCheckHandler((webContents, permission) => {
if (!webContents) return false;
return isAllowedPermission(webContents, permission);
});
}

View File

@@ -12,6 +12,7 @@ import { TabsController } from './tabs.js';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const APP_NAME = 'CloudCLI';
const APP_USER_MODEL_ID = 'ai.cloudcli.desktop';
const CALLBACK_PROTOCOL = 'cloudcli';
const CALLBACK_URL = `${CALLBACK_PROTOCOL}://auth/callback`;
const CLOUDCLI_CONTROL_PLANE_URL = process.env.CLOUDCLI_CONTROL_PLANE_URL || 'https://cloudcli.ai';
@@ -20,6 +21,10 @@ const AUTH_CALLBACK_TTL_MS = 10 * 60 * 1000;
const tabs = new TabsController();
if (process.platform === 'win32') {
app.setAppUserModelId(APP_USER_MODEL_ID);
}
let activeTarget = { kind: 'launcher', name: APP_NAME, url: null };
let desktopWindow = null;
let localServer = null;

View File

@@ -0,0 +1,57 @@
#!/usr/bin/env node
import fs from 'node:fs/promises';
import path from 'node:path';
import sharp from 'sharp';
const assetsDir = 'electron/assets';
const sourcePath = 'public/logo-512.png';
const icoPath = path.join(assetsDir, 'logo-windows.ico');
const sizes = [16, 24, 32, 48, 64, 128, 256];
function writeDirectoryEntry(buffer, image, offset) {
buffer.writeUInt8(image.size === 256 ? 0 : image.size, offset);
buffer.writeUInt8(image.size === 256 ? 0 : image.size, offset + 1);
buffer.writeUInt8(0, offset + 2);
buffer.writeUInt8(0, offset + 3);
buffer.writeUInt16LE(1, offset + 4);
buffer.writeUInt16LE(32, offset + 6);
buffer.writeUInt32LE(image.buffer.length, offset + 8);
buffer.writeUInt32LE(image.offset, offset + 12);
}
async function renderPng(size) {
return sharp(sourcePath)
.resize(size, size, { fit: 'contain' })
.png()
.toBuffer();
}
await fs.mkdir(assetsDir, { recursive: true });
const images = await Promise.all(
sizes.map(async (size) => ({
size,
buffer: await renderPng(size),
offset: 0,
})),
);
const headerSize = 6 + images.length * 16;
let cursor = headerSize;
for (const image of images) {
image.offset = cursor;
cursor += image.buffer.length;
}
const ico = Buffer.alloc(cursor);
ico.writeUInt16LE(0, 0);
ico.writeUInt16LE(1, 2);
ico.writeUInt16LE(images.length, 4);
images.forEach((image, index) => {
writeDirectoryEntry(ico, image, 6 + index * 16);
image.buffer.copy(ico, image.offset);
});
await fs.writeFile(icoPath, ico);
console.log(`Wrote ${icoPath}`);

View File

@@ -43,6 +43,7 @@
"desktop:dist:win": "npm run build && npm run desktop:stage && electron-builder --projectDir .desktop-build/desktop-app --win nsis",
"server:bundle": "npm run build && node scripts/release/build-server-bundle.js",
"desktop:icon:mac": "node electron/scripts/generate-macos-icon.js",
"desktop:icon:win": "node electron/scripts/generate-windows-icon.js",
"build": "npm run build:semantics && npm run build:client && npm run build:server",
"build:client": "vite build",
"build:semantics": "node scripts/build-computer-semantics.mjs",
@@ -110,9 +111,16 @@
}
},
"win": {
"icon": "electron/assets/logo-windows.ico",
"target": [
"nsis"
]
],
"publisherName": "CloudCLI",
"verifyUpdateCodeSignature": false
},
"nsis": {
"installerIcon": "electron/assets/logo-windows.ico",
"uninstallerIcon": "electron/assets/logo-windows.ico"
}
},
"keywords": [

View File

@@ -101,6 +101,7 @@ function buildDesktopPackageJson(copiedOptionalDependencies) {
protocols: packageJson.build.protocols,
mac: packageJson.build.mac,
win: packageJson.build.win,
nsis: packageJson.build.nsis,
},
};
}

View File

@@ -204,6 +204,8 @@ export function useChatComposerState({
const textareaRef = useRef<HTMLTextAreaElement>(null);
const inputHighlightRef = useRef<HTMLDivElement>(null);
const textareaLineHeightRef = useRef<number | null>(null);
const lastAutosizedInputRef = useRef<string | null>(null);
const handleSubmitRef = useRef<
((event: FormEvent<HTMLFormElement> | MouseEvent | TouchEvent | KeyboardEvent<HTMLTextAreaElement>) => Promise<void>) | null
>(null);
@@ -457,6 +459,22 @@ export function useChatComposerState({
inputHighlightRef.current.scrollLeft = target.scrollLeft;
}, []);
const resizeTextarea = useCallback((target: HTMLTextAreaElement) => {
target.style.height = 'auto';
const nextHeight = Math.max(22, target.scrollHeight);
target.style.height = `${nextHeight}px`;
let lineHeight = textareaLineHeightRef.current;
if (!lineHeight) {
lineHeight = parseInt(window.getComputedStyle(target).lineHeight);
textareaLineHeightRef.current = Number.isFinite(lineHeight) ? lineHeight : 24;
}
const expanded = nextHeight > (textareaLineHeightRef.current || 24) * 2;
setIsTextareaExpanded((previous) => previous === expanded ? previous : expanded);
lastAutosizedInputRef.current = target.value;
}, []);
const handleImageFiles = useCallback((files: File[]) => {
const validFiles = files.filter((file) => {
try {
@@ -806,13 +824,13 @@ export function useChatComposerState({
if (!textareaRef.current) {
return;
}
// Re-run when input changes so restored drafts get the same autosize behavior as typed text.
textareaRef.current.style.height = 'auto';
textareaRef.current.style.height = `${Math.max(22, textareaRef.current.scrollHeight)}px`;
const lineHeight = parseInt(window.getComputedStyle(textareaRef.current).lineHeight);
const expanded = textareaRef.current.scrollHeight > lineHeight * 2;
setIsTextareaExpanded(expanded);
}, [input]);
if (lastAutosizedInputRef.current === input) {
return;
}
// Re-run for restored drafts and programmatic input changes. User typing is
// already resized in onInput, so this avoids doing the same forced layout twice.
resizeTextarea(textareaRef.current);
}, [input, resizeTextarea]);
useEffect(() => {
if (!textareaRef.current || input.trim()) {
@@ -894,15 +912,11 @@ export function useChatComposerState({
const handleTextareaInput = useCallback(
(event: FormEvent<HTMLTextAreaElement>) => {
const target = event.currentTarget;
target.style.height = 'auto';
target.style.height = `${Math.max(22, target.scrollHeight)}px`;
resizeTextarea(target);
setCursorPosition(target.selectionStart);
syncInputOverlayScroll(target);
const lineHeight = parseInt(window.getComputedStyle(target).lineHeight);
setIsTextareaExpanded(target.scrollHeight > lineHeight * 2);
},
[setCursorPosition, syncInputOverlayScroll],
[resizeTextarea, setCursorPosition, syncInputOverlayScroll],
);
const handleClearInput = useCallback(() => {

View File

@@ -309,7 +309,7 @@ function ChatInterface({
return (
<PermissionContext.Provider value={permissionContextValue}>
<div className="flex h-full flex-col">
<div className="flex h-full min-h-0 flex-col">
<ChatMessagesPane
scrollContainerRef={scrollContainerRef}
onWheel={handleScroll}

View File

@@ -1,4 +1,5 @@
import { useTranslation } from 'react-i18next';
import { useMemo } from 'react';
import type {
ChangeEvent,
ClipboardEvent,
@@ -154,12 +155,17 @@ export default function ChatComposer({
sendByCtrlEnter,
}: ChatComposerProps) {
const { t } = useTranslation('chat');
const textareaRect = textareaRef.current?.getBoundingClientRect();
const commandMenuPosition = {
top: textareaRect ? Math.max(16, textareaRect.top - 316) : 0,
left: textareaRect ? textareaRect.left : 16,
bottom: textareaRect ? window.innerHeight - textareaRect.top + 8 : 90,
};
const commandMenuPosition = useMemo(() => {
if (!isCommandMenuOpen) {
return { top: 0, left: 16, bottom: 90 };
}
const textareaRect = textareaRef.current?.getBoundingClientRect();
return {
top: textareaRect ? Math.max(16, textareaRect.top - 316) : 0,
left: textareaRect ? textareaRect.left : 16,
bottom: textareaRect ? window.innerHeight - textareaRect.top + 8 : 90,
};
}, [input, isCommandMenuOpen, textareaRef]);
// Detect if the AskUserQuestion interactive panel is active
const hasQuestionPanel = pendingPermissionRequests.some(
@@ -170,7 +176,7 @@ export default function ChatComposer({
const hasPendingPermissions = pendingPermissionRequests.length > 0;
return (
<div className="flex-shrink-0 p-2 pb-2 sm:p-4 sm:pb-4 md:p-4 md:pb-6">
<div className="chat-composer-shell flex-shrink-0 p-2 pb-2 sm:p-4 sm:pb-4 md:p-4 md:pb-6">
{!hasPendingPermissions && (
<ActivityIndicator activity={activity} onAbort={onAbortSession} />
)}

View File

@@ -1,5 +1,5 @@
import { useTranslation } from 'react-i18next';
import { useCallback, useMemo, useRef } from 'react';
import { memo, useCallback, useMemo, useRef } from 'react';
import type { Dispatch, RefObject, SetStateAction } from 'react';
import type { ChatMessage } from '../../types/types';
@@ -67,7 +67,7 @@ interface ChatMessagesPaneProps {
selectedProject: Project;
}
export default function ChatMessagesPane({
function ChatMessagesPane({
scrollContainerRef,
onWheel,
onTouchMove,
@@ -151,7 +151,7 @@ export default function ChatMessagesPane({
ref={scrollContainerRef}
onWheel={onWheel}
onTouchMove={onTouchMove}
className="relative flex-1 space-y-3 overflow-y-auto overflow-x-hidden px-0 py-3 sm:space-y-4 sm:p-4"
className="chat-messages-pane relative min-h-0 flex-1 space-y-3 overflow-y-auto overflow-x-hidden px-0 py-3 sm:space-y-4 sm:p-4"
>
{(isLoadingSessionMessages || isProcessing) && chatMessages.length === 0 ? (
<div className="mt-8 text-center text-gray-500 dark:text-gray-400">
@@ -308,3 +308,5 @@ export default function ChatMessagesPane({
</div>
);
}
export default memo(ChatMessagesPane);

View File

@@ -557,6 +557,30 @@
/* Mobile optimizations and components */
@layer components {
.chat-messages-pane {
contain: layout style paint;
}
.chat-composer-shell {
contain: layout style paint;
}
.chat-message {
contain: layout style paint;
content-visibility: auto;
contain-intrinsic-size: auto 180px;
}
.chat-message.assistant {
contain-intrinsic-size: auto 240px;
}
.chat-message.user,
.chat-message.tool,
.chat-message.error {
contain-intrinsic-size: auto 96px;
}
/* Mobile touch optimization and safe areas */
@media (max-width: 768px) {
* {