diff --git a/apps/desktop/electron/main.cjs b/apps/desktop/electron/main.cjs
index 928c4cee176..f616972ab9b 100644
--- a/apps/desktop/electron/main.cjs
+++ b/apps/desktop/electron/main.cjs
@@ -972,11 +972,7 @@ function installDevToolsShortcut(window) {
function installPreviewShortcut(window) {
window.webContents.on('before-input-event', (event, input) => {
const key = String(input.key || '').toLowerCase()
- const isPreviewCloseShortcut =
- key === 'w' &&
- (IS_MAC ? input.meta : input.control) &&
- !input.alt &&
- !input.shift
+ const isPreviewCloseShortcut = key === 'w' && (IS_MAC ? input.meta : input.control) && !input.alt && !input.shift
if (!isPreviewCloseShortcut || !previewShortcutActive) return
@@ -1313,14 +1309,18 @@ function findGitRoot(start) {
for (let i = 0; i < 50; i += 1) {
try {
- if (fs.existsSync(path.join(dir, '.git'))) {return dir}
+ if (fs.existsSync(path.join(dir, '.git'))) {
+ return dir
+ }
} catch {
return null
}
const parent = path.dirname(dir)
- if (parent === dir) {return null}
+ if (parent === dir) {
+ return null
+ }
dir = parent
}
@@ -1337,11 +1337,15 @@ function getGitignoreFile(giPath) {
return null
}
- if (!stat.isFile()) {return null}
+ if (!stat.isFile()) {
+ return null
+ }
const cached = gitignoreCache.get(giPath)
- if (cached && cached.mtime === stat.mtimeMs) {return cached}
+ if (cached && cached.mtime === stat.mtimeMs) {
+ return cached
+ }
try {
const entry = {
@@ -1376,7 +1380,9 @@ function gitignoreRulesFor(root, dir) {
for (const ruleDir of dirs) {
const rule = getGitignoreFile(path.join(ruleDir, '.gitignore'))
- if (rule) {rules.push(rule)}
+ if (rule) {
+ rules.push(rule)
+ }
}
return rules
@@ -1386,11 +1392,15 @@ function ignoredByRules(rules, abs, isDirectory) {
for (const rule of rules) {
const rel = path.relative(rule.base, abs)
- if (!rel || rel.startsWith('..') || path.isAbsolute(rel)) {continue}
+ if (!rel || rel.startsWith('..') || path.isAbsolute(rel)) {
+ continue
+ }
const probe = `${rel.split(path.sep).join('/')}${isDirectory ? '/' : ''}`
- if (rule.ig.ignores(probe)) {return true}
+ if (rule.ig.ignores(probe)) {
+ return true
+ }
}
return false
@@ -1410,12 +1420,16 @@ ipcMain.handle('hermes:fs:readDir', async (_event, dirPath) => {
const entries = dirents
.filter(d => {
- if (FS_READDIR_HIDDEN.has(d.name)) {return false}
+ if (FS_READDIR_HIDDEN.has(d.name)) {
+ return false
+ }
if (gitignoreRules.length > 0) {
const abs = path.join(resolved, d.name)
- if (ignoredByRules(gitignoreRules, abs, d.isDirectory())) {return false}
+ if (ignoredByRules(gitignoreRules, abs, d.isDirectory())) {
+ return false
+ }
}
return true
diff --git a/apps/desktop/preview-demo.html b/apps/desktop/preview-demo.html
index 33227103ba8..05bfb69eef4 100644
--- a/apps/desktop/preview-demo.html
+++ b/apps/desktop/preview-demo.html
@@ -9,8 +9,8 @@
html, body { height: 100%; margin: 0; }
body {
font-family: ui-sans-serif, system-ui, -apple-system, "SF Pro Text", sans-serif;
- background: radial-gradient(1200px 600px at 20% 10%, #3a2a1a 0%, #1a1410 40%, #0c0a08 100%);
- color: #f5e9d7;
+ background: radial-gradient(1200px 600px at 20% 10%, #4a1a33 0%, #2a1020 40%, #120810 100%);
+ color: #ffe4f1;
display: grid;
place-items: center;
padding: 2rem;
@@ -18,9 +18,9 @@
.card {
max-width: 520px;
padding: 2rem 2.25rem;
- border: 1px solid rgba(245,233,215,0.15);
+ border: 1px solid rgba(255,182,214,0.18);
border-radius: 14px;
- background: rgba(20,16,12,0.6);
+ background: rgba(28,14,22,0.6);
backdrop-filter: blur(6px);
box-shadow: 0 10px 40px rgba(0,0,0,0.4);
}
@@ -32,8 +32,8 @@
p { margin: 0.35rem 0; opacity: 0.85; line-height: 1.5; }
.dot {
display: inline-block; width: 10px; height: 10px; border-radius: 50%;
- background: #f4a93c; margin-right: 0.5rem;
- box-shadow: 0 0 12px #f4a93c;
+ background: #ff6fb5; margin-right: 0.5rem;
+ box-shadow: 0 0 12px #ff6fb5;
animation: pulse 1.6s ease-in-out infinite;
}
@keyframes pulse {
@@ -41,7 +41,7 @@
50% { transform: scale(1.4); opacity: 0.6; }
}
code {
- background: rgba(245,233,215,0.08);
+ background: rgba(255,182,214,0.10);
padding: 0.1rem 0.35rem;
border-radius: 4px;
font-size: 0.9em;
diff --git a/apps/desktop/src/app/agents/index.tsx b/apps/desktop/src/app/agents/index.tsx
index 2c8d58315aa..48b2ae8baab 100644
--- a/apps/desktop/src/app/agents/index.tsx
+++ b/apps/desktop/src/app/agents/index.tsx
@@ -103,7 +103,11 @@ function ActivityList({ tasks }: { tasks: readonly RailTask[] }) {
return (
{task.label}
@@ -124,9 +128,11 @@ function SectionStub({ label }: { label: string }) {
{label} — coming soon
Subagent stores aren't wired into the desktop yet. Once gateway events for{' '}
- subagent.spawn / progress / complete{' '}
- land here, this view shows the live spawn tree, replay history, and pause/kill controls — modelled on the TUI's{' '}
- /agents overlay.
+
+ subagent.spawn / progress / complete
+ {' '}
+ land here, this view shows the live spawn tree, replay history, and pause/kill controls — modelled on the
+ TUI's /agents overlay.
diff --git a/apps/desktop/src/app/chat/composer/attachments.tsx b/apps/desktop/src/app/chat/composer/attachments.tsx
index 6ebb8a3d600..71525215061 100644
--- a/apps/desktop/src/app/chat/composer/attachments.tsx
+++ b/apps/desktop/src/app/chat/composer/attachments.tsx
@@ -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 (
-
+
)}
- {attachment.label}
- {detail && {detail} }
+
+ {attachment.label}
+
+ {detail && (
+ {detail}
+ )}
{onRemove && (
diff --git a/apps/desktop/src/app/chat/composer/index.tsx b/apps/desktop/src/app/chat/composer/index.tsx
index 28890d4daee..78e1c9044d7 100644
--- a/apps/desktop/src/app/chat/composer/index.tsx
+++ b/apps/desktop/src/app/chat/composer/index.tsx
@@ -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
) => {
@@ -907,21 +884,12 @@ export function ChatBar({
const input = (
- {!draft && (
-
- {placeholder}
-
- )}
= { '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }
@@ -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 `
${escapeHtml(refLabel(id))} `
+ return `
${directiveIconSvg(kind)}${escapeHtml(refLabel(id))} `
}
/** 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'
diff --git a/apps/desktop/src/app/chat/composer/trigger-popover.tsx b/apps/desktop/src/app/chat/composer/trigger-popover.tsx
index 4235ef08781..82cf863f659 100644
--- a/apps/desktop/src/app/chat/composer/trigger-popover.tsx
+++ b/apps/desktop/src/app/chat/composer/trigger-popover.tsx
@@ -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 (
{display}
- {description &&
{description} }
+ {description && (
+
{description}
+ )}
)
})
diff --git a/apps/desktop/src/app/chat/hooks/use-composer-actions.ts b/apps/desktop/src/app/chat/hooks/use-composer-actions.ts
index a0346c9fea5..d9629e2871b 100644
--- a/apps/desktop/src/app/chat/hooks/use-composer-actions.ts
+++ b/apps/desktop/src/app/chat/hooks/use-composer-actions.ts
@@ -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)
diff --git a/apps/desktop/src/app/chat/right-rail/index.ts b/apps/desktop/src/app/chat/right-rail/index.ts
index 174b836c6d6..8bb73a68a89 100644
--- a/apps/desktop/src/app/chat/right-rail/index.ts
+++ b/apps/desktop/src/app/chat/right-rail/index.ts
@@ -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'
diff --git a/apps/desktop/src/app/chat/right-rail/preview-console-state.ts b/apps/desktop/src/app/chat/right-rail/preview-console-state.ts
index 726e868f273..057742d7b7d 100644
--- a/apps/desktop/src/app/chat/right-rail/preview-console-state.ts
+++ b/apps/desktop/src/app/chat/right-rail/preview-console-state.ts
@@ -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())
},
diff --git a/apps/desktop/src/app/chat/right-rail/preview-pane.tsx b/apps/desktop/src/app/chat/right-rail/preview-pane.tsx
index b8664381cad..95ee48a8e78 100644
--- a/apps/desktop/src/app/chat/right-rail/preview-pane.tsx
+++ b/apps/desktop/src/app/chat/right-rail/preview-pane.tsx
@@ -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
= {
+ 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 (
-
+
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 (
-
-
-
+
+
+
{title}
- {body}
+ {body &&
{body}
}
{(primaryAction || secondaryAction) && (
{primaryAction && (
-
- {error.description}
+
+ {error.description}
>
}
consoleHeight={consoleHeight}
@@ -541,98 +570,204 @@ async function readTextPreview(filePath: string) {
}
}
-function MarkdownPreview({ text }: { text: string }) {
- const components = useMemo(
- () => ({
- h1: ({ className, ...props }: ComponentProps<'h1'>) => (
-
- ),
- h2: ({ className, ...props }: ComponentProps<'h2'>) => (
-
- ),
- h3: ({ className, ...props }: ComponentProps<'h3'>) => (
-
- ),
- h4: ({ className, ...props }: ComponentProps<'h4'>) => (
-
- ),
- p: ({ className, ...props }: ComponentProps<'p'>) => (
-
- ),
- ul: ({ className, ...props }: ComponentProps<'ul'>) => (
-
- ),
- ol: ({ className, ...props }: ComponentProps<'ol'>) => (
-
- ),
- li: ({ className, ...props }: ComponentProps<'li'>) => ,
- blockquote: ({ className, ...props }: ComponentProps<'blockquote'>) => (
-
- ),
- 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 (
-
- {children}
-
- )
- }
+function tagged(Tag: T) {
+ const base = MD_TAG_CLASSES[Tag]
- return (
-
- {String(children).replace(/\n$/, '')}
-
- )
- },
- pre: ({ className, ...props }: ComponentProps<'pre'>) => (
-
- )
- }),
- []
- )
+ const Component = (({ className, ...rest }: ComponentProps) => {
+ const Element = Tag as React.ElementType
+
+ return
+ }) as React.FC>
+
+ Component.displayName = `Md.${Tag}`
+
+ return Component
+}
+
+function MarkdownCode({ className, children, ...props }: ComponentProps<'code'>) {
+ const language = /language-([^\s]+)/.exec(className || '')?.[1]
+
+ if (!language) {
+ return (
+
+ {children}
+
+ )
+ }
+ return (
+
+ {String(children).replace(/\n$/, '')}
+
+ )
+}
+
+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 (
-
+
{text}
)
}
+function PreviewToggle({ asSource, onToggle }: { asSource: boolean; onToggle: () => void }) {
+ return (
+
+
+ {asSource ? 'PREVIEW' : 'SOURCE'}
+
+
+ )
+}
+
+// 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, 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(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, line: number) => {
+ startLineDrag(event, filePath, inSelection(line) && selection ? selection : { end: line, start: line })
+ }
+
+ return (
+
+
+ {Array.from({ length: lineCount }, (_, index) => {
+ const line = index + 1
+ const selected = inSelection(line)
+
+ return (
+
handleLineClick(event, line)}
+ onDragStart={event => handleDragStart(event, line)}
+ title="Click to select · shift-click to extend · drag to composer"
+ >
+ {line}
+
+ )
+ })}
+
+
+ {selection && (
+
+ )}
+
+ {text}
+
+
+
+ )
+}
+
function LocalFilePreview({ reloadKey, target }: { reloadKey: number; target: PreviewTarget }) {
const [state, setState] = useState({ 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 (
- {state.error} }
- title="Preview unavailable"
- />
- )
+ return
}
if (
@@ -732,14 +861,13 @@ function LocalFilePreview({ reloadKey, target }: { reloadKey: number; target: Pr
return (
- {binary
- ? `Previewing ${target.label} may show unreadable text.`
- : `${target.label} is ${formatBytes(size)}. Hermes will show the first 512 KB.`}
-
+ 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 ? (
-
- Showing first 512 KB.
-
- ) : null
-
- if (isMarkdown && !renderMarkdownAsSource) {
- return (
-
- {truncatedBanner}
-
- setRenderMarkdownAsSource(true)}
- type="button"
- >
- SOURCE
-
-
-
-
- )
- }
+ const showRendered = isMarkdown && !renderMarkdownAsSource
return (
- {truncatedBanner}
- {isMarkdown && (
-
-
setRenderMarkdownAsSource(false)}
- type="button"
- >
- PREVIEW
-
+ {state.truncated && (
+
+ Showing first 512 KB.
)}
-
-
- {state.text}
-
-
+ {isMarkdown &&
setRenderMarkdownAsSource(s => !s)} />}
+ {showRendered ? (
+
+ ) : (
+
+ )}
)
}
return (
- {target.mimeType || 'This file type'} can still be attached as context.
-
- }
+ 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
(null)
@@ -1002,14 +1087,14 @@ export function PreviewPane({
{
active: consoleOpen,
icon: ,
- id: `${titlebarToolGroupId}-console`,
+ id: `${TITLEBAR_GROUP_ID}-console`,
label: consoleOpen ? 'Hide preview console' : 'Show preview console',
onSelect: () => consoleState.setOpen(open => !open)
},
{
active: devtoolsOpen,
icon: ,
- 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: ,
- id: `${titlebarToolGroupId}-reload`,
+ id: `${TITLEBAR_GROUP_ID}-reload`,
label: 'Reload preview',
onSelect: reloadPreview
},
{
icon: ,
- 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
])
diff --git a/apps/desktop/src/app/chat/right-rail/preview.tsx b/apps/desktop/src/app/chat/right-rail/preview.tsx
index 895740073dc..bc2b6410d13 100644
--- a/apps/desktop/src/app/chat/right-rail/preview.tsx
+++ b/apps/desktop/src/app/chat/right-rail/preview.tsx
@@ -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 . 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 (
-