vibetunnel/mac/VibeTunnel/Presentation/Components/NewSessionForm.swift
Peter Steinberger 42021bb514 Fix inconsistent button state management
- Remove all uses of deprecated highlight() method in CustomMenuWindow
- Consistently use state property for NSStatusBarButton management
- Update StatusBarMenuManager to reset button state when menu state is .none
- Fix concurrency issues in CustomMenuWindow frame observer
- Ensure button state is properly managed throughout menu lifecycle

This fixes the issue where the button could display inconsistent visual states
or get stuck due to conflicting approaches between highlight() and state.
2025-07-02 00:00:53 +01:00

445 lines
17 KiB
Swift

import SwiftUI
/// Compact new session form designed for the popover
struct NewSessionForm: View {
@Binding var isPresented: Bool
@Environment(ServerManager.self) private var serverManager
@Environment(SessionMonitor.self) private var sessionMonitor
@Environment(SessionService.self) private var sessionService
// Form fields
@State private var command = "zsh"
@State private var sessionName = ""
@State private var workingDirectory = "~/"
@State private var spawnWindow = true
@State private var titleMode: TitleMode = .dynamic
// UI state
@State private var isCreating = false
@State private var showError = false
@State private var errorMessage = ""
@FocusState private var focusedField: Field?
enum Field: Hashable {
case command
case name
case directory
}
enum TitleMode: String, CaseIterable {
case none = "none"
case filter = "filter"
case `static` = "static"
case dynamic = "dynamic"
var displayName: String {
switch self {
case .none: "None"
case .filter: "Filter"
case .static: "Static"
case .dynamic: "Dynamic"
}
}
}
/// Quick commands synced with frontend
private let quickCommands = [
("claude", ""),
("gemini", ""),
("zsh", nil),
("python3", nil),
("node", nil),
("pnpm run dev", nil)
]
var body: some View {
VStack(spacing: 0) {
// Header with back button
HStack {
Button(action: {
isPresented = false
}) {
HStack(spacing: 4) {
Image(systemName: "chevron.left")
.font(.system(size: 11, weight: .medium))
Text("Sessions")
.font(.system(size: 12, weight: .medium))
}
}
.buttonStyle(.plain)
.foregroundColor(.primary.opacity(0.8))
Spacer()
Text("New Session")
.font(.system(size: 13, weight: .semibold))
Spacer()
// Balance the back button
Color.clear
.frame(width: 60)
}
.padding(.horizontal, 16)
.padding(.vertical, 10)
.background(
LinearGradient(
colors: [
Color(NSColor.controlBackgroundColor).opacity(0.6),
Color(NSColor.controlBackgroundColor).opacity(0.3)
],
startPoint: .top,
endPoint: .bottom
)
)
Divider()
// Form content
ScrollView {
VStack(alignment: .leading, spacing: 18) {
// Name field (first)
VStack(alignment: .leading, spacing: 6) {
Text("Name")
.font(.system(size: 11, weight: .medium))
.foregroundColor(.secondary)
TextField("(optional)", text: $sessionName)
.textFieldStyle(.roundedBorder)
.focused($focusedField, equals: .name)
}
// Command field (second)
VStack(alignment: .leading, spacing: 6) {
Text("Command")
.font(.system(size: 11, weight: .medium))
.foregroundColor(.secondary)
TextField("claude", text: $command)
.textFieldStyle(.roundedBorder)
.focused($focusedField, equals: .command)
.onChange(of: command) { _, newValue in
// Auto-select dynamic title mode for AI tools
if newValue.lowercased().contains("claude") ||
newValue.lowercased().contains("gemini")
{
titleMode = .dynamic
}
}
}
// Working Directory (third)
VStack(alignment: .leading, spacing: 6) {
Text("Working Directory")
.font(.system(size: 11, weight: .medium))
.foregroundColor(.secondary)
HStack(spacing: 8) {
TextField("~/", text: $workingDirectory)
.textFieldStyle(.roundedBorder)
.focused($focusedField, equals: .directory)
Button(action: selectDirectory) {
Image(systemName: "folder")
.font(.system(size: 12))
.foregroundColor(.secondary)
}
.buttonStyle(.borderless)
.help("Choose directory")
}
}
// Quick Start
VStack(alignment: .leading, spacing: 10) {
Text("Quick Start")
.font(.system(size: 11, weight: .medium))
.foregroundColor(.secondary)
LazyVGrid(columns: [
GridItem(.flexible()),
GridItem(.flexible()),
GridItem(.flexible())
], spacing: 8) {
ForEach(quickCommands, id: \.0) { cmd in
Button(action: {
command = cmd.0
sessionName = ""
}) {
HStack(spacing: 4) {
if let emoji = cmd.1 {
Text(emoji)
.font(.system(size: 12))
}
Text(cmd.0)
.font(.system(size: 11))
Spacer()
}
.padding(.horizontal, 12)
.padding(.vertical, 6)
.background(
RoundedRectangle(cornerRadius: 6)
.fill(Color.primary.opacity(0.05))
)
.overlay(
RoundedRectangle(cornerRadius: 6)
.stroke(Color.primary.opacity(0.1), lineWidth: 1)
)
}
.buttonStyle(.plain)
}
}
}
Divider()
.padding(.vertical, 4)
// Options
VStack(spacing: 16) {
// Title Mode with combo box - right aligned
HStack {
Text("Title Mode")
.font(.system(size: 11, weight: .medium))
.foregroundColor(.secondary)
Spacer()
Menu {
ForEach(TitleMode.allCases, id: \.self) { mode in
Button(action: { titleMode = mode }) {
HStack {
Text(mode.displayName)
if mode == titleMode {
Image(systemName: "checkmark")
}
}
}
}
} label: {
HStack(spacing: 4) {
Text(titleMode.displayName)
.font(.system(size: 11))
.foregroundColor(.primary)
Image(systemName: "chevron.up.chevron.down")
.font(.system(size: 8, weight: .medium))
.foregroundColor(.secondary)
}
.padding(.horizontal, 10)
.padding(.vertical, 4)
.background(
RoundedRectangle(cornerRadius: 6)
.fill(Color.primary.opacity(0.05))
)
.overlay(
RoundedRectangle(cornerRadius: 6)
.stroke(Color.primary.opacity(0.1), lineWidth: 1)
)
}
.menuStyle(.borderlessButton)
.menuIndicator(.hidden)
.fixedSize()
}
// Open in Terminal
HStack {
Text("Terminal")
.font(.system(size: 11, weight: .medium))
.foregroundColor(.secondary)
Text("Open in native terminal window")
.font(.system(size: 11))
.foregroundColor(.secondary.opacity(0.8))
Spacer()
Toggle("", isOn: $spawnWindow)
.toggleStyle(.switch)
.scaleEffect(0.8)
.labelsHidden()
}
}
}
.padding(.horizontal, 16)
.padding(.vertical, 16)
}
.frame(maxHeight: 400)
Divider()
// Create button with improved styling
HStack {
Spacer()
Button(action: createSession) {
if isCreating {
HStack(spacing: 4) {
ProgressView()
.scaleEffect(0.7)
.controlSize(.small)
Text("Creating...")
}
.frame(minWidth: 80)
.padding(.horizontal, 16)
.padding(.vertical, 6)
} else {
Text("Create")
.font(.system(size: 13, weight: .medium))
.frame(minWidth: 80)
.padding(.horizontal, 16)
.padding(.vertical, 6)
}
}
.buttonStyle(.plain)
.foregroundColor(.white)
.background(
RoundedRectangle(cornerRadius: 6)
.fill(command.isEmpty || workingDirectory.isEmpty ? Color.gray.opacity(0.4) : Color(
red: 0.2,
green: 0.6,
blue: 0.3
))
)
.disabled(isCreating || command.isEmpty || workingDirectory.isEmpty)
}
.padding(.horizontal, 16)
.padding(.vertical, 12)
}
.frame(width: 384)
.background(.ultraThinMaterial, in: RoundedRectangle(cornerRadius: 12))
.clipShape(RoundedRectangle(cornerRadius: 12))
.fixedSize(horizontal: true, vertical: false)
.onAppear {
loadPreferences()
focusedField = .name
}
.alert("Error", isPresented: $showError) {
Button("OK") {}
} message: {
Text(errorMessage)
}
.compositingGroup() // Render the entire form as a single composited layer
}
private func selectDirectory() {
let panel = NSOpenPanel()
panel.canChooseFiles = false
panel.canChooseDirectories = true
panel.allowsMultipleSelection = false
panel.directoryURL = URL(fileURLWithPath: NSString(string: workingDirectory).expandingTildeInPath)
if panel.runModal() == .OK, let url = panel.url {
let path = url.path
let homeDir = NSHomeDirectory()
if path.hasPrefix(homeDir) {
workingDirectory = "~" + path.dropFirst(homeDir.count)
} else {
workingDirectory = path
}
}
}
private func createSession() {
guard !command.isEmpty && !workingDirectory.isEmpty else { return }
isCreating = true
savePreferences()
Task {
do {
// Parse command into array
let commandArray = parseCommand(command.trimmingCharacters(in: .whitespacesAndNewlines))
// Expand tilde in working directory
let expandedWorkingDir = NSString(string: workingDirectory).expandingTildeInPath
// Create session using SessionService
let sessionId = try await sessionService.createSession(
command: commandArray,
workingDir: expandedWorkingDir,
name: sessionName.isEmpty ? nil : sessionName.trimmingCharacters(in: .whitespacesAndNewlines),
titleMode: titleMode.rawValue,
spawnTerminal: spawnWindow
)
// If not spawning window, open in browser
if !spawnWindow {
if let webURL = URL(string: "http://127.0.0.1:\(serverManager.port)/?sessionId=\(sessionId)") {
NSWorkspace.shared.open(webURL)
}
}
await MainActor.run {
isPresented = false
}
} catch {
await MainActor.run {
isCreating = false
errorMessage = error.localizedDescription
showError = true
}
}
}
}
private func parseCommand(_ cmd: String) -> [String] {
// Simple command parsing that respects quotes
var result: [String] = []
var current = ""
var inQuotes = false
var quoteChar: Character?
for char in cmd {
if !inQuotes && (char == "\"" || char == "'") {
inQuotes = true
quoteChar = char
} else if inQuotes && char == quoteChar {
inQuotes = false
quoteChar = nil
} else if !inQuotes && char == " " {
if !current.isEmpty {
result.append(current)
current = ""
}
} else {
current.append(char)
}
}
if !current.isEmpty {
result.append(current)
}
return result.isEmpty ? ["zsh"] : result
}
// MARK: - Preferences
private func loadPreferences() {
if let savedCommand = UserDefaults.standard.string(forKey: "NewSession.command") {
command = savedCommand
}
if let savedDir = UserDefaults.standard.string(forKey: "NewSession.workingDirectory") {
workingDirectory = savedDir
}
// Check if spawn window preference has been explicitly set
if UserDefaults.standard.object(forKey: "NewSession.spawnWindow") != nil {
spawnWindow = UserDefaults.standard.bool(forKey: "NewSession.spawnWindow")
} else {
// Default to true if never set
spawnWindow = true
}
if let savedMode = UserDefaults.standard.string(forKey: "NewSession.titleMode"),
let mode = TitleMode(rawValue: savedMode)
{
titleMode = mode
}
}
private func savePreferences() {
UserDefaults.standard.set(command, forKey: "NewSession.command")
UserDefaults.standard.set(workingDirectory, forKey: "NewSession.workingDirectory")
UserDefaults.standard.set(spawnWindow, forKey: "NewSession.spawnWindow")
UserDefaults.standard.set(titleMode.rawValue, forKey: "NewSession.titleMode")
}
}