fix: unify sidebar interactions and window limits

This commit is contained in:
Kaede Akino committed 2026-08-27 05:36:37 +08:00
1 parent d10816165f
commit 0250a384d1
5 files changed
+142 -68

No files matched your search

+2 -2
View File
@@ -39,7 +39,7 @@ struct RootView: View {
}
.overlay { ExternalAuthWindowPresenter() }
.frame(
minWidth: Metrics.Window.detailMin,
minWidth: Metrics.Window.minWidth,
maxWidth: .infinity,
minHeight: Metrics.Window.minHeight,
maxHeight: .infinity
@@ -136,7 +136,7 @@ struct RootView: View {
.frame(
minWidth: Metrics.Window.detailMin,
maxWidth: .infinity,
minHeight: Metrics.Window.minHeight,
minHeight: Metrics.Window.detailMinHeight,
maxHeight: .infinity
)
.onGeometryChange(for: CGFloat.self) {
+2 -2
View File
@@ -70,8 +70,8 @@ struct TermindApp: App {
#endif
}
.defaultSize(width: Metrics.Window.defaultWidth, height: Metrics.Window.defaultHeight)
// Lets the window shrink to what the columns actually need, instead of
// AppKit's default of never going below the size it launched at.
// RootView publishes the calculated two-column floor. Active optional
// panes may raise it, but the window can never shrink below the base UI.
.windowResizability(.contentMinSize)
.commands { TermindCommands(app: app) }
+19 -10
View File
@@ -30,6 +30,11 @@ nonisolated enum Metrics {
/// Color stripe on the leading edge of a host row.
static let stripeWidth: CGFloat = 2.5
static let statusDot: CGFloat = 7
/// Vault and Assistant navigation use one comfortable target so every
/// destination, chat, archive, and new-chat action keeps a steady rhythm.
static let navigationRowHeight: CGFloat = 36
/// Shared content inset for the leading glyph and trailing accessory.
static let navigationRowInset: CGFloat = 8
/// The fixed column every sidebar row's leading glyph sits in.
///
/// Fixed rather than left to `Label`, whose icon takes the symbol's own
@@ -260,17 +265,21 @@ nonisolated enum Metrics {
}
enum Window {
/// The detail column's own floor, applied to the detail `VStack` itself.
///
/// Deliberately *not* summed into a window-level `minWidth`: wrapping a
/// `NavigationSplitView` in a `.frame` takes the width negotiation away
/// from the split view, which then lays out at the sum of its columns'
/// ideal widths and centres that inside the window — clipping the
/// sidebar on the left. The window
/// minimum comes from `.windowResizability(.contentMinSize)` instead,
/// which reads these three floors through the view tree.
/// The detail column's own floors. The window-level values below add
/// the chrome that sits beside or above it; keeping the two concepts
/// separate prevents accidentally counting the tab strip twice.
static let detailMin: CGFloat = 320
static let minHeight: CGFloat = 440
static let detailMinHeight: CGFloat = 440
/// The narrowest permanent two-column layout is Vault: one minimum
/// sidebar, the native split-view divider, and Vault's main surface.
/// Optional Vault work details keep contributing their own active
/// minimum through `.contentMinSize` when one is presented.
static let minWidth: CGFloat = Metrics.Sidebar.min + 1 + Metrics.VaultContent.min
/// Root content includes the pinned tab strip and its divider above the
/// detail floor. The macOS title bar is outside this measurement.
static let minHeight: CGFloat = Metrics.TabBar.height + 1 + detailMinHeight
static let defaultWidth: CGFloat = 1180
static let defaultHeight: CGFloat = 740
}
+74 -50
View File
@@ -14,7 +14,11 @@ struct AssistantSidebarView: View {
window.openAssistant()
} label: {
Label("New Chat", systemImage: "plus.bubble")
.frame(maxWidth: .infinity, alignment: .leading)
.frame(
maxWidth: .infinity,
minHeight: Metrics.Sidebar.navigationRowHeight,
alignment: .leading
)
.contentShape(.rect)
}
.buttonStyle(.plain)
@@ -23,7 +27,10 @@ struct AssistantSidebarView: View {
window.showAssistantArchive()
} label: {
Image(systemName: "archivebox")
.frame(width: 28, height: 24)
.frame(
width: Metrics.Sidebar.navigationRowHeight,
height: Metrics.Sidebar.navigationRowHeight
)
.background(
window.isAssistantArchivePresented
? Color.accentColor.opacity(0.16) : Color.clear,
@@ -34,7 +41,8 @@ struct AssistantSidebarView: View {
.help(Text("Archived"))
.accessibilityLabel(Text("Archived"))
}
.padding(Metrics.Space.m)
.padding(.horizontal, Metrics.Space.m)
.padding(.vertical, Metrics.Space.s)
}
}
@@ -214,43 +222,52 @@ private struct AssistantConversationList: View {
}
private var activeList: some View {
List {
if !window.assistantLibrary.pinnedConversations.isEmpty {
Section("Pinned") {
ScrollView {
LazyVStack(alignment: .leading, spacing: 0) {
if !window.assistantLibrary.pinnedConversations.isEmpty {
sectionTitle("Pinned")
rows(window.assistantLibrary.pinnedConversations)
}
}
if !window.assistantLibrary.activeConversations.isEmpty {
Section("Chats") {
if !window.assistantLibrary.activeConversations.isEmpty {
sectionTitle("Chats")
rows(window.assistantLibrary.activeConversations)
}
}
}
.listStyle(.sidebar)
.background(Color.clear)
}
private var archivedList: some View {
ScrollView {
LazyVStack(spacing: Metrics.Space.xs) {
rows(window.assistantLibrary.archivedConversations, isArchiveManagement: true)
LazyVStack(spacing: 0) {
rows(window.assistantLibrary.archivedConversations)
}
.frame(maxWidth: archiveMaxWidth ?? .infinity)
.frame(maxWidth: .infinity, alignment: .topLeading)
.padding(.horizontal, Metrics.Space.xl)
.padding(
.horizontal,
archiveMaxWidth == nil ? 0 : Metrics.Space.xl
)
.padding(.vertical, Metrics.Space.m)
}
.background(Color.clear)
}
private func sectionTitle(_ title: LocalizedStringKey) -> some View {
Text(title)
.font(.caption)
.foregroundStyle(.secondary)
.padding(.horizontal, Metrics.Sidebar.navigationRowInset)
.padding(.top, Metrics.Space.m)
.padding(.bottom, Metrics.Space.xs)
}
@ViewBuilder
private func rows(
_ conversations: [ConversationModel], isArchiveManagement: Bool = false
) -> some View {
private func rows(_ conversations: [ConversationModel]) -> some View {
ForEach(conversations) { conversation in
AssistantConversationRow(
conversation: conversation,
isSelected: selectedID == conversation.id,
isArchiveManagement: isArchiveManagement,
onOpen: { onOpen(conversation.id) },
onDelete: { deleteTarget = conversation }
)
@@ -318,7 +335,6 @@ private struct AssistantArchiveView: View {
private struct AssistantConversationRow: View {
let conversation: ConversationModel
let isSelected: Bool
var isArchiveManagement = false
let onOpen: () -> Void
let onDelete: () -> Void
@@ -329,6 +345,46 @@ private struct AssistantConversationRow: View {
@FocusState private var titleFocused: Bool
var body: some View {
ZStack {
Rectangle()
.fill(
isSelected
? Color.accentColor.opacity(0.16)
: hovering
? Color.primary.opacity(0.055)
: Color.clear
)
rowContent
.padding(.horizontal, Metrics.Sidebar.navigationRowInset)
}
.frame(maxWidth: .infinity, alignment: .leading)
.frame(height: Metrics.Sidebar.navigationRowHeight)
.contentShape(.rect)
.onTapGesture { if !renaming { onOpen() } }
.onHover { hovering = $0 }
.focusable(false)
.focusEffectDisabled()
.accessibilityAddTraits(isSelected ? .isSelected : [])
.overlay {
ConversationContextMenuHost(
isArchived: conversation.isArchived,
isPinned: conversation.isPinned,
managementDisabled: conversation.isStreaming || conversation.isCompacting,
onTogglePin: { window.toggleAssistantPin(conversation.id) },
onRename: beginRename,
onToggleArchive: {
window.setAssistantArchived(
conversation.id, archived: !conversation.isArchived
)
},
onDelete: onDelete
)
.accessibilityHidden(true)
}
}
private var rowContent: some View {
HStack(spacing: Metrics.Space.s) {
Image(systemName: conversation.isArchived ? "archivebox" : "sparkles")
.foregroundStyle(.tint)
@@ -355,38 +411,6 @@ private struct AssistantConversationRow: View {
.help(Text(conversation.isArchived ? "Unarchive" : "Archive"))
}
}
.frame(maxWidth: .infinity, alignment: .leading)
.padding(.horizontal, isArchiveManagement ? Metrics.Space.l : Metrics.Space.s)
.padding(.vertical, isArchiveManagement ? Metrics.Space.m : Metrics.Space.xs)
.background(rowFill, in: .rect(cornerRadius: Metrics.Radius.medium))
.contentShape(.rect(cornerRadius: Metrics.Radius.medium))
.onTapGesture { if !renaming { onOpen() } }
.onHover { hovering = $0 }
.focusable(false)
.focusEffectDisabled()
.listRowBackground(Color.clear)
.overlay {
ConversationContextMenuHost(
isArchived: conversation.isArchived,
isPinned: conversation.isPinned,
managementDisabled: conversation.isStreaming || conversation.isCompacting,
onTogglePin: { window.toggleAssistantPin(conversation.id) },
onRename: beginRename,
onToggleArchive: {
window.setAssistantArchived(
conversation.id, archived: !conversation.isArchived
)
},
onDelete: onDelete
)
.accessibilityHidden(true)
}
}
private var rowFill: Color {
if isSelected { return Color.accentColor.opacity(0.14) }
if hovering { return Color.primary.opacity(0.055) }
return .clear
}
private func beginRename() {
+45 -4
View File
@@ -6,6 +6,8 @@ struct VaultSidebarView: View {
@Environment(WindowModel.self) private var window
@AppStorage(SettingsKey.compactSidebar) private var compactSidebar = false
@AppStorage(SettingsKey.showRecents) private var showRecents = true
@State private var hoveredPage: VaultPage?
@State private var hoveredRecentHostID: Host.ID?
var body: some View {
List {
@@ -29,7 +31,9 @@ struct VaultSidebarView: View {
}
private func row(_ page: VaultPage, count: Int = 0) -> some View {
Button {
let isSelected = window.vaultDestination.page == page
return Button {
window.openPage(page)
} label: {
HStack(spacing: Metrics.Space.s) {
@@ -42,14 +46,18 @@ struct VaultSidebarView: View {
.foregroundStyle(.tertiary)
}
}
.frame(maxWidth: .infinity, alignment: .leading)
.frame(height: Metrics.Sidebar.navigationRowHeight)
.padding(.horizontal, Metrics.Sidebar.navigationRowInset)
.contentShape(.rect)
}
.buttonStyle(.plain)
.onHover { setPageHover($0, page: page) }
.accessibilityAddTraits(isSelected ? .isSelected : [])
.listRowBackground(
window.vaultDestination.page == page
? Color.accentColor.opacity(0.16)
: Color.clear
rowFill(isSelected: isSelected, isHovering: hoveredPage == page)
)
.listRowInsets(EdgeInsets())
}
private var recentHosts: [Host] {
@@ -76,9 +84,20 @@ struct VaultSidebarView: View {
}
Spacer(minLength: 0)
}
.frame(maxWidth: .infinity, alignment: .leading)
.frame(minHeight: Metrics.Sidebar.navigationRowHeight)
.padding(.horizontal, Metrics.Sidebar.navigationRowInset)
.contentShape(.rect)
}
.buttonStyle(.plain)
.onHover { setRecentHover($0, hostID: host.id) }
.listRowBackground(
rowFill(
isSelected: false,
isHovering: hoveredRecentHostID == host.id
)
)
.listRowInsets(EdgeInsets())
.contextMenu {
HostContextMenu(host: host)
}
@@ -86,6 +105,28 @@ struct VaultSidebarView: View {
.accessibilityHint("Quick Connect with SSH")
}
private func rowFill(isSelected: Bool, isHovering: Bool) -> Color {
if isSelected { return Color.accentColor.opacity(0.16) }
if isHovering { return Color.primary.opacity(0.055) }
return .clear
}
private func setPageHover(_ hovering: Bool, page: VaultPage) {
if hovering {
hoveredPage = page
} else if hoveredPage == page {
hoveredPage = nil
}
}
private func setRecentHover(_ hovering: Bool, hostID: Host.ID) {
if hovering {
hoveredRecentHostID = hostID
} else if hoveredRecentHostID == hostID {
hoveredRecentHostID = nil
}
}
}
/// The first pinned tab's main content. Its navigator is mounted by the