Compare commits

...
Author SHA1 Message Date
ethernet 4d81fea19e feat(desktop): forward renderer console.* to desktop.log
Mirror all renderer console.log/warn/error/info/debug calls into
desktop.log via IPC. A side-effect module (console-forward.ts) monkey-
patches console.* at app init to send each call through a fire-and-forget
ipcRenderer.send to the main process, which routes it through
rememberLog() so the lines land in desktop.log alongside the main
process's own [hermes] lines.

Forwarded lines are prefixed [renderer:debug], [renderer:warn],
[renderer:error], or [renderer:info]. Objects are JSON-serialized
(capped at 4KB per line). The original console methods are preserved
— devtools still shows the full interactive object inspection.

This means the debug trace [trace:*] entries now land in desktop.log
too, so you can read them with 'hermes logs desktop' without needing
devtools open.
2026-07-23 16:59:33 -04:00
ethernet a71a90bc73 feat(desktop): add debug trace instrumentation
A device-local toggle (Settings → Advanced → Debug trace logging) that,
when enabled, dumps structured console.debug entries for every stateful
event in the desktop app:

- Session state transitions (busy/needsInput/storedSessionId edges)
- Compaction start/finish per session
- message.complete events (session id, message count, usage/billing)
- Session switches (activeSessionId + selectedStoredSessionId changes)
- Compression id rotation (the spookiest bug class — route/pin/draft key
  silently changes mid-turn)
