vibetunnel/mac/VibeTunnel/Presentation/Components/NewSessionForm.swift
Peter Steinberger f60bd2d5a0 Implement side-rounded menu borders for macOS menu bar
- Create custom SideRoundedRectangle shape with flat top/bottom borders
- Apply custom shape to both SwiftUI background and NSWindow mask layer
- Update CustomMenuContainer to use the new shape for consistent styling
- Maintain rounded corners only on left and right sides as requested

This gives the menu bar dropdown a more integrated appearance with the
menu bar while keeping the modern rounded aesthetic on the sides.
2025-07-02 00:00:53 +01:00

441 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")
}
}