diff --git a/apps/desktop/electron/main.cjs b/apps/desktop/electron/main.cjs index 2ea13de79e9..928c4cee176 100644 --- a/apps/desktop/electron/main.cjs +++ b/apps/desktop/electron/main.cjs @@ -94,6 +94,85 @@ const MEDIA_MIME_TYPES = { const PREVIEW_HTML_EXTENSIONS = new Set(['.html', '.htm']) const PREVIEW_WATCH_DEBOUNCE_MS = 120 const LOCAL_PREVIEW_HOSTS = new Set(['0.0.0.0', '127.0.0.1', '::1', '[::1]', 'localhost']) +const TEXT_PREVIEW_MAX_BYTES = 512 * 1024 +const PREVIEW_LANGUAGE_BY_EXT = { + '.c': 'c', + '.conf': 'ini', + '.cpp': 'cpp', + '.css': 'css', + '.csv': 'csv', + '.go': 'go', + '.graphql': 'graphql', + '.h': 'c', + '.hpp': 'cpp', + '.html': 'html', + '.java': 'java', + '.js': 'javascript', + '.json': 'json', + '.jsx': 'jsx', + '.kt': 'kotlin', + '.lua': 'lua', + '.md': 'markdown', + '.mjs': 'javascript', + '.py': 'python', + '.rb': 'ruby', + '.rs': 'rust', + '.sh': 'shell', + '.sql': 'sql', + '.svg': 'xml', + '.toml': 'toml', + '.ts': 'typescript', + '.tsx': 'tsx', + '.txt': 'text', + '.xml': 'xml', + '.yaml': 'yaml', + '.yml': 'yaml', + '.zsh': 'shell' +} + +function looksBinary(buffer) { + if (!buffer.length) return false + + let suspicious = 0 + + for (const byte of buffer) { + if (byte === 0) return true + // Allow common whitespace controls: tab, LF, CR. + if (byte < 32 && byte !== 9 && byte !== 10 && byte !== 13) suspicious += 1 + } + + return suspicious / buffer.length > 0.12 +} + +function previewFileMetadata(filePath, mimeType) { + let byteSize = 0 + let binary = false + + try { + const stat = fs.statSync(filePath) + byteSize = stat.size + + if (!mimeType.startsWith('image/')) { + const fd = fs.openSync(filePath, 'r') + + try { + const sample = Buffer.alloc(Math.min(byteSize, 4096)) + const bytesRead = fs.readSync(fd, sample, 0, sample.length, 0) + binary = looksBinary(sample.subarray(0, bytesRead)) + } finally { + fs.closeSync(fd) + } + } + } catch { + // Metadata is best-effort; the read handlers surface hard errors later. + } + + return { + binary, + byteSize, + large: byteSize > TEXT_PREVIEW_MAX_BYTES + } +} app.setName(APP_NAME) app.setAboutPanelOptions({ @@ -106,6 +185,7 @@ let hermesProcess = null let connectionPromise = null const hermesLog = [] const previewWatchers = new Map() +let previewShortcutActive = false function rememberLog(chunk) { const text = String(chunk || '').trim() @@ -617,13 +697,26 @@ function previewFileTarget(rawTarget, baseDir) { } const ext = path.extname(resolved).toLowerCase() - if (!PREVIEW_HTML_EXTENSIONS.has(ext) || !fileExists(resolved)) { + if (!fileExists(resolved)) { return null } + const mimeType = mimeTypeForPath(resolved) + const metadata = previewFileMetadata(resolved, mimeType) + const isHtml = PREVIEW_HTML_EXTENSIONS.has(ext) + const isImage = mimeType.startsWith('image/') + const previewKind = isHtml ? 'html' : isImage ? 'image' : metadata.binary ? 'binary' : 'text' + return { + binary: metadata.binary, + byteSize: metadata.byteSize, kind: 'file', + large: metadata.large, label: path.basename(resolved), + language: PREVIEW_LANGUAGE_BY_EXT[ext] || 'text', + mimeType, + path: resolved, + previewKind, source: raw, url: pathToFileURL(resolved).toString() } @@ -671,12 +764,11 @@ function normalizePreviewTarget(rawTarget, baseDir) { } } -function previewFilePathFromUrl(rawUrl) { +function filePathFromPreviewUrl(rawUrl) { const filePath = fileURLToPath(String(rawUrl || '')) - const ext = path.extname(filePath).toLowerCase() - if (!PREVIEW_HTML_EXTENSIONS.has(ext) || !fileExists(filePath)) { - throw new Error('Preview file is not a readable HTML file') + if (!fileExists(filePath)) { + throw new Error('Preview file is not readable') } return filePath @@ -690,7 +782,7 @@ function sendPreviewFileChanged(payload) { } function watchPreviewFile(rawUrl) { - const filePath = previewFilePathFromUrl(rawUrl) + const filePath = filePathFromPreviewUrl(rawUrl) const watchDir = path.dirname(filePath) const targetName = path.basename(filePath) const id = crypto.randomBytes(12).toString('base64url') @@ -768,6 +860,13 @@ function sendBackendExit(payload) { webContents.send('hermes:backend-exit', payload) } +function sendClosePreviewRequested() { + if (!mainWindow || mainWindow.isDestroyed()) return + const { webContents } = mainWindow + if (!webContents || webContents.isDestroyed()) return + webContents.send('hermes:close-preview-requested') +} + function getAppIconPath() { return APP_ICON_PATHS.find(fileExists) } @@ -793,7 +892,21 @@ function buildApplicationMenu() { template.push({ label: 'File', - submenu: [IS_MAC ? { role: 'close' } : { role: 'quit' }] + submenu: [ + IS_MAC + ? { + accelerator: 'CommandOrControl+W', + click: () => { + if (previewShortcutActive) { + sendClosePreviewRequested() + } else { + mainWindow?.close() + } + }, + label: 'Close' + } + : { role: 'quit' } + ] }) template.push({ label: 'Edit', @@ -856,6 +969,22 @@ function installDevToolsShortcut(window) { }) } +function installPreviewShortcut(window) { + window.webContents.on('before-input-event', (event, input) => { + const key = String(input.key || '').toLowerCase() + const isPreviewCloseShortcut = + key === 'w' && + (IS_MAC ? input.meta : input.control) && + !input.alt && + !input.shift + + if (!isPreviewCloseShortcut || !previewShortcutActive) return + + event.preventDefault() + sendClosePreviewRequested() + }) +} + function installContextMenu(window) { window.webContents.on('context-menu', (_event, params) => { const template = [] @@ -1043,6 +1172,7 @@ function createWindow() { } } + installPreviewShortcut(mainWindow) installDevToolsShortcut(mainWindow) installContextMenu(mainWindow) @@ -1055,6 +1185,10 @@ function createWindow() { ipcMain.handle('hermes:connection', async () => startHermes()) +ipcMain.on('hermes:previewShortcutActive', (_event, active) => { + previewShortcutActive = Boolean(active) +}) + ipcMain.handle('hermes:requestMicrophoneAccess', async () => { if (!IS_MAC || typeof systemPreferences.askForMediaAccess !== 'function') { return true @@ -1082,11 +1216,38 @@ ipcMain.handle('hermes:notify', (_event, payload) => { }) ipcMain.handle('hermes:readFileDataUrl', async (_event, filePath) => { - const resolved = path.resolve(String(filePath || '')) + const input = String(filePath || '') + const resolved = input.startsWith('file:') ? fileURLToPath(input) : path.resolve(input) const data = await fs.promises.readFile(resolved) return `data:${mimeTypeForPath(resolved)};base64,${data.toString('base64')}` }) +ipcMain.handle('hermes:readFileText', async (_event, filePath) => { + const input = String(filePath || '') + const resolved = input.startsWith('file:') ? fileURLToPath(input) : path.resolve(input) + const ext = path.extname(resolved).toLowerCase() + const stat = await fs.promises.stat(resolved) + const handle = await fs.promises.open(resolved, 'r') + const bytesToRead = Math.min(stat.size, TEXT_PREVIEW_MAX_BYTES) + + try { + const buffer = Buffer.alloc(bytesToRead) + const { bytesRead } = await handle.read(buffer, 0, bytesToRead, 0) + + return { + binary: looksBinary(buffer.subarray(0, Math.min(bytesRead, 4096))), + byteSize: stat.size, + language: PREVIEW_LANGUAGE_BY_EXT[ext] || 'text', + mimeType: mimeTypeForPath(resolved), + path: resolved, + text: buffer.subarray(0, bytesRead).toString('utf8'), + truncated: stat.size > TEXT_PREVIEW_MAX_BYTES + } + } finally { + await handle.close() + } +}) + ipcMain.handle('hermes:selectPaths', async (_event, options = {}) => { const properties = ['openFile'] if (options?.directories) properties.push('openDirectory') @@ -1137,6 +1298,151 @@ ipcMain.handle('hermes:stopPreviewFileWatch', (_event, id) => stopPreviewFileWat ipcMain.handle('hermes:openExternal', (_event, url) => shell.openExternal(url)) +// Always-hidden noise (covers non-git projects too — gitignore would catch +// these anyway when present, but we want the same hygiene without one). +const FS_READDIR_HIDDEN = new Set(['.git', '.hg', '.svn', 'node_modules', '__pycache__', '.next', '.venv', 'venv']) + +const ignore = require('ignore') + +// Cache one Ignore instance per .gitignore path keyed by mtime so edits +// invalidate automatically without us having to wire a watcher. +const gitignoreCache = new Map() // gitignorePath → { mtime: number, ig: Ignore, base: string } + +function findGitRoot(start) { + let dir = start + + for (let i = 0; i < 50; i += 1) { + try { + if (fs.existsSync(path.join(dir, '.git'))) {return dir} + } catch { + return null + } + + const parent = path.dirname(dir) + + if (parent === dir) {return null} + + dir = parent + } + + return null +} + +function getGitignoreFile(giPath) { + let stat = null + + try { + stat = fs.statSync(giPath) + } catch { + return null + } + + if (!stat.isFile()) {return null} + + const cached = gitignoreCache.get(giPath) + + if (cached && cached.mtime === stat.mtimeMs) {return cached} + + try { + const entry = { + base: path.dirname(giPath), + ig: ignore().add(fs.readFileSync(giPath, 'utf8')), + mtime: stat.mtimeMs + } + + gitignoreCache.set(giPath, entry) + + return entry + } catch { + return null + } +} + +function gitignoreRulesFor(root, dir) { + const rules = [] + const rel = path.relative(root, dir) + const dirs = [root] + + if (rel && !rel.startsWith('..') && !path.isAbsolute(rel)) { + const parts = rel.split(path.sep).filter(Boolean) + let current = root + + for (const part of parts) { + current = path.join(current, part) + dirs.push(current) + } + } + + for (const ruleDir of dirs) { + const rule = getGitignoreFile(path.join(ruleDir, '.gitignore')) + + if (rule) {rules.push(rule)} + } + + return rules +} + +function ignoredByRules(rules, abs, isDirectory) { + for (const rule of rules) { + const rel = path.relative(rule.base, abs) + + if (!rel || rel.startsWith('..') || path.isAbsolute(rel)) {continue} + + const probe = `${rel.split(path.sep).join('/')}${isDirectory ? '/' : ''}` + + if (rule.ig.ignores(probe)) {return true} + } + + return false +} + +ipcMain.handle('hermes:fs:readDir', async (_event, dirPath) => { + const resolved = path.resolve(String(dirPath || '')) + + if (!resolved) { + return { entries: [], error: 'invalid-path' } + } + + try { + const dirents = await fs.promises.readdir(resolved, { withFileTypes: true }) + const root = findGitRoot(resolved) + const gitignoreRules = root ? gitignoreRulesFor(root, resolved) : [] + + const entries = dirents + .filter(d => { + if (FS_READDIR_HIDDEN.has(d.name)) {return false} + + if (gitignoreRules.length > 0) { + const abs = path.join(resolved, d.name) + + if (ignoredByRules(gitignoreRules, abs, d.isDirectory())) {return false} + } + + return true + }) + .map(d => ({ name: d.name, path: path.join(resolved, d.name), isDirectory: d.isDirectory() })) + .sort((a, b) => Number(b.isDirectory) - Number(a.isDirectory) || a.name.localeCompare(b.name)) + + return { entries } + } catch (error) { + return { entries: [], error: error?.code || 'read-error' } + } +}) + +ipcMain.handle('hermes:fs:gitRoot', async (_event, startPath) => { + const input = String(startPath || '') + const resolved = input.startsWith('file:') ? fileURLToPath(input) : path.resolve(input) + + try { + const stat = await fs.promises.stat(resolved) + const start = stat.isDirectory() ? resolved : path.dirname(resolved) + + return findGitRoot(start) + } catch { + return findGitRoot(resolved) + } +}) + app.whenReady().then(() => { Menu.setApplicationMenu(buildApplicationMenu()) installMediaPermissions() diff --git a/apps/desktop/electron/preload.cjs b/apps/desktop/electron/preload.cjs index 90e1e313876..20621c1d06e 100644 --- a/apps/desktop/electron/preload.cjs +++ b/apps/desktop/electron/preload.cjs @@ -6,6 +6,7 @@ contextBridge.exposeInMainWorld('hermesDesktop', { notify: payload => ipcRenderer.invoke('hermes:notify', payload), requestMicrophoneAccess: () => ipcRenderer.invoke('hermes:requestMicrophoneAccess'), readFileDataUrl: filePath => ipcRenderer.invoke('hermes:readFileDataUrl', filePath), + readFileText: filePath => ipcRenderer.invoke('hermes:readFileText', filePath), selectPaths: options => ipcRenderer.invoke('hermes:selectPaths', options), writeClipboard: text => ipcRenderer.invoke('hermes:writeClipboard', text), saveImageFromUrl: url => ipcRenderer.invoke('hermes:saveImageFromUrl', url), @@ -21,7 +22,15 @@ contextBridge.exposeInMainWorld('hermesDesktop', { normalizePreviewTarget: (target, baseDir) => ipcRenderer.invoke('hermes:normalizePreviewTarget', target, baseDir), watchPreviewFile: url => ipcRenderer.invoke('hermes:watchPreviewFile', url), stopPreviewFileWatch: id => ipcRenderer.invoke('hermes:stopPreviewFileWatch', id), + setPreviewShortcutActive: active => ipcRenderer.send('hermes:previewShortcutActive', Boolean(active)), openExternal: url => ipcRenderer.invoke('hermes:openExternal', url), + readDir: dirPath => ipcRenderer.invoke('hermes:fs:readDir', dirPath), + gitRoot: startPath => ipcRenderer.invoke('hermes:fs:gitRoot', startPath), + onClosePreviewRequested: callback => { + const listener = () => callback() + ipcRenderer.on('hermes:close-preview-requested', listener) + return () => ipcRenderer.removeListener('hermes:close-preview-requested', listener) + }, onPreviewFileChanged: callback => { const listener = (_event, payload) => callback(payload) ipcRenderer.on('hermes:preview-file-changed', listener) diff --git a/apps/desktop/package.json b/apps/desktop/package.json index 26fa3380b7d..59ffc3ff87f 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -50,15 +50,18 @@ "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "cmdk": "^1.1.1", + "ignore": "^7.0.5", "liquid-glass-react": "^1.1.1", "lucide-react": "^0.577.0", "nanostores": "^1.3.0", "radix-ui": "^1.4.3", "react": "^19.2.5", + "react-arborist": "^3.5.0", "react-dom": "^19.2.5", "react-router-dom": "^7.14.2", "react-shiki": "^0.9.3", "shiki": "^4.0.2", + "streamdown": "^2.5.0", "tailwind-merge": "^3.5.0", "tailwindcss": "^4.2.4", "tw-shimmer": "^0.4.11", diff --git a/apps/desktop/preview-demo.html b/apps/desktop/preview-demo.html new file mode 100644 index 00000000000..33227103ba8 --- /dev/null +++ b/apps/desktop/preview-demo.html @@ -0,0 +1,65 @@ + + + + + +Preview Demo + + + +
+

preview-demo.html

+

Tiny standalone HTML artifact — no server, no build step.

+

Open directly in a browser via file://.

+

+
+ + + diff --git a/apps/desktop/src/app/agents/index.tsx b/apps/desktop/src/app/agents/index.tsx new file mode 100644 index 00000000000..2c8d58315aa --- /dev/null +++ b/apps/desktop/src/app/agents/index.tsx @@ -0,0 +1,134 @@ +import { useStore } from '@nanostores/react' +import { useMemo, useState } from 'react' + +import { Activity, AlertCircle, Layers3, Loader2, type LucideIcon, RefreshCw, Sparkles } from '@/lib/icons' +import { cn } from '@/lib/utils' +import { $desktopActionTasks, buildRailTasks, type RailTask, type RailTaskStatus } from '@/store/activity' +import { $previewServerRestart } from '@/store/preview' +import { $sessions, $workingSessionIds } from '@/store/session' + +import { OverlayCard } from '../overlays/overlay-chrome' +import { OverlayMain, OverlayNavItem, OverlaySidebar, OverlaySplitLayout } from '../overlays/overlay-split-layout' +import { OverlayView } from '../overlays/overlay-view' + +type AgentsSection = 'tree' | 'activity' | 'history' + +interface SectionDef { + description: string + icon: LucideIcon + id: AgentsSection + label: string +} + +const SECTIONS: readonly SectionDef[] = [ + { description: 'Live subagent spawn tree for the current turn', icon: Layers3, id: 'tree', label: 'Spawn tree' }, + { description: 'Background work across sessions and the desktop', icon: Activity, id: 'activity', label: 'Activity' }, + { description: 'Past spawn snapshots, replay, and diff', icon: RefreshCw, id: 'history', label: 'History' } +] + +const STATUS_TONE: Record = { + error: 'text-destructive', + running: 'text-foreground', + success: 'text-emerald-500' +} + +const STATUS_ICON: Record = { + error: AlertCircle, + running: Loader2, + success: Sparkles +} + +interface AgentsViewProps { + initialSection?: AgentsSection + onClose: () => void +} + +export function AgentsView({ initialSection = 'tree', onClose }: AgentsViewProps) { + const [section, setSection] = useState(initialSection) + + const sessions = useStore($sessions) + const workingSessionIds = useStore($workingSessionIds) + const previewRestart = useStore($previewServerRestart) + const desktopActionTasks = useStore($desktopActionTasks) + + const activityTasks = useMemo( + () => buildRailTasks(workingSessionIds, sessions, previewRestart, desktopActionTasks), + [desktopActionTasks, previewRestart, sessions, workingSessionIds] + ) + + const active = SECTIONS.find(s => s.id === section) ?? SECTIONS[0]! + + return ( + + + + {SECTIONS.map(s => ( + setSection(s.id)} + /> + ))} + + + +
+

{active.label}

+

{active.description}

+
+ + {section === 'activity' ? : } +
+
+
+ ) +} + +function ActivityList({ tasks }: { tasks: readonly RailTask[] }) { + if (tasks.length === 0) { + return ( + + No background activity. Long-running tools, preview restarts, and parallel sessions surface here. + + ) + } + + return ( +
+ {tasks.map(task => { + const Icon = STATUS_ICON[task.status] + + return ( + + +
+
{task.label}
+ {task.detail &&
{task.detail}
} +
+
+ ) + })} +
+ ) +} + +function SectionStub({ label }: { label: string }) { + return ( + + +
+

{label} — coming soon

+

+ Subagent stores aren't wired into the desktop yet. Once gateway events for{' '} + subagent.spawn / progress / complete{' '} + land here, this view shows the live spawn tree, replay history, and pause/kill controls — modelled on the TUI's{' '} + /agents overlay. +

+
+
+ ) +} diff --git a/apps/desktop/src/app/artifacts/index.tsx b/apps/desktop/src/app/artifacts/index.tsx index 816028a3370..177f29e72de 100644 --- a/apps/desktop/src/app/artifacts/index.tsx +++ b/apps/desktop/src/app/artifacts/index.tsx @@ -5,6 +5,7 @@ import { useNavigate } from 'react-router-dom' import { ZoomableImage } from '@/components/assistant-ui/zoomable-image' import { PageLoader } from '@/components/page-loader' import { Button } from '@/components/ui/button' +import { CopyButton } from '@/components/ui/copy-button' import { Input } from '@/components/ui/input' import { Pagination, @@ -17,9 +18,9 @@ import { } from '@/components/ui/pagination' import { getSessionMessages, listSessions } from '@/hermes' import { sessionTitle } from '@/lib/chat-runtime' -import { Copy, ExternalLink, FileImage, FileText, FolderOpen, Layers3, Link2, RefreshCw, Search, X } from '@/lib/icons' +import { ExternalLink, FileImage, FileText, FolderOpen, Layers3, Link2, RefreshCw, Search, X } from '@/lib/icons' import { cn } from '@/lib/utils' -import { notify, notifyError } from '@/store/notifications' +import { notifyError } from '@/store/notifications' import type { SessionInfo, SessionMessage } from '@/types/hermes' import { sessionRoute } from '../routes' @@ -346,7 +347,11 @@ interface ArtifactsViewProps extends React.ComponentProps<'section'> { setTitlebarToolGroup?: SetTitlebarToolGroup } -export function ArtifactsView({ setStatusbarItemGroup: _setStatusbarItemGroup, setTitlebarToolGroup, ...props }: ArtifactsViewProps) { +export function ArtifactsView({ + setStatusbarItemGroup: _setStatusbarItemGroup, + setTitlebarToolGroup, + ...props +}: ArtifactsViewProps) { const navigate = useNavigate() const [artifacts, setArtifacts] = useState(null) const [query, setQuery] = useState('') @@ -469,24 +474,6 @@ export function ArtifactsView({ setStatusbarItemGroup: _setStatusbarItemGroup, s } }, [artifacts]) - const copyArtifact = useCallback(async (value: string) => { - try { - if (window.hermesDesktop?.writeClipboard) { - await window.hermesDesktop.writeClipboard(value) - } else if (navigator.clipboard?.writeText) { - await navigator.clipboard.writeText(value) - } - - notify({ - kind: 'success', - title: 'Copied', - message: value - }) - } catch (err) { - notifyError(err, 'Copy failed') - } - }, []) - const openArtifact = useCallback(async (href: string) => { try { if (window.hermesDesktop?.openExternal) { @@ -510,10 +497,7 @@ export function ArtifactsView({ setStatusbarItemGroup: _setStatusbarItemGroup, s }, []) return ( -
+

Artifacts

{counts.all} found @@ -645,7 +629,6 @@ export function ArtifactsView({ setStatusbarItemGroup: _setStatusbarItemGroup, s navigate(sessionRoute(sessionId))} /> @@ -804,12 +787,11 @@ function ArtifactImageCard({ artifact, failedImage, onImageError, onOpenChat }: interface ArtifactListRowProps { artifact: ArtifactRecord - onCopy: (value: string) => void | Promise onOpen: (href: string) => void | Promise onOpenChat: (sessionId: string) => void } -function ArtifactListRow({ artifact, onCopy, onOpen, onOpenChat }: ArtifactListRowProps) { +function ArtifactListRow({ artifact, onOpen, onOpenChat }: ArtifactListRowProps) { const Icon = artifact.kind === 'file' ? FileText : Link2 return ( @@ -852,16 +834,14 @@ function ArtifactListRow({ artifact, onCopy, onOpen, onOpenChat }: ArtifactListR > - + iconClassName="size-3.5" + label="Copy" + text={artifact.value} + /> {onRemove && ( + ) + }) + )} + + ) +} diff --git a/apps/desktop/src/app/chat/hooks/use-composer-actions.ts b/apps/desktop/src/app/chat/hooks/use-composer-actions.ts index 11aa9ae1924..a0346c9fea5 100644 --- a/apps/desktop/src/app/chat/hooks/use-composer-actions.ts +++ b/apps/desktop/src/app/chat/hooks/use-composer-actions.ts @@ -31,12 +31,22 @@ function isImagePath(filePath: string): boolean { } export interface DroppedFile { - file: File + /** Browser-native File handle. Absent for in-app drags (e.g. project tree). */ + file?: File + /** Absolute filesystem path. Empty when an OS drop didn't carry one. */ path: string + /** True if the entry is a directory. Currently only set by in-app drags. */ + isDirectory?: boolean } +/** MIME emitted by in-app drag sources (project tree, etc.). Payload is JSON + * `{ path: string; isDirectory?: boolean }[]`. */ +export const HERMES_PATHS_MIME = 'application/x-hermes-paths' + /** - * Eagerly resolve files from a drop event into [File, path] pairs. + * Eagerly resolve files from a drop event into [File?, path, isDirectory?] + * triples. Internal Hermes sources (e.g. the project tree) ride on a custom + * MIME and produce path-only entries; OS drops produce File-bearing entries. * * Must be called synchronously from inside the drop handler — `DataTransfer` * items are detached as soon as the handler returns, and `webUtils.getPathForFile` @@ -44,19 +54,42 @@ export interface DroppedFile { */ export function extractDroppedFiles(transfer: DataTransfer): DroppedFile[] { const result: DroppedFile[] = [] - const seen = new Set() + const seenPaths = new Set() + const seenFiles = new Set() const getPath = window.hermesDesktop?.getPathForFile + // In-app drags first — they carry richer metadata (isDirectory) than the + // File-based fallback can provide, and produce no overlapping native files. + try { + const internalRaw = transfer.getData(HERMES_PATHS_MIME) + + if (internalRaw) { + const parsed = JSON.parse(internalRaw) as { path?: unknown; isDirectory?: unknown }[] + + for (const entry of parsed) { + if (!entry || typeof entry.path !== 'string' || !entry.path || seenPaths.has(entry.path)) { + continue + } + + seenPaths.add(entry.path) + result.push({ isDirectory: entry.isDirectory === true, path: entry.path }) + } + } + } catch { + // Malformed payload — fall through to native files. + } + const fileList = transfer.files if (fileList) { for (let i = 0; i < fileList.length; i += 1) { const file = fileList.item(i) - if (!file || seen.has(file)) { + if (!file || seenFiles.has(file)) { continue } - seen.add(file) + + seenFiles.add(file) let path = '' if (getPath) { @@ -67,6 +100,14 @@ export function extractDroppedFiles(transfer: DataTransfer): DroppedFile[] { } } + if (path && seenPaths.has(path)) { + continue + } + + if (path) { + seenPaths.add(path) + } + result.push({ file, path }) } } @@ -80,12 +121,14 @@ export function extractDroppedFiles(transfer: DataTransfer): DroppedFile[] { if (!item || item.kind !== 'file') { continue } + const file = item.getAsFile() - if (!file || seen.has(file)) { + if (!file || seenFiles.has(file)) { continue } - seen.add(file) + + seenFiles.add(file) let path = '' if (getPath) { @@ -96,6 +139,14 @@ export function extractDroppedFiles(transfer: DataTransfer): DroppedFile[] { } } + if (path && seenPaths.has(path)) { + continue + } + + if (path) { + seenPaths.add(path) + } + result.push({ file, path }) } } @@ -282,6 +333,26 @@ export function useComposerActions({ activeSessionId, currentCwd, requestGateway } }, [attachImagePath]) + const attachContextFolderPath = useCallback( + (folderPath: string) => { + if (!folderPath) {return false} + + const rel = contextPath(folderPath, currentCwd) + + addComposerAttachment({ + id: attachmentId('folder', rel), + kind: 'folder', + label: pathLabel(folderPath), + detail: rel, + refText: `@folder:${formatRefValue(rel)}`, + path: folderPath + }) + + return true + }, + [currentCwd] + ) + const attachDroppedItems = useCallback( async (candidates: DroppedFile[]) => { if (candidates.length === 0) { @@ -291,9 +362,49 @@ export function useComposerActions({ activeSessionId, currentCwd, requestGateway let attached = false let lastFailure: string | null = null - for (const { file, path: knownPath } of candidates) { + for (const candidate of candidates) { + const { file, isDirectory, path: knownPath } = candidate + + // Path-only entry (in-app drag from the file browser tree, etc.). + if (!file) { + if (isDirectory) { + if (knownPath && attachContextFolderPath(knownPath)) { + attached = true + + continue + } + + lastFailure = `Could not attach folder ${knownPath || ''}` + + continue + } + + if (knownPath && isImagePath(knownPath)) { + if (await attachImagePath(knownPath)) { + attached = true + + continue + } + + lastFailure = `Could not attach ${knownPath}` + + continue + } + + if (knownPath && attachContextFilePath(knownPath)) { + attached = true + + continue + } + + lastFailure = `Could not attach ${knownPath || 'file'}` + + continue + } + const fallbackPath = !knownPath && window.hermesDesktop?.getPathForFile ? window.hermesDesktop.getPathForFile(file) : '' + const filePath = knownPath || fallbackPath || '' const isImage = file.type.startsWith('image/') || isImagePath(file.name) || (filePath && isImagePath(filePath)) @@ -324,7 +435,7 @@ export function useComposerActions({ activeSessionId, currentCwd, requestGateway return attached }, - [attachContextFilePath, attachImageBlob, attachImagePath] + [attachContextFilePath, attachContextFolderPath, attachImageBlob, attachImagePath] ) const removeAttachment = useCallback( @@ -349,6 +460,7 @@ export function useComposerActions({ activeSessionId, currentCwd, requestGateway return { addContextRefAttachment, + attachContextFilePath, attachDroppedItems, attachImageBlob, attachImagePath, diff --git a/apps/desktop/src/app/chat/index.tsx b/apps/desktop/src/app/chat/index.tsx index e504d152a07..657dcdca345 100644 --- a/apps/desktop/src/app/chat/index.tsx +++ b/apps/desktop/src/app/chat/index.tsx @@ -39,14 +39,11 @@ import { import type { ModelOptionsResponse } from '@/types/hermes' import { routeSessionId } from '../routes' -import type { SetStatusbarItemGroup } from '../shell/statusbar-controls' import { titlebarHeaderBaseClass, titlebarHeaderShadowClass } from '../shell/titlebar' -import type { SetTitlebarToolGroup } from '../shell/titlebar-controls' import { ChatBar, ChatBarFallback } from './composer' import type { ChatBarState } from './composer/types' import type { DroppedFile } from './hooks/use-composer-actions' -import { ChatPreviewRail, ChatRightRail } from './right-rail' import { SessionActionsMenu } from './sidebar/session-actions-menu' interface ChatViewProps extends Omit, 'onSubmit'> { @@ -66,21 +63,10 @@ interface ChatViewProps extends Omit, 'onSubmit'> { onPickImages: () => void onRemoveAttachment: (id: string) => void onSubmit: (text: string) => Promise | void - onChangeCwd: (cwd: string) => void - onBrowseCwd: () => void - onOpenModelPicker: () => void - onRestartPreviewServer?: (url: string, context?: string) => Promise - onSetFastMode: (enabled: boolean) => void - onSetReasoningEffort: (effort: string) => void - onSelectPersonality: (name: string) => void - onOpenCommandCenterSystem: () => void - onOpenSkills: () => void onThreadMessagesChange: (messages: readonly ThreadMessage[]) => void onEdit: (message: AppendMessage) => Promise onReload: (parentId: string | null) => Promise onTranscribeAudio?: (audio: Blob) => Promise - setStatusbarItemGroup?: SetStatusbarItemGroup - setTitlebarToolGroup?: SetTitlebarToolGroup } function threadLoadingState( @@ -125,21 +111,10 @@ export function ChatView({ onPickImages, onRemoveAttachment, onSubmit, - onChangeCwd: _onChangeCwd, - onBrowseCwd: _onBrowseCwd, - onOpenModelPicker: _onOpenModelPicker, - onRestartPreviewServer, - onSetFastMode: _onSetFastMode, - onSetReasoningEffort: _onSetReasoningEffort, - onSelectPersonality: _onSelectPersonality, - onOpenCommandCenterSystem, - onOpenSkills, onThreadMessagesChange, onEdit, onReload, - onTranscribeAudio, - setStatusbarItemGroup: _setStatusbarItemGroup, - setTitlebarToolGroup + onTranscribeAudio }: ChatViewProps) { const location = useLocation() const activeSessionId = useStore($activeSessionId) @@ -270,85 +245,75 @@ export function ChatView({ }) return ( - <> -
-
-
- {title && ( - +
+
+ {title && ( + + - - )} -
-
- - - -
- - - {showChatBar && ( - }> - - - )} - +

{title}

+ + + + )}
-
+
- - - + + +
+ + + {showChatBar && ( + }> + + + )} + +
+
) } - -export { PREVIEW_RAIL_WIDTH, SESSION_INSPECTOR_WIDTH } from './right-rail' diff --git a/apps/desktop/src/app/chat/right-rail/agent-section.tsx b/apps/desktop/src/app/chat/right-rail/agent-section.tsx deleted file mode 100644 index 2779ae4acce..00000000000 --- a/apps/desktop/src/app/chat/right-rail/agent-section.tsx +++ /dev/null @@ -1,108 +0,0 @@ -'use client' - -import { useMemo } from 'react' - -import { RailActionRow } from './rail-action-row' -import { RailSection } from './rail-section' -import { type RailSelectOption, RailSelectRow } from './rail-select-row' -import { RailToggleRow } from './rail-toggle-row' - -const REASONING_OPTIONS: RailSelectOption[] = [ - { value: 'none', label: 'Off' }, - { value: 'minimal', label: 'Minimal' }, - { value: 'low', label: 'Low' }, - { value: 'medium', label: 'Medium' }, - { value: 'high', label: 'High' }, - { value: 'xhigh', label: 'XHigh' } -] - -interface AgentSectionProps { - modelLabel: string - providerName?: string - reasoningEffort: string - serviceTier: string - fastMode: boolean - personality: string - personalities: string[] - onOpenModelPicker?: () => void - onSetReasoningEffort?: (effort: string) => void - onSetFastMode?: (enabled: boolean) => void - onSelectPersonality?: (name: string) => void -} - -export function AgentSection({ - modelLabel, - providerName, - reasoningEffort, - serviceTier, - fastMode, - personality, - personalities, - onOpenModelPicker, - onSetReasoningEffort, - onSetFastMode, - onSelectPersonality -}: AgentSectionProps) { - const activeReasoning = normalizeReasoningEffort(reasoningEffort) - const fastEnabled = fastMode || ['fast', 'priority'].includes(serviceTier.trim().toLowerCase()) - - const activePersonality = personalityOptionKey(personality) - - const personalityOptions = useMemo( - () => - [...new Set(['none', ...personalities, personality].map(personalityOptionKey).filter(Boolean))].map(name => ({ - value: name, - label: name === 'none' ? 'None' : titleize(name) - })), - [personalities, personality] - ) - - return ( - - - - - - - ) -} - -function personalityOptionKey(value?: string): string { - const key = value?.trim().toLowerCase() || 'none' - - return key === 'default' ? 'none' : key -} - -function normalizeReasoningEffort(value: string): string { - const normalized = value.trim().toLowerCase() - - return REASONING_OPTIONS.some(option => option.value === normalized) ? normalized : 'medium' -} - -function titleize(value: string): string { - return value - .replace(/[-_]+/g, ' ') - .replace(/\s+/g, ' ') - .trim() - .replace(/(^|\s)\S/g, m => m.toUpperCase()) -} diff --git a/apps/desktop/src/app/chat/right-rail/index.ts b/apps/desktop/src/app/chat/right-rail/index.ts new file mode 100644 index 00000000000..174b836c6d6 --- /dev/null +++ b/apps/desktop/src/app/chat/right-rail/index.ts @@ -0,0 +1 @@ +export { ChatPreviewRail, PREVIEW_RAIL_PANE_WIDTH } from './preview' diff --git a/apps/desktop/src/app/chat/right-rail/index.tsx b/apps/desktop/src/app/chat/right-rail/index.tsx deleted file mode 100644 index 88dcecc96cb..00000000000 --- a/apps/desktop/src/app/chat/right-rail/index.tsx +++ /dev/null @@ -1,137 +0,0 @@ -import { useStore } from '@nanostores/react' -import { type ReactNode, useMemo } from 'react' - -import type { SetTitlebarToolGroup } from '@/app/shell/titlebar-controls' -import { Button } from '@/components/ui/button' -import { AlertCircle, Loader2, Sparkles } from '@/lib/icons' -import { cn } from '@/lib/utils' -import { $desktopActionTasks, buildRailTasks, type RailTask, type RailTaskStatus } from '@/store/activity' -import { $inspectorOpen } from '@/store/layout' -import { $previewReloadRequest, $previewServerRestart, $previewTarget } from '@/store/preview' -import { $sessions, $workingSessionIds } from '@/store/session' - -import { PreviewPane } from './preview-pane' - -export const SESSION_INSPECTOR_WIDTH = 'clamp(13.5rem, 21vw, 20rem)' -export const PREVIEW_RAIL_WIDTH = 'clamp(18rem, 36vw, 38rem)' - -const RAIL_TASK_LIMIT = 6 - -const TASK_ICONS: Record = { - error: , - running: , - success: -} - -interface ChatRightRailProps { - onOpenCommandCenterSystem: () => void - onOpenSkills: () => void -} - -export function ChatRightRail({ onOpenCommandCenterSystem, onOpenSkills }: ChatRightRailProps) { - const inspectorOpen = useStore($inspectorOpen) - const sessions = useStore($sessions) - const workingSessionIds = useStore($workingSessionIds) - const previewRestart = useStore($previewServerRestart) - const desktopActionTasks = useStore($desktopActionTasks) - - const tasks = useMemo( - () => buildRailTasks(workingSessionIds, sessions, previewRestart, desktopActionTasks), - [desktopActionTasks, previewRestart, sessions, workingSessionIds] - ) - - return ( -
- -
- ) -} - -export function ChatPreviewRail({ - onRestartServer, - setTitlebarToolGroup -}: { - onRestartServer?: (url: string, context?: string) => Promise - setTitlebarToolGroup?: SetTitlebarToolGroup -}) { - const previewReloadRequest = useStore($previewReloadRequest) - const previewTarget = useStore($previewTarget) - - if (!previewTarget) { - return