feat: better composer etc
This commit is contained in:
@@ -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 && (
|
||||
|
||||
@@ -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> = { '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user