mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-06-15 09:21:36 +00:00
feat: file preview and folder tree etc
This commit is contained in:
parent
74127e0c48
commit
42db075e10
96 changed files with 7952 additions and 3277 deletions
|
|
@ -94,6 +94,85 @@ const MEDIA_MIME_TYPES = {
|
|||
const PREVIEW_HTML_EXTENSIONS = new Set(['.html', '.htm'])
|
||||
const PREVIEW_WATCH_DEBOUNCE_MS = 120
|
||||
const LOCAL_PREVIEW_HOSTS = new Set(['0.0.0.0', '127.0.0.1', '::1', '[::1]', 'localhost'])
|
||||
const TEXT_PREVIEW_MAX_BYTES = 512 * 1024
|
||||
const PREVIEW_LANGUAGE_BY_EXT = {
|
||||
'.c': 'c',
|
||||
'.conf': 'ini',
|
||||
'.cpp': 'cpp',
|
||||
'.css': 'css',
|
||||
'.csv': 'csv',
|
||||
'.go': 'go',
|
||||
'.graphql': 'graphql',
|
||||
'.h': 'c',
|
||||
'.hpp': 'cpp',
|
||||
'.html': 'html',
|
||||
'.java': 'java',
|
||||
'.js': 'javascript',
|
||||
'.json': 'json',
|
||||
'.jsx': 'jsx',
|
||||
'.kt': 'kotlin',
|
||||
'.lua': 'lua',
|
||||
'.md': 'markdown',
|
||||
'.mjs': 'javascript',
|
||||
'.py': 'python',
|
||||
'.rb': 'ruby',
|
||||
'.rs': 'rust',
|
||||
'.sh': 'shell',
|
||||
'.sql': 'sql',
|
||||
'.svg': 'xml',
|
||||
'.toml': 'toml',
|
||||
'.ts': 'typescript',
|
||||
'.tsx': 'tsx',
|
||||
'.txt': 'text',
|
||||
'.xml': 'xml',
|
||||
'.yaml': 'yaml',
|
||||
'.yml': 'yaml',
|
||||
'.zsh': 'shell'
|
||||
}
|
||||
|
||||
function looksBinary(buffer) {
|
||||
if (!buffer.length) return false
|
||||
|
||||
let suspicious = 0
|
||||
|
||||
for (const byte of buffer) {
|
||||
if (byte === 0) return true
|
||||
// Allow common whitespace controls: tab, LF, CR.
|
||||
if (byte < 32 && byte !== 9 && byte !== 10 && byte !== 13) suspicious += 1
|
||||
}
|
||||
|
||||
return suspicious / buffer.length > 0.12
|
||||
}
|
||||
|
||||
function previewFileMetadata(filePath, mimeType) {
|
||||
let byteSize = 0
|
||||
let binary = false
|
||||
|
||||
try {
|
||||
const stat = fs.statSync(filePath)
|
||||
byteSize = stat.size
|
||||
|
||||
if (!mimeType.startsWith('image/')) {
|
||||
const fd = fs.openSync(filePath, 'r')
|
||||
|
||||
try {
|
||||
const sample = Buffer.alloc(Math.min(byteSize, 4096))
|
||||
const bytesRead = fs.readSync(fd, sample, 0, sample.length, 0)
|
||||
binary = looksBinary(sample.subarray(0, bytesRead))
|
||||
} finally {
|
||||
fs.closeSync(fd)
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Metadata is best-effort; the read handlers surface hard errors later.
|
||||
}
|
||||
|
||||
return {
|
||||
binary,
|
||||
byteSize,
|
||||
large: byteSize > TEXT_PREVIEW_MAX_BYTES
|
||||
}
|
||||
}
|
||||
|
||||
app.setName(APP_NAME)
|
||||
app.setAboutPanelOptions({
|
||||
|
|
@ -106,6 +185,7 @@ let hermesProcess = null
|
|||
let connectionPromise = null
|
||||
const hermesLog = []
|
||||
const previewWatchers = new Map()
|
||||
let previewShortcutActive = false
|
||||
|
||||
function rememberLog(chunk) {
|
||||
const text = String(chunk || '').trim()
|
||||
|
|
@ -617,13 +697,26 @@ function previewFileTarget(rawTarget, baseDir) {
|
|||
}
|
||||
|
||||
const ext = path.extname(resolved).toLowerCase()
|
||||
if (!PREVIEW_HTML_EXTENSIONS.has(ext) || !fileExists(resolved)) {
|
||||
if (!fileExists(resolved)) {
|
||||
return null
|
||||
}
|
||||
|
||||
const mimeType = mimeTypeForPath(resolved)
|
||||
const metadata = previewFileMetadata(resolved, mimeType)
|
||||
const isHtml = PREVIEW_HTML_EXTENSIONS.has(ext)
|
||||
const isImage = mimeType.startsWith('image/')
|
||||
const previewKind = isHtml ? 'html' : isImage ? 'image' : metadata.binary ? 'binary' : 'text'
|
||||
|
||||
return {
|
||||
binary: metadata.binary,
|
||||
byteSize: metadata.byteSize,
|
||||
kind: 'file',
|
||||
large: metadata.large,
|
||||
label: path.basename(resolved),
|
||||
language: PREVIEW_LANGUAGE_BY_EXT[ext] || 'text',
|
||||
mimeType,
|
||||
path: resolved,
|
||||
previewKind,
|
||||
source: raw,
|
||||
url: pathToFileURL(resolved).toString()
|
||||
}
|
||||
|
|
@ -671,12 +764,11 @@ function normalizePreviewTarget(rawTarget, baseDir) {
|
|||
}
|
||||
}
|
||||
|
||||
function previewFilePathFromUrl(rawUrl) {
|
||||
function filePathFromPreviewUrl(rawUrl) {
|
||||
const filePath = fileURLToPath(String(rawUrl || ''))
|
||||
const ext = path.extname(filePath).toLowerCase()
|
||||
|
||||
if (!PREVIEW_HTML_EXTENSIONS.has(ext) || !fileExists(filePath)) {
|
||||
throw new Error('Preview file is not a readable HTML file')
|
||||
if (!fileExists(filePath)) {
|
||||
throw new Error('Preview file is not readable')
|
||||
}
|
||||
|
||||
return filePath
|
||||
|
|
@ -690,7 +782,7 @@ function sendPreviewFileChanged(payload) {
|
|||
}
|
||||
|
||||
function watchPreviewFile(rawUrl) {
|
||||
const filePath = previewFilePathFromUrl(rawUrl)
|
||||
const filePath = filePathFromPreviewUrl(rawUrl)
|
||||
const watchDir = path.dirname(filePath)
|
||||
const targetName = path.basename(filePath)
|
||||
const id = crypto.randomBytes(12).toString('base64url')
|
||||
|
|
@ -768,6 +860,13 @@ function sendBackendExit(payload) {
|
|||
webContents.send('hermes:backend-exit', payload)
|
||||
}
|
||||
|
||||
function sendClosePreviewRequested() {
|
||||
if (!mainWindow || mainWindow.isDestroyed()) return
|
||||
const { webContents } = mainWindow
|
||||
if (!webContents || webContents.isDestroyed()) return
|
||||
webContents.send('hermes:close-preview-requested')
|
||||
}
|
||||
|
||||
function getAppIconPath() {
|
||||
return APP_ICON_PATHS.find(fileExists)
|
||||
}
|
||||
|
|
@ -793,7 +892,21 @@ function buildApplicationMenu() {
|
|||
|
||||
template.push({
|
||||
label: 'File',
|
||||
submenu: [IS_MAC ? { role: 'close' } : { role: 'quit' }]
|
||||
submenu: [
|
||||
IS_MAC
|
||||
? {
|
||||
accelerator: 'CommandOrControl+W',
|
||||
click: () => {
|
||||
if (previewShortcutActive) {
|
||||
sendClosePreviewRequested()
|
||||
} else {
|
||||
mainWindow?.close()
|
||||
}
|
||||
},
|
||||
label: 'Close'
|
||||
}
|
||||
: { role: 'quit' }
|
||||
]
|
||||
})
|
||||
template.push({
|
||||
label: 'Edit',
|
||||
|
|
@ -856,6 +969,22 @@ function installDevToolsShortcut(window) {
|
|||
})
|
||||
}
|
||||
|
||||
function installPreviewShortcut(window) {
|
||||
window.webContents.on('before-input-event', (event, input) => {
|
||||
const key = String(input.key || '').toLowerCase()
|
||||
const isPreviewCloseShortcut =
|
||||
key === 'w' &&
|
||||
(IS_MAC ? input.meta : input.control) &&
|
||||
!input.alt &&
|
||||
!input.shift
|
||||
|
||||
if (!isPreviewCloseShortcut || !previewShortcutActive) return
|
||||
|
||||
event.preventDefault()
|
||||
sendClosePreviewRequested()
|
||||
})
|
||||
}
|
||||
|
||||
function installContextMenu(window) {
|
||||
window.webContents.on('context-menu', (_event, params) => {
|
||||
const template = []
|
||||
|
|
@ -1043,6 +1172,7 @@ function createWindow() {
|
|||
}
|
||||
}
|
||||
|
||||
installPreviewShortcut(mainWindow)
|
||||
installDevToolsShortcut(mainWindow)
|
||||
installContextMenu(mainWindow)
|
||||
|
||||
|
|
@ -1055,6 +1185,10 @@ function createWindow() {
|
|||
|
||||
ipcMain.handle('hermes:connection', async () => startHermes())
|
||||
|
||||
ipcMain.on('hermes:previewShortcutActive', (_event, active) => {
|
||||
previewShortcutActive = Boolean(active)
|
||||
})
|
||||
|
||||
ipcMain.handle('hermes:requestMicrophoneAccess', async () => {
|
||||
if (!IS_MAC || typeof systemPreferences.askForMediaAccess !== 'function') {
|
||||
return true
|
||||
|
|
@ -1082,11 +1216,38 @@ ipcMain.handle('hermes:notify', (_event, payload) => {
|
|||
})
|
||||
|
||||
ipcMain.handle('hermes:readFileDataUrl', async (_event, filePath) => {
|
||||
const resolved = path.resolve(String(filePath || ''))
|
||||
const input = String(filePath || '')
|
||||
const resolved = input.startsWith('file:') ? fileURLToPath(input) : path.resolve(input)
|
||||
const data = await fs.promises.readFile(resolved)
|
||||
return `data:${mimeTypeForPath(resolved)};base64,${data.toString('base64')}`
|
||||
})
|
||||
|
||||
ipcMain.handle('hermes:readFileText', async (_event, filePath) => {
|
||||
const input = String(filePath || '')
|
||||
const resolved = input.startsWith('file:') ? fileURLToPath(input) : path.resolve(input)
|
||||
const ext = path.extname(resolved).toLowerCase()
|
||||
const stat = await fs.promises.stat(resolved)
|
||||
const handle = await fs.promises.open(resolved, 'r')
|
||||
const bytesToRead = Math.min(stat.size, TEXT_PREVIEW_MAX_BYTES)
|
||||
|
||||
try {
|
||||
const buffer = Buffer.alloc(bytesToRead)
|
||||
const { bytesRead } = await handle.read(buffer, 0, bytesToRead, 0)
|
||||
|
||||
return {
|
||||
binary: looksBinary(buffer.subarray(0, Math.min(bytesRead, 4096))),
|
||||
byteSize: stat.size,
|
||||
language: PREVIEW_LANGUAGE_BY_EXT[ext] || 'text',
|
||||
mimeType: mimeTypeForPath(resolved),
|
||||
path: resolved,
|
||||
text: buffer.subarray(0, bytesRead).toString('utf8'),
|
||||
truncated: stat.size > TEXT_PREVIEW_MAX_BYTES
|
||||
}
|
||||
} finally {
|
||||
await handle.close()
|
||||
}
|
||||
})
|
||||
|
||||
ipcMain.handle('hermes:selectPaths', async (_event, options = {}) => {
|
||||
const properties = ['openFile']
|
||||
if (options?.directories) properties.push('openDirectory')
|
||||
|
|
@ -1137,6 +1298,151 @@ ipcMain.handle('hermes:stopPreviewFileWatch', (_event, id) => stopPreviewFileWat
|
|||
|
||||
ipcMain.handle('hermes:openExternal', (_event, url) => shell.openExternal(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 ignore = require('ignore')
|
||||
|
||||
// Cache one Ignore instance per .gitignore path keyed by mtime so edits
|
||||
// invalidate automatically without us having to wire a watcher.
|
||||
const gitignoreCache = new Map() // gitignorePath → { mtime: number, ig: Ignore, base: string }
|
||||
|
||||
function findGitRoot(start) {
|
||||
let dir = start
|
||||
|
||||
for (let i = 0; i < 50; i += 1) {
|
||||
try {
|
||||
if (fs.existsSync(path.join(dir, '.git'))) {return dir}
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
|
||||
const parent = path.dirname(dir)
|
||||
|
||||
if (parent === dir) {return null}
|
||||
|
||||
dir = parent
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
function getGitignoreFile(giPath) {
|
||||
let stat = null
|
||||
|
||||
try {
|
||||
stat = fs.statSync(giPath)
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
|
||||
if (!stat.isFile()) {return null}
|
||||
|
||||
const cached = gitignoreCache.get(giPath)
|
||||
|
||||
if (cached && cached.mtime === stat.mtimeMs) {return cached}
|
||||
|
||||
try {
|
||||
const entry = {
|
||||
base: path.dirname(giPath),
|
||||
ig: ignore().add(fs.readFileSync(giPath, 'utf8')),
|
||||
mtime: stat.mtimeMs
|
||||
}
|
||||
|
||||
gitignoreCache.set(giPath, entry)
|
||||
|
||||
return entry
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
function gitignoreRulesFor(root, dir) {
|
||||
const rules = []
|
||||
const rel = path.relative(root, dir)
|
||||
const dirs = [root]
|
||||
|
||||
if (rel && !rel.startsWith('..') && !path.isAbsolute(rel)) {
|
||||
const parts = rel.split(path.sep).filter(Boolean)
|
||||
let current = root
|
||||
|
||||
for (const part of parts) {
|
||||
current = path.join(current, part)
|
||||
dirs.push(current)
|
||||
}
|
||||
}
|
||||
|
||||
for (const ruleDir of dirs) {
|
||||
const rule = getGitignoreFile(path.join(ruleDir, '.gitignore'))
|
||||
|
||||
if (rule) {rules.push(rule)}
|
||||
}
|
||||
|
||||
return rules
|
||||
}
|
||||
|
||||
function ignoredByRules(rules, abs, isDirectory) {
|
||||
for (const rule of rules) {
|
||||
const rel = path.relative(rule.base, abs)
|
||||
|
||||
if (!rel || rel.startsWith('..') || path.isAbsolute(rel)) {continue}
|
||||
|
||||
const probe = `${rel.split(path.sep).join('/')}${isDirectory ? '/' : ''}`
|
||||
|
||||
if (rule.ig.ignores(probe)) {return true}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
ipcMain.handle('hermes:fs:readDir', async (_event, dirPath) => {
|
||||
const resolved = path.resolve(String(dirPath || ''))
|
||||
|
||||
if (!resolved) {
|
||||
return { entries: [], error: 'invalid-path' }
|
||||
}
|
||||
|
||||
try {
|
||||
const dirents = await fs.promises.readdir(resolved, { withFileTypes: true })
|
||||
const root = findGitRoot(resolved)
|
||||
const gitignoreRules = root ? gitignoreRulesFor(root, resolved) : []
|
||||
|
||||
const entries = dirents
|
||||
.filter(d => {
|
||||
if (FS_READDIR_HIDDEN.has(d.name)) {return false}
|
||||
|
||||
if (gitignoreRules.length > 0) {
|
||||
const abs = path.join(resolved, d.name)
|
||||
|
||||
if (ignoredByRules(gitignoreRules, abs, d.isDirectory())) {return false}
|
||||
}
|
||||
|
||||
return true
|
||||
})
|
||||
.map(d => ({ name: d.name, path: path.join(resolved, d.name), isDirectory: d.isDirectory() }))
|
||||
.sort((a, b) => Number(b.isDirectory) - Number(a.isDirectory) || a.name.localeCompare(b.name))
|
||||
|
||||
return { entries }
|
||||
} catch (error) {
|
||||
return { entries: [], error: error?.code || 'read-error' }
|
||||
}
|
||||
})
|
||||
|
||||
ipcMain.handle('hermes:fs:gitRoot', async (_event, startPath) => {
|
||||
const input = String(startPath || '')
|
||||
const resolved = input.startsWith('file:') ? fileURLToPath(input) : path.resolve(input)
|
||||
|
||||
try {
|
||||
const stat = await fs.promises.stat(resolved)
|
||||
const start = stat.isDirectory() ? resolved : path.dirname(resolved)
|
||||
|
||||
return findGitRoot(start)
|
||||
} catch {
|
||||
return findGitRoot(resolved)
|
||||
}
|
||||
})
|
||||
|
||||
app.whenReady().then(() => {
|
||||
Menu.setApplicationMenu(buildApplicationMenu())
|
||||
installMediaPermissions()
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ contextBridge.exposeInMainWorld('hermesDesktop', {
|
|||
notify: payload => ipcRenderer.invoke('hermes:notify', payload),
|
||||
requestMicrophoneAccess: () => ipcRenderer.invoke('hermes:requestMicrophoneAccess'),
|
||||
readFileDataUrl: filePath => ipcRenderer.invoke('hermes:readFileDataUrl', filePath),
|
||||
readFileText: filePath => ipcRenderer.invoke('hermes:readFileText', filePath),
|
||||
selectPaths: options => ipcRenderer.invoke('hermes:selectPaths', options),
|
||||
writeClipboard: text => ipcRenderer.invoke('hermes:writeClipboard', text),
|
||||
saveImageFromUrl: url => ipcRenderer.invoke('hermes:saveImageFromUrl', url),
|
||||
|
|
@ -21,7 +22,15 @@ contextBridge.exposeInMainWorld('hermesDesktop', {
|
|||
normalizePreviewTarget: (target, baseDir) => ipcRenderer.invoke('hermes:normalizePreviewTarget', target, baseDir),
|
||||
watchPreviewFile: url => ipcRenderer.invoke('hermes:watchPreviewFile', url),
|
||||
stopPreviewFileWatch: id => ipcRenderer.invoke('hermes:stopPreviewFileWatch', id),
|
||||
setPreviewShortcutActive: active => ipcRenderer.send('hermes:previewShortcutActive', Boolean(active)),
|
||||
openExternal: url => ipcRenderer.invoke('hermes:openExternal', url),
|
||||
readDir: dirPath => ipcRenderer.invoke('hermes:fs:readDir', dirPath),
|
||||
gitRoot: startPath => ipcRenderer.invoke('hermes:fs:gitRoot', startPath),
|
||||
onClosePreviewRequested: callback => {
|
||||
const listener = () => callback()
|
||||
ipcRenderer.on('hermes:close-preview-requested', listener)
|
||||
return () => ipcRenderer.removeListener('hermes:close-preview-requested', listener)
|
||||
},
|
||||
onPreviewFileChanged: callback => {
|
||||
const listener = (_event, payload) => callback(payload)
|
||||
ipcRenderer.on('hermes:preview-file-changed', listener)
|
||||
|
|
|
|||
|
|
@ -50,15 +50,18 @@
|
|||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"cmdk": "^1.1.1",
|
||||
"ignore": "^7.0.5",
|
||||
"liquid-glass-react": "^1.1.1",
|
||||
"lucide-react": "^0.577.0",
|
||||
"nanostores": "^1.3.0",
|
||||
"radix-ui": "^1.4.3",
|
||||
"react": "^19.2.5",
|
||||
"react-arborist": "^3.5.0",
|
||||
"react-dom": "^19.2.5",
|
||||
"react-router-dom": "^7.14.2",
|
||||
"react-shiki": "^0.9.3",
|
||||
"shiki": "^4.0.2",
|
||||
"streamdown": "^2.5.0",
|
||||
"tailwind-merge": "^3.5.0",
|
||||
"tailwindcss": "^4.2.4",
|
||||
"tw-shimmer": "^0.4.11",
|
||||
|
|
|
|||
65
apps/desktop/preview-demo.html
Normal file
65
apps/desktop/preview-demo.html
Normal file
|
|
@ -0,0 +1,65 @@
|
|||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1" />
|
||||
<title>Preview Demo</title>
|
||||
<style>
|
||||
:root { color-scheme: dark; }
|
||||
html, body { height: 100%; margin: 0; }
|
||||
body {
|
||||
font-family: ui-sans-serif, system-ui, -apple-system, "SF Pro Text", sans-serif;
|
||||
background: radial-gradient(1200px 600px at 20% 10%, #3a2a1a 0%, #1a1410 40%, #0c0a08 100%);
|
||||
color: #f5e9d7;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
padding: 2rem;
|
||||
}
|
||||
.card {
|
||||
max-width: 520px;
|
||||
padding: 2rem 2.25rem;
|
||||
border: 1px solid rgba(245,233,215,0.15);
|
||||
border-radius: 14px;
|
||||
background: rgba(20,16,12,0.6);
|
||||
backdrop-filter: blur(6px);
|
||||
box-shadow: 0 10px 40px rgba(0,0,0,0.4);
|
||||
}
|
||||
h1 {
|
||||
margin: 0 0 0.5rem;
|
||||
font-size: 1.5rem;
|
||||
letter-spacing: 0.01em;
|
||||
}
|
||||
p { margin: 0.35rem 0; opacity: 0.85; line-height: 1.5; }
|
||||
.dot {
|
||||
display: inline-block; width: 10px; height: 10px; border-radius: 50%;
|
||||
background: #f4a93c; margin-right: 0.5rem;
|
||||
box-shadow: 0 0 12px #f4a93c;
|
||||
animation: pulse 1.6s ease-in-out infinite;
|
||||
}
|
||||
@keyframes pulse {
|
||||
0%,100% { transform: scale(1); opacity: 1; }
|
||||
50% { transform: scale(1.4); opacity: 0.6; }
|
||||
}
|
||||
code {
|
||||
background: rgba(245,233,215,0.08);
|
||||
padding: 0.1rem 0.35rem;
|
||||
border-radius: 4px;
|
||||
font-size: 0.9em;
|
||||
}
|
||||
.time { font-variant-numeric: tabular-nums; opacity: 0.7; font-size: 0.85rem; margin-top: 1rem; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="card">
|
||||
<h1><span class="dot"></span>preview-demo.html</h1>
|
||||
<p>Tiny standalone HTML artifact — no server, no build step.</p>
|
||||
<p>Open directly in a browser via <code>file://</code>.</p>
|
||||
<p class="time" id="t"></p>
|
||||
</div>
|
||||
<script>
|
||||
const el = document.getElementById('t');
|
||||
const tick = () => { el.textContent = new Date().toLocaleString(); };
|
||||
tick(); setInterval(tick, 1000);
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
134
apps/desktop/src/app/agents/index.tsx
Normal file
134
apps/desktop/src/app/agents/index.tsx
Normal file
|
|
@ -0,0 +1,134 @@
|
|||
import { useStore } from '@nanostores/react'
|
||||
import { useMemo, useState } from 'react'
|
||||
|
||||
import { Activity, AlertCircle, Layers3, Loader2, type LucideIcon, RefreshCw, Sparkles } from '@/lib/icons'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { $desktopActionTasks, buildRailTasks, type RailTask, type RailTaskStatus } from '@/store/activity'
|
||||
import { $previewServerRestart } from '@/store/preview'
|
||||
import { $sessions, $workingSessionIds } from '@/store/session'
|
||||
|
||||
import { OverlayCard } from '../overlays/overlay-chrome'
|
||||
import { OverlayMain, OverlayNavItem, OverlaySidebar, OverlaySplitLayout } from '../overlays/overlay-split-layout'
|
||||
import { OverlayView } from '../overlays/overlay-view'
|
||||
|
||||
type AgentsSection = 'tree' | 'activity' | 'history'
|
||||
|
||||
interface SectionDef {
|
||||
description: string
|
||||
icon: LucideIcon
|
||||
id: AgentsSection
|
||||
label: string
|
||||
}
|
||||
|
||||
const SECTIONS: readonly SectionDef[] = [
|
||||
{ description: 'Live subagent spawn tree for the current turn', icon: Layers3, id: 'tree', label: 'Spawn tree' },
|
||||
{ description: 'Background work across sessions and the desktop', icon: Activity, id: 'activity', label: 'Activity' },
|
||||
{ description: 'Past spawn snapshots, replay, and diff', icon: RefreshCw, id: 'history', label: 'History' }
|
||||
]
|
||||
|
||||
const STATUS_TONE: Record<RailTaskStatus, string> = {
|
||||
error: 'text-destructive',
|
||||
running: 'text-foreground',
|
||||
success: 'text-emerald-500'
|
||||
}
|
||||
|
||||
const STATUS_ICON: Record<RailTaskStatus, LucideIcon> = {
|
||||
error: AlertCircle,
|
||||
running: Loader2,
|
||||
success: Sparkles
|
||||
}
|
||||
|
||||
interface AgentsViewProps {
|
||||
initialSection?: AgentsSection
|
||||
onClose: () => void
|
||||
}
|
||||
|
||||
export function AgentsView({ initialSection = 'tree', onClose }: AgentsViewProps) {
|
||||
const [section, setSection] = useState<AgentsSection>(initialSection)
|
||||
|
||||
const sessions = useStore($sessions)
|
||||
const workingSessionIds = useStore($workingSessionIds)
|
||||
const previewRestart = useStore($previewServerRestart)
|
||||
const desktopActionTasks = useStore($desktopActionTasks)
|
||||
|
||||
const activityTasks = useMemo(
|
||||
() => buildRailTasks(workingSessionIds, sessions, previewRestart, desktopActionTasks),
|
||||
[desktopActionTasks, previewRestart, sessions, workingSessionIds]
|
||||
)
|
||||
|
||||
const active = SECTIONS.find(s => s.id === section) ?? SECTIONS[0]!
|
||||
|
||||
return (
|
||||
<OverlayView closeLabel="Close agents" onClose={onClose}>
|
||||
<OverlaySplitLayout>
|
||||
<OverlaySidebar>
|
||||
{SECTIONS.map(s => (
|
||||
<OverlayNavItem
|
||||
active={s.id === section}
|
||||
icon={s.icon}
|
||||
key={s.id}
|
||||
label={s.label}
|
||||
onClick={() => setSection(s.id)}
|
||||
/>
|
||||
))}
|
||||
</OverlaySidebar>
|
||||
|
||||
<OverlayMain>
|
||||
<header className="mb-4">
|
||||
<h2 className="text-sm font-semibold text-foreground">{active.label}</h2>
|
||||
<p className="text-xs text-muted-foreground">{active.description}</p>
|
||||
</header>
|
||||
|
||||
{section === 'activity' ? <ActivityList tasks={activityTasks} /> : <SectionStub label={active.label} />}
|
||||
</OverlayMain>
|
||||
</OverlaySplitLayout>
|
||||
</OverlayView>
|
||||
)
|
||||
}
|
||||
|
||||
function ActivityList({ tasks }: { tasks: readonly RailTask[] }) {
|
||||
if (tasks.length === 0) {
|
||||
return (
|
||||
<OverlayCard className="px-3 py-4 text-sm text-muted-foreground">
|
||||
No background activity. Long-running tools, preview restarts, and parallel sessions surface here.
|
||||
</OverlayCard>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="grid min-h-0 gap-1.5 overflow-y-auto pr-1">
|
||||
{tasks.map(task => {
|
||||
const Icon = STATUS_ICON[task.status]
|
||||
|
||||
return (
|
||||
<OverlayCard className="flex items-start gap-2.5 px-3 py-2" key={task.id}>
|
||||
<Icon
|
||||
className={cn('mt-0.5 size-3.5 shrink-0', STATUS_TONE[task.status], task.status === 'running' && 'animate-spin')}
|
||||
/>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="truncate text-sm font-medium text-foreground">{task.label}</div>
|
||||
{task.detail && <div className="truncate text-xs text-muted-foreground">{task.detail}</div>}
|
||||
</div>
|
||||
</OverlayCard>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function SectionStub({ label }: { label: string }) {
|
||||
return (
|
||||
<OverlayCard className="grid place-items-center gap-3 px-6 py-12 text-center">
|
||||
<Sparkles className="size-6 text-muted-foreground/70" />
|
||||
<div className="grid gap-1">
|
||||
<p className="text-sm font-medium text-foreground">{label} — coming soon</p>
|
||||
<p className="max-w-md text-xs leading-relaxed text-muted-foreground">
|
||||
Subagent stores aren't wired into the desktop yet. Once gateway events for{' '}
|
||||
<code className="rounded bg-muted/60 px-1 py-0.5 font-mono text-[0.65rem]">subagent.spawn / progress / complete</code>{' '}
|
||||
land here, this view shows the live spawn tree, replay history, and pause/kill controls — modelled on the TUI's{' '}
|
||||
<code className="rounded bg-muted/60 px-1 py-0.5 font-mono text-[0.65rem]">/agents</code> overlay.
|
||||
</p>
|
||||
</div>
|
||||
</OverlayCard>
|
||||
)
|
||||
}
|
||||
|
|
@ -5,6 +5,7 @@ import { useNavigate } from 'react-router-dom'
|
|||
import { ZoomableImage } from '@/components/assistant-ui/zoomable-image'
|
||||
import { PageLoader } from '@/components/page-loader'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { CopyButton } from '@/components/ui/copy-button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import {
|
||||
Pagination,
|
||||
|
|
@ -17,9 +18,9 @@ import {
|
|||
} from '@/components/ui/pagination'
|
||||
import { getSessionMessages, listSessions } from '@/hermes'
|
||||
import { sessionTitle } from '@/lib/chat-runtime'
|
||||
import { Copy, ExternalLink, FileImage, FileText, FolderOpen, Layers3, Link2, RefreshCw, Search, X } from '@/lib/icons'
|
||||
import { ExternalLink, FileImage, FileText, FolderOpen, Layers3, Link2, RefreshCw, Search, X } from '@/lib/icons'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { notify, notifyError } from '@/store/notifications'
|
||||
import { notifyError } from '@/store/notifications'
|
||||
import type { SessionInfo, SessionMessage } from '@/types/hermes'
|
||||
|
||||
import { sessionRoute } from '../routes'
|
||||
|
|
@ -346,7 +347,11 @@ interface ArtifactsViewProps extends React.ComponentProps<'section'> {
|
|||
setTitlebarToolGroup?: SetTitlebarToolGroup
|
||||
}
|
||||
|
||||
export function ArtifactsView({ setStatusbarItemGroup: _setStatusbarItemGroup, setTitlebarToolGroup, ...props }: ArtifactsViewProps) {
|
||||
export function ArtifactsView({
|
||||
setStatusbarItemGroup: _setStatusbarItemGroup,
|
||||
setTitlebarToolGroup,
|
||||
...props
|
||||
}: ArtifactsViewProps) {
|
||||
const navigate = useNavigate()
|
||||
const [artifacts, setArtifacts] = useState<ArtifactRecord[] | null>(null)
|
||||
const [query, setQuery] = useState('')
|
||||
|
|
@ -469,24 +474,6 @@ export function ArtifactsView({ setStatusbarItemGroup: _setStatusbarItemGroup, s
|
|||
}
|
||||
}, [artifacts])
|
||||
|
||||
const copyArtifact = useCallback(async (value: string) => {
|
||||
try {
|
||||
if (window.hermesDesktop?.writeClipboard) {
|
||||
await window.hermesDesktop.writeClipboard(value)
|
||||
} else if (navigator.clipboard?.writeText) {
|
||||
await navigator.clipboard.writeText(value)
|
||||
}
|
||||
|
||||
notify({
|
||||
kind: 'success',
|
||||
title: 'Copied',
|
||||
message: value
|
||||
})
|
||||
} catch (err) {
|
||||
notifyError(err, 'Copy failed')
|
||||
}
|
||||
}, [])
|
||||
|
||||
const openArtifact = useCallback(async (href: string) => {
|
||||
try {
|
||||
if (window.hermesDesktop?.openExternal) {
|
||||
|
|
@ -510,10 +497,7 @@ export function ArtifactsView({ setStatusbarItemGroup: _setStatusbarItemGroup, s
|
|||
}, [])
|
||||
|
||||
return (
|
||||
<section
|
||||
{...props}
|
||||
className="flex h-full min-w-0 flex-col overflow-hidden rounded-[0.9375rem] bg-background"
|
||||
>
|
||||
<section {...props} className="flex h-full min-w-0 flex-col overflow-hidden rounded-[0.9375rem] bg-background">
|
||||
<header className={titlebarHeaderBaseClass}>
|
||||
<h2 className="pointer-events-auto text-base font-semibold leading-none tracking-tight">Artifacts</h2>
|
||||
<span className="pointer-events-auto text-xs text-muted-foreground">{counts.all} found</span>
|
||||
|
|
@ -645,7 +629,6 @@ export function ArtifactsView({ setStatusbarItemGroup: _setStatusbarItemGroup, s
|
|||
<ArtifactListRow
|
||||
artifact={artifact}
|
||||
key={artifact.id}
|
||||
onCopy={copyArtifact}
|
||||
onOpen={openArtifact}
|
||||
onOpenChat={sessionId => navigate(sessionRoute(sessionId))}
|
||||
/>
|
||||
|
|
@ -804,12 +787,11 @@ function ArtifactImageCard({ artifact, failedImage, onImageError, onOpenChat }:
|
|||
|
||||
interface ArtifactListRowProps {
|
||||
artifact: ArtifactRecord
|
||||
onCopy: (value: string) => void | Promise<void>
|
||||
onOpen: (href: string) => void | Promise<void>
|
||||
onOpenChat: (sessionId: string) => void
|
||||
}
|
||||
|
||||
function ArtifactListRow({ artifact, onCopy, onOpen, onOpenChat }: ArtifactListRowProps) {
|
||||
function ArtifactListRow({ artifact, onOpen, onOpenChat }: ArtifactListRowProps) {
|
||||
const Icon = artifact.kind === 'file' ? FileText : Link2
|
||||
|
||||
return (
|
||||
|
|
@ -852,16 +834,14 @@ function ArtifactListRow({ artifact, onCopy, onOpen, onOpenChat }: ArtifactListR
|
|||
>
|
||||
<ExternalLink className="size-3.5" />
|
||||
</Button>
|
||||
<Button
|
||||
<CopyButton
|
||||
appearance="button"
|
||||
buttonSize="icon-xs"
|
||||
className="text-muted-foreground hover:text-foreground"
|
||||
onClick={() => void onCopy(artifact.value)}
|
||||
size="icon-xs"
|
||||
title="Copy"
|
||||
type="button"
|
||||
variant="ghost"
|
||||
>
|
||||
<Copy className="size-3.5" />
|
||||
</Button>
|
||||
iconClassName="size-3.5"
|
||||
label="Copy"
|
||||
text={artifact.value}
|
||||
/>
|
||||
<Button
|
||||
className="text-muted-foreground hover:text-foreground"
|
||||
onClick={() => onOpenChat(artifact.sessionId)}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,11 @@
|
|||
import { useStore } from '@nanostores/react'
|
||||
|
||||
import { FileText, FolderOpen, ImageIcon, Link, X } from '@/lib/icons'
|
||||
import { normalizeOrLocalPreviewTarget } from '@/lib/local-preview'
|
||||
import type { ComposerAttachment } from '@/store/composer'
|
||||
import { notifyError } from '@/store/notifications'
|
||||
import { setCurrentSessionPreviewTarget } from '@/store/preview'
|
||||
import { $currentCwd } from '@/store/session'
|
||||
|
||||
export function AttachmentList({
|
||||
attachments,
|
||||
|
|
@ -9,7 +15,7 @@ export function AttachmentList({
|
|||
onRemove?: (id: string) => void
|
||||
}) {
|
||||
return (
|
||||
<div className="flex flex-wrap gap-1 px-1 pt-1">
|
||||
<div className="flex max-w-full flex-wrap gap-1.5 px-1 pt-1" data-slot="composer-attachments">
|
||||
{attachments.map(a => (
|
||||
<AttachmentPill attachment={a} key={a.id} onRemove={onRemove} />
|
||||
))}
|
||||
|
|
@ -19,21 +25,62 @@ export function AttachmentList({
|
|||
|
||||
function AttachmentPill({ attachment, onRemove }: { attachment: ComposerAttachment; onRemove?: (id: string) => void }) {
|
||||
const Icon = { folder: FolderOpen, url: Link, image: ImageIcon, file: FileText }[attachment.kind]
|
||||
const cwd = useStore($currentCwd)
|
||||
const canPreview = attachment.kind !== 'folder'
|
||||
const detail = attachment.detail && attachment.detail !== attachment.label ? attachment.detail : undefined
|
||||
|
||||
async function openPreview() {
|
||||
if (!canPreview) {
|
||||
return
|
||||
}
|
||||
|
||||
const rawTarget = attachment.path || attachment.detail || attachment.refText?.replace(/^@(file|image|url):/, '') || attachment.label || ''
|
||||
const target = rawTarget.replace(/^`|`$/g, '')
|
||||
|
||||
if (!target) {
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const preview = await normalizeOrLocalPreviewTarget(target, cwd || undefined)
|
||||
|
||||
if (!preview) {
|
||||
throw new Error(`Could not preview ${attachment.label}`)
|
||||
}
|
||||
|
||||
setCurrentSessionPreviewTarget(preview, 'manual', target)
|
||||
} catch (error) {
|
||||
notifyError(error, 'Preview unavailable')
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="group/attachment relative shrink-0" title={attachment.label}>
|
||||
{attachment.previewUrl && attachment.kind === 'image' ? (
|
||||
<img
|
||||
alt={attachment.label}
|
||||
className="size-7 rounded-md border border-border/70 object-cover"
|
||||
draggable={false}
|
||||
src={attachment.previewUrl}
|
||||
/>
|
||||
) : (
|
||||
<span className="grid size-7 place-items-center rounded-md border border-border/70 bg-muted/30 text-muted-foreground">
|
||||
<Icon className="size-3.5" />
|
||||
<div className="group/attachment relative min-w-0 shrink-0" title={attachment.path || attachment.detail || attachment.label}>
|
||||
<button
|
||||
aria-label={canPreview ? `Preview ${attachment.label}` : attachment.label}
|
||||
className="flex max-w-56 items-center gap-2 border border-border/60 bg-background/50 px-2 py-1.5 text-left shadow-[inset_0_1px_0_rgba(255,255,255,0.25)] transition-colors hover:border-primary/35 hover:bg-accent/45 disabled:cursor-default"
|
||||
disabled={!canPreview}
|
||||
onClick={() => void openPreview()}
|
||||
title={canPreview ? `Preview ${attachment.label}` : attachment.label}
|
||||
type="button"
|
||||
>
|
||||
{attachment.previewUrl && attachment.kind === 'image' ? (
|
||||
<img
|
||||
alt={attachment.label}
|
||||
className="size-8 shrink-0 border border-border/70 object-cover"
|
||||
draggable={false}
|
||||
src={attachment.previewUrl}
|
||||
/>
|
||||
) : (
|
||||
<span className="grid size-8 shrink-0 place-items-center border border-border/55 bg-muted/35 text-muted-foreground">
|
||||
<Icon className="size-3.5" />
|
||||
</span>
|
||||
)}
|
||||
<span className="min-w-0">
|
||||
<span className="block truncate text-[0.72rem] font-medium leading-4 text-foreground/90">{attachment.label}</span>
|
||||
{detail && <span className="block truncate font-mono text-[0.6rem] leading-3 text-muted-foreground/65">{detail}</span>}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
{onRemove && (
|
||||
<button
|
||||
aria-label={`Remove ${attachment.label}`}
|
||||
|
|
|
|||
|
|
@ -1,48 +0,0 @@
|
|||
import type { Unstable_TriggerAdapter, Unstable_TriggerItem } from '@assistant-ui/core'
|
||||
import { ComposerPrimitive, type Unstable_MentionDirective } from '@assistant-ui/react'
|
||||
|
||||
import { COMPLETION_DRAWER_ROW_CLASS, CompletionDrawerEmpty, ComposerCompletionDrawer } from './completion-drawer'
|
||||
|
||||
export function DirectivePopover({
|
||||
adapter,
|
||||
directive,
|
||||
loading = false
|
||||
}: {
|
||||
adapter: Unstable_TriggerAdapter
|
||||
directive: Unstable_MentionDirective
|
||||
loading?: boolean
|
||||
}) {
|
||||
return (
|
||||
<ComposerCompletionDrawer adapter={adapter} ariaLabel="Reference suggestions" char="@">
|
||||
<ComposerPrimitive.Unstable_TriggerPopover.Directive {...directive} />
|
||||
<ComposerPrimitive.Unstable_TriggerPopoverItems>
|
||||
{items => (
|
||||
<div className="grid gap-0.5 pt-0.5">
|
||||
{items.length === 0 ? (
|
||||
<CompletionDrawerEmpty title={loading ? 'Looking up...' : 'No matches.'}>
|
||||
Try <span className="font-mono text-foreground/80">@</span> for shortcuts, or paths like{' '}
|
||||
<span className="font-mono text-foreground/80">@~/Desktop</span> /{' '}
|
||||
<span className="font-mono text-foreground/80">@./src</span>.
|
||||
</CompletionDrawerEmpty>
|
||||
) : (
|
||||
items.map((item, index) => <DirectiveRow index={index} item={item} key={item.id} />)
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</ComposerPrimitive.Unstable_TriggerPopoverItems>
|
||||
</ComposerCompletionDrawer>
|
||||
)
|
||||
}
|
||||
|
||||
function DirectiveRow({ index, item }: { index: number; item: Unstable_TriggerItem }) {
|
||||
const metadata = item.metadata as { display?: string; meta?: string } | undefined
|
||||
const display = metadata?.display || item.label
|
||||
const description = metadata?.meta || item.description
|
||||
|
||||
return (
|
||||
<ComposerPrimitive.Unstable_TriggerPopoverItem className={COMPLETION_DRAWER_ROW_CLASS} index={index} item={item}>
|
||||
<span className="shrink-0 truncate font-mono font-medium leading-5 text-foreground">{display}</span>
|
||||
{description && <span className="min-w-0 truncate leading-5 text-muted-foreground/80">{description}</span>}
|
||||
</ComposerPrimitive.Unstable_TriggerPopoverItem>
|
||||
)
|
||||
}
|
||||
|
|
@ -7,6 +7,28 @@ import type { CompletionEntry, CompletionPayload } from './use-live-completion-a
|
|||
import { useLiveCompletionAdapter } from './use-live-completion-adapter'
|
||||
|
||||
const KIND_RE = /^@(file|folder|url|image|tool|git):(.*)$/
|
||||
const REF_STARTERS = new Set(['file', 'folder', 'url', 'image', 'tool', 'git'])
|
||||
|
||||
const STARTER_META: Record<string, string> = {
|
||||
file: 'Attach a file reference',
|
||||
folder: 'Attach a folder reference',
|
||||
url: 'Attach a URL reference',
|
||||
image: 'Attach an image reference',
|
||||
tool: 'Attach a tool reference',
|
||||
git: 'Attach git context'
|
||||
}
|
||||
|
||||
function starterEntries(query: string): CompletionEntry[] {
|
||||
const q = query.trim().toLowerCase()
|
||||
const kinds = Array.from(REF_STARTERS)
|
||||
const filtered = q ? kinds.filter(kind => kind.startsWith(q)) : kinds
|
||||
|
||||
return filtered.map(kind => ({
|
||||
text: `@${kind}:`,
|
||||
display: `@${kind}:`,
|
||||
meta: STARTER_META[kind] || ''
|
||||
}))
|
||||
}
|
||||
|
||||
interface AtItemMetadata extends Record<string, string> {
|
||||
icon: string
|
||||
|
|
@ -61,11 +83,13 @@ export function useAtCompletions(options: {
|
|||
|
||||
const fetcher = useCallback(
|
||||
async (query: string): Promise<CompletionPayload> => {
|
||||
const starters = starterEntries(query)
|
||||
|
||||
if (!gateway) {
|
||||
return { items: [], query }
|
||||
return { items: starters, query }
|
||||
}
|
||||
|
||||
const word = `@${query}`
|
||||
const word = REF_STARTERS.has(query) ? `@${query}:` : `@${query}`
|
||||
const params: Record<string, unknown> = { word }
|
||||
|
||||
if (sessionId) {
|
||||
|
|
@ -78,10 +102,11 @@ export function useAtCompletions(options: {
|
|||
|
||||
try {
|
||||
const result = await gateway.request<{ items?: CompletionEntry[] }>('complete.path', params)
|
||||
const items = result.items ?? []
|
||||
|
||||
return { items: result.items ?? [], query }
|
||||
return { items: items.length > 0 ? items : starters, query }
|
||||
} catch {
|
||||
return { items: [], query }
|
||||
return { items: starters, query }
|
||||
}
|
||||
},
|
||||
[gateway, sessionId, cwd]
|
||||
|
|
|
|||
|
|
@ -1,20 +1,25 @@
|
|||
import './liquid-glass-overrides.css'
|
||||
|
||||
import type { Unstable_TriggerAdapter, Unstable_TriggerItem } from '@assistant-ui/core'
|
||||
import { ComposerPrimitive, useAui, useAuiState } from '@assistant-ui/react'
|
||||
import { useStore } from '@nanostores/react'
|
||||
import LiquidGlass from 'liquid-glass-react'
|
||||
import {
|
||||
type ClipboardEvent,
|
||||
type CSSProperties,
|
||||
type FormEvent,
|
||||
type KeyboardEvent,
|
||||
type DragEvent as ReactDragEvent,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useRef,
|
||||
useState
|
||||
} from 'react'
|
||||
|
||||
import { hermesDirectiveFormatter } from '@/components/assistant-ui/directive-text'
|
||||
import { formatRefValue, hermesDirectiveFormatter } from '@/components/assistant-ui/directive-text'
|
||||
import { useMediaQuery } from '@/hooks/use-media-query'
|
||||
import { chatMessageText } from '@/lib/chat-messages'
|
||||
import { contextPath } from '@/lib/chat-runtime'
|
||||
import { DATA_IMAGE_URL_RE, dataUrlToBlob } from '@/lib/embedded-images'
|
||||
import { triggerHaptic } from '@/lib/haptics'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
|
@ -22,20 +27,27 @@ import { $composerAttachments, $composerDraft } from '@/store/composer'
|
|||
import { $messages } from '@/store/session'
|
||||
import { $threadScrolledUp } from '@/store/thread-scroll'
|
||||
|
||||
import { extractDroppedFiles } from '../hooks/use-composer-actions'
|
||||
import { type DroppedFile, extractDroppedFiles, HERMES_PATHS_MIME } from '../hooks/use-composer-actions'
|
||||
|
||||
import { AttachmentList } from './attachments'
|
||||
import { ContextMenu } from './context-menu'
|
||||
import { ComposerControls } from './controls'
|
||||
import { DirectivePopover } from './directive-popover'
|
||||
import { HelpHint } from './help-hint'
|
||||
import { useAtCompletions } from './hooks/use-at-completions'
|
||||
import { useComposerGlassTweaks } from './hooks/use-composer-glass-tweaks'
|
||||
import { useSlashCompletions } from './hooks/use-slash-completions'
|
||||
import { useVoiceConversation } from './hooks/use-voice-conversation'
|
||||
import { useVoiceRecorder } from './hooks/use-voice-recorder'
|
||||
import {
|
||||
composerHtml,
|
||||
composerPlainText,
|
||||
escapeHtml,
|
||||
placeCaretEnd,
|
||||
refChipHtml,
|
||||
RICH_INPUT_SLOT
|
||||
} from './rich-editor'
|
||||
import { SkinSlashPopover } from './skin-slash-popover'
|
||||
import { SlashPopover } from './slash-popover'
|
||||
import { ComposerTriggerPopover } from './trigger-popover'
|
||||
import type { ChatBarProps } from './types'
|
||||
import { UrlDialog } from './url-dialog'
|
||||
import { VoiceActivity, VoicePlaybackActivity } from './voice-activity'
|
||||
|
|
@ -117,6 +129,36 @@ const COMPOSER_FROST_CLASS = cn(
|
|||
'group-focus-within/composer:[-webkit-backdrop-filter:none]'
|
||||
)
|
||||
|
||||
interface TriggerState {
|
||||
kind: '@' | '/'
|
||||
query: string
|
||||
tokenLength: number
|
||||
}
|
||||
|
||||
const TRIGGER_RE = /(?:^|[\s])([@/])([^\s@/]*)$/
|
||||
|
||||
/** Caret-anchored text before the cursor, or null if the selection isn't a collapsed caret inside `editor`. */
|
||||
function textBeforeCaret(editor: HTMLDivElement): string | null {
|
||||
const sel = window.getSelection()
|
||||
const range = sel?.rangeCount ? sel.getRangeAt(0) : null
|
||||
|
||||
if (!range?.collapsed || !editor.contains(range.commonAncestorContainer)) {return null}
|
||||
|
||||
const before = range.cloneRange()
|
||||
before.selectNodeContents(editor)
|
||||
before.setEnd(range.startContainer, range.startOffset)
|
||||
|
||||
return before.toString()
|
||||
}
|
||||
|
||||
function detectTrigger(textBefore: string): TriggerState | null {
|
||||
const match = TRIGGER_RE.exec(textBefore)
|
||||
|
||||
if (!match) {return null}
|
||||
|
||||
return { kind: match[1] as '@' | '/', query: match[2], tokenLength: 1 + match[2].length }
|
||||
}
|
||||
|
||||
export function ChatBar({
|
||||
busy,
|
||||
cwd,
|
||||
|
|
@ -144,9 +186,9 @@ export function ChatBar({
|
|||
const scrolledUp = useStore($threadScrolledUp)
|
||||
|
||||
const composerRef = useRef<HTMLFormElement | null>(null)
|
||||
const editorRef = useRef<HTMLDivElement | null>(null)
|
||||
const glassShellRef = useRef<HTMLDivElement | null>(null)
|
||||
const draftRef = useRef(draft)
|
||||
const textareaRef = useRef<HTMLTextAreaElement | null>(null)
|
||||
const urlInputRef = useRef<HTMLInputElement | null>(null)
|
||||
|
||||
const [urlOpen, setUrlOpen] = useState(false)
|
||||
|
|
@ -192,7 +234,7 @@ export function ChatBar({
|
|||
|
||||
const glassTweaks = useComposerGlassTweaks()
|
||||
|
||||
const focusInput = () => window.requestAnimationFrame(() => textareaRef.current?.focus({ preventScroll: true }))
|
||||
const focusInput = () => window.requestAnimationFrame(() => editorRef.current?.focus({ preventScroll: true }))
|
||||
|
||||
useEffect(() => {
|
||||
if (!disabled) {
|
||||
|
|
@ -203,6 +245,12 @@ export function ChatBar({
|
|||
useEffect(() => {
|
||||
draftRef.current = draft
|
||||
$composerDraft.set(draft)
|
||||
|
||||
const editor = editorRef.current
|
||||
|
||||
if (editor && document.activeElement !== editor && composerPlainText(editor) !== draft) {
|
||||
editor.innerHTML = composerHtml(draft)
|
||||
}
|
||||
}, [draft])
|
||||
|
||||
useEffect(
|
||||
|
|
@ -232,7 +280,7 @@ export function ChatBar({
|
|||
return
|
||||
}
|
||||
|
||||
const wraps = (textareaRef.current?.scrollHeight ?? 0) > 42
|
||||
const wraps = (editorRef.current?.scrollHeight ?? 0) > 42
|
||||
|
||||
if (draft.includes('\n') || wraps) {
|
||||
setExpanded(true)
|
||||
|
|
@ -265,13 +313,108 @@ export function ChatBar({
|
|||
focusInput()
|
||||
}
|
||||
|
||||
const insertInlineRefs = (refs: string[]) => {
|
||||
const editor = editorRef.current
|
||||
|
||||
if (!refs.length || !editor) {
|
||||
return false
|
||||
}
|
||||
|
||||
const inline = refs.join(' ')
|
||||
const selection = window.getSelection()
|
||||
|
||||
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(/^@([^:]+):(.+)$/)
|
||||
const holder = document.createElement('span')
|
||||
holder.innerHTML = match ? refChipHtml(match[1], match[2]) : escapeHtml(ref)
|
||||
fragment.appendChild(holder.firstChild || 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)
|
||||
editor.innerHTML = composerHtml(`${current}${current && !/\s$/.test(current) ? ' ' : ''}${inline} `)
|
||||
placeCaretEnd(editor)
|
||||
}
|
||||
|
||||
const nextDraft = composerPlainText(editor)
|
||||
draftRef.current = nextDraft
|
||||
aui.composer().setText(nextDraft)
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
const droppedFileInlineRef = (candidate: DroppedFile) => {
|
||||
if (!candidate.path) {
|
||||
return null
|
||||
}
|
||||
|
||||
const kind = candidate.isDirectory ? 'folder' : 'file'
|
||||
const rel = contextPath(candidate.path, cwd || '')
|
||||
|
||||
return `@${kind}:${formatRefValue(rel)}`
|
||||
}
|
||||
|
||||
const selectSkinSlashCommand = (command: string) => {
|
||||
draftRef.current = command
|
||||
aui.composer().setText(command)
|
||||
focusInput()
|
||||
}
|
||||
|
||||
const handlePaste = (event: ClipboardEvent<HTMLTextAreaElement>) => {
|
||||
const handlePaste = (event: ClipboardEvent<HTMLDivElement>) => {
|
||||
const imageBlobs = extractClipboardImageBlobs(event.clipboardData)
|
||||
|
||||
if (imageBlobs.length > 0) {
|
||||
|
|
@ -304,32 +447,205 @@ export function ChatBar({
|
|||
return
|
||||
}
|
||||
|
||||
const trimmedText = pastedText.replace(/^[\t ]*(?:\r\n|\r|\n)+|(?:\r\n|\r|\n)+[\t ]*$/g, '')
|
||||
event.preventDefault()
|
||||
document.execCommand('insertText', false, pastedText)
|
||||
const nextDraft = composerPlainText(event.currentTarget)
|
||||
draftRef.current = nextDraft
|
||||
aui.composer().setText(nextDraft)
|
||||
}
|
||||
|
||||
const [trigger, setTrigger] = useState<TriggerState | null>(null)
|
||||
const [triggerActive, setTriggerActive] = useState(0)
|
||||
const [triggerItems, setTriggerItems] = useState<readonly Unstable_TriggerItem[]>([])
|
||||
|
||||
// Try caret-anchored detection first; fall back to whole-draft so blur/select-all
|
||||
// edge cases still surface the popover instead of silently closing it.
|
||||
const refreshTrigger = useCallback(() => {
|
||||
const editor = editorRef.current
|
||||
|
||||
if (!editor) {return}
|
||||
|
||||
const before = textBeforeCaret(editor)
|
||||
const detected = detectTrigger(before ?? composerPlainText(editor))
|
||||
|
||||
setTrigger(detected)
|
||||
setTriggerActive(0)
|
||||
}, [])
|
||||
|
||||
const handleEditorInput = (event: FormEvent<HTMLDivElement>) => {
|
||||
const editor = event.currentTarget
|
||||
|
||||
// Strip Chrome's stray <br> when the editor is otherwise empty so :empty
|
||||
// pseudo-class works for the placeholder.
|
||||
if (editor.childNodes.length === 1 && editor.firstChild?.nodeName === 'BR') {
|
||||
editor.replaceChildren()
|
||||
}
|
||||
|
||||
const nextDraft = composerPlainText(editor)
|
||||
|
||||
if (nextDraft !== draftRef.current) {
|
||||
draftRef.current = nextDraft
|
||||
aui.composer().setText(nextDraft)
|
||||
}
|
||||
|
||||
window.setTimeout(refreshTrigger, 0)
|
||||
}
|
||||
|
||||
const triggerAdapter: Unstable_TriggerAdapter | null = trigger?.kind === '@' ? at.adapter : trigger?.kind === '/' ? slash.adapter : null
|
||||
|
||||
useEffect(() => {
|
||||
if (!trigger || !triggerAdapter?.search) {
|
||||
setTriggerItems([])
|
||||
|
||||
if (trimmedText === pastedText) {
|
||||
return
|
||||
}
|
||||
|
||||
event.preventDefault()
|
||||
const textarea = event.currentTarget
|
||||
const start = textarea.selectionStart
|
||||
const end = textarea.selectionEnd
|
||||
setTriggerItems(triggerAdapter.search(trigger.query))
|
||||
}, [trigger, triggerAdapter])
|
||||
|
||||
const nextDraft = textarea.value.slice(0, start) + trimmedText + textarea.value.slice(end)
|
||||
const triggerLoading = trigger?.kind === '@' ? at.loading : trigger?.kind === '/' ? slash.loading : false
|
||||
|
||||
const cursor = start + trimmedText.length
|
||||
const closeTrigger = () => {
|
||||
setTrigger(null)
|
||||
setTriggerItems([])
|
||||
setTriggerActive(0)
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (!triggerItems.length) {
|
||||
setTriggerActive(0)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
if (triggerActive >= triggerItems.length) {
|
||||
setTriggerActive(triggerItems.length - 1)
|
||||
}
|
||||
}, [triggerActive, triggerItems.length])
|
||||
|
||||
const replaceTriggerWithChip = (item: Unstable_TriggerItem) => {
|
||||
const editor = editorRef.current
|
||||
const sel = window.getSelection()
|
||||
|
||||
if (!editor || !trigger) {
|
||||
return
|
||||
}
|
||||
|
||||
const serialized = hermesDirectiveFormatter.serialize(item)
|
||||
|
||||
const replaceDraftFallback = () => {
|
||||
const current = composerPlainText(editor)
|
||||
|
||||
const nextDraft = `${current.slice(0, Math.max(0, current.length - trigger.tokenLength))}${serialized}${
|
||||
serialized.endsWith(' ') ? '' : ' '
|
||||
}`
|
||||
|
||||
editor.innerHTML = composerHtml(nextDraft)
|
||||
placeCaretEnd(editor)
|
||||
draftRef.current = nextDraft
|
||||
aui.composer().setText(nextDraft)
|
||||
closeTrigger()
|
||||
}
|
||||
|
||||
if (!sel?.rangeCount) {
|
||||
replaceDraftFallback()
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
const range = sel.getRangeAt(0)
|
||||
const startNode = range.startContainer
|
||||
const startOffset = range.startOffset
|
||||
|
||||
if (startNode.nodeType !== Node.TEXT_NODE || startOffset < trigger.tokenLength) {
|
||||
replaceDraftFallback()
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
const replaceRange = document.createRange()
|
||||
replaceRange.setStart(startNode, startOffset - trigger.tokenLength)
|
||||
replaceRange.setEnd(startNode, startOffset)
|
||||
|
||||
const fragment = document.createDocumentFragment()
|
||||
const directiveMatch = serialized.match(/^@([^:]+):(.+)$/)
|
||||
|
||||
if (directiveMatch) {
|
||||
const holder = document.createElement('span')
|
||||
holder.innerHTML = refChipHtml(directiveMatch[1], directiveMatch[2])
|
||||
const chipNode = holder.firstChild
|
||||
|
||||
if (chipNode) {
|
||||
fragment.appendChild(chipNode)
|
||||
const space = document.createTextNode(' ')
|
||||
fragment.appendChild(space)
|
||||
|
||||
replaceRange.deleteContents()
|
||||
replaceRange.insertNode(fragment)
|
||||
|
||||
const after = document.createRange()
|
||||
after.setStart(space, 1)
|
||||
after.collapse(true)
|
||||
sel.removeAllRanges()
|
||||
sel.addRange(after)
|
||||
} else {
|
||||
replaceRange.deleteContents()
|
||||
document.execCommand('insertText', false, `${serialized} `)
|
||||
}
|
||||
} else {
|
||||
replaceRange.deleteContents()
|
||||
document.execCommand('insertText', false, serialized.endsWith(' ') ? serialized : `${serialized} `)
|
||||
}
|
||||
|
||||
const nextDraft = composerPlainText(editor)
|
||||
draftRef.current = nextDraft
|
||||
aui.composer().setText(nextDraft)
|
||||
window.requestAnimationFrame(() => {
|
||||
const current = textareaRef.current
|
||||
closeTrigger()
|
||||
}
|
||||
|
||||
const handleEditorKeyDown = (event: KeyboardEvent<HTMLDivElement>) => {
|
||||
if (trigger && triggerItems.length > 0) {
|
||||
if (event.key === 'ArrowDown') {
|
||||
event.preventDefault()
|
||||
setTriggerActive(idx => (idx + 1) % triggerItems.length)
|
||||
|
||||
if (!current) {
|
||||
return
|
||||
}
|
||||
|
||||
current.focus({ preventScroll: true })
|
||||
current.setSelectionRange(cursor, cursor)
|
||||
})
|
||||
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 === 'Enter' && !event.shiftKey) {
|
||||
event.preventDefault()
|
||||
submitDraft()
|
||||
}
|
||||
}
|
||||
|
||||
const handleEditorKeyUp = () => {
|
||||
window.setTimeout(refreshTrigger, 0)
|
||||
}
|
||||
|
||||
const dragHasAttachments = (transfer: DataTransfer | null) => {
|
||||
|
|
@ -337,6 +653,10 @@ export function ChatBar({
|
|||
return false
|
||||
}
|
||||
|
||||
if (Array.from(transfer.types || []).includes(HERMES_PATHS_MIME)) {
|
||||
return true
|
||||
}
|
||||
|
||||
if (Array.from(transfer.types || []).includes('Files')) {
|
||||
return true
|
||||
}
|
||||
|
|
@ -398,6 +718,16 @@ export function ChatBar({
|
|||
return
|
||||
}
|
||||
|
||||
if (Array.from(event.dataTransfer.types || []).includes(HERMES_PATHS_MIME)) {
|
||||
const refs = candidates.map(droppedFileInlineRef).filter((ref): ref is string => Boolean(ref))
|
||||
|
||||
if (insertInlineRefs(refs)) {
|
||||
triggerHaptic('selection')
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
void Promise.resolve(onAttachDroppedItems(candidates)).then(attached => {
|
||||
if (attached) {
|
||||
triggerHaptic('selection')
|
||||
|
|
@ -406,9 +736,44 @@ export function ChatBar({
|
|||
})
|
||||
}
|
||||
|
||||
const handleInputDragOver = (event: ReactDragEvent<HTMLDivElement>) => {
|
||||
if (!dragHasAttachments(event.dataTransfer)) {
|
||||
return
|
||||
}
|
||||
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
event.dataTransfer.dropEffect = 'copy'
|
||||
}
|
||||
|
||||
const handleInputDrop = (event: ReactDragEvent<HTMLDivElement>) => {
|
||||
if (!dragHasAttachments(event.dataTransfer)) {
|
||||
return
|
||||
}
|
||||
|
||||
const candidates = extractDroppedFiles(event.dataTransfer)
|
||||
const refs = candidates.map(droppedFileInlineRef).filter((ref): ref is string => Boolean(ref))
|
||||
|
||||
if (!refs.length) {
|
||||
return
|
||||
}
|
||||
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
resetDragState()
|
||||
|
||||
if (insertInlineRefs(refs)) {
|
||||
triggerHaptic('selection')
|
||||
}
|
||||
}
|
||||
|
||||
const clearDraft = () => {
|
||||
aui.composer().setText('')
|
||||
draftRef.current = ''
|
||||
|
||||
if (editorRef.current) {
|
||||
editorRef.current.innerHTML = ''
|
||||
}
|
||||
}
|
||||
|
||||
const submitDraft = () => {
|
||||
|
|
@ -541,19 +906,42 @@ export function ChatBar({
|
|||
)
|
||||
|
||||
const input = (
|
||||
<ComposerPrimitive.Input
|
||||
className={cn(
|
||||
'min-h-(--composer-input-min-height) max-h-(--composer-input-max-height) resize-none overflow-y-auto bg-transparent pb-1 pr-1 pt-1 leading-normal text-foreground outline-none placeholder:text-muted-foreground/80 disabled:cursor-not-allowed',
|
||||
stacked && 'pl-3',
|
||||
stacked ? 'w-full' : 'min-w-(--composer-input-inline-min-width) flex-1'
|
||||
<div className={cn('relative', stacked ? 'w-full' : 'min-w-(--composer-input-inline-min-width) flex-1')}>
|
||||
{!draft && (
|
||||
<div
|
||||
aria-hidden
|
||||
className={cn(
|
||||
'pointer-events-none absolute inset-0 pb-1 pr-1 pt-1 leading-normal text-muted-foreground/80',
|
||||
stacked && 'pl-3'
|
||||
)}
|
||||
>
|
||||
{placeholder}
|
||||
</div>
|
||||
)}
|
||||
disabled={disabled}
|
||||
onPaste={handlePaste}
|
||||
placeholder={placeholder}
|
||||
ref={textareaRef}
|
||||
rows={1}
|
||||
unstable_focusOnScrollToBottom={false}
|
||||
/>
|
||||
<div
|
||||
aria-label="Message"
|
||||
className={cn(
|
||||
'min-h-(--composer-input-min-height) max-h-(--composer-input-max-height) overflow-y-auto bg-transparent pb-1 pr-1 pt-1 leading-normal text-foreground outline-none empty:before:content-[attr(data-placeholder)] disabled:cursor-not-allowed **:data-ref-text:cursor-default',
|
||||
stacked && 'pl-3',
|
||||
stacked ? 'w-full' : 'min-w-(--composer-input-inline-min-width) flex-1'
|
||||
)}
|
||||
contentEditable={!disabled}
|
||||
data-placeholder={placeholder}
|
||||
data-slot={RICH_INPUT_SLOT}
|
||||
onBlur={() => window.setTimeout(closeTrigger, 80)}
|
||||
onDragOver={handleInputDragOver}
|
||||
onDrop={handleInputDrop}
|
||||
onInput={handleEditorInput}
|
||||
onKeyDown={handleEditorKeyDown}
|
||||
onKeyUp={handleEditorKeyUp}
|
||||
onMouseUp={refreshTrigger}
|
||||
onPaste={handlePaste}
|
||||
ref={editorRef}
|
||||
role="textbox"
|
||||
suppressContentEditableWarning
|
||||
/>
|
||||
<ComposerPrimitive.Input className="sr-only" tabIndex={-1} unstable_focusOnScrollToBottom={false} />
|
||||
</div>
|
||||
)
|
||||
|
||||
return (
|
||||
|
|
@ -581,12 +969,16 @@ export function ChatBar({
|
|||
}
|
||||
>
|
||||
{showHelpHint && <HelpHint />}
|
||||
<DirectivePopover
|
||||
adapter={at.adapter}
|
||||
directive={{ formatter: hermesDirectiveFormatter }}
|
||||
loading={at.loading}
|
||||
/>
|
||||
<SlashPopover adapter={slash.adapter} loading={slash.loading} />
|
||||
{trigger && (
|
||||
<ComposerTriggerPopover
|
||||
activeIndex={triggerActive}
|
||||
items={triggerItems}
|
||||
kind={trigger.kind}
|
||||
loading={triggerLoading}
|
||||
onHover={setTriggerActive}
|
||||
onPick={replaceTriggerWithChip}
|
||||
/>
|
||||
)}
|
||||
<SkinSlashPopover draft={draft} onSelect={selectSkinSlashCommand} />
|
||||
<div className="pointer-events-none absolute inset-0" style={{ background: glassTweaks.fadeBackground }} />
|
||||
<div className="relative w-full">
|
||||
|
|
|
|||
94
apps/desktop/src/app/chat/composer/rich-editor.ts
Normal file
94
apps/desktop/src/app/chat/composer/rich-editor.ts
Normal file
|
|
@ -0,0 +1,94 @@
|
|||
/**
|
||||
* Helpers for the contenteditable composer surface: serialize refs to chip
|
||||
* HTML, walk the DOM back to plain `@kind:value` text, and place the caret.
|
||||
*
|
||||
* Chip values are always wrapped in backticks/quotes so REF_RE stops at the
|
||||
* fence — without that, typing after a chip would get re-absorbed on the next
|
||||
* plain-text round-trip.
|
||||
*/
|
||||
import { formatRefValue } from '@/components/assistant-ui/directive-text'
|
||||
|
||||
export const RICH_INPUT_SLOT = 'composer-rich-input'
|
||||
|
||||
export const REF_RE = /@(file|folder|url|image|tool):(`[^`\n]+`|"[^"\n]+"|'[^'\n]+'|\S+)/g
|
||||
|
||||
const ESC: Record<string, string> = { '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }
|
||||
|
||||
export function escapeHtml(value: string) {
|
||||
return value.replace(/[&<>"']/g, ch => ESC[ch] || ch)
|
||||
}
|
||||
|
||||
export function unquoteRef(raw: string) {
|
||||
const head = raw[0]
|
||||
const tail = raw[raw.length - 1]
|
||||
const quoted = (head === '`' && tail === '`') || (head === '"' && tail === '"') || (head === "'" && tail === "'")
|
||||
|
||||
return quoted ? raw.slice(1, -1) : raw.replace(/[,.;!?]+$/, '')
|
||||
}
|
||||
|
||||
export function refLabel(id: string) {
|
||||
return id.split(/[\\/]/).filter(Boolean).pop() || id
|
||||
}
|
||||
|
||||
/** Always-quote variant of formatRefValue — chips need a fence even for safe values. */
|
||||
export function quoteRefValue(value: string) {
|
||||
if (!value.includes('`')) {return `\`${value}\``}
|
||||
|
||||
if (!value.includes('"')) {return `"${value}"`}
|
||||
|
||||
if (!value.includes("'")) {return `'${value}'`}
|
||||
|
||||
return formatRefValue(value)
|
||||
}
|
||||
|
||||
export function refChipHtml(kind: string, rawValue: string) {
|
||||
const id = unquoteRef(rawValue)
|
||||
const text = `@${kind}:${quoteRefValue(id)}`
|
||||
|
||||
return `<span contenteditable="false" data-ref-text="${escapeHtml(text)}" data-ref-id="${escapeHtml(id)}" data-ref-kind="${kind}" class="mx-0.5 inline-flex max-w-56 items-center gap-1 border border-primary/20 bg-primary/8 px-1.5 py-0.5 align-[0.02em] text-[0.86em] font-medium leading-none text-primary"><span class="truncate">${escapeHtml(refLabel(id))}</span></span>`
|
||||
}
|
||||
|
||||
/** Serialize a draft string into chip-HTML for the contenteditable surface. */
|
||||
export function composerHtml(text: string) {
|
||||
let cursor = 0
|
||||
let html = ''
|
||||
|
||||
REF_RE.lastIndex = 0
|
||||
|
||||
for (const match of text.matchAll(REF_RE)) {
|
||||
const index = match.index ?? 0
|
||||
html += escapeHtml(text.slice(cursor, index)).replace(/\n/g, '<br>')
|
||||
html += refChipHtml(match[1] || 'file', match[2] || '')
|
||||
cursor = index + match[0].length
|
||||
}
|
||||
|
||||
return html + escapeHtml(text.slice(cursor)).replace(/\n/g, '<br>')
|
||||
}
|
||||
|
||||
/** Walk a DOM subtree back to the plain `@kind:value` text it represents. */
|
||||
export function composerPlainText(node: Node): string {
|
||||
if (node.nodeType === Node.TEXT_NODE) {return node.textContent || ''}
|
||||
|
||||
if (node.nodeType !== Node.ELEMENT_NODE) {return ''}
|
||||
|
||||
const el = node as HTMLElement
|
||||
|
||||
if (el.dataset.refText) {return el.dataset.refText}
|
||||
|
||||
if (el.tagName === 'BR') {return '\n'}
|
||||
|
||||
const text = Array.from(node.childNodes).map(composerPlainText).join('')
|
||||
const block = el.tagName === 'DIV' || el.tagName === 'P'
|
||||
|
||||
return block && text && el.dataset.slot !== RICH_INPUT_SLOT ? `${text}\n` : text
|
||||
}
|
||||
|
||||
export function placeCaretEnd(element: HTMLElement) {
|
||||
const range = document.createRange()
|
||||
const selection = window.getSelection()
|
||||
|
||||
range.selectNodeContents(element)
|
||||
range.collapse(false)
|
||||
selection?.removeAllRanges()
|
||||
selection?.addRange(range)
|
||||
}
|
||||
|
|
@ -1,59 +0,0 @@
|
|||
import type { Unstable_DirectiveFormatter, Unstable_TriggerAdapter, Unstable_TriggerItem } from '@assistant-ui/core'
|
||||
import { ComposerPrimitive } from '@assistant-ui/react'
|
||||
|
||||
import { COMPLETION_DRAWER_ROW_CLASS, CompletionDrawerEmpty, ComposerCompletionDrawer } from './completion-drawer'
|
||||
|
||||
const slashFormatter: Unstable_DirectiveFormatter = {
|
||||
serialize(item: Unstable_TriggerItem): string {
|
||||
const metadata = item.metadata as { command?: unknown; display?: unknown } | undefined
|
||||
const command = typeof metadata?.command === 'string' ? metadata.command : null
|
||||
|
||||
if (command) {
|
||||
return command
|
||||
}
|
||||
|
||||
return `/${item.label}`
|
||||
},
|
||||
parse() {
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
export function SlashPopover({ adapter, loading }: { adapter: Unstable_TriggerAdapter; loading: boolean }) {
|
||||
return (
|
||||
<ComposerCompletionDrawer adapter={adapter} ariaLabel="Slash command suggestions" char="/">
|
||||
<ComposerPrimitive.Unstable_TriggerPopover.Directive formatter={slashFormatter} />
|
||||
<ComposerPrimitive.Unstable_TriggerPopoverItems>
|
||||
{items => (
|
||||
<div className="grid gap-0.5 pt-0.5">
|
||||
{items.length === 0 ? (
|
||||
<CompletionDrawerEmpty title={loading ? 'Looking up...' : 'No matching commands.'}>
|
||||
Try <span className="font-mono text-foreground/80">/help</span> for the desktop command list.
|
||||
</CompletionDrawerEmpty>
|
||||
) : (
|
||||
items.map((item, index) => {
|
||||
const meta = item.metadata as { command?: string; display?: string; meta?: string } | undefined
|
||||
const display = meta?.display ?? meta?.command ?? `/${item.label}`
|
||||
const description = meta?.meta || item.description
|
||||
|
||||
return (
|
||||
<ComposerPrimitive.Unstable_TriggerPopoverItem
|
||||
className={COMPLETION_DRAWER_ROW_CLASS}
|
||||
index={index}
|
||||
item={item}
|
||||
key={item.id}
|
||||
>
|
||||
<span className="shrink-0 font-mono font-medium leading-5 text-foreground">{display}</span>
|
||||
{description && (
|
||||
<span className="min-w-0 truncate leading-5 text-muted-foreground/80">{description}</span>
|
||||
)}
|
||||
</ComposerPrimitive.Unstable_TriggerPopoverItem>
|
||||
)
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</ComposerPrimitive.Unstable_TriggerPopoverItems>
|
||||
</ComposerCompletionDrawer>
|
||||
)
|
||||
}
|
||||
64
apps/desktop/src/app/chat/composer/trigger-popover.tsx
Normal file
64
apps/desktop/src/app/chat/composer/trigger-popover.tsx
Normal file
|
|
@ -0,0 +1,64 @@
|
|||
import type { Unstable_TriggerItem } from '@assistant-ui/core'
|
||||
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
import { COMPLETION_DRAWER_CLASS, COMPLETION_DRAWER_ROW_CLASS, CompletionDrawerEmpty } from './completion-drawer'
|
||||
|
||||
interface ComposerTriggerPopoverProps {
|
||||
activeIndex: number
|
||||
items: readonly Unstable_TriggerItem[]
|
||||
kind: '@' | '/'
|
||||
loading: boolean
|
||||
onHover: (index: number) => void
|
||||
onPick: (item: Unstable_TriggerItem) => void
|
||||
}
|
||||
|
||||
export function ComposerTriggerPopover({ activeIndex, items, kind, loading, onHover, onPick }: ComposerTriggerPopoverProps) {
|
||||
return (
|
||||
<div
|
||||
className={COMPLETION_DRAWER_CLASS}
|
||||
data-slot="composer-completion-drawer"
|
||||
data-state="open"
|
||||
onMouseDown={event => event.preventDefault()}
|
||||
role="listbox"
|
||||
>
|
||||
{items.length === 0 ? (
|
||||
<CompletionDrawerEmpty title={loading ? 'Looking up…' : 'No matches.'}>
|
||||
{kind === '@' ? (
|
||||
<>
|
||||
Try <span className="font-mono text-foreground/80">@file:</span> or{' '}
|
||||
<span className="font-mono text-foreground/80">@folder:</span>.
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
Try <span className="font-mono text-foreground/80">/help</span>.
|
||||
</>
|
||||
)}
|
||||
</CompletionDrawerEmpty>
|
||||
) : (
|
||||
items.map((item, index) => {
|
||||
const meta = item.metadata as { display?: string; meta?: string } | undefined
|
||||
const display = meta?.display ?? (kind === '/' ? `/${item.label}` : item.label)
|
||||
const description = meta?.meta || item.description
|
||||
|
||||
return (
|
||||
<button
|
||||
className={cn(
|
||||
COMPLETION_DRAWER_ROW_CLASS,
|
||||
index === activeIndex && 'bg-[color-mix(in_srgb,var(--dt-accent)_70%,transparent)]'
|
||||
)}
|
||||
data-highlighted={index === activeIndex ? '' : undefined}
|
||||
key={item.id}
|
||||
onClick={() => onPick(item)}
|
||||
onMouseEnter={() => onHover(index)}
|
||||
type="button"
|
||||
>
|
||||
<span className="shrink-0 truncate font-mono font-medium leading-5 text-foreground">{display}</span>
|
||||
{description && <span className="min-w-0 truncate leading-5 text-muted-foreground/80">{description}</span>}
|
||||
</button>
|
||||
)
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -31,12 +31,22 @@ function isImagePath(filePath: string): boolean {
|
|||
}
|
||||
|
||||
export interface DroppedFile {
|
||||
file: File
|
||||
/** Browser-native File handle. Absent for in-app drags (e.g. project tree). */
|
||||
file?: File
|
||||
/** Absolute filesystem path. Empty when an OS drop didn't carry one. */
|
||||
path: string
|
||||
/** True if the entry is a directory. Currently only set by in-app drags. */
|
||||
isDirectory?: boolean
|
||||
}
|
||||
|
||||
/** MIME emitted by in-app drag sources (project tree, etc.). Payload is JSON
|
||||
* `{ path: string; isDirectory?: boolean }[]`. */
|
||||
export const HERMES_PATHS_MIME = 'application/x-hermes-paths'
|
||||
|
||||
/**
|
||||
* Eagerly resolve files from a drop event into [File, path] pairs.
|
||||
* Eagerly resolve files from a drop event into [File?, path, isDirectory?]
|
||||
* triples. Internal Hermes sources (e.g. the project tree) ride on a custom
|
||||
* MIME and produce path-only entries; OS drops produce File-bearing entries.
|
||||
*
|
||||
* Must be called synchronously from inside the drop handler — `DataTransfer`
|
||||
* items are detached as soon as the handler returns, and `webUtils.getPathForFile`
|
||||
|
|
@ -44,19 +54,42 @@ export interface DroppedFile {
|
|||
*/
|
||||
export function extractDroppedFiles(transfer: DataTransfer): DroppedFile[] {
|
||||
const result: DroppedFile[] = []
|
||||
const seen = new Set<File>()
|
||||
const seenPaths = new Set<string>()
|
||||
const seenFiles = new Set<File>()
|
||||
const getPath = window.hermesDesktop?.getPathForFile
|
||||
|
||||
// In-app drags first — they carry richer metadata (isDirectory) than the
|
||||
// File-based fallback can provide, and produce no overlapping native files.
|
||||
try {
|
||||
const internalRaw = transfer.getData(HERMES_PATHS_MIME)
|
||||
|
||||
if (internalRaw) {
|
||||
const parsed = JSON.parse(internalRaw) as { path?: unknown; isDirectory?: unknown }[]
|
||||
|
||||
for (const entry of parsed) {
|
||||
if (!entry || typeof entry.path !== 'string' || !entry.path || seenPaths.has(entry.path)) {
|
||||
continue
|
||||
}
|
||||
|
||||
seenPaths.add(entry.path)
|
||||
result.push({ isDirectory: entry.isDirectory === true, path: entry.path })
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Malformed payload — fall through to native files.
|
||||
}
|
||||
|
||||
const fileList = transfer.files
|
||||
|
||||
if (fileList) {
|
||||
for (let i = 0; i < fileList.length; i += 1) {
|
||||
const file = fileList.item(i)
|
||||
|
||||
if (!file || seen.has(file)) {
|
||||
if (!file || seenFiles.has(file)) {
|
||||
continue
|
||||
}
|
||||
seen.add(file)
|
||||
|
||||
seenFiles.add(file)
|
||||
let path = ''
|
||||
|
||||
if (getPath) {
|
||||
|
|
@ -67,6 +100,14 @@ export function extractDroppedFiles(transfer: DataTransfer): DroppedFile[] {
|
|||
}
|
||||
}
|
||||
|
||||
if (path && seenPaths.has(path)) {
|
||||
continue
|
||||
}
|
||||
|
||||
if (path) {
|
||||
seenPaths.add(path)
|
||||
}
|
||||
|
||||
result.push({ file, path })
|
||||
}
|
||||
}
|
||||
|
|
@ -80,12 +121,14 @@ export function extractDroppedFiles(transfer: DataTransfer): DroppedFile[] {
|
|||
if (!item || item.kind !== 'file') {
|
||||
continue
|
||||
}
|
||||
|
||||
const file = item.getAsFile()
|
||||
|
||||
if (!file || seen.has(file)) {
|
||||
if (!file || seenFiles.has(file)) {
|
||||
continue
|
||||
}
|
||||
seen.add(file)
|
||||
|
||||
seenFiles.add(file)
|
||||
let path = ''
|
||||
|
||||
if (getPath) {
|
||||
|
|
@ -96,6 +139,14 @@ export function extractDroppedFiles(transfer: DataTransfer): DroppedFile[] {
|
|||
}
|
||||
}
|
||||
|
||||
if (path && seenPaths.has(path)) {
|
||||
continue
|
||||
}
|
||||
|
||||
if (path) {
|
||||
seenPaths.add(path)
|
||||
}
|
||||
|
||||
result.push({ file, path })
|
||||
}
|
||||
}
|
||||
|
|
@ -282,6 +333,26 @@ export function useComposerActions({ activeSessionId, currentCwd, requestGateway
|
|||
}
|
||||
}, [attachImagePath])
|
||||
|
||||
const attachContextFolderPath = useCallback(
|
||||
(folderPath: string) => {
|
||||
if (!folderPath) {return false}
|
||||
|
||||
const rel = contextPath(folderPath, currentCwd)
|
||||
|
||||
addComposerAttachment({
|
||||
id: attachmentId('folder', rel),
|
||||
kind: 'folder',
|
||||
label: pathLabel(folderPath),
|
||||
detail: rel,
|
||||
refText: `@folder:${formatRefValue(rel)}`,
|
||||
path: folderPath
|
||||
})
|
||||
|
||||
return true
|
||||
},
|
||||
[currentCwd]
|
||||
)
|
||||
|
||||
const attachDroppedItems = useCallback(
|
||||
async (candidates: DroppedFile[]) => {
|
||||
if (candidates.length === 0) {
|
||||
|
|
@ -291,9 +362,49 @@ export function useComposerActions({ activeSessionId, currentCwd, requestGateway
|
|||
let attached = false
|
||||
let lastFailure: string | null = null
|
||||
|
||||
for (const { file, path: knownPath } of candidates) {
|
||||
for (const candidate of candidates) {
|
||||
const { file, isDirectory, path: knownPath } = candidate
|
||||
|
||||
// Path-only entry (in-app drag from the file browser tree, etc.).
|
||||
if (!file) {
|
||||
if (isDirectory) {
|
||||
if (knownPath && attachContextFolderPath(knownPath)) {
|
||||
attached = true
|
||||
|
||||
continue
|
||||
}
|
||||
|
||||
lastFailure = `Could not attach folder ${knownPath || ''}`
|
||||
|
||||
continue
|
||||
}
|
||||
|
||||
if (knownPath && isImagePath(knownPath)) {
|
||||
if (await attachImagePath(knownPath)) {
|
||||
attached = true
|
||||
|
||||
continue
|
||||
}
|
||||
|
||||
lastFailure = `Could not attach ${knownPath}`
|
||||
|
||||
continue
|
||||
}
|
||||
|
||||
if (knownPath && attachContextFilePath(knownPath)) {
|
||||
attached = true
|
||||
|
||||
continue
|
||||
}
|
||||
|
||||
lastFailure = `Could not attach ${knownPath || 'file'}`
|
||||
|
||||
continue
|
||||
}
|
||||
|
||||
const fallbackPath =
|
||||
!knownPath && window.hermesDesktop?.getPathForFile ? window.hermesDesktop.getPathForFile(file) : ''
|
||||
|
||||
const filePath = knownPath || fallbackPath || ''
|
||||
const isImage = file.type.startsWith('image/') || isImagePath(file.name) || (filePath && isImagePath(filePath))
|
||||
|
||||
|
|
@ -324,7 +435,7 @@ export function useComposerActions({ activeSessionId, currentCwd, requestGateway
|
|||
|
||||
return attached
|
||||
},
|
||||
[attachContextFilePath, attachImageBlob, attachImagePath]
|
||||
[attachContextFilePath, attachContextFolderPath, attachImageBlob, attachImagePath]
|
||||
)
|
||||
|
||||
const removeAttachment = useCallback(
|
||||
|
|
@ -349,6 +460,7 @@ export function useComposerActions({ activeSessionId, currentCwd, requestGateway
|
|||
|
||||
return {
|
||||
addContextRefAttachment,
|
||||
attachContextFilePath,
|
||||
attachDroppedItems,
|
||||
attachImageBlob,
|
||||
attachImagePath,
|
||||
|
|
|
|||
|
|
@ -39,14 +39,11 @@ import {
|
|||
import type { ModelOptionsResponse } from '@/types/hermes'
|
||||
|
||||
import { routeSessionId } from '../routes'
|
||||
import type { SetStatusbarItemGroup } from '../shell/statusbar-controls'
|
||||
import { titlebarHeaderBaseClass, titlebarHeaderShadowClass } from '../shell/titlebar'
|
||||
import type { SetTitlebarToolGroup } from '../shell/titlebar-controls'
|
||||
|
||||
import { ChatBar, ChatBarFallback } from './composer'
|
||||
import type { ChatBarState } from './composer/types'
|
||||
import type { DroppedFile } from './hooks/use-composer-actions'
|
||||
import { ChatPreviewRail, ChatRightRail } from './right-rail'
|
||||
import { SessionActionsMenu } from './sidebar/session-actions-menu'
|
||||
|
||||
interface ChatViewProps extends Omit<React.ComponentProps<'div'>, 'onSubmit'> {
|
||||
|
|
@ -66,21 +63,10 @@ interface ChatViewProps extends Omit<React.ComponentProps<'div'>, 'onSubmit'> {
|
|||
onPickImages: () => void
|
||||
onRemoveAttachment: (id: string) => void
|
||||
onSubmit: (text: string) => Promise<void> | void
|
||||
onChangeCwd: (cwd: string) => void
|
||||
onBrowseCwd: () => void
|
||||
onOpenModelPicker: () => void
|
||||
onRestartPreviewServer?: (url: string, context?: string) => Promise<string>
|
||||
onSetFastMode: (enabled: boolean) => void
|
||||
onSetReasoningEffort: (effort: string) => void
|
||||
onSelectPersonality: (name: string) => void
|
||||
onOpenCommandCenterSystem: () => void
|
||||
onOpenSkills: () => void
|
||||
onThreadMessagesChange: (messages: readonly ThreadMessage[]) => void
|
||||
onEdit: (message: AppendMessage) => Promise<void>
|
||||
onReload: (parentId: string | null) => Promise<void>
|
||||
onTranscribeAudio?: (audio: Blob) => Promise<string>
|
||||
setStatusbarItemGroup?: SetStatusbarItemGroup
|
||||
setTitlebarToolGroup?: SetTitlebarToolGroup
|
||||
}
|
||||
|
||||
function threadLoadingState(
|
||||
|
|
@ -125,21 +111,10 @@ export function ChatView({
|
|||
onPickImages,
|
||||
onRemoveAttachment,
|
||||
onSubmit,
|
||||
onChangeCwd: _onChangeCwd,
|
||||
onBrowseCwd: _onBrowseCwd,
|
||||
onOpenModelPicker: _onOpenModelPicker,
|
||||
onRestartPreviewServer,
|
||||
onSetFastMode: _onSetFastMode,
|
||||
onSetReasoningEffort: _onSetReasoningEffort,
|
||||
onSelectPersonality: _onSelectPersonality,
|
||||
onOpenCommandCenterSystem,
|
||||
onOpenSkills,
|
||||
onThreadMessagesChange,
|
||||
onEdit,
|
||||
onReload,
|
||||
onTranscribeAudio,
|
||||
setStatusbarItemGroup: _setStatusbarItemGroup,
|
||||
setTitlebarToolGroup
|
||||
onTranscribeAudio
|
||||
}: ChatViewProps) {
|
||||
const location = useLocation()
|
||||
const activeSessionId = useStore($activeSessionId)
|
||||
|
|
@ -270,85 +245,75 @@ export function ChatView({
|
|||
})
|
||||
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
className={cn(
|
||||
'relative col-start-2 col-end-3 row-start-1 flex h-full min-w-0 flex-col overflow-hidden rounded-[0.9375rem] bg-transparent',
|
||||
className
|
||||
)}
|
||||
>
|
||||
<header className={cn(titlebarHeaderBaseClass, isRoutedSessionView && titlebarHeaderShadowClass)}>
|
||||
<div className="min-w-0 flex-1">
|
||||
{title && (
|
||||
<SessionActionsMenu
|
||||
align="start"
|
||||
onDelete={selectedSessionId ? onDeleteSelectedSession : undefined}
|
||||
onPin={selectedSessionId ? onToggleSelectedPin : undefined}
|
||||
pinned={selectedIsPinned}
|
||||
sessionId={selectedSessionId || activeSessionId || ''}
|
||||
sideOffset={8}
|
||||
title={title}
|
||||
<div
|
||||
className={cn(
|
||||
'relative flex h-full min-w-0 flex-col overflow-hidden rounded-[0.9375rem] bg-transparent',
|
||||
className
|
||||
)}
|
||||
>
|
||||
<header className={cn(titlebarHeaderBaseClass, isRoutedSessionView && titlebarHeaderShadowClass)}>
|
||||
<div className="min-w-0 flex-1">
|
||||
{title && (
|
||||
<SessionActionsMenu
|
||||
align="start"
|
||||
onDelete={selectedSessionId ? onDeleteSelectedSession : undefined}
|
||||
onPin={selectedSessionId ? onToggleSelectedPin : undefined}
|
||||
pinned={selectedIsPinned}
|
||||
sessionId={selectedSessionId || activeSessionId || ''}
|
||||
sideOffset={8}
|
||||
title={title}
|
||||
>
|
||||
<Button
|
||||
className="pointer-events-auto h-7 min-w-0 gap-1.5 rounded-lg px-1 py-0 text-foreground hover:bg-accent/70 data-[state=open]:bg-accent/70 [-webkit-app-region:no-drag]"
|
||||
type="button"
|
||||
variant="ghost"
|
||||
>
|
||||
<Button
|
||||
className="pointer-events-auto h-7 min-w-0 gap-1.5 rounded-lg px-1 py-0 text-foreground hover:bg-accent/70 data-[state=open]:bg-accent/70 [-webkit-app-region:no-drag]"
|
||||
type="button"
|
||||
variant="ghost"
|
||||
>
|
||||
<h2 className="max-w-[62vw] truncate text-base font-semibold leading-none tracking-tight">{title}</h2>
|
||||
<ChevronDown className="shrink-0 text-foreground/75" size={16} />
|
||||
</Button>
|
||||
</SessionActionsMenu>
|
||||
)}
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<NotificationStack />
|
||||
|
||||
<div className="relative min-h-0 max-w-full flex-1 overflow-hidden rounded-[1.0625rem] bg-transparent contain-[layout_paint]">
|
||||
<AssistantRuntimeProvider runtime={runtime}>
|
||||
<Thread
|
||||
intro={showIntro ? { personality: introPersonality, seed: introSeed } : undefined}
|
||||
loading={threadLoading}
|
||||
onBranchInNewChat={onBranchInNewChat}
|
||||
sessionKey={threadKey}
|
||||
/>
|
||||
{showChatBar && (
|
||||
<Suspense fallback={<ChatBarFallback />}>
|
||||
<ChatBar
|
||||
busy={busy}
|
||||
cwd={currentCwd}
|
||||
disabled={!gatewayOpen}
|
||||
focusKey={activeSessionId}
|
||||
gateway={gateway}
|
||||
maxRecordingSeconds={maxVoiceRecordingSeconds}
|
||||
onAddContextRef={onAddContextRef}
|
||||
onAddUrl={onAddUrl}
|
||||
onAttachDroppedItems={onAttachDroppedItems}
|
||||
onAttachImageBlob={onAttachImageBlob}
|
||||
onCancel={onCancel}
|
||||
onPasteClipboardImage={onPasteClipboardImage}
|
||||
onPickFiles={onPickFiles}
|
||||
onPickFolders={onPickFolders}
|
||||
onPickImages={onPickImages}
|
||||
onRemoveAttachment={onRemoveAttachment}
|
||||
onSubmit={onSubmit}
|
||||
onTranscribeAudio={onTranscribeAudio}
|
||||
sessionId={activeSessionId}
|
||||
state={chatBarState}
|
||||
/>
|
||||
</Suspense>
|
||||
)}
|
||||
</AssistantRuntimeProvider>
|
||||
<h2 className="max-w-[62vw] truncate text-base font-semibold leading-none tracking-tight">{title}</h2>
|
||||
<ChevronDown className="shrink-0 text-foreground/75" size={16} />
|
||||
</Button>
|
||||
</SessionActionsMenu>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<ChatPreviewRail onRestartServer={onRestartPreviewServer} setTitlebarToolGroup={setTitlebarToolGroup} />
|
||||
<ChatRightRail
|
||||
onOpenCommandCenterSystem={onOpenCommandCenterSystem}
|
||||
onOpenSkills={onOpenSkills}
|
||||
/>
|
||||
</>
|
||||
<NotificationStack />
|
||||
|
||||
<div className="relative min-h-0 max-w-full flex-1 overflow-hidden rounded-[1.0625rem] bg-transparent contain-[layout_paint]">
|
||||
<AssistantRuntimeProvider runtime={runtime}>
|
||||
<Thread
|
||||
intro={showIntro ? { personality: introPersonality, seed: introSeed } : undefined}
|
||||
loading={threadLoading}
|
||||
onBranchInNewChat={onBranchInNewChat}
|
||||
sessionKey={threadKey}
|
||||
/>
|
||||
{showChatBar && (
|
||||
<Suspense fallback={<ChatBarFallback />}>
|
||||
<ChatBar
|
||||
busy={busy}
|
||||
cwd={currentCwd}
|
||||
disabled={!gatewayOpen}
|
||||
focusKey={activeSessionId}
|
||||
gateway={gateway}
|
||||
maxRecordingSeconds={maxVoiceRecordingSeconds}
|
||||
onAddContextRef={onAddContextRef}
|
||||
onAddUrl={onAddUrl}
|
||||
onAttachDroppedItems={onAttachDroppedItems}
|
||||
onAttachImageBlob={onAttachImageBlob}
|
||||
onCancel={onCancel}
|
||||
onPasteClipboardImage={onPasteClipboardImage}
|
||||
onPickFiles={onPickFiles}
|
||||
onPickFolders={onPickFolders}
|
||||
onPickImages={onPickImages}
|
||||
onRemoveAttachment={onRemoveAttachment}
|
||||
onSubmit={onSubmit}
|
||||
onTranscribeAudio={onTranscribeAudio}
|
||||
sessionId={activeSessionId}
|
||||
state={chatBarState}
|
||||
/>
|
||||
</Suspense>
|
||||
)}
|
||||
</AssistantRuntimeProvider>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export { PREVIEW_RAIL_WIDTH, SESSION_INSPECTOR_WIDTH } from './right-rail'
|
||||
|
|
|
|||
|
|
@ -1,108 +0,0 @@
|
|||
'use client'
|
||||
|
||||
import { useMemo } from 'react'
|
||||
|
||||
import { RailActionRow } from './rail-action-row'
|
||||
import { RailSection } from './rail-section'
|
||||
import { type RailSelectOption, RailSelectRow } from './rail-select-row'
|
||||
import { RailToggleRow } from './rail-toggle-row'
|
||||
|
||||
const REASONING_OPTIONS: RailSelectOption[] = [
|
||||
{ value: 'none', label: 'Off' },
|
||||
{ value: 'minimal', label: 'Minimal' },
|
||||
{ value: 'low', label: 'Low' },
|
||||
{ value: 'medium', label: 'Medium' },
|
||||
{ value: 'high', label: 'High' },
|
||||
{ value: 'xhigh', label: 'XHigh' }
|
||||
]
|
||||
|
||||
interface AgentSectionProps {
|
||||
modelLabel: string
|
||||
providerName?: string
|
||||
reasoningEffort: string
|
||||
serviceTier: string
|
||||
fastMode: boolean
|
||||
personality: string
|
||||
personalities: string[]
|
||||
onOpenModelPicker?: () => void
|
||||
onSetReasoningEffort?: (effort: string) => void
|
||||
onSetFastMode?: (enabled: boolean) => void
|
||||
onSelectPersonality?: (name: string) => void
|
||||
}
|
||||
|
||||
export function AgentSection({
|
||||
modelLabel,
|
||||
providerName,
|
||||
reasoningEffort,
|
||||
serviceTier,
|
||||
fastMode,
|
||||
personality,
|
||||
personalities,
|
||||
onOpenModelPicker,
|
||||
onSetReasoningEffort,
|
||||
onSetFastMode,
|
||||
onSelectPersonality
|
||||
}: AgentSectionProps) {
|
||||
const activeReasoning = normalizeReasoningEffort(reasoningEffort)
|
||||
const fastEnabled = fastMode || ['fast', 'priority'].includes(serviceTier.trim().toLowerCase())
|
||||
|
||||
const activePersonality = personalityOptionKey(personality)
|
||||
|
||||
const personalityOptions = useMemo<RailSelectOption[]>(
|
||||
() =>
|
||||
[...new Set(['none', ...personalities, personality].map(personalityOptionKey).filter(Boolean))].map(name => ({
|
||||
value: name,
|
||||
label: name === 'none' ? 'None' : titleize(name)
|
||||
})),
|
||||
[personalities, personality]
|
||||
)
|
||||
|
||||
return (
|
||||
<RailSection title="Agent">
|
||||
<RailActionRow
|
||||
ariaLabel="Change model"
|
||||
onClick={onOpenModelPicker}
|
||||
primary={modelLabel || 'Hermes'}
|
||||
secondary={providerName}
|
||||
/>
|
||||
<RailSelectRow
|
||||
ariaLabel="Change reasoning effort"
|
||||
label="Reasoning"
|
||||
menuLabel="Reasoning"
|
||||
onChange={onSetReasoningEffort}
|
||||
options={REASONING_OPTIONS}
|
||||
value={activeReasoning}
|
||||
/>
|
||||
<RailToggleRow checked={fastEnabled} label="Fast mode" onChange={onSetFastMode} />
|
||||
<RailSelectRow
|
||||
ariaLabel="Change personality"
|
||||
label="Personality"
|
||||
menuLabel="Personality"
|
||||
menuWidthClass="w-52"
|
||||
onChange={onSelectPersonality}
|
||||
options={personalityOptions}
|
||||
value={activePersonality}
|
||||
/>
|
||||
</RailSection>
|
||||
)
|
||||
}
|
||||
|
||||
function personalityOptionKey(value?: string): string {
|
||||
const key = value?.trim().toLowerCase() || 'none'
|
||||
|
||||
return key === 'default' ? 'none' : key
|
||||
}
|
||||
|
||||
function normalizeReasoningEffort(value: string): string {
|
||||
const normalized = value.trim().toLowerCase()
|
||||
|
||||
return REASONING_OPTIONS.some(option => option.value === normalized) ? normalized : 'medium'
|
||||
}
|
||||
|
||||
function titleize(value: string): string {
|
||||
return value
|
||||
.replace(/[-_]+/g, ' ')
|
||||
.replace(/\s+/g, ' ')
|
||||
.trim()
|
||||
.replace(/(^|\s)\S/g, m => m.toUpperCase())
|
||||
}
|
||||
1
apps/desktop/src/app/chat/right-rail/index.ts
Normal file
1
apps/desktop/src/app/chat/right-rail/index.ts
Normal file
|
|
@ -0,0 +1 @@
|
|||
export { ChatPreviewRail, PREVIEW_RAIL_PANE_WIDTH } from './preview'
|
||||
|
|
@ -1,137 +0,0 @@
|
|||
import { useStore } from '@nanostores/react'
|
||||
import { type ReactNode, useMemo } from 'react'
|
||||
|
||||
import type { SetTitlebarToolGroup } from '@/app/shell/titlebar-controls'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { AlertCircle, Loader2, Sparkles } from '@/lib/icons'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { $desktopActionTasks, buildRailTasks, type RailTask, type RailTaskStatus } from '@/store/activity'
|
||||
import { $inspectorOpen } from '@/store/layout'
|
||||
import { $previewReloadRequest, $previewServerRestart, $previewTarget } from '@/store/preview'
|
||||
import { $sessions, $workingSessionIds } from '@/store/session'
|
||||
|
||||
import { PreviewPane } from './preview-pane'
|
||||
|
||||
export const SESSION_INSPECTOR_WIDTH = 'clamp(13.5rem, 21vw, 20rem)'
|
||||
export const PREVIEW_RAIL_WIDTH = 'clamp(18rem, 36vw, 38rem)'
|
||||
|
||||
const RAIL_TASK_LIMIT = 6
|
||||
|
||||
const TASK_ICONS: Record<RailTaskStatus, ReactNode> = {
|
||||
error: <AlertCircle className="size-3 text-destructive" />,
|
||||
running: <Loader2 className="size-3 animate-spin text-muted-foreground" />,
|
||||
success: <Sparkles className="size-3 text-emerald-500" />
|
||||
}
|
||||
|
||||
interface ChatRightRailProps {
|
||||
onOpenCommandCenterSystem: () => void
|
||||
onOpenSkills: () => void
|
||||
}
|
||||
|
||||
export function ChatRightRail({ onOpenCommandCenterSystem, onOpenSkills }: ChatRightRailProps) {
|
||||
const inspectorOpen = useStore($inspectorOpen)
|
||||
const sessions = useStore($sessions)
|
||||
const workingSessionIds = useStore($workingSessionIds)
|
||||
const previewRestart = useStore($previewServerRestart)
|
||||
const desktopActionTasks = useStore($desktopActionTasks)
|
||||
|
||||
const tasks = useMemo(
|
||||
() => buildRailTasks(workingSessionIds, sessions, previewRestart, desktopActionTasks),
|
||||
[desktopActionTasks, previewRestart, sessions, workingSessionIds]
|
||||
)
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'col-start-4 col-end-5 row-start-1 min-w-0 overflow-hidden',
|
||||
inspectorOpen && 'border-l border-border/60'
|
||||
)}
|
||||
>
|
||||
<aside
|
||||
aria-hidden={!inspectorOpen}
|
||||
className={cn(
|
||||
'relative flex h-full w-full min-w-0 flex-col overflow-hidden bg-transparent pb-2 pl-2 pr-3 pt-[calc(var(--titlebar-height)+0.25rem)] text-muted-foreground transition-none',
|
||||
inspectorOpen ? 'opacity-100' : 'pointer-events-none opacity-0'
|
||||
)}
|
||||
>
|
||||
<RailHeader onOpenAll={onOpenCommandCenterSystem} />
|
||||
|
||||
<div className="flex min-h-0 flex-1 flex-col gap-1.5 overflow-y-auto px-1.5">
|
||||
{tasks.length === 0 ? <EmptyRail /> : tasks.slice(0, RAIL_TASK_LIMIT).map(task => <RailRow key={task.id} task={task} />)}
|
||||
</div>
|
||||
|
||||
<RailFooter onOpenSkills={onOpenSkills} onOpenSystem={onOpenCommandCenterSystem} />
|
||||
</aside>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function ChatPreviewRail({
|
||||
onRestartServer,
|
||||
setTitlebarToolGroup
|
||||
}: {
|
||||
onRestartServer?: (url: string, context?: string) => Promise<string>
|
||||
setTitlebarToolGroup?: SetTitlebarToolGroup
|
||||
}) {
|
||||
const previewReloadRequest = useStore($previewReloadRequest)
|
||||
const previewTarget = useStore($previewTarget)
|
||||
|
||||
if (!previewTarget) {
|
||||
return <aside aria-hidden="true" className="col-start-3 col-end-4 row-start-1 min-w-0 overflow-hidden" />
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="pointer-events-none col-start-3 col-end-4 row-start-1 min-w-0 overflow-hidden">
|
||||
<PreviewPane
|
||||
onRestartServer={onRestartServer}
|
||||
reloadRequest={previewReloadRequest}
|
||||
setTitlebarToolGroup={setTitlebarToolGroup}
|
||||
target={previewTarget}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function RailHeader({ onOpenAll }: { onOpenAll: () => void }) {
|
||||
return (
|
||||
<div className="mb-2 flex items-center justify-between gap-1 px-1.5">
|
||||
<span className="text-[0.68rem] font-semibold uppercase tracking-[0.07em] text-muted-foreground/85">Background</span>
|
||||
<Button className="h-6 px-2 text-[0.68rem]" onClick={onOpenAll} size="sm" variant="ghost">
|
||||
View all
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function RailFooter({ onOpenSkills, onOpenSystem }: { onOpenSkills: () => void; onOpenSystem: () => void }) {
|
||||
return (
|
||||
<div className="mt-2 flex items-center gap-1 px-1.5">
|
||||
<Button className="h-6 flex-1 justify-start px-2 text-[0.68rem]" onClick={onOpenSkills} size="sm" variant="ghost">
|
||||
Agents
|
||||
</Button>
|
||||
<Button className="h-6 flex-1 justify-start px-2 text-[0.68rem]" onClick={onOpenSystem} size="sm" variant="ghost">
|
||||
System
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function EmptyRail() {
|
||||
return (
|
||||
<div className="rounded-md border border-border/45 bg-background/55 px-2.5 py-2 text-[0.68rem] text-muted-foreground/80">
|
||||
No background activity.
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function RailRow({ task }: { task: RailTask }) {
|
||||
return (
|
||||
<div className="rounded-md border border-border/45 bg-background/58 px-2 py-1.5">
|
||||
<div className="flex items-center gap-1.5">
|
||||
{TASK_ICONS[task.status]}
|
||||
<span className="truncate text-[0.72rem] font-medium text-foreground/90">{task.label}</span>
|
||||
</div>
|
||||
<div className="mt-0.5 truncate pl-4.5 text-[0.66rem] text-muted-foreground/80">{task.detail}</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -0,0 +1,80 @@
|
|||
import { atom, computed } from 'nanostores'
|
||||
|
||||
type Updater<T> = T | ((current: T) => T)
|
||||
|
||||
interface WritableStore<T> {
|
||||
get: () => T
|
||||
set: (value: T) => void
|
||||
}
|
||||
|
||||
const DEFAULT_CONSOLE_HEIGHT = 240
|
||||
|
||||
export interface ConsoleEntry {
|
||||
id: number
|
||||
level: number
|
||||
line?: number
|
||||
message: string
|
||||
source?: string
|
||||
}
|
||||
|
||||
export interface ConsoleEntryInput {
|
||||
level: number
|
||||
line?: number
|
||||
message: string
|
||||
source?: string
|
||||
}
|
||||
|
||||
function updateAtom<T>(store: WritableStore<T>, next: Updater<T>) {
|
||||
store.set(typeof next === 'function' ? (next as (current: T) => T)(store.get()) : next)
|
||||
}
|
||||
|
||||
export function createPreviewConsoleState() {
|
||||
const $height = atom(DEFAULT_CONSOLE_HEIGHT)
|
||||
const $logs = atom<ConsoleEntry[]>([])
|
||||
const $logCount = computed($logs, logs => logs.length)
|
||||
const $open = atom(false)
|
||||
const $selectedLogIds = atom<ReadonlySet<number>>(new Set())
|
||||
let nextLogId = 0
|
||||
|
||||
return {
|
||||
$height,
|
||||
$logCount,
|
||||
$logs,
|
||||
$open,
|
||||
$selectedLogIds,
|
||||
append(entry: ConsoleEntryInput) {
|
||||
$logs.set([...$logs.get().slice(-199), { ...entry, id: ++nextLogId }])
|
||||
},
|
||||
clear() {
|
||||
$logs.set([])
|
||||
$selectedLogIds.set(new Set())
|
||||
},
|
||||
clearSelection() {
|
||||
if ($selectedLogIds.get().size === 0) {return}
|
||||
|
||||
$selectedLogIds.set(new Set())
|
||||
},
|
||||
reset() {
|
||||
nextLogId = 0
|
||||
$logs.set([])
|
||||
$selectedLogIds.set(new Set())
|
||||
},
|
||||
setHeight(next: Updater<number>) {
|
||||
updateAtom($height, next)
|
||||
},
|
||||
setOpen(next: Updater<boolean>) {
|
||||
updateAtom($open, next)
|
||||
},
|
||||
toggleSelection(id: number) {
|
||||
const next = new Set($selectedLogIds.get())
|
||||
|
||||
if (!next.delete(id)) {
|
||||
next.add(id)
|
||||
}
|
||||
|
||||
$selectedLogIds.set(next)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export type PreviewConsoleState = ReturnType<typeof createPreviewConsoleState>
|
||||
44
apps/desktop/src/app/chat/right-rail/preview-pane.test.tsx
Normal file
44
apps/desktop/src/app/chat/right-rail/preview-pane.test.tsx
Normal file
|
|
@ -0,0 +1,44 @@
|
|||
import { act, cleanup, render } from '@testing-library/react'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { PreviewPane } from './preview-pane'
|
||||
|
||||
describe('PreviewPane console state', () => {
|
||||
afterEach(() => {
|
||||
cleanup()
|
||||
})
|
||||
|
||||
it('does not rebuild the pane titlebar group for streamed console logs', () => {
|
||||
const setTitlebarToolGroup = vi.fn()
|
||||
|
||||
const rendered = render(
|
||||
<PreviewPane
|
||||
onClose={vi.fn()}
|
||||
setTitlebarToolGroup={setTitlebarToolGroup}
|
||||
target={{
|
||||
kind: 'url',
|
||||
label: 'Preview',
|
||||
source: 'http://localhost:5174',
|
||||
url: 'http://localhost:5174'
|
||||
}}
|
||||
/>
|
||||
)
|
||||
|
||||
const initialCalls = setTitlebarToolGroup.mock.calls.length
|
||||
const webview = rendered.container.querySelector('webview')
|
||||
|
||||
expect(webview).toBeInstanceOf(HTMLElement)
|
||||
|
||||
act(() => {
|
||||
webview?.dispatchEvent(
|
||||
Object.assign(new Event('console-message'), {
|
||||
level: 0,
|
||||
message: 'streamed log line',
|
||||
sourceId: 'http://localhost:5174/src/main.tsx'
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
expect(setTitlebarToolGroup).toHaveBeenCalledTimes(initialCalls)
|
||||
})
|
||||
})
|
||||
File diff suppressed because it is too large
Load diff
44
apps/desktop/src/app/chat/right-rail/preview.tsx
Normal file
44
apps/desktop/src/app/chat/right-rail/preview.tsx
Normal file
|
|
@ -0,0 +1,44 @@
|
|||
import { useStore } from '@nanostores/react'
|
||||
|
||||
import type { SetTitlebarToolGroup } from '@/app/shell/titlebar-controls'
|
||||
import {
|
||||
$filePreviewTarget,
|
||||
$previewReloadRequest,
|
||||
$previewTarget,
|
||||
dismissFilePreviewTarget,
|
||||
dismissPreviewTarget
|
||||
} from '@/store/preview'
|
||||
|
||||
import { PreviewPane } from './preview-pane'
|
||||
|
||||
const INTRINSIC = 'clamp(18rem, 36vw, 38rem)'
|
||||
|
||||
// Track for <Pane id="preview">. Folds the intrinsic clamp with a min-floor
|
||||
// against --chat-min-width so the chat surface never gets squeezed below it.
|
||||
// Subtracts the project browser width so preview yields rather than crushing
|
||||
// the chat when both right-side panes are open.
|
||||
export const PREVIEW_RAIL_PANE_WIDTH = `min(${INTRINSIC}, max(0px, calc(100vw - var(--pane-chat-sidebar-width) - var(--pane-file-browser-width, 0px) - var(--chat-min-width))))`
|
||||
|
||||
interface ChatPreviewRailProps {
|
||||
onRestartServer?: (url: string, context?: string) => Promise<string>
|
||||
setTitlebarToolGroup?: SetTitlebarToolGroup
|
||||
}
|
||||
|
||||
export function ChatPreviewRail({ onRestartServer, setTitlebarToolGroup }: ChatPreviewRailProps) {
|
||||
const previewReloadRequest = useStore($previewReloadRequest)
|
||||
const filePreviewTarget = useStore($filePreviewTarget)
|
||||
const previewTarget = useStore($previewTarget)
|
||||
const target = filePreviewTarget ?? previewTarget
|
||||
|
||||
if (!target) {return null}
|
||||
|
||||
return (
|
||||
<PreviewPane
|
||||
onClose={filePreviewTarget ? dismissFilePreviewTarget : dismissPreviewTarget}
|
||||
onRestartServer={filePreviewTarget ? undefined : onRestartServer}
|
||||
reloadRequest={previewReloadRequest}
|
||||
setTitlebarToolGroup={setTitlebarToolGroup}
|
||||
target={target}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
|
@ -1,123 +0,0 @@
|
|||
'use client'
|
||||
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { FolderOpen, GitBranch, Pencil } from '@/lib/icons'
|
||||
|
||||
import { RailSection } from './rail-section'
|
||||
|
||||
interface ProjectSectionProps {
|
||||
cwd: string
|
||||
branch: string
|
||||
busy: boolean
|
||||
onChangeCwd?: (cwd: string) => void
|
||||
onBrowseCwd?: () => void
|
||||
}
|
||||
|
||||
export function ProjectSection({ cwd, branch, busy, onChangeCwd, onBrowseCwd }: ProjectSectionProps) {
|
||||
const [editing, setEditing] = useState(false)
|
||||
const [draft, setDraft] = useState(cwd)
|
||||
const inputRef = useRef<HTMLInputElement | null>(null)
|
||||
const canChange = Boolean(onChangeCwd) && !busy
|
||||
const beginEdit = () => canChange && setEditing(true)
|
||||
|
||||
useEffect(() => {
|
||||
if (!editing) {
|
||||
setDraft(cwd)
|
||||
}
|
||||
}, [cwd, editing])
|
||||
|
||||
useEffect(() => {
|
||||
if (editing) {
|
||||
inputRef.current?.focus()
|
||||
}
|
||||
}, [editing])
|
||||
|
||||
const apply = () => {
|
||||
const next = draft.trim()
|
||||
|
||||
if (next && next !== cwd) {
|
||||
onChangeCwd?.(next)
|
||||
}
|
||||
|
||||
setEditing(false)
|
||||
}
|
||||
|
||||
const branchLabel = branch.trim()
|
||||
|
||||
return (
|
||||
<RailSection title="Project">
|
||||
{editing ? (
|
||||
<Input
|
||||
className="-ml-1.5 w-[calc(100%_+_0.375rem)] h-7 bg-background px-1.5 font-mono text-[0.6875rem]"
|
||||
onBlur={apply}
|
||||
onChange={e => setDraft(e.target.value)}
|
||||
onKeyDown={e => {
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault()
|
||||
apply()
|
||||
} else if (e.key === 'Escape') {
|
||||
e.preventDefault()
|
||||
setEditing(false)
|
||||
}
|
||||
}}
|
||||
placeholder="/path/to/project"
|
||||
ref={inputRef}
|
||||
value={draft}
|
||||
/>
|
||||
) : (
|
||||
<div className="-ml-1.5 w-[calc(100%_+_0.375rem)] group grid min-w-0 grid-cols-[auto_minmax(0,1fr)_auto] items-center gap-1 rounded-md border border-transparent bg-transparent px-1.5 py-1 font-mono text-[0.6875rem] text-foreground/75 transition-[background-color,border-color,color,box-shadow] hover:border-input hover:bg-background hover:text-foreground focus-visible:border-ring focus-visible:bg-background focus-visible:outline-none focus-visible:ring-[0.1875rem] focus-visible:ring-ring/30">
|
||||
<button
|
||||
aria-label="Browse workspace folder"
|
||||
className="grid size-4 shrink-0 place-items-center rounded text-muted-foreground/60 hover:text-foreground focus-visible:outline-none disabled:cursor-default disabled:hover:text-muted-foreground/60"
|
||||
disabled={!canChange || !onBrowseCwd}
|
||||
onClick={onBrowseCwd}
|
||||
type="button"
|
||||
>
|
||||
<FolderOpen className="size-3" />
|
||||
</button>
|
||||
<button
|
||||
aria-label="Edit working directory"
|
||||
className="min-w-0 truncate text-right focus-visible:outline-none disabled:cursor-default"
|
||||
dir="rtl"
|
||||
disabled={!canChange}
|
||||
onClick={beginEdit}
|
||||
type="button"
|
||||
>
|
||||
<span dir="ltr">{compactPath(cwd) || '—'}</span>
|
||||
</button>
|
||||
{canChange && (
|
||||
<button
|
||||
aria-hidden="true"
|
||||
className="grid size-4 shrink-0 place-items-center rounded text-muted-foreground/60 opacity-60 transition-opacity hover:text-foreground group-hover:opacity-100 focus-visible:outline-none"
|
||||
onClick={beginEdit}
|
||||
tabIndex={-1}
|
||||
type="button"
|
||||
>
|
||||
<Pencil className="size-3" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{branchLabel && (
|
||||
<div className="-ml-1.5 w-[calc(100%_+_0.375rem)] flex min-w-0 items-center gap-1 rounded-md border border-transparent bg-transparent px-1.5 py-1 text-[0.6875rem] transition-[background-color,border-color,color,box-shadow] hover:border-input hover:bg-background hover:text-foreground">
|
||||
<GitBranch className="size-3 shrink-0 text-muted-foreground/60" />
|
||||
<span className="min-w-0 truncate font-mono text-foreground/75">{branchLabel}</span>
|
||||
</div>
|
||||
)}
|
||||
</RailSection>
|
||||
)
|
||||
}
|
||||
|
||||
function compactPath(path: string): string {
|
||||
if (!path) {
|
||||
return ''
|
||||
}
|
||||
|
||||
const normalized = path.replace(/\\/g, '/').replace(/\/+$/, '')
|
||||
const parts = normalized.split('/').filter(Boolean)
|
||||
|
||||
return parts.length <= 4 ? normalized || path : `.../${parts.slice(-3).join('/')}`
|
||||
}
|
||||
|
|
@ -1,33 +0,0 @@
|
|||
'use client'
|
||||
|
||||
import { ChevronDown } from '@/lib/icons'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
interface RailActionRowProps {
|
||||
primary: string
|
||||
secondary?: string
|
||||
ariaLabel?: string
|
||||
onClick?: () => void
|
||||
}
|
||||
|
||||
export function RailActionRow({ primary, secondary, ariaLabel, onClick }: RailActionRowProps) {
|
||||
return (
|
||||
<button
|
||||
aria-label={ariaLabel}
|
||||
className={cn(
|
||||
'-ml-1.5 w-[calc(100%_+_0.375rem)] group grid gap-px rounded-md border border-transparent bg-transparent px-1.5 py-1 text-left transition-[background-color,border-color,color,box-shadow] hover:border-input hover:bg-background hover:text-foreground focus-visible:border-ring focus-visible:bg-background focus-visible:outline-none focus-visible:ring-[0.1875rem] focus-visible:ring-ring/30 disabled:cursor-default disabled:hover:border-transparent disabled:hover:bg-transparent'
|
||||
)}
|
||||
disabled={!onClick}
|
||||
onClick={onClick}
|
||||
type="button"
|
||||
>
|
||||
<span className="flex items-center gap-1.5">
|
||||
<span className="min-w-0 flex-1 truncate font-mono text-[0.6875rem] text-foreground/85">{primary}</span>
|
||||
{onClick && (
|
||||
<ChevronDown className="size-3 shrink-0 text-muted-foreground/60 opacity-0 transition-opacity group-hover:opacity-100 group-focus-within:opacity-100" />
|
||||
)}
|
||||
</span>
|
||||
{secondary && <span className="truncate text-[0.625rem] text-muted-foreground/70">{secondary}</span>}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
|
@ -1,19 +0,0 @@
|
|||
import type { ReactNode } from 'react'
|
||||
|
||||
export function RailSectionLabel({ children }: { children: ReactNode }) {
|
||||
return <div className="text-xs font-medium text-muted-foreground/90">{children}</div>
|
||||
}
|
||||
|
||||
interface RailSectionProps {
|
||||
title: string
|
||||
children: ReactNode
|
||||
}
|
||||
|
||||
export function RailSection({ title, children }: RailSectionProps) {
|
||||
return (
|
||||
<section className="grid gap-1.5 py-1.5">
|
||||
<RailSectionLabel>{title}</RailSectionLabel>
|
||||
{children}
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
|
@ -1,88 +0,0 @@
|
|||
'use client'
|
||||
|
||||
import { type ReactNode, useState } from 'react'
|
||||
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuCheckboxItem,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger
|
||||
} from '@/components/ui/dropdown-menu'
|
||||
import { ChevronDown } from '@/lib/icons'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
export interface RailSelectOption {
|
||||
value: string
|
||||
label: string
|
||||
}
|
||||
|
||||
interface RailSelectRowProps {
|
||||
label: string
|
||||
menuLabel?: string
|
||||
value: string
|
||||
options: RailSelectOption[]
|
||||
valueLabel?: ReactNode
|
||||
ariaLabel?: string
|
||||
menuWidthClass?: string
|
||||
onChange?: (value: string) => void
|
||||
}
|
||||
|
||||
export function RailSelectRow({
|
||||
label,
|
||||
menuLabel,
|
||||
value,
|
||||
options,
|
||||
valueLabel,
|
||||
ariaLabel,
|
||||
menuWidthClass = 'w-44',
|
||||
onChange
|
||||
}: RailSelectRowProps) {
|
||||
const [open, setOpen] = useState(false)
|
||||
const activeOption = options.find(option => option.value === value)
|
||||
const displayLabel = valueLabel ?? activeOption?.label ?? value
|
||||
|
||||
return (
|
||||
<DropdownMenu onOpenChange={setOpen} open={open}>
|
||||
<DropdownMenuTrigger asChild disabled={!onChange}>
|
||||
<button
|
||||
aria-label={ariaLabel ?? `Change ${label.toLowerCase()}`}
|
||||
className="-ml-1.5 w-[calc(100%_+_0.375rem)] group flex items-center gap-1.5 rounded-md border border-transparent bg-transparent px-1.5 py-1 text-left transition-[background-color,border-color,color,box-shadow] hover:border-input hover:bg-background hover:text-foreground focus-visible:border-ring focus-visible:bg-background focus-visible:outline-none focus-visible:ring-[0.1875rem] focus-visible:ring-ring/30 disabled:cursor-default disabled:hover:border-transparent disabled:hover:bg-transparent"
|
||||
type="button"
|
||||
>
|
||||
<span className="min-w-0 flex-1 truncate text-[0.6875rem] text-muted-foreground group-hover:text-foreground group-focus-within:text-foreground">
|
||||
{label}
|
||||
</span>
|
||||
<span className="truncate text-[0.6875rem] text-foreground/75">{displayLabel}</span>
|
||||
{onChange && (
|
||||
<ChevronDown className="size-3 shrink-0 text-muted-foreground/60 opacity-0 transition-opacity group-hover:opacity-100 group-focus-within:opacity-100" />
|
||||
)}
|
||||
</button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent
|
||||
align="end"
|
||||
className={cn(menuWidthClass, 'border-border/70 bg-popover/95 shadow-md')}
|
||||
side="bottom"
|
||||
sideOffset={6}
|
||||
>
|
||||
<DropdownMenuLabel className="text-xs text-muted-foreground">{menuLabel ?? label}</DropdownMenuLabel>
|
||||
<DropdownMenuSeparator />
|
||||
{options.map(option => (
|
||||
<DropdownMenuCheckboxItem
|
||||
checked={value === option.value}
|
||||
className="text-xs text-muted-foreground focus:text-foreground"
|
||||
key={option.value}
|
||||
onSelect={e => {
|
||||
e.preventDefault()
|
||||
onChange?.(option.value)
|
||||
setOpen(false)
|
||||
}}
|
||||
>
|
||||
{option.label}
|
||||
</DropdownMenuCheckboxItem>
|
||||
))}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)
|
||||
}
|
||||
|
|
@ -1,36 +0,0 @@
|
|||
'use client'
|
||||
|
||||
import { useId } from 'react'
|
||||
|
||||
import { Switch } from '@/components/ui/switch'
|
||||
|
||||
interface RailToggleRowProps {
|
||||
label: string
|
||||
checked: boolean
|
||||
valueLabel?: string
|
||||
onChange?: (enabled: boolean) => void
|
||||
}
|
||||
|
||||
export function RailToggleRow({ label, checked, valueLabel, onChange }: RailToggleRowProps) {
|
||||
const id = useId()
|
||||
const disabled = !onChange
|
||||
|
||||
return (
|
||||
<label
|
||||
className={`-ml-1.5 w-[calc(100%_+_0.375rem)] group flex items-center gap-1.5 rounded-md border border-transparent bg-transparent px-1.5 py-1 text-left transition-[background-color,border-color,color,box-shadow] ${disabled ? 'cursor-default' : 'cursor-pointer hover:border-input hover:bg-background hover:text-foreground focus-within:border-ring focus-within:bg-background focus-within:ring-[0.1875rem] focus-within:ring-ring/30'}`}
|
||||
htmlFor={id}
|
||||
>
|
||||
<span className="min-w-0 flex-1 truncate text-[0.6875rem] text-muted-foreground group-hover:text-foreground group-focus-within:text-foreground">
|
||||
{label}
|
||||
</span>
|
||||
<span className="truncate text-[0.6875rem] text-foreground/75">{valueLabel ?? (checked ? 'On' : 'Off')}</span>
|
||||
<Switch
|
||||
checked={checked}
|
||||
className="h-4 w-7 [&_[data-slot=switch-thumb]]:size-3 [&_[data-slot=switch-thumb]]:data-[state=checked]:translate-x-3"
|
||||
disabled={disabled}
|
||||
id={id}
|
||||
onCheckedChange={next => onChange?.(next)}
|
||||
/>
|
||||
</label>
|
||||
)
|
||||
}
|
||||
|
|
@ -15,7 +15,7 @@ import {
|
|||
} from '@/components/ui/sidebar'
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
import type { SessionInfo } from '@/hermes'
|
||||
import { Brain, ChevronDown, Layers3, Pin, Plus, RefreshCw } from '@/lib/icons'
|
||||
import { Brain, ChevronDown, Command, Layers3, Pin, Plus, RefreshCw, Settings } from '@/lib/icons'
|
||||
import { cn } from '@/lib/utils'
|
||||
import {
|
||||
$pinnedSessionIds,
|
||||
|
|
@ -29,7 +29,7 @@ import {
|
|||
} from '@/store/layout'
|
||||
import { $selectedStoredSessionId, $sessions, $sessionsLoading, $workingSessionIds } from '@/store/session'
|
||||
|
||||
import { type AppView, ARTIFACTS_ROUTE, SKILLS_ROUTE } from '../../routes'
|
||||
import { type AppView, ARTIFACTS_ROUTE, COMMAND_CENTER_ROUTE, SETTINGS_ROUTE, SKILLS_ROUTE } from '../../routes'
|
||||
import type { SidebarNavItem } from '../../types'
|
||||
|
||||
import { SidebarSessionRow } from './session-row'
|
||||
|
|
@ -37,16 +37,21 @@ import { SidebarSessionRow } from './session-row'
|
|||
const SIDEBAR_NAV: SidebarNavItem[] = [
|
||||
{
|
||||
id: 'new-session',
|
||||
label: 'New session',
|
||||
label: 'New chat',
|
||||
icon: Plus,
|
||||
action: 'new-session'
|
||||
},
|
||||
{ id: 'command-center', label: 'Command Center', icon: Command, route: COMMAND_CENTER_ROUTE },
|
||||
{ id: 'skills', label: 'Skills', icon: Brain, route: SKILLS_ROUTE },
|
||||
{ id: 'artifacts', label: 'Artifacts', icon: Layers3, route: ARTIFACTS_ROUTE }
|
||||
{ id: 'artifacts', label: 'Artifacts', icon: Layers3, route: ARTIFACTS_ROUTE },
|
||||
{ id: 'settings', label: 'Settings', icon: Settings, route: SETTINGS_ROUTE }
|
||||
]
|
||||
|
||||
const sidebarNavItemClass =
|
||||
'flex h-7 w-full justify-start gap-2 rounded-md px-2 text-left text-sm font-medium text-muted-foreground transition-colors duration-300 ease-out hover:bg-accent hover:text-foreground hover:transition-none'
|
||||
'flex h-7 w-full justify-start gap-2 rounded-md border border-transparent px-2 text-left text-sm font-medium text-muted-foreground transition-colors duration-300 ease-out hover:border-[color-mix(in_srgb,var(--dt-border)_60%,transparent)] hover:bg-[color-mix(in_srgb,var(--dt-card)_78%,transparent)] hover:text-foreground hover:transition-none'
|
||||
|
||||
const sidebarNavItemActiveClass =
|
||||
'border-[color-mix(in_srgb,var(--dt-primary)_34%,var(--dt-border))] bg-[color-mix(in_srgb,var(--dt-primary)_10%,var(--dt-card))] text-foreground shadow-[inset_0_0.0625rem_0_color-mix(in_srgb,white_40%,transparent)]'
|
||||
|
||||
interface ChatSidebarProps extends React.ComponentProps<typeof Sidebar> {
|
||||
currentView: AppView
|
||||
|
|
@ -109,12 +114,17 @@ export function ChatSidebar({
|
|||
>
|
||||
<SidebarContent className="gap-0 overflow-hidden bg-transparent">
|
||||
<SidebarGroup className="shrink-0 pl-4 pr-2 pb-2 pt-[calc(var(--titlebar-height)+0.25rem)]">
|
||||
<SidebarGroupLabel className="h-auto px-2 pb-1 pt-1 text-[0.64rem] font-semibold uppercase tracking-[0.07em] text-muted-foreground/70">
|
||||
Workspace
|
||||
</SidebarGroupLabel>
|
||||
<SidebarGroupContent>
|
||||
<SidebarMenu className="gap-px">
|
||||
{SIDEBAR_NAV.map(item => {
|
||||
const isInteractive = Boolean(item.action) || Boolean(item.route)
|
||||
|
||||
const active =
|
||||
(item.id === 'command-center' && currentView === 'command-center') ||
|
||||
(item.id === 'settings' && currentView === 'settings') ||
|
||||
(item.id === 'skills' && currentView === 'skills') ||
|
||||
(item.id === 'artifacts' && currentView === 'artifacts')
|
||||
|
||||
|
|
@ -124,8 +134,9 @@ export function ChatSidebar({
|
|||
aria-disabled={!isInteractive}
|
||||
className={cn(
|
||||
sidebarNavItemClass,
|
||||
active && 'bg-accent text-foreground',
|
||||
!isInteractive && 'cursor-default hover:bg-transparent hover:text-muted-foreground'
|
||||
active && sidebarNavItemActiveClass,
|
||||
!isInteractive &&
|
||||
'cursor-default hover:border-transparent hover:bg-transparent hover:text-muted-foreground'
|
||||
)}
|
||||
onClick={() => onNavigate(item)}
|
||||
tooltip={item.label}
|
||||
|
|
@ -147,9 +158,9 @@ export function ChatSidebar({
|
|||
{pinsOpen && (
|
||||
<SidebarGroupContent className="flex min-h-10 shrink-0 flex-col gap-px rounded-lg pb-2 pt-1">
|
||||
{pinnedSessions.length === 0 && (
|
||||
<div className="flex min-h-8 items-center gap-2 rounded-lg px-2 text-xs text-muted-foreground opacity-50">
|
||||
<div className="flex min-h-8 items-center gap-2 rounded-lg px-2 text-xs text-muted-foreground/80">
|
||||
<Pin size={14} />
|
||||
<span>Shift+click to pin</span>
|
||||
<span>Pin important chats from the ••• menu</span>
|
||||
</div>
|
||||
)}
|
||||
{pinnedSessions.map(session => (
|
||||
|
|
@ -188,7 +199,7 @@ export function ChatSidebar({
|
|||
<RefreshCw className={cn(sessionsLoading && 'animate-spin')} />
|
||||
</Button>
|
||||
}
|
||||
label="Sessions"
|
||||
label="Recent chats"
|
||||
onToggle={() => setSidebarRecentsOpen(!recentsOpen)}
|
||||
open={recentsOpen}
|
||||
/>
|
||||
|
|
@ -270,7 +281,7 @@ function SidebarSessionSkeletons() {
|
|||
function SidebarEmptySessionState() {
|
||||
return (
|
||||
<div className="grid min-h-35 place-items-center rounded-lg px-3 text-center text-xs text-muted-foreground">
|
||||
Recent chats will appear here.
|
||||
Start a chat to build your history.
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -278,7 +289,7 @@ function SidebarEmptySessionState() {
|
|||
function SidebarAllPinnedState() {
|
||||
return (
|
||||
<div className="grid min-h-24 place-items-center rounded-lg px-3 text-center text-xs text-muted-foreground">
|
||||
Pinned sessions stay above.
|
||||
Everything here is pinned. Unpin a chat to show it in recents.
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,15 +1,18 @@
|
|||
import {
|
||||
IconArchive,
|
||||
IconBookmark,
|
||||
IconBookmarkFilled,
|
||||
IconCircleX,
|
||||
IconCopy,
|
||||
IconFileDownload,
|
||||
IconPencil
|
||||
} from '@tabler/icons-react'
|
||||
import { IconBookmark, IconBookmarkFilled, IconCircleX, IconFileDownload, IconPencil } from '@tabler/icons-react'
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import type * as React from 'react'
|
||||
import type { ReactNode } from 'react'
|
||||
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { CopyButton } from '@/components/ui/copy-button'
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle
|
||||
} from '@/components/ui/dialog'
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
|
|
@ -17,10 +20,13 @@ import {
|
|||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger
|
||||
} from '@/components/ui/dropdown-menu'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { renameSession } from '@/hermes'
|
||||
import { triggerHaptic } from '@/lib/haptics'
|
||||
import { exportSession } from '@/lib/session-export'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { notify, notifyError } from '@/store/notifications'
|
||||
import { setSessions } from '@/store/session'
|
||||
|
||||
interface SessionActionsMenuProps extends Pick<
|
||||
React.ComponentProps<typeof DropdownMenuContent>,
|
||||
|
|
@ -45,70 +51,154 @@ export function SessionActionsMenu({
|
|||
sideOffset = 6
|
||||
}: SessionActionsMenuProps) {
|
||||
const itemClass = 'gap-2.5 text-foreground focus:bg-accent [&_svg]:size-4'
|
||||
const [renameOpen, setRenameOpen] = useState(false)
|
||||
|
||||
const copyId = async () => {
|
||||
triggerHaptic('selection')
|
||||
return (
|
||||
<>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>{children}</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align={align} aria-label={`Actions for ${title}`} className="w-44" sideOffset={sideOffset}>
|
||||
<DropdownMenuItem
|
||||
className={itemClass}
|
||||
disabled={!onPin}
|
||||
onSelect={() => {
|
||||
triggerHaptic('selection')
|
||||
onPin?.()
|
||||
}}
|
||||
>
|
||||
{pinned ? <IconBookmarkFilled /> : <IconBookmark />}
|
||||
<span>{pinned ? 'Unpin' : 'Pin'}</span>
|
||||
</DropdownMenuItem>
|
||||
<CopyButton
|
||||
appearance="menu-item"
|
||||
className={itemClass}
|
||||
disabled={!sessionId}
|
||||
errorMessage="Could not copy session ID"
|
||||
label="Copy ID"
|
||||
text={sessionId}
|
||||
/>
|
||||
<DropdownMenuItem
|
||||
className={itemClass}
|
||||
disabled={!sessionId}
|
||||
onSelect={() => {
|
||||
triggerHaptic('selection')
|
||||
void exportSession(sessionId, { title })
|
||||
}}
|
||||
>
|
||||
<IconFileDownload />
|
||||
<span>Export</span>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
className={itemClass}
|
||||
disabled={!sessionId}
|
||||
onSelect={() => {
|
||||
triggerHaptic('selection')
|
||||
setRenameOpen(true)
|
||||
}}
|
||||
>
|
||||
<IconPencil />
|
||||
<span>Rename</span>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator className="my-3" />
|
||||
<DropdownMenuItem
|
||||
className={cn(itemClass, 'text-destructive focus:text-destructive')}
|
||||
disabled={!onDelete}
|
||||
onSelect={() => {
|
||||
triggerHaptic('warning')
|
||||
onDelete?.()
|
||||
}}
|
||||
variant="destructive"
|
||||
>
|
||||
<IconCircleX />
|
||||
<span>Delete</span>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
|
||||
<RenameSessionDialog currentTitle={title} onOpenChange={setRenameOpen} open={renameOpen} sessionId={sessionId} />
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
interface RenameSessionDialogProps {
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
sessionId: string
|
||||
currentTitle: string
|
||||
}
|
||||
|
||||
function RenameSessionDialog({ open, onOpenChange, sessionId, currentTitle }: RenameSessionDialogProps) {
|
||||
const [value, setValue] = useState(currentTitle)
|
||||
const [submitting, setSubmitting] = useState(false)
|
||||
const inputRef = useRef<HTMLInputElement>(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
setValue(currentTitle)
|
||||
window.setTimeout(() => inputRef.current?.select(), 0)
|
||||
}
|
||||
}, [currentTitle, open])
|
||||
|
||||
const submit = async () => {
|
||||
const next = value.trim()
|
||||
|
||||
if (!sessionId || submitting) {
|
||||
return
|
||||
}
|
||||
|
||||
if (next === currentTitle.trim()) {
|
||||
onOpenChange(false)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
setSubmitting(true)
|
||||
|
||||
try {
|
||||
await navigator.clipboard.writeText(sessionId)
|
||||
notify({ kind: 'success', message: 'Session ID copied', durationMs: 2_000 })
|
||||
const result = await renameSession(sessionId, next)
|
||||
const finalTitle = result.title || next || ''
|
||||
setSessions(prev => prev.map(s => (s.id === sessionId ? { ...s, title: finalTitle || null } : s)))
|
||||
notify({ kind: 'success', message: 'Renamed', durationMs: 2_000 })
|
||||
onOpenChange(false)
|
||||
} catch (err) {
|
||||
notifyError(err, 'Could not copy session ID')
|
||||
notifyError(err, 'Rename failed')
|
||||
} finally {
|
||||
setSubmitting(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>{children}</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align={align} aria-label={`Actions for ${title}`} className="w-44" sideOffset={sideOffset}>
|
||||
<DropdownMenuItem
|
||||
className={itemClass}
|
||||
disabled={!onPin}
|
||||
onSelect={() => {
|
||||
triggerHaptic('selection')
|
||||
onPin?.()
|
||||
<Dialog onOpenChange={onOpenChange} open={open}>
|
||||
<DialogContent className="max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Rename session</DialogTitle>
|
||||
<DialogDescription>Give this chat a memorable title. Leave empty to clear.</DialogDescription>
|
||||
</DialogHeader>
|
||||
<Input
|
||||
autoFocus
|
||||
disabled={submitting}
|
||||
onChange={event => setValue(event.target.value)}
|
||||
onKeyDown={event => {
|
||||
if (event.key === 'Enter') {
|
||||
event.preventDefault()
|
||||
void submit()
|
||||
} else if (event.key === 'Escape') {
|
||||
onOpenChange(false)
|
||||
}
|
||||
}}
|
||||
>
|
||||
{pinned ? <IconBookmarkFilled /> : <IconBookmark />}
|
||||
<span>{pinned ? 'Unpin' : 'Pin'}</span>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem className={itemClass} onSelect={() => void copyId()}>
|
||||
<IconCopy />
|
||||
<span>Copy ID</span>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
className={itemClass}
|
||||
disabled={!sessionId}
|
||||
onSelect={() => {
|
||||
triggerHaptic('selection')
|
||||
void exportSession(sessionId, { title })
|
||||
}}
|
||||
>
|
||||
<IconFileDownload />
|
||||
<span>Export</span>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem className={itemClass}>
|
||||
<IconPencil />
|
||||
<span>Rename</span>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem className={itemClass}>
|
||||
<IconArchive />
|
||||
<span>Add to project</span>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator className="my-3" />
|
||||
<DropdownMenuItem
|
||||
className={cn(itemClass, 'text-destructive focus:text-destructive')}
|
||||
disabled={!onDelete}
|
||||
onSelect={() => {
|
||||
triggerHaptic('warning')
|
||||
onDelete?.()
|
||||
}}
|
||||
variant="destructive"
|
||||
>
|
||||
<IconCircleX />
|
||||
<span>Delete</span>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
placeholder="Untitled session"
|
||||
ref={inputRef}
|
||||
value={value}
|
||||
/>
|
||||
<DialogFooter>
|
||||
<Button disabled={submitting} onClick={() => onOpenChange(false)} type="button" variant="ghost">
|
||||
Cancel
|
||||
</Button>
|
||||
<Button disabled={submitting} onClick={() => void submit()} type="button">
|
||||
Save
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
167
apps/desktop/src/app/file-browser/index.tsx
Normal file
167
apps/desktop/src/app/file-browser/index.tsx
Normal file
|
|
@ -0,0 +1,167 @@
|
|||
import { useStore } from '@nanostores/react'
|
||||
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { FadeText } from '@/components/ui/fade-text'
|
||||
import { FolderOpen, RefreshCw } from '@/lib/icons'
|
||||
import { normalizeOrLocalPreviewTarget } from '@/lib/local-preview'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { notifyError } from '@/store/notifications'
|
||||
import { setCurrentSessionPreviewTarget } from '@/store/preview'
|
||||
import { $currentCwd } from '@/store/session'
|
||||
|
||||
import { ProjectTree } from './tree'
|
||||
import { useProjectTree } from './use-project-tree'
|
||||
|
||||
const HEADER_ACTION_CLASS =
|
||||
'pointer-events-none size-6 shrink-0 opacity-0 text-muted-foreground/75 transition-opacity hover:text-foreground focus-visible:opacity-100 group-focus-within/project-header:pointer-events-auto group-focus-within/project-header:opacity-100 group-hover/project-header:pointer-events-auto group-hover/project-header:opacity-100'
|
||||
|
||||
interface FileBrowserPaneProps {
|
||||
/** Activates a file row — drops the path into the composer as `@file:` ref. */
|
||||
onActivateFile: (path: string) => void
|
||||
onChangeCwd: (path: string) => Promise<void> | void
|
||||
}
|
||||
|
||||
export function FileBrowserPane({ onActivateFile, onChangeCwd }: FileBrowserPaneProps) {
|
||||
const currentCwd = useStore($currentCwd).trim()
|
||||
const hasCwd = currentCwd.length > 0
|
||||
const cwdName = hasCwd ? (currentCwd.split(/[\\/]+/).filter(Boolean).pop() ?? currentCwd) : 'No folder selected'
|
||||
const { data, loadChildren, openState, refreshRoot, rootError, rootLoading, setNodeOpen } = useProjectTree(currentCwd)
|
||||
|
||||
const chooseFolder = async () => {
|
||||
const selected = await window.hermesDesktop?.selectPaths({
|
||||
title: 'Change working directory',
|
||||
defaultPath: hasCwd ? currentCwd : undefined,
|
||||
directories: true,
|
||||
multiple: false
|
||||
})
|
||||
|
||||
if (selected?.[0]) {
|
||||
await onChangeCwd(selected[0])
|
||||
}
|
||||
}
|
||||
|
||||
const previewFile = async (path: string) => {
|
||||
try {
|
||||
const preview = await normalizeOrLocalPreviewTarget(path, currentCwd || undefined)
|
||||
|
||||
if (!preview) {
|
||||
throw new Error(`Could not preview ${path}`)
|
||||
}
|
||||
|
||||
setCurrentSessionPreviewTarget(preview, 'file-browser', path)
|
||||
} catch (error) {
|
||||
notifyError(error, 'Preview unavailable')
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<aside
|
||||
aria-label="File browser"
|
||||
className="relative flex h-full w-full min-w-0 flex-col overflow-hidden border-l border-border/60 bg-[color-mix(in_srgb,var(--dt-sidebar-bg)_94%,transparent)] pt-[calc(var(--titlebar-height)-0.625rem)] text-muted-foreground [backdrop-filter:blur(1.5rem)_saturate(1.08)]"
|
||||
>
|
||||
<header className="group/project-header shrink-0 pl-4 pr-2 pb-1 pt-0">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<FadeText
|
||||
className="flex-1 px-2 pb-1 pt-1 text-[0.64rem] font-semibold uppercase tracking-[0.07em] text-muted-foreground/70"
|
||||
title={hasCwd ? currentCwd : 'No folder selected'}
|
||||
>
|
||||
{cwdName}
|
||||
</FadeText>
|
||||
<Button
|
||||
aria-label="Change working directory"
|
||||
className={HEADER_ACTION_CLASS}
|
||||
onClick={() => void chooseFolder()}
|
||||
size="icon"
|
||||
title="Change working directory"
|
||||
variant="ghost"
|
||||
>
|
||||
<FolderOpen className="size-3.5" />
|
||||
</Button>
|
||||
<Button
|
||||
aria-label="Refresh tree"
|
||||
className={HEADER_ACTION_CLASS}
|
||||
disabled={!hasCwd || rootLoading}
|
||||
onClick={() => void refreshRoot()}
|
||||
size="icon"
|
||||
title="Refresh tree"
|
||||
variant="ghost"
|
||||
>
|
||||
<RefreshCw className={cn('size-3.5', rootLoading && 'animate-spin')} />
|
||||
</Button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<FileTreeBody
|
||||
cwd={currentCwd}
|
||||
data={data}
|
||||
error={rootError}
|
||||
loading={rootLoading}
|
||||
onActivateFile={onActivateFile}
|
||||
onLoadChildren={loadChildren}
|
||||
onNodeOpenChange={setNodeOpen}
|
||||
onPreviewFile={previewFile}
|
||||
openState={openState}
|
||||
/>
|
||||
</aside>
|
||||
)
|
||||
}
|
||||
|
||||
interface FileTreeBodyProps {
|
||||
cwd: string
|
||||
data: ReturnType<typeof useProjectTree>['data']
|
||||
error: string | null
|
||||
loading: boolean
|
||||
onActivateFile: (path: string) => void
|
||||
onLoadChildren: (id: string) => void | Promise<void>
|
||||
onNodeOpenChange: (id: string, open: boolean) => void
|
||||
onPreviewFile?: (path: string) => void
|
||||
openState: ReturnType<typeof useProjectTree>['openState']
|
||||
}
|
||||
|
||||
function FileTreeBody({
|
||||
cwd,
|
||||
data,
|
||||
error,
|
||||
loading,
|
||||
onActivateFile,
|
||||
onLoadChildren,
|
||||
onNodeOpenChange,
|
||||
onPreviewFile,
|
||||
openState
|
||||
}: FileTreeBodyProps) {
|
||||
if (!cwd) {
|
||||
return <EmptyState body="Set a working directory from the status bar to browse files." title="No project" />
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return <EmptyState body={`Could not read this folder (${error}).`} title="Unreadable" />
|
||||
}
|
||||
|
||||
if (loading && data.length === 0) {
|
||||
return <EmptyState body="Reading project…" title="Loading" />
|
||||
}
|
||||
|
||||
if (data.length === 0) {
|
||||
return <EmptyState body="This folder is empty." title="Empty" />
|
||||
}
|
||||
|
||||
return (
|
||||
<ProjectTree
|
||||
data={data}
|
||||
onActivateFile={onActivateFile}
|
||||
onLoadChildren={onLoadChildren}
|
||||
onNodeOpenChange={onNodeOpenChange}
|
||||
onPreviewFile={onPreviewFile}
|
||||
openState={openState}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function EmptyState({ body, title }: { body: string; title: string }) {
|
||||
return (
|
||||
<div className="flex min-h-0 flex-1 flex-col items-center justify-center gap-1 px-4 text-center">
|
||||
<div className="text-[0.7rem] font-semibold uppercase tracking-[0.07em] text-muted-foreground/75">{title}</div>
|
||||
<div className="text-[0.68rem] leading-relaxed text-muted-foreground/65">{body}</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
143
apps/desktop/src/app/file-browser/ipc.ts
Normal file
143
apps/desktop/src/app/file-browser/ipc.ts
Normal file
|
|
@ -0,0 +1,143 @@
|
|||
import ignore from 'ignore'
|
||||
|
||||
import type { HermesReadDirEntry, HermesReadDirResult } from '@/global'
|
||||
|
||||
export type ProjectTreeEntry = HermesReadDirEntry
|
||||
|
||||
interface GitignoreRule {
|
||||
base: string
|
||||
ig: ReturnType<typeof ignore>
|
||||
}
|
||||
|
||||
const gitRootCache = new Map<string, Promise<string | null>>()
|
||||
const gitignoreCache = new Map<string, Promise<GitignoreRule | null>>()
|
||||
|
||||
function decodeDataUrl(dataUrl: string) {
|
||||
const match = dataUrl.match(/^data:[^,]*,(.*)$/)
|
||||
const data = match?.[1] || ''
|
||||
const isBase64 = dataUrl.slice(0, dataUrl.indexOf(',')).includes(';base64')
|
||||
|
||||
if (!isBase64) {return decodeURIComponent(data)}
|
||||
|
||||
const bytes = Uint8Array.from(atob(data), ch => ch.charCodeAt(0))
|
||||
|
||||
return new TextDecoder().decode(bytes)
|
||||
}
|
||||
|
||||
function clean(path: string) {
|
||||
return path.replace(/\/+$/, '') || '/'
|
||||
}
|
||||
|
||||
/** Strict POSIX-style relative path; null if `child` is not inside `root`. */
|
||||
function relativeTo(root: string, child: string) {
|
||||
const r = clean(root)
|
||||
const c = clean(child)
|
||||
|
||||
if (c === r) {return ''}
|
||||
|
||||
return c.startsWith(`${r}/`) ? c.slice(r.length + 1) : null
|
||||
}
|
||||
|
||||
/** Repo-root → repo-root/a → repo-root/a/b → … for every dir between root and `dir`. */
|
||||
function ancestorDirs(root: string, dir: string) {
|
||||
const r = clean(root)
|
||||
const rel = relativeTo(r, dir)
|
||||
|
||||
if (rel === null || rel === '') {return [r]}
|
||||
|
||||
const dirs = [r]
|
||||
let current = r
|
||||
|
||||
for (const part of rel.split('/').filter(Boolean)) {
|
||||
current = `${current}/${part}`
|
||||
dirs.push(current)
|
||||
}
|
||||
|
||||
return dirs
|
||||
}
|
||||
|
||||
async function gitRootFor(start: string) {
|
||||
if (!window.hermesDesktop?.gitRoot) {return null}
|
||||
|
||||
const key = clean(start)
|
||||
let cached = gitRootCache.get(key)
|
||||
|
||||
if (!cached) {
|
||||
cached = window.hermesDesktop.gitRoot(key)
|
||||
gitRootCache.set(key, cached)
|
||||
}
|
||||
|
||||
return cached
|
||||
}
|
||||
|
||||
/** Read .gitignore at `dir` if it actually exists — never probe missing files. */
|
||||
async function readGitignore(dir: string): Promise<GitignoreRule | null> {
|
||||
if (!window.hermesDesktop?.readDir || !window.hermesDesktop.readFileDataUrl) {return null}
|
||||
|
||||
try {
|
||||
const listing = await window.hermesDesktop.readDir(dir)
|
||||
|
||||
if (!listing.entries.some(e => e.name === '.gitignore' && !e.isDirectory)) {return null}
|
||||
|
||||
const text = decodeDataUrl(await window.hermesDesktop.readFileDataUrl(`${dir}/.gitignore`))
|
||||
|
||||
return { base: dir, ig: ignore().add(text) }
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
async function gitignoreFor(dir: string) {
|
||||
const key = clean(dir)
|
||||
let cached = gitignoreCache.get(key)
|
||||
|
||||
if (!cached) {
|
||||
cached = readGitignore(key)
|
||||
gitignoreCache.set(key, cached)
|
||||
}
|
||||
|
||||
return cached
|
||||
}
|
||||
|
||||
function ignoredBy(rules: GitignoreRule[], entry: HermesReadDirEntry) {
|
||||
return rules.some(rule => {
|
||||
const rel = relativeTo(rule.base, entry.path)
|
||||
|
||||
if (rel === null || rel === '') {return false}
|
||||
|
||||
return rule.ig.ignores(entry.isDirectory ? `${rel}/` : rel)
|
||||
})
|
||||
}
|
||||
|
||||
async function filterIgnored(entries: HermesReadDirEntry[], rootPath: string, dirPath: string) {
|
||||
const root = await gitRootFor(rootPath)
|
||||
|
||||
if (!root) {return entries}
|
||||
|
||||
const rules = (await Promise.all(ancestorDirs(root, dirPath).map(gitignoreFor))).filter(
|
||||
(r): r is GitignoreRule => Boolean(r)
|
||||
)
|
||||
|
||||
return rules.length > 0 ? entries.filter(entry => !ignoredBy(rules, entry)) : entries
|
||||
}
|
||||
|
||||
export async function readProjectDir(dirPath: string, rootPath = dirPath): Promise<HermesReadDirResult> {
|
||||
if (!window.hermesDesktop) {return { entries: [], error: 'no-bridge' }}
|
||||
|
||||
const result = await window.hermesDesktop.readDir(dirPath)
|
||||
|
||||
return { ...result, entries: await filterIgnored(result.entries, rootPath, dirPath) }
|
||||
}
|
||||
|
||||
export function clearProjectDirCache(rootPath?: string) {
|
||||
if (!rootPath) {
|
||||
gitRootCache.clear()
|
||||
gitignoreCache.clear()
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
const key = clean(rootPath)
|
||||
gitRootCache.delete(key)
|
||||
gitignoreCache.delete(key)
|
||||
}
|
||||
177
apps/desktop/src/app/file-browser/tree.tsx
Normal file
177
apps/desktop/src/app/file-browser/tree.tsx
Normal file
|
|
@ -0,0 +1,177 @@
|
|||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { type NodeApi, type NodeRendererProps, Tree, type TreeApi } from 'react-arborist'
|
||||
|
||||
import { ChevronDown, ChevronRight, FileText, FolderOpen, Loader2 } from '@/lib/icons'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
import type { TreeNode } from './use-project-tree'
|
||||
|
||||
const ROW_HEIGHT = 24
|
||||
const INDENT = 14
|
||||
|
||||
interface ProjectTreeProps {
|
||||
data: TreeNode[]
|
||||
onActivateFile: (path: string) => void
|
||||
onLoadChildren: (id: string) => void | Promise<void>
|
||||
onNodeOpenChange: (id: string, open: boolean) => void
|
||||
onPreviewFile?: (path: string) => void
|
||||
openState: Record<string, boolean>
|
||||
}
|
||||
|
||||
export function ProjectTree({
|
||||
data,
|
||||
onActivateFile,
|
||||
onLoadChildren,
|
||||
onNodeOpenChange,
|
||||
onPreviewFile,
|
||||
openState
|
||||
}: ProjectTreeProps) {
|
||||
const containerRef = useRef<HTMLDivElement | null>(null)
|
||||
const treeRef = useRef<TreeApi<TreeNode> | null>(null)
|
||||
const [size, setSize] = useState({ height: 0, width: 0 })
|
||||
|
||||
useEffect(() => {
|
||||
const el = containerRef.current
|
||||
|
||||
if (!el || typeof ResizeObserver === 'undefined') {return}
|
||||
|
||||
const observer = new ResizeObserver(([entry]) => {
|
||||
const { height, width } = entry.contentRect
|
||||
setSize({ height, width })
|
||||
})
|
||||
|
||||
observer.observe(el)
|
||||
|
||||
return () => observer.disconnect()
|
||||
}, [])
|
||||
|
||||
const handleToggle = useCallback(
|
||||
(id: string) => {
|
||||
const node = treeRef.current?.get(id)
|
||||
|
||||
if (!node) {return}
|
||||
|
||||
onNodeOpenChange(id, node.isOpen)
|
||||
|
||||
// Lazy fetch on first expand: children===undefined means "not yet loaded".
|
||||
if (node.isOpen && node.data.children === undefined) {
|
||||
void onLoadChildren(id)
|
||||
}
|
||||
},
|
||||
[onLoadChildren, onNodeOpenChange]
|
||||
)
|
||||
|
||||
const handleActivate = useCallback(
|
||||
(node: NodeApi<TreeNode>) => {
|
||||
if (!node.data.isDirectory) {
|
||||
onPreviewFile?.(node.data.id)
|
||||
}
|
||||
},
|
||||
[onPreviewFile]
|
||||
)
|
||||
|
||||
return (
|
||||
<div className="min-h-0 flex-1 overflow-hidden px-2" ref={containerRef}>
|
||||
{size.height > 0 && size.width > 0 ? (
|
||||
<Tree<TreeNode>
|
||||
childrenAccessor={node => (node.isDirectory ? (node.children ?? []) : null)}
|
||||
data={data}
|
||||
disableDrag
|
||||
disableDrop
|
||||
disableEdit
|
||||
height={size.height}
|
||||
indent={INDENT}
|
||||
initialOpenState={openState}
|
||||
onActivate={handleActivate}
|
||||
onToggle={handleToggle}
|
||||
openByDefault={false}
|
||||
padding={2}
|
||||
ref={treeRef}
|
||||
rowHeight={ROW_HEIGHT}
|
||||
width={size.width}
|
||||
>
|
||||
{props => <ProjectTreeRow {...props} onAttachFile={onActivateFile} onPreviewFile={onPreviewFile} />}
|
||||
</Tree>
|
||||
) : null}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function ProjectTreeRow({
|
||||
dragHandle,
|
||||
node,
|
||||
onAttachFile,
|
||||
onPreviewFile,
|
||||
style
|
||||
}: NodeRendererProps<TreeNode> & { onAttachFile: (path: string) => void; onPreviewFile?: (path: string) => void }) {
|
||||
const isFolder = node.data.isDirectory
|
||||
const isPlaceholder = node.data.id.endsWith('::__loading__')
|
||||
const Caret = node.isOpen ? ChevronDown : ChevronRight
|
||||
|
||||
return (
|
||||
<div
|
||||
aria-expanded={isFolder ? node.isOpen : undefined}
|
||||
aria-selected={node.isSelected}
|
||||
className={cn(
|
||||
'group/row flex h-full cursor-pointer select-none items-center gap-1 rounded-sm px-1.5 text-[0.72rem] leading-none text-foreground/85 transition-colors hover:bg-accent/55',
|
||||
node.isSelected && 'bg-accent/65 text-foreground',
|
||||
isPlaceholder && 'pointer-events-none italic text-muted-foreground/70'
|
||||
)}
|
||||
draggable={!isPlaceholder}
|
||||
onClick={event => {
|
||||
event.stopPropagation()
|
||||
|
||||
if (isPlaceholder) {return}
|
||||
|
||||
if (isFolder) {
|
||||
node.toggle()
|
||||
} else {
|
||||
node.select()
|
||||
|
||||
if (event.shiftKey) {
|
||||
onAttachFile(node.data.id)
|
||||
}
|
||||
}
|
||||
}}
|
||||
onDoubleClick={event => {
|
||||
event.stopPropagation()
|
||||
|
||||
if (!isFolder && !isPlaceholder) {
|
||||
onPreviewFile?.(node.data.id)
|
||||
}
|
||||
}}
|
||||
onDragStart={event => {
|
||||
if (isPlaceholder) {
|
||||
event.preventDefault()
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
// Custom MIME the composer's drop handler unpacks. text/plain is set
|
||||
// as a fallback so dragging into other apps gets a sensible payload
|
||||
// (the absolute path).
|
||||
const payload = JSON.stringify([{ isDirectory: isFolder, path: node.data.id }])
|
||||
|
||||
event.dataTransfer.effectAllowed = 'copy'
|
||||
event.dataTransfer.setData('application/x-hermes-paths', payload)
|
||||
event.dataTransfer.setData('text/plain', node.data.id)
|
||||
}}
|
||||
ref={dragHandle}
|
||||
style={style}
|
||||
>
|
||||
<span aria-hidden className={cn('flex w-3.5 items-center justify-center', !isFolder && 'opacity-0')}>
|
||||
{isFolder && !isPlaceholder ? <Caret className="size-3 text-muted-foreground/70" /> : null}
|
||||
</span>
|
||||
<span aria-hidden className="flex w-3.5 items-center justify-center text-muted-foreground/85">
|
||||
{isPlaceholder ? (
|
||||
<Loader2 className="size-3 animate-spin" />
|
||||
) : isFolder ? (
|
||||
<FolderOpen className="size-3.5" />
|
||||
) : (
|
||||
<FileText className="size-3.5" />
|
||||
)}
|
||||
</span>
|
||||
<span className="min-w-0 flex-1 truncate">{node.data.name}</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
190
apps/desktop/src/app/file-browser/use-project-tree.test.ts
Normal file
190
apps/desktop/src/app/file-browser/use-project-tree.test.ts
Normal file
|
|
@ -0,0 +1,190 @@
|
|||
import { act, renderHook, waitFor } from '@testing-library/react'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import type { HermesReadDirResult } from '@/global'
|
||||
|
||||
import { resetProjectTreeState, useProjectTree } from './use-project-tree'
|
||||
|
||||
const readDir = vi.fn<(path: string) => Promise<HermesReadDirResult>>()
|
||||
|
||||
beforeEach(() => {
|
||||
resetProjectTreeState()
|
||||
readDir.mockReset()
|
||||
;(window as unknown as { hermesDesktop: { readDir: typeof readDir } }).hermesDesktop = { readDir }
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
resetProjectTreeState()
|
||||
delete (window as unknown as { hermesDesktop?: unknown }).hermesDesktop
|
||||
})
|
||||
|
||||
function ok(entries: { name: string; path: string; isDirectory: boolean }[]): HermesReadDirResult {
|
||||
return { entries }
|
||||
}
|
||||
|
||||
describe('useProjectTree', () => {
|
||||
it('starts empty when cwd is blank and skips IPC', async () => {
|
||||
const { result } = renderHook(() => useProjectTree(''))
|
||||
|
||||
await waitFor(() => expect(result.current.rootLoading).toBe(false))
|
||||
|
||||
expect(result.current.data).toEqual([])
|
||||
expect(result.current.rootError).toBeNull()
|
||||
expect(readDir).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('loads root entries on mount and sorts folders before files', async () => {
|
||||
readDir.mockResolvedValueOnce(
|
||||
ok([
|
||||
{ name: 'README.md', path: '/p/README.md', isDirectory: false },
|
||||
{ name: 'src', path: '/p/src', isDirectory: true }
|
||||
])
|
||||
)
|
||||
|
||||
const { result } = renderHook(() => useProjectTree('/p'))
|
||||
|
||||
await waitFor(() => expect(result.current.data.length).toBe(2))
|
||||
|
||||
expect(readDir).toHaveBeenCalledWith('/p')
|
||||
// Hook trusts main-process sort order; folders/files preserved as supplied.
|
||||
expect(result.current.data.map(n => n.name)).toEqual(['README.md', 'src'])
|
||||
// Folder children start undefined (lazy load on first expand).
|
||||
expect(result.current.data.find(n => n.name === 'src')?.children).toBeUndefined()
|
||||
expect(result.current.data.find(n => n.name === 'src')?.isDirectory).toBe(true)
|
||||
expect(result.current.data.find(n => n.name === 'README.md')?.isDirectory).toBe(false)
|
||||
})
|
||||
|
||||
it('records rootError when readDir returns an error', async () => {
|
||||
readDir.mockResolvedValueOnce({ entries: [], error: 'EACCES' })
|
||||
|
||||
const { result } = renderHook(() => useProjectTree('/locked'))
|
||||
|
||||
await waitFor(() => expect(result.current.rootError).toBe('EACCES'))
|
||||
expect(result.current.data).toEqual([])
|
||||
})
|
||||
|
||||
it('lazy-loads children on loadChildren and replaces the placeholder', async () => {
|
||||
readDir.mockResolvedValueOnce(ok([{ name: 'src', path: '/p/src', isDirectory: true }]))
|
||||
readDir.mockResolvedValueOnce(
|
||||
ok([
|
||||
{ name: 'index.ts', path: '/p/src/index.ts', isDirectory: false },
|
||||
{ name: 'lib', path: '/p/src/lib', isDirectory: true }
|
||||
])
|
||||
)
|
||||
|
||||
const { result } = renderHook(() => useProjectTree('/p'))
|
||||
|
||||
await waitFor(() => expect(result.current.data.length).toBe(1))
|
||||
|
||||
await act(async () => {
|
||||
await result.current.loadChildren('/p/src')
|
||||
})
|
||||
|
||||
const src = result.current.data[0]
|
||||
expect(src.children?.map(n => n.name)).toEqual(['index.ts', 'lib'])
|
||||
expect(src.loading).toBe(false)
|
||||
expect(src.error).toBeUndefined()
|
||||
})
|
||||
|
||||
it('keeps loaded tree state across remounts for the same cwd', async () => {
|
||||
readDir.mockResolvedValueOnce(ok([{ name: 'src', path: '/p/src', isDirectory: true }]))
|
||||
|
||||
const { result, unmount } = renderHook(() => useProjectTree('/p'))
|
||||
|
||||
await waitFor(() => expect(result.current.data.length).toBe(1))
|
||||
|
||||
act(() => {
|
||||
result.current.setNodeOpen('/p/src', true)
|
||||
})
|
||||
|
||||
unmount()
|
||||
|
||||
const remounted = renderHook(() => useProjectTree('/p'))
|
||||
|
||||
expect(remounted.result.current.data.map(n => n.name)).toEqual(['src'])
|
||||
expect(remounted.result.current.openState).toEqual({ '/p/src': true })
|
||||
expect(readDir).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('captures per-folder error code and leaves the folder expandable but empty', async () => {
|
||||
readDir.mockResolvedValueOnce(ok([{ name: 'priv', path: '/p/priv', isDirectory: true }]))
|
||||
readDir.mockResolvedValueOnce({ entries: [], error: 'EACCES' })
|
||||
|
||||
const { result } = renderHook(() => useProjectTree('/p'))
|
||||
|
||||
await waitFor(() => expect(result.current.data.length).toBe(1))
|
||||
|
||||
await act(async () => {
|
||||
await result.current.loadChildren('/p/priv')
|
||||
})
|
||||
|
||||
expect(result.current.data[0].error).toBe('EACCES')
|
||||
expect(result.current.data[0].children).toEqual([])
|
||||
})
|
||||
|
||||
it('dedupes concurrent loadChildren calls for the same id', async () => {
|
||||
readDir.mockResolvedValueOnce(ok([{ name: 'src', path: '/p/src', isDirectory: true }]))
|
||||
|
||||
let resolveChildren: ((value: HermesReadDirResult) => void) | undefined
|
||||
readDir.mockImplementationOnce(
|
||||
() =>
|
||||
new Promise<HermesReadDirResult>(resolve => {
|
||||
resolveChildren = resolve
|
||||
})
|
||||
)
|
||||
|
||||
const { result } = renderHook(() => useProjectTree('/p'))
|
||||
|
||||
await waitFor(() => expect(result.current.data.length).toBe(1))
|
||||
|
||||
await act(async () => {
|
||||
// First call enters inflight, second short-circuits, third also short-circuits.
|
||||
void result.current.loadChildren('/p/src')
|
||||
void result.current.loadChildren('/p/src')
|
||||
void result.current.loadChildren('/p/src')
|
||||
resolveChildren?.(ok([{ name: 'a.ts', path: '/p/src/a.ts', isDirectory: false }]))
|
||||
})
|
||||
|
||||
// Mount load + a single folder fetch — duplicates were dropped.
|
||||
expect(readDir).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('refreshRoot reloads the root and clears prior error', async () => {
|
||||
readDir.mockResolvedValueOnce({ entries: [], error: 'EACCES' })
|
||||
readDir.mockResolvedValueOnce(ok([{ name: 'README.md', path: '/p/README.md', isDirectory: false }]))
|
||||
|
||||
const { result } = renderHook(() => useProjectTree('/p'))
|
||||
|
||||
await waitFor(() => expect(result.current.rootError).toBe('EACCES'))
|
||||
|
||||
await act(async () => {
|
||||
await result.current.refreshRoot()
|
||||
})
|
||||
|
||||
expect(result.current.rootError).toBeNull()
|
||||
expect(result.current.data.map(n => n.name)).toEqual(['README.md'])
|
||||
})
|
||||
|
||||
it('reloads when cwd changes', async () => {
|
||||
readDir.mockResolvedValueOnce(ok([{ name: 'one', path: '/a/one', isDirectory: false }]))
|
||||
readDir.mockResolvedValueOnce(ok([{ name: 'two', path: '/b/two', isDirectory: false }]))
|
||||
|
||||
const { rerender, result } = renderHook(({ cwd }) => useProjectTree(cwd), { initialProps: { cwd: '/a' } })
|
||||
|
||||
await waitFor(() => expect(result.current.data[0]?.name).toBe('one'))
|
||||
|
||||
rerender({ cwd: '/b' })
|
||||
|
||||
await waitFor(() => expect(result.current.data[0]?.name).toBe('two'))
|
||||
expect(readDir).toHaveBeenLastCalledWith('/b')
|
||||
})
|
||||
|
||||
it('returns no-bridge gracefully when window.hermesDesktop is missing', async () => {
|
||||
delete (window as unknown as { hermesDesktop?: unknown }).hermesDesktop
|
||||
|
||||
const { result } = renderHook(() => useProjectTree('/p'))
|
||||
|
||||
await waitFor(() => expect(result.current.rootError).toBe('no-bridge'))
|
||||
expect(result.current.data).toEqual([])
|
||||
})
|
||||
})
|
||||
221
apps/desktop/src/app/file-browser/use-project-tree.ts
Normal file
221
apps/desktop/src/app/file-browser/use-project-tree.ts
Normal file
|
|
@ -0,0 +1,221 @@
|
|||
import { useStore } from '@nanostores/react'
|
||||
import { atom } from 'nanostores'
|
||||
import { useCallback, useEffect, useMemo } from 'react'
|
||||
|
||||
import { clearProjectDirCache, readProjectDir } from './ipc'
|
||||
|
||||
export interface TreeNode {
|
||||
/** Absolute filesystem path. Doubles as react-arborist node id. */
|
||||
id: string
|
||||
name: string
|
||||
/** Drives arborist's leaf-vs-expandable decision via childrenAccessor. */
|
||||
isDirectory: boolean
|
||||
/** `undefined` = directory, children not yet loaded. `[]` = loaded empty. */
|
||||
children?: TreeNode[]
|
||||
/** True while a readDir for this folder is in flight. */
|
||||
loading?: boolean
|
||||
/** Last error code from readDir (e.g. EACCES). Cleared on next successful load. */
|
||||
error?: string
|
||||
}
|
||||
|
||||
const PLACEHOLDER_ID = '__loading__'
|
||||
|
||||
function makeNode(path: string, name: string, isDirectory: boolean): TreeNode {
|
||||
return { id: path, isDirectory, name }
|
||||
}
|
||||
|
||||
function patchNode(nodes: TreeNode[] | undefined | null, id: string, patch: (n: TreeNode) => TreeNode): TreeNode[] {
|
||||
if (!nodes) {return []}
|
||||
|
||||
return nodes.map(n => {
|
||||
if (n.id === id) {return patch(n)}
|
||||
|
||||
if (n.children && n.children.length > 0) {
|
||||
return { ...n, children: patchNode(n.children, id, patch) }
|
||||
}
|
||||
|
||||
return n
|
||||
})
|
||||
}
|
||||
|
||||
function placeholderChild(parentId: string): TreeNode {
|
||||
return { id: `${parentId}::${PLACEHOLDER_ID}`, isDirectory: false, name: 'Loading…' }
|
||||
}
|
||||
|
||||
export interface UseProjectTreeResult {
|
||||
data: TreeNode[]
|
||||
openState: Record<string, boolean>
|
||||
rootError: string | null
|
||||
rootLoading: boolean
|
||||
loadChildren: (id: string) => Promise<void>
|
||||
refreshRoot: () => Promise<void>
|
||||
setNodeOpen: (id: string, open: boolean) => void
|
||||
}
|
||||
|
||||
interface ProjectTreeState {
|
||||
cwd: string
|
||||
data: TreeNode[]
|
||||
loaded: boolean
|
||||
openState: Record<string, boolean>
|
||||
requestId: number
|
||||
rootError: string | null
|
||||
rootLoading: boolean
|
||||
}
|
||||
|
||||
const initialState: ProjectTreeState = {
|
||||
cwd: '',
|
||||
data: [],
|
||||
loaded: false,
|
||||
openState: {},
|
||||
requestId: 0,
|
||||
rootError: null,
|
||||
rootLoading: false
|
||||
}
|
||||
|
||||
const inflight = new Set<string>()
|
||||
const $projectTree = atom<ProjectTreeState>(initialState)
|
||||
let nextRootRequestId = 0
|
||||
|
||||
function setProjectTree(updater: (current: ProjectTreeState) => ProjectTreeState) {
|
||||
$projectTree.set(updater($projectTree.get()))
|
||||
}
|
||||
|
||||
function clearProjectTree() {
|
||||
nextRootRequestId += 1
|
||||
inflight.clear()
|
||||
$projectTree.set({ ...initialState, requestId: nextRootRequestId })
|
||||
}
|
||||
|
||||
async function loadRoot(cwd: string, { force = false }: { force?: boolean } = {}) {
|
||||
if (!cwd) {
|
||||
clearProjectTree()
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
const current = $projectTree.get()
|
||||
|
||||
if (!force && current.cwd === cwd && (current.loaded || current.rootLoading)) {
|
||||
return
|
||||
}
|
||||
|
||||
const requestId = nextRootRequestId + 1
|
||||
nextRootRequestId = requestId
|
||||
inflight.clear()
|
||||
|
||||
if (force || current.cwd !== cwd) {
|
||||
clearProjectDirCache(cwd)
|
||||
}
|
||||
|
||||
$projectTree.set({
|
||||
cwd,
|
||||
data: [],
|
||||
loaded: false,
|
||||
openState: current.cwd === cwd ? current.openState : {},
|
||||
requestId,
|
||||
rootError: null,
|
||||
rootLoading: true
|
||||
})
|
||||
|
||||
const { entries, error } = await readProjectDir(cwd, cwd)
|
||||
|
||||
setProjectTree(latest => {
|
||||
if (latest.cwd !== cwd || latest.requestId !== requestId) {
|
||||
return latest
|
||||
}
|
||||
|
||||
return {
|
||||
...latest,
|
||||
data: error ? [] : entries.map(e => makeNode(e.path, e.name, e.isDirectory)),
|
||||
loaded: true,
|
||||
rootError: error || null,
|
||||
rootLoading: false
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
export function resetProjectTreeState() {
|
||||
clearProjectTree()
|
||||
clearProjectDirCache()
|
||||
}
|
||||
|
||||
/**
|
||||
* Lazy-loads a directory tree rooted at `cwd`. Children are fetched on first
|
||||
* expand and cached in this feature-owned atom so unrelated chat rerenders or
|
||||
* remounts cannot reset the browser. A placeholder leaf renders so the
|
||||
* disclosure caret shows for unloaded folders. `refreshRoot` invalidates the
|
||||
* whole tree (used after cwd change or manual refresh).
|
||||
*/
|
||||
export function useProjectTree(cwd: string): UseProjectTreeResult {
|
||||
const state = useStore($projectTree)
|
||||
|
||||
const refreshRoot = useCallback(() => loadRoot(cwd, { force: true }), [cwd])
|
||||
|
||||
const setNodeOpen = useCallback(
|
||||
(id: string, open: boolean) => {
|
||||
setProjectTree(current => {
|
||||
if (current.cwd !== cwd || current.openState[id] === open) {
|
||||
return current
|
||||
}
|
||||
|
||||
return {
|
||||
...current,
|
||||
openState: {
|
||||
...current.openState,
|
||||
[id]: open
|
||||
}
|
||||
}
|
||||
})
|
||||
},
|
||||
[cwd]
|
||||
)
|
||||
|
||||
const loadChildren = useCallback(async (id: string) => {
|
||||
if (!cwd || inflight.has(id)) {return}
|
||||
inflight.add(id)
|
||||
|
||||
setProjectTree(current => {
|
||||
if (current.cwd !== cwd) {return current}
|
||||
|
||||
return {
|
||||
...current,
|
||||
data: patchNode(current.data, id, n => ({ ...n, loading: true, children: [placeholderChild(n.id)] }))
|
||||
}
|
||||
})
|
||||
|
||||
const { entries, error } = await readProjectDir(id, cwd)
|
||||
|
||||
inflight.delete(id)
|
||||
|
||||
setProjectTree(current => {
|
||||
if (current.cwd !== cwd) {return current}
|
||||
|
||||
return {
|
||||
...current,
|
||||
data: patchNode(current.data, id, n => ({
|
||||
...n,
|
||||
loading: false,
|
||||
error: error || undefined,
|
||||
children: error ? [] : entries.map(e => makeNode(e.path, e.name, e.isDirectory))
|
||||
}))
|
||||
}
|
||||
})
|
||||
}, [cwd])
|
||||
|
||||
useEffect(() => {
|
||||
void loadRoot(cwd)
|
||||
}, [cwd])
|
||||
|
||||
return useMemo(
|
||||
() => ({
|
||||
data: state.cwd === cwd ? state.data : [],
|
||||
loadChildren,
|
||||
openState: state.cwd === cwd ? state.openState : {},
|
||||
refreshRoot,
|
||||
rootError: state.cwd === cwd ? state.rootError : null,
|
||||
rootLoading: state.cwd === cwd ? state.rootLoading : false,
|
||||
setNodeOpen
|
||||
}),
|
||||
[cwd, loadChildren, refreshRoot, setNodeOpen, state.cwd, state.data, state.openState, state.rootError, state.rootLoading]
|
||||
)
|
||||
}
|
||||
|
|
@ -30,10 +30,10 @@ export function OverlaySearchInput({
|
|||
|
||||
return (
|
||||
<div className={cn('relative', containerClassName)}>
|
||||
<Search className="pointer-events-none absolute left-3 top-1/2 size-3.5 -translate-y-1/2 text-muted-foreground/80" />
|
||||
<Search className="pointer-events-none absolute left-3 top-1/2 z-1 size-3.5 -translate-y-1/2 text-muted-foreground/80" />
|
||||
<Input
|
||||
className={cn(
|
||||
'h-8.5 rounded-full border border-[color-mix(in_srgb,var(--dt-border)_60%,transparent)] bg-[color-mix(in_srgb,var(--dt-card)_85%,transparent)] py-2 pl-8 pr-12 text-sm shadow-[inset_0_0.0625rem_0_color-mix(in_srgb,white_38%,transparent)] focus-visible:border-[color-mix(in_srgb,var(--dt-ring)_70%,transparent)] focus-visible:bg-background',
|
||||
'relative z-0 h-8.5 rounded-full border border-[color-mix(in_srgb,var(--dt-border)_60%,transparent)] bg-[color-mix(in_srgb,var(--dt-card)_85%,transparent)] py-2 pl-8 pr-12 text-sm shadow-[inset_0_0.0625rem_0_color-mix(in_srgb,white_38%,transparent)] focus-visible:border-[color-mix(in_srgb,var(--dt-ring)_70%,transparent)] focus-visible:bg-background dark:border-[color-mix(in_srgb,var(--dt-border)_48%,transparent)] dark:bg-[color-mix(in_srgb,var(--dt-card)_96%,var(--dt-background))] dark:shadow-[inset_0_0.0625rem_0_color-mix(in_srgb,white_10%,transparent)]',
|
||||
inputClassName
|
||||
)}
|
||||
onChange={event => onChange(event.target.value)}
|
||||
|
|
@ -42,11 +42,11 @@ export function OverlaySearchInput({
|
|||
value={value}
|
||||
/>
|
||||
{loading ? (
|
||||
<Loader2 className="pointer-events-none absolute right-3 top-1/2 size-3.5 -translate-y-1/2 animate-spin text-muted-foreground/70" />
|
||||
<Loader2 className="pointer-events-none absolute right-3 top-1/2 z-1 size-3.5 -translate-y-1/2 animate-spin text-muted-foreground/70" />
|
||||
) : value ? (
|
||||
<Button
|
||||
aria-label="Clear search"
|
||||
className="absolute right-1.5 top-1/2 -translate-y-1/2 text-muted-foreground/85 hover:bg-accent/60 hover:text-foreground"
|
||||
className="absolute right-1.5 top-1/2 z-1 -translate-y-1/2 text-muted-foreground/85 hover:bg-accent/60 hover:text-foreground"
|
||||
onClick={clear}
|
||||
size="icon-xs"
|
||||
variant="ghost"
|
||||
|
|
|
|||
|
|
@ -30,7 +30,7 @@ export function OverlaySplitLayout({ children, className }: OverlaySplitLayoutPr
|
|||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'grid h-full min-h-0 flex-1 grid-cols-[13rem_minmax(0,1fr)] overflow-hidden rounded-[0.95rem] border border-[color-mix(in_srgb,var(--dt-border)_58%,transparent)] bg-[color-mix(in_srgb,var(--dt-card)_94%,transparent)] shadow-[inset_0_0.0625rem_0_color-mix(in_srgb,white_46%,transparent),0_0.5rem_1.5rem_-1rem_color-mix(in_srgb,#000_22%,transparent)] max-[760px]:grid-cols-1',
|
||||
'grid h-full min-h-0 flex-1 grid-cols-[13rem_minmax(0,1fr)] overflow-hidden rounded-[0.95rem] border border-[color-mix(in_srgb,var(--dt-border)_58%,transparent)] bg-[color-mix(in_srgb,var(--dt-card)_94%,transparent)] shadow-[inset_0_0.0625rem_0_color-mix(in_srgb,white_46%,transparent),0_0.5rem_1.5rem_-1rem_color-mix(in_srgb,#000_22%,transparent)] max-[760px]:grid-cols-1 dark:border-[color-mix(in_srgb,var(--dt-border)_36%,transparent)] dark:shadow-[inset_0_0.0625rem_0_color-mix(in_srgb,white_10%,transparent),0_0.5rem_1.5rem_-1rem_color-mix(in_srgb,#000_45%,transparent)]',
|
||||
className
|
||||
)}
|
||||
>
|
||||
|
|
|
|||
|
|
@ -4,10 +4,11 @@ export const SETTINGS_ROUTE = '/settings'
|
|||
export const COMMAND_CENTER_ROUTE = '/command-center'
|
||||
export const SKILLS_ROUTE = '/skills'
|
||||
export const ARTIFACTS_ROUTE = '/artifacts'
|
||||
export const AGENTS_ROUTE = '/agents'
|
||||
|
||||
export type AppView = 'chat' | 'settings' | 'command-center' | 'skills' | 'artifacts'
|
||||
export type AppView = 'chat' | 'settings' | 'command-center' | 'skills' | 'artifacts' | 'agents'
|
||||
|
||||
export type AppRouteId = 'new' | 'settings' | 'command-center' | 'skills' | 'artifacts'
|
||||
export type AppRouteId = 'new' | 'settings' | 'command-center' | 'skills' | 'artifacts' | 'agents'
|
||||
|
||||
export interface AppRoute {
|
||||
id: AppRouteId
|
||||
|
|
@ -20,7 +21,8 @@ export const APP_ROUTES = [
|
|||
{ id: 'settings', path: SETTINGS_ROUTE, view: 'settings' },
|
||||
{ id: 'command-center', path: COMMAND_CENTER_ROUTE, view: 'command-center' },
|
||||
{ id: 'skills', path: SKILLS_ROUTE, view: 'skills' },
|
||||
{ id: 'artifacts', path: ARTIFACTS_ROUTE, view: 'artifacts' }
|
||||
{ id: 'artifacts', path: ARTIFACTS_ROUTE, view: 'artifacts' },
|
||||
{ id: 'agents', path: AGENTS_ROUTE, view: 'agents' }
|
||||
] as const satisfies readonly AppRoute[]
|
||||
|
||||
const APP_VIEW_BY_PATH = new Map<string, AppView>(APP_ROUTES.map(route => [route.path, route.view]))
|
||||
|
|
|
|||
|
|
@ -0,0 +1,52 @@
|
|||
import { type MutableRefObject, useCallback, useEffect } from 'react'
|
||||
|
||||
import { $currentCwd, setContextSuggestions } from '@/store/session'
|
||||
|
||||
import type { ContextSuggestion } from '../../types'
|
||||
|
||||
interface ContextSuggestionsOptions {
|
||||
activeSessionId: string | null
|
||||
activeSessionIdRef: MutableRefObject<string | null>
|
||||
currentCwd: string
|
||||
gatewayState: string | undefined
|
||||
requestGateway: <T = unknown>(method: string, params?: Record<string, unknown>) => Promise<T>
|
||||
}
|
||||
|
||||
export function useContextSuggestions({
|
||||
activeSessionId,
|
||||
activeSessionIdRef,
|
||||
currentCwd,
|
||||
gatewayState,
|
||||
requestGateway
|
||||
}: ContextSuggestionsOptions) {
|
||||
const refresh = useCallback(async () => {
|
||||
if (!activeSessionId) {
|
||||
setContextSuggestions([])
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
const sessionId = activeSessionId
|
||||
const cwd = currentCwd || ''
|
||||
|
||||
// Race guard: only commit if the session+cwd we sent for still match
|
||||
// by the time the gateway responds.
|
||||
const stillCurrent = () => activeSessionIdRef.current === sessionId && $currentCwd.get() === cwd
|
||||
|
||||
try {
|
||||
const result = await requestGateway<{ items?: ContextSuggestion[] }>('complete.path', {
|
||||
session_id: sessionId,
|
||||
word: '@file:',
|
||||
cwd: cwd || undefined
|
||||
})
|
||||
|
||||
if (stillCurrent()) {setContextSuggestions((result.items || []).filter(i => i.text))}
|
||||
} catch {
|
||||
if (stillCurrent()) {setContextSuggestions([])}
|
||||
}
|
||||
}, [activeSessionId, activeSessionIdRef, currentCwd, requestGateway])
|
||||
|
||||
useEffect(() => {
|
||||
if (gatewayState === 'open' && activeSessionId) {void refresh()}
|
||||
}, [activeSessionId, gatewayState, refresh])
|
||||
}
|
||||
108
apps/desktop/src/app/session/hooks/use-cwd-actions.ts
Normal file
108
apps/desktop/src/app/session/hooks/use-cwd-actions.ts
Normal file
|
|
@ -0,0 +1,108 @@
|
|||
import { type MutableRefObject, useCallback } from 'react'
|
||||
|
||||
import { notify, notifyError } from '@/store/notifications'
|
||||
import { $currentCwd, setCurrentBranch, setCurrentCwd } from '@/store/session'
|
||||
import type { SessionRuntimeInfo } from '@/types/hermes'
|
||||
|
||||
interface CwdActionsOptions {
|
||||
activeSessionId: string | null
|
||||
activeSessionIdRef: MutableRefObject<string | null>
|
||||
currentCwd: string
|
||||
requestGateway: <T = unknown>(method: string, params?: Record<string, unknown>) => Promise<T>
|
||||
}
|
||||
|
||||
export function useCwdActions({ activeSessionId, activeSessionIdRef, currentCwd, requestGateway }: CwdActionsOptions) {
|
||||
const refreshProjectBranch = useCallback(
|
||||
async (cwd: string) => {
|
||||
const target = cwd.trim()
|
||||
|
||||
if (!target || activeSessionIdRef.current) {return}
|
||||
|
||||
try {
|
||||
const info = await requestGateway<{ branch?: string; cwd?: string }>('config.get', { key: 'project', cwd: target })
|
||||
|
||||
if (!activeSessionIdRef.current && ($currentCwd.get() || target) === (info.cwd || target)) {
|
||||
setCurrentBranch(info.branch || '')
|
||||
}
|
||||
} catch {
|
||||
setCurrentBranch('')
|
||||
}
|
||||
},
|
||||
[activeSessionIdRef, requestGateway]
|
||||
)
|
||||
|
||||
const changeSessionCwd = useCallback(
|
||||
async (cwd: string) => {
|
||||
const trimmed = cwd.trim()
|
||||
|
||||
if (!trimmed) {return}
|
||||
|
||||
const persistGlobal = async () => {
|
||||
const info = await requestGateway<{ branch?: string; cwd?: string; value?: string }>('config.set', {
|
||||
...(activeSessionId && { session_id: activeSessionId }),
|
||||
key: 'terminal.cwd',
|
||||
value: trimmed
|
||||
})
|
||||
|
||||
setCurrentCwd(info.cwd || info.value || trimmed)
|
||||
|
||||
if (!activeSessionId) {setCurrentBranch(info.branch || '')}
|
||||
}
|
||||
|
||||
if (!activeSessionId) {
|
||||
try {
|
||||
await persistGlobal()
|
||||
} catch (err) {
|
||||
notifyError(err, 'Working directory change failed')
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const info = await requestGateway<SessionRuntimeInfo>('session.cwd.set', {
|
||||
session_id: activeSessionId,
|
||||
cwd: trimmed
|
||||
})
|
||||
|
||||
setCurrentCwd(info.cwd || trimmed)
|
||||
setCurrentBranch(info.branch || '')
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err)
|
||||
|
||||
// Older gateways without `session.cwd.set` fall back to a global write —
|
||||
// user has to restart the active session for it to take effect.
|
||||
if (!message.includes('unknown method')) {
|
||||
notifyError(err, 'Working directory change failed')
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
await persistGlobal()
|
||||
notify({
|
||||
kind: 'warning',
|
||||
title: 'Working directory saved',
|
||||
message: 'Restart the desktop backend to apply cwd changes to this active session.'
|
||||
})
|
||||
} catch (fallbackErr) {
|
||||
notifyError(fallbackErr, 'Working directory change failed')
|
||||
}
|
||||
}
|
||||
},
|
||||
[activeSessionId, requestGateway]
|
||||
)
|
||||
|
||||
const browseSessionCwd = useCallback(async () => {
|
||||
const paths = await window.hermesDesktop?.selectPaths({
|
||||
title: 'Change working directory',
|
||||
defaultPath: currentCwd || undefined,
|
||||
directories: true,
|
||||
multiple: false
|
||||
})
|
||||
|
||||
if (paths?.[0]) {await changeSessionCwd(paths[0])}
|
||||
}, [changeSessionCwd, currentCwd])
|
||||
|
||||
return { browseSessionCwd, changeSessionCwd, refreshProjectBranch }
|
||||
}
|
||||
74
apps/desktop/src/app/session/hooks/use-hermes-config.ts
Normal file
74
apps/desktop/src/app/session/hooks/use-hermes-config.ts
Normal file
|
|
@ -0,0 +1,74 @@
|
|||
import { type MutableRefObject, useCallback, useState } from 'react'
|
||||
|
||||
import { getHermesConfig, getHermesConfigDefaults } from '@/hermes'
|
||||
import { BUILTIN_PERSONALITIES, normalizePersonalityValue, personalityNamesFromConfig } from '@/lib/chat-runtime'
|
||||
import {
|
||||
$currentCwd,
|
||||
setAvailablePersonalities,
|
||||
setCurrentCwd,
|
||||
setCurrentFastMode,
|
||||
setCurrentPersonality,
|
||||
setCurrentReasoningEffort,
|
||||
setCurrentServiceTier,
|
||||
setIntroPersonality
|
||||
} from '@/store/session'
|
||||
|
||||
const DEFAULT_VOICE_SECONDS = 120
|
||||
const FAST_TIERS = new Set(['fast', 'priority', 'on'])
|
||||
|
||||
function recordingLimit(value: unknown) {
|
||||
return typeof value === 'number' && Number.isFinite(value) && value > 0 ? value : DEFAULT_VOICE_SECONDS
|
||||
}
|
||||
|
||||
interface HermesConfigOptions {
|
||||
activeSessionIdRef: MutableRefObject<string | null>
|
||||
refreshProjectBranch: (cwd: string) => Promise<void>
|
||||
}
|
||||
|
||||
export function useHermesConfig({ activeSessionIdRef, refreshProjectBranch }: HermesConfigOptions) {
|
||||
const [voiceMaxRecordingSeconds, setVoiceMaxRecordingSeconds] = useState(DEFAULT_VOICE_SECONDS)
|
||||
const [sttEnabled, setSttEnabled] = useState(true)
|
||||
|
||||
const refreshHermesConfig = useCallback(async () => {
|
||||
try {
|
||||
const [config, defaults] = await Promise.all([getHermesConfig(), getHermesConfigDefaults().catch(() => ({}))])
|
||||
|
||||
const personality = normalizePersonalityValue(
|
||||
typeof config.display?.personality === 'string' ? config.display.personality : ''
|
||||
)
|
||||
|
||||
setIntroPersonality(personality)
|
||||
// Active sessions keep their per-session value; standalone falls back to config.
|
||||
setCurrentPersonality(prev => (activeSessionIdRef.current ? prev || personality : personality))
|
||||
setAvailablePersonalities([
|
||||
...new Set([
|
||||
'none',
|
||||
...BUILTIN_PERSONALITIES,
|
||||
...personalityNamesFromConfig(defaults),
|
||||
...personalityNamesFromConfig(config)
|
||||
])
|
||||
])
|
||||
|
||||
const cwd = (config.terminal?.cwd ?? '').trim()
|
||||
|
||||
if (cwd && cwd !== '.') {
|
||||
setCurrentCwd(prev => prev || cwd)
|
||||
void refreshProjectBranch($currentCwd.get() || cwd)
|
||||
}
|
||||
|
||||
const reasoning = (config.agent?.reasoning_effort ?? '').trim()
|
||||
const tier = (config.agent?.service_tier ?? '').trim()
|
||||
|
||||
setCurrentReasoningEffort(prev => (activeSessionIdRef.current ? prev : reasoning))
|
||||
setCurrentServiceTier(prev => (activeSessionIdRef.current ? prev : tier))
|
||||
setCurrentFastMode(prev => (activeSessionIdRef.current ? prev : FAST_TIERS.has(tier.toLowerCase())))
|
||||
|
||||
setVoiceMaxRecordingSeconds(recordingLimit(config.voice?.max_recording_seconds))
|
||||
setSttEnabled(config.stt?.enabled !== false)
|
||||
} catch {
|
||||
// Config is nice-to-have; chat still works without it.
|
||||
}
|
||||
}, [activeSessionIdRef, refreshProjectBranch])
|
||||
|
||||
return { refreshHermesConfig, sttEnabled, voiceMaxRecordingSeconds }
|
||||
}
|
||||
79
apps/desktop/src/app/session/hooks/use-model-controls.ts
Normal file
79
apps/desktop/src/app/session/hooks/use-model-controls.ts
Normal file
|
|
@ -0,0 +1,79 @@
|
|||
import { type QueryClient } from '@tanstack/react-query'
|
||||
import { useCallback } from 'react'
|
||||
|
||||
import { getGlobalModelInfo, setGlobalModel } from '@/hermes'
|
||||
import { notifyError } from '@/store/notifications'
|
||||
import { setCurrentModel, setCurrentProvider } from '@/store/session'
|
||||
import type { ModelOptionsResponse } from '@/types/hermes'
|
||||
|
||||
interface ModelSelection {
|
||||
model: string
|
||||
persistGlobal: boolean
|
||||
provider: string
|
||||
}
|
||||
|
||||
interface ModelControlsOptions {
|
||||
activeSessionId: string | null
|
||||
queryClient: QueryClient
|
||||
requestGateway: <T = unknown>(method: string, params?: Record<string, unknown>) => Promise<T>
|
||||
}
|
||||
|
||||
export function useModelControls({ activeSessionId, queryClient, requestGateway }: ModelControlsOptions) {
|
||||
const updateModelOptionsCache = useCallback(
|
||||
(provider: string, model: string, includeGlobal: boolean) => {
|
||||
const patch = (prev: ModelOptionsResponse | undefined) => ({ ...(prev ?? {}), provider, model })
|
||||
|
||||
queryClient.setQueryData<ModelOptionsResponse>(['model-options', activeSessionId || 'global'], patch)
|
||||
|
||||
if (includeGlobal) {queryClient.setQueryData<ModelOptionsResponse>(['model-options', 'global'], patch)}
|
||||
},
|
||||
[activeSessionId, queryClient]
|
||||
)
|
||||
|
||||
const refreshCurrentModel = useCallback(async () => {
|
||||
try {
|
||||
const result = await getGlobalModelInfo()
|
||||
|
||||
if (typeof result.model === 'string') {setCurrentModel(result.model)}
|
||||
|
||||
if (typeof result.provider === 'string') {setCurrentProvider(result.provider)}
|
||||
} catch {
|
||||
// The delayed session.info event still updates this once the agent is ready.
|
||||
}
|
||||
}, [])
|
||||
|
||||
const selectModel = useCallback(
|
||||
(selection: ModelSelection) => {
|
||||
setCurrentModel(selection.model)
|
||||
setCurrentProvider(selection.provider)
|
||||
updateModelOptionsCache(selection.provider, selection.model, selection.persistGlobal || !activeSessionId)
|
||||
|
||||
void (async () => {
|
||||
try {
|
||||
if (activeSessionId) {
|
||||
await requestGateway('slash.exec', {
|
||||
session_id: activeSessionId,
|
||||
command: `/model ${selection.model} --provider ${selection.provider}${selection.persistGlobal ? ' --global' : ''}`
|
||||
})
|
||||
|
||||
if (selection.persistGlobal) {void refreshCurrentModel()}
|
||||
void queryClient.invalidateQueries({
|
||||
queryKey: selection.persistGlobal ? ['model-options'] : ['model-options', activeSessionId]
|
||||
})
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
await setGlobalModel(selection.provider, selection.model)
|
||||
void refreshCurrentModel()
|
||||
void queryClient.invalidateQueries({ queryKey: ['model-options'] })
|
||||
} catch (err) {
|
||||
notifyError(err, 'Model switch failed')
|
||||
}
|
||||
})()
|
||||
},
|
||||
[activeSessionId, queryClient, refreshCurrentModel, requestGateway, updateModelOptionsCache]
|
||||
)
|
||||
|
||||
return { refreshCurrentModel, selectModel, updateModelOptionsCache }
|
||||
}
|
||||
144
apps/desktop/src/app/session/hooks/use-preview-routing.test.tsx
Normal file
144
apps/desktop/src/app/session/hooks/use-preview-routing.test.tsx
Normal file
|
|
@ -0,0 +1,144 @@
|
|||
import { act, cleanup, render, waitFor } from '@testing-library/react'
|
||||
import { useEffect, useRef } from 'react'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { assistantTextPart, type ChatMessage } from '@/lib/chat-messages'
|
||||
import {
|
||||
$previewTarget,
|
||||
clearSessionPreviewRegistry,
|
||||
type PreviewTarget,
|
||||
registerSessionPreview
|
||||
} from '@/store/preview'
|
||||
import { $currentCwd, $messages } from '@/store/session'
|
||||
import type { RpcEvent } from '@/types/hermes'
|
||||
|
||||
import { usePreviewRouting } from './use-preview-routing'
|
||||
|
||||
function assistantMessage(id: string, text: string): ChatMessage {
|
||||
return {
|
||||
id,
|
||||
parts: [assistantTextPart(text)],
|
||||
role: 'assistant'
|
||||
}
|
||||
}
|
||||
|
||||
function previewTarget(source: string): PreviewTarget {
|
||||
const isUrl = /^https?:\/\//i.test(source)
|
||||
|
||||
return {
|
||||
kind: isUrl ? 'url' : 'file',
|
||||
label: source,
|
||||
path: isUrl ? undefined : source,
|
||||
previewKind: isUrl ? undefined : 'html',
|
||||
source,
|
||||
url: isUrl ? source : `file://${source}`
|
||||
}
|
||||
}
|
||||
|
||||
let handleEvent: (event: RpcEvent) => void = () => undefined
|
||||
|
||||
function PreviewRoutingHarness({ onEvent }: { onEvent: (handler: (event: RpcEvent) => void) => void }) {
|
||||
const activeSessionIdRef = useRef<string | null>('session-1')
|
||||
|
||||
const routing = usePreviewRouting({
|
||||
activeSessionIdRef,
|
||||
baseHandleGatewayEvent: vi.fn(),
|
||||
currentCwd: '/work',
|
||||
currentView: 'chat',
|
||||
requestGateway: vi.fn(),
|
||||
routedSessionId: 'session-1',
|
||||
selectedStoredSessionId: null
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
onEvent(routing.handleDesktopGatewayEvent)
|
||||
}, [onEvent, routing.handleDesktopGatewayEvent])
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
describe('usePreviewRouting', () => {
|
||||
beforeEach(() => {
|
||||
$currentCwd.set('/work')
|
||||
$messages.set([])
|
||||
$previewTarget.set(null)
|
||||
window.localStorage.clear()
|
||||
clearSessionPreviewRegistry()
|
||||
handleEvent = () => undefined
|
||||
|
||||
Object.defineProperty(window, 'hermesDesktop', {
|
||||
configurable: true,
|
||||
value: {
|
||||
normalizePreviewTarget: vi.fn(async (target: string) => previewTarget(target))
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
cleanup()
|
||||
$messages.set([])
|
||||
$previewTarget.set(null)
|
||||
window.localStorage.clear()
|
||||
clearSessionPreviewRegistry()
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
it('opens the active session preview from the registry', async () => {
|
||||
const target = previewTarget('/work/demo.html')
|
||||
|
||||
registerSessionPreview('session-1', target, 'tool-result')
|
||||
render(<PreviewRoutingHarness onEvent={handler => { handleEvent = handler }} />)
|
||||
|
||||
await waitFor(() => {
|
||||
expect($previewTarget.get()).toEqual({ ...target, renderMode: 'preview' })
|
||||
})
|
||||
})
|
||||
|
||||
it('does not infer previews from assistant prose', async () => {
|
||||
render(<PreviewRoutingHarness onEvent={handler => { handleEvent = handler }} />)
|
||||
|
||||
act(() => {
|
||||
$messages.set([
|
||||
assistantMessage('a1', 'Preview: http://localhost:5173/'),
|
||||
assistantMessage('a2', 'Open /work/demo.html')
|
||||
])
|
||||
})
|
||||
|
||||
expect($previewTarget.get()).toBeNull()
|
||||
expect(window.hermesDesktop.normalizePreviewTarget).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('registers structured tool-result preview targets', async () => {
|
||||
render(<PreviewRoutingHarness onEvent={handler => { handleEvent = handler }} />)
|
||||
|
||||
act(() =>
|
||||
handleEvent({
|
||||
payload: { path: './dist/index.html' },
|
||||
session_id: 'session-1',
|
||||
type: 'tool.complete'
|
||||
})
|
||||
)
|
||||
|
||||
await waitFor(() => {
|
||||
expect($previewTarget.get()?.source).toBe('./dist/index.html')
|
||||
})
|
||||
|
||||
expect(window.localStorage.getItem('hermes.desktop.sessionPreviews.v1')).toContain('./dist/index.html')
|
||||
})
|
||||
|
||||
it('registers html previews from edit inline diffs', async () => {
|
||||
render(<PreviewRoutingHarness onEvent={handler => { handleEvent = handler }} />)
|
||||
|
||||
act(() =>
|
||||
handleEvent({
|
||||
payload: { inline_diff: '\u001b[38;2;218;165;32ma/preview-demo.html -> b/preview-demo.html\u001b[0m\n' },
|
||||
session_id: 'session-1',
|
||||
type: 'tool.complete'
|
||||
})
|
||||
)
|
||||
|
||||
await waitFor(() => {
|
||||
expect($previewTarget.get()?.source).toBe('preview-demo.html')
|
||||
})
|
||||
})
|
||||
})
|
||||
195
apps/desktop/src/app/session/hooks/use-preview-routing.ts
Normal file
195
apps/desktop/src/app/session/hooks/use-preview-routing.ts
Normal file
|
|
@ -0,0 +1,195 @@
|
|||
import { useStore } from '@nanostores/react'
|
||||
import { type MutableRefObject, useCallback, useEffect } from 'react'
|
||||
|
||||
import { gatewayEventCompletedFileDiff } from '@/lib/gateway-events'
|
||||
import {
|
||||
$previewTarget,
|
||||
$sessionPreviewRegistry,
|
||||
beginPreviewServerRestart,
|
||||
completePreviewServerRestart,
|
||||
getSessionPreviewRecord,
|
||||
progressPreviewServerRestart,
|
||||
requestPreviewReload,
|
||||
setPreviewTarget,
|
||||
setSessionPreviewTarget
|
||||
} from '@/store/preview'
|
||||
import { $currentCwd } from '@/store/session'
|
||||
import type { RpcEvent } from '@/types/hermes'
|
||||
|
||||
type EventHandler = (event: RpcEvent) => void
|
||||
|
||||
interface PreviewRoutingOptions {
|
||||
activeSessionIdRef: MutableRefObject<string | null>
|
||||
baseHandleGatewayEvent: EventHandler
|
||||
currentCwd: string
|
||||
currentView: string
|
||||
requestGateway: <T = unknown>(method: string, params?: Record<string, unknown>) => Promise<T>
|
||||
routedSessionId: string | null
|
||||
selectedStoredSessionId: string | null
|
||||
}
|
||||
|
||||
function asRecord(payload: unknown): Record<string, unknown> {
|
||||
return payload && typeof payload === 'object' ? (payload as Record<string, unknown>) : {}
|
||||
}
|
||||
|
||||
function activePreviewSessionId(
|
||||
activeSessionIdRef: MutableRefObject<string | null>,
|
||||
routedSessionId: string | null,
|
||||
selectedStoredSessionId: string | null
|
||||
): string {
|
||||
return selectedStoredSessionId || routedSessionId || activeSessionIdRef.current || ''
|
||||
}
|
||||
|
||||
function looksLikePreviewTarget(value: string): boolean {
|
||||
return /^https?:\/\//i.test(value) || /^file:\/\//i.test(value) || /^(?:\/|\.{1,2}\/|~\/).+/.test(value)
|
||||
}
|
||||
|
||||
function stripAnsi(value: string): string {
|
||||
return value.replace(new RegExp(`${String.fromCharCode(27)}\\[[0-9;]*m`, 'g'), '')
|
||||
}
|
||||
|
||||
function htmlPathFromInlineDiff(value: string): string {
|
||||
const cleaned = stripAnsi(value).replace(/^\s*┊\s*review diff\s*\n/i, '')
|
||||
|
||||
for (const match of cleaned.matchAll(/(?:^|\s)(?:[ab]\/)?([^\s]+\.html?)(?=\s|$)/gi)) {
|
||||
const candidate = match[1]?.trim()
|
||||
|
||||
if (candidate) {
|
||||
return candidate
|
||||
}
|
||||
}
|
||||
|
||||
return ''
|
||||
}
|
||||
|
||||
function structuredPreviewCandidate(payload: unknown): string {
|
||||
const record = asRecord(payload)
|
||||
const fields = ['url', 'target', 'path', 'file', 'filepath', 'preview']
|
||||
|
||||
for (const field of fields) {
|
||||
const value = record[field]
|
||||
|
||||
if (typeof value === 'string') {
|
||||
const target = value.trim()
|
||||
|
||||
if (target && looksLikePreviewTarget(target)) {
|
||||
return target
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const inlineDiff = record.inline_diff
|
||||
|
||||
if (typeof inlineDiff === 'string') {
|
||||
return htmlPathFromInlineDiff(inlineDiff)
|
||||
}
|
||||
|
||||
return ''
|
||||
}
|
||||
|
||||
export function usePreviewRouting({
|
||||
activeSessionIdRef,
|
||||
baseHandleGatewayEvent,
|
||||
currentCwd,
|
||||
currentView,
|
||||
requestGateway,
|
||||
routedSessionId,
|
||||
selectedStoredSessionId
|
||||
}: PreviewRoutingOptions) {
|
||||
const previewRegistry = useStore($sessionPreviewRegistry)
|
||||
const previewSessionId = activePreviewSessionId(activeSessionIdRef, routedSessionId, selectedStoredSessionId)
|
||||
|
||||
useEffect(() => {
|
||||
if (currentView !== 'chat' || !previewSessionId) {
|
||||
setPreviewTarget(null)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
const record = getSessionPreviewRecord(previewSessionId)
|
||||
|
||||
setPreviewTarget(record?.normalized ?? null)
|
||||
}, [currentView, previewRegistry, previewSessionId])
|
||||
|
||||
const registerStructuredPreview = useCallback(
|
||||
async (event: RpcEvent) => {
|
||||
if (event.session_id && event.session_id !== activeSessionIdRef.current && event.session_id !== previewSessionId) {return}
|
||||
|
||||
if (!event.type.startsWith('tool.')) {return}
|
||||
|
||||
if (!previewSessionId) {return}
|
||||
|
||||
const candidate = structuredPreviewCandidate(event.payload)
|
||||
|
||||
if (!candidate) {return}
|
||||
const desktop = window.hermesDesktop
|
||||
|
||||
if (!desktop?.normalizePreviewTarget) {return}
|
||||
const sessionId = previewSessionId
|
||||
const cwd = currentCwd || ''
|
||||
const target = await desktop.normalizePreviewTarget(candidate, cwd || undefined).catch(() => null)
|
||||
|
||||
if (
|
||||
!target ||
|
||||
sessionId !== activePreviewSessionId(activeSessionIdRef, routedSessionId, selectedStoredSessionId) ||
|
||||
$currentCwd.get() !== cwd
|
||||
) {
|
||||
return
|
||||
}
|
||||
|
||||
setSessionPreviewTarget(sessionId, target, 'tool-result', candidate)
|
||||
},
|
||||
[activeSessionIdRef, currentCwd, previewSessionId, routedSessionId, selectedStoredSessionId]
|
||||
)
|
||||
|
||||
const restartPreviewServer = useCallback(
|
||||
async (url: string, context?: string) => {
|
||||
const sessionId = activeSessionIdRef.current
|
||||
|
||||
if (!sessionId) {throw new Error('No active session for background restart')}
|
||||
|
||||
const cwd = $currentCwd.get() || currentCwd || ''
|
||||
|
||||
const result = await requestGateway<{ task_id?: string }>('preview.restart', {
|
||||
context: context || undefined,
|
||||
cwd: cwd || undefined,
|
||||
session_id: sessionId,
|
||||
url
|
||||
})
|
||||
|
||||
const taskId = result.task_id || ''
|
||||
|
||||
if (!taskId) {throw new Error('Background restart did not return a task id')}
|
||||
|
||||
beginPreviewServerRestart(taskId, url)
|
||||
|
||||
return taskId
|
||||
},
|
||||
[activeSessionIdRef, currentCwd, requestGateway]
|
||||
)
|
||||
|
||||
const handleDesktopGatewayEvent = useCallback<EventHandler>(
|
||||
event => {
|
||||
baseHandleGatewayEvent(event)
|
||||
|
||||
if (event.type === 'preview.restart.complete') {
|
||||
const { task_id, text } = asRecord(event.payload)
|
||||
|
||||
if (typeof task_id === 'string' && task_id) {completePreviewServerRestart(task_id, typeof text === 'string' ? text : '')}
|
||||
} else if (event.type === 'preview.restart.progress') {
|
||||
const { task_id, text } = asRecord(event.payload)
|
||||
|
||||
if (typeof task_id === 'string' && task_id) {progressPreviewServerRestart(task_id, typeof text === 'string' ? text : '')}
|
||||
}
|
||||
|
||||
if (event.session_id && event.session_id !== activeSessionIdRef.current) {return}
|
||||
|
||||
void registerStructuredPreview(event)
|
||||
|
||||
if ($previewTarget.get()?.kind === 'url' && gatewayEventCompletedFileDiff(event)) {requestPreviewReload()}
|
||||
},
|
||||
[activeSessionIdRef, baseHandleGatewayEvent, registerStructuredPreview]
|
||||
)
|
||||
|
||||
return { handleDesktopGatewayEvent, restartPreviewServer }
|
||||
}
|
||||
|
|
@ -188,7 +188,7 @@ export function usePromptActions({
|
|||
.join('\n')
|
||||
|
||||
const hasImageAttachment = attachments.some(attachment => attachment.kind === 'image')
|
||||
const displayRefs = attachments.map(attachmentDisplayText).filter(Boolean).join('\n')
|
||||
const attachmentRefs = attachments.map(attachmentDisplayText).filter((ref): ref is string => Boolean(ref))
|
||||
|
||||
const text =
|
||||
[contextRefs, visibleText].filter(Boolean).join('\n\n') ||
|
||||
|
|
@ -201,12 +201,8 @@ export function usePromptActions({
|
|||
const userMessage: ChatMessage = {
|
||||
id: `user-${Date.now()}`,
|
||||
role: 'user',
|
||||
parts: [
|
||||
textPart(
|
||||
[displayRefs, visibleText].filter(Boolean).join('\n\n') ||
|
||||
attachments.map(attachment => attachment.label).join(', ')
|
||||
)
|
||||
]
|
||||
parts: [textPart(visibleText || (attachmentRefs.length ? '' : attachments.map(a => a.label).join(', ')))],
|
||||
attachmentRefs
|
||||
}
|
||||
|
||||
const releaseBusy = () => {
|
||||
|
|
|
|||
87
apps/desktop/src/app/session/hooks/use-route-resume.ts
Normal file
87
apps/desktop/src/app/session/hooks/use-route-resume.ts
Normal file
|
|
@ -0,0 +1,87 @@
|
|||
import { type MutableRefObject, useEffect } from 'react'
|
||||
|
||||
import { isNewChatRoute } from '@/app/routes'
|
||||
|
||||
interface RouteResumeOptions {
|
||||
activeSessionId: string | null
|
||||
activeSessionIdRef: MutableRefObject<string | null>
|
||||
creatingSessionRef: MutableRefObject<boolean>
|
||||
currentView: string
|
||||
freshDraftReady: boolean
|
||||
gatewayState: string | undefined
|
||||
locationPathname: string
|
||||
resumeSession: (sessionId: string, focus: boolean) => Promise<unknown>
|
||||
routedSessionId: string | null
|
||||
runtimeIdByStoredSessionIdRef: MutableRefObject<Map<string, string>>
|
||||
selectedStoredSessionId: string | null
|
||||
selectedStoredSessionIdRef: MutableRefObject<string | null>
|
||||
startFreshSessionDraft: (focus: boolean) => unknown
|
||||
}
|
||||
|
||||
// HashRouter boot edge case: pathname briefly reads `/` before the hash is
|
||||
// parsed. If the hash references a real session, defer; resume picks it up
|
||||
// next tick. Without this, ctrl+R on `#/:sessionId` flashes 5 loading states.
|
||||
function rawHashLooksLikeSession(): boolean {
|
||||
if (typeof window === 'undefined') {return false}
|
||||
const hash = window.location.hash.replace(/^#/, '')
|
||||
|
||||
if (!hash || hash === '/') {return false}
|
||||
|
||||
return !hash.startsWith('/settings') && !hash.startsWith('/skills') && !hash.startsWith('/artifacts')
|
||||
}
|
||||
|
||||
export function useRouteResume({
|
||||
activeSessionId,
|
||||
activeSessionIdRef,
|
||||
creatingSessionRef,
|
||||
currentView,
|
||||
freshDraftReady,
|
||||
gatewayState,
|
||||
locationPathname,
|
||||
resumeSession,
|
||||
routedSessionId,
|
||||
runtimeIdByStoredSessionIdRef,
|
||||
selectedStoredSessionId,
|
||||
selectedStoredSessionIdRef,
|
||||
startFreshSessionDraft
|
||||
}: RouteResumeOptions) {
|
||||
useEffect(() => {
|
||||
if (currentView !== 'chat' || gatewayState !== 'open') {return}
|
||||
|
||||
if (routedSessionId) {
|
||||
const cachedRuntime = runtimeIdByStoredSessionIdRef.current.get(routedSessionId)
|
||||
|
||||
const alreadyActive =
|
||||
routedSessionId === selectedStoredSessionIdRef.current &&
|
||||
Boolean(cachedRuntime) &&
|
||||
cachedRuntime === activeSessionIdRef.current
|
||||
|
||||
if (!alreadyActive) {void resumeSession(routedSessionId, true)}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
if (
|
||||
isNewChatRoute(locationPathname) &&
|
||||
!creatingSessionRef.current &&
|
||||
(selectedStoredSessionId || activeSessionId || !freshDraftReady) &&
|
||||
!rawHashLooksLikeSession()
|
||||
) {
|
||||
startFreshSessionDraft(true)
|
||||
}
|
||||
}, [
|
||||
activeSessionId,
|
||||
activeSessionIdRef,
|
||||
creatingSessionRef,
|
||||
currentView,
|
||||
freshDraftReady,
|
||||
gatewayState,
|
||||
locationPathname,
|
||||
resumeSession,
|
||||
routedSessionId,
|
||||
runtimeIdByStoredSessionIdRef,
|
||||
selectedStoredSessionId,
|
||||
selectedStoredSessionIdRef,
|
||||
startFreshSessionDraft
|
||||
])
|
||||
}
|
||||
|
|
@ -28,8 +28,8 @@ import {
|
|||
setIntroSeed,
|
||||
setMessages,
|
||||
setSelectedStoredSessionId,
|
||||
setSessionStartedAt,
|
||||
setSessions,
|
||||
setSessionStartedAt,
|
||||
setTurnStartedAt
|
||||
} from '@/store/session'
|
||||
import type { SessionCreateResponse, SessionInfo, SessionResumeResponse, UsageStats } from '@/types/hermes'
|
||||
|
|
|
|||
|
|
@ -2,17 +2,18 @@ import { useStore } from '@nanostores/react'
|
|||
import type { CSSProperties, ReactNode, PointerEvent as ReactPointerEvent } from 'react'
|
||||
import { useCallback } from 'react'
|
||||
|
||||
import { PaneShell } from '@/components/pane-shell'
|
||||
import { SidebarProvider } from '@/components/ui/sidebar'
|
||||
import { triggerHaptic } from '@/lib/haptics'
|
||||
import {
|
||||
$inspectorOpen,
|
||||
$fileBrowserOpen,
|
||||
$sidebarOpen,
|
||||
$sidebarWidth,
|
||||
FILE_BROWSER_DEFAULT_WIDTH,
|
||||
setSidebarOpen,
|
||||
setSidebarResizing,
|
||||
setSidebarWidth
|
||||
} from '@/store/layout'
|
||||
import { $previewTarget } from '@/store/preview'
|
||||
import { $connection } from '@/store/session'
|
||||
|
||||
import { StatusbarControls, type StatusbarItem } from './statusbar-controls'
|
||||
|
|
@ -21,41 +22,27 @@ import { TitlebarControls, type TitlebarTool } from './titlebar-controls'
|
|||
|
||||
interface AppShellProps {
|
||||
children: ReactNode
|
||||
inspectorWidth: string
|
||||
leftTitlebarTools?: readonly TitlebarTool[]
|
||||
leftStatusbarItems?: readonly StatusbarItem[]
|
||||
previewWidth: string
|
||||
rightRailOpen: boolean
|
||||
sidebar: ReactNode
|
||||
statusbarItems?: readonly StatusbarItem[]
|
||||
titlebarTools?: readonly TitlebarTool[]
|
||||
leftTitlebarTools?: readonly TitlebarTool[]
|
||||
onOpenSettings: () => void
|
||||
overlays?: ReactNode
|
||||
statusbarItems?: readonly StatusbarItem[]
|
||||
titlebarTools?: readonly TitlebarTool[]
|
||||
}
|
||||
|
||||
export function AppShell({
|
||||
children,
|
||||
inspectorWidth,
|
||||
leftTitlebarTools,
|
||||
leftStatusbarItems,
|
||||
previewWidth,
|
||||
rightRailOpen,
|
||||
sidebar,
|
||||
statusbarItems,
|
||||
titlebarTools,
|
||||
leftTitlebarTools,
|
||||
onOpenSettings,
|
||||
overlays
|
||||
overlays,
|
||||
statusbarItems,
|
||||
titlebarTools
|
||||
}: AppShellProps) {
|
||||
const sidebarWidth = useStore($sidebarWidth)
|
||||
const connection = useStore($connection)
|
||||
const sidebarOpen = useStore($sidebarOpen)
|
||||
const inspectorOpen = useStore($inspectorOpen)
|
||||
const previewTarget = useStore($previewTarget)
|
||||
|
||||
// The shell grid should describe visible app chrome only. Titlebar buttons
|
||||
// and draggable hit-zones are fixed overlays, so keeping an invisible grid
|
||||
// column for a closed sidebar pushes/clips the actual chat surface.
|
||||
const displayedSidebarWidth = sidebarOpen ? sidebarWidth : 0
|
||||
const fileBrowserOpen = useStore($fileBrowserOpen)
|
||||
const connection = useStore($connection)
|
||||
|
||||
const titlebarControls = titlebarControlsPosition(connection?.windowButtonPosition)
|
||||
|
||||
|
|
@ -63,18 +50,28 @@ export function AppShell({
|
|||
? 0
|
||||
: titlebarControls.left + TITLEBAR_HEIGHT + Math.round(TITLEBAR_HEIGHT / 2)
|
||||
|
||||
const showPreviewRail = rightRailOpen && Boolean(previewTarget)
|
||||
const showInspectorRail = rightRailOpen && inspectorOpen
|
||||
// The static system cluster (file-browser, haptics, settings) is hardcoded
|
||||
// in TitlebarControls. Pane-supplied tools (preview's group) render in a
|
||||
// separate cluster anchored further left.
|
||||
const SYSTEM_TOOL_COUNT = 3
|
||||
const paneToolCount = titlebarTools?.filter(tool => !tool.hidden).length ?? 0
|
||||
const systemToolsWidth = `calc(${SYSTEM_TOOL_COUNT} * var(--titlebar-control-size))`
|
||||
|
||||
const inspectorColumn = showInspectorRail ? 'var(--inspector-width)' : '0px'
|
||||
// Where the pane-tool cluster's right edge sits, measured from the inner
|
||||
// titlebar padding (--titlebar-tools-right). Two anchors:
|
||||
// - file-browser closed → flush against static cluster's left edge
|
||||
// - file-browser open → flush against the file-browser pane's left edge
|
||||
// (= preview pane's right edge)
|
||||
const previewToolbarGap = fileBrowserOpen ? FILE_BROWSER_DEFAULT_WIDTH : systemToolsWidth
|
||||
|
||||
const previewColumn = showPreviewRail
|
||||
? `min(var(--preview-width), max(0px, calc(100vw - var(--sidebar-width) - ${showInspectorRail ? 'var(--inspector-width)' : '0px'} - var(--chat-min-width))))`
|
||||
: '0px'
|
||||
|
||||
const titlebarToolCount = (titlebarTools?.filter(tool => !tool.hidden).length ?? 0) + (rightRailOpen ? 1 : 0) + 2
|
||||
|
||||
const shellGridColumns = 'var(--sidebar-width) minmax(0,1fr) var(--preview-col) var(--inspector-col)'
|
||||
// Used by the drag region to know where the rightmost interactive element
|
||||
// ends. When pane tools are present, that's `gap + paneCount * controlSize`
|
||||
// (the leftmost button is at `tools-right + gap + paneCount * size`).
|
||||
// Otherwise the static cluster's footprint is enough.
|
||||
const titlebarToolsWidth =
|
||||
paneToolCount > 0
|
||||
? `calc(${previewToolbarGap} + ${paneToolCount} * var(--titlebar-control-size))`
|
||||
: systemToolsWidth
|
||||
|
||||
const startSidebarResize = useCallback(
|
||||
(event: ReactPointerEvent<HTMLDivElement>) => {
|
||||
|
|
@ -115,69 +112,51 @@ export function AppShell({
|
|||
open={sidebarOpen}
|
||||
style={
|
||||
{
|
||||
'--inspector-width': inspectorWidth,
|
||||
'--preview-width': previewWidth,
|
||||
'--sidebar-width': `${displayedSidebarWidth}px`,
|
||||
'--chat-center-offset': '0px',
|
||||
'--shell-left-sidebar-width': `${displayedSidebarWidth}px`,
|
||||
'--shell-preview-pane-width': previewColumn,
|
||||
'--shell-right-sidebar-width': inspectorColumn,
|
||||
'--shell-right-region-width': 'calc(var(--shell-preview-pane-width) + var(--shell-right-sidebar-width))',
|
||||
'--shell-preview-toolbar-gap': showPreviewRail
|
||||
? 'max(0px, calc(var(--shell-right-sidebar-width) - (3 * var(--titlebar-control-size)) + 0.2rem))'
|
||||
: '0px',
|
||||
// Alias for shadcn <Sidebar> descendants. Resolves to the chat-sidebar
|
||||
// pane track via PaneShell's emitted --pane-chat-sidebar-width.
|
||||
'--sidebar-width': 'var(--pane-chat-sidebar-width)',
|
||||
'--titlebar-height': `${TITLEBAR_HEIGHT}px`,
|
||||
'--titlebar-content-inset': `${titlebarContentInset}px`,
|
||||
'--titlebar-controls-left': `${titlebarControls.left}px`,
|
||||
'--titlebar-controls-top': `${titlebarControls.top}px`,
|
||||
'--titlebar-tools-right': '0.75rem',
|
||||
'--titlebar-tools-width': `calc(${titlebarToolCount} * var(--titlebar-control-size) + var(--shell-preview-toolbar-gap))`
|
||||
'--titlebar-tools-width': titlebarToolsWidth,
|
||||
// Anchor for the pane-tool cluster's right edge in TitlebarControls.
|
||||
// Sourced from the layout store rather than the PaneShell-emitted
|
||||
// --pane-*-width vars because the titlebar is a sibling of PaneShell
|
||||
// and CSS variables resolve at the consumer's scope.
|
||||
'--shell-preview-toolbar-gap': previewToolbarGap
|
||||
} as CSSProperties
|
||||
}
|
||||
>
|
||||
<TitlebarControls
|
||||
leftTools={leftTitlebarTools}
|
||||
onOpenSettings={onOpenSettings}
|
||||
showInspectorToggle={rightRailOpen}
|
||||
tools={titlebarTools}
|
||||
/>
|
||||
<TitlebarControls leftTools={leftTitlebarTools} onOpenSettings={onOpenSettings} tools={titlebarTools} />
|
||||
|
||||
<main
|
||||
className="relative grid h-screen w-full overflow-hidden bg-background pr-0.75 pb-0.75 pt-0.75 transition-none"
|
||||
style={
|
||||
{
|
||||
'--inspector-col': inspectorColumn,
|
||||
'--preview-col': previewColumn,
|
||||
gridTemplateColumns: shellGridColumns,
|
||||
gridTemplateRows: 'minmax(0,1fr) auto'
|
||||
} as CSSProperties
|
||||
}
|
||||
>
|
||||
<div
|
||||
aria-hidden="true"
|
||||
className="pointer-events-none absolute left-0 top-0 z-1 h-(--titlebar-height) w-(--titlebar-controls-left) [-webkit-app-region:drag]"
|
||||
/>
|
||||
<div
|
||||
aria-hidden="true"
|
||||
className="pointer-events-none absolute top-0 z-1 h-(--titlebar-height) left-[calc(var(--titlebar-controls-left)+(var(--titlebar-control-size)*2)+0.75rem)] right-[calc(var(--titlebar-tools-right)+var(--titlebar-tools-width)+0.75rem)] [-webkit-app-region:drag]"
|
||||
/>
|
||||
|
||||
<div className="col-start-1 col-end-2 row-start-1 min-h-0 overflow-hidden">{sidebar}</div>
|
||||
|
||||
{sidebarOpen && (
|
||||
<main className="relative flex h-screen w-full flex-col overflow-hidden bg-background pr-0.75 pb-0.75 pt-0.75 transition-none">
|
||||
<PaneShell className="min-h-0 flex-1">
|
||||
<div
|
||||
aria-label="Resize sidebar"
|
||||
aria-orientation="vertical"
|
||||
className="group absolute bottom-0 top-0 left-[calc(var(--sidebar-width)-0.5rem)] z-5 w-4 cursor-col-resize [-webkit-app-region:no-drag]"
|
||||
onPointerDown={startSidebarResize}
|
||||
role="separator"
|
||||
tabIndex={0}
|
||||
>
|
||||
<span className="absolute left-1/2 top-1/2 h-23 w-0.75 -translate-x-1/2 -translate-y-1/2 rounded-full bg-muted-foreground/80 opacity-0 transition-opacity duration-100 group-hover:opacity-[0.65] group-focus-visible:opacity-[0.65]" />
|
||||
</div>
|
||||
)}
|
||||
aria-hidden="true"
|
||||
className="pointer-events-none absolute left-0 top-0 z-1 h-(--titlebar-height) w-(--titlebar-controls-left) [-webkit-app-region:drag]"
|
||||
/>
|
||||
<div
|
||||
aria-hidden="true"
|
||||
className="pointer-events-none absolute top-0 z-1 h-(--titlebar-height) left-[calc(var(--titlebar-controls-left)+(var(--titlebar-control-size)*2)+0.75rem)] right-[calc(var(--titlebar-tools-right)+var(--titlebar-tools-width)+0.75rem)] [-webkit-app-region:drag]"
|
||||
/>
|
||||
|
||||
{children}
|
||||
{children}
|
||||
|
||||
{sidebarOpen && (
|
||||
<div
|
||||
aria-label="Resize sidebar"
|
||||
aria-orientation="vertical"
|
||||
className="group absolute bottom-0 top-0 left-[calc(var(--pane-chat-sidebar-width)-0.5rem)] z-5 w-4 cursor-col-resize [-webkit-app-region:no-drag]"
|
||||
onPointerDown={startSidebarResize}
|
||||
role="separator"
|
||||
tabIndex={0}
|
||||
>
|
||||
<span className="absolute left-1/2 top-1/2 h-23 w-0.75 -translate-x-1/2 -translate-y-1/2 rounded-full bg-muted-foreground/80 opacity-0 transition-opacity duration-100 group-hover:opacity-[0.65] group-focus-visible:opacity-[0.65]" />
|
||||
</div>
|
||||
)}
|
||||
</PaneShell>
|
||||
|
||||
<StatusbarControls items={statusbarItems} leftItems={leftStatusbarItems} />
|
||||
</main>
|
||||
|
|
|
|||
68
apps/desktop/src/app/shell/hooks/use-overlay-routing.ts
Normal file
68
apps/desktop/src/app/shell/hooks/use-overlay-routing.ts
Normal file
|
|
@ -0,0 +1,68 @@
|
|||
import { useCallback, useEffect, useMemo, useRef } from 'react'
|
||||
import { useLocation, useNavigate } from 'react-router-dom'
|
||||
|
||||
import { type CommandCenterSection } from '@/app/command-center'
|
||||
import { AGENTS_ROUTE, appViewForPath, COMMAND_CENTER_ROUTE, NEW_CHAT_ROUTE } from '@/app/routes'
|
||||
|
||||
const SECTIONS = ['models', 'sessions', 'system'] as const
|
||||
const OVERLAY_VIEWS = new Set(['settings', 'command-center', 'agents'])
|
||||
|
||||
export function useOverlayRouting() {
|
||||
const location = useLocation()
|
||||
const navigate = useNavigate()
|
||||
|
||||
const currentView = appViewForPath(location.pathname)
|
||||
const settingsOpen = currentView === 'settings'
|
||||
const commandCenterOpen = currentView === 'command-center'
|
||||
const agentsOpen = currentView === 'agents'
|
||||
const chatOpen = currentView === 'chat'
|
||||
const overlayOpen = OVERLAY_VIEWS.has(currentView)
|
||||
|
||||
// Overlay routes (settings/command-center/agents) stash the underlying path
|
||||
// so closing them returns there instead of bouncing to /.
|
||||
const returnPathRef = useRef(NEW_CHAT_ROUTE)
|
||||
|
||||
useEffect(() => {
|
||||
if (!overlayOpen) {
|
||||
returnPathRef.current = `${location.pathname}${location.search}${location.hash}`
|
||||
}
|
||||
}, [location.hash, location.pathname, location.search, overlayOpen])
|
||||
|
||||
const commandCenterInitialSection = useMemo<CommandCenterSection | undefined>(
|
||||
() => SECTIONS.find(value => value === new URLSearchParams(location.search).get('section')),
|
||||
[location.search]
|
||||
)
|
||||
|
||||
const openCommandCenterSection = useCallback(
|
||||
(section: CommandCenterSection) => navigate(`${COMMAND_CENTER_ROUTE}?section=${section}`),
|
||||
[navigate]
|
||||
)
|
||||
|
||||
const closeOverlayToPreviousRoute = useCallback(
|
||||
() => navigate(returnPathRef.current || NEW_CHAT_ROUTE, { replace: true }),
|
||||
[navigate]
|
||||
)
|
||||
|
||||
const toggleCommandCenter = useCallback(() => {
|
||||
if (commandCenterOpen) {
|
||||
closeOverlayToPreviousRoute()
|
||||
} else {
|
||||
navigate(COMMAND_CENTER_ROUTE)
|
||||
}
|
||||
}, [closeOverlayToPreviousRoute, commandCenterOpen, navigate])
|
||||
|
||||
const openAgents = useCallback(() => navigate(AGENTS_ROUTE), [navigate])
|
||||
|
||||
return {
|
||||
agentsOpen,
|
||||
chatOpen,
|
||||
closeOverlayToPreviousRoute,
|
||||
commandCenterInitialSection,
|
||||
commandCenterOpen,
|
||||
currentView,
|
||||
openAgents,
|
||||
openCommandCenterSection,
|
||||
settingsOpen,
|
||||
toggleCommandCenter
|
||||
}
|
||||
}
|
||||
42
apps/desktop/src/app/shell/hooks/use-status-snapshot.ts
Normal file
42
apps/desktop/src/app/shell/hooks/use-status-snapshot.ts
Normal file
|
|
@ -0,0 +1,42 @@
|
|||
import { useEffect, useState } from 'react'
|
||||
|
||||
import { getLogs, getStatus } from '@/hermes'
|
||||
import type { StatusResponse } from '@/types/hermes'
|
||||
|
||||
const REFRESH_MS = 15_000
|
||||
const LOG_TAIL = 12
|
||||
|
||||
export function useStatusSnapshot(gatewayState: string | undefined) {
|
||||
const [statusSnapshot, setStatusSnapshot] = useState<StatusResponse | null>(null)
|
||||
const [gatewayLogLines, setGatewayLogLines] = useState<string[]>([])
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
|
||||
const refresh = async () => {
|
||||
try {
|
||||
const [next, logs] = await Promise.all([
|
||||
getStatus(),
|
||||
getLogs({ file: 'gateway', lines: LOG_TAIL }).catch(() => ({ lines: [] }))
|
||||
])
|
||||
|
||||
if (cancelled) {return}
|
||||
|
||||
setStatusSnapshot(next)
|
||||
setGatewayLogLines(logs.lines.map(line => line.trim()).filter(Boolean))
|
||||
} catch {
|
||||
// Keep last snapshot through transient gateway flaps.
|
||||
}
|
||||
}
|
||||
|
||||
void refresh()
|
||||
const timer = window.setInterval(() => void refresh(), REFRESH_MS)
|
||||
|
||||
return () => {
|
||||
cancelled = true
|
||||
window.clearInterval(timer)
|
||||
}
|
||||
}, [gatewayState])
|
||||
|
||||
return { gatewayLogLines, statusSnapshot }
|
||||
}
|
||||
218
apps/desktop/src/app/shell/hooks/use-statusbar-items.tsx
Normal file
218
apps/desktop/src/app/shell/hooks/use-statusbar-items.tsx
Normal file
|
|
@ -0,0 +1,218 @@
|
|||
import { useStore } from '@nanostores/react'
|
||||
import { useMemo } from 'react'
|
||||
|
||||
import type { CommandCenterSection } from '@/app/command-center'
|
||||
import { buildGatewayLogItems } from '@/lib/gateway-events'
|
||||
import { Activity, AlertCircle, Command, Cpu, FolderOpen, GitBranch, Loader2, Sparkles } from '@/lib/icons'
|
||||
import { compactPath, contextBarLabel, LiveDuration, usageContextLabel } from '@/lib/statusbar'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { $desktopActionTasks } from '@/store/activity'
|
||||
import { $previewServerRestartStatus } from '@/store/preview'
|
||||
import {
|
||||
$busy,
|
||||
$currentBranch,
|
||||
$currentCwd,
|
||||
$currentModel,
|
||||
$currentProvider,
|
||||
$currentUsage,
|
||||
$sessionStartedAt,
|
||||
$turnStartedAt,
|
||||
$workingSessionIds,
|
||||
setModelPickerOpen
|
||||
} from '@/store/session'
|
||||
import type { StatusResponse } from '@/types/hermes'
|
||||
|
||||
import type { StatusbarItem, StatusbarMenuItem } from '../statusbar-controls'
|
||||
|
||||
interface StatusbarItemsOptions {
|
||||
agentsOpen: boolean
|
||||
browseSessionCwd: () => Promise<void>
|
||||
commandCenterOpen: boolean
|
||||
extraLeftItems: readonly StatusbarItem[]
|
||||
extraRightItems: readonly StatusbarItem[]
|
||||
gatewayLogLines: readonly string[]
|
||||
openAgents: () => void
|
||||
openCommandCenterSection: (section: CommandCenterSection) => void
|
||||
statusSnapshot: StatusResponse | null
|
||||
toggleCommandCenter: () => void
|
||||
}
|
||||
|
||||
export function useStatusbarItems({
|
||||
agentsOpen,
|
||||
browseSessionCwd,
|
||||
commandCenterOpen,
|
||||
extraLeftItems,
|
||||
extraRightItems,
|
||||
gatewayLogLines,
|
||||
openAgents,
|
||||
openCommandCenterSection,
|
||||
statusSnapshot,
|
||||
toggleCommandCenter
|
||||
}: StatusbarItemsOptions) {
|
||||
const busy = useStore($busy)
|
||||
const currentBranch = useStore($currentBranch)
|
||||
const currentCwd = useStore($currentCwd)
|
||||
const currentModel = useStore($currentModel)
|
||||
const currentProvider = useStore($currentProvider)
|
||||
const currentUsage = useStore($currentUsage)
|
||||
const desktopActionTasks = useStore($desktopActionTasks)
|
||||
const previewServerRestartStatus = useStore($previewServerRestartStatus)
|
||||
const sessionStartedAt = useStore($sessionStartedAt)
|
||||
const turnStartedAt = useStore($turnStartedAt)
|
||||
const workingSessionIds = useStore($workingSessionIds)
|
||||
|
||||
const contextUsage = useMemo(() => usageContextLabel(currentUsage), [currentUsage])
|
||||
const contextBar = useMemo(() => contextBarLabel(currentUsage), [currentUsage])
|
||||
|
||||
const platformMenuItems = useMemo<readonly StatusbarMenuItem[]>(
|
||||
() =>
|
||||
Object.entries(statusSnapshot?.gateway_platforms || {})
|
||||
.sort(([l], [r]) => l.localeCompare(r))
|
||||
.map(([name, platform]) => ({ disabled: true, id: `platform:${name}`, label: `${name} · ${platform.state}` })),
|
||||
[statusSnapshot?.gateway_platforms]
|
||||
)
|
||||
|
||||
const gatewayMenuItems = useMemo<readonly StatusbarMenuItem[]>(
|
||||
() => [
|
||||
{ id: 'gateway:open-system', label: 'Open system panel', onSelect: () => openCommandCenterSection('system') },
|
||||
...buildGatewayLogItems(gatewayLogLines),
|
||||
...platformMenuItems
|
||||
],
|
||||
[gatewayLogLines, openCommandCenterSection, platformMenuItems]
|
||||
)
|
||||
|
||||
const { bgFailed, bgRunning } = useMemo(() => {
|
||||
const actions = Object.values(desktopActionTasks)
|
||||
const running = actions.filter(t => t.status.running).length
|
||||
const failed = actions.filter(t => !t.status.running && (t.status.exit_code ?? 0) !== 0).length
|
||||
const previewRunning = previewServerRestartStatus === 'running' ? 1 : 0
|
||||
const previewFailed = previewServerRestartStatus === 'error' ? 1 : 0
|
||||
|
||||
return { bgFailed: failed + previewFailed, bgRunning: workingSessionIds.length + running + previewRunning }
|
||||
}, [desktopActionTasks, previewServerRestartStatus, workingSessionIds])
|
||||
|
||||
const gatewayUp = Boolean(statusSnapshot?.gateway_running)
|
||||
|
||||
const coreLeftStatusbarItems = useMemo<readonly StatusbarItem[]>(
|
||||
() => [
|
||||
{
|
||||
className: `h-6 w-6 justify-center px-0${commandCenterOpen ? ' bg-accent/55 text-foreground' : ''}`,
|
||||
icon: <Command className="size-3.5" />,
|
||||
id: 'command-center',
|
||||
onSelect: toggleCommandCenter,
|
||||
title: commandCenterOpen ? 'Close Command Center' : 'Open Command Center',
|
||||
variant: 'action'
|
||||
},
|
||||
{
|
||||
className: gatewayUp ? undefined : 'text-destructive hover:text-destructive',
|
||||
detail: gatewayUp ? statusSnapshot?.gateway_state || 'online' : 'offline',
|
||||
icon: gatewayUp ? <Activity className="size-3" /> : <AlertCircle className="size-3" />,
|
||||
id: 'gateway-health',
|
||||
label: 'Gateway',
|
||||
menuClassName: 'w-96',
|
||||
menuItems: gatewayMenuItems,
|
||||
title: 'Gateway and platform health',
|
||||
variant: 'menu'
|
||||
},
|
||||
{
|
||||
className: cn(
|
||||
agentsOpen && 'bg-accent/55 text-foreground',
|
||||
bgFailed > 0 && 'text-destructive hover:text-destructive'
|
||||
),
|
||||
detail: bgFailed > 0 ? `${bgFailed} failed` : bgRunning > 0 ? `${bgRunning} running` : undefined,
|
||||
icon:
|
||||
bgFailed > 0 ? (
|
||||
<AlertCircle className="size-3" />
|
||||
) : bgRunning > 0 ? (
|
||||
<Loader2 className="size-3 animate-spin" />
|
||||
) : (
|
||||
<Sparkles className="size-3" />
|
||||
),
|
||||
id: 'agents',
|
||||
label: 'Agents',
|
||||
onSelect: openAgents,
|
||||
title: agentsOpen ? 'Close agents' : 'Open agents',
|
||||
variant: 'action'
|
||||
}
|
||||
],
|
||||
[
|
||||
agentsOpen,
|
||||
bgFailed,
|
||||
bgRunning,
|
||||
commandCenterOpen,
|
||||
gatewayMenuItems,
|
||||
gatewayUp,
|
||||
openAgents,
|
||||
statusSnapshot?.gateway_state,
|
||||
toggleCommandCenter
|
||||
]
|
||||
)
|
||||
|
||||
const coreRightStatusbarItems = useMemo<readonly StatusbarItem[]>(
|
||||
() => [
|
||||
{
|
||||
detail: <LiveDuration since={turnStartedAt} />,
|
||||
hidden: !busy || !turnStartedAt,
|
||||
icon: <Loader2 className="size-3 animate-spin" />,
|
||||
id: 'running-timer',
|
||||
label: 'Running',
|
||||
title: 'Current turn elapsed',
|
||||
variant: 'text'
|
||||
},
|
||||
{
|
||||
detail: contextBar || undefined,
|
||||
hidden: !contextUsage,
|
||||
id: 'context-usage',
|
||||
label: contextUsage,
|
||||
title: 'Context usage',
|
||||
variant: 'text'
|
||||
},
|
||||
{
|
||||
detail: <LiveDuration since={sessionStartedAt} />,
|
||||
hidden: !sessionStartedAt,
|
||||
id: 'session-timer',
|
||||
label: 'Session',
|
||||
title: 'Runtime session elapsed',
|
||||
variant: 'text'
|
||||
},
|
||||
{
|
||||
detail: currentProvider || '',
|
||||
icon: <Cpu className="size-3" />,
|
||||
id: 'model-summary',
|
||||
label: currentModel || 'No model selected',
|
||||
onSelect: () => setModelPickerOpen(true),
|
||||
title: currentProvider ? `Switch model · ${currentProvider}: ${currentModel || ''}` : 'Open model picker',
|
||||
variant: 'action'
|
||||
},
|
||||
{
|
||||
icon: <FolderOpen className="size-3" />,
|
||||
id: 'cwd',
|
||||
label: currentCwd ? compactPath(currentCwd) : 'No project cwd',
|
||||
onSelect: () => void browseSessionCwd(),
|
||||
title: currentCwd ? `Change working directory · ${currentCwd}` : 'Choose working directory',
|
||||
variant: 'action'
|
||||
},
|
||||
{
|
||||
hidden: !currentBranch,
|
||||
icon: <GitBranch className="size-3" />,
|
||||
id: 'branch',
|
||||
label: currentBranch,
|
||||
title: currentBranch ? `Current branch: ${currentBranch}` : undefined,
|
||||
variant: 'text'
|
||||
}
|
||||
],
|
||||
[browseSessionCwd, busy, contextBar, contextUsage, currentBranch, currentCwd, currentModel, currentProvider, sessionStartedAt, turnStartedAt]
|
||||
)
|
||||
|
||||
const leftStatusbarItems = useMemo(
|
||||
() => [...coreLeftStatusbarItems, ...extraLeftItems],
|
||||
[coreLeftStatusbarItems, extraLeftItems]
|
||||
)
|
||||
|
||||
const statusbarItems = useMemo(
|
||||
() => [...extraRightItems, ...coreRightStatusbarItems],
|
||||
[coreRightStatusbarItems, extraRightItems]
|
||||
)
|
||||
|
||||
return { leftStatusbarItems, statusbarItems }
|
||||
}
|
||||
|
|
@ -48,7 +48,7 @@ interface StatusbarControlsProps extends ComponentProps<'footer'> {
|
|||
}
|
||||
|
||||
const statusbarItemClass =
|
||||
'inline-flex h-6 items-center gap-1.5 rounded-md px-2 text-[0.69rem] text-muted-foreground/95 transition-colors hover:bg-accent/55 hover:text-foreground disabled:cursor-default disabled:opacity-45'
|
||||
'inline-flex h-5 items-center gap-1 rounded px-1 text-[0.68rem] text-muted-foreground/95 transition-colors hover:bg-accent/55 hover:text-foreground disabled:cursor-default disabled:opacity-45'
|
||||
|
||||
export function StatusbarControls({ className, leftItems = [], items = [], ...props }: StatusbarControlsProps) {
|
||||
const navigate = useNavigate()
|
||||
|
|
@ -56,17 +56,17 @@ export function StatusbarControls({ className, leftItems = [], items = [], ...pr
|
|||
return (
|
||||
<footer
|
||||
className={cn(
|
||||
'col-span-4 row-start-2 row-end-3 flex h-7 items-center justify-between gap-2 border-t border-border/55 bg-[color-mix(in_srgb,var(--dt-muted)_45%,var(--dt-card))] px-2.5 py-1 text-muted-foreground/95 [-webkit-app-region:no-drag]',
|
||||
'flex h-7 shrink-0 items-center justify-between gap-2 border-t border-border/55 bg-[color-mix(in_srgb,var(--dt-muted)_45%,var(--dt-card))] px-2.5 py-1 text-muted-foreground/95 [-webkit-app-region:no-drag]',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<div className="flex min-w-0 items-center gap-1 overflow-x-auto">
|
||||
<div className="flex min-w-0 items-center gap-0.5 overflow-x-auto">
|
||||
{leftItems.filter(item => !item.hidden).map(item => (
|
||||
<StatusbarItemView item={item} key={`left:${item.id}`} navigate={navigate} />
|
||||
))}
|
||||
</div>
|
||||
<div className="flex min-w-0 items-center gap-1 overflow-x-auto">
|
||||
<div className="flex min-w-0 items-center gap-0.5 overflow-x-auto">
|
||||
{items.filter(item => !item.hidden).map(item => (
|
||||
<StatusbarItemView item={item} key={`right:${item.id}`} navigate={navigate} />
|
||||
))}
|
||||
|
|
@ -147,7 +147,7 @@ function StatusbarItemView({
|
|||
|
||||
if (item.variant === 'text' && !item.onSelect && !item.to && !item.href) {
|
||||
return (
|
||||
<div className={cn('inline-flex items-center gap-1.5 px-1 text-[0.69rem] text-muted-foreground/90', item.className)}>
|
||||
<div className={cn('inline-flex h-5 items-center gap-1 px-0.5 text-[0.68rem] text-muted-foreground/90', item.className)}>
|
||||
{content}
|
||||
</div>
|
||||
)
|
||||
|
|
|
|||
|
|
@ -3,10 +3,10 @@ import type { ComponentProps, ReactNode } from 'react'
|
|||
import { useNavigate } from 'react-router-dom'
|
||||
|
||||
import { triggerHaptic } from '@/lib/haptics'
|
||||
import { NotebookTabs, Settings, SlidersHorizontal, Volume2, VolumeX } from '@/lib/icons'
|
||||
import { FolderOpen, NotebookTabs, Settings, Volume2, VolumeX } from '@/lib/icons'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { $hapticsMuted, toggleHapticsMuted } from '@/store/haptics'
|
||||
import { $inspectorOpen, $sidebarOpen, toggleInspectorOpen, toggleSidebarOpen } from '@/store/layout'
|
||||
import { $fileBrowserOpen, $sidebarOpen, toggleFileBrowserOpen, toggleSidebarOpen } from '@/store/layout'
|
||||
|
||||
import { titlebarButtonClass } from './titlebar'
|
||||
|
||||
|
|
@ -29,21 +29,15 @@ export type SetTitlebarToolGroup = (id: string, tools: readonly TitlebarTool[],
|
|||
|
||||
interface TitlebarControlsProps extends ComponentProps<'div'> {
|
||||
leftTools?: readonly TitlebarTool[]
|
||||
showInspectorToggle: boolean
|
||||
tools?: readonly TitlebarTool[]
|
||||
onOpenSettings: () => void
|
||||
}
|
||||
|
||||
export function TitlebarControls({
|
||||
leftTools = [],
|
||||
showInspectorToggle,
|
||||
tools = [],
|
||||
onOpenSettings
|
||||
}: TitlebarControlsProps) {
|
||||
export function TitlebarControls({ leftTools = [], tools = [], onOpenSettings }: TitlebarControlsProps) {
|
||||
const navigate = useNavigate()
|
||||
const hapticsMuted = useStore($hapticsMuted)
|
||||
const fileBrowserOpen = useStore($fileBrowserOpen)
|
||||
const sidebarOpen = useStore($sidebarOpen)
|
||||
const inspectorOpen = useStore($inspectorOpen)
|
||||
|
||||
const toggleHaptics = () => {
|
||||
if (!hapticsMuted) {
|
||||
|
|
@ -70,17 +64,16 @@ export function TitlebarControls({
|
|||
...leftTools
|
||||
]
|
||||
|
||||
const rightToolbarTools: TitlebarTool[] = [
|
||||
...tools,
|
||||
// Static system tools — always pinned to the screen's right edge.
|
||||
const systemTools: TitlebarTool[] = [
|
||||
{
|
||||
active: inspectorOpen,
|
||||
hidden: !showInspectorToggle,
|
||||
icon: <SlidersHorizontal />,
|
||||
id: 'session-details',
|
||||
label: inspectorOpen ? 'Hide session details' : 'Show session details',
|
||||
active: fileBrowserOpen,
|
||||
icon: <FolderOpen />,
|
||||
id: 'file-browser',
|
||||
label: fileBrowserOpen ? 'Hide file browser' : 'Show file browser',
|
||||
onSelect: () => {
|
||||
triggerHaptic('tap')
|
||||
toggleInspectorOpen()
|
||||
toggleFileBrowserOpen()
|
||||
}
|
||||
},
|
||||
{
|
||||
|
|
@ -101,6 +94,9 @@ export function TitlebarControls({
|
|||
}
|
||||
]
|
||||
|
||||
const visibleSystemTools = systemTools.filter(tool => !tool.hidden)
|
||||
const visiblePaneTools = tools.filter(tool => !tool.hidden)
|
||||
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
|
|
@ -114,15 +110,32 @@ export function TitlebarControls({
|
|||
))}
|
||||
</div>
|
||||
|
||||
{/*
|
||||
Pane-scoped tools (preview's monitor / devtools / refresh / X) render
|
||||
as their own fixed cluster. AppShell sets --shell-preview-toolbar-gap
|
||||
to either the static cluster's width (file-browser closed → cluster
|
||||
sits flush against system tools) or the file-browser pane's width
|
||||
(file-browser open → cluster sits flush against the file-browser pane,
|
||||
i.e. at the preview pane's right edge). No margin hacks needed.
|
||||
*/}
|
||||
{visiblePaneTools.length > 0 && (
|
||||
<div
|
||||
aria-label="Pane controls"
|
||||
className="fixed top-(--titlebar-controls-top) right-[calc(var(--titlebar-tools-right)+var(--shell-preview-toolbar-gap,0px))] z-70 flex flex-row items-center gap-px pointer-events-auto select-none [-webkit-app-region:no-drag]"
|
||||
>
|
||||
{visiblePaneTools.map(tool => (
|
||||
<TitlebarToolButton key={tool.id} navigate={navigate} tool={tool} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div
|
||||
aria-label="App controls"
|
||||
className="fixed right-(--titlebar-tools-right) top-(--titlebar-controls-top) z-70 flex flex-row items-center justify-end gap-px pointer-events-auto select-none [-webkit-app-region:no-drag]"
|
||||
>
|
||||
{rightToolbarTools
|
||||
.filter(tool => !tool.hidden)
|
||||
.map(tool => (
|
||||
<TitlebarToolButton key={tool.id} navigate={navigate} tool={tool} />
|
||||
))}
|
||||
{visibleSystemTools.map(tool => (
|
||||
<TitlebarToolButton key={tool.id} navigate={navigate} tool={tool} />
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
|
|
|
|||
|
|
@ -51,7 +51,7 @@ export type CommandDispatchResponse =
|
|||
| SkillCommandDispatchResponse
|
||||
| SendCommandDispatchResponse
|
||||
|
||||
export type SidebarNavId = 'new-session' | 'skills' | 'artifacts'
|
||||
export type SidebarNavId = 'new-session' | 'command-center' | 'settings' | 'skills' | 'artifacts'
|
||||
|
||||
export interface SidebarNavItem {
|
||||
id: SidebarNavId
|
||||
|
|
|
|||
|
|
@ -186,10 +186,10 @@ function shortLabel(type: HermesRefType, id: string): string {
|
|||
}
|
||||
|
||||
/**
|
||||
* Renders a text message part with our directive segments as inline chips.
|
||||
* Unknown directive types fall through as plain text.
|
||||
* Renders text containing Hermes directives (`@file:...`, `@image:...`) as
|
||||
* inline chips. Embedded MEDIA images render below as a thumbnail row.
|
||||
*/
|
||||
export const DirectiveText: TextMessagePartComponent = ({ text }: TextMessagePartProps) => {
|
||||
export function DirectiveContent({ text }: { text: string }) {
|
||||
const { cleanedText, images } = useMemo(() => extractEmbeddedImages(text ?? ''), [text])
|
||||
const segments = useMemo(() => hermesDirectiveFormatter.parse(cleanedText), [cleanedText])
|
||||
|
||||
|
|
@ -220,6 +220,11 @@ export const DirectiveText: TextMessagePartComponent = ({ text }: TextMessagePar
|
|||
)
|
||||
}
|
||||
|
||||
/** assistant-ui adapter: same renderer, exposed as a TextMessagePartComponent. */
|
||||
export const DirectiveText: TextMessagePartComponent = ({ text }: TextMessagePartProps) => (
|
||||
<DirectiveContent text={text ?? ''} />
|
||||
)
|
||||
|
||||
const DirectiveChip: FC<{
|
||||
type: string
|
||||
label: string
|
||||
|
|
@ -230,14 +235,14 @@ const DirectiveChip: FC<{
|
|||
return (
|
||||
<span
|
||||
className={cn(
|
||||
'mx-0.5 inline-flex max-w-64 items-center gap-1 rounded-full bg-[color-mix(in_srgb,var(--dt-primary)_16%,transparent)] px-2 py-0.5 align-[0.02em] text-[0.92em] font-semibold leading-tight text-primary ring-1 ring-inset ring-primary/10'
|
||||
'mx-0.5 inline-flex max-w-56 items-center gap-1 border border-primary/20 bg-primary/8 px-1.5 py-0.5 align-[0.02em] text-[0.86em] font-medium leading-none text-primary'
|
||||
)}
|
||||
data-directive-id={id}
|
||||
data-directive-type={type}
|
||||
data-slot="aui_directive-chip"
|
||||
title={id}
|
||||
>
|
||||
{Icon && <Icon className="size-3.5 shrink-0 text-primary" />}
|
||||
{Icon && <Icon className="size-3 shrink-0 text-primary" />}
|
||||
<span className="truncate">{label}</span>
|
||||
</span>
|
||||
)
|
||||
|
|
|
|||
|
|
@ -175,10 +175,10 @@ export const Intro: FC<IntroProps> = ({ personality, seed }) => {
|
|||
}, [])
|
||||
|
||||
return (
|
||||
<div className="pointer-events-none flex min-h-[calc(100vh-var(--titlebar-height)-var(--thread-composer-clearance)-var(--composer-shell-pad-block-end))] flex-col items-center justify-center px-[calc(var(--vsq)*50)] text-center text-muted-foreground">
|
||||
<div className="pointer-events-none flex min-h-[calc(100vh-var(--titlebar-height)-var(--thread-composer-clearance)-var(--composer-shell-pad-block-end))] w-full min-w-0 flex-col items-center justify-center px-3 py-8 text-center text-muted-foreground sm:px-6 lg:px-8">
|
||||
<button
|
||||
aria-label="Change Hermes pose"
|
||||
className="pointer-events-auto mb-5 h-56 w-64 cursor-default border-0 bg-transparent p-0"
|
||||
className="pointer-events-auto mb-5 aspect-8/7 w-full max-w-64 cursor-default border-0 bg-transparent p-0"
|
||||
onClick={advanceFrame}
|
||||
type="button"
|
||||
>
|
||||
|
|
@ -191,9 +191,11 @@ export const Intro: FC<IntroProps> = ({ personality, seed }) => {
|
|||
style={{ transform: `translateX(${spriteOffsetPx}px) scale(1.1)` }}
|
||||
/>
|
||||
</button>
|
||||
<p className="mb-3 text-xs font-medium uppercase tracking-[0.18em] text-muted-foreground/75">Hermes Agent</p>
|
||||
<h1 className="mb-2.5 text-xl font-semibold tracking-tight text-foreground">{copy.headline}</h1>
|
||||
<p className="m-0 max-w-120 leading-normal">{copy.body}</p>
|
||||
<div className="w-full min-w-0 max-w-xl">
|
||||
<p className="mb-3 text-xs font-medium uppercase tracking-[0.18em] text-muted-foreground/75">Hermes Agent</p>
|
||||
<h1 className="mb-2.5 text-xl font-semibold tracking-tight text-foreground">{copy.headline}</h1>
|
||||
<p className="m-0 leading-normal">{copy.body}</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { preprocessMarkdown } from './markdown-text'
|
||||
import { preprocessMarkdown } from '@/lib/markdown-preprocess'
|
||||
|
||||
describe('preprocessMarkdown', () => {
|
||||
it('strips inline accidental triple-backtick starts', () => {
|
||||
|
|
|
|||
|
|
@ -7,9 +7,9 @@ import { type ComponentProps, memo, useEffect, useMemo, useState } from 'react'
|
|||
import { PreviewAttachment } from '@/components/assistant-ui/preview-attachment'
|
||||
import { SyntaxHighlighter } from '@/components/assistant-ui/shiki-highlighter'
|
||||
import { ZoomableImage } from '@/components/assistant-ui/zoomable-image'
|
||||
import { triggerHaptic } from '@/lib/haptics'
|
||||
import { Check, Copy } from '@/lib/icons'
|
||||
import { isLikelyProseCodeBlock, isLikelyProseFence, sanitizeLanguageTag } from '@/lib/markdown-code'
|
||||
import { CopyButton } from '@/components/ui/copy-button'
|
||||
import { isLikelyProseCodeBlock, sanitizeLanguageTag } from '@/lib/markdown-code'
|
||||
import { preprocessMarkdown } from '@/lib/markdown-preprocess'
|
||||
import {
|
||||
filePathFromMediaPath,
|
||||
mediaExternalUrl,
|
||||
|
|
@ -18,172 +18,10 @@ import {
|
|||
mediaName,
|
||||
mediaPathFromMarkdownHref
|
||||
} from '@/lib/media'
|
||||
import { isLikelyPreviewCandidate, previewTargetFromMarkdownHref, stripPreviewTargets } from '@/lib/preview-targets'
|
||||
import { previewTargetFromMarkdownHref } from '@/lib/preview-targets'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
/**
|
||||
* Strip provider/model "thinking" blocks before markdown render.
|
||||
*
|
||||
* Some Hermes providers stream raw `<think>…</think>` and similar into
|
||||
* assistant text. Proper reasoning UI uses dedicated `reasoning.*` parts.
|
||||
*/
|
||||
const REASONING_BLOCK_RE = /<(think|thinking|reasoning|scratchpad|analysis)>[\s\S]*?<\/\1>\s*/gi
|
||||
const PREVIEW_MARKER_RE = /\[Preview:[^\]]+\]\(#preview[:/][^)]+\)/gi
|
||||
|
||||
const FENCE_LINE_RE = /^([ \t]*)(`{3,}|~{3,})([^\n]*)$/
|
||||
const MIDLINE_FENCE_RE = /([^\n])(`{3,}|~{3,})(?=\s|$)/g
|
||||
const EMPTY_FENCE_BLOCK_RE = /(^|\n)[ \t]*(?:`{3,}|~{3,})[^\n]*\n[ \t]*(?:`{3,}|~{3,})[ \t]*(?=\n|$)/g
|
||||
|
||||
function stripMidlineFenceStarts(text: string): string {
|
||||
// Providers often stream inline fence noise like `200.``` http://...`.
|
||||
// A real fenced block must start at the beginning of a line; anything
|
||||
// mid-line should be treated as literal/prose and never allowed to create
|
||||
// an empty Streamdown code-card shell.
|
||||
return text.replace(MIDLINE_FENCE_RE, '$1')
|
||||
}
|
||||
|
||||
function stripEmptyFenceBlocks(text: string): string {
|
||||
// Remove already-balanced but empty fences before Streamdown sees them.
|
||||
// Returning null from our CodeHeader/SyntaxHighlighter is not enough: the
|
||||
// code plugin still renders its outer code-block wrapper, producing the
|
||||
// blank bordered element seen during streaming.
|
||||
return text.replace(EMPTY_FENCE_BLOCK_RE, '$1')
|
||||
}
|
||||
|
||||
function pushProseFence(out: string[], indent: string, info: string, lines: string[]) {
|
||||
if (info) {
|
||||
out.push(`${indent}${info}`.trimEnd())
|
||||
}
|
||||
|
||||
out.push(...lines)
|
||||
}
|
||||
|
||||
function findClosingFence(lines: string[], start: number, marker: string): number {
|
||||
for (let cursor = start + 1; cursor < lines.length; cursor += 1) {
|
||||
const closeMatch = (lines[cursor] || '').match(FENCE_LINE_RE)
|
||||
|
||||
if (!closeMatch) {
|
||||
continue
|
||||
}
|
||||
|
||||
const closeMarker = closeMatch[2] || ''
|
||||
const closeInfo = (closeMatch[3] || '').trim()
|
||||
|
||||
if (!closeInfo && closeMarker[0] === marker[0] && closeMarker.length >= marker.length) {
|
||||
return cursor
|
||||
}
|
||||
}
|
||||
|
||||
return -1
|
||||
}
|
||||
|
||||
function isPreviewOnlyFence(body: string): boolean {
|
||||
const lines = body
|
||||
.split('\n')
|
||||
.map(line => line.trim())
|
||||
.filter(Boolean)
|
||||
|
||||
return lines.length === 1 && isLikelyPreviewCandidate(lines[0])
|
||||
}
|
||||
|
||||
function normalizeFenceBlocks(text: string): string {
|
||||
const sourceLines = text.split('\n')
|
||||
const out: string[] = []
|
||||
let index = 0
|
||||
|
||||
while (index < sourceLines.length) {
|
||||
const line = sourceLines[index] || ''
|
||||
const match = line.match(FENCE_LINE_RE)
|
||||
|
||||
if (!match) {
|
||||
out.push(line)
|
||||
index += 1
|
||||
|
||||
continue
|
||||
}
|
||||
|
||||
const indent = match[1] || ''
|
||||
const marker = match[2] || '```'
|
||||
const infoRaw = (match[3] || '').trim()
|
||||
const languageToken = infoRaw.split(/\s+/, 1)[0] || ''
|
||||
const language = sanitizeLanguageTag(languageToken)
|
||||
const openerValid = !infoRaw || Boolean(language)
|
||||
|
||||
if (!openerValid) {
|
||||
out.push(`${indent}${infoRaw}`.trimEnd())
|
||||
index += 1
|
||||
|
||||
continue
|
||||
}
|
||||
|
||||
const closeIndex = findClosingFence(sourceLines, index, marker)
|
||||
const bodyLines = sourceLines.slice(index + 1, closeIndex === -1 ? sourceLines.length : closeIndex)
|
||||
const body = bodyLines.join('\n')
|
||||
|
||||
if (closeIndex !== -1 && !body.trim()) {
|
||||
// Empty fenced block: drop both delimiters. This prevents Streamdown's
|
||||
// code plugin from rendering an empty shell/card.
|
||||
index = closeIndex + 1
|
||||
|
||||
continue
|
||||
}
|
||||
|
||||
if (closeIndex !== -1 && isPreviewOnlyFence(body)) {
|
||||
// Agents often fence a lone preview URL to make it copyable. The chat UI
|
||||
// already renders a preview card for that URL, so don't show code fences.
|
||||
out.push(...bodyLines)
|
||||
index = closeIndex + 1
|
||||
|
||||
continue
|
||||
}
|
||||
|
||||
if (closeIndex === -1) {
|
||||
if (!body.trim()) {
|
||||
index += 1
|
||||
|
||||
continue
|
||||
}
|
||||
|
||||
if (isLikelyProseFence(infoRaw, body)) {
|
||||
pushProseFence(out, indent, infoRaw, bodyLines)
|
||||
} else {
|
||||
out.push(`${indent}${marker}${language}`)
|
||||
out.push(...bodyLines)
|
||||
}
|
||||
|
||||
break
|
||||
}
|
||||
|
||||
if (isLikelyProseFence(infoRaw, body)) {
|
||||
pushProseFence(out, indent, infoRaw, bodyLines)
|
||||
index = closeIndex + 1
|
||||
|
||||
continue
|
||||
}
|
||||
|
||||
out.push(`${indent}${marker}${language}`)
|
||||
out.push(...bodyLines)
|
||||
out.push(`${indent}${marker}`)
|
||||
index = closeIndex + 1
|
||||
}
|
||||
|
||||
return out.join('\n')
|
||||
}
|
||||
|
||||
export function preprocessMarkdown(text: string): string {
|
||||
const cleaned = text.replace(REASONING_BLOCK_RE, '').replace(PREVIEW_MARKER_RE, '')
|
||||
const normalizedFences = normalizeFenceBlocks(stripMidlineFenceStarts(cleaned))
|
||||
const strippedEmptyFences = stripEmptyFenceBlocks(normalizedFences)
|
||||
|
||||
return strippedEmptyFences
|
||||
.split(/((?:```|~~~)[\s\S]*?(?:```|~~~))/g)
|
||||
.map(part => (/^(?:```|~~~)/.test(part) ? part : stripPreviewTargets(part)))
|
||||
.join('')
|
||||
.replace(/[ \t]+\n/g, '\n')
|
||||
}
|
||||
|
||||
function CodeHeader({ language, code }: { language?: string; code?: string }) {
|
||||
const [copied, setCopied] = useState(false)
|
||||
const normalizedCode = (code ?? '').replace(/^\n+/, '').trimEnd()
|
||||
|
||||
// Streamdown can transiently parse stray backticks / incomplete fences as
|
||||
|
|
@ -194,41 +32,15 @@ function CodeHeader({ language, code }: { language?: string; code?: string }) {
|
|||
return null
|
||||
}
|
||||
|
||||
async function handleCopy() {
|
||||
if (!normalizedCode) {
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
if (window.hermesDesktop?.writeClipboard) {
|
||||
await window.hermesDesktop.writeClipboard(normalizedCode)
|
||||
} else if (navigator.clipboard?.writeText) {
|
||||
await navigator.clipboard.writeText(normalizedCode)
|
||||
}
|
||||
|
||||
triggerHaptic('selection')
|
||||
setCopied(true)
|
||||
setTimeout(() => setCopied(false), 1500)
|
||||
} catch {
|
||||
// Best-effort copy; silent failure is OK for a chat surface.
|
||||
}
|
||||
}
|
||||
|
||||
const cleanLanguage = sanitizeLanguageTag(language || '')
|
||||
const label = cleanLanguage && cleanLanguage !== 'unknown' ? cleanLanguage : ''
|
||||
|
||||
return (
|
||||
<div className="m-0 flex items-center justify-between gap-2 rounded-t-md border border-b-0 border-border bg-muted/60 px-3 py-1.5 text-xs text-muted-foreground">
|
||||
<span className="font-mono uppercase tracking-wide">{label || 'code'}</span>
|
||||
<button
|
||||
aria-label={copied ? 'Copied' : 'Copy code'}
|
||||
className="inline-flex items-center gap-1 rounded-sm px-1.5 py-0.5 text-[0.75rem] text-muted-foreground transition-colors hover:bg-accent hover:text-foreground"
|
||||
onClick={handleCopy}
|
||||
type="button"
|
||||
>
|
||||
{copied ? <Check size={12} /> : <Copy size={12} />}
|
||||
{copied ? 'Copied' : 'Copy'}
|
||||
</button>
|
||||
<CopyButton appearance="inline" iconClassName="size-3" label="Copy code" text={normalizedCode}>
|
||||
Copy
|
||||
</CopyButton>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -360,7 +172,7 @@ function MarkdownLink({ className, href, ...props }: ComponentProps<'a'>) {
|
|||
}
|
||||
|
||||
if (previewTarget) {
|
||||
return <PreviewAttachment target={previewTarget} />
|
||||
return <PreviewAttachment source="explicit-link" target={previewTarget} />
|
||||
}
|
||||
|
||||
return (
|
||||
|
|
|
|||
|
|
@ -2,12 +2,13 @@ import { useStore } from '@nanostores/react'
|
|||
import { useEffect, useRef, useState } from 'react'
|
||||
|
||||
import { MonitorPlay } from '@/lib/icons'
|
||||
import { normalizeOrLocalPreviewTarget } from '@/lib/local-preview'
|
||||
import { previewName } from '@/lib/preview-targets'
|
||||
import { notifyError } from '@/store/notifications'
|
||||
import { $previewTarget, setPreviewTarget } from '@/store/preview'
|
||||
import { $previewTarget, dismissPreviewTarget, type PreviewRecordSource, setCurrentSessionPreviewTarget } from '@/store/preview'
|
||||
import { $currentCwd } from '@/store/session'
|
||||
|
||||
export function PreviewAttachment({ target }: { target: string }) {
|
||||
export function PreviewAttachment({ source = 'manual', target }: { source?: PreviewRecordSource; target: string }) {
|
||||
const cwd = useStore($currentCwd)
|
||||
const activePreview = useStore($previewTarget)
|
||||
const [opening, setOpening] = useState(false)
|
||||
|
|
@ -37,37 +38,13 @@ export function PreviewAttachment({ target }: { target: string }) {
|
|||
setOpening(false)
|
||||
}, [cwd, target])
|
||||
|
||||
function localFallbackPreview(value: string) {
|
||||
if (/^https?:\/\//i.test(value)) {
|
||||
return { kind: 'url' as const, label: previewName(value), source: value, url: value }
|
||||
}
|
||||
|
||||
if (/^file:\/\//i.test(value)) {
|
||||
return { kind: 'file' as const, label: previewName(value), source: value, url: value }
|
||||
}
|
||||
|
||||
if (/^(?:\/|\.{1,2}\/|~\/).+\.html?$/i.test(value)) {
|
||||
const path = value.startsWith('file://') ? value : `file://${encodeURI(value)}`
|
||||
|
||||
return { kind: 'file' as const, label: previewName(value), source: value, url: path }
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
function isMissingPreviewIpc(error: unknown): boolean {
|
||||
const message = error instanceof Error ? error.message : typeof error === 'string' ? error : ''
|
||||
|
||||
return message.includes("No handler registered for 'hermes:normalizePreviewTarget'")
|
||||
}
|
||||
|
||||
async function togglePreview() {
|
||||
if (opening) {
|
||||
return
|
||||
}
|
||||
|
||||
if (isActive) {
|
||||
setPreviewTarget(null)
|
||||
dismissPreviewTarget()
|
||||
|
||||
return
|
||||
}
|
||||
|
|
@ -79,15 +56,7 @@ export function PreviewAttachment({ target }: { target: string }) {
|
|||
setOpening(true)
|
||||
|
||||
try {
|
||||
const preview = await window.hermesDesktop
|
||||
?.normalizePreviewTarget(requestTarget, requestCwd || undefined)
|
||||
.catch(error => {
|
||||
if (isMissingPreviewIpc(error)) {
|
||||
return localFallbackPreview(requestTarget)
|
||||
}
|
||||
|
||||
throw error
|
||||
})
|
||||
const preview = await normalizeOrLocalPreviewTarget(requestTarget, requestCwd || undefined)
|
||||
|
||||
if (
|
||||
!mountedRef.current ||
|
||||
|
|
@ -108,7 +77,7 @@ export function PreviewAttachment({ target }: { target: string }) {
|
|||
return
|
||||
}
|
||||
|
||||
setPreviewTarget(preview)
|
||||
setCurrentSessionPreviewTarget(preview, source, requestTarget)
|
||||
} catch (error) {
|
||||
if (
|
||||
!mountedRef.current ||
|
||||
|
|
@ -128,21 +97,21 @@ export function PreviewAttachment({ target }: { target: string }) {
|
|||
}
|
||||
|
||||
return (
|
||||
<div className="inline-flex max-w-[min(100%,32rem)] items-center gap-3 rounded-xl border border-border/70 bg-card/70 p-3 text-sm">
|
||||
<div className="grid size-9 shrink-0 place-items-center rounded-lg bg-accent text-muted-foreground">
|
||||
<MonitorPlay className="size-4" />
|
||||
</div>
|
||||
<div className="min-w-0 max-w-64">
|
||||
<div className="truncate font-medium text-foreground">{name}</div>
|
||||
<div className="truncate font-mono text-xs text-muted-foreground">{target}</div>
|
||||
<div className="flex w-full max-w-160 flex-wrap items-center gap-2.5 rounded-lg border border-border/55 bg-card/55 px-2.5 py-1.5 text-sm">
|
||||
<span className="grid size-7 shrink-0 place-items-center rounded-md bg-muted/55 text-muted-foreground/85">
|
||||
<MonitorPlay className="size-3.5" />
|
||||
</span>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="truncate text-[0.78rem] font-medium leading-[1.15rem] text-foreground/90">{name}</div>
|
||||
<div className="truncate font-mono text-[0.66rem] leading-4 text-muted-foreground/70">{target}</div>
|
||||
</div>
|
||||
<button
|
||||
className="shrink-0 rounded-lg border border-border/70 px-2.5 py-1 text-xs font-medium text-muted-foreground transition-colors hover:bg-accent hover:text-foreground disabled:opacity-50"
|
||||
className="ml-auto shrink-0 rounded-md border border-border/55 bg-background/40 px-2 py-1 text-[0.7rem] font-medium text-muted-foreground transition-colors hover:bg-accent/55 hover:text-foreground disabled:opacity-50 max-[28rem]:ml-9 max-[28rem]:w-[calc(100%-2.25rem)]"
|
||||
disabled={opening}
|
||||
onClick={() => void togglePreview()}
|
||||
type="button"
|
||||
>
|
||||
{opening ? 'Opening...' : isActive ? 'Hide Preview' : 'Toggle Preview'}
|
||||
{opening ? 'Opening…' : isActive ? 'Hide' : 'Open preview'}
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
|
|
|
|||
|
|
@ -85,6 +85,23 @@ function assistantMessage(text: string, running = true): ThreadMessage {
|
|||
} as ThreadMessage
|
||||
}
|
||||
|
||||
function assistantReasoningMessage(text: string): ThreadMessage {
|
||||
return {
|
||||
id: 'assistant-reasoning-1',
|
||||
role: 'assistant',
|
||||
content: [{ type: 'reasoning', text }],
|
||||
status: { type: 'complete', reason: 'stop' },
|
||||
createdAt,
|
||||
metadata: {
|
||||
unstable_state: null,
|
||||
unstable_annotations: [],
|
||||
unstable_data: [],
|
||||
steps: [],
|
||||
custom: {}
|
||||
}
|
||||
} as ThreadMessage
|
||||
}
|
||||
|
||||
function StreamingHarness() {
|
||||
const [messages, setMessages] = useState<ThreadMessage[]>([userMessage()])
|
||||
const [isRunning, setIsRunning] = useState(true)
|
||||
|
|
@ -123,6 +140,20 @@ function StreamingHarness() {
|
|||
)
|
||||
}
|
||||
|
||||
function ReasoningHarness() {
|
||||
const runtime = useExternalStoreRuntime<ThreadMessage>({
|
||||
messages: [assistantReasoningMessage(' The user is asking what this file is.')],
|
||||
isRunning: false,
|
||||
onNew: async () => {}
|
||||
})
|
||||
|
||||
return (
|
||||
<AssistantRuntimeProvider runtime={runtime}>
|
||||
<Thread />
|
||||
</AssistantRuntimeProvider>
|
||||
)
|
||||
}
|
||||
|
||||
describe('assistant-ui streaming renderer', () => {
|
||||
beforeEach(() => {
|
||||
resizeObservers.clear()
|
||||
|
|
@ -157,7 +188,8 @@ describe('assistant-ui streaming renderer', () => {
|
|||
it('does not pull the viewport back down after the user scrolls up during streaming', async () => {
|
||||
const { container } = render(<StreamingHarness />)
|
||||
|
||||
const viewport = container.querySelector('[data-slot="aui_thread-viewport"]') as HTMLDivElement
|
||||
const content = container.querySelector('[data-slot="aui_thread-content"]') as HTMLDivElement
|
||||
const viewport = content.parentElement as HTMLDivElement
|
||||
let scrollHeight = 1_000
|
||||
|
||||
Object.defineProperty(viewport, 'clientHeight', { configurable: true, value: 200 })
|
||||
|
|
@ -191,4 +223,14 @@ describe('assistant-ui streaming renderer', () => {
|
|||
|
||||
expect(viewport.scrollTop).toBe(420)
|
||||
})
|
||||
|
||||
it('renders reasoning text without a leading token space', () => {
|
||||
const { container } = render(<ReasoningHarness />)
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: /thinking/i }))
|
||||
|
||||
expect(container.querySelector('[data-slot="aui_reasoning-text"]')?.textContent).toBe(
|
||||
'The user is asking what this file is.'
|
||||
)
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -28,7 +28,7 @@ import { StickToBottom, useStickToBottomContext } from 'use-stick-to-bottom'
|
|||
import { useElapsedSeconds } from '@/components/assistant-ui/activity-timer'
|
||||
import { ActivityTimerText } from '@/components/assistant-ui/activity-timer-text'
|
||||
import { ClarifyTool } from '@/components/assistant-ui/clarify-tool'
|
||||
import { DirectiveText } from '@/components/assistant-ui/directive-text'
|
||||
import { DirectiveContent, DirectiveText } from '@/components/assistant-ui/directive-text'
|
||||
import { GeneratedImageProvider, useGeneratedImageContext } from '@/components/assistant-ui/generated-image-context'
|
||||
import { ImageGenerationPlaceholder } from '@/components/assistant-ui/image-generation-placeholder'
|
||||
import { Intro, type IntroProps } from '@/components/assistant-ui/intro'
|
||||
|
|
@ -36,6 +36,7 @@ import { MarkdownText } from '@/components/assistant-ui/markdown-text'
|
|||
import { PreviewAttachment } from '@/components/assistant-ui/preview-attachment'
|
||||
import { ToolFallback } from '@/components/assistant-ui/tool-fallback'
|
||||
import { TooltipIconButton } from '@/components/assistant-ui/tooltip-icon-button'
|
||||
import { CopyButton } from '@/components/ui/copy-button'
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
|
|
@ -49,7 +50,6 @@ import {
|
|||
CheckIcon,
|
||||
ChevronLeftIcon,
|
||||
ChevronRightIcon,
|
||||
CopyIcon,
|
||||
GitBranchIcon,
|
||||
Loader2Icon,
|
||||
MoreHorizontalIcon,
|
||||
|
|
@ -137,7 +137,7 @@ export const Thread: FC<{
|
|||
>
|
||||
<ThreadScrollSync sessionKey={sessionKey} />
|
||||
<StickToBottom.Content
|
||||
className="mx-auto flex w-full max-w-[48rem] min-w-0 flex-col gap-3 px-4 pt-[calc(var(--vsq)*19)] sm:px-6 lg:px-8"
|
||||
className="mx-auto flex w-full max-w-3xl min-w-0 flex-col gap-3 px-4 pt-[calc(var(--vsq)*19)] sm:px-6 lg:px-8"
|
||||
data-slot="aui_thread-content"
|
||||
scrollClassName="overflow-x-hidden overflow-y-auto overscroll-contain"
|
||||
>
|
||||
|
|
@ -371,6 +371,10 @@ const ComposerClearance: FC = () => {
|
|||
})
|
||||
|
||||
useEffect(() => {
|
||||
if (typeof document === 'undefined') {
|
||||
return
|
||||
}
|
||||
|
||||
let composerObserver: ResizeObserver | null = null
|
||||
let observedComposer: HTMLElement | null = null
|
||||
|
||||
|
|
@ -385,6 +389,10 @@ const ComposerClearance: FC = () => {
|
|||
}
|
||||
|
||||
const bindComposer = () => {
|
||||
if (typeof document === 'undefined') {
|
||||
return false
|
||||
}
|
||||
|
||||
const composer = document.querySelector<HTMLElement>('[data-slot="composer-root"]')
|
||||
|
||||
if (!composer || composer === observedComposer) {
|
||||
|
|
@ -471,7 +479,7 @@ const AssistantMessage: FC<{ onBranchInNewChat?: (messageId: string) => void }>
|
|||
{previewTargets.length > 0 && (
|
||||
<div className="mt-3 flex flex-wrap gap-2">
|
||||
{previewTargets.map(target => (
|
||||
<PreviewAttachment key={target} target={target} />
|
||||
<PreviewAttachment key={target} source="explicit-link" target={target} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
|
@ -563,42 +571,59 @@ const ThinkingDisclosure: FC<{
|
|||
const elapsed = useElapsedSeconds(pending)
|
||||
|
||||
return (
|
||||
<div className="mb-2 text-sm text-muted-foreground">
|
||||
<div className="text-sm text-muted-foreground" data-slot="tool-block">
|
||||
<button
|
||||
aria-expanded={open}
|
||||
className="inline-flex max-w-full items-center gap-1 rounded-md py-0.5 pr-1 text-left text-muted-foreground transition-colors hover:bg-accent hover:text-foreground"
|
||||
className="group/thinking-row flex w-full max-w-full min-w-0 items-start gap-2 rounded-md px-2 py-0.5 text-left text-muted-foreground transition-colors hover:bg-accent/35 hover:text-foreground"
|
||||
onClick={() => setOpen(value => !value)}
|
||||
type="button"
|
||||
>
|
||||
<ChevronRightIcon
|
||||
className={cn('size-3 shrink-0 text-muted-foreground/80 transition-transform', open && 'rotate-90')}
|
||||
/>
|
||||
<span
|
||||
className={cn('shrink-0 text-xs font-medium text-foreground/70', pending && 'shimmer text-foreground/55')}
|
||||
>
|
||||
Thinking
|
||||
<span className="flex h-[1.1rem] shrink-0 items-center">
|
||||
<ChevronRightIcon
|
||||
className={cn(
|
||||
'size-3 text-muted-foreground/55 transition-transform group-hover/thinking-row:text-muted-foreground/85',
|
||||
open && 'rotate-90'
|
||||
)}
|
||||
/>
|
||||
</span>
|
||||
<span className="flex min-w-0 flex-1 items-baseline gap-1.5">
|
||||
<span
|
||||
className={cn(
|
||||
'text-[0.78rem] font-medium leading-[1.1rem] text-foreground/75',
|
||||
pending && 'shimmer text-foreground/55'
|
||||
)}
|
||||
>
|
||||
Thinking
|
||||
</span>
|
||||
{pending && (
|
||||
<ActivityTimerText className="text-[0.625rem] tabular-nums text-muted-foreground/55" seconds={elapsed} />
|
||||
)}
|
||||
</span>
|
||||
{pending && <ActivityTimerText seconds={elapsed} />}
|
||||
</button>
|
||||
{open && <div className="ml-4 mt-1 max-w-full wrap-anywhere border-l border-border pl-3">{children}</div>}
|
||||
{open && (
|
||||
<div className="mt-2 w-full min-w-0 max-w-full overflow-hidden pl-6 pr-2 wrap-anywhere pb-1">{children}</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const ReasoningPart: FC<{ text: string; status?: { type: string } }> = ({ text, status }) => (
|
||||
<div className="mb-1 mt-1">
|
||||
const ReasoningPart: FC<{ text: string; status?: { type: string } }> = ({ text, status }) => {
|
||||
const displayText = text.trimStart()
|
||||
|
||||
return (
|
||||
<ThinkingDisclosure pending={status?.type === 'running'}>
|
||||
<div
|
||||
className={cn(
|
||||
'whitespace-pre-wrap text-xs leading-relaxed text-muted-foreground/85',
|
||||
status?.type === 'running' && 'shimmer text-muted-foreground/55'
|
||||
)}
|
||||
data-slot="aui_reasoning-text"
|
||||
>
|
||||
{text}
|
||||
{displayText}
|
||||
</div>
|
||||
</ThinkingDisclosure>
|
||||
</div>
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
const TIME_FMT = new Intl.DateTimeFormat(undefined, { hour: 'numeric', minute: '2-digit' })
|
||||
|
||||
|
|
@ -679,27 +704,15 @@ const AssistantActionBar: FC<MessageActionProps> = ({ messageId, messageText, on
|
|||
}
|
||||
|
||||
const CopyMessageButton: FC<{ text: string }> = ({ text }) => {
|
||||
const [copied, setCopied] = useState(false)
|
||||
|
||||
const copy = useCallback(async () => {
|
||||
if (!text) {
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
await navigator.clipboard.writeText(text)
|
||||
triggerHaptic('selection')
|
||||
setCopied(true)
|
||||
window.setTimeout(() => setCopied(false), 2000)
|
||||
} catch (error) {
|
||||
notifyError(error, 'Copy failed')
|
||||
}
|
||||
}, [text])
|
||||
|
||||
return (
|
||||
<TooltipIconButton disabled={!text} onClick={() => void copy()} tooltip={copied ? 'Copied' : 'Copy'}>
|
||||
{copied ? <CheckIcon /> : <CopyIcon />}
|
||||
</TooltipIconButton>
|
||||
<CopyButton
|
||||
appearance="icon"
|
||||
buttonSize="icon"
|
||||
className="aui-button-icon size-6 p-1"
|
||||
disabled={!text}
|
||||
label="Copy"
|
||||
text={text}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
|
|
@ -774,18 +787,45 @@ const AssistantFooter: FC<MessageActionProps> = props => (
|
|||
const branchButtonClass =
|
||||
'grid size-6 place-items-center rounded-md text-muted-foreground transition-colors hover:bg-accent hover:text-foreground disabled:opacity-35'
|
||||
|
||||
const EMPTY_ATTACHMENT_REFS: string[] = []
|
||||
|
||||
function messageAttachmentRefs(value: unknown): string[] {
|
||||
if (!Array.isArray(value)) {
|
||||
return EMPTY_ATTACHMENT_REFS
|
||||
}
|
||||
|
||||
return value.every(ref => typeof ref === 'string') ? value : EMPTY_ATTACHMENT_REFS
|
||||
}
|
||||
|
||||
const UserMessage: FC = () => {
|
||||
const content = useAuiState(s => s.message.content)
|
||||
const messageText = messageContentText(content)
|
||||
|
||||
const attachmentRefs = useAuiState(s => {
|
||||
const custom = (s.message.metadata?.custom ?? {}) as { attachmentRefs?: unknown }
|
||||
|
||||
return messageAttachmentRefs(custom.attachmentRefs)
|
||||
})
|
||||
|
||||
const hasBody = messageText.trim().length > 0
|
||||
|
||||
return (
|
||||
<MessagePrimitive.Root
|
||||
className="group flex min-w-0 max-w-[min(72%,34rem)] flex-col items-end gap-2 self-end overflow-hidden"
|
||||
data-role="user"
|
||||
data-slot="aui_user-message-root"
|
||||
>
|
||||
<div className="wrap-anywhere max-w-full overflow-hidden whitespace-pre-line rounded-2xl border border-[color-mix(in_srgb,var(--dt-user-bubble-border)_78%,transparent)] bg-[color-mix(in_srgb,var(--dt-user-bubble)_94%,transparent)] px-3 py-2 leading-[1.48] text-foreground/95">
|
||||
<MessagePrimitive.Parts components={{ Text: DirectiveText }} />
|
||||
<div className="flex min-w-0 max-w-full flex-col gap-1.5 overflow-hidden rounded-2xl border border-[color-mix(in_srgb,var(--dt-user-bubble-border)_78%,transparent)] bg-[color-mix(in_srgb,var(--dt-user-bubble)_94%,transparent)] px-3 py-2 leading-[1.48] text-foreground/95">
|
||||
{attachmentRefs.length > 0 && (
|
||||
<div className="-mx-1 flex flex-wrap gap-1 border-b border-border/45 pb-1.5">
|
||||
<DirectiveContent text={attachmentRefs.join(' ')} />
|
||||
</div>
|
||||
)}
|
||||
{hasBody && (
|
||||
<div className="wrap-anywhere whitespace-pre-line">
|
||||
<MessagePrimitive.Parts components={{ Text: DirectiveText }} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="min-h-6">
|
||||
<UserActionBar messageText={messageText} />
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -102,13 +102,13 @@ export function ZoomableImage({ className, containerClassName, src, alt, slot, .
|
|||
const lightbox = src ? (
|
||||
<Dialog onOpenChange={setLightboxOpen} open={lightboxOpen}>
|
||||
<DialogContent
|
||||
className="grid max-h-[calc(100vh-2rem)] w-auto max-w-[calc(100vw-2rem)] place-items-center overflow-visible border-0 bg-transparent p-0 shadow-none"
|
||||
className="block w-auto max-h-[calc(100vh-12rem)] max-w-[calc(100vw-12rem)] overflow-visible border-0 bg-transparent p-0 shadow-none"
|
||||
showCloseButton={false}
|
||||
>
|
||||
<div className="group/lightbox relative max-h-[calc(100vh-2rem)] max-w-[calc(100vw-2rem)] overflow-auto">
|
||||
<div className="group/lightbox relative inline-block">
|
||||
<img
|
||||
alt={alt ?? ''}
|
||||
className="block max-h-[calc(100vh-2rem)] max-w-full cursor-zoom-out select-auto rounded-lg object-contain shadow-2xl"
|
||||
className="block max-h-[calc(100vh-12rem)] max-w-[calc(100vw-12rem)] cursor-zoom-out select-auto rounded-lg object-contain shadow-2xl"
|
||||
onClick={() => setLightboxOpen(false)}
|
||||
src={src}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -2,8 +2,9 @@ import { useStore } from '@nanostores/react'
|
|||
import { type ReactNode, useEffect, useRef, useState } from 'react'
|
||||
|
||||
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'
|
||||
import { CopyButton } from '@/components/ui/copy-button'
|
||||
import { triggerHaptic } from '@/lib/haptics'
|
||||
import { AlertCircle, AlertTriangle, CheckCircle2, Copy, Info, type LucideIcon, X } from '@/lib/icons'
|
||||
import { AlertCircle, AlertTriangle, CheckCircle2, Info, type LucideIcon, X } from '@/lib/icons'
|
||||
import { cn } from '@/lib/utils'
|
||||
import {
|
||||
$notifications,
|
||||
|
|
@ -116,18 +117,6 @@ function NotificationItem({ notification }: { notification: AppNotification }) {
|
|||
}
|
||||
|
||||
function NotificationDetail({ detail }: { detail: string }) {
|
||||
const [copied, setCopied] = useState(false)
|
||||
|
||||
async function copyDetail() {
|
||||
try {
|
||||
await navigator.clipboard.writeText(detail)
|
||||
setCopied(true)
|
||||
window.setTimeout(() => setCopied(false), 1200)
|
||||
} catch {
|
||||
// Best effort; details remain visible even if clipboard access fails.
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<details className="mt-2 text-xs text-muted-foreground">
|
||||
<summary className="cursor-pointer select-none font-medium text-muted-foreground hover:text-foreground">
|
||||
|
|
@ -137,14 +126,16 @@ function NotificationDetail({ detail }: { detail: string }) {
|
|||
<pre className="max-h-32 whitespace-pre-wrap wrap-break-word font-mono text-[0.6875rem] leading-relaxed">
|
||||
{detail}
|
||||
</pre>
|
||||
<button
|
||||
<CopyButton
|
||||
appearance="inline"
|
||||
className="mt-1 inline-flex items-center gap-1 rounded px-1.5 py-0.5 text-[0.6875rem] text-muted-foreground hover:bg-accent hover:text-foreground"
|
||||
onClick={copyDetail}
|
||||
type="button"
|
||||
errorMessage="Could not copy notification detail"
|
||||
iconClassName="size-3"
|
||||
label="Copy detail"
|
||||
text={detail}
|
||||
>
|
||||
<Copy className="size-3" />
|
||||
{copied ? 'Copied' : 'Copy detail'}
|
||||
</button>
|
||||
Copy detail
|
||||
</CopyButton>
|
||||
</div>
|
||||
</details>
|
||||
)
|
||||
|
|
|
|||
14
apps/desktop/src/components/pane-shell/context.ts
Normal file
14
apps/desktop/src/components/pane-shell/context.ts
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
import { createContext } from 'react'
|
||||
|
||||
export interface PaneSlot {
|
||||
column: number
|
||||
side: 'left' | 'right'
|
||||
open: boolean
|
||||
}
|
||||
|
||||
export interface PaneShellContextValue {
|
||||
paneById: Map<string, PaneSlot>
|
||||
mainColumn: number
|
||||
}
|
||||
|
||||
export const PaneShellContext = createContext<PaneShellContextValue | null>(null)
|
||||
4
apps/desktop/src/components/pane-shell/index.ts
Normal file
4
apps/desktop/src/components/pane-shell/index.ts
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
export type { PaneShellContextValue, PaneSlot } from './context'
|
||||
export { PaneShellContext } from './context'
|
||||
export { Pane, PaneMain, PaneShell } from './pane-shell'
|
||||
export type { PaneMainProps, PaneProps, PaneShellProps } from './pane-shell'
|
||||
247
apps/desktop/src/components/pane-shell/pane-shell.test.tsx
Normal file
247
apps/desktop/src/components/pane-shell/pane-shell.test.tsx
Normal file
|
|
@ -0,0 +1,247 @@
|
|||
import { cleanup, render } from '@testing-library/react'
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
|
||||
|
||||
import { $paneStates, setPaneOpen, setPaneWidthOverride } from '@/store/panes'
|
||||
|
||||
import { Pane, PaneMain, PaneShell } from './pane-shell'
|
||||
|
||||
function gridContainer(rendered: ReturnType<typeof render>): HTMLElement {
|
||||
const root = rendered.container.firstElementChild
|
||||
|
||||
if (!(root instanceof HTMLElement)) {
|
||||
throw new Error('PaneShell did not render a root element')
|
||||
}
|
||||
|
||||
return root
|
||||
}
|
||||
|
||||
function getColumnTemplate(container: HTMLElement): string[] {
|
||||
return (container.style.gridTemplateColumns ?? '').split(/\s+/).filter(Boolean)
|
||||
}
|
||||
|
||||
describe('PaneShell composition', () => {
|
||||
beforeEach(() => {
|
||||
$paneStates.set({})
|
||||
window.localStorage.clear()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
cleanup()
|
||||
$paneStates.set({})
|
||||
window.localStorage.clear()
|
||||
})
|
||||
|
||||
it('builds a 2-column grid for one left pane + main', () => {
|
||||
const rendered = render(
|
||||
<PaneShell>
|
||||
<Pane id="files" side="left" width="240px">
|
||||
files
|
||||
</Pane>
|
||||
<PaneMain>main</PaneMain>
|
||||
</PaneShell>
|
||||
)
|
||||
|
||||
const tracks = getColumnTemplate(gridContainer(rendered))
|
||||
|
||||
expect(tracks).toEqual(['240px', 'minmax(0,1fr)'])
|
||||
})
|
||||
|
||||
it('orders panes left-to-right by side, preserving source order within a side', () => {
|
||||
const rendered = render(
|
||||
<PaneShell>
|
||||
<Pane id="files" side="left" width="240px">
|
||||
files
|
||||
</Pane>
|
||||
<Pane id="sessions" side="left" width="200px">
|
||||
sessions
|
||||
</Pane>
|
||||
<PaneMain>main</PaneMain>
|
||||
<Pane id="preview" side="right" width="320px">
|
||||
preview
|
||||
</Pane>
|
||||
<Pane id="inspector" side="right" width="280px">
|
||||
inspector
|
||||
</Pane>
|
||||
</PaneShell>
|
||||
)
|
||||
|
||||
const tracks = getColumnTemplate(gridContainer(rendered))
|
||||
|
||||
expect(tracks).toEqual(['240px', '200px', 'minmax(0,1fr)', '320px', '280px'])
|
||||
})
|
||||
|
||||
it('collapses a closed pane to 0px', () => {
|
||||
const rendered = render(
|
||||
<PaneShell>
|
||||
<Pane defaultOpen={false} id="files" side="left" width="240px">
|
||||
files
|
||||
</Pane>
|
||||
<PaneMain>main</PaneMain>
|
||||
</PaneShell>
|
||||
)
|
||||
|
||||
const tracks = getColumnTemplate(gridContainer(rendered))
|
||||
|
||||
expect(tracks).toEqual(['0px', 'minmax(0,1fr)'])
|
||||
})
|
||||
|
||||
it('reads open state from the panes store', () => {
|
||||
setPaneOpen('files', false)
|
||||
|
||||
const rendered = render(
|
||||
<PaneShell>
|
||||
<Pane id="files" side="left" width="240px">
|
||||
files
|
||||
</Pane>
|
||||
<PaneMain>main</PaneMain>
|
||||
</PaneShell>
|
||||
)
|
||||
|
||||
expect(getColumnTemplate(gridContainer(rendered))).toEqual(['0px', 'minmax(0,1fr)'])
|
||||
})
|
||||
|
||||
it('disabled forces the track to 0px even when the store says open', () => {
|
||||
setPaneOpen('files', true)
|
||||
|
||||
const rendered = render(
|
||||
<PaneShell>
|
||||
<Pane disabled={true} id="files" side="left" width="240px">
|
||||
files
|
||||
</Pane>
|
||||
<PaneMain>main</PaneMain>
|
||||
</PaneShell>
|
||||
)
|
||||
|
||||
expect(getColumnTemplate(gridContainer(rendered))).toEqual(['0px', 'minmax(0,1fr)'])
|
||||
})
|
||||
|
||||
it('disabled does NOT mutate the store-persisted open state', () => {
|
||||
setPaneOpen('files', true)
|
||||
|
||||
render(
|
||||
<PaneShell>
|
||||
<Pane disabled={true} id="files" side="left" width="240px">
|
||||
files
|
||||
</Pane>
|
||||
<PaneMain>main</PaneMain>
|
||||
</PaneShell>
|
||||
)
|
||||
|
||||
expect($paneStates.get().files?.open).toBe(true)
|
||||
})
|
||||
|
||||
it('uses widthOverride from the store when set', () => {
|
||||
setPaneOpen('files', true)
|
||||
setPaneWidthOverride('files', 320)
|
||||
|
||||
const rendered = render(
|
||||
<PaneShell>
|
||||
<Pane id="files" side="left" width="240px">
|
||||
files
|
||||
</Pane>
|
||||
<PaneMain>main</PaneMain>
|
||||
</PaneShell>
|
||||
)
|
||||
|
||||
expect(getColumnTemplate(gridContainer(rendered))).toEqual(['320px', 'minmax(0,1fr)'])
|
||||
})
|
||||
|
||||
it('preserves CSS-string widths verbatim (clamp, var, etc.)', () => {
|
||||
const rendered = render(
|
||||
<PaneShell>
|
||||
<Pane id="inspector" side="right" width="clamp(13.5rem,21vw,20rem)">
|
||||
inspector
|
||||
</Pane>
|
||||
<PaneMain>main</PaneMain>
|
||||
</PaneShell>
|
||||
)
|
||||
|
||||
const template = gridContainer(rendered).style.gridTemplateColumns
|
||||
|
||||
expect(template).toContain('clamp(13.5rem,21vw,20rem)')
|
||||
})
|
||||
|
||||
it('coerces numeric widths to px', () => {
|
||||
const rendered = render(
|
||||
<PaneShell>
|
||||
<Pane id="files" side="left" width={224}>
|
||||
files
|
||||
</Pane>
|
||||
<PaneMain>main</PaneMain>
|
||||
</PaneShell>
|
||||
)
|
||||
|
||||
expect(getColumnTemplate(gridContainer(rendered))).toEqual(['224px', 'minmax(0,1fr)'])
|
||||
})
|
||||
|
||||
it('emits per-pane width as a CSS variable', () => {
|
||||
const rendered = render(
|
||||
<PaneShell>
|
||||
<Pane id="files" side="left" width="240px">
|
||||
files
|
||||
</Pane>
|
||||
<PaneMain>main</PaneMain>
|
||||
</PaneShell>
|
||||
)
|
||||
|
||||
const root = gridContainer(rendered)
|
||||
|
||||
expect(root.style.getPropertyValue('--pane-files-width').trim()).toBe('240px')
|
||||
})
|
||||
|
||||
it('places a Pane in the correct grid column via inline style', () => {
|
||||
const rendered = render(
|
||||
<PaneShell>
|
||||
<Pane id="files" side="left" width="240px">
|
||||
<span data-testid="files-content">files</span>
|
||||
</Pane>
|
||||
<PaneMain>
|
||||
<span data-testid="main-content">main</span>
|
||||
</PaneMain>
|
||||
<Pane id="preview" side="right" width="320px">
|
||||
<span data-testid="preview-content">preview</span>
|
||||
</Pane>
|
||||
</PaneShell>
|
||||
)
|
||||
|
||||
const filesCell = rendered.getByTestId('files-content').parentElement!
|
||||
const mainCell = rendered.getByTestId('main-content').parentElement!
|
||||
const previewCell = rendered.getByTestId('preview-content').parentElement!
|
||||
|
||||
expect(filesCell.style.gridColumn).toBe('1 / 2')
|
||||
expect(mainCell.style.gridColumn).toBe('2 / 3')
|
||||
expect(previewCell.style.gridColumn).toBe('3 / 4')
|
||||
})
|
||||
|
||||
it('marks closed panes aria-hidden', () => {
|
||||
const rendered = render(
|
||||
<PaneShell>
|
||||
<Pane defaultOpen={false} id="files" side="left" width="240px">
|
||||
<span data-testid="files-content">files</span>
|
||||
</Pane>
|
||||
<PaneMain>main</PaneMain>
|
||||
</PaneShell>
|
||||
)
|
||||
|
||||
const cell = rendered.getByTestId('files-content').parentElement!
|
||||
|
||||
expect(cell.getAttribute('aria-hidden')).toBe('true')
|
||||
expect(cell.getAttribute('data-pane-open')).toBe('false')
|
||||
})
|
||||
|
||||
it('passes through arbitrary non-Pane children for self-placement', () => {
|
||||
const rendered = render(
|
||||
<PaneShell>
|
||||
<Pane id="files" side="left" width="240px">
|
||||
files
|
||||
</Pane>
|
||||
<PaneMain>main</PaneMain>
|
||||
<div data-testid="floating-overlay" style={{ position: 'absolute' }}>
|
||||
overlay
|
||||
</div>
|
||||
</PaneShell>
|
||||
)
|
||||
|
||||
expect(rendered.getByTestId('floating-overlay')).toBeDefined()
|
||||
})
|
||||
})
|
||||
220
apps/desktop/src/components/pane-shell/pane-shell.tsx
Normal file
220
apps/desktop/src/components/pane-shell/pane-shell.tsx
Normal file
|
|
@ -0,0 +1,220 @@
|
|||
import { useStore } from '@nanostores/react'
|
||||
import {
|
||||
Children,
|
||||
type CSSProperties,
|
||||
isValidElement,
|
||||
type ReactElement,
|
||||
type ReactNode,
|
||||
useContext,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef
|
||||
} from 'react'
|
||||
|
||||
import { cn } from '@/lib/utils'
|
||||
import { $paneStates, ensurePaneRegistered } from '@/store/panes'
|
||||
|
||||
import { PaneShellContext, type PaneShellContextValue, type PaneSlot } from './context'
|
||||
|
||||
type PaneSide = 'left' | 'right'
|
||||
type WidthValue = string | number
|
||||
|
||||
interface PaneRoleMarker {
|
||||
__paneShellRole?: 'pane' | 'main'
|
||||
}
|
||||
|
||||
export interface PaneProps {
|
||||
children?: ReactNode
|
||||
className?: string
|
||||
defaultOpen?: boolean
|
||||
/** Forces the pane closed (track→0, aria-hidden) without writing to the store — for transient route gates. */
|
||||
disabled?: boolean
|
||||
id: string
|
||||
maxWidth?: WidthValue
|
||||
minWidth?: WidthValue
|
||||
resizable?: boolean
|
||||
side: PaneSide
|
||||
width?: WidthValue
|
||||
}
|
||||
|
||||
export interface PaneMainProps {
|
||||
children?: ReactNode
|
||||
className?: string
|
||||
}
|
||||
|
||||
export interface PaneShellProps {
|
||||
children?: ReactNode
|
||||
className?: string
|
||||
style?: CSSProperties
|
||||
}
|
||||
|
||||
interface CollectedPane {
|
||||
defaultOpen: boolean
|
||||
disabled: boolean
|
||||
id: string
|
||||
side: PaneSide
|
||||
width: string
|
||||
}
|
||||
|
||||
const DEFAULT_WIDTH = '16rem'
|
||||
|
||||
const widthToCss = (value: WidthValue | undefined, fallback: string) =>
|
||||
value === undefined ? fallback : typeof value === 'number' ? `${value}px` : value
|
||||
|
||||
function isRole(child: unknown, role: 'pane' | 'main'): child is ReactElement {
|
||||
return isValidElement(child) && (child.type as PaneRoleMarker)?.__paneShellRole === role
|
||||
}
|
||||
|
||||
function collectPanes(children: ReactNode) {
|
||||
const left: CollectedPane[] = []
|
||||
const right: CollectedPane[] = []
|
||||
let mainCount = 0
|
||||
|
||||
Children.forEach(children, child => {
|
||||
if (isRole(child, 'main')) {
|
||||
mainCount++
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
if (!isRole(child, 'pane')) {return}
|
||||
|
||||
const props = child.props as PaneProps
|
||||
|
||||
const entry: CollectedPane = {
|
||||
defaultOpen: props.defaultOpen ?? true,
|
||||
disabled: props.disabled ?? false,
|
||||
id: props.id,
|
||||
side: props.side,
|
||||
width: widthToCss(props.width, DEFAULT_WIDTH)
|
||||
}
|
||||
|
||||
;(props.side === 'left' ? left : right).push(entry)
|
||||
})
|
||||
|
||||
return { left, mainCount, right }
|
||||
}
|
||||
|
||||
function trackForPane(pane: CollectedPane, states: Record<string, { open: boolean; widthOverride?: number }>) {
|
||||
const stateOpen = states[pane.id]?.open ?? pane.defaultOpen
|
||||
const open = !pane.disabled && stateOpen
|
||||
|
||||
if (!open) {return { open: false, track: '0px' }}
|
||||
|
||||
const override = states[pane.id]?.widthOverride
|
||||
|
||||
return { open: true, track: override !== undefined ? `${override}px` : pane.width }
|
||||
}
|
||||
|
||||
export function PaneShell({ children, className, style }: PaneShellProps) {
|
||||
const paneStates = useStore($paneStates)
|
||||
const { left, mainCount, right } = useMemo(() => collectPanes(children), [children])
|
||||
|
||||
if (import.meta.env.DEV && mainCount > 1) {
|
||||
console.warn('[PaneShell] expected at most one <PaneMain>, got', mainCount)
|
||||
}
|
||||
|
||||
const ctxValue = useMemo(() => {
|
||||
const paneById = new Map<string, PaneSlot>()
|
||||
const tracks: string[] = []
|
||||
const cssVars: Record<string, string> = {}
|
||||
let column = 1
|
||||
|
||||
for (const pane of left) {
|
||||
const { open, track } = trackForPane(pane, paneStates)
|
||||
tracks.push(track)
|
||||
paneById.set(pane.id, { column, open, side: 'left' })
|
||||
cssVars[`--pane-${pane.id}-width`] = track
|
||||
column++
|
||||
}
|
||||
|
||||
tracks.push('minmax(0,1fr)')
|
||||
const mainColumn = column++
|
||||
|
||||
for (const pane of right) {
|
||||
const { open, track } = trackForPane(pane, paneStates)
|
||||
tracks.push(track)
|
||||
paneById.set(pane.id, { column, open, side: 'right' })
|
||||
cssVars[`--pane-${pane.id}-width`] = track
|
||||
column++
|
||||
}
|
||||
|
||||
return { cssVars, gridTemplate: tracks.join(' '), mainColumn, paneById } satisfies PaneShellContextValue & {
|
||||
cssVars: Record<string, string>
|
||||
gridTemplate: string
|
||||
}
|
||||
}, [left, paneStates, right])
|
||||
|
||||
const composedStyle = useMemo<CSSProperties>(
|
||||
() => ({ ...ctxValue.cssVars, ...style, gridTemplateColumns: ctxValue.gridTemplate }),
|
||||
[ctxValue.cssVars, ctxValue.gridTemplate, style]
|
||||
)
|
||||
|
||||
return (
|
||||
<PaneShellContext.Provider value={{ mainColumn: ctxValue.mainColumn, paneById: ctxValue.paneById }}>
|
||||
<div className={cn('relative grid h-full min-h-0', className)} style={composedStyle}>
|
||||
{children}
|
||||
</div>
|
||||
</PaneShellContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
export function Pane({ children, className, defaultOpen = true, disabled = false, id }: PaneProps) {
|
||||
const ctx = useContext(PaneShellContext)
|
||||
const registered = useRef(false)
|
||||
|
||||
useEffect(() => {
|
||||
if (registered.current) {return}
|
||||
registered.current = true
|
||||
ensurePaneRegistered(id, { open: defaultOpen })
|
||||
}, [defaultOpen, id])
|
||||
|
||||
if (!ctx) {
|
||||
if (import.meta.env.DEV) {console.warn(`[Pane:${id}] must be rendered inside <PaneShell>`)}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
const slot = ctx.paneById.get(id)
|
||||
|
||||
if (!slot) {return null}
|
||||
|
||||
const open = slot.open && !disabled
|
||||
|
||||
return (
|
||||
<div
|
||||
aria-hidden={!open}
|
||||
className={cn('row-start-1 min-w-0 overflow-hidden', !open && 'pointer-events-none', className)}
|
||||
data-pane-id={id}
|
||||
data-pane-open={open ? 'true' : 'false'}
|
||||
data-pane-side={slot.side}
|
||||
style={{ gridColumn: `${slot.column} / ${slot.column + 1}` }}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
;(Pane as unknown as PaneRoleMarker).__paneShellRole = 'pane'
|
||||
|
||||
export function PaneMain({ children, className }: PaneMainProps) {
|
||||
const ctx = useContext(PaneShellContext)
|
||||
|
||||
if (!ctx) {
|
||||
if (import.meta.env.DEV) {console.warn('[PaneMain] must be rendered inside <PaneShell>')}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn('row-start-1 flex min-h-0 min-w-0 flex-col overflow-hidden', className)}
|
||||
data-pane-main="true"
|
||||
style={{ gridColumn: `${ctx.mainColumn} / ${ctx.mainColumn + 1}` }}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
;(PaneMain as unknown as PaneRoleMarker).__paneShellRole = 'main'
|
||||
|
|
@ -1,76 +0,0 @@
|
|||
'use client'
|
||||
|
||||
import type { FC } from 'react'
|
||||
|
||||
import { AgentSection } from '@/app/chat/right-rail/agent-section'
|
||||
import { ProjectSection } from '@/app/chat/right-rail/project-section'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
export interface SessionInspectorProps {
|
||||
open: boolean
|
||||
cwd: string
|
||||
branch: string
|
||||
busy: boolean
|
||||
modelLabel: string
|
||||
modelTitle?: string
|
||||
providerName?: string
|
||||
reasoningEffort: string
|
||||
serviceTier: string
|
||||
fastMode: boolean
|
||||
personality: string
|
||||
personalities: string[]
|
||||
onChangeCwd?: (cwd: string) => void
|
||||
onBrowseCwd?: () => void
|
||||
onOpenModelPicker?: () => void
|
||||
onSetReasoningEffort?: (effort: string) => void
|
||||
onSetFastMode?: (enabled: boolean) => void
|
||||
onSelectPersonality?: (name: string) => void
|
||||
}
|
||||
|
||||
export const SESSION_INSPECTOR_WIDTH = '14rem'
|
||||
|
||||
export const SessionInspector: FC<SessionInspectorProps> = ({
|
||||
open,
|
||||
cwd,
|
||||
branch,
|
||||
busy,
|
||||
modelLabel,
|
||||
providerName,
|
||||
reasoningEffort,
|
||||
serviceTier,
|
||||
fastMode,
|
||||
personality,
|
||||
personalities,
|
||||
onChangeCwd,
|
||||
onBrowseCwd,
|
||||
onOpenModelPicker,
|
||||
onSetFastMode,
|
||||
onSetReasoningEffort,
|
||||
onSelectPersonality
|
||||
}) => (
|
||||
<aside
|
||||
aria-hidden={!open}
|
||||
className={cn(
|
||||
'relative flex h-full w-full min-w-0 flex-col overflow-hidden bg-transparent pb-2 pl-2 pr-3 pt-[calc(var(--titlebar-height)+0.25rem)] text-muted-foreground transition-none',
|
||||
open ? 'opacity-100' : 'pointer-events-none opacity-0'
|
||||
)}
|
||||
data-open={open}
|
||||
>
|
||||
<div className="flex min-h-0 flex-1 flex-col gap-2.5 overflow-y-auto overscroll-contain pl-1.5 pr-1 text-xs">
|
||||
<ProjectSection branch={branch} busy={busy} cwd={cwd} onBrowseCwd={onBrowseCwd} onChangeCwd={onChangeCwd} />
|
||||
<AgentSection
|
||||
fastMode={fastMode}
|
||||
modelLabel={modelLabel}
|
||||
onOpenModelPicker={onOpenModelPicker}
|
||||
onSelectPersonality={onSelectPersonality}
|
||||
onSetFastMode={onSetFastMode}
|
||||
onSetReasoningEffort={onSetReasoningEffort}
|
||||
personalities={personalities}
|
||||
personality={personality}
|
||||
providerName={providerName}
|
||||
reasoningEffort={reasoningEffort}
|
||||
serviceTier={serviceTier}
|
||||
/>
|
||||
</div>
|
||||
</aside>
|
||||
)
|
||||
220
apps/desktop/src/components/ui/copy-button.tsx
Normal file
220
apps/desktop/src/components/ui/copy-button.tsx
Normal file
|
|
@ -0,0 +1,220 @@
|
|||
import * as React from 'react'
|
||||
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { DropdownMenuItem } from '@/components/ui/dropdown-menu'
|
||||
import { triggerHaptic } from '@/lib/haptics'
|
||||
import { Check, Copy, X } from '@/lib/icons'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
type CopyPayload = string | (() => Promise<string> | string)
|
||||
type CopyButtonAppearance = 'button' | 'icon' | 'inline' | 'menu-item' | 'tool-row'
|
||||
type CopyStatus = 'copied' | 'error' | 'idle'
|
||||
const COPIED_RESET_MS = 1_500
|
||||
|
||||
export async function writeClipboardText(text: string) {
|
||||
if (!text) {
|
||||
return
|
||||
}
|
||||
|
||||
if (window.hermesDesktop?.writeClipboard) {
|
||||
await window.hermesDesktop.writeClipboard(text)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
if (navigator.clipboard?.writeText) {
|
||||
await navigator.clipboard.writeText(text)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
throw new Error('Clipboard API is unavailable')
|
||||
}
|
||||
|
||||
export interface CopyButtonProps {
|
||||
appearance?: CopyButtonAppearance
|
||||
buttonSize?: React.ComponentProps<typeof Button>['size']
|
||||
buttonVariant?: React.ComponentProps<typeof Button>['variant']
|
||||
children?: React.ReactNode
|
||||
className?: string
|
||||
disabled?: boolean
|
||||
errorMessage?: string
|
||||
haptic?: boolean
|
||||
iconClassName?: string
|
||||
label?: string
|
||||
onCopied?: () => void
|
||||
onCopyError?: (error: unknown) => void
|
||||
preventDefault?: boolean
|
||||
showLabel?: boolean
|
||||
stopPropagation?: boolean
|
||||
text: CopyPayload
|
||||
title?: string
|
||||
}
|
||||
|
||||
export function CopyButton({
|
||||
appearance = 'button',
|
||||
buttonSize,
|
||||
buttonVariant = 'ghost',
|
||||
children,
|
||||
className,
|
||||
disabled = false,
|
||||
errorMessage = 'Copy failed',
|
||||
haptic = true,
|
||||
iconClassName,
|
||||
label = 'Copy',
|
||||
onCopied,
|
||||
onCopyError,
|
||||
preventDefault = false,
|
||||
showLabel,
|
||||
stopPropagation = false,
|
||||
text,
|
||||
title
|
||||
}: CopyButtonProps) {
|
||||
const [status, setStatus] = React.useState<CopyStatus>('idle')
|
||||
const resetRef = React.useRef<number | null>(null)
|
||||
|
||||
React.useEffect(() => {
|
||||
return () => {
|
||||
if (resetRef.current !== null) {
|
||||
window.clearTimeout(resetRef.current)
|
||||
}
|
||||
}
|
||||
}, [])
|
||||
|
||||
const copy = React.useCallback(
|
||||
async (event?: Event | React.MouseEvent<HTMLElement>) => {
|
||||
if (preventDefault) {
|
||||
event?.preventDefault()
|
||||
}
|
||||
|
||||
if (stopPropagation) {
|
||||
event?.stopPropagation()
|
||||
}
|
||||
|
||||
try {
|
||||
const value = typeof text === 'function' ? await text() : text
|
||||
|
||||
if (!value) {
|
||||
return
|
||||
}
|
||||
|
||||
await writeClipboardText(value)
|
||||
|
||||
if (haptic) {
|
||||
triggerHaptic('selection')
|
||||
}
|
||||
|
||||
if (resetRef.current !== null) {
|
||||
window.clearTimeout(resetRef.current)
|
||||
}
|
||||
|
||||
setStatus('copied')
|
||||
resetRef.current = window.setTimeout(() => {
|
||||
setStatus('idle')
|
||||
resetRef.current = null
|
||||
}, COPIED_RESET_MS)
|
||||
onCopied?.()
|
||||
} catch (error) {
|
||||
onCopyError?.(error)
|
||||
|
||||
if (resetRef.current !== null) {
|
||||
window.clearTimeout(resetRef.current)
|
||||
}
|
||||
|
||||
setStatus('error')
|
||||
resetRef.current = window.setTimeout(() => {
|
||||
setStatus('idle')
|
||||
resetRef.current = null
|
||||
}, COPIED_RESET_MS)
|
||||
}
|
||||
},
|
||||
[haptic, onCopied, onCopyError, preventDefault, stopPropagation, text]
|
||||
)
|
||||
|
||||
const Icon = status === 'copied' ? Check : status === 'error' ? X : Copy
|
||||
const icon = <Icon className={cn('size-3.5', iconClassName)} />
|
||||
const visibleChildren =
|
||||
(showLabel ?? (appearance !== 'icon' && appearance !== 'tool-row'))
|
||||
? status === 'copied'
|
||||
? 'Copied'
|
||||
: status === 'error'
|
||||
? 'Failed'
|
||||
: (children ?? label)
|
||||
: null
|
||||
|
||||
const content = (
|
||||
<>
|
||||
{icon}
|
||||
{visibleChildren}
|
||||
</>
|
||||
)
|
||||
|
||||
const feedbackLabel = status === 'copied' ? 'Copied' : status === 'error' ? errorMessage : (title ?? label)
|
||||
const ariaLabel = status === 'idle' ? label : feedbackLabel
|
||||
|
||||
if (appearance === 'menu-item') {
|
||||
return (
|
||||
<DropdownMenuItem
|
||||
className={className}
|
||||
disabled={disabled}
|
||||
onSelect={event => {
|
||||
event.preventDefault()
|
||||
void copy(event)
|
||||
}}
|
||||
>
|
||||
{content}
|
||||
</DropdownMenuItem>
|
||||
)
|
||||
}
|
||||
|
||||
if (appearance === 'inline') {
|
||||
return (
|
||||
<button
|
||||
aria-label={ariaLabel}
|
||||
className={cn(
|
||||
'inline-flex items-center gap-1 rounded-sm px-1.5 py-0.5 text-[0.75rem] text-muted-foreground transition-colors hover:bg-accent hover:text-foreground disabled:opacity-40',
|
||||
className
|
||||
)}
|
||||
disabled={disabled}
|
||||
onClick={event => void copy(event)}
|
||||
title={feedbackLabel}
|
||||
type="button"
|
||||
>
|
||||
{content}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
if (appearance === 'tool-row') {
|
||||
return (
|
||||
<button
|
||||
aria-label={ariaLabel}
|
||||
className={cn(
|
||||
'grid size-6 place-items-center rounded-md text-muted-foreground/70 opacity-0 transition-opacity hover:bg-accent/55 hover:text-foreground focus-visible:opacity-100 group-hover/tool-row:opacity-100 disabled:opacity-40',
|
||||
className
|
||||
)}
|
||||
disabled={disabled}
|
||||
onClick={event => void copy(event)}
|
||||
title={feedbackLabel}
|
||||
type="button"
|
||||
>
|
||||
{icon}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<Button
|
||||
aria-label={ariaLabel}
|
||||
className={className}
|
||||
disabled={disabled}
|
||||
onClick={event => void copy(event)}
|
||||
size={buttonSize ?? (appearance === 'icon' ? 'icon' : 'default')}
|
||||
title={feedbackLabel}
|
||||
type="button"
|
||||
variant={buttonVariant}
|
||||
>
|
||||
{content}
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
65
apps/desktop/src/components/ui/fade-text.tsx
Normal file
65
apps/desktop/src/components/ui/fade-text.tsx
Normal file
|
|
@ -0,0 +1,65 @@
|
|||
import type { ComponentProps, CSSProperties } from 'react'
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
interface FadeTextProps extends Omit<ComponentProps<'span'>, 'children'> {
|
||||
children: React.ReactNode
|
||||
/**
|
||||
* Width of the fade region on the trailing edge. Accepts any CSS length.
|
||||
* Defaults to 3rem so long strings clearly trail off — short enough to
|
||||
* preserve readable content, long enough to feel like a deliberate fade
|
||||
* rather than a clipped ellipsis.
|
||||
*/
|
||||
fadeWidth?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Single-line text that fades out instead of truncating with an ellipsis.
|
||||
*
|
||||
* Uses an inline mask-image so the fade resolves against whatever the parent
|
||||
* background is — no need to know the surface color, no after-pseudo overlap.
|
||||
* The mask is only applied when the text is actually overflowing, so short
|
||||
* strings render as plain text without an unnecessary gradient on their tail.
|
||||
*/
|
||||
export function FadeText({ children, className, fadeWidth = '3rem', style, ...rest }: FadeTextProps) {
|
||||
const ref = useRef<HTMLSpanElement>(null)
|
||||
const [overflowing, setOverflowing] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
const el = ref.current
|
||||
|
||||
if (!el) {
|
||||
return
|
||||
}
|
||||
|
||||
const measure = () => {
|
||||
setOverflowing(el.scrollWidth - el.clientWidth > 1)
|
||||
}
|
||||
|
||||
measure()
|
||||
const observer = new ResizeObserver(measure)
|
||||
observer.observe(el)
|
||||
|
||||
return () => observer.disconnect()
|
||||
}, [children])
|
||||
|
||||
const maskStyle: CSSProperties = overflowing
|
||||
? {
|
||||
maskImage: `linear-gradient(to right, black calc(100% - ${fadeWidth}), transparent)`,
|
||||
WebkitMaskImage: `linear-gradient(to right, black calc(100% - ${fadeWidth}), transparent)`,
|
||||
...style
|
||||
}
|
||||
: style ?? {}
|
||||
|
||||
return (
|
||||
<span
|
||||
{...rest}
|
||||
className={cn('block min-w-0 max-w-full overflow-hidden whitespace-nowrap', className)}
|
||||
ref={ref}
|
||||
style={maskStyle}
|
||||
>
|
||||
{children}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
34
apps/desktop/src/global.d.ts
vendored
34
apps/desktop/src/global.d.ts
vendored
|
|
@ -8,6 +8,7 @@ declare global {
|
|||
notify: (payload: HermesNotification) => Promise<boolean>
|
||||
requestMicrophoneAccess: () => Promise<boolean>
|
||||
readFileDataUrl: (filePath: string) => Promise<string>
|
||||
readFileText: (filePath: string) => Promise<HermesReadFileTextResult>
|
||||
selectPaths: (options?: HermesSelectPathsOptions) => Promise<string[]>
|
||||
writeClipboard: (text: string) => Promise<boolean>
|
||||
saveImageFromUrl: (url: string) => Promise<boolean>
|
||||
|
|
@ -17,7 +18,11 @@ declare global {
|
|||
normalizePreviewTarget: (target: string, baseDir?: string) => Promise<HermesPreviewTarget | null>
|
||||
watchPreviewFile: (url: string) => Promise<HermesPreviewWatch>
|
||||
stopPreviewFileWatch: (id: string) => Promise<boolean>
|
||||
setPreviewShortcutActive?: (active: boolean) => void
|
||||
openExternal: (url: string) => Promise<void>
|
||||
readDir: (path: string) => Promise<HermesReadDirResult>
|
||||
gitRoot?: (path: string) => Promise<string | null>
|
||||
onClosePreviewRequested?: (callback: () => void) => () => void
|
||||
onPreviewFileChanged: (callback: (payload: HermesPreviewFileChanged) => void) => () => void
|
||||
onBackendExit: (callback: (payload: BackendExit) => void) => () => void
|
||||
}
|
||||
|
|
@ -45,17 +50,46 @@ export interface HermesNotification {
|
|||
}
|
||||
|
||||
export interface HermesPreviewTarget {
|
||||
binary?: boolean
|
||||
byteSize?: number
|
||||
kind: 'file' | 'url'
|
||||
label: string
|
||||
large?: boolean
|
||||
language?: string
|
||||
mimeType?: string
|
||||
path?: string
|
||||
previewKind?: 'binary' | 'html' | 'image' | 'text'
|
||||
renderMode?: 'preview' | 'source'
|
||||
source: string
|
||||
url: string
|
||||
}
|
||||
|
||||
export interface HermesReadFileTextResult {
|
||||
binary?: boolean
|
||||
byteSize?: number
|
||||
language?: string
|
||||
mimeType?: string
|
||||
path: string
|
||||
text: string
|
||||
truncated?: boolean
|
||||
}
|
||||
|
||||
export interface HermesPreviewWatch {
|
||||
id: string
|
||||
path: string
|
||||
}
|
||||
|
||||
export interface HermesReadDirEntry {
|
||||
name: string
|
||||
path: string
|
||||
isDirectory: boolean
|
||||
}
|
||||
|
||||
export interface HermesReadDirResult {
|
||||
entries: HermesReadDirEntry[]
|
||||
error?: string
|
||||
}
|
||||
|
||||
export interface HermesPreviewFileChanged {
|
||||
id: string
|
||||
path: string
|
||||
|
|
|
|||
|
|
@ -102,6 +102,14 @@ export function deleteSession(id: string): Promise<{ ok: boolean }> {
|
|||
})
|
||||
}
|
||||
|
||||
export function renameSession(id: string, title: string): Promise<{ ok: boolean; title: string }> {
|
||||
return window.hermesDesktop.api<{ ok: boolean; title: string }>({
|
||||
path: `/api/sessions/${encodeURIComponent(id)}`,
|
||||
method: 'PATCH',
|
||||
body: { title }
|
||||
})
|
||||
}
|
||||
|
||||
export function getGlobalModelInfo(): Promise<ModelInfoResponse> {
|
||||
return window.hermesDesktop.api<ModelInfoResponse>({
|
||||
path: '/api/model/info'
|
||||
|
|
|
|||
|
|
@ -13,6 +13,8 @@ export type ChatMessage = {
|
|||
pending?: boolean
|
||||
branchGroupId?: string
|
||||
hidden?: boolean
|
||||
/** Composer attachment ref strings (`@file:...`, `@image:...`) sent with this user message. */
|
||||
attachmentRefs?: string[]
|
||||
}
|
||||
|
||||
export type GatewayEventPayload = {
|
||||
|
|
|
|||
|
|
@ -106,10 +106,20 @@ export function coerceGatewayText(value: unknown): string {
|
|||
return String(value)
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize a reasoning/thinking text payload from the gateway.
|
||||
*
|
||||
* Only the leading status prefix (e.g. "Hermes is thinking...") and the
|
||||
* obvious placeholder echoes are stripped. We deliberately do NOT trim
|
||||
* the delta — reasoning streams as small chunks (often individual tokens
|
||||
* with leading or trailing spaces), and trimming each chunk before
|
||||
* concatenation collapses adjacent words together. Whitespace between
|
||||
* tokens belongs to the data, not chrome.
|
||||
*/
|
||||
export function coerceThinkingText(value: unknown): string {
|
||||
const text = coerceGatewayText(value).replace(THINKING_STATUS_PREFIX_RE, '').trim()
|
||||
const raw = coerceGatewayText(value).replace(THINKING_STATUS_PREFIX_RE, '')
|
||||
|
||||
return EMPTY_THINKING_PLACEHOLDER_RE.test(text) ? '' : text
|
||||
return EMPTY_THINKING_PLACEHOLDER_RE.test(raw) ? '' : raw
|
||||
}
|
||||
|
||||
export function isImageGenerationTool(name?: string): boolean {
|
||||
|
|
@ -281,7 +291,7 @@ export function toRuntimeMessage(message: ChatMessage): ThreadMessage {
|
|||
content: message.parts.filter((part): part is Extract<ChatMessagePart, { type: 'text' }> => part.type === 'text'),
|
||||
attachments: [],
|
||||
createdAt,
|
||||
metadata: { custom: {} }
|
||||
metadata: { custom: { attachmentRefs: message.attachmentRefs ?? [] } }
|
||||
} as ThreadMessage
|
||||
}
|
||||
|
||||
|
|
|
|||
32
apps/desktop/src/lib/gateway-events.ts
Normal file
32
apps/desktop/src/lib/gateway-events.ts
Normal file
|
|
@ -0,0 +1,32 @@
|
|||
import type { StatusbarMenuItem } from '@/app/shell/statusbar-controls'
|
||||
|
||||
const LOG_TAIL = 5
|
||||
|
||||
interface RpcEventLike {
|
||||
payload?: unknown
|
||||
type?: string
|
||||
}
|
||||
|
||||
function asRecord(payload: unknown): Record<string, unknown> {
|
||||
return payload && typeof payload === 'object' ? (payload as Record<string, unknown>) : {}
|
||||
}
|
||||
|
||||
export function gatewayEventCompletedFileDiff(event: RpcEventLike): boolean {
|
||||
if (event.type !== 'tool.complete') {return false}
|
||||
const diff = asRecord(event.payload).inline_diff
|
||||
|
||||
return typeof diff === 'string' && diff.trim().length > 0
|
||||
}
|
||||
|
||||
export function buildGatewayLogItems(lines: readonly string[]): readonly StatusbarMenuItem[] {
|
||||
if (lines.length === 0) {
|
||||
return [{ className: 'text-muted-foreground', disabled: true, id: 'gateway-log-empty', label: 'No recent gateway log lines' }]
|
||||
}
|
||||
|
||||
return lines.slice(-LOG_TAIL).map((line, index) => ({
|
||||
className: 'font-mono text-[0.68rem] text-muted-foreground',
|
||||
disabled: true,
|
||||
id: `gateway-log:${index}`,
|
||||
label: line.trim().slice(0, 120) || '(blank log line)'
|
||||
}))
|
||||
}
|
||||
116
apps/desktop/src/lib/local-preview.ts
Normal file
116
apps/desktop/src/lib/local-preview.ts
Normal file
|
|
@ -0,0 +1,116 @@
|
|||
import type { PreviewTarget } from '@/store/preview'
|
||||
|
||||
const HTML_EXTENSIONS = new Set(['.htm', '.html'])
|
||||
const IMAGE_EXTENSIONS = new Set(['.bmp', '.gif', '.jpeg', '.jpg', '.png', '.svg', '.webp'])
|
||||
|
||||
const LANGUAGE_BY_EXT: Record<string, string> = {
|
||||
'.c': 'c',
|
||||
'.conf': 'ini',
|
||||
'.cpp': 'cpp',
|
||||
'.css': 'css',
|
||||
'.csv': 'csv',
|
||||
'.go': 'go',
|
||||
'.graphql': 'graphql',
|
||||
'.h': 'c',
|
||||
'.hpp': 'cpp',
|
||||
'.html': 'html',
|
||||
'.java': 'java',
|
||||
'.js': 'javascript',
|
||||
'.json': 'json',
|
||||
'.jsx': 'jsx',
|
||||
'.log': 'text',
|
||||
'.lua': 'lua',
|
||||
'.md': 'markdown',
|
||||
'.mjs': 'javascript',
|
||||
'.py': 'python',
|
||||
'.rb': 'ruby',
|
||||
'.rs': 'rust',
|
||||
'.sh': 'shell',
|
||||
'.sql': 'sql',
|
||||
'.svg': 'xml',
|
||||
'.toml': 'toml',
|
||||
'.ts': 'typescript',
|
||||
'.tsx': 'tsx',
|
||||
'.txt': 'text',
|
||||
'.xml': 'xml',
|
||||
'.yaml': 'yaml',
|
||||
'.yml': 'yaml',
|
||||
'.zsh': 'shell'
|
||||
}
|
||||
|
||||
function basename(value: string) {
|
||||
return value.split(/[\\/]/).filter(Boolean).pop() || value
|
||||
}
|
||||
|
||||
function extension(value: string) {
|
||||
const clean = value.split(/[?#]/, 1)[0] || value
|
||||
const idx = clean.lastIndexOf('.')
|
||||
|
||||
return idx >= 0 ? clean.slice(idx).toLowerCase() : ''
|
||||
}
|
||||
|
||||
function joinPath(base: string, rel: string) {
|
||||
if (!base) {return rel}
|
||||
|
||||
return `${base.replace(/\/+$/, '')}/${rel.replace(/^\.?\//, '')}`
|
||||
}
|
||||
|
||||
function pathToFileUrl(path: string) {
|
||||
const encoded = path.split('/').map(part => encodeURIComponent(part)).join('/')
|
||||
|
||||
return `file://${encoded.startsWith('/') ? encoded : `/${encoded}`}`
|
||||
}
|
||||
|
||||
export function localPreviewTarget(rawTarget: string, cwd?: string | null): PreviewTarget | null {
|
||||
const raw = rawTarget.trim().replace(/^`|`$/g, '')
|
||||
|
||||
if (!raw) {return null}
|
||||
|
||||
if (/^https?:\/\//i.test(raw)) {
|
||||
return { kind: 'url', label: basename(raw), source: raw, url: raw }
|
||||
}
|
||||
|
||||
let path = raw
|
||||
|
||||
if (/^file:\/\//i.test(raw)) {
|
||||
try {
|
||||
path = decodeURIComponent(new URL(raw).pathname)
|
||||
} catch {
|
||||
path = raw.replace(/^file:\/\//i, '')
|
||||
}
|
||||
} else if (!raw.startsWith('/') && cwd) {
|
||||
path = joinPath(cwd, raw)
|
||||
}
|
||||
|
||||
const ext = extension(path)
|
||||
const isHtml = HTML_EXTENSIONS.has(ext)
|
||||
const isImage = IMAGE_EXTENSIONS.has(ext)
|
||||
|
||||
return {
|
||||
kind: 'file',
|
||||
label: basename(path),
|
||||
language: LANGUAGE_BY_EXT[ext] || 'text',
|
||||
path,
|
||||
// Renderer fallback can't stat/sniff without reading; assume text unless
|
||||
// image/html extension says otherwise. LocalFilePreview still guards
|
||||
// binary/large files when readFileText/readFileDataUrl returns metadata.
|
||||
previewKind: isHtml ? 'html' : isImage ? 'image' : 'text',
|
||||
source: raw,
|
||||
url: pathToFileUrl(path)
|
||||
}
|
||||
}
|
||||
|
||||
export async function normalizeOrLocalPreviewTarget(rawTarget: string, cwd?: string | null): Promise<PreviewTarget | null> {
|
||||
try {
|
||||
const normalized = await window.hermesDesktop?.normalizePreviewTarget?.(rawTarget, cwd || undefined)
|
||||
|
||||
if (normalized) {
|
||||
return normalized
|
||||
}
|
||||
} catch {
|
||||
// Running Electron may still have the old HTML-only preview IPC. Fall
|
||||
// through to renderer-side local classification so text/images still open.
|
||||
}
|
||||
|
||||
return localPreviewTarget(rawTarget, cwd)
|
||||
}
|
||||
182
apps/desktop/src/lib/markdown-preprocess.ts
Normal file
182
apps/desktop/src/lib/markdown-preprocess.ts
Normal file
|
|
@ -0,0 +1,182 @@
|
|||
import { isLikelyProseFence, sanitizeLanguageTag } from '@/lib/markdown-code'
|
||||
import { stripPreviewTargets } from '@/lib/preview-targets'
|
||||
|
||||
/**
|
||||
* Strip provider/model "thinking" blocks before markdown render.
|
||||
*
|
||||
* Some Hermes providers stream raw `<think>…</think>` and similar into
|
||||
* assistant text. Proper reasoning UI uses dedicated `reasoning.*` parts.
|
||||
*/
|
||||
const REASONING_BLOCK_RE = /<(think|thinking|reasoning|scratchpad|analysis)>[\s\S]*?<\/\1>\s*/gi
|
||||
const PREVIEW_MARKER_RE = /\[Preview:[^\]]+\]\(#preview[:/][^)]+\)/gi
|
||||
|
||||
const FENCE_LINE_RE = /^([ \t]*)(`{3,}|~{3,})([^\n]*)$/
|
||||
const EMPTY_FENCE_BLOCK_RE = /(^|\n)[ \t]*(?:`{3,}|~{3,})[^\n]*\n[ \t]*(?:`{3,}|~{3,})[ \t]*(?=\n|$)/g
|
||||
|
||||
/**
|
||||
* Forcefully scrub backtick noise the model streams that doesn't form a
|
||||
* valid markdown construct.
|
||||
*
|
||||
* Strategy: find every well-formed `\`\`\`…\`\`\`` (or `~~~`) block whose
|
||||
* opener and closer are each on their own line, snapshot those ranges as
|
||||
* "protected", then nuke all other triple-backtick runs in the text.
|
||||
* This handles every failure mode in one shot:
|
||||
*
|
||||
* - mid-line `\`\`\`` (`there:\`\`\` cd /Users/...`)
|
||||
* - orphan opener with no closer
|
||||
* - orphan closer with no opener
|
||||
* - chunk-boundary backticks where the preceding non-newline char isn't
|
||||
* in the same regex window
|
||||
* - empty `` `` `` `` and orphan double backticks
|
||||
*
|
||||
* Real, well-formed fenced blocks survive untouched.
|
||||
*/
|
||||
function scrubBacktickNoise(text: string): string {
|
||||
const balancedFenceRe = /(^|\n)([ \t]*)(`{3,}|~{3,})([^\n]*)\n([\s\S]*?)\n[ \t]*\3[ \t]*(?=\n|$)/g
|
||||
const protectedRanges: { end: number; start: number }[] = []
|
||||
let match: RegExpExecArray | null
|
||||
|
||||
while ((match = balancedFenceRe.exec(text)) !== null) {
|
||||
const start = match.index + match[1].length
|
||||
|
||||
protectedRanges.push({ end: balancedFenceRe.lastIndex, start })
|
||||
}
|
||||
|
||||
const fenceNoiseRe = /`{3,}/g
|
||||
let out = ''
|
||||
let cursor = 0
|
||||
|
||||
for (const range of protectedRanges) {
|
||||
out += text.slice(cursor, range.start).replace(fenceNoiseRe, '')
|
||||
out += text.slice(range.start, range.end)
|
||||
cursor = range.end
|
||||
}
|
||||
|
||||
out += text.slice(cursor).replace(fenceNoiseRe, '')
|
||||
|
||||
// Empty inline code spans (`` `` `` `` with nothing meaningful inside)
|
||||
// render as literal backticks. Two passes catch chains.
|
||||
for (let pass = 0; pass < 2; pass += 1) {
|
||||
out = out.replace(/``\s*``/g, '')
|
||||
out = out.replace(/(^|[^`])``(?=\s|[.,;:!?)\]'"\u2014\u2013-]|$)/g, '$1')
|
||||
}
|
||||
|
||||
return out
|
||||
}
|
||||
|
||||
function stripEmptyFenceBlocks(text: string): string {
|
||||
return text.replace(EMPTY_FENCE_BLOCK_RE, '$1')
|
||||
}
|
||||
|
||||
function pushProseFence(out: string[], indent: string, info: string, lines: string[]) {
|
||||
if (info) {
|
||||
out.push(`${indent}${info}`.trimEnd())
|
||||
}
|
||||
|
||||
out.push(...lines)
|
||||
}
|
||||
|
||||
function findClosingFence(lines: string[], start: number, marker: string): number {
|
||||
for (let cursor = start + 1; cursor < lines.length; cursor += 1) {
|
||||
const closeMatch = (lines[cursor] || '').match(FENCE_LINE_RE)
|
||||
|
||||
if (!closeMatch) {
|
||||
continue
|
||||
}
|
||||
|
||||
const closeMarker = closeMatch[2] || ''
|
||||
const closeInfo = (closeMatch[3] || '').trim()
|
||||
|
||||
if (!closeInfo && closeMarker[0] === marker[0] && closeMarker.length >= marker.length) {
|
||||
return cursor
|
||||
}
|
||||
}
|
||||
|
||||
return -1
|
||||
}
|
||||
|
||||
function normalizeFenceBlocks(text: string): string {
|
||||
const sourceLines = text.split('\n')
|
||||
const out: string[] = []
|
||||
let index = 0
|
||||
|
||||
while (index < sourceLines.length) {
|
||||
const line = sourceLines[index] || ''
|
||||
const match = line.match(FENCE_LINE_RE)
|
||||
|
||||
if (!match) {
|
||||
out.push(line)
|
||||
index += 1
|
||||
|
||||
continue
|
||||
}
|
||||
|
||||
const indent = match[1] || ''
|
||||
const marker = match[2] || '```'
|
||||
const infoRaw = (match[3] || '').trim()
|
||||
const languageToken = infoRaw.split(/\s+/, 1)[0] || ''
|
||||
const language = sanitizeLanguageTag(languageToken)
|
||||
const openerValid = !infoRaw || Boolean(language)
|
||||
|
||||
if (!openerValid) {
|
||||
out.push(`${indent}${infoRaw}`.trimEnd())
|
||||
index += 1
|
||||
|
||||
continue
|
||||
}
|
||||
|
||||
const closeIndex = findClosingFence(sourceLines, index, marker)
|
||||
const bodyLines = sourceLines.slice(index + 1, closeIndex === -1 ? sourceLines.length : closeIndex)
|
||||
const body = bodyLines.join('\n')
|
||||
|
||||
if (closeIndex !== -1 && !body.trim()) {
|
||||
index = closeIndex + 1
|
||||
|
||||
continue
|
||||
}
|
||||
|
||||
if (closeIndex === -1) {
|
||||
if (!body.trim()) {
|
||||
index += 1
|
||||
|
||||
continue
|
||||
}
|
||||
|
||||
if (isLikelyProseFence(infoRaw, body)) {
|
||||
pushProseFence(out, indent, infoRaw, bodyLines)
|
||||
} else {
|
||||
out.push(`${indent}${marker}${language}`)
|
||||
out.push(...bodyLines)
|
||||
}
|
||||
|
||||
break
|
||||
}
|
||||
|
||||
if (isLikelyProseFence(infoRaw, body)) {
|
||||
pushProseFence(out, indent, infoRaw, bodyLines)
|
||||
index = closeIndex + 1
|
||||
|
||||
continue
|
||||
}
|
||||
|
||||
out.push(`${indent}${marker}${language}`)
|
||||
out.push(...bodyLines)
|
||||
out.push(`${indent}${marker}`)
|
||||
index = closeIndex + 1
|
||||
}
|
||||
|
||||
return out.join('\n')
|
||||
}
|
||||
|
||||
export function preprocessMarkdown(text: string): string {
|
||||
const cleaned = text.replace(REASONING_BLOCK_RE, '').replace(PREVIEW_MARKER_RE, '')
|
||||
const scrubbed = scrubBacktickNoise(cleaned)
|
||||
const normalizedFences = normalizeFenceBlocks(scrubbed)
|
||||
const strippedEmptyFences = stripEmptyFenceBlocks(normalizedFences)
|
||||
|
||||
return strippedEmptyFences
|
||||
.split(/((?:```|~~~)[\s\S]*?(?:```|~~~))/g)
|
||||
.map(part => (/^(?:```|~~~)/.test(part) ? part : stripPreviewTargets(part)))
|
||||
.join('')
|
||||
.replace(/[ \t]+\n/g, '\n')
|
||||
}
|
||||
|
|
@ -1,54 +1,15 @@
|
|||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import {
|
||||
extractPreviewCandidates,
|
||||
extractPreviewTargets,
|
||||
isLikelyPreviewCandidate,
|
||||
previewTargetFromMarkdownHref,
|
||||
renderPreviewTargets,
|
||||
stripPreviewTargets
|
||||
} from './preview-targets'
|
||||
|
||||
describe('preview target detection', () => {
|
||||
it('extracts local server URLs and html files', () => {
|
||||
expect(
|
||||
extractPreviewCandidates(
|
||||
'Open http://localhost:5173/ and /tmp/mycelium-bunnies/index.html, not https://example.com/app.'
|
||||
)
|
||||
).toEqual(['http://localhost:5173/', '/tmp/mycelium-bunnies/index.html'])
|
||||
})
|
||||
|
||||
it('accepts relative html files and file URLs', () => {
|
||||
expect(extractPreviewCandidates('Wrote ./dist/index.html and file:///tmp/demo.html.')).toEqual([
|
||||
'./dist/index.html',
|
||||
'file:///tmp/demo.html'
|
||||
])
|
||||
})
|
||||
|
||||
it('accepts bare html files and common preview directories', () => {
|
||||
expect(extractPreviewCandidates('Open index.html, nested/demo.html, ./dist, and /tmp/site/.')).toEqual([
|
||||
'index.html',
|
||||
'nested/demo.html',
|
||||
'./dist',
|
||||
'/tmp/site/'
|
||||
])
|
||||
})
|
||||
|
||||
it('rejects non-html file URLs and obvious local API or asset URLs', () => {
|
||||
expect(isLikelyPreviewCandidate('file:///tmp/demo.png')).toBe(false)
|
||||
expect(isLikelyPreviewCandidate('http://localhost:3000/api/users')).toBe(false)
|
||||
expect(isLikelyPreviewCandidate('http://localhost:3000/src/main.tsx')).toBe(false)
|
||||
})
|
||||
|
||||
it('ignores remote web URLs', () => {
|
||||
expect(isLikelyPreviewCandidate('https://example.com/demo')).toBe(false)
|
||||
expect(isLikelyPreviewCandidate('http://127.0.0.1:3000')).toBe(true)
|
||||
})
|
||||
|
||||
it('renders previewable paths as markdown links', () => {
|
||||
expect(renderPreviewTargets('ready\n/tmp/mycelium-bunnies.html\nopen it')).toBe(
|
||||
'ready\n[Preview: mycelium-bunnies.html](#preview/%2Ftmp%2Fmycelium-bunnies.html)\nopen it'
|
||||
)
|
||||
it('does not infer preview targets from raw paths or URLs', () => {
|
||||
expect(extractPreviewTargets('Preview: http://localhost:5173/')).toEqual([])
|
||||
expect(extractPreviewTargets('Open index.html\n/tmp/demo.html\nhttp://localhost:5173/')).toEqual([])
|
||||
})
|
||||
|
||||
it('decodes preview markdown hrefs', () => {
|
||||
|
|
@ -62,7 +23,9 @@ describe('preview target detection', () => {
|
|||
})
|
||||
|
||||
it('strips preview targets from visible assistant text', () => {
|
||||
expect(stripPreviewTargets('ready\n/tmp/mycelium-bunnies.html\nopen it')).toBe('ready\nopen it')
|
||||
expect(stripPreviewTargets('ready\n/tmp/mycelium-bunnies.html\nopen it')).toBe(
|
||||
'ready\n/tmp/mycelium-bunnies.html\nopen it'
|
||||
)
|
||||
expect(stripPreviewTargets('[Preview: demo.html](#preview:%2Ftmp%2Fdemo.html)\nopen it')).toBe('open it')
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -1,217 +1,7 @@
|
|||
const LOCAL_HOSTS = new Set(['0.0.0.0', '127.0.0.1', '::1', '[::1]', 'localhost'])
|
||||
|
||||
const PREVIEW_DIRECTORY_NAMES = new Set(['build', 'dist', 'out', 'public', 'site', 'web', 'www'])
|
||||
|
||||
const HTML_EXT_RE = /\.html?(?:[?#].*)?$/i
|
||||
|
||||
const ASSET_EXT_RE =
|
||||
/\.(?:cjs|css|csv|gif|ico|jpe?g|js|json|jsx|map|mjs|otf|png|svg|ts|tsx|ttf|txt|wasm|webp|woff2?|xml)$/i
|
||||
|
||||
const URL_RE = /\bhttps?:\/\/[^\s<>"'`)\]]+/gi
|
||||
|
||||
const FILE_URL_RE = /\bfile:\/\/[^\s<>"'`)\]]+/gi
|
||||
|
||||
const POSIX_HTML_PATH_RE =
|
||||
/(?:^|[\s("'`])(?<path>\/[^\s<>"'`]*?\.html?(?:[?#][^\s<>"'`)\]]*)?)(?:[),.;:!?]*)(?=$|[\s)"'`])/gi
|
||||
|
||||
const RELATIVE_HTML_PATH_RE =
|
||||
/(?:^|[\s("'`])(?<path>\.{1,2}\/[^\s<>"'`]*?\.html?(?:[?#][^\s<>"'`)\]]*)?)(?:[),.;:!?]*)(?=$|[\s)"'`])/gi
|
||||
|
||||
const BARE_HTML_PATH_RE =
|
||||
/(?:^|[\s("'`])(?<path>(?:[A-Za-z0-9._-]+\/)*[A-Za-z0-9._-]+\.html?(?:[?#][^\s<>"'`)\]]*)?)(?:[),.;:!?]*)(?=$|[\s)"'`])/gi
|
||||
|
||||
const POSIX_PATH_RE = /(?:^|[\s("'`])(?<path>\/[^\s<>"'`]+)(?:[),.;:!?]*)(?=$|[\s)"'`])/gi
|
||||
|
||||
const RELATIVE_PATH_RE = /(?:^|[\s("'`])(?<path>(?:\.{1,2}|~)\/[^\s<>"'`]+)(?:[),.;:!?]*)(?=$|[\s)"'`])/gi
|
||||
|
||||
const PREVIEW_MARKDOWN_RE = /\[Preview:[^\]]+\]\((?<href>#preview[:/][^)]+)\)/gi
|
||||
|
||||
interface PreviewCandidateMatch {
|
||||
end: number
|
||||
index: number
|
||||
value: string
|
||||
}
|
||||
|
||||
function stripTrailingPunctuation(value: string): string {
|
||||
return value.replace(/[),.;:!?]+$/, '')
|
||||
}
|
||||
|
||||
function pathWithoutQuery(value: string): string {
|
||||
return value.split(/[?#]/, 1)[0]
|
||||
}
|
||||
|
||||
function pathBasename(value: string): string {
|
||||
return pathWithoutQuery(value).replace(/\/+$/, '').split(/[\\/]/).filter(Boolean).pop()?.toLowerCase() || ''
|
||||
}
|
||||
|
||||
function isHtmlFileUrl(value: string): boolean {
|
||||
try {
|
||||
const url = new URL(value)
|
||||
|
||||
return url.protocol === 'file:' && HTML_EXT_RE.test(url.pathname)
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
function isPreviewDirectoryCandidate(value: string): boolean {
|
||||
const path = pathWithoutQuery(value)
|
||||
|
||||
if (!/^(?:\/|\.{1,2}\/|~\/)/.test(path) || HTML_EXT_RE.test(value)) {
|
||||
return false
|
||||
}
|
||||
|
||||
const name = pathBasename(path)
|
||||
|
||||
if (!name || /\.[a-z0-9]{1,8}$/i.test(name)) {
|
||||
return false
|
||||
}
|
||||
|
||||
return path.endsWith('/') || PREVIEW_DIRECTORY_NAMES.has(name)
|
||||
}
|
||||
|
||||
function isLocalPreviewUrl(value: string): boolean {
|
||||
try {
|
||||
const url = new URL(value)
|
||||
|
||||
if (!['http:', 'https:'].includes(url.protocol)) {
|
||||
return false
|
||||
}
|
||||
|
||||
if (!LOCAL_HOSTS.has(url.hostname.toLowerCase())) {
|
||||
return false
|
||||
}
|
||||
|
||||
const pathname = url.pathname.toLowerCase()
|
||||
|
||||
if (/^\/(?:api|graphql|health|metrics|rpc)(?:\/|$)/.test(pathname)) {
|
||||
return false
|
||||
}
|
||||
|
||||
return !ASSET_EXT_RE.test(pathname)
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
export function isLikelyPreviewCandidate(value: string): boolean {
|
||||
const trimmed = stripTrailingPunctuation(value.trim())
|
||||
|
||||
return (
|
||||
isHtmlFileUrl(trimmed) ||
|
||||
HTML_EXT_RE.test(trimmed) ||
|
||||
isPreviewDirectoryCandidate(trimmed) ||
|
||||
isLocalPreviewUrl(trimmed)
|
||||
)
|
||||
}
|
||||
|
||||
function collectPreviewMatches(text: string): PreviewCandidateMatch[] {
|
||||
const matches: PreviewCandidateMatch[] = []
|
||||
|
||||
const collect = (index: number | undefined, raw: string, value = raw) => {
|
||||
if (index === undefined) {
|
||||
return
|
||||
}
|
||||
|
||||
const candidate = stripTrailingPunctuation(value.trim())
|
||||
|
||||
if (!candidate || !isLikelyPreviewCandidate(candidate)) {
|
||||
return
|
||||
}
|
||||
|
||||
const offset = raw.indexOf(value)
|
||||
const start = index + Math.max(0, offset)
|
||||
|
||||
matches.push({
|
||||
end: start + candidate.length,
|
||||
index: start,
|
||||
value: candidate
|
||||
})
|
||||
}
|
||||
|
||||
for (const match of text.matchAll(URL_RE)) {
|
||||
collect(match.index, match[0])
|
||||
}
|
||||
|
||||
for (const match of text.matchAll(FILE_URL_RE)) {
|
||||
collect(match.index, match[0])
|
||||
}
|
||||
|
||||
for (const match of text.matchAll(POSIX_HTML_PATH_RE)) {
|
||||
collect(match.index, match[0], match.groups?.path || '')
|
||||
}
|
||||
|
||||
for (const match of text.matchAll(RELATIVE_HTML_PATH_RE)) {
|
||||
collect(match.index, match[0], match.groups?.path || '')
|
||||
}
|
||||
|
||||
for (const match of text.matchAll(BARE_HTML_PATH_RE)) {
|
||||
collect(match.index, match[0], match.groups?.path || '')
|
||||
}
|
||||
|
||||
for (const match of text.matchAll(POSIX_PATH_RE)) {
|
||||
collect(match.index, match[0], match.groups?.path || '')
|
||||
}
|
||||
|
||||
for (const match of text.matchAll(RELATIVE_PATH_RE)) {
|
||||
collect(match.index, match[0], match.groups?.path || '')
|
||||
}
|
||||
|
||||
return matches.sort((a, b) => a.index - b.index)
|
||||
}
|
||||
|
||||
export function extractPreviewCandidates(text: string): string[] {
|
||||
const candidates: string[] = []
|
||||
const seen = new Set<string>()
|
||||
|
||||
const push = (value: string) => {
|
||||
const candidate = stripTrailingPunctuation(value.trim())
|
||||
|
||||
if (!candidate || seen.has(candidate) || !isLikelyPreviewCandidate(candidate)) {
|
||||
return
|
||||
}
|
||||
|
||||
seen.add(candidate)
|
||||
candidates.push(candidate)
|
||||
}
|
||||
|
||||
for (const match of collectPreviewMatches(text)) {
|
||||
push(match.value)
|
||||
}
|
||||
|
||||
return candidates
|
||||
}
|
||||
|
||||
export function stripPreviewTargets(text: string): string {
|
||||
const matches = collectPreviewMatches(text)
|
||||
let cursor = 0
|
||||
let stripped = ''
|
||||
|
||||
for (const match of matches) {
|
||||
if (match.index < cursor) {
|
||||
continue
|
||||
}
|
||||
|
||||
const lineStart = text.lastIndexOf('\n', Math.max(0, match.index - 1)) + 1
|
||||
const nextLineBreak = text.indexOf('\n', match.end)
|
||||
const lineEnd = nextLineBreak === -1 ? text.length : nextLineBreak + 1
|
||||
const beforeOnLine = text.slice(lineStart, match.index)
|
||||
const afterOnLine = text.slice(match.end, nextLineBreak === -1 ? text.length : nextLineBreak)
|
||||
|
||||
if (lineStart >= cursor && !beforeOnLine.trim() && !afterOnLine.trim()) {
|
||||
stripped += text.slice(cursor, lineStart)
|
||||
cursor = lineEnd
|
||||
|
||||
continue
|
||||
}
|
||||
|
||||
stripped += text.slice(cursor, match.index)
|
||||
cursor = match.end
|
||||
}
|
||||
|
||||
stripped += text.slice(cursor)
|
||||
|
||||
return stripped
|
||||
return text
|
||||
.replace(PREVIEW_MARKDOWN_RE, '')
|
||||
.replace(/[ \t]+\n/g, '\n')
|
||||
.replace(/\n{3,}/g, '\n\n')
|
||||
|
|
@ -219,8 +9,8 @@ export function stripPreviewTargets(text: string): string {
|
|||
}
|
||||
|
||||
export function extractPreviewTargets(text: string): string[] {
|
||||
const targets = extractPreviewCandidates(text)
|
||||
const seen = new Set(targets)
|
||||
const targets: string[] = []
|
||||
const seen = new Set<string>()
|
||||
|
||||
for (const match of text.matchAll(PREVIEW_MARKDOWN_RE)) {
|
||||
const target = previewTargetFromMarkdownHref(match.groups?.href)
|
||||
|
|
@ -272,26 +62,3 @@ export function previewDisplayLabel(target: string): string {
|
|||
return `Preview: ${escaped}`
|
||||
}
|
||||
|
||||
function previewLink(value: string): string {
|
||||
return `[${previewDisplayLabel(value)}](${previewMarkdownHref(value)})`
|
||||
}
|
||||
|
||||
export function renderPreviewTargets(text: string): string {
|
||||
const matches = collectPreviewMatches(text)
|
||||
let cursor = 0
|
||||
let rendered = ''
|
||||
const seen = new Set<string>()
|
||||
|
||||
for (const match of matches) {
|
||||
if (match.index < cursor || seen.has(match.value)) {
|
||||
continue
|
||||
}
|
||||
|
||||
rendered += text.slice(cursor, match.index)
|
||||
rendered += previewLink(match.value)
|
||||
cursor = match.end
|
||||
seen.add(match.value)
|
||||
}
|
||||
|
||||
return rendered + text.slice(cursor)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import './styles.css'
|
||||
import 'streamdown/styles.css'
|
||||
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||
import { StrictMode } from 'react'
|
||||
|
|
|
|||
|
|
@ -1,47 +1,67 @@
|
|||
import { atom } from 'nanostores'
|
||||
import { atom, computed, type ReadableAtom } from 'nanostores'
|
||||
|
||||
import { arraysEqual, insertUniqueId, persistStringArray, storedStringArray } from '@/lib/storage'
|
||||
|
||||
import {
|
||||
arraysEqual,
|
||||
insertUniqueId,
|
||||
persistBoolean,
|
||||
persistStringArray,
|
||||
storedBoolean,
|
||||
storedStringArray
|
||||
} from '@/lib/storage'
|
||||
$paneStates,
|
||||
ensurePaneRegistered,
|
||||
setPaneOpen,
|
||||
setPaneWidthOverride,
|
||||
togglePane
|
||||
} from './panes'
|
||||
|
||||
export const SIDEBAR_DEFAULT_WIDTH = 224
|
||||
export const SIDEBAR_MAX_WIDTH = 320
|
||||
const SIDEBAR_OPEN_STORAGE_KEY = 'hermes.desktop.sidebarOpen'
|
||||
const SIDEBAR_PINNED_STORAGE_KEY = 'hermes.desktop.pinnedSessions'
|
||||
const INSPECTOR_OPEN_STORAGE_KEY = 'hermes.desktop.inspectorOpen'
|
||||
export const FILE_BROWSER_DEFAULT_WIDTH = '17rem'
|
||||
|
||||
const SIDEBAR_PINNED_STORAGE_KEY = 'hermes.desktop.pinnedSessions'
|
||||
|
||||
export const CHAT_SIDEBAR_PANE_ID = 'chat-sidebar'
|
||||
export const FILE_BROWSER_PANE_ID = 'file-browser'
|
||||
|
||||
// Pre-register app chrome panes so legacy callers see the correct initial
|
||||
// values whether or not the user has any persisted state.
|
||||
ensurePaneRegistered(CHAT_SIDEBAR_PANE_ID, { open: true })
|
||||
ensurePaneRegistered(FILE_BROWSER_PANE_ID, { open: false })
|
||||
|
||||
export const $sidebarOpen: ReadableAtom<boolean> = computed(
|
||||
$paneStates,
|
||||
states => states[CHAT_SIDEBAR_PANE_ID]?.open ?? true
|
||||
)
|
||||
|
||||
export const $fileBrowserOpen: ReadableAtom<boolean> = computed(
|
||||
$paneStates,
|
||||
states => states[FILE_BROWSER_PANE_ID]?.open ?? false
|
||||
)
|
||||
|
||||
export const $sidebarWidth: ReadableAtom<number> = computed($paneStates, states => {
|
||||
const override = states[CHAT_SIDEBAR_PANE_ID]?.widthOverride
|
||||
|
||||
return typeof override === 'number' ? override : SIDEBAR_DEFAULT_WIDTH
|
||||
})
|
||||
|
||||
export const $sidebarWidth = atom(SIDEBAR_DEFAULT_WIDTH)
|
||||
export const $sidebarOpen = atom(storedBoolean(SIDEBAR_OPEN_STORAGE_KEY, true))
|
||||
export const $inspectorOpen = atom(storedBoolean(INSPECTOR_OPEN_STORAGE_KEY, true))
|
||||
export const $pinnedSessionIds = atom(storedStringArray(SIDEBAR_PINNED_STORAGE_KEY))
|
||||
export const $sidebarPinsOpen = atom(true)
|
||||
export const $sidebarRecentsOpen = atom(true)
|
||||
export const $isSidebarResizing = atom(false)
|
||||
|
||||
$sidebarOpen.subscribe(open => persistBoolean(SIDEBAR_OPEN_STORAGE_KEY, open))
|
||||
$inspectorOpen.subscribe(open => persistBoolean(INSPECTOR_OPEN_STORAGE_KEY, open))
|
||||
$pinnedSessionIds.subscribe(ids => persistStringArray(SIDEBAR_PINNED_STORAGE_KEY, [...ids]))
|
||||
|
||||
export function setSidebarWidth(width: number) {
|
||||
const bounded = Math.min(SIDEBAR_MAX_WIDTH, Math.max(SIDEBAR_DEFAULT_WIDTH, width))
|
||||
$sidebarWidth.set(bounded)
|
||||
setPaneWidthOverride(CHAT_SIDEBAR_PANE_ID, bounded)
|
||||
}
|
||||
|
||||
export function setSidebarOpen(open: boolean) {
|
||||
$sidebarOpen.set(open)
|
||||
setPaneOpen(CHAT_SIDEBAR_PANE_ID, open)
|
||||
}
|
||||
|
||||
export function toggleSidebarOpen() {
|
||||
$sidebarOpen.set(!$sidebarOpen.get())
|
||||
togglePane(CHAT_SIDEBAR_PANE_ID)
|
||||
}
|
||||
|
||||
export function toggleInspectorOpen() {
|
||||
$inspectorOpen.set(!$inspectorOpen.get())
|
||||
export function toggleFileBrowserOpen() {
|
||||
togglePane(FILE_BROWSER_PANE_ID)
|
||||
}
|
||||
|
||||
export function setSidebarPinsOpen(open: boolean) {
|
||||
|
|
|
|||
146
apps/desktop/src/store/panes.test.ts
Normal file
146
apps/desktop/src/store/panes.test.ts
Normal file
|
|
@ -0,0 +1,146 @@
|
|||
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
|
||||
|
||||
import {
|
||||
$paneOpen,
|
||||
$paneStates,
|
||||
$paneWidthOverride,
|
||||
clearPaneWidthOverride,
|
||||
ensurePaneRegistered,
|
||||
getPaneStateSnapshot,
|
||||
setPaneOpen,
|
||||
setPaneWidthOverride,
|
||||
togglePane
|
||||
} from './panes'
|
||||
|
||||
const STORAGE_KEY = 'hermes.desktop.paneStates.v1'
|
||||
|
||||
describe('panes store', () => {
|
||||
beforeEach(() => {
|
||||
$paneStates.set({})
|
||||
window.localStorage.clear()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
$paneStates.set({})
|
||||
window.localStorage.clear()
|
||||
})
|
||||
|
||||
describe('ensurePaneRegistered', () => {
|
||||
it('adds a pane with defaults when missing', () => {
|
||||
ensurePaneRegistered('files', { open: true })
|
||||
|
||||
expect(getPaneStateSnapshot('files')).toEqual({ open: true, widthOverride: undefined })
|
||||
})
|
||||
|
||||
it('is a no-op when the pane already exists', () => {
|
||||
ensurePaneRegistered('files', { open: false })
|
||||
ensurePaneRegistered('files', { open: true })
|
||||
|
||||
expect(getPaneStateSnapshot('files')?.open).toBe(false)
|
||||
})
|
||||
|
||||
it('preserves an existing widthOverride when re-registering', () => {
|
||||
ensurePaneRegistered('files', { open: true })
|
||||
setPaneWidthOverride('files', 360)
|
||||
ensurePaneRegistered('files', { open: false })
|
||||
|
||||
expect(getPaneStateSnapshot('files')?.widthOverride).toBe(360)
|
||||
})
|
||||
})
|
||||
|
||||
describe('setPaneOpen / togglePane', () => {
|
||||
it('updates the pane open flag', () => {
|
||||
ensurePaneRegistered('files', { open: false })
|
||||
setPaneOpen('files', true)
|
||||
|
||||
expect(getPaneStateSnapshot('files')?.open).toBe(true)
|
||||
})
|
||||
|
||||
it('togglePane flips the current value', () => {
|
||||
ensurePaneRegistered('files', { open: false })
|
||||
togglePane('files')
|
||||
togglePane('files')
|
||||
togglePane('files')
|
||||
|
||||
expect(getPaneStateSnapshot('files')?.open).toBe(true)
|
||||
})
|
||||
|
||||
it('togglePane on an unregistered id starts from false', () => {
|
||||
togglePane('ephemeral')
|
||||
|
||||
expect(getPaneStateSnapshot('ephemeral')?.open).toBe(true)
|
||||
})
|
||||
|
||||
it('preserves widthOverride across open/close changes', () => {
|
||||
ensurePaneRegistered('files', { open: true })
|
||||
setPaneWidthOverride('files', 280)
|
||||
setPaneOpen('files', false)
|
||||
setPaneOpen('files', true)
|
||||
|
||||
expect(getPaneStateSnapshot('files')?.widthOverride).toBe(280)
|
||||
})
|
||||
})
|
||||
|
||||
describe('width overrides', () => {
|
||||
it('setPaneWidthOverride stores the px value', () => {
|
||||
ensurePaneRegistered('files', { open: true })
|
||||
setPaneWidthOverride('files', 300)
|
||||
|
||||
expect(getPaneStateSnapshot('files')?.widthOverride).toBe(300)
|
||||
})
|
||||
|
||||
it('clearPaneWidthOverride removes the override', () => {
|
||||
ensurePaneRegistered('files', { open: true })
|
||||
setPaneWidthOverride('files', 300)
|
||||
clearPaneWidthOverride('files')
|
||||
|
||||
expect(getPaneStateSnapshot('files')?.widthOverride).toBeUndefined()
|
||||
})
|
||||
|
||||
it('width override is in-memory only — not persisted across reloads', () => {
|
||||
ensurePaneRegistered('files', { open: true })
|
||||
setPaneWidthOverride('files', 300)
|
||||
|
||||
const persisted = window.localStorage.getItem(STORAGE_KEY)
|
||||
|
||||
expect(persisted).not.toBeNull()
|
||||
expect(JSON.parse(persisted ?? '{}')).toEqual({ files: { open: true } })
|
||||
})
|
||||
|
||||
it('open flag is persisted across changes', () => {
|
||||
ensurePaneRegistered('files', { open: false })
|
||||
setPaneOpen('files', true)
|
||||
|
||||
const persisted = window.localStorage.getItem(STORAGE_KEY)
|
||||
|
||||
expect(persisted).not.toBeNull()
|
||||
expect(JSON.parse(persisted ?? '{}')).toEqual({ files: { open: true } })
|
||||
})
|
||||
})
|
||||
|
||||
describe('derived atoms', () => {
|
||||
it('$paneOpen reflects the pane state', () => {
|
||||
const open$ = $paneOpen('files')
|
||||
expect(open$.get()).toBe(false)
|
||||
|
||||
ensurePaneRegistered('files', { open: true })
|
||||
expect(open$.get()).toBe(true)
|
||||
|
||||
setPaneOpen('files', false)
|
||||
expect(open$.get()).toBe(false)
|
||||
})
|
||||
|
||||
it('$paneWidthOverride reflects the width', () => {
|
||||
const width$ = $paneWidthOverride('files')
|
||||
expect(width$.get()).toBeUndefined()
|
||||
|
||||
ensurePaneRegistered('files', { open: true })
|
||||
setPaneWidthOverride('files', 240)
|
||||
expect(width$.get()).toBe(240)
|
||||
})
|
||||
|
||||
it('$paneOpen returns the same atom instance for repeated calls', () => {
|
||||
expect($paneOpen('files')).toBe($paneOpen('files'))
|
||||
})
|
||||
})
|
||||
})
|
||||
118
apps/desktop/src/store/panes.ts
Normal file
118
apps/desktop/src/store/panes.ts
Normal file
|
|
@ -0,0 +1,118 @@
|
|||
import { atom, computed, type ReadableAtom } from 'nanostores'
|
||||
|
||||
export interface PaneStateSnapshot {
|
||||
open: boolean
|
||||
widthOverride?: number
|
||||
}
|
||||
|
||||
export interface PaneRegisterDefaults {
|
||||
open: boolean
|
||||
widthOverride?: number
|
||||
}
|
||||
|
||||
const STORAGE_KEY = 'hermes.desktop.paneStates.v1'
|
||||
|
||||
function isSnapshot(value: unknown): value is PaneStateSnapshot {
|
||||
if (!value || typeof value !== 'object') {return false}
|
||||
const r = value as Record<string, unknown>
|
||||
|
||||
if (typeof r.open !== 'boolean') {return false}
|
||||
|
||||
return r.widthOverride === undefined || (typeof r.widthOverride === 'number' && Number.isFinite(r.widthOverride))
|
||||
}
|
||||
|
||||
function load(): Record<string, PaneStateSnapshot> {
|
||||
if (typeof window === 'undefined') {return {}}
|
||||
|
||||
try {
|
||||
const raw = window.localStorage.getItem(STORAGE_KEY)
|
||||
|
||||
if (raw) {
|
||||
const parsed = JSON.parse(raw) as unknown
|
||||
|
||||
if (parsed && typeof parsed === 'object') {
|
||||
const out: Record<string, PaneStateSnapshot> = {}
|
||||
|
||||
for (const [id, value] of Object.entries(parsed as Record<string, unknown>)) {
|
||||
if (isSnapshot(value)) {out[id] = { open: value.open, widthOverride: value.widthOverride }}
|
||||
}
|
||||
|
||||
return out
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Treat unparseable persisted state as missing.
|
||||
}
|
||||
|
||||
return {}
|
||||
}
|
||||
|
||||
// widthOverride is in-memory only — phase 2 can add per-pane persistWidth opt-in.
|
||||
function persist(states: Record<string, PaneStateSnapshot>) {
|
||||
if (typeof window === 'undefined') {return}
|
||||
const minimal: Record<string, { open: boolean }> = {}
|
||||
|
||||
for (const [id, s] of Object.entries(states)) {minimal[id] = { open: s.open }}
|
||||
|
||||
try {
|
||||
window.localStorage.setItem(STORAGE_KEY, JSON.stringify(minimal))
|
||||
} catch {
|
||||
// Storage failures are nonfatal.
|
||||
}
|
||||
}
|
||||
|
||||
export const $paneStates = atom<Record<string, PaneStateSnapshot>>(load())
|
||||
|
||||
$paneStates.subscribe(persist)
|
||||
|
||||
// Cached per-pane derived atoms keep useStore subscriptions referentially stable.
|
||||
function memoized<T>(cache: Map<string, ReadableAtom<T>>, id: string, selector: (s: PaneStateSnapshot | undefined) => T) {
|
||||
let cached = cache.get(id)
|
||||
|
||||
if (!cached) {
|
||||
cached = computed($paneStates, states => selector(states[id]))
|
||||
cache.set(id, cached)
|
||||
}
|
||||
|
||||
return cached
|
||||
}
|
||||
|
||||
const openCache = new Map<string, ReadableAtom<boolean>>()
|
||||
const stateCache = new Map<string, ReadableAtom<PaneStateSnapshot | undefined>>()
|
||||
const widthCache = new Map<string, ReadableAtom<number | undefined>>()
|
||||
|
||||
export const $paneOpen = (id: string) => memoized(openCache, id, s => s?.open ?? false)
|
||||
export const $paneState = (id: string) => memoized(stateCache, id, s => s)
|
||||
export const $paneWidthOverride = (id: string) => memoized(widthCache, id, s => s?.widthOverride)
|
||||
|
||||
export function ensurePaneRegistered(id: string, defaults: PaneRegisterDefaults) {
|
||||
const current = $paneStates.get()
|
||||
|
||||
if (current[id] !== undefined) {return}
|
||||
$paneStates.set({ ...current, [id]: { open: defaults.open, widthOverride: defaults.widthOverride } })
|
||||
}
|
||||
|
||||
export function setPaneOpen(id: string, open: boolean) {
|
||||
const current = $paneStates.get()
|
||||
const existing = current[id]
|
||||
|
||||
if (existing?.open === open) {return}
|
||||
$paneStates.set({ ...current, [id]: { open, widthOverride: existing?.widthOverride } })
|
||||
}
|
||||
|
||||
export function togglePane(id: string) {
|
||||
const current = $paneStates.get()
|
||||
const existing = current[id]
|
||||
$paneStates.set({ ...current, [id]: { open: !(existing?.open ?? false), widthOverride: existing?.widthOverride } })
|
||||
}
|
||||
|
||||
export function setPaneWidthOverride(id: string, width: number | undefined) {
|
||||
const current = $paneStates.get()
|
||||
const existing = current[id] ?? { open: false }
|
||||
|
||||
if (existing.widthOverride === width) {return}
|
||||
$paneStates.set({ ...current, [id]: { open: existing.open, widthOverride: width } })
|
||||
}
|
||||
|
||||
export const clearPaneWidthOverride = (id: string) => setPaneWidthOverride(id, undefined)
|
||||
export const getPaneStateSnapshot = (id: string) => $paneStates.get()[id]
|
||||
131
apps/desktop/src/store/preview.test.ts
Normal file
131
apps/desktop/src/store/preview.test.ts
Normal file
|
|
@ -0,0 +1,131 @@
|
|||
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
|
||||
|
||||
import {
|
||||
$filePreviewTarget,
|
||||
$previewServerRestart,
|
||||
$previewServerRestartStatus,
|
||||
$previewTarget,
|
||||
$sessionPreviewRegistry,
|
||||
beginPreviewServerRestart,
|
||||
clearSessionPreviewRegistry,
|
||||
dismissFilePreviewTarget,
|
||||
dismissPreviewTarget,
|
||||
getSessionPreviewRecord,
|
||||
type PreviewTarget,
|
||||
progressPreviewServerRestart,
|
||||
setCurrentSessionPreviewTarget
|
||||
} from './preview'
|
||||
import { $activeSessionId, $selectedStoredSessionId } from './session'
|
||||
|
||||
function previewTarget(source: string): PreviewTarget {
|
||||
return {
|
||||
kind: 'file',
|
||||
label: source,
|
||||
path: source,
|
||||
previewKind: 'html',
|
||||
source,
|
||||
url: `file://${source}`
|
||||
}
|
||||
}
|
||||
|
||||
function withRenderMode(target: PreviewTarget, renderMode: PreviewTarget['renderMode']): PreviewTarget {
|
||||
return { ...target, renderMode }
|
||||
}
|
||||
|
||||
describe('preview store', () => {
|
||||
beforeEach(() => {
|
||||
$previewServerRestart.set(null)
|
||||
$activeSessionId.set('session-1')
|
||||
$selectedStoredSessionId.set(null)
|
||||
window.localStorage.clear()
|
||||
clearSessionPreviewRegistry()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
$previewServerRestart.set(null)
|
||||
$activeSessionId.set(null)
|
||||
$selectedStoredSessionId.set(null)
|
||||
window.localStorage.clear()
|
||||
clearSessionPreviewRegistry()
|
||||
})
|
||||
|
||||
it('does not notify status subscribers for restart progress text', () => {
|
||||
const statuses: string[] = []
|
||||
const unsubscribe = $previewServerRestartStatus.subscribe(status => statuses.push(status))
|
||||
|
||||
beginPreviewServerRestart('task-1', 'http://localhost:5174')
|
||||
progressPreviewServerRestart('task-1', 'first line')
|
||||
progressPreviewServerRestart('task-1', 'second line')
|
||||
unsubscribe()
|
||||
|
||||
expect(statuses).toEqual(['idle', 'running'])
|
||||
})
|
||||
|
||||
it('persists registered previews and dismissal per session', () => {
|
||||
const target = previewTarget('/work/demo.html')
|
||||
|
||||
setCurrentSessionPreviewTarget(target, 'tool-result')
|
||||
|
||||
expect($previewTarget.get()).toEqual(withRenderMode(target, 'preview'))
|
||||
expect(getSessionPreviewRecord('session-1')?.normalized).toEqual(withRenderMode(target, 'preview'))
|
||||
expect(window.localStorage.getItem('hermes.desktop.sessionPreviews.v1')).toContain('/work/demo.html')
|
||||
|
||||
dismissPreviewTarget()
|
||||
|
||||
expect($previewTarget.get()).toBeNull()
|
||||
expect(getSessionPreviewRecord('session-1')).toBeNull()
|
||||
expect($sessionPreviewRegistry.get()['session-1']?.[0]?.dismissedAt).toEqual(expect.any(Number))
|
||||
|
||||
setCurrentSessionPreviewTarget(target, 'tool-result')
|
||||
|
||||
expect(getSessionPreviewRecord('session-1')?.dismissedAt).toBeUndefined()
|
||||
})
|
||||
|
||||
it('replaces the session preview instead of keeping a back stack', () => {
|
||||
const first = previewTarget('/work/first.html')
|
||||
const second = previewTarget('/work/second.html')
|
||||
|
||||
setCurrentSessionPreviewTarget(first, 'tool-result')
|
||||
setCurrentSessionPreviewTarget(second, 'tool-result')
|
||||
|
||||
expect($sessionPreviewRegistry.get()['session-1']).toHaveLength(1)
|
||||
expect(getSessionPreviewRecord('session-1')?.normalized).toEqual(withRenderMode(second, 'preview'))
|
||||
|
||||
dismissPreviewTarget()
|
||||
|
||||
expect($previewTarget.get()).toBeNull()
|
||||
expect(getSessionPreviewRecord('session-1')).toBeNull()
|
||||
expect($sessionPreviewRegistry.get()['session-1']?.map(record => record.normalized.url)).toEqual([
|
||||
'file:///work/second.html'
|
||||
])
|
||||
})
|
||||
|
||||
it('keeps file inspection separate from live preview', () => {
|
||||
const target = previewTarget('/work/demo.html')
|
||||
const preview = previewTarget('/work/live.html')
|
||||
|
||||
setCurrentSessionPreviewTarget(preview, 'tool-result')
|
||||
|
||||
setCurrentSessionPreviewTarget(target, 'manual')
|
||||
|
||||
expect($filePreviewTarget.get()).toEqual(withRenderMode(target, 'source'))
|
||||
expect($previewTarget.get()).toEqual(withRenderMode(preview, 'preview'))
|
||||
expect(getSessionPreviewRecord('session-1')?.normalized).toEqual(withRenderMode(preview, 'preview'))
|
||||
|
||||
dismissFilePreviewTarget()
|
||||
|
||||
expect($filePreviewTarget.get()).toBeNull()
|
||||
expect($previewTarget.get()).toEqual(withRenderMode(preview, 'preview'))
|
||||
})
|
||||
|
||||
it('clears file inspection when a live preview opens', () => {
|
||||
const file = previewTarget('/work/file.html')
|
||||
const live = previewTarget('/work/live.html')
|
||||
|
||||
setCurrentSessionPreviewTarget(file, 'manual')
|
||||
setCurrentSessionPreviewTarget(live, 'tool-result')
|
||||
|
||||
expect($filePreviewTarget.get()).toBeNull()
|
||||
expect($previewTarget.get()).toEqual(withRenderMode(live, 'preview'))
|
||||
})
|
||||
})
|
||||
|
|
@ -1,8 +1,18 @@
|
|||
import { atom } from 'nanostores'
|
||||
import { atom, computed } from 'nanostores'
|
||||
|
||||
import { $activeSessionId, $selectedStoredSessionId } from './session'
|
||||
|
||||
export interface PreviewTarget {
|
||||
binary?: boolean
|
||||
byteSize?: number
|
||||
kind: 'file' | 'url'
|
||||
label: string
|
||||
large?: boolean
|
||||
language?: string
|
||||
mimeType?: string
|
||||
path?: string
|
||||
previewKind?: 'binary' | 'html' | 'image' | 'text'
|
||||
renderMode?: 'preview' | 'source'
|
||||
source: string
|
||||
url: string
|
||||
}
|
||||
|
|
@ -14,9 +24,33 @@ export interface PreviewServerRestart {
|
|||
url: string
|
||||
}
|
||||
|
||||
export type PreviewRecordSource = 'explicit-link' | 'file-browser' | 'manual' | 'tool-result'
|
||||
|
||||
export interface SessionPreviewRecord {
|
||||
autoOpen?: boolean
|
||||
createdAt: number
|
||||
dismissedAt?: number
|
||||
id: string
|
||||
normalized: PreviewTarget
|
||||
sessionId: string
|
||||
source: PreviewRecordSource
|
||||
target: string
|
||||
}
|
||||
|
||||
type SessionPreviewRegistry = Record<string, SessionPreviewRecord[]>
|
||||
|
||||
const REGISTRY_STORAGE_KEY = 'hermes.desktop.sessionPreviews.v1'
|
||||
const MAX_RECORDS_PER_SESSION = 1
|
||||
const MAX_SESSIONS = 120
|
||||
|
||||
export const $previewTarget = atom<PreviewTarget | null>(null)
|
||||
export const $filePreviewTarget = atom<PreviewTarget | null>(null)
|
||||
export const $previewReloadRequest = atom(0)
|
||||
export const $previewServerRestart = atom<PreviewServerRestart | null>(null)
|
||||
export const $previewServerRestartStatus = computed($previewServerRestart, restart => restart?.status ?? 'idle')
|
||||
export const $sessionPreviewRegistry = atom<SessionPreviewRegistry>(loadSessionPreviewRegistry())
|
||||
|
||||
$sessionPreviewRegistry.subscribe(persistSessionPreviewRegistry)
|
||||
|
||||
function isSamePreviewTarget(a: PreviewTarget | null, b: PreviewTarget | null): boolean {
|
||||
if (a === b) {
|
||||
|
|
@ -27,7 +61,7 @@ function isSamePreviewTarget(a: PreviewTarget | null, b: PreviewTarget | null):
|
|||
return false
|
||||
}
|
||||
|
||||
return a.kind === b.kind && a.label === b.label && a.source === b.source && a.url === b.url
|
||||
return a.kind === b.kind && a.label === b.label && a.renderMode === b.renderMode && a.source === b.source && a.url === b.url
|
||||
}
|
||||
|
||||
export function setPreviewTarget(target: PreviewTarget | null) {
|
||||
|
|
@ -38,6 +72,252 @@ export function setPreviewTarget(target: PreviewTarget | null) {
|
|||
$previewTarget.set(target)
|
||||
}
|
||||
|
||||
export function setFilePreviewTarget(target: PreviewTarget | null) {
|
||||
if (isSamePreviewTarget($filePreviewTarget.get(), target)) {
|
||||
return
|
||||
}
|
||||
|
||||
$filePreviewTarget.set(target)
|
||||
}
|
||||
|
||||
function isPreviewTarget(value: unknown): value is PreviewTarget {
|
||||
if (!value || typeof value !== 'object') {return false}
|
||||
const r = value as Record<string, unknown>
|
||||
|
||||
return (
|
||||
(r.kind === 'file' || r.kind === 'url') &&
|
||||
typeof r.label === 'string' &&
|
||||
typeof r.source === 'string' &&
|
||||
typeof r.url === 'string'
|
||||
)
|
||||
}
|
||||
|
||||
function isPreviewRecord(value: unknown): value is SessionPreviewRecord {
|
||||
if (!value || typeof value !== 'object') {return false}
|
||||
const r = value as Record<string, unknown>
|
||||
|
||||
return (
|
||||
typeof r.createdAt === 'number' &&
|
||||
typeof r.id === 'string' &&
|
||||
isPreviewTarget(r.normalized) &&
|
||||
typeof r.sessionId === 'string' &&
|
||||
['explicit-link', 'file-browser', 'manual', 'tool-result'].includes(String(r.source)) &&
|
||||
typeof r.target === 'string' &&
|
||||
(r.dismissedAt === undefined || typeof r.dismissedAt === 'number')
|
||||
)
|
||||
}
|
||||
|
||||
function loadSessionPreviewRegistry(): SessionPreviewRegistry {
|
||||
if (typeof window === 'undefined') {return {}}
|
||||
|
||||
try {
|
||||
const raw = window.localStorage.getItem(REGISTRY_STORAGE_KEY)
|
||||
|
||||
if (!raw) {return {}}
|
||||
const parsed = JSON.parse(raw) as unknown
|
||||
|
||||
if (!parsed || typeof parsed !== 'object') {return {}}
|
||||
const out: SessionPreviewRegistry = {}
|
||||
|
||||
for (const [sessionId, records] of Object.entries(parsed as Record<string, unknown>)) {
|
||||
if (!Array.isArray(records)) {continue}
|
||||
const valid = records.filter(isPreviewRecord).slice(0, MAX_RECORDS_PER_SESSION)
|
||||
|
||||
if (valid.length > 0) {out[sessionId] = valid}
|
||||
}
|
||||
|
||||
return pruneRegistry(out)
|
||||
} catch {
|
||||
return {}
|
||||
}
|
||||
}
|
||||
|
||||
function persistSessionPreviewRegistry(registry: SessionPreviewRegistry) {
|
||||
if (typeof window === 'undefined') {return}
|
||||
|
||||
try {
|
||||
window.localStorage.setItem(REGISTRY_STORAGE_KEY, JSON.stringify(pruneRegistry(registry)))
|
||||
} catch {
|
||||
// Session previews are a desktop convenience; storage failures are nonfatal.
|
||||
}
|
||||
}
|
||||
|
||||
function pruneRegistry(registry: SessionPreviewRegistry): SessionPreviewRegistry {
|
||||
const entries = Object.entries(registry)
|
||||
.map(([sessionId, records]) => [
|
||||
sessionId,
|
||||
[...records].sort((a, b) => b.createdAt - a.createdAt).slice(0, MAX_RECORDS_PER_SESSION)
|
||||
] as const)
|
||||
.filter(([, records]) => records.length > 0)
|
||||
.sort(([, a], [, b]) => (b[0]?.createdAt ?? 0) - (a[0]?.createdAt ?? 0))
|
||||
.slice(0, MAX_SESSIONS)
|
||||
|
||||
return Object.fromEntries(entries)
|
||||
}
|
||||
|
||||
function currentPreviewSessionId(): string {
|
||||
return $selectedStoredSessionId.get() || $activeSessionId.get() || ''
|
||||
}
|
||||
|
||||
function recordId(sessionId: string, target: PreviewTarget): string {
|
||||
return `${sessionId}:${target.url}`
|
||||
}
|
||||
|
||||
export function registerSessionPreview(
|
||||
sessionId: string | null | undefined,
|
||||
target: PreviewTarget,
|
||||
source: PreviewRecordSource,
|
||||
rawTarget = target.source
|
||||
): SessionPreviewRecord | null {
|
||||
const id = sessionId?.trim()
|
||||
|
||||
if (!id) {return null}
|
||||
|
||||
const current = $sessionPreviewRegistry.get()
|
||||
const now = Date.now()
|
||||
const records = current[id] ?? []
|
||||
const existing = records.find(record => record.normalized.url === target.url)
|
||||
const normalized = previewTargetForSource(target, source)
|
||||
|
||||
const nextRecord: SessionPreviewRecord = {
|
||||
autoOpen: true,
|
||||
createdAt: now,
|
||||
id: existing?.id || recordId(id, target),
|
||||
normalized,
|
||||
sessionId: id,
|
||||
source,
|
||||
target: rawTarget || target.source
|
||||
}
|
||||
|
||||
$sessionPreviewRegistry.set(
|
||||
pruneRegistry({
|
||||
...current,
|
||||
[id]: [nextRecord]
|
||||
})
|
||||
)
|
||||
|
||||
return nextRecord
|
||||
}
|
||||
|
||||
function previewTargetForSource(target: PreviewTarget, source: PreviewRecordSource): PreviewTarget {
|
||||
if (target.kind !== 'file' || target.previewKind !== 'html') {
|
||||
return target
|
||||
}
|
||||
|
||||
return {
|
||||
...target,
|
||||
renderMode: source === 'file-browser' || source === 'manual' ? 'source' : 'preview'
|
||||
}
|
||||
}
|
||||
|
||||
function shouldOpenAsFilePreview(target: PreviewTarget, source: PreviewRecordSource): boolean {
|
||||
return target.kind === 'file' && (source === 'file-browser' || source === 'manual')
|
||||
}
|
||||
|
||||
export function registerCurrentSessionPreview(
|
||||
target: PreviewTarget,
|
||||
source: PreviewRecordSource,
|
||||
rawTarget = target.source
|
||||
): SessionPreviewRecord | null {
|
||||
return registerSessionPreview(currentPreviewSessionId(), target, source, rawTarget)
|
||||
}
|
||||
|
||||
export function setSessionPreviewTarget(
|
||||
sessionId: string | null | undefined,
|
||||
target: PreviewTarget,
|
||||
source: PreviewRecordSource,
|
||||
rawTarget = target.source
|
||||
): SessionPreviewRecord | null {
|
||||
if (shouldOpenAsFilePreview(target, source)) {
|
||||
setFilePreviewTarget(previewTargetForSource(target, source))
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
const record = registerSessionPreview(sessionId, target, source, rawTarget)
|
||||
|
||||
setFilePreviewTarget(null)
|
||||
setPreviewTarget(record?.normalized ?? previewTargetForSource(target, source))
|
||||
|
||||
return record
|
||||
}
|
||||
|
||||
export function setCurrentSessionPreviewTarget(
|
||||
target: PreviewTarget,
|
||||
source: PreviewRecordSource,
|
||||
rawTarget = target.source
|
||||
): SessionPreviewRecord | null {
|
||||
if (shouldOpenAsFilePreview(target, source)) {
|
||||
setFilePreviewTarget(previewTargetForSource(target, source))
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
const record = registerCurrentSessionPreview(target, source, rawTarget)
|
||||
|
||||
setFilePreviewTarget(null)
|
||||
setPreviewTarget(record?.normalized ?? previewTargetForSource(target, source))
|
||||
|
||||
return record
|
||||
}
|
||||
|
||||
export function getSessionPreviewRecord(sessionId: string | null | undefined): SessionPreviewRecord | null {
|
||||
const id = sessionId?.trim()
|
||||
|
||||
if (!id) {return null}
|
||||
|
||||
return $sessionPreviewRegistry.get()[id]?.find(record => !record.dismissedAt && record.autoOpen !== false) ?? null
|
||||
}
|
||||
|
||||
export function dismissSessionPreview(sessionId: string | null | undefined, url?: string) {
|
||||
const id = sessionId?.trim()
|
||||
|
||||
if (!id) {return}
|
||||
const current = $sessionPreviewRegistry.get()
|
||||
const records = current[id]
|
||||
|
||||
if (!records?.length) {return}
|
||||
const now = Date.now()
|
||||
const targetUrl = url || records.find(record => !record.dismissedAt)?.normalized.url
|
||||
|
||||
if (!targetUrl) {return}
|
||||
|
||||
// The preview rail is a single active file, not a back stack. Dismissing the
|
||||
// current preview should leave the rail closed instead of revealing an older
|
||||
// record for the same session.
|
||||
const dismissedRecords = records.map(record => ({
|
||||
...record,
|
||||
autoOpen: false,
|
||||
dismissedAt: now
|
||||
}))
|
||||
|
||||
$sessionPreviewRegistry.set({
|
||||
...current,
|
||||
[id]: dismissedRecords
|
||||
})
|
||||
}
|
||||
|
||||
/** User clicked the close X — clear the target and persist dismissal for the current session. */
|
||||
export function dismissPreviewTarget() {
|
||||
const current = $previewTarget.get()
|
||||
|
||||
if (current?.url) {
|
||||
dismissSessionPreview(currentPreviewSessionId(), current.url)
|
||||
}
|
||||
|
||||
$previewTarget.set(null)
|
||||
}
|
||||
|
||||
export function dismissFilePreviewTarget() {
|
||||
setFilePreviewTarget(null)
|
||||
}
|
||||
|
||||
export function clearSessionPreviewRegistry() {
|
||||
$sessionPreviewRegistry.set({})
|
||||
setPreviewTarget(null)
|
||||
setFilePreviewTarget(null)
|
||||
}
|
||||
|
||||
export function requestPreviewReload() {
|
||||
$previewReloadRequest.set($previewReloadRequest.get() + 1)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,12 +4,60 @@ import { persistBoolean, storedBoolean } from '@/lib/storage'
|
|||
|
||||
export type ToolViewMode = 'product' | 'technical'
|
||||
|
||||
type ToolDisclosureStates = Record<string, boolean>
|
||||
|
||||
const TOOL_VIEW_TECHNICAL_STORAGE_KEY = 'hermes.desktop.toolView.technical'
|
||||
const TOOL_DISCLOSURE_STORAGE_KEY = 'hermes.desktop.toolDisclosure.v1'
|
||||
const MAX_DISCLOSURE_STATES = 240
|
||||
|
||||
export const $toolViewMode = atom<ToolViewMode>(storedBoolean(TOOL_VIEW_TECHNICAL_STORAGE_KEY, false) ? 'technical' : 'product')
|
||||
export const $toolDisclosureStates = atom<ToolDisclosureStates>(loadToolDisclosureStates())
|
||||
|
||||
$toolViewMode.subscribe(mode => persistBoolean(TOOL_VIEW_TECHNICAL_STORAGE_KEY, mode === 'technical'))
|
||||
$toolDisclosureStates.subscribe(persistToolDisclosureStates)
|
||||
|
||||
export function setToolViewMode(mode: ToolViewMode) {
|
||||
$toolViewMode.set(mode)
|
||||
}
|
||||
|
||||
function loadToolDisclosureStates(): ToolDisclosureStates {
|
||||
if (typeof window === 'undefined') {return {}}
|
||||
|
||||
try {
|
||||
const raw = window.localStorage.getItem(TOOL_DISCLOSURE_STORAGE_KEY)
|
||||
|
||||
if (!raw) {return {}}
|
||||
const parsed = JSON.parse(raw) as unknown
|
||||
|
||||
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {return {}}
|
||||
|
||||
return Object.fromEntries(
|
||||
Object.entries(parsed as Record<string, unknown>)
|
||||
.filter((entry): entry is [string, boolean] => typeof entry[0] === 'string' && typeof entry[1] === 'boolean')
|
||||
.slice(-MAX_DISCLOSURE_STATES)
|
||||
)
|
||||
} catch {
|
||||
return {}
|
||||
}
|
||||
}
|
||||
|
||||
function persistToolDisclosureStates(states: ToolDisclosureStates) {
|
||||
if (typeof window === 'undefined') {return}
|
||||
|
||||
try {
|
||||
const entries = Object.entries(states).slice(-MAX_DISCLOSURE_STATES)
|
||||
|
||||
window.localStorage.setItem(TOOL_DISCLOSURE_STORAGE_KEY, JSON.stringify(Object.fromEntries(entries)))
|
||||
} catch {
|
||||
// Tool disclosure is a local UI preference; ignore storage failures.
|
||||
}
|
||||
}
|
||||
|
||||
export function setToolDisclosureOpen(id: string, open: boolean) {
|
||||
if (!id) {return}
|
||||
const current = $toolDisclosureStates.get()
|
||||
|
||||
if (current[id] === open) {return}
|
||||
|
||||
$toolDisclosureStates.set({ ...current, [id]: open })
|
||||
}
|
||||
|
|
|
|||
|
|
@ -83,7 +83,7 @@
|
|||
:root {
|
||||
color-scheme: dark;
|
||||
|
||||
/* Fallback values (Claude Light theme) — ThemeProvider overwrites on mount. */
|
||||
/* Default visual tokens. ThemeProvider only overrides colors and font families. */
|
||||
--dt-background: #f7f7f7;
|
||||
--dt-foreground: #242424;
|
||||
--dt-card: #ffffff;
|
||||
|
|
@ -110,7 +110,7 @@
|
|||
|
||||
--dt-font-sans: -apple-system, BlinkMacSystemFont, 'SF Pro Text', 'Segoe UI', system-ui, sans-serif;
|
||||
--dt-font-mono: 'SF Mono', ui-monospace, 'Cascadia Code', Menlo, Consolas, monospace;
|
||||
--dt-base-size: 0.9375rem;
|
||||
--dt-base-size: 0.875rem;
|
||||
--dt-line-height: 1.55;
|
||||
--dt-letter-spacing: 0;
|
||||
--dt-spacing-mul: 1;
|
||||
|
|
@ -175,7 +175,7 @@
|
|||
}
|
||||
|
||||
html {
|
||||
font-size: var(--dt-base-size, 0.9375rem);
|
||||
font-size: var(--dt-base-size, 0.875rem);
|
||||
}
|
||||
|
||||
body {
|
||||
|
|
@ -282,7 +282,7 @@ canvas {
|
|||
* react-virtuoso) with stable item sizing — not a CSS heuristic.
|
||||
*/
|
||||
|
||||
.aui-md img {
|
||||
[data-slot='aui_assistant-message-content'] .aui-md img {
|
||||
display: block;
|
||||
width: auto;
|
||||
height: auto;
|
||||
|
|
@ -296,11 +296,11 @@ canvas {
|
|||
0 0.625rem 1.5rem color-mix(in srgb, #000 5%, transparent);
|
||||
}
|
||||
|
||||
.aui-md [data-slot='aui_markdown-image'] {
|
||||
[data-slot='aui_assistant-message-content'] .aui-md [data-slot='aui_markdown-image'] {
|
||||
max-width: min(100%, var(--image-preview-max-width));
|
||||
}
|
||||
|
||||
.aui-md {
|
||||
[data-slot='aui_assistant-message-content'] .aui-md {
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
|
|
@ -324,17 +324,62 @@ canvas {
|
|||
scroll-behavior: auto;
|
||||
}
|
||||
|
||||
.aui-md a,
|
||||
.aui-md code {
|
||||
[data-slot='aui_assistant-message-content'] .aui-md a {
|
||||
overflow-wrap: anywhere;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.aui-md p:has(> img:only-child) {
|
||||
/**
|
||||
* Inline `code`: monospace pill with a subtle background. Scoped to plain
|
||||
* inline code only — fenced blocks are handled by the SyntaxHighlighter
|
||||
* component and live inside `[data-streamdown='code-block']`, so we explicitly
|
||||
* unset there to keep the highlighter rendering its own chrome.
|
||||
*/
|
||||
[data-slot='aui_assistant-message-content'] .aui-md code {
|
||||
/* Inline (not inside a fenced code-block). Use plain inline rendering so
|
||||
* the surrounding `<p>` (wrap-anywhere) can break long paths/URLs at any
|
||||
* character. inline-block + white-space: nowrap was leaking long paths
|
||||
* past the chat column under any sibling pane width. */
|
||||
display: inline;
|
||||
max-width: 100%;
|
||||
font-family: ui-monospace, 'SF Mono', 'Cascadia Code', Menlo, Consolas, monospace;
|
||||
font-size: 0.86em;
|
||||
padding: 0.01rem 0.16rem;
|
||||
border-radius: 0.2rem;
|
||||
background: #f4f4f5;
|
||||
color: #be185d;
|
||||
border: 0;
|
||||
overflow-wrap: anywhere;
|
||||
word-break: break-word;
|
||||
white-space: normal;
|
||||
}
|
||||
|
||||
:root.dark [data-slot='aui_assistant-message-content'] .aui-md code {
|
||||
background: #27272a;
|
||||
color: #f9a8d4;
|
||||
border-color: #831843;
|
||||
}
|
||||
|
||||
[data-slot='aui_assistant-message-content'] .aui-md [data-streamdown='code-block'] code {
|
||||
max-width: none;
|
||||
font-family: inherit;
|
||||
font-size: inherit;
|
||||
padding: 0;
|
||||
border-radius: 0;
|
||||
background: transparent;
|
||||
color: inherit;
|
||||
overflow-x: visible;
|
||||
overflow-wrap: inherit;
|
||||
vertical-align: baseline;
|
||||
word-break: inherit;
|
||||
white-space: inherit;
|
||||
}
|
||||
|
||||
[data-slot='aui_assistant-message-content'] .aui-md p:has(> img:only-child) {
|
||||
margin-block: 0.75rem;
|
||||
}
|
||||
|
||||
.aui-md p:has(> [data-slot='aui_markdown-image']:only-child) {
|
||||
[data-slot='aui_assistant-message-content'] .aui-md p:has(> [data-slot='aui_markdown-image']:only-child) {
|
||||
margin-block: 0.75rem;
|
||||
}
|
||||
|
||||
|
|
@ -346,14 +391,14 @@ canvas {
|
|||
* section breaks. Margin collapse picks the larger neighbor — producing
|
||||
* "more above headings, less below" without per-pair rules.
|
||||
*/
|
||||
.aui-md p,
|
||||
.aui-md ul,
|
||||
.aui-md ol,
|
||||
.aui-md blockquote,
|
||||
.aui-md pre,
|
||||
.aui-md table,
|
||||
.aui-md [data-streamdown='code-block'],
|
||||
.aui-md div:has(> table) {
|
||||
[data-slot='aui_assistant-message-content'] .aui-md p,
|
||||
[data-slot='aui_assistant-message-content'] .aui-md ul,
|
||||
[data-slot='aui_assistant-message-content'] .aui-md ol,
|
||||
[data-slot='aui_assistant-message-content'] .aui-md blockquote,
|
||||
[data-slot='aui_assistant-message-content'] .aui-md pre,
|
||||
[data-slot='aui_assistant-message-content'] .aui-md table,
|
||||
[data-slot='aui_assistant-message-content'] .aui-md [data-streamdown='code-block'],
|
||||
[data-slot='aui_assistant-message-content'] .aui-md div:has(> table) {
|
||||
margin: 0 0 1rem;
|
||||
}
|
||||
|
||||
|
|
@ -361,8 +406,7 @@ canvas {
|
|||
* with `flex flex-col gap-2 p-2 border bg-sidebar rounded-xl my-4`. Our own
|
||||
* CodeHeader + SyntaxHighlighter already supply the chrome, so undo the
|
||||
* library's wrapper to keep the header flush with the code body. */
|
||||
.aui-md [data-streamdown='code-block'],
|
||||
[data-streamdown='code-block'] {
|
||||
[data-slot='aui_assistant-message-content'] .aui-md [data-streamdown='code-block'] {
|
||||
padding: 0 !important;
|
||||
gap: 0 !important;
|
||||
border: 0 !important;
|
||||
|
|
@ -371,67 +415,106 @@ canvas {
|
|||
margin: 1rem 0 !important;
|
||||
}
|
||||
|
||||
.aui-md [data-streamdown='code-block'] > *,
|
||||
[data-streamdown='code-block'] > * {
|
||||
[data-slot='aui_assistant-message-content'] .aui-md [data-streamdown='code-block'] > * {
|
||||
margin: 0 !important;
|
||||
}
|
||||
|
||||
.aui-md h1 {
|
||||
[data-slot='aui_assistant-message-content'] .aui-md h1 {
|
||||
margin: 1.6rem 0 0.55rem;
|
||||
}
|
||||
.aui-md h2 {
|
||||
[data-slot='aui_assistant-message-content'] .aui-md h2 {
|
||||
margin: 1.4rem 0 0.5rem;
|
||||
}
|
||||
.aui-md h3 {
|
||||
[data-slot='aui_assistant-message-content'] .aui-md h3 {
|
||||
margin: 1.15rem 0 0.45rem;
|
||||
}
|
||||
.aui-md h4 {
|
||||
[data-slot='aui_assistant-message-content'] .aui-md h4 {
|
||||
margin: 0.95rem 0 0.4rem;
|
||||
}
|
||||
.aui-md hr {
|
||||
|
||||
[data-slot='aui_assistant-message-content'] .aui-md hr {
|
||||
margin: 1.5rem 0;
|
||||
}
|
||||
|
||||
/* `padding-left` keeps outside-position list markers in the gutter. */
|
||||
.aui-md ul,
|
||||
.aui-md ol {
|
||||
[data-slot='aui_assistant-message-content'] .aui-md ul,
|
||||
[data-slot='aui_assistant-message-content'] .aui-md ol {
|
||||
padding-left: 1.75rem;
|
||||
}
|
||||
|
||||
/* Tight inter-bullet gap; loose items override below. */
|
||||
.aui-md li + li {
|
||||
[data-slot='aui_assistant-message-content'] .aui-md li + li {
|
||||
margin-top: 0.375rem;
|
||||
}
|
||||
|
||||
/* Inside a bullet, hug nested blocks to the lead text. */
|
||||
.aui-md li > p {
|
||||
[data-slot='aui_assistant-message-content'] .aui-md li > p {
|
||||
margin-bottom: 0.4rem;
|
||||
}
|
||||
.aui-md li > ul,
|
||||
.aui-md li > ol {
|
||||
[data-slot='aui_assistant-message-content'] .aui-md li > ul,
|
||||
[data-slot='aui_assistant-message-content'] .aui-md li > ol {
|
||||
margin-top: 0.4rem;
|
||||
}
|
||||
|
||||
/* Loose list items (CommonMark wraps each in <p> when any sibling has a
|
||||
block child) need visible separation — the tight rhythm collapses
|
||||
against a trailing heavy block like a code fence. */
|
||||
.aui-md li:has(> p) {
|
||||
[data-slot='aui_assistant-message-content'] .aui-md li:has(> p) {
|
||||
margin-bottom: 0.85rem;
|
||||
}
|
||||
.aui-md li:has(> p):last-child {
|
||||
[data-slot='aui_assistant-message-content'] .aui-md li:has(> p):last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
/* Trim edge margins at the container, list items, and blockquotes. */
|
||||
.aui-md > :first-child,
|
||||
.aui-md > * > :first-child,
|
||||
.aui-md li > :first-child,
|
||||
.aui-md blockquote > :first-child {
|
||||
[data-slot='aui_assistant-message-content'] .aui-md > :first-child,
|
||||
[data-slot='aui_assistant-message-content'] .aui-md > * > :first-child,
|
||||
[data-slot='aui_assistant-message-content'] .aui-md li > :first-child,
|
||||
[data-slot='aui_assistant-message-content'] .aui-md blockquote > :first-child {
|
||||
margin-top: 0;
|
||||
}
|
||||
.aui-md > :last-child,
|
||||
.aui-md > * > :last-child,
|
||||
.aui-md li > :last-child,
|
||||
.aui-md blockquote > :last-child {
|
||||
[data-slot='aui_assistant-message-content'] .aui-md > :last-child,
|
||||
[data-slot='aui_assistant-message-content'] .aui-md > * > :last-child,
|
||||
[data-slot='aui_assistant-message-content'] .aui-md li > :last-child,
|
||||
[data-slot='aui_assistant-message-content'] .aui-md blockquote > :last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Intelligent spacing around inline tool/thinking blocks inside an
|
||||
* assistant message.
|
||||
*
|
||||
* Two cases to balance:
|
||||
* 1. Two tool/thinking rows next to each other → keep them tight so a
|
||||
* multi-step turn reads as one continuous activity column.
|
||||
* 2. A tool/thinking row next to prose (markdown text) → give it real
|
||||
* breathing room so the activity feels like a separate panel and
|
||||
* doesn't crowd the paragraph above or below.
|
||||
*
|
||||
* Markdown text is wrapped in `.aui-md`; tool/thinking rows expose
|
||||
* `data-slot="tool-block"`. Sibling combinators handle the rest.
|
||||
*/
|
||||
[data-slot='tool-block'] + [data-slot='tool-block'] {
|
||||
margin-top: 0.25rem;
|
||||
}
|
||||
|
||||
/**
|
||||
* When the previous tool/thinking block is expanded (showing its content),
|
||||
* give the next block visible breathing room so they don't read as one
|
||||
* continuous chunk. The :has() guard keeps the tight rhythm between two
|
||||
* collapsed rows.
|
||||
*/
|
||||
[data-slot='tool-block']:has(> :nth-child(2)) + [data-slot='tool-block'] {
|
||||
margin-top: 0.625rem;
|
||||
}
|
||||
|
||||
[data-slot='aui_assistant-message-content'] [data-slot='tool-block'] + .aui-md,
|
||||
[data-slot='aui_assistant-message-content'] .aui-md + [data-slot='tool-block'] {
|
||||
margin-top: 0.875rem;
|
||||
}
|
||||
|
||||
/* When the assistant message starts with a tool block, don't pile padding
|
||||
* above it — the message header already provides spacing. */
|
||||
[data-slot='aui_assistant-message-content'] > [data-slot='tool-block']:first-child {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,8 +2,8 @@
|
|||
* Desktop theme context.
|
||||
*
|
||||
* Applies the active theme as CSS custom properties on :root, making every
|
||||
* Tailwind utility that references a `--color-*` / `--radius` / `--font-*`
|
||||
* variable pick up the change automatically.
|
||||
* Tailwind utility that references a color or font-family token pick up the
|
||||
* change automatically.
|
||||
*
|
||||
* Persists mode (light/dark/system) and skin separately. Mode controls
|
||||
* brightness; skin controls accent family.
|
||||
|
|
@ -16,13 +16,12 @@ import { matchesQuery, useMediaQuery } from '@/hooks/use-media-query'
|
|||
import {
|
||||
BUILTIN_THEME_LIST,
|
||||
BUILTIN_THEMES,
|
||||
DEFAULT_LAYOUT,
|
||||
DEFAULT_TYPOGRAPHY,
|
||||
defaultTheme,
|
||||
nousLightTheme,
|
||||
nousTheme
|
||||
} from './presets'
|
||||
import type { DesktopTheme, DesktopThemeColors, ThemeDensity } from './types'
|
||||
import type { DesktopTheme, DesktopThemeColors } from './types'
|
||||
|
||||
const STORAGE_KEY = 'hermes-desktop-theme-v2' // Stores skin name.
|
||||
const MODE_KEY = 'hermes-desktop-mode-v1'
|
||||
|
|
@ -30,12 +29,6 @@ const DEFAULT_SKIN = 'default'
|
|||
|
||||
export type ThemeMode = 'light' | 'dark' | 'system'
|
||||
|
||||
const DENSITY_MULTIPLIERS: Record<ThemeDensity, string> = {
|
||||
compact: '0.85',
|
||||
comfortable: '1',
|
||||
spacious: '1.2'
|
||||
}
|
||||
|
||||
const INJECTED_FONT_URLS = new Set<string>()
|
||||
const SKIN_THEME_LIST = BUILTIN_THEME_LIST.filter(t => t.name !== 'nous-light')
|
||||
|
||||
|
|
@ -180,8 +173,7 @@ function deriveTheme(skinName: string, mode: 'light' | 'dark'): DesktopTheme {
|
|||
label: `${isDefault ? 'Hermes' : seed.label} ${mode === 'light' ? 'Light' : 'Dark'}`,
|
||||
description: `${seed.label} ${mode} palette`,
|
||||
colors: mode === 'light' ? lightColors(seed, skinName) : darkColors(seed, skinName),
|
||||
typography: fontOnly(seed),
|
||||
layout: undefined
|
||||
typography: fontOnly(seed)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -220,7 +212,6 @@ function applyTheme(theme: DesktopTheme, mode: 'light' | 'dark') {
|
|||
|
||||
const root = document.documentElement
|
||||
const typo = { ...DEFAULT_TYPOGRAPHY, ...NOUS_FONT_FAMILY_FALLBACK, ...theme.typography }
|
||||
const layout = { ...DEFAULT_LAYOUT, ...theme.layout }
|
||||
const c = theme.colors
|
||||
|
||||
const rendered = renderedModeFor(theme.colors, mode)
|
||||
|
|
@ -253,21 +244,14 @@ function applyTheme(theme: DesktopTheme, mode: 'light' | 'dark') {
|
|||
'--dt-sidebar-border': c.sidebarBorder ?? c.border,
|
||||
'--dt-user-bubble': c.userBubble ?? c.muted,
|
||||
'--dt-user-bubble-border': c.userBubbleBorder ?? c.border,
|
||||
'--radius': layout.radius,
|
||||
'--dt-spacing-mul': DENSITY_MULTIPLIERS[layout.density] ?? '1',
|
||||
'--dt-font-sans': typo.fontSans,
|
||||
'--dt-font-mono': typo.fontMono,
|
||||
'--dt-base-size': typo.baseSize,
|
||||
'--dt-line-height': typo.lineHeight,
|
||||
'--dt-letter-spacing': typo.letterSpacing
|
||||
'--dt-font-mono': typo.fontMono
|
||||
}
|
||||
|
||||
for (const [k, v] of Object.entries(vars)) {
|
||||
root.style.setProperty(k, v)
|
||||
}
|
||||
|
||||
root.style.setProperty('font-size', 'var(--dt-base-size)')
|
||||
|
||||
if (typo.fontUrl && !INJECTED_FONT_URLS.has(typo.fontUrl)) {
|
||||
const link = document.createElement('link')
|
||||
link.rel = 'stylesheet'
|
||||
|
|
|
|||
|
|
@ -1,3 +1,3 @@
|
|||
export { ThemeProvider, useSyncThemeFromBackend, useTheme } from './context'
|
||||
export { BUILTIN_THEME_LIST, BUILTIN_THEMES } from './presets'
|
||||
export type { DesktopTheme, DesktopThemeColors, DesktopThemeLayout, DesktopThemeTypography } from './types'
|
||||
export type { DesktopTheme, DesktopThemeColors, DesktopThemeTypography } from './types'
|
||||
|
|
|
|||
|
|
@ -7,7 +7,7 @@
|
|||
* Add new themes here — no code changes needed elsewhere.
|
||||
*/
|
||||
|
||||
import type { DesktopTheme, DesktopThemeLayout, DesktopThemeTypography } from './types'
|
||||
import type { DesktopTheme, DesktopThemeTypography } from './types'
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Shared defaults
|
||||
|
|
@ -21,15 +21,7 @@ const SYSTEM_MONO =
|
|||
|
||||
export const DEFAULT_TYPOGRAPHY: DesktopThemeTypography = {
|
||||
fontSans: SYSTEM_SANS,
|
||||
fontMono: SYSTEM_MONO,
|
||||
baseSize: '0.9375rem',
|
||||
lineHeight: '1.55',
|
||||
letterSpacing: '0'
|
||||
}
|
||||
|
||||
export const DEFAULT_LAYOUT: DesktopThemeLayout = {
|
||||
radius: '0.75rem',
|
||||
density: 'comfortable'
|
||||
fontMono: SYSTEM_MONO
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
|
@ -122,9 +114,6 @@ export const nousTheme: DesktopTheme = {
|
|||
fontSans: SYSTEM_SANS,
|
||||
fontMono: `"Courier Prime", ${SYSTEM_MONO}`,
|
||||
fontUrl: 'https://fonts.googleapis.com/css2?family=Courier+Prime:wght@400;700&display=swap'
|
||||
},
|
||||
layout: {
|
||||
radius: '0.25rem'
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -192,11 +181,7 @@ export const midnightTheme: DesktopTheme = {
|
|||
},
|
||||
typography: {
|
||||
fontMono: `"JetBrains Mono", ${SYSTEM_MONO}`,
|
||||
fontUrl: 'https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;500;700&display=swap',
|
||||
letterSpacing: '-0.005em'
|
||||
},
|
||||
layout: {
|
||||
radius: '0.875rem'
|
||||
fontUrl: 'https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;500;700&display=swap'
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -233,9 +218,6 @@ export const emberTheme: DesktopTheme = {
|
|||
typography: {
|
||||
fontMono: `"IBM Plex Mono", ${SYSTEM_MONO}`,
|
||||
fontUrl: 'https://fonts.googleapis.com/css2?family=IBM+Plex+Mono:wght@400;500;700&display=swap'
|
||||
},
|
||||
layout: {
|
||||
radius: '0.375rem'
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -268,9 +250,6 @@ export const monoTheme: DesktopTheme = {
|
|||
sidebarBorder: '#202020',
|
||||
userBubble: '#1a1a1a',
|
||||
userBubbleBorder: '#363636'
|
||||
},
|
||||
layout: {
|
||||
radius: '0.375rem'
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -306,11 +285,7 @@ export const cyberpunkTheme: DesktopTheme = {
|
|||
},
|
||||
typography: {
|
||||
fontMono: `"Courier New", Courier, monospace`,
|
||||
fontSans: `"Courier New", Courier, monospace`,
|
||||
letterSpacing: '0.02em'
|
||||
},
|
||||
layout: {
|
||||
radius: '0'
|
||||
fontSans: `"Courier New", Courier, monospace`
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,10 +1,12 @@
|
|||
/**
|
||||
* Desktop app theme model.
|
||||
*
|
||||
* Three orthogonal layers:
|
||||
* 1. `colors` — all Tailwind token values written directly to CSS vars.
|
||||
* 2. `typography` — font families, base size, line-height, letter-spacing.
|
||||
* 3. `layout` — corner radius, spacing density.
|
||||
* Two theme layers:
|
||||
* 1. `colors` — Tailwind color token values written directly to CSS vars.
|
||||
* 2. `typography` — font families and optional font stylesheet URL.
|
||||
*
|
||||
* Layout, sizing, spacing, radius, line-height, and letter-spacing live in
|
||||
* `styles.css` so CSS remains the source of truth for app geometry.
|
||||
*
|
||||
* Every field except `name`, `label`, and `description` is optional —
|
||||
* missing values fall back to the `default` theme.
|
||||
|
|
@ -66,21 +68,6 @@ export interface DesktopThemeTypography {
|
|||
fontMono: string
|
||||
/** Optional Google/Bunny/self-hosted font stylesheet URL. */
|
||||
fontUrl?: string
|
||||
/** Root font size: `"0.875rem"`, `"0.9375rem"`, `"1rem"`. */
|
||||
baseSize: string
|
||||
/** Default line height: `"1.5"`, `"1.6"`. */
|
||||
lineHeight: string
|
||||
/** Default letter spacing: `"0"`, `"-0.01em"`. */
|
||||
letterSpacing: string
|
||||
}
|
||||
|
||||
export type ThemeDensity = 'compact' | 'comfortable' | 'spacious'
|
||||
|
||||
export interface DesktopThemeLayout {
|
||||
/** Corner-radius token: `"0"`, `"0.5rem"`, `"1rem"`. */
|
||||
radius: string
|
||||
/** Spacing multiplier. */
|
||||
density: ThemeDensity
|
||||
}
|
||||
|
||||
export interface DesktopTheme {
|
||||
|
|
@ -89,5 +76,4 @@ export interface DesktopTheme {
|
|||
description: string
|
||||
colors: DesktopThemeColors
|
||||
typography?: Partial<DesktopThemeTypography>
|
||||
layout?: Partial<DesktopThemeLayout>
|
||||
}
|
||||
|
|
|
|||
46
apps/desktop/src/themes/use-skin-command.ts
Normal file
46
apps/desktop/src/themes/use-skin-command.ts
Normal file
|
|
@ -0,0 +1,46 @@
|
|||
import { useCallback } from 'react'
|
||||
|
||||
import { useTheme } from './context'
|
||||
|
||||
const ALIASES: Record<string, string> = {
|
||||
ares: 'ember',
|
||||
hermes: 'default'
|
||||
}
|
||||
|
||||
export function useSkinCommand() {
|
||||
const { availableThemes, setTheme, themeName } = useTheme()
|
||||
|
||||
return useCallback(
|
||||
(rawArg: string) => {
|
||||
const arg = rawArg.trim()
|
||||
|
||||
if (!availableThemes.length) {return 'No desktop themes are available.'}
|
||||
|
||||
const activeIndex = Math.max(0, availableThemes.findIndex(t => t.name === themeName))
|
||||
|
||||
if (!arg || arg === 'next') {
|
||||
const next = availableThemes[(activeIndex + 1) % availableThemes.length]
|
||||
setTheme(next.name)
|
||||
|
||||
return `Desktop theme switched to ${next.label}.`
|
||||
}
|
||||
|
||||
if (arg === 'list' || arg === 'ls' || arg === 'status') {
|
||||
const rows = availableThemes.map(t => `${t.name === themeName ? '*' : ' '} ${t.name.padEnd(10)} ${t.label}`)
|
||||
|
||||
return ['Desktop themes:', ...rows, '', 'Use /skin <name>, or /skin to cycle.'].join('\n')
|
||||
}
|
||||
|
||||
const normalized = arg.toLowerCase()
|
||||
const targetName = ALIASES[normalized] || normalized
|
||||
const target = availableThemes.find(t => t.name.toLowerCase() === targetName || t.label.toLowerCase() === normalized)
|
||||
|
||||
if (!target) {return `Unknown desktop theme: ${arg}\nAvailable: ${availableThemes.map(t => t.name).join(', ')}`}
|
||||
|
||||
setTheme(target.name)
|
||||
|
||||
return `Desktop theme switched to ${target.label}.`
|
||||
},
|
||||
[availableThemes, setTheme, themeName]
|
||||
)
|
||||
}
|
||||
|
|
@ -2445,6 +2445,28 @@ async def delete_session_endpoint(session_id: str):
|
|||
db.close()
|
||||
|
||||
|
||||
class SessionRename(BaseModel):
|
||||
title: str
|
||||
|
||||
|
||||
@app.patch("/api/sessions/{session_id}")
|
||||
async def rename_session_endpoint(session_id: str, body: SessionRename):
|
||||
from hermes_state import SessionDB
|
||||
db = SessionDB()
|
||||
try:
|
||||
sid = db.resolve_session_id(session_id) or session_id
|
||||
try:
|
||||
ok = db.set_session_title(sid, body.title)
|
||||
except ValueError as exc:
|
||||
# Title collision or validation failure (e.g. duplicate title).
|
||||
raise HTTPException(status_code=409, detail=str(exc))
|
||||
if not ok:
|
||||
raise HTTPException(status_code=404, detail="Session not found")
|
||||
return {"ok": True, "title": db.get_session_title(sid) or ""}
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Log viewer endpoint
|
||||
# ---------------------------------------------------------------------------
|
||||
|
|
|
|||
132
package-lock.json
generated
132
package-lock.json
generated
|
|
@ -79,15 +79,18 @@
|
|||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"cmdk": "^1.1.1",
|
||||
"ignore": "^7.0.5",
|
||||
"liquid-glass-react": "^1.1.1",
|
||||
"lucide-react": "^0.577.0",
|
||||
"nanostores": "^1.3.0",
|
||||
"radix-ui": "^1.4.3",
|
||||
"react": "^19.2.5",
|
||||
"react-arborist": "^3.5.0",
|
||||
"react-dom": "^19.2.5",
|
||||
"react-router-dom": "^7.14.2",
|
||||
"react-shiki": "^0.9.3",
|
||||
"shiki": "^4.0.2",
|
||||
"streamdown": "^2.5.0",
|
||||
"tailwind-merge": "^3.5.0",
|
||||
"tailwindcss": "^4.2.4",
|
||||
"tw-shimmer": "^0.4.11",
|
||||
|
|
@ -6451,6 +6454,21 @@
|
|||
"integrity": "sha512-HPwpGIzkl28mWyZqG52jiqDJ12waP11Pa1lGoiyUkIEuMLBP0oeK/C89esbXrxsky5we7dfd8U58nm0SgAWpVw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@react-dnd/asap": {
|
||||
"version": "4.0.1",
|
||||
"resolved": "https://registry.npmjs.org/@react-dnd/asap/-/asap-4.0.1.tgz",
|
||||
"integrity": "sha512-kLy0PJDDwvwwTXxqTFNAAllPHD73AycE9ypWeln/IguoGBEbvFcPDbCV03G52bEcC5E+YgupBE0VzHGdC8SIXg=="
|
||||
},
|
||||
"node_modules/@react-dnd/invariant": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@react-dnd/invariant/-/invariant-2.0.0.tgz",
|
||||
"integrity": "sha512-xL4RCQBCBDJ+GRwKTFhGUW8GXa4yoDfJrPbLblc3U09ciS+9ZJXJ3Qrcs/x2IODOdIE5kQxvMmE2UKyqUictUw=="
|
||||
},
|
||||
"node_modules/@react-dnd/shallowequal": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@react-dnd/shallowequal/-/shallowequal-2.0.0.tgz",
|
||||
"integrity": "sha512-Pc/AFTdwZwEKJxFJvlxrSmGe/di+aAOBn60sremrpLo6VI/6cmiUYNNwlI5KNYttg7uypzA3ILPMPgxB2GYZEg=="
|
||||
},
|
||||
"node_modules/@react-three/fiber": {
|
||||
"version": "9.6.1",
|
||||
"resolved": "https://registry.npmjs.org/@react-three/fiber/-/fiber-9.6.1.tgz",
|
||||
|
|
@ -11352,6 +11370,24 @@
|
|||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/dnd-core": {
|
||||
"version": "14.0.1",
|
||||
"resolved": "https://registry.npmjs.org/dnd-core/-/dnd-core-14.0.1.tgz",
|
||||
"integrity": "sha512-+PVS2VPTgKFPYWo3vAFEA8WPbTf7/xo43TifH9G8S1KqnrQu0o77A3unrF5yOugy4mIz7K5wAVFHUcha7wsz6A==",
|
||||
"dependencies": {
|
||||
"@react-dnd/asap": "^4.0.0",
|
||||
"@react-dnd/invariant": "^2.0.0",
|
||||
"redux": "^4.1.1"
|
||||
}
|
||||
},
|
||||
"node_modules/dnd-core/node_modules/redux": {
|
||||
"version": "4.2.1",
|
||||
"resolved": "https://registry.npmjs.org/redux/-/redux-4.2.1.tgz",
|
||||
"integrity": "sha512-LAUYz4lc+Do8/g7aeRa8JkyDErK6ekstQaqWQrNRW//MY1TvCEpMtpTWvlQ+FPbWCx+Xixu/6SHt5N0HR+SB4w==",
|
||||
"dependencies": {
|
||||
"@babel/runtime": "^7.9.2"
|
||||
}
|
||||
},
|
||||
"node_modules/doctrine": {
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/doctrine/-/doctrine-2.1.0.tgz",
|
||||
|
|
@ -12642,7 +12678,6 @@
|
|||
"version": "3.1.3",
|
||||
"resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
|
||||
"integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/fast-json-stable-stringify": {
|
||||
|
|
@ -13594,6 +13629,19 @@
|
|||
"hermes-estree": "0.25.1"
|
||||
}
|
||||
},
|
||||
"node_modules/hoist-non-react-statics": {
|
||||
"version": "3.3.2",
|
||||
"resolved": "https://registry.npmjs.org/hoist-non-react-statics/-/hoist-non-react-statics-3.3.2.tgz",
|
||||
"integrity": "sha512-/gGivxi8JPKWNm/W0jSmzcMPpfpPLc3dY/6GxhX2hQ9iGj3aDfklV4ET7NjKpSinLpJ5vafa9iiGIEZg10SfBw==",
|
||||
"dependencies": {
|
||||
"react-is": "^16.7.0"
|
||||
}
|
||||
},
|
||||
"node_modules/hoist-non-react-statics/node_modules/react-is": {
|
||||
"version": "16.13.1",
|
||||
"resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz",
|
||||
"integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ=="
|
||||
},
|
||||
"node_modules/hosted-git-info": {
|
||||
"version": "4.1.0",
|
||||
"resolved": "https://registry.npmjs.org/hosted-git-info/-/hosted-git-info-4.1.0.tgz",
|
||||
|
|
@ -13864,8 +13912,6 @@
|
|||
"version": "7.0.5",
|
||||
"resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.5.tgz",
|
||||
"integrity": "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 4"
|
||||
}
|
||||
|
|
@ -15794,6 +15840,11 @@
|
|||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/memoize-one": {
|
||||
"version": "5.2.1",
|
||||
"resolved": "https://registry.npmjs.org/memoize-one/-/memoize-one-5.2.1.tgz",
|
||||
"integrity": "sha512-zYiwtZUcYyXKo/np96AGZAckk+FWWsUdJ3cHGGmld7+AhvcWmQyGCYUh1hc4Q/pkOhb65dQR/pqCyK0cOaHz4Q=="
|
||||
},
|
||||
"node_modules/merge-deep": {
|
||||
"version": "3.0.3",
|
||||
"resolved": "https://registry.npmjs.org/merge-deep/-/merge-deep-3.0.3.tgz",
|
||||
|
|
@ -18197,6 +18248,22 @@
|
|||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/react-arborist": {
|
||||
"version": "3.5.0",
|
||||
"resolved": "https://registry.npmjs.org/react-arborist/-/react-arborist-3.5.0.tgz",
|
||||
"integrity": "sha512-FdXOICSt7P2h+Pxin1ULN02b4qrXJznNcshgwwWVtuYMLWSJcD245PQ4HOSj/Lr2T1uEegmnEm5Lbns2hUUsqg==",
|
||||
"dependencies": {
|
||||
"react-dnd": "^14.0.3",
|
||||
"react-dnd-html5-backend": "^14.0.3",
|
||||
"react-window": "^1.8.11",
|
||||
"redux": "^5.0.0",
|
||||
"use-sync-external-store": "^1.2.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": ">= 16.14",
|
||||
"react-dom": ">= 16.14"
|
||||
}
|
||||
},
|
||||
"node_modules/react-colorful": {
|
||||
"version": "5.6.1",
|
||||
"resolved": "https://registry.npmjs.org/react-colorful/-/react-colorful-5.6.1.tgz",
|
||||
|
|
@ -18207,6 +18274,43 @@
|
|||
"react-dom": ">=16.8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/react-dnd": {
|
||||
"version": "14.0.5",
|
||||
"resolved": "https://registry.npmjs.org/react-dnd/-/react-dnd-14.0.5.tgz",
|
||||
"integrity": "sha512-9i1jSgbyVw0ELlEVt/NkCUkxy1hmhJOkePoCH713u75vzHGyXhPDm28oLfc2NMSBjZRM1Y+wRjHXJT3sPrTy+A==",
|
||||
"dependencies": {
|
||||
"@react-dnd/invariant": "^2.0.0",
|
||||
"@react-dnd/shallowequal": "^2.0.0",
|
||||
"dnd-core": "14.0.1",
|
||||
"fast-deep-equal": "^3.1.3",
|
||||
"hoist-non-react-statics": "^3.3.2"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/hoist-non-react-statics": ">= 3.3.1",
|
||||
"@types/node": ">= 12",
|
||||
"@types/react": ">= 16",
|
||||
"react": ">= 16.14"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/hoist-non-react-statics": {
|
||||
"optional": true
|
||||
},
|
||||
"@types/node": {
|
||||
"optional": true
|
||||
},
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/react-dnd-html5-backend": {
|
||||
"version": "14.1.0",
|
||||
"resolved": "https://registry.npmjs.org/react-dnd-html5-backend/-/react-dnd-html5-backend-14.1.0.tgz",
|
||||
"integrity": "sha512-6ONeqEC3XKVf4eVmMTe0oPds+c5B9Foyj8p/ZKLb7kL2qh9COYxiBHv3szd6gztqi/efkmriywLUVlPotqoJyw==",
|
||||
"dependencies": {
|
||||
"dnd-core": "14.0.1"
|
||||
}
|
||||
},
|
||||
"node_modules/react-dom": {
|
||||
"version": "19.2.5",
|
||||
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.5.tgz",
|
||||
|
|
@ -18433,6 +18537,22 @@
|
|||
}
|
||||
}
|
||||
},
|
||||
"node_modules/react-window": {
|
||||
"version": "1.8.11",
|
||||
"resolved": "https://registry.npmjs.org/react-window/-/react-window-1.8.11.tgz",
|
||||
"integrity": "sha512-+SRbUVT2scadgFSWx+R1P754xHPEqvcfSfVX10QYg6POOz+WNgkN48pS+BtZNIMGiL1HYrSEiCkwsMS15QogEQ==",
|
||||
"dependencies": {
|
||||
"@babel/runtime": "^7.0.0",
|
||||
"memoize-one": ">=3.1.1 <6"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">8.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": "^15.0.0 || ^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
|
||||
"react-dom": "^15.0.0 || ^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/read-binary-file-arch": {
|
||||
"version": "1.0.6",
|
||||
"resolved": "https://registry.npmjs.org/read-binary-file-arch/-/read-binary-file-arch-1.0.6.tgz",
|
||||
|
|
@ -18485,6 +18605,11 @@
|
|||
"node": ">= 6"
|
||||
}
|
||||
},
|
||||
"node_modules/redux": {
|
||||
"version": "5.0.1",
|
||||
"resolved": "https://registry.npmjs.org/redux/-/redux-5.0.1.tgz",
|
||||
"integrity": "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w=="
|
||||
},
|
||||
"node_modules/reflect.getprototypeof": {
|
||||
"version": "1.0.10",
|
||||
"resolved": "https://registry.npmjs.org/reflect.getprototypeof/-/reflect.getprototypeof-1.0.10.tgz",
|
||||
|
|
@ -19690,7 +19815,6 @@
|
|||
"version": "2.5.0",
|
||||
"resolved": "https://registry.npmjs.org/streamdown/-/streamdown-2.5.0.tgz",
|
||||
"integrity": "sha512-/tTnURfIOxZK/pqJAxsfCvETG/XCJHoWnk3jq9xLcuz6CSpnjjuxSRBTTL4PKGhxiZQf0lqPxGhImdpwcZ2XwA==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"clsx": "^2.1.1",
|
||||
"hast-util-to-jsx-runtime": "^2.3.6",
|
||||
|
|
|
|||
|
|
@ -1850,6 +1850,65 @@ def _ephemeral_preview_agent_kwargs(agent, task_id: str) -> dict:
|
|||
return kwargs
|
||||
|
||||
|
||||
def _preview_restart_history(session: dict, max_messages: int = 24, max_tool_chars: int = 1200) -> list[dict]:
|
||||
"""Distill the parent session's recent history into a context the
|
||||
ephemeral preview-restart agent can actually use.
|
||||
|
||||
The restart agent has no idea what app the user was building, what
|
||||
server they ran, what cwd was active, or which port belongs to which
|
||||
project. Without this, it would take the bare URL + console logs and
|
||||
guess — usually starting the wrong thing.
|
||||
|
||||
We keep the last ``max_messages`` messages from the parent session so
|
||||
the restart agent sees recent user prompts, assistant replies, and
|
||||
most importantly any terminal/tool calls. Tool result payloads are
|
||||
truncated so we don't blow the context window with file dumps.
|
||||
"""
|
||||
try:
|
||||
with session["history_lock"]:
|
||||
history = list(session.get("history", []) or [])
|
||||
except Exception:
|
||||
history = list(session.get("history", []) or [])
|
||||
|
||||
if not history:
|
||||
return []
|
||||
|
||||
# Anchor on the last user turn so we always include at least the most
|
||||
# recent request and the assistant/tool work that followed it. Then
|
||||
# extend backwards up to max_messages so we capture the prior context.
|
||||
last_user_idx = None
|
||||
for idx in range(len(history) - 1, -1, -1):
|
||||
if history[idx].get("role") == "user":
|
||||
last_user_idx = idx
|
||||
break
|
||||
|
||||
start = max(0, len(history) - max_messages)
|
||||
if last_user_idx is not None:
|
||||
start = min(start, last_user_idx)
|
||||
|
||||
trimmed: list[dict] = []
|
||||
for msg in history[start:]:
|
||||
if not isinstance(msg, dict):
|
||||
continue
|
||||
role = msg.get("role")
|
||||
if role not in ("user", "assistant", "tool", "system"):
|
||||
continue
|
||||
|
||||
copy = {k: v for k, v in msg.items() if k != "reasoning"}
|
||||
# Truncate heavy tool outputs so a single 50KB file read doesn't
|
||||
# crowd out the rest of the context.
|
||||
if role == "tool":
|
||||
content = copy.get("content")
|
||||
if isinstance(content, str) and len(content) > max_tool_chars:
|
||||
copy["content"] = (
|
||||
content[:max_tool_chars]
|
||||
+ f"\n... (truncated, original {len(content)} chars)"
|
||||
)
|
||||
trimmed.append(copy)
|
||||
|
||||
return trimmed
|
||||
|
||||
|
||||
def _preview_tool_result_preview(name: str, result: str) -> str:
|
||||
try:
|
||||
data = json.loads(result)
|
||||
|
|
@ -3670,6 +3729,8 @@ def _(rid, params: dict) -> dict:
|
|||
|
||||
task_id = f"preview_{uuid.uuid4().hex[:6]}"
|
||||
parent = params.get("session_id", "")
|
||||
parent_history = _preview_restart_history(session)
|
||||
has_history = bool(parent_history)
|
||||
prompt = "\n".join(
|
||||
line
|
||||
for line in [
|
||||
|
|
@ -3680,8 +3741,14 @@ def _(rid, params: dict) -> dict:
|
|||
"",
|
||||
f"Preview console:\n{context}" if context else "",
|
||||
"" if context else "",
|
||||
(
|
||||
"The conversation history above is from the user's main session — including the commands you (the assistant) previously ran to start servers, edit files, or check ports. Use it to figure out exactly which server should be running at this Preview URL. The user did not start a brand new task; recover what they had working."
|
||||
if has_history
|
||||
else None
|
||||
),
|
||||
"Restart exactly the app intended for the Preview URL, not Hermes Desktop itself.",
|
||||
"The Preview URL and port are the target. Preserve that target unless you conclude it is impossible.",
|
||||
"If the prior conversation shows a specific command that bound this URL/port, prefer re-running THAT exact command (in the same cwd) over guessing a new one.",
|
||||
"First inspect what process, if any, owns the Preview URL port. If a stale server exists, inspect its cwd and prefer that cwd over the Hermes/Desktop process cwd.",
|
||||
"The Current working directory is only a hint. Do not assume it is the preview app root when the port owner or files indicate another root.",
|
||||
"If the console shows a module-script MIME error for src/main.tsx or similar, a static server is serving source files. Do not restart python -m http.server or any dumb static server for that app.",
|
||||
|
|
@ -3706,11 +3773,24 @@ def _(rid, params: dict) -> dict:
|
|||
if cwd and os.path.isdir(os.path.abspath(os.path.expanduser(cwd))):
|
||||
register_task_env_overrides(task_id, {"cwd": os.path.abspath(os.path.expanduser(cwd))})
|
||||
|
||||
_emit("preview.restart.progress", parent, {"task_id": task_id, "text": "Starting hidden restart agent"})
|
||||
history_note = (
|
||||
f" (with {len(parent_history)} parent-session messages of context)"
|
||||
if parent_history
|
||||
else ""
|
||||
)
|
||||
_emit(
|
||||
"preview.restart.progress",
|
||||
parent,
|
||||
{"task_id": task_id, "text": f"Starting hidden restart agent{history_note}"},
|
||||
)
|
||||
result = AIAgent(
|
||||
**_ephemeral_preview_agent_kwargs(session["agent"], task_id),
|
||||
**_preview_restart_callbacks(parent, task_id),
|
||||
).run_conversation(user_message=prompt, task_id=task_id)
|
||||
).run_conversation(
|
||||
user_message=prompt,
|
||||
task_id=task_id,
|
||||
conversation_history=parent_history or None,
|
||||
)
|
||||
text = (
|
||||
result.get("final_response", str(result))
|
||||
if isinstance(result, dict)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue