diff --git a/apps/desktop/index.html b/apps/desktop/index.html index db989ee8d2..4478787d75 100644 --- a/apps/desktop/index.html +++ b/apps/desktop/index.html @@ -8,7 +8,7 @@ Hermes -
+
diff --git a/apps/desktop/package.json b/apps/desktop/package.json index 4d09d4a868..66ff9d03a1 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -47,17 +47,21 @@ "@chenglou/pretext": "^0.0.6", "@hermes/shared": "file:../shared", "@nanostores/react": "^1.1.0", + "@nous-research/ui": "^0.12.0", "@radix-ui/react-slot": "^1.2.4", "@streamdown/code": "^1.1.1", "@tabler/icons-react": "^3.41.1", + "@tailwindcss/typography": "^0.5.19", "@tailwindcss/vite": "^4.2.4", "@tanstack/react-query": "^5.100.6", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "cmdk": "^1.1.1", "ignore": "^7.0.5", + "leva": "^0.10.1", "liquid-glass-react": "^1.1.1", "lucide-react": "^0.577.0", + "motion": "^12.38.0", "nanostores": "^1.3.0", "radix-ui": "^1.4.3", "react": "^19.2.5", diff --git a/apps/desktop/public/ds-assets/filler-bg0.jpg b/apps/desktop/public/ds-assets/filler-bg0.jpg new file mode 100644 index 0000000000..4909694173 Binary files /dev/null and b/apps/desktop/public/ds-assets/filler-bg0.jpg differ diff --git a/apps/desktop/src/app/chat/composer/completion-drawer.tsx b/apps/desktop/src/app/chat/composer/completion-drawer.tsx index d0a9f47d0f..34470fe63e 100644 --- a/apps/desktop/src/app/chat/composer/completion-drawer.tsx +++ b/apps/desktop/src/app/chat/composer/completion-drawer.tsx @@ -5,7 +5,7 @@ import type { ReactNode } from 'react' export const COMPLETION_DRAWER_CLASS = [ 'absolute inset-x-0 bottom-[calc(100%-0.5rem)] z-50', 'max-h-[min(23rem,calc(100vh-8rem))] overflow-y-auto overscroll-contain', - 'rounded-t-(--composer-active-radius) border border-b-0', + 'border border-b-0', 'border-[color-mix(in_srgb,var(--dt-ring)_45%,transparent)]', 'bg-[color-mix(in_srgb,var(--dt-popover)_96%,transparent)]', 'px-1.5 pb-3 pt-1.5 text-popover-foreground', diff --git a/apps/desktop/src/app/chat/composer/hooks/use-composer-glass-tweaks.ts b/apps/desktop/src/app/chat/composer/hooks/use-composer-glass-tweaks.ts deleted file mode 100644 index 32f6789b96..0000000000 --- a/apps/desktop/src/app/chat/composer/hooks/use-composer-glass-tweaks.ts +++ /dev/null @@ -1,35 +0,0 @@ -export type ComposerLiquidGlassMode = 'polar' | 'prominent' | 'shader' | 'standard' - -export interface ComposerGlassTweakOutputs { - fadeBackground: string - liquid: { - aberrationIntensity: number - blurAmount: number - cornerRadius: number - displacementScale: number - elasticity: number - mode: ComposerLiquidGlassMode - saturation: number - } - liquidKey: string - showLibraryRims: boolean -} - -const COMPOSER_GLASS_TWEAKS: ComposerGlassTweakOutputs = { - fadeBackground: 'linear-gradient(to bottom, transparent, color-mix(in srgb, var(--dt-background) 10%, transparent))', - liquid: { - aberrationIntensity: 0.95, - blurAmount: 0.072, - cornerRadius: 24, - displacementScale: 46, - elasticity: 0, - mode: 'standard', - saturation: 128 - }, - liquidKey: ['standard', '0.950', '0.072', '24', '46', '0.00', '128'].join(':'), - showLibraryRims: false -} - -export function useComposerGlassTweaks(): ComposerGlassTweakOutputs { - return COMPOSER_GLASS_TWEAKS -} diff --git a/apps/desktop/src/app/chat/composer/index.tsx b/apps/desktop/src/app/chat/composer/index.tsx index e13df39b7b..a3679f5a2f 100644 --- a/apps/desktop/src/app/chat/composer/index.tsx +++ b/apps/desktop/src/app/chat/composer/index.tsx @@ -6,7 +6,6 @@ import { useStore } from '@nanostores/react' import LiquidGlass from 'liquid-glass-react' import { type ClipboardEvent, - type CSSProperties, type FormEvent, type KeyboardEvent, type DragEvent as ReactDragEvent, @@ -34,7 +33,6 @@ import { ContextMenu } from './context-menu' import { ComposerControls } from './controls' import { HelpHint } from './help-hint' import { useAtCompletions } from './hooks/use-at-completions' -import { useComposerGlassTweaks } from './hooks/use-composer-glass-tweaks' import { useSlashCompletions } from './hooks/use-slash-completions' import { useVoiceConversation } from './hooks/use-voice-conversation' import { useVoiceRecorder } from './hooks/use-voice-recorder' @@ -52,7 +50,7 @@ import { UrlDialog } from './url-dialog' import { VoiceActivity, VoicePlaybackActivity } from './voice-activity' const COMPOSER_SHELL_CLASS = - 'group/composer absolute bottom-0 left-1/2 z-30 max-w-full pt-2 pb-[var(--composer-shell-pad-block-end)]' + 'group/composer absolute bottom-0 left-1/2 z-30 w-[min(var(--composer-width),calc(100%-2rem))] max-w-full -translate-x-1/2 rounded-2xl pt-2 pb-[var(--composer-shell-pad-block-end)]' function extractClipboardImageBlobs(clipboard: DataTransfer): Blob[] { const blobs: Blob[] = [] @@ -110,17 +108,13 @@ function extractClipboardImageBlobs(clipboard: DataTransfer): Blob[] { return blobs } -// Below this composer width the input gets cramped — drop controls onto a second row. -// Floor matches the natural min-content of contextMenu + 8rem input + controls + gaps; -// going higher caused unwanted stacking on empty state when the parent transiently -// reported a tiny width before layout settled. const COMPOSER_STACK_BREAKPOINT_PX = 320 const COMPOSER_SCROLLED_DIM_CLASS = 'opacity-30 group-hover/composer:opacity-100 group-focus-within/composer:opacity-100' const COMPOSER_FROST_CLASS = cn( - 'pointer-events-none absolute inset-0 -z-10 rounded-(--composer-active-radius)', + 'pointer-events-none absolute inset-0 -z-10 rounded-[inherit]', 'bg-[color-mix(in_srgb,var(--dt-card)_72%,transparent)]', 'backdrop-blur-[0.75rem] backdrop-saturate-[1.12]', '[-webkit-backdrop-filter:blur(0.75rem)_saturate(1.12)]', @@ -131,6 +125,21 @@ const COMPOSER_FROST_CLASS = cn( 'group-focus-within/composer:[-webkit-backdrop-filter:none]' ) +const COMPOSER_GLASS = { + fadeBackground: 'linear-gradient(to bottom, transparent, color-mix(in srgb, var(--dt-background) 10%, transparent))', + liquidKey: ['standard', '0.950', '0.072', '0', '46', '0.00', '128'].join(':'), + showLibraryRims: false, + liquid: { + aberrationIntensity: 0.95, + blurAmount: 0.072, + cornerRadius: 0, + displacementScale: 46, + elasticity: 0, + mode: 'standard' as const, + saturation: 128 + } +} + interface TriggerState { kind: '@' | '/' query: string @@ -218,8 +227,6 @@ export function ChatBar({ const placeholder = disabled ? 'Starting Hermes…' : 'Ask anything' - const glassTweaks = useComposerGlassTweaks() - const focusInput = () => window.requestAnimationFrame(() => editorRef.current?.focus({ preventScroll: true })) useEffect(() => { @@ -266,8 +273,6 @@ export function ChatBar({ return } - // Threshold deliberately above a single rendered line + padding so font-metric - // jitter on an empty/short editor never triggers spurious expansion. const wraps = (editorRef.current?.scrollHeight ?? 0) > 56 if (draft.includes('\n') || wraps) { @@ -282,10 +287,6 @@ export function ChatBar({ return } - // No sync read: getBoundingClientRect() right after mount can return a - // transient pre-layout width that briefly flips the composer into stacked - // mode. ResizeObserver fires once on observe() with the settled width, then - // again on every actual size change. const ro = new ResizeObserver(() => { const width = el.getBoundingClientRect().width @@ -439,10 +440,6 @@ export function ChatBar({ return } - // Some clipboard sources deliver an image as a giant `data:image/...;base64,...` - // text/plain payload. Without this guard the whole base64 string would be - // inserted into the textarea (and persisted as the user message). Drop it - // outright — image pastes belong on the image-blob path above. if (DATA_IMAGE_URL_RE.test(pastedText.trim())) { event.preventDefault() @@ -460,8 +457,6 @@ export function ChatBar({ const [triggerActive, setTriggerActive] = useState(0) const [triggerItems, setTriggerItems] = useState([]) - // Try caret-anchored detection first; fall back to whole-draft so blur/select-all - // edge cases still surface the popover instead of silently closing it. const refreshTrigger = useCallback(() => { const editor = editorRef.current @@ -479,8 +474,6 @@ export function ChatBar({ const handleEditorInput = (event: FormEvent) => { const editor = event.currentTarget - // Strip Chrome's stray
when the editor is otherwise empty so :empty - // pseudo-class works for the placeholder. if (editor.childNodes.length === 1 && editor.firstChild?.nodeName === 'BR') { editor.replaceChildren() } @@ -528,8 +521,6 @@ export function ChatBar({ } const serialized = hermesDirectiveFormatter.serialize(item) - // Starters (`@file:`) drill in: insert verbatim and keep the popover live so - // the user can keep typing the path. Chips/simple refs commit and close. const starter = serialized.endsWith(':') const text = starter || serialized.endsWith(' ') ? serialized : `${serialized} ` const directive = !starter && serialized.match(/^@([^:]+):(.+)$/) @@ -545,7 +536,6 @@ export function ChatBar({ const node = range?.startContainer const offset = range?.startOffset ?? 0 - // No usable caret range — replace from the end of the draft instead. if (!sel || !range || node?.nodeType !== Node.TEXT_NODE || offset < trigger.tokenLength) { const current = composerPlainText(editor) renderComposerContents(editor, `${current.slice(0, Math.max(0, current.length - trigger.tokenLength))}${text}`) @@ -928,12 +918,6 @@ export function ChatBar({ submitDraft() }} ref={composerRef} - style={ - { - '--composer-active-radius': `${glassTweaks.liquid.cornerRadius}px`, - '--composer-glass-radius': `${glassTweaks.liquid.cornerRadius}px` - } as CSSProperties - } > {showHelpHint && } {trigger && ( @@ -947,31 +931,33 @@ export function ChatBar({ /> )} -
-
+
+
@@ -979,11 +965,11 @@ export function ChatBar({
@@ -991,14 +977,14 @@ export function ChatBar({ {dragActive && (
Drop files to attach
)}
{attachments.length > 0 && } - {/* - Single CSS Grid keeps {input} (and the contenteditable inside - it) in a stable parent across the stacked/inline toggle. - Earlier this was a JSX conditional that rendered {input} - inside two different fragments — when `stacked` flipped (e.g. - the moment text wrapped past two lines and the auto-expand - effect triggered), React reconciled them as different - positions and unmounted/remounted the contenteditable. The - fresh mount started empty and any in-flight characters were - lost. Switching the layout via grid-template-areas keeps the - exact same DOM nodes and lets the browser handle the reflow. - */}
-
+
diff --git a/apps/desktop/src/app/chat/composer/liquid-glass-overrides.css b/apps/desktop/src/app/chat/composer/liquid-glass-overrides.css index 13ecb10d93..bb493a4789 100644 --- a/apps/desktop/src/app/chat/composer/liquid-glass-overrides.css +++ b/apps/desktop/src/app/chat/composer/liquid-glass-overrides.css @@ -1,5 +1,3 @@ -/* liquid-glass-react emits helper nodes that ignore local utility classes. Keep - these overrides scoped by class so the rest of app styling stays utility-first. */ .composer-liquid-shell-wrap > div:not(.composer-liquid-shell) { position: absolute !important; inset: 0 !important; @@ -9,6 +7,7 @@ height: 100% !important; transform: none !important; margin: 0 !important; + border-radius: inherit !important; box-sizing: border-box; } @@ -25,6 +24,7 @@ height: 100% !important; transform: none !important; margin: 0 !important; + border-radius: inherit !important; box-sizing: border-box; display: block !important; } @@ -34,6 +34,7 @@ top: 0 !important; left: 0 !important; transform: none !important; + border-radius: inherit !important; transition: none !important; } @@ -45,6 +46,7 @@ width: 100% !important; height: 100% !important; transform: none !important; + border-radius: inherit !important; } .composer-liquid-shell > .glass, @@ -56,18 +58,19 @@ width: 100% !important; height: 100% !important; transform: none !important; + border-radius: inherit !important; } .composer-liquid-shell > .glass { width: 100% !important; height: 100% !important; padding: 0 !important; - border-radius: var(--composer-glass-radius, 24px) !important; + border-radius: inherit !important; box-shadow: none !important; } .composer-liquid-shell > .glass > .glass__warp { - border-radius: var(--composer-glass-radius, 24px) !important; + border-radius: inherit !important; } .composer-liquid-shell > .glass > div { diff --git a/apps/desktop/src/app/chat/index.tsx b/apps/desktop/src/app/chat/index.tsx index f7d6dbb031..34229db109 100644 --- a/apps/desktop/src/app/chat/index.tsx +++ b/apps/desktop/src/app/chat/index.tsx @@ -45,6 +45,7 @@ import { ChatBar, ChatBarFallback } from './composer' import type { ChatBarState } from './composer/types' import type { DroppedFile } from './hooks/use-composer-actions' import { SessionActionsMenu } from './sidebar/session-actions-menu' +import { lastVisibleMessageIsUser, threadLoadingState } from './thread-loading' interface ChatViewProps extends Omit, 'onSubmit'> { gateway: HermesGateway | null @@ -69,30 +70,6 @@ interface ChatViewProps extends Omit, 'onSubmit'> { onTranscribeAudio?: (audio: Blob) => Promise } -function threadLoadingState( - loadingSession: boolean, - busy: boolean, - awaitingResponse: boolean, - lastMessageIsUser: boolean -) { - if (loadingSession) { - return 'session' - } - - // Only show the response spinner when we're actually waiting for an - // assistant reply to a user message. Previously any `busy && awaiting` - // window showed the spinner — including the brief gateway-hydration blip - // right after a session resume, which produced a visible flicker chain: - // session spinner → response spinner → content. - // Gating on `lastMessageIsUser` means the spinner only appears when the - // user actually just sent something and there's no assistant reply yet. - if (busy && awaitingResponse && lastMessageIsUser) { - return 'response' - } - - return undefined -} - export function ChatView({ className, gateway, @@ -145,10 +122,9 @@ export function ChatView({ // resumed yet. Once `activeSessionId` is set (runtime has resumed), the // session exists — even if it has zero messages (a brand-new routed // session). The flicker where `busy` flips true briefly during hydrate - // is handled by `threadLoadingState`'s `lastMessageIsUser` gate. + // is handled by `threadLoadingState`'s last-visible-user gate. const loadingSession = isRoutedSessionView && messages.length === 0 && !activeSessionId - const lastMessageIsUser = messages.at(-1)?.role === 'user' - const threadLoading = threadLoadingState(loadingSession, busy, awaitingResponse, lastMessageIsUser) + const threadLoading = threadLoadingState(loadingSession, busy, awaitingResponse, lastVisibleMessageIsUser(messages)) const showChatBar = !loadingSession const threadKey = selectedSessionId || activeSessionId || (isRoutedSessionView ? location.pathname : 'new') const title = activeStoredSession ? sessionTitle(activeStoredSession) : '' 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 14962487a6..889f3df557 100644 --- a/apps/desktop/src/app/chat/right-rail/preview-pane.tsx +++ b/apps/desktop/src/app/chat/right-rail/preview-pane.tsx @@ -1351,7 +1351,7 @@ export function PreviewPane({ } const webview = document.createElement('webview') as PreviewWebview - webview.className = 'hermes-preview-webview h-full w-full flex-1 bg-background' + webview.className = 'flex h-full w-full flex-1 bg-background' webview.setAttribute('partition', 'persist:hermes-preview') webview.setAttribute('src', target.url) webview.setAttribute('webpreferences', 'contextIsolation=yes,nodeIntegration=no,sandbox=yes') diff --git a/apps/desktop/src/app/chat/sidebar/index.tsx b/apps/desktop/src/app/chat/sidebar/index.tsx index 3547f35b59..f51a693844 100644 --- a/apps/desktop/src/app/chat/sidebar/index.tsx +++ b/apps/desktop/src/app/chat/sidebar/index.tsx @@ -50,7 +50,7 @@ const sidebarNavItemClass = 'flex h-7 w-full justify-start gap-2 rounded-md border border-transparent px-2 text-left text-sm font-medium text-muted-foreground transition-colors duration-300 ease-out hover:border-[color-mix(in_srgb,var(--dt-border)_60%,transparent)] hover:bg-[color-mix(in_srgb,var(--dt-card)_78%,transparent)] hover:text-foreground hover:transition-none' const sidebarNavItemActiveClass = - 'border-[color-mix(in_srgb,var(--dt-primary)_34%,var(--dt-border))] bg-[color-mix(in_srgb,var(--dt-primary)_10%,var(--dt-card))] text-foreground shadow-[inset_0_0.0625rem_0_color-mix(in_srgb,white_40%,transparent)]' + 'border-[color-mix(in_srgb,var(--dt-midground)_34%,var(--dt-border))] bg-[color-mix(in_srgb,var(--dt-midground)_10%,var(--dt-card))] text-foreground shadow-[inset_0_0.0625rem_0_color-mix(in_srgb,white_40%,transparent)]' interface ChatSidebarProps extends React.ComponentProps { currentView: AppView @@ -114,7 +114,8 @@ export function ChatSidebar({ > - + + @@ -238,16 +239,22 @@ interface SidebarSectionHeaderProps extends React.ComponentProps<'div'> { function SidebarSectionHeader({ label, open, onToggle, action }: SidebarSectionHeaderProps) { return (
- + diff --git a/apps/desktop/src/app/chat/sidebar/session-row.tsx b/apps/desktop/src/app/chat/sidebar/session-row.tsx index 323b2f29c8..33db6d2c64 100644 --- a/apps/desktop/src/app/chat/sidebar/session-row.tsx +++ b/apps/desktop/src/app/chat/sidebar/session-row.tsx @@ -10,7 +10,7 @@ import { cn } from '@/lib/utils' import { SessionActionsMenu } from './session-actions-menu' export const sidebarSessionRowClass = - 'group relative grid min-h-7 grid-cols-[minmax(0,1fr)_1.5rem] items-center rounded-lg transition-colors duration-300 ease-out hover:bg-accent hover:transition-none' + 'group relative grid min-h-7 grid-cols-[minmax(0,1fr)_1.5rem] items-center rounded-lg transition-colors duration-300 ease-out hover:bg-[color-mix(in_srgb,var(--dt-midground)_8%,transparent)] hover:transition-none' export const sidebarSessionFadeClass = 'after:pointer-events-none after:absolute after:inset-y-0 after:right-0 after:z-1 after:w-18 after:rounded-[inherit] after:bg-linear-to-r after:from-transparent after:via-[color-mix(in_srgb,var(--dt-sidebar-bg)_78%,transparent)] after:to-[color-mix(in_srgb,var(--dt-sidebar-bg)_96%,transparent)] after:opacity-0 after:transition-opacity after:duration-200 after:ease-out hover:after:opacity-100 focus-within:after:opacity-100' @@ -46,6 +46,7 @@ export function SidebarSessionRow({ )} data-working={isWorking ? 'true' : undefined} > + {isWorking &&