Move conversation name to header (macOS document-title style)

The save indicator was sitting in the bottom-right corner of the footer.
Moved it to the center of the header bar, where macOS apps conventionally
show the document/conversation title. An orange dot appears when there are
unsaved changes; clicking saves. Removed the indicator from the footer.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-06-16 14:44:32 +02:00
parent b3bb7c4a59
commit 22f745762f
3 changed files with 129 additions and 107 deletions
+4 -1
View File
@@ -38,7 +38,10 @@ struct ChatView: View {
provider: viewModel.currentProvider,
model: viewModel.selectedModel,
onModelSelect: onModelSelect,
onProviderChange: onProviderChange
onProviderChange: onProviderChange,
conversationName: viewModel.currentConversationName,
hasUnsavedChanges: viewModel.hasUnsavedChanges,
onQuickSave: viewModel.quickSave
)
// Messages
-9
View File
@@ -93,15 +93,6 @@ struct FooterView: View {
}
#endif
// Save indicator (only when chat has messages)
if stats.messageCount > 0 {
SaveIndicator(
conversationName: conversationName,
hasUnsavedChanges: hasUnsavedChanges,
onSave: onQuickSave
)
}
// Update available badge (shows only when an update exists no version number)
#if os(macOS)
UpdateBadge()
+125 -97
View File
@@ -31,109 +31,24 @@ struct HeaderView: View {
let model: ModelInfo?
let onModelSelect: () -> Void
let onProviderChange: (Settings.Provider) -> Void
var conversationName: String? = nil
var hasUnsavedChanges: Bool = false
var onQuickSave: (() -> Void)? = nil
private let settings = SettingsService.shared
private let registry = ProviderRegistry.shared
var body: some View {
HStack(spacing: 12) {
// Provider picker dropdown only shows configured providers
Menu {
ForEach(registry.configuredProviders, id: \.self) { p in
Button {
onProviderChange(p)
} label: {
HStack {
Image(systemName: p.iconName)
Text(p.displayName)
if p == provider {
Image(systemName: "checkmark")
}
}
}
}
} label: {
HStack(spacing: 4) {
Image(systemName: provider.iconName)
.font(.system(size: settings.guiTextSize - 2))
Text(provider.displayName)
.font(.system(size: settings.guiTextSize - 2, weight: .medium))
Image(systemName: "chevron.up.chevron.down")
.font(.system(size: 8))
.opacity(0.7)
}
.foregroundColor(.white)
.padding(.horizontal, 8)
.padding(.vertical, 4)
.background(Color.providerColor(provider))
.cornerRadius(4)
}
.menuStyle(.borderlessButton)
.fixedSize()
.help("Switch provider")
// Model name (clickable model selector)
Button(action: onModelSelect) {
if let model = model {
HStack(spacing: 6) {
Text(model.name)
.font(.system(size: settings.guiTextSize, weight: .medium))
.foregroundColor(.oaiPrimary)
// Capability badges
HStack(spacing: 3) {
if model.capabilities.vision {
Image(systemName: "eye")
.font(.system(size: 9))
.foregroundColor(.oaiSecondary)
}
if model.capabilities.tools {
Image(systemName: "wrench")
.font(.system(size: 9))
.foregroundColor(.oaiSecondary)
}
if model.capabilities.online {
Image(systemName: "globe")
.font(.system(size: 9))
.foregroundColor(.oaiSecondary)
}
if model.capabilities.imageGeneration {
Image(systemName: "paintbrush")
.font(.system(size: 9))
.foregroundColor(.oaiSecondary)
}
}
Image(systemName: "chevron.down")
.font(.caption2)
.foregroundColor(.oaiSecondary)
}
} else {
HStack(spacing: 4) {
Text("No model selected")
.font(.system(size: settings.guiTextSize))
.foregroundColor(.oaiSecondary)
Image(systemName: "chevron.down")
.font(.caption2)
.foregroundColor(.oaiSecondary)
}
}
}
.buttonStyle(.plain)
.help("Select model")
// Favourite star
if let model = model {
let isFav = settings.favoriteModelIds.contains(model.id)
Button(action: { settings.toggleFavoriteModel(model.id) }) {
Image(systemName: isFav ? "star.fill" : "star")
.font(.system(size: settings.guiTextSize - 3))
.foregroundColor(isFav ? .yellow : .oaiSecondary)
}
.buttonStyle(.plain)
.help(isFav ? "Remove from favorites" : "Add to favorites")
ZStack {
// Left: provider + model + star
HStack(spacing: 12) {
providerMenu
modelButton
starButton
Spacer()
}
Spacer()
// Center: conversation title (macOS document-title style)
conversationTitle
}
.padding(.horizontal, 16)
.padding(.vertical, 8)
@@ -145,6 +60,119 @@ struct HeaderView: View {
alignment: .bottom
)
}
// MARK: - Conversation title (center)
@ViewBuilder
private var conversationTitle: some View {
if let name = conversationName {
Button(action: { if hasUnsavedChanges { onQuickSave?() } }) {
HStack(spacing: 5) {
if hasUnsavedChanges {
Circle()
.fill(Color.orange)
.frame(width: 6, height: 6)
}
Text(name)
.font(.system(size: settings.guiTextSize - 1, weight: .medium))
.foregroundColor(.oaiPrimary)
.lineLimit(1)
.frame(maxWidth: 300)
}
}
.buttonStyle(.plain)
.disabled(!hasUnsavedChanges)
.help(hasUnsavedChanges ? "Unsaved changes — click to save" : "Saved")
.animation(.easeInOut(duration: 0.2), value: hasUnsavedChanges)
.animation(.easeInOut(duration: 0.2), value: name)
}
}
// MARK: - Subviews (extracted so ZStack stays readable)
private var providerMenu: some View {
Menu {
ForEach(registry.configuredProviders, id: \.self) { p in
Button {
onProviderChange(p)
} label: {
HStack {
Image(systemName: p.iconName)
Text(p.displayName)
if p == provider { Image(systemName: "checkmark") }
}
}
}
} label: {
HStack(spacing: 4) {
Image(systemName: provider.iconName)
.font(.system(size: settings.guiTextSize - 2))
Text(provider.displayName)
.font(.system(size: settings.guiTextSize - 2, weight: .medium))
Image(systemName: "chevron.up.chevron.down")
.font(.system(size: 8))
.opacity(0.7)
}
.foregroundColor(.white)
.padding(.horizontal, 8)
.padding(.vertical, 4)
.background(Color.providerColor(provider))
.cornerRadius(4)
}
.menuStyle(.borderlessButton)
.fixedSize()
.help("Switch provider")
}
private var modelButton: some View {
Button(action: onModelSelect) {
if let model = model {
HStack(spacing: 6) {
Text(model.name)
.font(.system(size: settings.guiTextSize, weight: .medium))
.foregroundColor(.oaiPrimary)
HStack(spacing: 3) {
if model.capabilities.vision {
Image(systemName: "eye").font(.system(size: 9)).foregroundColor(.oaiSecondary)
}
if model.capabilities.tools {
Image(systemName: "wrench").font(.system(size: 9)).foregroundColor(.oaiSecondary)
}
if model.capabilities.online {
Image(systemName: "globe").font(.system(size: 9)).foregroundColor(.oaiSecondary)
}
if model.capabilities.imageGeneration {
Image(systemName: "paintbrush").font(.system(size: 9)).foregroundColor(.oaiSecondary)
}
}
Image(systemName: "chevron.down").font(.caption2).foregroundColor(.oaiSecondary)
}
} else {
HStack(spacing: 4) {
Text("No model selected")
.font(.system(size: settings.guiTextSize))
.foregroundColor(.oaiSecondary)
Image(systemName: "chevron.down").font(.caption2).foregroundColor(.oaiSecondary)
}
}
}
.buttonStyle(.plain)
.help("Select model")
}
@ViewBuilder
private var starButton: some View {
if let model = model {
let isFav = settings.favoriteModelIds.contains(model.id)
Button(action: { settings.toggleFavoriteModel(model.id) }) {
Image(systemName: isFav ? "star.fill" : "star")
.font(.system(size: settings.guiTextSize - 3))
.foregroundColor(isFav ? .yellow : .oaiSecondary)
}
.buttonStyle(.plain)
.help(isFav ? "Remove from favorites" : "Add to favorites")
}
}
}
// MARK: - Status Pills (used by SidebarView)