feat(desktop): composer/intro polish + shared ErrorState

- Composer single-line row centers (was bottom-aligned); placeholder
  randomizes per session (starter vs follow-up) without mid-stream flip.
- Drop chat header on brand-new sessions (dead label + border).
- ⌘N flashes its sidebar hint; ⌘. toggles the command center.
- Intro wordmark fills width (drop 8rem fit cap).
- Unify error states on a shared ErrorState component (boundary + updates).
This commit is contained in:
Brooklyn Nicholson
2026-06-04 00:19:05 -05:00
parent 3a5e36cfa5
commit 5bb7156949
8 changed files with 188 additions and 71 deletions
+54 -2
View File
@@ -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"]'
)}
>
<div className="flex items-center [grid-area:menu]">{contextMenu}</div>
+7
View File
@@ -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 (
<header className={cn(titlebarHeaderBaseClass, isRoutedSessionView && titlebarHeaderShadowClass)}>
<div className="min-w-0 flex-1">
+24 -1
View File
@@ -230,8 +230,28 @@ export function ChatSidebar({
const [workspaceOrderIds, setWorkspaceOrderIds] = useState<string[]>([])
const [searchQuery, setSearchQuery] = useState('')
const [serverMatches, setServerMatches] = useState<SessionSearchResult[]>([])
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<typeof setTimeout> | 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({
<>
<span className="min-w-0 flex-1 truncate max-[46.25rem]:hidden">{item.label}</span>
{item.id === 'new-session' && (
<KbdGroup className="ml-auto max-[46.25rem]:hidden" keys={[...NEW_SESSION_KBD]} />
<KbdGroup
className={cn('ml-auto max-[46.25rem]:hidden', newSessionKbdFlash && 'opacity-100!')}
keys={[...NEW_SESSION_KBD]}
/>
)}
</>
)}
@@ -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)
+15 -23
View File
@@ -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 (
<div className="grid gap-5 px-6 pb-6 pt-7 pr-8">
<div className="flex flex-col items-center gap-3 text-center">
<span className="flex size-14 items-center justify-center rounded-2xl bg-destructive/10 text-destructive">
<AlertCircle className="size-7" />
</span>
<DialogTitle className="text-center text-xl">Update didnt finish</DialogTitle>
<DialogDescription className="text-center text-sm">
<ErrorState
className="px-6 pb-6 pt-7 pr-8"
description={
<DialogDescription className="max-w-prose text-center text-sm leading-5 text-muted-foreground">
{message || 'No worries — nothing was lost. You can try again now.'}
</DialogDescription>
</div>
<div className="grid gap-2">
<Button className="font-semibold" onClick={onRetry} size="lg">
Try again
</Button>
<button
className="text-center text-sm font-medium text-muted-foreground transition-colors hover:text-foreground"
onClick={onDismiss}
type="button"
>
Not now
</button>
</div>
</div>
}
title={<DialogTitle className="text-center text-xl font-semibold tracking-tight">Update didnt finish</DialogTitle>}
>
<Button className="font-semibold" onClick={onRetry} size="lg">
Try again
</Button>
<Button onClick={onDismiss} variant="text">
Not now
</Button>
</ErrorState>
)
}