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 ( -
+
{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 ( -
-
- -
+
+ +
{title}
- {body} + {body &&
{body}
}
{(primaryAction || secondaryAction) && (
{primaryAction && ( +
+ ) +} + +// 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} -
- -
- -
- ) - } + const showRendered = isMarkdown && !renderMarkdownAsSource return (
- {truncatedBanner} - {isMarkdown && ( -
- + {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 ( - + {sidebar} @@ -479,7 +495,10 @@ export function DesktopController() { /> + } path="artifacts" /> @@ -491,12 +510,28 @@ export function DesktopController() { } path="*" /> - + {chatOpen ? ( ) : null} - + diff --git a/apps/desktop/src/app/file-browser/index.tsx b/apps/desktop/src/app/file-browser/index.tsx index 920474813db..6be0362d278 100644 --- a/apps/desktop/src/app/file-browser/index.tsx +++ b/apps/desktop/src/app/file-browser/index.tsx @@ -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 () => { diff --git a/apps/desktop/src/app/file-browser/ipc.ts b/apps/desktop/src/app/file-browser/ipc.ts index 1ebf33afad9..843ebe761cd 100644 --- a/apps/desktop/src/app/file-browser/ipc.ts +++ b/apps/desktop/src/app/file-browser/ipc.ts @@ -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 { - 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 { - if (!window.hermesDesktop) {return { entries: [], error: 'no-bridge' }} + if (!window.hermesDesktop) { + return { entries: [], error: 'no-bridge' } + } const result = await window.hermesDesktop.readDir(dirPath) diff --git a/apps/desktop/src/app/file-browser/tree.tsx b/apps/desktop/src/app/file-browser/tree.tsx index 34846595e3f..e249167584a 100644 --- a/apps/desktop/src/app/file-browser/tree.tsx +++ b/apps/desktop/src/app/file-browser/tree.tsx @@ -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() diff --git a/apps/desktop/src/app/file-browser/use-project-tree.ts b/apps/desktop/src/app/file-browser/use-project-tree.ts index 657625ad0f5..44d62bd35c5 100644 --- a/apps/desktop/src/app/file-browser/use-project-tree.ts +++ b/apps/desktop/src/app/file-browser/use-project-tree.ts @@ -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 + ] ) } diff --git a/apps/desktop/src/app/session/hooks/use-context-suggestions.ts b/apps/desktop/src/app/session/hooks/use-context-suggestions.ts index 1ec5451d85e..b1e1b8878ac 100644 --- a/apps/desktop/src/app/session/hooks/use-context-suggestions.ts +++ b/apps/desktop/src/app/session/hooks/use-context-suggestions.ts @@ -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]) } diff --git a/apps/desktop/src/app/session/hooks/use-cwd-actions.ts b/apps/desktop/src/app/session/hooks/use-cwd-actions.ts index d63f1168071..0c549b7164e 100644 --- a/apps/desktop/src/app/session/hooks/use-cwd-actions.ts +++ b/apps/desktop/src/app/session/hooks/use-cwd-actions.ts @@ -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 } diff --git a/apps/desktop/src/app/session/hooks/use-model-controls.ts b/apps/desktop/src/app/session/hooks/use-model-controls.ts index 21ebd02c50a..e6c4d4834bf 100644 --- a/apps/desktop/src/app/session/hooks/use-model-controls.ts +++ b/apps/desktop/src/app/session/hooks/use-model-controls.ts @@ -25,7 +25,9 @@ export function useModelControls({ activeSessionId, queryClient, requestGateway queryClient.setQueryData(['model-options', activeSessionId || 'global'], patch) - if (includeGlobal) {queryClient.setQueryData(['model-options', 'global'], patch)} + if (includeGlobal) { + queryClient.setQueryData(['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] }) diff --git a/apps/desktop/src/app/session/hooks/use-preview-routing.test.tsx b/apps/desktop/src/app/session/hooks/use-preview-routing.test.tsx index d0222874499..1134ffe4fae 100644 --- a/apps/desktop/src/app/session/hooks/use-preview-routing.test.tsx +++ b/apps/desktop/src/app/session/hooks/use-preview-routing.test.tsx @@ -87,7 +87,13 @@ describe('usePreviewRouting', () => { const target = previewTarget('/work/demo.html') registerSessionPreview('session-1', target, 'tool-result') - render( { handleEvent = handler }} />) + render( + { + 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( { handleEvent = handler }} />) + render( + { + handleEvent = handler + }} + /> + ) act(() => { $messages.set([ @@ -109,7 +121,13 @@ describe('usePreviewRouting', () => { }) it('registers structured tool-result preview targets', async () => { - render( { handleEvent = handler }} />) + render( + { + handleEvent = handler + }} + /> + ) act(() => handleEvent({ @@ -127,7 +145,13 @@ describe('usePreviewRouting', () => { }) it('registers html previews from edit inline diffs', async () => { - render( { handleEvent = handler }} />) + render( + { + handleEvent = handler + }} + /> + ) act(() => handleEvent({ diff --git a/apps/desktop/src/app/session/hooks/use-preview-routing.ts b/apps/desktop/src/app/session/hooks/use-preview-routing.ts index 76abfc95ffc..9661e3b9e06 100644 --- a/apps/desktop/src/app/session/hooks/use-preview-routing.ts +++ b/apps/desktop/src/app/session/hooks/use-preview-routing.ts @@ -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] ) diff --git a/apps/desktop/src/app/session/hooks/use-route-resume.ts b/apps/desktop/src/app/session/hooks/use-route-resume.ts index ab9c1e973e1..5d7b04a9965 100644 --- a/apps/desktop/src/app/session/hooks/use-route-resume.ts +++ b/apps/desktop/src/app/session/hooks/use-route-resume.ts @@ -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 } diff --git a/apps/desktop/src/app/session/hooks/use-session-actions.ts b/apps/desktop/src/app/session/hooks/use-session-actions.ts index 8d61642dce6..b73b9be6d6c 100644 --- a/apps/desktop/src/app/session/hooks/use-session-actions.ts +++ b/apps/desktop/src/app/session/hooks/use-session-actions.ts @@ -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 }) diff --git a/apps/desktop/src/app/shell/app-shell.tsx b/apps/desktop/src/app/shell/app-shell.tsx index 19e9682044e..54bbeb27299 100644 --- a/apps/desktop/src/app/shell/app-shell.tsx +++ b/apps/desktop/src/app/shell/app-shell.tsx @@ -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) => { - 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 ( {children} - - {sidebarOpen && ( -
- -
- )} diff --git a/apps/desktop/src/app/shell/hooks/use-status-snapshot.ts b/apps/desktop/src/app/shell/hooks/use-status-snapshot.ts index 82fc225f0e7..687a8436e9c 100644 --- a/apps/desktop/src/app/shell/hooks/use-status-snapshot.ts +++ b/apps/desktop/src/app/shell/hooks/use-status-snapshot.ts @@ -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)) diff --git a/apps/desktop/src/app/shell/hooks/use-statusbar-items.tsx b/apps/desktop/src/app/shell/hooks/use-statusbar-items.tsx index 2f9a1ce9ecc..6ca14ad119d 100644 --- a/apps/desktop/src/app/shell/hooks/use-statusbar-items.tsx +++ b/apps/desktop/src/app/shell/hooks/use-statusbar-items.tsx @@ -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( diff --git a/apps/desktop/src/app/shell/statusbar-controls.tsx b/apps/desktop/src/app/shell/statusbar-controls.tsx index 009b2b963f9..a74c637a358 100644 --- a/apps/desktop/src/app/shell/statusbar-controls.tsx +++ b/apps/desktop/src/app/shell/statusbar-controls.tsx @@ -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} >
- {leftItems.filter(item => !item.hidden).map(item => ( - - ))} + {leftItems + .filter(item => !item.hidden) + .map(item => ( + + ))}
- {items.filter(item => !item.hidden).map(item => ( - - ))} + {items + .filter(item => !item.hidden) + .map(item => ( + + ))}
) } -function StatusbarItemView({ - item, - navigate -}: { - item: StatusbarItem - navigate: ReturnType -}) { +function StatusbarItemView({ item, navigate }: { item: StatusbarItem; navigate: ReturnType }) { const content = ( <> {item.icon} @@ -147,7 +140,12 @@ function StatusbarItemView({ if (item.variant === 'text' && !item.onSelect && !item.to && !item.href) { return ( -
+
{content}
) diff --git a/apps/desktop/src/app/skills/index.tsx b/apps/desktop/src/app/skills/index.tsx index a02a11aa442..fd07799d8f5 100644 --- a/apps/desktop/src/app/skills/index.tsx +++ b/apps/desktop/src/app/skills/index.tsx @@ -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('skills') const [query, setQuery] = useState('') const [skills, setSkills] = useState(null) @@ -168,10 +172,7 @@ export function SkillsView({ setStatusbarItemGroup: _setStatusbarItemGroup, setT } return ( -
+

Skills

diff --git a/apps/desktop/src/components/assistant-ui/directive-text.tsx b/apps/desktop/src/components/assistant-ui/directive-text.tsx index 17d0c83ba21..57e47d2c6e5 100644 --- a/apps/desktop/src/components/assistant-ui/directive-text.tsx +++ b/apps/desktop/src/components/assistant-ui/directive-text.tsx @@ -2,25 +2,85 @@ import type { Unstable_DirectiveFormatter, Unstable_DirectiveSegment, Unstable_TriggerItem } from '@assistant-ui/core' import type { TextMessagePartComponent, TextMessagePartProps } from '@assistant-ui/react' -import type { ComponentType, FC } from 'react' -import { Fragment, useMemo } from 'react' +import type { FC } from 'react' +import { Fragment, useEffect, useMemo, useState } from 'react' import { ZoomableImage } from '@/components/assistant-ui/zoomable-image' import { extractEmbeddedImages } from '@/lib/embedded-images' -import { AtSign, FileText, FolderOpen, ImageIcon, Link as LinkIcon, Wrench } from '@/lib/icons' -import { cn } from '@/lib/utils' -const HERMES_REF_TYPES = ['file', 'folder', 'url', 'image', 'tool'] as const +const HERMES_REF_TYPES = ['file', 'folder', 'url', 'image', 'tool', 'line'] as const type HermesRefType = (typeof HERMES_REF_TYPES)[number] -const ICONS: Record> = { - file: FileText, - folder: FolderOpen, - url: LinkIcon, - image: ImageIcon, - tool: Wrench +/** Single source of truth for chip icon glyphs (Tabler outline @ 24×24). + * Used both by the rendered and the raw SVG markup the + * contenteditable composer embeds via `directiveIconSvg`. */ +const ICON_PATHS: Record = { + file: [ + 'M14 3v4a1 1 0 0 0 1 1h4', + 'M17 21h-10a2 2 0 0 1 -2 -2v-14a2 2 0 0 1 2 -2h7l5 5v11a2 2 0 0 1 -2 2', + 'M9 9l1 0', + 'M9 13l6 0', + 'M9 17l6 0' + ], + folder: [ + 'M5 19l2.757 -7.351a1 1 0 0 1 .936 -.649h12.307a1 1 0 0 1 .986 1.164l-.996 5.211a2 2 0 0 1 -1.964 1.625h-14.026a2 2 0 0 1 -2 -2v-11a2 2 0 0 1 2 -2h4l3 3h7a2 2 0 0 1 2 2v2' + ], + url: [ + 'M9 15l6 -6', + 'M11 6l.463 -.536a5 5 0 0 1 7.071 7.072l-.534 .464', + 'M13 18l-.397 .534a5.068 5.068 0 0 1 -7.127 0a4.972 4.972 0 0 1 0 -7.071l.524 -.463' + ], + image: [ + 'M15 8h.01', + 'M3 6a3 3 0 0 1 3 -3h12a3 3 0 0 1 3 3v12a3 3 0 0 1 -3 3h-12a3 3 0 0 1 -3 -3v-12', + 'M3 16l5 -5c.928 -.893 2.072 -.893 3 0l5 5', + 'M14 14l1 -1c.928 -.893 2.072 -.893 3 0l3 3' + ], + tool: ['M7 10h3v-3l-3.5 -3.5a6 6 0 0 1 8 8l6 6a2 2 0 0 1 -3 3l-6 -6a6 6 0 0 1 -8 -8l3.5 3.5'], + line: ['M5 9l14 0', 'M5 15l14 0', 'M11 4l-4 16', 'M17 4l-4 16'] } +const ICON_FALLBACK = ['M8 12a4 4 0 1 0 8 0a4 4 0 1 0 -8 0', 'M16 12v1.5a2.5 2.5 0 0 0 5 0v-1.5a9 9 0 1 0 -5.5 8.28'] + +const ICON_CLASS = 'size-3 shrink-0 opacity-80' + +const SVG_ATTRS = + 'xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"' + +const iconPathsFor = (type: string) => ICON_PATHS[type as HermesRefType] ?? ICON_FALLBACK + +/** SVG markup string for embedding directly in HTML (composer contenteditable). */ +export function directiveIconSvg(type: string) { + const inner = iconPathsFor(type) + .map(d => ``) + .join('') + + return `${inner}` +} + +const DirectiveIcon: FC<{ type: string }> = ({ type }) => ( + + {iconPathsFor(type).map(d => ( + + ))} + +) + +/** Shared chip styling — used by both the rendered and the + * raw HTML composer chips in `rich-editor.ts`. Neutral subtle wash + plain + * muted-foreground text so chips read as quiet tags on any bubble color. */ +export const DIRECTIVE_CHIP_CLASS = + 'mx-0.5 inline-flex max-w-56 items-center gap-1 rounded px-1.5 py-0.5 align-[0.02em] text-[0.86em] font-normal leading-none bg-[color-mix(in_srgb,currentColor_8%,transparent)] text-muted-foreground' + /** * Parses our composer's `@type:value` references into directive segments * so they render as inline chips in user messages instead of raw text. @@ -34,7 +94,7 @@ const ICONS: Record> = { const CANONICAL_DIRECTIVE_RE = /:([\w-]{1,64})\[([^\]\n]{1,1024})\](?:\{name=([^}\n]{1,1024})\})?/g const HERMES_DIRECTIVE_RE = new RegExp( - '@(file|folder|url|image|tool):(' + '`[^`\\n]+`' + '|"[^"\\n]+"' + "|'[^'\\n]+'" + '|\\S+' + ')', + '@(file|folder|url|image|tool|line):(' + '`[^`\\n]+`' + '|"[^"\\n]+"' + "|'[^'\\n]+'" + '|\\S+' + ')', 'g' ) @@ -198,6 +258,8 @@ export function DirectiveContent({ text }: { text: string }) { {segments.map((segment, index) => segment.kind === 'text' ? ( {segment.text} + ) : segment.type === 'image' ? ( + ) : ( ) @@ -225,25 +287,67 @@ export const DirectiveText: TextMessagePartComponent = ({ text }: TextMessagePar ) +/** Image refs render as a thumbnail rather than a chip — matches how persisted + * messages render after the backend embeds the data URL, so the UX is stable + * across initial send and refresh. */ +const DirectiveImage: FC<{ id: string; label: string }> = ({ id, label }) => { + const remote = /^(?:https?|data):/i.test(id) + const [src, setSrc] = useState(remote ? id : null) + const [failed, setFailed] = useState(false) + + useEffect(() => { + if (remote || !id) { + return + } + + let alive = true + void window.hermesDesktop + ?.readFileDataUrl(id) + .then(url => alive && setSrc(url)) + .catch(() => alive && setFailed(true)) + + return () => { + alive = false + } + }, [id, remote]) + + if (failed) { + return + } + + if (!src) { + return ( + + ) + } + + return ( + + ) +} + const DirectiveChip: FC<{ type: string label: string id: string -}> = ({ type, label, id }) => { - const Icon = ICONS[type as HermesRefType] ?? AtSign - - return ( - - {Icon && } - {label} - - ) -} +}> = ({ type, label, id }) => ( + + + {label} + +) diff --git a/apps/desktop/src/components/assistant-ui/markdown-text.test.ts b/apps/desktop/src/components/assistant-ui/markdown-text.test.ts index a74a7c0be5a..10ed91f0ab7 100644 --- a/apps/desktop/src/components/assistant-ui/markdown-text.test.ts +++ b/apps/desktop/src/components/assistant-ui/markdown-text.test.ts @@ -52,6 +52,7 @@ describe('preprocessMarkdown', () => { const input = ['```Heads up - a bunny got added', '- Pure white (`#ffffff`)', '- Ambient dropped to 0.18'].join( '\n' ) + const output = preprocessMarkdown(input) expect(output).not.toContain('```heads') diff --git a/apps/desktop/src/components/assistant-ui/preview-attachment.tsx b/apps/desktop/src/components/assistant-ui/preview-attachment.tsx index dfde7604603..cc4c8ef2d48 100644 --- a/apps/desktop/src/components/assistant-ui/preview-attachment.tsx +++ b/apps/desktop/src/components/assistant-ui/preview-attachment.tsx @@ -5,7 +5,12 @@ import { MonitorPlay } from '@/lib/icons' import { normalizeOrLocalPreviewTarget } from '@/lib/local-preview' import { previewName } from '@/lib/preview-targets' import { notifyError } from '@/store/notifications' -import { $previewTarget, dismissPreviewTarget, type PreviewRecordSource, setCurrentSessionPreviewTarget } from '@/store/preview' +import { + $previewTarget, + dismissPreviewTarget, + type PreviewRecordSource, + setCurrentSessionPreviewTarget +} from '@/store/preview' import { $currentCwd } from '@/store/session' export function PreviewAttachment({ source = 'manual', target }: { source?: PreviewRecordSource; target: string }) { diff --git a/apps/desktop/src/components/assistant-ui/tool-fallback.tsx b/apps/desktop/src/components/assistant-ui/tool-fallback.tsx index f45d174133d..b13b750bc23 100644 --- a/apps/desktop/src/components/assistant-ui/tool-fallback.tsx +++ b/apps/desktop/src/components/assistant-ui/tool-fallback.tsx @@ -561,7 +561,10 @@ function toolSubtitle( firstStringField(argsRecord, ['path', 'file', 'filepath']) || htmlPathFromInlineDiff(firstStringField(resultRecord, ['inline_diff'])) - return path || (firstStringField(resultRecord, ['inline_diff']) ? 'Changed file' : fallbackDetailText(argsRecord, resultRecord)) + return ( + path || + (firstStringField(resultRecord, ['inline_diff']) ? 'Changed file' : fallbackDetailText(argsRecord, resultRecord)) + ) } if (toolName === 'web_extract') { diff --git a/apps/desktop/src/components/pane-shell/pane-shell.test.tsx b/apps/desktop/src/components/pane-shell/pane-shell.test.tsx index 8ba48613385..99f481f0540 100644 --- a/apps/desktop/src/components/pane-shell/pane-shell.test.tsx +++ b/apps/desktop/src/components/pane-shell/pane-shell.test.tsx @@ -1,4 +1,4 @@ -import { cleanup, render } from '@testing-library/react' +import { cleanup, fireEvent, render } from '@testing-library/react' import { afterEach, beforeEach, describe, expect, it } from 'vitest' import { $paneStates, setPaneOpen, setPaneWidthOverride } from '@/store/panes' @@ -19,6 +19,23 @@ function getColumnTemplate(container: HTMLElement): string[] { return (container.style.gridTemplateColumns ?? '').split(/\s+/).filter(Boolean) } +function mockWidth(element: HTMLElement, width: number) { + Object.defineProperty(element, 'getBoundingClientRect', { + configurable: true, + value: () => ({ + bottom: 0, + height: 0, + left: 0, + right: width, + top: 0, + width, + x: 0, + y: 0, + toJSON: () => ({}) + }) + }) +} + describe('PaneShell composition', () => { beforeEach(() => { $paneStates.set({}) @@ -244,4 +261,73 @@ describe('PaneShell composition', () => { expect(rendered.getByTestId('floating-overlay')).toBeDefined() }) + + it('shows a resize handle only when resizable', () => { + const rendered = render( + + + files + + + preview + + main + + ) + + expect(rendered.queryByLabelText('Resize files')).toBeNull() + expect(rendered.getByLabelText('Resize preview')).toBeDefined() + }) + + it('dragging a left-pane separator stores a wider width override', () => { + const rendered = render( + + + files + + main + + ) + + const paneCell = rendered.getByTestId('files-content').parentElement + + if (!(paneCell instanceof HTMLElement)) { + throw new Error('Expected pane cell element') + } + + mockWidth(paneCell, 240) + const separator = rendered.getByLabelText('Resize files') + + fireEvent.pointerDown(separator, { clientX: 240, pointerId: 1 }) + fireEvent.pointerMove(window, { clientX: 300 }) + fireEvent.pointerUp(window, { clientX: 300 }) + + expect($paneStates.get().files?.widthOverride).toBe(300) + }) + + it('dragging a right-pane separator clamps to max width', () => { + const rendered = render( + + main + + preview + + + ) + + const paneCell = rendered.getByTestId('preview-content').parentElement + + if (!(paneCell instanceof HTMLElement)) { + throw new Error('Expected pane cell element') + } + + mockWidth(paneCell, 320) + const separator = rendered.getByLabelText('Resize preview') + + fireEvent.pointerDown(separator, { clientX: 900, pointerId: 1 }) + fireEvent.pointerMove(window, { clientX: 760 }) + fireEvent.pointerUp(window, { clientX: 760 }) + + expect($paneStates.get().preview?.widthOverride).toBe(340) + }) }) diff --git a/apps/desktop/src/components/pane-shell/pane-shell.tsx b/apps/desktop/src/components/pane-shell/pane-shell.tsx index e2d8e8dd0ab..4397cc61cff 100644 --- a/apps/desktop/src/components/pane-shell/pane-shell.tsx +++ b/apps/desktop/src/components/pane-shell/pane-shell.tsx @@ -3,8 +3,10 @@ import { Children, type CSSProperties, isValidElement, + type PointerEvent as ReactPointerEvent, type ReactElement, type ReactNode, + useCallback, useContext, useEffect, useMemo, @@ -12,7 +14,7 @@ import { } from 'react' import { cn } from '@/lib/utils' -import { $paneStates, ensurePaneRegistered } from '@/store/panes' +import { $paneStates, ensurePaneRegistered, setPaneWidthOverride } from '@/store/panes' import { PaneShellContext, type PaneShellContextValue, type PaneSlot } from './context' @@ -57,10 +59,26 @@ interface CollectedPane { } const DEFAULT_WIDTH = '16rem' +const DEFAULT_RESIZE_MIN_WIDTH = 160 const widthToCss = (value: WidthValue | undefined, fallback: string) => value === undefined ? fallback : typeof value === 'number' ? `${value}px` : value +const remPx = () => + typeof window === 'undefined' + ? 16 + : Number.parseFloat(window.getComputedStyle(document.documentElement).fontSize) || 16 + +// Resolves PaneProps.minWidth/maxWidth (number | "Npx" | "Nrem") to pixels for drag clamping. +function widthToPx(value: WidthValue | undefined) { + if (typeof value === 'number') {return Number.isFinite(value) ? value : undefined} + const match = value?.trim().match(/^(-?\d*\.?\d+)(px|rem)?$/) + + if (!match) {return undefined} + + return Number.parseFloat(match[1]) * (match[2] === 'rem' ? remPx() : 1) +} + function isRole(child: unknown, role: 'pane' | 'main'): child is ReactElement { return isValidElement(child) && (child.type as PaneRoleMarker)?.__paneShellRole === role } @@ -159,9 +177,19 @@ export function PaneShell({ children, className, style }: PaneShellProps) { ) } -export function Pane({ children, className, defaultOpen = true, disabled = false, id }: PaneProps) { +export function Pane({ + children, + className, + defaultOpen = true, + disabled = false, + id, + maxWidth, + minWidth, + resizable = false +}: PaneProps) { const ctx = useContext(PaneShellContext) const registered = useRef(false) + const paneRef = useRef(null) useEffect(() => { if (registered.current) {return} @@ -169,27 +197,86 @@ export function Pane({ children, className, defaultOpen = true, disabled = false ensurePaneRegistered(id, { open: defaultOpen }) }, [defaultOpen, id]) + const slot = ctx?.paneById.get(id) + const open = Boolean(slot?.open && !disabled) + const canResize = open && resizable + const lo = widthToPx(minWidth) ?? DEFAULT_RESIZE_MIN_WIDTH + const hi = widthToPx(maxWidth) ?? Number.POSITIVE_INFINITY + const side = slot?.side ?? 'left' + + const startResize = useCallback( + (event: ReactPointerEvent) => { + const paneWidth = paneRef.current?.getBoundingClientRect().width ?? 0 + + if (!canResize || paneWidth <= 0) {return} + event.preventDefault() + + const handle = event.currentTarget + const { pointerId, clientX: startX } = event + const dir = side === 'left' ? 1 : -1 + const restoreCursor = document.body.style.cursor + const restoreSelect = document.body.style.userSelect + + handle.setPointerCapture?.(pointerId) + document.body.style.cursor = 'col-resize' + document.body.style.userSelect = 'none' + + const onMove = (e: PointerEvent) => { + const next = paneWidth + (e.clientX - startX) * dir + setPaneWidthOverride(id, Math.round(Math.min(hi, Math.max(lo, next)))) + } + + const cleanup = () => { + document.body.style.cursor = restoreCursor + document.body.style.userSelect = restoreSelect + handle.releasePointerCapture?.(pointerId) + window.removeEventListener('pointermove', onMove, true) + window.removeEventListener('pointerup', cleanup, true) + window.removeEventListener('pointercancel', cleanup, true) + window.removeEventListener('blur', cleanup) + } + + window.addEventListener('pointermove', onMove, true) + window.addEventListener('pointerup', cleanup, true) + window.addEventListener('pointercancel', cleanup, true) + window.addEventListener('blur', cleanup) + }, + [canResize, hi, id, lo, side] + ) + if (!ctx) { if (import.meta.env.DEV) {console.warn(`[Pane:${id}] must be rendered inside `)} return null } - const slot = ctx.paneById.get(id) - if (!slot) {return null} - const open = slot.open && !disabled - return (
+ {canResize && ( +
+ +
+ )} {children}
) diff --git a/apps/desktop/src/components/ui/copy-button.tsx b/apps/desktop/src/components/ui/copy-button.tsx index defb9ec5ffd..a28f1c7037d 100644 --- a/apps/desktop/src/components/ui/copy-button.tsx +++ b/apps/desktop/src/components/ui/copy-button.tsx @@ -133,6 +133,7 @@ export function CopyButton({ const Icon = status === 'copied' ? Check : status === 'error' ? X : Copy const icon = + const visibleChildren = (showLabel ?? (appearance !== 'icon' && appearance !== 'tool-row')) ? status === 'copied' diff --git a/apps/desktop/src/components/ui/fade-text.tsx b/apps/desktop/src/components/ui/fade-text.tsx index c3487439bff..59f74695960 100644 --- a/apps/desktop/src/components/ui/fade-text.tsx +++ b/apps/desktop/src/components/ui/fade-text.tsx @@ -50,7 +50,7 @@ export function FadeText({ children, className, fadeWidth = '3rem', style, ...re WebkitMaskImage: `linear-gradient(to right, black calc(100% - ${fadeWidth}), transparent)`, ...style } - : style ?? {} + : (style ?? {}) return ( { } export function gatewayEventCompletedFileDiff(event: RpcEventLike): boolean { - if (event.type !== 'tool.complete') {return false} + if (event.type !== 'tool.complete') { + return false + } const diff = asRecord(event.payload).inline_diff return typeof diff === 'string' && diff.trim().length > 0 @@ -20,7 +22,14 @@ export function gatewayEventCompletedFileDiff(event: RpcEventLike): boolean { export function buildGatewayLogItems(lines: readonly string[]): readonly StatusbarMenuItem[] { if (lines.length === 0) { - return [{ className: 'text-muted-foreground', disabled: true, id: 'gateway-log-empty', label: 'No recent gateway log lines' }] + return [ + { + className: 'text-muted-foreground', + disabled: true, + id: 'gateway-log-empty', + label: 'No recent gateway log lines' + } + ] } return lines.slice(-LOG_TAIL).map((line, index) => ({ diff --git a/apps/desktop/src/lib/icons.ts b/apps/desktop/src/lib/icons.ts index a6f9105b9a0..5f14e550de2 100644 --- a/apps/desktop/src/lib/icons.ts +++ b/apps/desktop/src/lib/icons.ts @@ -32,6 +32,7 @@ import { IconGitBranch as GitBranch, IconGitBranch as GitBranchIcon, IconGlobe as Globe, + IconHash as Hash, IconHelpCircle as HelpCircle, IconPhoto as ImageIcon, IconInfoCircle as Info, @@ -120,6 +121,7 @@ export { GitBranch, GitBranchIcon, Globe, + Hash, HelpCircle, ImageIcon, Info, diff --git a/apps/desktop/src/lib/local-preview.ts b/apps/desktop/src/lib/local-preview.ts index c566b4b8b51..6c181699901 100644 --- a/apps/desktop/src/lib/local-preview.ts +++ b/apps/desktop/src/lib/local-preview.ts @@ -50,13 +50,18 @@ function extension(value: string) { } function joinPath(base: string, rel: string) { - if (!base) {return rel} + if (!base) { + return rel + } return `${base.replace(/\/+$/, '')}/${rel.replace(/^\.?\//, '')}` } function pathToFileUrl(path: string) { - const encoded = path.split('/').map(part => encodeURIComponent(part)).join('/') + const encoded = path + .split('/') + .map(part => encodeURIComponent(part)) + .join('/') return `file://${encoded.startsWith('/') ? encoded : `/${encoded}`}` } @@ -64,7 +69,9 @@ function pathToFileUrl(path: string) { export function localPreviewTarget(rawTarget: string, cwd?: string | null): PreviewTarget | null { const raw = rawTarget.trim().replace(/^`|`$/g, '') - if (!raw) {return null} + if (!raw) { + return null + } if (/^https?:\/\//i.test(raw)) { return { kind: 'url', label: basename(raw), source: raw, url: raw } @@ -100,7 +107,10 @@ export function localPreviewTarget(rawTarget: string, cwd?: string | null): Prev } } -export async function normalizeOrLocalPreviewTarget(rawTarget: string, cwd?: string | null): Promise { +export async function normalizeOrLocalPreviewTarget( + rawTarget: string, + cwd?: string | null +): Promise { try { const normalized = await window.hermesDesktop?.normalizePreviewTarget?.(rawTarget, cwd || undefined) diff --git a/apps/desktop/src/lib/preview-targets.test.ts b/apps/desktop/src/lib/preview-targets.test.ts index a7323f65184..20a116f8fdf 100644 --- a/apps/desktop/src/lib/preview-targets.test.ts +++ b/apps/desktop/src/lib/preview-targets.test.ts @@ -1,10 +1,6 @@ import { describe, expect, it } from 'vitest' -import { - extractPreviewTargets, - previewTargetFromMarkdownHref, - stripPreviewTargets -} from './preview-targets' +import { extractPreviewTargets, previewTargetFromMarkdownHref, stripPreviewTargets } from './preview-targets' describe('preview target detection', () => { it('does not infer preview targets from raw paths or URLs', () => { diff --git a/apps/desktop/src/lib/preview-targets.ts b/apps/desktop/src/lib/preview-targets.ts index 51e19f2dfff..bc7108abd45 100644 --- a/apps/desktop/src/lib/preview-targets.ts +++ b/apps/desktop/src/lib/preview-targets.ts @@ -61,4 +61,3 @@ export function previewDisplayLabel(target: string): string { return `Preview: ${escaped}` } - diff --git a/apps/desktop/src/main.tsx b/apps/desktop/src/main.tsx index 3bf0b8f0bc8..daf26132f15 100644 --- a/apps/desktop/src/main.tsx +++ b/apps/desktop/src/main.tsx @@ -1,5 +1,4 @@ import './styles.css' -import 'streamdown/styles.css' import { QueryClient, QueryClientProvider } from '@tanstack/react-query' import { StrictMode } from 'react' diff --git a/apps/desktop/src/store/activity.ts b/apps/desktop/src/store/activity.ts index ba81dccd531..f8a4ada428b 100644 --- a/apps/desktop/src/store/activity.ts +++ b/apps/desktop/src/store/activity.ts @@ -54,7 +54,8 @@ export function buildRailTasks( id: `preview:${previewRestart.taskId}`, label: 'Preview restart', detail: previewRestart.message || previewRestart.url, - status: previewRestart.status === 'error' ? 'error' : previewRestart.status === 'running' ? 'running' : 'success', + status: + previewRestart.status === 'error' ? 'error' : previewRestart.status === 'running' ? 'running' : 'success', updatedAt: Date.now() } ] diff --git a/apps/desktop/src/store/layout.ts b/apps/desktop/src/store/layout.ts index abc5a550f95..7d1f6e6e925 100644 --- a/apps/desktop/src/store/layout.ts +++ b/apps/desktop/src/store/layout.ts @@ -2,17 +2,13 @@ import { atom, computed, type ReadableAtom } from 'nanostores' import { arraysEqual, insertUniqueId, persistStringArray, storedStringArray } from '@/lib/storage' -import { - $paneStates, - ensurePaneRegistered, - setPaneOpen, - setPaneWidthOverride, - togglePane -} from './panes' +import { $paneStates, ensurePaneRegistered, setPaneOpen, setPaneWidthOverride, togglePane } from './panes' export const SIDEBAR_DEFAULT_WIDTH = 224 export const SIDEBAR_MAX_WIDTH = 320 export const FILE_BROWSER_DEFAULT_WIDTH = '17rem' +export const FILE_BROWSER_MIN_WIDTH = '14rem' +export const FILE_BROWSER_MAX_WIDTH = '20rem' const SIDEBAR_PINNED_STORAGE_KEY = 'hermes.desktop.pinnedSessions' diff --git a/apps/desktop/src/store/panes.ts b/apps/desktop/src/store/panes.ts index 0c62da33e46..f37a6b766b2 100644 --- a/apps/desktop/src/store/panes.ts +++ b/apps/desktop/src/store/panes.ts @@ -13,16 +13,22 @@ export interface PaneRegisterDefaults { const STORAGE_KEY = 'hermes.desktop.paneStates.v1' function isSnapshot(value: unknown): value is PaneStateSnapshot { - if (!value || typeof value !== 'object') {return false} + if (!value || typeof value !== 'object') { + return false + } const r = value as Record - if (typeof r.open !== 'boolean') {return false} + if (typeof r.open !== 'boolean') { + return false + } return r.widthOverride === undefined || (typeof r.widthOverride === 'number' && Number.isFinite(r.widthOverride)) } function load(): Record { - if (typeof window === 'undefined') {return {}} + if (typeof window === 'undefined') { + return {} + } try { const raw = window.localStorage.getItem(STORAGE_KEY) @@ -34,7 +40,9 @@ function load(): Record { const out: Record = {} for (const [id, value] of Object.entries(parsed as Record)) { - if (isSnapshot(value)) {out[id] = { open: value.open, widthOverride: value.widthOverride }} + if (isSnapshot(value)) { + out[id] = { open: value.open, widthOverride: value.widthOverride } + } } return out @@ -49,10 +57,14 @@ function load(): Record { // widthOverride is in-memory only — phase 2 can add per-pane persistWidth opt-in. function persist(states: Record) { - if (typeof window === 'undefined') {return} + if (typeof window === 'undefined') { + return + } const minimal: Record = {} - for (const [id, s] of Object.entries(states)) {minimal[id] = { open: s.open }} + for (const [id, s] of Object.entries(states)) { + minimal[id] = { open: s.open } + } try { window.localStorage.setItem(STORAGE_KEY, JSON.stringify(minimal)) @@ -66,7 +78,11 @@ export const $paneStates = atom>(load()) $paneStates.subscribe(persist) // Cached per-pane derived atoms keep useStore subscriptions referentially stable. -function memoized(cache: Map>, id: string, selector: (s: PaneStateSnapshot | undefined) => T) { +function memoized( + cache: Map>, + id: string, + selector: (s: PaneStateSnapshot | undefined) => T +) { let cached = cache.get(id) if (!cached) { @@ -88,7 +104,9 @@ export const $paneWidthOverride = (id: string) => memoized(widthCache, id, s => export function ensurePaneRegistered(id: string, defaults: PaneRegisterDefaults) { const current = $paneStates.get() - if (current[id] !== undefined) {return} + if (current[id] !== undefined) { + return + } $paneStates.set({ ...current, [id]: { open: defaults.open, widthOverride: defaults.widthOverride } }) } @@ -96,7 +114,9 @@ export function setPaneOpen(id: string, open: boolean) { const current = $paneStates.get() const existing = current[id] - if (existing?.open === open) {return} + if (existing?.open === open) { + return + } $paneStates.set({ ...current, [id]: { open, widthOverride: existing?.widthOverride } }) } @@ -110,7 +130,9 @@ export function setPaneWidthOverride(id: string, width: number | undefined) { const current = $paneStates.get() const existing = current[id] ?? { open: false } - if (existing.widthOverride === width) {return} + if (existing.widthOverride === width) { + return + } $paneStates.set({ ...current, [id]: { open: existing.open, widthOverride: width } }) } diff --git a/apps/desktop/src/store/preview.ts b/apps/desktop/src/store/preview.ts index a1a99e3cfbf..476e3d3a3de 100644 --- a/apps/desktop/src/store/preview.ts +++ b/apps/desktop/src/store/preview.ts @@ -61,7 +61,13 @@ function isSamePreviewTarget(a: PreviewTarget | null, b: PreviewTarget | null): return false } - return a.kind === b.kind && a.label === b.label && a.renderMode === b.renderMode && a.source === b.source && a.url === b.url + return ( + a.kind === b.kind && + a.label === b.label && + a.renderMode === b.renderMode && + a.source === b.source && + a.url === b.url + ) } export function setPreviewTarget(target: PreviewTarget | null) { @@ -72,7 +78,7 @@ export function setPreviewTarget(target: PreviewTarget | null) { $previewTarget.set(target) } -export function setFilePreviewTarget(target: PreviewTarget | null) { +function setFilePreviewTarget(target: PreviewTarget | null) { if (isSamePreviewTarget($filePreviewTarget.get(), target)) { return } @@ -80,8 +86,33 @@ export function setFilePreviewTarget(target: PreviewTarget | null) { $filePreviewTarget.set(target) } +// Manual/file-browser opens are "peeking at a file" → source view in the file +// pane. Tool/explicit-link opens are runnable artifacts → live preview pane. +function isFilePreviewSource(source: PreviewRecordSource): boolean { + return source === 'file-browser' || source === 'manual' +} + +function previewTargetForSource(target: PreviewTarget, source: PreviewRecordSource): PreviewTarget { + if (target.kind !== 'file' || target.previewKind !== 'html') { + return target + } + + return { ...target, renderMode: isFilePreviewSource(source) ? 'source' : 'preview' } +} + +function tryOpenFilePreview(target: PreviewTarget, source: PreviewRecordSource): boolean { + if (target.kind !== 'file' || !isFilePreviewSource(source)) { + return false + } + setFilePreviewTarget(previewTargetForSource(target, source)) + + return true +} + function isPreviewTarget(value: unknown): value is PreviewTarget { - if (!value || typeof value !== 'object') {return false} + if (!value || typeof value !== 'object') { + return false + } const r = value as Record return ( @@ -93,7 +124,9 @@ function isPreviewTarget(value: unknown): value is PreviewTarget { } function isPreviewRecord(value: unknown): value is SessionPreviewRecord { - if (!value || typeof value !== 'object') {return false} + if (!value || typeof value !== 'object') { + return false + } const r = value as Record return ( @@ -108,22 +141,32 @@ function isPreviewRecord(value: unknown): value is SessionPreviewRecord { } function loadSessionPreviewRegistry(): SessionPreviewRegistry { - if (typeof window === 'undefined') {return {}} + if (typeof window === 'undefined') { + return {} + } try { const raw = window.localStorage.getItem(REGISTRY_STORAGE_KEY) - if (!raw) {return {}} + if (!raw) { + return {} + } const parsed = JSON.parse(raw) as unknown - if (!parsed || typeof parsed !== 'object') {return {}} + if (!parsed || typeof parsed !== 'object') { + return {} + } const out: SessionPreviewRegistry = {} for (const [sessionId, records] of Object.entries(parsed as Record)) { - if (!Array.isArray(records)) {continue} + if (!Array.isArray(records)) { + continue + } const valid = records.filter(isPreviewRecord).slice(0, MAX_RECORDS_PER_SESSION) - if (valid.length > 0) {out[sessionId] = valid} + if (valid.length > 0) { + out[sessionId] = valid + } } return pruneRegistry(out) @@ -133,7 +176,9 @@ function loadSessionPreviewRegistry(): SessionPreviewRegistry { } function persistSessionPreviewRegistry(registry: SessionPreviewRegistry) { - if (typeof window === 'undefined') {return} + if (typeof window === 'undefined') { + return + } try { window.localStorage.setItem(REGISTRY_STORAGE_KEY, JSON.stringify(pruneRegistry(registry))) @@ -144,10 +189,10 @@ function persistSessionPreviewRegistry(registry: SessionPreviewRegistry) { function pruneRegistry(registry: SessionPreviewRegistry): SessionPreviewRegistry { const entries = Object.entries(registry) - .map(([sessionId, records]) => [ - sessionId, - [...records].sort((a, b) => b.createdAt - a.createdAt).slice(0, MAX_RECORDS_PER_SESSION) - ] as const) + .map( + ([sessionId, records]) => + [sessionId, [...records].sort((a, b) => b.createdAt - a.createdAt).slice(0, MAX_RECORDS_PER_SESSION)] as const + ) .filter(([, records]) => records.length > 0) .sort(([, a], [, b]) => (b[0]?.createdAt ?? 0) - (a[0]?.createdAt ?? 0)) .slice(0, MAX_SESSIONS) @@ -171,7 +216,9 @@ export function registerSessionPreview( ): SessionPreviewRecord | null { const id = sessionId?.trim() - if (!id) {return null} + if (!id) { + return null + } const current = $sessionPreviewRegistry.get() const now = Date.now() @@ -199,38 +246,13 @@ export function registerSessionPreview( return nextRecord } -function previewTargetForSource(target: PreviewTarget, source: PreviewRecordSource): PreviewTarget { - if (target.kind !== 'file' || target.previewKind !== 'html') { - return target - } - - return { - ...target, - renderMode: source === 'file-browser' || source === 'manual' ? 'source' : 'preview' - } -} - -function shouldOpenAsFilePreview(target: PreviewTarget, source: PreviewRecordSource): boolean { - return target.kind === 'file' && (source === 'file-browser' || source === 'manual') -} - -export function registerCurrentSessionPreview( - target: PreviewTarget, - source: PreviewRecordSource, - rawTarget = target.source -): SessionPreviewRecord | null { - return registerSessionPreview(currentPreviewSessionId(), target, source, rawTarget) -} - export function setSessionPreviewTarget( sessionId: string | null | undefined, target: PreviewTarget, source: PreviewRecordSource, rawTarget = target.source ): SessionPreviewRecord | null { - if (shouldOpenAsFilePreview(target, source)) { - setFilePreviewTarget(previewTargetForSource(target, source)) - + if (tryOpenFilePreview(target, source)) { return null } @@ -247,24 +269,15 @@ export function setCurrentSessionPreviewTarget( source: PreviewRecordSource, rawTarget = target.source ): SessionPreviewRecord | null { - if (shouldOpenAsFilePreview(target, source)) { - setFilePreviewTarget(previewTargetForSource(target, source)) - - return null - } - - const record = registerCurrentSessionPreview(target, source, rawTarget) - - setFilePreviewTarget(null) - setPreviewTarget(record?.normalized ?? previewTargetForSource(target, source)) - - return record + return setSessionPreviewTarget(currentPreviewSessionId(), target, source, rawTarget) } export function getSessionPreviewRecord(sessionId: string | null | undefined): SessionPreviewRecord | null { const id = sessionId?.trim() - if (!id) {return null} + if (!id) { + return null + } return $sessionPreviewRegistry.get()[id]?.find(record => !record.dismissedAt && record.autoOpen !== false) ?? null } @@ -272,15 +285,21 @@ export function getSessionPreviewRecord(sessionId: string | null | undefined): S export function dismissSessionPreview(sessionId: string | null | undefined, url?: string) { const id = sessionId?.trim() - if (!id) {return} + if (!id) { + return + } const current = $sessionPreviewRegistry.get() const records = current[id] - if (!records?.length) {return} + if (!records?.length) { + return + } const now = Date.now() const targetUrl = url || records.find(record => !record.dismissedAt)?.normalized.url - if (!targetUrl) {return} + if (!targetUrl) { + return + } // The preview rail is a single active file, not a back stack. Dismissing the // current preview should leave the rail closed instead of revealing an older diff --git a/apps/desktop/src/store/tool-view.ts b/apps/desktop/src/store/tool-view.ts index d13d53beb56..11f96bfee94 100644 --- a/apps/desktop/src/store/tool-view.ts +++ b/apps/desktop/src/store/tool-view.ts @@ -10,7 +10,9 @@ const TOOL_VIEW_TECHNICAL_STORAGE_KEY = 'hermes.desktop.toolView.technical' const TOOL_DISCLOSURE_STORAGE_KEY = 'hermes.desktop.toolDisclosure.v1' const MAX_DISCLOSURE_STATES = 240 -export const $toolViewMode = atom(storedBoolean(TOOL_VIEW_TECHNICAL_STORAGE_KEY, false) ? 'technical' : 'product') +export const $toolViewMode = atom( + storedBoolean(TOOL_VIEW_TECHNICAL_STORAGE_KEY, false) ? 'technical' : 'product' +) export const $toolDisclosureStates = atom(loadToolDisclosureStates()) $toolViewMode.subscribe(mode => persistBoolean(TOOL_VIEW_TECHNICAL_STORAGE_KEY, mode === 'technical')) @@ -21,15 +23,21 @@ export function setToolViewMode(mode: ToolViewMode) { } function loadToolDisclosureStates(): ToolDisclosureStates { - if (typeof window === 'undefined') {return {}} + if (typeof window === 'undefined') { + return {} + } try { const raw = window.localStorage.getItem(TOOL_DISCLOSURE_STORAGE_KEY) - if (!raw) {return {}} + if (!raw) { + return {} + } const parsed = JSON.parse(raw) as unknown - if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {return {}} + if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) { + return {} + } return Object.fromEntries( Object.entries(parsed as Record) @@ -42,7 +50,9 @@ function loadToolDisclosureStates(): ToolDisclosureStates { } function persistToolDisclosureStates(states: ToolDisclosureStates) { - if (typeof window === 'undefined') {return} + if (typeof window === 'undefined') { + return + } try { const entries = Object.entries(states).slice(-MAX_DISCLOSURE_STATES) @@ -54,10 +64,14 @@ function persistToolDisclosureStates(states: ToolDisclosureStates) { } export function setToolDisclosureOpen(id: string, open: boolean) { - if (!id) {return} + if (!id) { + return + } const current = $toolDisclosureStates.get() - if (current[id] === open) {return} + if (current[id] === open) { + return + } $toolDisclosureStates.set({ ...current, [id]: open }) } diff --git a/apps/desktop/src/styles.css b/apps/desktop/src/styles.css index 723b61beba2..0fecf986abc 100644 --- a/apps/desktop/src/styles.css +++ b/apps/desktop/src/styles.css @@ -344,22 +344,16 @@ canvas { max-width: 100%; font-family: ui-monospace, 'SF Mono', 'Cascadia Code', Menlo, Consolas, monospace; font-size: 0.86em; - padding: 0.01rem 0.16rem; - border-radius: 0.2rem; - background: #f4f4f5; - color: #be185d; + padding: 0.01rem 0.2rem; + border-radius: 0.25rem; + background: color-mix(in srgb, var(--dt-muted) 80%, transparent); + color: var(--dt-muted-foreground); border: 0; overflow-wrap: anywhere; word-break: break-word; white-space: normal; } -:root.dark [data-slot='aui_assistant-message-content'] .aui-md code { - background: #27272a; - color: #f9a8d4; - border-color: #831843; -} - [data-slot='aui_assistant-message-content'] .aui-md [data-streamdown='code-block'] code { max-width: none; font-family: inherit; diff --git a/apps/desktop/src/themes/use-skin-command.ts b/apps/desktop/src/themes/use-skin-command.ts index 3c3b8d7b290..67b5a170c56 100644 --- a/apps/desktop/src/themes/use-skin-command.ts +++ b/apps/desktop/src/themes/use-skin-command.ts @@ -14,9 +14,14 @@ export function useSkinCommand() { (rawArg: string) => { const arg = rawArg.trim() - if (!availableThemes.length) {return 'No desktop themes are available.'} + if (!availableThemes.length) { + return 'No desktop themes are available.' + } - const activeIndex = Math.max(0, availableThemes.findIndex(t => t.name === themeName)) + const activeIndex = Math.max( + 0, + availableThemes.findIndex(t => t.name === themeName) + ) if (!arg || arg === 'next') { const next = availableThemes[(activeIndex + 1) % availableThemes.length] @@ -33,9 +38,13 @@ export function useSkinCommand() { const normalized = arg.toLowerCase() const targetName = ALIASES[normalized] || normalized - const target = availableThemes.find(t => t.name.toLowerCase() === targetName || t.label.toLowerCase() === normalized) + const target = availableThemes.find( + t => t.name.toLowerCase() === targetName || t.label.toLowerCase() === normalized + ) - if (!target) {return `Unknown desktop theme: ${arg}\nAvailable: ${availableThemes.map(t => t.name).join(', ')}`} + if (!target) { + return `Unknown desktop theme: ${arg}\nAvailable: ${availableThemes.map(t => t.name).join(', ')}` + } setTheme(target.name)