feat(desktop): reconcile live tool events, polish thread chrome, harden boot

- chat-messages: match tool rows by overlapping query/context/preview values
  so preview-first `tool.progress` rows reliably adopt later stable-id
  `tool.start` payloads instead of spawning ghost rows or mis-merging
  parallel same-name calls; preserve prior args/result across phases.
- tui_gateway: emit full args + parsed result on `tool.start` / `tool.complete`,
  drop redundant `tool.started` re-emit from `tool.progress`.
- electron/main: prefer SOURCE_REPO_ROOT before PATH `hermes` in dev so
  local backend edits actually run; split hardening helpers into
  `electron/hardening.cjs` with tests.
- thread/tool UI: one-shot enter animation keyed by stable ids, braille
  spinner for running rows, Cursor-like disclosure rows, drill-down +
  duration/count formatting via new tool-fallback-model.
- composer: extract `text-utils`, drop liquid-glass overrides.
- right-rail: split preview-pane into preview-console / preview-file.
- runtime: incremental external-store runtime + runtime-readiness gate;
  onboarding store + tests; route-resume hook test.
- regression tests for live tool reconciliation (parallel tools, id-less
  progress, preview-first rows, structured args/results).
This commit is contained in:
Brooklyn Nicholson
2026-05-11 21:38:47 -04:00
parent fdf73f0adf
commit d208f2c2c0
64 changed files with 5614 additions and 2703 deletions
@@ -49,6 +49,7 @@ describe('collectArtifactsForSession', () => {
timestamp: 3000
}
]
const artifacts = collectArtifactsForSession(makeSession({ id: 'session-2' }), messages)
expect(artifacts).toHaveLength(1)
+1 -4
View File
@@ -859,10 +859,7 @@ function LocationCell({ artifact }: { artifact: ArtifactRecord; ctx: CellCtx })
return (
<div className="group/location flex min-w-0 items-center gap-1.5">
<div
className={cn(
'min-w-0 flex-1 truncate text-xs text-muted-foreground/85',
isLink ? 'font-medium' : 'font-mono'
)}
className={cn('min-w-0 flex-1 truncate text-xs text-muted-foreground/85', isLink ? 'font-medium' : 'font-mono')}
title={artifact.value}
>
{value}
+7 -145
View File
@@ -1,9 +1,6 @@
import './liquid-glass-overrides.css'
import type { Unstable_TriggerAdapter, Unstable_TriggerItem } from '@assistant-ui/core'
import { ComposerPrimitive, useAui, useAuiState } from '@assistant-ui/react'
import { useStore } from '@nanostores/react'
import LiquidGlass from 'liquid-glass-react'
import {
type ClipboardEvent,
type FormEvent,
@@ -20,7 +17,7 @@ import { useMediaQuery } from '@/hooks/use-media-query'
import { useResizeObserver } from '@/hooks/use-resize-observer'
import { chatMessageText } from '@/lib/chat-messages'
import { contextPath } from '@/lib/chat-runtime'
import { DATA_IMAGE_URL_RE, dataUrlToBlob } from '@/lib/embedded-images'
import { DATA_IMAGE_URL_RE } from '@/lib/embedded-images'
import { triggerHaptic } from '@/lib/haptics'
import { cn } from '@/lib/utils'
import { $composerAttachments, $composerDraft } from '@/store/composer'
@@ -45,117 +42,16 @@ import {
RICH_INPUT_SLOT
} from './rich-editor'
import { SkinSlashPopover } from './skin-slash-popover'
import { detectTrigger, extractClipboardImageBlobs, textBeforeCaret, type TriggerState } from './text-utils'
import { ComposerTriggerPopover } from './trigger-popover'
import type { ChatBarProps } from './types'
import { UrlDialog } from './url-dialog'
import { VoiceActivity, VoicePlaybackActivity } from './voice-activity'
function extractClipboardImageBlobs(clipboard: DataTransfer): Blob[] {
const blobs: Blob[] = []
const seen = new Set<Blob>()
const push = (blob: Blob | null) => {
if (!blob || blob.size === 0 || seen.has(blob)) {
return
}
seen.add(blob)
blobs.push(blob)
}
if (clipboard.items?.length) {
for (const item of clipboard.items) {
if (item.kind === 'file' && item.type.startsWith('image/')) {
push(item.getAsFile())
}
}
}
if (clipboard.files?.length) {
for (let i = 0; i < clipboard.files.length; i += 1) {
const file = clipboard.files.item(i)
if (file && file.type.startsWith('image/')) {
push(file)
}
}
}
if (blobs.length > 0) {
return blobs
}
const text = clipboard.getData('text/plain').trim()
if (DATA_IMAGE_URL_RE.test(text)) {
push(dataUrlToBlob(text))
}
if (blobs.length === 0) {
const html = clipboard.getData('text/html')
if (html) {
const matches = html.matchAll(/<img\b[^>]*?\bsrc\s*=\s*["'](data:image\/[^"']+)["']/gi)
for (const match of matches) {
push(dataUrlToBlob(match[1]))
}
}
}
return blobs
}
const COMPOSER_STACK_BREAKPOINT_PX = 320
const COMPOSER_GLASS = {
fadeBackground: 'linear-gradient(to bottom, transparent, color-mix(in srgb, var(--dt-background) 10%, transparent))',
liquidKey: ['standard', '0.950', '0.072', '0', '46', '0.00', '128'].join(':'),
showLibraryRims: false,
liquid: {
aberrationIntensity: 0.95,
blurAmount: 0.072,
cornerRadius: 0,
displacementScale: 46,
elasticity: 0,
mode: 'standard' as const,
saturation: 128
}
}
interface TriggerState {
kind: '@' | '/'
query: string
tokenLength: number
}
const TRIGGER_RE = /(?:^|[\s])([@/])([^\s@/]*)$/
/** Caret-anchored text before the cursor, or null if the selection isn't a collapsed caret inside `editor`. */
function textBeforeCaret(editor: HTMLDivElement): string | null {
const sel = window.getSelection()
const range = sel?.rangeCount ? sel.getRangeAt(0) : null
if (!range?.collapsed || !editor.contains(range.commonAncestorContainer)) {
return null
}
const before = range.cloneRange()
before.selectNodeContents(editor)
before.setEnd(range.startContainer, range.startOffset)
return before.toString()
}
function detectTrigger(textBefore: string): TriggerState | null {
const match = TRIGGER_RE.exec(textBefore)
if (!match) {
return null
}
return { kind: match[1] as '@' | '/', query: match[2], tokenLength: 1 + match[2].length }
}
const COMPOSER_FADE_BACKGROUND =
'linear-gradient(to bottom, transparent, color-mix(in srgb, var(--dt-background) 10%, transparent))'
export function ChatBar({
busy,
@@ -186,7 +82,6 @@ export function ChatBar({
const composerRef = useRef<HTMLFormElement | null>(null)
const composerSurfaceRef = useRef<HTMLDivElement | null>(null)
const editorRef = useRef<HTMLDivElement | null>(null)
const glassShellRef = useRef<HTMLDivElement | null>(null)
const draftRef = useRef(draft)
const urlInputRef = useRef<HTMLInputElement | null>(null)
@@ -931,38 +826,9 @@ export function ChatBar({
<SkinSlashPopover draft={draft} onSelect={selectSkinSlashCommand} />
<div
className="pointer-events-none absolute inset-0 rounded-[inherit]"
style={{ background: COMPOSER_GLASS.fadeBackground }}
style={{ background: COMPOSER_FADE_BACKGROUND }}
/>
<div className="relative w-full rounded-[inherit]">
<div
className={cn(
'composer-liquid-shell-wrap absolute -inset-px isolate overflow-hidden rounded-[calc(var(--radius-2xl)+1px)] transition-opacity duration-200 ease-out',
scrolledUp
? 'opacity-30 group-hover/composer:opacity-100 group-focus-within/composer:opacity-100'
: 'opacity-100'
)}
data-glass-frame="true"
data-show-library-rims={COMPOSER_GLASS.showLibraryRims ? 'true' : undefined}
data-slot="composer-liquid-shell-wrap"
ref={glassShellRef}
>
<LiquidGlass
aberrationIntensity={COMPOSER_GLASS.liquid.aberrationIntensity}
blurAmount={COMPOSER_GLASS.liquid.blurAmount}
className="composer-liquid-shell pointer-events-none absolute inset-0 h-full w-full"
cornerRadius={COMPOSER_GLASS.liquid.cornerRadius}
displacementScale={COMPOSER_GLASS.liquid.displacementScale}
elasticity={COMPOSER_GLASS.liquid.elasticity}
key={COMPOSER_GLASS.liquidKey}
mode={COMPOSER_GLASS.liquid.mode}
mouseContainer={composerRef}
padding="0"
saturation={COMPOSER_GLASS.liquid.saturation}
style={{ position: 'absolute', inset: 0, width: '100%', height: '100%' }}
>
<span className="block h-full w-full" />
</LiquidGlass>
</div>
<div
className={cn(
'relative z-4 isolate rounded-[inherit] border border-[color-mix(in_srgb,var(--dt-composer-ring)_calc(18%*var(--composer-ring-strength)),var(--dt-input))] shadow-composer transition-[border-color,box-shadow] duration-200 ease-out',
@@ -983,9 +849,7 @@ export function ChatBar({
'[-webkit-backdrop-filter:blur(0.75rem)_saturate(1.12)]',
'transition-[background-color] duration-150 ease-out',
'group-data-[thread-scrolled-up]/composer:bg-[color-mix(in_srgb,var(--dt-card)_48%,transparent)]',
'group-focus-within/composer:bg-[var(--dt-card)]',
'group-focus-within/composer:[backdrop-filter:none]',
'group-focus-within/composer:[-webkit-backdrop-filter:none]'
'group-focus-within/composer:bg-[color-mix(in_srgb,var(--dt-card)_85%,transparent)]'
)}
/>
{dragActive && (
@@ -1057,9 +921,7 @@ export function ChatBarFallback() {
'[-webkit-backdrop-filter:blur(0.75rem)_saturate(1.12)]',
'transition-[background-color] duration-150 ease-out',
'group-data-[thread-scrolled-up]/composer:bg-[color-mix(in_srgb,var(--dt-card)_48%,transparent)]',
'group-focus-within/composer:bg-[var(--dt-card)]',
'group-focus-within/composer:[backdrop-filter:none]',
'group-focus-within/composer:[-webkit-backdrop-filter:none]'
'group-focus-within/composer:bg-[color-mix(in_srgb,var(--dt-card)_85%,transparent)]'
)}
/>
</div>
@@ -1,82 +0,0 @@
.composer-liquid-shell-wrap > div:not(.composer-liquid-shell) {
position: absolute !important;
inset: 0 !important;
top: 0 !important;
left: 0 !important;
width: 100% !important;
height: 100% !important;
transform: none !important;
margin: 0 !important;
border-radius: inherit !important;
box-sizing: border-box;
}
.composer-liquid-shell-wrap:not([data-show-library-rims='true']) > span {
display: none !important;
}
.composer-liquid-shell-wrap[data-show-library-rims='true'] > span {
position: absolute !important;
inset: 0 !important;
top: 0 !important;
left: 0 !important;
width: 100% !important;
height: 100% !important;
transform: none !important;
margin: 0 !important;
border-radius: inherit !important;
box-sizing: border-box;
display: block !important;
}
.composer-liquid-shell {
z-index: 1;
top: 0 !important;
left: 0 !important;
transform: none !important;
border-radius: inherit !important;
transition: none !important;
}
.composer-liquid-shell > svg {
position: absolute !important;
inset: 0 !important;
top: 0 !important;
left: 0 !important;
width: 100% !important;
height: 100% !important;
transform: none !important;
border-radius: inherit !important;
}
.composer-liquid-shell > .glass,
.composer-liquid-shell > :not(svg):not(.glass) {
position: absolute !important;
inset: 0 !important;
top: 0 !important;
left: 0 !important;
width: 100% !important;
height: 100% !important;
transform: none !important;
border-radius: inherit !important;
}
.composer-liquid-shell > .glass {
width: 100% !important;
height: 100% !important;
padding: 0 !important;
border-radius: inherit !important;
box-shadow: none !important;
}
.composer-liquid-shell > .glass > .glass__warp {
border-radius: inherit !important;
}
.composer-liquid-shell > .glass > div {
width: 100%;
height: 100%;
font: inherit !important;
text-shadow: none !important;
color: inherit !important;
}
@@ -0,0 +1,91 @@
import { DATA_IMAGE_URL_RE, dataUrlToBlob } from '@/lib/embedded-images'
export interface TriggerState {
kind: '@' | '/'
query: string
tokenLength: number
}
const TRIGGER_RE = /(?:^|[\s])([@/])([^\s@/]*)$/
export function extractClipboardImageBlobs(clipboard: DataTransfer): Blob[] {
const blobs: Blob[] = []
const seen = new Set<Blob>()
const push = (blob: Blob | null) => {
if (!blob || blob.size === 0 || seen.has(blob)) {
return
}
seen.add(blob)
blobs.push(blob)
}
if (clipboard.items?.length) {
for (const item of clipboard.items) {
if (item.kind === 'file' && item.type.startsWith('image/')) {
push(item.getAsFile())
}
}
}
if (clipboard.files?.length) {
for (let i = 0; i < clipboard.files.length; i += 1) {
const file = clipboard.files.item(i)
if (file && file.type.startsWith('image/')) {
push(file)
}
}
}
if (blobs.length > 0) {
return blobs
}
const text = clipboard.getData('text/plain').trim()
if (DATA_IMAGE_URL_RE.test(text)) {
push(dataUrlToBlob(text))
}
if (blobs.length === 0) {
const html = clipboard.getData('text/html')
if (html) {
const matches = html.matchAll(/<img\b[^>]*?\bsrc\s*=\s*["'](data:image\/[^"']+)["']/gi)
for (const match of matches) {
push(dataUrlToBlob(match[1]))
}
}
}
return blobs
}
/** Caret-anchored text before the cursor, or null if the selection isn't a collapsed caret inside `editor`. */
export function textBeforeCaret(editor: HTMLDivElement): string | null {
const sel = window.getSelection()
const range = sel?.rangeCount ? sel.getRangeAt(0) : null
if (!range?.collapsed || !editor.contains(range.commonAncestorContainer)) {
return null
}
const before = range.cloneRange()
before.selectNodeContents(editor)
before.setEnd(range.startContainer, range.startOffset)
return before.toString()
}
export function detectTrigger(textBefore: string): TriggerState | null {
const match = TRIGGER_RE.exec(textBefore)
if (!match) {
return null
}
return { kind: match[1] as '@' | '/', query: match[2], tokenLength: 1 + match[2].length }
}
+59 -32
View File
@@ -2,8 +2,7 @@ import {
type AppendMessage,
AssistantRuntimeProvider,
ExportedMessageRepository,
type ThreadMessage,
useExternalStoreRuntime
type ThreadMessage
} from '@assistant-ui/react'
import { useStore } from '@nanostores/react'
import { useQuery } from '@tanstack/react-query'
@@ -18,6 +17,7 @@ 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 { useIncrementalExternalStoreRuntime } from '@/lib/incremental-external-store-runtime'
import { cn } from '@/lib/utils'
import { $pinnedSessionIds } from '@/store/layout'
import {
@@ -70,6 +70,55 @@ interface ChatViewProps extends Omit<React.ComponentProps<'div'>, 'onSubmit'> {
onTranscribeAudio?: (audio: Blob) => Promise<string>
}
interface ChatHeaderProps {
activeSessionId: null | string
isRoutedSessionView: boolean
onDeleteSelectedSession: () => void
onToggleSelectedPin: () => void
selectedSessionId: null | string
}
function ChatHeader({
activeSessionId,
isRoutedSessionView,
onDeleteSelectedSession,
onToggleSelectedPin,
selectedSessionId
}: ChatHeaderProps) {
const sessions = useStore($sessions)
const pinnedSessionIds = useStore($pinnedSessionIds)
const activeStoredSession = sessions.find(session => session.id === selectedSessionId) || null
const title = activeStoredSession ? sessionTitle(activeStoredSession) : ''
const selectedIsPinned = selectedSessionId ? pinnedSessionIds.includes(selectedSessionId) : false
return (
<header className={cn(titlebarHeaderBaseClass, isRoutedSessionView && titlebarHeaderShadowClass)}>
<div className="min-w-0 flex-1">
{title && (
<SessionActionsMenu
align="start"
onDelete={selectedSessionId ? onDeleteSelectedSession : undefined}
onPin={selectedSessionId ? onToggleSelectedPin : undefined}
pinned={selectedIsPinned}
sessionId={selectedSessionId || activeSessionId || ''}
sideOffset={8}
title={title}
>
<Button
className="pointer-events-auto h-7 min-w-0 gap-1.5 rounded-lg px-1 py-0 text-foreground hover:bg-accent/70 data-[state=open]:bg-accent/70 [-webkit-app-region:no-drag]"
type="button"
variant="ghost"
>
<h2 className="max-w-[62vw] truncate text-base font-semibold leading-none tracking-tight">{title}</h2>
<ChevronDown className="shrink-0 text-foreground/75" size={16} />
</Button>
</SessionActionsMenu>
)}
</div>
</header>
)
}
export function ChatView({
className,
gateway,
@@ -107,13 +156,9 @@ export function ChatView({
const introPersonality = useStore($introPersonality)
const introSeed = useStore($introSeed)
const messages = useStore($messages)
const pinnedSessionIds = useStore($pinnedSessionIds)
const selectedSessionId = useStore($selectedStoredSessionId)
const sessions = useStore($sessions)
const runtimeMessageCacheRef = useRef(new WeakMap<ChatMessage, ThreadMessage>())
const activeStoredSession = sessions.find(session => session.id === selectedSessionId) || null
const isRoutedSessionView = Boolean(routeSessionId(location.pathname))
const selectedIsPinned = selectedSessionId ? pinnedSessionIds.includes(selectedSessionId) : false
const showIntro =
freshDraftReady && !isRoutedSessionView && !selectedSessionId && !activeSessionId && messages.length === 0
@@ -127,7 +172,6 @@ export function ChatView({
const threadLoading = threadLoadingState(loadingSession, busy, awaitingResponse, lastVisibleMessageIsUser(messages))
const showChatBar = !loadingSession
const threadKey = selectedSessionId || activeSessionId || (isRoutedSessionView ? location.pathname : 'new')
const title = activeStoredSession ? sessionTitle(activeStoredSession) : ''
const modelOptionsQuery = useQuery<ModelOptionsResponse>({
queryKey: ['model-options', activeSessionId || 'global'],
@@ -207,7 +251,7 @@ export function ChatView({
return ExportedMessageRepository.fromBranchableArray(items, { headId })
}, [messages])
const runtime = useExternalStoreRuntime<ThreadMessage>({
const runtime = useIncrementalExternalStoreRuntime<ThreadMessage>({
messageRepository: runtimeMessageRepository,
isRunning: busy,
setMessages: onThreadMessagesChange,
@@ -227,30 +271,13 @@ export function ChatView({
className
)}
>
<header className={cn(titlebarHeaderBaseClass, isRoutedSessionView && titlebarHeaderShadowClass)}>
<div className="min-w-0 flex-1">
{title && (
<SessionActionsMenu
align="start"
onDelete={selectedSessionId ? onDeleteSelectedSession : undefined}
onPin={selectedSessionId ? onToggleSelectedPin : undefined}
pinned={selectedIsPinned}
sessionId={selectedSessionId || activeSessionId || ''}
sideOffset={8}
title={title}
>
<Button
className="pointer-events-auto h-7 min-w-0 gap-1.5 rounded-lg px-1 py-0 text-foreground hover:bg-accent/70 data-[state=open]:bg-accent/70 [-webkit-app-region:no-drag]"
type="button"
variant="ghost"
>
<h2 className="max-w-[62vw] truncate text-base font-semibold leading-none tracking-tight">{title}</h2>
<ChevronDown className="shrink-0 text-foreground/75" size={16} />
</Button>
</SessionActionsMenu>
)}
</div>
</header>
<ChatHeader
activeSessionId={activeSessionId}
isRoutedSessionView={isRoutedSessionView}
onDeleteSelectedSession={onDeleteSelectedSession}
onToggleSelectedPin={onToggleSelectedPin}
selectedSessionId={selectedSessionId}
/>
<NotificationStack />
@@ -0,0 +1,288 @@
import { useStore } from '@nanostores/react'
import type { CSSProperties, MutableRefObject, PointerEvent as ReactPointerEvent, RefObject } from 'react'
import { useEffect, useMemo, useRef } from 'react'
import { CopyButton } from '@/components/ui/copy-button'
import { PanelBottom, Send, Trash2 } from '@/lib/icons'
import { cn } from '@/lib/utils'
import { $composerDraft, setComposerDraft } from '@/store/composer'
import { notify } from '@/store/notifications'
import type { ConsoleEntry, PreviewConsoleState } from './preview-console-state'
const consoleLevelLabel: Record<number, string> = {
0: 'log',
1: 'info',
2: 'warn',
3: 'error'
}
const consoleLevelClass: Record<number, string> = {
0: 'text-foreground',
1: 'text-sky-700 dark:text-sky-300',
2: 'text-amber-700 dark:text-amber-300',
3: 'text-destructive'
}
const CONSOLE_BOTTOM_THRESHOLD = 24
const CONSOLE_HEADER_HEIGHT = 32
export function compactUrl(value: string): string {
try {
const url = new URL(value)
if (url.protocol === 'file:') {
return decodeURIComponent(url.pathname)
}
return `${url.host}${url.pathname}${url.search}`
} catch {
return value
}
}
export function formatLogLine(log: ConsoleEntry): string {
const head = `[${consoleLevelLabel[log.level] || 'log'}]`
const tail = log.source ? ` (${compactUrl(log.source)}${log.line ? `:${log.line}` : ''})` : ''
return `${head} ${log.message}${tail}`.trim()
}
export function formatConsoleEntries(entries: ConsoleEntry[]): string {
return entries.map(formatLogLine).join('\n')
}
export function isNearConsoleBottom(element: HTMLDivElement | null): boolean {
if (!element) {
return true
}
return element.scrollHeight - element.scrollTop - element.clientHeight <= CONSOLE_BOTTOM_THRESHOLD
}
export function clampConsoleHeight(value: number): number {
return Math.max(value, CONSOLE_HEADER_HEIGHT)
}
interface ConsoleRowProps {
copyText: string
log: ConsoleEntry
onSend: () => void
onToggleSelect: () => void
selected: boolean
}
function ConsoleRow({ copyText, log, onSend, onToggleSelect, selected }: ConsoleRowProps) {
return (
<div
className={cn(
'group/row grid grid-cols-[3.25rem_minmax(0,1fr)_auto] items-start gap-2 rounded-md border border-transparent px-1 py-1 transition-colors hover:bg-accent/40',
selected && 'border-border/60 bg-accent/40'
)}
>
<button
className={cn(
'mt-0.5 cursor-pointer text-left uppercase opacity-70 transition-colors hover:opacity-100',
consoleLevelClass[log.level] ?? consoleLevelClass[0]
)}
onClick={onToggleSelect}
title={selected ? 'Deselect entry' : 'Select entry'}
type="button"
>
{consoleLevelLabel[log.level] || 'log'}
</button>
<div className="min-w-0" data-selectable-text="true">
<span className={cn('block wrap-break-word', consoleLevelClass[log.level] ?? consoleLevelClass[0])}>
{log.message}
</span>
{log.source && (
<span className="block truncate text-muted-foreground/60">
{compactUrl(log.source)}
{log.line ? `:${log.line}` : ''}
</span>
)}
</div>
<span className="opacity-0 transition-opacity group-hover/row:opacity-100">
<CopyButton
appearance="inline"
className="rounded-md p-1 text-muted-foreground transition-colors hover:bg-accent hover:text-foreground"
errorMessage="Could not copy console output"
iconClassName="size-3"
label="Copy this entry"
showLabel={false}
text={copyText}
/>
<button
className="rounded-md p-1 text-muted-foreground transition-colors hover:bg-accent hover:text-foreground"
onClick={onSend}
title="Send this entry to chat"
type="button"
>
<Send className="size-3" />
</button>
</span>
</div>
)
}
export function PreviewConsoleTitlebarIcon({ consoleState }: { consoleState: PreviewConsoleState }) {
const logCount = useStore(consoleState.$logCount)
return (
<>
<PanelBottom />
{logCount > 0 && <span className="sr-only">{logCount} console messages</span>}
</>
)
}
interface PreviewConsolePanelProps {
consoleBodyRef: RefObject<HTMLDivElement | null>
consoleShouldStickRef: MutableRefObject<boolean>
consoleState: PreviewConsoleState
startConsoleResize: (event: ReactPointerEvent<HTMLDivElement>) => void
}
export function PreviewConsolePanel({
consoleBodyRef,
consoleShouldStickRef,
consoleState,
startConsoleResize
}: PreviewConsolePanelProps) {
const consoleHeight = useStore(consoleState.$height)
const logs = useStore(consoleState.$logs)
const selectedLogIds = useStore(consoleState.$selectedLogIds)
const visibleSelection = useMemo(() => logs.filter(log => selectedLogIds.has(log.id)), [logs, selectedLogIds])
const sendableLogs = visibleSelection.length > 0 ? visibleSelection : logs
const stickScrollRafRef = useRef<number | null>(null)
useEffect(() => {
if (!consoleShouldStickRef.current) {
return
}
if (stickScrollRafRef.current !== null) {
window.cancelAnimationFrame(stickScrollRafRef.current)
stickScrollRafRef.current = null
}
stickScrollRafRef.current = window.requestAnimationFrame(() => {
stickScrollRafRef.current = null
const consoleBody = consoleBodyRef.current
consoleBody?.scrollTo({ top: consoleBody.scrollHeight })
})
return () => {
if (stickScrollRafRef.current !== null) {
window.cancelAnimationFrame(stickScrollRafRef.current)
stickScrollRafRef.current = null
}
}
}, [consoleBodyRef, consoleHeight, consoleShouldStickRef, logs])
function sendLogsToComposer(entries: ConsoleEntry[]) {
if (!entries.length) {
return
}
const block = ['Preview console:', '```', ...entries.map(formatLogLine), '```'].join('\n')
const draft = $composerDraft.get()
const next = draft && !draft.endsWith('\n') ? `${draft}\n\n${block}` : `${draft}${block}`
setComposerDraft(next)
consoleState.clearSelection()
notify({
kind: 'success',
title: 'Sent to chat',
message: `${entries.length} log entr${entries.length === 1 ? 'y' : 'ies'} added to composer`
})
}
return (
<div
className="pointer-events-auto absolute inset-x-0 bottom-0 z-20 flex h-(--preview-console-height) min-h-8 flex-col overflow-hidden border-t border-border/60 bg-background"
style={{ '--preview-console-height': `${consoleHeight}px` } as CSSProperties}
>
<div
aria-label="Resize preview console"
className="group absolute inset-x-0 -top-1 z-1 h-2 cursor-row-resize"
onDoubleClick={() => consoleState.setHeight(CONSOLE_HEADER_HEIGHT)}
onPointerDown={startConsoleResize}
role="separator"
>
<span className="absolute left-1/2 top-1/2 h-0.75 w-23 -translate-x-1/2 -translate-y-1/2 rounded-full bg-muted-foreground/80 opacity-0 transition-opacity duration-100 group-hover:opacity-[0.5]" />
</div>
<div className="flex h-8 shrink-0 items-center justify-between border-b border-border/50 px-2">
<div className="flex items-center gap-2 text-[0.6875rem] font-medium text-muted-foreground">
<PanelBottom className="size-3.5" />
Preview Console
{selectedLogIds.size > 0 && (
<span className="rounded-full bg-muted px-1.5 py-px text-[0.5625rem] text-muted-foreground">
{selectedLogIds.size} selected
</span>
)}
</div>
<div className="flex items-center gap-1">
<button
className="inline-flex items-center gap-1 rounded-md px-1.5 py-0.5 text-[0.625rem] text-muted-foreground transition-colors hover:bg-accent hover:text-foreground disabled:opacity-40"
disabled={sendableLogs.length === 0}
onClick={() => sendLogsToComposer(sendableLogs)}
title={
visibleSelection.length > 0
? `Send ${visibleSelection.length} selected to chat`
: 'Send all log entries to chat'
}
type="button"
>
<Send className="size-3" />
Send to chat
</button>
<CopyButton
appearance="inline"
className="inline-flex items-center gap-1 rounded-md px-1.5 py-0.5 text-[0.625rem] text-muted-foreground transition-colors hover:bg-accent hover:text-foreground disabled:opacity-40"
disabled={sendableLogs.length === 0}
errorMessage="Could not copy console output"
iconClassName="size-3"
label={visibleSelection.length > 0 ? 'Copy selected to clipboard' : 'Copy all to clipboard'}
text={() => formatConsoleEntries(sendableLogs)}
>
Copy
</CopyButton>
<button
className="inline-flex items-center gap-1 rounded-md px-1.5 py-0.5 text-[0.625rem] text-muted-foreground transition-colors hover:bg-accent hover:text-foreground disabled:opacity-40"
disabled={logs.length === 0}
onClick={consoleState.clear}
title="Clear console"
type="button"
>
<Trash2 className="size-3" />
Clear
</button>
</div>
</div>
<div
className="min-h-0 flex-1 overflow-y-auto px-2 py-1.5 font-mono text-[0.6875rem] leading-relaxed"
ref={consoleBodyRef}
>
{logs.length > 0 ? (
logs.map(log => {
const selected = selectedLogIds.has(log.id)
return (
<ConsoleRow
copyText={formatLogLine(log)}
key={log.id}
log={log}
onSend={() => sendLogsToComposer([log])}
onToggleSelect={() => consoleState.toggleSelection(log.id)}
selected={selected}
/>
)
})
) : (
<div className="py-2 text-muted-foreground/70">No console messages yet.</div>
)}
</div>
</div>
)
}
@@ -0,0 +1,553 @@
import type * as React from 'react'
import type {
ComponentProps,
CSSProperties,
DragEvent as ReactDragEvent,
MouseEvent as ReactMouseEvent,
ReactNode
} from 'react'
import { useEffect, useMemo, useState } from 'react'
import ShikiHighlighter from 'react-shiki'
import { Streamdown } from 'streamdown'
import { HERMES_PATHS_MIME } from '@/app/chat/hooks/use-composer-actions'
import { cn } from '@/lib/utils'
import type { PreviewTarget } from '@/store/preview'
const SHIKI_THEME = { dark: 'github-dark-default', light: 'github-light-default' } as const
const TEXT_PREVIEW_MAX_BYTES = 512 * 1024
type EmptyStateTone = 'neutral' | 'warning'
const TONE_STYLES: Record<EmptyStateTone, { cube: string; primary: string }> = {
neutral: {
cube: 'text-muted-foreground/35',
primary: 'border-border bg-background text-foreground hover:bg-accent'
},
warning: {
cube: 'text-amber-500/70 dark:text-amber-300/70',
primary:
'border-amber-400/40 bg-amber-50 text-amber-900 hover:bg-amber-100 dark:border-amber-300/30 dark:bg-amber-300/15 dark:text-amber-100 dark:hover:bg-amber-300/20'
}
}
function PreviewCubeIcon({ className }: { className?: string }) {
return (
<svg aria-hidden="true" className={cn('size-16', className)} viewBox="0 0 64 64">
<path
d="M32 5 56 18.5v27L32 59 8 45.5v-27L32 5Z"
fill="none"
stroke="currentColor"
strokeLinejoin="round"
strokeWidth="1.25"
/>
<path
d="M8 18.5 32 32l24-13.5M32 32v27"
fill="none"
stroke="currentColor"
strokeLinejoin="round"
strokeWidth="1.25"
/>
<path d="M20 11.75 44 25.25" fill="none" opacity="0.45" stroke="currentColor" strokeWidth="0.9" />
</svg>
)
}
interface PreviewEmptyStateProps {
body?: ReactNode
consoleHeight?: number
primaryAction?: { disabled?: boolean; label: string; onClick: () => void }
secondaryAction?: { disabled?: boolean; label: string; onClick: () => void }
title: string
tone?: EmptyStateTone
}
export function PreviewEmptyState({
body,
consoleHeight = 0,
primaryAction,
secondaryAction,
title,
tone = 'neutral'
}: PreviewEmptyStateProps) {
const styles = TONE_STYLES[tone]
return (
<div
className="absolute inset-x-0 top-0 z-10 grid place-items-center bg-background px-8 py-10 text-center bottom-(--preview-error-bottom)"
style={{ '--preview-error-bottom': `${consoleHeight}px` } as CSSProperties}
>
<div className="grid max-w-sm justify-items-center gap-5">
<PreviewCubeIcon className={styles.cube} />
<div className="grid gap-2">
<div className="text-sm font-medium text-foreground">{title}</div>
{body && <div className="text-xs leading-relaxed text-muted-foreground">{body}</div>}
</div>
{(primaryAction || secondaryAction) && (
<div className="grid justify-items-center gap-2">
{primaryAction && (
<button
className={cn(
'rounded-full border px-3.5 py-1.5 text-xs font-medium shadow-xs transition-colors disabled:cursor-default disabled:opacity-60',
styles.primary
)}
disabled={primaryAction.disabled}
onClick={primaryAction.onClick}
type="button"
>
{primaryAction.label}
</button>
)}
{secondaryAction && (
<button
className="text-[0.6875rem] font-medium text-muted-foreground underline decoration-muted-foreground/25 underline-offset-4 transition-colors hover:text-foreground hover:decoration-foreground/55 disabled:cursor-default disabled:text-muted-foreground/55 disabled:no-underline"
disabled={secondaryAction.disabled}
onClick={secondaryAction.onClick}
type="button"
>
{secondaryAction.label}
</button>
)}
</div>
)}
</div>
</div>
)
}
interface LocalPreviewState {
binary?: boolean
byteSize?: number
dataUrl?: string
error?: string
language?: string
loading: boolean
text?: string
truncated?: boolean
}
function filePathForTarget(target: PreviewTarget) {
if (target.path) {
return target.path
}
try {
const url = new URL(target.url)
return url.protocol === 'file:' ? decodeURIComponent(url.pathname) : target.url
} catch {
return target.url
}
}
function formatBytes(bytes: number | undefined) {
if (!bytes) {
return 'unknown size'
}
const units = ['B', 'KB', 'MB', 'GB']
let value = bytes
let unit = 0
while (value >= 1024 && unit < units.length - 1) {
value /= 1024
unit += 1
}
return `${value >= 10 || unit === 0 ? value.toFixed(0) : value.toFixed(1)} ${units[unit]}`
}
function looksBinaryBytes(bytes: Uint8Array) {
if (!bytes.length) {
return false
}
let suspicious = 0
for (const byte of bytes.slice(0, 4096)) {
if (byte === 0) {
return true
}
if (byte < 32 && byte !== 9 && byte !== 10 && byte !== 13) {
suspicious += 1
}
}
return suspicious / Math.min(bytes.length, 4096) > 0.12
}
async function readTextPreview(filePath: string) {
if (window.hermesDesktop.readFileText) {
try {
return await window.hermesDesktop.readFileText(filePath)
} catch (error) {
const message = error instanceof Error ? error.message : String(error)
if (!message.includes("No handler registered for 'hermes:readFileText'")) {
throw error
}
}
}
// Back-compat for a running Electron process whose preload hasn't been
// restarted since readFileText was added. readFileDataUrl already existed.
const dataUrl = await window.hermesDesktop.readFileDataUrl(filePath)
const [, metadata = '', data = ''] = dataUrl.match(/^data:([^,]*),(.*)$/) || []
const base64 = metadata.includes(';base64')
const mimeType = metadata.replace(/;base64$/, '') || undefined
const raw = base64 ? atob(data) : decodeURIComponent(data)
const bytes = Uint8Array.from(raw, ch => ch.charCodeAt(0))
return {
binary: looksBinaryBytes(bytes),
byteSize: bytes.byteLength,
mimeType,
path: filePath,
text: new TextDecoder().decode(bytes)
}
}
// Lightweight markdown renderer for file previews. Streamdown does the parse;
// our components keep typography simple and route fenced code through Shiki
// without the library's copy/download/fullscreen chrome.
const MD_TAG_CLASSES = {
h1: 'mb-3 mt-6 text-3xl font-bold leading-tight tracking-tight first:mt-0',
h2: 'mb-2.5 mt-5 text-2xl font-semibold leading-snug tracking-tight first:mt-0',
h3: 'mb-2 mt-4 text-xl font-semibold leading-snug first:mt-0',
h4: 'mb-2 mt-3 text-base font-semibold leading-snug first:mt-0',
p: 'mb-4 leading-relaxed text-foreground last:mb-0',
ul: 'mb-4 list-disc pl-6 marker:text-muted-foreground/70 last:mb-0',
ol: 'mb-4 list-decimal pl-6 marker:text-muted-foreground/70 last:mb-0',
li: 'mt-1 leading-relaxed',
blockquote: 'mb-4 border-l-2 border-border pl-3 text-muted-foreground italic last:mb-0',
pre: 'mb-4 overflow-hidden rounded-lg border border-border bg-card font-mono text-xs leading-relaxed last:mb-0 [&_pre]:m-0 [&_pre]:overflow-x-auto [&_pre]:bg-transparent! [&_pre]:p-3 [&_pre]:font-mono'
} as const
function tagged<T extends keyof typeof MD_TAG_CLASSES>(Tag: T) {
const base = MD_TAG_CLASSES[Tag]
const Component = (({ className, ...rest }: ComponentProps<T>) => {
const Element = Tag as React.ElementType
return <Element className={cn(base, className)} {...rest} />
}) as React.FC<ComponentProps<T>>
Component.displayName = `Md.${Tag}`
return Component
}
function MarkdownCode({ className, children, ...props }: ComponentProps<'code'>) {
const language = /language-([^\s]+)/.exec(className || '')?.[1]
if (!language) {
return (
<code
className={cn(
'rounded bg-muted px-1 py-0.5 font-mono text-[0.86em] text-pink-700 dark:text-pink-300',
className
)}
{...props}
>
{children}
</code>
)
}
return (
<ShikiHighlighter
addDefaultStyles={false}
as="div"
defaultColor="light-dark()"
delay={80}
language={language}
showLanguage={false}
theme={SHIKI_THEME}
>
{String(children).replace(/\n$/, '')}
</ShikiHighlighter>
)
}
const MARKDOWN_COMPONENTS = {
h1: tagged('h1'),
h2: tagged('h2'),
h3: tagged('h3'),
h4: tagged('h4'),
p: tagged('p'),
ul: tagged('ul'),
ol: tagged('ol'),
li: tagged('li'),
blockquote: tagged('blockquote'),
pre: tagged('pre'),
code: MarkdownCode
}
function MarkdownPreview({ text }: { text: string }) {
return (
<div className="preview-markdown mx-auto max-w-3xl px-4 py-3 text-sm text-foreground">
<Streamdown components={MARKDOWN_COMPONENTS} controls={false} mode="static" parseIncompleteMarkdown={false}>
{text}
</Streamdown>
</div>
)
}
function PreviewToggle({ asSource, onToggle }: { asSource: boolean; onToggle: () => void }) {
return (
<div className="sticky top-0 z-10 flex justify-end border-b border-border/40 bg-background/90 px-3 py-1 backdrop-blur">
<button
className="text-[0.625rem] font-bold text-muted-foreground underline decoration-muted-foreground/25 underline-offset-4 transition-colors hover:text-foreground hover:decoration-foreground/55"
onClick={onToggle}
type="button"
>
{asSource ? 'PREVIEW' : 'SOURCE'}
</button>
</div>
)
}
// Gutter and Shiki output share `font-mono text-xs leading-relaxed py-3` so
// each line aligns vertically. The selection overlay relies on the same
// `text-xs * leading-relaxed = 1.21875rem` line-height to position itself.
const SOURCE_LINE_HEIGHT_REM = 1.21875
const SOURCE_PAD_Y_REM = 0.75
interface LineSelection {
end: number
start: number
}
function startLineDrag(event: ReactDragEvent<HTMLElement>, filePath: string, { end, start }: LineSelection) {
const lineEnd = end > start ? end : undefined
const label = lineEnd ? `${filePath}:${start}-${end}` : `${filePath}:${start}`
event.dataTransfer.setData(HERMES_PATHS_MIME, JSON.stringify([{ line: start, lineEnd, path: filePath }]))
event.dataTransfer.setData('text/plain', label)
event.dataTransfer.effectAllowed = 'copy'
}
function SourceView({ filePath, language, text }: { filePath: string; language: string; text: string }) {
const lineCount = useMemo(() => Math.max(1, text.split('\n').length), [text])
const [selection, setSelection] = useState<LineSelection | null>(null)
const inSelection = (line: number) => selection != null && line >= selection.start && line <= selection.end
const handleLineClick = (event: ReactMouseEvent, line: number) => {
if (event.shiftKey && selection) {
setSelection({ end: Math.max(selection.end, line), start: Math.min(selection.start, line) })
return
}
if (selection?.start === line && selection.end === line) {
setSelection(null)
return
}
setSelection({ end: line, start: line })
}
const handleDragStart = (event: ReactDragEvent<HTMLElement>, line: number) => {
startLineDrag(event, filePath, inSelection(line) && selection ? selection : { end: line, start: line })
}
return (
<div className="grid min-w-max grid-cols-[auto_minmax(0,1fr)] font-mono text-xs leading-relaxed">
<div className="select-none py-3 text-right text-muted-foreground/55">
{Array.from({ length: lineCount }, (_, index) => {
const line = index + 1
const selected = inSelection(line)
return (
<div
className={cn(
'cursor-pointer px-3 tabular-nums transition-colors',
selected
? 'bg-amber-200/45 text-amber-900 dark:bg-amber-300/20 dark:text-amber-100'
: 'hover:text-foreground'
)}
draggable
key={line}
onClick={event => handleLineClick(event, line)}
onDragStart={event => handleDragStart(event, line)}
title="Click to select · shift-click to extend · drag to composer"
>
{line}
</div>
)
})}
</div>
<div className="relative [&_pre]:m-0 [&_pre]:px-3 [&_pre]:py-3">
{selection && (
<div
aria-hidden
className="pointer-events-none absolute inset-x-0 bg-amber-200/35 dark:bg-amber-300/10"
style={{
top: `calc(${SOURCE_PAD_Y_REM}rem + ${selection.start - 1} * ${SOURCE_LINE_HEIGHT_REM}rem)`,
height: `calc(${selection.end - selection.start + 1} * ${SOURCE_LINE_HEIGHT_REM}rem)`
}}
/>
)}
<ShikiHighlighter
addDefaultStyles={false}
as="div"
defaultColor="light-dark()"
delay={80}
language={language || 'text'}
showLanguage={false}
theme={SHIKI_THEME}
>
{text}
</ShikiHighlighter>
</div>
</div>
)
}
export function LocalFilePreview({ reloadKey, target }: { reloadKey: number; target: PreviewTarget }) {
const [state, setState] = useState<LocalPreviewState>({ loading: true })
const [forcePreview, setForcePreview] = useState(false)
const [renderMarkdownAsSource, setRenderMarkdownAsSource] = useState(false)
const filePath = filePathForTarget(target)
const isImage = target.previewKind === 'image'
// HTML files are rendered as source code, not in a webview - so they take
// the same path as plain text files. `previewKind === 'binary'` arrives
// when the file is forcibly previewed past the binary refusal screen.
const isText = target.previewKind === 'text' || target.previewKind === 'binary' || target.previewKind === 'html'
const blockedByTarget = !isImage && !forcePreview && (target.binary || target.large)
useEffect(() => {
let active = true
async function load() {
if (blockedByTarget) {
setState({ loading: false })
return
}
if (!isImage && !isText) {
setState({ loading: false })
return
}
setState({ loading: true })
try {
if (isImage) {
const dataUrl = await window.hermesDesktop.readFileDataUrl(filePath)
if (active) {
setState({ dataUrl, loading: false })
}
return
}
const result = await readTextPreview(filePath)
if (active) {
const shouldBlock = !forcePreview && (result.binary || (result.byteSize ?? 0) > TEXT_PREVIEW_MAX_BYTES)
setState({
binary: result.binary,
byteSize: result.byteSize,
language: result.language || target.language || 'text',
loading: false,
text: shouldBlock ? undefined : result.text,
truncated: result.truncated
})
}
} catch (error) {
if (active) {
setState({
error: error instanceof Error ? error.message : String(error),
loading: false
})
}
}
}
void load()
return () => {
active = false
}
}, [blockedByTarget, filePath, forcePreview, isImage, isText, reloadKey, target.language])
if (state.loading) {
return <div className="grid h-full place-items-center text-xs text-muted-foreground">Loading preview</div>
}
if (state.error) {
return <PreviewEmptyState body={state.error} title="Preview unavailable" />
}
if (
!isImage &&
!forcePreview &&
(target.binary || target.large || state.binary || (state.byteSize ?? 0) > TEXT_PREVIEW_MAX_BYTES)
) {
const binary = target.binary || state.binary
const size = target.byteSize || state.byteSize
return (
<PreviewEmptyState
body={
binary
? `Previewing ${target.label} may show unreadable text.`
: `${target.label} is ${formatBytes(size)}. Hermes will only show the first 512 KB.`
}
primaryAction={{ label: 'Preview anyway', onClick: () => setForcePreview(true) }}
title={binary ? 'This looks like a binary file' : 'This file is large'}
tone="warning"
/>
)
}
if (isImage && state.dataUrl) {
return (
<div className="flex h-full w-full items-center justify-center overflow-auto bg-[color-mix(in_srgb,var(--dt-card)_42%,transparent)] p-4">
<img
alt={target.label}
className="max-h-full max-w-full rounded-lg object-contain shadow-sm"
draggable={false}
src={state.dataUrl}
/>
</div>
)
}
if (isText && state.text !== undefined) {
const isMarkdown = (state.language || target.language) === 'markdown'
const showRendered = isMarkdown && !renderMarkdownAsSource
return (
<div className="h-full overflow-auto bg-background">
{state.truncated && (
<div className="border-b border-border/60 bg-muted/35 px-3 py-1.5 text-[0.68rem] text-muted-foreground">
Showing first 512 KB.
</div>
)}
{isMarkdown && <PreviewToggle asSource={!showRendered} onToggle={() => setRenderMarkdownAsSource(s => !s)} />}
{showRendered ? (
<MarkdownPreview text={state.text} />
) : (
<SourceView filePath={filePath} language={state.language || 'text'} text={state.text} />
)}
</div>
)
}
return (
<PreviewEmptyState
body={`${target.mimeType || 'This file type'} can still be attached as context.`}
title="No inline preview"
/>
)
}
@@ -1,30 +1,23 @@
import { useStore } from '@nanostores/react'
import type {
ComponentProps,
CSSProperties,
MutableRefObject,
DragEvent as ReactDragEvent,
MouseEvent as ReactMouseEvent,
ReactNode,
PointerEvent as ReactPointerEvent,
RefObject
} from 'react'
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import ShikiHighlighter from 'react-shiki'
import { Streamdown } from 'streamdown'
import type { PointerEvent as ReactPointerEvent } from 'react'
import { useCallback, useEffect, useRef, useState } from 'react'
import { HERMES_PATHS_MIME } from '@/app/chat/hooks/use-composer-actions'
import type { SetTitlebarToolGroup, TitlebarTool } from '@/app/shell/titlebar-controls'
import { CopyButton } from '@/components/ui/copy-button'
import { Bug, PanelBottom, RefreshCw, Send, Trash2, X } from '@/lib/icons'
import { Bug, RefreshCw, X } from '@/lib/icons'
import { cn } from '@/lib/utils'
import { $composerDraft, setComposerDraft } from '@/store/composer'
import { notify, notifyError } from '@/store/notifications'
import { $previewServerRestart, failPreviewServerRestart, type PreviewTarget } from '@/store/preview'
import { type ConsoleEntry, createPreviewConsoleState, type PreviewConsoleState } from './preview-console-state'
const SHIKI_THEME = { dark: 'github-dark-default', light: 'github-light-default' } as const
import {
clampConsoleHeight,
compactUrl,
formatLogLine,
isNearConsoleBottom,
PreviewConsolePanel,
PreviewConsoleTitlebarIcon
} from './preview-console'
import { type ConsoleEntry, createPreviewConsoleState } from './preview-console-state'
import { LocalFilePreview, PreviewEmptyState } from './preview-file'
type PreviewWebview = HTMLElement & {
closeDevTools?: () => void
@@ -50,62 +43,8 @@ interface PreviewLoadErrorState {
url: string
}
const consoleLevelLabel: Record<number, string> = {
0: 'log',
1: 'info',
2: 'warn',
3: 'error'
}
const consoleLevelClass: Record<number, string> = {
0: 'text-foreground',
1: 'text-sky-700 dark:text-sky-300',
2: 'text-amber-700 dark:text-amber-300',
3: 'text-destructive'
}
const CONSOLE_BOTTOM_THRESHOLD = 24
const CONSOLE_HEADER_HEIGHT = 32
const FILE_RELOAD_DEBOUNCE_MS = 200
const SERVER_RESTART_TIMEOUT_MS = 45_000
const TEXT_PREVIEW_MAX_BYTES = 512 * 1024
function compactUrl(value: string): string {
try {
const url = new URL(value)
if (url.protocol === 'file:') {
return decodeURIComponent(url.pathname)
}
return `${url.host}${url.pathname}${url.search}`
} catch {
return value
}
}
function formatLogLine(log: ConsoleEntry): string {
const head = `[${consoleLevelLabel[log.level] || 'log'}]`
const tail = log.source ? ` (${compactUrl(log.source)}${log.line ? `:${log.line}` : ''})` : ''
return `${head} ${log.message}${tail}`.trim()
}
function formatConsoleEntries(entries: ConsoleEntry[]): string {
return entries.map(formatLogLine).join('\n')
}
function isNearConsoleBottom(element: HTMLDivElement | null): boolean {
if (!element) {
return true
}
return element.scrollHeight - element.scrollTop - element.clientHeight <= CONSOLE_BOTTOM_THRESHOLD
}
function clampConsoleHeight(value: number): number {
return Math.max(value, CONSOLE_HEADER_HEIGHT)
}
function loadErrorTitle(error: PreviewLoadErrorState): string {
const description = error.description.toLowerCase()
@@ -127,176 +66,6 @@ function isModuleMimeError(message: string): boolean {
return lower.includes('failed to load module script') && lower.includes('mime type')
}
interface ConsoleRowProps {
copyText: string
log: ConsoleEntry
onSend: () => void
onToggleSelect: () => void
selected: boolean
}
function ConsoleRow({ copyText, log, onSend, onToggleSelect, selected }: ConsoleRowProps) {
return (
<div
className={cn(
'group/row grid grid-cols-[3.25rem_minmax(0,1fr)_auto] items-start gap-2 rounded-md border border-transparent px-1 py-1 transition-colors hover:bg-accent/40',
selected && 'border-border/60 bg-accent/40'
)}
>
<button
className={cn(
'mt-0.5 cursor-pointer text-left uppercase opacity-70 transition-colors hover:opacity-100',
consoleLevelClass[log.level] ?? consoleLevelClass[0]
)}
onClick={onToggleSelect}
title={selected ? 'Deselect entry' : 'Select entry'}
type="button"
>
{consoleLevelLabel[log.level] || 'log'}
</button>
<div className="min-w-0" data-selectable-text="true">
<span className={cn('block wrap-break-word', consoleLevelClass[log.level] ?? consoleLevelClass[0])}>
{log.message}
</span>
{log.source && (
<span className="block truncate text-muted-foreground/60">
{compactUrl(log.source)}
{log.line ? `:${log.line}` : ''}
</span>
)}
</div>
<span className="opacity-0 transition-opacity group-hover/row:opacity-100">
<CopyButton
appearance="inline"
className="rounded-md p-1 text-muted-foreground transition-colors hover:bg-accent hover:text-foreground"
errorMessage="Could not copy console output"
iconClassName="size-3"
label="Copy this entry"
showLabel={false}
text={copyText}
/>
<button
className="rounded-md p-1 text-muted-foreground transition-colors hover:bg-accent hover:text-foreground"
onClick={onSend}
title="Send this entry to chat"
type="button"
>
<Send className="size-3" />
</button>
</span>
</div>
)
}
function PreviewConsoleTitlebarIcon({ consoleState }: { consoleState: PreviewConsoleState }) {
const logCount = useStore(consoleState.$logCount)
return (
<>
<PanelBottom />
{logCount > 0 && <span className="sr-only">{logCount} console messages</span>}
</>
)
}
type EmptyStateTone = 'neutral' | 'warning'
const TONE_STYLES: Record<EmptyStateTone, { cube: string; primary: string }> = {
neutral: {
cube: 'text-muted-foreground/35',
primary: 'border-border bg-background text-foreground hover:bg-accent'
},
warning: {
cube: 'text-amber-500/70 dark:text-amber-300/70',
primary:
'border-amber-400/40 bg-amber-50 text-amber-900 hover:bg-amber-100 dark:border-amber-300/30 dark:bg-amber-300/15 dark:text-amber-100 dark:hover:bg-amber-300/20'
}
}
function PreviewCubeIcon({ className }: { className?: string }) {
return (
<svg aria-hidden="true" className={cn('size-16', className)} viewBox="0 0 64 64">
<path
d="M32 5 56 18.5v27L32 59 8 45.5v-27L32 5Z"
fill="none"
stroke="currentColor"
strokeLinejoin="round"
strokeWidth="1.25"
/>
<path
d="M8 18.5 32 32l24-13.5M32 32v27"
fill="none"
stroke="currentColor"
strokeLinejoin="round"
strokeWidth="1.25"
/>
<path d="M20 11.75 44 25.25" fill="none" opacity="0.45" stroke="currentColor" strokeWidth="0.9" />
</svg>
)
}
interface PreviewEmptyStateProps {
body?: ReactNode
consoleHeight?: number
primaryAction?: { disabled?: boolean; label: string; onClick: () => void }
secondaryAction?: { disabled?: boolean; label: string; onClick: () => void }
title: string
tone?: EmptyStateTone
}
function PreviewEmptyState({
body,
consoleHeight = 0,
primaryAction,
secondaryAction,
title,
tone = 'neutral'
}: PreviewEmptyStateProps) {
const styles = TONE_STYLES[tone]
return (
<div
className="absolute inset-x-0 top-0 z-10 grid place-items-center bg-background px-8 py-10 text-center bottom-(--preview-error-bottom)"
style={{ '--preview-error-bottom': `${consoleHeight}px` } as CSSProperties}
>
<div className="grid max-w-sm justify-items-center gap-5">
<PreviewCubeIcon className={styles.cube} />
<div className="grid gap-2">
<div className="text-sm font-medium text-foreground">{title}</div>
{body && <div className="text-xs leading-relaxed text-muted-foreground">{body}</div>}
</div>
{(primaryAction || secondaryAction) && (
<div className="grid justify-items-center gap-2">
{primaryAction && (
<button
className={cn(
'rounded-full border px-3.5 py-1.5 text-xs font-medium shadow-xs transition-colors disabled:cursor-default disabled:opacity-60',
styles.primary
)}
disabled={primaryAction.disabled}
onClick={primaryAction.onClick}
type="button"
>
{primaryAction.label}
</button>
)}
{secondaryAction && (
<button
className="text-[0.6875rem] font-medium text-muted-foreground underline decoration-muted-foreground/25 underline-offset-4 transition-colors hover:text-foreground hover:decoration-foreground/55 disabled:cursor-default disabled:text-muted-foreground/55 disabled:no-underline"
disabled={secondaryAction.disabled}
onClick={secondaryAction.onClick}
type="button"
>
{secondaryAction.label}
</button>
)}
</div>
)}
</div>
</div>
)
}
function PreviewLoadError({
consoleHeight = 0,
error,
@@ -344,592 +113,6 @@ function PreviewLoadError({
)
}
function PreviewConsolePanel({
consoleBodyRef,
consoleShouldStickRef,
consoleState,
startConsoleResize
}: {
consoleBodyRef: RefObject<HTMLDivElement | null>
consoleShouldStickRef: MutableRefObject<boolean>
consoleState: PreviewConsoleState
startConsoleResize: (event: ReactPointerEvent<HTMLDivElement>) => void
}) {
const consoleHeight = useStore(consoleState.$height)
const logs = useStore(consoleState.$logs)
const selectedLogIds = useStore(consoleState.$selectedLogIds)
const visibleSelection = useMemo(() => logs.filter(log => selectedLogIds.has(log.id)), [logs, selectedLogIds])
const sendableLogs = visibleSelection.length > 0 ? visibleSelection : logs
const stickScrollRafRef = useRef<number | null>(null)
useEffect(() => {
if (!consoleShouldStickRef.current) {
return
}
if (stickScrollRafRef.current !== null) {
window.cancelAnimationFrame(stickScrollRafRef.current)
stickScrollRafRef.current = null
}
stickScrollRafRef.current = window.requestAnimationFrame(() => {
stickScrollRafRef.current = null
const consoleBody = consoleBodyRef.current
consoleBody?.scrollTo({ top: consoleBody.scrollHeight })
})
return () => {
if (stickScrollRafRef.current !== null) {
window.cancelAnimationFrame(stickScrollRafRef.current)
stickScrollRafRef.current = null
}
}
}, [consoleBodyRef, consoleHeight, consoleShouldStickRef, logs])
function sendLogsToComposer(entries: ConsoleEntry[]) {
if (!entries.length) {
return
}
const block = ['Preview console:', '```', ...entries.map(formatLogLine), '```'].join('\n')
const draft = $composerDraft.get()
const next = draft && !draft.endsWith('\n') ? `${draft}\n\n${block}` : `${draft}${block}`
setComposerDraft(next)
consoleState.clearSelection()
notify({
kind: 'success',
title: 'Sent to chat',
message: `${entries.length} log entr${entries.length === 1 ? 'y' : 'ies'} added to composer`
})
}
return (
<div
className="pointer-events-auto absolute inset-x-0 bottom-0 z-20 flex h-(--preview-console-height) min-h-8 flex-col overflow-hidden border-t border-border/60 bg-background"
style={{ '--preview-console-height': `${consoleHeight}px` } as CSSProperties}
>
<div
aria-label="Resize preview console"
className="group absolute inset-x-0 -top-1 z-1 h-2 cursor-row-resize"
onDoubleClick={() => consoleState.setHeight(CONSOLE_HEADER_HEIGHT)}
onPointerDown={startConsoleResize}
role="separator"
>
<span className="absolute left-1/2 top-1/2 h-0.75 w-23 -translate-x-1/2 -translate-y-1/2 rounded-full bg-muted-foreground/80 opacity-0 transition-opacity duration-100 group-hover:opacity-[0.5]" />
</div>
<div className="flex h-8 shrink-0 items-center justify-between border-b border-border/50 px-2">
<div className="flex items-center gap-2 text-[0.6875rem] font-medium text-muted-foreground">
<PanelBottom className="size-3.5" />
Preview Console
{selectedLogIds.size > 0 && (
<span className="rounded-full bg-muted px-1.5 py-px text-[0.5625rem] text-muted-foreground">
{selectedLogIds.size} selected
</span>
)}
</div>
<div className="flex items-center gap-1">
<button
className="inline-flex items-center gap-1 rounded-md px-1.5 py-0.5 text-[0.625rem] text-muted-foreground transition-colors hover:bg-accent hover:text-foreground disabled:opacity-40"
disabled={sendableLogs.length === 0}
onClick={() => sendLogsToComposer(sendableLogs)}
title={
visibleSelection.length > 0
? `Send ${visibleSelection.length} selected to chat`
: 'Send all log entries to chat'
}
type="button"
>
<Send className="size-3" />
Send to chat
</button>
<CopyButton
appearance="inline"
className="inline-flex items-center gap-1 rounded-md px-1.5 py-0.5 text-[0.625rem] text-muted-foreground transition-colors hover:bg-accent hover:text-foreground disabled:opacity-40"
disabled={sendableLogs.length === 0}
errorMessage="Could not copy console output"
iconClassName="size-3"
label={visibleSelection.length > 0 ? 'Copy selected to clipboard' : 'Copy all to clipboard'}
text={() => formatConsoleEntries(sendableLogs)}
>
Copy
</CopyButton>
<button
className="inline-flex items-center gap-1 rounded-md px-1.5 py-0.5 text-[0.625rem] text-muted-foreground transition-colors hover:bg-accent hover:text-foreground disabled:opacity-40"
disabled={logs.length === 0}
onClick={consoleState.clear}
title="Clear console"
type="button"
>
<Trash2 className="size-3" />
Clear
</button>
</div>
</div>
<div
className="min-h-0 flex-1 overflow-y-auto px-2 py-1.5 font-mono text-[0.6875rem] leading-relaxed"
ref={consoleBodyRef}
>
{logs.length > 0 ? (
logs.map(log => {
const selected = selectedLogIds.has(log.id)
return (
<ConsoleRow
copyText={formatLogLine(log)}
key={log.id}
log={log}
onSend={() => sendLogsToComposer([log])}
onToggleSelect={() => consoleState.toggleSelection(log.id)}
selected={selected}
/>
)
})
) : (
<div className="py-2 text-muted-foreground/70">No console messages yet.</div>
)}
</div>
</div>
)
}
interface LocalPreviewState {
binary?: boolean
byteSize?: number
dataUrl?: string
error?: string
language?: string
loading: boolean
text?: string
truncated?: boolean
}
function filePathForTarget(target: PreviewTarget) {
if (target.path) {
return target.path
}
try {
const url = new URL(target.url)
return url.protocol === 'file:' ? decodeURIComponent(url.pathname) : target.url
} catch {
return target.url
}
}
function formatBytes(bytes: number | undefined) {
if (!bytes) {
return 'unknown size'
}
const units = ['B', 'KB', 'MB', 'GB']
let value = bytes
let unit = 0
while (value >= 1024 && unit < units.length - 1) {
value /= 1024
unit += 1
}
return `${value >= 10 || unit === 0 ? value.toFixed(0) : value.toFixed(1)} ${units[unit]}`
}
function looksBinaryBytes(bytes: Uint8Array) {
if (!bytes.length) {
return false
}
let suspicious = 0
for (const byte of bytes.slice(0, 4096)) {
if (byte === 0) {
return true
}
if (byte < 32 && byte !== 9 && byte !== 10 && byte !== 13) {
suspicious += 1
}
}
return suspicious / Math.min(bytes.length, 4096) > 0.12
}
async function readTextPreview(filePath: string) {
if (window.hermesDesktop.readFileText) {
try {
return await window.hermesDesktop.readFileText(filePath)
} catch (error) {
const message = error instanceof Error ? error.message : String(error)
if (!message.includes("No handler registered for 'hermes:readFileText'")) {
throw error
}
}
}
// Back-compat for a running Electron process whose preload hasn't been
// restarted since readFileText was added. readFileDataUrl already existed.
const dataUrl = await window.hermesDesktop.readFileDataUrl(filePath)
const [, metadata = '', data = ''] = dataUrl.match(/^data:([^,]*),(.*)$/) || []
const base64 = metadata.includes(';base64')
const mimeType = metadata.replace(/;base64$/, '') || undefined
const raw = base64 ? atob(data) : decodeURIComponent(data)
const bytes = Uint8Array.from(raw, ch => ch.charCodeAt(0))
return {
binary: looksBinaryBytes(bytes),
byteSize: bytes.byteLength,
mimeType,
path: filePath,
text: new TextDecoder().decode(bytes)
}
}
// Lightweight markdown renderer for file previews. Streamdown does the parse;
// our components keep typography simple and route fenced code through Shiki
// without the library's copy/download/fullscreen chrome.
const MD_TAG_CLASSES = {
h1: 'mb-3 mt-6 text-3xl font-bold leading-tight tracking-tight first:mt-0',
h2: 'mb-2.5 mt-5 text-2xl font-semibold leading-snug tracking-tight first:mt-0',
h3: 'mb-2 mt-4 text-xl font-semibold leading-snug first:mt-0',
h4: 'mb-2 mt-3 text-base font-semibold leading-snug first:mt-0',
p: 'mb-4 leading-relaxed text-foreground last:mb-0',
ul: 'mb-4 list-disc pl-6 marker:text-muted-foreground/70 last:mb-0',
ol: 'mb-4 list-decimal pl-6 marker:text-muted-foreground/70 last:mb-0',
li: 'mt-1 leading-relaxed',
blockquote: 'mb-4 border-l-2 border-border pl-3 text-muted-foreground italic last:mb-0',
pre: 'mb-4 overflow-hidden rounded-lg border border-border bg-card font-mono text-xs leading-relaxed last:mb-0 [&_pre]:m-0 [&_pre]:overflow-x-auto [&_pre]:bg-transparent! [&_pre]:p-3 [&_pre]:font-mono'
} as const
function tagged<T extends keyof typeof MD_TAG_CLASSES>(Tag: T) {
const base = MD_TAG_CLASSES[Tag]
const Component = (({ className, ...rest }: ComponentProps<T>) => {
const Element = Tag as React.ElementType
return <Element className={cn(base, className)} {...rest} />
}) as React.FC<ComponentProps<T>>
Component.displayName = `Md.${Tag}`
return Component
}
function MarkdownCode({ className, children, ...props }: ComponentProps<'code'>) {
const language = /language-([^\s]+)/.exec(className || '')?.[1]
if (!language) {
return (
<code
className={cn(
'rounded bg-muted px-1 py-0.5 font-mono text-[0.86em] text-pink-700 dark:text-pink-300',
className
)}
{...props}
>
{children}
</code>
)
}
return (
<ShikiHighlighter
addDefaultStyles={false}
as="div"
defaultColor="light-dark()"
delay={80}
language={language}
showLanguage={false}
theme={SHIKI_THEME}
>
{String(children).replace(/\n$/, '')}
</ShikiHighlighter>
)
}
const MARKDOWN_COMPONENTS = {
h1: tagged('h1'),
h2: tagged('h2'),
h3: tagged('h3'),
h4: tagged('h4'),
p: tagged('p'),
ul: tagged('ul'),
ol: tagged('ol'),
li: tagged('li'),
blockquote: tagged('blockquote'),
pre: tagged('pre'),
code: MarkdownCode
}
function MarkdownPreview({ text }: { text: string }) {
return (
<div className="preview-markdown mx-auto max-w-3xl px-4 py-3 text-sm text-foreground">
<Streamdown components={MARKDOWN_COMPONENTS} controls={false} mode="static" parseIncompleteMarkdown={false}>
{text}
</Streamdown>
</div>
)
}
function PreviewToggle({ asSource, onToggle }: { asSource: boolean; onToggle: () => void }) {
return (
<div className="sticky top-0 z-10 flex justify-end border-b border-border/40 bg-background/90 px-3 py-1 backdrop-blur">
<button
className="text-[0.625rem] font-bold text-muted-foreground underline decoration-muted-foreground/25 underline-offset-4 transition-colors hover:text-foreground hover:decoration-foreground/55"
onClick={onToggle}
type="button"
>
{asSource ? 'PREVIEW' : 'SOURCE'}
</button>
</div>
)
}
// Gutter and Shiki output share `font-mono text-xs leading-relaxed py-3` so
// each line aligns vertically. The selection overlay relies on the same
// `text-xs * leading-relaxed = 1.21875rem` line-height to position itself.
const SOURCE_LINE_HEIGHT_REM = 1.21875
const SOURCE_PAD_Y_REM = 0.75
interface LineSelection {
end: number
start: number
}
function startLineDrag(event: ReactDragEvent<HTMLElement>, filePath: string, { end, start }: LineSelection) {
const lineEnd = end > start ? end : undefined
const label = lineEnd ? `${filePath}:${start}-${end}` : `${filePath}:${start}`
event.dataTransfer.setData(HERMES_PATHS_MIME, JSON.stringify([{ line: start, lineEnd, path: filePath }]))
event.dataTransfer.setData('text/plain', label)
event.dataTransfer.effectAllowed = 'copy'
}
function SourceView({ filePath, language, text }: { filePath: string; language: string; text: string }) {
const lineCount = useMemo(() => Math.max(1, text.split('\n').length), [text])
const [selection, setSelection] = useState<LineSelection | null>(null)
const inSelection = (line: number) => selection != null && line >= selection.start && line <= selection.end
const handleLineClick = (event: ReactMouseEvent, line: number) => {
if (event.shiftKey && selection) {
setSelection({ end: Math.max(selection.end, line), start: Math.min(selection.start, line) })
return
}
if (selection?.start === line && selection.end === line) {
setSelection(null)
return
}
setSelection({ end: line, start: line })
}
const handleDragStart = (event: ReactDragEvent<HTMLElement>, line: number) => {
startLineDrag(event, filePath, inSelection(line) && selection ? selection : { end: line, start: line })
}
return (
<div className="grid min-w-max grid-cols-[auto_minmax(0,1fr)] font-mono text-xs leading-relaxed">
<div className="select-none py-3 text-right text-muted-foreground/55">
{Array.from({ length: lineCount }, (_, index) => {
const line = index + 1
const selected = inSelection(line)
return (
<div
className={cn(
'cursor-pointer px-3 tabular-nums transition-colors',
selected
? 'bg-amber-200/45 text-amber-900 dark:bg-amber-300/20 dark:text-amber-100'
: 'hover:text-foreground'
)}
draggable
key={line}
onClick={event => handleLineClick(event, line)}
onDragStart={event => handleDragStart(event, line)}
title="Click to select · shift-click to extend · drag to composer"
>
{line}
</div>
)
})}
</div>
<div className="relative [&_pre]:m-0 [&_pre]:px-3 [&_pre]:py-3">
{selection && (
<div
aria-hidden
className="pointer-events-none absolute inset-x-0 bg-amber-200/35 dark:bg-amber-300/10"
style={{
top: `calc(${SOURCE_PAD_Y_REM}rem + ${selection.start - 1} * ${SOURCE_LINE_HEIGHT_REM}rem)`,
height: `calc(${selection.end - selection.start + 1} * ${SOURCE_LINE_HEIGHT_REM}rem)`
}}
/>
)}
<ShikiHighlighter
addDefaultStyles={false}
as="div"
defaultColor="light-dark()"
delay={80}
language={language || 'text'}
showLanguage={false}
theme={SHIKI_THEME}
>
{text}
</ShikiHighlighter>
</div>
</div>
)
}
function LocalFilePreview({ reloadKey, target }: { reloadKey: number; target: PreviewTarget }) {
const [state, setState] = useState<LocalPreviewState>({ loading: true })
const [forcePreview, setForcePreview] = useState(false)
const [renderMarkdownAsSource, setRenderMarkdownAsSource] = useState(false)
const filePath = filePathForTarget(target)
const isImage = target.previewKind === 'image'
// HTML files are rendered as source code, not in a webview — so they take
// the same path as plain text files. `previewKind === 'binary'` arrives
// when the file is forcibly previewed past the binary refusal screen.
const isText = target.previewKind === 'text' || target.previewKind === 'binary' || target.previewKind === 'html'
const blockedByTarget = !isImage && !forcePreview && (target.binary || target.large)
useEffect(() => {
let active = true
async function load() {
if (blockedByTarget) {
setState({ loading: false })
return
}
if (!isImage && !isText) {
setState({ loading: false })
return
}
setState({ loading: true })
try {
if (isImage) {
const dataUrl = await window.hermesDesktop.readFileDataUrl(filePath)
if (active) {
setState({ dataUrl, loading: false })
}
return
}
const result = await readTextPreview(filePath)
if (active) {
const shouldBlock = !forcePreview && (result.binary || (result.byteSize ?? 0) > TEXT_PREVIEW_MAX_BYTES)
setState({
binary: result.binary,
byteSize: result.byteSize,
language: result.language || target.language || 'text',
loading: false,
text: shouldBlock ? undefined : result.text,
truncated: result.truncated
})
}
} catch (error) {
if (active) {
setState({
error: error instanceof Error ? error.message : String(error),
loading: false
})
}
}
}
void load()
return () => {
active = false
}
}, [blockedByTarget, filePath, forcePreview, isImage, isText, reloadKey, target.language])
if (state.loading) {
return <div className="grid h-full place-items-center text-xs text-muted-foreground">Loading preview</div>
}
if (state.error) {
return <PreviewEmptyState body={state.error} title="Preview unavailable" />
}
if (
!isImage &&
!forcePreview &&
(target.binary || target.large || state.binary || (state.byteSize ?? 0) > TEXT_PREVIEW_MAX_BYTES)
) {
const binary = target.binary || state.binary
const size = target.byteSize || state.byteSize
return (
<PreviewEmptyState
body={
binary
? `Previewing ${target.label} may show unreadable text.`
: `${target.label} is ${formatBytes(size)}. Hermes will only show the first 512 KB.`
}
primaryAction={{ label: 'Preview anyway', onClick: () => setForcePreview(true) }}
title={binary ? 'This looks like a binary file' : 'This file is large'}
tone="warning"
/>
)
}
if (isImage && state.dataUrl) {
return (
<div className="flex h-full w-full items-center justify-center overflow-auto bg-[color-mix(in_srgb,var(--dt-card)_42%,transparent)] p-4">
<img
alt={target.label}
className="max-h-full max-w-full rounded-lg object-contain shadow-sm"
draggable={false}
src={state.dataUrl}
/>
</div>
)
}
if (isText && state.text !== undefined) {
const isMarkdown = (state.language || target.language) === 'markdown'
const showRendered = isMarkdown && !renderMarkdownAsSource
return (
<div className="h-full overflow-auto bg-background">
{state.truncated && (
<div className="border-b border-border/60 bg-muted/35 px-3 py-1.5 text-[0.68rem] text-muted-foreground">
Showing first 512 KB.
</div>
)}
{isMarkdown && <PreviewToggle asSource={!showRendered} onToggle={() => setRenderMarkdownAsSource(s => !s)} />}
{showRendered ? (
<MarkdownPreview text={state.text} />
) : (
<SourceView filePath={filePath} language={state.language || 'text'} text={state.text} />
)}
</div>
)
}
return (
<PreviewEmptyState
body={`${target.mimeType || 'This file type'} can still be attached as context.`}
title="No inline preview"
/>
)
}
const TITLEBAR_GROUP_ID = 'preview'
export function PreviewPane({
+3 -1
View File
@@ -1,5 +1,7 @@
import type { ChatMessage } from '@/lib/chat-messages'
export type ThreadLoadingState = 'response' | 'session'
export function lastVisibleMessageIsUser(messages: ChatMessage[]): boolean {
const lastVisible = [...messages].reverse().find(message => !message.hidden)
@@ -11,7 +13,7 @@ export function threadLoadingState(
busy: boolean,
awaitingResponse: boolean,
lastVisibleIsUser: boolean
) {
): ThreadLoadingState | undefined {
if (loadingSession) {
return 'session'
}
@@ -89,7 +89,12 @@ 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-messaging', route: MESSAGING_ROUTE, title: 'Messaging', detail: 'Set up Telegram, Slack, Discord, and more' },
{
id: 'nav-messaging',
route: MESSAGING_ROUTE,
title: 'Messaging',
detail: 'Set up Telegram, Slack, Discord, and more'
},
{ id: 'nav-artifacts', route: ARTIFACTS_ROUTE, title: 'Artifacts', detail: 'Browse generated outputs' }
]
@@ -10,6 +10,7 @@ import { setCurrentSessionPreviewTarget } from '@/store/preview'
import { $currentCwd } from '@/store/session'
import { SidebarPanelLabel } from '../shell/sidebar-label'
import { ProjectTree } from './tree'
import { useProjectTree } from './use-project-tree'
@@ -22,8 +22,11 @@ export function useRouteEnumParam<T extends string>(
(next: T) => {
const params = new URLSearchParams(search)
if (next === fallback) {params.delete(key)}
else {params.set(key, next)}
if (next === fallback) {
params.delete(key)
} else {
params.set(key, next)
}
const qs = params.toString()
navigate({ hash, pathname, search: qs ? `?${qs}` : '' }, { replace: true })
+25 -20
View File
@@ -78,11 +78,17 @@ const HINT_BY_STATE: Record<string, string> = {
const stateLabel = (state?: null | string) => (state ? STATE_LABELS[state] || state.replace(/_/g, ' ') : 'Unknown')
function stateTone({ enabled, state }: MessagingPlatformInfo): StatusTone {
if (!enabled) {return 'muted'}
if (!enabled) {
return 'muted'
}
if (state === 'connected') {return 'good'}
if (state === 'connected') {
return 'good'
}
if (state === 'fatal' || state === 'startup_failed') {return 'bad'}
if (state === 'fatal' || state === 'startup_failed') {
return 'bad'
}
return 'warn'
}
@@ -511,9 +517,7 @@ function PlatformDetail({
<section>
<SectionTitle>Get your credentials</SectionTitle>
<p className="mt-1 text-sm leading-6 text-muted-foreground">
{introCopy(platform)}
</p>
<p className="mt-1 text-sm leading-6 text-muted-foreground">{introCopy(platform)}</p>
<div className="mt-3">
<Button asChild size="sm" variant="outline">
<a href={platform.docs_url} rel="noreferrer" target="_blank">
@@ -572,9 +576,7 @@ function PlatformDetail({
type="button"
>
<span>Advanced ({hiddenCount})</span>
<ChevronDown
className={cn('size-3.5 transition-transform', !showAdvanced && '-rotate-90')}
/>
<ChevronDown className={cn('size-3.5 transition-transform', !showAdvanced && '-rotate-90')} />
</button>
{showAdvanced && (
<div className="mt-3 space-y-4">
@@ -632,7 +634,8 @@ const PLATFORM_INTRO: Record<string, string> = {
mattermost:
'On your Mattermost server, create a bot account or personal access token, then paste the server URL and token here.',
matrix: 'Sign in to your homeserver with the bot account, then copy the access token, user ID, and homeserver URL.',
signal: 'Run a signal-cli REST bridge somewhere reachable, then point Hermes at the URL and the registered phone number.',
signal:
'Run a signal-cli REST bridge somewhere reachable, then point Hermes at the URL and the registered phone number.',
whatsapp:
'Start the WhatsApp bridge that ships with Hermes, scan the QR code on first run, then enable the platform.',
bluebubbles:
@@ -642,8 +645,7 @@ const PLATFORM_INTRO: Record<string, string> = {
email:
'Use a dedicated mailbox. For Gmail/Workspace, create an app password and use imap.gmail.com / smtp.gmail.com.',
sms: 'Get your Twilio Account SID and Auth Token from the Twilio console, plus a phone number that can send SMS.',
dingtalk:
'Create a DingTalk app in the developer console, then copy the Client ID (App key) and Client Secret here.',
dingtalk: 'Create a DingTalk app in the developer console, then copy the Client ID (App key) and Client Secret here.',
feishu:
'Create a Feishu / Lark app, configure the bot capability, and copy the App ID, App secret, and event encryption keys.',
wecom:
@@ -655,7 +657,8 @@ const PLATFORM_INTRO: Record<string, string> = {
qqbot: 'Register an app on the QQ Open Platform (q.qq.com) and copy the App ID and Client Secret.',
api_server:
'Expose Hermes as an OpenAI-compatible API. Set an auth key, then point Open WebUI / LobeChat / etc. at the host:port.',
webhook: 'Run an HTTP server that other tools (GitHub, GitLab, custom apps) can POST to. Use the secret to verify signatures.'
webhook:
'Run an HTTP server that other tools (GitHub, GitLab, custom apps) can POST to. Use the secret to verify signatures.'
}
const introCopy = (platform: MessagingPlatformInfo) => PLATFORM_INTRO[platform.id] || platform.description
@@ -717,16 +720,15 @@ function MessagingField({
}
function SectionTitle({ children }: { children: React.ReactNode }) {
return (
<h4 className="text-[0.7rem] font-semibold uppercase tracking-[0.14em] text-muted-foreground">{children}</h4>
)
return <h4 className="text-[0.7rem] font-semibold uppercase tracking-[0.14em] text-muted-foreground">{children}</h4>
}
function PlatformHint({ platform }: { platform: MessagingPlatformInfo }) {
if (!platform.enabled || platform.state === 'connected') {return null}
if (!platform.enabled || platform.state === 'connected') {
return null
}
const hint =
HINT_BY_STATE[platform.state || ''] || (platform.gateway_running ? null : HINT_BY_STATE.gateway_stopped)
const hint = HINT_BY_STATE[platform.state || ''] || (platform.gateway_running ? null : HINT_BY_STATE.gateway_stopped)
return hint ? <p className="mt-2 text-xs leading-5 text-muted-foreground">{hint}</p> : null
}
@@ -748,7 +750,10 @@ function StatePill({ children, tone }: { children: string; tone: StatusTone }) {
function SetupPill({ active, children }: { active: boolean; children: string }) {
return (
<span
className={cn('inline-flex items-center rounded-full px-2 py-0.5 text-[0.66rem] font-medium', PILL_TONE[active ? 'good' : 'muted'])}
className={cn(
'inline-flex items-center rounded-full px-2 py-0.5 text-[0.66rem] font-medium',
PILL_TONE[active ? 'good' : 'muted']
)}
>
{children}
</span>
@@ -65,7 +65,9 @@ const STREAM_DELTA_FLUSH_MS = 16
// Anonymous progress events that carry todos but no name still belong to the
// todo stream; named todo events are obviously routed there too.
function toTodoPayload(payload: GatewayEventPayload | undefined): GatewayEventPayload | undefined {
if (!payload) {return undefined}
if (!payload) {
return undefined
}
const isTodo = payload.name === 'todo' || (!payload.name && Object.hasOwn(payload, 'todos'))
return isTodo ? { ...payload, name: 'todo', tool_id: payload.tool_id || 'todo-live' } : undefined
@@ -561,7 +563,9 @@ export function useMessageStream({
setCurrentUsage(current => ({ ...current, ...payload.usage }))
}
} else if (event.type === 'tool.start' || event.type === 'tool.progress' || event.type === 'tool.generating') {
if (!sessionId) {return}
if (!sessionId) {
return
}
flushQueuedDeltas(sessionId)
upsertToolCall(sessionId, toTodoPayload(payload) ?? payload, 'running')
} else if (event.type === 'tool.complete') {
@@ -46,19 +46,12 @@ function blobToDataUrl(blob: Blob): Promise<string> {
})
}
interface SetupStatus {
provider_configured?: boolean
}
interface RuntimeCheck {
error?: string
ok?: boolean
}
function isProviderSetupError(error: unknown) {
const message = error instanceof Error ? error.message : String(error)
return /No inference provider configured|OPENROUTER_API_KEY|OPENAI_API_KEY|ANTHROPIC_API_KEY|set an API key/i.test(message)
return /No inference provider configured|OPENROUTER_API_KEY|OPENAI_API_KEY|ANTHROPIC_API_KEY|set an API key/i.test(
message
)
}
interface PromptActionsOptions {
@@ -197,25 +190,24 @@ export function usePromptActions({
async (rawText: string) => {
const visibleText = rawText.trim()
const attachments = $composerAttachments.get()
const contextRefs = attachments
.map(attachment => attachment.refText)
.map(a => a.refText)
.filter(Boolean)
.join('\n')
const hasImageAttachment = attachments.some(attachment => attachment.kind === 'image')
const attachmentRefs = attachments.map(attachmentDisplayText).filter((ref): ref is string => Boolean(ref))
const hasImage = attachments.some(a => a.kind === 'image')
const attachmentRefs = attachments.map(attachmentDisplayText).filter((r): r is string => Boolean(r))
const text =
[contextRefs, visibleText].filter(Boolean).join('\n\n') ||
(hasImageAttachment ? 'What do you see in this image?' : '')
[contextRefs, visibleText].filter(Boolean).join('\n\n') || (hasImage ? 'What do you see in this image?' : '')
if (!text || busyRef.current) {
return
}
const optimisticId = `user-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`
const userMessage: ChatMessage = {
id: `user-${Date.now()}`,
id: optimisticId,
role: 'user',
parts: [textPart(visibleText || (attachmentRefs.length ? '' : attachments.map(a => a.label).join(', ')))],
attachmentRefs
@@ -227,61 +219,80 @@ export function usePromptActions({
setAwaitingResponse(false)
}
// Idempotent optimistic insert — re-running with the resolved sessionId
// after createBackendSessionForSend just overwrites with the same id.
const seedOptimistic = (sid: string) =>
updateSessionState(
sid,
state => ({
...state,
messages: state.messages.some(m => m.id === optimisticId)
? state.messages
: [...state.messages, userMessage],
busy: true,
awaitingResponse: true,
pendingBranchGroup: null,
sawAssistantPayload: false,
interrupted: false
}),
selectedStoredSessionIdRef.current
)
const dropOptimistic = (sid: null | string) => {
if (!sid) {
setMessages(current => current.filter(m => m.id !== optimisticId))
return
}
updateSessionState(
sid,
state => ({
...state,
messages: state.messages.filter(m => m.id !== optimisticId),
busy: false,
awaitingResponse: false,
pendingBranchGroup: null
}),
selectedStoredSessionIdRef.current
)
}
busyRef.current = true
setBusy(true)
setAwaitingResponse(true)
clearNotifications()
const [setup, runtime] = await Promise.all([
requestGateway<SetupStatus>('setup.status').catch(() => null),
requestGateway<RuntimeCheck>('setup.runtime_check').catch(() => null)
])
let sessionId: null | string = activeSessionId
const runtimeReady = runtime?.ok !== undefined ? Boolean(runtime?.ok) : setup?.provider_configured !== false
if (!runtimeReady) {
releaseBusy()
requestDesktopOnboarding(
runtime?.error || 'Add a provider credential before sending your first message.'
)
return
if (sessionId) {
seedOptimistic(sessionId)
} else {
setMessages(current => [...current, userMessage])
}
let sessionId = activeSessionId
if (!sessionId) {
try {
sessionId = await createBackendSessionForSend()
} catch (err) {
dropOptimistic(null)
releaseBusy()
notifyError(err, 'Session unavailable')
return
}
if (!sessionId) {
dropOptimistic(null)
releaseBusy()
notify({ kind: 'error', title: 'Session unavailable', message: 'Could not create a new session' })
return
}
seedOptimistic(sessionId)
}
if (!sessionId) {
releaseBusy()
notify({ kind: 'error', title: 'Session unavailable', message: 'Could not create a new session' })
return
}
updateSessionState(
sessionId,
state => ({
...state,
messages: [...state.messages, userMessage],
busy: true,
awaitingResponse: true,
pendingBranchGroup: null,
sawAssistantPayload: false,
interrupted: false
}),
selectedStoredSessionIdRef.current
)
try {
await syncImageAttachmentsForSubmit(sessionId, attachments)
await requestGateway('prompt.submit', { session_id: sessionId, text })
@@ -0,0 +1,136 @@
import { cleanup, render } from '@testing-library/react'
import type { MutableRefObject } from 'react'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { useRouteResume } from './use-route-resume'
interface HarnessProps {
activeSessionId: null | string
activeSessionIdRef: MutableRefObject<null | string>
creatingSessionRef: MutableRefObject<boolean>
currentView: string
freshDraftReady: boolean
gatewayState: string
locationPathname: string
resumeSession: (sessionId: string, focus: boolean) => Promise<unknown>
routedSessionId: null | string
runtimeIdByStoredSessionIdRef: MutableRefObject<Map<string, string>>
selectedStoredSessionId: null | string
selectedStoredSessionIdRef: MutableRefObject<null | string>
startFreshSessionDraft: (focus: boolean) => unknown
}
function RouteResumeHarness(props: HarnessProps) {
useRouteResume(props)
return null
}
describe('useRouteResume', () => {
afterEach(() => {
cleanup()
vi.restoreAllMocks()
})
it('does not re-resume the old session during a /:sid -> /new transition', () => {
const resumeSession = vi.fn(async () => undefined)
const startFreshSessionDraft = vi.fn()
const activeSessionIdRef: MutableRefObject<null | string> = { current: 'runtime-1' }
const creatingSessionRef = { current: false }
const runtimeIdByStoredSessionIdRef = { current: new Map([['session-1', 'runtime-1']]) }
const selectedStoredSessionIdRef: MutableRefObject<null | string> = { current: 'session-1' }
const { rerender } = render(
<RouteResumeHarness
activeSessionId="runtime-1"
activeSessionIdRef={activeSessionIdRef}
creatingSessionRef={creatingSessionRef}
currentView="chat"
freshDraftReady={false}
gatewayState="open"
locationPathname="/session-1"
resumeSession={resumeSession}
routedSessionId="session-1"
runtimeIdByStoredSessionIdRef={runtimeIdByStoredSessionIdRef}
selectedStoredSessionId="session-1"
selectedStoredSessionIdRef={selectedStoredSessionIdRef}
startFreshSessionDraft={startFreshSessionDraft}
/>
)
expect(resumeSession).not.toHaveBeenCalled()
// Simulate startFreshSessionDraft state updates landing before route update.
activeSessionIdRef.current = null
selectedStoredSessionIdRef.current = null
rerender(
<RouteResumeHarness
activeSessionId={null}
activeSessionIdRef={activeSessionIdRef}
creatingSessionRef={creatingSessionRef}
currentView="chat"
freshDraftReady
gatewayState="open"
locationPathname="/session-1"
resumeSession={resumeSession}
routedSessionId="session-1"
runtimeIdByStoredSessionIdRef={runtimeIdByStoredSessionIdRef}
selectedStoredSessionId={null}
selectedStoredSessionIdRef={selectedStoredSessionIdRef}
startFreshSessionDraft={startFreshSessionDraft}
/>
)
expect(resumeSession).not.toHaveBeenCalled()
})
it('resumes when pathname changes to a routed session', () => {
const resumeSession = vi.fn(async () => undefined)
const startFreshSessionDraft = vi.fn()
const activeSessionIdRef: MutableRefObject<null | string> = { current: null }
const creatingSessionRef = { current: false }
const runtimeIdByStoredSessionIdRef = { current: new Map() }
const selectedStoredSessionIdRef: MutableRefObject<null | string> = { current: null }
const { rerender } = render(
<RouteResumeHarness
activeSessionId={null}
activeSessionIdRef={activeSessionIdRef}
creatingSessionRef={creatingSessionRef}
currentView="chat"
freshDraftReady
gatewayState="open"
locationPathname="/"
resumeSession={resumeSession}
routedSessionId={null}
runtimeIdByStoredSessionIdRef={runtimeIdByStoredSessionIdRef}
selectedStoredSessionId={null}
selectedStoredSessionIdRef={selectedStoredSessionIdRef}
startFreshSessionDraft={startFreshSessionDraft}
/>
)
expect(resumeSession).not.toHaveBeenCalled()
rerender(
<RouteResumeHarness
activeSessionId={null}
activeSessionIdRef={activeSessionIdRef}
creatingSessionRef={creatingSessionRef}
currentView="chat"
freshDraftReady
gatewayState="open"
locationPathname="/session-2"
resumeSession={resumeSession}
routedSessionId="session-2"
runtimeIdByStoredSessionIdRef={runtimeIdByStoredSessionIdRef}
selectedStoredSessionId={null}
selectedStoredSessionIdRef={selectedStoredSessionIdRef}
startFreshSessionDraft={startFreshSessionDraft}
/>
)
expect(resumeSession).toHaveBeenCalledTimes(1)
expect(resumeSession).toHaveBeenCalledWith('session-2', true)
})
})
@@ -1,4 +1,4 @@
import { type MutableRefObject, useEffect } from 'react'
import { type MutableRefObject, useEffect, useRef } from 'react'
import { isNewChatRoute } from '@/app/routes'
@@ -55,8 +55,17 @@ export function useRouteResume({
selectedStoredSessionIdRef,
startFreshSessionDraft
}: RouteResumeOptions) {
const lastPathnameRef = useRef<string | null>(null)
const wasGatewayOpenRef = useRef(false)
useEffect(() => {
if (currentView !== 'chat' || gatewayState !== 'open') {
const gatewayOpen = gatewayState === 'open'
const pathnameChanged = lastPathnameRef.current !== locationPathname
const gatewayBecameOpen = !wasGatewayOpenRef.current && gatewayOpen
lastPathnameRef.current = locationPathname
wasGatewayOpenRef.current = gatewayOpen
if (currentView !== 'chat' || !gatewayOpen) {
return
}
@@ -68,7 +77,12 @@ export function useRouteResume({
Boolean(cachedRuntime) &&
cachedRuntime === activeSessionIdRef.current
if (!alreadyActive) {
// Resume only when the route meaningfully changed (or gateway just opened).
// This avoids a transient /:sid re-resume during "new chat" state clears
// before the pathname updates from /:sid -> /.
const shouldResume = pathnameChanged || gatewayBecameOpen
if (!alreadyActive && shouldResume && !creatingSessionRef.current) {
void resumeSession(routedSessionId, true)
}
@@ -46,7 +46,9 @@ function ModeCard({
<button
className={cn(
'rounded-2xl border p-4 text-left transition',
active ? 'border-primary bg-primary/10 ring-2 ring-primary/15' : 'border-border bg-background/60 hover:bg-muted/40',
active
? 'border-primary bg-primary/10 ring-2 ring-primary/15'
: 'border-border bg-background/60 hover:bg-muted/40',
disabled && 'cursor-not-allowed opacity-50'
)}
disabled={disabled}
@@ -179,7 +181,12 @@ export function GatewaySettings() {
}
if (!window.hermesDesktop?.getConnectionConfig) {
return <EmptyState description="The desktop IPC bridge does not expose gateway settings." title="Gateway settings unavailable" />
return (
<EmptyState
description="The desktop IPC bridge does not expose gateway settings."
title="Gateway settings unavailable"
/>
)
}
return (
@@ -191,8 +198,8 @@ export function GatewaySettings() {
{state.envOverride ? <Pill tone="primary">env override</Pill> : null}
</div>
<p className="mt-2 max-w-2xl text-xs leading-5 text-muted-foreground">
Hermes Desktop starts its own local gateway by default. Use a remote gateway when you want this app to
control an already-running Hermes backend on another machine or behind a trusted proxy.
Hermes Desktop starts its own local gateway by default. Use a remote gateway when you want this app to control
an already-running Hermes backend on another machine or behind a trusted proxy.
</p>
</div>
@@ -249,7 +256,9 @@ export function GatewaySettings() {
className={cn('h-8 font-mono', CONTROL_TEXT)}
disabled={state.envOverride}
onChange={event => setRemoteToken(event.target.value)}
placeholder={state.remoteTokenSet ? `Existing token ${state.remoteTokenPreview ?? 'saved'}` : 'Paste session token'}
placeholder={
state.remoteTokenSet ? `Existing token ${state.remoteTokenPreview ?? 'saved'}` : 'Paste session token'
}
type="password"
value={remoteToken}
/>
@@ -262,7 +271,11 @@ export function GatewaySettings() {
{lastTest ? <div className="mt-4 text-xs text-primary">{lastTest}</div> : null}
<div className="mt-6 flex flex-wrap justify-end gap-3">
<Button disabled={state.envOverride || testing || !canUseRemote} onClick={() => void testRemote()} variant="outline">
<Button
disabled={state.envOverride || testing || !canUseRemote}
onClick={() => void testRemote()}
variant="outline"
>
{testing ? <Loader2 className="size-4 animate-spin" /> : null}
Test remote
</Button>
+2 -1
View File
@@ -43,11 +43,12 @@ function safeSet(target: Record<string, unknown>, key: string, value: unknown):
if (key === '__proto__' || key === 'constructor' || key === 'prototype' || !key) {
throw new Error(`Unsafe config key: ${key}`)
}
Object.defineProperty(target, key, {
value,
writable: true,
enumerable: true,
configurable: true,
configurable: true
})
}
+1
View File
@@ -60,6 +60,7 @@ export function AppShell({
const viewportFullscreen = useSyncExternalStore(subscribeWindowSize, viewportIsFullscreen, () => false)
const isFullscreen = Boolean(connection?.isFullscreen) || viewportFullscreen
const titlebarControls = titlebarControlsPosition(connection?.windowButtonPosition, isFullscreen)
const titlebarContentInset = sidebarOpen
? 0
: titlebarControls.left + TITLEBAR_HEIGHT + Math.round(TITLEBAR_HEIGHT / 2)
@@ -123,8 +123,9 @@ export function useStatusbarItems({
const sha = updateStatus?.currentSha?.slice(0, 7) ?? null
const behind = updateStatus?.behind ?? 0
const applying = updateApply.applying || updateApply.stage === 'restart'
const base = appVersion ? `v${appVersion}` : sha ?? 'unknown'
const base = appVersion ? `v${appVersion}` : (sha ?? 'unknown')
const behindHint = !applying && behind > 0 ? ` (+${behind})` : ''
const label = applying
? updateApply.stage === 'restart'
? `${base} · restart`
@@ -110,37 +110,37 @@ function StatusbarItemView({ item, navigate }: { item: StatusbarItem; navigate:
: (item.menuItems ?? [])
.filter(menuItem => !menuItem.hidden)
.map(menuItem => (
<DropdownMenuItem
className={cn('gap-2 text-foreground focus:bg-accent [&_svg]:size-4', menuItem.className)}
disabled={menuItem.disabled}
key={menuItem.id}
onSelect={() => {
if (menuItem.to) {
navigate(menuItem.to)
}
<DropdownMenuItem
className={cn('gap-2 text-foreground focus:bg-accent [&_svg]:size-4', menuItem.className)}
disabled={menuItem.disabled}
key={menuItem.id}
onSelect={() => {
if (menuItem.to) {
navigate(menuItem.to)
}
menuItem.onSelect?.()
}}
>
{menuItem.href ? (
<a
className="inline-flex w-full items-center gap-2"
href={menuItem.href}
rel="noreferrer"
target="_blank"
title={menuItem.title ?? menuItem.label}
menuItem.onSelect?.()
}}
>
{menuItem.icon}
<span className="truncate">{menuItem.label}</span>
</a>
) : (
<>
{menuItem.icon}
<span className="truncate">{menuItem.label}</span>
</>
)}
</DropdownMenuItem>
))}
{menuItem.href ? (
<a
className="inline-flex w-full items-center gap-2"
href={menuItem.href}
rel="noreferrer"
target="_blank"
title={menuItem.title ?? menuItem.label}
>
{menuItem.icon}
<span className="truncate">{menuItem.label}</span>
</a>
) : (
<>
{menuItem.icon}
<span className="truncate">{menuItem.label}</span>
</>
)}
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
)
+1 -4
View File
@@ -379,10 +379,7 @@ function CategoryButton({
type="button"
>
<span
className={cn(
'underline-offset-4 decoration-current',
active ? 'font-medium underline' : 'hover:underline'
)}
className={cn('underline-offset-4 decoration-current', active ? 'font-medium underline' : 'hover:underline')}
>
{label}
</span>
+10 -7
View File
@@ -46,11 +46,9 @@ export function UpdatesOverlay() {
}, [checking, open, status])
const behind = status?.behind ?? 0
const phase: 'idle' | 'applying' | 'error' = apply.applying || apply.stage === 'restart'
? 'applying'
: apply.stage === 'error'
? 'error'
: 'idle'
const phase: 'idle' | 'applying' | 'error' =
apply.applying || apply.stage === 'restart' ? 'applying' : apply.stage === 'error' ? 'error' : 'idle'
const handleClose = (next: boolean) => {
if (phase === 'applying') {
@@ -114,7 +112,9 @@ function IdleView({
status: DesktopUpdateStatus | null
}) {
if (!status && checking) {
return <CenteredStatus icon={<Loader2 className="size-6 animate-spin text-primary" />} title="Looking for updates…" />
return (
<CenteredStatus icon={<Loader2 className="size-6 animate-spin text-primary" />} title="Looking for updates…" />
)
}
if (!status) {
@@ -223,7 +223,9 @@ function IdleView({
</div>
{remaining > 0 && (
<p className="text-center text-xs text-muted-foreground">+ {remaining} more change{remaining === 1 ? '' : 's'} included.</p>
<p className="text-center text-xs text-muted-foreground">
+ {remaining} more change{remaining === 1 ? '' : 's'} included.
</p>
)}
</div>
)
@@ -231,6 +233,7 @@ function IdleView({
function ApplyingView({ apply }: { apply: UpdateApplyState }) {
const label = STAGE_LABELS[apply.stage] ?? 'Updating Hermes…'
const percent =
typeof apply.percent === 'number' && Number.isFinite(apply.percent)
? Math.max(2, Math.min(100, Math.round(apply.percent)))