feat: file preview and folder tree etc
This commit is contained in:
@@ -0,0 +1,134 @@
|
||||
import { useStore } from '@nanostores/react'
|
||||
import { useMemo, useState } from 'react'
|
||||
|
||||
import { Activity, AlertCircle, Layers3, Loader2, type LucideIcon, RefreshCw, Sparkles } from '@/lib/icons'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { $desktopActionTasks, buildRailTasks, type RailTask, type RailTaskStatus } from '@/store/activity'
|
||||
import { $previewServerRestart } from '@/store/preview'
|
||||
import { $sessions, $workingSessionIds } from '@/store/session'
|
||||
|
||||
import { OverlayCard } from '../overlays/overlay-chrome'
|
||||
import { OverlayMain, OverlayNavItem, OverlaySidebar, OverlaySplitLayout } from '../overlays/overlay-split-layout'
|
||||
import { OverlayView } from '../overlays/overlay-view'
|
||||
|
||||
type AgentsSection = 'tree' | 'activity' | 'history'
|
||||
|
||||
interface SectionDef {
|
||||
description: string
|
||||
icon: LucideIcon
|
||||
id: AgentsSection
|
||||
label: string
|
||||
}
|
||||
|
||||
const SECTIONS: readonly SectionDef[] = [
|
||||
{ description: 'Live subagent spawn tree for the current turn', icon: Layers3, id: 'tree', label: 'Spawn tree' },
|
||||
{ description: 'Background work across sessions and the desktop', icon: Activity, id: 'activity', label: 'Activity' },
|
||||
{ description: 'Past spawn snapshots, replay, and diff', icon: RefreshCw, id: 'history', label: 'History' }
|
||||
]
|
||||
|
||||
const STATUS_TONE: Record<RailTaskStatus, string> = {
|
||||
error: 'text-destructive',
|
||||
running: 'text-foreground',
|
||||
success: 'text-emerald-500'
|
||||
}
|
||||
|
||||
const STATUS_ICON: Record<RailTaskStatus, LucideIcon> = {
|
||||
error: AlertCircle,
|
||||
running: Loader2,
|
||||
success: Sparkles
|
||||
}
|
||||
|
||||
interface AgentsViewProps {
|
||||
initialSection?: AgentsSection
|
||||
onClose: () => void
|
||||
}
|
||||
|
||||
export function AgentsView({ initialSection = 'tree', onClose }: AgentsViewProps) {
|
||||
const [section, setSection] = useState<AgentsSection>(initialSection)
|
||||
|
||||
const sessions = useStore($sessions)
|
||||
const workingSessionIds = useStore($workingSessionIds)
|
||||
const previewRestart = useStore($previewServerRestart)
|
||||
const desktopActionTasks = useStore($desktopActionTasks)
|
||||
|
||||
const activityTasks = useMemo(
|
||||
() => buildRailTasks(workingSessionIds, sessions, previewRestart, desktopActionTasks),
|
||||
[desktopActionTasks, previewRestart, sessions, workingSessionIds]
|
||||
)
|
||||
|
||||
const active = SECTIONS.find(s => s.id === section) ?? SECTIONS[0]!
|
||||
|
||||
return (
|
||||
<OverlayView closeLabel="Close agents" onClose={onClose}>
|
||||
<OverlaySplitLayout>
|
||||
<OverlaySidebar>
|
||||
{SECTIONS.map(s => (
|
||||
<OverlayNavItem
|
||||
active={s.id === section}
|
||||
icon={s.icon}
|
||||
key={s.id}
|
||||
label={s.label}
|
||||
onClick={() => setSection(s.id)}
|
||||
/>
|
||||
))}
|
||||
</OverlaySidebar>
|
||||
|
||||
<OverlayMain>
|
||||
<header className="mb-4">
|
||||
<h2 className="text-sm font-semibold text-foreground">{active.label}</h2>
|
||||
<p className="text-xs text-muted-foreground">{active.description}</p>
|
||||
</header>
|
||||
|
||||
{section === 'activity' ? <ActivityList tasks={activityTasks} /> : <SectionStub label={active.label} />}
|
||||
</OverlayMain>
|
||||
</OverlaySplitLayout>
|
||||
</OverlayView>
|
||||
)
|
||||
}
|
||||
|
||||
function ActivityList({ tasks }: { tasks: readonly RailTask[] }) {
|
||||
if (tasks.length === 0) {
|
||||
return (
|
||||
<OverlayCard className="px-3 py-4 text-sm text-muted-foreground">
|
||||
No background activity. Long-running tools, preview restarts, and parallel sessions surface here.
|
||||
</OverlayCard>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="grid min-h-0 gap-1.5 overflow-y-auto pr-1">
|
||||
{tasks.map(task => {
|
||||
const Icon = STATUS_ICON[task.status]
|
||||
|
||||
return (
|
||||
<OverlayCard className="flex items-start gap-2.5 px-3 py-2" key={task.id}>
|
||||
<Icon
|
||||
className={cn('mt-0.5 size-3.5 shrink-0', STATUS_TONE[task.status], task.status === 'running' && 'animate-spin')}
|
||||
/>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="truncate text-sm font-medium text-foreground">{task.label}</div>
|
||||
{task.detail && <div className="truncate text-xs text-muted-foreground">{task.detail}</div>}
|
||||
</div>
|
||||
</OverlayCard>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function SectionStub({ label }: { label: string }) {
|
||||
return (
|
||||
<OverlayCard className="grid place-items-center gap-3 px-6 py-12 text-center">
|
||||
<Sparkles className="size-6 text-muted-foreground/70" />
|
||||
<div className="grid gap-1">
|
||||
<p className="text-sm font-medium text-foreground">{label} — coming soon</p>
|
||||
<p className="max-w-md text-xs leading-relaxed text-muted-foreground">
|
||||
Subagent stores aren't wired into the desktop yet. Once gateway events for{' '}
|
||||
<code className="rounded bg-muted/60 px-1 py-0.5 font-mono text-[0.65rem]">subagent.spawn / progress / complete</code>{' '}
|
||||
land here, this view shows the live spawn tree, replay history, and pause/kill controls — modelled on the TUI's{' '}
|
||||
<code className="rounded bg-muted/60 px-1 py-0.5 font-mono text-[0.65rem]">/agents</code> overlay.
|
||||
</p>
|
||||
</div>
|
||||
</OverlayCard>
|
||||
)
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import { useNavigate } from 'react-router-dom'
|
||||
import { ZoomableImage } from '@/components/assistant-ui/zoomable-image'
|
||||
import { PageLoader } from '@/components/page-loader'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { CopyButton } from '@/components/ui/copy-button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import {
|
||||
Pagination,
|
||||
@@ -17,9 +18,9 @@ import {
|
||||
} from '@/components/ui/pagination'
|
||||
import { getSessionMessages, listSessions } from '@/hermes'
|
||||
import { sessionTitle } from '@/lib/chat-runtime'
|
||||
import { Copy, ExternalLink, FileImage, FileText, FolderOpen, Layers3, Link2, RefreshCw, Search, X } from '@/lib/icons'
|
||||
import { ExternalLink, FileImage, FileText, FolderOpen, Layers3, Link2, RefreshCw, Search, X } from '@/lib/icons'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { notify, notifyError } from '@/store/notifications'
|
||||
import { notifyError } from '@/store/notifications'
|
||||
import type { SessionInfo, SessionMessage } from '@/types/hermes'
|
||||
|
||||
import { sessionRoute } from '../routes'
|
||||
@@ -346,7 +347,11 @@ interface ArtifactsViewProps extends React.ComponentProps<'section'> {
|
||||
setTitlebarToolGroup?: SetTitlebarToolGroup
|
||||
}
|
||||
|
||||
export function ArtifactsView({ setStatusbarItemGroup: _setStatusbarItemGroup, setTitlebarToolGroup, ...props }: ArtifactsViewProps) {
|
||||
export function ArtifactsView({
|
||||
setStatusbarItemGroup: _setStatusbarItemGroup,
|
||||
setTitlebarToolGroup,
|
||||
...props
|
||||
}: ArtifactsViewProps) {
|
||||
const navigate = useNavigate()
|
||||
const [artifacts, setArtifacts] = useState<ArtifactRecord[] | null>(null)
|
||||
const [query, setQuery] = useState('')
|
||||
@@ -469,24 +474,6 @@ export function ArtifactsView({ setStatusbarItemGroup: _setStatusbarItemGroup, s
|
||||
}
|
||||
}, [artifacts])
|
||||
|
||||
const copyArtifact = useCallback(async (value: string) => {
|
||||
try {
|
||||
if (window.hermesDesktop?.writeClipboard) {
|
||||
await window.hermesDesktop.writeClipboard(value)
|
||||
} else if (navigator.clipboard?.writeText) {
|
||||
await navigator.clipboard.writeText(value)
|
||||
}
|
||||
|
||||
notify({
|
||||
kind: 'success',
|
||||
title: 'Copied',
|
||||
message: value
|
||||
})
|
||||
} catch (err) {
|
||||
notifyError(err, 'Copy failed')
|
||||
}
|
||||
}, [])
|
||||
|
||||
const openArtifact = useCallback(async (href: string) => {
|
||||
try {
|
||||
if (window.hermesDesktop?.openExternal) {
|
||||
@@ -510,10 +497,7 @@ export function ArtifactsView({ setStatusbarItemGroup: _setStatusbarItemGroup, s
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<section
|
||||
{...props}
|
||||
className="flex h-full min-w-0 flex-col overflow-hidden rounded-[0.9375rem] bg-background"
|
||||
>
|
||||
<section {...props} className="flex h-full min-w-0 flex-col overflow-hidden rounded-[0.9375rem] bg-background">
|
||||
<header className={titlebarHeaderBaseClass}>
|
||||
<h2 className="pointer-events-auto text-base font-semibold leading-none tracking-tight">Artifacts</h2>
|
||||
<span className="pointer-events-auto text-xs text-muted-foreground">{counts.all} found</span>
|
||||
@@ -645,7 +629,6 @@ export function ArtifactsView({ setStatusbarItemGroup: _setStatusbarItemGroup, s
|
||||
<ArtifactListRow
|
||||
artifact={artifact}
|
||||
key={artifact.id}
|
||||
onCopy={copyArtifact}
|
||||
onOpen={openArtifact}
|
||||
onOpenChat={sessionId => navigate(sessionRoute(sessionId))}
|
||||
/>
|
||||
@@ -804,12 +787,11 @@ function ArtifactImageCard({ artifact, failedImage, onImageError, onOpenChat }:
|
||||
|
||||
interface ArtifactListRowProps {
|
||||
artifact: ArtifactRecord
|
||||
onCopy: (value: string) => void | Promise<void>
|
||||
onOpen: (href: string) => void | Promise<void>
|
||||
onOpenChat: (sessionId: string) => void
|
||||
}
|
||||
|
||||
function ArtifactListRow({ artifact, onCopy, onOpen, onOpenChat }: ArtifactListRowProps) {
|
||||
function ArtifactListRow({ artifact, onOpen, onOpenChat }: ArtifactListRowProps) {
|
||||
const Icon = artifact.kind === 'file' ? FileText : Link2
|
||||
|
||||
return (
|
||||
@@ -852,16 +834,14 @@ function ArtifactListRow({ artifact, onCopy, onOpen, onOpenChat }: ArtifactListR
|
||||
>
|
||||
<ExternalLink className="size-3.5" />
|
||||
</Button>
|
||||
<Button
|
||||
<CopyButton
|
||||
appearance="button"
|
||||
buttonSize="icon-xs"
|
||||
className="text-muted-foreground hover:text-foreground"
|
||||
onClick={() => void onCopy(artifact.value)}
|
||||
size="icon-xs"
|
||||
title="Copy"
|
||||
type="button"
|
||||
variant="ghost"
|
||||
>
|
||||
<Copy className="size-3.5" />
|
||||
</Button>
|
||||
iconClassName="size-3.5"
|
||||
label="Copy"
|
||||
text={artifact.value}
|
||||
/>
|
||||
<Button
|
||||
className="text-muted-foreground hover:text-foreground"
|
||||
onClick={() => onOpenChat(artifact.sessionId)}
|
||||
|
||||
@@ -1,5 +1,11 @@
|
||||
import { useStore } from '@nanostores/react'
|
||||
|
||||
import { FileText, FolderOpen, ImageIcon, Link, X } from '@/lib/icons'
|
||||
import { normalizeOrLocalPreviewTarget } from '@/lib/local-preview'
|
||||
import type { ComposerAttachment } from '@/store/composer'
|
||||
import { notifyError } from '@/store/notifications'
|
||||
import { setCurrentSessionPreviewTarget } from '@/store/preview'
|
||||
import { $currentCwd } from '@/store/session'
|
||||
|
||||
export function AttachmentList({
|
||||
attachments,
|
||||
@@ -9,7 +15,7 @@ export function AttachmentList({
|
||||
onRemove?: (id: string) => void
|
||||
}) {
|
||||
return (
|
||||
<div className="flex flex-wrap gap-1 px-1 pt-1">
|
||||
<div className="flex max-w-full flex-wrap gap-1.5 px-1 pt-1" data-slot="composer-attachments">
|
||||
{attachments.map(a => (
|
||||
<AttachmentPill attachment={a} key={a.id} onRemove={onRemove} />
|
||||
))}
|
||||
@@ -19,21 +25,62 @@ export function AttachmentList({
|
||||
|
||||
function AttachmentPill({ attachment, onRemove }: { attachment: ComposerAttachment; onRemove?: (id: string) => void }) {
|
||||
const Icon = { folder: FolderOpen, url: Link, image: ImageIcon, file: FileText }[attachment.kind]
|
||||
const cwd = useStore($currentCwd)
|
||||
const canPreview = attachment.kind !== 'folder'
|
||||
const detail = attachment.detail && attachment.detail !== attachment.label ? attachment.detail : undefined
|
||||
|
||||
async function openPreview() {
|
||||
if (!canPreview) {
|
||||
return
|
||||
}
|
||||
|
||||
const rawTarget = attachment.path || attachment.detail || attachment.refText?.replace(/^@(file|image|url):/, '') || attachment.label || ''
|
||||
const target = rawTarget.replace(/^`|`$/g, '')
|
||||
|
||||
if (!target) {
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const preview = await normalizeOrLocalPreviewTarget(target, cwd || undefined)
|
||||
|
||||
if (!preview) {
|
||||
throw new Error(`Could not preview ${attachment.label}`)
|
||||
}
|
||||
|
||||
setCurrentSessionPreviewTarget(preview, 'manual', target)
|
||||
} catch (error) {
|
||||
notifyError(error, 'Preview unavailable')
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="group/attachment relative shrink-0" title={attachment.label}>
|
||||
{attachment.previewUrl && attachment.kind === 'image' ? (
|
||||
<img
|
||||
alt={attachment.label}
|
||||
className="size-7 rounded-md border border-border/70 object-cover"
|
||||
draggable={false}
|
||||
src={attachment.previewUrl}
|
||||
/>
|
||||
) : (
|
||||
<span className="grid size-7 place-items-center rounded-md border border-border/70 bg-muted/30 text-muted-foreground">
|
||||
<Icon className="size-3.5" />
|
||||
<div className="group/attachment relative min-w-0 shrink-0" title={attachment.path || attachment.detail || attachment.label}>
|
||||
<button
|
||||
aria-label={canPreview ? `Preview ${attachment.label}` : attachment.label}
|
||||
className="flex max-w-56 items-center gap-2 border border-border/60 bg-background/50 px-2 py-1.5 text-left shadow-[inset_0_1px_0_rgba(255,255,255,0.25)] transition-colors hover:border-primary/35 hover:bg-accent/45 disabled:cursor-default"
|
||||
disabled={!canPreview}
|
||||
onClick={() => void openPreview()}
|
||||
title={canPreview ? `Preview ${attachment.label}` : attachment.label}
|
||||
type="button"
|
||||
>
|
||||
{attachment.previewUrl && attachment.kind === 'image' ? (
|
||||
<img
|
||||
alt={attachment.label}
|
||||
className="size-8 shrink-0 border border-border/70 object-cover"
|
||||
draggable={false}
|
||||
src={attachment.previewUrl}
|
||||
/>
|
||||
) : (
|
||||
<span className="grid size-8 shrink-0 place-items-center border border-border/55 bg-muted/35 text-muted-foreground">
|
||||
<Icon className="size-3.5" />
|
||||
</span>
|
||||
)}
|
||||
<span className="min-w-0">
|
||||
<span className="block truncate text-[0.72rem] font-medium leading-4 text-foreground/90">{attachment.label}</span>
|
||||
{detail && <span className="block truncate font-mono text-[0.6rem] leading-3 text-muted-foreground/65">{detail}</span>}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
{onRemove && (
|
||||
<button
|
||||
aria-label={`Remove ${attachment.label}`}
|
||||
|
||||
@@ -1,48 +0,0 @@
|
||||
import type { Unstable_TriggerAdapter, Unstable_TriggerItem } from '@assistant-ui/core'
|
||||
import { ComposerPrimitive, type Unstable_MentionDirective } from '@assistant-ui/react'
|
||||
|
||||
import { COMPLETION_DRAWER_ROW_CLASS, CompletionDrawerEmpty, ComposerCompletionDrawer } from './completion-drawer'
|
||||
|
||||
export function DirectivePopover({
|
||||
adapter,
|
||||
directive,
|
||||
loading = false
|
||||
}: {
|
||||
adapter: Unstable_TriggerAdapter
|
||||
directive: Unstable_MentionDirective
|
||||
loading?: boolean
|
||||
}) {
|
||||
return (
|
||||
<ComposerCompletionDrawer adapter={adapter} ariaLabel="Reference suggestions" char="@">
|
||||
<ComposerPrimitive.Unstable_TriggerPopover.Directive {...directive} />
|
||||
<ComposerPrimitive.Unstable_TriggerPopoverItems>
|
||||
{items => (
|
||||
<div className="grid gap-0.5 pt-0.5">
|
||||
{items.length === 0 ? (
|
||||
<CompletionDrawerEmpty title={loading ? 'Looking up...' : 'No matches.'}>
|
||||
Try <span className="font-mono text-foreground/80">@</span> for shortcuts, or paths like{' '}
|
||||
<span className="font-mono text-foreground/80">@~/Desktop</span> /{' '}
|
||||
<span className="font-mono text-foreground/80">@./src</span>.
|
||||
</CompletionDrawerEmpty>
|
||||
) : (
|
||||
items.map((item, index) => <DirectiveRow index={index} item={item} key={item.id} />)
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</ComposerPrimitive.Unstable_TriggerPopoverItems>
|
||||
</ComposerCompletionDrawer>
|
||||
)
|
||||
}
|
||||
|
||||
function DirectiveRow({ index, item }: { index: number; item: Unstable_TriggerItem }) {
|
||||
const metadata = item.metadata as { display?: string; meta?: string } | undefined
|
||||
const display = metadata?.display || item.label
|
||||
const description = metadata?.meta || item.description
|
||||
|
||||
return (
|
||||
<ComposerPrimitive.Unstable_TriggerPopoverItem className={COMPLETION_DRAWER_ROW_CLASS} index={index} item={item}>
|
||||
<span className="shrink-0 truncate font-mono font-medium leading-5 text-foreground">{display}</span>
|
||||
{description && <span className="min-w-0 truncate leading-5 text-muted-foreground/80">{description}</span>}
|
||||
</ComposerPrimitive.Unstable_TriggerPopoverItem>
|
||||
)
|
||||
}
|
||||
@@ -7,6 +7,28 @@ import type { CompletionEntry, CompletionPayload } from './use-live-completion-a
|
||||
import { useLiveCompletionAdapter } from './use-live-completion-adapter'
|
||||
|
||||
const KIND_RE = /^@(file|folder|url|image|tool|git):(.*)$/
|
||||
const REF_STARTERS = new Set(['file', 'folder', 'url', 'image', 'tool', 'git'])
|
||||
|
||||
const STARTER_META: Record<string, string> = {
|
||||
file: 'Attach a file reference',
|
||||
folder: 'Attach a folder reference',
|
||||
url: 'Attach a URL reference',
|
||||
image: 'Attach an image reference',
|
||||
tool: 'Attach a tool reference',
|
||||
git: 'Attach git context'
|
||||
}
|
||||
|
||||
function starterEntries(query: string): CompletionEntry[] {
|
||||
const q = query.trim().toLowerCase()
|
||||
const kinds = Array.from(REF_STARTERS)
|
||||
const filtered = q ? kinds.filter(kind => kind.startsWith(q)) : kinds
|
||||
|
||||
return filtered.map(kind => ({
|
||||
text: `@${kind}:`,
|
||||
display: `@${kind}:`,
|
||||
meta: STARTER_META[kind] || ''
|
||||
}))
|
||||
}
|
||||
|
||||
interface AtItemMetadata extends Record<string, string> {
|
||||
icon: string
|
||||
@@ -61,11 +83,13 @@ export function useAtCompletions(options: {
|
||||
|
||||
const fetcher = useCallback(
|
||||
async (query: string): Promise<CompletionPayload> => {
|
||||
const starters = starterEntries(query)
|
||||
|
||||
if (!gateway) {
|
||||
return { items: [], query }
|
||||
return { items: starters, query }
|
||||
}
|
||||
|
||||
const word = `@${query}`
|
||||
const word = REF_STARTERS.has(query) ? `@${query}:` : `@${query}`
|
||||
const params: Record<string, unknown> = { word }
|
||||
|
||||
if (sessionId) {
|
||||
@@ -78,10 +102,11 @@ export function useAtCompletions(options: {
|
||||
|
||||
try {
|
||||
const result = await gateway.request<{ items?: CompletionEntry[] }>('complete.path', params)
|
||||
const items = result.items ?? []
|
||||
|
||||
return { items: result.items ?? [], query }
|
||||
return { items: items.length > 0 ? items : starters, query }
|
||||
} catch {
|
||||
return { items: [], query }
|
||||
return { items: starters, query }
|
||||
}
|
||||
},
|
||||
[gateway, sessionId, cwd]
|
||||
|
||||
@@ -1,20 +1,25 @@
|
||||
import './liquid-glass-overrides.css'
|
||||
|
||||
import type { Unstable_TriggerAdapter, Unstable_TriggerItem } from '@assistant-ui/core'
|
||||
import { ComposerPrimitive, useAui, useAuiState } from '@assistant-ui/react'
|
||||
import { useStore } from '@nanostores/react'
|
||||
import LiquidGlass from 'liquid-glass-react'
|
||||
import {
|
||||
type ClipboardEvent,
|
||||
type CSSProperties,
|
||||
type FormEvent,
|
||||
type KeyboardEvent,
|
||||
type DragEvent as ReactDragEvent,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useRef,
|
||||
useState
|
||||
} from 'react'
|
||||
|
||||
import { hermesDirectiveFormatter } from '@/components/assistant-ui/directive-text'
|
||||
import { formatRefValue, hermesDirectiveFormatter } from '@/components/assistant-ui/directive-text'
|
||||
import { useMediaQuery } from '@/hooks/use-media-query'
|
||||
import { chatMessageText } from '@/lib/chat-messages'
|
||||
import { contextPath } from '@/lib/chat-runtime'
|
||||
import { DATA_IMAGE_URL_RE, dataUrlToBlob } from '@/lib/embedded-images'
|
||||
import { triggerHaptic } from '@/lib/haptics'
|
||||
import { cn } from '@/lib/utils'
|
||||
@@ -22,20 +27,27 @@ import { $composerAttachments, $composerDraft } from '@/store/composer'
|
||||
import { $messages } from '@/store/session'
|
||||
import { $threadScrolledUp } from '@/store/thread-scroll'
|
||||
|
||||
import { extractDroppedFiles } from '../hooks/use-composer-actions'
|
||||
import { type DroppedFile, extractDroppedFiles, HERMES_PATHS_MIME } from '../hooks/use-composer-actions'
|
||||
|
||||
import { AttachmentList } from './attachments'
|
||||
import { ContextMenu } from './context-menu'
|
||||
import { ComposerControls } from './controls'
|
||||
import { DirectivePopover } from './directive-popover'
|
||||
import { HelpHint } from './help-hint'
|
||||
import { useAtCompletions } from './hooks/use-at-completions'
|
||||
import { useComposerGlassTweaks } from './hooks/use-composer-glass-tweaks'
|
||||
import { useSlashCompletions } from './hooks/use-slash-completions'
|
||||
import { useVoiceConversation } from './hooks/use-voice-conversation'
|
||||
import { useVoiceRecorder } from './hooks/use-voice-recorder'
|
||||
import {
|
||||
composerHtml,
|
||||
composerPlainText,
|
||||
escapeHtml,
|
||||
placeCaretEnd,
|
||||
refChipHtml,
|
||||
RICH_INPUT_SLOT
|
||||
} from './rich-editor'
|
||||
import { SkinSlashPopover } from './skin-slash-popover'
|
||||
import { SlashPopover } from './slash-popover'
|
||||
import { ComposerTriggerPopover } from './trigger-popover'
|
||||
import type { ChatBarProps } from './types'
|
||||
import { UrlDialog } from './url-dialog'
|
||||
import { VoiceActivity, VoicePlaybackActivity } from './voice-activity'
|
||||
@@ -117,6 +129,36 @@ const COMPOSER_FROST_CLASS = cn(
|
||||
'group-focus-within/composer:[-webkit-backdrop-filter:none]'
|
||||
)
|
||||
|
||||
interface TriggerState {
|
||||
kind: '@' | '/'
|
||||
query: string
|
||||
tokenLength: number
|
||||
}
|
||||
|
||||
const TRIGGER_RE = /(?:^|[\s])([@/])([^\s@/]*)$/
|
||||
|
||||
/** Caret-anchored text before the cursor, or null if the selection isn't a collapsed caret inside `editor`. */
|
||||
function textBeforeCaret(editor: HTMLDivElement): string | null {
|
||||
const sel = window.getSelection()
|
||||
const range = sel?.rangeCount ? sel.getRangeAt(0) : null
|
||||
|
||||
if (!range?.collapsed || !editor.contains(range.commonAncestorContainer)) {return null}
|
||||
|
||||
const before = range.cloneRange()
|
||||
before.selectNodeContents(editor)
|
||||
before.setEnd(range.startContainer, range.startOffset)
|
||||
|
||||
return before.toString()
|
||||
}
|
||||
|
||||
function detectTrigger(textBefore: string): TriggerState | null {
|
||||
const match = TRIGGER_RE.exec(textBefore)
|
||||
|
||||
if (!match) {return null}
|
||||
|
||||
return { kind: match[1] as '@' | '/', query: match[2], tokenLength: 1 + match[2].length }
|
||||
}
|
||||
|
||||
export function ChatBar({
|
||||
busy,
|
||||
cwd,
|
||||
@@ -144,9 +186,9 @@ export function ChatBar({
|
||||
const scrolledUp = useStore($threadScrolledUp)
|
||||
|
||||
const composerRef = useRef<HTMLFormElement | null>(null)
|
||||
const editorRef = useRef<HTMLDivElement | null>(null)
|
||||
const glassShellRef = useRef<HTMLDivElement | null>(null)
|
||||
const draftRef = useRef(draft)
|
||||
const textareaRef = useRef<HTMLTextAreaElement | null>(null)
|
||||
const urlInputRef = useRef<HTMLInputElement | null>(null)
|
||||
|
||||
const [urlOpen, setUrlOpen] = useState(false)
|
||||
@@ -192,7 +234,7 @@ export function ChatBar({
|
||||
|
||||
const glassTweaks = useComposerGlassTweaks()
|
||||
|
||||
const focusInput = () => window.requestAnimationFrame(() => textareaRef.current?.focus({ preventScroll: true }))
|
||||
const focusInput = () => window.requestAnimationFrame(() => editorRef.current?.focus({ preventScroll: true }))
|
||||
|
||||
useEffect(() => {
|
||||
if (!disabled) {
|
||||
@@ -203,6 +245,12 @@ export function ChatBar({
|
||||
useEffect(() => {
|
||||
draftRef.current = draft
|
||||
$composerDraft.set(draft)
|
||||
|
||||
const editor = editorRef.current
|
||||
|
||||
if (editor && document.activeElement !== editor && composerPlainText(editor) !== draft) {
|
||||
editor.innerHTML = composerHtml(draft)
|
||||
}
|
||||
}, [draft])
|
||||
|
||||
useEffect(
|
||||
@@ -232,7 +280,7 @@ export function ChatBar({
|
||||
return
|
||||
}
|
||||
|
||||
const wraps = (textareaRef.current?.scrollHeight ?? 0) > 42
|
||||
const wraps = (editorRef.current?.scrollHeight ?? 0) > 42
|
||||
|
||||
if (draft.includes('\n') || wraps) {
|
||||
setExpanded(true)
|
||||
@@ -265,13 +313,108 @@ export function ChatBar({
|
||||
focusInput()
|
||||
}
|
||||
|
||||
const insertInlineRefs = (refs: string[]) => {
|
||||
const editor = editorRef.current
|
||||
|
||||
if (!refs.length || !editor) {
|
||||
return false
|
||||
}
|
||||
|
||||
const inline = refs.join(' ')
|
||||
const selection = window.getSelection()
|
||||
|
||||
const range =
|
||||
selection?.rangeCount && editor.contains(selection.getRangeAt(0).commonAncestorContainer)
|
||||
? selection.getRangeAt(0)
|
||||
: null
|
||||
|
||||
editor.focus({ preventScroll: true })
|
||||
|
||||
if (range) {
|
||||
const beforeRange = range.cloneRange()
|
||||
beforeRange.selectNodeContents(editor)
|
||||
beforeRange.setEnd(range.startContainer, range.startOffset)
|
||||
const beforeContainer = document.createElement('div')
|
||||
beforeContainer.appendChild(beforeRange.cloneContents())
|
||||
|
||||
const afterRange = range.cloneRange()
|
||||
afterRange.selectNodeContents(editor)
|
||||
afterRange.setStart(range.endContainer, range.endOffset)
|
||||
const afterContainer = document.createElement('div')
|
||||
afterContainer.appendChild(afterRange.cloneContents())
|
||||
|
||||
const beforeText = composerPlainText(beforeContainer)
|
||||
const afterText = composerPlainText(afterContainer)
|
||||
const needsBeforeSpace = beforeText.length > 0 && !/\s$/.test(beforeText)
|
||||
const needsAfterSpace = afterText.length === 0 || !/^\s/.test(afterText)
|
||||
range.deleteContents()
|
||||
const fragment = document.createDocumentFragment()
|
||||
|
||||
if (needsBeforeSpace) {
|
||||
fragment.appendChild(document.createTextNode(' '))
|
||||
}
|
||||
|
||||
refs.forEach((ref, index) => {
|
||||
const match = ref.match(/^@([^:]+):(.+)$/)
|
||||
const holder = document.createElement('span')
|
||||
holder.innerHTML = match ? refChipHtml(match[1], match[2]) : escapeHtml(ref)
|
||||
fragment.appendChild(holder.firstChild || document.createTextNode(ref))
|
||||
|
||||
if (index < refs.length - 1) {
|
||||
fragment.appendChild(document.createTextNode(' '))
|
||||
}
|
||||
})
|
||||
|
||||
const trailingSpace = needsAfterSpace ? document.createTextNode(' ') : null
|
||||
|
||||
if (trailingSpace) {
|
||||
fragment.appendChild(trailingSpace)
|
||||
}
|
||||
|
||||
range.insertNode(fragment)
|
||||
|
||||
const nextRange = document.createRange()
|
||||
|
||||
if (trailingSpace) {
|
||||
nextRange.setStart(trailingSpace, trailingSpace.length)
|
||||
} else {
|
||||
nextRange.setStartAfter(fragment.lastChild || range.startContainer)
|
||||
}
|
||||
|
||||
nextRange.collapse(true)
|
||||
selection?.removeAllRanges()
|
||||
selection?.addRange(nextRange)
|
||||
} else {
|
||||
const current = composerPlainText(editor)
|
||||
editor.innerHTML = composerHtml(`${current}${current && !/\s$/.test(current) ? ' ' : ''}${inline} `)
|
||||
placeCaretEnd(editor)
|
||||
}
|
||||
|
||||
const nextDraft = composerPlainText(editor)
|
||||
draftRef.current = nextDraft
|
||||
aui.composer().setText(nextDraft)
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
const droppedFileInlineRef = (candidate: DroppedFile) => {
|
||||
if (!candidate.path) {
|
||||
return null
|
||||
}
|
||||
|
||||
const kind = candidate.isDirectory ? 'folder' : 'file'
|
||||
const rel = contextPath(candidate.path, cwd || '')
|
||||
|
||||
return `@${kind}:${formatRefValue(rel)}`
|
||||
}
|
||||
|
||||
const selectSkinSlashCommand = (command: string) => {
|
||||
draftRef.current = command
|
||||
aui.composer().setText(command)
|
||||
focusInput()
|
||||
}
|
||||
|
||||
const handlePaste = (event: ClipboardEvent<HTMLTextAreaElement>) => {
|
||||
const handlePaste = (event: ClipboardEvent<HTMLDivElement>) => {
|
||||
const imageBlobs = extractClipboardImageBlobs(event.clipboardData)
|
||||
|
||||
if (imageBlobs.length > 0) {
|
||||
@@ -304,32 +447,205 @@ export function ChatBar({
|
||||
return
|
||||
}
|
||||
|
||||
const trimmedText = pastedText.replace(/^[\t ]*(?:\r\n|\r|\n)+|(?:\r\n|\r|\n)+[\t ]*$/g, '')
|
||||
event.preventDefault()
|
||||
document.execCommand('insertText', false, pastedText)
|
||||
const nextDraft = composerPlainText(event.currentTarget)
|
||||
draftRef.current = nextDraft
|
||||
aui.composer().setText(nextDraft)
|
||||
}
|
||||
|
||||
const [trigger, setTrigger] = useState<TriggerState | null>(null)
|
||||
const [triggerActive, setTriggerActive] = useState(0)
|
||||
const [triggerItems, setTriggerItems] = useState<readonly Unstable_TriggerItem[]>([])
|
||||
|
||||
// Try caret-anchored detection first; fall back to whole-draft so blur/select-all
|
||||
// edge cases still surface the popover instead of silently closing it.
|
||||
const refreshTrigger = useCallback(() => {
|
||||
const editor = editorRef.current
|
||||
|
||||
if (!editor) {return}
|
||||
|
||||
const before = textBeforeCaret(editor)
|
||||
const detected = detectTrigger(before ?? composerPlainText(editor))
|
||||
|
||||
setTrigger(detected)
|
||||
setTriggerActive(0)
|
||||
}, [])
|
||||
|
||||
const handleEditorInput = (event: FormEvent<HTMLDivElement>) => {
|
||||
const editor = event.currentTarget
|
||||
|
||||
// Strip Chrome's stray <br> when the editor is otherwise empty so :empty
|
||||
// pseudo-class works for the placeholder.
|
||||
if (editor.childNodes.length === 1 && editor.firstChild?.nodeName === 'BR') {
|
||||
editor.replaceChildren()
|
||||
}
|
||||
|
||||
const nextDraft = composerPlainText(editor)
|
||||
|
||||
if (nextDraft !== draftRef.current) {
|
||||
draftRef.current = nextDraft
|
||||
aui.composer().setText(nextDraft)
|
||||
}
|
||||
|
||||
window.setTimeout(refreshTrigger, 0)
|
||||
}
|
||||
|
||||
const triggerAdapter: Unstable_TriggerAdapter | null = trigger?.kind === '@' ? at.adapter : trigger?.kind === '/' ? slash.adapter : null
|
||||
|
||||
useEffect(() => {
|
||||
if (!trigger || !triggerAdapter?.search) {
|
||||
setTriggerItems([])
|
||||
|
||||
if (trimmedText === pastedText) {
|
||||
return
|
||||
}
|
||||
|
||||
event.preventDefault()
|
||||
const textarea = event.currentTarget
|
||||
const start = textarea.selectionStart
|
||||
const end = textarea.selectionEnd
|
||||
setTriggerItems(triggerAdapter.search(trigger.query))
|
||||
}, [trigger, triggerAdapter])
|
||||
|
||||
const nextDraft = textarea.value.slice(0, start) + trimmedText + textarea.value.slice(end)
|
||||
const triggerLoading = trigger?.kind === '@' ? at.loading : trigger?.kind === '/' ? slash.loading : false
|
||||
|
||||
const cursor = start + trimmedText.length
|
||||
const closeTrigger = () => {
|
||||
setTrigger(null)
|
||||
setTriggerItems([])
|
||||
setTriggerActive(0)
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (!triggerItems.length) {
|
||||
setTriggerActive(0)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
if (triggerActive >= triggerItems.length) {
|
||||
setTriggerActive(triggerItems.length - 1)
|
||||
}
|
||||
}, [triggerActive, triggerItems.length])
|
||||
|
||||
const replaceTriggerWithChip = (item: Unstable_TriggerItem) => {
|
||||
const editor = editorRef.current
|
||||
const sel = window.getSelection()
|
||||
|
||||
if (!editor || !trigger) {
|
||||
return
|
||||
}
|
||||
|
||||
const serialized = hermesDirectiveFormatter.serialize(item)
|
||||
|
||||
const replaceDraftFallback = () => {
|
||||
const current = composerPlainText(editor)
|
||||
|
||||
const nextDraft = `${current.slice(0, Math.max(0, current.length - trigger.tokenLength))}${serialized}${
|
||||
serialized.endsWith(' ') ? '' : ' '
|
||||
}`
|
||||
|
||||
editor.innerHTML = composerHtml(nextDraft)
|
||||
placeCaretEnd(editor)
|
||||
draftRef.current = nextDraft
|
||||
aui.composer().setText(nextDraft)
|
||||
closeTrigger()
|
||||
}
|
||||
|
||||
if (!sel?.rangeCount) {
|
||||
replaceDraftFallback()
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
const range = sel.getRangeAt(0)
|
||||
const startNode = range.startContainer
|
||||
const startOffset = range.startOffset
|
||||
|
||||
if (startNode.nodeType !== Node.TEXT_NODE || startOffset < trigger.tokenLength) {
|
||||
replaceDraftFallback()
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
const replaceRange = document.createRange()
|
||||
replaceRange.setStart(startNode, startOffset - trigger.tokenLength)
|
||||
replaceRange.setEnd(startNode, startOffset)
|
||||
|
||||
const fragment = document.createDocumentFragment()
|
||||
const directiveMatch = serialized.match(/^@([^:]+):(.+)$/)
|
||||
|
||||
if (directiveMatch) {
|
||||
const holder = document.createElement('span')
|
||||
holder.innerHTML = refChipHtml(directiveMatch[1], directiveMatch[2])
|
||||
const chipNode = holder.firstChild
|
||||
|
||||
if (chipNode) {
|
||||
fragment.appendChild(chipNode)
|
||||
const space = document.createTextNode(' ')
|
||||
fragment.appendChild(space)
|
||||
|
||||
replaceRange.deleteContents()
|
||||
replaceRange.insertNode(fragment)
|
||||
|
||||
const after = document.createRange()
|
||||
after.setStart(space, 1)
|
||||
after.collapse(true)
|
||||
sel.removeAllRanges()
|
||||
sel.addRange(after)
|
||||
} else {
|
||||
replaceRange.deleteContents()
|
||||
document.execCommand('insertText', false, `${serialized} `)
|
||||
}
|
||||
} else {
|
||||
replaceRange.deleteContents()
|
||||
document.execCommand('insertText', false, serialized.endsWith(' ') ? serialized : `${serialized} `)
|
||||
}
|
||||
|
||||
const nextDraft = composerPlainText(editor)
|
||||
draftRef.current = nextDraft
|
||||
aui.composer().setText(nextDraft)
|
||||
window.requestAnimationFrame(() => {
|
||||
const current = textareaRef.current
|
||||
closeTrigger()
|
||||
}
|
||||
|
||||
const handleEditorKeyDown = (event: KeyboardEvent<HTMLDivElement>) => {
|
||||
if (trigger && triggerItems.length > 0) {
|
||||
if (event.key === 'ArrowDown') {
|
||||
event.preventDefault()
|
||||
setTriggerActive(idx => (idx + 1) % triggerItems.length)
|
||||
|
||||
if (!current) {
|
||||
return
|
||||
}
|
||||
|
||||
current.focus({ preventScroll: true })
|
||||
current.setSelectionRange(cursor, cursor)
|
||||
})
|
||||
if (event.key === 'ArrowUp') {
|
||||
event.preventDefault()
|
||||
setTriggerActive(idx => (idx - 1 + triggerItems.length) % triggerItems.length)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
if (event.key === 'Enter' || event.key === 'Tab') {
|
||||
event.preventDefault()
|
||||
const item = triggerItems[triggerActive]
|
||||
|
||||
if (item) {
|
||||
replaceTriggerWithChip(item)
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
if (event.key === 'Escape') {
|
||||
event.preventDefault()
|
||||
closeTrigger()
|
||||
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
if (event.key === 'Enter' && !event.shiftKey) {
|
||||
event.preventDefault()
|
||||
submitDraft()
|
||||
}
|
||||
}
|
||||
|
||||
const handleEditorKeyUp = () => {
|
||||
window.setTimeout(refreshTrigger, 0)
|
||||
}
|
||||
|
||||
const dragHasAttachments = (transfer: DataTransfer | null) => {
|
||||
@@ -337,6 +653,10 @@ export function ChatBar({
|
||||
return false
|
||||
}
|
||||
|
||||
if (Array.from(transfer.types || []).includes(HERMES_PATHS_MIME)) {
|
||||
return true
|
||||
}
|
||||
|
||||
if (Array.from(transfer.types || []).includes('Files')) {
|
||||
return true
|
||||
}
|
||||
@@ -398,6 +718,16 @@ export function ChatBar({
|
||||
return
|
||||
}
|
||||
|
||||
if (Array.from(event.dataTransfer.types || []).includes(HERMES_PATHS_MIME)) {
|
||||
const refs = candidates.map(droppedFileInlineRef).filter((ref): ref is string => Boolean(ref))
|
||||
|
||||
if (insertInlineRefs(refs)) {
|
||||
triggerHaptic('selection')
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
void Promise.resolve(onAttachDroppedItems(candidates)).then(attached => {
|
||||
if (attached) {
|
||||
triggerHaptic('selection')
|
||||
@@ -406,9 +736,44 @@ export function ChatBar({
|
||||
})
|
||||
}
|
||||
|
||||
const handleInputDragOver = (event: ReactDragEvent<HTMLDivElement>) => {
|
||||
if (!dragHasAttachments(event.dataTransfer)) {
|
||||
return
|
||||
}
|
||||
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
event.dataTransfer.dropEffect = 'copy'
|
||||
}
|
||||
|
||||
const handleInputDrop = (event: ReactDragEvent<HTMLDivElement>) => {
|
||||
if (!dragHasAttachments(event.dataTransfer)) {
|
||||
return
|
||||
}
|
||||
|
||||
const candidates = extractDroppedFiles(event.dataTransfer)
|
||||
const refs = candidates.map(droppedFileInlineRef).filter((ref): ref is string => Boolean(ref))
|
||||
|
||||
if (!refs.length) {
|
||||
return
|
||||
}
|
||||
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
resetDragState()
|
||||
|
||||
if (insertInlineRefs(refs)) {
|
||||
triggerHaptic('selection')
|
||||
}
|
||||
}
|
||||
|
||||
const clearDraft = () => {
|
||||
aui.composer().setText('')
|
||||
draftRef.current = ''
|
||||
|
||||
if (editorRef.current) {
|
||||
editorRef.current.innerHTML = ''
|
||||
}
|
||||
}
|
||||
|
||||
const submitDraft = () => {
|
||||
@@ -541,19 +906,42 @@ export function ChatBar({
|
||||
)
|
||||
|
||||
const input = (
|
||||
<ComposerPrimitive.Input
|
||||
className={cn(
|
||||
'min-h-(--composer-input-min-height) max-h-(--composer-input-max-height) resize-none overflow-y-auto bg-transparent pb-1 pr-1 pt-1 leading-normal text-foreground outline-none placeholder:text-muted-foreground/80 disabled:cursor-not-allowed',
|
||||
stacked && 'pl-3',
|
||||
stacked ? 'w-full' : 'min-w-(--composer-input-inline-min-width) flex-1'
|
||||
<div className={cn('relative', stacked ? 'w-full' : 'min-w-(--composer-input-inline-min-width) flex-1')}>
|
||||
{!draft && (
|
||||
<div
|
||||
aria-hidden
|
||||
className={cn(
|
||||
'pointer-events-none absolute inset-0 pb-1 pr-1 pt-1 leading-normal text-muted-foreground/80',
|
||||
stacked && 'pl-3'
|
||||
)}
|
||||
>
|
||||
{placeholder}
|
||||
</div>
|
||||
)}
|
||||
disabled={disabled}
|
||||
onPaste={handlePaste}
|
||||
placeholder={placeholder}
|
||||
ref={textareaRef}
|
||||
rows={1}
|
||||
unstable_focusOnScrollToBottom={false}
|
||||
/>
|
||||
<div
|
||||
aria-label="Message"
|
||||
className={cn(
|
||||
'min-h-(--composer-input-min-height) max-h-(--composer-input-max-height) overflow-y-auto bg-transparent pb-1 pr-1 pt-1 leading-normal text-foreground outline-none empty:before:content-[attr(data-placeholder)] disabled:cursor-not-allowed **:data-ref-text:cursor-default',
|
||||
stacked && 'pl-3',
|
||||
stacked ? 'w-full' : 'min-w-(--composer-input-inline-min-width) flex-1'
|
||||
)}
|
||||
contentEditable={!disabled}
|
||||
data-placeholder={placeholder}
|
||||
data-slot={RICH_INPUT_SLOT}
|
||||
onBlur={() => window.setTimeout(closeTrigger, 80)}
|
||||
onDragOver={handleInputDragOver}
|
||||
onDrop={handleInputDrop}
|
||||
onInput={handleEditorInput}
|
||||
onKeyDown={handleEditorKeyDown}
|
||||
onKeyUp={handleEditorKeyUp}
|
||||
onMouseUp={refreshTrigger}
|
||||
onPaste={handlePaste}
|
||||
ref={editorRef}
|
||||
role="textbox"
|
||||
suppressContentEditableWarning
|
||||
/>
|
||||
<ComposerPrimitive.Input className="sr-only" tabIndex={-1} unstable_focusOnScrollToBottom={false} />
|
||||
</div>
|
||||
)
|
||||
|
||||
return (
|
||||
@@ -581,12 +969,16 @@ export function ChatBar({
|
||||
}
|
||||
>
|
||||
{showHelpHint && <HelpHint />}
|
||||
<DirectivePopover
|
||||
adapter={at.adapter}
|
||||
directive={{ formatter: hermesDirectiveFormatter }}
|
||||
loading={at.loading}
|
||||
/>
|
||||
<SlashPopover adapter={slash.adapter} loading={slash.loading} />
|
||||
{trigger && (
|
||||
<ComposerTriggerPopover
|
||||
activeIndex={triggerActive}
|
||||
items={triggerItems}
|
||||
kind={trigger.kind}
|
||||
loading={triggerLoading}
|
||||
onHover={setTriggerActive}
|
||||
onPick={replaceTriggerWithChip}
|
||||
/>
|
||||
)}
|
||||
<SkinSlashPopover draft={draft} onSelect={selectSkinSlashCommand} />
|
||||
<div className="pointer-events-none absolute inset-0" style={{ background: glassTweaks.fadeBackground }} />
|
||||
<div className="relative w-full">
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
/**
|
||||
* Helpers for the contenteditable composer surface: serialize refs to chip
|
||||
* HTML, walk the DOM back to plain `@kind:value` text, and place the caret.
|
||||
*
|
||||
* Chip values are always wrapped in backticks/quotes so REF_RE stops at the
|
||||
* fence — without that, typing after a chip would get re-absorbed on the next
|
||||
* plain-text round-trip.
|
||||
*/
|
||||
import { formatRefValue } from '@/components/assistant-ui/directive-text'
|
||||
|
||||
export const RICH_INPUT_SLOT = 'composer-rich-input'
|
||||
|
||||
export const REF_RE = /@(file|folder|url|image|tool):(`[^`\n]+`|"[^"\n]+"|'[^'\n]+'|\S+)/g
|
||||
|
||||
const ESC: Record<string, string> = { '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }
|
||||
|
||||
export function escapeHtml(value: string) {
|
||||
return value.replace(/[&<>"']/g, ch => ESC[ch] || ch)
|
||||
}
|
||||
|
||||
export function unquoteRef(raw: string) {
|
||||
const head = raw[0]
|
||||
const tail = raw[raw.length - 1]
|
||||
const quoted = (head === '`' && tail === '`') || (head === '"' && tail === '"') || (head === "'" && tail === "'")
|
||||
|
||||
return quoted ? raw.slice(1, -1) : raw.replace(/[,.;!?]+$/, '')
|
||||
}
|
||||
|
||||
export function refLabel(id: string) {
|
||||
return id.split(/[\\/]/).filter(Boolean).pop() || id
|
||||
}
|
||||
|
||||
/** Always-quote variant of formatRefValue — chips need a fence even for safe values. */
|
||||
export function quoteRefValue(value: string) {
|
||||
if (!value.includes('`')) {return `\`${value}\``}
|
||||
|
||||
if (!value.includes('"')) {return `"${value}"`}
|
||||
|
||||
if (!value.includes("'")) {return `'${value}'`}
|
||||
|
||||
return formatRefValue(value)
|
||||
}
|
||||
|
||||
export function refChipHtml(kind: string, rawValue: string) {
|
||||
const id = unquoteRef(rawValue)
|
||||
const text = `@${kind}:${quoteRefValue(id)}`
|
||||
|
||||
return `<span contenteditable="false" data-ref-text="${escapeHtml(text)}" data-ref-id="${escapeHtml(id)}" data-ref-kind="${kind}" class="mx-0.5 inline-flex max-w-56 items-center gap-1 border border-primary/20 bg-primary/8 px-1.5 py-0.5 align-[0.02em] text-[0.86em] font-medium leading-none text-primary"><span class="truncate">${escapeHtml(refLabel(id))}</span></span>`
|
||||
}
|
||||
|
||||
/** Serialize a draft string into chip-HTML for the contenteditable surface. */
|
||||
export function composerHtml(text: string) {
|
||||
let cursor = 0
|
||||
let html = ''
|
||||
|
||||
REF_RE.lastIndex = 0
|
||||
|
||||
for (const match of text.matchAll(REF_RE)) {
|
||||
const index = match.index ?? 0
|
||||
html += escapeHtml(text.slice(cursor, index)).replace(/\n/g, '<br>')
|
||||
html += refChipHtml(match[1] || 'file', match[2] || '')
|
||||
cursor = index + match[0].length
|
||||
}
|
||||
|
||||
return html + escapeHtml(text.slice(cursor)).replace(/\n/g, '<br>')
|
||||
}
|
||||
|
||||
/** Walk a DOM subtree back to the plain `@kind:value` text it represents. */
|
||||
export function composerPlainText(node: Node): string {
|
||||
if (node.nodeType === Node.TEXT_NODE) {return node.textContent || ''}
|
||||
|
||||
if (node.nodeType !== Node.ELEMENT_NODE) {return ''}
|
||||
|
||||
const el = node as HTMLElement
|
||||
|
||||
if (el.dataset.refText) {return el.dataset.refText}
|
||||
|
||||
if (el.tagName === 'BR') {return '\n'}
|
||||
|
||||
const text = Array.from(node.childNodes).map(composerPlainText).join('')
|
||||
const block = el.tagName === 'DIV' || el.tagName === 'P'
|
||||
|
||||
return block && text && el.dataset.slot !== RICH_INPUT_SLOT ? `${text}\n` : text
|
||||
}
|
||||
|
||||
export function placeCaretEnd(element: HTMLElement) {
|
||||
const range = document.createRange()
|
||||
const selection = window.getSelection()
|
||||
|
||||
range.selectNodeContents(element)
|
||||
range.collapse(false)
|
||||
selection?.removeAllRanges()
|
||||
selection?.addRange(range)
|
||||
}
|
||||
@@ -1,59 +0,0 @@
|
||||
import type { Unstable_DirectiveFormatter, Unstable_TriggerAdapter, Unstable_TriggerItem } from '@assistant-ui/core'
|
||||
import { ComposerPrimitive } from '@assistant-ui/react'
|
||||
|
||||
import { COMPLETION_DRAWER_ROW_CLASS, CompletionDrawerEmpty, ComposerCompletionDrawer } from './completion-drawer'
|
||||
|
||||
const slashFormatter: Unstable_DirectiveFormatter = {
|
||||
serialize(item: Unstable_TriggerItem): string {
|
||||
const metadata = item.metadata as { command?: unknown; display?: unknown } | undefined
|
||||
const command = typeof metadata?.command === 'string' ? metadata.command : null
|
||||
|
||||
if (command) {
|
||||
return command
|
||||
}
|
||||
|
||||
return `/${item.label}`
|
||||
},
|
||||
parse() {
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
export function SlashPopover({ adapter, loading }: { adapter: Unstable_TriggerAdapter; loading: boolean }) {
|
||||
return (
|
||||
<ComposerCompletionDrawer adapter={adapter} ariaLabel="Slash command suggestions" char="/">
|
||||
<ComposerPrimitive.Unstable_TriggerPopover.Directive formatter={slashFormatter} />
|
||||
<ComposerPrimitive.Unstable_TriggerPopoverItems>
|
||||
{items => (
|
||||
<div className="grid gap-0.5 pt-0.5">
|
||||
{items.length === 0 ? (
|
||||
<CompletionDrawerEmpty title={loading ? 'Looking up...' : 'No matching commands.'}>
|
||||
Try <span className="font-mono text-foreground/80">/help</span> for the desktop command list.
|
||||
</CompletionDrawerEmpty>
|
||||
) : (
|
||||
items.map((item, index) => {
|
||||
const meta = item.metadata as { command?: string; display?: string; meta?: string } | undefined
|
||||
const display = meta?.display ?? meta?.command ?? `/${item.label}`
|
||||
const description = meta?.meta || item.description
|
||||
|
||||
return (
|
||||
<ComposerPrimitive.Unstable_TriggerPopoverItem
|
||||
className={COMPLETION_DRAWER_ROW_CLASS}
|
||||
index={index}
|
||||
item={item}
|
||||
key={item.id}
|
||||
>
|
||||
<span className="shrink-0 font-mono font-medium leading-5 text-foreground">{display}</span>
|
||||
{description && (
|
||||
<span className="min-w-0 truncate leading-5 text-muted-foreground/80">{description}</span>
|
||||
)}
|
||||
</ComposerPrimitive.Unstable_TriggerPopoverItem>
|
||||
)
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</ComposerPrimitive.Unstable_TriggerPopoverItems>
|
||||
</ComposerCompletionDrawer>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
import type { Unstable_TriggerItem } from '@assistant-ui/core'
|
||||
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
import { COMPLETION_DRAWER_CLASS, COMPLETION_DRAWER_ROW_CLASS, CompletionDrawerEmpty } from './completion-drawer'
|
||||
|
||||
interface ComposerTriggerPopoverProps {
|
||||
activeIndex: number
|
||||
items: readonly Unstable_TriggerItem[]
|
||||
kind: '@' | '/'
|
||||
loading: boolean
|
||||
onHover: (index: number) => void
|
||||
onPick: (item: Unstable_TriggerItem) => void
|
||||
}
|
||||
|
||||
export function ComposerTriggerPopover({ activeIndex, items, kind, loading, onHover, onPick }: ComposerTriggerPopoverProps) {
|
||||
return (
|
||||
<div
|
||||
className={COMPLETION_DRAWER_CLASS}
|
||||
data-slot="composer-completion-drawer"
|
||||
data-state="open"
|
||||
onMouseDown={event => event.preventDefault()}
|
||||
role="listbox"
|
||||
>
|
||||
{items.length === 0 ? (
|
||||
<CompletionDrawerEmpty title={loading ? 'Looking up…' : 'No matches.'}>
|
||||
{kind === '@' ? (
|
||||
<>
|
||||
Try <span className="font-mono text-foreground/80">@file:</span> or{' '}
|
||||
<span className="font-mono text-foreground/80">@folder:</span>.
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
Try <span className="font-mono text-foreground/80">/help</span>.
|
||||
</>
|
||||
)}
|
||||
</CompletionDrawerEmpty>
|
||||
) : (
|
||||
items.map((item, index) => {
|
||||
const meta = item.metadata as { display?: string; meta?: string } | undefined
|
||||
const display = meta?.display ?? (kind === '/' ? `/${item.label}` : item.label)
|
||||
const description = meta?.meta || item.description
|
||||
|
||||
return (
|
||||
<button
|
||||
className={cn(
|
||||
COMPLETION_DRAWER_ROW_CLASS,
|
||||
index === activeIndex && 'bg-[color-mix(in_srgb,var(--dt-accent)_70%,transparent)]'
|
||||
)}
|
||||
data-highlighted={index === activeIndex ? '' : undefined}
|
||||
key={item.id}
|
||||
onClick={() => onPick(item)}
|
||||
onMouseEnter={() => onHover(index)}
|
||||
type="button"
|
||||
>
|
||||
<span className="shrink-0 truncate font-mono font-medium leading-5 text-foreground">{display}</span>
|
||||
{description && <span className="min-w-0 truncate leading-5 text-muted-foreground/80">{description}</span>}
|
||||
</button>
|
||||
)
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -31,12 +31,22 @@ function isImagePath(filePath: string): boolean {
|
||||
}
|
||||
|
||||
export interface DroppedFile {
|
||||
file: File
|
||||
/** Browser-native File handle. Absent for in-app drags (e.g. project tree). */
|
||||
file?: File
|
||||
/** Absolute filesystem path. Empty when an OS drop didn't carry one. */
|
||||
path: string
|
||||
/** True if the entry is a directory. Currently only set by in-app drags. */
|
||||
isDirectory?: boolean
|
||||
}
|
||||
|
||||
/** MIME emitted by in-app drag sources (project tree, etc.). Payload is JSON
|
||||
* `{ path: string; isDirectory?: boolean }[]`. */
|
||||
export const HERMES_PATHS_MIME = 'application/x-hermes-paths'
|
||||
|
||||
/**
|
||||
* Eagerly resolve files from a drop event into [File, path] pairs.
|
||||
* Eagerly resolve files from a drop event into [File?, path, isDirectory?]
|
||||
* triples. Internal Hermes sources (e.g. the project tree) ride on a custom
|
||||
* MIME and produce path-only entries; OS drops produce File-bearing entries.
|
||||
*
|
||||
* Must be called synchronously from inside the drop handler — `DataTransfer`
|
||||
* items are detached as soon as the handler returns, and `webUtils.getPathForFile`
|
||||
@@ -44,19 +54,42 @@ export interface DroppedFile {
|
||||
*/
|
||||
export function extractDroppedFiles(transfer: DataTransfer): DroppedFile[] {
|
||||
const result: DroppedFile[] = []
|
||||
const seen = new Set<File>()
|
||||
const seenPaths = new Set<string>()
|
||||
const seenFiles = new Set<File>()
|
||||
const getPath = window.hermesDesktop?.getPathForFile
|
||||
|
||||
// In-app drags first — they carry richer metadata (isDirectory) than the
|
||||
// File-based fallback can provide, and produce no overlapping native files.
|
||||
try {
|
||||
const internalRaw = transfer.getData(HERMES_PATHS_MIME)
|
||||
|
||||
if (internalRaw) {
|
||||
const parsed = JSON.parse(internalRaw) as { path?: unknown; isDirectory?: unknown }[]
|
||||
|
||||
for (const entry of parsed) {
|
||||
if (!entry || typeof entry.path !== 'string' || !entry.path || seenPaths.has(entry.path)) {
|
||||
continue
|
||||
}
|
||||
|
||||
seenPaths.add(entry.path)
|
||||
result.push({ isDirectory: entry.isDirectory === true, path: entry.path })
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Malformed payload — fall through to native files.
|
||||
}
|
||||
|
||||
const fileList = transfer.files
|
||||
|
||||
if (fileList) {
|
||||
for (let i = 0; i < fileList.length; i += 1) {
|
||||
const file = fileList.item(i)
|
||||
|
||||
if (!file || seen.has(file)) {
|
||||
if (!file || seenFiles.has(file)) {
|
||||
continue
|
||||
}
|
||||
seen.add(file)
|
||||
|
||||
seenFiles.add(file)
|
||||
let path = ''
|
||||
|
||||
if (getPath) {
|
||||
@@ -67,6 +100,14 @@ export function extractDroppedFiles(transfer: DataTransfer): DroppedFile[] {
|
||||
}
|
||||
}
|
||||
|
||||
if (path && seenPaths.has(path)) {
|
||||
continue
|
||||
}
|
||||
|
||||
if (path) {
|
||||
seenPaths.add(path)
|
||||
}
|
||||
|
||||
result.push({ file, path })
|
||||
}
|
||||
}
|
||||
@@ -80,12 +121,14 @@ export function extractDroppedFiles(transfer: DataTransfer): DroppedFile[] {
|
||||
if (!item || item.kind !== 'file') {
|
||||
continue
|
||||
}
|
||||
|
||||
const file = item.getAsFile()
|
||||
|
||||
if (!file || seen.has(file)) {
|
||||
if (!file || seenFiles.has(file)) {
|
||||
continue
|
||||
}
|
||||
seen.add(file)
|
||||
|
||||
seenFiles.add(file)
|
||||
let path = ''
|
||||
|
||||
if (getPath) {
|
||||
@@ -96,6 +139,14 @@ export function extractDroppedFiles(transfer: DataTransfer): DroppedFile[] {
|
||||
}
|
||||
}
|
||||
|
||||
if (path && seenPaths.has(path)) {
|
||||
continue
|
||||
}
|
||||
|
||||
if (path) {
|
||||
seenPaths.add(path)
|
||||
}
|
||||
|
||||
result.push({ file, path })
|
||||
}
|
||||
}
|
||||
@@ -282,6 +333,26 @@ export function useComposerActions({ activeSessionId, currentCwd, requestGateway
|
||||
}
|
||||
}, [attachImagePath])
|
||||
|
||||
const attachContextFolderPath = useCallback(
|
||||
(folderPath: string) => {
|
||||
if (!folderPath) {return false}
|
||||
|
||||
const rel = contextPath(folderPath, currentCwd)
|
||||
|
||||
addComposerAttachment({
|
||||
id: attachmentId('folder', rel),
|
||||
kind: 'folder',
|
||||
label: pathLabel(folderPath),
|
||||
detail: rel,
|
||||
refText: `@folder:${formatRefValue(rel)}`,
|
||||
path: folderPath
|
||||
})
|
||||
|
||||
return true
|
||||
},
|
||||
[currentCwd]
|
||||
)
|
||||
|
||||
const attachDroppedItems = useCallback(
|
||||
async (candidates: DroppedFile[]) => {
|
||||
if (candidates.length === 0) {
|
||||
@@ -291,9 +362,49 @@ export function useComposerActions({ activeSessionId, currentCwd, requestGateway
|
||||
let attached = false
|
||||
let lastFailure: string | null = null
|
||||
|
||||
for (const { file, path: knownPath } of candidates) {
|
||||
for (const candidate of candidates) {
|
||||
const { file, isDirectory, path: knownPath } = candidate
|
||||
|
||||
// Path-only entry (in-app drag from the file browser tree, etc.).
|
||||
if (!file) {
|
||||
if (isDirectory) {
|
||||
if (knownPath && attachContextFolderPath(knownPath)) {
|
||||
attached = true
|
||||
|
||||
continue
|
||||
}
|
||||
|
||||
lastFailure = `Could not attach folder ${knownPath || ''}`
|
||||
|
||||
continue
|
||||
}
|
||||
|
||||
if (knownPath && isImagePath(knownPath)) {
|
||||
if (await attachImagePath(knownPath)) {
|
||||
attached = true
|
||||
|
||||
continue
|
||||
}
|
||||
|
||||
lastFailure = `Could not attach ${knownPath}`
|
||||
|
||||
continue
|
||||
}
|
||||
|
||||
if (knownPath && attachContextFilePath(knownPath)) {
|
||||
attached = true
|
||||
|
||||
continue
|
||||
}
|
||||
|
||||
lastFailure = `Could not attach ${knownPath || 'file'}`
|
||||
|
||||
continue
|
||||
}
|
||||
|
||||
const fallbackPath =
|
||||
!knownPath && window.hermesDesktop?.getPathForFile ? window.hermesDesktop.getPathForFile(file) : ''
|
||||
|
||||
const filePath = knownPath || fallbackPath || ''
|
||||
const isImage = file.type.startsWith('image/') || isImagePath(file.name) || (filePath && isImagePath(filePath))
|
||||
|
||||
@@ -324,7 +435,7 @@ export function useComposerActions({ activeSessionId, currentCwd, requestGateway
|
||||
|
||||
return attached
|
||||
},
|
||||
[attachContextFilePath, attachImageBlob, attachImagePath]
|
||||
[attachContextFilePath, attachContextFolderPath, attachImageBlob, attachImagePath]
|
||||
)
|
||||
|
||||
const removeAttachment = useCallback(
|
||||
@@ -349,6 +460,7 @@ export function useComposerActions({ activeSessionId, currentCwd, requestGateway
|
||||
|
||||
return {
|
||||
addContextRefAttachment,
|
||||
attachContextFilePath,
|
||||
attachDroppedItems,
|
||||
attachImageBlob,
|
||||
attachImagePath,
|
||||
|
||||
@@ -39,14 +39,11 @@ import {
|
||||
import type { ModelOptionsResponse } from '@/types/hermes'
|
||||
|
||||
import { routeSessionId } from '../routes'
|
||||
import type { SetStatusbarItemGroup } from '../shell/statusbar-controls'
|
||||
import { titlebarHeaderBaseClass, titlebarHeaderShadowClass } from '../shell/titlebar'
|
||||
import type { SetTitlebarToolGroup } from '../shell/titlebar-controls'
|
||||
|
||||
import { ChatBar, ChatBarFallback } from './composer'
|
||||
import type { ChatBarState } from './composer/types'
|
||||
import type { DroppedFile } from './hooks/use-composer-actions'
|
||||
import { ChatPreviewRail, ChatRightRail } from './right-rail'
|
||||
import { SessionActionsMenu } from './sidebar/session-actions-menu'
|
||||
|
||||
interface ChatViewProps extends Omit<React.ComponentProps<'div'>, 'onSubmit'> {
|
||||
@@ -66,21 +63,10 @@ interface ChatViewProps extends Omit<React.ComponentProps<'div'>, 'onSubmit'> {
|
||||
onPickImages: () => void
|
||||
onRemoveAttachment: (id: string) => void
|
||||
onSubmit: (text: string) => Promise<void> | void
|
||||
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
|
||||
onOpenCommandCenterSystem: () => void
|
||||
onOpenSkills: () => void
|
||||
onThreadMessagesChange: (messages: readonly ThreadMessage[]) => void
|
||||
onEdit: (message: AppendMessage) => Promise<void>
|
||||
onReload: (parentId: string | null) => Promise<void>
|
||||
onTranscribeAudio?: (audio: Blob) => Promise<string>
|
||||
setStatusbarItemGroup?: SetStatusbarItemGroup
|
||||
setTitlebarToolGroup?: SetTitlebarToolGroup
|
||||
}
|
||||
|
||||
function threadLoadingState(
|
||||
@@ -125,21 +111,10 @@ export function ChatView({
|
||||
onPickImages,
|
||||
onRemoveAttachment,
|
||||
onSubmit,
|
||||
onChangeCwd: _onChangeCwd,
|
||||
onBrowseCwd: _onBrowseCwd,
|
||||
onOpenModelPicker: _onOpenModelPicker,
|
||||
onRestartPreviewServer,
|
||||
onSetFastMode: _onSetFastMode,
|
||||
onSetReasoningEffort: _onSetReasoningEffort,
|
||||
onSelectPersonality: _onSelectPersonality,
|
||||
onOpenCommandCenterSystem,
|
||||
onOpenSkills,
|
||||
onThreadMessagesChange,
|
||||
onEdit,
|
||||
onReload,
|
||||
onTranscribeAudio,
|
||||
setStatusbarItemGroup: _setStatusbarItemGroup,
|
||||
setTitlebarToolGroup
|
||||
onTranscribeAudio
|
||||
}: ChatViewProps) {
|
||||
const location = useLocation()
|
||||
const activeSessionId = useStore($activeSessionId)
|
||||
@@ -270,85 +245,75 @@ export function ChatView({
|
||||
})
|
||||
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
className={cn(
|
||||
'relative col-start-2 col-end-3 row-start-1 flex h-full min-w-0 flex-col overflow-hidden rounded-[0.9375rem] bg-transparent',
|
||||
className
|
||||
)}
|
||||
>
|
||||
<header className={cn(titlebarHeaderBaseClass, isRoutedSessionView && titlebarHeaderShadowClass)}>
|
||||
<div className="min-w-0 flex-1">
|
||||
{title && (
|
||||
<SessionActionsMenu
|
||||
align="start"
|
||||
onDelete={selectedSessionId ? onDeleteSelectedSession : undefined}
|
||||
onPin={selectedSessionId ? onToggleSelectedPin : undefined}
|
||||
pinned={selectedIsPinned}
|
||||
sessionId={selectedSessionId || activeSessionId || ''}
|
||||
sideOffset={8}
|
||||
title={title}
|
||||
<div
|
||||
className={cn(
|
||||
'relative flex h-full min-w-0 flex-col overflow-hidden rounded-[0.9375rem] bg-transparent',
|
||||
className
|
||||
)}
|
||||
>
|
||||
<header className={cn(titlebarHeaderBaseClass, isRoutedSessionView && titlebarHeaderShadowClass)}>
|
||||
<div className="min-w-0 flex-1">
|
||||
{title && (
|
||||
<SessionActionsMenu
|
||||
align="start"
|
||||
onDelete={selectedSessionId ? onDeleteSelectedSession : undefined}
|
||||
onPin={selectedSessionId ? onToggleSelectedPin : undefined}
|
||||
pinned={selectedIsPinned}
|
||||
sessionId={selectedSessionId || activeSessionId || ''}
|
||||
sideOffset={8}
|
||||
title={title}
|
||||
>
|
||||
<Button
|
||||
className="pointer-events-auto h-7 min-w-0 gap-1.5 rounded-lg px-1 py-0 text-foreground hover:bg-accent/70 data-[state=open]:bg-accent/70 [-webkit-app-region:no-drag]"
|
||||
type="button"
|
||||
variant="ghost"
|
||||
>
|
||||
<Button
|
||||
className="pointer-events-auto h-7 min-w-0 gap-1.5 rounded-lg px-1 py-0 text-foreground hover:bg-accent/70 data-[state=open]:bg-accent/70 [-webkit-app-region:no-drag]"
|
||||
type="button"
|
||||
variant="ghost"
|
||||
>
|
||||
<h2 className="max-w-[62vw] truncate text-base font-semibold leading-none tracking-tight">{title}</h2>
|
||||
<ChevronDown className="shrink-0 text-foreground/75" size={16} />
|
||||
</Button>
|
||||
</SessionActionsMenu>
|
||||
)}
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<NotificationStack />
|
||||
|
||||
<div className="relative min-h-0 max-w-full flex-1 overflow-hidden rounded-[1.0625rem] bg-transparent contain-[layout_paint]">
|
||||
<AssistantRuntimeProvider runtime={runtime}>
|
||||
<Thread
|
||||
intro={showIntro ? { personality: introPersonality, seed: introSeed } : undefined}
|
||||
loading={threadLoading}
|
||||
onBranchInNewChat={onBranchInNewChat}
|
||||
sessionKey={threadKey}
|
||||
/>
|
||||
{showChatBar && (
|
||||
<Suspense fallback={<ChatBarFallback />}>
|
||||
<ChatBar
|
||||
busy={busy}
|
||||
cwd={currentCwd}
|
||||
disabled={!gatewayOpen}
|
||||
focusKey={activeSessionId}
|
||||
gateway={gateway}
|
||||
maxRecordingSeconds={maxVoiceRecordingSeconds}
|
||||
onAddContextRef={onAddContextRef}
|
||||
onAddUrl={onAddUrl}
|
||||
onAttachDroppedItems={onAttachDroppedItems}
|
||||
onAttachImageBlob={onAttachImageBlob}
|
||||
onCancel={onCancel}
|
||||
onPasteClipboardImage={onPasteClipboardImage}
|
||||
onPickFiles={onPickFiles}
|
||||
onPickFolders={onPickFolders}
|
||||
onPickImages={onPickImages}
|
||||
onRemoveAttachment={onRemoveAttachment}
|
||||
onSubmit={onSubmit}
|
||||
onTranscribeAudio={onTranscribeAudio}
|
||||
sessionId={activeSessionId}
|
||||
state={chatBarState}
|
||||
/>
|
||||
</Suspense>
|
||||
)}
|
||||
</AssistantRuntimeProvider>
|
||||
<h2 className="max-w-[62vw] truncate text-base font-semibold leading-none tracking-tight">{title}</h2>
|
||||
<ChevronDown className="shrink-0 text-foreground/75" size={16} />
|
||||
</Button>
|
||||
</SessionActionsMenu>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<ChatPreviewRail onRestartServer={onRestartPreviewServer} setTitlebarToolGroup={setTitlebarToolGroup} />
|
||||
<ChatRightRail
|
||||
onOpenCommandCenterSystem={onOpenCommandCenterSystem}
|
||||
onOpenSkills={onOpenSkills}
|
||||
/>
|
||||
</>
|
||||
<NotificationStack />
|
||||
|
||||
<div className="relative min-h-0 max-w-full flex-1 overflow-hidden rounded-[1.0625rem] bg-transparent contain-[layout_paint]">
|
||||
<AssistantRuntimeProvider runtime={runtime}>
|
||||
<Thread
|
||||
intro={showIntro ? { personality: introPersonality, seed: introSeed } : undefined}
|
||||
loading={threadLoading}
|
||||
onBranchInNewChat={onBranchInNewChat}
|
||||
sessionKey={threadKey}
|
||||
/>
|
||||
{showChatBar && (
|
||||
<Suspense fallback={<ChatBarFallback />}>
|
||||
<ChatBar
|
||||
busy={busy}
|
||||
cwd={currentCwd}
|
||||
disabled={!gatewayOpen}
|
||||
focusKey={activeSessionId}
|
||||
gateway={gateway}
|
||||
maxRecordingSeconds={maxVoiceRecordingSeconds}
|
||||
onAddContextRef={onAddContextRef}
|
||||
onAddUrl={onAddUrl}
|
||||
onAttachDroppedItems={onAttachDroppedItems}
|
||||
onAttachImageBlob={onAttachImageBlob}
|
||||
onCancel={onCancel}
|
||||
onPasteClipboardImage={onPasteClipboardImage}
|
||||
onPickFiles={onPickFiles}
|
||||
onPickFolders={onPickFolders}
|
||||
onPickImages={onPickImages}
|
||||
onRemoveAttachment={onRemoveAttachment}
|
||||
onSubmit={onSubmit}
|
||||
onTranscribeAudio={onTranscribeAudio}
|
||||
sessionId={activeSessionId}
|
||||
state={chatBarState}
|
||||
/>
|
||||
</Suspense>
|
||||
)}
|
||||
</AssistantRuntimeProvider>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export { PREVIEW_RAIL_WIDTH, SESSION_INSPECTOR_WIDTH } from './right-rail'
|
||||
|
||||
@@ -1,108 +0,0 @@
|
||||
'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())
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export { ChatPreviewRail, PREVIEW_RAIL_PANE_WIDTH } from './preview'
|
||||
@@ -1,137 +0,0 @@
|
||||
import { useStore } from '@nanostores/react'
|
||||
import { type ReactNode, useMemo } from 'react'
|
||||
|
||||
import type { SetTitlebarToolGroup } from '@/app/shell/titlebar-controls'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { AlertCircle, Loader2, Sparkles } from '@/lib/icons'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { $desktopActionTasks, buildRailTasks, type RailTask, type RailTaskStatus } from '@/store/activity'
|
||||
import { $inspectorOpen } from '@/store/layout'
|
||||
import { $previewReloadRequest, $previewServerRestart, $previewTarget } from '@/store/preview'
|
||||
import { $sessions, $workingSessionIds } from '@/store/session'
|
||||
|
||||
import { PreviewPane } from './preview-pane'
|
||||
|
||||
export const SESSION_INSPECTOR_WIDTH = 'clamp(13.5rem, 21vw, 20rem)'
|
||||
export const PREVIEW_RAIL_WIDTH = 'clamp(18rem, 36vw, 38rem)'
|
||||
|
||||
const RAIL_TASK_LIMIT = 6
|
||||
|
||||
const TASK_ICONS: Record<RailTaskStatus, ReactNode> = {
|
||||
error: <AlertCircle className="size-3 text-destructive" />,
|
||||
running: <Loader2 className="size-3 animate-spin text-muted-foreground" />,
|
||||
success: <Sparkles className="size-3 text-emerald-500" />
|
||||
}
|
||||
|
||||
interface ChatRightRailProps {
|
||||
onOpenCommandCenterSystem: () => void
|
||||
onOpenSkills: () => void
|
||||
}
|
||||
|
||||
export function ChatRightRail({ onOpenCommandCenterSystem, onOpenSkills }: ChatRightRailProps) {
|
||||
const inspectorOpen = useStore($inspectorOpen)
|
||||
const sessions = useStore($sessions)
|
||||
const workingSessionIds = useStore($workingSessionIds)
|
||||
const previewRestart = useStore($previewServerRestart)
|
||||
const desktopActionTasks = useStore($desktopActionTasks)
|
||||
|
||||
const tasks = useMemo(
|
||||
() => buildRailTasks(workingSessionIds, sessions, previewRestart, desktopActionTasks),
|
||||
[desktopActionTasks, previewRestart, sessions, workingSessionIds]
|
||||
)
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'col-start-4 col-end-5 row-start-1 min-w-0 overflow-hidden',
|
||||
inspectorOpen && 'border-l border-border/60'
|
||||
)}
|
||||
>
|
||||
<aside
|
||||
aria-hidden={!inspectorOpen}
|
||||
className={cn(
|
||||
'relative flex h-full 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-none',
|
||||
inspectorOpen ? 'opacity-100' : 'pointer-events-none opacity-0'
|
||||
)}
|
||||
>
|
||||
<RailHeader onOpenAll={onOpenCommandCenterSystem} />
|
||||
|
||||
<div className="flex min-h-0 flex-1 flex-col gap-1.5 overflow-y-auto px-1.5">
|
||||
{tasks.length === 0 ? <EmptyRail /> : tasks.slice(0, RAIL_TASK_LIMIT).map(task => <RailRow key={task.id} task={task} />)}
|
||||
</div>
|
||||
|
||||
<RailFooter onOpenSkills={onOpenSkills} onOpenSystem={onOpenCommandCenterSystem} />
|
||||
</aside>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function ChatPreviewRail({
|
||||
onRestartServer,
|
||||
setTitlebarToolGroup
|
||||
}: {
|
||||
onRestartServer?: (url: string, context?: string) => Promise<string>
|
||||
setTitlebarToolGroup?: SetTitlebarToolGroup
|
||||
}) {
|
||||
const previewReloadRequest = useStore($previewReloadRequest)
|
||||
const previewTarget = useStore($previewTarget)
|
||||
|
||||
if (!previewTarget) {
|
||||
return <aside aria-hidden="true" className="col-start-3 col-end-4 row-start-1 min-w-0 overflow-hidden" />
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="pointer-events-none col-start-3 col-end-4 row-start-1 min-w-0 overflow-hidden">
|
||||
<PreviewPane
|
||||
onRestartServer={onRestartServer}
|
||||
reloadRequest={previewReloadRequest}
|
||||
setTitlebarToolGroup={setTitlebarToolGroup}
|
||||
target={previewTarget}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function RailHeader({ onOpenAll }: { onOpenAll: () => void }) {
|
||||
return (
|
||||
<div className="mb-2 flex items-center justify-between gap-1 px-1.5">
|
||||
<span className="text-[0.68rem] font-semibold uppercase tracking-[0.07em] text-muted-foreground/85">Background</span>
|
||||
<Button className="h-6 px-2 text-[0.68rem]" onClick={onOpenAll} size="sm" variant="ghost">
|
||||
View all
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function RailFooter({ onOpenSkills, onOpenSystem }: { onOpenSkills: () => void; onOpenSystem: () => void }) {
|
||||
return (
|
||||
<div className="mt-2 flex items-center gap-1 px-1.5">
|
||||
<Button className="h-6 flex-1 justify-start px-2 text-[0.68rem]" onClick={onOpenSkills} size="sm" variant="ghost">
|
||||
Agents
|
||||
</Button>
|
||||
<Button className="h-6 flex-1 justify-start px-2 text-[0.68rem]" onClick={onOpenSystem} size="sm" variant="ghost">
|
||||
System
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function EmptyRail() {
|
||||
return (
|
||||
<div className="rounded-md border border-border/45 bg-background/55 px-2.5 py-2 text-[0.68rem] text-muted-foreground/80">
|
||||
No background activity.
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function RailRow({ task }: { task: RailTask }) {
|
||||
return (
|
||||
<div className="rounded-md border border-border/45 bg-background/58 px-2 py-1.5">
|
||||
<div className="flex items-center gap-1.5">
|
||||
{TASK_ICONS[task.status]}
|
||||
<span className="truncate text-[0.72rem] font-medium text-foreground/90">{task.label}</span>
|
||||
</div>
|
||||
<div className="mt-0.5 truncate pl-4.5 text-[0.66rem] text-muted-foreground/80">{task.detail}</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
import { atom, computed } from 'nanostores'
|
||||
|
||||
type Updater<T> = T | ((current: T) => T)
|
||||
|
||||
interface WritableStore<T> {
|
||||
get: () => T
|
||||
set: (value: T) => void
|
||||
}
|
||||
|
||||
const DEFAULT_CONSOLE_HEIGHT = 240
|
||||
|
||||
export interface ConsoleEntry {
|
||||
id: number
|
||||
level: number
|
||||
line?: number
|
||||
message: string
|
||||
source?: string
|
||||
}
|
||||
|
||||
export interface ConsoleEntryInput {
|
||||
level: number
|
||||
line?: number
|
||||
message: string
|
||||
source?: string
|
||||
}
|
||||
|
||||
function updateAtom<T>(store: WritableStore<T>, next: Updater<T>) {
|
||||
store.set(typeof next === 'function' ? (next as (current: T) => T)(store.get()) : next)
|
||||
}
|
||||
|
||||
export function createPreviewConsoleState() {
|
||||
const $height = atom(DEFAULT_CONSOLE_HEIGHT)
|
||||
const $logs = atom<ConsoleEntry[]>([])
|
||||
const $logCount = computed($logs, logs => logs.length)
|
||||
const $open = atom(false)
|
||||
const $selectedLogIds = atom<ReadonlySet<number>>(new Set())
|
||||
let nextLogId = 0
|
||||
|
||||
return {
|
||||
$height,
|
||||
$logCount,
|
||||
$logs,
|
||||
$open,
|
||||
$selectedLogIds,
|
||||
append(entry: ConsoleEntryInput) {
|
||||
$logs.set([...$logs.get().slice(-199), { ...entry, id: ++nextLogId }])
|
||||
},
|
||||
clear() {
|
||||
$logs.set([])
|
||||
$selectedLogIds.set(new Set())
|
||||
},
|
||||
clearSelection() {
|
||||
if ($selectedLogIds.get().size === 0) {return}
|
||||
|
||||
$selectedLogIds.set(new Set())
|
||||
},
|
||||
reset() {
|
||||
nextLogId = 0
|
||||
$logs.set([])
|
||||
$selectedLogIds.set(new Set())
|
||||
},
|
||||
setHeight(next: Updater<number>) {
|
||||
updateAtom($height, next)
|
||||
},
|
||||
setOpen(next: Updater<boolean>) {
|
||||
updateAtom($open, next)
|
||||
},
|
||||
toggleSelection(id: number) {
|
||||
const next = new Set($selectedLogIds.get())
|
||||
|
||||
if (!next.delete(id)) {
|
||||
next.add(id)
|
||||
}
|
||||
|
||||
$selectedLogIds.set(next)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export type PreviewConsoleState = ReturnType<typeof createPreviewConsoleState>
|
||||
@@ -0,0 +1,44 @@
|
||||
import { act, cleanup, render } from '@testing-library/react'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { PreviewPane } from './preview-pane'
|
||||
|
||||
describe('PreviewPane console state', () => {
|
||||
afterEach(() => {
|
||||
cleanup()
|
||||
})
|
||||
|
||||
it('does not rebuild the pane titlebar group for streamed console logs', () => {
|
||||
const setTitlebarToolGroup = vi.fn()
|
||||
|
||||
const rendered = render(
|
||||
<PreviewPane
|
||||
onClose={vi.fn()}
|
||||
setTitlebarToolGroup={setTitlebarToolGroup}
|
||||
target={{
|
||||
kind: 'url',
|
||||
label: 'Preview',
|
||||
source: 'http://localhost:5174',
|
||||
url: 'http://localhost:5174'
|
||||
}}
|
||||
/>
|
||||
)
|
||||
|
||||
const initialCalls = setTitlebarToolGroup.mock.calls.length
|
||||
const webview = rendered.container.querySelector('webview')
|
||||
|
||||
expect(webview).toBeInstanceOf(HTMLElement)
|
||||
|
||||
act(() => {
|
||||
webview?.dispatchEvent(
|
||||
Object.assign(new Event('console-message'), {
|
||||
level: 0,
|
||||
message: 'streamed log line',
|
||||
sourceId: 'http://localhost:5174/src/main.tsx'
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
expect(setTitlebarToolGroup).toHaveBeenCalledTimes(initialCalls)
|
||||
})
|
||||
})
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,44 @@
|
||||
import { useStore } from '@nanostores/react'
|
||||
|
||||
import type { SetTitlebarToolGroup } from '@/app/shell/titlebar-controls'
|
||||
import {
|
||||
$filePreviewTarget,
|
||||
$previewReloadRequest,
|
||||
$previewTarget,
|
||||
dismissFilePreviewTarget,
|
||||
dismissPreviewTarget
|
||||
} from '@/store/preview'
|
||||
|
||||
import { PreviewPane } from './preview-pane'
|
||||
|
||||
const INTRINSIC = 'clamp(18rem, 36vw, 38rem)'
|
||||
|
||||
// Track for <Pane id="preview">. Folds the intrinsic clamp with a min-floor
|
||||
// against --chat-min-width so the chat surface never gets squeezed below it.
|
||||
// Subtracts the project browser width so preview yields rather than crushing
|
||||
// the chat when both right-side panes are open.
|
||||
export const PREVIEW_RAIL_PANE_WIDTH = `min(${INTRINSIC}, max(0px, calc(100vw - var(--pane-chat-sidebar-width) - var(--pane-file-browser-width, 0px) - var(--chat-min-width))))`
|
||||
|
||||
interface ChatPreviewRailProps {
|
||||
onRestartServer?: (url: string, context?: string) => Promise<string>
|
||||
setTitlebarToolGroup?: SetTitlebarToolGroup
|
||||
}
|
||||
|
||||
export function ChatPreviewRail({ onRestartServer, setTitlebarToolGroup }: ChatPreviewRailProps) {
|
||||
const previewReloadRequest = useStore($previewReloadRequest)
|
||||
const filePreviewTarget = useStore($filePreviewTarget)
|
||||
const previewTarget = useStore($previewTarget)
|
||||
const target = filePreviewTarget ?? previewTarget
|
||||
|
||||
if (!target) {return null}
|
||||
|
||||
return (
|
||||
<PreviewPane
|
||||
onClose={filePreviewTarget ? dismissFilePreviewTarget : dismissPreviewTarget}
|
||||
onRestartServer={filePreviewTarget ? undefined : onRestartServer}
|
||||
reloadRequest={previewReloadRequest}
|
||||
setTitlebarToolGroup={setTitlebarToolGroup}
|
||||
target={target}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -1,123 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { FolderOpen, GitBranch, Pencil } from '@/lib/icons'
|
||||
|
||||
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('/')}`
|
||||
}
|
||||
@@ -1,33 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import { ChevronDown } from '@/lib/icons'
|
||||
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>
|
||||
)
|
||||
}
|
||||
@@ -1,19 +0,0 @@
|
||||
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>
|
||||
)
|
||||
}
|
||||
@@ -1,88 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import { type ReactNode, useState } from 'react'
|
||||
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuCheckboxItem,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger
|
||||
} from '@/components/ui/dropdown-menu'
|
||||
import { ChevronDown } from '@/lib/icons'
|
||||
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>
|
||||
)
|
||||
}
|
||||
@@ -1,36 +0,0 @@
|
||||
'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>
|
||||
)
|
||||
}
|
||||
@@ -15,7 +15,7 @@ import {
|
||||
} from '@/components/ui/sidebar'
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
import type { SessionInfo } from '@/hermes'
|
||||
import { Brain, ChevronDown, Layers3, Pin, Plus, RefreshCw } from '@/lib/icons'
|
||||
import { Brain, ChevronDown, Command, Layers3, Pin, Plus, RefreshCw, Settings } from '@/lib/icons'
|
||||
import { cn } from '@/lib/utils'
|
||||
import {
|
||||
$pinnedSessionIds,
|
||||
@@ -29,7 +29,7 @@ import {
|
||||
} from '@/store/layout'
|
||||
import { $selectedStoredSessionId, $sessions, $sessionsLoading, $workingSessionIds } from '@/store/session'
|
||||
|
||||
import { type AppView, ARTIFACTS_ROUTE, SKILLS_ROUTE } from '../../routes'
|
||||
import { type AppView, ARTIFACTS_ROUTE, COMMAND_CENTER_ROUTE, SETTINGS_ROUTE, SKILLS_ROUTE } from '../../routes'
|
||||
import type { SidebarNavItem } from '../../types'
|
||||
|
||||
import { SidebarSessionRow } from './session-row'
|
||||
@@ -37,16 +37,21 @@ import { SidebarSessionRow } from './session-row'
|
||||
const SIDEBAR_NAV: SidebarNavItem[] = [
|
||||
{
|
||||
id: 'new-session',
|
||||
label: 'New session',
|
||||
label: 'New chat',
|
||||
icon: Plus,
|
||||
action: 'new-session'
|
||||
},
|
||||
{ id: 'command-center', label: 'Command Center', icon: Command, route: COMMAND_CENTER_ROUTE },
|
||||
{ id: 'skills', label: 'Skills', icon: Brain, route: SKILLS_ROUTE },
|
||||
{ id: 'artifacts', label: 'Artifacts', icon: Layers3, route: ARTIFACTS_ROUTE }
|
||||
{ id: 'artifacts', label: 'Artifacts', icon: Layers3, route: ARTIFACTS_ROUTE },
|
||||
{ id: 'settings', label: 'Settings', icon: Settings, route: SETTINGS_ROUTE }
|
||||
]
|
||||
|
||||
const sidebarNavItemClass =
|
||||
'flex h-7 w-full justify-start gap-2 rounded-md px-2 text-left text-sm font-medium text-muted-foreground transition-colors duration-300 ease-out hover:bg-accent hover:text-foreground hover:transition-none'
|
||||
'flex h-7 w-full justify-start gap-2 rounded-md border border-transparent px-2 text-left text-sm font-medium text-muted-foreground transition-colors duration-300 ease-out hover:border-[color-mix(in_srgb,var(--dt-border)_60%,transparent)] hover:bg-[color-mix(in_srgb,var(--dt-card)_78%,transparent)] hover:text-foreground hover:transition-none'
|
||||
|
||||
const sidebarNavItemActiveClass =
|
||||
'border-[color-mix(in_srgb,var(--dt-primary)_34%,var(--dt-border))] bg-[color-mix(in_srgb,var(--dt-primary)_10%,var(--dt-card))] text-foreground shadow-[inset_0_0.0625rem_0_color-mix(in_srgb,white_40%,transparent)]'
|
||||
|
||||
interface ChatSidebarProps extends React.ComponentProps<typeof Sidebar> {
|
||||
currentView: AppView
|
||||
@@ -109,12 +114,17 @@ export function ChatSidebar({
|
||||
>
|
||||
<SidebarContent className="gap-0 overflow-hidden bg-transparent">
|
||||
<SidebarGroup className="shrink-0 pl-4 pr-2 pb-2 pt-[calc(var(--titlebar-height)+0.25rem)]">
|
||||
<SidebarGroupLabel className="h-auto px-2 pb-1 pt-1 text-[0.64rem] font-semibold uppercase tracking-[0.07em] text-muted-foreground/70">
|
||||
Workspace
|
||||
</SidebarGroupLabel>
|
||||
<SidebarGroupContent>
|
||||
<SidebarMenu className="gap-px">
|
||||
{SIDEBAR_NAV.map(item => {
|
||||
const isInteractive = Boolean(item.action) || Boolean(item.route)
|
||||
|
||||
const active =
|
||||
(item.id === 'command-center' && currentView === 'command-center') ||
|
||||
(item.id === 'settings' && currentView === 'settings') ||
|
||||
(item.id === 'skills' && currentView === 'skills') ||
|
||||
(item.id === 'artifacts' && currentView === 'artifacts')
|
||||
|
||||
@@ -124,8 +134,9 @@ export function ChatSidebar({
|
||||
aria-disabled={!isInteractive}
|
||||
className={cn(
|
||||
sidebarNavItemClass,
|
||||
active && 'bg-accent text-foreground',
|
||||
!isInteractive && 'cursor-default hover:bg-transparent hover:text-muted-foreground'
|
||||
active && sidebarNavItemActiveClass,
|
||||
!isInteractive &&
|
||||
'cursor-default hover:border-transparent hover:bg-transparent hover:text-muted-foreground'
|
||||
)}
|
||||
onClick={() => onNavigate(item)}
|
||||
tooltip={item.label}
|
||||
@@ -147,9 +158,9 @@ export function ChatSidebar({
|
||||
{pinsOpen && (
|
||||
<SidebarGroupContent className="flex min-h-10 shrink-0 flex-col gap-px rounded-lg pb-2 pt-1">
|
||||
{pinnedSessions.length === 0 && (
|
||||
<div className="flex min-h-8 items-center gap-2 rounded-lg px-2 text-xs text-muted-foreground opacity-50">
|
||||
<div className="flex min-h-8 items-center gap-2 rounded-lg px-2 text-xs text-muted-foreground/80">
|
||||
<Pin size={14} />
|
||||
<span>Shift+click to pin</span>
|
||||
<span>Pin important chats from the ••• menu</span>
|
||||
</div>
|
||||
)}
|
||||
{pinnedSessions.map(session => (
|
||||
@@ -188,7 +199,7 @@ export function ChatSidebar({
|
||||
<RefreshCw className={cn(sessionsLoading && 'animate-spin')} />
|
||||
</Button>
|
||||
}
|
||||
label="Sessions"
|
||||
label="Recent chats"
|
||||
onToggle={() => setSidebarRecentsOpen(!recentsOpen)}
|
||||
open={recentsOpen}
|
||||
/>
|
||||
@@ -270,7 +281,7 @@ function SidebarSessionSkeletons() {
|
||||
function SidebarEmptySessionState() {
|
||||
return (
|
||||
<div className="grid min-h-35 place-items-center rounded-lg px-3 text-center text-xs text-muted-foreground">
|
||||
Recent chats will appear here.
|
||||
Start a chat to build your history.
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -278,7 +289,7 @@ function SidebarEmptySessionState() {
|
||||
function SidebarAllPinnedState() {
|
||||
return (
|
||||
<div className="grid min-h-24 place-items-center rounded-lg px-3 text-center text-xs text-muted-foreground">
|
||||
Pinned sessions stay above.
|
||||
Everything here is pinned. Unpin a chat to show it in recents.
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,15 +1,18 @@
|
||||
import {
|
||||
IconArchive,
|
||||
IconBookmark,
|
||||
IconBookmarkFilled,
|
||||
IconCircleX,
|
||||
IconCopy,
|
||||
IconFileDownload,
|
||||
IconPencil
|
||||
} from '@tabler/icons-react'
|
||||
import { IconBookmark, IconBookmarkFilled, IconCircleX, IconFileDownload, IconPencil } from '@tabler/icons-react'
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import type * as React from 'react'
|
||||
import type { ReactNode } from 'react'
|
||||
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { CopyButton } from '@/components/ui/copy-button'
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle
|
||||
} from '@/components/ui/dialog'
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
@@ -17,10 +20,13 @@ import {
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger
|
||||
} from '@/components/ui/dropdown-menu'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { renameSession } from '@/hermes'
|
||||
import { triggerHaptic } from '@/lib/haptics'
|
||||
import { exportSession } from '@/lib/session-export'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { notify, notifyError } from '@/store/notifications'
|
||||
import { setSessions } from '@/store/session'
|
||||
|
||||
interface SessionActionsMenuProps extends Pick<
|
||||
React.ComponentProps<typeof DropdownMenuContent>,
|
||||
@@ -45,70 +51,154 @@ export function SessionActionsMenu({
|
||||
sideOffset = 6
|
||||
}: SessionActionsMenuProps) {
|
||||
const itemClass = 'gap-2.5 text-foreground focus:bg-accent [&_svg]:size-4'
|
||||
const [renameOpen, setRenameOpen] = useState(false)
|
||||
|
||||
const copyId = async () => {
|
||||
triggerHaptic('selection')
|
||||
return (
|
||||
<>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>{children}</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align={align} aria-label={`Actions for ${title}`} className="w-44" sideOffset={sideOffset}>
|
||||
<DropdownMenuItem
|
||||
className={itemClass}
|
||||
disabled={!onPin}
|
||||
onSelect={() => {
|
||||
triggerHaptic('selection')
|
||||
onPin?.()
|
||||
}}
|
||||
>
|
||||
{pinned ? <IconBookmarkFilled /> : <IconBookmark />}
|
||||
<span>{pinned ? 'Unpin' : 'Pin'}</span>
|
||||
</DropdownMenuItem>
|
||||
<CopyButton
|
||||
appearance="menu-item"
|
||||
className={itemClass}
|
||||
disabled={!sessionId}
|
||||
errorMessage="Could not copy session ID"
|
||||
label="Copy ID"
|
||||
text={sessionId}
|
||||
/>
|
||||
<DropdownMenuItem
|
||||
className={itemClass}
|
||||
disabled={!sessionId}
|
||||
onSelect={() => {
|
||||
triggerHaptic('selection')
|
||||
void exportSession(sessionId, { title })
|
||||
}}
|
||||
>
|
||||
<IconFileDownload />
|
||||
<span>Export</span>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
className={itemClass}
|
||||
disabled={!sessionId}
|
||||
onSelect={() => {
|
||||
triggerHaptic('selection')
|
||||
setRenameOpen(true)
|
||||
}}
|
||||
>
|
||||
<IconPencil />
|
||||
<span>Rename</span>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator className="my-3" />
|
||||
<DropdownMenuItem
|
||||
className={cn(itemClass, 'text-destructive focus:text-destructive')}
|
||||
disabled={!onDelete}
|
||||
onSelect={() => {
|
||||
triggerHaptic('warning')
|
||||
onDelete?.()
|
||||
}}
|
||||
variant="destructive"
|
||||
>
|
||||
<IconCircleX />
|
||||
<span>Delete</span>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
|
||||
<RenameSessionDialog currentTitle={title} onOpenChange={setRenameOpen} open={renameOpen} sessionId={sessionId} />
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
interface RenameSessionDialogProps {
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
sessionId: string
|
||||
currentTitle: string
|
||||
}
|
||||
|
||||
function RenameSessionDialog({ open, onOpenChange, sessionId, currentTitle }: RenameSessionDialogProps) {
|
||||
const [value, setValue] = useState(currentTitle)
|
||||
const [submitting, setSubmitting] = useState(false)
|
||||
const inputRef = useRef<HTMLInputElement>(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
setValue(currentTitle)
|
||||
window.setTimeout(() => inputRef.current?.select(), 0)
|
||||
}
|
||||
}, [currentTitle, open])
|
||||
|
||||
const submit = async () => {
|
||||
const next = value.trim()
|
||||
|
||||
if (!sessionId || submitting) {
|
||||
return
|
||||
}
|
||||
|
||||
if (next === currentTitle.trim()) {
|
||||
onOpenChange(false)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
setSubmitting(true)
|
||||
|
||||
try {
|
||||
await navigator.clipboard.writeText(sessionId)
|
||||
notify({ kind: 'success', message: 'Session ID copied', durationMs: 2_000 })
|
||||
const result = await renameSession(sessionId, next)
|
||||
const finalTitle = result.title || next || ''
|
||||
setSessions(prev => prev.map(s => (s.id === sessionId ? { ...s, title: finalTitle || null } : s)))
|
||||
notify({ kind: 'success', message: 'Renamed', durationMs: 2_000 })
|
||||
onOpenChange(false)
|
||||
} catch (err) {
|
||||
notifyError(err, 'Could not copy session ID')
|
||||
notifyError(err, 'Rename failed')
|
||||
} finally {
|
||||
setSubmitting(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>{children}</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align={align} aria-label={`Actions for ${title}`} className="w-44" sideOffset={sideOffset}>
|
||||
<DropdownMenuItem
|
||||
className={itemClass}
|
||||
disabled={!onPin}
|
||||
onSelect={() => {
|
||||
triggerHaptic('selection')
|
||||
onPin?.()
|
||||
<Dialog onOpenChange={onOpenChange} open={open}>
|
||||
<DialogContent className="max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Rename session</DialogTitle>
|
||||
<DialogDescription>Give this chat a memorable title. Leave empty to clear.</DialogDescription>
|
||||
</DialogHeader>
|
||||
<Input
|
||||
autoFocus
|
||||
disabled={submitting}
|
||||
onChange={event => setValue(event.target.value)}
|
||||
onKeyDown={event => {
|
||||
if (event.key === 'Enter') {
|
||||
event.preventDefault()
|
||||
void submit()
|
||||
} else if (event.key === 'Escape') {
|
||||
onOpenChange(false)
|
||||
}
|
||||
}}
|
||||
>
|
||||
{pinned ? <IconBookmarkFilled /> : <IconBookmark />}
|
||||
<span>{pinned ? 'Unpin' : 'Pin'}</span>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem className={itemClass} onSelect={() => void copyId()}>
|
||||
<IconCopy />
|
||||
<span>Copy ID</span>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
className={itemClass}
|
||||
disabled={!sessionId}
|
||||
onSelect={() => {
|
||||
triggerHaptic('selection')
|
||||
void exportSession(sessionId, { title })
|
||||
}}
|
||||
>
|
||||
<IconFileDownload />
|
||||
<span>Export</span>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem className={itemClass}>
|
||||
<IconPencil />
|
||||
<span>Rename</span>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem className={itemClass}>
|
||||
<IconArchive />
|
||||
<span>Add to project</span>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator className="my-3" />
|
||||
<DropdownMenuItem
|
||||
className={cn(itemClass, 'text-destructive focus:text-destructive')}
|
||||
disabled={!onDelete}
|
||||
onSelect={() => {
|
||||
triggerHaptic('warning')
|
||||
onDelete?.()
|
||||
}}
|
||||
variant="destructive"
|
||||
>
|
||||
<IconCircleX />
|
||||
<span>Delete</span>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
placeholder="Untitled session"
|
||||
ref={inputRef}
|
||||
value={value}
|
||||
/>
|
||||
<DialogFooter>
|
||||
<Button disabled={submitting} onClick={() => onOpenChange(false)} type="button" variant="ghost">
|
||||
Cancel
|
||||
</Button>
|
||||
<Button disabled={submitting} onClick={() => void submit()} type="button">
|
||||
Save
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,167 @@
|
||||
import { useStore } from '@nanostores/react'
|
||||
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { FadeText } from '@/components/ui/fade-text'
|
||||
import { FolderOpen, RefreshCw } from '@/lib/icons'
|
||||
import { normalizeOrLocalPreviewTarget } from '@/lib/local-preview'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { notifyError } from '@/store/notifications'
|
||||
import { setCurrentSessionPreviewTarget } from '@/store/preview'
|
||||
import { $currentCwd } from '@/store/session'
|
||||
|
||||
import { ProjectTree } from './tree'
|
||||
import { useProjectTree } from './use-project-tree'
|
||||
|
||||
const HEADER_ACTION_CLASS =
|
||||
'pointer-events-none size-6 shrink-0 opacity-0 text-muted-foreground/75 transition-opacity hover:text-foreground focus-visible:opacity-100 group-focus-within/project-header:pointer-events-auto group-focus-within/project-header:opacity-100 group-hover/project-header:pointer-events-auto group-hover/project-header:opacity-100'
|
||||
|
||||
interface FileBrowserPaneProps {
|
||||
/** Activates a file row — drops the path into the composer as `@file:` ref. */
|
||||
onActivateFile: (path: string) => void
|
||||
onChangeCwd: (path: string) => Promise<void> | void
|
||||
}
|
||||
|
||||
export function FileBrowserPane({ onActivateFile, onChangeCwd }: FileBrowserPaneProps) {
|
||||
const currentCwd = useStore($currentCwd).trim()
|
||||
const hasCwd = currentCwd.length > 0
|
||||
const cwdName = hasCwd ? (currentCwd.split(/[\\/]+/).filter(Boolean).pop() ?? currentCwd) : 'No folder selected'
|
||||
const { data, loadChildren, openState, refreshRoot, rootError, rootLoading, setNodeOpen } = useProjectTree(currentCwd)
|
||||
|
||||
const chooseFolder = async () => {
|
||||
const selected = await window.hermesDesktop?.selectPaths({
|
||||
title: 'Change working directory',
|
||||
defaultPath: hasCwd ? currentCwd : undefined,
|
||||
directories: true,
|
||||
multiple: false
|
||||
})
|
||||
|
||||
if (selected?.[0]) {
|
||||
await onChangeCwd(selected[0])
|
||||
}
|
||||
}
|
||||
|
||||
const previewFile = async (path: string) => {
|
||||
try {
|
||||
const preview = await normalizeOrLocalPreviewTarget(path, currentCwd || undefined)
|
||||
|
||||
if (!preview) {
|
||||
throw new Error(`Could not preview ${path}`)
|
||||
}
|
||||
|
||||
setCurrentSessionPreviewTarget(preview, 'file-browser', path)
|
||||
} catch (error) {
|
||||
notifyError(error, 'Preview unavailable')
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<aside
|
||||
aria-label="File browser"
|
||||
className="relative flex h-full w-full min-w-0 flex-col overflow-hidden border-l border-border/60 bg-[color-mix(in_srgb,var(--dt-sidebar-bg)_94%,transparent)] pt-[calc(var(--titlebar-height)-0.625rem)] text-muted-foreground [backdrop-filter:blur(1.5rem)_saturate(1.08)]"
|
||||
>
|
||||
<header className="group/project-header shrink-0 pl-4 pr-2 pb-1 pt-0">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<FadeText
|
||||
className="flex-1 px-2 pb-1 pt-1 text-[0.64rem] font-semibold uppercase tracking-[0.07em] text-muted-foreground/70"
|
||||
title={hasCwd ? currentCwd : 'No folder selected'}
|
||||
>
|
||||
{cwdName}
|
||||
</FadeText>
|
||||
<Button
|
||||
aria-label="Change working directory"
|
||||
className={HEADER_ACTION_CLASS}
|
||||
onClick={() => void chooseFolder()}
|
||||
size="icon"
|
||||
title="Change working directory"
|
||||
variant="ghost"
|
||||
>
|
||||
<FolderOpen className="size-3.5" />
|
||||
</Button>
|
||||
<Button
|
||||
aria-label="Refresh tree"
|
||||
className={HEADER_ACTION_CLASS}
|
||||
disabled={!hasCwd || rootLoading}
|
||||
onClick={() => void refreshRoot()}
|
||||
size="icon"
|
||||
title="Refresh tree"
|
||||
variant="ghost"
|
||||
>
|
||||
<RefreshCw className={cn('size-3.5', rootLoading && 'animate-spin')} />
|
||||
</Button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<FileTreeBody
|
||||
cwd={currentCwd}
|
||||
data={data}
|
||||
error={rootError}
|
||||
loading={rootLoading}
|
||||
onActivateFile={onActivateFile}
|
||||
onLoadChildren={loadChildren}
|
||||
onNodeOpenChange={setNodeOpen}
|
||||
onPreviewFile={previewFile}
|
||||
openState={openState}
|
||||
/>
|
||||
</aside>
|
||||
)
|
||||
}
|
||||
|
||||
interface FileTreeBodyProps {
|
||||
cwd: string
|
||||
data: ReturnType<typeof useProjectTree>['data']
|
||||
error: string | null
|
||||
loading: boolean
|
||||
onActivateFile: (path: string) => void
|
||||
onLoadChildren: (id: string) => void | Promise<void>
|
||||
onNodeOpenChange: (id: string, open: boolean) => void
|
||||
onPreviewFile?: (path: string) => void
|
||||
openState: ReturnType<typeof useProjectTree>['openState']
|
||||
}
|
||||
|
||||
function FileTreeBody({
|
||||
cwd,
|
||||
data,
|
||||
error,
|
||||
loading,
|
||||
onActivateFile,
|
||||
onLoadChildren,
|
||||
onNodeOpenChange,
|
||||
onPreviewFile,
|
||||
openState
|
||||
}: FileTreeBodyProps) {
|
||||
if (!cwd) {
|
||||
return <EmptyState body="Set a working directory from the status bar to browse files." title="No project" />
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return <EmptyState body={`Could not read this folder (${error}).`} title="Unreadable" />
|
||||
}
|
||||
|
||||
if (loading && data.length === 0) {
|
||||
return <EmptyState body="Reading project…" title="Loading" />
|
||||
}
|
||||
|
||||
if (data.length === 0) {
|
||||
return <EmptyState body="This folder is empty." title="Empty" />
|
||||
}
|
||||
|
||||
return (
|
||||
<ProjectTree
|
||||
data={data}
|
||||
onActivateFile={onActivateFile}
|
||||
onLoadChildren={onLoadChildren}
|
||||
onNodeOpenChange={onNodeOpenChange}
|
||||
onPreviewFile={onPreviewFile}
|
||||
openState={openState}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function EmptyState({ body, title }: { body: string; title: string }) {
|
||||
return (
|
||||
<div className="flex min-h-0 flex-1 flex-col items-center justify-center gap-1 px-4 text-center">
|
||||
<div className="text-[0.7rem] font-semibold uppercase tracking-[0.07em] text-muted-foreground/75">{title}</div>
|
||||
<div className="text-[0.68rem] leading-relaxed text-muted-foreground/65">{body}</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,143 @@
|
||||
import ignore from 'ignore'
|
||||
|
||||
import type { HermesReadDirEntry, HermesReadDirResult } from '@/global'
|
||||
|
||||
export type ProjectTreeEntry = HermesReadDirEntry
|
||||
|
||||
interface GitignoreRule {
|
||||
base: string
|
||||
ig: ReturnType<typeof ignore>
|
||||
}
|
||||
|
||||
const gitRootCache = new Map<string, Promise<string | null>>()
|
||||
const gitignoreCache = new Map<string, Promise<GitignoreRule | null>>()
|
||||
|
||||
function decodeDataUrl(dataUrl: string) {
|
||||
const match = dataUrl.match(/^data:[^,]*,(.*)$/)
|
||||
const data = match?.[1] || ''
|
||||
const isBase64 = dataUrl.slice(0, dataUrl.indexOf(',')).includes(';base64')
|
||||
|
||||
if (!isBase64) {return decodeURIComponent(data)}
|
||||
|
||||
const bytes = Uint8Array.from(atob(data), ch => ch.charCodeAt(0))
|
||||
|
||||
return new TextDecoder().decode(bytes)
|
||||
}
|
||||
|
||||
function clean(path: string) {
|
||||
return path.replace(/\/+$/, '') || '/'
|
||||
}
|
||||
|
||||
/** Strict POSIX-style relative path; null if `child` is not inside `root`. */
|
||||
function relativeTo(root: string, child: string) {
|
||||
const r = clean(root)
|
||||
const c = clean(child)
|
||||
|
||||
if (c === r) {return ''}
|
||||
|
||||
return c.startsWith(`${r}/`) ? c.slice(r.length + 1) : null
|
||||
}
|
||||
|
||||
/** Repo-root → repo-root/a → repo-root/a/b → … for every dir between root and `dir`. */
|
||||
function ancestorDirs(root: string, dir: string) {
|
||||
const r = clean(root)
|
||||
const rel = relativeTo(r, dir)
|
||||
|
||||
if (rel === null || rel === '') {return [r]}
|
||||
|
||||
const dirs = [r]
|
||||
let current = r
|
||||
|
||||
for (const part of rel.split('/').filter(Boolean)) {
|
||||
current = `${current}/${part}`
|
||||
dirs.push(current)
|
||||
}
|
||||
|
||||
return dirs
|
||||
}
|
||||
|
||||
async function gitRootFor(start: string) {
|
||||
if (!window.hermesDesktop?.gitRoot) {return null}
|
||||
|
||||
const key = clean(start)
|
||||
let cached = gitRootCache.get(key)
|
||||
|
||||
if (!cached) {
|
||||
cached = window.hermesDesktop.gitRoot(key)
|
||||
gitRootCache.set(key, cached)
|
||||
}
|
||||
|
||||
return cached
|
||||
}
|
||||
|
||||
/** Read .gitignore at `dir` if it actually exists — never probe missing files. */
|
||||
async function readGitignore(dir: string): Promise<GitignoreRule | null> {
|
||||
if (!window.hermesDesktop?.readDir || !window.hermesDesktop.readFileDataUrl) {return null}
|
||||
|
||||
try {
|
||||
const listing = await window.hermesDesktop.readDir(dir)
|
||||
|
||||
if (!listing.entries.some(e => e.name === '.gitignore' && !e.isDirectory)) {return null}
|
||||
|
||||
const text = decodeDataUrl(await window.hermesDesktop.readFileDataUrl(`${dir}/.gitignore`))
|
||||
|
||||
return { base: dir, ig: ignore().add(text) }
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
async function gitignoreFor(dir: string) {
|
||||
const key = clean(dir)
|
||||
let cached = gitignoreCache.get(key)
|
||||
|
||||
if (!cached) {
|
||||
cached = readGitignore(key)
|
||||
gitignoreCache.set(key, cached)
|
||||
}
|
||||
|
||||
return cached
|
||||
}
|
||||
|
||||
function ignoredBy(rules: GitignoreRule[], entry: HermesReadDirEntry) {
|
||||
return rules.some(rule => {
|
||||
const rel = relativeTo(rule.base, entry.path)
|
||||
|
||||
if (rel === null || rel === '') {return false}
|
||||
|
||||
return rule.ig.ignores(entry.isDirectory ? `${rel}/` : rel)
|
||||
})
|
||||
}
|
||||
|
||||
async function filterIgnored(entries: HermesReadDirEntry[], rootPath: string, dirPath: string) {
|
||||
const root = await gitRootFor(rootPath)
|
||||
|
||||
if (!root) {return entries}
|
||||
|
||||
const rules = (await Promise.all(ancestorDirs(root, dirPath).map(gitignoreFor))).filter(
|
||||
(r): r is GitignoreRule => Boolean(r)
|
||||
)
|
||||
|
||||
return rules.length > 0 ? entries.filter(entry => !ignoredBy(rules, entry)) : entries
|
||||
}
|
||||
|
||||
export async function readProjectDir(dirPath: string, rootPath = dirPath): Promise<HermesReadDirResult> {
|
||||
if (!window.hermesDesktop) {return { entries: [], error: 'no-bridge' }}
|
||||
|
||||
const result = await window.hermesDesktop.readDir(dirPath)
|
||||
|
||||
return { ...result, entries: await filterIgnored(result.entries, rootPath, dirPath) }
|
||||
}
|
||||
|
||||
export function clearProjectDirCache(rootPath?: string) {
|
||||
if (!rootPath) {
|
||||
gitRootCache.clear()
|
||||
gitignoreCache.clear()
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
const key = clean(rootPath)
|
||||
gitRootCache.delete(key)
|
||||
gitignoreCache.delete(key)
|
||||
}
|
||||
@@ -0,0 +1,177 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { type NodeApi, type NodeRendererProps, Tree, type TreeApi } from 'react-arborist'
|
||||
|
||||
import { ChevronDown, ChevronRight, FileText, FolderOpen, Loader2 } from '@/lib/icons'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
import type { TreeNode } from './use-project-tree'
|
||||
|
||||
const ROW_HEIGHT = 24
|
||||
const INDENT = 14
|
||||
|
||||
interface ProjectTreeProps {
|
||||
data: TreeNode[]
|
||||
onActivateFile: (path: string) => void
|
||||
onLoadChildren: (id: string) => void | Promise<void>
|
||||
onNodeOpenChange: (id: string, open: boolean) => void
|
||||
onPreviewFile?: (path: string) => void
|
||||
openState: Record<string, boolean>
|
||||
}
|
||||
|
||||
export function ProjectTree({
|
||||
data,
|
||||
onActivateFile,
|
||||
onLoadChildren,
|
||||
onNodeOpenChange,
|
||||
onPreviewFile,
|
||||
openState
|
||||
}: ProjectTreeProps) {
|
||||
const containerRef = useRef<HTMLDivElement | null>(null)
|
||||
const treeRef = useRef<TreeApi<TreeNode> | null>(null)
|
||||
const [size, setSize] = useState({ height: 0, width: 0 })
|
||||
|
||||
useEffect(() => {
|
||||
const el = containerRef.current
|
||||
|
||||
if (!el || typeof ResizeObserver === 'undefined') {return}
|
||||
|
||||
const observer = new ResizeObserver(([entry]) => {
|
||||
const { height, width } = entry.contentRect
|
||||
setSize({ height, width })
|
||||
})
|
||||
|
||||
observer.observe(el)
|
||||
|
||||
return () => observer.disconnect()
|
||||
}, [])
|
||||
|
||||
const handleToggle = useCallback(
|
||||
(id: string) => {
|
||||
const node = treeRef.current?.get(id)
|
||||
|
||||
if (!node) {return}
|
||||
|
||||
onNodeOpenChange(id, node.isOpen)
|
||||
|
||||
// Lazy fetch on first expand: children===undefined means "not yet loaded".
|
||||
if (node.isOpen && node.data.children === undefined) {
|
||||
void onLoadChildren(id)
|
||||
}
|
||||
},
|
||||
[onLoadChildren, onNodeOpenChange]
|
||||
)
|
||||
|
||||
const handleActivate = useCallback(
|
||||
(node: NodeApi<TreeNode>) => {
|
||||
if (!node.data.isDirectory) {
|
||||
onPreviewFile?.(node.data.id)
|
||||
}
|
||||
},
|
||||
[onPreviewFile]
|
||||
)
|
||||
|
||||
return (
|
||||
<div className="min-h-0 flex-1 overflow-hidden px-2" ref={containerRef}>
|
||||
{size.height > 0 && size.width > 0 ? (
|
||||
<Tree<TreeNode>
|
||||
childrenAccessor={node => (node.isDirectory ? (node.children ?? []) : null)}
|
||||
data={data}
|
||||
disableDrag
|
||||
disableDrop
|
||||
disableEdit
|
||||
height={size.height}
|
||||
indent={INDENT}
|
||||
initialOpenState={openState}
|
||||
onActivate={handleActivate}
|
||||
onToggle={handleToggle}
|
||||
openByDefault={false}
|
||||
padding={2}
|
||||
ref={treeRef}
|
||||
rowHeight={ROW_HEIGHT}
|
||||
width={size.width}
|
||||
>
|
||||
{props => <ProjectTreeRow {...props} onAttachFile={onActivateFile} onPreviewFile={onPreviewFile} />}
|
||||
</Tree>
|
||||
) : null}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function ProjectTreeRow({
|
||||
dragHandle,
|
||||
node,
|
||||
onAttachFile,
|
||||
onPreviewFile,
|
||||
style
|
||||
}: NodeRendererProps<TreeNode> & { onAttachFile: (path: string) => void; onPreviewFile?: (path: string) => void }) {
|
||||
const isFolder = node.data.isDirectory
|
||||
const isPlaceholder = node.data.id.endsWith('::__loading__')
|
||||
const Caret = node.isOpen ? ChevronDown : ChevronRight
|
||||
|
||||
return (
|
||||
<div
|
||||
aria-expanded={isFolder ? node.isOpen : undefined}
|
||||
aria-selected={node.isSelected}
|
||||
className={cn(
|
||||
'group/row flex h-full cursor-pointer select-none items-center gap-1 rounded-sm px-1.5 text-[0.72rem] leading-none text-foreground/85 transition-colors hover:bg-accent/55',
|
||||
node.isSelected && 'bg-accent/65 text-foreground',
|
||||
isPlaceholder && 'pointer-events-none italic text-muted-foreground/70'
|
||||
)}
|
||||
draggable={!isPlaceholder}
|
||||
onClick={event => {
|
||||
event.stopPropagation()
|
||||
|
||||
if (isPlaceholder) {return}
|
||||
|
||||
if (isFolder) {
|
||||
node.toggle()
|
||||
} else {
|
||||
node.select()
|
||||
|
||||
if (event.shiftKey) {
|
||||
onAttachFile(node.data.id)
|
||||
}
|
||||
}
|
||||
}}
|
||||
onDoubleClick={event => {
|
||||
event.stopPropagation()
|
||||
|
||||
if (!isFolder && !isPlaceholder) {
|
||||
onPreviewFile?.(node.data.id)
|
||||
}
|
||||
}}
|
||||
onDragStart={event => {
|
||||
if (isPlaceholder) {
|
||||
event.preventDefault()
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
// Custom MIME the composer's drop handler unpacks. text/plain is set
|
||||
// as a fallback so dragging into other apps gets a sensible payload
|
||||
// (the absolute path).
|
||||
const payload = JSON.stringify([{ isDirectory: isFolder, path: node.data.id }])
|
||||
|
||||
event.dataTransfer.effectAllowed = 'copy'
|
||||
event.dataTransfer.setData('application/x-hermes-paths', payload)
|
||||
event.dataTransfer.setData('text/plain', node.data.id)
|
||||
}}
|
||||
ref={dragHandle}
|
||||
style={style}
|
||||
>
|
||||
<span aria-hidden className={cn('flex w-3.5 items-center justify-center', !isFolder && 'opacity-0')}>
|
||||
{isFolder && !isPlaceholder ? <Caret className="size-3 text-muted-foreground/70" /> : null}
|
||||
</span>
|
||||
<span aria-hidden className="flex w-3.5 items-center justify-center text-muted-foreground/85">
|
||||
{isPlaceholder ? (
|
||||
<Loader2 className="size-3 animate-spin" />
|
||||
) : isFolder ? (
|
||||
<FolderOpen className="size-3.5" />
|
||||
) : (
|
||||
<FileText className="size-3.5" />
|
||||
)}
|
||||
</span>
|
||||
<span className="min-w-0 flex-1 truncate">{node.data.name}</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,190 @@
|
||||
import { act, renderHook, waitFor } from '@testing-library/react'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import type { HermesReadDirResult } from '@/global'
|
||||
|
||||
import { resetProjectTreeState, useProjectTree } from './use-project-tree'
|
||||
|
||||
const readDir = vi.fn<(path: string) => Promise<HermesReadDirResult>>()
|
||||
|
||||
beforeEach(() => {
|
||||
resetProjectTreeState()
|
||||
readDir.mockReset()
|
||||
;(window as unknown as { hermesDesktop: { readDir: typeof readDir } }).hermesDesktop = { readDir }
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
resetProjectTreeState()
|
||||
delete (window as unknown as { hermesDesktop?: unknown }).hermesDesktop
|
||||
})
|
||||
|
||||
function ok(entries: { name: string; path: string; isDirectory: boolean }[]): HermesReadDirResult {
|
||||
return { entries }
|
||||
}
|
||||
|
||||
describe('useProjectTree', () => {
|
||||
it('starts empty when cwd is blank and skips IPC', async () => {
|
||||
const { result } = renderHook(() => useProjectTree(''))
|
||||
|
||||
await waitFor(() => expect(result.current.rootLoading).toBe(false))
|
||||
|
||||
expect(result.current.data).toEqual([])
|
||||
expect(result.current.rootError).toBeNull()
|
||||
expect(readDir).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('loads root entries on mount and sorts folders before files', async () => {
|
||||
readDir.mockResolvedValueOnce(
|
||||
ok([
|
||||
{ name: 'README.md', path: '/p/README.md', isDirectory: false },
|
||||
{ name: 'src', path: '/p/src', isDirectory: true }
|
||||
])
|
||||
)
|
||||
|
||||
const { result } = renderHook(() => useProjectTree('/p'))
|
||||
|
||||
await waitFor(() => expect(result.current.data.length).toBe(2))
|
||||
|
||||
expect(readDir).toHaveBeenCalledWith('/p')
|
||||
// Hook trusts main-process sort order; folders/files preserved as supplied.
|
||||
expect(result.current.data.map(n => n.name)).toEqual(['README.md', 'src'])
|
||||
// Folder children start undefined (lazy load on first expand).
|
||||
expect(result.current.data.find(n => n.name === 'src')?.children).toBeUndefined()
|
||||
expect(result.current.data.find(n => n.name === 'src')?.isDirectory).toBe(true)
|
||||
expect(result.current.data.find(n => n.name === 'README.md')?.isDirectory).toBe(false)
|
||||
})
|
||||
|
||||
it('records rootError when readDir returns an error', async () => {
|
||||
readDir.mockResolvedValueOnce({ entries: [], error: 'EACCES' })
|
||||
|
||||
const { result } = renderHook(() => useProjectTree('/locked'))
|
||||
|
||||
await waitFor(() => expect(result.current.rootError).toBe('EACCES'))
|
||||
expect(result.current.data).toEqual([])
|
||||
})
|
||||
|
||||
it('lazy-loads children on loadChildren and replaces the placeholder', async () => {
|
||||
readDir.mockResolvedValueOnce(ok([{ name: 'src', path: '/p/src', isDirectory: true }]))
|
||||
readDir.mockResolvedValueOnce(
|
||||
ok([
|
||||
{ name: 'index.ts', path: '/p/src/index.ts', isDirectory: false },
|
||||
{ name: 'lib', path: '/p/src/lib', isDirectory: true }
|
||||
])
|
||||
)
|
||||
|
||||
const { result } = renderHook(() => useProjectTree('/p'))
|
||||
|
||||
await waitFor(() => expect(result.current.data.length).toBe(1))
|
||||
|
||||
await act(async () => {
|
||||
await result.current.loadChildren('/p/src')
|
||||
})
|
||||
|
||||
const src = result.current.data[0]
|
||||
expect(src.children?.map(n => n.name)).toEqual(['index.ts', 'lib'])
|
||||
expect(src.loading).toBe(false)
|
||||
expect(src.error).toBeUndefined()
|
||||
})
|
||||
|
||||
it('keeps loaded tree state across remounts for the same cwd', async () => {
|
||||
readDir.mockResolvedValueOnce(ok([{ name: 'src', path: '/p/src', isDirectory: true }]))
|
||||
|
||||
const { result, unmount } = renderHook(() => useProjectTree('/p'))
|
||||
|
||||
await waitFor(() => expect(result.current.data.length).toBe(1))
|
||||
|
||||
act(() => {
|
||||
result.current.setNodeOpen('/p/src', true)
|
||||
})
|
||||
|
||||
unmount()
|
||||
|
||||
const remounted = renderHook(() => useProjectTree('/p'))
|
||||
|
||||
expect(remounted.result.current.data.map(n => n.name)).toEqual(['src'])
|
||||
expect(remounted.result.current.openState).toEqual({ '/p/src': true })
|
||||
expect(readDir).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('captures per-folder error code and leaves the folder expandable but empty', async () => {
|
||||
readDir.mockResolvedValueOnce(ok([{ name: 'priv', path: '/p/priv', isDirectory: true }]))
|
||||
readDir.mockResolvedValueOnce({ entries: [], error: 'EACCES' })
|
||||
|
||||
const { result } = renderHook(() => useProjectTree('/p'))
|
||||
|
||||
await waitFor(() => expect(result.current.data.length).toBe(1))
|
||||
|
||||
await act(async () => {
|
||||
await result.current.loadChildren('/p/priv')
|
||||
})
|
||||
|
||||
expect(result.current.data[0].error).toBe('EACCES')
|
||||
expect(result.current.data[0].children).toEqual([])
|
||||
})
|
||||
|
||||
it('dedupes concurrent loadChildren calls for the same id', async () => {
|
||||
readDir.mockResolvedValueOnce(ok([{ name: 'src', path: '/p/src', isDirectory: true }]))
|
||||
|
||||
let resolveChildren: ((value: HermesReadDirResult) => void) | undefined
|
||||
readDir.mockImplementationOnce(
|
||||
() =>
|
||||
new Promise<HermesReadDirResult>(resolve => {
|
||||
resolveChildren = resolve
|
||||
})
|
||||
)
|
||||
|
||||
const { result } = renderHook(() => useProjectTree('/p'))
|
||||
|
||||
await waitFor(() => expect(result.current.data.length).toBe(1))
|
||||
|
||||
await act(async () => {
|
||||
// First call enters inflight, second short-circuits, third also short-circuits.
|
||||
void result.current.loadChildren('/p/src')
|
||||
void result.current.loadChildren('/p/src')
|
||||
void result.current.loadChildren('/p/src')
|
||||
resolveChildren?.(ok([{ name: 'a.ts', path: '/p/src/a.ts', isDirectory: false }]))
|
||||
})
|
||||
|
||||
// Mount load + a single folder fetch — duplicates were dropped.
|
||||
expect(readDir).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('refreshRoot reloads the root and clears prior error', async () => {
|
||||
readDir.mockResolvedValueOnce({ entries: [], error: 'EACCES' })
|
||||
readDir.mockResolvedValueOnce(ok([{ name: 'README.md', path: '/p/README.md', isDirectory: false }]))
|
||||
|
||||
const { result } = renderHook(() => useProjectTree('/p'))
|
||||
|
||||
await waitFor(() => expect(result.current.rootError).toBe('EACCES'))
|
||||
|
||||
await act(async () => {
|
||||
await result.current.refreshRoot()
|
||||
})
|
||||
|
||||
expect(result.current.rootError).toBeNull()
|
||||
expect(result.current.data.map(n => n.name)).toEqual(['README.md'])
|
||||
})
|
||||
|
||||
it('reloads when cwd changes', async () => {
|
||||
readDir.mockResolvedValueOnce(ok([{ name: 'one', path: '/a/one', isDirectory: false }]))
|
||||
readDir.mockResolvedValueOnce(ok([{ name: 'two', path: '/b/two', isDirectory: false }]))
|
||||
|
||||
const { rerender, result } = renderHook(({ cwd }) => useProjectTree(cwd), { initialProps: { cwd: '/a' } })
|
||||
|
||||
await waitFor(() => expect(result.current.data[0]?.name).toBe('one'))
|
||||
|
||||
rerender({ cwd: '/b' })
|
||||
|
||||
await waitFor(() => expect(result.current.data[0]?.name).toBe('two'))
|
||||
expect(readDir).toHaveBeenLastCalledWith('/b')
|
||||
})
|
||||
|
||||
it('returns no-bridge gracefully when window.hermesDesktop is missing', async () => {
|
||||
delete (window as unknown as { hermesDesktop?: unknown }).hermesDesktop
|
||||
|
||||
const { result } = renderHook(() => useProjectTree('/p'))
|
||||
|
||||
await waitFor(() => expect(result.current.rootError).toBe('no-bridge'))
|
||||
expect(result.current.data).toEqual([])
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,221 @@
|
||||
import { useStore } from '@nanostores/react'
|
||||
import { atom } from 'nanostores'
|
||||
import { useCallback, useEffect, useMemo } from 'react'
|
||||
|
||||
import { clearProjectDirCache, readProjectDir } from './ipc'
|
||||
|
||||
export interface TreeNode {
|
||||
/** Absolute filesystem path. Doubles as react-arborist node id. */
|
||||
id: string
|
||||
name: string
|
||||
/** Drives arborist's leaf-vs-expandable decision via childrenAccessor. */
|
||||
isDirectory: boolean
|
||||
/** `undefined` = directory, children not yet loaded. `[]` = loaded empty. */
|
||||
children?: TreeNode[]
|
||||
/** True while a readDir for this folder is in flight. */
|
||||
loading?: boolean
|
||||
/** Last error code from readDir (e.g. EACCES). Cleared on next successful load. */
|
||||
error?: string
|
||||
}
|
||||
|
||||
const PLACEHOLDER_ID = '__loading__'
|
||||
|
||||
function makeNode(path: string, name: string, isDirectory: boolean): TreeNode {
|
||||
return { id: path, isDirectory, name }
|
||||
}
|
||||
|
||||
function patchNode(nodes: TreeNode[] | undefined | null, id: string, patch: (n: TreeNode) => TreeNode): TreeNode[] {
|
||||
if (!nodes) {return []}
|
||||
|
||||
return nodes.map(n => {
|
||||
if (n.id === id) {return patch(n)}
|
||||
|
||||
if (n.children && n.children.length > 0) {
|
||||
return { ...n, children: patchNode(n.children, id, patch) }
|
||||
}
|
||||
|
||||
return n
|
||||
})
|
||||
}
|
||||
|
||||
function placeholderChild(parentId: string): TreeNode {
|
||||
return { id: `${parentId}::${PLACEHOLDER_ID}`, isDirectory: false, name: 'Loading…' }
|
||||
}
|
||||
|
||||
export interface UseProjectTreeResult {
|
||||
data: TreeNode[]
|
||||
openState: Record<string, boolean>
|
||||
rootError: string | null
|
||||
rootLoading: boolean
|
||||
loadChildren: (id: string) => Promise<void>
|
||||
refreshRoot: () => Promise<void>
|
||||
setNodeOpen: (id: string, open: boolean) => void
|
||||
}
|
||||
|
||||
interface ProjectTreeState {
|
||||
cwd: string
|
||||
data: TreeNode[]
|
||||
loaded: boolean
|
||||
openState: Record<string, boolean>
|
||||
requestId: number
|
||||
rootError: string | null
|
||||
rootLoading: boolean
|
||||
}
|
||||
|
||||
const initialState: ProjectTreeState = {
|
||||
cwd: '',
|
||||
data: [],
|
||||
loaded: false,
|
||||
openState: {},
|
||||
requestId: 0,
|
||||
rootError: null,
|
||||
rootLoading: false
|
||||
}
|
||||
|
||||
const inflight = new Set<string>()
|
||||
const $projectTree = atom<ProjectTreeState>(initialState)
|
||||
let nextRootRequestId = 0
|
||||
|
||||
function setProjectTree(updater: (current: ProjectTreeState) => ProjectTreeState) {
|
||||
$projectTree.set(updater($projectTree.get()))
|
||||
}
|
||||
|
||||
function clearProjectTree() {
|
||||
nextRootRequestId += 1
|
||||
inflight.clear()
|
||||
$projectTree.set({ ...initialState, requestId: nextRootRequestId })
|
||||
}
|
||||
|
||||
async function loadRoot(cwd: string, { force = false }: { force?: boolean } = {}) {
|
||||
if (!cwd) {
|
||||
clearProjectTree()
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
const current = $projectTree.get()
|
||||
|
||||
if (!force && current.cwd === cwd && (current.loaded || current.rootLoading)) {
|
||||
return
|
||||
}
|
||||
|
||||
const requestId = nextRootRequestId + 1
|
||||
nextRootRequestId = requestId
|
||||
inflight.clear()
|
||||
|
||||
if (force || current.cwd !== cwd) {
|
||||
clearProjectDirCache(cwd)
|
||||
}
|
||||
|
||||
$projectTree.set({
|
||||
cwd,
|
||||
data: [],
|
||||
loaded: false,
|
||||
openState: current.cwd === cwd ? current.openState : {},
|
||||
requestId,
|
||||
rootError: null,
|
||||
rootLoading: true
|
||||
})
|
||||
|
||||
const { entries, error } = await readProjectDir(cwd, cwd)
|
||||
|
||||
setProjectTree(latest => {
|
||||
if (latest.cwd !== cwd || latest.requestId !== requestId) {
|
||||
return latest
|
||||
}
|
||||
|
||||
return {
|
||||
...latest,
|
||||
data: error ? [] : entries.map(e => makeNode(e.path, e.name, e.isDirectory)),
|
||||
loaded: true,
|
||||
rootError: error || null,
|
||||
rootLoading: false
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
export function resetProjectTreeState() {
|
||||
clearProjectTree()
|
||||
clearProjectDirCache()
|
||||
}
|
||||
|
||||
/**
|
||||
* Lazy-loads a directory tree rooted at `cwd`. Children are fetched on first
|
||||
* expand and cached in this feature-owned atom so unrelated chat rerenders or
|
||||
* remounts cannot reset the browser. A placeholder leaf renders so the
|
||||
* disclosure caret shows for unloaded folders. `refreshRoot` invalidates the
|
||||
* whole tree (used after cwd change or manual refresh).
|
||||
*/
|
||||
export function useProjectTree(cwd: string): UseProjectTreeResult {
|
||||
const state = useStore($projectTree)
|
||||
|
||||
const refreshRoot = useCallback(() => loadRoot(cwd, { force: true }), [cwd])
|
||||
|
||||
const setNodeOpen = useCallback(
|
||||
(id: string, open: boolean) => {
|
||||
setProjectTree(current => {
|
||||
if (current.cwd !== cwd || current.openState[id] === open) {
|
||||
return current
|
||||
}
|
||||
|
||||
return {
|
||||
...current,
|
||||
openState: {
|
||||
...current.openState,
|
||||
[id]: open
|
||||
}
|
||||
}
|
||||
})
|
||||
},
|
||||
[cwd]
|
||||
)
|
||||
|
||||
const loadChildren = useCallback(async (id: string) => {
|
||||
if (!cwd || inflight.has(id)) {return}
|
||||
inflight.add(id)
|
||||
|
||||
setProjectTree(current => {
|
||||
if (current.cwd !== cwd) {return current}
|
||||
|
||||
return {
|
||||
...current,
|
||||
data: patchNode(current.data, id, n => ({ ...n, loading: true, children: [placeholderChild(n.id)] }))
|
||||
}
|
||||
})
|
||||
|
||||
const { entries, error } = await readProjectDir(id, cwd)
|
||||
|
||||
inflight.delete(id)
|
||||
|
||||
setProjectTree(current => {
|
||||
if (current.cwd !== cwd) {return current}
|
||||
|
||||
return {
|
||||
...current,
|
||||
data: patchNode(current.data, id, n => ({
|
||||
...n,
|
||||
loading: false,
|
||||
error: error || undefined,
|
||||
children: error ? [] : entries.map(e => makeNode(e.path, e.name, e.isDirectory))
|
||||
}))
|
||||
}
|
||||
})
|
||||
}, [cwd])
|
||||
|
||||
useEffect(() => {
|
||||
void loadRoot(cwd)
|
||||
}, [cwd])
|
||||
|
||||
return useMemo(
|
||||
() => ({
|
||||
data: state.cwd === cwd ? state.data : [],
|
||||
loadChildren,
|
||||
openState: state.cwd === cwd ? state.openState : {},
|
||||
refreshRoot,
|
||||
rootError: state.cwd === cwd ? state.rootError : null,
|
||||
rootLoading: state.cwd === cwd ? state.rootLoading : false,
|
||||
setNodeOpen
|
||||
}),
|
||||
[cwd, loadChildren, refreshRoot, setNodeOpen, state.cwd, state.data, state.openState, state.rootError, state.rootLoading]
|
||||
)
|
||||
}
|
||||
@@ -30,10 +30,10 @@ export function OverlaySearchInput({
|
||||
|
||||
return (
|
||||
<div className={cn('relative', containerClassName)}>
|
||||
<Search className="pointer-events-none absolute left-3 top-1/2 size-3.5 -translate-y-1/2 text-muted-foreground/80" />
|
||||
<Search className="pointer-events-none absolute left-3 top-1/2 z-1 size-3.5 -translate-y-1/2 text-muted-foreground/80" />
|
||||
<Input
|
||||
className={cn(
|
||||
'h-8.5 rounded-full border border-[color-mix(in_srgb,var(--dt-border)_60%,transparent)] bg-[color-mix(in_srgb,var(--dt-card)_85%,transparent)] py-2 pl-8 pr-12 text-sm shadow-[inset_0_0.0625rem_0_color-mix(in_srgb,white_38%,transparent)] focus-visible:border-[color-mix(in_srgb,var(--dt-ring)_70%,transparent)] focus-visible:bg-background',
|
||||
'relative z-0 h-8.5 rounded-full border border-[color-mix(in_srgb,var(--dt-border)_60%,transparent)] bg-[color-mix(in_srgb,var(--dt-card)_85%,transparent)] py-2 pl-8 pr-12 text-sm shadow-[inset_0_0.0625rem_0_color-mix(in_srgb,white_38%,transparent)] focus-visible:border-[color-mix(in_srgb,var(--dt-ring)_70%,transparent)] focus-visible:bg-background dark:border-[color-mix(in_srgb,var(--dt-border)_48%,transparent)] dark:bg-[color-mix(in_srgb,var(--dt-card)_96%,var(--dt-background))] dark:shadow-[inset_0_0.0625rem_0_color-mix(in_srgb,white_10%,transparent)]',
|
||||
inputClassName
|
||||
)}
|
||||
onChange={event => onChange(event.target.value)}
|
||||
@@ -42,11 +42,11 @@ export function OverlaySearchInput({
|
||||
value={value}
|
||||
/>
|
||||
{loading ? (
|
||||
<Loader2 className="pointer-events-none absolute right-3 top-1/2 size-3.5 -translate-y-1/2 animate-spin text-muted-foreground/70" />
|
||||
<Loader2 className="pointer-events-none absolute right-3 top-1/2 z-1 size-3.5 -translate-y-1/2 animate-spin text-muted-foreground/70" />
|
||||
) : value ? (
|
||||
<Button
|
||||
aria-label="Clear search"
|
||||
className="absolute right-1.5 top-1/2 -translate-y-1/2 text-muted-foreground/85 hover:bg-accent/60 hover:text-foreground"
|
||||
className="absolute right-1.5 top-1/2 z-1 -translate-y-1/2 text-muted-foreground/85 hover:bg-accent/60 hover:text-foreground"
|
||||
onClick={clear}
|
||||
size="icon-xs"
|
||||
variant="ghost"
|
||||
|
||||
@@ -30,7 +30,7 @@ export function OverlaySplitLayout({ children, className }: OverlaySplitLayoutPr
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'grid h-full min-h-0 flex-1 grid-cols-[13rem_minmax(0,1fr)] overflow-hidden rounded-[0.95rem] border border-[color-mix(in_srgb,var(--dt-border)_58%,transparent)] bg-[color-mix(in_srgb,var(--dt-card)_94%,transparent)] shadow-[inset_0_0.0625rem_0_color-mix(in_srgb,white_46%,transparent),0_0.5rem_1.5rem_-1rem_color-mix(in_srgb,#000_22%,transparent)] max-[760px]:grid-cols-1',
|
||||
'grid h-full min-h-0 flex-1 grid-cols-[13rem_minmax(0,1fr)] overflow-hidden rounded-[0.95rem] border border-[color-mix(in_srgb,var(--dt-border)_58%,transparent)] bg-[color-mix(in_srgb,var(--dt-card)_94%,transparent)] shadow-[inset_0_0.0625rem_0_color-mix(in_srgb,white_46%,transparent),0_0.5rem_1.5rem_-1rem_color-mix(in_srgb,#000_22%,transparent)] max-[760px]:grid-cols-1 dark:border-[color-mix(in_srgb,var(--dt-border)_36%,transparent)] dark:shadow-[inset_0_0.0625rem_0_color-mix(in_srgb,white_10%,transparent),0_0.5rem_1.5rem_-1rem_color-mix(in_srgb,#000_45%,transparent)]',
|
||||
className
|
||||
)}
|
||||
>
|
||||
|
||||
@@ -4,10 +4,11 @@ export const SETTINGS_ROUTE = '/settings'
|
||||
export const COMMAND_CENTER_ROUTE = '/command-center'
|
||||
export const SKILLS_ROUTE = '/skills'
|
||||
export const ARTIFACTS_ROUTE = '/artifacts'
|
||||
export const AGENTS_ROUTE = '/agents'
|
||||
|
||||
export type AppView = 'chat' | 'settings' | 'command-center' | 'skills' | 'artifacts'
|
||||
export type AppView = 'chat' | 'settings' | 'command-center' | 'skills' | 'artifacts' | 'agents'
|
||||
|
||||
export type AppRouteId = 'new' | 'settings' | 'command-center' | 'skills' | 'artifacts'
|
||||
export type AppRouteId = 'new' | 'settings' | 'command-center' | 'skills' | 'artifacts' | 'agents'
|
||||
|
||||
export interface AppRoute {
|
||||
id: AppRouteId
|
||||
@@ -20,7 +21,8 @@ export const APP_ROUTES = [
|
||||
{ id: 'settings', path: SETTINGS_ROUTE, view: 'settings' },
|
||||
{ id: 'command-center', path: COMMAND_CENTER_ROUTE, view: 'command-center' },
|
||||
{ id: 'skills', path: SKILLS_ROUTE, view: 'skills' },
|
||||
{ id: 'artifacts', path: ARTIFACTS_ROUTE, view: 'artifacts' }
|
||||
{ id: 'artifacts', path: ARTIFACTS_ROUTE, view: 'artifacts' },
|
||||
{ id: 'agents', path: AGENTS_ROUTE, view: 'agents' }
|
||||
] as const satisfies readonly AppRoute[]
|
||||
|
||||
const APP_VIEW_BY_PATH = new Map<string, AppView>(APP_ROUTES.map(route => [route.path, route.view]))
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
import { type MutableRefObject, useCallback, useEffect } from 'react'
|
||||
|
||||
import { $currentCwd, setContextSuggestions } from '@/store/session'
|
||||
|
||||
import type { ContextSuggestion } from '../../types'
|
||||
|
||||
interface ContextSuggestionsOptions {
|
||||
activeSessionId: string | null
|
||||
activeSessionIdRef: MutableRefObject<string | null>
|
||||
currentCwd: string
|
||||
gatewayState: string | undefined
|
||||
requestGateway: <T = unknown>(method: string, params?: Record<string, unknown>) => Promise<T>
|
||||
}
|
||||
|
||||
export function useContextSuggestions({
|
||||
activeSessionId,
|
||||
activeSessionIdRef,
|
||||
currentCwd,
|
||||
gatewayState,
|
||||
requestGateway
|
||||
}: ContextSuggestionsOptions) {
|
||||
const refresh = useCallback(async () => {
|
||||
if (!activeSessionId) {
|
||||
setContextSuggestions([])
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
const sessionId = activeSessionId
|
||||
const cwd = currentCwd || ''
|
||||
|
||||
// Race guard: only commit if the session+cwd we sent for still match
|
||||
// by the time the gateway responds.
|
||||
const stillCurrent = () => activeSessionIdRef.current === sessionId && $currentCwd.get() === cwd
|
||||
|
||||
try {
|
||||
const result = await requestGateway<{ items?: ContextSuggestion[] }>('complete.path', {
|
||||
session_id: sessionId,
|
||||
word: '@file:',
|
||||
cwd: cwd || undefined
|
||||
})
|
||||
|
||||
if (stillCurrent()) {setContextSuggestions((result.items || []).filter(i => i.text))}
|
||||
} catch {
|
||||
if (stillCurrent()) {setContextSuggestions([])}
|
||||
}
|
||||
}, [activeSessionId, activeSessionIdRef, currentCwd, requestGateway])
|
||||
|
||||
useEffect(() => {
|
||||
if (gatewayState === 'open' && activeSessionId) {void refresh()}
|
||||
}, [activeSessionId, gatewayState, refresh])
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
import { type MutableRefObject, useCallback } from 'react'
|
||||
|
||||
import { notify, notifyError } from '@/store/notifications'
|
||||
import { $currentCwd, setCurrentBranch, setCurrentCwd } from '@/store/session'
|
||||
import type { SessionRuntimeInfo } from '@/types/hermes'
|
||||
|
||||
interface CwdActionsOptions {
|
||||
activeSessionId: string | null
|
||||
activeSessionIdRef: MutableRefObject<string | null>
|
||||
currentCwd: string
|
||||
requestGateway: <T = unknown>(method: string, params?: Record<string, unknown>) => Promise<T>
|
||||
}
|
||||
|
||||
export function useCwdActions({ activeSessionId, activeSessionIdRef, currentCwd, requestGateway }: CwdActionsOptions) {
|
||||
const refreshProjectBranch = useCallback(
|
||||
async (cwd: string) => {
|
||||
const target = cwd.trim()
|
||||
|
||||
if (!target || activeSessionIdRef.current) {return}
|
||||
|
||||
try {
|
||||
const info = await requestGateway<{ branch?: string; cwd?: string }>('config.get', { key: 'project', cwd: target })
|
||||
|
||||
if (!activeSessionIdRef.current && ($currentCwd.get() || target) === (info.cwd || target)) {
|
||||
setCurrentBranch(info.branch || '')
|
||||
}
|
||||
} catch {
|
||||
setCurrentBranch('')
|
||||
}
|
||||
},
|
||||
[activeSessionIdRef, requestGateway]
|
||||
)
|
||||
|
||||
const changeSessionCwd = useCallback(
|
||||
async (cwd: string) => {
|
||||
const trimmed = cwd.trim()
|
||||
|
||||
if (!trimmed) {return}
|
||||
|
||||
const persistGlobal = async () => {
|
||||
const info = await requestGateway<{ branch?: string; cwd?: string; value?: string }>('config.set', {
|
||||
...(activeSessionId && { session_id: activeSessionId }),
|
||||
key: 'terminal.cwd',
|
||||
value: trimmed
|
||||
})
|
||||
|
||||
setCurrentCwd(info.cwd || info.value || trimmed)
|
||||
|
||||
if (!activeSessionId) {setCurrentBranch(info.branch || '')}
|
||||
}
|
||||
|
||||
if (!activeSessionId) {
|
||||
try {
|
||||
await persistGlobal()
|
||||
} catch (err) {
|
||||
notifyError(err, 'Working directory change failed')
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const info = await requestGateway<SessionRuntimeInfo>('session.cwd.set', {
|
||||
session_id: activeSessionId,
|
||||
cwd: trimmed
|
||||
})
|
||||
|
||||
setCurrentCwd(info.cwd || trimmed)
|
||||
setCurrentBranch(info.branch || '')
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err)
|
||||
|
||||
// Older gateways without `session.cwd.set` fall back to a global write —
|
||||
// user has to restart the active session for it to take effect.
|
||||
if (!message.includes('unknown method')) {
|
||||
notifyError(err, 'Working directory change failed')
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
await persistGlobal()
|
||||
notify({
|
||||
kind: 'warning',
|
||||
title: 'Working directory saved',
|
||||
message: 'Restart the desktop backend to apply cwd changes to this active session.'
|
||||
})
|
||||
} catch (fallbackErr) {
|
||||
notifyError(fallbackErr, 'Working directory change failed')
|
||||
}
|
||||
}
|
||||
},
|
||||
[activeSessionId, requestGateway]
|
||||
)
|
||||
|
||||
const browseSessionCwd = useCallback(async () => {
|
||||
const paths = await window.hermesDesktop?.selectPaths({
|
||||
title: 'Change working directory',
|
||||
defaultPath: currentCwd || undefined,
|
||||
directories: true,
|
||||
multiple: false
|
||||
})
|
||||
|
||||
if (paths?.[0]) {await changeSessionCwd(paths[0])}
|
||||
}, [changeSessionCwd, currentCwd])
|
||||
|
||||
return { browseSessionCwd, changeSessionCwd, refreshProjectBranch }
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
import { type MutableRefObject, useCallback, useState } from 'react'
|
||||
|
||||
import { getHermesConfig, getHermesConfigDefaults } from '@/hermes'
|
||||
import { BUILTIN_PERSONALITIES, normalizePersonalityValue, personalityNamesFromConfig } from '@/lib/chat-runtime'
|
||||
import {
|
||||
$currentCwd,
|
||||
setAvailablePersonalities,
|
||||
setCurrentCwd,
|
||||
setCurrentFastMode,
|
||||
setCurrentPersonality,
|
||||
setCurrentReasoningEffort,
|
||||
setCurrentServiceTier,
|
||||
setIntroPersonality
|
||||
} from '@/store/session'
|
||||
|
||||
const DEFAULT_VOICE_SECONDS = 120
|
||||
const FAST_TIERS = new Set(['fast', 'priority', 'on'])
|
||||
|
||||
function recordingLimit(value: unknown) {
|
||||
return typeof value === 'number' && Number.isFinite(value) && value > 0 ? value : DEFAULT_VOICE_SECONDS
|
||||
}
|
||||
|
||||
interface HermesConfigOptions {
|
||||
activeSessionIdRef: MutableRefObject<string | null>
|
||||
refreshProjectBranch: (cwd: string) => Promise<void>
|
||||
}
|
||||
|
||||
export function useHermesConfig({ activeSessionIdRef, refreshProjectBranch }: HermesConfigOptions) {
|
||||
const [voiceMaxRecordingSeconds, setVoiceMaxRecordingSeconds] = useState(DEFAULT_VOICE_SECONDS)
|
||||
const [sttEnabled, setSttEnabled] = useState(true)
|
||||
|
||||
const refreshHermesConfig = useCallback(async () => {
|
||||
try {
|
||||
const [config, defaults] = await Promise.all([getHermesConfig(), getHermesConfigDefaults().catch(() => ({}))])
|
||||
|
||||
const personality = normalizePersonalityValue(
|
||||
typeof config.display?.personality === 'string' ? config.display.personality : ''
|
||||
)
|
||||
|
||||
setIntroPersonality(personality)
|
||||
// Active sessions keep their per-session value; standalone falls back to config.
|
||||
setCurrentPersonality(prev => (activeSessionIdRef.current ? prev || personality : personality))
|
||||
setAvailablePersonalities([
|
||||
...new Set([
|
||||
'none',
|
||||
...BUILTIN_PERSONALITIES,
|
||||
...personalityNamesFromConfig(defaults),
|
||||
...personalityNamesFromConfig(config)
|
||||
])
|
||||
])
|
||||
|
||||
const cwd = (config.terminal?.cwd ?? '').trim()
|
||||
|
||||
if (cwd && cwd !== '.') {
|
||||
setCurrentCwd(prev => prev || cwd)
|
||||
void refreshProjectBranch($currentCwd.get() || cwd)
|
||||
}
|
||||
|
||||
const reasoning = (config.agent?.reasoning_effort ?? '').trim()
|
||||
const tier = (config.agent?.service_tier ?? '').trim()
|
||||
|
||||
setCurrentReasoningEffort(prev => (activeSessionIdRef.current ? prev : reasoning))
|
||||
setCurrentServiceTier(prev => (activeSessionIdRef.current ? prev : tier))
|
||||
setCurrentFastMode(prev => (activeSessionIdRef.current ? prev : FAST_TIERS.has(tier.toLowerCase())))
|
||||
|
||||
setVoiceMaxRecordingSeconds(recordingLimit(config.voice?.max_recording_seconds))
|
||||
setSttEnabled(config.stt?.enabled !== false)
|
||||
} catch {
|
||||
// Config is nice-to-have; chat still works without it.
|
||||
}
|
||||
}, [activeSessionIdRef, refreshProjectBranch])
|
||||
|
||||
return { refreshHermesConfig, sttEnabled, voiceMaxRecordingSeconds }
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
import { type QueryClient } from '@tanstack/react-query'
|
||||
import { useCallback } from 'react'
|
||||
|
||||
import { getGlobalModelInfo, setGlobalModel } from '@/hermes'
|
||||
import { notifyError } from '@/store/notifications'
|
||||
import { setCurrentModel, setCurrentProvider } from '@/store/session'
|
||||
import type { ModelOptionsResponse } from '@/types/hermes'
|
||||
|
||||
interface ModelSelection {
|
||||
model: string
|
||||
persistGlobal: boolean
|
||||
provider: string
|
||||
}
|
||||
|
||||
interface ModelControlsOptions {
|
||||
activeSessionId: string | null
|
||||
queryClient: QueryClient
|
||||
requestGateway: <T = unknown>(method: string, params?: Record<string, unknown>) => Promise<T>
|
||||
}
|
||||
|
||||
export function useModelControls({ activeSessionId, queryClient, requestGateway }: ModelControlsOptions) {
|
||||
const updateModelOptionsCache = useCallback(
|
||||
(provider: string, model: string, includeGlobal: boolean) => {
|
||||
const patch = (prev: ModelOptionsResponse | undefined) => ({ ...(prev ?? {}), provider, model })
|
||||
|
||||
queryClient.setQueryData<ModelOptionsResponse>(['model-options', activeSessionId || 'global'], patch)
|
||||
|
||||
if (includeGlobal) {queryClient.setQueryData<ModelOptionsResponse>(['model-options', 'global'], patch)}
|
||||
},
|
||||
[activeSessionId, queryClient]
|
||||
)
|
||||
|
||||
const refreshCurrentModel = useCallback(async () => {
|
||||
try {
|
||||
const result = await getGlobalModelInfo()
|
||||
|
||||
if (typeof result.model === 'string') {setCurrentModel(result.model)}
|
||||
|
||||
if (typeof result.provider === 'string') {setCurrentProvider(result.provider)}
|
||||
} catch {
|
||||
// The delayed session.info event still updates this once the agent is ready.
|
||||
}
|
||||
}, [])
|
||||
|
||||
const selectModel = useCallback(
|
||||
(selection: ModelSelection) => {
|
||||
setCurrentModel(selection.model)
|
||||
setCurrentProvider(selection.provider)
|
||||
updateModelOptionsCache(selection.provider, selection.model, selection.persistGlobal || !activeSessionId)
|
||||
|
||||
void (async () => {
|
||||
try {
|
||||
if (activeSessionId) {
|
||||
await requestGateway('slash.exec', {
|
||||
session_id: activeSessionId,
|
||||
command: `/model ${selection.model} --provider ${selection.provider}${selection.persistGlobal ? ' --global' : ''}`
|
||||
})
|
||||
|
||||
if (selection.persistGlobal) {void refreshCurrentModel()}
|
||||
void queryClient.invalidateQueries({
|
||||
queryKey: selection.persistGlobal ? ['model-options'] : ['model-options', activeSessionId]
|
||||
})
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
await setGlobalModel(selection.provider, selection.model)
|
||||
void refreshCurrentModel()
|
||||
void queryClient.invalidateQueries({ queryKey: ['model-options'] })
|
||||
} catch (err) {
|
||||
notifyError(err, 'Model switch failed')
|
||||
}
|
||||
})()
|
||||
},
|
||||
[activeSessionId, queryClient, refreshCurrentModel, requestGateway, updateModelOptionsCache]
|
||||
)
|
||||
|
||||
return { refreshCurrentModel, selectModel, updateModelOptionsCache }
|
||||
}
|
||||
@@ -0,0 +1,144 @@
|
||||
import { act, cleanup, render, waitFor } from '@testing-library/react'
|
||||
import { useEffect, useRef } from 'react'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { assistantTextPart, type ChatMessage } from '@/lib/chat-messages'
|
||||
import {
|
||||
$previewTarget,
|
||||
clearSessionPreviewRegistry,
|
||||
type PreviewTarget,
|
||||
registerSessionPreview
|
||||
} from '@/store/preview'
|
||||
import { $currentCwd, $messages } from '@/store/session'
|
||||
import type { RpcEvent } from '@/types/hermes'
|
||||
|
||||
import { usePreviewRouting } from './use-preview-routing'
|
||||
|
||||
function assistantMessage(id: string, text: string): ChatMessage {
|
||||
return {
|
||||
id,
|
||||
parts: [assistantTextPart(text)],
|
||||
role: 'assistant'
|
||||
}
|
||||
}
|
||||
|
||||
function previewTarget(source: string): PreviewTarget {
|
||||
const isUrl = /^https?:\/\//i.test(source)
|
||||
|
||||
return {
|
||||
kind: isUrl ? 'url' : 'file',
|
||||
label: source,
|
||||
path: isUrl ? undefined : source,
|
||||
previewKind: isUrl ? undefined : 'html',
|
||||
source,
|
||||
url: isUrl ? source : `file://${source}`
|
||||
}
|
||||
}
|
||||
|
||||
let handleEvent: (event: RpcEvent) => void = () => undefined
|
||||
|
||||
function PreviewRoutingHarness({ onEvent }: { onEvent: (handler: (event: RpcEvent) => void) => void }) {
|
||||
const activeSessionIdRef = useRef<string | null>('session-1')
|
||||
|
||||
const routing = usePreviewRouting({
|
||||
activeSessionIdRef,
|
||||
baseHandleGatewayEvent: vi.fn(),
|
||||
currentCwd: '/work',
|
||||
currentView: 'chat',
|
||||
requestGateway: vi.fn(),
|
||||
routedSessionId: 'session-1',
|
||||
selectedStoredSessionId: null
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
onEvent(routing.handleDesktopGatewayEvent)
|
||||
}, [onEvent, routing.handleDesktopGatewayEvent])
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
describe('usePreviewRouting', () => {
|
||||
beforeEach(() => {
|
||||
$currentCwd.set('/work')
|
||||
$messages.set([])
|
||||
$previewTarget.set(null)
|
||||
window.localStorage.clear()
|
||||
clearSessionPreviewRegistry()
|
||||
handleEvent = () => undefined
|
||||
|
||||
Object.defineProperty(window, 'hermesDesktop', {
|
||||
configurable: true,
|
||||
value: {
|
||||
normalizePreviewTarget: vi.fn(async (target: string) => previewTarget(target))
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
cleanup()
|
||||
$messages.set([])
|
||||
$previewTarget.set(null)
|
||||
window.localStorage.clear()
|
||||
clearSessionPreviewRegistry()
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
it('opens the active session preview from the registry', async () => {
|
||||
const target = previewTarget('/work/demo.html')
|
||||
|
||||
registerSessionPreview('session-1', target, 'tool-result')
|
||||
render(<PreviewRoutingHarness onEvent={handler => { handleEvent = handler }} />)
|
||||
|
||||
await waitFor(() => {
|
||||
expect($previewTarget.get()).toEqual({ ...target, renderMode: 'preview' })
|
||||
})
|
||||
})
|
||||
|
||||
it('does not infer previews from assistant prose', async () => {
|
||||
render(<PreviewRoutingHarness onEvent={handler => { handleEvent = handler }} />)
|
||||
|
||||
act(() => {
|
||||
$messages.set([
|
||||
assistantMessage('a1', 'Preview: http://localhost:5173/'),
|
||||
assistantMessage('a2', 'Open /work/demo.html')
|
||||
])
|
||||
})
|
||||
|
||||
expect($previewTarget.get()).toBeNull()
|
||||
expect(window.hermesDesktop.normalizePreviewTarget).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('registers structured tool-result preview targets', async () => {
|
||||
render(<PreviewRoutingHarness onEvent={handler => { handleEvent = handler }} />)
|
||||
|
||||
act(() =>
|
||||
handleEvent({
|
||||
payload: { path: './dist/index.html' },
|
||||
session_id: 'session-1',
|
||||
type: 'tool.complete'
|
||||
})
|
||||
)
|
||||
|
||||
await waitFor(() => {
|
||||
expect($previewTarget.get()?.source).toBe('./dist/index.html')
|
||||
})
|
||||
|
||||
expect(window.localStorage.getItem('hermes.desktop.sessionPreviews.v1')).toContain('./dist/index.html')
|
||||
})
|
||||
|
||||
it('registers html previews from edit inline diffs', async () => {
|
||||
render(<PreviewRoutingHarness onEvent={handler => { handleEvent = handler }} />)
|
||||
|
||||
act(() =>
|
||||
handleEvent({
|
||||
payload: { inline_diff: '\u001b[38;2;218;165;32ma/preview-demo.html -> b/preview-demo.html\u001b[0m\n' },
|
||||
session_id: 'session-1',
|
||||
type: 'tool.complete'
|
||||
})
|
||||
)
|
||||
|
||||
await waitFor(() => {
|
||||
expect($previewTarget.get()?.source).toBe('preview-demo.html')
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,195 @@
|
||||
import { useStore } from '@nanostores/react'
|
||||
import { type MutableRefObject, useCallback, useEffect } from 'react'
|
||||
|
||||
import { gatewayEventCompletedFileDiff } from '@/lib/gateway-events'
|
||||
import {
|
||||
$previewTarget,
|
||||
$sessionPreviewRegistry,
|
||||
beginPreviewServerRestart,
|
||||
completePreviewServerRestart,
|
||||
getSessionPreviewRecord,
|
||||
progressPreviewServerRestart,
|
||||
requestPreviewReload,
|
||||
setPreviewTarget,
|
||||
setSessionPreviewTarget
|
||||
} from '@/store/preview'
|
||||
import { $currentCwd } from '@/store/session'
|
||||
import type { RpcEvent } from '@/types/hermes'
|
||||
|
||||
type EventHandler = (event: RpcEvent) => void
|
||||
|
||||
interface PreviewRoutingOptions {
|
||||
activeSessionIdRef: MutableRefObject<string | null>
|
||||
baseHandleGatewayEvent: EventHandler
|
||||
currentCwd: string
|
||||
currentView: string
|
||||
requestGateway: <T = unknown>(method: string, params?: Record<string, unknown>) => Promise<T>
|
||||
routedSessionId: string | null
|
||||
selectedStoredSessionId: string | null
|
||||
}
|
||||
|
||||
function asRecord(payload: unknown): Record<string, unknown> {
|
||||
return payload && typeof payload === 'object' ? (payload as Record<string, unknown>) : {}
|
||||
}
|
||||
|
||||
function activePreviewSessionId(
|
||||
activeSessionIdRef: MutableRefObject<string | null>,
|
||||
routedSessionId: string | null,
|
||||
selectedStoredSessionId: string | null
|
||||
): string {
|
||||
return selectedStoredSessionId || routedSessionId || activeSessionIdRef.current || ''
|
||||
}
|
||||
|
||||
function looksLikePreviewTarget(value: string): boolean {
|
||||
return /^https?:\/\//i.test(value) || /^file:\/\//i.test(value) || /^(?:\/|\.{1,2}\/|~\/).+/.test(value)
|
||||
}
|
||||
|
||||
function stripAnsi(value: string): string {
|
||||
return value.replace(new RegExp(`${String.fromCharCode(27)}\\[[0-9;]*m`, 'g'), '')
|
||||
}
|
||||
|
||||
function htmlPathFromInlineDiff(value: string): string {
|
||||
const cleaned = stripAnsi(value).replace(/^\s*┊\s*review diff\s*\n/i, '')
|
||||
|
||||
for (const match of cleaned.matchAll(/(?:^|\s)(?:[ab]\/)?([^\s]+\.html?)(?=\s|$)/gi)) {
|
||||
const candidate = match[1]?.trim()
|
||||
|
||||
if (candidate) {
|
||||
return candidate
|
||||
}
|
||||
}
|
||||
|
||||
return ''
|
||||
}
|
||||
|
||||
function structuredPreviewCandidate(payload: unknown): string {
|
||||
const record = asRecord(payload)
|
||||
const fields = ['url', 'target', 'path', 'file', 'filepath', 'preview']
|
||||
|
||||
for (const field of fields) {
|
||||
const value = record[field]
|
||||
|
||||
if (typeof value === 'string') {
|
||||
const target = value.trim()
|
||||
|
||||
if (target && looksLikePreviewTarget(target)) {
|
||||
return target
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const inlineDiff = record.inline_diff
|
||||
|
||||
if (typeof inlineDiff === 'string') {
|
||||
return htmlPathFromInlineDiff(inlineDiff)
|
||||
}
|
||||
|
||||
return ''
|
||||
}
|
||||
|
||||
export function usePreviewRouting({
|
||||
activeSessionIdRef,
|
||||
baseHandleGatewayEvent,
|
||||
currentCwd,
|
||||
currentView,
|
||||
requestGateway,
|
||||
routedSessionId,
|
||||
selectedStoredSessionId
|
||||
}: PreviewRoutingOptions) {
|
||||
const previewRegistry = useStore($sessionPreviewRegistry)
|
||||
const previewSessionId = activePreviewSessionId(activeSessionIdRef, routedSessionId, selectedStoredSessionId)
|
||||
|
||||
useEffect(() => {
|
||||
if (currentView !== 'chat' || !previewSessionId) {
|
||||
setPreviewTarget(null)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
const record = getSessionPreviewRecord(previewSessionId)
|
||||
|
||||
setPreviewTarget(record?.normalized ?? null)
|
||||
}, [currentView, previewRegistry, previewSessionId])
|
||||
|
||||
const registerStructuredPreview = useCallback(
|
||||
async (event: RpcEvent) => {
|
||||
if (event.session_id && event.session_id !== activeSessionIdRef.current && event.session_id !== previewSessionId) {return}
|
||||
|
||||
if (!event.type.startsWith('tool.')) {return}
|
||||
|
||||
if (!previewSessionId) {return}
|
||||
|
||||
const candidate = structuredPreviewCandidate(event.payload)
|
||||
|
||||
if (!candidate) {return}
|
||||
const desktop = window.hermesDesktop
|
||||
|
||||
if (!desktop?.normalizePreviewTarget) {return}
|
||||
const sessionId = previewSessionId
|
||||
const cwd = currentCwd || ''
|
||||
const target = await desktop.normalizePreviewTarget(candidate, cwd || undefined).catch(() => null)
|
||||
|
||||
if (
|
||||
!target ||
|
||||
sessionId !== activePreviewSessionId(activeSessionIdRef, routedSessionId, selectedStoredSessionId) ||
|
||||
$currentCwd.get() !== cwd
|
||||
) {
|
||||
return
|
||||
}
|
||||
|
||||
setSessionPreviewTarget(sessionId, target, 'tool-result', candidate)
|
||||
},
|
||||
[activeSessionIdRef, currentCwd, previewSessionId, routedSessionId, selectedStoredSessionId]
|
||||
)
|
||||
|
||||
const restartPreviewServer = useCallback(
|
||||
async (url: string, context?: string) => {
|
||||
const sessionId = activeSessionIdRef.current
|
||||
|
||||
if (!sessionId) {throw new Error('No active session for background restart')}
|
||||
|
||||
const cwd = $currentCwd.get() || currentCwd || ''
|
||||
|
||||
const result = await requestGateway<{ task_id?: string }>('preview.restart', {
|
||||
context: context || undefined,
|
||||
cwd: cwd || undefined,
|
||||
session_id: sessionId,
|
||||
url
|
||||
})
|
||||
|
||||
const taskId = result.task_id || ''
|
||||
|
||||
if (!taskId) {throw new Error('Background restart did not return a task id')}
|
||||
|
||||
beginPreviewServerRestart(taskId, url)
|
||||
|
||||
return taskId
|
||||
},
|
||||
[activeSessionIdRef, currentCwd, requestGateway]
|
||||
)
|
||||
|
||||
const handleDesktopGatewayEvent = useCallback<EventHandler>(
|
||||
event => {
|
||||
baseHandleGatewayEvent(event)
|
||||
|
||||
if (event.type === 'preview.restart.complete') {
|
||||
const { task_id, text } = asRecord(event.payload)
|
||||
|
||||
if (typeof task_id === 'string' && task_id) {completePreviewServerRestart(task_id, typeof text === 'string' ? text : '')}
|
||||
} else if (event.type === 'preview.restart.progress') {
|
||||
const { task_id, text } = asRecord(event.payload)
|
||||
|
||||
if (typeof task_id === 'string' && task_id) {progressPreviewServerRestart(task_id, typeof text === 'string' ? text : '')}
|
||||
}
|
||||
|
||||
if (event.session_id && event.session_id !== activeSessionIdRef.current) {return}
|
||||
|
||||
void registerStructuredPreview(event)
|
||||
|
||||
if ($previewTarget.get()?.kind === 'url' && gatewayEventCompletedFileDiff(event)) {requestPreviewReload()}
|
||||
},
|
||||
[activeSessionIdRef, baseHandleGatewayEvent, registerStructuredPreview]
|
||||
)
|
||||
|
||||
return { handleDesktopGatewayEvent, restartPreviewServer }
|
||||
}
|
||||
@@ -188,7 +188,7 @@ export function usePromptActions({
|
||||
.join('\n')
|
||||
|
||||
const hasImageAttachment = attachments.some(attachment => attachment.kind === 'image')
|
||||
const displayRefs = attachments.map(attachmentDisplayText).filter(Boolean).join('\n')
|
||||
const attachmentRefs = attachments.map(attachmentDisplayText).filter((ref): ref is string => Boolean(ref))
|
||||
|
||||
const text =
|
||||
[contextRefs, visibleText].filter(Boolean).join('\n\n') ||
|
||||
@@ -201,12 +201,8 @@ export function usePromptActions({
|
||||
const userMessage: ChatMessage = {
|
||||
id: `user-${Date.now()}`,
|
||||
role: 'user',
|
||||
parts: [
|
||||
textPart(
|
||||
[displayRefs, visibleText].filter(Boolean).join('\n\n') ||
|
||||
attachments.map(attachment => attachment.label).join(', ')
|
||||
)
|
||||
]
|
||||
parts: [textPart(visibleText || (attachmentRefs.length ? '' : attachments.map(a => a.label).join(', ')))],
|
||||
attachmentRefs
|
||||
}
|
||||
|
||||
const releaseBusy = () => {
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
import { type MutableRefObject, useEffect } from 'react'
|
||||
|
||||
import { isNewChatRoute } from '@/app/routes'
|
||||
|
||||
interface RouteResumeOptions {
|
||||
activeSessionId: string | null
|
||||
activeSessionIdRef: MutableRefObject<string | null>
|
||||
creatingSessionRef: MutableRefObject<boolean>
|
||||
currentView: string
|
||||
freshDraftReady: boolean
|
||||
gatewayState: string | undefined
|
||||
locationPathname: string
|
||||
resumeSession: (sessionId: string, focus: boolean) => Promise<unknown>
|
||||
routedSessionId: string | null
|
||||
runtimeIdByStoredSessionIdRef: MutableRefObject<Map<string, string>>
|
||||
selectedStoredSessionId: string | null
|
||||
selectedStoredSessionIdRef: MutableRefObject<string | null>
|
||||
startFreshSessionDraft: (focus: boolean) => unknown
|
||||
}
|
||||
|
||||
// HashRouter boot edge case: pathname briefly reads `/` before the hash is
|
||||
// parsed. If the hash references a real session, defer; resume picks it up
|
||||
// next tick. Without this, ctrl+R on `#/:sessionId` flashes 5 loading states.
|
||||
function rawHashLooksLikeSession(): boolean {
|
||||
if (typeof window === 'undefined') {return false}
|
||||
const hash = window.location.hash.replace(/^#/, '')
|
||||
|
||||
if (!hash || hash === '/') {return false}
|
||||
|
||||
return !hash.startsWith('/settings') && !hash.startsWith('/skills') && !hash.startsWith('/artifacts')
|
||||
}
|
||||
|
||||
export function useRouteResume({
|
||||
activeSessionId,
|
||||
activeSessionIdRef,
|
||||
creatingSessionRef,
|
||||
currentView,
|
||||
freshDraftReady,
|
||||
gatewayState,
|
||||
locationPathname,
|
||||
resumeSession,
|
||||
routedSessionId,
|
||||
runtimeIdByStoredSessionIdRef,
|
||||
selectedStoredSessionId,
|
||||
selectedStoredSessionIdRef,
|
||||
startFreshSessionDraft
|
||||
}: RouteResumeOptions) {
|
||||
useEffect(() => {
|
||||
if (currentView !== 'chat' || gatewayState !== 'open') {return}
|
||||
|
||||
if (routedSessionId) {
|
||||
const cachedRuntime = runtimeIdByStoredSessionIdRef.current.get(routedSessionId)
|
||||
|
||||
const alreadyActive =
|
||||
routedSessionId === selectedStoredSessionIdRef.current &&
|
||||
Boolean(cachedRuntime) &&
|
||||
cachedRuntime === activeSessionIdRef.current
|
||||
|
||||
if (!alreadyActive) {void resumeSession(routedSessionId, true)}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
if (
|
||||
isNewChatRoute(locationPathname) &&
|
||||
!creatingSessionRef.current &&
|
||||
(selectedStoredSessionId || activeSessionId || !freshDraftReady) &&
|
||||
!rawHashLooksLikeSession()
|
||||
) {
|
||||
startFreshSessionDraft(true)
|
||||
}
|
||||
}, [
|
||||
activeSessionId,
|
||||
activeSessionIdRef,
|
||||
creatingSessionRef,
|
||||
currentView,
|
||||
freshDraftReady,
|
||||
gatewayState,
|
||||
locationPathname,
|
||||
resumeSession,
|
||||
routedSessionId,
|
||||
runtimeIdByStoredSessionIdRef,
|
||||
selectedStoredSessionId,
|
||||
selectedStoredSessionIdRef,
|
||||
startFreshSessionDraft
|
||||
])
|
||||
}
|
||||
@@ -28,8 +28,8 @@ import {
|
||||
setIntroSeed,
|
||||
setMessages,
|
||||
setSelectedStoredSessionId,
|
||||
setSessionStartedAt,
|
||||
setSessions,
|
||||
setSessionStartedAt,
|
||||
setTurnStartedAt
|
||||
} from '@/store/session'
|
||||
import type { SessionCreateResponse, SessionInfo, SessionResumeResponse, UsageStats } from '@/types/hermes'
|
||||
|
||||
@@ -2,17 +2,18 @@ import { useStore } from '@nanostores/react'
|
||||
import type { CSSProperties, ReactNode, PointerEvent as ReactPointerEvent } from 'react'
|
||||
import { useCallback } from 'react'
|
||||
|
||||
import { PaneShell } from '@/components/pane-shell'
|
||||
import { SidebarProvider } from '@/components/ui/sidebar'
|
||||
import { triggerHaptic } from '@/lib/haptics'
|
||||
import {
|
||||
$inspectorOpen,
|
||||
$fileBrowserOpen,
|
||||
$sidebarOpen,
|
||||
$sidebarWidth,
|
||||
FILE_BROWSER_DEFAULT_WIDTH,
|
||||
setSidebarOpen,
|
||||
setSidebarResizing,
|
||||
setSidebarWidth
|
||||
} from '@/store/layout'
|
||||
import { $previewTarget } from '@/store/preview'
|
||||
import { $connection } from '@/store/session'
|
||||
|
||||
import { StatusbarControls, type StatusbarItem } from './statusbar-controls'
|
||||
@@ -21,41 +22,27 @@ import { TitlebarControls, type TitlebarTool } from './titlebar-controls'
|
||||
|
||||
interface AppShellProps {
|
||||
children: ReactNode
|
||||
inspectorWidth: string
|
||||
leftTitlebarTools?: readonly TitlebarTool[]
|
||||
leftStatusbarItems?: readonly StatusbarItem[]
|
||||
previewWidth: string
|
||||
rightRailOpen: boolean
|
||||
sidebar: ReactNode
|
||||
statusbarItems?: readonly StatusbarItem[]
|
||||
titlebarTools?: readonly TitlebarTool[]
|
||||
leftTitlebarTools?: readonly TitlebarTool[]
|
||||
onOpenSettings: () => void
|
||||
overlays?: ReactNode
|
||||
statusbarItems?: readonly StatusbarItem[]
|
||||
titlebarTools?: readonly TitlebarTool[]
|
||||
}
|
||||
|
||||
export function AppShell({
|
||||
children,
|
||||
inspectorWidth,
|
||||
leftTitlebarTools,
|
||||
leftStatusbarItems,
|
||||
previewWidth,
|
||||
rightRailOpen,
|
||||
sidebar,
|
||||
statusbarItems,
|
||||
titlebarTools,
|
||||
leftTitlebarTools,
|
||||
onOpenSettings,
|
||||
overlays
|
||||
overlays,
|
||||
statusbarItems,
|
||||
titlebarTools
|
||||
}: AppShellProps) {
|
||||
const sidebarWidth = useStore($sidebarWidth)
|
||||
const connection = useStore($connection)
|
||||
const sidebarOpen = useStore($sidebarOpen)
|
||||
const inspectorOpen = useStore($inspectorOpen)
|
||||
const previewTarget = useStore($previewTarget)
|
||||
|
||||
// The shell grid should describe visible app chrome only. Titlebar buttons
|
||||
// and draggable hit-zones are fixed overlays, so keeping an invisible grid
|
||||
// column for a closed sidebar pushes/clips the actual chat surface.
|
||||
const displayedSidebarWidth = sidebarOpen ? sidebarWidth : 0
|
||||
const fileBrowserOpen = useStore($fileBrowserOpen)
|
||||
const connection = useStore($connection)
|
||||
|
||||
const titlebarControls = titlebarControlsPosition(connection?.windowButtonPosition)
|
||||
|
||||
@@ -63,18 +50,28 @@ export function AppShell({
|
||||
? 0
|
||||
: titlebarControls.left + TITLEBAR_HEIGHT + Math.round(TITLEBAR_HEIGHT / 2)
|
||||
|
||||
const showPreviewRail = rightRailOpen && Boolean(previewTarget)
|
||||
const showInspectorRail = rightRailOpen && inspectorOpen
|
||||
// The static system cluster (file-browser, haptics, settings) is hardcoded
|
||||
// in TitlebarControls. Pane-supplied tools (preview's group) render in a
|
||||
// separate cluster anchored further left.
|
||||
const SYSTEM_TOOL_COUNT = 3
|
||||
const paneToolCount = titlebarTools?.filter(tool => !tool.hidden).length ?? 0
|
||||
const systemToolsWidth = `calc(${SYSTEM_TOOL_COUNT} * var(--titlebar-control-size))`
|
||||
|
||||
const inspectorColumn = showInspectorRail ? 'var(--inspector-width)' : '0px'
|
||||
// Where the pane-tool cluster's right edge sits, measured from the inner
|
||||
// titlebar padding (--titlebar-tools-right). Two anchors:
|
||||
// - file-browser closed → flush against static cluster's left edge
|
||||
// - file-browser open → flush against the file-browser pane's left edge
|
||||
// (= preview pane's right edge)
|
||||
const previewToolbarGap = fileBrowserOpen ? FILE_BROWSER_DEFAULT_WIDTH : systemToolsWidth
|
||||
|
||||
const previewColumn = showPreviewRail
|
||||
? `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
|
||||
|
||||
const shellGridColumns = 'var(--sidebar-width) minmax(0,1fr) var(--preview-col) var(--inspector-col)'
|
||||
// Used by the drag region to know where the rightmost interactive element
|
||||
// ends. When pane tools are present, that's `gap + paneCount * controlSize`
|
||||
// (the leftmost button is at `tools-right + gap + paneCount * size`).
|
||||
// Otherwise the static cluster's footprint is enough.
|
||||
const titlebarToolsWidth =
|
||||
paneToolCount > 0
|
||||
? `calc(${previewToolbarGap} + ${paneToolCount} * var(--titlebar-control-size))`
|
||||
: systemToolsWidth
|
||||
|
||||
const startSidebarResize = useCallback(
|
||||
(event: ReactPointerEvent<HTMLDivElement>) => {
|
||||
@@ -115,69 +112,51 @@ export function AppShell({
|
||||
open={sidebarOpen}
|
||||
style={
|
||||
{
|
||||
'--inspector-width': inspectorWidth,
|
||||
'--preview-width': previewWidth,
|
||||
'--sidebar-width': `${displayedSidebarWidth}px`,
|
||||
'--chat-center-offset': '0px',
|
||||
'--shell-left-sidebar-width': `${displayedSidebarWidth}px`,
|
||||
'--shell-preview-pane-width': previewColumn,
|
||||
'--shell-right-sidebar-width': inspectorColumn,
|
||||
'--shell-right-region-width': 'calc(var(--shell-preview-pane-width) + var(--shell-right-sidebar-width))',
|
||||
'--shell-preview-toolbar-gap': showPreviewRail
|
||||
? 'max(0px, calc(var(--shell-right-sidebar-width) - (3 * var(--titlebar-control-size)) + 0.2rem))'
|
||||
: '0px',
|
||||
// Alias for shadcn <Sidebar> descendants. Resolves to the chat-sidebar
|
||||
// pane track via PaneShell's emitted --pane-chat-sidebar-width.
|
||||
'--sidebar-width': 'var(--pane-chat-sidebar-width)',
|
||||
'--titlebar-height': `${TITLEBAR_HEIGHT}px`,
|
||||
'--titlebar-content-inset': `${titlebarContentInset}px`,
|
||||
'--titlebar-controls-left': `${titlebarControls.left}px`,
|
||||
'--titlebar-controls-top': `${titlebarControls.top}px`,
|
||||
'--titlebar-tools-right': '0.75rem',
|
||||
'--titlebar-tools-width': `calc(${titlebarToolCount} * var(--titlebar-control-size) + var(--shell-preview-toolbar-gap))`
|
||||
'--titlebar-tools-width': titlebarToolsWidth,
|
||||
// Anchor for the pane-tool cluster's right edge in TitlebarControls.
|
||||
// Sourced from the layout store rather than the PaneShell-emitted
|
||||
// --pane-*-width vars because the titlebar is a sibling of PaneShell
|
||||
// and CSS variables resolve at the consumer's scope.
|
||||
'--shell-preview-toolbar-gap': previewToolbarGap
|
||||
} as CSSProperties
|
||||
}
|
||||
>
|
||||
<TitlebarControls
|
||||
leftTools={leftTitlebarTools}
|
||||
onOpenSettings={onOpenSettings}
|
||||
showInspectorToggle={rightRailOpen}
|
||||
tools={titlebarTools}
|
||||
/>
|
||||
<TitlebarControls leftTools={leftTitlebarTools} onOpenSettings={onOpenSettings} tools={titlebarTools} />
|
||||
|
||||
<main
|
||||
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,
|
||||
'--preview-col': previewColumn,
|
||||
gridTemplateColumns: shellGridColumns,
|
||||
gridTemplateRows: 'minmax(0,1fr) auto'
|
||||
} as CSSProperties
|
||||
}
|
||||
>
|
||||
<div
|
||||
aria-hidden="true"
|
||||
className="pointer-events-none absolute left-0 top-0 z-1 h-(--titlebar-height) w-(--titlebar-controls-left) [-webkit-app-region:drag]"
|
||||
/>
|
||||
<div
|
||||
aria-hidden="true"
|
||||
className="pointer-events-none absolute top-0 z-1 h-(--titlebar-height) left-[calc(var(--titlebar-controls-left)+(var(--titlebar-control-size)*2)+0.75rem)] right-[calc(var(--titlebar-tools-right)+var(--titlebar-tools-width)+0.75rem)] [-webkit-app-region:drag]"
|
||||
/>
|
||||
|
||||
<div className="col-start-1 col-end-2 row-start-1 min-h-0 overflow-hidden">{sidebar}</div>
|
||||
|
||||
{sidebarOpen && (
|
||||
<main className="relative flex h-screen w-full flex-col overflow-hidden bg-background pr-0.75 pb-0.75 pt-0.75 transition-none">
|
||||
<PaneShell className="min-h-0 flex-1">
|
||||
<div
|
||||
aria-label="Resize sidebar"
|
||||
aria-orientation="vertical"
|
||||
className="group absolute bottom-0 top-0 left-[calc(var(--sidebar-width)-0.5rem)] z-5 w-4 cursor-col-resize [-webkit-app-region:no-drag]"
|
||||
onPointerDown={startSidebarResize}
|
||||
role="separator"
|
||||
tabIndex={0}
|
||||
>
|
||||
<span className="absolute left-1/2 top-1/2 h-23 w-0.75 -translate-x-1/2 -translate-y-1/2 rounded-full bg-muted-foreground/80 opacity-0 transition-opacity duration-100 group-hover:opacity-[0.65] group-focus-visible:opacity-[0.65]" />
|
||||
</div>
|
||||
)}
|
||||
aria-hidden="true"
|
||||
className="pointer-events-none absolute left-0 top-0 z-1 h-(--titlebar-height) w-(--titlebar-controls-left) [-webkit-app-region:drag]"
|
||||
/>
|
||||
<div
|
||||
aria-hidden="true"
|
||||
className="pointer-events-none absolute top-0 z-1 h-(--titlebar-height) left-[calc(var(--titlebar-controls-left)+(var(--titlebar-control-size)*2)+0.75rem)] right-[calc(var(--titlebar-tools-right)+var(--titlebar-tools-width)+0.75rem)] [-webkit-app-region:drag]"
|
||||
/>
|
||||
|
||||
{children}
|
||||
{children}
|
||||
|
||||
{sidebarOpen && (
|
||||
<div
|
||||
aria-label="Resize sidebar"
|
||||
aria-orientation="vertical"
|
||||
className="group absolute bottom-0 top-0 left-[calc(var(--pane-chat-sidebar-width)-0.5rem)] z-5 w-4 cursor-col-resize [-webkit-app-region:no-drag]"
|
||||
onPointerDown={startSidebarResize}
|
||||
role="separator"
|
||||
tabIndex={0}
|
||||
>
|
||||
<span className="absolute left-1/2 top-1/2 h-23 w-0.75 -translate-x-1/2 -translate-y-1/2 rounded-full bg-muted-foreground/80 opacity-0 transition-opacity duration-100 group-hover:opacity-[0.65] group-focus-visible:opacity-[0.65]" />
|
||||
</div>
|
||||
)}
|
||||
</PaneShell>
|
||||
|
||||
<StatusbarControls items={statusbarItems} leftItems={leftStatusbarItems} />
|
||||
</main>
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
import { useCallback, useEffect, useMemo, useRef } from 'react'
|
||||
import { useLocation, useNavigate } from 'react-router-dom'
|
||||
|
||||
import { type CommandCenterSection } from '@/app/command-center'
|
||||
import { AGENTS_ROUTE, appViewForPath, COMMAND_CENTER_ROUTE, NEW_CHAT_ROUTE } from '@/app/routes'
|
||||
|
||||
const SECTIONS = ['models', 'sessions', 'system'] as const
|
||||
const OVERLAY_VIEWS = new Set(['settings', 'command-center', 'agents'])
|
||||
|
||||
export function useOverlayRouting() {
|
||||
const location = useLocation()
|
||||
const navigate = useNavigate()
|
||||
|
||||
const currentView = appViewForPath(location.pathname)
|
||||
const settingsOpen = currentView === 'settings'
|
||||
const commandCenterOpen = currentView === 'command-center'
|
||||
const agentsOpen = currentView === 'agents'
|
||||
const chatOpen = currentView === 'chat'
|
||||
const overlayOpen = OVERLAY_VIEWS.has(currentView)
|
||||
|
||||
// Overlay routes (settings/command-center/agents) stash the underlying path
|
||||
// so closing them returns there instead of bouncing to /.
|
||||
const returnPathRef = useRef(NEW_CHAT_ROUTE)
|
||||
|
||||
useEffect(() => {
|
||||
if (!overlayOpen) {
|
||||
returnPathRef.current = `${location.pathname}${location.search}${location.hash}`
|
||||
}
|
||||
}, [location.hash, location.pathname, location.search, overlayOpen])
|
||||
|
||||
const commandCenterInitialSection = useMemo<CommandCenterSection | undefined>(
|
||||
() => SECTIONS.find(value => value === new URLSearchParams(location.search).get('section')),
|
||||
[location.search]
|
||||
)
|
||||
|
||||
const openCommandCenterSection = useCallback(
|
||||
(section: CommandCenterSection) => navigate(`${COMMAND_CENTER_ROUTE}?section=${section}`),
|
||||
[navigate]
|
||||
)
|
||||
|
||||
const closeOverlayToPreviousRoute = useCallback(
|
||||
() => navigate(returnPathRef.current || NEW_CHAT_ROUTE, { replace: true }),
|
||||
[navigate]
|
||||
)
|
||||
|
||||
const toggleCommandCenter = useCallback(() => {
|
||||
if (commandCenterOpen) {
|
||||
closeOverlayToPreviousRoute()
|
||||
} else {
|
||||
navigate(COMMAND_CENTER_ROUTE)
|
||||
}
|
||||
}, [closeOverlayToPreviousRoute, commandCenterOpen, navigate])
|
||||
|
||||
const openAgents = useCallback(() => navigate(AGENTS_ROUTE), [navigate])
|
||||
|
||||
return {
|
||||
agentsOpen,
|
||||
chatOpen,
|
||||
closeOverlayToPreviousRoute,
|
||||
commandCenterInitialSection,
|
||||
commandCenterOpen,
|
||||
currentView,
|
||||
openAgents,
|
||||
openCommandCenterSection,
|
||||
settingsOpen,
|
||||
toggleCommandCenter
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
|
||||
import { getLogs, getStatus } from '@/hermes'
|
||||
import type { StatusResponse } from '@/types/hermes'
|
||||
|
||||
const REFRESH_MS = 15_000
|
||||
const LOG_TAIL = 12
|
||||
|
||||
export function useStatusSnapshot(gatewayState: string | undefined) {
|
||||
const [statusSnapshot, setStatusSnapshot] = useState<StatusResponse | null>(null)
|
||||
const [gatewayLogLines, setGatewayLogLines] = useState<string[]>([])
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
|
||||
const refresh = async () => {
|
||||
try {
|
||||
const [next, logs] = await Promise.all([
|
||||
getStatus(),
|
||||
getLogs({ file: 'gateway', lines: LOG_TAIL }).catch(() => ({ lines: [] }))
|
||||
])
|
||||
|
||||
if (cancelled) {return}
|
||||
|
||||
setStatusSnapshot(next)
|
||||
setGatewayLogLines(logs.lines.map(line => line.trim()).filter(Boolean))
|
||||
} catch {
|
||||
// Keep last snapshot through transient gateway flaps.
|
||||
}
|
||||
}
|
||||
|
||||
void refresh()
|
||||
const timer = window.setInterval(() => void refresh(), REFRESH_MS)
|
||||
|
||||
return () => {
|
||||
cancelled = true
|
||||
window.clearInterval(timer)
|
||||
}
|
||||
}, [gatewayState])
|
||||
|
||||
return { gatewayLogLines, statusSnapshot }
|
||||
}
|
||||
@@ -0,0 +1,218 @@
|
||||
import { useStore } from '@nanostores/react'
|
||||
import { useMemo } from 'react'
|
||||
|
||||
import type { CommandCenterSection } from '@/app/command-center'
|
||||
import { buildGatewayLogItems } from '@/lib/gateway-events'
|
||||
import { Activity, AlertCircle, Command, Cpu, FolderOpen, GitBranch, Loader2, Sparkles } from '@/lib/icons'
|
||||
import { compactPath, contextBarLabel, LiveDuration, usageContextLabel } from '@/lib/statusbar'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { $desktopActionTasks } from '@/store/activity'
|
||||
import { $previewServerRestartStatus } from '@/store/preview'
|
||||
import {
|
||||
$busy,
|
||||
$currentBranch,
|
||||
$currentCwd,
|
||||
$currentModel,
|
||||
$currentProvider,
|
||||
$currentUsage,
|
||||
$sessionStartedAt,
|
||||
$turnStartedAt,
|
||||
$workingSessionIds,
|
||||
setModelPickerOpen
|
||||
} from '@/store/session'
|
||||
import type { StatusResponse } from '@/types/hermes'
|
||||
|
||||
import type { StatusbarItem, StatusbarMenuItem } from '../statusbar-controls'
|
||||
|
||||
interface StatusbarItemsOptions {
|
||||
agentsOpen: boolean
|
||||
browseSessionCwd: () => Promise<void>
|
||||
commandCenterOpen: boolean
|
||||
extraLeftItems: readonly StatusbarItem[]
|
||||
extraRightItems: readonly StatusbarItem[]
|
||||
gatewayLogLines: readonly string[]
|
||||
openAgents: () => void
|
||||
openCommandCenterSection: (section: CommandCenterSection) => void
|
||||
statusSnapshot: StatusResponse | null
|
||||
toggleCommandCenter: () => void
|
||||
}
|
||||
|
||||
export function useStatusbarItems({
|
||||
agentsOpen,
|
||||
browseSessionCwd,
|
||||
commandCenterOpen,
|
||||
extraLeftItems,
|
||||
extraRightItems,
|
||||
gatewayLogLines,
|
||||
openAgents,
|
||||
openCommandCenterSection,
|
||||
statusSnapshot,
|
||||
toggleCommandCenter
|
||||
}: StatusbarItemsOptions) {
|
||||
const busy = useStore($busy)
|
||||
const currentBranch = useStore($currentBranch)
|
||||
const currentCwd = useStore($currentCwd)
|
||||
const currentModel = useStore($currentModel)
|
||||
const currentProvider = useStore($currentProvider)
|
||||
const currentUsage = useStore($currentUsage)
|
||||
const desktopActionTasks = useStore($desktopActionTasks)
|
||||
const previewServerRestartStatus = useStore($previewServerRestartStatus)
|
||||
const sessionStartedAt = useStore($sessionStartedAt)
|
||||
const turnStartedAt = useStore($turnStartedAt)
|
||||
const workingSessionIds = useStore($workingSessionIds)
|
||||
|
||||
const contextUsage = useMemo(() => usageContextLabel(currentUsage), [currentUsage])
|
||||
const contextBar = useMemo(() => contextBarLabel(currentUsage), [currentUsage])
|
||||
|
||||
const platformMenuItems = useMemo<readonly StatusbarMenuItem[]>(
|
||||
() =>
|
||||
Object.entries(statusSnapshot?.gateway_platforms || {})
|
||||
.sort(([l], [r]) => l.localeCompare(r))
|
||||
.map(([name, platform]) => ({ disabled: true, id: `platform:${name}`, label: `${name} · ${platform.state}` })),
|
||||
[statusSnapshot?.gateway_platforms]
|
||||
)
|
||||
|
||||
const gatewayMenuItems = useMemo<readonly StatusbarMenuItem[]>(
|
||||
() => [
|
||||
{ id: 'gateway:open-system', label: 'Open system panel', onSelect: () => openCommandCenterSection('system') },
|
||||
...buildGatewayLogItems(gatewayLogLines),
|
||||
...platformMenuItems
|
||||
],
|
||||
[gatewayLogLines, openCommandCenterSection, platformMenuItems]
|
||||
)
|
||||
|
||||
const { bgFailed, bgRunning } = useMemo(() => {
|
||||
const actions = Object.values(desktopActionTasks)
|
||||
const running = actions.filter(t => t.status.running).length
|
||||
const failed = actions.filter(t => !t.status.running && (t.status.exit_code ?? 0) !== 0).length
|
||||
const previewRunning = previewServerRestartStatus === 'running' ? 1 : 0
|
||||
const previewFailed = previewServerRestartStatus === 'error' ? 1 : 0
|
||||
|
||||
return { bgFailed: failed + previewFailed, bgRunning: workingSessionIds.length + running + previewRunning }
|
||||
}, [desktopActionTasks, previewServerRestartStatus, workingSessionIds])
|
||||
|
||||
const gatewayUp = Boolean(statusSnapshot?.gateway_running)
|
||||
|
||||
const coreLeftStatusbarItems = useMemo<readonly StatusbarItem[]>(
|
||||
() => [
|
||||
{
|
||||
className: `h-6 w-6 justify-center px-0${commandCenterOpen ? ' bg-accent/55 text-foreground' : ''}`,
|
||||
icon: <Command className="size-3.5" />,
|
||||
id: 'command-center',
|
||||
onSelect: toggleCommandCenter,
|
||||
title: commandCenterOpen ? 'Close Command Center' : 'Open Command Center',
|
||||
variant: 'action'
|
||||
},
|
||||
{
|
||||
className: gatewayUp ? undefined : 'text-destructive hover:text-destructive',
|
||||
detail: gatewayUp ? statusSnapshot?.gateway_state || 'online' : 'offline',
|
||||
icon: gatewayUp ? <Activity className="size-3" /> : <AlertCircle className="size-3" />,
|
||||
id: 'gateway-health',
|
||||
label: 'Gateway',
|
||||
menuClassName: 'w-96',
|
||||
menuItems: gatewayMenuItems,
|
||||
title: 'Gateway and platform health',
|
||||
variant: 'menu'
|
||||
},
|
||||
{
|
||||
className: cn(
|
||||
agentsOpen && 'bg-accent/55 text-foreground',
|
||||
bgFailed > 0 && 'text-destructive hover:text-destructive'
|
||||
),
|
||||
detail: bgFailed > 0 ? `${bgFailed} failed` : bgRunning > 0 ? `${bgRunning} running` : undefined,
|
||||
icon:
|
||||
bgFailed > 0 ? (
|
||||
<AlertCircle className="size-3" />
|
||||
) : bgRunning > 0 ? (
|
||||
<Loader2 className="size-3 animate-spin" />
|
||||
) : (
|
||||
<Sparkles className="size-3" />
|
||||
),
|
||||
id: 'agents',
|
||||
label: 'Agents',
|
||||
onSelect: openAgents,
|
||||
title: agentsOpen ? 'Close agents' : 'Open agents',
|
||||
variant: 'action'
|
||||
}
|
||||
],
|
||||
[
|
||||
agentsOpen,
|
||||
bgFailed,
|
||||
bgRunning,
|
||||
commandCenterOpen,
|
||||
gatewayMenuItems,
|
||||
gatewayUp,
|
||||
openAgents,
|
||||
statusSnapshot?.gateway_state,
|
||||
toggleCommandCenter
|
||||
]
|
||||
)
|
||||
|
||||
const coreRightStatusbarItems = useMemo<readonly StatusbarItem[]>(
|
||||
() => [
|
||||
{
|
||||
detail: <LiveDuration since={turnStartedAt} />,
|
||||
hidden: !busy || !turnStartedAt,
|
||||
icon: <Loader2 className="size-3 animate-spin" />,
|
||||
id: 'running-timer',
|
||||
label: 'Running',
|
||||
title: 'Current turn elapsed',
|
||||
variant: 'text'
|
||||
},
|
||||
{
|
||||
detail: contextBar || undefined,
|
||||
hidden: !contextUsage,
|
||||
id: 'context-usage',
|
||||
label: contextUsage,
|
||||
title: 'Context usage',
|
||||
variant: 'text'
|
||||
},
|
||||
{
|
||||
detail: <LiveDuration since={sessionStartedAt} />,
|
||||
hidden: !sessionStartedAt,
|
||||
id: 'session-timer',
|
||||
label: 'Session',
|
||||
title: 'Runtime session elapsed',
|
||||
variant: 'text'
|
||||
},
|
||||
{
|
||||
detail: currentProvider || '',
|
||||
icon: <Cpu className="size-3" />,
|
||||
id: 'model-summary',
|
||||
label: currentModel || 'No model selected',
|
||||
onSelect: () => setModelPickerOpen(true),
|
||||
title: currentProvider ? `Switch model · ${currentProvider}: ${currentModel || ''}` : 'Open model picker',
|
||||
variant: 'action'
|
||||
},
|
||||
{
|
||||
icon: <FolderOpen className="size-3" />,
|
||||
id: 'cwd',
|
||||
label: currentCwd ? compactPath(currentCwd) : 'No project cwd',
|
||||
onSelect: () => void browseSessionCwd(),
|
||||
title: currentCwd ? `Change working directory · ${currentCwd}` : 'Choose working directory',
|
||||
variant: 'action'
|
||||
},
|
||||
{
|
||||
hidden: !currentBranch,
|
||||
icon: <GitBranch className="size-3" />,
|
||||
id: 'branch',
|
||||
label: currentBranch,
|
||||
title: currentBranch ? `Current branch: ${currentBranch}` : undefined,
|
||||
variant: 'text'
|
||||
}
|
||||
],
|
||||
[browseSessionCwd, busy, contextBar, contextUsage, currentBranch, currentCwd, currentModel, currentProvider, sessionStartedAt, turnStartedAt]
|
||||
)
|
||||
|
||||
const leftStatusbarItems = useMemo(
|
||||
() => [...coreLeftStatusbarItems, ...extraLeftItems],
|
||||
[coreLeftStatusbarItems, extraLeftItems]
|
||||
)
|
||||
|
||||
const statusbarItems = useMemo(
|
||||
() => [...extraRightItems, ...coreRightStatusbarItems],
|
||||
[coreRightStatusbarItems, extraRightItems]
|
||||
)
|
||||
|
||||
return { leftStatusbarItems, statusbarItems }
|
||||
}
|
||||
@@ -48,7 +48,7 @@ interface StatusbarControlsProps extends ComponentProps<'footer'> {
|
||||
}
|
||||
|
||||
const statusbarItemClass =
|
||||
'inline-flex h-6 items-center gap-1.5 rounded-md px-2 text-[0.69rem] text-muted-foreground/95 transition-colors hover:bg-accent/55 hover:text-foreground disabled:cursor-default disabled:opacity-45'
|
||||
'inline-flex h-5 items-center gap-1 rounded px-1 text-[0.68rem] text-muted-foreground/95 transition-colors hover:bg-accent/55 hover:text-foreground disabled:cursor-default disabled:opacity-45'
|
||||
|
||||
export function StatusbarControls({ className, leftItems = [], items = [], ...props }: StatusbarControlsProps) {
|
||||
const navigate = useNavigate()
|
||||
@@ -56,17 +56,17 @@ export function StatusbarControls({ className, leftItems = [], items = [], ...pr
|
||||
return (
|
||||
<footer
|
||||
className={cn(
|
||||
'col-span-4 row-start-2 row-end-3 flex h-7 items-center justify-between gap-2 border-t border-border/55 bg-[color-mix(in_srgb,var(--dt-muted)_45%,var(--dt-card))] px-2.5 py-1 text-muted-foreground/95 [-webkit-app-region:no-drag]',
|
||||
'flex h-7 shrink-0 items-center justify-between gap-2 border-t border-border/55 bg-[color-mix(in_srgb,var(--dt-muted)_45%,var(--dt-card))] px-2.5 py-1 text-muted-foreground/95 [-webkit-app-region:no-drag]',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<div className="flex min-w-0 items-center gap-1 overflow-x-auto">
|
||||
<div className="flex min-w-0 items-center gap-0.5 overflow-x-auto">
|
||||
{leftItems.filter(item => !item.hidden).map(item => (
|
||||
<StatusbarItemView item={item} key={`left:${item.id}`} navigate={navigate} />
|
||||
))}
|
||||
</div>
|
||||
<div className="flex min-w-0 items-center gap-1 overflow-x-auto">
|
||||
<div className="flex min-w-0 items-center gap-0.5 overflow-x-auto">
|
||||
{items.filter(item => !item.hidden).map(item => (
|
||||
<StatusbarItemView item={item} key={`right:${item.id}`} navigate={navigate} />
|
||||
))}
|
||||
@@ -147,7 +147,7 @@ function StatusbarItemView({
|
||||
|
||||
if (item.variant === 'text' && !item.onSelect && !item.to && !item.href) {
|
||||
return (
|
||||
<div className={cn('inline-flex items-center gap-1.5 px-1 text-[0.69rem] text-muted-foreground/90', item.className)}>
|
||||
<div className={cn('inline-flex h-5 items-center gap-1 px-0.5 text-[0.68rem] text-muted-foreground/90', item.className)}>
|
||||
{content}
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -3,10 +3,10 @@ import type { ComponentProps, ReactNode } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
|
||||
import { triggerHaptic } from '@/lib/haptics'
|
||||
import { NotebookTabs, Settings, SlidersHorizontal, Volume2, VolumeX } from '@/lib/icons'
|
||||
import { FolderOpen, NotebookTabs, Settings, Volume2, VolumeX } from '@/lib/icons'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { $hapticsMuted, toggleHapticsMuted } from '@/store/haptics'
|
||||
import { $inspectorOpen, $sidebarOpen, toggleInspectorOpen, toggleSidebarOpen } from '@/store/layout'
|
||||
import { $fileBrowserOpen, $sidebarOpen, toggleFileBrowserOpen, toggleSidebarOpen } from '@/store/layout'
|
||||
|
||||
import { titlebarButtonClass } from './titlebar'
|
||||
|
||||
@@ -29,21 +29,15 @@ export type SetTitlebarToolGroup = (id: string, tools: readonly TitlebarTool[],
|
||||
|
||||
interface TitlebarControlsProps extends ComponentProps<'div'> {
|
||||
leftTools?: readonly TitlebarTool[]
|
||||
showInspectorToggle: boolean
|
||||
tools?: readonly TitlebarTool[]
|
||||
onOpenSettings: () => void
|
||||
}
|
||||
|
||||
export function TitlebarControls({
|
||||
leftTools = [],
|
||||
showInspectorToggle,
|
||||
tools = [],
|
||||
onOpenSettings
|
||||
}: TitlebarControlsProps) {
|
||||
export function TitlebarControls({ leftTools = [], tools = [], onOpenSettings }: TitlebarControlsProps) {
|
||||
const navigate = useNavigate()
|
||||
const hapticsMuted = useStore($hapticsMuted)
|
||||
const fileBrowserOpen = useStore($fileBrowserOpen)
|
||||
const sidebarOpen = useStore($sidebarOpen)
|
||||
const inspectorOpen = useStore($inspectorOpen)
|
||||
|
||||
const toggleHaptics = () => {
|
||||
if (!hapticsMuted) {
|
||||
@@ -70,17 +64,16 @@ export function TitlebarControls({
|
||||
...leftTools
|
||||
]
|
||||
|
||||
const rightToolbarTools: TitlebarTool[] = [
|
||||
...tools,
|
||||
// Static system tools — always pinned to the screen's right edge.
|
||||
const systemTools: TitlebarTool[] = [
|
||||
{
|
||||
active: inspectorOpen,
|
||||
hidden: !showInspectorToggle,
|
||||
icon: <SlidersHorizontal />,
|
||||
id: 'session-details',
|
||||
label: inspectorOpen ? 'Hide session details' : 'Show session details',
|
||||
active: fileBrowserOpen,
|
||||
icon: <FolderOpen />,
|
||||
id: 'file-browser',
|
||||
label: fileBrowserOpen ? 'Hide file browser' : 'Show file browser',
|
||||
onSelect: () => {
|
||||
triggerHaptic('tap')
|
||||
toggleInspectorOpen()
|
||||
toggleFileBrowserOpen()
|
||||
}
|
||||
},
|
||||
{
|
||||
@@ -101,6 +94,9 @@ export function TitlebarControls({
|
||||
}
|
||||
]
|
||||
|
||||
const visibleSystemTools = systemTools.filter(tool => !tool.hidden)
|
||||
const visiblePaneTools = tools.filter(tool => !tool.hidden)
|
||||
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
@@ -114,15 +110,32 @@ export function TitlebarControls({
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/*
|
||||
Pane-scoped tools (preview's monitor / devtools / refresh / X) render
|
||||
as their own fixed cluster. AppShell sets --shell-preview-toolbar-gap
|
||||
to either the static cluster's width (file-browser closed → cluster
|
||||
sits flush against system tools) or the file-browser pane's width
|
||||
(file-browser open → cluster sits flush against the file-browser pane,
|
||||
i.e. at the preview pane's right edge). No margin hacks needed.
|
||||
*/}
|
||||
{visiblePaneTools.length > 0 && (
|
||||
<div
|
||||
aria-label="Pane controls"
|
||||
className="fixed top-(--titlebar-controls-top) right-[calc(var(--titlebar-tools-right)+var(--shell-preview-toolbar-gap,0px))] z-70 flex flex-row items-center gap-px pointer-events-auto select-none [-webkit-app-region:no-drag]"
|
||||
>
|
||||
{visiblePaneTools.map(tool => (
|
||||
<TitlebarToolButton key={tool.id} navigate={navigate} tool={tool} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div
|
||||
aria-label="App controls"
|
||||
className="fixed right-(--titlebar-tools-right) top-(--titlebar-controls-top) z-70 flex flex-row items-center justify-end gap-px pointer-events-auto select-none [-webkit-app-region:no-drag]"
|
||||
>
|
||||
{rightToolbarTools
|
||||
.filter(tool => !tool.hidden)
|
||||
.map(tool => (
|
||||
<TitlebarToolButton key={tool.id} navigate={navigate} tool={tool} />
|
||||
))}
|
||||
{visibleSystemTools.map(tool => (
|
||||
<TitlebarToolButton key={tool.id} navigate={navigate} tool={tool} />
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
|
||||
@@ -51,7 +51,7 @@ export type CommandDispatchResponse =
|
||||
| SkillCommandDispatchResponse
|
||||
| SendCommandDispatchResponse
|
||||
|
||||
export type SidebarNavId = 'new-session' | 'skills' | 'artifacts'
|
||||
export type SidebarNavId = 'new-session' | 'command-center' | 'settings' | 'skills' | 'artifacts'
|
||||
|
||||
export interface SidebarNavItem {
|
||||
id: SidebarNavId
|
||||
|
||||
@@ -186,10 +186,10 @@ function shortLabel(type: HermesRefType, id: string): string {
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders a text message part with our directive segments as inline chips.
|
||||
* Unknown directive types fall through as plain text.
|
||||
* Renders text containing Hermes directives (`@file:...`, `@image:...`) as
|
||||
* inline chips. Embedded MEDIA images render below as a thumbnail row.
|
||||
*/
|
||||
export const DirectiveText: TextMessagePartComponent = ({ text }: TextMessagePartProps) => {
|
||||
export function DirectiveContent({ text }: { text: string }) {
|
||||
const { cleanedText, images } = useMemo(() => extractEmbeddedImages(text ?? ''), [text])
|
||||
const segments = useMemo(() => hermesDirectiveFormatter.parse(cleanedText), [cleanedText])
|
||||
|
||||
@@ -220,6 +220,11 @@ export const DirectiveText: TextMessagePartComponent = ({ text }: TextMessagePar
|
||||
)
|
||||
}
|
||||
|
||||
/** assistant-ui adapter: same renderer, exposed as a TextMessagePartComponent. */
|
||||
export const DirectiveText: TextMessagePartComponent = ({ text }: TextMessagePartProps) => (
|
||||
<DirectiveContent text={text ?? ''} />
|
||||
)
|
||||
|
||||
const DirectiveChip: FC<{
|
||||
type: string
|
||||
label: string
|
||||
@@ -230,14 +235,14 @@ const DirectiveChip: FC<{
|
||||
return (
|
||||
<span
|
||||
className={cn(
|
||||
'mx-0.5 inline-flex max-w-64 items-center gap-1 rounded-full bg-[color-mix(in_srgb,var(--dt-primary)_16%,transparent)] px-2 py-0.5 align-[0.02em] text-[0.92em] font-semibold leading-tight text-primary ring-1 ring-inset ring-primary/10'
|
||||
'mx-0.5 inline-flex max-w-56 items-center gap-1 border border-primary/20 bg-primary/8 px-1.5 py-0.5 align-[0.02em] text-[0.86em] font-medium leading-none text-primary'
|
||||
)}
|
||||
data-directive-id={id}
|
||||
data-directive-type={type}
|
||||
data-slot="aui_directive-chip"
|
||||
title={id}
|
||||
>
|
||||
{Icon && <Icon className="size-3.5 shrink-0 text-primary" />}
|
||||
{Icon && <Icon className="size-3 shrink-0 text-primary" />}
|
||||
<span className="truncate">{label}</span>
|
||||
</span>
|
||||
)
|
||||
|
||||
@@ -175,10 +175,10 @@ export const Intro: FC<IntroProps> = ({ personality, seed }) => {
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<div className="pointer-events-none flex min-h-[calc(100vh-var(--titlebar-height)-var(--thread-composer-clearance)-var(--composer-shell-pad-block-end))] flex-col items-center justify-center px-[calc(var(--vsq)*50)] text-center text-muted-foreground">
|
||||
<div className="pointer-events-none flex min-h-[calc(100vh-var(--titlebar-height)-var(--thread-composer-clearance)-var(--composer-shell-pad-block-end))] w-full min-w-0 flex-col items-center justify-center px-3 py-8 text-center text-muted-foreground sm:px-6 lg:px-8">
|
||||
<button
|
||||
aria-label="Change Hermes pose"
|
||||
className="pointer-events-auto mb-5 h-56 w-64 cursor-default border-0 bg-transparent p-0"
|
||||
className="pointer-events-auto mb-5 aspect-8/7 w-full max-w-64 cursor-default border-0 bg-transparent p-0"
|
||||
onClick={advanceFrame}
|
||||
type="button"
|
||||
>
|
||||
@@ -191,9 +191,11 @@ export const Intro: FC<IntroProps> = ({ personality, seed }) => {
|
||||
style={{ transform: `translateX(${spriteOffsetPx}px) scale(1.1)` }}
|
||||
/>
|
||||
</button>
|
||||
<p className="mb-3 text-xs font-medium uppercase tracking-[0.18em] text-muted-foreground/75">Hermes Agent</p>
|
||||
<h1 className="mb-2.5 text-xl font-semibold tracking-tight text-foreground">{copy.headline}</h1>
|
||||
<p className="m-0 max-w-120 leading-normal">{copy.body}</p>
|
||||
<div className="w-full min-w-0 max-w-xl">
|
||||
<p className="mb-3 text-xs font-medium uppercase tracking-[0.18em] text-muted-foreground/75">Hermes Agent</p>
|
||||
<h1 className="mb-2.5 text-xl font-semibold tracking-tight text-foreground">{copy.headline}</h1>
|
||||
<p className="m-0 leading-normal">{copy.body}</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { preprocessMarkdown } from './markdown-text'
|
||||
import { preprocessMarkdown } from '@/lib/markdown-preprocess'
|
||||
|
||||
describe('preprocessMarkdown', () => {
|
||||
it('strips inline accidental triple-backtick starts', () => {
|
||||
|
||||
@@ -7,9 +7,9 @@ import { type ComponentProps, memo, useEffect, useMemo, useState } from 'react'
|
||||
import { PreviewAttachment } from '@/components/assistant-ui/preview-attachment'
|
||||
import { SyntaxHighlighter } from '@/components/assistant-ui/shiki-highlighter'
|
||||
import { ZoomableImage } from '@/components/assistant-ui/zoomable-image'
|
||||
import { triggerHaptic } from '@/lib/haptics'
|
||||
import { Check, Copy } from '@/lib/icons'
|
||||
import { isLikelyProseCodeBlock, isLikelyProseFence, sanitizeLanguageTag } from '@/lib/markdown-code'
|
||||
import { CopyButton } from '@/components/ui/copy-button'
|
||||
import { isLikelyProseCodeBlock, sanitizeLanguageTag } from '@/lib/markdown-code'
|
||||
import { preprocessMarkdown } from '@/lib/markdown-preprocess'
|
||||
import {
|
||||
filePathFromMediaPath,
|
||||
mediaExternalUrl,
|
||||
@@ -18,172 +18,10 @@ import {
|
||||
mediaName,
|
||||
mediaPathFromMarkdownHref
|
||||
} from '@/lib/media'
|
||||
import { isLikelyPreviewCandidate, previewTargetFromMarkdownHref, stripPreviewTargets } from '@/lib/preview-targets'
|
||||
import { previewTargetFromMarkdownHref } from '@/lib/preview-targets'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
/**
|
||||
* Strip provider/model "thinking" blocks before markdown render.
|
||||
*
|
||||
* Some Hermes providers stream raw `<think>…</think>` and similar into
|
||||
* assistant text. Proper reasoning UI uses dedicated `reasoning.*` parts.
|
||||
*/
|
||||
const REASONING_BLOCK_RE = /<(think|thinking|reasoning|scratchpad|analysis)>[\s\S]*?<\/\1>\s*/gi
|
||||
const PREVIEW_MARKER_RE = /\[Preview:[^\]]+\]\(#preview[:/][^)]+\)/gi
|
||||
|
||||
const FENCE_LINE_RE = /^([ \t]*)(`{3,}|~{3,})([^\n]*)$/
|
||||
const MIDLINE_FENCE_RE = /([^\n])(`{3,}|~{3,})(?=\s|$)/g
|
||||
const EMPTY_FENCE_BLOCK_RE = /(^|\n)[ \t]*(?:`{3,}|~{3,})[^\n]*\n[ \t]*(?:`{3,}|~{3,})[ \t]*(?=\n|$)/g
|
||||
|
||||
function stripMidlineFenceStarts(text: string): string {
|
||||
// Providers often stream inline fence noise like `200.``` http://...`.
|
||||
// A real fenced block must start at the beginning of a line; anything
|
||||
// mid-line should be treated as literal/prose and never allowed to create
|
||||
// an empty Streamdown code-card shell.
|
||||
return text.replace(MIDLINE_FENCE_RE, '$1')
|
||||
}
|
||||
|
||||
function stripEmptyFenceBlocks(text: string): string {
|
||||
// Remove already-balanced but empty fences before Streamdown sees them.
|
||||
// Returning null from our CodeHeader/SyntaxHighlighter is not enough: the
|
||||
// code plugin still renders its outer code-block wrapper, producing the
|
||||
// blank bordered element seen during streaming.
|
||||
return text.replace(EMPTY_FENCE_BLOCK_RE, '$1')
|
||||
}
|
||||
|
||||
function pushProseFence(out: string[], indent: string, info: string, lines: string[]) {
|
||||
if (info) {
|
||||
out.push(`${indent}${info}`.trimEnd())
|
||||
}
|
||||
|
||||
out.push(...lines)
|
||||
}
|
||||
|
||||
function findClosingFence(lines: string[], start: number, marker: string): number {
|
||||
for (let cursor = start + 1; cursor < lines.length; cursor += 1) {
|
||||
const closeMatch = (lines[cursor] || '').match(FENCE_LINE_RE)
|
||||
|
||||
if (!closeMatch) {
|
||||
continue
|
||||
}
|
||||
|
||||
const closeMarker = closeMatch[2] || ''
|
||||
const closeInfo = (closeMatch[3] || '').trim()
|
||||
|
||||
if (!closeInfo && closeMarker[0] === marker[0] && closeMarker.length >= marker.length) {
|
||||
return cursor
|
||||
}
|
||||
}
|
||||
|
||||
return -1
|
||||
}
|
||||
|
||||
function isPreviewOnlyFence(body: string): boolean {
|
||||
const lines = body
|
||||
.split('\n')
|
||||
.map(line => line.trim())
|
||||
.filter(Boolean)
|
||||
|
||||
return lines.length === 1 && isLikelyPreviewCandidate(lines[0])
|
||||
}
|
||||
|
||||
function normalizeFenceBlocks(text: string): string {
|
||||
const sourceLines = text.split('\n')
|
||||
const out: string[] = []
|
||||
let index = 0
|
||||
|
||||
while (index < sourceLines.length) {
|
||||
const line = sourceLines[index] || ''
|
||||
const match = line.match(FENCE_LINE_RE)
|
||||
|
||||
if (!match) {
|
||||
out.push(line)
|
||||
index += 1
|
||||
|
||||
continue
|
||||
}
|
||||
|
||||
const indent = match[1] || ''
|
||||
const marker = match[2] || '```'
|
||||
const infoRaw = (match[3] || '').trim()
|
||||
const languageToken = infoRaw.split(/\s+/, 1)[0] || ''
|
||||
const language = sanitizeLanguageTag(languageToken)
|
||||
const openerValid = !infoRaw || Boolean(language)
|
||||
|
||||
if (!openerValid) {
|
||||
out.push(`${indent}${infoRaw}`.trimEnd())
|
||||
index += 1
|
||||
|
||||
continue
|
||||
}
|
||||
|
||||
const closeIndex = findClosingFence(sourceLines, index, marker)
|
||||
const bodyLines = sourceLines.slice(index + 1, closeIndex === -1 ? sourceLines.length : closeIndex)
|
||||
const body = bodyLines.join('\n')
|
||||
|
||||
if (closeIndex !== -1 && !body.trim()) {
|
||||
// Empty fenced block: drop both delimiters. This prevents Streamdown's
|
||||
// code plugin from rendering an empty shell/card.
|
||||
index = closeIndex + 1
|
||||
|
||||
continue
|
||||
}
|
||||
|
||||
if (closeIndex !== -1 && isPreviewOnlyFence(body)) {
|
||||
// Agents often fence a lone preview URL to make it copyable. The chat UI
|
||||
// already renders a preview card for that URL, so don't show code fences.
|
||||
out.push(...bodyLines)
|
||||
index = closeIndex + 1
|
||||
|
||||
continue
|
||||
}
|
||||
|
||||
if (closeIndex === -1) {
|
||||
if (!body.trim()) {
|
||||
index += 1
|
||||
|
||||
continue
|
||||
}
|
||||
|
||||
if (isLikelyProseFence(infoRaw, body)) {
|
||||
pushProseFence(out, indent, infoRaw, bodyLines)
|
||||
} else {
|
||||
out.push(`${indent}${marker}${language}`)
|
||||
out.push(...bodyLines)
|
||||
}
|
||||
|
||||
break
|
||||
}
|
||||
|
||||
if (isLikelyProseFence(infoRaw, body)) {
|
||||
pushProseFence(out, indent, infoRaw, bodyLines)
|
||||
index = closeIndex + 1
|
||||
|
||||
continue
|
||||
}
|
||||
|
||||
out.push(`${indent}${marker}${language}`)
|
||||
out.push(...bodyLines)
|
||||
out.push(`${indent}${marker}`)
|
||||
index = closeIndex + 1
|
||||
}
|
||||
|
||||
return out.join('\n')
|
||||
}
|
||||
|
||||
export function preprocessMarkdown(text: string): string {
|
||||
const cleaned = text.replace(REASONING_BLOCK_RE, '').replace(PREVIEW_MARKER_RE, '')
|
||||
const normalizedFences = normalizeFenceBlocks(stripMidlineFenceStarts(cleaned))
|
||||
const strippedEmptyFences = stripEmptyFenceBlocks(normalizedFences)
|
||||
|
||||
return strippedEmptyFences
|
||||
.split(/((?:```|~~~)[\s\S]*?(?:```|~~~))/g)
|
||||
.map(part => (/^(?:```|~~~)/.test(part) ? part : stripPreviewTargets(part)))
|
||||
.join('')
|
||||
.replace(/[ \t]+\n/g, '\n')
|
||||
}
|
||||
|
||||
function CodeHeader({ language, code }: { language?: string; code?: string }) {
|
||||
const [copied, setCopied] = useState(false)
|
||||
const normalizedCode = (code ?? '').replace(/^\n+/, '').trimEnd()
|
||||
|
||||
// Streamdown can transiently parse stray backticks / incomplete fences as
|
||||
@@ -194,41 +32,15 @@ function CodeHeader({ language, code }: { language?: string; code?: string }) {
|
||||
return null
|
||||
}
|
||||
|
||||
async function handleCopy() {
|
||||
if (!normalizedCode) {
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
if (window.hermesDesktop?.writeClipboard) {
|
||||
await window.hermesDesktop.writeClipboard(normalizedCode)
|
||||
} else if (navigator.clipboard?.writeText) {
|
||||
await navigator.clipboard.writeText(normalizedCode)
|
||||
}
|
||||
|
||||
triggerHaptic('selection')
|
||||
setCopied(true)
|
||||
setTimeout(() => setCopied(false), 1500)
|
||||
} catch {
|
||||
// Best-effort copy; silent failure is OK for a chat surface.
|
||||
}
|
||||
}
|
||||
|
||||
const cleanLanguage = sanitizeLanguageTag(language || '')
|
||||
const label = cleanLanguage && cleanLanguage !== 'unknown' ? cleanLanguage : ''
|
||||
|
||||
return (
|
||||
<div className="m-0 flex items-center justify-between gap-2 rounded-t-md border border-b-0 border-border bg-muted/60 px-3 py-1.5 text-xs text-muted-foreground">
|
||||
<span className="font-mono uppercase tracking-wide">{label || 'code'}</span>
|
||||
<button
|
||||
aria-label={copied ? 'Copied' : 'Copy code'}
|
||||
className="inline-flex items-center gap-1 rounded-sm px-1.5 py-0.5 text-[0.75rem] text-muted-foreground transition-colors hover:bg-accent hover:text-foreground"
|
||||
onClick={handleCopy}
|
||||
type="button"
|
||||
>
|
||||
{copied ? <Check size={12} /> : <Copy size={12} />}
|
||||
{copied ? 'Copied' : 'Copy'}
|
||||
</button>
|
||||
<CopyButton appearance="inline" iconClassName="size-3" label="Copy code" text={normalizedCode}>
|
||||
Copy
|
||||
</CopyButton>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -360,7 +172,7 @@ function MarkdownLink({ className, href, ...props }: ComponentProps<'a'>) {
|
||||
}
|
||||
|
||||
if (previewTarget) {
|
||||
return <PreviewAttachment target={previewTarget} />
|
||||
return <PreviewAttachment source="explicit-link" target={previewTarget} />
|
||||
}
|
||||
|
||||
return (
|
||||
|
||||
@@ -2,12 +2,13 @@ import { useStore } from '@nanostores/react'
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
|
||||
import { MonitorPlay } from '@/lib/icons'
|
||||
import { normalizeOrLocalPreviewTarget } from '@/lib/local-preview'
|
||||
import { previewName } from '@/lib/preview-targets'
|
||||
import { notifyError } from '@/store/notifications'
|
||||
import { $previewTarget, setPreviewTarget } from '@/store/preview'
|
||||
import { $previewTarget, dismissPreviewTarget, type PreviewRecordSource, setCurrentSessionPreviewTarget } from '@/store/preview'
|
||||
import { $currentCwd } from '@/store/session'
|
||||
|
||||
export function PreviewAttachment({ target }: { target: string }) {
|
||||
export function PreviewAttachment({ source = 'manual', target }: { source?: PreviewRecordSource; target: string }) {
|
||||
const cwd = useStore($currentCwd)
|
||||
const activePreview = useStore($previewTarget)
|
||||
const [opening, setOpening] = useState(false)
|
||||
@@ -37,37 +38,13 @@ export function PreviewAttachment({ target }: { target: string }) {
|
||||
setOpening(false)
|
||||
}, [cwd, target])
|
||||
|
||||
function localFallbackPreview(value: string) {
|
||||
if (/^https?:\/\//i.test(value)) {
|
||||
return { kind: 'url' as const, label: previewName(value), source: value, url: value }
|
||||
}
|
||||
|
||||
if (/^file:\/\//i.test(value)) {
|
||||
return { kind: 'file' as const, label: previewName(value), source: value, url: value }
|
||||
}
|
||||
|
||||
if (/^(?:\/|\.{1,2}\/|~\/).+\.html?$/i.test(value)) {
|
||||
const path = value.startsWith('file://') ? value : `file://${encodeURI(value)}`
|
||||
|
||||
return { kind: 'file' as const, label: previewName(value), source: value, url: path }
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
function isMissingPreviewIpc(error: unknown): boolean {
|
||||
const message = error instanceof Error ? error.message : typeof error === 'string' ? error : ''
|
||||
|
||||
return message.includes("No handler registered for 'hermes:normalizePreviewTarget'")
|
||||
}
|
||||
|
||||
async function togglePreview() {
|
||||
if (opening) {
|
||||
return
|
||||
}
|
||||
|
||||
if (isActive) {
|
||||
setPreviewTarget(null)
|
||||
dismissPreviewTarget()
|
||||
|
||||
return
|
||||
}
|
||||
@@ -79,15 +56,7 @@ export function PreviewAttachment({ target }: { target: string }) {
|
||||
setOpening(true)
|
||||
|
||||
try {
|
||||
const preview = await window.hermesDesktop
|
||||
?.normalizePreviewTarget(requestTarget, requestCwd || undefined)
|
||||
.catch(error => {
|
||||
if (isMissingPreviewIpc(error)) {
|
||||
return localFallbackPreview(requestTarget)
|
||||
}
|
||||
|
||||
throw error
|
||||
})
|
||||
const preview = await normalizeOrLocalPreviewTarget(requestTarget, requestCwd || undefined)
|
||||
|
||||
if (
|
||||
!mountedRef.current ||
|
||||
@@ -108,7 +77,7 @@ export function PreviewAttachment({ target }: { target: string }) {
|
||||
return
|
||||
}
|
||||
|
||||
setPreviewTarget(preview)
|
||||
setCurrentSessionPreviewTarget(preview, source, requestTarget)
|
||||
} catch (error) {
|
||||
if (
|
||||
!mountedRef.current ||
|
||||
@@ -128,21 +97,21 @@ export function PreviewAttachment({ target }: { target: string }) {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="inline-flex max-w-[min(100%,32rem)] items-center gap-3 rounded-xl border border-border/70 bg-card/70 p-3 text-sm">
|
||||
<div className="grid size-9 shrink-0 place-items-center rounded-lg bg-accent text-muted-foreground">
|
||||
<MonitorPlay className="size-4" />
|
||||
</div>
|
||||
<div className="min-w-0 max-w-64">
|
||||
<div className="truncate font-medium text-foreground">{name}</div>
|
||||
<div className="truncate font-mono text-xs text-muted-foreground">{target}</div>
|
||||
<div className="flex w-full max-w-160 flex-wrap items-center gap-2.5 rounded-lg border border-border/55 bg-card/55 px-2.5 py-1.5 text-sm">
|
||||
<span className="grid size-7 shrink-0 place-items-center rounded-md bg-muted/55 text-muted-foreground/85">
|
||||
<MonitorPlay className="size-3.5" />
|
||||
</span>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="truncate text-[0.78rem] font-medium leading-[1.15rem] text-foreground/90">{name}</div>
|
||||
<div className="truncate font-mono text-[0.66rem] leading-4 text-muted-foreground/70">{target}</div>
|
||||
</div>
|
||||
<button
|
||||
className="shrink-0 rounded-lg border border-border/70 px-2.5 py-1 text-xs font-medium text-muted-foreground transition-colors hover:bg-accent hover:text-foreground disabled:opacity-50"
|
||||
className="ml-auto shrink-0 rounded-md border border-border/55 bg-background/40 px-2 py-1 text-[0.7rem] font-medium text-muted-foreground transition-colors hover:bg-accent/55 hover:text-foreground disabled:opacity-50 max-[28rem]:ml-9 max-[28rem]:w-[calc(100%-2.25rem)]"
|
||||
disabled={opening}
|
||||
onClick={() => void togglePreview()}
|
||||
type="button"
|
||||
>
|
||||
{opening ? 'Opening...' : isActive ? 'Hide Preview' : 'Toggle Preview'}
|
||||
{opening ? 'Opening…' : isActive ? 'Hide' : 'Open preview'}
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -85,6 +85,23 @@ function assistantMessage(text: string, running = true): ThreadMessage {
|
||||
} as ThreadMessage
|
||||
}
|
||||
|
||||
function assistantReasoningMessage(text: string): ThreadMessage {
|
||||
return {
|
||||
id: 'assistant-reasoning-1',
|
||||
role: 'assistant',
|
||||
content: [{ type: 'reasoning', text }],
|
||||
status: { type: 'complete', reason: 'stop' },
|
||||
createdAt,
|
||||
metadata: {
|
||||
unstable_state: null,
|
||||
unstable_annotations: [],
|
||||
unstable_data: [],
|
||||
steps: [],
|
||||
custom: {}
|
||||
}
|
||||
} as ThreadMessage
|
||||
}
|
||||
|
||||
function StreamingHarness() {
|
||||
const [messages, setMessages] = useState<ThreadMessage[]>([userMessage()])
|
||||
const [isRunning, setIsRunning] = useState(true)
|
||||
@@ -123,6 +140,20 @@ function StreamingHarness() {
|
||||
)
|
||||
}
|
||||
|
||||
function ReasoningHarness() {
|
||||
const runtime = useExternalStoreRuntime<ThreadMessage>({
|
||||
messages: [assistantReasoningMessage(' The user is asking what this file is.')],
|
||||
isRunning: false,
|
||||
onNew: async () => {}
|
||||
})
|
||||
|
||||
return (
|
||||
<AssistantRuntimeProvider runtime={runtime}>
|
||||
<Thread />
|
||||
</AssistantRuntimeProvider>
|
||||
)
|
||||
}
|
||||
|
||||
describe('assistant-ui streaming renderer', () => {
|
||||
beforeEach(() => {
|
||||
resizeObservers.clear()
|
||||
@@ -157,7 +188,8 @@ describe('assistant-ui streaming renderer', () => {
|
||||
it('does not pull the viewport back down after the user scrolls up during streaming', async () => {
|
||||
const { container } = render(<StreamingHarness />)
|
||||
|
||||
const viewport = container.querySelector('[data-slot="aui_thread-viewport"]') as HTMLDivElement
|
||||
const content = container.querySelector('[data-slot="aui_thread-content"]') as HTMLDivElement
|
||||
const viewport = content.parentElement as HTMLDivElement
|
||||
let scrollHeight = 1_000
|
||||
|
||||
Object.defineProperty(viewport, 'clientHeight', { configurable: true, value: 200 })
|
||||
@@ -191,4 +223,14 @@ describe('assistant-ui streaming renderer', () => {
|
||||
|
||||
expect(viewport.scrollTop).toBe(420)
|
||||
})
|
||||
|
||||
it('renders reasoning text without a leading token space', () => {
|
||||
const { container } = render(<ReasoningHarness />)
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: /thinking/i }))
|
||||
|
||||
expect(container.querySelector('[data-slot="aui_reasoning-text"]')?.textContent).toBe(
|
||||
'The user is asking what this file is.'
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -28,7 +28,7 @@ import { StickToBottom, useStickToBottomContext } from 'use-stick-to-bottom'
|
||||
import { useElapsedSeconds } from '@/components/assistant-ui/activity-timer'
|
||||
import { ActivityTimerText } from '@/components/assistant-ui/activity-timer-text'
|
||||
import { ClarifyTool } from '@/components/assistant-ui/clarify-tool'
|
||||
import { DirectiveText } from '@/components/assistant-ui/directive-text'
|
||||
import { DirectiveContent, DirectiveText } from '@/components/assistant-ui/directive-text'
|
||||
import { GeneratedImageProvider, useGeneratedImageContext } from '@/components/assistant-ui/generated-image-context'
|
||||
import { ImageGenerationPlaceholder } from '@/components/assistant-ui/image-generation-placeholder'
|
||||
import { Intro, type IntroProps } from '@/components/assistant-ui/intro'
|
||||
@@ -36,6 +36,7 @@ import { MarkdownText } from '@/components/assistant-ui/markdown-text'
|
||||
import { PreviewAttachment } from '@/components/assistant-ui/preview-attachment'
|
||||
import { ToolFallback } from '@/components/assistant-ui/tool-fallback'
|
||||
import { TooltipIconButton } from '@/components/assistant-ui/tooltip-icon-button'
|
||||
import { CopyButton } from '@/components/ui/copy-button'
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
@@ -49,7 +50,6 @@ import {
|
||||
CheckIcon,
|
||||
ChevronLeftIcon,
|
||||
ChevronRightIcon,
|
||||
CopyIcon,
|
||||
GitBranchIcon,
|
||||
Loader2Icon,
|
||||
MoreHorizontalIcon,
|
||||
@@ -137,7 +137,7 @@ export const Thread: FC<{
|
||||
>
|
||||
<ThreadScrollSync sessionKey={sessionKey} />
|
||||
<StickToBottom.Content
|
||||
className="mx-auto flex w-full max-w-[48rem] min-w-0 flex-col gap-3 px-4 pt-[calc(var(--vsq)*19)] sm:px-6 lg:px-8"
|
||||
className="mx-auto flex w-full max-w-3xl min-w-0 flex-col gap-3 px-4 pt-[calc(var(--vsq)*19)] sm:px-6 lg:px-8"
|
||||
data-slot="aui_thread-content"
|
||||
scrollClassName="overflow-x-hidden overflow-y-auto overscroll-contain"
|
||||
>
|
||||
@@ -371,6 +371,10 @@ const ComposerClearance: FC = () => {
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
if (typeof document === 'undefined') {
|
||||
return
|
||||
}
|
||||
|
||||
let composerObserver: ResizeObserver | null = null
|
||||
let observedComposer: HTMLElement | null = null
|
||||
|
||||
@@ -385,6 +389,10 @@ const ComposerClearance: FC = () => {
|
||||
}
|
||||
|
||||
const bindComposer = () => {
|
||||
if (typeof document === 'undefined') {
|
||||
return false
|
||||
}
|
||||
|
||||
const composer = document.querySelector<HTMLElement>('[data-slot="composer-root"]')
|
||||
|
||||
if (!composer || composer === observedComposer) {
|
||||
@@ -471,7 +479,7 @@ const AssistantMessage: FC<{ onBranchInNewChat?: (messageId: string) => void }>
|
||||
{previewTargets.length > 0 && (
|
||||
<div className="mt-3 flex flex-wrap gap-2">
|
||||
{previewTargets.map(target => (
|
||||
<PreviewAttachment key={target} target={target} />
|
||||
<PreviewAttachment key={target} source="explicit-link" target={target} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
@@ -563,42 +571,59 @@ const ThinkingDisclosure: FC<{
|
||||
const elapsed = useElapsedSeconds(pending)
|
||||
|
||||
return (
|
||||
<div className="mb-2 text-sm text-muted-foreground">
|
||||
<div className="text-sm text-muted-foreground" data-slot="tool-block">
|
||||
<button
|
||||
aria-expanded={open}
|
||||
className="inline-flex max-w-full items-center gap-1 rounded-md py-0.5 pr-1 text-left text-muted-foreground transition-colors hover:bg-accent hover:text-foreground"
|
||||
className="group/thinking-row flex w-full max-w-full min-w-0 items-start gap-2 rounded-md px-2 py-0.5 text-left text-muted-foreground transition-colors hover:bg-accent/35 hover:text-foreground"
|
||||
onClick={() => setOpen(value => !value)}
|
||||
type="button"
|
||||
>
|
||||
<ChevronRightIcon
|
||||
className={cn('size-3 shrink-0 text-muted-foreground/80 transition-transform', open && 'rotate-90')}
|
||||
/>
|
||||
<span
|
||||
className={cn('shrink-0 text-xs font-medium text-foreground/70', pending && 'shimmer text-foreground/55')}
|
||||
>
|
||||
Thinking
|
||||
<span className="flex h-[1.1rem] shrink-0 items-center">
|
||||
<ChevronRightIcon
|
||||
className={cn(
|
||||
'size-3 text-muted-foreground/55 transition-transform group-hover/thinking-row:text-muted-foreground/85',
|
||||
open && 'rotate-90'
|
||||
)}
|
||||
/>
|
||||
</span>
|
||||
<span className="flex min-w-0 flex-1 items-baseline gap-1.5">
|
||||
<span
|
||||
className={cn(
|
||||
'text-[0.78rem] font-medium leading-[1.1rem] text-foreground/75',
|
||||
pending && 'shimmer text-foreground/55'
|
||||
)}
|
||||
>
|
||||
Thinking
|
||||
</span>
|
||||
{pending && (
|
||||
<ActivityTimerText className="text-[0.625rem] tabular-nums text-muted-foreground/55" seconds={elapsed} />
|
||||
)}
|
||||
</span>
|
||||
{pending && <ActivityTimerText seconds={elapsed} />}
|
||||
</button>
|
||||
{open && <div className="ml-4 mt-1 max-w-full wrap-anywhere border-l border-border pl-3">{children}</div>}
|
||||
{open && (
|
||||
<div className="mt-2 w-full min-w-0 max-w-full overflow-hidden pl-6 pr-2 wrap-anywhere pb-1">{children}</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const ReasoningPart: FC<{ text: string; status?: { type: string } }> = ({ text, status }) => (
|
||||
<div className="mb-1 mt-1">
|
||||
const ReasoningPart: FC<{ text: string; status?: { type: string } }> = ({ text, status }) => {
|
||||
const displayText = text.trimStart()
|
||||
|
||||
return (
|
||||
<ThinkingDisclosure pending={status?.type === 'running'}>
|
||||
<div
|
||||
className={cn(
|
||||
'whitespace-pre-wrap text-xs leading-relaxed text-muted-foreground/85',
|
||||
status?.type === 'running' && 'shimmer text-muted-foreground/55'
|
||||
)}
|
||||
data-slot="aui_reasoning-text"
|
||||
>
|
||||
{text}
|
||||
{displayText}
|
||||
</div>
|
||||
</ThinkingDisclosure>
|
||||
</div>
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
const TIME_FMT = new Intl.DateTimeFormat(undefined, { hour: 'numeric', minute: '2-digit' })
|
||||
|
||||
@@ -679,27 +704,15 @@ const AssistantActionBar: FC<MessageActionProps> = ({ messageId, messageText, on
|
||||
}
|
||||
|
||||
const CopyMessageButton: FC<{ text: string }> = ({ text }) => {
|
||||
const [copied, setCopied] = useState(false)
|
||||
|
||||
const copy = useCallback(async () => {
|
||||
if (!text) {
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
await navigator.clipboard.writeText(text)
|
||||
triggerHaptic('selection')
|
||||
setCopied(true)
|
||||
window.setTimeout(() => setCopied(false), 2000)
|
||||
} catch (error) {
|
||||
notifyError(error, 'Copy failed')
|
||||
}
|
||||
}, [text])
|
||||
|
||||
return (
|
||||
<TooltipIconButton disabled={!text} onClick={() => void copy()} tooltip={copied ? 'Copied' : 'Copy'}>
|
||||
{copied ? <CheckIcon /> : <CopyIcon />}
|
||||
</TooltipIconButton>
|
||||
<CopyButton
|
||||
appearance="icon"
|
||||
buttonSize="icon"
|
||||
className="aui-button-icon size-6 p-1"
|
||||
disabled={!text}
|
||||
label="Copy"
|
||||
text={text}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -774,18 +787,45 @@ const AssistantFooter: FC<MessageActionProps> = props => (
|
||||
const branchButtonClass =
|
||||
'grid size-6 place-items-center rounded-md text-muted-foreground transition-colors hover:bg-accent hover:text-foreground disabled:opacity-35'
|
||||
|
||||
const EMPTY_ATTACHMENT_REFS: string[] = []
|
||||
|
||||
function messageAttachmentRefs(value: unknown): string[] {
|
||||
if (!Array.isArray(value)) {
|
||||
return EMPTY_ATTACHMENT_REFS
|
||||
}
|
||||
|
||||
return value.every(ref => typeof ref === 'string') ? value : EMPTY_ATTACHMENT_REFS
|
||||
}
|
||||
|
||||
const UserMessage: FC = () => {
|
||||
const content = useAuiState(s => s.message.content)
|
||||
const messageText = messageContentText(content)
|
||||
|
||||
const attachmentRefs = useAuiState(s => {
|
||||
const custom = (s.message.metadata?.custom ?? {}) as { attachmentRefs?: unknown }
|
||||
|
||||
return messageAttachmentRefs(custom.attachmentRefs)
|
||||
})
|
||||
|
||||
const hasBody = messageText.trim().length > 0
|
||||
|
||||
return (
|
||||
<MessagePrimitive.Root
|
||||
className="group flex min-w-0 max-w-[min(72%,34rem)] flex-col items-end gap-2 self-end overflow-hidden"
|
||||
data-role="user"
|
||||
data-slot="aui_user-message-root"
|
||||
>
|
||||
<div className="wrap-anywhere max-w-full overflow-hidden whitespace-pre-line rounded-2xl border border-[color-mix(in_srgb,var(--dt-user-bubble-border)_78%,transparent)] bg-[color-mix(in_srgb,var(--dt-user-bubble)_94%,transparent)] px-3 py-2 leading-[1.48] text-foreground/95">
|
||||
<MessagePrimitive.Parts components={{ Text: DirectiveText }} />
|
||||
<div className="flex min-w-0 max-w-full flex-col gap-1.5 overflow-hidden rounded-2xl border border-[color-mix(in_srgb,var(--dt-user-bubble-border)_78%,transparent)] bg-[color-mix(in_srgb,var(--dt-user-bubble)_94%,transparent)] px-3 py-2 leading-[1.48] text-foreground/95">
|
||||
{attachmentRefs.length > 0 && (
|
||||
<div className="-mx-1 flex flex-wrap gap-1 border-b border-border/45 pb-1.5">
|
||||
<DirectiveContent text={attachmentRefs.join(' ')} />
|
||||
</div>
|
||||
)}
|
||||
{hasBody && (
|
||||
<div className="wrap-anywhere whitespace-pre-line">
|
||||
<MessagePrimitive.Parts components={{ Text: DirectiveText }} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="min-h-6">
|
||||
<UserActionBar messageText={messageText} />
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -102,13 +102,13 @@ export function ZoomableImage({ className, containerClassName, src, alt, slot, .
|
||||
const lightbox = src ? (
|
||||
<Dialog onOpenChange={setLightboxOpen} open={lightboxOpen}>
|
||||
<DialogContent
|
||||
className="grid max-h-[calc(100vh-2rem)] w-auto max-w-[calc(100vw-2rem)] place-items-center overflow-visible border-0 bg-transparent p-0 shadow-none"
|
||||
className="block w-auto max-h-[calc(100vh-12rem)] max-w-[calc(100vw-12rem)] overflow-visible border-0 bg-transparent p-0 shadow-none"
|
||||
showCloseButton={false}
|
||||
>
|
||||
<div className="group/lightbox relative max-h-[calc(100vh-2rem)] max-w-[calc(100vw-2rem)] overflow-auto">
|
||||
<div className="group/lightbox relative inline-block">
|
||||
<img
|
||||
alt={alt ?? ''}
|
||||
className="block max-h-[calc(100vh-2rem)] max-w-full cursor-zoom-out select-auto rounded-lg object-contain shadow-2xl"
|
||||
className="block max-h-[calc(100vh-12rem)] max-w-[calc(100vw-12rem)] cursor-zoom-out select-auto rounded-lg object-contain shadow-2xl"
|
||||
onClick={() => setLightboxOpen(false)}
|
||||
src={src}
|
||||
/>
|
||||
|
||||
@@ -2,8 +2,9 @@ import { useStore } from '@nanostores/react'
|
||||
import { type ReactNode, useEffect, useRef, useState } from 'react'
|
||||
|
||||
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'
|
||||
import { CopyButton } from '@/components/ui/copy-button'
|
||||
import { triggerHaptic } from '@/lib/haptics'
|
||||
import { AlertCircle, AlertTriangle, CheckCircle2, Copy, Info, type LucideIcon, X } from '@/lib/icons'
|
||||
import { AlertCircle, AlertTriangle, CheckCircle2, Info, type LucideIcon, X } from '@/lib/icons'
|
||||
import { cn } from '@/lib/utils'
|
||||
import {
|
||||
$notifications,
|
||||
@@ -116,18 +117,6 @@ function NotificationItem({ notification }: { notification: AppNotification }) {
|
||||
}
|
||||
|
||||
function NotificationDetail({ detail }: { detail: string }) {
|
||||
const [copied, setCopied] = useState(false)
|
||||
|
||||
async function copyDetail() {
|
||||
try {
|
||||
await navigator.clipboard.writeText(detail)
|
||||
setCopied(true)
|
||||
window.setTimeout(() => setCopied(false), 1200)
|
||||
} catch {
|
||||
// Best effort; details remain visible even if clipboard access fails.
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<details className="mt-2 text-xs text-muted-foreground">
|
||||
<summary className="cursor-pointer select-none font-medium text-muted-foreground hover:text-foreground">
|
||||
@@ -137,14 +126,16 @@ function NotificationDetail({ detail }: { detail: string }) {
|
||||
<pre className="max-h-32 whitespace-pre-wrap wrap-break-word font-mono text-[0.6875rem] leading-relaxed">
|
||||
{detail}
|
||||
</pre>
|
||||
<button
|
||||
<CopyButton
|
||||
appearance="inline"
|
||||
className="mt-1 inline-flex items-center gap-1 rounded px-1.5 py-0.5 text-[0.6875rem] text-muted-foreground hover:bg-accent hover:text-foreground"
|
||||
onClick={copyDetail}
|
||||
type="button"
|
||||
errorMessage="Could not copy notification detail"
|
||||
iconClassName="size-3"
|
||||
label="Copy detail"
|
||||
text={detail}
|
||||
>
|
||||
<Copy className="size-3" />
|
||||
{copied ? 'Copied' : 'Copy detail'}
|
||||
</button>
|
||||
Copy detail
|
||||
</CopyButton>
|
||||
</div>
|
||||
</details>
|
||||
)
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
import { createContext } from 'react'
|
||||
|
||||
export interface PaneSlot {
|
||||
column: number
|
||||
side: 'left' | 'right'
|
||||
open: boolean
|
||||
}
|
||||
|
||||
export interface PaneShellContextValue {
|
||||
paneById: Map<string, PaneSlot>
|
||||
mainColumn: number
|
||||
}
|
||||
|
||||
export const PaneShellContext = createContext<PaneShellContextValue | null>(null)
|
||||
@@ -0,0 +1,4 @@
|
||||
export type { PaneShellContextValue, PaneSlot } from './context'
|
||||
export { PaneShellContext } from './context'
|
||||
export { Pane, PaneMain, PaneShell } from './pane-shell'
|
||||
export type { PaneMainProps, PaneProps, PaneShellProps } from './pane-shell'
|
||||
@@ -0,0 +1,247 @@
|
||||
import { cleanup, render } from '@testing-library/react'
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
|
||||
|
||||
import { $paneStates, setPaneOpen, setPaneWidthOverride } from '@/store/panes'
|
||||
|
||||
import { Pane, PaneMain, PaneShell } from './pane-shell'
|
||||
|
||||
function gridContainer(rendered: ReturnType<typeof render>): HTMLElement {
|
||||
const root = rendered.container.firstElementChild
|
||||
|
||||
if (!(root instanceof HTMLElement)) {
|
||||
throw new Error('PaneShell did not render a root element')
|
||||
}
|
||||
|
||||
return root
|
||||
}
|
||||
|
||||
function getColumnTemplate(container: HTMLElement): string[] {
|
||||
return (container.style.gridTemplateColumns ?? '').split(/\s+/).filter(Boolean)
|
||||
}
|
||||
|
||||
describe('PaneShell composition', () => {
|
||||
beforeEach(() => {
|
||||
$paneStates.set({})
|
||||
window.localStorage.clear()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
cleanup()
|
||||
$paneStates.set({})
|
||||
window.localStorage.clear()
|
||||
})
|
||||
|
||||
it('builds a 2-column grid for one left pane + main', () => {
|
||||
const rendered = render(
|
||||
<PaneShell>
|
||||
<Pane id="files" side="left" width="240px">
|
||||
files
|
||||
</Pane>
|
||||
<PaneMain>main</PaneMain>
|
||||
</PaneShell>
|
||||
)
|
||||
|
||||
const tracks = getColumnTemplate(gridContainer(rendered))
|
||||
|
||||
expect(tracks).toEqual(['240px', 'minmax(0,1fr)'])
|
||||
})
|
||||
|
||||
it('orders panes left-to-right by side, preserving source order within a side', () => {
|
||||
const rendered = render(
|
||||
<PaneShell>
|
||||
<Pane id="files" side="left" width="240px">
|
||||
files
|
||||
</Pane>
|
||||
<Pane id="sessions" side="left" width="200px">
|
||||
sessions
|
||||
</Pane>
|
||||
<PaneMain>main</PaneMain>
|
||||
<Pane id="preview" side="right" width="320px">
|
||||
preview
|
||||
</Pane>
|
||||
<Pane id="inspector" side="right" width="280px">
|
||||
inspector
|
||||
</Pane>
|
||||
</PaneShell>
|
||||
)
|
||||
|
||||
const tracks = getColumnTemplate(gridContainer(rendered))
|
||||
|
||||
expect(tracks).toEqual(['240px', '200px', 'minmax(0,1fr)', '320px', '280px'])
|
||||
})
|
||||
|
||||
it('collapses a closed pane to 0px', () => {
|
||||
const rendered = render(
|
||||
<PaneShell>
|
||||
<Pane defaultOpen={false} id="files" side="left" width="240px">
|
||||
files
|
||||
</Pane>
|
||||
<PaneMain>main</PaneMain>
|
||||
</PaneShell>
|
||||
)
|
||||
|
||||
const tracks = getColumnTemplate(gridContainer(rendered))
|
||||
|
||||
expect(tracks).toEqual(['0px', 'minmax(0,1fr)'])
|
||||
})
|
||||
|
||||
it('reads open state from the panes store', () => {
|
||||
setPaneOpen('files', false)
|
||||
|
||||
const rendered = render(
|
||||
<PaneShell>
|
||||
<Pane id="files" side="left" width="240px">
|
||||
files
|
||||
</Pane>
|
||||
<PaneMain>main</PaneMain>
|
||||
</PaneShell>
|
||||
)
|
||||
|
||||
expect(getColumnTemplate(gridContainer(rendered))).toEqual(['0px', 'minmax(0,1fr)'])
|
||||
})
|
||||
|
||||
it('disabled forces the track to 0px even when the store says open', () => {
|
||||
setPaneOpen('files', true)
|
||||
|
||||
const rendered = render(
|
||||
<PaneShell>
|
||||
<Pane disabled={true} id="files" side="left" width="240px">
|
||||
files
|
||||
</Pane>
|
||||
<PaneMain>main</PaneMain>
|
||||
</PaneShell>
|
||||
)
|
||||
|
||||
expect(getColumnTemplate(gridContainer(rendered))).toEqual(['0px', 'minmax(0,1fr)'])
|
||||
})
|
||||
|
||||
it('disabled does NOT mutate the store-persisted open state', () => {
|
||||
setPaneOpen('files', true)
|
||||
|
||||
render(
|
||||
<PaneShell>
|
||||
<Pane disabled={true} id="files" side="left" width="240px">
|
||||
files
|
||||
</Pane>
|
||||
<PaneMain>main</PaneMain>
|
||||
</PaneShell>
|
||||
)
|
||||
|
||||
expect($paneStates.get().files?.open).toBe(true)
|
||||
})
|
||||
|
||||
it('uses widthOverride from the store when set', () => {
|
||||
setPaneOpen('files', true)
|
||||
setPaneWidthOverride('files', 320)
|
||||
|
||||
const rendered = render(
|
||||
<PaneShell>
|
||||
<Pane id="files" side="left" width="240px">
|
||||
files
|
||||
</Pane>
|
||||
<PaneMain>main</PaneMain>
|
||||
</PaneShell>
|
||||
)
|
||||
|
||||
expect(getColumnTemplate(gridContainer(rendered))).toEqual(['320px', 'minmax(0,1fr)'])
|
||||
})
|
||||
|
||||
it('preserves CSS-string widths verbatim (clamp, var, etc.)', () => {
|
||||
const rendered = render(
|
||||
<PaneShell>
|
||||
<Pane id="inspector" side="right" width="clamp(13.5rem,21vw,20rem)">
|
||||
inspector
|
||||
</Pane>
|
||||
<PaneMain>main</PaneMain>
|
||||
</PaneShell>
|
||||
)
|
||||
|
||||
const template = gridContainer(rendered).style.gridTemplateColumns
|
||||
|
||||
expect(template).toContain('clamp(13.5rem,21vw,20rem)')
|
||||
})
|
||||
|
||||
it('coerces numeric widths to px', () => {
|
||||
const rendered = render(
|
||||
<PaneShell>
|
||||
<Pane id="files" side="left" width={224}>
|
||||
files
|
||||
</Pane>
|
||||
<PaneMain>main</PaneMain>
|
||||
</PaneShell>
|
||||
)
|
||||
|
||||
expect(getColumnTemplate(gridContainer(rendered))).toEqual(['224px', 'minmax(0,1fr)'])
|
||||
})
|
||||
|
||||
it('emits per-pane width as a CSS variable', () => {
|
||||
const rendered = render(
|
||||
<PaneShell>
|
||||
<Pane id="files" side="left" width="240px">
|
||||
files
|
||||
</Pane>
|
||||
<PaneMain>main</PaneMain>
|
||||
</PaneShell>
|
||||
)
|
||||
|
||||
const root = gridContainer(rendered)
|
||||
|
||||
expect(root.style.getPropertyValue('--pane-files-width').trim()).toBe('240px')
|
||||
})
|
||||
|
||||
it('places a Pane in the correct grid column via inline style', () => {
|
||||
const rendered = render(
|
||||
<PaneShell>
|
||||
<Pane id="files" side="left" width="240px">
|
||||
<span data-testid="files-content">files</span>
|
||||
</Pane>
|
||||
<PaneMain>
|
||||
<span data-testid="main-content">main</span>
|
||||
</PaneMain>
|
||||
<Pane id="preview" side="right" width="320px">
|
||||
<span data-testid="preview-content">preview</span>
|
||||
</Pane>
|
||||
</PaneShell>
|
||||
)
|
||||
|
||||
const filesCell = rendered.getByTestId('files-content').parentElement!
|
||||
const mainCell = rendered.getByTestId('main-content').parentElement!
|
||||
const previewCell = rendered.getByTestId('preview-content').parentElement!
|
||||
|
||||
expect(filesCell.style.gridColumn).toBe('1 / 2')
|
||||
expect(mainCell.style.gridColumn).toBe('2 / 3')
|
||||
expect(previewCell.style.gridColumn).toBe('3 / 4')
|
||||
})
|
||||
|
||||
it('marks closed panes aria-hidden', () => {
|
||||
const rendered = render(
|
||||
<PaneShell>
|
||||
<Pane defaultOpen={false} id="files" side="left" width="240px">
|
||||
<span data-testid="files-content">files</span>
|
||||
</Pane>
|
||||
<PaneMain>main</PaneMain>
|
||||
</PaneShell>
|
||||
)
|
||||
|
||||
const cell = rendered.getByTestId('files-content').parentElement!
|
||||
|
||||
expect(cell.getAttribute('aria-hidden')).toBe('true')
|
||||
expect(cell.getAttribute('data-pane-open')).toBe('false')
|
||||
})
|
||||
|
||||
it('passes through arbitrary non-Pane children for self-placement', () => {
|
||||
const rendered = render(
|
||||
<PaneShell>
|
||||
<Pane id="files" side="left" width="240px">
|
||||
files
|
||||
</Pane>
|
||||
<PaneMain>main</PaneMain>
|
||||
<div data-testid="floating-overlay" style={{ position: 'absolute' }}>
|
||||
overlay
|
||||
</div>
|
||||
</PaneShell>
|
||||
)
|
||||
|
||||
expect(rendered.getByTestId('floating-overlay')).toBeDefined()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,220 @@
|
||||
import { useStore } from '@nanostores/react'
|
||||
import {
|
||||
Children,
|
||||
type CSSProperties,
|
||||
isValidElement,
|
||||
type ReactElement,
|
||||
type ReactNode,
|
||||
useContext,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef
|
||||
} from 'react'
|
||||
|
||||
import { cn } from '@/lib/utils'
|
||||
import { $paneStates, ensurePaneRegistered } from '@/store/panes'
|
||||
|
||||
import { PaneShellContext, type PaneShellContextValue, type PaneSlot } from './context'
|
||||
|
||||
type PaneSide = 'left' | 'right'
|
||||
type WidthValue = string | number
|
||||
|
||||
interface PaneRoleMarker {
|
||||
__paneShellRole?: 'pane' | 'main'
|
||||
}
|
||||
|
||||
export interface PaneProps {
|
||||
children?: ReactNode
|
||||
className?: string
|
||||
defaultOpen?: boolean
|
||||
/** Forces the pane closed (track→0, aria-hidden) without writing to the store — for transient route gates. */
|
||||
disabled?: boolean
|
||||
id: string
|
||||
maxWidth?: WidthValue
|
||||
minWidth?: WidthValue
|
||||
resizable?: boolean
|
||||
side: PaneSide
|
||||
width?: WidthValue
|
||||
}
|
||||
|
||||
export interface PaneMainProps {
|
||||
children?: ReactNode
|
||||
className?: string
|
||||
}
|
||||
|
||||
export interface PaneShellProps {
|
||||
children?: ReactNode
|
||||
className?: string
|
||||
style?: CSSProperties
|
||||
}
|
||||
|
||||
interface CollectedPane {
|
||||
defaultOpen: boolean
|
||||
disabled: boolean
|
||||
id: string
|
||||
side: PaneSide
|
||||
width: string
|
||||
}
|
||||
|
||||
const DEFAULT_WIDTH = '16rem'
|
||||
|
||||
const widthToCss = (value: WidthValue | undefined, fallback: string) =>
|
||||
value === undefined ? fallback : typeof value === 'number' ? `${value}px` : value
|
||||
|
||||
function isRole(child: unknown, role: 'pane' | 'main'): child is ReactElement {
|
||||
return isValidElement(child) && (child.type as PaneRoleMarker)?.__paneShellRole === role
|
||||
}
|
||||
|
||||
function collectPanes(children: ReactNode) {
|
||||
const left: CollectedPane[] = []
|
||||
const right: CollectedPane[] = []
|
||||
let mainCount = 0
|
||||
|
||||
Children.forEach(children, child => {
|
||||
if (isRole(child, 'main')) {
|
||||
mainCount++
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
if (!isRole(child, 'pane')) {return}
|
||||
|
||||
const props = child.props as PaneProps
|
||||
|
||||
const entry: CollectedPane = {
|
||||
defaultOpen: props.defaultOpen ?? true,
|
||||
disabled: props.disabled ?? false,
|
||||
id: props.id,
|
||||
side: props.side,
|
||||
width: widthToCss(props.width, DEFAULT_WIDTH)
|
||||
}
|
||||
|
||||
;(props.side === 'left' ? left : right).push(entry)
|
||||
})
|
||||
|
||||
return { left, mainCount, right }
|
||||
}
|
||||
|
||||
function trackForPane(pane: CollectedPane, states: Record<string, { open: boolean; widthOverride?: number }>) {
|
||||
const stateOpen = states[pane.id]?.open ?? pane.defaultOpen
|
||||
const open = !pane.disabled && stateOpen
|
||||
|
||||
if (!open) {return { open: false, track: '0px' }}
|
||||
|
||||
const override = states[pane.id]?.widthOverride
|
||||
|
||||
return { open: true, track: override !== undefined ? `${override}px` : pane.width }
|
||||
}
|
||||
|
||||
export function PaneShell({ children, className, style }: PaneShellProps) {
|
||||
const paneStates = useStore($paneStates)
|
||||
const { left, mainCount, right } = useMemo(() => collectPanes(children), [children])
|
||||
|
||||
if (import.meta.env.DEV && mainCount > 1) {
|
||||
console.warn('[PaneShell] expected at most one <PaneMain>, got', mainCount)
|
||||
}
|
||||
|
||||
const ctxValue = useMemo(() => {
|
||||
const paneById = new Map<string, PaneSlot>()
|
||||
const tracks: string[] = []
|
||||
const cssVars: Record<string, string> = {}
|
||||
let column = 1
|
||||
|
||||
for (const pane of left) {
|
||||
const { open, track } = trackForPane(pane, paneStates)
|
||||
tracks.push(track)
|
||||
paneById.set(pane.id, { column, open, side: 'left' })
|
||||
cssVars[`--pane-${pane.id}-width`] = track
|
||||
column++
|
||||
}
|
||||
|
||||
tracks.push('minmax(0,1fr)')
|
||||
const mainColumn = column++
|
||||
|
||||
for (const pane of right) {
|
||||
const { open, track } = trackForPane(pane, paneStates)
|
||||
tracks.push(track)
|
||||
paneById.set(pane.id, { column, open, side: 'right' })
|
||||
cssVars[`--pane-${pane.id}-width`] = track
|
||||
column++
|
||||
}
|
||||
|
||||
return { cssVars, gridTemplate: tracks.join(' '), mainColumn, paneById } satisfies PaneShellContextValue & {
|
||||
cssVars: Record<string, string>
|
||||
gridTemplate: string
|
||||
}
|
||||
}, [left, paneStates, right])
|
||||
|
||||
const composedStyle = useMemo<CSSProperties>(
|
||||
() => ({ ...ctxValue.cssVars, ...style, gridTemplateColumns: ctxValue.gridTemplate }),
|
||||
[ctxValue.cssVars, ctxValue.gridTemplate, style]
|
||||
)
|
||||
|
||||
return (
|
||||
<PaneShellContext.Provider value={{ mainColumn: ctxValue.mainColumn, paneById: ctxValue.paneById }}>
|
||||
<div className={cn('relative grid h-full min-h-0', className)} style={composedStyle}>
|
||||
{children}
|
||||
</div>
|
||||
</PaneShellContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
export function Pane({ children, className, defaultOpen = true, disabled = false, id }: PaneProps) {
|
||||
const ctx = useContext(PaneShellContext)
|
||||
const registered = useRef(false)
|
||||
|
||||
useEffect(() => {
|
||||
if (registered.current) {return}
|
||||
registered.current = true
|
||||
ensurePaneRegistered(id, { open: defaultOpen })
|
||||
}, [defaultOpen, id])
|
||||
|
||||
if (!ctx) {
|
||||
if (import.meta.env.DEV) {console.warn(`[Pane:${id}] must be rendered inside <PaneShell>`)}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
const slot = ctx.paneById.get(id)
|
||||
|
||||
if (!slot) {return null}
|
||||
|
||||
const open = slot.open && !disabled
|
||||
|
||||
return (
|
||||
<div
|
||||
aria-hidden={!open}
|
||||
className={cn('row-start-1 min-w-0 overflow-hidden', !open && 'pointer-events-none', className)}
|
||||
data-pane-id={id}
|
||||
data-pane-open={open ? 'true' : 'false'}
|
||||
data-pane-side={slot.side}
|
||||
style={{ gridColumn: `${slot.column} / ${slot.column + 1}` }}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
;(Pane as unknown as PaneRoleMarker).__paneShellRole = 'pane'
|
||||
|
||||
export function PaneMain({ children, className }: PaneMainProps) {
|
||||
const ctx = useContext(PaneShellContext)
|
||||
|
||||
if (!ctx) {
|
||||
if (import.meta.env.DEV) {console.warn('[PaneMain] must be rendered inside <PaneShell>')}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn('row-start-1 flex min-h-0 min-w-0 flex-col overflow-hidden', className)}
|
||||
data-pane-main="true"
|
||||
style={{ gridColumn: `${ctx.mainColumn} / ${ctx.mainColumn + 1}` }}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
;(PaneMain as unknown as PaneRoleMarker).__paneShellRole = 'main'
|
||||
@@ -1,76 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import type { FC } from 'react'
|
||||
|
||||
import { AgentSection } from '@/app/chat/right-rail/agent-section'
|
||||
import { ProjectSection } from '@/app/chat/right-rail/project-section'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
export interface SessionInspectorProps {
|
||||
open: boolean
|
||||
cwd: string
|
||||
branch: string
|
||||
busy: boolean
|
||||
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'
|
||||
|
||||
export const SessionInspector: FC<SessionInspectorProps> = ({
|
||||
open,
|
||||
cwd,
|
||||
branch,
|
||||
busy,
|
||||
modelLabel,
|
||||
providerName,
|
||||
reasoningEffort,
|
||||
serviceTier,
|
||||
fastMode,
|
||||
personality,
|
||||
personalities,
|
||||
onChangeCwd,
|
||||
onBrowseCwd,
|
||||
onOpenModelPicker,
|
||||
onSetFastMode,
|
||||
onSetReasoningEffort,
|
||||
onSelectPersonality
|
||||
}) => (
|
||||
<aside
|
||||
aria-hidden={!open}
|
||||
className={cn(
|
||||
'relative flex h-full 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-none',
|
||||
open ? 'opacity-100' : 'pointer-events-none 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">
|
||||
<ProjectSection branch={branch} busy={busy} cwd={cwd} onBrowseCwd={onBrowseCwd} onChangeCwd={onChangeCwd} />
|
||||
<AgentSection
|
||||
fastMode={fastMode}
|
||||
modelLabel={modelLabel}
|
||||
onOpenModelPicker={onOpenModelPicker}
|
||||
onSelectPersonality={onSelectPersonality}
|
||||
onSetFastMode={onSetFastMode}
|
||||
onSetReasoningEffort={onSetReasoningEffort}
|
||||
personalities={personalities}
|
||||
personality={personality}
|
||||
providerName={providerName}
|
||||
reasoningEffort={reasoningEffort}
|
||||
serviceTier={serviceTier}
|
||||
/>
|
||||
</div>
|
||||
</aside>
|
||||
)
|
||||
@@ -0,0 +1,220 @@
|
||||
import * as React from 'react'
|
||||
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { DropdownMenuItem } from '@/components/ui/dropdown-menu'
|
||||
import { triggerHaptic } from '@/lib/haptics'
|
||||
import { Check, Copy, X } from '@/lib/icons'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
type CopyPayload = string | (() => Promise<string> | string)
|
||||
type CopyButtonAppearance = 'button' | 'icon' | 'inline' | 'menu-item' | 'tool-row'
|
||||
type CopyStatus = 'copied' | 'error' | 'idle'
|
||||
const COPIED_RESET_MS = 1_500
|
||||
|
||||
export async function writeClipboardText(text: string) {
|
||||
if (!text) {
|
||||
return
|
||||
}
|
||||
|
||||
if (window.hermesDesktop?.writeClipboard) {
|
||||
await window.hermesDesktop.writeClipboard(text)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
if (navigator.clipboard?.writeText) {
|
||||
await navigator.clipboard.writeText(text)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
throw new Error('Clipboard API is unavailable')
|
||||
}
|
||||
|
||||
export interface CopyButtonProps {
|
||||
appearance?: CopyButtonAppearance
|
||||
buttonSize?: React.ComponentProps<typeof Button>['size']
|
||||
buttonVariant?: React.ComponentProps<typeof Button>['variant']
|
||||
children?: React.ReactNode
|
||||
className?: string
|
||||
disabled?: boolean
|
||||
errorMessage?: string
|
||||
haptic?: boolean
|
||||
iconClassName?: string
|
||||
label?: string
|
||||
onCopied?: () => void
|
||||
onCopyError?: (error: unknown) => void
|
||||
preventDefault?: boolean
|
||||
showLabel?: boolean
|
||||
stopPropagation?: boolean
|
||||
text: CopyPayload
|
||||
title?: string
|
||||
}
|
||||
|
||||
export function CopyButton({
|
||||
appearance = 'button',
|
||||
buttonSize,
|
||||
buttonVariant = 'ghost',
|
||||
children,
|
||||
className,
|
||||
disabled = false,
|
||||
errorMessage = 'Copy failed',
|
||||
haptic = true,
|
||||
iconClassName,
|
||||
label = 'Copy',
|
||||
onCopied,
|
||||
onCopyError,
|
||||
preventDefault = false,
|
||||
showLabel,
|
||||
stopPropagation = false,
|
||||
text,
|
||||
title
|
||||
}: CopyButtonProps) {
|
||||
const [status, setStatus] = React.useState<CopyStatus>('idle')
|
||||
const resetRef = React.useRef<number | null>(null)
|
||||
|
||||
React.useEffect(() => {
|
||||
return () => {
|
||||
if (resetRef.current !== null) {
|
||||
window.clearTimeout(resetRef.current)
|
||||
}
|
||||
}
|
||||
}, [])
|
||||
|
||||
const copy = React.useCallback(
|
||||
async (event?: Event | React.MouseEvent<HTMLElement>) => {
|
||||
if (preventDefault) {
|
||||
event?.preventDefault()
|
||||
}
|
||||
|
||||
if (stopPropagation) {
|
||||
event?.stopPropagation()
|
||||
}
|
||||
|
||||
try {
|
||||
const value = typeof text === 'function' ? await text() : text
|
||||
|
||||
if (!value) {
|
||||
return
|
||||
}
|
||||
|
||||
await writeClipboardText(value)
|
||||
|
||||
if (haptic) {
|
||||
triggerHaptic('selection')
|
||||
}
|
||||
|
||||
if (resetRef.current !== null) {
|
||||
window.clearTimeout(resetRef.current)
|
||||
}
|
||||
|
||||
setStatus('copied')
|
||||
resetRef.current = window.setTimeout(() => {
|
||||
setStatus('idle')
|
||||
resetRef.current = null
|
||||
}, COPIED_RESET_MS)
|
||||
onCopied?.()
|
||||
} catch (error) {
|
||||
onCopyError?.(error)
|
||||
|
||||
if (resetRef.current !== null) {
|
||||
window.clearTimeout(resetRef.current)
|
||||
}
|
||||
|
||||
setStatus('error')
|
||||
resetRef.current = window.setTimeout(() => {
|
||||
setStatus('idle')
|
||||
resetRef.current = null
|
||||
}, COPIED_RESET_MS)
|
||||
}
|
||||
},
|
||||
[haptic, onCopied, onCopyError, preventDefault, stopPropagation, text]
|
||||
)
|
||||
|
||||
const Icon = status === 'copied' ? Check : status === 'error' ? X : Copy
|
||||
const icon = <Icon className={cn('size-3.5', iconClassName)} />
|
||||
const visibleChildren =
|
||||
(showLabel ?? (appearance !== 'icon' && appearance !== 'tool-row'))
|
||||
? status === 'copied'
|
||||
? 'Copied'
|
||||
: status === 'error'
|
||||
? 'Failed'
|
||||
: (children ?? label)
|
||||
: null
|
||||
|
||||
const content = (
|
||||
<>
|
||||
{icon}
|
||||
{visibleChildren}
|
||||
</>
|
||||
)
|
||||
|
||||
const feedbackLabel = status === 'copied' ? 'Copied' : status === 'error' ? errorMessage : (title ?? label)
|
||||
const ariaLabel = status === 'idle' ? label : feedbackLabel
|
||||
|
||||
if (appearance === 'menu-item') {
|
||||
return (
|
||||
<DropdownMenuItem
|
||||
className={className}
|
||||
disabled={disabled}
|
||||
onSelect={event => {
|
||||
event.preventDefault()
|
||||
void copy(event)
|
||||
}}
|
||||
>
|
||||
{content}
|
||||
</DropdownMenuItem>
|
||||
)
|
||||
}
|
||||
|
||||
if (appearance === 'inline') {
|
||||
return (
|
||||
<button
|
||||
aria-label={ariaLabel}
|
||||
className={cn(
|
||||
'inline-flex items-center gap-1 rounded-sm px-1.5 py-0.5 text-[0.75rem] text-muted-foreground transition-colors hover:bg-accent hover:text-foreground disabled:opacity-40',
|
||||
className
|
||||
)}
|
||||
disabled={disabled}
|
||||
onClick={event => void copy(event)}
|
||||
title={feedbackLabel}
|
||||
type="button"
|
||||
>
|
||||
{content}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
if (appearance === 'tool-row') {
|
||||
return (
|
||||
<button
|
||||
aria-label={ariaLabel}
|
||||
className={cn(
|
||||
'grid size-6 place-items-center rounded-md text-muted-foreground/70 opacity-0 transition-opacity hover:bg-accent/55 hover:text-foreground focus-visible:opacity-100 group-hover/tool-row:opacity-100 disabled:opacity-40',
|
||||
className
|
||||
)}
|
||||
disabled={disabled}
|
||||
onClick={event => void copy(event)}
|
||||
title={feedbackLabel}
|
||||
type="button"
|
||||
>
|
||||
{icon}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<Button
|
||||
aria-label={ariaLabel}
|
||||
className={className}
|
||||
disabled={disabled}
|
||||
onClick={event => void copy(event)}
|
||||
size={buttonSize ?? (appearance === 'icon' ? 'icon' : 'default')}
|
||||
title={feedbackLabel}
|
||||
type="button"
|
||||
variant={buttonVariant}
|
||||
>
|
||||
{content}
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
import type { ComponentProps, CSSProperties } from 'react'
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
interface FadeTextProps extends Omit<ComponentProps<'span'>, 'children'> {
|
||||
children: React.ReactNode
|
||||
/**
|
||||
* Width of the fade region on the trailing edge. Accepts any CSS length.
|
||||
* Defaults to 3rem so long strings clearly trail off — short enough to
|
||||
* preserve readable content, long enough to feel like a deliberate fade
|
||||
* rather than a clipped ellipsis.
|
||||
*/
|
||||
fadeWidth?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Single-line text that fades out instead of truncating with an ellipsis.
|
||||
*
|
||||
* Uses an inline mask-image so the fade resolves against whatever the parent
|
||||
* background is — no need to know the surface color, no after-pseudo overlap.
|
||||
* The mask is only applied when the text is actually overflowing, so short
|
||||
* strings render as plain text without an unnecessary gradient on their tail.
|
||||
*/
|
||||
export function FadeText({ children, className, fadeWidth = '3rem', style, ...rest }: FadeTextProps) {
|
||||
const ref = useRef<HTMLSpanElement>(null)
|
||||
const [overflowing, setOverflowing] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
const el = ref.current
|
||||
|
||||
if (!el) {
|
||||
return
|
||||
}
|
||||
|
||||
const measure = () => {
|
||||
setOverflowing(el.scrollWidth - el.clientWidth > 1)
|
||||
}
|
||||
|
||||
measure()
|
||||
const observer = new ResizeObserver(measure)
|
||||
observer.observe(el)
|
||||
|
||||
return () => observer.disconnect()
|
||||
}, [children])
|
||||
|
||||
const maskStyle: CSSProperties = overflowing
|
||||
? {
|
||||
maskImage: `linear-gradient(to right, black calc(100% - ${fadeWidth}), transparent)`,
|
||||
WebkitMaskImage: `linear-gradient(to right, black calc(100% - ${fadeWidth}), transparent)`,
|
||||
...style
|
||||
}
|
||||
: style ?? {}
|
||||
|
||||
return (
|
||||
<span
|
||||
{...rest}
|
||||
className={cn('block min-w-0 max-w-full overflow-hidden whitespace-nowrap', className)}
|
||||
ref={ref}
|
||||
style={maskStyle}
|
||||
>
|
||||
{children}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
Vendored
+34
@@ -8,6 +8,7 @@ declare global {
|
||||
notify: (payload: HermesNotification) => Promise<boolean>
|
||||
requestMicrophoneAccess: () => Promise<boolean>
|
||||
readFileDataUrl: (filePath: string) => Promise<string>
|
||||
readFileText: (filePath: string) => Promise<HermesReadFileTextResult>
|
||||
selectPaths: (options?: HermesSelectPathsOptions) => Promise<string[]>
|
||||
writeClipboard: (text: string) => Promise<boolean>
|
||||
saveImageFromUrl: (url: string) => Promise<boolean>
|
||||
@@ -17,7 +18,11 @@ declare global {
|
||||
normalizePreviewTarget: (target: string, baseDir?: string) => Promise<HermesPreviewTarget | null>
|
||||
watchPreviewFile: (url: string) => Promise<HermesPreviewWatch>
|
||||
stopPreviewFileWatch: (id: string) => Promise<boolean>
|
||||
setPreviewShortcutActive?: (active: boolean) => void
|
||||
openExternal: (url: string) => Promise<void>
|
||||
readDir: (path: string) => Promise<HermesReadDirResult>
|
||||
gitRoot?: (path: string) => Promise<string | null>
|
||||
onClosePreviewRequested?: (callback: () => void) => () => void
|
||||
onPreviewFileChanged: (callback: (payload: HermesPreviewFileChanged) => void) => () => void
|
||||
onBackendExit: (callback: (payload: BackendExit) => void) => () => void
|
||||
}
|
||||
@@ -45,17 +50,46 @@ export interface HermesNotification {
|
||||
}
|
||||
|
||||
export interface HermesPreviewTarget {
|
||||
binary?: boolean
|
||||
byteSize?: number
|
||||
kind: 'file' | 'url'
|
||||
label: string
|
||||
large?: boolean
|
||||
language?: string
|
||||
mimeType?: string
|
||||
path?: string
|
||||
previewKind?: 'binary' | 'html' | 'image' | 'text'
|
||||
renderMode?: 'preview' | 'source'
|
||||
source: string
|
||||
url: string
|
||||
}
|
||||
|
||||
export interface HermesReadFileTextResult {
|
||||
binary?: boolean
|
||||
byteSize?: number
|
||||
language?: string
|
||||
mimeType?: string
|
||||
path: string
|
||||
text: string
|
||||
truncated?: boolean
|
||||
}
|
||||
|
||||
export interface HermesPreviewWatch {
|
||||
id: string
|
||||
path: string
|
||||
}
|
||||
|
||||
export interface HermesReadDirEntry {
|
||||
name: string
|
||||
path: string
|
||||
isDirectory: boolean
|
||||
}
|
||||
|
||||
export interface HermesReadDirResult {
|
||||
entries: HermesReadDirEntry[]
|
||||
error?: string
|
||||
}
|
||||
|
||||
export interface HermesPreviewFileChanged {
|
||||
id: string
|
||||
path: string
|
||||
|
||||
@@ -102,6 +102,14 @@ export function deleteSession(id: string): Promise<{ ok: boolean }> {
|
||||
})
|
||||
}
|
||||
|
||||
export function renameSession(id: string, title: string): Promise<{ ok: boolean; title: string }> {
|
||||
return window.hermesDesktop.api<{ ok: boolean; title: string }>({
|
||||
path: `/api/sessions/${encodeURIComponent(id)}`,
|
||||
method: 'PATCH',
|
||||
body: { title }
|
||||
})
|
||||
}
|
||||
|
||||
export function getGlobalModelInfo(): Promise<ModelInfoResponse> {
|
||||
return window.hermesDesktop.api<ModelInfoResponse>({
|
||||
path: '/api/model/info'
|
||||
|
||||
@@ -13,6 +13,8 @@ export type ChatMessage = {
|
||||
pending?: boolean
|
||||
branchGroupId?: string
|
||||
hidden?: boolean
|
||||
/** Composer attachment ref strings (`@file:...`, `@image:...`) sent with this user message. */
|
||||
attachmentRefs?: string[]
|
||||
}
|
||||
|
||||
export type GatewayEventPayload = {
|
||||
|
||||
@@ -106,10 +106,20 @@ export function coerceGatewayText(value: unknown): string {
|
||||
return String(value)
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize a reasoning/thinking text payload from the gateway.
|
||||
*
|
||||
* Only the leading status prefix (e.g. "Hermes is thinking...") and the
|
||||
* obvious placeholder echoes are stripped. We deliberately do NOT trim
|
||||
* the delta — reasoning streams as small chunks (often individual tokens
|
||||
* with leading or trailing spaces), and trimming each chunk before
|
||||
* concatenation collapses adjacent words together. Whitespace between
|
||||
* tokens belongs to the data, not chrome.
|
||||
*/
|
||||
export function coerceThinkingText(value: unknown): string {
|
||||
const text = coerceGatewayText(value).replace(THINKING_STATUS_PREFIX_RE, '').trim()
|
||||
const raw = coerceGatewayText(value).replace(THINKING_STATUS_PREFIX_RE, '')
|
||||
|
||||
return EMPTY_THINKING_PLACEHOLDER_RE.test(text) ? '' : text
|
||||
return EMPTY_THINKING_PLACEHOLDER_RE.test(raw) ? '' : raw
|
||||
}
|
||||
|
||||
export function isImageGenerationTool(name?: string): boolean {
|
||||
@@ -281,7 +291,7 @@ export function toRuntimeMessage(message: ChatMessage): ThreadMessage {
|
||||
content: message.parts.filter((part): part is Extract<ChatMessagePart, { type: 'text' }> => part.type === 'text'),
|
||||
attachments: [],
|
||||
createdAt,
|
||||
metadata: { custom: {} }
|
||||
metadata: { custom: { attachmentRefs: message.attachmentRefs ?? [] } }
|
||||
} as ThreadMessage
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
import type { StatusbarMenuItem } from '@/app/shell/statusbar-controls'
|
||||
|
||||
const LOG_TAIL = 5
|
||||
|
||||
interface RpcEventLike {
|
||||
payload?: unknown
|
||||
type?: string
|
||||
}
|
||||
|
||||
function asRecord(payload: unknown): Record<string, unknown> {
|
||||
return payload && typeof payload === 'object' ? (payload as Record<string, unknown>) : {}
|
||||
}
|
||||
|
||||
export function gatewayEventCompletedFileDiff(event: RpcEventLike): boolean {
|
||||
if (event.type !== 'tool.complete') {return false}
|
||||
const diff = asRecord(event.payload).inline_diff
|
||||
|
||||
return typeof diff === 'string' && diff.trim().length > 0
|
||||
}
|
||||
|
||||
export function buildGatewayLogItems(lines: readonly string[]): readonly StatusbarMenuItem[] {
|
||||
if (lines.length === 0) {
|
||||
return [{ className: 'text-muted-foreground', disabled: true, id: 'gateway-log-empty', label: 'No recent gateway log lines' }]
|
||||
}
|
||||
|
||||
return lines.slice(-LOG_TAIL).map((line, index) => ({
|
||||
className: 'font-mono text-[0.68rem] text-muted-foreground',
|
||||
disabled: true,
|
||||
id: `gateway-log:${index}`,
|
||||
label: line.trim().slice(0, 120) || '(blank log line)'
|
||||
}))
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
import type { PreviewTarget } from '@/store/preview'
|
||||
|
||||
const HTML_EXTENSIONS = new Set(['.htm', '.html'])
|
||||
const IMAGE_EXTENSIONS = new Set(['.bmp', '.gif', '.jpeg', '.jpg', '.png', '.svg', '.webp'])
|
||||
|
||||
const LANGUAGE_BY_EXT: Record<string, string> = {
|
||||
'.c': 'c',
|
||||
'.conf': 'ini',
|
||||
'.cpp': 'cpp',
|
||||
'.css': 'css',
|
||||
'.csv': 'csv',
|
||||
'.go': 'go',
|
||||
'.graphql': 'graphql',
|
||||
'.h': 'c',
|
||||
'.hpp': 'cpp',
|
||||
'.html': 'html',
|
||||
'.java': 'java',
|
||||
'.js': 'javascript',
|
||||
'.json': 'json',
|
||||
'.jsx': 'jsx',
|
||||
'.log': 'text',
|
||||
'.lua': 'lua',
|
||||
'.md': 'markdown',
|
||||
'.mjs': 'javascript',
|
||||
'.py': 'python',
|
||||
'.rb': 'ruby',
|
||||
'.rs': 'rust',
|
||||
'.sh': 'shell',
|
||||
'.sql': 'sql',
|
||||
'.svg': 'xml',
|
||||
'.toml': 'toml',
|
||||
'.ts': 'typescript',
|
||||
'.tsx': 'tsx',
|
||||
'.txt': 'text',
|
||||
'.xml': 'xml',
|
||||
'.yaml': 'yaml',
|
||||
'.yml': 'yaml',
|
||||
'.zsh': 'shell'
|
||||
}
|
||||
|
||||
function basename(value: string) {
|
||||
return value.split(/[\\/]/).filter(Boolean).pop() || value
|
||||
}
|
||||
|
||||
function extension(value: string) {
|
||||
const clean = value.split(/[?#]/, 1)[0] || value
|
||||
const idx = clean.lastIndexOf('.')
|
||||
|
||||
return idx >= 0 ? clean.slice(idx).toLowerCase() : ''
|
||||
}
|
||||
|
||||
function joinPath(base: string, rel: string) {
|
||||
if (!base) {return rel}
|
||||
|
||||
return `${base.replace(/\/+$/, '')}/${rel.replace(/^\.?\//, '')}`
|
||||
}
|
||||
|
||||
function pathToFileUrl(path: string) {
|
||||
const encoded = path.split('/').map(part => encodeURIComponent(part)).join('/')
|
||||
|
||||
return `file://${encoded.startsWith('/') ? encoded : `/${encoded}`}`
|
||||
}
|
||||
|
||||
export function localPreviewTarget(rawTarget: string, cwd?: string | null): PreviewTarget | null {
|
||||
const raw = rawTarget.trim().replace(/^`|`$/g, '')
|
||||
|
||||
if (!raw) {return null}
|
||||
|
||||
if (/^https?:\/\//i.test(raw)) {
|
||||
return { kind: 'url', label: basename(raw), source: raw, url: raw }
|
||||
}
|
||||
|
||||
let path = raw
|
||||
|
||||
if (/^file:\/\//i.test(raw)) {
|
||||
try {
|
||||
path = decodeURIComponent(new URL(raw).pathname)
|
||||
} catch {
|
||||
path = raw.replace(/^file:\/\//i, '')
|
||||
}
|
||||
} else if (!raw.startsWith('/') && cwd) {
|
||||
path = joinPath(cwd, raw)
|
||||
}
|
||||
|
||||
const ext = extension(path)
|
||||
const isHtml = HTML_EXTENSIONS.has(ext)
|
||||
const isImage = IMAGE_EXTENSIONS.has(ext)
|
||||
|
||||
return {
|
||||
kind: 'file',
|
||||
label: basename(path),
|
||||
language: LANGUAGE_BY_EXT[ext] || 'text',
|
||||
path,
|
||||
// Renderer fallback can't stat/sniff without reading; assume text unless
|
||||
// image/html extension says otherwise. LocalFilePreview still guards
|
||||
// binary/large files when readFileText/readFileDataUrl returns metadata.
|
||||
previewKind: isHtml ? 'html' : isImage ? 'image' : 'text',
|
||||
source: raw,
|
||||
url: pathToFileUrl(path)
|
||||
}
|
||||
}
|
||||
|
||||
export async function normalizeOrLocalPreviewTarget(rawTarget: string, cwd?: string | null): Promise<PreviewTarget | null> {
|
||||
try {
|
||||
const normalized = await window.hermesDesktop?.normalizePreviewTarget?.(rawTarget, cwd || undefined)
|
||||
|
||||
if (normalized) {
|
||||
return normalized
|
||||
}
|
||||
} catch {
|
||||
// Running Electron may still have the old HTML-only preview IPC. Fall
|
||||
// through to renderer-side local classification so text/images still open.
|
||||
}
|
||||
|
||||
return localPreviewTarget(rawTarget, cwd)
|
||||
}
|
||||
@@ -0,0 +1,182 @@
|
||||
import { isLikelyProseFence, sanitizeLanguageTag } from '@/lib/markdown-code'
|
||||
import { stripPreviewTargets } from '@/lib/preview-targets'
|
||||
|
||||
/**
|
||||
* Strip provider/model "thinking" blocks before markdown render.
|
||||
*
|
||||
* Some Hermes providers stream raw `<think>…</think>` and similar into
|
||||
* assistant text. Proper reasoning UI uses dedicated `reasoning.*` parts.
|
||||
*/
|
||||
const REASONING_BLOCK_RE = /<(think|thinking|reasoning|scratchpad|analysis)>[\s\S]*?<\/\1>\s*/gi
|
||||
const PREVIEW_MARKER_RE = /\[Preview:[^\]]+\]\(#preview[:/][^)]+\)/gi
|
||||
|
||||
const FENCE_LINE_RE = /^([ \t]*)(`{3,}|~{3,})([^\n]*)$/
|
||||
const EMPTY_FENCE_BLOCK_RE = /(^|\n)[ \t]*(?:`{3,}|~{3,})[^\n]*\n[ \t]*(?:`{3,}|~{3,})[ \t]*(?=\n|$)/g
|
||||
|
||||
/**
|
||||
* Forcefully scrub backtick noise the model streams that doesn't form a
|
||||
* valid markdown construct.
|
||||
*
|
||||
* Strategy: find every well-formed `\`\`\`…\`\`\`` (or `~~~`) block whose
|
||||
* opener and closer are each on their own line, snapshot those ranges as
|
||||
* "protected", then nuke all other triple-backtick runs in the text.
|
||||
* This handles every failure mode in one shot:
|
||||
*
|
||||
* - mid-line `\`\`\`` (`there:\`\`\` cd /Users/...`)
|
||||
* - orphan opener with no closer
|
||||
* - orphan closer with no opener
|
||||
* - chunk-boundary backticks where the preceding non-newline char isn't
|
||||
* in the same regex window
|
||||
* - empty `` `` `` `` and orphan double backticks
|
||||
*
|
||||
* Real, well-formed fenced blocks survive untouched.
|
||||
*/
|
||||
function scrubBacktickNoise(text: string): string {
|
||||
const balancedFenceRe = /(^|\n)([ \t]*)(`{3,}|~{3,})([^\n]*)\n([\s\S]*?)\n[ \t]*\3[ \t]*(?=\n|$)/g
|
||||
const protectedRanges: { end: number; start: number }[] = []
|
||||
let match: RegExpExecArray | null
|
||||
|
||||
while ((match = balancedFenceRe.exec(text)) !== null) {
|
||||
const start = match.index + match[1].length
|
||||
|
||||
protectedRanges.push({ end: balancedFenceRe.lastIndex, start })
|
||||
}
|
||||
|
||||
const fenceNoiseRe = /`{3,}/g
|
||||
let out = ''
|
||||
let cursor = 0
|
||||
|
||||
for (const range of protectedRanges) {
|
||||
out += text.slice(cursor, range.start).replace(fenceNoiseRe, '')
|
||||
out += text.slice(range.start, range.end)
|
||||
cursor = range.end
|
||||
}
|
||||
|
||||
out += text.slice(cursor).replace(fenceNoiseRe, '')
|
||||
|
||||
// Empty inline code spans (`` `` `` `` with nothing meaningful inside)
|
||||
// render as literal backticks. Two passes catch chains.
|
||||
for (let pass = 0; pass < 2; pass += 1) {
|
||||
out = out.replace(/``\s*``/g, '')
|
||||
out = out.replace(/(^|[^`])``(?=\s|[.,;:!?)\]'"\u2014\u2013-]|$)/g, '$1')
|
||||
}
|
||||
|
||||
return out
|
||||
}
|
||||
|
||||
function stripEmptyFenceBlocks(text: string): string {
|
||||
return text.replace(EMPTY_FENCE_BLOCK_RE, '$1')
|
||||
}
|
||||
|
||||
function pushProseFence(out: string[], indent: string, info: string, lines: string[]) {
|
||||
if (info) {
|
||||
out.push(`${indent}${info}`.trimEnd())
|
||||
}
|
||||
|
||||
out.push(...lines)
|
||||
}
|
||||
|
||||
function findClosingFence(lines: string[], start: number, marker: string): number {
|
||||
for (let cursor = start + 1; cursor < lines.length; cursor += 1) {
|
||||
const closeMatch = (lines[cursor] || '').match(FENCE_LINE_RE)
|
||||
|
||||
if (!closeMatch) {
|
||||
continue
|
||||
}
|
||||
|
||||
const closeMarker = closeMatch[2] || ''
|
||||
const closeInfo = (closeMatch[3] || '').trim()
|
||||
|
||||
if (!closeInfo && closeMarker[0] === marker[0] && closeMarker.length >= marker.length) {
|
||||
return cursor
|
||||
}
|
||||
}
|
||||
|
||||
return -1
|
||||
}
|
||||
|
||||
function normalizeFenceBlocks(text: string): string {
|
||||
const sourceLines = text.split('\n')
|
||||
const out: string[] = []
|
||||
let index = 0
|
||||
|
||||
while (index < sourceLines.length) {
|
||||
const line = sourceLines[index] || ''
|
||||
const match = line.match(FENCE_LINE_RE)
|
||||
|
||||
if (!match) {
|
||||
out.push(line)
|
||||
index += 1
|
||||
|
||||
continue
|
||||
}
|
||||
|
||||
const indent = match[1] || ''
|
||||
const marker = match[2] || '```'
|
||||
const infoRaw = (match[3] || '').trim()
|
||||
const languageToken = infoRaw.split(/\s+/, 1)[0] || ''
|
||||
const language = sanitizeLanguageTag(languageToken)
|
||||
const openerValid = !infoRaw || Boolean(language)
|
||||
|
||||
if (!openerValid) {
|
||||
out.push(`${indent}${infoRaw}`.trimEnd())
|
||||
index += 1
|
||||
|
||||
continue
|
||||
}
|
||||
|
||||
const closeIndex = findClosingFence(sourceLines, index, marker)
|
||||
const bodyLines = sourceLines.slice(index + 1, closeIndex === -1 ? sourceLines.length : closeIndex)
|
||||
const body = bodyLines.join('\n')
|
||||
|
||||
if (closeIndex !== -1 && !body.trim()) {
|
||||
index = closeIndex + 1
|
||||
|
||||
continue
|
||||
}
|
||||
|
||||
if (closeIndex === -1) {
|
||||
if (!body.trim()) {
|
||||
index += 1
|
||||
|
||||
continue
|
||||
}
|
||||
|
||||
if (isLikelyProseFence(infoRaw, body)) {
|
||||
pushProseFence(out, indent, infoRaw, bodyLines)
|
||||
} else {
|
||||
out.push(`${indent}${marker}${language}`)
|
||||
out.push(...bodyLines)
|
||||
}
|
||||
|
||||
break
|
||||
}
|
||||
|
||||
if (isLikelyProseFence(infoRaw, body)) {
|
||||
pushProseFence(out, indent, infoRaw, bodyLines)
|
||||
index = closeIndex + 1
|
||||
|
||||
continue
|
||||
}
|
||||
|
||||
out.push(`${indent}${marker}${language}`)
|
||||
out.push(...bodyLines)
|
||||
out.push(`${indent}${marker}`)
|
||||
index = closeIndex + 1
|
||||
}
|
||||
|
||||
return out.join('\n')
|
||||
}
|
||||
|
||||
export function preprocessMarkdown(text: string): string {
|
||||
const cleaned = text.replace(REASONING_BLOCK_RE, '').replace(PREVIEW_MARKER_RE, '')
|
||||
const scrubbed = scrubBacktickNoise(cleaned)
|
||||
const normalizedFences = normalizeFenceBlocks(scrubbed)
|
||||
const strippedEmptyFences = stripEmptyFenceBlocks(normalizedFences)
|
||||
|
||||
return strippedEmptyFences
|
||||
.split(/((?:```|~~~)[\s\S]*?(?:```|~~~))/g)
|
||||
.map(part => (/^(?:```|~~~)/.test(part) ? part : stripPreviewTargets(part)))
|
||||
.join('')
|
||||
.replace(/[ \t]+\n/g, '\n')
|
||||
}
|
||||
@@ -1,54 +1,15 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import {
|
||||
extractPreviewCandidates,
|
||||
extractPreviewTargets,
|
||||
isLikelyPreviewCandidate,
|
||||
previewTargetFromMarkdownHref,
|
||||
renderPreviewTargets,
|
||||
stripPreviewTargets
|
||||
} from './preview-targets'
|
||||
|
||||
describe('preview target detection', () => {
|
||||
it('extracts local server URLs and html files', () => {
|
||||
expect(
|
||||
extractPreviewCandidates(
|
||||
'Open http://localhost:5173/ and /tmp/mycelium-bunnies/index.html, not https://example.com/app.'
|
||||
)
|
||||
).toEqual(['http://localhost:5173/', '/tmp/mycelium-bunnies/index.html'])
|
||||
})
|
||||
|
||||
it('accepts relative html files and file URLs', () => {
|
||||
expect(extractPreviewCandidates('Wrote ./dist/index.html and file:///tmp/demo.html.')).toEqual([
|
||||
'./dist/index.html',
|
||||
'file:///tmp/demo.html'
|
||||
])
|
||||
})
|
||||
|
||||
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)
|
||||
})
|
||||
|
||||
it('renders previewable paths as markdown links', () => {
|
||||
expect(renderPreviewTargets('ready\n/tmp/mycelium-bunnies.html\nopen it')).toBe(
|
||||
'ready\n[Preview: mycelium-bunnies.html](#preview/%2Ftmp%2Fmycelium-bunnies.html)\nopen it'
|
||||
)
|
||||
it('does not infer preview targets from raw paths or URLs', () => {
|
||||
expect(extractPreviewTargets('Preview: http://localhost:5173/')).toEqual([])
|
||||
expect(extractPreviewTargets('Open index.html\n/tmp/demo.html\nhttp://localhost:5173/')).toEqual([])
|
||||
})
|
||||
|
||||
it('decodes preview markdown hrefs', () => {
|
||||
@@ -62,7 +23,9 @@ describe('preview target detection', () => {
|
||||
})
|
||||
|
||||
it('strips preview targets from visible assistant text', () => {
|
||||
expect(stripPreviewTargets('ready\n/tmp/mycelium-bunnies.html\nopen it')).toBe('ready\nopen it')
|
||||
expect(stripPreviewTargets('ready\n/tmp/mycelium-bunnies.html\nopen it')).toBe(
|
||||
'ready\n/tmp/mycelium-bunnies.html\nopen it'
|
||||
)
|
||||
expect(stripPreviewTargets('[Preview: demo.html](#preview:%2Ftmp%2Fdemo.html)\nopen it')).toBe('open it')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,217 +1,7 @@
|
||||
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<>"'`)\]]*)?)(?:[),.;:!?]*)(?=$|[\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 {
|
||||
end: number
|
||||
index: number
|
||||
value: string
|
||||
}
|
||||
|
||||
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)
|
||||
|
||||
if (!['http:', 'https:'].includes(url.protocol)) {
|
||||
return false
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
export function isLikelyPreviewCandidate(value: string): boolean {
|
||||
const trimmed = stripTrailingPunctuation(value.trim())
|
||||
|
||||
return (
|
||||
isHtmlFileUrl(trimmed) ||
|
||||
HTML_EXT_RE.test(trimmed) ||
|
||||
isPreviewDirectoryCandidate(trimmed) ||
|
||||
isLocalPreviewUrl(trimmed)
|
||||
)
|
||||
}
|
||||
|
||||
function collectPreviewMatches(text: string): PreviewCandidateMatch[] {
|
||||
const matches: PreviewCandidateMatch[] = []
|
||||
|
||||
const collect = (index: number | undefined, raw: string, value = raw) => {
|
||||
if (index === undefined) {
|
||||
return
|
||||
}
|
||||
|
||||
const candidate = stripTrailingPunctuation(value.trim())
|
||||
|
||||
if (!candidate || !isLikelyPreviewCandidate(candidate)) {
|
||||
return
|
||||
}
|
||||
|
||||
const offset = raw.indexOf(value)
|
||||
const start = index + Math.max(0, offset)
|
||||
|
||||
matches.push({
|
||||
end: start + candidate.length,
|
||||
index: start,
|
||||
value: candidate
|
||||
})
|
||||
}
|
||||
|
||||
for (const match of text.matchAll(URL_RE)) {
|
||||
collect(match.index, match[0])
|
||||
}
|
||||
|
||||
for (const match of text.matchAll(FILE_URL_RE)) {
|
||||
collect(match.index, match[0])
|
||||
}
|
||||
|
||||
for (const match of text.matchAll(POSIX_HTML_PATH_RE)) {
|
||||
collect(match.index, match[0], match.groups?.path || '')
|
||||
}
|
||||
|
||||
for (const match of text.matchAll(RELATIVE_HTML_PATH_RE)) {
|
||||
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)
|
||||
}
|
||||
|
||||
export function extractPreviewCandidates(text: string): string[] {
|
||||
const candidates: string[] = []
|
||||
const seen = new Set<string>()
|
||||
|
||||
const push = (value: string) => {
|
||||
const candidate = stripTrailingPunctuation(value.trim())
|
||||
|
||||
if (!candidate || seen.has(candidate) || !isLikelyPreviewCandidate(candidate)) {
|
||||
return
|
||||
}
|
||||
|
||||
seen.add(candidate)
|
||||
candidates.push(candidate)
|
||||
}
|
||||
|
||||
for (const match of collectPreviewMatches(text)) {
|
||||
push(match.value)
|
||||
}
|
||||
|
||||
return candidates
|
||||
}
|
||||
|
||||
export function stripPreviewTargets(text: string): string {
|
||||
const matches = collectPreviewMatches(text)
|
||||
let cursor = 0
|
||||
let stripped = ''
|
||||
|
||||
for (const match of matches) {
|
||||
if (match.index < cursor) {
|
||||
continue
|
||||
}
|
||||
|
||||
const lineStart = text.lastIndexOf('\n', Math.max(0, match.index - 1)) + 1
|
||||
const nextLineBreak = text.indexOf('\n', match.end)
|
||||
const lineEnd = nextLineBreak === -1 ? text.length : nextLineBreak + 1
|
||||
const beforeOnLine = text.slice(lineStart, match.index)
|
||||
const afterOnLine = text.slice(match.end, nextLineBreak === -1 ? text.length : nextLineBreak)
|
||||
|
||||
if (lineStart >= cursor && !beforeOnLine.trim() && !afterOnLine.trim()) {
|
||||
stripped += text.slice(cursor, lineStart)
|
||||
cursor = lineEnd
|
||||
|
||||
continue
|
||||
}
|
||||
|
||||
stripped += text.slice(cursor, match.index)
|
||||
cursor = match.end
|
||||
}
|
||||
|
||||
stripped += text.slice(cursor)
|
||||
|
||||
return stripped
|
||||
return text
|
||||
.replace(PREVIEW_MARKDOWN_RE, '')
|
||||
.replace(/[ \t]+\n/g, '\n')
|
||||
.replace(/\n{3,}/g, '\n\n')
|
||||
@@ -219,8 +9,8 @@ export function stripPreviewTargets(text: string): string {
|
||||
}
|
||||
|
||||
export function extractPreviewTargets(text: string): string[] {
|
||||
const targets = extractPreviewCandidates(text)
|
||||
const seen = new Set(targets)
|
||||
const targets: string[] = []
|
||||
const seen = new Set<string>()
|
||||
|
||||
for (const match of text.matchAll(PREVIEW_MARKDOWN_RE)) {
|
||||
const target = previewTargetFromMarkdownHref(match.groups?.href)
|
||||
@@ -272,26 +62,3 @@ export function previewDisplayLabel(target: string): string {
|
||||
return `Preview: ${escaped}`
|
||||
}
|
||||
|
||||
function previewLink(value: string): string {
|
||||
return `[${previewDisplayLabel(value)}](${previewMarkdownHref(value)})`
|
||||
}
|
||||
|
||||
export function renderPreviewTargets(text: string): string {
|
||||
const matches = collectPreviewMatches(text)
|
||||
let cursor = 0
|
||||
let rendered = ''
|
||||
const seen = new Set<string>()
|
||||
|
||||
for (const match of matches) {
|
||||
if (match.index < cursor || seen.has(match.value)) {
|
||||
continue
|
||||
}
|
||||
|
||||
rendered += text.slice(cursor, match.index)
|
||||
rendered += previewLink(match.value)
|
||||
cursor = match.end
|
||||
seen.add(match.value)
|
||||
}
|
||||
|
||||
return rendered + text.slice(cursor)
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import './styles.css'
|
||||
import 'streamdown/styles.css'
|
||||
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||
import { StrictMode } from 'react'
|
||||
|
||||
@@ -1,47 +1,67 @@
|
||||
import { atom } from 'nanostores'
|
||||
import { atom, computed, type ReadableAtom } from 'nanostores'
|
||||
|
||||
import { arraysEqual, insertUniqueId, persistStringArray, storedStringArray } from '@/lib/storage'
|
||||
|
||||
import {
|
||||
arraysEqual,
|
||||
insertUniqueId,
|
||||
persistBoolean,
|
||||
persistStringArray,
|
||||
storedBoolean,
|
||||
storedStringArray
|
||||
} from '@/lib/storage'
|
||||
$paneStates,
|
||||
ensurePaneRegistered,
|
||||
setPaneOpen,
|
||||
setPaneWidthOverride,
|
||||
togglePane
|
||||
} from './panes'
|
||||
|
||||
export const SIDEBAR_DEFAULT_WIDTH = 224
|
||||
export const SIDEBAR_MAX_WIDTH = 320
|
||||
const SIDEBAR_OPEN_STORAGE_KEY = 'hermes.desktop.sidebarOpen'
|
||||
export const FILE_BROWSER_DEFAULT_WIDTH = '17rem'
|
||||
|
||||
const SIDEBAR_PINNED_STORAGE_KEY = 'hermes.desktop.pinnedSessions'
|
||||
const INSPECTOR_OPEN_STORAGE_KEY = 'hermes.desktop.inspectorOpen'
|
||||
|
||||
export const CHAT_SIDEBAR_PANE_ID = 'chat-sidebar'
|
||||
export const FILE_BROWSER_PANE_ID = 'file-browser'
|
||||
|
||||
// Pre-register app chrome panes so legacy callers see the correct initial
|
||||
// values whether or not the user has any persisted state.
|
||||
ensurePaneRegistered(CHAT_SIDEBAR_PANE_ID, { open: true })
|
||||
ensurePaneRegistered(FILE_BROWSER_PANE_ID, { open: false })
|
||||
|
||||
export const $sidebarOpen: ReadableAtom<boolean> = computed(
|
||||
$paneStates,
|
||||
states => states[CHAT_SIDEBAR_PANE_ID]?.open ?? true
|
||||
)
|
||||
|
||||
export const $fileBrowserOpen: ReadableAtom<boolean> = computed(
|
||||
$paneStates,
|
||||
states => states[FILE_BROWSER_PANE_ID]?.open ?? false
|
||||
)
|
||||
|
||||
export const $sidebarWidth: ReadableAtom<number> = computed($paneStates, states => {
|
||||
const override = states[CHAT_SIDEBAR_PANE_ID]?.widthOverride
|
||||
|
||||
return typeof override === 'number' ? override : SIDEBAR_DEFAULT_WIDTH
|
||||
})
|
||||
|
||||
export const $sidebarWidth = atom(SIDEBAR_DEFAULT_WIDTH)
|
||||
export const $sidebarOpen = atom(storedBoolean(SIDEBAR_OPEN_STORAGE_KEY, true))
|
||||
export const $inspectorOpen = atom(storedBoolean(INSPECTOR_OPEN_STORAGE_KEY, true))
|
||||
export const $pinnedSessionIds = atom(storedStringArray(SIDEBAR_PINNED_STORAGE_KEY))
|
||||
export const $sidebarPinsOpen = atom(true)
|
||||
export const $sidebarRecentsOpen = atom(true)
|
||||
export const $isSidebarResizing = atom(false)
|
||||
|
||||
$sidebarOpen.subscribe(open => persistBoolean(SIDEBAR_OPEN_STORAGE_KEY, open))
|
||||
$inspectorOpen.subscribe(open => persistBoolean(INSPECTOR_OPEN_STORAGE_KEY, open))
|
||||
$pinnedSessionIds.subscribe(ids => persistStringArray(SIDEBAR_PINNED_STORAGE_KEY, [...ids]))
|
||||
|
||||
export function setSidebarWidth(width: number) {
|
||||
const bounded = Math.min(SIDEBAR_MAX_WIDTH, Math.max(SIDEBAR_DEFAULT_WIDTH, width))
|
||||
$sidebarWidth.set(bounded)
|
||||
setPaneWidthOverride(CHAT_SIDEBAR_PANE_ID, bounded)
|
||||
}
|
||||
|
||||
export function setSidebarOpen(open: boolean) {
|
||||
$sidebarOpen.set(open)
|
||||
setPaneOpen(CHAT_SIDEBAR_PANE_ID, open)
|
||||
}
|
||||
|
||||
export function toggleSidebarOpen() {
|
||||
$sidebarOpen.set(!$sidebarOpen.get())
|
||||
togglePane(CHAT_SIDEBAR_PANE_ID)
|
||||
}
|
||||
|
||||
export function toggleInspectorOpen() {
|
||||
$inspectorOpen.set(!$inspectorOpen.get())
|
||||
export function toggleFileBrowserOpen() {
|
||||
togglePane(FILE_BROWSER_PANE_ID)
|
||||
}
|
||||
|
||||
export function setSidebarPinsOpen(open: boolean) {
|
||||
|
||||
@@ -0,0 +1,146 @@
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
|
||||
|
||||
import {
|
||||
$paneOpen,
|
||||
$paneStates,
|
||||
$paneWidthOverride,
|
||||
clearPaneWidthOverride,
|
||||
ensurePaneRegistered,
|
||||
getPaneStateSnapshot,
|
||||
setPaneOpen,
|
||||
setPaneWidthOverride,
|
||||
togglePane
|
||||
} from './panes'
|
||||
|
||||
const STORAGE_KEY = 'hermes.desktop.paneStates.v1'
|
||||
|
||||
describe('panes store', () => {
|
||||
beforeEach(() => {
|
||||
$paneStates.set({})
|
||||
window.localStorage.clear()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
$paneStates.set({})
|
||||
window.localStorage.clear()
|
||||
})
|
||||
|
||||
describe('ensurePaneRegistered', () => {
|
||||
it('adds a pane with defaults when missing', () => {
|
||||
ensurePaneRegistered('files', { open: true })
|
||||
|
||||
expect(getPaneStateSnapshot('files')).toEqual({ open: true, widthOverride: undefined })
|
||||
})
|
||||
|
||||
it('is a no-op when the pane already exists', () => {
|
||||
ensurePaneRegistered('files', { open: false })
|
||||
ensurePaneRegistered('files', { open: true })
|
||||
|
||||
expect(getPaneStateSnapshot('files')?.open).toBe(false)
|
||||
})
|
||||
|
||||
it('preserves an existing widthOverride when re-registering', () => {
|
||||
ensurePaneRegistered('files', { open: true })
|
||||
setPaneWidthOverride('files', 360)
|
||||
ensurePaneRegistered('files', { open: false })
|
||||
|
||||
expect(getPaneStateSnapshot('files')?.widthOverride).toBe(360)
|
||||
})
|
||||
})
|
||||
|
||||
describe('setPaneOpen / togglePane', () => {
|
||||
it('updates the pane open flag', () => {
|
||||
ensurePaneRegistered('files', { open: false })
|
||||
setPaneOpen('files', true)
|
||||
|
||||
expect(getPaneStateSnapshot('files')?.open).toBe(true)
|
||||
})
|
||||
|
||||
it('togglePane flips the current value', () => {
|
||||
ensurePaneRegistered('files', { open: false })
|
||||
togglePane('files')
|
||||
togglePane('files')
|
||||
togglePane('files')
|
||||
|
||||
expect(getPaneStateSnapshot('files')?.open).toBe(true)
|
||||
})
|
||||
|
||||
it('togglePane on an unregistered id starts from false', () => {
|
||||
togglePane('ephemeral')
|
||||
|
||||
expect(getPaneStateSnapshot('ephemeral')?.open).toBe(true)
|
||||
})
|
||||
|
||||
it('preserves widthOverride across open/close changes', () => {
|
||||
ensurePaneRegistered('files', { open: true })
|
||||
setPaneWidthOverride('files', 280)
|
||||
setPaneOpen('files', false)
|
||||
setPaneOpen('files', true)
|
||||
|
||||
expect(getPaneStateSnapshot('files')?.widthOverride).toBe(280)
|
||||
})
|
||||
})
|
||||
|
||||
describe('width overrides', () => {
|
||||
it('setPaneWidthOverride stores the px value', () => {
|
||||
ensurePaneRegistered('files', { open: true })
|
||||
setPaneWidthOverride('files', 300)
|
||||
|
||||
expect(getPaneStateSnapshot('files')?.widthOverride).toBe(300)
|
||||
})
|
||||
|
||||
it('clearPaneWidthOverride removes the override', () => {
|
||||
ensurePaneRegistered('files', { open: true })
|
||||
setPaneWidthOverride('files', 300)
|
||||
clearPaneWidthOverride('files')
|
||||
|
||||
expect(getPaneStateSnapshot('files')?.widthOverride).toBeUndefined()
|
||||
})
|
||||
|
||||
it('width override is in-memory only — not persisted across reloads', () => {
|
||||
ensurePaneRegistered('files', { open: true })
|
||||
setPaneWidthOverride('files', 300)
|
||||
|
||||
const persisted = window.localStorage.getItem(STORAGE_KEY)
|
||||
|
||||
expect(persisted).not.toBeNull()
|
||||
expect(JSON.parse(persisted ?? '{}')).toEqual({ files: { open: true } })
|
||||
})
|
||||
|
||||
it('open flag is persisted across changes', () => {
|
||||
ensurePaneRegistered('files', { open: false })
|
||||
setPaneOpen('files', true)
|
||||
|
||||
const persisted = window.localStorage.getItem(STORAGE_KEY)
|
||||
|
||||
expect(persisted).not.toBeNull()
|
||||
expect(JSON.parse(persisted ?? '{}')).toEqual({ files: { open: true } })
|
||||
})
|
||||
})
|
||||
|
||||
describe('derived atoms', () => {
|
||||
it('$paneOpen reflects the pane state', () => {
|
||||
const open$ = $paneOpen('files')
|
||||
expect(open$.get()).toBe(false)
|
||||
|
||||
ensurePaneRegistered('files', { open: true })
|
||||
expect(open$.get()).toBe(true)
|
||||
|
||||
setPaneOpen('files', false)
|
||||
expect(open$.get()).toBe(false)
|
||||
})
|
||||
|
||||
it('$paneWidthOverride reflects the width', () => {
|
||||
const width$ = $paneWidthOverride('files')
|
||||
expect(width$.get()).toBeUndefined()
|
||||
|
||||
ensurePaneRegistered('files', { open: true })
|
||||
setPaneWidthOverride('files', 240)
|
||||
expect(width$.get()).toBe(240)
|
||||
})
|
||||
|
||||
it('$paneOpen returns the same atom instance for repeated calls', () => {
|
||||
expect($paneOpen('files')).toBe($paneOpen('files'))
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,118 @@
|
||||
import { atom, computed, type ReadableAtom } from 'nanostores'
|
||||
|
||||
export interface PaneStateSnapshot {
|
||||
open: boolean
|
||||
widthOverride?: number
|
||||
}
|
||||
|
||||
export interface PaneRegisterDefaults {
|
||||
open: boolean
|
||||
widthOverride?: number
|
||||
}
|
||||
|
||||
const STORAGE_KEY = 'hermes.desktop.paneStates.v1'
|
||||
|
||||
function isSnapshot(value: unknown): value is PaneStateSnapshot {
|
||||
if (!value || typeof value !== 'object') {return false}
|
||||
const r = value as Record<string, unknown>
|
||||
|
||||
if (typeof r.open !== 'boolean') {return false}
|
||||
|
||||
return r.widthOverride === undefined || (typeof r.widthOverride === 'number' && Number.isFinite(r.widthOverride))
|
||||
}
|
||||
|
||||
function load(): Record<string, PaneStateSnapshot> {
|
||||
if (typeof window === 'undefined') {return {}}
|
||||
|
||||
try {
|
||||
const raw = window.localStorage.getItem(STORAGE_KEY)
|
||||
|
||||
if (raw) {
|
||||
const parsed = JSON.parse(raw) as unknown
|
||||
|
||||
if (parsed && typeof parsed === 'object') {
|
||||
const out: Record<string, PaneStateSnapshot> = {}
|
||||
|
||||
for (const [id, value] of Object.entries(parsed as Record<string, unknown>)) {
|
||||
if (isSnapshot(value)) {out[id] = { open: value.open, widthOverride: value.widthOverride }}
|
||||
}
|
||||
|
||||
return out
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Treat unparseable persisted state as missing.
|
||||
}
|
||||
|
||||
return {}
|
||||
}
|
||||
|
||||
// widthOverride is in-memory only — phase 2 can add per-pane persistWidth opt-in.
|
||||
function persist(states: Record<string, PaneStateSnapshot>) {
|
||||
if (typeof window === 'undefined') {return}
|
||||
const minimal: Record<string, { open: boolean }> = {}
|
||||
|
||||
for (const [id, s] of Object.entries(states)) {minimal[id] = { open: s.open }}
|
||||
|
||||
try {
|
||||
window.localStorage.setItem(STORAGE_KEY, JSON.stringify(minimal))
|
||||
} catch {
|
||||
// Storage failures are nonfatal.
|
||||
}
|
||||
}
|
||||
|
||||
export const $paneStates = atom<Record<string, PaneStateSnapshot>>(load())
|
||||
|
||||
$paneStates.subscribe(persist)
|
||||
|
||||
// Cached per-pane derived atoms keep useStore subscriptions referentially stable.
|
||||
function memoized<T>(cache: Map<string, ReadableAtom<T>>, id: string, selector: (s: PaneStateSnapshot | undefined) => T) {
|
||||
let cached = cache.get(id)
|
||||
|
||||
if (!cached) {
|
||||
cached = computed($paneStates, states => selector(states[id]))
|
||||
cache.set(id, cached)
|
||||
}
|
||||
|
||||
return cached
|
||||
}
|
||||
|
||||
const openCache = new Map<string, ReadableAtom<boolean>>()
|
||||
const stateCache = new Map<string, ReadableAtom<PaneStateSnapshot | undefined>>()
|
||||
const widthCache = new Map<string, ReadableAtom<number | undefined>>()
|
||||
|
||||
export const $paneOpen = (id: string) => memoized(openCache, id, s => s?.open ?? false)
|
||||
export const $paneState = (id: string) => memoized(stateCache, id, s => s)
|
||||
export const $paneWidthOverride = (id: string) => memoized(widthCache, id, s => s?.widthOverride)
|
||||
|
||||
export function ensurePaneRegistered(id: string, defaults: PaneRegisterDefaults) {
|
||||
const current = $paneStates.get()
|
||||
|
||||
if (current[id] !== undefined) {return}
|
||||
$paneStates.set({ ...current, [id]: { open: defaults.open, widthOverride: defaults.widthOverride } })
|
||||
}
|
||||
|
||||
export function setPaneOpen(id: string, open: boolean) {
|
||||
const current = $paneStates.get()
|
||||
const existing = current[id]
|
||||
|
||||
if (existing?.open === open) {return}
|
||||
$paneStates.set({ ...current, [id]: { open, widthOverride: existing?.widthOverride } })
|
||||
}
|
||||
|
||||
export function togglePane(id: string) {
|
||||
const current = $paneStates.get()
|
||||
const existing = current[id]
|
||||
$paneStates.set({ ...current, [id]: { open: !(existing?.open ?? false), widthOverride: existing?.widthOverride } })
|
||||
}
|
||||
|
||||
export function setPaneWidthOverride(id: string, width: number | undefined) {
|
||||
const current = $paneStates.get()
|
||||
const existing = current[id] ?? { open: false }
|
||||
|
||||
if (existing.widthOverride === width) {return}
|
||||
$paneStates.set({ ...current, [id]: { open: existing.open, widthOverride: width } })
|
||||
}
|
||||
|
||||
export const clearPaneWidthOverride = (id: string) => setPaneWidthOverride(id, undefined)
|
||||
export const getPaneStateSnapshot = (id: string) => $paneStates.get()[id]
|
||||
@@ -0,0 +1,131 @@
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
|
||||
|
||||
import {
|
||||
$filePreviewTarget,
|
||||
$previewServerRestart,
|
||||
$previewServerRestartStatus,
|
||||
$previewTarget,
|
||||
$sessionPreviewRegistry,
|
||||
beginPreviewServerRestart,
|
||||
clearSessionPreviewRegistry,
|
||||
dismissFilePreviewTarget,
|
||||
dismissPreviewTarget,
|
||||
getSessionPreviewRecord,
|
||||
type PreviewTarget,
|
||||
progressPreviewServerRestart,
|
||||
setCurrentSessionPreviewTarget
|
||||
} from './preview'
|
||||
import { $activeSessionId, $selectedStoredSessionId } from './session'
|
||||
|
||||
function previewTarget(source: string): PreviewTarget {
|
||||
return {
|
||||
kind: 'file',
|
||||
label: source,
|
||||
path: source,
|
||||
previewKind: 'html',
|
||||
source,
|
||||
url: `file://${source}`
|
||||
}
|
||||
}
|
||||
|
||||
function withRenderMode(target: PreviewTarget, renderMode: PreviewTarget['renderMode']): PreviewTarget {
|
||||
return { ...target, renderMode }
|
||||
}
|
||||
|
||||
describe('preview store', () => {
|
||||
beforeEach(() => {
|
||||
$previewServerRestart.set(null)
|
||||
$activeSessionId.set('session-1')
|
||||
$selectedStoredSessionId.set(null)
|
||||
window.localStorage.clear()
|
||||
clearSessionPreviewRegistry()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
$previewServerRestart.set(null)
|
||||
$activeSessionId.set(null)
|
||||
$selectedStoredSessionId.set(null)
|
||||
window.localStorage.clear()
|
||||
clearSessionPreviewRegistry()
|
||||
})
|
||||
|
||||
it('does not notify status subscribers for restart progress text', () => {
|
||||
const statuses: string[] = []
|
||||
const unsubscribe = $previewServerRestartStatus.subscribe(status => statuses.push(status))
|
||||
|
||||
beginPreviewServerRestart('task-1', 'http://localhost:5174')
|
||||
progressPreviewServerRestart('task-1', 'first line')
|
||||
progressPreviewServerRestart('task-1', 'second line')
|
||||
unsubscribe()
|
||||
|
||||
expect(statuses).toEqual(['idle', 'running'])
|
||||
})
|
||||
|
||||
it('persists registered previews and dismissal per session', () => {
|
||||
const target = previewTarget('/work/demo.html')
|
||||
|
||||
setCurrentSessionPreviewTarget(target, 'tool-result')
|
||||
|
||||
expect($previewTarget.get()).toEqual(withRenderMode(target, 'preview'))
|
||||
expect(getSessionPreviewRecord('session-1')?.normalized).toEqual(withRenderMode(target, 'preview'))
|
||||
expect(window.localStorage.getItem('hermes.desktop.sessionPreviews.v1')).toContain('/work/demo.html')
|
||||
|
||||
dismissPreviewTarget()
|
||||
|
||||
expect($previewTarget.get()).toBeNull()
|
||||
expect(getSessionPreviewRecord('session-1')).toBeNull()
|
||||
expect($sessionPreviewRegistry.get()['session-1']?.[0]?.dismissedAt).toEqual(expect.any(Number))
|
||||
|
||||
setCurrentSessionPreviewTarget(target, 'tool-result')
|
||||
|
||||
expect(getSessionPreviewRecord('session-1')?.dismissedAt).toBeUndefined()
|
||||
})
|
||||
|
||||
it('replaces the session preview instead of keeping a back stack', () => {
|
||||
const first = previewTarget('/work/first.html')
|
||||
const second = previewTarget('/work/second.html')
|
||||
|
||||
setCurrentSessionPreviewTarget(first, 'tool-result')
|
||||
setCurrentSessionPreviewTarget(second, 'tool-result')
|
||||
|
||||
expect($sessionPreviewRegistry.get()['session-1']).toHaveLength(1)
|
||||
expect(getSessionPreviewRecord('session-1')?.normalized).toEqual(withRenderMode(second, 'preview'))
|
||||
|
||||
dismissPreviewTarget()
|
||||
|
||||
expect($previewTarget.get()).toBeNull()
|
||||
expect(getSessionPreviewRecord('session-1')).toBeNull()
|
||||
expect($sessionPreviewRegistry.get()['session-1']?.map(record => record.normalized.url)).toEqual([
|
||||
'file:///work/second.html'
|
||||
])
|
||||
})
|
||||
|
||||
it('keeps file inspection separate from live preview', () => {
|
||||
const target = previewTarget('/work/demo.html')
|
||||
const preview = previewTarget('/work/live.html')
|
||||
|
||||
setCurrentSessionPreviewTarget(preview, 'tool-result')
|
||||
|
||||
setCurrentSessionPreviewTarget(target, 'manual')
|
||||
|
||||
expect($filePreviewTarget.get()).toEqual(withRenderMode(target, 'source'))
|
||||
expect($previewTarget.get()).toEqual(withRenderMode(preview, 'preview'))
|
||||
expect(getSessionPreviewRecord('session-1')?.normalized).toEqual(withRenderMode(preview, 'preview'))
|
||||
|
||||
dismissFilePreviewTarget()
|
||||
|
||||
expect($filePreviewTarget.get()).toBeNull()
|
||||
expect($previewTarget.get()).toEqual(withRenderMode(preview, 'preview'))
|
||||
})
|
||||
|
||||
it('clears file inspection when a live preview opens', () => {
|
||||
const file = previewTarget('/work/file.html')
|
||||
const live = previewTarget('/work/live.html')
|
||||
|
||||
setCurrentSessionPreviewTarget(file, 'manual')
|
||||
setCurrentSessionPreviewTarget(live, 'tool-result')
|
||||
|
||||
expect($filePreviewTarget.get()).toBeNull()
|
||||
expect($previewTarget.get()).toEqual(withRenderMode(live, 'preview'))
|
||||
})
|
||||
})
|
||||
@@ -1,8 +1,18 @@
|
||||
import { atom } from 'nanostores'
|
||||
import { atom, computed } from 'nanostores'
|
||||
|
||||
import { $activeSessionId, $selectedStoredSessionId } from './session'
|
||||
|
||||
export interface PreviewTarget {
|
||||
binary?: boolean
|
||||
byteSize?: number
|
||||
kind: 'file' | 'url'
|
||||
label: string
|
||||
large?: boolean
|
||||
language?: string
|
||||
mimeType?: string
|
||||
path?: string
|
||||
previewKind?: 'binary' | 'html' | 'image' | 'text'
|
||||
renderMode?: 'preview' | 'source'
|
||||
source: string
|
||||
url: string
|
||||
}
|
||||
@@ -14,9 +24,33 @@ export interface PreviewServerRestart {
|
||||
url: string
|
||||
}
|
||||
|
||||
export type PreviewRecordSource = 'explicit-link' | 'file-browser' | 'manual' | 'tool-result'
|
||||
|
||||
export interface SessionPreviewRecord {
|
||||
autoOpen?: boolean
|
||||
createdAt: number
|
||||
dismissedAt?: number
|
||||
id: string
|
||||
normalized: PreviewTarget
|
||||
sessionId: string
|
||||
source: PreviewRecordSource
|
||||
target: string
|
||||
}
|
||||
|
||||
type SessionPreviewRegistry = Record<string, SessionPreviewRecord[]>
|
||||
|
||||
const REGISTRY_STORAGE_KEY = 'hermes.desktop.sessionPreviews.v1'
|
||||
const MAX_RECORDS_PER_SESSION = 1
|
||||
const MAX_SESSIONS = 120
|
||||
|
||||
export const $previewTarget = atom<PreviewTarget | null>(null)
|
||||
export const $filePreviewTarget = atom<PreviewTarget | null>(null)
|
||||
export const $previewReloadRequest = atom(0)
|
||||
export const $previewServerRestart = atom<PreviewServerRestart | null>(null)
|
||||
export const $previewServerRestartStatus = computed($previewServerRestart, restart => restart?.status ?? 'idle')
|
||||
export const $sessionPreviewRegistry = atom<SessionPreviewRegistry>(loadSessionPreviewRegistry())
|
||||
|
||||
$sessionPreviewRegistry.subscribe(persistSessionPreviewRegistry)
|
||||
|
||||
function isSamePreviewTarget(a: PreviewTarget | null, b: PreviewTarget | null): boolean {
|
||||
if (a === b) {
|
||||
@@ -27,7 +61,7 @@ function isSamePreviewTarget(a: PreviewTarget | null, b: PreviewTarget | null):
|
||||
return false
|
||||
}
|
||||
|
||||
return a.kind === b.kind && a.label === b.label && a.source === b.source && a.url === b.url
|
||||
return a.kind === b.kind && a.label === b.label && a.renderMode === b.renderMode && a.source === b.source && a.url === b.url
|
||||
}
|
||||
|
||||
export function setPreviewTarget(target: PreviewTarget | null) {
|
||||
@@ -38,6 +72,252 @@ export function setPreviewTarget(target: PreviewTarget | null) {
|
||||
$previewTarget.set(target)
|
||||
}
|
||||
|
||||
export function setFilePreviewTarget(target: PreviewTarget | null) {
|
||||
if (isSamePreviewTarget($filePreviewTarget.get(), target)) {
|
||||
return
|
||||
}
|
||||
|
||||
$filePreviewTarget.set(target)
|
||||
}
|
||||
|
||||
function isPreviewTarget(value: unknown): value is PreviewTarget {
|
||||
if (!value || typeof value !== 'object') {return false}
|
||||
const r = value as Record<string, unknown>
|
||||
|
||||
return (
|
||||
(r.kind === 'file' || r.kind === 'url') &&
|
||||
typeof r.label === 'string' &&
|
||||
typeof r.source === 'string' &&
|
||||
typeof r.url === 'string'
|
||||
)
|
||||
}
|
||||
|
||||
function isPreviewRecord(value: unknown): value is SessionPreviewRecord {
|
||||
if (!value || typeof value !== 'object') {return false}
|
||||
const r = value as Record<string, unknown>
|
||||
|
||||
return (
|
||||
typeof r.createdAt === 'number' &&
|
||||
typeof r.id === 'string' &&
|
||||
isPreviewTarget(r.normalized) &&
|
||||
typeof r.sessionId === 'string' &&
|
||||
['explicit-link', 'file-browser', 'manual', 'tool-result'].includes(String(r.source)) &&
|
||||
typeof r.target === 'string' &&
|
||||
(r.dismissedAt === undefined || typeof r.dismissedAt === 'number')
|
||||
)
|
||||
}
|
||||
|
||||
function loadSessionPreviewRegistry(): SessionPreviewRegistry {
|
||||
if (typeof window === 'undefined') {return {}}
|
||||
|
||||
try {
|
||||
const raw = window.localStorage.getItem(REGISTRY_STORAGE_KEY)
|
||||
|
||||
if (!raw) {return {}}
|
||||
const parsed = JSON.parse(raw) as unknown
|
||||
|
||||
if (!parsed || typeof parsed !== 'object') {return {}}
|
||||
const out: SessionPreviewRegistry = {}
|
||||
|
||||
for (const [sessionId, records] of Object.entries(parsed as Record<string, unknown>)) {
|
||||
if (!Array.isArray(records)) {continue}
|
||||
const valid = records.filter(isPreviewRecord).slice(0, MAX_RECORDS_PER_SESSION)
|
||||
|
||||
if (valid.length > 0) {out[sessionId] = valid}
|
||||
}
|
||||
|
||||
return pruneRegistry(out)
|
||||
} catch {
|
||||
return {}
|
||||
}
|
||||
}
|
||||
|
||||
function persistSessionPreviewRegistry(registry: SessionPreviewRegistry) {
|
||||
if (typeof window === 'undefined') {return}
|
||||
|
||||
try {
|
||||
window.localStorage.setItem(REGISTRY_STORAGE_KEY, JSON.stringify(pruneRegistry(registry)))
|
||||
} catch {
|
||||
// Session previews are a desktop convenience; storage failures are nonfatal.
|
||||
}
|
||||
}
|
||||
|
||||
function pruneRegistry(registry: SessionPreviewRegistry): SessionPreviewRegistry {
|
||||
const entries = Object.entries(registry)
|
||||
.map(([sessionId, records]) => [
|
||||
sessionId,
|
||||
[...records].sort((a, b) => b.createdAt - a.createdAt).slice(0, MAX_RECORDS_PER_SESSION)
|
||||
] as const)
|
||||
.filter(([, records]) => records.length > 0)
|
||||
.sort(([, a], [, b]) => (b[0]?.createdAt ?? 0) - (a[0]?.createdAt ?? 0))
|
||||
.slice(0, MAX_SESSIONS)
|
||||
|
||||
return Object.fromEntries(entries)
|
||||
}
|
||||
|
||||
function currentPreviewSessionId(): string {
|
||||
return $selectedStoredSessionId.get() || $activeSessionId.get() || ''
|
||||
}
|
||||
|
||||
function recordId(sessionId: string, target: PreviewTarget): string {
|
||||
return `${sessionId}:${target.url}`
|
||||
}
|
||||
|
||||
export function registerSessionPreview(
|
||||
sessionId: string | null | undefined,
|
||||
target: PreviewTarget,
|
||||
source: PreviewRecordSource,
|
||||
rawTarget = target.source
|
||||
): SessionPreviewRecord | null {
|
||||
const id = sessionId?.trim()
|
||||
|
||||
if (!id) {return null}
|
||||
|
||||
const current = $sessionPreviewRegistry.get()
|
||||
const now = Date.now()
|
||||
const records = current[id] ?? []
|
||||
const existing = records.find(record => record.normalized.url === target.url)
|
||||
const normalized = previewTargetForSource(target, source)
|
||||
|
||||
const nextRecord: SessionPreviewRecord = {
|
||||
autoOpen: true,
|
||||
createdAt: now,
|
||||
id: existing?.id || recordId(id, target),
|
||||
normalized,
|
||||
sessionId: id,
|
||||
source,
|
||||
target: rawTarget || target.source
|
||||
}
|
||||
|
||||
$sessionPreviewRegistry.set(
|
||||
pruneRegistry({
|
||||
...current,
|
||||
[id]: [nextRecord]
|
||||
})
|
||||
)
|
||||
|
||||
return nextRecord
|
||||
}
|
||||
|
||||
function previewTargetForSource(target: PreviewTarget, source: PreviewRecordSource): PreviewTarget {
|
||||
if (target.kind !== 'file' || target.previewKind !== 'html') {
|
||||
return target
|
||||
}
|
||||
|
||||
return {
|
||||
...target,
|
||||
renderMode: source === 'file-browser' || source === 'manual' ? 'source' : 'preview'
|
||||
}
|
||||
}
|
||||
|
||||
function shouldOpenAsFilePreview(target: PreviewTarget, source: PreviewRecordSource): boolean {
|
||||
return target.kind === 'file' && (source === 'file-browser' || source === 'manual')
|
||||
}
|
||||
|
||||
export function registerCurrentSessionPreview(
|
||||
target: PreviewTarget,
|
||||
source: PreviewRecordSource,
|
||||
rawTarget = target.source
|
||||
): SessionPreviewRecord | null {
|
||||
return registerSessionPreview(currentPreviewSessionId(), target, source, rawTarget)
|
||||
}
|
||||
|
||||
export function setSessionPreviewTarget(
|
||||
sessionId: string | null | undefined,
|
||||
target: PreviewTarget,
|
||||
source: PreviewRecordSource,
|
||||
rawTarget = target.source
|
||||
): SessionPreviewRecord | null {
|
||||
if (shouldOpenAsFilePreview(target, source)) {
|
||||
setFilePreviewTarget(previewTargetForSource(target, source))
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
const record = registerSessionPreview(sessionId, target, source, rawTarget)
|
||||
|
||||
setFilePreviewTarget(null)
|
||||
setPreviewTarget(record?.normalized ?? previewTargetForSource(target, source))
|
||||
|
||||
return record
|
||||
}
|
||||
|
||||
export function setCurrentSessionPreviewTarget(
|
||||
target: PreviewTarget,
|
||||
source: PreviewRecordSource,
|
||||
rawTarget = target.source
|
||||
): SessionPreviewRecord | null {
|
||||
if (shouldOpenAsFilePreview(target, source)) {
|
||||
setFilePreviewTarget(previewTargetForSource(target, source))
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
const record = registerCurrentSessionPreview(target, source, rawTarget)
|
||||
|
||||
setFilePreviewTarget(null)
|
||||
setPreviewTarget(record?.normalized ?? previewTargetForSource(target, source))
|
||||
|
||||
return record
|
||||
}
|
||||
|
||||
export function getSessionPreviewRecord(sessionId: string | null | undefined): SessionPreviewRecord | null {
|
||||
const id = sessionId?.trim()
|
||||
|
||||
if (!id) {return null}
|
||||
|
||||
return $sessionPreviewRegistry.get()[id]?.find(record => !record.dismissedAt && record.autoOpen !== false) ?? null
|
||||
}
|
||||
|
||||
export function dismissSessionPreview(sessionId: string | null | undefined, url?: string) {
|
||||
const id = sessionId?.trim()
|
||||
|
||||
if (!id) {return}
|
||||
const current = $sessionPreviewRegistry.get()
|
||||
const records = current[id]
|
||||
|
||||
if (!records?.length) {return}
|
||||
const now = Date.now()
|
||||
const targetUrl = url || records.find(record => !record.dismissedAt)?.normalized.url
|
||||
|
||||
if (!targetUrl) {return}
|
||||
|
||||
// The preview rail is a single active file, not a back stack. Dismissing the
|
||||
// current preview should leave the rail closed instead of revealing an older
|
||||
// record for the same session.
|
||||
const dismissedRecords = records.map(record => ({
|
||||
...record,
|
||||
autoOpen: false,
|
||||
dismissedAt: now
|
||||
}))
|
||||
|
||||
$sessionPreviewRegistry.set({
|
||||
...current,
|
||||
[id]: dismissedRecords
|
||||
})
|
||||
}
|
||||
|
||||
/** User clicked the close X — clear the target and persist dismissal for the current session. */
|
||||
export function dismissPreviewTarget() {
|
||||
const current = $previewTarget.get()
|
||||
|
||||
if (current?.url) {
|
||||
dismissSessionPreview(currentPreviewSessionId(), current.url)
|
||||
}
|
||||
|
||||
$previewTarget.set(null)
|
||||
}
|
||||
|
||||
export function dismissFilePreviewTarget() {
|
||||
setFilePreviewTarget(null)
|
||||
}
|
||||
|
||||
export function clearSessionPreviewRegistry() {
|
||||
$sessionPreviewRegistry.set({})
|
||||
setPreviewTarget(null)
|
||||
setFilePreviewTarget(null)
|
||||
}
|
||||
|
||||
export function requestPreviewReload() {
|
||||
$previewReloadRequest.set($previewReloadRequest.get() + 1)
|
||||
}
|
||||
|
||||
@@ -4,12 +4,60 @@ import { persistBoolean, storedBoolean } from '@/lib/storage'
|
||||
|
||||
export type ToolViewMode = 'product' | 'technical'
|
||||
|
||||
type ToolDisclosureStates = Record<string, boolean>
|
||||
|
||||
const TOOL_VIEW_TECHNICAL_STORAGE_KEY = 'hermes.desktop.toolView.technical'
|
||||
const TOOL_DISCLOSURE_STORAGE_KEY = 'hermes.desktop.toolDisclosure.v1'
|
||||
const MAX_DISCLOSURE_STATES = 240
|
||||
|
||||
export const $toolViewMode = atom<ToolViewMode>(storedBoolean(TOOL_VIEW_TECHNICAL_STORAGE_KEY, false) ? 'technical' : 'product')
|
||||
export const $toolDisclosureStates = atom<ToolDisclosureStates>(loadToolDisclosureStates())
|
||||
|
||||
$toolViewMode.subscribe(mode => persistBoolean(TOOL_VIEW_TECHNICAL_STORAGE_KEY, mode === 'technical'))
|
||||
$toolDisclosureStates.subscribe(persistToolDisclosureStates)
|
||||
|
||||
export function setToolViewMode(mode: ToolViewMode) {
|
||||
$toolViewMode.set(mode)
|
||||
}
|
||||
|
||||
function loadToolDisclosureStates(): ToolDisclosureStates {
|
||||
if (typeof window === 'undefined') {return {}}
|
||||
|
||||
try {
|
||||
const raw = window.localStorage.getItem(TOOL_DISCLOSURE_STORAGE_KEY)
|
||||
|
||||
if (!raw) {return {}}
|
||||
const parsed = JSON.parse(raw) as unknown
|
||||
|
||||
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {return {}}
|
||||
|
||||
return Object.fromEntries(
|
||||
Object.entries(parsed as Record<string, unknown>)
|
||||
.filter((entry): entry is [string, boolean] => typeof entry[0] === 'string' && typeof entry[1] === 'boolean')
|
||||
.slice(-MAX_DISCLOSURE_STATES)
|
||||
)
|
||||
} catch {
|
||||
return {}
|
||||
}
|
||||
}
|
||||
|
||||
function persistToolDisclosureStates(states: ToolDisclosureStates) {
|
||||
if (typeof window === 'undefined') {return}
|
||||
|
||||
try {
|
||||
const entries = Object.entries(states).slice(-MAX_DISCLOSURE_STATES)
|
||||
|
||||
window.localStorage.setItem(TOOL_DISCLOSURE_STORAGE_KEY, JSON.stringify(Object.fromEntries(entries)))
|
||||
} catch {
|
||||
// Tool disclosure is a local UI preference; ignore storage failures.
|
||||
}
|
||||
}
|
||||
|
||||
export function setToolDisclosureOpen(id: string, open: boolean) {
|
||||
if (!id) {return}
|
||||
const current = $toolDisclosureStates.get()
|
||||
|
||||
if (current[id] === open) {return}
|
||||
|
||||
$toolDisclosureStates.set({ ...current, [id]: open })
|
||||
}
|
||||
|
||||
+126
-43
@@ -83,7 +83,7 @@
|
||||
:root {
|
||||
color-scheme: dark;
|
||||
|
||||
/* Fallback values (Claude Light theme) — ThemeProvider overwrites on mount. */
|
||||
/* Default visual tokens. ThemeProvider only overrides colors and font families. */
|
||||
--dt-background: #f7f7f7;
|
||||
--dt-foreground: #242424;
|
||||
--dt-card: #ffffff;
|
||||
@@ -110,7 +110,7 @@
|
||||
|
||||
--dt-font-sans: -apple-system, BlinkMacSystemFont, 'SF Pro Text', 'Segoe UI', system-ui, sans-serif;
|
||||
--dt-font-mono: 'SF Mono', ui-monospace, 'Cascadia Code', Menlo, Consolas, monospace;
|
||||
--dt-base-size: 0.9375rem;
|
||||
--dt-base-size: 0.875rem;
|
||||
--dt-line-height: 1.55;
|
||||
--dt-letter-spacing: 0;
|
||||
--dt-spacing-mul: 1;
|
||||
@@ -175,7 +175,7 @@
|
||||
}
|
||||
|
||||
html {
|
||||
font-size: var(--dt-base-size, 0.9375rem);
|
||||
font-size: var(--dt-base-size, 0.875rem);
|
||||
}
|
||||
|
||||
body {
|
||||
@@ -282,7 +282,7 @@ canvas {
|
||||
* react-virtuoso) with stable item sizing — not a CSS heuristic.
|
||||
*/
|
||||
|
||||
.aui-md img {
|
||||
[data-slot='aui_assistant-message-content'] .aui-md img {
|
||||
display: block;
|
||||
width: auto;
|
||||
height: auto;
|
||||
@@ -296,11 +296,11 @@ canvas {
|
||||
0 0.625rem 1.5rem color-mix(in srgb, #000 5%, transparent);
|
||||
}
|
||||
|
||||
.aui-md [data-slot='aui_markdown-image'] {
|
||||
[data-slot='aui_assistant-message-content'] .aui-md [data-slot='aui_markdown-image'] {
|
||||
max-width: min(100%, var(--image-preview-max-width));
|
||||
}
|
||||
|
||||
.aui-md {
|
||||
[data-slot='aui_assistant-message-content'] .aui-md {
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
@@ -324,17 +324,62 @@ canvas {
|
||||
scroll-behavior: auto;
|
||||
}
|
||||
|
||||
.aui-md a,
|
||||
.aui-md code {
|
||||
[data-slot='aui_assistant-message-content'] .aui-md a {
|
||||
overflow-wrap: anywhere;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.aui-md p:has(> img:only-child) {
|
||||
/**
|
||||
* Inline `code`: monospace pill with a subtle background. Scoped to plain
|
||||
* inline code only — fenced blocks are handled by the SyntaxHighlighter
|
||||
* component and live inside `[data-streamdown='code-block']`, so we explicitly
|
||||
* unset there to keep the highlighter rendering its own chrome.
|
||||
*/
|
||||
[data-slot='aui_assistant-message-content'] .aui-md code {
|
||||
/* Inline (not inside a fenced code-block). Use plain inline rendering so
|
||||
* the surrounding `<p>` (wrap-anywhere) can break long paths/URLs at any
|
||||
* character. inline-block + white-space: nowrap was leaking long paths
|
||||
* past the chat column under any sibling pane width. */
|
||||
display: inline;
|
||||
max-width: 100%;
|
||||
font-family: ui-monospace, 'SF Mono', 'Cascadia Code', Menlo, Consolas, monospace;
|
||||
font-size: 0.86em;
|
||||
padding: 0.01rem 0.16rem;
|
||||
border-radius: 0.2rem;
|
||||
background: #f4f4f5;
|
||||
color: #be185d;
|
||||
border: 0;
|
||||
overflow-wrap: anywhere;
|
||||
word-break: break-word;
|
||||
white-space: normal;
|
||||
}
|
||||
|
||||
:root.dark [data-slot='aui_assistant-message-content'] .aui-md code {
|
||||
background: #27272a;
|
||||
color: #f9a8d4;
|
||||
border-color: #831843;
|
||||
}
|
||||
|
||||
[data-slot='aui_assistant-message-content'] .aui-md [data-streamdown='code-block'] code {
|
||||
max-width: none;
|
||||
font-family: inherit;
|
||||
font-size: inherit;
|
||||
padding: 0;
|
||||
border-radius: 0;
|
||||
background: transparent;
|
||||
color: inherit;
|
||||
overflow-x: visible;
|
||||
overflow-wrap: inherit;
|
||||
vertical-align: baseline;
|
||||
word-break: inherit;
|
||||
white-space: inherit;
|
||||
}
|
||||
|
||||
[data-slot='aui_assistant-message-content'] .aui-md p:has(> img:only-child) {
|
||||
margin-block: 0.75rem;
|
||||
}
|
||||
|
||||
.aui-md p:has(> [data-slot='aui_markdown-image']:only-child) {
|
||||
[data-slot='aui_assistant-message-content'] .aui-md p:has(> [data-slot='aui_markdown-image']:only-child) {
|
||||
margin-block: 0.75rem;
|
||||
}
|
||||
|
||||
@@ -346,14 +391,14 @@ canvas {
|
||||
* section breaks. Margin collapse picks the larger neighbor — producing
|
||||
* "more above headings, less below" without per-pair rules.
|
||||
*/
|
||||
.aui-md p,
|
||||
.aui-md ul,
|
||||
.aui-md ol,
|
||||
.aui-md blockquote,
|
||||
.aui-md pre,
|
||||
.aui-md table,
|
||||
.aui-md [data-streamdown='code-block'],
|
||||
.aui-md div:has(> table) {
|
||||
[data-slot='aui_assistant-message-content'] .aui-md p,
|
||||
[data-slot='aui_assistant-message-content'] .aui-md ul,
|
||||
[data-slot='aui_assistant-message-content'] .aui-md ol,
|
||||
[data-slot='aui_assistant-message-content'] .aui-md blockquote,
|
||||
[data-slot='aui_assistant-message-content'] .aui-md pre,
|
||||
[data-slot='aui_assistant-message-content'] .aui-md table,
|
||||
[data-slot='aui_assistant-message-content'] .aui-md [data-streamdown='code-block'],
|
||||
[data-slot='aui_assistant-message-content'] .aui-md div:has(> table) {
|
||||
margin: 0 0 1rem;
|
||||
}
|
||||
|
||||
@@ -361,8 +406,7 @@ canvas {
|
||||
* with `flex flex-col gap-2 p-2 border bg-sidebar rounded-xl my-4`. Our own
|
||||
* CodeHeader + SyntaxHighlighter already supply the chrome, so undo the
|
||||
* library's wrapper to keep the header flush with the code body. */
|
||||
.aui-md [data-streamdown='code-block'],
|
||||
[data-streamdown='code-block'] {
|
||||
[data-slot='aui_assistant-message-content'] .aui-md [data-streamdown='code-block'] {
|
||||
padding: 0 !important;
|
||||
gap: 0 !important;
|
||||
border: 0 !important;
|
||||
@@ -371,67 +415,106 @@ canvas {
|
||||
margin: 1rem 0 !important;
|
||||
}
|
||||
|
||||
.aui-md [data-streamdown='code-block'] > *,
|
||||
[data-streamdown='code-block'] > * {
|
||||
[data-slot='aui_assistant-message-content'] .aui-md [data-streamdown='code-block'] > * {
|
||||
margin: 0 !important;
|
||||
}
|
||||
|
||||
.aui-md h1 {
|
||||
[data-slot='aui_assistant-message-content'] .aui-md h1 {
|
||||
margin: 1.6rem 0 0.55rem;
|
||||
}
|
||||
.aui-md h2 {
|
||||
[data-slot='aui_assistant-message-content'] .aui-md h2 {
|
||||
margin: 1.4rem 0 0.5rem;
|
||||
}
|
||||
.aui-md h3 {
|
||||
[data-slot='aui_assistant-message-content'] .aui-md h3 {
|
||||
margin: 1.15rem 0 0.45rem;
|
||||
}
|
||||
.aui-md h4 {
|
||||
[data-slot='aui_assistant-message-content'] .aui-md h4 {
|
||||
margin: 0.95rem 0 0.4rem;
|
||||
}
|
||||
.aui-md hr {
|
||||
|
||||
[data-slot='aui_assistant-message-content'] .aui-md hr {
|
||||
margin: 1.5rem 0;
|
||||
}
|
||||
|
||||
/* `padding-left` keeps outside-position list markers in the gutter. */
|
||||
.aui-md ul,
|
||||
.aui-md ol {
|
||||
[data-slot='aui_assistant-message-content'] .aui-md ul,
|
||||
[data-slot='aui_assistant-message-content'] .aui-md ol {
|
||||
padding-left: 1.75rem;
|
||||
}
|
||||
|
||||
/* Tight inter-bullet gap; loose items override below. */
|
||||
.aui-md li + li {
|
||||
[data-slot='aui_assistant-message-content'] .aui-md li + li {
|
||||
margin-top: 0.375rem;
|
||||
}
|
||||
|
||||
/* Inside a bullet, hug nested blocks to the lead text. */
|
||||
.aui-md li > p {
|
||||
[data-slot='aui_assistant-message-content'] .aui-md li > p {
|
||||
margin-bottom: 0.4rem;
|
||||
}
|
||||
.aui-md li > ul,
|
||||
.aui-md li > ol {
|
||||
[data-slot='aui_assistant-message-content'] .aui-md li > ul,
|
||||
[data-slot='aui_assistant-message-content'] .aui-md li > ol {
|
||||
margin-top: 0.4rem;
|
||||
}
|
||||
|
||||
/* Loose list items (CommonMark wraps each in <p> when any sibling has a
|
||||
block child) need visible separation — the tight rhythm collapses
|
||||
against a trailing heavy block like a code fence. */
|
||||
.aui-md li:has(> p) {
|
||||
[data-slot='aui_assistant-message-content'] .aui-md li:has(> p) {
|
||||
margin-bottom: 0.85rem;
|
||||
}
|
||||
.aui-md li:has(> p):last-child {
|
||||
[data-slot='aui_assistant-message-content'] .aui-md li:has(> p):last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
/* Trim edge margins at the container, list items, and blockquotes. */
|
||||
.aui-md > :first-child,
|
||||
.aui-md > * > :first-child,
|
||||
.aui-md li > :first-child,
|
||||
.aui-md blockquote > :first-child {
|
||||
[data-slot='aui_assistant-message-content'] .aui-md > :first-child,
|
||||
[data-slot='aui_assistant-message-content'] .aui-md > * > :first-child,
|
||||
[data-slot='aui_assistant-message-content'] .aui-md li > :first-child,
|
||||
[data-slot='aui_assistant-message-content'] .aui-md blockquote > :first-child {
|
||||
margin-top: 0;
|
||||
}
|
||||
.aui-md > :last-child,
|
||||
.aui-md > * > :last-child,
|
||||
.aui-md li > :last-child,
|
||||
.aui-md blockquote > :last-child {
|
||||
[data-slot='aui_assistant-message-content'] .aui-md > :last-child,
|
||||
[data-slot='aui_assistant-message-content'] .aui-md > * > :last-child,
|
||||
[data-slot='aui_assistant-message-content'] .aui-md li > :last-child,
|
||||
[data-slot='aui_assistant-message-content'] .aui-md blockquote > :last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Intelligent spacing around inline tool/thinking blocks inside an
|
||||
* assistant message.
|
||||
*
|
||||
* Two cases to balance:
|
||||
* 1. Two tool/thinking rows next to each other → keep them tight so a
|
||||
* multi-step turn reads as one continuous activity column.
|
||||
* 2. A tool/thinking row next to prose (markdown text) → give it real
|
||||
* breathing room so the activity feels like a separate panel and
|
||||
* doesn't crowd the paragraph above or below.
|
||||
*
|
||||
* Markdown text is wrapped in `.aui-md`; tool/thinking rows expose
|
||||
* `data-slot="tool-block"`. Sibling combinators handle the rest.
|
||||
*/
|
||||
[data-slot='tool-block'] + [data-slot='tool-block'] {
|
||||
margin-top: 0.25rem;
|
||||
}
|
||||
|
||||
/**
|
||||
* When the previous tool/thinking block is expanded (showing its content),
|
||||
* give the next block visible breathing room so they don't read as one
|
||||
* continuous chunk. The :has() guard keeps the tight rhythm between two
|
||||
* collapsed rows.
|
||||
*/
|
||||
[data-slot='tool-block']:has(> :nth-child(2)) + [data-slot='tool-block'] {
|
||||
margin-top: 0.625rem;
|
||||
}
|
||||
|
||||
[data-slot='aui_assistant-message-content'] [data-slot='tool-block'] + .aui-md,
|
||||
[data-slot='aui_assistant-message-content'] .aui-md + [data-slot='tool-block'] {
|
||||
margin-top: 0.875rem;
|
||||
}
|
||||
|
||||
/* When the assistant message starts with a tool block, don't pile padding
|
||||
* above it — the message header already provides spacing. */
|
||||
[data-slot='aui_assistant-message-content'] > [data-slot='tool-block']:first-child {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
@@ -2,8 +2,8 @@
|
||||
* Desktop theme context.
|
||||
*
|
||||
* Applies the active theme as CSS custom properties on :root, making every
|
||||
* Tailwind utility that references a `--color-*` / `--radius` / `--font-*`
|
||||
* variable pick up the change automatically.
|
||||
* Tailwind utility that references a color or font-family token pick up the
|
||||
* change automatically.
|
||||
*
|
||||
* Persists mode (light/dark/system) and skin separately. Mode controls
|
||||
* brightness; skin controls accent family.
|
||||
@@ -16,13 +16,12 @@ import { matchesQuery, useMediaQuery } from '@/hooks/use-media-query'
|
||||
import {
|
||||
BUILTIN_THEME_LIST,
|
||||
BUILTIN_THEMES,
|
||||
DEFAULT_LAYOUT,
|
||||
DEFAULT_TYPOGRAPHY,
|
||||
defaultTheme,
|
||||
nousLightTheme,
|
||||
nousTheme
|
||||
} from './presets'
|
||||
import type { DesktopTheme, DesktopThemeColors, ThemeDensity } from './types'
|
||||
import type { DesktopTheme, DesktopThemeColors } from './types'
|
||||
|
||||
const STORAGE_KEY = 'hermes-desktop-theme-v2' // Stores skin name.
|
||||
const MODE_KEY = 'hermes-desktop-mode-v1'
|
||||
@@ -30,12 +29,6 @@ const DEFAULT_SKIN = 'default'
|
||||
|
||||
export type ThemeMode = 'light' | 'dark' | 'system'
|
||||
|
||||
const DENSITY_MULTIPLIERS: Record<ThemeDensity, string> = {
|
||||
compact: '0.85',
|
||||
comfortable: '1',
|
||||
spacious: '1.2'
|
||||
}
|
||||
|
||||
const INJECTED_FONT_URLS = new Set<string>()
|
||||
const SKIN_THEME_LIST = BUILTIN_THEME_LIST.filter(t => t.name !== 'nous-light')
|
||||
|
||||
@@ -180,8 +173,7 @@ function deriveTheme(skinName: string, mode: 'light' | 'dark'): DesktopTheme {
|
||||
label: `${isDefault ? 'Hermes' : seed.label} ${mode === 'light' ? 'Light' : 'Dark'}`,
|
||||
description: `${seed.label} ${mode} palette`,
|
||||
colors: mode === 'light' ? lightColors(seed, skinName) : darkColors(seed, skinName),
|
||||
typography: fontOnly(seed),
|
||||
layout: undefined
|
||||
typography: fontOnly(seed)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -220,7 +212,6 @@ function applyTheme(theme: DesktopTheme, mode: 'light' | 'dark') {
|
||||
|
||||
const root = document.documentElement
|
||||
const typo = { ...DEFAULT_TYPOGRAPHY, ...NOUS_FONT_FAMILY_FALLBACK, ...theme.typography }
|
||||
const layout = { ...DEFAULT_LAYOUT, ...theme.layout }
|
||||
const c = theme.colors
|
||||
|
||||
const rendered = renderedModeFor(theme.colors, mode)
|
||||
@@ -253,21 +244,14 @@ function applyTheme(theme: DesktopTheme, mode: 'light' | 'dark') {
|
||||
'--dt-sidebar-border': c.sidebarBorder ?? c.border,
|
||||
'--dt-user-bubble': c.userBubble ?? c.muted,
|
||||
'--dt-user-bubble-border': c.userBubbleBorder ?? c.border,
|
||||
'--radius': layout.radius,
|
||||
'--dt-spacing-mul': DENSITY_MULTIPLIERS[layout.density] ?? '1',
|
||||
'--dt-font-sans': typo.fontSans,
|
||||
'--dt-font-mono': typo.fontMono,
|
||||
'--dt-base-size': typo.baseSize,
|
||||
'--dt-line-height': typo.lineHeight,
|
||||
'--dt-letter-spacing': typo.letterSpacing
|
||||
'--dt-font-mono': typo.fontMono
|
||||
}
|
||||
|
||||
for (const [k, v] of Object.entries(vars)) {
|
||||
root.style.setProperty(k, v)
|
||||
}
|
||||
|
||||
root.style.setProperty('font-size', 'var(--dt-base-size)')
|
||||
|
||||
if (typo.fontUrl && !INJECTED_FONT_URLS.has(typo.fontUrl)) {
|
||||
const link = document.createElement('link')
|
||||
link.rel = 'stylesheet'
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
export { ThemeProvider, useSyncThemeFromBackend, useTheme } from './context'
|
||||
export { BUILTIN_THEME_LIST, BUILTIN_THEMES } from './presets'
|
||||
export type { DesktopTheme, DesktopThemeColors, DesktopThemeLayout, DesktopThemeTypography } from './types'
|
||||
export type { DesktopTheme, DesktopThemeColors, DesktopThemeTypography } from './types'
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
* Add new themes here — no code changes needed elsewhere.
|
||||
*/
|
||||
|
||||
import type { DesktopTheme, DesktopThemeLayout, DesktopThemeTypography } from './types'
|
||||
import type { DesktopTheme, DesktopThemeTypography } from './types'
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Shared defaults
|
||||
@@ -21,15 +21,7 @@ const SYSTEM_MONO =
|
||||
|
||||
export const DEFAULT_TYPOGRAPHY: DesktopThemeTypography = {
|
||||
fontSans: SYSTEM_SANS,
|
||||
fontMono: SYSTEM_MONO,
|
||||
baseSize: '0.9375rem',
|
||||
lineHeight: '1.55',
|
||||
letterSpacing: '0'
|
||||
}
|
||||
|
||||
export const DEFAULT_LAYOUT: DesktopThemeLayout = {
|
||||
radius: '0.75rem',
|
||||
density: 'comfortable'
|
||||
fontMono: SYSTEM_MONO
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -122,9 +114,6 @@ export const nousTheme: DesktopTheme = {
|
||||
fontSans: SYSTEM_SANS,
|
||||
fontMono: `"Courier Prime", ${SYSTEM_MONO}`,
|
||||
fontUrl: 'https://fonts.googleapis.com/css2?family=Courier+Prime:wght@400;700&display=swap'
|
||||
},
|
||||
layout: {
|
||||
radius: '0.25rem'
|
||||
}
|
||||
}
|
||||
|
||||
@@ -192,11 +181,7 @@ export const midnightTheme: DesktopTheme = {
|
||||
},
|
||||
typography: {
|
||||
fontMono: `"JetBrains Mono", ${SYSTEM_MONO}`,
|
||||
fontUrl: 'https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;500;700&display=swap',
|
||||
letterSpacing: '-0.005em'
|
||||
},
|
||||
layout: {
|
||||
radius: '0.875rem'
|
||||
fontUrl: 'https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;500;700&display=swap'
|
||||
}
|
||||
}
|
||||
|
||||
@@ -233,9 +218,6 @@ export const emberTheme: DesktopTheme = {
|
||||
typography: {
|
||||
fontMono: `"IBM Plex Mono", ${SYSTEM_MONO}`,
|
||||
fontUrl: 'https://fonts.googleapis.com/css2?family=IBM+Plex+Mono:wght@400;500;700&display=swap'
|
||||
},
|
||||
layout: {
|
||||
radius: '0.375rem'
|
||||
}
|
||||
}
|
||||
|
||||
@@ -268,9 +250,6 @@ export const monoTheme: DesktopTheme = {
|
||||
sidebarBorder: '#202020',
|
||||
userBubble: '#1a1a1a',
|
||||
userBubbleBorder: '#363636'
|
||||
},
|
||||
layout: {
|
||||
radius: '0.375rem'
|
||||
}
|
||||
}
|
||||
|
||||
@@ -306,11 +285,7 @@ export const cyberpunkTheme: DesktopTheme = {
|
||||
},
|
||||
typography: {
|
||||
fontMono: `"Courier New", Courier, monospace`,
|
||||
fontSans: `"Courier New", Courier, monospace`,
|
||||
letterSpacing: '0.02em'
|
||||
},
|
||||
layout: {
|
||||
radius: '0'
|
||||
fontSans: `"Courier New", Courier, monospace`
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
/**
|
||||
* Desktop app theme model.
|
||||
*
|
||||
* Three orthogonal layers:
|
||||
* 1. `colors` — all Tailwind token values written directly to CSS vars.
|
||||
* 2. `typography` — font families, base size, line-height, letter-spacing.
|
||||
* 3. `layout` — corner radius, spacing density.
|
||||
* Two theme layers:
|
||||
* 1. `colors` — Tailwind color token values written directly to CSS vars.
|
||||
* 2. `typography` — font families and optional font stylesheet URL.
|
||||
*
|
||||
* Layout, sizing, spacing, radius, line-height, and letter-spacing live in
|
||||
* `styles.css` so CSS remains the source of truth for app geometry.
|
||||
*
|
||||
* Every field except `name`, `label`, and `description` is optional —
|
||||
* missing values fall back to the `default` theme.
|
||||
@@ -66,21 +68,6 @@ export interface DesktopThemeTypography {
|
||||
fontMono: string
|
||||
/** Optional Google/Bunny/self-hosted font stylesheet URL. */
|
||||
fontUrl?: string
|
||||
/** Root font size: `"0.875rem"`, `"0.9375rem"`, `"1rem"`. */
|
||||
baseSize: string
|
||||
/** Default line height: `"1.5"`, `"1.6"`. */
|
||||
lineHeight: string
|
||||
/** Default letter spacing: `"0"`, `"-0.01em"`. */
|
||||
letterSpacing: string
|
||||
}
|
||||
|
||||
export type ThemeDensity = 'compact' | 'comfortable' | 'spacious'
|
||||
|
||||
export interface DesktopThemeLayout {
|
||||
/** Corner-radius token: `"0"`, `"0.5rem"`, `"1rem"`. */
|
||||
radius: string
|
||||
/** Spacing multiplier. */
|
||||
density: ThemeDensity
|
||||
}
|
||||
|
||||
export interface DesktopTheme {
|
||||
@@ -89,5 +76,4 @@ export interface DesktopTheme {
|
||||
description: string
|
||||
colors: DesktopThemeColors
|
||||
typography?: Partial<DesktopThemeTypography>
|
||||
layout?: Partial<DesktopThemeLayout>
|
||||
}
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
import { useCallback } from 'react'
|
||||
|
||||
import { useTheme } from './context'
|
||||
|
||||
const ALIASES: Record<string, string> = {
|
||||
ares: 'ember',
|
||||
hermes: 'default'
|
||||
}
|
||||
|
||||
export function useSkinCommand() {
|
||||
const { availableThemes, setTheme, themeName } = useTheme()
|
||||
|
||||
return useCallback(
|
||||
(rawArg: string) => {
|
||||
const arg = rawArg.trim()
|
||||
|
||||
if (!availableThemes.length) {return 'No desktop themes are available.'}
|
||||
|
||||
const activeIndex = Math.max(0, availableThemes.findIndex(t => t.name === themeName))
|
||||
|
||||
if (!arg || arg === 'next') {
|
||||
const next = availableThemes[(activeIndex + 1) % availableThemes.length]
|
||||
setTheme(next.name)
|
||||
|
||||
return `Desktop theme switched to ${next.label}.`
|
||||
}
|
||||
|
||||
if (arg === 'list' || arg === 'ls' || arg === 'status') {
|
||||
const rows = availableThemes.map(t => `${t.name === themeName ? '*' : ' '} ${t.name.padEnd(10)} ${t.label}`)
|
||||
|
||||
return ['Desktop themes:', ...rows, '', 'Use /skin <name>, or /skin to cycle.'].join('\n')
|
||||
}
|
||||
|
||||
const normalized = arg.toLowerCase()
|
||||
const targetName = ALIASES[normalized] || normalized
|
||||
const target = availableThemes.find(t => t.name.toLowerCase() === targetName || t.label.toLowerCase() === normalized)
|
||||
|
||||
if (!target) {return `Unknown desktop theme: ${arg}\nAvailable: ${availableThemes.map(t => t.name).join(', ')}`}
|
||||
|
||||
setTheme(target.name)
|
||||
|
||||
return `Desktop theme switched to ${target.label}.`
|
||||
},
|
||||
[availableThemes, setTheme, themeName]
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user