diff --git a/apps/desktop/src/app/chat/composer/index.tsx b/apps/desktop/src/app/chat/composer/index.tsx index ec1dfbb999..d71ceaf458 100644 --- a/apps/desktop/src/app/chat/composer/index.tsx +++ b/apps/desktop/src/app/chat/composer/index.tsx @@ -41,7 +41,7 @@ import { UrlDialog } from './url-dialog' import { VoiceActivity, VoicePlaybackActivity } from './voice-activity' const COMPOSER_SHELL_CLASS = - 'group/composer absolute bottom-0 left-1/2 z-30 max-w-full -translate-x-1/2 pt-2 pb-[var(--composer-shell-pad-block-end)]' + 'group/composer absolute bottom-0 left-1/2 z-30 max-w-full pt-2 pb-[var(--composer-shell-pad-block-end)]' function extractClipboardImageBlobs(clipboard: DataTransfer): Blob[] { const blobs: Blob[] = [] diff --git a/apps/desktop/src/app/chat/index.tsx b/apps/desktop/src/app/chat/index.tsx index 1604c54107..cbfe72c775 100644 --- a/apps/desktop/src/app/chat/index.tsx +++ b/apps/desktop/src/app/chat/index.tsx @@ -99,6 +99,7 @@ function threadLoadingState( } export function ChatView({ + className, gateway, onToggleSelectedPin, onDeleteSelectedSession, @@ -254,7 +255,7 @@ export function ChatView({ return ( <> -
+
{title && ( 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 f96599aac6..9fbea533a0 100644 --- a/apps/desktop/src/app/chat/right-rail/preview-pane.tsx +++ b/apps/desktop/src/app/chat/right-rail/preview-pane.tsx @@ -37,6 +37,9 @@ const consoleLevelClass: Record = { 3: 'text-destructive' } +const CONSOLE_BOTTOM_THRESHOLD = 24 +const FILE_RELOAD_DEBOUNCE_MS = 200 + function compactUrl(value: string): string { try { const url = new URL(value) @@ -58,6 +61,14 @@ function formatLogLine(log: ConsoleEntry): string { return `${head} ${log.message}${tail}`.trim() } +function isNearConsoleBottom(element: HTMLDivElement | null): boolean { + if (!element) { + return true + } + + return element.scrollHeight - element.scrollTop - element.clientHeight <= CONSOLE_BOTTOM_THRESHOLD +} + interface ConsoleRowProps { log: ConsoleEntry onCopy: () => void | Promise @@ -136,10 +147,11 @@ async function writeClipboardText(text: string) { export function PreviewPane({ target }: { target: PreviewTarget }) { const consoleBodyRef = useRef(null) + const consoleShouldStickRef = useRef(true) const hostRef = useRef(null) const logIdRef = useRef(0) const webviewRef = useRef(null) - const [consoleOpen, setConsoleOpen] = useState(true) + const [consoleOpen, setConsoleOpen] = useState(false) const [currentUrl, setCurrentUrl] = useState(target.url) const [devtoolsOpen, setDevtoolsOpen] = useState(false) const [logs, setLogs] = useState([]) @@ -211,30 +223,55 @@ export function PreviewPane({ target }: { target: PreviewTarget }) { } useEffect(() => { - if (consoleOpen) { - consoleBodyRef.current?.scrollTo({ top: consoleBodyRef.current.scrollHeight }) + if (consoleOpen && consoleShouldStickRef.current) { + const consoleBody = consoleBodyRef.current + + consoleBody?.scrollTo({ top: consoleBody.scrollHeight }) } }, [consoleOpen, logs]) + useEffect(() => { + if (consoleOpen) { + consoleShouldStickRef.current = true + + const consoleBody = consoleBodyRef.current + + consoleBody?.scrollTo({ top: consoleBody.scrollHeight }) + } + }, [consoleOpen]) + useEffect(() => { if (target.kind !== 'file' || !window.hermesDesktop?.watchPreviewFile || !window.hermesDesktop?.onPreviewFileChanged) { return } let active = true + let pendingReloadCount = 0 + let pendingReloadUrl = '' + let reloadTimer: ReturnType | null = null let watchId = '' - const unsubscribe = window.hermesDesktop.onPreviewFileChanged(payload => { - if (!active || payload.id !== watchId) { + const flushReload = () => { + if (!active || pendingReloadCount === 0) { return } + const changedCount = pendingReloadCount + const changedUrl = pendingReloadUrl + + pendingReloadCount = 0 + pendingReloadUrl = '' + + consoleShouldStickRef.current = isNearConsoleBottom(consoleBodyRef.current) setLogs(prev => [ ...prev.slice(-199), { id: ++logIdRef.current, level: 1, - message: `File changed, reloading preview: ${compactUrl(payload.url)}` + message: + changedCount === 1 + ? `File changed, reloading preview: ${compactUrl(changedUrl)}` + : `${changedCount} file changes, reloading preview: ${compactUrl(changedUrl)}` } ]) @@ -243,6 +280,24 @@ export function PreviewPane({ target }: { target: PreviewTarget }) { } else { webviewRef.current?.reload?.() } + } + + const unsubscribe = window.hermesDesktop.onPreviewFileChanged(payload => { + if (!active || payload.id !== watchId) { + return + } + + pendingReloadCount += 1 + pendingReloadUrl = payload.url + + if (reloadTimer) { + clearTimeout(reloadTimer) + } + + reloadTimer = setTimeout(() => { + reloadTimer = null + flushReload() + }, FILE_RELOAD_DEBOUNCE_MS) }) void window.hermesDesktop @@ -271,6 +326,10 @@ export function PreviewPane({ target }: { target: PreviewTarget }) { active = false unsubscribe() + if (reloadTimer) { + clearTimeout(reloadTimer) + } + if (watchId) { void window.hermesDesktop?.stopPreviewFileWatch?.(watchId) } @@ -298,6 +357,7 @@ export function PreviewPane({ target }: { target: PreviewTarget }) { webview.setAttribute('webpreferences', 'contextIsolation=yes,nodeIntegration=no,sandbox=yes') const appendLog = (entry: Omit) => { + consoleShouldStickRef.current = isNearConsoleBottom(consoleBodyRef.current) setLogs(prev => [...prev.slice(-199), { ...entry, id: ++logIdRef.current }]) } diff --git a/apps/desktop/src/app/shell/app-shell.tsx b/apps/desktop/src/app/shell/app-shell.tsx index fa9e402b60..590ca5fcc3 100644 --- a/apps/desktop/src/app/shell/app-shell.tsx +++ b/apps/desktop/src/app/shell/app-shell.tsx @@ -46,14 +46,24 @@ export function AppShell({ const inspectorOpen = useStore($inspectorOpen) const previewTarget = useStore($previewTarget) - const displayedSidebarWidth = sidebarOpen ? sidebarWidth : Math.round(sidebarWidth * 0.8) + // The shell grid should describe visible app chrome only. Titlebar buttons + // and draggable hit-zones are fixed overlays, so keeping an invisible grid + // column for a closed sidebar pushes/clips the actual chat surface. + const displayedSidebarWidth = sidebarOpen ? sidebarWidth : 0 const titlebarControls = titlebarControlsPosition(connection?.windowButtonPosition) + const titlebarContentInset = titlebarControls.left + TITLEBAR_HEIGHT + Math.round(TITLEBAR_HEIGHT / 2) const showRightRail = rightRailOpen && (inspectorOpen || Boolean(previewTarget)) // Right rail yields to chat min-width before the chat column starts crushing the composer. const inspectorColumn = showRightRail ? 'min(var(--inspector-width), max(0px, calc(100vw - var(--sidebar-width) - var(--chat-min-width) - 2 * var(--shell-gap))))' : '0px' + // Always keep the shell as 3 columns because the sidebar and chat are + // always rendered as grid children. Collapsing to a single grid column + // makes the hidden sidebar occupy row 1 and pushes chat into row 2, which + // looks like a blank/white screen when closing the preview with sidebars + // hidden. Centering is handled by setting closed side columns to 0px. + const shellGridColumns = 'var(--sidebar-width) minmax(0,1fr) var(--inspector-col)' const startSidebarResize = useCallback( (event: ReactPointerEvent) => { @@ -95,7 +105,9 @@ export function AppShell({ style={ { '--sidebar-width': `${displayedSidebarWidth}px`, + '--chat-center-offset': '0px', '--titlebar-height': `${TITLEBAR_HEIGHT}px`, + '--titlebar-content-inset': `${titlebarContentInset}px`, '--titlebar-controls-left': `${titlebarControls.left}px`, '--titlebar-controls-top': `${titlebarControls.top}px` } as CSSProperties @@ -110,13 +122,14 @@ export function AppShell({
diff --git a/apps/desktop/src/app/shell/titlebar.ts b/apps/desktop/src/app/shell/titlebar.ts index 7e1a6c9a19..1224735818 100644 --- a/apps/desktop/src/app/shell/titlebar.ts +++ b/apps/desktop/src/app/shell/titlebar.ts @@ -16,7 +16,7 @@ export const titlebarButtonClass = 'h-[var(--titlebar-control-height)] w-[var(--titlebar-control-size)] rounded-md text-muted-foreground hover:bg-accent hover:text-foreground' export const titlebarHeaderBaseClass = - 'relative z-3 flex h-(--titlebar-height) shrink-0 items-center gap-3 bg-background/70 px-3 backdrop-blur-sm' + 'relative z-3 flex h-(--titlebar-height) shrink-0 items-center justify-center gap-3 bg-background/70 px-[max(0.75rem,var(--titlebar-content-inset,0px))] backdrop-blur-sm' export const titlebarHeaderShadowClass = "shadow-header after:pointer-events-none after:absolute after:left-0 after:right-0 after:top-full after:h-10 after:bg-linear-to-b after:from-background after:via-background/80 after:to-transparent after:content-['']" diff --git a/apps/desktop/src/components/assistant-ui/markdown-text.tsx b/apps/desktop/src/components/assistant-ui/markdown-text.tsx index d59162becd..57749c6def 100644 --- a/apps/desktop/src/components/assistant-ui/markdown-text.tsx +++ b/apps/desktop/src/components/assistant-ui/markdown-text.tsx @@ -31,12 +31,25 @@ const REASONING_BLOCK_RE = /<(think|thinking|reasoning|scratchpad|analysis)>[\s\ const PREVIEW_MARKER_RE = /\[Preview:[^\]]+\]\(#preview[:/][^)]+\)/gi const FENCE_LINE_RE = /^([ \t]*)(`{3,}|~{3,})([^\n]*)$/ -const MIDLINE_FENCE_RE = /([^\n])```+(?=\s|$)/g +const MIDLINE_FENCE_RE = /([^\n])(`{3,}|~{3,})(?=\s|$)/g +const EMPTY_FENCE_BLOCK_RE = /(^|\n)[ \t]*(?:`{3,}|~{3,})[^\n]*\n[ \t]*(?:`{3,}|~{3,})[ \t]*(?=\n|$)/g function stripMidlineFenceStarts(text: string): string { + // Providers often stream inline fence noise like `200.``` http://...`. + // A real fenced block must start at the beginning of a line; anything + // mid-line should be treated as literal/prose and never allowed to create + // an empty Streamdown code-card shell. return text.replace(MIDLINE_FENCE_RE, '$1') } +function stripEmptyFenceBlocks(text: string): string { + // Remove already-balanced but empty fences before Streamdown sees them. + // Returning null from our CodeHeader/SyntaxHighlighter is not enough: the + // code plugin still renders its outer code-block wrapper, producing the + // blank bordered element seen during streaming. + return text.replace(EMPTY_FENCE_BLOCK_RE, '$1') +} + function pushProseFence(out: string[], indent: string, info: string, lines: string[]) { if (info) { out.push(`${indent}${info}`.trimEnd()) @@ -96,6 +109,13 @@ function normalizeFenceBlocks(text: string): string { const bodyLines = sourceLines.slice(index + 1, closeIndex === -1 ? sourceLines.length : closeIndex) const body = bodyLines.join('\n') + if (closeIndex !== -1 && !body.trim()) { + // Empty fenced block: drop both delimiters. This prevents Streamdown's + // code plugin from rendering an empty shell/card. + index = closeIndex + 1 + continue + } + if (closeIndex === -1) { if (!body.trim()) { index += 1 @@ -130,8 +150,9 @@ function normalizeFenceBlocks(text: string): string { export function preprocessMarkdown(text: string): string { const cleaned = text.replace(REASONING_BLOCK_RE, '').replace(PREVIEW_MARKER_RE, '') const normalizedFences = normalizeFenceBlocks(stripMidlineFenceStarts(cleaned)) + const strippedEmptyFences = stripEmptyFenceBlocks(normalizedFences) - return normalizedFences + return strippedEmptyFences .split(/((?:```|~~~)[\s\S]*?(?:```|~~~))/g) .map(part => (/^(?:```|~~~)/.test(part) ? part : stripPreviewTargets(part))) .join('') @@ -140,21 +161,26 @@ export function preprocessMarkdown(text: string): string { function CodeHeader({ language, code }: { language?: string; code?: string }) { const [copied, setCopied] = useState(false) + const normalizedCode = (code ?? '').replace(/^\n+/, '').trimEnd() - if (isLikelyProseCodeBlock(language, code)) { + // Streamdown can transiently parse stray backticks / incomplete fences as + // an empty code block while text is streaming, e.g. "200.``` http://...". + // Rendering our header + empty body for that looks like a giant blank + // code card. Hide the whole block until there's actual code content. + if (!normalizedCode.trim() || isLikelyProseCodeBlock(language, normalizedCode)) { return null } async function handleCopy() { - if (!code) { + if (!normalizedCode) { return } try { if (window.hermesDesktop?.writeClipboard) { - await window.hermesDesktop.writeClipboard(code) + await window.hermesDesktop.writeClipboard(normalizedCode) } else if (navigator.clipboard?.writeText) { - await navigator.clipboard.writeText(code) + await navigator.clipboard.writeText(normalizedCode) } triggerHaptic('selection') diff --git a/apps/desktop/src/components/assistant-ui/preview-attachment.tsx b/apps/desktop/src/components/assistant-ui/preview-attachment.tsx index 3f2dda6ace..ba52a24ac2 100644 --- a/apps/desktop/src/components/assistant-ui/preview-attachment.tsx +++ b/apps/desktop/src/components/assistant-ui/preview-attachment.tsx @@ -1,6 +1,6 @@ import { useStore } from '@nanostores/react' import { MonitorPlay } from 'lucide-react' -import { useState } from 'react' +import { useEffect, useRef, useState } from 'react' import { previewName } from '@/lib/preview-targets' import { notifyError } from '@/store/notifications' @@ -11,22 +11,45 @@ export function PreviewAttachment({ target }: { target: string }) { const cwd = useStore($currentCwd) const activePreview = useStore($previewTarget) const [opening, setOpening] = useState(false) + const activePreviewRef = useRef(activePreview) + const cwdRef = useRef(cwd) + const mountedRef = useRef(false) + const requestTokenRef = useRef(0) + const targetRef = useRef(target) const name = previewName(target) const isActive = activePreview?.source === target - function localFallbackPreview() { - if (/^https?:\/\//i.test(target)) { - return { kind: 'url' as const, label: previewName(target), source: target, url: target } + activePreviewRef.current = activePreview + cwdRef.current = cwd + targetRef.current = target + + useEffect(() => { + mountedRef.current = true + + return () => { + mountedRef.current = false + requestTokenRef.current += 1 + } + }, []) + + useEffect(() => { + requestTokenRef.current += 1 + setOpening(false) + }, [cwd, target]) + + function localFallbackPreview(value: string) { + if (/^https?:\/\//i.test(value)) { + return { kind: 'url' as const, label: previewName(value), source: value, url: value } } - if (/^file:\/\//i.test(target)) { - return { kind: 'file' as const, label: previewName(target), source: target, url: target } + if (/^file:\/\//i.test(value)) { + return { kind: 'file' as const, label: previewName(value), source: value, url: value } } - if (/^(?:\/|\.{1,2}\/|~\/).+\.html?$/i.test(target)) { - const path = target.startsWith('file://') ? target : `file://${encodeURI(target)}` + if (/^(?:\/|\.{1,2}\/|~\/).+\.html?$/i.test(value)) { + const path = value.startsWith('file://') ? value : `file://${encodeURI(value)}` - return { kind: 'file' as const, label: previewName(target), source: target, url: path } + return { kind: 'file' as const, label: previewName(value), source: value, url: path } } return null @@ -49,26 +72,56 @@ export function PreviewAttachment({ target }: { target: string }) { return } + const requestToken = ++requestTokenRef.current + const requestTarget = target + const requestCwd = cwd + setOpening(true) try { - const preview = await window.hermesDesktop?.normalizePreviewTarget(target, cwd || undefined).catch(error => { + const preview = await window.hermesDesktop?.normalizePreviewTarget(requestTarget, requestCwd || undefined).catch(error => { if (isMissingPreviewIpc(error)) { - return localFallbackPreview() + return localFallbackPreview(requestTarget) } throw error }) + if ( + !mountedRef.current || + requestTokenRef.current !== requestToken || + targetRef.current !== requestTarget || + cwdRef.current !== requestCwd + ) { + return + } + if (!preview) { - throw new Error(`Could not open preview target: ${target}`) + throw new Error(`Could not open preview target: ${requestTarget}`) + } + + const currentPreview = activePreviewRef.current + + if (currentPreview?.source === preview.source && currentPreview.url === preview.url) { + return } setPreviewTarget(preview) } catch (error) { + if ( + !mountedRef.current || + requestTokenRef.current !== requestToken || + targetRef.current !== requestTarget || + cwdRef.current !== requestCwd + ) { + return + } + notifyError(error, 'Preview unavailable') } finally { - setOpening(false) + if (mountedRef.current && requestTokenRef.current === requestToken) { + setOpening(false) + } } } diff --git a/apps/desktop/src/components/assistant-ui/shiki-highlighter.tsx b/apps/desktop/src/components/assistant-ui/shiki-highlighter.tsx index a744b9be38..6dcc610170 100644 --- a/apps/desktop/src/components/assistant-ui/shiki-highlighter.tsx +++ b/apps/desktop/src/components/assistant-ui/shiki-highlighter.tsx @@ -28,6 +28,12 @@ export const SyntaxHighlighter: FC = ({ // fence padding without touching indentation on the first real line. const trimmed = (code ?? '').replace(/^\n+/, '').trimEnd() + // Avoid rendering an empty code card while Streamdown is still deciding + // whether a transient/incomplete fence is real markdown. + if (!trimmed.trim()) { + return null + } + if (isLikelyProseCodeBlock(language, trimmed)) { return
{trimmed}
} diff --git a/apps/desktop/src/components/assistant-ui/thread.tsx b/apps/desktop/src/components/assistant-ui/thread.tsx index 06bcfa8be5..9ba11e6f23 100644 --- a/apps/desktop/src/components/assistant-ui/thread.tsx +++ b/apps/desktop/src/components/assistant-ui/thread.tsx @@ -146,7 +146,7 @@ export const Thread: FC<{ > diff --git a/apps/desktop/src/store/preview.ts b/apps/desktop/src/store/preview.ts index 630fe14522..ca988a67bc 100644 --- a/apps/desktop/src/store/preview.ts +++ b/apps/desktop/src/store/preview.ts @@ -9,6 +9,22 @@ export interface PreviewTarget { export const $previewTarget = atom(null) +function isSamePreviewTarget(a: PreviewTarget | null, b: PreviewTarget | null): boolean { + if (a === b) { + return true + } + + if (!a || !b) { + return false + } + + return a.kind === b.kind && a.label === b.label && a.source === b.source && a.url === b.url +} + export function setPreviewTarget(target: PreviewTarget | null) { + if (isSamePreviewTarget($previewTarget.get(), target)) { + return + } + $previewTarget.set(target) } diff --git a/apps/desktop/src/styles.css b/apps/desktop/src/styles.css index 7d0c076b59..0e4ef9b9c0 100644 --- a/apps/desktop/src/styles.css +++ b/apps/desktop/src/styles.css @@ -311,6 +311,7 @@ canvas { [data-slot='composer-root'] { width: clamp(var(--composer-min-width), var(--composer-target-width), var(--composer-max-width)); max-width: calc(100% - var(--composer-inline-clearance)); + transform: translateX(-50%); } /* Thread scroll container (from use-stick-to-bottom).