diff --git a/assistant/src/__tests__/host-transfer-proxy-targeted.test.ts b/assistant/src/__tests__/host-transfer-proxy-targeted.test.ts index 8ebbe5dc8b8..294f3921932 100644 --- a/assistant/src/__tests__/host-transfer-proxy-targeted.test.ts +++ b/assistant/src/__tests__/host-transfer-proxy-targeted.test.ts @@ -3,7 +3,7 @@ * * Covers: * - requestToHost() explicit valid targetClientId → validates, broadcasts with targetClientId - * - requestToHost() auto-resolve when exactly one host_file-capable client → auto-resolves + * - requestToHost() untargeted call with single capable client → broadcasts without targetClientId (no auto-resolve) * - requestToHost() unknown targetClientId → early error, no broadcast * - requestToHost() incapable client → early error, no broadcast * - requestToSandbox() explicit valid targetClientId → same 4 cases @@ -142,10 +142,16 @@ describe("HostTransferProxy — targetClientId", () => { }); }); - // ── requestToHost() — auto-resolve single capable client ───────────── + // ── requestToHost() — untargeted single client (no auto-resolve) ────── + // + // Rationale: auto-resolving targetClientId for single clients requires the + // macOS client to send x-vellum-client-id on content requests. Pre-Phase 3 + // clients don't send this header, causing a 400 and a 120 s hang. For + // untargeted calls we broadcast without targetClientId (backward-compatible) + // regardless of how many host_file-capable clients are connected. - describe("requestToHost() — auto-resolve when exactly one capable client", () => { - test("auto-resolves targetClientId to the single capable client", async () => { + describe("requestToHost() — untargeted call with single capable client", () => { + test("broadcasts without targetClientId (no auto-resolve)", async () => { setup(); setupSingleClient("client-solo"); @@ -162,10 +168,11 @@ describe("HostTransferProxy — targetClientId", () => { await waitForMessages(sentMessages, 1); const sent = sentMessages[0] as Record; - expect(sent.targetClientId).toBe("client-solo"); + // No auto-resolve: untargeted broadcast should have no targetClientId. + expect(sent.targetClientId).toBeUndefined(); const opts = sentMessageOptions[0] as Record | undefined; - expect(opts?.targetClientId).toBe("client-solo"); + expect(opts?.targetClientId).toBeUndefined(); const requestId = sent.requestId as string; proxy.resolveTransferResult(requestId, { isError: false }); @@ -249,10 +256,10 @@ describe("HostTransferProxy — targetClientId", () => { }); }); - // ── requestToSandbox() — auto-resolve single capable client ────────── + // ── requestToSandbox() — untargeted single client (no auto-resolve) ──── - describe("requestToSandbox() — auto-resolve when exactly one capable client", () => { - test("auto-resolves targetClientId", async () => { + describe("requestToSandbox() — untargeted call with single capable client", () => { + test("broadcasts without targetClientId (no auto-resolve)", async () => { setup(); setupSingleClient("client-solo"); @@ -264,7 +271,8 @@ describe("HostTransferProxy — targetClientId", () => { expect(sentMessages).toHaveLength(1); const sent = sentMessages[0] as Record; - expect(sent.targetClientId).toBe("client-solo"); + // No auto-resolve: untargeted broadcast should have no targetClientId. + expect(sent.targetClientId).toBeUndefined(); proxy.cancel(sent.requestId as string); await resultPromise; diff --git a/assistant/src/daemon/host-transfer-proxy.ts b/assistant/src/daemon/host-transfer-proxy.ts index 2ddbeade46d..e1f83f97d2a 100644 --- a/assistant/src/daemon/host-transfer-proxy.ts +++ b/assistant/src/daemon/host-transfer-proxy.ts @@ -144,9 +144,6 @@ export class HostTransferProxy { isError: true, }); } - } else { - const capable = assistantEventHub.listClientsByCapability("host_file"); - if (capable.length === 1) resolvedTargetClientId = capable[0].clientId; } const requestId = uuid(); @@ -311,9 +308,6 @@ export class HostTransferProxy { isError: true, }); } - } else { - const capable = assistantEventHub.listClientsByCapability("host_file"); - if (capable.length === 1) resolvedTargetClientId = capable[0].clientId; } const requestId = uuid(); diff --git a/clients/macos/vellum-assistant/Features/MainWindow/ConversationTitleActionsControl.swift b/clients/macos/vellum-assistant/Features/MainWindow/ConversationTitleActionsControl.swift index a772045e080..ae48adc9239 100644 --- a/clients/macos/vellum-assistant/Features/MainWindow/ConversationTitleActionsControl.swift +++ b/clients/macos/vellum-assistant/Features/MainWindow/ConversationTitleActionsControl.swift @@ -15,7 +15,7 @@ struct ConversationTitleActionsControl: View { let onArchive: () -> Void let onRename: () -> Void let onOpenForkParent: () -> Void - let onAnalyzeConversation: () -> Void + var onAnalyzeConversation: (() -> Void)? = nil let onRefresh: () -> Void var onOpenInNewWindow: (() -> Void)? = nil @@ -167,7 +167,7 @@ struct ConversationActionsMenuContent: View { let onUnpin: () -> Void let onArchive: () -> Void let onRename: () -> Void - let onAnalyzeConversation: () -> Void + var onAnalyzeConversation: (() -> Void)? = nil let onRefresh: () -> Void var onOpenInNewWindow: (() -> Void)? = nil @@ -181,7 +181,7 @@ struct ConversationActionsMenuContent: View { VMenuItem(icon: VIcon.gitBranch.rawValue, label: "Fork conversation", action: onForkConversation) } - if presentation.isPersisted && !presentation.isChannelConversation { + if presentation.isPersisted && !presentation.isChannelConversation, let onAnalyzeConversation { VMenuItem( icon: VIcon.sparkles.rawValue, label: "Analyze conversation", diff --git a/clients/macos/vellum-assistant/Features/MainWindow/MainWindowView.swift b/clients/macos/vellum-assistant/Features/MainWindow/MainWindowView.swift index f7de1ea1395..f3e546b5152 100644 --- a/clients/macos/vellum-assistant/Features/MainWindow/MainWindowView.swift +++ b/clients/macos/vellum-assistant/Features/MainWindow/MainWindowView.swift @@ -801,7 +801,7 @@ struct ConversationTitleOverlay: View { let onArchive: () -> Void let onRename: () -> Void let onOpenForkParent: () -> Void - let onAnalyzeConversation: () -> Void + var onAnalyzeConversation: (() -> Void)? = nil let onRefresh: () -> Void var onOpenInNewWindow: (() -> Void)? = nil diff --git a/clients/macos/vellum-assistant/Features/MainWindow/Sidebar/ConversationSwitcherDrawer.swift b/clients/macos/vellum-assistant/Features/MainWindow/Sidebar/ConversationSwitcherDrawer.swift index f12e8099e4e..19edcc32142 100644 --- a/clients/macos/vellum-assistant/Features/MainWindow/Sidebar/ConversationSwitcherDrawer.swift +++ b/clients/macos/vellum-assistant/Features/MainWindow/Sidebar/ConversationSwitcherDrawer.swift @@ -15,6 +15,8 @@ struct ConversationSwitcherDrawer: View { let selectConversation: (ConversationModel) -> Void let onDismiss: () -> Void + @Environment(AssistantFeatureFlagStore.self) private var assistantFeatureFlagStore + /// Max conversations shown per section before "Show more". private let maxPerSection = 5 @@ -91,7 +93,7 @@ struct ConversationSwitcherDrawer: View { onDragStart: { sidebar.beginConversationDrag(conversation.id) }, - onAnalyze: conversation.conversationId != nil && !conversation.isChannelConversation ? { + onAnalyze: conversation.conversationId != nil && !conversation.isChannelConversation && assistantFeatureFlagStore.isEnabled("analyze-conversation") ? { selectConversation(conversation) Task { await conversationManager.analyzeActiveConversation() } } : nil, diff --git a/clients/macos/vellum-assistant/Features/MainWindow/Sidebar/SidebarView.swift b/clients/macos/vellum-assistant/Features/MainWindow/Sidebar/SidebarView.swift index eb167a7f03e..7d86d90aa70 100644 --- a/clients/macos/vellum-assistant/Features/MainWindow/Sidebar/SidebarView.swift +++ b/clients/macos/vellum-assistant/Features/MainWindow/Sidebar/SidebarView.swift @@ -239,7 +239,7 @@ struct SidebarView: View { onDragStart: { sidebar.beginConversationDrag(conversation.id) }, - onAnalyze: conversation.conversationId != nil && !conversation.isChannelConversation ? { + onAnalyze: conversation.conversationId != nil && !conversation.isChannelConversation && assistantFeatureFlagStore.isEnabled("analyze-conversation") ? { selectConversation(conversation) Task { await conversationManager.analyzeActiveConversation() } } : nil, diff --git a/clients/macos/vellum-assistant/Features/MainWindow/TopBarView.swift b/clients/macos/vellum-assistant/Features/MainWindow/TopBarView.swift index 2a4639dc862..db1439502e6 100644 --- a/clients/macos/vellum-assistant/Features/MainWindow/TopBarView.swift +++ b/clients/macos/vellum-assistant/Features/MainWindow/TopBarView.swift @@ -26,6 +26,8 @@ struct TopBarView: View { let onRenameConversation: () -> Void let onOpenForkParent: () -> Void + @Environment(AssistantFeatureFlagStore.self) private var assistantFeatureFlagStore + @AppStorage("sidebarExpanded") private var sidebarExpanded: Bool = true @AppStorage("sidebarToggleShortcut") private var sidebarToggleShortcut: String = "cmd+\\" @AppStorage("homeShortcut") private var homeShortcut: String = "cmd+shift+h" @@ -195,9 +197,9 @@ struct TopBarView: View { }, onRename: onRenameConversation, onOpenForkParent: onOpenForkParent, - onAnalyzeConversation: { + onAnalyzeConversation: assistantFeatureFlagStore.isEnabled("analyze-conversation") ? { Task { await conversationManager.analyzeActiveConversation() } - }, + } : nil, onRefresh: { conversationManager.refreshActiveConversation() }, diff --git a/meta/feature-flags/feature-flag-registry.json b/meta/feature-flags/feature-flag-registry.json index d5cc90cf90d..46a79a9d757 100644 --- a/meta/feature-flags/feature-flag-registry.json +++ b/meta/feature-flags/feature-flag-registry.json @@ -264,6 +264,14 @@ "label": "App Control", "description": "Enable the app-control skill (per-app screenshot + raw input bypassing AX tree)", "defaultEnabled": false + }, + { + "id": "analyze-conversation", + "scope": "assistant", + "key": "analyze-conversation", + "label": "Analyze Conversation", + "description": "Show the 'Analyze' / 'Analyze conversation' option in conversation context menus and the conversation title actions dropdown.", + "defaultEnabled": false } ] }