feat(desktop): polish chat voice and loading states

This commit is contained in:
Brooklyn Nicholson
2026-05-01 16:44:30 -05:00
parent 6c624f197c
commit 9f3d393a4d
79 changed files with 9395 additions and 4880 deletions
+1
View File
@@ -55,6 +55,7 @@ environments/benchmarks/evals/
# Web UI build output
hermes_cli/web_dist/
apps/desktop/dist/
apps/desktop/*.tsbuildinfo
# Web UI assets — synced from @nous-research/ui at build time via
# `npm run sync-assets` (see web/package.json).
+34 -1
View File
@@ -1,4 +1,16 @@
const { app, BrowserWindow, Menu, Notification, clipboard, dialog, ipcMain, nativeImage, shell } = require('electron')
const {
app,
BrowserWindow,
Menu,
Notification,
clipboard,
dialog,
ipcMain,
nativeImage,
session,
shell,
systemPreferences
} = require('electron')
const crypto = require('node:crypto')
const fs = require('node:fs')
const http = require('node:http')
@@ -391,6 +403,18 @@ function installContextMenu(window) {
})
}
function installMediaPermissions() {
session.defaultSession.setPermissionRequestHandler((_webContents, permission, callback, details) => {
if (permission === 'media' && details?.mediaTypes?.includes('audio')) {
callback(true)
return
}
callback(false)
})
}
async function startHermes() {
if (connectionPromise) return connectionPromise
@@ -488,6 +512,14 @@ function createWindow() {
ipcMain.handle('hermes:connection', async () => startHermes())
ipcMain.handle('hermes:requestMicrophoneAccess', async () => {
if (!IS_MAC || typeof systemPreferences.askForMediaAccess !== 'function') {
return true
}
return systemPreferences.askForMediaAccess('microphone')
})
ipcMain.handle('hermes:api', async (_event, request) => {
const connection = await startHermes()
return fetchJson(`${connection.baseUrl}${request.path}`, connection.token, {
@@ -539,6 +571,7 @@ ipcMain.handle('hermes:openExternal', (_event, url) => shell.openExternal(url))
app.whenReady().then(() => {
Menu.setApplicationMenu(buildApplicationMenu())
installMediaPermissions()
createWindow()
startHermes().catch(error => rememberLog(error.stack || error.message))
+1
View File
@@ -4,6 +4,7 @@ contextBridge.exposeInMainWorld('hermesDesktop', {
getConnection: () => ipcRenderer.invoke('hermes:connection'),
api: request => ipcRenderer.invoke('hermes:api', request),
notify: payload => ipcRenderer.invoke('hermes:notify', payload),
requestMicrophoneAccess: () => ipcRenderer.invoke('hermes:requestMicrophoneAccess'),
readFileDataUrl: filePath => ipcRenderer.invoke('hermes:readFileDataUrl', filePath),
selectPaths: options => ipcRenderer.invoke('hermes:selectPaths', options),
writeClipboard: text => ipcRenderer.invoke('hermes:writeClipboard', text),
+105 -287
View File
@@ -19,17 +19,19 @@
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"cmdk": "^1.1.1",
"leva": "^0.10.1",
"liquid-glass-react": "^1.1.1",
"lucide-react": "^0.577.0",
"nanostores": "^1.3.0",
"radix-ui": "^1.4.3",
"react": "^19.2.5",
"react-dom": "^19.2.5",
"react-router-dom": "^7.14.2",
"react-shiki": "^0.9.3",
"shiki": "^4.0.2",
"tailwind-merge": "^3.5.0",
"tailwindcss": "^4.2.4",
"tw-shimmer": "^0.4.11"
"tw-shimmer": "^0.4.11",
"web-haptics": "^0.0.6"
},
"devDependencies": {
"@eslint/js": "^9.39.4",
@@ -5370,15 +5372,6 @@
"integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==",
"license": "MIT"
},
"node_modules/@stitches/react": {
"version": "1.2.8",
"resolved": "https://registry.npmjs.org/@stitches/react/-/react-1.2.8.tgz",
"integrity": "sha512-9g9dWI4gsSVe8bNLlb+lMkBYsnIKCZTmvqvDG+Avnn69XfmHZKiaMrx7cgTaddq7aTPPmXiTsbFcUy0xgI4+wA==",
"license": "MIT",
"peerDependencies": {
"react": ">= 16.3.0"
}
},
"node_modules/@streamdown/code": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/@streamdown/code/-/code-1.1.1.tgz",
@@ -6519,24 +6512,6 @@
"d3-transition": "^3.0.1"
}
},
"node_modules/@use-gesture/core": {
"version": "10.3.1",
"resolved": "https://registry.npmjs.org/@use-gesture/core/-/core-10.3.1.tgz",
"integrity": "sha512-WcINiDt8WjqBdUXye25anHiNxPc0VOrlT8F6LLkU6cycrOGUDyY/yyFmsg3k8i5OLvv25llc0QC45GhR/C8llw==",
"license": "MIT"
},
"node_modules/@use-gesture/react": {
"version": "10.3.1",
"resolved": "https://registry.npmjs.org/@use-gesture/react/-/react-10.3.1.tgz",
"integrity": "sha512-Yy19y6O2GJq8f7CHf7L0nxL8bf4PZCPaVOCgJrusOeFHY1LvHgYXnmnXg6N5iwAnbgbZCDjo60SiM6IPJi9C5g==",
"license": "MIT",
"dependencies": {
"@use-gesture/core": "10.3.1"
},
"peerDependencies": {
"react": ">= 16.8.0"
}
},
"node_modules/@vitejs/plugin-react": {
"version": "6.0.1",
"resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-6.0.1.tgz",
@@ -6919,15 +6894,6 @@
"node": ">=12"
}
},
"node_modules/assign-symbols": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/assign-symbols/-/assign-symbols-1.0.0.tgz",
"integrity": "sha512-Q+JC7Whu8HhmTdBph/Tq59IoRtoy6KAm5zzPv00WdujX82lbAL8K7WVjne7vdCsAmbF4AYaDOPyO3k0kl8qIrw==",
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/assistant-cloud": {
"version": "0.1.27",
"resolved": "https://registry.npmjs.org/assistant-cloud/-/assistant-cloud-0.1.27.tgz",
@@ -6983,15 +6949,6 @@
"dev": true,
"license": "MIT"
},
"node_modules/attr-accept": {
"version": "2.2.5",
"resolved": "https://registry.npmjs.org/attr-accept/-/attr-accept-2.2.5.tgz",
"integrity": "sha512-0bDNnY/u6pPwHDMoF0FieU354oBi0a8rD9FcsLwzcGWbc8KS8KPIi7y+s13OlVY+gMWc/9xEMUgNE6Qm8ZllYQ==",
"license": "MIT",
"engines": {
"node": ">=4"
}
},
"node_modules/available-typed-arrays": {
"version": "1.0.7",
"resolved": "https://registry.npmjs.org/available-typed-arrays/-/available-typed-arrays-1.0.7.tgz",
@@ -7442,12 +7399,6 @@
"dev": true,
"license": "MIT"
},
"node_modules/colord": {
"version": "2.9.3",
"resolved": "https://registry.npmjs.org/colord/-/colord-2.9.3.tgz",
"integrity": "sha512-jeC1axXpnb0/2nn/Y1LPuLdgXBLH7aDcHu4KEKfqw3CUhX7ZpfBSlPKyqXE6btIgEzfWtrX3/tyBCaCvXvMkOw==",
"license": "MIT"
},
"node_modules/combined-stream": {
"version": "1.0.8",
"resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz",
@@ -7525,6 +7476,19 @@
"dev": true,
"license": "MIT"
},
"node_modules/cookie": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/cookie/-/cookie-1.1.1.tgz",
"integrity": "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==",
"license": "MIT",
"engines": {
"node": ">=18"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
}
},
"node_modules/cose-base": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/cose-base/-/cose-base-1.0.3.tgz",
@@ -9100,27 +9064,6 @@
"integrity": "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==",
"license": "MIT"
},
"node_modules/extend-shallow": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/extend-shallow/-/extend-shallow-2.0.1.tgz",
"integrity": "sha512-zCnTtlxNoAiDc3gqY2aYAWFx7XWWiasuF2K8Me5WbN8otHKTUKBwjPtNpRs/rbUZm7KxWAaNj7P1a/p52GbVug==",
"license": "MIT",
"dependencies": {
"is-extendable": "^0.1.0"
},
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/extend-shallow/node_modules/is-extendable": {
"version": "0.1.1",
"resolved": "https://registry.npmjs.org/is-extendable/-/is-extendable-0.1.1.tgz",
"integrity": "sha512-5BMULNob1vgFX6EjQw5izWDxrecWK9AM72rugNr0TFldMOi0fj6Jk+zeKIt0xGj4cEfQIJth4w3OKWOJ4f+AFw==",
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/extract-zip": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/extract-zip/-/extract-zip-2.0.1.tgz",
@@ -9203,18 +9146,6 @@
"node": ">=16.0.0"
}
},
"node_modules/file-selector": {
"version": "0.5.0",
"resolved": "https://registry.npmjs.org/file-selector/-/file-selector-0.5.0.tgz",
"integrity": "sha512-s8KNnmIDTBoD0p9uJ9uD0XY38SCeBOtj0UMXyQSLg1Ypfrfj8+dAvwsLjYQkQ2GjhVtp2HrnF5cJzMhBjfD8HA==",
"license": "MIT",
"dependencies": {
"tslib": "^2.0.3"
},
"engines": {
"node": ">= 10"
}
},
"node_modules/find-up": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz",
@@ -9290,15 +9221,6 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/for-in": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/for-in/-/for-in-1.0.2.tgz",
"integrity": "sha512-7EwmXrOjyL+ChxMhmG5lnW9MPt1aIeZEwKhQzoBUdTV0N3zuwWDZYVJatDvZ2OyzPUvdIAZDsCetk3coyMfcnQ==",
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/form-data": {
"version": "4.0.5",
"resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz",
@@ -9498,15 +9420,6 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/get-value": {
"version": "2.0.6",
"resolved": "https://registry.npmjs.org/get-value/-/get-value-2.0.6.tgz",
"integrity": "sha512-Ln0UQDlxH1BapMu3GPtf7CuYNwRZf2gwCuPqbyG6pB8WfmFpzqcy4xtAaAMUhnNqjMKTiCPZG2oMT3YSx8U2NA==",
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/glob-parent": {
"version": "6.0.2",
"resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz",
@@ -10219,18 +10132,6 @@
"url": "https://github.com/sponsors/wooorm"
}
},
"node_modules/is-extendable": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/is-extendable/-/is-extendable-1.0.1.tgz",
"integrity": "sha512-arnXMxT1hhoKo9k1LZdmlNyJdDDfy2v0fXjFlmok4+i8ul/6WlbVge9bhM74OpNPQPMGUToDtz+KXa1PneJxOA==",
"license": "MIT",
"dependencies": {
"is-plain-object": "^2.0.4"
},
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/is-extglob": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz",
@@ -10365,18 +10266,6 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/is-plain-object": {
"version": "2.0.4",
"resolved": "https://registry.npmjs.org/is-plain-object/-/is-plain-object-2.0.4.tgz",
"integrity": "sha512-h5PpgXkWitc38BBMYawTYMWJHFZJVnBquFE57xFpjB8pJFiF6gZ+bU+WyI/yqXiFR5mdLsgYNaPe8uao6Uv9Og==",
"license": "MIT",
"dependencies": {
"isobject": "^3.0.1"
},
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/is-potential-custom-element-name": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz",
@@ -10543,15 +10432,6 @@
"dev": true,
"license": "ISC"
},
"node_modules/isobject": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/isobject/-/isobject-3.0.1.tgz",
"integrity": "sha512-WhB9zCku7EGTj/HQQRz5aUQEUeoQZH2bWcltRErOpymJ4boYE6wL9Tbr23krRPSZ+C5zqNSrSw+Cc7sZZ4b7vg==",
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/iterator.prototype": {
"version": "1.1.5",
"resolved": "https://registry.npmjs.org/iterator.prototype/-/iterator.prototype-1.1.5.tgz",
@@ -10602,6 +10482,7 @@
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
"integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==",
"dev": true,
"license": "MIT"
},
"node_modules/js-yaml": {
@@ -10829,46 +10710,6 @@
"integrity": "sha512-8h2oVEZNktL4BH2JCOI90iD1yXwL6iNW7KcCKT2QZgQJR2vbqDsldCTPRU9NifTCqHZci57XvQQ15YTu+sTYPg==",
"license": "MIT"
},
"node_modules/leva": {
"version": "0.10.1",
"resolved": "https://registry.npmjs.org/leva/-/leva-0.10.1.tgz",
"integrity": "sha512-BcjnfUX8jpmwZUz2L7AfBtF9vn4ggTH33hmeufDULbP3YgNZ/C+ss/oO3stbrqRQyaOmRwy70y7BGTGO81S3rA==",
"license": "MIT",
"dependencies": {
"@radix-ui/react-portal": "^1.1.4",
"@radix-ui/react-tooltip": "^1.1.8",
"@stitches/react": "^1.2.8",
"@use-gesture/react": "^10.2.5",
"colord": "^2.9.2",
"dequal": "^2.0.2",
"merge-value": "^1.0.0",
"react-colorful": "^5.5.1",
"react-dropzone": "^12.0.0",
"v8n": "^1.3.3",
"zustand": "^3.6.9"
},
"peerDependencies": {
"react": "^18.0.0 || ^19.0.0",
"react-dom": "^18.0.0 || ^19.0.0"
}
},
"node_modules/leva/node_modules/zustand": {
"version": "3.7.2",
"resolved": "https://registry.npmjs.org/zustand/-/zustand-3.7.2.tgz",
"integrity": "sha512-PIJDIZKtokhof+9+60cpockVOq05sJzHCriyvaLBmEJixseQ1a5Kdov6fWZfWOu5SK9c+FhH1jU0tntLxRJYMA==",
"license": "MIT",
"engines": {
"node": ">=12.7.0"
},
"peerDependencies": {
"react": ">=16.8"
},
"peerDependenciesMeta": {
"react": {
"optional": true
}
}
},
"node_modules/levn": {
"version": "0.4.1",
"resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz",
@@ -11132,6 +10973,19 @@
"url": "https://opencollective.com/parcel"
}
},
"node_modules/liquid-glass-react": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/liquid-glass-react/-/liquid-glass-react-1.1.1.tgz",
"integrity": "sha512-pKzaktaMAEztd93wpWcz2Z5Z9qdLJUNJdMX+n00Ca4XsnrLTQ5xJzm/+GQXZUeuFXe/PQ8ziVMZO6531PyaFJw==",
"license": "MIT",
"workspaces": [
"liquid-glass"
],
"peerDependencies": {
"react": ">=19",
"react-dom": ">=19"
}
},
"node_modules/locate-path": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz",
@@ -11182,6 +11036,7 @@
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz",
"integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==",
"dev": true,
"license": "MIT",
"dependencies": {
"js-tokens": "^3.0.0 || ^4.0.0"
@@ -11574,21 +11429,6 @@
"dev": true,
"license": "CC0-1.0"
},
"node_modules/merge-value": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/merge-value/-/merge-value-1.0.0.tgz",
"integrity": "sha512-fJMmvat4NeKz63Uv9iHWcPDjCWcCkoiRoajRTEO8hlhUC6rwaHg0QCF9hBOTjZmm4JuglPckPSTtcuJL5kp0TQ==",
"license": "MIT",
"dependencies": {
"get-value": "^2.0.6",
"is-extendable": "^1.0.0",
"mixin-deep": "^1.2.0",
"set-value": "^2.0.0"
},
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/mermaid": {
"version": "11.14.0",
"resolved": "https://registry.npmjs.org/mermaid/-/mermaid-11.14.0.tgz",
@@ -12252,19 +12092,6 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/mixin-deep": {
"version": "1.3.2",
"resolved": "https://registry.npmjs.org/mixin-deep/-/mixin-deep-1.3.2.tgz",
"integrity": "sha512-WRoDn//mXBiJ1H40rqa3vH0toePwSsGb45iInWlTySa+Uu4k3tYUSxa2v1KqAiLtvlrSzaExqS1gtk96A9zvEA==",
"license": "MIT",
"dependencies": {
"for-in": "^1.0.2",
"is-extendable": "^1.0.1"
},
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/mlly": {
"version": "1.8.2",
"resolved": "https://registry.npmjs.org/mlly/-/mlly-1.8.2.tgz",
@@ -12376,6 +12203,7 @@
"version": "4.1.1",
"resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz",
"integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=0.10.0"
@@ -12858,6 +12686,7 @@
"version": "15.8.1",
"resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz",
"integrity": "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==",
"dev": true,
"license": "MIT",
"dependencies": {
"loose-envify": "^1.4.0",
@@ -13061,16 +12890,6 @@
"node": ">=0.10.0"
}
},
"node_modules/react-colorful": {
"version": "5.6.1",
"resolved": "https://registry.npmjs.org/react-colorful/-/react-colorful-5.6.1.tgz",
"integrity": "sha512-1exovf0uGTGyq5mXQT0zgQ80uvj2PCwvF8zY1RN9/vbJVSjSo3fsB/4L3ObbF7u70NduSiK4xu4Y6q1MHoUGEw==",
"license": "MIT",
"peerDependencies": {
"react": ">=16.8.0",
"react-dom": ">=16.8.0"
}
},
"node_modules/react-dom": {
"version": "19.2.5",
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.5.tgz",
@@ -13083,27 +12902,11 @@
"react": "^19.2.5"
}
},
"node_modules/react-dropzone": {
"version": "12.1.0",
"resolved": "https://registry.npmjs.org/react-dropzone/-/react-dropzone-12.1.0.tgz",
"integrity": "sha512-iBYHA1rbopIvtzokEX4QubO6qk5IF/x3BtKGu74rF2JkQDXnwC4uO/lHKpaw4PJIV6iIAYOlwLv2FpiGyqHNog==",
"license": "MIT",
"dependencies": {
"attr-accept": "^2.2.2",
"file-selector": "^0.5.0",
"prop-types": "^15.8.1"
},
"engines": {
"node": ">= 10.13"
},
"peerDependencies": {
"react": ">= 16.8"
}
},
"node_modules/react-is": {
"version": "16.13.1",
"resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz",
"integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==",
"dev": true,
"license": "MIT"
},
"node_modules/react-remove-scroll": {
@@ -13153,6 +12956,44 @@
}
}
},
"node_modules/react-router": {
"version": "7.14.2",
"resolved": "https://registry.npmjs.org/react-router/-/react-router-7.14.2.tgz",
"integrity": "sha512-yCqNne6I8IB6rVCH7XUvlBK7/QKyqypBFGv+8dj4QBFJiiRX+FG7/nkdAvGElyvVZ/HQP5N19wzteuTARXi5Gw==",
"license": "MIT",
"dependencies": {
"cookie": "^1.0.1",
"set-cookie-parser": "^2.6.0"
},
"engines": {
"node": ">=20.0.0"
},
"peerDependencies": {
"react": ">=18",
"react-dom": ">=18"
},
"peerDependenciesMeta": {
"react-dom": {
"optional": true
}
}
},
"node_modules/react-router-dom": {
"version": "7.14.2",
"resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-7.14.2.tgz",
"integrity": "sha512-YZcM5ES8jJSM+KrJ9BdvHHqlnGTg5tH3sC5ChFRj4inosKctdyzBDhOyyHdGk597q2OT6NTrCA1OvB/YDwfekQ==",
"license": "MIT",
"dependencies": {
"react-router": "7.14.2"
},
"engines": {
"node": ">=20.0.0"
},
"peerDependencies": {
"react": ">=18",
"react-dom": ">=18"
}
},
"node_modules/react-shiki": {
"version": "0.9.3",
"resolved": "https://registry.npmjs.org/react-shiki/-/react-shiki-0.9.3.tgz",
@@ -13694,6 +13535,12 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/set-cookie-parser": {
"version": "2.7.2",
"resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.7.2.tgz",
"integrity": "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==",
"license": "MIT"
},
"node_modules/set-function-length": {
"version": "1.2.2",
"resolved": "https://registry.npmjs.org/set-function-length/-/set-function-length-1.2.2.tgz",
@@ -13743,30 +13590,6 @@
"node": ">= 0.4"
}
},
"node_modules/set-value": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/set-value/-/set-value-2.0.1.tgz",
"integrity": "sha512-JxHc1weCN68wRY0fhCoXpyK55m/XPHafOmK4UWD7m2CI14GMcFypt4w/0+NV5f/ZMby2F6S2wwA7fgynh9gWSw==",
"license": "MIT",
"dependencies": {
"extend-shallow": "^2.0.1",
"is-extendable": "^0.1.1",
"is-plain-object": "^2.0.3",
"split-string": "^3.0.1"
},
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/set-value/node_modules/is-extendable": {
"version": "0.1.1",
"resolved": "https://registry.npmjs.org/is-extendable/-/is-extendable-0.1.1.tgz",
"integrity": "sha512-5BMULNob1vgFX6EjQw5izWDxrecWK9AM72rugNr0TFldMOi0fj6Jk+zeKIt0xGj4cEfQIJth4w3OKWOJ4f+AFw==",
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/shebang-command": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz",
@@ -13924,31 +13747,6 @@
"url": "https://github.com/sponsors/wooorm"
}
},
"node_modules/split-string": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/split-string/-/split-string-3.1.0.tgz",
"integrity": "sha512-NzNVhJDYpwceVVii8/Hu6DKfD2G+NrQHlS/V/qgv763EYudVwEcMQNxd2lh+0VrUByXN/oJkl5grOhYWvQUYiw==",
"license": "MIT",
"dependencies": {
"extend-shallow": "^3.0.0"
},
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/split-string/node_modules/extend-shallow": {
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/extend-shallow/-/extend-shallow-3.0.2.tgz",
"integrity": "sha512-BwY5b5Ql4+qZoefgMj2NUmx+tehVTH/Kf4k1ZEtOHNFcm2wSxMRo992l6X3TIgni2eZVTZ85xMOjF31fwZAj6Q==",
"license": "MIT",
"dependencies": {
"assign-symbols": "^1.0.0",
"is-extendable": "^1.0.1"
},
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/sprintf-js": {
"version": "1.1.3",
"resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.1.3.tgz",
@@ -14841,12 +14639,6 @@
"uuid": "dist/esm/bin/uuid"
}
},
"node_modules/v8n": {
"version": "1.5.1",
"resolved": "https://registry.npmjs.org/v8n/-/v8n-1.5.1.tgz",
"integrity": "sha512-LdabyT4OffkyXFCe9UT+uMkxNBs5rcTVuZClvxQr08D5TUgo1OFKkoT65qYRCsiKBl/usHjpXvP4hHMzzDRj3A==",
"license": "MIT"
},
"node_modules/vfile": {
"version": "6.0.3",
"resolved": "https://registry.npmjs.org/vfile/-/vfile-6.0.3.tgz",
@@ -15138,6 +14930,32 @@
"node": ">=20.0.0"
}
},
"node_modules/web-haptics": {
"version": "0.0.6",
"resolved": "https://registry.npmjs.org/web-haptics/-/web-haptics-0.0.6.tgz",
"integrity": "sha512-eCzcf1LDi20+Fr0x9V3OkX92k0gxEQXaHajmhXHitsnk6SxPeshv8TBtBRqxyst8HI1uf2FyFVE7QS3jo1gkrw==",
"license": "MIT",
"peerDependencies": {
"react": ">=18",
"react-dom": ">=18",
"svelte": ">=4",
"vue": ">=3"
},
"peerDependenciesMeta": {
"react": {
"optional": true
},
"react-dom": {
"optional": true
},
"svelte": {
"optional": true
},
"vue": {
"optional": true
}
}
},
"node_modules/web-namespaces": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/web-namespaces/-/web-namespaces-2.0.1.tgz",
+4 -2
View File
@@ -32,17 +32,19 @@
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"cmdk": "^1.1.1",
"leva": "^0.10.1",
"liquid-glass-react": "^1.1.1",
"lucide-react": "^0.577.0",
"nanostores": "^1.3.0",
"radix-ui": "^1.4.3",
"react": "^19.2.5",
"react-dom": "^19.2.5",
"react-router-dom": "^7.14.2",
"react-shiki": "^0.9.3",
"shiki": "^4.0.2",
"tailwind-merge": "^3.5.0",
"tailwindcss": "^4.2.4",
"tw-shimmer": "^0.4.11"
"tw-shimmer": "^0.4.11",
"web-haptics": "^0.0.6"
},
"devDependencies": {
"@eslint/js": "^9.39.4",
+772 -19
View File
@@ -1,27 +1,780 @@
import { Layers3 } from 'lucide-react'
import type * as React from 'react'
import {
Copy,
Download,
ExternalLink,
FileImage,
FileText,
FolderOpen,
Layers3,
Link2,
RefreshCw,
Search,
X
} from 'lucide-react'
import type { ReactNode } from 'react'
import { useCallback, useEffect, useMemo, useState } from 'react'
import { useNavigate } from 'react-router-dom'
import { titlebarHeaderClass } from '../shell/titlebar'
import { PageLoader } from '@/components/page-loader'
import { Button } from '@/components/ui/button'
import { Dialog, DialogContent } from '@/components/ui/dialog'
import { Input } from '@/components/ui/input'
import { getSessionMessages, listSessions } from '@/hermes'
import { sessionTitle } from '@/lib/chat-runtime'
import { cn } from '@/lib/utils'
import { notify, notifyError } from '@/store/notifications'
import type { SessionInfo, SessionMessage } from '@/types/hermes'
export function ArtifactsView(props: React.ComponentProps<'section'>) {
import { sessionRoute } from '../routes'
import { TITLEBAR_ICON_SIZE, titlebarButtonClass, titlebarHeaderBaseClass } from '../shell/titlebar'
type ArtifactKind = 'image' | 'file' | 'link'
interface ArtifactRecord {
id: string
kind: ArtifactKind
value: string
href: string
label: string
sessionId: string
sessionTitle: string
timestamp: number
}
const MARKDOWN_IMAGE_RE = /!\[([^\]]*)\]\(([^)\s]+)\)/g
const MARKDOWN_LINK_RE = /\[([^\]]+)\]\(([^)\s]+)\)/g
const URL_RE = /https?:\/\/[^\s<>"')]+/g
const PATH_RE = /(^|[\s("'`])((?:\/|~\/|\.\.?\/)[^\s"'`<>]+(?:\.[a-z0-9]{1,8})?)/gi
const IMAGE_EXT_RE = /\.(?:png|jpe?g|gif|webp|svg|bmp)(?:\?.*)?$/i
const FILE_EXT_RE = /\.(?:png|jpe?g|gif|webp|svg|bmp|pdf|txt|json|md|csv|zip|tar|gz|mp3|wav|mp4|mov)(?:\?.*)?$/i
const KEY_HINT_RE = /(path|file|url|image|artifact|output|download|result|target)/i
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'
const ARTIFACT_TIME_FMT = new Intl.DateTimeFormat(undefined, {
day: 'numeric',
hour: 'numeric',
minute: '2-digit',
month: 'short'
})
function normalizeValue(value: string): string {
return value.trim().replace(/[),.;]+$/, '')
}
function parseMaybeJson(value: string): unknown {
if (!value.trim()) {
return null
}
try {
return JSON.parse(value)
} catch {
return null
}
}
function looksLikePathOrUrl(value: string): boolean {
return (
<section
{...props}
className="flex h-[calc(100vh-0.375rem)] min-w-0 flex-col overflow-hidden rounded-[0.9375rem] bg-background"
value.startsWith('http://') ||
value.startsWith('https://') ||
value.startsWith('file://') ||
value.startsWith('data:image/') ||
value.startsWith('/') ||
value.startsWith('./') ||
value.startsWith('../') ||
value.startsWith('~/')
)
}
function looksLikeArtifact(value: string): boolean {
if (value.startsWith('data:image/')) {
return true
}
if (looksLikePathOrUrl(value) && (IMAGE_EXT_RE.test(value) || FILE_EXT_RE.test(value))) {
return true
}
return value.startsWith('/') && value.includes('.')
}
function artifactKind(value: string): ArtifactKind {
if (value.startsWith('data:image/') || IMAGE_EXT_RE.test(value)) {
return 'image'
}
if (value.startsWith('/') || value.startsWith('./') || value.startsWith('../') || value.startsWith('~/') || value.startsWith('file://')) {
return 'file'
}
return 'link'
}
function artifactHref(value: string): string {
if (value.startsWith('http://') || value.startsWith('https://') || value.startsWith('file://') || value.startsWith('data:')) {
return value
}
if (value.startsWith('/')) {
return `file://${encodeURI(value)}`
}
return value
}
function artifactLabel(value: string): string {
try {
const url = new URL(value)
const item = url.pathname.split('/').filter(Boolean).pop()
return item || value
} catch {
const parts = value.split(/[\\/]/).filter(Boolean)
return parts.pop() || value
}
}
function messageText(message: SessionMessage): string {
if (typeof message.content === 'string' && message.content.trim()) {
return message.content
}
if (typeof message.text === 'string' && message.text.trim()) {
return message.text
}
if (typeof message.context === 'string' && message.context.trim()) {
return message.context
}
return ''
}
function collectStringValues(value: unknown, keyPath: string, collector: (value: string, keyPath: string) => void): void {
if (typeof value === 'string') {
collector(value, keyPath)
return
}
if (Array.isArray(value)) {
value.forEach((entry, index) => collectStringValues(entry, `${keyPath}.${index}`, collector))
return
}
if (!value || typeof value !== 'object') {
return
}
for (const [key, child] of Object.entries(value as Record<string, unknown>)) {
collectStringValues(child, keyPath ? `${keyPath}.${key}` : key, collector)
}
}
function collectArtifactsFromText(text: string, pushValue: (value: string) => void): void {
for (const match of text.matchAll(MARKDOWN_IMAGE_RE)) {
pushValue(match[2] || '')
}
for (const match of text.matchAll(MARKDOWN_LINK_RE)) {
const start = match.index ?? 0
if (start > 0 && text[start - 1] === '!') {
continue
}
const value = match[2] || ''
if (looksLikeArtifact(value)) {
pushValue(value)
}
}
for (const match of text.matchAll(URL_RE)) {
const value = match[0] || ''
if (looksLikeArtifact(value)) {
pushValue(value)
}
}
for (const match of text.matchAll(PATH_RE)) {
pushValue(match[2] || '')
}
}
function collectArtifactsFromMessage(message: SessionMessage, pushValue: (value: string) => void): void {
const text = messageText(message)
if (text) {
collectArtifactsFromText(text, pushValue)
}
if (message.role !== 'tool' && !Array.isArray(message.tool_calls)) {
return
}
if (Array.isArray(message.tool_calls)) {
for (const call of message.tool_calls) {
collectStringValues(call, 'tool_call', (value, keyPath) => {
const normalized = normalizeValue(value)
if (!normalized) {
return
}
if (KEY_HINT_RE.test(keyPath) && (looksLikePathOrUrl(normalized) || FILE_EXT_RE.test(normalized))) {
pushValue(normalized)
}
})
}
}
const parsed = parseMaybeJson(text)
if (parsed !== null) {
collectStringValues(parsed, 'tool_result', (value, keyPath) => {
const normalized = normalizeValue(value)
if (!normalized) {
return
}
if ((KEY_HINT_RE.test(keyPath) || looksLikePathOrUrl(normalized)) && looksLikeArtifact(normalized)) {
pushValue(normalized)
}
})
}
}
function collectArtifactsForSession(session: SessionInfo, messages: SessionMessage[]): ArtifactRecord[] {
const found = new Map<string, ArtifactRecord>()
const title = sessionTitle(session)
for (const message of messages) {
if (message.role !== 'assistant' && message.role !== 'tool') {
continue
}
collectArtifactsFromMessage(message, candidate => {
const value = normalizeValue(candidate)
if (!value || !looksLikeArtifact(value)) {
return
}
const key = `${session.id}:${value}`
if (found.has(key)) {
return
}
found.set(key, {
id: key,
kind: artifactKind(value),
value,
href: artifactHref(value),
label: artifactLabel(value),
sessionId: session.id,
sessionTitle: title,
timestamp: message.timestamp || session.last_active || session.started_at || Date.now()
})
})
}
return Array.from(found.values())
}
function formatArtifactTime(timestamp: number): string {
return ARTIFACT_TIME_FMT.format(new Date(timestamp))
}
interface ArtifactsViewProps extends React.ComponentProps<'section'> {
setTitlebarActions?: (actions: ReactNode | null) => void
}
export function ArtifactsView({ setTitlebarActions, ...props }: ArtifactsViewProps) {
const navigate = useNavigate()
const [artifacts, setArtifacts] = useState<ArtifactRecord[] | null>(null)
const [query, setQuery] = useState('')
const [kindFilter, setKindFilter] = useState<'all' | ArtifactKind>('all')
const [refreshing, setRefreshing] = useState(false)
const [savingArtifactId, setSavingArtifactId] = useState<string | null>(null)
const [failedImageIds, setFailedImageIds] = useState<Set<string>>(() => new Set())
const [lightboxArtifact, setLightboxArtifact] = useState<ArtifactRecord | null>(null)
const refreshArtifacts = useCallback(async () => {
setRefreshing(true)
try {
const sessions = (await listSessions(30)).sessions
const results = await Promise.allSettled(sessions.map(session => getSessionMessages(session.id)))
const nextArtifacts: ArtifactRecord[] = []
results.forEach((result, index) => {
if (result.status !== 'fulfilled') {
return
}
const session = sessions[index]
nextArtifacts.push(...collectArtifactsForSession(session, result.value.messages))
})
setArtifacts(nextArtifacts.sort((a, b) => b.timestamp - a.timestamp))
} catch (err) {
notifyError(err, 'Artifacts failed to load')
setArtifacts([])
} finally {
setRefreshing(false)
}
}, [])
useEffect(() => {
void refreshArtifacts()
}, [refreshArtifacts])
useEffect(() => {
if (!setTitlebarActions) {
return
}
setTitlebarActions(
<button
aria-label={refreshing ? 'Refreshing artifacts' : 'Refresh artifacts'}
className={cn(titlebarButtonClass, 'grid place-items-center bg-transparent')}
disabled={refreshing}
onClick={() => void refreshArtifacts()}
type="button"
>
<RefreshCw className={cn(refreshing && 'animate-spin')} size={TITLEBAR_ICON_SIZE} />
</button>
)
return () => setTitlebarActions(null)
}, [refreshArtifacts, refreshing, setTitlebarActions])
const visibleArtifacts = useMemo(() => {
if (!artifacts) {
return []
}
const q = query.trim().toLowerCase()
return artifacts.filter(artifact => {
if (kindFilter !== 'all' && artifact.kind !== kindFilter) {
return false
}
if (!q) {
return true
}
return (
artifact.label.toLowerCase().includes(q) ||
artifact.value.toLowerCase().includes(q) ||
artifact.sessionTitle.toLowerCase().includes(q)
)
})
}, [artifacts, kindFilter, query])
const counts = useMemo(() => {
const all = artifacts || []
return {
all: all.length,
image: all.filter(artifact => artifact.kind === 'image').length,
file: all.filter(artifact => artifact.kind === 'file').length,
link: all.filter(artifact => artifact.kind === 'link').length
}
}, [artifacts])
const copyArtifact = useCallback(async (value: string) => {
try {
if (window.hermesDesktop?.writeClipboard) {
await window.hermesDesktop.writeClipboard(value)
} else if (navigator.clipboard?.writeText) {
await navigator.clipboard.writeText(value)
}
notify({
kind: 'success',
title: 'Copied',
message: value
})
} catch (err) {
notifyError(err, 'Copy failed')
}
}, [])
const openArtifact = useCallback(async (href: string) => {
try {
if (window.hermesDesktop?.openExternal) {
await window.hermesDesktop.openExternal(href)
} else {
window.open(href, '_blank', 'noopener,noreferrer')
}
} catch (err) {
notifyError(err, 'Open failed')
}
}, [])
const saveImageArtifact = useCallback(async (artifact: ArtifactRecord) => {
if (artifact.kind !== 'image') {
return
}
setSavingArtifactId(artifact.id)
try {
if (!window.hermesDesktop?.saveImageFromUrl) {
throw new Error('Image saving is unavailable in this build.')
}
const saved = await window.hermesDesktop.saveImageFromUrl(artifact.href)
if (saved) {
notify({
kind: 'success',
title: 'Image saved',
message: artifact.label
})
}
} catch (err) {
notifyError(err, 'Save failed')
} finally {
setSavingArtifactId(null)
}
}, [])
const markImageFailed = useCallback((id: string) => {
setFailedImageIds(current => {
if (current.has(id)) {
return current
}
return new Set(current).add(id)
})
}, [])
const imageLightbox = lightboxArtifact ? (
<Dialog onOpenChange={open => !open && setLightboxArtifact(null)} open>
<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={lightboxArtifact.label}
className="block max-h-[calc(100vh-2rem)] max-w-full cursor-zoom-out select-auto rounded-lg object-contain shadow-2xl"
onClick={() => setLightboxArtifact(null)}
src={lightboxArtifact.href}
/>
<button
aria-label={savingArtifactId === lightboxArtifact.id ? 'Saving image' : 'Download image'}
className={cn(imageActionButtonClass, 'group-hover/lightbox:opacity-100')}
disabled={savingArtifactId === lightboxArtifact.id}
onClick={event => {
event.stopPropagation()
void saveImageArtifact(lightboxArtifact)
}}
title={savingArtifactId === lightboxArtifact.id ? 'Saving image' : 'Download image'}
type="button"
>
<Download className={cn('size-4', savingArtifactId === lightboxArtifact.id && 'animate-pulse')} />
</button>
</div>
</DialogContent>
</Dialog>
) : null
return (
<>
<section
{...props}
className="flex h-[calc(100vh-0.375rem)] min-w-0 flex-col overflow-hidden rounded-[0.9375rem] bg-background"
>
<header className={titlebarHeaderBaseClass}>
<h2 className="text-base font-semibold leading-none tracking-tight">Artifacts</h2>
<span className="text-xs text-muted-foreground">{counts.all} found</span>
</header>
<div className="min-h-0 flex-1 overflow-hidden rounded-[1.0625rem] border border-border/50 bg-background/85">
<div className="border-b border-border/50 px-4 py-3">
<div className="flex flex-wrap items-center gap-2">
<FilterButton
active={kindFilter === 'all'}
icon={Layers3}
label={`All (${counts.all})`}
onClick={() => setKindFilter('all')}
/>
<FilterButton
active={kindFilter === 'image'}
icon={FileImage}
label={`Images (${counts.image})`}
onClick={() => setKindFilter('image')}
/>
<FilterButton
active={kindFilter === 'file'}
icon={FileText}
label={`Files (${counts.file})`}
onClick={() => setKindFilter('file')}
/>
<FilterButton
active={kindFilter === 'link'}
icon={Link2}
label={`Links (${counts.link})`}
onClick={() => setKindFilter('link')}
/>
<div className="ml-auto w-full max-w-sm min-w-64">
<div className="relative">
<Search className="pointer-events-none absolute left-2.5 top-1/2 size-3.5 -translate-y-1/2 text-muted-foreground" />
<Input
className="h-8 rounded-lg pl-8 pr-8 text-sm"
onChange={event => setQuery(event.target.value)}
placeholder="Search artifacts..."
value={query}
/>
{query && (
<Button
aria-label="Clear search"
className="absolute right-1 top-1/2 h-6 w-6 -translate-y-1/2 text-muted-foreground hover:text-foreground"
onClick={() => setQuery('')}
size="icon"
type="button"
variant="ghost"
>
<X className="size-3.5" />
</Button>
)}
</div>
</div>
</div>
</div>
{!artifacts ? (
<PageLoader label="Indexing recent session artifacts" />
) : visibleArtifacts.length === 0 ? (
<div className="grid h-full place-items-center px-6 text-center">
<div>
<div className="text-sm font-medium">No artifacts found</div>
<div className="mt-1 text-xs text-muted-foreground">
Generated images and file outputs will appear here as sessions produce them.
</div>
</div>
</div>
) : (
<div className="h-full overflow-y-auto p-3">
<div className="grid grid-cols-[repeat(auto-fill,minmax(13rem,1fr))] items-start gap-3">
{visibleArtifacts.map(artifact => (
<ArtifactCard
artifact={artifact}
failedImage={failedImageIds.has(artifact.id)}
key={artifact.id}
onCopy={copyArtifact}
onImageError={markImageFailed}
onOpen={openArtifact}
onOpenChat={sessionId => navigate(sessionRoute(sessionId))}
onSaveImage={saveImageArtifact}
onZoom={setLightboxArtifact}
saving={savingArtifactId === artifact.id}
/>
))}
</div>
</div>
)}
</div>
</section>
{imageLightbox}
</>
)
}
function FilterButton({
active,
icon: Icon,
label,
onClick
}: {
active: boolean
icon: typeof Layers3
label: string
onClick: () => void
}) {
return (
<Button
className={cn(
'h-8 gap-1.5 rounded-md px-2.5 text-xs',
active ? 'bg-accent text-foreground' : 'text-muted-foreground hover:text-foreground'
)}
onClick={onClick}
size="sm"
type="button"
variant="ghost"
>
<Icon className="size-3.5" />
{label}
</Button>
)
}
interface ArtifactCardProps {
artifact: ArtifactRecord
failedImage: boolean
onCopy: (value: string) => void | Promise<void>
onImageError: (id: string) => void
onOpen: (href: string) => void | Promise<void>
onOpenChat: (sessionId: string) => void
onSaveImage: (artifact: ArtifactRecord) => void | Promise<void>
onZoom: (artifact: ArtifactRecord) => void
saving: boolean
}
function ArtifactCard({
artifact,
failedImage,
onCopy,
onImageError,
onOpen,
onOpenChat,
onSaveImage,
onZoom,
saving
}: ArtifactCardProps) {
const image = artifact.kind === 'image'
if (!image) {
const Icon = artifact.kind === 'file' ? FileText : Link2
return (
<article className="group/artifact grid grid-cols-[2rem_minmax(0,1fr)_auto] items-start gap-2 rounded-xl border border-border/50 bg-background/70 p-3 shadow-[0_0.1875rem_0.75rem_color-mix(in_srgb,black_3%,transparent)]">
<div className="mt-0.5 grid size-8 place-items-center rounded-lg bg-muted text-muted-foreground">
<Icon className="size-4" />
</div>
<div className="min-w-0">
<div className="mb-1 flex items-center gap-1.5 text-[0.68rem] uppercase tracking-[0.08em] text-muted-foreground">
{artifact.kind}
</div>
<div className="truncate text-sm font-medium">{artifact.label}</div>
<div className="mt-0.5 truncate font-mono text-[0.68rem] text-muted-foreground/80">{artifact.value}</div>
<div className="mt-2 truncate text-[0.68rem] text-muted-foreground">
{artifact.sessionTitle} · {formatArtifactTime(artifact.timestamp)}
</div>
</div>
<div className="flex items-center gap-0.5 opacity-70 transition-opacity group-hover/artifact:opacity-100">
<Button
className="text-muted-foreground hover:text-foreground"
onClick={() => void onOpen(artifact.href)}
size="icon-xs"
title="Open"
type="button"
variant="ghost"
>
<ExternalLink className="size-3.5" />
</Button>
<Button
className="text-muted-foreground hover:text-foreground"
onClick={() => void onCopy(artifact.value)}
size="icon-xs"
title="Copy"
type="button"
variant="ghost"
>
<Copy className="size-3.5" />
</Button>
<Button
className="text-muted-foreground hover:text-foreground"
onClick={() => onOpenChat(artifact.sessionId)}
size="icon-xs"
title="Open chat"
type="button"
variant="ghost"
>
<FolderOpen className="size-3.5" />
</Button>
</div>
</article>
)
}
return (
<article
className={cn(
'group/artifact overflow-hidden rounded-xl border border-border/50 bg-background/70 shadow-[0_0.1875rem_0.75rem_color-mix(in_srgb,black_3%,transparent)]',
image && 'bg-muted/20'
)}
>
<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>
{image && (
<button
aria-label={failedImage ? undefined : `Open ${artifact.label}`}
className={cn(
'relative flex h-56 w-full items-center justify-center overflow-hidden border-b border-border/50 bg-[color-mix(in_srgb,var(--dt-muted)_58%,var(--dt-background))] p-2',
failedImage ? 'cursor-default' : 'cursor-zoom-in'
)}
disabled={failedImage}
onClick={() => onZoom(artifact)}
title={failedImage ? undefined : 'Open image'}
type="button"
>
{!failedImage && (
<>
<img
alt=""
className="max-h-full max-w-full rounded-md object-contain shadow-sm"
data-slot="artifact-media"
decoding="async"
loading="lazy"
onError={() => onImageError(artifact.id)}
src={artifact.href}
/>
<span
aria-label={saving ? 'Saving image' : 'Download image'}
className={cn(imageActionButtonClass, 'group-hover/artifact:opacity-100')}
onClick={event => {
event.stopPropagation()
void onSaveImage(artifact)
}}
title={saving ? 'Saving image' : 'Download image'}
>
<Download className={cn('size-4', saving && 'animate-pulse')} />
</span>
</>
)}
</button>
)}
<div className="space-y-2 p-3">
<div className="min-w-0">
<div className="mb-1 flex items-center gap-1.5 text-[0.68rem] uppercase tracking-[0.08em] text-muted-foreground">
{image ? (
<FileImage className="size-3.5" />
) : artifact.kind === 'file' ? (
<FileText className="size-3.5" />
) : (
<Link2 className="size-3.5" />
)}
{artifact.kind}
</div>
<div className="truncate text-sm font-medium">{artifact.label}</div>
<div className="mt-0.5 truncate text-[0.68rem] text-muted-foreground">{artifact.value}</div>
</div>
<div className="truncate text-[0.68rem] text-muted-foreground">
{artifact.sessionTitle} · {formatArtifactTime(artifact.timestamp)}
</div>
<div className="flex flex-wrap gap-1.5">
<Button onClick={() => onOpenChat(artifact.sessionId)} size="sm" type="button" variant="outline">
<FolderOpen className="size-3.5" />
Chat
</Button>
</div>
</div>
</section>
</article>
)
}
@@ -0,0 +1,53 @@
import { X } from 'lucide-react'
import type { ComposerAttachment } from '@/store/composer'
import { ATTACHMENT_ICON } from './constants'
export 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>
)
}
@@ -0,0 +1,115 @@
import type { Unstable_TriggerItem } from '@assistant-ui/core'
import type { Unstable_IconComponent } from '@assistant-ui/react'
import { FileText, FolderOpen, ImageIcon, Link, type LucideIcon } from 'lucide-react'
import type { CSSProperties } from 'react'
import { cn } from '@/lib/utils'
import type { ComposerAttachment } from '@/store/composer'
export const STACK_AT = 500
export const NARROW_VIEWPORT = '(max-width: 680px)'
export const EXPAND_HEIGHT_PX = 42
export const SHELL =
'absolute bottom-0 left-1/2 z-30 w-[min(calc(100%_-_1rem),clamp(26rem,61.8%,56rem))] max-w-full -translate-x-1/2'
export const ICON_BTN = 'h-8 w-8 shrink-0 rounded-full'
export const GHOST_ICON_BTN = cn(ICON_BTN, 'text-muted-foreground hover:bg-accent hover:text-foreground')
export const COMPOSER_BACKDROP_STYLE = {
backdropFilter: 'blur(.5rem) saturate(1.18)',
WebkitBackdropFilter: 'blur(.5rem) saturate(1.18)'
} satisfies CSSProperties
export const ATTACHMENT_ICON: Record<ComposerAttachment['kind'], LucideIcon> = {
folder: FolderOpen,
url: Link,
image: ImageIcon,
file: FileText
}
export const DIRECTIVE_ICONS: Record<string, Unstable_IconComponent> = {
file: FileText,
folder: FolderOpen,
image: ImageIcon,
url: Link
}
export 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'
export 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.'
}
]
export 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.'
]
export 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' }
}
]
export const EDGE_NEWLINES_RE = /^[\t ]*(?:\r\n|\r|\n)+|(?:\r\n|\r|\n)+[\t ]*$/g
export const DEFAULT_MAX_RECORDING_SECONDS = 120
// Conversation-mode VAD tuning — mirrors `tools.voice_mode` defaults so the
// browser pipeline feels like the CLI continuous loop.
export const CONVERSATION_SPEECH_LEVEL = 0.075
export const CONVERSATION_POST_SPEECH_SILENCE_MS = 1_250
export const CONVERSATION_IDLE_SILENCE_MS = 12_000
export const CONVERSATION_MAX_TURN_SECONDS = 60
export const VOICE_MIME_TYPES = [
'audio/webm;codecs=opus',
'audio/webm',
'audio/mp4',
'audio/ogg;codecs=opus',
'audio/ogg',
'audio/wav'
]
@@ -0,0 +1,136 @@
import { Clipboard, FileText, FolderOpen, ImageIcon, Link, type LucideIcon, MessageSquareText, Plus } from 'lucide-react'
import { Button } from '@/components/ui/button'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuSub,
DropdownMenuSubContent,
DropdownMenuSubTrigger,
DropdownMenuTrigger
} from '@/components/ui/dropdown-menu'
import { cn } from '@/lib/utils'
import { GHOST_ICON_BTN, PROMPT_SNIPPETS } from './constants'
import type { ChatBarState, ContextSuggestion } from './types'
export 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>
)
}
export 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>
)
}
@@ -0,0 +1,223 @@
import { ArrowUp, AudioLines, Loader2, Mic, MicOff, Square } from 'lucide-react'
import { Button } from '@/components/ui/button'
import { triggerHaptic } from '@/lib/haptics'
import { cn } from '@/lib/utils'
import { GHOST_ICON_BTN, ICON_BTN } from './constants'
import type { ConversationStatus } from './hooks/use-voice-conversation'
import type { ChatBarState, VoiceStatus } from './types'
interface ConversationProps {
active: boolean
level: number
muted: boolean
status: ConversationStatus
onEnd: () => void
onStart: () => void
onToggleMute: () => void
}
export function ComposerControls({
busy,
canSubmit,
conversation,
disabled,
hasComposerPayload,
state,
voiceStatus,
onDictate
}: {
busy: boolean
canSubmit: boolean
conversation: ConversationProps
disabled: boolean
hasComposerPayload: boolean
state: ChatBarState
voiceStatus: VoiceStatus
onDictate: () => void
}) {
if (conversation.active) {
return <ConversationPill {...conversation} disabled={disabled} />
}
const showVoicePrimary = !busy && !hasComposerPayload
return (
<div className="ml-auto flex shrink-0 items-center gap-1.5">
<DictationButton disabled={disabled} onToggle={onDictate} state={state.voice} status={voiceStatus} />
{showVoicePrimary ? (
<Button
aria-label="Start voice conversation"
className={cn(ICON_BTN, 'p-0')}
disabled={disabled}
onClick={() => {
triggerHaptic('open')
conversation.onStart()
}}
size="icon"
title="Start voice conversation"
type="button"
>
<AudioLines size={17} />
</Button>
) : (
<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 ConversationPill({
disabled,
level,
muted,
onEnd,
onToggleMute,
status
}: ConversationProps & { disabled: boolean }) {
const speaking = status === 'speaking'
const listening = status === 'listening' && !muted
const label =
status === 'speaking'
? 'Speaking'
: status === 'transcribing'
? 'Transcribing'
: status === 'thinking'
? 'Thinking'
: muted
? 'Muted'
: 'Listening'
return (
<div className="ml-auto flex shrink-0 items-center gap-1">
<Button
aria-label={muted ? 'Unmute microphone' : 'Mute microphone'}
aria-pressed={muted}
className={cn(GHOST_ICON_BTN, 'p-0', muted && 'bg-muted text-muted-foreground')}
disabled={disabled}
onClick={() => {
triggerHaptic('selection')
onToggleMute()
}}
size="icon"
title={muted ? 'Unmute microphone' : 'Mute microphone'}
type="button"
variant="ghost"
>
{muted ? <MicOff size={16} /> : <Mic size={16} />}
</Button>
<Button
aria-label="End voice conversation"
className="h-8 gap-1.5 rounded-full bg-primary px-3 text-xs font-medium text-primary-foreground hover:bg-primary/90"
disabled={disabled}
onClick={() => {
triggerHaptic('close')
onEnd()
}}
title="End voice conversation"
type="button"
>
<ConversationIndicator level={level} listening={listening} speaking={speaking} />
<span>End</span>
</Button>
<span className="sr-only" role="status">
{label}
</span>
</div>
)
}
function ConversationIndicator({
level,
listening,
speaking
}: {
level: number
listening: boolean
speaking: boolean
}) {
if (speaking) {
return <Loader2 className="animate-spin" size={12} />
}
const bars = [0.55, 0.85, 1, 0.85, 0.55]
const normalized = Math.max(0, Math.min(level, 1))
return (
<span aria-hidden="true" className="flex h-3 items-center gap-0.5">
{bars.map((weight, index) => {
const height = listening ? 0.3 + Math.min(0.7, normalized * weight) : 0.3
return (
<span
className="w-0.5 rounded-full bg-current"
key={index}
style={{ height: `${height * 100}%` }}
/>
)
})}
</span>
)
}
function DictationButton({
disabled,
state,
status,
onToggle
}: {
disabled: boolean
state: ChatBarState['voice']
status: VoiceStatus
onToggle: () => void
}) {
const active = state.active || status !== 'idle'
const aria =
status === 'recording'
? 'Stop dictation'
: status === 'transcribing'
? 'Transcribing dictation'
: 'Voice dictation'
return (
<Button
aria-label={aria}
aria-pressed={active}
className={cn(
GHOST_ICON_BTN,
'p-0',
'data-[active=true]:bg-accent data-[active=true]:text-foreground',
status === 'recording' && 'bg-primary/10 text-primary hover:bg-primary/15 hover:text-primary',
status === 'transcribing' && 'bg-primary/10 text-primary'
)}
data-active={active}
disabled={disabled || !state.enabled || status === 'transcribing'}
onClick={() => {
triggerHaptic(active ? 'close' : 'open')
onToggle()
}}
size="icon"
title={aria}
type="button"
variant="ghost"
>
{status === 'recording' ? (
<Square className="fill-current" size={12} />
) : status === 'transcribing' ? (
<Loader2 className="animate-spin" size={16} />
) : (
<Mic size={16} />
)}
</Button>
)
}
@@ -0,0 +1,110 @@
import type { Unstable_TriggerAdapter, Unstable_TriggerItem } from '@assistant-ui/core'
import {
ComposerPrimitive,
type Unstable_IconComponent,
type Unstable_MentionCategory,
type Unstable_MentionDirective
} from '@assistant-ui/react'
import { ChevronDown } from 'lucide-react'
import { DIRECTIVE_POPOVER_CLASS, REF_ITEMS } from './constants'
import type { ContextSuggestion } from './types'
export 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>
)
}
export 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
}
@@ -0,0 +1,35 @@
export type ComposerLiquidGlassMode = 'polar' | 'prominent' | 'shader' | 'standard'
export interface ComposerGlassTweakOutputs {
fadeBackground: string
liquid: {
aberrationIntensity: number
blurAmount: number
cornerRadius: number
displacementScale: number
elasticity: number
mode: ComposerLiquidGlassMode
saturation: number
}
liquidKey: string
showLibraryRims: boolean
}
const COMPOSER_GLASS_TWEAKS: ComposerGlassTweakOutputs = {
fadeBackground: 'linear-gradient(to bottom, transparent, color-mix(in srgb, var(--dt-background) 10%, transparent))',
liquid: {
aberrationIntensity: 0.95,
blurAmount: 0.072,
cornerRadius: 20,
displacementScale: 46,
elasticity: 0,
mode: 'standard',
saturation: 128
},
liquidKey: ['standard', '0.950', '0.072', '20', '46', '0.00', '128'].join(':'),
showLibraryRims: false
}
export function useComposerGlassTweaks(): ComposerGlassTweakOutputs {
return COMPOSER_GLASS_TWEAKS
}
@@ -0,0 +1,287 @@
import { useEffect, useRef, useState } from 'react'
import { VOICE_MIME_TYPES } from '../constants'
type BrowserAudioContext = typeof AudioContext
export interface MicRecorderOptions {
onLevel?: (level: number) => void
onError?: (error: Error) => void
onSilence?: () => void
silenceLevel?: number
silenceMs?: number
idleSilenceMs?: number
}
export interface MicRecording {
audio: Blob
durationMs: number
heardSpeech: boolean
}
interface MicRecorderHandle {
start: (options?: MicRecorderOptions) => Promise<void>
stop: () => Promise<MicRecording | null>
cancel: () => void
}
function preferredVoiceMimeType(): string {
if (typeof MediaRecorder === 'undefined') {
return ''
}
return VOICE_MIME_TYPES.find(type => MediaRecorder.isTypeSupported(type)) || ''
}
function micError(error: unknown): Error {
const name = error instanceof DOMException ? error.name : ''
if (name === 'NotAllowedError' || name === 'SecurityError') {
return new Error('Microphone permission was denied.')
}
if (name === 'NotFoundError' || name === 'DevicesNotFoundError') {
return new Error('No microphone was found.')
}
if (name === 'NotReadableError' || name === 'TrackStartError') {
return new Error('Microphone is already in use by another app.')
}
if (name === 'OverconstrainedError') {
return new Error('Microphone constraints are not supported by this device.')
}
if (error instanceof Error) {
return error
}
return new Error('Could not start microphone recording.')
}
export function useMicRecorder(): { handle: MicRecorderHandle; level: number; recording: boolean } {
const [level, setLevel] = useState(0)
const [recording, setRecording] = useState(false)
const recorderRef = useRef<MediaRecorder | null>(null)
const streamRef = useRef<MediaStream | null>(null)
const chunksRef = useRef<Blob[]>([])
const audioContextRef = useRef<AudioContext | null>(null)
const animationRef = useRef<number | null>(null)
const startedAtRef = useRef(0)
const heardSpeechRef = useRef(false)
const silenceTriggeredRef = useRef(false)
const silenceStartedAtRef = useRef<number | null>(null)
const stopResolverRef = useRef<((recording: MicRecording | null) => void) | null>(null)
const cleanup = () => {
if (animationRef.current) {
window.cancelAnimationFrame(animationRef.current)
animationRef.current = null
}
void audioContextRef.current?.close()
audioContextRef.current = null
streamRef.current?.getTracks().forEach(track => track.stop())
streamRef.current = null
recorderRef.current = null
setLevel(0)
setRecording(false)
silenceTriggeredRef.current = false
}
useEffect(() => () => cleanup(), [])
const startMeter = (stream: MediaStream, options: MicRecorderOptions) => {
const audioWindow = window as Window & { webkitAudioContext?: BrowserAudioContext }
const AudioContextCtor = window.AudioContext || audioWindow.webkitAudioContext
if (!AudioContextCtor) {
return
}
try {
const audioContext = new AudioContextCtor()
const analyser = audioContext.createAnalyser()
const source = audioContext.createMediaStreamSource(stream)
analyser.fftSize = 256
const data = new Uint8Array(analyser.fftSize)
source.connect(analyser)
audioContextRef.current = audioContext
const tick = () => {
analyser.getByteTimeDomainData(data)
let sum = 0
for (const value of data) {
const centered = value - 128
sum += centered * centered
}
const rms = Math.sqrt(sum / data.length)
const normalized = Math.min(1, rms / 42)
const now = Date.now()
setLevel(normalized)
options.onLevel?.(normalized)
const speechThreshold = options.silenceLevel ?? 0
const silenceMs = options.silenceMs ?? 0
const idleSilenceMs = options.idleSilenceMs ?? 0
if (speechThreshold > 0 && options.onSilence && !silenceTriggeredRef.current) {
if (normalized >= speechThreshold) {
heardSpeechRef.current = true
silenceStartedAtRef.current = null
} else if (heardSpeechRef.current && silenceMs > 0) {
silenceStartedAtRef.current ??= now
if (now - silenceStartedAtRef.current >= silenceMs) {
silenceTriggeredRef.current = true
options.onSilence()
return
}
} else if (!heardSpeechRef.current && idleSilenceMs > 0 && now - startedAtRef.current >= idleSilenceMs) {
silenceTriggeredRef.current = true
options.onSilence()
return
}
}
animationRef.current = window.requestAnimationFrame(tick)
}
tick()
} catch {
setLevel(0)
}
}
const start: MicRecorderHandle['start'] = async (options = {}) => {
if (recorderRef.current) {
return
}
if (!navigator.mediaDevices?.getUserMedia || typeof MediaRecorder === 'undefined') {
throw new Error('This runtime does not support microphone recording.')
}
const permitted = await window.hermesDesktop?.requestMicrophoneAccess?.()
if (permitted === false) {
throw new Error('Microphone access denied.')
}
let stream: MediaStream
try {
stream = await navigator.mediaDevices.getUserMedia({
audio: { echoCancellation: true, noiseSuppression: true }
})
} catch (error) {
throw micError(error)
}
const mimeType = preferredVoiceMimeType()
let recorder: MediaRecorder
try {
recorder = new MediaRecorder(stream, mimeType ? { mimeType } : undefined)
} catch (error) {
stream.getTracks().forEach(track => track.stop())
throw micError(error)
}
chunksRef.current = []
streamRef.current = stream
recorderRef.current = recorder
heardSpeechRef.current = false
silenceTriggeredRef.current = false
silenceStartedAtRef.current = null
startedAtRef.current = Date.now()
recorder.ondataavailable = event => {
if (event.data.size > 0) {
chunksRef.current.push(event.data)
}
}
recorder.onstop = () => {
const chunks = chunksRef.current
const recordingType = recorder.mimeType || mimeType || 'audio/webm'
const durationMs = Date.now() - startedAtRef.current
const heardSpeech = heardSpeechRef.current
chunksRef.current = []
cleanup()
const resolver = stopResolverRef.current
stopResolverRef.current = null
if (!chunks.length) {
resolver?.(null)
return
}
resolver?.({
audio: new Blob(chunks, { type: recordingType }),
durationMs,
heardSpeech
})
}
recorder.onerror = event => {
const error = micError((event as Event & { error?: unknown }).error)
const resolver = stopResolverRef.current
stopResolverRef.current = null
cleanup()
options.onError?.(error)
resolver?.(null)
}
recorder.start()
setRecording(true)
startMeter(stream, options)
}
const stop: MicRecorderHandle['stop'] = () =>
new Promise<MicRecording | null>(resolve => {
const recorder = recorderRef.current
if (!recorder || recorder.state === 'inactive') {
cleanup()
resolve(null)
return
}
stopResolverRef.current = resolve
recorder.stop()
})
const cancel: MicRecorderHandle['cancel'] = () => {
const recorder = recorderRef.current
const resolver = stopResolverRef.current
stopResolverRef.current = null
if (recorder && recorder.state !== 'inactive') {
recorder.ondataavailable = null
recorder.onerror = null
recorder.onstop = null
recorder.stop()
}
cleanup()
resolver?.(null)
}
const handle: MicRecorderHandle = { start, stop, cancel }
return { handle, level, recording }
}
@@ -0,0 +1,274 @@
import { useCallback, useEffect, useRef, useState } from 'react'
import { speakText } from '@/hermes'
import { notify, notifyError } from '@/store/notifications'
import {
CONVERSATION_IDLE_SILENCE_MS,
CONVERSATION_MAX_TURN_SECONDS,
CONVERSATION_POST_SPEECH_SILENCE_MS,
CONVERSATION_SPEECH_LEVEL
} from '../constants'
import { useMicRecorder } from './use-mic-recorder'
export type ConversationStatus = 'idle' | 'listening' | 'transcribing' | 'thinking' | 'speaking'
interface VoiceConversationOptions {
busy: boolean
enabled: boolean
onFatalError?: () => void
onSubmit: (text: string) => void
onTranscribeAudio?: (audio: Blob) => Promise<string>
pendingResponseText: () => string | null
consumePendingResponse: () => void
}
export function useVoiceConversation({
busy,
enabled,
onFatalError,
onSubmit,
onTranscribeAudio,
pendingResponseText,
consumePendingResponse
}: VoiceConversationOptions) {
const { handle, level } = useMicRecorder()
const [status, setStatus] = useState<ConversationStatus>('idle')
const [muted, setMuted] = useState(false)
const audioRef = useRef<HTMLAudioElement | null>(null)
const turnTimeoutRef = useRef<number | null>(null)
const pendingStartRef = useRef(false)
const lastSpokenRef = useRef<string | null>(null)
const enabledRef = useRef(enabled)
const mutedRef = useRef(muted)
const busyRef = useRef(busy)
const statusRef = useRef<ConversationStatus>('idle')
const wasEnabledRef = useRef(enabled)
useEffect(() => {
enabledRef.current = enabled
}, [enabled])
useEffect(() => {
mutedRef.current = muted
}, [muted])
useEffect(() => {
busyRef.current = busy
}, [busy])
useEffect(() => {
statusRef.current = status
}, [status])
const clearTurnTimeout = () => {
if (turnTimeoutRef.current) {
window.clearTimeout(turnTimeoutRef.current)
turnTimeoutRef.current = null
}
}
const stopAudio = useCallback(() => {
const audio = audioRef.current
if (audio) {
audio.pause()
audio.src = ''
audioRef.current = null
}
}, [])
const handleTurn = useCallback(async () => {
clearTurnTimeout()
setStatus('transcribing')
const result = await handle.stop()
if (!result || !result.heardSpeech || !onTranscribeAudio) {
if (enabledRef.current && !mutedRef.current && !busyRef.current && statusRef.current !== 'speaking') {
pendingStartRef.current = true
}
setStatus('idle')
return
}
try {
const transcript = (await onTranscribeAudio(result.audio)).trim()
if (!transcript) {
if (enabledRef.current) {
pendingStartRef.current = true
}
setStatus('idle')
return
}
onSubmit(transcript)
setStatus('thinking')
} catch (error) {
notifyError(error, 'Voice transcription failed')
if (enabledRef.current && !mutedRef.current && !busyRef.current) {
pendingStartRef.current = true
}
setStatus('idle')
}
}, [handle, onSubmit, onTranscribeAudio])
const startListening = useCallback(async () => {
pendingStartRef.current = false
if (!enabledRef.current || mutedRef.current || busyRef.current) {
return
}
if (statusRef.current !== 'idle') {
return
}
try {
await handle.start({
silenceLevel: CONVERSATION_SPEECH_LEVEL,
silenceMs: CONVERSATION_POST_SPEECH_SILENCE_MS,
idleSilenceMs: CONVERSATION_IDLE_SILENCE_MS,
onError: error => {
notifyError(error, 'Microphone failed')
pendingStartRef.current = false
onFatalError?.()
},
onSilence: () => void handleTurn()
})
setStatus('listening')
turnTimeoutRef.current = window.setTimeout(
() => void handleTurn(),
CONVERSATION_MAX_TURN_SECONDS * 1000
)
} catch (error) {
notifyError(error, 'Could not start voice session')
pendingStartRef.current = false
setStatus('idle')
onFatalError?.()
}
}, [handle, handleTurn, onFatalError])
const speak = useCallback(
async (text: string) => {
stopAudio()
setStatus('speaking')
try {
const response = await speakText(text)
const audio = new Audio(response.data_url)
audioRef.current = audio
await new Promise<void>((resolve, reject) => {
audio.addEventListener('ended', () => resolve(), { once: true })
audio.addEventListener('error', () => reject(new Error('Playback failed')), { once: true })
void audio.play().catch(reject)
})
} catch (error) {
notifyError(error, 'Voice playback failed')
} finally {
audioRef.current = null
if (enabledRef.current) {
pendingStartRef.current = true
setStatus('idle')
} else {
setStatus('idle')
}
}
},
[stopAudio]
)
const start = useCallback(async () => {
if (!onTranscribeAudio) {
notify({
kind: 'warning',
title: 'Voice unavailable',
message: 'Configure speech-to-text to use voice mode.'
})
onFatalError?.()
return
}
setMuted(false)
lastSpokenRef.current = null
pendingStartRef.current = true
}, [onFatalError, onTranscribeAudio])
const end = useCallback(async () => {
pendingStartRef.current = false
clearTurnTimeout()
stopAudio()
handle.cancel()
lastSpokenRef.current = null
consumePendingResponse()
setMuted(false)
setStatus('idle')
}, [consumePendingResponse, handle, stopAudio])
const toggleMute = useCallback(() => {
setMuted(value => {
const next = !value
if (next) {
clearTurnTimeout()
handle.cancel()
setStatus('idle')
} else if (enabledRef.current && !busyRef.current && statusRef.current === 'idle') {
pendingStartRef.current = true
}
return next
})
}, [handle])
// Drive the loop: speak any new assistant response, otherwise start listening
// when the agent is idle and we're between turns.
useEffect(() => {
if (!enabled || muted) {
return
}
const text = pendingResponseText()
const trimmed = text?.trim() ?? ''
if (trimmed && trimmed !== lastSpokenRef.current && status !== 'speaking') {
lastSpokenRef.current = trimmed
consumePendingResponse()
void speak(trimmed)
return
}
if (busy || status !== 'idle') {
return
}
if (pendingStartRef.current) {
void startListening()
}
}, [busy, consumePendingResponse, enabled, muted, pendingResponseText, speak, startListening, status])
useEffect(() => {
if (enabled && !wasEnabledRef.current) {
void start()
}
if (!enabled && wasEnabledRef.current) {
void end()
}
wasEnabledRef.current = enabled
}, [enabled, end, start])
return { end, level, muted, start, status, toggleMute }
}
@@ -0,0 +1,116 @@
import { useEffect, useRef, useState } from 'react'
import { notify, notifyError } from '@/store/notifications'
import type { VoiceActivityState, VoiceStatus } from '../types'
import { useMicRecorder } from './use-mic-recorder'
interface VoiceRecorderOptions {
maxRecordingSeconds: number
onTranscribeAudio?: (audio: Blob) => Promise<string>
focusInput: () => void
onTranscript: (text: string) => void
}
export function useVoiceRecorder({
maxRecordingSeconds,
onTranscribeAudio,
focusInput,
onTranscript
}: VoiceRecorderOptions) {
const { handle, level, recording } = useMicRecorder()
const [voiceStatus, setVoiceStatus] = useState<VoiceStatus>('idle')
const [elapsedSeconds, setElapsedSeconds] = useState(0)
const startedAtRef = useRef(0)
const intervalRef = useRef<number | null>(null)
const timeoutRef = useRef<number | null>(null)
const clearTimers = () => {
if (intervalRef.current) {
window.clearInterval(intervalRef.current)
intervalRef.current = null
}
if (timeoutRef.current) {
window.clearTimeout(timeoutRef.current)
timeoutRef.current = null
}
}
useEffect(() => () => clearTimers(), [])
const stop = async () => {
clearTimers()
const result = await handle.stop()
if (!result) {
setVoiceStatus('idle')
return
}
if (!onTranscribeAudio) {
setVoiceStatus('idle')
return
}
setVoiceStatus('transcribing')
try {
const transcript = (await onTranscribeAudio(result.audio)).trim()
if (!transcript) {
notify({ kind: 'warning', title: 'No speech detected', message: 'Try recording again.' })
} else {
onTranscript(transcript)
}
} catch (error) {
notifyError(error, 'Voice transcription failed')
} finally {
setVoiceStatus('idle')
focusInput()
}
}
const start = async () => {
if (!onTranscribeAudio) {
notify({ kind: 'warning', title: 'Voice unavailable', message: 'Voice transcription is not available yet.' })
return
}
try {
await handle.start({ onError: error => notifyError(error, 'Voice recording failed') })
startedAtRef.current = Date.now()
setElapsedSeconds(0)
setVoiceStatus('recording')
intervalRef.current = window.setInterval(
() => setElapsedSeconds((Date.now() - startedAtRef.current) / 1000),
250
)
const cap = Math.max(1, Math.min(Math.trunc(maxRecordingSeconds), 600))
timeoutRef.current = window.setTimeout(() => void stop(), cap * 1000)
} catch (error) {
setVoiceStatus('idle')
notifyError(error, 'Voice recording failed')
}
}
const dictate = () => {
if (recording) {
void stop()
} else if (voiceStatus === 'idle') {
void start()
}
}
const voiceActivityState: VoiceActivityState = {
elapsedSeconds,
level,
status: voiceStatus
}
return { dictate, voiceActivityState, voiceStatus }
}
@@ -0,0 +1,452 @@
import { ComposerPrimitive, unstable_useMentionAdapter, useAui, useAuiState } from '@assistant-ui/react'
import { useStore } from '@nanostores/react'
import LiquidGlass from 'liquid-glass-react'
import { FileText } from 'lucide-react'
import { type ClipboardEvent, type CSSProperties, useEffect, useMemo, useRef, useState } from 'react'
import { hermesDirectiveFormatter } from '@/components/assistant-ui/directive-text'
import { chatMessageText } from '@/lib/chat-messages'
import { triggerHaptic } from '@/lib/haptics'
import { cn } from '@/lib/utils'
import { $composerAttachments } from '@/store/composer'
import { $messages } from '@/store/session'
import { $threadScrolledUp } from '@/store/thread-scroll'
import { AttachmentList } from './attachments'
import {
ASK_PLACEHOLDERS,
COMPOSER_BACKDROP_STYLE,
DEFAULT_MAX_RECORDING_SECONDS,
DIRECTIVE_ICONS,
EDGE_NEWLINES_RE,
EXPAND_HEIGHT_PX,
NARROW_VIEWPORT,
SHELL,
STACK_AT
} from './constants'
import { ContextMenu } from './context-menu'
import { ComposerControls } from './controls'
import { buildMentionCategories, DirectivePopover } from './directive-popover'
import { useComposerGlassTweaks } from './hooks/use-composer-glass-tweaks'
import { useVoiceConversation } from './hooks/use-voice-conversation'
import { useVoiceRecorder } from './hooks/use-voice-recorder'
import type { ChatBarProps } from './types'
import { UrlDialog } from './url-dialog'
import { VoiceActivity } from './voice-activity'
function trimPastedEdgeNewlines(text: string): string {
return text.replace(EDGE_NEWLINES_RE, '')
}
export function ChatBar({
busy,
disabled,
focusKey,
maxRecordingSeconds = DEFAULT_MAX_RECORDING_SECONDS,
state,
onCancel,
onAddContextRef,
onAddUrl,
onPasteClipboardImage,
onPickFiles,
onPickFolders,
onPickImages,
onRemoveAttachment,
onSubmit,
onTranscribeAudio
}: 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 glassShellRef = useRef<HTMLDivElement | null>(null)
const draftRef = useRef(draft)
const textareaRef = useRef<HTMLTextAreaElement | null>(null)
const urlInputRef = useRef<HTMLInputElement | null>(null)
const [urlOpen, setUrlOpen] = useState(false)
const [urlValue, setUrlValue] = useState('')
const [expanded, setExpanded] = useState(false)
const [voiceConversationActive, setVoiceConversationActive] = useState(false)
const [stack, setStack] = useState(false)
const lastSpokenIdRef = useRef<string | null>(null)
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 hasComposerPayload = draft.trim().length > 0 || attachments.length > 0
const canSubmit = busy || hasComposerPayload
const glassTweaks = useComposerGlassTweaks()
const focusInput = () => window.requestAnimationFrame(() => textareaRef.current?.focus())
useEffect(() => {
if (!disabled) {
focusInput()
}
}, [disabled, focusKey])
useEffect(() => {
draftRef.current = draft
}, [draft])
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 currentDraft = draftRef.current
const sep = currentDraft && !currentDraft.endsWith('\n') ? '\n' : ''
const nextDraft = `${currentDraft}${sep}${text}`
draftRef.current = nextDraft
aui.composer().setText(nextDraft)
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) {
triggerHaptic('cancel')
onCancel()
} else if (draft.trim() || attachments.length > 0) {
triggerHaptic('submit')
onSubmit(draft)
aui.composer().setText('')
}
focusInput()
}
const submitUrl = () => {
const url = urlValue.trim()
if (!url) {
return
}
if (onAddUrl) {
onAddUrl(url)
} else {
insertText(`@url:${url}`)
}
triggerHaptic('success')
setUrlValue('')
setUrlOpen(false)
}
const { dictate, voiceActivityState, voiceStatus } = useVoiceRecorder({
focusInput,
maxRecordingSeconds,
onTranscript: insertText,
onTranscribeAudio
})
const pendingResponseText = () => {
const messages = $messages.get()
const last = messages.findLast(m => m.role === 'assistant' && !m.pending && !m.hidden)
if (!last || last.id === lastSpokenIdRef.current) {
return null
}
const text = chatMessageText(last).trim()
if (!text) {
return null
}
lastSpokenIdRef.current = last.id
return text
}
const consumePendingResponse = () => {
const messages = $messages.get()
const last = messages.findLast(m => m.role === 'assistant' && !m.hidden)
if (last) {
lastSpokenIdRef.current = last.id
}
}
const submitVoiceTurn = (text: string) => {
if (busy) {
return
}
triggerHaptic('submit')
onSubmit(text)
aui.composer().setText('')
draftRef.current = ''
}
const conversation = useVoiceConversation({
busy,
consumePendingResponse,
enabled: voiceConversationActive,
onFatalError: () => setVoiceConversationActive(false),
onSubmit: submitVoiceTurn,
onTranscribeAudio,
pendingResponseText
})
const contextMenu = (
<ContextMenu
onAddContextRef={onAddContextRef}
onInsertText={insertText}
onOpenUrlDialog={() => {
triggerHaptic('open')
setUrlOpen(true)
}}
onPasteClipboardImage={onPasteClipboardImage}
onPickFiles={onPickFiles}
onPickFolders={onPickFolders}
onPickImages={onPickImages}
state={state}
/>
)
const controls = (
<ComposerControls
busy={busy}
canSubmit={canSubmit}
conversation={{
active: voiceConversationActive,
level: conversation.level,
muted: conversation.muted,
onEnd: () => {
setVoiceConversationActive(false)
void conversation.end()
},
onStart: () => setVoiceConversationActive(true),
onToggleMute: conversation.toggleMute,
status: conversation.status
}}
disabled={disabled}
hasComposerPayload={hasComposerPayload}
onDictate={dictate}
state={state}
voiceStatus={voiceStatus}
/>
)
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-8 pt-2')}
onSubmit={e => {
e.preventDefault()
submitDraft()
}}
ref={composerRef}
>
<div
className="pointer-events-none absolute inset-x-0 bottom-0 top-0"
style={{ background: glassTweaks.fadeBackground }}
/>
<div className="relative w-full">
<div
className={cn(
'composer-liquid-shell-wrap absolute inset-0 transition-opacity duration-200 ease-out',
scrolledUp
? 'opacity-70 group-hover/composer:opacity-100 group-focus-within/composer:opacity-100'
: 'opacity-100'
)}
data-glass-frame="true"
data-show-library-rims={glassTweaks.showLibraryRims ? 'true' : undefined}
ref={glassShellRef}
style={
{
'--composer-glass-radius': `${glassTweaks.liquid.cornerRadius}px`
} as CSSProperties
}
>
<LiquidGlass
aberrationIntensity={glassTweaks.liquid.aberrationIntensity}
blurAmount={glassTweaks.liquid.blurAmount}
className="composer-liquid-shell pointer-events-none absolute inset-0 h-full w-full"
cornerRadius={glassTweaks.liquid.cornerRadius}
displacementScale={glassTweaks.liquid.displacementScale}
elasticity={glassTweaks.liquid.elasticity}
key={glassTweaks.liquidKey}
mode={glassTweaks.liquid.mode}
mouseContainer={composerRef}
padding="0"
saturation={glassTweaks.liquid.saturation}
style={{ position: 'absolute', top: '0', left: '0', width: '100%', height: '100%' }}
>
<span className="block h-full w-full" />
</LiquidGlass>
</div>
<div
className={cn(
'relative z-4 flex w-full flex-col gap-1.5 overflow-hidden border border-input/70 bg-card/72 px-2 py-1.5 shadow-composer transition-[border-color,box-shadow,opacity] duration-200 ease-out group-focus-within/composer:border-ring/35 group-focus-within/composer:shadow-composer-focus',
scrolledUp
? 'opacity-60 group-hover/composer:opacity-100 group-focus-within/composer:opacity-100'
: 'opacity-100'
)}
style={{ ...COMPOSER_BACKDROP_STYLE, borderRadius: `${glassTweaks.liquid.cornerRadius}px` }}
>
<VoiceActivity state={voiceActivityState} />
{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-8 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>
)
}
@@ -0,0 +1,49 @@
export interface ContextSuggestion {
text: string
display: string
meta?: string
}
export interface QuickModelOption {
provider: string
providerName: string
model: string
}
export interface ChatBarState {
model: {
model: string
provider: string
canSwitch: boolean
loading?: boolean
quickModels?: QuickModelOption[]
}
tools: { enabled: boolean; label: string; suggestions?: ContextSuggestion[] }
voice: { enabled: boolean; active: boolean }
}
export interface ChatBarProps {
busy: boolean
disabled: boolean
focusKey?: string | null
maxRecordingSeconds?: number
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
onTranscribeAudio?: (audio: Blob) => Promise<string>
}
export type VoiceStatus = 'idle' | 'recording' | 'transcribing'
export interface VoiceActivityState {
elapsedSeconds: number
level: number
status: VoiceStatus
}
@@ -0,0 +1,56 @@
import type * as React from 'react'
import { Button } from '@/components/ui/button'
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog'
import { Input } from '@/components/ui/input'
export 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>
)
}
@@ -0,0 +1,77 @@
import { Loader2, Mic } from 'lucide-react'
import { cn } from '@/lib/utils'
import type { VoiceActivityState } from './types'
function formatElapsed(seconds: number) {
const safeSeconds = Math.max(0, Math.floor(seconds))
const minutes = Math.floor(safeSeconds / 60)
const remainingSeconds = safeSeconds % 60
return `${minutes}:${remainingSeconds.toString().padStart(2, '0')}`
}
function VoiceLevelBars({ level, active }: { active: boolean; level: number }) {
const normalized = Math.max(0, Math.min(level, 1))
const bars = [0.5, 0.78, 1, 0.78, 0.5]
return (
<div aria-hidden="true" className="flex h-4 items-center gap-0.5">
{bars.map((weight, index) => {
const height = active ? 0.25 + Math.min(0.68, normalized * weight) : 0.25
return (
<span
className={cn(
'w-0.5 rounded-full bg-current transition-[height,opacity] duration-100 ease-out',
active ? 'opacity-80' : 'animate-pulse opacity-45'
)}
key={index}
style={{ height: `${height * 100}%` }}
/>
)
})}
</div>
)
}
export function VoiceActivity({
state
}: {
state: VoiceActivityState
}) {
if (state.status === 'idle') {
return null
}
const recording = state.status === 'recording'
const title = recording ? 'Dictating' : 'Transcribing'
return (
<div
aria-live="polite"
className={cn(
'flex h-8 items-center gap-2 rounded-xl border border-border/55 bg-muted/55 px-2.5 text-xs text-muted-foreground',
'shadow-[inset_0_1px_0_rgba(255,255,255,0.35)] backdrop-blur-sm'
)}
role="status"
>
<div
className={cn(
'flex size-5 shrink-0 items-center justify-center rounded-full',
recording ? 'bg-primary/15 text-primary' : 'bg-primary/10 text-primary'
)}
>
{recording ? <Mic size={12} /> : <Loader2 className="animate-spin" size={12} />}
</div>
<div className="flex min-w-0 flex-1 items-center gap-2">
<span className="truncate font-medium text-foreground/85">{title}</span>
<span className="font-mono text-[0.6875rem] text-muted-foreground/85">{formatElapsed(state.elapsedSeconds)}</span>
</div>
<VoiceLevelBars active={recording} level={state.level} />
</div>
)
}
@@ -1,14 +1,14 @@
import { useCallback } from 'react'
import { contextPath, attachmentId, pathLabel } from '@/lib/chat-runtime'
import { attachmentId, contextPath, pathLabel } from '@/lib/chat-runtime'
import {
addComposerAttachment,
removeComposerAttachment,
type ComposerAttachment
type ComposerAttachment,
removeComposerAttachment
} from '@/store/composer'
import { notify, notifyError } from '@/store/notifications'
import type { ImageAttachResponse, ImageDetachResponse } from '../types'
import type { ImageAttachResponse, ImageDetachResponse } from '../../types'
interface ComposerActionsOptions {
activeSessionId: string | null
+45 -8
View File
@@ -1,17 +1,22 @@
import { AssistantRuntimeProvider, ExportedMessageRepository, type ThreadMessage, useExternalStoreRuntime } from '@assistant-ui/react'
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 { useLocation } from 'react-router-dom'
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 { cn } from '@/lib/utils'
import { $pinnedSessionIds } from '@/store/layout'
import {
$activeSessionId,
@@ -28,10 +33,13 @@ import {
$selectedStoredSessionId,
$sessions
} from '@/store/session'
import type { ModelOptionsResponse } from '@/types/hermes'
import { routeSessionId } from '../routes'
import { titlebarHeaderClass } from '../shell/titlebar'
import { titlebarHeaderBaseClass, titlebarHeaderShadowClass } from '../shell/titlebar'
import { ChatBar, ChatBarFallback } from './composer'
import type { ChatBarState } from './composer/types'
import { ChatRightRail } from './right-rail'
import { SessionActionsMenu } from './sidebar/session-actions-menu'
@@ -42,6 +50,8 @@ interface ChatViewProps extends Omit<React.ComponentProps<'div'>, 'onSubmit'> {
onCancel: () => void
onAddContextRef: (refText: string, label?: string, detail?: string) => void
onAddUrl: (url: string) => void
onBranchInNewChat: (messageId: string) => void
maxVoiceRecordingSeconds?: number
onPasteClipboardImage: () => void
onPickFiles: () => void
onPickFolders: () => void
@@ -54,6 +64,19 @@ interface ChatViewProps extends Omit<React.ComponentProps<'div'>, 'onSubmit'> {
onSelectPersonality: (name: string) => void
onThreadMessagesChange: (messages: readonly ThreadMessage[]) => void
onReload: (parentId: string | null) => Promise<void>
onTranscribeAudio?: (audio: Blob) => Promise<string>
}
function threadLoadingState(loadingSession: boolean, busy: boolean, awaitingResponse: boolean) {
if (loadingSession) {
return 'session'
}
if (!busy) {
return undefined
}
return awaitingResponse ? 'response' : 'working'
}
export function ChatView({
@@ -63,6 +86,8 @@ export function ChatView({
onCancel,
onAddContextRef,
onAddUrl,
onBranchInNewChat,
maxVoiceRecordingSeconds,
onPasteClipboardImage,
onPickFiles,
onPickFolders,
@@ -74,8 +99,10 @@ export function ChatView({
onOpenModelPicker,
onSelectPersonality,
onThreadMessagesChange,
onReload
onReload,
onTranscribeAudio
}: ChatViewProps) {
const location = useLocation()
const activeSessionId = useStore($activeSessionId)
const awaitingResponse = useStore($awaitingResponse)
const busy = useStore($busy)
@@ -92,14 +119,17 @@ export function ChatView({
const selectedSessionId = useStore($selectedStoredSessionId)
const sessions = useStore($sessions)
const activeStoredSession = sessions.find(session => session.id === selectedSessionId) || null
const isRoutedSessionView = Boolean(routeSessionId())
const isRoutedSessionView = Boolean(routeSessionId(location.pathname))
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 threadLoading = threadLoadingState(loadingSession, busy, awaitingResponse)
const showChatBar = !loadingSession
const title = activeStoredSession ? sessionTitle(activeStoredSession) : ''
const modelOptionsQuery = useQuery<ModelOptionsResponse>({
queryKey: ['model-options', activeSessionId || 'global'],
queryFn: () => {
@@ -115,10 +145,12 @@ export function ChatView({
},
enabled: gatewayOpen
})
const quickModels = useMemo(
() => quickModelOptions(modelOptionsQuery.data, currentProvider, currentModel),
[currentModel, currentProvider, modelOptionsQuery.data]
)
const chatBarState = useMemo<ChatBarState>(
() => ({
model: {
@@ -140,6 +172,7 @@ export function ChatView({
}),
[contextSuggestions, currentModel, currentProvider, gatewayOpen, quickModels]
)
const runtimeMessageRepository = useMemo(() => {
const items: { message: ThreadMessage; parentId: string | null }[] = []
const branchParentByGroup = new Map<string, string | null>()
@@ -167,6 +200,7 @@ export function ChatView({
return ExportedMessageRepository.fromBranchableArray(items, { headId })
}, [messages])
const runtime = useExternalStoreRuntime<ThreadMessage>({
messageRepository: runtimeMessageRepository,
isRunning: busy,
@@ -182,7 +216,7 @@ export function ChatView({
return (
<>
<div className="flex h-[calc(100vh-0.375rem)] min-w-0 flex-col overflow-hidden rounded-[0.9375rem] bg-transparent">
<header className={titlebarHeaderClass}>
<header className={cn(titlebarHeaderBaseClass, isRoutedSessionView && titlebarHeaderShadowClass)}>
<div className="min-w-0 flex-1">
{title && (
<SessionActionsMenu
@@ -213,6 +247,7 @@ export function ChatView({
<Thread
intro={showIntro ? { personality: introPersonality, seed: introSeed } : undefined}
loading={threadLoading}
onBranchInNewChat={onBranchInNewChat}
/>
{showChatBar && (
<Suspense fallback={<ChatBarFallback />}>
@@ -220,6 +255,7 @@ export function ChatView({
busy={busy}
disabled={!gatewayOpen}
focusKey={activeSessionId}
maxRecordingSeconds={maxVoiceRecordingSeconds}
onAddContextRef={onAddContextRef}
onAddUrl={onAddUrl}
onCancel={onCancel}
@@ -229,6 +265,7 @@ export function ChatView({
onPickImages={onPickImages}
onRemoveAttachment={onRemoveAttachment}
onSubmit={onSubmit}
onTranscribeAudio={onTranscribeAudio}
state={chatBarState}
/>
</Suspense>
+13 -8
View File
@@ -1,5 +1,6 @@
import { useStore } from '@nanostores/react'
import { ChevronDown, Layers3, Pin, Plus, RefreshCw, Sparkles } from 'lucide-react'
import { useMemo } from 'react'
import type * as React from 'react'
import { Button } from '@/components/ui/button'
@@ -27,7 +28,7 @@ import {
setSidebarRecentsOpen,
unpinSession
} from '@/store/layout'
import { $selectedStoredSessionId, $sessions, $sessionsLoading } from '@/store/session'
import { $selectedStoredSessionId, $sessions, $sessionsLoading, $workingSessionIds } from '@/store/session'
import { type AppView, ARTIFACTS_ROUTE, SKILLS_ROUTE } from '../../routes'
import type { SidebarNavItem } from '../../types'
@@ -71,15 +72,17 @@ export function ChatSidebar({
const selectedSessionId = useStore($selectedStoredSessionId)
const sessions = useStore($sessions)
const sessionsLoading = useStore($sessionsLoading)
const workingSessionIds = useStore($workingSessionIds)
const sortedSessions = [...sessions].sort((a, b) => {
const sortedSessions = useMemo(() => [...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
})
}), [sessions])
const sessionsById = new Map(sessions.map(session => [session.id, session]))
const sessionsById = useMemo(() => new Map(sessions.map(session => [session.id, session])), [sessions])
const workingSessionIdSet = useMemo(() => new Set(workingSessionIds), [workingSessionIds])
const visiblePinnedIds = pinnedSessionIds.filter(id => sessionsById.has(id))
const visiblePinnedIdSet = new Set(visiblePinnedIds)
@@ -94,13 +97,13 @@ export function ChatSidebar({
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)]',
'relative h-screen min-w-0 overflow-hidden border-r border-t-0 border-b-0 border-l-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)]',
: 'transition-[opacity,transform,border-color,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'
? 'translate-x-0 border-(--sidebar-edge-border) bg-[color-mix(in_srgb,var(--dt-sidebar-bg)_97%,transparent)] opacity-100'
: 'pointer-events-none -translate-x-2 border-transparent bg-transparent opacity-0'
)}
collapsible="none"
>
@@ -153,6 +156,7 @@ export function ChatSidebar({
<SidebarSessionRow
isPinned
isSelected={session.id === selectedSessionId}
isWorking={workingSessionIdSet.has(session.id)}
key={session.id}
onDelete={() => onDeleteSession(session.id)}
onPin={() => unpinSession(session.id)}
@@ -200,6 +204,7 @@ export function ChatSidebar({
<SidebarSessionRow
isPinned={false}
isSelected={session.id === selectedSessionId}
isWorking={workingSessionIdSet.has(session.id)}
key={session.id}
onDelete={() => onDeleteSession(session.id)}
onPin={() => pinSession(session.id)}
@@ -9,6 +9,7 @@ import {
DropdownMenuSeparator,
DropdownMenuTrigger
} from '@/components/ui/dropdown-menu'
import { triggerHaptic } from '@/lib/haptics'
import { cn } from '@/lib/utils'
interface SessionActionsMenuProps extends Pick<
@@ -37,7 +38,14 @@ export function SessionActionsMenu({
<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}>
<DropdownMenuItem
className={itemClass}
disabled={!onPin}
onSelect={() => {
triggerHaptic('selection')
onPin?.()
}}
>
<Pin />
<span>{pinned ? 'Unpin' : 'Pin'}</span>
</DropdownMenuItem>
@@ -53,7 +61,10 @@ export function SessionActionsMenu({
<DropdownMenuItem
className={cn(itemClass, 'text-destructive focus:text-destructive')}
disabled={!onDelete}
onSelect={onDelete}
onSelect={() => {
triggerHaptic('warning')
onDelete?.()
}}
variant="destructive"
>
<Trash2 />
@@ -4,6 +4,7 @@ import type * as React from 'react'
import { Button } from '@/components/ui/button'
import type { SessionInfo } from '@/hermes'
import { sessionTitle } from '@/lib/chat-runtime'
import { triggerHaptic } from '@/lib/haptics'
import { cn } from '@/lib/utils'
import { SessionActionsMenu } from './session-actions-menu'
@@ -18,6 +19,7 @@ interface SidebarSessionRowProps extends React.ComponentProps<'div'> {
session: SessionInfo
isPinned: boolean
isSelected: boolean
isWorking: boolean
onDelete: () => void
onPin: () => void
onResume: () => void
@@ -27,6 +29,7 @@ export function SidebarSessionRow({
session,
isPinned,
isSelected,
isWorking,
onDelete,
onPin,
onResume
@@ -34,13 +37,22 @@ export function SidebarSessionRow({
const title = sessionTitle(session)
return (
<div className={cn(sidebarSessionRowClass, sidebarSessionFadeClass, isSelected && 'bg-accent')}>
<div
className={cn(
sidebarSessionRowClass,
sidebarSessionFadeClass,
isSelected && 'bg-accent',
isWorking && 'text-foreground'
)}
data-working={isWorking ? 'true' : undefined}
>
<button
className="z-0 flex min-w-0 items-center bg-transparent py-1 pl-2 text-left"
className="z-0 flex min-w-0 items-center gap-1.5 bg-transparent py-1 pl-2 text-left"
onClick={event => {
if (event.shiftKey) {
event.preventDefault()
event.stopPropagation()
triggerHaptic('selection')
onPin()
return
@@ -50,6 +62,13 @@ export function SidebarSessionRow({
}}
type="button"
>
{isWorking && (
<span
aria-label="Session running"
className="relative size-1.5 shrink-0 rounded-full bg-primary shadow-[0_0_0.625rem_color-mix(in_srgb,var(--primary)_65%,transparent)] before:absolute before:inset-0 before:rounded-full before:bg-primary before:opacity-75 before:content-[''] before:animate-ping"
role="status"
/>
)}
<span className="truncate text-sm font-medium text-foreground/90">{title}</span>
</button>
<div className="relative z-2 grid w-6 place-items-center">
+627
View File
@@ -0,0 +1,627 @@
import { useStore } from '@nanostores/react'
import { useQueryClient } from '@tanstack/react-query'
import { type ReactNode, useCallback, useEffect, useRef, useState } from 'react'
import { Navigate, Route, Routes, useLocation, useNavigate, useParams } from 'react-router-dom'
import type { ModelOptionsResponse, SessionRuntimeInfo } from '@/types/hermes'
import {
getGlobalModelInfo,
getHermesConfig,
getHermesConfigDefaults,
getSessionMessages,
type HermesGateway,
listSessions,
setGlobalModel
} from '../hermes'
import { toChatMessages } from '../lib/chat-messages'
import { BUILTIN_PERSONALITIES, normalizePersonalityValue, personalityNamesFromConfig } from '../lib/chat-runtime'
import { $pinnedSessionIds, pinSession, unpinSession } from '../store/layout'
import { notify, notifyError } from '../store/notifications'
import {
$activeSessionId,
$currentCwd,
$freshDraftReady,
$gatewayState,
$selectedStoredSessionId,
setAvailablePersonalities,
setAwaitingResponse,
setBusy,
setContextSuggestions,
setCurrentBranch,
setCurrentCwd,
setCurrentModel,
setCurrentPersonality,
setCurrentProvider,
setIntroPersonality,
setMessages,
setModelPickerOpen,
setSessions,
setSessionsLoading
} from '../store/session'
import { ArtifactsView } from './artifacts'
import { ChatView, SESSION_INSPECTOR_WIDTH } from './chat'
import { useComposerActions } from './chat/hooks/use-composer-actions'
import { ChatSidebar } from './chat/sidebar'
import { useGatewayBoot } from './gateway/hooks/use-gateway-boot'
import { useGatewayRequest } from './gateway/hooks/use-gateway-request'
import { ModelPickerOverlay } from './model-picker-overlay'
import {
appViewForPath,
isNewChatRoute,
NEW_CHAT_ROUTE,
routeSessionId,
sessionRoute
} from './routes'
import { useMessageStream } from './session/hooks/use-message-stream'
import { usePromptActions } from './session/hooks/use-prompt-actions'
import { useSessionActions } from './session/hooks/use-session-actions'
import { useSessionStateCache } from './session/hooks/use-session-state-cache'
import { SettingsView } from './settings'
import { AppShell } from './shell/app-shell'
import { SkillsView } from './skills'
import type { ContextSuggestion } from './types'
const DEFAULT_VOICE_RECORDING_SECONDS = 120
function normalizeRecordingLimit(value: unknown): number {
return typeof value === 'number' && Number.isFinite(value) && value > 0 ? value : DEFAULT_VOICE_RECORDING_SECONDS
}
export function DesktopController() {
const queryClient = useQueryClient()
const location = useLocation()
const navigate = useNavigate()
const busyRef = useRef(false)
const gatewayState = useStore($gatewayState)
const activeSessionId = useStore($activeSessionId)
const selectedStoredSessionId = useStore($selectedStoredSessionId)
const currentCwd = useStore($currentCwd)
const freshDraftReady = useStore($freshDraftReady)
const routedSessionId = routeSessionId(location.pathname)
const currentView = appViewForPath(location.pathname)
const settingsOpen = currentView === 'settings'
const chatOpen = currentView === 'chat'
const settingsReturnPathRef = useRef(NEW_CHAT_ROUTE)
const [titlebarActions, setTitlebarActions] = useState<ReactNode>(null)
const [voiceMaxRecordingSeconds, setVoiceMaxRecordingSeconds] = useState(DEFAULT_VOICE_RECORDING_SECONDS)
const [sttEnabled, setSttEnabled] = useState(true)
const {
activeSessionIdRef,
ensureSessionState,
runtimeIdByStoredSessionIdRef,
selectedStoredSessionIdRef,
sessionStateByRuntimeIdRef,
syncSessionStateToView,
updateSessionState
} = useSessionStateCache({
activeSessionId,
busyRef,
selectedStoredSessionId,
setAwaitingResponse,
setBusy,
setMessages
})
const toggleSelectedPin = useCallback(() => {
const sessionId = $selectedStoredSessionId.get()
if (!sessionId) {
return
}
if ($pinnedSessionIds.get().includes(sessionId)) {
unpinSession(sessionId)
} else {
pinSession(sessionId)
}
}, [])
const refreshSessions = useCallback(async () => {
setSessionsLoading(true)
try {
const result = await listSessions(50)
setSessions(result.sessions)
} finally {
setSessionsLoading(false)
}
}, [])
const { connectionRef, gatewayRef, requestGateway } = useGatewayRequest()
const setBootGateway = useCallback(
(gateway: HermesGateway | null) => {
gatewayRef.current = gateway
},
[gatewayRef]
)
const setBootConnection = useCallback(
(connection: Awaited<ReturnType<NonNullable<typeof window.hermesDesktop>['getConnection']>> | null) => {
connectionRef.current = connection
},
[connectionRef]
)
const updateModelOptionsCache = useCallback(
(provider: string, model: string, includeGlobal: boolean) => {
const patch = (prev: ModelOptionsResponse | undefined) => ({
...(prev ?? {}),
provider,
model
})
queryClient.setQueryData<ModelOptionsResponse>(['model-options', activeSessionId || 'global'], patch)
if (includeGlobal) {
queryClient.setQueryData<ModelOptionsResponse>(['model-options', 'global'], patch)
}
},
[activeSessionId, queryClient]
)
const refreshContextSuggestions = useCallback(async () => {
if (!activeSessionId) {
setContextSuggestions([])
return
}
try {
const result = await requestGateway<{ items?: ContextSuggestion[] }>('complete.path', {
session_id: activeSessionId,
word: '@file:',
cwd: currentCwd || undefined
})
setContextSuggestions((result.items || []).filter(item => item.text))
} catch {
setContextSuggestions([])
}
}, [activeSessionId, currentCwd, requestGateway])
const refreshCurrentModel = useCallback(async () => {
try {
const result = await getGlobalModelInfo()
if (typeof result.model === 'string') {
setCurrentModel(result.model)
}
if (typeof result.provider === 'string') {
setCurrentProvider(result.provider)
}
} catch {
// The delayed session.info event can still update this once the agent is ready.
}
}, [])
const changeSessionCwd = useCallback(
async (cwd: string) => {
const trimmed = cwd.trim()
if (!trimmed) {
return
}
const persistGlobal = async () => {
await requestGateway('config.set', {
...(activeSessionId && { session_id: activeSessionId }),
key: 'terminal.cwd',
value: trimmed
})
setCurrentCwd(trimmed)
if (!activeSessionId) {
setCurrentBranch('')
}
}
if (!activeSessionId) {
try {
await persistGlobal()
} catch (err) {
notifyError(err, 'Working directory change failed')
}
return
}
try {
const info = await requestGateway<SessionRuntimeInfo>('session.cwd.set', {
session_id: activeSessionId,
cwd: trimmed
})
setCurrentCwd(info.cwd || trimmed)
setCurrentBranch(info.branch || '')
} catch (err) {
const message = err instanceof Error ? err.message : String(err)
if (!message.includes('unknown method')) {
notifyError(err, 'Working directory change failed')
return
}
try {
await persistGlobal()
notify({
kind: 'warning',
title: 'Working directory saved',
message: 'Restart the desktop backend to apply cwd changes to this active session.'
})
} catch (fallbackErr) {
notifyError(fallbackErr, 'Working directory change failed')
}
}
},
[activeSessionId, requestGateway]
)
const browseSessionCwd = useCallback(async () => {
const paths = await window.hermesDesktop?.selectPaths({
title: 'Change working directory',
defaultPath: currentCwd || undefined,
directories: true,
multiple: false
})
if (paths?.[0]) {
await changeSessionCwd(paths[0])
}
}, [changeSessionCwd, currentCwd])
const selectModel = useCallback(
(selection: { provider: string; model: string; persistGlobal: boolean }) => {
setCurrentModel(selection.model)
setCurrentProvider(selection.provider)
updateModelOptionsCache(selection.provider, selection.model, selection.persistGlobal || !activeSessionId)
void (async () => {
try {
if (activeSessionId) {
await requestGateway('slash.exec', {
session_id: activeSessionId,
command: `/model ${selection.model} --provider ${selection.provider}${
selection.persistGlobal ? ' --global' : ''
}`
})
if (selection.persistGlobal) {
void refreshCurrentModel()
}
void queryClient.invalidateQueries({
queryKey: selection.persistGlobal ? ['model-options'] : ['model-options', activeSessionId]
})
return
}
await setGlobalModel(selection.provider, selection.model)
void refreshCurrentModel()
void queryClient.invalidateQueries({ queryKey: ['model-options'] })
} catch (err) {
notifyError(err, 'Model switch failed')
}
})()
},
[activeSessionId, queryClient, refreshCurrentModel, requestGateway, updateModelOptionsCache]
)
const refreshHermesConfig = useCallback(async () => {
try {
const [config, defaults] = await Promise.all([getHermesConfig(), getHermesConfigDefaults().catch(() => ({}))])
const configPersonality = normalizePersonalityValue(
typeof config.display?.personality === 'string' ? config.display.personality : ''
)
setIntroPersonality(configPersonality)
setCurrentPersonality(prev => (activeSessionIdRef.current ? prev || configPersonality : configPersonality))
setAvailablePersonalities([
...new Set([
'none',
...BUILTIN_PERSONALITIES,
...personalityNamesFromConfig(defaults),
...personalityNamesFromConfig(config)
])
])
const cwd = (config.terminal?.cwd ?? '').trim()
if (cwd && cwd !== '.') {
setCurrentCwd(prev => prev || cwd)
}
setVoiceMaxRecordingSeconds(normalizeRecordingLimit(config.voice?.max_recording_seconds))
setSttEnabled(config.stt?.enabled !== false)
} catch {
// Config is nice-to-have for the empty-state copy; the chat still works.
}
}, [activeSessionIdRef])
const selectPersonality = useCallback(
async (name: string) => {
const trimmed = (name || '').trim() || 'none'
const normalized = normalizePersonalityValue(trimmed)
setCurrentPersonality(normalized)
if (!activeSessionId) {
setIntroPersonality(normalized)
}
try {
await (activeSessionId
? requestGateway('slash.exec', {
session_id: activeSessionId,
command: `/personality ${trimmed}`
})
: requestGateway('config.set', {
key: 'personality',
value: trimmed
}))
if (!activeSessionId) {
void refreshHermesConfig()
}
} catch (err) {
void refreshHermesConfig()
notifyError(err, 'Personality change failed')
}
},
[activeSessionId, refreshHermesConfig, requestGateway]
)
const { addContextRefAttachment, pasteClipboardImage, pickContextPaths, pickImages, removeAttachment } =
useComposerActions({
activeSessionId,
currentCwd,
requestGateway
})
const hydrateFromStoredSession = useCallback(
async (
attempts = 1,
storedSessionId = selectedStoredSessionIdRef.current,
runtimeSessionId = activeSessionIdRef.current
) => {
if (!storedSessionId || !runtimeSessionId) {
return
}
for (let index = 0; index < Math.max(1, attempts); index += 1) {
try {
const latest = await getSessionMessages(storedSessionId)
updateSessionState(
runtimeSessionId,
state => ({
...state,
messages: toChatMessages(latest.messages)
}),
storedSessionId
)
return
} catch {
// Best-effort fallback when live stream payloads are empty.
}
if (index < attempts - 1) {
await new Promise(resolve => window.setTimeout(resolve, 250))
}
}
},
[activeSessionIdRef, selectedStoredSessionIdRef, updateSessionState]
)
const { handleGatewayEvent } = useMessageStream({
activeSessionIdRef,
hydrateFromStoredSession,
queryClient,
refreshHermesConfig,
refreshSessions,
updateSessionState
})
const {
branchCurrentSession,
createBackendSessionForSend,
openSettings,
removeSession,
resumeSession,
selectSidebarItem,
startFreshSessionDraft
} = useSessionActions({
activeSessionId,
activeSessionIdRef,
busyRef,
ensureSessionState,
navigate,
requestGateway,
runtimeIdByStoredSessionIdRef,
selectedStoredSessionId,
selectedStoredSessionIdRef,
sessionStateByRuntimeIdRef,
syncSessionStateToView,
updateSessionState
})
useEffect(() => {
if (currentView !== 'settings') {
settingsReturnPathRef.current = `${location.pathname}${location.search}${location.hash}`
}
}, [currentView, location.hash, location.pathname, location.search])
const closeSettingsToPreviousRoute = useCallback(() => {
navigate(settingsReturnPathRef.current || NEW_CHAT_ROUTE, { replace: true })
}, [navigate])
const branchInNewChat = useCallback(
async (messageId: string) => {
const branched = await branchCurrentSession(messageId)
if (branched) {
await refreshSessions().catch(() => undefined)
}
},
[branchCurrentSession, refreshSessions]
)
const { cancelRun, handleThreadMessagesChange, reloadFromMessage, submitText, transcribeVoiceAudio } =
usePromptActions({
activeSessionId,
activeSessionIdRef,
busyRef,
createBackendSessionForSend,
requestGateway,
selectedStoredSessionIdRef,
sttEnabled,
updateSessionState
})
useGatewayBoot({
handleGatewayEvent,
onConnectionReady: setBootConnection,
onGatewayReady: setBootGateway,
refreshHermesConfig,
refreshSessions
})
useEffect(() => {
if (gatewayState === 'open') {
void refreshCurrentModel()
void refreshSessions().catch(() => undefined)
}
}, [gatewayState, refreshCurrentModel, refreshSessions])
useEffect(() => {
if (gatewayState === 'open' && activeSessionId) {
void refreshContextSuggestions()
}
}, [activeSessionId, gatewayState, refreshContextSuggestions])
useEffect(() => {
if (currentView !== 'chat' || gatewayState !== 'open') {
return
}
if (routedSessionId) {
const cachedRuntimeId = runtimeIdByStoredSessionIdRef.current.get(routedSessionId)
const alreadyActive =
routedSessionId === selectedStoredSessionIdRef.current &&
Boolean(cachedRuntimeId) &&
cachedRuntimeId === activeSessionIdRef.current
if (!alreadyActive) {
void resumeSession(routedSessionId, true)
}
} else if (isNewChatRoute(location.pathname) && (selectedStoredSessionId || activeSessionId || !freshDraftReady)) {
startFreshSessionDraft(true)
}
}, [
activeSessionIdRef,
activeSessionId,
currentView,
freshDraftReady,
gatewayState,
location.pathname,
resumeSession,
routedSessionId,
runtimeIdByStoredSessionIdRef,
selectedStoredSessionId,
selectedStoredSessionIdRef,
startFreshSessionDraft
])
const sidebar = (
<ChatSidebar
currentView={currentView}
onDeleteSession={sessionId => void removeSession(sessionId)}
onNavigate={selectSidebarItem}
onRefreshSessions={() => void refreshSessions()}
onResumeSession={sessionId => navigate(sessionRoute(sessionId))}
/>
)
const overlays = (
<>
<ModelPickerOverlay gateway={gatewayRef.current || undefined} onSelect={selectModel} />
{settingsOpen && (
<SettingsView
onClose={closeSettingsToPreviousRoute}
onConfigSaved={() => {
void refreshHermesConfig()
void refreshCurrentModel()
void queryClient.invalidateQueries({ queryKey: ['model-options'] })
}}
/>
)}
</>
)
const chatView = (
<ChatView
gateway={gatewayRef.current}
maxVoiceRecordingSeconds={voiceMaxRecordingSeconds}
onAddContextRef={addContextRefAttachment}
onAddUrl={url => addContextRefAttachment(`@url:${url}`, url)}
onBranchInNewChat={messageId => void branchInNewChat(messageId)}
onBrowseCwd={() => void browseSessionCwd()}
onCancel={() => void cancelRun()}
onChangeCwd={cwd => void changeSessionCwd(cwd)}
onDeleteSelectedSession={() => {
if (selectedStoredSessionId) {
void removeSession(selectedStoredSessionId)
}
}}
onOpenModelPicker={() => setModelPickerOpen(true)}
onPasteClipboardImage={() => void pasteClipboardImage()}
onPickFiles={() => void pickContextPaths('file')}
onPickFolders={() => void pickContextPaths('folder')}
onPickImages={() => void pickImages()}
onReload={reloadFromMessage}
onRemoveAttachment={id => void removeAttachment(id)}
onSelectPersonality={name => void selectPersonality(name)}
onSubmit={text => void submitText(text)}
onThreadMessagesChange={handleThreadMessagesChange}
onToggleSelectedPin={toggleSelectedPin}
onTranscribeAudio={transcribeVoiceAudio}
/>
)
return (
<AppShell
inspectorWidth={SESSION_INSPECTOR_WIDTH}
onOpenSettings={openSettings}
overlays={overlays}
rightRailOpen={chatOpen}
settingsOpen={settingsOpen}
sidebar={sidebar}
titlebarActions={titlebarActions}
>
<Routes>
<Route element={chatView} index />
<Route element={chatView} path=":sessionId" />
<Route element={<SkillsView setTitlebarActions={setTitlebarActions} />} path="skills" />
<Route element={<ArtifactsView setTitlebarActions={setTitlebarActions} />} path="artifacts" />
<Route element={null} path="settings" />
<Route element={<Navigate replace to={NEW_CHAT_ROUTE} />} path="new" />
<Route element={<LegacySessionRedirect />} path="sessions/:sessionId" />
<Route element={<Navigate replace to={NEW_CHAT_ROUTE} />} path="*" />
</Routes>
</AppShell>
)
}
function LegacySessionRedirect() {
const { sessionId } = useParams()
return <Navigate replace to={sessionId ? sessionRoute(sessionId) : NEW_CHAT_ROUTE} />
}
@@ -0,0 +1,97 @@
import { useEffect } from 'react'
import { HermesGateway } from '@/hermes'
import { notify, notifyError } from '@/store/notifications'
import { setConnection, setGatewayState, setSessionsLoading } from '@/store/session'
import type { RpcEvent } from '@/types/hermes'
interface GatewayBootOptions {
handleGatewayEvent: (event: RpcEvent) => void
onConnectionReady: (connection: Awaited<ReturnType<NonNullable<typeof window.hermesDesktop>['getConnection']>> | null) => void
onGatewayReady: (gateway: HermesGateway | null) => void
refreshHermesConfig: () => Promise<void>
refreshSessions: () => Promise<void>
}
export function useGatewayBoot({
handleGatewayEvent,
onConnectionReady,
onGatewayReady,
refreshHermesConfig,
refreshSessions
}: GatewayBootOptions) {
useEffect(() => {
let cancelled = false
const desktop = window.hermesDesktop
if (!desktop) {
setSessionsLoading(false)
return () => void (cancelled = true)
}
const gateway = new HermesGateway()
onGatewayReady(gateway)
const offState = gateway.onState(st => void setGatewayState(st))
const offEvent = gateway.onEvent(handleGatewayEvent)
const offExit = desktop.onBackendExit(() => {
notify({
kind: 'error',
title: 'Backend stopped',
message: 'Hermes background process exited.',
durationMs: 0
})
})
async function boot() {
try {
const conn = await desktop.getConnection()
if (cancelled) {
return
}
onConnectionReady(conn)
setConnection(conn)
await gateway.connect(conn.wsUrl)
if (cancelled) {
return
}
await refreshHermesConfig()
if (cancelled) {
return
}
await refreshSessions()
} catch (err) {
if (!cancelled) {
notifyError(err, 'Desktop boot failed')
setSessionsLoading(false)
}
}
}
void boot()
return () => {
cancelled = true
offState()
offEvent()
offExit()
gateway.close()
onConnectionReady(null)
onGatewayReady(null)
}
}, [
handleGatewayEvent,
onConnectionReady,
onGatewayReady,
refreshHermesConfig,
refreshSessions
])
}
@@ -0,0 +1,90 @@
import { useStore } from '@nanostores/react'
import { useCallback, useEffect, useRef } from 'react'
import { HermesGateway } from '@/hermes'
import { $gatewayState, setConnection } from '@/store/session'
export function useGatewayRequest() {
const gatewayState = useStore($gatewayState)
const gatewayRef = useRef<HermesGateway | null>(null)
const connectionRef = useRef<Awaited<ReturnType<NonNullable<typeof window.hermesDesktop>['getConnection']>> | null>(
null
)
const gatewayStateRef = useRef(gatewayState)
const reconnectingRef = useRef<Promise<HermesGateway | null> | null>(null)
useEffect(() => {
gatewayStateRef.current = gatewayState
}, [gatewayState])
const ensureGatewayOpen = useCallback(async () => {
const existing = gatewayRef.current
if (!existing) {
return null
}
if (gatewayStateRef.current === 'open') {
return existing
}
if (reconnectingRef.current) {
return reconnectingRef.current
}
reconnectingRef.current = (async () => {
const desktop = window.hermesDesktop
if (!desktop) {
return null
}
const conn = connectionRef.current || (await desktop.getConnection())
connectionRef.current = conn
setConnection(conn)
try {
await existing.connect(conn.wsUrl)
return existing
} catch {
return null
} finally {
reconnectingRef.current = null
}
})()
return reconnectingRef.current
}, [])
const requestGateway = useCallback(
async <T>(method: string, params: Record<string, unknown> = {}) => {
const gateway = gatewayRef.current
if (!gateway) {
throw new Error('Hermes gateway unavailable')
}
try {
return await gateway.request<T>(method, params)
} catch (error) {
const message = error instanceof Error ? error.message : String(error)
if (!/not connected|connection closed/i.test(message)) {
throw error
}
const recovered = await ensureGatewayOpen()
if (!recovered) {
throw error
}
return recovered.request<T>(method, params)
}
},
[ensureGatewayOpen]
)
return { connectionRef, gatewayRef, requestGateway }
}
File diff suppressed because it is too large Load Diff
+26 -37
View File
@@ -1,8 +1,8 @@
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 const SESSION_ROUTE_PREFIX = '/'
export const NEW_CHAT_ROUTE = '/'
export const SETTINGS_ROUTE = '/settings'
export const SKILLS_ROUTE = '/skills'
export const ARTIFACTS_ROUTE = '/artifacts'
export type AppView = 'chat' | 'settings' | 'skills' | 'artifacts'
@@ -10,53 +10,42 @@ export type AppRouteId = 'new' | 'settings' | 'skills' | 'artifacts'
export interface AppRoute {
id: AppRouteId
hash: string
path: 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' }
{ id: 'new', path: NEW_CHAT_ROUTE, view: 'chat' },
{ id: 'settings', path: SETTINGS_ROUTE, view: 'settings' },
{ id: 'skills', path: SKILLS_ROUTE, view: 'skills' },
{ id: 'artifacts', path: 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]))
const APP_VIEW_BY_PATH = new Map<string, AppView>(APP_ROUTES.map(route => [route.path, route.view]))
const RESERVED_PATHS: ReadonlySet<string> = new Set(APP_ROUTES.map(route => route.path))
export function currentRouteHash(): string {
return window.location.hash || NEW_CHAT_ROUTE
export function isNewChatRoute(pathname: string): boolean {
return pathname === NEW_CHAT_ROUTE
}
export function routeSessionId(hash = currentRouteHash()): string | null {
if (!hash.startsWith(SESSION_ROUTE_PREFIX)) {
export function routeSessionId(pathname: string): string | null {
if (!pathname.startsWith(SESSION_ROUTE_PREFIX) || RESERVED_PATHS.has(pathname)) {
return null
}
const id = hash.slice(SESSION_ROUTE_PREFIX.length)
const id = pathname.slice(SESSION_ROUTE_PREFIX.length)
return id ? decodeURIComponent(id) : null
return id && !id.includes('/') ? decodeURIComponent(id) : null
}
export function writeRoute(hash: string, replace = false) {
if (window.location.hash === hash) {
return
export function sessionRoute(sessionId: string): string {
return `${SESSION_ROUTE_PREFIX}${encodeURIComponent(sessionId)}`
}
export function appViewForPath(pathname: string): AppView {
if (isNewChatRoute(pathname) || routeSessionId(pathname)) {
return 'chat'
}
const nextUrl = `${window.location.pathname}${window.location.search}${hash}`
if (replace) {
window.history.replaceState(null, '', nextUrl)
} else {
window.history.pushState(null, '', nextUrl)
}
return APP_VIEW_BY_PATH.get(pathname) ?? 'chat'
}
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
@@ -0,0 +1,422 @@
import type { QueryClient } from '@tanstack/react-query'
import { type MutableRefObject, useCallback } from 'react'
import { flushSync } from 'react-dom'
import {
appendReasoningPart,
appendTextPart,
type ChatMessage,
type ChatMessagePart,
chatMessageText,
type GatewayEventPayload,
reasoningPart,
textPart,
upsertToolPart
} from '@/lib/chat-messages'
import { coerceGatewayText, coerceThinkingText, normalizePersonalityValue } from '@/lib/chat-runtime'
import { triggerHaptic } from '@/lib/haptics'
import { notify } from '@/store/notifications'
import {
setCurrentBranch,
setCurrentCwd,
setCurrentModel,
setCurrentPersonality,
setCurrentProvider
} from '@/store/session'
import type { RpcEvent } from '@/types/hermes'
import type { ClientSessionState } from '../../types'
interface MessageStreamOptions {
activeSessionIdRef: MutableRefObject<string | null>
hydrateFromStoredSession: (
attempts?: number,
storedSessionId?: string | null,
runtimeSessionId?: string | null
) => Promise<void>
queryClient: QueryClient
refreshHermesConfig: () => Promise<void>
refreshSessions: () => Promise<void>
updateSessionState: (
sessionId: string,
updater: (state: ClientSessionState) => ClientSessionState,
storedSessionId?: string | null
) => ClientSessionState
}
export function useMessageStream({
activeSessionIdRef,
hydrateFromStoredSession,
queryClient,
refreshHermesConfig,
refreshSessions,
updateSessionState
}: MessageStreamOptions) {
// Patch the in-flight assistant message (or seed it). Centralises the
// streamId/groupId bookkeeping every event callback would otherwise repeat.
const mutateStream = useCallback(
(
sessionId: string,
transform: (parts: ChatMessagePart[], message: ChatMessage) => ChatMessagePart[],
seed: () => ChatMessagePart[],
opts: {
sync?: boolean
pending?: (message: ChatMessage) => boolean
} = {}
) => {
const apply = () => {
updateSessionState(sessionId, state => {
// After a stop, drop any late deltas / tool events for the
// cancelled turn so they don't keep growing the (now finalized)
// assistant bubble or, worse, seed a brand-new bubble that
// appears to belong to the next user message.
if (state.interrupted) {
return state
}
const streamId = state.streamId ?? `assistant-stream-${Date.now()}`
const groupId = state.pendingBranchGroup ?? undefined
const prev = state.messages
let nextMessages: ChatMessage[]
if (!prev.some(m => m.id === streamId)) {
nextMessages = [
...prev,
{
id: streamId,
role: 'assistant',
parts: seed(),
pending: true,
branchGroupId: groupId
}
]
} else {
nextMessages = prev.map(m =>
m.id === streamId
? {
...m,
parts: transform(m.parts, m),
pending: opts.pending ? opts.pending(m) : true
}
: m
)
}
return {
...state,
messages: nextMessages,
streamId,
sawAssistantPayload: true,
awaitingResponse: false
}
})
}
opts.sync ? flushSync(apply) : apply()
},
[updateSessionState]
)
const appendAssistantDelta = useCallback(
(sessionId: string, delta: string) => {
if (!delta) {
return
}
mutateStream(
sessionId,
parts => appendTextPart(parts, delta),
() => [textPart(delta)],
{ sync: true }
)
},
[mutateStream]
)
const appendReasoningDelta = useCallback(
(sessionId: string, delta: string, replace = false) => {
if (!delta) {
return
}
mutateStream(
sessionId,
(parts, message) => {
if (replace && chatMessageText(message).trim()) {
return parts
}
if (replace) {
return [...parts.filter(part => part.type !== 'reasoning'), reasoningPart(delta)]
}
return appendReasoningPart(parts, delta)
},
() => [reasoningPart(delta)],
{ sync: true }
)
},
[mutateStream]
)
const upsertToolCall = useCallback(
(sessionId: string, payload: GatewayEventPayload | undefined, phase: 'running' | 'complete') => {
mutateStream(
sessionId,
parts => upsertToolPart(parts, payload, phase),
() => upsertToolPart([], payload, phase),
{ pending: m => phase !== 'complete' || (m.pending ?? false) }
)
},
[mutateStream]
)
const completeAssistantMessage = useCallback(
(sessionId: string, text: string) => {
let shouldHydrate = false
const completedState = updateSessionState(sessionId, state => {
// Late completion from an already-cancelled turn: cancelRun has
// already finalized the bubble and added the [interrupted] marker;
// re-running the dedupe below would erase that marker and replace
// the partial with the (just-cancelled) full text.
if (state.interrupted) {
return state
}
const streamId = state.streamId
const finalText = text.trim()
const normalize = (value: string) => value.replace(/\s+/g, ' ').trim()
const dedupeReference = normalize(finalText)
const replaceTextPart = (parts: ChatMessagePart[]) => {
const kept = parts.filter(part => {
if (part.type === 'text') {
return false
}
if (part.type !== 'reasoning' || !dedupeReference) {
return true
}
const r = normalize(part.text)
return !(r && (dedupeReference.startsWith(r) || r.startsWith(dedupeReference)))
})
return text ? [...kept, textPart(text)] : kept
}
const completeMessage = (message: ChatMessage): ChatMessage => ({
...message,
parts: replaceTextPart(message.parts),
pending: false
})
const prev = state.messages
let nextMessages = prev
if (streamId && prev.some(m => m.id === streamId)) {
nextMessages = prev.map(m => (m.id === streamId ? completeMessage(m) : m))
} else {
const fallbackIndex = [...prev]
.reverse()
.findIndex(message => message.role === 'assistant' && !message.hidden)
if (fallbackIndex >= 0) {
const index = prev.length - 1 - fallbackIndex
const existing = prev[index]
const existingText = chatMessageText(existing).trim()
if (existing.pending || (finalText && existingText === finalText)) {
nextMessages = prev.map((message, messageIndex) =>
messageIndex === index ? completeMessage(message) : message
)
} else if (text) {
nextMessages = [
...prev,
{
id: `assistant-${Date.now()}`,
role: 'assistant',
parts: [textPart(text)],
branchGroupId: state.pendingBranchGroup ?? undefined
}
]
}
} else if (text) {
nextMessages = [
...prev,
{
id: `assistant-${Date.now()}`,
role: 'assistant',
parts: [textPart(text)],
branchGroupId: state.pendingBranchGroup ?? undefined
}
]
}
}
shouldHydrate = !state.sawAssistantPayload || !finalText
return {
...state,
messages: nextMessages,
streamId: null,
pendingBranchGroup: null,
awaitingResponse: false,
busy: false
}
})
void refreshSessions().catch(() => undefined)
if (shouldHydrate) {
void hydrateFromStoredSession(3, completedState.storedSessionId, sessionId)
}
if (document.hidden && sessionId === activeSessionIdRef.current) {
void window.hermesDesktop?.notify({
title: 'Hermes finished',
body: text.slice(0, 140) || 'The response is ready.'
})
}
},
[activeSessionIdRef, hydrateFromStoredSession, refreshSessions, updateSessionState]
)
const handleGatewayEvent = useCallback(
(event: RpcEvent) => {
const payload = event.payload as GatewayEventPayload | undefined
const explicitSid = event.session_id || ''
const sessionId = explicitSid || activeSessionIdRef.current
const isActiveEvent = !!sessionId && sessionId === activeSessionIdRef.current
if (event.type === 'gateway.ready') {
return
} else if (event.type === 'session.info') {
// Apply session-scoped fields when the event targets the active
// session, OR when it's a global broadcast and we have no session.
const apply = explicitSid ? isActiveEvent : !activeSessionIdRef.current
const modelChanged = typeof payload?.model === 'string'
const providerChanged = typeof payload?.provider === 'string'
if (apply) {
if (modelChanged) {
setCurrentModel(payload!.model || '')
}
if (providerChanged) {
setCurrentProvider(payload!.provider || '')
}
if (typeof payload?.cwd === 'string') {
setCurrentCwd(payload.cwd)
}
if (typeof payload?.branch === 'string') {
setCurrentBranch(payload.branch)
}
if (typeof payload?.personality === 'string') {
setCurrentPersonality(normalizePersonalityValue(payload.personality))
}
}
void refreshHermesConfig()
if (modelChanged || providerChanged) {
void queryClient.invalidateQueries({
queryKey: explicitSid && sessionId ? ['model-options', sessionId] : ['model-options']
})
}
} else if (event.type === 'message.start') {
if (!sessionId) {
return
}
if (isActiveEvent) {
triggerHaptic('streamStart')
}
updateSessionState(sessionId, state => ({
...state,
busy: true,
awaitingResponse: true,
sawAssistantPayload: false,
interrupted: false
}))
} else if (event.type === 'message.delta') {
if (sessionId) {
appendAssistantDelta(sessionId, coerceGatewayText(payload?.text))
}
} else if (event.type === 'thinking.delta') {
if (sessionId) {
appendReasoningDelta(sessionId, coerceThinkingText(payload?.text))
}
} else if (event.type === 'reasoning.delta') {
if (sessionId) {
appendReasoningDelta(sessionId, coerceGatewayText(payload?.text))
}
} else if (event.type === 'reasoning.available') {
if (sessionId) {
appendReasoningDelta(sessionId, coerceGatewayText(payload?.text), true)
}
} else if (event.type === 'message.complete') {
if (!sessionId) {
return
}
if (isActiveEvent) {
triggerHaptic('streamDone')
}
const finalText = coerceGatewayText(payload?.text) || coerceGatewayText(payload?.rendered)
completeAssistantMessage(sessionId, finalText)
} else if (event.type === 'tool.start' || event.type === 'tool.progress' || event.type === 'tool.generating') {
if (!sessionId) {
return
}
upsertToolCall(sessionId, payload, 'running')
} else if (event.type === 'tool.complete') {
if (sessionId) {
upsertToolCall(sessionId, payload, 'complete')
}
} else if (event.type === 'error') {
if (isActiveEvent) {
notify({
kind: 'error',
title: 'Hermes error',
message: payload?.message || 'Hermes reported an error'
})
}
if (sessionId) {
updateSessionState(sessionId, state => ({
...state,
awaitingResponse: false,
busy: false
}))
}
}
},
[
appendAssistantDelta,
appendReasoningDelta,
activeSessionIdRef,
completeAssistantMessage,
queryClient,
refreshHermesConfig,
updateSessionState,
upsertToolCall
]
)
return {
appendAssistantDelta,
appendReasoningDelta,
completeAssistantMessage,
handleGatewayEvent,
upsertToolCall
}
}
@@ -0,0 +1,427 @@
import type { ThreadMessage } from '@assistant-ui/react'
import { type MutableRefObject, useCallback } from 'react'
import { transcribeAudio } from '@/hermes'
import { appendTextPart, branchGroupForUser, type ChatMessage, chatMessageText, textPart } from '@/lib/chat-messages'
import {
attachmentDisplayText,
INTERRUPTED_MARKER,
parseCommandDispatch,
parseSlashCommand,
SLASH_COMMAND_RE
} from '@/lib/chat-runtime'
import { triggerHaptic } from '@/lib/haptics'
import { $composerAttachments, clearComposerAttachments } from '@/store/composer'
import { clearNotifications, notify, notifyError } from '@/store/notifications'
import { $busy, $messages, setAwaitingResponse, setBusy } from '@/store/session'
import type { ClientSessionState, SlashExecResponse } from '../../types'
function blobToDataUrl(blob: Blob): Promise<string> {
return new Promise((resolve, reject) => {
const reader = new FileReader()
reader.addEventListener('load', () => {
if (typeof reader.result === 'string') {
resolve(reader.result)
} else {
reject(new Error('Could not read recorded audio'))
}
})
reader.addEventListener('error', () => reject(reader.error || new Error('Could not read recorded audio')))
reader.readAsDataURL(blob)
})
}
interface PromptActionsOptions {
activeSessionId: string | null
activeSessionIdRef: MutableRefObject<string | null>
busyRef: MutableRefObject<boolean>
createBackendSessionForSend: () => Promise<string | null>
requestGateway: <T>(method: string, params?: Record<string, unknown>) => Promise<T>
selectedStoredSessionIdRef: MutableRefObject<string | null>
sttEnabled: boolean
updateSessionState: (
sessionId: string,
updater: (state: ClientSessionState) => ClientSessionState,
storedSessionId?: string | null
) => ClientSessionState
}
export function usePromptActions({
activeSessionId,
activeSessionIdRef,
busyRef,
createBackendSessionForSend,
requestGateway,
selectedStoredSessionIdRef,
sttEnabled,
updateSessionState
}: PromptActionsOptions) {
const appendSessionTextMessage = useCallback(
(sessionId: string, role: ChatMessage['role'], text: string) => {
const body = text.trim()
if (!body) {
return
}
updateSessionState(
sessionId,
state => ({
...state,
messages: [
...state.messages,
{
id: `${role}-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
role,
parts: [textPart(body)]
}
]
}),
selectedStoredSessionIdRef.current
)
},
[selectedStoredSessionIdRef, updateSessionState]
)
const submitPromptText = useCallback(
async (rawText: string) => {
const visibleText = rawText.trim()
const attachments = $composerAttachments.get()
const contextRefs = attachments
.map(attachment => attachment.refText)
.filter(Boolean)
.join('\n')
const hasImageAttachment = attachments.some(attachment => attachment.kind === 'image')
const displayRefs = attachments.map(attachmentDisplayText).filter(Boolean).join('\n')
const text =
[contextRefs, visibleText].filter(Boolean).join('\n\n') ||
(hasImageAttachment ? 'What do you see in this image?' : '')
if (!text || busyRef.current) {
return
}
const userMessage: ChatMessage = {
id: `user-${Date.now()}`,
role: 'user',
parts: [
textPart(
[displayRefs, visibleText].filter(Boolean).join('\n\n') ||
attachments.map(attachment => attachment.label).join(', ')
)
]
}
busyRef.current = true
setBusy(true)
setAwaitingResponse(true)
clearNotifications()
const sessionId = activeSessionId ? activeSessionId : await createBackendSessionForSend()
if (!sessionId) {
busyRef.current = false
setBusy(false)
setAwaitingResponse(false)
notify({
kind: 'error',
title: 'Session unavailable',
message: 'Could not create a new session'
})
return
}
updateSessionState(
sessionId,
state => ({
...state,
messages: [...state.messages, userMessage],
busy: true,
awaitingResponse: true,
pendingBranchGroup: null,
sawAssistantPayload: false,
interrupted: false
}),
selectedStoredSessionIdRef.current
)
try {
await requestGateway('prompt.submit', { session_id: sessionId, text })
clearComposerAttachments()
} catch (err) {
busyRef.current = false
updateSessionState(sessionId, state => ({
...state,
messages: state.messages.filter(message => message.id !== userMessage.id),
busy: false,
awaitingResponse: false
}))
notifyError(err, 'Prompt failed')
}
},
[activeSessionId, createBackendSessionForSend, requestGateway, selectedStoredSessionIdRef, updateSessionState]
)
const executeSlashCommand = useCallback(
async (rawCommand: string, options?: { sessionId?: string; recordInput?: boolean }) => {
const runSlash = async (commandText: string, sessionHint?: string, recordInput = true): Promise<void> => {
const command = commandText.trim()
const { name, arg } = parseSlashCommand(command)
const sessionId = sessionHint || activeSessionIdRef.current || (await createBackendSessionForSend())
if (!sessionId) {
notify({
kind: 'error',
title: 'Session unavailable',
message: 'Could not create a new session'
})
return
}
const renderSlashOutput = (text: string) => appendSessionTextMessage(sessionId, 'system', text)
if (recordInput) {
appendSessionTextMessage(sessionId, 'user', command)
}
if (!name) {
renderSlashOutput('empty slash command')
return
}
try {
const result = await requestGateway<SlashExecResponse>('slash.exec', {
session_id: sessionId,
command: command.replace(/^\/+/, '')
})
const body = result?.output || `/${name}: no output`
renderSlashOutput(result?.warning ? `warning: ${result.warning}\n${body}` : body)
return
} catch {
// Fall back to command.dispatch for skill/send/alias directives.
}
try {
const dispatch = parseCommandDispatch(
await requestGateway<unknown>('command.dispatch', {
session_id: sessionId,
name,
arg
})
)
if (!dispatch) {
renderSlashOutput('error: invalid response: command.dispatch')
return
}
if (dispatch.type === 'exec' || dispatch.type === 'plugin') {
renderSlashOutput(dispatch.output ?? '(no output)')
return
}
if (dispatch.type === 'alias') {
await runSlash(`/${dispatch.target}${arg ? ` ${arg}` : ''}`, sessionId, false)
return
}
const message = ('message' in dispatch ? dispatch.message : '')?.trim() ?? ''
if (!message) {
renderSlashOutput(
`/${name}: ${dispatch.type === 'skill' ? 'skill payload missing message' : 'empty message'}`
)
return
}
if (dispatch.type === 'skill') {
renderSlashOutput(`⚡ loading skill: ${dispatch.name}`)
}
if (busyRef.current) {
renderSlashOutput('session busy — /interrupt the current turn before sending this command')
return
}
await submitPromptText(message)
} catch (err) {
renderSlashOutput(`error: ${err instanceof Error ? err.message : String(err)}`)
}
}
await runSlash(rawCommand, options?.sessionId, options?.recordInput ?? true)
},
[activeSessionIdRef, appendSessionTextMessage, createBackendSessionForSend, requestGateway, submitPromptText]
)
const submitText = useCallback(
async (rawText: string) => {
const visibleText = rawText.trim()
const attachments = $composerAttachments.get()
if (!attachments.length && SLASH_COMMAND_RE.test(visibleText)) {
triggerHaptic('selection')
await executeSlashCommand(visibleText)
return
}
await submitPromptText(rawText)
},
[executeSlashCommand, submitPromptText]
)
const transcribeVoiceAudio = useCallback(
async (audio: Blob) => {
if (!sttEnabled) {
throw new Error('Speech-to-text is disabled in settings.')
}
const dataUrl = await blobToDataUrl(audio)
const result = await transcribeAudio(dataUrl, audio.type)
return result.transcript
},
[sttEnabled]
)
const cancelRun = useCallback(async () => {
if (!activeSessionId) {
return
}
updateSessionState(activeSessionId, state => {
const streamId = state.streamId
const messages = streamId
? state.messages.map(message =>
message.id === streamId
? {
...message,
parts: chatMessageText(message).trim()
? appendTextPart(message.parts, INTERRUPTED_MARKER)
: [...message.parts, textPart(INTERRUPTED_MARKER.trim())],
pending: false
}
: message
)
: state.messages
return {
...state,
messages,
busy: false,
awaitingResponse: false,
streamId: null,
pendingBranchGroup: null,
interrupted: true
}
})
try {
await requestGateway('session.interrupt', { session_id: activeSessionId })
} catch (err) {
notifyError(err, 'Stop failed')
}
}, [activeSessionId, requestGateway, updateSessionState])
const reloadFromMessage = useCallback(
async (parentId: string | null) => {
if (!activeSessionId || $busy.get()) {
return
}
const messages = $messages.get()
const parentIndex = parentId ? messages.findIndex(message => message.id === parentId) : messages.length - 1
const userIndex =
parentIndex >= 0
? [...messages.slice(0, parentIndex + 1)].reverse().findIndex(message => message.role === 'user')
: -1
if (userIndex < 0) {
return
}
const absoluteUserIndex = parentIndex - userIndex
const userMessage = messages[absoluteUserIndex]
const userText = userMessage ? chatMessageText(userMessage).trim() : ''
if (!userText) {
return
}
const targetAssistant =
parentId && messages[parentIndex]?.role === 'assistant'
? messages[parentIndex]
: messages.slice(absoluteUserIndex + 1).find(message => message.role === 'assistant')
const branchGroupId = targetAssistant?.branchGroupId ?? branchGroupForUser(userMessage)
clearNotifications()
updateSessionState(activeSessionId, state => {
const nextUserIndex = state.messages.findIndex(
(message, index) => index > absoluteUserIndex && message.role === 'user'
)
const end = nextUserIndex < 0 ? state.messages.length : nextUserIndex
return {
...state,
busy: true,
awaitingResponse: true,
pendingBranchGroup: branchGroupId,
sawAssistantPayload: false,
interrupted: false,
messages: [
...state.messages.slice(0, absoluteUserIndex + 1),
...state.messages
.slice(absoluteUserIndex + 1, end)
.map(message => (message.role === 'assistant' ? { ...message, branchGroupId, hidden: true } : message))
]
}
})
try {
await requestGateway('prompt.submit', { session_id: activeSessionId, text: userText })
} catch (err) {
updateSessionState(activeSessionId, state => ({
...state,
busy: false,
awaitingResponse: false
}))
notifyError(err, 'Regenerate failed')
}
},
[activeSessionId, requestGateway, updateSessionState]
)
const handleThreadMessagesChange = useCallback(
(nextMessages: readonly ThreadMessage[]) => {
const visibleIds = new Set(nextMessages.map(message => message.id))
const sessionId = activeSessionIdRef.current
if (!sessionId) {
return
}
updateSessionState(sessionId, state => ({
...state,
messages: state.messages.map(message =>
message.role === 'assistant' && message.branchGroupId
? { ...message, hidden: !visibleIds.has(message.id) }
: message
)
}))
},
[activeSessionIdRef, updateSessionState]
)
return { cancelRun, handleThreadMessagesChange, reloadFromMessage, submitText, transcribeVoiceAudio }
}
@@ -0,0 +1,442 @@
import type { MutableRefObject } from 'react'
import { useCallback, useRef } from 'react'
import type { NavigateFunction } from 'react-router-dom'
import { deleteSession, getSessionMessages } from '@/hermes'
import { chatMessageText, toChatMessages } from '@/lib/chat-messages'
import { normalizePersonalityValue } from '@/lib/chat-runtime'
import { clearComposerAttachments, clearComposerDraft } from '@/store/composer'
import { $pinnedSessionIds } from '@/store/layout'
import { clearNotifications, notify, notifyError } from '@/store/notifications'
import {
$messages,
$sessions,
setActiveSessionId,
setAwaitingResponse,
setBusy,
setCurrentBranch,
setCurrentCwd,
setCurrentModel,
setCurrentPersonality,
setCurrentProvider,
setFreshDraftReady,
setIntroSeed,
setMessages,
setSelectedStoredSessionId,
setSessions
} from '@/store/session'
import type { SessionCreateResponse, SessionResumeResponse } from '@/types/hermes'
import { NEW_CHAT_ROUTE, sessionRoute, SETTINGS_ROUTE } from '../../routes'
import type { ClientSessionState, SidebarNavItem } from '../../types'
interface SessionActionsOptions {
activeSessionId: string | null
activeSessionIdRef: MutableRefObject<string | null>
busyRef: MutableRefObject<boolean>
ensureSessionState: (sessionId: string, storedSessionId?: string | null) => ClientSessionState
navigate: NavigateFunction
requestGateway: <T>(method: string, params?: Record<string, unknown>) => Promise<T>
runtimeIdByStoredSessionIdRef: MutableRefObject<Map<string, string>>
selectedStoredSessionId: string | null
selectedStoredSessionIdRef: MutableRefObject<string | null>
sessionStateByRuntimeIdRef: MutableRefObject<Map<string, ClientSessionState>>
syncSessionStateToView: (sessionId: string, state: ClientSessionState) => void
updateSessionState: (
sessionId: string,
updater: (state: ClientSessionState) => ClientSessionState,
storedSessionId?: string | null
) => ClientSessionState
}
export function useSessionActions({
activeSessionId,
activeSessionIdRef,
busyRef,
ensureSessionState,
navigate,
requestGateway,
runtimeIdByStoredSessionIdRef,
selectedStoredSessionId,
selectedStoredSessionIdRef,
sessionStateByRuntimeIdRef,
syncSessionStateToView,
updateSessionState
}: SessionActionsOptions) {
const resumeRequestRef = useRef(0)
const startFreshSessionDraft = useCallback(
(replaceRoute = false) => {
busyRef.current = false
setBusy(false)
setAwaitingResponse(false)
clearNotifications()
setIntroSeed(seed => seed + 1)
navigate(NEW_CHAT_ROUTE, { replace: replaceRoute })
setActiveSessionId(null)
activeSessionIdRef.current = null
setSelectedStoredSessionId(null)
selectedStoredSessionIdRef.current = null
setMessages([])
clearComposerDraft()
clearComposerAttachments()
setFreshDraftReady(true)
},
[activeSessionIdRef, busyRef, navigate, selectedStoredSessionIdRef]
)
const createBackendSessionForSend = useCallback(async (): Promise<string | null> => {
const created = await requestGateway<SessionCreateResponse>('session.create', { cols: 96 })
setActiveSessionId(created.session_id)
activeSessionIdRef.current = created.session_id
ensureSessionState(created.session_id, created.stored_session_id ?? null)
if (created.stored_session_id) {
setSelectedStoredSessionId(created.stored_session_id)
selectedStoredSessionIdRef.current = created.stored_session_id
navigate(sessionRoute(created.stored_session_id), { replace: true })
}
if (created.info?.model) {
setCurrentModel(created.info.model)
}
if (created.info?.provider) {
setCurrentProvider(created.info.provider)
}
if (created.info?.cwd) {
setCurrentCwd(created.info.cwd)
}
if (created.info?.branch) {
setCurrentBranch(created.info.branch)
}
if (typeof created.info?.personality === 'string') {
setCurrentPersonality(normalizePersonalityValue(created.info.personality))
}
return created.session_id
}, [activeSessionIdRef, ensureSessionState, navigate, requestGateway, selectedStoredSessionIdRef])
const selectSidebarItem = useCallback(
(item: SidebarNavItem) => {
if (item.action === 'new-session') {
startFreshSessionDraft()
return
}
if (item.route) {
navigate(item.route)
}
},
[navigate, startFreshSessionDraft]
)
const openSettings = useCallback(() => {
navigate(SETTINGS_ROUTE)
}, [navigate])
const closeSettings = useCallback(() => {
if (selectedStoredSessionId) {
navigate(sessionRoute(selectedStoredSessionId))
return
}
navigate(NEW_CHAT_ROUTE)
}, [navigate, selectedStoredSessionId])
const resumeSession = useCallback(
async (storedSessionId: string, replaceRoute = false) => {
const requestId = resumeRequestRef.current + 1
resumeRequestRef.current = requestId
const isCurrentResume = () =>
resumeRequestRef.current === requestId && selectedStoredSessionIdRef.current === storedSessionId
const cachedRuntimeId = runtimeIdByStoredSessionIdRef.current.get(storedSessionId)
const cachedState = cachedRuntimeId && sessionStateByRuntimeIdRef.current.get(cachedRuntimeId)
if (cachedRuntimeId && cachedState) {
setFreshDraftReady(false)
clearNotifications()
setSelectedStoredSessionId(storedSessionId)
selectedStoredSessionIdRef.current = storedSessionId
setActiveSessionId(cachedRuntimeId)
activeSessionIdRef.current = cachedRuntimeId
syncSessionStateToView(cachedRuntimeId, cachedState)
clearComposerDraft()
clearComposerAttachments()
return
}
setFreshDraftReady(false)
setActiveSessionId(null)
activeSessionIdRef.current = null
busyRef.current = true
setBusy(true)
setAwaitingResponse(false)
clearNotifications()
setSelectedStoredSessionId(storedSessionId)
selectedStoredSessionIdRef.current = storedSessionId
setMessages([])
try {
let resumeApplied = false
const storedMessagesPromise = getSessionMessages(storedSessionId)
.then(storedMessages => {
if (!resumeApplied && isCurrentResume()) {
setMessages(toChatMessages(storedMessages.messages))
}
})
.catch(() => undefined)
const resumePromise = requestGateway<SessionResumeResponse>('session.resume', {
session_id: storedSessionId,
cols: 96
})
void storedMessagesPromise
const resumed = await resumePromise
resumeApplied = true
if (!isCurrentResume()) {
return
}
setActiveSessionId(resumed.session_id)
activeSessionIdRef.current = resumed.session_id
updateSessionState(
resumed.session_id,
state => ({
...state,
messages: toChatMessages(resumed.messages),
busy: false,
awaitingResponse: false
}),
storedSessionId
)
clearComposerDraft()
clearComposerAttachments()
if (resumed.info?.model) {
setCurrentModel(resumed.info.model)
}
if (resumed.info?.provider) {
setCurrentProvider(resumed.info.provider)
}
if (resumed.info?.cwd) {
setCurrentCwd(resumed.info.cwd)
}
setCurrentBranch(resumed.info?.branch || '')
if (typeof resumed.info?.personality === 'string') {
setCurrentPersonality(normalizePersonalityValue(resumed.info.personality))
}
} catch (err) {
if (!isCurrentResume()) {
return
}
const fallback = await getSessionMessages(storedSessionId)
if (!isCurrentResume()) {
return
}
setMessages(toChatMessages(fallback.messages))
notifyError(err, 'Resume failed')
} finally {
if (isCurrentResume()) {
busyRef.current = false
setBusy(false)
setAwaitingResponse(false)
}
}
},
[
activeSessionIdRef,
busyRef,
requestGateway,
runtimeIdByStoredSessionIdRef,
selectedStoredSessionIdRef,
sessionStateByRuntimeIdRef,
syncSessionStateToView,
updateSessionState
]
)
const branchCurrentSession = useCallback(async (messageId?: string): Promise<boolean> => {
const sourceSessionId = activeSessionIdRef.current
if (!sourceSessionId) {
notify({
kind: 'warning',
title: 'Nothing to branch',
message: 'Start or resume a chat before branching.'
})
return false
}
if (busyRef.current) {
notify({
kind: 'warning',
title: 'Session busy',
message: 'Stop the current turn before branching this chat.'
})
return false
}
try {
const currentMessages = $messages.get()
const targetIndex = messageId ? currentMessages.findIndex(message => message.id === messageId) : -1
const branchStart = targetIndex >= 0 ? targetIndex : Math.max(currentMessages.length - 1, 0)
const branchEnd = targetIndex >= 0 ? targetIndex + 1 : currentMessages.length
const branchMessages = currentMessages
.slice(branchStart, branchEnd)
.map(message => ({
content: chatMessageText(message),
source: message,
role: message.role
}))
.filter(message => message.content.trim() && ['assistant', 'system', 'user'].includes(message.role))
if (!branchMessages.length) {
notify({
kind: 'warning',
title: 'Nothing to branch',
message: 'This message has no text to branch from.'
})
return false
}
clearNotifications()
const branched = await requestGateway<SessionCreateResponse>('session.create', {
cols: 96,
messages: branchMessages.map(({ content, role }) => ({ content, role })),
title: 'Branch'
})
const routedSessionId = branched.stored_session_id ?? branched.session_id
setFreshDraftReady(false)
ensureSessionState(branched.session_id, routedSessionId)
setActiveSessionId(branched.session_id)
activeSessionIdRef.current = branched.session_id
updateSessionState(
branched.session_id,
state => ({
...state,
messages: branchMessages.map(({ source }) => source),
busy: false,
awaitingResponse: false
}),
routedSessionId
)
setSelectedStoredSessionId(routedSessionId)
selectedStoredSessionIdRef.current = routedSessionId
navigate(sessionRoute(routedSessionId))
clearComposerDraft()
clearComposerAttachments()
if (branched.info?.model) {
setCurrentModel(branched.info.model)
}
if (branched.info?.provider) {
setCurrentProvider(branched.info.provider)
}
if (branched.info?.cwd) {
setCurrentCwd(branched.info.cwd)
}
setCurrentBranch(branched.info?.branch || '')
if (typeof branched.info?.personality === 'string') {
setCurrentPersonality(normalizePersonalityValue(branched.info.personality))
}
return true
} catch (err) {
notifyError(err, 'Branch failed')
return false
}
}, [activeSessionIdRef, busyRef, ensureSessionState, navigate, requestGateway, selectedStoredSessionIdRef, updateSessionState])
const removeSession = useCallback(
async (storedSessionId: string) => {
clearNotifications()
const removed = $sessions.get().find(s => s.id === storedSessionId)
const wasSelected = selectedStoredSessionId === storedSessionId
const previousMessages = $messages.get()
const previousPinnedSessionIds = $pinnedSessionIds.get()
setSessions(prev => prev.filter(s => s.id !== storedSessionId))
$pinnedSessionIds.set(previousPinnedSessionIds.filter(id => id !== storedSessionId))
if (wasSelected) {
setSelectedStoredSessionId(null)
selectedStoredSessionIdRef.current = null
setMessages([])
}
try {
if (wasSelected && activeSessionId) {
await requestGateway('session.close', {
session_id: activeSessionId
}).catch(() => undefined)
}
await deleteSession(storedSessionId)
if (wasSelected) {
startFreshSessionDraft()
}
} catch (err) {
if (removed) {
setSessions(prev => [removed, ...prev])
}
$pinnedSessionIds.set(previousPinnedSessionIds)
if (wasSelected) {
setSelectedStoredSessionId(storedSessionId)
selectedStoredSessionIdRef.current = storedSessionId
setMessages(previousMessages)
}
notifyError(err, 'Delete failed')
}
},
[activeSessionId, selectedStoredSessionId, selectedStoredSessionIdRef, startFreshSessionDraft, requestGateway]
)
return {
branchCurrentSession,
closeSettings,
createBackendSessionForSend,
openSettings,
removeSession,
resumeSession,
selectSidebarItem,
startFreshSessionDraft
}
}
@@ -1,11 +1,11 @@
import { useStore } from '@nanostores/react'
import { useCallback, useEffect, useRef, type MutableRefObject } from 'react'
import { type MutableRefObject, useCallback, useEffect, useRef } from 'react'
import { createClientSessionState } from '@/lib/chat-runtime'
import type { ChatMessage } from '@/lib/chat-messages'
import { $busy } from '@/store/session'
import { createClientSessionState } from '@/lib/chat-runtime'
import { $busy, setSessionWorking } from '@/store/session'
import type { ClientSessionState } from '../types'
import type { ClientSessionState } from '../../types'
interface SessionStateCacheOptions {
activeSessionId: string | null
@@ -47,10 +47,19 @@ export function useSessionStateCache({
if (existing) {
if (storedSessionId !== undefined) {
const previousStoredSessionId = existing.storedSessionId
existing.storedSessionId = storedSessionId
if (storedSessionId) {
runtimeIdByStoredSessionIdRef.current.set(storedSessionId, sessionId)
if (existing.busy) {
setSessionWorking(storedSessionId, true)
}
}
if (previousStoredSessionId && previousStoredSessionId !== storedSessionId) {
setSessionWorking(previousStoredSessionId, false)
}
}
@@ -90,6 +99,12 @@ export function useSessionStateCache({
const previous = ensureSessionState(sessionId, storedSessionId)
const next = updater({ ...previous, messages: previous.messages })
sessionStateByRuntimeIdRef.current.set(sessionId, next)
if (previous.storedSessionId !== next.storedSessionId || !next.busy) {
setSessionWorking(previous.storedSessionId, false)
}
setSessionWorking(next.storedSessionId, next.busy)
syncSessionStateToView(sessionId, next)
return next
@@ -0,0 +1,161 @@
import { Check, Palette } from 'lucide-react'
import { triggerHaptic } from '@/lib/haptics'
import { cn } from '@/lib/utils'
import { useTheme } from '@/themes/context'
import { BUILTIN_THEMES } from '@/themes/presets'
import { MODE_OPTIONS } from './constants'
import { prettyName } from './helpers'
import { Pill, SectionHeading, SettingsContent } from './primitives'
function ThemePreview({ name }: { name: string }) {
const t = BUILTIN_THEMES[name]
if (!t) {
return null
}
const c = t.colors
return (
<div
className="h-20 overflow-hidden rounded-xl border shadow-xs"
style={{ backgroundColor: c.background, borderColor: c.border }}
>
<div className="flex h-full">
<div
className="w-12 border-r"
style={{
backgroundColor: c.sidebarBackground ?? c.muted,
borderColor: c.sidebarBorder ?? c.border
}}
/>
<div className="flex flex-1 flex-col gap-2 p-3">
<div className="h-2.5 w-16 rounded-full" style={{ backgroundColor: c.foreground }} />
<div className="h-2 w-24 rounded-full" style={{ backgroundColor: c.mutedForeground }} />
<div className="mt-auto flex justify-end">
<div
className="h-5 w-16 rounded-full border"
style={{
backgroundColor: c.userBubble ?? c.muted,
borderColor: c.userBubbleBorder ?? c.border
}}
/>
</div>
</div>
</div>
</div>
)
}
export function AppearanceSettings() {
const { themeName, mode, availableThemes, setTheme, setMode } = useTheme()
const activeTheme = availableThemes.find(t => t.name === themeName)
return (
<SettingsContent>
<div className="space-y-7">
<div>
<SectionHeading icon={Palette} title="Appearance" />
<p className="max-w-2xl text-sm leading-6 text-muted-foreground">
These are desktop-only display preferences. Mode controls brightness; theme controls the accent palette and
chat surface styling.
</p>
</div>
<section className="rounded-2xl border border-border/50 bg-card/55 p-4 shadow-sm">
<div className="mb-3 flex items-center justify-between gap-3">
<div>
<div className="text-sm font-medium">Color Mode</div>
<div className="mt-1 text-xs text-muted-foreground">
Pick a fixed mode or let Hermes follow your system setting.
</div>
</div>
<Pill>{prettyName(mode)}</Pill>
</div>
<div className="grid gap-2 sm:grid-cols-3">
{MODE_OPTIONS.map(({ id, label, description, icon: Icon }) => {
const active = mode === id
return (
<button
className={cn(
'group rounded-xl border border-border/45 bg-background/55 p-3 text-left transition hover:border-primary/35 hover:bg-accent/45',
active && 'border-primary/65 bg-primary/8 ring-2 ring-primary/25'
)}
key={id}
onClick={() => {
triggerHaptic('crisp')
setMode(id)
}}
type="button"
>
<div className="flex items-start justify-between gap-3">
<span className="flex size-9 items-center justify-center rounded-lg bg-muted text-foreground transition group-hover:bg-background">
<Icon className="size-4" />
</span>
{active && (
<span className="grid size-5 place-items-center rounded-full bg-primary text-primary-foreground">
<Check className="size-3.5" />
</span>
)}
</div>
<div className="mt-3 text-sm font-medium">{label}</div>
<div className="mt-1 text-xs leading-5 text-muted-foreground">{description}</div>
</button>
)
})}
</div>
</section>
<section className="rounded-2xl border border-border/50 bg-card/55 p-4 shadow-sm">
<div className="mb-3 flex items-center justify-between gap-3">
<div>
<div className="text-sm font-medium">Theme</div>
<div className="mt-1 text-xs text-muted-foreground">
Desktop palettes only. The selected mode is applied on top.
</div>
</div>
{activeTheme && <Pill>{activeTheme.label}</Pill>}
</div>
<div className="grid gap-3 sm:grid-cols-2 xl:grid-cols-3">
{availableThemes.map(theme => {
const active = themeName === theme.name
return (
<button
className={cn(
'rounded-2xl border border-border/45 bg-background/50 p-2.5 text-left transition hover:border-primary/35 hover:bg-accent/35',
active && 'border-primary/65 bg-primary/8 ring-2 ring-primary/25'
)}
key={theme.name}
onClick={() => {
triggerHaptic('crisp')
setTheme(theme.name)
}}
type="button"
>
<ThemePreview name={theme.name} />
<div className="mt-3 flex items-start justify-between gap-3 px-1">
<div className="min-w-0">
<div className="truncate text-sm font-medium">{theme.label}</div>
<div className="mt-0.5 line-clamp-2 text-xs leading-5 text-muted-foreground">
{theme.description}
</div>
</div>
{active && (
<span className="mt-0.5 grid size-5 shrink-0 place-items-center rounded-full bg-primary text-primary-foreground">
<Check className="size-3.5" />
</span>
)}
</div>
</button>
)
})}
</div>
</section>
</div>
</SettingsContent>
)
}
@@ -0,0 +1,355 @@
import type { ChangeEvent, ReactNode } from 'react'
import { useEffect, useMemo, useRef, useState } from 'react'
import { Input } from '@/components/ui/input'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
import { Switch } from '@/components/ui/switch'
import { Textarea } from '@/components/ui/textarea'
import {
getElevenLabsVoices,
getHermesConfigDefaults,
getHermesConfigRecord,
getHermesConfigSchema,
saveHermesConfig
} from '@/hermes'
import { cn } from '@/lib/utils'
import { notify, notifyError } from '@/store/notifications'
import type { ConfigFieldSchema, HermesConfigRecord } from '@/types/hermes'
import { CONTROL_TEXT, EMPTY_SELECT_VALUE, FIELD_DESCRIPTIONS, FIELD_LABELS, SECTIONS } from './constants'
import { enumOptionsFor, getNested, includesQuery, prettyName, setNested } from './helpers'
import { EmptyState, ListRow, LoadingState, SettingsContent } from './primitives'
import type { SearchProps } from './types'
function ConfigField({
schemaKey,
schema,
value,
enumOptions,
optionLabels,
onChange
}: {
schemaKey: string
schema: ConfigFieldSchema
value: unknown
enumOptions?: string[]
optionLabels?: Record<string, string>
onChange: (value: unknown) => void
}) {
const label = FIELD_LABELS[schemaKey] ?? prettyName(schemaKey.split('.').pop() ?? schemaKey)
const normalize = (v: string) => v.toLowerCase().replace(/[^a-z0-9]+/g, '')
const rawDescription = (FIELD_DESCRIPTIONS[schemaKey] ?? schema.description ?? '').trim()
const normalizedDesc = normalize(rawDescription)
const description =
rawDescription && normalizedDesc !== normalize(label) && normalizedDesc !== normalize(schemaKey)
? rawDescription
: undefined
const row = (action: ReactNode, wide = false) => (
<ListRow action={action} description={description} title={label} wide={wide} />
)
if (schema.type === 'boolean') {
return row(
<div className="flex items-center justify-end gap-3">
<span className="text-xs text-muted-foreground">{value ? 'On' : 'Off'}</span>
<Switch checked={Boolean(value)} onCheckedChange={onChange} />
</div>
)
}
const selectOptions = enumOptions ?? (schema.type === 'select' ? (schema.options ?? []).map(String) : undefined)
if (selectOptions) {
return row(
<Select
onValueChange={next => onChange(next === EMPTY_SELECT_VALUE ? '' : next)}
value={String(value ?? '') || EMPTY_SELECT_VALUE}
>
<SelectTrigger className={CONTROL_TEXT}>
<SelectValue />
</SelectTrigger>
<SelectContent>
{selectOptions.map(option => (
<SelectItem key={option || EMPTY_SELECT_VALUE} value={option || EMPTY_SELECT_VALUE}>
{option ? (optionLabels?.[option] ?? prettyName(option)) : schemaKey === 'display.personality' ? 'None' : '(none)'}
</SelectItem>
))}
</SelectContent>
</Select>
)
}
if (schema.type === 'number') {
return row(
<Input
className={cn('h-8', CONTROL_TEXT)}
onChange={e => {
const raw = e.target.value
const n = raw === '' ? 0 : Number(raw)
if (!Number.isNaN(n)) {
onChange(n)
}
}}
placeholder="Not set"
type="number"
value={value === undefined || value === null ? '' : String(value)}
/>
)
}
if (schema.type === 'list') {
return row(
<Input
className={cn('h-8', CONTROL_TEXT)}
onChange={e =>
onChange(
e.target.value
.split(',')
.map(s => s.trim())
.filter(Boolean)
)
}
placeholder="comma-separated values"
value={Array.isArray(value) ? value.join(', ') : String(value ?? '')}
/>
)
}
if (typeof value === 'object' && value !== null) {
return row(
<Textarea
className={cn('min-h-28 resize-y bg-background font-mono', CONTROL_TEXT)}
onChange={e => {
try {
onChange(JSON.parse(e.target.value))
} catch {
/* keep last valid */
}
}}
placeholder="Not set"
spellCheck={false}
value={JSON.stringify(value, null, 2)}
/>,
true
)
}
const isLong = schema.type === 'text' || String(value ?? '').length > 100
return row(
isLong ? (
<Textarea
className={cn('min-h-24 resize-y bg-background', CONTROL_TEXT)}
onChange={e => onChange(e.target.value)}
placeholder="Not set"
value={String(value ?? '')}
/>
) : (
<Input
className={cn('h-8', CONTROL_TEXT)}
onChange={e => onChange(e.target.value)}
placeholder="Not set"
value={String(value ?? '')}
/>
),
isLong
)
}
export function ConfigSettings({
query,
activeSectionId,
onConfigSaved,
importInputRef
}: SearchProps & {
activeSectionId: string
onConfigSaved?: () => void
importInputRef: React.RefObject<HTMLInputElement | null>
}) {
const [config, setConfig] = useState<HermesConfigRecord | null>(null)
const [_defaults, setDefaults] = useState<HermesConfigRecord | null>(null)
const [schema, setSchema] = useState<Record<string, ConfigFieldSchema> | null>(null)
const [elevenLabsVoiceOptions, setElevenLabsVoiceOptions] = useState<string[] | null>(null)
const [elevenLabsVoiceLabels, setElevenLabsVoiceLabels] = useState<Record<string, string>>({})
const saveVersionRef = useRef(0)
const [saveVersion, setSaveVersion] = useState(0)
useEffect(() => {
let cancelled = false
Promise.all([getHermesConfigRecord(), getHermesConfigDefaults(), getHermesConfigSchema()])
.then(([c, d, s]) => {
if (cancelled) {
return
}
setConfig(c)
setDefaults(d)
setSchema(s.fields)
})
.catch(err => notifyError(err, 'Settings failed to load'))
return () => void (cancelled = true)
}, [])
useEffect(() => {
let cancelled = false
getElevenLabsVoices()
.then(result => {
if (cancelled || !result.available) {
return
}
setElevenLabsVoiceOptions(result.voices.map(voice => voice.voice_id))
setElevenLabsVoiceLabels(Object.fromEntries(result.voices.map(voice => [voice.voice_id, voice.label])))
})
.catch(() => {
if (!cancelled) {
setElevenLabsVoiceOptions(null)
setElevenLabsVoiceLabels({})
}
})
return () => void (cancelled = true)
}, [])
useEffect(() => {
if (!config || saveVersion === 0) {
return
}
const v = saveVersion
const t = window.setTimeout(() => {
void (async () => {
try {
await saveHermesConfig(config)
if (saveVersionRef.current === v) {
onConfigSaved?.()
}
} catch (err) {
if (saveVersionRef.current === v) {
notifyError(err, 'Autosave failed')
}
}
})()
}, 550)
return () => window.clearTimeout(t)
}, [config, onConfigSaved, saveVersion])
const updateConfig = (next: HermesConfigRecord) => {
saveVersionRef.current += 1
setConfig(next)
setSaveVersion(saveVersionRef.current)
}
const sectionFields = useMemo(() => {
if (!schema) {
return new Map<string, [string, ConfigFieldSchema][]>()
}
return new Map(
SECTIONS.map(s => [s.id, s.keys.flatMap(k => (schema[k] ? [[k, schema[k]] as [string, ConfigFieldSchema]] : []))])
)
}, [schema])
const matched = useMemo(() => {
const q = query.trim().toLowerCase()
if (!schema || !q) {
return []
}
const seen = new Set<string>()
return SECTIONS.flatMap(s =>
s.keys.flatMap(k => {
if (seen.has(k) || !schema[k]) {
return []
}
seen.add(k)
const label = prettyName(k.split('.').pop() ?? k)
const item = schema[k]
const hit =
k.toLowerCase().includes(q) ||
label.toLowerCase().includes(q) ||
includesQuery(item.category, q) ||
includesQuery(item.description, q)
return hit ? [[k, item] as [string, ConfigFieldSchema]] : []
})
)
}, [schema, query])
const fields = query.trim() ? matched : (sectionFields.get(activeSectionId) ?? [])
function handleImport(e: ChangeEvent<HTMLInputElement>) {
const file = e.target.files?.[0]
if (!file) {
return
}
const reader = new FileReader()
reader.onload = () => {
try {
updateConfig(JSON.parse(String(reader.result)))
notify({ kind: 'success', title: 'Config imported', message: 'Saving…' })
} catch (err) {
notifyError(err, 'Invalid config JSON')
}
}
reader.readAsText(file)
e.target.value = ''
}
if (!config || !schema) {
return <LoadingState label="Loading Hermes configuration..." />
}
return (
<SettingsContent>
{query.trim() && (
<div className="mb-4 text-xs text-muted-foreground">
{fields.length} result{fields.length === 1 ? '' : 's'}
</div>
)}
{fields.length === 0 ? (
<EmptyState description="Try a different search term or choose another section." title="No matching settings" />
) : (
<div className="divide-y divide-border/40">
{fields.map(([key, field]) => (
<ConfigField
enumOptions={
key === 'tts.elevenlabs.voice_id'
? enumOptionsFor(key, getNested(config, key), config, elevenLabsVoiceOptions ?? undefined)
: enumOptionsFor(key, getNested(config, key), config)
}
key={key}
onChange={value => updateConfig(setNested(config, key, value))}
optionLabels={key === 'tts.elevenlabs.voice_id' ? elevenLabsVoiceLabels : undefined}
schema={field}
schemaKey={key}
value={getNested(config, key)}
/>
))}
</div>
)}
<input
accept=".json,application/json"
className="hidden"
onChange={handleImport}
ref={importInputRef}
type="file"
/>
</SettingsContent>
)
}
+297
View File
@@ -0,0 +1,297 @@
import { Brain, Lock, type LucideIcon, MessageCircle, Mic, Monitor, Moon, Palette, Sparkles, Sun, Wrench } from 'lucide-react'
import type { ThemeMode } from '@/themes/context'
import type { DesktopConfigSection } from './types'
interface ProviderPrefix {
prefix: string
name: string
priority: number
}
export const EMPTY_SELECT_VALUE = '__hermes_empty__'
export const CONTROL_TEXT = 'text-[0.8125rem]'
export const PROVIDER_GROUPS: ProviderPrefix[] = [
{ prefix: 'NOUS_', name: 'Nous Portal', priority: 0 },
{ prefix: 'ANTHROPIC_', name: 'Anthropic', priority: 1 },
{ prefix: 'DASHSCOPE_', name: 'DashScope (Qwen)', priority: 2 },
{ prefix: 'HERMES_QWEN_', name: 'DashScope (Qwen)', priority: 2 },
{ prefix: 'DEEPSEEK_', name: 'DeepSeek', priority: 3 },
{ prefix: 'GOOGLE_', name: 'Gemini', priority: 4 },
{ prefix: 'GEMINI_', name: 'Gemini', priority: 4 },
{ prefix: 'GLM_', name: 'GLM / Z.AI', priority: 5 },
{ prefix: 'ZAI_', name: 'GLM / Z.AI', priority: 5 },
{ prefix: 'Z_AI_', name: 'GLM / Z.AI', priority: 5 },
{ prefix: 'HF_', name: 'Hugging Face', priority: 6 },
{ prefix: 'KIMI_', name: 'Kimi / Moonshot', priority: 7 },
{ prefix: 'MINIMAX_', name: 'MiniMax', priority: 8 },
{ prefix: 'MINIMAX_CN_', name: 'MiniMax (China)', priority: 9 },
{ prefix: 'OPENCODE_GO_', name: 'OpenCode Go', priority: 10 },
{ prefix: 'OPENCODE_ZEN_', name: 'OpenCode Zen', priority: 11 },
{ prefix: 'OPENROUTER_', name: 'OpenRouter', priority: 12 },
{ prefix: 'XIAOMI_', name: 'Xiaomi MiMo', priority: 13 }
]
export const BUILTIN_PERSONALITIES = [
'helpful',
'concise',
'technical',
'creative',
'teacher',
'kawaii',
'catgirl',
'pirate',
'shakespeare',
'surfer',
'noir',
'uwu',
'philosopher',
'hype'
]
// Schema-side select overrides for desktop-relevant enum fields whose
// backend schema only declares a string type.
export const ENUM_OPTIONS: Record<string, string[]> = {
'agent.image_input_mode': ['auto', 'native', 'text'],
'approvals.mode': ['manual', 'smart', 'off'],
'code_execution.mode': ['project', 'strict'],
'context.engine': ['compressor', 'default', 'custom'],
'delegation.reasoning_effort': ['', 'minimal', 'low', 'medium', 'high', 'xhigh'],
'memory.provider': ['', 'builtin', 'honcho'],
'stt.local.model': ['tiny', 'base', 'small', 'medium', 'large-v3'],
'tts.openai.voice': ['alloy', 'echo', 'fable', 'onyx', 'nova', 'shimmer']
}
export const FIELD_LABELS: Record<string, string> = {
model: 'Default Model',
model_context_length: 'Context Window',
fallback_providers: 'Fallback Models',
toolsets: 'Enabled Toolsets',
timezone: 'Timezone',
'display.personality': 'Personality',
'display.show_reasoning': 'Reasoning Blocks',
'agent.max_turns': 'Max Agent Steps',
'agent.image_input_mode': 'Image Attachments',
'terminal.cwd': 'Working Directory',
'terminal.backend': 'Execution Backend',
'terminal.timeout': 'Command Timeout',
'terminal.persistent_shell': 'Persistent Shell',
'terminal.env_passthrough': 'Environment Passthrough',
file_read_max_chars: 'File Read Limit',
'tool_output.max_bytes': 'Terminal Output Limit',
'tool_output.max_lines': 'File Page Limit',
'tool_output.max_line_length': 'Line Length Limit',
'code_execution.mode': 'Code Execution Mode',
'approvals.mode': 'Approval Mode',
'approvals.timeout': 'Approval Timeout',
'approvals.mcp_reload_confirm': 'Confirm MCP Reloads',
command_allowlist: 'Command Allowlist',
'security.redact_secrets': 'Redact Secrets',
'security.allow_private_urls': 'Allow Private URLs',
'browser.allow_private_urls': 'Browser Private URLs',
'browser.auto_local_for_private_urls': 'Local Browser For Private URLs',
'checkpoints.enabled': 'File Checkpoints',
'checkpoints.max_snapshots': 'Checkpoint Limit',
'voice.record_key': 'Voice Shortcut',
'voice.max_recording_seconds': 'Max Recording Length',
'voice.auto_tts': 'Read Responses Aloud',
'stt.enabled': 'Speech To Text',
'stt.provider': 'Speech-To-Text Provider',
'stt.local.model': 'Local Transcription Model',
'stt.local.language': 'Transcription Language',
'tts.provider': 'Text-To-Speech Provider',
'tts.edge.voice': 'Edge Voice',
'tts.openai.model': 'OpenAI TTS Model',
'tts.openai.voice': 'OpenAI Voice',
'tts.elevenlabs.voice_id': 'ElevenLabs Voice',
'tts.elevenlabs.model_id': 'ElevenLabs Model',
'memory.memory_enabled': 'Persistent Memory',
'memory.user_profile_enabled': 'User Profile',
'memory.memory_char_limit': 'Memory Budget',
'memory.user_char_limit': 'Profile Budget',
'memory.provider': 'Memory Provider',
'context.engine': 'Context Engine',
'compression.enabled': 'Auto-Compression',
'compression.threshold': 'Compression Threshold',
'compression.target_ratio': 'Compression Target',
'compression.protect_last_n': 'Protected Recent Messages',
'agent.api_max_retries': 'API Retries',
'agent.service_tier': 'Service Tier',
'agent.tool_use_enforcement': 'Tool-Use Enforcement',
'delegation.model': 'Subagent Model',
'delegation.provider': 'Subagent Provider',
'delegation.max_iterations': 'Subagent Turn Limit',
'delegation.max_concurrent_children': 'Parallel Subagents',
'delegation.child_timeout_seconds': 'Subagent Timeout',
'delegation.reasoning_effort': 'Subagent Reasoning Effort',
'auxiliary.vision.provider': 'Vision Provider',
'auxiliary.vision.model': 'Vision Model',
'auxiliary.compression.provider': 'Compression Provider',
'auxiliary.compression.model': 'Compression Model',
'auxiliary.title_generation.provider': 'Title Provider',
'auxiliary.title_generation.model': 'Title Model'
}
export const FIELD_DESCRIPTIONS: Record<string, string> = {
model: 'Used for new chats unless you pick a different model in the composer.',
model_context_length: "Leave at 0 to use the selected model's detected context window.",
fallback_providers: 'Backup provider:model entries to try if the default model fails.',
'display.personality': 'Default assistant style for new sessions.',
timezone: 'Used when Hermes needs local time context. Blank uses the system timezone.',
'display.show_reasoning': 'Show reasoning sections when the backend provides them.',
'agent.image_input_mode': 'Controls how image attachments are sent to the model.',
'terminal.cwd': 'Default project folder for tool and terminal work.',
'code_execution.mode': 'How strictly code execution is scoped to the current project.',
'terminal.persistent_shell': 'Keep shell state between commands when the backend supports it.',
'terminal.env_passthrough': 'Environment variables to pass into tool execution.',
file_read_max_chars: 'Maximum characters Hermes can read from one file request.',
'approvals.mode': 'How Hermes handles commands that need explicit approval.',
'approvals.timeout': 'How long approval prompts wait before timing out.',
'security.redact_secrets': 'Hide detected secrets from model-visible content when possible.',
'checkpoints.enabled': 'Create rollback snapshots before file edits.',
'memory.memory_enabled': 'Save durable memories that can help future sessions.',
'memory.user_profile_enabled': 'Maintain a compact profile of user preferences.',
'context.engine': 'Strategy for managing long conversations near the context limit.',
'compression.enabled': 'Summarize older context when conversations get large.',
'voice.auto_tts': 'Automatically speak assistant responses.',
'stt.enabled': 'Enable local or provider-backed speech transcription.',
'agent.max_turns': 'Upper bound for tool-calling turns before Hermes stops a run.'
}
// Curated desktop config surface: only fields a user might tune from the app.
export const SECTIONS: DesktopConfigSection[] = [
{
id: 'model',
label: 'Model',
icon: Sparkles,
keys: ['model', 'model_context_length', 'fallback_providers']
},
{
id: 'chat',
label: 'Chat',
icon: MessageCircle,
keys: ['display.personality', 'timezone', 'display.show_reasoning', 'agent.image_input_mode']
},
{
id: 'appearance',
label: 'Appearance',
icon: Palette,
keys: []
},
{
id: 'workspace',
label: 'Workspace',
icon: Monitor,
keys: [
'terminal.cwd',
'code_execution.mode',
'terminal.persistent_shell',
'terminal.env_passthrough',
'file_read_max_chars'
]
},
{
id: 'safety',
label: 'Safety',
icon: Lock,
keys: [
'approvals.mode',
'approvals.timeout',
'approvals.mcp_reload_confirm',
'command_allowlist',
'security.redact_secrets',
'security.allow_private_urls',
'browser.allow_private_urls',
'browser.auto_local_for_private_urls',
'checkpoints.enabled'
]
},
{
id: 'memory',
label: 'Memory & Context',
icon: Brain,
keys: [
'memory.memory_enabled',
'memory.user_profile_enabled',
'memory.memory_char_limit',
'memory.user_char_limit',
'memory.provider',
'context.engine',
'compression.enabled',
'compression.threshold',
'compression.target_ratio',
'compression.protect_last_n'
]
},
{
id: 'voice',
label: 'Voice',
icon: Mic,
keys: [
'tts.provider',
'stt.enabled',
'stt.provider',
'voice.auto_tts',
'tts.edge.voice',
'tts.openai.model',
'tts.openai.voice',
'tts.elevenlabs.voice_id',
'tts.elevenlabs.model_id',
'stt.local.model',
'stt.local.language',
'voice.record_key',
'voice.max_recording_seconds'
]
},
{
id: 'advanced',
label: 'Advanced',
icon: Wrench,
keys: [
'toolsets',
'terminal.backend',
'terminal.timeout',
'tool_output.max_bytes',
'tool_output.max_lines',
'tool_output.max_line_length',
'checkpoints.max_snapshots',
'agent.max_turns',
'agent.api_max_retries',
'agent.service_tier',
'agent.tool_use_enforcement',
'delegation.model',
'delegation.provider',
'delegation.max_iterations',
'delegation.max_concurrent_children',
'delegation.child_timeout_seconds',
'delegation.reasoning_effort',
'auxiliary.vision.provider',
'auxiliary.vision.model',
'auxiliary.compression.provider',
'auxiliary.compression.model',
'auxiliary.title_generation.provider',
'auxiliary.title_generation.model'
]
}
]
export interface ModeOption {
id: ThemeMode
label: string
description: string
icon: LucideIcon
}
export const MODE_OPTIONS: ModeOption[] = [
{ id: 'light', label: 'Light', description: 'Bright desktop surfaces', icon: Sun },
{ id: 'dark', label: 'Dark', description: 'Low-glare workspace', icon: Moon },
{ id: 'system', label: 'System', description: 'Follow macOS appearance', icon: Monitor }
]
export const SEARCH_PLACEHOLDER: Record<'config' | 'keys' | 'tools', string> = {
config: 'Search settings...',
keys: 'Search API keys...',
tools: 'Search skills and tools...'
}
+84
View File
@@ -0,0 +1,84 @@
import type { HermesConfigRecord, ToolsetInfo } from '@/types/hermes'
import { BUILTIN_PERSONALITIES, ENUM_OPTIONS, PROVIDER_GROUPS } from './constants'
export const asText = (v: unknown): string => (typeof v === 'string' ? v : v == null ? '' : String(v))
export const includesQuery = (v: unknown, q: string) => asText(v).toLowerCase().includes(q)
export const prettyName = (v: string) => v.replace(/_/g, ' ').replace(/\b\w/g, c => c.toUpperCase())
export const toolNames = (t: ToolsetInfo) => (Array.isArray(t.tools) ? t.tools.map(asText).filter(Boolean) : [])
export const withoutKey = <T,>(record: Record<string, T>, key: string) => {
const next = { ...record }
delete next[key]
return next
}
export const redactedValue = (v: string) => (v.length <= 8 ? '••••' : `${v.slice(0, 4)}...${v.slice(-4)}`)
export const providerGroup = (key: string) => PROVIDER_GROUPS.find(g => key.startsWith(g.prefix))?.name ?? 'Other'
export const providerPriority = (name: string) => PROVIDER_GROUPS.find(g => g.name === name)?.priority ?? 99
export function getNested(obj: HermesConfigRecord, path: string): unknown {
let cur: unknown = obj
for (const part of path.split('.')) {
if (cur == null || typeof cur !== 'object') {
return undefined
}
cur = (cur as Record<string, unknown>)[part]
}
return cur
}
export function setNested(obj: HermesConfigRecord, path: string, value: unknown): HermesConfigRecord {
const clone = structuredClone(obj)
const parts = path.split('.')
let cur: Record<string, unknown> = clone
for (let i = 0; i < parts.length - 1; i += 1) {
const part = parts[i]
if (cur[part] == null || typeof cur[part] !== 'object') {
cur[part] = {}
}
cur = cur[part] as Record<string, unknown>
}
cur[parts[parts.length - 1]] = value
return clone
}
function personalityOptions(config: HermesConfigRecord): string[] {
const custom = getNested(config, 'agent.personalities')
const customNames =
custom && typeof custom === 'object' && !Array.isArray(custom) ? Object.keys(custom as Record<string, unknown>) : []
return [...new Set(['', ...BUILTIN_PERSONALITIES, ...customNames])]
}
export function enumOptionsFor(
key: string,
value: unknown,
config: HermesConfigRecord,
dynamicOptions?: string[]
): string[] | undefined {
const opts = dynamicOptions ?? (key === 'display.personality' ? personalityOptions(config) : ENUM_OPTIONS[key])
if (!opts) {
return undefined
}
const current = asText(value)
return current && !opts.includes(current) ? [...opts, current] : opts
}
+212 -4
View File
@@ -1,7 +1,215 @@
import type * as React from 'react'
import { Download, KeyRound, Package, RotateCcw, Search, Upload, X } from 'lucide-react'
import { useEffect, useRef, useState } from 'react'
import { SettingsPage } from '@/components/settings-page'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { getHermesConfigDefaults, getHermesConfigRecord, saveHermesConfig } from '@/hermes'
import { triggerHaptic } from '@/lib/haptics'
import { notifyError } from '@/store/notifications'
export function SettingsView(props: React.ComponentProps<typeof SettingsPage>) {
return <SettingsPage {...props} />
import { AppearanceSettings } from './appearance-settings'
import { ConfigSettings } from './config-settings'
import { SEARCH_PLACEHOLDER, SECTIONS } from './constants'
import { KeysSettings } from './keys-settings'
import { NavLink } from './primitives'
import { ToolsSettings } from './tools-settings'
import type { SettingsPageProps, SettingsQueryKey, SettingsView as SettingsViewId } from './types'
export function SettingsView({ onClose, onConfigSaved }: SettingsPageProps) {
const [activeView, setActiveView] = useState<SettingsViewId>('config:model')
const [queries, setQueries] = useState<Record<SettingsQueryKey, string>>({
config: '',
keys: '',
tools: ''
})
const searchInputRef = useRef<HTMLInputElement>(null)
const importInputRef = useRef<HTMLInputElement | null>(null)
const queryKey: SettingsQueryKey = activeView.startsWith('config:') ? 'config' : (activeView as SettingsQueryKey)
const query = queries[queryKey]
const setQuery = (next: string) => setQueries(c => ({ ...c, [queryKey]: next }))
const exportConfig = async () => {
try {
const cfg = await getHermesConfigRecord()
const blob = new Blob([JSON.stringify(cfg, null, 2)], { type: 'application/json' })
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = 'hermes-config.json'
a.click()
URL.revokeObjectURL(url)
triggerHaptic('success')
} catch (err) {
notifyError(err, 'Export failed')
}
}
const resetConfig = async () => {
if (!window.confirm('Reset all settings to Hermes defaults?')) {
return
}
try {
await saveHermesConfig(await getHermesConfigDefaults())
triggerHaptic('success')
onConfigSaved?.()
} catch (err) {
notifyError(err, 'Reset failed')
}
}
useEffect(() => {
const onKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Escape') {
e.preventDefault()
triggerHaptic('close')
onClose()
return
}
if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === 'p') {
e.preventDefault()
searchInputRef.current?.focus()
searchInputRef.current?.select()
}
}
window.addEventListener('keydown', onKeyDown)
return () => window.removeEventListener('keydown', onKeyDown)
}, [onClose])
return (
<div className="fixed inset-0 z-60 flex min-h-0 flex-col bg-background/98 p-0.75 backdrop-blur-xl">
<div className="pointer-events-none fixed inset-x-0 top-0 z-10 h-[calc(var(--titlebar-height)+0.1875rem)] [-webkit-app-region:drag]">
<div className="pointer-events-auto absolute left-1/2 top-[calc(1rem+var(--titlebar-height)/2)] w-[min(36rem,calc(100vw-32rem))] min-w-80 -translate-x-1/2 -translate-y-1/2 [-webkit-app-region:no-drag]">
<Search className="pointer-events-none absolute left-3 top-1/2 size-3.5 -translate-y-1/2 text-muted-foreground/80" />
<Input
className="h-9 rounded-full border-transparent bg-background py-2 pl-8 pr-20 text-sm shadow-header focus-visible:bg-background"
onChange={e => setQuery(e.target.value)}
placeholder={SEARCH_PLACEHOLDER[queryKey]}
ref={searchInputRef}
value={query}
/>
{query ? (
<Button
aria-label="Clear search"
className="absolute right-1.5 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
onClick={() => setQuery('')}
size="icon-xs"
variant="ghost"
>
<X className="size-3.5" />
</Button>
) : (
<span className="pointer-events-none absolute right-2 top-1/2 -translate-y-1/2 rounded-md bg-background/80 px-1.5 py-0.5 text-[0.62rem] leading-none text-muted-foreground shadow-xs">
Cmd P
</span>
)}
</div>
<Button
aria-label="Close settings"
className="pointer-events-auto absolute right-3.75 top-[calc(0.1875rem+var(--titlebar-height)/2)] h-7 w-7 -translate-y-1/2 rounded-lg text-muted-foreground hover:bg-accent/70 hover:text-foreground [-webkit-app-region:no-drag]"
onClick={() => {
triggerHaptic('close')
onClose()
}}
size="icon"
variant="ghost"
>
<X size={16} />
</Button>
</div>
<div className="grid min-h-0 flex-1 grid-cols-[13rem_minmax(0,1fr)] rounded-[1.0625rem] bg-background/90 pt-(--titlebar-height) max-[760px]:grid-cols-1">
<aside className="flex min-h-0 flex-col gap-0.5 overflow-y-auto bg-muted/20 px-4 py-5">
{SECTIONS.map(s => {
const view = `config:${s.id}` as SettingsViewId
return (
<NavLink
active={activeView === view && !queries.config.trim()}
icon={s.icon}
key={s.id}
label={s.label}
onClick={() => setActiveView(view)}
/>
)
})}
<div className="my-2 h-px bg-border/30" />
<NavLink
active={activeView === 'keys'}
icon={KeyRound}
label="API Keys"
onClick={() => setActiveView('keys')}
/>
<NavLink
active={activeView === 'tools'}
icon={Package}
label="Skills & Tools"
onClick={() => setActiveView('tools')}
/>
<div className="mt-auto flex items-center gap-1 pt-2">
<Button
className="text-muted-foreground"
onClick={() => void exportConfig()}
size="icon-xs"
title="Export config"
variant="ghost"
>
<Download />
</Button>
<Button
className="text-muted-foreground"
onClick={() => {
triggerHaptic('open')
importInputRef.current?.click()
}}
size="icon-xs"
title="Import config"
variant="ghost"
>
<Upload />
</Button>
<Button
className="text-muted-foreground"
onClick={() => {
triggerHaptic('warning')
void resetConfig()
}}
size="icon-xs"
title="Reset to defaults"
variant="ghost"
>
<RotateCcw />
</Button>
</div>
</aside>
<main className="flex min-h-0 flex-1 flex-col overflow-hidden">
{activeView === 'config:appearance' ? (
<AppearanceSettings />
) : activeView.startsWith('config:') ? (
<ConfigSettings
activeSectionId={activeView.slice('config:'.length)}
importInputRef={importInputRef}
onConfigSaved={onConfigSaved}
query={queries.config}
/>
) : activeView === 'keys' ? (
<KeysSettings query={queries.keys} />
) : (
<ToolsSettings query={queries.tools} />
)}
</main>
</div>
</div>
)
}
export { SettingsView as SettingsPage }
@@ -0,0 +1,420 @@
import { Check, Eye, EyeOff, Save, Settings2, Trash2, X, Zap } from 'lucide-react'
import { useCallback, useEffect, useMemo, useState } from 'react'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { deleteEnvVar, getEnvVars, revealEnvVar, setEnvVar } from '@/hermes'
import { cn } from '@/lib/utils'
import { notify, notifyError } from '@/store/notifications'
import type { EnvVarInfo } from '@/types/hermes'
import { CONTROL_TEXT } from './constants'
import { asText, includesQuery, prettyName, providerGroup, providerPriority, redactedValue, withoutKey } from './helpers'
import { LoadingState, Pill, SectionHeading, SettingsContent } from './primitives'
import type { EnvPatch, EnvRowProps, ProviderGroup, SearchProps } from './types'
interface EnvActionsProps {
varKey: string
info: EnvVarInfo
saving: string | null
onEdit: () => void
onClear: (key: string) => void
onReveal: (key: string) => void
isRevealed: boolean
showReveal?: boolean
}
function EnvActions({
varKey,
info,
saving,
onEdit,
onClear,
onReveal,
isRevealed,
showReveal = true
}: EnvActionsProps) {
return (
<div className="flex shrink-0 items-center gap-1.5">
{info.url && (
<Button asChild size="xs" title="Open provider docs" variant="ghost">
<a href={info.url} rel="noreferrer" target="_blank">
Docs
</a>
</Button>
)}
{info.is_set && showReveal && (
<Button
onClick={() => onReveal(varKey)}
size="icon-xs"
title={isRevealed ? 'Hide value' : 'Reveal value'}
variant="ghost"
>
{isRevealed ? <EyeOff /> : <Eye />}
</Button>
)}
<Button onClick={onEdit} size="xs" variant="outline">
{info.is_set ? 'Replace' : 'Set'}
</Button>
{info.is_set && (
<Button
disabled={saving === varKey}
onClick={() => onClear(varKey)}
size="icon-xs"
title="Clear value"
variant="ghost"
>
<Trash2 />
</Button>
)}
</div>
)
}
function EnvVarRow({
varKey,
info,
edits,
revealed,
saving,
setEdits,
onSave,
onClear,
onReveal,
compact = false
}: EnvRowProps) {
const isEditing = edits[varKey] !== undefined
const isRevealed = revealed[varKey] !== undefined
const value = isRevealed ? revealed[varKey] : info.redacted_value
const startEdit = () => setEdits(c => ({ ...c, [varKey]: '' }))
if (compact && !isEditing) {
return (
<div className="flex items-center justify-between gap-3 py-1.5">
<div className="min-w-0">
<div className="truncate font-mono text-[0.72rem] text-muted-foreground">{varKey}</div>
<div className="truncate text-[0.68rem] text-muted-foreground/70">{info.description}</div>
</div>
<EnvActions
info={info}
isRevealed={isRevealed}
onClear={onClear}
onEdit={startEdit}
onReveal={onReveal}
saving={saving}
showReveal={false}
varKey={varKey}
/>
</div>
)
}
return (
<div className="grid gap-2 rounded-xl bg-background/55 p-3">
<div className="flex flex-wrap items-start justify-between gap-3">
<div className="min-w-0">
<div className="flex flex-wrap items-center gap-2">
<span className="font-mono text-xs font-medium">{varKey}</span>
<Pill tone={info.is_set ? 'primary' : 'muted'}>
{info.is_set && <Check className="size-3" />}
{info.is_set ? 'Set' : 'Not set'}
</Pill>
</div>
<p className="mt-1 text-xs leading-5 text-muted-foreground">{info.description}</p>
</div>
<EnvActions
info={info}
isRevealed={isRevealed}
onClear={onClear}
onEdit={startEdit}
onReveal={onReveal}
saving={saving}
varKey={varKey}
/>
</div>
{!isEditing && info.is_set && (
<div
className={cn(
'rounded-md px-3 py-2 font-mono text-xs',
isRevealed ? 'bg-background text-foreground' : 'bg-muted/30 text-muted-foreground'
)}
>
{value || '---'}
</div>
)}
{isEditing && (
<div className="flex flex-wrap items-center gap-2">
<Input
autoFocus
className={cn('min-w-56 flex-1 font-mono', CONTROL_TEXT)}
onChange={e => setEdits(c => ({ ...c, [varKey]: e.target.value }))}
placeholder={info.is_set ? 'Replace current value' : 'Enter value'}
type={info.is_password ? 'password' : 'text'}
value={edits[varKey]}
/>
<Button disabled={saving === varKey || !edits[varKey]} onClick={() => onSave(varKey)} size="sm">
<Save />
{saving === varKey ? 'Saving' : 'Save'}
</Button>
<Button onClick={() => setEdits(c => withoutKey(c, varKey))} size="sm" variant="outline">
<X />
Cancel
</Button>
</div>
)}
</div>
)
}
function EnvProviderGroup({
group,
rowProps
}: {
group: ProviderGroup
rowProps: Omit<EnvRowProps, 'varKey' | 'info'>
}) {
const [expanded, setExpanded] = useState(false)
const setCount = group.entries.filter(([, info]) => info.is_set).length
return (
<div className="overflow-hidden rounded-xl bg-background/60">
<button
className="flex w-full items-center justify-between gap-3 bg-transparent px-3 py-2.5 text-left hover:bg-accent/50"
onClick={() => setExpanded(e => !e)}
type="button"
>
<span className="flex min-w-0 items-center gap-2">
<Zap className="size-4 shrink-0 text-muted-foreground" />
<span className="truncate text-sm font-medium">
{group.name === 'Other' ? 'Other providers' : group.name}
</span>
{setCount > 0 && <Pill tone="primary">{setCount} set</Pill>}
</span>
<span className="text-xs text-muted-foreground">{group.entries.length} keys</span>
</button>
{expanded && (
<div className="grid gap-2 bg-muted/20 p-3">
{group.entries.map(([key, info]) => (
<EnvVarRow compact={!info.is_set} info={info} key={key} varKey={key} {...rowProps} />
))}
</div>
)}
</div>
)
}
export function KeysSettings({ query }: SearchProps) {
const [vars, setVars] = useState<Record<string, EnvVarInfo> | null>(null)
const [edits, setEdits] = useState<Record<string, string>>({})
const [revealed, setRevealed] = useState<Record<string, string>>({})
const [saving, setSaving] = useState<string | null>(null)
const [showAdvanced, setShowAdvanced] = useState(true)
useEffect(() => {
let cancelled = false
void (async () => {
try {
const next = await getEnvVars()
if (!cancelled) {
setVars(next)
}
} catch (err) {
notifyError(err, 'API keys failed to load')
}
})()
return () => void (cancelled = true)
}, [])
const filterEnv = useCallback(
(info: EnvVarInfo, key: string, q: string, cat: string, extra?: string) => {
if (asText(info.category) !== cat) {
return false
}
if (!showAdvanced && Boolean(info.advanced)) {
return false
}
if (!q) {
return true
}
return (
key.toLowerCase().includes(q) ||
includesQuery(info.description, q) ||
Boolean(extra && extra.toLowerCase().includes(q))
)
},
[showAdvanced]
)
const providerGroups = useMemo<ProviderGroup[]>(() => {
if (!vars) {
return []
}
const q = query.trim().toLowerCase()
const entries = Object.entries(vars).filter(([key, info]) =>
filterEnv(info, key, q, 'provider', providerGroup(key))
)
const groups = new Map<string, [string, EnvVarInfo][]>()
for (const entry of entries) {
const name = providerGroup(entry[0])
groups.set(name, [...(groups.get(name) ?? []), entry])
}
return Array.from(groups, ([name, entries]) => ({
name,
priority: providerPriority(name),
entries: entries.sort(([a], [b]) => a.localeCompare(b)),
hasAnySet: entries.some(([, info]) => info.is_set)
})).sort((a, b) => a.priority - b.priority || a.name.localeCompare(b.name))
}, [filterEnv, query, vars])
const otherGroups = useMemo(() => {
if (!vars) {
return []
}
const q = query.trim().toLowerCase()
const labels: Record<string, string> = {
tool: 'Tools',
messaging: 'Messaging',
setting: 'Settings'
}
return ['tool', 'messaging', 'setting'].flatMap(cat => {
const entries = Object.entries(vars)
.filter(([key, info]) => filterEnv(info, key, q, cat))
.sort(([a], [b]) => a.localeCompare(b))
return entries.length === 0 ? [] : [{ category: cat, label: labels[cat] ?? prettyName(cat), entries }]
})
}, [filterEnv, query, vars])
function patchVar(key: string, patch: EnvPatch) {
setVars(c => (c ? { ...c, [key]: { ...c[key], ...patch } } : c))
}
function clearLocalState(key: string) {
setEdits(c => withoutKey(c, key))
setRevealed(c => withoutKey(c, key))
}
async function handleSave(key: string) {
const value = edits[key]
if (!value) {
return
}
setSaving(key)
try {
await setEnvVar(key, value)
patchVar(key, { is_set: true, redacted_value: redactedValue(value) })
clearLocalState(key)
notify({ kind: 'success', title: 'Credential saved', message: `${key} updated.` })
} catch (err) {
notifyError(err, `Failed to save ${key}`)
} finally {
setSaving(null)
}
}
async function handleClear(key: string) {
if (!window.confirm(`Remove ${key} from .env?`)) {
return
}
setSaving(key)
try {
await deleteEnvVar(key)
patchVar(key, { is_set: false, redacted_value: null })
clearLocalState(key)
notify({ kind: 'success', title: 'Credential removed', message: `${key} removed.` })
} catch (err) {
notifyError(err, `Failed to remove ${key}`)
} finally {
setSaving(null)
}
}
async function handleReveal(key: string) {
if (revealed[key]) {
setRevealed(c => withoutKey(c, key))
return
}
try {
const result = await revealEnvVar(key)
setRevealed(c => ({ ...c, [key]: result.value }))
} catch (err) {
notifyError(err, `Failed to reveal ${key}`)
}
}
if (!vars) {
return <LoadingState label="Loading API keys and credentials..." />
}
const rowProps = {
edits,
revealed,
saving,
setEdits,
onSave: handleSave,
onClear: handleClear,
onReveal: handleReveal
}
const configuredCount = providerGroups.filter(g => g.hasAnySet).length
return (
<SettingsContent>
<div className="mb-4 flex justify-end">
<Button onClick={() => setShowAdvanced(s => !s)} size="sm" variant="outline">
{showAdvanced ? 'Hide advanced' : 'Show advanced'}
</Button>
</div>
<div className="mb-6">
<SectionHeading
icon={Zap}
meta={`${configuredCount} of ${providerGroups.length} configured`}
title="LLM providers"
/>
<div className="grid gap-2">
{providerGroups.map(group => (
<EnvProviderGroup group={group} key={group.name} rowProps={rowProps} />
))}
</div>
</div>
{otherGroups.map(group => (
<div className="mb-6" key={group.category}>
<SectionHeading
icon={Settings2}
meta={`${group.entries.filter(([, i]) => i.is_set).length} of ${group.entries.length} set`}
title={group.label}
/>
<div className="grid gap-2">
{group.entries.map(([key, info]) => (
<EnvVarRow info={info} key={key} varKey={key} {...rowProps} />
))}
</div>
</div>
))}
</SettingsContent>
)
}
@@ -0,0 +1,115 @@
import type { LucideIcon } from 'lucide-react'
import type { ReactNode } from 'react'
import { PageLoader } from '@/components/page-loader'
import { Button } from '@/components/ui/button'
import { cn } from '@/lib/utils'
export function SettingsContent({ children }: { children: ReactNode }) {
return (
<section className="min-h-0 overflow-hidden">
<div className="h-full min-h-0 overflow-y-auto px-8 py-6 pb-24">
<div className="mx-auto w-full max-w-5xl">{children}</div>
</div>
</section>
)
}
export function Pill({ tone = 'muted', children }: { tone?: 'muted' | 'primary'; children: ReactNode }) {
return (
<span
className={cn(
'inline-flex items-center gap-1 rounded-full px-2 py-0.5 text-[0.66rem]',
tone === 'primary' ? 'bg-primary/10 text-primary' : 'bg-muted text-muted-foreground'
)}
>
{children}
</span>
)
}
export function SectionHeading({ icon: Icon, title, meta }: { icon: LucideIcon; title: string; meta?: string }) {
return (
<div className="mb-3 flex items-center gap-2 pt-3.5 text-sm font-medium">
<Icon className="size-4 text-muted-foreground" />
<span>{title}</span>
{meta && <Pill>{meta}</Pill>}
</div>
)
}
export function NavLink({
icon: Icon,
label,
active,
onClick
}: {
icon: LucideIcon
label: string
active: boolean
onClick: () => void
}) {
return (
<Button
className={cn(
'flex min-h-8 w-full justify-start gap-2 rounded-lg px-2.5 text-left text-sm transition',
active ? 'bg-muted text-foreground' : 'text-foreground/80 hover:bg-muted/70'
)}
onClick={onClick}
size="sm"
type="button"
variant="ghost"
>
<Icon className="size-4 shrink-0" />
<span className="min-w-0 flex-1 truncate">{label}</span>
</Button>
)
}
export function ListRow({
title,
description,
hint,
action,
below,
wide = false
}: {
title: ReactNode
description?: ReactNode
hint?: ReactNode
action?: ReactNode
below?: ReactNode
wide?: boolean
}) {
return (
<div
className={cn(
'grid gap-4 py-3.5 sm:grid-cols-[minmax(0,1fr)_minmax(15rem,22rem)] sm:items-center',
wide && 'sm:grid-cols-1 sm:items-start'
)}
>
<div className="min-w-0">
<div className="text-sm font-medium text-foreground">{title}</div>
{description && <div className="mt-1 text-xs leading-5 text-muted-foreground">{description}</div>}
{hint && <div className="mt-1 block font-mono text-[0.68rem] text-muted-foreground/45">{hint}</div>}
{below}
</div>
{action && <div className={cn('min-w-0', !wide && 'sm:justify-self-end')}>{action}</div>}
</div>
)
}
export function LoadingState({ label }: { label: string }) {
return <PageLoader label={label} />
}
export function EmptyState({ title, description }: { title: string; description: string }) {
return (
<div className="grid min-h-48 place-items-center text-center">
<div>
<div className="text-sm font-medium">{title}</div>
<div className="mt-1 text-xs text-muted-foreground">{description}</div>
</div>
</div>
)
}
@@ -0,0 +1,183 @@
import { Brain, Wrench } from 'lucide-react'
import { useEffect, useMemo, useState } from 'react'
import { Switch } from '@/components/ui/switch'
import { getSkills, getToolsets, toggleSkill } from '@/hermes'
import { notify, notifyError } from '@/store/notifications'
import type { SkillInfo, ToolsetInfo } from '@/types/hermes'
import { asText, includesQuery, prettyName, toolNames } from './helpers'
import { ListRow, LoadingState, Pill, SectionHeading, SettingsContent } from './primitives'
import type { SearchProps } from './types'
export function ToolsSettings({ query }: SearchProps) {
const [skills, setSkills] = useState<SkillInfo[] | null>(null)
const [toolsets, setToolsets] = useState<ToolsetInfo[] | null>(null)
const [savingSkill, setSavingSkill] = useState<string | null>(null)
useEffect(() => {
let cancelled = false
Promise.all([getSkills(), getToolsets()])
.then(([s, t]) => {
if (cancelled) {
return
}
setSkills(s)
setToolsets(t)
})
.catch(err => notifyError(err, 'Capabilities failed to load'))
return () => void (cancelled = true)
}, [])
const filteredSkills = useMemo(() => {
if (!skills) {
return []
}
const q = query.trim().toLowerCase()
return skills
.filter(s => !q || includesQuery(s.name, q) || includesQuery(s.description, q) || includesQuery(s.category, q))
.sort(
(a, b) => asText(a.category).localeCompare(asText(b.category)) || asText(a.name).localeCompare(asText(b.name))
)
}, [query, skills])
const filteredToolsets = useMemo(() => {
if (!toolsets) {
return []
}
const q = query.trim().toLowerCase()
return toolsets
.filter(t => {
if (!q) {
return true
}
return (
includesQuery(t.name, q) ||
includesQuery(t.label, q) ||
includesQuery(t.description, q) ||
toolNames(t).some(n => includesQuery(n, q))
)
})
.sort((a, b) => asText(a.label || a.name).localeCompare(asText(b.label || b.name)))
}, [query, toolsets])
const skillGroups = useMemo(() => {
const groups = new Map<string, SkillInfo[]>()
for (const skill of filteredSkills) {
const cat = asText(skill.category) || 'other'
groups.set(cat, [...(groups.get(cat) ?? []), skill])
}
return Array.from(groups).sort(([a], [b]) => a.localeCompare(b))
}, [filteredSkills])
async function handleToggleSkill(skill: SkillInfo, enabled: boolean) {
setSavingSkill(skill.name)
try {
await toggleSkill(skill.name, enabled)
setSkills(c => c?.map(s => (s.name === skill.name ? { ...s, enabled } : s)) ?? c)
notify({
kind: 'success',
title: enabled ? 'Skill enabled' : 'Skill disabled',
message: `${skill.name} applies to new sessions.`
})
} catch (err) {
notifyError(err, `Failed to update ${skill.name}`)
} finally {
setSavingSkill(null)
}
}
if (!skills || !toolsets) {
return <LoadingState label="Loading skills and toolsets..." />
}
return (
<SettingsContent>
<div className="mb-6">
<SectionHeading icon={Brain} meta={`${filteredSkills.filter(s => s.enabled).length} enabled`} title="Skills" />
{skillGroups.map(([category, list]) => (
<div className="mt-4 first:mt-0" key={category}>
<div className="mb-1 text-[0.68rem] font-semibold uppercase tracking-[0.12em] text-muted-foreground">
{prettyName(category)}
</div>
<div className="divide-y divide-border/40">
{list.map(skill => (
<ListRow
action={
<Switch
checked={skill.enabled}
disabled={savingSkill === skill.name}
onCheckedChange={c => void handleToggleSkill(skill, c)}
/>
}
description={asText(skill.description)}
key={asText(skill.name)}
title={asText(skill.name)}
/>
))}
</div>
</div>
))}
</div>
<div className="mb-6">
<SectionHeading
icon={Wrench}
meta={`${filteredToolsets.filter(t => t.enabled).length} enabled`}
title="Toolsets"
/>
<div className="divide-y divide-border/40">
{filteredToolsets.map(toolset => {
const tools = toolNames(toolset)
const label = asText(toolset.label || toolset.name)
return (
<ListRow
action={
<div className="flex shrink-0 items-center gap-1.5">
<Pill tone={toolset.enabled ? 'primary' : 'muted'}>{toolset.enabled ? 'Enabled' : 'Disabled'}</Pill>
<Pill tone={toolset.configured ? 'primary' : 'muted'}>
{toolset.configured ? 'Configured' : 'Needs keys'}
</Pill>
</div>
}
below={
tools.length > 0 && (
<div className="mt-3 flex flex-wrap gap-1">
{tools.slice(0, 10).map(t => (
<span
className="rounded-md bg-muted px-1.5 py-0.5 font-mono text-[0.64rem] text-muted-foreground"
key={t}
>
{t}
</span>
))}
{tools.length > 10 && (
<span className="rounded-md bg-muted px-1.5 py-0.5 text-[0.64rem] text-muted-foreground">
+{tools.length - 10} more
</span>
)}
</div>
)
}
description={asText(toolset.description)}
key={asText(toolset.name) || label}
title={label}
/>
)
})}
</div>
</div>
</SettingsContent>
)
}
+44
View File
@@ -0,0 +1,44 @@
import type { LucideIcon } from 'lucide-react'
import type { Dispatch, SetStateAction } from 'react'
import type { EnvVarInfo } from '@/types/hermes'
export type SettingsView = 'keys' | 'tools' | `config:${string}`
export type SettingsQueryKey = 'config' | 'keys' | 'tools'
export type EnvPatch = Partial<Pick<EnvVarInfo, 'is_set' | 'redacted_value'>>
export interface SettingsPageProps {
onClose: () => void
onConfigSaved?: () => void
}
export interface SearchProps {
query: string
}
export interface ProviderGroup {
name: string
priority: number
entries: [string, EnvVarInfo][]
hasAnySet: boolean
}
export interface DesktopConfigSection {
id: string
label: string
icon: LucideIcon
keys: string[]
}
export interface EnvRowProps {
varKey: string
info: EnvVarInfo
edits: Record<string, string>
revealed: Record<string, string>
saving: string | null
setEdits: Dispatch<SetStateAction<Record<string, string>>>
onSave: (key: string) => void
onClear: (key: string) => void
onReveal: (key: string) => void
compact?: boolean
}
+5
View File
@@ -3,6 +3,7 @@ import type { CSSProperties, ReactNode, PointerEvent as ReactPointerEvent } from
import { useCallback } from 'react'
import { SidebarProvider } from '@/components/ui/sidebar'
import { triggerHaptic } from '@/lib/haptics'
import { cn } from '@/lib/utils'
import {
$inspectorOpen,
@@ -24,6 +25,7 @@ interface AppShellProps {
rightRailOpen: boolean
settingsOpen: boolean
sidebar: ReactNode
titlebarActions?: ReactNode
onOpenSettings: () => void
overlays?: ReactNode
}
@@ -34,6 +36,7 @@ export function AppShell({
rightRailOpen,
settingsOpen,
sidebar,
titlebarActions,
onOpenSettings,
overlays
}: AppShellProps) {
@@ -66,6 +69,7 @@ export function AppShell({
const handleUp = () => {
setSidebarResizing(false)
triggerHaptic('crisp')
document.body.style.cursor = previousCursor
document.body.style.userSelect = previousUserSelect
window.removeEventListener('pointermove', handleMove)
@@ -93,6 +97,7 @@ export function AppShell({
}
>
<TitlebarControls
leadingActions={titlebarActions}
onOpenSettings={onOpenSettings}
settingsOpen={settingsOpen}
showInspectorToggle={rightRailOpen}
@@ -1,8 +1,11 @@
import { useStore } from '@nanostores/react'
import { NotebookTabs, Search, Settings, SlidersHorizontal } from 'lucide-react'
import { NotebookTabs, Search, Settings, SlidersHorizontal, Volume2, VolumeX } from 'lucide-react'
import type { ReactNode } from 'react'
import type * as React from 'react'
import { triggerHaptic } from '@/lib/haptics'
import { cn } from '@/lib/utils'
import { $hapticsMuted, toggleHapticsMuted } from '@/store/haptics'
import { $inspectorOpen, $sidebarOpen, toggleInspectorOpen, toggleSidebarOpen } from '@/store/layout'
import { TITLEBAR_ICON_SIZE, titlebarButtonClass } from './titlebar'
@@ -10,13 +13,32 @@ import { TITLEBAR_ICON_SIZE, titlebarButtonClass } from './titlebar'
interface TitlebarControlsProps extends React.ComponentProps<'div'> {
settingsOpen: boolean
showInspectorToggle: boolean
leadingActions?: ReactNode
onOpenSettings: () => void
}
export function TitlebarControls({ settingsOpen, showInspectorToggle, onOpenSettings }: TitlebarControlsProps) {
export function TitlebarControls({
settingsOpen,
showInspectorToggle,
leadingActions,
onOpenSettings
}: TitlebarControlsProps) {
const hapticsMuted = useStore($hapticsMuted)
const sidebarOpen = useStore($sidebarOpen)
const inspectorOpen = useStore($inspectorOpen)
const toggleHaptics = () => {
if (!hapticsMuted) {
triggerHaptic('tap')
}
toggleHapticsMuted()
if (hapticsMuted) {
window.requestAnimationFrame(() => triggerHaptic('success'))
}
}
return (
<>
<div
@@ -26,7 +48,10 @@ export function TitlebarControls({ settingsOpen, showInspectorToggle, onOpenSett
<button
aria-label={sidebarOpen ? 'Hide sidebar' : 'Show sidebar'}
className={cn(titlebarButtonClass, 'grid place-items-center bg-transparent [&_svg]:size-3.5')}
onClick={toggleSidebarOpen}
onClick={() => {
triggerHaptic('tap')
toggleSidebarOpen()
}}
onPointerDown={event => event.stopPropagation()}
type="button"
>
@@ -48,11 +73,15 @@ export function TitlebarControls({ settingsOpen, showInspectorToggle, onOpenSett
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]"
>
{leadingActions}
{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}
onClick={() => {
triggerHaptic('tap')
toggleInspectorOpen()
}}
onPointerDown={event => event.stopPropagation()}
title={inspectorOpen ? 'Hide session details' : 'Show session details'}
type="button"
@@ -60,10 +89,28 @@ export function TitlebarControls({ settingsOpen, showInspectorToggle, onOpenSett
<SlidersHorizontal />
</button>
)}
<button
aria-label={hapticsMuted ? 'Unmute haptics' : 'Mute haptics'}
aria-pressed={hapticsMuted}
className={cn(
titlebarButtonClass,
'grid place-items-center bg-transparent [&_svg]:size-3.5',
hapticsMuted && 'bg-muted text-muted-foreground'
)}
onClick={toggleHaptics}
onPointerDown={event => event.stopPropagation()}
title={hapticsMuted ? 'Unmute haptics' : 'Mute haptics'}
type="button"
>
{hapticsMuted ? <VolumeX /> : <Volume2 />}
</button>
<button
aria-label="Open settings"
className={cn(titlebarButtonClass, 'grid place-items-center bg-transparent [&_svg]:size-3.5')}
onClick={onOpenSettings}
onClick={() => {
triggerHaptic('open')
onOpenSettings()
}}
onPointerDown={event => event.stopPropagation()}
title="Settings"
type="button"
+5 -2
View File
@@ -15,8 +15,11 @@ const WINDOW_BUTTON_FALLBACK = {
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 const titlebarHeaderBaseClass =
'relative z-3 flex h-(--titlebar-height) shrink-0 items-center gap-3 bg-background/70 px-3 backdrop-blur-sm'
export const titlebarHeaderShadowClass =
"shadow-header after:pointer-events-none after:absolute after:left-0 after:right-0 after:top-full after:h-10 after:bg-linear-to-b after:from-background after:via-background/80 after:to-transparent after:content-['']"
export function titlebarControlsPosition(windowButtonPosition: HermesConnection['windowButtonPosition'] | undefined) {
const position = windowButtonPosition || WINDOW_BUTTON_FALLBACK
+382 -13
View File
@@ -1,27 +1,396 @@
import { Sparkles } from 'lucide-react'
import type * as React from 'react'
import { Brain, RefreshCw, Search, Wrench, X } from 'lucide-react'
import type { LucideIcon } from 'lucide-react'
import type { ReactNode } from 'react'
import { useCallback, useEffect, useMemo, useState } from 'react'
import { titlebarHeaderClass } from '../shell/titlebar'
import { PageLoader } from '@/components/page-loader'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Switch } from '@/components/ui/switch'
import { getSkills, getToolsets, toggleSkill } from '@/hermes'
import { cn } from '@/lib/utils'
import { notify, notifyError } from '@/store/notifications'
import type { SkillInfo, ToolsetInfo } from '@/types/hermes'
import { asText, includesQuery, prettyName, toolNames } from '../settings/helpers'
import { TITLEBAR_ICON_SIZE, titlebarButtonClass, titlebarHeaderBaseClass } from '../shell/titlebar'
type SkillsMode = 'skills' | 'toolsets'
function categoryFor(skill: SkillInfo): string {
return asText(skill.category) || 'general'
}
function filteredSkills(skills: SkillInfo[], query: string, category: string | null): SkillInfo[] {
const q = query.trim().toLowerCase()
return skills
.filter(skill => {
if (category && categoryFor(skill) !== category) {
return false
}
if (!q) {
return true
}
return (
includesQuery(skill.name, q) ||
includesQuery(skill.description, q) ||
includesQuery(skill.category, q)
)
})
.sort((a, b) => asText(a.name).localeCompare(asText(b.name)))
}
function filteredToolsets(toolsets: ToolsetInfo[], query: string): ToolsetInfo[] {
const q = query.trim().toLowerCase()
return toolsets
.filter(toolset => {
if (!q) {
return true
}
return (
includesQuery(toolset.name, q) ||
includesQuery(toolset.label, q) ||
includesQuery(toolset.description, q) ||
toolNames(toolset).some(name => includesQuery(name, q))
)
})
.sort((a, b) => asText(a.label || a.name).localeCompare(asText(b.label || b.name)))
}
interface SkillsViewProps extends React.ComponentProps<'section'> {
setTitlebarActions?: (actions: ReactNode | null) => void
}
export function SkillsView({ setTitlebarActions, ...props }: SkillsViewProps) {
const [mode, setMode] = useState<SkillsMode>('skills')
const [query, setQuery] = useState('')
const [skills, setSkills] = useState<SkillInfo[] | null>(null)
const [toolsets, setToolsets] = useState<ToolsetInfo[] | null>(null)
const [activeCategory, setActiveCategory] = useState<string | null>(null)
const [refreshing, setRefreshing] = useState(false)
const [savingSkill, setSavingSkill] = useState<string | null>(null)
const refreshCapabilities = useCallback(async () => {
setRefreshing(true)
try {
const [nextSkills, nextToolsets] = await Promise.all([getSkills(), getToolsets()])
setSkills(nextSkills)
setToolsets(nextToolsets)
} catch (err) {
notifyError(err, 'Skills failed to load')
} finally {
setRefreshing(false)
}
}, [])
useEffect(() => {
void refreshCapabilities()
}, [refreshCapabilities])
useEffect(() => {
if (!setTitlebarActions) {
return
}
setTitlebarActions(
<button
aria-label={refreshing ? 'Refreshing skills' : 'Refresh skills'}
className={cn(titlebarButtonClass, 'grid place-items-center bg-transparent')}
disabled={refreshing}
onClick={() => void refreshCapabilities()}
type="button"
>
<RefreshCw className={cn(refreshing && 'animate-spin')} size={TITLEBAR_ICON_SIZE} />
</button>
)
return () => setTitlebarActions(null)
}, [refreshCapabilities, refreshing, setTitlebarActions])
const categories = useMemo(() => {
if (!skills) {
return []
}
const counts = new Map<string, number>()
for (const skill of skills) {
const key = categoryFor(skill)
counts.set(key, (counts.get(key) || 0) + 1)
}
return Array.from(counts.entries())
.sort(([a], [b]) => a.localeCompare(b))
.map(([key, count]) => ({ key, count }))
}, [skills])
const visibleSkills = useMemo(
() => (skills ? filteredSkills(skills, query, mode === 'skills' ? activeCategory : null) : []),
[activeCategory, mode, query, skills]
)
const visibleToolsets = useMemo(() => (toolsets ? filteredToolsets(toolsets, query) : []), [query, toolsets])
const skillGroups = useMemo(() => {
const groups = new Map<string, SkillInfo[]>()
for (const skill of visibleSkills) {
const key = categoryFor(skill)
groups.set(key, [...(groups.get(key) || []), skill])
}
return Array.from(groups.entries()).sort(([a], [b]) => a.localeCompare(b))
}, [visibleSkills])
const totalSkills = skills?.length || 0
const enabledSkills = skills?.filter(skill => skill.enabled).length || 0
const enabledToolsets = toolsets?.filter(toolset => toolset.enabled).length || 0
async function handleToggleSkill(skill: SkillInfo, enabled: boolean) {
setSavingSkill(skill.name)
try {
await toggleSkill(skill.name, enabled)
setSkills(current => current?.map(row => (row.name === skill.name ? { ...row, enabled } : row)) ?? current)
notify({
kind: 'success',
title: enabled ? 'Skill enabled' : 'Skill disabled',
message: `${skill.name} applies to new sessions.`
})
} catch (err) {
notifyError(err, `Failed to update ${skill.name}`)
} finally {
setSavingSkill(null)
}
}
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}>
<header className={titlebarHeaderBaseClass}>
<h2 className="text-base font-semibold leading-none tracking-tight">Skills</h2>
<span className="text-xs text-muted-foreground">{enabledSkills}/{totalSkills} enabled</span>
</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 className="min-h-0 flex-1 overflow-hidden rounded-[1.0625rem] border border-border/50 bg-background/85">
<div className="border-b border-border/50 px-4 py-3">
<div className="flex flex-wrap items-center gap-2">
<ModeButton active={mode === 'skills'} icon={Brain} onClick={() => setMode('skills')} text="Skills" />
<ModeButton active={mode === 'toolsets'} icon={Wrench} onClick={() => setMode('toolsets')} text="Toolsets" />
<div className="ml-auto w-full max-w-sm min-w-64">
<div className="relative">
<Search className="pointer-events-none absolute left-2.5 top-1/2 size-3.5 -translate-y-1/2 text-muted-foreground" />
<Input
className="h-8 rounded-lg pl-8 pr-8 text-sm"
onChange={event => setQuery(event.target.value)}
placeholder={mode === 'skills' ? 'Search skills...' : 'Search toolsets...'}
value={query}
/>
{query && (
<Button
aria-label="Clear search"
className="absolute right-1 top-1/2 h-6 w-6 -translate-y-1/2 text-muted-foreground hover:text-foreground"
onClick={() => setQuery('')}
size="icon"
type="button"
variant="ghost"
>
<X className="size-3.5" />
</Button>
)}
</div>
</div>
</div>
{mode === 'skills' && categories.length > 0 && (
<div className="mt-2 flex flex-wrap gap-1.5">
<CategoryButton
active={activeCategory === null}
count={totalSkills}
label="All"
onClick={() => setActiveCategory(null)}
/>
{categories.map(category => (
<CategoryButton
active={activeCategory === category.key}
count={category.count}
key={category.key}
label={prettyName(category.key)}
onClick={() => setActiveCategory(activeCategory === category.key ? null : category.key)}
/>
))}
</div>
)}
</div>
{!skills || !toolsets ? (
<PageLoader label="Loading capabilities..." />
) : mode === 'skills' ? (
<div className="h-full overflow-y-auto px-4 py-3">
{visibleSkills.length === 0 ? (
<EmptyState description="Try a broader search or different category." title="No skills found" />
) : (
<div className="space-y-4">
{skillGroups.map(([category, list]) => (
<div className="space-y-1.5" key={category}>
<div className="text-[0.68rem] font-semibold uppercase tracking-[0.12em] text-muted-foreground">
{prettyName(category)}
</div>
<div className="divide-y divide-border/40 rounded-lg border border-border/40 bg-background/70">
{list.map(skill => (
<div className="grid gap-3 px-3 py-2.5 sm:grid-cols-[minmax(0,1fr)_auto] sm:items-center" key={skill.name}>
<div className="min-w-0">
<div className="truncate text-sm font-medium">{skill.name}</div>
<p className="mt-0.5 text-xs text-muted-foreground">
{asText(skill.description) || 'No description.'}
</p>
</div>
<Switch
checked={skill.enabled}
disabled={savingSkill === skill.name}
onCheckedChange={checked => void handleToggleSkill(skill, checked)}
/>
</div>
))}
</div>
</div>
))}
</div>
)}
</div>
) : (
<div className="h-full overflow-y-auto px-4 py-3">
{visibleToolsets.length === 0 ? (
<EmptyState description="Try a broader search query." title="No toolsets found" />
) : (
<div className="space-y-2">
<div className="text-xs text-muted-foreground">{enabledToolsets}/{toolsets.length} toolsets enabled</div>
<div className="divide-y divide-border/40 rounded-lg border border-border/40 bg-background/70">
{visibleToolsets.map(toolset => {
const tools = toolNames(toolset)
const label = asText(toolset.label || toolset.name)
return (
<div className="px-3 py-2.5" key={toolset.name}>
<div className="flex items-center justify-between gap-2">
<div className="truncate text-sm font-medium">{label}</div>
<div className="flex items-center gap-1.5">
<StatusPill active={toolset.enabled}>{toolset.enabled ? 'Enabled' : 'Disabled'}</StatusPill>
<StatusPill active={toolset.configured}>
{toolset.configured ? 'Configured' : 'Needs keys'}
</StatusPill>
</div>
</div>
<p className="mt-1 text-xs text-muted-foreground">{asText(toolset.description) || 'No description.'}</p>
{tools.length > 0 && (
<div className="mt-2 flex flex-wrap gap-1">
{tools.map(name => (
<span
className="rounded-md bg-muted px-1.5 py-0.5 font-mono text-[0.65rem] text-muted-foreground"
key={name}
>
{name}
</span>
))}
</div>
)}
</div>
)
})}
</div>
</div>
)}
</div>
)}
</div>
</section>
)
}
function ModeButton({
active,
icon: Icon,
onClick,
text
}: {
active: boolean
icon: LucideIcon
onClick: () => void
text: string
}) {
return (
<Button
className={cn(
'h-8 gap-1.5 rounded-md px-2.5 text-xs',
active ? 'bg-accent text-foreground' : 'text-muted-foreground hover:text-foreground'
)}
onClick={onClick}
size="sm"
type="button"
variant="ghost"
>
<Icon className="size-3.5" />
{text}
</Button>
)
}
function CategoryButton({
active,
count,
label,
onClick
}: {
active: boolean
count: number
label: string
onClick: () => void
}) {
return (
<Button
className={cn(
'h-7 rounded-full px-2.5 text-[0.68rem]',
active ? 'bg-accent text-foreground' : 'text-muted-foreground hover:text-foreground'
)}
onClick={onClick}
size="sm"
type="button"
variant="ghost"
>
{label}
<span className="ml-1 rounded-full bg-muted px-1.5 py-0 text-[0.62rem] text-muted-foreground">{count}</span>
</Button>
)
}
function StatusPill({ active, children }: { active: boolean; children: string }) {
return (
<span
className={cn(
'inline-flex items-center rounded-full px-1.5 py-0.5 text-[0.64rem]',
active ? 'bg-primary/10 text-primary' : 'bg-muted text-muted-foreground'
)}
>
{children}
</span>
)
}
function EmptyState({ title, description }: { title: string; description: string }) {
return (
<div className="grid min-h-52 place-items-center text-center">
<div>
<div className="text-sm font-medium">{title}</div>
<div className="mt-1 text-xs text-muted-foreground">{description}</div>
</div>
</div>
)
}
@@ -0,0 +1,21 @@
import { cn } from '@/lib/utils'
import { formatElapsed } from './activity-timer'
interface ActivityTimerTextProps {
seconds: number
className?: string
}
export function ActivityTimerText({ seconds, className }: ActivityTimerTextProps) {
return (
<span
className={cn(
'shrink-0 font-mono text-[0.56rem] leading-none tracking-[0.02em] text-muted-foreground/45 tabular-nums',
className
)}
>
{formatElapsed(seconds)}
</span>
)
}
@@ -7,6 +7,7 @@ import { type ComponentProps, memo, useMemo, useState } from 'react'
import { SyntaxHighlighter } from '@/components/assistant-ui/shiki-highlighter'
import { Dialog, DialogContent } from '@/components/ui/dialog'
import { triggerHaptic } from '@/lib/haptics'
import { cn } from '@/lib/utils'
import { notify, notifyError } from '@/store/notifications'
@@ -37,6 +38,7 @@ function CodeHeader({ language, code }: { language?: string; code?: string }) {
await navigator.clipboard.writeText(code)
}
triggerHaptic('selection')
setCopied(true)
setTimeout(() => setCopied(false), 1500)
} catch {
@@ -8,10 +8,21 @@ import {
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 {
CheckIcon,
ChevronLeftIcon,
ChevronRightIcon,
CopyIcon,
GitBranchIcon,
MoreHorizontalIcon,
RefreshCwIcon,
Volume2Icon,
VolumeXIcon
} from 'lucide-react'
import { type FC, type ReactNode, useCallback, useEffect, useLayoutEffect, useRef, useState } from 'react'
import { formatElapsed, useElapsedSeconds } from '@/components/assistant-ui/activity-timer'
import { useElapsedSeconds } from '@/components/assistant-ui/activity-timer'
import { ActivityTimerText } from '@/components/assistant-ui/activity-timer-text'
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'
@@ -19,7 +30,18 @@ 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 {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuTrigger
} from '@/components/ui/dropdown-menu'
import { Loader } from '@/components/ui/loader'
import { speakText } from '@/hermes'
import { triggerHaptic } from '@/lib/haptics'
import { cn } from '@/lib/utils'
import { notifyError } from '@/store/notifications'
import { setThreadScrolledUp } from '@/store/thread-scroll'
const THINKING_FACES = [
@@ -58,73 +80,100 @@ const THINKING_VERBS = [
'brainstorming'
]
type ThreadLoadingState = 'response' | 'session'
type ThreadLoadingState = 'response' | 'session' | 'working'
interface MessageActionProps {
messageId: string
messageText: string
onBranchInNewChat?: (messageId: string) => void
}
const BOTTOM_DISTANCE_PX = 24
let readAloudAudio: HTMLAudioElement | null = null
function isNearBottom(el: HTMLElement): boolean {
return el.scrollHeight - (el.scrollTop + el.clientHeight) <= BOTTOM_DISTANCE_PX
}
function partText(part: unknown): string {
if (typeof part === 'string') {
return part
}
if (part && typeof part === 'object' && 'text' in part && typeof part.text === 'string') {
return part.text
}
return ''
}
function messageContentText(content: unknown): string {
if (typeof content === 'string') {
return content.trim()
}
return Array.isArray(content) ? content.map(partText).join('').trim() : ''
}
export const Thread: FC<{
intro?: IntroProps
loading?: ThreadLoadingState
}> = ({ intro, loading }) => {
const [autoScroll, setAutoScroll] = useState(true)
const previousLoading = useRef<ThreadLoadingState | undefined>(undefined)
onBranchInNewChat?: (messageId: string) => void
}> = ({ intro, loading, onBranchInNewChat }) => {
const viewportRef = useRef<HTMLDivElement | null>(null)
const messageCount = useAuiState(s => s.thread.messages.length)
const isRunning = useAuiState(s => s.thread.isRunning)
const lastMessageId = useAuiState(s => s.thread.messages.at(-1)?.id ?? '')
const shouldStickToBottomRef = useRef(true)
const handleScroll = useCallback((event: React.UIEvent<HTMLDivElement>) => {
const el = event.currentTarget
const nearBottom = isNearBottom(el)
const nearBottom = isNearBottom(event.currentTarget)
shouldStickToBottomRef.current = nearBottom
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)
}, [])
useLayoutEffect(() => {
const viewport = viewportRef.current
if (!viewport) {
return
}
const force = loading === 'session'
if (!force && !shouldStickToBottomRef.current) {
return
}
viewport.scrollTop = viewport.scrollHeight
shouldStickToBottomRef.current = true
setThreadScrolledUp(false)
}, [isRunning, lastMessageId, loading, messageCount])
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"
className="h-full min-h-0 overflow-y-auto overscroll-contain px-[clamp(1rem,10%,12rem)] pt-[calc(var(--vsq)*19)] scroll-smooth"
data-slot="aui_thread-viewport"
onPointerDown={handlePointerDown}
onScroll={handleScroll}
onWheel={handleWheel}
ref={viewportRef}
scrollToBottomOnInitialize
scrollToBottomOnRunStart
scrollToBottomOnThreadSwitch
>
<div className="flex w-full flex-col gap-3">
<ThreadPrimitive.Messages>{() => <ThreadMessage />}</ThreadPrimitive.Messages>
<ThreadPrimitive.Messages>{() => <ThreadMessage onBranchInNewChat={onBranchInNewChat} />}</ThreadPrimitive.Messages>
{loading === 'response' && <ResponseLoadingIndicator />}
{loading === 'working' && <WorkingIndicator />}
</div>
<ThreadPrimitive.ViewportFooter className="h-[220px] shrink-0" />
</ThreadPrimitive.Viewport>
{loading === 'session' && <CenteredThreadSpinner />}
</ThreadPrimitive.Root>
@@ -138,11 +187,18 @@ const CenteredThreadSpinner: FC = () => (
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" />
<Loader
aria-hidden="true"
className="size-12 text-primary/70"
pathSteps={220}
role="presentation"
strokeScale={0.72}
type="rose-curve"
/>
</div>
)
const ThreadMessage: FC = () => {
const ThreadMessage: FC<{ onBranchInNewChat?: (messageId: string) => void }> = ({ onBranchInNewChat }) => {
const role = useAuiState(s => s.message.role)
const isEditing = useAuiState(s => s.message.composer.isEditing)
@@ -167,10 +223,14 @@ const ThreadMessage: FC = () => {
return null
}
return <AssistantMessage />
return <AssistantMessage onBranchInNewChat={onBranchInNewChat} />
}
const AssistantMessage: FC = () => {
const AssistantMessage: FC<{ onBranchInNewChat?: (messageId: string) => void }> = ({ onBranchInNewChat }) => {
const messageId = useAuiState(s => s.message.id)
const content = useAuiState(s => s.message.content)
const messageText = messageContentText(content)
return (
<MessagePrimitive.Root
className="group flex w-full flex-col gap-2 self-start"
@@ -195,12 +255,20 @@ const AssistantMessage: FC = () => {
</MessagePrimitive.Error>
</div>
<div className="min-h-6">
<AssistantFooter />
<AssistantFooter messageId={messageId} messageText={messageText} onBranchInNewChat={onBranchInNewChat} />
</div>
</MessagePrimitive.Root>
)
}
const STATUS_ROW_CLASS = 'flex max-w-full items-center gap-2 self-start text-sm text-muted-foreground/70'
const StatusRow: FC<{ children: ReactNode; label: string }> = ({ children, label }) => (
<div aria-label={label} aria-live="polite" className={STATUS_ROW_CLASS} role="status">
{children}
</div>
)
const ResponseLoadingIndicator: FC = () => {
const [tick, setTick] = useState(0)
const elapsed = useElapsedSeconds()
@@ -215,17 +283,24 @@ const ResponseLoadingIndicator: FC = () => {
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"
>
<StatusRow label="Hermes is loading a response">
<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>
<ActivityTimerText seconds={elapsed} />
</StatusRow>
)
}
const WorkingIndicator: FC = () => {
const elapsed = useElapsedSeconds()
return (
<StatusRow label="Hermes is still working">
<Loader className="size-4 text-muted-foreground/60" label="Still working" strokeScale={0.65} type="spiral-search" />
<span className="shimmer min-w-0 truncate text-muted-foreground/60">Still working</span>
<ActivityTimerText seconds={elapsed} />
</StatusRow>
)
}
@@ -267,7 +342,7 @@ const ThinkingDisclosure: FC<{
<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"
className="inline-flex max-w-full items-center gap-1 rounded-md py-0.5 pr-1 text-left text-muted-foreground transition-colors hover:bg-accent hover:text-foreground"
onClick={() => setOpen(value => !value)}
type="button"
>
@@ -279,7 +354,7 @@ const ThinkingDisclosure: FC<{
>
Thinking
</span>
{pending && <ActivityTimerBadge seconds={elapsed} tone={elapsed >= 20 ? 'warm' : 'muted'} />}
{pending && <ActivityTimerText seconds={elapsed} />}
</button>
{open && <div className="ml-4 mt-1 max-w-full wrap-anywhere border-l border-border pl-3">{children}</div>}
</div>
@@ -301,67 +376,221 @@ const ReasoningPart: FC<{ text: string; status?: { type: string } }> = ({ text,
</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 TIME_FMT = new Intl.DateTimeFormat(undefined, { hour: 'numeric', minute: '2-digit' })
const SHORT_FMT = new Intl.DateTimeFormat(undefined, {
day: 'numeric',
hour: 'numeric',
minute: '2-digit',
month: 'short'
})
function startOfDay(d: Date): number {
return new Date(d.getFullYear(), d.getMonth(), d.getDate()).getTime()
}
function formatMessageTimestamp(value: Date | string | number | undefined): string {
if (!value) {
return ''
}
const date = value instanceof Date ? value : new Date(value)
if (Number.isNaN(date.getTime())) {
return ''
}
const dayDelta = Math.round((startOfDay(new Date()) - startOfDay(date)) / 86_400_000)
if (dayDelta === 0) {
return `Today, ${TIME_FMT.format(date)}`
}
if (dayDelta === 1) {
return `Yesterday, ${TIME_FMT.format(date)}`
}
return SHORT_FMT.format(date)
}
const ACTION_BAR_CLASS = cn(
'absolute inset-0 flex gap-1 text-muted-foreground opacity-0 transition-opacity duration-100',
'pointer-events-none group-hover:pointer-events-auto group-hover:opacity-100',
'focus-within:pointer-events-auto focus-within:opacity-100'
)
const AssistantFooter: FC = () => {
const AssistantActionBar: FC<MessageActionProps> = ({ messageId, messageText, onBranchInNewChat }) => {
const [menuOpen, setMenuOpen] = useState(false)
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
<div className="relative h-6 w-20 shrink-0">
<ActionBarPrimitive.Root
className={cn(ACTION_BAR_CLASS, menuOpen && 'pointer-events-auto opacity-100')}
hideWhenRunning
>
<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 />
<CopyMessageButton text={messageText} />
<ActionBarPrimitive.Reload asChild>
<TooltipIconButton onClick={() => triggerHaptic('submit')} tooltip="Refresh">
<RefreshCwIcon />
</TooltipIconButton>
</ActionBarPrimitive.Reload>
<DropdownMenu onOpenChange={setMenuOpen} open={menuOpen}>
<DropdownMenuTrigger asChild>
<TooltipIconButton tooltip="More actions">
<MoreHorizontalIcon />
</TooltipIconButton>
</DropdownMenuTrigger>
<DropdownMenuContent align="start" onCloseAutoFocus={e => e.preventDefault()} sideOffset={6}>
<MessageTimestamp />
<DropdownMenuItem onSelect={() => onBranchInNewChat?.(messageId)}>
<GitBranchIcon />
Branch in new chat
</DropdownMenuItem>
<ReadAloudItem text={messageText} />
</DropdownMenuContent>
</DropdownMenu>
</ActionBarPrimitive.Root>
</div>
)
}
const CopyMessageButton: FC<{ text: string }> = ({ text }) => {
const [copied, setCopied] = useState(false)
const copy = useCallback(async () => {
if (!text) {
return
}
try {
await navigator.clipboard.writeText(text)
triggerHaptic('selection')
setCopied(true)
window.setTimeout(() => setCopied(false), 2000)
} catch (error) {
notifyError(error, 'Copy failed')
}
}, [text])
return (
<TooltipIconButton disabled={!text} onClick={() => void copy()} tooltip={copied ? 'Copied' : 'Copy'}>
{copied ? <CheckIcon /> : <CopyIcon />}
</TooltipIconButton>
)
}
let currentAudio: HTMLAudioElement | null = null
function stopCurrentAudio() {
if (!currentAudio) {
return
}
currentAudio.pause()
currentAudio.src = ''
currentAudio = null
}
const ReadAloudItem: FC<{ text: string }> = ({ text }) => {
const [reading, setReading] = useState(false)
const seqRef = useRef(0)
const stop = useCallback(() => {
seqRef.current += 1
stopCurrentAudio()
setReading(false)
}, [])
const read = useCallback(async () => {
if (!text) {
return
}
stopCurrentAudio()
const seq = ++seqRef.current
const isCurrent = () => seq === seqRef.current
const finish = () => {
if (!isCurrent()) {
return
}
currentAudio = null
setReading(false)
}
setReading(true)
try {
const { data_url } = await speakText(text)
if (!isCurrent()) {
return
}
const audio = new Audio(data_url)
currentAudio = audio
audio.addEventListener('ended', finish, { once: true })
audio.addEventListener('error', finish, { once: true })
await audio.play()
} catch (error) {
if (isCurrent()) {
notifyError(error, 'Read aloud failed')
finish()
}
}
}, [text])
const Icon = reading ? VolumeXIcon : Volume2Icon
return (
<DropdownMenuItem
disabled={!reading && !text}
onSelect={e => {
e.preventDefault()
void (reading ? stop() : read())
}}
>
<Icon />
{reading ? 'Stop reading' : 'Read aloud'}
</DropdownMenuItem>
)
}
const MessageTimestamp: FC = () => {
const createdAt = useAuiState(s => s.message.createdAt)
const label = formatMessageTimestamp(createdAt)
if (!label) {
return null
}
return <DropdownMenuLabel className="text-xs font-normal text-muted-foreground">{label}</DropdownMenuLabel>
}
const AssistantFooter: FC<MessageActionProps> = props => (
<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 {...props} />
</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
@@ -4,7 +4,8 @@ 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 { useElapsedSeconds } from '@/components/assistant-ui/activity-timer'
import { ActivityTimerText } from '@/components/assistant-ui/activity-timer-text'
import { cn } from '@/lib/utils'
const TOOL_SPINNER_FRAMES = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏']
@@ -164,7 +165,7 @@ export const ToolFallback = ({ toolName, args, result }: ToolCallMessagePartProp
{spinnerFrame}
</span>
) : null}
{isPending && <ToolTimerBadge seconds={elapsed} />}
{isPending && <ActivityTimerText 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">
@@ -176,15 +177,3 @@ export const ToolFallback = ({ toolName, args, result }: ToolCallMessagePartProp
)
}
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>
)
-829
View File
@@ -1,829 +0,0 @@
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
}
@@ -0,0 +1,19 @@
import { useStore } from '@nanostores/react'
import { type ReactNode, useEffect } from 'react'
import { useWebHaptics } from 'web-haptics/react'
import { registerHapticTrigger } from '@/lib/haptics'
import { $hapticsMuted } from '@/store/haptics'
export function HapticsProvider({ children }: { children: ReactNode }) {
const muted = useStore($hapticsMuted)
const { trigger } = useWebHaptics({ debug: true, showSwitch: false })
useEffect(() => {
registerHapticTrigger(muted ? null : trigger)
return () => registerHapticTrigger(null)
}, [muted, trigger])
return <>{children}</>
}
+74 -41
View File
@@ -1,8 +1,9 @@
import { useStore } from '@nanostores/react'
import { AlertCircle, AlertTriangle, CheckCircle2, Info, type LucideIcon, X } from 'lucide-react'
import { type ReactNode, useEffect, useState } from 'react'
import { AlertCircle, AlertTriangle, CheckCircle2, Copy, Info, type LucideIcon, X } from 'lucide-react'
import { type ReactNode, useEffect, useRef, useState } from 'react'
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'
import { triggerHaptic } from '@/lib/haptics'
import { cn } from '@/lib/utils'
import {
$notifications,
@@ -12,33 +13,21 @@ import {
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'
}
type ToneVariant = 'default' | 'destructive' | 'warning' | 'success'
const tone: Record<NotificationKind, { icon: LucideIcon; iconClass: string; variant: ToneVariant }> = {
error: { icon: AlertCircle, iconClass: 'text-destructive', variant: 'destructive' },
warning: { icon: AlertTriangle, iconClass: 'text-primary', variant: 'warning' },
info: { icon: Info, iconClass: 'text-muted-foreground', variant: 'default' },
success: { icon: CheckCircle2, iconClass: 'text-primary', variant: 'success' }
}
const STACK_SURFACE = 'pointer-events-auto border-border/80 bg-popover/95 shadow-lg shadow-black/5 backdrop-blur-md'
const GHOST_BTN = 'bg-transparent text-muted-foreground hover:text-foreground'
export function NotificationStack() {
const notifications = useStore($notifications)
const lastNotificationIdRef = useRef<string | null>(null)
const [expanded, setExpanded] = useState(false)
useEffect(() => {
@@ -47,6 +36,24 @@ export function NotificationStack() {
}
}, [notifications.length])
useEffect(() => {
const latest = notifications[0]
if (!latest || latest.id === lastNotificationIdRef.current) {
return
}
lastNotificationIdRef.current = latest.id
if (latest.kind === 'success') {
triggerHaptic('success')
} else if (latest.kind === 'error') {
triggerHaptic('error')
} else if (latest.kind === 'warning') {
triggerHaptic('warning')
}
}, [notifications])
if (notifications.length === 0) {
return null
}
@@ -61,26 +68,17 @@ export function NotificationStack() {
role="region"
>
<NotificationItem notification={latest} />
{expanded && olderNotifications.map(n => <NotificationItem key={n.id} notification={n} />)}
{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"
>
<div className={cn(STACK_SURFACE, 'flex min-h-8 items-center justify-between rounded-lg px-3 text-xs')}>
<button className={cn(GHOST_BTN, 'font-medium')} onClick={() => setExpanded(v => !v)} 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"
>
<button className={GHOST_BTN} onClick={clearNotifications} type="button">
Clear all
</button>
</div>
)}
{expanded &&
olderNotifications.map(notification => <NotificationItem key={notification.id} notification={notification} />)}
</div>
)
}
@@ -88,19 +86,21 @@ export function NotificationStack() {
function NotificationItem({ notification }: { notification: AppNotification }) {
const styles = tone[notification.kind]
const Icon = styles.icon
const hasDetail = Boolean(notification.detail && notification.detail !== notification.message)
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"
className={cn(STACK_SURFACE, 'grid-cols-[auto_minmax(0,1fr)_auto] pr-2.5')}
role={notification.kind === 'error' ? 'alert' : 'status'}
variant={styles.variant}
variant="default"
>
<Icon />
<Icon className={styles.iconClass} />
<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>
{hasDetail && <NotificationDetail detail={notification.detail || ''} />}
</AlertDescription>
</div>
<button
@@ -115,6 +115,39 @@ function NotificationItem({ notification }: { notification: AppNotification }) {
)
}
function NotificationDetail({ detail }: { detail: string }) {
const [copied, setCopied] = useState(false)
async function copyDetail() {
try {
await navigator.clipboard.writeText(detail)
setCopied(true)
window.setTimeout(() => setCopied(false), 1200)
} catch {
// Best effort; details remain visible even if clipboard access fails.
}
}
return (
<details className="mt-2 text-xs text-muted-foreground">
<summary className="cursor-pointer select-none font-medium text-muted-foreground hover:text-foreground">
Details
</summary>
<div className="mt-1 rounded-md border border-border/70 bg-background/65 p-2">
<pre className="max-h-32 whitespace-pre-wrap wrap-break-word font-mono text-[0.6875rem] leading-relaxed">{detail}</pre>
<button
className="mt-1 inline-flex items-center gap-1 rounded px-1.5 py-0.5 text-[0.6875rem] text-muted-foreground hover:bg-accent hover:text-foreground"
onClick={copyDetail}
type="button"
>
<Copy className="size-3" />
{copied ? 'Copied' : 'Copy detail'}
</button>
</div>
</details>
)
}
export function InlineNotice({
kind = 'info',
title,
@@ -0,0 +1,34 @@
import type { ComponentProps } from 'react'
import { Loader } from '@/components/ui/loader'
import { cn } from '@/lib/utils'
interface PageLoaderProps extends Omit<ComponentProps<'div'>, 'children'> {
label?: string
}
export function PageLoader({
'aria-label': ariaLabel,
className,
label = 'Loading',
role = 'status',
...props
}: PageLoaderProps) {
return (
<div
{...props}
aria-label={ariaLabel ?? label}
className={cn('grid h-full place-items-center', className)}
role={role}
>
<Loader
aria-hidden="true"
className="size-10 text-primary/70"
pathSteps={220}
role="presentation"
strokeScale={0.72}
type="rose-curve"
/>
</div>
)
}
@@ -198,6 +198,12 @@ function WorkspaceSection({
)
}
function personalityOptionKey(value?: string): string {
const key = value?.trim().toLowerCase() || 'none'
return key === 'default' ? 'none' : key
}
function AgentSection({
label: modelLabel,
onOpen,
@@ -217,12 +223,12 @@ function AgentSection({
const [open, setOpen] = useState(false)
const merged = useMemo(
() => [...new Set(['default', ...options, current].map(s => s?.trim().toLowerCase()).filter(Boolean))],
() => [...new Set(['none', ...options, current].map(personalityOptionKey).filter(Boolean))],
[current, options]
)
const activeKey = (current || 'default').trim().toLowerCase()
const personalityLabel = current ? titleize(current) : 'Default'
const activeKey = personalityOptionKey(current)
const personalityLabel = activeKey === 'none' ? 'None' : titleize(activeKey)
return (
<section className="grid gap-1.5 py-1.5">
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -24,7 +24,7 @@ function DialogOverlay({ className, ...props }: React.ComponentProps<typeof Dial
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',
'fixed inset-0 z-50 bg-black/50 backdrop-blur-sm 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"
+552
View File
@@ -0,0 +1,552 @@
import { type ComponentProps, useEffect, useRef } from 'react'
import { cn } from '@/lib/utils'
export const LOADER_TYPES = [
'original-thinking',
'thinking-five',
'thinking-nine',
'rose-orbit',
'rose-curve',
'rose-two',
'rose-three',
'rose-four',
'lissajous-drift',
'lemniscate-bloom',
'hypotrochoid-loop',
'three-petal-spiral',
'four-petal-spiral',
'five-petal-spiral',
'six-petal-spiral',
'butterfly-phase',
'cardioid-glow',
'cardioid-heart',
'heart-wave',
'spiral-search',
'fourier-flow'
] as const
export type LoaderType = (typeof LOADER_TYPES)[number]
interface Point {
x: number
y: number
}
interface LoaderCurve {
durationMs: number
name: string
particleCount: number
point: (progress: number, detailScale: number) => Point
pulseDurationMs: number
rotate: boolean
rotationDurationMs: number
strokeWidth: number
trailSpan: number
}
interface LoaderProps extends Omit<ComponentProps<'div'>, 'children'> {
label?: string
pathSteps?: number
strokeScale?: number
type?: LoaderType
}
interface BaseCurveOptions extends Pick<LoaderCurve, 'durationMs' | 'particleCount' | 'pulseDurationMs' | 'strokeWidth' | 'trailSpan'> {
point?: LoaderCurve['point']
rotate?: boolean
rotationDurationMs?: number
}
const TWO_PI = Math.PI * 2
const LOADER_CURVES: Record<LoaderType, LoaderCurve> = {
'original-thinking': thinkingCurve('Original Thinking', 7, {
durationMs: 4600,
particleCount: 64,
pulseDurationMs: 4200,
rotationDurationMs: 28000,
trailSpan: 0.38
}),
'thinking-five': thinkingCurve('Thinking Five', 5, {
durationMs: 4600,
particleCount: 62,
pulseDurationMs: 4200,
rotationDurationMs: 28000,
trailSpan: 0.38
}),
'thinking-nine': thinkingCurve('Thinking Nine', 9, {
durationMs: 4700,
particleCount: 68,
pulseDurationMs: 4200,
rotationDurationMs: 30000,
trailSpan: 0.39
}),
'rose-orbit': {
...baseCurve('Rose Orbit', {
durationMs: 5200,
particleCount: 72,
pulseDurationMs: 4600,
rotate: true,
rotationDurationMs: 28000,
strokeWidth: 5.2,
trailSpan: 0.42
}),
point(progress, detailScale) {
const t = progress * TWO_PI
const r = 7 - 2.7 * detailScale * Math.cos(7 * t)
return {
x: 50 + Math.cos(t) * r * 3.9,
y: 50 + Math.sin(t) * r * 3.9
}
}
},
'rose-curve': roseCurve('Rose Curve', 5, {
durationMs: 5400,
particleCount: 78,
pulseDurationMs: 4600,
strokeWidth: 4.5,
trailSpan: 0.32
}),
'rose-two': roseCurve('Rose Two', 2, {
durationMs: 5200,
particleCount: 74,
pulseDurationMs: 4300,
strokeWidth: 4.6,
trailSpan: 0.3
}),
'rose-three': roseCurve('Rose Three', 3, {
durationMs: 5300,
particleCount: 76,
pulseDurationMs: 4400,
strokeWidth: 4.6,
trailSpan: 0.31
}),
'rose-four': roseCurve('Rose Four', 4, {
durationMs: 5400,
particleCount: 78,
pulseDurationMs: 4500,
strokeWidth: 4.6,
trailSpan: 0.32
}),
'lissajous-drift': {
...baseCurve('Lissajous Drift', {
durationMs: 6000,
particleCount: 68,
pulseDurationMs: 5400,
strokeWidth: 4.7,
trailSpan: 0.34
}),
point(progress, detailScale) {
const t = progress * TWO_PI
const amp = 24 + detailScale * 6
return {
x: 50 + Math.sin(3 * t + 1.57) * amp,
y: 50 + Math.sin(4 * t) * (amp * 0.92)
}
}
},
'lemniscate-bloom': {
...baseCurve('Lemniscate Bloom', {
durationMs: 5600,
particleCount: 70,
pulseDurationMs: 5000,
rotationDurationMs: 34000,
strokeWidth: 4.8,
trailSpan: 0.4
}),
point(progress, detailScale) {
const t = progress * TWO_PI
const scale = 20 + detailScale * 7
const denom = 1 + Math.sin(t) ** 2
return {
x: 50 + (scale * Math.cos(t)) / denom,
y: 50 + (scale * Math.sin(t) * Math.cos(t)) / denom
}
}
},
'hypotrochoid-loop': {
...baseCurve('Hypotrochoid Loop', {
durationMs: 7600,
particleCount: 82,
pulseDurationMs: 6200,
rotationDurationMs: 42000,
strokeWidth: 4.6,
trailSpan: 0.46
}),
point(progress, detailScale) {
const t = progress * TWO_PI
const r = 2.7 + detailScale * 0.45
const d = 4.8 + detailScale * 1.2
const x = (8.2 - r) * Math.cos(t) + d * Math.cos(((8.2 - r) / r) * t)
const y = (8.2 - r) * Math.sin(t) - d * Math.sin(((8.2 - r) / r) * t)
return {
x: 50 + x * 3.05,
y: 50 + y * 3.05
}
}
},
'three-petal-spiral': spiralPetalCurve('Three-Petal Spiral', 3, 82),
'four-petal-spiral': spiralPetalCurve('Four-Petal Spiral', 4, 84),
'five-petal-spiral': spiralPetalCurve('Five-Petal Spiral', 5, 85),
'six-petal-spiral': spiralPetalCurve('Six-Petal Spiral', 6, 86),
'butterfly-phase': {
...baseCurve('Butterfly Phase', {
durationMs: 9000,
particleCount: 88,
pulseDurationMs: 7000,
rotationDurationMs: 50000,
strokeWidth: 4.4,
trailSpan: 0.32
}),
point(progress, detailScale) {
const t = progress * Math.PI * 12
const butterfly =
Math.exp(Math.cos(t)) - 2 * Math.cos(4 * t) - Math.sin(t / 12) ** 5
const scale = 4.6 + detailScale * 0.45
return {
x: 50 + Math.sin(t) * butterfly * scale,
y: 50 + Math.cos(t) * butterfly * scale
}
}
},
'cardioid-glow': cardioidCurve('Cardioid Glow', {
a: 8.4,
particleCount: 72,
pointFor(t, r, scale) {
return {
x: 50 + Math.cos(t) * r * scale,
y: 50 + Math.sin(t) * r * scale
}
},
rFor(t, a) {
return a * (1 - Math.cos(t))
}
}),
'cardioid-heart': cardioidCurve('Cardioid Heart', {
a: 8.8,
particleCount: 74,
pointFor(t, r, scale) {
const baseX = Math.cos(t) * r
const baseY = Math.sin(t) * r
return {
x: 50 - baseY * scale,
y: 50 - baseX * scale
}
},
rFor(t, a) {
return a * (1 + Math.cos(t))
}
}),
'heart-wave': {
...baseCurve('Heart Wave', {
durationMs: 8400,
particleCount: 104,
pulseDurationMs: 5600,
rotationDurationMs: 22000,
strokeWidth: 3.9,
trailSpan: 0.18
}),
point(progress, detailScale) {
const root = 3.3
const xLimit = Math.sqrt(root)
const x = -xLimit + progress * xLimit * 2
const safeRoot = Math.max(0, root - x * x)
const wave = 0.9 * Math.sqrt(safeRoot) * Math.sin(6.4 * Math.PI * x)
const curve = Math.abs(x) ** (2 / 3)
const y = curve + wave
return {
x: 50 + x * 23.2,
y: 18 + (1.75 - y) * (24.5 + detailScale * 1.5)
}
}
},
'spiral-search': {
...baseCurve('Spiral Search', {
durationMs: 7800,
particleCount: 86,
pulseDurationMs: 6800,
rotationDurationMs: 44000,
strokeWidth: 4.3,
trailSpan: 0.28
}),
point(progress, detailScale) {
const t = progress * TWO_PI
const angle = t * 4
const radius = 8 + (1 - Math.cos(t)) * (8.5 + detailScale * 2.4)
return {
x: 50 + Math.cos(angle) * radius,
y: 50 + Math.sin(angle) * radius
}
}
},
'fourier-flow': {
...baseCurve('Fourier Flow', {
durationMs: 8400,
particleCount: 92,
pulseDurationMs: 6800,
rotationDurationMs: 44000,
strokeWidth: 4.2,
trailSpan: 0.31
}),
point(progress, detailScale) {
const t = progress * TWO_PI
const mix = 1 + detailScale * 0.16
const x = 17 * Math.cos(t) + 7.5 * Math.cos(3 * t + 0.6 * mix) + 3.2 * Math.sin(5 * t - 0.4)
const y = 15 * Math.sin(t) + 8.2 * Math.sin(2 * t + 0.25) - 4.2 * Math.cos(4 * t - 0.5 * mix)
return {
x: 50 + x,
y: 50 + y
}
}
}
}
export function Loader({
className,
label = 'Loading',
pathSteps = 240,
role = 'status',
strokeScale = 1,
type = 'rose-curve',
...props
}: LoaderProps) {
const config = LOADER_CURVES[type]
const groupRef = useRef<SVGGElement | null>(null)
const particleRefs = useRef<Array<SVGCircleElement | null>>([])
const pathRef = useRef<SVGPathElement | null>(null)
useEffect(() => {
let animationFrame = 0
const startedAt = performance.now()
const phaseOffset = Math.random()
particleRefs.current.length = config.particleCount
const render = (now: number) => {
const time = now - startedAt
const progress = ((time + phaseOffset * config.durationMs) % config.durationMs) / config.durationMs
const detailScale = detailScaleFor(time, config, phaseOffset)
const rotation = rotationFor(time, config, phaseOffset)
groupRef.current?.setAttribute('transform', `rotate(${rotation} 50 50)`)
pathRef.current?.setAttribute('d', buildPath(config, detailScale, pathSteps))
particleRefs.current.forEach((node, index) => {
if (!node) {
return
}
const particle = particleFor(config, index, progress, detailScale, strokeScale)
node.setAttribute('cx', particle.x.toFixed(2))
node.setAttribute('cy', particle.y.toFixed(2))
node.setAttribute('r', particle.radius.toFixed(2))
node.setAttribute('opacity', particle.opacity.toFixed(3))
})
animationFrame = window.requestAnimationFrame(render)
}
render(performance.now())
return () => window.cancelAnimationFrame(animationFrame)
}, [config, pathSteps, strokeScale])
return (
<div
{...props}
aria-label={props['aria-label'] ?? label}
className={cn('inline-grid size-10 place-items-center text-primary', className)}
role={role}
>
<svg aria-hidden="true" className="size-full overflow-visible" fill="none" viewBox="0 0 100 100">
<g ref={groupRef}>
<path
opacity="0.1"
ref={pathRef}
stroke="currentColor"
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={config.strokeWidth * strokeScale}
/>
{Array.from({ length: config.particleCount }, (_, index) => (
<circle
fill="currentColor"
key={`${type}-${index}`}
ref={node => {
particleRefs.current[index] = node
}}
/>
))}
</g>
</svg>
</div>
)
}
function baseCurve(name: string, options: BaseCurveOptions): LoaderCurve {
return {
durationMs: options.durationMs,
name,
particleCount: options.particleCount,
point: options.point ?? (() => ({ x: 50, y: 50 })),
pulseDurationMs: options.pulseDurationMs,
rotate: options.rotate ?? false,
rotationDurationMs: options.rotationDurationMs ?? 36000,
strokeWidth: options.strokeWidth,
trailSpan: options.trailSpan
}
}
function thinkingCurve(
name: string,
petalCount: number,
options: Pick<LoaderCurve, 'durationMs' | 'particleCount' | 'pulseDurationMs' | 'rotationDurationMs' | 'trailSpan'>
): LoaderCurve {
return {
...baseCurve(name, {
...options,
rotate: true,
strokeWidth: 5.5
}),
point(progress, detailScale) {
const t = progress * TWO_PI
const x = 7 * Math.cos(t) - 3 * detailScale * Math.cos(petalCount * t)
const y = 7 * Math.sin(t) - 3 * detailScale * Math.sin(petalCount * t)
return {
x: 50 + x * 3.9,
y: 50 + y * 3.9
}
}
}
}
function roseCurve(
name: string,
k: number,
options: Pick<LoaderCurve, 'durationMs' | 'particleCount' | 'pulseDurationMs' | 'strokeWidth' | 'trailSpan'>
): LoaderCurve {
return {
...baseCurve(name, {
...options,
rotate: true,
rotationDurationMs: 28000
}),
point(progress, detailScale) {
const t = progress * TWO_PI
const a = 9.2 + detailScale * 0.6
const r = a * (0.72 + detailScale * 0.28) * Math.cos(k * t)
return {
x: 50 + Math.cos(t) * r * 3.25,
y: 50 + Math.sin(t) * r * 3.25
}
}
}
}
function spiralPetalCurve(name: string, spiralR: number, particleCount: number): LoaderCurve {
return {
...baseCurve(name, {
durationMs: 4600,
particleCount,
pulseDurationMs: 4200,
rotate: true,
rotationDurationMs: 28000,
strokeWidth: 4.4,
trailSpan: 0.34
}),
point(progress, detailScale) {
const t = progress * TWO_PI
const spiralr = 1
const d = 3 + detailScale * 0.25
const baseX = (spiralR - spiralr) * Math.cos(t) + d * Math.cos(((spiralR - spiralr) / spiralr) * t)
const baseY = (spiralR - spiralr) * Math.sin(t) - d * Math.sin(((spiralR - spiralr) / spiralr) * t)
const scale = 2.2 + detailScale * 0.45
return {
x: 50 + baseX * scale,
y: 50 + baseY * scale
}
}
}
}
function cardioidCurve(
name: string,
options: {
a: number
particleCount: number
pointFor: (t: number, r: number, scale: number) => Point
rFor: (t: number, a: number) => number
}
): LoaderCurve {
return {
...baseCurve(name, {
durationMs: 6200,
particleCount: options.particleCount,
pulseDurationMs: 5200,
rotationDurationMs: 36000,
strokeWidth: 4.9,
trailSpan: 0.36
}),
point(progress, detailScale) {
const t = progress * TWO_PI
const a = options.a + detailScale * 0.8
const r = options.rFor(t, a)
return options.pointFor(t, r, 2.15)
}
}
}
function buildPath(config: LoaderCurve, detailScale: number, steps: number) {
return Array.from({ length: steps + 1 }, (_, index) => {
const point = config.point(index / steps, detailScale)
return `${index === 0 ? 'M' : 'L'} ${point.x.toFixed(2)} ${point.y.toFixed(2)}`
}).join(' ')
}
function detailScaleFor(time: number, config: LoaderCurve, phaseOffset: number) {
const pulseProgress = ((time + phaseOffset * config.pulseDurationMs) % config.pulseDurationMs) / config.pulseDurationMs
const pulseAngle = pulseProgress * TWO_PI
return 0.52 + ((Math.sin(pulseAngle + 0.55) + 1) / 2) * 0.48
}
function normalizeProgress(progress: number) {
return ((progress % 1) + 1) % 1
}
function particleFor(config: LoaderCurve, index: number, progress: number, detailScale: number, strokeScale: number) {
const tailOffset = index / (config.particleCount - 1)
const point = config.point(normalizeProgress(progress - tailOffset * config.trailSpan), detailScale)
const fade = (1 - tailOffset) ** 0.56
return {
opacity: 0.04 + fade * 0.96,
radius: (0.9 + fade * 2.7) * strokeScale,
x: point.x,
y: point.y
}
}
function rotationFor(time: number, config: LoaderCurve, phaseOffset: number) {
if (!config.rotate) {
return 0
}
return -(((time + phaseOffset * config.rotationDurationMs) % config.rotationDurationMs) / config.rotationDurationMs) * 360
}
+1 -1
View File
@@ -26,7 +26,7 @@ function SheetOverlay({ className, ...props }: React.ComponentProps<typeof Sheet
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',
'fixed inset-0 z-50 bg-black/50 backdrop-blur-sm 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"
+1
View File
@@ -6,6 +6,7 @@ declare global {
getConnection: () => Promise<HermesConnection>
api: <T>(request: HermesApiRequest) => Promise<T>
notify: (payload: HermesNotification) => Promise<boolean>
requestMicrophoneAccess: () => Promise<boolean>
readFileDataUrl: (filePath: string) => Promise<string>
selectPaths: (options?: HermesSelectPathsOptions) => Promise<string[]>
writeClipboard: (text: string) => Promise<boolean>
+37 -23
View File
@@ -1,5 +1,8 @@
import type {
AudioSpeakResponse,
AudioTranscriptionResponse,
ConfigSchemaResponse,
ElevenLabsVoicesResponse,
EnvVarInfo,
HermesConfig,
HermesConfigRecord,
@@ -7,15 +10,18 @@ import type {
ModelOptionsResponse,
PaginatedSessions,
RpcEvent,
SessionInfo,
SessionMessagesResponse,
SkillInfo,
ToolsetInfo
} from '@/types/hermes'
export type {
AudioSpeakResponse,
AudioTranscriptionResponse,
ConfigFieldSchema,
ConfigSchemaResponse,
ElevenLabsVoice,
ElevenLabsVoicesResponse,
EnvVarInfo,
GatewayReadyPayload,
HermesConfig,
@@ -175,30 +181,13 @@ export class HermesGateway {
}
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
}
}
const result = await window.hermesDesktop.api<PaginatedSessions>({
path: `/api/sessions?limit=${limit}&offset=0&min_messages=1`
})
return {
sessions: collected.slice(0, limit),
total,
limit,
...result,
sessions: result.sessions.slice(0, limit),
offset: 0
}
}
@@ -324,3 +313,28 @@ export function setGlobalModel(
}
})
}
export function transcribeAudio(dataUrl: string, mimeType?: string): Promise<AudioTranscriptionResponse> {
return window.hermesDesktop.api<AudioTranscriptionResponse>({
path: '/api/audio/transcribe',
method: 'POST',
body: {
data_url: dataUrl,
mime_type: mimeType
}
})
}
export function speakText(text: string): Promise<AudioSpeakResponse> {
return window.hermesDesktop.api<AudioSpeakResponse>({
path: '/api/audio/speak',
method: 'POST',
body: { text }
})
}
export function getElevenLabsVoices(): Promise<ElevenLabsVoicesResponse> {
return window.hermesDesktop.api<ElevenLabsVoicesResponse>({
path: '/api/audio/elevenlabs/voices'
})
}
+71 -1
View File
@@ -244,6 +244,32 @@ function applyStoredToolResult(messages: ChatMessage[], toolMessage: SessionMess
return false
}
function applyStoredToolResultToParts(parts: ChatMessagePart[], toolMessage: SessionMessage): ChatMessagePart[] | null {
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 || ''
const partIndex = parts.findIndex(
part =>
part.type === 'tool-call' &&
((toolCallId && part.toolCallId === toolCallId) || (!toolCallId && part.toolName === toolName))
)
if (partIndex < 0) {
return null
}
const next = [...parts]
const existing = next[partIndex]
next[partIndex] = {
...existing,
result: parseStoredToolResult(content),
isError: false
} as ChatMessagePart
return next
}
function storedToolMessagePart(toolMessage: SessionMessage, fallbackIndex: number): ChatMessagePart {
const name = toolMessage.tool_name || toolMessage.name || 'tool'
const context = toolMessage.context || toolMessage.text || toolMessage.content || ''
@@ -260,6 +286,42 @@ function storedToolMessagePart(toolMessage: SessionMessage, fallbackIndex: numbe
}
}
function withUniqueToolCallIds(messages: ChatMessage[]): ChatMessage[] {
const seen = new Set<string>()
return messages.map(message => {
let changed = false
const parts = message.parts.map((part, index) => {
if (part.type !== 'tool-call') {
return part
}
const id = part.toolCallId || `${message.id}-tool-${index}`
if (!seen.has(id)) {
seen.add(id)
if (part.toolCallId) {
return part
}
changed = true
return { ...part, toolCallId: id } as ChatMessagePart
}
changed = true
const uniqueId = `${id}-${message.id}-${index}`
seen.add(uniqueId)
return { ...part, toolCallId: uniqueId } as ChatMessagePart
})
return changed ? { ...message, parts } : message
})
}
export function toChatMessages(messages: SessionMessage[]): ChatMessage[] {
const result: ChatMessage[] = []
let pendingToolParts: ChatMessagePart[] = []
@@ -282,6 +344,14 @@ export function toChatMessages(messages: SessionMessage[]): ChatMessage[] {
messages.forEach((message, index) => {
if (message.role === 'tool') {
const updatedPendingToolParts = applyStoredToolResultToParts(pendingToolParts, message)
if (updatedPendingToolParts) {
pendingToolParts = updatedPendingToolParts
return
}
if (applyStoredToolResult(result, message)) {
return
}
@@ -343,7 +413,7 @@ export function toChatMessages(messages: SessionMessage[]): ChatMessage[] {
})
flushPendingTools(messages.length)
return result.filter(m => chatMessageText(m).trim() || m.parts.some(part => part.type !== 'text'))
return withUniqueToolCallIds(result.filter(m => chatMessageText(m).trim() || m.parts.some(part => part.type !== 'text')))
}
export function branchGroupForUser(userMessage: ChatMessage): string {
+1 -1
View File
@@ -1,7 +1,7 @@
import type { ThreadMessage } from '@assistant-ui/react'
import type { QuickModelOption } from '@/app/chat/composer/types'
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'
+28
View File
@@ -0,0 +1,28 @@
// Routes `navigator.clipboard.writeText` through Electron IPC, since the
// renderer's clipboard API throws "Write permission denied" whenever the
// document loses focus (e.g. clicking a portaled Radix dropdown). The IPC
// path runs in the main process and is unconditional.
export function installClipboardShim() {
const ipc = window.hermesDesktop?.writeClipboard
if (!ipc || !navigator.clipboard) {
return
}
const native = navigator.clipboard.writeText?.bind(navigator.clipboard)
const writeText = async (text: string) => {
try {
await ipc(text)
} catch {
await native?.(text)
}
}
try {
Object.defineProperty(navigator.clipboard, 'writeText', { configurable: true, value: writeText, writable: true })
} catch {
// Browser refused override; primitives keep using the native API.
}
}
+112
View File
@@ -0,0 +1,112 @@
import type { HapticInput, TriggerOptions } from 'web-haptics'
import { $hapticsMuted } from '@/store/haptics'
export type HapticIntent =
| 'cancel'
| 'close'
| 'crisp'
| 'error'
| 'open'
| 'selection'
| 'streamDone'
| 'streamStart'
| 'submit'
| 'success'
| 'tap'
| 'warning'
interface HapticConfig {
options?: TriggerOptions
pattern: HapticInput
}
const airyTap = [{ duration: 16, intensity: 0.52 }]
const crispTap = [{ duration: 10, intensity: 0.92 }]
const friendlySuccess = [
{ duration: 28, intensity: 0.5 },
{ delay: 42, duration: 30, intensity: 0.68 },
{ delay: 48, duration: 38, intensity: 0.86 }
]
const softArrive = [
{ duration: 18, intensity: 0.42 },
{ delay: 36, duration: 22, intensity: 0.66 }
]
const softLeave = [
{ duration: 22, intensity: 0.58 },
{ delay: 32, duration: 16, intensity: 0.34 }
]
const HAPTIC_INTENTS: Record<HapticIntent, HapticConfig> = {
cancel: {
pattern: [
{ duration: 34, intensity: 0.72 },
{ delay: 54, duration: 26, intensity: 0.38 }
]
},
close: { pattern: softLeave },
crisp: { pattern: crispTap },
error: {
pattern: [
{ duration: 34, intensity: 0.82 },
{ delay: 42, duration: 34, intensity: 0.72 },
{ delay: 58, duration: 44, intensity: 0.86 }
]
},
open: { pattern: softArrive },
selection: { pattern: airyTap },
streamDone: { pattern: friendlySuccess },
streamStart: { pattern: [{ duration: 10, intensity: 0.32 }] },
submit: {
pattern: [
{ duration: 24, intensity: 0.58 },
{ delay: 48, duration: 36, intensity: 0.82 }
]
},
success: { pattern: friendlySuccess },
tap: {
pattern: [
{ duration: 14, intensity: 0.58 },
{ delay: 30, duration: 12, intensity: 0.42 }
]
},
warning: {
pattern: [
{ duration: 34, intensity: 0.64 },
{ delay: 84, duration: 42, intensity: 0.5 }
]
}
}
export type HapticTrigger = (input?: HapticInput, options?: TriggerOptions) => Promise<void> | undefined
let registeredTrigger: HapticTrigger | null = null
let lastSelectionAt = 0
export function registerHapticTrigger(trigger: HapticTrigger | null) {
registeredTrigger = trigger
}
export function triggerHaptic(intent: HapticIntent = 'selection') {
if ($hapticsMuted.get() || !registeredTrigger) {
return
}
const now = performance.now()
if (intent === 'selection') {
if (now - lastSelectionAt < 50) {
return
}
lastSelectionAt = now
}
const config = HAPTIC_INTENTS[intent]
void registeredTrigger(config.pattern, config.options)?.catch(() => undefined)
}
+10 -1
View File
@@ -3,10 +3,15 @@ import './styles.css'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import { HashRouter } from 'react-router-dom'
import App from './app'
import { HapticsProvider } from './components/haptics-provider'
import { installClipboardShim } from './lib/clipboard'
import { ThemeProvider } from './themes/context'
installClipboardShim()
const queryClient = new QueryClient({
defaultOptions: {
queries: {
@@ -20,7 +25,11 @@ createRoot(document.getElementById('root')!).render(
<StrictMode>
<QueryClientProvider client={queryClient}>
<ThemeProvider>
<App />
<HapticsProvider>
<HashRouter>
<App />
</HashRouter>
</HapticsProvider>
</ThemeProvider>
</QueryClientProvider>
</StrictMode>
+9 -1
View File
@@ -1,5 +1,7 @@
import { atom } from 'nanostores'
import { triggerHaptic } from '@/lib/haptics'
export interface ComposerAttachment {
id: string
kind: 'image' | 'file' | 'folder' | 'url'
@@ -22,7 +24,13 @@ export function clearComposerDraft() {
}
export function addComposerAttachment(attachment: ComposerAttachment) {
$composerAttachments.set(upsertAttachment($composerAttachments.get(), attachment))
const previous = $composerAttachments.get()
const next = upsertAttachment(previous, attachment)
$composerAttachments.set(next)
if (next.length > previous.length && attachment.kind !== 'url') {
triggerHaptic('selection')
}
}
export function removeComposerAttachment(id: string): ComposerAttachment | null {
+17
View File
@@ -0,0 +1,17 @@
import { atom } from 'nanostores'
import { persistBoolean, storedBoolean } from '@/lib/storage'
const HAPTICS_MUTED_STORAGE_KEY = 'hermes.desktop.hapticsMuted'
export const $hapticsMuted = atom(storedBoolean(HAPTICS_MUTED_STORAGE_KEY, false))
$hapticsMuted.subscribe(muted => persistBoolean(HAPTICS_MUTED_STORAGE_KEY, muted))
export function setHapticsMuted(muted: boolean) {
$hapticsMuted.set(muted)
}
export function toggleHapticsMuted() {
$hapticsMuted.set(!$hapticsMuted.get())
}
+50 -7
View File
@@ -7,6 +7,7 @@ export interface AppNotification {
kind: NotificationKind
title?: string
message: string
detail?: string
createdAt: number
}
@@ -15,6 +16,7 @@ interface NotificationInput {
kind?: NotificationKind
title?: string
message: string
detail?: string
durationMs?: number
}
@@ -31,14 +33,51 @@ function defaultDuration(kind: NotificationKind) {
return 5_000
}
function readableErrorMessage(error: unknown, fallback: string) {
function cleanErrorText(value: string) {
return value.replace(/^Error:\s*/, '').trim()
}
const ERROR_SUMMARIES: { test: (msg: string) => boolean; summarize: (msg: string) => string }[] = [
{
test: msg => /incorrect api key provided/i.test(msg) || /['"]code['"]\s*:\s*['"]invalid_api_key['"]/i.test(msg),
summarize: msg => {
const status = msg.match(/(?:error code|status(?:Code)?)[^\d]*(\d{3})/i)?.[1]
return `OpenAI rejected the API key${status ? ` (${status} invalid_api_key)` : ''}.`
}
},
{
test: msg => /neither voice_tools_openai_key nor openai_api_key is set/i.test(msg),
summarize: () => 'OpenAI TTS needs VOICE_TOOLS_OPENAI_KEY or OPENAI_API_KEY.'
},
{
test: msg => /method not allowed/i.test(msg),
summarize: () => 'The desktop backend does not support that audio endpoint yet. Restart Hermes Desktop.'
},
{
test: msg => /microphone permission/i.test(msg),
summarize: () => 'Microphone permission was denied.'
}
]
function summarizeErrorMessage(message: string, fallback: string) {
const rule = ERROR_SUMMARIES.find(r => r.test(message))
if (rule) {
return rule.summarize(message)
}
return message.length > 180 ? fallback : message || fallback
}
function readableError(error: unknown, fallback: string): { message: string; detail?: string } {
const raw = error instanceof Error ? error.message : typeof error === 'string' ? error : fallback
const unwrapped = raw.match(/Error invoking remote method '[^']+': Error: (.+)$/)?.[1] ?? raw
const cleaned = cleanErrorText(unwrapped)
const detail = cleaned.match(/"detail"\s*:\s*"([^"]+)"/)?.[1] ?? cleaned
const summary = summarizeErrorMessage(detail, 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
return { message: summary, detail: detail === summary ? undefined : detail }
}
export function notify(input: NotificationInput): string {
@@ -50,6 +89,7 @@ export function notify(input: NotificationInput): string {
kind,
title: input.title,
message: input.message,
detail: input.detail,
createdAt: Date.now()
}
@@ -70,10 +110,13 @@ export function notify(input: NotificationInput): string {
}
export function notifyError(error: unknown, fallback: string): string {
const readable = readableError(error, fallback)
return notify({
kind: 'error',
title: fallback,
message: readableErrorMessage(error, fallback)
message: readable.message,
detail: readable.detail
})
}
+19 -1
View File
@@ -1,9 +1,9 @@
import { atom } from 'nanostores'
import type { ContextSuggestion } from '@/app/types'
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)
@@ -20,6 +20,7 @@ export const $connection = atom<HermesConnection | null>(null)
export const $gatewayState = atom('idle')
export const $sessions = atom<SessionInfo[]>([])
export const $sessionsLoading = atom(true)
export const $workingSessionIds = atom<string[]>([])
export const $activeSessionId = atom<string | null>(null)
export const $selectedStoredSessionId = atom<string | null>(null)
export const $messages = atom<ChatMessage[]>([])
@@ -41,6 +42,7 @@ export const setConnection = (next: Updater<HermesConnection | null>) => updateA
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 setWorkingSessionIds = (next: Updater<string[]>) => updateAtom($workingSessionIds, 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)
@@ -57,3 +59,19 @@ export const setAvailablePersonalities = (next: Updater<string[]>) => updateAtom
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)
export function setSessionWorking(sessionId: string | null | undefined, working: boolean) {
if (!sessionId) {
return
}
setWorkingSessionIds(current => {
const alreadyWorking = current.includes(sessionId)
if (working) {
return alreadyWorking ? current : [...current, sessionId]
}
return alreadyWorking ? current.filter(id => id !== sessionId) : current
})
}
+88
View File
@@ -177,12 +177,100 @@
textarea {
font: inherit;
}
}
button {
-webkit-app-region: no-drag;
}
.composer-liquid-shell-wrap {
pointer-events: none;
border-radius: var(--composer-glass-radius, 20px);
isolation: isolate;
overflow: hidden;
}
/* Fragment siblings emitted by liquid-glass-react (not inside .composer-liquid-shell). */
.composer-liquid-shell-wrap > div:not(.composer-liquid-shell) {
position: absolute !important;
inset: 0 !important;
top: 0 !important;
left: 0 !important;
width: 100% !important;
height: 100% !important;
transform: none !important;
margin: 0 !important;
box-sizing: border-box;
}
/* Liquid-glass rim spans (toggle via data-show-library-rims on shell wrap). */
.composer-liquid-shell-wrap:not([data-show-library-rims='true']) > span {
display: none !important;
}
.composer-liquid-shell-wrap[data-show-library-rims='true'] > span {
position: absolute !important;
inset: 0 !important;
top: 0 !important;
left: 0 !important;
width: 100% !important;
height: 100% !important;
transform: none !important;
margin: 0 !important;
box-sizing: border-box;
display: block !important;
}
.composer-liquid-shell {
z-index: 1;
top: 0 !important;
left: 0 !important;
transform: none !important;
transition: none !important;
}
.composer-liquid-shell > svg {
position: absolute !important;
inset: 0 !important;
top: 0 !important;
left: 0 !important;
width: 100% !important;
height: 100% !important;
transform: none !important;
}
.composer-liquid-shell > .glass,
.composer-liquid-shell > :not(svg):not(.glass) {
position: absolute !important;
inset: 0 !important;
top: 0 !important;
left: 0 !important;
width: 100% !important;
height: 100% !important;
transform: none !important;
}
.composer-liquid-shell > .glass {
width: 100% !important;
height: 100% !important;
padding: 0 !important;
border-radius: var(--composer-glass-radius, 20px) !important;
box-shadow: none !important;
}
.composer-liquid-shell > .glass > .glass__warp {
border-radius: var(--composer-glass-radius, 20px) !important;
}
.composer-liquid-shell > .glass > div {
width: 100%;
height: 100%;
font: inherit !important;
text-shadow: none !important;
color: inherit !important;
}
input,
textarea,
[contenteditable]:not([contenteditable='false']),
+35 -2
View File
@@ -116,6 +116,10 @@ function lightColors(seed: DesktopTheme, skinName: string): DesktopThemeColors {
return nousLightTheme.colors
}
if (skinName === 'nous') {
return seed.colors
}
const accent = seed.colors.ring || seed.colors.primary
const soft = mix('#ffffff', accent, 0.1)
const softer = mix('#ffffff', accent, 0.06)
@@ -168,6 +172,32 @@ function deriveTheme(skinName: string, mode: 'light' | 'dark'): DesktopTheme {
}
}
function skinNameFromTheme(theme: DesktopTheme, mode: 'light' | 'dark'): string {
const suffix = `-${mode}`
return theme.name.endsWith(suffix) ? theme.name.slice(0, -suffix.length) : theme.name
}
/**
* Returns the *rendered* mode for a theme, regardless of what the user has
* toggled. A skin like Nous keeps a white background even when `mode === 'dark'`,
* so we shouldn't apply the `.dark` class (which assumes a dark surface and
* triggers shadow/scrollbar/form-control rules tuned for one). Decide from the
* actual background luminance.
*/
function renderedModeFor(colors: DesktopThemeColors, mode: 'light' | 'dark'): 'light' | 'dark' {
const rgb = hexToRgb(colors.background)
if (!rgb) {
return mode
}
const [r, g, b] = rgb.map(v => v / 255)
const luminance = 0.2126 * r + 0.7152 * g + 0.0722 * b
return luminance > 0.5 ? 'light' : 'dark'
}
// ─── CSS application ────────────────────────────────────────────────────────
function applyTheme(theme: DesktopTheme, mode: 'light' | 'dark') {
@@ -180,8 +210,11 @@ function applyTheme(theme: DesktopTheme, mode: 'light' | 'dark') {
const layout = { ...DEFAULT_LAYOUT, ...theme.layout }
const c = theme.colors
root.style.setProperty('color-scheme', mode)
root.classList.toggle('dark', mode === 'dark')
const rendered = renderedModeFor(theme.colors, mode)
root.style.setProperty('color-scheme', rendered)
root.dataset.hermesTheme = skinNameFromTheme(theme, mode)
root.classList.toggle('dark', rendered === 'dark')
const vars: Record<string, string> = {
'--dt-background': c.background,
+43
View File
@@ -86,6 +86,48 @@ export const hermesGoldTheme: DesktopTheme = {
}
}
const NOUS_LENS_BLUE = '#0053FD'
/** Nous — bright white with electric blue from the NousNet identity system. */
export const nousTheme: DesktopTheme = {
name: 'nous',
label: 'Nous',
description: 'Design-system white with electric Nous blue and subtle grain',
colors: {
background: '#FFFFFF',
foreground: '#17171A',
card: '#FFFFFF',
cardForeground: '#17171A',
muted: `color-mix(in srgb, ${NOUS_LENS_BLUE} 5%, #FFFFFF)`,
mutedForeground: '#666678',
popover: '#FFFFFF',
popoverForeground: '#17171A',
primary: NOUS_LENS_BLUE,
primaryForeground: '#FFFFFF',
secondary: `color-mix(in srgb, ${NOUS_LENS_BLUE} 7%, #FFFFFF)`,
secondaryForeground: '#242432',
accent: `color-mix(in srgb, ${NOUS_LENS_BLUE} 10%, #FFFFFF)`,
accentForeground: '#202030',
border: `color-mix(in srgb, ${NOUS_LENS_BLUE} 22%, transparent)`,
input: `color-mix(in srgb, ${NOUS_LENS_BLUE} 30%, transparent)`,
ring: NOUS_LENS_BLUE,
destructive: '#C72E4D',
destructiveForeground: '#FFFFFF',
sidebarBackground: `color-mix(in srgb, ${NOUS_LENS_BLUE} 2.5%, #FFFFFF)`,
sidebarBorder: `color-mix(in srgb, ${NOUS_LENS_BLUE} 18%, transparent)`,
userBubble: `color-mix(in srgb, ${NOUS_LENS_BLUE} 6%, #FFFFFF)`,
userBubbleBorder: `color-mix(in srgb, ${NOUS_LENS_BLUE} 24%, transparent)`
},
typography: {
fontSans: SYSTEM_SANS,
fontMono: `"Courier Prime", ${SYSTEM_MONO}`,
fontUrl: 'https://fonts.googleapis.com/css2?family=Courier+Prime:wght@400;700&display=swap'
},
layout: {
radius: '0.25rem'
}
}
/** Classic Hermes dark teal. */
export const defaultTheme: DesktopTheme = {
name: 'default',
@@ -314,6 +356,7 @@ export const slateTheme: DesktopTheme = {
export const BUILTIN_THEMES: Record<string, DesktopTheme> = {
'nous-light': nousLightTheme,
default: defaultTheme,
nous: nousTheme,
gold: hermesGoldTheme,
midnight: midnightTheme,
ember: emberTheme,
+32
View File
@@ -10,6 +10,30 @@ export interface ConfigSchemaResponse {
fields: Record<string, ConfigFieldSchema>
}
export interface AudioTranscriptionResponse {
ok: boolean
provider?: string
transcript: string
}
export interface AudioSpeakResponse {
ok: boolean
data_url: string
mime_type: string
provider?: string
}
export interface ElevenLabsVoice {
label: string
name: string
voice_id: string
}
export interface ElevenLabsVoicesResponse {
available: boolean
voices: ElevenLabsVoice[]
}
export interface EnvVarInfo {
advanced: boolean
category: string
@@ -36,6 +60,12 @@ export interface HermesConfig {
terminal?: {
cwd?: string
}
stt?: {
enabled?: boolean
}
voice?: {
max_recording_seconds?: number
}
}
export type HermesConfigRecord = Record<string, unknown>
@@ -79,6 +109,8 @@ export interface RpcEvent<T = unknown> {
export interface SessionCreateResponse {
info?: SessionRuntimeInfo
message_count?: number
messages?: SessionMessage[]
session_id: string
stored_session_id?: string
}
+2 -2
View File
@@ -730,7 +730,7 @@ DEFAULT_CONFIG = {
"display": {
"compact": False,
"personality": "kawaii",
"personality": "",
"resume_display": "full",
"busy_input_mode": "interrupt", # interrupt | queue | steer
# When true, `hermes --tui` auto-resumes the most recent human-
@@ -4375,7 +4375,7 @@ def show_config():
print()
print(color("◆ Display", Colors.CYAN, Colors.BOLD))
display = config.get('display', {})
print(f" Personality: {display.get('personality', 'kawaii')}")
print(f" Personality: {display.get('personality') or 'none'}")
print(f" Reasoning: {'on' if display.get('show_reasoning', False) else 'off'}")
print(f" Bell: {'on' if display.get('bell_on_complete', False) else 'off'}")
ump = display.get('user_message_preview', {}) if isinstance(display.get('user_message_preview', {}), dict) else {}
+226 -3
View File
@@ -10,6 +10,8 @@ Usage:
"""
import asyncio
import base64
import binascii
import hmac
import importlib.util
import json
@@ -18,6 +20,7 @@ import os
import secrets
import subprocess
import sys
import tempfile
import threading
import time
import urllib.parse
@@ -449,6 +452,11 @@ class EnvVarReveal(BaseModel):
key: str
class AudioTranscriptionRequest(BaseModel):
data_url: str
mime_type: Optional[str] = None
class ModelAssignment(BaseModel):
"""Payload for POST /api/model/set — assign a provider/model to a slot.
@@ -463,6 +471,29 @@ class ModelAssignment(BaseModel):
task: str = ""
_AUDIO_MIME_EXTENSIONS: Dict[str, str] = {
"audio/aac": ".aac",
"audio/flac": ".flac",
"audio/m4a": ".m4a",
"audio/mp3": ".mp3",
"audio/mp4": ".mp4",
"audio/mpeg": ".mp3",
"audio/ogg": ".ogg",
"audio/wav": ".wav",
"audio/wave": ".wav",
"audio/webm": ".webm",
"audio/x-m4a": ".m4a",
"audio/x-wav": ".wav",
"video/webm": ".webm",
}
_MAX_TRANSCRIPTION_UPLOAD_BYTES = 25 * 1024 * 1024
def _audio_extension_for_mime(mime_type: str) -> str:
normalized = (mime_type or "").split(";", 1)[0].strip().lower()
return _AUDIO_MIME_EXTENSIONS.get(normalized, ".webm")
_GATEWAY_HEALTH_URL = os.getenv("GATEWAY_HEALTH_URL")
try:
_GATEWAY_HEALTH_TIMEOUT = float(os.getenv("GATEWAY_HEALTH_TIMEOUT", "3"))
@@ -616,6 +647,197 @@ async def get_status():
}
@app.post("/api/audio/transcribe")
async def transcribe_audio_upload(payload: AudioTranscriptionRequest):
data_url = (payload.data_url or "").strip()
if not data_url.startswith("data:") or "," not in data_url:
raise HTTPException(status_code=400, detail="Invalid audio payload")
header, encoded = data_url.split(",", 1)
if ";base64" not in header:
raise HTTPException(status_code=400, detail="Audio payload must be base64 encoded")
mime_type = (payload.mime_type or header[5:].split(";", 1)[0] or "audio/webm").strip()
normalized_mime_type = mime_type.split(";", 1)[0].lower()
if not (normalized_mime_type.startswith("audio/") or normalized_mime_type == "video/webm"):
raise HTTPException(status_code=400, detail="Payload must be an audio recording")
try:
audio_bytes = base64.b64decode(encoded, validate=True)
except (binascii.Error, ValueError):
raise HTTPException(status_code=400, detail="Audio payload is not valid base64")
if not audio_bytes:
raise HTTPException(status_code=400, detail="Audio recording is empty")
if len(audio_bytes) > _MAX_TRANSCRIPTION_UPLOAD_BYTES:
raise HTTPException(status_code=413, detail="Audio recording is too large")
temp_path = ""
try:
suffix = _audio_extension_for_mime(mime_type)
with tempfile.NamedTemporaryFile(
prefix="hermes-desktop-voice-",
suffix=suffix,
delete=False,
) as tmp:
tmp.write(audio_bytes)
temp_path = tmp.name
from tools.transcription_tools import transcribe_audio
loop = asyncio.get_running_loop()
result = await loop.run_in_executor(None, transcribe_audio, temp_path)
except HTTPException:
raise
except Exception as exc:
_log.exception("Desktop voice transcription failed")
raise HTTPException(status_code=500, detail=f"Transcription failed: {exc}")
finally:
if temp_path:
try:
os.unlink(temp_path)
except OSError:
pass
if not result.get("success"):
raise HTTPException(
status_code=400,
detail=result.get("error") or "Transcription failed",
)
return {
"ok": True,
"transcript": str(result.get("transcript") or "").strip(),
"provider": result.get("provider"),
}
class TTSSpeakRequest(BaseModel):
text: str
def _elevenlabs_voice_label(voice: Dict[str, Any]) -> str:
name = str(voice.get("name") or voice.get("voice_id") or "Voice").strip()
category = str(voice.get("category") or "").strip()
return f"{name} ({category})" if category else name
@app.get("/api/audio/elevenlabs/voices")
async def get_elevenlabs_voices():
"""Return ElevenLabs voices when an API key is configured.
The desktop UI uses this for the ``tts.elevenlabs.voice_id`` dropdown.
Only non-secret voice metadata is returned; the API key stays server-side.
"""
api_key = (load_env().get("ELEVENLABS_API_KEY") or os.environ.get("ELEVENLABS_API_KEY") or "").strip()
if not api_key:
return {"available": False, "voices": []}
request = urllib.request.Request(
"https://api.elevenlabs.io/v1/voices",
headers={
"Accept": "application/json",
"xi-api-key": api_key,
},
)
try:
loop = asyncio.get_running_loop()
def _fetch() -> Dict[str, Any]:
with urllib.request.urlopen(request, timeout=10) as response:
return json.loads(response.read().decode("utf-8"))
payload = await loop.run_in_executor(None, _fetch)
except Exception as exc:
_log.warning("ElevenLabs voice list failed: %s", exc)
raise HTTPException(status_code=502, detail="Could not load ElevenLabs voices")
voices = []
for voice in payload.get("voices") or []:
if not isinstance(voice, dict):
continue
voice_id = str(voice.get("voice_id") or "").strip()
if not voice_id:
continue
voices.append({
"voice_id": voice_id,
"name": str(voice.get("name") or voice_id),
"label": _elevenlabs_voice_label(voice),
})
voices.sort(key=lambda item: str(item.get("label") or "").lower())
return {"available": True, "voices": voices}
@app.post("/api/audio/speak")
async def speak_text(payload: TTSSpeakRequest):
"""Synthesize speech and return audio as base64 data URL.
Used by the desktop voice-conversation mode to play back assistant
responses without exposing the on-disk file path. Reuses the
existing TTS provider chain (Edge / OpenAI / ElevenLabs / etc.)
configured in ``~/.hermes/config.yaml`` under ``tts.``.
"""
text = (payload.text or "").strip()
if not text:
raise HTTPException(status_code=400, detail="Text is required")
try:
from tools.tts_tool import text_to_speech_tool
loop = asyncio.get_running_loop()
result_json = await loop.run_in_executor(None, text_to_speech_tool, text)
except Exception as exc:
_log.exception("Desktop voice TTS failed")
raise HTTPException(status_code=500, detail=f"Speech synthesis failed: {exc}")
try:
result = json.loads(result_json) if isinstance(result_json, str) else result_json
except Exception:
raise HTTPException(status_code=500, detail="Invalid TTS response")
if not result.get("success"):
raise HTTPException(
status_code=400,
detail=result.get("error") or "Speech synthesis failed",
)
file_path = result.get("file_path")
if not file_path or not os.path.isfile(file_path):
raise HTTPException(status_code=500, detail="Audio file missing")
ext = os.path.splitext(file_path)[1].lower()
mime_type = {
".mp3": "audio/mpeg",
".ogg": "audio/ogg",
".opus": "audio/ogg",
".wav": "audio/wav",
".flac": "audio/flac",
}.get(ext, "audio/mpeg")
try:
with open(file_path, "rb") as fh:
audio_bytes = fh.read()
except OSError as exc:
raise HTTPException(status_code=500, detail=f"Could not read audio: {exc}")
finally:
try:
os.unlink(file_path)
except OSError:
pass
encoded = base64.b64encode(audio_bytes).decode("ascii")
return {
"ok": True,
"data_url": f"data:{mime_type};base64,{encoded}",
"mime_type": mime_type,
"provider": result.get("provider"),
}
# ---------------------------------------------------------------------------
# Gateway + update actions (invoked from the Status page).
#
@@ -749,13 +971,14 @@ async def get_action_status(name: str, lines: int = 200):
@app.get("/api/sessions")
async def get_sessions(limit: int = 20, offset: int = 0):
async def get_sessions(limit: int = 20, offset: int = 0, min_messages: int = 0):
try:
from hermes_state import SessionDB
db = SessionDB()
try:
sessions = db.list_sessions_rich(limit=limit, offset=offset)
total = db.session_count()
min_message_count = max(0, min_messages)
sessions = db.list_sessions_rich(limit=limit, offset=offset, min_message_count=min_message_count)
total = db.session_count(min_message_count=min_message_count)
now = time.time()
for s in sessions:
s["is_active"] = (
+18 -7
View File
@@ -932,6 +932,7 @@ class SessionDB:
limit: int = 20,
offset: int = 0,
include_children: bool = False,
min_message_count: int = 0,
project_compression_tips: bool = True,
) -> List[Dict[str, Any]]:
"""List sessions with preview (first user message) and last active timestamp.
@@ -977,6 +978,9 @@ class SessionDB:
placeholders = ",".join("?" for _ in exclude_sources)
where_clauses.append(f"s.source NOT IN ({placeholders})")
params.extend(exclude_sources)
if min_message_count > 0:
where_clauses.append("s.message_count >= ?")
params.append(min_message_count)
where_sql = f"WHERE {' AND '.join(where_clauses)}" if where_clauses else ""
query = f"""
@@ -1798,15 +1802,22 @@ class SessionDB:
# Utility
# =========================================================================
def session_count(self, source: str = None) -> int:
def session_count(self, source: str = None, min_message_count: int = 0) -> int:
"""Count sessions, optionally filtered by source."""
where_clauses = []
params = []
if source:
where_clauses.append("source = ?")
params.append(source)
if min_message_count > 0:
where_clauses.append("message_count >= ?")
params.append(min_message_count)
where_sql = f" WHERE {' AND '.join(where_clauses)}" if where_clauses else ""
with self._lock:
if source:
cursor = self._conn.execute(
"SELECT COUNT(*) FROM sessions WHERE source = ?", (source,)
)
else:
cursor = self._conn.execute("SELECT COUNT(*) FROM sessions")
cursor = self._conn.execute(f"SELECT COUNT(*) FROM sessions{where_sql}", params)
return cursor.fetchone()[0]
def message_count(self, session_id: str = None) -> int:
+44
View File
@@ -125,6 +125,50 @@ class TestWebServerEndpoints:
assert "hermes_home" in data
assert "active_sessions" in data
def test_audio_transcription_endpoint(self, monkeypatch):
import tools.transcription_tools as transcription_tools
captured = {}
def fake_transcribe_audio(path):
captured["path"] = path
return {
"success": True,
"transcript": "hello from voice mode",
"provider": "test",
}
monkeypatch.setattr(transcription_tools, "transcribe_audio", fake_transcribe_audio)
resp = self.client.post(
"/api/audio/transcribe",
json={
"data_url": "data:audio/webm;base64,aGVsbG8=",
"mime_type": "audio/webm",
},
)
assert resp.status_code == 200
assert resp.json() == {
"ok": True,
"transcript": "hello from voice mode",
"provider": "test",
}
assert captured["path"].endswith(".webm")
assert not Path(captured["path"]).exists()
def test_audio_transcription_rejects_invalid_base64(self):
resp = self.client.post(
"/api/audio/transcribe",
json={
"data_url": "data:audio/webm;base64,not base64",
"mime_type": "audio/webm",
},
)
assert resp.status_code == 400
assert "base64" in resp.json()["detail"]
def test_get_status_filters_unconfigured_gateway_platforms(self, monkeypatch):
import gateway.config as gateway_config
import hermes_cli.web_server as web_server
+40 -4
View File
@@ -507,6 +507,14 @@ def _start_agent_build(sid: str, session: dict) -> None:
db = _get_db()
if db is not None:
db.create_session(key, source="tui", model=_resolve_model())
seed_history = current.get("history") or []
for msg in seed_history:
if isinstance(msg, dict) and msg.get("role") in ("user", "assistant", "system"):
db.append_message(
session_id=key,
role=msg.get("role", "user"),
content=msg.get("content"),
)
pending_title = (current.get("pending_title") or "").strip()
if pending_title:
try:
@@ -2009,6 +2017,30 @@ def _history_to_messages(history: list[dict]) -> list[dict]:
return messages
def _coerce_seed_history(value: Any) -> list[dict]:
if not isinstance(value, list):
return []
history = []
for item in value:
if not isinstance(item, dict):
continue
role = item.get("role")
if role not in ("user", "assistant", "system"):
continue
content = item.get("content")
if content is None:
content = item.get("text")
if not isinstance(content, str) or not content.strip():
continue
history.append({"role": role, "content": content})
return history
# ── Methods: session ─────────────────────────────────────────────────
@@ -2017,6 +2049,8 @@ def _(rid, params: dict) -> dict:
sid = uuid.uuid4().hex[:8]
key = _new_session_key()
cols = int(params.get("cols", 80))
history = _coerce_seed_history(params.get("messages"))
title = str(params.get("title") or "").strip()
_enable_gateway_prompts()
ready = threading.Event()
@@ -2028,12 +2062,12 @@ def _(rid, params: dict) -> dict:
"attached_images": [],
"cols": cols,
"edit_snapshots": {},
"history": [],
"history": history,
"history_lock": threading.Lock(),
"history_version": 0,
"image_counter": 0,
"cwd": _completion_cwd(params),
"pending_title": None,
"pending_title": title or None,
"running": False,
"session_key": key,
"show_reasoning": _load_show_reasoning(),
@@ -2062,6 +2096,8 @@ def _(rid, params: dict) -> dict:
{
"session_id": sid,
"stored_session_id": key,
"message_count": len(history),
"messages": _history_to_messages(history),
"info": {
"model": _resolve_model(),
"tools": {},
@@ -3747,7 +3783,7 @@ def _(rid, params: dict) -> dict:
pname, new_prompt = _validate_personality(str(value or ""), cfg)
_write_config_key("display.personality", pname)
_write_config_key("agent.system_prompt", new_prompt)
nv = str(value or "default")
nv = str(value or "none")
history_reset, info = _apply_personality_to_session(
sid_key, session, new_prompt, pname
)
@@ -3816,7 +3852,7 @@ def _(rid, params: dict) -> dict:
if key == "personality":
return _ok(
rid,
{"value": (_load_cfg().get("display") or {}).get("personality", "default")},
{"value": (_load_cfg().get("display") or {}).get("personality") or "none"},
)
if key == "reasoning":
cfg = _load_cfg()