diff --git a/AGENTS.md b/AGENTS.md index 4008c1cc01d..57f8a2aaa46 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -2,6 +2,8 @@ Instructions for AI coding assistants and developers working on the hermes-agent codebase. +**Never give up on the right solution.** + ## Development Environment ```bash diff --git a/apps/desktop/src/app/artifacts/index.tsx b/apps/desktop/src/app/artifacts/index.tsx index e3412993bef..8909ca7761f 100644 --- a/apps/desktop/src/app/artifacts/index.tsx +++ b/apps/desktop/src/app/artifacts/index.tsx @@ -1,5 +1,5 @@ import { Copy, ExternalLink, FileImage, FileText, FolderOpen, Layers3, Link2, RefreshCw, Search, X } from 'lucide-react' -import type { ReactNode } from 'react' +import type * as React from 'react' import { useCallback, useEffect, useMemo, useState } from 'react' import { useNavigate } from 'react-router-dom' @@ -23,7 +23,8 @@ import { notify, notifyError } from '@/store/notifications' import type { SessionInfo, SessionMessage } from '@/types/hermes' import { sessionRoute } from '../routes' -import { TITLEBAR_ICON_SIZE, titlebarButtonClass, titlebarHeaderBaseClass } from '../shell/titlebar' +import { titlebarHeaderBaseClass } from '../shell/titlebar' +import type { SetTitlebarToolGroup } from '../shell/titlebar-controls' type ArtifactKind = 'image' | 'file' | 'link' @@ -340,10 +341,10 @@ function paginationItems(page: number, pageCount: number): Array { - setTitlebarActions?: (actions: ReactNode | null) => void + setTitlebarToolGroup?: SetTitlebarToolGroup } -export function ArtifactsView({ setTitlebarActions, ...props }: ArtifactsViewProps) { +export function ArtifactsView({ setTitlebarToolGroup, ...props }: ArtifactsViewProps) { const navigate = useNavigate() const [artifacts, setArtifacts] = useState(null) const [query, setQuery] = useState('') @@ -384,24 +385,22 @@ export function ArtifactsView({ setTitlebarActions, ...props }: ArtifactsViewPro }, [refreshArtifacts]) useEffect(() => { - if (!setTitlebarActions) { + if (!setTitlebarToolGroup) { return } - setTitlebarActions( - - ) + setTitlebarToolGroup('artifacts', [ + { + disabled: refreshing, + icon: , + id: 'refresh-artifacts', + label: refreshing ? 'Refreshing artifacts' : 'Refresh artifacts', + onSelect: () => void refreshArtifacts() + } + ]) - return () => setTitlebarActions(null) - }, [refreshArtifacts, refreshing, setTitlebarActions]) + return () => setTitlebarToolGroup('artifacts', []) + }, [refreshArtifacts, refreshing, setTitlebarToolGroup]) useEffect(() => { setImagePage(1) @@ -514,8 +513,8 @@ export function ArtifactsView({ setTitlebarActions, ...props }: ArtifactsViewPro className="flex h-[calc(100vh-0.375rem)] min-w-0 flex-col overflow-hidden rounded-[0.9375rem] bg-background" >
-

Artifacts

- {counts.all} found +

Artifacts

