From 5269012c5170425f776edc1a006cc7ffbbc64c25 Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Tue, 5 May 2026 13:17:40 -0500 Subject: [PATCH] feat: file tabs --- apps/desktop/electron/main.cjs | 71 +++++++- apps/desktop/package.json | 4 +- apps/desktop/src/app/chat/composer/index.tsx | 8 +- .../src/app/chat/right-rail/preview-pane.tsx | 56 ++++-- .../src/app/chat/right-rail/preview.tsx | 166 ++++++++++++++++-- apps/desktop/src/app/desktop-controller.tsx | 88 ++++++---- apps/desktop/src/app/file-browser/index.tsx | 2 + .../src/app/file-browser/use-project-tree.ts | 1 + .../app/session/hooks/use-message-stream.ts | 129 +++++++++++++- .../app/session/hooks/use-model-controls.ts | 1 + .../app/session/hooks/use-preview-routing.ts | 2 + .../src/app/session/hooks/use-route-resume.ts | 1 + .../session/hooks/use-session-state-cache.ts | 54 +++++- .../src/app/shell/titlebar-controls.tsx | 2 +- .../components/assistant-ui/markdown-text.tsx | 17 +- .../assistant-ui/shiki-highlighter.tsx | 17 +- .../src/components/assistant-ui/thread.tsx | 95 ++++++++-- .../src/components/pane-shell/pane-shell.tsx | 38 ++-- apps/desktop/src/lib/chat-messages.ts | 9 +- apps/desktop/src/lib/gateway-events.ts | 1 + apps/desktop/src/store/layout.ts | 11 +- apps/desktop/src/store/panes.ts | 5 + apps/desktop/src/store/preview.test.ts | 6 +- apps/desktop/src/store/preview.ts | 99 +++++++++-- apps/desktop/src/store/tool-view.ts | 2 + apps/desktop/src/themes/use-skin-command.ts | 1 + apps/desktop/vite.config.ts | 10 ++ 27 files changed, 763 insertions(+), 133 deletions(-) diff --git a/apps/desktop/electron/main.cjs b/apps/desktop/electron/main.cjs index f616972ab9b..dfb603a9ba6 100644 --- a/apps/desktop/electron/main.cjs +++ b/apps/desktop/electron/main.cjs @@ -32,6 +32,8 @@ const BUNDLED_HERMES_ROOT = path.join(process.resourcesPath, 'hermes-agent') const BUNDLED_VENV_ROOT = path.join(app.getPath('userData'), 'hermes-runtime') const BUNDLED_VENV_MARKER = path.join(BUNDLED_VENV_ROOT, '.hermes-desktop-runtime.json') const DESKTOP_LOG_PATH = path.join(app.getPath('userData'), 'desktop.log') +const DESKTOP_LOG_FLUSH_MS = 120 +const DESKTOP_LOG_BUFFER_MAX_CHARS = 64 * 1024 const RUNTIME_SCHEMA_VERSION = 3 const BUNDLED_RUNTIME_REQUIREMENTS = [ 'openai>=2.21.0,<3', @@ -186,6 +188,47 @@ let connectionPromise = null const hermesLog = [] const previewWatchers = new Map() let previewShortcutActive = false +let desktopLogBuffer = '' +let desktopLogFlushTimer = null +let desktopLogFlushPromise = Promise.resolve() + +function flushDesktopLogBufferSync() { + if (!desktopLogBuffer) return + const chunk = desktopLogBuffer + desktopLogBuffer = '' + + try { + fs.mkdirSync(path.dirname(DESKTOP_LOG_PATH), { recursive: true }) + fs.appendFileSync(DESKTOP_LOG_PATH, chunk) + } catch { + // Logging must never block app startup/shutdown. + } +} + +function flushDesktopLogBufferAsync() { + if (!desktopLogBuffer) return desktopLogFlushPromise + const chunk = desktopLogBuffer + desktopLogBuffer = '' + + desktopLogFlushPromise = desktopLogFlushPromise + .then(async () => { + await fs.promises.mkdir(path.dirname(DESKTOP_LOG_PATH), { recursive: true }) + await fs.promises.appendFile(DESKTOP_LOG_PATH, chunk) + }) + .catch(() => { + // Logging must never crash the desktop shell. + }) + + return desktopLogFlushPromise +} + +function scheduleDesktopLogFlush() { + if (desktopLogFlushTimer) return + desktopLogFlushTimer = setTimeout(() => { + desktopLogFlushTimer = null + void flushDesktopLogBufferAsync() + }, DESKTOP_LOG_FLUSH_MS) +} function rememberLog(chunk) { const text = String(chunk || '').trim() @@ -196,12 +239,19 @@ function rememberLog(chunk) { hermesLog.splice(0, hermesLog.length - 300) } - try { - fs.mkdirSync(path.dirname(DESKTOP_LOG_PATH), { recursive: true }) - fs.appendFileSync(DESKTOP_LOG_PATH, `${lines.join('\n')}\n`) - } catch { - // Logging must never block app startup. + desktopLogBuffer += `${lines.join('\n')}\n` + + if (desktopLogBuffer.length >= DESKTOP_LOG_BUFFER_MAX_CHARS) { + if (desktopLogFlushTimer) { + clearTimeout(desktopLogFlushTimer) + desktopLogFlushTimer = null + } + void flushDesktopLogBufferAsync() + + return } + + scheduleDesktopLogFlush() } function fileExists(filePath) { @@ -1156,6 +1206,7 @@ function createWindow() { preload: path.join(__dirname, 'preload.cjs'), contextIsolation: true, webviewTag: true, + sandbox: true, nodeIntegration: false, devTools: Boolean(DEV_SERVER) } @@ -1177,6 +1228,10 @@ function createWindow() { } else { mainWindow.loadURL(pathToFileURL(resolveRendererIndex()).toString()) } + + mainWindow.webContents.once('did-finish-load', () => { + startHermes().catch(error => rememberLog(error.stack || error.message)) + }) } ipcMain.handle('hermes:connection', async () => startHermes()) @@ -1461,7 +1516,6 @@ app.whenReady().then(() => { Menu.setApplicationMenu(buildApplicationMenu()) installMediaPermissions() createWindow() - startHermes().catch(error => rememberLog(error.stack || error.message)) app.on('activate', () => { if (BrowserWindow.getAllWindows().length === 0) createWindow() @@ -1469,6 +1523,11 @@ app.whenReady().then(() => { }) app.on('before-quit', () => { + if (desktopLogFlushTimer) { + clearTimeout(desktopLogFlushTimer) + desktopLogFlushTimer = null + } + flushDesktopLogBufferSync() closePreviewWatchers() if (hermesProcess && !hermesProcess.killed) { diff --git a/apps/desktop/package.json b/apps/desktop/package.json index 401bf570074..544738f8843 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -11,6 +11,8 @@ "dev": "concurrently -k \"npm:dev:renderer\" \"npm:dev:electron\"", "dev:renderer": "vite --host 127.0.0.1 --port 5174", "dev:electron": "wait-on http://127.0.0.1:5174 && cross-env HERMES_DESKTOP_DEV_SERVER=http://127.0.0.1:5174 electron .", + "profile:main": "wait-on http://127.0.0.1:5174 && cross-env HERMES_DESKTOP_DEV_SERVER=http://127.0.0.1:5174 electron --inspect=9229 .", + "profile:main:cpu": "wait-on http://127.0.0.1:5174 && cross-env NODE_OPTIONS=--cpu-prof HERMES_DESKTOP_DEV_SERVER=http://127.0.0.1:5174 electron .", "start": "npm run build && electron .", "build": "tsc -b && vite build", "stage:hermes": "node scripts/stage-hermes-payload.mjs", @@ -121,7 +123,7 @@ "asar": true, "afterSign": "scripts/notarize.cjs", "asarUnpack": [ - "dist/**" + "**/*.node" ], "mac": { "category": "public.app-category.developer-tools", diff --git a/apps/desktop/src/app/chat/composer/index.tsx b/apps/desktop/src/app/chat/composer/index.tsx index 7d6d245b26c..1a037bf403f 100644 --- a/apps/desktop/src/app/chat/composer/index.tsx +++ b/apps/desktop/src/app/chat/composer/index.tsx @@ -38,7 +38,13 @@ import { useComposerGlassTweaks } from './hooks/use-composer-glass-tweaks' import { useSlashCompletions } from './hooks/use-slash-completions' import { useVoiceConversation } from './hooks/use-voice-conversation' import { useVoiceRecorder } from './hooks/use-voice-recorder' -import { composerPlainText, placeCaretEnd, refChipElement, renderComposerContents, RICH_INPUT_SLOT } from './rich-editor' +import { + composerPlainText, + placeCaretEnd, + refChipElement, + renderComposerContents, + RICH_INPUT_SLOT +} from './rich-editor' import { SkinSlashPopover } from './skin-slash-popover' import { ComposerTriggerPopover } from './trigger-popover' import type { ChatBarProps } from './types' diff --git a/apps/desktop/src/app/chat/right-rail/preview-pane.tsx b/apps/desktop/src/app/chat/right-rail/preview-pane.tsx index 95ee48a8e78..14962487a6c 100644 --- a/apps/desktop/src/app/chat/right-rail/preview-pane.tsx +++ b/apps/desktop/src/app/chat/right-rail/preview-pane.tsx @@ -36,6 +36,7 @@ type PreviewWebview = HTMLElement & { } interface PreviewPaneProps { + embedded?: boolean onClose: () => void onRestartServer?: (url: string, context?: string) => Promise reloadRequest?: number @@ -359,15 +360,30 @@ function PreviewConsolePanel({ const selectedLogIds = useStore(consoleState.$selectedLogIds) const visibleSelection = useMemo(() => logs.filter(log => selectedLogIds.has(log.id)), [logs, selectedLogIds]) const sendableLogs = visibleSelection.length > 0 ? visibleSelection : logs + const stickScrollRafRef = useRef(null) useEffect(() => { if (!consoleShouldStickRef.current) { return } - const consoleBody = consoleBodyRef.current + if (stickScrollRafRef.current !== null) { + window.cancelAnimationFrame(stickScrollRafRef.current) + stickScrollRafRef.current = null + } - consoleBody?.scrollTo({ top: consoleBody.scrollHeight }) + stickScrollRafRef.current = window.requestAnimationFrame(() => { + stickScrollRafRef.current = null + const consoleBody = consoleBodyRef.current + consoleBody?.scrollTo({ top: consoleBody.scrollHeight }) + }) + + return () => { + if (stickScrollRafRef.current !== null) { + window.cancelAnimationFrame(stickScrollRafRef.current) + stickScrollRafRef.current = null + } + } }, [consoleBodyRef, consoleHeight, consoleShouldStickRef, logs]) function sendLogsToComposer(entries: ConsoleEntry[]) { @@ -917,6 +933,7 @@ function LocalFilePreview({ reloadKey, target }: { reloadKey: number; target: Pr const TITLEBAR_GROUP_ID = 'preview' export function PreviewPane({ + embedded = false, onClose, onRestartServer, reloadRequest = 0, @@ -1136,9 +1153,12 @@ export function PreviewPane({ consoleShouldStickRef.current = true - const consoleBody = consoleBodyRef.current + const handle = window.requestAnimationFrame(() => { + const consoleBody = consoleBodyRef.current + consoleBody?.scrollTo({ top: consoleBody.scrollHeight }) + }) - consoleBody?.scrollTo({ top: consoleBody.scrollHeight }) + return () => window.cancelAnimationFrame(handle) }, [consoleOpen]) useEffect(() => { @@ -1423,21 +1443,23 @@ export function PreviewPane({ }, [appendConsoleEntry, consoleState, isWebPreview, target.url]) return ( -