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)
This commit is contained in:
Amrit Subramanian 2025-10-04 22:49:33 -04:00
parent 0a1c6fd7b4
commit cd4e0f64c8
4 changed files with 119 additions and 140 deletions

203
main.ts
View file

@ -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);
});

View file

@ -47,6 +47,12 @@
"index.html",
"node_modules/**/*"
],
"publish": [
{
"provider": "github",
"releaseType": "release"
}
],
"mac": {
"category": "public.app-category.developer-tools",
"target": [

View file

@ -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 = '<option value="">Loading branches...</option>';
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 = '<span class="loading-spinner"></span> 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 = '<option value="">Loading branches...</option>';
codingAgentSelect.value = "claude";
if (setupCommandsTextarea) {
setupCommandsTextarea.value = "";
}
});
// MCP Server management functions

View file

@ -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;
}
}