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)