Add SSE connection status indicator to notification settings

- Show real-time connection status with colored indicator
- Display 'Event Stream: Connected/Disconnected' on separate row
- Add warning message when notifications enabled but connection is down
- Update connection status via NotificationCenter events
- Add debugging logs to EventSource for better troubleshooting
This commit is contained in:
Peter Steinberger 2025-07-28 18:06:02 +02:00
parent 5083cb5c1e
commit dad2f3380e
13 changed files with 261 additions and 116 deletions

View file

@ -2,7 +2,9 @@
"permissions": { "permissions": {
"allow": [ "allow": [
"Bash(pnpm test:*)", "Bash(pnpm test:*)",
"Bash(rg:*)" "Bash(rg:*)",
"Bash(./scripts/vtlog.sh:*)",
"Bash(ls:*)"
], ],
"deny": [] "deny": []
}, },

View file

@ -282,13 +282,19 @@ VibeTunnel includes a powerful log viewing utility for debugging and monitoring:
**Common usage**: **Common usage**:
```bash ```bash
./scripts/vtlog.sh -f # Follow logs in real-time # View recent logs (RECOMMENDED for debugging):
./scripts/vtlog.sh -n 200 # Show last 200 lines ./scripts/vtlog.sh -n 200 # Show last 200 lines
./scripts/vtlog.sh -n 500 -s "error" # Search last 500 lines for "error"
./scripts/vtlog.sh -e # Show only errors ./scripts/vtlog.sh -e # Show only errors
./scripts/vtlog.sh -c ServerManager # Show logs from specific component ./scripts/vtlog.sh -c ServerManager # Show logs from specific component
./scripts/vtlog.sh --server -e # Show server errors ./scripts/vtlog.sh --server -e # Show server errors
# Follow logs in real-time (AVOID in Claude Code - causes timeouts):
./scripts/vtlog.sh -f # Follow logs in real-time - DO NOT USE for checking existing logs
``` ```
**Important for Claude Code**: Always use `-n` to check a specific number of recent log lines. Do NOT use `-f` (follow mode) as it will block and timeout after 2 minutes. Follow mode is only useful when monitoring logs in a separate terminal while performing actions.
**Log prefixes**: **Log prefixes**:
- `[FE]` - Frontend (browser) logs - `[FE]` - Frontend (browser) logs
- `[SRV]` - Server-side logs from Node.js/Bun - `[SRV]` - Server-side logs from Node.js/Bun

View file

@ -13,6 +13,10 @@ extension Notification.Name {
// MARK: - Welcome // MARK: - Welcome
static let showWelcomeScreen = Notification.Name("showWelcomeScreen") static let showWelcomeScreen = Notification.Name("showWelcomeScreen")
// MARK: - Services
static let notificationServiceConnectionChanged = Notification.Name("notificationServiceConnectionChanged")
} }
/// Notification categories for user notifications. /// Notification categories for user notifications.

View file

@ -72,6 +72,11 @@ final class BunServer {
return localAuthToken return localAuthToken
} }
/// Get the current authentication mode
var authMode: String {
AuthConfig.current().mode
}
// MARK: - Initialization // MARK: - Initialization
init() { init() {

View file

@ -121,6 +121,7 @@ final class EventSource: NSObject {
} }
// Dispatch event // Dispatch event
logger.debug("🎯 Dispatching event - type: \(event.event ?? "default"), data: \(event.data ?? "none")")
DispatchQueue.main.async { DispatchQueue.main.async {
self.onMessage?(event) self.onMessage?(event)
} }
@ -201,8 +202,12 @@ extension EventSource: URLSessionDataDelegate {
} }
func urlSession(_ session: URLSession, dataTask: URLSessionDataTask, didReceive data: Data) { func urlSession(_ session: URLSession, dataTask: URLSessionDataTask, didReceive data: Data) {
guard let text = String(data: data, encoding: .utf8) else { return } guard let text = String(data: data, encoding: .utf8) else {
logger.error("Failed to decode data as UTF-8")
return
}
logger.debug("📨 EventSource received data: \(text)")
buffer += text buffer += text
processBuffer() processBuffer()
} }

