feat: better pane management and toolbar api
This commit is contained in:
@@ -1,5 +1,5 @@
|
||||
import { Copy, ExternalLink, FileImage, FileText, FolderOpen, Layers3, Link2, RefreshCw, Search, X } from 'lucide-react'
|
||||
import type { ReactNode } from 'react'
|
||||
import type * as React from 'react'
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
|
||||
@@ -23,7 +23,8 @@ import { notify, notifyError } from '@/store/notifications'
|
||||
import type { SessionInfo, SessionMessage } from '@/types/hermes'
|
||||
|
||||
import { sessionRoute } from '../routes'
|
||||
import { TITLEBAR_ICON_SIZE, titlebarButtonClass, titlebarHeaderBaseClass } from '../shell/titlebar'
|
||||
import { titlebarHeaderBaseClass } from '../shell/titlebar'
|
||||
import type { SetTitlebarToolGroup } from '../shell/titlebar-controls'
|
||||
|
||||
type ArtifactKind = 'image' | 'file' | 'link'
|
||||
|
||||
@@ -340,10 +341,10 @@ function paginationItems(page: number, pageCount: number): Array<number | 'ellip
|
||||
}
|
||||
|
||||
interface ArtifactsViewProps extends React.ComponentProps<'section'> {
|
||||
setTitlebarActions?: (actions: ReactNode | null) => void
|
||||
setTitlebarToolGroup?: SetTitlebarToolGroup
|
||||
}
|
||||
|
||||
export function ArtifactsView({ setTitlebarActions, ...props }: ArtifactsViewProps) {
|
||||
export function ArtifactsView({ setTitlebarToolGroup, ...props }: ArtifactsViewProps) {
|
||||
const navigate = useNavigate()
|
||||
const [artifacts, setArtifacts] = useState<ArtifactRecord[] | null>(null)
|
||||
const [query, setQuery] = useState('')
|
||||
@@ -384,24 +385,22 @@ export function ArtifactsView({ setTitlebarActions, ...props }: ArtifactsViewPro
|
||||
}, [refreshArtifacts])
|
||||
|
||||
useEffect(() => {
|
||||
if (!setTitlebarActions) {
|
||||
if (!setTitlebarToolGroup) {
|
||||
return
|
||||
}
|
||||
|
||||
setTitlebarActions(
|
||||
<button
|
||||
aria-label={refreshing ? 'Refreshing artifacts' : 'Refresh artifacts'}
|
||||
className={cn(titlebarButtonClass, 'grid place-items-center bg-transparent')}
|
||||
disabled={refreshing}
|
||||
onClick={() => void refreshArtifacts()}
|
||||
type="button"
|
||||
>
|
||||
<RefreshCw className={cn(refreshing && 'animate-spin')} size={TITLEBAR_ICON_SIZE} />
|
||||
</button>
|
||||
)
|
||||
setTitlebarToolGroup('artifacts', [
|
||||
{
|
||||
disabled: refreshing,
|
||||
icon: <RefreshCw className={cn(refreshing && 'animate-spin')} />,
|
||||
id: 'refresh-artifacts',
|
||||
label: refreshing ? 'Refreshing artifacts' : 'Refresh artifacts',
|
||||
onSelect: () => void refreshArtifacts()
|
||||
}
|
||||
])
|
||||
|
||||
return () => setTitlebarActions(null)
|
||||
}, [refreshArtifacts, refreshing, setTitlebarActions])
|
||||
return () => setTitlebarToolGroup('artifacts', [])
|
||||
}, [refreshArtifacts, refreshing, setTitlebarToolGroup])
|
||||
|
||||
useEffect(() => {
|
||||
setImagePage(1)
|
||||
@@ -514,8 +513,8 @@ export function ArtifactsView({ setTitlebarActions, ...props }: ArtifactsViewPro
|
||||
className="flex h-[calc(100vh-0.375rem)] min-w-0 flex-col overflow-hidden rounded-[0.9375rem] bg-background"
|
||||
>
|
||||
<header className={titlebarHeaderBaseClass}>
|
||||
<h2 className="text-base font-semibold leading-none tracking-tight">Artifacts</h2>
|
||||
<span className="text-xs text-muted-foreground">{counts.all} found</span>
|
||||
<h2 className="pointer-events-auto text-base font-semibold leading-none tracking-tight">Artifacts</h2>
|
||||
<span className="pointer-events-auto text-xs text-muted-foreground">{counts.all} found</span>
|
||||
</header>
|
||||
|
||||
<div className="min-h-0 flex-1 overflow-hidden rounded-[1.0625rem] border border-border/50 bg-background/85">
|
||||
|
||||
@@ -40,11 +40,12 @@ import type { ModelOptionsResponse } from '@/types/hermes'
|
||||
|
||||
import { routeSessionId } from '../routes'
|
||||
import { titlebarHeaderBaseClass, titlebarHeaderShadowClass } from '../shell/titlebar'
|
||||
import type { SetTitlebarToolGroup } from '../shell/titlebar-controls'
|
||||
|
||||
import { ChatBar, ChatBarFallback } from './composer'
|
||||
import type { ChatBarState } from './composer/types'
|
||||
import type { DroppedFile } from './hooks/use-composer-actions'
|
||||
import { ChatRightRail } from './right-rail'
|
||||
import { ChatPreviewRail, ChatRightRail } from './right-rail'
|
||||
import { SessionActionsMenu } from './sidebar/session-actions-menu'
|
||||
|
||||
interface ChatViewProps extends Omit<React.ComponentProps<'div'>, 'onSubmit'> {
|
||||
@@ -72,6 +73,7 @@ interface ChatViewProps extends Omit<React.ComponentProps<'div'>, 'onSubmit'> {
|
||||
onEdit: (message: AppendMessage) => Promise<void>
|
||||
onReload: (parentId: string | null) => Promise<void>
|
||||
onTranscribeAudio?: (audio: Blob) => Promise<string>
|
||||
setTitlebarToolGroup?: SetTitlebarToolGroup
|
||||
}
|
||||
|
||||
function threadLoadingState(
|
||||
@@ -123,7 +125,8 @@ export function ChatView({
|
||||
onThreadMessagesChange,
|
||||
onEdit,
|
||||
onReload,
|
||||
onTranscribeAudio
|
||||
onTranscribeAudio,
|
||||
setTitlebarToolGroup
|
||||
}: ChatViewProps) {
|
||||
const location = useLocation()
|
||||
const activeSessionId = useStore($activeSessionId)
|
||||
@@ -255,7 +258,7 @@ export function ChatView({
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className={cn('relative flex h-[calc(100vh-0.375rem)] min-w-0 flex-col overflow-hidden rounded-[0.9375rem] bg-transparent', className)}>
|
||||
<div className={cn('relative col-start-2 col-end-3 row-start-1 flex h-[calc(100vh-0.375rem)] min-w-0 flex-col overflow-hidden rounded-[0.9375rem] bg-transparent', className)}>
|
||||
<header className={cn(titlebarHeaderBaseClass, isRoutedSessionView && titlebarHeaderShadowClass)}>
|
||||
<div className="min-w-0 flex-1">
|
||||
{title && (
|
||||
@@ -269,7 +272,7 @@ export function ChatView({
|
||||
title={title}
|
||||
>
|
||||
<Button
|
||||
className="h-7 min-w-0 gap-1.5 rounded-lg px-1 py-0 text-foreground hover:bg-accent/70 data-[state=open]:bg-accent/70 [-webkit-app-region:no-drag]"
|
||||
className="pointer-events-auto h-7 min-w-0 gap-1.5 rounded-lg px-1 py-0 text-foreground hover:bg-accent/70 data-[state=open]:bg-accent/70 [-webkit-app-region:no-drag]"
|
||||
type="button"
|
||||
variant="ghost"
|
||||
>
|
||||
@@ -283,7 +286,7 @@ export function ChatView({
|
||||
|
||||
<NotificationStack />
|
||||
|
||||
<div className="relative min-h-0 flex-1 overflow-hidden rounded-[1.0625rem] bg-transparent">
|
||||
<div className="relative min-h-0 max-w-full flex-1 overflow-hidden rounded-[1.0625rem] bg-transparent contain-[layout_paint]">
|
||||
<AssistantRuntimeProvider runtime={runtime}>
|
||||
<Thread
|
||||
intro={showIntro ? { personality: introPersonality, seed: introSeed } : undefined}
|
||||
@@ -321,6 +324,7 @@ export function ChatView({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ChatPreviewRail setTitlebarToolGroup={setTitlebarToolGroup} />
|
||||
<ChatRightRail
|
||||
onBrowseCwd={onBrowseCwd}
|
||||
onChangeCwd={onChangeCwd}
|
||||
|
||||
@@ -2,8 +2,10 @@ import { useStore } from '@nanostores/react'
|
||||
import type * as React from 'react'
|
||||
|
||||
import { SESSION_INSPECTOR_WIDTH, SessionInspector } from '@/components/session-inspector'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { $inspectorOpen } from '@/store/layout'
|
||||
import { $previewTarget } from '@/store/preview'
|
||||
import type { SetTitlebarToolGroup } from '@/app/shell/titlebar-controls'
|
||||
import {
|
||||
$availablePersonalities,
|
||||
$busy,
|
||||
@@ -32,7 +34,6 @@ export function ChatRightRail({
|
||||
onSelectPersonality
|
||||
}: ChatRightRailProps) {
|
||||
const inspectorOpen = useStore($inspectorOpen)
|
||||
const previewTarget = useStore($previewTarget)
|
||||
const gatewayOpen = useStore($gatewayState) === 'open'
|
||||
const busy = useStore($busy)
|
||||
const cwd = useStore($currentCwd)
|
||||
@@ -42,26 +43,44 @@ export function ChatRightRail({
|
||||
const personality = useStore($currentPersonality)
|
||||
const personalities = useStore($availablePersonalities)
|
||||
|
||||
if (previewTarget) {
|
||||
return <PreviewPane target={previewTarget} />
|
||||
return (
|
||||
<div className="col-start-4 col-end-5 row-start-1 min-w-0 overflow-hidden">
|
||||
<SessionInspector
|
||||
branch={branch}
|
||||
busy={busy}
|
||||
cwd={cwd}
|
||||
modelLabel={model ? model.split('/').pop() || model : ''}
|
||||
modelTitle={provider ? `${provider}: ${model || ''}` : model}
|
||||
onBrowseCwd={onBrowseCwd}
|
||||
onChangeCwd={onChangeCwd}
|
||||
onOpenModelPicker={gatewayOpen ? onOpenModelPicker : undefined}
|
||||
onSelectPersonality={gatewayOpen ? onSelectPersonality : undefined}
|
||||
open={inspectorOpen}
|
||||
personalities={personalities}
|
||||
personality={personality}
|
||||
providerName={provider}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function ChatPreviewRail({ setTitlebarToolGroup }: { setTitlebarToolGroup?: SetTitlebarToolGroup }) {
|
||||
const inspectorOpen = useStore($inspectorOpen)
|
||||
const previewTarget = useStore($previewTarget)
|
||||
|
||||
if (!previewTarget) {
|
||||
return <aside aria-hidden="true" className="col-start-3 col-end-4 row-start-1 min-w-0 overflow-hidden" />
|
||||
}
|
||||
|
||||
return (
|
||||
<SessionInspector
|
||||
branch={branch}
|
||||
busy={busy}
|
||||
cwd={cwd}
|
||||
modelLabel={model ? model.split('/').pop() || model : ''}
|
||||
modelTitle={provider ? `${provider}: ${model || ''}` : model}
|
||||
onBrowseCwd={onBrowseCwd}
|
||||
onChangeCwd={onChangeCwd}
|
||||
onOpenModelPicker={gatewayOpen ? onOpenModelPicker : undefined}
|
||||
onSelectPersonality={gatewayOpen ? onSelectPersonality : undefined}
|
||||
open={inspectorOpen}
|
||||
personalities={personalities}
|
||||
personality={personality}
|
||||
providerName={provider}
|
||||
/>
|
||||
<div
|
||||
className={cn(
|
||||
'pointer-events-none col-start-3 col-end-4 row-start-1 min-w-0 overflow-hidden',
|
||||
inspectorOpen && 'border-r border-border/60'
|
||||
)}
|
||||
>
|
||||
<PreviewPane setTitlebarToolGroup={setTitlebarToolGroup} target={previewTarget} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { Bug, Check, Copy, ExternalLink, PanelBottom, RefreshCw, Send, Trash2, X } from 'lucide-react'
|
||||
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { Bug, Check, Copy, PanelBottom, RefreshCw, Send, Trash2, X } from 'lucide-react'
|
||||
import type { CSSProperties, PointerEvent as ReactPointerEvent } from 'react'
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
|
||||
import { Button } from '@/components/ui/button'
|
||||
import type { SetTitlebarToolGroup, TitlebarTool } from '@/app/shell/titlebar-controls'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { $composerDraft, setComposerDraft } from '@/store/composer'
|
||||
import { notify, notifyError } from '@/store/notifications'
|
||||
@@ -23,6 +24,12 @@ interface ConsoleEntry {
|
||||
source?: string
|
||||
}
|
||||
|
||||
interface PreviewLoadErrorState {
|
||||
code?: number
|
||||
description: string
|
||||
url: string
|
||||
}
|
||||
|
||||
const consoleLevelLabel: Record<number, string> = {
|
||||
0: 'log',
|
||||
1: 'info',
|
||||
@@ -38,6 +45,8 @@ const consoleLevelClass: Record<number, string> = {
|
||||
}
|
||||
|
||||
const CONSOLE_BOTTOM_THRESHOLD = 24
|
||||
const CONSOLE_DEFAULT_HEIGHT = 240
|
||||
const CONSOLE_HEADER_HEIGHT = 32
|
||||
const FILE_RELOAD_DEBOUNCE_MS = 200
|
||||
|
||||
function compactUrl(value: string): string {
|
||||
@@ -69,6 +78,20 @@ function isNearConsoleBottom(element: HTMLDivElement | null): boolean {
|
||||
return element.scrollHeight - element.scrollTop - element.clientHeight <= CONSOLE_BOTTOM_THRESHOLD
|
||||
}
|
||||
|
||||
function clampConsoleHeight(value: number): number {
|
||||
return Math.max(value, CONSOLE_HEADER_HEIGHT)
|
||||
}
|
||||
|
||||
function loadErrorTitle(error: PreviewLoadErrorState): string {
|
||||
const description = error.description.toLowerCase()
|
||||
|
||||
if (description.includes('connection') || description.includes('refused') || description.includes('not found')) {
|
||||
return 'Server not found'
|
||||
}
|
||||
|
||||
return 'Preview failed to load'
|
||||
}
|
||||
|
||||
interface ConsoleRowProps {
|
||||
log: ConsoleEntry
|
||||
onCopy: () => void | Promise<void>
|
||||
@@ -129,6 +152,67 @@ function ConsoleRow({ log, onCopy, onSend, onToggleSelect, selected }: ConsoleRo
|
||||
)
|
||||
}
|
||||
|
||||
function PreviewLoadError({
|
||||
consoleHeight = 0,
|
||||
error,
|
||||
onRetry
|
||||
}: {
|
||||
consoleHeight?: number
|
||||
error: PreviewLoadErrorState
|
||||
onRetry: () => void
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
className="absolute inset-x-0 top-0 z-10 grid place-items-center bg-background px-6 text-center bottom-(--preview-error-bottom)"
|
||||
style={{ '--preview-error-bottom': `${consoleHeight}px` } as CSSProperties}
|
||||
>
|
||||
<div className="grid max-w-72 justify-items-center gap-4">
|
||||
<svg aria-hidden="true" className="size-16 text-muted-foreground/35" viewBox="0 0 64 64">
|
||||
<path
|
||||
d="M32 5 56 18.5v27L32 59 8 45.5v-27L32 5Z"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth="1.25"
|
||||
/>
|
||||
<path
|
||||
d="M8 18.5 32 32l24-13.5M32 32v27"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth="1.25"
|
||||
/>
|
||||
<path d="M20 11.75 44 25.25" fill="none" stroke="currentColor" strokeWidth="0.9" opacity="0.45" />
|
||||
</svg>
|
||||
<div className="grid gap-1.5">
|
||||
<div className="text-sm font-medium text-foreground">{loadErrorTitle(error)}</div>
|
||||
<div className="text-xs leading-5 text-muted-foreground">
|
||||
<a
|
||||
className="pointer-events-auto cursor-pointer font-mono text-muted-foreground/90 underline decoration-muted-foreground/30 underline-offset-4 transition-colors hover:text-foreground hover:decoration-foreground/70"
|
||||
href={error.url}
|
||||
onClick={event => {
|
||||
event.preventDefault()
|
||||
void window.hermesDesktop?.openExternal(error.url)
|
||||
}}
|
||||
>
|
||||
{compactUrl(error.url)}
|
||||
</a>
|
||||
{error.code ? ` (${error.code})` : ''}
|
||||
</div>
|
||||
<div className="text-[0.6875rem] leading-5 text-muted-foreground/70">{error.description}</div>
|
||||
</div>
|
||||
<button
|
||||
className="rounded-full border border-border bg-background px-3 py-1.5 text-xs font-medium text-foreground shadow-xs transition-colors hover:bg-accent"
|
||||
onClick={onRetry}
|
||||
type="button"
|
||||
>
|
||||
Try again
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
async function writeClipboardText(text: string) {
|
||||
if (!text) {
|
||||
return
|
||||
@@ -145,12 +229,20 @@ async function writeClipboardText(text: string) {
|
||||
}
|
||||
}
|
||||
|
||||
export function PreviewPane({ target }: { target: PreviewTarget }) {
|
||||
export function PreviewPane({
|
||||
setTitlebarToolGroup,
|
||||
target
|
||||
}: {
|
||||
setTitlebarToolGroup?: SetTitlebarToolGroup
|
||||
target: PreviewTarget
|
||||
}) {
|
||||
const consoleBodyRef = useRef<HTMLDivElement | null>(null)
|
||||
const consoleShouldStickRef = useRef(true)
|
||||
const hostRef = useRef<HTMLDivElement | null>(null)
|
||||
const logIdRef = useRef(0)
|
||||
const previewContentRef = useRef<HTMLDivElement | null>(null)
|
||||
const webviewRef = useRef<PreviewWebview | null>(null)
|
||||
const [consoleHeight, setConsoleHeight] = useState(CONSOLE_DEFAULT_HEIGHT)
|
||||
const [consoleOpen, setConsoleOpen] = useState(false)
|
||||
const [currentUrl, setCurrentUrl] = useState(target.url)
|
||||
const [devtoolsOpen, setDevtoolsOpen] = useState(false)
|
||||
@@ -158,8 +250,72 @@ export function PreviewPane({ target }: { target: PreviewTarget }) {
|
||||
const [selectedLogIds, setSelectedLogIds] = useState<Set<number>>(() => new Set())
|
||||
const [copiedAll, setCopiedAll] = useState(false)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [loadError, setLoadError] = useState<PreviewLoadErrorState | null>(null)
|
||||
const visibleSelection = useMemo(() => logs.filter(log => selectedLogIds.has(log.id)), [logs, selectedLogIds])
|
||||
const sendableLogs = visibleSelection.length > 0 ? visibleSelection : logs
|
||||
const currentLabel = compactUrl(currentUrl)
|
||||
const previewLabel =
|
||||
target.label && target.label.replace(/\/$/, '') !== currentLabel.replace(/\/$/, '') ? target.label : currentLabel
|
||||
|
||||
const startConsoleResize = useCallback(
|
||||
(event: ReactPointerEvent<HTMLDivElement>) => {
|
||||
event.preventDefault()
|
||||
|
||||
const handle = event.currentTarget
|
||||
const pointerId = event.pointerId
|
||||
const startY = event.clientY
|
||||
const startHeight = consoleHeight
|
||||
const previousCursor = document.body.style.cursor
|
||||
const previousUserSelect = document.body.style.userSelect
|
||||
let active = true
|
||||
|
||||
handle.setPointerCapture?.(pointerId)
|
||||
|
||||
document.body.style.cursor = 'row-resize'
|
||||
document.body.style.userSelect = 'none'
|
||||
|
||||
const handleMove = (moveEvent: PointerEvent) => {
|
||||
if (!active) {
|
||||
return
|
||||
}
|
||||
|
||||
setConsoleHeight(clampConsoleHeight(startHeight + startY - moveEvent.clientY))
|
||||
}
|
||||
|
||||
const cleanup = () => {
|
||||
if (!active) {
|
||||
return
|
||||
}
|
||||
|
||||
active = false
|
||||
document.body.style.cursor = previousCursor
|
||||
document.body.style.userSelect = previousUserSelect
|
||||
handle.releasePointerCapture?.(pointerId)
|
||||
window.removeEventListener('pointermove', handleMove, true)
|
||||
window.removeEventListener('pointerup', cleanup, true)
|
||||
window.removeEventListener('pointercancel', cleanup, true)
|
||||
window.removeEventListener('blur', cleanup)
|
||||
handle.removeEventListener('lostpointercapture', cleanup)
|
||||
}
|
||||
|
||||
window.addEventListener('pointermove', handleMove, true)
|
||||
window.addEventListener('pointerup', cleanup, true)
|
||||
window.addEventListener('pointercancel', cleanup, true)
|
||||
window.addEventListener('blur', cleanup)
|
||||
handle.addEventListener('lostpointercapture', cleanup)
|
||||
},
|
||||
[consoleHeight]
|
||||
)
|
||||
|
||||
const reloadPreview = useCallback(() => {
|
||||
setLoadError(null)
|
||||
|
||||
if (webviewRef.current?.reloadIgnoringCache) {
|
||||
webviewRef.current.reloadIgnoringCache()
|
||||
} else {
|
||||
webviewRef.current?.reload?.()
|
||||
}
|
||||
}, [])
|
||||
|
||||
function toggleLogSelection(id: number) {
|
||||
setSelectedLogIds(prev => {
|
||||
@@ -204,7 +360,7 @@ export function PreviewPane({ target }: { target: PreviewTarget }) {
|
||||
})
|
||||
}
|
||||
|
||||
function toggleDevTools() {
|
||||
const toggleDevTools = useCallback(() => {
|
||||
const webview = webviewRef.current
|
||||
|
||||
if (!webview?.openDevTools) {
|
||||
@@ -220,7 +376,52 @@ export function PreviewPane({ target }: { target: PreviewTarget }) {
|
||||
|
||||
webview.openDevTools()
|
||||
setDevtoolsOpen(true)
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (!setTitlebarToolGroup) {
|
||||
return
|
||||
}
|
||||
|
||||
const tools: TitlebarTool[] = [
|
||||
{
|
||||
active: consoleOpen,
|
||||
icon: (
|
||||
<>
|
||||
<PanelBottom />
|
||||
{logs.length > 0 && <span className="sr-only">{logs.length} console messages</span>}
|
||||
</>
|
||||
),
|
||||
id: 'preview-console',
|
||||
label: consoleOpen ? 'Hide preview console' : 'Show preview console',
|
||||
onSelect: () => setConsoleOpen(open => !open)
|
||||
},
|
||||
{
|
||||
active: devtoolsOpen,
|
||||
icon: <Bug />,
|
||||
id: 'preview-devtools',
|
||||
label: devtoolsOpen ? 'Hide preview DevTools' : 'Open preview DevTools',
|
||||
onSelect: toggleDevTools
|
||||
},
|
||||
{
|
||||
icon: <RefreshCw className={cn(loading && 'animate-spin')} />,
|
||||
id: 'preview-reload',
|
||||
label: 'Reload preview',
|
||||
onSelect: reloadPreview
|
||||
},
|
||||
{
|
||||
className: 'mr-(--shell-preview-toolbar-gap)',
|
||||
icon: <X />,
|
||||
id: 'preview-close',
|
||||
label: 'Close preview',
|
||||
onSelect: () => setPreviewTarget(null)
|
||||
}
|
||||
]
|
||||
|
||||
setTitlebarToolGroup('preview', tools)
|
||||
|
||||
return () => setTitlebarToolGroup('preview', [])
|
||||
}, [consoleOpen, currentUrl, devtoolsOpen, loading, logs.length, reloadPreview, setTitlebarToolGroup, toggleDevTools])
|
||||
|
||||
useEffect(() => {
|
||||
if (consoleOpen && consoleShouldStickRef.current) {
|
||||
@@ -228,7 +429,7 @@ export function PreviewPane({ target }: { target: PreviewTarget }) {
|
||||
|
||||
consoleBody?.scrollTo({ top: consoleBody.scrollHeight })
|
||||
}
|
||||
}, [consoleOpen, logs])
|
||||
}, [consoleHeight, consoleOpen, logs])
|
||||
|
||||
useEffect(() => {
|
||||
if (consoleOpen) {
|
||||
@@ -275,11 +476,7 @@ export function PreviewPane({ target }: { target: PreviewTarget }) {
|
||||
}
|
||||
])
|
||||
|
||||
if (webviewRef.current?.reloadIgnoringCache) {
|
||||
webviewRef.current.reloadIgnoringCache()
|
||||
} else {
|
||||
webviewRef.current?.reload?.()
|
||||
}
|
||||
reloadPreview()
|
||||
}
|
||||
|
||||
const unsubscribe = window.hermesDesktop.onPreviewFileChanged(payload => {
|
||||
@@ -334,7 +531,7 @@ export function PreviewPane({ target }: { target: PreviewTarget }) {
|
||||
void window.hermesDesktop?.stopPreviewFileWatch?.(watchId)
|
||||
}
|
||||
}
|
||||
}, [target.kind, target.url])
|
||||
}, [reloadPreview, target.kind, target.url])
|
||||
|
||||
useEffect(() => {
|
||||
const host = hostRef.current
|
||||
@@ -347,6 +544,7 @@ export function PreviewPane({ target }: { target: PreviewTarget }) {
|
||||
webviewRef.current = null
|
||||
setCurrentUrl(target.url)
|
||||
setDevtoolsOpen(false)
|
||||
setLoadError(null)
|
||||
setLogs([])
|
||||
setLoading(true)
|
||||
|
||||
@@ -381,6 +579,7 @@ export function PreviewPane({ target }: { target: PreviewTarget }) {
|
||||
const detail = event as Event & { url?: string }
|
||||
|
||||
if (detail.url) {
|
||||
setLoadError(null)
|
||||
setCurrentUrl(detail.url)
|
||||
}
|
||||
}
|
||||
@@ -391,13 +590,23 @@ export function PreviewPane({ target }: { target: PreviewTarget }) {
|
||||
errorDescription?: string
|
||||
validatedURL?: string
|
||||
}
|
||||
const errorCode = detail.errorCode
|
||||
|
||||
if (errorCode === -3) {
|
||||
return
|
||||
}
|
||||
|
||||
appendLog({
|
||||
level: 3,
|
||||
message: `Load failed${detail.errorCode ? ` (${detail.errorCode})` : ''}: ${
|
||||
message: `Load failed${errorCode ? ` (${errorCode})` : ''}: ${
|
||||
detail.errorDescription || detail.validatedURL || 'unknown error'
|
||||
}`
|
||||
})
|
||||
setLoadError({
|
||||
code: errorCode,
|
||||
description: detail.errorDescription || 'The preview page could not be reached.',
|
||||
url: detail.validatedURL || currentUrl || target.url
|
||||
})
|
||||
setLoading(false)
|
||||
}
|
||||
|
||||
@@ -425,157 +634,129 @@ export function PreviewPane({ target }: { target: PreviewTarget }) {
|
||||
}, [target.url])
|
||||
|
||||
return (
|
||||
<aside className="relative flex h-screen min-w-0 flex-col overflow-hidden bg-transparent pb-2 pl-2 pr-3 pt-[calc(var(--titlebar-height)+0.25rem)] text-muted-foreground">
|
||||
<div className="flex min-h-0 flex-1 flex-col overflow-hidden rounded-2xl border border-border/60 bg-card/70 shadow-sm">
|
||||
<div className="flex items-center gap-1.5 border-b border-border/60 px-2 py-2">
|
||||
<aside className="relative flex h-screen w-full min-w-0 flex-col overflow-hidden border-l border-border/60 bg-background text-muted-foreground">
|
||||
<div className="flex min-h-0 flex-1 flex-col overflow-hidden">
|
||||
<div className="pointer-events-none flex min-h-(--titlebar-height) items-center gap-1.5 border-b border-border/60 bg-background px-2 py-1">
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="truncate text-xs font-medium text-foreground">{target.label || 'Preview'}</div>
|
||||
<div className="truncate font-mono text-[0.625rem] text-muted-foreground">{compactUrl(currentUrl)}</div>
|
||||
<a
|
||||
className="pointer-events-auto inline max-w-full cursor-pointer truncate text-left text-xs font-medium text-foreground underline-offset-4 transition-colors hover:text-primary hover:underline"
|
||||
href={currentUrl}
|
||||
rel="noreferrer"
|
||||
target="_blank"
|
||||
title={`Open ${currentUrl}`}
|
||||
>
|
||||
{previewLabel || 'Preview'}
|
||||
</a>
|
||||
</div>
|
||||
<Button
|
||||
aria-label={consoleOpen ? 'Hide preview console' : 'Show preview console'}
|
||||
className="h-7 shrink-0 rounded-lg px-2 text-[0.6875rem]"
|
||||
onClick={() => setConsoleOpen(open => !open)}
|
||||
size="xs"
|
||||
title={consoleOpen ? 'Hide Console' : 'Show Console'}
|
||||
type="button"
|
||||
variant="ghost"
|
||||
>
|
||||
<PanelBottom className="size-3.5" />
|
||||
Console
|
||||
{logs.length > 0 && (
|
||||
<span className="ml-0.5 rounded-full bg-muted px-1.5 py-px text-[0.5625rem] text-muted-foreground">
|
||||
{logs.length}
|
||||
</span>
|
||||
)}
|
||||
</Button>
|
||||
<Button
|
||||
aria-label={devtoolsOpen ? 'Hide preview DevTools' : 'Open preview DevTools'}
|
||||
className="h-7 shrink-0 rounded-lg px-2 text-[0.6875rem]"
|
||||
onClick={toggleDevTools}
|
||||
size="xs"
|
||||
title={devtoolsOpen ? 'Hide DevTools' : 'Open DevTools'}
|
||||
type="button"
|
||||
variant="ghost"
|
||||
>
|
||||
<Bug className="size-3.5" />
|
||||
{devtoolsOpen ? 'Hide DevTools' : 'DevTools'}
|
||||
</Button>
|
||||
<Button
|
||||
aria-label="Reload preview"
|
||||
className="size-7 shrink-0 rounded-lg"
|
||||
onClick={() => webviewRef.current?.reload?.()}
|
||||
size="icon"
|
||||
type="button"
|
||||
variant="ghost"
|
||||
>
|
||||
<RefreshCw className={cn('size-3.5', loading && 'animate-spin')} />
|
||||
</Button>
|
||||
<Button
|
||||
aria-label="Open preview externally"
|
||||
className="size-7 shrink-0 rounded-lg"
|
||||
onClick={() => void window.hermesDesktop?.openExternal(currentUrl)}
|
||||
size="icon"
|
||||
type="button"
|
||||
variant="ghost"
|
||||
>
|
||||
<ExternalLink className="size-3.5" />
|
||||
</Button>
|
||||
<Button
|
||||
aria-label="Close preview"
|
||||
className="size-7 shrink-0 rounded-lg"
|
||||
onClick={() => setPreviewTarget(null)}
|
||||
size="icon"
|
||||
type="button"
|
||||
variant="ghost"
|
||||
>
|
||||
<X className="size-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="min-h-0 flex-1 bg-background" ref={hostRef} />
|
||||
<div className="pointer-events-auto relative min-h-0 flex-1 overflow-hidden bg-background" ref={previewContentRef}>
|
||||
<div
|
||||
className={cn('absolute inset-0 flex bg-background', loadError && 'pointer-events-none opacity-0')}
|
||||
ref={hostRef}
|
||||
/>
|
||||
{loadError && (
|
||||
<PreviewLoadError
|
||||
consoleHeight={consoleOpen ? consoleHeight : 0}
|
||||
error={loadError}
|
||||
onRetry={reloadPreview}
|
||||
/>
|
||||
)}
|
||||
|
||||
{consoleOpen && (
|
||||
<div className="min-h-44 border-t border-border/60 bg-background/95">
|
||||
<div className="flex h-8 items-center justify-between border-b border-border/50 px-2">
|
||||
<div className="flex items-center gap-2 text-[0.6875rem] font-medium text-muted-foreground">
|
||||
<PanelBottom className="size-3.5" />
|
||||
Preview Console
|
||||
{selectedLogIds.size > 0 && (
|
||||
<span className="rounded-full bg-muted px-1.5 py-px text-[0.5625rem] text-muted-foreground">
|
||||
{selectedLogIds.size} selected
|
||||
</span>
|
||||
{consoleOpen && (
|
||||
<div
|
||||
className="pointer-events-auto absolute inset-x-0 bottom-0 z-20 flex h-(--preview-console-height) min-h-8 flex-col overflow-hidden border-t border-border/60 bg-background"
|
||||
style={{ '--preview-console-height': `${consoleHeight}px` } as CSSProperties}
|
||||
>
|
||||
<div
|
||||
aria-label="Resize preview console"
|
||||
className="group absolute inset-x-0 -top-1 z-1 h-2 cursor-row-resize"
|
||||
onDoubleClick={() => setConsoleHeight(CONSOLE_HEADER_HEIGHT)}
|
||||
onPointerDown={startConsoleResize}
|
||||
role="separator"
|
||||
>
|
||||
<span className="absolute left-1/2 top-1/2 h-0.75 w-23 -translate-x-1/2 -translate-y-1/2 rounded-full bg-muted-foreground/80 opacity-0 transition-opacity duration-100 group-hover:opacity-[0.5]" />
|
||||
</div>
|
||||
<div className="flex h-8 shrink-0 items-center justify-between border-b border-border/50 px-2">
|
||||
<div className="flex items-center gap-2 text-[0.6875rem] font-medium text-muted-foreground">
|
||||
<PanelBottom className="size-3.5" />
|
||||
Preview Console
|
||||
{selectedLogIds.size > 0 && (
|
||||
<span className="rounded-full bg-muted px-1.5 py-px text-[0.5625rem] text-muted-foreground">
|
||||
{selectedLogIds.size} selected
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<button
|
||||
className="inline-flex items-center gap-1 rounded-md px-1.5 py-0.5 text-[0.625rem] text-muted-foreground transition-colors hover:bg-accent hover:text-foreground disabled:opacity-40"
|
||||
disabled={sendableLogs.length === 0}
|
||||
onClick={() => sendLogsToComposer(sendableLogs)}
|
||||
title={
|
||||
visibleSelection.length > 0
|
||||
? `Send ${visibleSelection.length} selected to chat`
|
||||
: 'Send all log entries to chat'
|
||||
}
|
||||
type="button"
|
||||
>
|
||||
<Send className="size-3" />
|
||||
Send to chat
|
||||
</button>
|
||||
<button
|
||||
className="inline-flex items-center gap-1 rounded-md px-1.5 py-0.5 text-[0.625rem] text-muted-foreground transition-colors hover:bg-accent hover:text-foreground disabled:opacity-40"
|
||||
disabled={sendableLogs.length === 0}
|
||||
onClick={async () => {
|
||||
await copyConsoleText(
|
||||
sendableLogs,
|
||||
visibleSelection.length > 0 ? `${visibleSelection.length} selected entries` : 'All console entries'
|
||||
)
|
||||
setCopiedAll(true)
|
||||
setTimeout(() => setCopiedAll(false), 1500)
|
||||
}}
|
||||
title={visibleSelection.length > 0 ? 'Copy selected to clipboard' : 'Copy all to clipboard'}
|
||||
type="button"
|
||||
>
|
||||
{copiedAll ? <Check className="size-3" /> : <Copy className="size-3" />}
|
||||
Copy
|
||||
</button>
|
||||
<button
|
||||
className="inline-flex items-center gap-1 rounded-md px-1.5 py-0.5 text-[0.625rem] text-muted-foreground transition-colors hover:bg-accent hover:text-foreground disabled:opacity-40"
|
||||
disabled={logs.length === 0}
|
||||
onClick={() => {
|
||||
setLogs([])
|
||||
setSelectedLogIds(new Set())
|
||||
}}
|
||||
title="Clear console"
|
||||
type="button"
|
||||
>
|
||||
<Trash2 className="size-3" />
|
||||
Clear
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="min-h-0 flex-1 overflow-y-auto px-2 py-1.5 font-mono text-[0.6875rem] leading-relaxed" ref={consoleBodyRef}>
|
||||
{logs.length > 0 ? (
|
||||
logs.map(log => {
|
||||
const selected = selectedLogIds.has(log.id)
|
||||
|
||||
return (
|
||||
<ConsoleRow
|
||||
key={log.id}
|
||||
log={log}
|
||||
onCopy={() => copyConsoleText([log], 'Log entry copied')}
|
||||
onSend={() => sendLogsToComposer([log])}
|
||||
onToggleSelect={() => toggleLogSelection(log.id)}
|
||||
selected={selected}
|
||||
/>
|
||||
)
|
||||
})
|
||||
) : (
|
||||
<div className="py-2 text-muted-foreground/70">No console messages yet.</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<button
|
||||
className="inline-flex items-center gap-1 rounded-md px-1.5 py-0.5 text-[0.625rem] text-muted-foreground transition-colors hover:bg-accent hover:text-foreground disabled:opacity-40"
|
||||
disabled={sendableLogs.length === 0}
|
||||
onClick={() => sendLogsToComposer(sendableLogs)}
|
||||
title={
|
||||
visibleSelection.length > 0
|
||||
? `Send ${visibleSelection.length} selected to chat`
|
||||
: 'Send all log entries to chat'
|
||||
}
|
||||
type="button"
|
||||
>
|
||||
<Send className="size-3" />
|
||||
Send to chat
|
||||
</button>
|
||||
<button
|
||||
className="inline-flex items-center gap-1 rounded-md px-1.5 py-0.5 text-[0.625rem] text-muted-foreground transition-colors hover:bg-accent hover:text-foreground disabled:opacity-40"
|
||||
disabled={sendableLogs.length === 0}
|
||||
onClick={async () => {
|
||||
await copyConsoleText(
|
||||
sendableLogs,
|
||||
visibleSelection.length > 0 ? `${visibleSelection.length} selected entries` : 'All console entries'
|
||||
)
|
||||
setCopiedAll(true)
|
||||
setTimeout(() => setCopiedAll(false), 1500)
|
||||
}}
|
||||
title={visibleSelection.length > 0 ? 'Copy selected to clipboard' : 'Copy all to clipboard'}
|
||||
type="button"
|
||||
>
|
||||
{copiedAll ? <Check className="size-3" /> : <Copy className="size-3" />}
|
||||
Copy
|
||||
</button>
|
||||
<button
|
||||
className="inline-flex items-center gap-1 rounded-md px-1.5 py-0.5 text-[0.625rem] text-muted-foreground transition-colors hover:bg-accent hover:text-foreground disabled:opacity-40"
|
||||
disabled={logs.length === 0}
|
||||
onClick={() => {
|
||||
setLogs([])
|
||||
setSelectedLogIds(new Set())
|
||||
}}
|
||||
title="Clear console"
|
||||
type="button"
|
||||
>
|
||||
<Trash2 className="size-3" />
|
||||
Clear
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="h-40 overflow-y-auto px-2 py-1.5 font-mono text-[0.6875rem] leading-relaxed" ref={consoleBodyRef}>
|
||||
{logs.length > 0 ? (
|
||||
logs.map(log => {
|
||||
const selected = selectedLogIds.has(log.id)
|
||||
|
||||
return (
|
||||
<ConsoleRow
|
||||
key={log.id}
|
||||
log={log}
|
||||
onCopy={() => copyConsoleText([log], 'Log entry copied')}
|
||||
onSend={() => sendLogsToComposer([log])}
|
||||
onToggleSelect={() => toggleLogSelection(log.id)}
|
||||
selected={selected}
|
||||
/>
|
||||
)
|
||||
})
|
||||
) : (
|
||||
<div className="py-2 text-muted-foreground/70">No console messages yet.</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
)
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useStore } from '@nanostores/react'
|
||||
import { useQueryClient } from '@tanstack/react-query'
|
||||
import { type ReactNode, useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { Navigate, Route, Routes, useLocation, useNavigate, useParams } from 'react-router-dom'
|
||||
|
||||
import type { ModelOptionsResponse, SessionRuntimeInfo } from '@/types/hermes'
|
||||
@@ -20,7 +20,7 @@ import { BUILTIN_PERSONALITIES, normalizePersonalityValue, personalityNamesFromC
|
||||
import { extractPreviewCandidates } from '../lib/preview-targets'
|
||||
import { $pinnedSessionIds, pinSession, unpinSession } from '../store/layout'
|
||||
import { notify, notifyError } from '../store/notifications'
|
||||
import { $previewTarget, setPreviewTarget } from '../store/preview'
|
||||
import { setPreviewTarget } from '../store/preview'
|
||||
import {
|
||||
$activeSessionId,
|
||||
$currentCwd,
|
||||
@@ -59,6 +59,7 @@ import { useSessionActions } from './session/hooks/use-session-actions'
|
||||
import { useSessionStateCache } from './session/hooks/use-session-state-cache'
|
||||
import { SettingsView } from './settings'
|
||||
import { AppShell } from './shell/app-shell'
|
||||
import type { SetTitlebarToolGroup, TitlebarTool, TitlebarToolSide } from './shell/titlebar-controls'
|
||||
import { SkillsView } from './skills'
|
||||
import type { ContextSuggestion } from './types'
|
||||
|
||||
@@ -87,7 +88,6 @@ export function DesktopController() {
|
||||
const gatewayState = useStore($gatewayState)
|
||||
const { availableThemes, setTheme, themeName } = useTheme()
|
||||
const activeSessionId = useStore($activeSessionId)
|
||||
const previewTarget = useStore($previewTarget)
|
||||
const messages = useStore($messages)
|
||||
const selectedStoredSessionId = useStore($selectedStoredSessionId)
|
||||
const currentCwd = useStore($currentCwd)
|
||||
@@ -102,7 +102,9 @@ export function DesktopController() {
|
||||
const chatOpen = currentView === 'chat'
|
||||
const settingsReturnPathRef = useRef(NEW_CHAT_ROUTE)
|
||||
const refreshSessionsRequestRef = useRef(0)
|
||||
const [titlebarActions, setTitlebarActions] = useState<ReactNode>(null)
|
||||
const [titlebarToolGroups, setTitlebarToolGroups] = useState<
|
||||
Record<TitlebarToolSide, Record<string, readonly TitlebarTool[]>>
|
||||
>({ left: {}, right: {} })
|
||||
const [voiceMaxRecordingSeconds, setVoiceMaxRecordingSeconds] = useState(DEFAULT_VOICE_RECORDING_SECONDS)
|
||||
const [sttEnabled, setSttEnabled] = useState(true)
|
||||
|
||||
@@ -123,6 +125,30 @@ export function DesktopController() {
|
||||
setMessages
|
||||
})
|
||||
|
||||
const setTitlebarToolGroup = useCallback<SetTitlebarToolGroup>((id, tools, side = 'right') => {
|
||||
setTitlebarToolGroups(current => {
|
||||
const next = { ...current, [side]: { ...current[side] } }
|
||||
|
||||
if (tools.length === 0) {
|
||||
delete next[side][id]
|
||||
} else {
|
||||
next[side][id] = tools
|
||||
}
|
||||
|
||||
return next
|
||||
})
|
||||
}, [])
|
||||
|
||||
const leftTitlebarTools = useMemo(
|
||||
() => Object.values(titlebarToolGroups.left).flat(),
|
||||
[titlebarToolGroups.left]
|
||||
)
|
||||
|
||||
const titlebarTools = useMemo(
|
||||
() => Object.values(titlebarToolGroups.right).flat(),
|
||||
[titlebarToolGroups.right]
|
||||
)
|
||||
|
||||
const toggleSelectedPin = useCallback(() => {
|
||||
const sessionId = $selectedStoredSessionId.get()
|
||||
|
||||
@@ -786,6 +812,7 @@ export function DesktopController() {
|
||||
onSelectPersonality={name => void selectPersonality(name)}
|
||||
onSubmit={submitText}
|
||||
onThreadMessagesChange={handleThreadMessagesChange}
|
||||
setTitlebarToolGroup={setTitlebarToolGroup}
|
||||
onToggleSelectedPin={toggleSelectedPin}
|
||||
onTranscribeAudio={transcribeVoiceAudio}
|
||||
/>
|
||||
@@ -793,19 +820,21 @@ export function DesktopController() {
|
||||
|
||||
return (
|
||||
<AppShell
|
||||
inspectorWidth={previewTarget ? PREVIEW_RAIL_WIDTH : SESSION_INSPECTOR_WIDTH}
|
||||
inspectorWidth={SESSION_INSPECTOR_WIDTH}
|
||||
leftTitlebarTools={leftTitlebarTools}
|
||||
onOpenSettings={openSettings}
|
||||
overlays={overlays}
|
||||
previewWidth={PREVIEW_RAIL_WIDTH}
|
||||
rightRailOpen={chatOpen}
|
||||
settingsOpen={settingsOpen}
|
||||
sidebar={sidebar}
|
||||
titlebarActions={titlebarActions}
|
||||
titlebarTools={titlebarTools}
|
||||
>
|
||||
<Routes>
|
||||
<Route element={chatView} index />
|
||||
<Route element={chatView} path=":sessionId" />
|
||||
<Route element={<SkillsView setTitlebarActions={setTitlebarActions} />} path="skills" />
|
||||
<Route element={<ArtifactsView setTitlebarActions={setTitlebarActions} />} path="artifacts" />
|
||||
<Route element={<SkillsView setTitlebarToolGroup={setTitlebarToolGroup} />} path="skills" />
|
||||
<Route element={<ArtifactsView setTitlebarToolGroup={setTitlebarToolGroup} />} path="artifacts" />
|
||||
<Route element={null} path="settings" />
|
||||
<Route element={<Navigate replace to={NEW_CHAT_ROUTE} />} path="new" />
|
||||
<Route element={<LegacySessionRedirect />} path="sessions/:sessionId" />
|
||||
|
||||
@@ -17,15 +17,17 @@ import { $previewTarget } from '@/store/preview'
|
||||
import { $connection } from '@/store/session'
|
||||
|
||||
import { TITLEBAR_HEIGHT, titlebarControlsPosition } from './titlebar'
|
||||
import { TitlebarControls } from './titlebar-controls'
|
||||
import { TitlebarControls, type TitlebarTool } from './titlebar-controls'
|
||||
|
||||
interface AppShellProps {
|
||||
children: ReactNode
|
||||
inspectorWidth: string
|
||||
leftTitlebarTools?: readonly TitlebarTool[]
|
||||
previewWidth: string
|
||||
rightRailOpen: boolean
|
||||
settingsOpen: boolean
|
||||
sidebar: ReactNode
|
||||
titlebarActions?: ReactNode
|
||||
titlebarTools?: readonly TitlebarTool[]
|
||||
onOpenSettings: () => void
|
||||
overlays?: ReactNode
|
||||
}
|
||||
@@ -33,10 +35,12 @@ interface AppShellProps {
|
||||
export function AppShell({
|
||||
children,
|
||||
inspectorWidth,
|
||||
leftTitlebarTools,
|
||||
previewWidth,
|
||||
rightRailOpen,
|
||||
settingsOpen,
|
||||
sidebar,
|
||||
titlebarActions,
|
||||
titlebarTools,
|
||||
onOpenSettings,
|
||||
overlays
|
||||
}: AppShellProps) {
|
||||
@@ -50,22 +54,36 @@ export function AppShell({
|
||||
// and draggable hit-zones are fixed overlays, so keeping an invisible grid
|
||||
// column for a closed sidebar pushes/clips the actual chat surface.
|
||||
const displayedSidebarWidth = sidebarOpen ? sidebarWidth : 0
|
||||
|
||||
const titlebarControls = titlebarControlsPosition(connection?.windowButtonPosition)
|
||||
|
||||
const titlebarContentInset = sidebarOpen
|
||||
? 0
|
||||
: titlebarControls.left + TITLEBAR_HEIGHT + Math.round(TITLEBAR_HEIGHT / 2)
|
||||
const showRightRail = rightRailOpen && (inspectorOpen || Boolean(previewTarget))
|
||||
|
||||
// Right rail yields to chat min-width before the chat column starts crushing the composer.
|
||||
const inspectorColumn = showRightRail
|
||||
? 'min(var(--inspector-width), max(0px, calc(100vw - var(--sidebar-width) - var(--chat-min-width) - 2 * var(--shell-gap))))'
|
||||
const showPreviewRail = rightRailOpen && Boolean(previewTarget)
|
||||
const showInspectorRail = rightRailOpen && inspectorOpen
|
||||
|
||||
const inspectorColumn = showInspectorRail ? 'var(--inspector-width)' : '0px'
|
||||
|
||||
// Preview yields first because it is the widest rail; keep chat usable before
|
||||
// letting the webview consume horizontal space.
|
||||
const previewColumn = showPreviewRail
|
||||
? `min(var(--preview-width), max(0px, calc(100vw - var(--sidebar-width) - ${showInspectorRail ? 'var(--inspector-width)' : '0px'} - var(--chat-min-width) - 3 * var(--shell-gap))))`
|
||||
: '0px'
|
||||
// Always keep the shell as 3 columns because the sidebar and chat are
|
||||
// always rendered as grid children. Collapsing to a single grid column
|
||||
// makes the hidden sidebar occupy row 1 and pushes chat into row 2, which
|
||||
// looks like a blank/white screen when closing the preview with sidebars
|
||||
// hidden. Centering is handled by setting closed side columns to 0px.
|
||||
const shellGridColumns = 'var(--sidebar-width) minmax(0,1fr) var(--inspector-col)'
|
||||
|
||||
const titlebarToolCount = (titlebarTools?.filter(tool => !tool.hidden).length ?? 0) + (rightRailOpen ? 1 : 0) + 2
|
||||
|
||||
const previewToolbarGap = showPreviewRail
|
||||
? 'max(0px, calc(var(--shell-right-sidebar-width) - (3 * var(--titlebar-control-size)) + 0.2rem))'
|
||||
: '0px'
|
||||
|
||||
// Always keep the shell as fixed columns because sidebar/chat/preview/inspector
|
||||
// are always rendered as grid children. Hidden rails collapse to 0px so they
|
||||
// don't float over the chat surface or reorder into a new row.
|
||||
const shellGridColumns = 'var(--sidebar-width) minmax(0,1fr) var(--preview-col) var(--inspector-col)'
|
||||
|
||||
const hasSideGaps = sidebarOpen || showPreviewRail || showInspectorRail
|
||||
|
||||
const startSidebarResize = useCallback(
|
||||
(event: ReactPointerEvent<HTMLDivElement>) => {
|
||||
@@ -106,42 +124,52 @@ export function AppShell({
|
||||
open={sidebarOpen}
|
||||
style={
|
||||
{
|
||||
'--inspector-width': inspectorWidth,
|
||||
'--preview-width': previewWidth,
|
||||
'--sidebar-width': `${displayedSidebarWidth}px`,
|
||||
'--chat-center-offset': '0px',
|
||||
'--shell-left-sidebar-width': `${displayedSidebarWidth}px`,
|
||||
'--shell-preview-pane-width': previewColumn,
|
||||
'--shell-right-sidebar-width': inspectorColumn,
|
||||
'--shell-right-region-width': 'calc(var(--shell-preview-pane-width) + var(--shell-right-sidebar-width))',
|
||||
'--shell-preview-toolbar-gap': previewToolbarGap,
|
||||
'--titlebar-height': `${TITLEBAR_HEIGHT}px`,
|
||||
'--titlebar-content-inset': `${titlebarContentInset}px`,
|
||||
'--titlebar-controls-left': `${titlebarControls.left}px`,
|
||||
'--titlebar-controls-top': `${titlebarControls.top}px`
|
||||
'--titlebar-controls-top': `${titlebarControls.top}px`,
|
||||
'--titlebar-tools-right': '0.75rem',
|
||||
'--titlebar-tools-width': `calc(${titlebarToolCount} * var(--titlebar-control-size) + var(--shell-preview-toolbar-gap))`
|
||||
} as CSSProperties
|
||||
}
|
||||
>
|
||||
<TitlebarControls
|
||||
leadingActions={titlebarActions}
|
||||
leftTools={leftTitlebarTools}
|
||||
onOpenSettings={onOpenSettings}
|
||||
settingsOpen={settingsOpen}
|
||||
showInspectorToggle={rightRailOpen}
|
||||
tools={titlebarTools}
|
||||
/>
|
||||
|
||||
<main
|
||||
className={cn(
|
||||
'relative grid h-screen w-full overflow-hidden bg-background pr-0.75 pb-0.75 pt-0.75 transition-none',
|
||||
sidebarOpen || showRightRail ? 'gap-(--shell-gap)' : 'gap-0'
|
||||
hasSideGaps ? 'gap-(--shell-gap)' : 'gap-0'
|
||||
)}
|
||||
style={
|
||||
{
|
||||
'--inspector-width': inspectorWidth,
|
||||
'--inspector-col': inspectorColumn,
|
||||
'--preview-col': previewColumn,
|
||||
gridTemplateColumns: shellGridColumns
|
||||
} as CSSProperties
|
||||
}
|
||||
>
|
||||
<div
|
||||
aria-hidden="true"
|
||||
className="absolute left-0 top-0 z-1 h-(--titlebar-height) w-(--titlebar-controls-left) [-webkit-app-region:drag]"
|
||||
className="pointer-events-none absolute left-0 top-0 z-1 h-(--titlebar-height) w-(--titlebar-controls-left) [-webkit-app-region:drag]"
|
||||
/>
|
||||
<div
|
||||
aria-hidden="true"
|
||||
className="absolute right-20 top-0 z-1 h-(--titlebar-height) left-[calc(var(--titlebar-controls-left)+(var(--titlebar-control-size)*2)+0.75rem)] [-webkit-app-region:drag]"
|
||||
className="pointer-events-none absolute top-0 z-1 h-(--titlebar-height) left-[calc(var(--titlebar-controls-left)+(var(--titlebar-control-size)*2)+0.75rem)] right-[calc(var(--titlebar-tools-right)+var(--titlebar-tools-width)+0.75rem)] [-webkit-app-region:drag]"
|
||||
/>
|
||||
|
||||
{sidebar}
|
||||
|
||||
@@ -2,6 +2,7 @@ import { useStore } from '@nanostores/react'
|
||||
import { NotebookTabs, Search, Settings, SlidersHorizontal, Volume2, VolumeX } from 'lucide-react'
|
||||
import type { ReactNode } from 'react'
|
||||
import type * as React from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
|
||||
import { triggerHaptic } from '@/lib/haptics'
|
||||
import { cn } from '@/lib/utils'
|
||||
@@ -10,19 +11,39 @@ import { $inspectorOpen, $sidebarOpen, toggleInspectorOpen, toggleSidebarOpen }
|
||||
|
||||
import { TITLEBAR_ICON_SIZE, titlebarButtonClass } from './titlebar'
|
||||
|
||||
export interface TitlebarTool {
|
||||
id: string
|
||||
label: string
|
||||
active?: boolean
|
||||
className?: string
|
||||
disabled?: boolean
|
||||
hidden?: boolean
|
||||
href?: string
|
||||
icon: ReactNode
|
||||
onSelect?: () => void
|
||||
title?: string
|
||||
to?: string
|
||||
}
|
||||
|
||||
export type TitlebarToolSide = 'left' | 'right'
|
||||
export type SetTitlebarToolGroup = (id: string, tools: readonly TitlebarTool[], side?: TitlebarToolSide) => void
|
||||
|
||||
interface TitlebarControlsProps extends React.ComponentProps<'div'> {
|
||||
leftTools?: readonly TitlebarTool[]
|
||||
settingsOpen: boolean
|
||||
showInspectorToggle: boolean
|
||||
leadingActions?: ReactNode
|
||||
tools?: readonly TitlebarTool[]
|
||||
onOpenSettings: () => void
|
||||
}
|
||||
|
||||
export function TitlebarControls({
|
||||
leftTools = [],
|
||||
settingsOpen,
|
||||
showInspectorToggle,
|
||||
leadingActions,
|
||||
tools = [],
|
||||
onOpenSettings
|
||||
}: TitlebarControlsProps) {
|
||||
const navigate = useNavigate()
|
||||
const hapticsMuted = useStore($hapticsMuted)
|
||||
const sidebarOpen = useStore($sidebarOpen)
|
||||
const inspectorOpen = useStore($inspectorOpen)
|
||||
@@ -39,86 +60,136 @@ export function TitlebarControls({
|
||||
}
|
||||
}
|
||||
|
||||
const leftToolbarTools: TitlebarTool[] = [
|
||||
{
|
||||
icon: <NotebookTabs />,
|
||||
id: 'sidebar',
|
||||
label: sidebarOpen ? 'Hide sidebar' : 'Show sidebar',
|
||||
onSelect: () => {
|
||||
triggerHaptic('tap')
|
||||
toggleSidebarOpen()
|
||||
}
|
||||
},
|
||||
{
|
||||
icon: <Search size={TITLEBAR_ICON_SIZE} />,
|
||||
id: 'search',
|
||||
label: 'Search'
|
||||
},
|
||||
...leftTools
|
||||
]
|
||||
|
||||
const rightToolbarTools: TitlebarTool[] = [
|
||||
...tools,
|
||||
{
|
||||
active: inspectorOpen,
|
||||
hidden: !showInspectorToggle,
|
||||
icon: <SlidersHorizontal />,
|
||||
id: 'session-details',
|
||||
label: inspectorOpen ? 'Hide session details' : 'Show session details',
|
||||
onSelect: () => {
|
||||
triggerHaptic('tap')
|
||||
toggleInspectorOpen()
|
||||
}
|
||||
},
|
||||
{
|
||||
active: hapticsMuted,
|
||||
icon: hapticsMuted ? <VolumeX /> : <Volume2 />,
|
||||
id: 'haptics',
|
||||
label: hapticsMuted ? 'Unmute haptics' : 'Mute haptics',
|
||||
onSelect: toggleHaptics
|
||||
},
|
||||
{
|
||||
icon: <Settings />,
|
||||
id: 'settings',
|
||||
label: 'Open settings',
|
||||
onSelect: () => {
|
||||
triggerHaptic('open')
|
||||
onOpenSettings()
|
||||
}
|
||||
}
|
||||
]
|
||||
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
aria-label="Window controls"
|
||||
className="fixed left-(--titlebar-controls-left) top-(--titlebar-controls-top) z-50 grid translate-y-[2px] grid-flow-col auto-cols-(--titlebar-control-size) items-center pointer-events-auto [-webkit-app-region:no-drag]"
|
||||
className="fixed left-(--titlebar-controls-left) top-(--titlebar-controls-top) z-2147483647 flex translate-y-[2px] flex-row items-center gap-px pointer-events-auto [-webkit-app-region:no-drag]"
|
||||
>
|
||||
<button
|
||||
aria-label={sidebarOpen ? 'Hide sidebar' : 'Show sidebar'}
|
||||
className={cn(titlebarButtonClass, 'grid place-items-center bg-transparent [&_svg]:size-3.5')}
|
||||
onClick={() => {
|
||||
triggerHaptic('tap')
|
||||
toggleSidebarOpen()
|
||||
}}
|
||||
onPointerDown={event => event.stopPropagation()}
|
||||
type="button"
|
||||
>
|
||||
<NotebookTabs />
|
||||
</button>
|
||||
|
||||
<button
|
||||
aria-label="Search"
|
||||
className={cn(titlebarButtonClass, 'grid place-items-center bg-transparent')}
|
||||
onPointerDown={event => event.stopPropagation()}
|
||||
type="button"
|
||||
>
|
||||
<Search size={TITLEBAR_ICON_SIZE} />
|
||||
</button>
|
||||
{leftToolbarTools
|
||||
.filter(tool => !tool.hidden)
|
||||
.map(tool => (
|
||||
<TitlebarToolButton key={tool.id} navigate={navigate} tool={tool} />
|
||||
))}
|
||||
</div>
|
||||
|
||||
{!settingsOpen && (
|
||||
<div
|
||||
aria-label="App controls"
|
||||
className="fixed right-3 top-(--titlebar-controls-top) z-1100 grid grid-flow-col auto-cols-(--titlebar-control-size) items-center pointer-events-auto [-webkit-app-region:no-drag]"
|
||||
className="fixed right-(--titlebar-tools-right) top-(--titlebar-controls-top) z-2147483647 flex flex-row items-center justify-end gap-px pointer-events-auto [-webkit-app-region:no-drag]"
|
||||
>
|
||||
{leadingActions}
|
||||
{showInspectorToggle && (
|
||||
<button
|
||||
aria-label={inspectorOpen ? 'Hide session details' : 'Show session details'}
|
||||
className={cn(titlebarButtonClass, 'grid place-items-center bg-transparent [&_svg]:size-3.5')}
|
||||
onClick={() => {
|
||||
triggerHaptic('tap')
|
||||
toggleInspectorOpen()
|
||||
}}
|
||||
onPointerDown={event => event.stopPropagation()}
|
||||
title={inspectorOpen ? 'Hide session details' : 'Show session details'}
|
||||
type="button"
|
||||
>
|
||||
<SlidersHorizontal />
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
aria-label={hapticsMuted ? 'Unmute haptics' : 'Mute haptics'}
|
||||
aria-pressed={hapticsMuted}
|
||||
className={cn(
|
||||
titlebarButtonClass,
|
||||
'grid place-items-center bg-transparent [&_svg]:size-3.5',
|
||||
hapticsMuted && 'bg-muted text-muted-foreground'
|
||||
)}
|
||||
onClick={toggleHaptics}
|
||||
onPointerDown={event => event.stopPropagation()}
|
||||
title={hapticsMuted ? 'Unmute haptics' : 'Mute haptics'}
|
||||
type="button"
|
||||
>
|
||||
{hapticsMuted ? <VolumeX /> : <Volume2 />}
|
||||
</button>
|
||||
<button
|
||||
aria-label="Open settings"
|
||||
className={cn(titlebarButtonClass, 'grid place-items-center bg-transparent [&_svg]:size-3.5')}
|
||||
onClick={() => {
|
||||
triggerHaptic('open')
|
||||
onOpenSettings()
|
||||
}}
|
||||
onPointerDown={event => event.stopPropagation()}
|
||||
title="Settings"
|
||||
type="button"
|
||||
>
|
||||
<Settings />
|
||||
</button>
|
||||
{rightToolbarTools
|
||||
.filter(tool => !tool.hidden)
|
||||
.map(tool => (
|
||||
<TitlebarToolButton
|
||||
key={tool.id}
|
||||
navigate={navigate}
|
||||
tool={tool}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
function TitlebarToolButton({
|
||||
navigate,
|
||||
tool
|
||||
}: {
|
||||
navigate: ReturnType<typeof useNavigate>
|
||||
tool: TitlebarTool
|
||||
}) {
|
||||
const className = cn(
|
||||
titlebarButtonClass,
|
||||
'grid place-items-center bg-transparent [&_svg]:size-3.5',
|
||||
tool.active && 'bg-muted text-muted-foreground',
|
||||
tool.className
|
||||
)
|
||||
|
||||
if (tool.href) {
|
||||
return (
|
||||
<a
|
||||
aria-label={tool.label}
|
||||
className={className}
|
||||
href={tool.href}
|
||||
onPointerDown={event => event.stopPropagation()}
|
||||
rel="noreferrer"
|
||||
target="_blank"
|
||||
title={tool.title ?? tool.label}
|
||||
>
|
||||
{tool.icon}
|
||||
</a>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<button
|
||||
aria-label={tool.label}
|
||||
aria-pressed={tool.active}
|
||||
className={className}
|
||||
disabled={tool.disabled}
|
||||
onClick={() => {
|
||||
if (tool.to) {
|
||||
navigate(tool.to)
|
||||
}
|
||||
|
||||
tool.onSelect?.()
|
||||
}}
|
||||
onPointerDown={event => event.stopPropagation()}
|
||||
title={tool.title ?? tool.label}
|
||||
type="button"
|
||||
>
|
||||
{tool.icon}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -16,7 +16,7 @@ export const titlebarButtonClass =
|
||||
'h-[var(--titlebar-control-height)] w-[var(--titlebar-control-size)] rounded-md text-muted-foreground hover:bg-accent hover:text-foreground'
|
||||
|
||||
export const titlebarHeaderBaseClass =
|
||||
'relative z-3 flex h-(--titlebar-height) shrink-0 items-center justify-start gap-3 bg-background/70 px-[max(0.75rem,var(--titlebar-content-inset,0px))] backdrop-blur-sm'
|
||||
'pointer-events-none relative z-3 flex h-(--titlebar-height) shrink-0 items-center justify-start gap-3 bg-background/70 px-[max(0.75rem,var(--titlebar-content-inset,0px))] backdrop-blur-sm'
|
||||
|
||||
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-['']"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Brain, RefreshCw, Search, Wrench, X } from 'lucide-react'
|
||||
import type { LucideIcon } from 'lucide-react'
|
||||
import type { ReactNode } from 'react'
|
||||
import type * as React from 'react'
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react'
|
||||
|
||||
import { PageLoader } from '@/components/page-loader'
|
||||
@@ -13,7 +13,8 @@ import { notify, notifyError } from '@/store/notifications'
|
||||
import type { SkillInfo, ToolsetInfo } from '@/types/hermes'
|
||||
|
||||
import { asText, includesQuery, prettyName, toolNames } from '../settings/helpers'
|
||||
import { TITLEBAR_ICON_SIZE, titlebarButtonClass, titlebarHeaderBaseClass } from '../shell/titlebar'
|
||||
import { titlebarHeaderBaseClass } from '../shell/titlebar'
|
||||
import type { SetTitlebarToolGroup } from '../shell/titlebar-controls'
|
||||
|
||||
type SkillsMode = 'skills' | 'toolsets'
|
||||
|
||||
@@ -59,10 +60,10 @@ function filteredToolsets(toolsets: ToolsetInfo[], query: string): ToolsetInfo[]
|
||||
}
|
||||
|
||||
interface SkillsViewProps extends React.ComponentProps<'section'> {
|
||||
setTitlebarActions?: (actions: ReactNode | null) => void
|
||||
setTitlebarToolGroup?: SetTitlebarToolGroup
|
||||
}
|
||||
|
||||
export function SkillsView({ setTitlebarActions, ...props }: SkillsViewProps) {
|
||||
export function SkillsView({ setTitlebarToolGroup, ...props }: SkillsViewProps) {
|
||||
const [mode, setMode] = useState<SkillsMode>('skills')
|
||||
const [query, setQuery] = useState('')
|
||||
const [skills, setSkills] = useState<SkillInfo[] | null>(null)
|
||||
@@ -90,24 +91,22 @@ export function SkillsView({ setTitlebarActions, ...props }: SkillsViewProps) {
|
||||
}, [refreshCapabilities])
|
||||
|
||||
useEffect(() => {
|
||||
if (!setTitlebarActions) {
|
||||
if (!setTitlebarToolGroup) {
|
||||
return
|
||||
}
|
||||
|
||||
setTitlebarActions(
|
||||
<button
|
||||
aria-label={refreshing ? 'Refreshing skills' : 'Refresh skills'}
|
||||
className={cn(titlebarButtonClass, 'grid place-items-center bg-transparent')}
|
||||
disabled={refreshing}
|
||||
onClick={() => void refreshCapabilities()}
|
||||
type="button"
|
||||
>
|
||||
<RefreshCw className={cn(refreshing && 'animate-spin')} size={TITLEBAR_ICON_SIZE} />
|
||||
</button>
|
||||
)
|
||||
setTitlebarToolGroup('skills', [
|
||||
{
|
||||
disabled: refreshing,
|
||||
icon: <RefreshCw className={cn(refreshing && 'animate-spin')} />,
|
||||
id: 'refresh-skills',
|
||||
label: refreshing ? 'Refreshing skills' : 'Refresh skills',
|
||||
onSelect: () => void refreshCapabilities()
|
||||
}
|
||||
])
|
||||
|
||||
return () => setTitlebarActions(null)
|
||||
}, [refreshCapabilities, refreshing, setTitlebarActions])
|
||||
return () => setTitlebarToolGroup('skills', [])
|
||||
}, [refreshCapabilities, refreshing, setTitlebarToolGroup])
|
||||
|
||||
const categories = useMemo(() => {
|
||||
if (!skills) {
|
||||
@@ -172,8 +171,8 @@ export function SkillsView({ setTitlebarActions, ...props }: SkillsViewProps) {
|
||||
className="flex h-[calc(100vh-0.375rem)] min-w-0 flex-col overflow-hidden rounded-[0.9375rem] bg-background"
|
||||
>
|
||||
<header className={titlebarHeaderBaseClass}>
|
||||
<h2 className="text-base font-semibold leading-none tracking-tight">Skills</h2>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
<h2 className="pointer-events-auto text-base font-semibold leading-none tracking-tight">Skills</h2>
|
||||
<span className="pointer-events-auto text-xs text-muted-foreground">
|
||||
{enabledSkills}/{totalSkills} enabled
|
||||
</span>
|
||||
</header>
|
||||
|
||||
Reference in New Issue
Block a user