diff --git a/CLAUDE.md b/CLAUDE.md index bfc8d80b..b325e2e1 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -418,7 +418,6 @@ The agent will: - Don't check for "old format" vs "new format" - Don't add fallbacks for older versions - If you suggest backwards compatibility in any form, you have failed to understand this project - ## Key Files Quick Reference - Architecture Details: `docs/ARCHITECTURE.md` diff --git a/docs/push-impl.md b/docs/push-impl.md new file mode 100644 index 00000000..a0ecfae4 --- /dev/null +++ b/docs/push-impl.md @@ -0,0 +1,501 @@ +# Push Notification Implementation Plan + +This document outlines the comprehensive plan for improving VibeTunnel's notification system through two major initiatives: +1. Creating a dedicated Notifications tab in macOS settings +2. Migrating SessionMonitor from the Mac app to the server for unified notifications + +## Overview + +Currently, VibeTunnel has inconsistent notification implementations between the Mac and web clients. The Mac app has its own SessionMonitor while the web relies on server events. This leads to: +- Different notification behaviors between platforms +- Missing features (e.g., Claude Turn notifications not shown in web UI) +- Duplicate code and maintenance burden +- Inconsistent descriptions and thresholds + +## Part 1: macOS Settings Redesign + +### Current State +- Notification settings are cramped in the General tab +- No room for descriptive text explaining each notification type +- Settings are already at 710px height (quite tall) +- Missing helpful context that exists in the web UI + +### Proposed Solution: Dedicated Notifications Tab + +#### 1. Add Notifications Tab to SettingsTab enum + +```swift +// SettingsTab.swift +enum SettingsTab: String, CaseIterable { + case general + case notifications // NEW + case quickStart + case dashboard + // ... rest of tabs +} + +// Add display name and icon +var displayName: String { + switch self { + case .notifications: "Notifications" + // ... rest + } +} + +var icon: String { + switch self { + case .notifications: "bell.badge" + // ... rest + } +} +``` + +#### 2. Create NotificationSettingsView.swift + +```swift +struct NotificationSettingsView: View { + @ObservedObject private var configManager = ConfigManager.shared + @ObservedObject private var notificationService = NotificationService.shared + + var body: some View { + Form { + // Master toggle section + Section { + VStack(alignment: .leading, spacing: 8) { + Toggle("Show Session Notifications", isOn: $showNotifications) + Text("Display native macOS notifications for session and command events") + .font(.caption) + .foregroundStyle(.secondary) + } + } + + // Notification types section + Section { + NotificationToggleRow( + title: "Session starts", + description: "When a new session starts (useful for shared terminals)", + isOn: $configManager.notificationSessionStart, + helpText: NotificationHelp.sessionStart + ) + + NotificationToggleRow( + title: "Session ends", + description: "When a session terminates or crashes (shows exit code)", + isOn: $configManager.notificationSessionExit, + helpText: NotificationHelp.sessionExit + ) + + // ... other notification types + } header: { + Text("Notification Types") + } + + // Behavior section + Section { + Toggle("Play sound", isOn: $configManager.notificationSoundEnabled) + Toggle("Show in Notification Center", isOn: $configManager.showInNotificationCenter) + } header: { + Text("Notification Behavior") + } + + // Test section + Section { + Button("Test Notification") { + notificationService.sendTestNotification() + } + } + } + } +} +``` + +#### 3. Create Reusable NotificationToggleRow Component + +```swift +struct NotificationToggleRow: View { + let title: String + let description: String + @Binding var isOn: Bool + let helpText: String + + var body: some View { + HStack(alignment: .top, spacing: 12) { + VStack(alignment: .leading, spacing: 4) { + HStack { + Toggle(title, isOn: $isOn) + .toggleStyle(.checkbox) + HelpTooltip(text: helpText) + } + Text(description) + .font(.caption) + .foregroundStyle(.secondary) + } + } + .padding(.vertical, 4) + } +} +``` + +#### 4. Update SettingsView.swift + +```swift +// Add the new tab +NotificationSettingsView() + .tabItem { + Label(SettingsTab.notifications.displayName, + systemImage: SettingsTab.notifications.icon) + } + .tag(SettingsTab.notifications) +``` + +#### 5. Update GeneralSettingsView.swift + +Remove all notification-related settings to free up space. + +### Standardized Notification Descriptions + +Use these descriptions consistently across Mac and web: + +| Type | Title | Description | +|------|-------|-------------| +| Session Start | Session starts | When a new session starts (useful for shared terminals) | +| Session Exit | Session ends | When a session terminates or crashes (shows exit code) | +| Command Error | Commands fail | When commands fail with non-zero exit codes | +| Command Completion | Commands complete (> 3 seconds) | When commands taking >3 seconds finish (builds, tests, etc.) | +| Terminal Bell | Terminal bell (🔔) | Terminal bell (^G) from vim, IRC mentions, completion sounds | +| Claude Turn | Claude turn notifications | When Claude AI finishes responding and awaits input | + +## Part 2: Server-Side SessionMonitor Migration + +### Current Architecture + +``` +Mac App: + SessionMonitor (Swift) → NotificationService → macOS notifications + +Server: + PtyManager → Basic events → SSE → Web notifications +``` + +### Proposed Architecture + +``` +Server: + PtyManager → SessionMonitor (TypeScript) → Enhanced events → SSE/WebSocket + ↓ + Mac & Web clients +``` + +### Implementation Steps + +#### 1. Create Server-Side SessionMonitor + +```typescript +// web/src/server/services/session-monitor.ts + +export interface SessionState { + id: string; + name: string; + command: string[]; + isRunning: boolean; + activityStatus?: { + isActive: boolean; + lastActivity?: Date; + specificStatus?: { + app: string; + status: string; + }; + }; + commandStartTime?: Date; + lastCommand?: string; +} + +export class SessionMonitor { + private sessions = new Map(); + private claudeIdleNotified = new Set(); + private lastActivityState = new Map(); + private commandThresholdMs = 3000; // 3 seconds + + constructor( + private ptyManager: PtyManager, + private eventBus: EventEmitter + ) { + this.setupEventListeners(); + } + + private detectClaudeSession(session: SessionState): boolean { + const isClaudeCommand = session.command + .join(' ') + .toLowerCase() + .includes('claude'); + + const isClaudeApp = session.activityStatus?.specificStatus?.app + .toLowerCase() + .includes('claude') ?? false; + + return isClaudeCommand || isClaudeApp; + } + + private checkClaudeTurnNotification(sessionId: string, newState: SessionState) { + if (!this.detectClaudeSession(newState)) return; + + const currentActive = newState.activityStatus?.isActive ?? false; + const previousActive = this.lastActivityState.get(sessionId) ?? false; + + // Claude went from active to idle + if (previousActive && !currentActive && !this.claudeIdleNotified.has(sessionId)) { + this.eventBus.emit('notification', { + type: ServerEventType.ClaudeTurn, + sessionId, + sessionName: newState.name, + message: 'Claude has finished responding' + }); + this.claudeIdleNotified.add(sessionId); + } + + // Reset when Claude becomes active again + if (!previousActive && currentActive) { + this.claudeIdleNotified.delete(sessionId); + } + + this.lastActivityState.set(sessionId, currentActive); + } + + // ... other monitoring methods +} +``` + +#### 2. Enhance Event Types + +```typescript +// web/src/shared/types.ts + +export enum ServerEventType { + SessionStart = 'session-start', + SessionExit = 'session-exit', + CommandFinished = 'command-finished', + CommandError = 'command-error', // NEW - separate from finished + Bell = 'bell', // NEW + ClaudeTurn = 'claude-turn', + Connected = 'connected' +} + +export interface ServerEvent { + type: ServerEventType; + timestamp: string; + sessionId: string; + sessionName?: string; + + // Event-specific data + exitCode?: number; + command?: string; + duration?: number; + message?: string; + + // Activity status for richer client UI + activityStatus?: { + isActive: boolean; + app?: string; + }; +} +``` + +#### 3. Integrate with PtyManager + +```typescript +// web/src/server/pty/pty-manager.ts + +class PtyManager { + private sessionMonitor: SessionMonitor; + + constructor() { + this.sessionMonitor = new SessionMonitor(this, serverEventBus); + } + + // Feed data to SessionMonitor + private handlePtyData(sessionId: string, data: string) { + // Existing data handling... + + // Detect bell character + if (data.includes('\x07')) { + serverEventBus.emit('notification', { + type: ServerEventType.Bell, + sessionId, + sessionName: this.sessions.get(sessionId)?.name + }); + } + + // Update activity status + this.sessionMonitor.updateActivity(sessionId, { + isActive: true, + lastActivity: new Date() + }); + } +} +``` + +#### 4. Update Server Routes + +```typescript +// web/src/server/routes/events.ts + +// Enhanced event handling +serverEventBus.on('notification', (event: ServerEvent) => { + // Send to all connected SSE clients + broadcastEvent(event); + + // Log for debugging + logger.info(`📢 Notification event: ${event.type} for session ${event.sessionId}`); +}); +``` + +#### 5. Update Mac NotificationService + +```swift +// NotificationService.swift + +class NotificationService { + // Remove local SessionMonitor dependency + // Subscribe to server SSE events instead + + private func connectToServerEvents() { + eventSource = EventSource(url: "http://localhost:4020/api/events") + + eventSource.onMessage { event in + guard let data = event.data, + let serverEvent = try? JSONDecoder().decode(ServerEvent.self, from: data) else { + return + } + + Task { @MainActor in + self.handleServerEvent(serverEvent) + } + } + } + + private func handleServerEvent(_ event: ServerEvent) { + // Map server events to notifications + switch event.type { + case .sessionStart: + if preferences.sessionStart { + sendNotification(for: event) + } + // ... handle other event types + } + } +} +``` + +#### 6. Update Web Notification Service + +```typescript +// web/src/client/services/push-notification-service.ts + +// Add Claude Turn to notification handling +private handleServerEvent(event: ServerEvent) { + if (!this.preferences[this.mapEventTypeToPreference(event.type)]) { + return; + } + + // Send browser notification + this.showNotification(event); +} + +private mapEventTypeToPreference(type: ServerEventType): keyof NotificationPreferences { + const mapping = { + [ServerEventType.SessionStart]: 'sessionStart', + [ServerEventType.SessionExit]: 'sessionExit', + [ServerEventType.CommandFinished]: 'commandCompletion', + [ServerEventType.CommandError]: 'commandError', + [ServerEventType.Bell]: 'bell', + [ServerEventType.ClaudeTurn]: 'claudeTurn' // Now properly mapped + }; + return mapping[type]; +} +``` + +#### 7. Add Claude Turn to Web UI + +```typescript +// web/src/client/components/settings.ts + +// In notification types section +${this.renderNotificationToggle('claudeTurn', 'Claude Turn', + 'When Claude AI finishes responding and awaits input')} +``` + +## Migration Strategy + +### Phase 1: Preparation (Non-breaking) +1. Implement server-side SessionMonitor alongside existing system +2. Add new event types to shared types +3. Update web UI to show Claude Turn option + +### Phase 2: Server Enhancement (Non-breaking) +1. Deploy enhanced server with SessionMonitor +2. Server emits both old and new event formats +3. Test with web client to ensure compatibility + +### Phase 3: Mac App Migration +1. Update Mac app to consume server events +2. Keep fallback to local monitoring if server unavailable +3. Remove local SessionMonitor once stable + +### Phase 4: Cleanup +1. Remove old event formats from server +2. Remove local SessionMonitor code from Mac +3. Document new architecture + +## Testing Plan + +### Unit Tests +- SessionMonitor Claude detection logic +- Event threshold calculations +- Activity state transitions + +### Integration Tests +- Server events reach both Mac and web clients +- Notification preferences are respected +- Claude Turn notifications work correctly +- Bell character detection + +### Manual Testing +- Test each notification type on both platforms +- Verify descriptions match +- Test with multiple clients connected +- Test offline Mac app behavior + +## Success Metrics + +1. **Consistency**: Same notifications appear on Mac and web for same events +2. **Feature Parity**: Claude Turn available on both platforms +3. **Performance**: No noticeable lag in notifications +4. **Reliability**: No missed notifications +5. **Maintainability**: Single codebase for monitoring logic + +## Timeline Estimate + +- **Week 1**: Implement macOS Notifications tab +- **Week 2**: Create server-side SessionMonitor +- **Week 3**: Integrate and test with web client +- **Week 4**: Migrate Mac app and testing +- **Week 5**: Polish, documentation, and deployment + +## Risks and Mitigations + +| Risk | Impact | Mitigation | +|------|--------|------------| +| Breaking existing notifications | High | Phased rollout, maintain backwards compatibility | +| Performance impact on server | Medium | Efficient event handling, consider debouncing | +| Mac app offline mode | Medium | Keep local fallback for critical notifications | +| Complex migration | Medium | Detailed testing plan, feature flags | + +## Conclusion + +This two-part implementation will: +1. Provide a better UI for notification settings on macOS +2. Create a unified notification system across all platforms +3. Reduce code duplication and maintenance burden +4. Ensure consistent behavior for all users + +The migration is designed to be non-breaking with careful phases to minimize risk. \ No newline at end of file diff --git a/mac/VibeTunnel/Presentation/Components/HelpTooltip.swift b/mac/VibeTunnel/Presentation/Components/HelpTooltip.swift new file mode 100644 index 00000000..f8bda3ac --- /dev/null +++ b/mac/VibeTunnel/Presentation/Components/HelpTooltip.swift @@ -0,0 +1,54 @@ +import SwiftUI + +/// A help icon with tooltip for explaining settings +struct HelpTooltip: View { + let text: String + @State private var isHovering = false + + var body: some View { + Image(systemName: "questionmark.circle") + .foregroundColor(.secondary) + .imageScale(.small) + .help(text) + .onHover { hovering in + isHovering = hovering + } + .scaleEffect(isHovering ? 1.1 : 1.0) + .animation(.easeInOut(duration: 0.1), value: isHovering) + } +} + +/// Notification setting descriptions +enum NotificationHelp { + static let sessionStarts = "Get notified when a new terminal session begins. Useful for monitoring when someone starts using your shared terminal." + + static let sessionEnds = "Get notified when a terminal session closes. Shows exit code if the session crashed or exited abnormally." + + static let commandsComplete = "Get notified when commands that take longer than 3 seconds finish. Perfect for long builds, tests, or data processing tasks." + + static let commandsFail = "Get notified when any command exits with an error (non-zero exit code). Helps you quickly spot and fix problems." + + static let terminalBell = "Get notified when programs output the terminal bell character (^G). Common in vim alerts, IRC mentions, and completion notifications." + + static let claudeTurn = "Get notified when Claude AI finishes responding and is waiting for your input. Automatically detects Claude CLI sessions and tracks activity transitions." +} + +#Preview { + VStack(alignment: .leading, spacing: 20) { + HStack { + Text("Session starts") + HelpTooltip(text: NotificationHelp.sessionStarts) + } + + HStack { + Text("Commands complete (> 3 seconds)") + HelpTooltip(text: NotificationHelp.commandsComplete) + } + + HStack { + Text("Claude turn notifications") + HelpTooltip(text: NotificationHelp.claudeTurn) + } + } + .padding() +} diff --git a/mac/VibeTunnel/Presentation/Views/Settings/GeneralSettingsView.swift b/mac/VibeTunnel/Presentation/Views/Settings/GeneralSettingsView.swift index 3e25b277..b4bb5cd4 100644 --- a/mac/VibeTunnel/Presentation/Views/Settings/GeneralSettingsView.swift +++ b/mac/VibeTunnel/Presentation/Views/Settings/GeneralSettingsView.swift @@ -165,4 +165,3 @@ struct GeneralSettingsView: View { } } } - diff --git a/mac/VibeTunnel/Presentation/Views/Settings/RemoteAccessSettingsView.swift b/mac/VibeTunnel/Presentation/Views/Settings/RemoteAccessSettingsView.swift index fce74070..e4c71947 100644 --- a/mac/VibeTunnel/Presentation/Views/Settings/RemoteAccessSettingsView.swift +++ b/mac/VibeTunnel/Presentation/Views/Settings/RemoteAccessSettingsView.swift @@ -540,6 +540,108 @@ private struct ErrorView: View { } } +// MARK: - Authentication Section + +private struct AuthenticationSection: View { + @Binding var authMode: AuthenticationMode + @Binding var enableSSHKeys: Bool + let logger: Logger + let serverManager: ServerManager + + var body: some View { + Section { + VStack(alignment: .leading, spacing: 16) { + // Authentication mode picker + VStack(alignment: .leading, spacing: 8) { + HStack { + Text("Authentication Method") + .font(.callout) + Spacer() + Picker("", selection: $authMode) { + ForEach(AuthenticationMode.allCases, id: \.self) { mode in + Text(mode.displayName) + .tag(mode) + } + } + .labelsHidden() + .pickerStyle(.menu) + .frame(alignment: .trailing) + .onChange(of: authMode) { _, newValue in + // Save the authentication mode + UserDefaults.standard.set( + newValue.rawValue, + forKey: AppConstants.UserDefaultsKeys.authenticationMode + ) + + Task { + logger.info("Authentication mode changed to: \(newValue.rawValue)") + await serverManager.restart() + } + } + } + + Text(authMode.description) + .font(.caption) + .foregroundStyle(.secondary) + .fixedSize(horizontal: false, vertical: true) + .frame(maxWidth: .infinity, alignment: .leading) + } + + // Additional info based on selected mode + if authMode == .osAuth || authMode == .both { + HStack(alignment: .center, spacing: 6) { + Image(systemName: "info.circle") + .foregroundColor(.blue) + .font(.system(size: 12)) + .frame(width: 16, height: 16) + Text("Uses your macOS username: \(NSUserName())") + .font(.caption) + .foregroundStyle(.secondary) + Spacer() + } + } + + if authMode == .sshKeys || authMode == .both { + HStack(alignment: .center, spacing: 6) { + Image(systemName: "key.fill") + .foregroundColor(.blue) + .font(.system(size: 12)) + .frame(width: 16, height: 16) + Text("SSH keys from ~/.ssh/authorized_keys") + .font(.caption) + .foregroundStyle(.secondary) + Spacer() + Button("Open folder") { + let sshPath = NSHomeDirectory() + "/.ssh" + if FileManager.default.fileExists(atPath: sshPath) { + NSWorkspace.shared.open(URL(fileURLWithPath: sshPath)) + } else { + // Create .ssh directory if it doesn't exist + try? FileManager.default.createDirectory( + atPath: sshPath, + withIntermediateDirectories: true, + attributes: [.posixPermissions: 0o700] + ) + NSWorkspace.shared.open(URL(fileURLWithPath: sshPath)) + } + } + .buttonStyle(.link) + .font(.caption) + } + } + } + } header: { + Text("Authentication") + .font(.headline) + } footer: { + Text("Localhost connections are always accessible without authentication.") + .font(.caption) + .multilineTextAlignment(.center) + .frame(maxWidth: .infinity) + } + } +} + // MARK: - Previews #Preview("Remote Access Settings") { diff --git a/web/src/client/components/terminal-quick-keys.ts b/web/src/client/components/terminal-quick-keys.ts index ff3f15c8..0176c9d2 100644 --- a/web/src/client/components/terminal-quick-keys.ts +++ b/web/src/client/components/terminal-quick-keys.ts @@ -287,6 +287,10 @@ export class TerminalQuickKeys extends LitElement { /* Smooth transition when keyboard appears/disappears */ transition: bottom 0.3s ease-out; background-color: rgb(var(--color-bg-secondary)); + /* Prevent horizontal overflow */ + width: 100%; + max-width: 100vw; + overflow-x: hidden; } /* The actual bar with buttons */ @@ -299,6 +303,9 @@ export class TerminalQuickKeys extends LitElement { appearance: none; /* Add shadow for visibility */ box-shadow: 0 -2px 10px rgb(var(--color-bg-secondary) / 0.5); + /* Ensure proper width */ + width: 100%; + box-sizing: border-box; } /* Quick key buttons */ @@ -448,7 +455,7 @@ export class TerminalQuickKeys extends LitElement { >
-
+
${TERMINAL_QUICK_KEYS.filter((k) => k.row === 1).map( ({ key, label, modifier, arrow, toggle }) => html`