From 0250a384d151f4555425dd5d97f7ec671ece2b68 Mon Sep 17 00:00:00 2001 From: Kaede Akino Date: Thu, 27 Aug 2026 05:36:37 +0800 Subject: [PATCH] fix: unify sidebar interactions and window limits --- Termind/App/RootView.swift | 4 +- Termind/App/TermindApp.swift | 4 +- Termind/Design/DesignTokens.swift | 29 ++-- .../Features/Assistant/AssistantView.swift | 124 +++++++++++------- Termind/Features/Vault/VaultView.swift | 49 ++++++- 5 files changed, 142 insertions(+), 68 deletions(-) diff --git a/Termind/App/RootView.swift b/Termind/App/RootView.swift index 39147294..b9abcc0d 100644 --- a/Termind/App/RootView.swift +++ b/Termind/App/RootView.swift @@ -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) { diff --git a/Termind/App/TermindApp.swift b/Termind/App/TermindApp.swift index 59663d91..05b79e78 100644 --- a/Termind/App/TermindApp.swift +++ b/Termind/App/TermindApp.swift @@ -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) } diff --git a/Termind/Design/DesignTokens.swift b/Termind/Design/DesignTokens.swift index 4d89be48..bbc9c3ff 100644 --- a/Termind/Design/DesignTokens.swift +++ b/Termind/Design/DesignTokens.swift @@ -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 } diff --git a/Termind/Features/Assistant/AssistantView.swift b/Termind/Features/Assistant/AssistantView.swift index 869582c2..0c36293f 100644 --- a/Termind/Features/Assistant/AssistantView.swift +++ b/Termind/Features/Assistant/AssistantView.swift @@ -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() { diff --git a/Termind/Features/Vault/VaultView.swift b/Termind/Features/Vault/VaultView.swift index 9465508d..e790cb38 100644 --- a/Termind/Features/Vault/VaultView.swift +++ b/Termind/Features/Vault/VaultView.swift @@ -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