feat: better icons and overlay panes

This commit is contained in:
Brooklyn Nicholson
2026-05-04 14:20:18 -05:00
parent ca8f2c7907
commit d1d0ed4016
71 changed files with 2043 additions and 363 deletions
@@ -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 -2
View File
@@ -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 {
+2 -2
View File
@@ -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'