diff --git a/apps/desktop/scripts/diag-overlay-ab.mjs b/apps/desktop/scripts/diag-overlay-ab.mjs new file mode 100644 index 000000000000..e42f87534b8c --- /dev/null +++ b/apps/desktop/scripts/diag-overlay-ab.mjs @@ -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) }) diff --git a/apps/desktop/scripts/diag-overlay-churn.mjs b/apps/desktop/scripts/diag-overlay-churn.mjs new file mode 100644 index 000000000000..41a2379093f5 --- /dev/null +++ b/apps/desktop/scripts/diag-overlay-churn.mjs @@ -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) +}) diff --git a/apps/desktop/scripts/diag-overlay-full.mjs b/apps/desktop/scripts/diag-overlay-full.mjs new file mode 100644 index 000000000000..c851faa96439 --- /dev/null +++ b/apps/desktop/scripts/diag-overlay-full.mjs @@ -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) }) diff --git a/apps/desktop/scripts/diag-overlay-sweep.mjs b/apps/desktop/scripts/diag-overlay-sweep.mjs new file mode 100644 index 000000000000..bfb54fc1f3de --- /dev/null +++ b/apps/desktop/scripts/diag-overlay-sweep.mjs @@ -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) }) diff --git a/apps/desktop/src/app/artifacts/index.tsx b/apps/desktop/src/app/artifacts/index.tsx index 6660b15f5956..2482405fb7b0 100644 --- a/apps/desktop/src/app/artifacts/index.tsx +++ b/apps/desktop/src/app/artifacts/index.tsx @@ -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 ( ) -} +}) -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 }) /> ) -} +}) -function SessionCell({ artifact, ctx }: { artifact: ArtifactRecord; ctx: CellCtx }) { +const SessionCell = memo(function SessionCell({ artifact, ctx }: { artifact: ArtifactRecord; ctx: CellCtx }) { return ( ctx.onOpenChat(artifact.sessionId)} title={artifact.sessionTitle}> @@ -615,7 +617,7 @@ function SessionCell({ artifact, ctx }: { artifact: ArtifactRecord; ctx: CellCtx ) -} +}) const ARTIFACT_COLUMNS: readonly ArtifactColumn[] = [ { diff --git a/apps/desktop/src/app/chat/session-status-dot.tsx b/apps/desktop/src/app/chat/session-status-dot.tsx index 684b21e9927e..09fb7773a870 100644 --- a/apps/desktop/src/app/chat/session-status-dot.tsx +++ b/apps/desktop/src/app/chat/session-status-dot.tsx @@ -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 }) diff --git a/apps/desktop/src/app/chat/sidebar/session-row.tsx b/apps/desktop/src/app/chat/sidebar/session-row.tsx index 051f8893d7a8..fcaa8b19edfd 100644 --- a/apps/desktop/src/app/chat/sidebar/session-row.tsx +++ b/apps/desktop/src/app/chat/sidebar/session-row.tsx @@ -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({ ) } + +// 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) diff --git a/apps/desktop/src/app/command-center/index.tsx b/apps/desktop/src/app/command-center/index.tsx index 1c9f1dd7fd7a..14fe739f2f3d 100644 --- a/apps/desktop/src/app/command-center/index.tsx +++ b/apps/desktop/src/app/command-center/index.tsx @@ -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(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 ( - ({ - active: section === value, - icon: - value === 'sessions' - ? MessageCircle - : value === 'system' - ? Activity - : value === 'maintenance' - ? Wrench - : BarChart3, - id: value, - label: cc.sections[value], - onSelect: () => setSection(value) - }))} - /> +
diff --git a/apps/desktop/src/app/command-palette/index.tsx b/apps/desktop/src/app/command-palette/index.tsx index f627f1395742..b9e9944685f3 100644 --- a/apps/desktop/src/app/command-palette/index.tsx +++ b/apps/desktop/src/app/command-palette/index.tsx @@ -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 + 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 ( + onSelectItem(item)} + value={paletteValue(item)} + > + + {item.label} + {combo && } + {item.to && ( + + )} + {item.active && ( + + )} + + ) +}) + // Hermes session ids: __<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 ( - handleSelect(item)} - value={paletteValue(item)} - > - - {item.label} - {combo && } - {item.to && ( - - )} - {item.active && ( - - )} - - ) - })} + {group.items.map(item => ( + + ))} ))} diff --git a/apps/desktop/src/app/overlays/overlay-split-layout.tsx b/apps/desktop/src/app/overlays/overlay-split-layout.tsx index 6ed570f4b99b..b4a90dd0f322 100644 --- a/apps/desktop/src/app/overlays/overlay-split-layout.tsx +++ b/apps/desktop/src/app/overlays/overlay-split-layout.tsx @@ -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 (