diff --git a/src/main/__tests__/store.test.ts b/src/main/__tests__/store.test.ts index 09b261a..9f6a5cf 100644 --- a/src/main/__tests__/store.test.ts +++ b/src/main/__tests__/store.test.ts @@ -8,7 +8,7 @@ vi.mock('fs/promises', () => ({ })) import { readFile, writeFile, rename, mkdir } from 'fs/promises' -import { loadState, saveState, type PersistedState } from '../store' +import { loadState, saveState, DEFAULT_AUTO_SUMMARY, type PersistedState } from '../store' const mockedReadFile = vi.mocked(readFile) const mockedWriteFile = vi.mocked(writeFile) @@ -34,7 +34,8 @@ describe('loadState', () => { nextProjectId: 1, sessions: [], activeSessionIndex: null, - gridCols: 2 + gridCols: 2, + autoSummary: DEFAULT_AUTO_SUMMARY }) }) diff --git a/src/main/store.ts b/src/main/store.ts index 9f8eaf7..3c736a5 100644 --- a/src/main/store.ts +++ b/src/main/store.ts @@ -1,7 +1,8 @@ import { readFile, writeFile, rename, mkdir } from 'fs/promises' import { join } from 'path' import { homedir } from 'os' -import type { PrInfo, IssueInfo } from '../shared/types' +import type { PrInfo, IssueInfo, AutoSummarySettings } from '../shared/types' +import { DEFAULT_AUTO_SUMMARY } from '../shared/types' const STORE_DIR = join(homedir(), '.konductor') const STATE_FILE = join(STORE_DIR, 'state.json') @@ -23,6 +24,9 @@ export interface SessionData { issue?: IssueInfo } +export type { AutoSummarySettings } +export { DEFAULT_AUTO_SUMMARY } + export interface PersistedState { projects: ProjectData[] activeProjectId: string | null @@ -30,6 +34,7 @@ export interface PersistedState { sessions: SessionData[] activeSessionIndex: number | null gridCols?: 1 | 2 + autoSummary?: AutoSummarySettings } const DEFAULT_STATE: PersistedState = { @@ -38,20 +43,36 @@ const DEFAULT_STATE: PersistedState = { nextProjectId: 1, sessions: [], activeSessionIndex: null, - gridCols: 2 + gridCols: 2, + autoSummary: DEFAULT_AUTO_SUMMARY } export async function loadState(): Promise { try { const raw = await readFile(STATE_FILE, 'utf-8') const parsed = JSON.parse(raw) as Partial + const rawAuto = parsed.autoSummary + const autoSummary: AutoSummarySettings = { + enabled: + typeof rawAuto?.enabled === 'boolean' ? rawAuto.enabled : DEFAULT_AUTO_SUMMARY.enabled, + debounceSeconds: + typeof rawAuto?.debounceSeconds === 'number' && rawAuto.debounceSeconds >= 0 + ? rawAuto.debounceSeconds + : DEFAULT_AUTO_SUMMARY.debounceSeconds, + minTurns: + typeof rawAuto?.minTurns === 'number' && rawAuto.minTurns >= 1 + ? rawAuto.minTurns + : DEFAULT_AUTO_SUMMARY.minTurns + } + return { projects: Array.isArray(parsed.projects) ? parsed.projects : [], activeProjectId: parsed.activeProjectId ?? null, nextProjectId: typeof parsed.nextProjectId === 'number' ? parsed.nextProjectId : 1, sessions: Array.isArray(parsed.sessions) ? parsed.sessions : [], activeSessionIndex: parsed.activeSessionIndex ?? null, - gridCols: parsed.gridCols === 1 ? 1 : 2 + gridCols: parsed.gridCols === 1 ? 1 : 2, + autoSummary } } catch { return { ...DEFAULT_STATE } diff --git a/src/renderer/src/App.tsx b/src/renderer/src/App.tsx index 29af8a7..15bd36f 100644 --- a/src/renderer/src/App.tsx +++ b/src/renderer/src/App.tsx @@ -42,7 +42,9 @@ function App(): React.JSX.Element { resizeSession, updateSessionSummary, gridCols, - setGridCols + setGridCols, + autoSummary, + setAutoSummary } = useSessions() const { @@ -376,7 +378,13 @@ function App(): React.JSX.Element { /> )} - {effectiveViewMode === 'settings' && setViewMode('grid')} />} + {effectiveViewMode === 'settings' && ( + setViewMode('grid')} + autoSummary={autoSummary} + onAutoSummaryChange={setAutoSummary} + /> + )} {worktreeProject && ( diff --git a/src/renderer/src/components/SettingsView.tsx b/src/renderer/src/components/SettingsView.tsx index 4978eec..12df4e4 100644 --- a/src/renderer/src/components/SettingsView.tsx +++ b/src/renderer/src/components/SettingsView.tsx @@ -1,11 +1,14 @@ import { useState, useEffect, useRef, useCallback } from 'react' import { ChevronLeftIcon, RefreshIcon } from './Icons' import type { UpdateStatus, LogEntry } from '../../../preload/index' +import type { AutoSummarySettings } from '../../../shared/types' const api = window.konductorAPI interface SettingsViewProps { onBack: () => void + autoSummary: AutoSummarySettings + onAutoSummaryChange: (settings: AutoSummarySettings) => void } const levelColors: Record = { @@ -30,7 +33,11 @@ function formatTime(ts: number): string { }) } -export default function SettingsView({ onBack }: SettingsViewProps): React.JSX.Element { +export default function SettingsView({ + onBack, + autoSummary, + onAutoSummaryChange +}: SettingsViewProps): React.JSX.Element { const [updateStatus, setUpdateStatus] = useState(null) const [checking, setChecking] = useState(false) const [logs, setLogs] = useState([]) @@ -152,6 +159,79 @@ export default function SettingsView({ onBack }: SettingsViewProps): React.JSX.E + {/* Auto Summary Section */} +
+

+ Auto Summary +

+
+
+
+
Enable auto-summary
+
+ Automatically regenerate session summaries using AI as the conversation develops +
+
+ +
+ + {autoSummary.enabled && ( + <> +
+
+
Debounce interval
+
+ Minimum seconds between summary regenerations per session +
+
+ { + const v = parseInt(e.target.value, 10) + if (!isNaN(v) && v >= 0) + onAutoSummaryChange({ ...autoSummary, debounceSeconds: v }) + }} + className="w-20 bg-surface border border-surface-border rounded px-2 py-1 text-sm text-gray-200 text-right focus:outline-none focus:border-gray-500 [appearance:textfield] [&::-webkit-outer-spin-button]:appearance-none [&::-webkit-inner-spin-button]:appearance-none" + /> +
+ +
+
+
Minimum turns
+
+ Claude turns required before generating a summary (resets after each + generation) +
+
+ { + const v = parseInt(e.target.value, 10) + if (!isNaN(v) && v >= 1) onAutoSummaryChange({ ...autoSummary, minTurns: v }) + }} + className="w-20 bg-surface border border-surface-border rounded px-2 py-1 text-sm text-gray-200 text-right focus:outline-none focus:border-gray-500 [appearance:textfield] [&::-webkit-outer-spin-button]:appearance-none [&::-webkit-inner-spin-button]:appearance-none" + /> +
+ + )} +
+
+ {/* App Logs Section */}
diff --git a/src/renderer/src/hooks/useSessions.ts b/src/renderer/src/hooks/useSessions.ts index 7fcdc61..450d463 100644 --- a/src/renderer/src/hooks/useSessions.ts +++ b/src/renderer/src/hooks/useSessions.ts @@ -3,6 +3,8 @@ import { Terminal } from '@xterm/xterm' import type { PrInfo, IssueInfo } from '../../../shared/types' import type { Project, Session, ActivityState } from '../types' import type { GridCols } from '../components/GridView' +import type { AutoSummarySettings } from '../../../shared/types' +import { DEFAULT_AUTO_SUMMARY } from '../../../shared/types' import { TERM_THEME } from '../termTheme' import '@xterm/xterm/css/xterm.css' @@ -39,6 +41,7 @@ interface HmrState { sessionMeta: SessionMeta[] activeSessionId: string | null gridCols?: 1 | 2 + autoSummary?: AutoSummarySettings } // Captured once at module load time. Consumed by the first mount of useSessions(). @@ -87,11 +90,20 @@ export function useSessions() { initialHmrState?.activeSessionId ?? null ) const [gridCols, setGridCols] = useState(initialHmrState?.gridCols ?? 2) + const [autoSummary, setAutoSummary] = useState( + initialHmrState?.autoSummary ?? DEFAULT_AUTO_SUMMARY + ) // `ready` gates BOTH the save-to-disk effect and the PTY listener subscription. // It becomes true only after all async initialization (disk load OR HMR restore) completes. const [ready, setReady] = useState(false) const sessionsRef = useRef([]) const projectsRef = useRef([]) + const autoSummaryRef = useRef(autoSummary) + + // Per-session tracking for debounced auto-summary + const turnCountsRef = useRef>(new Map()) + const lastSummaryTimeRef = useRef>(new Map()) + const summaryTimersRef = useRef>>(new Map()) // Keep refs in sync useEffect(() => { @@ -100,6 +112,9 @@ export function useSessions() { useEffect(() => { projectsRef.current = projects }, [projects]) + useEffect(() => { + autoSummaryRef.current = autoSummary + }, [autoSummary]) // ─── Cold start: load from disk ───────────────────────────────────── useEffect(() => { @@ -118,6 +133,9 @@ export function useSessions() { if (state.gridCols) { setGridCols(state.gridCols) } + if (state.autoSummary) { + setAutoSummary(state.autoSummary) + } // Create dormant session placeholders (lazy — no PTY spawned yet) if (state.sessions.length > 0) { @@ -239,9 +257,10 @@ export function useSessions() { issue: s.issue })), activeSessionIndex: activeIdx >= 0 ? activeIdx : null, - gridCols + gridCols, + autoSummary }) - }, [projects, activeProjectId, sessions, activeSessionId, gridCols, ready]) + }, [projects, activeProjectId, sessions, activeSessionId, gridCols, autoSummary, ready]) // ─── HMR: save state before module disposal (register once) ──────── useEffect(() => { @@ -254,7 +273,8 @@ export function useSessions() { activeProjectId: () => activeProjectId, activeSessionId: () => activeSessionId, sessions: () => sessionsRef.current, - gridCols: () => gridCols + gridCols: () => gridCols, + autoSummary: () => autoSummary } // Update the closures each render @@ -279,7 +299,8 @@ export function useSessions() { issue: s.issue })), activeSessionId: r.activeSessionId(), - gridCols: r.gridCols() + gridCols: r.gridCols(), + autoSummary: r.autoSummary() } satisfies HmrState for (const s of r.sessions()) { @@ -321,13 +342,58 @@ export function useSessions() { prev.map((s) => { if (s.claudeSessionId !== claudeSessionId) return s const updates: Partial = { activity: state } - // Only auto-set summary if the session doesn't already have one - // (preserves manual edits and avoids overwriting with later responses) + // Only auto-set summary from the hook's quick extract if the session + // doesn't already have one (preserves manual edits) if (summary && !s.summary) updates.summary = summary return { ...s, ...updates } }) ) + // ── Debounced AI auto-summary ────────────────────────────────── + // On Stop/Notification (state=waiting), schedule AI summary regeneration + // respecting both the turn count and time-based debounce thresholds. + if (state === 'waiting') { + const settings = autoSummaryRef.current + if (settings.enabled) { + const session = sessionsRef.current.find((s) => s.claudeSessionId === claudeSessionId) + if (session) { + const turns = (turnCountsRef.current.get(claudeSessionId) ?? 0) + 1 + turnCountsRef.current.set(claudeSessionId, turns) + + if (turns >= settings.minTurns) { + // Clear any pending timer for this session + const existing = summaryTimersRef.current.get(claudeSessionId) + if (existing) clearTimeout(existing) + + const lastTime = lastSummaryTimeRef.current.get(claudeSessionId) ?? 0 + const elapsed = (Date.now() - lastTime) / 1000 + const delay = Math.max(0, settings.debounceSeconds - elapsed) * 1000 + + const timer = setTimeout(() => { + summaryTimersRef.current.delete(claudeSessionId) + const current = sessionsRef.current.find( + (s) => s.claudeSessionId === claudeSessionId + ) + if (!current) return + + api.generateSummary(current.cwd, claudeSessionId).then((aiSummary) => { + if (!aiSummary) return + lastSummaryTimeRef.current.set(claudeSessionId, Date.now()) + turnCountsRef.current.set(claudeSessionId, 0) + setSessions((prev) => + prev.map((s) => + s.claudeSessionId === claudeSessionId ? { ...s, summary: aiSummary } : s + ) + ) + }) + }, delay) + + summaryTimersRef.current.set(claudeSessionId, timer) + } + } + } + } + // Check for new PR when Claude finishes a turn (non-blocking) // Re-fetch if no PR or if the known PR is merged (branch may have a new PR) if (state === 'ready') { @@ -406,6 +472,9 @@ export function useSessions() { unsubExit() unsubActivity() unsubRequest() + // Clean up any pending summary timers + for (const timer of summaryTimersRef.current.values()) clearTimeout(timer) + summaryTimersRef.current.clear() } }, [ready]) @@ -599,7 +668,9 @@ export function useSessions() { resizeSession, updateSessionSummary, gridCols, - setGridCols + setGridCols, + autoSummary, + setAutoSummary } } @@ -612,6 +683,7 @@ interface HmrRefs { activeSessionId: () => string | null sessions: () => Session[] gridCols: () => 1 | 2 + autoSummary: () => AutoSummarySettings } const refs = { current: null as null | HmrRefs } const disposeRegistered = { current: false } diff --git a/src/shared/types.ts b/src/shared/types.ts index 2144425..9e4744e 100644 --- a/src/shared/types.ts +++ b/src/shared/types.ts @@ -1,5 +1,21 @@ // Shared types used across main, preload, and renderer processes. +// ─── Auto Summary ─────────────────────────────────────────────────── + +export interface AutoSummarySettings { + enabled: boolean + /** Minimum seconds between AI summary regenerations per session */ + debounceSeconds: number + /** Minimum Claude turns before first auto-summary (and between re-generations) */ + minTurns: number +} + +export const DEFAULT_AUTO_SUMMARY: AutoSummarySettings = { + enabled: true, + debounceSeconds: 30, + minTurns: 3 +} + // ─── GitHub ────────────────────────────────────────────────────────── export interface GitHubRepo {