mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-29 18:46:59 +00:00
Merge pull request #73698 from NousResearch/bb/desktop-overlay-perf
perf(desktop): kill sidebar + overlay render churn from hot store subscriptions
This commit is contained in:
commit
40a53ca031
12 changed files with 815 additions and 201 deletions
61
apps/desktop/scripts/diag-overlay-ab.mjs
Normal file
61
apps/desktop/scripts/diag-overlay-ab.mjs
Normal file
|
|
@ -0,0 +1,61 @@
|
|||
#!/usr/bin/env node
|
||||
// Robust A/B: measure each surface N times, report median wasted renders.
|
||||
// Reduces timing noise from stream ticks landing on different clicks.
|
||||
import WebSocket from 'ws'
|
||||
|
||||
const RUNS = 3
|
||||
let msgId = 1
|
||||
function send(ws, method, params = {}) {
|
||||
const id = msgId++
|
||||
return new Promise((resolve, reject) => {
|
||||
const h = (data) => { const m = JSON.parse(data); if (m.id === id) { ws.off('message', h); m.error ? reject(m.error) : resolve(m.result) } }
|
||||
ws.on('message', h); ws.send(JSON.stringify({ id, method, params }))
|
||||
})
|
||||
}
|
||||
async function ev(ws, e) { return (await send(ws, 'Runtime.evaluate', { expression: e, returnByValue: true, awaitPromise: true }))?.result?.value }
|
||||
const sleep = ms => new Promise(r => setTimeout(r, ms))
|
||||
const median = arr => { const s = [...arr].sort((a,b)=>a-b); return s[Math.floor(s.length/2)] }
|
||||
|
||||
async function wsUrl() {
|
||||
const d = await (await fetch('http://127.0.0.1:9222/json/list')).json()
|
||||
return d.find(t => t.type === 'page' && (t.url||'').includes('5174')).webSocketDebuggerUrl
|
||||
}
|
||||
|
||||
async function measureOnce(ws, setup, holdMs) {
|
||||
await setup(ws)
|
||||
await sleep(300)
|
||||
await ev(ws, `__RENDER_COUNTS__.start(); true`)
|
||||
await sleep(holdMs)
|
||||
const rep = JSON.parse(await ev(ws, `JSON.stringify(__RENDER_COUNTS__.report())`))
|
||||
return rep.reduce((s, c) => s + c.wasted, 0)
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const ws = new WebSocket(await wsUrl())
|
||||
await new Promise((res, rej) => { ws.on('open', res); ws.on('error', rej) })
|
||||
await send(ws, 'Runtime.enable')
|
||||
|
||||
const surfaces = [
|
||||
['Artifacts', ws => ev(ws, `window.location.hash='#/artifacts'; true`), 2000],
|
||||
['Messaging', ws => ev(ws, `window.location.hash='#/messaging'; true`), 2000],
|
||||
['Cron', ws => ev(ws, `window.location.hash='#/cron'; true`), 2000],
|
||||
['Profiles', ws => ev(ws, `window.location.hash='#/profiles'; true`), 2000],
|
||||
['Agents', ws => ev(ws, `window.location.hash='#/agents'; true`), 2000],
|
||||
['Starmap', ws => ev(ws, `window.location.hash='#/starmap'; true`), 2000],
|
||||
['Webhooks', ws => ev(ws, `window.location.hash='#/webhooks'; true`), 2000],
|
||||
['CommandCenter/System', async ws => { await ev(ws, `window.location.hash='#/command-center'; true`); await sleep(400); await ev(ws, `Array.from(document.querySelectorAll('button')).find(b=>b.textContent?.trim()==='System')?.click(); true`) }, 2000],
|
||||
]
|
||||
|
||||
const label = process.argv[2] || 'RUN'
|
||||
console.log(`\n=== ${label} (median of ${RUNS}, wasted renders, 2s idle) ===`)
|
||||
for (const [name, setup, hold] of surfaces) {
|
||||
const runs = []
|
||||
for (let i = 0; i < RUNS; i++) {
|
||||
runs.push(await measureOnce(ws, setup, hold))
|
||||
await ev(ws, `window.location.hash='#/'; true`); await sleep(300)
|
||||
}
|
||||
console.log(` ${name.padEnd(24)} median ${String(median(runs)).padStart(6)} (runs: ${runs.join(', ')})`)
|
||||
}
|
||||
ws.close()
|
||||
}
|
||||
main().catch(e => { console.error(e); process.exit(1) })
|
||||
161
apps/desktop/scripts/diag-overlay-churn.mjs
Normal file
161
apps/desktop/scripts/diag-overlay-churn.mjs
Normal file
|
|
@ -0,0 +1,161 @@
|
|||
#!/usr/bin/env node
|
||||
// CDP probe: measure render churn on overlay surfaces (cmdk, settings, command-center, skills)
|
||||
// against the user's live instance on :9222. Read-only — never closes or navigates away.
|
||||
import WebSocket from 'ws'
|
||||
|
||||
const CDP_URL = 'ws://127.0.0.1:9222'
|
||||
const TARGET_GLOB = '/devtools/page/'
|
||||
|
||||
let msgId = 1
|
||||
|
||||
function send(ws, method, params = {}) {
|
||||
const id = msgId++
|
||||
return new Promise((resolve, reject) => {
|
||||
const handler = (data) => {
|
||||
const msg = JSON.parse(data.toString())
|
||||
if (msg.id === id) {
|
||||
ws.off('message', handler)
|
||||
if (msg.error) reject(new Error(JSON.stringify(msg.error)))
|
||||
else resolve(msg.result)
|
||||
}
|
||||
}
|
||||
ws.on('message', handler)
|
||||
ws.send(JSON.stringify({ id, method, params }))
|
||||
})
|
||||
}
|
||||
|
||||
async function getRendererTarget() {
|
||||
const resp = await fetch('http://127.0.0.1:9222/json/list')
|
||||
const targets = await resp.json()
|
||||
// Find the renderer target (not devtools)
|
||||
return targets.find(t => t.url?.startsWith('http://127.0.0.1:5174') || t.url?.includes('5174'))
|
||||
?? targets.find(t => t.type === 'page' && !t.url.startsWith('devtools'))
|
||||
}
|
||||
|
||||
async function evalInPage(ws, expr) {
|
||||
const result = await send(ws, 'Runtime.evaluate', {
|
||||
expression: expr,
|
||||
returnByValue: true,
|
||||
awaitPromise: true,
|
||||
})
|
||||
return result?.result?.value
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const target = await getRendererTarget()
|
||||
if (!target) {
|
||||
console.error('No renderer target found')
|
||||
process.exit(1)
|
||||
}
|
||||
console.log(`Target: ${target.url}`)
|
||||
|
||||
const ws = new WebSocket(target.webSocketDebuggerUrl)
|
||||
await new Promise((resolve, reject) => {
|
||||
ws.on('open', resolve)
|
||||
ws.on('error', reject)
|
||||
})
|
||||
|
||||
await send(ws, 'Runtime.enable')
|
||||
|
||||
// Check if __RENDER_COUNTS__ is available
|
||||
const hasCounter = await evalInPage(ws, 'typeof __RENDER_COUNTS__')
|
||||
console.log(`__RENDER_COUNTS__ available: ${hasCounter}`)
|
||||
|
||||
if (hasCounter !== 'object') {
|
||||
console.log('Render counter not loaded — checking perf-live...')
|
||||
const hasPerfLive = await evalInPage(ws, 'typeof __PERF_LIVE__')
|
||||
console.log(`__PERF_LIVE__ available: ${hasPerfLive}`)
|
||||
}
|
||||
|
||||
// 1. Baseline: open cmdk, type a few chars, measure
|
||||
console.log('\n=== CmdK Palette ===')
|
||||
await evalInPage(ws, `
|
||||
window.__renderBaseline = {}
|
||||
if (typeof __RENDER_COUNTS__ === 'object' && __RENDER_COUNTS__) {
|
||||
__RENDER_COUNTS__.reset()
|
||||
}
|
||||
// Open cmdk via keyboard shortcut
|
||||
const evt = new KeyboardEvent('keydown', { key: 'k', metaKey: true, bubbles: true })
|
||||
document.dispatchEvent(evt)
|
||||
true
|
||||
`)
|
||||
await new Promise(r => setTimeout(r, 500))
|
||||
|
||||
// Type some characters
|
||||
for (const ch of ['s', 'e', 't', 't', 'i', 'n', 'g']) {
|
||||
await evalInPage(ws, `
|
||||
const el = document.querySelector('[cmdk-input]') || document.querySelector('input[placeholder]')
|
||||
if (el) {
|
||||
el.focus()
|
||||
el.value = el.value + '${ch}'
|
||||
el.dispatchEvent(new Event('input', { bubbles: true }))
|
||||
}
|
||||
true
|
||||
`)
|
||||
await new Promise(r => setTimeout(r, 80))
|
||||
}
|
||||
|
||||
await new Promise(r => setTimeout(r, 300))
|
||||
const cmdkCounts = await evalInPage(ws, `
|
||||
__RENDER_COUNTS__?.snapshot?.() ?? __RENDER_COUNTS__?.counts ?? 'no counter'
|
||||
`)
|
||||
console.log('CmdK render counts after typing "setting":', JSON.stringify(cmdkCounts, null, 2))
|
||||
|
||||
// Close cmdk
|
||||
await evalInPage(ws, `new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }); document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true })); true`)
|
||||
await new Promise(r => setTimeout(r, 300))
|
||||
|
||||
// 2. Command Center — measure renders while on System tab
|
||||
console.log('\n=== Command Center (System tab) ===')
|
||||
await evalInPage(ws, `
|
||||
if (typeof __RENDER_COUNTS__ === 'object' && __RENDER_COUNTS__) {
|
||||
__RENDER_COUNTS__.reset()
|
||||
}
|
||||
// Navigate to command center system section
|
||||
const link = Array.from(document.querySelectorAll('a, button')).find(el => el.textContent?.includes('System') || el.textContent?.includes('Command Center'))
|
||||
if (link) link.click()
|
||||
true
|
||||
`)
|
||||
await new Promise(r => setTimeout(r, 1000))
|
||||
|
||||
const ccCounts = await evalInPage(ws, `
|
||||
__RENDER_COUNTS__?.snapshot?.() ?? __RENDER_COUNTS__?.counts ?? 'no counter'
|
||||
`)
|
||||
console.log('Command Center render counts (after 1s on System tab):', JSON.stringify(ccCounts, null, 2))
|
||||
|
||||
// 3. Settings page — measure renders on nav
|
||||
console.log('\n=== Settings Page ===')
|
||||
await evalInPage(ws, `
|
||||
if (typeof __RENDER_COUNTS__ === 'object' && __RENDER_COUNTS__) {
|
||||
__RENDER_COUNTS__.reset()
|
||||
}
|
||||
// Navigate to settings
|
||||
const link = Array.from(document.querySelectorAll('a, button')).find(el => el.textContent?.includes('Settings'))
|
||||
if (link) link.click()
|
||||
true
|
||||
`)
|
||||
await new Promise(r => setTimeout(r, 1000))
|
||||
|
||||
// Click through a few nav items
|
||||
for (const label of ['Appearance', 'Gateway', 'Keys', 'About']) {
|
||||
await evalInPage(ws, `
|
||||
const el = Array.from(document.querySelectorAll('button')).find(b => b.textContent?.trim() === '${label}')
|
||||
if (el) el.click()
|
||||
true
|
||||
`)
|
||||
await new Promise(r => setTimeout(r, 150))
|
||||
}
|
||||
|
||||
const settingsCounts = await evalInPage(ws, `
|
||||
__RENDER_COUNTS__?.snapshot?.() ?? __RENDER_COUNTS__?.counts ?? 'no counter'
|
||||
`)
|
||||
console.log('Settings render counts (after clicking 4 nav items):', JSON.stringify(settingsCounts, null, 2))
|
||||
|
||||
ws.close()
|
||||
console.log('\nDone.')
|
||||
}
|
||||
|
||||
main().catch(err => {
|
||||
console.error('Error:', err)
|
||||
process.exit(1)
|
||||
})
|
||||
201
apps/desktop/scripts/diag-overlay-full.mjs
Normal file
201
apps/desktop/scripts/diag-overlay-full.mjs
Normal file
|
|
@ -0,0 +1,201 @@
|
|||
#!/usr/bin/env node
|
||||
// Comprehensive overlay render-churn measurement on the live instance.
|
||||
// Measures: cmdk root + submenus, settings, command center, capabilities, system overlays.
|
||||
// Read-only — never closes the app, only navigates and clicks.
|
||||
import WebSocket from 'ws'
|
||||
|
||||
const WS_URL = 'ws://127.0.0.1:9222/devtools/page/6E095DBE024BD280C674D00023C01201'
|
||||
let msgId = 1
|
||||
|
||||
function send(ws, method, params = {}) {
|
||||
const id = msgId++
|
||||
return new Promise((resolve, reject) => {
|
||||
const handler = (data) => {
|
||||
const msg = JSON.parse(data.toString())
|
||||
if (msg.id === id) {
|
||||
ws.off('message', handler)
|
||||
msg.error ? reject(new Error(JSON.stringify(msg.error))) : resolve(msg.result)
|
||||
}
|
||||
}
|
||||
ws.on('message', handler)
|
||||
ws.send(JSON.stringify({ id, method, params }))
|
||||
})
|
||||
}
|
||||
|
||||
async function eval_(ws, expr) {
|
||||
const r = await send(ws, 'Runtime.evaluate', { expression: expr, returnByValue: true, awaitPromise: true })
|
||||
return r?.result?.value
|
||||
}
|
||||
|
||||
async function sleep(ms) { return new Promise(r => setTimeout(r, ms)) }
|
||||
|
||||
async function measure(ws, label, fn, settleMs = 400) {
|
||||
await eval_(ws, `__RENDER_COUNTS__.start(); true`)
|
||||
await fn(ws)
|
||||
await sleep(settleMs)
|
||||
const report = await eval_(ws, `JSON.stringify(__RENDER_COUNTS__.report())`)
|
||||
const parsed = JSON.parse(report)
|
||||
const totalRenders = parsed.reduce((s, c) => s + c.renders, 0)
|
||||
const totalWasted = parsed.reduce((s, c) => s + c.wasted, 0)
|
||||
const totalMs = parsed.reduce((s, c) => s + c.totalMs, 0)
|
||||
const top5 = parsed.filter(c => c.wasted > 0).sort((a,b) => b.wasted - a.wasted).slice(0, 5)
|
||||
console.log(`\n${label}`)
|
||||
console.log(` total: ${totalRenders} renders, ${totalWasted} wasted, ${totalMs.toFixed(1)}ms`)
|
||||
if (top5.length > 0) {
|
||||
for (const c of top5) {
|
||||
console.log(` ${c.name}: ${c.renders} renders, ${c.wasted} wasted, ${c.totalMs.toFixed(1)}ms`)
|
||||
}
|
||||
} else {
|
||||
console.log(` (no wasted renders)`)
|
||||
}
|
||||
return { label, totalRenders, totalWasted, totalMs, top: top5 }
|
||||
}
|
||||
|
||||
async function clickByText(ws, text, tag = 'button') {
|
||||
return eval_(ws, `
|
||||
const el = Array.from(document.querySelectorAll('${tag}')).find(b => b.textContent?.trim() === '${text}')
|
||||
if (el) { el.click(); true } else false
|
||||
`)
|
||||
}
|
||||
|
||||
async function clickByHref(ws, partial) {
|
||||
return eval_(ws, `
|
||||
const el = Array.from(document.querySelectorAll('a,button')).find(e => e.getAttribute('href')?.includes('${partial}'))
|
||||
if (el) { el.click(); true } else false
|
||||
`)
|
||||
}
|
||||
|
||||
async function typeInInput(ws, text) {
|
||||
for (const ch of text) {
|
||||
await eval_(ws, `
|
||||
const el = document.querySelector('[cmdk-input]') || document.querySelector('input[type="text"]') || document.querySelector('input')
|
||||
if (el) { el.focus(); el.value = el.value + '${ch}'; el.dispatchEvent(new Event('input', {bubbles:true})) }
|
||||
true
|
||||
`)
|
||||
await sleep(50)
|
||||
}
|
||||
}
|
||||
|
||||
async function pressKey(ws, key, mods = {}) {
|
||||
await eval_(ws, `
|
||||
document.dispatchEvent(new KeyboardEvent('keydown', { key: '${key}', ${Object.entries(mods).map(([k,v]) => `${k}:${v}`).join(', ')}, bubbles: true }))
|
||||
true
|
||||
`)
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const ws = new WebSocket(WS_URL)
|
||||
await new Promise((resolve, reject) => {
|
||||
ws.on('open', resolve)
|
||||
ws.on('error', reject)
|
||||
})
|
||||
await send(ws, 'Runtime.enable')
|
||||
|
||||
const avail = await eval_(ws, `typeof __RENDER_COUNTS__`)
|
||||
console.log(`__RENDER_COUNTS__: ${avail}`)
|
||||
if (avail !== 'object') {
|
||||
console.error('Render counter not loaded. Reload the page.')
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
const results = []
|
||||
|
||||
// === 1. CmdK root: open, type "setting", close ===
|
||||
results.push(await measure(ws, '1. CmdK root (type "setting")', async (ws) => {
|
||||
await pressKey(ws, 'k', { metaKey: true })
|
||||
await sleep(300)
|
||||
await typeInInput(ws, 'setting')
|
||||
}))
|
||||
await pressKey(ws, 'Escape')
|
||||
await sleep(300)
|
||||
|
||||
// === 2. CmdK submenu: open, navigate to theme picker, click a theme, back ===
|
||||
results.push(await measure(ws, '2. CmdK theme submenu', async (ws) => {
|
||||
await pressKey(ws, 'k', { metaKey: true })
|
||||
await sleep(300)
|
||||
await typeInInput(ws, 'theme')
|
||||
await sleep(200)
|
||||
// Click first theme item
|
||||
await eval_(ws, `
|
||||
const item = document.querySelector('[cmdk-item]')
|
||||
if (item) item.click()
|
||||
true
|
||||
`)
|
||||
await sleep(300)
|
||||
}))
|
||||
await pressKey(ws, 'Escape')
|
||||
await sleep(300)
|
||||
|
||||
// === 3. CmdK color-mode submenu ===
|
||||
results.push(await measure(ws, '3. CmdK color-mode submenu', async (ws) => {
|
||||
await pressKey(ws, 'k', { metaKey: true })
|
||||
await sleep(300)
|
||||
await typeInInput(ws, 'color mode')
|
||||
await sleep(200)
|
||||
await eval_(ws, `const item = document.querySelector('[cmdk-item]'); if (item) item.click(); true`)
|
||||
await sleep(300)
|
||||
}))
|
||||
await pressKey(ws, 'Escape')
|
||||
await sleep(300)
|
||||
|
||||
// === 4. Settings: open, click through nav items ===
|
||||
results.push(await measure(ws, '4. Settings (5 nav clicks)', async (ws) => {
|
||||
await clickByHref(ws, 'settings')
|
||||
await sleep(500)
|
||||
for (const label of ['Appearance', 'Gateway', 'Keys', 'Notifications', 'About']) {
|
||||
await clickByText(ws, label)
|
||||
await sleep(100)
|
||||
}
|
||||
}))
|
||||
|
||||
// === 5. Command Center: open to System tab, idle 2s ===
|
||||
results.push(await measure(ws, '5. Command Center (System tab, 2s idle)', async (ws) => {
|
||||
await clickByHref(ws, 'command-center')
|
||||
await sleep(500)
|
||||
await clickByText(ws, 'System')
|
||||
await sleep(2000)
|
||||
}, 100))
|
||||
|
||||
// === 6. Command Center: Sessions tab ===
|
||||
results.push(await measure(ws, '6. Command Center (Sessions tab, 1s)', async (ws) => {
|
||||
await clickByText(ws, 'Sessions')
|
||||
await sleep(1000)
|
||||
}, 100))
|
||||
|
||||
// === 7. Capabilities/Skills page ===
|
||||
results.push(await measure(ws, '7. Capabilities (Skills tab, 1s)', async (ws) => {
|
||||
await clickByHref(ws, 'skills')
|
||||
await sleep(800)
|
||||
// Click Skills tab if not already
|
||||
await clickByText(ws, 'Skills')
|
||||
await sleep(1000)
|
||||
}, 100))
|
||||
|
||||
// === 8. Capabilities Toolsets tab ===
|
||||
results.push(await measure(ws, '8. Capabilities (Toolsets tab, 1s)', async (ws) => {
|
||||
await clickByText(ws, 'Tools')
|
||||
await sleep(1000)
|
||||
}, 100))
|
||||
|
||||
// === 9. Capabilities MCP tab ===
|
||||
results.push(await measure(ws, '9. Capabilities (MCP tab, 1s)', async (ws) => {
|
||||
await clickByText(ws, 'MCP')
|
||||
await sleep(1000)
|
||||
}, 100))
|
||||
|
||||
// Navigate back to chat
|
||||
await eval_(ws, `window.location.hash = '#/'; true`)
|
||||
await sleep(300)
|
||||
|
||||
// Summary table
|
||||
console.log('\n\n=== SUMMARY ===')
|
||||
console.log('Surface'.padEnd(45) + 'Renders'.padStart(10) + 'Wasted'.padStart(10) + 'ms'.padStart(10))
|
||||
console.log('-'.repeat(75))
|
||||
for (const r of results) {
|
||||
console.log(r.label.padEnd(45) + String(r.totalRenders).padStart(10) + String(r.totalWasted).padStart(10) + r.totalMs.toFixed(1).padStart(10))
|
||||
}
|
||||
|
||||
ws.close()
|
||||
}
|
||||
|
||||
main().catch(err => { console.error(err); process.exit(1) })
|
||||
110
apps/desktop/scripts/diag-overlay-sweep.mjs
Normal file
110
apps/desktop/scripts/diag-overlay-sweep.mjs
Normal file
|
|
@ -0,0 +1,110 @@
|
|||
#!/usr/bin/env node
|
||||
// Full surface sweep: every overlay, every settings sub-page, every system overlay.
|
||||
// Read-only — navigates and clicks, never closes the app.
|
||||
import WebSocket from 'ws'
|
||||
|
||||
const WS_URL = process.env.CDP_WS || 'ws://127.0.0.1:9222/devtools/page/6E095DBE024BD280C674D00023C01201'
|
||||
let msgId = 1
|
||||
|
||||
function send(ws, method, params = {}) {
|
||||
const id = msgId++
|
||||
return new Promise((resolve, reject) => {
|
||||
const handler = (data) => {
|
||||
const msg = JSON.parse(data.toString())
|
||||
if (msg.id === id) {
|
||||
ws.off('message', handler)
|
||||
msg.error ? reject(new Error(JSON.stringify(msg.error))) : resolve(msg.result)
|
||||
}
|
||||
}
|
||||
ws.on('message', handler)
|
||||
ws.send(JSON.stringify({ id, method, params }))
|
||||
})
|
||||
}
|
||||
async function eval_(ws, expr) {
|
||||
const r = await send(ws, 'Runtime.evaluate', { expression: expr, returnByValue: true, awaitPromise: true })
|
||||
return r?.result?.value
|
||||
}
|
||||
async function sleep(ms) { return new Promise(r => setTimeout(r, ms)) }
|
||||
|
||||
async function measure(ws, label, fn, settleMs = 300) {
|
||||
await eval_(ws, `__RENDER_COUNTS__.start(); true`)
|
||||
await fn(ws)
|
||||
await sleep(settleMs)
|
||||
const report = JSON.parse(await eval_(ws, `JSON.stringify(__RENDER_COUNTS__.report())`))
|
||||
const totalRenders = report.reduce((s, c) => s + c.renders, 0)
|
||||
const totalWasted = report.reduce((s, c) => s + c.wasted, 0)
|
||||
const totalMs = report.reduce((s, c) => s + c.totalMs, 0)
|
||||
const top = report.filter(c => c.wasted > 0).sort((a,b) => b.wasted - a.wasted).slice(0, 6)
|
||||
console.log(`\n${label}`)
|
||||
console.log(` ${totalRenders} renders · ${totalWasted} wasted · ${totalMs.toFixed(1)}ms`)
|
||||
for (const c of top) console.log(` ${c.name}: ${c.renders}r / ${c.wasted}w / ${c.totalMs.toFixed(1)}ms`)
|
||||
if (top.length === 0) console.log(` (clean)`)
|
||||
return { label, totalRenders, totalWasted, totalMs }
|
||||
}
|
||||
|
||||
async function nav(ws, hash) {
|
||||
await eval_(ws, `window.location.hash = '#${hash}'; true`)
|
||||
}
|
||||
async function clickText(ws, text, tag='button') {
|
||||
return eval_(ws, `
|
||||
const el = Array.from(document.querySelectorAll('${tag}')).find(b => b.textContent?.trim() === ${JSON.stringify(text)})
|
||||
if (el) { el.click(); true } else false
|
||||
`)
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const ws = new WebSocket(WS_URL)
|
||||
await new Promise((res, rej) => { ws.on('open', res); ws.on('error', rej) })
|
||||
await send(ws, 'Runtime.enable')
|
||||
if (await eval_(ws, `typeof __RENDER_COUNTS__`) !== 'object') { console.error('counter not loaded'); process.exit(1) }
|
||||
|
||||
const results = []
|
||||
|
||||
// System overlays — open each, idle 2s (streaming underneath is the churn test)
|
||||
for (const [label, route] of [
|
||||
['Artifacts', '/artifacts'],
|
||||
['Messaging', '/messaging'],
|
||||
['Cron', '/cron'],
|
||||
['Profiles', '/profiles'],
|
||||
['Agents', '/agents'],
|
||||
['Starmap', '/starmap'],
|
||||
['Webhooks', '/webhooks'],
|
||||
]) {
|
||||
results.push(await measure(ws, `OVERLAY: ${label} (open + 2s idle)`, async (ws) => {
|
||||
await nav(ws, route)
|
||||
await sleep(2200)
|
||||
}, 100))
|
||||
await nav(ws, '/')
|
||||
await sleep(300)
|
||||
}
|
||||
|
||||
// Settings sub-pages — open settings, click each nav item, idle 1.5s on it
|
||||
await nav(ws, '/settings')
|
||||
await sleep(600)
|
||||
for (const label of ['Model','Session','Appearance','Notifications','Providers','Gateway','Keybinds','API Keys','Plugins','Archived Chats','About']) {
|
||||
results.push(await measure(ws, `SETTINGS: ${label} (1.5s idle)`, async (ws) => {
|
||||
await clickText(ws, label)
|
||||
await sleep(1500)
|
||||
}, 100))
|
||||
}
|
||||
await nav(ws, '/')
|
||||
await sleep(300)
|
||||
|
||||
// Messaging sub-tabs (platform detail) — click through if present
|
||||
await nav(ws, '/messaging')
|
||||
await sleep(800)
|
||||
results.push(await measure(ws, 'MESSAGING: idle 2s w/ platform list', async (ws) => {
|
||||
await sleep(2000)
|
||||
}, 100))
|
||||
await nav(ws, '/')
|
||||
await sleep(300)
|
||||
|
||||
console.log('\n\n=== SUMMARY (wasted renders) ===')
|
||||
console.log('Surface'.padEnd(48) + 'Renders'.padStart(9) + 'Wasted'.padStart(9) + 'ms'.padStart(9))
|
||||
console.log('-'.repeat(75))
|
||||
for (const r of results) {
|
||||
console.log(r.label.padEnd(48) + String(r.totalRenders).padStart(9) + String(r.totalWasted).padStart(9) + r.totalMs.toFixed(1).padStart(9))
|
||||
}
|
||||
ws.close()
|
||||
}
|
||||
main().catch(e => { console.error(e); process.exit(1) })
|
||||
|
|
@ -1,5 +1,5 @@
|
|||
import type * as React from 'react'
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react'
|
||||
import { memo, useCallback, useEffect, useMemo, useState } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
|
||||
import { ZoomableImage } from '@/components/chat/zoomable-image'
|
||||
|
|
@ -96,7 +96,7 @@ type CellCtx = {
|
|||
}
|
||||
|
||||
interface ArtifactColumn {
|
||||
Cell: (props: { artifact: ArtifactRecord; ctx: CellCtx }) => React.ReactElement
|
||||
Cell: React.ComponentType<{ artifact: ArtifactRecord; ctx: CellCtx }>
|
||||
bodyClassName: string
|
||||
header: (filter: ArtifactFilter, a: Translations['artifacts']) => string
|
||||
id: 'location' | 'primary' | 'session'
|
||||
|
|
@ -278,10 +278,12 @@ export function ArtifactsView({ setStatusbarItemGroup: _setStatusbarItemGroup, .
|
|||
})
|
||||
}, [])
|
||||
|
||||
const cellCtx: CellCtx = {
|
||||
onOpen: openArtifact,
|
||||
onOpenChat: sessionId => openSession(sessionId, navigate)
|
||||
}
|
||||
// Stable ctx: recreating it (or its onOpenChat closure) every render made
|
||||
// every artifact cell re-render whenever the page did — and a link cell's
|
||||
// async title fetch re-rendered the page repeatedly. openArtifact is already
|
||||
// a useCallback; navigate is stable, so onOpenChat can be too.
|
||||
const openChat = useCallback((sessionId: string) => openSession(sessionId, navigate), [navigate])
|
||||
const cellCtx: CellCtx = useMemo(() => ({ onOpen: openArtifact, onOpenChat: openChat }), [openArtifact, openChat])
|
||||
|
||||
return (
|
||||
<PageSearchShell
|
||||
|
|
@ -549,7 +551,7 @@ function ArtifactCellAction({
|
|||
)
|
||||
}
|
||||
|
||||
function PrimaryCell({ artifact, ctx }: { artifact: ArtifactRecord; ctx: CellCtx }) {
|
||||
const PrimaryCell = memo(function PrimaryCell({ artifact, ctx }: { artifact: ArtifactRecord; ctx: CellCtx }) {
|
||||
const isLink = artifact.kind === 'link'
|
||||
const brand = isLink ? resolveBrandIcon(shortHostLabel(artifact.href)) : null
|
||||
const Icon = brand ?? (isLink ? Link2 : FileText)
|
||||
|
|
@ -571,9 +573,9 @@ function PrimaryCell({ artifact, ctx }: { artifact: ArtifactRecord; ctx: CellCtx
|
|||
</span>
|
||||
</ArtifactCellAction>
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
function LocationCell({ artifact }: { artifact: ArtifactRecord; ctx: CellCtx }) {
|
||||
const LocationCell = memo(function LocationCell({ artifact }: { artifact: ArtifactRecord; ctx: CellCtx }) {
|
||||
const { t } = useI18n()
|
||||
const isLink = artifact.kind === 'link'
|
||||
const value = isLink ? hostPathLabel(artifact.value) : artifact.value
|
||||
|
|
@ -602,9 +604,9 @@ function LocationCell({ artifact }: { artifact: ArtifactRecord; ctx: CellCtx })
|
|||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
function SessionCell({ artifact, ctx }: { artifact: ArtifactRecord; ctx: CellCtx }) {
|
||||
const SessionCell = memo(function SessionCell({ artifact, ctx }: { artifact: ArtifactRecord; ctx: CellCtx }) {
|
||||
return (
|
||||
<ArtifactCellAction onClick={() => ctx.onOpenChat(artifact.sessionId)} title={artifact.sessionTitle}>
|
||||
<span className="flex min-w-0 flex-col">
|
||||
|
|
@ -615,7 +617,7 @@ function SessionCell({ artifact, ctx }: { artifact: ArtifactRecord; ctx: CellCtx
|
|||
</span>
|
||||
</ArtifactCellAction>
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
const ARTIFACT_COLUMNS: readonly ArtifactColumn[] = [
|
||||
{
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import { useStore } from '@nanostores/react'
|
||||
|
||||
import { type Translations, useI18n } from '@/i18n'
|
||||
import { useStoreSelector } from '@/lib/use-session-slice'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { $backgroundRunningSessionIds } from '@/store/composer-status'
|
||||
import { $unreadFinishedSessionIds } from '@/store/session'
|
||||
|
|
@ -111,11 +112,15 @@ export function SessionStatusDot({ storedSessionId, session, branchStem, classNa
|
|||
useStore($sessionColorById)
|
||||
const color = sessionColorFor(session) ?? null
|
||||
|
||||
const needsInput = useStore($attentionSessionIds).includes(storedSessionId)
|
||||
const isWorking = useStore($workingSessionIds).includes(storedSessionId)
|
||||
const isStalled = useStore($stalledSessionIds).includes(storedSessionId)
|
||||
const isUnread = useStore($unreadFinishedSessionIds).includes(storedSessionId)
|
||||
const hasBackground = useStore($backgroundRunningSessionIds).includes(storedSessionId)
|
||||
// Per-session membership as booleans via useStoreSelector: these arrays tick
|
||||
// on every stream delta (any session working/stalled/etc changes the array
|
||||
// reference), but a given dot only repaints when ITS OWN membership flips.
|
||||
// A plain useStore(array).includes(id) re-rendered every dot on every tick.
|
||||
const needsInput = useStoreSelector($attentionSessionIds, ids => ids.includes(storedSessionId))
|
||||
const isWorking = useStoreSelector($workingSessionIds, ids => ids.includes(storedSessionId))
|
||||
const isStalled = useStoreSelector($stalledSessionIds, ids => ids.includes(storedSessionId))
|
||||
const isUnread = useStoreSelector($unreadFinishedSessionIds, ids => ids.includes(storedSessionId))
|
||||
const hasBackground = useStoreSelector($backgroundRunningSessionIds, ids => ids.includes(storedSessionId))
|
||||
|
||||
const dotState = sessionDotState({ hasBackground, isStalled, isUnread, isWorking, needsInput })
|
||||
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import { useStore } from '@nanostores/react'
|
||||
import { memo } from 'react'
|
||||
import type * as React from 'react'
|
||||
|
||||
import { ProfileTag } from '@/app/chat/profile-tag'
|
||||
|
|
@ -54,7 +55,7 @@ function formatAge(seconds: number, r: Translations['sidebar']['row']): string {
|
|||
return unit === 'second' ? r.ageNow : `${value}${r[AGE_KEY[unit]]}`
|
||||
}
|
||||
|
||||
export function SidebarSessionRow({
|
||||
function SidebarSessionRowImpl({
|
||||
session,
|
||||
branchStem,
|
||||
isPinned,
|
||||
|
|
@ -251,3 +252,32 @@ export function SidebarSessionRow({
|
|||
</SessionContextMenu>
|
||||
)
|
||||
}
|
||||
|
||||
// The sidebar re-renders on every stream tick ($sessions/$workingSessionIds
|
||||
// churn), and it stays mounted beneath every overlay — so an unmemoized row
|
||||
// re-rendered the whole list (and its Codicon/label/status-dot subtree) on each
|
||||
// delta, bleeding churn into Settings, Cron, Profiles, Artifacts, etc.
|
||||
//
|
||||
// The callback props (onArchive/onResume/…) are fresh closures every render by
|
||||
// design (they close over the row's session id), so a default memo never bails.
|
||||
// They're pure id-forwarders, though — identical behavior for a given row — so
|
||||
// the comparator deliberately ignores them and compares only the DATA that
|
||||
// changes what the row paints. A row whose session/selection/working/pin state
|
||||
// is unchanged now bails out, even while a sibling session streams.
|
||||
function rowPropsEqual(a: SidebarSessionRowProps, b: SidebarSessionRowProps): boolean {
|
||||
return (
|
||||
a.session === b.session &&
|
||||
a.isPinned === b.isPinned &&
|
||||
a.isSelected === b.isSelected &&
|
||||
a.isWorking === b.isWorking &&
|
||||
a.branchStem === b.branchStem &&
|
||||
a.reorderable === b.reorderable &&
|
||||
a.dragging === b.dragging &&
|
||||
a.showProfile === b.showProfile &&
|
||||
a.dragHandleProps === b.dragHandleProps &&
|
||||
a.className === b.className &&
|
||||
a.style === b.style
|
||||
)
|
||||
}
|
||||
|
||||
export const SidebarSessionRow = memo(SidebarSessionRowImpl, rowPropsEqual)
|
||||
|
|
|
|||
|
|
@ -1,4 +1,3 @@
|
|||
import { useStore } from '@nanostores/react'
|
||||
import { type MouseEvent, type ReactNode, useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
|
||||
import { LogTail } from '@/components/chat/log-tail'
|
||||
|
|
@ -26,6 +25,7 @@ import {
|
|||
} from '@/lib/icons'
|
||||
import { exportSession } from '@/lib/session-export'
|
||||
import { fmtDateTime } from '@/lib/time'
|
||||
import { useStoreSelector } from '@/lib/use-session-slice'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { upsertDesktopActionTask } from '@/store/activity'
|
||||
import { $pinnedSessionIds, pinSession, unpinSession } from '@/store/layout'
|
||||
|
|
@ -48,6 +48,12 @@ const LOG_LEVELS = ['ALL', 'INFO', 'WARNING', 'ERROR'] as const
|
|||
const USAGE_PERIODS = [7, 30, 90] as const
|
||||
type UsagePeriod = (typeof USAGE_PERIODS)[number]
|
||||
|
||||
// Stable empty arrays so the selector returns the same reference when we're
|
||||
// not on the Sessions tab — useStoreSelector bails out on Object.is, so the
|
||||
// component never re-renders from $sessions ticks while on System/Usage/etc.
|
||||
const EMPTY_SESSIONS: readonly never[] = []
|
||||
const EMPTY_PINNED: readonly string[] = []
|
||||
|
||||
interface CommandCenterViewProps {
|
||||
initialSection?: CommandCenterSection
|
||||
onClose: () => void
|
||||
|
|
@ -129,10 +135,12 @@ function EmptyPanel({ action, description, title }: { action?: ReactNode; descri
|
|||
export function CommandCenterView({ initialSection, onClose, onDeleteSession, onOpenSession }: CommandCenterViewProps) {
|
||||
const { t } = useI18n()
|
||||
const cc = t.commandCenter
|
||||
const sessions = useStore($sessions)
|
||||
const pinnedSessionIds = useStore($pinnedSessionIds)
|
||||
|
||||
// $sessions ticks on every streaming token (title updates, new sessions),
|
||||
// but we only need the data on the Sessions tab. Subscribe conditionally so
|
||||
// the System/Usage/Maintenance tabs don't re-render on every stream delta.
|
||||
const [section, setSection] = useRouteEnumParam('section', SECTIONS, initialSection ?? 'sessions')
|
||||
const sessions = useStoreSelector($sessions, s => (section === 'sessions' ? s : EMPTY_SESSIONS))
|
||||
const pinnedSessionIds = useStoreSelector($pinnedSessionIds, s => (section === 'sessions' ? s : EMPTY_PINNED))
|
||||
|
||||
const [query, setQuery] = useState('')
|
||||
const [status, setStatus] = useState<StatusResponse | null>(null)
|
||||
|
|
@ -294,25 +302,29 @@ export function CommandCenterView({ initialSection, onClose, onDeleteSession, on
|
|||
[cc, refreshSystem]
|
||||
)
|
||||
|
||||
const navGroups = useMemo(
|
||||
() =>
|
||||
SECTIONS.map(value => ({
|
||||
active: section === value,
|
||||
icon:
|
||||
value === 'sessions'
|
||||
? MessageCircle
|
||||
: value === 'system'
|
||||
? Activity
|
||||
: value === 'maintenance'
|
||||
? Wrench
|
||||
: BarChart3,
|
||||
id: value,
|
||||
label: cc.sections[value],
|
||||
onSelect: () => setSection(value)
|
||||
})),
|
||||
[cc, section, setSection]
|
||||
)
|
||||
|
||||
return (
|
||||
<OverlayView closeLabel={cc.close} onClose={onClose}>
|
||||
<OverlaySplitLayout>
|
||||
<OverlayNav
|
||||
groups={SECTIONS.map(value => ({
|
||||
active: section === value,
|
||||
icon:
|
||||
value === 'sessions'
|
||||
? MessageCircle
|
||||
: value === 'system'
|
||||
? Activity
|
||||
: value === 'maintenance'
|
||||
? Wrench
|
||||
: BarChart3,
|
||||
id: value,
|
||||
label: cc.sections[value],
|
||||
onSelect: () => setSection(value)
|
||||
}))}
|
||||
/>
|
||||
<OverlayNav groups={navGroups} />
|
||||
|
||||
<OverlayMain>
|
||||
<header className="mb-4 flex items-center justify-between gap-3 max-[47.5rem]:mb-2">
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
import { useStore } from '@nanostores/react'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { Dialog as DialogPrimitive } from 'radix-ui'
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
|
||||
import { HUD_HEADING, HUD_ITEM, HUD_POSITION, HUD_SURFACE, HUD_TEXT } from '@/app/floating-hud'
|
||||
|
|
@ -218,6 +218,41 @@ const rankGroups = (groups: PaletteGroup[], search: string): PaletteGroup[] => {
|
|||
// theme lists under both Light and Dark). The id suffix disambiguates.
|
||||
const paletteValue = (item: PaletteItem): string => `${item.label}\u0001${item.id}`
|
||||
|
||||
const PaletteRow = memo(function PaletteRow({
|
||||
bindings,
|
||||
item,
|
||||
onSelectMods,
|
||||
onSelectItem
|
||||
}: {
|
||||
bindings: Record<string, string[]>
|
||||
item: PaletteItem
|
||||
onSelectMods: (event: { ctrlKey: boolean; metaKey: boolean; shiftKey: boolean }) => void
|
||||
onSelectItem: (item: PaletteItem) => void
|
||||
}) {
|
||||
const Icon = item.icon
|
||||
const combo = item.action ? bindings[item.action]?.[0] : undefined
|
||||
|
||||
return (
|
||||
<CommandItem
|
||||
className={cn(HUD_ITEM, HUD_TEXT)}
|
||||
keywords={item.keywords}
|
||||
onMouseDown={onSelectMods}
|
||||
onSelect={() => onSelectItem(item)}
|
||||
value={paletteValue(item)}
|
||||
>
|
||||
<Icon className="size-3.5 shrink-0 text-muted-foreground" />
|
||||
<span className="truncate">{item.label}</span>
|
||||
{combo && <KbdCombo className="ml-auto opacity-55" combo={combo} size="sm" />}
|
||||
{item.to && (
|
||||
<ChevronRight className={cn('size-3.5 shrink-0 text-muted-foreground/70', !combo && 'ml-auto')} />
|
||||
)}
|
||||
{item.active && (
|
||||
<Check className={cn('size-3.5 shrink-0 text-primary', !combo && !item.to && 'ml-auto')} />
|
||||
)}
|
||||
</CommandItem>
|
||||
)
|
||||
})
|
||||
|
||||
// Hermes session ids: <YYYYMMDD>_<HHMMSS>_<6 hex>. Used to offer a direct
|
||||
// "Go to session ‹id›" jump for ids that aren't in the recent-200 list.
|
||||
const SESSION_ID_RE = /^\d{8}_\d{6}_[a-f0-9]{6}$/
|
||||
|
|
@ -996,35 +1031,15 @@ export function CommandPalette() {
|
|||
heading={group.heading}
|
||||
key={group.heading ?? `palette-group-${index}`}
|
||||
>
|
||||
{group.items.map(item => {
|
||||
const Icon = item.icon
|
||||
const combo = item.action ? bindings[item.action]?.[0] : undefined
|
||||
|
||||
return (
|
||||
<CommandItem
|
||||
className={cn(HUD_ITEM, HUD_TEXT)}
|
||||
key={item.id}
|
||||
keywords={item.keywords}
|
||||
onMouseDown={noteSelectMods}
|
||||
onSelect={() => handleSelect(item)}
|
||||
value={paletteValue(item)}
|
||||
>
|
||||
<Icon className="size-3.5 shrink-0 text-muted-foreground" />
|
||||
<span className="truncate">{item.label}</span>
|
||||
{combo && <KbdCombo className="ml-auto opacity-55" combo={combo} size="sm" />}
|
||||
{item.to && (
|
||||
<ChevronRight
|
||||
className={cn('size-3.5 shrink-0 text-muted-foreground/70', !combo && 'ml-auto')}
|
||||
/>
|
||||
)}
|
||||
{item.active && (
|
||||
<Check
|
||||
className={cn('size-3.5 shrink-0 text-primary', !combo && !item.to && 'ml-auto')}
|
||||
/>
|
||||
)}
|
||||
</CommandItem>
|
||||
)
|
||||
})}
|
||||
{group.items.map(item => (
|
||||
<PaletteRow
|
||||
bindings={bindings}
|
||||
item={item}
|
||||
key={item.id}
|
||||
onSelectItem={handleSelect}
|
||||
onSelectMods={noteSelectMods}
|
||||
/>
|
||||
))}
|
||||
</CommandGroup>
|
||||
))}
|
||||
</>
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { Fragment, type ReactNode } from 'react'
|
||||
import { Fragment, memo, type ReactNode } from 'react'
|
||||
|
||||
import { TabDropdown } from '@/components/ui/tab-dropdown'
|
||||
import type { IconComponent } from '@/lib/icons'
|
||||
|
|
@ -94,7 +94,14 @@ export function OverlayMain({ children, className }: OverlayMainProps) {
|
|||
)
|
||||
}
|
||||
|
||||
export function OverlayNavItem({ active, icon: Icon, label, nested, onClick, trailing }: OverlayNavItemProps) {
|
||||
export const OverlayNavItem = memo(function OverlayNavItem({
|
||||
active,
|
||||
icon: Icon,
|
||||
label,
|
||||
nested,
|
||||
onClick,
|
||||
trailing
|
||||
}: OverlayNavItemProps) {
|
||||
return (
|
||||
<button
|
||||
className={cn(
|
||||
|
|
@ -121,7 +128,7 @@ export function OverlayNavItem({ active, icon: Icon, label, nested, onClick, tra
|
|||
{trailing}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
export interface OverlayNavLink {
|
||||
active: boolean
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { useEffect, useRef } from 'react'
|
||||
import { useCallback, useEffect, useMemo, useRef } from 'react'
|
||||
import { useLocation, useNavigate } from 'react-router-dom'
|
||||
|
||||
import { codiconIcon } from '@/components/ui/codicon'
|
||||
|
|
@ -85,22 +85,29 @@ export function SettingsView({ onClose, onConfigSaved, onMainModelChanged }: Set
|
|||
// Jump to a section + its sub-view in one navigate. Two sequential setters
|
||||
// would each read the same stale `search` and the second would clobber the
|
||||
// first's `tab` — so the sub-view never opened on narrow screens.
|
||||
const openSubView = (tab: SettingsViewId, param: string, value: string, fallback: string) => {
|
||||
const params = new URLSearchParams(search)
|
||||
params.set('tab', tab)
|
||||
const openSubView = useCallback(
|
||||
(tab: SettingsViewId, param: string, value: string, fallback: string) => {
|
||||
const params = new URLSearchParams(search)
|
||||
params.set('tab', tab)
|
||||
|
||||
if (value === fallback) {
|
||||
params.delete(param)
|
||||
} else {
|
||||
params.set(param, value)
|
||||
}
|
||||
if (value === fallback) {
|
||||
params.delete(param)
|
||||
} else {
|
||||
params.set(param, value)
|
||||
}
|
||||
|
||||
const qs = params.toString()
|
||||
navigate({ hash, pathname, search: qs ? `?${qs}` : '' }, { replace: true })
|
||||
}
|
||||
const qs = params.toString()
|
||||
navigate({ hash, pathname, search: qs ? `?${qs}` : '' }, { replace: true })
|
||||
},
|
||||
[hash, navigate, pathname, search]
|
||||
)
|
||||
|
||||
const openProviderView = (view: ProviderView) => openSubView('providers', 'pview', view, 'accounts')
|
||||
const openKeysView = (view: KeysView) => openSubView('keys', 'kview', view, 'tools')
|
||||
const openProviderView = useCallback(
|
||||
(view: ProviderView) => openSubView('providers', 'pview', view, 'accounts'),
|
||||
[openSubView]
|
||||
)
|
||||
|
||||
const openKeysView = useCallback((view: KeysView) => openSubView('keys', 'kview', view, 'tools'), [openSubView])
|
||||
|
||||
const importInputRef = useRef<HTMLInputElement | null>(null)
|
||||
|
||||
|
|
@ -134,123 +141,124 @@ export function SettingsView({ onClose, onConfigSaved, onMainModelChanged }: Set
|
|||
}
|
||||
}
|
||||
|
||||
const navGroups: OverlayNavGroup[] = [
|
||||
const navGroups: OverlayNavGroup[] = useMemo(() => [
|
||||
...SECTIONS.map(s => {
|
||||
const view = `config:${s.id}` as SettingsViewId
|
||||
const view = `config:${s.id}` as SettingsViewId
|
||||
|
||||
return {
|
||||
active: activeView === view,
|
||||
icon: s.icon,
|
||||
id: view,
|
||||
label: t.settings.sections[s.id] ?? s.label,
|
||||
onSelect: () => setActiveView(view)
|
||||
return {
|
||||
active: activeView === view,
|
||||
icon: s.icon,
|
||||
id: view,
|
||||
label: t.settings.sections[s.id] ?? s.label,
|
||||
onSelect: () => setActiveView(view)
|
||||
}
|
||||
}),
|
||||
{
|
||||
active: activeView === 'notifications',
|
||||
icon: Bell,
|
||||
id: 'notifications',
|
||||
label: t.settings.nav.notifications,
|
||||
onSelect: () => setActiveView('notifications')
|
||||
},
|
||||
{
|
||||
active: activeView === 'billing',
|
||||
icon: BarChart3,
|
||||
id: 'billing',
|
||||
label: t.settings.nav.billing,
|
||||
onSelect: () => setActiveView('billing')
|
||||
},
|
||||
{
|
||||
active: activeView === 'providers',
|
||||
children: [
|
||||
{
|
||||
active: activeView === 'providers' && providerView === 'accounts',
|
||||
icon: codiconIcon('account'),
|
||||
id: 'pview:accounts',
|
||||
label: t.settings.nav.providerAccounts,
|
||||
onSelect: () => openProviderView('accounts')
|
||||
},
|
||||
{
|
||||
active: activeView === 'providers' && providerView === 'keys',
|
||||
icon: KeyRound,
|
||||
id: 'pview:keys',
|
||||
label: t.settings.nav.providerApiKeys,
|
||||
onSelect: () => openProviderView('keys')
|
||||
},
|
||||
{
|
||||
active: activeView === 'providers' && providerView === 'custom-endpoints',
|
||||
icon: Globe,
|
||||
id: 'pview:custom-endpoints',
|
||||
label: t.settings.nav.providerCustomEndpoints,
|
||||
onSelect: () => openProviderView('custom-endpoints')
|
||||
}
|
||||
],
|
||||
gapBefore: true,
|
||||
icon: Zap,
|
||||
id: 'providers',
|
||||
label: t.settings.nav.providers,
|
||||
onSelect: () => setActiveView('providers')
|
||||
},
|
||||
{
|
||||
active: activeView === 'gateway',
|
||||
icon: Globe,
|
||||
id: 'gateway',
|
||||
label: t.settings.nav.gateway,
|
||||
onSelect: () => setActiveView('gateway')
|
||||
},
|
||||
{
|
||||
active: activeView === 'keybinds',
|
||||
icon: Keyboard,
|
||||
id: 'keybinds',
|
||||
label: t.settings.nav.keybinds,
|
||||
onSelect: () => setActiveView('keybinds')
|
||||
},
|
||||
{
|
||||
active: activeView === 'keys',
|
||||
children: [
|
||||
{
|
||||
active: activeView === 'keys' && keysView === 'tools',
|
||||
icon: Wrench,
|
||||
id: 'kview:tools',
|
||||
label: t.settings.nav.keysTools,
|
||||
onSelect: () => openKeysView('tools')
|
||||
},
|
||||
{
|
||||
active: activeView === 'keys' && keysView === 'settings',
|
||||
icon: Settings2,
|
||||
id: 'kview:settings',
|
||||
label: t.settings.nav.keysSettings,
|
||||
onSelect: () => openKeysView('settings')
|
||||
}
|
||||
],
|
||||
icon: KeyRound,
|
||||
id: 'keys',
|
||||
label: t.settings.nav.apiKeys,
|
||||
onSelect: () => setActiveView('keys')
|
||||
},
|
||||
{
|
||||
active: activeView === 'plugins',
|
||||
icon: Package,
|
||||
id: 'plugins',
|
||||
label: t.settings.nav.plugins,
|
||||
onSelect: () => setActiveView('plugins')
|
||||
},
|
||||
{
|
||||
active: activeView === 'sessions',
|
||||
icon: Archive,
|
||||
id: 'sessions',
|
||||
label: t.settings.nav.archivedChats,
|
||||
onSelect: () => setActiveView('sessions')
|
||||
},
|
||||
{
|
||||
active: activeView === 'about',
|
||||
gapBefore: true,
|
||||
icon: Info,
|
||||
id: 'about',
|
||||
label: t.settings.nav.about,
|
||||
onSelect: () => setActiveView('about')
|
||||
}
|
||||
}),
|
||||
{
|
||||
active: activeView === 'notifications',
|
||||
icon: Bell,
|
||||
id: 'notifications',
|
||||
label: t.settings.nav.notifications,
|
||||
onSelect: () => setActiveView('notifications')
|
||||
},
|
||||
{
|
||||
active: activeView === 'billing',
|
||||
icon: BarChart3,
|
||||
id: 'billing',
|
||||
label: t.settings.nav.billing,
|
||||
onSelect: () => setActiveView('billing')
|
||||
},
|
||||
{
|
||||
active: activeView === 'providers',
|
||||
children: [
|
||||
{
|
||||
active: activeView === 'providers' && providerView === 'accounts',
|
||||
icon: codiconIcon('account'),
|
||||
id: 'pview:accounts',
|
||||
label: t.settings.nav.providerAccounts,
|
||||
onSelect: () => openProviderView('accounts')
|
||||
},
|
||||
{
|
||||
active: activeView === 'providers' && providerView === 'keys',
|
||||
icon: KeyRound,
|
||||
id: 'pview:keys',
|
||||
label: t.settings.nav.providerApiKeys,
|
||||
onSelect: () => openProviderView('keys')
|
||||
},
|
||||
{
|
||||
active: activeView === 'providers' && providerView === 'custom-endpoints',
|
||||
icon: Globe,
|
||||
id: 'pview:custom-endpoints',
|
||||
label: t.settings.nav.providerCustomEndpoints,
|
||||
onSelect: () => openProviderView('custom-endpoints')
|
||||
}
|
||||
],
|
||||
gapBefore: true,
|
||||
icon: Zap,
|
||||
id: 'providers',
|
||||
label: t.settings.nav.providers,
|
||||
onSelect: () => setActiveView('providers')
|
||||
},
|
||||
{
|
||||
active: activeView === 'gateway',
|
||||
icon: Globe,
|
||||
id: 'gateway',
|
||||
label: t.settings.nav.gateway,
|
||||
onSelect: () => setActiveView('gateway')
|
||||
},
|
||||
{
|
||||
active: activeView === 'keybinds',
|
||||
icon: Keyboard,
|
||||
id: 'keybinds',
|
||||
label: t.settings.nav.keybinds,
|
||||
onSelect: () => setActiveView('keybinds')
|
||||
},
|
||||
{
|
||||
active: activeView === 'keys',
|
||||
children: [
|
||||
{
|
||||
active: activeView === 'keys' && keysView === 'tools',
|
||||
icon: Wrench,
|
||||
id: 'kview:tools',
|
||||
label: t.settings.nav.keysTools,
|
||||
onSelect: () => openKeysView('tools')
|
||||
},
|
||||
{
|
||||
active: activeView === 'keys' && keysView === 'settings',
|
||||
icon: Settings2,
|
||||
id: 'kview:settings',
|
||||
label: t.settings.nav.keysSettings,
|
||||
onSelect: () => openKeysView('settings')
|
||||
}
|
||||
],
|
||||
icon: KeyRound,
|
||||
id: 'keys',
|
||||
label: t.settings.nav.apiKeys,
|
||||
onSelect: () => setActiveView('keys')
|
||||
},
|
||||
{
|
||||
active: activeView === 'plugins',
|
||||
icon: Package,
|
||||
id: 'plugins',
|
||||
label: t.settings.nav.plugins,
|
||||
onSelect: () => setActiveView('plugins')
|
||||
},
|
||||
{
|
||||
active: activeView === 'sessions',
|
||||
icon: Archive,
|
||||
id: 'sessions',
|
||||
label: t.settings.nav.archivedChats,
|
||||
onSelect: () => setActiveView('sessions')
|
||||
},
|
||||
{
|
||||
active: activeView === 'about',
|
||||
gapBefore: true,
|
||||
icon: Info,
|
||||
id: 'about',
|
||||
label: t.settings.nav.about,
|
||||
onSelect: () => setActiveView('about')
|
||||
}
|
||||
]
|
||||
]
|
||||
, [activeView, keysView, providerView, t, setActiveView, openProviderView, openKeysView])
|
||||
|
||||
const navFooter = (
|
||||
<>
|
||||
|
|
|
|||
|
|
@ -16,7 +16,6 @@ import {
|
|||
getSkills,
|
||||
getToolsets,
|
||||
getUsageAnalytics,
|
||||
type HermesGateway,
|
||||
toggleSkill,
|
||||
toggleToolset
|
||||
} from '@/hermes'
|
||||
|
|
@ -26,6 +25,7 @@ import { compactNumber } from '@/lib/format'
|
|||
import { queryClient, writeCache } from '@/lib/query-client'
|
||||
import { invalidateSlashCompletions } from '@/lib/slash-completion-cache'
|
||||
import { normalize } from '@/lib/text'
|
||||
import { useStoreSelector } from '@/lib/use-session-slice'
|
||||
import { $gateway } from '@/store/gateway'
|
||||
import { notify, notifyError } from '@/store/notifications'
|
||||
import { $activeGatewayProfile, normalizeProfileKey } from '@/store/profile'
|
||||
|
|
@ -184,8 +184,10 @@ interface SkillsViewProps extends React.ComponentProps<'section'> {
|
|||
|
||||
export function SkillsView({ setStatusbarItemGroup: _setStatusbarItemGroup, ...props }: SkillsViewProps) {
|
||||
const { t } = useI18n()
|
||||
const gateway = useStore($gateway) as HermesGateway | null
|
||||
const [mode, setMode] = useRouteEnumParam('tab', SKILLS_MODES, 'skills')
|
||||
// $gateway only feeds the MCP tab — gate the subscription so Skills/Toolsets/Hub
|
||||
// tabs don't re-render on connect/disconnect/reconnect.
|
||||
const gateway = useStoreSelector($gateway, g => (mode === 'mcp' ? g : null))
|
||||
|
||||
const [query, setQuery] = useState('')
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue