Files
hermes-agent/apps/desktop/src/components/session-inspector.tsx
T

314 lines
9.7 KiB
TypeScript

'use client'
import { ChevronDown, FolderOpen, GitBranch, Pencil } from 'lucide-react'
import { type FC, useEffect, useMemo, useRef, useState } from 'react'
import {
DropdownMenu,
DropdownMenuCheckboxItem,
DropdownMenuContent,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger
} from '@/components/ui/dropdown-menu'
import { Input } from '@/components/ui/input'
import { cn } from '@/lib/utils'
export type SessionInspectorProps = {
open: boolean
cwd: string
branch: string
busy: boolean
modelLabel: string
modelTitle?: string
providerName?: string
personality: string
personalities: string[]
onChangeCwd?: (cwd: string) => void
onBrowseCwd?: () => void
onOpenModelPicker?: () => 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,
personality,
personalities,
onChangeCwd,
onBrowseCwd,
onOpenModelPicker,
onSelectPersonality
}) => (
<aside
aria-hidden={!open}
className={cn(
'relative flex h-screen w-full min-w-0 flex-col overflow-hidden bg-transparent pb-2 pl-2 pr-3 pt-[calc(var(--titlebar-height)+0.25rem)] text-muted-foreground transition-[opacity,transform] duration-300 ease-[cubic-bezier(0.22,1,0.36,1)]',
open ? 'translate-x-0 opacity-100' : 'pointer-events-none translate-x-2 opacity-0'
)}
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} />
<AgentSection
current={personality}
label={modelLabel}
onOpen={onOpenModelPicker}
onSelect={onSelectPersonality}
options={personalities}
providerName={providerName}
title={modelTitle}
/>
</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="h-7 bg-background px-2 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,
'group grid w-full 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())
}