diff --git a/apps/desktop/src/app/chat/composer/index.tsx b/apps/desktop/src/app/chat/composer/index.tsx index d6b54c4d6..f55e3c115 100644 --- a/apps/desktop/src/app/chat/composer/index.tsx +++ b/apps/desktop/src/app/chat/composer/index.tsx @@ -81,6 +81,31 @@ const COMPOSER_SINGLE_LINE_MAX_PX = 36 const COMPOSER_FADE_BACKGROUND = 'linear-gradient(to bottom, transparent, color-mix(in srgb, var(--dt-background) 10%, transparent))' +// Resting composer placeholders. New sessions get open-ended starters; an +// existing chat gets phrasings that read as a continuation of the thread. +// One is picked at random per session (stable until the session changes). +const NEW_SESSION_PLACEHOLDERS = [ + 'What are we building?', + 'Give Hermes a task', + "What's on your mind?", + 'Describe what you need', + 'What should we tackle?', + 'Ask anything', + 'Start with a goal' +] + +const FOLLOW_UP_PLACEHOLDERS = [ + 'Send a follow-up', + 'Add more context', + 'Refine the request', + "What's next?", + 'Keep it going', + 'Push it further', + 'Adjust or continue' +] + +const pickPlaceholder = (pool: readonly string[]) => pool[Math.floor(Math.random() * pool.length)] + interface QueueEditState { attachments: ComposerAttachment[] draft: string @@ -163,6 +188,33 @@ export function ChatBar({ const gatewayState = useStore($gatewayState) + // Resting placeholder: a starter for brand-new sessions, a continuation for + // existing ones. Picked once and only re-rolled when we genuinely move to a + // *different* conversation. Critically, the first id assignment of a freshly + // started session (null → id, on the first send) is treated as the same + // conversation so the placeholder doesn't visibly flip mid-stream. + const [restingPlaceholder, setRestingPlaceholder] = useState(() => + pickPlaceholder(sessionId ? FOLLOW_UP_PLACEHOLDERS : NEW_SESSION_PLACEHOLDERS) + ) + const prevSessionIdRef = useRef(sessionId) + + useEffect(() => { + const prev = prevSessionIdRef.current + prevSessionIdRef.current = sessionId + + if (prev === sessionId) { + return + } + + // null → id: the new session we're already in just got persisted. Keep the + // starter we showed instead of swapping to a follow-up under the user. + if (prev == null && sessionId) { + return + } + + setRestingPlaceholder(pickPlaceholder(sessionId ? FOLLOW_UP_PLACEHOLDERS : NEW_SESSION_PLACEHOLDERS)) + }, [sessionId]) + // When the bar is disabled it's because the gateway isn't open. Distinguish a // cold start ("Starting Hermes...") from a dropped connection we're trying to // restore (e.g. after the Mac slept) so the stuck state reads as recoverable. @@ -170,7 +222,7 @@ export function ChatBar({ ? gatewayState === 'closed' || gatewayState === 'error' ? 'Reconnecting to Hermes…' : 'Starting Hermes...' - : 'Send follow-up' + : restingPlaceholder const focusInput = useCallback(() => { focusComposerInput(editorRef.current) @@ -1285,7 +1337,7 @@ export function ChatBar({ 'grid w-full', stacked ? 'grid-cols-[auto_1fr] gap-(--composer-row-gap) [grid-template-areas:"input_input"_"menu_controls"]' - : 'grid-cols-[auto_1fr_auto] items-end gap-(--composer-control-gap) [grid-template-areas:"menu_input_controls"]' + : 'grid-cols-[auto_1fr_auto] items-center gap-(--composer-control-gap) [grid-template-areas:"menu_input_controls"]' )} >
{contextMenu}
diff --git a/apps/desktop/src/app/chat/index.tsx b/apps/desktop/src/app/chat/index.tsx index ac19adc60..7630bb733 100644 --- a/apps/desktop/src/app/chat/index.tsx +++ b/apps/desktop/src/app/chat/index.tsx @@ -110,6 +110,13 @@ function ChatHeader({ ? pinnedSessionIds.includes(selectedSessionId) : false + // A brand-new session has no session to pin/delete/rename, so the header is + // just a dead "New session" label + chevron. Drop it (and its border) + // entirely until there's a real session to act on. + if (!selectedSessionId && !activeSessionId && !isRoutedSessionView) { + return null + } + return (
diff --git a/apps/desktop/src/app/chat/sidebar/index.tsx b/apps/desktop/src/app/chat/sidebar/index.tsx index f5f7808ef..a8aa706e2 100644 --- a/apps/desktop/src/app/chat/sidebar/index.tsx +++ b/apps/desktop/src/app/chat/sidebar/index.tsx @@ -230,8 +230,28 @@ export function ChatSidebar({ const [workspaceOrderIds, setWorkspaceOrderIds] = useState([]) const [searchQuery, setSearchQuery] = useState('') const [serverMatches, setServerMatches] = useState([]) + const [newSessionKbdFlash, setNewSessionKbdFlash] = useState(false) const trimmedQuery = searchQuery.trim() + // Flash the ⌘N hint full-opacity (no transition) for the press, so hitting + // the shortcut visibly pings its affordance in the sidebar. + useEffect(() => { + let timeout: ReturnType | undefined + + const onShortcut = () => { + setNewSessionKbdFlash(true) + clearTimeout(timeout) + timeout = setTimeout(() => setNewSessionKbdFlash(false), 140) + } + + window.addEventListener('hermes:new-session-shortcut', onShortcut) + + return () => { + window.removeEventListener('hermes:new-session-shortcut', onShortcut) + clearTimeout(timeout) + } + }, []) + const activeSidebarSessionId = currentView === 'chat' ? selectedSessionId : null const dndSensors = useSensors( @@ -449,7 +469,10 @@ export function ChatSidebar({ <> {item.label} {item.id === 'new-session' && ( - + )} )} diff --git a/apps/desktop/src/app/desktop-controller.tsx b/apps/desktop/src/app/desktop-controller.tsx index 0a4631979..801b963b3 100644 --- a/apps/desktop/src/app/desktop-controller.tsx +++ b/apps/desktop/src/app/desktop-controller.tsx @@ -216,6 +216,20 @@ export function DesktopController() { return () => window.removeEventListener('keydown', onKeyDown) }, []) + // Cmd/Ctrl+. toggles the command center (sessions / system / usage). + useEffect(() => { + const onKeyDown = (event: KeyboardEvent) => { + if ((event.metaKey || event.ctrlKey) && !event.altKey && !event.shiftKey && event.key === '.') { + event.preventDefault() + toggleCommandCenter() + } + } + + window.addEventListener('keydown', onKeyDown) + + return () => window.removeEventListener('keydown', onKeyDown) + }, [toggleCommandCenter]) + const refreshSessions = useCallback(async () => { const requestId = refreshSessionsRequestRef.current + 1 refreshSessionsRequestRef.current = requestId @@ -435,6 +449,8 @@ export function DesktopController() { event.preventDefault() startFreshSessionDraft() + // Briefly light up the sidebar's ⌘N hint so the shortcut is discoverable. + window.dispatchEvent(new CustomEvent('hermes:new-session-shortcut')) } window.addEventListener('keydown', onKeyDown) diff --git a/apps/desktop/src/app/updates-overlay.tsx b/apps/desktop/src/app/updates-overlay.tsx index 792665d45..1bbd41304 100644 --- a/apps/desktop/src/app/updates-overlay.tsx +++ b/apps/desktop/src/app/updates-overlay.tsx @@ -4,6 +4,7 @@ import { useEffect, useState } from 'react' import { Button } from '@/components/ui/button' import { writeClipboardText } from '@/components/ui/copy-button' import { Dialog, DialogContent, DialogDescription, DialogTitle } from '@/components/ui/dialog' +import { ErrorState } from '@/components/ui/error-state' import type { DesktopUpdateCommit, DesktopUpdateStage, DesktopUpdateStatus } from '@/global' import { buildCommitChangelog, type CommitGroup } from '@/lib/commit-changelog' import { AlertCircle, Check, CheckCircle2, Copy, Loader2, Sparkles, Terminal } from '@/lib/icons' @@ -331,31 +332,22 @@ function ApplyingView({ apply }: { apply: UpdateApplyState }) { function ErrorView({ message, onDismiss, onRetry }: { message: string; onDismiss: () => void; onRetry: () => void }) { return ( -
-
- - - - - Update didn’t finish - + {message || 'No worries — nothing was lost. You can try again now.'} -
- -
- - -
-
+ } + title={Update didn’t finish} + > + + + ) } diff --git a/apps/desktop/src/components/chat/intro.tsx b/apps/desktop/src/components/chat/intro.tsx index 7cd914c8d..d5ec61b0b 100644 --- a/apps/desktop/src/components/chat/intro.tsx +++ b/apps/desktop/src/components/chat/intro.tsx @@ -142,6 +142,8 @@ function pickCopy(copies: IntroCopy[], seed = 0): IntroCopy { return copies[Math.abs(seed) % copies.length] || FALLBACK_COPY[0] } +const WORDMARK = 'HERMES AGENT' + function resolveCopy(personality?: string, seed?: number): IntroCopy { const personalityKey = normalizeKey(personality) @@ -155,7 +157,6 @@ function resolveCopy(personality?: string, seed?: number): IntroCopy { export function Intro({ personality, seed }: IntroProps) { const [mountSeed] = useState(() => Math.floor(Math.random() * 100000)) const copy = resolveCopy(personality, mountSeed + (seed ?? 0)) - return (

- HERMES AGENT + {WORDMARK} - +

{copy.body}

diff --git a/apps/desktop/src/components/error-boundary.tsx b/apps/desktop/src/components/error-boundary.tsx index d9f4bb80d..12a7dbcf2 100644 --- a/apps/desktop/src/components/error-boundary.tsx +++ b/apps/desktop/src/components/error-boundary.tsx @@ -1,7 +1,7 @@ import { Component, type ErrorInfo, type ReactNode } from 'react' import { Button } from '@/components/ui/button' -import { AlertTriangle, RefreshCw } from '@/lib/icons' +import { ErrorState } from '@/components/ui/error-state' export interface ErrorBoundaryFallbackProps { error: Error @@ -53,43 +53,25 @@ export class ErrorBoundary extends Component -
-
-
- -
-
-

Something broke in the interface

-

- The view hit an unexpected error. Your chats and settings are safe - try again, or reload the window. -

-
-
- -
-
- {error.message || String(error)} -
- -
- - - -
-
-
+
+ + + + +
) } diff --git a/apps/desktop/src/components/ui/error-state.tsx b/apps/desktop/src/components/ui/error-state.tsx new file mode 100644 index 000000000..52ecc2e4d --- /dev/null +++ b/apps/desktop/src/components/ui/error-state.tsx @@ -0,0 +1,45 @@ +import type { ReactNode } from 'react' + +import { AlertCircle } from '@/lib/icons' +import { cn } from '@/lib/utils' + +export interface ErrorStateProps { + /** Optional actions row/stack rendered below the copy. */ + children?: ReactNode + className?: string + description?: ReactNode + /** Defaults to a destructive AlertCircle. */ + icon?: ReactNode + title: ReactNode +} + +// Shared, presentation-only error layout: a destructive icon chip over a +// centered title + body, with an optional actions stack. Used by both the +// top-level React error boundary and the in-dialog update error so every +// failure state reads the same. Title/description accept nodes so callers in a +// Radix Dialog can pass DialogTitle/DialogDescription for accessibility. +export function ErrorState({ children, className, description, icon, title }: ErrorStateProps) { + return ( +
+
+ + {icon ?? } + + + {typeof title === 'string' ? ( +

{title}

+ ) : ( + title + )} + + {typeof description === 'string' ? ( +

{description}

+ ) : ( + description + )} +
+ + {children &&
{children}
} +
+ ) +}