feat: theme changes, composer tweaks, in app update ux, finesse
This commit is contained in:
@@ -1,5 +1,6 @@
|
||||
import { useStore } from '@nanostores/react'
|
||||
import type { CSSProperties, ReactNode } from 'react'
|
||||
import { useSyncExternalStore } from 'react'
|
||||
|
||||
import { Backdrop } from '@/components/Backdrop'
|
||||
import { PaneShell } from '@/components/pane-shell'
|
||||
@@ -28,6 +29,21 @@ interface AppShellProps {
|
||||
titlebarTools?: readonly TitlebarTool[]
|
||||
}
|
||||
|
||||
// Renderer-side fallback so layout snaps even when the main-process fullscreen event
|
||||
// hasn't landed yet (e.g. dev reloads, before the IPC bridge is wired).
|
||||
function subscribeWindowSize(cb: () => void) {
|
||||
window.addEventListener('resize', cb)
|
||||
window.addEventListener('fullscreenchange', cb)
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('resize', cb)
|
||||
window.removeEventListener('fullscreenchange', cb)
|
||||
}
|
||||
}
|
||||
|
||||
const viewportIsFullscreen = () =>
|
||||
window.innerWidth >= window.screen.width && window.innerHeight >= window.screen.height
|
||||
|
||||
export function AppShell({
|
||||
children,
|
||||
leftStatusbarItems,
|
||||
@@ -41,9 +57,9 @@ export function AppShell({
|
||||
const fileBrowserOpen = useStore($fileBrowserOpen)
|
||||
const fileBrowserWidthOverride = useStore($paneWidthOverride(FILE_BROWSER_PANE_ID))
|
||||
const connection = useStore($connection)
|
||||
|
||||
const titlebarControls = titlebarControlsPosition(connection?.windowButtonPosition)
|
||||
|
||||
const viewportFullscreen = useSyncExternalStore(subscribeWindowSize, viewportIsFullscreen, () => false)
|
||||
const isFullscreen = Boolean(connection?.isFullscreen) || viewportFullscreen
|
||||
const titlebarControls = titlebarControlsPosition(connection?.windowButtonPosition, isFullscreen)
|
||||
const titlebarContentInset = sidebarOpen
|
||||
? 0
|
||||
: titlebarControls.left + TITLEBAR_HEIGHT + Math.round(TITLEBAR_HEIGHT / 2)
|
||||
@@ -101,7 +117,7 @@ export function AppShell({
|
||||
<TitlebarControls leftTools={leftTitlebarTools} onOpenSettings={onOpenSettings} tools={titlebarTools} />
|
||||
|
||||
<Backdrop />
|
||||
<main className="relative z-[3] flex h-screen w-full flex-col overflow-hidden pr-0.75 pb-0.75 pt-0.75 transition-none">
|
||||
<main className="relative z-3 flex h-screen w-full flex-col overflow-hidden pr-0.75 pt-0.75 transition-none">
|
||||
<PaneShell className="min-h-0 flex-1">
|
||||
<div
|
||||
aria-hidden="true"
|
||||
|
||||
@@ -4,7 +4,7 @@ import { useCallback, useMemo, useState } from 'react'
|
||||
import type { CommandCenterSection } from '@/app/command-center'
|
||||
import { GatewayMenuPanel } from '@/app/shell/gateway-menu-panel'
|
||||
import { restartGateway } from '@/hermes'
|
||||
import { Activity, AlertCircle, Command, Cpu, FolderOpen, GitBranch, Loader2, Sparkles } from '@/lib/icons'
|
||||
import { Activity, AlertCircle, Command, Cpu, FolderOpen, GitBranch, Hash, Loader2, Sparkles } from '@/lib/icons'
|
||||
import { compactPath, contextBarLabel, LiveDuration, usageContextLabel } from '@/lib/statusbar'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { $desktopActionTasks } from '@/store/activity'
|
||||
@@ -22,6 +22,7 @@ import {
|
||||
$workingSessionIds,
|
||||
setModelPickerOpen
|
||||
} from '@/store/session'
|
||||
import { $desktopVersion, $updateApply, $updateStatus, setUpdateOverlayOpen } from '@/store/updates'
|
||||
import type { StatusResponse } from '@/types/hermes'
|
||||
|
||||
import type { StatusbarItem } from '../statusbar-controls'
|
||||
@@ -62,6 +63,9 @@ export function useStatusbarItems({
|
||||
const sessionStartedAt = useStore($sessionStartedAt)
|
||||
const turnStartedAt = useStore($turnStartedAt)
|
||||
const workingSessionIds = useStore($workingSessionIds)
|
||||
const updateStatus = useStore($updateStatus)
|
||||
const updateApply = useStore($updateApply)
|
||||
const desktopVersion = useStore($desktopVersion)
|
||||
|
||||
const contextUsage = useMemo(() => usageContextLabel(currentUsage), [currentUsage])
|
||||
const contextBar = useMemo(() => contextBarLabel(currentUsage), [currentUsage])
|
||||
@@ -114,10 +118,54 @@ export function useStatusbarItems({
|
||||
|
||||
const gatewayUp = Boolean(statusSnapshot?.gateway_running)
|
||||
|
||||
const versionItem = useMemo<StatusbarItem>(() => {
|
||||
const appVersion = desktopVersion?.appVersion
|
||||
const sha = updateStatus?.currentSha?.slice(0, 7) ?? null
|
||||
const behind = updateStatus?.behind ?? 0
|
||||
const applying = updateApply.applying || updateApply.stage === 'restart'
|
||||
const base = appVersion ? `v${appVersion}` : sha ?? 'unknown'
|
||||
const behindHint = !applying && behind > 0 ? ` (+${behind})` : ''
|
||||
const label = applying
|
||||
? updateApply.stage === 'restart'
|
||||
? `${base} · restart`
|
||||
: `${base} · update`
|
||||
: `${base}${behindHint}`
|
||||
|
||||
const tooltip = [
|
||||
applying ? updateApply.message || 'Update in progress' : null,
|
||||
!applying && behind > 0 && `${behind} commit${behind === 1 ? '' : 's'} behind ${updateStatus?.branch ?? '…'}`,
|
||||
appVersion && `Hermes Desktop v${appVersion}`,
|
||||
sha && `commit ${sha}`,
|
||||
updateStatus?.branch && `branch ${updateStatus.branch}`
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' · ')
|
||||
|
||||
return {
|
||||
className: !applying && behind > 0 ? 'text-primary hover:text-primary' : undefined,
|
||||
detail: appVersion && sha && !applying ? sha : undefined,
|
||||
hidden: !appVersion && !sha,
|
||||
icon: applying ? <Loader2 className="size-3 animate-spin" /> : <Hash className="size-3" />,
|
||||
id: 'version',
|
||||
label,
|
||||
onSelect: () => setUpdateOverlayOpen(true),
|
||||
title: tooltip || undefined,
|
||||
variant: 'action'
|
||||
}
|
||||
}, [
|
||||
desktopVersion?.appVersion,
|
||||
updateApply.applying,
|
||||
updateApply.message,
|
||||
updateApply.stage,
|
||||
updateStatus?.behind,
|
||||
updateStatus?.branch,
|
||||
updateStatus?.currentSha
|
||||
])
|
||||
|
||||
const coreLeftStatusbarItems = useMemo<readonly StatusbarItem[]>(
|
||||
() => [
|
||||
{
|
||||
className: `h-6 w-6 justify-center px-0${commandCenterOpen ? ' bg-accent/55 text-foreground' : ''}`,
|
||||
className: `w-7 justify-center px-0${commandCenterOpen ? ' bg-accent/55 text-foreground' : ''}`,
|
||||
icon: <Command className="size-3.5" />,
|
||||
id: 'command-center',
|
||||
onSelect: toggleCommandCenter,
|
||||
@@ -220,7 +268,8 @@ export function useStatusbarItems({
|
||||
label: currentBranch,
|
||||
title: currentBranch ? `Current branch: ${currentBranch}` : undefined,
|
||||
variant: 'text'
|
||||
}
|
||||
},
|
||||
versionItem
|
||||
],
|
||||
[
|
||||
browseSessionCwd,
|
||||
@@ -232,7 +281,8 @@ export function useStatusbarItems({
|
||||
currentModel,
|
||||
currentProvider,
|
||||
sessionStartedAt,
|
||||
turnStartedAt
|
||||
turnStartedAt,
|
||||
versionItem
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
import type * as React from 'react'
|
||||
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
interface SidebarPanelLabelProps extends React.ComponentProps<'span'> {
|
||||
dotClassName?: string
|
||||
}
|
||||
|
||||
export function SidebarPanelLabel({ children, className, dotClassName, ...props }: SidebarPanelLabelProps) {
|
||||
return (
|
||||
<span
|
||||
className={cn(
|
||||
'flex min-w-0 items-center gap-2 text-[0.64rem] font-semibold uppercase tracking-[0.16em] text-sidebar-foreground/72',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<span aria-hidden="true" className={cn('dither inline-block size-2 shrink-0 rounded-[1px]', dotClassName)} />
|
||||
<span className="min-w-0 truncate leading-none">{children}</span>
|
||||
</span>
|
||||
)
|
||||
}
|
||||
@@ -44,7 +44,7 @@ interface StatusbarControlsProps extends ComponentProps<'footer'> {
|
||||
}
|
||||
|
||||
const statusbarItemClass =
|
||||
'inline-flex h-5 items-center gap-1 rounded px-1 text-[0.68rem] text-muted-foreground/95 transition-colors hover:bg-[color-mix(in_srgb,var(--dt-midground)_10%,transparent)] hover:text-foreground disabled:cursor-default disabled:opacity-45'
|
||||
'inline-flex h-full cursor-pointer items-center gap-1 rounded-none px-1.5 text-[0.68rem] text-muted-foreground/95 transition-colors hover:bg-(--chrome-action-hover) hover:text-foreground disabled:cursor-default disabled:opacity-45'
|
||||
|
||||
export function StatusbarControls({ className, leftItems = [], items = [], ...props }: StatusbarControlsProps) {
|
||||
const navigate = useNavigate()
|
||||
@@ -52,19 +52,19 @@ export function StatusbarControls({ className, leftItems = [], items = [], ...pr
|
||||
return (
|
||||
<footer
|
||||
className={cn(
|
||||
'flex h-7 shrink-0 items-center justify-between gap-2 border-t border-border/55 bg-[color-mix(in_srgb,var(--dt-muted)_45%,var(--dt-card))] px-2.5 py-1 text-muted-foreground/95 [-webkit-app-region:no-drag]',
|
||||
'flex h-7 shrink-0 items-stretch justify-between gap-2 border-t border-border/55 bg-[color-mix(in_srgb,var(--dt-muted)_45%,var(--dt-card))] px-1 py-0 text-muted-foreground/95 [-webkit-app-region:no-drag]',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<div className="flex min-w-0 items-center gap-0.5 overflow-x-auto">
|
||||
<div className="flex min-w-0 items-stretch gap-0.5 overflow-x-auto">
|
||||
{leftItems
|
||||
.filter(item => !item.hidden)
|
||||
.map(item => (
|
||||
<StatusbarItemView item={item} key={`left:${item.id}`} navigate={navigate} />
|
||||
))}
|
||||
</div>
|
||||
<div className="flex min-w-0 items-center gap-0.5 overflow-x-auto">
|
||||
<div className="flex min-w-0 items-stretch gap-0.5 overflow-x-auto">
|
||||
{items
|
||||
.filter(item => !item.hidden)
|
||||
.map(item => (
|
||||
@@ -150,7 +150,7 @@ function StatusbarItemView({ item, navigate }: { item: StatusbarItem; navigate:
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'inline-flex h-5 items-center gap-1 px-0.5 text-[0.68rem] text-muted-foreground/90',
|
||||
'inline-flex h-full items-center gap-1 px-1.5 text-[0.68rem] text-muted-foreground/90',
|
||||
item.className
|
||||
)}
|
||||
>
|
||||
|
||||
@@ -101,7 +101,7 @@ export function TitlebarControls({ leftTools = [], tools = [], onOpenSettings }:
|
||||
<>
|
||||
<div
|
||||
aria-label="Window controls"
|
||||
className="fixed left-(--titlebar-controls-left) top-(--titlebar-controls-top) z-70 flex translate-y-[2px] flex-row items-center gap-px pointer-events-auto select-none [-webkit-app-region:no-drag]"
|
||||
className="fixed left-(--titlebar-controls-left) top-(--titlebar-controls-top) z-70 flex translate-y-[2px] flex-row items-center gap-x-1 pointer-events-auto select-none [-webkit-app-region:no-drag]"
|
||||
>
|
||||
{leftToolbarTools
|
||||
.filter(tool => !tool.hidden)
|
||||
@@ -121,7 +121,7 @@ export function TitlebarControls({ leftTools = [], tools = [], onOpenSettings }:
|
||||
{visiblePaneTools.length > 0 && (
|
||||
<div
|
||||
aria-label="Pane controls"
|
||||
className="fixed top-(--titlebar-controls-top) right-[calc(var(--titlebar-tools-right)+var(--shell-preview-toolbar-gap,0))] z-70 flex flex-row items-center gap-px pointer-events-auto select-none [-webkit-app-region:no-drag]"
|
||||
className="fixed top-(--titlebar-controls-top) right-[calc(var(--titlebar-tools-right)+var(--shell-preview-toolbar-gap,0))] z-70 flex flex-row items-center gap-x-1 pointer-events-auto select-none [-webkit-app-region:no-drag]"
|
||||
>
|
||||
{visiblePaneTools.map(tool => (
|
||||
<TitlebarToolButton key={tool.id} navigate={navigate} tool={tool} />
|
||||
@@ -131,7 +131,7 @@ export function TitlebarControls({ leftTools = [], tools = [], onOpenSettings }:
|
||||
|
||||
<div
|
||||
aria-label="App controls"
|
||||
className="fixed right-(--titlebar-tools-right) top-(--titlebar-controls-top) z-70 flex flex-row items-center justify-end gap-px pointer-events-auto select-none [-webkit-app-region:no-drag]"
|
||||
className="fixed right-(--titlebar-tools-right) top-(--titlebar-controls-top) z-70 flex flex-row items-center justify-end gap-x-1 pointer-events-auto select-none [-webkit-app-region:no-drag]"
|
||||
>
|
||||
{visibleSystemTools.map(tool => (
|
||||
<TitlebarToolButton key={tool.id} navigate={navigate} tool={tool} />
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { TITLEBAR_CONTROL_OFFSET_X, titlebarControlsPosition } from './titlebar'
|
||||
|
||||
describe('titlebarControlsPosition', () => {
|
||||
it('offsets controls from visible traffic lights', () => {
|
||||
expect(titlebarControlsPosition({ x: 24, y: 10 }).left).toBe(24 + TITLEBAR_CONTROL_OFFSET_X)
|
||||
})
|
||||
|
||||
it('pins to the edge when macOS fullscreen hides traffic lights', () => {
|
||||
expect(titlebarControlsPosition({ x: 24, y: 10 }, true).left).toBe(14)
|
||||
})
|
||||
|
||||
it('falls back to the default offset when traffic-light coords are unavailable', () => {
|
||||
expect(titlebarControlsPosition(undefined, true).left).toBe(24 + TITLEBAR_CONTROL_OFFSET_X)
|
||||
})
|
||||
})
|
||||
@@ -21,11 +21,16 @@ export const titlebarHeaderBaseClass =
|
||||
export const titlebarHeaderShadowClass =
|
||||
"shadow-header after:pointer-events-none after:absolute after:left-0 after:right-0 after:top-full after:h-10 after:bg-linear-to-b after:from-background after:via-background/80 after:to-transparent after:content-['']"
|
||||
|
||||
export function titlebarControlsPosition(windowButtonPosition: HermesConnection['windowButtonPosition'] | undefined) {
|
||||
const position = windowButtonPosition || WINDOW_BUTTON_FALLBACK
|
||||
export function titlebarControlsPosition(
|
||||
windowButtonPosition: HermesConnection['windowButtonPosition'] | undefined,
|
||||
isFullscreen = false
|
||||
) {
|
||||
const top = Math.max(0, TITLEBAR_CONTROLS_TOP)
|
||||
|
||||
return {
|
||||
left: position.x + TITLEBAR_CONTROL_OFFSET_X,
|
||||
top: Math.max(0, TITLEBAR_CONTROLS_TOP)
|
||||
// macOS hides traffic lights in fullscreen — pin to the edge instead of reserving their slot.
|
||||
if (windowButtonPosition && isFullscreen) {
|
||||
return { left: 14, top }
|
||||
}
|
||||
|
||||
return { left: (windowButtonPosition ?? WINDOW_BUTTON_FALLBACK).x + TITLEBAR_CONTROL_OFFSET_X, top }
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user