diff --git a/electron/assets/logo-windows.ico b/electron/assets/logo-windows.ico new file mode 100644 index 00000000..8ce6a911 Binary files /dev/null and b/electron/assets/logo-windows.ico differ diff --git a/electron/desktopWindow.js b/electron/desktopWindow.js index 8dcef0e8..4e66c068 100644 --- a/electron/desktopWindow.js +++ b/electron/desktopWindow.js @@ -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); }); } diff --git a/electron/main.js b/electron/main.js index 665b8c96..f2808faf 100644 --- a/electron/main.js +++ b/electron/main.js @@ -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; diff --git a/electron/scripts/generate-windows-icon.js b/electron/scripts/generate-windows-icon.js new file mode 100644 index 00000000..7d6ebdef --- /dev/null +++ b/electron/scripts/generate-windows-icon.js @@ -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}`); diff --git a/package.json b/package.json index 2d995d68..6ce8266b 100644 --- a/package.json +++ b/package.json @@ -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": [ diff --git a/scripts/release/prepare-desktop-app.js b/scripts/release/prepare-desktop-app.js index ebf0f3d0..50edf895 100644 --- a/scripts/release/prepare-desktop-app.js +++ b/scripts/release/prepare-desktop-app.js @@ -101,6 +101,7 @@ function buildDesktopPackageJson(copiedOptionalDependencies) { protocols: packageJson.build.protocols, mac: packageJson.build.mac, win: packageJson.build.win, + nsis: packageJson.build.nsis, }, }; } diff --git a/src/components/chat/hooks/useChatComposerState.ts b/src/components/chat/hooks/useChatComposerState.ts index c1f86f2d..443313bf 100644 --- a/src/components/chat/hooks/useChatComposerState.ts +++ b/src/components/chat/hooks/useChatComposerState.ts @@ -204,6 +204,8 @@ export function useChatComposerState({ const textareaRef = useRef(null); const inputHighlightRef = useRef(null); + const textareaLineHeightRef = useRef(null); + const lastAutosizedInputRef = useRef(null); const handleSubmitRef = useRef< ((event: FormEvent | MouseEvent | TouchEvent | KeyboardEvent) => Promise) | 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) => { 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(() => { diff --git a/src/components/chat/view/ChatInterface.tsx b/src/components/chat/view/ChatInterface.tsx index 5efe6af4..7a86b4e6 100644 --- a/src/components/chat/view/ChatInterface.tsx +++ b/src/components/chat/view/ChatInterface.tsx @@ -309,7 +309,7 @@ function ChatInterface({ return ( -
+
{ + 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 ( -
+
{!hasPendingPermissions && ( )} diff --git a/src/components/chat/view/subcomponents/ChatMessagesPane.tsx b/src/components/chat/view/subcomponents/ChatMessagesPane.tsx index d97c944f..bb61096a 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, 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 ? (
@@ -308,3 +308,5 @@ export default function ChatMessagesPane({
); } + +export default memo(ChatMessagesPane); diff --git a/src/index.css b/src/index.css index 06028a03..f99ce614 100644 --- a/src/index.css +++ b/src/index.css @@ -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) { * {