View file

@ -21,6 +21,9 @@ final class NotificationService: NSObject {
private var isConnected = false private var isConnected = false
private var recentlyNotifiedSessions = Set<String>() private var recentlyNotifiedSessions = Set<String>()
private var notificationCleanupTimer: Timer? private var notificationCleanupTimer: Timer?
/// Public property to check SSE connection status
var isSSEConnected: Bool { isConnected }
/// Notification types that can be enabled/disabled /// Notification types that can be enabled/disabled
struct NotificationPreferences { struct NotificationPreferences {
@ -63,14 +66,50 @@ final class NotificationService: NSObject {
/// Start monitoring server events /// Start monitoring server events
func start() async { func start() async {
logger.info("🚀 NotificationService.start() called")
guard serverManager.isRunning else { guard serverManager.isRunning else {
logger.warning("🔴 Server not running, cannot start notification service") logger.warning("🔴 Server not running, cannot start notification service")
return return
} }
logger.info("🔔 Starting notification service...") logger.info("🔔 Starting notification service - server is running on port \(self.serverManager.port)")
connect() // Wait for Unix socket to be ready before connecting SSE
// This ensures the server is fully ready to accept connections
await MainActor.run {
waitForUnixSocketAndConnect()
}
}
/// Wait for Unix socket ready notification then connect
private func waitForUnixSocketAndConnect() {
logger.info("⏳ Waiting for Unix socket ready notification...")
// Check if Unix socket is already connected
if SharedUnixSocketManager.shared.isConnected {
logger.info("✅ Unix socket already connected, connecting to SSE immediately")
connect()
return
}
// Listen for Unix socket ready notification
NotificationCenter.default.addObserver(
forName: SharedUnixSocketManager.unixSocketReadyNotification,
object: nil,
queue: .main
) { [weak self] _ in
Task { @MainActor [weak self] in
self?.logger.info("✅ Unix socket ready notification received, connecting to SSE")
self?.connect()
// Remove observer after first notification to prevent duplicate connections
NotificationCenter.default.removeObserver(
self as Any,
name: SharedUnixSocketManager.unixSocketReadyNotification,
object: nil
)
}
}
} }
/// Stop monitoring server events /// Stop monitoring server events
@ -372,60 +411,49 @@ final class NotificationService: NSObject {
// MARK: - Private Methods // MARK: - Private Methods
private func setupNotifications() { private func setupNotifications() {
// Listen for server state changes // Note: We do NOT listen for server state changes here
NotificationCenter.default.addObserver( // Connection is managed explicitly via start() and stop() methods
self, // This prevents dual-path connection attempts
selector: #selector(serverStateChanged),
name: .serverStateChanged,
object: nil
)
} }
@objc
private func serverStateChanged(_ notification: Notification) {
if serverManager.isRunning {
logger.info("🔔 Server started, initializing notification service...")
// Delay connection to ensure server is ready
Task {
try? await Task.sleep(nanoseconds: 2_000_000_000) // 2 seconds (increased delay)
await MainActor.run {
if serverManager.isRunning {
logger.info("🔔 Server ready, connecting notification service...")
connect()
} else {
logger.warning("🔴 Server stopped before notification service could connect")
}
}
}
} else {
logger.info("🔔 Server stopped, disconnecting notification service...")
disconnect()
}
}
private func connect() { private func connect() {
logger.info("🔌 NotificationService.connect() called - isConnected: \(self.isConnected)")
guard !isConnected else { guard !isConnected else {
logger.info("Already connected to notification service") logger.info("Already connected to notification service")
return return
} }
guard let authToken = serverManager.localAuthToken else { // When auth mode is "none", we can connect without a token.
logger.error("No auth token available for notification service") // In any other auth mode, a token is required for the local Mac app to connect.
if serverManager.authMode != "none", serverManager.localAuthToken == nil {
logger.error("No auth token available for notification service in auth mode '\(self.serverManager.authMode)'")
return return
} }
guard let url = URL(string: "http://localhost:\(serverManager.port)/api/events") else { let eventsURL = "http://localhost:\(self.serverManager.port)/api/events"
logger.error("Invalid events URL") logger.info("📡 Attempting to connect to SSE endpoint: \(eventsURL)")
guard let url = URL(string: eventsURL) else {
logger.error("Invalid events URL: \(eventsURL)")
return return
} }
// Create headers // Create headers
var headers: [String: String] = [ var headers: [String: String] = [
"Authorization": "Bearer \(authToken)",
"Accept": "text/event-stream", "Accept": "text/event-stream",
"Cache-Control": "no-cache" "Cache-Control": "no-cache"
] ]
// Add authorization header if auth token is available.
// When auth mode is "none", there's no token, and that's okay.
if let authToken = serverManager.localAuthToken {
headers["Authorization"] = "Bearer \(authToken)"
logger.info("🔑 Using auth token for SSE connection")
} else {
logger.info("🔓 Connecting to SSE without an auth token (auth mode: '\(self.serverManager.authMode)')")
}
// Add custom header to indicate this is the Mac app // Add custom header to indicate this is the Mac app
headers["X-VibeTunnel-Client"] = "mac-app" headers["X-VibeTunnel-Client"] = "mac-app"
@ -435,6 +463,8 @@ final class NotificationService: NSObject {
Task { @MainActor in Task { @MainActor in
self?.logger.info("✅ Connected to notification event stream") self?.logger.info("✅ Connected to notification event stream")
self?.isConnected = true self?.isConnected = true
// Post notification for UI update
NotificationCenter.default.post(name: .notificationServiceConnectionChanged, object: nil)
} }
} }
@ -444,12 +474,15 @@ final class NotificationService: NSObject {
self?.logger.error("❌ EventSource error: \(error)") self?.logger.error("❌ EventSource error: \(error)")
} }
self?.isConnected = false self?.isConnected = false
// Post notification for UI update
NotificationCenter.default.post(name: .notificationServiceConnectionChanged, object: nil)
// Don't reconnect here - let server state changes trigger reconnection // Don't reconnect here - let server state changes trigger reconnection
} }
} }
eventSource?.onMessage = { [weak self] event in eventSource?.onMessage = { [weak self] event in
Task { @MainActor in Task { @MainActor in
self?.logger.info("🎯 EventSource onMessage fired! Event type: \(event.event ?? "default"), Has data: \(event.data != nil)")
self?.handleEvent(event) self?.handleEvent(event)
} }
} }
@ -462,12 +495,19 @@ final class NotificationService: NSObject {
eventSource = nil eventSource = nil
isConnected = false isConnected = false
logger.info("Disconnected from notification service") logger.info("Disconnected from notification service")
// Post notification for UI update
NotificationCenter.default.post(name: .notificationServiceConnectionChanged, object: nil)
} }
private func handleEvent(_ event: Event) { private func handleEvent(_ event: Event) {
guard let data = event.data else { return } guard let data = event.data else {
logger.warning("Received event with no data")
return
}
logger.debug("📨 Received event: \(data)") // Log event details for debugging
logger.debug("📨 Received SSE event - Type: \(event.event ?? "message"), ID: \(event.id ?? "none")")
logger.debug("📨 Event data: \(data)")
do { do {
guard let jsonData = data.data(using: .utf8) else { guard let jsonData = data.data(using: .utf8) else {
@ -757,27 +797,63 @@ final class NotificationService: NSObject {
func sendServerTestNotification() async { func sendServerTestNotification() async {
logger.info("🧪 Sending test notification through server...") logger.info("🧪 Sending test notification through server...")
guard let url = serverManager.buildURL(endpoint: "/api/test-notification") else { // Check if server is running
logger.error("Failed to build test notification URL") guard serverManager.isRunning else {
logger.error("❌ Cannot send test notification - server is not running")
return return
} }
// If not connected to SSE, try to connect first
if !isConnected {
logger.warning("⚠️ Not connected to SSE endpoint, attempting to connect...")
await MainActor.run {
connect()
}
// Give it a moment to connect
try? await Task.sleep(nanoseconds: 500_000_000) // 0.5 seconds
}
// Log server info
logger.info("Server info - Port: \(self.serverManager.port), Running: \(self.serverManager.isRunning), SSE Connected: \(self.isConnected)")
guard let url = serverManager.buildURL(endpoint: "/api/test-notification") else {
logger.error("❌ Failed to build test notification URL")
return
}
logger.info("📤 Sending POST request to: \(url)")
var request = URLRequest(url: url) var request = URLRequest(url: url)
request.httpMethod = "POST" request.httpMethod = "POST"
request.setValue("application/json", forHTTPHeaderField: "Content-Type") request.setValue("application/json", forHTTPHeaderField: "Content-Type")
// Add auth token if available
if let authToken = serverManager.localAuthToken {
request.setValue("Bearer \(authToken)", forHTTPHeaderField: "Authorization")
logger.debug("Added auth token to request")
}
do { do {
let (_, response) = try await URLSession.shared.data(for: request) let (data, response) = try await URLSession.shared.data(for: request)
if let httpResponse = response as? HTTPURLResponse { if let httpResponse = response as? HTTPURLResponse {
logger.info("📥 Received response - Status: \(httpResponse.statusCode)")
if httpResponse.statusCode == 200 { if httpResponse.statusCode == 200 {
logger.info("✅ Server test notification sent successfully") logger.info("✅ Server test notification sent successfully")
if let responseData = String(data: data, encoding: .utf8) {
logger.debug("Response data: \(responseData)")
}
} else { } else {
logger.error("❌ Server test notification failed with status: \(httpResponse.statusCode)") logger.error("❌ Server test notification failed with status: \(httpResponse.statusCode)")
if let errorData = String(data: data, encoding: .utf8) {
logger.error("Error response: \(errorData)")
}
} }
} }
} catch { } catch {
logger.error("❌ Failed to send server test notification: \(error)") logger.error("❌ Failed to send server test notification: \(error)")
logger.error("Error details: \(error.localizedDescription)")
} }
} }
@ -788,8 +864,3 @@ final class NotificationService: NSObject {
} }
} }
// MARK: - Extensions
extension Notification.Name {
static let serverStateChanged = Notification.Name("ServerStateChanged")
}

View file

@ -65,6 +65,11 @@ class ServerManager {
bunServer?.localToken bunServer?.localToken
} }
/// The current authentication mode of the server
var authMode: String {
bunServer?.authMode ?? "os"
}
var bindAddress: String { var bindAddress: String {
get { get {
// Get the raw value from UserDefaults, defaulting to the app default // Get the raw value from UserDefaults, defaulting to the app default
@ -309,8 +314,7 @@ class ServerManager {
isRunning = false isRunning = false
// Post notification that server state has changed // Notification service connection is now handled explicitly via start() method
NotificationCenter.default.post(name: .serverStateChanged, object: nil)
// Clear the auth token from SessionMonitor // Clear the auth token from SessionMonitor
SessionMonitor.shared.setLocalAuthToken(nil) SessionMonitor.shared.setLocalAuthToken(nil)

View file

@ -22,6 +22,10 @@ final class SharedUnixSocketManager {
private init() { private init() {
logger.info("🚀 SharedUnixSocketManager initialized") logger.info("🚀 SharedUnixSocketManager initialized")
} }
// MARK: - Notifications
static let unixSocketReadyNotification = Notification.Name("unixSocketReady")
// MARK: - Public Methods // MARK: - Public Methods
@ -41,10 +45,36 @@ final class SharedUnixSocketManager {
self?.distributeMessage(data) self?.distributeMessage(data)
} }
} }
// Set up state change handler to notify when socket is ready
socket.onStateChange = { [weak self] state in
Task { @MainActor [weak self] in
self?.handleSocketStateChange(state)
}
}
unixSocket = socket unixSocket = socket
return socket return socket
} }
/// Handle socket state changes and notify when ready
private func handleSocketStateChange(_ state: UnixSocketConnection.ConnectionState) {
switch state {
case .ready:
logger.info("🚀 Unix socket is ready, posting notification")
NotificationCenter.default.post(name: Self.unixSocketReadyNotification, object: nil)
case .failed(let error):
logger.error("❌ Unix socket connection failed: \(error)")
case .cancelled:
logger.info("🛑 Unix socket connection cancelled")
case .preparing:
logger.debug("🔄 Unix socket is preparing connection")
case .setup:
logger.debug("🔧 Unix socket is in setup state")
case .waiting(let error):
logger.warning("⏳ Unix socket is waiting: \(error)")
}
}
/// Check if the shared connection is connected /// Check if the shared connection is connected
var isConnected: Bool { var isConnected: Bool {

View file

@ -14,6 +14,7 @@ struct NotificationSettingsView: View {
@State private var isTestingNotification = false @State private var isTestingNotification = false
@State private var showingPermissionAlert = false @State private var showingPermissionAlert = false
@State private var sseConnectionStatus = false
private func updateNotificationPreferences() { private func updateNotificationPreferences() {
// Load current preferences from ConfigManager and notify the service // Load current preferences from ConfigManager and notify the service
@ -62,6 +63,36 @@ struct NotificationSettingsView: View {
Text("Display native macOS notifications for session and command events") Text("Display native macOS notifications for session and command events")
.font(.caption) .font(.caption)
.foregroundStyle(.secondary) .foregroundStyle(.secondary)
// SSE Connection Status Row
HStack(spacing: 6) {
Circle()
.fill(sseConnectionStatus ? Color.green : Color.red)
.frame(width: 8, height: 8)
Text("Event Stream:")
.font(.caption)
.foregroundStyle(.secondary)
Text(sseConnectionStatus ? "Connected" : "Disconnected")
.font(.caption)
.foregroundStyle(sseConnectionStatus ? .green : .red)
.fontWeight(.medium)
Spacer()
}
.help(sseConnectionStatus
? "Real-time notification stream is connected"
: "Real-time notification stream is disconnected. Check if the server is running.")
// Show warning when disconnected
if showNotifications && !sseConnectionStatus {
HStack(spacing: 6) {
Image(systemName: "exclamationmark.triangle.fill")
.foregroundStyle(.yellow)
.font(.caption)
Text("Real-time notifications are unavailable. The server connection may be down.")
.font(.caption)
.foregroundStyle(.secondary)
}
}
} }
.padding(.vertical, 8) .padding(.vertical, 8)
} }
@ -192,6 +223,14 @@ struct NotificationSettingsView: View {
.onAppear { .onAppear {
// Sync the AppStorage value with ConfigManager on first load // Sync the AppStorage value with ConfigManager on first load
showNotifications = configManager.notificationsEnabled showNotifications = configManager.notificationsEnabled
// Update initial connection status
sseConnectionStatus = notificationService.isSSEConnected
}
.onReceive(NotificationCenter.default.publisher(for: .notificationServiceConnectionChanged)) { _ in
// Update connection status when it changes
sseConnectionStatus = notificationService.isSSEConnected
logger.debug("SSE connection status changed: \(sseConnectionStatus)")
} }
} }
.alert("Notification Permission Required", isPresented: $showingPermissionAlert) { .alert("Notification Permission Required", isPresented: $showingPermissionAlert) {

View file

@ -204,9 +204,19 @@ export class DirectKeyboardManager {
// Focus synchronously - critical for iOS Safari // Focus synchronously - critical for iOS Safari
this.hiddenInput.focus(); this.hiddenInput.focus();
// Also click synchronously to help trigger keyboard // Set a dummy value and select it to help trigger iOS keyboard
this.hiddenInput.click(); // This helps iOS recognize that we want to show the keyboard
logger.log('Focused and clicked hidden input synchronously'); this.hiddenInput.value = ' ';
this.hiddenInput.setSelectionRange(0, 1);
// Clear the dummy value after a short delay
setTimeout(() => {
if (this.hiddenInput) {
this.hiddenInput.value = '';
}
}, 50);
logger.log('Focused hidden input with dummy value trick');
} }
} }

View file

@ -109,17 +109,9 @@ export class TerminalQuickKeys extends LitElement {
this.isLandscape = window.innerWidth > window.innerHeight && window.innerWidth > 600; this.isLandscape = window.innerWidth > window.innerHeight && window.innerWidth > 600;
} }
private getButtonSizeClass(label: string): string { private getButtonSizeClass(_label: string): string {
if (label.length >= 4) { // Use flexible sizing without constraining width
// Long text: compact with max-width constraint return this.isLandscape ? 'px-1 py-1' : 'px-1.5 py-1.5';
return this.isLandscape ? 'px-0.5 py-1 flex-1 max-w-14' : 'px-0.5 py-1.5 flex-1 max-w-16';
} else if (label.length === 3) {
// Medium text: slightly more padding, larger max-width
return this.isLandscape ? 'px-1 py-1 flex-1 max-w-16' : 'px-1 py-1.5 flex-1 max-w-18';
} else {
// Short text: can grow freely
return this.isLandscape ? 'px-1 py-1 flex-1' : 'px-1 py-1.5 flex-1';
}
} }
private getButtonFontClass(label: string): string { private getButtonFontClass(label: string): string {
@ -210,7 +202,7 @@ export class TerminalQuickKeys extends LitElement {
} }
if (this.onKeyPress) { if (this.onKeyPress) {
this.onKeyPress(key, isModifier, isSpecial); this.onKeyPress(key, isModifier, isSpecial, isToggle);
} }
} }
@ -233,7 +225,7 @@ export class TerminalQuickKeys extends LitElement {
// Send first key immediately // Send first key immediately
if (this.onKeyPress) { if (this.onKeyPress) {
this.onKeyPress(key, isModifier, isSpecial); this.onKeyPress(key, isModifier, isSpecial, false);
} }
// Start repeat after 500ms initial delay // Start repeat after 500ms initial delay
@ -279,31 +271,20 @@ export class TerminalQuickKeys extends LitElement {
position: fixed; position: fixed;
left: 0; left: 0;
right: 0; right: 0;
/* Default to bottom of screen */
bottom: 0; bottom: 0;
z-index: 999999; z-index: 999999;
/* Ensure it stays on top */
isolation: isolate;
/* Smooth transition when keyboard appears/disappears */
transition: bottom 0.3s ease-out;
background-color: rgb(var(--color-bg-secondary)); background-color: rgb(var(--color-bg-secondary));
/* Prevent horizontal overflow */
width: 100%; width: 100%;
max-width: 100vw; /* Properly handle safe areas */
overflow-x: hidden; padding-left: env(safe-area-inset-left);
padding-right: env(safe-area-inset-right);
} }
/* The actual bar with buttons */ /* The actual bar with buttons */
.quick-keys-bar { .quick-keys-bar {
background: rgb(var(--color-bg-secondary)); background: rgb(var(--color-bg-secondary));
border-top: 1px solid rgb(var(--color-border-base)); border-top: 1px solid rgb(var(--color-border-base));
padding: 0.25rem 0.25rem; padding: 0.25rem 0;
/* Prevent iOS from adding its own styling */
-webkit-appearance: none;
appearance: none;
/* Add shadow for visibility */
box-shadow: 0 -2px 10px rgb(var(--color-bg-secondary) / 0.5);
/* Ensure proper width */
width: 100%; width: 100%;
box-sizing: border-box; box-sizing: border-box;
} }
@ -314,8 +295,6 @@ export class TerminalQuickKeys extends LitElement {
-webkit-tap-highlight-color: transparent; -webkit-tap-highlight-color: transparent;
user-select: none; user-select: none;
-webkit-user-select: none; -webkit-user-select: none;
/* Ensure buttons are clickable */
touch-action: manipulation;
} }
/* Modifier key styling */ /* Modifier key styling */
@ -360,20 +339,6 @@ export class TerminalQuickKeys extends LitElement {
font-size: 8px; font-size: 8px;
} }
/* Max width constraints for buttons */
.max-w-14 {
max-width: 3.5rem; /* 56px */
}
.max-w-16 {
max-width: 4rem; /* 64px */
}
.max-w-18 {
max-width: 4.5rem; /* 72px */
}
/* Combo key styling (like ^C, ^Z) */ /* Combo key styling (like ^C, ^Z) */
.combo-key { .combo-key {
background-color: rgb(var(--color-bg-tertiary)); background-color: rgb(var(--color-bg-tertiary));
@ -401,7 +366,6 @@ export class TerminalQuickKeys extends LitElement {
-webkit-tap-highlight-color: transparent; -webkit-tap-highlight-color: transparent;
user-select: none; user-select: none;
-webkit-user-select: none; -webkit-user-select: none;
touch-action: manipulation;
} }
/* Toggle button styling */ /* Toggle button styling */
@ -430,15 +394,8 @@ export class TerminalQuickKeys extends LitElement {
-webkit-tap-highlight-color: transparent; -webkit-tap-highlight-color: transparent;
user-select: none; user-select: none;
-webkit-user-select: none; -webkit-user-select: none;
touch-action: manipulation;
} }
/* Landscape mode adjustments */
@media (orientation: landscape) and (max-width: 926px) {
.quick-keys-bar {
padding: 0.2rem 0.2rem;
}
}
</style> </style>
`; `;
} }
@ -455,7 +412,7 @@ export class TerminalQuickKeys extends LitElement {
> >
<div class="quick-keys-bar"> <div class="quick-keys-bar">
<!-- Row 1 --> <!-- Row 1 -->
<div class="flex gap-0.5 justify-center mb-0.5 flex-wrap"> <div class="flex gap-0.5 mb-0.5 overflow-x-auto scrollbar-hide px-0.5">
${TERMINAL_QUICK_KEYS.filter((k) => k.row === 1).map( ${TERMINAL_QUICK_KEYS.filter((k) => k.row === 1).map(
({ key, label, modifier, arrow, toggle }) => html` ({ key, label, modifier, arrow, toggle }) => html`
<button <button
@ -507,7 +464,7 @@ export class TerminalQuickKeys extends LitElement {
this.showCtrlKeys this.showCtrlKeys
? html` ? html`
<!-- Ctrl shortcuts row --> <!-- Ctrl shortcuts row -->
<div class="flex gap-0.5 justify-center flex-wrap mb-0.5"> <div class="flex gap-0.5 mb-0.5 overflow-x-auto scrollbar-hide px-0.5">
${CTRL_SHORTCUTS.map( ${CTRL_SHORTCUTS.map(
({ key, label, combo, special }) => html` ({ key, label, combo, special }) => html`
<button <button
@ -542,7 +499,7 @@ export class TerminalQuickKeys extends LitElement {
: this.showFunctionKeys : this.showFunctionKeys
? html` ? html`
<!-- Function keys row --> <!-- Function keys row -->
<div class="flex gap-0.5 justify-center flex-wrap mb-0.5"> <div class="flex gap-0.5 mb-0.5 overflow-x-auto scrollbar-hide px-0.5">
${FUNCTION_KEYS.map( ${FUNCTION_KEYS.map(
({ key, label }) => html` ({ key, label }) => html`
<button <button
@ -576,7 +533,7 @@ export class TerminalQuickKeys extends LitElement {
` `
: html` : html`
<!-- Regular row 2 --> <!-- Regular row 2 -->
<div class="flex gap-0.5 justify-center mb-0.5 flex-wrap"> <div class="flex gap-0.5 mb-0.5 overflow-x-auto scrollbar-hide px-0.5">
${TERMINAL_QUICK_KEYS.filter((k) => k.row === 2).map( ${TERMINAL_QUICK_KEYS.filter((k) => k.row === 2).map(
({ key, label, modifier, combo, special, toggle }) => html` ({ key, label, modifier, combo, special, toggle }) => html`
<button <button
@ -597,12 +554,12 @@ export class TerminalQuickKeys extends LitElement {
if (key === 'Paste') { if (key === 'Paste') {
this.handlePasteImmediate(e); this.handlePasteImmediate(e);
} else { } else {
this.handleKeyPress(key, modifier || combo, special, toggle, e); this.handleKeyPress(key, modifier || combo, special, false, e);
} }
}} }}
@click=${(e: MouseEvent) => { @click=${(e: MouseEvent) => {
if (e.detail !== 0) { if (e.detail !== 0) {
this.handleKeyPress(key, modifier || combo, special, toggle, e); this.handleKeyPress(key, modifier || combo, special, false, e);
} }
}} }}
> >
@ -615,7 +572,7 @@ export class TerminalQuickKeys extends LitElement {
} }
<!-- Row 3 - Additional special characters (always visible) --> <!-- Row 3 - Additional special characters (always visible) -->
<div class="flex gap-0.5 justify-center flex-wrap"> <div class="flex gap-0.5 overflow-x-auto scrollbar-hide px-0.5">
${TERMINAL_QUICK_KEYS.filter((k) => k.row === 3).map( ${TERMINAL_QUICK_KEYS.filter((k) => k.row === 3).map(
({ key, label, modifier, combo, special }) => html` ({ key, label, modifier, combo, special }) => html`
<button <button

View file

@ -473,6 +473,17 @@
/* Micro-interactions and animations */ /* Micro-interactions and animations */
@layer utilities { @layer utilities {
/* Hide scrollbar for webkit browsers */
.scrollbar-hide::-webkit-scrollbar {
display: none;
}
/* Hide scrollbar for Firefox */
.scrollbar-hide {
-ms-overflow-style: none; /* IE and Edge */
scrollbar-width: none; /* Firefox */
}
/* Smooth transitions for interactive elements */ /* Smooth transitions for interactive elements */
.interactive { .interactive {
@apply transition-all duration-200 ease-out; @apply transition-all duration-200 ease-out;

View file

@ -40,9 +40,9 @@ export function createEventsRouter(sessionMonitor?: SessionMonitor): Router {
} }
}; };
// Send initial connection event // Send initial connection event as default message event
try { try {
res.write('event: connected\ndata: {"type": "connected"}\n\n'); res.write('data: {"type": "connected"}\n\n');
} catch (error) { } catch (error) {
logger.debug('Failed to send initial connection event:', error); logger.debug('Failed to send initial connection event:', error);
return; return;
@ -69,8 +69,9 @@ export function createEventsRouter(sessionMonitor?: SessionMonitor): Router {
logger.info('🧪 Forwarding test notification through SSE:', event); logger.info('🧪 Forwarding test notification through SSE:', event);
} }
// Proper SSE format with id, event, and data fields // Send as default message event (not named event) for compatibility with Mac EventSource
const sseMessage = `id: ${++eventId}\nevent: ${event.type}\ndata: ${JSON.stringify(event)}\n\n`; // The event type is already included in the data payload
const sseMessage = `id: ${++eventId}\ndata: ${JSON.stringify(event)}\n\n`;
try { try {
res.write(sseMessage); res.write(sseMessage);