mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-06-10 08:32:09 +00:00
chore: uptick
This commit is contained in:
parent
fd97a7cba4
commit
fa92720d2c
22 changed files with 1203 additions and 348 deletions
|
|
@ -92,6 +92,7 @@ const MEDIA_MIME_TYPES = {
|
|||
}
|
||||
|
||||
const PREVIEW_HTML_EXTENSIONS = new Set(['.html', '.htm'])
|
||||
const PREVIEW_WATCH_DEBOUNCE_MS = 120
|
||||
const LOCAL_PREVIEW_HOSTS = new Set(['0.0.0.0', '127.0.0.1', '::1', '[::1]', 'localhost'])
|
||||
|
||||
app.setName(APP_NAME)
|
||||
|
|
@ -690,13 +691,23 @@ function sendPreviewFileChanged(payload) {
|
|||
|
||||
function watchPreviewFile(rawUrl) {
|
||||
const filePath = previewFilePathFromUrl(rawUrl)
|
||||
const watchDir = path.dirname(filePath)
|
||||
const targetName = path.basename(filePath)
|
||||
const id = crypto.randomBytes(12).toString('base64url')
|
||||
let timer = null
|
||||
const watcher = fs.watch(filePath, () => {
|
||||
const watcher = fs.watch(watchDir, (_eventType, filename) => {
|
||||
const changedName = filename ? path.basename(String(filename)) : ''
|
||||
|
||||
if (changedName && changedName !== targetName) {
|
||||
return
|
||||
}
|
||||
|
||||
if (timer) clearTimeout(timer)
|
||||
timer = setTimeout(() => {
|
||||
timer = null
|
||||
if (!fileExists(filePath)) return
|
||||
sendPreviewFileChanged({ id, path: filePath, url: pathToFileURL(filePath).toString() })
|
||||
}, 120)
|
||||
}, PREVIEW_WATCH_DEBOUNCE_MS)
|
||||
})
|
||||
|
||||
previewWatchers.set(id, {
|
||||
|
|
|
|||
|
|
@ -68,6 +68,9 @@ interface ChatViewProps extends Omit<React.ComponentProps<'div'>, 'onSubmit'> {
|
|||
onChangeCwd: (cwd: string) => void
|
||||
onBrowseCwd: () => void
|
||||
onOpenModelPicker: () => void
|
||||
onRestartPreviewServer?: (url: string, context?: string) => Promise<string>
|
||||
onSetFastMode: (enabled: boolean) => void
|
||||
onSetReasoningEffort: (effort: string) => void
|
||||
onSelectPersonality: (name: string) => void
|
||||
onThreadMessagesChange: (messages: readonly ThreadMessage[]) => void
|
||||
onEdit: (message: AppendMessage) => Promise<void>
|
||||
|
|
@ -121,6 +124,9 @@ export function ChatView({
|
|||
onChangeCwd,
|
||||
onBrowseCwd,
|
||||
onOpenModelPicker,
|
||||
onRestartPreviewServer,
|
||||
onSetFastMode,
|
||||
onSetReasoningEffort,
|
||||
onSelectPersonality,
|
||||
onThreadMessagesChange,
|
||||
onEdit,
|
||||
|
|
@ -324,12 +330,14 @@ export function ChatView({
|
|||
</div>
|
||||
</div>
|
||||
|
||||
<ChatPreviewRail setTitlebarToolGroup={setTitlebarToolGroup} />
|
||||
<ChatPreviewRail onRestartServer={onRestartPreviewServer} setTitlebarToolGroup={setTitlebarToolGroup} />
|
||||
<ChatRightRail
|
||||
onBrowseCwd={onBrowseCwd}
|
||||
onChangeCwd={onChangeCwd}
|
||||
onOpenModelPicker={onOpenModelPicker}
|
||||
onSelectPersonality={onSelectPersonality}
|
||||
onSetFastMode={onSetFastMode}
|
||||
onSetReasoningEffort={onSetReasoningEffort}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
|
|
|
|||
108
apps/desktop/src/app/chat/right-rail/agent-section.tsx
Normal file
108
apps/desktop/src/app/chat/right-rail/agent-section.tsx
Normal file
|
|
@ -0,0 +1,108 @@
|
|||
'use client'
|
||||
|
||||
import { useMemo } from 'react'
|
||||
|
||||
import { RailActionRow } from './rail-action-row'
|
||||
import { RailSection } from './rail-section'
|
||||
import { type RailSelectOption, RailSelectRow } from './rail-select-row'
|
||||
import { RailToggleRow } from './rail-toggle-row'
|
||||
|
||||
const REASONING_OPTIONS: RailSelectOption[] = [
|
||||
{ value: 'none', label: 'Off' },
|
||||
{ value: 'minimal', label: 'Minimal' },
|
||||
{ value: 'low', label: 'Low' },
|
||||
{ value: 'medium', label: 'Medium' },
|
||||
{ value: 'high', label: 'High' },
|
||||
{ value: 'xhigh', label: 'XHigh' }
|
||||
]
|
||||
|
||||
interface AgentSectionProps {
|
||||
modelLabel: string
|
||||
providerName?: string
|
||||
reasoningEffort: string
|
||||
serviceTier: string
|
||||
fastMode: boolean
|
||||
personality: string
|
||||
personalities: string[]
|
||||
onOpenModelPicker?: () => void
|
||||
onSetReasoningEffort?: (effort: string) => void
|
||||
onSetFastMode?: (enabled: boolean) => void
|
||||
onSelectPersonality?: (name: string) => void
|
||||
}
|
||||
|
||||
export function AgentSection({
|
||||
modelLabel,
|
||||
providerName,
|
||||
reasoningEffort,
|
||||
serviceTier,
|
||||
fastMode,
|
||||
personality,
|
||||
personalities,
|
||||
onOpenModelPicker,
|
||||
onSetReasoningEffort,
|
||||
onSetFastMode,
|
||||
onSelectPersonality
|
||||
}: AgentSectionProps) {
|
||||
const activeReasoning = normalizeReasoningEffort(reasoningEffort)
|
||||
const fastEnabled = fastMode || ['fast', 'priority'].includes(serviceTier.trim().toLowerCase())
|
||||
|
||||
const activePersonality = personalityOptionKey(personality)
|
||||
|
||||
const personalityOptions = useMemo<RailSelectOption[]>(
|
||||
() =>
|
||||
[...new Set(['none', ...personalities, personality].map(personalityOptionKey).filter(Boolean))].map(name => ({
|
||||
value: name,
|
||||
label: name === 'none' ? 'None' : titleize(name)
|
||||
})),
|
||||
[personalities, personality]
|
||||
)
|
||||
|
||||
return (
|
||||
<RailSection title="Agent">
|
||||
<RailActionRow
|
||||
ariaLabel="Change model"
|
||||
onClick={onOpenModelPicker}
|
||||
primary={modelLabel || 'Hermes'}
|
||||
secondary={providerName}
|
||||
/>
|
||||
<RailSelectRow
|
||||
ariaLabel="Change reasoning effort"
|
||||
label="Reasoning"
|
||||
menuLabel="Reasoning"
|
||||
onChange={onSetReasoningEffort}
|
||||
options={REASONING_OPTIONS}
|
||||
value={activeReasoning}
|
||||
/>
|
||||
<RailToggleRow checked={fastEnabled} label="Fast mode" onChange={onSetFastMode} />
|
||||
<RailSelectRow
|
||||
ariaLabel="Change personality"
|
||||
label="Personality"
|
||||
menuLabel="Personality"
|
||||
menuWidthClass="w-52"
|
||||
onChange={onSelectPersonality}
|
||||
options={personalityOptions}
|
||||
value={activePersonality}
|
||||
/>
|
||||
</RailSection>
|
||||
)
|
||||
}
|
||||
|
||||
function personalityOptionKey(value?: string): string {
|
||||
const key = value?.trim().toLowerCase() || 'none'
|
||||
|
||||
return key === 'default' ? 'none' : key
|
||||
}
|
||||
|
||||
function normalizeReasoningEffort(value: string): string {
|
||||
const normalized = value.trim().toLowerCase()
|
||||
|
||||
return REASONING_OPTIONS.some(option => option.value === normalized) ? normalized : 'medium'
|
||||
}
|
||||
|
||||
function titleize(value: string): string {
|
||||
return value
|
||||
.replace(/[-_]+/g, ' ')
|
||||
.replace(/\s+/g, ' ')
|
||||
.trim()
|
||||
.replace(/(^|\s)\S/g, m => m.toUpperCase())
|
||||
}
|
||||
|
|
@ -1,19 +1,22 @@
|
|||
import { useStore } from '@nanostores/react'
|
||||
import type * as React from 'react'
|
||||
|
||||
import type { SetTitlebarToolGroup } from '@/app/shell/titlebar-controls'
|
||||
import { SESSION_INSPECTOR_WIDTH, SessionInspector } from '@/components/session-inspector'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { $inspectorOpen } from '@/store/layout'
|
||||
import { $previewTarget } from '@/store/preview'
|
||||
import type { SetTitlebarToolGroup } from '@/app/shell/titlebar-controls'
|
||||
import { $previewReloadRequest, $previewTarget } from '@/store/preview'
|
||||
import {
|
||||
$availablePersonalities,
|
||||
$busy,
|
||||
$currentBranch,
|
||||
$currentCwd,
|
||||
$currentFastMode,
|
||||
$currentModel,
|
||||
$currentPersonality,
|
||||
$currentProvider,
|
||||
$currentReasoningEffort,
|
||||
$currentServiceTier,
|
||||
$gatewayState
|
||||
} from '@/store/session'
|
||||
|
||||
|
|
@ -24,6 +27,8 @@ interface ChatRightRailProps extends Pick<
|
|||
'onBrowseCwd' | 'onChangeCwd'
|
||||
> {
|
||||
onOpenModelPicker: () => void
|
||||
onSetFastMode: (enabled: boolean) => void
|
||||
onSetReasoningEffort: (effort: string) => void
|
||||
onSelectPersonality: (name: string) => void
|
||||
}
|
||||
|
||||
|
|
@ -31,6 +36,8 @@ export function ChatRightRail({
|
|||
onBrowseCwd,
|
||||
onChangeCwd,
|
||||
onOpenModelPicker,
|
||||
onSetFastMode,
|
||||
onSetReasoningEffort,
|
||||
onSelectPersonality
|
||||
}: ChatRightRailProps) {
|
||||
const inspectorOpen = useStore($inspectorOpen)
|
||||
|
|
@ -40,32 +47,51 @@ export function ChatRightRail({
|
|||
const branch = useStore($currentBranch)
|
||||
const model = useStore($currentModel)
|
||||
const provider = useStore($currentProvider)
|
||||
const reasoningEffort = useStore($currentReasoningEffort)
|
||||
const serviceTier = useStore($currentServiceTier)
|
||||
const fastMode = useStore($currentFastMode)
|
||||
const personality = useStore($currentPersonality)
|
||||
const personalities = useStore($availablePersonalities)
|
||||
|
||||
return (
|
||||
<div className="col-start-4 col-end-5 row-start-1 min-w-0 overflow-hidden">
|
||||
<div
|
||||
className={cn(
|
||||
'col-start-4 col-end-5 row-start-1 min-w-0 overflow-hidden',
|
||||
inspectorOpen && 'border-l border-border/60'
|
||||
)}
|
||||
>
|
||||
<SessionInspector
|
||||
branch={branch}
|
||||
busy={busy}
|
||||
cwd={cwd}
|
||||
fastMode={fastMode}
|
||||
modelLabel={model ? model.split('/').pop() || model : ''}
|
||||
modelTitle={provider ? `${provider}: ${model || ''}` : model}
|
||||
onBrowseCwd={onBrowseCwd}
|
||||
onChangeCwd={onChangeCwd}
|
||||
onOpenModelPicker={gatewayOpen ? onOpenModelPicker : undefined}
|
||||
onSelectPersonality={gatewayOpen ? onSelectPersonality : undefined}
|
||||
onSetFastMode={gatewayOpen ? onSetFastMode : undefined}
|
||||
onSetReasoningEffort={gatewayOpen ? onSetReasoningEffort : undefined}
|
||||
open={inspectorOpen}
|
||||
personalities={personalities}
|
||||
personality={personality}
|
||||
providerName={provider}
|
||||
reasoningEffort={reasoningEffort}
|
||||
serviceTier={serviceTier}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function ChatPreviewRail({ setTitlebarToolGroup }: { setTitlebarToolGroup?: SetTitlebarToolGroup }) {
|
||||
const inspectorOpen = useStore($inspectorOpen)
|
||||
export function ChatPreviewRail({
|
||||
onRestartServer,
|
||||
setTitlebarToolGroup
|
||||
}: {
|
||||
onRestartServer?: (url: string, context?: string) => Promise<string>
|
||||
setTitlebarToolGroup?: SetTitlebarToolGroup
|
||||
}) {
|
||||
const previewReloadRequest = useStore($previewReloadRequest)
|
||||
const previewTarget = useStore($previewTarget)
|
||||
|
||||
if (!previewTarget) {
|
||||
|
|
@ -74,12 +100,14 @@ export function ChatPreviewRail({ setTitlebarToolGroup }: { setTitlebarToolGroup
|
|||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'pointer-events-none col-start-3 col-end-4 row-start-1 min-w-0 overflow-hidden',
|
||||
inspectorOpen && 'border-r border-border/60'
|
||||
)}
|
||||
className="pointer-events-none col-start-3 col-end-4 row-start-1 min-w-0 overflow-hidden"
|
||||
>
|
||||
<PreviewPane setTitlebarToolGroup={setTitlebarToolGroup} target={previewTarget} />
|
||||
<PreviewPane
|
||||
onRestartServer={onRestartServer}
|
||||
reloadRequest={previewReloadRequest}
|
||||
setTitlebarToolGroup={setTitlebarToolGroup}
|
||||
target={previewTarget}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
import { useStore } from '@nanostores/react'
|
||||
import { Bug, Check, Copy, PanelBottom, RefreshCw, Send, Trash2, X } from 'lucide-react'
|
||||
import type { CSSProperties, PointerEvent as ReactPointerEvent } from 'react'
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
|
|
@ -6,10 +7,11 @@ import type { SetTitlebarToolGroup, TitlebarTool } from '@/app/shell/titlebar-co
|
|||
import { cn } from '@/lib/utils'
|
||||
import { $composerDraft, setComposerDraft } from '@/store/composer'
|
||||
import { notify, notifyError } from '@/store/notifications'
|
||||
import { type PreviewTarget, setPreviewTarget } from '@/store/preview'
|
||||
import { $previewServerRestart, failPreviewServerRestart, type PreviewTarget, setPreviewTarget } from '@/store/preview'
|
||||
|
||||
type PreviewWebview = HTMLElement & {
|
||||
closeDevTools?: () => void
|
||||
getURL?: () => string
|
||||
isDevToolsOpened?: () => boolean
|
||||
openDevTools?: () => void
|
||||
reload?: () => void
|
||||
|
|
@ -24,6 +26,13 @@ interface ConsoleEntry {
|
|||
source?: string
|
||||
}
|
||||
|
||||
interface PreviewPaneProps {
|
||||
onRestartServer?: (url: string, context?: string) => Promise<string>
|
||||
reloadRequest?: number
|
||||
setTitlebarToolGroup?: SetTitlebarToolGroup
|
||||
target: PreviewTarget
|
||||
}
|
||||
|
||||
interface PreviewLoadErrorState {
|
||||
code?: number
|
||||
description: string
|
||||
|
|
@ -48,6 +57,7 @@ const CONSOLE_BOTTOM_THRESHOLD = 24
|
|||
const CONSOLE_DEFAULT_HEIGHT = 240
|
||||
const CONSOLE_HEADER_HEIGHT = 32
|
||||
const FILE_RELOAD_DEBOUNCE_MS = 200
|
||||
const SERVER_RESTART_TIMEOUT_MS = 45_000
|
||||
|
||||
function compactUrl(value: string): string {
|
||||
try {
|
||||
|
|
@ -85,6 +95,10 @@ function clampConsoleHeight(value: number): number {
|
|||
function loadErrorTitle(error: PreviewLoadErrorState): string {
|
||||
const description = error.description.toLowerCase()
|
||||
|
||||
if (description.includes('module script') || description.includes('mime type')) {
|
||||
return 'Preview app failed to boot'
|
||||
}
|
||||
|
||||
if (description.includes('connection') || description.includes('refused') || description.includes('not found')) {
|
||||
return 'Server not found'
|
||||
}
|
||||
|
|
@ -92,6 +106,12 @@ function loadErrorTitle(error: PreviewLoadErrorState): string {
|
|||
return 'Preview failed to load'
|
||||
}
|
||||
|
||||
function isModuleMimeError(message: string): boolean {
|
||||
const lower = message.toLowerCase()
|
||||
|
||||
return lower.includes('failed to load module script') && lower.includes('mime type')
|
||||
}
|
||||
|
||||
interface ConsoleRowProps {
|
||||
log: ConsoleEntry
|
||||
onCopy: () => void | Promise<void>
|
||||
|
|
@ -155,11 +175,15 @@ function ConsoleRow({ log, onCopy, onSend, onToggleSelect, selected }: ConsoleRo
|
|||
function PreviewLoadError({
|
||||
consoleHeight = 0,
|
||||
error,
|
||||
onRetry
|
||||
onRestartServer,
|
||||
onRetry,
|
||||
restarting
|
||||
}: {
|
||||
consoleHeight?: number
|
||||
error: PreviewLoadErrorState
|
||||
onRestartServer?: () => void
|
||||
onRetry: () => void
|
||||
restarting?: boolean
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
|
|
@ -182,7 +206,7 @@ function PreviewLoadError({
|
|||
strokeLinejoin="round"
|
||||
strokeWidth="1.25"
|
||||
/>
|
||||
<path d="M20 11.75 44 25.25" fill="none" stroke="currentColor" strokeWidth="0.9" opacity="0.45" />
|
||||
<path d="M20 11.75 44 25.25" fill="none" opacity="0.45" stroke="currentColor" strokeWidth="0.9" />
|
||||
</svg>
|
||||
<div className="grid gap-1.5">
|
||||
<div className="text-sm font-medium text-foreground">{loadErrorTitle(error)}</div>
|
||||
|
|
@ -201,13 +225,25 @@ function PreviewLoadError({
|
|||
</div>
|
||||
<div className="text-[0.6875rem] leading-5 text-muted-foreground/70">{error.description}</div>
|
||||
</div>
|
||||
<button
|
||||
className="rounded-full border border-border bg-background px-3 py-1.5 text-xs font-medium text-foreground shadow-xs transition-colors hover:bg-accent"
|
||||
onClick={onRetry}
|
||||
type="button"
|
||||
>
|
||||
Try again
|
||||
</button>
|
||||
<div className="grid justify-items-center gap-2">
|
||||
<button
|
||||
className="rounded-full border border-border bg-background px-3 py-1.5 text-xs font-medium text-foreground shadow-xs transition-colors hover:bg-accent"
|
||||
onClick={onRetry}
|
||||
type="button"
|
||||
>
|
||||
Try again
|
||||
</button>
|
||||
{onRestartServer && (
|
||||
<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={restarting}
|
||||
onClick={onRestartServer}
|
||||
type="button"
|
||||
>
|
||||
{restarting ? 'Hermes is restarting...' : 'Ask Hermes to restart the server'}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
|
@ -230,18 +266,20 @@ async function writeClipboardText(text: string) {
|
|||
}
|
||||
|
||||
export function PreviewPane({
|
||||
onRestartServer,
|
||||
reloadRequest = 0,
|
||||
setTitlebarToolGroup,
|
||||
target
|
||||
}: {
|
||||
setTitlebarToolGroup?: SetTitlebarToolGroup
|
||||
target: PreviewTarget
|
||||
}) {
|
||||
}: PreviewPaneProps) {
|
||||
const consoleBodyRef = useRef<HTMLDivElement | null>(null)
|
||||
const consoleShouldStickRef = useRef(true)
|
||||
const hostRef = useRef<HTMLDivElement | null>(null)
|
||||
const logIdRef = useRef(0)
|
||||
const lastReloadRequestRef = useRef(reloadRequest)
|
||||
const lastRestartEventRef = useRef('')
|
||||
const previewContentRef = useRef<HTMLDivElement | null>(null)
|
||||
const webviewRef = useRef<PreviewWebview | null>(null)
|
||||
const previewServerRestart = useStore($previewServerRestart)
|
||||
const [consoleHeight, setConsoleHeight] = useState(CONSOLE_DEFAULT_HEIGHT)
|
||||
const [consoleOpen, setConsoleOpen] = useState(false)
|
||||
const [currentUrl, setCurrentUrl] = useState(target.url)
|
||||
|
|
@ -254,8 +292,12 @@ export function PreviewPane({
|
|||
const visibleSelection = useMemo(() => logs.filter(log => selectedLogIds.has(log.id)), [logs, selectedLogIds])
|
||||
const sendableLogs = visibleSelection.length > 0 ? visibleSelection : logs
|
||||
const currentLabel = compactUrl(currentUrl)
|
||||
|
||||
const previewLabel =
|
||||
target.label && target.label.replace(/\/$/, '') !== currentLabel.replace(/\/$/, '') ? target.label : currentLabel
|
||||
const restartingServer =
|
||||
previewServerRestart?.status === 'running' &&
|
||||
(previewServerRestart.url === target.url || previewServerRestart.url === currentUrl)
|
||||
|
||||
const startConsoleResize = useCallback(
|
||||
(event: ReactPointerEvent<HTMLDivElement>) => {
|
||||
|
|
@ -317,6 +359,33 @@ export function PreviewPane({
|
|||
}
|
||||
}, [])
|
||||
|
||||
const appendConsoleEntry = useCallback((entry: Omit<ConsoleEntry, 'id'>) => {
|
||||
consoleShouldStickRef.current = isNearConsoleBottom(consoleBodyRef.current)
|
||||
setLogs(prev => [...prev.slice(-199), { ...entry, id: ++logIdRef.current }])
|
||||
}, [])
|
||||
|
||||
const restartServer = useCallback(async () => {
|
||||
if (!onRestartServer) {
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const context = logs.slice(-12).map(formatLogLine).join('\n')
|
||||
const taskId = await onRestartServer(currentUrl, context || undefined)
|
||||
|
||||
appendConsoleEntry({
|
||||
level: 1,
|
||||
message: `Hermes is looking for a preview server to restart (${taskId})`
|
||||
})
|
||||
} catch (error) {
|
||||
appendConsoleEntry({
|
||||
level: 2,
|
||||
message: `Could not start server restart: ${error instanceof Error ? error.message : String(error)}`
|
||||
})
|
||||
notifyError(error, 'Server restart failed')
|
||||
}
|
||||
}, [appendConsoleEntry, currentUrl, logs, onRestartServer])
|
||||
|
||||
function toggleLogSelection(id: number) {
|
||||
setSelectedLogIds(prev => {
|
||||
const next = new Set(prev)
|
||||
|
|
@ -441,6 +510,73 @@ export function PreviewPane({
|
|||
}
|
||||
}, [consoleOpen])
|
||||
|
||||
useEffect(() => {
|
||||
if (
|
||||
!previewServerRestart ||
|
||||
!previewServerRestart.message ||
|
||||
(previewServerRestart.url !== target.url && previewServerRestart.url !== currentUrl)
|
||||
) {
|
||||
return
|
||||
}
|
||||
|
||||
const eventKey = `${previewServerRestart.taskId}:${previewServerRestart.status}:${previewServerRestart.message || ''}`
|
||||
|
||||
if (eventKey === lastRestartEventRef.current) {
|
||||
return
|
||||
}
|
||||
|
||||
lastRestartEventRef.current = eventKey
|
||||
appendConsoleEntry({
|
||||
level: previewServerRestart.status === 'error' ? 2 : 1,
|
||||
message:
|
||||
previewServerRestart.status === 'running'
|
||||
? previewServerRestart.message
|
||||
: previewServerRestart.status === 'complete'
|
||||
? `Hermes finished restarting the preview server${
|
||||
previewServerRestart.message ? `: ${previewServerRestart.message}` : ''
|
||||
}`
|
||||
: `Server restart failed: ${previewServerRestart.message || 'unknown error'}`
|
||||
})
|
||||
|
||||
if (previewServerRestart.status === 'complete') {
|
||||
reloadPreview()
|
||||
}
|
||||
}, [appendConsoleEntry, currentUrl, previewServerRestart, reloadPreview, target.url])
|
||||
|
||||
useEffect(() => {
|
||||
if (!restartingServer || !previewServerRestart) {
|
||||
return
|
||||
}
|
||||
|
||||
const taskId = previewServerRestart.taskId
|
||||
const timer = window.setTimeout(() => {
|
||||
failPreviewServerRestart(
|
||||
taskId,
|
||||
'Hermes is still working, but no restart result has arrived yet. The server command may be running in the foreground.'
|
||||
)
|
||||
}, SERVER_RESTART_TIMEOUT_MS)
|
||||
|
||||
return () => window.clearTimeout(timer)
|
||||
}, [previewServerRestart, restartingServer])
|
||||
|
||||
useEffect(() => {
|
||||
if (reloadRequest === lastReloadRequestRef.current) {
|
||||
return
|
||||
}
|
||||
|
||||
lastReloadRequestRef.current = reloadRequest
|
||||
|
||||
if (target.kind !== 'url') {
|
||||
return
|
||||
}
|
||||
|
||||
appendConsoleEntry({
|
||||
level: 1,
|
||||
message: 'Workspace changed, reloading preview'
|
||||
})
|
||||
reloadPreview()
|
||||
}, [appendConsoleEntry, reloadPreview, reloadRequest, target.kind])
|
||||
|
||||
useEffect(() => {
|
||||
if (target.kind !== 'file' || !window.hermesDesktop?.watchPreviewFile || !window.hermesDesktop?.onPreviewFileChanged) {
|
||||
return
|
||||
|
|
@ -463,18 +599,13 @@ export function PreviewPane({
|
|||
pendingReloadCount = 0
|
||||
pendingReloadUrl = ''
|
||||
|
||||
consoleShouldStickRef.current = isNearConsoleBottom(consoleBodyRef.current)
|
||||
setLogs(prev => [
|
||||
...prev.slice(-199),
|
||||
{
|
||||
id: ++logIdRef.current,
|
||||
level: 1,
|
||||
message:
|
||||
changedCount === 1
|
||||
? `File changed, reloading preview: ${compactUrl(changedUrl)}`
|
||||
: `${changedCount} file changes, reloading preview: ${compactUrl(changedUrl)}`
|
||||
}
|
||||
])
|
||||
appendConsoleEntry({
|
||||
level: 1,
|
||||
message:
|
||||
changedCount === 1
|
||||
? `File changed, reloading preview: ${compactUrl(changedUrl)}`
|
||||
: `${changedCount} file changes, reloading preview: ${compactUrl(changedUrl)}`
|
||||
})
|
||||
|
||||
reloadPreview()
|
||||
}
|
||||
|
|
@ -509,14 +640,10 @@ export function PreviewPane({
|
|||
watchId = watch.id
|
||||
})
|
||||
.catch(error => {
|
||||
setLogs(prev => [
|
||||
...prev.slice(-199),
|
||||
{
|
||||
id: ++logIdRef.current,
|
||||
level: 2,
|
||||
message: `Could not watch preview file: ${error instanceof Error ? error.message : String(error)}`
|
||||
}
|
||||
])
|
||||
appendConsoleEntry({
|
||||
level: 2,
|
||||
message: `Could not watch preview file: ${error instanceof Error ? error.message : String(error)}`
|
||||
})
|
||||
})
|
||||
|
||||
return () => {
|
||||
|
|
@ -531,7 +658,7 @@ export function PreviewPane({
|
|||
void window.hermesDesktop?.stopPreviewFileWatch?.(watchId)
|
||||
}
|
||||
}
|
||||
}, [reloadPreview, target.kind, target.url])
|
||||
}, [appendConsoleEntry, reloadPreview, target.kind, target.url])
|
||||
|
||||
useEffect(() => {
|
||||
const host = hostRef.current
|
||||
|
|
@ -554,11 +681,6 @@ export function PreviewPane({
|
|||
webview.setAttribute('src', target.url)
|
||||
webview.setAttribute('webpreferences', 'contextIsolation=yes,nodeIntegration=no,sandbox=yes')
|
||||
|
||||
const appendLog = (entry: Omit<ConsoleEntry, 'id'>) => {
|
||||
consoleShouldStickRef.current = isNearConsoleBottom(consoleBodyRef.current)
|
||||
setLogs(prev => [...prev.slice(-199), { ...entry, id: ++logIdRef.current }])
|
||||
}
|
||||
|
||||
const onConsole = (event: Event) => {
|
||||
const detail = event as Event & {
|
||||
level?: number
|
||||
|
|
@ -566,13 +688,23 @@ export function PreviewPane({
|
|||
message?: string
|
||||
sourceId?: string
|
||||
}
|
||||
const message = detail.message || ''
|
||||
|
||||
appendLog({
|
||||
appendConsoleEntry({
|
||||
level: detail.level ?? 0,
|
||||
line: detail.line,
|
||||
message: detail.message || '',
|
||||
message,
|
||||
source: detail.sourceId
|
||||
})
|
||||
|
||||
if ((detail.level ?? 0) >= 3 && isModuleMimeError(message)) {
|
||||
setLoadError({
|
||||
description:
|
||||
'Module scripts are being served with the wrong MIME type. This usually means a static file server is serving a Vite/React app instead of the project dev server.',
|
||||
url: webview.getURL?.() || target.url
|
||||
})
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const onNavigate = (event: Event) => {
|
||||
|
|
@ -590,13 +722,14 @@ export function PreviewPane({
|
|||
errorDescription?: string
|
||||
validatedURL?: string
|
||||
}
|
||||
|
||||
const errorCode = detail.errorCode
|
||||
|
||||
if (errorCode === -3) {
|
||||
return
|
||||
}
|
||||
|
||||
appendLog({
|
||||
appendConsoleEntry({
|
||||
level: 3,
|
||||
message: `Load failed${errorCode ? ` (${errorCode})` : ''}: ${
|
||||
detail.errorDescription || detail.validatedURL || 'unknown error'
|
||||
|
|
@ -605,7 +738,7 @@ export function PreviewPane({
|
|||
setLoadError({
|
||||
code: errorCode,
|
||||
description: detail.errorDescription || 'The preview page could not be reached.',
|
||||
url: detail.validatedURL || currentUrl || target.url
|
||||
url: detail.validatedURL || webview.getURL?.() || target.url
|
||||
})
|
||||
setLoading(false)
|
||||
}
|
||||
|
|
@ -631,7 +764,7 @@ export function PreviewPane({
|
|||
webview.removeEventListener('did-stop-loading', onStop)
|
||||
webview.remove()
|
||||
}
|
||||
}, [target.url])
|
||||
}, [appendConsoleEntry, target.url])
|
||||
|
||||
return (
|
||||
<aside className="relative flex h-screen w-full min-w-0 flex-col overflow-hidden border-l border-border/60 bg-background text-muted-foreground">
|
||||
|
|
@ -659,7 +792,9 @@ export function PreviewPane({
|
|||
<PreviewLoadError
|
||||
consoleHeight={consoleOpen ? consoleHeight : 0}
|
||||
error={loadError}
|
||||
onRestartServer={target.kind === 'url' && onRestartServer ? () => void restartServer() : undefined}
|
||||
onRetry={reloadPreview}
|
||||
restarting={restartingServer}
|
||||
/>
|
||||
)}
|
||||
|
||||
|
|
|
|||
123
apps/desktop/src/app/chat/right-rail/project-section.tsx
Normal file
123
apps/desktop/src/app/chat/right-rail/project-section.tsx
Normal file
|
|
@ -0,0 +1,123 @@
|
|||
'use client'
|
||||
|
||||
import { FolderOpen, GitBranch, Pencil } from 'lucide-react'
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
|
||||
import { Input } from '@/components/ui/input'
|
||||
|
||||
import { RailSection } from './rail-section'
|
||||
|
||||
interface ProjectSectionProps {
|
||||
cwd: string
|
||||
branch: string
|
||||
busy: boolean
|
||||
onChangeCwd?: (cwd: string) => void
|
||||
onBrowseCwd?: () => void
|
||||
}
|
||||
|
||||
export function ProjectSection({ cwd, branch, busy, onChangeCwd, onBrowseCwd }: ProjectSectionProps) {
|
||||
const [editing, setEditing] = useState(false)
|
||||
const [draft, setDraft] = useState(cwd)
|
||||
const inputRef = useRef<HTMLInputElement | null>(null)
|
||||
const canChange = Boolean(onChangeCwd) && !busy
|
||||
const beginEdit = () => canChange && setEditing(true)
|
||||
|
||||
useEffect(() => {
|
||||
if (!editing) {
|
||||
setDraft(cwd)
|
||||
}
|
||||
}, [cwd, editing])
|
||||
|
||||
useEffect(() => {
|
||||
if (editing) {
|
||||
inputRef.current?.focus()
|
||||
}
|
||||
}, [editing])
|
||||
|
||||
const apply = () => {
|
||||
const next = draft.trim()
|
||||
|
||||
if (next && next !== cwd) {
|
||||
onChangeCwd?.(next)
|
||||
}
|
||||
|
||||
setEditing(false)
|
||||
}
|
||||
|
||||
const branchLabel = branch.trim()
|
||||
|
||||
return (
|
||||
<RailSection title="Project">
|
||||
{editing ? (
|
||||
<Input
|
||||
className="-ml-1.5 w-[calc(100%_+_0.375rem)] h-7 bg-background px-1.5 font-mono text-[0.6875rem]"
|
||||
onBlur={apply}
|
||||
onChange={e => setDraft(e.target.value)}
|
||||
onKeyDown={e => {
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault()
|
||||
apply()
|
||||
} else if (e.key === 'Escape') {
|
||||
e.preventDefault()
|
||||
setEditing(false)
|
||||
}
|
||||
}}
|
||||
placeholder="/path/to/project"
|
||||
ref={inputRef}
|
||||
value={draft}
|
||||
/>
|
||||
) : (
|
||||
<div className="-ml-1.5 w-[calc(100%_+_0.375rem)] group grid min-w-0 grid-cols-[auto_minmax(0,1fr)_auto] items-center gap-1 rounded-md border border-transparent bg-transparent px-1.5 py-1 font-mono text-[0.6875rem] text-foreground/75 transition-[background-color,border-color,color,box-shadow] hover:border-input hover:bg-background hover:text-foreground focus-visible:border-ring focus-visible:bg-background focus-visible:outline-none focus-visible:ring-[0.1875rem] focus-visible:ring-ring/30">
|
||||
<button
|
||||
aria-label="Browse workspace folder"
|
||||
className="grid size-4 shrink-0 place-items-center rounded text-muted-foreground/60 hover:text-foreground focus-visible:outline-none disabled:cursor-default disabled:hover:text-muted-foreground/60"
|
||||
disabled={!canChange || !onBrowseCwd}
|
||||
onClick={onBrowseCwd}
|
||||
type="button"
|
||||
>
|
||||
<FolderOpen className="size-3" />
|
||||
</button>
|
||||
<button
|
||||
aria-label="Edit working directory"
|
||||
className="min-w-0 truncate text-right focus-visible:outline-none disabled:cursor-default"
|
||||
dir="rtl"
|
||||
disabled={!canChange}
|
||||
onClick={beginEdit}
|
||||
type="button"
|
||||
>
|
||||
<span dir="ltr">{compactPath(cwd) || '—'}</span>
|
||||
</button>
|
||||
{canChange && (
|
||||
<button
|
||||
aria-hidden="true"
|
||||
className="grid size-4 shrink-0 place-items-center rounded text-muted-foreground/60 opacity-60 transition-opacity hover:text-foreground group-hover:opacity-100 focus-visible:outline-none"
|
||||
onClick={beginEdit}
|
||||
tabIndex={-1}
|
||||
type="button"
|
||||
>
|
||||
<Pencil className="size-3" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{branchLabel && (
|
||||
<div className="-ml-1.5 w-[calc(100%_+_0.375rem)] flex min-w-0 items-center gap-1 rounded-md border border-transparent bg-transparent px-1.5 py-1 text-[0.6875rem] transition-[background-color,border-color,color,box-shadow] hover:border-input hover:bg-background hover:text-foreground">
|
||||
<GitBranch className="size-3 shrink-0 text-muted-foreground/60" />
|
||||
<span className="min-w-0 truncate font-mono text-foreground/75">{branchLabel}</span>
|
||||
</div>
|
||||
)}
|
||||
</RailSection>
|
||||
)
|
||||
}
|
||||
|
||||
function compactPath(path: string): string {
|
||||
if (!path) {
|
||||
return ''
|
||||
}
|
||||
|
||||
const normalized = path.replace(/\\/g, '/').replace(/\/+$/, '')
|
||||
const parts = normalized.split('/').filter(Boolean)
|
||||
|
||||
return parts.length <= 4 ? normalized || path : `.../${parts.slice(-3).join('/')}`
|
||||
}
|
||||
34
apps/desktop/src/app/chat/right-rail/rail-action-row.tsx
Normal file
34
apps/desktop/src/app/chat/right-rail/rail-action-row.tsx
Normal file
|
|
@ -0,0 +1,34 @@
|
|||
'use client'
|
||||
|
||||
import { ChevronDown } from 'lucide-react'
|
||||
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
interface RailActionRowProps {
|
||||
primary: string
|
||||
secondary?: string
|
||||
ariaLabel?: string
|
||||
onClick?: () => void
|
||||
}
|
||||
|
||||
export function RailActionRow({ primary, secondary, ariaLabel, onClick }: RailActionRowProps) {
|
||||
return (
|
||||
<button
|
||||
aria-label={ariaLabel}
|
||||
className={cn(
|
||||
'-ml-1.5 w-[calc(100%_+_0.375rem)] group grid gap-px rounded-md border border-transparent bg-transparent px-1.5 py-1 text-left transition-[background-color,border-color,color,box-shadow] hover:border-input hover:bg-background hover:text-foreground focus-visible:border-ring focus-visible:bg-background focus-visible:outline-none focus-visible:ring-[0.1875rem] focus-visible:ring-ring/30 disabled:cursor-default disabled:hover:border-transparent disabled:hover:bg-transparent'
|
||||
)}
|
||||
disabled={!onClick}
|
||||
onClick={onClick}
|
||||
type="button"
|
||||
>
|
||||
<span className="flex items-center gap-1.5">
|
||||
<span className="min-w-0 flex-1 truncate font-mono text-[0.6875rem] text-foreground/85">{primary}</span>
|
||||
{onClick && (
|
||||
<ChevronDown className="size-3 shrink-0 text-muted-foreground/60 opacity-0 transition-opacity group-hover:opacity-100 group-focus-within:opacity-100" />
|
||||
)}
|
||||
</span>
|
||||
{secondary && <span className="truncate text-[0.625rem] text-muted-foreground/70">{secondary}</span>}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
19
apps/desktop/src/app/chat/right-rail/rail-section.tsx
Normal file
19
apps/desktop/src/app/chat/right-rail/rail-section.tsx
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
import type { ReactNode } from 'react'
|
||||
|
||||
export function RailSectionLabel({ children }: { children: ReactNode }) {
|
||||
return <div className="text-xs font-medium text-muted-foreground/90">{children}</div>
|
||||
}
|
||||
|
||||
interface RailSectionProps {
|
||||
title: string
|
||||
children: ReactNode
|
||||
}
|
||||
|
||||
export function RailSection({ title, children }: RailSectionProps) {
|
||||
return (
|
||||
<section className="grid gap-1.5 py-1.5">
|
||||
<RailSectionLabel>{title}</RailSectionLabel>
|
||||
{children}
|
||||
</section>
|
||||
)
|
||||
}
|
||||
88
apps/desktop/src/app/chat/right-rail/rail-select-row.tsx
Normal file
88
apps/desktop/src/app/chat/right-rail/rail-select-row.tsx
Normal file
|
|
@ -0,0 +1,88 @@
|
|||
'use client'
|
||||
|
||||
import { ChevronDown } from 'lucide-react'
|
||||
import { type ReactNode, useState } from 'react'
|
||||
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuCheckboxItem,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger
|
||||
} from '@/components/ui/dropdown-menu'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
export interface RailSelectOption {
|
||||
value: string
|
||||
label: string
|
||||
}
|
||||
|
||||
interface RailSelectRowProps {
|
||||
label: string
|
||||
menuLabel?: string
|
||||
value: string
|
||||
options: RailSelectOption[]
|
||||
valueLabel?: ReactNode
|
||||
ariaLabel?: string
|
||||
menuWidthClass?: string
|
||||
onChange?: (value: string) => void
|
||||
}
|
||||
|
||||
export function RailSelectRow({
|
||||
label,
|
||||
menuLabel,
|
||||
value,
|
||||
options,
|
||||
valueLabel,
|
||||
ariaLabel,
|
||||
menuWidthClass = 'w-44',
|
||||
onChange
|
||||
}: RailSelectRowProps) {
|
||||
const [open, setOpen] = useState(false)
|
||||
const activeOption = options.find(option => option.value === value)
|
||||
const displayLabel = valueLabel ?? activeOption?.label ?? value
|
||||
|
||||
return (
|
||||
<DropdownMenu onOpenChange={setOpen} open={open}>
|
||||
<DropdownMenuTrigger asChild disabled={!onChange}>
|
||||
<button
|
||||
aria-label={ariaLabel ?? `Change ${label.toLowerCase()}`}
|
||||
className="-ml-1.5 w-[calc(100%_+_0.375rem)] group flex items-center gap-1.5 rounded-md border border-transparent bg-transparent px-1.5 py-1 text-left transition-[background-color,border-color,color,box-shadow] hover:border-input hover:bg-background hover:text-foreground focus-visible:border-ring focus-visible:bg-background focus-visible:outline-none focus-visible:ring-[0.1875rem] focus-visible:ring-ring/30 disabled:cursor-default disabled:hover:border-transparent disabled:hover:bg-transparent"
|
||||
type="button"
|
||||
>
|
||||
<span className="min-w-0 flex-1 truncate text-[0.6875rem] text-muted-foreground group-hover:text-foreground group-focus-within:text-foreground">
|
||||
{label}
|
||||
</span>
|
||||
<span className="truncate text-[0.6875rem] text-foreground/75">{displayLabel}</span>
|
||||
{onChange && (
|
||||
<ChevronDown className="size-3 shrink-0 text-muted-foreground/60 opacity-0 transition-opacity group-hover:opacity-100 group-focus-within:opacity-100" />
|
||||
)}
|
||||
</button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent
|
||||
align="end"
|
||||
className={cn(menuWidthClass, 'border-border/70 bg-popover/95 shadow-md')}
|
||||
side="bottom"
|
||||
sideOffset={6}
|
||||
>
|
||||
<DropdownMenuLabel className="text-xs text-muted-foreground">{menuLabel ?? label}</DropdownMenuLabel>
|
||||
<DropdownMenuSeparator />
|
||||
{options.map(option => (
|
||||
<DropdownMenuCheckboxItem
|
||||
checked={value === option.value}
|
||||
className="text-xs text-muted-foreground focus:text-foreground"
|
||||
key={option.value}
|
||||
onSelect={e => {
|
||||
e.preventDefault()
|
||||
onChange?.(option.value)
|
||||
setOpen(false)
|
||||
}}
|
||||
>
|
||||
{option.label}
|
||||
</DropdownMenuCheckboxItem>
|
||||
))}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)
|
||||
}
|
||||
36
apps/desktop/src/app/chat/right-rail/rail-toggle-row.tsx
Normal file
36
apps/desktop/src/app/chat/right-rail/rail-toggle-row.tsx
Normal file
|
|
@ -0,0 +1,36 @@
|
|||
'use client'
|
||||
|
||||
import { useId } from 'react'
|
||||
|
||||
import { Switch } from '@/components/ui/switch'
|
||||
|
||||
interface RailToggleRowProps {
|
||||
label: string
|
||||
checked: boolean
|
||||
valueLabel?: string
|
||||
onChange?: (enabled: boolean) => void
|
||||
}
|
||||
|
||||
export function RailToggleRow({ label, checked, valueLabel, onChange }: RailToggleRowProps) {
|
||||
const id = useId()
|
||||
const disabled = !onChange
|
||||
|
||||
return (
|
||||
<label
|
||||
className={`-ml-1.5 w-[calc(100%_+_0.375rem)] group flex items-center gap-1.5 rounded-md border border-transparent bg-transparent px-1.5 py-1 text-left transition-[background-color,border-color,color,box-shadow] ${disabled ? 'cursor-default' : 'cursor-pointer hover:border-input hover:bg-background hover:text-foreground focus-within:border-ring focus-within:bg-background focus-within:ring-[0.1875rem] focus-within:ring-ring/30'}`}
|
||||
htmlFor={id}
|
||||
>
|
||||
<span className="min-w-0 flex-1 truncate text-[0.6875rem] text-muted-foreground group-hover:text-foreground group-focus-within:text-foreground">
|
||||
{label}
|
||||
</span>
|
||||
<span className="truncate text-[0.6875rem] text-foreground/75">{valueLabel ?? (checked ? 'On' : 'Off')}</span>
|
||||
<Switch
|
||||
checked={checked}
|
||||
className="h-4 w-7 [&_[data-slot=switch-thumb]]:size-3 [&_[data-slot=switch-thumb]]:data-[state=checked]:translate-x-3"
|
||||
disabled={disabled}
|
||||
id={id}
|
||||
onCheckedChange={next => onChange?.(next)}
|
||||
/>
|
||||
</label>
|
||||
)
|
||||
}
|
||||
|
|
@ -20,10 +20,20 @@ import { BUILTIN_PERSONALITIES, normalizePersonalityValue, personalityNamesFromC
|
|||
import { extractPreviewCandidates } from '../lib/preview-targets'
|
||||
import { $pinnedSessionIds, pinSession, unpinSession } from '../store/layout'
|
||||
import { notify, notifyError } from '../store/notifications'
|
||||
import { setPreviewTarget } from '../store/preview'
|
||||
import {
|
||||
$previewTarget,
|
||||
beginPreviewServerRestart,
|
||||
completePreviewServerRestart,
|
||||
progressPreviewServerRestart,
|
||||
requestPreviewReload,
|
||||
setPreviewTarget
|
||||
} from '../store/preview'
|
||||
import {
|
||||
$activeSessionId,
|
||||
$currentCwd,
|
||||
$currentFastMode,
|
||||
$currentReasoningEffort,
|
||||
$currentServiceTier,
|
||||
$freshDraftReady,
|
||||
$gatewayState,
|
||||
$messages,
|
||||
|
|
@ -34,9 +44,12 @@ import {
|
|||
setContextSuggestions,
|
||||
setCurrentBranch,
|
||||
setCurrentCwd,
|
||||
setCurrentFastMode,
|
||||
setCurrentModel,
|
||||
setCurrentPersonality,
|
||||
setCurrentProvider,
|
||||
setCurrentReasoningEffort,
|
||||
setCurrentServiceTier,
|
||||
setIntroPersonality,
|
||||
setMessages,
|
||||
setModelPickerOpen,
|
||||
|
|
@ -69,9 +82,9 @@ function normalizeRecordingLimit(value: unknown): number {
|
|||
return typeof value === 'number' && Number.isFinite(value) && value > 0 ? value : DEFAULT_VOICE_RECORDING_SECONDS
|
||||
}
|
||||
|
||||
function gatewayEventPreviewText(event: { payload?: unknown }): string {
|
||||
function gatewayEventPreviewText(event: { payload?: unknown; type?: string }): string {
|
||||
const payload = event.payload && typeof event.payload === 'object' ? (event.payload as Record<string, unknown>) : {}
|
||||
const fields = ['text', 'rendered', 'preview', 'context', 'summary', 'message']
|
||||
const fields = event.type?.startsWith('message.') ? ['text', 'rendered', 'preview'] : ['preview']
|
||||
|
||||
return fields
|
||||
.map(key => payload[key])
|
||||
|
|
@ -79,6 +92,16 @@ function gatewayEventPreviewText(event: { payload?: unknown }): string {
|
|||
.join('\n')
|
||||
}
|
||||
|
||||
function gatewayEventCompletedFileDiff(event: { payload?: unknown; type?: string }): boolean {
|
||||
if (event.type !== 'tool.complete' || !event.payload || typeof event.payload !== 'object') {
|
||||
return false
|
||||
}
|
||||
|
||||
const inlineDiff = (event.payload as Record<string, unknown>).inline_diff
|
||||
|
||||
return typeof inlineDiff === 'string' && inlineDiff.trim().length > 0
|
||||
}
|
||||
|
||||
export function DesktopController() {
|
||||
const queryClient = useQueryClient()
|
||||
const location = useLocation()
|
||||
|
|
@ -102,9 +125,11 @@ export function DesktopController() {
|
|||
const chatOpen = currentView === 'chat'
|
||||
const settingsReturnPathRef = useRef(NEW_CHAT_ROUTE)
|
||||
const refreshSessionsRequestRef = useRef(0)
|
||||
|
||||
const [titlebarToolGroups, setTitlebarToolGroups] = useState<
|
||||
Record<TitlebarToolSide, Record<string, readonly TitlebarTool[]>>
|
||||
>({ left: {}, right: {} })
|
||||
|
||||
const [voiceMaxRecordingSeconds, setVoiceMaxRecordingSeconds] = useState(DEFAULT_VOICE_RECORDING_SECONDS)
|
||||
const [sttEnabled, setSttEnabled] = useState(true)
|
||||
|
||||
|
|
@ -257,6 +282,30 @@ export function DesktopController() {
|
|||
}
|
||||
}, [])
|
||||
|
||||
const refreshProjectBranch = useCallback(
|
||||
async (cwd: string) => {
|
||||
const targetCwd = cwd.trim()
|
||||
|
||||
if (!targetCwd || activeSessionIdRef.current) {
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const info = await requestGateway<{ branch?: string; cwd?: string }>('config.get', {
|
||||
key: 'project',
|
||||
cwd: targetCwd
|
||||
})
|
||||
|
||||
if (!activeSessionIdRef.current && ($currentCwd.get() || targetCwd) === (info.cwd || targetCwd)) {
|
||||
setCurrentBranch(info.branch || '')
|
||||
}
|
||||
} catch {
|
||||
setCurrentBranch('')
|
||||
}
|
||||
},
|
||||
[activeSessionIdRef, requestGateway]
|
||||
)
|
||||
|
||||
const changeSessionCwd = useCallback(
|
||||
async (cwd: string) => {
|
||||
const trimmed = cwd.trim()
|
||||
|
|
@ -266,15 +315,18 @@ export function DesktopController() {
|
|||
}
|
||||
|
||||
const persistGlobal = async () => {
|
||||
await requestGateway('config.set', {
|
||||
const info = await requestGateway<{ branch?: string; cwd?: string; value?: string }>('config.set', {
|
||||
...(activeSessionId && { session_id: activeSessionId }),
|
||||
key: 'terminal.cwd',
|
||||
value: trimmed
|
||||
})
|
||||
setCurrentCwd(trimmed)
|
||||
|
||||
const nextCwd = info.cwd || info.value || trimmed
|
||||
|
||||
setCurrentCwd(nextCwd)
|
||||
|
||||
if (!activeSessionId) {
|
||||
setCurrentBranch('')
|
||||
setCurrentBranch(info.branch || '')
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -394,14 +446,24 @@ export function DesktopController() {
|
|||
|
||||
if (cwd && cwd !== '.') {
|
||||
setCurrentCwd(prev => prev || cwd)
|
||||
void refreshProjectBranch($currentCwd.get() || cwd)
|
||||
}
|
||||
|
||||
const reasoningEffort = (config.agent?.reasoning_effort ?? '').trim()
|
||||
const serviceTier = (config.agent?.service_tier ?? '').trim()
|
||||
|
||||
setCurrentReasoningEffort(prev => (activeSessionIdRef.current ? prev : reasoningEffort))
|
||||
setCurrentServiceTier(prev => (activeSessionIdRef.current ? prev : serviceTier))
|
||||
setCurrentFastMode(prev =>
|
||||
activeSessionIdRef.current ? prev : ['fast', 'priority', 'on'].includes(serviceTier.toLowerCase())
|
||||
)
|
||||
|
||||
setVoiceMaxRecordingSeconds(normalizeRecordingLimit(config.voice?.max_recording_seconds))
|
||||
setSttEnabled(config.stt?.enabled !== false)
|
||||
} catch {
|
||||
// Config is nice-to-have for the empty-state copy; the chat still works.
|
||||
}
|
||||
}, [activeSessionIdRef])
|
||||
}, [activeSessionIdRef, refreshProjectBranch])
|
||||
|
||||
const selectPersonality = useCallback(
|
||||
async (name: string) => {
|
||||
|
|
@ -435,6 +497,50 @@ export function DesktopController() {
|
|||
[activeSessionId, refreshHermesConfig, requestGateway]
|
||||
)
|
||||
|
||||
const setReasoningEffort = useCallback(
|
||||
async (effort: string) => {
|
||||
const value = effort.trim().toLowerCase()
|
||||
const previous = $currentReasoningEffort.get()
|
||||
setCurrentReasoningEffort(value)
|
||||
|
||||
try {
|
||||
await requestGateway('config.set', {
|
||||
...(activeSessionId && { session_id: activeSessionId }),
|
||||
key: 'reasoning',
|
||||
value
|
||||
})
|
||||
} catch (err) {
|
||||
setCurrentReasoningEffort(previous)
|
||||
void refreshHermesConfig()
|
||||
notifyError(err, 'Reasoning change failed')
|
||||
}
|
||||
},
|
||||
[activeSessionId, refreshHermesConfig, requestGateway]
|
||||
)
|
||||
|
||||
const setFastMode = useCallback(
|
||||
async (enabled: boolean) => {
|
||||
const previousFast = $currentFastMode.get()
|
||||
const previousTier = $currentServiceTier.get()
|
||||
setCurrentFastMode(enabled)
|
||||
setCurrentServiceTier(enabled ? 'priority' : '')
|
||||
|
||||
try {
|
||||
await requestGateway('config.set', {
|
||||
...(activeSessionId && { session_id: activeSessionId }),
|
||||
key: 'fast',
|
||||
value: enabled ? 'fast' : 'normal'
|
||||
})
|
||||
} catch (err) {
|
||||
setCurrentFastMode(previousFast)
|
||||
setCurrentServiceTier(previousTier)
|
||||
void refreshHermesConfig()
|
||||
notifyError(err, 'Fast mode change failed')
|
||||
}
|
||||
},
|
||||
[activeSessionId, refreshHermesConfig, requestGateway]
|
||||
)
|
||||
|
||||
const hydrateFromStoredSession = useCallback(
|
||||
async (
|
||||
attempts = 1,
|
||||
|
|
@ -512,10 +618,56 @@ export function DesktopController() {
|
|||
[activeSessionIdRef, currentCwd]
|
||||
)
|
||||
|
||||
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(
|
||||
(event: Parameters<typeof handleGatewayEvent>[0]) => {
|
||||
handleGatewayEvent(event)
|
||||
|
||||
if (event.type === 'preview.restart.complete') {
|
||||
const payload = event.payload && typeof event.payload === 'object' ? (event.payload as Record<string, unknown>) : {}
|
||||
const taskId = typeof payload.task_id === 'string' ? payload.task_id : ''
|
||||
|
||||
if (taskId) {
|
||||
completePreviewServerRestart(taskId, typeof payload.text === 'string' ? payload.text : '')
|
||||
}
|
||||
}
|
||||
|
||||
if (event.type === 'preview.restart.progress') {
|
||||
const payload = event.payload && typeof event.payload === 'object' ? (event.payload as Record<string, unknown>) : {}
|
||||
const taskId = typeof payload.task_id === 'string' ? payload.task_id : ''
|
||||
|
||||
if (taskId) {
|
||||
progressPreviewServerRestart(taskId, typeof payload.text === 'string' ? payload.text : '')
|
||||
}
|
||||
}
|
||||
|
||||
if (event.session_id && event.session_id !== activeSessionIdRef.current) {
|
||||
return
|
||||
}
|
||||
|
|
@ -525,6 +677,10 @@ export function DesktopController() {
|
|||
if (previewText) {
|
||||
void openDetectedPreview(previewText)
|
||||
}
|
||||
|
||||
if ($previewTarget.get()?.kind === 'url' && gatewayEventCompletedFileDiff(event)) {
|
||||
requestPreviewReload()
|
||||
}
|
||||
},
|
||||
[activeSessionIdRef, handleGatewayEvent, openDetectedPreview]
|
||||
)
|
||||
|
|
@ -809,12 +965,15 @@ export function DesktopController() {
|
|||
onPickImages={() => void pickImages()}
|
||||
onReload={reloadFromMessage}
|
||||
onRemoveAttachment={id => void removeAttachment(id)}
|
||||
onRestartPreviewServer={restartPreviewServer}
|
||||
onSelectPersonality={name => void selectPersonality(name)}
|
||||
onSetFastMode={enabled => void setFastMode(enabled)}
|
||||
onSetReasoningEffort={effort => void setReasoningEffort(effort)}
|
||||
onSubmit={submitText}
|
||||
onThreadMessagesChange={handleThreadMessagesChange}
|
||||
setTitlebarToolGroup={setTitlebarToolGroup}
|
||||
onToggleSelectedPin={toggleSelectedPin}
|
||||
onTranscribeAudio={transcribeVoiceAudio}
|
||||
setTitlebarToolGroup={setTitlebarToolGroup}
|
||||
/>
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -20,9 +20,12 @@ import { notify } from '@/store/notifications'
|
|||
import {
|
||||
setCurrentBranch,
|
||||
setCurrentCwd,
|
||||
setCurrentFastMode,
|
||||
setCurrentModel,
|
||||
setCurrentPersonality,
|
||||
setCurrentProvider
|
||||
setCurrentProvider,
|
||||
setCurrentReasoningEffort,
|
||||
setCurrentServiceTier
|
||||
} from '@/store/session'
|
||||
import { recordToolDiff } from '@/store/tool-diffs'
|
||||
import type { RpcEvent } from '@/types/hermes'
|
||||
|
|
@ -321,6 +324,18 @@ export function useMessageStream({
|
|||
setCurrentPersonality(normalizePersonalityValue(payload.personality))
|
||||
}
|
||||
|
||||
if (typeof payload?.reasoning_effort === 'string') {
|
||||
setCurrentReasoningEffort(payload.reasoning_effort)
|
||||
}
|
||||
|
||||
if (typeof payload?.service_tier === 'string') {
|
||||
setCurrentServiceTier(payload.service_tier)
|
||||
}
|
||||
|
||||
if (typeof payload?.fast === 'boolean') {
|
||||
setCurrentFastMode(payload.fast)
|
||||
}
|
||||
|
||||
if (runningChanged && sessionId) {
|
||||
updateSessionState(sessionId, state => {
|
||||
const busy = Boolean(payload!.running)
|
||||
|
|
|
|||
|
|
@ -17,9 +17,12 @@ import {
|
|||
setBusy,
|
||||
setCurrentBranch,
|
||||
setCurrentCwd,
|
||||
setCurrentFastMode,
|
||||
setCurrentModel,
|
||||
setCurrentPersonality,
|
||||
setCurrentProvider,
|
||||
setCurrentReasoningEffort,
|
||||
setCurrentServiceTier,
|
||||
setFreshDraftReady,
|
||||
setIntroSeed,
|
||||
setMessages,
|
||||
|
|
@ -54,6 +57,7 @@ interface SessionActionsOptions {
|
|||
|
||||
function withAppendedText(message: ChatMessage, suffix: string): ChatMessage {
|
||||
let appended = false
|
||||
|
||||
const parts = message.parts.map(part => {
|
||||
if (part.type !== 'text' || appended) {
|
||||
return part
|
||||
|
|
@ -193,6 +197,18 @@ function applyRuntimeInfo(info: SessionCreateResponse['info'] | undefined) {
|
|||
if (typeof info.personality === 'string') {
|
||||
setCurrentPersonality(normalizePersonalityValue(info.personality))
|
||||
}
|
||||
|
||||
if (typeof info.reasoning_effort === 'string') {
|
||||
setCurrentReasoningEffort(info.reasoning_effort)
|
||||
}
|
||||
|
||||
if (typeof info.service_tier === 'string') {
|
||||
setCurrentServiceTier(info.service_tier)
|
||||
}
|
||||
|
||||
if (typeof info.fast === 'boolean') {
|
||||
setCurrentFastMode(info.fast)
|
||||
}
|
||||
}
|
||||
|
||||
export function useSessionActions({
|
||||
|
|
@ -385,6 +401,7 @@ export function useSessionActions({
|
|||
// Keep the already-painted local snapshot for the view/cache when it
|
||||
// exists; use gateway messages only as a fallback when no local
|
||||
// snapshot was available.
|
||||
|
||||
const messagesForView = localSnapshot.length > 0
|
||||
? localSnapshot
|
||||
: chatMessageArraysEquivalent(currentMessages, resumedMessages)
|
||||
|
|
|
|||
|
|
@ -4,7 +4,6 @@ import { useCallback } from 'react'
|
|||
|
||||
import { SidebarProvider } from '@/components/ui/sidebar'
|
||||
import { triggerHaptic } from '@/lib/haptics'
|
||||
import { cn } from '@/lib/utils'
|
||||
import {
|
||||
$inspectorOpen,
|
||||
$sidebarOpen,
|
||||
|
|
@ -66,21 +65,14 @@ export function AppShell({
|
|||
|
||||
const inspectorColumn = showInspectorRail ? 'var(--inspector-width)' : '0px'
|
||||
|
||||
// Preview yields first because it is the widest rail; keep chat usable before
|
||||
// letting the webview consume horizontal space.
|
||||
const previewColumn = showPreviewRail
|
||||
? `min(var(--preview-width), max(0px, calc(100vw - var(--sidebar-width) - ${showInspectorRail ? 'var(--inspector-width)' : '0px'} - var(--chat-min-width) - 3 * var(--shell-gap))))`
|
||||
? `min(var(--preview-width), max(0px, calc(100vw - var(--sidebar-width) - ${showInspectorRail ? 'var(--inspector-width)' : '0px'} - var(--chat-min-width))))`
|
||||
: '0px'
|
||||
|
||||
const titlebarToolCount = (titlebarTools?.filter(tool => !tool.hidden).length ?? 0) + (rightRailOpen ? 1 : 0) + 2
|
||||
|
||||
// Always keep the shell as fixed columns because sidebar/chat/preview/inspector
|
||||
// are always rendered as grid children. Hidden rails collapse to 0px so they
|
||||
// don't float over the chat surface or reorder into a new row.
|
||||
const shellGridColumns = 'var(--sidebar-width) minmax(0,1fr) var(--preview-col) var(--inspector-col)'
|
||||
|
||||
const hasSideGaps = sidebarOpen || showPreviewRail || showInspectorRail
|
||||
|
||||
const startSidebarResize = useCallback(
|
||||
(event: ReactPointerEvent<HTMLDivElement>) => {
|
||||
event.preventDefault()
|
||||
|
|
@ -149,10 +141,7 @@ export function AppShell({
|
|||
/>
|
||||
|
||||
<main
|
||||
className={cn(
|
||||
'relative grid h-screen w-full overflow-hidden bg-background pr-0.75 pb-0.75 pt-0.75 transition-none',
|
||||
hasSideGaps ? 'gap-(--shell-gap)' : 'gap-0'
|
||||
)}
|
||||
className="relative grid h-screen w-full overflow-hidden bg-background pr-0.75 pb-0.75 pt-0.75 transition-none"
|
||||
style={
|
||||
{
|
||||
'--inspector-col': inspectorColumn,
|
||||
|
|
|
|||
|
|
@ -1,20 +1,12 @@
|
|||
'use client'
|
||||
|
||||
import { ChevronDown, FolderOpen, GitBranch, Pencil } from 'lucide-react'
|
||||
import { type FC, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import type { FC } from 'react'
|
||||
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuCheckboxItem,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger
|
||||
} from '@/components/ui/dropdown-menu'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { AgentSection } from '@/app/chat/right-rail/agent-section'
|
||||
import { ProjectSection } from '@/app/chat/right-rail/project-section'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
export type SessionInspectorProps = {
|
||||
export interface SessionInspectorProps {
|
||||
open: boolean
|
||||
cwd: string
|
||||
branch: string
|
||||
|
|
@ -22,39 +14,38 @@ export type SessionInspectorProps = {
|
|||
modelLabel: string
|
||||
modelTitle?: string
|
||||
providerName?: string
|
||||
reasoningEffort: string
|
||||
serviceTier: string
|
||||
fastMode: boolean
|
||||
personality: string
|
||||
personalities: string[]
|
||||
onChangeCwd?: (cwd: string) => void
|
||||
onBrowseCwd?: () => void
|
||||
onOpenModelPicker?: () => void
|
||||
onSetReasoningEffort?: (effort: string) => void
|
||||
onSetFastMode?: (enabled: boolean) => void
|
||||
onSelectPersonality?: (name: string) => void
|
||||
}
|
||||
|
||||
export const SESSION_INSPECTOR_WIDTH = '14rem'
|
||||
|
||||
// Quiet button-like row: invisible until hovered/focused.
|
||||
const quietControl =
|
||||
'rounded-md border border-transparent bg-transparent transition-[background-color,border-color,color,box-shadow] hover:border-input hover:bg-background hover:text-foreground focus-visible:border-ring focus-visible:bg-background focus-visible:outline-none focus-visible:ring-[0.1875rem] focus-visible:ring-ring/30'
|
||||
|
||||
// Bleed interactive rows leftwards by 6px so the hover ring doesn't look
|
||||
// indented relative to the section labels above them.
|
||||
const bleed = '-ml-1.5 w-[calc(100%_+_0.375rem)]'
|
||||
|
||||
const disabledRow = 'disabled:cursor-default disabled:hover:border-transparent disabled:hover:bg-transparent'
|
||||
|
||||
export const SessionInspector: FC<SessionInspectorProps> = ({
|
||||
open,
|
||||
cwd,
|
||||
branch,
|
||||
busy,
|
||||
modelLabel,
|
||||
modelTitle,
|
||||
providerName,
|
||||
reasoningEffort,
|
||||
serviceTier,
|
||||
fastMode,
|
||||
personality,
|
||||
personalities,
|
||||
onChangeCwd,
|
||||
onBrowseCwd,
|
||||
onOpenModelPicker,
|
||||
onSetFastMode,
|
||||
onSetReasoningEffort,
|
||||
onSelectPersonality
|
||||
}) => (
|
||||
<aside
|
||||
|
|
@ -66,249 +57,26 @@ export const SessionInspector: FC<SessionInspectorProps> = ({
|
|||
data-open={open}
|
||||
>
|
||||
<div className="flex min-h-0 flex-1 flex-col gap-2.5 overflow-y-auto overscroll-contain pl-1.5 pr-1 text-xs">
|
||||
<WorkspaceSection branch={branch} busy={busy} cwd={cwd} onBrowseCwd={onBrowseCwd} onChangeCwd={onChangeCwd} />
|
||||
<ProjectSection
|
||||
branch={branch}
|
||||
busy={busy}
|
||||
cwd={cwd}
|
||||
onBrowseCwd={onBrowseCwd}
|
||||
onChangeCwd={onChangeCwd}
|
||||
/>
|
||||
<AgentSection
|
||||
current={personality}
|
||||
label={modelLabel}
|
||||
onOpen={onOpenModelPicker}
|
||||
onSelect={onSelectPersonality}
|
||||
options={personalities}
|
||||
fastMode={fastMode}
|
||||
modelLabel={modelLabel}
|
||||
onOpenModelPicker={onOpenModelPicker}
|
||||
onSelectPersonality={onSelectPersonality}
|
||||
onSetFastMode={onSetFastMode}
|
||||
onSetReasoningEffort={onSetReasoningEffort}
|
||||
personalities={personalities}
|
||||
personality={personality}
|
||||
providerName={providerName}
|
||||
title={modelTitle}
|
||||
reasoningEffort={reasoningEffort}
|
||||
serviceTier={serviceTier}
|
||||
/>
|
||||
</div>
|
||||
</aside>
|
||||
)
|
||||
|
||||
function SectionLabel({ children }: { children: React.ReactNode }) {
|
||||
return <div className="text-xs font-medium text-muted-foreground/90">{children}</div>
|
||||
}
|
||||
|
||||
function WorkspaceSection({
|
||||
cwd,
|
||||
branch,
|
||||
busy,
|
||||
onChangeCwd,
|
||||
onBrowseCwd
|
||||
}: {
|
||||
cwd: string
|
||||
branch: string
|
||||
busy: boolean
|
||||
onChangeCwd?: (cwd: string) => void
|
||||
onBrowseCwd?: () => void
|
||||
}) {
|
||||
const [editing, setEditing] = useState(false)
|
||||
const [draft, setDraft] = useState(cwd)
|
||||
const inputRef = useRef<HTMLInputElement | null>(null)
|
||||
const canChange = Boolean(onChangeCwd) && !busy
|
||||
const beginEdit = () => canChange && setEditing(true)
|
||||
|
||||
useEffect(() => {
|
||||
if (!editing) {
|
||||
setDraft(cwd)
|
||||
}
|
||||
}, [cwd, editing])
|
||||
|
||||
useEffect(() => {
|
||||
if (editing) {
|
||||
inputRef.current?.focus()
|
||||
}
|
||||
}, [editing])
|
||||
|
||||
const apply = () => {
|
||||
const next = draft.trim()
|
||||
|
||||
if (next && next !== cwd) {
|
||||
onChangeCwd?.(next)
|
||||
}
|
||||
|
||||
setEditing(false)
|
||||
}
|
||||
|
||||
const branchLabel = branch.trim()
|
||||
|
||||
return (
|
||||
<section className="grid gap-1.5 py-1.5">
|
||||
<SectionLabel>cwd</SectionLabel>
|
||||
{editing ? (
|
||||
<Input
|
||||
className={cn(bleed, 'h-7 bg-background px-1.5 font-mono text-[0.6875rem]')}
|
||||
onBlur={apply}
|
||||
onChange={e => setDraft(e.target.value)}
|
||||
onKeyDown={e => {
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault()
|
||||
apply()
|
||||
} else if (e.key === 'Escape') {
|
||||
e.preventDefault()
|
||||
setEditing(false)
|
||||
}
|
||||
}}
|
||||
placeholder="/path/to/project"
|
||||
ref={inputRef}
|
||||
value={draft}
|
||||
/>
|
||||
) : (
|
||||
<div
|
||||
className={cn(
|
||||
quietControl,
|
||||
bleed,
|
||||
'group grid min-w-0 grid-cols-[auto_minmax(0,1fr)_auto] items-center gap-1 px-1.5 py-1 font-mono text-[0.6875rem] text-foreground/75'
|
||||
)}
|
||||
>
|
||||
<button
|
||||
aria-label="Browse workspace folder"
|
||||
className="grid size-4 shrink-0 place-items-center rounded text-muted-foreground/60 hover:text-foreground focus-visible:outline-none disabled:cursor-default disabled:hover:text-muted-foreground/60"
|
||||
disabled={!canChange || !onBrowseCwd}
|
||||
onClick={onBrowseCwd}
|
||||
type="button"
|
||||
>
|
||||
<FolderOpen className="size-3" />
|
||||
</button>
|
||||
<button
|
||||
aria-label="Edit working directory"
|
||||
className="min-w-0 truncate text-right focus-visible:outline-none disabled:cursor-default"
|
||||
dir="rtl"
|
||||
disabled={!canChange}
|
||||
onClick={beginEdit}
|
||||
type="button"
|
||||
>
|
||||
<span dir="ltr">{compactPath(cwd) || '—'}</span>
|
||||
</button>
|
||||
{canChange && (
|
||||
<button
|
||||
aria-hidden="true"
|
||||
className="grid size-4 shrink-0 place-items-center rounded text-muted-foreground/60 opacity-60 transition-opacity hover:text-foreground group-hover:opacity-100 focus-visible:outline-none"
|
||||
onClick={beginEdit}
|
||||
tabIndex={-1}
|
||||
type="button"
|
||||
>
|
||||
<Pencil className="size-3" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{branchLabel && (
|
||||
<div className={cn(quietControl, bleed, 'flex min-w-0 items-center gap-1 px-1.5 py-1 text-[0.6875rem]')}>
|
||||
<GitBranch className="size-3 shrink-0 text-muted-foreground/60" />
|
||||
<span className="min-w-0 truncate font-mono text-foreground/75">{branchLabel}</span>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
function personalityOptionKey(value?: string): string {
|
||||
const key = value?.trim().toLowerCase() || 'none'
|
||||
|
||||
return key === 'default' ? 'none' : key
|
||||
}
|
||||
|
||||
function AgentSection({
|
||||
label: modelLabel,
|
||||
onOpen,
|
||||
providerName,
|
||||
current,
|
||||
options,
|
||||
onSelect
|
||||
}: {
|
||||
label: string
|
||||
title?: string
|
||||
providerName?: string
|
||||
onOpen?: () => void
|
||||
current: string
|
||||
options: string[]
|
||||
onSelect?: (name: string) => void
|
||||
}) {
|
||||
const [open, setOpen] = useState(false)
|
||||
|
||||
const merged = useMemo(
|
||||
() => [...new Set(['none', ...options, current].map(personalityOptionKey).filter(Boolean))],
|
||||
[current, options]
|
||||
)
|
||||
|
||||
const activeKey = personalityOptionKey(current)
|
||||
const personalityLabel = activeKey === 'none' ? 'None' : titleize(activeKey)
|
||||
|
||||
return (
|
||||
<section className="grid gap-1.5 py-1.5">
|
||||
<SectionLabel>Agent</SectionLabel>
|
||||
<button
|
||||
aria-label="Change model"
|
||||
className={cn(quietControl, bleed, disabledRow, 'group grid gap-px px-1.5 py-1 text-left')}
|
||||
disabled={!onOpen}
|
||||
onClick={onOpen}
|
||||
type="button"
|
||||
>
|
||||
<span className="flex items-center gap-1.5">
|
||||
<span className="min-w-0 flex-1 truncate font-mono text-[0.6875rem] text-foreground/85">
|
||||
{modelLabel || 'Hermes'}
|
||||
</span>
|
||||
{onOpen && (
|
||||
<ChevronDown className="size-3 shrink-0 text-muted-foreground/60 opacity-0 transition-opacity group-hover:opacity-100" />
|
||||
)}
|
||||
</span>
|
||||
{providerName && <span className="truncate text-[0.625rem] text-muted-foreground/70">{providerName}</span>}
|
||||
</button>
|
||||
<DropdownMenu onOpenChange={setOpen} open={open}>
|
||||
<DropdownMenuTrigger asChild disabled={!onSelect}>
|
||||
<button
|
||||
aria-label="Change personality"
|
||||
className={cn(quietControl, bleed, disabledRow, 'group flex items-center gap-1.5 px-1.5 py-1 text-left')}
|
||||
type="button"
|
||||
>
|
||||
<span className="min-w-0 flex-1 truncate text-[0.6875rem] text-muted-foreground group-hover:text-foreground group-focus-visible:text-foreground">
|
||||
{personalityLabel}
|
||||
</span>
|
||||
{onSelect && (
|
||||
<ChevronDown className="size-3 shrink-0 text-muted-foreground/60 opacity-0 transition-opacity group-hover:opacity-100" />
|
||||
)}
|
||||
</button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent
|
||||
align="end"
|
||||
className="w-52 border-border/70 bg-popover/95 shadow-md"
|
||||
side="bottom"
|
||||
sideOffset={6}
|
||||
>
|
||||
<DropdownMenuLabel className="text-xs text-muted-foreground">Personality</DropdownMenuLabel>
|
||||
<DropdownMenuSeparator />
|
||||
{merged.map(name => (
|
||||
<DropdownMenuCheckboxItem
|
||||
checked={activeKey === name}
|
||||
className="text-xs text-muted-foreground focus:text-foreground"
|
||||
key={name}
|
||||
onSelect={e => {
|
||||
e.preventDefault()
|
||||
onSelect?.(name)
|
||||
setOpen(false)
|
||||
}}
|
||||
>
|
||||
{titleize(name)}
|
||||
</DropdownMenuCheckboxItem>
|
||||
))}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
function compactPath(path: string): string {
|
||||
if (!path) {
|
||||
return ''
|
||||
}
|
||||
|
||||
const normalized = path.replace(/\\/g, '/').replace(/\/+$/, '')
|
||||
const parts = normalized.split('/').filter(Boolean)
|
||||
|
||||
return parts.length <= 4 ? normalized || path : `.../${parts.slice(-3).join('/')}`
|
||||
}
|
||||
|
||||
function titleize(value: string): string {
|
||||
return value
|
||||
.replace(/[-_]+/g, ' ')
|
||||
.replace(/\s+/g, ' ')
|
||||
.trim()
|
||||
.replace(/(^|\s)\S/g, m => m.toUpperCase())
|
||||
}
|
||||
|
|
|
|||
|
|
@ -31,6 +31,9 @@ export type GatewayEventPayload = {
|
|||
todos?: unknown
|
||||
model?: string
|
||||
provider?: string
|
||||
reasoning_effort?: string
|
||||
service_tier?: string
|
||||
fast?: boolean
|
||||
running?: boolean
|
||||
cwd?: string
|
||||
branch?: string
|
||||
|
|
|
|||
|
|
@ -25,6 +25,21 @@ describe('preview target detection', () => {
|
|||
])
|
||||
})
|
||||
|
||||
it('accepts bare html files and common preview directories', () => {
|
||||
expect(extractPreviewCandidates('Open index.html, nested/demo.html, ./dist, and /tmp/site/.')).toEqual([
|
||||
'index.html',
|
||||
'nested/demo.html',
|
||||
'./dist',
|
||||
'/tmp/site/'
|
||||
])
|
||||
})
|
||||
|
||||
it('rejects non-html file URLs and obvious local API or asset URLs', () => {
|
||||
expect(isLikelyPreviewCandidate('file:///tmp/demo.png')).toBe(false)
|
||||
expect(isLikelyPreviewCandidate('http://localhost:3000/api/users')).toBe(false)
|
||||
expect(isLikelyPreviewCandidate('http://localhost:3000/src/main.tsx')).toBe(false)
|
||||
})
|
||||
|
||||
it('ignores remote web URLs', () => {
|
||||
expect(isLikelyPreviewCandidate('https://example.com/demo')).toBe(false)
|
||||
expect(isLikelyPreviewCandidate('http://127.0.0.1:3000')).toBe(true)
|
||||
|
|
|
|||
|
|
@ -1,9 +1,29 @@
|
|||
const LOCAL_HOSTS = new Set(['0.0.0.0', '127.0.0.1', '::1', '[::1]', 'localhost'])
|
||||
|
||||
const PREVIEW_DIRECTORY_NAMES = new Set(['build', 'dist', 'out', 'public', 'site', 'web', 'www'])
|
||||
|
||||
const HTML_EXT_RE = /\.html?(?:[?#].*)?$/i
|
||||
|
||||
const ASSET_EXT_RE =
|
||||
/\.(?:cjs|css|csv|gif|ico|jpe?g|js|json|jsx|map|mjs|otf|png|svg|ts|tsx|ttf|txt|wasm|webp|woff2?|xml)$/i
|
||||
|
||||
const URL_RE = /\bhttps?:\/\/[^\s<>"'`)\]]+/gi
|
||||
|
||||
const FILE_URL_RE = /\bfile:\/\/[^\s<>"'`)\]]+/gi
|
||||
const POSIX_HTML_PATH_RE = /(?:^|[\s("'`])(?<path>\/[^\s<>"'`]*?\.html?)(?:[),.;:!?]*)(?=$|[\s)"'`])/gi
|
||||
const RELATIVE_HTML_PATH_RE = /(?:^|[\s("'`])(?<path>\.{1,2}\/[^\s<>"'`]*?\.html?)(?:[),.;:!?]*)(?=$|[\s)"'`])/gi
|
||||
|
||||
const POSIX_HTML_PATH_RE =
|
||||
/(?:^|[\s("'`])(?<path>\/[^\s<>"'`]*?\.html?(?:[?#][^\s<>"'`)\]]*)?)(?:[),.;:!?]*)(?=$|[\s)"'`])/gi
|
||||
|
||||
const RELATIVE_HTML_PATH_RE =
|
||||
/(?:^|[\s("'`])(?<path>\.{1,2}\/[^\s<>"'`]*?\.html?(?:[?#][^\s<>"'`)\]]*)?)(?:[),.;:!?]*)(?=$|[\s)"'`])/gi
|
||||
|
||||
const BARE_HTML_PATH_RE =
|
||||
/(?:^|[\s("'`])(?<path>(?:[A-Za-z0-9._-]+\/)*[A-Za-z0-9._-]+\.html?(?:[?#][^\s<>"'`)\]]*)?)(?:[),.;:!?]*)(?=$|[\s)"'`])/gi
|
||||
|
||||
const POSIX_PATH_RE = /(?:^|[\s("'`])(?<path>\/[^\s<>"'`]+)(?:[),.;:!?]*)(?=$|[\s)"'`])/gi
|
||||
|
||||
const RELATIVE_PATH_RE = /(?:^|[\s("'`])(?<path>(?:\.{1,2}|~)\/[^\s<>"'`]+)(?:[),.;:!?]*)(?=$|[\s)"'`])/gi
|
||||
|
||||
const PREVIEW_MARKDOWN_RE = /\[Preview:[^\]]+\]\((?<href>#preview[:/][^)]+)\)/gi
|
||||
|
||||
interface PreviewCandidateMatch {
|
||||
|
|
@ -16,6 +36,40 @@ function stripTrailingPunctuation(value: string): string {
|
|||
return value.replace(/[),.;:!?]+$/, '')
|
||||
}
|
||||
|
||||
function pathWithoutQuery(value: string): string {
|
||||
return value.split(/[?#]/, 1)[0]
|
||||
}
|
||||
|
||||
function pathBasename(value: string): string {
|
||||
return pathWithoutQuery(value).replace(/\/+$/, '').split(/[\\/]/).filter(Boolean).pop()?.toLowerCase() || ''
|
||||
}
|
||||
|
||||
function isHtmlFileUrl(value: string): boolean {
|
||||
try {
|
||||
const url = new URL(value)
|
||||
|
||||
return url.protocol === 'file:' && HTML_EXT_RE.test(url.pathname)
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
function isPreviewDirectoryCandidate(value: string): boolean {
|
||||
const path = pathWithoutQuery(value)
|
||||
|
||||
if (!/^(?:\/|\.{1,2}\/|~\/)/.test(path) || HTML_EXT_RE.test(value)) {
|
||||
return false
|
||||
}
|
||||
|
||||
const name = pathBasename(path)
|
||||
|
||||
if (!name || /\.[a-z0-9]{1,8}$/i.test(name)) {
|
||||
return false
|
||||
}
|
||||
|
||||
return path.endsWith('/') || PREVIEW_DIRECTORY_NAMES.has(name)
|
||||
}
|
||||
|
||||
function isLocalPreviewUrl(value: string): boolean {
|
||||
try {
|
||||
const url = new URL(value)
|
||||
|
|
@ -24,7 +78,17 @@ function isLocalPreviewUrl(value: string): boolean {
|
|||
return false
|
||||
}
|
||||
|
||||
return LOCAL_HOSTS.has(url.hostname.toLowerCase())
|
||||
if (!LOCAL_HOSTS.has(url.hostname.toLowerCase())) {
|
||||
return false
|
||||
}
|
||||
|
||||
const pathname = url.pathname.toLowerCase()
|
||||
|
||||
if (/^\/(?:api|graphql|health|metrics|rpc)(?:\/|$)/.test(pathname)) {
|
||||
return false
|
||||
}
|
||||
|
||||
return !ASSET_EXT_RE.test(pathname)
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
|
|
@ -33,7 +97,7 @@ function isLocalPreviewUrl(value: string): boolean {
|
|||
export function isLikelyPreviewCandidate(value: string): boolean {
|
||||
const trimmed = stripTrailingPunctuation(value.trim())
|
||||
|
||||
return trimmed.startsWith('file://') || HTML_EXT_RE.test(trimmed) || isLocalPreviewUrl(trimmed)
|
||||
return isHtmlFileUrl(trimmed) || HTML_EXT_RE.test(trimmed) || isPreviewDirectoryCandidate(trimmed) || isLocalPreviewUrl(trimmed)
|
||||
}
|
||||
|
||||
function collectPreviewMatches(text: string): PreviewCandidateMatch[] {
|
||||
|
|
@ -76,6 +140,18 @@ function collectPreviewMatches(text: string): PreviewCandidateMatch[] {
|
|||
collect(match.index, match[0], match.groups?.path || '')
|
||||
}
|
||||
|
||||
for (const match of text.matchAll(BARE_HTML_PATH_RE)) {
|
||||
collect(match.index, match[0], match.groups?.path || '')
|
||||
}
|
||||
|
||||
for (const match of text.matchAll(POSIX_PATH_RE)) {
|
||||
collect(match.index, match[0], match.groups?.path || '')
|
||||
}
|
||||
|
||||
for (const match of text.matchAll(RELATIVE_PATH_RE)) {
|
||||
collect(match.index, match[0], match.groups?.path || '')
|
||||
}
|
||||
|
||||
return matches.sort((a, b) => a.index - b.index)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -7,7 +7,16 @@ export interface PreviewTarget {
|
|||
url: string
|
||||
}
|
||||
|
||||
export interface PreviewServerRestart {
|
||||
message?: string
|
||||
status: 'complete' | 'error' | 'running'
|
||||
taskId: string
|
||||
url: string
|
||||
}
|
||||
|
||||
export const $previewTarget = atom<PreviewTarget | null>(null)
|
||||
export const $previewReloadRequest = atom(0)
|
||||
export const $previewServerRestart = atom<PreviewServerRestart | null>(null)
|
||||
|
||||
function isSamePreviewTarget(a: PreviewTarget | null, b: PreviewTarget | null): boolean {
|
||||
if (a === b) {
|
||||
|
|
@ -28,3 +37,52 @@ export function setPreviewTarget(target: PreviewTarget | null) {
|
|||
|
||||
$previewTarget.set(target)
|
||||
}
|
||||
|
||||
export function requestPreviewReload() {
|
||||
$previewReloadRequest.set($previewReloadRequest.get() + 1)
|
||||
}
|
||||
|
||||
export function beginPreviewServerRestart(taskId: string, url: string) {
|
||||
$previewServerRestart.set({ status: 'running', taskId, url })
|
||||
}
|
||||
|
||||
export function completePreviewServerRestart(taskId: string, text: string) {
|
||||
const current = $previewServerRestart.get()
|
||||
|
||||
if (current?.taskId !== taskId) {
|
||||
return
|
||||
}
|
||||
|
||||
$previewServerRestart.set({
|
||||
...current,
|
||||
message: text,
|
||||
status: text.trim().toLowerCase().startsWith('error:') ? 'error' : 'complete'
|
||||
})
|
||||
}
|
||||
|
||||
export function progressPreviewServerRestart(taskId: string, text: string) {
|
||||
const current = $previewServerRestart.get()
|
||||
|
||||
if (current?.taskId !== taskId || current.status !== 'running') {
|
||||
return
|
||||
}
|
||||
|
||||
$previewServerRestart.set({
|
||||
...current,
|
||||
message: text
|
||||
})
|
||||
}
|
||||
|
||||
export function failPreviewServerRestart(taskId: string, message: string) {
|
||||
const current = $previewServerRestart.get()
|
||||
|
||||
if (current?.taskId !== taskId || current.status !== 'running') {
|
||||
return
|
||||
}
|
||||
|
||||
$previewServerRestart.set({
|
||||
...current,
|
||||
message,
|
||||
status: 'error'
|
||||
})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -29,6 +29,9 @@ export const $busy = atom(false)
|
|||
export const $awaitingResponse = atom(false)
|
||||
export const $currentModel = atom('')
|
||||
export const $currentProvider = atom('')
|
||||
export const $currentReasoningEffort = atom('')
|
||||
export const $currentServiceTier = atom('')
|
||||
export const $currentFastMode = atom(false)
|
||||
export const $currentCwd = atom('')
|
||||
export const $currentBranch = atom('')
|
||||
export const $introPersonality = atom('')
|
||||
|
|
@ -51,6 +54,9 @@ export const setBusy = (next: Updater<boolean>) => updateAtom($busy, next)
|
|||
export const setAwaitingResponse = (next: Updater<boolean>) => updateAtom($awaitingResponse, next)
|
||||
export const setCurrentModel = (next: Updater<string>) => updateAtom($currentModel, next)
|
||||
export const setCurrentProvider = (next: Updater<string>) => updateAtom($currentProvider, next)
|
||||
export const setCurrentReasoningEffort = (next: Updater<string>) => updateAtom($currentReasoningEffort, next)
|
||||
export const setCurrentServiceTier = (next: Updater<string>) => updateAtom($currentServiceTier, next)
|
||||
export const setCurrentFastMode = (next: Updater<boolean>) => updateAtom($currentFastMode, next)
|
||||
export const setCurrentCwd = (next: Updater<string>) => updateAtom($currentCwd, next)
|
||||
export const setCurrentBranch = (next: Updater<string>) => updateAtom($currentBranch, next)
|
||||
export const setIntroPersonality = (next: Updater<string>) => updateAtom($introPersonality, next)
|
||||
|
|
|
|||
|
|
@ -51,7 +51,9 @@ export interface GatewayReadyPayload {
|
|||
|
||||
export interface HermesConfig {
|
||||
agent?: {
|
||||
reasoning_effort?: string
|
||||
personalities?: Record<string, unknown>
|
||||
service_tier?: string
|
||||
}
|
||||
display?: {
|
||||
personality?: string
|
||||
|
|
|
|||
|
|
@ -1838,6 +1838,74 @@ def _background_agent_kwargs(agent, task_id: str) -> dict:
|
|||
}
|
||||
|
||||
|
||||
def _ephemeral_preview_agent_kwargs(agent, task_id: str) -> dict:
|
||||
kwargs = _background_agent_kwargs(agent, task_id)
|
||||
kwargs.update(
|
||||
{
|
||||
"enabled_toolsets": ["terminal", "file"],
|
||||
"session_db": None,
|
||||
"skip_memory": True,
|
||||
}
|
||||
)
|
||||
return kwargs
|
||||
|
||||
|
||||
def _preview_tool_result_preview(name: str, result: str) -> str:
|
||||
try:
|
||||
data = json.loads(result)
|
||||
except Exception:
|
||||
return ""
|
||||
|
||||
if not isinstance(data, dict):
|
||||
return ""
|
||||
|
||||
if name == "terminal":
|
||||
output = str(data.get("output") or "").strip()
|
||||
exit_code = data.get("exit_code")
|
||||
if output:
|
||||
return output[-1200:]
|
||||
if data.get("session_id"):
|
||||
return f"Background process started: {data.get('session_id')}"
|
||||
if exit_code is not None:
|
||||
return f"terminal exited with code {exit_code}"
|
||||
|
||||
return str(data.get("error") or "").strip()[:1200]
|
||||
|
||||
|
||||
def _preview_restart_callbacks(parent: str, task_id: str) -> dict:
|
||||
started_at: dict[str, float] = {}
|
||||
|
||||
def progress(message: str, level: str = "info") -> None:
|
||||
text = str(message or "").strip()
|
||||
if text:
|
||||
_emit("preview.restart.progress", parent, {"task_id": task_id, "level": level, "text": text})
|
||||
|
||||
def tool_start(tool_call_id: str, name: str, args: dict) -> None:
|
||||
started_at[tool_call_id] = time.time()
|
||||
ctx = _tool_ctx(name, args)
|
||||
progress(f"Running {name}{f': {ctx}' if ctx else ''}")
|
||||
|
||||
def tool_complete(tool_call_id: str, name: str, _args: dict, result: str) -> None:
|
||||
duration_s = time.time() - started_at.get(tool_call_id, time.time())
|
||||
summary = _tool_summary(name, result, duration_s) or f"Finished {name}{f' in {_fmt_tool_duration(duration_s)}' if duration_s else ''}"
|
||||
output = _preview_tool_result_preview(name, result)
|
||||
progress(summary + (f"\n{output}" if output else ""))
|
||||
|
||||
def tool_progress(event_type: str, name: str | None = None, preview: str | None = None, **_kwargs) -> None:
|
||||
if preview:
|
||||
progress(str(preview))
|
||||
elif name:
|
||||
progress(f"{event_type.replace('.', ' ')}: {name}")
|
||||
|
||||
return {
|
||||
"tool_start_callback": tool_start,
|
||||
"tool_complete_callback": tool_complete,
|
||||
"tool_progress_callback": tool_progress,
|
||||
"tool_gen_callback": lambda name: progress(f"Preparing {name}"),
|
||||
"status_callback": lambda kind, text=None: progress(text if text is not None else kind),
|
||||
}
|
||||
|
||||
|
||||
def _reset_session_agent(sid: str, session: dict) -> dict:
|
||||
tokens = _set_session_context(session["session_key"])
|
||||
try:
|
||||
|
|
@ -3506,6 +3574,87 @@ def _(rid, params: dict) -> dict:
|
|||
return _ok(rid, {"task_id": task_id})
|
||||
|
||||
|
||||
@method("preview.restart")
|
||||
def _(rid, params: dict) -> dict:
|
||||
session, err = _sess(params, rid)
|
||||
if err:
|
||||
return err
|
||||
|
||||
url = str(params.get("url") or "").strip()
|
||||
cwd = str(params.get("cwd") or "").strip()
|
||||
context = str(params.get("context") or "").strip()
|
||||
|
||||
if not url:
|
||||
return _err(rid, 4012, "url required")
|
||||
|
||||
task_id = f"preview_{uuid.uuid4().hex[:6]}"
|
||||
parent = params.get("session_id", "")
|
||||
prompt = "\n".join(
|
||||
line
|
||||
for line in [
|
||||
"The desktop preview pane cannot load a local server URL.",
|
||||
"",
|
||||
f"Preview URL: {url}",
|
||||
f"Current working directory: {cwd or '(unknown)'}",
|
||||
"",
|
||||
f"Preview console:\n{context}" if context else "",
|
||||
"" if context else "",
|
||||
"Restart exactly the app intended for the Preview URL, not Hermes Desktop itself.",
|
||||
"The Preview URL and port are the target. Preserve that target unless you conclude it is impossible.",
|
||||
"First inspect what process, if any, owns the Preview URL port. If a stale server exists, inspect its cwd and prefer that cwd over the Hermes/Desktop process cwd.",
|
||||
"The Current working directory is only a hint. Do not assume it is the preview app root when the port owner or files indicate another root.",
|
||||
"If the console shows a module-script MIME error for src/main.tsx or similar, a static server is serving source files. Do not restart python -m http.server or any dumb static server for that app.",
|
||||
"For module-script MIME failures, inspect package.json/vite config in the candidate app root and start the real dev server/bundler (for example npm/pnpm/yarn dev) so module transforms happen.",
|
||||
"Before declaring success, verify the Preview URL responds with the intended app, not Hermes Desktop. If it serves Hermes/Desktop UI or another unrelated app, stop that process and report failure.",
|
||||
"Do not modify files. Do not ask the user unless blocked.",
|
||||
"Prefer existing project scripts or commands when they are clear.",
|
||||
"If a stale process owns the needed port, handle it safely.",
|
||||
"Start long-running servers detached/in the background, then return immediately.",
|
||||
"Do not run a foreground dev server command that blocks this background task.",
|
||||
"Keep the final response short: what command/server was started, or why it could not be restarted.",
|
||||
]
|
||||
if line
|
||||
)
|
||||
|
||||
def run():
|
||||
session_tokens = _set_session_context(task_id)
|
||||
try:
|
||||
from run_agent import AIAgent
|
||||
from tools.terminal_tool import register_task_env_overrides
|
||||
|
||||
if cwd and os.path.isdir(os.path.abspath(os.path.expanduser(cwd))):
|
||||
register_task_env_overrides(task_id, {"cwd": os.path.abspath(os.path.expanduser(cwd))})
|
||||
|
||||
_emit("preview.restart.progress", parent, {"task_id": task_id, "text": "Starting hidden restart agent"})
|
||||
result = AIAgent(
|
||||
**_ephemeral_preview_agent_kwargs(session["agent"], task_id),
|
||||
**_preview_restart_callbacks(parent, task_id),
|
||||
).run_conversation(user_message=prompt, task_id=task_id)
|
||||
text = (
|
||||
result.get("final_response", str(result))
|
||||
if isinstance(result, dict)
|
||||
else str(result)
|
||||
)
|
||||
_emit("preview.restart.complete", parent, {"task_id": task_id, "text": text})
|
||||
except Exception as e:
|
||||
_emit(
|
||||
"preview.restart.complete",
|
||||
parent,
|
||||
{"task_id": task_id, "text": f"error: {e}"},
|
||||
)
|
||||
finally:
|
||||
try:
|
||||
from tools.terminal_tool import clear_task_env_overrides
|
||||
|
||||
clear_task_env_overrides(task_id)
|
||||
except Exception:
|
||||
pass
|
||||
_clear_session_context(session_tokens)
|
||||
|
||||
threading.Thread(target=run, daemon=True).start()
|
||||
return _ok(rid, {"task_id": task_id})
|
||||
|
||||
|
||||
# ── Methods: respond ─────────────────────────────────────────────────
|
||||
|
||||
|
||||
|
|
@ -3900,7 +4049,10 @@ def _(rid, params: dict) -> dict:
|
|||
return _err(rid, 4002, f"working directory does not exist: {raw}")
|
||||
_write_config_key("terminal.cwd", cwd)
|
||||
os.environ["TERMINAL_CWD"] = cwd
|
||||
return _ok(rid, {"key": "terminal.cwd", "value": cwd})
|
||||
return _ok(
|
||||
rid,
|
||||
{"key": "terminal.cwd", "value": cwd, "cwd": cwd, "branch": _git_branch_for_cwd(cwd)},
|
||||
)
|
||||
|
||||
if key in ("prompt", "personality", "skin"):
|
||||
try:
|
||||
|
|
@ -3964,6 +4116,11 @@ def _(rid, params: dict) -> dict:
|
|||
from hermes_constants import display_hermes_home
|
||||
|
||||
return _ok(rid, {"home": str(_hermes_home), "display": display_hermes_home()})
|
||||
if key == "project":
|
||||
cfg_terminal = _load_cfg().get("terminal") or {}
|
||||
raw = str(params.get("cwd", "") or cfg_terminal.get("cwd", "") or "").strip()
|
||||
cwd = _completion_cwd({"cwd": raw} if raw else {})
|
||||
return _ok(rid, {"cwd": cwd, "branch": _git_branch_for_cwd(cwd)})
|
||||
if key == "full":
|
||||
return _ok(rid, {"config": _load_cfg()})
|
||||
if key == "prompt":
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue