import AppKit import OSLog import SwiftUI /// Row component displaying a single terminal session. /// /// Shows session information including command, directory, git status, /// activity indicators, and provides interaction for opening, renaming, /// and terminating sessions. Supports both window and web-based sessions. struct SessionRow: View { let session: (key: String, value: ServerSessionInfo) let isHovered: Bool let isActive: Bool let isFocused: Bool @Environment(\.openWindow) private var openWindow @Environment(ServerManager.self) private var serverManager @Environment(SessionMonitor.self) private var sessionMonitor @Environment(SessionService.self) private var sessionService @Environment(GitRepositoryMonitor.self) private var gitRepositoryMonitor @Environment(\.colorScheme) private var colorScheme @State private var isTerminating = false @State private var isEditing = false @State private var editedName = "" @State private var isHoveringFolder = false @FocusState private var isEditFieldFocused: Bool private static let logger = Logger(subsystem: BundleIdentifiers.loggerSubsystem, category: "SessionRow") /// Computed property that reads directly from the monitor's cache /// This will automatically update when the monitor refreshes private var gitRepository: GitRepository? { gitRepositoryMonitor.getCachedRepository(for: session.value.workingDir) } var body: some View { Button(action: handleTap) { content } .buttonStyle(PlainButtonStyle()) .task(id: session.value.workingDir) { // Fetch repository data if not already cached if gitRepository == nil { _ = await gitRepositoryMonitor.findRepository(for: session.value.workingDir) } } } var content: some View { HStack(spacing: 8) { // Activity indicator with subtle glow ZStack { Circle() .fill(activityColor.opacity(0.3)) .frame(width: 8, height: 8) .blur(radius: 2) Circle() .fill(activityColor) .frame(width: 4, height: 4) } // Session info - use flexible width VStack(alignment: .leading, spacing: 2) { // First row: Command name, session name, and window indicator - FULL WIDTH HStack(spacing: 4) { if isEditing { TextField("Session Name", text: $editedName) .font(.system(size: 12, weight: .medium)) .textFieldStyle(.plain) .focused($isEditFieldFocused) .onSubmit { saveSessionName() } .onKeyPress(.escape) { cancelEditing() return .handled } } else { // Show command name Text(commandName) .font(.system(size: 12, weight: .medium)) .foregroundColor(.primary) .lineLimit(1) .truncationMode(.tail) // Show session name if available if !session.value.name.isEmpty { Text("–") .font(.system(size: 12)) .foregroundColor(.secondary.opacity(0.6)) Text(session.value.name) .font(.system(size: 12)) .foregroundColor(.secondary) .lineLimit(1) .truncationMode(.tail) } // Edit button (pencil icon) - only show on hover if isHovered && !isEditing { HStack(spacing: 6) { Button(action: startEditing) { Image(systemName: "square.and.pencil") .font(.system(size: 11)) .foregroundColor(.primary) } .buttonStyle(.plain) .help("Rename session") .modifier(HoverOpacityModifier()) // Magic wand button for AI assistant sessions if isAIAssistantSession { Button(action: sendAIPrompt) { Image(systemName: "wand.and.rays") .font(.system(size: 11)) .foregroundColor(.primary) } .buttonStyle(.plain) .help("Send prompt to update terminal title") .modifier(HoverOpacityModifier()) } } } } Spacer() // Window indicator - only show globe if no window if !hasWindow { Image(systemName: "globe") .font(.system(size: 10)) .foregroundColor(.secondary.opacity(0.6)) } } // Second row: Path, Git info, Duration and X button HStack(alignment: .center, spacing: 6) { // Left side: Path and git info HStack(alignment: .center, spacing: 4) { // Folder icon - clickable Button(action: { NSWorkspace.shared.selectFile(nil, inFileViewerRootedAtPath: session.value.workingDir) }, label: { Image(systemName: "folder") .font(.system(size: 10)) .foregroundColor(isHoveringFolder ? .primary : .secondary) .padding(4) .background( RoundedRectangle(cornerRadius: 4) .fill(isHoveringFolder ? AppColors.Fallback.controlBackground(for: colorScheme) .opacity(0.3) : Color.clear ) ) .overlay( RoundedRectangle(cornerRadius: 4) .strokeBorder( isHoveringFolder ? AppColors.Fallback.gitBorder(for: colorScheme) .opacity(0.4) : Color.clear, lineWidth: 0.5 ) ) }) .buttonStyle(.plain) .onHover { hovering in isHoveringFolder = hovering } .help("Open in Finder") // Path text - not clickable Text(compactPath) .font(.system(size: 10, design: .monospaced)) .foregroundColor(.secondary) .lineLimit(1) .truncationMode(.head) .layoutPriority(-1) // Lowest priority if let repo = gitRepository { GitRepositoryRow(repository: repo) .layoutPriority(1) // Highest priority } } .frame(maxWidth: .infinity, alignment: .leading) // Right side: Duration and X button overlay ZStack { // Duration label (hidden on hover) if !duration.isEmpty && !isHovered && !isTerminating { Text(duration) .font(.system(size: 10)) .foregroundColor(.secondary.opacity(0.6)) } // Show X button on hover (overlays duration) if !isTerminating && isHovered { Button(action: terminateSession) { ZStack { Circle() .fill(AppColors.Fallback.destructive(for: colorScheme).opacity(0.1)) .frame(width: 14, height: 14) Circle() .strokeBorder( AppColors.Fallback.destructive(for: colorScheme).opacity(0.3), lineWidth: 0.5 ) .frame(width: 14, height: 14) Image(systemName: "xmark") .font(.system(size: 8, weight: .medium)) .foregroundColor(AppColors.Fallback.destructive(for: colorScheme).opacity(0.8)) } } .buttonStyle(.plain) } // Show progress indicator while terminating if isTerminating { ProgressView() .scaleEffect(0.5) .frame(width: 14, height: 14) } } .frame(width: 30, height: 16) } // Third row: Activity status (if present) if let activityStatus = session.value.activityStatus?.specificStatus?.status { HStack(spacing: 4) { Text(activityStatus) .font(.system(size: 10)) .foregroundColor(AppColors.Fallback.activityIndicator(for: colorScheme)) .lineLimit(1) .truncationMode(.tail) Spacer() } } } .frame(maxWidth: .infinity) } .padding(.horizontal, 12) .padding(.vertical, 8) .contentShape(Rectangle()) .background( RoundedRectangle(cornerRadius: 6) .fill(isHovered ? hoverBackgroundColor : Color.clear) ) .overlay( RoundedRectangle(cornerRadius: 6) .strokeBorder( isFocused ? AppColors.Fallback.accentHover(for: colorScheme).opacity(2) : Color.clear, lineWidth: 1 ) ) .focusable() .help(tooltipText) .contextMenu { if hasWindow { Button("Focus Terminal Window") { WindowTracker.shared.focusWindow(for: session.key) } } else { Button("Open in Browser") { if let url = DashboardURLBuilder.dashboardURL(port: serverManager.port, sessionId: session.key) { NSWorkspace.shared.open(url) } } } Button("View Session Details") { openWindow(id: "session-detail", value: session.key) } Divider() Button("Show in Finder") { NSWorkspace.shared.selectFile(nil, inFileViewerRootedAtPath: session.value.workingDir) } // Add git repository options if available if let repo = gitRepository { Divider() // Open in Git app let gitAppName = getGitAppName() Button("Open in \(gitAppName)") { GitAppLauncher.shared.openRepository(at: repo.path) } if repo.githubURL != nil { Button("Open on GitHub") { if let url = repo.githubURL { NSWorkspace.shared.open(url) } } } Divider() Button("Copy Branch Name") { NSPasteboard.general.clearContents() NSPasteboard.general.setString(repo.currentBranch ?? "detached", forType: .string) } Button("Copy Repository Path") { NSPasteboard.general.clearContents() NSPasteboard.general.setString(repo.path, forType: .string) } } Divider() Button("Rename Session...") { startEditing() } Divider() Button("Kill Session", role: .destructive) { terminateSession() } Divider() Button("Copy Session ID") { NSPasteboard.general.clearContents() NSPasteboard.general.setString(session.key, forType: .string) } } } private func handleTap() { guard !isEditing else { return } if hasWindow { WindowTracker.shared.focusWindow(for: session.key) } else { // Open browser for sessions without windows if let url = DashboardURLBuilder.dashboardURL(port: serverManager.port, sessionId: session.key) { NSWorkspace.shared.open(url) } } } private func getGitAppName() -> String { GitAppHelper.getPreferredGitAppName() } private func terminateSession() { isTerminating = true Task { do { try await sessionService.terminateSession(sessionId: session.key) // Session terminated successfully // The session monitor will automatically update } catch { // Handle error await MainActor.run { isTerminating = false } // Error terminating session - reset state } } } private var commandName: String { // Extract the process name from the command guard let firstCommand = session.value.command.first else { return "Unknown" } // Extract just the executable name from the path let executableName = (firstCommand as NSString).lastPathComponent // Special handling for common commands switch executableName { case "zsh", "bash", "sh": // For shells, check if there's a -c argument with the actual command if session.value.command.count > 2, session.value.command.contains("-c"), let cIndex = session.value.command.firstIndex(of: "-c"), cIndex + 1 < session.value.command.count { let actualCommand = session.value.command[cIndex + 1] return (actualCommand as NSString).lastPathComponent } return executableName default: return executableName } } private var isAIAssistantSession: Bool { // Check if this is an AI assistant session by looking at the command let aiAssistants = ["claude", "gemini", "openhands", "aider", "codex"] let cmd = commandName.lowercased() // Match exact executable names or at word boundaries return aiAssistants.contains { ai in cmd == ai || cmd.hasPrefix(ai + ".") || // e.g., claude.exe cmd.hasPrefix(ai + "-wrapper") // e.g., claude-wrapper } } private var sessionName: String { // Use the session name if available, otherwise fall back to directory name if !session.value.name.isEmpty { return session.value.name } let workingDir = session.value.workingDir return (workingDir as NSString).lastPathComponent } private func startEditing() { editedName = session.value.name isEditing = true isEditFieldFocused = true } private func cancelEditing() { isEditing = false editedName = "" isEditFieldFocused = false } private func saveSessionName() { let trimmedName = editedName.trimmingCharacters(in: .whitespacesAndNewlines) guard !trimmedName.isEmpty else { cancelEditing() return } // Update the session name via SessionService Task { do { try await sessionService.renameSession(sessionId: session.key, to: trimmedName) // Clear editing state after successful update await MainActor.run { isEditing = false editedName = "" isEditFieldFocused = false } } catch { // Error already handled - editing state reverted cancelEditing() } } } private func sendAIPrompt() { Task { do { // Send a prompt that encourages the AI assistant to use vt title let prompt = "use vt title to update the terminal title with what you're currently working on" try await sessionService.sendInput(to: session.key, text: prompt) // Send Enter key to submit the prompt try await sessionService.sendKey(to: session.key, key: "enter") } catch { // Silently handle errors for now Self.logger.error("Failed to send prompt to AI assistant: \(error)") } } } private var compactPath: String { let path = session.value.workingDir let homeDir = NSHomeDirectory() if path.hasPrefix(homeDir) { let relativePath = String(path.dropFirst(homeDir.count)) return "~" + relativePath } let components = (path as NSString).pathComponents if components.count > 2 { let lastTwo = components.suffix(2).joined(separator: "/") return ".../" + lastTwo } return path } private var activityColor: Color { isActive ? AppColors.Fallback.activityIndicator(for: colorScheme) : AppColors.Fallback .gitClean(for: colorScheme) } private var hasWindow: Bool { // Check if WindowTracker has found a window for this session // This includes both spawned terminals and those attached via vt WindowTracker.shared.windowInfo(for: session.key) != nil } private var hoverBackgroundColor: Color { AppColors.Fallback.accentHover(for: colorScheme) } private var tooltipText: String { var tooltip = "" // Session name if !session.value.name.isEmpty { tooltip += "Session: \(session.value.name)\n" } // Command tooltip += "Command: \(session.value.command.joined(separator: " "))\n" // Project path tooltip += "Path: \(session.value.workingDir)\n" // Git info if let repo = gitRepository { tooltip += "Git: \(repo.currentBranch ?? "detached")" if repo.hasChanges { tooltip += " (\(repo.statusText))" } tooltip += "\n" } // Activity status if let activityStatus = session.value.activityStatus?.specificStatus?.status { tooltip += "Activity: \(activityStatus)\n" } else { tooltip += "Activity: \(isActive ? "Active" : "Idle")\n" } // Duration tooltip += "Duration: \(formattedDuration)" return tooltip } private var formattedDuration: String { // Parse ISO8601 date string with fractional seconds let formatter = ISO8601DateFormatter() formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds] guard let startDate = formatter.date(from: session.value.startedAt) else { // Fallback: try without fractional seconds formatter.formatOptions = [.withInternetDateTime] guard let startDate = formatter.date(from: session.value.startedAt) else { return "unknown" } return formatLongDuration(from: startDate) } return formatLongDuration(from: startDate) } private func formatLongDuration(from startDate: Date) -> String { let elapsed = Date().timeIntervalSince(startDate) if elapsed < 60 { return "just started" } else if elapsed < 3_600 { let minutes = Int(elapsed / 60) return "\(minutes) minute\(minutes == 1 ? "" : "s")" } else if elapsed < 86_400 { let hours = Int(elapsed / 3_600) let minutes = Int((elapsed.truncatingRemainder(dividingBy: 3_600)) / 60) if minutes > 0 { return "\(hours) hour\(hours == 1 ? "" : "s") \(minutes) minute\(minutes == 1 ? "" : "s")" } return "\(hours) hour\(hours == 1 ? "" : "s")" } else { let days = Int(elapsed / 86_400) let hours = Int((elapsed.truncatingRemainder(dividingBy: 86_400)) / 3_600) if hours > 0 { return "\(days) day\(days == 1 ? "" : "s") \(hours) hour\(hours == 1 ? "" : "s")" } return "\(days) day\(days == 1 ? "" : "s")" } } private var duration: String { // Parse ISO8601 date string with fractional seconds let formatter = ISO8601DateFormatter() formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds] guard let startDate = formatter.date(from: session.value.startedAt) else { // Fallback: try without fractional seconds formatter.formatOptions = [.withInternetDateTime] guard let startDate = formatter.date(from: session.value.startedAt) else { return "" // Return empty string instead of "unknown" } return formatDuration(from: startDate) } return formatDuration(from: startDate) } private func formatDuration(from startDate: Date) -> String { let elapsed = Date().timeIntervalSince(startDate) if elapsed < 60 { return "now" } else if elapsed < 3_600 { let minutes = Int(elapsed / 60) return "\(minutes)m" } else if elapsed < 86_400 { let hours = Int(elapsed / 3_600) return "\(hours)h" } else { let days = Int(elapsed / 86_400) return "\(days)d" } } } /// Modifier that makes an element fully opaque on hover struct HoverOpacityModifier: ViewModifier { @State private var isHovering = false func body(content: Content) -> some View { content .opacity(isHovering ? 1.0 : 0.5) .scaleEffect(isHovering ? 1.0 : 0.95) .animation(.easeInOut(duration: 0.15), value: isHovering) .onHover { hovering in isHovering = hovering } } }