diff --git a/README.md b/README.md index 87061a48..83a0331e 100644 --- a/README.md +++ b/README.md @@ -675,6 +675,19 @@ When developing the web interface, you often need to test changes on external de - **Firewall**: macOS may prompt to allow incoming connections - click "Allow" - **Auto-rebuild**: Changes to the web code are automatically rebuilt, but you need to manually refresh the browser +##### Pasting on Mobile Devices + +When using VibeTunnel on mobile browsers (Safari, Chrome), pasting works differently than on desktop: + +**To paste on mobile:** +1. Press the paste button on the keyboard toolbar +2. A white input box will appear +3. Long-press inside the white box to bring up the paste menu +4. Select "Paste" from the menu +5. The text will be pasted into your terminal session + +**Note**: Due to browser security restrictions on non-HTTPS connections, the paste API is limited on mobile devices. The white input box is a workaround that allows clipboard access through the browser's native paste functionality. + #### Future: Hot Module Replacement For true hot module replacement without manual refresh, see our [Vite migration plan](docs/vite-plan.md) which would provide: diff --git a/mac/VibeTunnel/Presentation/Views/Welcome/ControlAgentArmyPageView.swift b/mac/VibeTunnel/Presentation/Views/Welcome/ControlAgentArmyPageView.swift index b7375531..cd95cca2 100644 --- a/mac/VibeTunnel/Presentation/Views/Welcome/ControlAgentArmyPageView.swift +++ b/mac/VibeTunnel/Presentation/Views/Welcome/ControlAgentArmyPageView.swift @@ -52,14 +52,20 @@ struct ControlAgentArmyPageView: View { .cornerRadius(6) } - Text( - "Session titles appear in the menu bar and terminal windows.\nUse the dashboard to rename sessions manually, or use the magic wand with Claude/Gemini." - ) - .font(.caption) - .foregroundColor(.secondary) - .multilineTextAlignment(.center) - .frame(maxWidth: 420) - .fixedSize(horizontal: false, vertical: true) + VStack(spacing: 8) { + Text( + "Session titles appear in the menu bar and terminal windows.\nUse the dashboard to rename sessions manually, or use the magic wand with Claude/Gemini." + ) + .font(.caption) + .foregroundColor(.secondary) + .multilineTextAlignment(.center) + .frame(maxWidth: 420) + .fixedSize(horizontal: false, vertical: true) + + Link("Learn more", destination: URL(string: "https://steipete.me/posts/command-your-claude-code-army-reloaded")!) + .font(.caption) + .foregroundColor(.accentColor) + } } .padding(.vertical, 12) } diff --git a/mac/VibeTunnel/Presentation/Views/Welcome/ProjectFolderPageView.swift b/mac/VibeTunnel/Presentation/Views/Welcome/ProjectFolderPageView.swift index 53270447..53623c50 100644 --- a/mac/VibeTunnel/Presentation/Views/Welcome/ProjectFolderPageView.swift +++ b/mac/VibeTunnel/Presentation/Views/Welcome/ProjectFolderPageView.swift @@ -20,122 +20,85 @@ struct ProjectFolderPageView: View { } var body: some View { - VStack(spacing: 24) { - // Title and description - VStack(spacing: 12) { + VStack(spacing: 30) { + VStack(spacing: 16) { Text("Choose Your Project Folder") - .font(.system(size: 24, weight: .semibold)) - .foregroundColor(.primary) + .font(.largeTitle) + .fontWeight(.semibold) Text( "Select the folder where you keep your projects. VibeTunnel will use this for quick access and repository discovery." ) - .font(.system(size: 14)) + .font(.body) .foregroundColor(.secondary) .multilineTextAlignment(.center) - .frame(maxWidth: 400) - } + .frame(maxWidth: 480) + .fixedSize(horizontal: false, vertical: true) - // Folder picker section - VStack(alignment: .leading, spacing: 12) { - Text("Project Folder") - .font(.system(size: 13, weight: .medium)) - .foregroundColor(.secondary) + // Folder and repository section + VStack(spacing: 16) { + // Folder picker + HStack { + Text(selectedPath.isEmpty ? "~/" : selectedPath) + .font(.system(size: 13)) + .foregroundColor(selectedPath.isEmpty ? .secondary : .primary) + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.horizontal, 10) + .padding(.vertical, 6) + .background(Color(NSColor.controlBackgroundColor)) + .cornerRadius(6) - HStack { - Text(selectedPath.isEmpty ? "~/" : selectedPath) - .font(.system(size: 13)) - .foregroundColor(selectedPath.isEmpty ? .secondary : .primary) - .frame(maxWidth: .infinity, alignment: .leading) - .padding(.horizontal, 10) - .padding(.vertical, 6) - .background(Color(NSColor.controlBackgroundColor)) - .cornerRadius(6) - - Button("Choose...") { - showFolderPicker() + Button("Choose...") { + showFolderPicker() + } + .buttonStyle(.bordered) } - .buttonStyle(.bordered) - } + .frame(width: 350) - // Repository preview - if !selectedPath.isEmpty { - VStack(alignment: .leading, spacing: 8) { + // Repository count + if !selectedPath.isEmpty { HStack { - Text("Discovered Repositories") - .font(.system(size: 12, weight: .medium)) + Image(systemName: "folder.badge.gearshape") + .font(.system(size: 12)) .foregroundColor(.secondary) - + if isScanning { - ProgressView() - .scaleEffect(0.5) - .frame(width: 16, height: 16) + Text("Scanning...") + .font(.system(size: 12)) + .foregroundColor(.secondary) + } else if discoveredRepos.isEmpty { + Text("No repositories found") + .font(.system(size: 12)) + .foregroundColor(.secondary) + } else { + Text("\(discoveredRepos.count) repositor\(discoveredRepos.count == 1 ? "y" : "ies") found") + .font(.system(size: 12)) + .foregroundColor(.primary) } - + Spacer() } - - ScrollView { - VStack(alignment: .leading, spacing: 4) { - if discoveredRepos.isEmpty && !isScanning { - Text("No repositories found in this folder") - .font(.system(size: 11)) - .foregroundColor(.secondary) - .italic() - .padding(.vertical, 8) - } else { - ForEach(discoveredRepos) { repo in - HStack { - Image(systemName: "folder.badge.gearshape") - .font(.system(size: 11)) - .foregroundColor(.secondary) - - Text(repo.name) - .font(.system(size: 11)) - .lineLimit(1) - - Spacer() - } - .padding(.vertical, 2) - } - } - } - } - .frame(maxHeight: 100) - .padding(8) + .padding(.horizontal, 10) + .padding(.vertical, 6) .background(Color(NSColor.controlBackgroundColor).opacity(0.5)) .cornerRadius(6) + .frame(width: 350) } + // Tip + HStack(alignment: .top, spacing: 6) { + Text("You can change this later in Settings → Application → Repository") + .font(.system(size: 11)) + .foregroundColor(.secondary) + .multilineTextAlignment(.leading) + .fixedSize(horizontal: false, vertical: true) + } + .frame(maxWidth: 350) + .padding(.top, 8) } } - .frame(maxWidth: 400) - - // Tips - VStack(alignment: .leading, spacing: 8) { - HStack(alignment: .top, spacing: 8) { - Image(systemName: "lightbulb") - .font(.system(size: 12)) - .foregroundColor(.orange) - - Text("You can change this later in Settings → Application → Repository Base Path") - .font(.system(size: 11)) - .foregroundColor(.secondary) - } - - HStack(alignment: .top, spacing: 8) { - Image(systemName: "info.circle") - .font(.system(size: 12)) - .foregroundColor(.blue) - - Text("VibeTunnel will scan up to 3 levels deep for Git repositories") - .font(.system(size: 11)) - .foregroundColor(.secondary) - } - } - .frame(maxWidth: 400) - .padding(.top, 12) + Spacer() } - .padding(.horizontal, 40) + .padding() .onAppear { selectedPath = repositoryBasePath if !selectedPath.isEmpty { @@ -190,7 +153,7 @@ struct ProjectFolderPageView: View { let repos = await findGitRepositories(in: expandedPath, maxDepth: 3) await MainActor.run { - discoveredRepos = repos.prefix(10).map { path in + discoveredRepos = repos.map { path in RepositoryInfo(name: URL(fileURLWithPath: path).lastPathComponent, path: path) } isScanning = false diff --git a/mac/VibeTunnel/Presentation/Views/Welcome/RequestPermissionsPageView.swift b/mac/VibeTunnel/Presentation/Views/Welcome/RequestPermissionsPageView.swift index 4f9ef278..d4b34241 100644 --- a/mac/VibeTunnel/Presentation/Views/Welcome/RequestPermissionsPageView.swift +++ b/mac/VibeTunnel/Presentation/Views/Welcome/RequestPermissionsPageView.swift @@ -73,7 +73,7 @@ struct RequestPermissionsPageView: View { .foregroundColor(.secondary) } .font(.body) - .frame(maxWidth: 250) + .frame(maxWidth: 250, alignment: .leading) .frame(height: 32) } else { Button("Grant Automation Permission") { @@ -93,7 +93,7 @@ struct RequestPermissionsPageView: View { .foregroundColor(.secondary) } .font(.body) - .frame(maxWidth: 250) + .frame(maxWidth: 250, alignment: .leading) .frame(height: 32) } else { Button("Grant Accessibility Permission") { @@ -113,7 +113,7 @@ struct RequestPermissionsPageView: View { .foregroundColor(.secondary) } .font(.body) - .frame(maxWidth: 250) + .frame(maxWidth: 250, alignment: .leading) .frame(height: 32) } else { Button("Grant Screen Recording Permission") { diff --git a/web/src/client/components/screencap-view.ts b/web/src/client/components/screencap-view.ts index 634139ff..51b0eede 100644 --- a/web/src/client/components/screencap-view.ts +++ b/web/src/client/components/screencap-view.ts @@ -1336,7 +1336,7 @@ export class ScreencapView extends LitElement { // Show overlay when not capturing or waiting to start return html` -
+
${ this.status === 'loading' diff --git a/web/src/client/components/unified-settings.ts b/web/src/client/components/unified-settings.ts index fb726b4d..0f33565b 100644 --- a/web/src/client/components/unified-settings.ts +++ b/web/src/client/components/unified-settings.ts @@ -617,10 +617,10 @@ export class UnifiedSettings extends LitElement {
-
+
- -

+ +

${ this.isServerConfigured ? 'This path is synced with the VibeTunnel Mac app' @@ -646,7 +646,7 @@ export class UnifiedSettings extends LitElement { ${ this.isServerConfigured ? html` -

+
{ - logger.log(`System handler: ${message.action}`); + logger.log(`System handler: ${message.action}, type: ${message.type}, id: ${message.id}`); switch (message.action) { case 'repository-path-update': { const payload = message.payload as { path: string }; + logger.log(`Repository path update received: ${JSON.stringify(payload)}`); + if (!payload?.path) { + logger.error('Missing path in payload'); return createControlResponse(message, null, 'Missing path in payload'); } try { // Update the server configuration + logger.log(`Calling updateRepositoryPath with: ${payload.path}`); const updateSuccess = await this.controlUnixHandler.updateRepositoryPath(payload.path); if (updateSuccess) { - logger.log(`Updated repository path to: ${payload.path}`); + logger.log(`Successfully updated repository path to: ${payload.path}`); return createControlResponse(message, { success: true, path: payload.path }); } else { + logger.error('updateRepositoryPath returned false'); return createControlResponse(message, null, 'Failed to update repository path'); } } catch (error) { @@ -566,7 +571,7 @@ export class ControlUnixHandler { private async handleMacMessage(message: ControlMessage) { logger.log( - `Mac message - category: ${message.category}, action: ${message.action}, id: ${message.id}` + `Mac message - category: ${message.category}, action: ${message.action}, type: ${message.type}, id: ${message.id}` ); // Handle ping keep-alive from Mac client @@ -576,6 +581,11 @@ export class ControlUnixHandler { return; } + // Log repository-path-update messages specifically + if (message.category === 'system' && message.action === 'repository-path-update') { + logger.log(`🔍 Repository path update message details:`, JSON.stringify(message)); + } + // Check if this is a response to a pending request if (message.type === 'response' && this.pendingRequests.has(message.id)) { const resolver = this.pendingRequests.get(message.id); @@ -587,6 +597,15 @@ export class ControlUnixHandler { return; } + // Skip processing for response messages that aren't pending requests + // This prevents response loops where error responses get processed again + if (message.type === 'response') { + logger.debug( + `Ignoring response message that has no pending request: ${message.id}, action: ${message.action}` + ); + return; + } + const handler = this.handlers.get(message.category); if (!handler) { logger.warn(`No handler for category: ${message.category}`); @@ -722,16 +741,21 @@ export class ControlUnixHandler { * Update the repository path and notify all connected clients */ async updateRepositoryPath(path: string): Promise { + logger.log(`updateRepositoryPath called with path: ${path}`); + try { this.currentRepositoryPath = path; + logger.log(`Set currentRepositoryPath to: ${this.currentRepositoryPath}`); // Call the callback to update server configuration and broadcast to web clients if (this.configUpdateCallback) { + logger.log('Calling configUpdateCallback...'); this.configUpdateCallback({ repositoryBasePath: path }); + logger.log('configUpdateCallback completed successfully'); return true; } - logger.warn('No config update callback set'); + logger.warn('No config update callback set - is the server initialized?'); return false; } catch (error) { logger.error('Failed to update repository path:', error); diff --git a/web/src/test/unit/control-unix-handler.test.ts b/web/src/test/unit/control-unix-handler.test.ts index 674f60cc..60f91161 100644 --- a/web/src/test/unit/control-unix-handler.test.ts +++ b/web/src/test/unit/control-unix-handler.test.ts @@ -91,4 +91,95 @@ describe('Control Unix Handler', () => { expect(mockCallback).toHaveBeenCalledWith({ repositoryBasePath: '/test/path' }); }); }); + + describe('Mac Message Handling', () => { + it('should process repository-path-update messages from Mac app', async () => { + const mockCallback = vi.fn(); + controlUnixHandler.setConfigUpdateCallback(mockCallback); + + // Simulate Mac sending a repository-path-update message + const message = { + id: 'mac-msg-123', + type: 'request' as const, + category: 'system' as const, + action: 'repository-path-update', + payload: { path: '/Users/test/MacSelectedPath' }, + }; + + // Process the message through the system handler + const systemHandler = (controlUnixHandler as any).handlers.get('system'); + const response = await systemHandler.handleMessage(message); + + // Verify the update was processed + expect(mockCallback).toHaveBeenCalledWith({ + repositoryBasePath: '/Users/test/MacSelectedPath', + }); + + // Verify successful response + expect(response).toMatchObject({ + id: 'mac-msg-123', + type: 'response', + category: 'system', + action: 'repository-path-update', + payload: { success: true, path: '/Users/test/MacSelectedPath' }, + }); + + // Verify the path was stored + expect(controlUnixHandler.getRepositoryPath()).toBe('/Users/test/MacSelectedPath'); + }); + + it('should handle missing path in repository-path-update payload', async () => { + const mockCallback = vi.fn(); + controlUnixHandler.setConfigUpdateCallback(mockCallback); + + // Message with missing path + const message = { + id: 'mac-msg-456', + type: 'request' as const, + category: 'system' as const, + action: 'repository-path-update', + payload: {}, + }; + + // Process the message + const systemHandler = (controlUnixHandler as any).handlers.get('system'); + const response = await systemHandler.handleMessage(message); + + // Verify callback was not called + expect(mockCallback).not.toHaveBeenCalled(); + + // Verify error response + expect(response).toMatchObject({ + id: 'mac-msg-456', + type: 'response', + category: 'system', + action: 'repository-path-update', + error: 'Missing path in payload', + }); + }); + + it('should not process response messages for repository-path-update', async () => { + const mockCallback = vi.fn(); + controlUnixHandler.setConfigUpdateCallback(mockCallback); + + // Response message (should be ignored) + const message = { + id: 'mac-msg-789', + type: 'response' as const, + category: 'system' as const, + action: 'repository-path-update', + payload: { success: true, path: '/some/path' }, + }; + + // Simulate handleMacMessage behavior - response messages without pending requests are ignored + const pendingRequests = (controlUnixHandler as any).pendingRequests; + const hasPendingRequest = pendingRequests.has(message.id); + + // Since this is a response without a pending request, it should be ignored + expect(hasPendingRequest).toBe(false); + + // Verify callback was not called + expect(mockCallback).not.toHaveBeenCalled(); + }); + }); });