mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-06-13 09:01:54 +00:00
feat: better pane management and toolbar api
This commit is contained in:
parent
a66303eaef
commit
6dcf5bcbc0
17 changed files with 772 additions and 348 deletions
|
|
@ -2,6 +2,8 @@
|
|||
|
||||
Instructions for AI coding assistants and developers working on the hermes-agent codebase.
|
||||
|
||||
**Never give up on the right solution.**
|
||||
|
||||
## Development Environment
|
||||
|
||||
```bash
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -177,7 +177,7 @@ export const Intro: FC<IntroProps> = ({ personality, seed }) => {
|
|||
}, [advanceFrame, frameOffset])
|
||||
|
||||
return (
|
||||
<div className="pointer-events-none absolute inset-0 z-1 flex flex-col items-center justify-center px-[calc(var(--vsq)*50)] text-center text-muted-foreground">
|
||||
<div className="pointer-events-none flex min-h-[calc(100vh-var(--titlebar-height)-var(--thread-composer-clearance)-var(--composer-shell-pad-block-end))] flex-col items-center justify-center px-[calc(var(--vsq)*50)] text-center text-muted-foreground">
|
||||
<button
|
||||
aria-label="Change Hermes pose"
|
||||
className="pointer-events-auto mb-5 h-56 w-64 cursor-default border-0 bg-transparent p-0"
|
||||
|
|
|
|||
|
|
@ -36,6 +36,17 @@ describe('preprocessMarkdown', () => {
|
|||
expect(output).toContain('- **Scroll wheel** - zoom')
|
||||
})
|
||||
|
||||
it('drops fences around a preview-only URL block', () => {
|
||||
const fence = '```'
|
||||
const input = ['Server is back.', '', fence, 'http://localhost:8812/', fence].join('\n')
|
||||
|
||||
const output = preprocessMarkdown(input)
|
||||
|
||||
expect(output).toContain('Server is back.')
|
||||
expect(output).not.toContain('```')
|
||||
expect(output).not.toContain('http://localhost:8812/')
|
||||
})
|
||||
|
||||
it('demotes prose sentence masquerading as fence info', () => {
|
||||
const input = ['```Heads up - a bunny got added', '- Pure white (`#ffffff`)', '- Ambient dropped to 0.18'].join('\n')
|
||||
const output = preprocessMarkdown(input)
|
||||
|
|
|
|||
|
|
@ -18,7 +18,7 @@ import {
|
|||
mediaPathFromMarkdownHref
|
||||
} from '@/lib/media'
|
||||
import { isLikelyProseCodeBlock, isLikelyProseFence, sanitizeLanguageTag } from '@/lib/markdown-code'
|
||||
import { previewTargetFromMarkdownHref, stripPreviewTargets } from '@/lib/preview-targets'
|
||||
import { isLikelyPreviewCandidate, previewTargetFromMarkdownHref, stripPreviewTargets } from '@/lib/preview-targets'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
/**
|
||||
|
|
@ -77,6 +77,15 @@ function findClosingFence(lines: string[], start: number, marker: string): numbe
|
|||
return -1
|
||||
}
|
||||
|
||||
function isPreviewOnlyFence(body: string): boolean {
|
||||
const lines = body
|
||||
.split('\n')
|
||||
.map(line => line.trim())
|
||||
.filter(Boolean)
|
||||
|
||||
return lines.length === 1 && isLikelyPreviewCandidate(lines[0])
|
||||
}
|
||||
|
||||
function normalizeFenceBlocks(text: string): string {
|
||||
const sourceLines = text.split('\n')
|
||||
const out: string[] = []
|
||||
|
|
@ -116,6 +125,14 @@ function normalizeFenceBlocks(text: string): string {
|
|||
continue
|
||||
}
|
||||
|
||||
if (closeIndex !== -1 && isPreviewOnlyFence(body)) {
|
||||
// Agents often fence a lone preview URL to make it copyable. The chat UI
|
||||
// already renders a preview card for that URL, so don't show code fences.
|
||||
out.push(...bodyLines)
|
||||
index = closeIndex + 1
|
||||
continue
|
||||
}
|
||||
|
||||
if (closeIndex === -1) {
|
||||
if (!body.trim()) {
|
||||
index += 1
|
||||
|
|
@ -406,7 +423,7 @@ const MarkdownTextImpl = () => {
|
|||
<li className={cn('leading-relaxed', className)} {...props} />
|
||||
),
|
||||
table: ({ className, ...props }: ComponentProps<'table'>) => (
|
||||
<div className="w-full overflow-x-auto rounded-md border border-border">
|
||||
<div className="max-w-full overflow-x-auto rounded-md border border-border">
|
||||
<table
|
||||
className={cn(
|
||||
'w-full border-collapse text-sm [&_tr]:border-b [&_tr]:border-border last:[&_tr]:border-0',
|
||||
|
|
@ -442,7 +459,7 @@ const MarkdownTextImpl = () => {
|
|||
<StreamdownTextPrimitive
|
||||
caret="block"
|
||||
components={components}
|
||||
containerClassName="aui-md text-foreground"
|
||||
containerClassName="aui-md max-w-full overflow-hidden text-foreground"
|
||||
lineNumbers={false}
|
||||
mode="streaming"
|
||||
parseIncompleteMarkdown
|
||||
|
|
|
|||
|
|
@ -33,6 +33,7 @@ import {
|
|||
useRef,
|
||||
useState
|
||||
} from 'react'
|
||||
import spinners from 'unicode-animations'
|
||||
// Scroll behavior: delegated to `use-stick-to-bottom` (StackBlitz), the
|
||||
// reference implementation that powers bolt.new and several other streaming
|
||||
// chat UIs. It handles everything we care about — spring-animated catch-up,
|
||||
|
|
@ -44,7 +45,6 @@ import {
|
|||
// keeping `$threadScrolledUp` in sync with `isAtBottom` for the composer's
|
||||
// dim-when-scrolled-away treatment.
|
||||
import { StickToBottom, useStickToBottomContext } from 'use-stick-to-bottom'
|
||||
import spinners from 'unicode-animations'
|
||||
|
||||
import { useElapsedSeconds } from '@/components/assistant-ui/activity-timer'
|
||||
import { ActivityTimerText } from '@/components/assistant-ui/activity-timer-text'
|
||||
|
|
@ -115,9 +115,7 @@ export const Thread: FC<{
|
|||
}> = ({ intro, loading, onBranchInNewChat, sessionKey }) => {
|
||||
return (
|
||||
<GeneratedImageProvider>
|
||||
<ThreadPrimitive.Root className="relative grid h-full min-h-0 grid-rows-[minmax(0,1fr)] overflow-hidden bg-transparent">
|
||||
<AuiIf condition={s => Boolean(intro) && s.thread.isEmpty}>{intro && <Intro {...intro} />}</AuiIf>
|
||||
|
||||
<ThreadPrimitive.Root className="relative grid h-full min-h-0 max-w-full grid-rows-[minmax(0,1fr)] overflow-hidden bg-transparent contain-[layout_paint]">
|
||||
<ThreadPrimitive.ViewportProvider>
|
||||
{/*
|
||||
* <StickToBottom> renders a wrapper <div>; <StickToBottom.Content>
|
||||
|
|
@ -140,16 +138,17 @@ export const Thread: FC<{
|
|||
* content above the composer, not hidden behind it.
|
||||
*/}
|
||||
<StickToBottom
|
||||
className="relative h-full min-h-0"
|
||||
className="relative h-full min-h-0 max-w-full overflow-hidden contain-[layout_paint]"
|
||||
initial="instant"
|
||||
resize="instant"
|
||||
>
|
||||
<ThreadScrollSync sessionKey={sessionKey} />
|
||||
<StickToBottom.Content
|
||||
className="mx-auto flex w-full max-w-[48rem] flex-col gap-3 px-4 pt-[calc(var(--vsq)*19)] sm:px-6 lg:px-8"
|
||||
className="mx-auto flex w-full max-w-[48rem] min-w-0 flex-col gap-3 px-4 pt-[calc(var(--vsq)*19)] sm:px-6 lg:px-8"
|
||||
data-slot="aui_thread-content"
|
||||
scrollClassName="overflow-y-auto overscroll-contain"
|
||||
scrollClassName="overflow-x-hidden overflow-y-auto overscroll-contain"
|
||||
>
|
||||
<AuiIf condition={s => Boolean(intro) && s.thread.isEmpty}>{intro && <Intro {...intro} />}</AuiIf>
|
||||
<ThreadPrimitive.Messages
|
||||
components={{
|
||||
AssistantMessage: () => <AssistantMessage onBranchInNewChat={onBranchInNewChat} />,
|
||||
|
|
@ -357,25 +356,26 @@ const ThreadScrollSync: FC<{ sessionKey?: string | null }> = ({ sessionKey }) =>
|
|||
* textarea grows (multi-line input), attachments expand, or the composer
|
||||
* enters a focused/expanded state.
|
||||
*/
|
||||
const COMPOSER_BREATHING_ROOM_PX = 20
|
||||
const COMPOSER_BREATHING_ROOM_PX = 36
|
||||
const DEFAULT_COMPOSER_CLEARANCE_PX = 192
|
||||
|
||||
const ComposerClearance: FC = () => {
|
||||
const [height, setHeight] = useState<number>(() => {
|
||||
// Sensible default until the observer wires up (~ 8rem).
|
||||
if (typeof document === 'undefined') return 128
|
||||
// Keep enough space even while the floating composer is still mounting.
|
||||
if (typeof document === 'undefined') {
|
||||
return DEFAULT_COMPOSER_CLEARANCE_PX
|
||||
}
|
||||
|
||||
const composer = document.querySelector<HTMLElement>('[data-slot="composer-root"]')
|
||||
|
||||
return composer ? composer.getBoundingClientRect().height + COMPOSER_BREATHING_ROOM_PX : 128
|
||||
return composer ? composer.getBoundingClientRect().height + COMPOSER_BREATHING_ROOM_PX : DEFAULT_COMPOSER_CLEARANCE_PX
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
const composer = document.querySelector<HTMLElement>('[data-slot="composer-root"]')
|
||||
let composerObserver: ResizeObserver | null = null
|
||||
let observedComposer: HTMLElement | null = null
|
||||
|
||||
if (!composer) {
|
||||
return
|
||||
}
|
||||
|
||||
const apply = () => {
|
||||
const apply = (composer: HTMLElement) => {
|
||||
const h = composer.getBoundingClientRect().height
|
||||
|
||||
setHeight(prev => {
|
||||
|
|
@ -385,11 +385,30 @@ const ComposerClearance: FC = () => {
|
|||
})
|
||||
}
|
||||
|
||||
apply()
|
||||
const observer = new ResizeObserver(apply)
|
||||
observer.observe(composer)
|
||||
const bindComposer = () => {
|
||||
const composer = document.querySelector<HTMLElement>('[data-slot="composer-root"]')
|
||||
|
||||
return () => observer.disconnect()
|
||||
if (!composer || composer === observedComposer) {
|
||||
return false
|
||||
}
|
||||
|
||||
observedComposer = composer
|
||||
apply(composer)
|
||||
composerObserver?.disconnect()
|
||||
composerObserver = new ResizeObserver(() => apply(composer))
|
||||
composerObserver.observe(composer)
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
bindComposer()
|
||||
const mutationObserver = new MutationObserver(() => void bindComposer())
|
||||
mutationObserver.observe(document.body, { childList: true, subtree: true })
|
||||
|
||||
return () => {
|
||||
composerObserver?.disconnect()
|
||||
mutationObserver.disconnect()
|
||||
}
|
||||
}, [])
|
||||
|
||||
return <div aria-hidden="true" className="shrink-0" style={{ height: `${height}px` }} />
|
||||
|
|
@ -435,11 +454,11 @@ const AssistantMessage: FC<{ onBranchInNewChat?: (messageId: string) => void }>
|
|||
|
||||
return (
|
||||
<MessagePrimitive.Root
|
||||
className="group flex w-full flex-col gap-2 self-start"
|
||||
className="group flex w-full min-w-0 max-w-full flex-col gap-2 self-start overflow-hidden"
|
||||
data-role="assistant"
|
||||
data-slot="aui_assistant-message-root"
|
||||
>
|
||||
<div className="wrap-anywhere text-pretty text-foreground" data-slot="aui_assistant-message-content">
|
||||
<div className="wrap-anywhere min-w-0 max-w-full overflow-hidden text-pretty text-foreground" data-slot="aui_assistant-message-content">
|
||||
<MessagePrimitive.Parts
|
||||
components={{
|
||||
Text: MarkdownText,
|
||||
|
|
@ -540,7 +559,7 @@ const ThinkingDisclosure: FC<{
|
|||
const elapsed = useElapsedSeconds(pending)
|
||||
|
||||
return (
|
||||
<div className="mb-3 text-sm text-muted-foreground">
|
||||
<div className="mb-2 text-sm text-muted-foreground">
|
||||
<button
|
||||
aria-expanded={open}
|
||||
className="inline-flex max-w-full items-center gap-1 rounded-md py-0.5 pr-1 text-left text-muted-foreground transition-colors hover:bg-accent hover:text-foreground"
|
||||
|
|
@ -757,11 +776,11 @@ const UserMessage: FC = () => {
|
|||
|
||||
return (
|
||||
<MessagePrimitive.Root
|
||||
className="group flex max-w-[min(72%,34rem)] flex-col items-end gap-2 self-end"
|
||||
className="group flex min-w-0 max-w-[min(72%,34rem)] flex-col items-end gap-2 self-end overflow-hidden"
|
||||
data-role="user"
|
||||
data-slot="aui_user-message-root"
|
||||
>
|
||||
<div className="wrap-anywhere whitespace-pre-line rounded-2xl border border-[color-mix(in_srgb,var(--dt-user-bubble-border)_78%,transparent)] bg-[color-mix(in_srgb,var(--dt-user-bubble)_94%,transparent)] px-3 py-2 leading-[1.48] text-foreground/95">
|
||||
<div className="wrap-anywhere max-w-full overflow-hidden whitespace-pre-line rounded-2xl border border-[color-mix(in_srgb,var(--dt-user-bubble-border)_78%,transparent)] bg-[color-mix(in_srgb,var(--dt-user-bubble)_94%,transparent)] px-3 py-2 leading-[1.48] text-foreground/95">
|
||||
<MessagePrimitive.Parts components={{ Text: DirectiveText }} />
|
||||
</div>
|
||||
<div className="min-h-6">
|
||||
|
|
|
|||
|
|
@ -165,7 +165,7 @@ export const ToolFallback = ({ toolCallId, toolName, args, result }: ToolCallMes
|
|||
}, [isPending])
|
||||
|
||||
return (
|
||||
<div className="mb-3 mt-1 text-sm text-muted-foreground">
|
||||
<div className="mb-2 mt-2 text-sm text-muted-foreground">
|
||||
<button
|
||||
className="inline-grid max-w-full grid-cols-[0.75rem_minmax(0,auto)_minmax(0,1fr)_auto_auto] items-center gap-1 rounded-md py-0.5 pr-1 text-left text-muted-foreground transition-colors hover:bg-accent hover:text-foreground"
|
||||
onClick={() => setOpen(v => !v)}
|
||||
|
|
|
|||
|
|
@ -3,6 +3,35 @@ import { describe, expect, it } from 'vitest'
|
|||
import { appendAssistantTextPart, chatMessageText, renderMediaTags, toChatMessages, upsertToolPart } from './chat-messages'
|
||||
|
||||
describe('toChatMessages', () => {
|
||||
it('merges queued tool-only assistant rows into the previous assistant bubble', () => {
|
||||
const messages = toChatMessages([
|
||||
{ role: 'assistant', content: 'Planning.', timestamp: 1 },
|
||||
{
|
||||
role: 'assistant',
|
||||
content: '',
|
||||
timestamp: 2,
|
||||
tool_calls: [{ id: 'tc-terminal', function: { name: 'terminal', arguments: '{"command":"ls"}' } }]
|
||||
},
|
||||
{ role: 'assistant', content: 'Done.', timestamp: 3 }
|
||||
])
|
||||
|
||||
const assistants = messages.filter(m => m.role === 'assistant')
|
||||
|
||||
expect(assistants).toHaveLength(1)
|
||||
expect(chatMessageText(assistants[0])).toContain('Planning')
|
||||
expect(chatMessageText(assistants[0])).toContain('Done')
|
||||
expect(assistants[0].parts.some(p => p.type === 'tool-call' && p.toolName === 'terminal')).toBe(true)
|
||||
const ordered = assistants[0].parts.map(p => p.type)
|
||||
|
||||
expect(ordered.filter(t => t === 'text')).toHaveLength(2)
|
||||
const toolIdx = assistants[0].parts.findIndex(p => p.type === 'tool-call')
|
||||
|
||||
expect(ordered.slice(0, toolIdx)).toContain('text')
|
||||
expect(
|
||||
assistants[0].parts.findIndex((p, i) => p.type === 'text' && i > toolIdx)
|
||||
).toBeGreaterThanOrEqual(0)
|
||||
})
|
||||
|
||||
it('hides attached context payloads from user message display', () => {
|
||||
const [message] = toChatMessages([
|
||||
{
|
||||
|
|
|
|||
|
|
@ -514,6 +514,22 @@ export function toChatMessages(messages: SessionMessage[]): ChatMessage[] {
|
|||
}
|
||||
|
||||
if (message.role === 'assistant' && pendingToolParts.length) {
|
||||
const last = result.at(-1)
|
||||
|
||||
// Session logs interleave tool-only assistant rows with later prose; appending onto
|
||||
// the previous bubble keeps one turn contiguous (vs unshift splitting the UI).
|
||||
if (last?.role === 'assistant') {
|
||||
last.parts = [...last.parts, ...pendingToolParts, ...parts]
|
||||
pendingToolParts = []
|
||||
pendingToolTimestamp = undefined
|
||||
|
||||
if (message.timestamp !== undefined) {
|
||||
last.timestamp = message.timestamp
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
parts.unshift(...pendingToolParts)
|
||||
pendingToolParts = []
|
||||
pendingToolTimestamp = undefined
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue