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"
- **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:

View file

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

View file

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

View file

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

View file

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

View file

@ -617,10 +617,10 @@ export class UnifiedSettings extends LitElement {
</div>
<!-- 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">
<label class="text-dark-text font-medium">Repository Base Path</label>
<p class="text-dark-text-muted text-xs mt-1">
<label class="text-primary font-medium">Repository Base Path</label>
<p class="text-muted text-xs mt-1">
${
this.isServerConfigured
? 'This path is synced with the VibeTunnel Mac app'
@ -646,7 +646,7 @@ export class UnifiedSettings extends LitElement {
${
this.isServerConfigured
? 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
class="w-5 h-5"
fill="none"

View file

@ -82,23 +82,28 @@ class SystemHandler implements MessageHandler {
constructor(private controlUnixHandler: ControlUnixHandler) {}
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) {
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<boolean> {
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);

View file

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