vibetunnel/mac/VibeTunnel/Presentation/Components/NewSessionForm.swift
Lachlan Donald 745f5090bb
feat: add Tailscale Serve integration with automatic authentication (#472)
* feat: add secure Tailscale Serve integration support

- Add --enable-tailscale-serve flag to bind server to localhost
- Implement Tailscale identity header authentication
- Add security validations for localhost origin and proxy headers
- Create TailscaleServeService to manage tailscale serve process
- Fix dev script to properly pass arguments through pnpm
- Add comprehensive auth middleware tests for all auth methods
- Ensure secure integration with Tailscale's reverse proxy

* refactor: use isFromLocalhostAddress helper for Tailscale auth

- Extract localhost checking logic into dedicated helper function
- Makes the code clearer and addresses review feedback
- Maintains the same security checks for Tailscale authentication

* feat(web): Add Tailscale Serve integration support

- Add TailscaleServeService to manage background tailscale serve process
- Add --enable-tailscale-serve and --use-tailscale-serve flags
- Force localhost binding when Tailscale Serve is enabled
- Enhance auth middleware to support Tailscale identity headers
- Add isFromLocalhostAddress helper for secure localhost validation
- Fix dev script to properly pass CLI arguments through pnpm
- Add comprehensive auth middleware tests (17 tests)
- Use 'tailscale serve reset' for thorough cleanup

The server now automatically manages the Tailscale Serve proxy process,
providing secure HTTPS access through Tailscale networks without manual
configuration.

* feat(mac): Add Tailscale Serve toggle in Remote Access settings

- Add 'Enable Tailscale Serve Integration' toggle in RemoteAccessSettingsView
- Pass --use-tailscale-serve flag from both BunServer and DevServerManager
- Show HTTPS URL when Tailscale Serve is enabled, HTTP when disabled
- Fix URL copy bug in ServerInfoSection for Tailscale addresses
- Update authentication documentation with new integration mode
- Server automatically restarts when toggle is changed

The macOS app now provides a user-friendly toggle to enable secure
Tailscale Serve integration without manual configuration.

* fix(security): Remove dangerous --allow-tailscale-auth flag

- Remove --allow-tailscale-auth flag that allowed header spoofing
- Remove --use-tailscale-serve alias for consistency
- Keep only --enable-tailscale-serve which safely manages everything
- Update all references in server.ts to use enableTailscaleServe
- Update macOS app to use --enable-tailscale-serve flag
- Update documentation to remove manual setup mode

The --allow-tailscale-auth flag was dangerous because it allowed users to
enable Tailscale header authentication while binding to network interfaces,
which would allow anyone on the network to spoof the Tailscale headers.

Now there's only one safe way to use Tailscale integration: --enable-tailscale-serve,
which forces localhost binding and manages the proxy automatically.

* fix: address PR feedback from Peter and Cursor

- Fix Promise hang bug in TailscaleServeService when process exits with code 0
- Move tailscaleServeEnabled string to AppConstants.UserDefaultsKeys
- Create TailscaleURLHelper for URL construction logic
- Add Linux support to TailscaleServeService with common Tailscale paths
- Update all references to use centralized constants
- Fix code formatting issues

* feat: Add Tailscale Serve status monitoring and error visibility

* fix: Correct pass-through argument logic for boolean flags and duplicates

- Track processed argument indices instead of checking if arg already exists in serverArgs
- Add set of known boolean flags that don't take values
- Allow duplicate arguments to be passed through
- Only treat non-dash arguments as values for non-boolean flags

This fixes issues where:
1. Boolean flags like --verbose were incorrectly consuming the next argument
2. Duplicate flags couldn't be passed through to the server

* fix: Resolve promise hanging and orphaned processes in Tailscale serve

- Add settled flag to prevent multiple promise resolutions
- Handle exit code 0 as a failure case during startup
- Properly terminate child process in cleanup method
- Add timeout for graceful shutdown before force killing

This fixes:
1. Promise hanging when tailscale serve exits with code 0
2. Orphaned processes when startup fails or cleanup is called

---------

Co-authored-by: Peter Steinberger <steipete@gmail.com>
2025-07-30 02:30:10 +02:00

760 lines
32 KiB
Swift

import os.log
import SwiftUI
private let logger = Logger(subsystem: BundleIdentifiers.loggerSubsystem, category: "NewSessionForm")
/// Compact new session form designed for the popover.
///
/// Provides a streamlined interface for creating new terminal sessions with
/// options for command selection, naming, directory settings, and window spawning.
/// Integrates with the server to create sessions both in terminal windows and web browsers.
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
@Environment(RepositoryDiscoveryService.self)
private var repositoryDiscovery
@Environment(GitRepositoryMonitor.self)
private var gitMonitor
@Environment(ConfigManager.self)
private var configManager
// Form fields
@State private var command = "zsh"
@State private var sessionName = ""
@State private var workingDirectory = FilePathConstants.defaultRepositoryBasePath
@State private var spawnWindow = true
@State private var titleMode: TitleMode = .dynamic
// Git worktree state
@State private var isGitRepository = false
@State private var gitRepoPath: String?
@State private var selectedWorktreePath: String?
@State private var selectedWorktreeBranch: String?
@State private var checkingGitStatus = false
@State private var worktreeService: WorktreeService?
// Branch state (matching web version)
@State private var currentBranch = ""
@State private var selectedBaseBranch = ""
@State private var branchSwitchWarning: String?
// UI state
@State private var isCreating = false
@State private var showError = false
@State private var errorMessage = ""
@State private var isHoveringCreate = false
@FocusState private var focusedField: Field?
enum Field: Hashable {
case command
case name
case directory
}
var body: some View {
VStack(spacing: 0) {
// Header with back button
HStack {
Button(action: {
isPresented = false
}, label: {
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) {
// Branch Switch Warning
if let warning = branchSwitchWarning {
HStack(alignment: .top, spacing: 8) {
Image(systemName: "exclamationmark.triangle.fill")
.font(.system(size: 14))
.foregroundColor(.yellow)
Text(warning)
.font(.system(size: 11))
.foregroundColor(.primary)
.fixedSize(horizontal: false, vertical: true)
Spacer(minLength: 0)
}
.padding(10)
.background(
RoundedRectangle(cornerRadius: 6)
.fill(Color.yellow.opacity(0.1))
)
.overlay(
RoundedRectangle(cornerRadius: 6)
.stroke(Color.yellow.opacity(0.3), lineWidth: 1)
)
}
// 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) {
AutocompleteTextField(text: $workingDirectory, placeholder: "~/")
.focused($focusedField, equals: .directory)
.onChange(of: workingDirectory) { _, newValue in
checkForGitRepository(at: newValue)
}
.zIndex(1) // Ensure autocomplete appears above other elements
Button(action: selectDirectory) {
Image(systemName: "folder")
.font(.system(size: 12))
.foregroundColor(.secondary)
.frame(width: 20, height: 20)
.contentShape(Rectangle())
}
.buttonStyle(.borderless)
.help("Choose directory")
}
}
// Git branch and worktree selection when Git repository is detected
if isGitRepository, let repoPath = gitRepoPath, let service = worktreeService {
GitBranchWorktreeSelector(
repoPath: repoPath,
gitMonitor: gitMonitor,
worktreeService: service,
onBranchChanged: { branch in
selectedBaseBranch = branch
branchSwitchWarning = nil
},
onWorktreeChanged: { worktree in
if let worktree {
// Find the worktree info to get the path
if let worktreeInfo = service.worktrees.first(where: { $0.branch == worktree }) {
selectedWorktreePath = worktreeInfo.path
selectedWorktreeBranch = worktreeInfo.branch
workingDirectory = worktreeInfo.path
}
} else {
selectedWorktreePath = nil
selectedWorktreeBranch = nil
// Don't change workingDirectory here - keep the original git repo path
}
},
onCreateWorktree: { branchName, baseBranch in
// Generate worktree path by slugifying branch name
let slugifiedBranch = branchName
.replacingOccurrences(of: "/", with: "-")
.replacingOccurrences(of: " ", with: "-")
.lowercased()
// Create worktree path in a 'worktrees' subdirectory
let repoURL = URL(fileURLWithPath: repoPath)
let worktreesDir = repoURL.appendingPathComponent("worktrees")
let worktreePath = worktreesDir.appendingPathComponent(slugifiedBranch).path
// Create the worktree
try await service.createWorktree(
gitRepoPath: repoPath,
branch: branchName,
worktreePath: worktreePath,
baseBranch: baseBranch
)
// After creation, select the new worktree
await service.fetchWorktrees(for: repoPath)
if let newWorktree = service.worktrees.first(where: { $0.branch == branchName }) {
selectedWorktreePath = newWorktree.path
selectedWorktreeBranch = newWorktree.branch
workingDirectory = newWorktree.path
}
}
)
.padding(.horizontal, 12)
.padding(.vertical, 10)
.background(
RoundedRectangle(cornerRadius: 6)
.fill(Color(NSColor.controlBackgroundColor).opacity(0.05))
)
.overlay(
RoundedRectangle(cornerRadius: 6)
.stroke(Color.accentColor.opacity(0.2), lineWidth: 1)
)
}
// 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(configManager.quickStartCommands) { cmd in
Button(action: {
command = cmd.command
sessionName = ""
}, label: {
Text(cmd.displayName)
.font(.system(size: 11))
.frame(maxWidth: .infinity, alignment: .leading)
.padding(.horizontal, 12)
.padding(.vertical, 6)
})
.background(
RoundedRectangle(cornerRadius: 6)
.fill(
command == cmd.command ? Color.accentColor.opacity(0.15) : Color.primary
.opacity(0.05)
)
)
.overlay(
RoundedRectangle(cornerRadius: 6)
.stroke(
command == cmd.command ? Color.accentColor.opacity(0.5) : 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 }, label: {
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(minHeight: 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...")
.font(.system(size: 12))
}
.padding(.horizontal, 10)
.padding(.vertical, 3)
} else {
Text("Create")
.font(.system(size: 12))
.padding(.horizontal, 10)
.padding(.vertical, 3)
}
}
.buttonStyle(.plain)
.foregroundColor(command.isEmpty || workingDirectory.isEmpty ? .secondary.opacity(0.5) : .secondary)
.background(
RoundedRectangle(cornerRadius: 6)
.fill(
isHoveringCreate && !command.isEmpty && !workingDirectory.isEmpty ? Color.accentColor
.opacity(0.05) : Color.clear
)
.animation(.easeInOut(duration: 0.2), value: isHoveringCreate)
)
.disabled(isCreating || command.isEmpty || workingDirectory.isEmpty)
.onHover { hovering in
isHoveringCreate = hovering
}
}
.padding(.horizontal, 16)
.padding(.vertical, 12)
}
.frame(width: 384)
.frame(minHeight: 500)
.background(.ultraThinMaterial, in: RoundedRectangle(cornerRadius: 12))
.clipShape(RoundedRectangle(cornerRadius: 12))
.fixedSize(horizontal: true, vertical: false)
.onAppear {
loadPreferences()
focusedField = .name
// Check if the default/loaded directory is a Git repository
checkForGitRepository(at: workingDirectory)
}
.task {
await repositoryDiscovery.discoverRepositories(in: configManager.repositoryBasePath)
}
.alert("Error", isPresented: $showError) {
Button("OK") {}
} message: {
Text(errorMessage)
}
.compositingGroup() // Render the entire form as a single composited layer
}
private func selectDirectory() {
// Find the menu window first
guard let menuWindow = NSApp.windows.first(where: { $0 is CustomMenuWindow }) as? CustomMenuWindow else {
return
}
let panel = NSOpenPanel()
panel.canChooseFiles = false
panel.canChooseDirectories = true
panel.allowsMultipleSelection = false
panel.directoryURL = URL(fileURLWithPath: NSString(string: workingDirectory).expandingTildeInPath)
// Set flag on the window to prevent it from hiding
menuWindow.isFileSelectionInProgress = true
// Use beginSheetModal to keep the window relationship
panel.beginSheetModal(for: menuWindow) { response in
Task { @MainActor in
if response == .OK, let url = panel.url {
let path = url.path
let homeDir = NSHomeDirectory()
if path.hasPrefix(homeDir) {
self.workingDirectory = "~" + path.dropFirst(homeDir.count)
} else {
self.workingDirectory = path
}
}
// Clear the flag after selection completes
menuWindow.isFileSelectionInProgress = false
// Ensure the menu window regains focus
menuWindow.makeKeyAndOrderFront(nil)
}
}
}
private func createSession() {
guard !command.isEmpty && !workingDirectory.isEmpty else { return }
isCreating = true
savePreferences()
Task {
do {
var finalWorkingDir: String
var effectiveBranch = ""
// Clear any previous warning
await MainActor.run {
branchSwitchWarning = nil
}
// If using a specific worktree
if let selectedWorktreePath, let selectedBranch = selectedWorktreeBranch {
// Using a specific worktree
finalWorkingDir = selectedWorktreePath
effectiveBranch = selectedBranch
} else if isGitRepository && !selectedBaseBranch.isEmpty && selectedBaseBranch != currentBranch {
// Not using worktree but selected a different branch - attempt to switch
finalWorkingDir = workingDirectory
if let service = worktreeService, let repoPath = gitRepoPath {
do {
try await service.switchBranch(gitRepoPath: repoPath, branch: selectedBaseBranch)
effectiveBranch = selectedBaseBranch
} catch {
// Branch switch failed - show warning but continue with current branch
effectiveBranch = currentBranch
let errorMessage = error.localizedDescription
let isUncommittedChanges = errorMessage.lowercased().contains("uncommitted changes")
await MainActor.run {
branchSwitchWarning = isUncommittedChanges
?
"Cannot switch to \(selectedBaseBranch) due to uncommitted changes. Creating session on \(currentBranch)."
:
"Failed to switch to \(selectedBaseBranch): \(errorMessage). Creating session on \(currentBranch)."
}
}
}
} else {
// Use current branch
finalWorkingDir = workingDirectory
effectiveBranch = selectedBaseBranch.isEmpty ? currentBranch : selectedBaseBranch
}
// Parse command into array
let commandArray = parseCommand(command.trimmingCharacters(in: .whitespacesAndNewlines))
// Expand tilde in working directory
let expandedWorkingDir = NSString(string: finalWorkingDir).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,
gitRepoPath: gitRepoPath,
gitBranch: effectiveBranch.isEmpty ? nil : effectiveBranch
)
// If not spawning window, open in browser
if !spawnWindow {
if let webURL = DashboardURLBuilder.dashboardURL(port: 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: AppConstants.UserDefaultsKeys.newSessionCommand) {
command = savedCommand
}
// Restore last used working directory, not repository base path
if let savedDirectory = UserDefaults.standard
.string(forKey: AppConstants.UserDefaultsKeys.newSessionWorkingDirectory)
{
workingDirectory = savedDirectory
} else {
// Default to repository base path if never set
workingDirectory = configManager.sessionWorkingDirectory
}
// Check if spawn window preference has been explicitly set
if UserDefaults.standard.object(forKey: AppConstants.UserDefaultsKeys.newSessionSpawnWindow) != nil {
spawnWindow = UserDefaults.standard.bool(forKey: AppConstants.UserDefaultsKeys.newSessionSpawnWindow)
} else {
// Default to true if never set
spawnWindow = true
}
if let savedMode = UserDefaults.standard.string(forKey: AppConstants.UserDefaultsKeys.newSessionTitleMode),
let mode = TitleMode(rawValue: savedMode)
{
titleMode = mode
}
}
private func savePreferences() {
UserDefaults.standard.set(command, forKey: AppConstants.UserDefaultsKeys.newSessionCommand)
UserDefaults.standard.set(workingDirectory, forKey: AppConstants.UserDefaultsKeys.newSessionWorkingDirectory)
UserDefaults.standard.set(spawnWindow, forKey: AppConstants.UserDefaultsKeys.newSessionSpawnWindow)
UserDefaults.standard.set(titleMode.rawValue, forKey: AppConstants.UserDefaultsKeys.newSessionTitleMode)
}
private func checkForGitRepository(at path: String) {
guard !checkingGitStatus else { return }
logger.info("🔍 Checking for Git repository at: \(path)")
checkingGitStatus = true
Task {
let expandedPath = NSString(string: path).expandingTildeInPath
logger.debug("🔍 Expanded path: \(expandedPath)")
if let repo = await gitMonitor.findRepository(for: expandedPath) {
logger.info("✅ Found Git repository: \(repo.path)")
await MainActor.run {
self.isGitRepository = true
self.gitRepoPath = repo.path
self.worktreeService = WorktreeService(serverManager: serverManager)
self.checkingGitStatus = false
}
// Fetch branches and worktrees in parallel
if let service = self.worktreeService {
await withTaskGroup(of: Void.self) { group in
group.addTask {
await service.fetchBranches(for: repo.path)
}
group.addTask {
await service.fetchWorktrees(for: repo.path)
}
}
// Update UI state with fetched data
await MainActor.run {
// Set available branches
// Branches are now loaded by GitBranchWorktreeSelector
// Find and set current branch
if let currentBranchData = service.branches.first(where: { $0.current }) {
self.currentBranch = currentBranchData.name
if self.selectedBaseBranch.isEmpty {
self.selectedBaseBranch = currentBranchData.name
}
}
// Pre-select current worktree if we're in one (not the main worktree)
if let currentWorktree = service.worktrees.first(where: {
$0.path == expandedPath && !($0.isMainWorktree ?? false)
}) {
self.selectedWorktreePath = currentWorktree.path
self.selectedWorktreeBranch = currentWorktree.branch
}
}
}
} else {
logger.info("❌ No Git repository found")
await MainActor.run {
self.isGitRepository = false
self.gitRepoPath = nil
self.selectedWorktreePath = nil
self.selectedWorktreeBranch = nil
self.worktreeService = nil
self.currentBranch = ""
self.selectedBaseBranch = ""
self.branchSwitchWarning = nil
self.checkingGitStatus = false
}
}
}
}
}
// MARK: - Repository Dropdown List
private struct RepositoryDropdownList: View {
let repositories: [DiscoveredRepository]
let isDiscovering: Bool
@Binding var selectedPath: String
@Binding var isShowing: Bool
var body: some View {
VStack(spacing: 0) {
ScrollView {
LazyVStack(spacing: 0) {
ForEach(repositories) { repository in
Button(action: {
selectedPath = repository.path
isShowing = false
}, label: {
HStack {
VStack(alignment: .leading, spacing: 2) {
Text(repository.displayName)
.font(.system(size: 11, weight: .medium))
.foregroundColor(.primary)
Text(repository.relativePath)
.font(.system(size: 10))
.foregroundColor(.secondary)
}
Spacer()
Text(repository.formattedLastModified)
.font(.system(size: 10))
.foregroundColor(.secondary)
}
.padding(.horizontal, 12)
.padding(.vertical, 8)
.background(
RoundedRectangle(cornerRadius: 4)
.fill(Color.clear)
)
.contentShape(Rectangle())
})
.buttonStyle(.plain)
.onHover { hovering in
if hovering {
// Add hover effect if needed
}
}
if repository.id != repositories.last?.id {
Divider()
.padding(.horizontal, 8)
}
}
}
}
.frame(maxHeight: 200)
}
.padding(.vertical, 4)
.background(
RoundedRectangle(cornerRadius: 6)
.fill(.regularMaterial)
)
.overlay(
RoundedRectangle(cornerRadius: 6)
.stroke(Color.primary.opacity(0.1), lineWidth: 1)
)
}
}