From cd4e0f64c88a2b88477f8f414d0ba850cb2bbc50 Mon Sep 17 00:00:00 2001 From: Amrit Subramanian Date: Sat, 4 Oct 2025 22:49:33 -0400 Subject: [PATCH] Add login shell support, session creation loading state, and refactoring - Spawn PTY as login shell (-l flag) to properly source shell configs (fixes pyenv issues) - Add animated loading spinner to session creation button - Refactor duplicate PTY spawning logic into shared spawnSessionPty() function - Fix unread indicator bugs (clear timers on session switch/close/delete) - Configure electron-builder to publish releases instead of drafts - Remove Windows builds (node-pty compilation issues) --- main.ts | 203 +++++++++++++++++++-------------------------------- package.json | 6 ++ renderer.ts | 42 ++++++++--- styles.css | 8 ++ 4 files changed, 119 insertions(+), 140 deletions(-) diff --git a/main.ts b/main.ts index 8bdd161..bc778dc 100644 --- a/main.ts +++ b/main.ts @@ -48,6 +48,78 @@ function getNextSessionNumber(): number { return Math.max(...sessions.map(s => s.number)) + 1; } +// Helper function to spawn PTY and setup coding agent +function spawnSessionPty( + sessionId: string, + worktreePath: string, + config: SessionConfig, + sessionUuid: string, + isNewSession: boolean +) { + const shell = os.platform() === "darwin" ? "zsh" : "bash"; + const ptyProcess = pty.spawn(shell, ["-l"], { + name: "xterm-color", + cols: 80, + rows: 30, + cwd: worktreePath, + env: process.env, + }); + + activePtyProcesses.set(sessionId, ptyProcess); + + let terminalReady = false; + let dataBuffer = ""; + + ptyProcess.onData((data) => { + // Only send data if window still exists and is not destroyed + if (mainWindow && !mainWindow.isDestroyed()) { + mainWindow.webContents.send("session-output", sessionId, data); + } + + // Detect when terminal is ready + if (!terminalReady) { + dataBuffer += data; + + // Method 1: Look for bracketed paste mode enable sequence + // Method 2: Fallback - look for common prompt indicators + const isReady = dataBuffer.includes("\x1b[?2004h") || + dataBuffer.includes("$ ") || dataBuffer.includes("% ") || + dataBuffer.includes("> ") || dataBuffer.includes("➜ ") || + dataBuffer.includes("➜ ") || dataBuffer.includes("✗ ") || + dataBuffer.includes("✓ ") || dataBuffer.endsWith("$") || + dataBuffer.endsWith("%") || dataBuffer.endsWith(">") || + dataBuffer.endsWith("➜") || dataBuffer.endsWith("✗") || + dataBuffer.endsWith("✓"); + + if (isReady) { + terminalReady = true; + + // Run setup commands if provided + if (config.setupCommands && config.setupCommands.length > 0) { + config.setupCommands.forEach(cmd => { + ptyProcess.write(cmd + "\r"); + }); + } + + // Auto-run the selected coding agent + if (config.codingAgent === "claude") { + const sessionFlag = isNewSession + ? `--session-id ${sessionUuid}` + : `--resume ${sessionUuid}`; + const skipPermissionsFlag = config.skipPermissions ? "--dangerously-skip-permissions" : ""; + const flags = [sessionFlag, skipPermissionsFlag].filter(f => f).join(" "); + const claudeCmd = `claude ${flags}\r`; + ptyProcess.write(claudeCmd); + } else if (config.codingAgent === "codex") { + ptyProcess.write("codex\r"); + } + } + } + }); + + return ptyProcess; +} + // Git worktree helper functions async function ensureFleetcodeExcluded(projectDir: string) { // Check if we've already initialized this project (persisted across app restarts) @@ -204,85 +276,7 @@ ipcMain.on("create-session", async (event, config: SessionConfig) => { savePersistedSessions(sessions); // Spawn PTY in worktree directory - const shell = os.platform() === "darwin" ? "zsh" : "bash"; - const ptyProcess = pty.spawn(shell, [], { - name: "xterm-color", - cols: 80, - rows: 30, - cwd: worktreePath, - env: process.env, - }); - - activePtyProcesses.set(sessionId, ptyProcess); - - let terminalReady = false; - let dataBuffer = ""; - - ptyProcess.onData((data) => { - // Only send data if window still exists and is not destroyed - if (mainWindow && !mainWindow.isDestroyed()) { - mainWindow.webContents.send("session-output", sessionId, data); - } - - // Detect when terminal is ready - if (!terminalReady) { - dataBuffer += data; - - // Method 1: Look for bracketed paste mode enable sequence - if (dataBuffer.includes("\x1b[?2004h")) { - terminalReady = true; - - // Run setup commands if provided - if (config.setupCommands && config.setupCommands.length > 0) { - config.setupCommands.forEach(cmd => { - ptyProcess.write(cmd + "\r"); - }); - } - - // Auto-run the selected coding agent - if (config.codingAgent === "claude") { - // New session always uses --session-id - const sessionFlag = `--session-id ${sessionUuid}`; - const skipPermissionsFlag = config.skipPermissions ? "--dangerously-skip-permissions" : ""; - const flags = [sessionFlag, skipPermissionsFlag].filter(f => f).join(" "); - const claudeCmd = `claude ${flags}\r`; - ptyProcess.write(claudeCmd); - } else if (config.codingAgent === "codex") { - ptyProcess.write("codex\r"); - } - } - - // Method 2: Fallback - look for common prompt indicators - else if (dataBuffer.includes("$ ") || dataBuffer.includes("% ") || - dataBuffer.includes("> ") || dataBuffer.includes("➜ ") || - dataBuffer.includes("➜ ") || - dataBuffer.includes("✗ ") || dataBuffer.includes("✓ ") || - dataBuffer.endsWith("$") || dataBuffer.endsWith("%") || - dataBuffer.endsWith(">") || dataBuffer.endsWith("➜") || - dataBuffer.endsWith("✗") || dataBuffer.endsWith("✓")) { - terminalReady = true; - - // Run setup commands if provided - if (config.setupCommands && config.setupCommands.length > 0) { - config.setupCommands.forEach(cmd => { - ptyProcess.write(cmd + "\r"); - }); - } - - // Auto-run the selected coding agent - if (config.codingAgent === "claude") { - // New session always uses --session-id - const sessionFlag = `--session-id ${sessionUuid}`; - const skipPermissionsFlag = config.skipPermissions ? "--dangerously-skip-permissions" : ""; - const flags = [sessionFlag, skipPermissionsFlag].filter(f => f).join(" "); - const claudeCmd = `claude ${flags}\r`; - ptyProcess.write(claudeCmd); - } else if (config.codingAgent === "codex") { - ptyProcess.write("codex\r"); - } - } - } - }); + spawnSessionPty(sessionId, worktreePath, config, sessionUuid, true); event.reply("session-created", sessionId, persistedSession); } catch (error) { @@ -326,56 +320,7 @@ ipcMain.on("reopen-session", (event, sessionId: string) => { } // Spawn new PTY in worktree directory - const shell = os.platform() === "darwin" ? "zsh" : "bash"; - const ptyProcess = pty.spawn(shell, [], { - name: "xterm-color", - cols: 80, - rows: 30, - cwd: session.worktreePath, - env: process.env, - }); - - activePtyProcesses.set(sessionId, ptyProcess); - - let terminalReady = false; - let dataBuffer = ""; - - ptyProcess.onData((data) => { - // Only send data if window still exists and is not destroyed - if (mainWindow && !mainWindow.isDestroyed()) { - mainWindow.webContents.send("session-output", sessionId, data); - } - - // Detect when terminal is ready and auto-run agent - if (!terminalReady) { - dataBuffer += data; - - if (dataBuffer.includes("\x1b[?2004h") || - dataBuffer.includes("$ ") || dataBuffer.includes("% ") || - dataBuffer.includes("➜ ") || dataBuffer.includes("✗ ") || - dataBuffer.includes("✓ ")) { - terminalReady = true; - - // Run setup commands if provided - if (session.config.setupCommands && session.config.setupCommands.length > 0) { - session.config.setupCommands.forEach(cmd => { - ptyProcess.write(cmd + "\r"); - }); - } - - if (session.config.codingAgent === "claude") { - // Reopened session always uses --resume - const sessionFlag = `--resume ${session.sessionUuid}`; - const skipPermissionsFlag = session.config.skipPermissions ? "--dangerously-skip-permissions" : ""; - const flags = [sessionFlag, skipPermissionsFlag].filter(f => f).join(" "); - const claudeCmd = `claude ${flags}\r`; - ptyProcess.write(claudeCmd); - } else if (session.config.codingAgent === "codex") { - ptyProcess.write("codex\r"); - } - } - } - }); + spawnSessionPty(sessionId, session.worktreePath, session.config, session.sessionUuid, false); event.reply("session-reopened", sessionId); }); diff --git a/package.json b/package.json index 9696358..3d1bcac 100644 --- a/package.json +++ b/package.json @@ -47,6 +47,12 @@ "index.html", "node_modules/**/*" ], + "publish": [ + { + "provider": "github", + "releaseType": "release" + } + ], "mac": { "category": "public.app-category.developer-tools", "target": [ diff --git a/renderer.ts b/renderer.ts index f62efda..779a739 100644 --- a/renderer.ts +++ b/renderer.ts @@ -740,6 +740,29 @@ ipcRenderer.on("session-output", (_event, sessionId: string, data: string) => { ipcRenderer.on("session-created", (_event, sessionId: string, persistedSession: any) => { const session = addSession(persistedSession, true); activateSession(sessionId); + + // Reset button state and close modal + const createBtn = document.getElementById("create-session") as HTMLButtonElement; + const modal = document.getElementById("config-modal"); + const projectDirInput = document.getElementById("project-dir") as HTMLInputElement; + const parentBranchSelect = document.getElementById("parent-branch") as HTMLSelectElement; + const setupCommandsTextarea = document.getElementById("setup-commands") as HTMLTextAreaElement; + + if (createBtn) { + createBtn.disabled = false; + createBtn.textContent = "Create Session"; + createBtn.classList.remove("loading"); + } + + modal?.classList.add("hidden"); + + // Reset form + projectDirInput.value = ""; + selectedDirectory = ""; + parentBranchSelect.innerHTML = ''; + if (setupCommandsTextarea) { + setupCommandsTextarea.value = ""; + } }); // Handle session reopened @@ -784,7 +807,7 @@ const skipPermissionsCheckbox = document.getElementById("skip-permissions") as H const skipPermissionsGroup = skipPermissionsCheckbox?.parentElement?.parentElement; const browseDirBtn = document.getElementById("browse-dir"); const cancelBtn = document.getElementById("cancel-session"); -const createBtn = document.getElementById("create-session"); +const createBtn = document.getElementById("create-session") as HTMLButtonElement; let selectedDirectory = ""; @@ -904,21 +927,18 @@ createBtn?.addEventListener("click", () => { setupCommands, }; + // Show loading state + if (createBtn) { + createBtn.disabled = true; + createBtn.innerHTML = ' Creating...'; + createBtn.classList.add("loading"); + } + // Save settings for next time ipcRenderer.send("save-settings", config); // Create the session ipcRenderer.send("create-session", config); - modal?.classList.add("hidden"); - - // Reset form - projectDirInput.value = ""; - selectedDirectory = ""; - parentBranchSelect.innerHTML = ''; - codingAgentSelect.value = "claude"; - if (setupCommandsTextarea) { - setupCommandsTextarea.value = ""; - } }); // MCP Server management functions diff --git a/styles.css b/styles.css index 55ec8ae..f2650ee 100644 --- a/styles.css +++ b/styles.css @@ -208,4 +208,12 @@ .btn-icon { @apply bg-gray-600 hover:bg-gray-700 text-white px-3 py-2 rounded text-sm font-medium transition; } + + .btn-primary.loading { + @apply opacity-75 cursor-not-allowed; + } + + .btn-primary.loading .loading-spinner { + @apply border-white border-t-transparent; + } }