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:
@@ -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>
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 didn’t 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 didn’t finish</DialogTitle>}
|
||||
>
|
||||
<Button className="font-semibold" onClick={onRetry} size="lg">
|
||||
Try again
|
||||
</Button>
|
||||
<Button onClick={onDismiss} variant="text">
|
||||
Not now
|
||||
</Button>
|
||||
</ErrorState>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user