Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
34 commits
Select commit Hold shift + click to select a range
eb20fcf
feat: add preview sidebar with dev server support
nilskroe Jan 25, 2026
bdc7c90
feat: persist preview URL across worktree switches
nilskroe Jan 25, 2026
5c50ac9
fix: recreate webview when preview sidebar reopens
nilskroe Jan 25, 2026
de6d2f5
fix: skip about:blank in webview navigation handlers
nilskroe Jan 25, 2026
089a97f
feat: add dev accounts for preview login auto-fill
nilskroe Jan 25, 2026
eba7323
fix: change devCredentials.get to mutation for imperative calls
nilskroe Jan 25, 2026
73bf5d7
feat: add screenshot capture to preview sidebar
nilskroe Jan 25, 2026
b188290
chore: clean up debug logging and unused imports
nilskroe Jan 25, 2026
e4c1d8c
fix: only export attachment handlers from active subchat
nilskroe Jan 25, 2026
72816fa
chore: add debug logging for preview element selection
nilskroe Jan 25, 2026
57651e3
feat: cmd+R triggers start/stop when preview is open
nilskroe Jan 25, 2026
5b0bbf8
Merge remote-tracking branch 'origin/main' into feature/start-mac-app
nilskroe Jan 25, 2026
e43bd94
fix: wrap TooltipTrigger buttons in span to prevent React 19 infinite…
nilskroe Jan 25, 2026
b6709ca
feat: add beta flag for preview sidebar
nilskroe Jan 25, 2026
ea61963
feat: improve sidebar mutual exclusion and port scanning
nilskroe Jan 25, 2026
dd53e22
perf: optimize render performance across multiple components
nilskroe Jan 25, 2026
2aa20cc
fix: improve React Grab integration for element selector
nilskroe Jan 25, 2026
ebde458
refactor: move running servers menu to help popover
nilskroe Jan 25, 2026
4541312
fix: always use React Grab plugin API and hide button if unavailable
nilskroe Jan 25, 2026
50e7a54
refactor: simplify MCP servers indicator
nilskroe Jan 25, 2026
e1ee091
fix: preview sidebar now closes diff sidebar (side-peek mode)
nilskroe Jan 25, 2026
af649b9
feat: add cmd+R shortcut to toggle preview sidebar
nilskroe Jan 25, 2026
f722b61
fix: correct React Grab plugin API structure for element selection
nilskroe Jan 25, 2026
49b02c8
fix: add missing devCredentials router to app router
nilskroe Jan 25, 2026
ca92611
feat: add Changes View display mode setting to Appearance tab
nilskroe Jan 25, 2026
5ac4c21
Merge main branch into feature/start-mac-app
nilskroe Jan 28, 2026
9931e9b
Fix memory leaks and improve process cleanup
nilskroe Jan 28, 2026
888ff6f
Fix missing useSetAtom import in preview-sidebar
nilskroe Jan 28, 2026
76433fe
Merge main branch into feature/start-mac-app
nilskroe Jan 31, 2026
d2ac30b
Fix merge conflicts and add missing betaPreviewSidebarEnabledAtom
nilskroe Jan 31, 2026
ec4ca66
perf: Optimize message sync loop and add LRU cache eviction
nilskroe Jan 31, 2026
9cc01cc
Fix additional performance issues for long chat sessions
nilskroe Jan 31, 2026
0e65e4b
Abort active streams before cache eviction and chat deletion
nilskroe Jan 31, 2026
477f87e
Merge branch 'fix/chat-performance-memory-leaks' into feature/start-m…
nilskroe Jan 31, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions drizzle/0006_acoustic_luke_cage.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
CREATE TABLE `dev_credentials` (
`id` text PRIMARY KEY NOT NULL,
`label` text NOT NULL,
`email` text NOT NULL,
`encrypted_password` text NOT NULL,
`domain` text,
`created_at` integer,
`updated_at` integer
);
3 changes: 3 additions & 0 deletions electron.vite.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,9 @@ export default defineConfig({
: undefined,
}),
],
server: {
port: 5199, // Use non-default port to avoid conflict with worktree dev servers
},
resolve: {
alias: {
"@": resolve(__dirname, "src/renderer"),
Expand Down
5 changes: 5 additions & 0 deletions src/main/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ import {
} from "./lib/cli"
import { cleanupGitWatchers } from "./lib/git/watcher"
import { cancelAllPendingOAuth, handleMcpOAuthCallback } from "./lib/mcp-auth"
import { terminalManager, portManager } from "./lib/terminal"
import {
createMainWindow,
createWindow,
Expand Down Expand Up @@ -907,6 +908,10 @@ if (gotTheLock) {
app.on("before-quit", async () => {
console.log("[App] Shutting down...")
cancelAllPendingOAuth()
// Kill all terminal sessions (including child processes like dev servers)
// This prevents orphan processes from consuming memory after app quits
await terminalManager.cleanup()
portManager.stopAllScanning()
await cleanupGitWatchers()
await shutdownAnalytics()
await closeDatabase()
Expand Down
20 changes: 20 additions & 0 deletions src/main/lib/db/schema/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -103,6 +103,24 @@ export const claudeCodeCredentials = sqliteTable("claude_code_credentials", {
userId: text("user_id"), // Desktop auth user ID (for reference)
})

// ============ DEV CREDENTIALS ============
// Stores encrypted credentials for auto-filling login forms in preview
export const devCredentials = sqliteTable("dev_credentials", {
id: text("id")
.primaryKey()
.$defaultFn(() => createId()),
label: text("label").notNull(), // Friendly name like "Test User 1"
email: text("email").notNull(),
encryptedPassword: text("encrypted_password").notNull(), // Encrypted with safeStorage
domain: text("domain"), // Optional domain hint for filtering
createdAt: integer("created_at", { mode: "timestamp" }).$defaultFn(
() => new Date(),
),
updatedAt: integer("updated_at", { mode: "timestamp" }).$defaultFn(
() => new Date(),
),
})

// ============ ANTHROPIC ACCOUNTS (Multi-account support) ============
// Stores multiple Anthropic OAuth accounts for quick switching
export const anthropicAccounts = sqliteTable("anthropic_accounts", {
Expand Down Expand Up @@ -137,6 +155,8 @@ export type SubChat = typeof subChats.$inferSelect
export type NewSubChat = typeof subChats.$inferInsert
export type ClaudeCodeCredential = typeof claudeCodeCredentials.$inferSelect
export type NewClaudeCodeCredential = typeof claudeCodeCredentials.$inferInsert
export type DevCredential = typeof devCredentials.$inferSelect
export type NewDevCredential = typeof devCredentials.$inferInsert
export type AnthropicAccount = typeof anthropicAccounts.$inferSelect
export type NewAnthropicAccount = typeof anthropicAccounts.$inferInsert
export type AnthropicSettings = typeof anthropicSettings.$inferSelect
98 changes: 89 additions & 9 deletions src/main/lib/terminal/manager.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { EventEmitter } from "node:events"
import { FALLBACK_SHELL, SHELL_CRASH_THRESHOLD_MS } from "./env"
import { portManager } from "./port-manager"
import { getProcessTree } from "./port-scanner"
import { createSession, setupInitialCommands } from "./session"
import type {
CreateSessionParams,
Expand Down Expand Up @@ -65,6 +66,9 @@ export class TerminalManager extends EventEmitter {

portManager.registerSession(session, workspaceId || "")

// Emit started event so subscribers know a session was created
this.emit(`started:${paneId}`, session.cwd)

return {
isNew: true,
serializedState: "",
Expand Down Expand Up @@ -111,10 +115,10 @@ export class TerminalManager extends EventEmitter {

this.emit(`exit:${paneId}`, exitCode, signal)

// Clean up session after delay
// Clean up session after short delay (allows late exit event handlers to fire)
const timeout = setTimeout(() => {
this.sessions.delete(paneId)
}, 5000)
}, 500)
timeout.unref()
})
}
Expand Down Expand Up @@ -169,7 +173,27 @@ export class TerminalManager extends EventEmitter {
}
}

signal(params: { paneId: string; signal?: string }): void {
/**
* Kill all processes in a process tree (shell + all child processes like bun, node, webpack, etc.)
* This prevents orphaned child processes from consuming memory after the shell exits.
*/
private async killProcessTree(pid: number, signal: NodeJS.Signals = "SIGTERM"): Promise<void> {
try {
const pids = await getProcessTree(pid)
// Kill in reverse order (children first, then parent) for cleaner shutdown
for (const childPid of pids.reverse()) {
try {
process.kill(childPid, signal)
} catch {
// Process may have already exited
}
}
} catch {
// getProcessTree may fail if process already exited
}
}

async signal(params: { paneId: string; signal?: string }): Promise<void> {
const { paneId, signal = "SIGTERM" } = params
const session = this.sessions.get(paneId)

Expand All @@ -180,6 +204,11 @@ export class TerminalManager extends EventEmitter {
return
}

// Kill entire process tree, not just the shell
const ptyPid = session.pty.pid
if (ptyPid) {
await this.killProcessTree(ptyPid, signal as NodeJS.Signals)
}
session.pty.kill(signal)
session.lastActive = Date.now()
}
Expand All @@ -193,11 +222,50 @@ export class TerminalManager extends EventEmitter {
return
}

if (session.isAlive) {
session.pty.kill()
} else {
if (!session.isAlive) {
this.sessions.delete(paneId)
return
}

const ptyPid = session.pty.pid

// Send SIGTERM to entire process tree first
try {
if (ptyPid) {
await this.killProcessTree(ptyPid, "SIGTERM")
}
session.pty.kill("SIGTERM")
} catch (error) {
console.error(`[TerminalManager] Failed to send SIGTERM to ${paneId}:`, error)
}

// Wait for graceful shutdown, then escalate to SIGKILL
await new Promise<void>((resolve) => {
const checkInterval = setInterval(() => {
if (!session.isAlive) {
clearInterval(checkInterval)
clearTimeout(forceKillTimeout)
resolve()
}
}, 100)

const forceKillTimeout = setTimeout(async () => {
clearInterval(checkInterval)
if (session.isAlive) {
try {
// SIGKILL entire process tree
if (ptyPid) {
await this.killProcessTree(ptyPid, "SIGKILL")
}
session.pty.kill("SIGKILL")
} catch (error) {
console.error(`[TerminalManager] Failed to send SIGKILL to ${paneId}:`, error)
}
}
// Give SIGKILL a moment to take effect
setTimeout(resolve, 100)
}, 2000)
})
}

detach(params: { paneId: string; serializedState?: string }): void {
Expand Down Expand Up @@ -365,11 +433,13 @@ export class TerminalManager extends EventEmitter {
}

async cleanup(): Promise<void> {
console.log(`[TerminalManager] Cleanup: killing ${this.sessions.size} sessions`)
const exitPromises: Promise<void>[] = []

for (const [paneId, session] of this.sessions.entries()) {
if (session.isAlive) {
const exitPromise = new Promise<void>((resolve) => {
const ptyPid = session.pty.pid
const exitPromise = new Promise<void>(async (resolve) => {
let timeoutId: ReturnType<typeof setTimeout> | undefined
const exitHandler = () => {
this.off(`exit:${paneId}`, exitHandler)
Expand All @@ -380,21 +450,31 @@ export class TerminalManager extends EventEmitter {
}
this.once(`exit:${paneId}`, exitHandler)

timeoutId = setTimeout(() => {
// Kill entire process tree (not just shell) to prevent orphan processes
if (ptyPid) {
await this.killProcessTree(ptyPid, "SIGTERM")
}
session.pty.kill()

timeoutId = setTimeout(async () => {
this.off(`exit:${paneId}`, exitHandler)
// Force kill if still alive after timeout
if (session.isAlive && ptyPid) {
await this.killProcessTree(ptyPid, "SIGKILL")
}
resolve()
}, 2000)
timeoutId.unref()
})

exitPromises.push(exitPromise)
session.pty.kill()
}
}

await Promise.all(exitPromises)
this.sessions.clear()
this.removeAllListeners()
console.log("[TerminalManager] Cleanup complete")
}
}

Expand Down
Loading