From fa92720d2ce866f3ef715e1ada4a8db70e849010 Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Sun, 3 May 2026 12:40:03 -0500 Subject: [PATCH] chore: uptick --- apps/desktop/electron/main.cjs | 15 +- apps/desktop/src/app/chat/index.tsx | 10 +- .../src/app/chat/right-rail/agent-section.tsx | 108 +++++++ .../desktop/src/app/chat/right-rail/index.tsx | 48 ++- .../src/app/chat/right-rail/preview-pane.tsx | 225 +++++++++++--- .../app/chat/right-rail/project-section.tsx | 123 ++++++++ .../app/chat/right-rail/rail-action-row.tsx | 34 ++ .../src/app/chat/right-rail/rail-section.tsx | 19 ++ .../app/chat/right-rail/rail-select-row.tsx | 88 ++++++ .../app/chat/right-rail/rail-toggle-row.tsx | 36 +++ apps/desktop/src/app/desktop-controller.tsx | 175 ++++++++++- .../app/session/hooks/use-message-stream.ts | 17 +- .../app/session/hooks/use-session-actions.ts | 17 + apps/desktop/src/app/shell/app-shell.tsx | 15 +- .../src/components/session-inspector.tsx | 294 ++---------------- apps/desktop/src/lib/chat-messages.ts | 3 + apps/desktop/src/lib/preview-targets.test.ts | 15 + apps/desktop/src/lib/preview-targets.ts | 84 ++++- apps/desktop/src/store/preview.ts | 58 ++++ apps/desktop/src/store/session.ts | 6 + apps/desktop/src/types/hermes.ts | 2 + tui_gateway/server.py | 159 +++++++++- 22 files changed, 1203 insertions(+), 348 deletions(-) create mode 100644 apps/desktop/src/app/chat/right-rail/agent-section.tsx create mode 100644 apps/desktop/src/app/chat/right-rail/project-section.tsx create mode 100644 apps/desktop/src/app/chat/right-rail/rail-action-row.tsx create mode 100644 apps/desktop/src/app/chat/right-rail/rail-section.tsx create mode 100644 apps/desktop/src/app/chat/right-rail/rail-select-row.tsx create mode 100644 apps/desktop/src/app/chat/right-rail/rail-toggle-row.tsx diff --git a/apps/desktop/electron/main.cjs b/apps/desktop/electron/main.cjs index e62034908e2..2ea13de79e9 100644 --- a/apps/desktop/electron/main.cjs +++ b/apps/desktop/electron/main.cjs @@ -92,6 +92,7 @@ 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']) app.setName(APP_NAME) @@ -690,13 +691,23 @@ function sendPreviewFileChanged(payload) { function watchPreviewFile(rawUrl) { const filePath = previewFilePathFromUrl(rawUrl) + const watchDir = path.dirname(filePath) + const targetName = path.basename(filePath) const id = crypto.randomBytes(12).toString('base64url') let timer = null - const watcher = fs.watch(filePath, () => { + const watcher = fs.watch(watchDir, (_eventType, filename) => { + const changedName = filename ? path.basename(String(filename)) : '' + + if (changedName && changedName !== targetName) { + return + } + if (timer) clearTimeout(timer) timer = setTimeout(() => { + timer = null + if (!fileExists(filePath)) return sendPreviewFileChanged({ id, path: filePath, url: pathToFileURL(filePath).toString() }) - }, 120) + }, PREVIEW_WATCH_DEBOUNCE_MS) }) previewWatchers.set(id, { diff --git a/apps/desktop/src/app/chat/index.tsx b/apps/desktop/src/app/chat/index.tsx index 432de6c39fc..ee66d034583 100644 --- a/apps/desktop/src/app/chat/index.tsx +++ b/apps/desktop/src/app/chat/index.tsx @@ -68,6 +68,9 @@ interface ChatViewProps extends Omit, 'onSubmit'> { 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 onThreadMessagesChange: (messages: readonly ThreadMessage[]) => void onEdit: (message: AppendMessage) => Promise @@ -121,6 +124,9 @@ export function ChatView({ onChangeCwd, onBrowseCwd, onOpenModelPicker, + onRestartPreviewServer, + onSetFastMode, + onSetReasoningEffort, onSelectPersonality, onThreadMessagesChange, onEdit, @@ -324,12 +330,14 @@ export function ChatView({ - + ) diff --git a/apps/desktop/src/app/chat/right-rail/agent-section.tsx b/apps/desktop/src/app/chat/right-rail/agent-section.tsx new file mode 100644 index 00000000000..2779ae4acce --- /dev/null +++ b/apps/desktop/src/app/chat/right-rail/agent-section.tsx @@ -0,0 +1,108 @@ +'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.tsx b/apps/desktop/src/app/chat/right-rail/index.tsx index 76c0a196549..a5032eb5e7f 100644 --- a/apps/desktop/src/app/chat/right-rail/index.tsx +++ b/apps/desktop/src/app/chat/right-rail/index.tsx @@ -1,19 +1,22 @@ import { useStore } from '@nanostores/react' import type * as React from 'react' +import type { SetTitlebarToolGroup } from '@/app/shell/titlebar-controls' import { SESSION_INSPECTOR_WIDTH, SessionInspector } from '@/components/session-inspector' import { cn } from '@/lib/utils' import { $inspectorOpen } from '@/store/layout' -import { $previewTarget } from '@/store/preview' -import type { SetTitlebarToolGroup } from '@/app/shell/titlebar-controls' +import { $previewReloadRequest, $previewTarget } from '@/store/preview' import { $availablePersonalities, $busy, $currentBranch, $currentCwd, + $currentFastMode, $currentModel, $currentPersonality, $currentProvider, + $currentReasoningEffort, + $currentServiceTier, $gatewayState } from '@/store/session' @@ -24,6 +27,8 @@ interface ChatRightRailProps extends Pick< 'onBrowseCwd' | 'onChangeCwd' > { onOpenModelPicker: () => void + onSetFastMode: (enabled: boolean) => void + onSetReasoningEffort: (effort: string) => void onSelectPersonality: (name: string) => void } @@ -31,6 +36,8 @@ export function ChatRightRail({ onBrowseCwd, onChangeCwd, onOpenModelPicker, + onSetFastMode, + onSetReasoningEffort, onSelectPersonality }: ChatRightRailProps) { const inspectorOpen = useStore($inspectorOpen) @@ -40,32 +47,51 @@ export function ChatRightRail({ const branch = useStore($currentBranch) const model = useStore($currentModel) const provider = useStore($currentProvider) + const reasoningEffort = useStore($currentReasoningEffort) + const serviceTier = useStore($currentServiceTier) + const fastMode = useStore($currentFastMode) const personality = useStore($currentPersonality) const personalities = useStore($availablePersonalities) return ( -
+
) } -export function ChatPreviewRail({ setTitlebarToolGroup }: { setTitlebarToolGroup?: SetTitlebarToolGroup }) { - const inspectorOpen = useStore($inspectorOpen) +export function ChatPreviewRail({ + onRestartServer, + setTitlebarToolGroup +}: { + onRestartServer?: (url: string, context?: string) => Promise + setTitlebarToolGroup?: SetTitlebarToolGroup +}) { + const previewReloadRequest = useStore($previewReloadRequest) const previewTarget = useStore($previewTarget) if (!previewTarget) { @@ -74,12 +100,14 @@ export function ChatPreviewRail({ setTitlebarToolGroup }: { setTitlebarToolGroup return (
- +
) } 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 028a460aa5e..e8e35db7533 100644 --- a/apps/desktop/src/app/chat/right-rail/preview-pane.tsx +++ b/apps/desktop/src/app/chat/right-rail/preview-pane.tsx @@ -1,3 +1,4 @@ +import { useStore } from '@nanostores/react' import { Bug, Check, Copy, PanelBottom, RefreshCw, Send, Trash2, X } from 'lucide-react' import type { CSSProperties, PointerEvent as ReactPointerEvent } from 'react' import { useCallback, useEffect, useMemo, useRef, useState } from 'react' @@ -6,10 +7,11 @@ import type { SetTitlebarToolGroup, TitlebarTool } from '@/app/shell/titlebar-co import { cn } from '@/lib/utils' import { $composerDraft, setComposerDraft } from '@/store/composer' import { notify, notifyError } from '@/store/notifications' -import { type PreviewTarget, setPreviewTarget } from '@/store/preview' +import { $previewServerRestart, failPreviewServerRestart, type PreviewTarget, setPreviewTarget } from '@/store/preview' type PreviewWebview = HTMLElement & { closeDevTools?: () => void + getURL?: () => string isDevToolsOpened?: () => boolean openDevTools?: () => void reload?: () => void @@ -24,6 +26,13 @@ interface ConsoleEntry { source?: string } +interface PreviewPaneProps { + onRestartServer?: (url: string, context?: string) => Promise + reloadRequest?: number + setTitlebarToolGroup?: SetTitlebarToolGroup + target: PreviewTarget +} + interface PreviewLoadErrorState { code?: number description: string @@ -48,6 +57,7 @@ const CONSOLE_BOTTOM_THRESHOLD = 24 const CONSOLE_DEFAULT_HEIGHT = 240 const CONSOLE_HEADER_HEIGHT = 32 const FILE_RELOAD_DEBOUNCE_MS = 200 +const SERVER_RESTART_TIMEOUT_MS = 45_000 function compactUrl(value: string): string { try { @@ -85,6 +95,10 @@ function clampConsoleHeight(value: number): number { function loadErrorTitle(error: PreviewLoadErrorState): string { const description = error.description.toLowerCase() + if (description.includes('module script') || description.includes('mime type')) { + return 'Preview app failed to boot' + } + if (description.includes('connection') || description.includes('refused') || description.includes('not found')) { return 'Server not found' } @@ -92,6 +106,12 @@ function loadErrorTitle(error: PreviewLoadErrorState): string { return 'Preview failed to load' } +function isModuleMimeError(message: string): boolean { + const lower = message.toLowerCase() + + return lower.includes('failed to load module script') && lower.includes('mime type') +} + interface ConsoleRowProps { log: ConsoleEntry onCopy: () => void | Promise @@ -155,11 +175,15 @@ function ConsoleRow({ log, onCopy, onSend, onToggleSelect, selected }: ConsoleRo function PreviewLoadError({ consoleHeight = 0, error, - onRetry + onRestartServer, + onRetry, + restarting }: { consoleHeight?: number error: PreviewLoadErrorState + onRestartServer?: () => void onRetry: () => void + restarting?: boolean }) { return (
- +
{loadErrorTitle(error)}
@@ -201,13 +225,25 @@ function PreviewLoadError({
{error.description}
- +
+ + {onRestartServer && ( + + )} +
) @@ -230,18 +266,20 @@ async function writeClipboardText(text: string) { } export function PreviewPane({ + onRestartServer, + reloadRequest = 0, setTitlebarToolGroup, target -}: { - setTitlebarToolGroup?: SetTitlebarToolGroup - target: PreviewTarget -}) { +}: PreviewPaneProps) { const consoleBodyRef = useRef(null) const consoleShouldStickRef = useRef(true) const hostRef = useRef(null) const logIdRef = useRef(0) + const lastReloadRequestRef = useRef(reloadRequest) + const lastRestartEventRef = useRef('') const previewContentRef = useRef(null) const webviewRef = useRef(null) + const previewServerRestart = useStore($previewServerRestart) const [consoleHeight, setConsoleHeight] = useState(CONSOLE_DEFAULT_HEIGHT) const [consoleOpen, setConsoleOpen] = useState(false) const [currentUrl, setCurrentUrl] = useState(target.url) @@ -254,8 +292,12 @@ export function PreviewPane({ const visibleSelection = useMemo(() => logs.filter(log => selectedLogIds.has(log.id)), [logs, selectedLogIds]) const sendableLogs = visibleSelection.length > 0 ? visibleSelection : logs const currentLabel = compactUrl(currentUrl) + const previewLabel = target.label && target.label.replace(/\/$/, '') !== currentLabel.replace(/\/$/, '') ? target.label : currentLabel + const restartingServer = + previewServerRestart?.status === 'running' && + (previewServerRestart.url === target.url || previewServerRestart.url === currentUrl) const startConsoleResize = useCallback( (event: ReactPointerEvent) => { @@ -317,6 +359,33 @@ export function PreviewPane({ } }, []) + const appendConsoleEntry = useCallback((entry: Omit) => { + consoleShouldStickRef.current = isNearConsoleBottom(consoleBodyRef.current) + setLogs(prev => [...prev.slice(-199), { ...entry, id: ++logIdRef.current }]) + }, []) + + const restartServer = useCallback(async () => { + if (!onRestartServer) { + return + } + + try { + const context = logs.slice(-12).map(formatLogLine).join('\n') + const taskId = await onRestartServer(currentUrl, context || undefined) + + appendConsoleEntry({ + level: 1, + message: `Hermes is looking for a preview server to restart (${taskId})` + }) + } catch (error) { + appendConsoleEntry({ + level: 2, + message: `Could not start server restart: ${error instanceof Error ? error.message : String(error)}` + }) + notifyError(error, 'Server restart failed') + } + }, [appendConsoleEntry, currentUrl, logs, onRestartServer]) + function toggleLogSelection(id: number) { setSelectedLogIds(prev => { const next = new Set(prev) @@ -441,6 +510,73 @@ export function PreviewPane({ } }, [consoleOpen]) + useEffect(() => { + if ( + !previewServerRestart || + !previewServerRestart.message || + (previewServerRestart.url !== target.url && previewServerRestart.url !== currentUrl) + ) { + return + } + + const eventKey = `${previewServerRestart.taskId}:${previewServerRestart.status}:${previewServerRestart.message || ''}` + + if (eventKey === lastRestartEventRef.current) { + return + } + + lastRestartEventRef.current = eventKey + appendConsoleEntry({ + level: previewServerRestart.status === 'error' ? 2 : 1, + message: + previewServerRestart.status === 'running' + ? previewServerRestart.message + : previewServerRestart.status === 'complete' + ? `Hermes finished restarting the preview server${ + previewServerRestart.message ? `: ${previewServerRestart.message}` : '' + }` + : `Server restart failed: ${previewServerRestart.message || 'unknown error'}` + }) + + if (previewServerRestart.status === 'complete') { + reloadPreview() + } + }, [appendConsoleEntry, currentUrl, previewServerRestart, reloadPreview, target.url]) + + useEffect(() => { + if (!restartingServer || !previewServerRestart) { + return + } + + const taskId = previewServerRestart.taskId + const timer = window.setTimeout(() => { + failPreviewServerRestart( + taskId, + 'Hermes is still working, but no restart result has arrived yet. The server command may be running in the foreground.' + ) + }, SERVER_RESTART_TIMEOUT_MS) + + return () => window.clearTimeout(timer) + }, [previewServerRestart, restartingServer]) + + useEffect(() => { + if (reloadRequest === lastReloadRequestRef.current) { + return + } + + lastReloadRequestRef.current = reloadRequest + + if (target.kind !== 'url') { + return + } + + appendConsoleEntry({ + level: 1, + message: 'Workspace changed, reloading preview' + }) + reloadPreview() + }, [appendConsoleEntry, reloadPreview, reloadRequest, target.kind]) + useEffect(() => { if (target.kind !== 'file' || !window.hermesDesktop?.watchPreviewFile || !window.hermesDesktop?.onPreviewFileChanged) { return @@ -463,18 +599,13 @@ export function PreviewPane({ pendingReloadCount = 0 pendingReloadUrl = '' - consoleShouldStickRef.current = isNearConsoleBottom(consoleBodyRef.current) - setLogs(prev => [ - ...prev.slice(-199), - { - id: ++logIdRef.current, - level: 1, - message: - changedCount === 1 - ? `File changed, reloading preview: ${compactUrl(changedUrl)}` - : `${changedCount} file changes, reloading preview: ${compactUrl(changedUrl)}` - } - ]) + appendConsoleEntry({ + level: 1, + message: + changedCount === 1 + ? `File changed, reloading preview: ${compactUrl(changedUrl)}` + : `${changedCount} file changes, reloading preview: ${compactUrl(changedUrl)}` + }) reloadPreview() } @@ -509,14 +640,10 @@ export function PreviewPane({ watchId = watch.id }) .catch(error => { - setLogs(prev => [ - ...prev.slice(-199), - { - id: ++logIdRef.current, - level: 2, - message: `Could not watch preview file: ${error instanceof Error ? error.message : String(error)}` - } - ]) + appendConsoleEntry({ + level: 2, + message: `Could not watch preview file: ${error instanceof Error ? error.message : String(error)}` + }) }) return () => { @@ -531,7 +658,7 @@ export function PreviewPane({ void window.hermesDesktop?.stopPreviewFileWatch?.(watchId) } } - }, [reloadPreview, target.kind, target.url]) + }, [appendConsoleEntry, reloadPreview, target.kind, target.url]) useEffect(() => { const host = hostRef.current @@ -554,11 +681,6 @@ export function PreviewPane({ webview.setAttribute('src', target.url) webview.setAttribute('webpreferences', 'contextIsolation=yes,nodeIntegration=no,sandbox=yes') - const appendLog = (entry: Omit) => { - consoleShouldStickRef.current = isNearConsoleBottom(consoleBodyRef.current) - setLogs(prev => [...prev.slice(-199), { ...entry, id: ++logIdRef.current }]) - } - const onConsole = (event: Event) => { const detail = event as Event & { level?: number @@ -566,13 +688,23 @@ export function PreviewPane({ message?: string sourceId?: string } + const message = detail.message || '' - appendLog({ + appendConsoleEntry({ level: detail.level ?? 0, line: detail.line, - message: detail.message || '', + message, source: detail.sourceId }) + + if ((detail.level ?? 0) >= 3 && isModuleMimeError(message)) { + setLoadError({ + description: + 'Module scripts are being served with the wrong MIME type. This usually means a static file server is serving a Vite/React app instead of the project dev server.', + url: webview.getURL?.() || target.url + }) + setLoading(false) + } } const onNavigate = (event: Event) => { @@ -590,13 +722,14 @@ export function PreviewPane({ errorDescription?: string validatedURL?: string } + const errorCode = detail.errorCode if (errorCode === -3) { return } - appendLog({ + appendConsoleEntry({ level: 3, message: `Load failed${errorCode ? ` (${errorCode})` : ''}: ${ detail.errorDescription || detail.validatedURL || 'unknown error' @@ -605,7 +738,7 @@ export function PreviewPane({ setLoadError({ code: errorCode, description: detail.errorDescription || 'The preview page could not be reached.', - url: detail.validatedURL || currentUrl || target.url + url: detail.validatedURL || webview.getURL?.() || target.url }) setLoading(false) } @@ -631,7 +764,7 @@ export function PreviewPane({ webview.removeEventListener('did-stop-loading', onStop) webview.remove() } - }, [target.url]) + }, [appendConsoleEntry, target.url]) return (