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:
@@ -49,6 +49,7 @@ describe('collectArtifactsForSession', () => {
|
||||
timestamp: 3000
|
||||
}
|
||||
]
|
||||
|
||||
const artifacts = collectArtifactsForSession(makeSession({ id: 'session-2' }), messages)
|
||||
|
||||
expect(artifacts).toHaveLength(1)
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
@@ -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({
|
||||
|
||||
@@ -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 })
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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)))
|
||||
|
||||
Reference in New Issue
Block a user