feat(desktop): add structured desktop chat app
Introduce the Electron desktop app with a split app/chat/settings structure and shared nanostore state so UI areas own their state instead of routing it through the root.
23
AGENTS.md
|
|
@ -61,6 +61,29 @@ hermes-agent/
|
|||
`gateway.log` when running the gateway. Profile-aware via `get_hermes_home()`.
|
||||
Browse with `hermes logs [--follow] [--level ...] [--session ...]`.
|
||||
|
||||
## TypeScript Style
|
||||
|
||||
Applies to TypeScript across Hermes: desktop, TUI, website, and future TS packages.
|
||||
|
||||
- Prefer small nanostores over component state when state is shared, reused, or read by distant UI.
|
||||
- Let each feature own its atoms. Chat state belongs near chat, shell state near shell, shared state in `src/store`.
|
||||
- Components that render from an atom should use `useStore`. Non-rendering actions should read with `$atom.get()`.
|
||||
- Do not pass state through three components when the leaf can subscribe to the atom.
|
||||
- Keep persistence beside the atom that owns it.
|
||||
- Keep route roots thin. They compose routes and shell; they should not become controllers.
|
||||
- No monolithic hooks. A hook should own one narrow job.
|
||||
- Prefer colocated action modules over hidden god hooks.
|
||||
- If a callback is pure side effect, use the terse void form:
|
||||
`onState={st => void setGatewayState(st)}`.
|
||||
- Async UI handlers should make intent explicit:
|
||||
`onClick={() => void save()}`.
|
||||
- Prefer interfaces for public props and shared object shapes. Avoid `type X = { ... }` for object props.
|
||||
- Extend React primitives for props: `React.ComponentProps<'button'>`, `React.ComponentProps<typeof Dialog>`, `Omit<...>`, `Pick<...>`.
|
||||
- Table-driven beats condition ladders when mapping ids, routes, or views.
|
||||
- `src/app` owns routes, pages, and page-specific components.
|
||||
- `src/store` owns shared atoms.
|
||||
- `src/lib` owns shared pure helpers.
|
||||
|
||||
## File Dependency Chain
|
||||
|
||||
```
|
||||
|
|
|
|||
11
apps/desktop/.prettierrc
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
{
|
||||
"arrowParens": "avoid",
|
||||
"bracketSpacing": true,
|
||||
"endOfLine": "auto",
|
||||
"printWidth": 120,
|
||||
"semi": false,
|
||||
"singleQuote": true,
|
||||
"tabWidth": 2,
|
||||
"trailingComma": "none",
|
||||
"useTabs": false
|
||||
}
|
||||
21
apps/desktop/components.json
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
{
|
||||
"$schema": "https://ui.shadcn.com/schema.json",
|
||||
"style": "new-york",
|
||||
"rsc": false,
|
||||
"tsx": true,
|
||||
"tailwind": {
|
||||
"config": "",
|
||||
"css": "src/styles.css",
|
||||
"baseColor": "neutral",
|
||||
"cssVariables": true,
|
||||
"prefix": ""
|
||||
},
|
||||
"aliases": {
|
||||
"components": "@/components",
|
||||
"utils": "@/lib/utils",
|
||||
"ui": "@/components/ui",
|
||||
"lib": "@/lib",
|
||||
"hooks": "@/hooks"
|
||||
},
|
||||
"iconLibrary": "lucide"
|
||||
}
|
||||
558
apps/desktop/electron/main.cjs
Normal file
|
|
@ -0,0 +1,558 @@
|
|||
const { app, BrowserWindow, Menu, Notification, clipboard, dialog, ipcMain, nativeImage, shell } = require('electron')
|
||||
const crypto = require('node:crypto')
|
||||
const fs = require('node:fs')
|
||||
const http = require('node:http')
|
||||
const https = require('node:https')
|
||||
const net = require('node:net')
|
||||
const path = require('node:path')
|
||||
const { fileURLToPath, pathToFileURL } = require('node:url')
|
||||
const { spawn } = require('node:child_process')
|
||||
|
||||
const PORT_FLOOR = 9120
|
||||
const PORT_CEILING = 9199
|
||||
const DEV_SERVER = process.env.HERMES_DESKTOP_DEV_SERVER
|
||||
const REPO_ROOT = path.resolve(__dirname, '../../..')
|
||||
const DESKTOP_ROOT = path.resolve(__dirname, '..')
|
||||
const IS_MAC = process.platform === 'darwin'
|
||||
const APP_NAME = 'Hermes'
|
||||
const TITLEBAR_HEIGHT = 34
|
||||
const MACOS_TRAFFIC_LIGHTS_HEIGHT = 14
|
||||
const WINDOW_BUTTON_POSITION = {
|
||||
x: 24,
|
||||
y: TITLEBAR_HEIGHT / 2 - MACOS_TRAFFIC_LIGHTS_HEIGHT / 2
|
||||
}
|
||||
const APP_ICON_PATH = path.join(DESKTOP_ROOT, 'public', 'apple-touch-icon.png')
|
||||
|
||||
app.setName(APP_NAME)
|
||||
|
||||
let mainWindow = null
|
||||
let hermesProcess = null
|
||||
let connectionPromise = null
|
||||
const hermesLog = []
|
||||
|
||||
function rememberLog(chunk) {
|
||||
const text = String(chunk || '').trim()
|
||||
if (!text) return
|
||||
hermesLog.push(...text.split(/\r?\n/).map(line => `[hermes] ${line}`))
|
||||
if (hermesLog.length > 300) {
|
||||
hermesLog.splice(0, hermesLog.length - 300)
|
||||
}
|
||||
}
|
||||
|
||||
function findPython() {
|
||||
const local = [path.join(REPO_ROOT, '.venv', 'bin', 'python'), path.join(REPO_ROOT, 'venv', 'bin', 'python')]
|
||||
return local.find(candidate => fs.existsSync(candidate)) || 'python3'
|
||||
}
|
||||
|
||||
function isPortAvailable(port) {
|
||||
return new Promise(resolve => {
|
||||
const server = net.createServer()
|
||||
server.once('error', () => resolve(false))
|
||||
server.once('listening', () => {
|
||||
server.close(() => resolve(true))
|
||||
})
|
||||
server.listen(port, '127.0.0.1')
|
||||
})
|
||||
}
|
||||
|
||||
async function pickPort() {
|
||||
for (let port = PORT_FLOOR; port <= PORT_CEILING; port += 1) {
|
||||
if (await isPortAvailable(port)) return port
|
||||
}
|
||||
throw new Error(`No free localhost port in ${PORT_FLOOR}-${PORT_CEILING}`)
|
||||
}
|
||||
|
||||
function fetchJson(url, token, options = {}) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const body = options.body === undefined ? undefined : Buffer.from(JSON.stringify(options.body))
|
||||
|
||||
const req = http.request(
|
||||
url,
|
||||
{
|
||||
method: options.method || 'GET',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-Hermes-Session-Token': token,
|
||||
...(body ? { 'Content-Length': String(body.length) } : {})
|
||||
}
|
||||
},
|
||||
res => {
|
||||
const chunks = []
|
||||
res.on('data', chunk => chunks.push(chunk))
|
||||
res.on('end', () => {
|
||||
const text = Buffer.concat(chunks).toString('utf8')
|
||||
if ((res.statusCode || 500) >= 400) {
|
||||
reject(new Error(`${res.statusCode}: ${text || res.statusMessage}`))
|
||||
return
|
||||
}
|
||||
try {
|
||||
resolve(text ? JSON.parse(text) : null)
|
||||
} catch (error) {
|
||||
reject(error)
|
||||
}
|
||||
})
|
||||
}
|
||||
)
|
||||
|
||||
req.on('error', reject)
|
||||
if (body) req.write(body)
|
||||
req.end()
|
||||
})
|
||||
}
|
||||
|
||||
function mimeTypeForPath(filePath) {
|
||||
const ext = path.extname(filePath || '').toLowerCase()
|
||||
if (ext === '.png') return 'image/png'
|
||||
if (ext === '.jpg' || ext === '.jpeg') return 'image/jpeg'
|
||||
if (ext === '.gif') return 'image/gif'
|
||||
if (ext === '.webp') return 'image/webp'
|
||||
if (ext === '.svg') return 'image/svg+xml'
|
||||
if (ext === '.bmp') return 'image/bmp'
|
||||
return 'application/octet-stream'
|
||||
}
|
||||
|
||||
function extensionForMimeType(mimeType) {
|
||||
const type = String(mimeType || '')
|
||||
.split(';')[0]
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
if (type === 'image/png') return '.png'
|
||||
if (type === 'image/jpeg') return '.jpg'
|
||||
if (type === 'image/gif') return '.gif'
|
||||
if (type === 'image/webp') return '.webp'
|
||||
if (type === 'image/bmp') return '.bmp'
|
||||
if (type === 'image/svg+xml') return '.svg'
|
||||
return ''
|
||||
}
|
||||
|
||||
function filenameFromUrl(rawUrl, fallback = 'image') {
|
||||
try {
|
||||
const parsed = new URL(rawUrl)
|
||||
const base = path.basename(decodeURIComponent(parsed.pathname || ''))
|
||||
return base && base.includes('.') ? base : fallback
|
||||
} catch {
|
||||
return fallback
|
||||
}
|
||||
}
|
||||
|
||||
async function resourceBufferFromUrl(rawUrl) {
|
||||
if (!rawUrl) throw new Error('Missing URL')
|
||||
if (rawUrl.startsWith('data:')) {
|
||||
const match = rawUrl.match(/^data:([^;,]+)?(;base64)?,(.*)$/s)
|
||||
if (!match) throw new Error('Invalid data URL')
|
||||
const mimeType = match[1] || 'application/octet-stream'
|
||||
const encoded = match[3] || ''
|
||||
const buffer = match[2] ? Buffer.from(encoded, 'base64') : Buffer.from(decodeURIComponent(encoded), 'utf8')
|
||||
return { buffer, mimeType }
|
||||
}
|
||||
if (rawUrl.startsWith('file:')) {
|
||||
const filePath = fileURLToPath(rawUrl)
|
||||
const buffer = await fs.promises.readFile(filePath)
|
||||
return { buffer, mimeType: mimeTypeForPath(filePath) }
|
||||
}
|
||||
|
||||
const parsed = new URL(rawUrl)
|
||||
const client = parsed.protocol === 'https:' ? https : http
|
||||
return new Promise((resolve, reject) => {
|
||||
const req = client.get(parsed, res => {
|
||||
if ((res.statusCode || 500) >= 400) {
|
||||
reject(new Error(`Failed to fetch ${rawUrl}: ${res.statusCode}`))
|
||||
res.resume()
|
||||
return
|
||||
}
|
||||
const chunks = []
|
||||
res.on('data', chunk => chunks.push(chunk))
|
||||
res.on('end', () => {
|
||||
resolve({
|
||||
buffer: Buffer.concat(chunks),
|
||||
mimeType: res.headers['content-type'] || 'application/octet-stream'
|
||||
})
|
||||
})
|
||||
})
|
||||
req.on('error', reject)
|
||||
})
|
||||
}
|
||||
|
||||
async function copyImageFromUrl(rawUrl) {
|
||||
const { buffer } = await resourceBufferFromUrl(rawUrl)
|
||||
const image = nativeImage.createFromBuffer(buffer)
|
||||
if (image.isEmpty()) throw new Error('Could not read image')
|
||||
clipboard.writeImage(image)
|
||||
}
|
||||
|
||||
async function saveImageFromUrl(rawUrl) {
|
||||
const { buffer, mimeType } = await resourceBufferFromUrl(rawUrl)
|
||||
const fallbackName = filenameFromUrl(rawUrl, `image${extensionForMimeType(mimeType) || '.png'}`)
|
||||
const result = await dialog.showSaveDialog(mainWindow, {
|
||||
title: 'Save Image',
|
||||
defaultPath: fallbackName
|
||||
})
|
||||
if (result.canceled || !result.filePath) return false
|
||||
await fs.promises.writeFile(result.filePath, buffer)
|
||||
return true
|
||||
}
|
||||
|
||||
async function waitForHermes(baseUrl, token) {
|
||||
const deadline = Date.now() + 45_000
|
||||
let lastError = null
|
||||
|
||||
while (Date.now() < deadline) {
|
||||
try {
|
||||
await fetchJson(`${baseUrl}/api/status`, token)
|
||||
return
|
||||
} catch (error) {
|
||||
lastError = error
|
||||
await new Promise(resolve => setTimeout(resolve, 500))
|
||||
}
|
||||
}
|
||||
|
||||
throw new Error(`Hermes dashboard did not become ready: ${lastError?.message || 'timeout'}`)
|
||||
}
|
||||
|
||||
function getWindowButtonPosition() {
|
||||
if (!IS_MAC) return null
|
||||
return mainWindow?.getWindowButtonPosition?.() || WINDOW_BUTTON_POSITION
|
||||
}
|
||||
|
||||
function getAppIconPath() {
|
||||
return fs.existsSync(APP_ICON_PATH) ? APP_ICON_PATH : undefined
|
||||
}
|
||||
|
||||
function resolveHermesCwd() {
|
||||
const candidates = [process.env.HERMES_DESKTOP_CWD, process.env.INIT_CWD, process.cwd(), REPO_ROOT]
|
||||
for (const candidate of candidates) {
|
||||
if (!candidate) continue
|
||||
const resolved = path.resolve(String(candidate))
|
||||
try {
|
||||
if (fs.statSync(resolved).isDirectory()) return resolved
|
||||
} catch {
|
||||
// Try the next candidate.
|
||||
}
|
||||
}
|
||||
return REPO_ROOT
|
||||
}
|
||||
|
||||
function buildApplicationMenu() {
|
||||
const template = []
|
||||
if (IS_MAC) {
|
||||
template.push({
|
||||
label: APP_NAME,
|
||||
submenu: [
|
||||
{ role: 'about', label: `About ${APP_NAME}` },
|
||||
{ type: 'separator' },
|
||||
{ role: 'services' },
|
||||
{ type: 'separator' },
|
||||
{ role: 'hide' },
|
||||
{ role: 'hideOthers' },
|
||||
{ role: 'unhide' },
|
||||
{ type: 'separator' },
|
||||
{ role: 'quit' }
|
||||
]
|
||||
})
|
||||
}
|
||||
|
||||
template.push({
|
||||
label: 'File',
|
||||
submenu: [IS_MAC ? { role: 'close' } : { role: 'quit' }]
|
||||
})
|
||||
template.push({
|
||||
label: 'Edit',
|
||||
submenu: [
|
||||
{ role: 'undo' },
|
||||
{ role: 'redo' },
|
||||
{ type: 'separator' },
|
||||
{ role: 'cut' },
|
||||
{ role: 'copy' },
|
||||
{ role: 'paste' },
|
||||
{ role: 'delete' },
|
||||
{ role: 'selectAll' }
|
||||
]
|
||||
})
|
||||
template.push({
|
||||
label: 'View',
|
||||
submenu: [
|
||||
{ role: 'reload' },
|
||||
{ role: 'forceReload' },
|
||||
{ role: 'toggleDevTools' },
|
||||
{ type: 'separator' },
|
||||
{ role: 'resetZoom' },
|
||||
{ role: 'zoomIn' },
|
||||
{ role: 'zoomOut' },
|
||||
{ type: 'separator' },
|
||||
{ role: 'togglefullscreen' }
|
||||
]
|
||||
})
|
||||
template.push({
|
||||
label: 'Window',
|
||||
submenu: IS_MAC
|
||||
? [{ role: 'minimize' }, { role: 'zoom' }, { role: 'front' }]
|
||||
: [{ role: 'minimize' }, { role: 'close' }]
|
||||
})
|
||||
|
||||
return Menu.buildFromTemplate(template)
|
||||
}
|
||||
|
||||
function toggleDevTools(window) {
|
||||
if (!DEV_SERVER) return
|
||||
const { webContents } = window
|
||||
if (webContents.isDevToolsOpened()) {
|
||||
webContents.closeDevTools()
|
||||
} else {
|
||||
webContents.openDevTools({ mode: 'detach' })
|
||||
}
|
||||
}
|
||||
|
||||
function installDevToolsShortcut(window) {
|
||||
if (!DEV_SERVER) return
|
||||
window.webContents.on('before-input-event', (event, input) => {
|
||||
const key = input.key.toLowerCase()
|
||||
const isInspectShortcut =
|
||||
input.key === 'F12' ||
|
||||
(IS_MAC && input.meta && input.alt && key === 'i') ||
|
||||
(!IS_MAC && input.control && input.shift && key === 'i')
|
||||
if (!isInspectShortcut) return
|
||||
event.preventDefault()
|
||||
toggleDevTools(window)
|
||||
})
|
||||
}
|
||||
|
||||
function installContextMenu(window) {
|
||||
window.webContents.on('context-menu', (_event, params) => {
|
||||
const template = []
|
||||
const hasSelection = Boolean(params.selectionText?.trim())
|
||||
const hasImage = params.mediaType === 'image' && Boolean(params.srcURL)
|
||||
const hasLink = Boolean(params.linkURL)
|
||||
const isEditable = Boolean(params.isEditable)
|
||||
|
||||
if (hasImage) {
|
||||
template.push(
|
||||
{
|
||||
label: 'Open Image',
|
||||
click: () => {
|
||||
if (params.srcURL && !params.srcURL.startsWith('data:')) {
|
||||
void shell.openExternal(params.srcURL)
|
||||
}
|
||||
},
|
||||
enabled: !params.srcURL.startsWith('data:')
|
||||
},
|
||||
{
|
||||
label: 'Copy Image',
|
||||
click: () => {
|
||||
void copyImageFromUrl(params.srcURL).catch(error => rememberLog(`Copy image failed: ${error.message}`))
|
||||
}
|
||||
},
|
||||
{
|
||||
label: 'Copy Image Address',
|
||||
click: () => clipboard.writeText(params.srcURL)
|
||||
},
|
||||
{
|
||||
label: 'Save Image As...',
|
||||
click: () => {
|
||||
void saveImageFromUrl(params.srcURL).catch(error => rememberLog(`Save image failed: ${error.message}`))
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
if (hasLink) {
|
||||
if (template.length) template.push({ type: 'separator' })
|
||||
template.push(
|
||||
{
|
||||
label: 'Open Link',
|
||||
click: () => void shell.openExternal(params.linkURL)
|
||||
},
|
||||
{
|
||||
label: 'Copy Link',
|
||||
click: () => clipboard.writeText(params.linkURL)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
if (hasSelection || isEditable) {
|
||||
if (template.length) template.push({ type: 'separator' })
|
||||
if (isEditable) {
|
||||
template.push(
|
||||
{ role: 'cut', enabled: params.editFlags.canCut },
|
||||
{ role: 'copy', enabled: params.editFlags.canCopy },
|
||||
{ role: 'paste', enabled: params.editFlags.canPaste },
|
||||
{ type: 'separator' },
|
||||
{ role: 'selectAll', enabled: params.editFlags.canSelectAll }
|
||||
)
|
||||
} else {
|
||||
template.push({ role: 'copy', enabled: params.editFlags.canCopy })
|
||||
}
|
||||
}
|
||||
|
||||
if (!template.length) {
|
||||
template.push({ role: 'selectAll' })
|
||||
}
|
||||
|
||||
Menu.buildFromTemplate(template).popup({ window })
|
||||
})
|
||||
}
|
||||
|
||||
async function startHermes() {
|
||||
if (connectionPromise) return connectionPromise
|
||||
|
||||
connectionPromise = (async () => {
|
||||
const port = await pickPort()
|
||||
const token = crypto.randomBytes(32).toString('base64url')
|
||||
const python = findPython()
|
||||
const args = [
|
||||
'-m',
|
||||
'hermes_cli.main',
|
||||
'dashboard',
|
||||
'--no-open',
|
||||
'--tui',
|
||||
'--host',
|
||||
'127.0.0.1',
|
||||
'--port',
|
||||
String(port)
|
||||
]
|
||||
const hermesCwd = resolveHermesCwd()
|
||||
|
||||
hermesProcess = spawn(python, args, {
|
||||
cwd: hermesCwd,
|
||||
env: {
|
||||
...process.env,
|
||||
PYTHONPATH: [REPO_ROOT, process.env.PYTHONPATH].filter(Boolean).join(path.delimiter),
|
||||
HERMES_DASHBOARD_SESSION_TOKEN: token,
|
||||
HERMES_DASHBOARD_TUI: '1',
|
||||
HERMES_WEB_DIST: path.join(REPO_ROOT, 'apps', 'desktop', 'dist')
|
||||
},
|
||||
stdio: ['ignore', 'pipe', 'pipe']
|
||||
})
|
||||
|
||||
hermesProcess.stdout.on('data', rememberLog)
|
||||
hermesProcess.stderr.on('data', rememberLog)
|
||||
hermesProcess.once('exit', (code, signal) => {
|
||||
rememberLog(`Hermes dashboard exited (${signal || code})`)
|
||||
hermesProcess = null
|
||||
connectionPromise = null
|
||||
mainWindow?.webContents.send('hermes:backend-exit', { code, signal })
|
||||
})
|
||||
|
||||
const baseUrl = `http://127.0.0.1:${port}`
|
||||
await waitForHermes(baseUrl, token)
|
||||
|
||||
return {
|
||||
baseUrl,
|
||||
token,
|
||||
wsUrl: `ws://127.0.0.1:${port}/api/ws?token=${encodeURIComponent(token)}`,
|
||||
logs: hermesLog.slice(-80),
|
||||
windowButtonPosition: getWindowButtonPosition()
|
||||
}
|
||||
})()
|
||||
|
||||
return connectionPromise
|
||||
}
|
||||
|
||||
function createWindow() {
|
||||
const icon = getAppIconPath()
|
||||
mainWindow = new BrowserWindow({
|
||||
width: 1220,
|
||||
height: 800,
|
||||
minWidth: 900,
|
||||
minHeight: 620,
|
||||
title: 'Hermes',
|
||||
titleBarStyle: IS_MAC ? 'hidden' : 'default',
|
||||
titleBarOverlay: IS_MAC ? { height: TITLEBAR_HEIGHT } : undefined,
|
||||
trafficLightPosition: IS_MAC ? WINDOW_BUTTON_POSITION : undefined,
|
||||
vibrancy: IS_MAC ? 'sidebar' : undefined,
|
||||
icon,
|
||||
backgroundColor: '#f7f7f7',
|
||||
webPreferences: {
|
||||
preload: path.join(__dirname, 'preload.cjs'),
|
||||
contextIsolation: true,
|
||||
nodeIntegration: false,
|
||||
devTools: Boolean(DEV_SERVER)
|
||||
}
|
||||
})
|
||||
|
||||
if (IS_MAC) {
|
||||
mainWindow.setWindowButtonPosition?.(WINDOW_BUTTON_POSITION)
|
||||
if (icon) {
|
||||
app.dock?.setIcon(icon)
|
||||
}
|
||||
}
|
||||
|
||||
installDevToolsShortcut(mainWindow)
|
||||
installContextMenu(mainWindow)
|
||||
|
||||
if (DEV_SERVER) {
|
||||
mainWindow.loadURL(DEV_SERVER)
|
||||
} else {
|
||||
mainWindow.loadURL(pathToFileURL(path.join(__dirname, '..', 'dist', 'index.html')).toString())
|
||||
}
|
||||
}
|
||||
|
||||
ipcMain.handle('hermes:connection', async () => startHermes())
|
||||
|
||||
ipcMain.handle('hermes:api', async (_event, request) => {
|
||||
const connection = await startHermes()
|
||||
return fetchJson(`${connection.baseUrl}${request.path}`, connection.token, {
|
||||
method: request.method,
|
||||
body: request.body
|
||||
})
|
||||
})
|
||||
|
||||
ipcMain.handle('hermes:notify', (_event, payload) => {
|
||||
if (!Notification.isSupported()) return false
|
||||
new Notification({
|
||||
title: payload?.title || 'Hermes',
|
||||
body: payload?.body || '',
|
||||
silent: Boolean(payload?.silent)
|
||||
}).show()
|
||||
return true
|
||||
})
|
||||
|
||||
ipcMain.handle('hermes:readFileDataUrl', async (_event, filePath) => {
|
||||
const resolved = path.resolve(String(filePath || ''))
|
||||
const data = await fs.promises.readFile(resolved)
|
||||
return `data:${mimeTypeForPath(resolved)};base64,${data.toString('base64')}`
|
||||
})
|
||||
|
||||
ipcMain.handle('hermes:selectPaths', async (_event, options = {}) => {
|
||||
const properties = ['openFile']
|
||||
if (options?.directories) properties.push('openDirectory')
|
||||
if (options?.multiple !== false) properties.push('multiSelections')
|
||||
|
||||
const result = await dialog.showOpenDialog(mainWindow, {
|
||||
title: options?.title || 'Add context',
|
||||
defaultPath: options?.defaultPath ? path.resolve(String(options.defaultPath)) : undefined,
|
||||
properties,
|
||||
filters: Array.isArray(options?.filters) ? options.filters : undefined
|
||||
})
|
||||
|
||||
if (result.canceled) return []
|
||||
return result.filePaths
|
||||
})
|
||||
|
||||
ipcMain.handle('hermes:writeClipboard', (_event, text) => {
|
||||
clipboard.writeText(String(text || ''))
|
||||
return true
|
||||
})
|
||||
|
||||
ipcMain.handle('hermes:saveImageFromUrl', (_event, url) => saveImageFromUrl(String(url || '')))
|
||||
|
||||
ipcMain.handle('hermes:openExternal', (_event, url) => shell.openExternal(url))
|
||||
|
||||
app.whenReady().then(() => {
|
||||
Menu.setApplicationMenu(buildApplicationMenu())
|
||||
createWindow()
|
||||
startHermes().catch(error => rememberLog(error.stack || error.message))
|
||||
|
||||
app.on('activate', () => {
|
||||
if (BrowserWindow.getAllWindows().length === 0) createWindow()
|
||||
})
|
||||
})
|
||||
|
||||
app.on('before-quit', () => {
|
||||
if (hermesProcess && !hermesProcess.killed) {
|
||||
hermesProcess.kill('SIGTERM')
|
||||
}
|
||||
})
|
||||
|
||||
app.on('window-all-closed', () => {
|
||||
if (process.platform !== 'darwin') app.quit()
|
||||
})
|
||||
17
apps/desktop/electron/preload.cjs
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
const { contextBridge, ipcRenderer } = require('electron')
|
||||
|
||||
contextBridge.exposeInMainWorld('hermesDesktop', {
|
||||
getConnection: () => ipcRenderer.invoke('hermes:connection'),
|
||||
api: request => ipcRenderer.invoke('hermes:api', request),
|
||||
notify: payload => ipcRenderer.invoke('hermes:notify', payload),
|
||||
readFileDataUrl: filePath => ipcRenderer.invoke('hermes:readFileDataUrl', filePath),
|
||||
selectPaths: options => ipcRenderer.invoke('hermes:selectPaths', options),
|
||||
writeClipboard: text => ipcRenderer.invoke('hermes:writeClipboard', text),
|
||||
saveImageFromUrl: url => ipcRenderer.invoke('hermes:saveImageFromUrl', url),
|
||||
openExternal: url => ipcRenderer.invoke('hermes:openExternal', url),
|
||||
onBackendExit: callback => {
|
||||
const listener = (_event, payload) => callback(payload)
|
||||
ipcRenderer.on('hermes:backend-exit', listener)
|
||||
return () => ipcRenderer.removeListener('hermes:backend-exit', listener)
|
||||
}
|
||||
})
|
||||
122
apps/desktop/eslint.config.mjs
Normal file
|
|
@ -0,0 +1,122 @@
|
|||
import js from '@eslint/js'
|
||||
import typescriptEslint from '@typescript-eslint/eslint-plugin'
|
||||
import typescriptParser from '@typescript-eslint/parser'
|
||||
import perfectionist from 'eslint-plugin-perfectionist'
|
||||
import reactPlugin from 'eslint-plugin-react'
|
||||
import reactCompiler from 'eslint-plugin-react-compiler'
|
||||
import hooksPlugin from 'eslint-plugin-react-hooks'
|
||||
import unusedImports from 'eslint-plugin-unused-imports'
|
||||
import globals from 'globals'
|
||||
|
||||
const noopRule = {
|
||||
meta: { schema: [], type: 'problem' },
|
||||
create: () => ({})
|
||||
}
|
||||
|
||||
const customRules = {
|
||||
rules: {
|
||||
'no-process-cwd': noopRule,
|
||||
'no-process-env-top-level': noopRule,
|
||||
'no-sync-fs': noopRule,
|
||||
'no-top-level-dynamic-import': noopRule,
|
||||
'no-top-level-side-effects': noopRule
|
||||
}
|
||||
}
|
||||
|
||||
export default [
|
||||
{
|
||||
ignores: ['**/node_modules/**', '**/dist/**', 'src/**/*.js']
|
||||
},
|
||||
js.configs.recommended,
|
||||
{
|
||||
files: ['**/*.{ts,tsx}'],
|
||||
languageOptions: {
|
||||
globals: {
|
||||
...globals.browser,
|
||||
...globals.node
|
||||
},
|
||||
parser: typescriptParser,
|
||||
parserOptions: {
|
||||
ecmaFeatures: { jsx: true },
|
||||
ecmaVersion: 'latest',
|
||||
sourceType: 'module'
|
||||
}
|
||||
},
|
||||
plugins: {
|
||||
'@typescript-eslint': typescriptEslint,
|
||||
'custom-rules': customRules,
|
||||
perfectionist,
|
||||
react: reactPlugin,
|
||||
'react-compiler': reactCompiler,
|
||||
'react-hooks': hooksPlugin,
|
||||
'unused-imports': unusedImports
|
||||
},
|
||||
rules: {
|
||||
'@typescript-eslint/consistent-type-imports': ['error', { prefer: 'type-imports' }],
|
||||
'@typescript-eslint/no-unused-vars': 'off',
|
||||
curly: ['error', 'all'],
|
||||
'no-fallthrough': ['error', { allowEmptyCase: true }],
|
||||
'no-undef': 'off',
|
||||
'no-unused-vars': 'off',
|
||||
'padding-line-between-statements': [
|
||||
1,
|
||||
{
|
||||
blankLine: 'always',
|
||||
next: [
|
||||
'block-like',
|
||||
'block',
|
||||
'return',
|
||||
'if',
|
||||
'class',
|
||||
'continue',
|
||||
'debugger',
|
||||
'break',
|
||||
'multiline-const',
|
||||
'multiline-let'
|
||||
],
|
||||
prev: '*'
|
||||
},
|
||||
{
|
||||
blankLine: 'always',
|
||||
next: '*',
|
||||
prev: ['case', 'default', 'multiline-const', 'multiline-let', 'multiline-block-like']
|
||||
},
|
||||
{ blankLine: 'never', next: ['block', 'block-like'], prev: ['case', 'default'] },
|
||||
{ blankLine: 'always', next: ['block', 'block-like'], prev: ['block', 'block-like'] },
|
||||
{ blankLine: 'always', next: ['empty'], prev: 'export' },
|
||||
{ blankLine: 'never', next: 'iife', prev: ['block', 'block-like', 'empty'] }
|
||||
],
|
||||
'perfectionist/sort-exports': ['error', { order: 'asc', type: 'natural' }],
|
||||
'perfectionist/sort-imports': [
|
||||
'error',
|
||||
{
|
||||
groups: ['side-effect', 'builtin', 'external', 'internal', 'parent', 'sibling', 'index'],
|
||||
order: 'asc',
|
||||
type: 'natural'
|
||||
}
|
||||
],
|
||||
'perfectionist/sort-jsx-props': ['error', { order: 'asc', type: 'natural' }],
|
||||
'perfectionist/sort-named-exports': ['error', { order: 'asc', type: 'natural' }],
|
||||
'perfectionist/sort-named-imports': ['error', { order: 'asc', type: 'natural' }],
|
||||
'react-compiler/react-compiler': 'warn',
|
||||
'react-hooks/exhaustive-deps': 'warn',
|
||||
'react-hooks/rules-of-hooks': 'error',
|
||||
'unused-imports/no-unused-imports': 'error'
|
||||
},
|
||||
settings: {
|
||||
react: { version: 'detect' }
|
||||
}
|
||||
},
|
||||
{
|
||||
files: ['**/*.js', '**/*.cjs'],
|
||||
ignores: ['**/node_modules/**', '**/dist/**'],
|
||||
languageOptions: {
|
||||
ecmaVersion: 'latest',
|
||||
globals: { ...globals.node },
|
||||
sourceType: 'commonjs'
|
||||
}
|
||||
},
|
||||
{
|
||||
ignores: ['*.config.*']
|
||||
}
|
||||
]
|
||||
14
apps/desktop/index.html
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<link rel="icon" href="/apple-touch-icon.png" />
|
||||
<link rel="apple-touch-icon" href="/apple-touch-icon.png" />
|
||||
<title>Hermes</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
15492
apps/desktop/package-lock.json
generated
Normal file
73
apps/desktop/package.json
Normal file
|
|
@ -0,0 +1,73 @@
|
|||
{
|
||||
"name": "@hermes-agent/desktop",
|
||||
"productName": "Hermes",
|
||||
"private": true,
|
||||
"version": "0.0.0",
|
||||
"description": "Native desktop shell for Hermes Agent.",
|
||||
"type": "module",
|
||||
"main": "electron/main.cjs",
|
||||
"scripts": {
|
||||
"dev": "concurrently -k \"npm:dev:renderer\" \"npm:dev:electron\"",
|
||||
"dev:renderer": "vite --host 127.0.0.1 --port 5174",
|
||||
"dev:electron": "wait-on http://127.0.0.1:5174 && cross-env HERMES_DESKTOP_DEV_SERVER=http://127.0.0.1:5174 electron .",
|
||||
"start": "npm run build && electron .",
|
||||
"build": "tsc -b && vite build",
|
||||
"type-check": "tsc -b",
|
||||
"lint": "eslint src/ electron/",
|
||||
"lint:fix": "eslint src/ electron/ --fix",
|
||||
"fmt": "prettier --write 'src/**/*.{ts,tsx}' 'electron/**/*.{js,cjs}' 'vite.config.ts'",
|
||||
"fix": "npm run lint:fix && npm run fmt",
|
||||
"test:ui": "vitest run --environment jsdom",
|
||||
"preview": "vite preview --host 127.0.0.1 --port 4174"
|
||||
},
|
||||
"dependencies": {
|
||||
"@assistant-ui/react": "^0.12.28",
|
||||
"@assistant-ui/react-streamdown": "^0.1.11",
|
||||
"@chenglou/pretext": "^0.0.6",
|
||||
"@nanostores/react": "^1.1.0",
|
||||
"@radix-ui/react-slot": "^1.2.4",
|
||||
"@streamdown/code": "^1.1.1",
|
||||
"@tailwindcss/vite": "^4.2.4",
|
||||
"@tanstack/react-query": "^5.100.6",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"cmdk": "^1.1.1",
|
||||
"leva": "^0.10.1",
|
||||
"lucide-react": "^0.577.0",
|
||||
"nanostores": "^1.3.0",
|
||||
"radix-ui": "^1.4.3",
|
||||
"react": "^19.2.5",
|
||||
"react-dom": "^19.2.5",
|
||||
"react-shiki": "^0.9.3",
|
||||
"shiki": "^4.0.2",
|
||||
"tailwind-merge": "^3.5.0",
|
||||
"tailwindcss": "^4.2.4",
|
||||
"tw-shimmer": "^0.4.11"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^9.39.4",
|
||||
"@testing-library/react": "^16.3.2",
|
||||
"@types/node": "^24.12.2",
|
||||
"@types/react": "^19.2.14",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"@typescript-eslint/eslint-plugin": "^8.59.1",
|
||||
"@typescript-eslint/parser": "^8.59.1",
|
||||
"@vitejs/plugin-react": "^6.0.1",
|
||||
"concurrently": "^9.2.1",
|
||||
"cross-env": "^10.1.0",
|
||||
"electron": "^40.9.3",
|
||||
"eslint": "^9.39.4",
|
||||
"eslint-plugin-perfectionist": "^5.9.0",
|
||||
"eslint-plugin-react": "^7.37.5",
|
||||
"eslint-plugin-react-compiler": "^19.1.0-rc.2",
|
||||
"eslint-plugin-react-hooks": "^7.1.1",
|
||||
"eslint-plugin-unused-imports": "^4.4.1",
|
||||
"globals": "^16.5.0",
|
||||
"jsdom": "^29.1.1",
|
||||
"prettier": "^3.8.3",
|
||||
"typescript": "^6.0.3",
|
||||
"vite": "^8.0.10",
|
||||
"vitest": "^4.1.5",
|
||||
"wait-on": "^9.0.5"
|
||||
}
|
||||
}
|
||||
BIN
apps/desktop/public/apple-touch-icon.png
Normal file
|
After Width: | Height: | Size: 1.1 MiB |
BIN
apps/desktop/public/hermes-frames/hermes-frame-0.png
Normal file
|
After Width: | Height: | Size: 132 KiB |
BIN
apps/desktop/public/hermes-frames/hermes-frame-1.png
Normal file
|
After Width: | Height: | Size: 115 KiB |
BIN
apps/desktop/public/hermes-frames/hermes-frame-2.png
Normal file
|
After Width: | Height: | Size: 109 KiB |
BIN
apps/desktop/public/hermes-frames/hermes-frame-3.png
Normal file
|
After Width: | Height: | Size: 76 KiB |
BIN
apps/desktop/public/hermes-frames/hermes-frame-4.png
Normal file
|
After Width: | Height: | Size: 117 KiB |
BIN
apps/desktop/public/hermes-frames/hermes-frame-5.png
Normal file
|
After Width: | Height: | Size: 97 KiB |
BIN
apps/desktop/public/hermes-frames/hermes-frame-6.png
Normal file
|
After Width: | Height: | Size: 97 KiB |
BIN
apps/desktop/public/hermes-frames/hermes-frame-7.png
Normal file
|
After Width: | Height: | Size: 92 KiB |
BIN
apps/desktop/public/hermes-sprite.png
Normal file
|
After Width: | Height: | Size: 883 KiB |
BIN
apps/desktop/public/hermes.png
Normal file
|
After Width: | Height: | Size: 1.3 MiB |
27
apps/desktop/src/app/artifacts/index.tsx
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
import { Layers3 } from 'lucide-react'
|
||||
import type * as React from 'react'
|
||||
|
||||
import { titlebarHeaderClass } from '../shell/titlebar'
|
||||
|
||||
export function ArtifactsView(props: React.ComponentProps<'section'>) {
|
||||
return (
|
||||
<section
|
||||
{...props}
|
||||
className="flex h-[calc(100vh-0.375rem)] min-w-0 flex-col overflow-hidden rounded-[0.9375rem] bg-background"
|
||||
>
|
||||
<header className={titlebarHeaderClass}>
|
||||
<h2 className="text-base font-semibold leading-none tracking-tight">Artifacts</h2>
|
||||
</header>
|
||||
<div className="grid min-h-0 flex-1 place-items-center px-8 text-center">
|
||||
<div className="max-w-md space-y-3">
|
||||
<Layers3 className="mx-auto size-8 text-muted-foreground" />
|
||||
<h3 className="text-lg font-semibold">Artifacts view is ready</h3>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Generated files and visual outputs now have a dedicated route and view module instead of being folded into
|
||||
App.tsx.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
250
apps/desktop/src/app/chat/index.tsx
Normal file
|
|
@ -0,0 +1,250 @@
|
|||
import { AssistantRuntimeProvider, ExportedMessageRepository, type ThreadMessage, useExternalStoreRuntime } from '@assistant-ui/react'
|
||||
import { useStore } from '@nanostores/react'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { ChevronDown } from 'lucide-react'
|
||||
import type * as React from 'react'
|
||||
import { Suspense, useMemo } from 'react'
|
||||
|
||||
import { Thread } from '@/components/assistant-ui/thread'
|
||||
import { ChatBar, ChatBarFallback, type ChatBarState } from '@/components/chat-bar'
|
||||
import { NotificationStack } from '@/components/notifications'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { getGlobalModelOptions, type HermesGateway } from '@/hermes'
|
||||
import { quickModelOptions, sessionTitle, toRuntimeMessage } from '@/lib/chat-runtime'
|
||||
import type { ModelOptionsResponse } from '@/types/hermes'
|
||||
import { $pinnedSessionIds } from '@/store/layout'
|
||||
import {
|
||||
$activeSessionId,
|
||||
$awaitingResponse,
|
||||
$busy,
|
||||
$contextSuggestions,
|
||||
$currentModel,
|
||||
$currentProvider,
|
||||
$freshDraftReady,
|
||||
$gatewayState,
|
||||
$introPersonality,
|
||||
$introSeed,
|
||||
$messages,
|
||||
$selectedStoredSessionId,
|
||||
$sessions
|
||||
} from '@/store/session'
|
||||
|
||||
import { routeSessionId } from '../routes'
|
||||
import { titlebarHeaderClass } from '../shell/titlebar'
|
||||
|
||||
import { ChatRightRail } from './right-rail'
|
||||
import { SessionActionsMenu } from './sidebar/session-actions-menu'
|
||||
|
||||
interface ChatViewProps extends Omit<React.ComponentProps<'div'>, 'onSubmit'> {
|
||||
gateway: HermesGateway | null
|
||||
onToggleSelectedPin: () => void
|
||||
onDeleteSelectedSession: () => void
|
||||
onCancel: () => void
|
||||
onAddContextRef: (refText: string, label?: string, detail?: string) => void
|
||||
onAddUrl: (url: string) => void
|
||||
onPasteClipboardImage: () => void
|
||||
onPickFiles: () => void
|
||||
onPickFolders: () => void
|
||||
onPickImages: () => void
|
||||
onRemoveAttachment: (id: string) => void
|
||||
onSubmit: (text: string) => void
|
||||
onChangeCwd: (cwd: string) => void
|
||||
onBrowseCwd: () => void
|
||||
onOpenModelPicker: () => void
|
||||
onSelectPersonality: (name: string) => void
|
||||
onThreadMessagesChange: (messages: readonly ThreadMessage[]) => void
|
||||
onReload: (parentId: string | null) => Promise<void>
|
||||
}
|
||||
|
||||
export function ChatView({
|
||||
gateway,
|
||||
onToggleSelectedPin,
|
||||
onDeleteSelectedSession,
|
||||
onCancel,
|
||||
onAddContextRef,
|
||||
onAddUrl,
|
||||
onPasteClipboardImage,
|
||||
onPickFiles,
|
||||
onPickFolders,
|
||||
onPickImages,
|
||||
onRemoveAttachment,
|
||||
onSubmit,
|
||||
onChangeCwd,
|
||||
onBrowseCwd,
|
||||
onOpenModelPicker,
|
||||
onSelectPersonality,
|
||||
onThreadMessagesChange,
|
||||
onReload
|
||||
}: ChatViewProps) {
|
||||
const activeSessionId = useStore($activeSessionId)
|
||||
const awaitingResponse = useStore($awaitingResponse)
|
||||
const busy = useStore($busy)
|
||||
const contextSuggestions = useStore($contextSuggestions)
|
||||
const currentModel = useStore($currentModel)
|
||||
const currentProvider = useStore($currentProvider)
|
||||
const freshDraftReady = useStore($freshDraftReady)
|
||||
const gatewayState = useStore($gatewayState)
|
||||
const gatewayOpen = gatewayState === 'open'
|
||||
const introPersonality = useStore($introPersonality)
|
||||
const introSeed = useStore($introSeed)
|
||||
const messages = useStore($messages)
|
||||
const pinnedSessionIds = useStore($pinnedSessionIds)
|
||||
const selectedSessionId = useStore($selectedStoredSessionId)
|
||||
const sessions = useStore($sessions)
|
||||
const activeStoredSession = sessions.find(session => session.id === selectedSessionId) || null
|
||||
const isRoutedSessionView = Boolean(routeSessionId())
|
||||
const selectedIsPinned = selectedSessionId ? pinnedSessionIds.includes(selectedSessionId) : false
|
||||
const showIntro =
|
||||
freshDraftReady && !isRoutedSessionView && !selectedSessionId && !activeSessionId && messages.length === 0
|
||||
const loadingSession = isRoutedSessionView && messages.length === 0
|
||||
const threadLoading = loadingSession ? 'session' : busy && awaitingResponse ? 'response' : undefined
|
||||
const showChatBar = !loadingSession
|
||||
const title = activeStoredSession ? sessionTitle(activeStoredSession) : ''
|
||||
const modelOptionsQuery = useQuery<ModelOptionsResponse>({
|
||||
queryKey: ['model-options', activeSessionId || 'global'],
|
||||
queryFn: () => {
|
||||
if (!activeSessionId) {
|
||||
return getGlobalModelOptions()
|
||||
}
|
||||
|
||||
if (!gateway) {
|
||||
throw new Error('Hermes gateway unavailable')
|
||||
}
|
||||
|
||||
return gateway.request<ModelOptionsResponse>('model.options', { session_id: activeSessionId })
|
||||
},
|
||||
enabled: gatewayOpen
|
||||
})
|
||||
const quickModels = useMemo(
|
||||
() => quickModelOptions(modelOptionsQuery.data, currentProvider, currentModel),
|
||||
[currentModel, currentProvider, modelOptionsQuery.data]
|
||||
)
|
||||
const chatBarState = useMemo<ChatBarState>(
|
||||
() => ({
|
||||
model: {
|
||||
model: currentModel,
|
||||
provider: currentProvider,
|
||||
canSwitch: gatewayOpen,
|
||||
loading: !gatewayOpen || (!currentModel && !currentProvider),
|
||||
quickModels
|
||||
},
|
||||
tools: {
|
||||
enabled: true,
|
||||
label: 'Add context',
|
||||
suggestions: contextSuggestions
|
||||
},
|
||||
voice: {
|
||||
enabled: true,
|
||||
active: false
|
||||
}
|
||||
}),
|
||||
[contextSuggestions, currentModel, currentProvider, gatewayOpen, quickModels]
|
||||
)
|
||||
const runtimeMessageRepository = useMemo(() => {
|
||||
const items: { message: ThreadMessage; parentId: string | null }[] = []
|
||||
const branchParentByGroup = new Map<string, string | null>()
|
||||
let visibleParentId: string | null = null
|
||||
let headId: string | null = null
|
||||
|
||||
for (const message of messages) {
|
||||
let parentId = visibleParentId
|
||||
|
||||
if (message.role === 'assistant' && message.branchGroupId) {
|
||||
if (!branchParentByGroup.has(message.branchGroupId)) {
|
||||
branchParentByGroup.set(message.branchGroupId, visibleParentId)
|
||||
}
|
||||
|
||||
parentId = branchParentByGroup.get(message.branchGroupId) ?? null
|
||||
}
|
||||
|
||||
items.push({ message: toRuntimeMessage(message), parentId })
|
||||
|
||||
if (!message.hidden) {
|
||||
visibleParentId = message.id
|
||||
headId = message.id
|
||||
}
|
||||
}
|
||||
|
||||
return ExportedMessageRepository.fromBranchableArray(items, { headId })
|
||||
}, [messages])
|
||||
const runtime = useExternalStoreRuntime<ThreadMessage>({
|
||||
messageRepository: runtimeMessageRepository,
|
||||
isRunning: busy,
|
||||
setMessages: onThreadMessagesChange,
|
||||
onNew: async () => {
|
||||
// Submission is handled explicitly by ChatBar.
|
||||
// Keeping this no-op avoids duplicate prompt.submit calls.
|
||||
},
|
||||
onCancel: async () => onCancel(),
|
||||
onReload
|
||||
})
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="flex h-[calc(100vh-0.375rem)] min-w-0 flex-col overflow-hidden rounded-[0.9375rem] bg-transparent">
|
||||
<header className={titlebarHeaderClass}>
|
||||
<div className="min-w-0 flex-1">
|
||||
{title && (
|
||||
<SessionActionsMenu
|
||||
align="end"
|
||||
onDelete={selectedSessionId ? onDeleteSelectedSession : undefined}
|
||||
onPin={selectedSessionId ? onToggleSelectedPin : undefined}
|
||||
pinned={selectedIsPinned}
|
||||
sideOffset={8}
|
||||
title={title}
|
||||
>
|
||||
<Button
|
||||
className="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 flex-1 overflow-hidden rounded-[1.0625rem] bg-transparent">
|
||||
<AssistantRuntimeProvider runtime={runtime}>
|
||||
<Thread
|
||||
intro={showIntro ? { personality: introPersonality, seed: introSeed } : undefined}
|
||||
loading={threadLoading}
|
||||
/>
|
||||
{showChatBar && (
|
||||
<Suspense fallback={<ChatBarFallback />}>
|
||||
<ChatBar
|
||||
busy={busy}
|
||||
disabled={!gatewayOpen}
|
||||
focusKey={activeSessionId}
|
||||
onAddContextRef={onAddContextRef}
|
||||
onAddUrl={onAddUrl}
|
||||
onCancel={onCancel}
|
||||
onPasteClipboardImage={onPasteClipboardImage}
|
||||
onPickFiles={onPickFiles}
|
||||
onPickFolders={onPickFolders}
|
||||
onPickImages={onPickImages}
|
||||
onRemoveAttachment={onRemoveAttachment}
|
||||
onSubmit={onSubmit}
|
||||
state={chatBarState}
|
||||
/>
|
||||
</Suspense>
|
||||
)}
|
||||
</AssistantRuntimeProvider>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ChatRightRail
|
||||
onBrowseCwd={onBrowseCwd}
|
||||
onChangeCwd={onChangeCwd}
|
||||
onOpenModelPicker={onOpenModelPicker}
|
||||
onSelectPersonality={onSelectPersonality}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export { SESSION_INSPECTOR_WIDTH } from './right-rail'
|
||||
58
apps/desktop/src/app/chat/right-rail/index.tsx
Normal file
|
|
@ -0,0 +1,58 @@
|
|||
import { useStore } from '@nanostores/react'
|
||||
import type * as React from 'react'
|
||||
|
||||
import { SESSION_INSPECTOR_WIDTH, SessionInspector } from '@/components/session-inspector'
|
||||
import { $inspectorOpen } from '@/store/layout'
|
||||
import {
|
||||
$availablePersonalities,
|
||||
$busy,
|
||||
$currentBranch,
|
||||
$currentCwd,
|
||||
$currentModel,
|
||||
$currentPersonality,
|
||||
$currentProvider,
|
||||
$gatewayState
|
||||
} from '@/store/session'
|
||||
|
||||
interface ChatRightRailProps
|
||||
extends Pick<React.ComponentProps<typeof SessionInspector>, 'onBrowseCwd' | 'onChangeCwd'> {
|
||||
onOpenModelPicker: () => void
|
||||
onSelectPersonality: (name: string) => void
|
||||
}
|
||||
|
||||
export function ChatRightRail({
|
||||
onBrowseCwd,
|
||||
onChangeCwd,
|
||||
onOpenModelPicker,
|
||||
onSelectPersonality
|
||||
}: ChatRightRailProps) {
|
||||
const inspectorOpen = useStore($inspectorOpen)
|
||||
const gatewayOpen = useStore($gatewayState) === 'open'
|
||||
const busy = useStore($busy)
|
||||
const cwd = useStore($currentCwd)
|
||||
const branch = useStore($currentBranch)
|
||||
const model = useStore($currentModel)
|
||||
const provider = useStore($currentProvider)
|
||||
const personality = useStore($currentPersonality)
|
||||
const personalities = useStore($availablePersonalities)
|
||||
|
||||
return (
|
||||
<SessionInspector
|
||||
branch={branch}
|
||||
busy={busy}
|
||||
cwd={cwd}
|
||||
modelLabel={model ? model.split('/').pop() || model : ''}
|
||||
modelTitle={provider ? `${provider}: ${model || ''}` : model}
|
||||
onBrowseCwd={onBrowseCwd}
|
||||
onChangeCwd={onChangeCwd}
|
||||
onOpenModelPicker={gatewayOpen ? onOpenModelPicker : undefined}
|
||||
onSelectPersonality={gatewayOpen ? onSelectPersonality : undefined}
|
||||
open={inspectorOpen}
|
||||
personalities={personalities}
|
||||
personality={personality}
|
||||
providerName={provider}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { SESSION_INSPECTOR_WIDTH }
|
||||
279
apps/desktop/src/app/chat/sidebar/index.tsx
Normal file
|
|
@ -0,0 +1,279 @@
|
|||
import { useStore } from '@nanostores/react'
|
||||
import { ChevronDown, Layers3, Pin, Plus, RefreshCw, Sparkles } from 'lucide-react'
|
||||
import type * as React from 'react'
|
||||
|
||||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
Sidebar,
|
||||
SidebarContent,
|
||||
SidebarGroup,
|
||||
SidebarGroupContent,
|
||||
SidebarGroupLabel,
|
||||
SidebarMenu,
|
||||
SidebarMenuButton,
|
||||
SidebarMenuItem
|
||||
} from '@/components/ui/sidebar'
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
import type { SessionInfo } from '@/hermes'
|
||||
import { cn } from '@/lib/utils'
|
||||
import {
|
||||
$isSidebarResizing,
|
||||
$pinnedSessionIds,
|
||||
$sidebarOpen,
|
||||
$sidebarPinsOpen,
|
||||
$sidebarRecentsOpen,
|
||||
pinSession,
|
||||
setSidebarPinsOpen,
|
||||
setSidebarRecentsOpen,
|
||||
unpinSession
|
||||
} from '@/store/layout'
|
||||
import { $selectedStoredSessionId, $sessions, $sessionsLoading } from '@/store/session'
|
||||
|
||||
import { type AppView, ARTIFACTS_ROUTE, SKILLS_ROUTE } from '../../routes'
|
||||
import type { SidebarNavItem } from '../../types'
|
||||
|
||||
import { SidebarSessionRow } from './session-row'
|
||||
|
||||
const SIDEBAR_NAV: SidebarNavItem[] = [
|
||||
{
|
||||
id: 'new-session',
|
||||
label: 'New session',
|
||||
icon: Plus,
|
||||
action: 'new-session'
|
||||
},
|
||||
{ id: 'skills', label: 'Skills', icon: Sparkles, route: SKILLS_ROUTE },
|
||||
{ id: 'artifacts', label: 'Artifacts', icon: Layers3, route: ARTIFACTS_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'
|
||||
|
||||
interface ChatSidebarProps extends React.ComponentProps<typeof Sidebar> {
|
||||
currentView: AppView
|
||||
onNavigate: (item: SidebarNavItem) => void
|
||||
onRefreshSessions: () => void
|
||||
onResumeSession: (sessionId: string) => void
|
||||
onDeleteSession: (sessionId: string) => void
|
||||
}
|
||||
|
||||
export function ChatSidebar({
|
||||
currentView,
|
||||
onNavigate,
|
||||
onRefreshSessions,
|
||||
onResumeSession,
|
||||
onDeleteSession
|
||||
}: ChatSidebarProps) {
|
||||
const sidebarOpen = useStore($sidebarOpen)
|
||||
const pinnedSessionIds = useStore($pinnedSessionIds)
|
||||
const isSidebarResizing = useStore($isSidebarResizing)
|
||||
const pinsOpen = useStore($sidebarPinsOpen)
|
||||
const recentsOpen = useStore($sidebarRecentsOpen)
|
||||
const selectedSessionId = useStore($selectedStoredSessionId)
|
||||
const sessions = useStore($sessions)
|
||||
const sessionsLoading = useStore($sessionsLoading)
|
||||
|
||||
const sortedSessions = [...sessions].sort((a, b) => {
|
||||
const aTime = a.last_active || a.started_at || 0
|
||||
const bTime = b.last_active || b.started_at || 0
|
||||
|
||||
return bTime - aTime
|
||||
})
|
||||
|
||||
const sessionsById = new Map(sessions.map(session => [session.id, session]))
|
||||
const visiblePinnedIds = pinnedSessionIds.filter(id => sessionsById.has(id))
|
||||
const visiblePinnedIdSet = new Set(visiblePinnedIds)
|
||||
|
||||
const pinnedSessions = visiblePinnedIds
|
||||
.map(id => sessionsById.get(id))
|
||||
.filter((session): session is SessionInfo => Boolean(session))
|
||||
|
||||
const recentSessions = sortedSessions.filter(session => !visiblePinnedIdSet.has(session.id))
|
||||
|
||||
const showSessionSkeletons = sessionsLoading && sortedSessions.length === 0
|
||||
|
||||
return (
|
||||
<Sidebar
|
||||
className={cn(
|
||||
'relative h-screen min-w-0 overflow-hidden rounded-tr-[0.9375rem] rounded-br-[0.9375rem] border-r border-t-0 border-l-0 border-b-0 text-foreground [backdrop-filter:blur(1.5rem)_saturate(1.08)]',
|
||||
isSidebarResizing
|
||||
? 'transition-none'
|
||||
: 'transition-[opacity,transform,border-color,box-shadow,background-color] duration-300 ease-[cubic-bezier(0.22,1,0.36,1)]',
|
||||
sidebarOpen
|
||||
? 'translate-x-0 border-(--sidebar-edge-border) bg-[color-mix(in_srgb,var(--dt-sidebar-bg)_97%,transparent)] opacity-100 shadow-(--shadow-sidebar)'
|
||||
: 'pointer-events-none -translate-x-2 border-transparent bg-transparent opacity-0 shadow-none'
|
||||
)}
|
||||
collapsible="none"
|
||||
>
|
||||
<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)]">
|
||||
<SidebarGroupContent>
|
||||
<SidebarMenu className="gap-px">
|
||||
{SIDEBAR_NAV.map(item => {
|
||||
const isInteractive = Boolean(item.action) || Boolean(item.route)
|
||||
|
||||
const active =
|
||||
(item.id === 'skills' && currentView === 'skills') ||
|
||||
(item.id === 'artifacts' && currentView === 'artifacts')
|
||||
|
||||
return (
|
||||
<SidebarMenuItem key={item.id}>
|
||||
<SidebarMenuButton
|
||||
aria-disabled={!isInteractive}
|
||||
className={cn(
|
||||
sidebarNavItemClass,
|
||||
active && 'bg-accent text-foreground',
|
||||
!isInteractive && 'cursor-default hover:bg-transparent hover:text-muted-foreground'
|
||||
)}
|
||||
onClick={() => onNavigate(item)}
|
||||
tooltip={item.label}
|
||||
type="button"
|
||||
>
|
||||
<item.icon className="size-4 shrink-0 text-[color-mix(in_srgb,currentColor_72%,transparent)]" />
|
||||
{sidebarOpen && <span className="max-[46.25rem]:hidden">{item.label}</span>}
|
||||
</SidebarMenuButton>
|
||||
</SidebarMenuItem>
|
||||
)
|
||||
})}
|
||||
</SidebarMenu>
|
||||
</SidebarGroupContent>
|
||||
</SidebarGroup>
|
||||
|
||||
{sidebarOpen && (
|
||||
<SidebarGroup className="shrink-0 pl-4 pr-2 pb-1 pt-0">
|
||||
<SidebarSectionHeader label="Pinned" onToggle={() => setSidebarPinsOpen(!pinsOpen)} open={pinsOpen} />
|
||||
{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">
|
||||
<Pin size={14} />
|
||||
<span>Shift+click to pin</span>
|
||||
</div>
|
||||
)}
|
||||
{pinnedSessions.map(session => (
|
||||
<SidebarSessionRow
|
||||
isPinned
|
||||
isSelected={session.id === selectedSessionId}
|
||||
key={session.id}
|
||||
onDelete={() => onDeleteSession(session.id)}
|
||||
onPin={() => unpinSession(session.id)}
|
||||
onResume={() => onResumeSession(session.id)}
|
||||
session={session}
|
||||
/>
|
||||
))}
|
||||
</SidebarGroupContent>
|
||||
)}
|
||||
</SidebarGroup>
|
||||
)}
|
||||
|
||||
{sidebarOpen && (
|
||||
<SidebarGroup className="min-h-0 flex-1 pl-4 pr-2 py-0">
|
||||
<SidebarSectionHeader
|
||||
action={
|
||||
<Button
|
||||
aria-label={sessionsLoading ? 'Refreshing sessions' : 'Refresh sessions'}
|
||||
className="size-4 rounded-sm p-0 text-muted-foreground opacity-10 hover:bg-accent hover:text-foreground hover:opacity-100 focus-visible:opacity-100 disabled:opacity-35 [&_svg]:size-3!"
|
||||
disabled={sessionsLoading}
|
||||
onClick={event => {
|
||||
event.stopPropagation()
|
||||
setSidebarRecentsOpen(true)
|
||||
onRefreshSessions()
|
||||
}}
|
||||
size="icon-xs"
|
||||
variant="ghost"
|
||||
>
|
||||
<RefreshCw className={cn(sessionsLoading && 'animate-spin')} />
|
||||
</Button>
|
||||
}
|
||||
label="Sessions"
|
||||
onToggle={() => setSidebarRecentsOpen(!recentsOpen)}
|
||||
open={recentsOpen}
|
||||
/>
|
||||
|
||||
{recentsOpen && (
|
||||
<SidebarGroupContent className="flex min-h-0 flex-1 flex-col gap-px overflow-y-auto overscroll-contain pb-1.75">
|
||||
{showSessionSkeletons && <SidebarSessionSkeletons />}
|
||||
{!showSessionSkeletons && sortedSessions.length === 0 && <SidebarEmptySessionState />}
|
||||
{!showSessionSkeletons && sortedSessions.length > 0 && recentSessions.length === 0 && (
|
||||
<SidebarAllPinnedState />
|
||||
)}
|
||||
{recentSessions.map(session => (
|
||||
<SidebarSessionRow
|
||||
isPinned={false}
|
||||
isSelected={session.id === selectedSessionId}
|
||||
key={session.id}
|
||||
onDelete={() => onDeleteSession(session.id)}
|
||||
onPin={() => pinSession(session.id)}
|
||||
onResume={() => onResumeSession(session.id)}
|
||||
session={session}
|
||||
/>
|
||||
))}
|
||||
</SidebarGroupContent>
|
||||
)}
|
||||
</SidebarGroup>
|
||||
)}
|
||||
</SidebarContent>
|
||||
</Sidebar>
|
||||
)
|
||||
}
|
||||
|
||||
interface SidebarSectionHeaderProps extends React.ComponentProps<'div'> {
|
||||
label: string
|
||||
open: boolean
|
||||
onToggle: () => void
|
||||
action?: React.ReactNode
|
||||
}
|
||||
|
||||
function SidebarSectionHeader({ label, open, onToggle, action }: SidebarSectionHeaderProps) {
|
||||
return (
|
||||
<div className="flex shrink-0 items-center justify-between px-2 pb-1 pt-1.5">
|
||||
<SidebarGroupLabel asChild className="h-auto p-0 text-muted-foreground">
|
||||
<button
|
||||
className="group/section-label flex w-fit items-center gap-1 bg-transparent text-left text-xs font-bold leading-none"
|
||||
onClick={onToggle}
|
||||
type="button"
|
||||
>
|
||||
<span className="text-xs font-semibold uppercase leading-none">{label}</span>
|
||||
|
||||
<ChevronDown
|
||||
className={cn('size-3 opacity-0 transition group-hover/section-label:opacity-100', !open && '-rotate-90')}
|
||||
/>
|
||||
</button>
|
||||
</SidebarGroupLabel>
|
||||
{action}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function SidebarSessionSkeletons() {
|
||||
const widths = ['w-32', 'w-40', 'w-28', 'w-36', 'w-24']
|
||||
|
||||
return (
|
||||
<div aria-hidden="true" className="grid gap-px">
|
||||
{widths.map((width, index) => (
|
||||
<div
|
||||
className="grid min-h-7 grid-cols-[minmax(0,1fr)_1.5rem] items-center rounded-lg px-2"
|
||||
key={`${width}-${index}`}
|
||||
>
|
||||
<Skeleton className={cn('h-3.5 rounded-full', width)} />
|
||||
<Skeleton className="mx-auto size-4 rounded-md opacity-60" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
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.
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
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.
|
||||
</div>
|
||||
)
|
||||
}
|
||||
65
apps/desktop/src/app/chat/sidebar/session-actions-menu.tsx
Normal file
|
|
@ -0,0 +1,65 @@
|
|||
import { Archive, Pencil, Pin, Trash2 } from 'lucide-react'
|
||||
import type * as React from 'react'
|
||||
import type { ReactNode } from 'react'
|
||||
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger
|
||||
} from '@/components/ui/dropdown-menu'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
interface SessionActionsMenuProps extends Pick<
|
||||
React.ComponentProps<typeof DropdownMenuContent>,
|
||||
'align' | 'sideOffset'
|
||||
> {
|
||||
children: ReactNode
|
||||
title: string
|
||||
pinned?: boolean
|
||||
onPin?: () => void
|
||||
onDelete?: () => void
|
||||
}
|
||||
|
||||
export function SessionActionsMenu({
|
||||
children,
|
||||
title,
|
||||
pinned = false,
|
||||
onPin,
|
||||
onDelete,
|
||||
align = 'end',
|
||||
sideOffset = 6
|
||||
}: SessionActionsMenuProps) {
|
||||
const itemClass = 'gap-2.5 text-foreground focus:bg-accent [&_svg]:size-4'
|
||||
|
||||
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={onPin}>
|
||||
<Pin />
|
||||
<span>{pinned ? 'Unpin' : 'Pin'}</span>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem className={itemClass}>
|
||||
<Pencil />
|
||||
<span>Rename</span>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem className={itemClass}>
|
||||
<Archive />
|
||||
<span>Add to project</span>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator className="my-3" />
|
||||
<DropdownMenuItem
|
||||
className={cn(itemClass, 'text-destructive focus:text-destructive')}
|
||||
disabled={!onDelete}
|
||||
onSelect={onDelete}
|
||||
variant="destructive"
|
||||
>
|
||||
<Trash2 />
|
||||
<span>Delete</span>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)
|
||||
}
|
||||
70
apps/desktop/src/app/chat/sidebar/session-row.tsx
Normal file
|
|
@ -0,0 +1,70 @@
|
|||
import { MoreVertical } from 'lucide-react'
|
||||
import type * as React from 'react'
|
||||
|
||||
import { Button } from '@/components/ui/button'
|
||||
import type { SessionInfo } from '@/hermes'
|
||||
import { sessionTitle } from '@/lib/chat-runtime'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
import { SessionActionsMenu } from './session-actions-menu'
|
||||
|
||||
export const sidebarSessionRowClass =
|
||||
'group relative grid min-h-7 grid-cols-[minmax(0,1fr)_1.5rem] items-center rounded-lg transition-colors duration-300 ease-out hover:bg-accent hover:transition-none'
|
||||
|
||||
export const sidebarSessionFadeClass =
|
||||
'after:pointer-events-none after:absolute after:inset-y-0 after:right-0 after:z-1 after:w-18 after:rounded-[inherit] after:bg-linear-to-r after:from-transparent after:via-[color-mix(in_srgb,var(--dt-sidebar-bg)_78%,transparent)] after:to-[color-mix(in_srgb,var(--dt-sidebar-bg)_96%,transparent)] after:opacity-0 after:transition-opacity after:duration-200 after:ease-out hover:after:opacity-100 focus-within:after:opacity-100'
|
||||
|
||||
interface SidebarSessionRowProps extends React.ComponentProps<'div'> {
|
||||
session: SessionInfo
|
||||
isPinned: boolean
|
||||
isSelected: boolean
|
||||
onDelete: () => void
|
||||
onPin: () => void
|
||||
onResume: () => void
|
||||
}
|
||||
|
||||
export function SidebarSessionRow({
|
||||
session,
|
||||
isPinned,
|
||||
isSelected,
|
||||
onDelete,
|
||||
onPin,
|
||||
onResume
|
||||
}: SidebarSessionRowProps) {
|
||||
const title = sessionTitle(session)
|
||||
|
||||
return (
|
||||
<div className={cn(sidebarSessionRowClass, sidebarSessionFadeClass, isSelected && 'bg-accent')}>
|
||||
<button
|
||||
className="z-0 flex min-w-0 items-center bg-transparent py-1 pl-2 text-left"
|
||||
onClick={event => {
|
||||
if (event.shiftKey) {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
onPin()
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
onResume()
|
||||
}}
|
||||
type="button"
|
||||
>
|
||||
<span className="truncate text-sm font-medium text-foreground/90">{title}</span>
|
||||
</button>
|
||||
<div className="relative z-2 grid w-6 place-items-center">
|
||||
<SessionActionsMenu onDelete={onDelete} onPin={onPin} pinned={isPinned} title={title}>
|
||||
<Button
|
||||
aria-label={`Actions for ${title}`}
|
||||
className="size-6 rounded-md bg-transparent text-transparent transition-colors duration-150 hover:bg-accent hover:text-foreground data-[state=open]:bg-accent data-[state=open]:text-foreground group-hover:text-muted-foreground"
|
||||
size="icon"
|
||||
title="Session actions"
|
||||
variant="ghost"
|
||||
>
|
||||
<MoreVertical size={15} />
|
||||
</Button>
|
||||
</SessionActionsMenu>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
171
apps/desktop/src/app/chat/use-composer-actions.ts
Normal file
|
|
@ -0,0 +1,171 @@
|
|||
import { useCallback } from 'react'
|
||||
|
||||
import { contextPath, attachmentId, pathLabel } from '@/lib/chat-runtime'
|
||||
import {
|
||||
addComposerAttachment,
|
||||
removeComposerAttachment,
|
||||
type ComposerAttachment
|
||||
} from '@/store/composer'
|
||||
import { notify, notifyError } from '@/store/notifications'
|
||||
|
||||
import type { ImageAttachResponse, ImageDetachResponse } from '../types'
|
||||
|
||||
interface ComposerActionsOptions {
|
||||
activeSessionId: string | null
|
||||
currentCwd: string
|
||||
requestGateway: <T>(method: string, params?: Record<string, unknown>) => Promise<T>
|
||||
}
|
||||
|
||||
export function useComposerActions({ activeSessionId, currentCwd, requestGateway }: ComposerActionsOptions) {
|
||||
const addContextRefAttachment = useCallback((refText: string, label?: string, detail?: string) => {
|
||||
let kind: ComposerAttachment['kind'] = 'file'
|
||||
|
||||
if (refText.startsWith('@folder:')) {
|
||||
kind = 'folder'
|
||||
}
|
||||
|
||||
if (refText.startsWith('@url:')) {
|
||||
kind = 'url'
|
||||
}
|
||||
|
||||
addComposerAttachment({
|
||||
id: attachmentId(kind, refText),
|
||||
kind,
|
||||
label: label || refText.replace(/^@(file|folder|url):/, ''),
|
||||
detail,
|
||||
refText
|
||||
})
|
||||
}, [])
|
||||
|
||||
const pickContextPaths = useCallback(
|
||||
async (kind: 'file' | 'folder') => {
|
||||
const paths = await window.hermesDesktop?.selectPaths({
|
||||
title: kind === 'file' ? 'Add files as context' : 'Add folders as context',
|
||||
defaultPath: currentCwd || undefined,
|
||||
directories: kind === 'folder'
|
||||
})
|
||||
|
||||
if (!paths?.length) {
|
||||
return
|
||||
}
|
||||
|
||||
for (const path of paths) {
|
||||
const rel = contextPath(path, currentCwd)
|
||||
|
||||
addComposerAttachment({
|
||||
id: attachmentId(kind, rel),
|
||||
kind,
|
||||
label: pathLabel(path),
|
||||
detail: rel,
|
||||
refText: `@${kind}:${rel}`,
|
||||
path
|
||||
})
|
||||
}
|
||||
},
|
||||
[currentCwd]
|
||||
)
|
||||
|
||||
const pickImages = useCallback(async () => {
|
||||
if (!activeSessionId) {
|
||||
return
|
||||
}
|
||||
|
||||
const paths = await window.hermesDesktop?.selectPaths({
|
||||
title: 'Attach images',
|
||||
defaultPath: currentCwd || undefined,
|
||||
filters: [
|
||||
{
|
||||
name: 'Images',
|
||||
extensions: ['png', 'jpg', 'jpeg', 'gif', 'webp', 'bmp', 'tiff']
|
||||
}
|
||||
]
|
||||
})
|
||||
|
||||
if (!paths?.length) {
|
||||
return
|
||||
}
|
||||
|
||||
for (const path of paths) {
|
||||
try {
|
||||
const result = await requestGateway<ImageAttachResponse>('image.attach', {
|
||||
session_id: activeSessionId,
|
||||
path
|
||||
})
|
||||
const attachedPath = result.path || path
|
||||
|
||||
if (result.attached) {
|
||||
const previewUrl = await window.hermesDesktop?.readFileDataUrl(attachedPath)
|
||||
|
||||
addComposerAttachment({
|
||||
id: attachmentId('image', attachedPath),
|
||||
kind: 'image',
|
||||
label: pathLabel(attachedPath),
|
||||
detail: attachedPath,
|
||||
previewUrl,
|
||||
path: attachedPath
|
||||
})
|
||||
}
|
||||
} catch (err) {
|
||||
notifyError(err, 'Image attach failed')
|
||||
}
|
||||
}
|
||||
}, [activeSessionId, currentCwd, requestGateway])
|
||||
|
||||
const pasteClipboardImage = useCallback(async () => {
|
||||
if (!activeSessionId) {
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await requestGateway<ImageAttachResponse>('clipboard.paste', {
|
||||
session_id: activeSessionId
|
||||
})
|
||||
|
||||
if (!result.attached) {
|
||||
notify({
|
||||
kind: 'warning',
|
||||
title: 'Clipboard',
|
||||
message: result.message || 'No image found in clipboard'
|
||||
})
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
const attachedPath = result.path || 'clipboard'
|
||||
const previewUrl = result.path && (await window.hermesDesktop?.readFileDataUrl(result.path))
|
||||
|
||||
addComposerAttachment({
|
||||
id: attachmentId('image', attachedPath),
|
||||
kind: 'image',
|
||||
label: pathLabel(attachedPath),
|
||||
detail: attachedPath,
|
||||
previewUrl: previewUrl || undefined,
|
||||
path: result.path
|
||||
})
|
||||
} catch (err) {
|
||||
notifyError(err, 'Clipboard paste failed')
|
||||
}
|
||||
}, [activeSessionId, requestGateway])
|
||||
|
||||
const removeAttachment = useCallback(
|
||||
async (id: string) => {
|
||||
const removed = removeComposerAttachment(id)
|
||||
|
||||
if (removed?.kind === 'image' && removed.path && activeSessionId) {
|
||||
await requestGateway<ImageDetachResponse>('image.detach', {
|
||||
session_id: activeSessionId,
|
||||
path: removed.path
|
||||
}).catch(() => undefined)
|
||||
}
|
||||
},
|
||||
[activeSessionId, requestGateway]
|
||||
)
|
||||
|
||||
return {
|
||||
addContextRefAttachment,
|
||||
pasteClipboardImage,
|
||||
pickContextPaths,
|
||||
pickImages,
|
||||
removeAttachment
|
||||
}
|
||||
}
|
||||
1660
apps/desktop/src/app/index.tsx
Normal file
42
apps/desktop/src/app/model-picker-overlay.tsx
Normal file
|
|
@ -0,0 +1,42 @@
|
|||
import { useStore } from '@nanostores/react'
|
||||
import type * as React from 'react'
|
||||
|
||||
import { ModelPickerDialog } from '@/components/model-picker'
|
||||
import type { HermesGateway } from '@/hermes'
|
||||
import {
|
||||
$activeSessionId,
|
||||
$currentModel,
|
||||
$currentProvider,
|
||||
$gatewayState,
|
||||
$modelPickerOpen,
|
||||
setModelPickerOpen
|
||||
} from '@/store/session'
|
||||
|
||||
interface ModelPickerOverlayProps {
|
||||
gateway?: HermesGateway
|
||||
onSelect: React.ComponentProps<typeof ModelPickerDialog>['onSelect']
|
||||
}
|
||||
|
||||
export function ModelPickerOverlay({ gateway, onSelect }: ModelPickerOverlayProps) {
|
||||
const activeSessionId = useStore($activeSessionId)
|
||||
const currentModel = useStore($currentModel)
|
||||
const currentProvider = useStore($currentProvider)
|
||||
const gatewayOpen = useStore($gatewayState) === 'open'
|
||||
const open = useStore($modelPickerOpen)
|
||||
|
||||
if (!gatewayOpen) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<ModelPickerDialog
|
||||
currentModel={currentModel}
|
||||
currentProvider={currentProvider}
|
||||
gw={gateway}
|
||||
onOpenChange={setModelPickerOpen}
|
||||
onSelect={onSelect}
|
||||
open={open}
|
||||
sessionId={activeSessionId}
|
||||
/>
|
||||
)
|
||||
}
|
||||
62
apps/desktop/src/app/routes.ts
Normal file
|
|
@ -0,0 +1,62 @@
|
|||
export const SESSION_ROUTE_PREFIX = '#/sessions/'
|
||||
export const NEW_CHAT_ROUTE = '#/new'
|
||||
export const SETTINGS_ROUTE = '#/settings'
|
||||
export const SKILLS_ROUTE = '#/skills'
|
||||
export const ARTIFACTS_ROUTE = '#/artifacts'
|
||||
|
||||
export type AppView = 'chat' | 'settings' | 'skills' | 'artifacts'
|
||||
|
||||
export type AppRouteId = 'new' | 'settings' | 'skills' | 'artifacts'
|
||||
|
||||
export interface AppRoute {
|
||||
id: AppRouteId
|
||||
hash: string
|
||||
view: AppView
|
||||
}
|
||||
|
||||
export const APP_ROUTES = [
|
||||
{ id: 'new', hash: NEW_CHAT_ROUTE, view: 'chat' },
|
||||
{ id: 'settings', hash: SETTINGS_ROUTE, view: 'settings' },
|
||||
{ id: 'skills', hash: SKILLS_ROUTE, view: 'skills' },
|
||||
{ id: 'artifacts', hash: ARTIFACTS_ROUTE, view: 'artifacts' }
|
||||
] as const satisfies readonly AppRoute[]
|
||||
|
||||
const APP_VIEW_BY_HASH = new Map<string, AppView>(APP_ROUTES.map(route => [route.hash, route.view]))
|
||||
|
||||
export function currentRouteHash(): string {
|
||||
return window.location.hash || NEW_CHAT_ROUTE
|
||||
}
|
||||
|
||||
export function routeSessionId(hash = currentRouteHash()): string | null {
|
||||
if (!hash.startsWith(SESSION_ROUTE_PREFIX)) {
|
||||
return null
|
||||
}
|
||||
|
||||
const id = hash.slice(SESSION_ROUTE_PREFIX.length)
|
||||
|
||||
return id ? decodeURIComponent(id) : null
|
||||
}
|
||||
|
||||
export function writeRoute(hash: string, replace = false) {
|
||||
if (window.location.hash === hash) {
|
||||
return
|
||||
}
|
||||
|
||||
const nextUrl = `${window.location.pathname}${window.location.search}${hash}`
|
||||
|
||||
if (replace) {
|
||||
window.history.replaceState(null, '', nextUrl)
|
||||
} else {
|
||||
window.history.pushState(null, '', nextUrl)
|
||||
}
|
||||
}
|
||||
|
||||
export function writeSessionRoute(sessionId: string, replace = false) {
|
||||
writeRoute(`${SESSION_ROUTE_PREFIX}${encodeURIComponent(sessionId)}`, replace)
|
||||
}
|
||||
|
||||
export function appViewForHash(hash = currentRouteHash()): AppView {
|
||||
return APP_VIEW_BY_HASH.get(hash) ?? 'chat'
|
||||
}
|
||||
|
||||
export const currentAppView = appViewForHash
|
||||
109
apps/desktop/src/app/session/use-session-state-cache.ts
Normal file
|
|
@ -0,0 +1,109 @@
|
|||
import { useStore } from '@nanostores/react'
|
||||
import { useCallback, useEffect, useRef, type MutableRefObject } from 'react'
|
||||
|
||||
import { createClientSessionState } from '@/lib/chat-runtime'
|
||||
import type { ChatMessage } from '@/lib/chat-messages'
|
||||
import { $busy } from '@/store/session'
|
||||
|
||||
import type { ClientSessionState } from '../types'
|
||||
|
||||
interface SessionStateCacheOptions {
|
||||
activeSessionId: string | null
|
||||
busyRef: MutableRefObject<boolean>
|
||||
selectedStoredSessionId: string | null
|
||||
setAwaitingResponse: (awaiting: boolean) => void
|
||||
setBusy: (busy: boolean) => void
|
||||
setMessages: (messages: ChatMessage[]) => void
|
||||
}
|
||||
|
||||
export function useSessionStateCache({
|
||||
activeSessionId,
|
||||
busyRef,
|
||||
selectedStoredSessionId,
|
||||
setAwaitingResponse,
|
||||
setBusy,
|
||||
setMessages
|
||||
}: SessionStateCacheOptions) {
|
||||
const busy = useStore($busy)
|
||||
const activeSessionIdRef = useRef<string | null>(null)
|
||||
const selectedStoredSessionIdRef = useRef<string | null>(null)
|
||||
const sessionStateByRuntimeIdRef = useRef(new Map<string, ClientSessionState>())
|
||||
const runtimeIdByStoredSessionIdRef = useRef(new Map<string, string>())
|
||||
|
||||
useEffect(() => {
|
||||
activeSessionIdRef.current = activeSessionId
|
||||
}, [activeSessionId])
|
||||
|
||||
useEffect(() => {
|
||||
busyRef.current = busy
|
||||
}, [busy, busyRef])
|
||||
|
||||
useEffect(() => {
|
||||
selectedStoredSessionIdRef.current = selectedStoredSessionId
|
||||
}, [selectedStoredSessionId])
|
||||
|
||||
const ensureSessionState = useCallback((sessionId: string, storedSessionId?: string | null) => {
|
||||
const existing = sessionStateByRuntimeIdRef.current.get(sessionId)
|
||||
|
||||
if (existing) {
|
||||
if (storedSessionId !== undefined) {
|
||||
existing.storedSessionId = storedSessionId
|
||||
|
||||
if (storedSessionId) {
|
||||
runtimeIdByStoredSessionIdRef.current.set(storedSessionId, sessionId)
|
||||
}
|
||||
}
|
||||
|
||||
return existing
|
||||
}
|
||||
|
||||
const created = createClientSessionState(storedSessionId ?? null)
|
||||
sessionStateByRuntimeIdRef.current.set(sessionId, created)
|
||||
|
||||
if (storedSessionId) {
|
||||
runtimeIdByStoredSessionIdRef.current.set(storedSessionId, sessionId)
|
||||
}
|
||||
|
||||
return created
|
||||
}, [])
|
||||
|
||||
const syncSessionStateToView = useCallback(
|
||||
(sessionId: string, state: ClientSessionState) => {
|
||||
if (sessionId !== activeSessionIdRef.current) {
|
||||
return
|
||||
}
|
||||
|
||||
setMessages(state.messages)
|
||||
setBusy(state.busy)
|
||||
busyRef.current = state.busy
|
||||
setAwaitingResponse(state.awaitingResponse)
|
||||
},
|
||||
[busyRef, setAwaitingResponse, setBusy, setMessages]
|
||||
)
|
||||
|
||||
const updateSessionState = useCallback(
|
||||
(
|
||||
sessionId: string,
|
||||
updater: (state: ClientSessionState) => ClientSessionState,
|
||||
storedSessionId?: string | null
|
||||
) => {
|
||||
const previous = ensureSessionState(sessionId, storedSessionId)
|
||||
const next = updater({ ...previous, messages: previous.messages })
|
||||
sessionStateByRuntimeIdRef.current.set(sessionId, next)
|
||||
syncSessionStateToView(sessionId, next)
|
||||
|
||||
return next
|
||||
},
|
||||
[ensureSessionState, syncSessionStateToView]
|
||||
)
|
||||
|
||||
return {
|
||||
activeSessionIdRef,
|
||||
ensureSessionState,
|
||||
runtimeIdByStoredSessionIdRef,
|
||||
selectedStoredSessionIdRef,
|
||||
sessionStateByRuntimeIdRef,
|
||||
syncSessionStateToView,
|
||||
updateSessionState
|
||||
}
|
||||
}
|
||||
7
apps/desktop/src/app/settings/index.tsx
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
import type * as React from 'react'
|
||||
|
||||
import { SettingsPage } from '@/components/settings-page'
|
||||
|
||||
export function SettingsView(props: React.ComponentProps<typeof SettingsPage>) {
|
||||
return <SettingsPage {...props} />
|
||||
}
|
||||
146
apps/desktop/src/app/shell/app-shell.tsx
Normal file
|
|
@ -0,0 +1,146 @@
|
|||
import { useStore } from '@nanostores/react'
|
||||
import type { CSSProperties, ReactNode, PointerEvent as ReactPointerEvent } from 'react'
|
||||
import { useCallback } from 'react'
|
||||
|
||||
import { SidebarProvider } from '@/components/ui/sidebar'
|
||||
import { cn } from '@/lib/utils'
|
||||
import {
|
||||
$inspectorOpen,
|
||||
$isSidebarResizing,
|
||||
$sidebarOpen,
|
||||
$sidebarWidth,
|
||||
setSidebarOpen,
|
||||
setSidebarResizing,
|
||||
setSidebarWidth
|
||||
} from '@/store/layout'
|
||||
import { $connection } from '@/store/session'
|
||||
|
||||
import { TITLEBAR_HEIGHT, titlebarControlsPosition } from './titlebar'
|
||||
import { TitlebarControls } from './titlebar-controls'
|
||||
|
||||
interface AppShellProps {
|
||||
children: ReactNode
|
||||
inspectorWidth: string
|
||||
rightRailOpen: boolean
|
||||
settingsOpen: boolean
|
||||
sidebar: ReactNode
|
||||
onOpenSettings: () => void
|
||||
overlays?: ReactNode
|
||||
}
|
||||
|
||||
export function AppShell({
|
||||
children,
|
||||
inspectorWidth,
|
||||
rightRailOpen,
|
||||
settingsOpen,
|
||||
sidebar,
|
||||
onOpenSettings,
|
||||
overlays
|
||||
}: AppShellProps) {
|
||||
const sidebarWidth = useStore($sidebarWidth)
|
||||
const connection = useStore($connection)
|
||||
const sidebarOpen = useStore($sidebarOpen)
|
||||
const inspectorOpen = useStore($inspectorOpen)
|
||||
const isSidebarResizing = useStore($isSidebarResizing)
|
||||
|
||||
const displayedSidebarWidth = sidebarOpen ? sidebarWidth : Math.round(sidebarWidth * 0.8)
|
||||
const titlebarControls = titlebarControlsPosition(connection?.windowButtonPosition)
|
||||
const showRightRail = rightRailOpen && inspectorOpen
|
||||
|
||||
const startSidebarResize = useCallback(
|
||||
(event: ReactPointerEvent<HTMLDivElement>) => {
|
||||
event.preventDefault()
|
||||
setSidebarResizing(true)
|
||||
|
||||
const startX = event.clientX
|
||||
const startWidth = sidebarWidth
|
||||
const previousCursor = document.body.style.cursor
|
||||
const previousUserSelect = document.body.style.userSelect
|
||||
|
||||
document.body.style.cursor = 'col-resize'
|
||||
document.body.style.userSelect = 'none'
|
||||
|
||||
const handleMove = (moveEvent: PointerEvent) => {
|
||||
setSidebarWidth(startWidth + moveEvent.clientX - startX)
|
||||
}
|
||||
|
||||
const handleUp = () => {
|
||||
setSidebarResizing(false)
|
||||
document.body.style.cursor = previousCursor
|
||||
document.body.style.userSelect = previousUserSelect
|
||||
window.removeEventListener('pointermove', handleMove)
|
||||
window.removeEventListener('pointerup', handleUp)
|
||||
}
|
||||
|
||||
window.addEventListener('pointermove', handleMove)
|
||||
window.addEventListener('pointerup', handleUp, { once: true })
|
||||
},
|
||||
[sidebarWidth]
|
||||
)
|
||||
|
||||
return (
|
||||
<SidebarProvider
|
||||
className="h-screen min-h-0 bg-background"
|
||||
onOpenChange={setSidebarOpen}
|
||||
open={sidebarOpen}
|
||||
style={
|
||||
{
|
||||
'--sidebar-width': `${displayedSidebarWidth}px`,
|
||||
'--titlebar-height': `${TITLEBAR_HEIGHT}px`,
|
||||
'--titlebar-controls-left': `${titlebarControls.left}px`,
|
||||
'--titlebar-controls-top': `${titlebarControls.top}px`
|
||||
} as CSSProperties
|
||||
}
|
||||
>
|
||||
<TitlebarControls
|
||||
onOpenSettings={onOpenSettings}
|
||||
settingsOpen={settingsOpen}
|
||||
showInspectorToggle={rightRailOpen}
|
||||
/>
|
||||
|
||||
<main
|
||||
className={cn(
|
||||
'relative grid h-screen w-full grid-cols-[var(--sidebar-width)_minmax(0,1fr)_var(--inspector-col)] overflow-hidden bg-background pr-0.75 pb-0.75 pt-0.75',
|
||||
isSidebarResizing
|
||||
? 'transition-none'
|
||||
: 'transition-[grid-template-columns,gap] duration-300 ease-[cubic-bezier(0.22,1,0.36,1)]',
|
||||
sidebarOpen || showRightRail ? 'gap-2.5' : 'gap-0'
|
||||
)}
|
||||
style={
|
||||
{
|
||||
'--inspector-width': inspectorWidth,
|
||||
'--inspector-col': showRightRail ? inspectorWidth : '0px'
|
||||
} as CSSProperties
|
||||
}
|
||||
>
|
||||
<div
|
||||
aria-hidden="true"
|
||||
className="absolute left-0 top-0 z-1 h-(--titlebar-height) w-(--titlebar-controls-left) [-webkit-app-region:drag]"
|
||||
/>
|
||||
<div
|
||||
aria-hidden="true"
|
||||
className="absolute right-20 top-0 z-1 h-(--titlebar-height) left-[calc(var(--titlebar-controls-left)+(var(--titlebar-control-size)*2)+0.75rem)] [-webkit-app-region:drag]"
|
||||
/>
|
||||
|
||||
{sidebar}
|
||||
|
||||
{sidebarOpen && (
|
||||
<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>
|
||||
)}
|
||||
|
||||
{children}
|
||||
</main>
|
||||
|
||||
{overlays}
|
||||
</SidebarProvider>
|
||||
)
|
||||
}
|
||||
77
apps/desktop/src/app/shell/titlebar-controls.tsx
Normal file
|
|
@ -0,0 +1,77 @@
|
|||
import { useStore } from '@nanostores/react'
|
||||
import { NotebookTabs, Search, Settings, SlidersHorizontal } from 'lucide-react'
|
||||
import type * as React from 'react'
|
||||
|
||||
import { cn } from '@/lib/utils'
|
||||
import { $inspectorOpen, $sidebarOpen, toggleInspectorOpen, toggleSidebarOpen } from '@/store/layout'
|
||||
|
||||
import { TITLEBAR_ICON_SIZE, titlebarButtonClass } from './titlebar'
|
||||
|
||||
interface TitlebarControlsProps extends React.ComponentProps<'div'> {
|
||||
settingsOpen: boolean
|
||||
showInspectorToggle: boolean
|
||||
onOpenSettings: () => void
|
||||
}
|
||||
|
||||
export function TitlebarControls({ settingsOpen, showInspectorToggle, onOpenSettings }: TitlebarControlsProps) {
|
||||
const sidebarOpen = useStore($sidebarOpen)
|
||||
const inspectorOpen = useStore($inspectorOpen)
|
||||
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
aria-label="Window controls"
|
||||
className="fixed left-(--titlebar-controls-left) top-(--titlebar-controls-top) z-50 grid translate-y-[2px] grid-flow-col auto-cols-(--titlebar-control-size) items-center pointer-events-auto [-webkit-app-region:no-drag]"
|
||||
>
|
||||
<button
|
||||
aria-label={sidebarOpen ? 'Hide sidebar' : 'Show sidebar'}
|
||||
className={cn(titlebarButtonClass, 'grid place-items-center bg-transparent [&_svg]:size-3.5')}
|
||||
onClick={toggleSidebarOpen}
|
||||
onPointerDown={event => event.stopPropagation()}
|
||||
type="button"
|
||||
>
|
||||
<NotebookTabs />
|
||||
</button>
|
||||
|
||||
<button
|
||||
aria-label="Search"
|
||||
className={cn(titlebarButtonClass, 'grid place-items-center bg-transparent')}
|
||||
onPointerDown={event => event.stopPropagation()}
|
||||
type="button"
|
||||
>
|
||||
<Search size={TITLEBAR_ICON_SIZE} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{!settingsOpen && (
|
||||
<div
|
||||
aria-label="App controls"
|
||||
className="fixed right-3 top-(--titlebar-controls-top) z-1100 grid grid-flow-col auto-cols-(--titlebar-control-size) items-center pointer-events-auto [-webkit-app-region:no-drag]"
|
||||
>
|
||||
{showInspectorToggle && (
|
||||
<button
|
||||
aria-label={inspectorOpen ? 'Hide session details' : 'Show session details'}
|
||||
className={cn(titlebarButtonClass, 'grid place-items-center bg-transparent [&_svg]:size-3.5')}
|
||||
onClick={toggleInspectorOpen}
|
||||
onPointerDown={event => event.stopPropagation()}
|
||||
title={inspectorOpen ? 'Hide session details' : 'Show session details'}
|
||||
type="button"
|
||||
>
|
||||
<SlidersHorizontal />
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
aria-label="Open settings"
|
||||
className={cn(titlebarButtonClass, 'grid place-items-center bg-transparent [&_svg]:size-3.5')}
|
||||
onClick={onOpenSettings}
|
||||
onPointerDown={event => event.stopPropagation()}
|
||||
title="Settings"
|
||||
type="button"
|
||||
>
|
||||
<Settings />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
28
apps/desktop/src/app/shell/titlebar.ts
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
import type { HermesConnection } from '@/global'
|
||||
|
||||
export const TITLEBAR_HEIGHT = 34
|
||||
export const MACOS_TRAFFIC_LIGHTS_HEIGHT = 14
|
||||
export const TITLEBAR_ICON_SIZE = 12
|
||||
export const TITLEBAR_CONTROL_OFFSET_X = 60
|
||||
export const TITLEBAR_CONTROL_HEIGHT = 22
|
||||
export const TITLEBAR_CONTROLS_TOP = (TITLEBAR_HEIGHT - TITLEBAR_CONTROL_HEIGHT) / 2
|
||||
|
||||
const WINDOW_BUTTON_FALLBACK = {
|
||||
x: 24,
|
||||
y: TITLEBAR_HEIGHT / 2 - MACOS_TRAFFIC_LIGHTS_HEIGHT / 2
|
||||
}
|
||||
|
||||
export const titlebarButtonClass =
|
||||
'h-[var(--titlebar-control-height)] w-[var(--titlebar-control-size)] rounded-md text-muted-foreground hover:bg-accent hover:text-foreground'
|
||||
|
||||
export const titlebarHeaderClass =
|
||||
"relative z-3 flex h-(--titlebar-height) shrink-0 items-center gap-3 bg-background/70 px-3 shadow-header backdrop-blur-sm after:pointer-events-none after:absolute after:left-0 after:right-0 after:top-full after:h-10 after:bg-linear-to-b after:from-background after:via-background/80 after:to-transparent after:content-['']"
|
||||
|
||||
export function titlebarControlsPosition(windowButtonPosition: HermesConnection['windowButtonPosition'] | undefined) {
|
||||
const position = windowButtonPosition || WINDOW_BUTTON_FALLBACK
|
||||
|
||||
return {
|
||||
left: position.x + TITLEBAR_CONTROL_OFFSET_X,
|
||||
top: Math.max(0, TITLEBAR_CONTROLS_TOP)
|
||||
}
|
||||
}
|
||||
27
apps/desktop/src/app/skills/index.tsx
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
import { Sparkles } from 'lucide-react'
|
||||
import type * as React from 'react'
|
||||
|
||||
import { titlebarHeaderClass } from '../shell/titlebar'
|
||||
|
||||
export function SkillsView(props: React.ComponentProps<'section'>) {
|
||||
return (
|
||||
<section
|
||||
{...props}
|
||||
className="flex h-[calc(100vh-0.375rem)] min-w-0 flex-col overflow-hidden rounded-[0.9375rem] bg-background"
|
||||
>
|
||||
<header className={titlebarHeaderClass}>
|
||||
<h2 className="text-base font-semibold leading-none tracking-tight">Skills</h2>
|
||||
</header>
|
||||
<div className="grid min-h-0 flex-1 place-items-center px-8 text-center">
|
||||
<div className="max-w-md space-y-3">
|
||||
<Sparkles className="mx-auto size-8 text-muted-foreground" />
|
||||
<h3 className="text-lg font-semibold">Skills view is ready</h3>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Skill management already lives in Settings. This route gives it a dedicated view boundary so the real screen
|
||||
can move here without touching the app shell again.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
74
apps/desktop/src/app/types.ts
Normal file
|
|
@ -0,0 +1,74 @@
|
|||
import type { LucideIcon } from 'lucide-react'
|
||||
|
||||
import type { ChatMessage } from '@/lib/chat-messages'
|
||||
|
||||
export interface ContextSuggestion {
|
||||
text: string
|
||||
display: string
|
||||
meta?: string
|
||||
}
|
||||
|
||||
export interface ImageAttachResponse {
|
||||
attached?: boolean
|
||||
path?: string
|
||||
text?: string
|
||||
message?: string
|
||||
}
|
||||
|
||||
export interface ImageDetachResponse {
|
||||
detached?: boolean
|
||||
count?: number
|
||||
}
|
||||
|
||||
export interface SlashExecResponse {
|
||||
output?: string
|
||||
warning?: string
|
||||
}
|
||||
|
||||
export interface ExecCommandDispatchResponse {
|
||||
type: 'exec' | 'plugin'
|
||||
output?: string
|
||||
}
|
||||
|
||||
export interface AliasCommandDispatchResponse {
|
||||
type: 'alias'
|
||||
target: string
|
||||
}
|
||||
|
||||
export interface SkillCommandDispatchResponse {
|
||||
type: 'skill'
|
||||
name: string
|
||||
message?: string
|
||||
}
|
||||
|
||||
export interface SendCommandDispatchResponse {
|
||||
type: 'send'
|
||||
message: string
|
||||
}
|
||||
|
||||
export type CommandDispatchResponse =
|
||||
| ExecCommandDispatchResponse
|
||||
| AliasCommandDispatchResponse
|
||||
| SkillCommandDispatchResponse
|
||||
| SendCommandDispatchResponse
|
||||
|
||||
export type SidebarNavId = 'new-session' | 'skills' | 'artifacts'
|
||||
|
||||
export interface SidebarNavItem {
|
||||
id: SidebarNavId
|
||||
label: string
|
||||
icon: LucideIcon
|
||||
route?: string
|
||||
action?: 'new-session'
|
||||
}
|
||||
|
||||
export interface ClientSessionState {
|
||||
storedSessionId: string | null
|
||||
messages: ChatMessage[]
|
||||
busy: boolean
|
||||
awaitingResponse: boolean
|
||||
streamId: string | null
|
||||
sawAssistantPayload: boolean
|
||||
pendingBranchGroup: string | null
|
||||
interrupted: boolean
|
||||
}
|
||||
36
apps/desktop/src/components/assistant-ui/activity-timer.ts
Normal file
|
|
@ -0,0 +1,36 @@
|
|||
import { useEffect, useRef, useState } from 'react'
|
||||
|
||||
const ELAPSED_TICK_MS = 1000
|
||||
|
||||
export function formatElapsed(seconds: number): string {
|
||||
if (seconds < 60) {
|
||||
return `${seconds}s`
|
||||
}
|
||||
|
||||
const minutes = Math.floor(seconds / 60)
|
||||
const remainder = seconds % 60
|
||||
|
||||
return `${minutes}:${String(remainder).padStart(2, '0')}`
|
||||
}
|
||||
|
||||
export function useElapsedSeconds(active = true): number {
|
||||
const startedAt = useRef(Date.now())
|
||||
const [elapsed, setElapsed] = useState(0)
|
||||
|
||||
useEffect(() => {
|
||||
if (!active) {
|
||||
return
|
||||
}
|
||||
|
||||
const update = () => {
|
||||
setElapsed(Math.max(0, Math.floor((Date.now() - startedAt.current) / 1000)))
|
||||
}
|
||||
|
||||
update()
|
||||
const id = window.setInterval(update, ELAPSED_TICK_MS)
|
||||
|
||||
return () => window.clearInterval(id)
|
||||
}, [active])
|
||||
|
||||
return elapsed
|
||||
}
|
||||
6
apps/desktop/src/components/assistant-ui/attachment.tsx
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
'use client'
|
||||
|
||||
// Minimal stubs — attachment upload not wired in the desktop app yet.
|
||||
export const ComposerAddAttachment = () => null
|
||||
export const ComposerAttachments = () => null
|
||||
export const UserMessageAttachments = () => null
|
||||
150
apps/desktop/src/components/assistant-ui/directive-text.tsx
Normal file
|
|
@ -0,0 +1,150 @@
|
|||
'use client'
|
||||
|
||||
import type { Unstable_DirectiveFormatter, Unstable_DirectiveSegment, Unstable_TriggerItem } from '@assistant-ui/core'
|
||||
import type { TextMessagePartComponent, TextMessagePartProps } from '@assistant-ui/react'
|
||||
import { AtSign, FileText, FolderOpen, ImageIcon, Link as LinkIcon, Wrench } from 'lucide-react'
|
||||
import type { ComponentType, FC } from 'react'
|
||||
import { Fragment, useMemo } from 'react'
|
||||
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
const HERMES_REF_TYPES = ['file', 'folder', 'url', 'image', 'tool'] as const
|
||||
type HermesRefType = (typeof HERMES_REF_TYPES)[number]
|
||||
|
||||
const ICONS: Record<HermesRefType, ComponentType<{ className?: string }>> = {
|
||||
file: FileText,
|
||||
folder: FolderOpen,
|
||||
url: LinkIcon,
|
||||
image: ImageIcon,
|
||||
tool: Wrench
|
||||
}
|
||||
|
||||
/**
|
||||
* Parses our composer's `@type:value` references into directive segments
|
||||
* so they render as inline chips in user messages instead of raw text.
|
||||
*
|
||||
* Supported types: file, folder, url, image. Anything else stays plain text.
|
||||
*/
|
||||
const CANONICAL_DIRECTIVE_RE = /:([\w-]{1,64})\[([^\]\n]{1,1024})\](?:\{name=([^}\n]{1,1024})\})?/gu
|
||||
|
||||
const HERMES_DIRECTIVE_RE = /@(file|folder|url|image|tool):(\S+)/gu
|
||||
|
||||
export const hermesDirectiveFormatter: Unstable_DirectiveFormatter = {
|
||||
serialize(item: Unstable_TriggerItem): string {
|
||||
if (item.id === `${item.type}:`) {
|
||||
return `@${item.id}`
|
||||
}
|
||||
|
||||
return `@${item.type}:${item.id}`
|
||||
},
|
||||
parse(text: string): readonly Unstable_DirectiveSegment[] {
|
||||
return parseDirectiveText(text)
|
||||
}
|
||||
}
|
||||
|
||||
function parseDirectiveText(text: string): Unstable_DirectiveSegment[] {
|
||||
const matches = [
|
||||
...Array.from(text.matchAll(CANONICAL_DIRECTIVE_RE)).map(match => ({
|
||||
start: match.index ?? 0,
|
||||
end: (match.index ?? 0) + match[0].length,
|
||||
type: match[1] || 'tool',
|
||||
label: match[2] || match[3] || '',
|
||||
id: match[3] || match[2] || ''
|
||||
})),
|
||||
...Array.from(text.matchAll(HERMES_DIRECTIVE_RE)).map(match => ({
|
||||
start: match.index ?? 0,
|
||||
end: (match.index ?? 0) + match[0].length,
|
||||
type: match[1] || 'file',
|
||||
label: shortLabel(match[1] as HermesRefType, match[2] || ''),
|
||||
id: match[2] || ''
|
||||
}))
|
||||
]
|
||||
.filter(match => match.id)
|
||||
.sort((a, b) => a.start - b.start)
|
||||
|
||||
const segments: Unstable_DirectiveSegment[] = []
|
||||
let cursor = 0
|
||||
|
||||
for (const match of matches) {
|
||||
if (match.start < cursor) {
|
||||
continue
|
||||
}
|
||||
|
||||
if (match.start > cursor) {
|
||||
segments.push({ kind: 'text', text: text.slice(cursor, match.start) })
|
||||
}
|
||||
|
||||
segments.push({
|
||||
kind: 'mention',
|
||||
type: match.type,
|
||||
label: match.label,
|
||||
id: match.id
|
||||
})
|
||||
cursor = match.end
|
||||
}
|
||||
|
||||
if (cursor < text.length) {
|
||||
segments.push({ kind: 'text', text: text.slice(cursor) })
|
||||
}
|
||||
|
||||
return segments
|
||||
}
|
||||
|
||||
function shortLabel(type: HermesRefType, id: string): string {
|
||||
if (type === 'url') {
|
||||
try {
|
||||
const parsed = new URL(id)
|
||||
|
||||
return parsed.hostname || id
|
||||
} catch {
|
||||
return id
|
||||
}
|
||||
}
|
||||
|
||||
const tail = id.split(/[\\/]/).filter(Boolean).pop()
|
||||
|
||||
return tail || id
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders a text message part with our directive segments as inline chips.
|
||||
* Unknown directive types fall through as plain text.
|
||||
*/
|
||||
export const DirectiveText: TextMessagePartComponent = ({ text }: TextMessagePartProps) => {
|
||||
const segments = useMemo(() => hermesDirectiveFormatter.parse(text ?? ''), [text])
|
||||
|
||||
return (
|
||||
<span className="whitespace-pre-line" data-slot="aui_directive-text">
|
||||
{segments.map((segment, index) =>
|
||||
segment.kind === 'text' ? (
|
||||
<Fragment key={`t-${index}`}>{segment.text}</Fragment>
|
||||
) : (
|
||||
<DirectiveChip id={segment.id} key={`m-${index}-${segment.id}`} label={segment.label} type={segment.type} />
|
||||
)
|
||||
)}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
const DirectiveChip: FC<{
|
||||
type: string
|
||||
label: string
|
||||
id: string
|
||||
}> = ({ type, label, id }) => {
|
||||
const Icon = ICONS[type as HermesRefType] ?? AtSign
|
||||
|
||||
return (
|
||||
<span
|
||||
className={cn(
|
||||
'mx-0.5 inline-flex max-w-56 items-center gap-1 rounded-full border border-border/80 bg-background/95 px-1.5 py-0.5 align-[0.05em] text-[0.82em] font-medium leading-none text-foreground shadow-sm ring-1 ring-black/3'
|
||||
)}
|
||||
data-directive-id={id}
|
||||
data-directive-type={type}
|
||||
data-slot="aui_directive-chip"
|
||||
title={id}
|
||||
>
|
||||
{Icon && <Icon className="size-3 shrink-0 text-muted-foreground" />}
|
||||
<span className="truncate">{label}</span>
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
|
@ -0,0 +1,19 @@
|
|||
'use client'
|
||||
|
||||
import { createContext, type ReactNode, useContext, useMemo, useState } from 'react'
|
||||
|
||||
type Value = {
|
||||
isPending: boolean
|
||||
setPending: (pending: boolean) => void
|
||||
}
|
||||
|
||||
const Ctx = createContext<Value | null>(null)
|
||||
|
||||
export function GeneratedImageProvider({ children }: { children: ReactNode }) {
|
||||
const [isPending, setPending] = useState(false)
|
||||
const value = useMemo(() => ({ isPending, setPending }), [isPending])
|
||||
|
||||
return <Ctx.Provider value={value}>{children}</Ctx.Provider>
|
||||
}
|
||||
|
||||
export const useGeneratedImageContext = () => useContext(Ctx)
|
||||
|
|
@ -0,0 +1,268 @@
|
|||
import { type FC, useEffect, useRef } from 'react'
|
||||
|
||||
type Rgb = { r: number; g: number; b: number }
|
||||
|
||||
const RAMP = ' .,:;-=+*#%@'
|
||||
|
||||
const FALLBACKS = {
|
||||
card: { r: 255, g: 255, b: 255 },
|
||||
muted: { r: 240, g: 240, b: 239 },
|
||||
foreground: { r: 36, g: 36, b: 36 },
|
||||
primary: { r: 207, g: 128, b: 109 },
|
||||
ring: { r: 185, g: 121, b: 105 }
|
||||
} satisfies Record<string, Rgb>
|
||||
|
||||
const clamp = (value: number, min: number, max: number) => Math.min(max, Math.max(min, value))
|
||||
|
||||
const smoothstep = (edge0: number, edge1: number, value: number) => {
|
||||
const t = clamp((value - edge0) / (edge1 - edge0), 0, 1)
|
||||
|
||||
return t * t * (3 - 2 * t)
|
||||
}
|
||||
|
||||
const parseColor = (value: string, fallback: Rgb): Rgb => {
|
||||
const hex = value.trim().match(/^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i)
|
||||
|
||||
if (hex) {
|
||||
return {
|
||||
r: Number.parseInt(hex[1], 16),
|
||||
g: Number.parseInt(hex[2], 16),
|
||||
b: Number.parseInt(hex[3], 16)
|
||||
}
|
||||
}
|
||||
|
||||
const rgb = value.trim().match(/rgba?\((\d+),\s*(\d+),\s*(\d+)/i)
|
||||
|
||||
return rgb ? { r: Number(rgb[1]), g: Number(rgb[2]), b: Number(rgb[3]) } : fallback
|
||||
}
|
||||
|
||||
const mix = (a: Rgb, b: Rgb, amount: number): Rgb => ({
|
||||
r: Math.round(a.r + (b.r - a.r) * amount),
|
||||
g: Math.round(a.g + (b.g - a.g) * amount),
|
||||
b: Math.round(a.b + (b.b - a.b) * amount)
|
||||
})
|
||||
|
||||
const rgba = ({ r, g, b }: Rgb, alpha: number) => `rgba(${r}, ${g}, ${b}, ${alpha})`
|
||||
|
||||
const hash2 = (x: number, y: number) => {
|
||||
const n = Math.sin(x * 127.1 + y * 311.7) * 43758.5453
|
||||
|
||||
return n - Math.floor(n)
|
||||
}
|
||||
|
||||
const noise2 = (x: number, y: number) => {
|
||||
const xi = Math.floor(x)
|
||||
const yi = Math.floor(y)
|
||||
const xf = x - xi
|
||||
const yf = y - yi
|
||||
const u = xf * xf * (3 - 2 * xf)
|
||||
const v = yf * yf * (3 - 2 * yf)
|
||||
const a = hash2(xi, yi)
|
||||
const b = hash2(xi + 1, yi)
|
||||
const c = hash2(xi, yi + 1)
|
||||
const d = hash2(xi + 1, yi + 1)
|
||||
|
||||
return a + (b - a) * u + (c - a) * v + (a - b - c + d) * u * v
|
||||
}
|
||||
|
||||
const fbm = (x: number, y: number) => {
|
||||
let value = 0
|
||||
let amplitude = 0.5
|
||||
let frequency = 1
|
||||
|
||||
for (let i = 0; i < 4; i += 1) {
|
||||
value += amplitude * noise2(x * frequency, y * frequency)
|
||||
frequency *= 2.04
|
||||
amplitude *= 0.52
|
||||
}
|
||||
|
||||
return value
|
||||
}
|
||||
|
||||
const readTheme = () => {
|
||||
const styles = getComputedStyle(document.documentElement)
|
||||
|
||||
return {
|
||||
card: parseColor(styles.getPropertyValue('--dt-card'), FALLBACKS.card),
|
||||
muted: parseColor(styles.getPropertyValue('--dt-muted'), FALLBACKS.muted),
|
||||
foreground: parseColor(styles.getPropertyValue('--dt-foreground'), FALLBACKS.foreground),
|
||||
primary: parseColor(styles.getPropertyValue('--dt-primary'), FALLBACKS.primary),
|
||||
ring: parseColor(styles.getPropertyValue('--dt-ring'), FALLBACKS.ring)
|
||||
}
|
||||
}
|
||||
|
||||
const fitCanvas = (canvas: HTMLCanvasElement, ctx: CanvasRenderingContext2D) => {
|
||||
const rect = canvas.getBoundingClientRect()
|
||||
const dpr = Math.min(window.devicePixelRatio || 1, 2)
|
||||
const width = Math.max(1, rect.width)
|
||||
const height = Math.max(1, rect.height)
|
||||
|
||||
canvas.width = Math.round(width * dpr)
|
||||
canvas.height = Math.round(height * dpr)
|
||||
ctx.setTransform(dpr, 0, 0, dpr, 0, 0)
|
||||
|
||||
return { width, height }
|
||||
}
|
||||
|
||||
const drawAsciiDiffusion = (ctx: CanvasRenderingContext2D, width: number, height: number, time: number) => {
|
||||
const theme = readTheme()
|
||||
const bg = ctx.createLinearGradient(0, 0, width, height)
|
||||
bg.addColorStop(0, rgba(mix(theme.card, theme.primary, 0.08), 1))
|
||||
bg.addColorStop(0.54, rgba(mix(theme.card, theme.muted, 0.68), 1))
|
||||
bg.addColorStop(1, rgba(mix(theme.muted, theme.ring, 0.12), 1))
|
||||
ctx.fillStyle = bg
|
||||
ctx.fillRect(0, 0, width, height)
|
||||
|
||||
const cycle = (time * 0.028) % 1
|
||||
|
||||
const denoise = cycle < 0.82 ? smoothstep(0.02, 0.82, cycle) : 1 - smoothstep(0.82, 1, cycle)
|
||||
|
||||
const fontSize = clamp(width / 58, 8, 13)
|
||||
const cellWidth = fontSize * 0.78
|
||||
const cellHeight = fontSize * 1.28
|
||||
const cols = Math.ceil(width / cellWidth)
|
||||
const rows = Math.ceil(height / cellHeight)
|
||||
const centerX = 0.53 + Math.sin(time * 0.055) * 0.02
|
||||
const centerY = 0.5 + Math.cos(time * 0.048) * 0.02
|
||||
const timestep = Math.floor(time * 1.15)
|
||||
const timestepBlend = smoothstep(0, 1, time * 1.15 - timestep)
|
||||
|
||||
ctx.font = `${fontSize}px "SF Mono", "Cascadia Code", Menlo, Consolas, monospace`
|
||||
ctx.textAlign = 'center'
|
||||
ctx.textBaseline = 'middle'
|
||||
|
||||
for (let row = -1; row <= rows + 1; row += 1) {
|
||||
for (let col = -1; col <= cols + 1; col += 1) {
|
||||
const x = col * cellWidth + cellWidth * 0.5
|
||||
const y = row * cellHeight + cellHeight * 0.5
|
||||
const nx = x / width
|
||||
const ny = y / height
|
||||
const dx = (nx - centerX) * 1.2
|
||||
const dy = (ny - centerY) * 0.95
|
||||
const radius = Math.hypot(dx, dy)
|
||||
const angle = Math.atan2(dy, dx)
|
||||
|
||||
const bloom =
|
||||
Math.exp(-(radius * radius) / 0.075) * 0.72 +
|
||||
Math.exp(-((radius - (0.28 + Math.sin(angle * 5 + time * 0.16) * 0.035)) ** 2) / 0.0028) * 0.8
|
||||
|
||||
const contour =
|
||||
Math.exp(-((Math.sin(angle * 3 + radius * 17 - time * 0.17) * 0.5 + 0.5 - radius) ** 2) / 0.016) * 0.38
|
||||
|
||||
const stem = Math.exp(-((nx - centerX + 0.05) ** 2 / 0.004 + (ny - centerY - 0.25) ** 2 / 0.08)) * 0.46
|
||||
|
||||
const latent = clamp(bloom + contour + stem, 0, 1)
|
||||
const staticA = hash2(col + timestep * 19, row - timestep * 11)
|
||||
|
||||
const staticB = hash2(col + (timestep + 1) * 19, row - (timestep + 1) * 11)
|
||||
|
||||
const staticNoise = staticA + (staticB - staticA) * timestepBlend
|
||||
const livingNoise = fbm(col * 0.12 + time * 0.024, row * 0.12 - time * 0.018)
|
||||
const denoiseWave = Math.exp(-((radius - denoise * 0.62) ** 2) / 0.006)
|
||||
|
||||
const signal = clamp(
|
||||
staticNoise * (1 - denoise) +
|
||||
latent * denoise +
|
||||
(livingNoise - 0.45) * (0.45 - denoise * 0.26) +
|
||||
denoiseWave * 0.3,
|
||||
0,
|
||||
1
|
||||
)
|
||||
|
||||
const dropoutA = hash2(col - timestep * 7, row + timestep * 13)
|
||||
|
||||
const dropoutB = hash2(col - (timestep + 1) * 7, row + (timestep + 1) * 13)
|
||||
|
||||
const dropout = dropoutA + (dropoutB - dropoutA) * timestepBlend
|
||||
|
||||
if (dropout > 0.35 + signal * 0.68) {
|
||||
continue
|
||||
}
|
||||
|
||||
const glyph = RAMP[clamp(Math.floor(signal * (RAMP.length - 1)), 0, RAMP.length - 1)]
|
||||
|
||||
if (glyph === ' ') {
|
||||
continue
|
||||
}
|
||||
|
||||
const jitter = (1 - denoise) * 1.35 + (1 - latent) * 0.45
|
||||
const jx = (noise2(col * 0.31, row * 0.31 + time * 0.09) - 0.5) * jitter
|
||||
const jy = (noise2(col * 0.27 - time * 0.085, row * 0.27) - 0.5) * jitter
|
||||
const tintAmount = clamp(latent * 0.7 + denoiseWave * 0.4, 0, 1)
|
||||
const warm = mix(theme.primary, theme.ring, hash2(col, row))
|
||||
const tint = mix(theme.foreground, warm, tintAmount)
|
||||
const alpha = clamp(0.12 + signal * 0.68 + denoiseWave * 0.16, 0, 0.86)
|
||||
|
||||
if (signal > 0.58 && denoise > 0.34) {
|
||||
ctx.fillStyle = rgba(theme.ring, alpha * 0.2)
|
||||
ctx.fillText(glyph, x + jx + 0.75, y + jy - 0.45)
|
||||
ctx.fillStyle = rgba(theme.primary, alpha * 0.18)
|
||||
ctx.fillText(glyph, x + jx - 0.75, y + jy + 0.45)
|
||||
}
|
||||
|
||||
ctx.fillStyle = rgba(tint, alpha)
|
||||
ctx.fillText(glyph, x + jx, y + jy)
|
||||
}
|
||||
}
|
||||
|
||||
const veil = ctx.createRadialGradient(
|
||||
width * centerX,
|
||||
height * centerY,
|
||||
0,
|
||||
width * centerX,
|
||||
height * centerY,
|
||||
Math.min(width, height) * (0.35 + denoise * 0.3)
|
||||
)
|
||||
|
||||
veil.addColorStop(0, rgba(theme.card, 0.08 + denoise * 0.12))
|
||||
veil.addColorStop(0.52, rgba(theme.card, 0.05))
|
||||
veil.addColorStop(1, rgba(theme.card, 0))
|
||||
ctx.fillStyle = veil
|
||||
ctx.fillRect(0, 0, width, height)
|
||||
}
|
||||
|
||||
const DiffusionCanvas: FC = () => {
|
||||
const canvasRef = useRef<HTMLCanvasElement | null>(null)
|
||||
const sizeRef = useRef({ width: 0, height: 0 })
|
||||
|
||||
useEffect(() => {
|
||||
const canvas = canvasRef.current
|
||||
const ctx = canvas?.getContext('2d')
|
||||
|
||||
if (!canvas || !ctx) {
|
||||
return
|
||||
}
|
||||
|
||||
const resize = () => {
|
||||
sizeRef.current = fitCanvas(canvas, ctx)
|
||||
}
|
||||
|
||||
const observer = new ResizeObserver(resize)
|
||||
observer.observe(canvas)
|
||||
resize()
|
||||
|
||||
let frame = requestAnimationFrame(function draw(now) {
|
||||
const { width, height } = sizeRef.current
|
||||
ctx.clearRect(0, 0, width, height)
|
||||
drawAsciiDiffusion(ctx, width, height, now / 1000)
|
||||
frame = requestAnimationFrame(draw)
|
||||
})
|
||||
|
||||
return () => {
|
||||
cancelAnimationFrame(frame)
|
||||
observer.disconnect()
|
||||
}
|
||||
}, [])
|
||||
|
||||
return <canvas className="absolute inset-0 h-full w-full" ref={canvasRef} />
|
||||
}
|
||||
|
||||
export const ImageGenerationPlaceholder: FC = () => {
|
||||
return (
|
||||
<div aria-label="Rendering image" aria-live="polite" className="w-full max-w-136 self-start" role="status">
|
||||
<div className="relative h-(--image-preview-height) overflow-hidden rounded-4xl border border-border/55 shadow-[inset_0_0.0625rem_0_color-mix(in_srgb,white_45%,transparent),inset_0_0_0_0.0625rem_color-mix(in_srgb,var(--dt-border)_34%,transparent),inset_0_-0.75rem_1.75rem_color-mix(in_srgb,var(--dt-primary)_5%,transparent)]">
|
||||
<DiffusionCanvas />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
75
apps/desktop/src/components/assistant-ui/intro-copy.jsonl
Normal file
|
|
@ -0,0 +1,75 @@
|
|||
{"personality":"helpful","headline":"Ready when you are","body":"Ask me to open a repo, run tests, fix a bug, or draft a PR. I'll walk through the steps with you."}
|
||||
{"personality":"helpful","headline":"How can I help today?","body":"Point me at a file, paste an error, or describe what you're building. I'll take it from there."}
|
||||
{"personality":"helpful","headline":"Let's get started","body":"Try: review my diff, run the test suite, or explain this function. Ask anything about your code."}
|
||||
{"personality":"helpful","headline":"Tell me what you need","body":"I can edit files, run commands, search the web, and walk you through tricky bugs. Just describe the task."}
|
||||
{"personality":"helpful","headline":"Hi, Hermes here","body":"Share a repo path or a question to start. I keep replies clear and link back to the files I touch."}
|
||||
{"personality":"concise","headline":"Ready.","body":"Describe the task. I'll do it."}
|
||||
{"personality":"concise","headline":"Waiting for input","body":"Paste code, errors, or a goal. Short answers, fast edits."}
|
||||
{"personality":"concise","headline":"Go.","body":"Ask. I'll read files, run tests, ship patches. No filler."}
|
||||
{"personality":"concise","headline":"Standing by","body":"One line is enough. I'll expand only when it matters."}
|
||||
{"personality":"concise","headline":"Your move","body":"Command, question, or file path. I handle the rest."}
|
||||
{"personality":"technical","headline":"Shell mounted. Awaiting input.","body":"Provide repo path, failing test, or stack trace. Tools: fs, git, exec, search, patch, http."}
|
||||
{"personality":"technical","headline":"Agent loop idle","body":"Send a prompt to trigger tool calls. Supports multi-file edits, test runs, git ops, and web fetches."}
|
||||
{"personality":"technical","headline":"Ready for dispatch","body":"Enter task. I will plan, call tools, verify output. Logs stream inline; diffs returned pre-apply."}
|
||||
{"personality":"technical","headline":"Stdin open","body":"Accepts natural language or structured commands. Typical flow: read -> plan -> patch -> test -> report."}
|
||||
{"personality":"technical","headline":"Tools initialized","body":"filesystem, terminal, git, browser, search. Describe the change; I return diffs and test output."}
|
||||
{"personality":"creative","headline":"A blank repo, a waiting cursor","body":"What shall we build? Paste an idea, a half-broken function, or a dream. I'll sketch it into shape."}
|
||||
{"personality":"creative","headline":"Fresh canvas, warm compiler","body":"Give me a spark - a feature, a refactor, a wild prototype - and I'll turn it into code you can run."}
|
||||
{"personality":"creative","headline":"Let's make something","body":"Describe the thing that doesn't exist yet. I'll pull tests, files, and APIs into a working draft."}
|
||||
{"personality":"creative","headline":"New file, new possibilities","body":"Bring an intent, not a spec. We can prototype fast, refine later, and rewrite the world in the margins."}
|
||||
{"personality":"creative","headline":"The muse is patched in","body":"Tell me what you're chasing. I'll remix examples, adapt snippets, and leave a tidy commit behind."}
|
||||
{"personality":"teacher","headline":"Class is in session","body":"Ask about any file, concept, or error. I'll explain the why, not just the fix, and show a worked example."}
|
||||
{"personality":"teacher","headline":"What shall we learn today?","body":"Paste code to review, a bug to debug, or a concept to unpack. I'll guide you step by step."}
|
||||
{"personality":"teacher","headline":"Ready to walk you through it","body":"Share the problem. I'll break it into parts, explain each, and leave you able to solve the next one alone."}
|
||||
{"personality":"teacher","headline":"Bring me a question","body":"We'll read the code together, find the root cause, and build a mental model you can reuse next time."}
|
||||
{"personality":"teacher","headline":"Let's start with the basics","body":"Name the topic or paste the snippet. Expect explanations, diagrams in prose, and practice prompts."}
|
||||
{"personality":"kawaii","headline":"hiii! ready to help! (^_^)","body":"paste a bug or a file path and i'll fix it super gently. tests, diffs, PRs - all with extra care! *sparkle*"}
|
||||
{"personality":"kawaii","headline":"hermes-chan is here! <3","body":"tell me what you're making! i love refactors, tiny helpers, and big scary repos alike (>w<)"}
|
||||
{"personality":"kawaii","headline":"let's code together!! :3","body":"drop an error, a goal, or a whole folder. i'll tidy it up with lots of love and a clean commit message!"}
|
||||
{"personality":"kawaii","headline":"awaiting your wish~","body":"one task at a time, done neatly! i can run tests, patch files, and make your repo feel cozy again <3"}
|
||||
{"personality":"kawaii","headline":"ready and happy! (>.<)","body":"say hi or paste a stack trace! no task too small, no repo too tangled. we'll untangle it together!"}
|
||||
{"personality":"catgirl","headline":"nya~ what are we hacking on?","body":"paste a file, paw at a bug, or toss me a repo. i'll pounce on failing tests and leave clean diffs, nyan~"}
|
||||
{"personality":"catgirl","headline":"*stretches* ready to code, nya","body":"describe the task. i'll patch, test, and purr over your PR. careful - i nip at unused imports!"}
|
||||
{"personality":"catgirl","headline":"mrrp! new session opened","body":"give me a goal and i'll chase it through the codebase. reads, edits, runs - all with a twitchy tail."}
|
||||
{"personality":"catgirl","headline":"tail up, claws sheathed","body":"paste an error or a plan. i debug like i hunt: quietly, thoroughly, with the occasional zoomie."}
|
||||
{"personality":"catgirl","headline":"nyaaa~ hermes reporting","body":"say the word and i'll read your files, run your tests, and curl up in your branch with a tidy commit."}
|
||||
{"personality":"pirate","headline":"Ahoy! Ready to sail the repo","body":"Name yer quarry - a bug, a feature, a cursed test - and I'll chase it down, matey. Diffs for plunder."}
|
||||
{"personality":"pirate","headline":"Hermes at the helm, arrr","body":"Point me at the charts (the code) and I'll patch the hull, fire the cannons (tests), hoist a clean PR."}
|
||||
{"personality":"pirate","headline":"What be the task, cap'n?","body":"Paste an error or a plan, ye scurvy dog. I'll navigate the stack trace and bring back treasure: green tests."}
|
||||
{"personality":"pirate","headline":"Anchors aweigh, keyboard ready","body":"Tell me where X marks the spot. I read, edit, and commit with the discipline of a proper crew, arrr."}
|
||||
{"personality":"pirate","headline":"Yo ho! Awaitin' orders","body":"Throw me a bug, a repo path, or a wild idea. I'll plunder the docs and return with workin' code."}
|
||||
{"personality":"shakespeare","headline":"Pray, what task dost thou bring?","body":"Speak thy bug, thy file, thy weary test, and I shall mend it with a scholar's hand and honest diff."}
|
||||
{"personality":"shakespeare","headline":"Hark! Hermes standeth ready","body":"Name the code that vexeth thee. I shall read, revise, and render a patch most fair and clean."}
|
||||
{"personality":"shakespeare","headline":"What news from thy repository?","body":"Present thy stack trace or thy dream. I'll traverse files, run tests, and report in plainest verse."}
|
||||
{"personality":"shakespeare","headline":"The stage is set, the cursor blinks","body":"Describe thy aim, good sir or madam. Thy branches shall be trimmed, thy bugs cast from the realm."}
|
||||
{"personality":"shakespeare","headline":"Speak, and I shall act","body":"A line of intent sufficeth. I read, I edit, I commit - and leave thy history unblemished."}
|
||||
{"personality":"surfer","headline":"Yo dude, what's the task?","body":"Drop a file, a bug, a gnarly stack trace - I'll ride it out. Clean diffs, green tests, no wipeouts."}
|
||||
{"personality":"surfer","headline":"Waves lookin' clean, ready to code","body":"Paste your repo path or the bug that's bumming you out. We'll paddle in, fix it, paddle out. Easy."}
|
||||
{"personality":"surfer","headline":"Hangin' ten at the prompt","body":"Tell me the vibe: feature, refactor, hotfix. I'll run tests, ship the patch, and keep it mellow, brah."}
|
||||
{"personality":"surfer","headline":"Stoked to help, bro","body":"Big bug? Little typo? Whole rewrite? Just point. I handle the code; you chill with the rad commits."}
|
||||
{"personality":"surfer","headline":"Tide's up, cursor's blinking","body":"Name the task and we're off. I read, edit, test, and leave a commit smoother than a dawn patrol."}
|
||||
{"personality":"noir","headline":"Another repo, another rainy night","body":"Tell me what's broken. I'll read the files, dust for prints, and leave a diff on the desk by morning."}
|
||||
{"personality":"noir","headline":"The cursor blinks. So do I.","body":"You've got a bug. I've got patience and a terminal. Name the case and I'll work it till it talks."}
|
||||
{"personality":"noir","headline":"Hermes. Code investigator.","body":"Paste the stack trace, the suspect file, the alibi. I read between the lines and return with the truth."}
|
||||
{"personality":"noir","headline":"Quiet night, open prompt","body":"Every bug leaves a trail. Give me the repo and a lead - I'll follow it, patch it, and close the file."}
|
||||
{"personality":"noir","headline":"No case too small","body":"A typo, a segfault, a whole rotten architecture - hand me the keys. I'll bring back clean tests."}
|
||||
{"personality":"uwu","headline":"uwu ready to hewp!","body":"paste a buggy fiwe or a goaw~ i'll wead, patch, and test, aww with tiny pawprints on the diff owo"}
|
||||
{"personality":"uwu","headline":"hermes-san is wistening","body":"teww me the task, no matter how smoww~ i pwomise cwean commits and gentwe refactors, nyuu~"}
|
||||
{"personality":"uwu","headline":"*tiny keyboard sounds*","body":"dwop yur ewwor message hewe! i'll find the cuwpwit, fix it, and weave a happy test suite behind me owo"}
|
||||
{"personality":"uwu","headline":"wet's fix things togedda!","body":"give me a wepo path ow a buggo and i'll take cawe of it uwu. gwr at bad code, kind to yu~"}
|
||||
{"personality":"uwu","headline":"awaiting yur command!","body":"i can wun tests, edit fiwes, and open pwease-wook PRs. just say da wowd, fwend uwu"}
|
||||
{"personality":"philosopher","headline":"To code is to inquire. Ask.","body":"What problem sits before you? Describe it, and we shall examine its form, its cause, and its solution."}
|
||||
{"personality":"philosopher","headline":"A blinking cursor, an open mind","body":"Every bug is a question in disguise. Share yours; I'll read, reason, and return an answer - and a patch."}
|
||||
{"personality":"philosopher","headline":"Begin with a single question","body":"What do you wish to build, or to understand? I'll reason from first principles, edit, and verify with tests."}
|
||||
{"personality":"philosopher","headline":"Consider the code, then speak","body":"Describe the end you seek. I pursue it through files, tests, and docs, and report what I found on the way."}
|
||||
{"personality":"philosopher","headline":"The unexamined repo is not worth running","body":"Share a path, a puzzle, or a principle. I'll trace the logic, propose a change, and justify each edit."}
|
||||
{"personality":"hype","headline":"LET'S GOOOO! READY TO SHIP!","body":"Paste that bug, that repo, that wild feature idea - I AM LOCKED IN. Clean diffs. Green tests. RIGHT NOW."}
|
||||
{"personality":"hype","headline":"HERMES ONLINE. LFG.","body":"Drop your task and watch me cook. Files read, tests run, PRs opened - we are NOT losing today, friend."}
|
||||
{"personality":"hype","headline":"New session, infinite W's","body":"Bring the gnarliest bug you've got. I'll read, patch, test, commit like my life depends on it. LET'S GO."}
|
||||
{"personality":"hype","headline":"ABSOLUTELY DIALED IN","body":"Describe the task. I'll blitz through files, crush failing tests, and leave a commit that SLAPS. Go go go."}
|
||||
{"personality":"hype","headline":"Ready. So ready. Too ready.","body":"Tiny typo or huge refactor - doesn't matter. I'm shipping clean code today. Name the task and let's WORK."}
|
||||
{"personality":"none","headline":"Hermes Agent is ready.","body":"Ask a question, paste an error, or point me at a repo. I can read code, run tools, and help you ship."}
|
||||
{"personality":"none","headline":"What are we building today?","body":"Describe the task in your own words. I'll pick the right tools, explain my plan, and check in before risky steps."}
|
||||
{"personality":"none","headline":"Start anywhere.","body":"Drop a file path, a traceback, or a rough idea. I'll investigate, suggest next steps, and keep things reversible."}
|
||||
{"personality":"none","headline":"Your workspace, one prompt away.","body":"Search the repo, edit files, run tests, open PRs. Tell me the goal and I'll handle the mechanical parts."}
|
||||
{"personality":"none","headline":"Ready when you are.","body":"Type a task, question, or snippet. I remember the session, cite my sources, and stop to ask when I'm unsure."}
|
||||
195
apps/desktop/src/components/assistant-ui/intro.tsx
Normal file
|
|
@ -0,0 +1,195 @@
|
|||
import { type FC, useCallback, useEffect, useState } from 'react'
|
||||
|
||||
import introCopyJsonl from './intro-copy.jsonl?raw'
|
||||
|
||||
type IntroCopy = {
|
||||
headline: string
|
||||
body: string
|
||||
}
|
||||
|
||||
type IntroCopyRecord = IntroCopy & {
|
||||
personality: string
|
||||
}
|
||||
|
||||
export type IntroProps = {
|
||||
personality?: string
|
||||
seed?: number
|
||||
}
|
||||
|
||||
const NEUTRAL_PERSONALITIES = new Set(['', 'default', 'none', 'neutral'])
|
||||
|
||||
const HERMES_FRAME_COUNT = 8
|
||||
|
||||
const FALLBACK_COPY: IntroCopy[] = [
|
||||
{
|
||||
headline: 'What are we moving today?',
|
||||
body: "Send a bug, branch, plan, or rough idea. I'll inspect the repo and turn it into the next concrete step."
|
||||
},
|
||||
{
|
||||
headline: "What's on your mind?",
|
||||
body: "Bring the code, question, or stuck part. I'll read the room before making changes."
|
||||
},
|
||||
{
|
||||
headline: 'What should Hermes look at?',
|
||||
body: "Send the task, failing path, or half-formed plan. I'll help turn it into action."
|
||||
},
|
||||
{
|
||||
headline: 'Where should we start?',
|
||||
body: "Bring the problem, goal, or file. I'll inspect first and keep the next step concrete."
|
||||
},
|
||||
{
|
||||
headline: 'What needs attention?',
|
||||
body: "Send the context you have. I'll help sort it into a plan or a fix."
|
||||
}
|
||||
]
|
||||
|
||||
function normalizeKey(value?: string): string {
|
||||
return (value || '').trim().toLowerCase()
|
||||
}
|
||||
|
||||
function titleize(value: string): string {
|
||||
return value
|
||||
.split(/[-_\s]+/)
|
||||
.filter(Boolean)
|
||||
.map(part => part.charAt(0).toUpperCase() + part.slice(1))
|
||||
.join(' ')
|
||||
}
|
||||
|
||||
function isIntroCopyRecord(value: unknown): value is IntroCopyRecord {
|
||||
if (!value || typeof value !== 'object') {
|
||||
return false
|
||||
}
|
||||
|
||||
const record = value as Record<string, unknown>
|
||||
|
||||
return (
|
||||
typeof record.personality === 'string' &&
|
||||
typeof record.headline === 'string' &&
|
||||
typeof record.body === 'string' &&
|
||||
Boolean(record.personality.trim()) &&
|
||||
Boolean(record.headline.trim()) &&
|
||||
Boolean(record.body.trim())
|
||||
)
|
||||
}
|
||||
|
||||
function parseIntroCopy(raw: string): Record<string, IntroCopy[]> {
|
||||
const byPersonality: Record<string, IntroCopy[]> = {}
|
||||
|
||||
for (const line of raw.split(/\r?\n/)) {
|
||||
const trimmed = line.trim()
|
||||
|
||||
if (!trimmed) {
|
||||
continue
|
||||
}
|
||||
|
||||
try {
|
||||
const parsed: unknown = JSON.parse(trimmed)
|
||||
|
||||
if (!isIntroCopyRecord(parsed)) {
|
||||
continue
|
||||
}
|
||||
|
||||
const key = normalizeKey(parsed.personality)
|
||||
byPersonality[key] ??= []
|
||||
byPersonality[key].push({
|
||||
headline: parsed.headline.trim(),
|
||||
body: parsed.body.trim()
|
||||
})
|
||||
} catch {
|
||||
// Bad generated copy should not break the whole desktop app.
|
||||
}
|
||||
}
|
||||
|
||||
return byPersonality
|
||||
}
|
||||
|
||||
const INTRO_COPY_BY_PERSONALITY = parseIntroCopy(introCopyJsonl)
|
||||
|
||||
function neutralCopy(): IntroCopy[] {
|
||||
return INTRO_COPY_BY_PERSONALITY.none || INTRO_COPY_BY_PERSONALITY.default || FALLBACK_COPY
|
||||
}
|
||||
|
||||
function fallbackCopyForPersonality(personalityKey: string): IntroCopy[] {
|
||||
if (NEUTRAL_PERSONALITIES.has(personalityKey)) {
|
||||
return neutralCopy()
|
||||
}
|
||||
|
||||
const label = titleize(personalityKey)
|
||||
|
||||
return [
|
||||
{
|
||||
headline: `${label} mode is on. What should we work on?`,
|
||||
body: "Send the task, file, or rough idea. I'll use your configured voice and keep the work grounded in this repo."
|
||||
},
|
||||
{
|
||||
headline: `What does ${label} Hermes need to see?`,
|
||||
body: "Bring the context or the stuck part. I'll adapt to your configured personality."
|
||||
},
|
||||
{
|
||||
headline: `${label} mode is ready.`,
|
||||
body: "Send the problem, file, or idea. I'll follow the personality you've configured."
|
||||
},
|
||||
{
|
||||
headline: `What should ${label} Hermes tackle?`,
|
||||
body: "Drop the task here. I'll keep the work grounded in the repo."
|
||||
},
|
||||
{
|
||||
headline: 'Where should we begin?',
|
||||
body: `Give me the context and I'll answer in ${label} mode.`
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
function pickCopy(copies: IntroCopy[], seed = 0): IntroCopy {
|
||||
return copies[Math.abs(seed) % copies.length] || FALLBACK_COPY[0]
|
||||
}
|
||||
|
||||
function resolveCopy(personality?: string, seed?: number): IntroCopy {
|
||||
const personalityKey = normalizeKey(personality)
|
||||
|
||||
const copies = NEUTRAL_PERSONALITIES.has(personalityKey)
|
||||
? INTRO_COPY_BY_PERSONALITY[personalityKey] || neutralCopy()
|
||||
: INTRO_COPY_BY_PERSONALITY[personalityKey] || fallbackCopyForPersonality(personalityKey)
|
||||
|
||||
return pickCopy(copies, seed)
|
||||
}
|
||||
|
||||
export const Intro: FC<IntroProps> = ({ personality, seed }) => {
|
||||
const [mountSeed] = useState(() => Math.floor(Math.random() * 100000))
|
||||
const [frameOffset, setFrameOffset] = useState(0)
|
||||
const introSeed = mountSeed + (seed ?? 0)
|
||||
const copy = resolveCopy(personality, introSeed)
|
||||
const frameIndex = Math.abs(introSeed + frameOffset) % HERMES_FRAME_COUNT
|
||||
|
||||
const advanceFrame = useCallback(() => {
|
||||
setFrameOffset(offset => offset + 1 + Math.floor(Math.random() * (HERMES_FRAME_COUNT - 1)))
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
const id = window.setTimeout(advanceFrame, 7000)
|
||||
|
||||
return () => window.clearTimeout(id)
|
||||
}, [advanceFrame, frameOffset])
|
||||
|
||||
return (
|
||||
<div className="pointer-events-none absolute inset-0 z-1 grid place-items-center content-center px-[calc(var(--vsq)*50)] pb-32 text-center text-muted-foreground">
|
||||
<button
|
||||
aria-label="Change Hermes pose"
|
||||
className="pointer-events-auto mb-5 h-56 w-64 cursor-default border-0 bg-transparent p-0"
|
||||
onClick={advanceFrame}
|
||||
type="button"
|
||||
>
|
||||
<img
|
||||
alt=""
|
||||
aria-hidden="true"
|
||||
className="h-full w-full scale-110 object-contain select-none"
|
||||
draggable={false}
|
||||
src={`/hermes-frames/hermes-frame-${frameIndex}.png?v=matte-clean-6`}
|
||||
/>
|
||||
</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>
|
||||
)
|
||||
}
|
||||
322
apps/desktop/src/components/assistant-ui/markdown-text.tsx
Normal file
|
|
@ -0,0 +1,322 @@
|
|||
'use client'
|
||||
|
||||
import { type StreamdownTextComponents, StreamdownTextPrimitive } from '@assistant-ui/react-streamdown'
|
||||
import { code } from '@streamdown/code'
|
||||
import { Check, Copy, Download } from 'lucide-react'
|
||||
import { type ComponentProps, memo, useMemo, useState } from 'react'
|
||||
|
||||
import { SyntaxHighlighter } from '@/components/assistant-ui/shiki-highlighter'
|
||||
import { Dialog, DialogContent } from '@/components/ui/dialog'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { notify, notifyError } from '@/store/notifications'
|
||||
|
||||
/**
|
||||
* 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
|
||||
|
||||
function stripReasoning(text: string): string {
|
||||
return text.replace(REASONING_BLOCK_RE, '')
|
||||
}
|
||||
|
||||
function CodeHeader({ language, code }: { language?: string; code?: string }) {
|
||||
const [copied, setCopied] = useState(false)
|
||||
|
||||
async function handleCopy() {
|
||||
if (!code) {
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
if (window.hermesDesktop?.writeClipboard) {
|
||||
await window.hermesDesktop.writeClipboard(code)
|
||||
} else if (navigator.clipboard?.writeText) {
|
||||
await navigator.clipboard.writeText(code)
|
||||
}
|
||||
|
||||
setCopied(true)
|
||||
setTimeout(() => setCopied(false), 1500)
|
||||
} catch {
|
||||
// Best-effort copy; silent failure is OK for a chat surface.
|
||||
}
|
||||
}
|
||||
|
||||
const label = language && language !== 'unknown' ? language : 'code'
|
||||
|
||||
return (
|
||||
<div className="mt-4 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}</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>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function imageFilename(src?: string): string {
|
||||
if (!src) {
|
||||
return 'image'
|
||||
}
|
||||
|
||||
try {
|
||||
const { pathname } = new URL(src, window.location.href)
|
||||
|
||||
return pathname.split('/').filter(Boolean).pop() || 'image'
|
||||
} catch {
|
||||
return src.split(/[\\/]/).filter(Boolean).pop() || 'image'
|
||||
}
|
||||
}
|
||||
|
||||
function isMissingIpcHandler(error: unknown): boolean {
|
||||
const message = error instanceof Error ? error.message : typeof error === 'string' ? error : ''
|
||||
|
||||
return message.includes("No handler registered for 'hermes:saveImageFromUrl'")
|
||||
}
|
||||
|
||||
async function startBrowserDownload(src: string) {
|
||||
const response = await fetch(src)
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Could not fetch image: ${response.status}`)
|
||||
}
|
||||
|
||||
const blobUrl = URL.createObjectURL(await response.blob())
|
||||
const link = document.createElement('a')
|
||||
link.href = blobUrl
|
||||
link.download = imageFilename(src)
|
||||
link.rel = 'noopener noreferrer'
|
||||
document.body.appendChild(link)
|
||||
link.click()
|
||||
link.remove()
|
||||
window.setTimeout(() => URL.revokeObjectURL(blobUrl), 30_000)
|
||||
}
|
||||
|
||||
const imageActionButtonClass =
|
||||
'absolute right-2 top-2 grid size-8 place-items-center rounded-full border border-border/70 bg-background/80 text-muted-foreground opacity-0 shadow-sm backdrop-blur transition-opacity hover:bg-accent hover:text-foreground focus-visible:opacity-100 disabled:opacity-50'
|
||||
|
||||
function MarkdownImage({ className, src, alt, ...props }: ComponentProps<'img'>) {
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [lightboxOpen, setLightboxOpen] = useState(false)
|
||||
const canOpen = Boolean(src)
|
||||
|
||||
async function handleDownload() {
|
||||
if (!src || saving) {
|
||||
return
|
||||
}
|
||||
|
||||
setSaving(true)
|
||||
|
||||
try {
|
||||
if (window.hermesDesktop?.saveImageFromUrl) {
|
||||
const saved = await window.hermesDesktop.saveImageFromUrl(src)
|
||||
|
||||
if (saved) {
|
||||
notify({
|
||||
kind: 'success',
|
||||
title: 'Image saved',
|
||||
message: imageFilename(src)
|
||||
})
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
await startBrowserDownload(src)
|
||||
} catch (error) {
|
||||
if (isMissingIpcHandler(error)) {
|
||||
try {
|
||||
await startBrowserDownload(src)
|
||||
notify({
|
||||
kind: 'info',
|
||||
title: 'Download started',
|
||||
message: 'Restart Hermes Desktop to use Save Image.'
|
||||
})
|
||||
} catch (fallbackError) {
|
||||
notifyError(fallbackError, 'Restart Hermes Desktop to save images')
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
notifyError(error, 'Image download failed')
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
function openLightbox() {
|
||||
if (canOpen) {
|
||||
setLightboxOpen(true)
|
||||
}
|
||||
}
|
||||
|
||||
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"
|
||||
showCloseButton={false}
|
||||
>
|
||||
<div className="group/lightbox relative max-h-[calc(100vh-2rem)] max-w-[calc(100vw-2rem)] overflow-auto">
|
||||
<img
|
||||
alt={alt ?? ''}
|
||||
className="block max-h-[calc(100vh-2rem)] max-w-full cursor-zoom-out select-auto rounded-lg object-contain shadow-2xl"
|
||||
onClick={() => setLightboxOpen(false)}
|
||||
src={src}
|
||||
/>
|
||||
<button
|
||||
aria-label={saving ? 'Saving image' : 'Download image'}
|
||||
className={cn(imageActionButtonClass, 'group-hover/lightbox:opacity-100')}
|
||||
disabled={saving}
|
||||
onClick={event => {
|
||||
event.stopPropagation()
|
||||
void handleDownload()
|
||||
}}
|
||||
title={saving ? 'Saving image' : 'Download image'}
|
||||
type="button"
|
||||
>
|
||||
<Download className={cn('size-4', saving && 'animate-pulse')} />
|
||||
</button>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
) : null
|
||||
|
||||
return (
|
||||
<>
|
||||
<span className="group/image relative my-3 inline-block max-w-full align-top" data-slot="aui_markdown-image">
|
||||
<button
|
||||
className="block max-w-full cursor-zoom-in bg-transparent p-0 text-left"
|
||||
disabled={!canOpen}
|
||||
onClick={openLightbox}
|
||||
title={canOpen ? 'Open image' : undefined}
|
||||
type="button"
|
||||
>
|
||||
<img alt={alt ?? ''} className={className} src={src} {...props} />
|
||||
</button>
|
||||
{src && (
|
||||
<button
|
||||
aria-label={saving ? 'Saving image' : 'Download image'}
|
||||
className={cn(imageActionButtonClass, 'group-hover/image:opacity-100')}
|
||||
disabled={saving}
|
||||
onClick={event => {
|
||||
event.stopPropagation()
|
||||
void handleDownload()
|
||||
}}
|
||||
title={saving ? 'Saving image' : 'Download image'}
|
||||
type="button"
|
||||
>
|
||||
<Download className={cn('size-4', saving && 'animate-pulse')} />
|
||||
</button>
|
||||
)}
|
||||
</span>
|
||||
{lightbox}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
const MarkdownTextImpl = () => {
|
||||
const components = useMemo(
|
||||
() =>
|
||||
({
|
||||
h1: ({ className, ...props }: ComponentProps<'h1'>) => (
|
||||
<h1 className={cn('text-xl font-semibold tracking-tight', className)} {...props} />
|
||||
),
|
||||
h2: ({ className, ...props }: ComponentProps<'h2'>) => (
|
||||
<h2 className={cn('text-lg font-semibold tracking-tight', className)} {...props} />
|
||||
),
|
||||
h3: ({ className, ...props }: ComponentProps<'h3'>) => (
|
||||
<h3 className={cn('text-base font-semibold', className)} {...props} />
|
||||
),
|
||||
h4: ({ className, ...props }: ComponentProps<'h4'>) => (
|
||||
<h4 className={cn('text-sm font-semibold', className)} {...props} />
|
||||
),
|
||||
p: ({ className, ...props }: ComponentProps<'p'>) => (
|
||||
<p className={cn('wrap-anywhere leading-relaxed', className)} {...props} />
|
||||
),
|
||||
a: ({ className, ...props }: ComponentProps<'a'>) => (
|
||||
<a
|
||||
className={cn(
|
||||
'font-medium text-foreground underline underline-offset-4 decoration-foreground/30 wrap-anywhere hover:decoration-foreground/70',
|
||||
className
|
||||
)}
|
||||
rel="noopener noreferrer"
|
||||
target="_blank"
|
||||
{...props}
|
||||
/>
|
||||
),
|
||||
hr: ({ className, ...props }: ComponentProps<'hr'>) => (
|
||||
<hr className={cn('border-border/70', className)} {...props} />
|
||||
),
|
||||
blockquote: ({ className, ...props }: ComponentProps<'blockquote'>) => (
|
||||
<blockquote
|
||||
className={cn('border-l-2 border-border pl-3 text-muted-foreground italic', className)}
|
||||
{...props}
|
||||
/>
|
||||
),
|
||||
ul: ({ className, ...props }: ComponentProps<'ul'>) => (
|
||||
<ul className={cn('list-disc marker:text-muted-foreground/70', className)} {...props} />
|
||||
),
|
||||
ol: ({ className, ...props }: ComponentProps<'ol'>) => (
|
||||
<ol className={cn('list-decimal marker:text-muted-foreground/70', className)} {...props} />
|
||||
),
|
||||
li: ({ className, ...props }: ComponentProps<'li'>) => (
|
||||
<li className={cn('leading-relaxed', className)} {...props} />
|
||||
),
|
||||
table: ({ className, ...props }: ComponentProps<'table'>) => (
|
||||
<div className="w-full overflow-x-auto rounded-md border border-border">
|
||||
<table
|
||||
className={cn(
|
||||
'w-full border-collapse text-sm [&_tr]:border-b [&_tr]:border-border last:[&_tr]:border-0',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</div>
|
||||
),
|
||||
thead: ({ className, ...props }: ComponentProps<'thead'>) => (
|
||||
<thead className={cn('bg-muted/50 text-foreground', className)} {...props} />
|
||||
),
|
||||
th: ({ className, ...props }: ComponentProps<'th'>) => (
|
||||
<th
|
||||
className={cn(
|
||||
'h-9 px-3 text-left align-middle text-xs font-medium uppercase tracking-wide text-muted-foreground',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
),
|
||||
td: ({ className, ...props }: ComponentProps<'td'>) => (
|
||||
<td className={cn('px-3 py-2 align-top text-sm leading-snug', className)} {...props} />
|
||||
),
|
||||
img: MarkdownImage,
|
||||
SyntaxHighlighter,
|
||||
CodeHeader
|
||||
}) as StreamdownTextComponents,
|
||||
[]
|
||||
)
|
||||
|
||||
return (
|
||||
<StreamdownTextPrimitive
|
||||
caret="block"
|
||||
components={components}
|
||||
containerClassName="aui-md text-foreground"
|
||||
lineNumbers={false}
|
||||
mode="streaming"
|
||||
parseIncompleteMarkdown
|
||||
plugins={{ code }}
|
||||
preprocess={stripReasoning}
|
||||
shikiTheme={['github-light-default', 'github-dark-default']}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export const MarkdownText = memo(MarkdownTextImpl)
|
||||
14
apps/desktop/src/components/assistant-ui/reasoning.tsx
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
'use client'
|
||||
|
||||
// Minimal reasoning stubs — not surfaced by the Hermes gateway yet.
|
||||
import { type ReactNode } from 'react'
|
||||
|
||||
export const ReasoningRoot = ({ children }: { children: ReactNode; defaultOpen?: boolean }) => (
|
||||
<div className="my-1">{children}</div>
|
||||
)
|
||||
export const ReasoningTrigger = (_props: { active?: boolean }) => null
|
||||
export const ReasoningContent = ({ children, 'aria-busy': _busy }: { children: ReactNode; 'aria-busy'?: boolean }) => (
|
||||
<div className="border-l-2 border-border pl-3 text-xs text-muted-foreground">{children}</div>
|
||||
)
|
||||
export const ReasoningText = ({ children }: { children: ReactNode }) => <div>{children}</div>
|
||||
export const Reasoning = (_props: object) => null
|
||||
|
|
@ -0,0 +1,48 @@
|
|||
'use client'
|
||||
|
||||
import type { SyntaxHighlighterProps } from '@assistant-ui/react-streamdown'
|
||||
import type { FC } from 'react'
|
||||
import ShikiHighlighter from 'react-shiki'
|
||||
|
||||
/**
|
||||
* assistant-ui's recommended `SyntaxHighlighter` slot.
|
||||
*
|
||||
* Uses the full `react-shiki` bundle so all `bundledLanguages` work
|
||||
* (rust, go, swift, kotlin, sql, etc.) — the `/web` subpath only ships
|
||||
* common web languages and silently falls back to plain text otherwise.
|
||||
*
|
||||
* Theme switching is automatic via the CSS `color-scheme` on `:root`
|
||||
* (set from the desktop theme provider).
|
||||
*
|
||||
* `showLanguage` is disabled because we render our own `CodeHeader`;
|
||||
* leaving it on causes the language to appear twice.
|
||||
*/
|
||||
export const SyntaxHighlighter: FC<SyntaxHighlighterProps> = ({
|
||||
components: { Pre, Code: _UnusedCode },
|
||||
language,
|
||||
code
|
||||
}) => {
|
||||
// Markdown fences include the pre-closing newline in `code`, which
|
||||
// Shiki tokenizes into a blank final line. Trim so the box ends on
|
||||
// real code.
|
||||
const trimmed = (code ?? '').trimEnd()
|
||||
|
||||
return (
|
||||
<Pre className="aui-shiki m-0 overflow-hidden rounded-b-md border border-t-0 border-border bg-card font-mono text-sm leading-relaxed [&_pre]:m-0 [&_pre]:overflow-x-auto [&_pre]:bg-transparent! [&_pre]:px-4 [&_pre]:py-3 [&_pre]:font-mono [&_pre]:leading-relaxed">
|
||||
<ShikiHighlighter
|
||||
addDefaultStyles={false}
|
||||
as="div"
|
||||
defaultColor="light-dark()"
|
||||
delay={120}
|
||||
language={language || 'text'}
|
||||
showLanguage={false}
|
||||
theme={{
|
||||
light: 'github-light-default',
|
||||
dark: 'github-dark-default'
|
||||
}}
|
||||
>
|
||||
{trimmed}
|
||||
</ShikiHighlighter>
|
||||
</Pre>
|
||||
)
|
||||
}
|
||||
118
apps/desktop/src/components/assistant-ui/streaming.test.tsx
Normal file
|
|
@ -0,0 +1,118 @@
|
|||
import { AssistantRuntimeProvider, type ThreadMessage, useExternalStoreRuntime } from '@assistant-ui/react'
|
||||
import { act, render, screen, waitFor } from '@testing-library/react'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { Thread } from './thread'
|
||||
|
||||
const createdAt = new Date('2026-05-01T00:00:00.000Z')
|
||||
|
||||
class TestResizeObserver {
|
||||
observe() {}
|
||||
unobserve() {}
|
||||
disconnect() {}
|
||||
}
|
||||
|
||||
vi.stubGlobal('ResizeObserver', TestResizeObserver)
|
||||
|
||||
Element.prototype.scrollTo = function scrollTo() {}
|
||||
|
||||
async function wait(ms: number) {
|
||||
await act(async () => {
|
||||
await new Promise(resolve => window.setTimeout(resolve, ms))
|
||||
})
|
||||
}
|
||||
|
||||
function userMessage(): ThreadMessage {
|
||||
return {
|
||||
id: 'user-1',
|
||||
role: 'user',
|
||||
content: [{ type: 'text', text: 'Stream a response' }],
|
||||
attachments: [],
|
||||
createdAt,
|
||||
metadata: { custom: {} }
|
||||
} as ThreadMessage
|
||||
}
|
||||
|
||||
function assistantMessage(text: string, running = true): ThreadMessage {
|
||||
return {
|
||||
id: 'assistant-1',
|
||||
role: 'assistant',
|
||||
content: [{ type: 'text', text }],
|
||||
status: running ? { type: 'running' } : { 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)
|
||||
|
||||
useEffect(() => {
|
||||
const first = window.setTimeout(() => {
|
||||
setMessages([userMessage(), assistantMessage('first chunk')])
|
||||
}, 50)
|
||||
|
||||
const second = window.setTimeout(() => {
|
||||
setMessages([userMessage(), assistantMessage('first chunk second chunk')])
|
||||
}, 500)
|
||||
|
||||
const complete = window.setTimeout(() => {
|
||||
setMessages([userMessage(), assistantMessage('first chunk second chunk', false)])
|
||||
setIsRunning(false)
|
||||
}, 700)
|
||||
|
||||
return () => {
|
||||
window.clearTimeout(first)
|
||||
window.clearTimeout(second)
|
||||
window.clearTimeout(complete)
|
||||
}
|
||||
}, [])
|
||||
|
||||
const runtime = useExternalStoreRuntime<ThreadMessage>({
|
||||
messages,
|
||||
isRunning,
|
||||
onNew: async () => {}
|
||||
})
|
||||
|
||||
return (
|
||||
<AssistantRuntimeProvider runtime={runtime}>
|
||||
<Thread loading={isRunning && messages.at(-1)?.role !== 'assistant' ? 'response' : undefined} />
|
||||
</AssistantRuntimeProvider>
|
||||
)
|
||||
}
|
||||
|
||||
describe('assistant-ui streaming renderer', () => {
|
||||
it('renders assistant text incrementally before completion', async () => {
|
||||
const { container } = render(<StreamingHarness />)
|
||||
|
||||
expect(screen.getByRole('status', { name: 'Hermes is loading a response' })).toBeTruthy()
|
||||
|
||||
await wait(80)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(container.textContent).toContain('first chunk')
|
||||
})
|
||||
expect(container.textContent).not.toContain('second chunk')
|
||||
expect(screen.queryByRole('status', { name: 'Hermes is loading a response' })).toBeNull()
|
||||
|
||||
await wait(500)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(container.textContent).toContain('first chunk second chunk')
|
||||
})
|
||||
|
||||
await wait(250)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(container.textContent).toContain('first chunk second chunk')
|
||||
})
|
||||
})
|
||||
})
|
||||
383
apps/desktop/src/components/assistant-ui/thread.tsx
Normal file
|
|
@ -0,0 +1,383 @@
|
|||
import {
|
||||
ActionBarPrimitive,
|
||||
AuiIf,
|
||||
BranchPickerPrimitive,
|
||||
ErrorPrimitive,
|
||||
MessagePrimitive,
|
||||
ThreadPrimitive,
|
||||
type ToolCallMessagePartProps,
|
||||
useAuiState
|
||||
} from '@assistant-ui/react'
|
||||
import { CheckIcon, ChevronLeftIcon, ChevronRightIcon, CopyIcon, LoaderCircleIcon, RefreshCwIcon } from 'lucide-react'
|
||||
import { type FC, type ReactNode, useCallback, useEffect, useRef, useState } from 'react'
|
||||
|
||||
import { formatElapsed, useElapsedSeconds } from '@/components/assistant-ui/activity-timer'
|
||||
import { 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'
|
||||
import { MarkdownText } from '@/components/assistant-ui/markdown-text'
|
||||
import { ToolFallback } from '@/components/assistant-ui/tool-fallback'
|
||||
import { TooltipIconButton } from '@/components/assistant-ui/tooltip-icon-button'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { setThreadScrolledUp } from '@/store/thread-scroll'
|
||||
|
||||
const THINKING_FACES = [
|
||||
'(。•́︿•̀。)',
|
||||
'(◔_◔)',
|
||||
'(¬‿¬)',
|
||||
'( •_•)>⌐■-■',
|
||||
'(⌐■_■)',
|
||||
'(´・_・`)',
|
||||
'◉_◉',
|
||||
'(°ロ°)',
|
||||
'( ˘⌣˘)♡',
|
||||
'ヽ(>∀<☆)☆',
|
||||
'٩(๑❛ᴗ❛๑)۶',
|
||||
'(⊙_⊙)',
|
||||
'(¬_¬)',
|
||||
'( ͡° ͜ʖ ͡°)',
|
||||
'ಠ_ಠ'
|
||||
]
|
||||
|
||||
const THINKING_VERBS = [
|
||||
'pondering',
|
||||
'contemplating',
|
||||
'musing',
|
||||
'cogitating',
|
||||
'ruminating',
|
||||
'deliberating',
|
||||
'mulling',
|
||||
'reflecting',
|
||||
'processing',
|
||||
'reasoning',
|
||||
'analyzing',
|
||||
'computing',
|
||||
'synthesizing',
|
||||
'formulating',
|
||||
'brainstorming'
|
||||
]
|
||||
|
||||
type ThreadLoadingState = 'response' | 'session'
|
||||
const BOTTOM_DISTANCE_PX = 24
|
||||
|
||||
function isNearBottom(el: HTMLElement): boolean {
|
||||
return el.scrollHeight - (el.scrollTop + el.clientHeight) <= BOTTOM_DISTANCE_PX
|
||||
}
|
||||
|
||||
export const Thread: FC<{
|
||||
intro?: IntroProps
|
||||
loading?: ThreadLoadingState
|
||||
}> = ({ intro, loading }) => {
|
||||
const [autoScroll, setAutoScroll] = useState(true)
|
||||
const previousLoading = useRef<ThreadLoadingState | undefined>(undefined)
|
||||
|
||||
const handleScroll = useCallback((event: React.UIEvent<HTMLDivElement>) => {
|
||||
const el = event.currentTarget
|
||||
const nearBottom = isNearBottom(el)
|
||||
setThreadScrolledUp(!nearBottom)
|
||||
|
||||
if (nearBottom) {
|
||||
setAutoScroll(true)
|
||||
}
|
||||
}, [])
|
||||
|
||||
const handleWheel = useCallback((event: React.WheelEvent<HTMLDivElement>) => {
|
||||
if (event.deltaY < 0) {
|
||||
setAutoScroll(false)
|
||||
}
|
||||
}, [])
|
||||
|
||||
const handlePointerDown = useCallback((event: React.PointerEvent<HTMLDivElement>) => {
|
||||
const rect = event.currentTarget.getBoundingClientRect()
|
||||
|
||||
if (event.clientX >= rect.right - 18) {
|
||||
setAutoScroll(false)
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (loading === 'response' && previousLoading.current !== 'response') {
|
||||
setAutoScroll(true)
|
||||
}
|
||||
|
||||
previousLoading.current = loading
|
||||
}, [loading])
|
||||
|
||||
useEffect(() => {
|
||||
return () => setThreadScrolledUp(false)
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<GeneratedImageProvider>
|
||||
<ThreadPrimitive.Root className="relative grid h-full min-h-0 grid-rows-[minmax(0,1fr)] overflow-hidden bg-transparent">
|
||||
<AuiIf condition={s => Boolean(intro) && s.thread.isEmpty}>{intro && <Intro {...intro} />}</AuiIf>
|
||||
|
||||
<ThreadPrimitive.Viewport
|
||||
autoScroll={autoScroll}
|
||||
className="h-full min-h-0 overflow-y-auto overscroll-contain px-[clamp(1rem,10%,12rem)] pb-32 pt-[calc(var(--vsq)*19)] scroll-smooth"
|
||||
data-slot="aui_thread-viewport"
|
||||
onPointerDown={handlePointerDown}
|
||||
onScroll={handleScroll}
|
||||
onWheel={handleWheel}
|
||||
>
|
||||
<div className="flex w-full flex-col gap-3">
|
||||
<ThreadPrimitive.Messages>{() => <ThreadMessage />}</ThreadPrimitive.Messages>
|
||||
{loading === 'response' && <ResponseLoadingIndicator />}
|
||||
</div>
|
||||
</ThreadPrimitive.Viewport>
|
||||
{loading === 'session' && <CenteredThreadSpinner />}
|
||||
</ThreadPrimitive.Root>
|
||||
</GeneratedImageProvider>
|
||||
)
|
||||
}
|
||||
|
||||
const CenteredThreadSpinner: FC = () => (
|
||||
<div
|
||||
aria-label="Loading session"
|
||||
className="pointer-events-none absolute inset-0 z-1 grid place-items-center"
|
||||
role="status"
|
||||
>
|
||||
<LoaderCircleIcon aria-hidden="true" className="size-5 animate-spin text-muted-foreground/70" />
|
||||
</div>
|
||||
)
|
||||
|
||||
const ThreadMessage: FC = () => {
|
||||
const role = useAuiState(s => s.message.role)
|
||||
const isEditing = useAuiState(s => s.message.composer.isEditing)
|
||||
|
||||
// The runtime synthesizes an empty assistant placeholder while isRunning is true
|
||||
// (last message is user). Rendering the full `MessagePrimitive.Root` for it adds
|
||||
// ~36px of invisible chrome (gap-2 + min-h-7 footer) which can push the
|
||||
// loading affordance too far below the user message. Skip it —
|
||||
// `ResponseLoadingIndicator` in the viewport handles the loading affordance directly.
|
||||
const isPlaceholder = useAuiState(
|
||||
s => s.message.role === 'assistant' && s.message.status?.type === 'running' && s.message.content.length === 0
|
||||
)
|
||||
|
||||
if (isEditing) {
|
||||
return <EditComposer />
|
||||
}
|
||||
|
||||
if (role === 'user') {
|
||||
return <UserMessage />
|
||||
}
|
||||
|
||||
if (isPlaceholder) {
|
||||
return null
|
||||
}
|
||||
|
||||
return <AssistantMessage />
|
||||
}
|
||||
|
||||
const AssistantMessage: FC = () => {
|
||||
return (
|
||||
<MessagePrimitive.Root
|
||||
className="group flex w-full flex-col gap-2 self-start"
|
||||
data-role="assistant"
|
||||
data-slot="aui_assistant-message-root"
|
||||
>
|
||||
<div className="wrap-anywhere text-pretty text-foreground" data-slot="aui_assistant-message-content">
|
||||
<MessagePrimitive.Parts
|
||||
components={{
|
||||
Text: MarkdownText,
|
||||
Reasoning: ReasoningPart,
|
||||
tools: { Fallback: ChainToolFallback }
|
||||
}}
|
||||
/>
|
||||
<MessagePrimitive.Error>
|
||||
<ErrorPrimitive.Root
|
||||
className="mt-2 rounded-md border border-destructive/20 bg-destructive/5 px-3 py-2 text-sm text-destructive"
|
||||
role="alert"
|
||||
>
|
||||
<ErrorPrimitive.Message />
|
||||
</ErrorPrimitive.Root>
|
||||
</MessagePrimitive.Error>
|
||||
</div>
|
||||
<div className="min-h-6">
|
||||
<AssistantFooter />
|
||||
</div>
|
||||
</MessagePrimitive.Root>
|
||||
)
|
||||
}
|
||||
|
||||
const ResponseLoadingIndicator: FC = () => {
|
||||
const [tick, setTick] = useState(0)
|
||||
const elapsed = useElapsedSeconds()
|
||||
|
||||
useEffect(() => {
|
||||
const id = window.setInterval(() => setTick(t => t + 1), 900)
|
||||
|
||||
return () => window.clearInterval(id)
|
||||
}, [])
|
||||
|
||||
const face = THINKING_FACES[tick % THINKING_FACES.length]
|
||||
const verb = THINKING_VERBS[tick % THINKING_VERBS.length]
|
||||
|
||||
return (
|
||||
<div
|
||||
aria-label="Hermes is loading a response"
|
||||
aria-live="polite"
|
||||
className="flex max-w-full items-center gap-2 self-start text-sm text-muted-foreground/70"
|
||||
role="status"
|
||||
>
|
||||
<span className="shimmer shimmer-repeat-delay-0 min-w-0 truncate text-muted-foreground/55">
|
||||
{face} {verb}…
|
||||
</span>
|
||||
<ActivityTimerBadge seconds={elapsed} tone={elapsed >= 20 ? 'warm' : 'muted'} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const ImageGenerateTool: FC<ToolCallMessagePartProps> = ({ result }) => {
|
||||
const generatedImage = useGeneratedImageContext()
|
||||
const running = result === undefined
|
||||
|
||||
useEffect(() => {
|
||||
generatedImage?.setPending(running)
|
||||
}, [generatedImage, running])
|
||||
|
||||
if (!running) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="mt-2">
|
||||
<ImageGenerationPlaceholder />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const ChainToolFallback: FC<ToolCallMessagePartProps> = props => {
|
||||
if (props.toolName === 'image_generate') {
|
||||
return <ImageGenerateTool {...props} />
|
||||
}
|
||||
|
||||
return <ToolFallback {...props} />
|
||||
}
|
||||
|
||||
const ThinkingDisclosure: FC<{
|
||||
children: ReactNode
|
||||
pending?: boolean
|
||||
}> = ({ children, pending = false }) => {
|
||||
const [open, setOpen] = useState(false)
|
||||
const elapsed = useElapsedSeconds(pending)
|
||||
|
||||
return (
|
||||
<div className="mb-3 text-sm text-muted-foreground">
|
||||
<button
|
||||
aria-expanded={open}
|
||||
className="inline-grid max-w-full grid-cols-[0.75rem_minmax(0,1fr)] items-center gap-1 rounded-md py-0.5 pr-1 text-left text-muted-foreground transition-colors hover:bg-accent 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>
|
||||
{pending && <ActivityTimerBadge seconds={elapsed} tone={elapsed >= 20 ? 'warm' : 'muted'} />}
|
||||
</button>
|
||||
{open && <div className="ml-4 mt-1 max-w-full wrap-anywhere border-l border-border pl-3">{children}</div>}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const ReasoningPart: FC<{ text: string; status?: { type: string } }> = ({ text, status }) => (
|
||||
<div className="mb-1 mt-1">
|
||||
<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'
|
||||
)}
|
||||
>
|
||||
{text}
|
||||
</div>
|
||||
</ThinkingDisclosure>
|
||||
</div>
|
||||
)
|
||||
|
||||
const AssistantActionBar: FC = () => (
|
||||
<div className="relative h-6 w-13 shrink-0">
|
||||
<ActionBarPrimitive.Root
|
||||
autohide="not-last"
|
||||
autohideFloat="always"
|
||||
className="absolute inset-0 flex gap-1 text-muted-foreground data-floating:opacity-0 data-floating:transition-opacity data-floating:duration-100 data-floating:group-hover:opacity-100 data-floating:focus-within:opacity-100"
|
||||
hideWhenRunning
|
||||
>
|
||||
<ActionBarPrimitive.Copy asChild copiedDuration={2000}>
|
||||
<TooltipIconButton className="group/copy" tooltip="Copy">
|
||||
<CopyIcon className="group-data-copied/copy:hidden" />
|
||||
<CheckIcon className="hidden group-data-copied/copy:block" />
|
||||
</TooltipIconButton>
|
||||
</ActionBarPrimitive.Copy>
|
||||
<ActionBarPrimitive.Reload asChild>
|
||||
<TooltipIconButton tooltip="Refresh">
|
||||
<RefreshCwIcon />
|
||||
</TooltipIconButton>
|
||||
</ActionBarPrimitive.Reload>
|
||||
</ActionBarPrimitive.Root>
|
||||
</div>
|
||||
)
|
||||
|
||||
const AssistantFooter: FC = () => {
|
||||
return (
|
||||
<div className="flex min-h-6 flex-col items-start gap-1">
|
||||
<BranchPickerPrimitive.Root
|
||||
className="inline-flex h-6 items-center gap-1 text-xs text-muted-foreground"
|
||||
hideWhenSingleBranch
|
||||
>
|
||||
<BranchPickerPrimitive.Previous className={branchButtonClass}>
|
||||
<ChevronLeftIcon className="size-3.5" />
|
||||
</BranchPickerPrimitive.Previous>
|
||||
<span className="tabular-nums">
|
||||
<BranchPickerPrimitive.Number /> / <BranchPickerPrimitive.Count />
|
||||
</span>
|
||||
<BranchPickerPrimitive.Next className={branchButtonClass}>
|
||||
<ChevronRightIcon className="size-3.5" />
|
||||
</BranchPickerPrimitive.Next>
|
||||
</BranchPickerPrimitive.Root>
|
||||
<AssistantActionBar />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
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 ActivityTimerBadge: FC<{ seconds: number; tone?: 'muted' | 'warm' }> = ({ seconds, tone = 'muted' }) => (
|
||||
<span
|
||||
className={cn(
|
||||
'shrink-0 rounded-full border px-1.5 py-0.5 font-mono text-[0.625rem] leading-none tabular-nums',
|
||||
tone === 'warm'
|
||||
? 'border-primary/20 bg-primary/8 text-primary'
|
||||
: 'border-border/70 bg-muted/40 text-muted-foreground/80'
|
||||
)}
|
||||
>
|
||||
{formatElapsed(seconds)}
|
||||
</span>
|
||||
)
|
||||
|
||||
const UserMessage: FC = () => {
|
||||
return (
|
||||
<MessagePrimitive.Root
|
||||
className="group flex max-w-[min(72%,34rem)] flex-col gap-2 self-end 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"
|
||||
data-role="user"
|
||||
data-slot="aui_user-message-root"
|
||||
>
|
||||
<div className="wrap-anywhere whitespace-pre-line leading-[1.48] text-foreground/95">
|
||||
<MessagePrimitive.Parts components={{ Text: DirectiveText }} />
|
||||
</div>
|
||||
</MessagePrimitive.Root>
|
||||
)
|
||||
}
|
||||
|
||||
const EditComposer: FC = () => {
|
||||
// Editing requires a real onEdit implementation against Hermes history.
|
||||
// Hide the edit composer until that contract is implemented.
|
||||
return null
|
||||
}
|
||||
190
apps/desktop/src/components/assistant-ui/tool-fallback.tsx
Normal file
|
|
@ -0,0 +1,190 @@
|
|||
'use client'
|
||||
|
||||
import { type ToolCallMessagePartProps } from '@assistant-ui/react'
|
||||
import { ChevronRight } from 'lucide-react'
|
||||
import { useEffect, useState } from 'react'
|
||||
|
||||
import { formatElapsed, useElapsedSeconds } from '@/components/assistant-ui/activity-timer'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
const TOOL_SPINNER_FRAMES = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏']
|
||||
|
||||
const TOOL_SPINNER_INTERVAL_MS = 80
|
||||
|
||||
function titleForTool(name: string): string {
|
||||
return (
|
||||
name
|
||||
.split('_')
|
||||
.filter(Boolean)
|
||||
.map(part => `${part[0]?.toUpperCase() ?? ''}${part.slice(1)}`)
|
||||
.join(' ') || name
|
||||
)
|
||||
}
|
||||
|
||||
function toolLabel(name: string, isPending: boolean): string {
|
||||
const labels: Record<string, { done: string; pending: string }> = {
|
||||
edit_file: { done: 'Edited file', pending: 'Editing file' },
|
||||
execute_code: { done: 'Ran code', pending: 'Running code' },
|
||||
image_generate: { done: 'Generated image', pending: 'Generating image' },
|
||||
list_files: { done: 'Listed files', pending: 'Listing files' },
|
||||
read_file: { done: 'Read file', pending: 'Reading file' },
|
||||
search_files: { done: 'Searched files', pending: 'Searching files' },
|
||||
session_search_recall: { done: 'Searched session history', pending: 'Searching session history' },
|
||||
terminal: { done: 'Ran command', pending: 'Running command' },
|
||||
todo: { done: 'Updated todos', pending: 'Updating todos' },
|
||||
web_extract: { done: 'Read webpage', pending: 'Reading webpage' },
|
||||
web_search: { done: 'Searched the web', pending: 'Searching the web' },
|
||||
write_file: { done: 'Edited file', pending: 'Editing file' }
|
||||
}
|
||||
|
||||
if (labels[name]) {
|
||||
return isPending ? labels[name].pending : labels[name].done
|
||||
}
|
||||
|
||||
return `${isPending ? 'Using' : 'Used'} ${titleForTool(name)}`
|
||||
}
|
||||
|
||||
function compactPreview(value: unknown, max = 72): string {
|
||||
const text =
|
||||
typeof value === 'string'
|
||||
? value
|
||||
: value && typeof value === 'object' && 'context' in value
|
||||
? String((value as { context?: unknown }).context ?? '')
|
||||
: ''
|
||||
|
||||
const oneLine = text.replace(/\s+/g, ' ').trim()
|
||||
|
||||
return oneLine.length > max ? `${oneLine.slice(0, max - 1)}…` : oneLine
|
||||
}
|
||||
|
||||
function shouldShowInlinePreview(toolName: string): boolean {
|
||||
return !['image_generate', 'terminal', 'execute_code'].includes(toolName)
|
||||
}
|
||||
|
||||
function contextValue(value: unknown): string {
|
||||
if (typeof value === 'string') {
|
||||
return value
|
||||
}
|
||||
|
||||
if (value && typeof value === 'object' && 'context' in value) {
|
||||
return String((value as { context?: unknown }).context ?? '')
|
||||
}
|
||||
|
||||
return ''
|
||||
}
|
||||
|
||||
function prettyJson(value: unknown): string {
|
||||
return typeof value === 'string' ? value : JSON.stringify(value, null, 2)
|
||||
}
|
||||
|
||||
function detailLabel(toolName: string): string {
|
||||
if (toolName === 'image_generate') {
|
||||
return 'Prompt'
|
||||
}
|
||||
|
||||
if (toolName === 'web_search') {
|
||||
return 'Query'
|
||||
}
|
||||
|
||||
if (toolName === 'web_extract') {
|
||||
return 'URL'
|
||||
}
|
||||
|
||||
if (toolName === 'terminal') {
|
||||
return 'Command'
|
||||
}
|
||||
|
||||
if (toolName === 'execute_code') {
|
||||
return 'Code'
|
||||
}
|
||||
|
||||
return 'Input'
|
||||
}
|
||||
|
||||
function detailText(args: unknown, result: unknown): string {
|
||||
const argContext = contextValue(args)
|
||||
const resultContext = contextValue(result)
|
||||
|
||||
if (resultContext && resultContext !== argContext) {
|
||||
return resultContext
|
||||
}
|
||||
|
||||
if (argContext) {
|
||||
return argContext
|
||||
}
|
||||
|
||||
if (result !== undefined) {
|
||||
return prettyJson(result)
|
||||
}
|
||||
|
||||
return prettyJson(args)
|
||||
}
|
||||
|
||||
export const ToolFallback = ({ toolName, args, result }: ToolCallMessagePartProps) => {
|
||||
const [open, setOpen] = useState(false)
|
||||
const isPending = result === undefined
|
||||
const [tick, setTick] = useState(0)
|
||||
const elapsed = useElapsedSeconds(isPending)
|
||||
const preview = compactPreview(args) || compactPreview(result)
|
||||
const label = toolLabel(toolName, isPending)
|
||||
const detail = detailText(args, result)
|
||||
const spinnerFrame = TOOL_SPINNER_FRAMES[tick % TOOL_SPINNER_FRAMES.length]
|
||||
|
||||
useEffect(() => {
|
||||
if (!isPending) {
|
||||
return
|
||||
}
|
||||
|
||||
const id = window.setInterval(() => setTick(value => value + 1), TOOL_SPINNER_INTERVAL_MS)
|
||||
|
||||
return () => window.clearInterval(id)
|
||||
}, [isPending])
|
||||
|
||||
return (
|
||||
<div className="mb-3 mt-1 text-sm text-muted-foreground">
|
||||
<button
|
||||
className="inline-grid max-w-full grid-cols-[0.75rem_minmax(0,auto)_minmax(0,1fr)_auto_auto] items-center gap-1 rounded-md py-0.5 pr-1 text-left text-muted-foreground transition-colors hover:bg-accent hover:text-foreground"
|
||||
onClick={() => setOpen(v => !v)}
|
||||
type="button"
|
||||
>
|
||||
<ChevronRight
|
||||
className={cn('shrink-0 text-muted-foreground/80 transition-transform', open && 'rotate-90')}
|
||||
size={12}
|
||||
/>
|
||||
<span
|
||||
className={cn('shrink-0 text-xs font-medium text-foreground/70', isPending && 'shimmer text-foreground/55')}
|
||||
>
|
||||
{label}
|
||||
</span>
|
||||
{preview && shouldShowInlinePreview(toolName) && (
|
||||
<span className="min-w-0 truncate text-xs text-muted-foreground/80">{preview}</span>
|
||||
)}
|
||||
{isPending ? (
|
||||
<span aria-label="Running" className="ml-1 w-3 shrink-0 text-center text-xs text-muted-foreground/80">
|
||||
{spinnerFrame}
|
||||
</span>
|
||||
) : null}
|
||||
{isPending && <ToolTimerBadge seconds={elapsed} />}
|
||||
</button>
|
||||
{open && (
|
||||
<div className="ml-4 mt-1 max-w-full whitespace-pre-wrap wrap-anywhere border-l border-border pl-3 text-xs leading-relaxed text-muted-foreground/85">
|
||||
<span className="mr-1 font-medium text-muted-foreground/70">{detailLabel(toolName)}:</span>
|
||||
{detail}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const ToolTimerBadge = ({ seconds }: { seconds: number }) => (
|
||||
<span
|
||||
className={cn(
|
||||
'shrink-0 rounded-full border px-1.5 py-0.5 font-mono text-[0.625rem] leading-none tabular-nums',
|
||||
seconds >= 15
|
||||
? 'border-primary/20 bg-primary/8 text-primary'
|
||||
: 'border-border/70 bg-muted/40 text-muted-foreground/80'
|
||||
)}
|
||||
>
|
||||
{formatElapsed(seconds)}
|
||||
</span>
|
||||
)
|
||||
9
apps/desktop/src/components/assistant-ui/tool-group.tsx
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
'use client'
|
||||
|
||||
import { type ReactNode } from 'react'
|
||||
|
||||
export const ToolGroupRoot = ({ children }: { children: ReactNode }) => (
|
||||
<div className="my-2 flex flex-col gap-1">{children}</div>
|
||||
)
|
||||
export const ToolGroupTrigger = (_props: { count?: number; active?: boolean }) => null
|
||||
export const ToolGroupContent = ({ children }: { children: ReactNode }) => <div>{children}</div>
|
||||
|
|
@ -0,0 +1,31 @@
|
|||
'use client'
|
||||
|
||||
import { type ComponentPropsWithRef, forwardRef } from 'react'
|
||||
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
export interface TooltipIconButtonProps extends ComponentPropsWithRef<typeof Button> {
|
||||
tooltip: string
|
||||
side?: 'top' | 'bottom' | 'left' | 'right'
|
||||
}
|
||||
|
||||
export const TooltipIconButton = forwardRef<HTMLButtonElement, TooltipIconButtonProps>(
|
||||
({ children, tooltip, side: _side = 'bottom', className, ...rest }, ref) => {
|
||||
return (
|
||||
<Button
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
{...rest}
|
||||
aria-label={tooltip}
|
||||
className={cn('aui-button-icon size-6 p-1', className)}
|
||||
ref={ref}
|
||||
title={tooltip}
|
||||
>
|
||||
{children}
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
TooltipIconButton.displayName = 'TooltipIconButton'
|
||||
829
apps/desktop/src/components/chat-bar.tsx
Normal file
|
|
@ -0,0 +1,829 @@
|
|||
import type { Unstable_TriggerAdapter, Unstable_TriggerItem } from '@assistant-ui/core'
|
||||
import {
|
||||
ComposerPrimitive,
|
||||
type Unstable_IconComponent,
|
||||
type Unstable_MentionCategory,
|
||||
type Unstable_MentionDirective,
|
||||
unstable_useMentionAdapter,
|
||||
useAui,
|
||||
useAuiState
|
||||
} from '@assistant-ui/react'
|
||||
import { useStore } from '@nanostores/react'
|
||||
import {
|
||||
ArrowUp,
|
||||
ChevronDown,
|
||||
Clipboard,
|
||||
FileText,
|
||||
FolderOpen,
|
||||
ImageIcon,
|
||||
Link,
|
||||
type LucideIcon,
|
||||
MessageSquareText,
|
||||
Mic,
|
||||
Plus,
|
||||
X
|
||||
} from 'lucide-react'
|
||||
import { type ClipboardEvent, type CSSProperties, useEffect, useMemo, useRef, useState } from 'react'
|
||||
|
||||
import { cn } from '../lib/utils'
|
||||
import { $composerAttachments, type ComposerAttachment } from '../store/composer'
|
||||
import { $threadScrolledUp } from '../store/thread-scroll'
|
||||
|
||||
import { hermesDirectiveFormatter } from './assistant-ui/directive-text'
|
||||
import { Button } from './ui/button'
|
||||
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from './ui/dialog'
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuSub,
|
||||
DropdownMenuSubContent,
|
||||
DropdownMenuSubTrigger,
|
||||
DropdownMenuTrigger
|
||||
} from './ui/dropdown-menu'
|
||||
import { Input } from './ui/input'
|
||||
|
||||
type ContextSuggestion = { text: string; display: string; meta?: string }
|
||||
|
||||
export type QuickModelOption = {
|
||||
provider: string
|
||||
providerName: string
|
||||
model: string
|
||||
}
|
||||
|
||||
export type ChatBarState = {
|
||||
model: {
|
||||
model: string
|
||||
provider: string
|
||||
canSwitch: boolean
|
||||
loading?: boolean
|
||||
quickModels?: QuickModelOption[]
|
||||
}
|
||||
tools: { enabled: boolean; label: string; suggestions?: ContextSuggestion[] }
|
||||
voice: { enabled: boolean; active: boolean }
|
||||
}
|
||||
|
||||
type ChatBarProps = {
|
||||
busy: boolean
|
||||
disabled: boolean
|
||||
focusKey?: string | null
|
||||
state: ChatBarState
|
||||
onCancel: () => void
|
||||
onAddContextRef?: (refText: string, label?: string, detail?: string) => void
|
||||
onAddUrl?: (url: string) => void
|
||||
onPasteClipboardImage?: () => void
|
||||
onPickFiles?: () => void
|
||||
onPickFolders?: () => void
|
||||
onPickImages?: () => void
|
||||
onRemoveAttachment?: (id: string) => void
|
||||
onSubmit: (value: string) => void
|
||||
}
|
||||
|
||||
// Stacked = controls drop below the textarea.
|
||||
const STACK_AT = 500
|
||||
const NARROW_VIEWPORT = '(max-width: 680px)'
|
||||
const EXPAND_HEIGHT_PX = 42
|
||||
|
||||
const SHELL =
|
||||
'absolute bottom-0 left-1/2 z-30 w-[min(calc(100%_-_1rem),clamp(26rem,78%,56rem))] max-w-full -translate-x-1/2'
|
||||
|
||||
const ICON_BTN = 'h-8 w-8 shrink-0 rounded-full'
|
||||
|
||||
const GHOST_ICON_BTN = cn(ICON_BTN, 'text-muted-foreground hover:bg-accent hover:text-foreground')
|
||||
|
||||
const COMPOSER_BACKDROP_STYLE = {
|
||||
backdropFilter: 'blur(.5rem) saturate(1.18)',
|
||||
WebkitBackdropFilter: 'blur(.5rem) saturate(1.18)'
|
||||
} satisfies CSSProperties
|
||||
|
||||
const ATTACHMENT_ICON: Record<ComposerAttachment['kind'], LucideIcon> = {
|
||||
folder: FolderOpen,
|
||||
url: Link,
|
||||
image: ImageIcon,
|
||||
file: FileText
|
||||
}
|
||||
|
||||
const DIRECTIVE_ICONS: Record<string, Unstable_IconComponent> = {
|
||||
file: FileText,
|
||||
folder: FolderOpen,
|
||||
image: ImageIcon,
|
||||
url: Link
|
||||
}
|
||||
|
||||
const DIRECTIVE_POPOVER_CLASS =
|
||||
'absolute bottom-24 left-1/2 z-50 w-[min(calc(100vw-1.5rem),28rem)] max-h-[min(28rem,calc(100vh-8rem))] -translate-x-1/2 overflow-y-auto overscroll-contain rounded-2xl border border-border/70 bg-popover p-1.5 text-popover-foreground shadow-2xl'
|
||||
|
||||
const PROMPT_SNIPPETS = [
|
||||
{
|
||||
label: 'Code review',
|
||||
text: 'Please review this for bugs, regressions, and missing tests.'
|
||||
},
|
||||
{
|
||||
label: 'Implementation plan',
|
||||
text: 'Please make a concise implementation plan before changing code.'
|
||||
},
|
||||
{
|
||||
label: 'Explain this',
|
||||
text: 'Please explain how this works and point me to the key files.'
|
||||
}
|
||||
]
|
||||
|
||||
const ASK_PLACEHOLDERS = [
|
||||
'Hey friend, what can I help with?',
|
||||
"What's on your mind? I'm here with you.",
|
||||
'Need a hand? We can take it one step at a time.',
|
||||
'Want to walk through this bug together?',
|
||||
"Share what you're working on and we'll figure it out.",
|
||||
"Tell me where you're stuck and I'll stay with you.",
|
||||
'Duck mode: gentle debugging, together.'
|
||||
]
|
||||
|
||||
const REF_ITEMS: Unstable_TriggerItem[] = [
|
||||
{
|
||||
id: 'file:',
|
||||
type: 'file',
|
||||
label: 'File',
|
||||
description: 'Attach a file path',
|
||||
metadata: { icon: 'file' }
|
||||
},
|
||||
{
|
||||
id: 'folder:',
|
||||
type: 'folder',
|
||||
label: 'Folder',
|
||||
description: 'Attach a folder path',
|
||||
metadata: { icon: 'folder' }
|
||||
},
|
||||
{
|
||||
id: 'url:',
|
||||
type: 'url',
|
||||
label: 'URL',
|
||||
description: 'Attach a web page',
|
||||
metadata: { icon: 'url' }
|
||||
},
|
||||
{
|
||||
id: 'image:',
|
||||
type: 'image',
|
||||
label: 'Image',
|
||||
description: 'Attach an image path',
|
||||
metadata: { icon: 'image' }
|
||||
}
|
||||
]
|
||||
|
||||
const EDGE_NEWLINES_RE = /^[\t ]*(?:\r\n|\r|\n)+|(?:\r\n|\r|\n)+[\t ]*$/g
|
||||
|
||||
function trimPastedEdgeNewlines(text: string): string {
|
||||
return text.replace(EDGE_NEWLINES_RE, '')
|
||||
}
|
||||
|
||||
export function ChatBar({
|
||||
busy,
|
||||
disabled,
|
||||
focusKey,
|
||||
state,
|
||||
onCancel,
|
||||
onAddContextRef,
|
||||
onAddUrl,
|
||||
onPasteClipboardImage,
|
||||
onPickFiles,
|
||||
onPickFolders,
|
||||
onPickImages,
|
||||
onRemoveAttachment,
|
||||
onSubmit
|
||||
}: ChatBarProps) {
|
||||
const aui = useAui()
|
||||
const draft = useAuiState(s => s.composer.text)
|
||||
const attachments = useStore($composerAttachments)
|
||||
const scrolledUp = useStore($threadScrolledUp)
|
||||
|
||||
const composerRef = useRef<HTMLFormElement | null>(null)
|
||||
const textareaRef = useRef<HTMLTextAreaElement | null>(null)
|
||||
const urlInputRef = useRef<HTMLInputElement | null>(null)
|
||||
|
||||
const [urlOpen, setUrlOpen] = useState(false)
|
||||
const [urlValue, setUrlValue] = useState('')
|
||||
const [expanded, setExpanded] = useState(false)
|
||||
const [stack, setStack] = useState(false)
|
||||
|
||||
const [askPlaceholder] = useState(
|
||||
() => ASK_PLACEHOLDERS[Math.floor(Math.random() * ASK_PLACEHOLDERS.length)] || 'Ask anything'
|
||||
)
|
||||
|
||||
const mentionCategories = useMemo(() => buildMentionCategories(state.tools.suggestions), [state.tools.suggestions])
|
||||
|
||||
const mention = unstable_useMentionAdapter({
|
||||
categories: mentionCategories,
|
||||
includeModelContextTools: false,
|
||||
formatter: hermesDirectiveFormatter,
|
||||
iconMap: DIRECTIVE_ICONS,
|
||||
fallbackIcon: FileText
|
||||
})
|
||||
|
||||
const stacked = expanded || stack
|
||||
const canSubmit = busy || draft.trim().length > 0 || attachments.length > 0
|
||||
|
||||
const focusInput = () => window.requestAnimationFrame(() => textareaRef.current?.focus())
|
||||
|
||||
useEffect(() => {
|
||||
if (!disabled) {
|
||||
focusInput()
|
||||
}
|
||||
}, [disabled, focusKey])
|
||||
|
||||
useEffect(() => {
|
||||
if (urlOpen) {
|
||||
window.requestAnimationFrame(() => urlInputRef.current?.focus())
|
||||
}
|
||||
}, [urlOpen])
|
||||
|
||||
useEffect(() => {
|
||||
if (!draft) {
|
||||
setExpanded(false)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
if (expanded) {
|
||||
return
|
||||
}
|
||||
|
||||
const wraps = (textareaRef.current?.scrollHeight ?? 0) > EXPAND_HEIGHT_PX
|
||||
|
||||
if (draft.includes('\n') || wraps) {
|
||||
setExpanded(true)
|
||||
}
|
||||
}, [draft, expanded])
|
||||
|
||||
useEffect(() => {
|
||||
const mq = window.matchMedia(NARROW_VIEWPORT)
|
||||
|
||||
const update = () => {
|
||||
const w = composerRef.current?.getBoundingClientRect().width ?? window.innerWidth
|
||||
|
||||
setStack(mq.matches || w < STACK_AT)
|
||||
}
|
||||
|
||||
update()
|
||||
mq.addEventListener('change', update)
|
||||
const ro = new ResizeObserver(update)
|
||||
|
||||
if (composerRef.current) {
|
||||
ro.observe(composerRef.current)
|
||||
}
|
||||
|
||||
return () => {
|
||||
mq.removeEventListener('change', update)
|
||||
ro.disconnect()
|
||||
}
|
||||
}, [])
|
||||
|
||||
const insertText = (text: string) => {
|
||||
const sep = draft && !draft.endsWith('\n') ? '\n' : ''
|
||||
aui.composer().setText(`${draft}${sep}${text}`)
|
||||
focusInput()
|
||||
}
|
||||
|
||||
const handlePaste = (event: ClipboardEvent<HTMLTextAreaElement>) => {
|
||||
const pastedText = event.clipboardData.getData('text')
|
||||
|
||||
if (!pastedText) {
|
||||
return
|
||||
}
|
||||
|
||||
const trimmedText = trimPastedEdgeNewlines(pastedText)
|
||||
|
||||
if (trimmedText === pastedText) {
|
||||
return
|
||||
}
|
||||
|
||||
event.preventDefault()
|
||||
const textarea = event.currentTarget
|
||||
const start = textarea.selectionStart
|
||||
const end = textarea.selectionEnd
|
||||
|
||||
const nextDraft = textarea.value.slice(0, start) + trimmedText + textarea.value.slice(end)
|
||||
|
||||
const cursor = start + trimmedText.length
|
||||
|
||||
aui.composer().setText(nextDraft)
|
||||
window.requestAnimationFrame(() => {
|
||||
const current = textareaRef.current
|
||||
|
||||
if (!current) {
|
||||
return
|
||||
}
|
||||
|
||||
current.focus()
|
||||
current.setSelectionRange(cursor, cursor)
|
||||
})
|
||||
}
|
||||
|
||||
const submitDraft = () => {
|
||||
if (busy) {
|
||||
onCancel()
|
||||
} else if (draft.trim() || attachments.length > 0) {
|
||||
onSubmit(draft)
|
||||
aui.composer().setText('')
|
||||
}
|
||||
|
||||
focusInput()
|
||||
}
|
||||
|
||||
const submitUrl = () => {
|
||||
const url = urlValue.trim()
|
||||
|
||||
if (!url) {
|
||||
return
|
||||
}
|
||||
|
||||
if (onAddUrl) {
|
||||
onAddUrl(url)
|
||||
} else {
|
||||
insertText(`@url:${url}`)
|
||||
}
|
||||
|
||||
setUrlValue('')
|
||||
setUrlOpen(false)
|
||||
}
|
||||
|
||||
const contextMenu = (
|
||||
<ContextMenu
|
||||
onAddContextRef={onAddContextRef}
|
||||
onInsertText={insertText}
|
||||
onOpenUrlDialog={() => setUrlOpen(true)}
|
||||
onPasteClipboardImage={onPasteClipboardImage}
|
||||
onPickFiles={onPickFiles}
|
||||
onPickFolders={onPickFolders}
|
||||
onPickImages={onPickImages}
|
||||
state={state}
|
||||
/>
|
||||
)
|
||||
|
||||
const controls = <ComposerControls busy={busy} canSubmit={canSubmit} disabled={disabled} state={state} />
|
||||
|
||||
const input = (
|
||||
<ComposerPrimitive.Input
|
||||
className={cn(
|
||||
'min-h-8 max-h-37.5 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-48 flex-1'
|
||||
)}
|
||||
disabled={disabled}
|
||||
onPaste={handlePaste}
|
||||
placeholder={disabled ? 'Starting Hermes...' : askPlaceholder}
|
||||
ref={textareaRef}
|
||||
rows={1}
|
||||
unstable_focusOnScrollToBottom={false}
|
||||
/>
|
||||
)
|
||||
|
||||
return (
|
||||
<>
|
||||
<ComposerPrimitive.Unstable_TriggerPopoverRoot>
|
||||
{mentionCategories.length > 0 && (
|
||||
<DirectivePopover
|
||||
adapter={mention.adapter}
|
||||
directive={mention.directive}
|
||||
fallbackIcon={mention.fallbackIcon ?? FileText}
|
||||
iconMap={mention.iconMap ?? DIRECTIVE_ICONS}
|
||||
/>
|
||||
)}
|
||||
<ComposerPrimitive.Root
|
||||
className={cn(SHELL, 'group/composer pb-4 pt-2')}
|
||||
onSubmit={e => {
|
||||
e.preventDefault()
|
||||
submitDraft()
|
||||
}}
|
||||
ref={composerRef}
|
||||
>
|
||||
<div className="pointer-events-none absolute inset-x-0 bottom-0 top-0 bg-linear-to-b from-transparent to-background/55" />
|
||||
<div className="relative w-full">
|
||||
<div
|
||||
aria-hidden="true"
|
||||
className="pointer-events-none absolute inset-0 rounded-[1.25rem] bg-card/1 transition-opacity duration-200 ease-out group-focus-within/composer:opacity-0"
|
||||
style={COMPOSER_BACKDROP_STYLE}
|
||||
/>
|
||||
<div
|
||||
aria-hidden="true"
|
||||
className={cn(
|
||||
'pointer-events-none absolute inset-0 rounded-[1.25rem] border border-input/70 bg-card/72 shadow-composer transition-[opacity,background-color,border-color,box-shadow] duration-200 ease-out group-focus-within/composer:border-ring/40 group-focus-within/composer:bg-card group-focus-within/composer:opacity-100 group-focus-within/composer:shadow-composer-focus',
|
||||
scrolledUp
|
||||
? 'opacity-60 group-hover/composer:opacity-100 group-focus-within/composer:opacity-100'
|
||||
: 'opacity-100'
|
||||
)}
|
||||
/>
|
||||
<div
|
||||
className={cn(
|
||||
'relative z-1 flex w-full flex-col gap-1.5 overflow-hidden rounded-[1.25rem] px-2 py-1.5 transition-opacity duration-200 ease-out',
|
||||
scrolledUp
|
||||
? 'opacity-60 group-hover/composer:opacity-100 group-focus-within/composer:opacity-100'
|
||||
: 'opacity-100'
|
||||
)}
|
||||
>
|
||||
{attachments.length > 0 && <AttachmentList attachments={attachments} onRemove={onRemoveAttachment} />}
|
||||
{stacked ? (
|
||||
<>
|
||||
{input}
|
||||
<div className="flex w-full items-center gap-1.5">
|
||||
{contextMenu}
|
||||
{controls}
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<div className="flex w-full items-end gap-1.5">
|
||||
{contextMenu}
|
||||
{input}
|
||||
{controls}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</ComposerPrimitive.Root>
|
||||
</ComposerPrimitive.Unstable_TriggerPopoverRoot>
|
||||
|
||||
<UrlDialog
|
||||
inputRef={urlInputRef}
|
||||
onChange={setUrlValue}
|
||||
onOpenChange={setUrlOpen}
|
||||
onSubmit={submitUrl}
|
||||
open={urlOpen}
|
||||
value={urlValue}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export function ChatBarFallback() {
|
||||
return (
|
||||
<div className={cn(SHELL, 'bg-linear-to-b from-transparent to-background/55 pb-4 pt-2')}>
|
||||
<div className="relative h-11 w-full">
|
||||
<div className="absolute inset-0 rounded-[1.25rem] bg-card/1" style={COMPOSER_BACKDROP_STYLE} />
|
||||
<div className="absolute inset-0 rounded-[1.25rem] border border-input/70 bg-card/72 shadow-composer" />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function ComposerControls({
|
||||
busy,
|
||||
canSubmit,
|
||||
disabled,
|
||||
state
|
||||
}: {
|
||||
busy: boolean
|
||||
canSubmit: boolean
|
||||
disabled: boolean
|
||||
state: ChatBarState
|
||||
}) {
|
||||
return (
|
||||
<div className="ml-auto flex shrink-0 items-center gap-1.5">
|
||||
<VoiceButton state={state.voice} />
|
||||
<Button
|
||||
aria-label={busy ? 'Stop' : 'Send'}
|
||||
className={cn(ICON_BTN, 'p-0')}
|
||||
disabled={disabled || !canSubmit}
|
||||
type="submit"
|
||||
>
|
||||
{busy ? <span className="block size-3 rounded-[0.1875rem] bg-current" /> : <ArrowUp size={18} />}
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function VoiceButton({ state }: { state: ChatBarState['voice'] }) {
|
||||
const aria = state.active ? 'Voice mode active' : 'Voice input'
|
||||
|
||||
return (
|
||||
<Button
|
||||
aria-label={aria}
|
||||
className={cn(GHOST_ICON_BTN, 'data-[active=true]:bg-accent data-[active=true]:text-foreground')}
|
||||
data-active={state.active}
|
||||
disabled={!state.enabled}
|
||||
size="icon"
|
||||
title={aria}
|
||||
type="button"
|
||||
variant="ghost"
|
||||
>
|
||||
<Mic size={16} />
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
|
||||
function ContextMenu({
|
||||
state,
|
||||
onAddContextRef,
|
||||
onInsertText,
|
||||
onOpenUrlDialog,
|
||||
onPasteClipboardImage,
|
||||
onPickFiles,
|
||||
onPickFolders,
|
||||
onPickImages
|
||||
}: {
|
||||
state: ChatBarState
|
||||
onAddContextRef?: (refText: string, label?: string, detail?: string) => void
|
||||
onInsertText: (text: string) => void
|
||||
onOpenUrlDialog: () => void
|
||||
onPasteClipboardImage?: () => void
|
||||
onPickFiles?: () => void
|
||||
onPickFolders?: () => void
|
||||
onPickImages?: () => void
|
||||
}) {
|
||||
const choose = (item: ContextSuggestion) =>
|
||||
onAddContextRef ? onAddContextRef(item.text, item.display, item.meta) : onInsertText(item.text)
|
||||
|
||||
const suggestions = state.tools.suggestions?.slice(0, 8) ?? []
|
||||
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
aria-label={state.tools.label}
|
||||
className={cn(GHOST_ICON_BTN, 'data-[state=open]:bg-accent data-[state=open]:text-foreground')}
|
||||
disabled={!state.tools.enabled}
|
||||
size="icon"
|
||||
title={state.tools.label}
|
||||
type="button"
|
||||
variant="ghost"
|
||||
>
|
||||
<Plus size={18} />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="start" className="w-64" side="top" sideOffset={10}>
|
||||
<DropdownMenuLabel className="text-xs text-muted-foreground">Add context</DropdownMenuLabel>
|
||||
<ContextMenuItem disabled={!onPickFiles} icon={FileText} onSelect={onPickFiles}>
|
||||
Files
|
||||
</ContextMenuItem>
|
||||
<ContextMenuItem disabled={!onPickFolders} icon={FolderOpen} onSelect={onPickFolders}>
|
||||
Folders
|
||||
</ContextMenuItem>
|
||||
<ContextMenuItem disabled={!onPickImages} icon={ImageIcon} onSelect={onPickImages}>
|
||||
Images
|
||||
</ContextMenuItem>
|
||||
<ContextMenuItem disabled={!onPasteClipboardImage} icon={Clipboard} onSelect={onPasteClipboardImage}>
|
||||
Image from clipboard
|
||||
</ContextMenuItem>
|
||||
<ContextMenuItem icon={Link} onSelect={onOpenUrlDialog}>
|
||||
URL
|
||||
</ContextMenuItem>
|
||||
|
||||
<DropdownMenuSeparator />
|
||||
|
||||
<DropdownMenuSub>
|
||||
<DropdownMenuSubTrigger>
|
||||
<FileText />
|
||||
<span>Suggested files</span>
|
||||
</DropdownMenuSubTrigger>
|
||||
<DropdownMenuSubContent className="w-72">
|
||||
{suggestions.length === 0 ? (
|
||||
<DropdownMenuItem disabled>
|
||||
<span className="text-muted-foreground">No suggestions</span>
|
||||
</DropdownMenuItem>
|
||||
) : (
|
||||
suggestions.map(item => (
|
||||
<DropdownMenuItem key={item.text} onSelect={() => choose(item)}>
|
||||
<FileText />
|
||||
<span className="min-w-0 flex-1 truncate">{item.display}</span>
|
||||
{item.meta && <span className="max-w-28 truncate text-xs text-muted-foreground">{item.meta}</span>}
|
||||
</DropdownMenuItem>
|
||||
))
|
||||
)}
|
||||
</DropdownMenuSubContent>
|
||||
</DropdownMenuSub>
|
||||
|
||||
<DropdownMenuSub>
|
||||
<DropdownMenuSubTrigger>
|
||||
<MessageSquareText />
|
||||
<span>Prompt snippets</span>
|
||||
</DropdownMenuSubTrigger>
|
||||
<DropdownMenuSubContent className="w-72">
|
||||
{PROMPT_SNIPPETS.map(snippet => (
|
||||
<ContextMenuItem icon={MessageSquareText} key={snippet.label} onSelect={() => onInsertText(snippet.text)}>
|
||||
{snippet.label}
|
||||
</ContextMenuItem>
|
||||
))}
|
||||
</DropdownMenuSubContent>
|
||||
</DropdownMenuSub>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)
|
||||
}
|
||||
|
||||
function ContextMenuItem({
|
||||
children,
|
||||
disabled,
|
||||
icon: Icon,
|
||||
onSelect
|
||||
}: {
|
||||
children: string
|
||||
disabled?: boolean
|
||||
icon: LucideIcon
|
||||
onSelect?: () => void
|
||||
}) {
|
||||
return (
|
||||
<DropdownMenuItem disabled={disabled} onSelect={onSelect}>
|
||||
<Icon />
|
||||
<span>{children}</span>
|
||||
</DropdownMenuItem>
|
||||
)
|
||||
}
|
||||
|
||||
function AttachmentList({
|
||||
attachments,
|
||||
onRemove
|
||||
}: {
|
||||
attachments: ComposerAttachment[]
|
||||
onRemove?: (id: string) => void
|
||||
}) {
|
||||
return (
|
||||
<div className="flex flex-wrap gap-1.5 px-1 pt-1">
|
||||
{attachments.map(a => (
|
||||
<AttachmentPill attachment={a} key={a.id} onRemove={onRemove} />
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function AttachmentPill({ attachment, onRemove }: { attachment: ComposerAttachment; onRemove?: (id: string) => void }) {
|
||||
const Icon = ATTACHMENT_ICON[attachment.kind]
|
||||
|
||||
return (
|
||||
<div className="group/attachment flex max-w-full items-center gap-2 rounded-2xl border border-border/70 bg-muted/35 py-1 pl-1 pr-1.5 text-xs text-foreground/90">
|
||||
{attachment.previewUrl ? (
|
||||
<img alt="" className="size-9 rounded-xl object-cover" draggable={false} src={attachment.previewUrl} />
|
||||
) : (
|
||||
<span className="grid size-9 shrink-0 place-items-center rounded-xl bg-background/70 text-muted-foreground">
|
||||
<Icon className="size-4" />
|
||||
</span>
|
||||
)}
|
||||
<span className="grid min-w-0 gap-0.5">
|
||||
<span className="truncate font-medium">{attachment.label}</span>
|
||||
{attachment.detail && (
|
||||
<span className="truncate text-[0.6875rem] text-muted-foreground">{attachment.detail}</span>
|
||||
)}
|
||||
</span>
|
||||
{onRemove && (
|
||||
<button
|
||||
aria-label={`Remove ${attachment.label}`}
|
||||
className="grid size-5 shrink-0 place-items-center rounded-full text-muted-foreground opacity-70 transition hover:bg-accent hover:text-foreground group-hover/attachment:opacity-100"
|
||||
onClick={() => onRemove(attachment.id)}
|
||||
type="button"
|
||||
>
|
||||
<X className="size-3.5" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function DirectivePopover({
|
||||
adapter,
|
||||
directive,
|
||||
fallbackIcon: Fallback,
|
||||
iconMap
|
||||
}: {
|
||||
adapter: Unstable_TriggerAdapter
|
||||
directive: Unstable_MentionDirective
|
||||
fallbackIcon: Unstable_IconComponent
|
||||
iconMap: Record<string, Unstable_IconComponent>
|
||||
}) {
|
||||
return (
|
||||
<ComposerPrimitive.Unstable_TriggerPopover adapter={adapter} char="@" className={DIRECTIVE_POPOVER_CLASS}>
|
||||
<ComposerPrimitive.Unstable_TriggerPopover.Directive {...directive} />
|
||||
<ComposerPrimitive.Unstable_TriggerPopoverCategories>
|
||||
{categories => (
|
||||
<div className="grid gap-1">
|
||||
{categories.map(c => (
|
||||
<ComposerPrimitive.Unstable_TriggerPopoverCategoryItem
|
||||
categoryId={c.id}
|
||||
className="flex w-full items-center justify-between rounded-xl px-3 py-2 text-left text-sm hover:bg-accent data-highlighted:bg-accent"
|
||||
key={c.id}
|
||||
>
|
||||
<span>{c.label}</span>
|
||||
<ChevronDown className="-rotate-90 size-3.5 text-muted-foreground" />
|
||||
</ComposerPrimitive.Unstable_TriggerPopoverCategoryItem>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</ComposerPrimitive.Unstable_TriggerPopoverCategories>
|
||||
<ComposerPrimitive.Unstable_TriggerPopoverItems>
|
||||
{items => (
|
||||
<div className="grid gap-1">
|
||||
<ComposerPrimitive.Unstable_TriggerPopoverBack className="mb-1 text-xs text-muted-foreground hover:text-foreground">
|
||||
Back
|
||||
</ComposerPrimitive.Unstable_TriggerPopoverBack>
|
||||
{items.map((item, index) => {
|
||||
const Icon = directiveIcon(item, iconMap, Fallback)
|
||||
|
||||
return (
|
||||
<ComposerPrimitive.Unstable_TriggerPopoverItem
|
||||
className="flex w-full items-center gap-2 rounded-xl px-3 py-2 text-left text-sm hover:bg-accent data-highlighted:bg-accent"
|
||||
index={index}
|
||||
item={item}
|
||||
key={`${item.type}:${item.id}`}
|
||||
>
|
||||
<Icon className="size-4 shrink-0 text-muted-foreground" />
|
||||
<span className="grid min-w-0 flex-1 gap-0.5">
|
||||
<span className="truncate font-medium">{item.label}</span>
|
||||
{item.description && (
|
||||
<span className="truncate text-xs text-muted-foreground">{item.description}</span>
|
||||
)}
|
||||
</span>
|
||||
</ComposerPrimitive.Unstable_TriggerPopoverItem>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</ComposerPrimitive.Unstable_TriggerPopoverItems>
|
||||
</ComposerPrimitive.Unstable_TriggerPopover>
|
||||
)
|
||||
}
|
||||
|
||||
function UrlDialog({
|
||||
inputRef,
|
||||
onChange,
|
||||
onOpenChange,
|
||||
onSubmit,
|
||||
open,
|
||||
value
|
||||
}: {
|
||||
inputRef: React.RefObject<HTMLInputElement | null>
|
||||
onChange: (value: string) => void
|
||||
onOpenChange: (open: boolean) => void
|
||||
onSubmit: () => void
|
||||
open: boolean
|
||||
value: string
|
||||
}) {
|
||||
return (
|
||||
<Dialog onOpenChange={onOpenChange} open={open}>
|
||||
<DialogContent className="max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Add URL Context</DialogTitle>
|
||||
<DialogDescription>
|
||||
Hermes will fetch this URL via the existing @url context resolver when you send the prompt.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<form
|
||||
className="grid gap-4"
|
||||
onSubmit={e => {
|
||||
e.preventDefault()
|
||||
onSubmit()
|
||||
}}
|
||||
>
|
||||
<Input
|
||||
onChange={e => onChange(e.target.value)}
|
||||
placeholder="https://example.com"
|
||||
ref={inputRef}
|
||||
value={value}
|
||||
/>
|
||||
<DialogFooter>
|
||||
<Button onClick={() => onOpenChange(false)} type="button" variant="ghost">
|
||||
Cancel
|
||||
</Button>
|
||||
<Button disabled={!value.trim()} type="submit">
|
||||
Add URL
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
|
||||
function buildMentionCategories(suggestions: ContextSuggestion[] | undefined): Unstable_MentionCategory[] {
|
||||
const items = (suggestions ?? [])
|
||||
.map(s => {
|
||||
const match = s.text.match(/^@(file|folder|url|image):(.+)$/)
|
||||
|
||||
if (!match) {
|
||||
return null
|
||||
}
|
||||
|
||||
const [, type, id] = match
|
||||
|
||||
return {
|
||||
id,
|
||||
type,
|
||||
label: s.display || id,
|
||||
description: s.meta,
|
||||
metadata: { icon: type }
|
||||
}
|
||||
})
|
||||
.filter((item): item is NonNullable<typeof item> => Boolean(item))
|
||||
|
||||
return [
|
||||
{ id: 'refs', label: 'Hermes refs', items: REF_ITEMS },
|
||||
...(items.length ? [{ id: 'context', label: 'Suggested files', items }] : [])
|
||||
]
|
||||
}
|
||||
|
||||
function directiveIcon(
|
||||
item: Unstable_TriggerItem,
|
||||
iconMap: Record<string, Unstable_IconComponent>,
|
||||
fallback: Unstable_IconComponent
|
||||
): Unstable_IconComponent {
|
||||
const meta = item.metadata as Record<string, unknown> | undefined
|
||||
const key = typeof meta?.icon === 'string' ? meta.icon : item.type
|
||||
|
||||
return iconMap[key] ?? iconMap[item.type] ?? fallback
|
||||
}
|
||||
216
apps/desktop/src/components/model-picker.tsx
Normal file
|
|
@ -0,0 +1,216 @@
|
|||
import { useQuery } from '@tanstack/react-query'
|
||||
import { useState } from 'react'
|
||||
|
||||
import type { ModelOptionProvider, ModelOptionsResponse } from '@/types/hermes'
|
||||
|
||||
import type { HermesGateway } from '../hermes'
|
||||
import { getGlobalModelOptions } from '../hermes'
|
||||
import { cn } from '../lib/utils'
|
||||
|
||||
import { InlineNotice } from './notifications'
|
||||
import { Button } from './ui/button'
|
||||
import { Checkbox } from './ui/checkbox'
|
||||
import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from './ui/command'
|
||||
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from './ui/dialog'
|
||||
import { Skeleton } from './ui/skeleton'
|
||||
|
||||
const pickerPanelClass = 'max-h-[85vh] max-w-2xl gap-0 overflow-hidden p-0'
|
||||
|
||||
interface ModelPickerDialogProps {
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
gw?: HermesGateway
|
||||
sessionId?: string | null
|
||||
currentModel: string
|
||||
currentProvider: string
|
||||
onSelect: (selection: { provider: string; model: string; persistGlobal: boolean }) => void
|
||||
}
|
||||
|
||||
export function ModelPickerDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
gw,
|
||||
sessionId,
|
||||
currentModel,
|
||||
currentProvider,
|
||||
onSelect
|
||||
}: ModelPickerDialogProps) {
|
||||
const [persistGlobal, setPersistGlobal] = useState(!sessionId)
|
||||
|
||||
const modelOptions = useQuery({
|
||||
queryKey: ['model-options', sessionId || 'global'],
|
||||
queryFn: () => {
|
||||
if (gw && sessionId) {
|
||||
return gw.request<ModelOptionsResponse>('model.options', {
|
||||
session_id: sessionId
|
||||
})
|
||||
}
|
||||
|
||||
return getGlobalModelOptions()
|
||||
},
|
||||
enabled: open
|
||||
})
|
||||
|
||||
const providers = modelOptions.data?.providers ?? []
|
||||
const optionsModel = String(modelOptions.data?.model ?? currentModel ?? '')
|
||||
const optionsProvider = String(modelOptions.data?.provider ?? currentProvider ?? '')
|
||||
const loading = modelOptions.isPending && !modelOptions.data
|
||||
|
||||
const error = modelOptions.error
|
||||
? modelOptions.error instanceof Error
|
||||
? modelOptions.error.message
|
||||
: String(modelOptions.error)
|
||||
: null
|
||||
|
||||
const selectModel = (provider: ModelOptionProvider, model: string) => {
|
||||
onSelect({
|
||||
provider: provider.slug,
|
||||
model,
|
||||
persistGlobal: persistGlobal || !sessionId
|
||||
})
|
||||
onOpenChange(false)
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog onOpenChange={onOpenChange} open={open}>
|
||||
<DialogContent className={pickerPanelClass}>
|
||||
<DialogHeader className="border-b border-border px-4 py-3">
|
||||
<DialogTitle>Switch model</DialogTitle>
|
||||
<DialogDescription className="font-mono text-xs leading-relaxed">
|
||||
current: {optionsModel || currentModel || '(unknown)'}
|
||||
{optionsProvider || currentProvider ? ` · ${optionsProvider || currentProvider}` : ''}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<Command className="rounded-none bg-card">
|
||||
<CommandInput autoFocus placeholder="Filter providers and models..." />
|
||||
<CommandList className="max-h-96">
|
||||
{!loading && !error && <CommandEmpty>No models found.</CommandEmpty>}
|
||||
<ModelResults
|
||||
currentModel={optionsModel || currentModel}
|
||||
currentProvider={optionsProvider || currentProvider}
|
||||
error={error}
|
||||
loading={loading}
|
||||
onSelectModel={selectModel}
|
||||
providers={providers}
|
||||
/>
|
||||
</CommandList>
|
||||
</Command>
|
||||
|
||||
<DialogFooter className="flex-row items-center justify-between gap-3 border-t border-border bg-card p-3 sm:justify-between">
|
||||
<label className="flex cursor-pointer select-none items-center gap-2 text-xs text-muted-foreground">
|
||||
<Checkbox
|
||||
checked={persistGlobal || !sessionId}
|
||||
disabled={!sessionId}
|
||||
onCheckedChange={checked => setPersistGlobal(checked === true)}
|
||||
/>
|
||||
{sessionId ? 'Persist globally (otherwise this session only)' : 'Persist globally'}
|
||||
</label>
|
||||
|
||||
<Button onClick={() => onOpenChange(false)} variant="outline">
|
||||
Cancel
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
|
||||
function ModelResults({
|
||||
loading,
|
||||
error,
|
||||
providers,
|
||||
currentModel,
|
||||
currentProvider,
|
||||
onSelectModel
|
||||
}: {
|
||||
loading: boolean
|
||||
error: string | null
|
||||
providers: ModelOptionProvider[]
|
||||
currentModel: string
|
||||
currentProvider: string
|
||||
onSelectModel: (provider: ModelOptionProvider, model: string) => void
|
||||
}) {
|
||||
if (loading) {
|
||||
return <LoadingResults />
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="px-3 py-3">
|
||||
<InlineNotice kind="error" title="Could not load models">
|
||||
{error}
|
||||
</InlineNotice>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (providers.length === 0) {
|
||||
return <div className="px-4 py-6 text-sm text-muted-foreground">No authenticated providers.</div>
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{providers.map(provider => {
|
||||
const models = provider.models ?? []
|
||||
|
||||
if (models.length === 0) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<CommandGroup heading={<ProviderHeading provider={provider} />} key={provider.slug}>
|
||||
{provider.warning && (
|
||||
<div className="px-2 pb-2">
|
||||
<InlineNotice className="px-2.5 py-1.5 text-xs" kind="warning">
|
||||
{provider.warning}
|
||||
</InlineNotice>
|
||||
</div>
|
||||
)}
|
||||
{models.map(model => {
|
||||
const isCurrent = model === currentModel && provider.slug === currentProvider
|
||||
|
||||
return (
|
||||
<CommandItem
|
||||
className={cn(
|
||||
'pl-6 font-mono',
|
||||
isCurrent &&
|
||||
'bg-primary text-primary-foreground data-[selected=true]:bg-primary data-[selected=true]:text-primary-foreground'
|
||||
)}
|
||||
key={`${provider.slug}:${model}`}
|
||||
onSelect={() => onSelectModel(provider, model)}
|
||||
value={`${provider.name} ${provider.slug} ${model}`}
|
||||
>
|
||||
<span className="min-w-0 flex-1 truncate">{model}</span>
|
||||
</CommandItem>
|
||||
)
|
||||
})}
|
||||
</CommandGroup>
|
||||
)
|
||||
})}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
function LoadingResults() {
|
||||
return (
|
||||
<CommandGroup heading={<Skeleton className="h-3 w-32" />}>
|
||||
{Array.from({ length: 4 }, (_, rowIndex) => (
|
||||
<div className="rounded-sm py-1.5 pl-6 pr-2" key={rowIndex}>
|
||||
<Skeleton className={cn('h-5', rowIndex % 3 === 0 ? 'w-3/5' : rowIndex % 3 === 1 ? 'w-4/5' : 'w-1/2')} />
|
||||
</div>
|
||||
))}
|
||||
</CommandGroup>
|
||||
)
|
||||
}
|
||||
|
||||
function ProviderHeading({ provider }: { provider: ModelOptionProvider }) {
|
||||
return (
|
||||
<span className="flex min-w-0 items-center gap-2">
|
||||
<span className="truncate">{provider.name}</span>
|
||||
<span className="font-mono text-xs font-normal normal-case tracking-normal text-muted-foreground">
|
||||
{provider.slug} · {provider.total_models ?? provider.models?.length ?? 0}
|
||||
</span>
|
||||
</span>
|
||||
)
|
||||
}
|
||||
139
apps/desktop/src/components/notifications.tsx
Normal file
|
|
@ -0,0 +1,139 @@
|
|||
import { useStore } from '@nanostores/react'
|
||||
import { AlertCircle, AlertTriangle, CheckCircle2, Info, type LucideIcon, X } from 'lucide-react'
|
||||
import { type ReactNode, useEffect, useState } from 'react'
|
||||
|
||||
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'
|
||||
import { cn } from '@/lib/utils'
|
||||
import {
|
||||
$notifications,
|
||||
type AppNotification,
|
||||
clearNotifications,
|
||||
dismissNotification,
|
||||
type NotificationKind
|
||||
} from '@/store/notifications'
|
||||
|
||||
const tone: Record<
|
||||
NotificationKind,
|
||||
{
|
||||
icon: LucideIcon
|
||||
variant: 'default' | 'destructive' | 'warning' | 'success'
|
||||
}
|
||||
> = {
|
||||
error: {
|
||||
icon: AlertCircle,
|
||||
variant: 'destructive'
|
||||
},
|
||||
warning: {
|
||||
icon: AlertTriangle,
|
||||
variant: 'warning'
|
||||
},
|
||||
info: {
|
||||
icon: Info,
|
||||
variant: 'default'
|
||||
},
|
||||
success: {
|
||||
icon: CheckCircle2,
|
||||
variant: 'success'
|
||||
}
|
||||
}
|
||||
|
||||
export function NotificationStack() {
|
||||
const notifications = useStore($notifications)
|
||||
const [expanded, setExpanded] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
if (notifications.length <= 1) {
|
||||
setExpanded(false)
|
||||
}
|
||||
}, [notifications.length])
|
||||
|
||||
if (notifications.length === 0) {
|
||||
return null
|
||||
}
|
||||
|
||||
const [latest, ...olderNotifications] = notifications
|
||||
const overflowCount = olderNotifications.length
|
||||
|
||||
return (
|
||||
<div
|
||||
aria-label="Notifications"
|
||||
className="pointer-events-none fixed left-1/2 top-[calc(var(--titlebar-height)+0.75rem)] z-1050 flex w-[min(32rem,calc(100vw-2rem))] -translate-x-1/2 flex-col gap-2"
|
||||
role="region"
|
||||
>
|
||||
<NotificationItem notification={latest} />
|
||||
{overflowCount > 0 && (
|
||||
<div className="pointer-events-auto flex min-h-8 items-center justify-between rounded-lg border border-border bg-card/80 px-3 text-xs text-muted-foreground shadow-xs">
|
||||
<button
|
||||
className="bg-transparent font-medium text-muted-foreground hover:text-foreground"
|
||||
onClick={() => setExpanded(value => !value)}
|
||||
type="button"
|
||||
>
|
||||
{expanded ? 'Hide' : 'Show'} {overflowCount} more {overflowCount === 1 ? 'notification' : 'notifications'}
|
||||
</button>
|
||||
<button
|
||||
className="bg-transparent text-muted-foreground hover:text-foreground"
|
||||
onClick={clearNotifications}
|
||||
type="button"
|
||||
>
|
||||
Clear all
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
{expanded &&
|
||||
olderNotifications.map(notification => <NotificationItem key={notification.id} notification={notification} />)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function NotificationItem({ notification }: { notification: AppNotification }) {
|
||||
const styles = tone[notification.kind]
|
||||
const Icon = styles.icon
|
||||
|
||||
return (
|
||||
<Alert
|
||||
aria-live={notification.kind === 'error' ? 'assertive' : 'polite'}
|
||||
className="pointer-events-auto grid-cols-[auto_minmax(0,1fr)_auto] pr-2.5 shadow-lg"
|
||||
role={notification.kind === 'error' ? 'alert' : 'status'}
|
||||
variant={styles.variant}
|
||||
>
|
||||
<Icon />
|
||||
<div className="col-start-2 min-w-0">
|
||||
{notification.title && <AlertTitle className="col-start-auto">{notification.title}</AlertTitle>}
|
||||
<AlertDescription className="col-start-auto">
|
||||
<p className="m-0">{notification.message}</p>
|
||||
</AlertDescription>
|
||||
</div>
|
||||
<button
|
||||
aria-label="Dismiss notification"
|
||||
className="col-start-3 -mr-1 grid size-6 place-items-center rounded-md bg-transparent text-muted-foreground transition-colors hover:bg-accent hover:text-foreground"
|
||||
onClick={() => dismissNotification(notification.id)}
|
||||
type="button"
|
||||
>
|
||||
<X className="size-3.5" />
|
||||
</button>
|
||||
</Alert>
|
||||
)
|
||||
}
|
||||
|
||||
export function InlineNotice({
|
||||
kind = 'info',
|
||||
title,
|
||||
children,
|
||||
className
|
||||
}: {
|
||||
kind?: NotificationKind
|
||||
title?: string
|
||||
children: ReactNode
|
||||
className?: string
|
||||
}) {
|
||||
const styles = tone[kind]
|
||||
const Icon = styles.icon
|
||||
|
||||
return (
|
||||
<Alert className={cn('min-w-0', className)} role={kind === 'error' ? 'alert' : 'status'} variant={styles.variant}>
|
||||
<Icon />
|
||||
{title && <AlertTitle>{title}</AlertTitle>}
|
||||
<AlertDescription className={cn(!title && 'row-start-1')}>{children}</AlertDescription>
|
||||
</Alert>
|
||||
)
|
||||
}
|
||||
307
apps/desktop/src/components/session-inspector.tsx
Normal file
|
|
@ -0,0 +1,307 @@
|
|||
'use client'
|
||||
|
||||
import { ChevronDown, FolderOpen, GitBranch, Pencil } from 'lucide-react'
|
||||
import { type FC, useEffect, useMemo, useRef, useState } from 'react'
|
||||
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuCheckboxItem,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger
|
||||
} from '@/components/ui/dropdown-menu'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
export type SessionInspectorProps = {
|
||||
open: boolean
|
||||
cwd: string
|
||||
branch: string
|
||||
busy: boolean
|
||||
modelLabel: string
|
||||
modelTitle?: string
|
||||
providerName?: string
|
||||
personality: string
|
||||
personalities: string[]
|
||||
onChangeCwd?: (cwd: string) => void
|
||||
onBrowseCwd?: () => void
|
||||
onOpenModelPicker?: () => void
|
||||
onSelectPersonality?: (name: string) => void
|
||||
}
|
||||
|
||||
export const SESSION_INSPECTOR_WIDTH = '14rem'
|
||||
|
||||
// Quiet button-like row: invisible until hovered/focused.
|
||||
const quietControl =
|
||||
'rounded-md border border-transparent bg-transparent 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'
|
||||
|
||||
// Bleed interactive rows leftwards by 6px so the hover ring doesn't look
|
||||
// indented relative to the section labels above them.
|
||||
const bleed = '-ml-1.5 w-[calc(100%_+_0.375rem)]'
|
||||
|
||||
const disabledRow = 'disabled:cursor-default disabled:hover:border-transparent disabled:hover:bg-transparent'
|
||||
|
||||
export const SessionInspector: FC<SessionInspectorProps> = ({
|
||||
open,
|
||||
cwd,
|
||||
branch,
|
||||
busy,
|
||||
modelLabel,
|
||||
modelTitle,
|
||||
providerName,
|
||||
personality,
|
||||
personalities,
|
||||
onChangeCwd,
|
||||
onBrowseCwd,
|
||||
onOpenModelPicker,
|
||||
onSelectPersonality
|
||||
}) => (
|
||||
<aside
|
||||
aria-hidden={!open}
|
||||
className={cn(
|
||||
'relative flex h-screen 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-[opacity,transform] duration-300 ease-[cubic-bezier(0.22,1,0.36,1)]',
|
||||
open ? 'translate-x-0 opacity-100' : 'pointer-events-none translate-x-2 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">
|
||||
<WorkspaceSection branch={branch} busy={busy} cwd={cwd} onBrowseCwd={onBrowseCwd} onChangeCwd={onChangeCwd} />
|
||||
<AgentSection
|
||||
current={personality}
|
||||
label={modelLabel}
|
||||
onOpen={onOpenModelPicker}
|
||||
onSelect={onSelectPersonality}
|
||||
options={personalities}
|
||||
providerName={providerName}
|
||||
title={modelTitle}
|
||||
/>
|
||||
</div>
|
||||
</aside>
|
||||
)
|
||||
|
||||
function SectionLabel({ children }: { children: React.ReactNode }) {
|
||||
return <div className="text-xs font-medium text-muted-foreground/90">{children}</div>
|
||||
}
|
||||
|
||||
function WorkspaceSection({
|
||||
cwd,
|
||||
branch,
|
||||
busy,
|
||||
onChangeCwd,
|
||||
onBrowseCwd
|
||||
}: {
|
||||
cwd: string
|
||||
branch: string
|
||||
busy: boolean
|
||||
onChangeCwd?: (cwd: string) => void
|
||||
onBrowseCwd?: () => void
|
||||
}) {
|
||||
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 (
|
||||
<section className="grid gap-1.5 py-1.5">
|
||||
<SectionLabel>cwd</SectionLabel>
|
||||
{editing ? (
|
||||
<Input
|
||||
className="h-7 bg-background px-2 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={cn(
|
||||
quietControl,
|
||||
'group grid w-full min-w-0 grid-cols-[auto_minmax(0,1fr)_auto] items-center gap-1 px-1.5 py-1 font-mono text-[0.6875rem] text-foreground/75'
|
||||
)}
|
||||
>
|
||||
<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={cn(quietControl, bleed, 'flex min-w-0 items-center gap-1 px-1.5 py-1 text-[0.6875rem]')}>
|
||||
<GitBranch className="size-3 shrink-0 text-muted-foreground/60" />
|
||||
<span className="min-w-0 truncate font-mono text-foreground/75">{branchLabel}</span>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
function AgentSection({
|
||||
label: modelLabel,
|
||||
onOpen,
|
||||
providerName,
|
||||
current,
|
||||
options,
|
||||
onSelect
|
||||
}: {
|
||||
label: string
|
||||
title?: string
|
||||
providerName?: string
|
||||
onOpen?: () => void
|
||||
current: string
|
||||
options: string[]
|
||||
onSelect?: (name: string) => void
|
||||
}) {
|
||||
const [open, setOpen] = useState(false)
|
||||
|
||||
const merged = useMemo(
|
||||
() => [...new Set(['default', ...options, current].map(s => s?.trim().toLowerCase()).filter(Boolean))],
|
||||
[current, options]
|
||||
)
|
||||
|
||||
const activeKey = (current || 'default').trim().toLowerCase()
|
||||
const personalityLabel = current ? titleize(current) : 'Default'
|
||||
|
||||
return (
|
||||
<section className="grid gap-1.5 py-1.5">
|
||||
<SectionLabel>Agent</SectionLabel>
|
||||
<button
|
||||
aria-label="Change model"
|
||||
className={cn(quietControl, bleed, disabledRow, 'group grid gap-px px-1.5 py-1 text-left')}
|
||||
disabled={!onOpen}
|
||||
onClick={onOpen}
|
||||
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">
|
||||
{modelLabel || 'Hermes'}
|
||||
</span>
|
||||
{onOpen && (
|
||||
<ChevronDown className="size-3 shrink-0 text-muted-foreground/60 opacity-0 transition-opacity group-hover:opacity-100" />
|
||||
)}
|
||||
</span>
|
||||
{providerName && <span className="truncate text-[0.625rem] text-muted-foreground/70">{providerName}</span>}
|
||||
</button>
|
||||
<DropdownMenu onOpenChange={setOpen} open={open}>
|
||||
<DropdownMenuTrigger asChild disabled={!onSelect}>
|
||||
<button
|
||||
aria-label="Change personality"
|
||||
className={cn(quietControl, bleed, disabledRow, 'group flex items-center gap-1.5 px-1.5 py-1 text-left')}
|
||||
type="button"
|
||||
>
|
||||
<span className="min-w-0 flex-1 truncate text-[0.6875rem] text-muted-foreground group-hover:text-foreground group-focus-visible:text-foreground">
|
||||
{personalityLabel}
|
||||
</span>
|
||||
{onSelect && (
|
||||
<ChevronDown className="size-3 shrink-0 text-muted-foreground/60 opacity-0 transition-opacity group-hover:opacity-100" />
|
||||
)}
|
||||
</button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent
|
||||
align="end"
|
||||
className="w-52 border-border/70 bg-popover/95 shadow-md"
|
||||
side="bottom"
|
||||
sideOffset={6}
|
||||
>
|
||||
<DropdownMenuLabel className="text-xs text-muted-foreground">Personality</DropdownMenuLabel>
|
||||
<DropdownMenuSeparator />
|
||||
{merged.map(name => (
|
||||
<DropdownMenuCheckboxItem
|
||||
checked={activeKey === name}
|
||||
className="text-xs text-muted-foreground focus:text-foreground"
|
||||
key={name}
|
||||
onSelect={e => {
|
||||
e.preventDefault()
|
||||
onSelect?.(name)
|
||||
setOpen(false)
|
||||
}}
|
||||
>
|
||||
{titleize(name)}
|
||||
</DropdownMenuCheckboxItem>
|
||||
))}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
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('/')}`
|
||||
}
|
||||
|
||||
function titleize(value: string): string {
|
||||
return value
|
||||
.replace(/[-_]+/g, ' ')
|
||||
.replace(/\s+/g, ' ')
|
||||
.trim()
|
||||
.replace(/(^|\s)\S/g, m => m.toUpperCase())
|
||||
}
|
||||
1775
apps/desktop/src/components/settings-page.tsx
Normal file
53
apps/desktop/src/components/ui/alert.tsx
Normal file
|
|
@ -0,0 +1,53 @@
|
|||
import { cva, type VariantProps } from 'class-variance-authority'
|
||||
import * as React from 'react'
|
||||
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
const alertVariants = cva(
|
||||
'relative grid w-full grid-cols-[auto_minmax(0,1fr)] items-start gap-x-3 gap-y-1 rounded-lg border bg-card px-4 py-3 text-sm text-card-foreground shadow-xs [&>svg]:mt-0.5 [&>svg]:size-4 [&>svg]:shrink-0',
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: 'border-border',
|
||||
destructive:
|
||||
'border-destructive/35 bg-[color-mix(in_srgb,var(--dt-card)_96%,var(--dt-destructive)_4%)] [&>svg]:text-destructive',
|
||||
warning:
|
||||
'border-primary/30 bg-[color-mix(in_srgb,var(--dt-card)_96%,var(--dt-primary)_4%)] [&>svg]:text-primary',
|
||||
success:
|
||||
'border-primary/25 bg-[color-mix(in_srgb,var(--dt-card)_97%,var(--dt-primary)_3%)] [&>svg]:text-primary'
|
||||
}
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: 'default'
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
function Alert({ className, variant, ...props }: React.ComponentProps<'div'> & VariantProps<typeof alertVariants>) {
|
||||
return <div className={cn(alertVariants({ variant }), className)} data-slot="alert" role="alert" {...props} />
|
||||
}
|
||||
|
||||
function AlertTitle({ className, ...props }: React.ComponentProps<'div'>) {
|
||||
return (
|
||||
<div
|
||||
className={cn('col-start-2 line-clamp-1 min-h-4 font-medium tracking-tight text-foreground', className)}
|
||||
data-slot="alert-title"
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function AlertDescription({ className, ...props }: React.ComponentProps<'div'>) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'col-start-2 grid justify-items-start gap-1 text-muted-foreground [&_p]:leading-relaxed',
|
||||
className
|
||||
)}
|
||||
data-slot="alert-description"
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Alert, AlertDescription, AlertTitle }
|
||||
62
apps/desktop/src/components/ui/button.tsx
Normal file
|
|
@ -0,0 +1,62 @@
|
|||
import { cva, type VariantProps } from 'class-variance-authority'
|
||||
import { Slot } from 'radix-ui'
|
||||
import * as React from 'react'
|
||||
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
const buttonVariants = cva(
|
||||
"inline-flex shrink-0 items-center justify-center gap-2 rounded-md text-sm font-medium whitespace-nowrap transition-all outline-none focus-visible:border-ring focus-visible:ring-[0.1875rem] focus-visible:ring-ring/50 disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: 'bg-primary text-primary-foreground hover:bg-primary/90',
|
||||
destructive:
|
||||
'bg-destructive text-white hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:bg-destructive/60 dark:focus-visible:ring-destructive/40',
|
||||
outline:
|
||||
'border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:border-input dark:bg-input/30 dark:hover:bg-input/50',
|
||||
secondary: 'bg-secondary text-secondary-foreground hover:bg-secondary/80',
|
||||
ghost: 'hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50',
|
||||
link: 'text-primary underline-offset-4 hover:underline'
|
||||
},
|
||||
size: {
|
||||
default: 'h-9 px-4 py-2 has-[>svg]:px-3',
|
||||
xs: "h-6 gap-1 rounded-md px-2 text-xs has-[>svg]:px-1.5 [&_svg:not([class*='size-'])]:size-3",
|
||||
sm: 'h-8 gap-1.5 rounded-md px-3 has-[>svg]:px-2.5',
|
||||
lg: 'h-10 rounded-md px-6 has-[>svg]:px-4',
|
||||
icon: 'size-9',
|
||||
'icon-xs': "size-6 rounded-md [&_svg:not([class*='size-'])]:size-3",
|
||||
'icon-sm': 'size-8',
|
||||
'icon-lg': 'size-10'
|
||||
}
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: 'default',
|
||||
size: 'default'
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
function Button({
|
||||
className,
|
||||
variant = 'default',
|
||||
size = 'default',
|
||||
asChild = false,
|
||||
...props
|
||||
}: React.ComponentProps<'button'> &
|
||||
VariantProps<typeof buttonVariants> & {
|
||||
asChild?: boolean
|
||||
}) {
|
||||
const Comp = asChild ? Slot.Root : 'button'
|
||||
|
||||
return (
|
||||
<Comp
|
||||
className={cn(buttonVariants({ variant, size, className }))}
|
||||
data-size={size}
|
||||
data-slot="button"
|
||||
data-variant={variant}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Button, buttonVariants }
|
||||
27
apps/desktop/src/components/ui/checkbox.tsx
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
import { CheckIcon } from 'lucide-react'
|
||||
import { Checkbox as CheckboxPrimitive } from 'radix-ui'
|
||||
import * as React from 'react'
|
||||
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
function Checkbox({ className, ...props }: React.ComponentProps<typeof CheckboxPrimitive.Root>) {
|
||||
return (
|
||||
<CheckboxPrimitive.Root
|
||||
className={cn(
|
||||
'peer size-4 shrink-0 rounded-sm border border-input shadow-xs outline-none transition-shadow focus-visible:border-ring focus-visible:ring-2 focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:border-primary data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40',
|
||||
className
|
||||
)}
|
||||
data-slot="checkbox"
|
||||
{...props}
|
||||
>
|
||||
<CheckboxPrimitive.Indicator
|
||||
className="flex items-center justify-center text-current"
|
||||
data-slot="checkbox-indicator"
|
||||
>
|
||||
<CheckIcon className="size-3.5" />
|
||||
</CheckboxPrimitive.Indicator>
|
||||
</CheckboxPrimitive.Root>
|
||||
)
|
||||
}
|
||||
|
||||
export { Checkbox }
|
||||
111
apps/desktop/src/components/ui/command.tsx
Normal file
|
|
@ -0,0 +1,111 @@
|
|||
import { Command as CommandPrimitive } from 'cmdk'
|
||||
import { SearchIcon } from 'lucide-react'
|
||||
import * as React from 'react'
|
||||
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
function Command({ className, ...props }: React.ComponentProps<typeof CommandPrimitive>) {
|
||||
return (
|
||||
<CommandPrimitive
|
||||
className={cn(
|
||||
'flex h-full w-full flex-col overflow-hidden rounded-md bg-popover text-popover-foreground',
|
||||
className
|
||||
)}
|
||||
data-slot="command"
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CommandInput({ className, ...props }: React.ComponentProps<typeof CommandPrimitive.Input>) {
|
||||
return (
|
||||
<div className="flex h-11 items-center gap-2 border-b border-border px-3" data-slot="command-input-wrapper">
|
||||
<SearchIcon className="size-4 shrink-0 text-muted-foreground" />
|
||||
<CommandPrimitive.Input
|
||||
className={cn(
|
||||
'flex h-10 w-full rounded-md bg-transparent py-3 text-sm outline-none placeholder:text-muted-foreground disabled:cursor-not-allowed disabled:opacity-50',
|
||||
className
|
||||
)}
|
||||
data-slot="command-input"
|
||||
{...props}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function CommandList({ className, ...props }: React.ComponentProps<typeof CommandPrimitive.List>) {
|
||||
return (
|
||||
<CommandPrimitive.List
|
||||
className={cn('max-h-100 overflow-y-auto overflow-x-hidden', className)}
|
||||
data-slot="command-list"
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CommandEmpty({ ...props }: React.ComponentProps<typeof CommandPrimitive.Empty>) {
|
||||
return (
|
||||
<CommandPrimitive.Empty
|
||||
className="py-6 text-center text-sm text-muted-foreground"
|
||||
data-slot="command-empty"
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CommandGroup({ className, ...props }: React.ComponentProps<typeof CommandPrimitive.Group>) {
|
||||
return (
|
||||
<CommandPrimitive.Group
|
||||
className={cn(
|
||||
'overflow-hidden p-1 text-foreground **:[[cmdk-group-heading]]:sticky **:[[cmdk-group-heading]]:top-0 **:[[cmdk-group-heading]]:z-10 **:[[cmdk-group-heading]]:bg-popover **:[[cmdk-group-heading]]:px-2 **:[[cmdk-group-heading]]:py-1.5 **:[[cmdk-group-heading]]:text-xs **:[[cmdk-group-heading]]:font-medium **:[[cmdk-group-heading]]:text-muted-foreground',
|
||||
className
|
||||
)}
|
||||
data-slot="command-group"
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CommandSeparator({ className, ...props }: React.ComponentProps<typeof CommandPrimitive.Separator>) {
|
||||
return (
|
||||
<CommandPrimitive.Separator
|
||||
className={cn('-mx-1 h-px bg-border', className)}
|
||||
data-slot="command-separator"
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CommandItem({ className, ...props }: React.ComponentProps<typeof CommandPrimitive.Item>) {
|
||||
return (
|
||||
<CommandPrimitive.Item
|
||||
className={cn(
|
||||
'relative flex cursor-default select-none items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-none data-[disabled=true]:pointer-events-none data-[selected=true]:bg-accent data-[selected=true]:text-accent-foreground data-[disabled=true]:opacity-50',
|
||||
className
|
||||
)}
|
||||
data-slot="command-item"
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CommandShortcut({ className, ...props }: React.ComponentProps<'span'>) {
|
||||
return (
|
||||
<span
|
||||
className={cn('ml-auto text-xs tracking-widest text-muted-foreground', className)}
|
||||
data-slot="command-shortcut"
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
Command,
|
||||
CommandEmpty,
|
||||
CommandGroup,
|
||||
CommandInput,
|
||||
CommandItem,
|
||||
CommandList,
|
||||
CommandSeparator,
|
||||
CommandShortcut
|
||||
}
|
||||
121
apps/desktop/src/components/ui/dialog.tsx
Normal file
|
|
@ -0,0 +1,121 @@
|
|||
import { XIcon } from 'lucide-react'
|
||||
import { Dialog as DialogPrimitive } from 'radix-ui'
|
||||
import * as React from 'react'
|
||||
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
function Dialog({ ...props }: React.ComponentProps<typeof DialogPrimitive.Root>) {
|
||||
return <DialogPrimitive.Root data-slot="dialog" {...props} />
|
||||
}
|
||||
|
||||
function DialogTrigger({ ...props }: React.ComponentProps<typeof DialogPrimitive.Trigger>) {
|
||||
return <DialogPrimitive.Trigger data-slot="dialog-trigger" {...props} />
|
||||
}
|
||||
|
||||
function DialogPortal({ ...props }: React.ComponentProps<typeof DialogPrimitive.Portal>) {
|
||||
return <DialogPrimitive.Portal data-slot="dialog-portal" {...props} />
|
||||
}
|
||||
|
||||
function DialogClose({ ...props }: React.ComponentProps<typeof DialogPrimitive.Close>) {
|
||||
return <DialogPrimitive.Close data-slot="dialog-close" {...props} />
|
||||
}
|
||||
|
||||
function DialogOverlay({ className, ...props }: React.ComponentProps<typeof DialogPrimitive.Overlay>) {
|
||||
return (
|
||||
<DialogPrimitive.Overlay
|
||||
className={cn(
|
||||
'fixed inset-0 z-50 bg-black/50 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:animate-in data-[state=open]:fade-in-0',
|
||||
className
|
||||
)}
|
||||
data-slot="dialog-overlay"
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DialogContent({
|
||||
className,
|
||||
children,
|
||||
showCloseButton = true,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Content> & {
|
||||
showCloseButton?: boolean
|
||||
}) {
|
||||
return (
|
||||
<DialogPortal>
|
||||
<DialogOverlay />
|
||||
<DialogPrimitive.Content
|
||||
className={cn(
|
||||
'fixed left-1/2 top-1/2 z-50 grid w-full max-w-lg -translate-x-1/2 -translate-y-1/2 gap-4 rounded-lg border border-border bg-card p-6 shadow-lg duration-200 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95',
|
||||
className
|
||||
)}
|
||||
data-slot="dialog-content"
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
{showCloseButton && (
|
||||
<DialogPrimitive.Close
|
||||
className="absolute right-3 top-3 rounded-md p-1.5 text-muted-foreground opacity-70 transition-opacity hover:bg-accent hover:text-foreground hover:opacity-100 focus:outline-none focus-visible:ring-2 focus-visible:ring-ring/50 disabled:pointer-events-none"
|
||||
data-slot="dialog-close-button"
|
||||
>
|
||||
<XIcon className="size-4" />
|
||||
<span className="sr-only">Close</span>
|
||||
</DialogPrimitive.Close>
|
||||
)}
|
||||
</DialogPrimitive.Content>
|
||||
</DialogPortal>
|
||||
)
|
||||
}
|
||||
|
||||
function DialogHeader({ className, ...props }: React.ComponentProps<'div'>) {
|
||||
return (
|
||||
<div
|
||||
className={cn('flex flex-col gap-1.5 text-center sm:text-left', className)}
|
||||
data-slot="dialog-header"
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DialogFooter({ className, ...props }: React.ComponentProps<'div'>) {
|
||||
return (
|
||||
<div
|
||||
className={cn('flex flex-col-reverse gap-2 sm:flex-row sm:justify-end', className)}
|
||||
data-slot="dialog-footer"
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DialogTitle({ className, ...props }: React.ComponentProps<typeof DialogPrimitive.Title>) {
|
||||
return (
|
||||
<DialogPrimitive.Title
|
||||
className={cn('text-base font-semibold tracking-tight text-foreground', className)}
|
||||
data-slot="dialog-title"
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DialogDescription({ className, ...props }: React.ComponentProps<typeof DialogPrimitive.Description>) {
|
||||
return (
|
||||
<DialogPrimitive.Description
|
||||
className={cn('text-sm text-muted-foreground', className)}
|
||||
data-slot="dialog-description"
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
Dialog,
|
||||
DialogClose,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogOverlay,
|
||||
DialogPortal,
|
||||
DialogTitle,
|
||||
DialogTrigger
|
||||
}
|
||||
217
apps/desktop/src/components/ui/dropdown-menu.tsx
Normal file
|
|
@ -0,0 +1,217 @@
|
|||
import { CheckIcon, ChevronRightIcon, CircleIcon } from 'lucide-react'
|
||||
import { DropdownMenu as DropdownMenuPrimitive } from 'radix-ui'
|
||||
import * as React from 'react'
|
||||
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
function DropdownMenu({ ...props }: React.ComponentProps<typeof DropdownMenuPrimitive.Root>) {
|
||||
return <DropdownMenuPrimitive.Root data-slot="dropdown-menu" {...props} />
|
||||
}
|
||||
|
||||
function DropdownMenuPortal({ ...props }: React.ComponentProps<typeof DropdownMenuPrimitive.Portal>) {
|
||||
return <DropdownMenuPrimitive.Portal data-slot="dropdown-menu-portal" {...props} />
|
||||
}
|
||||
|
||||
function DropdownMenuTrigger({ ...props }: React.ComponentProps<typeof DropdownMenuPrimitive.Trigger>) {
|
||||
return <DropdownMenuPrimitive.Trigger data-slot="dropdown-menu-trigger" {...props} />
|
||||
}
|
||||
|
||||
function DropdownMenuContent({
|
||||
className,
|
||||
sideOffset = 4,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Content>) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.Portal>
|
||||
<DropdownMenuPrimitive.Content
|
||||
className={cn(
|
||||
'z-50 max-h-(--radix-dropdown-menu-content-available-height) min-w-[8rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border bg-popover p-1 text-popover-foreground shadow-md data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95',
|
||||
className
|
||||
)}
|
||||
data-slot="dropdown-menu-content"
|
||||
sideOffset={sideOffset}
|
||||
{...props}
|
||||
/>
|
||||
</DropdownMenuPrimitive.Portal>
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuGroup({ ...props }: React.ComponentProps<typeof DropdownMenuPrimitive.Group>) {
|
||||
return <DropdownMenuPrimitive.Group data-slot="dropdown-menu-group" {...props} />
|
||||
}
|
||||
|
||||
function DropdownMenuItem({
|
||||
className,
|
||||
inset,
|
||||
variant = 'default',
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Item> & {
|
||||
inset?: boolean
|
||||
variant?: 'default' | 'destructive'
|
||||
}) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.Item
|
||||
className={cn(
|
||||
"relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50 data-[inset]:pl-8 data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 data-[variant=destructive]:focus:text-destructive dark:data-[variant=destructive]:focus:bg-destructive/20 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 [&_svg:not([class*='text-'])]:text-muted-foreground data-[variant=destructive]:*:[svg]:text-destructive!",
|
||||
className
|
||||
)}
|
||||
data-inset={inset}
|
||||
data-slot="dropdown-menu-item"
|
||||
data-variant={variant}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuCheckboxItem({
|
||||
className,
|
||||
children,
|
||||
checked,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.CheckboxItem>) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.CheckboxItem
|
||||
checked={checked}
|
||||
className={cn(
|
||||
"relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
className
|
||||
)}
|
||||
data-slot="dropdown-menu-checkbox-item"
|
||||
{...props}
|
||||
>
|
||||
<span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
|
||||
<DropdownMenuPrimitive.ItemIndicator>
|
||||
<CheckIcon className="size-4" />
|
||||
</DropdownMenuPrimitive.ItemIndicator>
|
||||
</span>
|
||||
{children}
|
||||
</DropdownMenuPrimitive.CheckboxItem>
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuRadioGroup({ ...props }: React.ComponentProps<typeof DropdownMenuPrimitive.RadioGroup>) {
|
||||
return <DropdownMenuPrimitive.RadioGroup data-slot="dropdown-menu-radio-group" {...props} />
|
||||
}
|
||||
|
||||
function DropdownMenuRadioItem({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.RadioItem>) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.RadioItem
|
||||
className={cn(
|
||||
"relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
className
|
||||
)}
|
||||
data-slot="dropdown-menu-radio-item"
|
||||
{...props}
|
||||
>
|
||||
<span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
|
||||
<DropdownMenuPrimitive.ItemIndicator>
|
||||
<CircleIcon className="size-2 fill-current" />
|
||||
</DropdownMenuPrimitive.ItemIndicator>
|
||||
</span>
|
||||
{children}
|
||||
</DropdownMenuPrimitive.RadioItem>
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuLabel({
|
||||
className,
|
||||
inset,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Label> & {
|
||||
inset?: boolean
|
||||
}) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.Label
|
||||
className={cn('px-2 py-1.5 text-sm font-medium data-[inset]:pl-8', className)}
|
||||
data-inset={inset}
|
||||
data-slot="dropdown-menu-label"
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuSeparator({ className, ...props }: React.ComponentProps<typeof DropdownMenuPrimitive.Separator>) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.Separator
|
||||
className={cn('-mx-1 my-1 h-px bg-border', className)}
|
||||
data-slot="dropdown-menu-separator"
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuShortcut({ className, ...props }: React.ComponentProps<'span'>) {
|
||||
return (
|
||||
<span
|
||||
className={cn('ml-auto text-xs tracking-widest text-muted-foreground', className)}
|
||||
data-slot="dropdown-menu-shortcut"
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuSub({ ...props }: React.ComponentProps<typeof DropdownMenuPrimitive.Sub>) {
|
||||
return <DropdownMenuPrimitive.Sub data-slot="dropdown-menu-sub" {...props} />
|
||||
}
|
||||
|
||||
function DropdownMenuSubTrigger({
|
||||
className,
|
||||
inset,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.SubTrigger> & {
|
||||
inset?: boolean
|
||||
}) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.SubTrigger
|
||||
className={cn(
|
||||
"flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground data-[inset]:pl-8 data-[state=open]:bg-accent data-[state=open]:text-accent-foreground [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 [&_svg:not([class*='text-'])]:text-muted-foreground",
|
||||
className
|
||||
)}
|
||||
data-inset={inset}
|
||||
data-slot="dropdown-menu-sub-trigger"
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<ChevronRightIcon className="ml-auto size-4" />
|
||||
</DropdownMenuPrimitive.SubTrigger>
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuSubContent({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.SubContent>) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.SubContent
|
||||
className={cn(
|
||||
'z-50 min-w-[8rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-lg data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95',
|
||||
className
|
||||
)}
|
||||
data-slot="dropdown-menu-sub-content"
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
DropdownMenu,
|
||||
DropdownMenuCheckboxItem,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuGroup,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuPortal,
|
||||
DropdownMenuRadioGroup,
|
||||
DropdownMenuRadioItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuShortcut,
|
||||
DropdownMenuSub,
|
||||
DropdownMenuSubContent,
|
||||
DropdownMenuSubTrigger,
|
||||
DropdownMenuTrigger
|
||||
}
|
||||
21
apps/desktop/src/components/ui/input.tsx
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
import * as React from 'react'
|
||||
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
function Input({ className, type, ...props }: React.ComponentProps<'input'>) {
|
||||
return (
|
||||
<input
|
||||
className={cn(
|
||||
'h-9 w-full min-w-0 rounded-md border border-input bg-transparent px-3 py-1 text-base shadow-xs transition-[color,box-shadow] outline-none selection:bg-primary selection:text-primary-foreground file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm dark:bg-input/30',
|
||||
'focus-visible:border-ring focus-visible:ring-[0.1875rem] focus-visible:ring-ring/50',
|
||||
'aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40',
|
||||
className
|
||||
)}
|
||||
data-slot="input"
|
||||
type={type}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Input }
|
||||
43
apps/desktop/src/components/ui/scroll-area.tsx
Normal file
|
|
@ -0,0 +1,43 @@
|
|||
import { ScrollArea as ScrollAreaPrimitive } from 'radix-ui'
|
||||
import * as React from 'react'
|
||||
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
function ScrollArea({ className, children, ...props }: React.ComponentProps<typeof ScrollAreaPrimitive.Root>) {
|
||||
return (
|
||||
<ScrollAreaPrimitive.Root className={cn('relative overflow-hidden', className)} data-slot="scroll-area" {...props}>
|
||||
<ScrollAreaPrimitive.Viewport className="size-full outline-none" data-slot="scroll-area-viewport">
|
||||
{children}
|
||||
</ScrollAreaPrimitive.Viewport>
|
||||
<ScrollBar />
|
||||
<ScrollAreaPrimitive.Corner />
|
||||
</ScrollAreaPrimitive.Root>
|
||||
)
|
||||
}
|
||||
|
||||
function ScrollBar({
|
||||
className,
|
||||
orientation = 'vertical',
|
||||
...props
|
||||
}: React.ComponentProps<typeof ScrollAreaPrimitive.ScrollAreaScrollbar>) {
|
||||
return (
|
||||
<ScrollAreaPrimitive.ScrollAreaScrollbar
|
||||
className={cn(
|
||||
'flex touch-none select-none p-px transition-colors',
|
||||
orientation === 'vertical' && 'h-full w-2.5 border-l border-l-transparent',
|
||||
orientation === 'horizontal' && 'h-2.5 flex-col border-t border-t-transparent',
|
||||
className
|
||||
)}
|
||||
data-slot="scroll-area-scrollbar"
|
||||
orientation={orientation}
|
||||
{...props}
|
||||
>
|
||||
<ScrollAreaPrimitive.ScrollAreaThumb
|
||||
className="relative flex-1 rounded-full bg-muted-foreground/30 hover:bg-muted-foreground/45"
|
||||
data-slot="scroll-area-thumb"
|
||||
/>
|
||||
</ScrollAreaPrimitive.ScrollAreaScrollbar>
|
||||
)
|
||||
}
|
||||
|
||||
export { ScrollArea, ScrollBar }
|
||||
85
apps/desktop/src/components/ui/select.tsx
Normal file
|
|
@ -0,0 +1,85 @@
|
|||
import { CheckIcon, ChevronDownIcon } from 'lucide-react'
|
||||
import { Select as SelectPrimitive } from 'radix-ui'
|
||||
import * as React from 'react'
|
||||
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
function Select({ ...props }: React.ComponentProps<typeof SelectPrimitive.Root>) {
|
||||
return <SelectPrimitive.Root data-slot="select" {...props} />
|
||||
}
|
||||
|
||||
function SelectTrigger({ className, children, ...props }: React.ComponentProps<typeof SelectPrimitive.Trigger>) {
|
||||
return (
|
||||
<SelectPrimitive.Trigger
|
||||
className={cn(
|
||||
'flex h-8 w-full items-center justify-between gap-2 rounded-lg border border-input bg-background px-3 py-2 text-sm whitespace-nowrap shadow-xs outline-none transition-[color,box-shadow] focus-visible:border-ring focus-visible:ring-[0.1875rem] focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 data-placeholder:text-muted-foreground [&_svg]:pointer-events-none [&_svg]:shrink-0',
|
||||
className
|
||||
)}
|
||||
data-slot="select-trigger"
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<SelectPrimitive.Icon asChild>
|
||||
<ChevronDownIcon className="size-4 opacity-60" />
|
||||
</SelectPrimitive.Icon>
|
||||
</SelectPrimitive.Trigger>
|
||||
)
|
||||
}
|
||||
|
||||
function SelectValue({ ...props }: React.ComponentProps<typeof SelectPrimitive.Value>) {
|
||||
return <SelectPrimitive.Value data-slot="select-value" {...props} />
|
||||
}
|
||||
|
||||
function SelectContent({
|
||||
className,
|
||||
children,
|
||||
position = 'popper',
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.Content>) {
|
||||
return (
|
||||
<SelectPrimitive.Portal>
|
||||
<SelectPrimitive.Content
|
||||
className={cn(
|
||||
'relative z-80 max-h-72 min-w-32 overflow-hidden rounded-md border border-border bg-popover text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2',
|
||||
position === 'popper' &&
|
||||
'data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1',
|
||||
className
|
||||
)}
|
||||
data-slot="select-content"
|
||||
position={position}
|
||||
{...props}
|
||||
>
|
||||
<SelectPrimitive.Viewport
|
||||
className={cn(
|
||||
'p-1',
|
||||
position === 'popper' && 'h-(--radix-select-trigger-height) w-full min-w-(--radix-select-trigger-width)'
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</SelectPrimitive.Viewport>
|
||||
</SelectPrimitive.Content>
|
||||
</SelectPrimitive.Portal>
|
||||
)
|
||||
}
|
||||
|
||||
function SelectItem({ className, children, ...props }: React.ComponentProps<typeof SelectPrimitive.Item>) {
|
||||
return (
|
||||
<SelectPrimitive.Item
|
||||
className={cn(
|
||||
'relative flex w-full cursor-default items-center gap-2 rounded-sm py-1.5 pr-8 pl-2 text-sm outline-none select-none focus:bg-accent focus:text-accent-foreground data-disabled:pointer-events-none data-disabled:opacity-50',
|
||||
className
|
||||
)}
|
||||
data-slot="select-item"
|
||||
{...props}
|
||||
>
|
||||
<span className="absolute right-2 flex size-3.5 items-center justify-center">
|
||||
<SelectPrimitive.ItemIndicator>
|
||||
<CheckIcon className="size-4" />
|
||||
</SelectPrimitive.ItemIndicator>
|
||||
</span>
|
||||
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
|
||||
</SelectPrimitive.Item>
|
||||
)
|
||||
}
|
||||
|
||||
export { Select, SelectContent, SelectItem, SelectTrigger, SelectValue }
|
||||
26
apps/desktop/src/components/ui/separator.tsx
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
import { Separator as SeparatorPrimitive } from 'radix-ui'
|
||||
import * as React from 'react'
|
||||
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
function Separator({
|
||||
className,
|
||||
orientation = 'horizontal',
|
||||
decorative = true,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SeparatorPrimitive.Root>) {
|
||||
return (
|
||||
<SeparatorPrimitive.Root
|
||||
className={cn(
|
||||
'shrink-0 bg-border data-[orientation=horizontal]:h-px data-[orientation=horizontal]:w-full data-[orientation=vertical]:h-full data-[orientation=vertical]:w-px',
|
||||
className
|
||||
)}
|
||||
data-slot="separator"
|
||||
decorative={decorative}
|
||||
orientation={orientation}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Separator }
|
||||
107
apps/desktop/src/components/ui/sheet.tsx
Normal file
|
|
@ -0,0 +1,107 @@
|
|||
'use client'
|
||||
|
||||
import { XIcon } from 'lucide-react'
|
||||
import { Dialog as SheetPrimitive } from 'radix-ui'
|
||||
import * as React from 'react'
|
||||
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
function Sheet({ ...props }: React.ComponentProps<typeof SheetPrimitive.Root>) {
|
||||
return <SheetPrimitive.Root data-slot="sheet" {...props} />
|
||||
}
|
||||
|
||||
function SheetTrigger({ ...props }: React.ComponentProps<typeof SheetPrimitive.Trigger>) {
|
||||
return <SheetPrimitive.Trigger data-slot="sheet-trigger" {...props} />
|
||||
}
|
||||
|
||||
function SheetClose({ ...props }: React.ComponentProps<typeof SheetPrimitive.Close>) {
|
||||
return <SheetPrimitive.Close data-slot="sheet-close" {...props} />
|
||||
}
|
||||
|
||||
function SheetPortal({ ...props }: React.ComponentProps<typeof SheetPrimitive.Portal>) {
|
||||
return <SheetPrimitive.Portal data-slot="sheet-portal" {...props} />
|
||||
}
|
||||
|
||||
function SheetOverlay({ className, ...props }: React.ComponentProps<typeof SheetPrimitive.Overlay>) {
|
||||
return (
|
||||
<SheetPrimitive.Overlay
|
||||
className={cn(
|
||||
'fixed inset-0 z-50 bg-black/50 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:animate-in data-[state=open]:fade-in-0',
|
||||
className
|
||||
)}
|
||||
data-slot="sheet-overlay"
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function SheetContent({
|
||||
className,
|
||||
children,
|
||||
side = 'right',
|
||||
showCloseButton = true,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SheetPrimitive.Content> & {
|
||||
side?: 'top' | 'right' | 'bottom' | 'left'
|
||||
showCloseButton?: boolean
|
||||
}) {
|
||||
return (
|
||||
<SheetPortal>
|
||||
<SheetOverlay />
|
||||
<SheetPrimitive.Content
|
||||
className={cn(
|
||||
'fixed z-50 flex flex-col gap-4 bg-background shadow-lg transition ease-in-out data-[state=closed]:animate-out data-[state=closed]:duration-300 data-[state=open]:animate-in data-[state=open]:duration-500',
|
||||
side === 'right' &&
|
||||
'inset-y-0 right-0 h-full w-3/4 border-l data-[state=closed]:slide-out-to-right data-[state=open]:slide-in-from-right sm:max-w-sm',
|
||||
side === 'left' &&
|
||||
'inset-y-0 left-0 h-full w-3/4 border-r data-[state=closed]:slide-out-to-left data-[state=open]:slide-in-from-left sm:max-w-sm',
|
||||
side === 'top' &&
|
||||
'inset-x-0 top-0 h-auto border-b data-[state=closed]:slide-out-to-top data-[state=open]:slide-in-from-top',
|
||||
side === 'bottom' &&
|
||||
'inset-x-0 bottom-0 h-auto border-t data-[state=closed]:slide-out-to-bottom data-[state=open]:slide-in-from-bottom',
|
||||
className
|
||||
)}
|
||||
data-slot="sheet-content"
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
{showCloseButton && (
|
||||
<SheetPrimitive.Close className="absolute top-4 right-4 rounded-xs opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:ring-2 focus:ring-ring focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none data-[state=open]:bg-secondary">
|
||||
<XIcon className="size-4" />
|
||||
<span className="sr-only">Close</span>
|
||||
</SheetPrimitive.Close>
|
||||
)}
|
||||
</SheetPrimitive.Content>
|
||||
</SheetPortal>
|
||||
)
|
||||
}
|
||||
|
||||
function SheetHeader({ className, ...props }: React.ComponentProps<'div'>) {
|
||||
return <div className={cn('flex flex-col gap-1.5 p-4', className)} data-slot="sheet-header" {...props} />
|
||||
}
|
||||
|
||||
function SheetFooter({ className, ...props }: React.ComponentProps<'div'>) {
|
||||
return <div className={cn('mt-auto flex flex-col gap-2 p-4', className)} data-slot="sheet-footer" {...props} />
|
||||
}
|
||||
|
||||
function SheetTitle({ className, ...props }: React.ComponentProps<typeof SheetPrimitive.Title>) {
|
||||
return (
|
||||
<SheetPrimitive.Title
|
||||
className={cn('font-semibold text-foreground', className)}
|
||||
data-slot="sheet-title"
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function SheetDescription({ className, ...props }: React.ComponentProps<typeof SheetPrimitive.Description>) {
|
||||
return (
|
||||
<SheetPrimitive.Description
|
||||
className={cn('text-sm text-muted-foreground', className)}
|
||||
data-slot="sheet-description"
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Sheet, SheetClose, SheetContent, SheetDescription, SheetFooter, SheetHeader, SheetTitle, SheetTrigger }
|
||||
681
apps/desktop/src/components/ui/sidebar.tsx
Normal file
|
|
@ -0,0 +1,681 @@
|
|||
'use client'
|
||||
|
||||
import { cva, type VariantProps } from 'class-variance-authority'
|
||||
import { PanelLeftIcon } from 'lucide-react'
|
||||
import { Slot } from 'radix-ui'
|
||||
import * as React from 'react'
|
||||
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Separator } from '@/components/ui/separator'
|
||||
import { Sheet, SheetContent, SheetDescription, SheetHeader, SheetTitle } from '@/components/ui/sheet'
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip'
|
||||
import { useIsMobile } from '@/hooks/use-mobile'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
const SIDEBAR_COOKIE_NAME = 'sidebar_state'
|
||||
const SIDEBAR_COOKIE_MAX_AGE = 60 * 60 * 24 * 7
|
||||
const SIDEBAR_WIDTH = '16rem'
|
||||
const SIDEBAR_WIDTH_MOBILE = '18rem'
|
||||
const SIDEBAR_WIDTH_ICON = '3rem'
|
||||
const SIDEBAR_KEYBOARD_SHORTCUT = 'b'
|
||||
|
||||
type SidebarContextProps = {
|
||||
state: 'expanded' | 'collapsed'
|
||||
open: boolean
|
||||
setOpen: (open: boolean) => void
|
||||
openMobile: boolean
|
||||
setOpenMobile: (open: boolean) => void
|
||||
isMobile: boolean
|
||||
toggleSidebar: () => void
|
||||
}
|
||||
|
||||
const SidebarContext = React.createContext<SidebarContextProps | null>(null)
|
||||
|
||||
function useSidebar() {
|
||||
const context = React.useContext(SidebarContext)
|
||||
|
||||
if (!context) {
|
||||
throw new Error('useSidebar must be used within a SidebarProvider.')
|
||||
}
|
||||
|
||||
return context
|
||||
}
|
||||
|
||||
function SidebarProvider({
|
||||
defaultOpen = true,
|
||||
open: openProp,
|
||||
onOpenChange: setOpenProp,
|
||||
className,
|
||||
style,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<'div'> & {
|
||||
defaultOpen?: boolean
|
||||
open?: boolean
|
||||
onOpenChange?: (open: boolean) => void
|
||||
}) {
|
||||
const isMobile = useIsMobile()
|
||||
const [openMobile, setOpenMobile] = React.useState(false)
|
||||
|
||||
// This is the internal state of the sidebar.
|
||||
// We use openProp and setOpenProp for control from outside the component.
|
||||
const [_open, _setOpen] = React.useState(defaultOpen)
|
||||
const open = openProp ?? _open
|
||||
|
||||
const setOpen = React.useCallback(
|
||||
(value: boolean | ((value: boolean) => boolean)) => {
|
||||
const openState = typeof value === 'function' ? value(open) : value
|
||||
|
||||
if (setOpenProp) {
|
||||
setOpenProp(openState)
|
||||
} else {
|
||||
_setOpen(openState)
|
||||
}
|
||||
|
||||
// This sets the cookie to keep the sidebar state.
|
||||
document.cookie = `${SIDEBAR_COOKIE_NAME}=${openState}; path=/; max-age=${SIDEBAR_COOKIE_MAX_AGE}`
|
||||
},
|
||||
[setOpenProp, open]
|
||||
)
|
||||
|
||||
// Helper to toggle the sidebar.
|
||||
const toggleSidebar = React.useCallback(() => {
|
||||
return isMobile ? setOpenMobile(open => !open) : setOpen(open => !open)
|
||||
}, [isMobile, setOpen, setOpenMobile])
|
||||
|
||||
// Adds a keyboard shortcut to toggle the sidebar.
|
||||
React.useEffect(() => {
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
if (event.key === SIDEBAR_KEYBOARD_SHORTCUT && (event.metaKey || event.ctrlKey)) {
|
||||
event.preventDefault()
|
||||
toggleSidebar()
|
||||
}
|
||||
}
|
||||
|
||||
window.addEventListener('keydown', handleKeyDown)
|
||||
|
||||
return () => window.removeEventListener('keydown', handleKeyDown)
|
||||
}, [toggleSidebar])
|
||||
|
||||
// We add a state so that we can do data-state="expanded" or "collapsed".
|
||||
// This makes it easier to style the sidebar with Tailwind classes.
|
||||
const state = open ? 'expanded' : 'collapsed'
|
||||
|
||||
const contextValue = React.useMemo<SidebarContextProps>(
|
||||
() => ({
|
||||
state,
|
||||
open,
|
||||
setOpen,
|
||||
isMobile,
|
||||
openMobile,
|
||||
setOpenMobile,
|
||||
toggleSidebar
|
||||
}),
|
||||
[state, open, setOpen, isMobile, openMobile, setOpenMobile, toggleSidebar]
|
||||
)
|
||||
|
||||
return (
|
||||
<SidebarContext.Provider value={contextValue}>
|
||||
<TooltipProvider delayDuration={0}>
|
||||
<div
|
||||
className={cn('group/sidebar-wrapper flex min-h-svh w-full has-data-[variant=inset]:bg-sidebar', className)}
|
||||
data-slot="sidebar-wrapper"
|
||||
style={
|
||||
{
|
||||
'--sidebar-width': SIDEBAR_WIDTH,
|
||||
'--sidebar-width-icon': SIDEBAR_WIDTH_ICON,
|
||||
...style
|
||||
} as React.CSSProperties
|
||||
}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
</TooltipProvider>
|
||||
</SidebarContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
function Sidebar({
|
||||
side = 'left',
|
||||
variant = 'sidebar',
|
||||
collapsible = 'offcanvas',
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<'div'> & {
|
||||
side?: 'left' | 'right'
|
||||
variant?: 'sidebar' | 'floating' | 'inset'
|
||||
collapsible?: 'offcanvas' | 'icon' | 'none'
|
||||
}) {
|
||||
const { isMobile, state, openMobile, setOpenMobile } = useSidebar()
|
||||
|
||||
if (collapsible === 'none') {
|
||||
return (
|
||||
<div
|
||||
className={cn('flex h-full w-(--sidebar-width) flex-col bg-sidebar text-sidebar-foreground', className)}
|
||||
data-slot="sidebar"
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (isMobile) {
|
||||
return (
|
||||
<Sheet onOpenChange={setOpenMobile} open={openMobile} {...props}>
|
||||
<SheetContent
|
||||
className="w-(--sidebar-width) bg-sidebar p-0 text-sidebar-foreground [&>button]:hidden"
|
||||
data-mobile="true"
|
||||
data-sidebar="sidebar"
|
||||
data-slot="sidebar"
|
||||
side={side}
|
||||
style={
|
||||
{
|
||||
'--sidebar-width': SIDEBAR_WIDTH_MOBILE
|
||||
} as React.CSSProperties
|
||||
}
|
||||
>
|
||||
<SheetHeader className="sr-only">
|
||||
<SheetTitle>Sidebar</SheetTitle>
|
||||
<SheetDescription>Displays the mobile sidebar.</SheetDescription>
|
||||
</SheetHeader>
|
||||
<div className="flex h-full w-full flex-col">{children}</div>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className="group peer hidden text-sidebar-foreground md:block"
|
||||
data-collapsible={state === 'collapsed' ? collapsible : ''}
|
||||
data-side={side}
|
||||
data-slot="sidebar"
|
||||
data-state={state}
|
||||
data-variant={variant}
|
||||
>
|
||||
{/* This is what handles the sidebar gap on desktop */}
|
||||
<div
|
||||
className={cn(
|
||||
'relative w-(--sidebar-width) bg-transparent transition-[width] duration-200 ease-linear',
|
||||
'group-data-[collapsible=offcanvas]:w-0',
|
||||
'group-data-[side=right]:rotate-180',
|
||||
variant === 'floating' || variant === 'inset'
|
||||
? 'group-data-[collapsible=icon]:w-[calc(var(--sidebar-width-icon)+(--spacing(4)))]'
|
||||
: 'group-data-[collapsible=icon]:w-(--sidebar-width-icon)'
|
||||
)}
|
||||
data-slot="sidebar-gap"
|
||||
/>
|
||||
<div
|
||||
className={cn(
|
||||
'fixed inset-y-0 z-10 hidden h-svh w-(--sidebar-width) transition-[left,right,width] duration-200 ease-linear md:flex',
|
||||
side === 'left'
|
||||
? 'left-0 group-data-[collapsible=offcanvas]:left-[calc(var(--sidebar-width)*-1)]'
|
||||
: 'right-0 group-data-[collapsible=offcanvas]:right-[calc(var(--sidebar-width)*-1)]',
|
||||
// Adjust the padding for floating and inset variants.
|
||||
variant === 'floating' || variant === 'inset'
|
||||
? 'p-2 group-data-[collapsible=icon]:w-[calc(var(--sidebar-width-icon)+(--spacing(4))+0.125rem)]'
|
||||
: 'group-data-[collapsible=icon]:w-(--sidebar-width-icon) group-data-[side=left]:border-r group-data-[side=right]:border-l',
|
||||
className
|
||||
)}
|
||||
data-slot="sidebar-container"
|
||||
{...props}
|
||||
>
|
||||
<div
|
||||
className="flex h-full w-full flex-col bg-sidebar group-data-[variant=floating]:rounded-lg group-data-[variant=floating]:border group-data-[variant=floating]:border-sidebar-border group-data-[variant=floating]:shadow-sm"
|
||||
data-sidebar="sidebar"
|
||||
data-slot="sidebar-inner"
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function SidebarTrigger({ className, onClick, ...props }: React.ComponentProps<typeof Button>) {
|
||||
const { toggleSidebar } = useSidebar()
|
||||
|
||||
return (
|
||||
<Button
|
||||
className={cn('size-7', className)}
|
||||
data-sidebar="trigger"
|
||||
data-slot="sidebar-trigger"
|
||||
onClick={event => {
|
||||
onClick?.(event)
|
||||
toggleSidebar()
|
||||
}}
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
{...props}
|
||||
>
|
||||
<PanelLeftIcon />
|
||||
<span className="sr-only">Toggle Sidebar</span>
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
|
||||
function SidebarRail({ className, ...props }: React.ComponentProps<'button'>) {
|
||||
const { toggleSidebar } = useSidebar()
|
||||
|
||||
return (
|
||||
<button
|
||||
aria-label="Toggle Sidebar"
|
||||
className={cn(
|
||||
'absolute inset-y-0 z-20 hidden w-4 -translate-x-1/2 transition-all ease-linear group-data-[side=left]:-right-4 group-data-[side=right]:left-0 after:absolute after:inset-y-0 after:left-1/2 after:w-[0.125rem] hover:after:bg-sidebar-border sm:flex',
|
||||
'in-data-[side=left]:cursor-w-resize in-data-[side=right]:cursor-e-resize',
|
||||
'[[data-side=left][data-state=collapsed]_&]:cursor-e-resize [[data-side=right][data-state=collapsed]_&]:cursor-w-resize',
|
||||
'group-data-[collapsible=offcanvas]:translate-x-0 group-data-[collapsible=offcanvas]:after:left-full hover:group-data-[collapsible=offcanvas]:bg-sidebar',
|
||||
'[[data-side=left][data-collapsible=offcanvas]_&]:-right-2',
|
||||
'[[data-side=right][data-collapsible=offcanvas]_&]:-left-2',
|
||||
className
|
||||
)}
|
||||
data-sidebar="rail"
|
||||
data-slot="sidebar-rail"
|
||||
onClick={toggleSidebar}
|
||||
tabIndex={-1}
|
||||
title="Toggle Sidebar"
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function SidebarInset({ className, ...props }: React.ComponentProps<'main'>) {
|
||||
return (
|
||||
<main
|
||||
className={cn(
|
||||
'relative flex w-full flex-1 flex-col bg-background',
|
||||
'md:peer-data-[variant=inset]:m-2 md:peer-data-[variant=inset]:ml-0 md:peer-data-[variant=inset]:rounded-xl md:peer-data-[variant=inset]:shadow-sm md:peer-data-[variant=inset]:peer-data-[state=collapsed]:ml-2',
|
||||
className
|
||||
)}
|
||||
data-slot="sidebar-inset"
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function SidebarInput({ className, ...props }: React.ComponentProps<typeof Input>) {
|
||||
return (
|
||||
<Input
|
||||
className={cn('h-8 w-full bg-background shadow-none', className)}
|
||||
data-sidebar="input"
|
||||
data-slot="sidebar-input"
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function SidebarHeader({ className, ...props }: React.ComponentProps<'div'>) {
|
||||
return (
|
||||
<div
|
||||
className={cn('flex flex-col gap-2 p-2', className)}
|
||||
data-sidebar="header"
|
||||
data-slot="sidebar-header"
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function SidebarFooter({ className, ...props }: React.ComponentProps<'div'>) {
|
||||
return (
|
||||
<div
|
||||
className={cn('flex flex-col gap-2 p-2', className)}
|
||||
data-sidebar="footer"
|
||||
data-slot="sidebar-footer"
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function SidebarSeparator({ className, ...props }: React.ComponentProps<typeof Separator>) {
|
||||
return (
|
||||
<Separator
|
||||
className={cn('mx-2 w-auto bg-sidebar-border', className)}
|
||||
data-sidebar="separator"
|
||||
data-slot="sidebar-separator"
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function SidebarContent({ className, ...props }: React.ComponentProps<'div'>) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'flex min-h-0 flex-1 flex-col gap-2 overflow-auto group-data-[collapsible=icon]:overflow-hidden',
|
||||
className
|
||||
)}
|
||||
data-sidebar="content"
|
||||
data-slot="sidebar-content"
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function SidebarGroup({ className, ...props }: React.ComponentProps<'div'>) {
|
||||
return (
|
||||
<div
|
||||
className={cn('relative flex w-full min-w-0 flex-col p-2', className)}
|
||||
data-sidebar="group"
|
||||
data-slot="sidebar-group"
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function SidebarGroupLabel({
|
||||
className,
|
||||
asChild = false,
|
||||
...props
|
||||
}: React.ComponentProps<'div'> & { asChild?: boolean }) {
|
||||
const Comp = asChild ? Slot.Root : 'div'
|
||||
|
||||
return (
|
||||
<Comp
|
||||
className={cn(
|
||||
'flex h-8 shrink-0 items-center rounded-md px-2 text-xs font-medium text-sidebar-foreground/70 ring-sidebar-ring outline-hidden transition-[margin,opacity] duration-200 ease-linear focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0',
|
||||
'group-data-[collapsible=icon]:-mt-8 group-data-[collapsible=icon]:opacity-0',
|
||||
className
|
||||
)}
|
||||
data-sidebar="group-label"
|
||||
data-slot="sidebar-group-label"
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function SidebarGroupAction({
|
||||
className,
|
||||
asChild = false,
|
||||
...props
|
||||
}: React.ComponentProps<'button'> & { asChild?: boolean }) {
|
||||
const Comp = asChild ? Slot.Root : 'button'
|
||||
|
||||
return (
|
||||
<Comp
|
||||
className={cn(
|
||||
'absolute top-3.5 right-3 flex aspect-square w-5 items-center justify-center rounded-md p-0 text-sidebar-foreground ring-sidebar-ring outline-hidden transition-transform hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0',
|
||||
// Increases the hit area of the button on mobile.
|
||||
'after:absolute after:-inset-2 md:after:hidden',
|
||||
'group-data-[collapsible=icon]:hidden',
|
||||
className
|
||||
)}
|
||||
data-sidebar="group-action"
|
||||
data-slot="sidebar-group-action"
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function SidebarGroupContent({ className, ...props }: React.ComponentProps<'div'>) {
|
||||
return (
|
||||
<div
|
||||
className={cn('w-full text-sm', className)}
|
||||
data-sidebar="group-content"
|
||||
data-slot="sidebar-group-content"
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function SidebarMenu({ className, ...props }: React.ComponentProps<'ul'>) {
|
||||
return (
|
||||
<ul
|
||||
className={cn('flex w-full min-w-0 flex-col gap-1', className)}
|
||||
data-sidebar="menu"
|
||||
data-slot="sidebar-menu"
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function SidebarMenuItem({ className, ...props }: React.ComponentProps<'li'>) {
|
||||
return (
|
||||
<li
|
||||
className={cn('group/menu-item relative', className)}
|
||||
data-sidebar="menu-item"
|
||||
data-slot="sidebar-menu-item"
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
const sidebarMenuButtonVariants = cva(
|
||||
'peer/menu-button flex w-full items-center gap-2 overflow-hidden rounded-md p-2 text-left text-sm ring-sidebar-ring outline-hidden transition-[width,height,padding] group-has-data-[sidebar=menu-action]/menu-item:pr-8 group-data-[collapsible=icon]:size-8! group-data-[collapsible=icon]:p-2! hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 active:bg-sidebar-accent active:text-sidebar-accent-foreground disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50 data-[active=true]:bg-sidebar-accent data-[active=true]:font-medium data-[active=true]:text-sidebar-accent-foreground data-[state=open]:hover:bg-sidebar-accent data-[state=open]:hover:text-sidebar-accent-foreground [&>span:last-child]:truncate [&>svg]:size-4 [&>svg]:shrink-0',
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: 'hover:bg-sidebar-accent hover:text-sidebar-accent-foreground',
|
||||
outline:
|
||||
'bg-background shadow-[0_0_0_0.0625rem_hsl(var(--sidebar-border))] hover:bg-sidebar-accent hover:text-sidebar-accent-foreground hover:shadow-[0_0_0_0.0625rem_hsl(var(--sidebar-accent))]'
|
||||
},
|
||||
size: {
|
||||
default: 'h-8 text-sm',
|
||||
sm: 'h-7 text-xs',
|
||||
lg: 'h-12 text-sm group-data-[collapsible=icon]:p-0!'
|
||||
}
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: 'default',
|
||||
size: 'default'
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
function SidebarMenuButton({
|
||||
asChild = false,
|
||||
isActive = false,
|
||||
variant = 'default',
|
||||
size = 'default',
|
||||
tooltip,
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<'button'> & {
|
||||
asChild?: boolean
|
||||
isActive?: boolean
|
||||
tooltip?: string | React.ComponentProps<typeof TooltipContent>
|
||||
} & VariantProps<typeof sidebarMenuButtonVariants>) {
|
||||
const Comp = asChild ? Slot.Root : 'button'
|
||||
const { isMobile, state } = useSidebar()
|
||||
|
||||
const button = (
|
||||
<Comp
|
||||
className={cn(sidebarMenuButtonVariants({ variant, size }), className)}
|
||||
data-active={isActive}
|
||||
data-sidebar="menu-button"
|
||||
data-size={size}
|
||||
data-slot="sidebar-menu-button"
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
|
||||
if (!tooltip) {
|
||||
return button
|
||||
}
|
||||
|
||||
if (typeof tooltip === 'string') {
|
||||
tooltip = {
|
||||
children: tooltip
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>{button}</TooltipTrigger>
|
||||
<TooltipContent align="center" hidden={state !== 'collapsed' || isMobile} side="right" {...tooltip} />
|
||||
</Tooltip>
|
||||
)
|
||||
}
|
||||
|
||||
function SidebarMenuAction({
|
||||
className,
|
||||
asChild = false,
|
||||
showOnHover = false,
|
||||
...props
|
||||
}: React.ComponentProps<'button'> & {
|
||||
asChild?: boolean
|
||||
showOnHover?: boolean
|
||||
}) {
|
||||
const Comp = asChild ? Slot.Root : 'button'
|
||||
|
||||
return (
|
||||
<Comp
|
||||
className={cn(
|
||||
'absolute top-1.5 right-1 flex aspect-square w-5 items-center justify-center rounded-md p-0 text-sidebar-foreground ring-sidebar-ring outline-hidden transition-transform peer-hover/menu-button:text-sidebar-accent-foreground hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0',
|
||||
// Increases the hit area of the button on mobile.
|
||||
'after:absolute after:-inset-2 md:after:hidden',
|
||||
'peer-data-[size=sm]/menu-button:top-1',
|
||||
'peer-data-[size=default]/menu-button:top-1.5',
|
||||
'peer-data-[size=lg]/menu-button:top-2.5',
|
||||
'group-data-[collapsible=icon]:hidden',
|
||||
showOnHover &&
|
||||
'group-focus-within/menu-item:opacity-100 group-hover/menu-item:opacity-100 peer-data-[active=true]/menu-button:text-sidebar-accent-foreground data-[state=open]:opacity-100 md:opacity-0',
|
||||
className
|
||||
)}
|
||||
data-sidebar="menu-action"
|
||||
data-slot="sidebar-menu-action"
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function SidebarMenuBadge({ className, ...props }: React.ComponentProps<'div'>) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'pointer-events-none absolute right-1 flex h-5 min-w-5 items-center justify-center rounded-md px-1 text-xs font-medium text-sidebar-foreground tabular-nums select-none',
|
||||
'peer-hover/menu-button:text-sidebar-accent-foreground peer-data-[active=true]/menu-button:text-sidebar-accent-foreground',
|
||||
'peer-data-[size=sm]/menu-button:top-1',
|
||||
'peer-data-[size=default]/menu-button:top-1.5',
|
||||
'peer-data-[size=lg]/menu-button:top-2.5',
|
||||
'group-data-[collapsible=icon]:hidden',
|
||||
className
|
||||
)}
|
||||
data-sidebar="menu-badge"
|
||||
data-slot="sidebar-menu-badge"
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function SidebarMenuSkeleton({
|
||||
className,
|
||||
showIcon = false,
|
||||
...props
|
||||
}: React.ComponentProps<'div'> & {
|
||||
showIcon?: boolean
|
||||
}) {
|
||||
// Random width between 50 to 90%.
|
||||
const width = React.useMemo(() => {
|
||||
return `${Math.floor(Math.random() * 40) + 50}%`
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn('flex h-8 items-center gap-2 rounded-md px-2', className)}
|
||||
data-sidebar="menu-skeleton"
|
||||
data-slot="sidebar-menu-skeleton"
|
||||
{...props}
|
||||
>
|
||||
{showIcon && <Skeleton className="size-4 rounded-md" data-sidebar="menu-skeleton-icon" />}
|
||||
<Skeleton
|
||||
className="h-4 max-w-(--skeleton-width) flex-1"
|
||||
data-sidebar="menu-skeleton-text"
|
||||
style={
|
||||
{
|
||||
'--skeleton-width': width
|
||||
} as React.CSSProperties
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function SidebarMenuSub({ className, ...props }: React.ComponentProps<'ul'>) {
|
||||
return (
|
||||
<ul
|
||||
className={cn(
|
||||
'mx-3.5 flex min-w-0 translate-x-px flex-col gap-1 border-l border-sidebar-border px-2.5 py-0.5',
|
||||
'group-data-[collapsible=icon]:hidden',
|
||||
className
|
||||
)}
|
||||
data-sidebar="menu-sub"
|
||||
data-slot="sidebar-menu-sub"
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function SidebarMenuSubItem({ className, ...props }: React.ComponentProps<'li'>) {
|
||||
return (
|
||||
<li
|
||||
className={cn('group/menu-sub-item relative', className)}
|
||||
data-sidebar="menu-sub-item"
|
||||
data-slot="sidebar-menu-sub-item"
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function SidebarMenuSubButton({
|
||||
asChild = false,
|
||||
size = 'md',
|
||||
isActive = false,
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<'a'> & {
|
||||
asChild?: boolean
|
||||
size?: 'sm' | 'md'
|
||||
isActive?: boolean
|
||||
}) {
|
||||
const Comp = asChild ? Slot.Root : 'a'
|
||||
|
||||
return (
|
||||
<Comp
|
||||
className={cn(
|
||||
'flex h-7 min-w-0 -translate-x-px items-center gap-2 overflow-hidden rounded-md px-2 text-sidebar-foreground ring-sidebar-ring outline-hidden hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 active:bg-sidebar-accent active:text-sidebar-accent-foreground disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50 [&>span:last-child]:truncate [&>svg]:size-4 [&>svg]:shrink-0 [&>svg]:text-sidebar-accent-foreground',
|
||||
'data-[active=true]:bg-sidebar-accent data-[active=true]:text-sidebar-accent-foreground',
|
||||
size === 'sm' && 'text-xs',
|
||||
size === 'md' && 'text-sm',
|
||||
'group-data-[collapsible=icon]:hidden',
|
||||
className
|
||||
)}
|
||||
data-active={isActive}
|
||||
data-sidebar="menu-sub-button"
|
||||
data-size={size}
|
||||
data-slot="sidebar-menu-sub-button"
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
Sidebar,
|
||||
SidebarContent,
|
||||
SidebarFooter,
|
||||
SidebarGroup,
|
||||
SidebarGroupAction,
|
||||
SidebarGroupContent,
|
||||
SidebarGroupLabel,
|
||||
SidebarHeader,
|
||||
SidebarInput,
|
||||
SidebarInset,
|
||||
SidebarMenu,
|
||||
SidebarMenuAction,
|
||||
SidebarMenuBadge,
|
||||
SidebarMenuButton,
|
||||
SidebarMenuItem,
|
||||
SidebarMenuSkeleton,
|
||||
SidebarMenuSub,
|
||||
SidebarMenuSubButton,
|
||||
SidebarMenuSubItem,
|
||||
SidebarProvider,
|
||||
SidebarRail,
|
||||
SidebarSeparator,
|
||||
SidebarTrigger,
|
||||
useSidebar
|
||||
}
|
||||
7
apps/desktop/src/components/ui/skeleton.tsx
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
import { cn } from '@/lib/utils'
|
||||
|
||||
function Skeleton({ className, ...props }: React.ComponentProps<'div'>) {
|
||||
return <div className={cn('animate-pulse rounded-md bg-accent', className)} data-slot="skeleton" {...props} />
|
||||
}
|
||||
|
||||
export { Skeleton }
|
||||
26
apps/desktop/src/components/ui/switch.tsx
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
import { Switch as SwitchPrimitive } from 'radix-ui'
|
||||
import * as React from 'react'
|
||||
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
function Switch({ className, ...props }: React.ComponentProps<typeof SwitchPrimitive.Root>) {
|
||||
return (
|
||||
<SwitchPrimitive.Root
|
||||
className={cn(
|
||||
'peer inline-flex h-5 w-9 shrink-0 items-center rounded-full border border-transparent bg-input shadow-xs transition-colors outline-none focus-visible:border-ring focus-visible:ring-[0.1875rem] focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary dark:bg-input/80',
|
||||
className
|
||||
)}
|
||||
data-slot="switch"
|
||||
{...props}
|
||||
>
|
||||
<SwitchPrimitive.Thumb
|
||||
className={cn(
|
||||
'pointer-events-none block size-4 rounded-full bg-background shadow-sm ring-0 transition-transform data-[state=checked]:translate-x-4 data-[state=unchecked]:translate-x-0'
|
||||
)}
|
||||
data-slot="switch-thumb"
|
||||
/>
|
||||
</SwitchPrimitive.Root>
|
||||
)
|
||||
}
|
||||
|
||||
export { Switch }
|
||||
36
apps/desktop/src/components/ui/tabs.tsx
Normal file
|
|
@ -0,0 +1,36 @@
|
|||
import { Tabs as TabsPrimitive } from 'radix-ui'
|
||||
import * as React from 'react'
|
||||
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
function Tabs({ className, ...props }: React.ComponentProps<typeof TabsPrimitive.Root>) {
|
||||
return <TabsPrimitive.Root className={cn('flex flex-col gap-2', className)} data-slot="tabs" {...props} />
|
||||
}
|
||||
|
||||
function TabsList({ className, ...props }: React.ComponentProps<typeof TabsPrimitive.List>) {
|
||||
return (
|
||||
<TabsPrimitive.List
|
||||
className={cn(
|
||||
'inline-flex h-9 items-center justify-center rounded-lg bg-muted p-1 text-muted-foreground',
|
||||
className
|
||||
)}
|
||||
data-slot="tabs-list"
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function TabsTrigger({ className, ...props }: React.ComponentProps<typeof TabsPrimitive.Trigger>) {
|
||||
return (
|
||||
<TabsPrimitive.Trigger
|
||||
className={cn(
|
||||
'inline-flex h-7 items-center justify-center gap-1.5 rounded-md px-3 text-sm font-medium whitespace-nowrap transition-all outline-none focus-visible:ring-[0.1875rem] focus-visible:ring-ring/35 disabled:pointer-events-none disabled:opacity-50 data-[state=active]:bg-background data-[state=active]:text-foreground data-[state=active]:shadow-xs [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0',
|
||||
className
|
||||
)}
|
||||
data-slot="tabs-trigger"
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Tabs, TabsList, TabsTrigger }
|
||||
18
apps/desktop/src/components/ui/textarea.tsx
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
import * as React from 'react'
|
||||
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
function Textarea({ className, ...props }: React.ComponentProps<'textarea'>) {
|
||||
return (
|
||||
<textarea
|
||||
className={cn(
|
||||
'min-h-16 w-full rounded-md border border-input bg-transparent px-3 py-2 text-sm shadow-xs transition-[color,box-shadow] outline-none placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-[0.1875rem] focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-destructive/20',
|
||||
className
|
||||
)}
|
||||
data-slot="textarea"
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Textarea }
|
||||
42
apps/desktop/src/components/ui/tooltip.tsx
Normal file
|
|
@ -0,0 +1,42 @@
|
|||
import { Tooltip as TooltipPrimitive } from 'radix-ui'
|
||||
import * as React from 'react'
|
||||
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
function TooltipProvider({ delayDuration = 0, ...props }: React.ComponentProps<typeof TooltipPrimitive.Provider>) {
|
||||
return <TooltipPrimitive.Provider data-slot="tooltip-provider" delayDuration={delayDuration} {...props} />
|
||||
}
|
||||
|
||||
function Tooltip({ ...props }: React.ComponentProps<typeof TooltipPrimitive.Root>) {
|
||||
return <TooltipPrimitive.Root data-slot="tooltip" {...props} />
|
||||
}
|
||||
|
||||
function TooltipTrigger({ ...props }: React.ComponentProps<typeof TooltipPrimitive.Trigger>) {
|
||||
return <TooltipPrimitive.Trigger data-slot="tooltip-trigger" {...props} />
|
||||
}
|
||||
|
||||
function TooltipContent({
|
||||
className,
|
||||
sideOffset = 0,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<typeof TooltipPrimitive.Content>) {
|
||||
return (
|
||||
<TooltipPrimitive.Portal>
|
||||
<TooltipPrimitive.Content
|
||||
className={cn(
|
||||
'z-50 w-fit origin-(--radix-tooltip-content-transform-origin) animate-in rounded-md bg-foreground px-3 py-1.5 text-xs text-balance text-background fade-in-0 zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95',
|
||||
className
|
||||
)}
|
||||
data-slot="tooltip-content"
|
||||
sideOffset={sideOffset}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<TooltipPrimitive.Arrow className="z-50 size-2.5 translate-y-[calc(-50%_-_0.125rem)] rotate-45 rounded-[0.125rem] bg-foreground fill-foreground" />
|
||||
</TooltipPrimitive.Content>
|
||||
</TooltipPrimitive.Portal>
|
||||
)
|
||||
}
|
||||
|
||||
export { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger }
|
||||
50
apps/desktop/src/global.d.ts
vendored
Normal file
|
|
@ -0,0 +1,50 @@
|
|||
export {}
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
hermesDesktop: {
|
||||
getConnection: () => Promise<HermesConnection>
|
||||
api: <T>(request: HermesApiRequest) => Promise<T>
|
||||
notify: (payload: HermesNotification) => Promise<boolean>
|
||||
readFileDataUrl: (filePath: string) => Promise<string>
|
||||
selectPaths: (options?: HermesSelectPathsOptions) => Promise<string[]>
|
||||
writeClipboard: (text: string) => Promise<boolean>
|
||||
saveImageFromUrl: (url: string) => Promise<boolean>
|
||||
openExternal: (url: string) => Promise<void>
|
||||
onBackendExit: (callback: (payload: BackendExit) => void) => () => void
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export interface HermesConnection {
|
||||
baseUrl: string
|
||||
token: string
|
||||
wsUrl: string
|
||||
logs: string[]
|
||||
windowButtonPosition: { x: number; y: number } | null
|
||||
}
|
||||
|
||||
export interface HermesApiRequest {
|
||||
path: string
|
||||
method?: string
|
||||
body?: unknown
|
||||
}
|
||||
|
||||
export interface HermesNotification {
|
||||
title?: string
|
||||
body?: string
|
||||
silent?: boolean
|
||||
}
|
||||
|
||||
export interface HermesSelectPathsOptions {
|
||||
title?: string
|
||||
defaultPath?: string
|
||||
directories?: boolean
|
||||
multiple?: boolean
|
||||
filters?: Array<{ name: string; extensions: string[] }>
|
||||
}
|
||||
|
||||
export interface BackendExit {
|
||||
code: number | null
|
||||
signal: string | null
|
||||
}
|
||||
326
apps/desktop/src/hermes.ts
Normal file
|
|
@ -0,0 +1,326 @@
|
|||
import type {
|
||||
ConfigSchemaResponse,
|
||||
EnvVarInfo,
|
||||
HermesConfig,
|
||||
HermesConfigRecord,
|
||||
ModelInfoResponse,
|
||||
ModelOptionsResponse,
|
||||
PaginatedSessions,
|
||||
RpcEvent,
|
||||
SessionInfo,
|
||||
SessionMessagesResponse,
|
||||
SkillInfo,
|
||||
ToolsetInfo
|
||||
} from '@/types/hermes'
|
||||
|
||||
export type {
|
||||
ConfigFieldSchema,
|
||||
ConfigSchemaResponse,
|
||||
EnvVarInfo,
|
||||
GatewayReadyPayload,
|
||||
HermesConfig,
|
||||
HermesConfigRecord,
|
||||
ModelInfoResponse,
|
||||
ModelOptionProvider,
|
||||
ModelOptionsResponse,
|
||||
PaginatedSessions,
|
||||
RpcEvent,
|
||||
SessionCreateResponse,
|
||||
SessionInfo,
|
||||
SessionMessage,
|
||||
SessionMessagesResponse,
|
||||
SessionResumeResponse,
|
||||
SessionRuntimeInfo,
|
||||
SkillInfo,
|
||||
ToolsetInfo
|
||||
} from '@/types/hermes'
|
||||
|
||||
type PendingCall = {
|
||||
resolve: (value: unknown) => void
|
||||
reject: (error: Error) => void
|
||||
}
|
||||
|
||||
export class HermesGateway {
|
||||
private socket: WebSocket | null = null
|
||||
private nextId = 1
|
||||
private pending = new Map<number, PendingCall>()
|
||||
private eventHandlers = new Set<(event: RpcEvent) => void>()
|
||||
private stateHandlers = new Set<(state: string) => void>()
|
||||
|
||||
async connect(wsUrl: string): Promise<void> {
|
||||
if (this.socket?.readyState === WebSocket.OPEN) {
|
||||
return
|
||||
}
|
||||
|
||||
this.setState('connecting')
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const ws = new WebSocket(wsUrl)
|
||||
this.socket = ws
|
||||
|
||||
ws.addEventListener('open', () => {
|
||||
this.setState('open')
|
||||
resolve()
|
||||
})
|
||||
|
||||
ws.addEventListener('error', () => {
|
||||
this.setState('error')
|
||||
reject(new Error('Could not connect to Hermes gateway'))
|
||||
})
|
||||
|
||||
ws.addEventListener('close', () => {
|
||||
this.setState('closed')
|
||||
|
||||
for (const call of this.pending.values()) {
|
||||
call.reject(new Error('Hermes gateway connection closed'))
|
||||
}
|
||||
|
||||
this.pending.clear()
|
||||
})
|
||||
|
||||
ws.addEventListener('message', message => {
|
||||
this.handleMessage(message.data)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
close(): void {
|
||||
this.socket?.close()
|
||||
this.socket = null
|
||||
}
|
||||
|
||||
onEvent(handler: (event: RpcEvent) => void): () => void {
|
||||
this.eventHandlers.add(handler)
|
||||
|
||||
return () => this.eventHandlers.delete(handler)
|
||||
}
|
||||
|
||||
onState(handler: (state: string) => void): () => void {
|
||||
this.stateHandlers.add(handler)
|
||||
|
||||
return () => this.stateHandlers.delete(handler)
|
||||
}
|
||||
|
||||
request<T>(method: string, params: Record<string, unknown> = {}): Promise<T> {
|
||||
const socket = this.socket
|
||||
|
||||
if (!socket || socket.readyState !== WebSocket.OPEN) {
|
||||
return Promise.reject(new Error('Hermes gateway is not connected'))
|
||||
}
|
||||
|
||||
const id = this.nextId++
|
||||
|
||||
const payload = JSON.stringify({
|
||||
jsonrpc: '2.0',
|
||||
id,
|
||||
method,
|
||||
params
|
||||
})
|
||||
|
||||
return new Promise<T>((resolve, reject) => {
|
||||
this.pending.set(id, {
|
||||
resolve: value => resolve(value as T),
|
||||
reject
|
||||
})
|
||||
socket.send(payload)
|
||||
})
|
||||
}
|
||||
|
||||
private handleMessage(raw: unknown): void {
|
||||
const text = typeof raw === 'string' ? raw : String(raw)
|
||||
|
||||
let frame: {
|
||||
id?: number
|
||||
result?: unknown
|
||||
error?: { message?: string }
|
||||
method?: string
|
||||
params?: RpcEvent
|
||||
}
|
||||
|
||||
try {
|
||||
frame = JSON.parse(text)
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
|
||||
if (typeof frame.id === 'number') {
|
||||
const call = this.pending.get(frame.id)
|
||||
|
||||
if (!call) {
|
||||
return
|
||||
}
|
||||
|
||||
this.pending.delete(frame.id)
|
||||
|
||||
if (frame.error) {
|
||||
call.reject(new Error(frame.error.message || 'Hermes RPC failed'))
|
||||
} else {
|
||||
call.resolve(frame.result)
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
if (frame.method === 'event' && frame.params) {
|
||||
for (const handler of this.eventHandlers) {
|
||||
handler(frame.params)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private setState(state: string): void {
|
||||
for (const handler of this.stateHandlers) {
|
||||
handler(state)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export async function listSessions(limit = 40): Promise<PaginatedSessions> {
|
||||
const pageSize = Math.max(limit, 50)
|
||||
const collected: SessionInfo[] = []
|
||||
let offset = 0
|
||||
let total = 0
|
||||
|
||||
while (collected.length < limit) {
|
||||
const result = await window.hermesDesktop.api<PaginatedSessions>({
|
||||
path: `/api/sessions?limit=${pageSize}&offset=${offset}`
|
||||
})
|
||||
|
||||
total = result.total
|
||||
collected.push(...result.sessions.filter(session => session.message_count > 0))
|
||||
|
||||
offset += result.sessions.length
|
||||
|
||||
if (result.sessions.length === 0 || offset >= result.total) {
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
sessions: collected.slice(0, limit),
|
||||
total,
|
||||
limit,
|
||||
offset: 0
|
||||
}
|
||||
}
|
||||
|
||||
export function getSessionMessages(id: string): Promise<SessionMessagesResponse> {
|
||||
return window.hermesDesktop.api<SessionMessagesResponse>({
|
||||
path: `/api/sessions/${encodeURIComponent(id)}/messages`
|
||||
})
|
||||
}
|
||||
|
||||
export function deleteSession(id: string): Promise<{ ok: boolean }> {
|
||||
return window.hermesDesktop.api<{ ok: boolean }>({
|
||||
path: `/api/sessions/${encodeURIComponent(id)}`,
|
||||
method: 'DELETE'
|
||||
})
|
||||
}
|
||||
|
||||
export function getGlobalModelInfo(): Promise<ModelInfoResponse> {
|
||||
return window.hermesDesktop.api<ModelInfoResponse>({
|
||||
path: '/api/model/info'
|
||||
})
|
||||
}
|
||||
|
||||
export function getHermesConfig(): Promise<HermesConfig> {
|
||||
return window.hermesDesktop.api<HermesConfig>({
|
||||
path: '/api/config'
|
||||
})
|
||||
}
|
||||
|
||||
export function getHermesConfigRecord(): Promise<HermesConfigRecord> {
|
||||
return window.hermesDesktop.api<HermesConfigRecord>({
|
||||
path: '/api/config'
|
||||
})
|
||||
}
|
||||
|
||||
export function getHermesConfigDefaults(): Promise<HermesConfigRecord> {
|
||||
return window.hermesDesktop.api<HermesConfigRecord>({
|
||||
path: '/api/config/defaults'
|
||||
})
|
||||
}
|
||||
|
||||
export function getHermesConfigSchema(): Promise<ConfigSchemaResponse> {
|
||||
return window.hermesDesktop.api<ConfigSchemaResponse>({
|
||||
path: '/api/config/schema'
|
||||
})
|
||||
}
|
||||
|
||||
export function saveHermesConfig(config: HermesConfigRecord): Promise<{ ok: boolean }> {
|
||||
return window.hermesDesktop.api<{ ok: boolean }>({
|
||||
path: '/api/config',
|
||||
method: 'PUT',
|
||||
body: { config }
|
||||
})
|
||||
}
|
||||
|
||||
export function getEnvVars(): Promise<Record<string, EnvVarInfo>> {
|
||||
return window.hermesDesktop.api<Record<string, EnvVarInfo>>({
|
||||
path: '/api/env'
|
||||
})
|
||||
}
|
||||
|
||||
export function setEnvVar(key: string, value: string): Promise<{ ok: boolean }> {
|
||||
return window.hermesDesktop.api<{ ok: boolean }>({
|
||||
path: '/api/env',
|
||||
method: 'PUT',
|
||||
body: { key, value }
|
||||
})
|
||||
}
|
||||
|
||||
export function deleteEnvVar(key: string): Promise<{ ok: boolean }> {
|
||||
return window.hermesDesktop.api<{ ok: boolean }>({
|
||||
path: '/api/env',
|
||||
method: 'DELETE',
|
||||
body: { key }
|
||||
})
|
||||
}
|
||||
|
||||
export function revealEnvVar(key: string): Promise<{ key: string; value: string }> {
|
||||
return window.hermesDesktop.api<{ key: string; value: string }>({
|
||||
path: '/api/env/reveal',
|
||||
method: 'POST',
|
||||
body: { key }
|
||||
})
|
||||
}
|
||||
|
||||
export function getSkills(): Promise<SkillInfo[]> {
|
||||
return window.hermesDesktop.api<SkillInfo[]>({
|
||||
path: '/api/skills'
|
||||
})
|
||||
}
|
||||
|
||||
export function toggleSkill(name: string, enabled: boolean): Promise<{ ok: boolean; name: string; enabled: boolean }> {
|
||||
return window.hermesDesktop.api<{ ok: boolean; name: string; enabled: boolean }>({
|
||||
path: '/api/skills/toggle',
|
||||
method: 'PUT',
|
||||
body: { name, enabled }
|
||||
})
|
||||
}
|
||||
|
||||
export function getToolsets(): Promise<ToolsetInfo[]> {
|
||||
return window.hermesDesktop.api<ToolsetInfo[]>({
|
||||
path: '/api/tools/toolsets'
|
||||
})
|
||||
}
|
||||
|
||||
export function getGlobalModelOptions(): Promise<ModelOptionsResponse> {
|
||||
return window.hermesDesktop.api<ModelOptionsResponse>({
|
||||
path: '/api/model/options'
|
||||
})
|
||||
}
|
||||
|
||||
export function setGlobalModel(
|
||||
provider: string,
|
||||
model: string
|
||||
): Promise<{ ok: boolean; provider: string; model: string }> {
|
||||
return window.hermesDesktop.api<{ ok: boolean; provider: string; model: string }>({
|
||||
path: '/api/model/set',
|
||||
method: 'POST',
|
||||
body: {
|
||||
scope: 'main',
|
||||
provider,
|
||||
model
|
||||
}
|
||||
})
|
||||
}
|
||||
24
apps/desktop/src/hooks/use-mobile.ts
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
import * as React from 'react'
|
||||
|
||||
const MOBILE_BREAKPOINT = 768
|
||||
const MOBILE_BREAKPOINT_REM = MOBILE_BREAKPOINT / 16
|
||||
const ONE_PIXEL_IN_REM = 1 / 16
|
||||
|
||||
export function useIsMobile() {
|
||||
const [isMobile, setIsMobile] = React.useState<boolean | undefined>(undefined)
|
||||
|
||||
React.useEffect(() => {
|
||||
const mql = window.matchMedia(`(max-width: ${MOBILE_BREAKPOINT_REM - ONE_PIXEL_IN_REM}rem)`)
|
||||
|
||||
const onChange = () => {
|
||||
setIsMobile(window.innerWidth < MOBILE_BREAKPOINT)
|
||||
}
|
||||
|
||||
mql.addEventListener('change', onChange)
|
||||
setIsMobile(window.innerWidth < MOBILE_BREAKPOINT)
|
||||
|
||||
return () => mql.removeEventListener('change', onChange)
|
||||
}, [])
|
||||
|
||||
return !!isMobile
|
||||
}
|
||||
351
apps/desktop/src/lib/chat-messages.ts
Normal file
|
|
@ -0,0 +1,351 @@
|
|||
import type { ThreadMessageLike } from '@assistant-ui/react'
|
||||
|
||||
import type { SessionMessage } from '@/types/hermes'
|
||||
|
||||
export type ChatMessagePart = Exclude<ThreadMessageLike['content'], string>[number]
|
||||
|
||||
export type ChatMessage = {
|
||||
id: string
|
||||
role: SessionMessage['role']
|
||||
parts: ChatMessagePart[]
|
||||
timestamp?: number
|
||||
pending?: boolean
|
||||
branchGroupId?: string
|
||||
hidden?: boolean
|
||||
}
|
||||
|
||||
export type GatewayEventPayload = {
|
||||
text?: string
|
||||
rendered?: string
|
||||
status?: string
|
||||
message?: string
|
||||
name?: string
|
||||
tool_id?: string
|
||||
context?: string
|
||||
preview?: string
|
||||
summary?: string
|
||||
error?: string | boolean
|
||||
duration_s?: number
|
||||
todos?: unknown
|
||||
model?: string
|
||||
provider?: string
|
||||
cwd?: string
|
||||
branch?: string
|
||||
personality?: string
|
||||
}
|
||||
|
||||
export function textPart(text: string): ChatMessagePart {
|
||||
return { type: 'text', text }
|
||||
}
|
||||
|
||||
export function reasoningPart(text: string): ChatMessagePart {
|
||||
return { type: 'reasoning', text }
|
||||
}
|
||||
|
||||
export function chatMessageText(message: ChatMessage): string {
|
||||
return message.parts
|
||||
.filter((part): part is Extract<ChatMessagePart, { type: 'text' }> => part.type === 'text')
|
||||
.map(part => part.text)
|
||||
.join('')
|
||||
}
|
||||
|
||||
export function appendTextPart(parts: ChatMessagePart[], delta: string): ChatMessagePart[] {
|
||||
const next = [...parts]
|
||||
const last = next.at(-1)
|
||||
|
||||
if (last?.type === 'text') {
|
||||
next[next.length - 1] = { ...last, text: `${last.text}${delta}` }
|
||||
|
||||
return next
|
||||
}
|
||||
|
||||
next.push(textPart(delta))
|
||||
|
||||
return next
|
||||
}
|
||||
|
||||
export function appendReasoningPart(parts: ChatMessagePart[], delta: string): ChatMessagePart[] {
|
||||
const next = [...parts]
|
||||
const last = next.at(-1)
|
||||
|
||||
if (last?.type === 'reasoning') {
|
||||
next[next.length - 1] = { ...last, text: `${last.text}${delta}` }
|
||||
|
||||
return next
|
||||
}
|
||||
|
||||
next.push(reasoningPart(delta))
|
||||
|
||||
return next
|
||||
}
|
||||
|
||||
export function hasToolPart(message: ChatMessage): boolean {
|
||||
return message.parts.some(part => part.type === 'tool-call')
|
||||
}
|
||||
|
||||
function toolId(payload: GatewayEventPayload | undefined): string {
|
||||
return payload?.tool_id || payload?.name || `tool-${Date.now()}`
|
||||
}
|
||||
|
||||
function toolArgs(payload: GatewayEventPayload | undefined): Record<string, unknown> {
|
||||
return {
|
||||
...(payload?.context ? { context: payload.context } : {}),
|
||||
...(payload?.preview ? { preview: payload.preview } : {})
|
||||
}
|
||||
}
|
||||
|
||||
function toolResult(payload: GatewayEventPayload | undefined): Record<string, unknown> {
|
||||
return {
|
||||
...(payload?.summary ? { summary: payload.summary } : {}),
|
||||
...(payload?.message ? { message: payload.message } : {}),
|
||||
...(payload?.preview ? { preview: payload.preview } : {}),
|
||||
...(payload?.duration_s !== undefined ? { duration_s: payload.duration_s } : {}),
|
||||
...(payload?.todos ? { todos: payload.todos } : {}),
|
||||
...(payload?.error ? { error: payload.error } : {})
|
||||
}
|
||||
}
|
||||
|
||||
export function upsertToolPart(
|
||||
parts: ChatMessagePart[],
|
||||
payload: GatewayEventPayload | undefined,
|
||||
phase: 'running' | 'complete'
|
||||
): ChatMessagePart[] {
|
||||
const id = toolId(payload)
|
||||
const name = payload?.name || 'tool'
|
||||
const next = [...parts]
|
||||
|
||||
const index = next.findIndex(
|
||||
part => part.type === 'tool-call' && ((part.toolCallId && part.toolCallId === id) || part.toolName === name)
|
||||
)
|
||||
|
||||
const base = {
|
||||
type: 'tool-call' as const,
|
||||
toolCallId: id,
|
||||
toolName: name,
|
||||
args: toolArgs(payload) as never,
|
||||
argsText: JSON.stringify(toolArgs(payload)),
|
||||
...(phase === 'complete'
|
||||
? {
|
||||
result: toolResult(payload),
|
||||
isError: Boolean(payload?.error)
|
||||
}
|
||||
: {})
|
||||
} satisfies ChatMessagePart
|
||||
|
||||
if (index === -1) {
|
||||
return [...next, base]
|
||||
}
|
||||
|
||||
next[index] = { ...next[index], ...base }
|
||||
|
||||
return next
|
||||
}
|
||||
|
||||
function recordFromUnknown(value: unknown): Record<string, unknown> | null {
|
||||
return value && typeof value === 'object' ? (value as Record<string, unknown>) : null
|
||||
}
|
||||
|
||||
function parseMaybeJsonObject(value: unknown): Record<string, unknown> {
|
||||
if (value && typeof value === 'object' && !Array.isArray(value)) {
|
||||
return value as Record<string, unknown>
|
||||
}
|
||||
|
||||
if (typeof value !== 'string' || !value.trim()) {
|
||||
return {}
|
||||
}
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(value)
|
||||
|
||||
return parsed && typeof parsed === 'object' && !Array.isArray(parsed) ? (parsed as Record<string, unknown>) : {}
|
||||
} catch {
|
||||
return {}
|
||||
}
|
||||
}
|
||||
|
||||
function firstNonEmptyObject(...values: unknown[]): Record<string, unknown> {
|
||||
for (const value of values) {
|
||||
const parsed = parseMaybeJsonObject(value)
|
||||
|
||||
if (Object.keys(parsed).length > 0) {
|
||||
return parsed
|
||||
}
|
||||
}
|
||||
|
||||
return {}
|
||||
}
|
||||
|
||||
function parseStoredToolResult(content: string): unknown {
|
||||
if (!content.trim()) {
|
||||
return ''
|
||||
}
|
||||
|
||||
try {
|
||||
return JSON.parse(content)
|
||||
} catch {
|
||||
return content
|
||||
}
|
||||
}
|
||||
|
||||
function toolPartFromStoredCall(call: unknown, fallbackIndex: number): ChatMessagePart {
|
||||
const row = recordFromUnknown(call) ?? {}
|
||||
const fn = recordFromUnknown(row.function)
|
||||
const id = String(row.id || row.tool_call_id || `stored-tool-${fallbackIndex}`)
|
||||
|
||||
const toolName = String(
|
||||
row.name || row.tool_name || fn?.name || (recordFromUnknown(row.input)?.name as string | undefined) || 'tool'
|
||||
)
|
||||
|
||||
const args = firstNonEmptyObject(fn?.arguments, row.arguments, row.args, row.input)
|
||||
|
||||
return {
|
||||
type: 'tool-call',
|
||||
toolCallId: id,
|
||||
toolName,
|
||||
args: args as never,
|
||||
argsText: Object.keys(args).length ? JSON.stringify(args) : ''
|
||||
}
|
||||
}
|
||||
|
||||
function applyStoredToolResult(messages: ChatMessage[], toolMessage: SessionMessage): boolean {
|
||||
const toolCallId = toolMessage.tool_call_id || undefined
|
||||
const toolName = toolMessage.tool_name || toolMessage.name || 'tool'
|
||||
const content = toolMessage.content || toolMessage.text || toolMessage.context || toolMessage.name || ''
|
||||
|
||||
for (let i = messages.length - 1; i >= 0; i -= 1) {
|
||||
const message = messages[i]
|
||||
|
||||
if (message.role !== 'assistant') {
|
||||
continue
|
||||
}
|
||||
|
||||
const partIndex = message.parts.findIndex(
|
||||
part =>
|
||||
part.type === 'tool-call' &&
|
||||
((toolCallId && part.toolCallId === toolCallId) || (!toolCallId && part.toolName === toolName))
|
||||
)
|
||||
|
||||
if (partIndex < 0) {
|
||||
continue
|
||||
}
|
||||
|
||||
const parts = [...message.parts]
|
||||
const existing = parts[partIndex]
|
||||
parts[partIndex] = {
|
||||
...existing,
|
||||
result: parseStoredToolResult(content),
|
||||
isError: false
|
||||
} as ChatMessagePart
|
||||
messages[i] = { ...message, parts }
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
function storedToolMessagePart(toolMessage: SessionMessage, fallbackIndex: number): ChatMessagePart {
|
||||
const name = toolMessage.tool_name || toolMessage.name || 'tool'
|
||||
const context = toolMessage.context || toolMessage.text || toolMessage.content || ''
|
||||
const args = context ? { context } : {}
|
||||
|
||||
return {
|
||||
type: 'tool-call',
|
||||
toolCallId: toolMessage.tool_call_id || `stored-tool-message-${fallbackIndex}`,
|
||||
toolName: name,
|
||||
args: args as never,
|
||||
argsText: Object.keys(args).length ? JSON.stringify(args) : '',
|
||||
result: context ? { context } : {},
|
||||
isError: false
|
||||
}
|
||||
}
|
||||
|
||||
export function toChatMessages(messages: SessionMessage[]): ChatMessage[] {
|
||||
const result: ChatMessage[] = []
|
||||
let pendingToolParts: ChatMessagePart[] = []
|
||||
let pendingToolTimestamp: number | undefined
|
||||
|
||||
const flushPendingTools = (index: number) => {
|
||||
if (!pendingToolParts.length) {
|
||||
return
|
||||
}
|
||||
|
||||
result.push({
|
||||
id: `${pendingToolTimestamp || Date.now()}-${index}-tools`,
|
||||
role: 'assistant',
|
||||
parts: pendingToolParts,
|
||||
timestamp: pendingToolTimestamp
|
||||
})
|
||||
pendingToolParts = []
|
||||
pendingToolTimestamp = undefined
|
||||
}
|
||||
|
||||
messages.forEach((message, index) => {
|
||||
if (message.role === 'tool') {
|
||||
if (applyStoredToolResult(result, message)) {
|
||||
return
|
||||
}
|
||||
|
||||
pendingToolParts = [...pendingToolParts, storedToolMessagePart(message, index)]
|
||||
pendingToolTimestamp ??= message.timestamp
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
const content = message.content || message.text || message.context || message.name || ''
|
||||
const parts: ChatMessagePart[] = []
|
||||
|
||||
const reasoning =
|
||||
message.reasoning ||
|
||||
message.reasoning_content ||
|
||||
(typeof message.reasoning_details === 'string' ? message.reasoning_details : '')
|
||||
|
||||
if (reasoning && message.role === 'assistant') {
|
||||
parts.push(reasoningPart(reasoning))
|
||||
}
|
||||
|
||||
if (content) {
|
||||
parts.push(textPart(content))
|
||||
}
|
||||
|
||||
if (message.role === 'assistant' && Array.isArray(message.tool_calls)) {
|
||||
parts.push(...message.tool_calls.map((call, callIndex) => toolPartFromStoredCall(call, callIndex)))
|
||||
}
|
||||
|
||||
if (!parts.length) {
|
||||
return
|
||||
}
|
||||
|
||||
const isToolOnlyAssistant =
|
||||
message.role === 'assistant' && parts.length > 0 && parts.every(part => part.type === 'tool-call')
|
||||
|
||||
if (isToolOnlyAssistant) {
|
||||
pendingToolParts = [...pendingToolParts, ...parts]
|
||||
pendingToolTimestamp ??= message.timestamp
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
if (message.role === 'assistant' && pendingToolParts.length) {
|
||||
parts.unshift(...pendingToolParts)
|
||||
pendingToolParts = []
|
||||
pendingToolTimestamp = undefined
|
||||
} else if (message.role !== 'assistant') {
|
||||
flushPendingTools(index)
|
||||
}
|
||||
|
||||
result.push({
|
||||
id: `${message.timestamp || Date.now()}-${index}-${message.role}`,
|
||||
role: message.role,
|
||||
parts,
|
||||
timestamp: message.timestamp
|
||||
})
|
||||
})
|
||||
flushPendingTools(messages.length)
|
||||
|
||||
return result.filter(m => chatMessageText(m).trim() || m.parts.some(part => part.type !== 'text'))
|
||||
}
|
||||
|
||||
export function branchGroupForUser(userMessage: ChatMessage): string {
|
||||
return `branch:${userMessage.id}`
|
||||
}
|
||||
307
apps/desktop/src/lib/chat-runtime.ts
Normal file
|
|
@ -0,0 +1,307 @@
|
|||
import type { ThreadMessage } from '@assistant-ui/react'
|
||||
|
||||
import type { ClientSessionState, CommandDispatchResponse } from '@/app/types'
|
||||
import type { QuickModelOption } from '@/components/chat-bar'
|
||||
import { type ChatMessage, type ChatMessagePart, chatMessageText, textPart } from '@/lib/chat-messages'
|
||||
import type { ComposerAttachment } from '@/store/composer'
|
||||
import type { ModelOptionsResponse, SessionInfo } from '@/types/hermes'
|
||||
|
||||
export const INTERRUPTED_MARKER = '\n\n_[interrupted]_'
|
||||
export const SLASH_COMMAND_RE = /^\/[^\s/]*(?:\s|$)/
|
||||
export const BUILTIN_PERSONALITIES = [
|
||||
'helpful',
|
||||
'concise',
|
||||
'technical',
|
||||
'creative',
|
||||
'teacher',
|
||||
'kawaii',
|
||||
'catgirl',
|
||||
'pirate',
|
||||
'shakespeare',
|
||||
'surfer',
|
||||
'noir',
|
||||
'uwu',
|
||||
'philosopher',
|
||||
'hype'
|
||||
]
|
||||
|
||||
const SPINNER_STATUS_RE = /^\s*[((][^\s))]{1,8}[))]\s+[^.\n]{2,48}\.\.\.\s*/
|
||||
|
||||
export function createClientSessionState(
|
||||
storedSessionId: string | null = null,
|
||||
messages: ChatMessage[] = []
|
||||
): ClientSessionState {
|
||||
return {
|
||||
storedSessionId,
|
||||
messages,
|
||||
busy: false,
|
||||
awaitingResponse: false,
|
||||
streamId: null,
|
||||
sawAssistantPayload: false,
|
||||
pendingBranchGroup: null,
|
||||
interrupted: false
|
||||
}
|
||||
}
|
||||
|
||||
export function sessionTitle(session: SessionInfo): string {
|
||||
return session.title?.trim() || session.preview?.trim() || 'Untitled session'
|
||||
}
|
||||
|
||||
export function coerceGatewayText(value: unknown): string {
|
||||
if (typeof value === 'string') {
|
||||
return value
|
||||
}
|
||||
|
||||
if (value === null || value === undefined) {
|
||||
return ''
|
||||
}
|
||||
|
||||
if (Array.isArray(value)) {
|
||||
return value
|
||||
.map(item => {
|
||||
if (typeof item === 'string') {
|
||||
return item
|
||||
}
|
||||
|
||||
if (item && typeof item === 'object') {
|
||||
const row = item as Record<string, unknown>
|
||||
|
||||
if (typeof row.text === 'string') {
|
||||
return row.text
|
||||
}
|
||||
|
||||
if (typeof row.output_text === 'string') {
|
||||
return row.output_text
|
||||
}
|
||||
}
|
||||
|
||||
return ''
|
||||
})
|
||||
.join('')
|
||||
}
|
||||
|
||||
if (typeof value === 'object') {
|
||||
const row = value as Record<string, unknown>
|
||||
|
||||
if (typeof row.text === 'string') {
|
||||
return row.text
|
||||
}
|
||||
|
||||
if (typeof row.output_text === 'string') {
|
||||
return row.output_text
|
||||
}
|
||||
|
||||
try {
|
||||
return JSON.stringify(value)
|
||||
} catch {
|
||||
return ''
|
||||
}
|
||||
}
|
||||
|
||||
return String(value)
|
||||
}
|
||||
|
||||
export function coerceThinkingText(value: unknown): string {
|
||||
return coerceGatewayText(value).replace(SPINNER_STATUS_RE, '').trim()
|
||||
}
|
||||
|
||||
export function isImageGenerationTool(name?: string): boolean {
|
||||
return name === 'image_generate'
|
||||
}
|
||||
|
||||
export function contextPath(path: string, cwd: string): string {
|
||||
if (!cwd) {
|
||||
return path
|
||||
}
|
||||
|
||||
const normalizedCwd = cwd.endsWith('/') ? cwd : `${cwd}/`
|
||||
|
||||
return path.startsWith(normalizedCwd) ? path.slice(normalizedCwd.length) : path
|
||||
}
|
||||
|
||||
export function attachmentId(kind: ComposerAttachment['kind'], value: string): string {
|
||||
return `${kind}:${value}`
|
||||
}
|
||||
|
||||
export function pathLabel(path: string): string {
|
||||
return path.split(/[\\/]/).filter(Boolean).pop() || path
|
||||
}
|
||||
|
||||
export function attachmentDisplayText(attachment: ComposerAttachment): string | null {
|
||||
if (attachment.refText) {
|
||||
return attachment.refText
|
||||
}
|
||||
|
||||
if (attachment.kind === 'image') {
|
||||
const id = attachment.detail || attachment.path || attachment.label
|
||||
|
||||
return id ? `@image:${id}` : null
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
export function personalityNamesFromConfig(config: unknown): string[] {
|
||||
const root = config && typeof config === 'object' ? (config as Record<string, unknown>) : {}
|
||||
const agent = root.agent && typeof root.agent === 'object' ? (root.agent as Record<string, unknown>) : {}
|
||||
const personalities = agent.personalities
|
||||
|
||||
return personalities && typeof personalities === 'object' && !Array.isArray(personalities)
|
||||
? Object.keys(personalities as Record<string, unknown>)
|
||||
: []
|
||||
}
|
||||
|
||||
export function normalizePersonalityValue(value: string): string {
|
||||
const trimmed = value.trim().toLowerCase()
|
||||
|
||||
return !trimmed || trimmed === 'default' || trimmed === 'none' ? '' : trimmed
|
||||
}
|
||||
|
||||
export function parseSlashCommand(command: string) {
|
||||
const match = command.replace(/^\/+/, '').match(/^(\S+)\s*(.*)$/)
|
||||
|
||||
return match ? { name: match[1], arg: match[2].trim() } : { name: '', arg: '' }
|
||||
}
|
||||
|
||||
export function parseCommandDispatch(raw: unknown): CommandDispatchResponse | null {
|
||||
if (!raw || typeof raw !== 'object') {
|
||||
return null
|
||||
}
|
||||
|
||||
const row = raw as Record<string, unknown>
|
||||
const str = (value: unknown) => (typeof value === 'string' ? value : undefined)
|
||||
|
||||
switch (row.type) {
|
||||
case 'exec':
|
||||
|
||||
case 'plugin':
|
||||
return { type: row.type, output: str(row.output) }
|
||||
|
||||
case 'alias':
|
||||
return typeof row.target === 'string' ? { type: 'alias', target: row.target } : null
|
||||
|
||||
case 'skill':
|
||||
return typeof row.name === 'string' ? { type: 'skill', name: row.name, message: str(row.message) } : null
|
||||
|
||||
case 'send':
|
||||
return typeof row.message === 'string' ? { type: 'send', message: row.message } : null
|
||||
|
||||
default:
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
export function quickModelOptions(
|
||||
data: ModelOptionsResponse | undefined,
|
||||
currentProvider: string,
|
||||
currentModel: string
|
||||
): QuickModelOption[] {
|
||||
const seen = new Set<string>()
|
||||
const options: QuickModelOption[] = []
|
||||
|
||||
const providers = [...(data?.providers ?? [])].sort((a, b) => {
|
||||
if (a.slug === currentProvider) {
|
||||
return -1
|
||||
}
|
||||
|
||||
if (b.slug === currentProvider) {
|
||||
return 1
|
||||
}
|
||||
|
||||
if (a.is_current) {
|
||||
return -1
|
||||
}
|
||||
|
||||
if (b.is_current) {
|
||||
return 1
|
||||
}
|
||||
|
||||
return 0
|
||||
})
|
||||
|
||||
const add = (provider: string, providerName: string, model: string) => {
|
||||
const key = `${provider}:${model}`
|
||||
|
||||
if (!model || seen.has(key)) {
|
||||
return
|
||||
}
|
||||
|
||||
seen.add(key)
|
||||
options.push({ provider, providerName, model })
|
||||
}
|
||||
|
||||
if (currentProvider && currentModel) {
|
||||
add(currentProvider, currentProvider, currentModel)
|
||||
}
|
||||
|
||||
for (const provider of providers) {
|
||||
const models = [...(provider.models ?? [])].sort((a, b) => {
|
||||
if (provider.slug === currentProvider && a === currentModel) {
|
||||
return -1
|
||||
}
|
||||
|
||||
if (provider.slug === currentProvider && b === currentModel) {
|
||||
return 1
|
||||
}
|
||||
|
||||
return 0
|
||||
})
|
||||
|
||||
for (const model of models) {
|
||||
add(provider.slug, provider.name, model)
|
||||
}
|
||||
|
||||
if (options.length >= 8) {
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
return options.slice(0, 8)
|
||||
}
|
||||
|
||||
export function toRuntimeMessage(message: ChatMessage): ThreadMessage {
|
||||
const role =
|
||||
message.role === 'user' || message.role === 'assistant' || message.role === 'system' ? message.role : 'assistant'
|
||||
|
||||
const createdAt = message.timestamp
|
||||
? new Date(message.timestamp * 1000)
|
||||
: new Date(Number(message.id.match(/\d+/)?.[0]) || Date.now())
|
||||
|
||||
if (role === 'user') {
|
||||
return {
|
||||
id: message.id,
|
||||
role,
|
||||
content: message.parts.filter((part): part is Extract<ChatMessagePart, { type: 'text' }> => part.type === 'text'),
|
||||
attachments: [],
|
||||
createdAt,
|
||||
metadata: { custom: {} }
|
||||
} as ThreadMessage
|
||||
}
|
||||
|
||||
if (role === 'system') {
|
||||
const text = chatMessageText(message)
|
||||
|
||||
return {
|
||||
id: message.id,
|
||||
role,
|
||||
content: [textPart(text)],
|
||||
createdAt,
|
||||
metadata: { custom: {} }
|
||||
} as ThreadMessage
|
||||
}
|
||||
|
||||
return {
|
||||
id: message.id,
|
||||
role,
|
||||
content: message.parts as Extract<ThreadMessage, { role: 'assistant' }>['content'],
|
||||
createdAt,
|
||||
status: message.pending ? { type: 'running' } : { type: 'complete', reason: 'stop' },
|
||||
metadata: {
|
||||
unstable_state: null,
|
||||
unstable_annotations: [],
|
||||
unstable_data: [],
|
||||
steps: [],
|
||||
custom: {}
|
||||
}
|
||||
} as ThreadMessage
|
||||
}
|
||||
57
apps/desktop/src/lib/storage.ts
Normal file
|
|
@ -0,0 +1,57 @@
|
|||
export function storedBoolean(key: string, fallback: boolean): boolean {
|
||||
try {
|
||||
const value = window.localStorage.getItem(key)
|
||||
|
||||
return value === null ? fallback : value === 'true'
|
||||
} catch {
|
||||
return fallback
|
||||
}
|
||||
}
|
||||
|
||||
export function persistBoolean(key: string, value: boolean) {
|
||||
try {
|
||||
window.localStorage.setItem(key, String(value))
|
||||
} catch {
|
||||
// Local storage is a convenience; ignore failures in restricted contexts.
|
||||
}
|
||||
}
|
||||
|
||||
export function storedStringArray(key: string): string[] {
|
||||
try {
|
||||
const value = window.localStorage.getItem(key)
|
||||
|
||||
if (!value) {
|
||||
return []
|
||||
}
|
||||
|
||||
const parsed = JSON.parse(value)
|
||||
|
||||
if (!Array.isArray(parsed)) {
|
||||
return []
|
||||
}
|
||||
|
||||
return parsed.filter((item): item is string => typeof item === 'string' && item.length > 0)
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
export function persistStringArray(key: string, value: string[]) {
|
||||
try {
|
||||
window.localStorage.setItem(key, JSON.stringify(value))
|
||||
} catch {
|
||||
// Pins are a local preference; restricted storage should not break chat.
|
||||
}
|
||||
}
|
||||
|
||||
export function arraysEqual(left: string[], right: string[]) {
|
||||
return left.length === right.length && left.every((item, index) => item === right[index])
|
||||
}
|
||||
|
||||
export function insertUniqueId(ids: string[], id: string, index: number) {
|
||||
const next = ids.filter(item => item !== id)
|
||||
const boundedIndex = Math.min(Math.max(index, 0), next.length)
|
||||
next.splice(boundedIndex, 0, id)
|
||||
|
||||
return next
|
||||
}
|
||||
6
apps/desktop/src/lib/utils.ts
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
import { type ClassValue, clsx } from 'clsx'
|
||||
import { twMerge } from 'tailwind-merge'
|
||||
|
||||
export function cn(...inputs: ClassValue[]) {
|
||||
return twMerge(clsx(inputs))
|
||||
}
|
||||
27
apps/desktop/src/main.tsx
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
import './styles.css'
|
||||
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||
import { StrictMode } from 'react'
|
||||
import { createRoot } from 'react-dom/client'
|
||||
|
||||
import App from './app'
|
||||
import { ThemeProvider } from './themes/context'
|
||||
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
refetchOnWindowFocus: false,
|
||||
staleTime: 60_000
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
createRoot(document.getElementById('root')!).render(
|
||||
<StrictMode>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<ThemeProvider>
|
||||
<App />
|
||||
</ThemeProvider>
|
||||
</QueryClientProvider>
|
||||
</StrictMode>
|
||||
)
|
||||
51
apps/desktop/src/store/composer.ts
Normal file
|
|
@ -0,0 +1,51 @@
|
|||
import { atom } from 'nanostores'
|
||||
|
||||
export interface ComposerAttachment {
|
||||
id: string
|
||||
kind: 'image' | 'file' | 'folder' | 'url'
|
||||
label: string
|
||||
detail?: string
|
||||
refText?: string
|
||||
previewUrl?: string
|
||||
path?: string
|
||||
}
|
||||
|
||||
export const $composerDraft = atom('')
|
||||
export const $composerAttachments = atom<ComposerAttachment[]>([])
|
||||
|
||||
export function setComposerDraft(value: string) {
|
||||
$composerDraft.set(value)
|
||||
}
|
||||
|
||||
export function clearComposerDraft() {
|
||||
$composerDraft.set('')
|
||||
}
|
||||
|
||||
export function addComposerAttachment(attachment: ComposerAttachment) {
|
||||
$composerAttachments.set(upsertAttachment($composerAttachments.get(), attachment))
|
||||
}
|
||||
|
||||
export function removeComposerAttachment(id: string): ComposerAttachment | null {
|
||||
const current = $composerAttachments.get()
|
||||
const removed = current.find(attachment => attachment.id === id) || null
|
||||
$composerAttachments.set(current.filter(attachment => attachment.id !== id))
|
||||
|
||||
return removed
|
||||
}
|
||||
|
||||
export function clearComposerAttachments() {
|
||||
$composerAttachments.set([])
|
||||
}
|
||||
|
||||
function upsertAttachment(attachments: ComposerAttachment[], attachment: ComposerAttachment) {
|
||||
const index = attachments.findIndex(item => item.id === attachment.id)
|
||||
|
||||
if (index < 0) {
|
||||
return [...attachments, attachment]
|
||||
}
|
||||
|
||||
const next = [...attachments]
|
||||
next[index] = attachment
|
||||
|
||||
return next
|
||||
}
|
||||
75
apps/desktop/src/store/layout.ts
Normal file
|
|
@ -0,0 +1,75 @@
|
|||
import { atom } from 'nanostores'
|
||||
|
||||
import {
|
||||
arraysEqual,
|
||||
insertUniqueId,
|
||||
persistBoolean,
|
||||
persistStringArray,
|
||||
storedBoolean,
|
||||
storedStringArray
|
||||
} from '@/lib/storage'
|
||||
|
||||
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 $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)
|
||||
}
|
||||
|
||||
export function setSidebarOpen(open: boolean) {
|
||||
$sidebarOpen.set(open)
|
||||
}
|
||||
|
||||
export function toggleSidebarOpen() {
|
||||
$sidebarOpen.set(!$sidebarOpen.get())
|
||||
}
|
||||
|
||||
export function toggleInspectorOpen() {
|
||||
$inspectorOpen.set(!$inspectorOpen.get())
|
||||
}
|
||||
|
||||
export function setSidebarPinsOpen(open: boolean) {
|
||||
$sidebarPinsOpen.set(open)
|
||||
}
|
||||
|
||||
export function setSidebarRecentsOpen(open: boolean) {
|
||||
$sidebarRecentsOpen.set(open)
|
||||
}
|
||||
|
||||
export function setSidebarResizing(resizing: boolean) {
|
||||
$isSidebarResizing.set(resizing)
|
||||
}
|
||||
|
||||
export function pinSession(sessionId: string, index?: number) {
|
||||
const prev = $pinnedSessionIds.get()
|
||||
const next = insertUniqueId(prev, sessionId, index ?? prev.filter(id => id !== sessionId).length)
|
||||
|
||||
if (!arraysEqual(prev, next)) {
|
||||
$pinnedSessionIds.set(next)
|
||||
}
|
||||
}
|
||||
|
||||
export function unpinSession(sessionId: string) {
|
||||
const prev = $pinnedSessionIds.get()
|
||||
const next = prev.filter(id => id !== sessionId)
|
||||
|
||||
if (!arraysEqual(prev, next)) {
|
||||
$pinnedSessionIds.set(next)
|
||||
}
|
||||
}
|
||||
93
apps/desktop/src/store/notifications.ts
Normal file
|
|
@ -0,0 +1,93 @@
|
|||
import { atom } from 'nanostores'
|
||||
|
||||
export type NotificationKind = 'error' | 'warning' | 'info' | 'success'
|
||||
|
||||
export interface AppNotification {
|
||||
id: string
|
||||
kind: NotificationKind
|
||||
title?: string
|
||||
message: string
|
||||
createdAt: number
|
||||
}
|
||||
|
||||
interface NotificationInput {
|
||||
id?: string
|
||||
kind?: NotificationKind
|
||||
title?: string
|
||||
message: string
|
||||
durationMs?: number
|
||||
}
|
||||
|
||||
let notificationCounter = 0
|
||||
const timers = new Map<string, number>()
|
||||
|
||||
export const $notifications = atom<AppNotification[]>([])
|
||||
|
||||
function defaultDuration(kind: NotificationKind) {
|
||||
if (kind === 'error' || kind === 'warning') {
|
||||
return 0
|
||||
}
|
||||
|
||||
return 5_000
|
||||
}
|
||||
|
||||
function readableErrorMessage(error: unknown, fallback: string) {
|
||||
const raw = error instanceof Error ? error.message : typeof error === 'string' ? error : fallback
|
||||
|
||||
const ipcMessage = raw.match(/Error invoking remote method '[^']+': Error: (.+)$/)
|
||||
const message = ipcMessage?.[1] || raw.replace(/^Error:\s*/, '')
|
||||
const detailMatch = message.match(/"detail"\s*:\s*"([^"]+)"/)
|
||||
|
||||
return detailMatch?.[1] || message
|
||||
}
|
||||
|
||||
export function notify(input: NotificationInput): string {
|
||||
const kind = input.kind ?? 'info'
|
||||
const id = input.id ?? `${Date.now()}-${notificationCounter++}`
|
||||
|
||||
const notification: AppNotification = {
|
||||
id,
|
||||
kind,
|
||||
title: input.title,
|
||||
message: input.message,
|
||||
createdAt: Date.now()
|
||||
}
|
||||
|
||||
window.clearTimeout(timers.get(id))
|
||||
timers.delete(id)
|
||||
$notifications.set([notification, ...$notifications.get().filter(item => item.id !== id)].slice(0, 4))
|
||||
|
||||
const duration = input.durationMs ?? defaultDuration(kind)
|
||||
|
||||
if (duration > 0) {
|
||||
timers.set(
|
||||
id,
|
||||
window.setTimeout(() => dismissNotification(id), duration)
|
||||
)
|
||||
}
|
||||
|
||||
return id
|
||||
}
|
||||
|
||||
export function notifyError(error: unknown, fallback: string): string {
|
||||
return notify({
|
||||
kind: 'error',
|
||||
title: fallback,
|
||||
message: readableErrorMessage(error, fallback)
|
||||
})
|
||||
}
|
||||
|
||||
export function dismissNotification(id: string) {
|
||||
window.clearTimeout(timers.get(id))
|
||||
timers.delete(id)
|
||||
$notifications.set($notifications.get().filter(item => item.id !== id))
|
||||
}
|
||||
|
||||
export function clearNotifications() {
|
||||
for (const timer of timers.values()) {
|
||||
window.clearTimeout(timer)
|
||||
}
|
||||
|
||||
timers.clear()
|
||||
$notifications.set([])
|
||||
}
|
||||
59
apps/desktop/src/store/session.ts
Normal file
|
|
@ -0,0 +1,59 @@
|
|||
import { atom } from 'nanostores'
|
||||
|
||||
import type { HermesConnection } from '@/global'
|
||||
import type { ChatMessage } from '@/lib/chat-messages'
|
||||
import type { SessionInfo } from '@/types/hermes'
|
||||
import type { ContextSuggestion } from '@/app/types'
|
||||
|
||||
type Updater<T> = T | ((current: T) => T)
|
||||
|
||||
interface AppAtom<T> {
|
||||
get: () => T
|
||||
set: (value: T) => void
|
||||
}
|
||||
|
||||
function updateAtom<T>(store: AppAtom<T>, next: Updater<T>) {
|
||||
store.set(typeof next === 'function' ? (next as (current: T) => T)(store.get()) : next)
|
||||
}
|
||||
|
||||
export const $connection = atom<HermesConnection | null>(null)
|
||||
export const $gatewayState = atom('idle')
|
||||
export const $sessions = atom<SessionInfo[]>([])
|
||||
export const $sessionsLoading = atom(true)
|
||||
export const $activeSessionId = atom<string | null>(null)
|
||||
export const $selectedStoredSessionId = atom<string | null>(null)
|
||||
export const $messages = atom<ChatMessage[]>([])
|
||||
export const $freshDraftReady = atom(false)
|
||||
export const $busy = atom(false)
|
||||
export const $awaitingResponse = atom(false)
|
||||
export const $currentModel = atom('')
|
||||
export const $currentProvider = atom('')
|
||||
export const $currentCwd = atom('')
|
||||
export const $currentBranch = atom('')
|
||||
export const $introPersonality = atom('')
|
||||
export const $currentPersonality = atom('')
|
||||
export const $availablePersonalities = atom<string[]>([])
|
||||
export const $introSeed = atom(0)
|
||||
export const $contextSuggestions = atom<ContextSuggestion[]>([])
|
||||
export const $modelPickerOpen = atom(false)
|
||||
|
||||
export const setConnection = (next: Updater<HermesConnection | null>) => updateAtom($connection, next)
|
||||
export const setGatewayState = (next: Updater<string>) => updateAtom($gatewayState, next)
|
||||
export const setSessions = (next: Updater<SessionInfo[]>) => updateAtom($sessions, next)
|
||||
export const setSessionsLoading = (next: Updater<boolean>) => updateAtom($sessionsLoading, next)
|
||||
export const setActiveSessionId = (next: Updater<string | null>) => updateAtom($activeSessionId, next)
|
||||
export const setSelectedStoredSessionId = (next: Updater<string | null>) => updateAtom($selectedStoredSessionId, next)
|
||||
export const setMessages = (next: Updater<ChatMessage[]>) => updateAtom($messages, next)
|
||||
export const setFreshDraftReady = (next: Updater<boolean>) => updateAtom($freshDraftReady, next)
|
||||
export const setBusy = (next: Updater<boolean>) => updateAtom($busy, next)
|
||||
export const setAwaitingResponse = (next: Updater<boolean>) => updateAtom($awaitingResponse, next)
|
||||
export const setCurrentModel = (next: Updater<string>) => updateAtom($currentModel, next)
|
||||
export const setCurrentProvider = (next: Updater<string>) => updateAtom($currentProvider, next)
|
||||
export const setCurrentCwd = (next: Updater<string>) => updateAtom($currentCwd, next)
|
||||
export const setCurrentBranch = (next: Updater<string>) => updateAtom($currentBranch, next)
|
||||
export const setIntroPersonality = (next: Updater<string>) => updateAtom($introPersonality, next)
|
||||
export const setCurrentPersonality = (next: Updater<string>) => updateAtom($currentPersonality, next)
|
||||
export const setAvailablePersonalities = (next: Updater<string[]>) => updateAtom($availablePersonalities, next)
|
||||
export const setIntroSeed = (next: Updater<number>) => updateAtom($introSeed, next)
|
||||
export const setContextSuggestions = (next: Updater<ContextSuggestion[]>) => updateAtom($contextSuggestions, next)
|
||||
export const setModelPickerOpen = (next: Updater<boolean>) => updateAtom($modelPickerOpen, next)
|
||||
11
apps/desktop/src/store/thread-scroll.ts
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
import { atom } from 'nanostores'
|
||||
|
||||
export const $threadScrolledUp = atom(false)
|
||||
|
||||
export function setThreadScrolledUp(value: boolean) {
|
||||
if ($threadScrolledUp.get() === value) {
|
||||
return
|
||||
}
|
||||
|
||||
$threadScrolledUp.set(value)
|
||||
}
|
||||
372
apps/desktop/src/styles.css
Normal file
|
|
@ -0,0 +1,372 @@
|
|||
@import 'tailwindcss';
|
||||
@import 'tw-shimmer';
|
||||
/*---break---
|
||||
*/
|
||||
@custom-variant dark (&:is(.dark *));
|
||||
|
||||
/**
|
||||
* @theme inline bridges runtime CSS variables (--dt-*) set by the
|
||||
* ThemeProvider into Tailwind utility tokens. Every time the theme
|
||||
* switches, ThemeProvider writes new --dt-* values onto :root and
|
||||
* all Tailwind utilities (bg-background, text-muted-foreground, …)
|
||||
* update automatically — no class rewrite needed.
|
||||
*/
|
||||
@theme inline {
|
||||
--color-background: var(--dt-background);
|
||||
--color-foreground: var(--dt-foreground);
|
||||
--color-card: var(--dt-card);
|
||||
--color-card-foreground: var(--dt-card-foreground);
|
||||
--color-muted: var(--dt-muted);
|
||||
--color-muted-foreground: var(--dt-muted-foreground);
|
||||
--color-popover: var(--dt-popover);
|
||||
--color-popover-foreground: var(--dt-popover-foreground);
|
||||
--color-primary: var(--dt-primary);
|
||||
--color-primary-foreground: var(--dt-primary-foreground);
|
||||
--color-secondary: var(--dt-secondary);
|
||||
--color-secondary-foreground: var(--dt-secondary-foreground);
|
||||
--color-accent: var(--dt-accent);
|
||||
--color-accent-foreground: var(--dt-accent-foreground);
|
||||
--color-border: var(--dt-border);
|
||||
--color-input: var(--dt-input);
|
||||
--color-ring: var(--dt-ring);
|
||||
--color-destructive: var(--dt-destructive);
|
||||
--color-destructive-foreground: var(--dt-destructive-foreground);
|
||||
|
||||
--font-sans: var(--dt-font-sans);
|
||||
--font-mono: var(--dt-font-mono);
|
||||
|
||||
--spacing-mul: var(--dt-spacing-mul, 1);
|
||||
--radius-sm: max(0rem, calc(var(--radius) - 0.25rem));
|
||||
--radius-md: max(0rem, calc(var(--radius) - 0.125rem));
|
||||
--radius-lg: var(--radius);
|
||||
--radius-xl: calc(var(--radius) + 0.25rem);
|
||||
|
||||
--color-sidebar-ring: var(--sidebar-ring);
|
||||
|
||||
--color-sidebar-border: var(--sidebar-border);
|
||||
|
||||
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
|
||||
|
||||
--color-sidebar-accent: var(--sidebar-accent);
|
||||
|
||||
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
|
||||
|
||||
--color-sidebar-primary: var(--sidebar-primary);
|
||||
|
||||
--color-sidebar-foreground: var(--sidebar-foreground);
|
||||
|
||||
--color-sidebar: var(--sidebar);
|
||||
|
||||
/* Shadow ink — derived from the foreground so it warms/cools with the theme. */
|
||||
--shadow-ink: var(--dt-foreground);
|
||||
--shadow-sidebar:
|
||||
0.0625rem 0 0.125rem 0 color-mix(in srgb, #000 4%, transparent),
|
||||
0.5rem 0 1.5rem -1rem color-mix(in srgb, #000 5%, transparent),
|
||||
1.25rem 0 3rem -2rem color-mix(in srgb, #000 6%, transparent);
|
||||
--shadow-header:
|
||||
0 0.5rem 0.875rem -0.375rem color-mix(in srgb, var(--dt-background) 96%, transparent),
|
||||
0 1.25rem 2rem -0.875rem color-mix(in srgb, var(--dt-background) 82%, transparent),
|
||||
0 2rem 3rem -1.5rem color-mix(in srgb, var(--dt-background) 55%, transparent);
|
||||
--shadow-composer:
|
||||
0 0 0 0.0625rem color-mix(in srgb, var(--shadow-ink) 6%, transparent),
|
||||
0 0.25rem 0.5rem color-mix(in srgb, var(--shadow-ink) 5%, transparent),
|
||||
0 0.75rem 2rem color-mix(in srgb, var(--shadow-ink) 8%, transparent);
|
||||
--shadow-composer-focus:
|
||||
0 0 0 0.1875rem color-mix(in srgb, var(--dt-ring) 18%, transparent),
|
||||
0 0 0 0.0625rem color-mix(in srgb, var(--dt-ring) 35%, transparent),
|
||||
0 0.5rem 1.5rem color-mix(in srgb, var(--shadow-ink) 6%, transparent);
|
||||
--shadow-user-message:
|
||||
0 0.0625rem 0.125rem color-mix(in srgb, var(--shadow-ink) 6%, transparent),
|
||||
0 0.25rem 0.75rem color-mix(in srgb, var(--shadow-ink) 4%, transparent);
|
||||
}
|
||||
|
||||
@layer base {
|
||||
:root {
|
||||
color-scheme: dark;
|
||||
|
||||
/* Fallback values (Claude Light theme) — ThemeProvider overwrites on mount. */
|
||||
--dt-background: #f7f7f7;
|
||||
--dt-foreground: #242424;
|
||||
--dt-card: #ffffff;
|
||||
--dt-card-foreground: #242424;
|
||||
--dt-muted: #f0f0ef;
|
||||
--dt-muted-foreground: #737373;
|
||||
--dt-popover: #ffffff;
|
||||
--dt-popover-foreground: #242424;
|
||||
--dt-primary: #cf806d;
|
||||
--dt-primary-foreground: #ffffff;
|
||||
--dt-secondary: #f1f1f0;
|
||||
--dt-secondary-foreground: #2d2d2d;
|
||||
--dt-accent: #eeeeed;
|
||||
--dt-accent-foreground: #242424;
|
||||
--dt-border: #dfdfdc;
|
||||
--dt-input: #ddddda;
|
||||
--dt-ring: #b97969;
|
||||
--dt-destructive: #b94a3a;
|
||||
--dt-destructive-foreground: #ffffff;
|
||||
--dt-sidebar-bg: #fafafa;
|
||||
--dt-sidebar-border: #e2e2df;
|
||||
--dt-user-bubble: #f2f2f1;
|
||||
--dt-user-bubble-border: #dededb;
|
||||
|
||||
--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-line-height: 1.55;
|
||||
--dt-letter-spacing: 0;
|
||||
--dt-spacing-mul: 1;
|
||||
|
||||
--radius: 0.75rem;
|
||||
--vsq: min(0.5vh, 0.5vw);
|
||||
--image-preview-max-width: 34rem;
|
||||
--image-preview-height: clamp(16.25rem, calc(var(--vsq) * 100), 26.25rem);
|
||||
|
||||
/* Sidebar layout */
|
||||
--sidebar-width: 14rem;
|
||||
--titlebar-control-size: 1.25rem;
|
||||
--titlebar-control-height: 1.375rem;
|
||||
|
||||
--sidebar: var(--dt-sidebar-bg);
|
||||
--sidebar-foreground: var(--dt-foreground);
|
||||
--sidebar-primary: var(--dt-primary);
|
||||
--sidebar-primary-foreground: var(--dt-primary-foreground);
|
||||
--sidebar-accent: var(--dt-accent);
|
||||
--sidebar-accent-foreground: var(--dt-accent-foreground);
|
||||
--sidebar-border: var(--dt-sidebar-border);
|
||||
--sidebar-ring: var(--dt-ring);
|
||||
--sidebar-edge-border: color-mix(in srgb, var(--dt-sidebar-border) 42%, transparent);
|
||||
}
|
||||
|
||||
:root.dark {
|
||||
--sidebar-edge-border: color-mix(in srgb, var(--dt-sidebar-border) 78%, transparent);
|
||||
--shadow-sidebar:
|
||||
0.0625rem 0 0.125rem 0 color-mix(in srgb, #000 82%, transparent),
|
||||
0.75rem 0 1.75rem -1rem color-mix(in srgb, #000 72%, transparent),
|
||||
1.5rem 0 3rem -1.75rem color-mix(in srgb, #000 62%, transparent);
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
border-color: var(--dt-border);
|
||||
}
|
||||
|
||||
html,
|
||||
body,
|
||||
#root {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
html {
|
||||
font-size: var(--dt-base-size, 0.9375rem);
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
background: var(--dt-background);
|
||||
color: var(--dt-foreground);
|
||||
font-family: var(--dt-font-sans);
|
||||
line-height: var(--dt-line-height, 1.55);
|
||||
letter-spacing: var(--dt-letter-spacing, 0);
|
||||
overflow: hidden;
|
||||
-webkit-user-select: none;
|
||||
user-select: none;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
}
|
||||
|
||||
button,
|
||||
textarea {
|
||||
font: inherit;
|
||||
}
|
||||
}
|
||||
|
||||
button {
|
||||
-webkit-app-region: no-drag;
|
||||
}
|
||||
|
||||
input,
|
||||
textarea,
|
||||
[contenteditable]:not([contenteditable='false']),
|
||||
[data-slot='aui_user-message-root'],
|
||||
[data-slot='aui_assistant-message-content'] {
|
||||
-webkit-user-select: text;
|
||||
user-select: text;
|
||||
}
|
||||
|
||||
button,
|
||||
[role='button'] {
|
||||
-webkit-user-select: none;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
img,
|
||||
picture,
|
||||
video,
|
||||
canvas,
|
||||
svg {
|
||||
-webkit-user-select: none;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
img,
|
||||
video,
|
||||
canvas {
|
||||
-webkit-user-drag: none;
|
||||
}
|
||||
|
||||
* {
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: color-mix(in srgb, var(--dt-muted-foreground) 32%, transparent) transparent;
|
||||
}
|
||||
|
||||
*::-webkit-scrollbar {
|
||||
width: 0.5rem;
|
||||
height: 0.5rem;
|
||||
}
|
||||
|
||||
*::-webkit-scrollbar-track,
|
||||
*::-webkit-scrollbar-corner {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
*::-webkit-scrollbar-thumb {
|
||||
background: color-mix(in srgb, var(--dt-muted-foreground) 32%, transparent);
|
||||
border-radius: 9999px;
|
||||
border: 0.125rem solid transparent;
|
||||
background-clip: padding-box;
|
||||
}
|
||||
|
||||
*::-webkit-scrollbar-thumb:hover {
|
||||
background: color-mix(in srgb, var(--dt-muted-foreground) 55%, transparent);
|
||||
background-clip: padding-box;
|
||||
}
|
||||
|
||||
*::-webkit-scrollbar-button {
|
||||
display: none;
|
||||
}
|
||||
|
||||
@supports (content-visibility: auto) {
|
||||
[data-slot='aui_user-message-root'],
|
||||
[data-slot='aui_assistant-message-root'] {
|
||||
content-visibility: auto;
|
||||
contain-intrinsic-size: auto 10rem;
|
||||
}
|
||||
|
||||
[data-slot='aui_user-message-root'] {
|
||||
contain-intrinsic-size: auto 4rem;
|
||||
}
|
||||
}
|
||||
|
||||
.aui-md img {
|
||||
display: block;
|
||||
width: auto;
|
||||
height: auto;
|
||||
max-width: min(100%, var(--image-preview-max-width));
|
||||
max-height: var(--image-preview-height);
|
||||
object-fit: contain;
|
||||
border: 0.0625rem solid color-mix(in srgb, var(--dt-border) 70%, transparent);
|
||||
border-radius: 1.125rem;
|
||||
box-shadow:
|
||||
0 0.0625rem 0.125rem color-mix(in srgb, #000 4%, transparent),
|
||||
0 0.625rem 1.5rem color-mix(in srgb, #000 5%, transparent);
|
||||
}
|
||||
|
||||
.aui-md [data-slot='aui_markdown-image'] {
|
||||
max-width: min(100%, var(--image-preview-max-width));
|
||||
}
|
||||
|
||||
.aui-md {
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.aui-md a,
|
||||
.aui-md code {
|
||||
overflow-wrap: anywhere;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.aui-md p:has(> img:only-child) {
|
||||
margin-block: 0.75rem;
|
||||
}
|
||||
|
||||
.aui-md p:has(> [data-slot='aui_markdown-image']:only-child) {
|
||||
margin-block: 0.75rem;
|
||||
}
|
||||
|
||||
/**
|
||||
* Markdown rhythm. Streamdown's wrapper <div> blocks `> *` selectors and
|
||||
* its bundled `space-y-4` lives in node_modules (unscanned by Tailwind v4),
|
||||
* so we drive everything from descendant selectors against tags. Each
|
||||
* block gets a uniform `margin-bottom`; headings add `margin-top` for
|
||||
* 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) {
|
||||
margin: 0 0 1rem;
|
||||
}
|
||||
|
||||
.aui-md h1 {
|
||||
margin: 1.6rem 0 0.55rem;
|
||||
}
|
||||
.aui-md h2 {
|
||||
margin: 1.4rem 0 0.5rem;
|
||||
}
|
||||
.aui-md h3 {
|
||||
margin: 1.15rem 0 0.45rem;
|
||||
}
|
||||
.aui-md h4 {
|
||||
margin: 0.95rem 0 0.4rem;
|
||||
}
|
||||
.aui-md hr {
|
||||
margin: 1.5rem 0;
|
||||
}
|
||||
|
||||
/* `padding-left` keeps outside-position list markers in the gutter. */
|
||||
.aui-md ul,
|
||||
.aui-md ol {
|
||||
padding-left: 1.75rem;
|
||||
}
|
||||
|
||||
/* Tight inter-bullet gap; loose items override below. */
|
||||
.aui-md li + li {
|
||||
margin-top: 0.375rem;
|
||||
}
|
||||
|
||||
/* Inside a bullet, hug nested blocks to the lead text. */
|
||||
.aui-md li > p {
|
||||
margin-bottom: 0.4rem;
|
||||
}
|
||||
.aui-md li > ul,
|
||||
.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) {
|
||||
margin-bottom: 0.85rem;
|
||||
}
|
||||
.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 {
|
||||
margin-top: 0;
|
||||
}
|
||||
.aui-md > :last-child,
|
||||
.aui-md > * > :last-child,
|
||||
.aui-md li > :last-child,
|
||||
.aui-md blockquote > :last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
344
apps/desktop/src/themes/context.tsx
Normal file
|
|
@ -0,0 +1,344 @@
|
|||
/**
|
||||
* 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.
|
||||
*
|
||||
* Persists mode (light/dark/system) and skin separately. Mode controls
|
||||
* brightness; skin controls accent family.
|
||||
*/
|
||||
|
||||
import { createContext, type ReactNode, useCallback, useContext, useEffect, useMemo, useState } from 'react'
|
||||
|
||||
import {
|
||||
BUILTIN_THEME_LIST,
|
||||
BUILTIN_THEMES,
|
||||
DEFAULT_LAYOUT,
|
||||
DEFAULT_TYPOGRAPHY,
|
||||
defaultTheme,
|
||||
nousLightTheme
|
||||
} from './presets'
|
||||
import type { DesktopTheme, DesktopThemeColors, ThemeDensity } from './types'
|
||||
|
||||
const STORAGE_KEY = 'hermes-desktop-theme-v2' // Stores skin name.
|
||||
const MODE_KEY = 'hermes-desktop-mode-v1'
|
||||
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')
|
||||
|
||||
function systemPrefersDark(): boolean {
|
||||
if (typeof window === 'undefined' || !window.matchMedia) {
|
||||
return false
|
||||
}
|
||||
|
||||
return window.matchMedia('(prefers-color-scheme: dark)').matches
|
||||
}
|
||||
|
||||
function effectiveMode(mode: ThemeMode, systemDark = systemPrefersDark()): 'light' | 'dark' {
|
||||
return mode === 'system' ? (systemDark ? 'dark' : 'light') : mode
|
||||
}
|
||||
|
||||
function normalizeSkin(name: string | null | undefined): string {
|
||||
if (!name || name === 'nous-light') {
|
||||
return DEFAULT_SKIN
|
||||
}
|
||||
|
||||
return BUILTIN_THEMES[name] && name !== 'nous-light' ? name : DEFAULT_SKIN
|
||||
}
|
||||
|
||||
function hexToRgb(hex: string): [number, number, number] | null {
|
||||
const clean = hex.trim().replace(/^#/, '')
|
||||
|
||||
if (!/^[0-9a-f]{6}$/i.test(clean)) {
|
||||
return null
|
||||
}
|
||||
|
||||
return [0, 2, 4].map(i => parseInt(clean.slice(i, i + 2), 16)) as [number, number, number]
|
||||
}
|
||||
|
||||
function rgbToHex([r, g, b]: [number, number, number]): string {
|
||||
return `#${[r, g, b].map(n => Math.round(n).toString(16).padStart(2, '0')).join('')}`
|
||||
}
|
||||
|
||||
function mix(a: string, b: string, amount: number): string {
|
||||
const ar = hexToRgb(a)
|
||||
const br = hexToRgb(b)
|
||||
|
||||
if (!ar || !br) {
|
||||
return a
|
||||
}
|
||||
|
||||
return rgbToHex([
|
||||
ar[0] + (br[0] - ar[0]) * amount,
|
||||
ar[1] + (br[1] - ar[1]) * amount,
|
||||
ar[2] + (br[2] - ar[2]) * amount
|
||||
])
|
||||
}
|
||||
|
||||
function readableOn(hex: string): string {
|
||||
const rgb = hexToRgb(hex)
|
||||
|
||||
if (!rgb) {
|
||||
return '#ffffff'
|
||||
}
|
||||
|
||||
const [r, g, b] = rgb.map(v => {
|
||||
const c = v / 255
|
||||
|
||||
return c <= 0.03928 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4
|
||||
})
|
||||
|
||||
return 0.2126 * r + 0.7152 * g + 0.0722 * b > 0.58 ? '#161616' : '#ffffff'
|
||||
}
|
||||
|
||||
function fontOnly(theme: DesktopTheme): DesktopTheme['typography'] {
|
||||
if (!theme.typography) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
const { fontSans, fontMono, fontUrl } = theme.typography
|
||||
|
||||
return { fontSans, fontMono, fontUrl }
|
||||
}
|
||||
|
||||
function lightColors(seed: DesktopTheme, skinName: string): DesktopThemeColors {
|
||||
if (skinName === DEFAULT_SKIN) {
|
||||
return nousLightTheme.colors
|
||||
}
|
||||
|
||||
const accent = seed.colors.ring || seed.colors.primary
|
||||
const soft = mix('#ffffff', accent, 0.1)
|
||||
const softer = mix('#ffffff', accent, 0.06)
|
||||
const border = mix('#ececef', accent, 0.14)
|
||||
|
||||
return {
|
||||
background: '#ffffff',
|
||||
foreground: '#161616',
|
||||
card: '#ffffff',
|
||||
cardForeground: '#161616',
|
||||
muted: softer,
|
||||
mutedForeground: mix('#6b6b70', accent, 0.16),
|
||||
popover: '#ffffff',
|
||||
popoverForeground: '#161616',
|
||||
primary: accent,
|
||||
primaryForeground: readableOn(accent),
|
||||
secondary: soft,
|
||||
secondaryForeground: mix('#2a2a2a', accent, 0.34),
|
||||
accent: soft,
|
||||
accentForeground: mix('#2a2a2a', accent, 0.34),
|
||||
border,
|
||||
input: mix('#e2e2e6', accent, 0.18),
|
||||
ring: accent,
|
||||
destructive: '#b94a3a',
|
||||
destructiveForeground: '#ffffff',
|
||||
sidebarBackground: mix('#fafafa', accent, 0.05),
|
||||
sidebarBorder: border,
|
||||
userBubble: soft,
|
||||
userBubbleBorder: border
|
||||
}
|
||||
}
|
||||
|
||||
function darkColors(seed: DesktopTheme, skinName: string): DesktopThemeColors {
|
||||
return skinName === DEFAULT_SKIN ? defaultTheme.colors : seed.colors
|
||||
}
|
||||
|
||||
function deriveTheme(skinName: string, mode: 'light' | 'dark'): DesktopTheme {
|
||||
const seed = BUILTIN_THEMES[skinName] ?? defaultTheme
|
||||
const isDefault = skinName === DEFAULT_SKIN
|
||||
const base = mode === 'light' ? nousLightTheme : defaultTheme
|
||||
|
||||
return {
|
||||
...base,
|
||||
name: `${skinName}-${mode}`,
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
// ─── CSS application ────────────────────────────────────────────────────────
|
||||
|
||||
function applyTheme(theme: DesktopTheme, mode: 'light' | 'dark') {
|
||||
if (typeof document === 'undefined') {
|
||||
return
|
||||
}
|
||||
|
||||
const root = document.documentElement
|
||||
const typo = { ...DEFAULT_TYPOGRAPHY, ...theme.typography }
|
||||
const layout = { ...DEFAULT_LAYOUT, ...theme.layout }
|
||||
const c = theme.colors
|
||||
|
||||
root.style.setProperty('color-scheme', mode)
|
||||
root.classList.toggle('dark', mode === 'dark')
|
||||
|
||||
const vars: Record<string, string> = {
|
||||
'--dt-background': c.background,
|
||||
'--dt-foreground': c.foreground,
|
||||
'--dt-card': c.card,
|
||||
'--dt-card-foreground': c.cardForeground,
|
||||
'--dt-muted': c.muted,
|
||||
'--dt-muted-foreground': c.mutedForeground,
|
||||
'--dt-popover': c.popover,
|
||||
'--dt-popover-foreground': c.popoverForeground,
|
||||
'--dt-primary': c.primary,
|
||||
'--dt-primary-foreground': c.primaryForeground,
|
||||
'--dt-secondary': c.secondary,
|
||||
'--dt-secondary-foreground': c.secondaryForeground,
|
||||
'--dt-accent': c.accent,
|
||||
'--dt-accent-foreground': c.accentForeground,
|
||||
'--dt-border': c.border,
|
||||
'--dt-input': c.input,
|
||||
'--dt-ring': c.ring,
|
||||
'--dt-destructive': c.destructive,
|
||||
'--dt-destructive-foreground': c.destructiveForeground,
|
||||
'--dt-sidebar-bg': c.sidebarBackground ?? c.background,
|
||||
'--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
|
||||
}
|
||||
|
||||
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'
|
||||
link.href = typo.fontUrl
|
||||
link.setAttribute('data-hermes-theme-font', 'true')
|
||||
document.head.appendChild(link)
|
||||
INJECTED_FONT_URLS.add(typo.fontUrl)
|
||||
}
|
||||
}
|
||||
|
||||
if (typeof window !== 'undefined') {
|
||||
const skin = normalizeSkin(window.localStorage.getItem(STORAGE_KEY))
|
||||
const mode = (window.localStorage.getItem(MODE_KEY) as ThemeMode) ?? 'light'
|
||||
const resolved = effectiveMode(mode)
|
||||
applyTheme(deriveTheme(skin, resolved), resolved)
|
||||
}
|
||||
|
||||
// ─── Context ────────────────────────────────────────────────────────────────
|
||||
|
||||
interface ThemeContextValue {
|
||||
theme: DesktopTheme
|
||||
themeName: string
|
||||
mode: ThemeMode
|
||||
availableThemes: Array<{ name: string; label: string; description: string }>
|
||||
setTheme: (name: string) => void
|
||||
setMode: (mode: ThemeMode) => void
|
||||
}
|
||||
|
||||
const ThemeContext = createContext<ThemeContextValue>({
|
||||
theme: nousLightTheme,
|
||||
themeName: DEFAULT_SKIN,
|
||||
mode: 'light',
|
||||
availableThemes: SKIN_THEME_LIST.map(({ name, label, description }) => ({
|
||||
name,
|
||||
label: name === DEFAULT_SKIN ? 'Hermes' : label,
|
||||
description
|
||||
})),
|
||||
setTheme: () => {},
|
||||
setMode: () => {}
|
||||
})
|
||||
|
||||
export function ThemeProvider({ children }: { children: ReactNode }) {
|
||||
const [themeName, setThemeNameState] = useState(() => {
|
||||
if (typeof window === 'undefined') {
|
||||
return DEFAULT_SKIN
|
||||
}
|
||||
|
||||
return normalizeSkin(window.localStorage.getItem(STORAGE_KEY))
|
||||
})
|
||||
|
||||
const [mode, setModeState] = useState<ThemeMode>(() => {
|
||||
if (typeof window === 'undefined') {
|
||||
return 'light'
|
||||
}
|
||||
|
||||
return (window.localStorage.getItem(MODE_KEY) as ThemeMode) ?? 'light'
|
||||
})
|
||||
|
||||
const [systemDark, setSystemDark] = useState(systemPrefersDark)
|
||||
|
||||
useEffect(() => {
|
||||
if (typeof window === 'undefined' || !window.matchMedia) {
|
||||
return
|
||||
}
|
||||
|
||||
const mql = window.matchMedia('(prefers-color-scheme: dark)')
|
||||
const listener = (e: MediaQueryListEvent) => setSystemDark(e.matches)
|
||||
mql.addEventListener('change', listener)
|
||||
|
||||
return () => mql.removeEventListener('change', listener)
|
||||
}, [])
|
||||
|
||||
const resolvedMode = effectiveMode(mode, systemDark)
|
||||
|
||||
const activeTheme = useMemo(() => deriveTheme(themeName, resolvedMode), [themeName, resolvedMode])
|
||||
|
||||
useEffect(() => applyTheme(activeTheme, resolvedMode), [activeTheme, resolvedMode])
|
||||
|
||||
const setTheme = useCallback((name: string) => {
|
||||
const next = normalizeSkin(name)
|
||||
setThemeNameState(next)
|
||||
window.localStorage.setItem(STORAGE_KEY, next)
|
||||
}, [])
|
||||
|
||||
const setMode = useCallback((next: ThemeMode) => {
|
||||
setModeState(next)
|
||||
window.localStorage.setItem(MODE_KEY, next)
|
||||
}, [])
|
||||
|
||||
const value = useMemo<ThemeContextValue>(
|
||||
() => ({
|
||||
theme: activeTheme,
|
||||
themeName,
|
||||
mode,
|
||||
availableThemes: SKIN_THEME_LIST.map(({ name, label, description }) => ({
|
||||
name,
|
||||
label: name === DEFAULT_SKIN ? 'Hermes' : label,
|
||||
description
|
||||
})),
|
||||
setTheme,
|
||||
setMode
|
||||
}),
|
||||
[activeTheme, themeName, mode, setTheme, setMode]
|
||||
)
|
||||
|
||||
return <ThemeContext.Provider value={value}>{children}</ThemeContext.Provider>
|
||||
}
|
||||
|
||||
export function useTheme(): ThemeContextValue {
|
||||
return useContext(ThemeContext)
|
||||
}
|
||||
|
||||
/** Sync the desktop skin with the active Hermes backend theme on connect. */
|
||||
export function useSyncThemeFromBackend(backendThemeName: string | undefined, setTheme: (name: string) => void) {
|
||||
useEffect(() => {
|
||||
if (backendThemeName && BUILTIN_THEMES[backendThemeName]) {
|
||||
setTheme(backendThemeName)
|
||||
}
|
||||
}, [backendThemeName, setTheme])
|
||||
}
|
||||
3
apps/desktop/src/themes/index.ts
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
export { ThemeProvider, useSyncThemeFromBackend, useTheme } from './context'
|
||||
export { BUILTIN_THEME_LIST, BUILTIN_THEMES } from './presets'
|
||||
export type { DesktopTheme, DesktopThemeColors, DesktopThemeLayout, DesktopThemeTypography } from './types'
|
||||
325
apps/desktop/src/themes/presets.ts
Normal file
|
|
@ -0,0 +1,325 @@
|
|||
/**
|
||||
* Built-in desktop themes.
|
||||
*
|
||||
* Names match the CLI skins and dashboard theme presets so users get
|
||||
* a consistent visual identity across surfaces.
|
||||
*
|
||||
* Add new themes here — no code changes needed elsewhere.
|
||||
*/
|
||||
|
||||
import type { DesktopTheme, DesktopThemeLayout, DesktopThemeTypography } from './types'
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Shared defaults
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const SYSTEM_SANS =
|
||||
'ui-sans-serif, -apple-system, BlinkMacSystemFont, "SF Pro Text", "SF Pro Display", "Inter", "Segoe UI", Roboto, "Helvetica Neue", Arial, system-ui, sans-serif'
|
||||
|
||||
const SYSTEM_MONO =
|
||||
'ui-monospace, "SF Mono", "JetBrains Mono", "Cascadia Code", Menlo, Monaco, Consolas, "Liberation Mono", monospace'
|
||||
|
||||
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'
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Built-in themes
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Hermes light — premium warm white with restrained antique gold. */
|
||||
export const nousLightTheme: DesktopTheme = {
|
||||
name: 'nous-light',
|
||||
label: 'Hermes Light',
|
||||
description: 'Warm white with antique gold — premium and restrained',
|
||||
colors: {
|
||||
background: '#FAF8F5',
|
||||
foreground: '#1A1610',
|
||||
card: '#FFFFFF',
|
||||
cardForeground: '#1A1610',
|
||||
muted: '#F3EFE8',
|
||||
mutedForeground: '#7A6E60',
|
||||
popover: '#FFFFFF',
|
||||
popoverForeground: '#1A1610',
|
||||
primary: '#A0782A',
|
||||
primaryForeground: '#ffffff',
|
||||
secondary: '#EDE8DF',
|
||||
secondaryForeground: '#1A1610',
|
||||
accent: '#EDE8DF',
|
||||
accentForeground: '#1A1610',
|
||||
border: '#E3DDCF',
|
||||
input: '#D8D1C3',
|
||||
ring: '#A0782A',
|
||||
destructive: '#b94a3a',
|
||||
destructiveForeground: '#ffffff',
|
||||
sidebarBackground: '#F5F2EC',
|
||||
sidebarBorder: '#E3DDCF',
|
||||
userBubble: '#EDE8DF',
|
||||
userBubbleBorder: '#E3DDCF'
|
||||
}
|
||||
}
|
||||
|
||||
/** Optional Hermes gold skin for people who want the classic TUI accent. */
|
||||
export const hermesGoldTheme: DesktopTheme = {
|
||||
name: 'gold',
|
||||
label: 'Gold',
|
||||
description: 'Classic Hermes gold accent',
|
||||
colors: {
|
||||
...nousLightTheme.colors,
|
||||
primary: '#d4af37',
|
||||
primaryForeground: '#1a1404',
|
||||
secondary: '#f6efd5',
|
||||
secondaryForeground: '#5a4310',
|
||||
accent: '#fbf3d4',
|
||||
accentForeground: '#5a4310',
|
||||
ring: '#d4af37',
|
||||
userBubble: '#f6efd5'
|
||||
}
|
||||
}
|
||||
|
||||
/** Classic Hermes dark teal. */
|
||||
export const defaultTheme: DesktopTheme = {
|
||||
name: 'default',
|
||||
label: 'Hermes Teal',
|
||||
description: 'Classic dark teal — the canonical Hermes look',
|
||||
colors: {
|
||||
background: '#0d1a1a',
|
||||
foreground: '#f0e8d8',
|
||||
card: '#111f1f',
|
||||
cardForeground: '#f0e8d8',
|
||||
muted: '#172828',
|
||||
mutedForeground: '#8aada6',
|
||||
popover: '#142222',
|
||||
popoverForeground: '#f0e8d8',
|
||||
primary: '#f0e8d8',
|
||||
primaryForeground: '#0d1a1a',
|
||||
secondary: '#1e3030',
|
||||
secondaryForeground: '#c8ddd8',
|
||||
accent: '#1b2e2e',
|
||||
accentForeground: '#e0d4c0',
|
||||
border: '#1e3232',
|
||||
input: '#1e3232',
|
||||
ring: '#6bbfb5',
|
||||
destructive: '#c0473a',
|
||||
destructiveForeground: '#fef2f2',
|
||||
sidebarBackground: '#0a1616',
|
||||
sidebarBorder: '#172424',
|
||||
userBubble: '#1a2e2e',
|
||||
userBubbleBorder: '#2a4a44'
|
||||
}
|
||||
}
|
||||
|
||||
/** Deep blue-violet with cool accents. Matches the dashboard midnight theme. */
|
||||
export const midnightTheme: DesktopTheme = {
|
||||
name: 'midnight',
|
||||
label: 'Midnight',
|
||||
description: 'Deep blue-violet with cool accents',
|
||||
colors: {
|
||||
background: '#08081c',
|
||||
foreground: '#ddd6ff',
|
||||
card: '#0d0d28',
|
||||
cardForeground: '#ddd6ff',
|
||||
muted: '#13133a',
|
||||
mutedForeground: '#7c7ab0',
|
||||
popover: '#0f0f2e',
|
||||
popoverForeground: '#ddd6ff',
|
||||
primary: '#ddd6ff',
|
||||
primaryForeground: '#08081c',
|
||||
secondary: '#1a1a4a',
|
||||
secondaryForeground: '#c4bff0',
|
||||
accent: '#1a1a44',
|
||||
accentForeground: '#d0c8ff',
|
||||
border: '#1e1e52',
|
||||
input: '#1e1e52',
|
||||
ring: '#8b80e8',
|
||||
destructive: '#b03060',
|
||||
destructiveForeground: '#fef2f2',
|
||||
sidebarBackground: '#06061a',
|
||||
sidebarBorder: '#12123a',
|
||||
userBubble: '#14143a',
|
||||
userBubbleBorder: '#242466'
|
||||
},
|
||||
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'
|
||||
}
|
||||
}
|
||||
|
||||
/** Warm crimson and bronze — forge vibes. Matches the CLI ares skin. */
|
||||
export const emberTheme: DesktopTheme = {
|
||||
name: 'ember',
|
||||
label: 'Ember',
|
||||
description: 'Warm crimson and bronze — forge vibes',
|
||||
colors: {
|
||||
background: '#160800',
|
||||
foreground: '#ffd8b0',
|
||||
card: '#1e0e04',
|
||||
cardForeground: '#ffd8b0',
|
||||
muted: '#2a1408',
|
||||
mutedForeground: '#aa7a56',
|
||||
popover: '#221008',
|
||||
popoverForeground: '#ffd8b0',
|
||||
primary: '#ffd8b0',
|
||||
primaryForeground: '#160800',
|
||||
secondary: '#341800',
|
||||
secondaryForeground: '#f0c090',
|
||||
accent: '#301600',
|
||||
accentForeground: '#e8c080',
|
||||
border: '#3a1c08',
|
||||
input: '#3a1c08',
|
||||
ring: '#d97316',
|
||||
destructive: '#c43010',
|
||||
destructiveForeground: '#fef2f2',
|
||||
sidebarBackground: '#100600',
|
||||
sidebarBorder: '#2a1004',
|
||||
userBubble: '#2a1000',
|
||||
userBubbleBorder: '#4a2010'
|
||||
},
|
||||
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'
|
||||
}
|
||||
}
|
||||
|
||||
/** Clean grayscale. Matches the CLI mono skin and dashboard mono theme. */
|
||||
export const monoTheme: DesktopTheme = {
|
||||
name: 'mono',
|
||||
label: 'Mono',
|
||||
description: 'Clean grayscale — minimal and focused',
|
||||
colors: {
|
||||
background: '#0e0e0e',
|
||||
foreground: '#eaeaea',
|
||||
card: '#141414',
|
||||
cardForeground: '#eaeaea',
|
||||
muted: '#1e1e1e',
|
||||
mutedForeground: '#808080',
|
||||
popover: '#181818',
|
||||
popoverForeground: '#eaeaea',
|
||||
primary: '#eaeaea',
|
||||
primaryForeground: '#0e0e0e',
|
||||
secondary: '#262626',
|
||||
secondaryForeground: '#c8c8c8',
|
||||
accent: '#222222',
|
||||
accentForeground: '#d8d8d8',
|
||||
border: '#2a2a2a',
|
||||
input: '#2a2a2a',
|
||||
ring: '#9a9a9a',
|
||||
destructive: '#a84040',
|
||||
destructiveForeground: '#fef2f2',
|
||||
sidebarBackground: '#0a0a0a',
|
||||
sidebarBorder: '#202020',
|
||||
userBubble: '#1a1a1a',
|
||||
userBubbleBorder: '#363636'
|
||||
},
|
||||
layout: {
|
||||
radius: '0.375rem'
|
||||
}
|
||||
}
|
||||
|
||||
/** Neon green on black. Matches the CLI cyberpunk skin and dashboard theme. */
|
||||
export const cyberpunkTheme: DesktopTheme = {
|
||||
name: 'cyberpunk',
|
||||
label: 'Cyberpunk',
|
||||
description: 'Neon green on black — matrix terminal',
|
||||
colors: {
|
||||
background: '#000a00',
|
||||
foreground: '#00ff41',
|
||||
card: '#001200',
|
||||
cardForeground: '#00ff41',
|
||||
muted: '#001a00',
|
||||
mutedForeground: '#1a8a30',
|
||||
popover: '#001000',
|
||||
popoverForeground: '#00ff41',
|
||||
primary: '#00ff41',
|
||||
primaryForeground: '#000a00',
|
||||
secondary: '#002800',
|
||||
secondaryForeground: '#00cc34',
|
||||
accent: '#002000',
|
||||
accentForeground: '#00e038',
|
||||
border: '#003000',
|
||||
input: '#003000',
|
||||
ring: '#00ff41',
|
||||
destructive: '#ff003c',
|
||||
destructiveForeground: '#000a00',
|
||||
sidebarBackground: '#000600',
|
||||
sidebarBorder: '#001800',
|
||||
userBubble: '#001400',
|
||||
userBubbleBorder: '#004800'
|
||||
},
|
||||
typography: {
|
||||
fontMono: `"Courier New", Courier, monospace`,
|
||||
fontSans: `"Courier New", Courier, monospace`,
|
||||
letterSpacing: '0.02em'
|
||||
},
|
||||
layout: {
|
||||
radius: '0'
|
||||
}
|
||||
}
|
||||
|
||||
/** Cool slate blue for developers. Matches the CLI slate skin. */
|
||||
export const slateTheme: DesktopTheme = {
|
||||
name: 'slate',
|
||||
label: 'Slate',
|
||||
description: 'Cool slate blue — focused developer theme',
|
||||
colors: {
|
||||
background: '#0d1117',
|
||||
foreground: '#c9d1d9',
|
||||
card: '#161b22',
|
||||
cardForeground: '#c9d1d9',
|
||||
muted: '#21262d',
|
||||
mutedForeground: '#8b949e',
|
||||
popover: '#1c2128',
|
||||
popoverForeground: '#c9d1d9',
|
||||
primary: '#c9d1d9',
|
||||
primaryForeground: '#0d1117',
|
||||
secondary: '#2a3038',
|
||||
secondaryForeground: '#adb5bf',
|
||||
accent: '#1e2530',
|
||||
accentForeground: '#c0c8d0',
|
||||
border: '#30363d',
|
||||
input: '#30363d',
|
||||
ring: '#58a6ff',
|
||||
destructive: '#cf4848',
|
||||
destructiveForeground: '#fef2f2',
|
||||
sidebarBackground: '#090d13',
|
||||
sidebarBorder: '#1c2228',
|
||||
userBubble: '#1e2a38',
|
||||
userBubbleBorder: '#2e4060'
|
||||
},
|
||||
typography: {
|
||||
fontMono: `"JetBrains Mono", ${SYSTEM_MONO}`
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Registry
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export const BUILTIN_THEMES: Record<string, DesktopTheme> = {
|
||||
'nous-light': nousLightTheme,
|
||||
default: defaultTheme,
|
||||
gold: hermesGoldTheme,
|
||||
midnight: midnightTheme,
|
||||
ember: emberTheme,
|
||||
mono: monoTheme,
|
||||
cyberpunk: cyberpunkTheme,
|
||||
slate: slateTheme
|
||||
}
|
||||
|
||||
export const BUILTIN_THEME_LIST = Object.values(BUILTIN_THEMES)
|
||||
93
apps/desktop/src/themes/types.ts
Normal file
|
|
@ -0,0 +1,93 @@
|
|||
/**
|
||||
* 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.
|
||||
*
|
||||
* Every field except `name`, `label`, and `description` is optional —
|
||||
* missing values fall back to the `default` theme.
|
||||
*
|
||||
* New themes need no code changes — add an entry to `presets.ts`.
|
||||
*/
|
||||
|
||||
export interface DesktopThemeColors {
|
||||
/** Deepest canvas — maps to `bg-background`. */
|
||||
background: string
|
||||
/** Primary text — maps to `text-foreground`. */
|
||||
foreground: string
|
||||
/** Elevated card/panel surface. */
|
||||
card: string
|
||||
/** Text on card surfaces. */
|
||||
cardForeground: string
|
||||
/** Muted background (hover, subtle fills). */
|
||||
muted: string
|
||||
/** Muted foreground text. */
|
||||
mutedForeground: string
|
||||
/** Popover/dropdown surface. */
|
||||
popover: string
|
||||
/** Popover foreground text. */
|
||||
popoverForeground: string
|
||||
/** Primary action background. */
|
||||
primary: string
|
||||
/** Text on primary action. */
|
||||
primaryForeground: string
|
||||
/** Secondary/subtle action background. */
|
||||
secondary: string
|
||||
/** Text on secondary action. */
|
||||
secondaryForeground: string
|
||||
/** Hover/selected accent fill. */
|
||||
accent: string
|
||||
/** Text on accent fill. */
|
||||
accentForeground: string
|
||||
/** Borders and separators. */
|
||||
border: string
|
||||
/** Form input border. */
|
||||
input: string
|
||||
/** Focus ring / primary accent tint. Also `text-ring` in action bars etc. */
|
||||
ring: string
|
||||
/** Destructive action (delete, error). */
|
||||
destructive: string
|
||||
/** Text on destructive. */
|
||||
destructiveForeground: string
|
||||
/** Sidebar-specific overrides (optional). */
|
||||
sidebarBackground?: string
|
||||
sidebarBorder?: string
|
||||
/** User message bubble. */
|
||||
userBubble?: string
|
||||
userBubbleBorder?: string
|
||||
}
|
||||
|
||||
export interface DesktopThemeTypography {
|
||||
/** CSS font-family for body copy. */
|
||||
fontSans: string
|
||||
/** CSS font-family for code/mono. */
|
||||
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 {
|
||||
name: string
|
||||
label: string
|
||||
description: string
|
||||
colors: DesktopThemeColors
|
||||
typography?: Partial<DesktopThemeTypography>
|
||||
layout?: Partial<DesktopThemeLayout>
|
||||
}
|
||||
159
apps/desktop/src/types/hermes.ts
Normal file
|
|
@ -0,0 +1,159 @@
|
|||
export interface ConfigFieldSchema {
|
||||
category?: string
|
||||
description?: string
|
||||
options?: unknown[]
|
||||
type?: 'boolean' | 'list' | 'number' | 'select' | 'string' | 'text'
|
||||
}
|
||||
|
||||
export interface ConfigSchemaResponse {
|
||||
category_order?: string[]
|
||||
fields: Record<string, ConfigFieldSchema>
|
||||
}
|
||||
|
||||
export interface EnvVarInfo {
|
||||
advanced: boolean
|
||||
category: string
|
||||
description: string
|
||||
is_password: boolean
|
||||
is_set: boolean
|
||||
redacted_value: null | string
|
||||
tools: string[]
|
||||
url: null | string
|
||||
}
|
||||
|
||||
export interface GatewayReadyPayload {
|
||||
skin?: unknown
|
||||
}
|
||||
|
||||
export interface HermesConfig {
|
||||
agent?: {
|
||||
personalities?: Record<string, unknown>
|
||||
}
|
||||
display?: {
|
||||
personality?: string
|
||||
skin?: string
|
||||
}
|
||||
terminal?: {
|
||||
cwd?: string
|
||||
}
|
||||
}
|
||||
|
||||
export type HermesConfigRecord = Record<string, unknown>
|
||||
|
||||
export interface ModelInfoResponse {
|
||||
auto_context_length?: number
|
||||
capabilities?: Record<string, unknown>
|
||||
config_context_length?: number
|
||||
effective_context_length?: number
|
||||
model: string
|
||||
provider: string
|
||||
}
|
||||
|
||||
export interface ModelOptionProvider {
|
||||
is_current?: boolean
|
||||
models?: string[]
|
||||
name: string
|
||||
slug: string
|
||||
total_models?: number
|
||||
warning?: string
|
||||
}
|
||||
|
||||
export interface ModelOptionsResponse {
|
||||
model?: string
|
||||
provider?: string
|
||||
providers?: ModelOptionProvider[]
|
||||
}
|
||||
|
||||
export interface PaginatedSessions {
|
||||
limit: number
|
||||
offset: number
|
||||
sessions: SessionInfo[]
|
||||
total: number
|
||||
}
|
||||
|
||||
export interface RpcEvent<T = unknown> {
|
||||
payload?: T
|
||||
session_id?: string
|
||||
type: string
|
||||
}
|
||||
|
||||
export interface SessionCreateResponse {
|
||||
info?: SessionRuntimeInfo
|
||||
session_id: string
|
||||
stored_session_id?: string
|
||||
}
|
||||
|
||||
export interface SessionInfo {
|
||||
ended_at: null | number
|
||||
id: string
|
||||
input_tokens: number
|
||||
is_active: boolean
|
||||
last_active: number
|
||||
message_count: number
|
||||
model: null | string
|
||||
output_tokens: number
|
||||
preview: null | string
|
||||
source: null | string
|
||||
started_at: number
|
||||
title: null | string
|
||||
tool_call_count: number
|
||||
}
|
||||
|
||||
export interface SessionMessage {
|
||||
codex_reasoning_items?: unknown
|
||||
content: null | string
|
||||
context?: string
|
||||
name?: string
|
||||
reasoning?: null | string
|
||||
reasoning_content?: null | string
|
||||
reasoning_details?: unknown
|
||||
role: 'assistant' | 'system' | 'tool' | 'user'
|
||||
text?: string
|
||||
timestamp?: number
|
||||
tool_call_id?: null | string
|
||||
tool_calls?: unknown
|
||||
tool_name?: string
|
||||
}
|
||||
|
||||
export interface SessionMessagesResponse {
|
||||
messages: SessionMessage[]
|
||||
session_id: string
|
||||
}
|
||||
|
||||
export interface SessionResumeResponse {
|
||||
info?: SessionRuntimeInfo
|
||||
message_count: number
|
||||
messages: SessionMessage[]
|
||||
resumed: string
|
||||
session_id: string
|
||||
}
|
||||
|
||||
export interface SessionRuntimeInfo {
|
||||
branch?: string
|
||||
cwd?: string
|
||||
fast?: boolean
|
||||
model?: string
|
||||
personality?: string
|
||||
provider?: string
|
||||
reasoning_effort?: string
|
||||
service_tier?: string
|
||||
skills?: Record<string, string[]> | string[]
|
||||
tools?: Record<string, string[]>
|
||||
version?: string
|
||||
}
|
||||
|
||||
export interface SkillInfo {
|
||||
category: string
|
||||
description: string
|
||||
enabled: boolean
|
||||
name: string
|
||||
}
|
||||
|
||||
export interface ToolsetInfo {
|
||||
configured: boolean
|
||||
description: string
|
||||
enabled: boolean
|
||||
label: string
|
||||
name: string
|
||||
tools: string[]
|
||||
}
|
||||
1
apps/desktop/src/vite-env.d.ts
vendored
Normal file
|
|
@ -0,0 +1 @@
|
|||
/// <reference types="vite/client" />
|
||||
24
apps/desktop/tsconfig.json
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"useDefineForClassFields": true,
|
||||
"lib": ["DOM", "DOM.Iterable", "ES2022"],
|
||||
"allowJs": false,
|
||||
"skipLibCheck": true,
|
||||
"esModuleInterop": true,
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"strict": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "Bundler",
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"noEmit": true,
|
||||
"jsx": "react-jsx",
|
||||
"paths": {
|
||||
"@/*": ["./src/*"]
|
||||
}
|
||||
},
|
||||
"include": ["src"],
|
||||
"references": []
|
||||
}
|
||||
22
apps/desktop/vite.config.ts
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
import { defineConfig } from 'vite'
|
||||
import react from '@vitejs/plugin-react'
|
||||
import tailwindcss from '@tailwindcss/vite'
|
||||
import path from 'path'
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react(), tailwindcss()],
|
||||
resolve: {
|
||||
alias: {
|
||||
'@': path.resolve(__dirname, './src')
|
||||
}
|
||||
},
|
||||
server: {
|
||||
host: '127.0.0.1',
|
||||
port: 5174,
|
||||
strictPort: true
|
||||
},
|
||||
preview: {
|
||||
host: '127.0.0.1',
|
||||
port: 4174
|
||||
}
|
||||
})
|
||||