- Persistence writes (key, op, truncated value preview)
- All gateway events (type, session id, payload — deltas summarized)
- Gateway connection state (idle→open→closed) + connection mode/profile
- Profile switches ($activeGatewayProfile changes)
- Resume failures + exhaustion (the #1 'stuck on loading' signal)
- Busy/awaitingResponse edges
- Message array length changes (count only, not per-token)
- Error boundary catches (React render crashes with componentStack)
- Blocking prompts: clarify/approval/sudo/secret raised/cleared edges
- Sessions list length changes (new/archive/delete/merge)

Zero cost when disabled: every debugTrace() call early-returns on the
$debugTraceEnabled atom, and the subscriptions (persistence, gateway
events, atom watchers) are no-op closures when tracing is off.

Pattern follows keep-awake: device-local localStorage atom, side-effect
import in main.tsx, ToggleRow in Settings → Advanced. i18n strings in
all four locales (en/ja/zh/zh-hant).
2026-07-23 16:55:06 -04:00
ethernet d44674fe08 feat(desktop): ship sourcemaps for renderer + electron main/preload
Enable sourcemaps in both vite (renderer) and esbuild (electron-main +
preload) so crash reports and devtools stack traces point at real TS/TSX
source instead of minified bundles.

Both packaging paths already carry dist/ wholesale — electron-builder
files: ["dist/**"] and nix cp -rn dist — so no packaging changes needed;
the .map files flow through automatically.
2026-07-23 16:55:06 -04:00
18 changed files with 498 additions and 4 deletions
+12
View File
@@ -9721,6 +9721,18 @@ ipcMain.handle('hermes:logs:reveal', async () => {
ipcMain.handle('hermes:logs:recent', async () => ({ path: DESKTOP_LOG_PATH, lines: hermesLog.slice(-200) }))
// Renderer console forwarding: the renderer monkey-patches console.* to send
// each call here. We format and route through rememberLog() so they land in
// desktop.log alongside the main process's own [hermes] lines.
ipcMain.on('hermes:console:forward', (_event, { level, message }) => {
const tag = level === 'error' ? '[renderer:error]'
: level === 'warn' ? '[renderer:warn]'
: level === 'info' ? '[renderer:info]'
: '[renderer:debug]'
rememberLog(`${tag} ${message}`)
})
function isExecutableFile(filePath) {
if (!filePath || !path.isAbsolute(filePath)) {
return false
+1
View File
@@ -108,6 +108,7 @@ contextBridge.exposeInMainWorld('hermesDesktop', {
},
revealLogs: () => ipcRenderer.invoke('hermes:logs:reveal'),
getRecentLogs: () => ipcRenderer.invoke('hermes:logs:recent'),
forwardConsole: (level, message) => ipcRenderer.send('hermes:console:forward', { level, message }),
readDir: dirPath => ipcRenderer.invoke('hermes:fs:readDir', dirPath),
gitRoot: startPath => ipcRenderer.invoke('hermes:fs:gitRoot', startPath),
revealPath: targetPath => ipcRenderer.invoke('hermes:fs:reveal', targetPath),
@@ -42,6 +42,7 @@ await build({
target: 'node20',
outfile: mainOut,
external,
sourcemap: true,
banner: {
js: "import { createRequire } from 'module'; const require = createRequire(import.meta.url);",
},
@@ -59,6 +60,7 @@ await build({
target: 'node20',
outfile: preloadOut,
external,
sourcemap: true,
define,
logLevel: 'info',
})
@@ -11,6 +11,7 @@ import { translateNow } from '@/i18n'
import { type GatewayEventPayload, textPart } from '@/lib/chat-messages'
import { coerceGatewayText, coerceThinkingText, normalizePersonalityValue } from '@/lib/chat-runtime'
import { playCompletionSound } from '@/lib/completion-sound'
import { debugTrace } from '@/lib/debug-trace'
import { resolveGatewayEventSessionId } from '@/lib/gateway-events'
import { triggerHaptic } from '@/lib/haptics'
import { modelOptionsQueryKey } from '@/lib/model-options'
@@ -34,6 +35,7 @@ import {
$currentCwd,
$currentModel,
$currentProvider,
$messages,
sessionMatchesStoredId,
setCurrentBranch,
setCurrentCwd,
@@ -541,6 +543,17 @@ export function useGatewayEventHandler(deps: GatewayEventDeps) {
return
}
debugTrace(
'message',
`complete session=${sessionId}`,
{
isActive: isActiveEvent,
messageCount: $messages.get().length,
hasUsage: Boolean(payload?.usage),
hasBilling: Boolean(payload?.billing)
}
)
// Turn ended — drop any blocking prompt still open for THIS session
// (e.g. interrupted, or the approval already resolved). Scoped to the
// session so a background turn finishing can't wipe the active chat's
@@ -7,6 +7,7 @@ import { useSearchParams } from 'react-router-dom'
import { Button } from '@/components/ui/button'
import { getElevenLabsVoices, getHermesConfigSchema, saveHermesConfig } from '@/hermes'
import { useI18n } from '@/i18n'
import { $debugTraceEnabled, setDebugTraceEnabled } from '@/lib/debug-trace'
import { $keepAwake, setKeepAwake } from '@/store/keep-awake'
import { notify, notifyError } from '@/store/notifications'
import { repoDiscoveryPolicyFromConfig, repoDiscoveryPolicySignature, scanAndRecordRepos } from '@/store/projects'
@@ -57,6 +58,7 @@ export function ConfigSettings({
const { t } = useI18n()
const c = t.settings.config
const keepAwake = useStore($keepAwake)
const debugTraceEnabled = useStore($debugTraceEnabled)
// The editable draft is local (debounced autosave watches it), but it's seeded
// from — and saved back through — the shared config cache, so edits are visible
// in the MCP/model surfaces and reopening the page doesn't reload-flash.
@@ -293,6 +295,9 @@ export function ConfigSettings({
{activeSectionId === 'advanced' && (
<ToggleRow checked={keepAwake} description={c.keepAwakeDesc} label={c.keepAwakeTitle} onChange={setKeepAwake} />
)}
{activeSectionId === 'advanced' && (
<ToggleRow checked={debugTraceEnabled} description={c.debugTraceDesc} label={c.debugTraceTitle} onChange={setDebugTraceEnabled} />
)}
{visibleFields.length === 0 ? (
<EmptyState description={c.emptyDesc} title={c.emptyTitle} />
) : (
@@ -2,6 +2,7 @@ import { Component, type ErrorInfo, type ReactNode } from 'react'
import { Button } from '@/components/ui/button'
import { ErrorState } from '@/components/ui/error-state'
import { debugTrace } from '@/lib/debug-trace'
import { useI18n } from '@/i18n'
export interface ErrorBoundaryFallbackProps {
@@ -30,6 +31,10 @@ export class ErrorBoundary extends Component<ErrorBoundaryProps, ErrorBoundarySt
componentDidCatch(error: Error, info: ErrorInfo) {
const tag = this.props.label ? `[error-boundary:${this.props.label}]` : '[error-boundary]'
console.error(tag, error, info.componentStack)
debugTrace('error-boundary', `${this.props.label ?? 'root'}: ${error.message}`, {
error,
componentStack: info.componentStack
})
this.props.onError?.(error, info)
}
+1
View File
@@ -116,6 +116,7 @@ declare global {
}
revealLogs: () => Promise<{ ok: boolean; path: string; error?: string }>
getRecentLogs: () => Promise<{ path: string; lines: string[] }>
forwardConsole: (level: string, message: string) => void
readDir: (path: string) => Promise<HermesReadDirResult>
gitRoot?: (path: string) => Promise<string | null>
// Reveal a path in the OS file manager (Finder / Explorer).
+3 -1
View File
@@ -541,7 +541,9 @@ export const en: Translations = {
imported: 'Config imported',
invalidJson: 'Invalid config JSON',
keepAwakeTitle: 'Keep computer awake',
keepAwakeDesc: 'Stop this machine from sleeping so long or overnight runs keep going. The display can still dim.'
keepAwakeDesc: 'Stop this machine from sleeping so long or overnight runs keep going. The display can still dim.',
debugTraceTitle: 'Debug trace logging',
debugTraceDesc: 'Log verbose state transitions, session switches, compaction events, and gateway events to the devtools console for bug reproduction.'
},
credentials: {
pasteKey: 'Paste key',
+3 -1
View File
@@ -648,7 +648,9 @@ export const ja = defineLocale({
imported: '設定をインポートしました',
invalidJson: '設定 JSON が無効です',
keepAwakeTitle: 'コンピューターをスリープさせない',
keepAwakeDesc: '本体のスリープを防ぎ、長時間や夜通しの実行を継続します。画面は暗転できます。'
keepAwakeDesc: '本体のスリープを防ぎ、長時間や夜通しの実行を継続します。画面は暗転できます。',
debugTraceTitle: 'デバッグトレースログ',
debugTraceDesc: 'バグ再現用に、状態遷移・セッション切替・圧縮イベント・ゲートウェイイベントをdevtoolsコンソールに詳細出力します。'
},
credentials: {
pasteKey: 'キーを貼り付け',
+2
View File
@@ -448,6 +448,8 @@ export interface Translations {
invalidJson: string
keepAwakeTitle: string
keepAwakeDesc: string
debugTraceTitle: string
debugTraceDesc: string
}
credentials: {
pasteKey: string
+3 -1
View File
@@ -636,7 +636,9 @@ export const zhHant = defineLocale({
imported: '設定已匯入',
invalidJson: '設定 JSON 無效',
keepAwakeTitle: '保持電腦喚醒',
keepAwakeDesc: '阻止本機睡眠,讓長時間或整夜執行持續進行。螢幕仍可變暗。'
keepAwakeDesc: '阻止本機睡眠,讓長時間或整夜執行持續進行。螢幕仍可變暗。',
debugTraceTitle: '除錯追蹤日誌',
debugTraceDesc: '將狀態轉換、工作階段切換、壓縮事件和閘道事件詳細輸出到 devtools 控制台,用於重現 bug。'
},
credentials: {
pasteKey: '貼上金鑰',
+3 -1
View File
@@ -748,7 +748,9 @@ export const zh: Translations = {
imported: '配置已导入',
invalidJson: '配置 JSON 无效',
keepAwakeTitle: '保持电脑唤醒',
keepAwakeDesc: '阻止本机休眠,让长时间或通宵运行继续进行。屏幕仍可变暗。'
keepAwakeDesc: '阻止本机休眠,让长时间或通宵运行继续进行。屏幕仍可变暗。',
debugTraceTitle: '调试追踪日志',
debugTraceDesc: '将状态转换、会话切换、压缩事件和网关事件详细输出到 devtools 控制台,用于复现 bug。'
},
credentials: {
pasteKey: '粘贴密钥',
+89
View File
@@ -0,0 +1,89 @@
/**
* Console forwarder mirrors renderer console.* calls into desktop.log via
* IPC. Always on: every console.log/warn/error/info/debug call is forwarded
* to the main process, which writes it through rememberLog() so it lands in
* desktop.log alongside the main process's own [hermes] lines.
*
* Objects/arrays are JSON-serialized (best-effort); non-serializable values
* fall back to their toString(). Multiple args are space-joined. This is a
* one-way fire-and-forget (ipcRenderer.send, no await) so it never blocks the
* renderer or affects call timing.
*
* The original console methods are preserved devtools still shows the full
* object inspection experience. This only adds a parallel write to desktop.log.
*/
type ConsoleMethod = 'log' | 'warn' | 'error' | 'info' | 'debug'
const LEVEL_MAP: Record<ConsoleMethod, string> = {
log: 'info',
warn: 'warn',
error: 'error',
info: 'info',
debug: 'debug'
}
function serializeArg(arg: unknown): string {
if (arg === null) {
return 'null'
}
if (arg === undefined) {
return 'undefined'
}
if (typeof arg === 'string') {
return arg
}
if (typeof arg === 'number' || typeof arg === 'boolean' || typeof arg === 'bigint') {
return String(arg)
}
if (arg instanceof Error) {
return `${arg.name}: ${arg.message}${arg.stack ? `\n${arg.stack}` : ''}`
}
// Objects/arrays — try JSON, fall back to String.
try {
return JSON.stringify(arg)
} catch {
try {
return String(arg)
} catch {
return '[unserializable]'
}
}
}
function forward(level: ConsoleMethod, args: unknown[]): void {
const forwarder = window.hermesDesktop?.forwardConsole
if (!forwarder) {
return
}
const message = args.map(serializeArg).join(' ')
// Cap at 4KB per line — a single huge object dump shouldn't flood desktop.log.
const capped = message.length > 4096 ? `${message.slice(0, 4096)}…(${message.length} chars)` : message
forwarder(LEVEL_MAP[level], capped)
}
if (typeof window !== 'undefined') {
for (const method of Object.keys(LEVEL_MAP) as ConsoleMethod[]) {
const original = console[method].bind(console)
console[method] = (...args: unknown[]) => {
original(...args)
try {
forward(method, args)
} catch {
// Forwarding must never break the original call or throw in the
// renderer — it already ran above.
}
}
}
}
+328
View File
@@ -0,0 +1,328 @@
/**
* Debug trace optional, verbose instrumentation of stateful desktop events.
*
* When enabled (Settings Advanced), dumps structured `console.debug` entries
* for every session state transition, compaction event, message completion,
* session switch, persistence write, and gateway event. The output lands in the
* renderer devtools console and any attached log capture, so you can point at
* the trace after reproducing a bug.
*
* Zero cost when disabled: every call site is a single function call that
* early-returns when the atom is off. The subscriptions (persistence, gateway
* events, atom watchers) are only attached once at module init, and their
* callbacks also early-return so the overhead is one closure call per event,
* which is negligible.
*/
import { atom } from 'nanostores'
import { onGatewayEvent } from '@/contrib/events'
import { onPersistenceEvent } from '@/lib/storage'
import { $activeGatewayProfile } from '@/store/profile'
import {
$activeSessionId,
$activeSessionStoredIdRotation,
$awaitingResponse,
$busy,
$connection,
$gatewayState,
$messages,
$resumeExhaustedSessionId,
$resumeFailedSessionId,
$selectedStoredSessionId,
$sessions
} from '@/store/session'
import { $clarifyRequest } from '@/store/clarify'
import { $approvalRequest, $secretRequest, $sudoRequest } from '@/store/prompts'
const KEY = 'hermes.desktop.debugTrace.v1'
/** Device-local preference — off by default, per machine. */
export const $debugTraceEnabled = atom<boolean>(
typeof window === 'undefined' ? false : (() => {
try {
return window.localStorage.getItem(KEY) === 'true'
} catch {
return false
}
})()
)
export function setDebugTraceEnabled(on: boolean): void {
$debugTraceEnabled.set(on)
}
if (typeof window !== 'undefined') {
$debugTraceEnabled.subscribe(on => {
try {
window.localStorage.setItem(KEY, String(on))
} catch {
// Storage best-effort.
}
})
}
export type DebugCategory =
| 'session-state'
| 'compaction'
| 'message'
| 'session-switch'
| 'persistence'
| 'gateway-event'
| 'connection'
| 'profile-switch'
| 'resume'
| 'busy'
| 'error-boundary'
| 'prompt'
| 'sessions-list'
const CATEGORY_PREFIX: Record<DebugCategory, string> = {
'session-state': '[trace:session-state]',
compaction: '[trace:compaction]',
message: '[trace:message]',
'session-switch': '[trace:session-switch]',
persistence: '[trace:persistence]',
'gateway-event': '[trace:gateway-event]',
connection: '[trace:connection]',
'profile-switch': '[trace:profile-switch]',
resume: '[trace:resume]',
busy: '[trace:busy]',
'error-boundary': '[trace:error-boundary]',
prompt: '[trace:prompt]',
'sessions-list': '[trace:sessions-list]'
}
/**
* Emit a debug trace entry. No-ops entirely when tracing is disabled.
*
* `data` is spread as additional console arguments (not stringified) so
* devtools can expand/inspect objects natively.
*/
export function debugTrace(
category: DebugCategory,
message: string,
...data: unknown[]
): void {
if (!$debugTraceEnabled.get()) {
return
}
const ts = new Date().toISOString()
// eslint-disable-next-line no-console
console.debug(`${CATEGORY_PREFIX[category]} ${ts} ${message}`, ...data)
}
// ---------------------------------------------------------------------------
// Subscriptions — attached once at module init, no-op when disabled.
// ---------------------------------------------------------------------------
if (typeof window !== 'undefined') {
// --- Session switches ---
let prevActive: string | null = null
$activeSessionId.subscribe(id => {
if (id !== prevActive) {
debugTrace('session-switch', `activeSessionId ${prevActive ?? 'null'}${id ?? 'null'}`)
prevActive = id
}
})
let prevSelected: string | null = null
$selectedStoredSessionId.subscribe(id => {
if (id !== prevSelected) {
debugTrace('session-switch', `selectedStoredSessionId ${prevSelected ?? 'null'}${id ?? 'null'}`)
prevSelected = id
}
})
// --- Persistence events ---
onPersistenceEvent(event => {
const valuePreview =
event.value === null
? 'null'
: event.value.length > 120
? `${event.value.slice(0, 120)}…(${event.value.length} chars)`
: event.value
debugTrace('persistence', `${event.op} ${event.key}`, { value: valuePreview })
})
// --- Gateway events (wildcard) ---
onGatewayEvent('*', event => {
const type = event.type ?? 'unknown'
const raw = event as unknown as Record<string, unknown>
const sessionId = raw.session_id ?? raw.sessionId ?? null
// Summarize — don't dump the full payload for high-frequency deltas.
const summary: Record<string, unknown> = { type }
if (sessionId) {
summary.sessionId = sessionId
}
// For message.delta, just note it happened (they fire 30×/s during a turn).
// For everything else, include the payload for inspection.
if (type === 'message.delta') {
summary.note = 'delta (streaming)'
} else {
summary.payload = event
}
debugTrace('gateway-event', type, summary)
})
// --- Gateway connection state ---
let prevGatewayState: string | undefined
$gatewayState.subscribe(state => {
if (state !== prevGatewayState) {
debugTrace('connection', `gatewayState ${prevGatewayState ?? 'undefined'}${state}`)
prevGatewayState = state
}
})
// --- Connection (mode/baseUrl/profile) ---
let prevConnMode: string | undefined
let prevConnProfile: string | undefined
$connection.subscribe(conn => {
const mode = conn?.mode ?? 'null'
const profile = conn?.profile ?? 'null'
if (mode !== prevConnMode || profile !== prevConnProfile) {
debugTrace('connection', `connection mode=${mode} profile=${profile} baseUrl=${conn?.baseUrl ?? 'null'}`)
prevConnMode = mode
prevConnProfile = profile
}
})
// --- Profile switches ---
let prevProfile: string | undefined
$activeGatewayProfile.subscribe(profile => {
if (profile !== prevProfile) {
debugTrace('profile-switch', `${prevProfile ?? 'undefined'}${profile}`)
prevProfile = profile
}
})
// --- Resume failures + exhaustion ---
let prevResumeFailed: string | null = null
$resumeFailedSessionId.subscribe(id => {
if (id !== prevResumeFailed) {
debugTrace('resume', `resumeFailedSessionId ${prevResumeFailed ?? 'null'}${id ?? 'null'}`)
prevResumeFailed = id
}
})
let prevResumeExhausted: string | null = null
$resumeExhaustedSessionId.subscribe(id => {
if (id !== prevResumeExhausted) {
debugTrace('resume', `resumeExhaustedSessionId ${prevResumeExhausted ?? 'null'}${id ?? 'null'}`)
prevResumeExhausted = id
}
})
// --- Busy / awaitingResponse edges ---
let prevBusy = false
$busy.subscribe(busy => {
if (busy !== prevBusy) {
debugTrace('busy', `busy ${prevBusy}${busy}`, { activeSessionId: $activeSessionId.get() })
prevBusy = busy
}
})
let prevAwaiting = false
$awaitingResponse.subscribe(awaiting => {
if (awaiting !== prevAwaiting) {
debugTrace('busy', `awaitingResponse ${prevAwaiting}${awaiting}`, { activeSessionId: $activeSessionId.get() })
prevAwaiting = awaiting
}
})
// --- Message array length changes ---
// Not per-token (that'd be insane) — only when the count changes, which
// captures: new message added, transcript cleared, session switched,
// reconciliation replaced the array.
let prevMessageCount = -1
$messages.subscribe(messages => {
const count = messages.length
if (count !== prevMessageCount) {
debugTrace('message', `messages count ${prevMessageCount}${count}`, {
activeSessionId: $activeSessionId.get()
})
prevMessageCount = count
}
})
// --- Compression id rotation ---
// Fires when auto-compaction rotates the active session's stored id mid-turn.
// One of the spookiest bug classes — the route / pin / draft key silently
// changes under the user.
$activeSessionStoredIdRotation.subscribe(rotation => {
if (rotation) {
debugTrace('session-switch', `compression id rotation`, {
prev: rotation.previousStoredSessionId,
next: rotation.nextStoredSessionId,
runtime: rotation.runtimeSessionId,
isActive: rotation.runtimeSessionId === $activeSessionId.get()
})
}
})
// --- Blocking prompts (clarify / approval / sudo / secret) ---
// When these appear/disappear, the chat is blocked. Tracing the edges
// catches "agent silently stalled" bugs.
let prevClarify: unknown = null
$clarifyRequest.subscribe(req => {
if (req !== prevClarify) {
debugTrace('prompt', `clarify ${prevClarify ? 'cleared' : 'raised'}`, {
sessionId: $activeSessionId.get(),
requestId: req ? 'present' : 'null'
})
prevClarify = req
}
})
let prevApproval: unknown = null
$approvalRequest.subscribe(req => {
if (req !== prevApproval) {
debugTrace('prompt', `approval ${prevApproval ? 'cleared' : 'raised'}`, {
sessionId: $activeSessionId.get()
})
prevApproval = req
}
})
let prevSudo: unknown = null
$sudoRequest.subscribe(req => {
if (req !== prevSudo) {
debugTrace('prompt', `sudo ${prevSudo ? 'cleared' : 'raised'}`, {
sessionId: $activeSessionId.get()
})
prevSudo = req
}
})
let prevSecret: unknown = null
$secretRequest.subscribe(req => {
if (req !== prevSecret) {
debugTrace('prompt', `secret ${prevSecret ? 'cleared' : 'raised'}`, {
sessionId: $activeSessionId.get()
})
prevSecret = req
}
})
// --- Sessions list length changes ---
// Captures: new session created, session archived/deleted, sidebar merge
// kept/dropped a row. Not per-field — just the count edge.
let prevSessionsCount = -1
$sessions.subscribe(list => {
const count = list.length
if (count !== prevSessionsCount) {
debugTrace('sessions-list', `sessions count ${prevSessionsCount}${count}`)
prevSessionsCount = count
}
})
}
+5
View File
@@ -1,6 +1,11 @@
import './styles.css'
// Side-effect: applies the persisted window translucency on load.
import './store/translucency'
// Side-effect: attaches debug trace subscriptions (persistence, gateway
// events, session switch watchers). No-ops entirely when tracing is disabled.
import './lib/debug-trace'
// Side-effect: mirrors renderer console.* calls into desktop.log via IPC.
import './lib/console-forward'
import { QueryClientProvider } from '@tanstack/react-query'
import { StrictMode } from 'react'
+5
View File
@@ -1,5 +1,6 @@
import { atom, computed } from 'nanostores'
import { debugTrace } from '@/lib/debug-trace'
import { $activeSessionId } from './session'
// Per-session flag while auto-compaction runs mid-turn. Without it the
@@ -25,6 +26,8 @@ export function setSessionCompacting(sessionId: string | null | undefined, activ
$compactingSessions.set({ ...sessions, [key]: true })
debugTrace('compaction', `started session=${key}`, { isActive: key === $activeSessionId.get() })
return
}
@@ -35,4 +38,6 @@ export function setSessionCompacting(sessionId: string | null | undefined, activ
const next = { ...sessions }
delete next[key]
$compactingSessions.set(next)
debugTrace('compaction', `finished session=${key}`, { isActive: key === $activeSessionId.get() })
}
+13
View File
@@ -27,6 +27,7 @@ import {
noteActiveTreeGroup,
revealTreePane
} from '@/components/pane-shell/tree/store'
import { debugTrace } from '@/lib/debug-trace'
import { stableArray } from '@/lib/stable-array'
import { readJson, writeJson } from '@/lib/storage'
@@ -189,6 +190,18 @@ export function publishSessionState(runtimeId: string, state: ClientSessionState
const prev = $sessionStates.get()[runtimeId] ?? null
$sessionStates.set({ ...$sessionStates.get(), [runtimeId]: state })
handleTransition(prev, state, runtimeId)
debugTrace(
'session-state',
`publish ${runtimeId}`,
{
prev: prev
? { storedSessionId: prev.storedSessionId, busy: prev.busy, needsInput: prev.needsInput }
: null,
next: { storedSessionId: state.storedSessionId, busy: state.busy, needsInput: state.needsInput },
isActive: runtimeId === $activeSessionId.get()
}
)
}
export function dropSessionState(runtimeId: string) {
+5
View File
@@ -42,6 +42,11 @@ export default defineConfig({
postcss: { plugins: [] }
},
build: {
// Ship sourcemaps so crash reports and devtools stack traces point at
// real TS/TSX source, not minified bundles. Separate .map files (not
// inline) keep the executable bundle lean; both electron-builder
// (files: ["dist/**"]) and nix (cp -rn dist) carry them through.
sourcemap: true,
// Keep desktop packaging stable: Shiki ships many dynamic chunks by
// default, and electron-builder can OOM scanning thousands of files.
// Collapsing to a single chunk is intentional, so the renderer bundle is