+ {counts.all} found
diff --git a/apps/desktop/src/app/chat/index.tsx b/apps/desktop/src/app/chat/index.tsx index d509bdb3ce4..432de6c39fc 100644 --- a/apps/desktop/src/app/chat/index.tsx +++ b/apps/desktop/src/app/chat/index.tsx @@ -40,11 +40,12 @@ import type { ModelOptionsResponse } from '@/types/hermes' import { routeSessionId } from '../routes' 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 { ChatRightRail } from './right-rail' +import { ChatPreviewRail, ChatRightRail } from './right-rail' import { SessionActionsMenu } from './sidebar/session-actions-menu' interface ChatViewProps extends Omit, 'onSubmit'> { @@ -72,6 +73,7 @@ interface ChatViewProps extends Omit, 'onSubmit'> { onEdit: (message: AppendMessage) => Promise onReload: (parentId: string | null) => Promise onTranscribeAudio?: (audio: Blob) => Promise + setTitlebarToolGroup?: SetTitlebarToolGroup } function threadLoadingState( @@ -123,7 +125,8 @@ export function ChatView({ onThreadMessagesChange, onEdit, onReload, - onTranscribeAudio + onTranscribeAudio, + setTitlebarToolGroup }: ChatViewProps) { const location = useLocation() const activeSessionId = useStore($activeSessionId) @@ -255,7 +258,7 @@ export function ChatView({ return ( <> -
+
{title && ( @@ -269,7 +272,7 @@ export function ChatView({ title={title} > +
+
+ ) +} + async function writeClipboardText(text: string) { if (!text) { return @@ -145,12 +229,20 @@ async function writeClipboardText(text: string) { } } -export function PreviewPane({ target }: { target: PreviewTarget }) { +export function PreviewPane({ + setTitlebarToolGroup, + target +}: { + setTitlebarToolGroup?: SetTitlebarToolGroup + target: PreviewTarget +}) { const consoleBodyRef = useRef(null) const consoleShouldStickRef = useRef(true) const hostRef = useRef(null) const logIdRef = useRef(0) + const previewContentRef = useRef(null) const webviewRef = useRef(null) + const [consoleHeight, setConsoleHeight] = useState(CONSOLE_DEFAULT_HEIGHT) const [consoleOpen, setConsoleOpen] = useState(false) const [currentUrl, setCurrentUrl] = useState(target.url) const [devtoolsOpen, setDevtoolsOpen] = useState(false) @@ -158,8 +250,72 @@ export function PreviewPane({ target }: { target: PreviewTarget }) { const [selectedLogIds, setSelectedLogIds] = useState>(() => new Set()) const [copiedAll, setCopiedAll] = useState(false) const [loading, setLoading] = useState(true) + const [loadError, setLoadError] = useState(null) 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 startConsoleResize = useCallback( + (event: ReactPointerEvent) => { + event.preventDefault() + + const handle = event.currentTarget + const pointerId = event.pointerId + const startY = event.clientY + const startHeight = consoleHeight + const previousCursor = document.body.style.cursor + const previousUserSelect = document.body.style.userSelect + let active = true + + handle.setPointerCapture?.(pointerId) + + document.body.style.cursor = 'row-resize' + document.body.style.userSelect = 'none' + + const handleMove = (moveEvent: PointerEvent) => { + if (!active) { + return + } + + setConsoleHeight(clampConsoleHeight(startHeight + startY - moveEvent.clientY)) + } + + const cleanup = () => { + if (!active) { + return + } + + active = false + document.body.style.cursor = previousCursor + document.body.style.userSelect = previousUserSelect + handle.releasePointerCapture?.(pointerId) + window.removeEventListener('pointermove', handleMove, true) + window.removeEventListener('pointerup', cleanup, true) + window.removeEventListener('pointercancel', cleanup, true) + window.removeEventListener('blur', cleanup) + handle.removeEventListener('lostpointercapture', cleanup) + } + + window.addEventListener('pointermove', handleMove, true) + window.addEventListener('pointerup', cleanup, true) + window.addEventListener('pointercancel', cleanup, true) + window.addEventListener('blur', cleanup) + handle.addEventListener('lostpointercapture', cleanup) + }, + [consoleHeight] + ) + + const reloadPreview = useCallback(() => { + setLoadError(null) + + if (webviewRef.current?.reloadIgnoringCache) { + webviewRef.current.reloadIgnoringCache() + } else { + webviewRef.current?.reload?.() + } + }, []) function toggleLogSelection(id: number) { setSelectedLogIds(prev => { @@ -204,7 +360,7 @@ export function PreviewPane({ target }: { target: PreviewTarget }) { }) } - function toggleDevTools() { + const toggleDevTools = useCallback(() => { const webview = webviewRef.current if (!webview?.openDevTools) { @@ -220,7 +376,52 @@ export function PreviewPane({ target }: { target: PreviewTarget }) { webview.openDevTools() setDevtoolsOpen(true) - } + }, []) + + useEffect(() => { + if (!setTitlebarToolGroup) { + return + } + + const tools: TitlebarTool[] = [ + { + active: consoleOpen, + icon: ( + <> + + {logs.length > 0 && {logs.length} console messages} + + ), + id: 'preview-console', + label: consoleOpen ? 'Hide preview console' : 'Show preview console', + onSelect: () => setConsoleOpen(open => !open) + }, + { + active: devtoolsOpen, + icon: , + id: 'preview-devtools', + label: devtoolsOpen ? 'Hide preview DevTools' : 'Open preview DevTools', + onSelect: toggleDevTools + }, + { + icon: , + id: 'preview-reload', + label: 'Reload preview', + onSelect: reloadPreview + }, + { + className: 'mr-(--shell-preview-toolbar-gap)', + icon: , + id: 'preview-close', + label: 'Close preview', + onSelect: () => setPreviewTarget(null) + } + ] + + setTitlebarToolGroup('preview', tools) + + return () => setTitlebarToolGroup('preview', []) + }, [consoleOpen, currentUrl, devtoolsOpen, loading, logs.length, reloadPreview, setTitlebarToolGroup, toggleDevTools]) useEffect(() => { if (consoleOpen && consoleShouldStickRef.current) { @@ -228,7 +429,7 @@ export function PreviewPane({ target }: { target: PreviewTarget }) { consoleBody?.scrollTo({ top: consoleBody.scrollHeight }) } - }, [consoleOpen, logs]) + }, [consoleHeight, consoleOpen, logs]) useEffect(() => { if (consoleOpen) { @@ -275,11 +476,7 @@ export function PreviewPane({ target }: { target: PreviewTarget }) { } ]) - if (webviewRef.current?.reloadIgnoringCache) { - webviewRef.current.reloadIgnoringCache() - } else { - webviewRef.current?.reload?.() - } + reloadPreview() } const unsubscribe = window.hermesDesktop.onPreviewFileChanged(payload => { @@ -334,7 +531,7 @@ export function PreviewPane({ target }: { target: PreviewTarget }) { void window.hermesDesktop?.stopPreviewFileWatch?.(watchId) } } - }, [target.kind, target.url]) + }, [reloadPreview, target.kind, target.url]) useEffect(() => { const host = hostRef.current @@ -347,6 +544,7 @@ export function PreviewPane({ target }: { target: PreviewTarget }) { webviewRef.current = null setCurrentUrl(target.url) setDevtoolsOpen(false) + setLoadError(null) setLogs([]) setLoading(true) @@ -381,6 +579,7 @@ export function PreviewPane({ target }: { target: PreviewTarget }) { const detail = event as Event & { url?: string } if (detail.url) { + setLoadError(null) setCurrentUrl(detail.url) } } @@ -391,13 +590,23 @@ export function PreviewPane({ target }: { target: PreviewTarget }) { errorDescription?: string validatedURL?: string } + const errorCode = detail.errorCode + + if (errorCode === -3) { + return + } appendLog({ level: 3, - message: `Load failed${detail.errorCode ? ` (${detail.errorCode})` : ''}: ${ + message: `Load failed${errorCode ? ` (${errorCode})` : ''}: ${ detail.errorDescription || detail.validatedURL || 'unknown error' }` }) + setLoadError({ + code: errorCode, + description: detail.errorDescription || 'The preview page could not be reached.', + url: detail.validatedURL || currentUrl || target.url + }) setLoading(false) } @@ -425,157 +634,129 @@ export function PreviewPane({ target }: { target: PreviewTarget }) { }, [target.url]) return ( -