diff --git a/apps/dashboard/src/components/ChatSidebar.tsx b/apps/dashboard/src/components/ChatSidebar.tsx index 38f1cf80abd..87af497237a 100644 --- a/apps/dashboard/src/components/ChatSidebar.tsx +++ b/apps/dashboard/src/components/ChatSidebar.tsx @@ -323,7 +323,7 @@ export function ChatSidebar({ channel, className }: ChatSidebarProps) { ) : undefined } - className="self-start min-w-0 px-0 py-0 normal-case tracking-normal text-sm font-medium hover:underline disabled:no-underline" + className="self-start min-w-0 px-0 py-0 normal-case tracking-normal text-sm font-medium underline-offset-4 decoration-current/40 hover:underline disabled:no-underline" title={info.model ?? "switch model"} > {modelLabel} diff --git a/apps/dashboard/src/components/Markdown.tsx b/apps/dashboard/src/components/Markdown.tsx index bef0804e7c4..c80ea08d11e 100644 --- a/apps/dashboard/src/components/Markdown.tsx +++ b/apps/dashboard/src/components/Markdown.tsx @@ -331,7 +331,7 @@ function InlineContent({ href={node.href} target="_blank" rel="noreferrer" - className="text-primary underline underline-offset-2 decoration-primary/30 hover:decoration-primary/60 transition-colors" + className="text-primary underline underline-offset-4 decoration-current/40 transition-colors" > {node.text} diff --git a/apps/dashboard/src/pages/AnalyticsPage.tsx b/apps/dashboard/src/pages/AnalyticsPage.tsx index 4896e760636..e46a1806e6d 100644 --- a/apps/dashboard/src/pages/AnalyticsPage.tsx +++ b/apps/dashboard/src/pages/AnalyticsPage.tsx @@ -510,7 +510,7 @@ export default function AnalyticsPage() { dashboard.show_token_analytics: true {" "} - in Config. + in Config.

diff --git a/apps/dashboard/src/pages/EnvPage.tsx b/apps/dashboard/src/pages/EnvPage.tsx index 1c457da0583..a06df1a5e15 100644 --- a/apps/dashboard/src/pages/EnvPage.tsx +++ b/apps/dashboard/src/pages/EnvPage.tsx @@ -148,7 +148,7 @@ function EnvVarRow({ href={info.url} target="_blank" rel="noreferrer" - className="inline-flex items-center gap-1 text-[0.65rem] text-primary hover:underline" + className="inline-flex items-center gap-1 text-[0.65rem] text-primary underline-offset-4 decoration-current/40 hover:underline" > {t.env.getKey} @@ -184,7 +184,7 @@ function EnvVarRow({ href={info.url} target="_blank" rel="noreferrer" - className="inline-flex items-center gap-1 text-[0.65rem] text-primary hover:underline" + className="inline-flex items-center gap-1 text-[0.65rem] text-primary underline-offset-4 decoration-current/40 hover:underline" > {t.env.getKey} @@ -217,7 +217,7 @@ function EnvVarRow({ href={info.url} target="_blank" rel="noreferrer" - className="inline-flex items-center gap-1 text-[0.65rem] text-primary hover:underline" + className="inline-flex items-center gap-1 text-[0.65rem] text-primary underline-offset-4 decoration-current/40 hover:underline" > {t.env.getKey} @@ -407,7 +407,7 @@ function ProviderGroupCard({ href={keyUrl} target="_blank" rel="noreferrer" - className="inline-flex items-center gap-1 text-[0.65rem] text-primary hover:underline" + className="inline-flex items-center gap-1 text-[0.65rem] text-primary underline-offset-4 decoration-current/40 hover:underline" onClick={(e) => e.stopPropagation()} > {t.env.getKey} diff --git a/apps/dashboard/src/pages/ModelsPage.tsx b/apps/dashboard/src/pages/ModelsPage.tsx index f09104d4241..98c5be1a617 100644 --- a/apps/dashboard/src/pages/ModelsPage.tsx +++ b/apps/dashboard/src/pages/ModelsPage.tsx @@ -927,7 +927,7 @@ export default function ModelsPage() { …) and provider retries, so they diverge from your provider bill. Enable{" "} dashboard.show_token_analytics{" "} - in Config to + in Config to show the local debug estimate anyway.

)} diff --git a/apps/dashboard/src/pages/PluginsPage.tsx b/apps/dashboard/src/pages/PluginsPage.tsx index 290e5e04f0f..6696f3580c1 100644 --- a/apps/dashboard/src/pages/PluginsPage.tsx +++ b/apps/dashboard/src/pages/PluginsPage.tsx @@ -346,7 +346,7 @@ export default function PluginsPage() { {!m.tab?.hidden ? ( - + diff --git a/apps/desktop/electron/main.cjs b/apps/desktop/electron/main.cjs index 9cd1b07068e..0d1c77d514c 100644 --- a/apps/desktop/electron/main.cjs +++ b/apps/desktop/electron/main.cjs @@ -31,6 +31,14 @@ const { resolveTimeoutMs } = require('./hardening.cjs') +let nodePty = null + +try { + nodePty = require('@homebridge/node-pty-prebuilt-multiarch') +} catch { + nodePty = null +} + const USER_DATA_OVERRIDE = process.env.HERMES_DESKTOP_USER_DATA_DIR if (USER_DATA_OVERRIDE) { const resolvedUserData = path.resolve(USER_DATA_OVERRIDE) @@ -133,6 +141,7 @@ const APP_ICON_PATHS = [ ] let rendererTitleBarTheme = null +const terminalSessions = new Map() function isHexColor(value) { return typeof value === 'string' && /^#[0-9a-f]{6}$/i.test(value) @@ -608,11 +617,10 @@ function findSystemPython() { if (pyExe) { for (const version of SUPPORTED_VERSIONS) { try { - const out = execFileSync( - pyExe, - [`-${version}`, '-c', 'import sys; print(sys.executable)'], - { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] } - ) + const out = execFileSync(pyExe, [`-${version}`, '-c', 'import sys; print(sys.executable)'], { + encoding: 'utf8', + stdio: ['ignore', 'pipe', 'ignore'] + }) const candidate = out.trim() if (candidate && fileExists(candidate)) return candidate } catch { @@ -2792,7 +2800,21 @@ ipcMain.handle('hermes:fetchLinkTitle', (_event, url) => fetchLinkTitle(url)) // Always-hidden noise (covers non-git projects too — gitignore would catch // these anyway when present, but we want the same hygiene without one). -const FS_READDIR_HIDDEN = new Set(['.git', '.hg', '.svn', 'node_modules', '__pycache__', '.next', '.venv', 'venv']) +const FS_READDIR_HIDDEN = new Set([ + '.git', + '.hg', + '.svn', + '.cache', + '.next', + '.turbo', + '.venv', + '__pycache__', + 'build', + 'dist', + 'node_modules', + 'target', + 'venv' +]) function findGitRoot(start) { let dir = start @@ -2818,6 +2840,76 @@ function findGitRoot(start) { return null } +function terminalShellCommand() { + if (IS_WINDOWS) { + return { args: [], command: process.env.COMSPEC || 'cmd.exe' } + } + + const configuredShell = process.env.SHELL || '' + const shellPath = + (path.isAbsolute(configuredShell) && fs.existsSync(configuredShell) && configuredShell) || + ['/bin/zsh', '/bin/bash', '/bin/sh'].find(candidate => fs.existsSync(candidate)) || + '/bin/sh' + const shellName = path.basename(shellPath) + const interactiveArgs = shellName.includes('zsh') || shellName.includes('bash') ? ['-il'] : ['-i'] + + return { args: interactiveArgs, command: shellPath, name: shellName } +} + +function safeTerminalCwd(cwd) { + const candidate = path.resolve(String(cwd || app.getPath('home'))) + + try { + const stat = fs.statSync(candidate) + + return stat.isDirectory() ? candidate : path.dirname(candidate) + } catch { + return app.getPath('home') + } +} + +function terminalShellEnv() { + const env = { ...process.env } + + // Electron is commonly launched through `npm run dev`; do not leak npm's + // managed prefix into a user's interactive shell (nvm/proto warn loudly). + for (const key of Object.keys(env)) { + if (key === 'npm_config_prefix' || key.startsWith('npm_config_') || key.startsWith('npm_package_')) { + delete env[key] + } + } + + env.COLORTERM = env.COLORTERM || 'truecolor' + env.LC_CTYPE = env.LC_CTYPE || 'UTF-8' + env.TERM = 'xterm-256color' + env.TERM_PROGRAM = 'Hermes' + env.TERM_PROGRAM_VERSION = app.getVersion() + + return env +} + +function terminalChannel(id, suffix) { + return `hermes:terminal:${id}:${suffix}` +} + +function disposeTerminalSession(id) { + const sessionInfo = terminalSessions.get(id) + + if (!sessionInfo) { + return false + } + + terminalSessions.delete(id) + + try { + sessionInfo.pty.kill() + } catch { + // Process may already be gone. + } + + return true +} + ipcMain.handle('hermes:fs:readDir', async (_event, dirPath) => { const resolved = path.resolve(String(dirPath || '')) @@ -2859,6 +2951,72 @@ ipcMain.handle('hermes:fs:gitRoot', async (_event, startPath) => { } }) +ipcMain.handle('hermes:terminal:start', async (event, payload = {}) => { + if (!nodePty) { + throw new Error('PTY support is unavailable. Reinstall desktop dependencies and restart Hermes.') + } + + const id = crypto.randomUUID() + const { args, command, name } = terminalShellCommand() + const cwd = safeTerminalCwd(payload?.cwd) + const cols = Math.max(2, Number.parseInt(String(payload?.cols || 80), 10) || 80) + const rows = Math.max(2, Number.parseInt(String(payload?.rows || 24), 10) || 24) + const ptyProcess = nodePty.spawn(command, args, { + cols, + cwd, + env: terminalShellEnv(), + name: 'xterm-256color', + rows + }) + + terminalSessions.set(id, { pty: ptyProcess, webContentsId: event.sender.id }) + + const send = (suffix, payload) => { + if (event.sender.isDestroyed()) { + return + } + + event.sender.send(terminalChannel(id, suffix), payload) + } + + ptyProcess.onData(data => send('data', data)) + ptyProcess.onExit(({ exitCode, signal }) => { + terminalSessions.delete(id) + send('exit', { code: exitCode, signal: signal || null }) + }) + event.sender.once('destroyed', () => disposeTerminalSession(id)) + + return { cwd, id, shell: name } +}) + +ipcMain.handle('hermes:terminal:write', (_event, id, data) => { + const sessionInfo = terminalSessions.get(String(id || '')) + + if (!sessionInfo) { + return false + } + + sessionInfo.pty.write(String(data || '')) + + return true +}) + +ipcMain.handle('hermes:terminal:resize', (_event, id, size = {}) => { + const sessionInfo = terminalSessions.get(String(id || '')) + + if (!sessionInfo) { + return false + } + + const cols = Math.max(2, Number.parseInt(String(size?.cols || 80), 10) || 80) + const rows = Math.max(2, Number.parseInt(String(size?.rows || 24), 10) || 24) + + sessionInfo.pty.resize(cols, rows) + + return true +}) +ipcMain.handle('hermes:terminal:dispose', (_event, id) => disposeTerminalSession(String(id || ''))) + ipcMain.handle('hermes:updates:check', async () => checkUpdates().catch(error => ({ supported: true, diff --git a/apps/desktop/electron/preload.cjs b/apps/desktop/electron/preload.cjs index de3dbeb8f93..2cc04f5a62e 100644 --- a/apps/desktop/electron/preload.cjs +++ b/apps/desktop/electron/preload.cjs @@ -33,6 +33,24 @@ contextBridge.exposeInMainWorld('hermesDesktop', { fetchLinkTitle: url => ipcRenderer.invoke('hermes:fetchLinkTitle', url), readDir: dirPath => ipcRenderer.invoke('hermes:fs:readDir', dirPath), gitRoot: startPath => ipcRenderer.invoke('hermes:fs:gitRoot', startPath), + terminal: { + dispose: id => ipcRenderer.invoke('hermes:terminal:dispose', id), + resize: (id, size) => ipcRenderer.invoke('hermes:terminal:resize', id, size), + start: options => ipcRenderer.invoke('hermes:terminal:start', options), + write: (id, data) => ipcRenderer.invoke('hermes:terminal:write', id, data), + onData: (id, callback) => { + const channel = `hermes:terminal:${id}:data` + const listener = (_event, payload) => callback(payload) + ipcRenderer.on(channel, listener) + return () => ipcRenderer.removeListener(channel, listener) + }, + onExit: (id, callback) => { + const channel = `hermes:terminal:${id}:exit` + const listener = (_event, payload) => callback(payload) + ipcRenderer.on(channel, listener) + return () => ipcRenderer.removeListener(channel, listener) + } + }, onClosePreviewRequested: callback => { const listener = () => callback() ipcRenderer.on('hermes:close-preview-requested', listener) diff --git a/apps/desktop/package.json b/apps/desktop/package.json index a70afbc7979..634356f55f0 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -45,7 +45,11 @@ "@assistant-ui/react-streamdown": "^0.1.11", "@audiowave/react": "^0.6.2", "@chenglou/pretext": "^0.0.6", + "@dnd-kit/core": "^6.3.1", + "@dnd-kit/sortable": "^10.0.0", + "@dnd-kit/utilities": "^3.2.2", "@hermes/shared": "file:../shared", + "@homebridge/node-pty-prebuilt-multiarch": "^0.13.1", "@nanostores/react": "^1.1.0", "@nous-research/ui": "^0.13.0", "@radix-ui/react-slot": "^1.2.4", @@ -54,6 +58,12 @@ "@tailwindcss/typography": "^0.5.19", "@tailwindcss/vite": "^4.2.4", "@tanstack/react-query": "^5.100.6", + "@tanstack/react-virtual": "^3.13.24", + "@vscode/codicons": "^0.0.45", + "@xterm/addon-fit": "^0.11.0", + "@xterm/addon-unicode11": "^0.9.0", + "@xterm/addon-web-links": "^0.12.0", + "@xterm/xterm": "^6.0.0", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "cmdk": "^1.1.1", @@ -62,7 +72,6 @@ "ignore": "^7.0.5", "katex": "^0.16.45", "leva": "^0.10.1", - "lucide-react": "^0.577.0", "motion": "^12.38.0", "nanostores": "^1.3.0", "radix-ui": "^1.4.3", @@ -80,7 +89,6 @@ "unicode-animations": "^1.0.3", "unified": "^11.0.5", "unist-util-visit-parents": "^6.0.2", - "use-stick-to-bottom": "^1.1.4", "vfile": "^6.0.3", "web-haptics": "^0.0.6" }, diff --git a/apps/desktop/src/app/agents/index.tsx b/apps/desktop/src/app/agents/index.tsx index cc5afa17f42..4bcf76e46e6 100644 --- a/apps/desktop/src/app/agents/index.tsx +++ b/apps/desktop/src/app/agents/index.tsx @@ -36,12 +36,7 @@ function statusGlyph(status: SubagentStatus): ReactNode { return } - return ( - - ) + return } const STREAM_TONE: Record = { @@ -65,7 +60,11 @@ function streamGlyph(entry: SubagentStreamEntry): ReactNode { } if (entry.kind === 'thinking') { - return + return ( + + … + + ) } return @@ -103,8 +102,13 @@ export function AgentsView({ onClose }: AgentsViewProps) { } const fmtDuration = (seconds?: number) => { - if (!seconds || seconds <= 0) return '' - if (seconds < 60) return `${seconds.toFixed(1)}s` + if (!seconds || seconds <= 0) { + return '' + } + + if (seconds < 60) { + return `${seconds.toFixed(1)}s` + } const m = Math.floor(seconds / 60) const s = Math.round(seconds % 60) @@ -113,18 +117,29 @@ const fmtDuration = (seconds?: number) => { } const fmtTokens = (value?: number) => { - if (!value) return '' + if (!value) { + return '' + } return value >= 1000 ? `${(value / 1000).toFixed(1)}k tok` : `${value} tok` } const fmtAge = (updatedAt: number, nowMs: number) => { const s = Math.max(0, Math.round((nowMs - updatedAt) / 1000)) - if (s < 2) return 'now' - if (s < 60) return `${s}s ago` + + if (s < 2) { + return 'now' + } + + if (s < 60) { + return `${s}s ago` + } const m = Math.floor(s / 60) - if (m < 60) return `${m}m ago` + + if (m < 60) { + return `${m}m ago` + } return `${Math.floor(m / 60)}h ago` } @@ -152,12 +167,14 @@ function groupDelegations(roots: readonly SubagentNode[]): RootGroup[] { if (prev && sameShape && closeInTime && uniqueStep) { prev.nodes.push(node) + continue } if (node.taskCount > 1) { n += 1 groups.push({ id: `delegation-${n}`, label: `Delegation ${n}`, nodes: [node], taskCount: node.taskCount }) + continue } @@ -180,7 +197,9 @@ function SubagentTree({ tree }: { tree: SubagentNode[] }) { const cost = flat.reduce((sum, n) => sum + (n.costUsd ?? 0), 0) useEffect(() => { - if (active <= 0 || typeof window === 'undefined') return + if (active <= 0 || typeof window === 'undefined') { + return + } const id = window.setInterval(() => setNowMs(Date.now()), 500) @@ -261,10 +280,7 @@ function StreamLine({ const tone = entry.isError ? 'text-destructive' : STREAM_TONE[entry.kind] return ( -
+
{streamGlyph(entry)} {entry.text} @@ -283,13 +299,17 @@ function StreamLine({ function SubagentRow({ node, depth = 0, nowMs }: { node: SubagentNode; depth?: number; nowMs: number }) { const running = node.status === 'running' || node.status === 'queued' const elapsed = useElapsedSeconds(running, `subagent:${node.id}`) + const durationSeconds = typeof node.durationSeconds === 'number' ? Math.max(0, Math.round(node.durationSeconds)) : elapsed + const [open, setOpen] = useState(() => running || depth < 2) const enterRef = useEnterAnimation(true, `subagent-row:${node.id}`) useEffect(() => { - if (running) setOpen(true) + if (running) { + setOpen(true) + } }, [running]) const visibleRows = open ? node.stream.slice(-10) : node.stream.slice(-2) @@ -304,11 +324,7 @@ function SubagentRow({ node, depth = 0, nowMs }: { node: SubagentNode; depth?: n ].filter(Boolean) return ( -
0 && 'pl-4')} - data-slot="tool-block" - ref={enterRef} - > +
0 && 'pl-4')} data-slot="tool-block" ref={enterRef}> - )} -
+ + setKindFilter('all')}> + All ({counts.all}) + + setKindFilter('image')}> + Images ({counts.image}) + + setKindFilter('file')}> + Files ({counts.file}) + + setKindFilter('link')}> + Links ({counts.link}) + + + } + onSearchChange={setQuery} + searchPlaceholder="Search artifacts..." + searchTrailingAction={ + + } + searchValue={query} + > + {!artifacts ? ( + + ) : visibleArtifacts.length === 0 ? ( +
+
+
No artifacts found
+
+ Generated images and file outputs will appear here as sessions produce them.
- - {!artifacts ? ( - - ) : visibleArtifacts.length === 0 ? ( -
-
-
No artifacts found
-
- Generated images and file outputs will appear here as sessions produce them. -
-
-
- ) : ( -
-
- {visibleImageArtifacts.length > 0 && ( -
-
- +
+ {visibleImageArtifacts.length > 0 && ( +
+
+ +
+
+ {pagedImageArtifacts.map(artifact => ( + navigate(sessionRoute(sessionId))} /> -
-
- {pagedImageArtifacts.map(artifact => ( - navigate(sessionRoute(sessionId))} - /> - ))} -
-
- )} + ))} +
+
+ )} - {visibleFileArtifacts.length > 0 && ( -
-
- -
-
- -
-
- )} -
+ {visibleFileArtifacts.length > 0 && ( +
+
+ +
+
+ +
+
+ )}
- )} -
- +
+ )} + ) } @@ -698,34 +651,6 @@ function ArtifactsPagination({ className, itemLabel, onPageChange, page, pageSiz ) } -function FilterButton({ - active, - icon: Icon, - label, - onClick -}: { - active: boolean - icon: typeof Layers3 - label: string - onClick: () => void -}) { - return ( - - ) -} - interface ArtifactImageCardProps { artifact: ArtifactRecord failedImage: boolean @@ -737,13 +662,12 @@ function ArtifactImageCard({ artifact, failedImage, onImageError, onOpenChat }: return (
@@ -763,15 +687,17 @@ function ArtifactImageCard({ artifact, failedImage, onImageError, onOpenChat }:
-
+
{artifact.kind}
-
{artifact.label}
-
{artifact.value}
+
+ {artifact.label} +
+
{artifact.value}
-
+
{artifact.sessionTitle} · {formatArtifactTime(artifact.timestamp)}
@@ -803,7 +729,7 @@ function ArtifactCellAction({ if (href) { return ( void ctx.onOpen(artifact.href)} title={label} > - + @@ -859,7 +785,10 @@ function LocationCell({ artifact }: { artifact: ArtifactRecord; ctx: CellCtx }) return (
{value} @@ -882,7 +811,7 @@ function SessionCell({ artifact, ctx }: { artifact: ArtifactRecord; ctx: CellCtx ctx.onOpenChat(artifact.sessionId)} title={artifact.sessionTitle}> {artifact.sessionTitle} - + {formatArtifactTime(artifact.timestamp)} @@ -924,8 +853,8 @@ function ArtifactTable({ filter: ArtifactFilter }) { return ( - - +
+ {ARTIFACT_COLUMNS.map(col => ( - + {artifacts.map(artifact => ( - + {ARTIFACT_COLUMNS.map(col => { const Cell = col.Cell diff --git a/apps/desktop/src/app/chat/composer/attachments.tsx b/apps/desktop/src/app/chat/composer/attachments.tsx index 71525215061..8d515fa9548 100644 --- a/apps/desktop/src/app/chat/composer/attachments.tsx +++ b/apps/desktop/src/app/chat/composer/attachments.tsx @@ -1,6 +1,7 @@ import { useStore } from '@nanostores/react' -import { FileText, FolderOpen, ImageIcon, Link, X } from '@/lib/icons' +import { Codicon } from '@/components/ui/codicon' +import { FileText, FolderOpen, ImageIcon, Link, Terminal } from '@/lib/icons' import { normalizeOrLocalPreviewTarget } from '@/lib/local-preview' import type { ComposerAttachment } from '@/store/composer' import { notifyError } from '@/store/notifications' @@ -16,17 +17,17 @@ export function AttachmentList({ }) { return (
- {attachments.map(a => ( - + {attachments.map(attachment => ( + ))}
) } function AttachmentPill({ attachment, onRemove }: { attachment: ComposerAttachment; onRemove?: (id: string) => void }) { - const Icon = { folder: FolderOpen, url: Link, image: ImageIcon, file: FileText }[attachment.kind] + const Icon = { folder: FolderOpen, url: Link, image: ImageIcon, file: FileText, terminal: Terminal }[attachment.kind] const cwd = useStore($currentCwd) - const canPreview = attachment.kind !== 'folder' + const canPreview = attachment.kind !== 'folder' && attachment.kind !== 'terminal' const detail = attachment.detail && attachment.detail !== attachment.label ? attachment.detail : undefined async function openPreview() { @@ -101,7 +102,7 @@ function AttachmentPill({ attachment, onRemove }: { attachment: ComposerAttachme onClick={() => onRemove(attachment.id)} type="button" > - + )} diff --git a/apps/desktop/src/app/chat/composer/completion-drawer.tsx b/apps/desktop/src/app/chat/composer/completion-drawer.tsx index 34470fe63e6..8b23c54f879 100644 --- a/apps/desktop/src/app/chat/composer/completion-drawer.tsx +++ b/apps/desktop/src/app/chat/composer/completion-drawer.tsx @@ -3,23 +3,30 @@ import { ComposerPrimitive } from '@assistant-ui/react' import type { ReactNode } from 'react' export const COMPLETION_DRAWER_CLASS = [ - 'absolute inset-x-0 bottom-[calc(100%-0.5rem)] z-50', + 'absolute bottom-[calc(100%+0.25rem)] left-0 z-50', + 'w-60 max-w-[calc(100vw-2rem)]', 'max-h-[min(23rem,calc(100vh-8rem))] overflow-y-auto overscroll-contain', - 'border border-b-0', - 'border-[color-mix(in_srgb,var(--dt-ring)_45%,transparent)]', - 'bg-[color-mix(in_srgb,var(--dt-popover)_96%,transparent)]', - 'px-1.5 pb-3 pt-1.5 text-popover-foreground', - 'backdrop-blur-[0.75rem] backdrop-saturate-[1.1]', - '[-webkit-backdrop-filter:blur(0.75rem)_saturate(1.1)]', - 'data-[state=open]:-mb-2', - 'data-[state=open]:shadow-[0_-0.0625rem_0_0.0625rem_color-mix(in_srgb,var(--dt-ring)_35%,transparent),0_-1rem_2.25rem_-1.75rem_color-mix(in_srgb,var(--dt-foreground)_34%,transparent),0_-0.3125rem_0.875rem_-0.6875rem_color-mix(in_srgb,var(--dt-foreground)_22%,transparent)]' + 'rounded-lg border border-(--ui-stroke-secondary)', + 'bg-[color-mix(in_srgb,var(--ui-bg-elevated)_96%,transparent)]', + 'p-1 text-xs text-popover-foreground shadow-md', + 'backdrop-blur-md' +].join(' ') + +export const COMPLETION_DRAWER_BELOW_CLASS = [ + 'absolute left-0 top-[calc(100%+0.25rem)] z-50', + 'w-60 max-w-[calc(100vw-2rem)]', + 'max-h-[min(23rem,calc(100vh-8rem))] overflow-y-auto overscroll-contain', + 'rounded-lg border border-(--ui-stroke-secondary)', + 'bg-[color-mix(in_srgb,var(--ui-bg-elevated)_96%,transparent)]', + 'p-1 text-xs text-popover-foreground shadow-md', + 'backdrop-blur-md' ].join(' ') export const COMPLETION_DRAWER_ROW_CLASS = [ - 'flex w-full min-w-0 items-baseline gap-2 rounded-md px-2.5 py-1', - 'text-left text-xs transition-colors', - 'hover:bg-[color-mix(in_srgb,var(--dt-accent)_70%,transparent)]', - 'data-[highlighted]:bg-[color-mix(in_srgb,var(--dt-accent)_70%,transparent)]' + 'relative flex cursor-default select-none items-center gap-2 rounded-md px-2 py-1', + 'w-full min-w-0 text-left text-xs outline-hidden transition-colors', + 'hover:bg-(--ui-bg-tertiary)', + 'data-[highlighted]:bg-(--ui-bg-tertiary) data-[highlighted]:text-foreground' ].join(' ') export function ComposerCompletionDrawer({ @@ -48,9 +55,9 @@ export function ComposerCompletionDrawer({ export function CompletionDrawerEmpty({ children, title }: { children?: ReactNode; title: string }) { return ( -
+

{title}

- {children &&

{children}

} + {children &&

{children}

}
) } diff --git a/apps/desktop/src/app/chat/composer/context-menu.tsx b/apps/desktop/src/app/chat/composer/context-menu.tsx index 4743d08b0f5..74de7b3b70a 100644 --- a/apps/desktop/src/app/chat/composer/context-menu.tsx +++ b/apps/desktop/src/app/chat/composer/context-menu.tsx @@ -1,4 +1,5 @@ import { Button } from '@/components/ui/button' +import { Codicon } from '@/components/ui/codicon' import { DropdownMenu, DropdownMenuContent, @@ -10,7 +11,7 @@ import { DropdownMenuSubTrigger, DropdownMenuTrigger } from '@/components/ui/dropdown-menu' -import { Clipboard, FileText, FolderOpen, ImageIcon, Link, type LucideIcon, MessageSquareText, Plus } from '@/lib/icons' +import { Clipboard, FileText, FolderOpen, type IconComponent, ImageIcon, Link, MessageSquareText } from '@/lib/icons' import { cn } from '@/lib/utils' import { GHOST_ICON_BTN } from './controls' @@ -38,14 +39,17 @@ export function ContextMenu({ @@ -107,7 +111,7 @@ export function ContextMenuItem({ }: { children: string disabled?: boolean - icon: LucideIcon + icon: IconComponent onSelect?: () => void }) { return ( diff --git a/apps/desktop/src/app/chat/composer/controls.tsx b/apps/desktop/src/app/chat/composer/controls.tsx index 7fa9255a9ea..bd4b140b471 100644 --- a/apps/desktop/src/app/chat/composer/controls.tsx +++ b/apps/desktop/src/app/chat/composer/controls.tsx @@ -1,13 +1,17 @@ import { Button } from '@/components/ui/button' +import { Codicon } from '@/components/ui/codicon' import { triggerHaptic } from '@/lib/haptics' -import { ArrowUp, AudioLines, Layers3, Loader2, Mic, MicOff, Square } from '@/lib/icons' +import { AudioLines, Layers3, Loader2, Square } from '@/lib/icons' import { cn } from '@/lib/utils' import type { ConversationStatus } from './hooks/use-voice-conversation' import type { ChatBarState, VoiceStatus } from './types' -export const ICON_BTN = 'size-(--composer-control-size) shrink-0 rounded-full' -export const GHOST_ICON_BTN = cn(ICON_BTN, 'text-muted-foreground hover:bg-accent hover:text-foreground') +export const ICON_BTN = 'size-(--composer-control-size) shrink-0 rounded-md' +export const GHOST_ICON_BTN = cn( + ICON_BTN, + 'text-(--ui-text-tertiary) hover:bg-(--chrome-action-hover) hover:text-foreground' +) // Send/voice-conversation primary: solid foreground-on-background circle // (reads as black-on-white in light mode, white-on-black in dark mode) to // match the reference composer's high-contrast CTA. Keeps the pill itself @@ -89,7 +93,7 @@ export function ComposerControls({ ) ) : ( - + )} )} @@ -136,7 +140,7 @@ function ConversationPill({ type="button" variant="ghost" > - {muted ? : } + {listening && ( ) diff --git a/apps/desktop/src/app/chat/composer/drop-affordance.ts b/apps/desktop/src/app/chat/composer/drop-affordance.ts new file mode 100644 index 00000000000..3426ec282b1 --- /dev/null +++ b/apps/desktop/src/app/chat/composer/drop-affordance.ts @@ -0,0 +1,2 @@ +export const COMPOSER_DROP_FADE_CLASS = 'transition-opacity duration-150 ease-out' +export const COMPOSER_DROP_ACTIVE_CLASS = 'opacity-60' diff --git a/apps/desktop/src/app/chat/composer/focus.ts b/apps/desktop/src/app/chat/composer/focus.ts index c728577465e..bf9e72b4b63 100644 --- a/apps/desktop/src/app/chat/composer/focus.ts +++ b/apps/desktop/src/app/chat/composer/focus.ts @@ -1,49 +1,103 @@ -export type ComposerFocusTarget = 'main' | 'edit' +/** + * Composer focus + external-insert bus. + * + * Mutations from outside the composer (sidebar attach, drag drop, terminal + * Cmd+L, preview console, etc.) dispatch through here. Each composer subscribes + * and routes the work back into its own ref/state. + * + * `dispatch` defers to a macrotask so synchronous click/keydown handlers + * (react-arborist row focus, picker `node.select()`) finish first and don't + * steal focus from the composer effect. + */ -interface ComposerFocusRequestDetail { - target: ComposerFocusTarget +export type ComposerTarget = 'edit' | 'main' +export type ComposerInsertMode = 'block' | 'inline' + +interface FocusDetail { + target: ComposerTarget } -const COMPOSER_FOCUS_REQUEST_EVENT = 'hermes:composer-focus-request' - -let activeComposerTarget: ComposerFocusTarget = 'main' - -function resolveTarget(target: ComposerFocusTarget | 'active'): ComposerFocusTarget { - return target === 'active' ? activeComposerTarget : target +interface InsertDetail { + mode: ComposerInsertMode + target: ComposerTarget + text: string } -export function markActiveComposer(target: ComposerFocusTarget) { - activeComposerTarget = target -} +const FOCUS_EVENT = 'hermes:composer-focus' +const INSERT_EVENT = 'hermes:composer-insert' -export function requestComposerFocus(target: ComposerFocusTarget | 'active' = 'active') { +let activeTarget: ComposerTarget = 'main' + +const resolve = (target: ComposerTarget | 'active') => (target === 'active' ? activeTarget : target) + +const dispatch = (name: string, detail: T) => { if (typeof window === 'undefined') { return } - const resolvedTarget = resolveTarget(target) - - const event = new CustomEvent(COMPOSER_FOCUS_REQUEST_EVENT, { - detail: { target: resolvedTarget } - }) - - window.dispatchEvent(event) + window.setTimeout(() => window.dispatchEvent(new CustomEvent(name, { detail })), 0) } -export function onComposerFocusRequest(handler: (target: ComposerFocusTarget) => void) { +const subscribe = (name: string, handler: (detail: T) => void) => { if (typeof window === 'undefined') { return () => undefined } const listener = (event: Event) => { - const detail = (event as CustomEvent).detail + const detail = (event as CustomEvent).detail - if (detail?.target === 'main' || detail?.target === 'edit') { - handler(detail.target) + if (detail) { + handler(detail) } } - window.addEventListener(COMPOSER_FOCUS_REQUEST_EVENT, listener) + window.addEventListener(name, listener) - return () => window.removeEventListener(COMPOSER_FOCUS_REQUEST_EVENT, listener) + return () => window.removeEventListener(name, listener) +} + +export const markActiveComposer = (target: ComposerTarget) => { + activeTarget = target +} + +export const requestComposerFocus = (target: ComposerTarget | 'active' = 'active') => + dispatch(FOCUS_EVENT, { target: resolve(target) }) + +export const requestComposerInsert = ( + text: string, + { mode = 'block', target = 'active' }: { mode?: ComposerInsertMode; target?: ComposerTarget | 'active' } = {} +) => { + const trimmed = text.trim() + + if (!trimmed) { + return + } + + dispatch(INSERT_EVENT, { mode, target: resolve(target), text: trimmed }) +} + +export const onComposerFocusRequest = (handler: (target: ComposerTarget) => void) => + subscribe(FOCUS_EVENT, ({ target }) => handler(target)) + +export const onComposerInsertRequest = (handler: (detail: InsertDetail) => void) => + subscribe(INSERT_EVENT, handler) + +/** + * Focus a composer input across React commit + browser focus restore. + * + * The triple-call survives: + * - sync: contenteditable already mounted + * - rAF: React just committed a `renderComposerContents` swap + * - 0ms: browser focus reclaim from a click target inside an external panel + */ +export const focusComposerInput = (el: HTMLElement | null) => { + if (!el) { + return + } + + const focus = () => el.focus({ preventScroll: true }) + + focus() + window.requestAnimationFrame(focus) + window.setTimeout(focus, 0) } diff --git a/apps/desktop/src/app/chat/composer/index.tsx b/apps/desktop/src/app/chat/composer/index.tsx index db9935d3898..eb3eb507eba 100644 --- a/apps/desktop/src/app/chat/composer/index.tsx +++ b/apps/desktop/src/app/chat/composer/index.tsx @@ -8,16 +8,16 @@ import { type DragEvent as ReactDragEvent, useCallback, useEffect, + useMemo, useRef, useState } from 'react' -import { formatRefValue, hermesDirectiveFormatter } from '@/components/assistant-ui/directive-text' +import { hermesDirectiveFormatter } from '@/components/assistant-ui/directive-text' import { Button } from '@/components/ui/button' import { useMediaQuery } from '@/hooks/use-media-query' import { useResizeObserver } from '@/hooks/use-resize-observer' import { chatMessageText } from '@/lib/chat-messages' -import { contextPath } from '@/lib/chat-runtime' import { DATA_IMAGE_URL_RE } from '@/lib/embedded-images' import { triggerHaptic } from '@/lib/haptics' import { cn } from '@/lib/utils' @@ -25,28 +25,39 @@ import { $composerAttachments, $composerDraft, clearComposerAttachments, - type ComposerAttachment + type ComposerAttachment, + reconcileComposerTerminalSelections } from '@/store/composer' import { $queuedPromptsBySession, enqueueQueuedPrompt, - removeQueuedPrompt, type QueuedPromptEntry, + removeQueuedPrompt, updateQueuedPrompt } from '@/store/composer-queue' import { $messages } from '@/store/session' import { $threadScrolledUp } from '@/store/thread-scroll' -import { type DroppedFile, extractDroppedFiles, HERMES_PATHS_MIME } from '../hooks/use-composer-actions' +import { extractDroppedFiles, HERMES_PATHS_MIME } from '../hooks/use-composer-actions' import { AttachmentList } from './attachments' import { ContextMenu } from './context-menu' import { ComposerControls } from './controls' +import { COMPOSER_DROP_ACTIVE_CLASS, COMPOSER_DROP_FADE_CLASS } from './drop-affordance' +import { + type ComposerInsertMode, + focusComposerInput, + markActiveComposer, + onComposerFocusRequest, + onComposerInsertRequest +} from './focus' import { HelpHint } from './help-hint' import { useAtCompletions } from './hooks/use-at-completions' import { useSlashCompletions } from './hooks/use-slash-completions' import { useVoiceConversation } from './hooks/use-voice-conversation' import { useVoiceRecorder } from './hooks/use-voice-recorder' +import { dragHasAttachments, droppedFileInlineRef, insertInlineRefsIntoEditor } from './inline-refs' +import { QueuePanel } from './queue-panel' import { composerPlainText, placeCaretEnd, @@ -54,7 +65,6 @@ import { renderComposerContents, RICH_INPUT_SLOT } from './rich-editor' -import { QueuePanel } from './queue-panel' import { SkinSlashPopover } from './skin-slash-popover' import { detectTrigger, extractClipboardImageBlobs, textBeforeCaret, type TriggerState } from './text-utils' import { ComposerTriggerPopover } from './trigger-popover' @@ -104,7 +114,11 @@ export function ChatBar({ const queuedPromptsBySession = useStore($queuedPromptsBySession) const scrolledUp = useStore($threadScrolledUp) const activeQueueSessionKey = queueSessionKey || sessionId || null - const queuedPrompts = activeQueueSessionKey ? (queuedPromptsBySession[activeQueueSessionKey] ?? []) : [] + + const queuedPrompts = useMemo( + () => (activeQueueSessionKey ? (queuedPromptsBySession[activeQueueSessionKey] ?? []) : []), + [activeQueueSessionKey, queuedPromptsBySession] + ) const composerRef = useRef(null) const composerSurfaceRef = useRef(null) @@ -121,10 +135,11 @@ export function ChatBar({ const [tight, setTight] = useState(false) const [dragActive, setDragActive] = useState(false) const [queueEdit, setQueueEdit] = useState(null) + const [focusRequestId, setFocusRequestId] = useState(0) const dragDepthRef = useRef(0) const lastSpokenIdRef = useRef(null) - const narrow = useMediaQuery('(max-width: 480px)') + const narrow = useMediaQuery('(max-width: 30rem)') const at = useAtCompletions({ gateway: gateway ?? null, sessionId: sessionId ?? null, cwd: cwd ?? null }) const slash = useSlashCompletions({ gateway: gateway ?? null }) @@ -132,23 +147,81 @@ export function ChatBar({ const stacked = expanded || narrow || tight const hasComposerPayload = draft.trim().length > 0 || attachments.length > 0 const canSubmit = busy || hasComposerPayload - const editingQueuedPrompt = queueEdit ? queuedPrompts.find(entry => entry.id === queueEdit.entryId) ?? null : null + const editingQueuedPrompt = queueEdit ? (queuedPrompts.find(entry => entry.id === queueEdit.entryId) ?? null) : null const busyAction = busy && hasComposerPayload ? 'queue' : 'stop' const showHelpHint = draft === '?' - const placeholder = disabled ? 'Starting Hermes…' : 'Ask anything' + const placeholder = disabled ? 'Starting Hermes...' : 'Send follow-up' - const focusInput = () => window.requestAnimationFrame(() => editorRef.current?.focus({ preventScroll: true })) + const focusInput = useCallback(() => { + focusComposerInput(editorRef.current) + markActiveComposer('main') + }, []) + + const requestMainFocus = useCallback(() => { + setFocusRequestId(id => id + 1) + }, []) + + const appendExternalText = useCallback( + (text: string, mode: ComposerInsertMode) => { + const value = text.trim() + + if (!value) { + return + } + + const base = mode === 'inline' ? draftRef.current.trimEnd() : draftRef.current + const sep = mode === 'inline' ? (base ? ' ' : '') : base && !base.endsWith('\n') ? '\n\n' : '' + const next = `${base}${sep}${value}` + + draftRef.current = next + aui.composer().setText(next) + + const editor = editorRef.current + + if (editor) { + renderComposerContents(editor, next) + placeCaretEnd(editor) + } + + setFocusRequestId(id => id + 1) + }, + [aui] + ) useEffect(() => { if (!disabled) { focusInput() } - }, [disabled, focusKey]) + }, [disabled, focusInput, focusKey, focusRequestId]) + + useEffect(() => { + if (disabled) { + return undefined + } + + const offFocus = onComposerFocusRequest(target => { + if (target === 'main') { + setFocusRequestId(id => id + 1) + } + }) + + const offInsert = onComposerInsertRequest(({ mode, target, text }) => { + if (target === 'main') { + appendExternalText(text, mode) + } + }) + + return () => { + offFocus() + offInsert() + } + }, [appendExternalText, disabled]) useEffect(() => { draftRef.current = draft $composerDraft.set(draft) + reconcileComposerTerminalSelections(draft) const editor = editorRef.current @@ -232,114 +305,33 @@ export function ChatBar({ draftRef.current = nextDraft aui.composer().setText(nextDraft) - focusInput() + requestMainFocus() } const insertInlineRefs = (refs: string[]) => { const editor = editorRef.current - if (!refs.length || !editor) { + if (!editor) { return false } - const inline = refs.join(' ') - const selection = window.getSelection() + const nextDraft = insertInlineRefsIntoEditor(editor, refs) - const range = - selection?.rangeCount && editor.contains(selection.getRangeAt(0).commonAncestorContainer) - ? selection.getRangeAt(0) - : null - - editor.focus({ preventScroll: true }) - - if (range) { - const beforeRange = range.cloneRange() - beforeRange.selectNodeContents(editor) - beforeRange.setEnd(range.startContainer, range.startOffset) - const beforeContainer = document.createElement('div') - beforeContainer.appendChild(beforeRange.cloneContents()) - - const afterRange = range.cloneRange() - afterRange.selectNodeContents(editor) - afterRange.setStart(range.endContainer, range.endOffset) - const afterContainer = document.createElement('div') - afterContainer.appendChild(afterRange.cloneContents()) - - const beforeText = composerPlainText(beforeContainer) - const afterText = composerPlainText(afterContainer) - const needsBeforeSpace = beforeText.length > 0 && !/\s$/.test(beforeText) - const needsAfterSpace = afterText.length === 0 || !/^\s/.test(afterText) - range.deleteContents() - const fragment = document.createDocumentFragment() - - if (needsBeforeSpace) { - fragment.appendChild(document.createTextNode(' ')) - } - - refs.forEach((ref, index) => { - const match = ref.match(/^@([^:]+):(.+)$/) - fragment.appendChild(match ? refChipElement(match[1], match[2]) : document.createTextNode(ref)) - - if (index < refs.length - 1) { - fragment.appendChild(document.createTextNode(' ')) - } - }) - - const trailingSpace = needsAfterSpace ? document.createTextNode(' ') : null - - if (trailingSpace) { - fragment.appendChild(trailingSpace) - } - - range.insertNode(fragment) - - const nextRange = document.createRange() - - if (trailingSpace) { - nextRange.setStart(trailingSpace, trailingSpace.length) - } else { - nextRange.setStartAfter(fragment.lastChild || range.startContainer) - } - - nextRange.collapse(true) - selection?.removeAllRanges() - selection?.addRange(nextRange) - } else { - const current = composerPlainText(editor) - renderComposerContents(editor, `${current}${current && !/\s$/.test(current) ? ' ' : ''}${inline} `) - placeCaretEnd(editor) + if (nextDraft === null) { + return false } - const nextDraft = composerPlainText(editor) draftRef.current = nextDraft aui.composer().setText(nextDraft) + requestMainFocus() return true } - const droppedFileInlineRef = (candidate: DroppedFile) => { - if (!candidate.path) { - return null - } - - 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)}` - } - const selectSkinSlashCommand = (command: string) => { draftRef.current = command aui.composer().setText(command) - focusInput() + requestMainFocus() } const handlePaste = (event: ClipboardEvent) => { @@ -453,6 +445,7 @@ export function ChatBar({ const finish = () => { draftRef.current = composerPlainText(editor) aui.composer().setText(draftRef.current) + requestMainFocus() starter ? window.setTimeout(refreshTrigger, 0) : closeTrigger() } @@ -498,7 +491,9 @@ export function ChatBar({ if ((event.metaKey || event.ctrlKey) && !event.altKey && !event.shiftKey && event.key.toLowerCase() === 'k') { event.preventDefault() - if (!busy) void drainNextQueued() + if (!busy) { + void drainNextQueued() + } return } @@ -554,29 +549,13 @@ export function ChatBar({ window.setTimeout(refreshTrigger, 0) } - const dragHasAttachments = (transfer: DataTransfer | null) => { - if (!transfer) { - return false - } - - if (Array.from(transfer.types || []).includes(HERMES_PATHS_MIME)) { - return true - } - - if (Array.from(transfer.types || []).includes('Files')) { - return true - } - - return Array.from(transfer.items || []).some(item => item.kind === 'file') - } - const resetDragState = () => { dragDepthRef.current = 0 setDragActive(false) } const handleDragEnter = (event: ReactDragEvent) => { - if (!onAttachDroppedItems || !dragHasAttachments(event.dataTransfer)) { + if (!onAttachDroppedItems || !dragHasAttachments(event.dataTransfer, HERMES_PATHS_MIME)) { return } @@ -589,7 +568,7 @@ export function ChatBar({ } const handleDragOver = (event: ReactDragEvent) => { - if (!onAttachDroppedItems || !dragHasAttachments(event.dataTransfer)) { + if (!onAttachDroppedItems || !dragHasAttachments(event.dataTransfer, HERMES_PATHS_MIME)) { return } @@ -625,7 +604,9 @@ export function ChatBar({ } if (Array.from(event.dataTransfer.types || []).includes(HERMES_PATHS_MIME)) { - const refs = candidates.map(droppedFileInlineRef).filter((ref): ref is string => Boolean(ref)) + const refs = candidates + .map(candidate => droppedFileInlineRef(candidate, cwd)) + .filter((ref): ref is string => Boolean(ref)) if (insertInlineRefs(refs)) { triggerHaptic('selection') @@ -637,13 +618,13 @@ export function ChatBar({ void Promise.resolve(onAttachDroppedItems(candidates)).then(attached => { if (attached) { triggerHaptic('selection') - focusInput() + requestMainFocus() } }) } const handleInputDragOver = (event: ReactDragEvent) => { - if (!dragHasAttachments(event.dataTransfer)) { + if (!dragHasAttachments(event.dataTransfer, HERMES_PATHS_MIME)) { return } @@ -653,12 +634,15 @@ export function ChatBar({ } const handleInputDrop = (event: ReactDragEvent) => { - if (!dragHasAttachments(event.dataTransfer)) { + if (!dragHasAttachments(event.dataTransfer, HERMES_PATHS_MIME)) { return } const candidates = extractDroppedFiles(event.dataTransfer) - const refs = candidates.map(droppedFileInlineRef).filter((ref): ref is string => Boolean(ref)) + + const refs = candidates + .map(candidate => droppedFileInlineRef(candidate, cwd)) + .filter((ref): ref is string => Boolean(ref)) if (!refs.length) { return @@ -673,14 +657,14 @@ export function ChatBar({ } } - const clearDraft = () => { + const clearDraft = useCallback(() => { aui.composer().setText('') draftRef.current = '' if (editorRef.current) { editorRef.current.replaceChildren() } - } + }, [aui]) const loadIntoComposer = (text: string, attachments: ComposerAttachment[]) => { draftRef.current = text @@ -696,7 +680,9 @@ export function ChatBar({ } const beginQueuedEdit = (entry: QueuedPromptEntry) => { - if (!activeQueueSessionKey || queueEdit) return + if (!activeQueueSessionKey || queueEdit) { + return + } setQueueEdit({ attachments: cloneAttachments($composerAttachments.get()), @@ -710,13 +696,17 @@ export function ChatBar({ } const exitQueuedEdit = (action: 'cancel' | 'save'): boolean => { - if (!queueEdit) return false + if (!queueEdit) { + return false + } if (action === 'save') { const text = draftRef.current const next = cloneAttachments($composerAttachments.get()) - if (!text.trim() && next.length === 0) return false + if (!text.trim() && next.length === 0) { + return false + } const saved = updateQueuedPrompt(queueEdit.sessionKey, queueEdit.entryId, { attachments: next, text }) triggerHaptic(saved ? 'success' : 'selection') @@ -732,32 +722,45 @@ export function ChatBar({ } const queueCurrentDraft = useCallback(() => { - if (!activeQueueSessionKey || (!draft.trim() && attachments.length === 0)) return false - if (!enqueueQueuedPrompt(activeQueueSessionKey, { text: draft, attachments })) return false + if (!activeQueueSessionKey || (!draft.trim() && attachments.length === 0)) { + return false + } + + if (!enqueueQueuedPrompt(activeQueueSessionKey, { text: draft, attachments })) { + return false + } clearDraft() clearComposerAttachments() triggerHaptic('selection') return true - }, [activeQueueSessionKey, attachments, draft]) + }, [activeQueueSessionKey, attachments, clearDraft, draft]) // All queue drain paths share one lock + send-then-remove sequence. // `pickEntry` lets each caller choose head, by-id, or skip-edited. const runDrain = useCallback( async (pickEntry: (entries: QueuedPromptEntry[]) => QueuedPromptEntry | undefined): Promise => { - if (drainingQueueRef.current || !activeQueueSessionKey) return false + if (drainingQueueRef.current || !activeQueueSessionKey) { + return false + } const entry = pickEntry(queuedPrompts) - if (!entry) return false + if (!entry) { + return false + } drainingQueueRef.current = true try { - const accepted = await Promise.resolve(onSubmit(entry.text, { attachments: entry.attachments, fromQueue: true })) + const accepted = await Promise.resolve( + onSubmit(entry.text, { attachments: entry.attachments, fromQueue: true }) + ) - if (accepted === false) return false + if (accepted === false) { + return false + } removeQueuedPrompt(activeQueueSessionKey, entry.id) @@ -785,7 +788,9 @@ export function ChatBar({ ) const interruptAndSendNextQueued = useCallback(async () => { - if (queuedPrompts.length === 0) return false + if (queuedPrompts.length === 0) { + return false + } await Promise.resolve(onCancel()) @@ -797,15 +802,22 @@ export function ChatBar({ const wasBusy = previousBusyRef.current previousBusyRef.current = busy - if (busy || !wasBusy || queuedPrompts.length === 0) return + if (busy || !wasBusy || queuedPrompts.length === 0) { + return + } void drainNextQueued() }, [busy, drainNextQueued, queuedPrompts.length]) // Clean up queue edit when its target disappears (session swap or external delete). useEffect(() => { - if (!queueEdit) return - if (queueEdit.sessionKey === activeQueueSessionKey && editingQueuedPrompt) return + if (!queueEdit) { + return + } + + if (queueEdit.sessionKey === activeQueueSessionKey && editingQueuedPrompt) { + return + } loadIntoComposer(queueEdit.draft, queueEdit.attachments) setQueueEdit(null) @@ -815,9 +827,11 @@ export function ChatBar({ if (queueEdit) { exitQueuedEdit('save') } else if (busy) { - if (hasComposerPayload) queueCurrentDraft() - else if (queuedPrompts.length > 0) void interruptAndSendNextQueued() - else { + if (hasComposerPayload) { + queueCurrentDraft() + } else if (queuedPrompts.length > 0) { + void interruptAndSendNextQueued() + } else { triggerHaptic('cancel') void Promise.resolve(onCancel()) } @@ -966,6 +980,7 @@ export function ChatBar({ onBlur={() => window.setTimeout(closeTrigger, 80)} onDragOver={handleInputDragOver} onDrop={handleInputDrop} + onFocus={() => markActiveComposer('main')} onInput={handleEditorInput} onKeyDown={handleEditorKeyDown} onKeyUp={handleEditorKeyUp} @@ -1033,10 +1048,11 @@ export function ChatBar({
- {dragActive && ( -
- Drop files to attach -
- )}
{queueEdit && editingQueuedPrompt && (
-
Editing queued turn in composer
+
+ Editing queued turn in composer +
diff --git a/apps/desktop/src/app/chat/composer/rich-editor.ts b/apps/desktop/src/app/chat/composer/rich-editor.ts index f88c2fbd611..3a45028e7ab 100644 --- a/apps/desktop/src/app/chat/composer/rich-editor.ts +++ b/apps/desktop/src/app/chat/composer/rich-editor.ts @@ -15,7 +15,7 @@ import { export const RICH_INPUT_SLOT = 'composer-rich-input' -export const REF_RE = /@(file|folder|url|image|tool|line):(`[^`\n]+`|"[^"\n]+"|'[^'\n]+'|\S+)/g +export const REF_RE = /@(file|folder|url|image|tool|line|terminal):(`[^`\n]+`|"[^"\n]+"|'[^'\n]+'|\S+)/g const ESC: Record = { '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' } diff --git a/apps/desktop/src/app/chat/composer/trigger-popover.tsx b/apps/desktop/src/app/chat/composer/trigger-popover.tsx index 82cf863f659..7cc6a3b2237 100644 --- a/apps/desktop/src/app/chat/composer/trigger-popover.tsx +++ b/apps/desktop/src/app/chat/composer/trigger-popover.tsx @@ -1,8 +1,45 @@ import type { Unstable_TriggerItem } from '@assistant-ui/core' +import { Codicon } from '@/components/ui/codicon' import { cn } from '@/lib/utils' -import { COMPLETION_DRAWER_CLASS, COMPLETION_DRAWER_ROW_CLASS, CompletionDrawerEmpty } from './completion-drawer' +import { + COMPLETION_DRAWER_BELOW_CLASS, + COMPLETION_DRAWER_CLASS, + COMPLETION_DRAWER_ROW_CLASS, + CompletionDrawerEmpty +} from './completion-drawer' + +const AT_ICON_BY_TYPE: Record = { + diff: 'diff', + file: 'book', + folder: 'folder', + git: 'git-branch', + image: 'file-media', + simple: 'symbol-misc', + staged: 'diff-added', + tool: 'tools', + url: 'globe' +} + +function completionIcon(kind: '@' | '/', item: Unstable_TriggerItem) { + if (kind === '/') { + return 'terminal' + } + + const meta = item.metadata as { rawText?: string } | undefined + const raw = meta?.rawText || item.label + + if (raw.startsWith('@diff')) { + return AT_ICON_BY_TYPE.diff + } + + if (raw.startsWith('@staged')) { + return AT_ICON_BY_TYPE.staged + } + + return AT_ICON_BY_TYPE[item.type] || AT_ICON_BY_TYPE.simple +} interface ComposerTriggerPopoverProps { activeIndex: number @@ -11,6 +48,7 @@ interface ComposerTriggerPopoverProps { loading: boolean onHover: (index: number) => void onPick: (item: Unstable_TriggerItem) => void + placement?: 'bottom' | 'top' } export function ComposerTriggerPopover({ @@ -19,11 +57,12 @@ export function ComposerTriggerPopover({ kind, loading, onHover, - onPick + onPick, + placement = 'top' }: ComposerTriggerPopoverProps) { return (
event.preventDefault()} @@ -50,19 +89,19 @@ export function ComposerTriggerPopover({ return ( ) 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 d9629e2871b..e48ff7accff 100644 --- a/apps/desktop/src/app/chat/hooks/use-composer-actions.ts +++ b/apps/desktop/src/app/chat/hooks/use-composer-actions.ts @@ -1,8 +1,14 @@ import { useCallback } from 'react' +import { requestComposerFocus, requestComposerInsert } from '@/app/chat/composer/focus' import { formatRefValue } from '@/components/assistant-ui/directive-text' import { attachmentId, contextPath, pathLabel } from '@/lib/chat-runtime' -import { addComposerAttachment, type ComposerAttachment, removeComposerAttachment } from '@/store/composer' +import { + addComposerAttachment, + type ComposerAttachment, + removeComposerAttachment, + setComposerTerminalSelection +} from '@/store/composer' import { notify, notifyError } from '@/store/notifications' import type { ImageDetachResponse } from '../../types' @@ -180,19 +186,38 @@ interface ComposerActionsOptions { requestGateway: (method: string, params?: Record) => Promise } +/** Add to the main composer and focus it. All sidebar/picker/drop attach paths funnel through here. */ +const attachToMain = (attachment: ComposerAttachment) => { + addComposerAttachment(attachment) + requestComposerFocus('main') +} + export function useComposerActions({ activeSessionId, currentCwd, requestGateway }: ComposerActionsOptions) { + const addTextToDraft = useCallback((text: string) => { + requestComposerInsert(text, { mode: 'block' }) + }, []) + + const addTerminalSelectionAttachment = useCallback((text: string, label = 'selection') => { + const trimmed = text.trim() + const normalizedLabel = label.trim() || 'selection' + const refText = `@terminal:${formatRefValue(normalizedLabel)}` + + if (!trimmed) { + return + } + + setComposerTerminalSelection(normalizedLabel, trimmed) + requestComposerInsert(refText, { mode: 'inline' }) + }, []) + const addContextRefAttachment = useCallback((refText: string, label?: string, detail?: string) => { - let kind: ComposerAttachment['kind'] = 'file' + const kind: ComposerAttachment['kind'] = refText.startsWith('@folder:') + ? 'folder' + : refText.startsWith('@url:') + ? 'url' + : 'file' - if (refText.startsWith('@folder:')) { - kind = 'folder' - } - - if (refText.startsWith('@url:')) { - kind = 'url' - } - - addComposerAttachment({ + attachToMain({ id: attachmentId(kind, refText), kind, label: label || refText.replace(/^@(file|folder|url):/, ''), @@ -216,7 +241,7 @@ export function useComposerActions({ activeSessionId, currentCwd, requestGateway for (const path of paths) { const rel = contextPath(path, currentCwd) - addComposerAttachment({ + attachToMain({ id: attachmentId(kind, rel), kind, label: pathLabel(path), @@ -237,7 +262,7 @@ export function useComposerActions({ activeSessionId, currentCwd, requestGateway const rel = contextPath(filePath, currentCwd) - addComposerAttachment({ + attachToMain({ id: attachmentId('file', rel), kind: 'file', label: pathLabel(filePath), @@ -264,7 +289,7 @@ export function useComposerActions({ activeSessionId, currentCwd, requestGateway path: filePath } - addComposerAttachment(baseAttachment) + attachToMain(baseAttachment) try { const previewUrl = await window.hermesDesktop?.readFileDataUrl(filePath) @@ -361,7 +386,7 @@ export function useComposerActions({ activeSessionId, currentCwd, requestGateway const rel = contextPath(folderPath, currentCwd) - addComposerAttachment({ + attachToMain({ id: attachmentId('folder', rel), kind: 'folder', label: pathLabel(folderPath), @@ -482,7 +507,10 @@ export function useComposerActions({ activeSessionId, currentCwd, requestGateway return { addContextRefAttachment, + addTerminalSelectionAttachment, + addTextToDraft, attachContextFilePath, + attachContextFolderPath, attachDroppedItems, attachImageBlob, attachImagePath, diff --git a/apps/desktop/src/app/chat/index.tsx b/apps/desktop/src/app/chat/index.tsx index 8786b7bb2a1..98cb2f636b9 100644 --- a/apps/desktop/src/app/chat/index.tsx +++ b/apps/desktop/src/app/chat/index.tsx @@ -11,16 +11,17 @@ import { Suspense, useMemo, useRef } from 'react' import { useLocation } from 'react-router-dom' import { Thread } from '@/components/assistant-ui/thread' +import { Backdrop } from '@/components/Backdrop' import { NotificationStack } from '@/components/notifications' import { Button } from '@/components/ui/button' +import { Codicon } from '@/components/ui/codicon' import { getGlobalModelOptions, type HermesGateway } from '@/hermes' import type { ChatMessage } from '@/lib/chat-messages' import { quickModelOptions, sessionTitle, toRuntimeMessage } from '@/lib/chat-runtime' -import { ChevronDown } from '@/lib/icons' import { useIncrementalExternalStoreRuntime } from '@/lib/incremental-external-store-runtime' import { cn } from '@/lib/utils' -import { $pinnedSessionIds } from '@/store/layout' import type { ComposerAttachment } from '@/store/composer' +import { $pinnedSessionIds } from '@/store/layout' import { $activeSessionId, $awaitingResponse, @@ -92,32 +93,30 @@ function ChatHeader({ const sessions = useStore($sessions) const pinnedSessionIds = useStore($pinnedSessionIds) const activeStoredSession = sessions.find(session => session.id === selectedSessionId) || null - const title = activeStoredSession ? sessionTitle(activeStoredSession) : '' + const title = activeStoredSession ? sessionTitle(activeStoredSession) : 'New agent' const selectedIsPinned = selectedSessionId ? pinnedSessionIds.includes(selectedSessionId) : false return (
- {title && ( - + - - )} +

{title}

+ + +
) @@ -271,10 +270,11 @@ export function ChatView({ return (
+ -
+
{showChatBar && ( diff --git a/apps/desktop/src/app/chat/right-rail/preview-console.tsx b/apps/desktop/src/app/chat/right-rail/preview-console.tsx index 22841aaa238..3617f59a1ed 100644 --- a/apps/desktop/src/app/chat/right-rail/preview-console.tsx +++ b/apps/desktop/src/app/chat/right-rail/preview-console.tsx @@ -2,10 +2,10 @@ import { useStore } from '@nanostores/react' import type { CSSProperties, MutableRefObject, PointerEvent as ReactPointerEvent, RefObject } from 'react' import { useEffect, useMemo, useRef } from 'react' +import { requestComposerInsert } from '@/app/chat/composer/focus' import { CopyButton } from '@/components/ui/copy-button' import { PanelBottom, Send, Trash2 } from '@/lib/icons' import { cn } from '@/lib/utils' -import { $composerDraft, setComposerDraft } from '@/store/composer' import { notify } from '@/store/notifications' import type { ConsoleEntry, PreviewConsoleState } from './preview-console-state' @@ -186,10 +186,8 @@ export function PreviewConsolePanel({ } const block = ['Preview console:', '```', ...entries.map(formatLogLine), '```'].join('\n') - const draft = $composerDraft.get() - const next = draft && !draft.endsWith('\n') ? `${draft}\n\n${block}` : `${draft}${block}` - setComposerDraft(next) + requestComposerInsert(block, { mode: 'block', target: 'main' }) consoleState.clearSelection() notify({ kind: 'success', diff --git a/apps/desktop/src/app/chat/right-rail/preview-file.tsx b/apps/desktop/src/app/chat/right-rail/preview-file.tsx index 7a650da4fa7..708961c23ae 100644 --- a/apps/desktop/src/app/chat/right-rail/preview-file.tsx +++ b/apps/desktop/src/app/chat/right-rail/preview-file.tsx @@ -100,7 +100,7 @@ export function PreviewEmptyState({ )} {secondaryAction && (
-
+
{selection && (
+
{target.label} +
{state.truncated && (
Showing first 512 KB. diff --git a/apps/desktop/src/app/chat/right-rail/preview-pane.test.tsx b/apps/desktop/src/app/chat/right-rail/preview-pane.test.tsx index 5c64bd55c95..163511b05bc 100644 --- a/apps/desktop/src/app/chat/right-rail/preview-pane.test.tsx +++ b/apps/desktop/src/app/chat/right-rail/preview-pane.test.tsx @@ -13,7 +13,6 @@ describe('PreviewPane console state', () => { const rendered = render( void onRestartServer?: (url: string, context?: string) => Promise reloadRequest?: number setTitlebarToolGroup?: SetTitlebarToolGroup @@ -84,7 +83,7 @@ function PreviewLoadError({ body={ <> { event.preventDefault() @@ -117,7 +116,6 @@ const TITLEBAR_GROUP_ID = 'preview' export function PreviewPane({ embedded = false, - onClose, onRestartServer, reloadRequest = 0, setTitlebarToolGroup, @@ -299,35 +297,13 @@ export function PreviewPane({ onSelect: toggleDevTools } ] - : []), - { - icon: , - id: `${TITLEBAR_GROUP_ID}-reload`, - label: 'Reload preview', - onSelect: reloadPreview - }, - { - icon: , - id: `${TITLEBAR_GROUP_ID}-close`, - label: 'Close preview', - onSelect: onClose - } + : []) ] setTitlebarToolGroup(TITLEBAR_GROUP_ID, tools) return () => setTitlebarToolGroup(TITLEBAR_GROUP_ID, []) - }, [ - consoleOpen, - consoleState, - devtoolsOpen, - isWebPreview, - loading, - onClose, - reloadPreview, - setTitlebarToolGroup, - toggleDevTools - ]) + }, [consoleOpen, consoleState, devtoolsOpen, isWebPreview, setTitlebarToolGroup, toggleDevTools]) useEffect(() => { if (!consoleOpen) { @@ -534,7 +510,7 @@ export function PreviewPane({ } const webview = document.createElement('webview') as PreviewWebview - webview.className = 'flex h-full w-full flex-1 bg-background' + webview.className = 'flex h-full w-full flex-1 bg-transparent' webview.setAttribute('partition', 'persist:hermes-preview') webview.setAttribute('src', target.url) webview.setAttribute('webpreferences', 'contextIsolation=yes,nodeIntegration=no,sandbox=yes') @@ -626,13 +602,13 @@ export function PreviewPane({ }, [appendConsoleEntry, consoleState, isWebPreview, target.url]) return ( -
@@ -934,9 +863,9 @@ function ArtifactTable({ ))}
{ ), thead: ({ className, ...props }: ComponentProps<'thead'>) => ( - + ), th: ({ className, ...props }: ComponentProps<'th'>) => (
), td: ({ className, ...props }: ComponentProps<'td'>) => ( - + ), img: MarkdownImage, - SyntaxHighlighter: (props: SyntaxHighlighterProps) => , - CodeHeader + SyntaxHighlighter: (props: SyntaxHighlighterProps) => }) as StreamdownTextComponents, [isStreaming] ) @@ -324,13 +312,13 @@ const MarkdownTextImpl = () => { caret="block" components={components} containerClassName={cn( - 'aui-md prose w-full max-w-none overflow-hidden text-base leading-(--dt-line-height) text-foreground', + 'aui-md prose w-full max-w-none overflow-hidden text-[length:var(--conversation-text-font-size)] leading-(--dt-line-height) text-foreground', 'prose-p:leading-(--dt-line-height) prose-li:leading-(--dt-line-height)', 'prose-headings:text-foreground prose-strong:text-foreground', 'prose-a:break-words prose-p:[overflow-wrap:anywhere]', - 'prose-li:marker:text-midground/55', - 'prose-code:rounded prose-code:border-0 prose-code:bg-muted/80 prose-code:px-0.5 prose-code:py-px prose-code:font-mono prose-code:text-[0.86em] prose-code:text-muted-foreground prose-code:before:content-none prose-code:after:content-none', - '[&>*:last-child]:mb-0' + 'prose-li:marker:text-muted-foreground/70', + 'prose-code:rounded-[0.25rem] prose-code:px-[0.1875rem] prose-code:py-px prose-code:font-mono prose-code:text-[0.9em] prose-code:font-normal prose-code:before:content-none prose-code:after:content-none', + '[&>*:first-child]:mt-0 [&>*:last-child]:mb-0 [&>*+*]:mt-1' )} lineNumbers={false} mode="streaming" @@ -345,7 +333,6 @@ const MarkdownTextImpl = () => { parseIncompleteMarkdown plugins={{ math: mathPlugin, ...(isStreaming ? {} : { code }) }} preprocess={preprocessMarkdown} - shikiTheme={['github-light-default', 'github-dark-default']} /> ) } diff --git a/apps/desktop/src/components/assistant-ui/streaming.test.tsx b/apps/desktop/src/components/assistant-ui/streaming.test.tsx index 44e70549083..70f66040e85 100644 --- a/apps/desktop/src/components/assistant-ui/streaming.test.tsx +++ b/apps/desktop/src/components/assistant-ui/streaming.test.tsx @@ -51,6 +51,34 @@ vi.stubGlobal('cancelAnimationFrame', (id: number) => window.clearTimeout(id)) Element.prototype.scrollTo = function scrollTo() {} +Element.prototype.animate = function animate() { + return { + cancel: () => {}, + finished: Promise.resolve() + } as unknown as Animation +} + +// jsdom returns 0 for offset*; the virtualizer reads those to size its +// viewport. Fall through to client* (which tests can override) or a sane +// default so virtualized items render. +function stubOffsetDimension( + prop: 'offsetHeight' | 'offsetWidth', + clientProp: 'clientHeight' | 'clientWidth', + fallback: number +) { + const previous = Object.getOwnPropertyDescriptor(HTMLElement.prototype, prop) + + Object.defineProperty(HTMLElement.prototype, prop, { + configurable: true, + get() { + return previous?.get?.call(this) || (this as HTMLElement)[clientProp] || fallback + } + }) +} + +stubOffsetDimension('offsetWidth', 'clientWidth', 800) +stubOffsetDimension('offsetHeight', 'clientHeight', 600) + async function wait(ms: number) { await act(async () => { await new Promise(resolve => window.setTimeout(resolve, ms)) @@ -85,6 +113,23 @@ function assistantMessage(text: string, running = true): ThreadMessage { } as ThreadMessage } +function assistantErrorMessage(error: string): ThreadMessage { + return { + id: 'assistant-error-1', + role: 'assistant', + content: [], + status: { type: 'incomplete', reason: 'error', error }, + createdAt, + metadata: { + unstable_state: null, + unstable_annotations: [], + unstable_data: [], + steps: [], + custom: {} + } + } as ThreadMessage +} + function assistantReasoningMessage(text: string): ThreadMessage { return { id: 'assistant-reasoning-1', @@ -232,6 +277,20 @@ function TodoHarness({ message }: { message: ThreadMessage }) { ) } +function MessageHarness({ message }: { message: ThreadMessage }) { + const runtime = useExternalStoreRuntime({ + messages: [message], + isRunning: false, + onNew: async () => {} + }) + + return ( + + + + ) +} + function ReasoningHarness() { const runtime = useExternalStoreRuntime({ messages: [assistantReasoningMessage(' The user is asking what this file is.')], @@ -311,6 +370,12 @@ describe('assistant-ui streaming renderer', () => { expect(container.querySelector('[data-slot="aui_composer-clearance"]')).toBeNull() }) + it('renders assistant provider errors inline', () => { + render() + + expect(screen.getByRole('alert').textContent).toContain('OpenRouter rejected the request (403).') + }) + it('does not pull the viewport back down after the user scrolls up during streaming', async () => { const { container } = render() diff --git a/apps/desktop/src/components/assistant-ui/thread-virtualizer.tsx b/apps/desktop/src/components/assistant-ui/thread-virtualizer.tsx new file mode 100644 index 00000000000..bfb7a26aa47 --- /dev/null +++ b/apps/desktop/src/components/assistant-ui/thread-virtualizer.tsx @@ -0,0 +1,306 @@ +import { ThreadPrimitive, useAuiEvent, useAuiState } from '@assistant-ui/react' +import { useVirtualizer, type Virtualizer } from '@tanstack/react-virtual' +import { type ComponentProps, type FC, type ReactNode, useCallback, useEffect, useMemo, useRef } from 'react' + +import { cn } from '@/lib/utils' +import { setThreadScrolledUp } from '@/store/thread-scroll' + +const ESTIMATED_ITEM_HEIGHT = 220 +const OVERSCAN = 4 +const AT_BOTTOM_THRESHOLD = 4 + +type ThreadMessageComponents = ComponentProps['components'] + +type MessageGroup = { id: string; index: number; kind: 'standalone' } | { id: string; indices: number[]; kind: 'turn' } + +interface VirtualizedThreadProps { + clampToComposer: boolean + components: ThreadMessageComponents + emptyPlaceholder?: ReactNode + loadingIndicator?: ReactNode + sessionKey?: string | null +} + +function buildGroups(signature: string): MessageGroup[] { + if (!signature) { + return [] + } + + const messages = signature.split('\n').map(row => { + const [index, id, role] = row.split(':') + + return { id, index: Number(index), role } + }) + + const groups: MessageGroup[] = [] + + for (let i = 0; i < messages.length; i++) { + const message = messages[i] + + if (message.role !== 'user') { + groups.push({ id: message.id, index: message.index, kind: 'standalone' }) + + continue + } + + const indices = [message.index] + + while (i + 1 < messages.length && messages[i + 1].role !== 'user') { + indices.push(messages[++i].index) + } + + groups.push({ id: message.id, indices, kind: 'turn' }) + } + + return groups +} + +export const VirtualizedThread: FC = ({ + clampToComposer, + components, + emptyPlaceholder, + loadingIndicator, + sessionKey +}) => { + const messageSignature = useAuiState(s => + s.thread.messages.map((message, index) => `${index}:${message.id}:${message.role}`).join('\n') + ) + + const groups = useMemo(() => buildGroups(messageSignature), [messageSignature]) + const renderEmpty = groups.length === 0 && Boolean(emptyPlaceholder) + const scrollerRef = useRef(null) + + const virtualizer = useVirtualizer({ + count: groups.length, + estimateSize: () => ESTIMATED_ITEM_HEIGHT, + getItemKey: index => groups[index]?.id ?? index, + getScrollElement: () => scrollerRef.current, + // Seed the rect so the initial range mounts something before + // `observeElementRect` reports the real layout (it overrides this). + initialRect: { height: 600, width: 800 }, + overscan: OVERSCAN + }) + + useThreadScrollAnchor({ + enabled: !renderEmpty, + groupCount: groups.length, + scrollerRef, + sessionKey: sessionKey ?? null, + virtualizer + }) + + const virtualItems = virtualizer.getVirtualItems() + const totalSize = virtualizer.getTotalSize() + const paddingTop = virtualItems[0]?.start ?? 0 + const paddingBottom = Math.max(0, totalSize - (virtualItems.at(-1)?.end ?? 0)) + + return ( +
+
+ {renderEmpty ? ( +
+ {emptyPlaceholder} +
+ ) : ( +
+ {/* Natural-flow virtualization: mounted items render as normal + flex siblings so `position: sticky` on the human bubble + resolves against the scroller without transform interference. + Padding spacers reserve scroll space for unmounted items. */} +
+ {virtualItems.map(virtualItem => { + const group = groups[virtualItem.index] + + if (!group) { + return null + } + + return ( +
+ {group.kind === 'turn' ? ( +
+ {group.indices.map(index => ( + + ))} +
+ ) : ( + + )} +
+ ) + })} +
+ {loadingIndicator} + {clampToComposer && ( + + )} +
+
+ ) +} + +interface ScrollAnchorOptions { + enabled: boolean + groupCount: number + scrollerRef: React.RefObject + sessionKey: string | null + virtualizer: Virtualizer +} + +function useThreadScrollAnchor({ enabled, groupCount, scrollerRef, sessionKey, virtualizer }: ScrollAnchorOptions) { + // `armed` = parked at bottom, content growth should follow. Cleared on + // user-driven upward scroll; re-armed when they reach bottom again. + const armedRef = useRef(true) + const lastTopRef = useRef(0) + const prevSessionKeyRef = useRef(sessionKey) + const prevGroupCountRef = useRef(0) + + const pinToBottom = useCallback(() => { + const el = scrollerRef.current + + if (!el) { + return + } + + el.scrollTop = el.scrollHeight + lastTopRef.current = el.scrollTop + }, [scrollerRef]) + + const jumpToBottom = useCallback(() => { + armedRef.current = true + + if (groupCount > 0) { + virtualizer.scrollToIndex(groupCount - 1, { align: 'end', behavior: 'auto' }) + } + + requestAnimationFrame(() => { + if (armedRef.current) { + pinToBottom() + } + }) + }, [groupCount, pinToBottom, virtualizer]) + + useEffect(() => () => setThreadScrolledUp(false), []) + + // Track at-bottom state, dim composer when scrolled up, disarm on user + // scroll/wheel/touch. + useEffect(() => { + const el = scrollerRef.current + + if (!el) { + return undefined + } + + const disarm = () => { + armedRef.current = false + } + + const onScroll = () => { + const top = el.scrollTop + + if (top + 1 < lastTopRef.current) { + armedRef.current = false + } + + lastTopRef.current = top + + const atBottom = el.scrollHeight - (top + el.clientHeight) <= AT_BOTTOM_THRESHOLD + + if (atBottom) { + armedRef.current = true + } + + setThreadScrolledUp(!atBottom) + } + + const onWheel = (event: WheelEvent) => { + if (event.deltaY < 0) { + disarm() + } + } + + el.addEventListener('scroll', onScroll, { passive: true }) + el.addEventListener('wheel', onWheel, { passive: true }) + el.addEventListener('touchmove', disarm, { passive: true }) + + return () => { + el.removeEventListener('scroll', onScroll) + el.removeEventListener('wheel', onWheel) + el.removeEventListener('touchmove', disarm) + } + }, [scrollerRef]) + + // Follow content growth (streaming, item measurements, loading indicator) + // while armed. + useEffect(() => { + if (!enabled) { + return undefined + } + + const el = scrollerRef.current + + if (!el) { + return undefined + } + + const observer = new ResizeObserver(() => { + if (armedRef.current) { + pinToBottom() + } + }) + + observer.observe(el) + + if (el.firstElementChild) { + observer.observe(el.firstElementChild) + } + + return () => observer.disconnect() + }, [enabled, pinToBottom, scrollerRef]) + + // Jump to bottom on session change OR when an empty thread first gets + // content. Both share the same intent and the same effect. + useEffect(() => { + const sessionChanged = prevSessionKeyRef.current !== sessionKey + const becameNonEmpty = prevGroupCountRef.current === 0 && groupCount > 0 + + prevSessionKeyRef.current = sessionKey + prevGroupCountRef.current = groupCount + + if (enabled && (sessionChanged || becameNonEmpty)) { + jumpToBottom() + } + }, [enabled, groupCount, jumpToBottom, sessionKey]) + + useAuiEvent('thread.runStart', jumpToBottom) +} diff --git a/apps/desktop/src/components/assistant-ui/thread.tsx b/apps/desktop/src/components/assistant-ui/thread.tsx index d0a039f0f14..ecc350d53d9 100644 --- a/apps/desktop/src/components/assistant-ui/thread.tsx +++ b/apps/desktop/src/components/assistant-ui/thread.tsx @@ -1,22 +1,57 @@ +import type { Unstable_TriggerAdapter, Unstable_TriggerItem } from '@assistant-ui/core' import { ActionBarPrimitive, - AuiIf, BranchPickerPrimitive, ComposerPrimitive, ErrorPrimitive, MessagePrimitive, - ThreadPrimitive, type ToolCallMessagePartProps, - useAuiEvent, + useAui, useAuiState } from '@assistant-ui/react' import { useStore } from '@nanostores/react' -import { type FC, type ReactNode, useCallback, useEffect, useMemo, useRef, useState } from 'react' -import { StickToBottom, useStickToBottomContext } from 'use-stick-to-bottom' +import { IconPlayerStopFilled } from '@tabler/icons-react' +import { + type ClipboardEvent, + type FC, + type FocusEvent, + type FormEvent, + type KeyboardEvent, + type DragEvent as ReactDragEvent, + type ReactNode, + useCallback, + useEffect, + useMemo, + useRef, + useState +} from 'react' +import { COMPOSER_DROP_ACTIVE_CLASS, COMPOSER_DROP_FADE_CLASS } from '@/app/chat/composer/drop-affordance' +import { + type ComposerInsertMode, + focusComposerInput, + markActiveComposer, + onComposerFocusRequest, + onComposerInsertRequest +} from '@/app/chat/composer/focus' +import { useAtCompletions } from '@/app/chat/composer/hooks/use-at-completions' +import { useSlashCompletions } from '@/app/chat/composer/hooks/use-slash-completions' +import { dragHasAttachments, droppedFileInlineRef, insertInlineRefsIntoEditor } from '@/app/chat/composer/inline-refs' +import { + composerPlainText, + placeCaretEnd, + refChipElement, + renderComposerContents, + RICH_INPUT_SLOT +} from '@/app/chat/composer/rich-editor' +import { detectTrigger, textBeforeCaret, type TriggerState } from '@/app/chat/composer/text-utils' +import { ComposerTriggerPopover } from '@/app/chat/composer/trigger-popover' +import { extractDroppedFiles, HERMES_PATHS_MIME } from '@/app/chat/hooks/use-composer-actions' import { ClarifyTool } from '@/components/assistant-ui/clarify-tool' import { DirectiveContent, DirectiveText } from '@/components/assistant-ui/directive-text' +import { hermesDirectiveFormatter } from '@/components/assistant-ui/directive-text' import { MarkdownText } from '@/components/assistant-ui/markdown-text' +import { VirtualizedThread } from '@/components/assistant-ui/thread-virtualizer' import { HoistedTodoPanel, todosFromMessageContent } from '@/components/assistant-ui/todo-tool' import { ToolFallback, ToolGroupSlot } from '@/components/assistant-ui/tool-fallback' import { TooltipIconButton } from '@/components/assistant-ui/tooltip-icon-button' @@ -27,6 +62,7 @@ import { GeneratedImageProvider, useGeneratedImageContext } from '@/components/c import { ImageGenerationPlaceholder } from '@/components/chat/image-generation-placeholder' import { Intro, type IntroProps } from '@/components/chat/intro' import { PreviewAttachment } from '@/components/chat/preview-attachment' +import { Codicon } from '@/components/ui/codicon' import { CopyButton } from '@/components/ui/copy-button' import { DropdownMenu, @@ -36,35 +72,19 @@ import { DropdownMenuTrigger } from '@/components/ui/dropdown-menu' import { Loader } from '@/components/ui/loader' +import type { HermesGateway } from '@/hermes' +import { DATA_IMAGE_URL_RE } from '@/lib/embedded-images' import { triggerHaptic } from '@/lib/haptics' -import { - CheckIcon, - ChevronLeftIcon, - ChevronRightIcon, - GitBranchIcon, - Loader2Icon, - MoreHorizontalIcon, - PencilIcon, - RefreshCwIcon, - Volume2Icon, - VolumeXIcon, - XIcon -} from '@/lib/icons' +import { GitBranchIcon, Loader2Icon, Volume2Icon, VolumeXIcon } from '@/lib/icons' import { extractPreviewTargets } from '@/lib/preview-targets' import { useEnterAnimation } from '@/lib/use-enter-animation' import { cn } from '@/lib/utils' import { playSpeechText, stopVoicePlayback } from '@/lib/voice-playback' import { notifyError } from '@/store/notifications' -import { setThreadScrolledUp } from '@/store/thread-scroll' import { $voicePlayback } from '@/store/voice-playback' type ThreadLoadingState = 'response' | 'session' -interface StickyStateFlags { - escapedFromLock: boolean - isAtBottom: boolean -} - interface MessageActionProps { messageId: string messageText: string @@ -99,236 +119,62 @@ const INTERRUPTED_ONLY_RE = /^_?\[interrupted\]_?$/i const isInterruptedOnlyMessage = (text: string) => INTERRUPTED_ONLY_RE.test(text.trim()) -function resetStickyState(state: StickyStateFlags) { - state.escapedFromLock = false - state.isAtBottom = true -} - -function pinElementToBottom(el: HTMLElement) { - el.scrollTop = el.scrollHeight - - return el.scrollTop -} - export const Thread: FC<{ clampToComposer?: boolean + cwd?: string | null + gateway?: HermesGateway | null intro?: IntroProps loading?: ThreadLoadingState onBranchInNewChat?: (messageId: string) => void + onCancel?: () => Promise | void + sessionId?: string | null sessionKey?: string | null -}> = ({ clampToComposer = false, intro, loading, onBranchInNewChat, sessionKey }) => { - const introHero = useAuiState(s => Boolean(intro) && s.thread.isEmpty) +}> = ({ + clampToComposer = false, + cwd = null, + gateway = null, + intro, + loading, + onBranchInNewChat, + onCancel, + sessionId = null, + sessionKey +}) => { + const messageComponents = useMemo( + () => ({ + AssistantMessage: () => , + SystemMessage, + UserEditComposer: () => , + UserMessage: () => + }), + [cwd, gateway, onBranchInNewChat, onCancel, sessionId] + ) + + const emptyPlaceholder = intro ? ( +
+ +
+ ) : undefined return ( - - - - - - Boolean(intro) && s.thread.isEmpty}> - {intro ? ( -
- -
- ) : null} -
- , - SystemMessage, - UserEditComposer, - UserMessage - }} - /> - {loading === 'response' && } -
-
-
+
+ : null} + sessionKey={sessionKey} + /> {loading === 'session' && } - +
) } -const ThreadScrollSync: FC<{ sessionKey?: string | null }> = ({ sessionKey }) => { - const { scrollRef, isAtBottom, state } = useStickToBottomContext() - const sessionKeyRef = useRef(sessionKey ?? null) - - const armedRef = useRef(null) - const pinRafRef = useRef(null) - const previousScrollTopRef = useRef(0) - const suppressNextScrollEventRef = useRef(false) - - const messageCount = useAuiState(s => s.thread.messages.length) - const prevMessageCountRef = useRef(messageCount) - - useEffect(() => { - setThreadScrolledUp(!isAtBottom) - }, [isAtBottom]) - - useEffect(() => { - return () => { - setThreadScrolledUp(false) - } - }, []) - - const armAndPin = useCallback( - (behavior: ScrollBehavior) => { - const el = scrollRef.current - - if (!el) { - return - } - - armedRef.current = behavior - resetStickyState(state) - suppressNextScrollEventRef.current = true - previousScrollTopRef.current = pinElementToBottom(el) - }, - [scrollRef, state] - ) - - useEffect(() => { - const el = scrollRef.current - - if (!el) { - return - } - - const observer = new ResizeObserver(() => { - if (pinRafRef.current !== null) { - return - } - - pinRafRef.current = window.requestAnimationFrame(() => { - pinRafRef.current = null - - if (!armedRef.current) { - return - } - - const distance = el.scrollHeight - (el.scrollTop + el.clientHeight) - - if (distance < 2) { - armedRef.current = null - - return - } - - suppressNextScrollEventRef.current = true - previousScrollTopRef.current = pinElementToBottom(el) - }) - }) - - observer.observe(el) - - const content = el.firstElementChild - - if (content) { - observer.observe(content) - } - - return () => { - observer.disconnect() - - if (pinRafRef.current !== null) { - window.cancelAnimationFrame(pinRafRef.current) - pinRafRef.current = null - } - } - }, [scrollRef]) - - useEffect(() => { - const el = scrollRef.current - - if (!el) { - return - } - - const onWheel = (e: WheelEvent) => { - if (e.deltaY < 0) { - armedRef.current = null - } - } - - const onTouch = () => { - armedRef.current = null - } - - const onScroll = () => { - const currentTop = el.scrollTop - - if (suppressNextScrollEventRef.current) { - suppressNextScrollEventRef.current = false - previousScrollTopRef.current = currentTop - - return - } - - if (currentTop + 1 < previousScrollTopRef.current) { - armedRef.current = null - } - - previousScrollTopRef.current = currentTop - } - - el.addEventListener('wheel', onWheel, { passive: true }) - el.addEventListener('touchmove', onTouch, { passive: true }) - el.addEventListener('scroll', onScroll, { passive: true }) - - return () => { - el.removeEventListener('wheel', onWheel) - el.removeEventListener('touchmove', onTouch) - el.removeEventListener('scroll', onScroll) - } - }, [scrollRef]) - - useEffect(() => { - const next = sessionKey ?? null - - if (sessionKeyRef.current === next) { - return - } - - sessionKeyRef.current = next - prevMessageCountRef.current = 0 - armAndPin('auto') - }, [armAndPin, sessionKey]) - - useEffect(() => { - const prev = prevMessageCountRef.current - prevMessageCountRef.current = messageCount - - if (prev === 0 && messageCount > 0) { - armAndPin('auto') - } - }, [armAndPin, messageCount]) - - useAuiEvent('thread.runStart', () => { - armAndPin('instant') - }) - - return null -} - function pickPrimaryPreviewTarget(targets: string[]): string[] { if (targets.length <= 1) { return targets @@ -386,7 +232,7 @@ const AssistantMessage: FC<{ onBranchInNewChat?: (messageId: string) => void }> >
void }> )} @@ -457,7 +303,7 @@ const ImageGenerateTool: FC = ({ result }) => { } return ( -
+
) @@ -527,28 +373,36 @@ const ThinkingDisclosure: FC<{ }, [isPreview]) return ( -
+
setUserOpen(!open)} open={open}> Thinking {pending && ( - + )} {open && (
= ({ messageId, messageText, on const [menuOpen, setMenuOpen] = useState(false) return ( -
+
= ({ messageId, messageText, on triggerHaptic('submit')} tooltip="Refresh"> - + - + e.preventDefault()} sideOffset={6}> @@ -744,19 +598,19 @@ const MessageTimestamp: FC = () => { } const AssistantFooter: FC = props => ( -
+
- - + + / - - + + @@ -773,9 +627,50 @@ function messageAttachmentRefs(value: unknown): string[] { return value.every(ref => typeof ref === 'string') ? value : EMPTY_ATTACHMENT_REFS } -const UserMessage: FC = () => { +function StickyHumanMessageContainer({ children }: { children: ReactNode }) { + return ( +
+ {children} +
+ ) +} + +// Shared "user bubble" base. Both the read-only message and the inline +// edit composer render the same bubble surface (rounded glass card, +// shadow-composer); they only differ in border weight, cursor, and +// padding-right (the read-only view reserves room for the restore icon). +const USER_BUBBLE_BASE_CLASS = + 'composer-human-message standalone-glass relative flex w-full min-w-0 max-w-full flex-col gap-1.5 overflow-hidden rounded-xl border bg-(--dt-user-bubble) px-3 py-2 text-left shadow-composer' + +const USER_ACTION_ICON_BUTTON_CLASS = + 'grid cursor-pointer place-items-center rounded-md bg-transparent text-(--ui-text-secondary) transition-colors hover:bg-(--ui-control-active-background) hover:text-foreground disabled:cursor-default disabled:text-(--ui-text-quaternary) disabled:opacity-70' + +const USER_ACTION_ICON_SIZE = '0.6875rem' +const StopGlyph = + +const UserMessage: FC<{ + onCancel?: () => Promise | void +}> = ({ onCancel }) => { + const messageId = useAuiState(s => s.message.id) const content = useAuiState(s => s.message.content) const messageText = messageContentText(content) + const threadRunning = useAuiState(s => s.thread.isRunning) + + const latestUserId = useAuiState(s => { + for (let i = s.thread.messages.length - 1; i >= 0; i--) { + const message = s.thread.messages[i] as { id?: string; role?: string } + + if (message.role === 'user') { + return message.id ?? null + } + } + + return null + }) const attachmentRefs = useAuiState(s => { const custom = (s.message.metadata?.custom ?? {}) as { attachmentRefs?: unknown } @@ -784,47 +679,108 @@ const UserMessage: FC = () => { }) const hasBody = messageText.trim().length > 0 + const isLatestUser = messageId === latestUserId + const showStop = isLatestUser && threadRunning && Boolean(onCancel) + const showRestore = !isLatestUser && !threadRunning + + const bubbleClassName = cn( + USER_BUBBLE_BASE_CLASS, + 'border-(--ui-stroke-tertiary) pr-9 text-[length:var(--conversation-text-font-size)] leading-(--dt-line-height) text-foreground/95 transition-colors', + !threadRunning && 'cursor-pointer hover:border-(--ui-stroke-secondary)' + ) + + const bubbleContent = ( + <> + {attachmentRefs.length > 0 && ( + + + + )} + {hasBody && ( + + + + )} + + ) return ( - -
- {attachmentRefs.length > 0 && ( -
- + + + +
+
+ {threadRunning ? ( +
{bubbleContent}
+ ) : ( + + + + )} + {(showStop || showRestore) && ( +
+ {showStop ? ( + + ) : ( + + )} +
+ )} +
+ + + + Restore checkpoint + + + / + + + Go forward + +
- )} - {hasBody && ( -
- -
- )} -
- + + ) } -const UserActionBar: FC<{ messageText: string }> = ({ messageText }) => ( -
- - - - triggerHaptic('selection')} tooltip="Edit"> - - - - -
-) - const SLASH_STATUS_RE = /^slash:(?\/[^\n]+)\n(?[\s\S]*)$/ const SystemMessage: FC = () => { @@ -861,29 +817,502 @@ const SystemMessage: FC = () => { ) } -const UserEditComposer: FC = () => ( - - -
- - - - - - - triggerHaptic('submit')} tooltip="Send edit"> - - - -
-
-) +interface UserEditComposerProps { + cwd: string | null + gateway: HermesGateway | null + sessionId: string | null +} + +const UserEditComposer: FC = ({ cwd, gateway, sessionId }) => { + const aui = useAui() + const draft = useAuiState(s => s.composer.text) + const rootRef = useRef(null) + const editorRef = useRef(null) + const draftRef = useRef(draft) + const dragDepthRef = useRef(0) + const [dragActive, setDragActive] = useState(false) + const [trigger, setTrigger] = useState(null) + const [triggerActive, setTriggerActive] = useState(0) + const [triggerItems, setTriggerItems] = useState([]) + const [triggerPlacement, setTriggerPlacement] = useState<'bottom' | 'top'>('top') + const [focusRequestId, setFocusRequestId] = useState(0) + const [submitting, setSubmitting] = useState(false) + const expanded = draft.includes('\n') + const canSubmit = draft.trim().length > 0 + const at = useAtCompletions({ cwd, gateway, sessionId }) + const slash = useSlashCompletions({ gateway }) + + const focusEditor = useCallback(() => { + const editor = editorRef.current + + focusComposerInput(editor) + + if (editor) { + placeCaretEnd(editor) + } + + markActiveComposer('edit') + }, []) + + const requestEditFocus = useCallback(() => { + setFocusRequestId(id => id + 1) + }, []) + + const appendExternalText = useCallback( + (text: string, mode: ComposerInsertMode) => { + const value = text.trim() + + if (!value) { + return + } + + const base = mode === 'inline' ? draftRef.current.trimEnd() : draftRef.current + const sep = mode === 'inline' ? (base ? ' ' : '') : base && !base.endsWith('\n') ? '\n\n' : '' + const next = `${base}${sep}${value}` + + draftRef.current = next + aui.composer().setText(next) + + const editor = editorRef.current + + if (editor) { + renderComposerContents(editor, next) + placeCaretEnd(editor) + } + + setFocusRequestId(id => id + 1) + }, + [aui] + ) + + useEffect(() => { + draftRef.current = draft + + const editor = editorRef.current + + if ( + editor && + (editor.childNodes.length === 0 || (document.activeElement !== editor && composerPlainText(editor) !== draft)) + ) { + renderComposerContents(editor, draft) + + if (document.activeElement === editor) { + placeCaretEnd(editor) + } + } + }, [draft]) + + useEffect(() => { + focusEditor() + }, [focusEditor, focusRequestId]) + + useEffect(() => { + const offFocus = onComposerFocusRequest(target => { + if (target === 'edit') { + setFocusRequestId(id => id + 1) + } + }) + + const offInsert = onComposerInsertRequest(({ mode, target, text }) => { + if (target === 'edit') { + appendExternalText(text, mode) + } + }) + + return () => { + offFocus() + offInsert() + } + }, [appendExternalText]) + + const syncDraftFromEditor = useCallback( + (editor: HTMLDivElement) => { + const nextDraft = composerPlainText(editor) + + if (nextDraft !== draftRef.current) { + draftRef.current = nextDraft + aui.composer().setText(nextDraft) + } + + return nextDraft + }, + [aui] + ) + + const refreshTrigger = useCallback(() => { + const editor = editorRef.current + + if (!editor) { + return + } + + const before = textBeforeCaret(editor) + const detected = detectTrigger(before ?? composerPlainText(editor)) + + if (detected) { + const rect = editor.getBoundingClientRect() + const spaceAbove = rect.top + const spaceBelow = window.innerHeight - rect.bottom + + setTriggerPlacement(spaceAbove < 220 && spaceBelow > spaceAbove ? 'bottom' : 'top') + } + + setTrigger(detected) + setTriggerActive(0) + }, []) + + const closeTrigger = useCallback(() => { + setTrigger(null) + setTriggerItems([]) + setTriggerActive(0) + }, []) + + const triggerAdapter: Unstable_TriggerAdapter | null = + trigger?.kind === '@' ? at.adapter : trigger?.kind === '/' ? slash.adapter : null + + useEffect(() => { + if (!trigger || !triggerAdapter?.search) { + setTriggerItems([]) + + return + } + + setTriggerItems(triggerAdapter.search(trigger.query)) + }, [trigger, triggerAdapter]) + + useEffect(() => { + setTriggerActive(idx => Math.min(idx, Math.max(0, triggerItems.length - 1))) + }, [triggerItems.length]) + + const triggerLoading = trigger?.kind === '@' ? at.loading : trigger?.kind === '/' ? slash.loading : false + + const replaceTriggerWithChip = useCallback( + (item: Unstable_TriggerItem) => { + const editor = editorRef.current + + if (!editor || !trigger) { + return + } + + const serialized = hermesDirectiveFormatter.serialize(item) + const starter = serialized.endsWith(':') + const text = starter || serialized.endsWith(' ') ? serialized : `${serialized} ` + const directive = !starter && serialized.match(/^@([^:]+):(.+)$/) + + const finish = () => { + draftRef.current = composerPlainText(editor) + aui.composer().setText(draftRef.current) + requestEditFocus() + 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 + + if (!sel || !range || node?.nodeType !== Node.TEXT_NODE || offset < trigger.tokenLength) { + const current = composerPlainText(editor) + renderComposerContents(editor, `${current.slice(0, Math.max(0, current.length - trigger.tokenLength))}${text}`) + placeCaretEnd(editor) + + return finish() + } + + const replaceRange = document.createRange() + replaceRange.setStart(node, offset - trigger.tokenLength) + replaceRange.setEnd(node, offset) + replaceRange.deleteContents() + + if (directive) { + const chip = refChipElement(directive[1], directive[2]) + const space = document.createTextNode(' ') + const fragment = document.createDocumentFragment() + fragment.append(chip, space) + replaceRange.insertNode(fragment) + + const caret = document.createRange() + caret.setStart(space, 1) + caret.collapse(true) + sel.removeAllRanges() + sel.addRange(caret) + + return finish() + } + + document.execCommand('insertText', false, text) + finish() + }, + [aui, closeTrigger, refreshTrigger, requestEditFocus, trigger] + ) + + const insertDroppedRefs = useCallback( + (candidates: ReturnType) => { + const editor = editorRef.current + + if (!editor) { + return false + } + + const refs = candidates + .map(candidate => droppedFileInlineRef(candidate, cwd)) + .filter((ref): ref is string => Boolean(ref)) + + const nextDraft = insertInlineRefsIntoEditor(editor, refs) + + if (nextDraft === null) { + return false + } + + draftRef.current = nextDraft + aui.composer().setText(nextDraft) + requestEditFocus() + + return true + }, + [aui, cwd, requestEditFocus] + ) + + const resetDragState = useCallback(() => { + dragDepthRef.current = 0 + setDragActive(false) + }, []) + + const handleDragEnter = (event: ReactDragEvent) => { + if (!dragHasAttachments(event.dataTransfer, HERMES_PATHS_MIME)) { + return + } + + event.preventDefault() + dragDepthRef.current += 1 + + if (!dragActive) { + setDragActive(true) + } + } + + const handleDragOver = (event: ReactDragEvent) => { + if (!dragHasAttachments(event.dataTransfer, HERMES_PATHS_MIME)) { + return + } + + event.preventDefault() + event.dataTransfer.dropEffect = 'copy' + } + + const handleDragLeave = (event: ReactDragEvent) => { + event.preventDefault() + dragDepthRef.current = Math.max(0, dragDepthRef.current - 1) + + if (dragDepthRef.current === 0) { + setDragActive(false) + } + } + + const handleDrop = (event: ReactDragEvent) => { + if (!dragHasAttachments(event.dataTransfer, HERMES_PATHS_MIME)) { + return + } + + const candidates = extractDroppedFiles(event.dataTransfer) + + if (!candidates.length) { + return + } + + event.preventDefault() + event.stopPropagation() + resetDragState() + + if (insertDroppedRefs(candidates)) { + triggerHaptic('selection') + } + } + + const handleInput = (event: FormEvent) => { + const editor = event.currentTarget + + if (editor.childNodes.length === 1 && editor.firstChild?.nodeName === 'BR') { + editor.replaceChildren() + } + + syncDraftFromEditor(editor) + window.setTimeout(refreshTrigger, 0) + } + + const handlePaste = (event: ClipboardEvent) => { + const pastedText = event.clipboardData.getData('text') + + if (!pastedText || DATA_IMAGE_URL_RE.test(pastedText.trim())) { + event.preventDefault() + + return + } + + event.preventDefault() + document.execCommand('insertText', false, pastedText) + syncDraftFromEditor(event.currentTarget) + } + + const submitEdit = (editor: HTMLDivElement) => { + const nextDraft = syncDraftFromEditor(editor) + + if (submitting || !nextDraft.trim()) { + return + } + + setSubmitting(true) + aui.composer().send() + } + + const handleEditBlur = useCallback( + (event: FocusEvent) => { + const nextTarget = event.relatedTarget + + if (nextTarget instanceof Node && event.currentTarget.contains(nextTarget)) { + return + } + + window.setTimeout(() => { + const root = rootRef.current + const active = document.activeElement + + if (submitting || (root && active && root.contains(active))) { + return + } + + closeTrigger() + aui.composer().cancel() + }, 80) + }, + [aui, closeTrigger, submitting] + ) + + const handleKeyDown = (event: KeyboardEvent) => { + if (trigger && triggerItems.length > 0) { + if (event.key === 'ArrowDown') { + event.preventDefault() + setTriggerActive(idx => (idx + 1) % triggerItems.length) + + return + } + + if (event.key === 'ArrowUp') { + event.preventDefault() + setTriggerActive(idx => (idx - 1 + triggerItems.length) % triggerItems.length) + + return + } + + if (event.key === 'Enter' || event.key === 'Tab') { + event.preventDefault() + const item = triggerItems[triggerActive] + + if (item) { + replaceTriggerWithChip(item) + } + + return + } + + if (event.key === 'Escape') { + event.preventDefault() + closeTrigger() + + return + } + } + + if (event.key === 'Escape') { + event.preventDefault() + aui.composer().cancel() + + return + } + + if (event.key === 'Enter' && !event.shiftKey) { + event.preventDefault() + submitEdit(event.currentTarget) + } + } + + return ( + + +
+ {trigger && ( + + )} +
+
window.setTimeout(closeTrigger, 80)} + onDragOver={handleDragOver} + onDrop={handleDrop} + onFocus={() => markActiveComposer('edit')} + onInput={handleInput} + onKeyDown={handleKeyDown} + onKeyUp={() => window.setTimeout(refreshTrigger, 0)} + onMouseUp={refreshTrigger} + onPaste={handlePaste} + ref={editorRef} + role="textbox" + suppressContentEditableWarning + /> + + +
+
+ + + ) +} diff --git a/apps/desktop/src/components/assistant-ui/todo-tool.tsx b/apps/desktop/src/components/assistant-ui/todo-tool.tsx index e974b6ba51f..549c8c3bd9d 100644 --- a/apps/desktop/src/components/assistant-ui/todo-tool.tsx +++ b/apps/desktop/src/components/assistant-ui/todo-tool.tsx @@ -16,11 +16,13 @@ export function todosFromMessageContent(content: unknown): TodoItem[] { if (!part || typeof part !== 'object') { continue } + const row = part as Record if (row.type !== 'tool-call' || row.toolName !== 'todo') { continue } + const parsed = parseTodos(row.result) ?? parseTodos(row.args) if (parsed !== null) { @@ -70,6 +72,7 @@ export const HoistedTodoPanel: FC<{ todos: TodoItem[] }> = ({ todos }) => { if (!todos.length) { return null } + const label = headerLabel(todos) return ( diff --git a/apps/desktop/src/components/assistant-ui/tool-fallback-model.ts b/apps/desktop/src/components/assistant-ui/tool-fallback-model.ts index df0e38103b3..9ca808d2096 100644 --- a/apps/desktop/src/components/assistant-ui/tool-fallback-model.ts +++ b/apps/desktop/src/components/assistant-ui/tool-fallback-model.ts @@ -1,6 +1,4 @@ import { normalizeExternalUrl } from '@/lib/external-link' -import { Command, FileText, Globe, LinkIcon, Search, Sparkles, Wrench } from '@/lib/icons' -import type { LucideIcon } from '@/lib/icons' import { extractToolErrorMessage, formatToolResultSummary } from '@/lib/tool-result-summary' export type ToolTone = 'agent' | 'browser' | 'default' | 'file' | 'image' | 'terminal' | 'web' @@ -31,7 +29,7 @@ export interface ToolView { detail: string detailLabel: string durationLabel?: string - icon: LucideIcon + icon?: string imageUrl?: string inlineDiff: string previewTarget?: string @@ -46,7 +44,7 @@ export interface ToolView { interface ToolMeta { done: string - icon: LucideIcon + icon?: string pending: string tone: ToolTone } @@ -63,39 +61,39 @@ export interface MessageRunningStateSlice { } const TOOL_META: Record = { - browser_click: { done: 'Clicked page element', pending: 'Clicking page element', icon: Globe, tone: 'browser' }, - browser_fill: { done: 'Filled form field', pending: 'Filling form field', icon: Globe, tone: 'browser' }, - browser_navigate: { done: 'Opened page', pending: 'Opening page', icon: Globe, tone: 'browser' }, + browser_click: { done: 'Clicked page element', pending: 'Clicking page element', icon: 'globe', tone: 'browser' }, + browser_fill: { done: 'Filled form field', pending: 'Filling form field', icon: 'globe', tone: 'browser' }, + browser_navigate: { done: 'Opened page', pending: 'Opening page', icon: 'globe', tone: 'browser' }, browser_snapshot: { done: 'Captured page snapshot', pending: 'Capturing page snapshot', - icon: Globe, + icon: 'globe', tone: 'browser' }, browser_take_screenshot: { done: 'Captured screenshot', pending: 'Capturing screenshot', - icon: Sparkles, + icon: 'file-media', tone: 'browser' }, - browser_type: { done: 'Typed on page', pending: 'Typing on page', icon: Globe, tone: 'browser' }, - edit_file: { done: 'Edited file', pending: 'Editing file', icon: FileText, tone: 'file' }, - execute_code: { done: 'Ran code', pending: 'Running code', icon: Command, tone: 'terminal' }, - image_generate: { done: 'Generated image', pending: 'Generating image', icon: Sparkles, tone: 'image' }, - list_files: { done: 'Listed files', pending: 'Listing files', icon: FileText, tone: 'file' }, - read_file: { done: 'Read file', pending: 'Reading file', icon: FileText, tone: 'file' }, - search_files: { done: 'Searched files', pending: 'Searching files', icon: FileText, tone: 'file' }, + browser_type: { done: 'Typed on page', pending: 'Typing on page', icon: 'globe', tone: 'browser' }, + edit_file: { done: 'Edited file', pending: 'Editing file', icon: 'edit', tone: 'file' }, + execute_code: { done: 'Ran code', pending: 'Running code', icon: 'terminal', tone: 'terminal' }, + image_generate: { done: 'Generated image', pending: 'Generating image', icon: 'file-media', tone: 'image' }, + list_files: { done: 'Listed files', pending: 'Listing files', icon: 'files', tone: 'file' }, + read_file: { done: 'Read file', pending: 'Reading file', icon: 'file', tone: 'file' }, + search_files: { done: 'Searched files', pending: 'Searching files', icon: 'search', tone: 'file' }, session_search_recall: { done: 'Searched session history', pending: 'Searching session history', - icon: Search, + icon: 'search', tone: 'agent' }, - terminal: { done: 'Ran command', pending: 'Running command', icon: Command, tone: 'terminal' }, - todo: { done: 'Updated todos', pending: 'Updating todos', icon: Wrench, tone: 'agent' }, - web_extract: { done: 'Read webpage', pending: 'Reading webpage', icon: LinkIcon, tone: 'web' }, - web_search: { done: 'Searched web', pending: 'Searching web', icon: Search, tone: 'web' }, - write_file: { done: 'Edited file', pending: 'Editing file', icon: FileText, tone: 'file' } + terminal: { done: 'Ran command', pending: 'Running command', icon: 'terminal', tone: 'terminal' }, + todo: { done: 'Updated todos', pending: 'Updating todos', icon: 'tools', tone: 'agent' }, + web_extract: { done: 'Read webpage', pending: 'Reading webpage', icon: 'globe', tone: 'web' }, + web_search: { done: 'Searched web', pending: 'Searching web', icon: 'search', tone: 'web' }, + write_file: { done: 'Edited file', pending: 'Editing file', icon: 'edit', tone: 'file' } } const INLINE_CODE_SPLIT_RE = /(`[^`\n]+`)/g @@ -117,9 +115,9 @@ function titleForTool(name: string): string { ) } -const PREFIX_META: { icon: LucideIcon; prefix: string; tone: ToolTone; verb: string }[] = [ - { prefix: 'browser_', verb: 'Browser', icon: Globe, tone: 'browser' }, - { prefix: 'web_', verb: 'Web', icon: Search, tone: 'web' } +const PREFIX_META: { icon?: string; prefix: string; tone: ToolTone; verb: string }[] = [ + { prefix: 'browser_', verb: 'Browser', icon: 'globe', tone: 'browser' }, + { prefix: 'web_', verb: 'Web', icon: 'globe', tone: 'web' } ] function toolMeta(name: string): ToolMeta { @@ -137,7 +135,7 @@ function toolMeta(name: string): ToolMeta { icon: prefix.icon, tone: prefix.tone } - : { done: action, pending: `Running ${action.toLowerCase()}`, icon: Wrench, tone: 'default' } + : { done: action, pending: `Running ${action.toLowerCase()}`, tone: 'default' } } function isRecord(value: unknown): value is Record { @@ -836,9 +834,17 @@ export function inlineDiffFromResult(result: unknown): string { // Falls back to a string only when there's something concrete to render — // counts of opaque items/fields are noise, not signal. function minimalValueSummary(value: unknown): string { - if (value == null) return '' - if (typeof value === 'string') return value - if (typeof value === 'number' || typeof value === 'boolean') return String(value) + if (value == null) { + return '' + } + + if (typeof value === 'string') { + return value + } + + if (typeof value === 'number' || typeof value === 'boolean') { + return String(value) + } return '' } @@ -1195,6 +1201,7 @@ export function buildToolView(part: ToolPart, inlineDiff: string): ToolView { const searchHits = part.toolName === 'web_search' && status !== 'error' ? extractSearchResults(part.result) : undefined + const resultCount = status === 'error' ? null : toolResultCount(part, argsRecord, resultRecord) return { diff --git a/apps/desktop/src/components/assistant-ui/tool-fallback.tsx b/apps/desktop/src/components/assistant-ui/tool-fallback.tsx index 09fc3279dc1..5e2f75ae167 100644 --- a/apps/desktop/src/components/assistant-ui/tool-fallback.tsx +++ b/apps/desktop/src/components/assistant-ui/tool-fallback.tsx @@ -8,10 +8,12 @@ import { useShallow } from 'zustand/shallow' import { useElapsedSeconds } from '@/components/chat/activity-timer' import { ActivityTimerText } from '@/components/chat/activity-timer-text' import { CompactMarkdown } from '@/components/chat/compact-markdown' +import { DiffLines } from '@/components/chat/diff-lines' import { DisclosureRow } from '@/components/chat/disclosure-row' import { PreviewAttachment } from '@/components/chat/preview-attachment' import { ZoomableImage } from '@/components/chat/zoomable-image' import { BrailleSpinner } from '@/components/ui/braille-spinner' +import { Codicon } from '@/components/ui/codicon' import { CopyButton } from '@/components/ui/copy-button' import { FadeText } from '@/components/ui/fade-text' import { PrettyLink, LinkifiedText as SharedLinkifiedText, urlSlugTitleLabel } from '@/lib/external-link' @@ -25,11 +27,9 @@ import { groupCopyText as buildGroupCopyText, buildToolView, cleanVisibleText, - compactPreview, groupFailedStepCount, groupPreviewTargets, groupStatus, - groupTailSubtitle, groupTitle, groupTotalDurationLabel, inlineDiffFromResult, @@ -54,27 +54,48 @@ const SPECIAL_TOOL_NAMES = new Set(['todo', 'image_generate', 'clarify']) // the group already shows. const ToolEmbedContext = createContext(false) -const STATUS_DOT_CLASS: Record = { - error: 'bg-destructive', - running: 'bg-muted-foreground/55 animate-pulse', - success: 'bg-emerald-500', - warning: 'bg-amber-500' -} +// Shared header chrome for tool rows. Both the single-tool DisclosureRow +// and the multi-tool group header pass through these constants so a +// "Patch" row and a "Tool actions · 2 steps" row are visually identical. +const TOOL_HEADER_TITLE_CLASS = + 'text-[length:var(--conversation-tool-font-size)] font-medium leading-(--conversation-line-height) text-(--ui-text-secondary)' -const STATUS_LABEL: Record = { - error: 'Error', - running: 'Running', - success: 'Done', - warning: 'Recovered' -} +const TOOL_HEADER_DURATION_CLASS = 'shrink-0 text-[0.625rem] tabular-nums text-(--ui-text-tertiary)' -function statusDot(status: ToolStatus): ReactNode { - return ( - - ) +const TOOL_HEADER_SUBTITLE_CLASS = + 'text-[length:var(--conversation-caption-font-size)] leading-(--conversation-caption-line-height) text-(--ui-text-tertiary)' + +const TOOL_HEADER_GLYPH_WRAP_CLASS = 'grid size-3.5 shrink-0 place-items-center self-center' + +// Glass-style section label that sits above any pre/JSON/output block. +// Lowercase tracking + tiny size so it reads as a quiet field label rather +// than a chrome heading. Used for "COMMAND OUTPUT", "INPUT", "OUTPUT", etc. +const TOOL_SECTION_LABEL_CLASS = 'mb-1 text-[0.65rem] font-medium uppercase tracking-[0.08em] text-(--ui-text-tertiary)' + +// Inset scroll surface for any detail body. The expanded tool row owns the +// border; the payload itself is just clipped raw text. +const TOOL_SECTION_SURFACE_CLASS = + 'max-h-20 max-w-full overflow-auto bg-transparent px-2 py-1.5 text-(--ui-text-secondary)' + +const TOOL_SECTION_PRE_CLASS = cn(TOOL_SECTION_SURFACE_CLASS, 'font-mono text-[0.7rem] leading-relaxed') + +function rawTechnicalTrace(args: unknown, result: unknown): string { + const parts = [args, result] + .filter(value => value !== undefined && value !== null) + .map(value => { + if (typeof value === 'string') { + return value + } + + try { + return JSON.stringify(value) + } catch { + return String(value) + } + }) + .filter(Boolean) + + return parts.join('\n') } function statusGlyph(status: ToolStatus): ReactNode { @@ -82,7 +103,7 @@ function statusGlyph(status: ToolStatus): ReactNode { return ( ) @@ -99,6 +120,29 @@ function statusGlyph(status: ToolStatus): ReactNode { return } +// Leading glyph for any tool-row header. Status (running/error/warning) +// takes precedence; otherwise falls back to the tool's codicon. Returns +// null when neither applies so callers can render unconditionally. +function ToolGlyph({ icon, status }: { icon?: string; status?: ToolStatus }) { + const node = status ? ( + statusGlyph(status) + ) : icon ? ( + + ) : null + + return node ? {node} : null +} + +// Which status (if any) should pre-empt the tool's icon in the leading +// slot. Success is silent — the row reads as "done" without a checkmark. +function leadingStatus(isPending: boolean, status: ToolStatus): ToolStatus | undefined { + if (isPending) { + return 'running' + } + + return status === 'success' ? undefined : status +} + function SearchResultsList({ hits }: { hits: SearchResultRow[] }) { return (
    @@ -110,17 +154,15 @@ function SearchResultsList({ hits }: { hits: SearchResultRow[] }) {
  1. {hit.url ? ( ) : ( - {trimmedTitle} - )} - {hit.snippet && ( -

    {hit.snippet}

    + {trimmedTitle} )} + {hit.snippet &&

    {hit.snippet}

    }
  2. ) })} @@ -156,7 +198,6 @@ function ToolEntry({ part }: ToolEntryProps) { // handles its own enter animation, so embedded children skip it. const enterRef = useEnterAnimation(messageRunning && !embedded, `tool-entry:${disclosureId}`) const elapsed = useElapsedSeconds(isPending, `tool:${disclosureId}`) - const preview = compactPreview(part.args) || compactPreview(part.result) const liveDiffs = useStore($toolInlineDiffs) const sideDiff = part.toolCallId ? liveDiffs[part.toolCallId] || '' : '' const inlineDiff = stripInlineDiffChrome(sideDiff) || inlineDiffFromResult(part.result) @@ -224,97 +265,67 @@ function ToolEntry({ part }: ToolEntryProps) { toolViewMode === 'technical' ) - const isTerminalLike = part.toolName === 'terminal' || part.toolName === 'execute_code' - const subtitleText = view.subtitle ? (toolViewMode === 'technical' ? preview || view.subtitle : view.subtitle) : '' - const subtitleIsSingleLine = !subtitleText.includes('\n') - const showStatusGlyph = isPending || view.status === 'error' || view.status === 'warning' const copyAction = useMemo(() => toolCopyPayload(part, view), [part, view]) const trailing = isPending && !embedded ? ( - + ) : !isPending && copyAction.text ? ( ) : undefined return (
    - setToolDisclosureOpen(disclosureId, !open) : undefined} - open={open} - trailing={trailing} - > - - {showStatusGlyph && ( - - {statusGlyph(isPending ? 'running' : view.status)} - - )} - - {view.title} - - {!isPending && view.countLabel && ( - {view.countLabel} - )} - {!isPending && view.durationLabel && ( - - {view.durationLabel} - - )} - - {subtitleText && - (subtitleIsSingleLine ? ( +
    + setToolDisclosureOpen(disclosureId, !open) : undefined} + open={open} + trailing={trailing} + > + + - {subtitleText} + {view.title} - ) : ( - - {subtitleText} - - ))} - + {!isPending && view.countLabel && {view.countLabel}} + {!isPending && view.durationLabel && ( + {view.durationLabel} + )} + + +
    {open && ( -
    +
    {!embedded && view.previewTarget && isPreviewableTarget(view.previewTarget) && ( )} {view.imageUrl && ( -
    +
    )} {hasSearchHits && view.searchHits && ( -
    - {searchResultsLabel && ( -

    - {searchResultsLabel} -

    - )} +
    + {searchResultsLabel &&

    {searchResultsLabel}

    }
    )} {showDetail && + toolViewMode !== 'technical' && (view.status === 'error' ? ( detailSections.summary || detailSections.body ? (
    @@ -334,53 +345,31 @@ function ToolEntry({ part }: ToolEntryProps) {
    ) : null ) : ( -
    - {view.detailLabel && ( -

    - {view.detailLabel} -

    - )} +
    + {view.detailLabel &&

    {view.detailLabel}

    } {renderDetailAsCode ? ( -
    -                    {view.detail}
    -                  
    +
    {view.detail}
    ) : ( - + )}
    ))} {showRawSearchDrilldown && ( -
    - - Raw response - -
    +            
    + Raw response +
                     {view.rawResult}
                   
    )} {toolViewMode === 'technical' && ( -
    - - {part.result !== undefined && } -
    +
    +              {rawTechnicalTrace(part.args, part.result)}
    +            
    )}
    )} - {view.inlineDiff && } -
    - ) -} - -function JsonSection({ label, value }: { label: string; value: string }) { - return ( -
    -
    - {label} -
    -
    -        {value}
    -      
    + {view.inlineDiff && }
    ) } @@ -422,6 +411,7 @@ export const ToolGroupSlot: FC groupTailSubtitle(visibleParts), [visibleParts]) const groupCopyText = useMemo(() => buildGroupCopyText(visibleParts), [visibleParts]) const previewTargets = useMemo(() => groupPreviewTargets(visibleParts), [visibleParts]) - const showGroupStatusGlyph = displayStatus !== 'success' return ( @@ -473,34 +461,23 @@ export const ToolGroupSlot: FC - - {showGroupStatusGlyph && ( - {statusGlyph(displayStatus)} - )} + + {groupTitle(visibleParts)} - {totalDurationLabel && ( - - {totalDurationLabel} - - )} + {totalDurationLabel && {totalDurationLabel}} - {tailSummary && ( - - {tailSummary.replace(/\n+/g, ' · ')} - - )} {statusSummary && ( @@ -538,31 +515,3 @@ export const ToolFallback = ({ toolCallId, toolName, args, isError, result }: To return } - -function InlineDiff({ text }: { text: string }) { - return ( -
    -      {text.split('\n').map((line, index) => {
    -        const added = line.startsWith('+') && !line.startsWith('+++')
    -        const removed = line.startsWith('-') && !line.startsWith('---')
    -        const hunk = line.startsWith('@@')
    -        const fileHeader = line.startsWith('---') || line.startsWith('+++') || / → /.test(line.slice(0, 60))
    -
    -        return (
    -          
    -            {line || ' '}
    -          
    -        )
    -      })}
    -    
    - ) -} diff --git a/apps/desktop/src/components/chat/activity-timer.ts b/apps/desktop/src/components/chat/activity-timer.ts index 29095c32db6..533dc5b373c 100644 --- a/apps/desktop/src/components/chat/activity-timer.ts +++ b/apps/desktop/src/components/chat/activity-timer.ts @@ -9,11 +9,13 @@ function startedAt(key?: string): number { if (!key) { return Date.now() } + const existing = startedAtByKey.get(key) if (existing !== undefined) { return existing } + const now = Date.now() startedAtByKey.set(key, now) diff --git a/apps/desktop/src/components/chat/code-card.tsx b/apps/desktop/src/components/chat/code-card.tsx new file mode 100644 index 00000000000..46997caa4d7 --- /dev/null +++ b/apps/desktop/src/components/chat/code-card.tsx @@ -0,0 +1,78 @@ +import * as React from 'react' + +import { Codicon, type CodiconProps } from '@/components/ui/codicon' +import { cn } from '@/lib/utils' + +/** + * Rounded-card shell for fenced code (and any equivalent: diffs, raw payloads, + * etc.) sized for the conversation column. Mirrors the expanded tool-row + * pattern so code blocks read as the same family of artifact. + */ +function CodeCard({ className, ...props }: React.ComponentProps<'div'>) { + return ( +
    + ) +} + +function CodeCardHeader({ className, ...props }: React.ComponentProps<'div'>) { + return ( +
    + ) +} + +function CodeCardTitle({ className, children, ...props }: React.ComponentProps<'span'>) { + return ( + + {children} + + ) +} + +function CodeCardIcon({ className, ...props }: CodiconProps) { + return ( + + ) +} + +function CodeCardSubtitle({ className, ...props }: React.ComponentProps<'span'>) { + return ( + + ) +} + +function CodeCardBody({ className, ...props }: React.ComponentProps<'div'>) { + return ( +
    + ) +} + +export { CodeCard, CodeCardBody, CodeCardHeader, CodeCardIcon, CodeCardSubtitle, CodeCardTitle } diff --git a/apps/desktop/src/components/chat/compact-markdown.tsx b/apps/desktop/src/components/chat/compact-markdown.tsx index 7bc88c86019..79e96e8fa65 100644 --- a/apps/desktop/src/components/chat/compact-markdown.tsx +++ b/apps/desktop/src/components/chat/compact-markdown.tsx @@ -41,14 +41,18 @@ function tagged(Tag: T) { function MarkdownAnchor({ children, className, href, ...rest }: ComponentProps<'a'>) { if (!href || !/^https?:\/\//i.test(href)) { return ( - + {children} ) } return ( - + {children} diff --git a/apps/desktop/src/components/chat/diff-lines.tsx b/apps/desktop/src/components/chat/diff-lines.tsx new file mode 100644 index 00000000000..926b77edf92 --- /dev/null +++ b/apps/desktop/src/components/chat/diff-lines.tsx @@ -0,0 +1,54 @@ +import * as React from 'react' + +import { cn } from '@/lib/utils' + +/** + * Per-line classed renderer for unified diffs. Lives outside `CodeCard` so + * tool-result panels (already nested inside a tool card) don't double-shell; + * for markdown ` ```diff ` fences the standard `CodeCard` + Shiki path runs + * instead and gives equivalent coloring. + */ +interface DiffLineKind { + className?: string + match: (line: string) => boolean +} + +const DIFF_LINE_KINDS: DiffLineKind[] = [ + { + className: 'text-emerald-700 dark:text-emerald-300', + match: line => line.startsWith('+') && !line.startsWith('+++') + }, + { className: 'text-rose-700 dark:text-rose-300', match: line => line.startsWith('-') && !line.startsWith('---') }, + { className: 'text-sky-700 dark:text-sky-300', match: line => line.startsWith('@@') }, + { + className: 'text-muted-foreground/70', + match: line => line.startsWith('---') || line.startsWith('+++') || / → /.test(line.slice(0, 60)) + } +] + +function classifyLine(line: string): string | undefined { + return DIFF_LINE_KINDS.find(kind => kind.match(line))?.className +} + +interface DiffLinesProps extends Omit, 'children'> { + text: string +} + +export function DiffLines({ className, text, ...props }: DiffLinesProps) { + return ( +
    +      {text.split('\n').map((line, index) => (
    +        
    +          {line || ' '}
    +        
    +      ))}
    +    
    + ) +} diff --git a/apps/desktop/src/components/chat/disclosure-row.tsx b/apps/desktop/src/components/chat/disclosure-row.tsx index 911be03f953..b528e10c601 100644 --- a/apps/desktop/src/components/chat/disclosure-row.tsx +++ b/apps/desktop/src/components/chat/disclosure-row.tsx @@ -1,13 +1,13 @@ -import { ChevronRight } from 'lucide-react' import type { ReactNode } from 'react' +import { DisclosureCaret } from '@/components/ui/disclosure-caret' import { cn } from '@/lib/utils' // Shared header row for any collapsible block (thinking, tool group, single // tool). Each parent supplies its own outer wrapper (with the data-slot CSS // uses to escape the message padding) and its own expanded body. // -// Cursor-style affordance: +// Affordance: // - No leading chevron; a caret appears to the RIGHT of the text on hover // (and stays visible when the row is open). // - The hover background is a tight content-shaped pill — sized to the @@ -26,13 +26,13 @@ export function DisclosureRow({ trailing?: ReactNode }) { return ( -
    +
    - {trailing && {trailing}} + {trailing && ( + {trailing} + )}
    ) } diff --git a/apps/desktop/src/components/chat/intro.tsx b/apps/desktop/src/components/chat/intro.tsx index f417c163995..7cd914c8dbe 100644 --- a/apps/desktop/src/components/chat/intro.tsx +++ b/apps/desktop/src/components/chat/intro.tsx @@ -1,4 +1,4 @@ -import { useState } from 'react' +import { type CSSProperties, useState } from 'react' import introCopyJsonl from './intro-copy.jsonl?raw' @@ -161,9 +161,17 @@ export function Intro({ personality, seed }: IntroProps) { className="pointer-events-none flex w-full min-w-0 flex-col items-center justify-center px-3 py-6 text-center text-muted-foreground sm:px-6 lg:px-8" data-slot="aui_intro" > -
    -

    - Hermes Agent +

    +

    + + HERMES AGENT + +

    {copy.body}

    diff --git a/apps/desktop/src/components/chat/shiki-highlighter.tsx b/apps/desktop/src/components/chat/shiki-highlighter.tsx index a5fbe1c340b..a0088e9eea9 100644 --- a/apps/desktop/src/components/chat/shiki-highlighter.tsx +++ b/apps/desktop/src/components/chat/shiki-highlighter.tsx @@ -4,72 +4,89 @@ import type { SyntaxHighlighterProps } from '@assistant-ui/react-streamdown' import type { FC } from 'react' import ShikiHighlighter from 'react-shiki' -import { isLikelyProseCodeBlock } from '@/lib/markdown-code' +import { + CodeCard, + CodeCardBody, + CodeCardHeader, + CodeCardIcon, + CodeCardSubtitle, + CodeCardTitle +} from '@/components/chat/code-card' +import { CopyButton } from '@/components/ui/copy-button' +import { codiconForLanguage, isLikelyProseCodeBlock, sanitizeLanguageTag } from '@/lib/markdown-code' /** - * assistant-ui's recommended `SyntaxHighlighter` slot. + * Streamdown's code adapter renders header + body as inline siblings, so we + * own the wrapping `` here and neutralize the upstream + * `data-streamdown="code-block"` chrome from styles.css. Anything that wants + * a card-shaped code surface should compose `CodeCard*` directly. * - * Uses the full `react-shiki` bundle so all `bundledLanguages` work - * (rust, go, swift, kotlin, sql, etc.) — the `/web` subpath only ships - * common web languages and silently falls back to plain text otherwise. - * - * Theme switching is automatic via the CSS `color-scheme` on `:root` - * (set from the desktop theme provider). - * - * `showLanguage` is disabled because we render our own `CodeHeader`; - * leaving it on causes the language to appear twice. + * `react-shiki` full bundle so all `bundledLanguages` work; theme switches + * follow the document `color-scheme` via `defaultColor="light-dark()"`. */ interface HermesSyntaxHighlighterProps extends SyntaxHighlighterProps { defer?: boolean } +const SHIKI_THEME = { dark: 'github-dark-default', light: 'github-light-default' } as const + export const SyntaxHighlighter: FC = ({ - components: { Pre, Code: _UnusedCode }, + components: { Pre }, language, code, defer = false }) => { - const preClassName = - 'aui-shiki m-0 overflow-hidden rounded-b-md border border-t-0 border-border bg-card font-mono text-sm leading-relaxed [&_pre]:m-0 [&_pre]:overflow-x-auto [&_pre]:bg-transparent! [&_pre]:px-4 [&_pre]:py-3 [&_pre]:font-mono [&_pre]:leading-relaxed' - - // Streamdown may hand us fence contents with edge newlines. Strip blank - // 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. + // Streaming may hand us empty/incomplete fences — render nothing rather + // than a transient empty card. if (!trimmed.trim()) { return null } if (isLikelyProseCodeBlock(language, trimmed)) { - return
    {trimmed}
    + return
    {trimmed}
    } - if (defer) { - return ( -
    -        {trimmed}
    -      
    - ) - } + const cleanLanguage = sanitizeLanguageTag(language || '') + const label = cleanLanguage && cleanLanguage !== 'unknown' ? cleanLanguage : '' return ( -
    -      
    -        {trimmed}
    -      
    -    
    + + + + + Code + {label && · {label}} + + + + +
    +          {defer ? (
    +            {trimmed}
    +          ) : (
    +            
    +              {trimmed}
    +            
    +          )}
    +        
    +
    +
    ) } diff --git a/apps/desktop/src/components/desktop-onboarding-overlay.tsx b/apps/desktop/src/components/desktop-onboarding-overlay.tsx index fce4fec0c40..d9e39861c49 100644 --- a/apps/desktop/src/components/desktop-onboarding-overlay.tsx +++ b/apps/desktop/src/components/desktop-onboarding-overlay.tsx @@ -144,8 +144,8 @@ export function DesktopOnboardingOverlay({ enabled, onCompleted, requestGateway const showPicker = flow.status === 'idle' || flow.status === 'success' return ( -
    -
    +
    +
    {reason ? : null} @@ -209,14 +209,14 @@ function Preparing({ boot }: { boot: DesktopBootState }) { function Header() { return ( -
    +
    -
    +
    -

    Welcome to Hermes

    -

    +

    Welcome to Hermes

    +

    Connect a model provider to start chatting. Most options take one click.

    @@ -252,7 +252,7 @@ function FooterLink({ children, onClick }: { children: React.ReactNode; onClick: return (
    ) diff --git a/apps/desktop/src/components/pane-shell/pane-shell.tsx b/apps/desktop/src/components/pane-shell/pane-shell.tsx index 6e6aab4127b..a3f6719ee54 100644 --- a/apps/desktop/src/components/pane-shell/pane-shell.tsx +++ b/apps/desktop/src/components/pane-shell/pane-shell.tsx @@ -54,6 +54,7 @@ interface CollectedPane { defaultOpen: boolean disabled: boolean id: string + resizable: boolean side: PaneSide width: string } @@ -110,6 +111,7 @@ function collectPanes(children: ReactNode) { defaultOpen: props.defaultOpen ?? true, disabled: props.disabled ?? false, id: props.id, + resizable: props.resizable ?? false, side: props.side, width: widthToCss(props.width, DEFAULT_WIDTH) } @@ -128,7 +130,7 @@ function trackForPane(pane: CollectedPane, states: Record - +
    )} {children} diff --git a/apps/desktop/src/components/ui/button.tsx b/apps/desktop/src/components/ui/button.tsx index 4b0aff4a9aa..467f4c0a5df 100644 --- a/apps/desktop/src/components/ui/button.tsx +++ b/apps/desktop/src/components/ui/button.tsx @@ -16,7 +16,7 @@ const buttonVariants = cva( 'border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:border-input dark:bg-input/30 dark:hover:bg-input/50', secondary: 'bg-secondary text-secondary-foreground hover:bg-secondary/80', ghost: 'hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50', - link: 'text-primary underline-offset-4 hover:underline' + link: 'text-primary underline-offset-4 decoration-current/20 hover:underline' }, size: { default: 'h-9 px-4 py-2 has-[>svg]:px-3', diff --git a/apps/desktop/src/components/ui/checkbox.tsx b/apps/desktop/src/components/ui/checkbox.tsx index e44111847ae..2e6b24256b2 100644 --- a/apps/desktop/src/components/ui/checkbox.tsx +++ b/apps/desktop/src/components/ui/checkbox.tsx @@ -1,7 +1,7 @@ import { Checkbox as CheckboxPrimitive } from 'radix-ui' import * as React from 'react' -import { CheckIcon } from '@/lib/icons' +import { Codicon } from '@/components/ui/codicon' import { cn } from '@/lib/utils' function Checkbox({ className, ...props }: React.ComponentProps) { @@ -18,7 +18,7 @@ function Checkbox({ className, ...props }: React.ComponentProps - + ) diff --git a/apps/desktop/src/components/ui/codicon.tsx b/apps/desktop/src/components/ui/codicon.tsx new file mode 100644 index 00000000000..b079216884c --- /dev/null +++ b/apps/desktop/src/components/ui/codicon.tsx @@ -0,0 +1,20 @@ +import type * as React from 'react' + +import { cn } from '@/lib/utils' + +export interface CodiconProps extends React.HTMLAttributes { + name: string + size?: number | string + spinning?: boolean +} + +export function Codicon({ className, name, size, spinning, style, ...props }: CodiconProps) { + return ( +