From c71112db15f2e3b986ce13804f67fb0ee9a4292a Mon Sep 17 00:00:00 2001 From: Ben Date: Tue, 7 Apr 2026 00:18:37 +0200 Subject: [PATCH 1/3] Fix auto-update and add Settings page with app logs Auto-updater now surfaces errors to the UI, uses periodic re-checks (every 30 min), and switches to checkForUpdates() since we have custom UI. Added centralized logger module with ring buffer that pipes main process logs to renderer via IPC. New Settings view shows update controls and a filterable live log viewer. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/main/fileWatcher.ts | 3 +- src/main/github.ts | 7 +- src/main/index.ts | 42 +++- src/main/logger.ts | 55 ++++++ src/main/sessionManager.ts | 13 +- src/main/worktree.ts | 3 +- src/preload/index.ts | 23 ++- src/renderer/src/App.tsx | 10 + src/renderer/src/components/SettingsView.tsx | 193 +++++++++++++++++++ src/renderer/src/components/Sidebar.tsx | 42 +++- src/renderer/src/types.ts | 2 +- 11 files changed, 364 insertions(+), 29 deletions(-) create mode 100644 src/main/logger.ts create mode 100644 src/renderer/src/components/SettingsView.tsx diff --git a/src/main/fileWatcher.ts b/src/main/fileWatcher.ts index c0a3693..58dbb91 100644 --- a/src/main/fileWatcher.ts +++ b/src/main/fileWatcher.ts @@ -1,4 +1,5 @@ import { execFile } from 'child_process' +import { log } from './logger' import { watch, type FSWatcher } from 'chokidar' import { BrowserWindow } from 'electron' @@ -42,7 +43,7 @@ function runGitStatus(cwd: string): Promise { return new Promise((resolve) => { execFile('git', ['status', '--porcelain'], { cwd }, (err, stdout) => { if (err) { - console.warn('[fileWatcher] git status failed for', cwd, (err as Error).message) + log.warn('fileWatcher', `git status failed for ${cwd}: ${(err as Error).message}`) resolve([]) return } diff --git a/src/main/github.ts b/src/main/github.ts index ec21147..766c5f4 100644 --- a/src/main/github.ts +++ b/src/main/github.ts @@ -1,4 +1,5 @@ import { execFile } from 'child_process' +import { log } from './logger' import type { GitHubRepo, GitHubPR, GitHubIssue } from '../shared/types' export type { GitHubRepo, GitHubPR, GitHubIssue } @@ -7,7 +8,7 @@ export function getGitHubRepo(cwd: string): Promise { return new Promise((resolve) => { execFile('git', ['remote', 'get-url', 'origin'], { cwd }, (err, stdout) => { if (err) { - console.warn('[github] Failed to get remote URL:', (err as Error).message) + log.warn('github', `Failed to get remote URL: ${(err as Error).message}`) resolve(null) return } @@ -39,7 +40,7 @@ export async function listPullRequests( { cwd }, (err, stdout) => { if (err) { - console.warn('[github] gh pr list failed:', (err as Error).message) + log.warn('github', `gh pr list failed: ${(err as Error).message}`) reject(err) return } @@ -99,7 +100,7 @@ export async function listIssues( { cwd }, (err, stdout) => { if (err) { - console.warn('[github] gh issue list failed:', (err as Error).message) + log.warn('github', `gh issue list failed: ${(err as Error).message}`) reject(err) return } diff --git a/src/main/index.ts b/src/main/index.ts index 7c57741..8e134eb 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -1,5 +1,6 @@ import { app, shell, BrowserWindow, ipcMain, dialog } from 'electron' import { autoUpdater } from 'electron-updater' +import { log } from './logger' if (process.platform === 'linux') { app.commandLine.appendSwitch('no-sandbox') @@ -200,7 +201,7 @@ function registerSessionHandlers(ipc: typeof ipcMain, window: BrowserWindow): vo } context = snippets.join('\n\n') } catch (err) { - console.warn('[summary] Failed to read transcript:', (err as Error).message) + log.warn('summary', `Failed to read transcript: ${(err as Error).message}`) } if (!context) return '' @@ -214,7 +215,7 @@ function registerSessionHandlers(ipc: typeof ipcMain, window: BrowserWindow): vo { env: getEnv(), timeout: 15000 }, (err, stdout) => { if (err || !stdout) { - if (err) console.warn('[summary] Claude CLI failed:', (err as Error).message) + if (err) log.warn('summary', `Claude CLI failed: ${(err as Error).message}`) resolve('') return } @@ -258,7 +259,7 @@ function registerFileHandlers(ipc: typeof ipcMain): void { } else { execFile('git', ['diff', 'HEAD', '--', filePath], { cwd }, (err, stdout) => { if (err && !stdout) { - console.warn('[diff] git diff failed for', filePath, (err as Error).message) + log.warn('diff', `git diff failed for ${filePath}: ${(err as Error).message}`) resolve('') return } @@ -371,6 +372,18 @@ function registerUpdateHandlers(ipc: typeof ipcMain): void { ipc.on('install-update', () => { autoUpdater.quitAndInstall() }) + ipc.on('check-for-updates', () => { + if (is.dev) { + log.warn('updater', 'Auto-update is not available in development mode') + mainWindow?.webContents.send('update-status', { + status: 'error', + message: 'Auto-update is not available in development mode' + }) + return + } + autoUpdater.checkForUpdates() + }) + ipc.handle('get-logs', () => log.getHistory()) } // ─── App Lifecycle ──────────────────────────────────────────────────── @@ -383,6 +396,8 @@ app.whenReady().then(() => { }) createWindow() + log.setWindow(mainWindow!) + log.info('app', `Konductor started (${is.dev ? 'dev' : 'production'})`) registerStateHandlers(ipcMain) registerSessionHandlers(ipcMain, mainWindow!) @@ -398,30 +413,37 @@ app.whenReady().then(() => { autoUpdater.autoDownload = true autoUpdater.autoInstallOnAppQuit = true - autoUpdater.on('checking-for-update', () => console.log('[updater] Checking for update…')) + autoUpdater.on('checking-for-update', () => log.info('updater', 'Checking for update…')) autoUpdater.on('update-available', (info) => { - console.log(`[updater] Update available: ${info.version}`) + log.info('updater', `Update available: ${info.version}`) mainWindow?.webContents.send('update-status', { status: 'available', version: info.version }) }) autoUpdater.on('update-not-available', (info) => - console.log(`[updater] Up to date (${info.version})`) + log.info('updater', `Up to date (${info.version})`) ) autoUpdater.on('download-progress', (p) => - console.log(`[updater] Downloading: ${Math.round(p.percent)}%`) + log.info('updater', `Downloading: ${Math.round(p.percent)}%`) ) autoUpdater.on('update-downloaded', (info) => { - console.log(`[updater] Update downloaded: ${info.version} — will install on quit`) + log.info('updater', `Update downloaded: ${info.version} — will install on quit`) mainWindow?.webContents.send('update-status', { status: 'ready', version: info.version }) }) - autoUpdater.on('error', (err) => console.error('[updater] Error:', err.message)) + autoUpdater.on('error', (err) => { + log.error('updater', err.message) + mainWindow?.webContents.send('update-status', { + status: 'error', + message: err.message + }) + }) - autoUpdater.checkForUpdatesAndNotify() + autoUpdater.checkForUpdates() + setInterval(() => autoUpdater.checkForUpdates(), 30 * 60 * 1000) } app.on('activate', () => { diff --git a/src/main/logger.ts b/src/main/logger.ts new file mode 100644 index 0000000..02768f5 --- /dev/null +++ b/src/main/logger.ts @@ -0,0 +1,55 @@ +import type { BrowserWindow } from 'electron' + +export interface LogEntry { + timestamp: number + level: 'info' | 'warn' | 'error' + category: string + message: string +} + +const MAX_ENTRIES = 500 + +class Logger { + private buffer: LogEntry[] = [] + private window: BrowserWindow | null = null + + setWindow(win: BrowserWindow): void { + this.window = win + } + + info(category: string, message: string): void { + this.write('info', category, message) + console.log(`[${category}] ${message}`) + } + + warn(category: string, message: string): void { + this.write('warn', category, message) + console.warn(`[${category}] ${message}`) + } + + error(category: string, message: string): void { + this.write('error', category, message) + console.error(`[${category}] ${message}`) + } + + getHistory(): LogEntry[] { + return this.buffer.slice() + } + + private write(level: LogEntry['level'], category: string, message: string): void { + const entry: LogEntry = { timestamp: Date.now(), level, category, message } + + if (this.buffer.length >= MAX_ENTRIES) { + this.buffer.shift() + } + this.buffer.push(entry) + + try { + this.window?.webContents.send('app-log', entry) + } catch { + // window may have been destroyed + } + } +} + +export const log = new Logger() diff --git a/src/main/sessionManager.ts b/src/main/sessionManager.ts index 7c18787..86e1e0f 100644 --- a/src/main/sessionManager.ts +++ b/src/main/sessionManager.ts @@ -8,6 +8,7 @@ import { BrowserWindow } from 'electron' import { is } from '@electron-toolkit/utils' import { createFileWatcher, FileWatcher } from './fileWatcher' import { shellQuote } from './shellEscape' +import { log } from './logger' import { ScrollbackBuffer } from './ringBuffer' const DEV_PLUGIN_PATH = join(__dirname, '../../claude-code-plugin') @@ -149,7 +150,7 @@ function spawnClaude( args.push('--prompt', prompt) } - console.log(`[session] spawn: ${getClaudePath()} ${args.join(' ')} cwd=${cwd}`) + log.info('session', `spawn: ${getClaudePath()} ${args.join(' ')} cwd=${cwd}`) return nodePty.spawn(getClaudePath(), args, { name: 'xterm-256color', @@ -178,9 +179,7 @@ export function createSession( const envScript = opts?.envScript ?? detectEnvScript(cwd) const env = envScript ? getProjectEnv(envScript, cwd) : undefined - console.log( - `[session] createSession id=${id} claude=${claudeSessionId} resume=${resume} cwd=${cwd}` - ) + log.info('session', `createSession id=${id} claude=${claudeSessionId} resume=${resume} cwd=${cwd}`) const pty = spawnClaude(cwd, claudeSessionId, name, resume, opts?.prompt, env) @@ -202,7 +201,7 @@ export function createSession( }) pty.onExit(({ exitCode }) => { - console.log(`[session] pty-exit id=${id} claude=${claudeSessionId} exitCode=${exitCode}`) + log.info('session', `pty-exit id=${id} claude=${claudeSessionId} exitCode=${exitCode}`) entry.alive = false if (!window.isDestroyed()) { window.webContents.send('pty-exit', { sessionId: id, exitCode }) @@ -299,14 +298,14 @@ export function ensurePluginInstalled(): void { // Add the marketplace (idempotent — no-ops if already added) execFileSync(claude, ['plugin', 'marketplace', 'add', MARKETPLACE_REPO], opts) } catch (err) { - console.warn('[plugin] Failed to add marketplace:', (err as Error).message) + log.warn('plugin', `Failed to add marketplace: ${(err as Error).message}`) } try { // Install the plugin (idempotent — no-ops if already installed) execFileSync(claude, ['plugin', 'install', MARKETPLACE_PLUGIN], opts) } catch (err) { - console.warn('[plugin] Failed to install plugin:', (err as Error).message) + log.warn('plugin', `Failed to install plugin: ${(err as Error).message}`) } } diff --git a/src/main/worktree.ts b/src/main/worktree.ts index 78228eb..12187f5 100644 --- a/src/main/worktree.ts +++ b/src/main/worktree.ts @@ -1,4 +1,5 @@ import { execFile } from 'child_process' +import { log } from './logger' import { join } from 'path' import { mkdir } from 'fs/promises' import type { WorktreeInfo, BranchDetail, PrInfo, PrState, BranchFile } from '../shared/types' @@ -30,7 +31,7 @@ function ghSafe(args: string[], cwd: string): Promise { return new Promise((resolve) => { execFile('gh', args, { cwd }, (err, stdout) => { if (err) { - console.warn('[gh]', args.slice(0, 3).join(' '), 'failed:', (err as Error).message) + log.warn('gh', `${args.slice(0, 3).join(' ')} failed: ${(err as Error).message}`) resolve('') } else { resolve((stdout || '').trim()) diff --git a/src/preload/index.ts b/src/preload/index.ts index c63f6c3..4beb846 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -6,7 +6,16 @@ import type { PersistedState } from '../main/store' import type { WorktreeInfo, BranchDetail, BranchFile } from '../shared/types' import type { GitHubRepo, GitHubPR, GitHubIssue } from '../shared/types' -export type UpdateStatus = { status: 'available' | 'ready'; version: string } +export type UpdateStatus = + | { status: 'available' | 'ready'; version: string } + | { status: 'error'; message: string } + +export interface LogEntry { + timestamp: number + level: 'info' | 'warn' | 'error' + category: string + message: string +} export interface KonductorAPI { loadState: () => Promise @@ -51,6 +60,9 @@ export interface KonductorAPI { generateSummary: (cwd: string, claudeSessionId: string) => Promise onUpdateStatus: (cb: (status: UpdateStatus) => void) => () => void installUpdate: () => void + checkForUpdates: () => void + getLogs: () => Promise + onAppLog: (cb: (entry: LogEntry) => void) => () => void onSessionActivity: ( cb: (claudeSessionId: string, state: ActivityState, tool: string, summary: string) => void ) => () => void @@ -169,6 +181,15 @@ const api: KonductorAPI = { return () => ipcRenderer.removeListener('update-status', handler) }, installUpdate: () => ipcRenderer.send('install-update'), + checkForUpdates: () => ipcRenderer.send('check-for-updates'), + getLogs: () => ipcRenderer.invoke('get-logs'), + onAppLog: (cb: (entry: LogEntry) => void) => { + const handler = (_event: Electron.IpcRendererEvent, entry: LogEntry): void => { + cb(entry) + } + ipcRenderer.on('app-log', handler) + return () => ipcRenderer.removeListener('app-log', handler) + }, onSessionActivity: ( cb: (claudeSessionId: string, state: ActivityState, tool: string, summary: string) => void diff --git a/src/renderer/src/App.tsx b/src/renderer/src/App.tsx index c73630a..140debc 100644 --- a/src/renderer/src/App.tsx +++ b/src/renderer/src/App.tsx @@ -9,6 +9,7 @@ import FocusView from './components/FocusView' import WorktreeModal from './components/WorktreeModal' import BranchesView from './components/BranchesView' import GitHubView from './components/GitHubView' +import SettingsView from './components/SettingsView' const savedViewMode = import.meta.hot?.data?.viewMode as ViewMode | undefined @@ -76,6 +77,10 @@ function App(): React.JSX.Element { setViewMode('github') }, []) + const handleShowSettings = useCallback(() => { + setViewMode('settings') + }, []) + const handleOpenBranchSession = useCallback( async (branch: string, isNew: boolean, prompt?: string) => { if (!activeProject || !activeProjectId) return @@ -309,6 +314,7 @@ function App(): React.JSX.Element { onUpdateProject={updateProject} onShowBranches={handleShowBranches} onShowGitHub={handleShowGitHub} + onShowSettings={handleShowSettings} />
@@ -362,6 +368,10 @@ function App(): React.JSX.Element { onOpenSession={handleOpenBranchSession} /> )} + + {effectiveViewMode === 'settings' && ( + setViewMode('grid')} /> + )}
{worktreeProject && ( diff --git a/src/renderer/src/components/SettingsView.tsx b/src/renderer/src/components/SettingsView.tsx new file mode 100644 index 0000000..ca12a29 --- /dev/null +++ b/src/renderer/src/components/SettingsView.tsx @@ -0,0 +1,193 @@ +import { useState, useEffect, useRef, useCallback } from 'react' +import { ChevronLeftIcon, RefreshIcon } from './Icons' +import type { UpdateStatus, LogEntry } from '../../../preload/index' + +const api = window.konductorAPI + +interface SettingsViewProps { + onBack: () => void +} + +const levelColors: Record = { + info: 'text-gray-400', + warn: 'text-yellow-400', + error: 'text-red-400' +} + +const levelBadgeColors: Record = { + info: 'bg-gray-700 text-gray-300', + warn: 'bg-yellow-900/50 text-yellow-400', + error: 'bg-red-900/50 text-red-400' +} + +function formatTime(ts: number): string { + const d = new Date(ts) + return d.toLocaleTimeString('en-US', { hour12: false, hour: '2-digit', minute: '2-digit', second: '2-digit' }) +} + +export default function SettingsView({ onBack }: SettingsViewProps): React.JSX.Element { + const [updateStatus, setUpdateStatus] = useState(null) + const [checking, setChecking] = useState(false) + const [logs, setLogs] = useState([]) + const [filter, setFilter] = useState('') + const logEndRef = useRef(null) + const logContainerRef = useRef(null) + const isAtBottom = useRef(true) + + useEffect(() => { + return api.onUpdateStatus((status) => { + setUpdateStatus(status) + setChecking(false) + }) + }, []) + + useEffect(() => { + api.getLogs().then(setLogs) + return api.onAppLog((entry) => { + setLogs((prev) => { + const next = [...prev, entry] + return next.length > 500 ? next.slice(-500) : next + }) + }) + }, []) + + useEffect(() => { + if (isAtBottom.current) { + logEndRef.current?.scrollIntoView({ behavior: 'smooth' }) + } + }, [logs]) + + const handleScroll = useCallback(() => { + const el = logContainerRef.current + if (!el) return + isAtBottom.current = el.scrollHeight - el.scrollTop - el.clientHeight < 40 + }, []) + + const handleCheckForUpdates = useCallback(() => { + setChecking(true) + setUpdateStatus(null) + api.checkForUpdates() + }, []) + + const filteredLogs = filter + ? logs.filter( + (l) => + l.category.toLowerCase().includes(filter.toLowerCase()) || + l.message.toLowerCase().includes(filter.toLowerCase()) + ) + : logs + + return ( +
+ {/* Header */} +
+ +

Settings

+
+ +
+ {/* Update Section */} +
+

+ Updates +

+
+
+
+
Konductor v{__APP_VERSION__}
+
+ {updateStatus === null && !checking && 'No update info yet'} + {checking && 'Checking for updates...'} + {updateStatus?.status === 'available' && ( + + Downloading v{updateStatus.version}... + + )} + {updateStatus?.status === 'ready' && ( + + v{updateStatus.version} ready to install + + )} + {updateStatus?.status === 'error' && ( + {updateStatus.message} + )} +
+
+
+ {updateStatus?.status === 'ready' ? ( + + ) : ( + + )} +
+
+ + {updateStatus?.status === 'available' && ( +
+
+
+ )} +
+
+ + {/* App Logs Section */} +
+
+

+ App Logs +

+ setFilter(e.target.value)} + className="bg-surface border border-surface-border rounded px-2 py-1 text-[10px] text-gray-300 placeholder-gray-600 w-40 focus:outline-none focus:border-gray-500" + /> +
+
+ {filteredLogs.length === 0 ? ( +
No logs yet
+ ) : ( +
+ {filteredLogs.map((entry, i) => ( +
+ {formatTime(entry.timestamp)} + + {entry.category} + + {entry.message} +
+ ))} +
+
+ )} +
+
+
+
+ ) +} diff --git a/src/renderer/src/components/Sidebar.tsx b/src/renderer/src/components/Sidebar.tsx index 6ef81d3..22f1ab2 100644 --- a/src/renderer/src/components/Sidebar.tsx +++ b/src/renderer/src/components/Sidebar.tsx @@ -26,6 +26,7 @@ interface SidebarProps { onUpdateProject: (id: string, updates: Partial) => void onShowBranches: () => void onShowGitHub: () => void + onShowSettings: () => void } export default function Sidebar({ @@ -42,7 +43,8 @@ export default function Sidebar({ onRemoveProject, onUpdateProject, onShowBranches, - onShowGitHub + onShowGitHub, + onShowSettings }: SidebarProps): React.JSX.Element { const [expandedIds, setExpandedIds] = useState>(() => { const initial = new Set() @@ -339,7 +341,23 @@ export default function Sidebar({ + New Project - +
+ + +
@@ -418,15 +436,29 @@ function EnvScriptSection({ } function VersionIndicator(): React.JSX.Element { - const [update, setUpdate] = useState<{ status: 'available' | 'ready'; version: string } | null>( - null - ) + const [update, setUpdate] = useState< + | { status: 'available' | 'ready'; version: string } + | { status: 'error'; message: string } + | null + >(null) useEffect(() => { return window.konductorAPI.onUpdateStatus((info) => setUpdate(info)) }, []) if (update) { + if (update.status === 'error') { + return ( +
+ + Update error +
+ ) + } + const isReady = update.status === 'ready' return (