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
+8 -3
View File
@@ -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
+1 -2
View File
@@ -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)
}
+1
View File
@@ -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
View 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'
+5 -7
View File
@@ -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 = [
+6 -1
View File
@@ -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
View 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')
}
}