feat: better composer etc

This commit is contained in:
Brooklyn Nicholson
2026-05-04 22:19:16 -05:00
parent 42db075e10
commit fcce49db3f
50 changed files with 1299 additions and 668 deletions
+10 -4
View File
@@ -103,7 +103,11 @@ function ActivityList({ tasks }: { tasks: readonly RailTask[] }) {
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')}
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>
@@ -124,9 +128,11 @@ function SectionStub({ label }: { label: string }) {
<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&apos;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&apos;s{' '}
<code className="rounded bg-muted/60 px-1 py-0.5 font-mono text-[0.65rem]">/agents</code> overlay.
<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&apos;s <code className="rounded bg-muted/60 px-1 py-0.5 font-mono text-[0.65rem]">/agents</code> overlay.
</p>
</div>
</OverlayCard>
@@ -34,7 +34,13 @@ function AttachmentPill({ attachment, onRemove }: { attachment: ComposerAttachme
return
}
const rawTarget = attachment.path || attachment.detail || attachment.refText?.replace(/^@(file|image|url):/, '') || attachment.label || ''
const rawTarget =
attachment.path ||
attachment.detail ||
attachment.refText?.replace(/^@(file|image|url):/, '') ||
attachment.label ||
''
const target = rawTarget.replace(/^`|`$/g, '')
if (!target) {
@@ -55,7 +61,10 @@ function AttachmentPill({ attachment, onRemove }: { attachment: ComposerAttachme
}
return (
<div className="group/attachment relative min-w-0 shrink-0" title={attachment.path || attachment.detail || attachment.label}>
<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"
@@ -77,8 +86,12 @@ function AttachmentPill({ attachment, onRemove }: { attachment: ComposerAttachme
</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 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 && (
+82 -114
View File
@@ -38,14 +38,7 @@ 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 { composerHtml, composerPlainText, escapeHtml, placeCaretEnd, refChipHtml, RICH_INPUT_SLOT } from './rich-editor'
import { SkinSlashPopover } from './skin-slash-popover'
import { ComposerTriggerPopover } from './trigger-popover'
import type { ChatBarProps } from './types'
@@ -112,7 +105,10 @@ function extractClipboardImageBlobs(clipboard: DataTransfer): Blob[] {
}
// Below this composer width the input gets cramped — drop controls onto a second row.
const COMPOSER_STACK_BREAKPOINT_PX = 380
// Floor matches the natural min-content of contextMenu + 8rem input + controls + gaps;
// going higher caused unwanted stacking on empty state when the parent transiently
// reported a tiny width before layout settled.
const COMPOSER_STACK_BREAKPOINT_PX = 320
const COMPOSER_SCROLLED_DIM_CLASS =
'opacity-30 group-hover/composer:opacity-100 group-focus-within/composer:opacity-100'
@@ -142,7 +138,9 @@ 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}
if (!range?.collapsed || !editor.contains(range.commonAncestorContainer)) {
return null
}
const before = range.cloneRange()
before.selectNodeContents(editor)
@@ -154,7 +152,9 @@ function textBeforeCaret(editor: HTMLDivElement): string | null {
function detectTrigger(textBefore: string): TriggerState | null {
const match = TRIGGER_RE.exec(textBefore)
if (!match) {return null}
if (!match) {
return null
}
return { kind: match[1] as '@' | '/', query: match[2], tokenLength: 1 + match[2].length }
}
@@ -202,20 +202,6 @@ export function ChatBar({
const narrow = useMediaQuery('(max-width: 480px)')
const [askPlaceholder] = useState(() => {
const lines = [
'Hey friend, what can I help with?',
"What's on your mind? I'm here with you.",
'Need a hand? We can take it one step at a time.',
'Want to walk through this bug together?',
"Share what you're working on and we'll figure it out.",
"Tell me where you're stuck and I'll stay with you.",
'Duck mode: gentle debugging, together.'
]
return lines[Math.floor(Math.random() * lines.length)] ?? 'Ask anything'
})
const at = useAtCompletions({ gateway: gateway ?? null, sessionId: sessionId ?? null, cwd: cwd ?? null })
const slash = useSlashCompletions({ gateway: gateway ?? null })
@@ -224,13 +210,7 @@ export function ChatBar({
const canSubmit = busy || hasComposerPayload
const showHelpHint = draft === '?'
const placeholder = disabled
? stacked
? 'Starting...'
: 'Starting Hermes...'
: stacked
? 'Ask anything'
: askPlaceholder
const placeholder = disabled ? 'Starting Hermes…' : 'Ask anything'
const glassTweaks = useComposerGlassTweaks()
@@ -280,7 +260,9 @@ export function ChatBar({
return
}
const wraps = (editorRef.current?.scrollHeight ?? 0) > 42
// Threshold deliberately above a single rendered line + padding so font-metric
// jitter on an empty/short editor never triggers spurious expansion.
const wraps = (editorRef.current?.scrollHeight ?? 0) > 56
if (draft.includes('\n') || wraps) {
setExpanded(true)
@@ -294,10 +276,18 @@ export function ChatBar({
return
}
const update = () => setTight(el.getBoundingClientRect().width < COMPOSER_STACK_BREAKPOINT_PX)
// No sync read: getBoundingClientRect() right after mount can return a
// transient pre-layout width that briefly flips the composer into stacked
// mode. ResizeObserver fires once on observe() with the settled width, then
// again on every actual size change.
const ro = new ResizeObserver(() => {
const width = el.getBoundingClientRect().width
if (width > 0) {
setTight(width < COMPOSER_STACK_BREAKPOINT_PX)
}
})
update()
const ro = new ResizeObserver(update)
ro.observe(el)
return () => ro.disconnect()
@@ -402,9 +392,17 @@ export function ChatBar({
return null
}
const kind = candidate.isDirectory ? 'folder' : 'file'
const rel = contextPath(candidate.path, cwd || '')
if (candidate.line) {
const { line, lineEnd } = candidate
const range = lineEnd && lineEnd > line ? `${line}-${lineEnd}` : `${line}`
return `@line:${formatRefValue(`${rel}:${range}`)}`
}
const kind = candidate.isDirectory ? 'folder' : 'file'
return `@${kind}:${formatRefValue(rel)}`
}
@@ -463,7 +461,9 @@ export function ChatBar({
const refreshTrigger = useCallback(() => {
const editor = editorRef.current
if (!editor) {return}
if (!editor) {
return
}
const before = textBeforeCaret(editor)
const detected = detectTrigger(before ?? composerPlainText(editor))
@@ -491,7 +491,8 @@ export function ChatBar({
window.setTimeout(refreshTrigger, 0)
}
const triggerAdapter: Unstable_TriggerAdapter | null = trigger?.kind === '@' ? at.adapter : trigger?.kind === '/' ? slash.adapter : null
const triggerAdapter: Unstable_TriggerAdapter | null =
trigger?.kind === '@' ? at.adapter : trigger?.kind === '/' ? slash.adapter : null
useEffect(() => {
if (!trigger || !triggerAdapter?.search) {
@@ -512,95 +513,71 @@ export function ChatBar({
}
useEffect(() => {
if (!triggerItems.length) {
setTriggerActive(0)
return
}
if (triggerActive >= triggerItems.length) {
setTriggerActive(triggerItems.length - 1)
}
}, [triggerActive, triggerItems.length])
setTriggerActive(idx => Math.min(idx, Math.max(0, triggerItems.length - 1)))
}, [triggerItems.length])
const replaceTriggerWithChip = (item: Unstable_TriggerItem) => {
const editor = editorRef.current
const sel = window.getSelection()
if (!editor || !trigger) {
return
}
const serialized = hermesDirectiveFormatter.serialize(item)
// Starters (`@file:`) drill in: insert verbatim and keep the popover live so
// the user can keep typing the path. Chips/simple refs commit and close.
const starter = serialized.endsWith(':')
const text = starter || serialized.endsWith(' ') ? serialized : `${serialized} `
const directive = !starter && serialized.match(/^@([^:]+):(.+)$/)
const replaceDraftFallback = () => {
const finish = () => {
draftRef.current = composerPlainText(editor)
aui.composer().setText(draftRef.current)
starter ? window.setTimeout(refreshTrigger, 0) : closeTrigger()
}
const sel = window.getSelection()
const range = sel?.rangeCount ? sel.getRangeAt(0) : null
const node = range?.startContainer
const offset = range?.startOffset ?? 0
// No usable caret range — replace from the end of the draft instead.
if (!sel || !range || node?.nodeType !== Node.TEXT_NODE || offset < trigger.tokenLength) {
const current = composerPlainText(editor)
const nextDraft = `${current.slice(0, Math.max(0, current.length - trigger.tokenLength))}${serialized}${
serialized.endsWith(' ') ? '' : ' '
}`
editor.innerHTML = composerHtml(nextDraft)
editor.innerHTML = composerHtml(`${current.slice(0, Math.max(0, current.length - trigger.tokenLength))}${text}`)
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
return finish()
}
const replaceRange = document.createRange()
replaceRange.setStart(startNode, startOffset - trigger.tokenLength)
replaceRange.setEnd(startNode, startOffset)
replaceRange.setStart(node, offset - trigger.tokenLength)
replaceRange.setEnd(node, offset)
replaceRange.deleteContents()
const fragment = document.createDocumentFragment()
const directiveMatch = serialized.match(/^@([^:]+):(.+)$/)
if (directiveMatch) {
if (directive) {
const holder = document.createElement('span')
holder.innerHTML = refChipHtml(directiveMatch[1], directiveMatch[2])
const chipNode = holder.firstChild
holder.innerHTML = refChipHtml(directive[1], directive[2])
const chip = holder.firstChild
if (chipNode) {
fragment.appendChild(chipNode)
if (chip) {
const space = document.createTextNode(' ')
fragment.appendChild(space)
replaceRange.deleteContents()
const fragment = document.createDocumentFragment()
fragment.append(chip, space)
replaceRange.insertNode(fragment)
const after = document.createRange()
after.setStart(space, 1)
after.collapse(true)
const caret = document.createRange()
caret.setStart(space, 1)
caret.collapse(true)
sel.removeAllRanges()
sel.addRange(after)
} else {
replaceRange.deleteContents()
document.execCommand('insertText', false, `${serialized} `)
sel.addRange(caret)
return finish()
}
} else {
replaceRange.deleteContents()
document.execCommand('insertText', false, serialized.endsWith(' ') ? serialized : `${serialized} `)
}
const nextDraft = composerPlainText(editor)
draftRef.current = nextDraft
aui.composer().setText(nextDraft)
closeTrigger()
document.execCommand('insertText', false, text)
finish()
}
const handleEditorKeyDown = (event: KeyboardEvent<HTMLDivElement>) => {
@@ -907,21 +884,12 @@ export function ChatBar({
const input = (
<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>
)}
<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',
'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 disabled:cursor-not-allowed',
'empty:before:content-[attr(data-placeholder)] empty:before:text-muted-foreground/60',
'**:data-ref-text:cursor-default',
stacked && 'pl-3',
stacked ? 'w-full' : 'min-w-(--composer-input-inline-min-width) flex-1'
)}
@@ -6,11 +6,11 @@
* 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'
import { DIRECTIVE_CHIP_CLASS, directiveIconSvg, 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
export const REF_RE = /@(file|folder|url|image|tool|line):(`[^`\n]+`|"[^"\n]+"|'[^'\n]+'|\S+)/g
const ESC: Record<string, string> = { '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#039;' }
@@ -32,11 +32,17 @@ export function refLabel(id: string) {
/** 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}"`}
if (!value.includes('"')) {
return `"${value}"`
}
if (!value.includes("'")) {return `'${value}'`}
if (!value.includes("'")) {
return `'${value}'`
}
return formatRefValue(value)
}
@@ -45,7 +51,7 @@ 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>`
return `<span contenteditable="false" data-ref-text="${escapeHtml(text)}" data-ref-id="${escapeHtml(id)}" data-ref-kind="${kind}" class="${DIRECTIVE_CHIP_CLASS}">${directiveIconSvg(kind)}<span class="truncate">${escapeHtml(refLabel(id))}</span></span>`
}
/** Serialize a draft string into chip-HTML for the contenteditable surface. */
@@ -67,15 +73,23 @@ export function composerHtml(text: string) {
/** 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.TEXT_NODE) {
return node.textContent || ''
}
if (node.nodeType !== Node.ELEMENT_NODE) {return ''}
if (node.nodeType !== Node.ELEMENT_NODE) {
return ''
}
const el = node as HTMLElement
if (el.dataset.refText) {return el.dataset.refText}
if (el.dataset.refText) {
return el.dataset.refText
}
if (el.tagName === 'BR') {return '\n'}
if (el.tagName === 'BR') {
return '\n'
}
const text = Array.from(node.childNodes).map(composerPlainText).join('')
const block = el.tagName === 'DIV' || el.tagName === 'P'
@@ -13,7 +13,14 @@ interface ComposerTriggerPopoverProps {
onPick: (item: Unstable_TriggerItem) => void
}
export function ComposerTriggerPopover({ activeIndex, items, kind, loading, onHover, onPick }: ComposerTriggerPopoverProps) {
export function ComposerTriggerPopover({
activeIndex,
items,
kind,
loading,
onHover,
onPick
}: ComposerTriggerPopoverProps) {
return (
<div
className={COMPLETION_DRAWER_CLASS}
@@ -54,7 +61,9 @@ export function ComposerTriggerPopover({ activeIndex, items, kind, loading, onHo
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>}
{description && (
<span className="min-w-0 truncate leading-5 text-muted-foreground/80">{description}</span>
)}
</button>
)
})
@@ -37,10 +37,14 @@ export interface DroppedFile {
path: string
/** True if the entry is a directory. Currently only set by in-app drags. */
isDirectory?: boolean
/** First line number for in-app line-ref drags (source view gutter). */
line?: number
/** Last line number for line-range drags (`line..lineEnd` inclusive). */
lineEnd?: number
}
/** MIME emitted by in-app drag sources (project tree, etc.). Payload is JSON
* `{ path: string; isDirectory?: boolean }[]`. */
/** MIME emitted by in-app drag sources (project tree, gutter line numbers).
* Payload is JSON `{ path; isDirectory?; line?; lineEnd? }[]`. */
export const HERMES_PATHS_MIME = 'application/x-hermes-paths'
/**
@@ -64,15 +68,31 @@ export function extractDroppedFiles(transfer: DataTransfer): DroppedFile[] {
const internalRaw = transfer.getData(HERMES_PATHS_MIME)
if (internalRaw) {
const parsed = JSON.parse(internalRaw) as { path?: unknown; isDirectory?: unknown }[]
const parsed = JSON.parse(internalRaw) as {
path?: unknown
isDirectory?: unknown
line?: unknown
lineEnd?: unknown
}[]
const positiveInt = (value: unknown) => (typeof value === 'number' && value > 0 ? Math.floor(value) : undefined)
for (const entry of parsed) {
if (!entry || typeof entry.path !== 'string' || !entry.path || seenPaths.has(entry.path)) {
if (!entry || typeof entry.path !== 'string' || !entry.path) {
continue
}
seenPaths.add(entry.path)
result.push({ isDirectory: entry.isDirectory === true, path: entry.path })
const line = positiveInt(entry.line)
const rawEnd = positiveInt(entry.lineEnd)
const lineEnd = line && rawEnd && rawEnd > line ? rawEnd : undefined
const dedupKey = line ? `${entry.path}:${line}-${lineEnd ?? line}` : entry.path
if (seenPaths.has(dedupKey)) {
continue
}
seenPaths.add(dedupKey)
result.push({ isDirectory: entry.isDirectory === true, line, lineEnd, path: entry.path })
}
}
} catch {
@@ -335,7 +355,9 @@ export function useComposerActions({ activeSessionId, currentCwd, requestGateway
const attachContextFolderPath = useCallback(
(folderPath: string) => {
if (!folderPath) {return false}
if (!folderPath) {
return false
}
const rel = contextPath(folderPath, currentCwd)
@@ -1 +1 @@
export { ChatPreviewRail, PREVIEW_RAIL_PANE_WIDTH } from './preview'
export { ChatPreviewRail, PREVIEW_RAIL_MAX_WIDTH, PREVIEW_RAIL_MIN_WIDTH, PREVIEW_RAIL_PANE_WIDTH } from './preview'
@@ -50,7 +50,9 @@ export function createPreviewConsoleState() {
$selectedLogIds.set(new Set())
},
clearSelection() {
if ($selectedLogIds.get().size === 0) {return}
if ($selectedLogIds.get().size === 0) {
return
}
$selectedLogIds.set(new Set())
},
@@ -3,6 +3,8 @@ import type {
ComponentProps,
CSSProperties,
MutableRefObject,
DragEvent as ReactDragEvent,
MouseEvent as ReactMouseEvent,
ReactNode,
PointerEvent as ReactPointerEvent,
RefObject
@@ -11,6 +13,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import ShikiHighlighter from 'react-shiki'
import { Streamdown } from 'streamdown'
import { HERMES_PATHS_MIME } from '@/app/chat/hooks/use-composer-actions'
import type { SetTitlebarToolGroup, TitlebarTool } from '@/app/shell/titlebar-controls'
import { CopyButton } from '@/components/ui/copy-button'
import { Bug, PanelBottom, RefreshCw, Send, Trash2, X } from '@/lib/icons'
@@ -21,6 +24,8 @@ import { $previewServerRestart, failPreviewServerRestart, type PreviewTarget } f
import { type ConsoleEntry, createPreviewConsoleState, type PreviewConsoleState } from './preview-console-state'
const SHIKI_THEME = { dark: 'github-dark-default', light: 'github-light-default' } as const
type PreviewWebview = HTMLElement & {
closeDevTools?: () => void
getURL?: () => string
@@ -36,7 +41,6 @@ interface PreviewPaneProps {
reloadRequest?: number
setTitlebarToolGroup?: SetTitlebarToolGroup
target: PreviewTarget
titlebarToolGroupId?: string
}
interface PreviewLoadErrorState {
@@ -194,9 +198,23 @@ function PreviewConsoleTitlebarIcon({ consoleState }: { consoleState: PreviewCon
)
}
function PreviewCubeIcon() {
type EmptyStateTone = 'neutral' | 'warning'
const TONE_STYLES: Record<EmptyStateTone, { cube: string; primary: string }> = {
neutral: {
cube: 'text-muted-foreground/35',
primary: 'border-border bg-background text-foreground hover:bg-accent'
},
warning: {
cube: 'text-amber-500/70 dark:text-amber-300/70',
primary:
'border-amber-400/40 bg-amber-50 text-amber-900 hover:bg-amber-100 dark:border-amber-300/30 dark:bg-amber-300/15 dark:text-amber-100 dark:hover:bg-amber-300/20'
}
}
function PreviewCubeIcon({ className }: { className?: string }) {
return (
<svg aria-hidden="true" className="size-16 text-muted-foreground/35" viewBox="0 0 64 64">
<svg aria-hidden="true" className={cn('size-16', className)} viewBox="0 0 64 64">
<path
d="M32 5 56 18.5v27L32 59 8 45.5v-27L32 5Z"
fill="none"
@@ -222,25 +240,38 @@ interface PreviewEmptyStateProps {
primaryAction?: { disabled?: boolean; label: string; onClick: () => void }
secondaryAction?: { disabled?: boolean; label: string; onClick: () => void }
title: string
tone?: EmptyStateTone
}
function PreviewEmptyState({ body, consoleHeight = 0, primaryAction, secondaryAction, title }: PreviewEmptyStateProps) {
function PreviewEmptyState({
body,
consoleHeight = 0,
primaryAction,
secondaryAction,
title,
tone = 'neutral'
}: PreviewEmptyStateProps) {
const styles = TONE_STYLES[tone]
return (
<div
className="absolute inset-x-0 top-0 z-10 grid place-items-center bg-background px-6 text-center bottom-(--preview-error-bottom)"
className="absolute inset-x-0 top-0 z-10 grid place-items-center bg-background px-8 py-10 text-center bottom-(--preview-error-bottom)"
style={{ '--preview-error-bottom': `${consoleHeight}px` } as CSSProperties}
>
<div className="grid max-w-72 justify-items-center gap-4">
<PreviewCubeIcon />
<div className="grid gap-1.5">
<div className="grid max-w-sm justify-items-center gap-5">
<PreviewCubeIcon className={styles.cube} />
<div className="grid gap-2">
<div className="text-sm font-medium text-foreground">{title}</div>
{body}
{body && <div className="text-xs leading-relaxed text-muted-foreground">{body}</div>}
</div>
{(primaryAction || secondaryAction) && (
<div className="grid justify-items-center gap-2">
{primaryAction && (
<button
className="rounded-full border border-border bg-background px-3 py-1.5 text-xs font-medium text-foreground shadow-xs transition-colors hover:bg-accent disabled:cursor-default disabled:opacity-60"
className={cn(
'rounded-full border px-3.5 py-1.5 text-xs font-medium shadow-xs transition-colors disabled:cursor-default disabled:opacity-60',
styles.primary
)}
disabled={primaryAction.disabled}
onClick={primaryAction.onClick}
type="button"
@@ -282,20 +313,18 @@ function PreviewLoadError({
<PreviewEmptyState
body={
<>
<div className="text-xs leading-5 text-muted-foreground">
<a
className="pointer-events-auto cursor-pointer font-mono text-muted-foreground/90 underline decoration-muted-foreground/30 underline-offset-4 transition-colors hover:text-foreground hover:decoration-foreground/70"
href={error.url}
onClick={event => {
event.preventDefault()
void window.hermesDesktop?.openExternal(error.url)
}}
>
{compactUrl(error.url)}
</a>
<a
className="pointer-events-auto block cursor-pointer font-mono text-muted-foreground/90 underline decoration-muted-foreground/30 underline-offset-4 transition-colors hover:text-foreground hover:decoration-foreground/70"
href={error.url}
onClick={event => {
event.preventDefault()
void window.hermesDesktop?.openExternal(error.url)
}}
>
{compactUrl(error.url)}
{error.code ? ` (${error.code})` : ''}
</div>
<div className="text-[0.6875rem] leading-5 text-muted-foreground/70">{error.description}</div>
</a>
<div className="mt-1 text-[0.6875rem] text-muted-foreground/70">{error.description}</div>
</>
}
consoleHeight={consoleHeight}
@@ -541,98 +570,204 @@ async function readTextPreview(filePath: string) {
}
}
function MarkdownPreview({ text }: { text: string }) {
const components = useMemo(
() => ({
h1: ({ className, ...props }: ComponentProps<'h1'>) => (
<h1 className={cn('mb-3 mt-6 text-3xl font-bold leading-tight tracking-tight first:mt-0', className)} {...props} />
),
h2: ({ className, ...props }: ComponentProps<'h2'>) => (
<h2 className={cn('mb-2.5 mt-5 text-2xl font-semibold leading-snug tracking-tight first:mt-0', className)} {...props} />
),
h3: ({ className, ...props }: ComponentProps<'h3'>) => (
<h3 className={cn('mb-2 mt-4 text-xl font-semibold leading-snug first:mt-0', className)} {...props} />
),
h4: ({ className, ...props }: ComponentProps<'h4'>) => (
<h4 className={cn('mb-2 mt-3 text-base font-semibold leading-snug first:mt-0', className)} {...props} />
),
p: ({ className, ...props }: ComponentProps<'p'>) => (
<p className={cn('mb-4 leading-relaxed text-foreground last:mb-0', className)} {...props} />
),
ul: ({ className, ...props }: ComponentProps<'ul'>) => (
<ul className={cn('mb-4 list-disc pl-6 marker:text-muted-foreground/70 last:mb-0', className)} {...props} />
),
ol: ({ className, ...props }: ComponentProps<'ol'>) => (
<ol className={cn('mb-4 list-decimal pl-6 marker:text-muted-foreground/70 last:mb-0', className)} {...props} />
),
li: ({ className, ...props }: ComponentProps<'li'>) => <li className={cn('mt-1 leading-relaxed', className)} {...props} />,
blockquote: ({ className, ...props }: ComponentProps<'blockquote'>) => (
<blockquote
className={cn('mb-4 border-l-2 border-border pl-3 text-muted-foreground italic last:mb-0', className)}
{...props}
/>
),
code: ({ className, children, ...props }: ComponentProps<'code'>) => {
const language = /language-([^\s]+)/.exec(className || '')?.[1]
// Lightweight markdown renderer for file previews. Streamdown does the parse;
// our components keep typography simple and route fenced code through Shiki
// without the library's copy/download/fullscreen chrome.
const MD_TAG_CLASSES = {
h1: 'mb-3 mt-6 text-3xl font-bold leading-tight tracking-tight first:mt-0',
h2: 'mb-2.5 mt-5 text-2xl font-semibold leading-snug tracking-tight first:mt-0',
h3: 'mb-2 mt-4 text-xl font-semibold leading-snug first:mt-0',
h4: 'mb-2 mt-3 text-base font-semibold leading-snug first:mt-0',
p: 'mb-4 leading-relaxed text-foreground last:mb-0',
ul: 'mb-4 list-disc pl-6 marker:text-muted-foreground/70 last:mb-0',
ol: 'mb-4 list-decimal pl-6 marker:text-muted-foreground/70 last:mb-0',
li: 'mt-1 leading-relaxed',
blockquote: 'mb-4 border-l-2 border-border pl-3 text-muted-foreground italic last:mb-0',
pre: 'mb-4 overflow-hidden rounded-lg border border-border bg-card font-mono text-xs leading-relaxed last:mb-0 [&_pre]:m-0 [&_pre]:overflow-x-auto [&_pre]:bg-transparent! [&_pre]:p-3 [&_pre]:font-mono'
} as const
if (!language) {
return (
<code
className={cn(
'rounded bg-muted px-1 py-0.5 font-mono text-[0.86em] text-pink-700 dark:text-pink-300',
className
)}
{...props}
>
{children}
</code>
)
}
function tagged<T extends keyof typeof MD_TAG_CLASSES>(Tag: T) {
const base = MD_TAG_CLASSES[Tag]
return (
<ShikiHighlighter
addDefaultStyles={false}
as="div"
defaultColor="light-dark()"
delay={80}
language={language}
showLanguage={false}
theme={{
dark: 'github-dark-default',
light: 'github-light-default'
}}
>
{String(children).replace(/\n$/, '')}
</ShikiHighlighter>
)
},
pre: ({ className, ...props }: ComponentProps<'pre'>) => (
<pre
className={cn(
'mb-4 overflow-hidden rounded-lg border border-border bg-card font-mono text-xs leading-relaxed last:mb-0 [&_pre]:m-0 [&_pre]:overflow-x-auto [&_pre]:bg-transparent! [&_pre]:p-3 [&_pre]:font-mono',
className
)}
{...props}
/>
)
}),
[]
)
const Component = (({ className, ...rest }: ComponentProps<T>) => {
const Element = Tag as React.ElementType
return <Element className={cn(base, className)} {...rest} />
}) as React.FC<ComponentProps<T>>
Component.displayName = `Md.${Tag}`
return Component
}
function MarkdownCode({ className, children, ...props }: ComponentProps<'code'>) {
const language = /language-([^\s]+)/.exec(className || '')?.[1]
if (!language) {
return (
<code
className={cn(
'rounded bg-muted px-1 py-0.5 font-mono text-[0.86em] text-pink-700 dark:text-pink-300',
className
)}
{...props}
>
{children}
</code>
)
}
return (
<ShikiHighlighter
addDefaultStyles={false}
as="div"
defaultColor="light-dark()"
delay={80}
language={language}
showLanguage={false}
theme={SHIKI_THEME}
>
{String(children).replace(/\n$/, '')}
</ShikiHighlighter>
)
}
const MARKDOWN_COMPONENTS = {
h1: tagged('h1'),
h2: tagged('h2'),
h3: tagged('h3'),
h4: tagged('h4'),
p: tagged('p'),
ul: tagged('ul'),
ol: tagged('ol'),
li: tagged('li'),
blockquote: tagged('blockquote'),
pre: tagged('pre'),
code: MarkdownCode
}
function MarkdownPreview({ text }: { text: string }) {
return (
<div className="preview-markdown mx-auto max-w-3xl px-4 py-3 text-sm text-foreground">
<Streamdown
components={components}
controls={false}
mode="static"
parseIncompleteMarkdown={false}
>
<Streamdown components={MARKDOWN_COMPONENTS} controls={false} mode="static" parseIncompleteMarkdown={false}>
{text}
</Streamdown>
</div>
)
}
function PreviewToggle({ asSource, onToggle }: { asSource: boolean; onToggle: () => void }) {
return (
<div className="sticky top-0 z-10 flex justify-end border-b border-border/40 bg-background/90 px-3 py-1 backdrop-blur">
<button
className="text-[0.625rem] font-bold text-muted-foreground underline decoration-muted-foreground/25 underline-offset-4 transition-colors hover:text-foreground hover:decoration-foreground/55"
onClick={onToggle}
type="button"
>
{asSource ? 'PREVIEW' : 'SOURCE'}
</button>
</div>
)
}
// Gutter and Shiki output share `font-mono text-xs leading-relaxed py-3` so
// each line aligns vertically. The selection overlay relies on the same
// `text-xs * leading-relaxed = 1.21875rem` line-height to position itself.
const SOURCE_LINE_HEIGHT_REM = 1.21875
const SOURCE_PAD_Y_REM = 0.75
interface LineSelection {
end: number
start: number
}
function startLineDrag(event: ReactDragEvent<HTMLElement>, filePath: string, { end, start }: LineSelection) {
const lineEnd = end > start ? end : undefined
const label = lineEnd ? `${filePath}:${start}-${end}` : `${filePath}:${start}`
event.dataTransfer.setData(HERMES_PATHS_MIME, JSON.stringify([{ line: start, lineEnd, path: filePath }]))
event.dataTransfer.setData('text/plain', label)
event.dataTransfer.effectAllowed = 'copy'
}
function SourceView({ filePath, language, text }: { filePath: string; language: string; text: string }) {
const lineCount = useMemo(() => Math.max(1, text.split('\n').length), [text])
const [selection, setSelection] = useState<LineSelection | null>(null)
const inSelection = (line: number) => selection != null && line >= selection.start && line <= selection.end
const handleLineClick = (event: ReactMouseEvent, line: number) => {
if (event.shiftKey && selection) {
setSelection({ end: Math.max(selection.end, line), start: Math.min(selection.start, line) })
return
}
if (selection?.start === line && selection.end === line) {
setSelection(null)
return
}
setSelection({ end: line, start: line })
}
const handleDragStart = (event: ReactDragEvent<HTMLElement>, line: number) => {
startLineDrag(event, filePath, inSelection(line) && selection ? selection : { end: line, start: line })
}
return (
<div className="grid min-w-max grid-cols-[auto_minmax(0,1fr)] font-mono text-xs leading-relaxed">
<div className="select-none py-3 text-right text-muted-foreground/55">
{Array.from({ length: lineCount }, (_, index) => {
const line = index + 1
const selected = inSelection(line)
return (
<div
className={cn(
'cursor-pointer px-3 tabular-nums transition-colors',
selected
? 'bg-amber-200/45 text-amber-900 dark:bg-amber-300/20 dark:text-amber-100'
: 'hover:text-foreground'
)}
draggable
key={line}
onClick={event => handleLineClick(event, line)}
onDragStart={event => handleDragStart(event, line)}
title="Click to select · shift-click to extend · drag to composer"
>
{line}
</div>
)
})}
</div>
<div className="relative [&_pre]:m-0 [&_pre]:px-3 [&_pre]:py-3">
{selection && (
<div
aria-hidden
className="pointer-events-none absolute inset-x-0 bg-amber-200/35 dark:bg-amber-300/10"
style={{
top: `calc(${SOURCE_PAD_Y_REM}rem + ${selection.start - 1} * ${SOURCE_LINE_HEIGHT_REM}rem)`,
height: `calc(${selection.end - selection.start + 1} * ${SOURCE_LINE_HEIGHT_REM}rem)`
}}
/>
)}
<ShikiHighlighter
addDefaultStyles={false}
as="div"
defaultColor="light-dark()"
delay={80}
language={language || 'text'}
showLanguage={false}
theme={SHIKI_THEME}
>
{text}
</ShikiHighlighter>
</div>
</div>
)
}
function LocalFilePreview({ reloadKey, target }: { reloadKey: number; target: PreviewTarget }) {
const [state, setState] = useState<LocalPreviewState>({ loading: true })
const [forcePreview, setForcePreview] = useState(false)
@@ -643,8 +778,7 @@ function LocalFilePreview({ reloadKey, target }: { reloadKey: number; target: Pr
// HTML files are rendered as source code, not in a webview — so they take
// the same path as plain text files. `previewKind === 'binary'` arrives
// when the file is forcibly previewed past the binary refusal screen.
const isText =
target.previewKind === 'text' || target.previewKind === 'binary' || target.previewKind === 'html'
const isText = target.previewKind === 'text' || target.previewKind === 'binary' || target.previewKind === 'html'
const blockedByTarget = !isImage && !forcePreview && (target.binary || target.large)
@@ -713,12 +847,7 @@ function LocalFilePreview({ reloadKey, target }: { reloadKey: number; target: Pr
}
if (state.error) {
return (
<PreviewEmptyState
body={<div className="text-xs leading-5 text-muted-foreground">{state.error}</div>}
title="Preview unavailable"
/>
)
return <PreviewEmptyState body={state.error} title="Preview unavailable" />
}
if (
@@ -732,14 +861,13 @@ function LocalFilePreview({ reloadKey, target }: { reloadKey: number; target: Pr
return (
<PreviewEmptyState
body={
<div className="text-xs leading-5 text-muted-foreground">
{binary
? `Previewing ${target.label} may show unreadable text.`
: `${target.label} is ${formatBytes(size)}. Hermes will show the first 512 KB.`}
</div>
binary
? `Previewing ${target.label} may show unreadable text.`
: `${target.label} is ${formatBytes(size)}. Hermes will only show the first 512 KB.`
}
primaryAction={{ label: 'Preview anyway', onClick: () => setForcePreview(true) }}
title={binary ? 'This looks like a binary file' : 'This file is large'}
tone="warning"
/>
)
}
@@ -759,84 +887,41 @@ function LocalFilePreview({ reloadKey, target }: { reloadKey: number; target: Pr
if (isText && state.text !== undefined) {
const isMarkdown = (state.language || target.language) === 'markdown'
const truncatedBanner = state.truncated ? (
<div className="border-b border-border/60 bg-muted/35 px-3 py-1.5 text-[0.68rem] text-muted-foreground">
Showing first 512 KB.
</div>
) : null
if (isMarkdown && !renderMarkdownAsSource) {
return (
<div className="h-full overflow-auto bg-background">
{truncatedBanner}
<div className="sticky top-0 z-10 flex justify-end border-b border-border/40 bg-background/90 px-3 py-1 backdrop-blur">
<button
className="text-[0.625rem] font-bold text-muted-foreground underline decoration-muted-foreground/25 underline-offset-4 transition-colors hover:text-foreground hover:decoration-foreground/55"
onClick={() => setRenderMarkdownAsSource(true)}
type="button"
>
SOURCE
</button>
</div>
<MarkdownPreview text={state.text} />
</div>
)
}
const showRendered = isMarkdown && !renderMarkdownAsSource
return (
<div className="h-full overflow-auto bg-background">
{truncatedBanner}
{isMarkdown && (
<div className="sticky top-0 z-10 flex justify-end border-b border-border/40 bg-background/90 px-3 py-1 backdrop-blur">
<button
className="text-[0.625rem] font-bold text-muted-foreground underline decoration-muted-foreground/25 underline-offset-4 transition-colors hover:text-foreground hover:decoration-foreground/55"
onClick={() => setRenderMarkdownAsSource(false)}
type="button"
>
PREVIEW
</button>
{state.truncated && (
<div className="border-b border-border/60 bg-muted/35 px-3 py-1.5 text-[0.68rem] text-muted-foreground">
Showing first 512 KB.
</div>
)}
<div className="min-w-max font-mono text-xs leading-relaxed [&_pre]:m-0 [&_pre]:p-3">
<ShikiHighlighter
addDefaultStyles={false}
as="div"
defaultColor="light-dark()"
delay={80}
language={state.language || 'text'}
showLanguage={false}
theme={{
dark: 'github-dark-default',
light: 'github-light-default'
}}
>
{state.text}
</ShikiHighlighter>
</div>
{isMarkdown && <PreviewToggle asSource={!showRendered} onToggle={() => setRenderMarkdownAsSource(s => !s)} />}
{showRendered ? (
<MarkdownPreview text={state.text} />
) : (
<SourceView filePath={filePath} language={state.language || 'text'} text={state.text} />
)}
</div>
)
}
return (
<PreviewEmptyState
body={
<div className="text-xs leading-5 text-muted-foreground">
{target.mimeType || 'This file type'} can still be attached as context.
</div>
}
body={`${target.mimeType || 'This file type'} can still be attached as context.`}
title="No inline preview"
/>
)
}
const TITLEBAR_GROUP_ID = 'preview'
export function PreviewPane({
onClose,
onRestartServer,
reloadRequest = 0,
setTitlebarToolGroup,
target,
titlebarToolGroupId = 'preview'
target
}: PreviewPaneProps) {
const [consoleState] = useState(() => createPreviewConsoleState())
const consoleBodyRef = useRef<HTMLDivElement | null>(null)
@@ -1002,14 +1087,14 @@ export function PreviewPane({
{
active: consoleOpen,
icon: <PreviewConsoleTitlebarIcon consoleState={consoleState} />,
id: `${titlebarToolGroupId}-console`,
id: `${TITLEBAR_GROUP_ID}-console`,
label: consoleOpen ? 'Hide preview console' : 'Show preview console',
onSelect: () => consoleState.setOpen(open => !open)
},
{
active: devtoolsOpen,
icon: <Bug />,
id: `${titlebarToolGroupId}-devtools`,
id: `${TITLEBAR_GROUP_ID}-devtools`,
label: devtoolsOpen ? 'Hide preview DevTools' : 'Open preview DevTools',
onSelect: toggleDevTools
}
@@ -1017,21 +1102,21 @@ export function PreviewPane({
: []),
{
icon: <RefreshCw className={cn(loading && 'animate-spin')} />,
id: `${titlebarToolGroupId}-reload`,
id: `${TITLEBAR_GROUP_ID}-reload`,
label: 'Reload preview',
onSelect: reloadPreview
},
{
icon: <X />,
id: `${titlebarToolGroupId}-close`,
id: `${TITLEBAR_GROUP_ID}-close`,
label: 'Close preview',
onSelect: onClose
}
]
setTitlebarToolGroup(titlebarToolGroupId, tools)
setTitlebarToolGroup(TITLEBAR_GROUP_ID, tools)
return () => setTitlebarToolGroup(titlebarToolGroupId, [])
return () => setTitlebarToolGroup(TITLEBAR_GROUP_ID, [])
}, [
consoleOpen,
consoleState,
@@ -1041,7 +1126,6 @@ export function PreviewPane({
onClose,
reloadPreview,
setTitlebarToolGroup,
titlebarToolGroupId,
toggleDevTools
])
@@ -11,7 +11,10 @@ import {
import { PreviewPane } from './preview-pane'
const INTRINSIC = 'clamp(18rem, 36vw, 38rem)'
export const PREVIEW_RAIL_MIN_WIDTH = '18rem'
export const PREVIEW_RAIL_MAX_WIDTH = '38rem'
const INTRINSIC = `clamp(${PREVIEW_RAIL_MIN_WIDTH}, 36vw, 32rem)`
// 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.
@@ -30,7 +33,9 @@ export function ChatPreviewRail({ onRestartServer, setTitlebarToolGroup }: ChatP
const previewTarget = useStore($previewTarget)
const target = filePreviewTarget ?? previewTarget
if (!target) {return null}
if (!target) {
return null
}
return (
<PreviewPane
+41 -6
View File
@@ -9,7 +9,16 @@ import { useSkinCommand } from '@/themes/use-skin-command'
import { formatRefValue } from '../components/assistant-ui/directive-text'
import { getSessionMessages, listSessions } from '../hermes'
import { toChatMessages } from '../lib/chat-messages'
import { $pinnedSessionIds, FILE_BROWSER_DEFAULT_WIDTH, pinSession, SIDEBAR_DEFAULT_WIDTH, unpinSession } from '../store/layout'
import {
$pinnedSessionIds,
FILE_BROWSER_DEFAULT_WIDTH,
FILE_BROWSER_MAX_WIDTH,
FILE_BROWSER_MIN_WIDTH,
pinSession,
SIDEBAR_DEFAULT_WIDTH,
SIDEBAR_MAX_WIDTH,
unpinSession
} from '../store/layout'
import { $filePreviewTarget, $previewTarget, dismissFilePreviewTarget, dismissPreviewTarget } from '../store/preview'
import {
$activeSessionId,
@@ -30,7 +39,7 @@ import { AgentsView } from './agents'
import { ArtifactsView } from './artifacts'
import { ChatView } from './chat'
import { useComposerActions } from './chat/hooks/use-composer-actions'
import { ChatPreviewRail, PREVIEW_RAIL_PANE_WIDTH } from './chat/right-rail'
import { ChatPreviewRail, PREVIEW_RAIL_MAX_WIDTH, PREVIEW_RAIL_MIN_WIDTH, PREVIEW_RAIL_PANE_WIDTH } from './chat/right-rail'
import { ChatSidebar } from './chat/sidebar'
import { CommandCenterView } from './command-center'
import { FileBrowserPane } from './file-browser'
@@ -464,7 +473,14 @@ export function DesktopController() {
statusbarItems={statusbarItems}
titlebarTools={titlebarToolGroups.flat.right}
>
<Pane id="chat-sidebar" side="left" width={`${SIDEBAR_DEFAULT_WIDTH}px`}>
<Pane
id="chat-sidebar"
maxWidth={SIDEBAR_MAX_WIDTH}
minWidth={SIDEBAR_DEFAULT_WIDTH}
resizable
side="left"
width={`${SIDEBAR_DEFAULT_WIDTH}px`}
>
{sidebar}
</Pane>
<PaneMain>
@@ -479,7 +495,10 @@ export function DesktopController() {
/>
<Route
element={
<ArtifactsView setStatusbarItemGroup={setStatusbarItemGroup} setTitlebarToolGroup={setTitlebarToolGroup} />
<ArtifactsView
setStatusbarItemGroup={setStatusbarItemGroup}
setTitlebarToolGroup={setTitlebarToolGroup}
/>
}
path="artifacts"
/>
@@ -491,12 +510,28 @@ export function DesktopController() {
<Route element={<Navigate replace to={NEW_CHAT_ROUTE} />} path="*" />
</Routes>
</PaneMain>
<Pane disabled={!chatOpen || (!previewTarget && !filePreviewTarget)} id="preview" side="right" width={PREVIEW_RAIL_PANE_WIDTH}>
<Pane
disabled={!chatOpen || (!previewTarget && !filePreviewTarget)}
id="preview"
maxWidth={PREVIEW_RAIL_MAX_WIDTH}
minWidth={PREVIEW_RAIL_MIN_WIDTH}
resizable
side="right"
width={PREVIEW_RAIL_PANE_WIDTH}
>
{chatOpen ? (
<ChatPreviewRail onRestartServer={restartPreviewServer} setTitlebarToolGroup={setTitlebarToolGroup} />
) : null}
</Pane>
<Pane defaultOpen={false} id="file-browser" side="right" width={FILE_BROWSER_DEFAULT_WIDTH}>
<Pane
defaultOpen={false}
id="file-browser"
maxWidth={FILE_BROWSER_MAX_WIDTH}
minWidth={FILE_BROWSER_MIN_WIDTH}
resizable
side="right"
width={FILE_BROWSER_DEFAULT_WIDTH}
>
<FileBrowserPane onActivateFile={composer.attachContextFilePath} onChangeCwd={changeSessionCwd} />
</Pane>
</AppShell>
+6 -1
View File
@@ -24,7 +24,12 @@ interface FileBrowserPaneProps {
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 cwdName = hasCwd
? (currentCwd
.split(/[\\/]+/)
.filter(Boolean)
.pop() ?? currentCwd)
: 'No folder selected'
const { data, loadChildren, openState, refreshRoot, rootError, rootLoading, setNodeOpen } = useProjectTree(currentCwd)
const chooseFolder = async () => {
+29 -11
View File
@@ -17,7 +17,9 @@ function decodeDataUrl(dataUrl: string) {
const data = match?.[1] || ''
const isBase64 = dataUrl.slice(0, dataUrl.indexOf(',')).includes(';base64')
if (!isBase64) {return decodeURIComponent(data)}
if (!isBase64) {
return decodeURIComponent(data)
}
const bytes = Uint8Array.from(atob(data), ch => ch.charCodeAt(0))
@@ -33,7 +35,9 @@ function relativeTo(root: string, child: string) {
const r = clean(root)
const c = clean(child)
if (c === r) {return ''}
if (c === r) {
return ''
}
return c.startsWith(`${r}/`) ? c.slice(r.length + 1) : null
}
@@ -43,7 +47,9 @@ function ancestorDirs(root: string, dir: string) {
const r = clean(root)
const rel = relativeTo(r, dir)
if (rel === null || rel === '') {return [r]}
if (rel === null || rel === '') {
return [r]
}
const dirs = [r]
let current = r
@@ -57,7 +63,9 @@ function ancestorDirs(root: string, dir: string) {
}
async function gitRootFor(start: string) {
if (!window.hermesDesktop?.gitRoot) {return null}
if (!window.hermesDesktop?.gitRoot) {
return null
}
const key = clean(start)
let cached = gitRootCache.get(key)
@@ -72,12 +80,16 @@ async function gitRootFor(start: string) {
/** 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}
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}
if (!listing.entries.some(e => e.name === '.gitignore' && !e.isDirectory)) {
return null
}
const text = decodeDataUrl(await window.hermesDesktop.readFileDataUrl(`${dir}/.gitignore`))
@@ -103,7 +115,9 @@ function ignoredBy(rules: GitignoreRule[], entry: HermesReadDirEntry) {
return rules.some(rule => {
const rel = relativeTo(rule.base, entry.path)
if (rel === null || rel === '') {return false}
if (rel === null || rel === '') {
return false
}
return rule.ig.ignores(entry.isDirectory ? `${rel}/` : rel)
})
@@ -112,17 +126,21 @@ function ignoredBy(rules: GitignoreRule[], entry: HermesReadDirEntry) {
async function filterIgnored(entries: HermesReadDirEntry[], rootPath: string, dirPath: string) {
const root = await gitRootFor(rootPath)
if (!root) {return entries}
if (!root) {
return entries
}
const rules = (await Promise.all(ancestorDirs(root, dirPath).map(gitignoreFor))).filter(
(r): r is GitignoreRule => Boolean(r)
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' }}
if (!window.hermesDesktop) {
return { entries: [], error: 'no-bridge' }
}
const result = await window.hermesDesktop.readDir(dirPath)
+9 -3
View File
@@ -33,7 +33,9 @@ export function ProjectTree({
useEffect(() => {
const el = containerRef.current
if (!el || typeof ResizeObserver === 'undefined') {return}
if (!el || typeof ResizeObserver === 'undefined') {
return
}
const observer = new ResizeObserver(([entry]) => {
const { height, width } = entry.contentRect
@@ -49,7 +51,9 @@ export function ProjectTree({
(id: string) => {
const node = treeRef.current?.get(id)
if (!node) {return}
if (!node) {
return
}
onNodeOpenChange(id, node.isOpen)
@@ -121,7 +125,9 @@ function ProjectTreeRow({
onClick={event => {
event.stopPropagation()
if (isPlaceholder) {return}
if (isPlaceholder) {
return
}
if (isFolder) {
node.toggle()
@@ -25,10 +25,14 @@ function makeNode(path: string, name: string, isDirectory: boolean): TreeNode {
}
function patchNode(nodes: TreeNode[] | undefined | null, id: string, patch: (n: TreeNode) => TreeNode): TreeNode[] {
if (!nodes) {return []}
if (!nodes) {
return []
}
return nodes.map(n => {
if (n.id === id) {return patch(n)}
if (n.id === id) {
return patch(n)
}
if (n.children && n.children.length > 0) {
return { ...n, children: patchNode(n.children, id, patch) }
@@ -170,37 +174,46 @@ export function useProjectTree(cwd: string): UseProjectTreeResult {
[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 loadChildren = useCallback(
async (id: string) => {
if (!cwd || inflight.has(id)) {
return
}
})
inflight.add(id)
const { entries, error } = await readProjectDir(id, cwd)
setProjectTree(current => {
if (current.cwd !== cwd) {
return current
}
inflight.delete(id)
return {
...current,
data: patchNode(current.data, id, n => ({ ...n, loading: true, children: [placeholderChild(n.id)] }))
}
})
setProjectTree(current => {
if (current.cwd !== cwd) {return current}
const { entries, error } = await readProjectDir(id, cwd)
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])
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)
@@ -216,6 +229,16 @@ export function useProjectTree(cwd: string): UseProjectTreeResult {
rootLoading: state.cwd === cwd ? state.rootLoading : false,
setNodeOpen
}),
[cwd, loadChildren, refreshRoot, setNodeOpen, state.cwd, state.data, state.openState, state.rootError, state.rootLoading]
[
cwd,
loadChildren,
refreshRoot,
setNodeOpen,
state.cwd,
state.data,
state.openState,
state.rootError,
state.rootLoading
]
)
}
@@ -40,13 +40,19 @@ export function useContextSuggestions({
cwd: cwd || undefined
})
if (stillCurrent()) {setContextSuggestions((result.items || []).filter(i => i.text))}
if (stillCurrent()) {
setContextSuggestions((result.items || []).filter(i => i.text))
}
} catch {
if (stillCurrent()) {setContextSuggestions([])}
if (stillCurrent()) {
setContextSuggestions([])
}
}
}, [activeSessionId, activeSessionIdRef, currentCwd, requestGateway])
useEffect(() => {
if (gatewayState === 'open' && activeSessionId) {void refresh()}
if (gatewayState === 'open' && activeSessionId) {
void refresh()
}
}, [activeSessionId, gatewayState, refresh])
}
@@ -16,10 +16,15 @@ export function useCwdActions({ activeSessionId, activeSessionIdRef, currentCwd,
async (cwd: string) => {
const target = cwd.trim()
if (!target || activeSessionIdRef.current) {return}
if (!target || activeSessionIdRef.current) {
return
}
try {
const info = await requestGateway<{ branch?: string; cwd?: string }>('config.get', { key: 'project', cwd: target })
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 || '')
@@ -35,7 +40,9 @@ export function useCwdActions({ activeSessionId, activeSessionIdRef, currentCwd,
async (cwd: string) => {
const trimmed = cwd.trim()
if (!trimmed) {return}
if (!trimmed) {
return
}
const persistGlobal = async () => {
const info = await requestGateway<{ branch?: string; cwd?: string; value?: string }>('config.set', {
@@ -46,7 +53,9 @@ export function useCwdActions({ activeSessionId, activeSessionIdRef, currentCwd,
setCurrentCwd(info.cwd || info.value || trimmed)
if (!activeSessionId) {setCurrentBranch(info.branch || '')}
if (!activeSessionId) {
setCurrentBranch(info.branch || '')
}
}
if (!activeSessionId) {
@@ -101,7 +110,9 @@ export function useCwdActions({ activeSessionId, activeSessionIdRef, currentCwd,
multiple: false
})
if (paths?.[0]) {await changeSessionCwd(paths[0])}
if (paths?.[0]) {
await changeSessionCwd(paths[0])
}
}, [changeSessionCwd, currentCwd])
return { browseSessionCwd, changeSessionCwd, refreshProjectBranch }
@@ -25,7 +25,9 @@ export function useModelControls({ activeSessionId, queryClient, requestGateway
queryClient.setQueryData<ModelOptionsResponse>(['model-options', activeSessionId || 'global'], patch)
if (includeGlobal) {queryClient.setQueryData<ModelOptionsResponse>(['model-options', 'global'], patch)}
if (includeGlobal) {
queryClient.setQueryData<ModelOptionsResponse>(['model-options', 'global'], patch)
}
},
[activeSessionId, queryClient]
)
@@ -34,9 +36,13 @@ export function useModelControls({ activeSessionId, queryClient, requestGateway
try {
const result = await getGlobalModelInfo()
if (typeof result.model === 'string') {setCurrentModel(result.model)}
if (typeof result.model === 'string') {
setCurrentModel(result.model)
}
if (typeof result.provider === 'string') {setCurrentProvider(result.provider)}
if (typeof result.provider === 'string') {
setCurrentProvider(result.provider)
}
} catch {
// The delayed session.info event still updates this once the agent is ready.
}
@@ -56,7 +62,9 @@ export function useModelControls({ activeSessionId, queryClient, requestGateway
command: `/model ${selection.model} --provider ${selection.provider}${selection.persistGlobal ? ' --global' : ''}`
})
if (selection.persistGlobal) {void refreshCurrentModel()}
if (selection.persistGlobal) {
void refreshCurrentModel()
}
void queryClient.invalidateQueries({
queryKey: selection.persistGlobal ? ['model-options'] : ['model-options', activeSessionId]
})
@@ -87,7 +87,13 @@ describe('usePreviewRouting', () => {
const target = previewTarget('/work/demo.html')
registerSessionPreview('session-1', target, 'tool-result')
render(<PreviewRoutingHarness onEvent={handler => { handleEvent = handler }} />)
render(
<PreviewRoutingHarness
onEvent={handler => {
handleEvent = handler
}}
/>
)
await waitFor(() => {
expect($previewTarget.get()).toEqual({ ...target, renderMode: 'preview' })
@@ -95,7 +101,13 @@ describe('usePreviewRouting', () => {
})
it('does not infer previews from assistant prose', async () => {
render(<PreviewRoutingHarness onEvent={handler => { handleEvent = handler }} />)
render(
<PreviewRoutingHarness
onEvent={handler => {
handleEvent = handler
}}
/>
)
act(() => {
$messages.set([
@@ -109,7 +121,13 @@ describe('usePreviewRouting', () => {
})
it('registers structured tool-result preview targets', async () => {
render(<PreviewRoutingHarness onEvent={handler => { handleEvent = handler }} />)
render(
<PreviewRoutingHarness
onEvent={handler => {
handleEvent = handler
}}
/>
)
act(() =>
handleEvent({
@@ -127,7 +145,13 @@ describe('usePreviewRouting', () => {
})
it('registers html previews from edit inline diffs', async () => {
render(<PreviewRoutingHarness onEvent={handler => { handleEvent = handler }} />)
render(
<PreviewRoutingHarness
onEvent={handler => {
handleEvent = handler
}}
/>
)
act(() =>
handleEvent({
@@ -113,18 +113,32 @@ export function usePreviewRouting({
const registerStructuredPreview = useCallback(
async (event: RpcEvent) => {
if (event.session_id && event.session_id !== activeSessionIdRef.current && event.session_id !== previewSessionId) {return}
if (
event.session_id &&
event.session_id !== activeSessionIdRef.current &&
event.session_id !== previewSessionId
) {
return
}
if (!event.type.startsWith('tool.')) {return}
if (!event.type.startsWith('tool.')) {
return
}
if (!previewSessionId) {return}
if (!previewSessionId) {
return
}
const candidate = structuredPreviewCandidate(event.payload)
if (!candidate) {return}
if (!candidate) {
return
}
const desktop = window.hermesDesktop
if (!desktop?.normalizePreviewTarget) {return}
if (!desktop?.normalizePreviewTarget) {
return
}
const sessionId = previewSessionId
const cwd = currentCwd || ''
const target = await desktop.normalizePreviewTarget(candidate, cwd || undefined).catch(() => null)
@@ -146,7 +160,9 @@ export function usePreviewRouting({
async (url: string, context?: string) => {
const sessionId = activeSessionIdRef.current
if (!sessionId) {throw new Error('No active session for background restart')}
if (!sessionId) {
throw new Error('No active session for background restart')
}
const cwd = $currentCwd.get() || currentCwd || ''
@@ -159,7 +175,9 @@ export function usePreviewRouting({
const taskId = result.task_id || ''
if (!taskId) {throw new Error('Background restart did not return a task id')}
if (!taskId) {
throw new Error('Background restart did not return a task id')
}
beginPreviewServerRestart(taskId, url)
@@ -175,18 +193,26 @@ export function usePreviewRouting({
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 : '')}
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 (typeof task_id === 'string' && task_id) {
progressPreviewServerRestart(task_id, typeof text === 'string' ? text : '')
}
}
if (event.session_id && event.session_id !== activeSessionIdRef.current) {return}
if (event.session_id && event.session_id !== activeSessionIdRef.current) {
return
}
void registerStructuredPreview(event)
if ($previewTarget.get()?.kind === 'url' && gatewayEventCompletedFileDiff(event)) {requestPreviewReload()}
if ($previewTarget.get()?.kind === 'url' && gatewayEventCompletedFileDiff(event)) {
requestPreviewReload()
}
},
[activeSessionIdRef, baseHandleGatewayEvent, registerStructuredPreview]
)
@@ -22,10 +22,14 @@ interface RouteResumeOptions {
// 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}
if (typeof window === 'undefined') {
return false
}
const hash = window.location.hash.replace(/^#/, '')
if (!hash || hash === '/') {return false}
if (!hash || hash === '/') {
return false
}
return !hash.startsWith('/settings') && !hash.startsWith('/skills') && !hash.startsWith('/artifacts')
}
@@ -46,7 +50,9 @@ export function useRouteResume({
startFreshSessionDraft
}: RouteResumeOptions) {
useEffect(() => {
if (currentView !== 'chat' || gatewayState !== 'open') {return}
if (currentView !== 'chat' || gatewayState !== 'open') {
return
}
if (routedSessionId) {
const cachedRuntime = runtimeIdByStoredSessionIdRef.current.get(routedSessionId)
@@ -56,7 +62,9 @@ export function useRouteResume({
Boolean(cachedRuntime) &&
cachedRuntime === activeSessionIdRef.current
if (!alreadyActive) {void resumeSession(routedSessionId, true)}
if (!alreadyActive) {
void resumeSession(routedSessionId, true)
}
return
}
@@ -655,16 +655,16 @@ export function useSessionActions({
setFreshDraftReady(false)
setSelectedStoredSessionId(storedSessionId)
selectedStoredSessionIdRef.current = storedSessionId
const stored = $sessions.get().find(session => session.id === storedSessionId)
const stored = $sessions.get().find(session => session.id === storedSessionId)
if (stored) {
setCurrentUsage(current => ({
...current,
input: stored.input_tokens || 0,
output: stored.output_tokens || 0,
total: (stored.input_tokens || 0) + (stored.output_tokens || 0)
}))
}
if (stored) {
setCurrentUsage(current => ({
...current,
input: stored.input_tokens || 0,
output: stored.output_tokens || 0,
total: (stored.input_tokens || 0) + (stored.output_tokens || 0)
}))
}
setMessages(previousMessages)
navigate(sessionRoute(storedSessionId), { replace: true })
+9 -54
View File
@@ -1,19 +1,16 @@
import { useStore } from '@nanostores/react'
import type { CSSProperties, ReactNode, PointerEvent as ReactPointerEvent } from 'react'
import { useCallback } from 'react'
import type { CSSProperties, ReactNode } from 'react'
import { PaneShell } from '@/components/pane-shell'
import { SidebarProvider } from '@/components/ui/sidebar'
import { triggerHaptic } from '@/lib/haptics'
import {
$fileBrowserOpen,
$sidebarOpen,
$sidebarWidth,
FILE_BROWSER_DEFAULT_WIDTH,
setSidebarOpen,
setSidebarResizing,
setSidebarWidth
FILE_BROWSER_PANE_ID,
setSidebarOpen
} from '@/store/layout'
import { $paneWidthOverride } from '@/store/panes'
import { $connection } from '@/store/session'
import { StatusbarControls, type StatusbarItem } from './statusbar-controls'
@@ -39,9 +36,9 @@ export function AppShell({
statusbarItems,
titlebarTools
}: AppShellProps) {
const sidebarWidth = useStore($sidebarWidth)
const sidebarOpen = useStore($sidebarOpen)
const fileBrowserOpen = useStore($fileBrowserOpen)
const fileBrowserWidthOverride = useStore($paneWidthOverride(FILE_BROWSER_PANE_ID))
const connection = useStore($connection)
const titlebarControls = titlebarControlsPosition(connection?.windowButtonPosition)
@@ -57,12 +54,15 @@ export function AppShell({
const paneToolCount = titlebarTools?.filter(tool => !tool.hidden).length ?? 0
const systemToolsWidth = `calc(${SYSTEM_TOOL_COUNT} * var(--titlebar-control-size))`
const fileBrowserWidth =
fileBrowserWidthOverride !== undefined ? `${fileBrowserWidthOverride}px` : FILE_BROWSER_DEFAULT_WIDTH
// 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 previewToolbarGap = fileBrowserOpen ? fileBrowserWidth : systemToolsWidth
// Used by the drag region to know where the rightmost interactive element
// ends. When pane tools are present, that's `gap + paneCount * controlSize`
@@ -73,38 +73,6 @@ export function AppShell({
? `calc(${previewToolbarGap} + ${paneToolCount} * var(--titlebar-control-size))`
: systemToolsWidth
const startSidebarResize = useCallback(
(event: ReactPointerEvent<HTMLDivElement>) => {
event.preventDefault()
setSidebarResizing(true)
const startX = event.clientX
const startWidth = sidebarWidth
const previousCursor = document.body.style.cursor
const previousUserSelect = document.body.style.userSelect
document.body.style.cursor = 'col-resize'
document.body.style.userSelect = 'none'
const handleMove = (moveEvent: PointerEvent) => {
setSidebarWidth(startWidth + moveEvent.clientX - startX)
}
const handleUp = () => {
setSidebarResizing(false)
triggerHaptic('crisp')
document.body.style.cursor = previousCursor
document.body.style.userSelect = previousUserSelect
window.removeEventListener('pointermove', handleMove)
window.removeEventListener('pointerup', handleUp)
}
window.addEventListener('pointermove', handleMove)
window.addEventListener('pointerup', handleUp, { once: true })
},
[sidebarWidth]
)
return (
<SidebarProvider
className="h-screen min-h-0 bg-background"
@@ -143,19 +111,6 @@ export function AppShell({
/>
{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} />
@@ -20,7 +20,9 @@ export function useStatusSnapshot(gatewayState: string | undefined) {
getLogs({ file: 'gateway', lines: LOG_TAIL }).catch(() => ({ lines: [] }))
])
if (cancelled) {return}
if (cancelled) {
return
}
setStatusSnapshot(next)
setGatewayLogLines(logs.lines.map(line => line.trim()).filter(Boolean))
@@ -201,7 +201,18 @@ export function useStatusbarItems({
variant: 'text'
}
],
[browseSessionCwd, busy, contextBar, contextUsage, currentBranch, currentCwd, currentModel, currentProvider, sessionStartedAt, turnStartedAt]
[
browseSessionCwd,
busy,
contextBar,
contextUsage,
currentBranch,
currentCwd,
currentModel,
currentProvider,
sessionStartedAt,
turnStartedAt
]
)
const leftStatusbarItems = useMemo(
@@ -1,12 +1,7 @@
import type { ComponentProps, ReactNode } from 'react'
import { useNavigate } from 'react-router-dom'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger
} from '@/components/ui/dropdown-menu'
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from '@/components/ui/dropdown-menu'
import { cn } from '@/lib/utils'
export interface StatusbarMenuItem {
@@ -62,26 +57,24 @@ export function StatusbarControls({ className, leftItems = [], items = [], ...pr
{...props}
>
<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} />
))}
{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-0.5 overflow-x-auto">
{items.filter(item => !item.hidden).map(item => (
<StatusbarItemView item={item} key={`right:${item.id}`} navigate={navigate} />
))}
{items
.filter(item => !item.hidden)
.map(item => (
<StatusbarItemView item={item} key={`right:${item.id}`} navigate={navigate} />
))}
</div>
</footer>
)
}
function StatusbarItemView({
item,
navigate
}: {
item: StatusbarItem
navigate: ReturnType<typeof useNavigate>
}) {
function StatusbarItemView({ item, navigate }: { item: StatusbarItem; navigate: ReturnType<typeof useNavigate> }) {
const content = (
<>
{item.icon}
@@ -147,7 +140,12 @@ function StatusbarItemView({
if (item.variant === 'text' && !item.onSelect && !item.to && !item.href) {
return (
<div className={cn('inline-flex h-5 items-center gap-1 px-0.5 text-[0.68rem] 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>
)
+6 -5
View File
@@ -65,7 +65,11 @@ interface SkillsViewProps extends React.ComponentProps<'section'> {
setTitlebarToolGroup?: SetTitlebarToolGroup
}
export function SkillsView({ setStatusbarItemGroup: _setStatusbarItemGroup, setTitlebarToolGroup, ...props }: SkillsViewProps) {
export function SkillsView({
setStatusbarItemGroup: _setStatusbarItemGroup,
setTitlebarToolGroup,
...props
}: SkillsViewProps) {
const [mode, setMode] = useState<SkillsMode>('skills')
const [query, setQuery] = useState('')
const [skills, setSkills] = useState<SkillInfo[] | null>(null)
@@ -168,10 +172,7 @@ export function SkillsView({ setStatusbarItemGroup: _setStatusbarItemGroup, setT
}
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">Skills</h2>
<span className="pointer-events-auto text-xs text-muted-foreground">