mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-06-14 09:11:54 +00:00
feat: better icons and overlay panes
This commit is contained in:
parent
ca8f2c7907
commit
d1d0ed4016
71 changed files with 2043 additions and 363 deletions
|
|
@ -36,14 +36,15 @@
|
|||
"preview": "vite preview --host 127.0.0.1 --port 4174"
|
||||
},
|
||||
"dependencies": {
|
||||
"@hermes/shared": "file:../shared",
|
||||
"@assistant-ui/react": "^0.12.28",
|
||||
"@assistant-ui/react-streamdown": "^0.1.11",
|
||||
"@audiowave/react": "^0.6.2",
|
||||
"@chenglou/pretext": "^0.0.6",
|
||||
"@hermes/shared": "file:../shared",
|
||||
"@nanostores/react": "^1.1.0",
|
||||
"@radix-ui/react-slot": "^1.2.4",
|
||||
"@streamdown/code": "^1.1.1",
|
||||
"@tabler/icons-react": "^3.41.1",
|
||||
"@tailwindcss/vite": "^4.2.4",
|
||||
"@tanstack/react-query": "^5.100.6",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
|
|
|
|||
|
|
@ -1,10 +1,9 @@
|
|||
import { Copy, ExternalLink, FileImage, FileText, FolderOpen, Layers3, Link2, RefreshCw, Search, X } from 'lucide-react'
|
||||
import type * as React from 'react'
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
|
||||
import { PageLoader } from '@/components/page-loader'
|
||||
import { ZoomableImage } from '@/components/assistant-ui/zoomable-image'
|
||||
import { PageLoader } from '@/components/page-loader'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import {
|
||||
|
|
@ -18,6 +17,7 @@ import {
|
|||
} from '@/components/ui/pagination'
|
||||
import { getSessionMessages, listSessions } from '@/hermes'
|
||||
import { sessionTitle } from '@/lib/chat-runtime'
|
||||
import { Copy, ExternalLink, FileImage, FileText, FolderOpen, Layers3, Link2, RefreshCw, Search, X } from '@/lib/icons'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { notify, notifyError } from '@/store/notifications'
|
||||
import type { SessionInfo, SessionMessage } from '@/types/hermes'
|
||||
|
|
|
|||
|
|
@ -1,5 +1,4 @@
|
|||
import { FileText, FolderOpen, ImageIcon, Link, X } from 'lucide-react'
|
||||
|
||||
import { FileText, FolderOpen, ImageIcon, Link, X } from '@/lib/icons'
|
||||
import type { ComposerAttachment } from '@/store/composer'
|
||||
|
||||
export function AttachmentList({
|
||||
|
|
@ -22,10 +21,7 @@ function AttachmentPill({ attachment, onRemove }: { attachment: ComposerAttachme
|
|||
const Icon = { folder: FolderOpen, url: Link, image: ImageIcon, file: FileText }[attachment.kind]
|
||||
|
||||
return (
|
||||
<div
|
||||
className="group/attachment relative shrink-0"
|
||||
title={attachment.label}
|
||||
>
|
||||
<div className="group/attachment relative shrink-0" title={attachment.label}>
|
||||
{attachment.previewUrl && attachment.kind === 'image' ? (
|
||||
<img
|
||||
alt={attachment.label}
|
||||
|
|
|
|||
|
|
@ -1,14 +1,3 @@
|
|||
import {
|
||||
Clipboard,
|
||||
FileText,
|
||||
FolderOpen,
|
||||
ImageIcon,
|
||||
Link,
|
||||
type LucideIcon,
|
||||
MessageSquareText,
|
||||
Plus
|
||||
} from 'lucide-react'
|
||||
|
||||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
DropdownMenu,
|
||||
|
|
@ -21,6 +10,7 @@ import {
|
|||
DropdownMenuSubTrigger,
|
||||
DropdownMenuTrigger
|
||||
} from '@/components/ui/dropdown-menu'
|
||||
import { Clipboard, FileText, FolderOpen, ImageIcon, Link, type LucideIcon, MessageSquareText, Plus } from '@/lib/icons'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
import { GHOST_ICON_BTN } from './controls'
|
||||
|
|
|
|||
|
|
@ -1,7 +1,6 @@
|
|||
import { ArrowUp, AudioLines, Loader2, Mic, MicOff, Square } from 'lucide-react'
|
||||
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { triggerHaptic } from '@/lib/haptics'
|
||||
import { ArrowUp, AudioLines, Loader2, Mic, MicOff, Square } from '@/lib/icons'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
import type { ConversationStatus } from './hooks/use-voice-conversation'
|
||||
|
|
|
|||
|
|
@ -3,10 +3,10 @@ import { useCallback } from 'react'
|
|||
|
||||
import type { HermesGateway } from '@/hermes'
|
||||
import {
|
||||
type CommandsCatalogLike,
|
||||
desktopSlashDescription,
|
||||
filterDesktopCommandsCatalog,
|
||||
isDesktopSlashSuggestion,
|
||||
type CommandsCatalogLike
|
||||
isDesktopSlashSuggestion
|
||||
} from '@/lib/desktop-slash-commands'
|
||||
|
||||
import type { CompletionEntry, CompletionPayload } from './use-live-completion-adapter'
|
||||
|
|
@ -57,17 +57,17 @@ export function useSlashCompletions(options: { gateway: HermesGateway | null }):
|
|||
if (!query) {
|
||||
const catalog = filterDesktopCommandsCatalog(await gateway.request<CommandsCatalogLike>('commands.catalog'))
|
||||
|
||||
const items = (catalog.pairs ?? [])
|
||||
.map(([command, meta]) => ({
|
||||
text: command,
|
||||
display: command,
|
||||
meta
|
||||
}))
|
||||
const items = (catalog.pairs ?? []).map(([command, meta]) => ({
|
||||
text: command,
|
||||
display: command,
|
||||
meta
|
||||
}))
|
||||
|
||||
return { items, query }
|
||||
}
|
||||
|
||||
const result = await gateway.request<{ items?: CompletionEntry[] }>('complete.slash', { text })
|
||||
|
||||
const items = (result.items ?? [])
|
||||
.filter(item => isDesktopSlashSuggestion(item.text))
|
||||
.map(item => ({
|
||||
|
|
|
|||
|
|
@ -1,4 +1,3 @@
|
|||
import { Globe } from 'lucide-react'
|
||||
import type * as React from 'react'
|
||||
|
||||
import { Button } from '@/components/ui/button'
|
||||
|
|
@ -11,6 +10,7 @@ import {
|
|||
DialogTitle
|
||||
} from '@/components/ui/dialog'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Globe } from '@/lib/icons'
|
||||
|
||||
const URL_HINT = /^https?:\/\//i
|
||||
|
||||
|
|
|
|||
|
|
@ -1,8 +1,8 @@
|
|||
import { useStore } from '@nanostores/react'
|
||||
import { Loader2, Mic, Volume2, VolumeX } from 'lucide-react'
|
||||
import { useEffect, useRef } from 'react'
|
||||
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Loader2, Mic, Volume2, VolumeX } from '@/lib/icons'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { stopVoicePlayback } from '@/lib/voice-playback'
|
||||
import { $voicePlayback } from '@/store/voice-playback'
|
||||
|
|
|
|||
|
|
@ -48,12 +48,17 @@ export function extractDroppedFiles(transfer: DataTransfer): DroppedFile[] {
|
|||
const getPath = window.hermesDesktop?.getPathForFile
|
||||
|
||||
const fileList = transfer.files
|
||||
|
||||
if (fileList) {
|
||||
for (let i = 0; i < fileList.length; i += 1) {
|
||||
const file = fileList.item(i)
|
||||
if (!file || seen.has(file)) continue
|
||||
|
||||
if (!file || seen.has(file)) {
|
||||
continue
|
||||
}
|
||||
seen.add(file)
|
||||
let path = ''
|
||||
|
||||
if (getPath) {
|
||||
try {
|
||||
path = getPath(file) || ''
|
||||
|
|
@ -61,19 +66,28 @@ export function extractDroppedFiles(transfer: DataTransfer): DroppedFile[] {
|
|||
path = ''
|
||||
}
|
||||
}
|
||||
|
||||
result.push({ file, path })
|
||||
}
|
||||
}
|
||||
|
||||
const items = transfer.items
|
||||
|
||||
if (items) {
|
||||
for (let i = 0; i < items.length; i += 1) {
|
||||
const item = items[i]
|
||||
if (!item || item.kind !== 'file') continue
|
||||
|
||||
if (!item || item.kind !== 'file') {
|
||||
continue
|
||||
}
|
||||
const file = item.getAsFile()
|
||||
if (!file || seen.has(file)) continue
|
||||
|
||||
if (!file || seen.has(file)) {
|
||||
continue
|
||||
}
|
||||
seen.add(file)
|
||||
let path = ''
|
||||
|
||||
if (getPath) {
|
||||
try {
|
||||
path = getPath(file) || ''
|
||||
|
|
@ -81,6 +95,7 @@ export function extractDroppedFiles(transfer: DataTransfer): DroppedFile[] {
|
|||
path = ''
|
||||
}
|
||||
}
|
||||
|
||||
result.push({ file, path })
|
||||
}
|
||||
}
|
||||
|
|
@ -94,11 +109,7 @@ interface ComposerActionsOptions {
|
|||
requestGateway: <T>(method: string, params?: Record<string, unknown>) => Promise<T>
|
||||
}
|
||||
|
||||
export function useComposerActions({
|
||||
activeSessionId,
|
||||
currentCwd,
|
||||
requestGateway
|
||||
}: ComposerActionsOptions) {
|
||||
export function useComposerActions({ activeSessionId, currentCwd, requestGateway }: ComposerActionsOptions) {
|
||||
const addContextRefAttachment = useCallback((refText: string, label?: string, detail?: string) => {
|
||||
let kind: ComposerAttachment['kind'] = 'file'
|
||||
|
||||
|
|
@ -169,38 +180,35 @@ export function useComposerActions({
|
|||
[currentCwd]
|
||||
)
|
||||
|
||||
const attachImagePath = useCallback(
|
||||
async (filePath: string) => {
|
||||
if (!filePath) {
|
||||
return false
|
||||
const attachImagePath = useCallback(async (filePath: string) => {
|
||||
if (!filePath) {
|
||||
return false
|
||||
}
|
||||
|
||||
const baseAttachment: ComposerAttachment = {
|
||||
id: attachmentId('image', filePath),
|
||||
kind: 'image',
|
||||
label: pathLabel(filePath),
|
||||
detail: filePath,
|
||||
path: filePath
|
||||
}
|
||||
|
||||
addComposerAttachment(baseAttachment)
|
||||
|
||||
try {
|
||||
const previewUrl = await window.hermesDesktop?.readFileDataUrl(filePath)
|
||||
|
||||
if (previewUrl) {
|
||||
addComposerAttachment({ ...baseAttachment, previewUrl })
|
||||
}
|
||||
|
||||
const baseAttachment: ComposerAttachment = {
|
||||
id: attachmentId('image', filePath),
|
||||
kind: 'image',
|
||||
label: pathLabel(filePath),
|
||||
detail: filePath,
|
||||
path: filePath
|
||||
}
|
||||
return true
|
||||
} catch (err) {
|
||||
notifyError(err, 'Image preview failed')
|
||||
|
||||
addComposerAttachment(baseAttachment)
|
||||
|
||||
try {
|
||||
const previewUrl = await window.hermesDesktop?.readFileDataUrl(filePath)
|
||||
|
||||
if (previewUrl) {
|
||||
addComposerAttachment({ ...baseAttachment, previewUrl })
|
||||
}
|
||||
|
||||
return true
|
||||
} catch (err) {
|
||||
notifyError(err, 'Image preview failed')
|
||||
|
||||
return true
|
||||
}
|
||||
},
|
||||
[]
|
||||
)
|
||||
return true
|
||||
}
|
||||
}, [])
|
||||
|
||||
const attachImageBlob = useCallback(
|
||||
async (blob: Blob) => {
|
||||
|
|
@ -284,22 +292,26 @@ export function useComposerActions({
|
|||
let lastFailure: string | null = null
|
||||
|
||||
for (const { file, path: knownPath } of candidates) {
|
||||
const fallbackPath = !knownPath && window.hermesDesktop?.getPathForFile ? window.hermesDesktop.getPathForFile(file) : ''
|
||||
const fallbackPath =
|
||||
!knownPath && window.hermesDesktop?.getPathForFile ? window.hermesDesktop.getPathForFile(file) : ''
|
||||
const filePath = knownPath || fallbackPath || ''
|
||||
const isImage = file.type.startsWith('image/') || isImagePath(file.name) || (filePath && isImagePath(filePath))
|
||||
|
||||
if (isImage) {
|
||||
if ((filePath && (await attachImagePath(filePath))) || (await attachImageBlob(file))) {
|
||||
attached = true
|
||||
|
||||
continue
|
||||
}
|
||||
|
||||
lastFailure = `Could not attach ${file.name || 'image'}`
|
||||
|
||||
continue
|
||||
}
|
||||
|
||||
if (filePath && attachContextFilePath(filePath)) {
|
||||
attached = true
|
||||
|
||||
continue
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -7,7 +7,6 @@ import {
|
|||
} from '@assistant-ui/react'
|
||||
import { useStore } from '@nanostores/react'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { ChevronDown } from 'lucide-react'
|
||||
import type * as React from 'react'
|
||||
import { Suspense, useMemo, useRef } from 'react'
|
||||
import { useLocation } from 'react-router-dom'
|
||||
|
|
@ -18,6 +17,7 @@ import { Button } from '@/components/ui/button'
|
|||
import { getGlobalModelOptions, type HermesGateway } from '@/hermes'
|
||||
import type { ChatMessage } from '@/lib/chat-messages'
|
||||
import { quickModelOptions, sessionTitle, toRuntimeMessage } from '@/lib/chat-runtime'
|
||||
import { ChevronDown } from '@/lib/icons'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { $pinnedSessionIds } from '@/store/layout'
|
||||
import {
|
||||
|
|
@ -264,7 +264,12 @@ export function ChatView({
|
|||
|
||||
return (
|
||||
<>
|
||||
<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)}>
|
||||
<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 && (
|
||||
|
|
|
|||
|
|
@ -99,9 +99,7 @@ export function ChatPreviewRail({
|
|||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className="pointer-events-none col-start-3 col-end-4 row-start-1 min-w-0 overflow-hidden"
|
||||
>
|
||||
<div className="pointer-events-none col-start-3 col-end-4 row-start-1 min-w-0 overflow-hidden">
|
||||
<PreviewPane
|
||||
onRestartServer={onRestartServer}
|
||||
reloadRequest={previewReloadRequest}
|
||||
|
|
|
|||
|
|
@ -1,9 +1,9 @@
|
|||
import { useStore } from '@nanostores/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 type { SetTitlebarToolGroup, TitlebarTool } from '@/app/shell/titlebar-controls'
|
||||
import { Bug, Check, Copy, PanelBottom, RefreshCw, Send, Trash2, X } from '@/lib/icons'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { $composerDraft, setComposerDraft } from '@/store/composer'
|
||||
import { notify, notifyError } from '@/store/notifications'
|
||||
|
|
@ -265,12 +265,7 @@ async function writeClipboardText(text: string) {
|
|||
}
|
||||
}
|
||||
|
||||
export function PreviewPane({
|
||||
onRestartServer,
|
||||
reloadRequest = 0,
|
||||
setTitlebarToolGroup,
|
||||
target
|
||||
}: PreviewPaneProps) {
|
||||
export function PreviewPane({ onRestartServer, reloadRequest = 0, setTitlebarToolGroup, target }: PreviewPaneProps) {
|
||||
const consoleBodyRef = useRef<HTMLDivElement | null>(null)
|
||||
const consoleShouldStickRef = useRef(true)
|
||||
const hostRef = useRef<HTMLDivElement | null>(null)
|
||||
|
|
@ -295,6 +290,7 @@ export function PreviewPane({
|
|||
|
||||
const previewLabel =
|
||||
target.label && target.label.replace(/\/$/, '') !== currentLabel.replace(/\/$/, '') ? target.label : currentLabel
|
||||
|
||||
const restartingServer =
|
||||
previewServerRestart?.status === 'running' &&
|
||||
(previewServerRestart.url === target.url || previewServerRestart.url === currentUrl)
|
||||
|
|
@ -532,10 +528,10 @@ export function PreviewPane({
|
|||
previewServerRestart.status === 'running'
|
||||
? previewServerRestart.message
|
||||
: previewServerRestart.status === 'complete'
|
||||
? `Hermes finished restarting the preview server${
|
||||
previewServerRestart.message ? `: ${previewServerRestart.message}` : ''
|
||||
}`
|
||||
: `Server restart failed: ${previewServerRestart.message || 'unknown error'}`
|
||||
? `Hermes finished restarting the preview server${
|
||||
previewServerRestart.message ? `: ${previewServerRestart.message}` : ''
|
||||
}`
|
||||
: `Server restart failed: ${previewServerRestart.message || 'unknown error'}`
|
||||
})
|
||||
|
||||
if (previewServerRestart.status === 'complete') {
|
||||
|
|
@ -549,6 +545,7 @@ export function PreviewPane({
|
|||
}
|
||||
|
||||
const taskId = previewServerRestart.taskId
|
||||
|
||||
const timer = window.setTimeout(() => {
|
||||
failPreviewServerRestart(
|
||||
taskId,
|
||||
|
|
@ -578,7 +575,11 @@ export function PreviewPane({
|
|||
}, [appendConsoleEntry, reloadPreview, reloadRequest, target.kind])
|
||||
|
||||
useEffect(() => {
|
||||
if (target.kind !== 'file' || !window.hermesDesktop?.watchPreviewFile || !window.hermesDesktop?.onPreviewFileChanged) {
|
||||
if (
|
||||
target.kind !== 'file' ||
|
||||
!window.hermesDesktop?.watchPreviewFile ||
|
||||
!window.hermesDesktop?.onPreviewFileChanged
|
||||
) {
|
||||
return
|
||||
}
|
||||
|
||||
|
|
@ -688,6 +689,7 @@ export function PreviewPane({
|
|||
message?: string
|
||||
sourceId?: string
|
||||
}
|
||||
|
||||
const message = detail.message || ''
|
||||
|
||||
appendConsoleEntry({
|
||||
|
|
@ -783,7 +785,10 @@ export function PreviewPane({
|
|||
</div>
|
||||
</div>
|
||||
|
||||
<div className="pointer-events-auto relative min-h-0 flex-1 overflow-hidden bg-background" ref={previewContentRef}>
|
||||
<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}
|
||||
|
|
@ -843,7 +848,9 @@ export function PreviewPane({
|
|||
onClick={async () => {
|
||||
await copyConsoleText(
|
||||
sendableLogs,
|
||||
visibleSelection.length > 0 ? `${visibleSelection.length} selected entries` : 'All console entries'
|
||||
visibleSelection.length > 0
|
||||
? `${visibleSelection.length} selected entries`
|
||||
: 'All console entries'
|
||||
)
|
||||
setCopiedAll(true)
|
||||
setTimeout(() => setCopiedAll(false), 1500)
|
||||
|
|
@ -869,7 +876,10 @@ export function PreviewPane({
|
|||
</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}>
|
||||
<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)
|
||||
|
|
|
|||
|
|
@ -1,9 +1,9 @@
|
|||
'use client'
|
||||
|
||||
import { FolderOpen, GitBranch, Pencil } from 'lucide-react'
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { FolderOpen, GitBranch, Pencil } from '@/lib/icons'
|
||||
|
||||
import { RailSection } from './rail-section'
|
||||
|
||||
|
|
|
|||
|
|
@ -1,7 +1,6 @@
|
|||
'use client'
|
||||
|
||||
import { ChevronDown } from 'lucide-react'
|
||||
|
||||
import { ChevronDown } from '@/lib/icons'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
interface RailActionRowProps {
|
||||
|
|
|
|||
|
|
@ -1,6 +1,5 @@
|
|||
'use client'
|
||||
|
||||
import { ChevronDown } from 'lucide-react'
|
||||
import { type ReactNode, useState } from 'react'
|
||||
|
||||
import {
|
||||
|
|
@ -11,6 +10,7 @@ import {
|
|||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger
|
||||
} from '@/components/ui/dropdown-menu'
|
||||
import { ChevronDown } from '@/lib/icons'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
export interface RailSelectOption {
|
||||
|
|
|
|||
|
|
@ -1,5 +1,4 @@
|
|||
import { useStore } from '@nanostores/react'
|
||||
import { ChevronDown, Layers3, Pin, Plus, RefreshCw, Sparkles } from 'lucide-react'
|
||||
import { useMemo } from 'react'
|
||||
import type * as React from 'react'
|
||||
|
||||
|
|
@ -16,6 +15,7 @@ import {
|
|||
} from '@/components/ui/sidebar'
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
import type { SessionInfo } from '@/hermes'
|
||||
import { Brain, ChevronDown, Layers3, Pin, Plus, RefreshCw } from '@/lib/icons'
|
||||
import { cn } from '@/lib/utils'
|
||||
import {
|
||||
$pinnedSessionIds,
|
||||
|
|
@ -41,7 +41,7 @@ const SIDEBAR_NAV: SidebarNavItem[] = [
|
|||
icon: Plus,
|
||||
action: 'new-session'
|
||||
},
|
||||
{ id: 'skills', label: 'Skills', icon: Sparkles, route: SKILLS_ROUTE },
|
||||
{ id: 'skills', label: 'Skills', icon: Brain, route: SKILLS_ROUTE },
|
||||
{ id: 'artifacts', label: 'Artifacts', icon: Layers3, route: ARTIFACTS_ROUTE }
|
||||
]
|
||||
|
||||
|
|
|
|||
|
|
@ -1,4 +1,12 @@
|
|||
import { Archive, Copy, Pencil, Pin, Trash2 } from 'lucide-react'
|
||||
import {
|
||||
IconArchive,
|
||||
IconBookmark,
|
||||
IconBookmarkFilled,
|
||||
IconCircleX,
|
||||
IconCopy,
|
||||
IconFileDownload,
|
||||
IconPencil
|
||||
} from '@tabler/icons-react'
|
||||
import type * as React from 'react'
|
||||
import type { ReactNode } from 'react'
|
||||
|
||||
|
|
@ -10,6 +18,7 @@ import {
|
|||
DropdownMenuTrigger
|
||||
} from '@/components/ui/dropdown-menu'
|
||||
import { triggerHaptic } from '@/lib/haptics'
|
||||
import { exportSession } from '@/lib/session-export'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { notify, notifyError } from '@/store/notifications'
|
||||
|
||||
|
|
@ -60,19 +69,30 @@ export function SessionActionsMenu({
|
|||
onPin?.()
|
||||
}}
|
||||
>
|
||||
<Pin />
|
||||
{pinned ? <IconBookmarkFilled /> : <IconBookmark />}
|
||||
<span>{pinned ? 'Unpin' : 'Pin'}</span>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem className={itemClass} onSelect={() => void copyId()}>
|
||||
<Copy />
|
||||
<IconCopy />
|
||||
<span>Copy ID</span>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
className={itemClass}
|
||||
disabled={!sessionId}
|
||||
onSelect={() => {
|
||||
triggerHaptic('selection')
|
||||
void exportSession(sessionId, { title })
|
||||
}}
|
||||
>
|
||||
<IconFileDownload />
|
||||
<span>Export</span>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem className={itemClass}>
|
||||
<Pencil />
|
||||
<IconPencil />
|
||||
<span>Rename</span>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem className={itemClass}>
|
||||
<Archive />
|
||||
<IconArchive />
|
||||
<span>Add to project</span>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator className="my-3" />
|
||||
|
|
@ -85,7 +105,7 @@ export function SessionActionsMenu({
|
|||
}}
|
||||
variant="destructive"
|
||||
>
|
||||
<Trash2 />
|
||||
<IconCircleX />
|
||||
<span>Delete</span>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
|
|
|
|||
|
|
@ -1,10 +1,10 @@
|
|||
import { MoreVertical } from 'lucide-react'
|
||||
import type * as React from 'react'
|
||||
|
||||
import { Button } from '@/components/ui/button'
|
||||
import type { SessionInfo } from '@/hermes'
|
||||
import { sessionTitle } from '@/lib/chat-runtime'
|
||||
import { triggerHaptic } from '@/lib/haptics'
|
||||
import { MoreVertical } from '@/lib/icons'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
import { SessionActionsMenu } from './session-actions-menu'
|
||||
|
|
|
|||
870
apps/desktop/src/app/command-center/index.tsx
Normal file
870
apps/desktop/src/app/command-center/index.tsx
Normal file
|
|
@ -0,0 +1,870 @@
|
|||
import { useStore } from '@nanostores/react'
|
||||
import {
|
||||
IconBookmark,
|
||||
IconBookmarkFilled,
|
||||
IconDownload,
|
||||
IconLoader2,
|
||||
IconRefresh,
|
||||
IconSparkles,
|
||||
IconTrash
|
||||
} from '@tabler/icons-react'
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
|
||||
import {
|
||||
getActionStatus,
|
||||
getAuxiliaryModels,
|
||||
getGlobalModelInfo,
|
||||
getGlobalModelOptions,
|
||||
getLogs,
|
||||
getStatus,
|
||||
restartGateway,
|
||||
searchSessions,
|
||||
setModelAssignment,
|
||||
updateHermes
|
||||
} from '@/hermes'
|
||||
import type {
|
||||
ActionStatusResponse,
|
||||
AuxiliaryModelsResponse,
|
||||
ModelOptionProvider,
|
||||
SessionInfo,
|
||||
SessionSearchResult as SessionSearchApiResult,
|
||||
StatusResponse
|
||||
} from '@/hermes'
|
||||
import { sessionTitle } from '@/lib/chat-runtime'
|
||||
import { triggerHaptic } from '@/lib/haptics'
|
||||
import { Activity, AlertCircle, Cpu, Pin } from '@/lib/icons'
|
||||
import { exportSession } from '@/lib/session-export'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { $pinnedSessionIds, pinSession, unpinSession } from '@/store/layout'
|
||||
import { $sessions } from '@/store/session'
|
||||
|
||||
import { OverlayActionButton, OverlayCard, overlayCardClass, OverlayIconButton } from '../overlays/overlay-chrome'
|
||||
import { OverlaySearchInput } from '../overlays/overlay-search-input'
|
||||
import { OverlayMain, OverlayNavItem, OverlaySidebar, OverlaySplitLayout } from '../overlays/overlay-split-layout'
|
||||
import { OverlayView } from '../overlays/overlay-view'
|
||||
import { ARTIFACTS_ROUTE, NEW_CHAT_ROUTE, SETTINGS_ROUTE, SKILLS_ROUTE } from '../routes'
|
||||
|
||||
type CommandCenterSection = 'models' | 'sessions' | 'system'
|
||||
|
||||
interface CommandCenterViewProps {
|
||||
onClose: () => void
|
||||
onDeleteSession: (sessionId: string) => Promise<void>
|
||||
onMainModelChanged?: (provider: string, model: string) => void
|
||||
onNavigateRoute: (path: string) => void
|
||||
onOpenSession: (sessionId: string) => void
|
||||
}
|
||||
|
||||
const SECTION_LABELS: Record<CommandCenterSection, string> = {
|
||||
sessions: 'Sessions',
|
||||
system: 'System',
|
||||
models: 'Models'
|
||||
}
|
||||
|
||||
const SECTION_DESCRIPTIONS: Record<CommandCenterSection, string> = {
|
||||
sessions: 'Search and manage sessions',
|
||||
system: 'Status, logs, and system actions',
|
||||
models: 'Global and auxiliary model controls'
|
||||
}
|
||||
|
||||
interface NavigationSearchEntry {
|
||||
detail?: string
|
||||
id: string
|
||||
route: string
|
||||
title: string
|
||||
}
|
||||
|
||||
interface SectionSearchEntry {
|
||||
detail?: string
|
||||
id: string
|
||||
section: CommandCenterSection
|
||||
title: string
|
||||
}
|
||||
|
||||
const NAVIGATION_SEARCH_ENTRIES: readonly NavigationSearchEntry[] = [
|
||||
{ id: 'nav-new-chat', route: NEW_CHAT_ROUTE, title: 'New chat', detail: 'Start a fresh session' },
|
||||
{ id: 'nav-settings', route: SETTINGS_ROUTE, title: 'Settings', detail: 'Configure Hermes desktop' },
|
||||
{ id: 'nav-skills', route: SKILLS_ROUTE, title: 'Skills', detail: 'Enable and inspect skills' },
|
||||
{ id: 'nav-artifacts', route: ARTIFACTS_ROUTE, title: 'Artifacts', detail: 'Browse generated outputs' }
|
||||
]
|
||||
|
||||
const SECTION_SEARCH_ENTRIES: readonly SectionSearchEntry[] = [
|
||||
{ id: 'section-sessions', section: 'sessions', title: 'Sessions panel', detail: 'Search, pin, and manage sessions' },
|
||||
{ id: 'section-system', section: 'system', title: 'System panel', detail: 'Gateway status, logs, restart/update' },
|
||||
{ id: 'section-models', section: 'models', title: 'Models panel', detail: 'Main and auxiliary model assignments' }
|
||||
]
|
||||
|
||||
interface SessionSearchHit {
|
||||
detail?: string
|
||||
kind: 'session'
|
||||
sessionId: string
|
||||
snippet: string
|
||||
title: string
|
||||
}
|
||||
|
||||
interface RouteSearchHit {
|
||||
detail?: string
|
||||
kind: 'route'
|
||||
route: string
|
||||
title: string
|
||||
}
|
||||
|
||||
interface SectionSearchHit {
|
||||
detail?: string
|
||||
kind: 'section'
|
||||
section: CommandCenterSection
|
||||
title: string
|
||||
}
|
||||
|
||||
type CommandCenterSearchResult = RouteSearchHit | SectionSearchHit | SessionSearchHit
|
||||
|
||||
interface CommandCenterSearchProvider {
|
||||
id: string
|
||||
label: string
|
||||
search: (query: string) => Promise<CommandCenterSearchResult[]>
|
||||
}
|
||||
|
||||
interface CommandCenterSearchGroup {
|
||||
id: string
|
||||
label: string
|
||||
results: CommandCenterSearchResult[]
|
||||
}
|
||||
|
||||
function formatTimestamp(value?: number | null): string {
|
||||
if (!value) {
|
||||
return ''
|
||||
}
|
||||
|
||||
const date = new Date(value * 1000)
|
||||
|
||||
if (Number.isNaN(date.getTime())) {
|
||||
return ''
|
||||
}
|
||||
|
||||
return new Intl.DateTimeFormat(undefined, { dateStyle: 'medium', timeStyle: 'short' }).format(date)
|
||||
}
|
||||
|
||||
function splitSessionSearchResult(result: SessionSearchApiResult, sessionsById: Map<string, SessionInfo>) {
|
||||
const row = sessionsById.get(result.session_id)
|
||||
const title = row ? sessionTitle(row) : result.session_id
|
||||
const detail = [result.model, result.source].filter(Boolean).join(' · ')
|
||||
|
||||
return { detail, title }
|
||||
}
|
||||
|
||||
function matchesSearchQuery(query: string, ...values: Array<string | undefined>): boolean {
|
||||
const normalized = query.trim().toLowerCase()
|
||||
|
||||
if (!normalized) {
|
||||
return true
|
||||
}
|
||||
|
||||
return values.some(value => value?.toLowerCase().includes(normalized))
|
||||
}
|
||||
|
||||
function useDebouncedValue<T>(value: T, delayMs: number): T {
|
||||
const [debounced, setDebounced] = useState(value)
|
||||
|
||||
useEffect(() => {
|
||||
const id = window.setTimeout(() => setDebounced(value), delayMs)
|
||||
|
||||
return () => window.clearTimeout(id)
|
||||
}, [delayMs, value])
|
||||
|
||||
return debounced
|
||||
}
|
||||
|
||||
export function CommandCenterView({
|
||||
onClose,
|
||||
onDeleteSession,
|
||||
onMainModelChanged,
|
||||
onNavigateRoute,
|
||||
onOpenSession
|
||||
}: CommandCenterViewProps) {
|
||||
const sessions = useStore($sessions)
|
||||
const pinnedSessionIds = useStore($pinnedSessionIds)
|
||||
const [section, setSection] = useState<CommandCenterSection>('sessions')
|
||||
const [query, setQuery] = useState('')
|
||||
const [searchLoading, setSearchLoading] = useState(false)
|
||||
const [searchGroups, setSearchGroups] = useState<CommandCenterSearchGroup[]>([])
|
||||
const [status, setStatus] = useState<StatusResponse | null>(null)
|
||||
const [logs, setLogs] = useState<string[]>([])
|
||||
const [systemLoading, setSystemLoading] = useState(false)
|
||||
const [systemError, setSystemError] = useState('')
|
||||
const [systemAction, setSystemAction] = useState<ActionStatusResponse | null>(null)
|
||||
const [modelsLoading, setModelsLoading] = useState(false)
|
||||
const [modelsError, setModelsError] = useState('')
|
||||
const [mainModel, setMainModel] = useState<{ model: string; provider: string } | null>(null)
|
||||
const [providers, setProviders] = useState<ModelOptionProvider[]>([])
|
||||
const [selectedProvider, setSelectedProvider] = useState('')
|
||||
const [selectedModel, setSelectedModel] = useState('')
|
||||
const [auxiliary, setAuxiliary] = useState<AuxiliaryModelsResponse | null>(null)
|
||||
const [applyingModel, setApplyingModel] = useState(false)
|
||||
const searchRequestRef = useRef(0)
|
||||
|
||||
const debouncedQuery = useDebouncedValue(query.trim(), 180)
|
||||
|
||||
const sessionsById = useMemo(() => new Map(sessions.map(session => [session.id, session])), [sessions])
|
||||
|
||||
const filteredSessions = useMemo(
|
||||
() =>
|
||||
[...sessions].sort((a, b) => {
|
||||
const left = a.last_active || a.started_at || 0
|
||||
const right = b.last_active || b.started_at || 0
|
||||
|
||||
return right - left
|
||||
}),
|
||||
[sessions]
|
||||
)
|
||||
|
||||
const selectedProviderModels = useMemo(
|
||||
() => providers.find(provider => provider.slug === selectedProvider)?.models ?? [],
|
||||
[providers, selectedProvider]
|
||||
)
|
||||
|
||||
const searchProviders = useMemo<readonly CommandCenterSearchProvider[]>(
|
||||
() => [
|
||||
{
|
||||
id: 'navigation',
|
||||
label: 'Navigate',
|
||||
search: async searchQuery => {
|
||||
const routeHits: RouteSearchHit[] = NAVIGATION_SEARCH_ENTRIES.filter(entry =>
|
||||
matchesSearchQuery(searchQuery, entry.title, entry.detail, entry.route)
|
||||
).map(entry => ({
|
||||
detail: entry.detail,
|
||||
kind: 'route',
|
||||
route: entry.route,
|
||||
title: entry.title
|
||||
}))
|
||||
|
||||
const sectionHits: SectionSearchHit[] = SECTION_SEARCH_ENTRIES.filter(entry =>
|
||||
matchesSearchQuery(searchQuery, entry.title, entry.detail, SECTION_LABELS[entry.section])
|
||||
).map(entry => ({
|
||||
detail: entry.detail,
|
||||
kind: 'section',
|
||||
section: entry.section,
|
||||
title: entry.title
|
||||
}))
|
||||
|
||||
return [...routeHits, ...sectionHits]
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 'sessions',
|
||||
label: 'Sessions',
|
||||
search: async searchQuery => {
|
||||
const response = await searchSessions(searchQuery)
|
||||
|
||||
return response.results.map(result => {
|
||||
const { detail, title } = splitSessionSearchResult(result, sessionsById)
|
||||
|
||||
return {
|
||||
detail,
|
||||
kind: 'session',
|
||||
sessionId: result.session_id,
|
||||
snippet: result.snippet || '',
|
||||
title
|
||||
} satisfies SessionSearchHit
|
||||
})
|
||||
}
|
||||
}
|
||||
],
|
||||
[sessionsById]
|
||||
)
|
||||
|
||||
const refreshSystem = useCallback(async () => {
|
||||
setSystemLoading(true)
|
||||
setSystemError('')
|
||||
|
||||
try {
|
||||
const [nextStatus, nextLogs] = await Promise.all([
|
||||
getStatus(),
|
||||
getLogs({
|
||||
file: 'agent',
|
||||
lines: 120
|
||||
})
|
||||
])
|
||||
|
||||
setStatus(nextStatus)
|
||||
setLogs(nextLogs.lines)
|
||||
} catch (error) {
|
||||
setSystemError(error instanceof Error ? error.message : String(error))
|
||||
} finally {
|
||||
setSystemLoading(false)
|
||||
}
|
||||
}, [])
|
||||
|
||||
const refreshModels = useCallback(async () => {
|
||||
setModelsLoading(true)
|
||||
setModelsError('')
|
||||
|
||||
try {
|
||||
const [modelInfo, modelOptions, auxiliaryModels] = await Promise.all([
|
||||
getGlobalModelInfo(),
|
||||
getGlobalModelOptions(),
|
||||
getAuxiliaryModels()
|
||||
])
|
||||
|
||||
setMainModel({ model: modelInfo.model, provider: modelInfo.provider })
|
||||
setProviders(modelOptions.providers || [])
|
||||
setSelectedProvider(prev => prev || modelInfo.provider)
|
||||
setSelectedModel(prev => prev || modelInfo.model)
|
||||
setAuxiliary(auxiliaryModels)
|
||||
} catch (error) {
|
||||
setModelsError(error instanceof Error ? error.message : String(error))
|
||||
} finally {
|
||||
setModelsLoading(false)
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
const onKeyDown = (event: KeyboardEvent) => {
|
||||
if (event.key === 'Escape') {
|
||||
event.preventDefault()
|
||||
triggerHaptic('close')
|
||||
onClose()
|
||||
}
|
||||
}
|
||||
|
||||
window.addEventListener('keydown', onKeyDown)
|
||||
|
||||
return () => window.removeEventListener('keydown', onKeyDown)
|
||||
}, [onClose])
|
||||
|
||||
useEffect(() => {
|
||||
if (!debouncedQuery) {
|
||||
setSearchGroups([])
|
||||
setSearchLoading(false)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
const requestId = searchRequestRef.current + 1
|
||||
searchRequestRef.current = requestId
|
||||
setSearchLoading(true)
|
||||
|
||||
void Promise.all(
|
||||
searchProviders.map(async provider => ({
|
||||
id: provider.id,
|
||||
label: provider.label,
|
||||
results: await provider.search(debouncedQuery)
|
||||
}))
|
||||
)
|
||||
.then(groups => {
|
||||
if (searchRequestRef.current === requestId) {
|
||||
setSearchGroups(groups.filter(group => group.results.length > 0))
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
if (searchRequestRef.current === requestId) {
|
||||
setSearchGroups([])
|
||||
}
|
||||
})
|
||||
.finally(() => {
|
||||
if (searchRequestRef.current === requestId) {
|
||||
setSearchLoading(false)
|
||||
}
|
||||
})
|
||||
}, [debouncedQuery, searchProviders])
|
||||
|
||||
useEffect(() => {
|
||||
if (section === 'system' && !status && !systemLoading) {
|
||||
void refreshSystem()
|
||||
}
|
||||
}, [refreshSystem, section, status, systemLoading])
|
||||
|
||||
useEffect(() => {
|
||||
if (section === 'models' && !mainModel && !modelsLoading) {
|
||||
void refreshModels()
|
||||
}
|
||||
}, [mainModel, modelsLoading, refreshModels, section])
|
||||
|
||||
useEffect(() => {
|
||||
if (!selectedProviderModels.length) {
|
||||
return
|
||||
}
|
||||
|
||||
if (!selectedProviderModels.includes(selectedModel)) {
|
||||
setSelectedModel(selectedProviderModels[0])
|
||||
}
|
||||
}, [selectedModel, selectedProviderModels])
|
||||
|
||||
const showGlobalSearchResults = debouncedQuery.length > 0
|
||||
const hasGlobalSearchResults = searchGroups.length > 0
|
||||
const sessionListHasResults = filteredSessions.length > 0
|
||||
|
||||
const runSystemAction = useCallback(
|
||||
async (kind: 'restart' | 'update') => {
|
||||
setSystemError('')
|
||||
|
||||
try {
|
||||
const started = kind === 'restart' ? await restartGateway() : await updateHermes()
|
||||
let nextStatus: ActionStatusResponse | null = null
|
||||
|
||||
for (let attempt = 0; attempt < 18; attempt += 1) {
|
||||
await new Promise(resolve => window.setTimeout(resolve, 1200))
|
||||
const polled = await getActionStatus(started.name, 180)
|
||||
nextStatus = polled
|
||||
setSystemAction(polled)
|
||||
|
||||
if (!polled.running) {
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if (!nextStatus) {
|
||||
setSystemAction({
|
||||
exit_code: null,
|
||||
lines: ['Action started, waiting for status...'],
|
||||
name: started.name,
|
||||
pid: started.pid,
|
||||
running: true
|
||||
})
|
||||
}
|
||||
} catch (error) {
|
||||
setSystemError(error instanceof Error ? error.message : String(error))
|
||||
} finally {
|
||||
void refreshSystem()
|
||||
}
|
||||
},
|
||||
[refreshSystem]
|
||||
)
|
||||
|
||||
const applyMainModel = useCallback(async () => {
|
||||
if (!selectedProvider || !selectedModel) {
|
||||
return
|
||||
}
|
||||
|
||||
setApplyingModel(true)
|
||||
setModelsError('')
|
||||
|
||||
try {
|
||||
const result = await setModelAssignment({
|
||||
model: selectedModel,
|
||||
provider: selectedProvider,
|
||||
scope: 'main'
|
||||
})
|
||||
|
||||
const provider = result.provider || selectedProvider
|
||||
const model = result.model || selectedModel
|
||||
setMainModel({ provider, model })
|
||||
onMainModelChanged?.(provider, model)
|
||||
await refreshModels()
|
||||
} catch (error) {
|
||||
setModelsError(error instanceof Error ? error.message : String(error))
|
||||
} finally {
|
||||
setApplyingModel(false)
|
||||
}
|
||||
}, [onMainModelChanged, refreshModels, selectedModel, selectedProvider])
|
||||
|
||||
const setAuxiliaryToMain = useCallback(
|
||||
async (task: string) => {
|
||||
if (!mainModel) {
|
||||
return
|
||||
}
|
||||
|
||||
setApplyingModel(true)
|
||||
setModelsError('')
|
||||
|
||||
try {
|
||||
await setModelAssignment({
|
||||
model: mainModel.model,
|
||||
provider: mainModel.provider,
|
||||
scope: 'auxiliary',
|
||||
task
|
||||
})
|
||||
await refreshModels()
|
||||
} catch (error) {
|
||||
setModelsError(error instanceof Error ? error.message : String(error))
|
||||
} finally {
|
||||
setApplyingModel(false)
|
||||
}
|
||||
},
|
||||
[mainModel, refreshModels]
|
||||
)
|
||||
|
||||
const resetAuxiliaryModels = useCallback(async () => {
|
||||
if (!mainModel) {
|
||||
return
|
||||
}
|
||||
|
||||
setApplyingModel(true)
|
||||
setModelsError('')
|
||||
|
||||
try {
|
||||
await setModelAssignment({
|
||||
model: mainModel.model,
|
||||
provider: mainModel.provider,
|
||||
scope: 'auxiliary',
|
||||
task: '__reset__'
|
||||
})
|
||||
await refreshModels()
|
||||
} catch (error) {
|
||||
setModelsError(error instanceof Error ? error.message : String(error))
|
||||
} finally {
|
||||
setApplyingModel(false)
|
||||
}
|
||||
}, [mainModel, refreshModels])
|
||||
|
||||
const handleSearchSelect = useCallback(
|
||||
(result: CommandCenterSearchResult) => {
|
||||
if (result.kind === 'route') {
|
||||
onNavigateRoute(result.route)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
if (result.kind === 'section') {
|
||||
setSection(result.section)
|
||||
setQuery('')
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
onOpenSession(result.sessionId)
|
||||
},
|
||||
[onNavigateRoute, onOpenSession]
|
||||
)
|
||||
|
||||
return (
|
||||
<OverlayView
|
||||
closeLabel="Close command center"
|
||||
headerContent={
|
||||
<OverlaySearchInput
|
||||
containerClassName="w-[min(36rem,calc(100vw-32rem))] min-w-80"
|
||||
loading={searchLoading}
|
||||
onChange={next => setQuery(next)}
|
||||
placeholder="Search sessions, views, and actions"
|
||||
value={query}
|
||||
/>
|
||||
}
|
||||
onClose={onClose}
|
||||
>
|
||||
<OverlaySplitLayout>
|
||||
<OverlaySidebar>
|
||||
{(['sessions', 'system', 'models'] as const).map(value => (
|
||||
<OverlayNavItem
|
||||
active={section === value}
|
||||
icon={value === 'sessions' ? Pin : value === 'system' ? Activity : Cpu}
|
||||
key={value}
|
||||
label={SECTION_LABELS[value]}
|
||||
onClick={() => setSection(value)}
|
||||
/>
|
||||
))}
|
||||
</OverlaySidebar>
|
||||
|
||||
<OverlayMain>
|
||||
<header className="mb-4 flex items-center justify-between gap-2">
|
||||
<div>
|
||||
<h2 className="text-sm font-semibold text-foreground">{SECTION_LABELS[section]}</h2>
|
||||
<p className="text-xs text-muted-foreground">{SECTION_DESCRIPTIONS[section]}</p>
|
||||
</div>
|
||||
{section === 'system' && (
|
||||
<OverlayActionButton disabled={systemLoading} onClick={() => void refreshSystem()}>
|
||||
<IconRefresh className={cn('mr-1.5 size-3.5', systemLoading && 'animate-spin')} />
|
||||
{systemLoading ? 'Refreshing...' : 'Refresh'}
|
||||
</OverlayActionButton>
|
||||
)}
|
||||
{section === 'models' && (
|
||||
<OverlayActionButton disabled={modelsLoading} onClick={() => void refreshModels()}>
|
||||
<IconRefresh className={cn('mr-1.5 size-3.5', modelsLoading && 'animate-spin')} />
|
||||
{modelsLoading ? 'Refreshing...' : 'Refresh'}
|
||||
</OverlayActionButton>
|
||||
)}
|
||||
</header>
|
||||
|
||||
{showGlobalSearchResults ? (
|
||||
<div className="min-h-0 flex-1 overflow-y-auto pr-1">
|
||||
{!hasGlobalSearchResults ? (
|
||||
<OverlayCard className="px-3 py-4 text-sm text-muted-foreground">
|
||||
No matching results found.
|
||||
</OverlayCard>
|
||||
) : (
|
||||
<div className="grid gap-3">
|
||||
{searchGroups.map(group => (
|
||||
<section className="grid gap-1.5" key={group.id}>
|
||||
<h3 className="px-0.5 text-xs font-semibold tracking-[0.08em] text-muted-foreground/80 uppercase">
|
||||
{group.label}
|
||||
</h3>
|
||||
{group.results.map(result => {
|
||||
if (result.kind === 'session') {
|
||||
const pinned = pinnedSessionIds.includes(result.sessionId)
|
||||
|
||||
return (
|
||||
<OverlayCard className="p-2.5" key={`${group.id}:${result.sessionId}:${result.snippet}`}>
|
||||
<button
|
||||
className="w-full text-left"
|
||||
onClick={() => handleSearchSelect(result)}
|
||||
type="button"
|
||||
>
|
||||
<div className="truncate text-sm font-medium text-foreground">{result.title}</div>
|
||||
<div className="mt-0.5 text-xs text-muted-foreground">
|
||||
{result.detail || result.sessionId}
|
||||
</div>
|
||||
{result.snippet && (
|
||||
<div className="mt-1 whitespace-pre-wrap text-xs text-muted-foreground/85">
|
||||
{result.snippet}
|
||||
</div>
|
||||
)}
|
||||
</button>
|
||||
<div className="mt-2 flex gap-1">
|
||||
<OverlayIconButton
|
||||
onClick={event => {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
pinned ? unpinSession(result.sessionId) : pinSession(result.sessionId)
|
||||
}}
|
||||
title={pinned ? 'Unpin session' : 'Pin session'}
|
||||
>
|
||||
{pinned ? (
|
||||
<IconBookmarkFilled className="size-3.5" />
|
||||
) : (
|
||||
<IconBookmark className="size-3.5" />
|
||||
)}
|
||||
</OverlayIconButton>
|
||||
<OverlayIconButton
|
||||
onClick={event => {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
void exportSession(result.sessionId, { title: result.title })
|
||||
}}
|
||||
title="Export session"
|
||||
>
|
||||
<IconDownload className="size-3.5" />
|
||||
</OverlayIconButton>
|
||||
<OverlayIconButton
|
||||
className="hover:text-destructive"
|
||||
onClick={event => {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
void onDeleteSession(result.sessionId)
|
||||
}}
|
||||
title="Delete session"
|
||||
>
|
||||
<IconTrash className="size-3.5" />
|
||||
</OverlayIconButton>
|
||||
</div>
|
||||
</OverlayCard>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<button
|
||||
className={cn(
|
||||
overlayCardClass,
|
||||
'w-full px-3 py-2 text-left transition-colors hover:bg-[color-mix(in_srgb,var(--dt-muted)_48%,var(--dt-card))]'
|
||||
)}
|
||||
key={`${group.id}:${result.kind}:${result.title}`}
|
||||
onClick={() => handleSearchSelect(result)}
|
||||
type="button"
|
||||
>
|
||||
<div className="text-sm font-medium text-foreground">{result.title}</div>
|
||||
{result.detail && (
|
||||
<div className="mt-0.5 text-xs text-muted-foreground">{result.detail}</div>
|
||||
)}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</section>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : section === 'sessions' ? (
|
||||
<div className="min-h-0 flex-1 overflow-y-auto">
|
||||
{!sessionListHasResults ? (
|
||||
<OverlayCard className="px-3 py-4 text-sm text-muted-foreground">No sessions yet.</OverlayCard>
|
||||
) : (
|
||||
<div className="grid gap-1.5">
|
||||
{filteredSessions.map(session => {
|
||||
const pinned = pinnedSessionIds.includes(session.id)
|
||||
|
||||
return (
|
||||
<OverlayCard className="flex items-center gap-2 px-2.5 py-2" key={session.id}>
|
||||
<button
|
||||
className="min-w-0 flex-1 text-left"
|
||||
onClick={() => onOpenSession(session.id)}
|
||||
type="button"
|
||||
>
|
||||
<div className="truncate text-sm font-medium text-foreground">{sessionTitle(session)}</div>
|
||||
<div className="truncate text-xs text-muted-foreground">
|
||||
{formatTimestamp(session.last_active || session.started_at)}
|
||||
</div>
|
||||
</button>
|
||||
<OverlayIconButton
|
||||
onClick={() => (pinned ? unpinSession(session.id) : pinSession(session.id))}
|
||||
title={pinned ? 'Unpin session' : 'Pin session'}
|
||||
>
|
||||
{pinned ? <IconBookmarkFilled className="size-3.5" /> : <IconBookmark className="size-3.5" />}
|
||||
</OverlayIconButton>
|
||||
<OverlayIconButton
|
||||
onClick={() => void exportSession(session.id, { session, title: sessionTitle(session) })}
|
||||
title="Export session"
|
||||
>
|
||||
<IconDownload className="size-3.5" />
|
||||
</OverlayIconButton>
|
||||
<OverlayIconButton
|
||||
className="hover:text-destructive"
|
||||
onClick={() => void onDeleteSession(session.id)}
|
||||
title="Delete session"
|
||||
>
|
||||
<IconTrash className="size-3.5" />
|
||||
</OverlayIconButton>
|
||||
</OverlayCard>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : section === 'system' ? (
|
||||
<div className="grid min-h-0 flex-1 grid-rows-[auto_minmax(0,1fr)] gap-3">
|
||||
<OverlayCard className="p-3 text-sm">
|
||||
{status ? (
|
||||
<div className="grid gap-2">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<span
|
||||
className={cn(
|
||||
'size-2 rounded-full',
|
||||
status.gateway_running ? 'bg-emerald-500' : 'bg-amber-500'
|
||||
)}
|
||||
/>
|
||||
<span className="font-medium text-foreground">
|
||||
{status.gateway_running ? 'Gateway running' : 'Gateway not running'}
|
||||
</span>
|
||||
</div>
|
||||
<div className="mt-1 text-xs text-muted-foreground">
|
||||
Hermes {status.version} · Active sessions {status.active_sessions}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex shrink-0 items-center gap-1.5 whitespace-nowrap">
|
||||
<OverlayActionButton className="h-7 px-2.5" onClick={() => void runSystemAction('restart')}>
|
||||
Restart gateway
|
||||
</OverlayActionButton>
|
||||
<OverlayActionButton className="h-7 px-2.5" onClick={() => void runSystemAction('update')}>
|
||||
Update Hermes
|
||||
</OverlayActionButton>
|
||||
</div>
|
||||
</div>
|
||||
{systemAction && (
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{systemAction.name} ·{' '}
|
||||
{systemAction.running ? 'running' : systemAction.exit_code === 0 ? 'done' : 'failed'}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-xs text-muted-foreground">Loading status...</div>
|
||||
)}
|
||||
</OverlayCard>
|
||||
|
||||
<OverlayCard className="min-h-0 overflow-hidden p-2">
|
||||
<div className="mb-2 flex items-center justify-between">
|
||||
<span className="text-xs font-medium text-muted-foreground">Recent logs</span>
|
||||
{systemError && (
|
||||
<span className="inline-flex items-center gap-1 text-xs text-destructive">
|
||||
<AlertCircle className="size-3.5" />
|
||||
{systemError}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<pre className="h-full min-h-0 overflow-auto whitespace-pre-wrap wrap-break-word font-mono text-[0.65rem] leading-relaxed text-muted-foreground">
|
||||
{logs.length ? logs.join('\n') : 'No logs loaded yet.'}
|
||||
</pre>
|
||||
</OverlayCard>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid min-h-0 flex-1 grid-rows-[auto_auto_minmax(0,1fr)] gap-3">
|
||||
<OverlayCard className="p-3">
|
||||
{mainModel ? (
|
||||
<>
|
||||
<div className="text-sm font-medium text-foreground">Main model</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{mainModel.provider} / {mainModel.model}
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<div className="text-xs text-muted-foreground">Loading model state...</div>
|
||||
)}
|
||||
</OverlayCard>
|
||||
|
||||
<OverlayCard className="p-3">
|
||||
<div className="mb-2 text-xs font-medium text-muted-foreground">Set global main model</div>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<select
|
||||
className="h-8 min-w-36 rounded-md border border-border bg-background px-2 text-xs text-foreground"
|
||||
onChange={event => setSelectedProvider(event.target.value)}
|
||||
value={selectedProvider}
|
||||
>
|
||||
{(providers.length ? providers : [{ name: '—', slug: '', models: [] }]).map(provider => (
|
||||
<option key={provider.slug || 'none'} value={provider.slug}>
|
||||
{provider.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<select
|
||||
className="h-8 min-w-58 rounded-md border border-border bg-background px-2 text-xs text-foreground"
|
||||
onChange={event => setSelectedModel(event.target.value)}
|
||||
value={selectedModel}
|
||||
>
|
||||
{(selectedProviderModels.length ? selectedProviderModels : ['']).map(model => (
|
||||
<option key={model || 'none'} value={model}>
|
||||
{model || 'No models available'}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<OverlayActionButton
|
||||
disabled={!selectedProvider || !selectedModel || applyingModel}
|
||||
onClick={() => void applyMainModel()}
|
||||
>
|
||||
{applyingModel ? (
|
||||
<IconLoader2 className="mr-1.5 size-3.5 animate-spin" />
|
||||
) : (
|
||||
<IconSparkles className="mr-1.5 size-3.5" />
|
||||
)}
|
||||
{applyingModel ? 'Applying...' : 'Apply'}
|
||||
</OverlayActionButton>
|
||||
</div>
|
||||
{modelsError && <div className="mt-2 text-xs text-destructive">{modelsError}</div>}
|
||||
</OverlayCard>
|
||||
|
||||
<OverlayCard className="min-h-0 overflow-auto p-2">
|
||||
<div className="mb-2 flex items-center justify-between">
|
||||
<span className="text-xs font-medium text-muted-foreground">Auxiliary assignments</span>
|
||||
<OverlayActionButton
|
||||
disabled={!mainModel || applyingModel}
|
||||
onClick={() => void resetAuxiliaryModels()}
|
||||
tone="subtle"
|
||||
>
|
||||
Reset all
|
||||
</OverlayActionButton>
|
||||
</div>
|
||||
<div className="grid gap-1.5">
|
||||
{(auxiliary?.tasks || []).map(task => (
|
||||
<OverlayCard className="flex items-center gap-2 px-2 py-1.5" key={task.task}>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="truncate text-xs font-medium text-foreground">{task.task}</div>
|
||||
<div className="truncate text-[0.65rem] text-muted-foreground">
|
||||
{task.provider} / {task.model}
|
||||
</div>
|
||||
</div>
|
||||
<OverlayActionButton
|
||||
disabled={!mainModel || applyingModel}
|
||||
onClick={() => void setAuxiliaryToMain(task.task)}
|
||||
>
|
||||
Set to main
|
||||
</OverlayActionButton>
|
||||
</OverlayCard>
|
||||
))}
|
||||
{!auxiliary?.tasks?.length && (
|
||||
<div className="text-xs text-muted-foreground">No auxiliary assignments reported.</div>
|
||||
)}
|
||||
</div>
|
||||
</OverlayCard>
|
||||
</div>
|
||||
)}
|
||||
</OverlayMain>
|
||||
</OverlaySplitLayout>
|
||||
</OverlayView>
|
||||
)
|
||||
}
|
||||
|
|
@ -62,10 +62,18 @@ import { ArtifactsView } from './artifacts'
|
|||
import { ChatView, PREVIEW_RAIL_WIDTH, SESSION_INSPECTOR_WIDTH } from './chat'
|
||||
import { useComposerActions } from './chat/hooks/use-composer-actions'
|
||||
import { ChatSidebar } from './chat/sidebar'
|
||||
import { CommandCenterView } from './command-center'
|
||||
import { useGatewayBoot } from './gateway/hooks/use-gateway-boot'
|
||||
import { useGatewayRequest } from './gateway/hooks/use-gateway-request'
|
||||
import { ModelPickerOverlay } from './model-picker-overlay'
|
||||
import { appViewForPath, isNewChatRoute, NEW_CHAT_ROUTE, routeSessionId, sessionRoute } from './routes'
|
||||
import {
|
||||
appViewForPath,
|
||||
COMMAND_CENTER_ROUTE,
|
||||
isNewChatRoute,
|
||||
NEW_CHAT_ROUTE,
|
||||
routeSessionId,
|
||||
sessionRoute
|
||||
} from './routes'
|
||||
import { useMessageStream } from './session/hooks/use-message-stream'
|
||||
import { usePromptActions } from './session/hooks/use-prompt-actions'
|
||||
import { useSessionActions } from './session/hooks/use-session-actions'
|
||||
|
|
@ -122,8 +130,9 @@ export function DesktopController() {
|
|||
routeTokenRef.current = routeToken
|
||||
const getRouteToken = useCallback(() => routeTokenRef.current, [])
|
||||
const settingsOpen = currentView === 'settings'
|
||||
const commandCenterOpen = currentView === 'command-center'
|
||||
const chatOpen = currentView === 'chat'
|
||||
const settingsReturnPathRef = useRef(NEW_CHAT_ROUTE)
|
||||
const overlayReturnPathRef = useRef(NEW_CHAT_ROUTE)
|
||||
const refreshSessionsRequestRef = useRef(0)
|
||||
|
||||
const [titlebarToolGroups, setTitlebarToolGroups] = useState<
|
||||
|
|
@ -164,15 +173,9 @@ export function DesktopController() {
|
|||
})
|
||||
}, [])
|
||||
|
||||
const leftTitlebarTools = useMemo(
|
||||
() => Object.values(titlebarToolGroups.left).flat(),
|
||||
[titlebarToolGroups.left]
|
||||
)
|
||||
const leftTitlebarTools = useMemo(() => Object.values(titlebarToolGroups.left).flat(), [titlebarToolGroups.left])
|
||||
|
||||
const titlebarTools = useMemo(
|
||||
() => Object.values(titlebarToolGroups.right).flat(),
|
||||
[titlebarToolGroups.right]
|
||||
)
|
||||
const titlebarTools = useMemo(() => Object.values(titlebarToolGroups.right).flat(), [titlebarToolGroups.right])
|
||||
|
||||
const toggleSelectedPin = useCallback(() => {
|
||||
const sessionId = $selectedStoredSessionId.get()
|
||||
|
|
@ -601,7 +604,11 @@ export function DesktopController() {
|
|||
for (const candidate of extractPreviewCandidates(text)) {
|
||||
const target = await desktop.normalizePreviewTarget(candidate, cwd || undefined).catch(() => null)
|
||||
|
||||
if (lastPreviewRouteRef.current !== routeKey || activeSessionIdRef.current !== sessionId || $currentCwd.get() !== cwd) {
|
||||
if (
|
||||
lastPreviewRouteRef.current !== routeKey ||
|
||||
activeSessionIdRef.current !== sessionId ||
|
||||
$currentCwd.get() !== cwd
|
||||
) {
|
||||
return
|
||||
}
|
||||
|
||||
|
|
@ -627,12 +634,14 @@ export function DesktopController() {
|
|||
}
|
||||
|
||||
const cwd = $currentCwd.get() || currentCwd || ''
|
||||
|
||||
const result = await requestGateway<{ task_id?: string }>('preview.restart', {
|
||||
context: context || undefined,
|
||||
cwd: cwd || undefined,
|
||||
session_id: sessionId,
|
||||
url
|
||||
})
|
||||
|
||||
const taskId = result.task_id || ''
|
||||
|
||||
if (!taskId) {
|
||||
|
|
@ -651,7 +660,8 @@ export function DesktopController() {
|
|||
handleGatewayEvent(event)
|
||||
|
||||
if (event.type === 'preview.restart.complete') {
|
||||
const payload = event.payload && typeof event.payload === 'object' ? (event.payload as Record<string, unknown>) : {}
|
||||
const payload =
|
||||
event.payload && typeof event.payload === 'object' ? (event.payload as Record<string, unknown>) : {}
|
||||
const taskId = typeof payload.task_id === 'string' ? payload.task_id : ''
|
||||
|
||||
if (taskId) {
|
||||
|
|
@ -660,7 +670,8 @@ export function DesktopController() {
|
|||
}
|
||||
|
||||
if (event.type === 'preview.restart.progress') {
|
||||
const payload = event.payload && typeof event.payload === 'object' ? (event.payload as Record<string, unknown>) : {}
|
||||
const payload =
|
||||
event.payload && typeof event.payload === 'object' ? (event.payload as Record<string, unknown>) : {}
|
||||
const taskId = typeof payload.task_id === 'string' ? payload.task_id : ''
|
||||
|
||||
if (taskId) {
|
||||
|
|
@ -734,8 +745,8 @@ export function DesktopController() {
|
|||
})
|
||||
|
||||
useEffect(() => {
|
||||
if (currentView !== 'settings') {
|
||||
settingsReturnPathRef.current = `${location.pathname}${location.search}${location.hash}`
|
||||
if (currentView !== 'settings' && currentView !== 'command-center') {
|
||||
overlayReturnPathRef.current = `${location.pathname}${location.search}${location.hash}`
|
||||
}
|
||||
}, [currentView, location.hash, location.pathname, location.search])
|
||||
|
||||
|
|
@ -750,10 +761,20 @@ export function DesktopController() {
|
|||
}
|
||||
}, [previewRouteKey])
|
||||
|
||||
const closeSettingsToPreviousRoute = useCallback(() => {
|
||||
navigate(settingsReturnPathRef.current || NEW_CHAT_ROUTE, { replace: true })
|
||||
const closeOverlayToPreviousRoute = useCallback(() => {
|
||||
navigate(overlayReturnPathRef.current || NEW_CHAT_ROUTE, { replace: true })
|
||||
}, [navigate])
|
||||
|
||||
const toggleCommandCenter = useCallback(() => {
|
||||
if (commandCenterOpen) {
|
||||
closeOverlayToPreviousRoute()
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
navigate(COMMAND_CENTER_ROUTE)
|
||||
}, [closeOverlayToPreviousRoute, commandCenterOpen, navigate])
|
||||
|
||||
const branchInNewChat = useCallback(
|
||||
async (messageId?: string) => {
|
||||
const branched = await branchCurrentSession(messageId)
|
||||
|
|
@ -891,7 +912,13 @@ export function DesktopController() {
|
|||
if (typeof window !== 'undefined') {
|
||||
const rawHash = window.location.hash.replace(/^#/, '')
|
||||
|
||||
if (rawHash && rawHash !== '/' && !rawHash.startsWith('/settings') && !rawHash.startsWith('/skills') && !rawHash.startsWith('/artifacts')) {
|
||||
if (
|
||||
rawHash &&
|
||||
rawHash !== '/' &&
|
||||
!rawHash.startsWith('/settings') &&
|
||||
!rawHash.startsWith('/skills') &&
|
||||
!rawHash.startsWith('/artifacts')
|
||||
) {
|
||||
return
|
||||
}
|
||||
}
|
||||
|
|
@ -929,7 +956,7 @@ export function DesktopController() {
|
|||
|
||||
{settingsOpen && (
|
||||
<SettingsView
|
||||
onClose={closeSettingsToPreviousRoute}
|
||||
onClose={closeOverlayToPreviousRoute}
|
||||
onConfigSaved={() => {
|
||||
void refreshHermesConfig()
|
||||
void refreshCurrentModel()
|
||||
|
|
@ -937,6 +964,22 @@ export function DesktopController() {
|
|||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{commandCenterOpen && (
|
||||
<CommandCenterView
|
||||
onClose={closeOverlayToPreviousRoute}
|
||||
onDeleteSession={removeSession}
|
||||
onMainModelChanged={(provider, model) => {
|
||||
setCurrentProvider(provider)
|
||||
setCurrentModel(model)
|
||||
updateModelOptionsCache(provider, model, true)
|
||||
void refreshCurrentModel()
|
||||
void queryClient.invalidateQueries({ queryKey: ['model-options'] })
|
||||
}}
|
||||
onNavigateRoute={path => navigate(path)}
|
||||
onOpenSession={sessionId => navigate(sessionRoute(sessionId))}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
|
||||
|
|
@ -979,13 +1022,14 @@ export function DesktopController() {
|
|||
|
||||
return (
|
||||
<AppShell
|
||||
commandCenterOpen={commandCenterOpen}
|
||||
inspectorWidth={SESSION_INSPECTOR_WIDTH}
|
||||
leftTitlebarTools={leftTitlebarTools}
|
||||
onOpenSettings={openSettings}
|
||||
onToggleCommandCenter={toggleCommandCenter}
|
||||
overlays={overlays}
|
||||
previewWidth={PREVIEW_RAIL_WIDTH}
|
||||
rightRailOpen={chatOpen}
|
||||
settingsOpen={settingsOpen}
|
||||
sidebar={sidebar}
|
||||
titlebarTools={titlebarTools}
|
||||
>
|
||||
|
|
@ -995,6 +1039,7 @@ export function DesktopController() {
|
|||
<Route element={<SkillsView setTitlebarToolGroup={setTitlebarToolGroup} />} path="skills" />
|
||||
<Route element={<ArtifactsView setTitlebarToolGroup={setTitlebarToolGroup} />} path="artifacts" />
|
||||
<Route element={null} path="settings" />
|
||||
<Route element={null} path="command-center" />
|
||||
<Route element={<Navigate replace to={NEW_CHAT_ROUTE} />} path="new" />
|
||||
<Route element={<LegacySessionRedirect />} path="sessions/:sessionId" />
|
||||
<Route element={<Navigate replace to={NEW_CHAT_ROUTE} />} path="*" />
|
||||
|
|
|
|||
66
apps/desktop/src/app/overlays/overlay-chrome.tsx
Normal file
66
apps/desktop/src/app/overlays/overlay-chrome.tsx
Normal file
|
|
@ -0,0 +1,66 @@
|
|||
import type { ButtonHTMLAttributes, ComponentProps, ReactNode } from 'react'
|
||||
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
export const overlayCardClass =
|
||||
'rounded-lg border border-[color-mix(in_srgb,var(--dt-border)_52%,transparent)] bg-[color-mix(in_srgb,var(--dt-card)_72%,transparent)] shadow-[inset_0_0.0625rem_0_color-mix(in_srgb,white_34%,transparent)]'
|
||||
|
||||
interface OverlayCardProps extends ComponentProps<'div'> {
|
||||
children: ReactNode
|
||||
}
|
||||
|
||||
interface OverlayActionButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
|
||||
tone?: 'default' | 'danger' | 'subtle'
|
||||
}
|
||||
|
||||
export function OverlayCard({ children, className, ...props }: OverlayCardProps) {
|
||||
return (
|
||||
<div className={cn(overlayCardClass, className)} {...props}>
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function OverlayActionButton({
|
||||
children,
|
||||
className,
|
||||
tone = 'default',
|
||||
type = 'button',
|
||||
...props
|
||||
}: OverlayActionButtonProps) {
|
||||
return (
|
||||
<button
|
||||
className={cn(
|
||||
'inline-flex h-8 items-center rounded-md border px-3 text-xs font-medium transition-colors disabled:cursor-default disabled:opacity-45',
|
||||
tone === 'default' &&
|
||||
'border-[color-mix(in_srgb,var(--dt-border)_55%,transparent)] bg-[color-mix(in_srgb,var(--dt-card)_80%,transparent)] text-foreground hover:bg-[color-mix(in_srgb,var(--dt-muted)_46%,var(--dt-card))]',
|
||||
tone === 'subtle' &&
|
||||
'h-7 border-transparent px-2 text-muted-foreground hover:border-[color-mix(in_srgb,var(--dt-border)_54%,transparent)] hover:bg-[color-mix(in_srgb,var(--dt-card)_72%,transparent)] hover:text-foreground',
|
||||
tone === 'danger' &&
|
||||
'h-7 border-transparent px-2 text-destructive hover:border-[color-mix(in_srgb,var(--dt-destructive)_40%,transparent)] hover:bg-[color-mix(in_srgb,var(--dt-destructive)_10%,transparent)] hover:text-destructive',
|
||||
className
|
||||
)}
|
||||
type={type}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
interface OverlayIconButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
|
||||
children: ReactNode
|
||||
}
|
||||
|
||||
export function OverlayIconButton({ children, className, type = 'button', ...props }: OverlayIconButtonProps) {
|
||||
return (
|
||||
<OverlayActionButton
|
||||
className={cn('h-7 w-7 justify-center px-0 [&_svg]:size-4', className)}
|
||||
tone="subtle"
|
||||
type={type}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</OverlayActionButton>
|
||||
)
|
||||
}
|
||||
59
apps/desktop/src/app/overlays/overlay-search-input.tsx
Normal file
59
apps/desktop/src/app/overlays/overlay-search-input.tsx
Normal file
|
|
@ -0,0 +1,59 @@
|
|||
import type { RefObject } from 'react'
|
||||
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Loader2, Search, X } from '@/lib/icons'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
interface OverlaySearchInputProps {
|
||||
placeholder: string
|
||||
value: string
|
||||
onChange: (value: string) => void
|
||||
containerClassName?: string
|
||||
inputClassName?: string
|
||||
loading?: boolean
|
||||
onClear?: () => void
|
||||
inputRef?: RefObject<HTMLInputElement | null>
|
||||
}
|
||||
|
||||
export function OverlaySearchInput({
|
||||
placeholder,
|
||||
value,
|
||||
onChange,
|
||||
containerClassName,
|
||||
inputClassName,
|
||||
loading = false,
|
||||
onClear,
|
||||
inputRef
|
||||
}: OverlaySearchInputProps) {
|
||||
const clear = onClear ?? (() => onChange(''))
|
||||
|
||||
return (
|
||||
<div className={cn('relative', containerClassName)}>
|
||||
<Search className="pointer-events-none absolute left-3 top-1/2 size-3.5 -translate-y-1/2 text-muted-foreground/80" />
|
||||
<Input
|
||||
className={cn(
|
||||
'h-8.5 rounded-full border border-[color-mix(in_srgb,var(--dt-border)_60%,transparent)] bg-[color-mix(in_srgb,var(--dt-card)_85%,transparent)] py-2 pl-8 pr-12 text-sm shadow-[inset_0_0.0625rem_0_color-mix(in_srgb,white_38%,transparent)] focus-visible:border-[color-mix(in_srgb,var(--dt-ring)_70%,transparent)] focus-visible:bg-background',
|
||||
inputClassName
|
||||
)}
|
||||
onChange={event => onChange(event.target.value)}
|
||||
placeholder={placeholder}
|
||||
ref={inputRef}
|
||||
value={value}
|
||||
/>
|
||||
{loading ? (
|
||||
<Loader2 className="pointer-events-none absolute right-3 top-1/2 size-3.5 -translate-y-1/2 animate-spin text-muted-foreground/70" />
|
||||
) : value ? (
|
||||
<Button
|
||||
aria-label="Clear search"
|
||||
className="absolute right-1.5 top-1/2 -translate-y-1/2 text-muted-foreground/85 hover:bg-accent/60 hover:text-foreground"
|
||||
onClick={clear}
|
||||
size="icon-xs"
|
||||
variant="ghost"
|
||||
>
|
||||
<X className="size-3.5" />
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
78
apps/desktop/src/app/overlays/overlay-split-layout.tsx
Normal file
78
apps/desktop/src/app/overlays/overlay-split-layout.tsx
Normal file
|
|
@ -0,0 +1,78 @@
|
|||
import type { ReactNode } from 'react'
|
||||
|
||||
import type { LucideIcon } from '@/lib/icons'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
interface OverlaySplitLayoutProps {
|
||||
children: ReactNode
|
||||
className?: string
|
||||
}
|
||||
|
||||
interface OverlaySidebarProps {
|
||||
children: ReactNode
|
||||
className?: string
|
||||
}
|
||||
|
||||
interface OverlayMainProps {
|
||||
children: ReactNode
|
||||
className?: string
|
||||
}
|
||||
|
||||
interface OverlayNavItemProps {
|
||||
active: boolean
|
||||
icon: LucideIcon
|
||||
label: string
|
||||
onClick: () => void
|
||||
trailing?: ReactNode
|
||||
}
|
||||
|
||||
export function OverlaySplitLayout({ children, className }: OverlaySplitLayoutProps) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'grid h-full min-h-0 flex-1 grid-cols-[13rem_minmax(0,1fr)] overflow-hidden rounded-[0.95rem] border border-[color-mix(in_srgb,var(--dt-border)_58%,transparent)] bg-[color-mix(in_srgb,var(--dt-card)_94%,transparent)] shadow-[inset_0_0.0625rem_0_color-mix(in_srgb,white_46%,transparent),0_0.5rem_1.5rem_-1rem_color-mix(in_srgb,#000_22%,transparent)] max-[760px]:grid-cols-1',
|
||||
className
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function OverlaySidebar({ children, className }: OverlaySidebarProps) {
|
||||
return (
|
||||
<aside
|
||||
className={cn(
|
||||
'flex min-h-0 flex-col gap-0.5 overflow-y-auto border-r border-[color-mix(in_srgb,var(--dt-border)_48%,transparent)] bg-[color-mix(in_srgb,var(--dt-muted)_55%,var(--dt-card))] px-3.5 py-4',
|
||||
className
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</aside>
|
||||
)
|
||||
}
|
||||
|
||||
export function OverlayMain({ children, className }: OverlayMainProps) {
|
||||
return (
|
||||
<main className={cn('flex min-h-0 flex-1 flex-col overflow-hidden bg-transparent p-4', className)}>{children}</main>
|
||||
)
|
||||
}
|
||||
|
||||
export function OverlayNavItem({ active, icon: Icon, label, onClick, trailing }: OverlayNavItemProps) {
|
||||
return (
|
||||
<button
|
||||
className={cn(
|
||||
'flex h-8 w-full items-center justify-start gap-2 rounded-md border px-2.5 text-left text-sm font-medium transition-colors',
|
||||
active
|
||||
? 'border-[color-mix(in_srgb,var(--dt-primary)_34%,var(--dt-border))] bg-[color-mix(in_srgb,var(--dt-primary)_10%,var(--dt-card))] text-foreground shadow-[inset_0_0.0625rem_0_color-mix(in_srgb,white_40%,transparent)]'
|
||||
: 'border-transparent bg-transparent text-foreground/78 hover:border-[color-mix(in_srgb,var(--dt-border)_60%,transparent)] hover:bg-[color-mix(in_srgb,var(--dt-card)_78%,transparent)] hover:text-foreground'
|
||||
)}
|
||||
onClick={onClick}
|
||||
type="button"
|
||||
>
|
||||
<Icon className={cn('size-4 shrink-0', active ? 'text-foreground/80' : 'text-muted-foreground/80')} />
|
||||
<span className="min-w-0 flex-1 truncate">{label}</span>
|
||||
{trailing}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
68
apps/desktop/src/app/overlays/overlay-view.tsx
Normal file
68
apps/desktop/src/app/overlays/overlay-view.tsx
Normal file
|
|
@ -0,0 +1,68 @@
|
|||
import type { ReactNode } from 'react'
|
||||
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { triggerHaptic } from '@/lib/haptics'
|
||||
import { X } from '@/lib/icons'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
interface OverlayViewProps {
|
||||
children: ReactNode
|
||||
onClose: () => void
|
||||
closeLabel?: string
|
||||
contentClassName?: string
|
||||
headerContent?: ReactNode
|
||||
rootClassName?: string
|
||||
}
|
||||
|
||||
export function OverlayView({
|
||||
children,
|
||||
onClose,
|
||||
closeLabel = 'Close',
|
||||
contentClassName,
|
||||
headerContent,
|
||||
rootClassName
|
||||
}: OverlayViewProps) {
|
||||
const closeOverlay = () => {
|
||||
triggerHaptic('close')
|
||||
onClose()
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className="fixed inset-0 z-50 bg-black/22 p-3 backdrop-blur-[2px] sm:p-8"
|
||||
onClick={event => {
|
||||
if (event.target === event.currentTarget) {
|
||||
closeOverlay()
|
||||
}
|
||||
}}
|
||||
role="presentation"
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
'relative flex h-full min-h-0 flex-col overflow-hidden rounded-2xl border border-[color-mix(in_srgb,var(--dt-border)_60%,transparent)] bg-background/96 shadow-[0_1.5rem_4rem_-2rem_color-mix(in_srgb,#000_40%,transparent)]',
|
||||
rootClassName
|
||||
)}
|
||||
>
|
||||
<div className="pointer-events-none absolute inset-x-0 top-0 z-10 h-[calc(var(--titlebar-height)+0.1875rem)] [-webkit-app-region:drag]">
|
||||
{headerContent && (
|
||||
<div className="pointer-events-auto absolute left-1/2 top-[calc(1rem+var(--titlebar-height)/2)] -translate-x-1/2 -translate-y-1/2 [-webkit-app-region:no-drag]">
|
||||
{headerContent}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Button
|
||||
aria-label={closeLabel}
|
||||
className="pointer-events-auto absolute right-3.75 top-[calc(0.1875rem+var(--titlebar-height)/2)] h-7 w-7 -translate-y-1/2 rounded-lg text-muted-foreground hover:bg-accent/70 hover:text-foreground [-webkit-app-region:no-drag]"
|
||||
onClick={closeOverlay}
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
>
|
||||
<X size={16} />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className={cn('min-h-0 flex flex-1 flex-col pt-(--titlebar-height)', contentClassName)}>{children}</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -1,12 +1,13 @@
|
|||
export const SESSION_ROUTE_PREFIX = '/'
|
||||
export const NEW_CHAT_ROUTE = '/'
|
||||
export const SETTINGS_ROUTE = '/settings'
|
||||
export const COMMAND_CENTER_ROUTE = '/command-center'
|
||||
export const SKILLS_ROUTE = '/skills'
|
||||
export const ARTIFACTS_ROUTE = '/artifacts'
|
||||
|
||||
export type AppView = 'chat' | 'settings' | 'skills' | 'artifacts'
|
||||
export type AppView = 'chat' | 'settings' | 'command-center' | 'skills' | 'artifacts'
|
||||
|
||||
export type AppRouteId = 'new' | 'settings' | 'skills' | 'artifacts'
|
||||
export type AppRouteId = 'new' | 'settings' | 'command-center' | 'skills' | 'artifacts'
|
||||
|
||||
export interface AppRoute {
|
||||
id: AppRouteId
|
||||
|
|
@ -17,6 +18,7 @@ export interface AppRoute {
|
|||
export const APP_ROUTES = [
|
||||
{ id: 'new', path: NEW_CHAT_ROUTE, view: 'chat' },
|
||||
{ id: 'settings', path: SETTINGS_ROUTE, view: 'settings' },
|
||||
{ id: 'command-center', path: COMMAND_CENTER_ROUTE, view: 'command-center' },
|
||||
{ id: 'skills', path: SKILLS_ROUTE, view: 'skills' },
|
||||
{ id: 'artifacts', path: ARTIFACTS_ROUTE, view: 'artifacts' }
|
||||
] as const satisfies readonly AppRoute[]
|
||||
|
|
|
|||
|
|
@ -394,9 +394,10 @@ export function useMessageStream({
|
|||
appendAssistantDelta(sessionId, coerceGatewayText(payload?.text))
|
||||
}
|
||||
} else if (event.type === 'thinking.delta') {
|
||||
if (sessionId) {
|
||||
appendReasoningDelta(sessionId, coerceThinkingText(payload?.text))
|
||||
}
|
||||
// thinking.delta carries the kawaii spinner status (face + verb from
|
||||
// KawaiiSpinner), not real reasoning. The bottom-of-thread loading
|
||||
// indicator already covers that UX, so we ignore these events to
|
||||
// avoid a duplicative "Thinking" disclosure showing spinner text.
|
||||
} else if (event.type === 'reasoning.delta') {
|
||||
if (sessionId) {
|
||||
appendReasoningDelta(sessionId, coerceThinkingText(payload?.text))
|
||||
|
|
|
|||
|
|
@ -18,7 +18,12 @@ import {
|
|||
isDesktopSlashCommand
|
||||
} from '@/lib/desktop-slash-commands'
|
||||
import { triggerHaptic } from '@/lib/haptics'
|
||||
import { $composerAttachments, addComposerAttachment, clearComposerAttachments, type ComposerAttachment } from '@/store/composer'
|
||||
import {
|
||||
$composerAttachments,
|
||||
addComposerAttachment,
|
||||
clearComposerAttachments,
|
||||
type ComposerAttachment
|
||||
} from '@/store/composer'
|
||||
import { clearNotifications, notify, notifyError } from '@/store/notifications'
|
||||
import { $busy, $messages, setAwaitingResponse, setBusy, setMessages } from '@/store/session'
|
||||
|
||||
|
|
@ -647,6 +652,7 @@ export function usePromptActions({
|
|||
|
||||
updateSessionState(sessionId, state => {
|
||||
let changed = false
|
||||
|
||||
const messages = state.messages.map(message => {
|
||||
if (message.role !== 'assistant' || !message.branchGroupId) {
|
||||
return message
|
||||
|
|
|
|||
|
|
@ -82,7 +82,13 @@ function preserveReasoningParts(message: ChatMessage, previous: ChatMessage): Ch
|
|||
}
|
||||
|
||||
function chatMessagesEquivalent(a: ChatMessage, b: ChatMessage): boolean {
|
||||
if (a.id !== b.id || a.role !== b.role || a.pending !== b.pending || a.hidden !== b.hidden || a.branchGroupId !== b.branchGroupId) {
|
||||
if (
|
||||
a.id !== b.id ||
|
||||
a.role !== b.role ||
|
||||
a.pending !== b.pending ||
|
||||
a.hidden !== b.hidden ||
|
||||
a.branchGroupId !== b.branchGroupId
|
||||
) {
|
||||
return false
|
||||
}
|
||||
|
||||
|
|
@ -290,7 +296,15 @@ export function useSessionActions({
|
|||
creatingSessionRef.current = false
|
||||
}, 0)
|
||||
}
|
||||
}, [activeSessionIdRef, creatingSessionRef, ensureSessionState, getRouteToken, navigate, requestGateway, selectedStoredSessionIdRef])
|
||||
}, [
|
||||
activeSessionIdRef,
|
||||
creatingSessionRef,
|
||||
ensureSessionState,
|
||||
getRouteToken,
|
||||
navigate,
|
||||
requestGateway,
|
||||
selectedStoredSessionIdRef
|
||||
])
|
||||
|
||||
const selectSidebarItem = useCallback(
|
||||
(item: SidebarNavItem) => {
|
||||
|
|
@ -402,11 +416,12 @@ export function useSessionActions({
|
|||
// exists; use gateway messages only as a fallback when no local
|
||||
// snapshot was available.
|
||||
|
||||
const messagesForView = localSnapshot.length > 0
|
||||
? localSnapshot
|
||||
: chatMessageArraysEquivalent(currentMessages, resumedMessages)
|
||||
? currentMessages
|
||||
: resumedMessages
|
||||
const messagesForView =
|
||||
localSnapshot.length > 0
|
||||
? localSnapshot
|
||||
: chatMessageArraysEquivalent(currentMessages, resumedMessages)
|
||||
? currentMessages
|
||||
: resumedMessages
|
||||
|
||||
setActiveSessionId(resumed.session_id)
|
||||
activeSessionIdRef.current = resumed.session_id
|
||||
|
|
|
|||
|
|
@ -1,6 +1,5 @@
|
|||
import { Check, Palette } from 'lucide-react'
|
||||
|
||||
import { triggerHaptic } from '@/lib/haptics'
|
||||
import { Check, Palette } from '@/lib/icons'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { useTheme } from '@/themes/context'
|
||||
import { BUILTIN_THEMES } from '@/themes/presets'
|
||||
|
|
|
|||
|
|
@ -10,8 +10,7 @@ import {
|
|||
Sparkles,
|
||||
Sun,
|
||||
Wrench
|
||||
} from 'lucide-react'
|
||||
|
||||
} from '@/lib/icons'
|
||||
import type { ThemeMode } from '@/themes/context'
|
||||
|
||||
import type { DesktopConfigSection } from './types'
|
||||
|
|
|
|||
|
|
@ -1,17 +1,20 @@
|
|||
import { Download, KeyRound, Package, RotateCcw, Search, Upload, X } from 'lucide-react'
|
||||
import { IconDownload, IconRefresh, IconUpload } from '@tabler/icons-react'
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { getHermesConfigDefaults, getHermesConfigRecord, saveHermesConfig } from '@/hermes'
|
||||
import { triggerHaptic } from '@/lib/haptics'
|
||||
import { KeyRound, Package } from '@/lib/icons'
|
||||
import { notifyError } from '@/store/notifications'
|
||||
|
||||
import { OverlayIconButton } from '../overlays/overlay-chrome'
|
||||
import { OverlaySearchInput } from '../overlays/overlay-search-input'
|
||||
import { OverlayMain, OverlayNavItem, OverlaySidebar, OverlaySplitLayout } from '../overlays/overlay-split-layout'
|
||||
import { OverlayView } from '../overlays/overlay-view'
|
||||
|
||||
import { AppearanceSettings } from './appearance-settings'
|
||||
import { ConfigSettings } from './config-settings'
|
||||
import { SEARCH_PLACEHOLDER, SECTIONS } from './constants'
|
||||
import { KeysSettings } from './keys-settings'
|
||||
import { NavLink } from './primitives'
|
||||
import { ToolsSettings } from './tools-settings'
|
||||
import type { SettingsPageProps, SettingsQueryKey, SettingsView as SettingsViewId } from './types'
|
||||
|
||||
|
|
@ -84,55 +87,26 @@ export function SettingsView({ onClose, onConfigSaved }: SettingsPageProps) {
|
|||
}, [onClose])
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-60 flex min-h-0 flex-col bg-background/98 p-0.75 backdrop-blur-xl">
|
||||
<div className="pointer-events-none fixed inset-x-0 top-0 z-10 h-[calc(var(--titlebar-height)+0.1875rem)] [-webkit-app-region:drag]">
|
||||
<div className="pointer-events-auto absolute left-1/2 top-[calc(1rem+var(--titlebar-height)/2)] w-[min(36rem,calc(100vw-32rem))] min-w-80 -translate-x-1/2 -translate-y-1/2 [-webkit-app-region:no-drag]">
|
||||
<Search className="pointer-events-none absolute left-3 top-1/2 size-3.5 -translate-y-1/2 text-muted-foreground/80" />
|
||||
<Input
|
||||
className="h-9 rounded-full border-transparent bg-background py-2 pl-8 pr-20 text-sm shadow-header focus-visible:bg-background"
|
||||
onChange={e => setQuery(e.target.value)}
|
||||
placeholder={SEARCH_PLACEHOLDER[queryKey]}
|
||||
ref={searchInputRef}
|
||||
value={query}
|
||||
/>
|
||||
{query ? (
|
||||
<Button
|
||||
aria-label="Clear search"
|
||||
className="absolute right-1.5 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
|
||||
onClick={() => setQuery('')}
|
||||
size="icon-xs"
|
||||
variant="ghost"
|
||||
>
|
||||
<X className="size-3.5" />
|
||||
</Button>
|
||||
) : (
|
||||
<span className="pointer-events-none absolute right-2 top-1/2 -translate-y-1/2 rounded-md bg-background/80 px-1.5 py-0.5 text-[0.62rem] leading-none text-muted-foreground shadow-xs">
|
||||
Cmd P
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Button
|
||||
aria-label="Close settings"
|
||||
className="pointer-events-auto absolute right-3.75 top-[calc(0.1875rem+var(--titlebar-height)/2)] h-7 w-7 -translate-y-1/2 rounded-lg text-muted-foreground hover:bg-accent/70 hover:text-foreground [-webkit-app-region:no-drag]"
|
||||
onClick={() => {
|
||||
triggerHaptic('close')
|
||||
onClose()
|
||||
}}
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
>
|
||||
<X size={16} />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="grid min-h-0 flex-1 grid-cols-[13rem_minmax(0,1fr)] rounded-[1.0625rem] bg-background/90 pt-(--titlebar-height) max-[760px]:grid-cols-1">
|
||||
<aside className="flex min-h-0 flex-col gap-0.5 overflow-y-auto bg-muted/20 px-4 py-5">
|
||||
<OverlayView
|
||||
closeLabel="Close settings"
|
||||
headerContent={
|
||||
<OverlaySearchInput
|
||||
containerClassName="w-[min(36rem,calc(100vw-32rem))] min-w-80"
|
||||
inputRef={searchInputRef}
|
||||
onChange={setQuery}
|
||||
placeholder={SEARCH_PLACEHOLDER[queryKey]}
|
||||
value={query}
|
||||
/>
|
||||
}
|
||||
onClose={onClose}
|
||||
>
|
||||
<OverlaySplitLayout>
|
||||
<OverlaySidebar>
|
||||
{SECTIONS.map(s => {
|
||||
const view = `config:${s.id}` as SettingsViewId
|
||||
|
||||
return (
|
||||
<NavLink
|
||||
<OverlayNavItem
|
||||
active={activeView === view && !queries.config.trim()}
|
||||
icon={s.icon}
|
||||
key={s.id}
|
||||
|
|
@ -142,56 +116,45 @@ export function SettingsView({ onClose, onConfigSaved }: SettingsPageProps) {
|
|||
)
|
||||
})}
|
||||
<div className="my-2 h-px bg-border/30" />
|
||||
<NavLink
|
||||
<OverlayNavItem
|
||||
active={activeView === 'keys'}
|
||||
icon={KeyRound}
|
||||
label="API Keys"
|
||||
onClick={() => setActiveView('keys')}
|
||||
/>
|
||||
<NavLink
|
||||
<OverlayNavItem
|
||||
active={activeView === 'tools'}
|
||||
icon={Package}
|
||||
label="Skills & Tools"
|
||||
onClick={() => setActiveView('tools')}
|
||||
/>
|
||||
<div className="mt-auto flex items-center gap-1 pt-2">
|
||||
<Button
|
||||
className="text-muted-foreground"
|
||||
onClick={() => void exportConfig()}
|
||||
size="icon-xs"
|
||||
title="Export config"
|
||||
variant="ghost"
|
||||
>
|
||||
<Download />
|
||||
</Button>
|
||||
<Button
|
||||
className="text-muted-foreground"
|
||||
<OverlayIconButton onClick={() => void exportConfig()} title="Export config">
|
||||
<IconDownload className="size-3.5" />
|
||||
</OverlayIconButton>
|
||||
<OverlayIconButton
|
||||
onClick={() => {
|
||||
triggerHaptic('open')
|
||||
importInputRef.current?.click()
|
||||
}}
|
||||
size="icon-xs"
|
||||
title="Import config"
|
||||
variant="ghost"
|
||||
>
|
||||
<Upload />
|
||||
</Button>
|
||||
<Button
|
||||
className="text-muted-foreground"
|
||||
<IconUpload className="size-3.5" />
|
||||
</OverlayIconButton>
|
||||
<OverlayIconButton
|
||||
className="hover:text-destructive"
|
||||
onClick={() => {
|
||||
triggerHaptic('warning')
|
||||
void resetConfig()
|
||||
}}
|
||||
size="icon-xs"
|
||||
title="Reset to defaults"
|
||||
variant="ghost"
|
||||
>
|
||||
<RotateCcw />
|
||||
</Button>
|
||||
<IconRefresh className="size-3.5" />
|
||||
</OverlayIconButton>
|
||||
</div>
|
||||
</aside>
|
||||
</OverlaySidebar>
|
||||
|
||||
<main className="flex min-h-0 flex-1 flex-col overflow-hidden">
|
||||
<OverlayMain className="p-0">
|
||||
{activeView === 'config:appearance' ? (
|
||||
<AppearanceSettings />
|
||||
) : activeView.startsWith('config:') ? (
|
||||
|
|
@ -206,9 +169,9 @@ export function SettingsView({ onClose, onConfigSaved }: SettingsPageProps) {
|
|||
) : (
|
||||
<ToolsSettings query={queries.tools} />
|
||||
)}
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
</OverlayMain>
|
||||
</OverlaySplitLayout>
|
||||
</OverlayView>
|
||||
)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,9 +1,9 @@
|
|||
import { Check, Eye, EyeOff, Save, Settings2, Trash2, X, Zap } from 'lucide-react'
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react'
|
||||
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { deleteEnvVar, getEnvVars, revealEnvVar, setEnvVar } from '@/hermes'
|
||||
import { Check, Eye, EyeOff, Save, Settings2, Trash2, X, Zap } from '@/lib/icons'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { notify, notifyError } from '@/store/notifications'
|
||||
import type { EnvVarInfo } from '@/types/hermes'
|
||||
|
|
@ -21,6 +21,8 @@ import {
|
|||
import { LoadingState, Pill, SectionHeading, SettingsContent } from './primitives'
|
||||
import type { EnvPatch, EnvRowProps, ProviderGroup, SearchProps } from './types'
|
||||
|
||||
const SHOW_ADVANCED_STORAGE_KEY = 'desktop.settings.keys.show_advanced'
|
||||
|
||||
interface EnvActionsProps {
|
||||
varKey: string
|
||||
info: EnvVarInfo
|
||||
|
|
@ -218,7 +220,28 @@ export function KeysSettings({ query }: SearchProps) {
|
|||
const [edits, setEdits] = useState<Record<string, string>>({})
|
||||
const [revealed, setRevealed] = useState<Record<string, string>>({})
|
||||
const [saving, setSaving] = useState<string | null>(null)
|
||||
const [showAdvanced, setShowAdvanced] = useState(true)
|
||||
|
||||
const [showAdvanced, setShowAdvanced] = useState<boolean>(() => {
|
||||
try {
|
||||
const stored = window.localStorage.getItem(SHOW_ADVANCED_STORAGE_KEY)
|
||||
|
||||
if (stored === null) {
|
||||
return false
|
||||
}
|
||||
|
||||
return stored === 'true'
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
try {
|
||||
window.localStorage.setItem(SHOW_ADVANCED_STORAGE_KEY, showAdvanced ? 'true' : 'false')
|
||||
} catch {
|
||||
// Ignore persistence failures and keep in-memory preference.
|
||||
}
|
||||
}, [showAdvanced])
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
|
|
|
|||
|
|
@ -1,8 +1,8 @@
|
|||
import type { LucideIcon } from 'lucide-react'
|
||||
import type { ReactNode } from 'react'
|
||||
|
||||
import { PageLoader } from '@/components/page-loader'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import type { LucideIcon } from '@/lib/icons'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
export function SettingsContent({ children }: { children: ReactNode }) {
|
||||
|
|
|
|||
|
|
@ -1,8 +1,8 @@
|
|||
import { Brain, Wrench } from 'lucide-react'
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
|
||||
import { Switch } from '@/components/ui/switch'
|
||||
import { getSkills, getToolsets, toggleSkill } from '@/hermes'
|
||||
import { Brain, Wrench } from '@/lib/icons'
|
||||
import { notify, notifyError } from '@/store/notifications'
|
||||
import type { SkillInfo, ToolsetInfo } from '@/types/hermes'
|
||||
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import type { LucideIcon } from 'lucide-react'
|
||||
import type { Dispatch, SetStateAction } from 'react'
|
||||
|
||||
import type { LucideIcon } from '@/lib/icons'
|
||||
import type { EnvVarInfo } from '@/types/hermes'
|
||||
|
||||
export type SettingsView = 'keys' | 'tools' | `config:${string}`
|
||||
|
|
|
|||
|
|
@ -19,27 +19,29 @@ import { TITLEBAR_HEIGHT, titlebarControlsPosition } from './titlebar'
|
|||
import { TitlebarControls, type TitlebarTool } from './titlebar-controls'
|
||||
|
||||
interface AppShellProps {
|
||||
commandCenterOpen: boolean
|
||||
children: ReactNode
|
||||
inspectorWidth: string
|
||||
leftTitlebarTools?: readonly TitlebarTool[]
|
||||
previewWidth: string
|
||||
rightRailOpen: boolean
|
||||
settingsOpen: boolean
|
||||
sidebar: ReactNode
|
||||
titlebarTools?: readonly TitlebarTool[]
|
||||
onToggleCommandCenter: () => void
|
||||
onOpenSettings: () => void
|
||||
overlays?: ReactNode
|
||||
}
|
||||
|
||||
export function AppShell({
|
||||
commandCenterOpen,
|
||||
children,
|
||||
inspectorWidth,
|
||||
leftTitlebarTools,
|
||||
previewWidth,
|
||||
rightRailOpen,
|
||||
settingsOpen,
|
||||
sidebar,
|
||||
titlebarTools,
|
||||
onToggleCommandCenter,
|
||||
onOpenSettings,
|
||||
overlays
|
||||
}: AppShellProps) {
|
||||
|
|
@ -133,9 +135,10 @@ export function AppShell({
|
|||
}
|
||||
>
|
||||
<TitlebarControls
|
||||
commandCenterOpen={commandCenterOpen}
|
||||
leftTools={leftTitlebarTools}
|
||||
onOpenSettings={onOpenSettings}
|
||||
settingsOpen={settingsOpen}
|
||||
onToggleCommandCenter={onToggleCommandCenter}
|
||||
showInspectorToggle={rightRailOpen}
|
||||
tools={titlebarTools}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -1,10 +1,9 @@
|
|||
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 type { ComponentProps, ReactNode } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
|
||||
import { triggerHaptic } from '@/lib/haptics'
|
||||
import { Command, NotebookTabs, Settings, SlidersHorizontal, Volume2, VolumeX } from '@/lib/icons'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { $hapticsMuted, toggleHapticsMuted } from '@/store/haptics'
|
||||
import { $inspectorOpen, $sidebarOpen, toggleInspectorOpen, toggleSidebarOpen } from '@/store/layout'
|
||||
|
|
@ -28,19 +27,21 @@ export interface TitlebarTool {
|
|||
export type TitlebarToolSide = 'left' | 'right'
|
||||
export type SetTitlebarToolGroup = (id: string, tools: readonly TitlebarTool[], side?: TitlebarToolSide) => void
|
||||
|
||||
interface TitlebarControlsProps extends React.ComponentProps<'div'> {
|
||||
interface TitlebarControlsProps extends ComponentProps<'div'> {
|
||||
commandCenterOpen: boolean
|
||||
leftTools?: readonly TitlebarTool[]
|
||||
settingsOpen: boolean
|
||||
showInspectorToggle: boolean
|
||||
tools?: readonly TitlebarTool[]
|
||||
onToggleCommandCenter: () => void
|
||||
onOpenSettings: () => void
|
||||
}
|
||||
|
||||
export function TitlebarControls({
|
||||
commandCenterOpen,
|
||||
leftTools = [],
|
||||
settingsOpen,
|
||||
showInspectorToggle,
|
||||
tools = [],
|
||||
onToggleCommandCenter,
|
||||
onOpenSettings
|
||||
}: TitlebarControlsProps) {
|
||||
const navigate = useNavigate()
|
||||
|
|
@ -71,9 +72,15 @@ export function TitlebarControls({
|
|||
}
|
||||
},
|
||||
{
|
||||
icon: <Search size={TITLEBAR_ICON_SIZE} />,
|
||||
id: 'search',
|
||||
label: 'Search'
|
||||
active: commandCenterOpen,
|
||||
icon: <Command size={TITLEBAR_ICON_SIZE} />,
|
||||
id: 'command-center',
|
||||
label: commandCenterOpen ? 'Close command center' : 'Open command center',
|
||||
title: commandCenterOpen ? 'Close command center' : 'Open command center',
|
||||
onSelect: () => {
|
||||
triggerHaptic('tap')
|
||||
onToggleCommandCenter()
|
||||
}
|
||||
},
|
||||
...leftTools
|
||||
]
|
||||
|
|
@ -113,7 +120,7 @@ export function TitlebarControls({
|
|||
<>
|
||||
<div
|
||||
aria-label="Window controls"
|
||||
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]"
|
||||
className="fixed left-(--titlebar-controls-left) top-(--titlebar-controls-top) z-70 flex translate-y-[2px] flex-row items-center gap-px pointer-events-auto select-none [-webkit-app-region:no-drag]"
|
||||
>
|
||||
{leftToolbarTools
|
||||
.filter(tool => !tool.hidden)
|
||||
|
|
@ -122,37 +129,24 @@ export function TitlebarControls({
|
|||
))}
|
||||
</div>
|
||||
|
||||
{!settingsOpen && (
|
||||
<div
|
||||
aria-label="App controls"
|
||||
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]"
|
||||
>
|
||||
{rightToolbarTools
|
||||
.filter(tool => !tool.hidden)
|
||||
.map(tool => (
|
||||
<TitlebarToolButton
|
||||
key={tool.id}
|
||||
navigate={navigate}
|
||||
tool={tool}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<div
|
||||
aria-label="App controls"
|
||||
className="fixed right-(--titlebar-tools-right) top-(--titlebar-controls-top) z-70 flex flex-row items-center justify-end gap-px pointer-events-auto select-none [-webkit-app-region:no-drag]"
|
||||
>
|
||||
{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
|
||||
}) {
|
||||
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',
|
||||
'grid place-items-center bg-transparent select-none [&_svg]:size-4',
|
||||
tool.className
|
||||
)
|
||||
|
||||
|
|
@ -175,7 +169,7 @@ function TitlebarToolButton({
|
|||
return (
|
||||
<button
|
||||
aria-label={tool.label}
|
||||
aria-pressed={tool.active}
|
||||
aria-pressed={tool.active ?? undefined}
|
||||
className={className}
|
||||
disabled={tool.disabled}
|
||||
onClick={() => {
|
||||
|
|
|
|||
|
|
@ -1,5 +1,3 @@
|
|||
import { Brain, RefreshCw, Search, Wrench, X } from 'lucide-react'
|
||||
import type { LucideIcon } from 'lucide-react'
|
||||
import type * as React from 'react'
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react'
|
||||
|
||||
|
|
@ -8,6 +6,8 @@ import { Button } from '@/components/ui/button'
|
|||
import { Input } from '@/components/ui/input'
|
||||
import { Switch } from '@/components/ui/switch'
|
||||
import { getSkills, getToolsets, toggleSkill } from '@/hermes'
|
||||
import { Brain, RefreshCw, Search, Wrench, X } from '@/lib/icons'
|
||||
import type { LucideIcon } from '@/lib/icons'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { notify, notifyError } from '@/store/notifications'
|
||||
import type { SkillInfo, ToolsetInfo } from '@/types/hermes'
|
||||
|
|
|
|||
|
|
@ -1,6 +1,5 @@
|
|||
import type { LucideIcon } from 'lucide-react'
|
||||
|
||||
import type { ChatMessage } from '@/lib/chat-messages'
|
||||
import type { LucideIcon } from '@/lib/icons'
|
||||
|
||||
export interface ContextSuggestion {
|
||||
text: string
|
||||
|
|
|
|||
|
|
@ -2,13 +2,13 @@
|
|||
|
||||
import { type ToolCallMessagePartProps } from '@assistant-ui/react'
|
||||
import { useStore } from '@nanostores/react'
|
||||
import { HelpCircle, Loader2, PencilLine } from 'lucide-react'
|
||||
import { type FormEvent, type KeyboardEvent, useCallback, useMemo, useRef, useState } from 'react'
|
||||
|
||||
import { ToolFallback } from '@/components/assistant-ui/tool-fallback'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Textarea } from '@/components/ui/textarea'
|
||||
import { triggerHaptic } from '@/lib/haptics'
|
||||
import { HelpCircle, Loader2, PencilLine } from '@/lib/icons'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { $clarifyRequest, clearClarifyRequest } from '@/store/clarify'
|
||||
import { $gateway } from '@/store/gateway'
|
||||
|
|
@ -213,7 +213,10 @@ function ClarifyToolPending({ args }: ToolCallMessagePartProps) {
|
|||
}}
|
||||
type="button"
|
||||
>
|
||||
<span aria-hidden className="grid size-5 shrink-0 place-items-center rounded-md bg-muted text-muted-foreground">
|
||||
<span
|
||||
aria-hidden
|
||||
className="grid size-5 shrink-0 place-items-center rounded-md bg-muted text-muted-foreground"
|
||||
>
|
||||
<PencilLine className="size-3" />
|
||||
</span>
|
||||
<span className="flex-1">Other (type your answer)</span>
|
||||
|
|
|
|||
|
|
@ -2,12 +2,12 @@
|
|||
|
||||
import type { Unstable_DirectiveFormatter, Unstable_DirectiveSegment, Unstable_TriggerItem } from '@assistant-ui/core'
|
||||
import type { TextMessagePartComponent, TextMessagePartProps } from '@assistant-ui/react'
|
||||
import { AtSign, FileText, FolderOpen, ImageIcon, Link as LinkIcon, Wrench } from 'lucide-react'
|
||||
import type { ComponentType, FC } from 'react'
|
||||
import { Fragment, useMemo } from 'react'
|
||||
|
||||
import { ZoomableImage } from '@/components/assistant-ui/zoomable-image'
|
||||
import { extractEmbeddedImages } from '@/lib/embedded-images'
|
||||
import { AtSign, FileText, FolderOpen, ImageIcon, Link as LinkIcon, Wrench } from '@/lib/icons'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
const HERMES_REF_TYPES = ['file', 'folder', 'url', 'image', 'tool'] as const
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { type FC, useCallback, useEffect, useState } from 'react'
|
||||
import { type FC, useCallback, useState } from 'react'
|
||||
|
||||
import introCopyJsonl from './intro-copy.jsonl?raw'
|
||||
|
||||
|
|
@ -19,6 +19,9 @@ export type IntroProps = {
|
|||
const NEUTRAL_PERSONALITIES = new Set(['', 'default', 'none', 'neutral'])
|
||||
|
||||
const HERMES_FRAME_COUNT = 8
|
||||
// Optical centering offsets tuned per frame so Hermes' body stays centered
|
||||
// even when the staff extends farther left/right in certain poses.
|
||||
const HERMES_DEFAULT_FRAME_OPTICAL_OFFSET_PX = [8, 4, 4, 0, 9, 2, 5, 9] as const
|
||||
const ASSET_BASE_URL = import.meta.env.BASE_URL || '/'
|
||||
|
||||
const FALLBACK_COPY: IntroCopy[] = [
|
||||
|
|
@ -165,17 +168,12 @@ export const Intro: FC<IntroProps> = ({ personality, seed }) => {
|
|||
const introSeed = mountSeed + (seed ?? 0)
|
||||
const copy = resolveCopy(personality, introSeed)
|
||||
const frameIndex = Math.abs(introSeed + frameOffset) % HERMES_FRAME_COUNT
|
||||
const spriteOffsetPx = HERMES_DEFAULT_FRAME_OPTICAL_OFFSET_PX[frameIndex] ?? 0
|
||||
|
||||
const advanceFrame = useCallback(() => {
|
||||
setFrameOffset(offset => offset + 1 + Math.floor(Math.random() * (HERMES_FRAME_COUNT - 1)))
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
const id = window.setTimeout(advanceFrame, 7000)
|
||||
|
||||
return () => window.clearTimeout(id)
|
||||
}, [advanceFrame, frameOffset])
|
||||
|
||||
return (
|
||||
<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
|
||||
|
|
@ -187,9 +185,10 @@ export const Intro: FC<IntroProps> = ({ personality, seed }) => {
|
|||
<img
|
||||
alt=""
|
||||
aria-hidden="true"
|
||||
className="h-full w-full scale-110 object-contain select-none"
|
||||
className="h-full w-full object-contain select-none"
|
||||
draggable={false}
|
||||
src={publicAssetPath(`hermes-frames/hermes-frame-${frameIndex}.png?v=matte-clean-6`)}
|
||||
style={{ transform: `translateX(${spriteOffsetPx}px) scale(1.1)` }}
|
||||
/>
|
||||
</button>
|
||||
<p className="mb-3 text-xs font-medium uppercase tracking-[0.18em] text-muted-foreground/75">Hermes Agent</p>
|
||||
|
|
|
|||
|
|
@ -22,6 +22,7 @@ describe('preprocessMarkdown', () => {
|
|||
|
||||
it('demotes invalid fenced prose blocks with closers', () => {
|
||||
const fence = '```'
|
||||
|
||||
const input = [
|
||||
`${fence} http://localhost:8812/`,
|
||||
'- **Scroll wheel** - zoom',
|
||||
|
|
@ -48,7 +49,9 @@ describe('preprocessMarkdown', () => {
|
|||
})
|
||||
|
||||
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 input = ['```Heads up - a bunny got added', '- Pure white (`#ffffff`)', '- Ambient dropped to 0.18'].join(
|
||||
'\n'
|
||||
)
|
||||
const output = preprocessMarkdown(input)
|
||||
|
||||
expect(output).not.toContain('```heads')
|
||||
|
|
|
|||
|
|
@ -2,13 +2,14 @@
|
|||
|
||||
import { type StreamdownTextComponents, StreamdownTextPrimitive } from '@assistant-ui/react-streamdown'
|
||||
import { code } from '@streamdown/code'
|
||||
import { Check, Copy } from 'lucide-react'
|
||||
import { type ComponentProps, memo, useEffect, useMemo, useState } from 'react'
|
||||
|
||||
import { PreviewAttachment } from '@/components/assistant-ui/preview-attachment'
|
||||
import { SyntaxHighlighter } from '@/components/assistant-ui/shiki-highlighter'
|
||||
import { ZoomableImage } from '@/components/assistant-ui/zoomable-image'
|
||||
import { triggerHaptic } from '@/lib/haptics'
|
||||
import { Check, Copy } from '@/lib/icons'
|
||||
import { isLikelyProseCodeBlock, isLikelyProseFence, sanitizeLanguageTag } from '@/lib/markdown-code'
|
||||
import {
|
||||
filePathFromMediaPath,
|
||||
mediaExternalUrl,
|
||||
|
|
@ -17,7 +18,6 @@ import {
|
|||
mediaName,
|
||||
mediaPathFromMarkdownHref
|
||||
} from '@/lib/media'
|
||||
import { isLikelyProseCodeBlock, isLikelyProseFence, sanitizeLanguageTag } from '@/lib/markdown-code'
|
||||
import { isLikelyPreviewCandidate, previewTargetFromMarkdownHref, stripPreviewTargets } from '@/lib/preview-targets'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
|
|
@ -98,6 +98,7 @@ function normalizeFenceBlocks(text: string): string {
|
|||
if (!match) {
|
||||
out.push(line)
|
||||
index += 1
|
||||
|
||||
continue
|
||||
}
|
||||
|
||||
|
|
@ -111,6 +112,7 @@ function normalizeFenceBlocks(text: string): string {
|
|||
if (!openerValid) {
|
||||
out.push(`${indent}${infoRaw}`.trimEnd())
|
||||
index += 1
|
||||
|
||||
continue
|
||||
}
|
||||
|
||||
|
|
@ -122,6 +124,7 @@ function normalizeFenceBlocks(text: string): string {
|
|||
// Empty fenced block: drop both delimiters. This prevents Streamdown's
|
||||
// code plugin from rendering an empty shell/card.
|
||||
index = closeIndex + 1
|
||||
|
||||
continue
|
||||
}
|
||||
|
||||
|
|
@ -130,12 +133,14 @@ function normalizeFenceBlocks(text: string): string {
|
|||
// 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
|
||||
|
||||
continue
|
||||
}
|
||||
|
||||
|
|
@ -152,6 +157,7 @@ function normalizeFenceBlocks(text: string): string {
|
|||
if (isLikelyProseFence(infoRaw, body)) {
|
||||
pushProseFence(out, indent, infoRaw, bodyLines)
|
||||
index = closeIndex + 1
|
||||
|
||||
continue
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
import { useStore } from '@nanostores/react'
|
||||
import { MonitorPlay } from 'lucide-react'
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
|
||||
import { MonitorPlay } from '@/lib/icons'
|
||||
import { previewName } from '@/lib/preview-targets'
|
||||
import { notifyError } from '@/store/notifications'
|
||||
import { $previewTarget, setPreviewTarget } from '@/store/preview'
|
||||
|
|
@ -79,13 +79,15 @@ export function PreviewAttachment({ target }: { target: string }) {
|
|||
setOpening(true)
|
||||
|
||||
try {
|
||||
const preview = await window.hermesDesktop?.normalizePreviewTarget(requestTarget, requestCwd || undefined).catch(error => {
|
||||
if (isMissingPreviewIpc(error)) {
|
||||
return localFallbackPreview(requestTarget)
|
||||
}
|
||||
const preview = await window.hermesDesktop
|
||||
?.normalizePreviewTarget(requestTarget, requestCwd || undefined)
|
||||
.catch(error => {
|
||||
if (isMissingPreviewIpc(error)) {
|
||||
return localFallbackPreview(requestTarget)
|
||||
}
|
||||
|
||||
throw error
|
||||
})
|
||||
throw error
|
||||
})
|
||||
|
||||
if (
|
||||
!mountedRef.current ||
|
||||
|
|
|
|||
|
|
@ -11,28 +11,7 @@ import {
|
|||
useAuiState
|
||||
} from '@assistant-ui/react'
|
||||
import { useStore } from '@nanostores/react'
|
||||
import {
|
||||
CheckIcon,
|
||||
ChevronLeftIcon,
|
||||
ChevronRightIcon,
|
||||
CopyIcon,
|
||||
GitBranchIcon,
|
||||
Loader2Icon,
|
||||
MoreHorizontalIcon,
|
||||
PencilIcon,
|
||||
RefreshCwIcon,
|
||||
Volume2Icon,
|
||||
VolumeXIcon,
|
||||
XIcon
|
||||
} from 'lucide-react'
|
||||
import {
|
||||
type FC,
|
||||
type ReactNode,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useRef,
|
||||
useState
|
||||
} from 'react'
|
||||
import { type FC, type ReactNode, useCallback, useEffect, 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
|
||||
|
|
@ -66,6 +45,20 @@ import {
|
|||
} from '@/components/ui/dropdown-menu'
|
||||
import { Loader } from '@/components/ui/loader'
|
||||
import { triggerHaptic } from '@/lib/haptics'
|
||||
import {
|
||||
CheckIcon,
|
||||
ChevronLeftIcon,
|
||||
ChevronRightIcon,
|
||||
CopyIcon,
|
||||
GitBranchIcon,
|
||||
Loader2Icon,
|
||||
MoreHorizontalIcon,
|
||||
PencilIcon,
|
||||
RefreshCwIcon,
|
||||
Volume2Icon,
|
||||
VolumeXIcon,
|
||||
XIcon
|
||||
} from '@/lib/icons'
|
||||
import { extractPreviewTargets } from '@/lib/preview-targets'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { playSpeechText, stopVoicePlayback } from '@/lib/voice-playback'
|
||||
|
|
@ -227,20 +220,23 @@ const ThreadScrollSync: FC<{ sessionKey?: string | null }> = ({ sessionKey }) =>
|
|||
|
||||
// Slam to bottom + arm the ref. Also forces library state flags off
|
||||
// so its internal resize handler doesn't fight our re-pins.
|
||||
const armAndPin = useCallback((behavior: ScrollBehavior) => {
|
||||
const el = scrollRef.current
|
||||
const armAndPin = useCallback(
|
||||
(behavior: ScrollBehavior) => {
|
||||
const el = scrollRef.current
|
||||
|
||||
if (!el) {
|
||||
return
|
||||
}
|
||||
if (!el) {
|
||||
return
|
||||
}
|
||||
|
||||
armedRef.current = behavior
|
||||
// Clear the library's escape/at-bottom flags directly on the mutable
|
||||
// state object so its resize handler sees a clean follow state.
|
||||
state.escapedFromLock = false
|
||||
state.isAtBottom = true
|
||||
el.scrollTop = el.scrollHeight
|
||||
}, [scrollRef, state])
|
||||
armedRef.current = behavior
|
||||
// Clear the library's escape/at-bottom flags directly on the mutable
|
||||
// state object so its resize handler sees a clean follow state.
|
||||
state.escapedFromLock = false
|
||||
state.isAtBottom = true
|
||||
el.scrollTop = el.scrollHeight
|
||||
},
|
||||
[scrollRef, state]
|
||||
)
|
||||
|
||||
// ResizeObserver loop — re-pins to bottom while armed, disarms when
|
||||
// actually at bottom. This is the assistant-ui pattern.
|
||||
|
|
@ -293,6 +289,7 @@ const ThreadScrollSync: FC<{ sessionKey?: string | null }> = ({ sessionKey }) =>
|
|||
armedRef.current = null
|
||||
}
|
||||
}
|
||||
|
||||
const onTouch = () => {
|
||||
armedRef.current = null
|
||||
}
|
||||
|
|
@ -368,7 +365,9 @@ const ComposerClearance: FC = () => {
|
|||
|
||||
const composer = document.querySelector<HTMLElement>('[data-slot="composer-root"]')
|
||||
|
||||
return composer ? composer.getBoundingClientRect().height + COMPOSER_BREATHING_ROOM_PX : DEFAULT_COMPOSER_CLEARANCE_PX
|
||||
return composer
|
||||
? composer.getBoundingClientRect().height + COMPOSER_BREATHING_ROOM_PX
|
||||
: DEFAULT_COMPOSER_CLEARANCE_PX
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
|
|
@ -458,7 +457,10 @@ const AssistantMessage: FC<{ onBranchInNewChat?: (messageId: string) => void }>
|
|||
data-role="assistant"
|
||||
data-slot="aui_assistant-message-root"
|
||||
>
|
||||
<div className="wrap-anywhere min-w-0 max-w-full overflow-hidden 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,
|
||||
|
|
|
|||
|
|
@ -2,11 +2,11 @@
|
|||
|
||||
import { type ToolCallMessagePartProps } from '@assistant-ui/react'
|
||||
import { useStore } from '@nanostores/react'
|
||||
import { ChevronRight } from 'lucide-react'
|
||||
import { useEffect, useState } from 'react'
|
||||
|
||||
import { useElapsedSeconds } from '@/components/assistant-ui/activity-timer'
|
||||
import { ActivityTimerText } from '@/components/assistant-ui/activity-timer-text'
|
||||
import { ChevronRight } from '@/lib/icons'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { $toolInlineDiffs } from '@/store/tool-diffs'
|
||||
|
||||
|
|
@ -89,7 +89,11 @@ function stripAnsi(value: string): string {
|
|||
}
|
||||
|
||||
function stripInlineDiffChrome(value: string): string {
|
||||
return value ? stripAnsi(value).replace(/^\s*┊\s*review diff\s*\n/i, '').trim() : ''
|
||||
return value
|
||||
? stripAnsi(value)
|
||||
.replace(/^\s*┊\s*review diff\s*\n/i, '')
|
||||
.trim()
|
||||
: ''
|
||||
}
|
||||
|
||||
function inlineDiffFromResult(result: unknown): string {
|
||||
|
|
|
|||
|
|
@ -1,9 +1,9 @@
|
|||
'use client'
|
||||
|
||||
import { Download } from 'lucide-react'
|
||||
import { type ComponentProps, useState } from 'react'
|
||||
|
||||
import { Dialog, DialogContent } from '@/components/ui/dialog'
|
||||
import { Download } from '@/lib/icons'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { notify, notifyError } from '@/store/notifications'
|
||||
|
||||
|
|
|
|||
|
|
@ -1,9 +1,9 @@
|
|||
import { useStore } from '@nanostores/react'
|
||||
import { AlertCircle, AlertTriangle, CheckCircle2, Copy, Info, type LucideIcon, X } from 'lucide-react'
|
||||
import { type ReactNode, useEffect, useRef, useState } from 'react'
|
||||
|
||||
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'
|
||||
import { triggerHaptic } from '@/lib/haptics'
|
||||
import { AlertCircle, AlertTriangle, CheckCircle2, Copy, Info, type LucideIcon, X } from '@/lib/icons'
|
||||
import { cn } from '@/lib/utils'
|
||||
import {
|
||||
$notifications,
|
||||
|
|
|
|||
|
|
@ -57,13 +57,7 @@ export const SessionInspector: FC<SessionInspectorProps> = ({
|
|||
data-open={open}
|
||||
>
|
||||
<div className="flex min-h-0 flex-1 flex-col gap-2.5 overflow-y-auto overscroll-contain pl-1.5 pr-1 text-xs">
|
||||
<ProjectSection
|
||||
branch={branch}
|
||||
busy={busy}
|
||||
cwd={cwd}
|
||||
onBrowseCwd={onBrowseCwd}
|
||||
onChangeCwd={onChangeCwd}
|
||||
/>
|
||||
<ProjectSection branch={branch} busy={busy} cwd={cwd} onBrowseCwd={onBrowseCwd} onChangeCwd={onChangeCwd} />
|
||||
<AgentSection
|
||||
fastMode={fastMode}
|
||||
modelLabel={modelLabel}
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
import { CheckIcon } from 'lucide-react'
|
||||
import { Checkbox as CheckboxPrimitive } from 'radix-ui'
|
||||
import * as React from 'react'
|
||||
|
||||
import { CheckIcon } from '@/lib/icons'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
function Checkbox({ className, ...props }: React.ComponentProps<typeof CheckboxPrimitive.Root>) {
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
import { Command as CommandPrimitive } from 'cmdk'
|
||||
import { SearchIcon } from 'lucide-react'
|
||||
import * as React from 'react'
|
||||
|
||||
import { SearchIcon } from '@/lib/icons'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
function Command({ className, ...props }: React.ComponentProps<typeof CommandPrimitive>) {
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
import { XIcon } from 'lucide-react'
|
||||
import { Dialog as DialogPrimitive } from 'radix-ui'
|
||||
import * as React from 'react'
|
||||
|
||||
import { XIcon } from '@/lib/icons'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
function Dialog({ ...props }: React.ComponentProps<typeof DialogPrimitive.Root>) {
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
import { CheckIcon, ChevronRightIcon, CircleIcon } from 'lucide-react'
|
||||
import { DropdownMenu as DropdownMenuPrimitive } from 'radix-ui'
|
||||
import * as React from 'react'
|
||||
|
||||
import { CheckIcon, ChevronRightIcon, CircleIcon } from '@/lib/icons'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
function DropdownMenu({ ...props }: React.ComponentProps<typeof DropdownMenuPrimitive.Root>) {
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import { ChevronLeft, ChevronRight, MoreHorizontal } from 'lucide-react'
|
||||
import * as React from 'react'
|
||||
|
||||
import { ChevronLeft, ChevronRight, MoreHorizontal } from '@/lib/icons'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
function Pagination({ className, ...props }: React.ComponentProps<'nav'>) {
|
||||
|
|
@ -15,7 +15,9 @@ function Pagination({ className, ...props }: React.ComponentProps<'nav'>) {
|
|||
}
|
||||
|
||||
function PaginationContent({ className, ...props }: React.ComponentProps<'ul'>) {
|
||||
return <ul className={cn('flex h-5 flex-row items-center gap-0.5', className)} data-slot="pagination-content" {...props} />
|
||||
return (
|
||||
<ul className={cn('flex h-5 flex-row items-center gap-0.5', className)} data-slot="pagination-content" {...props} />
|
||||
)
|
||||
}
|
||||
|
||||
function PaginationItem({ className, ...props }: React.ComponentProps<'li'>) {
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
import { CheckIcon, ChevronDownIcon } from 'lucide-react'
|
||||
import { Select as SelectPrimitive } from 'radix-ui'
|
||||
import * as React from 'react'
|
||||
|
||||
import { CheckIcon, ChevronDownIcon } from '@/lib/icons'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
function Select({ ...props }: React.ComponentProps<typeof SelectPrimitive.Root>) {
|
||||
|
|
|
|||
|
|
@ -1,9 +1,9 @@
|
|||
'use client'
|
||||
|
||||
import { XIcon } from 'lucide-react'
|
||||
import { Dialog as SheetPrimitive } from 'radix-ui'
|
||||
import * as React from 'react'
|
||||
|
||||
import { XIcon } from '@/lib/icons'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
function Sheet({ ...props }: React.ComponentProps<typeof SheetPrimitive.Root>) {
|
||||
|
|
|
|||
|
|
@ -1,7 +1,6 @@
|
|||
'use client'
|
||||
|
||||
import { cva, type VariantProps } from 'class-variance-authority'
|
||||
import { PanelLeftIcon } from 'lucide-react'
|
||||
import { Slot } from 'radix-ui'
|
||||
import * as React from 'react'
|
||||
|
||||
|
|
@ -12,6 +11,7 @@ import { Sheet, SheetContent, SheetDescription, SheetHeader, SheetTitle } from '
|
|||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip'
|
||||
import { useIsMobile } from '@/hooks/use-mobile'
|
||||
import { PanelLeftIcon } from '@/lib/icons'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
const SIDEBAR_COOKIE_NAME = 'sidebar_state'
|
||||
|
|
|
|||
|
|
@ -1,24 +1,35 @@
|
|||
import { JsonRpcGatewayClient } from '@hermes/shared'
|
||||
|
||||
import type {
|
||||
ActionResponse,
|
||||
ActionStatusResponse,
|
||||
AudioSpeakResponse,
|
||||
AudioTranscriptionResponse,
|
||||
AuxiliaryModelsResponse,
|
||||
ConfigSchemaResponse,
|
||||
ElevenLabsVoicesResponse,
|
||||
EnvVarInfo,
|
||||
HermesConfig,
|
||||
HermesConfigRecord,
|
||||
LogsResponse,
|
||||
ModelAssignmentRequest,
|
||||
ModelAssignmentResponse,
|
||||
ModelInfoResponse,
|
||||
ModelOptionsResponse,
|
||||
PaginatedSessions,
|
||||
RpcEvent,
|
||||
SessionMessagesResponse,
|
||||
SessionSearchResponse,
|
||||
SkillInfo,
|
||||
StatusResponse,
|
||||
ToolsetInfo
|
||||
} from '@/types/hermes'
|
||||
import { JsonRpcGatewayClient } from '@hermes/shared'
|
||||
|
||||
export type {
|
||||
ActionResponse,
|
||||
ActionStatusResponse,
|
||||
AudioSpeakResponse,
|
||||
AudioTranscriptionResponse,
|
||||
AuxiliaryModelsResponse,
|
||||
ConfigFieldSchema,
|
||||
ConfigSchemaResponse,
|
||||
ElevenLabsVoice,
|
||||
|
|
@ -27,6 +38,9 @@ export type {
|
|||
GatewayReadyPayload,
|
||||
HermesConfig,
|
||||
HermesConfigRecord,
|
||||
LogsResponse,
|
||||
ModelAssignmentRequest,
|
||||
ModelAssignmentResponse,
|
||||
ModelInfoResponse,
|
||||
ModelOptionProvider,
|
||||
ModelOptionsResponse,
|
||||
|
|
@ -38,7 +52,10 @@ export type {
|
|||
SessionMessagesResponse,
|
||||
SessionResumeResponse,
|
||||
SessionRuntimeInfo,
|
||||
SessionSearchResponse,
|
||||
SessionSearchResult,
|
||||
SkillInfo,
|
||||
StatusResponse,
|
||||
ToolsetInfo
|
||||
} from '@/types/hermes'
|
||||
|
||||
|
|
@ -66,6 +83,12 @@ export async function listSessions(limit = 40, minMessages = 0): Promise<Paginat
|
|||
}
|
||||
}
|
||||
|
||||
export function searchSessions(query: string): Promise<SessionSearchResponse> {
|
||||
return window.hermesDesktop.api<SessionSearchResponse>({
|
||||
path: `/api/sessions/search?q=${encodeURIComponent(query)}`
|
||||
})
|
||||
}
|
||||
|
||||
export function getSessionMessages(id: string): Promise<SessionMessagesResponse> {
|
||||
return window.hermesDesktop.api<SessionMessagesResponse>({
|
||||
path: `/api/sessions/${encodeURIComponent(id)}/messages`
|
||||
|
|
@ -85,6 +108,43 @@ export function getGlobalModelInfo(): Promise<ModelInfoResponse> {
|
|||
})
|
||||
}
|
||||
|
||||
export function getStatus(): Promise<StatusResponse> {
|
||||
return window.hermesDesktop.api<StatusResponse>({
|
||||
path: '/api/status'
|
||||
})
|
||||
}
|
||||
|
||||
export function getLogs(params: {
|
||||
component?: string
|
||||
file?: string
|
||||
level?: string
|
||||
lines?: number
|
||||
}): Promise<LogsResponse> {
|
||||
const query = new URLSearchParams()
|
||||
|
||||
if (params.file) {
|
||||
query.set('file', params.file)
|
||||
}
|
||||
|
||||
if (typeof params.lines === 'number') {
|
||||
query.set('lines', String(params.lines))
|
||||
}
|
||||
|
||||
if (params.level && params.level !== 'ALL') {
|
||||
query.set('level', params.level)
|
||||
}
|
||||
|
||||
if (params.component && params.component !== 'all') {
|
||||
query.set('component', params.component)
|
||||
}
|
||||
|
||||
const suffix = query.toString()
|
||||
|
||||
return window.hermesDesktop.api<LogsResponse>({
|
||||
path: suffix ? `/api/logs?${suffix}` : '/api/logs'
|
||||
})
|
||||
}
|
||||
|
||||
export function getHermesConfig(): Promise<HermesConfig> {
|
||||
return window.hermesDesktop.api<HermesConfig>({
|
||||
path: '/api/config'
|
||||
|
|
@ -188,6 +248,40 @@ export function setGlobalModel(
|
|||
})
|
||||
}
|
||||
|
||||
export function getAuxiliaryModels(): Promise<AuxiliaryModelsResponse> {
|
||||
return window.hermesDesktop.api<AuxiliaryModelsResponse>({
|
||||
path: '/api/model/auxiliary'
|
||||
})
|
||||
}
|
||||
|
||||
export function setModelAssignment(body: ModelAssignmentRequest): Promise<ModelAssignmentResponse> {
|
||||
return window.hermesDesktop.api<ModelAssignmentResponse>({
|
||||
path: '/api/model/set',
|
||||
method: 'POST',
|
||||
body
|
||||
})
|
||||
}
|
||||
|
||||
export function restartGateway(): Promise<ActionResponse> {
|
||||
return window.hermesDesktop.api<ActionResponse>({
|
||||
path: '/api/gateway/restart',
|
||||
method: 'POST'
|
||||
})
|
||||
}
|
||||
|
||||
export function updateHermes(): Promise<ActionResponse> {
|
||||
return window.hermesDesktop.api<ActionResponse>({
|
||||
path: '/api/hermes/update',
|
||||
method: 'POST'
|
||||
})
|
||||
}
|
||||
|
||||
export function getActionStatus(name: string, lines = 200): Promise<ActionStatusResponse> {
|
||||
return window.hermesDesktop.api<ActionStatusResponse>({
|
||||
path: `/api/actions/${encodeURIComponent(name)}/status?lines=${Math.max(1, lines)}`
|
||||
})
|
||||
}
|
||||
|
||||
export function transcribeAudio(dataUrl: string, mimeType?: string): Promise<AudioTranscriptionResponse> {
|
||||
return window.hermesDesktop.api<AudioTranscriptionResponse>({
|
||||
path: '/api/audio/transcribe',
|
||||
|
|
|
|||
|
|
@ -1,6 +1,12 @@
|
|||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { appendAssistantTextPart, chatMessageText, renderMediaTags, toChatMessages, upsertToolPart } from './chat-messages'
|
||||
import {
|
||||
appendAssistantTextPart,
|
||||
chatMessageText,
|
||||
renderMediaTags,
|
||||
toChatMessages,
|
||||
upsertToolPart
|
||||
} from './chat-messages'
|
||||
|
||||
describe('toChatMessages', () => {
|
||||
it('keeps a turn with interleaved tool-only rows in a single bubble', () => {
|
||||
|
|
@ -65,8 +71,7 @@ describe('toChatMessages', () => {
|
|||
const [message] = toChatMessages([
|
||||
{
|
||||
content: {
|
||||
text:
|
||||
'look\n\n--- Attached Context ---\n\n📄 @file:foo.ts (10 tokens)\n```ts\nconst x = 1\n```'
|
||||
text: 'look\n\n--- Attached Context ---\n\n📄 @file:foo.ts (10 tokens)\n```ts\nconst x = 1\n```'
|
||||
},
|
||||
role: 'user',
|
||||
timestamp: 1
|
||||
|
|
|
|||
|
|
@ -52,8 +52,7 @@ export function reasoningPart(text: string): ChatMessagePart {
|
|||
return { type: 'reasoning', text }
|
||||
}
|
||||
|
||||
const MEDIA_LINE_RE =
|
||||
/(^|\n)[\t ]*[`"']?MEDIA:\s*(?<line>`[^`\n]+`|"[^"\n]+"|'[^'\n]+'|\S+)[`"']?[\t ]*(\n|$)/g
|
||||
const MEDIA_LINE_RE = /(^|\n)[\t ]*[`"']?MEDIA:\s*(?<line>`[^`\n]+`|"[^"\n]+"|'[^'\n]+'|\S+)[`"']?[\t ]*(\n|$)/g
|
||||
|
||||
const MEDIA_TAG_RE = /[`"']?MEDIA:\s*(?<inline>`[^`\n]+`|"[^"\n]+"|'[^'\n]+'|\S+)[`"']?/g
|
||||
|
||||
|
|
|
|||
|
|
@ -1,9 +1,9 @@
|
|||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import {
|
||||
desktopSkinSlashCompletions,
|
||||
desktopSlashDescription,
|
||||
desktopSlashUnavailableMessage,
|
||||
desktopSkinSlashCompletions,
|
||||
filterDesktopCommandsCatalog,
|
||||
isDesktopSlashCommand,
|
||||
isDesktopSlashSuggestion
|
||||
|
|
|
|||
|
|
@ -196,6 +196,7 @@ export function desktopSkinSlashCompletions(
|
|||
argPrefix: string
|
||||
): DesktopSlashCompletion[] {
|
||||
const prefix = argPrefix.trim().toLowerCase()
|
||||
|
||||
const commands: DesktopSlashCompletion[] = [
|
||||
{
|
||||
text: '/skin list',
|
||||
|
|
@ -243,9 +244,5 @@ export function filterDesktopCommandsCatalog(catalog: CommandsCatalogLike): Comm
|
|||
}
|
||||
|
||||
function isKnownHermesSlashCommand(command: string): boolean {
|
||||
return (
|
||||
DESKTOP_COMMANDS.has(command) ||
|
||||
DESKTOP_ALIASES.has(command) ||
|
||||
BLOCKED_COMMANDS.has(command)
|
||||
)
|
||||
return DESKTOP_COMMANDS.has(command) || DESKTOP_ALIASES.has(command) || BLOCKED_COMMANDS.has(command)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -37,6 +37,7 @@ export function extractEmbeddedImages(text: string): EmbeddedImageExtraction {
|
|||
}
|
||||
|
||||
const images: string[] = []
|
||||
|
||||
const cleanedText = text
|
||||
.replace(EMBEDDED_IMAGE_RE, (_match, _open, dataUrl: string) => {
|
||||
images.push(dataUrl)
|
||||
|
|
|
|||
177
apps/desktop/src/lib/icons.ts
Normal file
177
apps/desktop/src/lib/icons.ts
Normal file
|
|
@ -0,0 +1,177 @@
|
|||
import {
|
||||
IconActivity as Activity,
|
||||
IconAlertCircle as AlertCircle,
|
||||
IconAlertTriangle as AlertTriangle,
|
||||
IconArrowUp as ArrowUp,
|
||||
IconAt as AtSign,
|
||||
IconWaveSine as AudioLines,
|
||||
IconBrain as Brain,
|
||||
IconBug as Bug,
|
||||
IconCheck as Check,
|
||||
IconCircleCheck as CheckCircle2,
|
||||
IconCheck as CheckIcon,
|
||||
IconChevronDown as ChevronDown,
|
||||
IconChevronDown as ChevronDownIcon,
|
||||
IconChevronLeft as ChevronLeft,
|
||||
IconChevronLeft as ChevronLeftIcon,
|
||||
IconChevronRight as ChevronRight,
|
||||
IconChevronRight as ChevronRightIcon,
|
||||
IconCircle as CircleIcon,
|
||||
IconClipboard as Clipboard,
|
||||
IconCommand as Command,
|
||||
IconCopy as Copy,
|
||||
IconCopy as CopyIcon,
|
||||
IconCpu as Cpu,
|
||||
IconDownload as Download,
|
||||
IconExternalLink as ExternalLink,
|
||||
IconEye as Eye,
|
||||
IconEyeOff as EyeOff,
|
||||
IconPhoto as FileImage,
|
||||
IconFileText as FileText,
|
||||
IconFolderOpen as FolderOpen,
|
||||
IconGitBranch as GitBranch,
|
||||
IconGitBranch as GitBranchIcon,
|
||||
IconGlobe as Globe,
|
||||
IconHelpCircle as HelpCircle,
|
||||
IconPhoto as ImageIcon,
|
||||
IconInfoCircle as Info,
|
||||
IconKey as KeyRound,
|
||||
IconLayersIntersect2 as Layers3,
|
||||
IconLink as Link,
|
||||
IconLink as Link2,
|
||||
IconLink as LinkIcon,
|
||||
IconLoader2 as Loader2,
|
||||
IconLoader2 as Loader2Icon,
|
||||
IconLock as Lock,
|
||||
IconMessageCircle as MessageCircle,
|
||||
IconMessage2 as MessageSquareText,
|
||||
IconMicrophone as Mic,
|
||||
IconMicrophoneOff as MicOff,
|
||||
IconDeviceDesktop as Monitor,
|
||||
IconDeviceDesktopAnalytics as MonitorPlay,
|
||||
IconMoon as Moon,
|
||||
IconDots as MoreHorizontal,
|
||||
IconDots as MoreHorizontalIcon,
|
||||
IconDotsVertical as MoreVertical,
|
||||
IconNotebook as NotebookTabs,
|
||||
IconPackage as Package,
|
||||
IconPalette as Palette,
|
||||
IconLayoutBottombar as PanelBottom,
|
||||
IconLayoutSidebar as PanelLeftIcon,
|
||||
IconPencil as Pencil,
|
||||
IconPencil as PencilIcon,
|
||||
IconPencil as PencilLine,
|
||||
IconPin as Pin,
|
||||
IconPlus as Plus,
|
||||
IconRefresh as RefreshCw,
|
||||
IconRefresh as RefreshCwIcon,
|
||||
IconDeviceFloppy as Save,
|
||||
IconSearch as Search,
|
||||
IconSearch as SearchIcon,
|
||||
IconSend as Send,
|
||||
IconSettings as Settings,
|
||||
IconSettings2 as Settings2,
|
||||
IconAdjustmentsHorizontal as SlidersHorizontal,
|
||||
IconSparkles as Sparkles,
|
||||
IconSquare as Square,
|
||||
IconSun as Sun,
|
||||
IconTrash as Trash2,
|
||||
IconVolume2 as Volume2,
|
||||
IconVolume2 as Volume2Icon,
|
||||
IconVolumeOff as VolumeX,
|
||||
IconVolumeOff as VolumeXIcon,
|
||||
IconTool as Wrench,
|
||||
IconX as X,
|
||||
IconX as XIcon,
|
||||
IconBolt as Zap
|
||||
} from '@tabler/icons-react'
|
||||
|
||||
export {
|
||||
Activity,
|
||||
AlertCircle,
|
||||
AlertTriangle,
|
||||
ArrowUp,
|
||||
AtSign,
|
||||
AudioLines,
|
||||
Brain,
|
||||
Bug,
|
||||
Check,
|
||||
CheckCircle2,
|
||||
CheckIcon,
|
||||
ChevronDown,
|
||||
ChevronDownIcon,
|
||||
ChevronLeft,
|
||||
ChevronLeftIcon,
|
||||
ChevronRight,
|
||||
ChevronRightIcon,
|
||||
CircleIcon,
|
||||
Clipboard,
|
||||
Command,
|
||||
Copy,
|
||||
CopyIcon,
|
||||
Cpu,
|
||||
Download,
|
||||
ExternalLink,
|
||||
Eye,
|
||||
EyeOff,
|
||||
FileImage,
|
||||
FileText,
|
||||
FolderOpen,
|
||||
GitBranch,
|
||||
GitBranchIcon,
|
||||
Globe,
|
||||
HelpCircle,
|
||||
ImageIcon,
|
||||
Info,
|
||||
KeyRound,
|
||||
Layers3,
|
||||
Link,
|
||||
Link2,
|
||||
LinkIcon,
|
||||
Loader2,
|
||||
Loader2Icon,
|
||||
Lock,
|
||||
MessageCircle,
|
||||
MessageSquareText,
|
||||
Mic,
|
||||
MicOff,
|
||||
Monitor,
|
||||
MonitorPlay,
|
||||
Moon,
|
||||
MoreHorizontal,
|
||||
MoreHorizontalIcon,
|
||||
MoreVertical,
|
||||
NotebookTabs,
|
||||
Package,
|
||||
Palette,
|
||||
PanelBottom,
|
||||
PanelLeftIcon,
|
||||
Pencil,
|
||||
PencilIcon,
|
||||
PencilLine,
|
||||
Pin,
|
||||
Plus,
|
||||
RefreshCw,
|
||||
RefreshCwIcon,
|
||||
Save,
|
||||
Search,
|
||||
SearchIcon,
|
||||
Send,
|
||||
Settings,
|
||||
Settings2,
|
||||
SlidersHorizontal,
|
||||
Sparkles,
|
||||
Square,
|
||||
Sun,
|
||||
Trash2,
|
||||
Volume2,
|
||||
Volume2Icon,
|
||||
VolumeX,
|
||||
VolumeXIcon,
|
||||
Wrench,
|
||||
X,
|
||||
XIcon,
|
||||
Zap
|
||||
}
|
||||
|
||||
export type { LucideIcon } from 'lucide-react'
|
||||
|
|
@ -1,5 +1,6 @@
|
|||
const VALID_LANGUAGE_RE = /^[a-z0-9][a-z0-9+#-]*$/i
|
||||
const NON_CODE_FENCE_LANGUAGES = new Set(['', 'text', 'plain', 'plaintext', 'md', 'markdown'])
|
||||
|
||||
const COMMON_CODE_LANGUAGES = new Set([
|
||||
'bash',
|
||||
'c',
|
||||
|
|
@ -48,14 +49,11 @@ export function sanitizeLanguageTag(tag: string): string {
|
|||
}
|
||||
|
||||
function proseLineCount(body: string): number {
|
||||
return body
|
||||
.split('\n')
|
||||
.filter(line => {
|
||||
const trimmed = line.trim()
|
||||
return body.split('\n').filter(line => {
|
||||
const trimmed = line.trim()
|
||||
|
||||
return Boolean(trimmed) && /^[A-Za-z0-9"'`*-]/.test(trimmed)
|
||||
})
|
||||
.length
|
||||
return Boolean(trimmed) && /^[A-Za-z0-9"'`*-]/.test(trimmed)
|
||||
}).length
|
||||
}
|
||||
|
||||
const CODE_SIGNAL_RE = [
|
||||
|
|
|
|||
|
|
@ -97,7 +97,12 @@ function isLocalPreviewUrl(value: string): boolean {
|
|||
export function isLikelyPreviewCandidate(value: string): boolean {
|
||||
const trimmed = stripTrailingPunctuation(value.trim())
|
||||
|
||||
return isHtmlFileUrl(trimmed) || HTML_EXT_RE.test(trimmed) || isPreviewDirectoryCandidate(trimmed) || isLocalPreviewUrl(trimmed)
|
||||
return (
|
||||
isHtmlFileUrl(trimmed) ||
|
||||
HTML_EXT_RE.test(trimmed) ||
|
||||
isPreviewDirectoryCandidate(trimmed) ||
|
||||
isLocalPreviewUrl(trimmed)
|
||||
)
|
||||
}
|
||||
|
||||
function collectPreviewMatches(text: string): PreviewCandidateMatch[] {
|
||||
|
|
|
|||
56
apps/desktop/src/lib/session-export.ts
Normal file
56
apps/desktop/src/lib/session-export.ts
Normal file
|
|
@ -0,0 +1,56 @@
|
|||
import type { SessionInfo } from '@/hermes'
|
||||
import { getSessionMessages } from '@/hermes'
|
||||
import { notify, notifyError } from '@/store/notifications'
|
||||
|
||||
interface ExportSessionParams {
|
||||
sessionId: string
|
||||
title?: string | null
|
||||
session?: SessionInfo
|
||||
}
|
||||
|
||||
function sanitizeFilenamePart(value: string) {
|
||||
return value
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9._-]+/g, '-')
|
||||
.replace(/^-+|-+$/g, '')
|
||||
.slice(0, 48)
|
||||
}
|
||||
|
||||
function sessionExportFilename(sessionId: string, title?: string | null) {
|
||||
const titlePart = title ? sanitizeFilenamePart(title) : ''
|
||||
const idPart = sanitizeFilenamePart(sessionId).slice(0, 8) || 'session'
|
||||
|
||||
return `${titlePart || 'session'}-${idPart}.json`
|
||||
}
|
||||
|
||||
export async function exportSession(sessionId: string, params: Omit<ExportSessionParams, 'sessionId'> = {}) {
|
||||
if (!sessionId) {
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const { messages } = await getSessionMessages(sessionId)
|
||||
|
||||
const payload = {
|
||||
exported_at: new Date().toISOString(),
|
||||
session_id: sessionId,
|
||||
title: params.title ?? null,
|
||||
session: params.session ?? null,
|
||||
message_count: messages.length,
|
||||
messages
|
||||
}
|
||||
|
||||
const blob = new Blob([JSON.stringify(payload, null, 2)], { type: 'application/json' })
|
||||
const downloadUrl = URL.createObjectURL(blob)
|
||||
const anchor = document.createElement('a')
|
||||
anchor.href = downloadUrl
|
||||
anchor.download = sessionExportFilename(sessionId, params.title)
|
||||
anchor.click()
|
||||
URL.revokeObjectURL(downloadUrl)
|
||||
|
||||
notify({ kind: 'success', message: 'Session exported', durationMs: 2_000 })
|
||||
} catch (err) {
|
||||
notifyError(err, 'Could not export session')
|
||||
}
|
||||
}
|
||||
|
|
@ -38,6 +38,7 @@ const DENSITY_MULTIPLIERS: Record<ThemeDensity, string> = {
|
|||
|
||||
const INJECTED_FONT_URLS = new Set<string>()
|
||||
const SKIN_THEME_LIST = BUILTIN_THEME_LIST.filter(t => t.name !== 'nous-light')
|
||||
|
||||
const NOUS_FONT_FAMILY_FALLBACK = {
|
||||
fontSans: nousTheme.typography?.fontSans ?? DEFAULT_TYPOGRAPHY.fontSans,
|
||||
fontMono: nousTheme.typography?.fontMono ?? DEFAULT_TYPOGRAPHY.fontMono
|
||||
|
|
|
|||
|
|
@ -192,3 +192,88 @@ export interface ToolsetInfo {
|
|||
name: string
|
||||
tools: string[]
|
||||
}
|
||||
|
||||
export interface SessionSearchResult {
|
||||
model: string | null
|
||||
role: string | null
|
||||
session_id: string
|
||||
session_started: number | null
|
||||
snippet: string
|
||||
source: string | null
|
||||
}
|
||||
|
||||
export interface SessionSearchResponse {
|
||||
results: SessionSearchResult[]
|
||||
}
|
||||
|
||||
export interface LogsResponse {
|
||||
file: string
|
||||
lines: string[]
|
||||
}
|
||||
|
||||
export interface PlatformStatus {
|
||||
error_code?: string
|
||||
error_message?: string
|
||||
state: string
|
||||
updated_at: string
|
||||
}
|
||||
|
||||
export interface StatusResponse {
|
||||
active_sessions: number
|
||||
config_path: string
|
||||
config_version: number
|
||||
env_path: string
|
||||
gateway_exit_reason: string | null
|
||||
gateway_health_url: string | null
|
||||
gateway_pid: number | null
|
||||
gateway_platforms: Record<string, PlatformStatus>
|
||||
gateway_running: boolean
|
||||
gateway_state: string | null
|
||||
gateway_updated_at: string | null
|
||||
hermes_home: string
|
||||
latest_config_version: number
|
||||
release_date: string
|
||||
version: string
|
||||
}
|
||||
|
||||
export interface ActionResponse {
|
||||
name: string
|
||||
ok: boolean
|
||||
pid: number
|
||||
}
|
||||
|
||||
export interface ActionStatusResponse {
|
||||
exit_code: number | null
|
||||
lines: string[]
|
||||
name: string
|
||||
pid: number | null
|
||||
running: boolean
|
||||
}
|
||||
|
||||
export interface AuxiliaryTaskAssignment {
|
||||
base_url: string
|
||||
model: string
|
||||
provider: string
|
||||
task: string
|
||||
}
|
||||
|
||||
export interface AuxiliaryModelsResponse {
|
||||
main: { model: string; provider: string }
|
||||
tasks: AuxiliaryTaskAssignment[]
|
||||
}
|
||||
|
||||
export interface ModelAssignmentRequest {
|
||||
model: string
|
||||
provider: string
|
||||
scope: 'main' | 'auxiliary'
|
||||
task?: string
|
||||
}
|
||||
|
||||
export interface ModelAssignmentResponse {
|
||||
model?: string
|
||||
ok: boolean
|
||||
provider?: string
|
||||
reset?: boolean
|
||||
scope?: string
|
||||
tasks?: string[]
|
||||
}
|
||||
|
|
|
|||
27
package-lock.json
generated
27
package-lock.json
generated
|
|
@ -73,6 +73,7 @@
|
|||
"@nanostores/react": "^1.1.0",
|
||||
"@radix-ui/react-slot": "^1.2.4",
|
||||
"@streamdown/code": "^1.1.1",
|
||||
"@tabler/icons-react": "^3.41.1",
|
||||
"@tailwindcss/vite": "^4.2.4",
|
||||
"@tanstack/react-query": "^5.100.6",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
|
|
@ -7340,6 +7341,32 @@
|
|||
"node": ">=10"
|
||||
}
|
||||
},
|
||||
"node_modules/@tabler/icons": {
|
||||
"version": "3.41.1",
|
||||
"resolved": "https://registry.npmjs.org/@tabler/icons/-/icons-3.41.1.tgz",
|
||||
"integrity": "sha512-OaRnVbRmH2nHtFeg+RmMJ/7m2oBIF9XCJAUD5gQnMrpK9f05ydj8MZrAf3NZQqOXyxGN1UBL0D5IKLLEUfr74Q==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/codecalm"
|
||||
}
|
||||
},
|
||||
"node_modules/@tabler/icons-react": {
|
||||
"version": "3.41.1",
|
||||
"resolved": "https://registry.npmjs.org/@tabler/icons-react/-/icons-react-3.41.1.tgz",
|
||||
"integrity": "sha512-kUgweE+DJtAlMZVIns1FTDdcbpRVnkK7ZpUOXmoxy3JAF0rSHj0TcP4VHF14+gMJGnF+psH2Zt26BLT6owetBA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@tabler/icons": "3.41.1"
|
||||
},
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/codecalm"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": ">= 16"
|
||||
}
|
||||
},
|
||||
"node_modules/@tailwindcss/node": {
|
||||
"version": "4.2.4",
|
||||
"resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.2.4.tgz",
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue