diff --git a/clients/ios/Resources/Assets.xcassets/AppIcon.appiconset/AppIcon.png b/clients/ios/Resources/Assets.xcassets/AppIcon.appiconset/AppIcon.png new file mode 100644 index 00000000000..51719fce80e Binary files /dev/null and b/clients/ios/Resources/Assets.xcassets/AppIcon.appiconset/AppIcon.png differ diff --git a/clients/ios/Resources/Assets.xcassets/AppIcon.appiconset/Contents.json b/clients/ios/Resources/Assets.xcassets/AppIcon.appiconset/Contents.json index 13613e3ee1a..cefcc878e08 100644 --- a/clients/ios/Resources/Assets.xcassets/AppIcon.appiconset/Contents.json +++ b/clients/ios/Resources/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -1,6 +1,7 @@ { "images" : [ { + "filename" : "AppIcon.png", "idiom" : "universal", "platform" : "ios", "size" : "1024x1024" diff --git a/clients/ios/Resources/Info.plist b/clients/ios/Resources/Info.plist index 86d34742daf..2e21530cb4a 100644 --- a/clients/ios/Resources/Info.plist +++ b/clients/ios/Resources/Info.plist @@ -6,6 +6,8 @@ Vellum Assistant CFBundleIdentifier com.vellum.vellum-assistant-ios + CFBundleExecutable + vellum-assistant-ios CFBundleVersion 1 CFBundleShortVersionString diff --git a/clients/macos/vellum-assistant/Features/Chat/ChatView.swift b/clients/macos/vellum-assistant/Features/Chat/ChatView.swift index d7a1645ef7f..8e04814657c 100644 --- a/clients/macos/vellum-assistant/Features/Chat/ChatView.swift +++ b/clients/macos/vellum-assistant/Features/Chat/ChatView.swift @@ -56,6 +56,7 @@ struct ChatView: View { @State private var isDropTargeted = false @State private var editorContentHeight: CGFloat = 20 + @AppStorage("useThreadDrawer") private var useThreadDrawer: Bool = false @State private var composerScrollOffset: CGFloat = 0 var body: some View { @@ -286,7 +287,8 @@ struct ChatView: View { } } .padding(.horizontal, VSpacing.xl) - .padding(.vertical, VSpacing.md) + .padding(.top, useThreadDrawer ? VSpacing.xs : VSpacing.md) + .padding(.bottom, VSpacing.md) .frame(maxWidth: 700) .frame(maxWidth: .infinity) } diff --git a/clients/macos/vellum-assistant/Features/MainWindow/MainWindowView.swift b/clients/macos/vellum-assistant/Features/MainWindow/MainWindowView.swift index e73723a80c2..41c80f6eda8 100644 --- a/clients/macos/vellum-assistant/Features/MainWindow/MainWindowView.swift +++ b/clients/macos/vellum-assistant/Features/MainWindow/MainWindowView.swift @@ -9,6 +9,9 @@ struct MainWindowView: View { @State private var activePanel: SidePanelType? @State private var isDynamicExpanded = false @State private var hasAPIKey = APIKeyManager.hasAnyKey() + @State private var columnVisibility: NavigationSplitViewVisibility = .automatic + @State private var selectedThreadId: UUID? + @AppStorage("useThreadDrawer") private var useThreadDrawer: Bool = false let daemonClient: DaemonClient let ambientAgent: AmbientAgent let onMicrophoneToggle: () -> Void @@ -22,81 +25,93 @@ struct MainWindowView: View { self.onMicrophoneToggle = onMicrophoneToggle } + // MARK: - Layout Constants + + /// Leading padding to account for macOS traffic light buttons (red/yellow/green). + /// Note: This is a fixed value that may not be accurate for all window styles or + /// if Apple changes the traffic light spacing. Dynamic measurement would be better + /// but requires complex window geometry inspection. + private let trafficLightPadding: CGFloat = 78 + var body: some View { GeometryReader { geometry in - VStack(spacing: 0) { - // Row 1 — thread tab bar + panel buttons - ThreadTabBar( - threads: threadManager.threads, - activeThreadId: threadManager.activeThreadId, - onSelect: { threadManager.selectThread(id: $0) }, - onClose: { threadManager.closeThread(id: $0) }, - onCreate: { threadManager.createThread() }, - activePanel: $activePanel - ) + Group { + if useThreadDrawer { + // Drawer mode: Custom split view (no NavigationSplitView) + VStack(spacing: 0) { + // Button bar (matches ThreadTabBar height and style) + VStack(spacing: 0) { + HStack(spacing: 0) { + // Thread drawer toggle + VIconButton(label: "Threads", icon: "list.bullet", isActive: columnVisibility != .detailOnly, iconOnly: true) { + withAnimation(.spring(response: 0.3, dampingFraction: 0.8)) { + columnVisibility = (columnVisibility == .detailOnly) ? .all : .detailOnly + } + } - // Row 2 — chat content with optional side panel - if isDynamicExpanded && activePanel == .generated { - GeneratedPanel( - onClose: { activePanel = nil; isDynamicExpanded = false }, - isExpanded: $isDynamicExpanded, - daemonClient: daemonClient - ) - } else { - VSplitView(panelWidth: geometry.size.width / zoomManager.zoomLevel * 0.5, showPanel: activePanel != nil, main: { - if let viewModel = threadManager.activeViewModel { - ChatView( - messages: viewModel.messages, - inputText: Binding( - get: { viewModel.inputText }, - set: { viewModel.inputText = $0 } - ), - hasAPIKey: hasAPIKey, - isThinking: viewModel.isThinking, - isSending: viewModel.isSending, - errorText: viewModel.errorText, - pendingQueuedCount: viewModel.pendingQueuedCount, - suggestion: viewModel.suggestion, - pendingAttachments: viewModel.pendingAttachments, - isRecording: viewModel.isRecording, - onOpenSettings: { - // Always provide an immediate, visible fallback. - activePanel = .settings - Self.openSettings() - }, - onSend: viewModel.sendMessage, - onStop: viewModel.stopGenerating, - onDismissError: viewModel.dismissError, - onAcceptSuggestion: viewModel.acceptSuggestion, - onAttach: { Self.openFilePicker(viewModel: viewModel) }, - onRemoveAttachment: { viewModel.removeAttachment(id: $0) }, - onDropFiles: { urls in urls.forEach { viewModel.addAttachment(url: $0) } }, - onDropImageData: { data, name in - let filename: String - if let name { - let basename = (name as NSString).lastPathComponent - let base = (basename as NSString).deletingPathExtension - filename = base.isEmpty ? "Dropped Image.png" : "\(base).png" - } else { - filename = "Dropped Image.png" + Spacer() + + // Panel toggle buttons + HStack(spacing: VSpacing.sm) { + VIconButton(label: "Dynamic", icon: "wand.and.stars", isActive: activePanel == .generated, iconOnly: true) { + togglePanel(.generated) } - viewModel.addAttachment(imageData: data, filename: filename) - }, - onPaste: { viewModel.addAttachmentFromPasteboard() }, - onMicrophoneToggle: onMicrophoneToggle, - onConfirmationAllow: { requestId in viewModel.respondToConfirmation(requestId: requestId, decision: "allow") }, - onConfirmationDeny: { requestId in viewModel.respondToConfirmation(requestId: requestId, decision: "deny") }, - onAddTrustRule: { toolName, pattern, scope, decision in return viewModel.addTrustRule(toolName: toolName, pattern: pattern, scope: scope, decision: decision) }, - onSurfaceAction: { surfaceId, actionId, data in viewModel.sendSurfaceAction(surfaceId: surfaceId, actionId: actionId, data: data) }, - onRegenerate: { viewModel.regenerateLastMessage() }, - sessionError: viewModel.sessionError, - onRetry: { viewModel.retryAfterSessionError() }, - onDismissSessionError: { viewModel.dismissSessionError() } - ) + VIconButton(label: "Skills", icon: "exclamationmark.triangle", isActive: activePanel == .agent, iconOnly: true) { + togglePanel(.agent) + } + VIconButton(label: "Settings", icon: "gearshape", isActive: activePanel == .settings, iconOnly: true) { + togglePanel(.settings) + } + VIconButton(label: "Directory", icon: "doc.text", isActive: activePanel == .directory, iconOnly: true) { + togglePanel(.directory) + } + VIconButton(label: "Debug", icon: "ant", isActive: activePanel == .debug, iconOnly: true) { + togglePanel(.debug) + } + VIconButton(label: "Doctor", icon: "stethoscope", isActive: activePanel == .doctor, iconOnly: true) { + togglePanel(.doctor) + } + } + } + .padding(.leading, trafficLightPadding) + .padding(.trailing, VSpacing.lg) + .frame(height: 36) + .background(VColor.background) + } + + // Content area with left drawer + chat + right panel + HStack(spacing: 0) { + // Left: Thread drawer (conditional) + if columnVisibility != .detailOnly { + threadDrawerView + .transition(.move(edge: .leading)) + } + + // Center: Chat + right panel + chatContentView(geometry: geometry) + .background(VColor.backgroundSubtle) + .clipShape(RoundedRectangle(cornerRadius: VRadius.lg)) + .padding(.bottom, VSpacing.sm) + .padding(.horizontal, VSpacing.sm) } - }, panel: { - panelContent - }) + .animation(.spring(response: 0.3, dampingFraction: 0.8), value: columnVisibility) + } + } else { + // Tab mode: Traditional layout + VStack(spacing: 0) { + // Row 1 — thread tab bar + ThreadTabBar( + threads: threadManager.threads, + activeThreadId: threadManager.activeThreadId, + onSelect: { threadManager.selectThread(id: $0) }, + onClose: { threadManager.closeThread(id: $0) }, + onCreate: { threadManager.createThread() }, + activePanel: $activePanel + ) + + // Row 2 — chat content with optional side panel + chatContentView(geometry: geometry) + } } } .ignoresSafeArea(edges: .top) @@ -118,6 +133,7 @@ struct MainWindowView: View { .animation(VAnimation.fast, value: zoomManager.showZoomIndicator) .onAppear { refreshAPIKeyState() + selectedThreadId = threadManager.activeThreadId } .onReceive(NotificationCenter.default.publisher(for: .apiKeyManagerDidChange)) { _ in refreshAPIKeyState() @@ -132,6 +148,149 @@ struct MainWindowView: View { isDynamicExpanded = false } } + .onChange(of: selectedThreadId) { _, newId in + if let newId = newId { + threadManager.selectThread(id: newId) + } + } + .onChange(of: threadManager.activeThreadId) { _, newId in + // Sync activeThreadId changes back to selectedThreadId to keep sidebar selection in sync + selectedThreadId = newId + } + } + + @ViewBuilder + private func threadItem(_ thread: ThreadModel) -> some View { + Button(action: { threadManager.selectThread(id: thread.id) }) { + HStack(spacing: VSpacing.sm) { + Text(thread.title) + .font(VFont.body) + .foregroundColor(thread.id == threadManager.activeThreadId ? VColor.accent : VColor.textPrimary) + Spacer() + // Reserve space for close button + if threadManager.threads.count > 1 { + Spacer().frame(width: 16) + } + } + .padding(.horizontal, VSpacing.lg) + .padding(.vertical, VSpacing.xs) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + .overlay(alignment: .trailing) { + if threadManager.threads.count > 1 { + Button(action: { threadManager.closeThread(id: thread.id) }) { + Image(systemName: "xmark") + .font(.system(size: 10, weight: .bold)) + .foregroundColor(VColor.textMuted) + .frame(width: 16, height: 16) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + .accessibilityLabel("Close \(thread.title)") + .padding(.trailing, VSpacing.lg) + } + } + .background(thread.id == threadManager.activeThreadId ? VColor.surface : Color.clear) + .clipShape(RoundedRectangle(cornerRadius: VRadius.sm)) + } + + @ViewBuilder + private var threadDrawerView: some View { + VStack(spacing: 0) { + HStack { + Text("THREADS") + .font(VFont.sectionTitle) + .foregroundColor(VColor.textPrimary) + Spacer() + VIconButton(label: "New Thread", icon: "plus", iconOnly: true) { + threadManager.createThread() + } + } + .padding(.horizontal, VSpacing.lg) + .padding(.top, VSpacing.lg) + .padding(.bottom, VSpacing.xs) + + ScrollView { + VStack(spacing: VSpacing.xs) { + ForEach(threadManager.threads) { thread in + threadItem(thread) + } + } + .padding(.horizontal, VSpacing.sm) + } + } + .frame(width: 240) + .background(VColor.backgroundSubtle) + .clipShape(RoundedRectangle(cornerRadius: VRadius.lg)) + .padding(.bottom, VSpacing.sm) + .padding(.leading, VSpacing.sm) + } + + @ViewBuilder + private func chatContentView(geometry: GeometryProxy) -> some View { + if isDynamicExpanded && activePanel == .generated { + GeneratedPanel( + onClose: { activePanel = nil; isDynamicExpanded = false }, + isExpanded: $isDynamicExpanded, + daemonClient: daemonClient + ) + } else { + VSplitView(panelWidth: geometry.size.width / zoomManager.zoomLevel * 0.5, showPanel: activePanel != nil, main: { + if let viewModel = threadManager.activeViewModel { + ChatView( + messages: viewModel.messages, + inputText: Binding( + get: { viewModel.inputText }, + set: { viewModel.inputText = $0 } + ), + hasAPIKey: hasAPIKey, + isThinking: viewModel.isThinking, + isSending: viewModel.isSending, + errorText: viewModel.errorText, + pendingQueuedCount: viewModel.pendingQueuedCount, + suggestion: viewModel.suggestion, + pendingAttachments: viewModel.pendingAttachments, + isRecording: viewModel.isRecording, + onOpenSettings: { + // Always provide an immediate, visible fallback. + activePanel = .settings + Self.openSettings() + }, + onSend: viewModel.sendMessage, + onStop: viewModel.stopGenerating, + onDismissError: viewModel.dismissError, + onAcceptSuggestion: viewModel.acceptSuggestion, + onAttach: { Self.openFilePicker(viewModel: viewModel) }, + onRemoveAttachment: { viewModel.removeAttachment(id: $0) }, + onDropFiles: { urls in urls.forEach { viewModel.addAttachment(url: $0) } }, + onDropImageData: { data, name in + let filename: String + if let name { + let basename = (name as NSString).lastPathComponent + let base = (basename as NSString).deletingPathExtension + filename = base.isEmpty ? "Dropped Image.png" : "\(base).png" + } else { + filename = "Dropped Image.png" + } + viewModel.addAttachment(imageData: data, filename: filename) + }, + onPaste: { viewModel.addAttachmentFromPasteboard() }, + onMicrophoneToggle: onMicrophoneToggle, + onConfirmationAllow: { requestId in viewModel.respondToConfirmation(requestId: requestId, decision: "allow") }, + onConfirmationDeny: { requestId in viewModel.respondToConfirmation(requestId: requestId, decision: "deny") }, + onAddTrustRule: { toolName, pattern, scope, decision in return viewModel.addTrustRule(toolName: toolName, pattern: pattern, scope: scope, decision: decision) }, + onSurfaceAction: { surfaceId, actionId, data in viewModel.sendSurfaceAction(surfaceId: surfaceId, actionId: actionId, data: data) }, + onRegenerate: { viewModel.regenerateLastMessage() }, + sessionError: viewModel.sessionError, + onRetry: { viewModel.retryAfterSessionError() }, + onDismissSessionError: { viewModel.dismissSessionError() } + ) + } + }, panel: { + panelContent + }) + } } @MainActor @@ -169,6 +328,14 @@ struct MainWindowView: View { hasAPIKey = APIKeyManager.hasAnyKey() || daemonClient.isConnected } + private func togglePanel(_ panel: SidePanelType) { + if activePanel == panel { + activePanel = nil + } else { + activePanel = panel + } + } + @ViewBuilder private var panelContent: some View { if let panel = activePanel { diff --git a/clients/macos/vellum-assistant/Features/MainWindow/Panels/SettingsPanel.swift b/clients/macos/vellum-assistant/Features/MainWindow/Panels/SettingsPanel.swift index b1f99afc794..f43019a9d9c 100644 --- a/clients/macos/vellum-assistant/Features/MainWindow/Panels/SettingsPanel.swift +++ b/clients/macos/vellum-assistant/Features/MainWindow/Panels/SettingsPanel.swift @@ -17,6 +17,7 @@ struct SettingsPanel: View { @State private var hasBraveKey: Bool = false @AppStorage("maxStepsPerSession") private var maxSteps: Double = 50 @AppStorage("ambientAgentEnabled") private var ambientEnabled: Bool = false + @AppStorage("useThreadDrawer") private var useThreadDrawer: Bool = false var body: some View { VSidePanel(title: "Settings", onClose: onClose) { @@ -180,6 +181,28 @@ struct SettingsPanel: View { .padding(VSpacing.lg) .vCard(background: Slate._900) + // DISPLAY section + VStack(alignment: .leading, spacing: VSpacing.md) { + Text("DISPLAY") + .font(VFont.sectionTitle) + .foregroundColor(VColor.textPrimary) + + HStack { + VStack(alignment: .leading, spacing: VSpacing.xs) { + Text("Show thread list drawer") + .font(VFont.body) + .foregroundColor(VColor.textSecondary) + Text("Access chat history from a left-side drawer instead of tabs") + .font(VFont.caption) + .foregroundColor(VColor.textMuted) + } + Spacer() + VToggle(isOn: $useThreadDrawer) + } + } + .padding(VSpacing.lg) + .vCard(background: Slate._900) + // PERMISSIONS section VStack(alignment: .leading, spacing: VSpacing.md) { Text("PERMISSIONS") diff --git a/clients/macos/vellum-assistant/Features/MainWindow/ThreadTab.swift b/clients/macos/vellum-assistant/Features/MainWindow/ThreadTab.swift index c8118725c6c..532f1d0eddf 100644 --- a/clients/macos/vellum-assistant/Features/MainWindow/ThreadTab.swift +++ b/clients/macos/vellum-assistant/Features/MainWindow/ThreadTab.swift @@ -40,6 +40,8 @@ struct ThreadTab: View { Image(systemName: "xmark") .font(.system(size: 10, weight: .bold)) .foregroundColor(VColor.textMuted) + .frame(width: 16, height: 16) + .contentShape(Rectangle()) } .buttonStyle(.plain) .accessibilityLabel("Close \(label)") diff --git a/clients/shared/DesignSystem/Components/Layout/VSplitView.swift b/clients/shared/DesignSystem/Components/Layout/VSplitView.swift index 8c0843cc888..7cdf2e7b9dc 100644 --- a/clients/shared/DesignSystem/Components/Layout/VSplitView.swift +++ b/clients/shared/DesignSystem/Components/Layout/VSplitView.swift @@ -8,9 +8,11 @@ public struct VSplitView: View { public var body: some View { HStack(spacing: 0) { + // Main content - shrinks when panel appears main .frame(maxWidth: .infinity, maxHeight: .infinity) + // Panel slides in from right, pushing content if showPanel, let panel = panel { panel .frame(width: panelWidth)