Merge branch 'main' - keep notification refactoring changes

This commit is contained in:
Peter Steinberger 2025-07-28 15:09:31 +02:00
commit 75dd51883b
8 changed files with 702 additions and 8 deletions

View file

@ -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`

501
docs/push-impl.md Normal file
View file

@ -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<string, SessionState>();
private claudeIdleNotified = new Set<string>();
private lastActivityState = new Map<string, boolean>();
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.

View file

@ -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()
}

View file

@ -165,4 +165,3 @@ struct GeneralSettingsView: View {
}
}
}

View file

@ -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") {

View file

@ -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 {
>
<div class="quick-keys-bar">
<!-- Row 1 -->
<div class="flex gap-0.5 justify-center mb-0.5">
<div class="flex gap-0.5 justify-center mb-0.5 flex-wrap">
${TERMINAL_QUICK_KEYS.filter((k) => k.row === 1).map(
({ key, label, modifier, arrow, toggle }) => html`
<button
@ -500,7 +507,7 @@ export class TerminalQuickKeys extends LitElement {
this.showCtrlKeys
? html`
<!-- Ctrl shortcuts row -->
<div class="flex gap-0.5 justify-between flex-wrap mb-0.5">
<div class="flex gap-0.5 justify-center flex-wrap mb-0.5">
${CTRL_SHORTCUTS.map(
({ key, label, combo, special }) => html`
<button
@ -535,7 +542,7 @@ export class TerminalQuickKeys extends LitElement {
: this.showFunctionKeys
? html`
<!-- Function keys row -->
<div class="flex gap-0.5 justify-between mb-0.5">
<div class="flex gap-0.5 justify-center flex-wrap mb-0.5">
${FUNCTION_KEYS.map(
({ key, label }) => html`
<button
@ -569,7 +576,7 @@ export class TerminalQuickKeys extends LitElement {
`
: html`
<!-- Regular row 2 -->
<div class="flex gap-0.5 justify-center mb-0.5">
<div class="flex gap-0.5 justify-center mb-0.5 flex-wrap">
${TERMINAL_QUICK_KEYS.filter((k) => k.row === 2).map(
({ key, label, modifier, combo, special, toggle }) => html`
<button
@ -608,7 +615,7 @@ export class TerminalQuickKeys extends LitElement {
}
<!-- Row 3 - Additional special characters (always visible) -->
<div class="flex gap-0.5 justify-center">
<div class="flex gap-0.5 justify-center flex-wrap">
${TERMINAL_QUICK_KEYS.filter((k) => k.row === 3).map(
({ key, label, modifier, combo, special }) => html`
<button

View file

@ -1489,7 +1489,10 @@ body.initial-session-load .session-flex-responsive > session-card:nth-child(n +
/* Position keyboard button */
.keyboard-button {
@apply fixed bottom-3 right-3 z-50;
@apply fixed z-50;
/* Account for safe areas on mobile devices */
bottom: calc(0.75rem + env(safe-area-inset-bottom, 0px));
right: calc(0.75rem + env(safe-area-inset-right, 0px));
/* Ensure button is always touchable */
touch-action: manipulation;
-webkit-tap-highlight-color: transparent;

View file

@ -28,6 +28,7 @@ import type {
} from '../../shared/types.js';
import { TitleMode } from '../../shared/types.js';
import { ProcessTreeAnalyzer } from '../services/process-tree-analyzer.js';
import type { SessionMonitor } from '../services/session-monitor.js';
import { ActivityDetector, type ActivityState } from '../utils/activity-detector.js';
import { TitleSequenceFilter } from '../utils/ansi-title-filter.js';
import { createLogger } from '../utils/logger.js';
@ -137,6 +138,7 @@ export class PtyManager extends EventEmitter {
private processTreeAnalyzer = new ProcessTreeAnalyzer(); // Process tree analysis for bell source identification
private activityFileWarningsLogged = new Set<string>(); // Track which sessions we've logged warnings for
private lastWrittenActivityState = new Map<string, string>(); // Track last written activity state to avoid unnecessary writes
private sessionMonitor: SessionMonitor | null = null; // Reference to SessionMonitor for notification tracking
// Command tracking for notifications
private commandTracking = new Map<
@ -181,6 +183,13 @@ export class PtyManager extends EventEmitter {
}
}
/**
* Set the SessionMonitor instance for activity tracking
*/
public setSessionMonitor(monitor: SessionMonitor): void {
this.sessionMonitor = monitor;
}
/**
* Setup terminal resize detection for when the hosting terminal is resized
*/
@ -708,6 +717,11 @@ export class PtyManager extends EventEmitter {
ptyProcess.onData((data: string) => {
let processedData = data;
// Track PTY output in SessionMonitor for activity and bell detection
if (this.sessionMonitor) {
this.sessionMonitor.trackPtyOutput(session.id, data);
}
// If title mode is not NONE, filter out any title sequences the process might
// have written to the stream.
if (session.titleMode !== undefined && session.titleMode !== TitleMode.NONE) {
@ -723,6 +737,16 @@ export class PtyManager extends EventEmitter {
if (activity.specificStatus?.status !== session.lastActivityStatus) {
session.lastActivityStatus = activity.specificStatus?.status;
this.markTitleUpdateNeeded(session);
// Update SessionMonitor with activity change
if (this.sessionMonitor) {
const isActive = activity.specificStatus?.status === 'working';
this.sessionMonitor.updateSessionActivity(
session.id,
isActive,
activity.specificStatus?.app
);
}
}
}
@ -2456,6 +2480,11 @@ export class PtyManager extends EventEmitter {
session.currentCommand = commandProc.command;
session.commandStartTime = Date.now();
// Update SessionMonitor with new command
if (this.sessionMonitor) {
this.sessionMonitor.updateCommand(session.id, commandProc.command);
}
// Special logging for Claude commands
const isClaudeCommand = commandProc.command.toLowerCase().includes('claude');
if (isClaudeCommand) {