Fix control message loop and simplify welcome screen repository display (#372)

This commit is contained in:
Peter Steinberger 2025-07-16 09:30:56 +02:00 committed by GitHub
parent 500c75ebc8
commit 2f3a4217d0
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
8 changed files with 211 additions and 114 deletions

View file

@ -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" - **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 - **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 #### Future: Hot Module Replacement
For true hot module replacement without manual refresh, see our [Vite migration plan](docs/vite-plan.md) which would provide: For true hot module replacement without manual refresh, see our [Vite migration plan](docs/vite-plan.md) which would provide:

View file

@ -52,14 +52,20 @@ struct ControlAgentArmyPageView: View {
.cornerRadius(6) .cornerRadius(6)
} }
Text( VStack(spacing: 8) {
"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." 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) .font(.caption)
.multilineTextAlignment(.center) .foregroundColor(.secondary)
.frame(maxWidth: 420) .multilineTextAlignment(.center)
.fixedSize(horizontal: false, vertical: true) .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) .padding(.vertical, 12)
} }

View file

@ -20,122 +20,85 @@ struct ProjectFolderPageView: View {
} }
var body: some View { var body: some View {
VStack(spacing: 24) { VStack(spacing: 30) {
// Title and description VStack(spacing: 16) {
VStack(spacing: 12) {
Text("Choose Your Project Folder") Text("Choose Your Project Folder")
.font(.system(size: 24, weight: .semibold)) .font(.largeTitle)
.foregroundColor(.primary) .fontWeight(.semibold)
Text( Text(
"Select the folder where you keep your projects. VibeTunnel will use this for quick access and repository discovery." "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) .foregroundColor(.secondary)
.multilineTextAlignment(.center) .multilineTextAlignment(.center)
.frame(maxWidth: 400) .frame(maxWidth: 480)
} .fixedSize(horizontal: false, vertical: true)
// Folder picker section // Folder and repository section
VStack(alignment: .leading, spacing: 12) { VStack(spacing: 16) {
Text("Project Folder") // Folder picker
.font(.system(size: 13, weight: .medium)) HStack {
.foregroundColor(.secondary) 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 { Button("Choose...") {
Text(selectedPath.isEmpty ? "~/" : selectedPath) showFolderPicker()
.font(.system(size: 13)) }
.foregroundColor(selectedPath.isEmpty ? .secondary : .primary) .buttonStyle(.bordered)
.frame(maxWidth: .infinity, alignment: .leading)
.padding(.horizontal, 10)
.padding(.vertical, 6)
.background(Color(NSColor.controlBackgroundColor))
.cornerRadius(6)
Button("Choose...") {
showFolderPicker()
} }
.buttonStyle(.bordered) .frame(width: 350)
}
// Repository preview // Repository count
if !selectedPath.isEmpty { if !selectedPath.isEmpty {
VStack(alignment: .leading, spacing: 8) {
HStack { HStack {
Text("Discovered Repositories") Image(systemName: "folder.badge.gearshape")
.font(.system(size: 12, weight: .medium)) .font(.system(size: 12))
.foregroundColor(.secondary) .foregroundColor(.secondary)
if isScanning { if isScanning {
ProgressView() Text("Scanning...")
.scaleEffect(0.5) .font(.system(size: 12))
.frame(width: 16, height: 16) .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() Spacer()
} }
.padding(.horizontal, 10)
ScrollView { .padding(.vertical, 6)
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)
.background(Color(NSColor.controlBackgroundColor).opacity(0.5)) .background(Color(NSColor.controlBackgroundColor).opacity(0.5))
.cornerRadius(6) .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) Spacer()
// 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)
} }
.padding(.horizontal, 40) .padding()
.onAppear { .onAppear {
selectedPath = repositoryBasePath selectedPath = repositoryBasePath
if !selectedPath.isEmpty { if !selectedPath.isEmpty {
@ -190,7 +153,7 @@ struct ProjectFolderPageView: View {
let repos = await findGitRepositories(in: expandedPath, maxDepth: 3) let repos = await findGitRepositories(in: expandedPath, maxDepth: 3)
await MainActor.run { await MainActor.run {
discoveredRepos = repos.prefix(10).map { path in discoveredRepos = repos.map { path in
RepositoryInfo(name: URL(fileURLWithPath: path).lastPathComponent, path: path) RepositoryInfo(name: URL(fileURLWithPath: path).lastPathComponent, path: path)
} }
isScanning = false isScanning = false

View file

@ -73,7 +73,7 @@ struct RequestPermissionsPageView: View {
.foregroundColor(.secondary) .foregroundColor(.secondary)
} }
.font(.body) .font(.body)
.frame(maxWidth: 250) .frame(maxWidth: 250, alignment: .leading)
.frame(height: 32) .frame(height: 32)
} else { } else {
Button("Grant Automation Permission") { Button("Grant Automation Permission") {
@ -93,7 +93,7 @@ struct RequestPermissionsPageView: View {
.foregroundColor(.secondary) .foregroundColor(.secondary)
} }
.font(.body) .font(.body)
.frame(maxWidth: 250) .frame(maxWidth: 250, alignment: .leading)
.frame(height: 32) .frame(height: 32)
} else { } else {
Button("Grant Accessibility Permission") { Button("Grant Accessibility Permission") {
@ -113,7 +113,7 @@ struct RequestPermissionsPageView: View {
.foregroundColor(.secondary) .foregroundColor(.secondary)
} }
.font(.body) .font(.body)
.frame(maxWidth: 250) .frame(maxWidth: 250, alignment: .leading)
.frame(height: 32) .frame(height: 32)
} else { } else {
Button("Grant Screen Recording Permission") { Button("Grant Screen Recording Permission") {

View file

@ -1336,7 +1336,7 @@ export class ScreencapView extends LitElement {
// Show overlay when not capturing or waiting to start // Show overlay when not capturing or waiting to start
return html` return html`
<div class.capture-overlay> <div class="capture-overlay">
<div class="status-message ${this.status}"> <div class="status-message ${this.status}">
${ ${
this.status === 'loading' this.status === 'loading'

View file

@ -617,10 +617,10 @@ export class UnifiedSettings extends LitElement {
</div> </div>
<!-- Repository Base Path --> <!-- Repository Base Path -->
<div class="p-4 bg-dark-bg-tertiary rounded-lg border border-dark-border"> <div class="p-4 bg-tertiary rounded-lg border border-base">
<div class="mb-3"> <div class="mb-3">
<label class="text-dark-text font-medium">Repository Base Path</label> <label class="text-primary font-medium">Repository Base Path</label>
<p class="text-dark-text-muted text-xs mt-1"> <p class="text-muted text-xs mt-1">
${ ${
this.isServerConfigured this.isServerConfigured
? 'This path is synced with the VibeTunnel Mac app' ? 'This path is synced with the VibeTunnel Mac app'
@ -646,7 +646,7 @@ export class UnifiedSettings extends LitElement {
${ ${
this.isServerConfigured this.isServerConfigured
? html` ? html`
<div class="flex items-center text-dark-text-muted" title="Synced with Mac app"> <div class="flex items-center text-muted" title="Synced with Mac app">
<svg <svg
class="w-5 h-5" class="w-5 h-5"
fill="none" fill="none"

View file

@ -82,23 +82,28 @@ class SystemHandler implements MessageHandler {
constructor(private controlUnixHandler: ControlUnixHandler) {} constructor(private controlUnixHandler: ControlUnixHandler) {}
async handleMessage(message: ControlMessage): Promise<ControlMessage | null> { async handleMessage(message: ControlMessage): Promise<ControlMessage | null> {
logger.log(`System handler: ${message.action}`); logger.log(`System handler: ${message.action}, type: ${message.type}, id: ${message.id}`);
switch (message.action) { switch (message.action) {
case 'repository-path-update': { case 'repository-path-update': {
const payload = message.payload as { path: string }; const payload = message.payload as { path: string };
logger.log(`Repository path update received: ${JSON.stringify(payload)}`);
if (!payload?.path) { if (!payload?.path) {
logger.error('Missing path in payload');
return createControlResponse(message, null, 'Missing path in payload'); return createControlResponse(message, null, 'Missing path in payload');
} }
try { try {
// Update the server configuration // Update the server configuration
logger.log(`Calling updateRepositoryPath with: ${payload.path}`);
const updateSuccess = await this.controlUnixHandler.updateRepositoryPath(payload.path); const updateSuccess = await this.controlUnixHandler.updateRepositoryPath(payload.path);
if (updateSuccess) { 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 }); return createControlResponse(message, { success: true, path: payload.path });
} else { } else {
logger.error('updateRepositoryPath returned false');
return createControlResponse(message, null, 'Failed to update repository path'); return createControlResponse(message, null, 'Failed to update repository path');
} }
} catch (error) { } catch (error) {
@ -566,7 +571,7 @@ export class ControlUnixHandler {
private async handleMacMessage(message: ControlMessage) { private async handleMacMessage(message: ControlMessage) {
logger.log( 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 // Handle ping keep-alive from Mac client
@ -576,6 +581,11 @@ export class ControlUnixHandler {
return; 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 // Check if this is a response to a pending request
if (message.type === 'response' && this.pendingRequests.has(message.id)) { if (message.type === 'response' && this.pendingRequests.has(message.id)) {
const resolver = this.pendingRequests.get(message.id); const resolver = this.pendingRequests.get(message.id);
@ -587,6 +597,15 @@ export class ControlUnixHandler {
return; 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); const handler = this.handlers.get(message.category);
if (!handler) { if (!handler) {
logger.warn(`No handler for category: ${message.category}`); logger.warn(`No handler for category: ${message.category}`);
@ -722,16 +741,21 @@ export class ControlUnixHandler {
* Update the repository path and notify all connected clients * Update the repository path and notify all connected clients
*/ */
async updateRepositoryPath(path: string): Promise<boolean> { async updateRepositoryPath(path: string): Promise<boolean> {
logger.log(`updateRepositoryPath called with path: ${path}`);
try { try {
this.currentRepositoryPath = path; this.currentRepositoryPath = path;
logger.log(`Set currentRepositoryPath to: ${this.currentRepositoryPath}`);
// Call the callback to update server configuration and broadcast to web clients // Call the callback to update server configuration and broadcast to web clients
if (this.configUpdateCallback) { if (this.configUpdateCallback) {
logger.log('Calling configUpdateCallback...');
this.configUpdateCallback({ repositoryBasePath: path }); this.configUpdateCallback({ repositoryBasePath: path });
logger.log('configUpdateCallback completed successfully');
return true; return true;
} }
logger.warn('No config update callback set'); logger.warn('No config update callback set - is the server initialized?');
return false; return false;
} catch (error) { } catch (error) {
logger.error('Failed to update repository path:', error); logger.error('Failed to update repository path:', error);

View file

@ -91,4 +91,95 @@ describe('Control Unix Handler', () => {
expect(mockCallback).toHaveBeenCalledWith({ repositoryBasePath: '/test/path' }); 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();
});
});
}); });