chore: uptick

This commit is contained in:
Brooklyn Nicholson
2026-05-03 12:40:03 -05:00
parent fd97a7cba4
commit fa92720d2c
22 changed files with 1203 additions and 348 deletions
+9 -1
View File
@@ -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}
/>
</>
)
@@ -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())
}
+38 -10
View File
@@ -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}
/>
)}
@@ -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('/')}`
}
@@ -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>
)
}
@@ -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>
)
}
@@ -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>
)
}
@@ -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>
)
}
+167 -8
View File
@@ -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)
+2 -13
View File
@@ -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,
+31 -263
View File
@@ -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())
}
+3
View File
@@ -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)
+80 -4
View File
@@ -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)
}
+58
View File
@@ -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'
})
}
+6
View File
@@ -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)
+2
View File
@@ -51,7 +51,9 @@ export interface GatewayReadyPayload {
export interface HermesConfig {
agent?: {
reasoning_effort?: string
personalities?: Record<string, unknown>
service_tier?: string
}
display?: {
personality?: string