feat: file preview and folder tree etc
This commit is contained in:
@@ -0,0 +1,52 @@
|
||||
import { type MutableRefObject, useCallback, useEffect } from 'react'
|
||||
|
||||
import { $currentCwd, setContextSuggestions } from '@/store/session'
|
||||
|
||||
import type { ContextSuggestion } from '../../types'
|
||||
|
||||
interface ContextSuggestionsOptions {
|
||||
activeSessionId: string | null
|
||||
activeSessionIdRef: MutableRefObject<string | null>
|
||||
currentCwd: string
|
||||
gatewayState: string | undefined
|
||||
requestGateway: <T = unknown>(method: string, params?: Record<string, unknown>) => Promise<T>
|
||||
}
|
||||
|
||||
export function useContextSuggestions({
|
||||
activeSessionId,
|
||||
activeSessionIdRef,
|
||||
currentCwd,
|
||||
gatewayState,
|
||||
requestGateway
|
||||
}: ContextSuggestionsOptions) {
|
||||
const refresh = useCallback(async () => {
|
||||
if (!activeSessionId) {
|
||||
setContextSuggestions([])
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
const sessionId = activeSessionId
|
||||
const cwd = currentCwd || ''
|
||||
|
||||
// Race guard: only commit if the session+cwd we sent for still match
|
||||
// by the time the gateway responds.
|
||||
const stillCurrent = () => activeSessionIdRef.current === sessionId && $currentCwd.get() === cwd
|
||||
|
||||
try {
|
||||
const result = await requestGateway<{ items?: ContextSuggestion[] }>('complete.path', {
|
||||
session_id: sessionId,
|
||||
word: '@file:',
|
||||
cwd: cwd || undefined
|
||||
})
|
||||
|
||||
if (stillCurrent()) {setContextSuggestions((result.items || []).filter(i => i.text))}
|
||||
} catch {
|
||||
if (stillCurrent()) {setContextSuggestions([])}
|
||||
}
|
||||
}, [activeSessionId, activeSessionIdRef, currentCwd, requestGateway])
|
||||
|
||||
useEffect(() => {
|
||||
if (gatewayState === 'open' && activeSessionId) {void refresh()}
|
||||
}, [activeSessionId, gatewayState, refresh])
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
import { type MutableRefObject, useCallback } from 'react'
|
||||
|
||||
import { notify, notifyError } from '@/store/notifications'
|
||||
import { $currentCwd, setCurrentBranch, setCurrentCwd } from '@/store/session'
|
||||
import type { SessionRuntimeInfo } from '@/types/hermes'
|
||||
|
||||
interface CwdActionsOptions {
|
||||
activeSessionId: string | null
|
||||
activeSessionIdRef: MutableRefObject<string | null>
|
||||
currentCwd: string
|
||||
requestGateway: <T = unknown>(method: string, params?: Record<string, unknown>) => Promise<T>
|
||||
}
|
||||
|
||||
export function useCwdActions({ activeSessionId, activeSessionIdRef, currentCwd, requestGateway }: CwdActionsOptions) {
|
||||
const refreshProjectBranch = useCallback(
|
||||
async (cwd: string) => {
|
||||
const target = cwd.trim()
|
||||
|
||||
if (!target || activeSessionIdRef.current) {return}
|
||||
|
||||
try {
|
||||
const info = await requestGateway<{ branch?: string; cwd?: string }>('config.get', { key: 'project', cwd: target })
|
||||
|
||||
if (!activeSessionIdRef.current && ($currentCwd.get() || target) === (info.cwd || target)) {
|
||||
setCurrentBranch(info.branch || '')
|
||||
}
|
||||
} catch {
|
||||
setCurrentBranch('')
|
||||
}
|
||||
},
|
||||
[activeSessionIdRef, requestGateway]
|
||||
)
|
||||
|
||||
const changeSessionCwd = useCallback(
|
||||
async (cwd: string) => {
|
||||
const trimmed = cwd.trim()
|
||||
|
||||
if (!trimmed) {return}
|
||||
|
||||
const persistGlobal = async () => {
|
||||
const info = await requestGateway<{ branch?: string; cwd?: string; value?: string }>('config.set', {
|
||||
...(activeSessionId && { session_id: activeSessionId }),
|
||||
key: 'terminal.cwd',
|
||||
value: trimmed
|
||||
})
|
||||
|
||||
setCurrentCwd(info.cwd || info.value || trimmed)
|
||||
|
||||
if (!activeSessionId) {setCurrentBranch(info.branch || '')}
|
||||
}
|
||||
|
||||
if (!activeSessionId) {
|
||||
try {
|
||||
await persistGlobal()
|
||||
} catch (err) {
|
||||
notifyError(err, 'Working directory change failed')
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const info = await requestGateway<SessionRuntimeInfo>('session.cwd.set', {
|
||||
session_id: activeSessionId,
|
||||
cwd: trimmed
|
||||
})
|
||||
|
||||
setCurrentCwd(info.cwd || trimmed)
|
||||
setCurrentBranch(info.branch || '')
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err)
|
||||
|
||||
// Older gateways without `session.cwd.set` fall back to a global write —
|
||||
// user has to restart the active session for it to take effect.
|
||||
if (!message.includes('unknown method')) {
|
||||
notifyError(err, 'Working directory change failed')
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
await persistGlobal()
|
||||
notify({
|
||||
kind: 'warning',
|
||||
title: 'Working directory saved',
|
||||
message: 'Restart the desktop backend to apply cwd changes to this active session.'
|
||||
})
|
||||
} catch (fallbackErr) {
|
||||
notifyError(fallbackErr, 'Working directory change failed')
|
||||
}
|
||||
}
|
||||
},
|
||||
[activeSessionId, requestGateway]
|
||||
)
|
||||
|
||||
const browseSessionCwd = useCallback(async () => {
|
||||
const paths = await window.hermesDesktop?.selectPaths({
|
||||
title: 'Change working directory',
|
||||
defaultPath: currentCwd || undefined,
|
||||
directories: true,
|
||||
multiple: false
|
||||
})
|
||||
|
||||
if (paths?.[0]) {await changeSessionCwd(paths[0])}
|
||||
}, [changeSessionCwd, currentCwd])
|
||||
|
||||
return { browseSessionCwd, changeSessionCwd, refreshProjectBranch }
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
import { type MutableRefObject, useCallback, useState } from 'react'
|
||||
|
||||
import { getHermesConfig, getHermesConfigDefaults } from '@/hermes'
|
||||
import { BUILTIN_PERSONALITIES, normalizePersonalityValue, personalityNamesFromConfig } from '@/lib/chat-runtime'
|
||||
import {
|
||||
$currentCwd,
|
||||
setAvailablePersonalities,
|
||||
setCurrentCwd,
|
||||
setCurrentFastMode,
|
||||
setCurrentPersonality,
|
||||
setCurrentReasoningEffort,
|
||||
setCurrentServiceTier,
|
||||
setIntroPersonality
|
||||
} from '@/store/session'
|
||||
|
||||
const DEFAULT_VOICE_SECONDS = 120
|
||||
const FAST_TIERS = new Set(['fast', 'priority', 'on'])
|
||||
|
||||
function recordingLimit(value: unknown) {
|
||||
return typeof value === 'number' && Number.isFinite(value) && value > 0 ? value : DEFAULT_VOICE_SECONDS
|
||||
}
|
||||
|
||||
interface HermesConfigOptions {
|
||||
activeSessionIdRef: MutableRefObject<string | null>
|
||||
refreshProjectBranch: (cwd: string) => Promise<void>
|
||||
}
|
||||
|
||||
export function useHermesConfig({ activeSessionIdRef, refreshProjectBranch }: HermesConfigOptions) {
|
||||
const [voiceMaxRecordingSeconds, setVoiceMaxRecordingSeconds] = useState(DEFAULT_VOICE_SECONDS)
|
||||
const [sttEnabled, setSttEnabled] = useState(true)
|
||||
|
||||
const refreshHermesConfig = useCallback(async () => {
|
||||
try {
|
||||
const [config, defaults] = await Promise.all([getHermesConfig(), getHermesConfigDefaults().catch(() => ({}))])
|
||||
|
||||
const personality = normalizePersonalityValue(
|
||||
typeof config.display?.personality === 'string' ? config.display.personality : ''
|
||||
)
|
||||
|
||||
setIntroPersonality(personality)
|
||||
// Active sessions keep their per-session value; standalone falls back to config.
|
||||
setCurrentPersonality(prev => (activeSessionIdRef.current ? prev || personality : personality))
|
||||
setAvailablePersonalities([
|
||||
...new Set([
|
||||
'none',
|
||||
...BUILTIN_PERSONALITIES,
|
||||
...personalityNamesFromConfig(defaults),
|
||||
...personalityNamesFromConfig(config)
|
||||
])
|
||||
])
|
||||
|
||||
const cwd = (config.terminal?.cwd ?? '').trim()
|
||||
|
||||
if (cwd && cwd !== '.') {
|
||||
setCurrentCwd(prev => prev || cwd)
|
||||
void refreshProjectBranch($currentCwd.get() || cwd)
|
||||
}
|
||||
|
||||
const reasoning = (config.agent?.reasoning_effort ?? '').trim()
|
||||
const tier = (config.agent?.service_tier ?? '').trim()
|
||||
|
||||
setCurrentReasoningEffort(prev => (activeSessionIdRef.current ? prev : reasoning))
|
||||
setCurrentServiceTier(prev => (activeSessionIdRef.current ? prev : tier))
|
||||
setCurrentFastMode(prev => (activeSessionIdRef.current ? prev : FAST_TIERS.has(tier.toLowerCase())))
|
||||
|
||||
setVoiceMaxRecordingSeconds(recordingLimit(config.voice?.max_recording_seconds))
|
||||
setSttEnabled(config.stt?.enabled !== false)
|
||||
} catch {
|
||||
// Config is nice-to-have; chat still works without it.
|
||||
}
|
||||
}, [activeSessionIdRef, refreshProjectBranch])
|
||||
|
||||
return { refreshHermesConfig, sttEnabled, voiceMaxRecordingSeconds }
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
import { type QueryClient } from '@tanstack/react-query'
|
||||
import { useCallback } from 'react'
|
||||
|
||||
import { getGlobalModelInfo, setGlobalModel } from '@/hermes'
|
||||
import { notifyError } from '@/store/notifications'
|
||||
import { setCurrentModel, setCurrentProvider } from '@/store/session'
|
||||
import type { ModelOptionsResponse } from '@/types/hermes'
|
||||
|
||||
interface ModelSelection {
|
||||
model: string
|
||||
persistGlobal: boolean
|
||||
provider: string
|
||||
}
|
||||
|
||||
interface ModelControlsOptions {
|
||||
activeSessionId: string | null
|
||||
queryClient: QueryClient
|
||||
requestGateway: <T = unknown>(method: string, params?: Record<string, unknown>) => Promise<T>
|
||||
}
|
||||
|
||||
export function useModelControls({ activeSessionId, queryClient, requestGateway }: ModelControlsOptions) {
|
||||
const updateModelOptionsCache = useCallback(
|
||||
(provider: string, model: string, includeGlobal: boolean) => {
|
||||
const patch = (prev: ModelOptionsResponse | undefined) => ({ ...(prev ?? {}), provider, model })
|
||||
|
||||
queryClient.setQueryData<ModelOptionsResponse>(['model-options', activeSessionId || 'global'], patch)
|
||||
|
||||
if (includeGlobal) {queryClient.setQueryData<ModelOptionsResponse>(['model-options', 'global'], patch)}
|
||||
},
|
||||
[activeSessionId, queryClient]
|
||||
)
|
||||
|
||||
const refreshCurrentModel = useCallback(async () => {
|
||||
try {
|
||||
const result = await getGlobalModelInfo()
|
||||
|
||||
if (typeof result.model === 'string') {setCurrentModel(result.model)}
|
||||
|
||||
if (typeof result.provider === 'string') {setCurrentProvider(result.provider)}
|
||||
} catch {
|
||||
// The delayed session.info event still updates this once the agent is ready.
|
||||
}
|
||||
}, [])
|
||||
|
||||
const selectModel = useCallback(
|
||||
(selection: ModelSelection) => {
|
||||
setCurrentModel(selection.model)
|
||||
setCurrentProvider(selection.provider)
|
||||
updateModelOptionsCache(selection.provider, selection.model, selection.persistGlobal || !activeSessionId)
|
||||
|
||||
void (async () => {
|
||||
try {
|
||||
if (activeSessionId) {
|
||||
await requestGateway('slash.exec', {
|
||||
session_id: activeSessionId,
|
||||
command: `/model ${selection.model} --provider ${selection.provider}${selection.persistGlobal ? ' --global' : ''}`
|
||||
})
|
||||
|
||||
if (selection.persistGlobal) {void refreshCurrentModel()}
|
||||
void queryClient.invalidateQueries({
|
||||
queryKey: selection.persistGlobal ? ['model-options'] : ['model-options', activeSessionId]
|
||||
})
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
await setGlobalModel(selection.provider, selection.model)
|
||||
void refreshCurrentModel()
|
||||
void queryClient.invalidateQueries({ queryKey: ['model-options'] })
|
||||
} catch (err) {
|
||||
notifyError(err, 'Model switch failed')
|
||||
}
|
||||
})()
|
||||
},
|
||||
[activeSessionId, queryClient, refreshCurrentModel, requestGateway, updateModelOptionsCache]
|
||||
)
|
||||
|
||||
return { refreshCurrentModel, selectModel, updateModelOptionsCache }
|
||||
}
|
||||
@@ -0,0 +1,144 @@
|
||||
import { act, cleanup, render, waitFor } from '@testing-library/react'
|
||||
import { useEffect, useRef } from 'react'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { assistantTextPart, type ChatMessage } from '@/lib/chat-messages'
|
||||
import {
|
||||
$previewTarget,
|
||||
clearSessionPreviewRegistry,
|
||||
type PreviewTarget,
|
||||
registerSessionPreview
|
||||
} from '@/store/preview'
|
||||
import { $currentCwd, $messages } from '@/store/session'
|
||||
import type { RpcEvent } from '@/types/hermes'
|
||||
|
||||
import { usePreviewRouting } from './use-preview-routing'
|
||||
|
||||
function assistantMessage(id: string, text: string): ChatMessage {
|
||||
return {
|
||||
id,
|
||||
parts: [assistantTextPart(text)],
|
||||
role: 'assistant'
|
||||
}
|
||||
}
|
||||
|
||||
function previewTarget(source: string): PreviewTarget {
|
||||
const isUrl = /^https?:\/\//i.test(source)
|
||||
|
||||
return {
|
||||
kind: isUrl ? 'url' : 'file',
|
||||
label: source,
|
||||
path: isUrl ? undefined : source,
|
||||
previewKind: isUrl ? undefined : 'html',
|
||||
source,
|
||||
url: isUrl ? source : `file://${source}`
|
||||
}
|
||||
}
|
||||
|
||||
let handleEvent: (event: RpcEvent) => void = () => undefined
|
||||
|
||||
function PreviewRoutingHarness({ onEvent }: { onEvent: (handler: (event: RpcEvent) => void) => void }) {
|
||||
const activeSessionIdRef = useRef<string | null>('session-1')
|
||||
|
||||
const routing = usePreviewRouting({
|
||||
activeSessionIdRef,
|
||||
baseHandleGatewayEvent: vi.fn(),
|
||||
currentCwd: '/work',
|
||||
currentView: 'chat',
|
||||
requestGateway: vi.fn(),
|
||||
routedSessionId: 'session-1',
|
||||
selectedStoredSessionId: null
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
onEvent(routing.handleDesktopGatewayEvent)
|
||||
}, [onEvent, routing.handleDesktopGatewayEvent])
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
describe('usePreviewRouting', () => {
|
||||
beforeEach(() => {
|
||||
$currentCwd.set('/work')
|
||||
$messages.set([])
|
||||
$previewTarget.set(null)
|
||||
window.localStorage.clear()
|
||||
clearSessionPreviewRegistry()
|
||||
handleEvent = () => undefined
|
||||
|
||||
Object.defineProperty(window, 'hermesDesktop', {
|
||||
configurable: true,
|
||||
value: {
|
||||
normalizePreviewTarget: vi.fn(async (target: string) => previewTarget(target))
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
cleanup()
|
||||
$messages.set([])
|
||||
$previewTarget.set(null)
|
||||
window.localStorage.clear()
|
||||
clearSessionPreviewRegistry()
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
it('opens the active session preview from the registry', async () => {
|
||||
const target = previewTarget('/work/demo.html')
|
||||
|
||||
registerSessionPreview('session-1', target, 'tool-result')
|
||||
render(<PreviewRoutingHarness onEvent={handler => { handleEvent = handler }} />)
|
||||
|
||||
await waitFor(() => {
|
||||
expect($previewTarget.get()).toEqual({ ...target, renderMode: 'preview' })
|
||||
})
|
||||
})
|
||||
|
||||
it('does not infer previews from assistant prose', async () => {
|
||||
render(<PreviewRoutingHarness onEvent={handler => { handleEvent = handler }} />)
|
||||
|
||||
act(() => {
|
||||
$messages.set([
|
||||
assistantMessage('a1', 'Preview: http://localhost:5173/'),
|
||||
assistantMessage('a2', 'Open /work/demo.html')
|
||||
])
|
||||
})
|
||||
|
||||
expect($previewTarget.get()).toBeNull()
|
||||
expect(window.hermesDesktop.normalizePreviewTarget).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('registers structured tool-result preview targets', async () => {
|
||||
render(<PreviewRoutingHarness onEvent={handler => { handleEvent = handler }} />)
|
||||
|
||||
act(() =>
|
||||
handleEvent({
|
||||
payload: { path: './dist/index.html' },
|
||||
session_id: 'session-1',
|
||||
type: 'tool.complete'
|
||||
})
|
||||
)
|
||||
|
||||
await waitFor(() => {
|
||||
expect($previewTarget.get()?.source).toBe('./dist/index.html')
|
||||
})
|
||||
|
||||
expect(window.localStorage.getItem('hermes.desktop.sessionPreviews.v1')).toContain('./dist/index.html')
|
||||
})
|
||||
|
||||
it('registers html previews from edit inline diffs', async () => {
|
||||
render(<PreviewRoutingHarness onEvent={handler => { handleEvent = handler }} />)
|
||||
|
||||
act(() =>
|
||||
handleEvent({
|
||||
payload: { inline_diff: '\u001b[38;2;218;165;32ma/preview-demo.html -> b/preview-demo.html\u001b[0m\n' },
|
||||
session_id: 'session-1',
|
||||
type: 'tool.complete'
|
||||
})
|
||||
)
|
||||
|
||||
await waitFor(() => {
|
||||
expect($previewTarget.get()?.source).toBe('preview-demo.html')
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,195 @@
|
||||
import { useStore } from '@nanostores/react'
|
||||
import { type MutableRefObject, useCallback, useEffect } from 'react'
|
||||
|
||||
import { gatewayEventCompletedFileDiff } from '@/lib/gateway-events'
|
||||
import {
|
||||
$previewTarget,
|
||||
$sessionPreviewRegistry,
|
||||
beginPreviewServerRestart,
|
||||
completePreviewServerRestart,
|
||||
getSessionPreviewRecord,
|
||||
progressPreviewServerRestart,
|
||||
requestPreviewReload,
|
||||
setPreviewTarget,
|
||||
setSessionPreviewTarget
|
||||
} from '@/store/preview'
|
||||
import { $currentCwd } from '@/store/session'
|
||||
import type { RpcEvent } from '@/types/hermes'
|
||||
|
||||
type EventHandler = (event: RpcEvent) => void
|
||||
|
||||
interface PreviewRoutingOptions {
|
||||
activeSessionIdRef: MutableRefObject<string | null>
|
||||
baseHandleGatewayEvent: EventHandler
|
||||
currentCwd: string
|
||||
currentView: string
|
||||
requestGateway: <T = unknown>(method: string, params?: Record<string, unknown>) => Promise<T>
|
||||
routedSessionId: string | null
|
||||
selectedStoredSessionId: string | null
|
||||
}
|
||||
|
||||
function asRecord(payload: unknown): Record<string, unknown> {
|
||||
return payload && typeof payload === 'object' ? (payload as Record<string, unknown>) : {}
|
||||
}
|
||||
|
||||
function activePreviewSessionId(
|
||||
activeSessionIdRef: MutableRefObject<string | null>,
|
||||
routedSessionId: string | null,
|
||||
selectedStoredSessionId: string | null
|
||||
): string {
|
||||
return selectedStoredSessionId || routedSessionId || activeSessionIdRef.current || ''
|
||||
}
|
||||
|
||||
function looksLikePreviewTarget(value: string): boolean {
|
||||
return /^https?:\/\//i.test(value) || /^file:\/\//i.test(value) || /^(?:\/|\.{1,2}\/|~\/).+/.test(value)
|
||||
}
|
||||
|
||||
function stripAnsi(value: string): string {
|
||||
return value.replace(new RegExp(`${String.fromCharCode(27)}\\[[0-9;]*m`, 'g'), '')
|
||||
}
|
||||
|
||||
function htmlPathFromInlineDiff(value: string): string {
|
||||
const cleaned = stripAnsi(value).replace(/^\s*┊\s*review diff\s*\n/i, '')
|
||||
|
||||
for (const match of cleaned.matchAll(/(?:^|\s)(?:[ab]\/)?([^\s]+\.html?)(?=\s|$)/gi)) {
|
||||
const candidate = match[1]?.trim()
|
||||
|
||||
if (candidate) {
|
||||
return candidate
|
||||
}
|
||||
}
|
||||
|
||||
return ''
|
||||
}
|
||||
|
||||
function structuredPreviewCandidate(payload: unknown): string {
|
||||
const record = asRecord(payload)
|
||||
const fields = ['url', 'target', 'path', 'file', 'filepath', 'preview']
|
||||
|
||||
for (const field of fields) {
|
||||
const value = record[field]
|
||||
|
||||
if (typeof value === 'string') {
|
||||
const target = value.trim()
|
||||
|
||||
if (target && looksLikePreviewTarget(target)) {
|
||||
return target
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const inlineDiff = record.inline_diff
|
||||
|
||||
if (typeof inlineDiff === 'string') {
|
||||
return htmlPathFromInlineDiff(inlineDiff)
|
||||
}
|
||||
|
||||
return ''
|
||||
}
|
||||
|
||||
export function usePreviewRouting({
|
||||
activeSessionIdRef,
|
||||
baseHandleGatewayEvent,
|
||||
currentCwd,
|
||||
currentView,
|
||||
requestGateway,
|
||||
routedSessionId,
|
||||
selectedStoredSessionId
|
||||
}: PreviewRoutingOptions) {
|
||||
const previewRegistry = useStore($sessionPreviewRegistry)
|
||||
const previewSessionId = activePreviewSessionId(activeSessionIdRef, routedSessionId, selectedStoredSessionId)
|
||||
|
||||
useEffect(() => {
|
||||
if (currentView !== 'chat' || !previewSessionId) {
|
||||
setPreviewTarget(null)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
const record = getSessionPreviewRecord(previewSessionId)
|
||||
|
||||
setPreviewTarget(record?.normalized ?? null)
|
||||
}, [currentView, previewRegistry, previewSessionId])
|
||||
|
||||
const registerStructuredPreview = useCallback(
|
||||
async (event: RpcEvent) => {
|
||||
if (event.session_id && event.session_id !== activeSessionIdRef.current && event.session_id !== previewSessionId) {return}
|
||||
|
||||
if (!event.type.startsWith('tool.')) {return}
|
||||
|
||||
if (!previewSessionId) {return}
|
||||
|
||||
const candidate = structuredPreviewCandidate(event.payload)
|
||||
|
||||
if (!candidate) {return}
|
||||
const desktop = window.hermesDesktop
|
||||
|
||||
if (!desktop?.normalizePreviewTarget) {return}
|
||||
const sessionId = previewSessionId
|
||||
const cwd = currentCwd || ''
|
||||
const target = await desktop.normalizePreviewTarget(candidate, cwd || undefined).catch(() => null)
|
||||
|
||||
if (
|
||||
!target ||
|
||||
sessionId !== activePreviewSessionId(activeSessionIdRef, routedSessionId, selectedStoredSessionId) ||
|
||||
$currentCwd.get() !== cwd
|
||||
) {
|
||||
return
|
||||
}
|
||||
|
||||
setSessionPreviewTarget(sessionId, target, 'tool-result', candidate)
|
||||
},
|
||||
[activeSessionIdRef, currentCwd, previewSessionId, routedSessionId, selectedStoredSessionId]
|
||||
)
|
||||
|
||||
const restartPreviewServer = useCallback(
|
||||
async (url: string, context?: string) => {
|
||||
const sessionId = activeSessionIdRef.current
|
||||
|
||||
if (!sessionId) {throw new Error('No active session for background restart')}
|
||||
|
||||
const cwd = $currentCwd.get() || currentCwd || ''
|
||||
|
||||
const result = await requestGateway<{ task_id?: string }>('preview.restart', {
|
||||
context: context || undefined,
|
||||
cwd: cwd || undefined,
|
||||
session_id: sessionId,
|
||||
url
|
||||
})
|
||||
|
||||
const taskId = result.task_id || ''
|
||||
|
||||
if (!taskId) {throw new Error('Background restart did not return a task id')}
|
||||
|
||||
beginPreviewServerRestart(taskId, url)
|
||||
|
||||
return taskId
|
||||
},
|
||||
[activeSessionIdRef, currentCwd, requestGateway]
|
||||
)
|
||||
|
||||
const handleDesktopGatewayEvent = useCallback<EventHandler>(
|
||||
event => {
|
||||
baseHandleGatewayEvent(event)
|
||||
|
||||
if (event.type === 'preview.restart.complete') {
|
||||
const { task_id, text } = asRecord(event.payload)
|
||||
|
||||
if (typeof task_id === 'string' && task_id) {completePreviewServerRestart(task_id, typeof text === 'string' ? text : '')}
|
||||
} else if (event.type === 'preview.restart.progress') {
|
||||
const { task_id, text } = asRecord(event.payload)
|
||||
|
||||
if (typeof task_id === 'string' && task_id) {progressPreviewServerRestart(task_id, typeof text === 'string' ? text : '')}
|
||||
}
|
||||
|
||||
if (event.session_id && event.session_id !== activeSessionIdRef.current) {return}
|
||||
|
||||
void registerStructuredPreview(event)
|
||||
|
||||
if ($previewTarget.get()?.kind === 'url' && gatewayEventCompletedFileDiff(event)) {requestPreviewReload()}
|
||||
},
|
||||
[activeSessionIdRef, baseHandleGatewayEvent, registerStructuredPreview]
|
||||
)
|
||||
|
||||
return { handleDesktopGatewayEvent, restartPreviewServer }
|
||||
}
|
||||
@@ -188,7 +188,7 @@ export function usePromptActions({
|
||||
.join('\n')
|
||||
|
||||
const hasImageAttachment = attachments.some(attachment => attachment.kind === 'image')
|
||||
const displayRefs = attachments.map(attachmentDisplayText).filter(Boolean).join('\n')
|
||||
const attachmentRefs = attachments.map(attachmentDisplayText).filter((ref): ref is string => Boolean(ref))
|
||||
|
||||
const text =
|
||||
[contextRefs, visibleText].filter(Boolean).join('\n\n') ||
|
||||
@@ -201,12 +201,8 @@ export function usePromptActions({
|
||||
const userMessage: ChatMessage = {
|
||||
id: `user-${Date.now()}`,
|
||||
role: 'user',
|
||||
parts: [
|
||||
textPart(
|
||||
[displayRefs, visibleText].filter(Boolean).join('\n\n') ||
|
||||
attachments.map(attachment => attachment.label).join(', ')
|
||||
)
|
||||
]
|
||||
parts: [textPart(visibleText || (attachmentRefs.length ? '' : attachments.map(a => a.label).join(', ')))],
|
||||
attachmentRefs
|
||||
}
|
||||
|
||||
const releaseBusy = () => {
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
import { type MutableRefObject, useEffect } from 'react'
|
||||
|
||||
import { isNewChatRoute } from '@/app/routes'
|
||||
|
||||
interface RouteResumeOptions {
|
||||
activeSessionId: string | null
|
||||
activeSessionIdRef: MutableRefObject<string | null>
|
||||
creatingSessionRef: MutableRefObject<boolean>
|
||||
currentView: string
|
||||
freshDraftReady: boolean
|
||||
gatewayState: string | undefined
|
||||
locationPathname: string
|
||||
resumeSession: (sessionId: string, focus: boolean) => Promise<unknown>
|
||||
routedSessionId: string | null
|
||||
runtimeIdByStoredSessionIdRef: MutableRefObject<Map<string, string>>
|
||||
selectedStoredSessionId: string | null
|
||||
selectedStoredSessionIdRef: MutableRefObject<string | null>
|
||||
startFreshSessionDraft: (focus: boolean) => unknown
|
||||
}
|
||||
|
||||
// HashRouter boot edge case: pathname briefly reads `/` before the hash is
|
||||
// parsed. If the hash references a real session, defer; resume picks it up
|
||||
// next tick. Without this, ctrl+R on `#/:sessionId` flashes 5 loading states.
|
||||
function rawHashLooksLikeSession(): boolean {
|
||||
if (typeof window === 'undefined') {return false}
|
||||
const hash = window.location.hash.replace(/^#/, '')
|
||||
|
||||
if (!hash || hash === '/') {return false}
|
||||
|
||||
return !hash.startsWith('/settings') && !hash.startsWith('/skills') && !hash.startsWith('/artifacts')
|
||||
}
|
||||
|
||||
export function useRouteResume({
|
||||
activeSessionId,
|
||||
activeSessionIdRef,
|
||||
creatingSessionRef,
|
||||
currentView,
|
||||
freshDraftReady,
|
||||
gatewayState,
|
||||
locationPathname,
|
||||
resumeSession,
|
||||
routedSessionId,
|
||||
runtimeIdByStoredSessionIdRef,
|
||||
selectedStoredSessionId,
|
||||
selectedStoredSessionIdRef,
|
||||
startFreshSessionDraft
|
||||
}: RouteResumeOptions) {
|
||||
useEffect(() => {
|
||||
if (currentView !== 'chat' || gatewayState !== 'open') {return}
|
||||
|
||||
if (routedSessionId) {
|
||||
const cachedRuntime = runtimeIdByStoredSessionIdRef.current.get(routedSessionId)
|
||||
|
||||
const alreadyActive =
|
||||
routedSessionId === selectedStoredSessionIdRef.current &&
|
||||
Boolean(cachedRuntime) &&
|
||||
cachedRuntime === activeSessionIdRef.current
|
||||
|
||||
if (!alreadyActive) {void resumeSession(routedSessionId, true)}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
if (
|
||||
isNewChatRoute(locationPathname) &&
|
||||
!creatingSessionRef.current &&
|
||||
(selectedStoredSessionId || activeSessionId || !freshDraftReady) &&
|
||||
!rawHashLooksLikeSession()
|
||||
) {
|
||||
startFreshSessionDraft(true)
|
||||
}
|
||||
}, [
|
||||
activeSessionId,
|
||||
activeSessionIdRef,
|
||||
creatingSessionRef,
|
||||
currentView,
|
||||
freshDraftReady,
|
||||
gatewayState,
|
||||
locationPathname,
|
||||
resumeSession,
|
||||
routedSessionId,
|
||||
runtimeIdByStoredSessionIdRef,
|
||||
selectedStoredSessionId,
|
||||
selectedStoredSessionIdRef,
|
||||
startFreshSessionDraft
|
||||
])
|
||||
}
|
||||
@@ -28,8 +28,8 @@ import {
|
||||
setIntroSeed,
|
||||
setMessages,
|
||||
setSelectedStoredSessionId,
|
||||
setSessionStartedAt,
|
||||
setSessions,
|
||||
setSessionStartedAt,
|
||||
setTurnStartedAt
|
||||
} from '@/store/session'
|
||||
import type { SessionCreateResponse, SessionInfo, SessionResumeResponse, UsageStats } from '@/types/hermes'
|
||||
|
||||
Reference in New Issue
Block a user